diff --git a/README.md b/README.md index f38f768..58be990 100644 --- a/README.md +++ b/README.md @@ -127,9 +127,12 @@ In the TUI shell it's `/prd [idea]`. ## moshscript -A metal scripting toolkit. Paste dead-simple, readable scripts and run them. +A metal scripting toolkit — **secretly all JS is legal**. The simple surface +stays dead-simple, but a `.mosh` file is real JavaScript under the hood with the +full moshcode command vocabulary injected as globals: -``` +```js +// alive.mosh — the starter script (unchanged, still works) while (alive) { code(); mosh(); @@ -138,39 +141,131 @@ while (alive) { } // no bugs, only features ``` -## Run +The secret that it's all JS — no new syntax to learn: + +```js +// deploy-agents.mosh — real work, still reads like the toy +const engines = ["claude", "codex"]; +for (const e of engines) { + install(e); // → moshcode install +} +mcp("install", "https://mcp.sentry.dev/mcp"); // fan out across engines +say(`ready to mosh with ${engines.length} engines`); +agents("claude"); // drop into an autonomous session +``` + +### Run ```sh -moshcode run examples/alive.mosh # run a script -moshcode run - < script.mosh # or pipe/paste from stdin -moshcode run --max 5 # bound the while loop (default 3) -echo 'say("hi"); notify();' | moshcode run - -moshcode commands # list built-in commands -moshcode help +moshcode run examples/alive.mosh # run a script +moshcode run deploy.mosh --dry-run # narrate without executing +moshcode run alive.mosh --max 5 # bound the while loop (default 3) +moshcode run deploy.mosh staging --fast # extra args reach the script as argv +moshcode run - < script.mosh # pipe/paste from stdin +moshcode commands # list the full vocabulary ``` No install/build step — it's plain ESM. `node bin/moshcode.mjs …` works too. -## moshscript +### Shebang — self-running scripts + +`.mosh` files support shebang lines, so `chmod +x` makes them run like shell +scripts. The `moshscript` executable is installed alongside `moshcode`: + +```js +#!/usr/bin/env moshscript +// deploy.mosh — chmod +x it and run it like any shell script +install("claude"); +agents("claude"); +``` -The whole language: +```sh +chmod +x deploy.mosh +./deploy.mosh # shebang → moshscript → moshcode run +./deploy.mosh --dry-run staging # args after the file reach the script +``` + +### Commands + +**Local verbs** (moshscript-only, in-process): + +| verb | description | +|---|---| +| `code()` | compile features (no bugs) | +| `mosh()` | open the pit + blast the moshcoding playlist | +| `notify(msg)` | fire-and-forget ping + approval link on moshcode.sh | +| `ask(prompt)` | blocking gate — waits for human reply at moshcode.sh | +| `say("…")` | print a line | +| `sleep(ms)` | pause for N milliseconds (blocking) | +| `stop()` | end the loop (`alive = false`) | +| `repeat()` | back to the top of the loop | + +**CLI verbs** (each shells out to `moshcode ...args`): + +| verb | description | +|---|---| +| `agents(engine)` | launch an autonomous agent session | +| `start(engine)` | raw-launch an engine | +| `install(target)` | install an engine or workflow tool | +| `upgrade(targets…)` | upgrade moshcode, engines, and tools | +| `mcp(args…)` | register/fan out an MCP server | +| `skill(args…)` | install a skill across engines | +| `prd(idea)` | publish/author an OpenPRD doc | +| `ugig(args…)` | drive the ugig workflow CLI | +| `coinpay(args…)` | drive the coinpay workflow CLI | +| `c0mpute(args…)` | drive the c0mpute workflow CLI | +| `pwd()` | print the current repo/location | +| `run(file)` | run another .mosh file (include/compose) | + +**Specials** (injected globals, not commands): + +| name | description | +|---|---| +| `alive` | `true` while the loop may continue; reads bounded by `--max` | +| `argv` | positional args passed after the script file | +| `env` | `process.env` — parameterize scripts from the environment | + +### Human-in-the-loop + +- `notify(msg)` — fire-and-forget. Pings the operator across configured channels + and surfaces an approval link at `app.moshcode.sh/approve/:id`. Returns `{ id, url }`. +- `ask(prompt)` — blocking gate. Same ping + link, then **blocks** until the + operator opens the link, reads the context, types instructions, and submits. + Resolves with their text (or `null` on timeout). Use with `await`: -- `while (alive) { … }` — loops the body while the `alive` flag is set (bounded by `--max`). -- `name(args…);` — call a command. `//` comments are ignored. +```js +const task = await ask("what should I work on next?"); +say(`got it: ${task}`); +``` -Built-in commands: `code()` `mosh()` `notify()` `repeat()` `say("…")` `sleep(ms)` `stop()`. +### Dry run -### notify() +`--dry-run` narrates every action without executing it — no engine spawns, no +installs, no network POSTs, no PRD writes: -Pings **moshcoding.com web notifications**, and — if `MOSHCODE_WEBHOOK_URL` is set — -also POSTs to that webhook. Both are HMAC-signed (`X-Moshcode-Signature`) with -`MOSHCODE_WEBHOOK_SECRET`. +``` +$ moshcode run deploy.mosh --dry-run +🎸 moshcode — running moshscript (dry run) + + ▶ install(claude) → would run: moshcode install claude + ▶ mcp(install, https://mcp.sentry.dev/mcp) → would run: moshcode mcp install … + 💬 ready to mosh with 2 engines + ▶ agents(claude) → would run: moshcode agents claude + +✓ 0 loop(s) — no bugs, only features. 🤘 +``` ### Add your own commands +The vocabulary is open for extension via the registry: + ```js -import { defaultCommands } from "moshcode/src/commands.mjs"; -const commands = { ...defaultCommands(), deploy: (ctx) => ctx.out("shipping…") }; +import { moshVocabulary } from "moshcode/src/commands.mjs"; +import { runScript } from "moshcode/src/runtime.mjs"; + +const commands = moshVocabulary(); +commands.register({ name: "deploy", summary: "ship it", run: (ctx) => ctx.out("shipping…") }); +await runScript(src, { commands }); ``` ## Env @@ -178,5 +273,7 @@ const commands = { ...defaultCommands(), deploy: (ctx) => ctx.out("shipping…") | var | default | purpose | |---|---|---| | `MOSHCODE_API` | `https://moshcoding.com` | web-notifications endpoint host | +| `MOSHCODE_SITE` | `https://app.moshcode.sh` | approval URL base | | `MOSHCODE_WEBHOOK_URL` | — | optional extra webhook for `notify()` | | `MOSHCODE_WEBHOOK_SECRET` | — | signs notify() posts | +| `MOSHCODE_PLAYLIST` | Spotify playlist URL | what `mosh()` blasts in the browser | diff --git a/bin/moshcode.mjs b/bin/moshcode.mjs index 01f0c71..8076cff 100755 --- a/bin/moshcode.mjs +++ b/bin/moshcode.mjs @@ -93,6 +93,9 @@ async function launchEngine(key, engine, args, { agentMode = false } = {}) { } function help() { + const vocab = moshVocabulary().all(); + const local = vocab.filter((c) => !["run","agents","start","install","upgrade","mcp","skill","prd","ugig","coinpay","c0mpute","pwd"].includes(c.name)); + const cli = vocab.filter((c) => !local.includes(c)); console.log(`moshcode — metal scripting toolkit 🤘 usage: @@ -103,8 +106,10 @@ usage: moshcode [args…] raw launch shorthand (backward compatible) moshcode [args…] transparently invoke ugig, coinpay, or c0mpute moshcode run [file.mosh] [--max N] run a moshscript (stdin with '-', or the - built-in loop if no file); --max bounds - the while loop (default 3) + [--dry-run] [args…] built-in loop if no file); --max bounds + the while loop (default 3); --dry-run + narrates without executing; extra args + reach the script as argv moshcode mcp install register an MCP server across every engine moshcode mcp add that supports it (claude/gemini/codex/opencode) moshcode skill install install a skill across every engine that @@ -131,14 +136,24 @@ isolated or trusted workspaces. use \`moshcode start \` for native defau tools (native CLI passthrough; each tool owns its auth and output): ${toolList()} -moshscript looks like this: +moshscript — secretly all JS is legal: ${DEFAULT_SCRIPT} -commands: code() mosh() notify() repeat() say("…") sleep(ms) stop() -notify() pings moshcoding.com web notifications, and a webhook too if -MOSHCODE_WEBHOOK_URL is set (signed with MOSHCODE_WEBHOOK_SECRET). +a .mosh file is real JavaScript with the command vocabulary injected as globals. +const, for, if, await, template strings — all just work. shebang lines +(#!/usr/bin/env moshscript) are stripped automatically, so chmod +x works. + +local commands (moshscript-only): +${local.map((c) => ` ${(`${c.name}()`).padEnd(14)} ${c.summary}`).join("\n")} + +CLI commands (each shells out to \`moshcode ...args\`): +${cli.map((c) => ` ${(`${c.name}()`).padEnd(14)} ${c.summary}`).join("\n")} + +human-in-the-loop: + notify(msg) fire-and-forget ping to moshcoding.com + webhook + ask(prompt) blocking gate — waits for human reply at moshcode.sh env: MOSHCODE_API (default https://moshcoding.com), MOSHCODE_WEBHOOK_URL, - MOSHCODE_WEBHOOK_SECRET + MOSHCODE_WEBHOOK_SECRET, MOSHCODE_PLAYLIST `); } diff --git a/bin/moshscript.mjs b/bin/moshscript.mjs new file mode 100755 index 0000000..7db74c2 --- /dev/null +++ b/bin/moshscript.mjs @@ -0,0 +1,26 @@ +#!/usr/bin/env node +// moshscript — thin alias for `moshcode run`, so `.mosh` files can use: +// +// #!/usr/bin/env moshscript +// +// as a shebang and run themselves like shell scripts: +// +// chmod +x deploy.mosh && ./deploy.mosh --dry-run staging +// +// All arguments are forwarded unchanged to `moshcode run`. +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const BIN = fileURLToPath(new URL("moshcode.mjs", import.meta.url)); +const args = process.argv.slice(2); // everything after `moshscript` + +const child = spawn(process.execPath, [BIN, "run", ...args], { stdio: "inherit" }); +child.on("error", (e) => { console.error(`moshscript: ${e.message}`); process.exit(1); }); +child.on("exit", (code, signal) => { + if (signal) { + try { process.kill(process.pid, signal); } + catch { process.exitCode = 1; } + return; + } + process.exitCode = code ?? 0; +}); diff --git a/install.sh b/install.sh index fd8e4ae..d7c9ba2 100644 --- a/install.sh +++ b/install.sh @@ -32,6 +32,7 @@ INSTALL_URL="https://moshcoding.com/install.sh" MOSHCODE_HOME="${MOSHCODE_HOME:-$HOME/.moshcode}" MOSHCODE_BIN="${MOSHCODE_BIN:-$HOME/.local/bin}" WRAPPER="$MOSHCODE_BIN/moshcode" +SCRIPT_WRAPPER="$MOSHCODE_BIN/moshscript" # ---- pretty output (acid-lime, matching the CLI) -------------------------- if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then @@ -97,6 +98,16 @@ exec node "$MOSHCODE_HOME/bin/moshcode.mjs" "\$@" WRAP_EOF chmod +x "$WRAPPER" ok "wrapper at $WRAPPER" + + # moshscript — thin alias for `moshcode run`, so .mosh files can use + # #!/usr/bin/env moshscript as a shebang and run like shell scripts. + cat > "$SCRIPT_WRAPPER" </dev/null || true + rm -f "$SCRIPT_WRAPPER" 2>/dev/null || true rm -rf "$MOSHCODE_HOME" 2>/dev/null || true - ok "removed $WRAPPER and $MOSHCODE_HOME. 🤘" + ok "removed $WRAPPER, $SCRIPT_WRAPPER, and $MOSHCODE_HOME. 🤘" } CMD="${1:-install}" diff --git a/package.json b/package.json index 54c3da5..7f6a4ef 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "type": "module", "description": "moshcode — a metal wrapper for coding engines and native UGig/CoinPay workflow CLIs, with OpenPRD and moshscript", "bin": { - "moshcode": "./bin/moshcode.mjs" + "moshcode": "./bin/moshcode.mjs", + "moshscript": "./bin/moshscript.mjs" }, "scripts": { "start": "node bin/moshcode.mjs", diff --git a/prd/0004-moshscript-run-programmable-moshcode.md b/prd/0004-moshscript-run-programmable-moshcode.md index 6fff9f0..edae02b 100644 --- a/prd/0004-moshscript-run-programmable-moshcode.md +++ b/prd/0004-moshscript-run-programmable-moshcode.md @@ -2,7 +2,7 @@ openprd: "0.2" id: "0004" title: moshscript — a scriptable /run for driving all of moshcode programmatically -status: Draft +status: Accepted authors: - anthony@chovy.com created: 2026-07-13 diff --git a/src/tui.mjs b/src/tui.mjs index 03548f3..c7e51d0 100644 --- a/src/tui.mjs +++ b/src/tui.mjs @@ -133,6 +133,13 @@ function printHelp() { ` ${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(" 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"), ].join("\n")); } diff --git a/test/cli.test.mjs b/test/cli.test.mjs index 3aa9737..08f0670 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -3,6 +3,8 @@ import test from "node:test"; import { runMoshcode, cliVerb } from "../src/cli.mjs"; import { moshVocabulary } from "../src/commands.mjs"; +import { runScript } from "../src/runtime.mjs"; +import { createRegistry } from "../src/registry.mjs"; function dryCtx() { return { dryRun: true, lines: [], out(l) { this.lines.push(l); } }; @@ -29,3 +31,59 @@ test("the CLI capabilities are all registered as verbs", () => { assert.ok(reg.has(name), `expected ${name}() in the vocabulary`); } }); + +// R12: pure unit tests for every CLI verb → argv mapping (dry-run, no real spawns). +// Each case verifies the verb narrates the correct `moshcode ...args` argv. +const VERB_ARGV_CASES = [ + { verb: "agents", args: ["claude"], expect: /moshcode agents claude/ }, + { verb: "agents", args: ["opencode", "--model", "gpt-4"], expect: /moshcode agents opencode --model gpt-4/ }, + { verb: "start", args: ["codex", "--sandbox"], expect: /moshcode start codex --sandbox/ }, + { verb: "install", args: ["claude"], expect: /moshcode install claude/ }, + { verb: "install", args: ["ugig"], expect: /moshcode install ugig/ }, + { verb: "upgrade", args: ["self"], expect: /moshcode upgrade self/ }, + { verb: "upgrade", args: [], expect: /moshcode upgrade/ }, + { verb: "mcp", args: ["install", "https://mcp.sentry.dev/mcp"], expect: /moshcode mcp install https:\/\/mcp\.sentry\.dev\/mcp/ }, + { verb: "skill", args: ["install", "https://github.com/example/skill"], expect: /moshcode skill install/ }, + { verb: "prd", args: ["my great idea"], expect: /moshcode prd my great idea/ }, + { verb: "ugig", args: ["--json", "gigs", "list"], expect: /moshcode ugig --json gigs list/ }, + { verb: "coinpay", args: ["wallet", "balance"], expect: /moshcode coinpay wallet balance/ }, + { verb: "c0mpute", args: ["status"], expect: /moshcode c0mpute status/ }, + { verb: "pwd", args: [], expect: /moshcode pwd/ }, + { verb: "run", args: ["setup.mosh"], expect: /moshcode run setup\.mosh/ }, +]; + +for (const { verb, args, expect: pattern } of VERB_ARGV_CASES) { + test(`verb→argv: ${verb}(${args.map(JSON.stringify).join(", ")}) narrates the correct argv`, () => { + const ctx = dryCtx(); + const cmd = moshVocabulary().get(verb); + assert.ok(cmd, `${verb}() must be in the vocabulary`); + cmd.run(ctx, ...args); + const output = ctx.lines.join("\n"); + assert.match(output, pattern, `expected ${verb}() to narrate ${pattern}, got: ${output}`); + }); +} + +// Verify CLI verbs return { ok, dryRun } under dry-run (no real spawn). +test("all CLI verbs return { ok: true, dryRun: true } in dry-run mode", () => { + const cliNames = ["agents", "start", "install", "upgrade", "mcp", "skill", "prd", "ugig", "coinpay", "c0mpute", "pwd", "run"]; + for (const name of cliNames) { + const ctx = dryCtx(); + const cmd = moshVocabulary().get(name); + const result = cmd.run(ctx, "test-arg"); + assert.equal(result.ok, true, `${name}() should return ok: true`); + assert.equal(result.dryRun, true, `${name}() should return dryRun: true`); + } +}); + +// Verify CLI verbs are callable from a real moshscript (dry-run, end-to-end through the runtime). +test("CLI verbs are callable from moshscript in dry-run mode", async () => { + const lines = []; + await runScript( + `install("claude"); agents("claude"); mcp("install", "https://example.com/mcp");`, + { commands: moshVocabulary(), dryRun: true, out: (s) => lines.push(s) } + ); + const output = lines.join("\n"); + assert.match(output, /would run: moshcode install claude/); + assert.match(output, /would run: moshcode agents claude/); + assert.match(output, /would run: moshcode mcp install https:\/\/example\.com\/mcp/); +});