#!/usr/bin/env npx tsx
/**
* snappy-api-sniffer/api.ts -- Capture XHR/fetch traffic from a real browser session
* and emit replayable "recipes" that consumer skills can call with plain fetch().
*
* Why this exists: many surfaces (Skool, LinkedIn feeds, etc.) gate their internal
* APIs behind JS challenges or Next.js middleware that reject cookie-curl. Running
* them through a real Playwright session via snappy-browse captures the real calls;
* this skill turns those calls into replay recipes.
*
* Composition (DRY):
* snappy-browse ------ Playwright transport (navigate, eval, network requests)
* └─ snappy-api-sniffer ------ capture → filter → emit recipe → replay
*
* Usage:
* npx tsx api.ts capture https://www.skool.com/snappy "api2.skool.com" skool-feed
* → navigates, waits, filters requests by URL substring, writes recipe json
*
* npx tsx api.ts replay skool-feed
* → reads recipe, re-executes via fetch() with stored cookies
*/
import { existsSync, mkdirSync, readFileSync, realpathSync, readdirSync, writeFileSync } from "fs";
import { join, dirname } from "path";
import { env } from "../snappy-settings/load.ts";
import { boundRows, limitSchema, takeLimit } from "../snappy-settings/read-limit.ts";
import { evidence, type EvidenceBlock } from "../snappy-settings/evidence-envelope.ts";
import { refusalTable } from "../snappy-settings/refusal-codes.ts";
import {
navigate,
clearRequests,
captureRequests,
close,
type CapturedRequest,
} from "../snappy-browse/session.ts";
const RECIPE_DIR = join(process.env.HOME!, ".claude/skills/snappy-api-sniffer/recipes");
export interface ApiRecipe {
name: string;
captured_at: string;
source_url: string;
filter: string;
requests: CapturedRequest[];
cookies_hint?: string; // path to auth state file used at capture time
}
/** Normalize a captured request into a shape consumer skills can replay. */
export function normalizeCapturedRequest(r: CapturedRequest): CapturedRequest {
return {
url: r.url,
method: r.method,
resource_type: r.resource_type,
status: r.status,
// Strip cookies from headers (cookies come from the state file at replay time)
request_headers: Object.fromEntries(
Object.entries(r.request_headers || {}).filter(([k]) => k.toLowerCase() !== "cookie")
),
post_data: r.post_data,
};
}
export interface ReplayResponse {
status: number;
body: string;
url: string;
contentType: string;
/** THE DECLARATION THIS ANSWER CARRIES ⟨R30, 2026-09-09⟩. `body` is bytes a
* third-party server wrote — the exact text the rule is about — so the answer
* says so in the wire itself. Minted by
* `snappy-settings/evidence-envelope.ts`; a NEW key beside `status`, `body`,
* `url` and `contentType`, none of which move. Declared OPTIONAL so that
* `ReplayItem extends ReplayResponse` in snappy-libretto keeps compiling
* wherever it builds one of these by hand; `replayCapturedRequest` always
* sets it. */
evidence?: EvidenceBlock;
}
interface StorageCookie {
name: string;
value: string;
domain?: string;
path?: string;
expires?: number;
secure?: boolean;
}
function cookieMatchesUrl(cookie: StorageCookie, url: URL): boolean {
const domain = (cookie.domain ?? "").replace(/^\./, "").toLowerCase();
const host = url.hostname.toLowerCase();
if (domain && host !== domain && !host.endsWith(`.${domain}`)) return false;
if (cookie.secure && url.protocol !== "https:") return false;
if (cookie.path && url.pathname !== cookie.path && !url.pathname.startsWith(cookie.path.endsWith("/") ? cookie.path : `${cookie.path}/`)) return false;
return cookie.expires === undefined || cookie.expires < 0 || cookie.expires * 1000 > Date.now();
}
/** Build the Cookie header for one URL from a Playwright storage-state file. */
export function cookieHeaderFromState(statePath: string | undefined, requestUrl: string): string {
if (!statePath || !existsSync(statePath)) return "";
const state = JSON.parse(readFileSync(statePath, "utf-8")) as { cookies?: StorageCookie[] };
const url = new URL(requestUrl);
return (state.cookies ?? [])
.filter((cookie) => cookieMatchesUrl(cookie, url))
.map((cookie) => `${cookie.name}=${cookie.value}`)
.join("; ");
}
/** Replay one normalized request via plain fetch, with auth loaded from disk. */
export async function replayCapturedRequest(
entry: CapturedRequest,
statePath?: string,
overrides: { urlRewrite?: (u: string) => string; body?: string } = {},
): Promise<ReplayResponse> {
const url = overrides.urlRewrite ? overrides.urlRewrite(entry.url) : entry.url;
const cookie = cookieHeaderFromState(statePath, url);
const headers: Record<string, string> = {
...(entry.request_headers || {}),
...(cookie ? { Cookie: cookie } : {}),
};
for (const h of ["host", "content-length", "connection", "cookie"]) {
const exact = Object.keys(headers).find((key) => key.toLowerCase() === h);
if (exact) delete headers[exact];
}
if (cookie) headers.Cookie = cookie;
const res = await fetch(url, {
method: entry.method,
headers,
body: overrides.body ?? entry.post_data,
redirect: "follow",
});
return {
status: res.status,
body: await res.text(),
url: res.url,
contentType: res.headers.get("content-type") ?? "",
// THE VENDOR ROAD AS THE VENDOR NAMES IT: the host and path the bytes
// actually came back from, not this hand's own verb. One response is one
// record, so the count is 1 — a replay hands back exactly what it asked for
// and nothing about the population it was drawn from, so no `total`.
evidence: evidence({ source: replaySource(res.url), count: 1 }),
};
}
/** THE ROAD, NAMED AS THE VENDOR NAMES IT — `api2.skool.com/graphql`, not
* `replay`. The query string is dropped: it carries the caller's arguments and
* sometimes a token, and `source` answers "which door did this come through",
* never "with what". A URL that will not parse is reported verbatim rather than
* silently renamed. */
function replaySource(rawUrl: string): string {
try {
const url = new URL(rawUrl);
return `${url.host}${url.pathname}`;
} catch {
return rawUrl;
}
}
/**
* Capture all XHR/fetch traffic on a page and write a recipe file.
* sourceUrl: where to navigate. filter: substring/pattern to keep.
* statePath: optional Playwright storage_state.json to load cookies from.
*/
export async function capture(
name: string,
sourceUrl: string,
filter: string,
opts: { statePath?: string; waitMs?: number } = {}
): Promise<ApiRecipe> {
const waitMs = opts.waitMs ?? 4000;
// Force fresh daemon if a state file was supplied — otherwise --state is ignored.
if (opts.statePath) {
try { close(); } catch {}
}
navigate(sourceUrl, opts.statePath);
clearRequests();
// Trigger a soft reload so the settled page re-fires its XHRs and they land in the capture buffer.
try { navigate(sourceUrl, opts.statePath); } catch {}
await new Promise((r) => setTimeout(r, waitMs));
const all = captureRequests(filter);
const xhrOnly = all.filter(
(r) => r.resource_type === "xhr" || r.resource_type === "fetch"
);
const recipe: ApiRecipe = {
name,
captured_at: new Date().toISOString(),
source_url: sourceUrl,
filter,
requests: xhrOnly.map(normalizeCapturedRequest),
cookies_hint: opts.statePath,
};
mkdirSync(RECIPE_DIR, { recursive: true });
const path = join(RECIPE_DIR, `${name}.json`);
writeFileSync(path, JSON.stringify(recipe, null, 2));
return recipe;
}
/** Load a previously-captured recipe. */
export function loadSnifferRecipe(name: string): ApiRecipe {
const path = join(RECIPE_DIR, `${name}.json`);
if (!existsSync(path)) throw new Error(`Recipe not found: ${path}`);
return JSON.parse(readFileSync(path, "utf-8"));
}
/** List captured recipe names without requiring one to already be selected. */
export function listSnifferRecipes(): string[] {
if (!existsSync(RECIPE_DIR)) return [];
return readdirSync(RECIPE_DIR)
.filter((name) => name.endsWith(".json"))
.map((name) => name.slice(0, -5))
.sort();
}
/**
* Replay a single request from a recipe via plain fetch().
* Loads cookies from the recipe's statePath (Playwright storage_state format).
*/
export async function replay(
recipeName: string,
index = 0,
overrides: { urlRewrite?: (u: string) => string } = {}
): Promise<ReplayResponse> {
const recipe = loadSnifferRecipe(recipeName);
const entry = recipe.requests[index];
if (!entry) throw new Error(`Recipe ${recipeName} has no request #${index}`);
return replayCapturedRequest(entry, recipe.cookies_hint, overrides);
}
// --- CLI ---
/** WHAT THIS HAND ANSWERS, and what each verb does to the world.
* Derived from this file's own CLI dispatch by
* `snappy-hands/contract-derive.ts` — a verb the code does not implement is
* never declared here. Snappy's daemon reads it (`api.ts contract`) to
* validate every call, build the argument words in order, decide whether the
* act runs now or stages for the owner, and hand the child exactly the
* environment keys named in `requires` — never a value, never anything else.
*/
export const HAND_CONTRACT = {
skill: "snappy-api-sniffer",
description: "Capture XHR/fetch traffic from a real Playwright session and emit replayable recipes that any consumer skill can call with plain fetch(). Exists because many surfaces (Skool, LinkedIn feeds, gated Next.js pages) reject cookie-curl but return real data to a logged-in browser. This is the network analog of snappy-dom-cartographer. Triggers on: intercept api, xhr capture, network recipe, replay api, reverse engineer api, middleware gated api, 202 empty body, cloudflare challenge.",
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"),
verbs: {
capture: {
args: ["url","filter","name","state-path?"], effect: "write-reversible",
class: "additive-write", execution: "call", openWorld: true,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { url: { type: "string", description: "Page to open while XHR/fetch traffic is recorded" }, filter: { type: "string", description: "Substring every recorded request URL must contain" }, name: { type: "string", description: "Name the captured recipe is filed under" }, "state-path": { type: "string", description: "Browser storage-state file that reuses a logged-in session" } } },
},
list: {
args: ["recipe-name?"], flags: { limit: "--limit" }, effect: "read",
class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: {
limit: limitSchema(200, "How many recorded requests to return, newest first"), "recipe-name": { type: "string", description: "Recipe whose requests are listed; omit to list every recipe name" } } },
},
replay: {
args: ["name","index?"], effect: "write-reversible",
class: "additive-write", execution: "call", openWorld: true,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { name: { type: "string", description: "Recipe to replay" }, index: { type: "integer", description: "Which request in the recipe to replay, zero-based; omit for all" } } },
},
},
} as const;
if (import.meta.url === `file://${realpathSync(process.argv[1])}` && process.argv[2] === "contract") {
console.log(JSON.stringify(HAND_CONTRACT, null, 2));
process.exit(0);
}
if (import.meta.url === `file://${realpathSync(process.argv[1])}`) {
(async () => {
const [, , cmd, ...args] = process.argv;
switch (cmd) {
case "capture": {
const [sourceUrl, filter, name, statePath] = args;
if (!sourceUrl || !filter || !name) {
console.error("Usage: api.ts capture <url> <filter> <name> [statePath]");
process.exit(1);
}
const recipe = await capture(name, sourceUrl, filter, { statePath });
console.log(`captured ${recipe.requests.length} requests → ${name}.json`);
for (const r of recipe.requests) {
console.log(` ${r.method} ${r.url}`);
}
try { close(); } catch {}
break;
}
case "replay": {
const [name, idxStr] = args;
if (!name) { console.error("Usage: api.ts replay <name> [index]"); process.exit(1); }
const out = await replay(name, Number(idxStr || 0));
console.log(`status=${out.status}`);
console.log(out.body.slice(0, 2000));
break;
}
case "list": {
const bound = takeLimit(args, { maximum: 200 });
if (bound.refusal) { console.log(JSON.stringify(bound.refusal, null, 2)); process.exit(1); }
// `args` is const-destructured from argv; the limit-bounded remainder
// gets its own name. Assigning to it threw TypeError: Assignment to
// constant variable on EVERY `list` call — the hand's derived first
// call crashed instead of answering.
const rest = bound.rest;
if (!rest[0]) {
const names = boundRows(listSnifferRecipes(), bound.limit);
console.log(names.length === 0 ? "No captured recipes." : names.join("\n"));
break;
}
const recipe = loadSnifferRecipe(rest[0]);
for (let i = 0; i < Math.min(recipe.requests.length, bound.limit); i++) {
console.log(`${i}: ${recipe.requests[i].method} ${recipe.requests[i].url}`);
}
break;
}
default:
console.log("Usage: npx tsx api.ts [capture|replay|list] ...");
}
})();
}
#!/usr/bin/env npx tsx
/**
* snappy-api-sniffer/api.ts -- Capture XHR/fetch traffic from a real browser session
* and emit replayable "recipes" that consumer skills can call with plain fetch().
*
* Why this exists: many surfaces (Skool, LinkedIn feeds, etc.) gate their internal
* APIs behind JS challenges or Next.js middleware that reject cookie-curl. Running
* them through a real Playwright session via snappy-browse captures the real calls;
* this skill turns those calls into replay recipes.
*
* Composition (DRY):
* snappy-browse ------ Playwright transport (navigate, eval, network requests)
* └─ snappy-api-sniffer ------ capture → filter → emit recipe → replay
*
* Usage:
* npx tsx api.ts capture https://www.skool.com/snappy "api2.skool.com" skool-feed
* → navigates, waits, filters requests by URL substring, writes recipe json
*
* npx tsx api.ts replay skool-feed
* → reads recipe, re-executes via fetch() with stored cookies
*/
import { existsSync, mkdirSync, readFileSync, realpathSync, readdirSync, writeFileSync } from "fs";
import { join, dirname } from "path";
import { env } from "../snappy-settings/load.ts";
import { boundRows, limitSchema, takeLimit } from "../snappy-settings/read-limit.ts";
import { evidence, type EvidenceBlock } from "../snappy-settings/evidence-envelope.ts";
import { refusalTable } from "../snappy-settings/refusal-codes.ts";
import {
navigate,
clearRequests,
captureRequests,
close,
type CapturedRequest,
} from "../snappy-browse/session.ts";
const RECIPE_DIR = join(process.env.HOME!, ".claude/skills/snappy-api-sniffer/recipes");
export interface ApiRecipe {
name: string;
captured_at: string;
source_url: string;
filter: string;
requests: CapturedRequest[];
cookies_hint?: string; // path to auth state file used at capture time
}
/** Normalize a captured request into a shape consumer skills can replay. */
export function normalizeCapturedRequest(r: CapturedRequest): CapturedRequest {
return {
url: r.url,
method: r.method,
resource_type: r.resource_type,
status: r.status,
// Strip cookies from headers (cookies come from the state file at replay time)
request_headers: Object.fromEntries(
Object.entries(r.request_headers || {}).filter(([k]) => k.toLowerCase() !== "cookie")
),
post_data: r.post_data,
};
}
export interface ReplayResponse {
status: number;
body: string;
url: string;
contentType: string;
/** THE DECLARATION THIS ANSWER CARRIES ⟨R30, 2026-09-09⟩. `body` is bytes a
* third-party server wrote — the exact text the rule is about — so the answer
* says so in the wire itself. Minted by
* `snappy-settings/evidence-envelope.ts`; a NEW key beside `status`, `body`,
* `url` and `contentType`, none of which move. Declared OPTIONAL so that
* `ReplayItem extends ReplayResponse` in snappy-libretto keeps compiling
* wherever it builds one of these by hand; `replayCapturedRequest` always
* sets it. */
evidence?: EvidenceBlock;
}
interface StorageCookie {
name: string;
value: string;
domain?: string;
path?: string;
expires?: number;
secure?: boolean;
}
function cookieMatchesUrl(cookie: StorageCookie, url: URL): boolean {
const domain = (cookie.domain ?? "").replace(/^\./, "").toLowerCase();
const host = url.hostname.toLowerCase();
if (domain && host !== domain && !host.endsWith(`.${domain}`)) return false;
if (cookie.secure && url.protocol !== "https:") return false;
if (cookie.path && url.pathname !== cookie.path && !url.pathname.startsWith(cookie.path.endsWith("/") ? cookie.path : `${cookie.path}/`)) return false;
return cookie.expires === undefined || cookie.expires < 0 || cookie.expires * 1000 > Date.now();
}
/** Build the Cookie header for one URL from a Playwright storage-state file. */
export function cookieHeaderFromState(statePath: string | undefined, requestUrl: string): string {
if (!statePath || !existsSync(statePath)) return "";
const state = JSON.parse(readFileSync(statePath, "utf-8")) as { cookies?: StorageCookie[] };
const url = new URL(requestUrl);
return (state.cookies ?? [])
.filter((cookie) => cookieMatchesUrl(cookie, url))
.map((cookie) => `${cookie.name}=${cookie.value}`)
.join("; ");
}
/** Replay one normalized request via plain fetch, with auth loaded from disk. */
export async function replayCapturedRequest(
entry: CapturedRequest,
statePath?: string,
overrides: { urlRewrite?: (u: string) => string; body?: string } = {},
): Promise<ReplayResponse> {
const url = overrides.urlRewrite ? overrides.urlRewrite(entry.url) : entry.url;
const cookie = cookieHeaderFromState(statePath, url);
const headers: Record<string, string> = {
...(entry.request_headers || {}),
...(cookie ? { Cookie: cookie } : {}),
};
for (const h of ["host", "content-length", "connection", "cookie"]) {
const exact = Object.keys(headers).find((key) => key.toLowerCase() === h);
if (exact) delete headers[exact];
}
if (cookie) headers.Cookie = cookie;
const res = await fetch(url, {
method: entry.method,
headers,
body: overrides.body ?? entry.post_data,
redirect: "follow",
});
return {
status: res.status,
body: await res.text(),
url: res.url,
contentType: res.headers.get("content-type") ?? "",
// THE VENDOR ROAD AS THE VENDOR NAMES IT: the host and path the bytes
// actually came back from, not this hand's own verb. One response is one
// record, so the count is 1 — a replay hands back exactly what it asked for
// and nothing about the population it was drawn from, so no `total`.
evidence: evidence({ source: replaySource(res.url), count: 1 }),
};
}
/** THE ROAD, NAMED AS THE VENDOR NAMES IT — `api2.skool.com/graphql`, not
* `replay`. The query string is dropped: it carries the caller's arguments and
* sometimes a token, and `source` answers "which door did this come through",
* never "with what". A URL that will not parse is reported verbatim rather than
* silently renamed. */
function replaySource(rawUrl: string): string {
try {
const url = new URL(rawUrl);
return `${url.host}${url.pathname}`;
} catch {
return rawUrl;
}
}
/**
* Capture all XHR/fetch traffic on a page and write a recipe file.
* sourceUrl: where to navigate. filter: substring/pattern to keep.
* statePath: optional Playwright storage_state.json to load cookies from.
*/
export async function capture(
name: string,
sourceUrl: string,
filter: string,
opts: { statePath?: string; waitMs?: number } = {}
): Promise<ApiRecipe> {
const waitMs = opts.waitMs ?? 4000;
// Force fresh daemon if a state file was supplied — otherwise --state is ignored.
if (opts.statePath) {
try { close(); } catch {}
}
navigate(sourceUrl, opts.statePath);
clearRequests();
// Trigger a soft reload so the settled page re-fires its XHRs and they land in the capture buffer.
try { navigate(sourceUrl, opts.statePath); } catch {}
await new Promise((r) => setTimeout(r, waitMs));
const all = captureRequests(filter);
const xhrOnly = all.filter(
(r) => r.resource_type === "xhr" || r.resource_type === "fetch"
);
const recipe: ApiRecipe = {
name,
captured_at: new Date().toISOString(),
source_url: sourceUrl,
filter,
requests: xhrOnly.map(normalizeCapturedRequest),
cookies_hint: opts.statePath,
};
mkdirSync(RECIPE_DIR, { recursive: true });
const path = join(RECIPE_DIR, `${name}.json`);
writeFileSync(path, JSON.stringify(recipe, null, 2));
return recipe;
}
/** Load a previously-captured recipe. */
export function loadSnifferRecipe(name: string): ApiRecipe {
const path = join(RECIPE_DIR, `${name}.json`);
if (!existsSync(path)) throw new Error(`Recipe not found: ${path}`);
return JSON.parse(readFileSync(path, "utf-8"));
}
/** List captured recipe names without requiring one to already be selected. */
export function listSnifferRecipes(): string[] {
if (!existsSync(RECIPE_DIR)) return [];
return readdirSync(RECIPE_DIR)
.filter((name) => name.endsWith(".json"))
.map((name) => name.slice(0, -5))
.sort();
}
/**
* Replay a single request from a recipe via plain fetch().
* Loads cookies from the recipe's statePath (Playwright storage_state format).
*/
export async function replay(
recipeName: string,
index = 0,
overrides: { urlRewrite?: (u: string) => string } = {}
): Promise<ReplayResponse> {
const recipe = loadSnifferRecipe(recipeName);
const entry = recipe.requests[index];
if (!entry) throw new Error(`Recipe ${recipeName} has no request #${index}`);
return replayCapturedRequest(entry, recipe.cookies_hint, overrides);
}
// --- CLI ---
/** WHAT THIS HAND ANSWERS, and what each verb does to the world.
* Derived from this file's own CLI dispatch by
* `snappy-hands/contract-derive.ts` — a verb the code does not implement is
* never declared here. Snappy's daemon reads it (`api.ts contract`) to
* validate every call, build the argument words in order, decide whether the
* act runs now or stages for the owner, and hand the child exactly the
* environment keys named in `requires` — never a value, never anything else.
*/
export const HAND_CONTRACT = {
skill: "snappy-api-sniffer",
description: "Capture XHR/fetch traffic from a real Playwright session and emit replayable recipes that any consumer skill can call with plain fetch(). Exists because many surfaces (Skool, LinkedIn feeds, gated Next.js pages) reject cookie-curl but return real data to a logged-in browser. This is the network analog of snappy-dom-cartographer. Triggers on: intercept api, xhr capture, network recipe, replay api, reverse engineer api, middleware gated api, 202 empty body, cloudflare challenge.",
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"),
verbs: {
capture: {
args: ["url","filter","name","state-path?"], effect: "write-reversible",
class: "additive-write", execution: "call", openWorld: true,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { url: { type: "string", description: "Page to open while XHR/fetch traffic is recorded" }, filter: { type: "string", description: "Substring every recorded request URL must contain" }, name: { type: "string", description: "Name the captured recipe is filed under" }, "state-path": { type: "string", description: "Browser storage-state file that reuses a logged-in session" } } },
},
list: {
args: ["recipe-name?"], flags: { limit: "--limit" }, effect: "read",
class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: {
limit: limitSchema(200, "How many recorded requests to return, newest first"), "recipe-name": { type: "string", description: "Recipe whose requests are listed; omit to list every recipe name" } } },
},
replay: {
args: ["name","index?"], effect: "write-reversible",
class: "additive-write", execution: "call", openWorld: true,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { name: { type: "string", description: "Recipe to replay" }, index: { type: "integer", description: "Which request in the recipe to replay, zero-based; omit for all" } } },
},
},
} as const;
if (import.meta.url === `file://${realpathSync(process.argv[1])}` && process.argv[2] === "contract") {
console.log(JSON.stringify(HAND_CONTRACT, null, 2));
process.exit(0);
}
if (import.meta.url === `file://${realpathSync(process.argv[1])}`) {
(async () => {
const [, , cmd, ...args] = process.argv;
switch (cmd) {
case "capture": {
const [sourceUrl, filter, name, statePath] = args;
if (!sourceUrl || !filter || !name) {
console.error("Usage: api.ts capture <url> <filter> <name> [statePath]");
process.exit(1);
}
const recipe = await capture(name, sourceUrl, filter, { statePath });
console.log(`captured ${recipe.requests.length} requests → ${name}.json`);
for (const r of recipe.requests) {
console.log(` ${r.method} ${r.url}`);
}
try { close(); } catch {}
break;
}
case "replay": {
const [name, idxStr] = args;
if (!name) { console.error("Usage: api.ts replay <name> [index]"); process.exit(1); }
const out = await replay(name, Number(idxStr || 0));
console.log(`status=${out.status}`);
console.log(out.body.slice(0, 2000));
break;
}
case "list": {
const bound = takeLimit(args, { maximum: 200 });
if (bound.refusal) { console.log(JSON.stringify(bound.refusal, null, 2)); process.exit(1); }
// `args` is const-destructured from argv; the limit-bounded remainder
// gets its own name. Assigning to it threw TypeError: Assignment to
// constant variable on EVERY `list` call — the hand's derived first
// call crashed instead of answering.
const rest = bound.rest;
if (!rest[0]) {
const names = boundRows(listSnifferRecipes(), bound.limit);
console.log(names.length === 0 ? "No captured recipes." : names.join("\n"));
break;
}
const recipe = loadSnifferRecipe(rest[0]);
for (let i = 0; i < Math.min(recipe.requests.length, bound.limit); i++) {
console.log(`${i}: ${recipe.requests[i].method} ${recipe.requests[i].url}`);
}
break;
}
default:
console.log("Usage: npx tsx api.ts [capture|replay|list] ...");
}
})();
}