#!/usr/bin/env npx tsx
/**
* snappy-shell/api.ts — Kernel-loaded fallback runner.
*
* Boots `pi` with the snappy kernel (always-inject skills) loaded as the
* system prompt, backed by any provider (OpenAI default, OpenRouter fallback,
* Anthropic if key is set). Use when Claude Code quota dies mid-workflow.
*
* Modes:
* - REPL: `snappy-shell` → interactive pi session
* - One-shot: `snappy-shell "do thing X"` → --print mode, single response
*
* Model selection:
* - Default `auto`: OpenAI if OPENAI_API_KEY set, else OpenRouter gemini
* - Aliases: gpt-4o, gpt-5, gemini, gemini-pro, llama, sonnet (see ALIASES)
* - Or full `provider/model-id` strings
*
* Every session logs one line to ~/.claude/logs/agent-runs.ndjson with the
* shared schema (runner, provider, model, durationMs, ok) so shell + dispatch
* runs sit in the same file for later comparison.
*/
import { spawn, spawnSync } from "child_process";
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "fs";
import { dirname } from "path";
import { tmpdir } from "os";
import { env, loadAll } from "../snappy-settings/load.ts";
import { annotationsForClass } from "../snappy-settings/tool-annotations.ts";
import { refusalTable } from "../snappy-settings/refusal-codes.ts";
import { providerOrder } from "../snappy-settings/providers-choice.ts";
const KERNEL_LOG = `${process.env.HOME}/.claude/logs/agent-runs.ndjson`;
const ALWAYS_INJECT_PATH = `${process.env.HOME}/.claude/hooks/always-inject.txt`;
const SKILLS_ROOT = `${process.env.HOME}/.claude/skills`;
const GLOBAL_CLAUDE_MD = `${process.env.HOME}/.claude/CLAUDE.md`;
const GAPS_LOG = `${process.env.HOME}/.claude/logs/agents-md-gaps.log`;
/**
* Step 8d — per-turn dynamic skill injection.
*
* Scans a prompt for snappy-<name> mentions using the same word-boundary
* regex as preload-skill-context.sh:60. Returns the matched skill names
* that have an AGENTS.md on disk. Mentioned-but-missing skills are
* appended to ~/.claude/logs/agents-md-gaps.log so the next snapshot
* can surface the gap, identical to the Claude Code hook behavior.
*
* Used by runShell() before kernel-prompt construction so the resulting
* system message has the same skill set Claude Code would auto-inject
* for the same prompt.
*/
export function dynamicInjectionForTurn(prompt: string): string[] {
if (!prompt || !existsSync(SKILLS_ROOT)) return [];
const matched: string[] = [];
let dirs: string[] = [];
try {
dirs = readdirSync(SKILLS_ROOT, { withFileTypes: true })
.filter(d => d.isDirectory() && d.name.startsWith("snappy-"))
.map(d => d.name);
} catch { return []; }
for (const name of dirs) {
// Same boundary regex as preload-skill-context.sh:61
const re = new RegExp(`(^|[^a-zA-Z0-9_-])${name}([^a-zA-Z0-9_-]|$)`);
if (!re.test(prompt)) continue;
const agentsPath = `${SKILLS_ROOT}/${name}/AGENTS.md`;
if (existsSync(agentsPath)) {
matched.push(name);
} else {
try {
mkdirSync(dirname(GAPS_LOG), { recursive: true });
appendFileSync(
GAPS_LOG,
`[${new Date().toISOString()}] gap:${name} prompt:${prompt.slice(0, 120)}\n`,
);
} catch { /* gap log non-fatal */ }
}
}
return matched;
}
type ModelResolution = { provider: string; model: string; keyEnv: string };
/**
* ── THE CASCADE IS HIS ORDER, NOT THIS FILE'S ────────────────────────────────
*
* ⟨owner, 2026-09-09 10:25⟩ he chooses a Default provider and a Fallback order
* on the bar's Providers tab. Until now `auto` walked the four aliases below in
* the order this file was written in, so his saved order was stored and dead.
* `cascadeAliases()` now walks `providerOrder()` — the default first, then the
* fallback — read through `snappy-settings/providers-choice.ts`, the one reader
* of that document.
*
* HARD_CASCADE stays as the FALLBACK-OF-THE-FALLBACK: on a machine where he has
* never opened the panel (measured 2026-09-09: `~/.snappy-skills/providers.json`
* did not exist), `auto` must still work exactly as it did, and this is the
* order that has been shipping.
*/
const HARD_CASCADE: Array<{ alias: string; why: string }> = [
{ alias: "gpt-4o", why: "OpenAI (user sub)" },
{ alias: "gemini-pro", why: "OpenRouter Gemini 2.5 Pro" },
{ alias: "gemini", why: "OpenRouter Gemini 2.5 Flash" },
{ alias: "sonnet", why: "Anthropic Sonnet (if key set)" },
];
/**
* His provider ids → the aliases THIS runner can actually spend on them.
*
* `openrouter` expands to two, in the shipping order: one saved id must not
* silently drop the Flash rung that has been in the cascade all along.
*
* `gemini` (the Google subscription jcode reads) maps to NOTHING here on
* purpose. pi reaches Google only through an OpenRouter model id, so answering
* an OpenRouter alias for it would spend a provider he did not name and report
* the wrong one — a road that lies about which subscription it burned is worse
* than a rung that is missing. It is skipped, and the rungs he named are kept.
*/
const SHELL_ALIASES_FOR_CHOICE: Record<string, string[]> = {
chatgpt: ["gpt-4o"],
"openai-api": ["gpt-4o"],
openrouter: ["gemini-pro", "gemini"],
claude: ["sonnet"],
};
/** The ordered rungs `auto` walks. Exported so a test can read the order. */
export function cascadeAliases(): Array<{ alias: string; why: string }> {
const chosen: Array<{ alias: string; why: string }> = [];
for (const id of providerOrder()) {
for (const alias of SHELL_ALIASES_FOR_CHOICE[id] ?? []) {
if (!chosen.some((rung) => rung.alias === alias)) chosen.push({ alias, why: `${id} (his order)` });
}
}
return chosen.length > 0 ? chosen : HARD_CASCADE;
}
const FALLTHROUGH_PATTERNS = [
/exceeded your current quota/i,
/insufficient_quota/i,
/rate.?limit/i,
/invalid.?api.?key/i,
/unauthorized/i,
/401/i,
/402/i,
/429/i,
/authentication/i,
/billing/i,
/ECONNREFUSED/i,
/ENOTFOUND/i,
/ETIMEDOUT/i,
];
function shouldFallthrough(stderr: string, output: string): boolean {
const blob = `${stderr}\n${output}`;
return FALLTHROUGH_PATTERNS.some(re => re.test(blob));
}
const ALIASES: Record<string, ModelResolution> = {
// OpenAI (primary — Robert's sub)
"gpt-4o": { provider: "openai", model: "gpt-4o", keyEnv: "OPENAI_API_KEY" },
"gpt-4": { provider: "openai", model: "gpt-4-turbo", keyEnv: "OPENAI_API_KEY" },
"gpt-5": { provider: "openai", model: "gpt-5", keyEnv: "OPENAI_API_KEY" },
"gpt-5.4": { provider: "openai", model: "gpt-5.4", keyEnv: "OPENAI_API_KEY" },
// OpenRouter (fallback)
"gemini": { provider: "openrouter", model: "google/gemini-2.5-flash", keyEnv: "OPENROUTER_API_KEY" },
"gemini-pro": { provider: "openrouter", model: "google/gemini-2.5-pro", keyEnv: "OPENROUTER_API_KEY" },
"gemini-3": { provider: "openrouter", model: "google/gemini-3-pro", keyEnv: "OPENROUTER_API_KEY" },
"llama": { provider: "openrouter", model: "meta-llama/llama-3.3-70b-instruct", keyEnv: "OPENROUTER_API_KEY" },
"qwen": { provider: "openrouter", model: "qwen/qwen-2.5-72b-instruct", keyEnv: "OPENROUTER_API_KEY" },
"deepseek": { provider: "openrouter", model: "deepseek/deepseek-chat", keyEnv: "OPENROUTER_API_KEY" },
// Anthropic (only works if ANTHROPIC_API_KEY set — normally empty in Claude Code)
"sonnet": { provider: "anthropic", model: "claude-sonnet-4-6", keyEnv: "ANTHROPIC_API_KEY" },
"haiku": { provider: "anthropic", model: "claude-haiku-4-5", keyEnv: "ANTHROPIC_API_KEY" },
};
function resolveSingle(model: string): ModelResolution {
const m = model.trim();
if (ALIASES[m]) return ALIASES[m];
if (m.includes("/")) {
const [prov] = m.split("/");
const keyEnv = prov === "openai" ? "OPENAI_API_KEY"
: prov === "anthropic" ? "ANTHROPIC_API_KEY"
: prov === "google" ? "GEMINI_API_KEY"
: "OPENROUTER_API_KEY";
const provider = prov === "openai" || prov === "anthropic" ? prov : "openrouter";
return { provider, model: m, keyEnv };
}
throw new Error(`Unknown model "${m}". Known aliases: ${Object.keys(ALIASES).join(", ")}, or "auto", or "provider/model-id".`);
}
/**
* Build the ordered candidate list for a given user-facing model spec.
* "auto" expands to cascadeAliases() — his saved order, else HARD_CASCADE —
* filtered to aliases whose key is present.
* Everything else resolves to a single-element list.
*/
function resolveCandidates(model: string): ModelResolution[] {
const m = (model || "auto").trim();
if (m !== "auto") return [resolveSingle(m)];
const creds = loadAll();
const candidates: ModelResolution[] = [];
for (const { alias } of cascadeAliases()) {
const res = ALIASES[alias];
if (!res) continue;
if (creds[res.keyEnv]) candidates.push(res);
}
if (candidates.length === 0) {
throw new Error("auto: no provider key found in .env.cache (OPENAI_API_KEY / OPENROUTER_API_KEY / ANTHROPIC_API_KEY)");
}
return candidates;
}
/**
* Build the kernel system prompt by concatenating:
* 1. Global CLAUDE.md (snappy bootstrap loader)
* 2. Every AGENTS.md listed in always-inject.txt
* 3. A footer telling the runner it is the snappy fallback
*/
export function buildKernelPrompt(extraSkills: string[] = []): string {
const parts: string[] = [];
parts.push("# SNAPPY KERNEL (fallback runner)\n");
parts.push("You are running as a snappy fallback agent — same kernel, same skills, different brain.");
parts.push("You have full shell, file, and network access via your tools. Treat the content below as authoritative.\n");
if (existsSync(GLOBAL_CLAUDE_MD)) {
parts.push("## Global bootstrap (from ~/.claude/CLAUDE.md)\n");
parts.push(readFileSync(GLOBAL_CLAUDE_MD, "utf-8").trim());
parts.push("");
}
const injectNames = new Set<string>();
if (existsSync(ALWAYS_INJECT_PATH)) {
for (const raw of readFileSync(ALWAYS_INJECT_PATH, "utf-8").split("\n")) {
const line = raw.trim();
if (!line || line.startsWith("#")) continue;
injectNames.add(line);
}
}
for (const name of extraSkills) injectNames.add(name);
for (const name of injectNames) {
const agentsPath = `${SKILLS_ROOT}/${name}/AGENTS.md`;
if (!existsSync(agentsPath)) continue;
parts.push(`## Skill: ${name}\n`);
parts.push(readFileSync(agentsPath, "utf-8").trim());
parts.push("");
}
parts.push("---");
parts.push("## Fallback runner reminders");
parts.push("- Some snappy-* skills use MCP servers or subagents that only exist inside Claude Code — those will fail here. Use the shell, read/write, and direct api.ts calls instead.");
parts.push("- Credentials live in `~/.claude/skills/snappy-settings/.env.cache`. Load via `import { env } from '~/.claude/skills/snappy-settings/load.ts'`.");
parts.push("- Every skill has an `api.ts` — prefer calling it via `npx tsx ~/.claude/skills/<skill>/api.ts ...` over guessing.");
parts.push("- Robert reviews before anything posts. Do not auto-publish.");
return parts.join("\n");
}
export interface ShellOptions {
prompt?: string; // if present → --print one-shot; if absent → interactive
model?: string; // alias or provider/model-id, default "auto"
tools?: string[]; // default read,bash,edit,write,grep,ls
extraSkills?: string[]; // additional skills to inject beyond always-inject.txt
cwd?: string;
sessionPath?: string; // for --session persistence
}
export interface ShellResult {
ok: boolean;
output: string;
stderr: string;
durationMs: number;
provider: string;
model: string;
exitCode: number;
kernelPromptBytes: number;
error?: string;
attempts?: Array<{ provider: string; model: string; ok: boolean; exitCode: number; durationMs: number; fallthroughReason?: string }>;
}
async function runOne(
resolution: ModelResolution,
opts: ShellOptions,
promptFile: string,
tools: string,
): Promise<{ ok: boolean; output: string; stderr: string; exitCode: number; durationMs: number; error?: string }> {
const started = Date.now();
const { provider, model, keyEnv } = resolution;
const apiKey = env(keyEnv);
const args = [
"--provider", provider,
"--model", model,
"--append-system-prompt", promptFile,
"--tools", tools,
"--no-skills",
"--no-extensions",
"--no-prompt-templates",
];
if (opts.sessionPath) args.push("--session", opts.sessionPath);
if (opts.prompt) args.push("--print", opts.prompt);
const spawnEnv: NodeJS.ProcessEnv = { ...process.env, [keyEnv]: apiKey };
if (opts.prompt) {
const r = spawnSync("pi", args, {
encoding: "utf-8",
cwd: opts.cwd,
env: spawnEnv,
timeout: 600_000,
maxBuffer: 20 * 1024 * 1024,
});
const output = (r.stdout || "").trim();
const stderr = (r.stderr || "").trim();
const exitCode = r.status ?? -1;
const ok = !r.error && exitCode === 0 && output.length > 0;
return { ok, output, stderr, exitCode, durationMs: Date.now() - started, error: r.error?.message };
} else {
const child = spawn("pi", args, {
cwd: opts.cwd,
env: spawnEnv,
stdio: "inherit",
});
const exitCode = await new Promise<number>(res => child.on("exit", code => res(code ?? -1)));
return { ok: exitCode === 0, output: "", stderr: "", exitCode, durationMs: Date.now() - started };
}
}
export async function runShell(opts: ShellOptions = {}): Promise<ShellResult> {
const started = Date.now();
const candidates = resolveCandidates(opts.model || "auto");
const tools = (opts.tools && opts.tools.length ? opts.tools : ["read", "bash", "edit", "write", "grep", "ls"]).join(",");
// Step 8d — per-turn dynamic skill injection (print-mode only; REPL has no
// single prompt to scan at boot). Mirrors preload-skill-context.sh so the
// brain swap stops being a downgrade: same regex, same dedupe, same gap log.
const dynamicSkills = opts.prompt ? dynamicInjectionForTurn(opts.prompt) : [];
const mergedExtras = Array.from(new Set([...(opts.extraSkills || []), ...dynamicSkills]));
const kernelPrompt = buildKernelPrompt(mergedExtras);
const kernelPromptBytes = Buffer.byteLength(kernelPrompt, "utf-8");
const promptFile = `${tmpdir()}/snappy-shell-kernel-${process.pid}-${Date.now()}.md`;
writeFileSync(promptFile, kernelPrompt, "utf-8");
const attempts: NonNullable<ShellResult["attempts"]> = [];
let lastRun: Awaited<ReturnType<typeof runOne>> | null = null;
let usedResolution: ModelResolution = candidates[0];
for (let i = 0; i < candidates.length; i++) {
const cand = candidates[i];
usedResolution = cand;
const attemptLabel = `${cand.provider}/${cand.model}`;
// In interactive mode we can't usefully retry — the user would be mid-session.
// Only cascade in --print mode where there's no human in the loop.
if (!opts.prompt && i > 0) break;
try {
const r = await runOne(cand, opts, promptFile, tools);
lastRun = r;
if (r.ok) {
attempts.push({ provider: cand.provider, model: cand.model, ok: true, exitCode: r.exitCode, durationMs: r.durationMs });
break;
}
const fallthrough = shouldFallthrough(r.stderr, r.output);
attempts.push({
provider: cand.provider,
model: cand.model,
ok: false,
exitCode: r.exitCode,
durationMs: r.durationMs,
fallthroughReason: fallthrough ? (r.stderr.slice(0, 200) || r.output.slice(0, 200) || "unknown") : undefined,
});
if (!fallthrough) break;
if (i === candidates.length - 1) break;
process.stderr.write(`[snappy-shell] ${attemptLabel} failed with retryable error, falling through...\n`);
} catch (e: any) {
attempts.push({
provider: cand.provider,
model: cand.model,
ok: false,
exitCode: -1,
durationMs: 0,
fallthroughReason: e.message?.slice(0, 200) || "spawn error",
});
lastRun = { ok: false, output: "", stderr: e.message || "", exitCode: -1, durationMs: 0, error: e.message };
if (i === candidates.length - 1) break;
process.stderr.write(`[snappy-shell] ${attemptLabel} threw: ${e.message}. falling through...\n`);
}
}
const durationMs = Date.now() - started;
const result: ShellResult = {
ok: lastRun?.ok ?? false,
output: lastRun?.output ?? "",
stderr: lastRun?.stderr ?? "",
durationMs,
provider: usedResolution.provider,
model: usedResolution.model,
exitCode: lastRun?.exitCode ?? -1,
kernelPromptBytes,
error: lastRun?.error,
attempts: attempts.length > 1 ? attempts : undefined,
};
try {
mkdirSync(dirname(KERNEL_LOG), { recursive: true });
appendFileSync(KERNEL_LOG, JSON.stringify({
ts: new Date().toISOString(),
runner: "snappy-shell",
mode: opts.prompt ? "print" : "repl",
promptPreview: (opts.prompt || "").slice(0, 200),
tools,
injected_skills: dynamicSkills,
...result,
outputPreview: (result.output || "").slice(0, 500),
}) + "\n");
} catch { /* log failure non-fatal */ }
return result;
}
// --- 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.
*/
/** THE HOST-FACING FACTS ⟨lane CONTRACTS N–Z, 2026-09-09⟩. `class` is the
* closed effect set snappy-tool-design rule 18 grades; `annotations` are
* DERIVED from it by the ONE derivation in
* `snappy-settings/tool-annotations.ts`, never written per verb, so a class
* and its published hints cannot disagree; `refusals` projects the ONE closed
* table in `snappy-settings/refusal-codes.ts`; `requires` is exactly the
* credential keys this file's own executable reads name, and nothing else. */
export const HAND_CONTRACT = {
skill: "snappy-shell",
description: "Kernel-loaded fallback runner. Boots pi with the full snappy kernel (CLAUDE.md + always-inject skills) as the system prompt, backed by OpenAI/OpenRouter/Anthropic. Use when Claude Code is unavailable or you want to run a workflow on a different brain. Triggers on mentions of fallback runner, snappy-shell, pi REPL, Claude Code down/outage, or running a skill on a non-Claude provider.",
managed: false,
requires: [] as string[],
refusals: refusalTable("rate_limited", "unknown_verb"),
verbs: {
},
} 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])}`) {
const args = process.argv.slice(2);
let model: string | undefined;
let tools: string[] | undefined;
let sessionPath: string | undefined;
const extraSkills: string[] = [];
const promptParts: string[] = [];
// `scan` subcommand — primitive smoke test for Step 8d. Reports which
// skills would auto-inject for a given prompt, without spending API
// credits. Verification 37/38 in the plan use this.
if (args[0] === "scan") {
const wantJson = args.includes("--json");
const promptStart = args.findIndex((a, i) => i > 0 && a !== "--json");
const scanPrompt = promptStart >= 0 ? args.slice(promptStart).filter(a => a !== "--json").join(" ") : "";
if (!scanPrompt) {
console.error("Usage: snappy-shell scan [--json] <prompt>");
process.exit(1);
}
const matched = dynamicInjectionForTurn(scanPrompt);
if (wantJson) {
console.log(JSON.stringify({ prompt: scanPrompt, matched, count: matched.length }));
} else {
console.log(`scanned: ${scanPrompt.slice(0, 80)}`);
console.log(`matched: ${matched.length} skill${matched.length === 1 ? "" : "s"}`);
for (const m of matched) console.log(` - ${m}`);
}
process.exit(0);
}
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === "--model" || a === "-m") { model = args[++i]; continue; }
if (a === "--tools") { tools = args[++i].split(","); continue; }
if (a === "--session") { sessionPath = args[++i]; continue; }
if (a === "--skill") { extraSkills.push(args[++i]); continue; }
if (a === "--help" || a === "-h") {
console.log(`snappy-shell — kernel-loaded fallback runner
Usage:
snappy-shell [flags] # interactive REPL mode
snappy-shell [flags] "prompt..." # one-shot --print mode
Flags:
-m, --model <alias|provider/id> Default: auto (his Providers-tab order, else openai → openrouter → anthropic)
--tools <csv> Default: read,bash,edit,write,grep,ls
--skill <name> Extra skill to inject (repeatable)
--session <path> Persist session to this file
-h, --help Show this help
Model aliases: ${Object.keys(ALIASES).join(", ")}, or "auto", or "provider/model-id".
Examples:
snappy-shell # interactive, auto-pick model
snappy-shell --model gpt-4o # interactive on GPT-4o
snappy-shell -m gemini "count snappy-* skills under ~/.claude/skills"
snappy-shell --skill snappy-inbox-sweep # inject inbox-sweep into kernel prompt`);
process.exit(0);
}
promptParts.push(a);
}
const prompt = promptParts.length ? promptParts.join(" ") : undefined;
runShell({ prompt, model, tools, extraSkills, sessionPath })
.then(r => {
if (r.ok) {
if (r.output) process.stdout.write(r.output + "\n");
process.exit(0);
} else {
console.error(`[snappy-shell] failed: exit=${r.exitCode} err=${r.error || "-"}`);
if (r.stderr) console.error(r.stderr);
process.exit(r.exitCode || 1);
}
})
.catch(e => { console.error(e); process.exit(1); });
}
#!/usr/bin/env npx tsx
/**
* snappy-shell/api.ts — Kernel-loaded fallback runner.
*
* Boots `pi` with the snappy kernel (always-inject skills) loaded as the
* system prompt, backed by any provider (OpenAI default, OpenRouter fallback,
* Anthropic if key is set). Use when Claude Code quota dies mid-workflow.
*
* Modes:
* - REPL: `snappy-shell` → interactive pi session
* - One-shot: `snappy-shell "do thing X"` → --print mode, single response
*
* Model selection:
* - Default `auto`: OpenAI if OPENAI_API_KEY set, else OpenRouter gemini
* - Aliases: gpt-4o, gpt-5, gemini, gemini-pro, llama, sonnet (see ALIASES)
* - Or full `provider/model-id` strings
*
* Every session logs one line to ~/.claude/logs/agent-runs.ndjson with the
* shared schema (runner, provider, model, durationMs, ok) so shell + dispatch
* runs sit in the same file for later comparison.
*/
import { spawn, spawnSync } from "child_process";
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "fs";
import { dirname } from "path";
import { tmpdir } from "os";
import { env, loadAll } from "../snappy-settings/load.ts";
import { annotationsForClass } from "../snappy-settings/tool-annotations.ts";
import { refusalTable } from "../snappy-settings/refusal-codes.ts";
import { providerOrder } from "../snappy-settings/providers-choice.ts";
const KERNEL_LOG = `${process.env.HOME}/.claude/logs/agent-runs.ndjson`;
const ALWAYS_INJECT_PATH = `${process.env.HOME}/.claude/hooks/always-inject.txt`;
const SKILLS_ROOT = `${process.env.HOME}/.claude/skills`;
const GLOBAL_CLAUDE_MD = `${process.env.HOME}/.claude/CLAUDE.md`;
const GAPS_LOG = `${process.env.HOME}/.claude/logs/agents-md-gaps.log`;
/**
* Step 8d — per-turn dynamic skill injection.
*
* Scans a prompt for snappy-<name> mentions using the same word-boundary
* regex as preload-skill-context.sh:60. Returns the matched skill names
* that have an AGENTS.md on disk. Mentioned-but-missing skills are
* appended to ~/.claude/logs/agents-md-gaps.log so the next snapshot
* can surface the gap, identical to the Claude Code hook behavior.
*
* Used by runShell() before kernel-prompt construction so the resulting
* system message has the same skill set Claude Code would auto-inject
* for the same prompt.
*/
export function dynamicInjectionForTurn(prompt: string): string[] {
if (!prompt || !existsSync(SKILLS_ROOT)) return [];
const matched: string[] = [];
let dirs: string[] = [];
try {
dirs = readdirSync(SKILLS_ROOT, { withFileTypes: true })
.filter(d => d.isDirectory() && d.name.startsWith("snappy-"))
.map(d => d.name);
} catch { return []; }
for (const name of dirs) {
// Same boundary regex as preload-skill-context.sh:61
const re = new RegExp(`(^|[^a-zA-Z0-9_-])${name}([^a-zA-Z0-9_-]|$)`);
if (!re.test(prompt)) continue;
const agentsPath = `${SKILLS_ROOT}/${name}/AGENTS.md`;
if (existsSync(agentsPath)) {
matched.push(name);
} else {
try {
mkdirSync(dirname(GAPS_LOG), { recursive: true });
appendFileSync(
GAPS_LOG,
`[${new Date().toISOString()}] gap:${name} prompt:${prompt.slice(0, 120)}\n`,
);
} catch { /* gap log non-fatal */ }
}
}
return matched;
}
type ModelResolution = { provider: string; model: string; keyEnv: string };
/**
* ── THE CASCADE IS HIS ORDER, NOT THIS FILE'S ────────────────────────────────
*
* ⟨owner, 2026-09-09 10:25⟩ he chooses a Default provider and a Fallback order
* on the bar's Providers tab. Until now `auto` walked the four aliases below in
* the order this file was written in, so his saved order was stored and dead.
* `cascadeAliases()` now walks `providerOrder()` — the default first, then the
* fallback — read through `snappy-settings/providers-choice.ts`, the one reader
* of that document.
*
* HARD_CASCADE stays as the FALLBACK-OF-THE-FALLBACK: on a machine where he has
* never opened the panel (measured 2026-09-09: `~/.snappy-skills/providers.json`
* did not exist), `auto` must still work exactly as it did, and this is the
* order that has been shipping.
*/
const HARD_CASCADE: Array<{ alias: string; why: string }> = [
{ alias: "gpt-4o", why: "OpenAI (user sub)" },
{ alias: "gemini-pro", why: "OpenRouter Gemini 2.5 Pro" },
{ alias: "gemini", why: "OpenRouter Gemini 2.5 Flash" },
{ alias: "sonnet", why: "Anthropic Sonnet (if key set)" },
];
/**
* His provider ids → the aliases THIS runner can actually spend on them.
*
* `openrouter` expands to two, in the shipping order: one saved id must not
* silently drop the Flash rung that has been in the cascade all along.
*
* `gemini` (the Google subscription jcode reads) maps to NOTHING here on
* purpose. pi reaches Google only through an OpenRouter model id, so answering
* an OpenRouter alias for it would spend a provider he did not name and report
* the wrong one — a road that lies about which subscription it burned is worse
* than a rung that is missing. It is skipped, and the rungs he named are kept.
*/
const SHELL_ALIASES_FOR_CHOICE: Record<string, string[]> = {
chatgpt: ["gpt-4o"],
"openai-api": ["gpt-4o"],
openrouter: ["gemini-pro", "gemini"],
claude: ["sonnet"],
};
/** The ordered rungs `auto` walks. Exported so a test can read the order. */
export function cascadeAliases(): Array<{ alias: string; why: string }> {
const chosen: Array<{ alias: string; why: string }> = [];
for (const id of providerOrder()) {
for (const alias of SHELL_ALIASES_FOR_CHOICE[id] ?? []) {
if (!chosen.some((rung) => rung.alias === alias)) chosen.push({ alias, why: `${id} (his order)` });
}
}
return chosen.length > 0 ? chosen : HARD_CASCADE;
}
const FALLTHROUGH_PATTERNS = [
/exceeded your current quota/i,
/insufficient_quota/i,
/rate.?limit/i,
/invalid.?api.?key/i,
/unauthorized/i,
/401/i,
/402/i,
/429/i,
/authentication/i,
/billing/i,
/ECONNREFUSED/i,
/ENOTFOUND/i,
/ETIMEDOUT/i,
];
function shouldFallthrough(stderr: string, output: string): boolean {
const blob = `${stderr}\n${output}`;
return FALLTHROUGH_PATTERNS.some(re => re.test(blob));
}
const ALIASES: Record<string, ModelResolution> = {
// OpenAI (primary — Robert's sub)
"gpt-4o": { provider: "openai", model: "gpt-4o", keyEnv: "OPENAI_API_KEY" },
"gpt-4": { provider: "openai", model: "gpt-4-turbo", keyEnv: "OPENAI_API_KEY" },
"gpt-5": { provider: "openai", model: "gpt-5", keyEnv: "OPENAI_API_KEY" },
"gpt-5.4": { provider: "openai", model: "gpt-5.4", keyEnv: "OPENAI_API_KEY" },
// OpenRouter (fallback)
"gemini": { provider: "openrouter", model: "google/gemini-2.5-flash", keyEnv: "OPENROUTER_API_KEY" },
"gemini-pro": { provider: "openrouter", model: "google/gemini-2.5-pro", keyEnv: "OPENROUTER_API_KEY" },
"gemini-3": { provider: "openrouter", model: "google/gemini-3-pro", keyEnv: "OPENROUTER_API_KEY" },
"llama": { provider: "openrouter", model: "meta-llama/llama-3.3-70b-instruct", keyEnv: "OPENROUTER_API_KEY" },
"qwen": { provider: "openrouter", model: "qwen/qwen-2.5-72b-instruct", keyEnv: "OPENROUTER_API_KEY" },
"deepseek": { provider: "openrouter", model: "deepseek/deepseek-chat", keyEnv: "OPENROUTER_API_KEY" },
// Anthropic (only works if ANTHROPIC_API_KEY set — normally empty in Claude Code)
"sonnet": { provider: "anthropic", model: "claude-sonnet-4-6", keyEnv: "ANTHROPIC_API_KEY" },
"haiku": { provider: "anthropic", model: "claude-haiku-4-5", keyEnv: "ANTHROPIC_API_KEY" },
};
function resolveSingle(model: string): ModelResolution {
const m = model.trim();
if (ALIASES[m]) return ALIASES[m];
if (m.includes("/")) {
const [prov] = m.split("/");
const keyEnv = prov === "openai" ? "OPENAI_API_KEY"
: prov === "anthropic" ? "ANTHROPIC_API_KEY"
: prov === "google" ? "GEMINI_API_KEY"
: "OPENROUTER_API_KEY";
const provider = prov === "openai" || prov === "anthropic" ? prov : "openrouter";
return { provider, model: m, keyEnv };
}
throw new Error(`Unknown model "${m}". Known aliases: ${Object.keys(ALIASES).join(", ")}, or "auto", or "provider/model-id".`);
}
/**
* Build the ordered candidate list for a given user-facing model spec.
* "auto" expands to cascadeAliases() — his saved order, else HARD_CASCADE —
* filtered to aliases whose key is present.
* Everything else resolves to a single-element list.
*/
function resolveCandidates(model: string): ModelResolution[] {
const m = (model || "auto").trim();
if (m !== "auto") return [resolveSingle(m)];
const creds = loadAll();
const candidates: ModelResolution[] = [];
for (const { alias } of cascadeAliases()) {
const res = ALIASES[alias];
if (!res) continue;
if (creds[res.keyEnv]) candidates.push(res);
}
if (candidates.length === 0) {
throw new Error("auto: no provider key found in .env.cache (OPENAI_API_KEY / OPENROUTER_API_KEY / ANTHROPIC_API_KEY)");
}
return candidates;
}
/**
* Build the kernel system prompt by concatenating:
* 1. Global CLAUDE.md (snappy bootstrap loader)
* 2. Every AGENTS.md listed in always-inject.txt
* 3. A footer telling the runner it is the snappy fallback
*/
export function buildKernelPrompt(extraSkills: string[] = []): string {
const parts: string[] = [];
parts.push("# SNAPPY KERNEL (fallback runner)\n");
parts.push("You are running as a snappy fallback agent — same kernel, same skills, different brain.");
parts.push("You have full shell, file, and network access via your tools. Treat the content below as authoritative.\n");
if (existsSync(GLOBAL_CLAUDE_MD)) {
parts.push("## Global bootstrap (from ~/.claude/CLAUDE.md)\n");
parts.push(readFileSync(GLOBAL_CLAUDE_MD, "utf-8").trim());
parts.push("");
}
const injectNames = new Set<string>();
if (existsSync(ALWAYS_INJECT_PATH)) {
for (const raw of readFileSync(ALWAYS_INJECT_PATH, "utf-8").split("\n")) {
const line = raw.trim();
if (!line || line.startsWith("#")) continue;
injectNames.add(line);
}
}
for (const name of extraSkills) injectNames.add(name);
for (const name of injectNames) {
const agentsPath = `${SKILLS_ROOT}/${name}/AGENTS.md`;
if (!existsSync(agentsPath)) continue;
parts.push(`## Skill: ${name}\n`);
parts.push(readFileSync(agentsPath, "utf-8").trim());
parts.push("");
}
parts.push("---");
parts.push("## Fallback runner reminders");
parts.push("- Some snappy-* skills use MCP servers or subagents that only exist inside Claude Code — those will fail here. Use the shell, read/write, and direct api.ts calls instead.");
parts.push("- Credentials live in `~/.claude/skills/snappy-settings/.env.cache`. Load via `import { env } from '~/.claude/skills/snappy-settings/load.ts'`.");
parts.push("- Every skill has an `api.ts` — prefer calling it via `npx tsx ~/.claude/skills/<skill>/api.ts ...` over guessing.");
parts.push("- Robert reviews before anything posts. Do not auto-publish.");
return parts.join("\n");
}
export interface ShellOptions {
prompt?: string; // if present → --print one-shot; if absent → interactive
model?: string; // alias or provider/model-id, default "auto"
tools?: string[]; // default read,bash,edit,write,grep,ls
extraSkills?: string[]; // additional skills to inject beyond always-inject.txt
cwd?: string;
sessionPath?: string; // for --session persistence
}
export interface ShellResult {
ok: boolean;
output: string;
stderr: string;
durationMs: number;
provider: string;
model: string;
exitCode: number;
kernelPromptBytes: number;
error?: string;
attempts?: Array<{ provider: string; model: string; ok: boolean; exitCode: number; durationMs: number; fallthroughReason?: string }>;
}
async function runOne(
resolution: ModelResolution,
opts: ShellOptions,
promptFile: string,
tools: string,
): Promise<{ ok: boolean; output: string; stderr: string; exitCode: number; durationMs: number; error?: string }> {
const started = Date.now();
const { provider, model, keyEnv } = resolution;
const apiKey = env(keyEnv);
const args = [
"--provider", provider,
"--model", model,
"--append-system-prompt", promptFile,
"--tools", tools,
"--no-skills",
"--no-extensions",
"--no-prompt-templates",
];
if (opts.sessionPath) args.push("--session", opts.sessionPath);
if (opts.prompt) args.push("--print", opts.prompt);
const spawnEnv: NodeJS.ProcessEnv = { ...process.env, [keyEnv]: apiKey };
if (opts.prompt) {
const r = spawnSync("pi", args, {
encoding: "utf-8",
cwd: opts.cwd,
env: spawnEnv,
timeout: 600_000,
maxBuffer: 20 * 1024 * 1024,
});
const output = (r.stdout || "").trim();
const stderr = (r.stderr || "").trim();
const exitCode = r.status ?? -1;
const ok = !r.error && exitCode === 0 && output.length > 0;
return { ok, output, stderr, exitCode, durationMs: Date.now() - started, error: r.error?.message };
} else {
const child = spawn("pi", args, {
cwd: opts.cwd,
env: spawnEnv,
stdio: "inherit",
});
const exitCode = await new Promise<number>(res => child.on("exit", code => res(code ?? -1)));
return { ok: exitCode === 0, output: "", stderr: "", exitCode, durationMs: Date.now() - started };
}
}
export async function runShell(opts: ShellOptions = {}): Promise<ShellResult> {
const started = Date.now();
const candidates = resolveCandidates(opts.model || "auto");
const tools = (opts.tools && opts.tools.length ? opts.tools : ["read", "bash", "edit", "write", "grep", "ls"]).join(",");
// Step 8d — per-turn dynamic skill injection (print-mode only; REPL has no
// single prompt to scan at boot). Mirrors preload-skill-context.sh so the
// brain swap stops being a downgrade: same regex, same dedupe, same gap log.
const dynamicSkills = opts.prompt ? dynamicInjectionForTurn(opts.prompt) : [];
const mergedExtras = Array.from(new Set([...(opts.extraSkills || []), ...dynamicSkills]));
const kernelPrompt = buildKernelPrompt(mergedExtras);
const kernelPromptBytes = Buffer.byteLength(kernelPrompt, "utf-8");
const promptFile = `${tmpdir()}/snappy-shell-kernel-${process.pid}-${Date.now()}.md`;
writeFileSync(promptFile, kernelPrompt, "utf-8");
const attempts: NonNullable<ShellResult["attempts"]> = [];
let lastRun: Awaited<ReturnType<typeof runOne>> | null = null;
let usedResolution: ModelResolution = candidates[0];
for (let i = 0; i < candidates.length; i++) {
const cand = candidates[i];
usedResolution = cand;
const attemptLabel = `${cand.provider}/${cand.model}`;
// In interactive mode we can't usefully retry — the user would be mid-session.
// Only cascade in --print mode where there's no human in the loop.
if (!opts.prompt && i > 0) break;
try {
const r = await runOne(cand, opts, promptFile, tools);
lastRun = r;
if (r.ok) {
attempts.push({ provider: cand.provider, model: cand.model, ok: true, exitCode: r.exitCode, durationMs: r.durationMs });
break;
}
const fallthrough = shouldFallthrough(r.stderr, r.output);
attempts.push({
provider: cand.provider,
model: cand.model,
ok: false,
exitCode: r.exitCode,
durationMs: r.durationMs,
fallthroughReason: fallthrough ? (r.stderr.slice(0, 200) || r.output.slice(0, 200) || "unknown") : undefined,
});
if (!fallthrough) break;
if (i === candidates.length - 1) break;
process.stderr.write(`[snappy-shell] ${attemptLabel} failed with retryable error, falling through...\n`);
} catch (e: any) {
attempts.push({
provider: cand.provider,
model: cand.model,
ok: false,
exitCode: -1,
durationMs: 0,
fallthroughReason: e.message?.slice(0, 200) || "spawn error",
});
lastRun = { ok: false, output: "", stderr: e.message || "", exitCode: -1, durationMs: 0, error: e.message };
if (i === candidates.length - 1) break;
process.stderr.write(`[snappy-shell] ${attemptLabel} threw: ${e.message}. falling through...\n`);
}
}
const durationMs = Date.now() - started;
const result: ShellResult = {
ok: lastRun?.ok ?? false,
output: lastRun?.output ?? "",
stderr: lastRun?.stderr ?? "",
durationMs,
provider: usedResolution.provider,
model: usedResolution.model,
exitCode: lastRun?.exitCode ?? -1,
kernelPromptBytes,
error: lastRun?.error,
attempts: attempts.length > 1 ? attempts : undefined,
};
try {
mkdirSync(dirname(KERNEL_LOG), { recursive: true });
appendFileSync(KERNEL_LOG, JSON.stringify({
ts: new Date().toISOString(),
runner: "snappy-shell",
mode: opts.prompt ? "print" : "repl",
promptPreview: (opts.prompt || "").slice(0, 200),
tools,
injected_skills: dynamicSkills,
...result,
outputPreview: (result.output || "").slice(0, 500),
}) + "\n");
} catch { /* log failure non-fatal */ }
return result;
}
// --- 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.
*/
/** THE HOST-FACING FACTS ⟨lane CONTRACTS N–Z, 2026-09-09⟩. `class` is the
* closed effect set snappy-tool-design rule 18 grades; `annotations` are
* DERIVED from it by the ONE derivation in
* `snappy-settings/tool-annotations.ts`, never written per verb, so a class
* and its published hints cannot disagree; `refusals` projects the ONE closed
* table in `snappy-settings/refusal-codes.ts`; `requires` is exactly the
* credential keys this file's own executable reads name, and nothing else. */
export const HAND_CONTRACT = {
skill: "snappy-shell",
description: "Kernel-loaded fallback runner. Boots pi with the full snappy kernel (CLAUDE.md + always-inject skills) as the system prompt, backed by OpenAI/OpenRouter/Anthropic. Use when Claude Code is unavailable or you want to run a workflow on a different brain. Triggers on mentions of fallback runner, snappy-shell, pi REPL, Claude Code down/outage, or running a skill on a non-Claude provider.",
managed: false,
requires: [] as string[],
refusals: refusalTable("rate_limited", "unknown_verb"),
verbs: {
},
} 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])}`) {
const args = process.argv.slice(2);
let model: string | undefined;
let tools: string[] | undefined;
let sessionPath: string | undefined;
const extraSkills: string[] = [];
const promptParts: string[] = [];
// `scan` subcommand — primitive smoke test for Step 8d. Reports which
// skills would auto-inject for a given prompt, without spending API
// credits. Verification 37/38 in the plan use this.
if (args[0] === "scan") {
const wantJson = args.includes("--json");
const promptStart = args.findIndex((a, i) => i > 0 && a !== "--json");
const scanPrompt = promptStart >= 0 ? args.slice(promptStart).filter(a => a !== "--json").join(" ") : "";
if (!scanPrompt) {
console.error("Usage: snappy-shell scan [--json] <prompt>");
process.exit(1);
}
const matched = dynamicInjectionForTurn(scanPrompt);
if (wantJson) {
console.log(JSON.stringify({ prompt: scanPrompt, matched, count: matched.length }));
} else {
console.log(`scanned: ${scanPrompt.slice(0, 80)}`);
console.log(`matched: ${matched.length} skill${matched.length === 1 ? "" : "s"}`);
for (const m of matched) console.log(` - ${m}`);
}
process.exit(0);
}
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === "--model" || a === "-m") { model = args[++i]; continue; }
if (a === "--tools") { tools = args[++i].split(","); continue; }
if (a === "--session") { sessionPath = args[++i]; continue; }
if (a === "--skill") { extraSkills.push(args[++i]); continue; }
if (a === "--help" || a === "-h") {
console.log(`snappy-shell — kernel-loaded fallback runner
Usage:
snappy-shell [flags] # interactive REPL mode
snappy-shell [flags] "prompt..." # one-shot --print mode
Flags:
-m, --model <alias|provider/id> Default: auto (his Providers-tab order, else openai → openrouter → anthropic)
--tools <csv> Default: read,bash,edit,write,grep,ls
--skill <name> Extra skill to inject (repeatable)
--session <path> Persist session to this file
-h, --help Show this help
Model aliases: ${Object.keys(ALIASES).join(", ")}, or "auto", or "provider/model-id".
Examples:
snappy-shell # interactive, auto-pick model
snappy-shell --model gpt-4o # interactive on GPT-4o
snappy-shell -m gemini "count snappy-* skills under ~/.claude/skills"
snappy-shell --skill snappy-inbox-sweep # inject inbox-sweep into kernel prompt`);
process.exit(0);
}
promptParts.push(a);
}
const prompt = promptParts.length ? promptParts.join(" ") : undefined;
runShell({ prompt, model, tools, extraSkills, sessionPath })
.then(r => {
if (r.ok) {
if (r.output) process.stdout.write(r.output + "\n");
process.exit(0);
} else {
console.error(`[snappy-shell] failed: exit=${r.exitCode} err=${r.error || "-"}`);
if (r.stderr) console.error(r.stderr);
process.exit(r.exitCode || 1);
}
})
.catch(e => { console.error(e); process.exit(1); });
}