diff --git a/browse/src/browser-skills.ts b/browse/src/browser-skills.ts new file mode 100644 index 00000000..5bf7241b --- /dev/null +++ b/browse/src/browser-skills.ts @@ -0,0 +1,420 @@ +/** + * browser-skills — storage helpers for per-task Playwright scripts. + * + * A browser-skill is a directory containing SKILL.md (frontmatter + prose), + * script.ts (deterministic Playwright-via-browse-client script), an _lib/ + * with a copy of the SDK, fixtures/ for tests, and script.test.ts. + * + * Three tiers, walked in order project > global > bundled (first-wins): + * project: /.gstack/browser-skills// + * global: ~/.gstack/browser-skills// + * bundled: /browser-skills// (read-only, ships with gstack) + * + * No INDEX.json. `listBrowserSkills()` walks the three directories every call + * (~5-10ms for 50 skills, invisible). Eliminates a whole class of "index + * drifted from disk" bugs. + * + * Tombstones move a skill to `/.tombstones/-/` so the user + * can recover. `$B skill list` ignores tombstoned directories. + * + * Zero side effects on import. Safe to import from tests. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import * as cp from 'child_process'; + +// ─── Types ────────────────────────────────────────────────────── + +export type SkillTier = 'project' | 'global' | 'bundled'; + +/** Required + optional fields from a browser-skill SKILL.md frontmatter. */ +export interface SkillFrontmatter { + /** Skill name; must match the directory name. */ + name: string; + /** One-line description (optional but recommended). */ + description?: string; + /** Primary hostname this skill targets, e.g. "news.ycombinator.com". */ + host: string; + /** Trigger phrases the resolver matches against ("scrape hn frontpage"). */ + triggers: string[]; + /** + * Args the script accepts (passed via `$B skill run --arg key=value`). + * Phase 1 keeps this loose: each arg is just a name and optional description. + */ + args: SkillArg[]; + /** + * Trust flag. true = full env passed to spawn (human-authored, audited). + * false (default) = scrubbed env, locked cwd. Orthogonal to scoped-token + * capabilities: untrusted skills still get a read+write daemon token. + */ + trusted: boolean; + /** Optional semver-ish version string for skill upgrades. */ + version?: string; + /** Whether the skill was hand-written or generated by the skillify flow. */ + source?: 'human' | 'agent'; +} + +export interface SkillArg { + name: string; + description?: string; +} + +export interface BrowserSkill { + name: string; + tier: SkillTier; + /** Absolute path to the skill directory. */ + dir: string; + frontmatter: SkillFrontmatter; + /** SKILL.md prose body (everything after the frontmatter block). */ + bodyMd: string; +} + +export interface TierPaths { + /** May be null in non-project contexts (e.g. tests, standalone runs). */ + project: string | null; + global: string; + bundled: string; +} + +// ─── Tier resolution ──────────────────────────────────────────── + +/** + * Resolve the three tier directories from runtime context. + * Project tier requires git or a project hint; returns null when neither resolves. + */ +export function defaultTierPaths(opts: { projectRoot?: string; home?: string; bundledRoot?: string } = {}): TierPaths { + const home = opts.home ?? os.homedir(); + const projectRoot = opts.projectRoot ?? detectProjectRoot(); + const bundledRoot = opts.bundledRoot ?? detectBundledRoot(); + + return { + project: projectRoot ? path.join(projectRoot, '.gstack', 'browser-skills') : null, + global: path.join(home, '.gstack', 'browser-skills'), + bundled: path.join(bundledRoot, 'browser-skills'), + }; +} + +function detectProjectRoot(): string | null { + try { + const proc = cp.spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf-8', timeout: 2000 }); + if (proc.status === 0) { + const out = proc.stdout.trim(); + return out || null; + } + } catch {} + return null; +} + +function detectBundledRoot(): string { + // The browse binary lives at /browse/dist/browse. + // The bundled browser-skills/ dir is a sibling of browse/ (i.e. /browser-skills/). + // For dev/source runs, process.execPath is bun itself — fall back to the source-tree + // directory two levels up from this file. + try { + const exec = process.execPath; + if (exec && /\/browse\/dist\/browse$/.test(exec)) { + return path.resolve(path.dirname(exec), '..', '..'); + } + } catch {} + // Source/dev fallback: walk up from this file's dir to a directory that has both browse/ and browser-skills/. + // browse/src/browser-skills.ts → ../../ (the gstack root). + return path.resolve(__dirname, '..', '..'); +} + +// ─── Frontmatter parsing ──────────────────────────────────────── + +/** + * Parse a SKILL.md into { frontmatter, bodyMd }. Throws if the file is + * missing required fields (host, triggers, args). + */ +export function parseSkillFile(content: string, opts: { skillName?: string } = {}): { frontmatter: SkillFrontmatter; bodyMd: string } { + if (!content.startsWith('---\n')) { + throw new Error('SKILL.md missing frontmatter block (expected starting "---\\n")'); + } + const fmEnd = content.indexOf('\n---', 4); + if (fmEnd === -1) { + throw new Error('SKILL.md frontmatter block not terminated (expected "\\n---")'); + } + const fmText = content.slice(4, fmEnd); + const bodyMd = content.slice(fmEnd + 4).replace(/^\n+/, ''); + const fm = parseFrontmatterFields(fmText); + + // Validate required fields. + const errors: string[] = []; + const name = fm.name ?? opts.skillName ?? ''; + if (!name) errors.push('missing required field: name (or skillName hint)'); + if (!fm.host) errors.push('missing required field: host'); + // triggers and args may be omitted — empty list is valid. + if (errors.length > 0) { + throw new Error(`SKILL.md validation failed: ${errors.join('; ')}`); + } + + const frontmatter: SkillFrontmatter = { + name, + description: fm.description, + host: fm.host as string, + triggers: Array.isArray(fm.triggers) ? fm.triggers : [], + args: Array.isArray(fm.args) ? fm.args : [], + trusted: fm.trusted === true, + version: typeof fm.version === 'string' ? fm.version : undefined, + source: fm.source === 'agent' || fm.source === 'human' ? fm.source : undefined, + }; + + return { frontmatter, bodyMd }; +} + +interface RawFrontmatter { + name?: string; + description?: string; + host?: string; + triggers?: string[]; + args?: SkillArg[]; + trusted?: boolean; + version?: string; + source?: string; +} + +/** + * Tiny frontmatter parser tuned for the browser-skill subset: + * - simple key: value scalars + * - YAML list: `key:\n - item1\n - item2` + * - args list of mappings: `args:\n - name: foo\n description: bar` + * + * Quoting: a value wrapped in "..." or '...' is taken literally (handles colons). + * Anything more exotic should use a real YAML library — not in Phase 1 scope. + */ +function parseFrontmatterFields(fm: string): RawFrontmatter { + const result: RawFrontmatter = {}; + const lines = fm.split('\n'); + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + + // Skip blank lines and comments + if (!line.trim() || line.trim().startsWith('#')) { i++; continue; } + + // Top-level scalar: `key: value` + const scalar = line.match(/^([a-zA-Z_][a-zA-Z0-9_-]*):\s*(.*)$/); + if (scalar && !line.startsWith(' ')) { + const key = scalar[1]; + const rawVal = scalar[2]; + + // Empty value: list or mapping follows on next lines + if (!rawVal) { + // Peek to determine list vs unset + const nextNonBlank = findNextNonBlank(lines, i + 1); + if (nextNonBlank !== -1 && lines[nextNonBlank].match(/^\s+-\s/)) { + // List — collect items + if (key === 'args') { + const { items, consumed } = collectArgsList(lines, i + 1); + (result as any)[key] = items; + i += 1 + consumed; + } else { + const { items, consumed } = collectStringList(lines, i + 1); + (result as any)[key] = items; + i += 1 + consumed; + } + continue; + } + i++; + continue; + } + + // Inline list: `key: []` + if (rawVal === '[]') { + (result as any)[key] = []; + i++; + continue; + } + + // Inline scalar + (result as any)[key] = parseScalar(rawVal); + i++; + continue; + } + + i++; + } + + return result; +} + +function findNextNonBlank(lines: string[], from: number): number { + for (let i = from; i < lines.length; i++) { + if (lines[i].trim()) return i; + } + return -1; +} + +function collectStringList(lines: string[], from: number): { items: string[]; consumed: number } { + const items: string[] = []; + let i = from; + while (i < lines.length) { + const line = lines[i]; + if (!line.trim()) { i++; continue; } + const m = line.match(/^\s+-\s+(.*)$/); + if (!m) break; + items.push(stripQuotes(m[1])); + i++; + } + return { items, consumed: i - from }; +} + +function collectArgsList(lines: string[], from: number): { items: SkillArg[]; consumed: number } { + const items: SkillArg[] = []; + let i = from; + while (i < lines.length) { + const line = lines[i]; + if (!line.trim()) { i++; continue; } + // Item start: ` - name: foo` (with whatever indent) + const itemStart = line.match(/^(\s+)-\s+(.+?):\s*(.*)$/); + if (!itemStart) break; + const indent = itemStart[1] + ' '; // continuation lines get 2 more spaces + const arg: SkillArg = { name: '' }; + if (itemStart[2] === 'name') { + arg.name = stripQuotes(itemStart[3]); + } else if (itemStart[2] === 'description') { + arg.description = stripQuotes(itemStart[3]); + } + i++; + // Read continuation lines ` description: ...` + while (i < lines.length) { + const cont = lines[i]; + if (!cont.startsWith(indent) || !cont.trim()) break; + const kv = cont.match(/^\s+([a-zA-Z_][a-zA-Z0-9_-]*):\s*(.*)$/); + if (!kv) break; + if (kv[1] === 'name') arg.name = stripQuotes(kv[2]); + else if (kv[1] === 'description') arg.description = stripQuotes(kv[2]); + i++; + } + items.push(arg); + } + return { items, consumed: i - from }; +} + +function parseScalar(raw: string): string | boolean | number { + const v = raw.trim(); + if (v === 'true') return true; + if (v === 'false') return false; + if (/^-?\d+$/.test(v)) return parseInt(v, 10); + return stripQuotes(v); +} + +function stripQuotes(v: string): string { + const trimmed = v.trim(); + if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'"))) { + return trimmed.slice(1, -1); + } + return trimmed; +} + +// ─── Listing + reading ────────────────────────────────────────── + +/** + * Walk all three tiers and return every visible skill (tombstones excluded). + * Tier precedence: project > global > bundled. If the same skill name appears + * in multiple tiers, the entry from the highest-priority tier wins. + */ +export function listBrowserSkills(tiers?: TierPaths): BrowserSkill[] { + const t = tiers ?? defaultTierPaths(); + const seen = new Map(); + + // Walk in priority order: project first, so it wins over global/bundled. + const order: Array<{ tier: SkillTier; root: string | null }> = [ + { tier: 'project', root: t.project }, + { tier: 'global', root: t.global }, + { tier: 'bundled', root: t.bundled }, + ]; + + for (const { tier, root } of order) { + if (!root || !fs.existsSync(root)) continue; + let entries: string[]; + try { entries = fs.readdirSync(root); } catch { continue; } + for (const entry of entries) { + if (entry.startsWith('.') || entry === '.tombstones') continue; + if (seen.has(entry)) continue; // higher-priority tier already claimed this name + const dir = path.join(root, entry); + let stat: fs.Stats; + try { stat = fs.statSync(dir); } catch { continue; } + if (!stat.isDirectory()) continue; + + const skillFile = path.join(dir, 'SKILL.md'); + if (!fs.existsSync(skillFile)) continue; + + try { + const content = fs.readFileSync(skillFile, 'utf-8'); + const { frontmatter, bodyMd } = parseSkillFile(content, { skillName: entry }); + seen.set(entry, { name: entry, tier, dir, frontmatter, bodyMd }); + } catch { + // Malformed skill — skip silently. listBrowserSkills is best-effort; + // skill-validation tests catch these at build time. + continue; + } + } + } + + return Array.from(seen.values()).sort((a, b) => a.name.localeCompare(b.name)); +} + +/** + * Read a single skill by name (first-tier-wins). Returns null if not found + * in any tier. + */ +export function readBrowserSkill(name: string, tiers?: TierPaths): BrowserSkill | null { + const t = tiers ?? defaultTierPaths(); + const order: Array<{ tier: SkillTier; root: string | null }> = [ + { tier: 'project', root: t.project }, + { tier: 'global', root: t.global }, + { tier: 'bundled', root: t.bundled }, + ]; + + for (const { tier, root } of order) { + if (!root) continue; + const dir = path.join(root, name); + const skillFile = path.join(dir, 'SKILL.md'); + if (!fs.existsSync(skillFile)) continue; + + try { + const content = fs.readFileSync(skillFile, 'utf-8'); + const { frontmatter, bodyMd } = parseSkillFile(content, { skillName: name }); + return { name, tier, dir, frontmatter, bodyMd }; + } catch { + // Malformed — try next tier. + continue; + } + } + + return null; +} + +// ─── Tombstone (rm) ───────────────────────────────────────────── + +/** + * Move a user-tier skill (project or global) into the tier's .tombstones/ + * directory. Returns the new path. + * + * Cannot tombstone bundled skills — they ship with gstack and are read-only. + * To remove a bundled skill, override it with a global/project entry, or + * remove the file from the gstack source tree. + */ +export function tombstoneBrowserSkill(name: string, tier: 'project' | 'global', tiers?: TierPaths): string { + const t = tiers ?? defaultTierPaths(); + const root = tier === 'project' ? t.project : t.global; + if (!root) { + throw new Error(`tombstoneBrowserSkill: tier "${tier}" has no resolved path`); + } + const src = path.join(root, name); + if (!fs.existsSync(src)) { + throw new Error(`tombstoneBrowserSkill: skill "${name}" not found in tier "${tier}" at ${src}`); + } + const tombstoneDir = path.join(root, '.tombstones'); + fs.mkdirSync(tombstoneDir, { recursive: true }); + const ts = new Date().toISOString().replace(/[:.]/g, '-'); + const dst = path.join(tombstoneDir, `${name}-${ts}`); + fs.renameSync(src, dst); + return dst; +} diff --git a/browse/test/browser-skills-storage.test.ts b/browse/test/browser-skills-storage.test.ts new file mode 100644 index 00000000..ee9f16fe --- /dev/null +++ b/browse/test/browser-skills-storage.test.ts @@ -0,0 +1,283 @@ +/** + * browser-skills storage tests — covers the 3-tier walk, frontmatter parsing, + * tombstone semantics. Uses tmp dirs for hermetic isolation; never touches + * real ~/.gstack/ or the gstack install. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + parseSkillFile, + listBrowserSkills, + readBrowserSkill, + tombstoneBrowserSkill, + type TierPaths, +} from '../src/browser-skills'; + +let tmpRoot: string; +let tiers: TierPaths; + +beforeEach(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'browser-skills-test-')); + tiers = { + project: path.join(tmpRoot, 'project', '.gstack', 'browser-skills'), + global: path.join(tmpRoot, 'home', '.gstack', 'browser-skills'), + bundled: path.join(tmpRoot, 'gstack-install', 'browser-skills'), + }; + fs.mkdirSync(tiers.project!, { recursive: true }); + fs.mkdirSync(tiers.global, { recursive: true }); + fs.mkdirSync(tiers.bundled, { recursive: true }); +}); + +afterEach(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }); +}); + +function makeSkill(tierRoot: string, name: string, frontmatter: string, body: string = '\nBody.\n') { + const dir = path.join(tierRoot, name); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'SKILL.md'), `---\n${frontmatter}\n---\n${body}`); + return dir; +} + +describe('parseSkillFile', () => { + it('parses simple frontmatter scalars', () => { + const md = '---\nname: foo\nhost: example.com\ndescription: hello world\ntrusted: true\n---\nbody'; + const { frontmatter, bodyMd } = parseSkillFile(md); + expect(frontmatter.name).toBe('foo'); + expect(frontmatter.host).toBe('example.com'); + expect(frontmatter.description).toBe('hello world'); + expect(frontmatter.trusted).toBe(true); + expect(bodyMd).toBe('body'); + }); + + it('parses string lists', () => { + const md = `--- +name: foo +host: example.com +triggers: + - first trigger + - second trigger + - "with: colons" +--- +body`; + const { frontmatter } = parseSkillFile(md); + expect(frontmatter.triggers).toEqual(['first trigger', 'second trigger', 'with: colons']); + }); + + it('parses args list of mappings', () => { + const md = `--- +name: foo +host: example.com +args: + - name: keywords + description: search query + - name: limit + description: max results +---`; + const { frontmatter } = parseSkillFile(md); + expect(frontmatter.args).toEqual([ + { name: 'keywords', description: 'search query' }, + { name: 'limit', description: 'max results' }, + ]); + }); + + it('handles empty inline list', () => { + const md = '---\nname: foo\nhost: example.com\nargs: []\ntriggers: []\n---\n'; + const { frontmatter } = parseSkillFile(md); + expect(frontmatter.args).toEqual([]); + expect(frontmatter.triggers).toEqual([]); + }); + + it('defaults trusted to false', () => { + const md = '---\nname: foo\nhost: example.com\n---\n'; + const { frontmatter } = parseSkillFile(md); + expect(frontmatter.trusted).toBe(false); + }); + + it('throws when frontmatter is missing', () => { + expect(() => parseSkillFile('no frontmatter here')).toThrow(/missing frontmatter/); + }); + + it('throws when frontmatter terminator is missing', () => { + expect(() => parseSkillFile('---\nname: foo\nhost: bar\n')).toThrow(/not terminated/); + }); + + it('throws when host is missing', () => { + const md = '---\nname: foo\n---\nbody'; + expect(() => parseSkillFile(md)).toThrow(/missing required field: host/); + }); + + it('throws when name is absent and no skillName hint', () => { + const md = '---\nhost: x\n---\nbody'; + expect(() => parseSkillFile(md)).toThrow(/missing required field: name/); + }); + + it('uses skillName hint when frontmatter omits name', () => { + const md = '---\nhost: example.com\n---\nbody'; + const { frontmatter } = parseSkillFile(md, { skillName: 'derived-name' }); + expect(frontmatter.name).toBe('derived-name'); + }); + + it('parses source field as union', () => { + const human = parseSkillFile('---\nname: f\nhost: h\nsource: human\n---\n').frontmatter; + const agent = parseSkillFile('---\nname: f\nhost: h\nsource: agent\n---\n').frontmatter; + const bogus = parseSkillFile('---\nname: f\nhost: h\nsource: alien\n---\n').frontmatter; + expect(human.source).toBe('human'); + expect(agent.source).toBe('agent'); + expect(bogus.source).toBeUndefined(); + }); +}); + +describe('listBrowserSkills', () => { + it('returns empty when no tiers have skills', () => { + expect(listBrowserSkills(tiers)).toEqual([]); + }); + + it('returns bundled-tier skills', () => { + makeSkill(tiers.bundled, 'foo', 'name: foo\nhost: example.com'); + const skills = listBrowserSkills(tiers); + expect(skills).toHaveLength(1); + expect(skills[0].name).toBe('foo'); + expect(skills[0].tier).toBe('bundled'); + }); + + it('returns global-tier skills', () => { + makeSkill(tiers.global, 'bar', 'name: bar\nhost: example.com'); + const skills = listBrowserSkills(tiers); + expect(skills).toHaveLength(1); + expect(skills[0].tier).toBe('global'); + }); + + it('returns project-tier skills', () => { + makeSkill(tiers.project!, 'baz', 'name: baz\nhost: example.com'); + const skills = listBrowserSkills(tiers); + expect(skills).toHaveLength(1); + expect(skills[0].tier).toBe('project'); + }); + + it('global overrides bundled when same name', () => { + makeSkill(tiers.bundled, 'shared', 'name: shared\nhost: bundled.com'); + makeSkill(tiers.global, 'shared', 'name: shared\nhost: global.com'); + const skills = listBrowserSkills(tiers); + expect(skills).toHaveLength(1); + expect(skills[0].tier).toBe('global'); + expect(skills[0].frontmatter.host).toBe('global.com'); + }); + + it('project overrides global and bundled when same name', () => { + makeSkill(tiers.bundled, 'shared', 'name: shared\nhost: bundled.com'); + makeSkill(tiers.global, 'shared', 'name: shared\nhost: global.com'); + makeSkill(tiers.project!, 'shared', 'name: shared\nhost: project.com'); + const skills = listBrowserSkills(tiers); + expect(skills).toHaveLength(1); + expect(skills[0].tier).toBe('project'); + expect(skills[0].frontmatter.host).toBe('project.com'); + }); + + it('returns all unique skills across tiers, sorted alphabetically', () => { + makeSkill(tiers.bundled, 'zebra', 'name: zebra\nhost: x.com'); + makeSkill(tiers.global, 'apple', 'name: apple\nhost: x.com'); + makeSkill(tiers.project!, 'mango', 'name: mango\nhost: x.com'); + const skills = listBrowserSkills(tiers); + expect(skills.map(s => s.name)).toEqual(['apple', 'mango', 'zebra']); + expect(skills.map(s => s.tier)).toEqual(['global', 'project', 'bundled']); + }); + + it('skips entries without SKILL.md', () => { + fs.mkdirSync(path.join(tiers.bundled, 'no-skill-md')); + fs.writeFileSync(path.join(tiers.bundled, 'no-skill-md', 'README'), 'nothing here'); + expect(listBrowserSkills(tiers)).toEqual([]); + }); + + it('skips dotfiles and .tombstones', () => { + makeSkill(tiers.bundled, '.hidden', 'name: hidden\nhost: x.com'); + fs.mkdirSync(path.join(tiers.global, '.tombstones', 'old-skill'), { recursive: true }); + fs.writeFileSync(path.join(tiers.global, '.tombstones', 'old-skill', 'SKILL.md'), '---\nname: x\nhost: y\n---\n'); + expect(listBrowserSkills(tiers)).toEqual([]); + }); + + it('skips malformed SKILL.md silently (best-effort listing)', () => { + fs.mkdirSync(path.join(tiers.bundled, 'broken')); + fs.writeFileSync(path.join(tiers.bundled, 'broken', 'SKILL.md'), 'no frontmatter'); + makeSkill(tiers.bundled, 'good', 'name: good\nhost: x.com'); + const skills = listBrowserSkills(tiers); + expect(skills.map(s => s.name)).toEqual(['good']); + }); +}); + +describe('readBrowserSkill', () => { + it('returns null when skill missing in all tiers', () => { + expect(readBrowserSkill('nope', tiers)).toBeNull(); + }); + + it('finds bundled-tier skill', () => { + makeSkill(tiers.bundled, 'foo', 'name: foo\nhost: example.com'); + const skill = readBrowserSkill('foo', tiers); + expect(skill).not.toBeNull(); + expect(skill!.tier).toBe('bundled'); + }); + + it('returns project-tier when same name in all three', () => { + makeSkill(tiers.bundled, 'shared', 'name: shared\nhost: bundled.com'); + makeSkill(tiers.global, 'shared', 'name: shared\nhost: global.com'); + makeSkill(tiers.project!, 'shared', 'name: shared\nhost: project.com'); + const skill = readBrowserSkill('shared', tiers); + expect(skill!.tier).toBe('project'); + expect(skill!.frontmatter.host).toBe('project.com'); + }); + + it('falls through to bundled when global is malformed', () => { + makeSkill(tiers.bundled, 'foo', 'name: foo\nhost: bundled.com'); + fs.mkdirSync(path.join(tiers.global, 'foo')); + fs.writeFileSync(path.join(tiers.global, 'foo', 'SKILL.md'), 'malformed'); + const skill = readBrowserSkill('foo', tiers); + expect(skill!.tier).toBe('bundled'); + expect(skill!.frontmatter.host).toBe('bundled.com'); + }); + + it('reads bodyMd correctly', () => { + makeSkill(tiers.bundled, 'foo', 'name: foo\nhost: x.com', '\n# Heading\n\nProse.\n'); + const skill = readBrowserSkill('foo', tiers); + expect(skill!.bodyMd).toContain('# Heading'); + expect(skill!.bodyMd).toContain('Prose.'); + }); +}); + +describe('tombstoneBrowserSkill', () => { + it('moves a global-tier skill to .tombstones/', () => { + makeSkill(tiers.global, 'gone', 'name: gone\nhost: x.com'); + const dst = tombstoneBrowserSkill('gone', 'global', tiers); + expect(fs.existsSync(path.join(tiers.global, 'gone'))).toBe(false); + expect(fs.existsSync(dst)).toBe(true); + expect(dst).toContain('.tombstones'); + }); + + it('moves a project-tier skill to .tombstones/', () => { + makeSkill(tiers.project!, 'gone', 'name: gone\nhost: x.com'); + const dst = tombstoneBrowserSkill('gone', 'project', tiers); + expect(fs.existsSync(path.join(tiers.project!, 'gone'))).toBe(false); + expect(fs.existsSync(dst)).toBe(true); + }); + + it('after tombstone, listBrowserSkills no longer returns it', () => { + makeSkill(tiers.global, 'gone', 'name: gone\nhost: x.com'); + expect(listBrowserSkills(tiers)).toHaveLength(1); + tombstoneBrowserSkill('gone', 'global', tiers); + expect(listBrowserSkills(tiers)).toEqual([]); + }); + + it('throws when skill not found in target tier', () => { + expect(() => tombstoneBrowserSkill('nope', 'global', tiers)).toThrow(/not found/); + }); + + it('after tombstone, listBrowserSkills falls through to bundled', () => { + makeSkill(tiers.bundled, 'shared', 'name: shared\nhost: bundled.com'); + makeSkill(tiers.global, 'shared', 'name: shared\nhost: global.com'); + expect(listBrowserSkills(tiers)[0].tier).toBe('global'); + tombstoneBrowserSkill('shared', 'global', tiers); + expect(listBrowserSkills(tiers)[0].tier).toBe('bundled'); + }); +});