browser-skills.ts
420 lines · browse/src/browser-skills.ts at 3f79d81
1/**
2 * browser-skills — storage helpers for per-task Playwright scripts.
3 *
4 * A browser-skill is a directory containing SKILL.md (frontmatter + prose),
5 * script.ts (deterministic Playwright-via-browse-client script), an _lib/
6 * with a copy of the SDK, fixtures/ for tests, and script.test.ts.
7 *
8 * Three tiers, walked in order project > global > bundled (first-wins):
9 * project: <project>/.gstack/browser-skills/<name>/
10 * global: ~/.gstack/browser-skills/<name>/
11 * bundled: <gstack-install>/browser-skills/<name>/ (read-only, ships with gstack)
12 *
13 * No INDEX.json. `listBrowserSkills()` walks the three directories every call
14 * (~5-10ms for 50 skills, invisible). Eliminates a whole class of "index
15 * drifted from disk" bugs.
16 *
17 * Tombstones move a skill to `<tier>/.tombstones/<name>-<ts>/` so the user
18 * can recover. `$B skill list` ignores tombstoned directories.
19 *
20 * Zero side effects on import. Safe to import from tests.
21 */
23import * as fs from 'fs';
24import * as path from 'path';
25import * as cp from 'child_process';
26import { resolveStateRoot } from '../../lib/state-root';
28// ─── Types ──────────────────────────────────────────────────────
30export type SkillTier = 'project' | 'global' | 'bundled';
32/** Required + optional fields from a browser-skill SKILL.md frontmatter. */
33export interface SkillFrontmatter {
34 /** Skill name; must match the directory name. */
35 name: string;
36 /** One-line description (optional but recommended). */
37 description?: string;
38 /** Primary hostname this skill targets, e.g. "news.ycombinator.com". */
39 host: string;
40 /** Trigger phrases the resolver matches against ("scrape hn frontpage"). */
41 triggers: string[];
42 /**
43 * Args the script accepts (passed via `$B skill run <name> --arg key=value`).
44 * Phase 1 keeps this loose: each arg is just a name and optional description.
45 */
46 args: SkillArg[];
47 /**
48 * Trust flag. true = full env passed to spawn (human-authored, audited).
49 * false (default) = scrubbed env, locked cwd. Orthogonal to scoped-token
50 * capabilities: untrusted skills still get a read+write daemon token.
51 */
52 trusted: boolean;
53 /** Optional semver-ish version string for skill upgrades. */
54 version?: string;
55 /** Whether the skill was hand-written or generated by the skillify flow. */
56 source?: 'human' | 'agent';
57}
59export interface SkillArg {
60 name: string;
61 description?: string;
62}
64export interface BrowserSkill {
65 name: string;
66 tier: SkillTier;
67 /** Absolute path to the skill directory. */
68 dir: string;
69 frontmatter: SkillFrontmatter;
70 /** SKILL.md prose body (everything after the frontmatter block). */
71 bodyMd: string;
72}
74export interface TierPaths {
75 /** May be null in non-project contexts (e.g. tests, standalone runs). */
76 project: string | null;
77 global: string;
78 bundled: string;
79}
81// ─── Tier resolution ────────────────────────────────────────────
83/**
84 * Resolve the three tier directories from runtime context.
85 * Project tier requires git or a project hint; returns null when neither resolves.
86 */
87export function defaultTierPaths(opts: { projectRoot?: string; home?: string; bundledRoot?: string } = {}): TierPaths {
88 const stateRoot = opts.home !== undefined ? path.join(opts.home, '.gstack') : resolveStateRoot();
89 const projectRoot = opts.projectRoot ?? detectProjectRoot();
90 const bundledRoot = opts.bundledRoot ?? detectBundledRoot();
92 return {
93 project: projectRoot ? path.join(projectRoot, '.gstack', 'browser-skills') : null,
94 global: path.join(stateRoot, 'browser-skills'),
95 bundled: path.join(bundledRoot, 'browser-skills'),
96 };
97}
99function detectProjectRoot(): string | null {
100 try {
101 const proc = cp.spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf-8', timeout: 2000, windowsHide: true });
102 if (proc.status === 0) {
103 const out = proc.stdout.trim();
104 return out || null;
105 }
106 } catch {}
107 return null;
108}
110function detectBundledRoot(): string {
111 // The browse binary lives at <gstack-install>/browse/dist/browse.
112 // The bundled browser-skills/ dir is a sibling of browse/ (i.e. <gstack-install>/browser-skills/).
113 // For dev/source runs, process.execPath is bun itself — fall back to the source-tree
114 // directory two levels up from this file.
115 try {
116 const exec = process.execPath;
117 if (exec && /\/browse\/dist\/browse$/.test(exec)) {
118 return path.resolve(path.dirname(exec), '..', '..');
119 }
120 } catch {}
121 // Source/dev fallback: walk up from this file's dir to a directory that has both browse/ and browser-skills/.
122 // browse/src/browser-skills.ts → ../../ (the gstack root).
123 return path.resolve(__dirname, '..', '..');
124}
126// ─── Frontmatter parsing ────────────────────────────────────────
128/**
129 * Parse a SKILL.md into { frontmatter, bodyMd }. Throws if the file is
130 * missing required fields (host, triggers, args).
131 */
132export function parseSkillFile(content: string, opts: { skillName?: string } = {}): { frontmatter: SkillFrontmatter; bodyMd: string } {
133 if (!content.startsWith('---\n')) {
134 throw new Error('SKILL.md missing frontmatter block (expected starting "---\\n")');
135 }
136 const fmEnd = content.indexOf('\n---', 4);
137 if (fmEnd === -1) {
138 throw new Error('SKILL.md frontmatter block not terminated (expected "\\n---")');
139 }
140 const fmText = content.slice(4, fmEnd);
141 const bodyMd = content.slice(fmEnd + 4).replace(/^\n+/, '');
142 const fm = parseFrontmatterFields(fmText);
144 // Validate required fields.
145 const errors: string[] = [];
146 const name = fm.name ?? opts.skillName ?? '';
147 if (!name) errors.push('missing required field: name (or skillName hint)');
148 if (!fm.host) errors.push('missing required field: host');
149 // triggers and args may be omitted — empty list is valid.
150 if (errors.length > 0) {
151 throw new Error(`SKILL.md validation failed: ${errors.join('; ')}`);
152 }
154 const frontmatter: SkillFrontmatter = {
155 name,
156 description: fm.description,
157 host: fm.host as string,
158 triggers: Array.isArray(fm.triggers) ? fm.triggers : [],
159 args: Array.isArray(fm.args) ? fm.args : [],
160 trusted: fm.trusted === true,
161 version: typeof fm.version === 'string' ? fm.version : undefined,
162 source: fm.source === 'agent' || fm.source === 'human' ? fm.source : undefined,
163 };
165 return { frontmatter, bodyMd };
166}
168interface RawFrontmatter {
169 name?: string;
170 description?: string;
171 host?: string;
172 triggers?: string[];
173 args?: SkillArg[];
174 trusted?: boolean;
175 version?: string;
176 source?: string;
177}
179/**
180 * Tiny frontmatter parser tuned for the browser-skill subset:
181 * - simple key: value scalars
182 * - YAML list: `key:\n - item1\n - item2`
183 * - args list of mappings: `args:\n - name: foo\n description: bar`
184 *
185 * Quoting: a value wrapped in "..." or '...' is taken literally (handles colons).
186 * Anything more exotic should use a real YAML library — not in Phase 1 scope.
187 */
188function parseFrontmatterFields(fm: string): RawFrontmatter {
189 const result: RawFrontmatter = {};
190 const lines = fm.split('\n');
191 let i = 0;
193 while (i < lines.length) {
194 const line = lines[i];
196 // Skip blank lines and comments
197 if (!line.trim() || line.trim().startsWith('#')) { i++; continue; }
199 // Top-level scalar: `key: value`
200 const scalar = line.match(/^([a-zA-Z_][a-zA-Z0-9_-]*):\s*(.*)$/);
201 if (scalar && !line.startsWith(' ')) {
202 const key = scalar[1];
203 const rawVal = scalar[2];
205 // Empty value: list or mapping follows on next lines
206 if (!rawVal) {
207 // Peek to determine list vs unset
208 const nextNonBlank = findNextNonBlank(lines, i + 1);
209 if (nextNonBlank !== -1 && lines[nextNonBlank].match(/^\s+-\s/)) {
210 // List — collect items
211 if (key === 'args') {
212 const { items, consumed } = collectArgsList(lines, i + 1);
213 (result as any)[key] = items;
214 i += 1 + consumed;
215 } else {
216 const { items, consumed } = collectStringList(lines, i + 1);
217 (result as any)[key] = items;
218 i += 1 + consumed;
219 }
220 continue;
221 }
222 i++;
223 continue;
224 }
226 // Inline list: `key: []`
227 if (rawVal === '[]') {
228 (result as any)[key] = [];
229 i++;
230 continue;
231 }
233 // Inline scalar
234 (result as any)[key] = parseScalar(rawVal);
235 i++;
236 continue;
237 }
239 i++;
240 }
242 return result;
243}
245function findNextNonBlank(lines: string[], from: number): number {
246 for (let i = from; i < lines.length; i++) {
247 if (lines[i].trim()) return i;
248 }
249 return -1;
250}
252function collectStringList(lines: string[], from: number): { items: string[]; consumed: number } {
253 const items: string[] = [];
254 let i = from;
255 while (i < lines.length) {
256 const line = lines[i];
257 if (!line.trim()) { i++; continue; }
258 const m = line.match(/^\s+-\s+(.*)$/);
259 if (!m) break;
260 items.push(stripQuotes(m[1]));
261 i++;
262 }
263 return { items, consumed: i - from };
264}
266function collectArgsList(lines: string[], from: number): { items: SkillArg[]; consumed: number } {
267 const items: SkillArg[] = [];
268 let i = from;
269 while (i < lines.length) {
270 const line = lines[i];
271 if (!line.trim()) { i++; continue; }
272 // Item start: ` - name: foo` (with whatever indent)
273 const itemStart = line.match(/^(\s+)-\s+(.+?):\s*(.*)$/);
274 if (!itemStart) break;
275 const indent = itemStart[1] + ' '; // continuation lines get 2 more spaces
276 const arg: SkillArg = { name: '' };
277 if (itemStart[2] === 'name') {
278 arg.name = stripQuotes(itemStart[3]);
279 } else if (itemStart[2] === 'description') {
280 arg.description = stripQuotes(itemStart[3]);
281 }
282 i++;
283 // Read continuation lines ` description: ...`
284 while (i < lines.length) {
285 const cont = lines[i];
286 if (!cont.startsWith(indent) || !cont.trim()) break;
287 const kv = cont.match(/^\s+([a-zA-Z_][a-zA-Z0-9_-]*):\s*(.*)$/);
288 if (!kv) break;
289 if (kv[1] === 'name') arg.name = stripQuotes(kv[2]);
290 else if (kv[1] === 'description') arg.description = stripQuotes(kv[2]);
291 i++;
292 }
293 items.push(arg);
294 }
295 return { items, consumed: i - from };
296}
298function parseScalar(raw: string): string | boolean | number {
299 const v = raw.trim();
300 if (v === 'true') return true;
301 if (v === 'false') return false;
302 if (/^-?\d+$/.test(v)) return parseInt(v, 10);
303 return stripQuotes(v);
304}
306function stripQuotes(v: string): string {
307 const trimmed = v.trim();
308 if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
309 (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
310 return trimmed.slice(1, -1);
311 }
312 return trimmed;
313}
315// ─── Listing + reading ──────────────────────────────────────────
317/**
318 * Walk all three tiers and return every visible skill (tombstones excluded).
319 * Tier precedence: project > global > bundled. If the same skill name appears
320 * in multiple tiers, the entry from the highest-priority tier wins.
321 */
322export function listBrowserSkills(tiers?: TierPaths): BrowserSkill[] {
323 const t = tiers ?? defaultTierPaths();
324 const seen = new Map<string, BrowserSkill>();
326 // Walk in priority order: project first, so it wins over global/bundled.
327 const order: Array<{ tier: SkillTier; root: string | null }> = [
328 { tier: 'project', root: t.project },
329 { tier: 'global', root: t.global },
330 { tier: 'bundled', root: t.bundled },
331 ];
333 for (const { tier, root } of order) {
334 if (!root || !fs.existsSync(root)) continue;
335 let entries: string[];
336 try { entries = fs.readdirSync(root); } catch { continue; }
337 for (const entry of entries) {
338 if (entry.startsWith('.') || entry === '.tombstones') continue;
339 if (seen.has(entry)) continue; // higher-priority tier already claimed this name
340 const dir = path.join(root, entry);
341 let stat: fs.Stats;
342 try { stat = fs.statSync(dir); } catch { continue; }
343 if (!stat.isDirectory()) continue;
345 const skillFile = path.join(dir, 'SKILL.md');
346 if (!fs.existsSync(skillFile)) continue;
348 try {
349 const content = fs.readFileSync(skillFile, 'utf-8');
350 const { frontmatter, bodyMd } = parseSkillFile(content, { skillName: entry });
351 seen.set(entry, { name: entry, tier, dir, frontmatter, bodyMd });
352 } catch {
353 // Malformed skill — skip silently. listBrowserSkills is best-effort;
354 // skill-validation tests catch these at build time.
355 continue;
356 }
357 }
358 }
360 return Array.from(seen.values()).sort((a, b) => a.name.localeCompare(b.name));
361}
363/**
364 * Read a single skill by name (first-tier-wins). Returns null if not found
365 * in any tier.
366 */
367export function readBrowserSkill(name: string, tiers?: TierPaths): BrowserSkill | null {
368 const t = tiers ?? defaultTierPaths();
369 const order: Array<{ tier: SkillTier; root: string | null }> = [
370 { tier: 'project', root: t.project },
371 { tier: 'global', root: t.global },
372 { tier: 'bundled', root: t.bundled },
373 ];
375 for (const { tier, root } of order) {
376 if (!root) continue;
377 const dir = path.join(root, name);
378 const skillFile = path.join(dir, 'SKILL.md');
379 if (!fs.existsSync(skillFile)) continue;
381 try {
382 const content = fs.readFileSync(skillFile, 'utf-8');
383 const { frontmatter, bodyMd } = parseSkillFile(content, { skillName: name });
384 return { name, tier, dir, frontmatter, bodyMd };
385 } catch {
386 // Malformed — try next tier.
387 continue;
388 }
389 }
391 return null;
392}
394// ─── Tombstone (rm) ─────────────────────────────────────────────
396/**
397 * Move a user-tier skill (project or global) into the tier's .tombstones/
398 * directory. Returns the new path.
399 *
400 * Cannot tombstone bundled skills — they ship with gstack and are read-only.
401 * To remove a bundled skill, override it with a global/project entry, or
402 * remove the file from the gstack source tree.
403 */
404export function tombstoneBrowserSkill(name: string, tier: 'project' | 'global', tiers?: TierPaths): string {
405 const t = tiers ?? defaultTierPaths();
406 const root = tier === 'project' ? t.project : t.global;
407 if (!root) {
408 throw new Error(`tombstoneBrowserSkill: tier "${tier}" has no resolved path`);
409 }
410 const src = path.join(root, name);
411 if (!fs.existsSync(src)) {
412 throw new Error(`tombstoneBrowserSkill: skill "${name}" not found in tier "${tier}" at ${src}`);
413 }
414 const tombstoneDir = path.join(root, '.tombstones');
415 fs.mkdirSync(tombstoneDir, { recursive: true });
416 const ts = new Date().toISOString().replace(/[:.]/g, '-');
417 const dst = path.join(tombstoneDir, `${name}-${ts}`);
418 fs.renameSync(src, dst);
419 return dst;
420}