garrytan/gstack · error · Error

Invalid skill name "${name}". Must be lowercase letters/digi

Error message

Invalid skill name "${name}". Must be lowercase letters/digits/dashes, start with a letter, no leading/trailing/consecutive dashes.

What it means

Thrown by validateSkillName when the name is non-empty, ≤64 chars, but does not match /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/. The pattern enforces: lowercase only, must start with a letter, digits allowed after the first letter, single dashes between alphanumeric groups, no leading/trailing/consecutive dashes. This bound is load-bearing — it is what makes the leaf directory name safe to join onto a tier root without escaping.

Source

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

import { mkdirSecure } from './file-permissions';
import { isPathWithin } from './platform';
import type { TierPaths } from './browser-skills';
import { defaultTierPaths } from './browser-skills';

// ─── Naming validation ──────────────────────────────────────────

/**
 * Skill names must be safe directory names: lowercase letters, digits, dashes.
 * Starts with a letter, no consecutive dashes, no trailing dash, ≤64 chars.
 * Rejects '..', leading dots, slashes, anything that could escape the tier dir.
 */
const SKILL_NAME_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;

export function validateSkillName(name: string): void {
  if (!name) throw new Error('Skill name is empty.');
  if (name.length > 64) throw new Error(`Skill name too long (${name.length} > 64).`);
  if (!SKILL_NAME_PATTERN.test(name)) {
    throw new Error(
      `Invalid skill name "${name}". Must be lowercase letters/digits/dashes, ` +
      `start with a letter, no leading/trailing/consecutive dashes.`,
    );
  }
}

// ─── Staging ────────────────────────────────────────────────────

export interface StageSkillOptions {
  name: string;
  /** Map of relative path → contents. Path may contain '/' for nested dirs. */
  files: Map<string, string | Buffer>;
  /** Optional override (tests pass synthetic spawn ids). */
  spawnId?: string;
  /** Optional override (tests pass a fake tmp root). */
  tmpRoot?: string;
}

View on GitHub (pinned to 94993f7401)

Solutions

  1. Normalize the name to lowercase, replace runs of non-alphanum with a single dash, strip leading/trailing dashes.
  2. Pick a name like 'hn-frontpage' that matches the pattern exactly.
  3. If the name comes from external input, run a slugify step before validateSkillName.

Example fix

// before
validateSkillName('HN Frontpage!');
// after
function slugify(s: string): string {
  return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
}
validateSkillName(slugify('HN Frontpage!')); // 'hn-frontpage'
Defensive patterns

Strategy: validation

Validate before calling

const SKILL_NAME_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;

function slugifySkillName(raw: string): string {
  const slug = raw.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
  if (!slug || !SKILL_NAME_PATTERN.test(slug)) {
    throw new Error(`Cannot derive valid skill name from "${raw}"`);
  }
  return slug;
}
// use before validateSkillName:
const name = slugifySkillName(rawInput);

Type guard

const isValidSkillName = (x: unknown): x is string =>
  typeof x === 'string' && /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(x) && x.length <= 64;

Prevention

When it happens

Trigger: validateSkillName with values like 'HN_Frontpage' (uppercase + underscore), '1st-skill' (leading digit), '-foo' (leading dash), 'foo--bar' (consecutive dashes), 'foo-' (trailing dash), 'foo.bar' (dot), 'foo/bar' (slash), 'foo bar' (space), or a name with non-ASCII characters.

Common situations: Agent-derived name from a page title preserved casing or spaces; user typed a CamelCase name; templating produced a leading dash; name was sanitized with a different ruleset (underscores instead of dashes); Unicode host-derived name.

Related errors


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