codex-toys/packages/codex-client/test/cli.test.ts

785 lines
18 KiB
TypeScript

import { describe, expect, test } from "vite-plus/test";
import { spawn } from "node:child_process";
import { chmod, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parseArgs } from "../src/cli/args.ts";
import { formatFetchInfo, type FetchInfo } from "../src/cli/fetch.ts";
import { parseJsonParamsText, parseJsonText } from "../src/cli/json.ts";
const testDir = path.dirname(fileURLToPath(import.meta.url));
describe("codex-flows CLI args", () => {
test("parses JSON text with a UTF-8 BOM", () => {
expect(parseJsonText("\uFEFF{\"ok\":true}", "params")).toEqual({ ok: true });
});
test("parses PowerShell-stripped JSON params", () => {
expect(parseJsonParamsText("{limit:3,sourceKinds:[]}", "params")).toEqual({
limit: 3,
sourceKinds: [],
});
expect(parseJsonParamsText("{threadId:019e649c-1452}", "params")).toEqual({
threadId: "019e649c-1452",
});
});
test("parses direct app-server calls", () => {
expect(parseArgs(["app", "thread/list", "{\"limit\":1}"], {}))
.toMatchObject({
type: "app-call",
method: "thread/list",
paramsText: "{\"limit\":1}",
url: "ws://127.0.0.1:3585",
});
expect(parseArgs([
"app",
"thread/list",
"--params-json",
"{\"limit\":20,\"sourceKinds\":[]}",
], {})).toMatchObject({
type: "app-call",
method: "thread/list",
paramsText: "{\"limit\":20,\"sourceKinds\":[]}",
});
expect(parseArgs([
"app",
"thread/turns/list",
"--params-file",
"params.json",
], {})).toMatchObject({
type: "app-call",
method: "thread/turns/list",
paramsFile: "params.json",
});
});
test("parses workspace-owned method calls", () => {
expect(parseArgs([
"--workspace-url",
"ws://127.0.0.1:4596",
"workspace",
"delegation.list",
], {})).toMatchObject({
type: "workspace-call",
method: "delegation.list",
url: "ws://127.0.0.1:4596",
});
});
test("parses workspace backend setup commands", () => {
expect(parseArgs(["workspace", "backend", "init", "local", "--overwrite"], {}))
.toEqual({
type: "workspace-backend-init-local",
workspaceRoot: undefined,
codexHome: undefined,
profile: undefined,
globalProfile: false,
overwrite: true,
json: false,
pretty: true,
});
expect(parseArgs([
"workspace",
"backend",
"init",
"local",
"--global",
"--profile",
"home",
"--workspace-root",
"/home/peezy",
"--codex-home",
"/home/peezy/.codex",
], {})).toMatchObject({
type: "workspace-backend-init-local",
globalProfile: true,
profile: "home",
workspaceRoot: "/home/peezy",
codexHome: "/home/peezy/.codex",
});
expect(parseArgs(["workspace", "backend", "status", "--json"], {}))
.toMatchObject({
type: "workspace-backend-status",
json: true,
workspaceUrl: "ws://127.0.0.1:3586",
});
expect(parseArgs(["workspace", "backend", "start", "--dry-run", "--json", "--profile=home"], {}))
.toMatchObject({
type: "workspace-backend-start",
profile: "home",
dryRun: true,
json: true,
});
expect(parseArgs(["workspace", "backend", "service", "install", "--profile", "home", "--dry-run"], {}))
.toMatchObject({
type: "workspace-backend-service-install",
profile: "home",
dryRun: true,
});
});
test("parses remote agent operator commands", () => {
expect(parseArgs(["remote", "status", "--json"], {}))
.toMatchObject({
type: "remote-status",
json: true,
workspaceUrl: "ws://127.0.0.1:3586",
});
expect(parseArgs([
"--ssh",
"devbox",
"--cwd",
"/work",
"remote",
"preflight",
"--json",
], {})).toMatchObject({
type: "remote-preflight",
cwd: "/work",
sshTarget: "devbox",
json: true,
});
expect(parseArgs([
"--cwd",
"/work",
"remote-agent",
"serve",
"--remote-codex-command",
"/opt/codex",
], {})).toMatchObject({
type: "remote-agent-serve",
cwd: "/work",
remoteCodexCommand: "/opt/codex",
});
expect(parseArgs([
"remote",
"turn",
"start",
"--prompt",
"hello remote",
"--via",
"workspace",
"--cwd",
"/work",
"--ssh",
"devbox",
"--remote-path-prepend",
"/home/peezy/.local/bin:/home/peezy/.bun/bin",
"--sandbox",
"danger-full-access",
"--approval-policy",
"never",
"--wait",
"--model",
"gpt-5.2",
], {})).toMatchObject({
type: "remote-turn-start",
prompt: "hello remote",
via: "workspace",
cwd: "/work",
sshTarget: "devbox",
remotePathPrepend: "/home/peezy/.local/bin:/home/peezy/.bun/bin",
sandbox: "danger-full-access",
approvalPolicy: "never",
wait: true,
model: "gpt-5.2",
});
});
test("parses turn run as the core prompt primitive", () => {
expect(parseArgs([
"--ssh",
"devbox",
"--cwd",
"/repo",
"--remote-agent-command",
"/opt/codex-flows",
"--remote-codex-command",
"/opt/codex",
"--remote-codex-arg",
"-s",
"--remote-codex-arg",
"danger-full-access",
"turn",
"run",
"scan current folder",
"--wait",
"--sandbox",
"danger-full-access",
"--approval-policy",
"never",
], {})).toMatchObject({
type: "turn-run",
prompt: "scan current folder",
sshTarget: "devbox",
cwd: "/repo",
remoteAgentCommand: "/opt/codex-flows",
remoteCodexCommand: "/opt/codex",
remoteCodexArgs: ["-s", "danger-full-access"],
wait: true,
sandbox: "danger-full-access",
approvalPolicy: "never",
});
});
test("parses turn automation commands", () => {
expect(parseArgs([
"--ssh",
"devbox",
"--cwd",
"/repo",
"automation",
"run",
"check-release",
"--event",
"event.json",
"--prompt",
"default prompt",
"--via",
"workspace",
"--sandbox",
"danger-full-access",
"--approval-policy",
"never",
"--json",
], {})).toMatchObject({
type: "automation-run",
target: "check-release",
eventPath: "event.json",
prompt: "default prompt",
via: "workspace",
sshTarget: "devbox",
cwd: "/repo",
sandbox: "danger-full-access",
approvalPolicy: "never",
json: true,
timeoutMs: 30 * 60 * 1000,
});
expect(parseArgs([
"--ssh",
"devbox",
"--cwd",
"/repo",
"automation",
"list",
"--workspace-root",
"/work",
], {}))
.toMatchObject({
type: "automation-list",
workspaceRoot: "/work",
sshTarget: "devbox",
cwd: "/repo",
});
expect(parseArgs(["turn", "run", "scan", "--wait"], {}))
.toMatchObject({
type: "turn-run",
timeoutMs: 30 * 60 * 1000,
});
});
test("parses SSH provider options on app, workspace, and fetch commands", () => {
const remote = {
sshTarget: "devbox",
cwd: "/repo",
remotePathPrepend: "/opt/node/bin",
remoteAgentCommand: "/opt/codex-flows",
};
expect(parseArgs([
"--ssh",
"devbox",
"--cwd",
"/repo",
"--remote-path-prepend",
"/opt/node/bin",
"--remote-agent-command",
"/opt/codex-flows",
"fetch",
], {})).toMatchObject({ type: "fetch", ...remote });
expect(parseArgs([
"--ssh=devbox",
"--cwd=/repo",
"app",
"thread/list",
], {})).toMatchObject({
type: "app-call",
sshTarget: "devbox",
cwd: "/repo",
});
expect(parseArgs([
"--ssh",
"devbox",
"--cwd",
"/repo",
"workspace",
"delegation.list",
], {})).toMatchObject({
type: "workspace-call",
sshTarget: "devbox",
cwd: "/repo",
});
});
test("parses workspace function commands", () => {
expect(parseArgs(["functions", "list", "--json"], {})).toMatchObject({
type: "functions-list",
json: true,
});
expect(parseArgs(["functions", "describe", "portfolioSnapshot", "--json"], {}))
.toMatchObject({
type: "functions-describe",
name: "portfolioSnapshot",
json: true,
});
expect(parseArgs([
"--ssh",
"devbox",
"--cwd",
"/repo",
"functions",
"call",
"portfolioSnapshot",
"--params-json",
"{\"account\":\"demo\"}",
"--json",
], {})).toMatchObject({
type: "functions-call",
name: "portfolioSnapshot",
paramsText: "{\"account\":\"demo\"}",
sshTarget: "devbox",
cwd: "/repo",
json: true,
});
});
test("executes SSH workspace function commands", async () => {
const fakeSsh = await createFunctionFakeSshCommand();
const env = { CODEX_FLOWS_SSH_COMMAND: fakeSsh };
const list = await runCli([
"--ssh",
"devbox",
"--cwd",
"/repo",
"functions",
"list",
"--json",
], env);
expect(list.exitCode).toBe(0);
expect(JSON.parse(list.stdout)).toEqual({
functions: [{
name: "portfolioSnapshot",
description: "Read portfolio.",
sideEffects: "read-only",
}],
});
const describe = await runCli([
"--ssh",
"devbox",
"--cwd",
"/repo",
"functions",
"describe",
"portfolioSnapshot",
"--json",
], env);
expect(describe.exitCode).toBe(0);
expect(JSON.parse(describe.stdout)).toMatchObject({
function: { name: "portfolioSnapshot" },
});
const call = await runCli([
"--ssh",
"devbox",
"--cwd",
"/repo",
"functions",
"call",
"portfolioSnapshot",
"--params-json",
"{\"account\":\"demo\"}",
"--json",
], env);
expect(call.exitCode).toBe(0);
expect(JSON.parse(call.stdout)).toEqual({
result: { account: "demo", equity: 456 },
});
});
test("parses app-server pass-through through the workspace backend", () => {
expect(parseArgs([
"workspace",
"app",
"thread/list",
"{\"limit\":2}",
], {})).toMatchObject({
type: "workspace-app-call",
method: "thread/list",
paramsText: "{\"limit\":2}",
});
expect(parseArgs([
"workspace",
"app",
"thread/list",
"--params-file",
"params.json",
], {})).toMatchObject({
type: "workspace-app-call",
method: "thread/list",
paramsFile: "params.json",
});
});
test("parses Actions helper commands and workspace Actions scaffolding", () => {
expect(parseArgs(["actions", "prepare-auth", "--workspace-root", "/work"], {}))
.toEqual({
type: "actions-prepare-auth",
workspaceRoot: "/work",
pretty: true,
});
expect(parseArgs(["actions", "cleanup"], {})).toEqual({
type: "actions-cleanup",
workspaceRoot: undefined,
pretty: true,
});
expect(() => parseArgs(["actions", "dispatch", "--event", "event.json"], {}))
.toThrow("actions requires prepare-auth or cleanup");
expect(parseArgs([
"workspace",
"init",
"actions",
"--forgejo",
"--overwrite",
], {})).toEqual({
type: "workspace-init-actions",
workspaceRoot: undefined,
forgejo: true,
github: false,
overwrite: true,
pretty: true,
});
});
test("executes Actions prepare-auth and cleanup commands against a temp workspace", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "codex-actions-cli-"));
await mkdir(path.join(root, ".codex", "sessions"), { recursive: true });
await mkdir(path.join(root, ".codex", "memories"), { recursive: true });
await writeFile(path.join(root, ".codex", "sessions", "one.jsonl"), "{}");
await writeFile(path.join(root, ".codex", "memories", "raw_memories.md"), "keep\n");
const prepare = await runCli([
"--workspace-root",
root,
"actions",
"prepare-auth",
], { CODEX_AUTH_JSON_B64: "", CODEX_AUTH_JSON: "{\"token\":\"cli\"}" });
expect(prepare.exitCode).toBe(0);
expect(JSON.parse(prepare.stdout)).toMatchObject({
source: "CODEX_AUTH_JSON",
wrote: true,
});
expect(JSON.parse(await readFile(path.join(root, ".codex", "auth.json"), "utf8")))
.toEqual({ token: "cli" });
const cleanup = await runCli([
"--workspace-root",
root,
"actions",
"cleanup",
]);
expect(cleanup.exitCode).toBe(0);
expect(JSON.parse(cleanup.stdout).removed).toContain("auth.json");
expect(JSON.parse(cleanup.stdout).removed).toContain("sessions");
expect(await readFile(path.join(root, ".codex", "memories", "raw_memories.md"), "utf8"))
.toBe("keep\n");
});
test("parses thread transplant commands", () => {
expect(parseArgs(["threads", "locate", "thread-1", "--codex-home", "/source", "--json"], {}))
.toEqual({
type: "threads-locate",
threadId: "thread-1",
codexHome: "/source",
json: true,
});
expect(parseArgs(["threads", "inspect", "thread-1", "--codex-home=/source", "--json"], {}))
.toEqual({
type: "threads-inspect",
threadIdOrPath: "thread-1",
codexHome: "/source",
json: true,
});
expect(parseArgs(["threads", "inspect", "/rollout.jsonl", "--json"], {})).toEqual({
type: "threads-inspect",
threadIdOrPath: "/rollout.jsonl",
json: true,
});
expect(parseArgs([
"threads",
"install-rollout",
"/rollout.jsonl",
"--codex-home",
"/target",
"--replace",
], {})).toEqual({
type: "threads-install-rollout",
rolloutPath: "/rollout.jsonl",
codexHome: "/target",
replace: true,
json: false,
});
expect(parseArgs([
"threads",
"transplant",
"thread-1",
"--from-codex-home",
"/source",
"--to-codex-home=/target",
"--replace",
"--json",
], {})).toEqual({
type: "threads-transplant",
threadId: "thread-1",
fromCodexHome: "/source",
toCodexHome: "/target",
replace: true,
json: true,
});
});
test("parses pack commands", () => {
expect(parseArgs(["pack", "inspect", "owner/repo", "--ref", "main", "--json"], {}))
.toEqual({
type: "pack-inspect",
source: "owner/repo",
ref: "main",
json: true,
});
expect(parseArgs([
"--workspace-root",
"/workspace",
"pack",
"add",
"./pack",
"--apply",
"--overwrite",
"--include",
"tdd",
"--exclude=repo-policy",
], {})).toEqual({
type: "pack-add",
source: "./pack",
ref: undefined,
workspaceRoot: "/workspace",
apply: true,
overwrite: true,
include: ["tdd"],
exclude: ["repo-policy"],
json: false,
});
expect(parseArgs(["pack", "doctor", "--json"], {})).toEqual({
type: "pack-doctor",
workspaceRoot: undefined,
json: true,
});
expect(parseArgs(["pack", "list"], {})).toEqual({
type: "pack-list",
workspaceRoot: undefined,
json: false,
});
});
test("rejects invalid method names", () => {
expect(() => parseArgs(["workspace", "not a method"], {}))
.toThrow("workspace method must be a JSON-RPC method name");
});
test("parses neofetch-style fetch command", () => {
expect(parseArgs(["--no-color", "fetch"], {})).toEqual({
type: "fetch",
appUrl: "ws://127.0.0.1:3585",
workspaceUrl: "ws://127.0.0.1:3586",
timeoutMs: 1500,
color: false,
json: false,
});
expect(parseArgs(["--json", "neofetch"], {})).toMatchObject({
type: "fetch",
json: true,
});
});
test("formats fetch output without ANSI colors", () => {
const info: FetchInfo = {
package: "@peezy.tech/codex-flows",
version: "0.3.1",
runtime: "node 24.15.0",
node: "24.0.0",
platform: "linux",
arch: "x64",
shell: "/bin/bash",
cwd: "/workspace",
codexCommand: "/tmp/codex",
appServerUrl: "ws://127.0.0.1:3585",
workspaceBackendUrl: "ws://127.0.0.1:3586",
codexHome: "/tmp/codex-home",
backend: {
mode: "workspace",
status: "connected",
url: "ws://127.0.0.1:3586",
server: {
name: "codex-workspace-backend-local",
version: "0.1.0",
},
capabilities: {
workspaceMethods: 8,
},
threads: {
total: 2,
active: 1,
idle: 1,
other: 0,
latest: [
{
id: "thread_1234567890",
label: "Implement CLI",
status: "active",
},
],
},
},
};
const output = formatFetchInfo(info, { color: false });
expect(output).toContain("codex-flows");
expect(output).toContain("package @peezy.tech/codex-flows@0.3.1");
expect(output).toContain("workspace ws://127.0.0.1:3586");
expect(output).toContain("backend workspace connected");
expect(output).toContain("threads 2 listed, 1 active, 1 idle");
expect(output).not.toContain("\x1b[");
});
});
async function runCli(
args: string[],
env: Record<string, string | undefined> = {},
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
const proc = spawn(process.execPath, [
"--import",
import.meta.resolve("tsx"),
path.resolve(testDir, "../src/cli/index.ts"),
...args,
], {
env: {
...process.env,
...env,
},
stdio: ["ignore", "pipe", "pipe"],
});
const [stdout, stderr, exitCode] = await Promise.all([
collectText(proc.stdout),
collectText(proc.stderr),
exitCodeFor(proc),
]);
return { exitCode: exitCode ?? 1, stdout, stderr };
}
async function createFunctionFakeSshCommand(): Promise<string> {
const dir = await mkdtemp(path.join(os.tmpdir(), "codex-functions-cli-"));
const command = path.join(dir, "ssh.mjs");
await writeFile(command, functionFakeSshScript());
await chmod(command, 0o755);
return command;
}
function functionFakeSshScript(): string {
return `#!/usr/bin/env node
import { stdin, stdout } from "node:process";
process.on("SIGTERM", () => process.exit(0));
let buffer = "";
stdin.setEncoding("utf8");
stdin.on("data", (chunk) => {
buffer += chunk;
let newline = buffer.indexOf("\\n");
while (newline !== -1) {
const line = buffer.slice(0, newline).trim();
buffer = buffer.slice(newline + 1);
if (line) handle(line);
newline = buffer.indexOf("\\n");
}
});
function handle(line) {
const message = JSON.parse(line);
stdout.write(JSON.stringify({
jsonrpc: "2.0",
id: message.id,
result: resultFor(message.method, message.params),
}) + "\\n");
}
function resultFor(method, params) {
if (method === "workspace.initialize") {
return {
ok: true,
serverInfo: { name: "fake-remote-agent", version: "0.1.0" },
capabilities: {
appServerPassThrough: true,
workspaceMethods: ["functions.list", "functions.describe", "functions.call"],
},
};
}
if (method === "functions.list") {
return {
functions: [{
name: "portfolioSnapshot",
description: "Read portfolio.",
sideEffects: "read-only",
}],
};
}
if (method === "functions.describe") {
return {
function: {
name: params.name,
description: "Read portfolio.",
sideEffects: "read-only",
},
};
}
if (method === "functions.call") {
return {
result: {
account: params.params.account,
equity: 456,
},
};
}
return {};
}
setInterval(() => {}, 1_000);
`;
}
async function collectText(stream: NodeJS.ReadableStream | null): Promise<string> {
let output = "";
if (!stream) {
return output;
}
for await (const chunk of stream) {
output += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
}
return output;
}
function exitCodeFor(child: ReturnType<typeof spawn>): Promise<number | null> {
return new Promise((resolve, reject) => {
child.once("error", reject);
child.once("exit", (code) => resolve(code));
});
}