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

  1. Pass the directory path returned by stageSkill (ending in the skill name), not a file inside it.
  2. Verify with `ls -ld <stagedDir>` that it is a directory before calling commitSkill.
  3. 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

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


AI-assisted analysis of garrytan/gstack@94993f7401 (2026-08-12). Data as JSON: /api/errors/8430e459586cbbc0. Report an issue: GitHub.