feat(browser-skills): 3-tier storage helpers

listBrowserSkills() walks project > global > bundled (first-wins),
parses SKILL.md frontmatter, no INDEX.json. readBrowserSkill() does
the same for a single name. tombstoneBrowserSkill() moves a skill
into .tombstones/<name>-<ts>/ for recoverability.

Frontmatter parser handles the subset browser-skills need: scalars
(host, description, trusted, version, source), string lists
(triggers), and arg-mapping lists ([{name, description}, ...]).
Quoted values handle colons; trusted defaults to false.

Bundled tier path is auto-detected from the binary install location;
project tier comes from git rev-parse; global is ~/.gstack/. All tier
paths are overridable for hermetic tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-04-26 04:55:38 -07:00
parent c0dff84647
commit faf663b22c
2 changed files with 703 additions and 0 deletions
+420
View File
@@ -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: <project>/.gstack/browser-skills/<name>/
* global: ~/.gstack/browser-skills/<name>/
* bundled: <gstack-install>/browser-skills/<name>/ (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 `<tier>/.tombstones/<name>-<ts>/` 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 <name> --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 <gstack-install>/browse/dist/browse.
// The bundled browser-skills/ dir is a sibling of browse/ (i.e. <gstack-install>/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<string, BrowserSkill>();
// 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;
}
+283
View File
@@ -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');
});
});