UNPKG

kestrel.markets

Version:

A typed, token-efficient language + runtime for agentic trading: agents author bounded plans, the runtime fires them at the tick. CLI + typed library + MCP server.

257 lines (248 loc) 15.3 kB
/** * # cli/index — the Kestrel CLI router (Kestrel CLI v1 §3) * * `main(argv)` resolves the render context ONCE, fast-paths the meta commands **before** any dynamic * import, dispatches LIGHT commands from eagerly-imported light modules, and lazy-imports the HEAVY * command modules only when a heavy verb is chosen. The whole body is wrapped in one try/catch; any * throw is rendered by {@link fail} and its exit code returned. `bin.ts` does `process.exit(await * main(...))`. * * Top-level imports are LIGHT only (args/context/errors/meta/lang/frame → `src/lang`, `src/frame`, * node built-ins). NO top-level import of `session/*`, `registry`, `bus`, or `adapters/lake`, so the * bundled `dist/cli.js` runs under bun-less node for every LIGHT command; heavy verbs fault loud * (exit 4) via `loadHeavy`. */ import { extractGlobals } from "./args.ts"; import { loadEnvFallback } from "./credentials.ts"; import { loadHeavy } from "./commands/_heavy.ts"; import { resolveFromProcess, type OutputCtx } from "./context.ts"; import { resolveCallerFromProcess, type Caller } from "./caller.ts"; import { CliError, EXIT, fail } from "./errors.ts"; import { helpCommand, versionCommand } from "./commands/meta.ts"; import { orientCommand } from "./commands/orient.ts"; import { validateCommand, printCommand } from "./commands/lang.ts"; import { frameCommand } from "./commands/frame.ts"; import { resolveBackend } from "./backend/select.ts"; import type { CliIo } from "./commands/agent.ts"; function unknownCommand(cmd: string): CliError { return new CliError({ code: "USAGE", exit: EXIT.USAGE, message: `unknown command ${JSON.stringify(cmd)}`, hint: "run `kestrel help` for the command list", }); } export async function main(argv: readonly string[], io?: CliIo): Promise<number> { // Hydrate `process.env` from the shared out-of-tree secrets home (~/.kestrel/.env, OSS-ADR-0054) // BEFORE any command resolves its config: a downstream reader (the `paper` verb's IBKR gateway // resolver, a bench pull) then sees a stored dev key as if it were exported. Fail-quiet and // dependency-light — `process.env` always WINS, an absent/hermetic store is a no-op, no value is // ever logged (so it cannot perturb determinism or a fixture's environment). loadEnvFallback(); // Arg pre-parse + render-context resolution BOTH fail closed to a minimal machine-safe context: a throw // here (a bad global flag, `--format`/`--color` with no value) must render the SAME clean // `error\tcode=…` line and a NONZERO exit as any other failure — never escape `main` as an unhandled // rejection (which dumps a raw stack and, on older node, lets the process exit 0). `extractGlobals` // previously ran OUTSIDE this guard, so a bad global flag rejected `main` instead of failing closed. let parsed: ReturnType<typeof extractGlobals>; let ctx: OutputCtx; let caller: Caller; try { parsed = extractGlobals(argv); ctx = resolveFromProcess(parsed.globals); // The ONE Caller resolution (ADR-0035 §a/§b), env-first / TTY-second. Additive: it governs ONLY // the bare-invocation orientation below; every one-shot verb keeps `resolveOutputCtx`'s existing // per-command rendering byte-for-byte (the ladder does not re-render verbs — PRD §0.2). caller = resolveCallerFromProcess(parsed.globals); } catch (err) { return fail({ mode: "text", color: false, width: Infinity, interactive: false, stream: false }, err); } const { globals, rest, wantHelp, wantVersion } = parsed; try { // Meta fast-path — BEFORE any dynamic import. `help`/`--help` → the full usage (stdout, exit 0); // `<cmd> --help` (and `help <cmd>`) route to that command's OWN usage; `rest` still carries the // command path (extractGlobals peeled only the render/meta flags), and helpCommand stays LIGHT // (no heavy import), so per-command help never pulls bun/chdb (kestrel-1qc). if (wantVersion) return versionCommand(ctx); if (wantHelp) return helpCommand(ctx, rest); // The bare invocation (no verb, no `--help`) renders the ORIENTATION, not the generic help // (ADR-0035 §c, bead kestrel-jvr4.3). Same content two Renderings: a resolved agent/CI/pipe gets // static `text` and exits 0 without reading stdin; a confident human gets it as the session's // opening view. `orientCommand` is LIGHT (node built-ins only) — it never faults exit 4. if (rest.length === 0) return orientCommand(ctx, caller, globals); // Local is the DEFAULT; `--api`/`KESTREL_API` selects the RemoteBackend. Node-light: this // pulls no heavy runtime, so it is safe to resolve before dispatching any LIGHT command. const backend = resolveBackend(globals, process.env as Record<string, string | undefined>); const [command, ...args] = rest; switch (command) { // ── LIGHT (node-runnable, eager light imports) ── case "version": return versionCommand(ctx); case "help": return helpCommand(ctx); case "parse": case "validate": // `await`, not a bare return: a rejection must be caught by THIS try (the fail() renderer), // and the command is async only for its lazy arm-tier edge (node-portable engine import). return await validateCommand(args, ctx); case "print": return printCommand(args, ctx); case "card": { // The authoring on-ramp (kestrel-nde6): print the shipped agent language card / first-steps // walkthroughs from the INSTALLED package, offline. LIGHT (node built-ins + fs only, no // network, no bun/chdb) — lazy-imported to keep the meta fast-path graph minimal, like `sim`. const card = await import("./commands/card.ts"); return card.cardCommand(args, ctx); } case "frame": case "percept": return await frameCommand(args, ctx, backend); case "register": { // Autonomous-agent self-registration (kestrel-markets-0t0, PLAT-ADR-0021 tier 1). // Lazy-imported: it is node-light (built-ins + fetch), but kept off the eager graph // so the meta/light hot path stays minimal. Threads `globals` for the API base. const register = await import("./commands/register.ts"); return await register.registerCommand(args, ctx, globals); } case "whoami": { // Self-inspection of the stored credential (kestrel-p7th): identity, scopes, expiry + // display-clock status, API host, keypair enrollment, file path — no hand-parsing // credentials.json. LIGHT: pure-local read (node built-ins + fs only, ZERO network, no // bun/chdb). Lazy-imported to keep the meta fast-path graph minimal. const self = await import("./commands/self.ts"); return self.whoamiCommand(args, ctx); } case "secrets": { // Operator/BYOK secret residency in `~/.kestrel/.env` (kestrel-jh9w.2 over the jh9w.1 // store). LIGHT: node built-ins + fs only, ZERO network. A secret VALUE never comes from // argv (prompt or --stdin only) and never appears in any output path. const secrets = await import("./commands/secrets.ts"); return await secrets.secretsCommand(args, ctx); } case "refresh": { // Renew the stored durable capability BEFORE it lapses (kestrel-p7th), projecting the // platform's existing `POST /capabilities/refresh` primitive onto the CLI face. LIGHT: // node built-ins + fetch, host-scoped to the credential's own `api`. The fresh token is // minted SERVER-SIDE — no client RNG, no client wall-clock in the token (determinism // doctrine). Lazy-imported to keep the meta fast-path graph minimal. const self = await import("./commands/self.ts"); return await self.refreshCommand(args, ctx, globals); } case "sim": { // The one-command funnel (kestrel-585 / kestrel-vcn). LIGHT: it drives the hosted // funnel over fetch only (no bun/chdb), so the site's hero snippet runs under plain // node. It always routes remote (a sim positional NAMES platform data) — its own base // resolution, NOT `resolveBackend` (which defaults local). Lazy-imported to keep the // meta fast-path graph minimal, but the module stays node-light so it never faults exit 4. const sim = await import("./commands/sim.ts"); return await sim.simCommand(args, ctx, globals); } case "prove": { // The zero-credential front door (kestrel-markets-n04e.1). LIGHT: it reuses `sim`'s // hosted core over fetch only (no bun/chdb) — so `npx kestrel.markets prove` runs under // plain node with no key, no config, no prompt. Lazy-imported to keep the meta fast-path // graph minimal; it stays node-light so it never faults exit 4. const prove = await import("./commands/prove.ts"); return await prove.proveCommand(args, ctx, globals); } case "replay": { // The reproduction verb (n04e.1): re-run a local proof, or degrade to verifying a // published one. LIGHT — the same fetch-only hosted core + the local JSONL ledger. const replay = await import("./commands/replay.ts"); return await replay.replayCommand(args, ctx, globals); } case "verify": { // The zero-trust proof check (n04e.1): re-verify a published proof's Ed25519 signature // against the INDEPENDENTLY-fetched published keys (node crypto). LIGHT, mints no trial. const verify = await import("./commands/verify.ts"); return await verify.verifyCommand(args, ctx, globals); } case "certify": { // Open recomputation (gate G10, kestrel-8kvs): fetch the proof's evidence bundle, re-project // the Blotter LOCALLY with the shipped projector, and assert byte-identical reproduction of the // hosted result. LIGHT — project/serialize/readBusText + pure sha256, no bun/chdb, no engine drive. const certify = await import("./commands/certify.ts"); return await certify.certifyCommand(args, ctx, globals); } // ── HEAVY *for the LOCAL transport only* (lazy import, then lazy bun/chdb inside) ── case "agent": { // The machine/agent mode — a LOSSLESS projection of the djm.5 SDK (kestrel-djm.6). Reads a JSONL // request stream and writes versioned protocol JSONL; endpoint selection (globals.api) is transport- // ONLY, and it is exactly what decides whether this verb is heavy at all (kestrel-z5lr). // // `agent` with NO `--api` builds `createSdk(localTransport())` — the local runtime — so it must pass // the same up-front bun guard every heavy verb passes (kestrel-mkn review B1): the module graph loads // fine under node (the Bun dependency is a LAZY global, not a `bun:*` specifier), so without the guard // {op:catalog} answered normally-looking data and {op:openSession} leaked a raw `Bun is not defined` // onto the PROTOCOL channel instead of the loud exit-4 RUNTIME_UNAVAILABLE. // // `agent --api <url>` builds `createSdk(remoteTransport(...))` — pure HTTP over `fetch`, ZERO bun // dependency, the node-only thin client the front door targets. Gating IT on the runtime over-fired: // it hard-refused exit 4 under bun-less node while the equivalent `run --api` (which gates at the // POINT OF USE — `LocalBackend.openSession`/`openDaySession` call `loadHeavy`, the RemoteBackend never // does) worked, breaking the documented transport parity. So gate on `globals.api === undefined`: the // LOCAL transport demands the heavy runtime, the REMOTE transport never did and no longer claims to. // `buildSdk` reads `globals.api` by the SAME rule, so the gate and the transport cannot disagree. const load = (): Promise<typeof import("./commands/agent.ts")> => import("./commands/agent.ts"); const agent = globals.api === undefined ? await loadHeavy(load) : await load(); return await agent.agentCommand(globals, io); } case "mcp": { // The MCP face over stdio (kestrel-0f7i) — `serveStdio(createKestrelMcpServer(...))` behind a // transport CHOICE. DEFAULT is the REMOTE transport (pure fetch, node-runnable — the npx stdio // drop-in an MCP client configures); `--local` opts into the in-process engine and is the ONE // heavy arm, gated through `loadHeavy` exactly like `agent`'s local transport so a bun-less // node refuses LOUD (exit 4) instead of leaking a raw `Bun is not defined` onto the MCP wire. // The gate keys on the SAME tokens `resolveMcpTransport` reads (`--local` present AND no // `--api`), so gate and transport cannot disagree — and a `--local --api` CONTRADICTION is // diagnosed as USAGE (inside the command, whose module graph is node-loadable) on every host, // never masked by a runtime-availability refusal. const load = (): Promise<typeof import("./commands/mcp.ts")> => import("./commands/mcp.ts"); const wantsLocal = args.includes("--local") && globals.api === undefined; const mcp = wantsLocal ? await loadHeavy(load) : await load(); return await mcp.mcpCommand(args, globals); } case "run": { const grade = await import("./commands/grade.ts"); return await grade.runCommand(args, ctx, backend); } case "day": { const grade = await import("./commands/grade.ts"); return await grade.dayCommand(args, ctx, backend); } case "paper": { // The PAPER session (kestrel-7o2.12) — a LIVE feed + the venue's paper gate under the SAME // engine `run`/`day` drive over recorded tape. Deliberately LOCAL-only (no `backend`): a paper // session is a socket to the operator's OWN client-launched IB Gateway on their OWN machine — // there is nothing here a remote backend could run on their behalf. PAPER-only by construction; // the driver can build no other gate. const paper = await import("./commands/paper.ts"); return await paper.paperCommand(args, ctx); } case "runs": { const [sub, ...subRest] = args; const reg = await import("./commands/registry.ts"); if (sub === "list") return await reg.runsListCommand(subRest, ctx); if (sub === "show") return await reg.runsShowCommand(subRest, ctx); if (sub === "compare") return await reg.runsCompareCommand(subRest, ctx); throw new CliError({ code: "USAGE", exit: EXIT.USAGE, message: `unknown \`runs\` subcommand ${JSON.stringify(sub ?? "")} — one of list | show | compare`, }); } case "lineage": { const reg = await import("./commands/registry.ts"); return await reg.lineageCommand(args, ctx); } case "leaderboard": { const reg = await import("./commands/registry.ts"); return await reg.leaderboardCommand(args, ctx); } default: throw unknownCommand(command!); } } catch (err) { return fail(ctx, err); } }