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/prd.mjs ADDED
@@ -0,0 +1,341 @@
1
+ // OpenPRD — DIP-style numbered product requirements docs, published in-repo.
2
+ // moshcode is a conductor: `/prd` publishes a numbered proposal into the local
3
+ // repo per the OpenPRD standard from LogicSRC, then hands it to a coding engine
4
+ // to author. Layout mirrors a BIP/EIP/DIP process:
5
+ // prd/README.md index (maintained by this tool)
6
+ // prd/0000-template.md the template
7
+ // prd/NNNN-slug.md one numbered PRD per file (committed, NOT gitignored)
8
+ // Standard: https://github.com/profullstack/logicsrc/blob/master/docs/openprd.md
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import { execSync } from "node:child_process";
12
+
13
+ export const OPENPRD = {
14
+ version: "0.2",
15
+ dir: "prd",
16
+ standard: "https://github.com/profullstack/logicsrc/blob/master/docs/openprd.md",
17
+ };
18
+
19
+ const INDEX_START = "<!-- PRD-INDEX:START -->";
20
+ const INDEX_END = "<!-- PRD-INDEX:END -->";
21
+
22
+ /** kebab-case slug from an idea/title. */
23
+ export function slugify(text) {
24
+ const s = String(text || "")
25
+ .toLowerCase()
26
+ .replace(/[^a-z0-9]+/g, "-")
27
+ .replace(/^-+|-+$/g, "")
28
+ .split("-").slice(0, 8).join("-");
29
+ return s || "untitled";
30
+ }
31
+
32
+ function titleFrom(idea) {
33
+ const raw = String(idea || "").trim();
34
+ const t = raw.length > 80 ? raw.slice(0, 80).trim() + "…" : raw;
35
+ return t ? t.charAt(0).toUpperCase() + t.slice(1) : "Untitled PRD";
36
+ }
37
+
38
+ function today() {
39
+ return new Date().toISOString().slice(0, 10);
40
+ }
41
+
42
+ function yamlString(value) {
43
+ return JSON.stringify(String(value ?? ""));
44
+ }
45
+
46
+ function gitEmail(root) {
47
+ try {
48
+ return execSync("git config user.email", { cwd: root, stdio: ["ignore", "pipe", "ignore"] })
49
+ .toString().trim() || "you@example.com";
50
+ } catch { return "you@example.com"; }
51
+ }
52
+
53
+ export function prdDir(root = process.cwd()) {
54
+ return path.join(root, OPENPRD.dir);
55
+ }
56
+
57
+ /** The canonical OpenPRD template (prd/0000-template.md), mirroring the standard. */
58
+ export function templateFile() {
59
+ return `---
60
+ openprd: "${OPENPRD.version}"
61
+ id: "0000"
62
+ title: "Short imperative title — start with a verb if possible"
63
+ status: Draft
64
+ authors:
65
+ - you@example.com
66
+ created: 2026-01-01
67
+ updated: 2026-01-01
68
+ repo:
69
+ discussion:
70
+ implementation:
71
+ tags:
72
+ supersedes:
73
+ superseded-by:
74
+ ---
75
+
76
+ ## Problem
77
+
78
+ The user/business problem, and why it matters now.
79
+
80
+ ## Goals
81
+
82
+ What success looks like, as outcomes (not features).
83
+
84
+ ## Non-Goals
85
+
86
+ Explicitly out of scope, to bound the work.
87
+
88
+ ## Users
89
+
90
+ Who this is for; personas or segments.
91
+
92
+ ## Requirements
93
+
94
+ - R1 [P0] First required capability.
95
+ - R2 [P1] Next capability.
96
+
97
+ ## UX Notes
98
+
99
+ Flows, states, and constraints that shape the experience.
100
+
101
+ ## Success Metrics
102
+
103
+ How the goals will be measured.
104
+
105
+ ## Risks & Open Questions
106
+
107
+ - Known risk or decision still owed.
108
+ `;
109
+ }
110
+
111
+ // The seed marker is a hidden HTML comment, so a `-->` inside the idea would
112
+ // close it early and render the rest of the idea (plus the real closer) as
113
+ // visible text at the top of the Problem section. Neutralise the terminator.
114
+ function commentSafe(text) {
115
+ return String(text).replace(/--+>/g, (run) => `${run.slice(0, -1)}&gt;`);
116
+ }
117
+
118
+ /** A numbered PRD body (prd/NNNN-slug.md), seeded from an idea. */
119
+ export function renderPrd({ id, title, idea, author }) {
120
+ const seed = idea && idea !== title ? `\n<!-- seed: ${commentSafe(idea)} -->` : "";
121
+ return `---
122
+ openprd: "${OPENPRD.version}"
123
+ id: "${id}"
124
+ title: ${yamlString(title)}
125
+ status: Draft
126
+ authors:
127
+ - ${author}
128
+ created: ${today()}
129
+ updated: ${today()}
130
+ repo:
131
+ discussion:
132
+ implementation:
133
+ tags:
134
+ supersedes:
135
+ superseded-by:
136
+ ---
137
+
138
+ ## Problem
139
+ ${seed}
140
+ _Describe the user/business problem, and why it matters now._
141
+
142
+ ## Goals
143
+ _What success looks like, as outcomes (not features)._
144
+
145
+ ## Non-Goals
146
+ _Explicitly out of scope._
147
+
148
+ ## Users
149
+ _Who this is for; personas or segments._
150
+
151
+ ## Requirements
152
+ - R1 [P0] _First required capability._
153
+ - R2 [P1] _Next capability._
154
+
155
+ ## UX Notes
156
+ _Flows, states, and constraints that shape the experience._
157
+
158
+ ## Success Metrics
159
+ _How the goals will be measured._
160
+
161
+ ## Risks & Open Questions
162
+ - _Known risk or decision still owed._
163
+ `;
164
+ }
165
+
166
+ /** Static README preamble + the auto-maintained index markers. */
167
+ function readmeShell() {
168
+ return `# PRDs
169
+
170
+ Product requirements documents for this repo, following the
171
+ [OpenPRD](${OPENPRD.standard}) standard — a numbered, committed proposal
172
+ collection (like a BIP/EIP/DIP process).
173
+
174
+ Each PRD is one file: \`NNNN-slug.md\`. \`0000-template.md\` is the template.
175
+ Lifecycle: **Draft → Review → Accepted → Final** (or Rejected / Withdrawn /
176
+ Superseded). Status lives in each file's front-matter.
177
+
178
+ Start one with \`moshcode prd "<idea>"\` (TUI: \`/prd\`).
179
+
180
+ ## Index
181
+
182
+ ${INDEX_START}
183
+ ${INDEX_END}
184
+ `;
185
+ }
186
+
187
+ // Read a front-matter scalar back to its plain text. renderPrd writes the title
188
+ // as a quoted YAML string so metacharacters stay valid, so the quotes (and any
189
+ // escapes inside them) are syntax, not part of the title — strip them, or every
190
+ // listing and index row shows `"My title"` instead of `My title`.
191
+ function unquoteYaml(value) {
192
+ const raw = String(value).trim();
193
+ if (raw.length >= 2 && raw.startsWith('"') && raw.endsWith('"')) {
194
+ try { return JSON.parse(raw); } catch { return raw.slice(1, -1); }
195
+ }
196
+ if (raw.length >= 2 && raw.startsWith("'") && raw.endsWith("'")) {
197
+ return raw.slice(1, -1).replace(/''/g, "'");
198
+ }
199
+ return raw;
200
+ }
201
+
202
+ // The front matter is the block between the leading `---` and its closing
203
+ // `---`; everything after that is prose. Reading a fixed window of leading
204
+ // lines instead let a body line that merely starts with `title:`/`status:` —
205
+ // a YAML sample in a fenced code block, say — win, because the scan kept the
206
+ // last match it saw anywhere in the window. It also cut off longer front
207
+ // matter. Return the block itself, or nothing when the file has none.
208
+ function frontMatter(text) {
209
+ const lines = text.split(/\r?\n/);
210
+ if (lines[0]?.trim() !== "---") return [];
211
+ const end = lines.findIndex((l, i) => i > 0 && l.trim() === "---");
212
+ return end === -1 ? [] : lines.slice(1, end);
213
+ }
214
+
215
+ /** List numbered PRDs (NNNN-slug.md, excluding the 0000 template). */
216
+ export function listPrds(root = process.cwd()) {
217
+ const base = prdDir(root);
218
+ let entries = [];
219
+ try { entries = fs.readdirSync(base); } catch { return []; }
220
+ const out = [];
221
+ for (const name of entries) {
222
+ const m = name.match(/^(\d{4})-(.+)\.md$/);
223
+ if (!m || m[1] === "0000") continue;
224
+ const file = path.join(base, name);
225
+ let title = m[2], status = "?";
226
+ try {
227
+ const head = frontMatter(fs.readFileSync(file, "utf8"));
228
+ for (const l of head) {
229
+ const t = l.match(/^title:\s*(.+)$/); if (t) title = unquoteYaml(t[1]);
230
+ const s = l.match(/^status:\s*(.+)$/); if (s) status = unquoteYaml(s[1]);
231
+ }
232
+ // A file this cannot read is still a PRD, and its number is still taken.
233
+ // Dropping it contradicts what the rest of this loop does: a PRD whose
234
+ // front matter will not parse is deliberately kept on the slug/"?"
235
+ // fallbacks above rather than discarded. Worse, `nextId` takes its max
236
+ // from this list, so dropping one hands its number straight back out and
237
+ // two PRDs end up numbered the same — in a scheme where the number is the
238
+ // identity. A committed symlink whose target is not checked out is enough
239
+ // to trigger it: `readFileSync` throws ENOENT. Keep the entry on the same
240
+ // fallbacks; the id stays reserved and the index row keeps its link.
241
+ } catch { /* unreadable: keep it with the file-name fallbacks */ }
242
+ out.push({ id: m[1], slug: m[2], title, status, file: name, path: file });
243
+ }
244
+ return out.sort((a, b) => a.id.localeCompare(b.id));
245
+ }
246
+
247
+ /** Next zero-padded 4-digit id (max existing + 1, min 0001). */
248
+ export function nextId(root = process.cwd()) {
249
+ const ids = listPrds(root).map((p) => parseInt(p.id, 10)).filter(Number.isFinite);
250
+ const max = ids.length ? Math.max(...ids) : 0;
251
+ return String(max + 1).padStart(4, "0");
252
+ }
253
+
254
+ /** Rewrite the README index table from the current PRDs on disk. */
255
+ export function regenerateIndex(root = process.cwd()) {
256
+ const readme = path.join(prdDir(root), "README.md");
257
+ let body;
258
+ try { body = fs.readFileSync(readme, "utf8"); } catch { return false; }
259
+ const prds = listPrds(root);
260
+ // A `|` in a title would close its table cell early and shift every column
261
+ // after it, so escape it for the markdown table. Escape `\` first: escaping
262
+ // the pipe alone turns `a\|b` into `a\\|b`, which markdown reads as an escaped
263
+ // backslash followed by a *live* pipe — the very break this is preventing.
264
+ // A newline would end the row outright, so flatten it to a space.
265
+ const cell = (text) => String(text)
266
+ .replace(/\\/g, "\\\\")
267
+ .replace(/\|/g, "\\|")
268
+ .replace(/\r\n?|\n/g, " ");
269
+ // The same reasoning applies to the link cell, which `cell` cannot help with:
270
+ // a file name is a link *destination*, so it has to end up a valid URL, not
271
+ // just an escaped table cell. `listPrds` accepts any `NNNN-<anything>.md` on
272
+ // disk — the test suite treats "a doc dropped into prd/" as supported, and a
273
+ // doc exported from a writing tool arrives as `0002-Search Ranking.md` — so
274
+ // the name is not always the slug `createPrd` would have produced. A space
275
+ // ends the destination early and the link dies; a `|` closes the cell and
276
+ // shifts every column after it, dropping Status off the row entirely.
277
+ // Percent-encode it: `p.file` is a bare readdir entry, never a path, so
278
+ // encodeURIComponent is safe here, and `(`/`)` are added by hand because it
279
+ // leaves those alone and an unescaped `)` would close the link early.
280
+ const linkTarget = (file) => encodeURIComponent(file)
281
+ .replace(/\(/g, "%28")
282
+ .replace(/\)/g, "%29");
283
+ const rows = prds.length
284
+ ? ["| # | Title | Status |", "|---|---|---|",
285
+ ...prds.map((p) => `| [${p.id}](${linkTarget(p.file)}) | ${cell(p.title)} | ${cell(p.status)} |`)].join("\n")
286
+ : "_No PRDs yet._";
287
+ // Use a function replacement so `$`-sequences in a PRD title (e.g. `$&`, `$1`,
288
+ // `$\`` ) are inserted verbatim instead of being read as String.replace special
289
+ // patterns, which would otherwise splice the match back in and corrupt the index.
290
+ const block = `${INDEX_START}\n${rows}\n${INDEX_END}`;
291
+ const next = body.replace(
292
+ new RegExp(`${INDEX_START}[\\s\\S]*${INDEX_END}`),
293
+ () => block,
294
+ );
295
+ if (next === body) return false;
296
+ fs.writeFileSync(readme, next);
297
+ return true;
298
+ }
299
+
300
+ /** Create prd/README.md + prd/0000-template.md if missing. Returns true if it bootstrapped. */
301
+ export function ensureBootstrap(root = process.cwd()) {
302
+ const base = prdDir(root);
303
+ fs.mkdirSync(base, { recursive: true });
304
+ let did = false;
305
+ const tpl = path.join(base, "0000-template.md");
306
+ if (!fs.existsSync(tpl)) { fs.writeFileSync(tpl, templateFile()); did = true; }
307
+ const readme = path.join(base, "README.md");
308
+ if (!fs.existsSync(readme)) { fs.writeFileSync(readme, readmeShell()); did = true; }
309
+ return did;
310
+ }
311
+
312
+ /**
313
+ * Publish a numbered PRD into the local repo. Bootstraps prd/ on first use.
314
+ * Returns { id, slug, path, existed, bootstrapped }.
315
+ */
316
+ export function createPrd(idea, root = process.cwd()) {
317
+ const bootstrapped = ensureBootstrap(root);
318
+ const slug = slugify(idea);
319
+ const title = titleFrom(idea);
320
+ // Reuse an existing PRD if the same slug already has a number.
321
+ const existing = listPrds(root).find((p) => p.slug === slug);
322
+ const id = existing ? existing.id : nextId(root);
323
+ const file = path.join(prdDir(root), `${id}-${slug}.md`);
324
+ const existed = fs.existsSync(file);
325
+ if (!existed) fs.writeFileSync(file, renderPrd({ id, title, idea, author: gitEmail(root) }));
326
+ regenerateIndex(root);
327
+ return { id, slug, path: file, existed, bootstrapped };
328
+ }
329
+
330
+ /** Prompt handed to a coding engine to author the scaffolded PRD in place. */
331
+ export function authoringPrompt({ path: file, idea }) {
332
+ return [
333
+ `Author a product requirements document at ${file} following the OpenPRD standard`,
334
+ `(${OPENPRD.standard}).`,
335
+ idea ? `The idea: ${idea}.` : "",
336
+ `Fill every one of the 8 sections (Problem, Goals, Non-Goals, Users, Requirements,`,
337
+ `UX Notes, Success Metrics, Risks & Open Questions), replacing the placeholder text.`,
338
+ `Keep the YAML front-matter and its keys (leave status: Draft). Number requirements`,
339
+ `R1, R2, … each with a [P0]/[P1]/[P2] priority. Be concrete and specific to this codebase.`,
340
+ ].filter(Boolean).join(" ");
341
+ }
package/src/pty.mjs ADDED
@@ -0,0 +1,176 @@
1
+ // PTY capture for the session mirror.
2
+ //
3
+ // Children are launched with `stdio: "inherit"` so they own the real terminal —
4
+ // which is why an engine or tool feels native, and also why the mirror never
5
+ // saw a byte of their output: those writes go to the tty's file descriptors and
6
+ // never pass through this process (see src/mirror.mjs).
7
+ //
8
+ // To see them we have to be in the middle, but a plain pipe is not an option:
9
+ // every one of these programs checks isTTY and degrades (no colour, no prompts,
10
+ // no full-screen UI) the moment it is talking to a pipe. So we run the child
11
+ // under a real pseudo-terminal and read a copy of the stream from the side.
12
+ //
13
+ // node-pty would be the obvious tool and is deliberately not used: it is a
14
+ // native module, and moshcode installs by untarring a release and running node
15
+ // (see install.sh) — there is no compiler in that path. `script(1)` allocates
16
+ // the same pseudo-terminal using nothing but the base system.
17
+ //
18
+ // Capability detection is required, not optional: util-linux and BSD/macOS
19
+ // `script` disagree on both flag names and argument order, and anything we
20
+ // cannot positively identify falls back to today's plain `inherit`.
21
+ import { spawnSync } from "node:child_process";
22
+ import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
23
+ import { StringDecoder } from "node:string_decoder";
24
+
25
+ /**
26
+ * POSIX single-quote escaping, for argv that has to survive being flattened
27
+ * into the single command string util-linux `script -c` accepts. A bare
28
+ * interpolation here would let an argument like `it's` break the command, or
29
+ * worse, run something else.
30
+ */
31
+ export function shQuote(value) {
32
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
33
+ }
34
+
35
+ /**
36
+ * Which `script(1)` this machine has: "util-linux", "bsd", or null when there
37
+ * is none we can drive. util-linux answers `--version`; BSD's has no version
38
+ * flag and exits non-zero on it, so darwin is identified by platform.
39
+ */
40
+ export function scriptFlavor({ platform = process.platform, runner = spawnSync } = {}) {
41
+ let out = "";
42
+ try {
43
+ const r = runner("script", ["--version"], { encoding: "utf8" });
44
+ if (r?.error) return null;
45
+ out = `${r?.stdout || ""}${r?.stderr || ""}`;
46
+ } catch {
47
+ return null;
48
+ }
49
+ if (/util-linux/i.test(out)) return "util-linux";
50
+ // BSD script printed a usage error rather than a version — that is still a
51
+ // usable script, but only on darwin do we know the flag set for certain.
52
+ if (platform === "darwin") return "bsd";
53
+ return null;
54
+ }
55
+
56
+ /**
57
+ * The spawn spec that runs `cmd args…` under a pseudo-terminal while recording
58
+ * a copy of everything to `transcript`. Returns null for an unknown flavour.
59
+ *
60
+ * -q / -Q suppress script's own "Script started/done" banner, so the transcript
61
+ * holds the child's bytes and nothing else. -f / -F flush on every write, which
62
+ * is what makes this realtime rather than a post-mortem log. util-linux -e
63
+ * makes script exit with the child's status, which callers rely on.
64
+ */
65
+ export function ptySpec(cmd, args = [], transcript, flavor) {
66
+ if (!cmd || !transcript) return null;
67
+ if (flavor === "util-linux") {
68
+ const line = [cmd, ...args].map(shQuote).join(" ");
69
+ return { cmd: "script", args: ["-q", "-e", "-f", "-c", line, transcript] };
70
+ }
71
+ if (flavor === "bsd") {
72
+ // BSD takes the transcript first and then a real argv, so no quoting.
73
+ return { cmd: "script", args: ["-q", "-F", transcript, cmd, ...args] };
74
+ }
75
+ return null;
76
+ }
77
+
78
+ /**
79
+ * Follow a transcript as it is written, handing each new slice to `onChunk`.
80
+ *
81
+ * Polls the size rather than using fs.watch: watch is unreliable across
82
+ * platforms and filesystems for a file being appended to by another process,
83
+ * and the mirror already batches on a 150ms timer, so a short poll costs
84
+ * nothing in perceived latency. Returns a stop() that drains whatever landed
85
+ * after the last tick before closing — the tail of a session is usually the
86
+ * part you care about.
87
+ *
88
+ * Bytes are decoded through a StringDecoder rather than per-slice toString:
89
+ * a slice boundary lands wherever the poll happened to catch the file, which
90
+ * is regularly in the middle of a multi-byte character. Engines draw their
91
+ * full-screen UI out of box-drawing characters (three UTF-8 bytes each), so
92
+ * decoding each slice independently turns them into U+FFFD in the mirror. The
93
+ * decoder holds the incomplete tail back until the rest of it arrives.
94
+ */
95
+ export function followFile(file, onChunk, { intervalMs = 100 } = {}) {
96
+ let fd = null;
97
+ let offset = 0;
98
+ let stopped = false;
99
+ let decoder = new StringDecoder("utf8");
100
+
101
+ const readNew = () => {
102
+ try {
103
+ if (fd === null) {
104
+ if (!existsSync(file)) return;
105
+ fd = openSync(file, "r");
106
+ }
107
+ const { size } = statSync(file);
108
+ // A transcript only grows; a smaller size means it was rotated or
109
+ // replaced, so resync rather than read garbage from the middle. Any
110
+ // half-character held back belongs to the old file, so drop it too.
111
+ if (size < offset) {
112
+ offset = 0;
113
+ decoder = new StringDecoder("utf8");
114
+ }
115
+ while (offset < size) {
116
+ const buf = Buffer.allocUnsafe(Math.min(65536, size - offset));
117
+ const read = readSync(fd, buf, 0, buf.length, offset);
118
+ if (read <= 0) break;
119
+ offset += read;
120
+ const text = decoder.write(buf.subarray(0, read));
121
+ if (text) onChunk(text);
122
+ }
123
+ } catch {
124
+ /* the child owns this file; a transient read error is not our problem */
125
+ }
126
+ };
127
+
128
+ const timer = setInterval(readNew, intervalMs);
129
+ timer.unref?.(); // never hold the process open for the sake of the mirror
130
+
131
+ return function stop() {
132
+ if (stopped) return;
133
+ stopped = true;
134
+ clearInterval(timer);
135
+ readNew(); // final drain
136
+ // Nothing more is coming, so a character still held back really is
137
+ // truncated. Emit it rather than swallowing the last bytes of a session.
138
+ const tail = decoder.end();
139
+ if (tail) onChunk(tail);
140
+ if (fd !== null) {
141
+ try { closeSync(fd); } catch { /* already gone */ }
142
+ fd = null;
143
+ }
144
+ };
145
+ }
146
+
147
+ // `script -q` silences the "Script started/done" notices on the terminal but
148
+ // still writes them into the transcript, so without this the mirror opens every
149
+ // engine session with a line of script(1) bookkeeping — including the fully
150
+ // quoted command line — and closes it with an exit-code footer. Both are
151
+ // anchored (header at the very start, footer at the very end), so this never
152
+ // touches output that merely happens to contain the words.
153
+ const HEADER = /^Script started on [^\n]*\n/;
154
+ const FOOTER = /\r?\nScript done on [^\n]*\n?$/;
155
+
156
+ /**
157
+ * Remove script(1)'s own bookkeeping from a transcript slice. `first` marks the
158
+ * opening slice, the only place a header can legitimately appear.
159
+ */
160
+ export function stripScriptBanner(text, first = false) {
161
+ const withoutHeader = first ? String(text).replace(HEADER, "") : String(text);
162
+ return withoutHeader.replace(FOOTER, "");
163
+ }
164
+
165
+ /**
166
+ * Should we capture this launch? Only when someone is actually watching (a
167
+ * mirror sink is attached) and the box has a script(1) we understand. Users who
168
+ * are not mirroring keep the exact `inherit` path they have today, so the
169
+ * blast radius of this feature is limited to mirrored sessions.
170
+ * MOSHCODE_MIRROR_PTY=0 forces it off.
171
+ */
172
+ export function ptyEnabled(sink, flavor = scriptFlavor()) {
173
+ if (typeof sink !== "function") return false;
174
+ if (process.env.MOSHCODE_MIRROR_PTY === "0") return false;
175
+ return Boolean(flavor);
176
+ }
package/src/pwd.mjs ADDED
@@ -0,0 +1,103 @@
1
+ // `moshcode pwd` / `/pwd` — where am I? Shows the current directory and, when
2
+ // inside a git repo, the repo root, branch, and origin. Zero-dependency: we
3
+ // read the .git dir directly instead of spawning git (moshcode stays lean and
4
+ // works even when git isn't on PATH).
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+
8
+ // Resolve a `.git` entry (dir or worktree/submodule file `gitdir: <path>`) to
9
+ // its actual git directory, or null if it can't be read.
10
+ function resolveGitDir(dotgit, dir) {
11
+ try {
12
+ const st = fs.statSync(dotgit);
13
+ if (st.isDirectory()) return dotgit;
14
+ if (st.isFile()) {
15
+ const m = /gitdir:\s*(.+)\s*/.exec(fs.readFileSync(dotgit, "utf8"));
16
+ return m ? path.resolve(dir, m[1].trim()) : null;
17
+ }
18
+ } catch { /* fall through */ }
19
+ return null;
20
+ }
21
+
22
+ // A linked worktree's git dir (.git/worktrees/<name>) holds only that
23
+ // worktree's own HEAD and index — the shared config, and therefore the
24
+ // remotes, stay in the main repo's git dir. Git names it in a `commondir`
25
+ // file, usually as a path relative to the worktree's git dir. Resolve it so
26
+ // origin is found from inside a worktree; outside one there is no such file
27
+ // and the git dir is already the common one.
28
+ function commonGitDir(gitDir) {
29
+ try {
30
+ const pointer = fs.readFileSync(path.join(gitDir, "commondir"), "utf8").trim();
31
+ if (pointer) return path.resolve(gitDir, pointer);
32
+ } catch { /* not a linked worktree */ }
33
+ return gitDir;
34
+ }
35
+
36
+ // Walk up from `start` until we find a `.git` with a readable HEAD (a real
37
+ // repo). A bare `.git` dir with no HEAD — e.g. a stray /tmp/.git — is skipped,
38
+ // not mistaken for a repo. Returns { root, gitDir } or null at the fs root.
39
+ function findGit(start) {
40
+ let dir = start;
41
+ for (;;) {
42
+ const dotgit = path.join(dir, ".git");
43
+ if (fs.existsSync(dotgit)) {
44
+ const gitDir = resolveGitDir(dotgit, dir);
45
+ if (gitDir && fs.existsSync(path.join(gitDir, "HEAD"))) return { root: dir, gitDir };
46
+ }
47
+ const parent = path.dirname(dir);
48
+ if (parent === dir) return null; // reached "/" (or a drive root)
49
+ dir = parent;
50
+ }
51
+ }
52
+
53
+ function readBranch(gitDir) {
54
+ try {
55
+ const head = fs.readFileSync(path.join(gitDir, "HEAD"), "utf8").trim();
56
+ const m = /^ref:\s*refs\/heads\/(.+)$/.exec(head);
57
+ if (m) return m[1];
58
+ return head.slice(0, 12) + " (detached)"; // detached HEAD → short sha
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ function readOrigin(gitDir) {
65
+ try {
66
+ const cfg = fs.readFileSync(path.join(gitDir, "config"), "utf8");
67
+ // Find the [remote "origin"] section's url. Fall back to the first remote.
68
+ const origin = /\[remote "origin"\][^[]*?url\s*=\s*(.+)/s.exec(cfg);
69
+ if (origin) return origin[1].split("\n")[0].trim();
70
+ const any = /\[remote "[^"]+"\][^[]*?url\s*=\s*(.+)/s.exec(cfg);
71
+ return any ? any[1].split("\n")[0].trim() : null;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Resolve where we are. Returns { cwd, home, git }, where git is null outside a
79
+ * repo, else { root, name, branch, origin }.
80
+ */
81
+ export function locate(cwd = process.cwd()) {
82
+ const home = process.env.HOME || process.env.USERPROFILE || "";
83
+ const found = findGit(cwd);
84
+ if (!found || !found.gitDir) return { cwd, home, git: null };
85
+ return {
86
+ cwd,
87
+ home,
88
+ git: {
89
+ root: found.root,
90
+ name: path.basename(found.root),
91
+ // HEAD is per-worktree; config (and its remotes) is shared.
92
+ branch: readBranch(found.gitDir),
93
+ origin: readOrigin(commonGitDir(found.gitDir)),
94
+ },
95
+ };
96
+ }
97
+
98
+ // Collapse $HOME to `~` for tidy display.
99
+ export function tilde(p, home) {
100
+ if (!home || p === home) return home ? "~" : p;
101
+ const relative = p.slice(home.length);
102
+ return p.startsWith(home) && relative.startsWith(path.sep) ? "~" + relative : p;
103
+ }
@@ -0,0 +1,37 @@
1
+ // The moshscript command registry — the single source of truth for the verbs a
2
+ // .mosh script can call.
3
+ //
4
+ // A command is a plain object: { name, summary, run(ctx, ...args) }.
5
+ // - name: the identifier the script calls, e.g. "mosh" → mosh()
6
+ // - summary: one-liner for `moshcode commands` / help
7
+ // - run: the implementation; receives the runtime ctx plus the JS call
8
+ // arguments spread out (so `notify("hi", "there")` → args = ["hi","there"]).
9
+ //
10
+ // The runtime (src/runtime.mjs) injects every registered command as a global of
11
+ // the same name, so scripts call them bare: `mosh()`, `notify("…")`. New verbs —
12
+ // engine launches, tool passthrough, fan-out — are added by registering more
13
+ // commands here (or via registry.register(...) from a host), never by touching
14
+ // the interpreter.
15
+
16
+ export function createRegistry(commands = []) {
17
+ const byName = new Map();
18
+
19
+ const register = (cmd) => {
20
+ if (!cmd || typeof cmd.name !== "string" || typeof cmd.run !== "function") {
21
+ throw new Error("moshscript: a command needs { name, run() }");
22
+ }
23
+ byName.set(cmd.name, { summary: "", ...cmd });
24
+ return api;
25
+ };
26
+
27
+ const api = {
28
+ register,
29
+ has: (name) => byName.has(name),
30
+ get: (name) => byName.get(name),
31
+ all: () => [...byName.values()],
32
+ names: () => [...byName.keys()],
33
+ };
34
+
35
+ commands.forEach(register);
36
+ return api;
37
+ }