Files
llmx/codex-cli/src/cli.tsx
2025-04-16 15:25:32 -07:00

398 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// Hack to suppress deprecation warnings (punycode)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(process as any).noDeprecation = true;
import type { AppRollout } from "./app";
import type { ApprovalPolicy } from "./approvals";
import type { CommandConfirmation } from "./utils/agent/agent-loop";
import type { AppConfig } from "./utils/config";
import type { ResponseItem } from "openai/resources/responses/responses";
import App from "./app";
import { runSinglePass } from "./cli_singlepass";
import { AgentLoop } from "./utils/agent/agent-loop";
import { initLogger } from "./utils/agent/log";
import { ReviewDecision } from "./utils/agent/review";
import { AutoApprovalMode } from "./utils/auto-approval-mode";
import { loadConfig, PRETTY_PRINT } from "./utils/config";
import { createInputItem } from "./utils/input-utils";
import {
isModelSupportedForResponses,
preloadModels,
} from "./utils/model-utils.js";
import { parseToolCall } from "./utils/parsers";
import { onExit, setInkRenderer } from "./utils/terminal";
import chalk from "chalk";
import fs from "fs";
import { render } from "ink";
import meow from "meow";
import path from "path";
import React from "react";
// Call this early so `tail -F "$TMPDIR/oai-codex/codex-cli-latest.log"` works
// immediately. This must be run with DEBUG=1 for logging to work.
initLogger();
// TODO: migrate to new versions of quiet mode
//
// -q, --quiet Non-interactive quiet mode that only prints final message
// -j, --json Non-interactive JSON output mode that prints JSON messages
const cli = meow(
`
Usage
$ codex [options] <prompt>
Options
-h, --help Show usage and exit
-m, --model <model> Model to use for completions (default: o4-mini)
-i, --image <path> Path(s) to image files to include as input
-v, --view <rollout> Inspect a previously saved rollout instead of starting a session
-q, --quiet Non-interactive mode that only prints the assistant's final output
-a, --approval-mode <mode> Override the approval policy: 'suggest', 'auto-edit', or 'full-auto'
--auto-edit Automatically approve file edits; still prompt for commands
--full-auto Automatically approve edits and commands when executed in the sandbox
--no-project-doc Do not automatically include the repository's 'codex.md'
--project-doc <file> Include an additional markdown file at <file> as context
--full-stdout Do not truncate stdout/stderr from command outputs
Dangerous options
--dangerously-auto-approve-everything
Skip all confirmation prompts and execute commands without
sandboxing. Intended solely for ephemeral local testing.
Experimental options
-f, --full-context Launch in "full-context" mode which loads the entire repository
into context and applies a batch of edits in one go. Incompatible
with all other flags, except for --model.
Examples
$ codex "Write and run a python program that prints ASCII art"
$ codex -q "fix build issues"
`,
{
importMeta: import.meta,
autoHelp: true,
flags: {
// misc
help: { type: "boolean", aliases: ["h"] },
view: { type: "string" },
model: { type: "string", aliases: ["m"] },
image: { type: "string", isMultiple: true, aliases: ["i"] },
quiet: {
type: "boolean",
aliases: ["q"],
description: "Non-interactive quiet mode",
},
dangerouslyAutoApproveEverything: {
type: "boolean",
description:
"Automatically approve all commands without prompting. This is EXTREMELY DANGEROUS and should only be used in trusted environments.",
},
autoEdit: {
type: "boolean",
description: "Automatically approve edits; prompt for commands.",
},
fullAuto: {
type: "boolean",
description:
"Automatically run commands in a sandbox; only prompt for failures.",
},
approvalMode: {
type: "string",
aliases: ["a"],
description:
"Determine the approval mode for Codex (default: suggest) Values: suggest, auto-edit, full-auto",
},
noProjectDoc: {
type: "boolean",
description: "Disable automatic inclusion of projectlevel codex.md",
},
projectDoc: {
type: "string",
description: "Path to a markdown file to include as project doc",
},
fullStdout: {
type: "boolean",
description:
"Disable truncation of command stdout/stderr messages (show everything)",
aliases: ["no-truncate"],
},
// Experimental mode where whole directory is loaded in context and model is requested
// to make code edits in a single pass.
fullContext: {
type: "boolean",
aliases: ["f"],
description: `Run in full-context editing approach. The model is given the whole code
directory as context and performs changes in one go without acting.`,
},
},
},
);
if (cli.flags.help) {
cli.showHelp();
}
// ---------------------------------------------------------------------------
// API key handling
// ---------------------------------------------------------------------------
const apiKey = process.env["OPENAI_API_KEY"];
if (!apiKey) {
// eslint-disable-next-line no-console
console.error(
`\n${chalk.red("Missing OpenAI API key.")}\n\n` +
`Set the environment variable ${chalk.bold("OPENAI_API_KEY")} ` +
`and re-run this command.\n` +
`You can create a key here: ${chalk.bold(
chalk.underline("https://platform.openai.com/account/api-keys"),
)}\n`,
);
process.exit(1);
}
const fullContextMode = Boolean(cli.flags.fullContext);
let config = loadConfig(undefined, undefined, {
cwd: process.cwd(),
disableProjectDoc: Boolean(cli.flags.noProjectDoc),
projectDocPath: cli.flags.projectDoc as string | undefined,
isFullContext: fullContextMode,
});
const prompt = cli.input[0];
const model = cli.flags.model;
const imagePaths = cli.flags.image as Array<string> | undefined;
config = {
apiKey,
...config,
model: model ?? config.model,
};
if (!(await isModelSupportedForResponses(config.model))) {
// eslint-disable-next-line no-console
console.error(
`The model "${config.model}" does not appear in the list of models ` +
`available to your account. Doublecheck the spelling (use\n` +
` openai models list\n` +
`to see the full list) or choose another model with the --model flag.`,
);
process.exit(1);
}
let rollout: AppRollout | undefined;
if (cli.flags.view) {
const viewPath = cli.flags.view;
const absolutePath = path.isAbsolute(viewPath)
? viewPath
: path.join(process.cwd(), viewPath);
try {
const content = fs.readFileSync(absolutePath, "utf-8");
rollout = JSON.parse(content) as AppRollout;
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error reading rollout file:", error);
process.exit(1);
}
}
// If we are running in --fullcontext mode, do that and exit.
if (fullContextMode) {
await runSinglePass({
originalPrompt: prompt,
config,
rootPath: process.cwd(),
});
onExit();
process.exit(0);
}
// If we are running in --quiet mode, do that and exit.
const quietMode = Boolean(cli.flags.quiet);
const autoApproveEverything = Boolean(
cli.flags.dangerouslyAutoApproveEverything,
);
const fullStdout = Boolean(cli.flags.fullStdout);
if (quietMode) {
process.env["CODEX_QUIET_MODE"] = "1";
if (!prompt || prompt.trim() === "") {
// eslint-disable-next-line no-console
console.error(
'Quiet mode requires a prompt string, e.g.,: codex -q "Fix bug #123 in the foobar project"',
);
process.exit(1);
}
await runQuietMode({
prompt: prompt as string,
imagePaths: imagePaths || [],
approvalPolicy: autoApproveEverything
? AutoApprovalMode.FULL_AUTO
: AutoApprovalMode.SUGGEST,
config,
});
onExit();
process.exit(0);
}
// Default to the "suggest" policy.
// Determine the approval policy to use in interactive mode.
//
// Priority (highest → lowest):
// 1. --fullAuto run everything automatically in a sandbox.
// 2. --dangerouslyAutoApproveEverything run everything **without** a sandbox
// or prompts. This is intended for completely trusted environments. Since
// it is more dangerous than --fullAuto we deliberately give it lower
// priority so a user specifying both flags still gets the safer behaviour.
// 3. --autoEdit automatically approve edits, but prompt for commands.
// 4. Default suggest mode (prompt for everything).
const approvalPolicy: ApprovalPolicy =
cli.flags.fullAuto || cli.flags.approvalMode === "full-auto"
? AutoApprovalMode.FULL_AUTO
: cli.flags.autoEdit || cli.flags.approvalMode === "auto-edit"
? AutoApprovalMode.AUTO_EDIT
: AutoApprovalMode.SUGGEST;
preloadModels();
const instance = render(
<App
prompt={prompt}
config={config}
rollout={rollout}
imagePaths={imagePaths}
approvalPolicy={approvalPolicy}
fullStdout={fullStdout}
/>,
{
patchConsole: process.env["DEBUG"] ? false : true,
},
);
setInkRenderer(instance);
function formatResponseItemForQuietMode(item: ResponseItem): string {
if (!PRETTY_PRINT) {
return JSON.stringify(item);
}
switch (item.type) {
case "message": {
const role = item.role === "assistant" ? "assistant" : item.role;
const txt = item.content
.map((c) => {
if (c.type === "output_text" || c.type === "input_text") {
return c.text;
}
if (c.type === "input_image") {
return "<Image>";
}
if (c.type === "input_file") {
return c.filename;
}
if (c.type === "refusal") {
return c.refusal;
}
return "?";
})
.join(" ");
return `${role}: ${txt}`;
}
case "function_call": {
const details = parseToolCall(item);
return `$ ${details?.cmdReadableText ?? item.name}`;
}
case "function_call_output": {
// @ts-expect-error metadata unknown on ResponseFunctionToolCallOutputItem
const meta = item.metadata as ExecOutputMetadata;
const parts: Array<string> = [];
if (typeof meta?.exit_code === "number") {
parts.push(`code: ${meta.exit_code}`);
}
if (typeof meta?.duration_seconds === "number") {
parts.push(`duration: ${meta.duration_seconds}s`);
}
const header = parts.length > 0 ? ` (${parts.join(", ")})` : "";
return `command.stdout${header}\n${item.output}`;
}
default: {
return JSON.stringify(item);
}
}
}
async function runQuietMode({
prompt,
imagePaths,
approvalPolicy,
config,
}: {
prompt: string;
imagePaths: Array<string>;
approvalPolicy: ApprovalPolicy;
config: AppConfig;
}): Promise<void> {
const agent = new AgentLoop({
model: config.model,
config: config,
instructions: config.instructions,
approvalPolicy,
onItem: (item: ResponseItem) => {
// eslint-disable-next-line no-console
console.log(formatResponseItemForQuietMode(item));
},
onLoading: () => {
/* intentionally ignored in quiet mode */
},
getCommandConfirmation: (
_command: Array<string>,
): Promise<CommandConfirmation> => {
return Promise.resolve({ review: ReviewDecision.NO_CONTINUE });
},
onLastResponseId: () => {
/* intentionally ignored in quiet mode */
},
});
const inputItem = await createInputItem(prompt, imagePaths);
await agent.run([inputItem]);
}
const exit = () => {
onExit();
process.exit(0);
};
process.on("SIGINT", exit);
process.on("SIGQUIT", exit);
process.on("SIGTERM", exit);
// ---------------------------------------------------------------------------
// Fallback for CtrlC when stdin is in rawmode
// ---------------------------------------------------------------------------
if (process.stdin.isTTY) {
// Ensure we do not leave the terminal in raw mode if the user presses
// CtrlC while some other component has focus and Ink is intercepting
// input. Node does *not* emit a SIGINT in rawmode, so we listen for the
// corresponding byte (0x03) ourselves and trigger a graceful shutdown.
const onRawData = (data: Buffer | string): void => {
const str = Buffer.isBuffer(data) ? data.toString("utf8") : data;
if (str === "\u0003") {
exit();
}
};
process.stdin.on("data", onRawData);
}
// Ensure terminal cleanup always runs, even when other code calls
// `process.exit()` directly.
process.once("exit", onExit);