diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3f76ce653..451438f96 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -91,6 +91,7 @@ - Optimized tool guidance for bash, grep, and glob to be more concise while clarifying shell boundaries and search timeouts. - Optimized models configuration resource probing to run in a single child process, reducing startup contention. - Startup release notes now default to a compact change-count summary. Use `startup.changelogMode` (`summary` | `expanded` | `hidden`) to control them; legacy `collapseChangelog` choices migrate automatically ([#6771](https://github.com/can1357/oh-my-pi/issues/6771)). +- Reduced `omp --help` cold-start latency and memory use by rendering lightweight command metadata without loading runtime command, provider, or native-addon graphs. ### Fixed diff --git a/packages/coding-agent/src/cli-commands.ts b/packages/coding-agent/src/cli-commands.ts index 17fd742e8..e9b60a030 100644 --- a/packages/coding-agent/src/cli-commands.ts +++ b/packages/coding-agent/src/cli-commands.ts @@ -10,41 +10,170 @@ */ import type { CommandEntry } from "@oh-my-pi/pi-utils/cli"; import { flagConsumesValue } from "./cli/flag-tables"; +import { launchHelp } from "./commands/launch-help"; export const commands: CommandEntry[] = [ - { name: "launch", load: () => import("./commands/launch").then(m => m.default) }, - { name: "acp", load: () => import("./commands/acp").then(m => m.default) }, - { name: "auth-broker", load: () => import("./commands/auth-broker").then(m => m.default) }, - { name: "auth-gateway", load: () => import("./commands/auth-gateway").then(m => m.default) }, - { name: "agents", load: () => import("./commands/agents").then(m => m.default) }, - { name: "bench", load: () => import("./commands/bench").then(m => m.default) }, - { name: "cleanse", load: () => import("./commands/cleanse").then(m => m.default) }, - { name: "commit", load: () => import("./commands/commit").then(m => m.default) }, - { name: "completions", load: () => import("./commands/completions").then(m => m.default) }, - { name: "__complete", load: () => import("./commands/complete").then(m => m.default) }, - { name: "config", load: () => import("./commands/config").then(m => m.default) }, - { name: "dry-balance", load: () => import("./commands/dry-balance").then(m => m.default) }, - { name: "gc", load: () => import("./commands/gc").then(m => m.default) }, - { name: "grep", load: () => import("./commands/grep").then(m => m.default) }, - { name: "gallery", load: () => import("./commands/gallery").then(m => m.default) }, - { name: "grievances", load: () => import("./commands/grievances").then(m => m.default) }, - { name: "install", load: () => import("./commands/install").then(m => m.default) }, - { name: "join", load: () => import("./commands/join").then(m => m.default) }, - { name: "models", load: () => import("./commands/models").then(m => m.default) }, - { name: "plugin", load: () => import("./commands/plugin").then(m => m.default) }, - { name: "say", load: () => import("./commands/say").then(m => m.default) }, - { name: "setup", load: () => import("./commands/setup").then(m => m.default) }, - { name: "shell", load: () => import("./commands/shell").then(m => m.default) }, - { name: "read", load: () => import("./commands/read").then(m => m.default) }, - { name: "ssh", load: () => import("./commands/ssh").then(m => m.default) }, - { name: "stats", load: () => import("./commands/stats").then(m => m.default) }, - { name: "update", load: () => import("./commands/update").then(m => m.default) }, - { name: "usage", load: () => import("./commands/usage").then(m => m.default) }, - { name: "tiny-models", load: () => import("./commands/tiny-models").then(m => m.default) }, - { name: "token", load: () => import("./commands/token").then(m => m.default) }, - { name: "ttsr", load: () => import("./commands/ttsr").then(m => m.default) }, - { name: "worktree", load: () => import("./commands/worktree").then(m => m.default), aliases: ["wt"] }, - { name: "search", load: () => import("./commands/web-search").then(m => m.default), aliases: ["q"] }, + { name: "launch", load: () => import("./commands/launch").then(m => m.default), help: launchHelp }, + { + name: "acp", + load: () => import("./commands/acp").then(m => m.default), + help: { description: "Run Oh My Pi as an ACP (Agent Client Protocol) server over stdio" }, + }, + { + name: "auth-broker", + load: () => import("./commands/auth-broker").then(m => m.default), + help: { description: "Manage the omp auth-broker (credential vault)" }, + }, + { + name: "auth-gateway", + load: () => import("./commands/auth-gateway").then(m => m.default), + help: { description: "Run an auth-gateway forward proxy backed by the configured broker" }, + }, + { + name: "agents", + load: () => import("./commands/agents").then(m => m.default), + help: { description: "Manage bundled task agents" }, + }, + { + name: "bench", + load: () => import("./commands/bench").then(m => m.default), + help: { + description: "Benchmark models with the same prompt: time-to-first-token and generation throughput (tokens/s)", + }, + }, + { + name: "cleanse", + load: () => import("./commands/cleanse").then(m => m.default), + help: { description: "Detect and fix project diagnostics with weighted parallel subagents" }, + }, + { + name: "commit", + load: () => import("./commands/commit").then(m => m.default), + help: { description: "Generate a commit message and update changelogs" }, + }, + { + name: "completions", + load: () => import("./commands/completions").then(m => m.default), + help: { description: "Print a shell completion script (bash, zsh, or fish)" }, + }, + { name: "__complete", load: () => import("./commands/complete").then(m => m.default), help: { hidden: true } }, + { + name: "config", + load: () => import("./commands/config").then(m => m.default), + help: { description: "Manage configuration settings" }, + }, + { + name: "dry-balance", + load: () => import("./commands/dry-balance").then(m => m.default), + help: { description: "Dry-run OAuth account balancing across random session ids" }, + }, + { + name: "gc", + load: () => import("./commands/gc").then(m => m.default), + help: { description: "Run storage garbage collection" }, + }, + { + name: "grep", + load: () => import("./commands/grep").then(m => m.default), + help: { description: "Test grep tool" }, + }, + { + name: "gallery", + load: () => import("./commands/gallery").then(m => m.default), + help: { description: "Preview tool renderers across streaming, in-progress, success, and failure states" }, + }, + { + name: "grievances", + load: () => import("./commands/grievances").then(m => m.default), + help: { description: "View, clean, or push reported tool issues (auto-QA grievances)" }, + }, + { + name: "install", + load: () => import("./commands/install").then(m => m.default), + help: { description: "Install or link an extension package (alias of `plugin install`/`plugin link`)" }, + }, + { + name: "join", + load: () => import("./commands/join").then(m => m.default), + help: { description: "Join a shared collab session (same as /join)" }, + }, + { + name: "models", + load: () => import("./commands/models").then(m => m.default), + help: { description: "List, search, and refresh available models" }, + }, + { + name: "plugin", + load: () => import("./commands/plugin").then(m => m.default), + help: { description: "Manage plugins (install, uninstall, list, etc.)" }, + }, + { + name: "say", + load: () => import("./commands/say").then(m => m.default), + help: { description: "Synthesize text with the local TTS engine and play it through the speakers" }, + }, + { + name: "setup", + load: () => import("./commands/setup").then(m => m.default), + help: { description: "Run onboarding setup or install dependencies for optional features" }, + }, + { + name: "shell", + load: () => import("./commands/shell").then(m => m.default), + help: { description: "Interactive shell console" }, + }, + { + name: "read", + load: () => import("./commands/read").then(m => m.default), + help: { description: "Show what the read tool will return for a path, URL, or internal URI" }, + }, + { + name: "ssh", + load: () => import("./commands/ssh").then(m => m.default), + help: { description: "Manage SSH host configurations" }, + }, + { + name: "stats", + load: () => import("./commands/stats").then(m => m.default), + help: { description: "View usage statistics" }, + }, + { + name: "update", + load: () => import("./commands/update").then(m => m.default), + help: { description: "Check for and install updates" }, + }, + { + name: "usage", + load: () => import("./commands/usage").then(m => m.default), + help: { description: "Show provider usage limits for every authenticated account" }, + }, + { + name: "tiny-models", + load: () => import("./commands/tiny-models").then(m => m.default), + help: { description: "Download tiny local models (session titles + memory)" }, + }, + { + name: "token", + load: () => import("./commands/token").then(m => m.default), + help: { description: "Get the API key or OAuth token for a provider" }, + }, + { + name: "ttsr", + load: () => import("./commands/ttsr").then(m => m.default), + help: { description: "Inspect and test Time-Traveling Stream Rules (TTSR)" }, + }, + { + name: "worktree", + load: () => import("./commands/worktree").then(m => m.default), + aliases: ["wt"], + help: { description: "List or clear agent-managed git worktrees (~/.omp/wt)" }, + }, + { + name: "search", + load: () => import("./commands/web-search").then(m => m.default), + aliases: ["q"], + help: { description: "Test web search providers" }, + }, ]; // Documented-looking plugin/marketplace verbs that are NOT registered top-level diff --git a/packages/coding-agent/src/cli.ts b/packages/coding-agent/src/cli.ts index 2a5d4d6e4..492ac7797 100755 --- a/packages/coding-agent/src/cli.ts +++ b/packages/coding-agent/src/cli.ts @@ -29,13 +29,10 @@ import { setProcessName } from "@oh-my-pi/pi-utils/process-name"; import { declareWorkerHostEntry, installWorkerInbox, isWorkerHostSelector } from "@oh-my-pi/pi-utils/worker-host"; import { installProfileAlias, resolveProfileAliasCommandFromProcess } from "./cli/profile-alias"; import { extractProfileFlags } from "./cli/profile-bootstrap"; -import { startJsEvalProcess } from "./eval/js/process-entry"; import type { WorkerInbound as JsWorkerInbound, WorkerOutbound as JsWorkerOutbound } from "./eval/js/worker-protocol"; import { DAEMON_BROKER_WORKER_ARG } from "./launch/protocol"; import { TERMINAL_OUTPUT_WORKER_ARG } from "./launch/terminal-output-worker-protocol"; import { COMPUTER_WORKER_ARG } from "./tools/computer/protocol"; -import { smokeTestComputerWorker } from "./tools/computer/supervisor"; -import { startComputerWorker } from "./tools/computer/worker-entry"; if (Bun.semver.order(Bun.version, MIN_BUN_VERSION) < 0) { process.stderr.write( @@ -61,8 +58,13 @@ const isProcessEntry = import.meta.main || process.env.PI_COMPILED === "true"; // import time, so it must not be imported before `setProfile` runs. async function showHelp(config: CliConfig): Promise { - const { renderRootHelp } = await import("@oh-my-pi/pi-utils/cli"); - const { getExtraHelpText } = await import("./cli/args"); + // Root help historically loads the selected profile's environment. Keep that + // contract after profile bootstrap without pulling in command/provider graphs. + await import("@oh-my-pi/pi-utils/env"); + const [{ renderRootHelp }, { getExtraHelpText }] = await Promise.all([ + import("@oh-my-pi/pi-utils/cli"), + import("./cli/help-extra"), + ]); renderRootHelp(config); const extra = getExtraHelpText(); if (extra.trim().length > 0) { @@ -86,6 +88,7 @@ async function runSmokeTest(): Promise { const { smokeTestSttWorker } = await import("./stt/asr-client"); const { smokeTestTtsWorker } = await import("./tts/tts-client"); const { smokeTestMnemopiEmbedWorker } = await import("./mnemopi/embed-client"); + const { smokeTestComputerWorker } = await import("./tools/computer/supervisor"); const { smokeTestJsEvalWorker } = await import("./eval/js/context-manager"); // Other smoke dependencies stay lazy so normal CLI startup does not load their worker clients. const { smokeTestDaemonBroker } = await import("./launch/client"); @@ -163,6 +166,8 @@ async function runWorkerEntrypoint(arg: string | undefined): Promise { } if (arg === COMPUTER_WORKER_ARG) { if (parentPort) installWorkerInbox(parentPort); + // This selector is the module-loading boundary; desktop capture dependencies are worker-only. + const { startComputerWorker } = await import("./tools/computer/worker-entry"); startComputerWorker(); return true; } @@ -172,11 +177,13 @@ async function runWorkerEntrypoint(arg: string | undefined): Promise { return true; } if (arg === JS_EVAL_PROCESS_ARG) { - // The bootstrap-safe interceptor seam is linked statically so this selector - // cannot load profile-scoped environment state after dispatch has begun. - // The JS evaluator forwards user-controlled payloads (tool-call args, - // display outputs); a non-serializable one must fail that cell, not - // SIGKILL the kernel and erase the eval session's state. + // This selector is the module-loading boundary; the evaluator process entry is worker-only. + // The bootstrap-safe interceptor stays linked statically so profile-scoped + // environment state cannot load after dispatch has begun. The JS evaluator + // forwards user-controlled payloads (tool-call args, display outputs); a + // non-serializable one must fail that cell, not SIGKILL the kernel and erase + // the eval session's state. + const { startJsEvalProcess } = await import("./eval/js/process-entry"); await runIpcSubprocessWorker( transport => startJsEvalProcess(transport, interceptUnhandledRejections), { rethrowConnectedSendErrors: true }, diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index da606c156..179e5774b 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -1,7 +1,7 @@ /** * CLI argument parsing and help display */ -import { APP_NAME, CONFIG_DIR_NAME, logger } from "@oh-my-pi/pi-utils"; +import { APP_NAME, logger } from "@oh-my-pi/pi-utils"; import chalk from "chalk"; import { CLI_THINKING_LEVELS, type ConfiguredThinkingLevel, parseCliThinkingLevel } from "../thinking"; import { BUILTIN_TOOL_NAMES, HIDDEN_TOOL_NAMES, normalizeToolNames } from "../tools/builtin-names"; @@ -13,8 +13,11 @@ import { STRING_SETTERS, STRING_VALUE_FLAGS, } from "./flag-tables"; +import { getExtraHelpText } from "./help-extra"; import { CliUsageError } from "./usage-error"; +export { getExtraHelpText }; + export type Mode = "text" | "json" | "rpc" | "acp" | "rpc-ui"; export interface Args { @@ -330,92 +333,6 @@ export function reportCliUsageError( return true; } -export function getExtraHelpText(): string { - return `${chalk.bold("Environment Variables:")} - ${chalk.dim("# Core Providers")} - ANTHROPIC_API_KEY - Anthropic Claude models - ANTHROPIC_OAUTH_TOKEN - Anthropic OAuth (takes precedence over API key) - CLAUDE_CODE_USE_FOUNDRY - Enable Anthropic Foundry mode (uses Foundry endpoint + mTLS) - FOUNDRY_BASE_URL - Anthropic Foundry base URL (e.g., https://) - ANTHROPIC_FOUNDRY_API_KEY - Anthropic token used as Authorization: Bearer in Foundry mode - ANTHROPIC_CUSTOM_HEADERS - Extra headers for Foundry or any custom ANTHROPIC_BASE_URL gateway (e.g., "user-id: USERNAME") - CLAUDE_CODE_CLIENT_CERT - Client certificate (PEM path or inline PEM) for mTLS - CLAUDE_CODE_CLIENT_KEY - Client private key (PEM path or inline PEM) for mTLS - NODE_EXTRA_CA_CERTS - CA bundle path (or inline PEM) for server certificate validation - OPENAI_API_KEY - OpenAI GPT models - GEMINI_API_KEY - Google Gemini models - COPILOT_GITHUB_TOKEN - GitHub Copilot - - ${chalk.dim("# Additional LLM Providers")} - AZURE_OPENAI_API_KEY - Azure OpenAI models - GROQ_API_KEY - Groq models - CEREBRAS_API_KEY - Cerebras models - XAI_API_KEY - xAI Grok models - OPENROUTER_API_KEY - OpenRouter aggregated models - KILO_API_KEY - Kilo Gateway models - MISTRAL_API_KEY - Mistral models - ZAI_API_KEY - z.ai models (ZhipuAI/GLM) - UMANS_AI_CODING_PLAN_API_KEY - Umans AI Coding Plan models - UMANS_WEBSEARCH_PROVIDER - Umans gateway web search backend (native or exa) - MINIMAX_API_KEY - MiniMax models - OPENCODE_API_KEY - OpenCode Zen/OpenCode Go models - CURSOR_ACCESS_TOKEN - Cursor AI models - AI_GATEWAY_API_KEY - Vercel AI Gateway - WAFER_SERVERLESS_API_KEY - Wafer Serverless (pay-as-you-go) - - ${chalk.dim("# Cloud Providers")} - AWS_PROFILE - AWS Bedrock (or AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY) - GOOGLE_CLOUD_PROJECT - Google Vertex AI (requires GOOGLE_CLOUD_LOCATION) - GOOGLE_APPLICATION_CREDENTIALS - Service account for Vertex AI - - ${chalk.dim("# Search & Tools")} - EXA_API_KEY - Exa web search - BRAVE_API_KEY - Brave web search - PERPLEXITY_API_KEY - Perplexity web search API key (optional; anonymous fallback) - PERPLEXITY_COOKIES - Perplexity web search (session cookie) - TAVILY_API_KEY - Tavily web search - TINYFISH_API_KEY - TinyFish web search - FIRECRAWL_API_KEY - Firecrawl web search - ANTHROPIC_SEARCH_API_KEY - Anthropic web search (override; isolates search from main ANTHROPIC_API_KEY) - ANTHROPIC_SEARCH_BASE_URL - Anthropic web search base URL (override; pairs with ANTHROPIC_SEARCH_API_KEY) - - ${chalk.dim("# Configuration")} - OMP_PROFILE - Named profile for isolated agent state (same as --profile) - Use \`omp --profile --alias \` to create a shell shortcut for a profile - PI_CODING_AGENT_DIR - Session storage directory (default: ~/${CONFIG_DIR_NAME}/agent) - PI_PACKAGE_DIR - Override package directory (for Nix/Guix store paths) - PI_SMOL_MODEL - Override smol/fast model (see --smol) - PI_SLOW_MODEL - Override slow/reasoning model (see --slow) - PI_PLAN_MODEL - Override planning model (see --plan) - PI_NO_PTY - Disable PTY-based interactive bash execution - For complete environment variable reference, see: - ${chalk.dim("docs/environment-variables.md")} -${chalk.bold("Available Tools (default-enabled unless noted):")} - read - Read file contents - bash - Execute bash commands - edit - Edit files with find/replace - write - Write files (creates/overwrites) - grep - Search file contents - glob - Find files by glob pattern - lsp - Language server protocol (code intelligence) - python - Execute Python code (requires: ${APP_NAME} setup python) - notebook - Edit Jupyter notebooks - inspect_image - Analyze images with a vision model - browser - Browser automation (Puppeteer) - computer - Native host desktop capture and input (disabled by default) - task - Launch sub-agents for parallel tasks - todo - Manage todo/task lists - web_search - Search the web - ask - Ask user questions (interactive mode only) - -${chalk.bold("Plugin Options:")} - --plugin-dir Load plugin from directory (repeatable) - -${chalk.bold("Useful Commands:")} - omp agents unpack - Export bundled subagents to ~/.omp/agent/agents (default) - omp agents unpack --project - Export bundled subagents to ./.omp/agents`; -} - export function printHelp(): void { process.stdout.write( `${chalk.bold(APP_NAME)} - AI coding assistant\n\n` + diff --git a/packages/coding-agent/src/cli/completion-gen.ts b/packages/coding-agent/src/cli/completion-gen.ts index 60aecbc17..75fb5d905 100644 --- a/packages/coding-agent/src/cli/completion-gen.ts +++ b/packages/coding-agent/src/cli/completion-gen.ts @@ -14,7 +14,7 @@ * — see `commands/complete.ts`. The flag→source mapping below is the only manual * knob and is keyed by flag name so it stays stable as flags are added. */ -import type { ArgDescriptor, CliConfig, CommandCtor, FlagDescriptor } from "@oh-my-pi/pi-utils/cli"; +import type { ArgDescriptor, CliConfig, CommandMetadata, FlagDescriptor } from "@oh-my-pi/pi-utils/cli"; import { BUILTIN_TOOL_NAMES } from "../tools/builtin-names"; export type Shell = "bash" | "zsh" | "fish"; @@ -88,9 +88,9 @@ function argValue(desc: ArgDescriptor): ValueSource { return { kind: "file" }; } -function buildFlags(Cmd: CommandCtor): CompletionFlag[] { +function buildFlags(command: CommandMetadata): CompletionFlag[] { const out: CompletionFlag[] = []; - const flags = Cmd.flags ?? {}; + const flags = command.flags ?? {}; for (const name in flags) { const desc = flags[name]; out.push({ @@ -104,9 +104,9 @@ function buildFlags(Cmd: CommandCtor): CompletionFlag[] { return out; } -function buildArgs(Cmd: CommandCtor): CompletionArg[] { +function buildArgs(command: CommandMetadata): CompletionArg[] { const out: CompletionArg[] = []; - const args = Cmd.args ?? {}; + const args = command.args ?? {}; for (const name in args) { const desc = args[name]; out.push({ name, description: desc.description ?? "", value: argValue(desc) }); diff --git a/packages/coding-agent/src/cli/help-extra.ts b/packages/coding-agent/src/cli/help-extra.ts new file mode 100644 index 000000000..129766226 --- /dev/null +++ b/packages/coding-agent/src/cli/help-extra.ts @@ -0,0 +1,88 @@ +import { APP_NAME, CONFIG_DIR_NAME } from "@oh-my-pi/pi-utils/dirs"; +import chalk from "chalk"; + +export function getExtraHelpText(): string { + return `${chalk.bold("Environment Variables:")} + ${chalk.dim("# Core Providers")} + ANTHROPIC_API_KEY - Anthropic Claude models + ANTHROPIC_OAUTH_TOKEN - Anthropic OAuth (takes precedence over API key) + CLAUDE_CODE_USE_FOUNDRY - Enable Anthropic Foundry mode (uses Foundry endpoint + mTLS) + FOUNDRY_BASE_URL - Anthropic Foundry base URL (e.g., https://) + ANTHROPIC_FOUNDRY_API_KEY - Anthropic token used as Authorization: Bearer in Foundry mode + ANTHROPIC_CUSTOM_HEADERS - Extra headers for Foundry or any custom ANTHROPIC_BASE_URL gateway (e.g., "user-id: USERNAME") + CLAUDE_CODE_CLIENT_CERT - Client certificate (PEM path or inline PEM) for mTLS + CLAUDE_CODE_CLIENT_KEY - Client private key (PEM path or inline PEM) for mTLS + NODE_EXTRA_CA_CERTS - CA bundle path (or inline PEM) for server certificate validation + OPENAI_API_KEY - OpenAI GPT models + GEMINI_API_KEY - Google Gemini models + COPILOT_GITHUB_TOKEN - GitHub Copilot + + ${chalk.dim("# Additional LLM Providers")} + AZURE_OPENAI_API_KEY - Azure OpenAI models + GROQ_API_KEY - Groq models + CEREBRAS_API_KEY - Cerebras models + XAI_API_KEY - xAI Grok models + OPENROUTER_API_KEY - OpenRouter aggregated models + KILO_API_KEY - Kilo Gateway models + MISTRAL_API_KEY - Mistral models + ZAI_API_KEY - z.ai models (ZhipuAI/GLM) + UMANS_AI_CODING_PLAN_API_KEY - Umans AI Coding Plan models + UMANS_WEBSEARCH_PROVIDER - Umans gateway web search backend (native or exa) + MINIMAX_API_KEY - MiniMax models + OPENCODE_API_KEY - OpenCode Zen/OpenCode Go models + CURSOR_ACCESS_TOKEN - Cursor AI models + AI_GATEWAY_API_KEY - Vercel AI Gateway + WAFER_SERVERLESS_API_KEY - Wafer Serverless (pay-as-you-go) + + ${chalk.dim("# Cloud Providers")} + AWS_PROFILE - AWS Bedrock (or AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY) + GOOGLE_CLOUD_PROJECT - Google Vertex AI (requires GOOGLE_CLOUD_LOCATION) + GOOGLE_APPLICATION_CREDENTIALS - Service account for Vertex AI + + ${chalk.dim("# Search & Tools")} + EXA_API_KEY - Exa web search + BRAVE_API_KEY - Brave web search + PERPLEXITY_API_KEY - Perplexity web search API key (optional; anonymous fallback) + PERPLEXITY_COOKIES - Perplexity web search (session cookie) + TAVILY_API_KEY - Tavily web search + TINYFISH_API_KEY - TinyFish web search + FIRECRAWL_API_KEY - Firecrawl web search + ANTHROPIC_SEARCH_API_KEY - Anthropic web search (override; isolates search from main ANTHROPIC_API_KEY) + ANTHROPIC_SEARCH_BASE_URL - Anthropic web search base URL (override; pairs with ANTHROPIC_SEARCH_API_KEY) + + ${chalk.dim("# Configuration")} + OMP_PROFILE - Named profile for isolated agent state (same as --profile) + Use \`omp --profile --alias \` to create a shell shortcut for a profile + PI_CODING_AGENT_DIR - Session storage directory (default: ~/${CONFIG_DIR_NAME}/agent) + PI_PACKAGE_DIR - Override package directory (for Nix/Guix store paths) + PI_SMOL_MODEL - Override smol/fast model (see --smol) + PI_SLOW_MODEL - Override slow/reasoning model (see --slow) + PI_PLAN_MODEL - Override planning model (see --plan) + PI_NO_PTY - Disable PTY-based interactive bash execution + For complete environment variable reference, see: + ${chalk.dim("docs/environment-variables.md")} +${chalk.bold("Available Tools (default-enabled unless noted):")} + read - Read file contents + bash - Execute bash commands + edit - Edit files with find/replace + write - Write files (creates/overwrites) + grep - Search file contents + glob - Find files by glob pattern + lsp - Language server protocol (code intelligence) + python - Execute Python code (requires: ${APP_NAME} setup python) + notebook - Edit Jupyter notebooks + inspect_image - Analyze images with a vision model + browser - Browser automation (Puppeteer) + computer - Native host desktop capture and input (disabled by default) + task - Launch sub-agents for parallel tasks + todo - Manage todo/task lists + web_search - Search the web + ask - Ask user questions (interactive mode only) + +${chalk.bold("Plugin Options:")} + --plugin-dir Load plugin from directory (repeatable) + +${chalk.bold("Useful Commands:")} + omp agents unpack - Export bundled subagents to ~/.omp/agent/agents (default) + omp agents unpack --project - Export bundled subagents to ./.omp/agents`; +} diff --git a/packages/coding-agent/src/cli/thinking-levels.ts b/packages/coding-agent/src/cli/thinking-levels.ts new file mode 100644 index 000000000..0898f5581 --- /dev/null +++ b/packages/coding-agent/src/cli/thinking-levels.ts @@ -0,0 +1,14 @@ +/** + * Thinking selectors accepted by the `--thinking` CLI flag, in display order. + * Shared by help metadata, shell completions, and validation warnings. + */ +export const CLI_THINKING_LEVELS: readonly string[] = [ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + "auto", +]; diff --git a/packages/coding-agent/src/commands/launch-help.ts b/packages/coding-agent/src/commands/launch-help.ts new file mode 100644 index 000000000..0481b7929 --- /dev/null +++ b/packages/coding-agent/src/commands/launch-help.ts @@ -0,0 +1,111 @@ +import { Args, type CommandMetadata, Flags } from "@oh-my-pi/pi-utils/cli"; +import { APP_NAME } from "@oh-my-pi/pi-utils/dirs"; +import { CLI_THINKING_LEVELS } from "../cli/thinking-levels"; + +export const launchHelp = { + description: "AI coding assistant", + hidden: true, + args: { + messages: Args.string({ + description: "Messages to send (prefix files with @)", + required: false, + multiple: true, + }), + }, + flags: { + model: Flags.string({ + description: 'Model to use (fuzzy match: "opus", "gpt-5.2", or "openai/gpt-5.2")', + }), + smol: Flags.string({ description: "Smol/fast model for lightweight tasks (or PI_SMOL_MODEL env)" }), + slow: Flags.string({ description: "Slow/reasoning model for thorough analysis (or PI_SLOW_MODEL env)" }), + plan: Flags.string({ description: "Plan model for architectural planning (or PI_PLAN_MODEL env)" }), + prewalk: Flags.boolean({ + description: + "Switch from the active model to a fast/cheap model at the first edit/write after the plan's todo list exists (default off; see prewalk.enabled)", + }), + "no-prewalk": Flags.boolean({ description: "Disable prewalk even if prewalk.enabled is set" }), + "prewalk-into": Flags.string({ description: 'Target model for prewalk (default the "smol" role)' }), + "plan-yolo": Flags.boolean({ + description: + "Force read-only plan mode at start, auto-approve the plan on the model's first resolve call, then switch to --plan-yolo-into to implement it", + }), + "plan-yolo-into": Flags.string({ description: 'Target model for plan-yolo execution (default the "smol" role)' }), + provider: Flags.string({ description: "Provider to use (legacy; prefer --model)" }), + "api-key": Flags.string({ description: "API key (defaults to env vars)" }), + "system-prompt": Flags.string({ description: "System prompt (default: coding assistant prompt)" }), + "append-system-prompt": Flags.string({ description: "Append text or file contents to the system prompt" }), + "allow-home": Flags.boolean({ description: "Allow starting in ~ without auto-switching to a temp dir" }), + profile: Flags.string({ description: "Use an isolated profile for auth, sessions, settings, and caches" }), + alias: Flags.string({ description: "Create a shell shortcut for the selected profile and exit" }), + cwd: Flags.string({ description: "Directory to start in (overrides the launch cwd)" }), + mode: Flags.string({ + description: "Output mode: text (default), json, rpc, or rpc-ui", + options: ["text", "json", "rpc", "acp", "rpc-ui"], + }), + config: Flags.string({ + description: "Load an extra config.yml-style overlay for this run (repeatable)", + multiple: true, + }), + "add-dir": Flags.string({ + description: "Add a workspace directory beyond the working directory (repeatable)", + multiple: true, + }), + print: Flags.boolean({ char: "p", description: "Non-interactive mode: process prompt and exit" }), + continue: Flags.boolean({ char: "c", description: "Continue previous session" }), + resume: Flags.string({ char: "r", description: "Resume a session (by ID prefix, path, or picker if omitted)" }), + "from-claude": Flags.boolean({ description: "Import a Claude Code session into OMP" }), + "from-codex": Flags.boolean({ description: "Import a Codex session into OMP" }), + "session-dir": Flags.string({ description: "Directory for session storage and lookup" }), + "no-session": Flags.boolean({ description: "Don't save session (ephemeral)" }), + models: Flags.string({ description: "Comma-separated model patterns for Ctrl+P cycling" }), + "no-tools": Flags.boolean({ description: "Disable all built-in tools" }), + "no-lsp": Flags.boolean({ description: "Disable LSP tools, formatting, and diagnostics" }), + "no-pty": Flags.boolean({ description: "Disable PTY-based interactive bash execution" }), + tools: Flags.string({ description: "Comma-separated list of tools to enable (default: all)" }), + thinking: Flags.string({ + description: `Set thinking level: ${CLI_THINKING_LEVELS.join(", ")}`, + options: [...CLI_THINKING_LEVELS], + }), + "hide-thinking": Flags.boolean({ + description: "Hide thinking blocks in TUI output (display only, does not disable model thinking)", + }), + advisor: Flags.boolean({ + description: "Enable the advisor runtime (passively reviews each turn and injects notes)", + }), + hook: Flags.string({ description: "Load a hook/extension file (can be used multiple times)", multiple: true }), + extension: Flags.string({ + char: "e", + description: "Load an extension file (can be used multiple times)", + multiple: true, + }), + "no-extensions": Flags.boolean({ + description: "Disable extension discovery (explicit -e paths still work)", + }), + "no-skills": Flags.boolean({ description: "Disable skills discovery and loading" }), + skills: Flags.string({ description: "Comma-separated glob patterns to filter skills (e.g., git-*,docker)" }), + "no-rules": Flags.boolean({ description: "Disable rules discovery and loading" }), + export: Flags.string({ description: "Export session file to HTML and exit" }), + "no-title": Flags.boolean({ description: "Disable title auto-generation" }), + "print-thoughts": Flags.boolean({ description: "Include thinking blocks in print mode text output" }), + "max-time": Flags.string({ description: "Stop the session after this duration (e.g., 600, 10m, 1h)" }), + "auto-approve": Flags.boolean({ + aliases: ["yolo"], + description: "Auto-approve all tool calls (skip approval prompts)", + }), + "approval-mode": Flags.string({ + options: ["always-ask", "write", "yolo"], + description: "Override tools.approvalMode for this session (always-ask|write|yolo)", + }), + }, + examples: [ + `# Interactive mode\n ${APP_NAME}`, + `# Interactive mode with initial prompt\n ${APP_NAME} "List all .ts files in src/"`, + `# Include files in initial message\n ${APP_NAME} @prompt.md @image.png "What color is the sky?"`, + `# Non-interactive mode (process and exit)\n ${APP_NAME} -p "List all .ts files in src/"`, + `# Continue previous session\n ${APP_NAME} --continue "What did we discuss?"`, + `# Create a shell shortcut for a work profile\n ${APP_NAME} --profile work --alias omp-work`, + `# Use different model (fuzzy matching)\n ${APP_NAME} --model opus "Help me refactor this code"`, + `# Limit model cycling to specific models\n ${APP_NAME} --models claude-sonnet,claude-haiku,gpt-4o`, + `# Export a session file to HTML\n ${APP_NAME} --export ~/.omp/agent/sessions/--path--/session.jsonl`, + ], +} satisfies CommandMetadata; diff --git a/packages/coding-agent/src/commands/launch.ts b/packages/coding-agent/src/commands/launch.ts index 8019bf4e6..a0c8ac6a7 100644 --- a/packages/coding-agent/src/commands/launch.ts +++ b/packages/coding-agent/src/commands/launch.ts @@ -2,202 +2,18 @@ * Root command for the coding agent CLI. */ -import { APP_NAME } from "@oh-my-pi/pi-utils"; -import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli"; +import { Command } from "@oh-my-pi/pi-utils/cli"; import { type Args as ParsedArgs, parseArgs, reportCliUsageError } from "../cli/args"; import { runRootCommand } from "../main"; import { prepareAcpTerminalAuthArgs } from "../modes/acp/terminal-auth"; -import { CLI_THINKING_LEVELS } from "../thinking"; +import { launchHelp } from "./launch-help"; export default class Index extends Command { - static description = "AI coding assistant"; - static hidden = true; - - static args = { - messages: Args.string({ - description: "Messages to send (prefix files with @)", - required: false, - multiple: true, - }), - }; - - static flags = { - model: Flags.string({ - description: 'Model to use (fuzzy match: "opus", "gpt-5.2", or "openai/gpt-5.2")', - }), - smol: Flags.string({ - description: "Smol/fast model for lightweight tasks (or PI_SMOL_MODEL env)", - }), - slow: Flags.string({ - description: "Slow/reasoning model for thorough analysis (or PI_SLOW_MODEL env)", - }), - plan: Flags.string({ - description: "Plan model for architectural planning (or PI_PLAN_MODEL env)", - }), - prewalk: Flags.boolean({ - description: - "Switch from the active model to a fast/cheap model at the first edit/write after the plan's todo list exists (default off; see prewalk.enabled)", - }), - "no-prewalk": Flags.boolean({ - description: "Disable prewalk even if prewalk.enabled is set", - }), - "prewalk-into": Flags.string({ - description: 'Target model for prewalk (default the "smol" role)', - }), - "plan-yolo": Flags.boolean({ - description: - "Force read-only plan mode at start, auto-approve the plan on the model's first resolve call, then switch to --plan-yolo-into to implement it", - }), - "plan-yolo-into": Flags.string({ - description: 'Target model for plan-yolo execution (default the "smol" role)', - }), - provider: Flags.string({ - description: "Provider to use (legacy; prefer --model)", - }), - "api-key": Flags.string({ - description: "API key (defaults to env vars)", - }), - "system-prompt": Flags.string({ - description: "System prompt (default: coding assistant prompt)", - }), - "append-system-prompt": Flags.string({ - description: "Append text or file contents to the system prompt", - }), - "allow-home": Flags.boolean({ - description: "Allow starting in ~ without auto-switching to a temp dir", - }), - profile: Flags.string({ - description: "Use an isolated profile for auth, sessions, settings, and caches", - }), - alias: Flags.string({ - description: "Create a shell shortcut for the selected profile and exit", - }), - cwd: Flags.string({ - description: "Directory to start in (overrides the launch cwd)", - }), - mode: Flags.string({ - description: "Output mode: text (default), json, rpc, or rpc-ui", - options: ["text", "json", "rpc", "acp", "rpc-ui"], - }), - config: Flags.string({ - description: "Load an extra config.yml-style overlay for this run (repeatable)", - multiple: true, - }), - "add-dir": Flags.string({ - description: "Add a workspace directory beyond the working directory (repeatable)", - multiple: true, - }), - print: Flags.boolean({ - char: "p", - description: "Non-interactive mode: process prompt and exit", - }), - continue: Flags.boolean({ - char: "c", - description: "Continue previous session", - }), - resume: Flags.string({ - char: "r", - description: "Resume a session (by ID prefix, path, or picker if omitted)", - }), - "from-claude": Flags.boolean({ - description: "Import a Claude Code session into OMP", - }), - "from-codex": Flags.boolean({ - description: "Import a Codex session into OMP", - }), - "session-dir": Flags.string({ - description: "Directory for session storage and lookup", - }), - "no-session": Flags.boolean({ - description: "Don't save session (ephemeral)", - }), - models: Flags.string({ - description: "Comma-separated model patterns for Ctrl+P cycling", - }), - "no-tools": Flags.boolean({ - description: "Disable all built-in tools", - }), - "no-lsp": Flags.boolean({ - description: "Disable LSP tools, formatting, and diagnostics", - }), - "no-pty": Flags.boolean({ - description: "Disable PTY-based interactive bash execution", - }), - tools: Flags.string({ - description: "Comma-separated list of tools to enable (default: all)", - }), - thinking: Flags.string({ - description: `Set thinking level: ${CLI_THINKING_LEVELS.join(", ")}`, - options: [...CLI_THINKING_LEVELS], - }), - "hide-thinking": Flags.boolean({ - description: "Hide thinking blocks in TUI output (display only, does not disable model thinking)", - }), - advisor: Flags.boolean({ - description: "Enable the advisor runtime (passively reviews each turn and injects notes)", - }), - hook: Flags.string({ - description: "Load a hook/extension file (can be used multiple times)", - multiple: true, - }), - extension: Flags.string({ - char: "e", - description: "Load an extension file (can be used multiple times)", - multiple: true, - }), - "no-extensions": Flags.boolean({ - description: "Disable extension discovery (explicit -e paths still work)", - }), - "no-skills": Flags.boolean({ - description: "Disable skills discovery and loading", - }), - skills: Flags.string({ - description: "Comma-separated glob patterns to filter skills (e.g., git-*,docker)", - }), - "no-rules": Flags.boolean({ - description: "Disable rules discovery and loading", - }), - export: Flags.string({ - description: "Export session file to HTML and exit", - }), - "no-title": Flags.boolean({ - description: "Disable title auto-generation", - }), - "print-thoughts": Flags.boolean({ - description: "Include thinking blocks in print mode text output", - }), - "max-time": Flags.string({ - description: "Stop the session after this duration (e.g., 600, 10m, 1h)", - }), - // `--auto-approve` / `--yolo`: declared here so oclif's auto-generated `--help` lists it. - // Runtime parsing happens in `cli/args.ts parseArgs` (line 176 in that file) — `runRootCommand` - // consumes the manual-parser output, not these oclif flag values. If you rename or remove - // either form, update both call sites in lockstep. - "auto-approve": Flags.boolean({ - aliases: ["yolo"], - description: "Auto-approve all tool calls (skip approval prompts)", - }), - // `--approval-mode`: declared here so oclif's auto-generated `--help` lists it; runtime parsing - // happens in `cli/args.ts parseArgs`. The value is applied via `Settings.override("tools.approvalMode", …)` - // in `main.ts` after the `Settings` instance is constructed, so every `settings.get("tools.approvalMode")` - // site (wrapper, `/settings` UI) observes the same value. - "approval-mode": Flags.string({ - options: ["always-ask", "write", "yolo"], - description: "Override tools.approvalMode for this session (always-ask|write|yolo)", - }), - }; - - static examples = [ - `# Interactive mode\n ${APP_NAME}`, - `# Interactive mode with initial prompt\n ${APP_NAME} "List all .ts files in src/"`, - `# Include files in initial message\n ${APP_NAME} @prompt.md @image.png "What color is the sky?"`, - `# Non-interactive mode (process and exit)\n ${APP_NAME} -p "List all .ts files in src/"`, - `# Continue previous session\n ${APP_NAME} --continue "What did we discuss?"`, - `# Create a shell shortcut for a work profile\n ${APP_NAME} --profile work --alias omp-work`, - `# Use different model (fuzzy matching)\n ${APP_NAME} --model opus "Help me refactor this code"`, - `# Limit model cycling to specific models\n ${APP_NAME} --models claude-sonnet,claude-haiku,gpt-4o`, - `# Export a session file to HTML\n ${APP_NAME} --export ~/.omp/agent/sessions/--path--/session.jsonl`, - ]; + static description = launchHelp.description; + static hidden = launchHelp.hidden; + static args = launchHelp.args; + static flags = launchHelp.flags; + static examples = launchHelp.examples; static strict = false; diff --git a/packages/coding-agent/src/thinking.ts b/packages/coding-agent/src/thinking.ts index 565c62060..b96e5d725 100644 --- a/packages/coding-agent/src/thinking.ts +++ b/packages/coding-agent/src/thinking.ts @@ -3,6 +3,8 @@ import { Effort, type Model, THINKING_EFFORTS } from "@oh-my-pi/pi-ai"; import { clampThinkingLevelForModel, getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking"; import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models"; +export { CLI_THINKING_LEVELS } from "./cli/thinking-levels"; + /** * Metadata used to render thinking selector values in the coding-agent UI. */ @@ -209,14 +211,6 @@ export function getConfiguredThinkingLevelMetadata(level: ConfiguredThinkingLeve return level === AUTO_THINKING ? AUTO_THINKING_METADATA : getThinkingLevelMetadata(level); } -/** - * Thinking selectors accepted by the `--thinking` CLI flag, in display order: - * `off`, every concrete effort (`minimal`..`max`), then `auto`. Single source - * for the flag's `options` list, shell completions, and the "invalid level" - * warning so all three stay in sync. - */ -export const CLI_THINKING_LEVELS: readonly string[] = [ThinkingLevel.Off, ...THINKING_EFFORTS, AUTO_THINKING]; - /** * Parses a `--thinking` CLI value. Accepts every {@link parseConfiguredThinkingLevel} * selector (`off`, `auto`, `minimal`..`max`) but rejects diff --git a/packages/coding-agent/test/eval/process-entry-import.test.ts b/packages/coding-agent/test/eval/process-entry-import.test.ts index d6bb57961..0c12d8d43 100644 --- a/packages/coding-agent/test/eval/process-entry-import.test.ts +++ b/packages/coding-agent/test/eval/process-entry-import.test.ts @@ -47,18 +47,25 @@ async function pingComputerWorker( } } -it("starts ordinary CLI paths without loading the native computer addon", async () => { +it("starts lightweight CLI paths without loading the native addon", async () => { const cliPath = path.resolve(import.meta.dir, "../../src/cli.ts"); for (const args of [ ["--no-addons", cliPath, "--version"], [cliPath, "--help"], ]) { const proc = Bun.spawn([process.execPath, ...args], { + env: { ...process.env, PI_DEBUG_STARTUP: "1" }, stdout: "pipe", stderr: "pipe", }); - const [exitCode, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()]); + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); expect(exitCode, `${args.at(-1)}: ${stderr}`).toBe(0); + expect(stdout).toContain(args.at(-1) === "--help" ? "USAGE" : "omp/"); + expect(stderr).not.toContain("native:loadNative"); } // Two cold CLI spawns (`--version`, `--help`) per run; the assertion is the exit // code, not the wall time. diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md index 27466e466..a5fee29f9 100644 --- a/packages/utils/CHANGELOG.md +++ b/packages/utils/CHANGELOG.md @@ -9,6 +9,10 @@ - Added a `postmortem.quit` configuration option to safely handle shutdown paths when the terminal output has already disconnected. - Added project-keyed OMP security-state directory helpers under the user state root. +### Changed + +- Added static command metadata support to the lightweight CLI runner so root help can render without importing command implementations. + ## [17.1.8] - 2026-07-28 ### Added diff --git a/packages/utils/src/cli.ts b/packages/utils/src/cli.ts index dfd10b76b..ca15b854c 100644 --- a/packages/utils/src/cli.ts +++ b/packages/utils/src/cli.ts @@ -133,15 +133,18 @@ export interface ParseOutput< // Command base class // --------------------------------------------------------------------------- -export interface CommandCtor { - new (argv: string[], config: CliConfig): Command; +export interface CommandMetadata { description?: string; hidden?: boolean; - strict?: boolean; - aliases?: string[]; - examples?: string[]; flags?: Record; args?: Record; + examples?: string[]; +} + +export interface CommandCtor extends CommandMetadata { + new (argv: string[], config: CliConfig): Command; + strict?: boolean; + aliases?: string[]; } /** Configuration passed to every command instance and help renderers. */ @@ -149,7 +152,7 @@ export interface CliConfig { bin: string; version: string; /** All registered commands keyed by their canonical name. */ - commands: Map; + commands: Map; } /** Minimal Command base matching the oclif surface we use. */ @@ -299,7 +302,7 @@ export function renderRootHelp(config: CliConfig): void { // Show the default command's flags/args/examples inline. // The default command is the one marked hidden (it's the implicit entry point). - const defaultCmd = [...commands.values()].find(C => C.hidden); + const defaultCmd = [...commands.values()].find(command => command.hidden); if (defaultCmd) { renderCommandBody(lines, defaultCmd); } @@ -309,8 +312,8 @@ export function renderRootHelp(config: CliConfig): void { if (visible.length > 0) { lines.push("COMMANDS"); const maxLen = Math.max(...visible.map(([n]) => n.length)); - for (const [name, C] of visible.sort((a, b) => a[0].localeCompare(b[0]))) { - lines.push(` ${name.padEnd(maxLen + 2)}${C.description ?? ""}`); + for (const [name, command] of visible.sort((a, b) => a[0].localeCompare(b[0]))) { + lines.push(` ${name.padEnd(maxLen + 2)}${command.description ?? ""}`); } lines.push(""); } @@ -350,9 +353,9 @@ export function renderCommandHelp(bin: string, id: string, Cmd: CommandCtor): vo process.stdout.write(lines.join("\n")); } -function renderCommandBody(lines: string[], Cmd: CommandCtor): void { - const argDefs = Cmd.args ?? {}; - const flagDefs = Cmd.flags ?? {}; +function renderCommandBody(lines: string[], command: CommandMetadata): void { + const argDefs = command.args ?? {}; + const flagDefs = command.flags ?? {}; // Arguments const argEntries = Object.entries(argDefs); @@ -387,9 +390,9 @@ function renderCommandBody(lines: string[], Cmd: CommandCtor): void { } // Examples - if (Cmd.examples && Cmd.examples.length > 0) { + if (command.examples && command.examples.length > 0) { lines.push("EXAMPLES"); - for (const ex of Cmd.examples) { + for (const ex of command.examples) { for (const line of ex.split("\n")) { lines.push(` ${line}`); } @@ -406,6 +409,7 @@ function renderCommandBody(lines: string[], Cmd: CommandCtor): void { export interface CommandEntry { name: string; load: () => Promise; + help?: CommandMetadata; aliases?: string[]; } @@ -506,10 +510,12 @@ async function loadEntry(entry: CommandEntry): Promise { /** Resolve all command loaders for help/alias display. */ async function loadAllCommands(opts: RunOptions): Promise { - const commands = new Map(); - const loaded = await Promise.all(opts.commands.map(async e => [e.name, await loadEntry(e)] as const)); - for (const [name, Cmd] of loaded) { - commands.set(name, Cmd); + const commands = new Map(); + const loaded = await Promise.all( + opts.commands.map(async entry => [entry.name, entry.help ?? (await loadEntry(entry))] as const), + ); + for (const [name, command] of loaded) { + commands.set(name, command); } return { bin: opts.bin, version: opts.version, commands }; } diff --git a/packages/utils/test/cli-help.test.ts b/packages/utils/test/cli-help.test.ts index 98c260ab9..cdeee02e9 100644 --- a/packages/utils/test/cli-help.test.ts +++ b/packages/utils/test/cli-help.test.ts @@ -54,6 +54,50 @@ describe("run() per-command help", () => { }); }); +describe("run() root help", () => { + // Contract: root help renders registered metadata without importing command + // implementations. Heavy or unavailable optional commands must not make + // `omp --help` slow or crash. + it("renders static metadata without loading command modules", async () => { + let loads = 0; + const commands: CommandEntry[] = [ + { + name: "launch", + load: async () => { + loads++; + throw new Error("runtime graph loaded"); + }, + help: { + hidden: true, + flags: { model: Flags.string({ description: "model selector" }) }, + }, + }, + { + name: "good", + load: async () => { + loads++; + throw new Error("runtime graph loaded"); + }, + help: { description: "prints good things" }, + }, + ]; + const writes: string[] = []; + const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(chunk => { + writes.push(String(chunk)); + return true; + }); + try { + await run({ bin: "omp", version: "0.0.0", argv: ["--help"], commands }); + } finally { + stdoutSpy.mockRestore(); + } + const output = writes.join(""); + expect(loads).toBe(0); + expect(output).toContain("--model="); + expect(output).toContain("good prints good things"); + }); +}); + describe("run() usage errors", () => { // Contract: a missing required arg prints a concise `error:` + USAGE line to // stderr and exits 1 — it must NOT throw past run() (which would dump a