diff --git a/tools/ai-cli-dispatch/src/dispatch.ts b/tools/ai-cli-dispatch/src/dispatch.ts new file mode 100644 index 0000000..9bc2a4c --- /dev/null +++ b/tools/ai-cli-dispatch/src/dispatch.ts @@ -0,0 +1,39 @@ +import type { ClientName } from "./types.js"; + +export interface DispatchConfig { + defaultClient?: ClientName; + client?: ClientName; +} + +const CLIENT_NAMES: ClientName[] = ["codex", "claude", "opencode"]; + +export function resolveClient( + prompt: string, + config?: DispatchConfig +): ClientName | null { + // Explicit --client flag takes highest precedence + if (config?.client && CLIENT_NAMES.includes(config.client)) { + return config.client; + } + + const lower = prompt.toLowerCase(); + + // Check for "open code" before "opencode" to handle the spaced variant + if (lower.includes("open code")) { + return "opencode"; + } + + if (lower.includes("claude")) { + return "claude"; + } + + if (lower.includes("codex")) { + return "codex"; + } + + if (lower.includes("opencode")) { + return "opencode"; + } + + return config?.defaultClient ?? null; +} diff --git a/tools/ai-cli-dispatch/tests/dispatch.test.ts b/tools/ai-cli-dispatch/tests/dispatch.test.ts new file mode 100644 index 0000000..663e91c --- /dev/null +++ b/tools/ai-cli-dispatch/tests/dispatch.test.ts @@ -0,0 +1,83 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { resolveClient } from "../src/dispatch.js"; +import type { ClientName } from "../src/types.js"; + +describe("resolveClient", () => { + it('returns "codex" when prompt contains "use codex"', () => { + const result = resolveClient("use codex to refactor this"); + assert.strictEqual(result, "codex"); + }); + + it('returns "claude" when prompt contains "tell claude to..."', () => { + const result = resolveClient("tell claude to review my code"); + assert.strictEqual(result, "claude"); + }); + + it('returns "opencode" when prompt contains "run with opencode"', () => { + const result = resolveClient("run with opencode"); + assert.strictEqual(result, "opencode"); + }); + + it('returns "opencode" when prompt contains "open code"', () => { + const result = resolveClient("open code this file"); + assert.strictEqual(result, "opencode"); + }); + + it("returns null when no keyword matches and no default is configured", () => { + const result = resolveClient("hello world"); + assert.strictEqual(result, null); + }); + + it("returns defaultClient when no keyword matches", () => { + const result = resolveClient("hello world", { defaultClient: "claude" }); + assert.strictEqual(result, "claude"); + }); + + it("prefers --client flag over keyword parsing", () => { + const result = resolveClient("use codex for this", { client: "claude" }); + assert.strictEqual(result, "claude"); + }); + + it("prefers --client flag over defaultClient", () => { + const result = resolveClient("hello world", { + client: "opencode", + defaultClient: "codex", + }); + assert.strictEqual(result, "opencode"); + }); + + it("handles uppercase CODEX", () => { + const result = resolveClient("Use CODEX please"); + assert.strictEqual(result, "codex"); + }); + + it("handles mixed-case Claude", () => { + const result = resolveClient("Tell Claude to fix this"); + assert.strictEqual(result, "claude"); + }); + + it("returns first match when multiple clients are mentioned", () => { + const result = resolveClient("ask claude or codex to help"); + assert.strictEqual(result, "claude"); + }); + + it("returns null for empty prompt with no default", () => { + const result = resolveClient(""); + assert.strictEqual(result, null); + }); + + it("returns defaultClient for empty prompt", () => { + const result = resolveClient("", { defaultClient: "codex" }); + assert.strictEqual(result, "codex"); + }); + + it("validates --client flag value", () => { + // Invalid client flag should fall back to keyword/default behavior + const result = resolveClient("use codex", { + client: "invalid" as ClientName, + }); + // If client flag is invalid, we should fall back to keyword matching + assert.strictEqual(result, "codex"); + }); +});