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
- Normalize the name to lowercase, replace runs of non-alphanum with a single dash, strip leading/trailing dashes.
- Pick a name like 'hn-frontpage' that matches the pattern exactly.
- 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
- Always slugify external input (page titles, agent output) before passing to validateSkillName.
- Lowercase first, then replace non-alphanum runs with single dashes, then trim dashes.
- Test the slugify step against realistic inputs in your wrapper.
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
- Invalid file path in stageSkill: "${relPath}".
- Skill name is empty.
- stageSkill: files map is empty.
- commitSkill: staged dir "${opts.stagedDir}" is a symlink — r
- commitSkill: staged path "${opts.stagedDir}" is not a direct
AI-assisted analysis of garrytan/gstack@94993f7401 (2026-08-12).
Data as JSON: /api/errors/7e7fad6a9b607d3a.
Report an issue: GitHub.