mirror of
https://github.com/openai/codex.git
synced 2026-02-03 07:23:39 +00:00
Compare commits
1 Commits
fix-cli
...
pakrym/add
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58f0fa50ab |
21
.github/workflows/rust-ci.yml
vendored
21
.github/workflows/rust-ci.yml
vendored
@@ -194,12 +194,31 @@ jobs:
|
||||
env:
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
# Run the Node-based codex-cli library tests on every platform in the matrix
|
||||
- name: Setup Node.js for codex-cli tests
|
||||
if: ${{ matrix.profile != 'release' }}
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: codex-cli library tests
|
||||
id: node_tests
|
||||
if: ${{ matrix.profile != 'release' }}
|
||||
continue-on-error: true
|
||||
run: |
|
||||
BIN_PATH="${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/${{ matrix.profile }}/codex"
|
||||
|
||||
npm ci
|
||||
npm test
|
||||
working-directory: codex-cli
|
||||
|
||||
# Fail the job if any of the previous steps failed.
|
||||
- name: verify all steps passed
|
||||
if: |
|
||||
steps.clippy.outcome == 'failure' ||
|
||||
steps.cargo_check_all_crates.outcome == 'failure' ||
|
||||
steps.test.outcome == 'failure'
|
||||
steps.test.outcome == 'failure' ||
|
||||
steps.node_tests.outcome == 'failure'
|
||||
run: |
|
||||
echo "One or more checks failed (clippy, cargo_check_all_crates, or test). See logs for details."
|
||||
exit 1
|
||||
|
||||
1
codex-cli/.gitignore
vendored
1
codex-cli/.gitignore
vendored
@@ -1 +1,2 @@
|
||||
/vendor/
|
||||
.npm-cache/
|
||||
|
||||
1398
codex-cli/package-lock.json
generated
1398
codex-cli/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,8 +11,29 @@
|
||||
},
|
||||
"files": [
|
||||
"bin",
|
||||
"vendor"
|
||||
"vendor",
|
||||
"dist"
|
||||
],
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"clean": "rm -rf dist",
|
||||
"pretest": "npm run build",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.7.7",
|
||||
"typescript": "^5.6.3",
|
||||
"vitest": "^2.1.8"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/openai/codex.git",
|
||||
|
||||
@@ -76,6 +76,7 @@ def main() -> int:
|
||||
staging_dir, created_temp = prepare_staging_dir(args.staging_dir)
|
||||
|
||||
try:
|
||||
ensure_built_library()
|
||||
stage_sources(staging_dir, version)
|
||||
|
||||
workflow_url = args.workflow_url
|
||||
@@ -145,6 +146,12 @@ def stage_sources(staging_dir: Path, version: str) -> None:
|
||||
if rg_manifest.exists():
|
||||
shutil.copy2(rg_manifest, bin_dir / "rg")
|
||||
|
||||
# If a built library is present, include it in the package.
|
||||
dist_src = CODEX_CLI_ROOT / "dist"
|
||||
if dist_src.exists():
|
||||
dist_dst = staging_dir / "dist"
|
||||
shutil.copytree(dist_src, dist_dst)
|
||||
|
||||
readme_src = REPO_ROOT / "README.md"
|
||||
if readme_src.exists():
|
||||
shutil.copy2(readme_src, staging_dir / "README.md")
|
||||
@@ -158,6 +165,23 @@ def stage_sources(staging_dir: Path, version: str) -> None:
|
||||
out.write("\n")
|
||||
|
||||
|
||||
def ensure_built_library() -> None:
|
||||
"""Ensure the TypeScript library is built before staging.
|
||||
|
||||
Attempts a no-op when the dist/ directory already exists. Otherwise,
|
||||
runs `npm ci` and `npm run build` in the codex-cli directory.
|
||||
"""
|
||||
dist_dir = CODEX_CLI_ROOT / "dist"
|
||||
if dist_dir.exists():
|
||||
return
|
||||
try:
|
||||
subprocess.check_call(["npm", "ci"], cwd=CODEX_CLI_ROOT)
|
||||
except Exception:
|
||||
# Fallback to `npm install` for environments without a lockfile update.
|
||||
subprocess.check_call(["npm", "install"], cwd=CODEX_CLI_ROOT)
|
||||
subprocess.check_call(["npm", "run", "build"], cwd=CODEX_CLI_ROOT)
|
||||
|
||||
|
||||
def install_native_binaries(staging_dir: Path, workflow_url: str | None) -> None:
|
||||
cmd = ["./scripts/install_native_deps.py"]
|
||||
if workflow_url:
|
||||
|
||||
289
codex-cli/src/index.ts
Normal file
289
codex-cli/src/index.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawn, type SpawnOptions, type ChildProcess } from "node:child_process";
|
||||
import { Readable } from "node:stream";
|
||||
import readline from "node:readline";
|
||||
|
||||
// __dirname equivalent in ESM context
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
export type CodexSpawnOptions = Omit<SpawnOptions, "env"> & {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
/**
|
||||
* Additional directories to prepend to PATH when launching codex.
|
||||
* By default, the appropriate vendor path is added automatically.
|
||||
*/
|
||||
extraPathDirs?: string[];
|
||||
/** Override the path to the codex binary (for testing/custom installs). */
|
||||
binaryPath?: string;
|
||||
/** Optional override for the model provider base URL (sets OPENAI_BASE_URL). */
|
||||
baseUrl?: string;
|
||||
|
||||
};
|
||||
|
||||
export type CodexResult = {
|
||||
type: "code" | "signal";
|
||||
exitCode?: number;
|
||||
signal?: NodeJS.Signals;
|
||||
};
|
||||
|
||||
// ------------------------
|
||||
// Exec JSON event types
|
||||
// ------------------------
|
||||
|
||||
export type CommandExecutionStatus = "in_progress" | "completed" | "failed";
|
||||
export type PatchApplyStatus = "completed" | "failed";
|
||||
export type PatchChangeKind = "add" | "delete" | "update";
|
||||
|
||||
export type AssistantMessageItem = {
|
||||
item_type: "assistant_message";
|
||||
id: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type ReasoningItem = {
|
||||
item_type: "reasoning";
|
||||
id: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type CommandExecutionItem = {
|
||||
item_type: "command_execution";
|
||||
id: string;
|
||||
command: string;
|
||||
aggregated_output: string;
|
||||
exit_code?: number;
|
||||
status: CommandExecutionStatus;
|
||||
};
|
||||
|
||||
export type FileUpdateChange = {
|
||||
path: string;
|
||||
kind: PatchChangeKind;
|
||||
};
|
||||
|
||||
export type FileChangeItem = {
|
||||
item_type: "file_change";
|
||||
id: string;
|
||||
changes: FileUpdateChange[];
|
||||
status: PatchApplyStatus;
|
||||
};
|
||||
|
||||
export type McpToolCallItem = {
|
||||
item_type: "mcp_tool_call";
|
||||
id: string;
|
||||
server: string;
|
||||
tool: string;
|
||||
status: CommandExecutionStatus;
|
||||
};
|
||||
|
||||
export type WebSearchItem = {
|
||||
item_type: "web_search";
|
||||
id: string;
|
||||
query: string;
|
||||
};
|
||||
|
||||
export type ErrorItem = {
|
||||
item_type: "error";
|
||||
id: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ConversationItem =
|
||||
| AssistantMessageItem
|
||||
| ReasoningItem
|
||||
| CommandExecutionItem
|
||||
| FileChangeItem
|
||||
| McpToolCallItem
|
||||
| WebSearchItem
|
||||
| ErrorItem;
|
||||
|
||||
export type ConversationEvent =
|
||||
| { type: "session.created"; session_id: string } & Record<string, never>
|
||||
| { type: "item.started"; item: ConversationItem }
|
||||
| { type: "item.completed"; item: ConversationItem }
|
||||
| { type: "error"; message: string };
|
||||
|
||||
/** Resolve the target triple for the current platform/arch. */
|
||||
function resolveTargetTriple(
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
arch: string = process.arch,
|
||||
): string | null {
|
||||
switch (platform) {
|
||||
case "linux":
|
||||
case "android":
|
||||
switch (arch) {
|
||||
case "x64":
|
||||
return "x86_64-unknown-linux-musl";
|
||||
case "arm64":
|
||||
return "aarch64-unknown-linux-musl";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
case "darwin":
|
||||
switch (arch) {
|
||||
case "x64":
|
||||
return "x86_64-apple-darwin";
|
||||
case "arm64":
|
||||
return "aarch64-apple-darwin";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
case "win32":
|
||||
switch (arch) {
|
||||
case "x64":
|
||||
return "x86_64-pc-windows-msvc";
|
||||
case "arm64":
|
||||
return "aarch64-pc-windows-msvc";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the absolute path to the packaged native codex binary for the current platform.
|
||||
*/
|
||||
function getCodexBinaryPath(): string {
|
||||
const targetTriple = resolveTargetTriple();
|
||||
if (!targetTriple) {
|
||||
throw new Error(`Unsupported platform: ${process.platform} (${process.arch})`);
|
||||
}
|
||||
|
||||
const vendorRoot = path.join(__dirname, "..", "vendor");
|
||||
const archRoot = path.join(vendorRoot, targetTriple);
|
||||
const codexBinaryName = process.platform === "win32" ? "codex.exe" : "codex";
|
||||
const binaryPath = path.join(archRoot, "codex", codexBinaryName);
|
||||
return binaryPath;
|
||||
}
|
||||
|
||||
/** Build an updated PATH including any vendor-provided path helpers. */
|
||||
function buildCodexPath(extraDirs: string[] = []): string {
|
||||
const targetTriple = resolveTargetTriple();
|
||||
if (!targetTriple) {
|
||||
throw new Error(`Unsupported platform: ${process.platform} (${process.arch})`);
|
||||
}
|
||||
|
||||
const vendorRoot = path.join(__dirname, "..", "vendor");
|
||||
const archRoot = path.join(vendorRoot, targetTriple);
|
||||
const toPrepend = [...extraDirs];
|
||||
|
||||
const pathDir = path.join(archRoot, "path");
|
||||
if (existsSync(pathDir)) {
|
||||
toPrepend.push(pathDir);
|
||||
}
|
||||
|
||||
const sep = process.platform === "win32" ? ";" : ":";
|
||||
const existing = process.env.PATH || "";
|
||||
return [...toPrepend, ...existing.split(sep).filter(Boolean)].join(sep);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn the packaged codex binary.
|
||||
*
|
||||
* The default behavior mirrors the CLI wrapper: `stdio: \"inherit\"`, and PATH
|
||||
* is augmented with any vendor-provided shims.
|
||||
*/
|
||||
/**
|
||||
* Execute the codex binary with provided arguments/options and await completion.
|
||||
* Defaults to the packaged binary unless `binaryPath` is provided in options.
|
||||
*/
|
||||
function resolveCodexCommand(binaryPath?: string): string {
|
||||
if (binaryPath) return binaryPath;
|
||||
const candidate = getCodexBinaryPath();
|
||||
if (existsSync(candidate)) return candidate;
|
||||
return process.platform === "win32" ? "codex.exe" : "codex";
|
||||
}
|
||||
|
||||
export async function execCodex(
|
||||
args: string[] = [],
|
||||
options: CodexSpawnOptions = {},
|
||||
): Promise<CodexResult> {
|
||||
const binaryPath = resolveCodexCommand(options.binaryPath);
|
||||
const { extraPathDirs = [], env, stdio = "inherit", ...rest } = options;
|
||||
|
||||
const childEnv: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
...env,
|
||||
PATH: buildCodexPath(extraPathDirs),
|
||||
CODEX_MANAGED_BY_NPM: "1",
|
||||
};
|
||||
|
||||
const child = spawn(binaryPath, args, { stdio, env: childEnv, ...rest });
|
||||
return await new Promise<CodexResult>((resolve, reject) => {
|
||||
child.on("error", (err) => reject(err));
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
resolve({ type: "signal", signal });
|
||||
} else {
|
||||
resolve({ type: "code", exitCode: code ?? 1 });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Parse newline-delimited JSON ConversationEvent objects from a readable stream. */
|
||||
export async function* parseExecEvents(stream: Readable): AsyncIterable<ConversationEvent> {
|
||||
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
||||
for await (const line of rl) {
|
||||
const trimmed = String(line).trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const obj = JSON.parse(trimmed) as ConversationEvent;
|
||||
// Basic shape check: must have a type
|
||||
if (obj && typeof obj === "object" && "type" in obj) {
|
||||
yield obj;
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed lines to keep the stream resilient.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type RunExecReturn = {
|
||||
child: ChildProcess;
|
||||
events: AsyncIterable<ConversationEvent>;
|
||||
done: Promise<CodexResult>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Run `codex exec` with JSON output enabled and return a live stream of events.
|
||||
* Always injects `exec` and `--json-experimental` ahead of provided args.
|
||||
*/
|
||||
export function runExec(execArgs: string[] = [], options: CodexSpawnOptions = {}): RunExecReturn {
|
||||
const binaryPath = resolveCodexCommand(options.binaryPath);
|
||||
const { extraPathDirs = [], env, baseUrl } = options;
|
||||
|
||||
const childEnv: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
...env,
|
||||
PATH: buildCodexPath(extraPathDirs),
|
||||
...(baseUrl ? { OPENAI_BASE_URL: baseUrl } : {}),
|
||||
};
|
||||
|
||||
const args = ["exec", "--experimental-json", ...execArgs]
|
||||
|
||||
// Force stdout to be piped so we can parse events; let stderr inherit by default.
|
||||
const child = spawn(binaryPath, args, {
|
||||
stdio: ["inherit", "pipe", "inherit"],
|
||||
env: childEnv
|
||||
});
|
||||
|
||||
const done = new Promise<CodexResult>((resolve, reject) => {
|
||||
child.on("error", (err) => reject(err));
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
resolve({ type: "signal", signal });
|
||||
} else {
|
||||
resolve({ type: "code", exitCode: code ?? 1 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const events = child.stdout ? parseExecEvents(child.stdout) : (async function* () {})();
|
||||
|
||||
return { child, events, done };
|
||||
}
|
||||
89
codex-cli/test/lib.test.ts
Normal file
89
codex-cli/test/lib.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { execCodex, parseExecEvents, runExec } from '@openai/codex';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import http from 'node:http';
|
||||
import os from 'node:os';
|
||||
|
||||
describe('execCodex', () => {
|
||||
test('invokes the configured binary with args and returns exit code', async () => {
|
||||
const result = await execCodex(['-e', 'process.exit(0)'], {
|
||||
binaryPath: process.execPath,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
expect(result.type).toBe('code');
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test('parses newline-delimited JSON conversation events', async () => {
|
||||
const stream = new PassThrough();
|
||||
const events = [
|
||||
{ type: 'session.created', session_id: 'abc' },
|
||||
{
|
||||
type: 'item.completed',
|
||||
item: {
|
||||
id: 'item_1',
|
||||
item_type: 'assistant_message',
|
||||
text: 'hello',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const iter = parseExecEvents(stream);
|
||||
// Write two JSONL lines, plus some whitespace noise
|
||||
for (const ev of events) {
|
||||
stream.write(JSON.stringify(ev) + '\n');
|
||||
}
|
||||
stream.write('\n \n');
|
||||
stream.end();
|
||||
|
||||
const out: unknown[] = [];
|
||||
for await (const ev of iter) out.push(ev);
|
||||
expect(out).toEqual(events);
|
||||
});
|
||||
|
||||
test('runExec streams events and respects baseUrl via OPENAI_BASE_URL', async () => {
|
||||
// Start a mock responses server
|
||||
let received = 0;
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.method === 'POST' && req.url?.endsWith('/v1/responses')) {
|
||||
received++;
|
||||
res.statusCode = 200;
|
||||
res.setHeader('content-type', 'text/event-stream');
|
||||
res.end('event: completed\n' + 'data: {"type":"response.completed"}\n\n');
|
||||
return;
|
||||
}
|
||||
res.statusCode = 404;
|
||||
res.end();
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const addr = server.address();
|
||||
if (!addr || typeof addr !== 'object') throw new Error('server address');
|
||||
const baseUrl = `http://127.0.0.1:${addr.port}/v1`;
|
||||
|
||||
// If CODEX_BIN is provided, run the real codex binary with --experimental-json.
|
||||
const codexBin = process.env.CODEX_BIN;
|
||||
if (!codexBin) {
|
||||
// Skip if the binary isn't available (e.g., local dev without Rust build).
|
||||
return;
|
||||
}
|
||||
const { events, done } = runExec([
|
||||
'--skip-git-repo-check',
|
||||
'-s',
|
||||
'danger-full-access',
|
||||
'hello world',
|
||||
], { binaryPath: codexBin, baseUrl, env: { OPENAI_API_KEY: 'dummy', CODEX_HOME: os.tmpdir() } });
|
||||
|
||||
const collected: any[] = [];
|
||||
for await (const ev of events) collected.push(ev);
|
||||
const result = await done;
|
||||
|
||||
expect(result.type).toBe('code');
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(received).toBe(1);
|
||||
// Just assert we received at least one event and the server saw a request.
|
||||
expect(collected.length).toBeGreaterThan(0);
|
||||
expect(received).toBe(1);
|
||||
|
||||
await new Promise<void>((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())));
|
||||
});
|
||||
});
|
||||
20
codex-cli/tsconfig.json
Normal file
20
codex-cli/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "Bundler",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user