Files
llmx/codex-cli/src/cli.tsx

552 lines
18 KiB
TypeScript
Raw Normal View History

#!/usr/bin/env node
import "dotenv/config";
// 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 { ReviewDecision } from "./utils/agent/review";
import { AutoApprovalMode } from "./utils/auto-approval-mode";
feat: notify when a newer version is available (#333) **Summary** This change introduces a new startup check that notifies users if a newer `@openai/codex` version is available. To avoid spamming, it writes a small state file recording the last check time and will only re‑check once every 24 hours. **What’s Changed** - **New file** `src/utils/check-updates.ts` - Runs `npm outdated --global @openai/codex` - Reads/writes `codex-state.json` under `CONFIG_DIR` - Limits checks to once per day (`UPDATE_CHECK_FREQUENCY = 24h`) - Uses `boxen` for a styled alert and `which` to locate the npm binary - **Hooked into** `src/cli.tsx` entrypoint: ```ts import { checkForUpdates } from "./utils/check-updates"; // … // after loading config await checkForUpdates().catch(); ``` - **Dependencies** - Added `boxen@^8.0.1`, `which@^5.0.0`, `@types/which@^3.0.4` - **Tests** - Vitest suite under `tests/check-updates.test.ts` - Snapshot in `__snapshots__/check-updates.test.ts.snap` **Motivation** Addresses issue #244. Users running a stale global install will now see a friendly reminder—at most once per day—to upgrade and enjoy the latest features. **Test Plan** - `getNPMCommandPath()` resolves npm correctly - `checkOutdated()` parses `npm outdated` JSON - State file prevents repeat alerts within 24h - Boxen snapshot matches expected output - No console output when state indicates a recent check **Related Issue** try resolves #244 **Preview** Prompt a pnpm‑style alert when outdated ![outdated‑alert](https://github.com/user-attachments/assets/294dad45-d858-45d1-bf34-55e672ab883a) Let me know if you’d tweak any of the messaging, throttle frequency, placement in the startup flow, or anything else. --------- Co-authored-by: Thibault Sottiaux <tibo@openai.com>
2025-04-19 08:00:45 +08:00
import { checkForUpdates } from "./utils/check-updates";
import {
getApiKey,
loadConfig,
PRETTY_PRINT,
INSTRUCTIONS_FILEPATH,
} from "./utils/config";
import { createInputItem } from "./utils/input-utils";
import { initLogger } from "./utils/logger/log";
import { isModelSupportedForResponses } from "./utils/model-utils.js";
import { parseToolCall } from "./utils/parsers";
import { onExit, setInkRenderer } from "./utils/terminal";
import chalk from "chalk";
import { spawnSync } from "child_process";
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>
$ codex completion <bash|zsh|fish>
Options
--version Print version and exit
-h, --help Show usage and exit
-m, --model <model> Model to use for completions (default: o4-mini)
-p, --provider <provider> Provider to use for completions (default: openai)
-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
-c, --config Open the instructions file in your editor
-w, --writable-root <path> Writable folder for sandbox in full-auto mode (can be specified multiple times)
-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
--notify Enable desktop notifications for responses
--disable-response-storage Disable serverside response storage (sends the
full conversation context with every request)
--flex-mode Use "flex-mode" processing mode for the request (only supported
with models o3 and o4-mini)
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"
$ codex completion bash
`,
{
importMeta: import.meta,
autoHelp: true,
flags: {
// misc
help: { type: "boolean", aliases: ["h"] },
version: { type: "boolean", description: "Print version and exit" },
view: { type: "string" },
model: { type: "string", aliases: ["m"] },
provider: { type: "string", aliases: ["p"] },
image: { type: "string", isMultiple: true, aliases: ["i"] },
quiet: {
type: "boolean",
aliases: ["q"],
description: "Non-interactive quiet mode",
},
config: {
type: "boolean",
aliases: ["c"],
description: "Open the instructions file in your editor",
},
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",
},
writableRoot: {
type: "string",
isMultiple: true,
aliases: ["w"],
description:
"Writable folder for sandbox in full-auto mode (can be specified multiple times)",
},
noProjectDoc: {
type: "boolean",
description: "Disable automatic inclusion of project-level codex.md",
},
projectDoc: {
type: "string",
description: "Path to a markdown file to include as project doc",
},
flexMode: {
type: "boolean",
description:
"Enable the flex-mode service tier (only supported by models o3 and o4-mini)",
},
fullStdout: {
type: "boolean",
description:
"Disable truncation of command stdout/stderr messages (show everything)",
aliases: ["no-truncate"],
},
// Notification
notify: {
type: "boolean",
description: "Enable desktop notifications for responses",
},
disableResponseStorage: {
type: "boolean",
description:
"Disable server-side response storage (sends full conversation context with every request)",
},
// 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.`,
},
},
},
);
// Handle 'completion' subcommand before any prompting or API calls
if (cli.input[0] === "completion") {
const shell = cli.input[1] || "bash";
const scripts: Record<string, string> = {
bash: `# bash completion for codex
_codex_completion() {
local cur
cur="\${COMP_WORDS[COMP_CWORD]}"
COMPREPLY=( $(compgen -o default -o filenames -- "\${cur}") )
}
complete -F _codex_completion codex`,
zsh: `# zsh completion for codex
#compdef codex
_codex() {
_arguments '*:filename:_files'
}
_codex`,
fish: `# fish completion for codex
complete -c codex -a '(__fish_complete_path)' -d 'file path'`,
};
const script = scripts[shell];
if (!script) {
// eslint-disable-next-line no-console
console.error(`Unsupported shell: ${shell}`);
process.exit(1);
}
// eslint-disable-next-line no-console
console.log(script);
process.exit(0);
}
// For --help, show help and exit.
if (cli.flags.help) {
cli.showHelp();
}
// For --config, open custom instructions file in editor and exit.
if (cli.flags.config) {
try {
loadConfig(); // Ensures the file is created if it doesn't already exit.
} catch {
// ignore errors
}
const filePath = INSTRUCTIONS_FILEPATH;
const editor =
process.env["EDITOR"] || (process.platform === "win32" ? "notepad" : "vi");
spawnSync(editor, [filePath], { stdio: "inherit" });
process.exit(0);
}
// ---------------------------------------------------------------------------
// API key handling
// ---------------------------------------------------------------------------
const fullContextMode = Boolean(cli.flags.fullContext);
let config = loadConfig(undefined, undefined, {
cwd: process.cwd(),
disableProjectDoc: Boolean(cli.flags.noProjectDoc),
projectDocPath: cli.flags.projectDoc,
isFullContext: fullContextMode,
});
const prompt = cli.input[0];
const model = cli.flags.model ?? config.model;
const imagePaths = cli.flags.image;
const provider = cli.flags.provider ?? config.provider ?? "openai";
const apiKey = getApiKey(provider);
// Set of providers that don't require API keys
const NO_API_KEY_REQUIRED = new Set(["ollama"]);
// Skip API key validation for providers that don't require an API key
if (!apiKey && !NO_API_KEY_REQUIRED.has(provider.toLowerCase())) {
// eslint-disable-next-line no-console
console.error(
`\n${chalk.red(`Missing ${provider} API key.`)}\n\n` +
`Set the environment variable ${chalk.bold(
`${provider.toUpperCase()}_API_KEY`,
)} ` +
`and re-run this command.\n` +
`${
provider.toLowerCase() === "openai"
? `You can create a key here: ${chalk.bold(
chalk.underline("https://platform.openai.com/account/api-keys"),
)}\n`
: `You can create a ${chalk.bold(
`${provider.toUpperCase()}_API_KEY`,
)} ` + `in the ${chalk.bold(`${provider}`)} dashboard.\n`
}`,
);
process.exit(1);
}
config = {
apiKey,
...config,
model: model ?? config.model,
notify: Boolean(cli.flags.notify),
flexMode: Boolean(cli.flags.flexMode),
provider,
disableResponseStorage:
cli.flags.disableResponseStorage !== undefined
? Boolean(cli.flags.disableResponseStorage)
: config.disableResponseStorage,
};
// Check for updates after loading config. This is important because we write state file in
// the config dir.
feat: notify when a newer version is available (#333) **Summary** This change introduces a new startup check that notifies users if a newer `@openai/codex` version is available. To avoid spamming, it writes a small state file recording the last check time and will only re‑check once every 24 hours. **What’s Changed** - **New file** `src/utils/check-updates.ts` - Runs `npm outdated --global @openai/codex` - Reads/writes `codex-state.json` under `CONFIG_DIR` - Limits checks to once per day (`UPDATE_CHECK_FREQUENCY = 24h`) - Uses `boxen` for a styled alert and `which` to locate the npm binary - **Hooked into** `src/cli.tsx` entrypoint: ```ts import { checkForUpdates } from "./utils/check-updates"; // … // after loading config await checkForUpdates().catch(); ``` - **Dependencies** - Added `boxen@^8.0.1`, `which@^5.0.0`, `@types/which@^3.0.4` - **Tests** - Vitest suite under `tests/check-updates.test.ts` - Snapshot in `__snapshots__/check-updates.test.ts.snap` **Motivation** Addresses issue #244. Users running a stale global install will now see a friendly reminder—at most once per day—to upgrade and enjoy the latest features. **Test Plan** - `getNPMCommandPath()` resolves npm correctly - `checkOutdated()` parses `npm outdated` JSON - State file prevents repeat alerts within 24h - Boxen snapshot matches expected output - No console output when state indicates a recent check **Related Issue** try resolves #244 **Preview** Prompt a pnpm‑style alert when outdated ![outdated‑alert](https://github.com/user-attachments/assets/294dad45-d858-45d1-bf34-55e672ab883a) Let me know if you’d tweak any of the messaging, throttle frequency, placement in the startup flow, or anything else. --------- Co-authored-by: Thibault Sottiaux <tibo@openai.com>
2025-04-19 08:00:45 +08:00
await checkForUpdates().catch();
// For --flex-mode, validate and exit if incorrect.
if (cli.flags.flexMode) {
const allowedFlexModels = new Set(["o3", "o4-mini"]);
if (!allowedFlexModels.has(config.model)) {
// eslint-disable-next-line no-console
console.error(
`The --flex-mode option is only supported when using the 'o3' or 'o4-mini' models. ` +
`Current model: '${config.model}'.`,
);
process.exit(1);
}
}
feat: notify when a newer version is available (#333) **Summary** This change introduces a new startup check that notifies users if a newer `@openai/codex` version is available. To avoid spamming, it writes a small state file recording the last check time and will only re‑check once every 24 hours. **What’s Changed** - **New file** `src/utils/check-updates.ts` - Runs `npm outdated --global @openai/codex` - Reads/writes `codex-state.json` under `CONFIG_DIR` - Limits checks to once per day (`UPDATE_CHECK_FREQUENCY = 24h`) - Uses `boxen` for a styled alert and `which` to locate the npm binary - **Hooked into** `src/cli.tsx` entrypoint: ```ts import { checkForUpdates } from "./utils/check-updates"; // … // after loading config await checkForUpdates().catch(); ``` - **Dependencies** - Added `boxen@^8.0.1`, `which@^5.0.0`, `@types/which@^3.0.4` - **Tests** - Vitest suite under `tests/check-updates.test.ts` - Snapshot in `__snapshots__/check-updates.test.ts.snap` **Motivation** Addresses issue #244. Users running a stale global install will now see a friendly reminder—at most once per day—to upgrade and enjoy the latest features. **Test Plan** - `getNPMCommandPath()` resolves npm correctly - `checkOutdated()` parses `npm outdated` JSON - State file prevents repeat alerts within 24h - Boxen snapshot matches expected output - No console output when state indicates a recent check **Related Issue** try resolves #244 **Preview** Prompt a pnpm‑style alert when outdated ![outdated‑alert](https://github.com/user-attachments/assets/294dad45-d858-45d1-bf34-55e672ab883a) Let me know if you’d tweak any of the messaging, throttle frequency, placement in the startup flow, or anything else. --------- Co-authored-by: Thibault Sottiaux <tibo@openai.com>
2025-04-19 08:00:45 +08:00
if (
!(await isModelSupportedForResponses(provider, config.model)) &&
(!provider || provider.toLowerCase() === "openai")
) {
// eslint-disable-next-line no-console
console.error(
`The model "${config.model}" does not appear in the list of models ` +
`available to your account. Double-check 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;
// For --view, optionally load an existing rollout from disk, display it and exit.
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);
}
}
// For --fullcontext, run the separate cli entrypoint and exit.
if (fullContextMode) {
await runSinglePass({
originalPrompt: prompt,
config,
rootPath: process.cwd(),
});
onExit();
process.exit(0);
}
// Ensure that all values in additionalWritableRoots are absolute paths.
const additionalWritableRoots: ReadonlyArray<string> = (
cli.flags.writableRoot ?? []
).map((p) => path.resolve(p));
// For --quiet, run the cli without user interactions and exit.
if (cli.flags.quiet) {
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);
}
// Determine approval policy for quiet mode based on flags
const quietApprovalPolicy: ApprovalPolicy =
cli.flags.fullAuto || cli.flags.approvalMode === "full-auto"
? AutoApprovalMode.FULL_AUTO
: cli.flags.autoEdit || cli.flags.approvalMode === "auto-edit"
? AutoApprovalMode.AUTO_EDIT
: config.approvalMode || AutoApprovalMode.SUGGEST;
await runQuietMode({
prompt,
imagePaths: imagePaths || [],
approvalPolicy: quietApprovalPolicy,
additionalWritableRoots,
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.
feat: read approvalMode from config file (#298) This PR implements support for reading the approvalMode setting from the user's config file (`~/.codex/config.json` or `~/.codex/config.yaml`), allowing users to set a persistent default approval mode without needing to specify command-line flags for each session. Changes: - Added approvalMode to the AppConfig type in config.ts - Updated loadConfig() to read the approval mode from the config file - Modified saveConfig() to persist the approval mode setting - Updated CLI logic to respect the config-defined approval mode (while maintaining CLI flag priority) - Added comprehensive tests for approval mode config functionality - Updated README to document the new config option in both YAML and JSON formats - additions to `.gitignore` for other CLI tools Motivation: As a user who regularly works with CLI-tools, I found it odd to have to alias this with the command flags I wanted when `approvalMode` simply wasn't being parsed even though it was an optional prop in `config.ts`. This change allows me (and other users) to set the preference once in the config file, streamlining daily usage while maintaining the ability to override via command-line flags when needed. Testing: I've added a new test case loads and saves approvalMode correctly that verifies: - Reading the approvalMode from the config file works correctly - Saving the approvalMode to the config file works as expected - The value persists through load/save operations All tests related to the implementation are passing.
2025-04-19 10:25:25 -04:00
// 4. config.approvalMode - use the approvalMode setting from ~/.codex/config.json.
// 5. Default suggest mode (prompt for everything).
const approvalPolicy: ApprovalPolicy =
cli.flags.fullAuto || cli.flags.approvalMode === "full-auto"
? AutoApprovalMode.FULL_AUTO
2025-04-16 22:23:18 +02:00
: cli.flags.autoEdit || cli.flags.approvalMode === "auto-edit"
? AutoApprovalMode.AUTO_EDIT
feat: read approvalMode from config file (#298) This PR implements support for reading the approvalMode setting from the user's config file (`~/.codex/config.json` or `~/.codex/config.yaml`), allowing users to set a persistent default approval mode without needing to specify command-line flags for each session. Changes: - Added approvalMode to the AppConfig type in config.ts - Updated loadConfig() to read the approval mode from the config file - Modified saveConfig() to persist the approval mode setting - Updated CLI logic to respect the config-defined approval mode (while maintaining CLI flag priority) - Added comprehensive tests for approval mode config functionality - Updated README to document the new config option in both YAML and JSON formats - additions to `.gitignore` for other CLI tools Motivation: As a user who regularly works with CLI-tools, I found it odd to have to alias this with the command flags I wanted when `approvalMode` simply wasn't being parsed even though it was an optional prop in `config.ts`. This change allows me (and other users) to set the preference once in the config file, streamlining daily usage while maintaining the ability to override via command-line flags when needed. Testing: I've added a new test case loads and saves approvalMode correctly that verifies: - Reading the approvalMode from the config file works correctly - Saving the approvalMode to the config file works as expected - The value persists through load/save operations All tests related to the implementation are passing.
2025-04-19 10:25:25 -04:00
: config.approvalMode || AutoApprovalMode.SUGGEST;
const instance = render(
<App
prompt={prompt}
config={config}
rollout={rollout}
imagePaths={imagePaths}
approvalPolicy={approvalPolicy}
additionalWritableRoots={additionalWritableRoots}
fullStdout={Boolean(cli.flags.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,
additionalWritableRoots,
config,
}: {
prompt: string;
imagePaths: Array<string>;
approvalPolicy: ApprovalPolicy;
additionalWritableRoots: ReadonlyArray<string>;
config: AppConfig;
}): Promise<void> {
const agent = new AgentLoop({
model: config.model,
config: config,
instructions: config.instructions,
provider: config.provider,
approvalPolicy,
additionalWritableRoots,
disableResponseStorage: config.disableResponseStorage,
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> => {
// In quiet mode, default to NO_CONTINUE, except when in full-auto mode
const reviewDecision =
approvalPolicy === AutoApprovalMode.FULL_AUTO
? ReviewDecision.YES
: ReviewDecision.NO_CONTINUE;
return Promise.resolve({ review: reviewDecision });
},
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 Ctrl-C when stdin is in raw-mode
// ---------------------------------------------------------------------------
if (process.stdin.isTTY) {
// Ensure we do not leave the terminal in raw mode if the user presses
// Ctrl-C while some other component has focus and Ink is intercepting
// input. Node does *not* emit a SIGINT in raw-mode, 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 clean-up always runs, even when other code calls
// `process.exit()` directly.
process.once("exit", onExit);