garrytan/gstack · error · Error
commitSkill: staged path "${opts.stagedDir}" is not a direct
Error message
commitSkill: staged path "${opts.stagedDir}" is not a directory. What it means
Thrown by commitSkill when lstatSync succeeded, the path is not a symlink, but stagedStat.isDirectory() is false — the staged path resolves to a regular file (or a socket/device), not a directory. fs.renameSync would still move it, but the result would not be a usable skill directory, so commitSkill bails first.
Source
Thrown at browse/src/browser-skill-write.ts:140
const tiers = opts.tiers ?? defaultTierPaths();
const tierRoot = opts.tier === 'project' ? tiers.project : tiers.global;
if (!tierRoot) {
throw new Error(`commitSkill: tier "${opts.tier}" has no resolved path.`);
}
// Refuse to follow a symlinked staging dir — caller should hand us the path
// returned by stageSkill, which is always a real directory.
let stagedStat: fs.Stats;
try {
stagedStat = fs.lstatSync(opts.stagedDir);
} catch (err: any) {
throw new Error(`commitSkill: staged dir "${opts.stagedDir}" not accessible: ${err.code ?? err.message}`);
}
if (stagedStat.isSymbolicLink()) {
throw new Error(`commitSkill: staged dir "${opts.stagedDir}" is a symlink — refusing to commit.`);
}
if (!stagedStat.isDirectory()) {
throw new Error(`commitSkill: staged path "${opts.stagedDir}" is not a directory.`);
}
// Ensure the tier root exists, then resolve its real path so the final
// destination check defends against tierRoot itself being a symlink.
fs.mkdirSync(tierRoot, { recursive: true, mode: 0o755 });
const realTierRoot = fs.realpathSync(tierRoot);
const dest = path.join(realTierRoot, opts.name);
if (!isPathWithin(dest, realTierRoot)) {
// Should be impossible after validateSkillName, but defense in depth.
throw new Error(`commitSkill: destination "${dest}" escapes tier root.`);
}
// Refuse to clobber. Both regular dirs and symlinks count.
let destExists = false;
try {
fs.lstatSync(dest);
destExists = true;View on GitHub (pinned to 94993f7401)
Solutions
- Pass the directory path returned by stageSkill (ending in the skill name), not a file inside it.
- Verify with `ls -ld <stagedDir>` that it is a directory before calling commitSkill.
- If a file overwrote the dir, remove it and re-run stageSkill.
Example fix
// before
commitSkill({ name: 'foo', tier: 'project', stagedDir: '/path/foo/SKILL.md' });
// after
commitSkill({ name: 'foo', tier: 'project', stagedDir: '/path/foo' }); Defensive patterns
Strategy: validation
Validate before calling
import * as fs from 'fs';
function assertStagedIsDirectory(stagedDir: string): void {
const stat = fs.lstatSync(stagedDir);
if (!stat.isDirectory()) {
throw new Error(`Staged path "${stagedDir}" is not a directory (got ${`+${stat.mode.toString(8)}`}).`);
}
}
// before commitSkill:
assertStagedIsDirectory(opts.stagedDir); Prevention
- Pass the directory path ending in the skill name, not a file inside it.
- Verify with `ls -ld <stagedDir>` before commit.
- Do not let another process overwrite the dir entry with a file mid-flow.
When it happens
Trigger: A caller passed a path to a file (e.g. the SKILL.md path rather than the skill dir); stagedDir was a directory at stage time but a file got written over it; tests that constructed stagedDir pointing at a file.
Common situations: Caller joined one path segment too many (e.g. path.join(stagingRoot, name, 'SKILL.md') instead of path.join(stagingRoot, name)); another process overwrote the dir entry with a file; misconfigured /skillify driver that points at the wrong level of the staging tree.
Related errors
- Invalid file path in stageSkill: "${relPath}".
- Skill "${name}" not found in any tier.
- Skill "${name}" not found.
- Skill "${name}" has no script.test.ts at ${testFile}
- Skill "${opts.skill.name}" missing script.ts at ${scriptPath
AI-assisted analysis of garrytan/gstack@94993f7401 (2026-08-12).
Data as JSON: /api/errors/8430e459586cbbc0.
Report an issue: GitHub.