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
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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
Expand Down
22 changes: 15 additions & 7 deletions src/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <cmd> ...args`, blocking until it exits. Returns { ok, code }. */
/**
* Run `moshcode <cmd> ...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();
Expand All @@ -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`. */
Expand Down
31 changes: 30 additions & 1 deletion src/commands.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 <name> ...args`. This is the whole point:
// scripting the CLI. Add a capability by adding a line here.
//
Expand Down
34 changes: 28 additions & 6 deletions src/tui.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -130,15 +130,15 @@ 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 <file.mosh>")} run a moshscript program`,
` ${acid("/run <file.mosh>")} run a moshscript [--max N] [--dry-run]`,
` ${acid("/help")} this`,
` ${acid("/quit")} leave the pit (or Ctrl-D)`,
"",
bone(" moshscript") + ash(" — secretly all JS is legal"),
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"),
Expand Down Expand Up @@ -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 <file.mosh> [--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).`));
Expand Down Expand Up @@ -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 <file.mosh>")); continue; }
await runFile(rest[0]);
await runFile(rest);
continue;
}
if (cmd === "shell" || cmd === "sh") {
Expand Down
76 changes: 76 additions & 0 deletions test/cli.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
Expand All @@ -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/);
});
Loading