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
text/typescript
/**
* # 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);
}
}