diff --git a/README.md b/README.md index 58be990..8a16766 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,7 @@ chmod +x deploy.mosh | `ask(prompt)` | blocking gate — waits for human reply at moshcode.sh | | `say("…")` | print a line | | `sleep(ms)` | pause for N milliseconds (blocking) | +| `shell(cmd)` | run a shell command (blocking, `$SHELL -c`); returns `{ ok, code }` | | `stop()` | end the loop (`alive = false`) | | `repeat()` | back to the top of the loop | @@ -238,6 +239,25 @@ const task = await ask("what should I work on next?"); say(`got it: ${task}`); ``` +### Error handling + +CLI verbs and `shell()` return `{ ok, code }` instead of throwing on non-zero +exits, so scripts can branch on outcomes without `try/catch`: + +```js +const r = install("claude"); +if (!r.ok) { + say(`install failed (exit ${r.code}), trying fallback…`); + install("codex"); +} + +const test = shell("npm test"); +if (!test.ok) notify("tests failed!"); +``` + +Only truly fatal errors (e.g. `moshcode` binary not found) throw. This keeps +`while (alive)` loops resilient — a single failing verb doesn't crash the script. + ### Dry run `--dry-run` narrates every action without executing it — no engine spawns, no diff --git a/src/cli.mjs b/src/cli.mjs index dac1b5b..3605ac8 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -23,7 +23,14 @@ import { ENGINES, aiExecArgs, pickAiEngine } from "./engines.mjs"; // self-referential and doesn't depend on `moshcode` being on PATH. const MOSHCODE_BIN = fileURLToPath(new URL("../bin/moshcode.mjs", import.meta.url)); -/** Run `moshcode ...args`, blocking until it exits. Returns { ok, code }. */ +/** + * Run `moshcode ...args`, blocking until it exits. + * + * Returns { ok, code } — always. A non-zero exit returns { ok: false, code } + * so scripts can branch on outcomes (`if (!install("foo").ok) …`) without a + * try/catch. Only truly fatal errors (spawn failures like ENOENT) throw. + * This is the R8 convention from PRD 0004. + */ export function runMoshcode(cmd, args, ctx) { const argv = [cmd, ...args.map(String)]; const printable = `moshcode ${argv.join(" ")}`.trimEnd(); @@ -35,13 +42,14 @@ export function runMoshcode(cmd, args, ctx) { ctx.out(` ▶ ${printable}`); const res = spawnSync(process.execPath, [MOSHCODE_BIN, ...argv], { stdio: "inherit" }); - if (res.error) throw res.error; - if (res.status !== 0) { - // Fail loud for now — whether a non-zero passthrough should throw or return - // a result is an open question in PRD 0004 (R8). - throw new Error(`moshscript: ${cmd}() → moshcode exited with ${res.signal || res.status}`); + if (res.error) throw res.error; // truly fatal: spawn itself failed (ENOENT etc.) + + const code = res.status ?? 1; + if (code !== 0) { + ctx.out(` ✗ ${cmd}() exited ${res.signal || code}`); + return { ok: false, code, signal: res.signal || null }; } - return { ok: true, code: res.status }; + return { ok: true, code: 0 }; } /** A vocabulary command mapping `name(...args)` → `moshcode name ...args`. */ diff --git a/src/commands.mjs b/src/commands.mjs index 6723c57..6500b12 100644 --- a/src/commands.mjs +++ b/src/commands.mjs @@ -13,7 +13,7 @@ // 2. Local verbs — moshscript-only flavor/helpers with no CLI equivalent // (mosh, code, notify, say, sleep, stop, repeat). `mosh()` is the worked // example of the local command shape. -import { spawn } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import { createRegistry } from "./registry.mjs"; import { cliVerb, aiVerb } from "./cli.mjs"; @@ -156,6 +156,35 @@ const COMMANDS = [ }, }, + { + name: "shell", + summary: "run a shell command (blocking, spawnSync $SHELL -c)", + // The moshscript system verb for arbitrary shell commands. Blocking + // (spawnSync + inherited stdio) so it runs inline in the no-`await` style, + // and the child owns the terminal for interactive commands. Returns + // { ok, code } so scripts can branch on the exit status: + // const r = shell("npm test"); if (!r.ok) say("tests failed"); + run(ctx, ...args) { + const cmd = args.join(" "); + if (!cmd) throw new Error("moshscript: shell() requires a command string"); + if (ctx.dryRun) { + ctx.out(` ▶ shell(${JSON.stringify(cmd)}) → would run: $SHELL -c ${JSON.stringify(cmd)}`); + return { ok: true, dryRun: true }; + } + const sh = process.env.SHELL + || (process.platform === "win32" ? (process.env.COMSPEC || "cmd.exe") : "/bin/sh"); + ctx.out(` ▶ shell: ${cmd}`); + const res = spawnSync(sh, ["-c", cmd], { stdio: "inherit" }); + if (res.error) throw res.error; + const code = res.status ?? 1; + if (code !== 0) { + ctx.out(` ✗ shell() exited ${res.signal || code}`); + return { ok: false, code, signal: res.signal || null }; + } + return { ok: true, code: 0 }; + }, + }, + // CLI verbs — each is `moshcode ...args`. This is the whole point: // scripting the CLI. Add a capability by adding a line here. // diff --git a/src/tui.mjs b/src/tui.mjs index 7ef742e..c94c5eb 100644 --- a/src/tui.mjs +++ b/src/tui.mjs @@ -130,7 +130,7 @@ function printHelp() { ` ${acid("/pwd")} show the current dir + git repo/branch/origin`, ` ${acid("/shell [cmd]")} drop into $SHELL (exit → back to the pit); also ${acid("!cmd")}`, ` ${acid("/prd [idea]")} publish a numbered PRD (OpenPRD), or list them with no arg`, - ` ${acid("/run ")} run a moshscript program`, + ` ${acid("/run ")} run a moshscript [--max N] [--dry-run]`, ` ${acid("/help")} this`, ` ${acid("/quit")} leave the pit (or Ctrl-D)`, "", @@ -138,7 +138,7 @@ function printHelp() { ash(" .mosh files are real JavaScript with the command vocabulary injected."), ash(" local verbs: ") + acid("code() mosh() notify() ask() say() sleep() stop() repeat()"), ash(" CLI verbs: ") + acid("agents() start() install() upgrade() mcp() skill() prd()"), - ash(" ") + acid("ugig() coinpay() c0mpute() pwd() run()"), + ash(" ") + acid("ugig() coinpay() c0mpute() pwd() run() shell()"), ash(" shebang: ") + acid("#!/usr/bin/env moshscript") + ash(" (chmod +x to self-run)"), "", ash(" raw shortcuts: type an engine or tool name by itself, e.g. ") + acid("claude") + ash(" or ") + acid("ugig"), @@ -261,14 +261,37 @@ function printPrds() { } } -async function runFile(file) { +async function runFile(args) { + // Parse /run options the same way the CLI does (R3: two entrypoints agree). + let max, dryRun = false, file = null; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === "--max" || a === "-n") { + const v = Number(args[++i]); + if (!Number.isInteger(v) || v < 1) { console.log(err(`--max needs a positive integer`)); return; } + max = v; + } else if (a.startsWith("--max=")) { + const v = Number(a.slice("--max=".length)); + if (!Number.isInteger(v) || v < 1) { console.log(err(`--max needs a positive integer`)); return; } + max = v; + } else if (a === "--dry-run") { + dryRun = true; + } else if (!file) { + file = a; + } + } + if (!file) { console.log(err("usage: /run [--max N] [--dry-run]")); return; } + let src; try { src = fs.readFileSync(file, "utf8"); } catch (e) { console.log(err(`can't read ${file}: ${e.message}`)); return; } console.log(hr()); + if (dryRun) console.log(info("dry run — narrating without executing")); let result = { iterations: 0 }; + const opts = { commands: moshVocabulary(), dryRun, out: (s) => console.log(s) }; + if (max !== undefined) opts.max = max; try { - result = await runScript(src, { commands: moshVocabulary(), out: (s) => console.log(s) }); + result = await runScript(src, opts); } catch (e) { console.log(err(String(e.message || e))); } console.log(hr()); console.log(info(`moshscript done — ${result.iterations} loop(s).`)); @@ -318,8 +341,7 @@ export async function tui() { if (cmd === "whoami") { await whoami(); continue; } if (cmd === "logout") { logout(); continue; } if (cmd === "run") { - if (!rest[0]) { console.log(err("usage: /run ")); continue; } - await runFile(rest[0]); + await runFile(rest); continue; } if (cmd === "shell" || cmd === "sh") { diff --git a/test/cli.test.mjs b/test/cli.test.mjs index 426652b..9215f77 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -89,6 +89,7 @@ test("CLI verbs are callable from moshscript in dry-run mode", async () => { assert.match(output, /would run: moshcode mcp install https:\/\/example\.com\/mcp/); }); +// ai() verb — headless, non-interactive engine invocation. test("aiExecArgs maps each engine to its headless invocation", () => { assert.deepEqual(aiExecArgs("claude", "hi"), ["-p", "hi"]); assert.deepEqual(aiExecArgs("codex", "hi"), ["exec", "hi"]); @@ -109,3 +110,78 @@ test("ai() in dry-run narrates the engine invocation and returns empty string", assert.equal(out, ""); assert.match(ctx.lines.join("\n"), /would run: codex exec/); }); + +// 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 () => { + // Run a real `moshcode` command that will fail (unknown engine). + // We use the actual moshcode binary via runMoshcode with a non-dry context. + const lines = []; + const ctx = { dryRun: false, out: (l) => lines.push(l) }; + // `moshcode agents nonexistent-engine-xyz` should exit non-zero. + const result = runMoshcode("agents", ["nonexistent-engine-xyz-99"], ctx); + assert.equal(result.ok, false, "non-zero exit should return ok: false"); + assert.ok(result.code !== 0, "should have a non-zero exit code"); + assert.equal(typeof result.code, "number"); +}); + +test("R8: a non-zero exit does NOT crash a moshscript — script continues", async () => { + const lines = []; + // The script calls a failing CLI verb then continues to the next line. + // Under the old throwing behavior, the second say() would never run. + const result = await runScript( + `const r = agents("nonexistent-engine-xyz-99"); + say("still alive after fail, ok=" + r.ok);`, + { commands: moshVocabulary(), out: (s) => lines.push(s) } + ); + const output = lines.join("\n"); + assert.match(output, /still alive after fail, ok=false/, + "script should continue after a non-zero CLI exit"); +}); + +// shell() verb — the system verb for arbitrary shell commands. +test("shell() is in the vocabulary", () => { + assert.ok(moshVocabulary().has("shell"), "expected shell() in the vocabulary"); +}); + +test("shell() in dry-run narrates the command without running it", () => { + const ctx = dryCtx(); + const cmd = moshVocabulary().get("shell"); + const result = cmd.run(ctx, "echo hello"); + assert.equal(result.ok, true); + assert.equal(result.dryRun, true); + assert.match(ctx.lines.join("\n"), /would run:.*echo hello/); +}); + +test("shell() throws when called without arguments", () => { + const ctx = dryCtx(); + const cmd = moshVocabulary().get("shell"); + assert.throws(() => cmd.run(ctx), /shell\(\) requires a command string/); +}); + +test("shell() runs a real command and returns { ok, code }", () => { + const lines = []; + const ctx = { dryRun: false, out: (l) => lines.push(l) }; + const cmd = moshVocabulary().get("shell"); + const result = cmd.run(ctx, "true"); + assert.equal(result.ok, true); + assert.equal(result.code, 0); +}); + +test("shell() returns { ok: false } on non-zero exit without throwing", () => { + const lines = []; + const ctx = { dryRun: false, out: (l) => lines.push(l) }; + const cmd = moshVocabulary().get("shell"); + const result = cmd.run(ctx, "false"); + assert.equal(result.ok, false); + assert.ok(result.code !== 0); +}); + +test("shell() is callable from moshscript and the script continues on failure", async () => { + const lines = []; + await runScript( + `const r = shell("false"); say("continued, ok=" + r.ok);`, + { commands: moshVocabulary(), out: (s) => lines.push(s) } + ); + assert.match(lines.join("\n"), /continued, ok=false/); +});