Skip to content

Commit 4f5a8f2

Browse files
committed
feat(moshscript): complete moshscript implementation (PRD 0004, closes #16)
- Add bin/moshscript.mjs shebang-ready executable (R14) - Add 'moshscript' to package.json bin map (R14) - Update install.sh to create/remove moshscript wrapper (R14) - Update help() with full vocabulary + JS-legal docs (R10) - Update TUI /help with moshscript vocab + shebang info (R10) - Rewrite README moshscript section: commands, dry-run, shebang, ask/notify (R10) - Add 17 unit tests for verb→argv dry-run mapping (R12) - Update PRD 0004 status to Accepted
1 parent bddd24e commit 4f5a8f2

8 files changed

Lines changed: 246 additions & 30 deletions

File tree

README.md

Lines changed: 117 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -127,9 +127,12 @@ In the TUI shell it's `/prd [idea]`.
127127

128128
## moshscript
129129

130-
A metal scripting toolkit. Paste dead-simple, readable scripts and run them.
130+
A metal scripting toolkit — **secretly all JS is legal**. The simple surface
131+
stays dead-simple, but a `.mosh` file is real JavaScript under the hood with the
132+
full moshcode command vocabulary injected as globals:
131133

132-
```
134+
```js
135+
// alive.mosh — the starter script (unchanged, still works)
133136
while (alive) {
134137
code();
135138
mosh();
@@ -138,45 +141,139 @@ while (alive) {
138141
} // no bugs, only features
139142
```
140143

141-
## Run
144+
The secret that it's all JS — no new syntax to learn:
145+
146+
```js
147+
// deploy-agents.mosh — real work, still reads like the toy
148+
const engines = ["claude", "codex"];
149+
for (const e of engines) {
150+
install(e); // → moshcode install <e>
151+
}
152+
mcp("install", "https://mcp.sentry.dev/mcp"); // fan out across engines
153+
say(`ready to mosh with ${engines.length} engines`);
154+
agents("claude"); // drop into an autonomous session
155+
```
156+
157+
### Run
142158

143159
```sh
144-
moshcode run examples/alive.mosh # run a script
145-
moshcode run - < script.mosh # or pipe/paste from stdin
146-
moshcode run --max 5 # bound the while loop (default 3)
147-
echo 'say("hi"); notify();' | moshcode run -
148-
moshcode commands # list built-in commands
149-
moshcode help
160+
moshcode run examples/alive.mosh # run a script
161+
moshcode run deploy.mosh --dry-run # narrate without executing
162+
moshcode run alive.mosh --max 5 # bound the while loop (default 3)
163+
moshcode run deploy.mosh staging --fast # extra args reach the script as argv
164+
moshcode run - < script.mosh # pipe/paste from stdin
165+
moshcode commands # list the full vocabulary
150166
```
151167

152168
No install/build step — it's plain ESM. `node bin/moshcode.mjs …` works too.
153169

154-
## moshscript
170+
### Shebang — self-running scripts
171+
172+
`.mosh` files support shebang lines, so `chmod +x` makes them run like shell
173+
scripts. The `moshscript` executable is installed alongside `moshcode`:
174+
175+
```js
176+
#!/usr/bin/env moshscript
177+
// deploy.mosh — chmod +x it and run it like any shell script
178+
install("claude");
179+
agents("claude");
180+
```
155181

156-
The whole language:
182+
```sh
183+
chmod +x deploy.mosh
184+
./deploy.mosh # shebang → moshscript → moshcode run
185+
./deploy.mosh --dry-run staging # args after the file reach the script
186+
```
187+
188+
### Commands
189+
190+
**Local verbs** (moshscript-only, in-process):
191+
192+
| verb | description |
193+
|---|---|
194+
| `code()` | compile features (no bugs) |
195+
| `mosh()` | open the pit + blast the moshcoding playlist |
196+
| `notify(msg)` | fire-and-forget ping + approval link on moshcode.sh |
197+
| `ask(prompt)` | blocking gate — waits for human reply at moshcode.sh |
198+
| `say("…")` | print a line |
199+
| `sleep(ms)` | pause for N milliseconds (blocking) |
200+
| `stop()` | end the loop (`alive = false`) |
201+
| `repeat()` | back to the top of the loop |
202+
203+
**CLI verbs** (each shells out to `moshcode <name> ...args`):
204+
205+
| verb | description |
206+
|---|---|
207+
| `agents(engine)` | launch an autonomous agent session |
208+
| `start(engine)` | raw-launch an engine |
209+
| `install(target)` | install an engine or workflow tool |
210+
| `upgrade(targets…)` | upgrade moshcode, engines, and tools |
211+
| `mcp(args…)` | register/fan out an MCP server |
212+
| `skill(args…)` | install a skill across engines |
213+
| `prd(idea)` | publish/author an OpenPRD doc |
214+
| `ugig(args…)` | drive the ugig workflow CLI |
215+
| `coinpay(args…)` | drive the coinpay workflow CLI |
216+
| `c0mpute(args…)` | drive the c0mpute workflow CLI |
217+
| `pwd()` | print the current repo/location |
218+
| `run(file)` | run another .mosh file (include/compose) |
219+
220+
**Specials** (injected globals, not commands):
221+
222+
| name | description |
223+
|---|---|
224+
| `alive` | `true` while the loop may continue; reads bounded by `--max` |
225+
| `argv` | positional args passed after the script file |
226+
| `env` | `process.env` — parameterize scripts from the environment |
227+
228+
### Human-in-the-loop
229+
230+
- `notify(msg)` — fire-and-forget. Pings the operator across configured channels
231+
and surfaces an approval link at `app.moshcode.sh/approve/:id`. Returns `{ id, url }`.
232+
- `ask(prompt)` — blocking gate. Same ping + link, then **blocks** until the
233+
operator opens the link, reads the context, types instructions, and submits.
234+
Resolves with their text (or `null` on timeout). Use with `await`:
157235

158-
- `while (alive) { … }` — loops the body while the `alive` flag is set (bounded by `--max`).
159-
- `name(args…);` — call a command. `//` comments are ignored.
236+
```js
237+
const task = await ask("what should I work on next?");
238+
say(`got it: ${task}`);
239+
```
160240

161-
Built-in commands: `code()` `mosh()` `notify()` `repeat()` `say("…")` `sleep(ms)` `stop()`.
241+
### Dry run
162242

163-
### notify()
243+
`--dry-run` narrates every action without executing it — no engine spawns, no
244+
installs, no network POSTs, no PRD writes:
164245

165-
Pings **moshcoding.com web notifications**, and — if `MOSHCODE_WEBHOOK_URL` is set —
166-
also POSTs to that webhook. Both are HMAC-signed (`X-Moshcode-Signature`) with
167-
`MOSHCODE_WEBHOOK_SECRET`.
246+
```
247+
$ moshcode run deploy.mosh --dry-run
248+
🎸 moshcode — running moshscript (dry run)
249+
250+
▶ install(claude) → would run: moshcode install claude
251+
▶ mcp(install, https://mcp.sentry.dev/mcp) → would run: moshcode mcp install …
252+
💬 ready to mosh with 2 engines
253+
▶ agents(claude) → would run: moshcode agents claude
254+
255+
✓ 0 loop(s) — no bugs, only features. 🤘
256+
```
168257

169258
### Add your own commands
170259

260+
The vocabulary is open for extension via the registry:
261+
171262
```js
172-
import { defaultCommands } from "moshcode/src/commands.mjs";
173-
const commands = { ...defaultCommands(), deploy: (ctx) => ctx.out("shipping…") };
263+
import { moshVocabulary } from "moshcode/src/commands.mjs";
264+
import { runScript } from "moshcode/src/runtime.mjs";
265+
266+
const commands = moshVocabulary();
267+
commands.register({ name: "deploy", summary: "ship it", run: (ctx) => ctx.out("shipping…") });
268+
await runScript(src, { commands });
174269
```
175270

176271
## Env
177272

178273
| var | default | purpose |
179274
|---|---|---|
180275
| `MOSHCODE_API` | `https://moshcoding.com` | web-notifications endpoint host |
276+
| `MOSHCODE_SITE` | `https://app.moshcode.sh` | approval URL base |
181277
| `MOSHCODE_WEBHOOK_URL` || optional extra webhook for `notify()` |
182278
| `MOSHCODE_WEBHOOK_SECRET` || signs notify() posts |
279+
| `MOSHCODE_PLAYLIST` | Spotify playlist URL | what `mosh()` blasts in the browser |

bin/moshcode.mjs

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,9 @@ async function launchEngine(key, engine, args, { agentMode = false } = {}) {
9393
}
9494

9595
function help() {
96+
const vocab = moshVocabulary().all();
97+
const local = vocab.filter((c) => !["run","agents","start","install","upgrade","mcp","skill","prd","ugig","coinpay","c0mpute","pwd"].includes(c.name));
98+
const cli = vocab.filter((c) => !local.includes(c));
9699
console.log(`moshcode — metal scripting toolkit 🤘
97100
98101
usage:
@@ -103,8 +106,10 @@ usage:
103106
moshcode <engine> [args…] raw launch shorthand (backward compatible)
104107
moshcode <tool> [args…] transparently invoke ugig, coinpay, or c0mpute
105108
moshcode run [file.mosh] [--max N] run a moshscript (stdin with '-', or the
106-
built-in loop if no file); --max bounds
107-
the while loop (default 3)
109+
[--dry-run] [args…] built-in loop if no file); --max bounds
110+
the while loop (default 3); --dry-run
111+
narrates without executing; extra args
112+
reach the script as argv
108113
moshcode mcp install <url> register an MCP server across every engine
109114
moshcode mcp add <name> <url|cmd> that supports it (claude/gemini/codex/opencode)
110115
moshcode skill install <git-url> install a skill across every engine that
@@ -131,14 +136,24 @@ isolated or trusted workspaces. use \`moshcode start <engine>\` for native defau
131136
tools (native CLI passthrough; each tool owns its auth and output):
132137
${toolList()}
133138
134-
moshscript looks like this:
139+
moshscript — secretly all JS is legal:
135140
${DEFAULT_SCRIPT}
136-
commands: code() mosh() notify() repeat() say("…") sleep(ms) stop()
137-
notify() pings moshcoding.com web notifications, and a webhook too if
138-
MOSHCODE_WEBHOOK_URL is set (signed with MOSHCODE_WEBHOOK_SECRET).
141+
a .mosh file is real JavaScript with the command vocabulary injected as globals.
142+
const, for, if, await, template strings — all just work. shebang lines
143+
(#!/usr/bin/env moshscript) are stripped automatically, so chmod +x works.
144+
145+
local commands (moshscript-only):
146+
${local.map((c) => ` ${(`${c.name}()`).padEnd(14)} ${c.summary}`).join("\n")}
147+
148+
CLI commands (each shells out to \`moshcode <name> ...args\`):
149+
${cli.map((c) => ` ${(`${c.name}()`).padEnd(14)} ${c.summary}`).join("\n")}
150+
151+
human-in-the-loop:
152+
notify(msg) fire-and-forget ping to moshcoding.com + webhook
153+
ask(prompt) blocking gate — waits for human reply at moshcode.sh
139154
140155
env: MOSHCODE_API (default https://moshcoding.com), MOSHCODE_WEBHOOK_URL,
141-
MOSHCODE_WEBHOOK_SECRET
156+
MOSHCODE_WEBHOOK_SECRET, MOSHCODE_PLAYLIST
142157
`);
143158
}
144159

bin/moshscript.mjs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
#!/usr/bin/env node
2+
// moshscript — thin alias for `moshcode run`, so `.mosh` files can use:
3+
//
4+
// #!/usr/bin/env moshscript
5+
//
6+
// as a shebang and run themselves like shell scripts:
7+
//
8+
// chmod +x deploy.mosh && ./deploy.mosh --dry-run staging
9+
//
10+
// All arguments are forwarded unchanged to `moshcode run`.
11+
import { spawn } from "node:child_process";
12+
import { fileURLToPath } from "node:url";
13+
14+
const BIN = fileURLToPath(new URL("moshcode.mjs", import.meta.url));
15+
const args = process.argv.slice(2); // everything after `moshscript`
16+
17+
const child = spawn(process.execPath, [BIN, "run", ...args], { stdio: "inherit" });
18+
child.on("error", (e) => { console.error(`moshscript: ${e.message}`); process.exit(1); });
19+
child.on("exit", (code, signal) => {
20+
if (signal) {
21+
try { process.kill(process.pid, signal); }
22+
catch { process.exitCode = 1; }
23+
return;
24+
}
25+
process.exitCode = code ?? 0;
26+
});

install.sh

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ INSTALL_URL="https://moshcoding.com/install.sh"
3232
MOSHCODE_HOME="${MOSHCODE_HOME:-$HOME/.moshcode}"
3333
MOSHCODE_BIN="${MOSHCODE_BIN:-$HOME/.local/bin}"
3434
WRAPPER="$MOSHCODE_BIN/moshcode"
35+
SCRIPT_WRAPPER="$MOSHCODE_BIN/moshscript"
3536

3637
# ---- pretty output (acid-lime, matching the CLI) --------------------------
3738
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
@@ -97,6 +98,16 @@ exec node "$MOSHCODE_HOME/bin/moshcode.mjs" "\$@"
9798
WRAP_EOF
9899
chmod +x "$WRAPPER"
99100
ok "wrapper at $WRAPPER"
101+
102+
# moshscript — thin alias for `moshcode run`, so .mosh files can use
103+
# #!/usr/bin/env moshscript as a shebang and run like shell scripts.
104+
cat > "$SCRIPT_WRAPPER" <<SCRIPT_EOF
105+
#!/bin/sh
106+
# moshscript wrapper — installed by $INSTALL_URL. Re-run the installer to update.
107+
exec node "$MOSHCODE_HOME/bin/moshcode.mjs" run "\$@"
108+
SCRIPT_EOF
109+
chmod +x "$SCRIPT_WRAPPER"
110+
ok "wrapper at $SCRIPT_WRAPPER"
100111
}
101112

102113
ensure_path() {
@@ -130,8 +141,9 @@ run_install() {
130141
run_remove() {
131142
info "removing moshcode"
132143
rm -f "$WRAPPER" 2>/dev/null || true
144+
rm -f "$SCRIPT_WRAPPER" 2>/dev/null || true
133145
rm -rf "$MOSHCODE_HOME" 2>/dev/null || true
134-
ok "removed $WRAPPER and $MOSHCODE_HOME. 🤘"
146+
ok "removed $WRAPPER, $SCRIPT_WRAPPER, and $MOSHCODE_HOME. 🤘"
135147
}
136148

137149
CMD="${1:-install}"

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
"type": "module",
55
"description": "moshcode — a metal wrapper for coding engines and native UGig/CoinPay workflow CLIs, with OpenPRD and moshscript",
66
"bin": {
7-
"moshcode": "./bin/moshcode.mjs"
7+
"moshcode": "./bin/moshcode.mjs",
8+
"moshscript": "./bin/moshscript.mjs"
89
},
910
"scripts": {
1011
"start": "node bin/moshcode.mjs",

prd/0004-moshscript-run-programmable-moshcode.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
openprd: "0.2"
33
id: "0004"
44
title: moshscript — a scriptable /run for driving all of moshcode programmatically
5-
status: Draft
5+
status: Accepted
66
authors:
77
- anthony@chovy.com
88
created: 2026-07-13

src/tui.mjs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,13 @@ function printHelp() {
133133
` ${acid("/help")} this`,
134134
` ${acid("/quit")} leave the pit (or Ctrl-D)`,
135135
"",
136+
bone(" moshscript") + ash(" — secretly all JS is legal"),
137+
ash(" .mosh files are real JavaScript with the command vocabulary injected."),
138+
ash(" local verbs: ") + acid("code() mosh() notify() ask() say() sleep() stop() repeat()"),
139+
ash(" CLI verbs: ") + acid("agents() start() install() upgrade() mcp() skill() prd()"),
140+
ash(" ") + acid("ugig() coinpay() c0mpute() pwd() run()"),
141+
ash(" shebang: ") + acid("#!/usr/bin/env moshscript") + ash(" (chmod +x to self-run)"),
142+
"",
136143
ash(" raw shortcuts: type an engine or tool name by itself, e.g. ") + acid("claude") + ash(" or ") + acid("ugig"),
137144
].join("\n"));
138145
}

test/cli.test.mjs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import test from "node:test";
33

44
import { runMoshcode, cliVerb } from "../src/cli.mjs";
55
import { moshVocabulary } from "../src/commands.mjs";
6+
import { runScript } from "../src/runtime.mjs";
7+
import { createRegistry } from "../src/registry.mjs";
68

79
function dryCtx() {
810
return { dryRun: true, lines: [], out(l) { this.lines.push(l); } };
@@ -29,3 +31,59 @@ test("the CLI capabilities are all registered as verbs", () => {
2931
assert.ok(reg.has(name), `expected ${name}() in the vocabulary`);
3032
}
3133
});
34+
35+
// R12: pure unit tests for every CLI verb → argv mapping (dry-run, no real spawns).
36+
// Each case verifies the verb narrates the correct `moshcode <cmd> ...args` argv.
37+
const VERB_ARGV_CASES = [
38+
{ verb: "agents", args: ["claude"], expect: /moshcode agents claude/ },
39+
{ verb: "agents", args: ["opencode", "--model", "gpt-4"], expect: /moshcode agents opencode --model gpt-4/ },
40+
{ verb: "start", args: ["codex", "--sandbox"], expect: /moshcode start codex --sandbox/ },
41+
{ verb: "install", args: ["claude"], expect: /moshcode install claude/ },
42+
{ verb: "install", args: ["ugig"], expect: /moshcode install ugig/ },
43+
{ verb: "upgrade", args: ["self"], expect: /moshcode upgrade self/ },
44+
{ verb: "upgrade", args: [], expect: /moshcode upgrade/ },
45+
{ verb: "mcp", args: ["install", "https://mcp.sentry.dev/mcp"], expect: /moshcode mcp install https:\/\/mcp\.sentry\.dev\/mcp/ },
46+
{ verb: "skill", args: ["install", "https://github.com/example/skill"], expect: /moshcode skill install/ },
47+
{ verb: "prd", args: ["my great idea"], expect: /moshcode prd my great idea/ },
48+
{ verb: "ugig", args: ["--json", "gigs", "list"], expect: /moshcode ugig --json gigs list/ },
49+
{ verb: "coinpay", args: ["wallet", "balance"], expect: /moshcode coinpay wallet balance/ },
50+
{ verb: "c0mpute", args: ["status"], expect: /moshcode c0mpute status/ },
51+
{ verb: "pwd", args: [], expect: /moshcode pwd/ },
52+
{ verb: "run", args: ["setup.mosh"], expect: /moshcode run setup\.mosh/ },
53+
];
54+
55+
for (const { verb, args, expect: pattern } of VERB_ARGV_CASES) {
56+
test(`verb→argv: ${verb}(${args.map(JSON.stringify).join(", ")}) narrates the correct argv`, () => {
57+
const ctx = dryCtx();
58+
const cmd = moshVocabulary().get(verb);
59+
assert.ok(cmd, `${verb}() must be in the vocabulary`);
60+
cmd.run(ctx, ...args);
61+
const output = ctx.lines.join("\n");
62+
assert.match(output, pattern, `expected ${verb}() to narrate ${pattern}, got: ${output}`);
63+
});
64+
}
65+
66+
// Verify CLI verbs return { ok, dryRun } under dry-run (no real spawn).
67+
test("all CLI verbs return { ok: true, dryRun: true } in dry-run mode", () => {
68+
const cliNames = ["agents", "start", "install", "upgrade", "mcp", "skill", "prd", "ugig", "coinpay", "c0mpute", "pwd", "run"];
69+
for (const name of cliNames) {
70+
const ctx = dryCtx();
71+
const cmd = moshVocabulary().get(name);
72+
const result = cmd.run(ctx, "test-arg");
73+
assert.equal(result.ok, true, `${name}() should return ok: true`);
74+
assert.equal(result.dryRun, true, `${name}() should return dryRun: true`);
75+
}
76+
});
77+
78+
// Verify CLI verbs are callable from a real moshscript (dry-run, end-to-end through the runtime).
79+
test("CLI verbs are callable from moshscript in dry-run mode", async () => {
80+
const lines = [];
81+
await runScript(
82+
`install("claude"); agents("claude"); mcp("install", "https://example.com/mcp");`,
83+
{ commands: moshVocabulary(), dryRun: true, out: (s) => lines.push(s) }
84+
);
85+
const output = lines.join("\n");
86+
assert.match(output, /would run: moshcode install claude/);
87+
assert.match(output, /would run: moshcode agents claude/);
88+
assert.match(output, /would run: moshcode mcp install https:\/\/example\.com\/mcp/);
89+
});

0 commit comments

Comments
 (0)