Skip to content

Commit 0f3aa92

Browse files
fix(ai): resolve engine aliases for ai({ engine })
`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.
1 parent dfcb8c1 commit 0f3aa92

4 files changed

Lines changed: 46 additions & 5 deletions

File tree

src/cli.mjs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
// Under --dry-run it narrates the argv instead of spawning.
1818
import { spawnSync } from "node:child_process";
1919
import { fileURLToPath } from "node:url";
20-
import { ENGINES, aiExecArgs, pickAiEngine } from "./engines.mjs";
20+
import { ENGINES, aiExecArgs, pickAiEngine, resolveEngine } from "./engines.mjs";
2121

2222
// Resolve THIS package's own moshcode entrypoint, so scripting stays
2323
// self-referential and doesn't depend on `moshcode` being on PATH.
@@ -68,8 +68,10 @@ export function cliVerb(name, summary) {
6868
*/
6969
export function runAi(ctx, prompt, opts = {}) {
7070
if (ctx.dryRun) {
71-
// narrate without requiring an installed engine
72-
const engine = pickAiEngine(opts.engine) || opts.engine || "claude";
71+
// narrate without requiring an installed engine — so the fallback has to
72+
// resolve an alias itself: with nothing installed pickAiEngine() returns
73+
// null, and handing the raw alias to aiExecArgs threw instead of narrating.
74+
const engine = pickAiEngine(opts.engine) || resolveEngine(opts.engine)?.[0] || opts.engine || "claude";
7375
const args = aiExecArgs(engine, prompt); // throws only on an unknown engine name
7476
ctx.out(` 🧠 ai(${JSON.stringify(String(prompt).slice(0, 48))}) → would run: ${engine} ${args.join(" ")}`);
7577
return "";

src/engines.mjs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,9 +153,19 @@ export function aiExecArgs(engine, prompt) {
153153
return fn(String(prompt));
154154
}
155155

156-
/** First installed engine that supports headless ai(), honoring a preference. */
156+
/**
157+
* First installed engine that supports headless ai(), honoring a preference.
158+
*
159+
* A preference names an engine the same way every other engine surface does
160+
* (`/agents cc`, `moshcode start cc`, `moshcode upgrade cc` — README: "name
161+
* any; alias ok"), so resolve ALIASES here too. Matching raw ENGINES keys only
162+
* made `ai(prompt, { engine: "cc" })` read as "no such engine" and fail with
163+
* "needs an installed engine" even when Claude was installed. An unknown name
164+
* still yields null.
165+
*/
157166
export function pickAiEngine(preferred) {
158-
const order = preferred ? [preferred] : ["claude", "codex", "opencode", "gemini", "aider"];
167+
const wanted = preferred ? resolveEngine(preferred)?.[0] : null;
168+
const order = preferred ? (wanted ? [wanted] : []) : ["claude", "codex", "opencode", "gemini", "aider"];
159169
for (const key of order) {
160170
if (Object.hasOwn(ENGINES, key) && Object.hasOwn(AI_EXEC, key) && isInstalled(ENGINES[key].bin)) return key;
161171
}

test/cli.test.mjs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,16 @@ test("ai() in dry-run narrates the engine invocation and returns empty string",
147147
assert.match(ctx.lines.join("\n"), /would run: codex exec/);
148148
});
149149

150+
test("ai() in dry-run narrates an aliased engine instead of throwing", () => {
151+
// A dry run narrates without requiring an installed engine, so with nothing
152+
// installed pickAiEngine() returns null and the fallback has to resolve the
153+
// alias itself — handing the raw "cc" to aiExecArgs threw "no headless mode".
154+
const ctx = { dryRun: true, lines: [], out(l) { this.lines.push(l); } };
155+
const out = runAi(ctx, "summarize the diff", { engine: "cc" });
156+
assert.equal(out, "");
157+
assert.match(ctx.lines.join("\n"), /would run: claude -p/);
158+
});
159+
150160
// R8: non-zero exits return { ok: false } instead of throwing, so scripts can
151161
// branch on outcomes without a try/catch.
152162
test("R8: a non-zero CLI exit returns { ok: false } instead of throwing", async () => {

test/engines.test.mjs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,25 @@ test("ranOk and exitReason cover clean exits, bad codes, and spawn errors", asyn
158158
assert.match(exitReason(missing), /ENOENT|not found|spawn/i);
159159
});
160160

161+
test("pickAiEngine resolves an engine alias, like every other engine surface", () => {
162+
// `/agents cc`, `moshcode start cc` and `moshcode upgrade cc` all resolve the
163+
// alias, so an ai() preference must too — otherwise `ai(p, { engine: "cc" })`
164+
// reports "needs an installed engine" with Claude sitting right there on PATH.
165+
const dir = tempDir("moshcode-ai-alias-");
166+
writeEngine(dir, "claude");
167+
const previous = process.env.PATH;
168+
process.env.PATH = `${dir}${path.delimiter}${previous || ""}`;
169+
try {
170+
assert.equal(pickAiEngine("claude"), "claude");
171+
assert.equal(pickAiEngine("cc"), "claude");
172+
assert.equal(pickAiEngine("claude-code"), "claude");
173+
// an unknown preference is still no engine at all
174+
assert.equal(pickAiEngine("definitely-not-an-engine"), null);
175+
} finally {
176+
process.env.PATH = previous;
177+
}
178+
});
179+
161180
test("engine lookup ignores inherited Object.prototype members", () => {
162181
// ENGINES/ALIASES/AI_EXEC are plain object literals, so an unknown name that
163182
// matches an Object.prototype member must not resolve as a real engine.

0 commit comments

Comments
 (0)