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