mirror of https://github.com/garrytan/gstack.git
421 lines
15 KiB
TypeScript
421 lines
15 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|