Skip to content

Commit f43c981

Browse files
authored
feat(moshscript): R8 error convention, shell() verb, TUI /run options (#19)
- R8 [P0]: CLI verbs return { ok: false, code } on non-zero exit instead of throwing, so scripts can branch on outcomes without try/catch. Only truly fatal spawn errors (ENOENT) still throw. - R6: Add shell(cmd) verb — runs $SHELL -c <cmd> (blocking, spawnSync), returns { ok, code }. Honors --dry-run. - R3: TUI /run now accepts --max N and --dry-run flags, matching the CLI entrypoint. Both share DEFAULT_MAX=3. - Tests: 8 new tests covering R8 error convention, shell() in dry-run, shell() with real commands, and shell() from moshscript. - README: document shell() verb, error handling convention.
1 parent 50cc920 commit f43c981

5 files changed

Lines changed: 169 additions & 14 deletions

File tree

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ chmod +x deploy.mosh
197197
| `ask(prompt)` | blocking gate — waits for human reply at moshcode.sh |
198198
| `say("…")` | print a line |
199199
| `sleep(ms)` | pause for N milliseconds (blocking) |
200+
| `shell(cmd)` | run a shell command (blocking, `$SHELL -c`); returns `{ ok, code }` |
200201
| `stop()` | end the loop (`alive = false`) |
201202
| `repeat()` | back to the top of the loop |
202203

@@ -238,6 +239,25 @@ const task = await ask("what should I work on next?");
238239
say(`got it: ${task}`);
239240
```
240241

242+
### Error handling
243+
244+
CLI verbs and `shell()` return `{ ok, code }` instead of throwing on non-zero
245+
exits, so scripts can branch on outcomes without `try/catch`:
246+
247+
```js
248+
const r = install("claude");
249+
if (!r.ok) {
250+
say(`install failed (exit ${r.code}), trying fallback…`);
251+
install("codex");
252+
}
253+
254+
const test = shell("npm test");
255+
if (!test.ok) notify("tests failed!");
256+
```
257+
258+
Only truly fatal errors (e.g. `moshcode` binary not found) throw. This keeps
259+
`while (alive)` loops resilient — a single failing verb doesn't crash the script.
260+
241261
### Dry run
242262

243263
`--dry-run` narrates every action without executing it — no engine spawns, no

src/cli.mjs

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,14 @@ import { ENGINES, aiExecArgs, pickAiEngine } from "./engines.mjs";
2323
// self-referential and doesn't depend on `moshcode` being on PATH.
2424
const MOSHCODE_BIN = fileURLToPath(new URL("../bin/moshcode.mjs", import.meta.url));
2525

26-
/** Run `moshcode <cmd> ...args`, blocking until it exits. Returns { ok, code }. */
26+
/**
27+
* Run `moshcode <cmd> ...args`, blocking until it exits.
28+
*
29+
* Returns { ok, code } — always. A non-zero exit returns { ok: false, code }
30+
* so scripts can branch on outcomes (`if (!install("foo").ok) …`) without a
31+
* try/catch. Only truly fatal errors (spawn failures like ENOENT) throw.
32+
* This is the R8 convention from PRD 0004.
33+
*/
2734
export function runMoshcode(cmd, args, ctx) {
2835
const argv = [cmd, ...args.map(String)];
2936
const printable = `moshcode ${argv.join(" ")}`.trimEnd();
@@ -35,13 +42,14 @@ export function runMoshcode(cmd, args, ctx) {
3542

3643
ctx.out(` ▶ ${printable}`);
3744
const res = spawnSync(process.execPath, [MOSHCODE_BIN, ...argv], { stdio: "inherit" });
38-
if (res.error) throw res.error;
39-
if (res.status !== 0) {
40-
// Fail loud for now — whether a non-zero passthrough should throw or return
41-
// a result is an open question in PRD 0004 (R8).
42-
throw new Error(`moshscript: ${cmd}() → moshcode exited with ${res.signal || res.status}`);
45+
if (res.error) throw res.error; // truly fatal: spawn itself failed (ENOENT etc.)
46+
47+
const code = res.status ?? 1;
48+
if (code !== 0) {
49+
ctx.out(` ✗ ${cmd}() exited ${res.signal || code}`);
50+
return { ok: false, code, signal: res.signal || null };
4351
}
44-
return { ok: true, code: res.status };
52+
return { ok: true, code: 0 };
4553
}
4654

4755
/** A vocabulary command mapping `name(...args)` → `moshcode name ...args`. */

src/commands.mjs

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
// 2. Local verbs — moshscript-only flavor/helpers with no CLI equivalent
1414
// (mosh, code, notify, say, sleep, stop, repeat). `mosh()` is the worked
1515
// example of the local command shape.
16-
import { spawn } from "node:child_process";
16+
import { spawn, spawnSync } from "node:child_process";
1717

1818
import { createRegistry } from "./registry.mjs";
1919
import { cliVerb, aiVerb } from "./cli.mjs";
@@ -156,6 +156,35 @@ const COMMANDS = [
156156
},
157157
},
158158

159+
{
160+
name: "shell",
161+
summary: "run a shell command (blocking, spawnSync $SHELL -c)",
162+
// The moshscript system verb for arbitrary shell commands. Blocking
163+
// (spawnSync + inherited stdio) so it runs inline in the no-`await` style,
164+
// and the child owns the terminal for interactive commands. Returns
165+
// { ok, code } so scripts can branch on the exit status:
166+
// const r = shell("npm test"); if (!r.ok) say("tests failed");
167+
run(ctx, ...args) {
168+
const cmd = args.join(" ");
169+
if (!cmd) throw new Error("moshscript: shell() requires a command string");
170+
if (ctx.dryRun) {
171+
ctx.out(` ▶ shell(${JSON.stringify(cmd)}) → would run: $SHELL -c ${JSON.stringify(cmd)}`);
172+
return { ok: true, dryRun: true };
173+
}
174+
const sh = process.env.SHELL
175+
|| (process.platform === "win32" ? (process.env.COMSPEC || "cmd.exe") : "/bin/sh");
176+
ctx.out(` ▶ shell: ${cmd}`);
177+
const res = spawnSync(sh, ["-c", cmd], { stdio: "inherit" });
178+
if (res.error) throw res.error;
179+
const code = res.status ?? 1;
180+
if (code !== 0) {
181+
ctx.out(` ✗ shell() exited ${res.signal || code}`);
182+
return { ok: false, code, signal: res.signal || null };
183+
}
184+
return { ok: true, code: 0 };
185+
},
186+
},
187+
159188
// CLI verbs — each is `moshcode <name> ...args`. This is the whole point:
160189
// scripting the CLI. Add a capability by adding a line here.
161190
//

src/tui.mjs

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -130,15 +130,15 @@ function printHelp() {
130130
` ${acid("/pwd")} show the current dir + git repo/branch/origin`,
131131
` ${acid("/shell [cmd]")} drop into $SHELL (exit → back to the pit); also ${acid("!cmd")}`,
132132
` ${acid("/prd [idea]")} publish a numbered PRD (OpenPRD), or list them with no arg`,
133-
` ${acid("/run <file.mosh>")} run a moshscript program`,
133+
` ${acid("/run <file.mosh>")} run a moshscript [--max N] [--dry-run]`,
134134
` ${acid("/help")} this`,
135135
` ${acid("/quit")} leave the pit (or Ctrl-D)`,
136136
"",
137137
bone(" moshscript") + ash(" — secretly all JS is legal"),
138138
ash(" .mosh files are real JavaScript with the command vocabulary injected."),
139139
ash(" local verbs: ") + acid("code() mosh() notify() ask() say() sleep() stop() repeat()"),
140140
ash(" CLI verbs: ") + acid("agents() start() install() upgrade() mcp() skill() prd()"),
141-
ash(" ") + acid("ugig() coinpay() c0mpute() pwd() run()"),
141+
ash(" ") + acid("ugig() coinpay() c0mpute() pwd() run() shell()"),
142142
ash(" shebang: ") + acid("#!/usr/bin/env moshscript") + ash(" (chmod +x to self-run)"),
143143
"",
144144
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() {
261261
}
262262
}
263263

264-
async function runFile(file) {
264+
async function runFile(args) {
265+
// Parse /run options the same way the CLI does (R3: two entrypoints agree).
266+
let max, dryRun = false, file = null;
267+
for (let i = 0; i < args.length; i++) {
268+
const a = args[i];
269+
if (a === "--max" || a === "-n") {
270+
const v = Number(args[++i]);
271+
if (!Number.isInteger(v) || v < 1) { console.log(err(`--max needs a positive integer`)); return; }
272+
max = v;
273+
} else if (a.startsWith("--max=")) {
274+
const v = Number(a.slice("--max=".length));
275+
if (!Number.isInteger(v) || v < 1) { console.log(err(`--max needs a positive integer`)); return; }
276+
max = v;
277+
} else if (a === "--dry-run") {
278+
dryRun = true;
279+
} else if (!file) {
280+
file = a;
281+
}
282+
}
283+
if (!file) { console.log(err("usage: /run <file.mosh> [--max N] [--dry-run]")); return; }
284+
265285
let src;
266286
try { src = fs.readFileSync(file, "utf8"); }
267287
catch (e) { console.log(err(`can't read ${file}: ${e.message}`)); return; }
268288
console.log(hr());
289+
if (dryRun) console.log(info("dry run — narrating without executing"));
269290
let result = { iterations: 0 };
291+
const opts = { commands: moshVocabulary(), dryRun, out: (s) => console.log(s) };
292+
if (max !== undefined) opts.max = max;
270293
try {
271-
result = await runScript(src, { commands: moshVocabulary(), out: (s) => console.log(s) });
294+
result = await runScript(src, opts);
272295
} catch (e) { console.log(err(String(e.message || e))); }
273296
console.log(hr());
274297
console.log(info(`moshscript done — ${result.iterations} loop(s).`));
@@ -319,8 +342,7 @@ export async function tui() {
319342
if (cmd === "whoami") { await whoami(); continue; }
320343
if (cmd === "logout") { logout(); continue; }
321344
if (cmd === "run") {
322-
if (!rest[0]) { console.log(err("usage: /run <file.mosh>")); continue; }
323-
await runFile(rest[0]);
345+
await runFile(rest);
324346
continue;
325347
}
326348
if (cmd === "shell" || cmd === "sh") {

test/cli.test.mjs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ test("CLI verbs are callable from moshscript in dry-run mode", async () => {
8989
assert.match(output, /would run: moshcode mcp install https:\/\/example\.com\/mcp/);
9090
});
9191

92+
// ai() verb — headless, non-interactive engine invocation.
9293
test("aiExecArgs maps each engine to its headless invocation", () => {
9394
assert.deepEqual(aiExecArgs("claude", "hi"), ["-p", "hi"]);
9495
assert.deepEqual(aiExecArgs("codex", "hi"), ["exec", "hi"]);
@@ -109,3 +110,78 @@ test("ai() in dry-run narrates the engine invocation and returns empty string",
109110
assert.equal(out, "");
110111
assert.match(ctx.lines.join("\n"), /would run: codex exec/);
111112
});
113+
114+
// R8: non-zero exits return { ok: false } instead of throwing, so scripts can
115+
// branch on outcomes without a try/catch.
116+
test("R8: a non-zero CLI exit returns { ok: false } instead of throwing", async () => {
117+
// Run a real `moshcode` command that will fail (unknown engine).
118+
// We use the actual moshcode binary via runMoshcode with a non-dry context.
119+
const lines = [];
120+
const ctx = { dryRun: false, out: (l) => lines.push(l) };
121+
// `moshcode agents nonexistent-engine-xyz` should exit non-zero.
122+
const result = runMoshcode("agents", ["nonexistent-engine-xyz-99"], ctx);
123+
assert.equal(result.ok, false, "non-zero exit should return ok: false");
124+
assert.ok(result.code !== 0, "should have a non-zero exit code");
125+
assert.equal(typeof result.code, "number");
126+
});
127+
128+
test("R8: a non-zero exit does NOT crash a moshscript — script continues", async () => {
129+
const lines = [];
130+
// The script calls a failing CLI verb then continues to the next line.
131+
// Under the old throwing behavior, the second say() would never run.
132+
const result = await runScript(
133+
`const r = agents("nonexistent-engine-xyz-99");
134+
say("still alive after fail, ok=" + r.ok);`,
135+
{ commands: moshVocabulary(), out: (s) => lines.push(s) }
136+
);
137+
const output = lines.join("\n");
138+
assert.match(output, /still alive after fail, ok=false/,
139+
"script should continue after a non-zero CLI exit");
140+
});
141+
142+
// shell() verb — the system verb for arbitrary shell commands.
143+
test("shell() is in the vocabulary", () => {
144+
assert.ok(moshVocabulary().has("shell"), "expected shell() in the vocabulary");
145+
});
146+
147+
test("shell() in dry-run narrates the command without running it", () => {
148+
const ctx = dryCtx();
149+
const cmd = moshVocabulary().get("shell");
150+
const result = cmd.run(ctx, "echo hello");
151+
assert.equal(result.ok, true);
152+
assert.equal(result.dryRun, true);
153+
assert.match(ctx.lines.join("\n"), /would run:.*echo hello/);
154+
});
155+
156+
test("shell() throws when called without arguments", () => {
157+
const ctx = dryCtx();
158+
const cmd = moshVocabulary().get("shell");
159+
assert.throws(() => cmd.run(ctx), /shell\(\) requires a command string/);
160+
});
161+
162+
test("shell() runs a real command and returns { ok, code }", () => {
163+
const lines = [];
164+
const ctx = { dryRun: false, out: (l) => lines.push(l) };
165+
const cmd = moshVocabulary().get("shell");
166+
const result = cmd.run(ctx, "true");
167+
assert.equal(result.ok, true);
168+
assert.equal(result.code, 0);
169+
});
170+
171+
test("shell() returns { ok: false } on non-zero exit without throwing", () => {
172+
const lines = [];
173+
const ctx = { dryRun: false, out: (l) => lines.push(l) };
174+
const cmd = moshVocabulary().get("shell");
175+
const result = cmd.run(ctx, "false");
176+
assert.equal(result.ok, false);
177+
assert.ok(result.code !== 0);
178+
});
179+
180+
test("shell() is callable from moshscript and the script continues on failure", async () => {
181+
const lines = [];
182+
await runScript(
183+
`const r = shell("false"); say("continued, ok=" + r.ok);`,
184+
{ commands: moshVocabulary(), out: (s) => lines.push(s) }
185+
);
186+
assert.match(lines.join("\n"), /continued, ok=false/);
187+
});

0 commit comments

Comments
 (0)