497 lines
15 KiB
TypeScript
497 lines
15 KiB
TypeScript
import { describe, it } from "node:test";
|
|
import assert from "node:assert";
|
|
import { main } from "../src/cli.js";
|
|
import { ClientNotFoundError } from "../src/types.js";
|
|
import type { ClientInfo, ExecResult, ClientName } from "../src/types.js";
|
|
|
|
function captureOutput() {
|
|
const logs: string[] = [];
|
|
const errors: string[] = [];
|
|
|
|
const origLog = console.log;
|
|
const origError = console.error;
|
|
|
|
console.log = (...args: unknown[]) => {
|
|
logs.push(args.map(String).join(" "));
|
|
};
|
|
console.error = (...args: unknown[]) => {
|
|
errors.push(args.map(String).join(" "));
|
|
};
|
|
|
|
return {
|
|
logs,
|
|
errors,
|
|
restore() {
|
|
console.log = origLog;
|
|
console.error = origError;
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("main", () => {
|
|
const mockClients: ClientInfo[] = [
|
|
{ name: "codex", found: true, path: "/usr/bin/codex", version: "1.0.0" },
|
|
{ name: "claude", found: true, path: "/usr/bin/claude", version: "2.0.0" },
|
|
{ name: "opencode", found: false },
|
|
];
|
|
|
|
const mockResult: ExecResult = {
|
|
stdout: "output",
|
|
stderr: "",
|
|
exitCode: 0,
|
|
client: "codex",
|
|
durationMs: 42,
|
|
};
|
|
|
|
it("returns 0 for --help and prints usage", async () => {
|
|
const out = captureOutput();
|
|
try {
|
|
const code = await main(["node", "cli.ts", "--help"]);
|
|
assert.strictEqual(code, 0);
|
|
assert.ok(out.logs.some((l) => l.includes("Usage:")));
|
|
} finally {
|
|
out.restore();
|
|
}
|
|
});
|
|
|
|
it("prints usage and returns 1 for bare invocation", async () => {
|
|
const out = captureOutput();
|
|
try {
|
|
const code = await main(["node", "cli.ts"]);
|
|
assert.strictEqual(code, 1);
|
|
assert.ok(out.logs.some((l) => l.includes("Usage:")));
|
|
} finally {
|
|
out.restore();
|
|
}
|
|
});
|
|
|
|
it("returns 1 for unknown command", async () => {
|
|
const out = captureOutput();
|
|
try {
|
|
const code = await main(["node", "cli.ts", "bogus"]);
|
|
assert.strictEqual(code, 1);
|
|
const err = out.errors[0] ?? out.logs[0];
|
|
assert.ok(String(err).includes("Unknown command"));
|
|
} finally {
|
|
out.restore();
|
|
}
|
|
});
|
|
|
|
it("list prints JSON array of clients by default", async () => {
|
|
const out = captureOutput();
|
|
try {
|
|
const code = await main(["node", "cli.ts", "list"], {
|
|
detectClients: () => mockClients,
|
|
});
|
|
assert.strictEqual(code, 0);
|
|
assert.strictEqual(out.logs.length, 1);
|
|
const parsed = JSON.parse(out.logs[0]);
|
|
assert.deepStrictEqual(parsed, mockClients);
|
|
} finally {
|
|
out.restore();
|
|
}
|
|
});
|
|
|
|
it("list prints human-readable output with --text", async () => {
|
|
const out = captureOutput();
|
|
try {
|
|
const code = await main(["node", "cli.ts", "list", "--text"], {
|
|
detectClients: () => mockClients,
|
|
});
|
|
assert.strictEqual(code, 0);
|
|
assert.ok(out.logs.some((l) => l.includes("codex:")));
|
|
assert.ok(out.logs.some((l) => l.includes("claude:")));
|
|
assert.ok(out.logs.some((l) => l.includes("opencode:")));
|
|
} finally {
|
|
out.restore();
|
|
}
|
|
});
|
|
|
|
it("run executes client with prompt and prints JSON result", async () => {
|
|
const out = captureOutput();
|
|
try {
|
|
const executed: { client: ClientName; prompt: string }[] = [];
|
|
const code = await main(
|
|
["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello"],
|
|
{
|
|
detectClients: () => mockClients,
|
|
executePrompt: async (client, prompt) => {
|
|
executed.push({ client, prompt });
|
|
return mockResult;
|
|
},
|
|
}
|
|
);
|
|
assert.strictEqual(code, 0);
|
|
assert.strictEqual(executed.length, 1);
|
|
assert.strictEqual(executed[0].client, "codex");
|
|
assert.strictEqual(executed[0].prompt, "hello");
|
|
const parsed = JSON.parse(out.logs[0]);
|
|
assert.deepStrictEqual(parsed, mockResult);
|
|
} finally {
|
|
out.restore();
|
|
}
|
|
});
|
|
|
|
it("run prints text output with --text", async () => {
|
|
const out = captureOutput();
|
|
const stdoutChunks: string[] = [];
|
|
const stderrChunks: string[] = [];
|
|
try {
|
|
const code = await main(
|
|
[
|
|
"node",
|
|
"cli.ts",
|
|
"run",
|
|
"--client",
|
|
"codex",
|
|
"--prompt",
|
|
"hello",
|
|
"--text",
|
|
],
|
|
{
|
|
detectClients: () => mockClients,
|
|
executePrompt: async () => ({
|
|
stdout: "hello-out",
|
|
stderr: "hello-err",
|
|
exitCode: 0,
|
|
client: "codex",
|
|
durationMs: 42,
|
|
}),
|
|
stdoutWrite: (chunk) => stdoutChunks.push(chunk),
|
|
stderrWrite: (chunk) => stderrChunks.push(chunk),
|
|
}
|
|
);
|
|
assert.strictEqual(code, 0);
|
|
assert.strictEqual(stdoutChunks.join(""), "hello-out");
|
|
assert.strictEqual(stderrChunks.join(""), "hello-err");
|
|
} finally {
|
|
out.restore();
|
|
}
|
|
});
|
|
|
|
it("run returns 1 and prints error JSON when client is missing", async () => {
|
|
const out = captureOutput();
|
|
try {
|
|
const code = await main(["node", "cli.ts", "run", "--prompt", "hello"], {
|
|
detectClients: () => mockClients,
|
|
});
|
|
assert.strictEqual(code, 1);
|
|
const parsed = JSON.parse(out.errors[0]);
|
|
assert.ok(parsed.error.includes("client"));
|
|
} finally {
|
|
out.restore();
|
|
}
|
|
});
|
|
|
|
it("run returns 1 and prints error text when client is missing in text mode", async () => {
|
|
const out = captureOutput();
|
|
try {
|
|
const code = await main(
|
|
["node", "cli.ts", "run", "--prompt", "hello", "--text"],
|
|
{
|
|
detectClients: () => mockClients,
|
|
}
|
|
);
|
|
assert.strictEqual(code, 1);
|
|
assert.ok(out.errors[0].includes("client"));
|
|
} finally {
|
|
out.restore();
|
|
}
|
|
});
|
|
|
|
it("run returns 1 and prints error JSON when prompt is missing", async () => {
|
|
const out = captureOutput();
|
|
try {
|
|
const code = await main(["node", "cli.ts", "run", "--client", "codex"], {
|
|
detectClients: () => mockClients,
|
|
});
|
|
assert.strictEqual(code, 1);
|
|
const parsed = JSON.parse(out.errors[0]);
|
|
assert.ok(parsed.error.includes("prompt"));
|
|
} finally {
|
|
out.restore();
|
|
}
|
|
});
|
|
|
|
it("run returns 1 for unknown client", async () => {
|
|
const out = captureOutput();
|
|
try {
|
|
const code = await main(
|
|
["node", "cli.ts", "run", "--client", "bogus", "--prompt", "hello"],
|
|
{
|
|
detectClients: () => mockClients,
|
|
}
|
|
);
|
|
assert.strictEqual(code, 1);
|
|
const parsed = JSON.parse(out.errors[0]);
|
|
assert.ok(parsed.error.includes("client"));
|
|
} finally {
|
|
out.restore();
|
|
}
|
|
});
|
|
|
|
it("run returns 1 and prints error JSON when client not found", async () => {
|
|
const out = captureOutput();
|
|
try {
|
|
const code = await main(
|
|
["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello"],
|
|
{
|
|
detectClients: () => mockClients,
|
|
executePrompt: async () => {
|
|
throw new ClientNotFoundError("codex");
|
|
},
|
|
}
|
|
);
|
|
assert.strictEqual(code, 1);
|
|
const parsed = JSON.parse(out.errors[0]);
|
|
assert.ok(parsed.error.includes("not found"));
|
|
} finally {
|
|
out.restore();
|
|
}
|
|
});
|
|
|
|
it("dispatch routes positional prompt and prints JSON result", async () => {
|
|
const out = captureOutput();
|
|
try {
|
|
const executed: { client: ClientName; prompt: string }[] = [];
|
|
const code = await main(
|
|
["node", "cli.ts", "dispatch", "use claude to write tests"],
|
|
{
|
|
detectClients: () => mockClients,
|
|
executePrompt: async (client, prompt) => {
|
|
executed.push({ client, prompt });
|
|
return mockResult;
|
|
},
|
|
resolveClient: () => "claude",
|
|
resolveConfig: () => ({ paths: {}, timeout: 600_000 }),
|
|
}
|
|
);
|
|
assert.strictEqual(code, 0);
|
|
assert.strictEqual(executed.length, 1);
|
|
assert.strictEqual(executed[0].client, "claude");
|
|
assert.strictEqual(executed[0].prompt, "use claude to write tests");
|
|
const parsed = JSON.parse(out.logs[0]);
|
|
assert.deepStrictEqual(parsed, mockResult);
|
|
} finally {
|
|
out.restore();
|
|
}
|
|
});
|
|
|
|
it("dispatch routes --prompt flag and prints JSON result", async () => {
|
|
const out = captureOutput();
|
|
try {
|
|
const executed: { client: ClientName; prompt: string }[] = [];
|
|
const code = await main(
|
|
["node", "cli.ts", "dispatch", "--prompt", "use codex to refactor"],
|
|
{
|
|
detectClients: () => mockClients,
|
|
executePrompt: async (client, prompt) => {
|
|
executed.push({ client, prompt });
|
|
return mockResult;
|
|
},
|
|
resolveClient: () => "codex",
|
|
resolveConfig: () => ({ paths: {}, timeout: 600_000 }),
|
|
}
|
|
);
|
|
assert.strictEqual(code, 0);
|
|
assert.strictEqual(executed.length, 1);
|
|
assert.strictEqual(executed[0].prompt, "use codex to refactor");
|
|
} finally {
|
|
out.restore();
|
|
}
|
|
});
|
|
|
|
it("dispatch respects --client override", async () => {
|
|
const out = captureOutput();
|
|
try {
|
|
const executed: { client: ClientName; prompt: string }[] = [];
|
|
const code = await main(
|
|
["node", "cli.ts", "dispatch", "do something", "--client", "opencode"],
|
|
{
|
|
detectClients: () => mockClients,
|
|
executePrompt: async (client, prompt) => {
|
|
executed.push({ client, prompt });
|
|
return mockResult;
|
|
},
|
|
resolveClient: (_p, cfg) => cfg?.client ?? null,
|
|
resolveConfig: () => ({ paths: {}, defaultClient: "claude", timeout: 600_000 }),
|
|
}
|
|
);
|
|
assert.strictEqual(code, 0);
|
|
assert.strictEqual(executed[0].client, "opencode");
|
|
} finally {
|
|
out.restore();
|
|
}
|
|
});
|
|
|
|
it("dispatch returns 1 when prompt cannot be resolved", async () => {
|
|
const out = captureOutput();
|
|
try {
|
|
const code = await main(
|
|
["node", "cli.ts", "dispatch", "do something"],
|
|
{
|
|
detectClients: () => mockClients,
|
|
executePrompt: async () => mockResult,
|
|
resolveClient: () => null,
|
|
resolveConfig: () => ({ paths: {}, timeout: 600_000 }),
|
|
}
|
|
);
|
|
assert.strictEqual(code, 1);
|
|
const parsed = JSON.parse(out.errors[0]);
|
|
assert.ok(parsed.error.includes("resolve"));
|
|
} finally {
|
|
out.restore();
|
|
}
|
|
});
|
|
|
|
it("dispatch returns 1 when resolved client is not found", async () => {
|
|
const out = captureOutput();
|
|
try {
|
|
const code = await main(
|
|
["node", "cli.ts", "dispatch", "do something"],
|
|
{
|
|
detectClients: () => mockClients,
|
|
executePrompt: async () => {
|
|
throw new ClientNotFoundError("claude");
|
|
},
|
|
resolveClient: () => "claude",
|
|
resolveConfig: () => ({ paths: {}, timeout: 600_000 }),
|
|
}
|
|
);
|
|
assert.strictEqual(code, 1);
|
|
const parsed = JSON.parse(out.errors[0]);
|
|
assert.ok(parsed.error.includes("not found"));
|
|
} finally {
|
|
out.restore();
|
|
}
|
|
});
|
|
|
|
it("dispatch returns 1 when no prompt is given", async () => {
|
|
const out = captureOutput();
|
|
try {
|
|
const code = await main(["node", "cli.ts", "dispatch"], {
|
|
detectClients: () => mockClients,
|
|
});
|
|
assert.strictEqual(code, 1);
|
|
const parsed = JSON.parse(out.errors[0]);
|
|
assert.ok(parsed.error.includes("prompt"));
|
|
} finally {
|
|
out.restore();
|
|
}
|
|
});
|
|
|
|
it("dispatch prints text output with --text", async () => {
|
|
const out = captureOutput();
|
|
const stdoutChunks: string[] = [];
|
|
try {
|
|
const code = await main(
|
|
["node", "cli.ts", "dispatch", "do something", "--text"],
|
|
{
|
|
detectClients: () => mockClients,
|
|
executePrompt: async () => ({
|
|
stdout: "done",
|
|
stderr: "",
|
|
exitCode: 0,
|
|
client: "codex",
|
|
durationMs: 42,
|
|
}),
|
|
resolveClient: () => "codex",
|
|
resolveConfig: () => ({ paths: {}, timeout: 600_000 }),
|
|
stdoutWrite: (chunk) => stdoutChunks.push(chunk),
|
|
}
|
|
);
|
|
assert.strictEqual(code, 0);
|
|
assert.strictEqual(stdoutChunks.join(""), "done");
|
|
} finally {
|
|
out.restore();
|
|
}
|
|
});
|
|
|
|
it("run prints debug diagnostic JSON to stderr with --debug", async () => {
|
|
const out = captureOutput();
|
|
const stderrChunks: string[] = [];
|
|
try {
|
|
const code = await main(
|
|
["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello", "--debug"],
|
|
{
|
|
detectClients: () => mockClients,
|
|
executePrompt: async (_client, _prompt, options?) => {
|
|
options?.onDebug?.({
|
|
command: "codex",
|
|
args: ["exec", "--yolo", "hello"],
|
|
pid: 12345,
|
|
exitCode: 0,
|
|
exitSignal: null,
|
|
durationMs: 42,
|
|
stderrLength: 0,
|
|
stdoutLength: 6,
|
|
} as any);
|
|
return {
|
|
stdout: "output",
|
|
stderr: "",
|
|
exitCode: 0,
|
|
client: "codex",
|
|
durationMs: 42,
|
|
};
|
|
},
|
|
stderrWrite: (chunk) => stderrChunks.push(chunk),
|
|
}
|
|
);
|
|
assert.strictEqual(code, 0);
|
|
assert.strictEqual(stderrChunks.length, 1);
|
|
const diag = JSON.parse(stderrChunks[0]);
|
|
assert.strictEqual(diag.command, "codex");
|
|
assert.deepStrictEqual(diag.args, ["exec", "--yolo", "hello"]);
|
|
assert.strictEqual(diag.pid, 12345);
|
|
assert.strictEqual(diag.exitCode, 0);
|
|
assert.strictEqual(diag.exitSignal, null);
|
|
assert.strictEqual(diag.durationMs, 42);
|
|
assert.strictEqual(diag.stderrLength, 0);
|
|
} finally {
|
|
out.restore();
|
|
}
|
|
});
|
|
|
|
it("dispatch prints debug diagnostic JSON to stderr with --debug", async () => {
|
|
const out = captureOutput();
|
|
const stderrChunks: string[] = [];
|
|
try {
|
|
const code = await main(
|
|
["node", "cli.ts", "dispatch", "do something", "--debug"],
|
|
{
|
|
detectClients: () => mockClients,
|
|
executePrompt: async (_client, _prompt, options?) => {
|
|
options?.onDebug?.({
|
|
command: "codex",
|
|
args: ["exec", "--yolo", "do something"],
|
|
pid: 12345,
|
|
exitCode: 0,
|
|
exitSignal: null,
|
|
durationMs: 42,
|
|
stderrLength: 0,
|
|
stdoutLength: 6,
|
|
} as any);
|
|
return {
|
|
stdout: "output",
|
|
stderr: "",
|
|
exitCode: 0,
|
|
client: "codex",
|
|
durationMs: 42,
|
|
};
|
|
},
|
|
resolveClient: () => "codex",
|
|
resolveConfig: () => ({ paths: {}, timeout: 600_000 }),
|
|
stderrWrite: (chunk) => stderrChunks.push(chunk),
|
|
}
|
|
);
|
|
assert.strictEqual(code, 0);
|
|
assert.strictEqual(stderrChunks.length, 1);
|
|
const diag = JSON.parse(stderrChunks[0]);
|
|
assert.strictEqual(diag.command, "codex");
|
|
assert.strictEqual(diag.durationMs, 42);
|
|
} finally {
|
|
out.restore();
|
|
}
|
|
});
|
|
});
|