moshcode 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +580 -0
  3. package/bin/moshcode.mjs +674 -0
  4. package/bin/moshscript.mjs +29 -0
  5. package/examples/alive.mosh +6 -0
  6. package/examples/scripting-the-cli.mosh +21 -0
  7. package/examples/team-secrets.mosh +20 -0
  8. package/examples/templates/bun-caddy-sqlite/.env.example +14 -0
  9. package/examples/templates/bun-caddy-sqlite/Caddyfile +18 -0
  10. package/examples/templates/bun-caddy-sqlite/README.md +97 -0
  11. package/examples/templates/bun-caddy-sqlite/deploy/moshcode-dns.service +39 -0
  12. package/examples/templates/bun-caddy-sqlite/deploy/moshpit-service.service +38 -0
  13. package/examples/templates/bun-caddy-sqlite/package.json +15 -0
  14. package/examples/templates/bun-caddy-sqlite/src/db.ts +47 -0
  15. package/examples/templates/bun-caddy-sqlite/src/server.ts +44 -0
  16. package/examples/templates/bun-caddy-sqlite/template.json +10 -0
  17. package/examples/templates/caddy-proxy/Caddyfile +36 -0
  18. package/examples/templates/caddy-proxy/README.md +104 -0
  19. package/examples/templates/caddy-proxy/deploy/moshcode-dns.service +39 -0
  20. package/examples/templates/caddy-proxy/template.json +8 -0
  21. package/examples/templates/caddy-static/Caddyfile +16 -0
  22. package/examples/templates/caddy-static/README.md +90 -0
  23. package/examples/templates/caddy-static/deploy/moshcode-dns.service +39 -0
  24. package/examples/templates/caddy-static/site/index.html +11 -0
  25. package/examples/templates/caddy-static/template.json +8 -0
  26. package/install.sh +194 -0
  27. package/package.json +28 -0
  28. package/prd/0000-template.md +49 -0
  29. package/prd/0001-wrap-ugig-and-coinpay-clis.md +121 -0
  30. package/prd/0002-separate-agent-and-raw-engine-launches.md +113 -0
  31. package/prd/0003-cross-engine-mcp-and-skill-installation.md +165 -0
  32. package/prd/0004-moshscript-run-programmable-moshcode.md +344 -0
  33. package/prd/0005-hosted-moshpit-resolver.md +192 -0
  34. package/prd/0006-help.md +359 -0
  35. package/prd/0007-profullstack-site-init.md +1183 -0
  36. package/prd/README.md +26 -0
  37. package/src/ads.mjs +58 -0
  38. package/src/auth.mjs +193 -0
  39. package/src/cli-schema.mjs +533 -0
  40. package/src/cli.mjs +118 -0
  41. package/src/commands.mjs +259 -0
  42. package/src/completion.mjs +594 -0
  43. package/src/console.mjs +244 -0
  44. package/src/dns-system.mjs +404 -0
  45. package/src/dns.mjs +2872 -0
  46. package/src/doh-server.mjs +256 -0
  47. package/src/doh.mjs +218 -0
  48. package/src/engines.mjs +385 -0
  49. package/src/escalate.mjs +85 -0
  50. package/src/help.mjs +443 -0
  51. package/src/integrations.mjs +265 -0
  52. package/src/mcp-catalog.mjs +50 -0
  53. package/src/mcp.mjs +155 -0
  54. package/src/mirror.mjs +187 -0
  55. package/src/notify.mjs +86 -0
  56. package/src/open-url.mjs +34 -0
  57. package/src/parking-http.mjs +65 -0
  58. package/src/pins.mjs +190 -0
  59. package/src/pit-url.mjs +13 -0
  60. package/src/prd.mjs +341 -0
  61. package/src/pty.mjs +176 -0
  62. package/src/pwd.mjs +103 -0
  63. package/src/registry.mjs +37 -0
  64. package/src/release-install.mjs +191 -0
  65. package/src/runtime.mjs +161 -0
  66. package/src/selfupdate.mjs +215 -0
  67. package/src/serve.mjs +502 -0
  68. package/src/skills.mjs +93 -0
  69. package/src/tabs.mjs +144 -0
  70. package/src/templates.mjs +456 -0
  71. package/src/tools.mjs +231 -0
  72. package/src/trade.mjs +137 -0
  73. package/src/trust.mjs +712 -0
  74. package/src/tui.mjs +736 -0
  75. package/src/ui.mjs +49 -0
  76. package/src/uninstall.mjs +113 -0
  77. package/src/upgrade.mjs +217 -0
package/src/tui.mjs ADDED
@@ -0,0 +1,736 @@
1
+ // The moshcode shell — run `moshcode` with no args. A metal prompt that opens
2
+ // passthrough sessions on any engine via `/agents <engine>`, installs engines,
3
+ // and runs moshscript. Each session hands the whole terminal to the engine's own
4
+ // CLI and takes it back on exit.
5
+ import readline from "node:readline";
6
+ import { spawn } from "node:child_process";
7
+ import fs from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ import { ENGINES, agentLaunchArgs, resolveEngine, engineStatus, openSession } from "./engines.mjs";
11
+ import { TOOLS, resolveTool, toolStatus, openTool } from "./tools.mjs";
12
+ import { tradeArgs, tradeUsage } from "./trade.mjs";
13
+ import { runUpgrade } from "./upgrade.mjs";
14
+ import { locate, tilde } from "./pwd.mjs";
15
+ import { createPrd, listPrds, authoringPrompt } from "./prd.mjs";
16
+ import { loginAuto, whoami, logout } from "./auth.mjs";
17
+ import { createMirror, teeOutput } from "./mirror.mjs";
18
+ import { fetchMotdAd } from "./ads.mjs";
19
+ import { runScript } from "./runtime.mjs";
20
+ import { moshVocabulary } from "./commands.mjs";
21
+ import { mcpCommand, skillCommand } from "./integrations.mjs";
22
+ import { banner, hr, acid, ash, bone, dim, ok, err, warn, info, moshcodeVersion } from "./ui.mjs";
23
+ import { CORE_CLI_COMMAND_NAMES } from "./cli-schema.mjs";
24
+ import { findPitCommand, pitHelpModel, renderPitCommand, suggest, wantsHelp } from "./help.mjs";
25
+ import { openNewTab } from "./tabs.mjs";
26
+
27
+ const PROMPT = () => acid("mosh ") + dim("▸ ");
28
+
29
+ // Command history for ↑/↓ recall. We recreate the readline interface around
30
+ // every engine session/install (a passthrough child owns the terminal), which
31
+ // would otherwise reset readline's own history each time — so we keep one shared
32
+ // array (newest-first, the order readline maintains) and persist it between runs.
33
+ const HISTORY_FILE = path.join(os.homedir(), ".moshcode_history");
34
+ const HISTORY_MAX = 500;
35
+ const history = loadHistory();
36
+
37
+ function loadHistory() {
38
+ try {
39
+ return fs.readFileSync(HISTORY_FILE, "utf8").split("\n").filter(Boolean).slice(0, HISTORY_MAX);
40
+ } catch {
41
+ return [];
42
+ }
43
+ }
44
+ function saveHistory() {
45
+ try {
46
+ // Owner-only, like credentials.json: the pit records whatever was typed at
47
+ // the prompt, and that includes secrets by design — `/mcp install <url> -H
48
+ // "Authorization: Bearer …"`, `/secrets`, `/coinpay`, and `!` shell escapes.
49
+ fs.writeFileSync(HISTORY_FILE, history.slice(0, HISTORY_MAX).join("\n") + "\n", { mode: 0o600 });
50
+ // `mode` only applies when the file is created, so a history file that
51
+ // already exists keeps whatever the umask gave it (0644 on most systems).
52
+ // Tighten it every save so existing installs get fixed too.
53
+ fs.chmodSync(HISTORY_FILE, 0o600);
54
+ } catch {
55
+ /* best effort — history is a convenience, never fatal */
56
+ }
57
+ }
58
+
59
+ const mkrl = () => {
60
+ const rl = readline.createInterface({
61
+ input: process.stdin,
62
+ output: process.stdout,
63
+ // Enable line-editing/history only with a real TTY (arrow keys need raw
64
+ // mode); piped input has no raw mode and would throw.
65
+ terminal: Boolean(process.stdin.isTTY),
66
+ historySize: HISTORY_MAX,
67
+ removeHistoryDuplicates: true,
68
+ });
69
+ // Share the persistent array so ↑/↓ recalls earlier commands even after the
70
+ // interface was torn down and rebuilt around an engine session.
71
+ rl.history = history;
72
+ return rl;
73
+ };
74
+ const ask = (rl) => new Promise((res) => rl.question(PROMPT(), res));
75
+
76
+ // Small shell-like tokenizer for TUI commands. It keeps quoted values such as
77
+ // `/coinpay card pay --description "Fix the build"` as one native CLI argument
78
+ // without invoking a shell or performing expansions.
79
+ export function splitCommandLine(line) {
80
+ const parts = [];
81
+ let value = "", quote = null, escaped = false, started = false;
82
+ const input = String(line);
83
+ for (let i = 0; i < input.length; i++) {
84
+ const char = input[i];
85
+ if (escaped) {
86
+ value += char;
87
+ escaped = false;
88
+ started = true;
89
+ } else if (char === "\\" && quote !== "'") {
90
+ const next = input[i + 1];
91
+ if (quote === '"' && next !== '"' && next !== "\\") {
92
+ value += char;
93
+ started = true;
94
+ continue;
95
+ }
96
+ escaped = true;
97
+ started = true;
98
+ } else if (quote) {
99
+ if (char === quote) quote = null;
100
+ else value += char;
101
+ } else if (char === "'" || char === '"') {
102
+ quote = char;
103
+ started = true;
104
+ } else if (/\s/.test(char)) {
105
+ if (started) {
106
+ parts.push(value);
107
+ value = "";
108
+ started = false;
109
+ }
110
+ } else {
111
+ value += char;
112
+ started = true;
113
+ }
114
+ }
115
+ if (escaped) throw new Error("trailing escape");
116
+ if (quote) throw new Error(`unterminated ${quote} quote`);
117
+ if (started) parts.push(value);
118
+ return parts;
119
+ }
120
+
121
+ // Everything after the first word of a command line, exactly as typed. `/shell`
122
+ // hands this straight to `$SHELL -c`, the same way `!cmd` does: the shell does
123
+ // its own parsing, so re-joining the tokenized parts would strip the user's
124
+ // quotes and escapes and silently split `-m "two words"` into two arguments.
125
+ function commandRemainder(line) {
126
+ const firstWord = /^\s*\S+\s*/.exec(String(line));
127
+ return firstWord ? String(line).slice(firstWord[0].length).trim() : "";
128
+ }
129
+
130
+ function printEngines(json = false) {
131
+ if (json) {
132
+ console.log(JSON.stringify(engineStatus().map(({ key, desc, bin, installed }) => ({
133
+ name: key,
134
+ description: desc,
135
+ binary: bin,
136
+ installed,
137
+ })), null, 2));
138
+ return;
139
+ }
140
+ console.log(bone(" engines") + ash(" — autonomous ") + acid("/agents <name>") + ash(" · raw ") + acid("/start <name>"));
141
+ for (const e of engineStatus()) {
142
+ const dot = e.installed ? acid("●") : ash("○");
143
+ console.log(` ${dot} ${bone(e.key.padEnd(9))} ${ash(e.installed ? "installed" : "not installed — /install " + e.key)}`);
144
+ }
145
+ }
146
+
147
+ function printTools() {
148
+ // Named generically rather than listing every tool: the roster grows, and a
149
+ // hardcoded list here silently goes stale the moment TOOLS gains an entry.
150
+ console.log(bone(" tools") + ash(" — run one with ") + acid("/<name>") + ash(", e.g. ") + acid("/gh"));
151
+ for (const tool of toolStatus()) {
152
+ const dot = tool.installed ? acid("●") : ash("○");
153
+ console.log(` ${dot} ${bone(tool.key.padEnd(9))} ${ash(tool.installed ? "installed" : "not installed — /install " + tool.key)}`);
154
+ }
155
+ console.log(ash(" the primary dev toolchain runs through moshcode as a dev.profullstack.com user"));
156
+ console.log(ash(" → ") + acid("https://dev.profullstack.com/"));
157
+ }
158
+
159
+ /**
160
+ * The moshscript vocabulary, split the way the CLI's help splits it.
161
+ *
162
+ * A verb that is also a CLI command shells out; everything else is local to the
163
+ * script. Deriving the split from TOOLS + the command table means a tool added
164
+ * to the roster cannot be misfiled here as a local verb.
165
+ */
166
+ /**
167
+ * Group `items` into lines whose joined length stays under `width`.
168
+ *
169
+ * Works on the bare strings, before any colour is applied — measuring a string
170
+ * that already contains ANSI escapes counts the escapes as width and wraps far
171
+ * too early.
172
+ */
173
+ function wrapPlain(items, width) {
174
+ const lines = [[]];
175
+ let length = 0;
176
+ for (const item of items) {
177
+ const cost = item.length + 3; // " · "
178
+ if (length + cost > width && lines[lines.length - 1].length) {
179
+ lines.push([]);
180
+ length = 0;
181
+ }
182
+ lines[lines.length - 1].push(item);
183
+ length += cost;
184
+ }
185
+ return lines.filter((l) => l.length);
186
+ }
187
+
188
+ function vocabulary() {
189
+ const cliVerbs = new Set([...CORE_CLI_COMMAND_NAMES, ...Object.keys(TOOLS)]);
190
+ const all = moshVocabulary().all().map((c) => c.name);
191
+ return {
192
+ local: all.filter((n) => !cliVerbs.has(n)),
193
+ cli: all.filter((n) => cliVerbs.has(n)),
194
+ };
195
+ }
196
+
197
+ /**
198
+ * `/help`, rendered from the schema (PRD 0006 R12).
199
+ *
200
+ * Every tool name used to be typed out here — `/railway /gh /supabase …` —
201
+ * immediately below a printTools() whose own comment explains why hardcoding
202
+ * the roster goes stale. It had already: `/logout` has been dispatched forever
203
+ * and appeared nowhere, and nothing said that `/dns` and `/console` do not
204
+ * exist in the pit at all, so their absence read as an oversight rather than a
205
+ * fact.
206
+ *
207
+ * `topic` renders one command instead of the list.
208
+ */
209
+ function printHelp(topic = null) {
210
+ if (topic) {
211
+ const block = renderPitCommand(topic);
212
+ if (!block) {
213
+ const near = suggest(String(topic).replace(/^\//, ""),
214
+ [...Object.keys(ENGINES), ...Object.keys(TOOLS)]);
215
+ console.log(err(`no help for "${topic}"${near ? ` — did you mean /${near}?` : ""}`));
216
+ console.log(ash(" /help for the list"));
217
+ return;
218
+ }
219
+ console.log(block.split("\n").map((l) => ` ${l}`).join("\n"));
220
+ return;
221
+ }
222
+
223
+ const model = pitHelpModel({ engines: Object.keys(ENGINES), tools: Object.keys(TOOLS) });
224
+ const USAGE_COL = 30;
225
+ const rows = model.commands.map((c) => {
226
+ const alias = c.aliases.length ? ash(` (${c.aliases.map((a) => `/${a}`).join(" ")})`) : "";
227
+ // A usage longer than the column gets its description on the next line
228
+ // rather than shunting it off the right edge.
229
+ return c.usage.length > USAGE_COL
230
+ ? ` ${acid(c.usage)}\n ${" ".repeat(USAGE_COL)} ${ash(c.description)}${alias}`
231
+ : ` ${acid(c.usage.padEnd(USAGE_COL))} ${ash(c.description)}${alias}`;
232
+ });
233
+
234
+ /** A roster, wrapped so a growing list never runs off an 80-column terminal. */
235
+ const roster = (items, colour = acid) =>
236
+ wrapPlain(items, 62).map((line) => line.map((i) => colour(i)).join(ash(" · ")));
237
+
238
+ console.log([
239
+ bone(" commands") + ash(" — one in detail with ") + acid("/help <command>"),
240
+ ...rows,
241
+ "",
242
+ ...roster(model.engines.map((e) => `/${e}`)).map((l, i) =>
243
+ (i ? " " : bone(" engines") + " ") + l),
244
+ ...roster(model.tools.map((t) => `/${t}`)).map((l, i) =>
245
+ (i ? " " : bone(" tools") + " ") + l),
246
+ ash(" a bare engine or tool name runs it — flags after it are its own"),
247
+ "",
248
+ bone(" not in the pit") + ash(" — CLI-only:"),
249
+ ...roster(model.notInPit.map((c) => `moshcode ${c.name}`), ash).map((l) => ` ${l}`),
250
+ "",
251
+ bone(" moshscript") + ash(" — secretly all JS is legal"),
252
+ ash(" .mosh files are real JavaScript with the command vocabulary injected."),
253
+ // Derived, for the same reason the tool roster is: these were three
254
+ // hand-typed lines that the vocabulary had already outgrown.
255
+ ...wrapPlain(vocabulary().local.map((n) => `${n}()`), 62)
256
+ .map((line, i) => (i ? ash(" ") : ash(" local verbs: ")) + acid(line.join(" "))),
257
+ ...wrapPlain(vocabulary().cli.map((n) => `${n}()`), 62)
258
+ .map((line, i) => (i ? ash(" ") : ash(" CLI verbs: ")) + acid(line.join(" "))),
259
+ ash(" shebang: ") + acid("#!/usr/bin/env moshscript") + ash(" (chmod +x to self-run)"),
260
+ "",
261
+ ash(" raw shortcuts: type an engine or tool name by itself, e.g. ") + acid("claude") + ash(" or ") + acid("ugig"),
262
+ ].join("\n"));
263
+ }
264
+
265
+ function printPwd() {
266
+ const { cwd, home, git } = locate();
267
+ console.log(" " + bone(tilde(cwd, home)));
268
+ if (git) {
269
+ console.log(" " + ash("repo ") + acid(git.name) + (git.branch ? ash(" on ") + bone(git.branch) : ""));
270
+ console.log(" " + ash("root ") + tilde(git.root, home));
271
+ if (git.origin) console.log(" " + ash("origin ") + ash(git.origin));
272
+ } else {
273
+ console.log(" " + ash("(not a git repo)"));
274
+ }
275
+ }
276
+
277
+ async function upgradeAll(targets) {
278
+ console.log(info(`upgrading ${bone("moshcode")} + installed engines/tools — hand-off to each updater…`));
279
+ await runUpgrade(targets, { log: (s) => console.log(s), rule: () => console.log(hr()) });
280
+ }
281
+
282
+ // The live mirror for this pit, once /sessions is watching. Module-level so the
283
+ // engine/tool hand-offs can flag who owns the terminal without threading it
284
+ // through every call.
285
+ let activeMirror = null;
286
+
287
+ /**
288
+ * Where a child's output should be copied to: the live mirror, or nowhere.
289
+ *
290
+ * teeOutput only sees what *this* process prints; a child launched with
291
+ * `stdio: "inherit"` writes straight to the tty and is invisible to it. Handing
292
+ * this sink down lets the launcher capture the child through a pty instead (see
293
+ * src/pty.mjs). Returning undefined when nothing is watching is what keeps
294
+ * unmirrored sessions on the untouched `inherit` path.
295
+ */
296
+ const childSink = () => (activeMirror ? (chunk) => activeMirror?.write(chunk) : undefined);
297
+
298
+ async function openEngine(key, engine, args, { agentMode = false } = {}) {
299
+ if (!engine.installed && !args.length) {
300
+ console.log(info(`${key} isn't installed — try ${acid("/install " + key)} first.`));
301
+ }
302
+ if (agentMode) {
303
+ // An agent view is just a listing — plain info. Anything else means the
304
+ // engine's own approval prompts are gone, which is worth a warning.
305
+ const note = `agent mode: ${key} ${agentLaunchArgs(engine).join(" ")}`;
306
+ console.log(engine.agentsView
307
+ ? info(`${note} — opening its agent view.`)
308
+ : warn(`${note} — native approvals/permissions are bypassed or auto-approved.`));
309
+ }
310
+ console.log(info(`opening ${bone(key)}${agentMode ? " autonomously" : " raw"} — hand-off to its CLI, exit it to come back…`));
311
+ console.log(hr());
312
+ activeMirror?.setEngine(key);
313
+ const r = await openSession(engine, agentMode ? agentLaunchArgs(engine, args) : args, { onOutput: childSink() });
314
+ activeMirror?.setEngine(null);
315
+ console.log(hr());
316
+ if (!r.ok) {
317
+ console.log(r.error?.code === "ENOENT"
318
+ ? err(`${key} isn't on PATH (\`${engine.bin}\`). install it with /install ${key}`)
319
+ : err(`couldn't launch ${key}: ${r.error?.message || r.error}`));
320
+ } else {
321
+ console.log(info(`${key} exited${r.code != null ? ` (code ${r.code})` : ""}. back in the pit.`));
322
+ }
323
+ }
324
+
325
+ async function openWorkflowTool(key, tool, args) {
326
+ if (!tool.installed) {
327
+ console.log(info(`${key} isn't installed — try ${acid("/install " + key)} first.`));
328
+ }
329
+ console.log(info(`opening ${bone(key)} — native CLI owns the terminal until it exits…`));
330
+ console.log(hr());
331
+ const result = await openTool(tool, args, { onOutput: childSink() });
332
+ console.log(hr());
333
+ if (!result.ok) {
334
+ console.log(result.error?.code === "ENOENT"
335
+ ? err(`${key} isn't on PATH (\`${tool.bin}\`). install it with /install ${key}`)
336
+ : err(`couldn't launch ${key}: ${result.error?.message || result.error}`));
337
+ } else {
338
+ console.log(info(`${key} exited${result.code != null ? ` (code ${result.code})` : result.signal ? ` (${result.signal})` : ""}. back in the pit.`));
339
+ }
340
+ }
341
+
342
+ // Spawn the user's shell with the terminal fully handed over (stdio inherit),
343
+ // inheriting the current cwd + env. No args → an interactive shell; a raw
344
+ // command string → `$SHELL -c "<cmd>"` (one-off). Resolves { ok, code, signal }.
345
+ function runShell(rawCmd) {
346
+ return new Promise((resolve) => {
347
+ const shell = process.env.SHELL
348
+ || (process.platform === "win32" ? (process.env.COMSPEC || "cmd.exe") : "/bin/sh");
349
+ const args = rawCmd ? ["-c", rawCmd] : [];
350
+ let child;
351
+ try { child = spawn(shell, args, { stdio: "inherit" }); }
352
+ catch (e) { resolve({ ok: false, error: e }); return; }
353
+ child.on("error", (e) => resolve({ ok: false, error: e }));
354
+ child.on("exit", (code, signal) => resolve({ ok: true, code, signal }));
355
+ });
356
+ }
357
+
358
+ // vim `:sh` — drop into a shell and land back at the mosh prompt on exit, with
359
+ // the whole TUI session (history, cwd) intact. `rawCmd` runs a one-off instead.
360
+ async function openShell(rawCmd) {
361
+ const shellName = path.basename(process.env.SHELL || "sh");
362
+ console.log(info(rawCmd
363
+ ? `${bone(shellName)} ${ash("-c")} ${ash(rawCmd)}`
364
+ : `dropping to ${bone(shellName)} — ${ash("`exit` or Ctrl-D brings you back to the pit")}`));
365
+ console.log(hr());
366
+ const r = await runShell(rawCmd);
367
+ console.log(hr());
368
+ if (!r.ok) {
369
+ console.log(err(`couldn't start shell: ${r.error?.message || r.error}`));
370
+ } else {
371
+ console.log(info(`shell exited${r.code != null ? ` (code ${r.code})` : r.signal ? ` (${r.signal})` : ""}. back in the pit.`));
372
+ }
373
+ }
374
+
375
+ function installTarget(key) {
376
+ return new Promise((resolve) => {
377
+ // Own properties only — `/install constructor` must print the unknown-target
378
+ // line, not resolve to something off Object.prototype and crash the pit.
379
+ const target = (Object.hasOwn(ENGINES, key) && ENGINES[key]) || (Object.hasOwn(TOOLS, key) && TOOLS[key]);
380
+ if (!target) { console.log(err(`unknown engine or tool "${key}"`)); return resolve(); }
381
+ console.log(info(`installing ${key}: ${target.install.cmd} ${target.install.args.join(" ")}`));
382
+ console.log(hr());
383
+ const child = spawn(target.install.cmd, target.install.args, { stdio: "inherit" });
384
+ child.on("error", (e) => {
385
+ console.log(hr());
386
+ console.log(err(`install failed: ${e.message}`));
387
+ if (e.code === "ENOENT" && target.installHelp) console.log(info(target.installHelp));
388
+ resolve();
389
+ });
390
+ child.on("exit", (code) => { console.log(hr()); console.log(code === 0 ? ok(`${key} installed. 🤘`) : err(`install exited ${code}`)); resolve(); });
391
+ });
392
+ }
393
+
394
+ // Prefer claude (its stripEnv keeps the session clean), else the first installed
395
+ // engine. Returns [key, engine] or null when nothing is installed.
396
+ function pickEngine() {
397
+ const st = engineStatus();
398
+ const chosen = st.find((e) => e.key === "claude" && e.installed) || st.find((e) => e.installed);
399
+ return chosen ? [chosen.key, ENGINES[chosen.key]] : null;
400
+ }
401
+
402
+ function printPrds() {
403
+ const prds = listPrds();
404
+ if (!prds.length) { console.log(info(`no PRDs yet — ${acid("/prd <idea>")} to start one.`)); return; }
405
+ console.log(bone(" PRDs") + ash(" — under prd/ (OpenPRD)"));
406
+ for (const p of prds) {
407
+ console.log(` ${acid(p.id)} ${ash(p.status.padEnd(9))} ${bone(p.title)}`);
408
+ }
409
+ }
410
+
411
+ async function runFile(args) {
412
+ // Parse /run options the same way the CLI does (R3: two entrypoints agree).
413
+ let max, dryRun = false, file = null;
414
+ let optionsEnded = false;
415
+ const argv = [];
416
+ for (let i = 0; i < args.length; i++) {
417
+ const a = args[i];
418
+ if (!optionsEnded && a === "--") {
419
+ optionsEnded = true;
420
+ } else if (!optionsEnded && (a === "--max" || a === "-n")) {
421
+ const v = Number(args[++i]);
422
+ if (!Number.isSafeInteger(v) || v < 1) { console.log(err(`--max needs a positive integer`)); return; }
423
+ max = v;
424
+ } else if (!optionsEnded && a.startsWith("--max=")) {
425
+ const v = Number(a.slice("--max=".length));
426
+ if (!Number.isSafeInteger(v) || v < 1) { console.log(err(`--max needs a positive integer`)); return; }
427
+ max = v;
428
+ } else if (!optionsEnded && a === "--dry-run") {
429
+ dryRun = true;
430
+ } else if (!optionsEnded && a.startsWith("-") && !file) {
431
+ console.log(err(`unknown option ${a}`));
432
+ return;
433
+ } else if (!file) {
434
+ file = a;
435
+ } else {
436
+ argv.push(a);
437
+ }
438
+ }
439
+ if (!file) { console.log(err("usage: /run <file.mosh> [--max N] [--dry-run]")); return; }
440
+
441
+ let src;
442
+ try { src = fs.readFileSync(file, "utf8"); }
443
+ catch (e) { console.log(err(`can't read ${file}: ${e.message}`)); return; }
444
+ console.log(hr());
445
+ if (dryRun) console.log(info("dry run — narrating without executing"));
446
+ let result = { iterations: 0 };
447
+ const opts = { commands: moshVocabulary(), dryRun, argv, out: (s) => console.log(s) };
448
+ if (max !== undefined) opts.max = max;
449
+ try {
450
+ result = await runScript(src, opts);
451
+ } catch (e) { console.log(err(String(e.message || e))); }
452
+ console.log(hr());
453
+ console.log(info(`moshscript done — ${result.iterations} loop(s).`));
454
+ }
455
+
456
+ export async function tui() {
457
+ // In flight while the banner renders, so the MOTD costs no visible startup
458
+ // time. Skipped for piped runs — nobody is reading an ad in a test harness.
459
+ const motd = process.stdin.isTTY ? fetchMotdAd() : Promise.resolve(null);
460
+
461
+ console.log(banner());
462
+ console.log();
463
+ printEngines();
464
+ console.log();
465
+ printTools();
466
+ console.log("\n" + ash(" /help for commands · /new for a tab · /quit to leave") + "\n");
467
+
468
+ const ad = await motd;
469
+ if (ad) console.log(dim(ad) + "\n");
470
+
471
+ const { restoreTee, drainRemote, atPrompt } = await startMirror();
472
+
473
+ let rl = mkrl();
474
+ for (;;) {
475
+ let line;
476
+ // Arm the prompt first, THEN release any command waiting from the web:
477
+ // rl.write() only lands as input once readline is actually asking.
478
+ const answer = ask(rl);
479
+ atPrompt(rl);
480
+ drainRemote();
481
+ try { line = await answer; } catch { break; }
482
+ finally { atPrompt(null); }
483
+ if (line == null) break; // Ctrl-D
484
+ line = line.trim();
485
+ if (!line) continue;
486
+ saveHistory(); // readline just recorded this line into the shared history
487
+
488
+ // vim-style shell escape: `!` drops into $SHELL, `!<cmd>` runs one-off. We
489
+ // take the raw remainder (not the tokenized parts) so quoting is preserved.
490
+ if (line.startsWith("!")) {
491
+ const rawCmd = line.slice(1).trim();
492
+ rl.close();
493
+ await openShell(rawCmd || null);
494
+ rl = mkrl();
495
+ continue;
496
+ }
497
+
498
+ let parts;
499
+ try { parts = splitCommandLine(line); }
500
+ catch (error) { console.log(err(`can't parse command: ${error.message}`)); continue; }
501
+ const [raw, ...rest] = parts;
502
+ const cmd = raw.toLowerCase().replace(/^\//, "");
503
+
504
+ if (cmd === "quit" || cmd === "exit" || cmd === "q") break;
505
+ if (cmd === "help" || cmd === "?" || cmd === "h") { printHelp(rest[0] || null); continue; }
506
+
507
+ // `/<command> --help` answers here, before the command runs (PRD 0006 R1,
508
+ // R12) — the same rule the CLI follows, so `/prd --help` cannot publish a
509
+ // PRD any more than `moshcode prd --help` can. Only for the pit's own
510
+ // verbs: after an engine or tool name the flag is theirs, so `/gh --help`
511
+ // still reaches gh.
512
+ if (findPitCommand(cmd) && wantsHelp(rest, { stopAt: cmd === "run" })) {
513
+ printHelp(cmd);
514
+ continue;
515
+ }
516
+ if (cmd === "new") {
517
+ if (rest.length) { console.log(err("usage: /new")); continue; }
518
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
519
+ console.log(err("/new needs an interactive terminal"));
520
+ continue;
521
+ }
522
+ rl.close();
523
+ console.log(info(process.env.TMUX
524
+ ? "opening a new mosh tab — switch with your tmux window keys…"
525
+ : "opening a two-tab mosh workspace — switch with Ctrl-b n/p or Ctrl-b <number>…"));
526
+ const result = await openNewTab();
527
+ if (!result.ok) {
528
+ console.log(err(`can't open a tab: ${result.error?.message || result.error}`));
529
+ console.log(ash(" /new uses tmux so every provider CLI still owns a real terminal"));
530
+ }
531
+ rl = mkrl();
532
+ continue;
533
+ }
534
+ if (cmd === "pwd" || cmd === "where") { printPwd(); continue; }
535
+ if (cmd === "login") {
536
+ const device = rest.includes("--device") || rest.includes("device") || rest.includes("-d");
537
+ const browser = rest.includes("--browser") || rest.includes("browser") || rest.includes("-b");
538
+ try { const { email } = await loginAuto({ device, browser }); console.log(ok(`logged in${email ? ` as ${email}` : ""} 🤘`)); }
539
+ catch (e) { console.log(err(String(e.message || e))); }
540
+ continue;
541
+ }
542
+ if (cmd === "whoami") { await whoami(); continue; }
543
+ if (cmd === "logout") { logout(); continue; }
544
+ if (cmd === "run") {
545
+ await runFile(rest);
546
+ continue;
547
+ }
548
+ if (cmd === "shell" || cmd === "sh") {
549
+ const rawCmd = commandRemainder(line);
550
+ rl.close();
551
+ await openShell(rawCmd || null);
552
+ rl = mkrl();
553
+ continue;
554
+ }
555
+ if (cmd === "install") {
556
+ if (!rest[0]) { console.log(err("usage: /install <engine|tool>")); continue; }
557
+ rl.close();
558
+ await installTarget(rest[0].toLowerCase());
559
+ rl = mkrl();
560
+ continue;
561
+ }
562
+ if (cmd === "upgrade" || cmd === "update") {
563
+ rl.close();
564
+ await upgradeAll(rest.map((r) => r.toLowerCase()));
565
+ rl = mkrl();
566
+ continue;
567
+ }
568
+ if (cmd === "prd") {
569
+ if (!rest.length) { printPrds(); continue; }
570
+ const idea = rest.join(" ");
571
+ // createPrd writes prd/ under the cwd, so it can throw for reasons that have
572
+ // nothing to do with the session: prd/ already taken by a regular file, a
573
+ // read-only checkout, a full disk. Report it and go back to the prompt like
574
+ // every sibling command does — an unguarded throw here escapes the loop and
575
+ // takes the whole REPL down, losing the session over one bad cwd.
576
+ let created;
577
+ try { created = createPrd(idea); }
578
+ catch (e) { console.log(err(`can't publish the PRD: ${String(e.message || e)}`)); continue; }
579
+ const { id, slug, path: file, existed, bootstrapped } = created;
580
+ if (bootstrapped) console.log(info(`bootstrapped ${bone("prd/")} — README + 0000-template.md`));
581
+ console.log(existed
582
+ ? info(`PRD ${bone(id)} exists — opening an engine to keep editing ${ash(file)}`)
583
+ : ok(`published ${bone(`prd/${id}-${slug}.md`)} ${ash("(committed — status: Draft)")}`));
584
+ const eng = pickEngine();
585
+ if (!eng) { console.log(info(`open an engine to fill it in — ${acid("/install claude")} then ${acid("/prd")} again.`)); continue; }
586
+ const [key, engine] = eng;
587
+ console.log(info(`handing ${bone(id)} to ${bone(key)} to author…`));
588
+ rl.close();
589
+ await openEngine(key, { ...engine, installed: true }, [authoringPrompt({ path: file, idea: existed ? "" : idea })]);
590
+ rl = mkrl();
591
+ continue;
592
+ }
593
+ if (cmd === "agents" || cmd === "agent" || cmd === "engines") {
594
+ if (!rest[0] || (rest.length === 1 && rest[0] === "--json")) {
595
+ printEngines(rest[0] === "--json");
596
+ continue;
597
+ }
598
+ const resolved = resolveEngine(rest[0]);
599
+ if (!resolved) { console.log(err(`unknown engine "${rest[0]}". try: ${Object.keys(ENGINES).join(", ")}`)); continue; }
600
+ const [key, engine] = resolved;
601
+ rl.close();
602
+ await openEngine(
603
+ key,
604
+ { ...engine, installed: engineStatus().find((e) => e.key === key)?.installed },
605
+ rest.slice(1),
606
+ { agentMode: true },
607
+ );
608
+ rl = mkrl();
609
+ continue;
610
+ }
611
+ if (cmd === "start") {
612
+ if (!rest[0]) { console.log(err("usage: /start <engine> [args…]")); continue; }
613
+ const resolved = resolveEngine(rest[0]);
614
+ if (!resolved) { console.log(err(`unknown engine "${rest[0]}". try: ${Object.keys(ENGINES).join(", ")}`)); continue; }
615
+ const [key, engine] = resolved;
616
+ rl.close();
617
+ await openEngine(key, { ...engine, installed: engineStatus().find((e) => e.key === key)?.installed }, rest.slice(1));
618
+ rl = mkrl();
619
+ continue;
620
+ }
621
+ if (cmd === "mcp") {
622
+ rl.close();
623
+ await mcpCommand(rest);
624
+ rl = mkrl();
625
+ continue;
626
+ }
627
+ if (cmd === "skill" || cmd === "skills") {
628
+ rl.close();
629
+ await skillCommand(rest);
630
+ rl = mkrl();
631
+ continue;
632
+ }
633
+ if (cmd === "tools") {
634
+ if (!rest[0]) { printTools(); continue; }
635
+ const resolved = resolveTool(rest[0]);
636
+ if (!resolved) { console.log(err(`unknown tool "${rest[0]}". try: ${Object.keys(TOOLS).join(", ")}`)); continue; }
637
+ const [key, tool] = resolved;
638
+ rl.close();
639
+ await openWorkflowTool(key, { ...tool, installed: toolStatus().find((entry) => entry.key === key)?.installed }, rest.slice(1));
640
+ rl = mkrl();
641
+ continue;
642
+ }
643
+ if (cmd === "trade") {
644
+ const translated = tradeArgs(rest);
645
+ if (translated.usage) { console.log(tradeUsage()); continue; }
646
+ if (translated.error) { console.log(err(translated.error)); continue; }
647
+ rl.close();
648
+ const tool = TOOLS.alpaca;
649
+ await openWorkflowTool("alpaca", {
650
+ ...tool,
651
+ installed: toolStatus().find((entry) => entry.key === "alpaca")?.installed,
652
+ }, translated.args);
653
+ rl = mkrl();
654
+ continue;
655
+ }
656
+ // Bare engine name → open it.
657
+ const resolved = resolveEngine(cmd);
658
+ if (resolved) {
659
+ const [key, engine] = resolved;
660
+ rl.close();
661
+ await openEngine(key, { ...engine, installed: engineStatus().find((e) => e.key === key)?.installed }, rest);
662
+ rl = mkrl();
663
+ continue;
664
+ }
665
+ // Bare workflow-tool name (including `/ugig` and `/coinpay`) → run it.
666
+ const resolvedTool = resolveTool(cmd);
667
+ if (resolvedTool) {
668
+ const [key, tool] = resolvedTool;
669
+ rl.close();
670
+ await openWorkflowTool(key, { ...tool, installed: toolStatus().find((entry) => entry.key === key)?.installed }, rest);
671
+ rl = mkrl();
672
+ continue;
673
+ }
674
+ console.log(err(`unknown command "${line}". /help for the list.`));
675
+ }
676
+
677
+ try { rl.close(); } catch { /* noop */ }
678
+ saveHistory();
679
+ console.log("\n" + ash("code hard, mosh harder. 🤘"));
680
+ await stopMirror(restoreTee);
681
+ }
682
+
683
+ /**
684
+ * Bring up the live mirror (app.moshcode.sh/sessions) for this pit: register
685
+ * the session, tee everything we print to it, and hold commands typed on the
686
+ * web until the prompt is ready for them.
687
+ *
688
+ * Entirely optional — not logged in, or the app unreachable, and the pit runs
689
+ * exactly as before.
690
+ */
691
+ async function startMirror() {
692
+ const noop = { restoreTee: null, drainRemote: () => {}, atPrompt: () => {} };
693
+ // Only mirror a real interactive pit. A piped or scripted run (tests, CI,
694
+ // `echo /quit | moshcode`) has no human to watch from a browser, and the
695
+ // long-poll would keep that process alive long after its input ran out.
696
+ if (!process.stdin.isTTY || process.env.MOSHCODE_NO_MIRROR) return noop;
697
+
698
+ let mirror;
699
+ try { mirror = createMirror({ version: moshcodeVersion() || "", cwd: process.cwd() }); }
700
+ catch { return noop; }
701
+
702
+ let started = false;
703
+ try { started = await mirror.start(); } catch { started = false; }
704
+ if (!started) return noop;
705
+
706
+ activeMirror = mirror;
707
+ const restoreTee = teeOutput((chunk) => mirror.write(chunk));
708
+
709
+ // Commands arrive whenever; the prompt is only ready between engine
710
+ // hand-offs. Queue them and replay in order once readline is asking.
711
+ const queue = [];
712
+ let promptRl = null;
713
+ const drainRemote = () => {
714
+ // Exactly one per prompt: readline resolves the pending question with the
715
+ // first line it sees, so writing a second here would be swallowed. The
716
+ // loop re-arms and drains the next one on its way round.
717
+ if (promptRl && queue.length) {
718
+ const body = queue.shift();
719
+ // Echo it so the mirror (and the person at the keyboard) can see that
720
+ // this line came from the web rather than the local keyboard.
721
+ console.log(ash(` ▸ (web) ${body}`));
722
+ promptRl.write(`${body}\n`);
723
+ }
724
+ };
725
+ mirror.onCommand((body) => { queue.push(body); drainRemote(); });
726
+
727
+ console.log(info(`mirroring this session → ${acid(mirror.url)}`));
728
+ return { restoreTee, drainRemote, atPrompt: (rl) => { promptRl = rl; } };
729
+ }
730
+
731
+ async function stopMirror(restoreTee) {
732
+ const mirror = activeMirror;
733
+ activeMirror = null;
734
+ try { restoreTee?.(); } catch { /* noop */ }
735
+ try { await mirror?.stop(); } catch { /* best effort */ }
736
+ }