/** * 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; }