From 022deac9fb32ac14043f0999c5188ee34892fd06 Mon Sep 17 00:00:00 2001 From: clawedassistant26 <307253840+clawedassistant26@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:21:49 +0000 Subject: [PATCH] fix(ai): resolve engine aliases for ai({ engine }) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ai(prompt, { engine: "cc" })` failed with "ai() needs an installed engine" even with Claude installed, because pickAiEngine() matched raw ENGINES keys and never consulted ALIASES — while /agents, start and upgrade all resolve aliases (README: "name any; alias ok"). Under --dry-run it was worse: the raw alias reached aiExecArgs() and threw "no headless mode", though a dry run is meant to narrate without requiring an installed engine. Resolve the preference through resolveEngine() and let the dry-run fallback do the same. An unknown name still yields null, and a named-but-not-installed engine still refuses to fall back to another one. --- src/cli.mjs | 8 +++++--- src/engines.mjs | 14 ++++++++++++-- test/cli.test.mjs | 10 ++++++++++ test/engines.test.mjs | 19 +++++++++++++++++++ 4 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/cli.mjs b/src/cli.mjs index f875a1f..a520ef9 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -17,7 +17,7 @@ // Under --dry-run it narrates the argv instead of spawning. import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; -import { ENGINES, aiExecArgs, pickAiEngine } from "./engines.mjs"; +import { ENGINES, aiExecArgs, pickAiEngine, resolveEngine } from "./engines.mjs"; // Resolve THIS package's own moshcode entrypoint, so scripting stays // self-referential and doesn't depend on `moshcode` being on PATH. @@ -68,8 +68,10 @@ export function cliVerb(name, summary) { */ export function runAi(ctx, prompt, opts = {}) { if (ctx.dryRun) { - // narrate without requiring an installed engine - const engine = pickAiEngine(opts.engine) || opts.engine || "claude"; + // narrate without requiring an installed engine — so the fallback has to + // resolve an alias itself: with nothing installed pickAiEngine() returns + // null, and handing the raw alias to aiExecArgs threw instead of narrating. + const engine = pickAiEngine(opts.engine) || resolveEngine(opts.engine)?.[0] || opts.engine || "claude"; const args = aiExecArgs(engine, prompt); // throws only on an unknown engine name ctx.out(` 🧠 ai(${JSON.stringify(String(prompt).slice(0, 48))}) → would run: ${engine} ${args.join(" ")}`); return ""; diff --git a/src/engines.mjs b/src/engines.mjs index 8a64f19..d4976a4 100644 --- a/src/engines.mjs +++ b/src/engines.mjs @@ -166,9 +166,19 @@ export function aiExecArgs(engine, prompt) { return fn(String(prompt)); } -/** First installed engine that supports headless ai(), honoring a preference. */ +/** + * First installed engine that supports headless ai(), honoring a preference. + * + * A preference names an engine the same way every other engine surface does + * (`/agents cc`, `moshcode start cc`, `moshcode upgrade cc` — README: "name + * any; alias ok"), so resolve ALIASES here too. Matching raw ENGINES keys only + * made `ai(prompt, { engine: "cc" })` read as "no such engine" and fail with + * "needs an installed engine" even when Claude was installed. An unknown name + * still yields null. + */ export function pickAiEngine(preferred) { - const order = preferred ? [preferred] : ["claude", "codex", "opencode", "privacycode", "gemini", "aider"]; + const wanted = preferred ? resolveEngine(preferred)?.[0] : null; + const order = preferred ? (wanted ? [wanted] : []) : ["claude", "codex", "opencode", "privacycode", "gemini", "aider"]; for (const key of order) { if (Object.hasOwn(ENGINES, key) && Object.hasOwn(AI_EXEC, key) && isInstalled(ENGINES[key].bin)) return key; } diff --git a/test/cli.test.mjs b/test/cli.test.mjs index 36a9b6a..5e88106 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -147,6 +147,16 @@ test("ai() in dry-run narrates the engine invocation and returns empty string", assert.match(ctx.lines.join("\n"), /would run: codex exec/); }); +test("ai() in dry-run narrates an aliased engine instead of throwing", () => { + // A dry run narrates without requiring an installed engine, so with nothing + // installed pickAiEngine() returns null and the fallback has to resolve the + // alias itself — handing the raw "cc" to aiExecArgs threw "no headless mode". + const ctx = { dryRun: true, lines: [], out(l) { this.lines.push(l); } }; + const out = runAi(ctx, "summarize the diff", { engine: "cc" }); + assert.equal(out, ""); + assert.match(ctx.lines.join("\n"), /would run: claude -p/); +}); + // R8: non-zero exits return { ok: false } instead of throwing, so scripts can // branch on outcomes without a try/catch. test("R8: a non-zero CLI exit returns { ok: false } instead of throwing", async () => { diff --git a/test/engines.test.mjs b/test/engines.test.mjs index f087802..9f24bbb 100644 --- a/test/engines.test.mjs +++ b/test/engines.test.mjs @@ -160,6 +160,25 @@ test("ranOk and exitReason cover clean exits, bad codes, and spawn errors", asyn assert.match(exitReason(missing), /ENOENT|not found|spawn/i); }); +test("pickAiEngine resolves an engine alias, like every other engine surface", () => { + // `/agents cc`, `moshcode start cc` and `moshcode upgrade cc` all resolve the + // alias, so an ai() preference must too — otherwise `ai(p, { engine: "cc" })` + // reports "needs an installed engine" with Claude sitting right there on PATH. + const dir = tempDir("moshcode-ai-alias-"); + writeEngine(dir, "claude"); + const previous = process.env.PATH; + process.env.PATH = `${dir}${path.delimiter}${previous || ""}`; + try { + assert.equal(pickAiEngine("claude"), "claude"); + assert.equal(pickAiEngine("cc"), "claude"); + assert.equal(pickAiEngine("claude-code"), "claude"); + // an unknown preference is still no engine at all + assert.equal(pickAiEngine("definitely-not-an-engine"), null); + } finally { + process.env.PATH = previous; + } +}); + test("engine lookup ignores inherited Object.prototype members", () => { // ENGINES/ALIASES/AI_EXEC are plain object literals, so an unknown name that // matches an Object.prototype member must not resolve as a real engine.