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.

127 lines (111 loc) 5.88 kB
/** * # cli/context — the one capability resolver (Kestrel CLI v1 §2) * * `resolveOutputCtx` is the single place that decides HOW output renders. It is called **exactly * once** in {@link ./index.ts main} before dispatch; the frozen {@link OutputCtx} it returns is * threaded to every command handler. Nothing downstream re-probes `isTTY` — the golden rule is * enforced structurally: this resolver returns only rendering knobs, so the same inputs produce * the same data and the same exit code across all three modes. * * Pure + deterministic: it reads its `flags`/`env`/`streams` arguments, never `process` directly, * so it is trivially unit-testable (see `tests/cli/context.test.ts`). No heavy imports. */ import { CliError, EXIT } from "./errors.ts"; /** The three rendering skins. `human` = colored/aligned TTY; `text` = plain TSV/lines (agent * default, token-cheap); `json` = deterministic machine JSON. Data is identical across all three. */ export type RenderMode = "human" | "text" | "json"; /** The frozen rendering context every command reads instead of probing the terminal. */ export interface OutputCtx { readonly mode: RenderMode; readonly color: boolean; /** Columns; `Infinity` when non-TTY/machine (never wrap/truncate machine output). */ readonly width: number; /** May prompt (both stdin+stdout TTY, not CI/agent). v1 never prompts; the flag guards future. */ readonly interactive: boolean; /** May animate/spinner (stdout TTY, not CI/agent). v1 has no progress UI. */ readonly stream: boolean; } interface Env { readonly [k: string]: string | undefined; } interface Streams { readonly stdinTTY: boolean; readonly stdoutTTY: boolean; readonly columns?: number; } /** The global rendering flags parsed by {@link ./args.ts} and stripped before command parsing. */ export interface GlobalFlags { readonly json?: boolean; readonly format?: string; readonly agent?: boolean; readonly color?: string; readonly noColor?: boolean; readonly api?: string; // selects the RemoteBackend; ignored by resolveOutputCtx (presentation-only) } function usageErr(message: string): CliError { return new CliError({ code: "USAGE", exit: EXIT.USAGE, message }); } /** * Resolve the frozen render context. Precedence for **mode**: explicit `--json`/`--format` > * `--agent`/env > auto-detect (piped/CI → text, TTY → human). Color, width, interactivity, and * streaming are derived independently. See Kestrel CLI v1 §2 for the full precedence table. */ export function resolveOutputCtx(flags: GlobalFlags, env: Env, s: Streams): OutputCtx { const isCI = !!(env.CI || env.GITHUB_ACTIONS || env.BUILDKITE || env.GITLAB_CI); // A marker counts when NON-EMPTY (any value, including "0") — the SAME value semantics as // `resolveCaller`'s agent-env table (src/cli/caller.ts `detectAgentEnv`), so the two resolvers can // never disagree about whether `KESTREL_AGENT`/`AGENT` is set. Non-empty (not `=== "1"`) is the // fail-closed reading: a weird value (`AGENT=true`) resolves toward machine output, never toward // an interactive surface (ADR-0035 §b: under any ambiguity, non-interactive). const isSet = (v: string | undefined): boolean => v !== undefined && v !== ""; const agent = flags.agent === true || isSet(env.KESTREL_AGENT) || isSet(env.AGENT); // --- mode --- let mode: RenderMode; if (flags.json === true) mode = "json"; else if (flags.format !== undefined) { if (flags.format !== "json" && flags.format !== "text" && flags.format !== "human") { throw usageErr(`--format must be json|text|human, got ${JSON.stringify(flags.format)}`); } mode = flags.format === "human" ? "human" : (flags.format as RenderMode); } else if (agent) { mode = "text"; // agent alias defaults to text (token-cheap); pass --json to force json } else if (!s.stdoutTTY || isCI) { mode = "text"; // piped/redirected/CI → machine } else { mode = "human"; } // --- interactivity / streaming (independent of the mode's format) --- const interactive = mode === "human" && s.stdinTTY && s.stdoutTTY && !isCI && !agent; const stream = mode === "human" && s.stdoutTTY && !isCI && !agent; // --- color --- const noColorFlag = flags.noColor === true; let color: boolean; if (mode !== "human") color = false; // machine output is never colored else if (noColorFlag) color = false; // --no-color is a NO_COLOR-equivalent request else if ("NO_COLOR" in env) color = false; // no-color.org: presence wins else if (flags.color === "always") color = true; else if (flags.color === "never") color = false; else if (env.FORCE_COLOR !== undefined && env.FORCE_COLOR !== "0") color = true; else if (env.FORCE_COLOR === "0") color = false; else if (env.KESTREL_COLOR === "always") color = true; else if (env.KESTREL_COLOR === "never") color = false; else color = s.stdoutTTY && !isCI; if (flags.color !== undefined && flags.color !== "always" && flags.color !== "never" && flags.color !== "auto") { throw usageErr(`--color must be always|never|auto, got ${JSON.stringify(flags.color)}`); } // --- width --- const width = mode === "human" ? (s.columns ?? (env.COLUMNS !== undefined ? Number(env.COLUMNS) : 0)) || 80 : Infinity; // never wrap/truncate machine output return Object.freeze({ mode, color, width, interactive, stream }); } /** Resolve the live context from `process` — the one place that touches the real terminal. */ export function resolveFromProcess(flags: GlobalFlags): OutputCtx { const stdout = process.stdout as NodeJS.WriteStream & { columns?: number }; return resolveOutputCtx(flags, process.env as Env, { stdinTTY: Boolean(process.stdin.isTTY), stdoutTTY: Boolean(process.stdout.isTTY), ...(typeof stdout.columns === "number" ? { columns: stdout.columns } : {}), }); }