/** * Single source of truth for argv flag classification, shared by: * - `parseArgs` in `./args.ts` (the launch-time CLI parser) * - `extractProfileFlags` in `./profile-bootstrap.ts` (the early * `--profile` / `--alias` pre-parser) * * `parseArgs` dispatches string-valued flags by looking up their setter in * {@link STRING_SETTERS}. Optional-value flags use {@link OPTIONAL_FLAGS} so * per-flag quirks (currently empty-string rejection for `--resume`) live here * instead of being hard-coded in the dispatch loop. * * The bootstrap doesn't dispatch — it only needs to know which flags consume * a value — so it consults {@link STRING_VALUE_FLAGS} and * {@link OPTIONAL_VALUE_FLAGS}, both derived from `Object.keys(...)` on the * setter/config records below. * * The deliberate consequence: a string-valued flag exists in this CLI surface * iff it has an entry here. Adding a new string-valued flag means adding a * setter/config entry in this file; both `args.ts` and the bootstrap pick it * up automatically, so the two cannot drift out of sync. * * IMPORT RULE: this module MUST NOT import any runtime value from * `@oh-my-pi/pi-utils` (or anything that transitively does). That package's * `env.ts` eagerly loads `.env` files from `getAgentDir()` during module * initialization, which would race the profile bootstrap. Type-only imports * are erased at runtime and are therefore safe. * * If a setter needs runtime dependencies (logging, validators, lookup * tables), they're passed in through {@link ParseDeps} and `args.ts` wires the * real implementations at the dispatch site. */ import { isServiceTierOpenAISettingValue, SERVICE_TIER_OPENAI_VALUES } from "../config/service-tier"; import type { ConfiguredThinkingLevel } from "../thinking"; import type { Args } from "./args"; import { CliUsageError } from "./usage-error"; /** * Runtime dependencies injected into setters that need to validate input or * warn about bad values. `args.ts` constructs one object at module load and * passes it to each {@link STRING_SETTERS} call. * * Keeping these out of the setter closures means this module stays free of * runtime imports from `@oh-my-pi/pi-utils`, which is the whole reason it can * be safely imported by `profile-bootstrap.ts` before `setProfile` runs. */ export interface ParseDeps { logger: { warn: (message: string, meta?: Record) => void }; parseThinking: (value: string | null | undefined) => ConfiguredThinkingLevel | undefined; builtinToolNames: readonly string[]; normalizeToolNames: (values: Iterable) => string[]; thinkingEfforts: readonly string[]; } export type StringSetter = (result: Args, value: string, deps: ParseDeps) => void; /** * Setter for a flag that may or may not consume the next argv token. * Receives `undefined` for the bare form (`--resume` with no value, etc.). */ export type OptionalSetter = (result: Args, value: string | undefined) => void; /** * Per-flag optional-value consumption policy. * * Every optional flag always rejects tokens that start with `-` — that shared * rule lives in the dispatch site. These booleans capture the *additional* * per-flag quirks: * * - `rejectEmpty`: treat `""` like “no value provided”. Needed for * `--resume` / `-r` / `--session`. Without it, an empty string * gets consumed as the session prefix and downstream resolution can match * every session. */ export interface OptionalFlagConfig { set: OptionalSetter; rejectEmpty?: boolean; } // Shared setters for flags that alias the same field. const setExtension: StringSetter = (result, value) => { result.extensions = result.extensions ?? []; result.extensions.push(value); }; const setResume: OptionalSetter = (result, value) => { result.resume = value !== undefined ? value : true; }; const MAX_TIME_DURATION_RE = /^(\d+(?:\.\d+)?)([smh])$/; function maxTimeMultiplier(unit: string | undefined): number { if (unit === "h") return 3600; if (unit === "m") return 60; return 1; } function parseMaxTimeSeconds(value: string): number { const trimmed = value.trim(); const duration = MAX_TIME_DURATION_RE.exec(trimmed); const seconds = duration ? Number(duration[1]) * maxTimeMultiplier(duration[2]) : Number(trimmed); if (Number.isFinite(seconds) && seconds > 0) return seconds; throw new CliUsageError( `Invalid --max-time value: ${JSON.stringify(value)}. Expected a positive number of seconds or duration like "5s", "10m", "1h".`, ); } /** * Setters for flags with string values. Most built-ins consume the next argv * token even when it starts with `-`; flags listed in * {@link EXTENSION_SHADOWABLE_STRING_FLAGS} use extension-style consumption so * a registered boolean extension can shadow them before profile bootstrap. */ export const STRING_SETTERS: Record = { "--cwd": (result, value) => { result.cwd = value; }, "--config": (result, value) => { result.config = [...(result.config ?? []), value]; }, "--add-dir": (result, value) => { result.addDir = [...(result.addDir ?? []), value]; }, "--mode": (result, value) => { if (value === "text" || value === "json" || value === "rpc" || value === "acp" || value === "rpc-ui") { result.mode = value; } }, "--fork": (result, value) => { result.fork = value; }, "--provider": (result, value) => { result.provider = value; }, "--model": (result, value) => { result.model = value; }, "--smol": (result, value) => { result.smol = value; }, "--slow": (result, value) => { result.slow = value; }, "--plan": (result, value) => { result.plan = value; }, "--prewalk-into": (result, value) => { result.prewalkInto = value; }, "--plan-yolo-into": (result, value) => { result.planYoloInto = value; }, "--max-time": (result, value) => { result.maxTime = parseMaxTimeSeconds(value); }, "--service-tier": (result, value) => { if (!isServiceTierOpenAISettingValue(value)) { throw new CliUsageError( `Invalid --service-tier value: ${JSON.stringify(value)}. Expected one of: ${SERVICE_TIER_OPENAI_VALUES.join(", ")}.`, ); } result.serviceTier = value; }, "--api-key": (result, value) => { result.apiKey = value; }, "--system-prompt": (result, value) => { result.systemPrompt = value; }, "--append-system-prompt": (result, value) => { result.appendSystemPrompt = value; }, "--provider-session-id": (result, value) => { result.providerSessionId = value; }, "--prompt-cache-key": (result, value) => { result.providerPromptCacheKey = value; }, "--session-dir": (result, value) => { result.sessionDir = value; }, "--models": (result, value) => { result.models = value.split(",").map(s => s.trim()); }, "--tools": (result, value, deps) => { const names = deps.normalizeToolNames( value .split(",") .map(s => s.trim()) .filter(Boolean), ); // An unknown name silently narrowing the toolset is worse than a failed // launch: scripts keep running believing the tool is available (e.g. a // stale `--tools bash,ssh` after the ssh tool's removal). const unknown = names.filter(name => !deps.builtinToolNames.includes(name)); if (unknown.length > 0) { throw new CliUsageError( `Unknown tool${unknown.length === 1 ? "" : "s"} in --tools: ${unknown.join(", ")}. Valid tools: ${deps.builtinToolNames.join(", ")}.`, ); } result.tools = names; }, "--thinking": (result, value, deps) => { const thinking = deps.parseThinking(value); if (thinking !== undefined) { result.thinking = thinking; } else { deps.logger.warn("Invalid thinking level passed to --thinking", { level: value, validThinkingLevels: deps.thinkingEfforts, }); } }, "--export": (result, value) => { result.export = value; }, "--hook": (result, value) => { result.hooks = result.hooks ?? []; result.hooks.push(value); }, "--extension": setExtension, "-e": setExtension, "--trusted-extension": (result, value) => { result.trustedExtensions = result.trustedExtensions ?? []; result.trustedExtensions.push(value); }, "--plugin-dir": (result, value) => { result.pluginDirs = result.pluginDirs ?? []; result.pluginDirs.push(value); }, "--skills": (result, value) => { result.skills = value.split(",").map(s => s.trim()); }, "--approval-mode": (result, value, deps) => { if (value === "always-ask" || value === "write" || value === "yolo") { result.approvalMode = value; } else { deps.logger.warn("Invalid value passed to --approval-mode", { value, validValues: ["always-ask", "write", "yolo"], }); } }, }; /** * Optional-value flags. Setters receive `undefined` for the bare form. * * The dispatch in `args.ts` applies the shared "doesn't start with `-`" * check for every flag, then consults the per-flag booleans below for the * remaining quirks. */ export const OPTIONAL_FLAGS: Record = { "--resume": { set: setResume, rejectEmpty: true }, "-r": { set: setResume, rejectEmpty: true }, "--session": { set: setResume, rejectEmpty: true }, }; /** * Derived from {@link STRING_SETTERS}. A flag is in this set if and only if * it has a setter — by construction, drift between "the bootstrap thinks * this flag accepts a value" and "the launch parser can set one" is * structurally impossible. */ export const STRING_VALUE_FLAGS: ReadonlySet = new Set(Object.keys(STRING_SETTERS)); /** * Built-in string flags known to be shadowed by bundled/common boolean * extensions before extension metadata is available. They still accept a * value-like successor for the built-in form (`--plan opus`), but a * flag-looking successor remains a fresh flag (`--plan --profile work`). */ export const EXTENSION_SHADOWABLE_STRING_FLAGS: ReadonlySet = new Set(["--plan"]); /** * Derived from {@link OPTIONAL_FLAGS}. Same single-source contract as * {@link STRING_VALUE_FLAGS}. */ export const OPTIONAL_VALUE_FLAGS: ReadonlySet = new Set(Object.keys(OPTIONAL_FLAGS)); /** * Internal marker inserted by the profile bootstrap when removing `--profile` * or `--alias` would otherwise make the following value-like token become the * value of a preceding optional/extension flag. `parseArgs` ignores it, but its * flag-looking shape preserves argv boundaries during the second parse. */ export const PROFILE_BOOTSTRAP_BOUNDARY_ARG = "--omp-profile-boundary"; /** * Long-form launch flags that take NO value (booleans). The bootstrap pre-parser * needs this to tell a known value-less flag (whose successor is a fresh * argument — `omp --print --profile work` still selects a profile) apart from an * UNKNOWN long option that might be an extension string flag consuming the next * token as its value (so the bootstrap must not steal that token as a global * `--profile`/`--alias`). MUST mirror the value-less flag arms of `parseArgs` * in `./args.ts`: adding a new boolean launch flag there means adding it here, * or `-- --profile X` stops selecting a profile. Short aliases * (`-h`/`-v`/`-c`/`-p`) are intentionally omitted — the protection rule only * fires for `--`-prefixed tokens. */ export const VALUELESS_FLAGS: ReadonlySet = new Set([ "--help", "--version", "--allow-home", "--continue", "--from-claude", "--from-codex", "--no-session", "--no-tools", "--no-lsp", "--no-pty", "--hide-thinking", "--advisor", "--prewalk", "--no-prewalk", "--plan-yolo", "--print", "--print-thoughts", "--no-extensions", "--no-skills", "--no-rules", "--no-title", "--auto-approve", "--yolo", ]); /** * Whether a bare long option (`--xxx`, no `=`) is unclassified — not a known * string-, optional-, or value-less flag. The bootstrap and subcommand * resolver treat these as possible extension string flags that may consume a * value-like successor (the extension flag table is not yet loaded). Shared so * both call sites classify identically. */ export function isUnknownLongValueCandidate(arg: string): boolean { return ( arg.startsWith("--") && !arg.includes("=") && !STRING_VALUE_FLAGS.has(arg) && !OPTIONAL_VALUE_FLAGS.has(arg) && !VALUELESS_FLAGS.has(arg) ); } /** * Whether a leading option `flag` consumes the following argv token `next` as * its value, applying the same contract as `extractProfileFlags` / `parseArgs`. * Single source of truth so subcommand detection ({@link resolveCliArgv}) skips * a flag's value instead of mistaking it for the subcommand — `omp --model acp` * means model `acp`, not the `acp` subcommand, exactly as the launch parser * reads it. */ export function flagConsumesValue(flag: string, next: string | undefined): boolean { // `--flag=value` carries its own value inline. if (flag.startsWith("--") && flag.includes("=")) return false; if (next === undefined) return false; // Known string flags consume any successor, even a flag-looking one // (`--system-prompt --foo` ⇒ the system prompt is literally `--foo`). if (STRING_VALUE_FLAGS.has(flag)) return true; const valueLike = !next.startsWith("-"); if (EXTENSION_SHADOWABLE_STRING_FLAGS.has(flag)) return valueLike; if (OPTIONAL_VALUE_FLAGS.has(flag)) { const config = OPTIONAL_FLAGS[flag]; return valueLike && !(config.rejectEmpty === true && next.length === 0); } if (isUnknownLongValueCandidate(flag)) return valueLike; return false; }