domain-skill-commands.ts
301 lines · browse/src/domain-skill-commands.ts at 3f79d81
1/**
2 * $B domain-skill subcommands — CLI surface for the domain-skills storage layer.
3 *
4 * Subcommands:
5 * save — save a skill body (host derived from active tab, T3)
6 * list — list all skills (project + global) visible here
7 * show <host> — print the body of a skill
8 * edit <host> — round-trip through $EDITOR
9 * promote-to-global <host> — promote active per-project skill to global
10 * rollback <host> — restore prior version
11 * rm <host> [--global] — tombstone a skill
12 *
13 * Design constraints:
14 * - host is ALWAYS derived from the active tab's top-level origin (T3
15 * confused-deputy fix). Never accepted as an arg.
16 * - Save-time security uses content-security.ts L1-L3 filters (importable
17 * from the compiled binary, unlike the L4 ML classifier). There is NO
18 * load-time L4 scan today — it died with the chat path; the
19 * classifier_score>0 promotion gate in domain-skills.ts compensates.
20 * - Output is structured: every success/error includes problem + cause +
21 * suggested-action. Matches the gstack house style.
22 *
23 * The body for `save` is supplied via stdin or --from-file, NOT inline argv,
24 * so multi-line markdown bodies don't get mangled by shell quoting.
25 */
27import { promises as fs } from 'fs';
28import * as path from 'path';
29import * as os from 'os';
30import { spawnSync } from 'child_process';
31import type { BrowserManager } from './browser-manager';
32import {
33 deriveHostFromActiveTab,
34 writeSkill,
35 readSkill,
36 listSkills,
37 promoteToGlobal,
38 rollbackSkill,
39 deleteSkill,
40 type DomainSkillRow,
41 type SkillScope,
42} from './domain-skills';
43import { runContentFilters } from './content-security';
44import { getCurrentProjectSlug } from './project-slug';
45import { logTelemetry } from './telemetry';
47// ─── Body input resolution ──────────────────────────────────────
49/**
50 * Read skill body from --from-file <path> or from stdin.
51 * Body is NEVER taken from inline argv (shell quoting hazard for multi-line markdown).
52 */
53async function readBodyFromArgs(args: string[]): Promise<string> {
54 const fromFileIdx = args.indexOf('--from-file');
55 if (fromFileIdx >= 0 && fromFileIdx + 1 < args.length) {
56 const filePath = args[fromFileIdx + 1]!;
57 const body = await fs.readFile(filePath, 'utf8');
58 return body;
59 }
60 // Read from stdin (the CLI may pipe content in)
61 return new Promise((resolve) => {
62 let data = '';
63 process.stdin.setEncoding('utf8');
64 process.stdin.on('data', (chunk) => (data += chunk));
65 process.stdin.on('end', () => resolve(data));
66 // If no stdin attached, end immediately with empty string
67 if (process.stdin.isTTY) resolve('');
68 });
69}
71// ─── Output formatting ──────────────────────────────────────────
73function formatSavedOk(row: DomainSkillRow, slug: string): string {
74 return [
75 `Saved (state: ${row.state}, scope: ${row.scope}).`,
76 `Host: ${row.host}`,
77 `Bytes: ${row.body.length}`,
78 `Version: ${row.version}`,
79 `Stored at: ~/.gstack/projects/${slug}/learnings.jsonl`,
80 '',
81 `Next: skill is quarantined and won't fire in prompts until used 3 times`,
82 ` without classifier flags. Run $B domain-skill list to see state.`,
83 ].join('\n');
84}
86function formatSkillListing(list: { project: DomainSkillRow[]; global: DomainSkillRow[] }): string {
87 if (list.project.length === 0 && list.global.length === 0) {
88 return 'No domain-skills yet.\n\nNext: navigate to a site, then $B domain-skill save with a markdown body to begin.';
89 }
90 const lines: string[] = [];
91 if (list.project.length > 0) {
92 lines.push('Project (per-project):');
93 for (const r of list.project) {
94 lines.push(` [${r.state}] ${r.host} — v${r.version}, ${r.body.length} bytes, used ${r.use_count}× (${r.flag_count} flags)`);
95 }
96 }
97 if (list.global.length > 0) {
98 if (lines.length > 0) lines.push('');
99 lines.push('Global (cross-project):');
100 for (const r of list.global) {
101 lines.push(` ${r.host} — v${r.version}, ${r.body.length} bytes`);
102 }
103 }
104 return lines.join('\n');
105}
107// ─── Subcommand handlers ────────────────────────────────────────
109async function handleSave(args: string[], bm: BrowserManager): Promise<string> {
110 const page = bm.getPage();
111 const host = await deriveHostFromActiveTab(page);
112 const body = await readBodyFromArgs(args);
113 if (!body || !body.trim()) {
114 throw new Error(
115 'Save failed: empty body.\n' +
116 'Cause: no content provided via --from-file or stdin.\n' +
117 'Action: pipe markdown into $B domain-skill save, or pass --from-file <path>.'
118 );
119 }
120 // L1-L3 content filters (datamarking, hidden-element strip, ARIA regex,
121 // URL blocklist). No L4 ML scan here — the classifier can't import in the
122 // compiled binary, and the load-time scan path no longer exists.
123 const filterResult = runContentFilters(body, page.url(), 'domain-skill-save');
124 if (filterResult.blocked) {
125 logTelemetry({ event: 'domain_skill_save_blocked', host, reason: filterResult.message });
126 throw new Error(
127 `Save blocked: ${filterResult.message}\n` +
128 'Cause: skill body trips L1-L3 content filters (likely contains URL blocklist match or ARIA injection patterns).\n' +
129 'Action: review the body for suspicious instruction-like content; rewrite and retry.'
130 );
131 }
132 // L1-L3 score is binary (passed or not). The L4 score field stays 0
133 // ("never ML-scanned") — nothing fills it in today, which is exactly why
134 // the promotion gate in domain-skills.ts requires classifier_score > 0.
135 const slug = getCurrentProjectSlug();
136 const row = await writeSkill({
137 host,
138 body,
139 projectSlug: slug,
140 source: 'agent',
141 classifierScore: 0, // L4 deferred to load-time
142 });
143 logTelemetry({ event: 'domain_skill_saved', host, scope: row.scope, state: row.state, bytes: body.length });
144 return formatSavedOk(row, slug);
145}
147async function handleList(_args: string[]): Promise<string> {
148 const slug = getCurrentProjectSlug();
149 const list = await listSkills(slug);
150 return formatSkillListing(list);
151}
153async function handleShow(args: string[]): Promise<string> {
154 const host = args[0];
155 if (!host) {
156 throw new Error(
157 'Usage: $B domain-skill show <host>\n' +
158 'Cause: missing hostname argument.\n' +
159 'Action: $B domain-skill list to see available hosts.'
160 );
161 }
162 const slug = getCurrentProjectSlug();
163 const result = await readSkill(host, slug);
164 if (!result) {
165 return `No active skill for ${host}.\n\nA quarantined skill may exist; run $B domain-skill list to see all states.`;
166 }
167 return [
168 `# ${result.row.host} (${result.source} scope, ${result.row.state})`,
169 `# version: ${result.row.version}, used: ${result.row.use_count}×, flags: ${result.row.flag_count}`,
170 '',
171 result.row.body,
172 ].join('\n');
173}
175async function handleEdit(args: string[]): Promise<string> {
176 const host = args[0];
177 if (!host) {
178 throw new Error('Usage: $B domain-skill edit <host>');
179 }
180 const slug = getCurrentProjectSlug();
181 // Read current body to seed the editor
182 const list = await listSkills(slug);
183 const current = [...list.project, ...list.global].find((r) => r.host === host);
184 if (!current) {
185 throw new Error(
186 `Cannot edit: no skill for ${host}.\n` +
187 'Cause: skill does not exist in this project or global scope.\n' +
188 'Action: $B domain-skill save to create one first.'
189 );
190 }
191 const editor = process.env.EDITOR || 'vi';
192 const tmpFile = path.join(os.tmpdir(), `gstack-domain-skill-${process.pid}-${Date.now()}.md`);
193 await fs.writeFile(tmpFile, current.body, 'utf8');
194 const result = spawnSync(editor, [tmpFile], { stdio: 'inherit' });
195 if (result.status !== 0) {
196 await fs.unlink(tmpFile).catch(() => {});
197 throw new Error(`Editor exited with status ${result.status}; no changes saved.`);
198 }
199 const newBody = await fs.readFile(tmpFile, 'utf8');
200 await fs.unlink(tmpFile).catch(() => {});
201 if (newBody === current.body) {
202 return `No changes for ${host}.`;
203 }
204 // Re-save (always per-project; promotion is explicit)
205 const page = (global as any).__bm?.getPage?.();
206 void page; // we're in the daemon — page available, but for edit we trust the existing host
207 const row = await writeSkill({
208 host: current.host,
209 body: newBody,
210 projectSlug: slug,
211 source: 'human',
212 classifierScore: 0,
213 });
214 return formatSavedOk(row, slug);
215}
217async function handlePromoteToGlobal(args: string[]): Promise<string> {
218 const host = args[0];
219 if (!host) {
220 throw new Error('Usage: $B domain-skill promote-to-global <host>');
221 }
222 const slug = getCurrentProjectSlug();
223 const row = await promoteToGlobal(host, slug);
224 return [
225 `Promoted ${row.host} to global scope (v${row.version}).`,
226 `Stored at: ~/.gstack/global-domain-skills.jsonl`,
227 '',
228 `This skill now fires for all projects unless they have a per-project skill for the same host.`,
229 ].join('\n');
230}
232async function handleRollback(args: string[]): Promise<string> {
233 const host = args[0];
234 if (!host) {
235 throw new Error('Usage: $B domain-skill rollback <host>');
236 }
237 const scope: SkillScope = args.includes('--global') ? 'global' : 'project';
238 const slug = getCurrentProjectSlug();
239 const row = await rollbackSkill(host, slug, scope);
240 return [
241 `Rolled back ${row.host} (${scope} scope) to prior version.`,
242 `New version: ${row.version} (content from earlier revision)`,
243 ].join('\n');
244}
246async function handleRm(args: string[]): Promise<string> {
247 const host = args[0];
248 if (!host) {
249 throw new Error('Usage: $B domain-skill rm <host> [--global]');
250 }
251 const scope: SkillScope = args.includes('--global') ? 'global' : 'project';
252 const slug = getCurrentProjectSlug();
253 await deleteSkill(host, slug, scope);
254 return `Tombstoned ${host} (${scope} scope). Use $B domain-skill rollback to restore.`;
255}
257// ─── Top-level dispatcher ──────────────────────────────────────
259export async function handleDomainSkillCommand(args: string[], bm: BrowserManager): Promise<string> {
260 const sub = args[0];
261 const rest = args.slice(1);
262 switch (sub) {
263 case 'save':
264 return handleSave(rest, bm);
265 case 'list':
266 return handleList(rest);
267 case 'show':
268 return handleShow(rest);
269 case 'edit':
270 return handleEdit(rest);
271 case 'promote-to-global':
272 return handlePromoteToGlobal(rest);
273 case 'rollback':
274 return handleRollback(rest);
275 case 'rm':
276 case 'remove':
277 case 'delete':
278 return handleRm(rest);
279 case undefined:
280 case '':
281 case 'help':
282 return [
283 '$B domain-skill — agent-authored per-site notes',
284 '',
285 'Subcommands:',
286 ' save save body from stdin or --from-file (host derived from active tab)',
287 ' list list all skills visible to current project',
288 ' show <host> print skill body',
289 ' edit <host> open in $EDITOR',
290 ' promote-to-global <host> promote active skill to global scope',
291 ' rollback <host> [--global] restore prior version',
292 ' rm <host> [--global] tombstone',
293 ].join('\n');
294 default:
295 throw new Error(
296 `Unknown subcommand: ${sub}\n` +
297 'Cause: not one of save|list|show|edit|promote-to-global|rollback|rm.\n' +
298 'Action: $B domain-skill help for the full list.'
299 );
300 }
301}