Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions src/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 "";
Expand Down
14 changes: 12 additions & 2 deletions src/engines.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
10 changes: 10 additions & 0 deletions test/cli.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
19 changes: 19 additions & 0 deletions test/engines.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading