garrytan/gstack · error · Error

commitSkill: a skill named "${opts.name}" already exists at

Error message

commitSkill: a skill named "${opts.name}" already exists at ${dest}. Pick a different name or remove the existing skill first ($B skill rm ${opts.name}${opts.tier === 'global' ? ' --global' : ''}).

What it means

Thrown by commitSkill when fs.lstatSync(dest) succeeded (i.e. the destination already exists as a regular dir, file, or symlink). commitSkill refuses to clobber because the agent approval gate is supposed to surface name collisions before this point. The error message includes the exact conflicting path and the corresponding `$B skill rm` command (with --global if the target tier is global) so the user can resolve it directly.

Source

Thrown at browse/src/browser-skill-write.ts:163

  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;
  } catch (err: any) {
    if (err.code !== 'ENOENT') throw err;
  }
  if (destExists) {
    throw new Error(
      `commitSkill: a skill named "${opts.name}" already exists at ${dest}. ` +
      `Pick a different name or remove the existing skill first ` +
      `($B skill rm ${opts.name}${opts.tier === 'global' ? ' --global' : ''}).`,
    );
  }

  fs.renameSync(opts.stagedDir, dest);
  return dest;
}

// ─── Discard (cleanup on failure or reject) ─────────────────────

/**
 * Remove the staged skill directory and its per-spawn wrapper. Called on
 * test failure (step 8 of /skillify) or approval rejection (step 9).
 *
 * Idempotent: missing dirs are not an error. Best-effort: failures are
 * swallowed (cleanup is fire-and-forget, not load-bearing).

View on GitHub (pinned to 94993f7401)

Solutions

  1. Remove the existing skill first: `$B skill rm <name>` (project) or `$B skill rm <name> --global`.
  2. Pick a different name for the new skill.
  3. If the existing entry is a stale symlink, remove it directly: `rm <dest>` (it is not a real skill so rm may not find it).
  4. Confirm the prior skill was tombstoned: `$B skill list` should no longer show it.

Example fix

// before: ~/.gstack/browser-skills/foo already exists
commitSkill({ name: 'foo', tier: 'global', stagedDir });
// after
// step 1: $B skill rm foo --global
// step 2: commitSkill({ name: 'foo', tier: 'global', stagedDir });
Defensive patterns

Strategy: validation

Validate before calling

import * as fs from 'fs';
import * as path from 'path';
import { defaultTierPaths } from './browser-skills';

function assertDestFree(name: string, tier: 'project' | 'global', tiers = defaultTierPaths()): void {
  const root = tier === 'project' ? tiers.project : tiers.global;
  if (!root) throw new Error(`Tier "${tier}" unresolved.`);
  const dest = path.join(root, name);
  try {
    fs.lstatSync(dest);
    throw new Error(`"${name}" already exists at ${dest}. Run: $B skill rm ${name}${tier === 'global' ? ' --global' : ''}`);
  } catch (err: any) {
    if (err.code === 'ENOENT') return; // free
    throw err;
  }
}
// before commitSkill:
assertDestFree(opts.name, opts.tier);

Prevention

When it happens

Trigger: commitSkill for a name that already exists in the target tier; a prior /skillify installed the same name; a tombstone was restored to the same tier; a hand-authored skill already occupies that dir; a symlink at the dest path (lstatSync does not follow, so a symlink counts as 'exists').

Common situations: Re-running /skillify with the same name without first removing the prior skill; trying to install a project-tier skill that shadows a global-tier one being committed to global; collision after a tombstone restore; tierRoot contains a leftover directory from a partial previous commit.

Related errors


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