garrytan/gstack · error · Error

commitSkill: tier "${opts.tier}" has no resolved path.

Error message

commitSkill: tier "${opts.tier}" has no resolved path.

What it means

Thrown by commitSkill when the resolved tier root is null. tiers.project is null whenever detectProjectRoot failed (no git repo, git not on PATH, or projectRoot override not supplied) — so committing to the 'project' tier in a non-project context fails here. The global tier is always a string, so this error is only reachable for tier==='project'.

Source

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

 * Atomically move the staged skill into its final tier path. Refuses to
 * clobber an existing skill at the same path — the agent's approval gate
 * MUST surface name collisions before calling this.
 *
 * Returns the absolute path of the committed skill dir.
 *
 * Throws when:
 *   - tier path is unresolved (project tier with no project root)
 *   - destination already exists
 *   - staged dir is a symlink (refuses to follow)
 *   - resolved destination escapes the tier root (defense in depth)
 */
export function commitSkill(opts: CommitSkillOptions): string {
  validateSkillName(opts.name);

  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

View on GitHub (pinned to 94993f7401)

Solutions

  1. Run from inside the target git project so detectProjectRoot resolves.
  2. Commit to the global tier instead: pass tier: 'global' (places the skill in ~/.gstack/browser-skills/).
  3. Pass a synthetic tiers override with project set if calling programmatically in a non-project context.
  4. Verify `git rev-parse --show-toplevel` succeeds from the working directory.

Example fix

// before
commitSkill({ name: 'foo', tier: 'project', stagedDir });  // outside a git repo
// after
commitSkill({ name: 'foo', tier: 'global', stagedDir });   // installs for the current user
Defensive patterns

Strategy: validation

Validate before calling

import { defaultTierPaths } from './browser-skills';

function assertTierResolvable(tier: 'project' | 'global', tiers = defaultTierPaths()): void {
  const root = tier === 'project' ? tiers.project : tiers.global;
  if (!root) {
    throw new Error(`Tier "${tier}" unresolved. Run inside a git repo or use tier: 'global'.`);
  }
}
// before commitSkill:
assertTierResolvable(opts.tier);

Prevention

When it happens

Trigger: commitSkill({name, tier: 'project', stagedDir}) called outside a git repository; called in a context where git rev-parse failed (git missing, timeout, non-git dir); tests that didn't pass a synthetic tiers.project. Passing tier: 'global' never hits this because tiers.global is always resolved.

Common situations: Running /skillify from /tmp or a non-git working directory; CI runner that checks out code without .git; sandboxed environment without git installed; HOME override that broke path resolution in tests.

Related errors


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