feat(M5): CLI Integration

This commit is contained in:
2026-05-18 18:39:33 -05:00
parent 0879ffe39f
commit 4f59258b20
2 changed files with 524 additions and 133 deletions
+385 -48
View File
@@ -1,66 +1,403 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import { main, discoverClients, execClient } from "../src/cli.js";
import { main } from "../src/cli.js";
import { ClientNotFoundError } from "../src/types.js";
import type { ClientInfo, ExecResult, ClientName } from "../src/types.js";
describe("discoverClients", () => {
it("returns all three configured clients", () => {
const clients = discoverClients();
assert.strictEqual(clients.length, 3);
const names = clients.map((c) => c.name);
assert.deepStrictEqual(names, ["codex", "claude", "opencode"]);
});
function captureOutput() {
const logs: string[] = [];
const errors: string[] = [];
it("returns ClientInfo with found boolean", () => {
const clients = discoverClients();
for (const c of clients) {
assert.strictEqual(typeof c.found, "boolean");
}
});
});
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", () => {
it("returns 0 for --help", async () => {
const code = await main(["node", "cli.ts", "--help"]);
assert.strictEqual(code, 0);
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,
};
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("returns 0 for list", async () => {
const code = await main(["node", "cli.ts", "list"]);
assert.strictEqual(code, 0);
});
it("returns 0 for list --json", async () => {
const code = await main(["node", "cli.ts", "list", "--json"]);
assert.strictEqual(code, 0);
});
it("returns 1 for exec without --client", async () => {
const code = await main(["node", "cli.ts", "exec", "--prompt", "hello"]);
assert.strictEqual(code, 1);
});
it("returns 1 for exec without --prompt", async () => {
const code = await main(["node", "cli.ts", "exec", "--client", "codex"]);
assert.strictEqual(code, 1);
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 code = await main(["node", "cli.ts", "bogus"]);
assert.strictEqual(code, 1);
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("returns 1 when no command is given", async () => {
const code = await main(["node", "cli.ts"]);
assert.strictEqual(code, 1);
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();
}
});
});
describe("execClient", () => {
it("throws ClientNotFoundError when client path is empty", () => {
assert.throws(
() => execClient("codex", "test prompt", ""),
(err: unknown) => err instanceof ClientNotFoundError
);
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,
}),
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: {} }),
}
);
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: {} }),
}
);
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" }),
}
);
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: {} }),
}
);
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: {} }),
}
);
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,
}),
resolveClient: () => "codex",
resolveConfig: () => ({ paths: {} }),
stdoutWrite: (chunk) => stdoutChunks.push(chunk),
}
);
assert.strictEqual(code, 0);
assert.strictEqual(stdoutChunks.join(""), "done");
} finally {
out.restore();
}
});
});