#!/usr/bin/env npx tsx
/**
* snappy-libretto/api.ts -- Record a browser job once, turn it into a typed
* lesson, replay its learned calls with plain fetch, and promote that replay
* into a snappy-* hand.
*
* Capture composes snappy-browse. Request normalization and fetch replay compose
* snappy-api-sniffer. Target creation composes snappy-skill.
*/
import {
chmodSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
realpathSync,
renameSync,
writeFileSync,
} from "fs";
import { homedir } from "os";
import { basename, dirname, join } from "path";
import { env } from "../snappy-settings/load.ts";
import {
normalizeCapturedRequest,
replayCapturedRequest,
type ReplayResponse,
} from "../snappy-api-sniffer/api.ts";
import {
captureRequests,
clearRequests,
close,
navigate,
runBrowser,
type CapturedRequest,
} from "../snappy-browse/session.ts";
import { scaffold } from "../snappy-skill/api.ts";
import { evidence } from "../snappy-settings/evidence-envelope.ts";
import { refusalTable } from "../snappy-settings/refusal-codes.ts";
void env;
const VERSION = 1 as const;
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
const LESSON_NAME = /^[a-z0-9][a-z0-9._-]{0,79}$/u;
const SKILL_NAME = /^snappy-[a-z0-9][a-z0-9-]*$/u;
const VERB_NAME = /^[a-z][a-z0-9-]*$/u;
export type LessonVerb =
| "goto"
| "click"
| "dblclick"
| "fill"
| "fill_secret"
| "press"
| "selectoption";
export interface LessonStep {
id: string;
at: string;
verb: LessonVerb;
target: string;
value?: string;
}
export interface LessonParameter {
name: string;
target: string;
recorded: string;
}
export interface LessonEndpoint extends CapturedRequest {
id: string;
}
export interface LibrettoLesson {
version: typeof VERSION;
name: string;
source_url: string;
profile: string;
recorded_at: string;
completed_at: string;
auth_state: string;
steps: LessonStep[];
parameters: LessonParameter[];
endpoints: LessonEndpoint[];
request_count_total: number;
}
export interface LessonSummary {
name: string;
source_url: string;
recorded_at: string;
step_count: number;
endpoint_count: number;
request_count_total: number;
auth_state_present: boolean;
}
export type ReplayArgs = Record<string, string | number | boolean | null>;
export interface ReplayItem extends ReplayResponse {
id: string;
method: string;
requestUrl: string;
}
export interface ReplayResult {
lesson: string;
request_count: number;
items: ReplayItem[];
}
export interface ReplayOptions {
now?: boolean;
}
export interface RecordOptions {
signal?: AbortSignal;
pollMs?: number;
maxMs?: number;
quiet?: boolean;
}
export interface PromoteResult {
lesson: string;
skill: string;
verb: string;
effect: "read" | "write";
files: string[];
}
export class SessionStaleError extends Error {
readonly code = "session_stale";
readonly lessonName: string;
constructor(lessonName: string, detail: string) {
super(`Lesson "${lessonName}" refused: captured browser session is stale. ${detail}`);
this.lessonName = lessonName;
this.name = "SessionStaleError";
}
}
function rootDir(): string {
return process.env.SNAPPY_LIBRETTO_HOME || join(homedir(), "snappy/libretto");
}
function lessonsDir(): string {
return join(rootDir(), "lessons");
}
function lessonDir(name: string): string {
assertLessonName(name);
return join(lessonsDir(), name);
}
function lessonPath(name: string): string {
return join(lessonDir(name), "lesson.json");
}
function assertLessonName(name: string): void {
if (!LESSON_NAME.test(name)) {
throw new Error("Lesson name must use 1-80 lowercase letters, digits, dot, underscore, or hyphen.");
}
}
function ensurePrivateDir(path: string): void {
mkdirSync(path, { recursive: true, mode: 0o700 });
chmodSync(path, 0o700);
}
function writePrivateJson(path: string, value: unknown): void {
ensurePrivateDir(dirname(path));
const temp = join(dirname(path), `.${basename(path)}.${process.pid}.tmp`);
writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
chmodSync(temp, 0o600);
renameSync(temp, path);
}
function writeJson(path: string, value: unknown): void {
mkdirSync(dirname(path), { recursive: true });
const temp = join(dirname(path), `.${basename(path)}.${process.pid}.tmp`);
writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`);
renameSync(temp, path);
}
function readJson<T>(path: string): T {
return JSON.parse(readFileSync(path, "utf-8")) as T;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function slug(value: string, fallback: string): string {
const out = value
.toLowerCase()
.replace(/[^a-z0-9]+/gu, "_")
.replace(/^_+|_+$/gu, "")
.slice(0, 48);
return out || fallback;
}
function profileName(url: string): string {
return slug(new URL(url).hostname, "browser");
}
function parseEvalResult(raw: string): unknown {
const clean = raw.trim();
const candidates: unknown[] = [];
try { candidates.push(JSON.parse(clean)); } catch { /* agent-browser may print a scalar */ }
for (const candidate of candidates) {
if (candidate && typeof candidate === "object" && "data" in candidate) {
const data = (candidate as { data?: { result?: unknown } }).data;
if (data && "result" in data) return typeof data.result === "string" ? JSON.parse(data.result) : data.result;
}
if (typeof candidate === "string") {
try { return JSON.parse(candidate); } catch { return candidate; }
}
return candidate;
}
const first = clean.indexOf("[");
const last = clean.lastIndexOf("]");
if (first >= 0 && last > first) return JSON.parse(clean.slice(first, last + 1));
throw new Error(`Could not parse recorder response (${clean.slice(0, 120)})`);
}
const RECORDER_SCRIPT = String.raw`(() => {
const KEY = "__snappyLibrettoRecorderV1";
const cssEscape = (value) => {
if (globalThis.CSS && typeof CSS.escape === "function") return CSS.escape(value);
return String(value).replace(/[^a-zA-Z0-9_-]/g, (c) => "\\" + c.codePointAt(0).toString(16) + " ");
};
const quoted = (value) => String(value).replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
const structural = (node) => {
const el = node instanceof Element ? node : node && node.parentElement;
if (!el) return "(target unavailable)";
for (const key of ["data-testid", "data-test", "data-qa"]) {
const value = el.getAttribute(key);
if (value) return "[" + key + "=\"" + quoted(value) + "\"]";
}
if (el.id) return "#" + cssEscape(el.id);
const aria = el.getAttribute("aria-label");
if (aria) return "[aria-label=\"" + quoted(aria) + "\"]";
const name = el.getAttribute("name");
if (name) return el.tagName.toLowerCase() + "[name=\"" + quoted(name) + "\"]";
const placeholder = el.getAttribute("placeholder");
if (placeholder) return el.tagName.toLowerCase() + "[placeholder=\"" + quoted(placeholder) + "\"]";
const role = el.getAttribute("role");
const text = (el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 100);
if ((role || /^(BUTTON|A)$/u.test(el.tagName)) && text) {
const roleName = role || (el.tagName === "A" ? "link" : "button");
return "role=" + roleName + "[name=\"" + quoted(text) + "\"]";
}
const parts = [];
let current = el;
while (current && current.nodeType === 1 && parts.length < 6) {
let part = current.tagName.toLowerCase();
const parent = current.parentElement;
if (parent) {
const peers = Array.from(parent.children).filter((x) => x.tagName === current.tagName);
if (peers.length > 1) part += ":nth-of-type(" + (peers.indexOf(current) + 1) + ")";
}
parts.unshift(part);
current = parent;
if (current && current.id) { parts.unshift("#" + cssEscape(current.id)); break; }
}
return parts.join(" > ");
};
if (!window[KEY]) {
const events = [];
const push = (verb, target, value) => events.push({ at: new Date().toISOString(), verb, target, ...(value === undefined ? {} : { value }) });
push("goto", location.href, location.href);
document.addEventListener("click", (event) => {
if (event.detail > 1) return;
push("click", structural(event.target));
}, true);
document.addEventListener("dblclick", (event) => push("dblclick", structural(event.target)), true);
document.addEventListener("change", (event) => {
const el = event.target;
if (!(el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement)) return;
if (el instanceof HTMLInputElement && el.type === "password") push("fill_secret", structural(el));
else if (el instanceof HTMLSelectElement) push("selectoption", structural(el), el.value);
else push("fill", structural(el), el.value);
}, true);
document.addEventListener("keydown", (event) => {
if (["Enter", "Escape"].includes(event.key)) push("press", structural(event.target), event.key);
}, true);
window[KEY] = { drain: () => events.splice(0, events.length) };
}
return JSON.stringify(window[KEY].drain());
})()`;
function browserEval(script: string): string {
return runBrowser("eval -b", [Buffer.from(script).toString("base64")]);
}
function collectSteps(raw: unknown, seen: LessonStep[]): void {
if (!Array.isArray(raw)) return;
for (const row of raw) {
if (!row || typeof row !== "object") continue;
const item = row as Record<string, unknown>;
const verb = typeof item.verb === "string" ? item.verb as LessonVerb : null;
const target = typeof item.target === "string" ? item.target : "";
if (!verb || !target || target.includes("[ref=e")) continue;
const value = typeof item.value === "string" ? item.value : undefined;
const previous = seen[seen.length - 1];
if (previous && previous.verb === verb && previous.target === target && previous.value === value) continue;
seen.push({
id: `step_${seen.length + 1}`,
at: typeof item.at === "string" ? item.at : new Date().toISOString(),
verb,
target,
...(verb === "fill_secret" || value === undefined ? {} : { value }),
});
}
}
function parametersOf(steps: LessonStep[]): LessonParameter[] {
const used = new Set<string>();
const parameters: LessonParameter[] = [];
for (const step of steps) {
if (step.verb !== "fill" || step.value === undefined) continue;
let name = slug(step.target.replace(/password/giu, "field"), `field_${parameters.length + 1}`);
while (used.has(name)) name = `${name}_${parameters.length + 1}`;
used.add(name);
parameters.push({ name, target: step.target, recorded: step.value });
}
return parameters;
}
function endpointsOf(requests: CapturedRequest[]): LessonEndpoint[] {
const seen = new Set<string>();
const out: LessonEndpoint[] = [];
for (const request of requests) {
const type = request.resource_type?.toLowerCase();
if (type !== "xhr" && type !== "fetch") continue;
const normalized = normalizeCapturedRequest(request);
const key = `${normalized.method.toUpperCase()} ${normalized.url} ${normalized.post_data ?? ""}`;
if (seen.has(key)) continue;
seen.add(key);
out.push({ ...normalized, id: `endpoint_${out.length + 1}` });
}
return out;
}
/**
* Open a headed browser under a stable site profile and record until aborted,
* the person closes the browser, or maxMs elapses. SIGINT finalizes the CLI.
*/
export async function recordLesson(url: string, name: string, opts: RecordOptions = {}): Promise<LibrettoLesson> {
assertLessonName(name);
const parsed = new URL(url);
if (!/^https?:$/u.test(parsed.protocol)) throw new Error("record URL must use http or https");
const dir = lessonDir(name);
if (existsSync(lessonPath(name))) throw new Error(`Lesson already exists: ${name}`);
ensurePrivateDir(dir);
const profile = join(rootDir(), "profiles", profileName(url));
ensurePrivateDir(profile);
const authState = join(dir, "auth-state.json");
const recordedAt = new Date().toISOString();
const steps: LessonStep[] = [];
let requests: CapturedRequest[] = [];
const oldProfile = process.env.AGENT_BROWSER_PROFILE;
const oldHeaded = process.env.AGENT_BROWSER_HEADED;
process.env.AGENT_BROWSER_PROFILE = profile;
process.env.AGENT_BROWSER_HEADED = "true";
if (!opts.quiet) {
console.error(`Recording "${name}" in a headed browser. Do the job once, then close the window or press Ctrl-C.`);
}
try {
clearRequests();
navigate(url);
let nextStateSave = 0;
const started = Date.now();
while (!opts.signal?.aborted && (!opts.maxMs || Date.now() - started < opts.maxMs)) {
try {
collectSteps(parseEvalResult(browserEval(RECORDER_SCRIPT)), steps);
requests = captureRequests();
if (Date.now() >= nextStateSave) {
runBrowser("state save", [authState]);
if (existsSync(authState)) chmodSync(authState, 0o600);
nextStateSave = Date.now() + 10_000;
}
} catch (error) {
if (steps.length || requests.length) break;
throw error;
}
await sleep(opts.pollMs ?? 750);
}
try { collectSteps(parseEvalResult(browserEval(RECORDER_SCRIPT)), steps); } catch { /* window may be closed */ }
try { requests = captureRequests(); } catch { /* retain last complete buffer */ }
try { runBrowser("state save", [authState]); } catch { /* retain periodic state */ }
} finally {
try { close(); } catch { /* person may already have closed it */ }
if (oldProfile === undefined) delete process.env.AGENT_BROWSER_PROFILE;
else process.env.AGENT_BROWSER_PROFILE = oldProfile;
if (oldHeaded === undefined) delete process.env.AGENT_BROWSER_HEADED;
else process.env.AGENT_BROWSER_HEADED = oldHeaded;
}
const endpoints = endpointsOf(requests);
if (steps.length === 0 && endpoints.length === 0) {
throw new Error(`Recording "${name}" captured no steps and no XHR/fetch calls; no empty lesson was written.`);
}
const lesson: LibrettoLesson = {
version: VERSION,
name,
source_url: url,
profile,
recorded_at: recordedAt,
completed_at: new Date().toISOString(),
auth_state: authState,
steps,
parameters: parametersOf(steps),
endpoints,
request_count_total: requests.length,
};
writePrivateJson(join(dir, "raw-capture.json"), { recorded_at: recordedAt, requests });
writePrivateJson(lessonPath(name), lesson);
return lesson;
}
/** List all complete lessons. Half-written directories are ignored. */
export function lessons(): LessonSummary[] {
if (!existsSync(lessonsDir())) return [];
const names = readdirSync(lessonsDir(), { withFileTypes: true });
const out: LessonSummary[] = [];
for (const entry of names) {
if (!entry.isDirectory() || !LESSON_NAME.test(entry.name) || !existsSync(lessonPath(entry.name))) continue;
try {
const item = loadLesson(entry.name);
out.push({
name: item.name,
source_url: item.source_url,
recorded_at: item.recorded_at,
step_count: item.steps.length,
endpoint_count: item.endpoints.length,
request_count_total: item.request_count_total,
auth_state_present: existsSync(item.auth_state),
});
} catch { /* a malformed lesson is not advertised */ }
}
return out.sort((a, b) => b.recorded_at.localeCompare(a.recorded_at));
}
/** Load one typed lesson. */
export function loadLesson(name: string): LibrettoLesson {
const path = lessonPath(name);
if (!existsSync(path)) throw new Error(`Lesson not found: ${name}`);
const lesson = readJson<LibrettoLesson>(path);
if (lesson.version !== VERSION || lesson.name !== name || !Array.isArray(lesson.steps) || !Array.isArray(lesson.endpoints)) {
throw new Error(`Lesson is malformed or uses an unsupported version: ${name}`);
}
return lesson;
}
/** Show the review-safe lesson shape. Headers, bodies, cookies, and values stay off stdout. */
export function lesson(name: string): {
name: string;
source_url: string;
recorded_at: string;
steps: Array<{ id: string; at: string; verb: LessonVerb; target: string; parameter?: string }>;
endpoints: Array<{ id: string; method: string; url: string; status?: number; resource_type: string }>;
} {
const item = loadLesson(name);
const parameterByTarget = new Map(item.parameters.map((p) => [p.target, p.name]));
return {
name: item.name,
source_url: item.source_url,
recorded_at: item.recorded_at,
steps: item.steps.map((step) => ({
id: step.id,
at: step.at,
verb: step.verb,
target: step.target,
...(parameterByTarget.has(step.target) ? { parameter: parameterByTarget.get(step.target)! } : {}),
})),
endpoints: item.endpoints.map((endpoint) => ({
id: endpoint.id,
method: endpoint.method,
url: endpoint.url,
status: endpoint.status,
resource_type: endpoint.resource_type,
})),
};
}
export function parseReplayArgs(text: string | undefined): ReplayArgs {
if (!text) return {};
if (text.trim().startsWith("{")) {
const parsed = JSON.parse(text) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Replay args JSON must be an object.");
return parsed as ReplayArgs;
}
const out: ReplayArgs = {};
for (const word of text.split(",")) {
const at = word.indexOf("=");
if (at <= 0) throw new Error("Replay args must be JSON or comma-separated key=value pairs.");
out[word.slice(0, at).trim()] = word.slice(at + 1);
}
return out;
}
function replaceRecorded(text: string, parameters: LessonParameter[], args: ReplayArgs): string {
let out = text;
for (const parameter of parameters) {
if (!(parameter.name in args)) continue;
const replacement = String(args[parameter.name] ?? "");
out = out.split(parameter.recorded).join(replacement);
out = out.split(encodeURIComponent(parameter.recorded)).join(encodeURIComponent(replacement));
}
return out;
}
function rewriteBody(body: string | undefined, parameters: LessonParameter[], args: ReplayArgs): string | undefined {
if (body === undefined) return undefined;
let output = replaceRecorded(body, parameters, args);
try {
const parsed = JSON.parse(output) as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
const object = parsed as Record<string, unknown>;
for (const [key, value] of Object.entries(args)) if (key in object) object[key] = value;
output = JSON.stringify(object);
}
} catch { /* form and text bodies are replaced as strings */ }
return output;
}
function assertAuthUsable(item: LibrettoLesson): void {
if (!existsSync(item.auth_state)) throw new SessionStaleError(item.name, "Its saved auth state is missing; record the job again.");
const state = readJson<{ cookies?: Array<{ expires?: number }> }>(item.auth_state);
const cookies = state.cookies ?? [];
if (cookies.length > 0 && cookies.every((cookie) => typeof cookie.expires === "number" && cookie.expires >= 0 && cookie.expires * 1000 <= Date.now())) {
throw new SessionStaleError(item.name, "Every captured cookie has expired; sign in and record the job again.");
}
}
function responseLooksLoggedOut(response: ReplayResponse, originalUrl: string): boolean {
if (response.status === 401 || response.status === 403) return true;
try {
const original = new URL(originalUrl);
const final = new URL(response.url);
if (final.host !== original.host && /(?:login|signin|sign-in|auth)/iu.test(final.href)) return true;
} catch { /* status remains authoritative */ }
return /text\/html/iu.test(response.contentType) && /(?:log in|sign in|session expired)/iu.test(response.body.slice(0, 4000));
}
/** Replay every learned XHR/fetch endpoint with plain fetch and captured auth. */
export async function replayLesson(name: string, args: ReplayArgs = {}, opts: ReplayOptions = {}): Promise<ReplayResult> {
const item = loadLesson(name);
if (item.endpoints.length === 0) throw new Error(`Lesson "${name}" learned no XHR/fetch endpoints.`);
assertAuthUsable(item);
const unsafe = item.endpoints.some((endpoint) => !SAFE_METHODS.has(endpoint.method.toUpperCase()));
if (unsafe && !opts.now) {
throw new Error(`Lesson "${name}" includes a non-read HTTP method. Replay is refused until the governed caller passes --now.`);
}
const items: ReplayItem[] = [];
for (const endpoint of item.endpoints) {
const requestUrl = replaceRecorded(endpoint.url, item.parameters, args);
const body = rewriteBody(endpoint.post_data, item.parameters, args);
const response = await replayCapturedRequest(endpoint, item.auth_state, {
urlRewrite: () => requestUrl,
...(body === undefined ? {} : { body }),
});
if (responseLooksLoggedOut(response, endpoint.url)) {
throw new SessionStaleError(name, `Endpoint ${endpoint.id} answered ${response.status} or redirected to sign-in; sign in and record again.`);
}
items.push({ ...response, id: endpoint.id, method: endpoint.method, requestUrl });
}
return { lesson: name, request_count: items.length, items };
}
export function summarizeReplay(result: ReplayResult): {
lesson: string;
request_count: number;
ok_count: number;
statuses: Record<string, number>;
body_bytes: number;
} {
const statuses: Record<string, number> = {};
let bodyBytes = 0;
for (const item of result.items) {
statuses[String(item.status)] = (statuses[String(item.status)] ?? 0) + 1;
bodyBytes += Buffer.byteLength(item.body);
}
return {
lesson: result.lesson,
request_count: result.request_count,
ok_count: result.items.filter((item) => item.status >= 200 && item.status < 300).length,
statuses,
body_bytes: bodyBytes,
};
}
function camelVerb(verb: string): string {
return verb.replace(/-([a-z0-9])/gu, (_match, letter: string) => letter.toUpperCase());
}
function lessonEffect(item: LibrettoLesson): "read" | "write" {
return item.endpoints.every((endpoint) => SAFE_METHODS.has(endpoint.method.toUpperCase())) ? "read" : "write";
}
function insertBeforeIndex(text: string, addition: string): string {
const marker = "<!-- SKILL-INDEX-START -->";
const at = text.indexOf(marker);
return at >= 0 ? `${text.slice(0, at).trimEnd()}\n\n${addition}\n\n${text.slice(at)}` : `${text.trimEnd()}\n\n${addition}\n`;
}
function managedApi(skill: string, records: Array<{ lesson: string; verb: string; effect: "read" | "write" }>): string {
const functions = records.map((record) => {
const fn = camelVerb(record.verb);
return `export async function ${fn}(args: ReplayArgs = {}, opts: ReplayOptions = {}): Promise<ReplayResult> {\n return replayLibrettoLesson(${JSON.stringify(record.lesson)}, args, opts);\n}`;
}).join("\n\n");
const contract = records.map((record) => ` ${JSON.stringify(record.verb)}: { args: ["args?"], effect: ${JSON.stringify(record.effect)}, flags: { json: "--json", now: "--now" } },`).join("\n");
const cases = records.map((record) => {
const fn = camelVerb(record.verb);
return ` case ${JSON.stringify(record.verb)}: {\n const payload = args.find((arg) => !arg.startsWith("--"));\n const result = await ${fn}(parseReplayArgs(payload), { now: args.includes("--now") });\n console.log(JSON.stringify(summarizeReplay(result), null, args.includes("--json") ? 0 : 2));\n break;\n }`;
}).join("\n");
return `#!/usr/bin/env npx tsx\n/** ${skill}/api.ts -- Libretto-promoted browser lessons. Generated by snappy-libretto. */\nimport { realpathSync } from "fs";\nimport { env } from "../snappy-settings/load.ts";\nimport { replayLesson as replayLibrettoLesson, parseReplayArgs, summarizeReplay, type ReplayArgs, type ReplayOptions, type ReplayResult } from "../snappy-libretto/api.ts";\nvoid env;\n\n${functions}\n\nexport const HAND_CONTRACT = {\n skill: ${JSON.stringify(skill)},\n managed: false,\n requires: [] as string[],\n verbs: {\n${contract}\n },\n} as const;\n\nif (import.meta.url === \`file://\${realpathSync(process.argv[1])}\` && process.argv[2] === "contract") {\n console.log(JSON.stringify(HAND_CONTRACT, null, 2));\n process.exit(0);\n}\n\nif (import.meta.url === \`file://\${realpathSync(process.argv[1])}\`) {\n (async () => {\n const [, , cmd, ...args] = process.argv;\n switch (cmd) {\n${cases}\n default:\n console.log(${JSON.stringify(`Usage: npx tsx api.ts [${records.map((record) => record.verb).join("|")}|contract] [args] [--now] [--json]`)});\n if (cmd) process.exitCode = 1;\n }\n })().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); });\n}\n`;
}
/** Promote a lesson through snappy-skill into a Libretto-managed target hand. */
export function promote(name: string, skill: string, verb: string): PromoteResult {
const item = loadLesson(name);
if (!SKILL_NAME.test(skill)) throw new Error("Target skill must be a lowercase snappy-* name.");
if (!VERB_NAME.test(verb)) throw new Error("Verb must be lowercase kebab-case.");
if (item.endpoints.length === 0) throw new Error(`Lesson "${name}" has no replayable endpoints to promote.`);
const effect = lessonEffect(item);
const scaffolded = scaffold(skill, { quiet: true });
const target = scaffolded.path;
const manifestPath = join(target, "libretto-verbs.json");
const previous = existsSync(manifestPath)
? readJson<Array<{ lesson: string; verb: string; effect: "read" | "write" }>>(manifestPath)
: [];
if (previous.some((record) => record.verb === verb && record.lesson !== name)) {
throw new Error(`Target ${skill} already maps verb "${verb}" to another lesson.`);
}
const apiPath = join(target, "api.ts");
const existingApi = readFileSync(apiPath, "utf-8");
const librettoManaged = existingApi.includes("Generated by snappy-libretto") || existingApi.includes("This is a scaffolded stub");
if (!librettoManaged) {
throw new Error(`Target ${skill} already owns a hand-written api.ts. Refusing to splice generated code into it; promote into a dedicated skill.`);
}
const records = previous.filter((record) => record.verb !== verb);
records.push({ lesson: name, verb, effect });
records.sort((a, b) => a.verb.localeCompare(b.verb));
writeJson(manifestPath, records);
writeFileSync(apiPath, managedApi(skill, records));
const section = `## Libretto-promoted verbs\n\n${records.map((record) => `- \`${record.verb}\` replays lesson \`${record.lesson}\` by plain fetch. Effect: \`${record.effect}\`.`).join("\n")}\n\nDo not hand-edit the generated API. Re-run \`snappy-libretto promote\` from the source lesson.`;
const skillMdPath = join(target, "SKILL.md");
const agentsPath = join(target, "AGENTS.md");
for (const path of [skillMdPath, agentsPath]) {
let text = readFileSync(path, "utf-8");
text = text.replace(/\n## Libretto-promoted verbs[\s\S]*?(?=\n## |\n<!-- SKILL-INDEX-START -->|$)/u, "");
writeFileSync(path, insertBeforeIndex(text, section));
}
return { lesson: name, skill, verb, effect, files: [apiPath, manifestPath, skillMdPath, agentsPath] };
}
export const HAND_CONTRACT = {
skill: "snappy-libretto",
description: "Record a browser job once in a headed, persistent agent-browser profile, review the typed lesson and learned XHR/fetch endpoints, replay it with plain fetch and captured auth, then promote it into a typed snappy-* verb. Triggers on: libretto, record browser job, teach this Mac, watch me once, browser lesson, learned endpoint, replay recording, promote lesson.",
managed: false,
/** NOTHING. ⟨lane browse-split, 2026-09-09⟩ This hand spends no credential:
* the browser carries its own logged-in state in a `--state` file and the
* replay road re-sends the cookies it recorded. It declared Canva's two
* OAuth keys until today for one reason — it imported the browser session
* functions off `snappy-browse/api.ts`, which also carries the Canva
* Connect road, and a requirement is read one hop through an import. The
* import now names `snappy-browse/session.ts`, which reads no key, so the
* declaration is honest at []. */
requires: [] as string[],
refusals: refusalTable("unknown_verb", "missing_argument", "missing_credential", "not_found", "approval_required", "invalid_argument"),
verbs: {
record: {
args: ["url", "name"], effect: "write-reversible", flags: { json: "--json" },
class: "additive-write", execution: "call", openWorld: true,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { url: { type: "string", description: "Page recorded while the person performs the read once" }, name: { type: "string", description: "Name the recorded lesson is filed under" } } },
},
lessons: {
args: [], effect: "read", flags: { json: "--json" },
class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
},
lesson: {
args: ["name"], effect: "read", flags: { json: "--json" },
class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { name: { type: "string", description: "Lesson whose steps are returned" } } },
},
replay: {
args: ["name", "args?"], effect: "write", flags: { json: "--json", now: "--now" },
class: "additive-write", execution: "call", openWorld: true,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { name: { type: "string", description: "Lesson to replay" }, args: { type: "string", description: "JSON object of values substituted into the lesson's requests" } } },
},
promote: {
args: ["name", "skill", "verb"], effect: "write-reversible", flags: { json: "--json" },
class: "additive-write", execution: "call", openWorld: false,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { name: { type: "string", description: "Lesson promoted into a hand" }, skill: { type: "string", description: "Skill that gains the verb" }, verb: { type: "string", description: "Verb name the lesson is published as" } } },
},
},
} as const;
function printJson(value: unknown, compact: boolean): void {
console.log(JSON.stringify(value, null, compact ? 0 : 2));
}
if (import.meta.url === `file://${realpathSync(process.argv[1])}` && process.argv[2] === "contract") {
printJson(HAND_CONTRACT, false);
process.exit(0);
}
if (import.meta.url === `file://${realpathSync(process.argv[1])}`) {
(async () => {
const [, , cmd, ...words] = process.argv;
const json = words.includes("--json");
const args = words.filter((word) => !word.startsWith("--"));
switch (cmd) {
case "record": {
const [url, name] = args;
if (!url || !name) throw new Error("Usage: api.ts record <url> <name> [--json]");
const controller = new AbortController();
process.once("SIGINT", () => controller.abort());
const item = await recordLesson(url, name, { signal: controller.signal, quiet: json });
const summary = { name: item.name, step_count: item.steps.length, endpoint_count: item.endpoints.length, request_count_total: item.request_count_total };
if (json) printJson(summary, true);
else console.log(`recorded ${summary.step_count} steps and ${summary.endpoint_count} endpoints from ${summary.request_count_total} requests -> ${item.name}`);
break;
}
case "lessons": {
const items = lessons();
// THE ENVELOPE RIDES BESIDE THE ROWS ⟨R30, 2026-09-09⟩. A lesson is a
// recording OF SOMEBODY ELSE'S SITE: `source_url`, the step targets and
// every learned endpoint are the vendor page's own strings, captured
// through a browser and replayed later by plain fetch. `lesson_count`
// and `lessons` keep their names and values; `evidence` is a NEW
// top-level sibling. The road named is the on-disk lesson store,
// because that is the door this read actually opened — the vendor each
// row came through is on the row itself, as `source_url`.
if (json) printJson({
lesson_count: items.length,
lessons: items,
evidence: evidence({ source: "libretto.lesson-store.list", count: items.length }),
}, true);
else {
console.log(`${items.length} lesson${items.length === 1 ? "" : "s"}`);
for (const item of items) console.log(`${item.name}\t${item.step_count} steps\t${item.endpoint_count} endpoints`);
}
break;
}
case "lesson": {
const [name] = args;
if (!name) throw new Error("Usage: api.ts lesson <name> [--json]");
const item = lesson(name);
// ONE LESSON IS ONE RECORD, so `count` is 1: the steps and endpoints
// under it are that lesson's body, not a second population.
if (json) printJson({
...item,
evidence: evidence({ source: "libretto.lesson-store.get", count: 1 }),
}, true);
else {
console.log(`${item.name}: ${item.steps.length} steps, ${item.endpoints.length} endpoints`);
for (const step of item.steps) console.log(`${step.id}\t${step.verb}\t${step.target}${step.parameter ? `\targ=${step.parameter}` : ""}`);
for (const endpoint of item.endpoints) console.log(`${endpoint.id}\t${endpoint.method}\t${endpoint.url}`);
}
break;
}
case "replay": {
const [name, payload] = args;
if (!name) throw new Error("Usage: api.ts replay <name> [args-json|key=value] [--now] [--json]");
// `replay` IS UNSTAMPED ON PURPOSE ⟨R30⟩: it is not a read verb (its
// class is additive-write, because a replayed request acts on the
// vendor), and `summarizeReplay` deliberately returns COUNTS ONLY —
// request_count, ok_count, statuses, body_bytes. No response body and
// no vendor sentence crosses this boundary, so there is nothing here
// for a declaration to be about.
const result = await replayLesson(name, parseReplayArgs(payload), { now: words.includes("--now") });
printJson(summarizeReplay(result), json);
break;
}
case "promote": {
const [name, skill, verb] = args;
if (!name || !skill || !verb) throw new Error("Usage: api.ts promote <name> <skill> <verb> [--json]");
const result = promote(name, skill, verb);
if (json) printJson(result, true);
else console.log(`promoted ${result.lesson} -> ${result.skill}.${result.verb} (${result.effect})`);
break;
}
default:
console.log("Usage: npx tsx api.ts [record|lessons|lesson|replay|promote|contract] ...");
if (cmd) process.exitCode = 1;
}
})().catch((error) => {
const code = error instanceof SessionStaleError ? error.code : "libretto_error";
if (process.argv.includes("--json")) printJson({ ok: false, code, error: error instanceof Error ? error.message : String(error) }, true);
else console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
}
#!/usr/bin/env npx tsx
/**
* snappy-libretto/api.ts -- Record a browser job once, turn it into a typed
* lesson, replay its learned calls with plain fetch, and promote that replay
* into a snappy-* hand.
*
* Capture composes snappy-browse. Request normalization and fetch replay compose
* snappy-api-sniffer. Target creation composes snappy-skill.
*/
import {
chmodSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
realpathSync,
renameSync,
writeFileSync,
} from "fs";
import { homedir } from "os";
import { basename, dirname, join } from "path";
import { env } from "../snappy-settings/load.ts";
import {
normalizeCapturedRequest,
replayCapturedRequest,
type ReplayResponse,
} from "../snappy-api-sniffer/api.ts";
import {
captureRequests,
clearRequests,
close,
navigate,
runBrowser,
type CapturedRequest,
} from "../snappy-browse/session.ts";
import { scaffold } from "../snappy-skill/api.ts";
import { evidence } from "../snappy-settings/evidence-envelope.ts";
import { refusalTable } from "../snappy-settings/refusal-codes.ts";
void env;
const VERSION = 1 as const;
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
const LESSON_NAME = /^[a-z0-9][a-z0-9._-]{0,79}$/u;
const SKILL_NAME = /^snappy-[a-z0-9][a-z0-9-]*$/u;
const VERB_NAME = /^[a-z][a-z0-9-]*$/u;
export type LessonVerb =
| "goto"
| "click"
| "dblclick"
| "fill"
| "fill_secret"
| "press"
| "selectoption";
export interface LessonStep {
id: string;
at: string;
verb: LessonVerb;
target: string;
value?: string;
}
export interface LessonParameter {
name: string;
target: string;
recorded: string;
}
export interface LessonEndpoint extends CapturedRequest {
id: string;
}
export interface LibrettoLesson {
version: typeof VERSION;
name: string;
source_url: string;
profile: string;
recorded_at: string;
completed_at: string;
auth_state: string;
steps: LessonStep[];
parameters: LessonParameter[];
endpoints: LessonEndpoint[];
request_count_total: number;
}
export interface LessonSummary {
name: string;
source_url: string;
recorded_at: string;
step_count: number;
endpoint_count: number;
request_count_total: number;
auth_state_present: boolean;
}
export type ReplayArgs = Record<string, string | number | boolean | null>;
export interface ReplayItem extends ReplayResponse {
id: string;
method: string;
requestUrl: string;
}
export interface ReplayResult {
lesson: string;
request_count: number;
items: ReplayItem[];
}
export interface ReplayOptions {
now?: boolean;
}
export interface RecordOptions {
signal?: AbortSignal;
pollMs?: number;
maxMs?: number;
quiet?: boolean;
}
export interface PromoteResult {
lesson: string;
skill: string;
verb: string;
effect: "read" | "write";
files: string[];
}
export class SessionStaleError extends Error {
readonly code = "session_stale";
readonly lessonName: string;
constructor(lessonName: string, detail: string) {
super(`Lesson "${lessonName}" refused: captured browser session is stale. ${detail}`);
this.lessonName = lessonName;
this.name = "SessionStaleError";
}
}
function rootDir(): string {
return process.env.SNAPPY_LIBRETTO_HOME || join(homedir(), "snappy/libretto");
}
function lessonsDir(): string {
return join(rootDir(), "lessons");
}
function lessonDir(name: string): string {
assertLessonName(name);
return join(lessonsDir(), name);
}
function lessonPath(name: string): string {
return join(lessonDir(name), "lesson.json");
}
function assertLessonName(name: string): void {
if (!LESSON_NAME.test(name)) {
throw new Error("Lesson name must use 1-80 lowercase letters, digits, dot, underscore, or hyphen.");
}
}
function ensurePrivateDir(path: string): void {
mkdirSync(path, { recursive: true, mode: 0o700 });
chmodSync(path, 0o700);
}
function writePrivateJson(path: string, value: unknown): void {
ensurePrivateDir(dirname(path));
const temp = join(dirname(path), `.${basename(path)}.${process.pid}.tmp`);
writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
chmodSync(temp, 0o600);
renameSync(temp, path);
}
function writeJson(path: string, value: unknown): void {
mkdirSync(dirname(path), { recursive: true });
const temp = join(dirname(path), `.${basename(path)}.${process.pid}.tmp`);
writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`);
renameSync(temp, path);
}
function readJson<T>(path: string): T {
return JSON.parse(readFileSync(path, "utf-8")) as T;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function slug(value: string, fallback: string): string {
const out = value
.toLowerCase()
.replace(/[^a-z0-9]+/gu, "_")
.replace(/^_+|_+$/gu, "")
.slice(0, 48);
return out || fallback;
}
function profileName(url: string): string {
return slug(new URL(url).hostname, "browser");
}
function parseEvalResult(raw: string): unknown {
const clean = raw.trim();
const candidates: unknown[] = [];
try { candidates.push(JSON.parse(clean)); } catch { /* agent-browser may print a scalar */ }
for (const candidate of candidates) {
if (candidate && typeof candidate === "object" && "data" in candidate) {
const data = (candidate as { data?: { result?: unknown } }).data;
if (data && "result" in data) return typeof data.result === "string" ? JSON.parse(data.result) : data.result;
}
if (typeof candidate === "string") {
try { return JSON.parse(candidate); } catch { return candidate; }
}
return candidate;
}
const first = clean.indexOf("[");
const last = clean.lastIndexOf("]");
if (first >= 0 && last > first) return JSON.parse(clean.slice(first, last + 1));
throw new Error(`Could not parse recorder response (${clean.slice(0, 120)})`);
}
const RECORDER_SCRIPT = String.raw`(() => {
const KEY = "__snappyLibrettoRecorderV1";
const cssEscape = (value) => {
if (globalThis.CSS && typeof CSS.escape === "function") return CSS.escape(value);
return String(value).replace(/[^a-zA-Z0-9_-]/g, (c) => "\\" + c.codePointAt(0).toString(16) + " ");
};
const quoted = (value) => String(value).replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
const structural = (node) => {
const el = node instanceof Element ? node : node && node.parentElement;
if (!el) return "(target unavailable)";
for (const key of ["data-testid", "data-test", "data-qa"]) {
const value = el.getAttribute(key);
if (value) return "[" + key + "=\"" + quoted(value) + "\"]";
}
if (el.id) return "#" + cssEscape(el.id);
const aria = el.getAttribute("aria-label");
if (aria) return "[aria-label=\"" + quoted(aria) + "\"]";
const name = el.getAttribute("name");
if (name) return el.tagName.toLowerCase() + "[name=\"" + quoted(name) + "\"]";
const placeholder = el.getAttribute("placeholder");
if (placeholder) return el.tagName.toLowerCase() + "[placeholder=\"" + quoted(placeholder) + "\"]";
const role = el.getAttribute("role");
const text = (el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 100);
if ((role || /^(BUTTON|A)$/u.test(el.tagName)) && text) {
const roleName = role || (el.tagName === "A" ? "link" : "button");
return "role=" + roleName + "[name=\"" + quoted(text) + "\"]";
}
const parts = [];
let current = el;
while (current && current.nodeType === 1 && parts.length < 6) {
let part = current.tagName.toLowerCase();
const parent = current.parentElement;
if (parent) {
const peers = Array.from(parent.children).filter((x) => x.tagName === current.tagName);
if (peers.length > 1) part += ":nth-of-type(" + (peers.indexOf(current) + 1) + ")";
}
parts.unshift(part);
current = parent;
if (current && current.id) { parts.unshift("#" + cssEscape(current.id)); break; }
}
return parts.join(" > ");
};
if (!window[KEY]) {
const events = [];
const push = (verb, target, value) => events.push({ at: new Date().toISOString(), verb, target, ...(value === undefined ? {} : { value }) });
push("goto", location.href, location.href);
document.addEventListener("click", (event) => {
if (event.detail > 1) return;
push("click", structural(event.target));
}, true);
document.addEventListener("dblclick", (event) => push("dblclick", structural(event.target)), true);
document.addEventListener("change", (event) => {
const el = event.target;
if (!(el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement)) return;
if (el instanceof HTMLInputElement && el.type === "password") push("fill_secret", structural(el));
else if (el instanceof HTMLSelectElement) push("selectoption", structural(el), el.value);
else push("fill", structural(el), el.value);
}, true);
document.addEventListener("keydown", (event) => {
if (["Enter", "Escape"].includes(event.key)) push("press", structural(event.target), event.key);
}, true);
window[KEY] = { drain: () => events.splice(0, events.length) };
}
return JSON.stringify(window[KEY].drain());
})()`;
function browserEval(script: string): string {
return runBrowser("eval -b", [Buffer.from(script).toString("base64")]);
}
function collectSteps(raw: unknown, seen: LessonStep[]): void {
if (!Array.isArray(raw)) return;
for (const row of raw) {
if (!row || typeof row !== "object") continue;
const item = row as Record<string, unknown>;
const verb = typeof item.verb === "string" ? item.verb as LessonVerb : null;
const target = typeof item.target === "string" ? item.target : "";
if (!verb || !target || target.includes("[ref=e")) continue;
const value = typeof item.value === "string" ? item.value : undefined;
const previous = seen[seen.length - 1];
if (previous && previous.verb === verb && previous.target === target && previous.value === value) continue;
seen.push({
id: `step_${seen.length + 1}`,
at: typeof item.at === "string" ? item.at : new Date().toISOString(),
verb,
target,
...(verb === "fill_secret" || value === undefined ? {} : { value }),
});
}
}
function parametersOf(steps: LessonStep[]): LessonParameter[] {
const used = new Set<string>();
const parameters: LessonParameter[] = [];
for (const step of steps) {
if (step.verb !== "fill" || step.value === undefined) continue;
let name = slug(step.target.replace(/password/giu, "field"), `field_${parameters.length + 1}`);
while (used.has(name)) name = `${name}_${parameters.length + 1}`;
used.add(name);
parameters.push({ name, target: step.target, recorded: step.value });
}
return parameters;
}
function endpointsOf(requests: CapturedRequest[]): LessonEndpoint[] {
const seen = new Set<string>();
const out: LessonEndpoint[] = [];
for (const request of requests) {
const type = request.resource_type?.toLowerCase();
if (type !== "xhr" && type !== "fetch") continue;
const normalized = normalizeCapturedRequest(request);
const key = `${normalized.method.toUpperCase()} ${normalized.url} ${normalized.post_data ?? ""}`;
if (seen.has(key)) continue;
seen.add(key);
out.push({ ...normalized, id: `endpoint_${out.length + 1}` });
}
return out;
}
/**
* Open a headed browser under a stable site profile and record until aborted,
* the person closes the browser, or maxMs elapses. SIGINT finalizes the CLI.
*/
export async function recordLesson(url: string, name: string, opts: RecordOptions = {}): Promise<LibrettoLesson> {
assertLessonName(name);
const parsed = new URL(url);
if (!/^https?:$/u.test(parsed.protocol)) throw new Error("record URL must use http or https");
const dir = lessonDir(name);
if (existsSync(lessonPath(name))) throw new Error(`Lesson already exists: ${name}`);
ensurePrivateDir(dir);
const profile = join(rootDir(), "profiles", profileName(url));
ensurePrivateDir(profile);
const authState = join(dir, "auth-state.json");
const recordedAt = new Date().toISOString();
const steps: LessonStep[] = [];
let requests: CapturedRequest[] = [];
const oldProfile = process.env.AGENT_BROWSER_PROFILE;
const oldHeaded = process.env.AGENT_BROWSER_HEADED;
process.env.AGENT_BROWSER_PROFILE = profile;
process.env.AGENT_BROWSER_HEADED = "true";
if (!opts.quiet) {
console.error(`Recording "${name}" in a headed browser. Do the job once, then close the window or press Ctrl-C.`);
}
try {
clearRequests();
navigate(url);
let nextStateSave = 0;
const started = Date.now();
while (!opts.signal?.aborted && (!opts.maxMs || Date.now() - started < opts.maxMs)) {
try {
collectSteps(parseEvalResult(browserEval(RECORDER_SCRIPT)), steps);
requests = captureRequests();
if (Date.now() >= nextStateSave) {
runBrowser("state save", [authState]);
if (existsSync(authState)) chmodSync(authState, 0o600);
nextStateSave = Date.now() + 10_000;
}
} catch (error) {
if (steps.length || requests.length) break;
throw error;
}
await sleep(opts.pollMs ?? 750);
}
try { collectSteps(parseEvalResult(browserEval(RECORDER_SCRIPT)), steps); } catch { /* window may be closed */ }
try { requests = captureRequests(); } catch { /* retain last complete buffer */ }
try { runBrowser("state save", [authState]); } catch { /* retain periodic state */ }
} finally {
try { close(); } catch { /* person may already have closed it */ }
if (oldProfile === undefined) delete process.env.AGENT_BROWSER_PROFILE;
else process.env.AGENT_BROWSER_PROFILE = oldProfile;
if (oldHeaded === undefined) delete process.env.AGENT_BROWSER_HEADED;
else process.env.AGENT_BROWSER_HEADED = oldHeaded;
}
const endpoints = endpointsOf(requests);
if (steps.length === 0 && endpoints.length === 0) {
throw new Error(`Recording "${name}" captured no steps and no XHR/fetch calls; no empty lesson was written.`);
}
const lesson: LibrettoLesson = {
version: VERSION,
name,
source_url: url,
profile,
recorded_at: recordedAt,
completed_at: new Date().toISOString(),
auth_state: authState,
steps,
parameters: parametersOf(steps),
endpoints,
request_count_total: requests.length,
};
writePrivateJson(join(dir, "raw-capture.json"), { recorded_at: recordedAt, requests });
writePrivateJson(lessonPath(name), lesson);
return lesson;
}
/** List all complete lessons. Half-written directories are ignored. */
export function lessons(): LessonSummary[] {
if (!existsSync(lessonsDir())) return [];
const names = readdirSync(lessonsDir(), { withFileTypes: true });
const out: LessonSummary[] = [];
for (const entry of names) {
if (!entry.isDirectory() || !LESSON_NAME.test(entry.name) || !existsSync(lessonPath(entry.name))) continue;
try {
const item = loadLesson(entry.name);
out.push({
name: item.name,
source_url: item.source_url,
recorded_at: item.recorded_at,
step_count: item.steps.length,
endpoint_count: item.endpoints.length,
request_count_total: item.request_count_total,
auth_state_present: existsSync(item.auth_state),
});
} catch { /* a malformed lesson is not advertised */ }
}
return out.sort((a, b) => b.recorded_at.localeCompare(a.recorded_at));
}
/** Load one typed lesson. */
export function loadLesson(name: string): LibrettoLesson {
const path = lessonPath(name);
if (!existsSync(path)) throw new Error(`Lesson not found: ${name}`);
const lesson = readJson<LibrettoLesson>(path);
if (lesson.version !== VERSION || lesson.name !== name || !Array.isArray(lesson.steps) || !Array.isArray(lesson.endpoints)) {
throw new Error(`Lesson is malformed or uses an unsupported version: ${name}`);
}
return lesson;
}
/** Show the review-safe lesson shape. Headers, bodies, cookies, and values stay off stdout. */
export function lesson(name: string): {
name: string;
source_url: string;
recorded_at: string;
steps: Array<{ id: string; at: string; verb: LessonVerb; target: string; parameter?: string }>;
endpoints: Array<{ id: string; method: string; url: string; status?: number; resource_type: string }>;
} {
const item = loadLesson(name);
const parameterByTarget = new Map(item.parameters.map((p) => [p.target, p.name]));
return {
name: item.name,
source_url: item.source_url,
recorded_at: item.recorded_at,
steps: item.steps.map((step) => ({
id: step.id,
at: step.at,
verb: step.verb,
target: step.target,
...(parameterByTarget.has(step.target) ? { parameter: parameterByTarget.get(step.target)! } : {}),
})),
endpoints: item.endpoints.map((endpoint) => ({
id: endpoint.id,
method: endpoint.method,
url: endpoint.url,
status: endpoint.status,
resource_type: endpoint.resource_type,
})),
};
}
export function parseReplayArgs(text: string | undefined): ReplayArgs {
if (!text) return {};
if (text.trim().startsWith("{")) {
const parsed = JSON.parse(text) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Replay args JSON must be an object.");
return parsed as ReplayArgs;
}
const out: ReplayArgs = {};
for (const word of text.split(",")) {
const at = word.indexOf("=");
if (at <= 0) throw new Error("Replay args must be JSON or comma-separated key=value pairs.");
out[word.slice(0, at).trim()] = word.slice(at + 1);
}
return out;
}
function replaceRecorded(text: string, parameters: LessonParameter[], args: ReplayArgs): string {
let out = text;
for (const parameter of parameters) {
if (!(parameter.name in args)) continue;
const replacement = String(args[parameter.name] ?? "");
out = out.split(parameter.recorded).join(replacement);
out = out.split(encodeURIComponent(parameter.recorded)).join(encodeURIComponent(replacement));
}
return out;
}
function rewriteBody(body: string | undefined, parameters: LessonParameter[], args: ReplayArgs): string | undefined {
if (body === undefined) return undefined;
let output = replaceRecorded(body, parameters, args);
try {
const parsed = JSON.parse(output) as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
const object = parsed as Record<string, unknown>;
for (const [key, value] of Object.entries(args)) if (key in object) object[key] = value;
output = JSON.stringify(object);
}
} catch { /* form and text bodies are replaced as strings */ }
return output;
}
function assertAuthUsable(item: LibrettoLesson): void {
if (!existsSync(item.auth_state)) throw new SessionStaleError(item.name, "Its saved auth state is missing; record the job again.");
const state = readJson<{ cookies?: Array<{ expires?: number }> }>(item.auth_state);
const cookies = state.cookies ?? [];
if (cookies.length > 0 && cookies.every((cookie) => typeof cookie.expires === "number" && cookie.expires >= 0 && cookie.expires * 1000 <= Date.now())) {
throw new SessionStaleError(item.name, "Every captured cookie has expired; sign in and record the job again.");
}
}
function responseLooksLoggedOut(response: ReplayResponse, originalUrl: string): boolean {
if (response.status === 401 || response.status === 403) return true;
try {
const original = new URL(originalUrl);
const final = new URL(response.url);
if (final.host !== original.host && /(?:login|signin|sign-in|auth)/iu.test(final.href)) return true;
} catch { /* status remains authoritative */ }
return /text\/html/iu.test(response.contentType) && /(?:log in|sign in|session expired)/iu.test(response.body.slice(0, 4000));
}
/** Replay every learned XHR/fetch endpoint with plain fetch and captured auth. */
export async function replayLesson(name: string, args: ReplayArgs = {}, opts: ReplayOptions = {}): Promise<ReplayResult> {
const item = loadLesson(name);
if (item.endpoints.length === 0) throw new Error(`Lesson "${name}" learned no XHR/fetch endpoints.`);
assertAuthUsable(item);
const unsafe = item.endpoints.some((endpoint) => !SAFE_METHODS.has(endpoint.method.toUpperCase()));
if (unsafe && !opts.now) {
throw new Error(`Lesson "${name}" includes a non-read HTTP method. Replay is refused until the governed caller passes --now.`);
}
const items: ReplayItem[] = [];
for (const endpoint of item.endpoints) {
const requestUrl = replaceRecorded(endpoint.url, item.parameters, args);
const body = rewriteBody(endpoint.post_data, item.parameters, args);
const response = await replayCapturedRequest(endpoint, item.auth_state, {
urlRewrite: () => requestUrl,
...(body === undefined ? {} : { body }),
});
if (responseLooksLoggedOut(response, endpoint.url)) {
throw new SessionStaleError(name, `Endpoint ${endpoint.id} answered ${response.status} or redirected to sign-in; sign in and record again.`);
}
items.push({ ...response, id: endpoint.id, method: endpoint.method, requestUrl });
}
return { lesson: name, request_count: items.length, items };
}
export function summarizeReplay(result: ReplayResult): {
lesson: string;
request_count: number;
ok_count: number;
statuses: Record<string, number>;
body_bytes: number;
} {
const statuses: Record<string, number> = {};
let bodyBytes = 0;
for (const item of result.items) {
statuses[String(item.status)] = (statuses[String(item.status)] ?? 0) + 1;
bodyBytes += Buffer.byteLength(item.body);
}
return {
lesson: result.lesson,
request_count: result.request_count,
ok_count: result.items.filter((item) => item.status >= 200 && item.status < 300).length,
statuses,
body_bytes: bodyBytes,
};
}
function camelVerb(verb: string): string {
return verb.replace(/-([a-z0-9])/gu, (_match, letter: string) => letter.toUpperCase());
}
function lessonEffect(item: LibrettoLesson): "read" | "write" {
return item.endpoints.every((endpoint) => SAFE_METHODS.has(endpoint.method.toUpperCase())) ? "read" : "write";
}
function insertBeforeIndex(text: string, addition: string): string {
const marker = "<!-- SKILL-INDEX-START -->";
const at = text.indexOf(marker);
return at >= 0 ? `${text.slice(0, at).trimEnd()}\n\n${addition}\n\n${text.slice(at)}` : `${text.trimEnd()}\n\n${addition}\n`;
}
function managedApi(skill: string, records: Array<{ lesson: string; verb: string; effect: "read" | "write" }>): string {
const functions = records.map((record) => {
const fn = camelVerb(record.verb);
return `export async function ${fn}(args: ReplayArgs = {}, opts: ReplayOptions = {}): Promise<ReplayResult> {\n return replayLibrettoLesson(${JSON.stringify(record.lesson)}, args, opts);\n}`;
}).join("\n\n");
const contract = records.map((record) => ` ${JSON.stringify(record.verb)}: { args: ["args?"], effect: ${JSON.stringify(record.effect)}, flags: { json: "--json", now: "--now" } },`).join("\n");
const cases = records.map((record) => {
const fn = camelVerb(record.verb);
return ` case ${JSON.stringify(record.verb)}: {\n const payload = args.find((arg) => !arg.startsWith("--"));\n const result = await ${fn}(parseReplayArgs(payload), { now: args.includes("--now") });\n console.log(JSON.stringify(summarizeReplay(result), null, args.includes("--json") ? 0 : 2));\n break;\n }`;
}).join("\n");
return `#!/usr/bin/env npx tsx\n/** ${skill}/api.ts -- Libretto-promoted browser lessons. Generated by snappy-libretto. */\nimport { realpathSync } from "fs";\nimport { env } from "../snappy-settings/load.ts";\nimport { replayLesson as replayLibrettoLesson, parseReplayArgs, summarizeReplay, type ReplayArgs, type ReplayOptions, type ReplayResult } from "../snappy-libretto/api.ts";\nvoid env;\n\n${functions}\n\nexport const HAND_CONTRACT = {\n skill: ${JSON.stringify(skill)},\n managed: false,\n requires: [] as string[],\n verbs: {\n${contract}\n },\n} as const;\n\nif (import.meta.url === \`file://\${realpathSync(process.argv[1])}\` && process.argv[2] === "contract") {\n console.log(JSON.stringify(HAND_CONTRACT, null, 2));\n process.exit(0);\n}\n\nif (import.meta.url === \`file://\${realpathSync(process.argv[1])}\`) {\n (async () => {\n const [, , cmd, ...args] = process.argv;\n switch (cmd) {\n${cases}\n default:\n console.log(${JSON.stringify(`Usage: npx tsx api.ts [${records.map((record) => record.verb).join("|")}|contract] [args] [--now] [--json]`)});\n if (cmd) process.exitCode = 1;\n }\n })().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); });\n}\n`;
}
/** Promote a lesson through snappy-skill into a Libretto-managed target hand. */
export function promote(name: string, skill: string, verb: string): PromoteResult {
const item = loadLesson(name);
if (!SKILL_NAME.test(skill)) throw new Error("Target skill must be a lowercase snappy-* name.");
if (!VERB_NAME.test(verb)) throw new Error("Verb must be lowercase kebab-case.");
if (item.endpoints.length === 0) throw new Error(`Lesson "${name}" has no replayable endpoints to promote.`);
const effect = lessonEffect(item);
const scaffolded = scaffold(skill, { quiet: true });
const target = scaffolded.path;
const manifestPath = join(target, "libretto-verbs.json");
const previous = existsSync(manifestPath)
? readJson<Array<{ lesson: string; verb: string; effect: "read" | "write" }>>(manifestPath)
: [];
if (previous.some((record) => record.verb === verb && record.lesson !== name)) {
throw new Error(`Target ${skill} already maps verb "${verb}" to another lesson.`);
}
const apiPath = join(target, "api.ts");
const existingApi = readFileSync(apiPath, "utf-8");
const librettoManaged = existingApi.includes("Generated by snappy-libretto") || existingApi.includes("This is a scaffolded stub");
if (!librettoManaged) {
throw new Error(`Target ${skill} already owns a hand-written api.ts. Refusing to splice generated code into it; promote into a dedicated skill.`);
}
const records = previous.filter((record) => record.verb !== verb);
records.push({ lesson: name, verb, effect });
records.sort((a, b) => a.verb.localeCompare(b.verb));
writeJson(manifestPath, records);
writeFileSync(apiPath, managedApi(skill, records));
const section = `## Libretto-promoted verbs\n\n${records.map((record) => `- \`${record.verb}\` replays lesson \`${record.lesson}\` by plain fetch. Effect: \`${record.effect}\`.`).join("\n")}\n\nDo not hand-edit the generated API. Re-run \`snappy-libretto promote\` from the source lesson.`;
const skillMdPath = join(target, "SKILL.md");
const agentsPath = join(target, "AGENTS.md");
for (const path of [skillMdPath, agentsPath]) {
let text = readFileSync(path, "utf-8");
text = text.replace(/\n## Libretto-promoted verbs[\s\S]*?(?=\n## |\n<!-- SKILL-INDEX-START -->|$)/u, "");
writeFileSync(path, insertBeforeIndex(text, section));
}
return { lesson: name, skill, verb, effect, files: [apiPath, manifestPath, skillMdPath, agentsPath] };
}
export const HAND_CONTRACT = {
skill: "snappy-libretto",
description: "Record a browser job once in a headed, persistent agent-browser profile, review the typed lesson and learned XHR/fetch endpoints, replay it with plain fetch and captured auth, then promote it into a typed snappy-* verb. Triggers on: libretto, record browser job, teach this Mac, watch me once, browser lesson, learned endpoint, replay recording, promote lesson.",
managed: false,
/** NOTHING. ⟨lane browse-split, 2026-09-09⟩ This hand spends no credential:
* the browser carries its own logged-in state in a `--state` file and the
* replay road re-sends the cookies it recorded. It declared Canva's two
* OAuth keys until today for one reason — it imported the browser session
* functions off `snappy-browse/api.ts`, which also carries the Canva
* Connect road, and a requirement is read one hop through an import. The
* import now names `snappy-browse/session.ts`, which reads no key, so the
* declaration is honest at []. */
requires: [] as string[],
refusals: refusalTable("unknown_verb", "missing_argument", "missing_credential", "not_found", "approval_required", "invalid_argument"),
verbs: {
record: {
args: ["url", "name"], effect: "write-reversible", flags: { json: "--json" },
class: "additive-write", execution: "call", openWorld: true,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { url: { type: "string", description: "Page recorded while the person performs the read once" }, name: { type: "string", description: "Name the recorded lesson is filed under" } } },
},
lessons: {
args: [], effect: "read", flags: { json: "--json" },
class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
},
lesson: {
args: ["name"], effect: "read", flags: { json: "--json" },
class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { name: { type: "string", description: "Lesson whose steps are returned" } } },
},
replay: {
args: ["name", "args?"], effect: "write", flags: { json: "--json", now: "--now" },
class: "additive-write", execution: "call", openWorld: true,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { name: { type: "string", description: "Lesson to replay" }, args: { type: "string", description: "JSON object of values substituted into the lesson's requests" } } },
},
promote: {
args: ["name", "skill", "verb"], effect: "write-reversible", flags: { json: "--json" },
class: "additive-write", execution: "call", openWorld: false,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { name: { type: "string", description: "Lesson promoted into a hand" }, skill: { type: "string", description: "Skill that gains the verb" }, verb: { type: "string", description: "Verb name the lesson is published as" } } },
},
},
} as const;
function printJson(value: unknown, compact: boolean): void {
console.log(JSON.stringify(value, null, compact ? 0 : 2));
}
if (import.meta.url === `file://${realpathSync(process.argv[1])}` && process.argv[2] === "contract") {
printJson(HAND_CONTRACT, false);
process.exit(0);
}
if (import.meta.url === `file://${realpathSync(process.argv[1])}`) {
(async () => {
const [, , cmd, ...words] = process.argv;
const json = words.includes("--json");
const args = words.filter((word) => !word.startsWith("--"));
switch (cmd) {
case "record": {
const [url, name] = args;
if (!url || !name) throw new Error("Usage: api.ts record <url> <name> [--json]");
const controller = new AbortController();
process.once("SIGINT", () => controller.abort());
const item = await recordLesson(url, name, { signal: controller.signal, quiet: json });
const summary = { name: item.name, step_count: item.steps.length, endpoint_count: item.endpoints.length, request_count_total: item.request_count_total };
if (json) printJson(summary, true);
else console.log(`recorded ${summary.step_count} steps and ${summary.endpoint_count} endpoints from ${summary.request_count_total} requests -> ${item.name}`);
break;
}
case "lessons": {
const items = lessons();
// THE ENVELOPE RIDES BESIDE THE ROWS ⟨R30, 2026-09-09⟩. A lesson is a
// recording OF SOMEBODY ELSE'S SITE: `source_url`, the step targets and
// every learned endpoint are the vendor page's own strings, captured
// through a browser and replayed later by plain fetch. `lesson_count`
// and `lessons` keep their names and values; `evidence` is a NEW
// top-level sibling. The road named is the on-disk lesson store,
// because that is the door this read actually opened — the vendor each
// row came through is on the row itself, as `source_url`.
if (json) printJson({
lesson_count: items.length,
lessons: items,
evidence: evidence({ source: "libretto.lesson-store.list", count: items.length }),
}, true);
else {
console.log(`${items.length} lesson${items.length === 1 ? "" : "s"}`);
for (const item of items) console.log(`${item.name}\t${item.step_count} steps\t${item.endpoint_count} endpoints`);
}
break;
}
case "lesson": {
const [name] = args;
if (!name) throw new Error("Usage: api.ts lesson <name> [--json]");
const item = lesson(name);
// ONE LESSON IS ONE RECORD, so `count` is 1: the steps and endpoints
// under it are that lesson's body, not a second population.
if (json) printJson({
...item,
evidence: evidence({ source: "libretto.lesson-store.get", count: 1 }),
}, true);
else {
console.log(`${item.name}: ${item.steps.length} steps, ${item.endpoints.length} endpoints`);
for (const step of item.steps) console.log(`${step.id}\t${step.verb}\t${step.target}${step.parameter ? `\targ=${step.parameter}` : ""}`);
for (const endpoint of item.endpoints) console.log(`${endpoint.id}\t${endpoint.method}\t${endpoint.url}`);
}
break;
}
case "replay": {
const [name, payload] = args;
if (!name) throw new Error("Usage: api.ts replay <name> [args-json|key=value] [--now] [--json]");
// `replay` IS UNSTAMPED ON PURPOSE ⟨R30⟩: it is not a read verb (its
// class is additive-write, because a replayed request acts on the
// vendor), and `summarizeReplay` deliberately returns COUNTS ONLY —
// request_count, ok_count, statuses, body_bytes. No response body and
// no vendor sentence crosses this boundary, so there is nothing here
// for a declaration to be about.
const result = await replayLesson(name, parseReplayArgs(payload), { now: words.includes("--now") });
printJson(summarizeReplay(result), json);
break;
}
case "promote": {
const [name, skill, verb] = args;
if (!name || !skill || !verb) throw new Error("Usage: api.ts promote <name> <skill> <verb> [--json]");
const result = promote(name, skill, verb);
if (json) printJson(result, true);
else console.log(`promoted ${result.lesson} -> ${result.skill}.${result.verb} (${result.effect})`);
break;
}
default:
console.log("Usage: npx tsx api.ts [record|lessons|lesson|replay|promote|contract] ...");
if (cmd) process.exitCode = 1;
}
})().catch((error) => {
const code = error instanceof SessionStaleError ? error.code : "libretto_error";
if (process.argv.includes("--json")) printJson({ ok: false, code, error: error instanceof Error ? error.message : String(error) }, true);
else console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
}