1fbc2cbd79
Follow-up to #1503. When an extension registered a flag whose name collides with a value-taking built-in — e.g. plan-mode's boolean `--plan` vs the built-in `--plan <plan-model>` selector — the extension-aware reparse still took the built-in branch. `omp --extension plan-mode --plan "review the diff"` consumed "review the diff" as the plan-model value, leaving parsed.messages empty and overwriting result.plan with the prompt text. recoverFlagValue only patched the extension flag value, not the corrupted parsed object that applyExtensionFlags returns as initialArgs. Fix at the source: parseArgs now checks the registered extension-flag set BEFORE the built-in branches, so a registered flag is parsed with the extension's semantics (boolean toggle / string value) and surfaces in unknownFlags without consuming the following token or touching the built-in field. This makes recoverFlagValue dead, so applyExtensionFlags is simplified to read resolved values straight from unknownFlags. Tests: parseArgs-level shadowing guard (boolean --plan keeps the message and leaves result.plan unset); applyExtensionFlags message/built-in-field preservation for colliding boolean (--plan) and string (--model) flags; non-colliding flag-looking-value rule retained. Verified the new guards fail without the shadowing fix.
332 lines
13 KiB
TypeScript
332 lines
13 KiB
TypeScript
/**
|
|
* CLI argument parsing and help display
|
|
*/
|
|
import { type Effort, THINKING_EFFORTS } from "@oh-my-pi/pi-ai";
|
|
import { APP_NAME, CONFIG_DIR_NAME, logger } from "@oh-my-pi/pi-utils";
|
|
import chalk from "chalk";
|
|
import { parseEffort } from "../thinking";
|
|
import { BUILTIN_TOOLS } from "../tools";
|
|
|
|
export type Mode = "text" | "json" | "rpc" | "acp" | "rpc-ui";
|
|
|
|
export interface Args {
|
|
cwd?: string;
|
|
allowHome?: boolean;
|
|
provider?: string;
|
|
model?: string;
|
|
smol?: string;
|
|
slow?: string;
|
|
plan?: string;
|
|
apiKey?: string;
|
|
systemPrompt?: string;
|
|
appendSystemPrompt?: string;
|
|
thinking?: Effort;
|
|
continue?: boolean;
|
|
resume?: string | true;
|
|
help?: boolean;
|
|
version?: boolean;
|
|
mode?: Mode;
|
|
noSession?: boolean;
|
|
sessionDir?: string;
|
|
providerSessionId?: string;
|
|
fork?: string;
|
|
models?: string[];
|
|
tools?: string[];
|
|
noTools?: boolean;
|
|
noLsp?: boolean;
|
|
noPty?: boolean;
|
|
hooks?: string[];
|
|
extensions?: string[];
|
|
noExtensions?: boolean;
|
|
pluginDirs?: string[];
|
|
print?: boolean;
|
|
export?: string;
|
|
noSkills?: boolean;
|
|
skills?: string[];
|
|
noRules?: boolean;
|
|
listModels?: string | true;
|
|
noTitle?: boolean;
|
|
autoApprove?: boolean;
|
|
approvalMode?: "always-ask" | "write" | "yolo";
|
|
messages: string[];
|
|
fileArgs: string[];
|
|
/** Unknown flags (potentially extension flags) - map of flag name to value */
|
|
unknownFlags: Map<string, boolean | string>;
|
|
}
|
|
|
|
export function parseArgs(inputArgs: string[], extensionFlags?: Map<string, { type: "boolean" | "string" }>): Args {
|
|
// Work on a copy: the `--option=value` handling below splices the value
|
|
// into the array, and callers reuse the same argv (the post-extension
|
|
// reparse in `runRootCommand` parses it a second time). Mutating the input
|
|
// would corrupt that later parse, so never touch the caller's array.
|
|
const args = [...inputArgs];
|
|
const result: Args = {
|
|
messages: [],
|
|
fileArgs: [],
|
|
unknownFlags: new Map(),
|
|
};
|
|
|
|
for (let i = 0; i < args.length; i++) {
|
|
let arg = args[i];
|
|
const flagIndex = i;
|
|
|
|
// Support --flag=value syntax (e.g. --tools=ask,read). The value is
|
|
// spliced in as the next token so value-consuming flags pick it up via
|
|
// `args[++i]`; a non-consuming flag (e.g. a boolean) leaves it behind and
|
|
// the post-loop guard drops it so it is not mistaken for a message.
|
|
let equalsValueIndex = -1;
|
|
if (arg.startsWith("--") && arg.includes("=")) {
|
|
const eqIdx = arg.indexOf("=");
|
|
const value = arg.slice(eqIdx + 1);
|
|
arg = arg.slice(0, eqIdx);
|
|
args.splice(i + 1, 0, value);
|
|
equalsValueIndex = i + 1;
|
|
}
|
|
|
|
// Extension-registered flags take precedence over built-ins: a flag an
|
|
// extension owns (e.g. plan-mode's boolean `--plan`) is parsed with the
|
|
// extension's semantics rather than falling into a built-in branch. For a
|
|
// value-taking built-in (`--plan`, `--model`, …) that branch would consume
|
|
// the following token — eating the user's message and setting the wrong
|
|
// built-in field — so registered flags shadow same-named built-ins here.
|
|
const extFlag = arg.startsWith("--") ? extensionFlags?.get(arg.slice(2)) : undefined;
|
|
if (extFlag) {
|
|
const flagName = arg.slice(2);
|
|
if (extFlag.type === "boolean") {
|
|
result.unknownFlags.set(flagName, true);
|
|
} else if (extFlag.type === "string" && i + 1 < args.length) {
|
|
// Consume the value in `--flag=value` form, or when the next token is
|
|
// not flag-looking. A `-`-prefixed token in space form is left to be
|
|
// its own flag; pass a flag-looking value as `--flag=value`.
|
|
if (equalsValueIndex !== -1 || !args[i + 1].startsWith("-")) {
|
|
result.unknownFlags.set(flagName, args[++i]);
|
|
}
|
|
}
|
|
} else if (arg === "--help" || arg === "-h") {
|
|
result.help = true;
|
|
} else if (arg === "--version" || arg === "-v") {
|
|
result.version = true;
|
|
} else if (arg === "--allow-home") {
|
|
result.allowHome = true;
|
|
} else if (arg === "--mode" && i + 1 < args.length) {
|
|
const mode = args[++i];
|
|
if (mode === "text" || mode === "json" || mode === "rpc" || mode === "acp" || mode === "rpc-ui") {
|
|
result.mode = mode;
|
|
}
|
|
} else if (arg === "--continue" || arg === "-c") {
|
|
result.continue = true;
|
|
} else if (arg === "--resume" || arg === "-r" || arg === "--session") {
|
|
const next = args[i + 1];
|
|
if (next && !next.startsWith("-")) {
|
|
result.resume = args[++i];
|
|
} else {
|
|
result.resume = true;
|
|
}
|
|
} else if (arg === "--fork" && i + 1 < args.length) {
|
|
result.fork = args[++i];
|
|
} else if (arg === "--provider" && i + 1 < args.length) {
|
|
result.provider = args[++i];
|
|
} else if (arg === "--model" && i + 1 < args.length) {
|
|
result.model = args[++i];
|
|
} else if (arg === "--smol" && i + 1 < args.length) {
|
|
result.smol = args[++i];
|
|
} else if (arg === "--slow" && i + 1 < args.length) {
|
|
result.slow = args[++i];
|
|
} else if (arg === "--plan" && i + 1 < args.length) {
|
|
result.plan = args[++i];
|
|
} else if (arg === "--api-key" && i + 1 < args.length) {
|
|
result.apiKey = args[++i];
|
|
} else if (arg === "--system-prompt" && i + 1 < args.length) {
|
|
result.systemPrompt = args[++i];
|
|
} else if (arg === "--append-system-prompt" && i + 1 < args.length) {
|
|
result.appendSystemPrompt = args[++i];
|
|
} else if (arg === "--provider-session-id" && i + 1 < args.length) {
|
|
result.providerSessionId = args[++i];
|
|
} else if (arg === "--no-session") {
|
|
result.noSession = true;
|
|
} else if (arg === "--session-dir" && i + 1 < args.length) {
|
|
result.sessionDir = args[++i];
|
|
} else if (arg === "--models" && i + 1 < args.length) {
|
|
result.models = args[++i].split(",").map(s => s.trim());
|
|
} else if (arg === "--no-tools") {
|
|
result.noTools = true;
|
|
} else if (arg === "--no-lsp") {
|
|
result.noLsp = true;
|
|
} else if (arg === "--no-pty") {
|
|
result.noPty = true;
|
|
} else if (arg === "--tools" && i + 1 < args.length) {
|
|
const toolNames = args[++i]
|
|
.split(",")
|
|
.map(s => s.trim().toLowerCase())
|
|
.filter(Boolean);
|
|
const validTools: string[] = [];
|
|
for (const name of toolNames) {
|
|
if (name in BUILTIN_TOOLS) {
|
|
validTools.push(name);
|
|
} else {
|
|
logger.warn("Unknown tool passed to --tools", {
|
|
tool: name,
|
|
validTools: Object.keys(BUILTIN_TOOLS),
|
|
});
|
|
}
|
|
}
|
|
result.tools = validTools;
|
|
} else if (arg === "--thinking" && i + 1 < args.length) {
|
|
const rawThinking = args[++i];
|
|
const thinking = parseEffort(rawThinking);
|
|
if (thinking !== undefined) {
|
|
result.thinking = thinking;
|
|
} else {
|
|
logger.warn("Invalid thinking level passed to --thinking", {
|
|
level: rawThinking,
|
|
validThinkingLevels: THINKING_EFFORTS,
|
|
});
|
|
}
|
|
} else if (arg === "--print" || arg === "-p") {
|
|
result.print = true;
|
|
} else if (arg === "--export" && i + 1 < args.length) {
|
|
result.export = args[++i];
|
|
} else if (arg === "--hook" && i + 1 < args.length) {
|
|
result.hooks = result.hooks ?? [];
|
|
result.hooks.push(args[++i]);
|
|
} else if ((arg === "--extension" || arg === "-e") && i + 1 < args.length) {
|
|
result.extensions = result.extensions ?? [];
|
|
result.extensions.push(args[++i]);
|
|
} else if (arg === "--plugin-dir" && i + 1 < args.length) {
|
|
result.pluginDirs = result.pluginDirs ?? [];
|
|
result.pluginDirs.push(args[++i]);
|
|
} else if (arg === "--no-extensions") {
|
|
result.noExtensions = true;
|
|
} else if (arg === "--no-skills") {
|
|
result.noSkills = true;
|
|
} else if (arg === "--no-rules") {
|
|
result.noRules = true;
|
|
} else if (arg === "--no-title") {
|
|
result.noTitle = true;
|
|
} else if (arg === "--auto-approve" || arg === "--yolo") {
|
|
result.autoApprove = true;
|
|
} else if (arg === "--approval-mode" && i + 1 < args.length) {
|
|
const mode = args[++i];
|
|
if (mode === "always-ask" || mode === "write" || mode === "yolo") {
|
|
result.approvalMode = mode;
|
|
} else {
|
|
logger.warn("Invalid value passed to --approval-mode", {
|
|
value: mode,
|
|
validValues: ["always-ask", "write", "yolo"],
|
|
});
|
|
}
|
|
} else if (arg === "--skills" && i + 1 < args.length) {
|
|
// Comma-separated glob patterns for skill filtering
|
|
result.skills = args[++i].split(",").map(s => s.trim());
|
|
} else if (arg === "--list-models") {
|
|
// Check if next arg is a search pattern (not a flag or file arg)
|
|
if (i + 1 < args.length && !args[i + 1].startsWith("-") && !args[i + 1].startsWith("@")) {
|
|
result.listModels = args[++i];
|
|
} else {
|
|
result.listModels = true;
|
|
}
|
|
} else if (arg.startsWith("@")) {
|
|
result.fileArgs.push(arg.slice(1)); // Remove @ prefix
|
|
} else if (!arg.startsWith("-")) {
|
|
result.messages.push(arg);
|
|
}
|
|
// Drop an unconsumed `--flag=value` value (e.g. a boolean flag): when no
|
|
// branch advanced past the spliced token, remove it so it does not fall
|
|
// through to a later iteration and become a positional message.
|
|
if (equalsValueIndex !== -1 && i === flagIndex) {
|
|
args.splice(equalsValueIndex, 1);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
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://<foundry-host>)
|
|
ANTHROPIC_FOUNDRY_API_KEY - Anthropic token used as Authorization: Bearer <token> in Foundry mode
|
|
ANTHROPIC_CUSTOM_HEADERS - Extra Foundry headers (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
|
|
GITHUB_TOKEN - GitHub Copilot (or GH_TOKEN, COPILOT_GITHUB_TOKEN)
|
|
|
|
${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)
|
|
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_PASS_API_KEY - Wafer Pass (flat-rate subscription; GLM-5.1, Qwen3.5)
|
|
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)
|
|
PERPLEXITY_COOKIES - Perplexity web search (session cookie)
|
|
TAVILY_API_KEY - Tavily web search
|
|
ANTHROPIC_SEARCH_API_KEY - Anthropic search provider
|
|
|
|
${chalk.dim("# Configuration")}
|
|
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
|
|
find - 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)
|
|
task - Launch sub-agents for parallel tasks
|
|
todo_write - Manage todo/task lists
|
|
web_search - Search the web
|
|
ask - Ask user questions (interactive mode only)
|
|
|
|
${chalk.bold("Plugin Options:")}
|
|
--plugin-dir <path> 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` +
|
|
`Run ${APP_NAME} --help for full command and option details.\n` +
|
|
`Run ${APP_NAME} <command> --help for command-specific help.\n\n` +
|
|
`${getExtraHelpText()}\n`,
|
|
);
|
|
}
|