@sealant/mend 0.1.0 → 0.2.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.
package/dist/main.js CHANGED
@@ -1,8 +1,22 @@
1
1
  #!/usr/bin/env node
2
+ import { spawn, spawnSync } from "node:child_process";
2
3
  import * as fs from "node:fs";
4
+ import { createRequire } from "node:module";
5
+ import * as net from "node:net";
3
6
  import * as os from "node:os";
4
7
  import * as path from "node:path";
5
- const CONFIG_PATH = path.join(os.homedir(), ".mend", "cli.json");
8
+ import { readSyncFiles, scanDotfileCandidates } from "./dotfiles.js";
9
+ import { isComposeFile, proposeFromCompose, proposeFromPackageJson, proposeFromWorkspacePackage, renderMendToml, workspaceGlobs, } from "./service-init.js";
10
+ import { CONTINUE_COMMANDS, gitTopLevel, HARNESS_COMMANDS, LIVE_STATUSES, matchProjectByCwd, normalizeProjectName, } from "./shared.js";
11
+ // `$XDG_CONFIG_HOME/mend`, default `~/.config/mend`; a pre-XDG `~/.mend` stays authoritative
12
+ // when it is the only one present (mirrors @mend/store's resolver — the CLI stays dependency-light).
13
+ const mendCliHome = () => {
14
+ const xdg = process.env["XDG_CONFIG_HOME"];
15
+ const preferred = path.join(xdg === undefined || xdg === "" ? path.join(os.homedir(), ".config") : xdg, "mend");
16
+ const legacy = path.join(os.homedir(), ".mend");
17
+ return !fs.existsSync(preferred) && fs.existsSync(legacy) ? legacy : preferred;
18
+ };
19
+ const CONFIG_PATH = path.join(mendCliHome(), "cli.json");
6
20
  const loadConfig = () => {
7
21
  let fileConfig = {};
8
22
  if (fs.existsSync(CONFIG_PATH)) {
@@ -28,7 +42,41 @@ const green = paint("32");
28
42
  const amber = paint("33");
29
43
  const cobalt = paint("34");
30
44
  const say = (line) => process.stdout.write(`${line}\n`);
31
- const api = async (config, method, route, body) => {
45
+ const detachKeyEnabled = process.env["MEND_DETACH_KEY"] !== "none";
46
+ const detachHint = () => (detachKeyEnabled ? ` · detach: ${dim("Ctrl+]")}` : "");
47
+ const herdrAgentOf = (harness) => {
48
+ if (harness === "codex" || harness === "claude" || harness === "opencode")
49
+ return harness;
50
+ return null;
51
+ };
52
+ const hintHerdrAttachment = (harness) => {
53
+ const agent = herdrAgentOf(harness);
54
+ if (process.env["HERDR_ENV"] !== "1" || agent === null)
55
+ return () => undefined;
56
+ // Herdr deliberately reads HERDR_AGENT from any member of the foreground
57
+ // process group. A pipe-tethered child carries the hint while Mend owns the
58
+ // pane, without claiming lifecycle authority: Herdr's native screen rules
59
+ // still derive working, idle, and blocked from the bridged agent UI.
60
+ const carrier = spawn(process.execPath, ["-e", "process.stdin.resume();process.stdin.once('end',()=>process.exit(0))"], {
61
+ env: { ...process.env, HERDR_AGENT: agent },
62
+ stdio: ["pipe", "ignore", "ignore"],
63
+ });
64
+ let stopped = false;
65
+ const stop = () => {
66
+ if (stopped)
67
+ return;
68
+ stopped = true;
69
+ carrier.stdin?.end();
70
+ carrier.kill();
71
+ };
72
+ process.once("exit", stop);
73
+ return () => {
74
+ process.off("exit", stop);
75
+ stop();
76
+ };
77
+ };
78
+ /** The raw server call — THROWS with a human message; the dashboard renders it. */
79
+ const request = async (config, method, route, body) => {
32
80
  const headers = { "content-type": "application/json" };
33
81
  if (config.token !== null)
34
82
  headers["authorization"] = `Bearer ${config.token}`;
@@ -42,23 +90,34 @@ const api = async (config, method, route, body) => {
42
90
  });
43
91
  }
44
92
  catch {
45
- return fail(`cannot reach the Mend server at ${config.url} — is it running?`);
93
+ throw new Error(`cannot reach the Mend server at ${config.url} — is it running?`);
46
94
  }
47
95
  if (response.status === 401) {
48
- return fail(`unauthorized — set MEND_TOKEN (or "token" in ${CONFIG_PATH}) to a bearer token for ${config.url}`);
96
+ throw new Error(`unauthorized — set MEND_TOKEN (or "token" in ${CONFIG_PATH}) to a bearer token for ${config.url}`);
49
97
  }
50
98
  const text = await response.text();
51
99
  if (!response.ok) {
100
+ let message = `${method} ${route} → ${response.status}`;
52
101
  try {
53
102
  const parsed = JSON.parse(text);
54
- return fail(parsed.message ?? `${method} ${route} → ${response.status}`);
103
+ message = parsed.message ?? message;
55
104
  }
56
105
  catch {
57
- return fail(`${method} ${route} → ${response.status}`);
106
+ // not JSON — keep the status line
58
107
  }
108
+ throw new Error(message);
59
109
  }
60
110
  return JSON.parse(text);
61
111
  };
112
+ /** The same call for one-shot commands: any failure prints and exits. */
113
+ const api = async (config, method, route, body) => {
114
+ try {
115
+ return await request(config, method, route, body);
116
+ }
117
+ catch (error) {
118
+ return fail(error instanceof Error ? error.message : String(error));
119
+ }
120
+ };
62
121
  const worktreePathOf = (storePath, worktree) => path.join(path.dirname(storePath), "worktrees", worktree);
63
122
  /** A live elapsed-time spinner around a slow await — provisioning is not a hang. */
64
123
  const withSpinner = async (label, work) => {
@@ -84,20 +143,39 @@ const withSpinner = async (label, work) => {
84
143
  const adopt = async (config, args) => {
85
144
  const nameFlag = args.indexOf("--name");
86
145
  const name = nameFlag !== -1 && args[nameFlag + 1] !== undefined ? String(args[nameFlag + 1]) : null;
87
- const positional = args.filter((a, i) => !a.startsWith("--") && (nameFlag === -1 || i !== nameFlag + 1));
88
- const rawSource = positional[0] ?? ".";
146
+ const authFlagIndex = args.indexOf("--auth");
147
+ const positional = args.filter((a, i) => !a.startsWith("--") &&
148
+ (nameFlag === -1 || i !== nameFlag + 1) &&
149
+ (authFlagIndex === -1 || i !== authFlagIndex + 1));
150
+ // Bare `mend adopt` adopts the repo the cwd is inside, not the subdirectory.
151
+ const rawSource = positional[0] ?? gitTopLevel(process.cwd()) ?? ".";
89
152
  const source = /^(https?|git|ssh):|^git@/.test(rawSource) ? rawSource : path.resolve(rawSource);
90
- const projectName = name ?? path.basename(source, ".git");
153
+ // Derived defaults are normalized ("Mend" → "mend"); explicit --name is sent as typed.
154
+ const projectName = name ?? normalizeProjectName(path.basename(source, ".git"));
155
+ const auth = authFlagIndex !== -1 && args[authFlagIndex + 1] !== undefined
156
+ ? String(args[authFlagIndex + 1])
157
+ : null;
158
+ if (auth !== null && auth !== "ambient" && auth !== "mend-key" && auth !== "bridge") {
159
+ return fail(`--auth takes "ambient", "mend-key", or "bridge", not "${auth}"`);
160
+ }
91
161
  const project = await api(config, "POST", "/projects", {
92
162
  name: projectName,
93
163
  source,
164
+ ...(auth === null ? {} : { gitAuthMode: auth }),
94
165
  });
95
166
  say(`${green("✓")} adopted · ${project.name} · ${dim(project.storePath)}`);
96
167
  say(`${dim(" default branch")} ${project.defaultBranch}`);
168
+ // Say which signer did the work — the clone already proved it answers.
169
+ if (project.gitAuthMode === "mend-key") {
170
+ say(`${dim(" git auth")} mend key ${dim("(the machine's deploy key signed this clone)")}`);
171
+ }
172
+ else if (project.gitAuthMode === "bridge") {
173
+ say(`${dim(" git auth")} bridge ${dim("(signed through the connected `mend keys share`)")}`);
174
+ }
97
175
  say(`${dim(" sessions start with:")} mend codex ${dim("(from anywhere —")} --project ${project.name}${dim(")")}`);
98
176
  };
99
177
  // ─── session launch ─────────────────────────────────────────────────────────
100
- const findProject = async (config, explicit) => {
178
+ const findProject = async (config, explicit, adoptCwd = false) => {
101
179
  const projects = await api(config, "GET", "/projects");
102
180
  if (explicit !== null) {
103
181
  const named = projects.find((p) => p.name === explicit);
@@ -106,18 +184,24 @@ const findProject = async (config, explicit) => {
106
184
  return named;
107
185
  }
108
186
  const cwd = process.cwd();
109
- const byOrigin = projects.find((p) => p.originUrl !== null && cwd.startsWith(p.originUrl));
110
- const byName = projects.find((p) => p.name === path.basename(cwd));
111
- const project = byOrigin ?? byName;
112
- if (project === undefined) {
187
+ const project = matchProjectByCwd(projects, cwd);
188
+ if (project !== undefined)
189
+ return project;
190
+ if (!adoptCwd) {
113
191
  return fail(`no adopted project matches ${cwd} — run "mend adopt" here first, or name one with --project`);
114
192
  }
115
- return project;
116
- };
117
- const HARNESS_COMMANDS = {
118
- codex: ["codex"],
119
- claude: ["claude"],
120
- opencode: ["opencode"],
193
+ // Launching from an un-adopted repo just adopts it — adoption is cheap and
194
+ // has no ceremony to deserve a separate errand.
195
+ const top = gitTopLevel(cwd);
196
+ if (top === null) {
197
+ return fail(`${cwd} is not inside a git repository — mend adopt <source> adopts one explicitly`);
198
+ }
199
+ const adopted = await api(config, "POST", "/projects", {
200
+ name: normalizeProjectName(path.basename(top)),
201
+ source: top,
202
+ });
203
+ say(`${green("✓")} adopted · ${adopted.name} · ${dim(adopted.storePath)}`);
204
+ return adopted;
121
205
  };
122
206
  const launch = async (config, harness, args) => {
123
207
  const projectFlag = args.indexOf("--project");
@@ -130,7 +214,7 @@ const launch = async (config, harness, args) => {
130
214
  if (argv.length === 0) {
131
215
  return fail(harness === "run" ? "usage: mend run -- <command...>" : `unknown harness ${harness}`);
132
216
  }
133
- const project = await findProject(config, explicit);
217
+ const project = await findProject(config, explicit, true);
134
218
  const session = await api(config, "POST", `/projects/${project.id}/sessions`, {
135
219
  harness,
136
220
  label: null,
@@ -146,14 +230,10 @@ const launch = async (config, harness, args) => {
146
230
  return supervisedRun(config, session, argv);
147
231
  }
148
232
  await withSpinner("provisioning workspace — a first launch builds the harness image (can take minutes)…", api(config, "POST", `/sessions/${session.id}/launch`, { argv }));
149
- say(`${green("✓ recording")} · workspace mounts the worktree · detach: ${dim("Ctrl+]")}`);
233
+ say(`${green("✓ recording")} · workspace mounts the worktree${detachHint()}`);
150
234
  say("");
151
- await attachTty(config, session.id, 0n);
152
- const settled = await api(config, "GET", `/sessions/${session.id}`);
153
- say("");
154
- say(`${green("✓")} session ${settled.session.status} · recorded`);
155
- say(`${cobalt(" review")} · ${config.url}/sessions/${session.id}`);
156
- process.exit(0);
235
+ await attachOrExit(config, session.id, session.harness);
236
+ exitAfterSessionEnd(config, session.id);
157
237
  };
158
238
  // ─── the terminal bridge: raw stdin/stdout against the platform PTY ─────────
159
239
  /**
@@ -162,12 +242,19 @@ const launch = async (config, harness, args) => {
162
242
  * JSON (resize up, end down). Auth happens once at connect (?token=); after
163
243
  * that a keystroke is a frame on an open socket — nothing else on the path.
164
244
  * Ctrl+] detaches — the session keeps running and can be reattached from
165
- * anywhere. Resolves when the session settles or the user detaches.
245
+ * anywhere. Resolves when the session settles or the user detaches; the
246
+ * caller decides what each outcome means (commands exit, the dashboard
247
+ * resumes).
166
248
  */
167
- const attachTty = async (config, sessionId, from) => {
249
+ const attachTty = async (config, sessionId, harness, from, processId, options) => {
168
250
  const url = new URL(`${config.url}/api/tty`);
169
251
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
170
- url.searchParams.set("session", sessionId);
252
+ // Process addressing reaches any PTY in the workspace (a shell); the
253
+ // session form remains the agent's PTY.
254
+ if (processId !== undefined)
255
+ url.searchParams.set("process", processId);
256
+ else
257
+ url.searchParams.set("session", sessionId);
171
258
  url.searchParams.set("from", from.toString());
172
259
  if (config.token !== null)
173
260
  url.searchParams.set("token", config.token);
@@ -180,51 +267,112 @@ const attachTty = async (config, sessionId, from) => {
180
267
  });
181
268
  }
182
269
  catch {
183
- return fail(`tty attach unavailable: could not connect to ${url.host}`);
270
+ return "unavailable";
184
271
  }
185
- const sendResize = () => ws.send(JSON.stringify({
186
- t: "resize",
187
- cols: process.stdout.columns ?? 80,
188
- rows: process.stdout.rows ?? 24,
189
- }));
190
- sendResize();
272
+ const stopHerdrHint = hintHerdrAttachment(harness);
273
+ let finishAttachment;
274
+ const finished = new Promise((resolve) => {
275
+ finishAttachment = resolve;
276
+ });
277
+ const onClose = () => finishAttachment?.();
278
+ if (ws.readyState === WebSocket.CLOSED)
279
+ finishAttachment?.();
280
+ else
281
+ ws.addEventListener("close", onClose, { once: true });
282
+ const sendResize = () => {
283
+ if (ws.readyState !== WebSocket.OPEN)
284
+ return;
285
+ ws.send(JSON.stringify({
286
+ t: "resize",
287
+ cols: process.stdout.columns ?? 80,
288
+ rows: process.stdout.rows ?? 24,
289
+ }));
290
+ };
191
291
  const onWinch = () => sendResize();
192
- process.stdout.on("resize", onWinch);
193
292
  const rawTty = process.stdin.isTTY === true;
194
- if (rawTty)
195
- process.stdin.setRawMode(true);
196
- process.stdin.resume();
293
+ let rawModeEnabled = false;
197
294
  let detached = false;
295
+ const onTtyFrame = (event) => {
296
+ if (typeof event.data !== "string") {
297
+ process.stdout.write(Buffer.from(event.data));
298
+ return;
299
+ }
300
+ try {
301
+ const frame = JSON.parse(event.data);
302
+ if (typeof frame !== "object" || frame === null || !("t" in frame) || frame.t !== "end") {
303
+ return;
304
+ }
305
+ // Session lifecycle is authoritative. Start detaching immediately instead
306
+ // of holding the user's terminal for the later close handshake and Mend's
307
+ // settle/checkpoint/harvest work.
308
+ ws.close();
309
+ finishAttachment?.();
310
+ }
311
+ catch {
312
+ // Unknown text control frame — ignore.
313
+ }
314
+ };
198
315
  const onKeys = (data) => {
199
- if (data.includes(0x1d)) {
316
+ if (detachKeyEnabled && data.includes(0x1d)) {
200
317
  // Ctrl+] — detach, leave the session running.
201
318
  detached = true;
202
319
  ws.close();
203
320
  return;
204
321
  }
322
+ if (ws.readyState !== WebSocket.OPEN)
323
+ return;
205
324
  // Copy into a plain ArrayBuffer (WebSocket.send rejects pooled Buffer views).
206
325
  ws.send(new Uint8Array(data).buffer);
207
326
  };
208
- process.stdin.on("data", onKeys);
209
- ws.addEventListener("message", (event) => {
210
- if (typeof event.data === "string")
211
- return; // control frames ({"t":"end"}) — close follows
212
- process.stdout.write(Buffer.from(event.data));
213
- });
214
- await new Promise((resolve) => {
215
- ws.addEventListener("close", () => resolve(), { once: true });
216
- });
217
- process.stdin.off("data", onKeys);
218
- process.stdout.off("resize", onWinch);
219
- if (rawTty)
220
- process.stdin.setRawMode(false);
221
- process.stdin.pause();
222
- if (detached) {
327
+ try {
328
+ sendResize();
329
+ process.stdout.on("resize", onWinch);
330
+ // Read-only (logs): never forward stdin — Ctrl+C exits the CLI, the
331
+ // socket drops, and the process inside keeps running untouched.
332
+ if (options?.readOnly !== true) {
333
+ if (rawTty) {
334
+ process.stdin.setRawMode(true);
335
+ rawModeEnabled = true;
336
+ }
337
+ process.stdin.resume();
338
+ process.stdin.on("data", onKeys);
339
+ }
340
+ ws.addEventListener("message", onTtyFrame);
341
+ await finished;
342
+ return detached ? "detached" : "ended";
343
+ }
344
+ finally {
345
+ process.stdin.off("data", onKeys);
346
+ process.stdout.off("resize", onWinch);
347
+ ws.removeEventListener("message", onTtyFrame);
348
+ ws.removeEventListener("close", onClose);
349
+ if (rawModeEnabled)
350
+ process.stdin.setRawMode(false);
351
+ process.stdin.pause();
352
+ if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)
353
+ ws.close();
354
+ stopHerdrHint();
355
+ }
356
+ };
357
+ /** How every one-shot command handles an attach outcome: detach says so, settle returns. */
358
+ const finishAttach = (config, sessionId, outcome) => {
359
+ if (outcome === "unavailable") {
360
+ return fail(`tty attach unavailable: could not connect to ${config.url}`);
361
+ }
362
+ if (outcome === "detached") {
223
363
  say("");
224
364
  say(`${amber("detached")} — the session keeps running; reattach: mend attach ${sessionId.slice(0, 8)}`);
225
365
  process.exit(0);
226
366
  }
227
367
  };
368
+ const attachOrExit = async (config, sessionId, harness) => finishAttach(config, sessionId, await attachTty(config, sessionId, harness, 0n));
369
+ /** Return terminal control as soon as the terminal reports the observed end. */
370
+ const exitAfterSessionEnd = (config, sessionId) => {
371
+ say("");
372
+ say(`${green("✓")} session ended`);
373
+ say(`${cobalt(" review")} · ${config.url}/sessions/${sessionId}`);
374
+ process.exit(0);
375
+ };
228
376
  /** Reattach a terminal to a running session (full scrollback replay, then live). */
229
377
  const attach = async (config, args) => {
230
378
  const prefix = args.find((a) => !a.startsWith("--"));
@@ -234,13 +382,707 @@ const attach = async (config, args) => {
234
382
  const match = sessions.find((s) => s.id.startsWith(prefix));
235
383
  if (match === undefined)
236
384
  return fail(`no active session matches "${prefix}"`);
237
- say(`${green("✓")} attaching to ${match.harness} · ${dim(match.id.slice(0, 8))} · detach: ${dim("Ctrl+]")}`);
385
+ say(`${green("✓")} attaching to ${match.harness} · ${dim(match.id.slice(0, 8))}${detachHint()}`);
238
386
  say("");
239
- await attachTty(config, match.id, 0n);
387
+ await attachOrExit(config, match.id, match.harness);
388
+ exitAfterSessionEnd(config, match.id);
389
+ };
390
+ /** Compact picker: numbered live sessions, one keystroke of typing. */
391
+ const pickSessionInteractively = async (rows) => {
392
+ say(dim("more than one live session — pick one:"));
393
+ rows.forEach((row, index) => {
394
+ say(` ${index + 1}. ${row.session.harness.padEnd(8)} ${dim(row.session.id.slice(0, 8))} ${row.projectName} ${dim(row.session.branch)}`);
395
+ });
396
+ const readline = await import("node:readline/promises");
397
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
398
+ const answer = await rl.question(" session #: ");
399
+ rl.close();
400
+ const chosen = rows[Number(answer.trim()) - 1];
401
+ if (chosen === undefined)
402
+ return fail(`"${answer.trim()}" is not one of the choices`);
403
+ return chosen.session;
404
+ };
405
+ /**
406
+ * The resolution order (docs/SESSION-SERVICES.md): an explicit id wins, then
407
+ * the cwd's project narrows, one candidate is taken, several go to the
408
+ * picker, and a non-interactive caller never gets a silent guess.
409
+ */
410
+ const resolveLiveSession = async (config, prefix) => {
411
+ const sessions = await api(config, "GET", "/sessions");
412
+ if (prefix !== undefined) {
413
+ const matches = sessions.filter((s) => s.id.startsWith(prefix));
414
+ if (matches.length === 0)
415
+ return fail(`no live session matches "${prefix}"`);
416
+ const exact = matches[0];
417
+ if (matches.length > 1 || exact === undefined) {
418
+ return fail(`session prefix "${prefix}" is ambiguous — use more of the id`);
419
+ }
420
+ return exact;
421
+ }
422
+ const projects = await api(config, "GET", "/projects");
423
+ const project = matchProjectByCwd(projects, process.cwd());
424
+ const candidates = project === undefined ? sessions : sessions.filter((s) => s.projectId === project.id);
425
+ const only = candidates[0];
426
+ if (only === undefined) {
427
+ return fail("no live session — start one with mend codex|claude|opencode");
428
+ }
429
+ if (candidates.length === 1)
430
+ return only;
431
+ if (process.stdin.isTTY !== true) {
432
+ return fail("several live sessions — name one: mend shell <session-id-prefix>");
433
+ }
434
+ const nameById = new Map(projects.map((p) => [p.id, p.name]));
435
+ return pickSessionInteractively(candidates.map((session) => ({
436
+ session,
437
+ projectName: nameById.get(session.projectId) ?? session.projectId.slice(0, 8),
438
+ })));
439
+ };
440
+ /**
441
+ * A real interactive shell in the session's CURRENT workspace — same
442
+ * /workspace/repo the agent is editing, same dependencies, same network. Not
443
+ * a shell in the host checkout. The shell holds a workspace lease, so the
444
+ * container survives the agent settling while the shell lives.
445
+ */
446
+ const shellCommand = async (config, args) => {
447
+ const prefix = args.find((a) => !a.startsWith("--"));
448
+ const session = await resolveLiveSession(config, prefix);
449
+ say(`${green("✓")} shell in ${session.harness} session ${dim(session.id.slice(0, 8))} · ${dim(session.branch)}${detachHint()}`);
450
+ const shellProcess = await withSpinner("opening a shell in the session workspace…", api(config, "POST", `/sessions/${session.id}/shell`));
451
+ say("");
452
+ const outcome = await attachTty(config, session.id, "shell", 0n, shellProcess.id);
453
+ if (outcome === "unavailable") {
454
+ return fail(`tty attach unavailable: could not connect to ${config.url}`);
455
+ }
240
456
  say("");
241
- say(`${green("✓")} session settled`);
457
+ if (outcome === "detached") {
458
+ say(`${amber("detached")} — the shell keeps running and holds the workspace open`);
459
+ process.exit(0);
460
+ }
461
+ say(`${green("✓")} shell ended`);
242
462
  process.exit(0);
243
463
  };
464
+ const serviceUrl = (config, service) => {
465
+ const host = new URL(config.url).hostname || "localhost";
466
+ // A UDP Service has no page to open — the endpoint is the whole fact.
467
+ return service.protocol === "udp"
468
+ ? `${host}:${service.hostPort ?? "?"} (udp)`
469
+ : `http://${host}:${service.hostPort ?? "?"}`;
470
+ };
471
+ const printService = (config, service) => {
472
+ const status = service.status === "reachable" ? green(service.status) : amber(service.status);
473
+ say(`${(service.label ?? service.id.slice(0, 8)).padEnd(12)} ${dim(`:${service.workspacePort ?? "?"}${service.protocol === "udp" ? "/udp" : ""} →`)} ${serviceUrl(config, service)} ${status} ${dim(service.id.slice(0, 8))}`);
474
+ };
475
+ /**
476
+ * Adopt an already-listening workspace port as a Service: Mend binds a host
477
+ * port on its private interfaces and pumps every connection into the
478
+ * session's workspace. No supervision — reachability is the observation.
479
+ */
480
+ const serviceAdd = async (config, args) => {
481
+ const nameFlag = args.indexOf("--name");
482
+ const name = nameFlag !== -1 && args[nameFlag + 1] !== undefined ? String(args[nameFlag + 1]) : null;
483
+ const protocol = args.includes("--udp") ? "udp" : "tcp";
484
+ const positional = args.filter((a, i) => !a.startsWith("--") && (nameFlag === -1 || i !== nameFlag + 1));
485
+ const portRaw = positional.find((a) => /^\d+$/.test(a));
486
+ if (portRaw === undefined) {
487
+ return fail("usage: mend service add [session] <port> [--name <n>] [--udp]");
488
+ }
489
+ const port = Number(portRaw);
490
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
491
+ return fail(`"${portRaw}" is not a port`);
492
+ }
493
+ const prefix = positional.find((a) => a !== portRaw);
494
+ const session = await resolveLiveSession(config, prefix);
495
+ const service = await api(config, "POST", `/sessions/${session.id}/services`, {
496
+ port,
497
+ name,
498
+ protocol,
499
+ });
500
+ say(`${green("✓")} Service ${service.label ?? ""} · ${service.status}`);
501
+ say(` ${cobalt(serviceUrl(config, service))}`);
502
+ if (protocol === "udp") {
503
+ say(dim(` udp — a reply is the only reachability signal; silence just relays`));
504
+ }
505
+ else if (service.status !== "reachable") {
506
+ say(dim(` nothing answered on :${port} yet — the URL goes live when something listens`));
507
+ }
508
+ };
509
+ const serviceList = async (config) => {
510
+ const services = await api(config, "GET", "/services");
511
+ if (services.length === 0) {
512
+ say(dim("no live services — mend service add <port> adopts a listening one"));
513
+ return;
514
+ }
515
+ for (const service of services)
516
+ printService(config, service);
517
+ };
518
+ const serviceStop = async (config, args) => {
519
+ const needle = args.find((a) => !a.startsWith("--"));
520
+ if (needle === undefined)
521
+ return fail("usage: mend service stop <name-or-id-prefix>");
522
+ const services = await api(config, "GET", "/services");
523
+ const matches = services.filter((service) => service.label === needle || service.id.startsWith(needle));
524
+ if (matches.length === 0)
525
+ return fail(`no live service matches "${needle}"`);
526
+ const match = matches[0];
527
+ if (matches.length > 1 || match === undefined) {
528
+ return fail(`"${needle}" is ambiguous — use more of the id`);
529
+ }
530
+ const stoppedService = await api(config, "POST", `/services/${match.id}/stop`);
531
+ say(`${green("✓")} stopped · ${stoppedService.label ?? stoppedService.id.slice(0, 8)}`);
532
+ };
533
+ const findLiveService = async (config, needle) => {
534
+ const services = await api(config, "GET", "/services");
535
+ const matches = services.filter((service) => service.label === needle || service.id.startsWith(needle));
536
+ if (matches.length === 0)
537
+ return fail(`no live service matches "${needle}"`);
538
+ const match = matches[0];
539
+ if (matches.length > 1 || match === undefined) {
540
+ return fail(`"${needle}" is ambiguous — use more of the id`);
541
+ }
542
+ return match;
543
+ };
544
+ /**
545
+ * Start and supervise a Service: the command runs as its own PTY process in
546
+ * the session's workspace (own record = its logs), Mend waits for the
547
+ * declared port to answer, then exposes it like any Service. The command
548
+ * never occupies the agent's terminal or a tool call.
549
+ */
550
+ const serviceRun = async (config, args) => {
551
+ const dashdash = args.indexOf("--");
552
+ const usage = "usage: mend service run [session] --port <port> [--name <n>] [--udp] -- <command...>\n" +
553
+ " mend service run [session] <name> (a declared recipe)";
554
+ // No explicit command = a DECLARED Service: resolve the name against the
555
+ // session worktree's mend.toml and start (or adopt) its recipe.
556
+ if (dashdash === -1) {
557
+ const positionals = args.filter((a) => !a.startsWith("--"));
558
+ const name = positionals.at(-1);
559
+ if (name === undefined)
560
+ return fail(usage);
561
+ const prefix = positionals.length > 1 ? positionals[0] : undefined;
562
+ const session = await resolveLiveSession(config, prefix);
563
+ const recipes = await api(config, "GET", `/sessions/${session.id}/recipes`);
564
+ const recipe = recipes.find((entry) => entry.name === name);
565
+ if (recipe === undefined) {
566
+ const known = recipes.map((entry) => entry.name).join(", ");
567
+ return fail(recipes.length === 0
568
+ ? `no mend.toml recipes in this worktree — declare [service.${name}] first`
569
+ : `no recipe named "${name}" — declared: ${known}`);
570
+ }
571
+ if (recipe.command === null) {
572
+ // A port-only recipe is an adopt: something else starts the listener.
573
+ const service = await api(config, "POST", `/sessions/${session.id}/services`, {
574
+ port: recipe.port,
575
+ name: recipe.name,
576
+ protocol: recipe.protocol,
577
+ });
578
+ say(`${green("✓")} Service ${service.label ?? ""} · ${service.status}`);
579
+ say(` ${cobalt(serviceUrl(config, service))}`);
580
+ return;
581
+ }
582
+ const service = await withSpinner(recipe.protocol === "udp"
583
+ ? `starting ${recipe.name} (udp :${recipe.port})…`
584
+ : `starting ${recipe.name} — waiting for :${recipe.port} to answer…`, api(config, "POST", `/sessions/${session.id}/services/run`, {
585
+ argv: ["sh", "-c", recipe.command],
586
+ port: recipe.port,
587
+ name: recipe.name,
588
+ protocol: recipe.protocol,
589
+ }));
590
+ say(`${green("✓")} Service ${service.label ?? ""} · ${service.status}`);
591
+ say(` ${cobalt(serviceUrl(config, service))}`);
592
+ say(dim(` logs: mend service logs ${service.label ?? service.id.slice(0, 8)}`));
593
+ return;
594
+ }
595
+ const argv = args.slice(dashdash + 1);
596
+ if (argv.length === 0)
597
+ return fail(usage);
598
+ const head = args.slice(0, dashdash);
599
+ const portFlag = head.indexOf("--port");
600
+ const port = portFlag !== -1 ? Number(head[portFlag + 1]) : Number.NaN;
601
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
602
+ return fail(usage);
603
+ const nameFlag = head.indexOf("--name");
604
+ const name = nameFlag !== -1 && head[nameFlag + 1] !== undefined ? String(head[nameFlag + 1]) : null;
605
+ const protocol = head.includes("--udp") ? "udp" : "tcp";
606
+ const prefix = head.find((a, i) => !a.startsWith("--") && i !== portFlag + 1 && i !== nameFlag + 1);
607
+ const session = await resolveLiveSession(config, prefix);
608
+ const service = await withSpinner(protocol === "udp"
609
+ ? `starting ${name ?? argv[0]} (udp :${port})…`
610
+ : `starting ${name ?? argv[0]} — waiting for :${port} to answer…`, api(config, "POST", `/sessions/${session.id}/services/run`, {
611
+ argv,
612
+ port,
613
+ name,
614
+ protocol,
615
+ }));
616
+ say(`${green("✓")} Service ${service.label ?? ""} · ${service.status}`);
617
+ say(` ${cobalt(serviceUrl(config, service))}`);
618
+ say(dim(` logs: mend service logs ${service.label ?? service.id.slice(0, 8)}`));
619
+ };
620
+ /**
621
+ * Watch a supervised Service's output: record replay, then live tail. A DEAD
622
+ * Service still answers — its record outlives the process — printed once
623
+ * instead of followed.
624
+ */
625
+ const serviceLogs = async (config, args) => {
626
+ const needle = args.find((a) => !a.startsWith("--"));
627
+ if (needle === undefined)
628
+ return fail("usage: mend service logs <name-or-id-prefix>");
629
+ const everything = await api(config, "GET", "/services?all=1");
630
+ const matches = everything.filter((service) => service.label === needle || service.id.startsWith(needle));
631
+ if (matches.length === 0)
632
+ return fail(`no service matches "${needle}"`);
633
+ // Prefer the live one; otherwise the newest ended one (list is newest-first).
634
+ const service = matches.find((m) => m.status !== "exited" && m.status !== "stopped") ?? matches[0];
635
+ if (service === undefined)
636
+ return fail(`no service matches "${needle}"`);
637
+ if (service.sealantSessionId === null) {
638
+ return fail(`"${needle}" is an adopted port — no process of Mend's, no logs. mend service run supervises.`);
639
+ }
640
+ if (service.status === "exited" || service.status === "stopped") {
641
+ // Post-mortem: print the record, don't attach.
642
+ const output = await api(config, "GET", `/processes/${service.id}/output`);
643
+ say(dim(`${service.label ?? service.id.slice(0, 8)} · ${service.status} — recorded output:`));
644
+ say("");
645
+ process.stdout.write(output.text.endsWith("\n") ? output.text : `${output.text}\n`);
646
+ process.exit(0);
647
+ }
648
+ say(dim(`following ${service.label ?? service.id.slice(0, 8)} — Ctrl+C detaches, the service keeps running`));
649
+ say("");
650
+ const outcome = await attachTty(config, service.sessionId, "service", 0n, service.id, {
651
+ readOnly: true,
652
+ });
653
+ if (outcome === "unavailable") {
654
+ return fail(`tty attach unavailable: could not connect to ${config.url}`);
655
+ }
656
+ say("");
657
+ say(dim("stream ended"));
658
+ process.exit(0);
659
+ };
660
+ const serviceRestart = async (config, args) => {
661
+ const needle = args.find((a) => !a.startsWith("--"));
662
+ if (needle === undefined)
663
+ return fail("usage: mend service restart <name-or-id-prefix>");
664
+ const service = await findLiveService(config, needle);
665
+ const restarted = await withSpinner(`restarting ${service.label ?? service.id.slice(0, 8)}…`, api(config, "POST", `/services/${service.id}/restart`));
666
+ say(`${green("✓")} restarted · ${restarted.status}`);
667
+ say(` ${cobalt(serviceUrl(config, restarted))}`);
668
+ };
669
+ /**
670
+ * Scaffold mend.toml from the project's own manifests. Static suggestion,
671
+ * not detection: package.json scripts and compose port mappings become
672
+ * recipe proposals the user confirms and commits. Nothing runs.
673
+ */
674
+ const serviceInit = async (args) => {
675
+ const root = gitTopLevel(process.cwd()) ?? process.cwd();
676
+ const target = path.join(root, "mend.toml");
677
+ if (fs.existsSync(target)) {
678
+ return fail(`${target} already exists — edit it directly (init never merges)`);
679
+ }
680
+ const rootFiles = fs.readdirSync(root);
681
+ const proposals = [];
682
+ const readRoot = (name) => fs.readFileSync(path.join(root, name), "utf8");
683
+ if (rootFiles.includes("package.json")) {
684
+ proposals.push(...proposeFromPackageJson(readRoot("package.json"), rootFiles));
685
+ }
686
+ // Monorepo sweep: every workspace package, named after its folder.
687
+ const globs = workspaceGlobs(rootFiles.includes("pnpm-workspace.yaml") ? readRoot("pnpm-workspace.yaml") : null, rootFiles.includes("package.json") ? readRoot("package.json") : null);
688
+ for (const glob of globs) {
689
+ const parent = glob.replace(/\/?\*+$/, "");
690
+ const dirs = glob.endsWith("*")
691
+ ? fs.existsSync(path.join(root, parent))
692
+ ? fs.readdirSync(path.join(root, parent)).map((dir) => path.join(parent, dir))
693
+ : []
694
+ : [glob];
695
+ for (const dir of dirs) {
696
+ const manifest = path.join(root, dir, "package.json");
697
+ if (!fs.existsSync(manifest))
698
+ continue;
699
+ for (const proposal of proposeFromWorkspacePackage(path.basename(dir), fs.readFileSync(manifest, "utf8"), rootFiles)) {
700
+ if (!proposals.some((existing) => existing.name === proposal.name)) {
701
+ proposals.push(proposal);
702
+ }
703
+ }
704
+ }
705
+ }
706
+ // Every compose flavor in the root, aggregated; non-default files need -f.
707
+ for (const composeName of rootFiles.filter(isComposeFile).toSorted()) {
708
+ const isDefault = composeName === "compose.yaml" || composeName === "docker-compose.yml";
709
+ for (const proposal of proposeFromCompose(readRoot(composeName))) {
710
+ if (!proposals.some((existing) => existing.name === proposal.name)) {
711
+ proposals.push({
712
+ ...proposal,
713
+ command: isDefault
714
+ ? proposal.command
715
+ : proposal.command.replace("docker compose ", `docker compose -f ${composeName} `),
716
+ source: `${composeName}: ${proposal.source}`,
717
+ });
718
+ }
719
+ }
720
+ }
721
+ if (proposals.length === 0) {
722
+ return fail("nothing to propose — no server-ish package.json script with a nameable port, no compose ports");
723
+ }
724
+ const toml = renderMendToml(proposals);
725
+ say(dim(`proposed ${target}:`));
726
+ say("");
727
+ process.stdout.write(toml);
728
+ say("");
729
+ if (!args.includes("--yes")) {
730
+ if (process.stdin.isTTY !== true) {
731
+ return fail("non-interactive — pass --yes to write the file");
732
+ }
733
+ const readline = await import("node:readline/promises");
734
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
735
+ const answer = await rl.question("write it? [y/N] ");
736
+ rl.close();
737
+ if (answer.trim().toLowerCase() !== "y") {
738
+ say(dim("nothing written"));
739
+ return;
740
+ }
741
+ }
742
+ fs.writeFileSync(target, toml);
743
+ say(`${green("✓")} wrote ${target} — commit it, then: mend service run ${proposals[0]?.name}`);
744
+ };
745
+ const serviceCommand = async (config, args) => {
746
+ const [verb, ...rest] = args;
747
+ switch (verb) {
748
+ case "run":
749
+ return serviceRun(config, rest);
750
+ case "add":
751
+ return serviceAdd(config, rest);
752
+ case "init":
753
+ return serviceInit(rest);
754
+ case "list":
755
+ case undefined:
756
+ return serviceList(config);
757
+ case "logs":
758
+ return serviceLogs(config, rest);
759
+ case "restart":
760
+ return serviceRestart(config, rest);
761
+ case "stop":
762
+ return serviceStop(config, rest);
763
+ default:
764
+ // Sugar: `mend service mysql` reads as `mend service run mysql` — a
765
+ // bare word that isn't a verb is a recipe name. A miss still explains
766
+ // itself (declared recipes are listed in the failure).
767
+ return serviceRun(config, [verb, ...rest]);
768
+ }
769
+ };
770
+ // ─── keys: the machine's Mend deploy key (docs/GIT-ACCESS.md) ───────────────
771
+ /** Print the public key with the one instruction that makes it useful. */
772
+ const printGitKey = (key) => {
773
+ if (key.publicKey === null)
774
+ return;
775
+ say(key.publicKey);
776
+ if (key.fingerprint !== null)
777
+ say(dim(` ${key.fingerprint}`));
778
+ say(dim(" add this as a deploy key on your git host (GitHub/GitLab/Gitea: repo"));
779
+ say(dim(" settings → deploy keys; grant write access if sessions should push),"));
780
+ say(dim(" then adopt with: mend adopt <ssh-url> --auth mend-key"));
781
+ };
782
+ const keysShow = async (config) => {
783
+ const key = await api(config, "GET", "/keys/git");
784
+ if (!key.exists) {
785
+ say(`no Mend key yet ${dim("— mend keys init generates one (ed25519, stays on the server host)")}`);
786
+ return;
787
+ }
788
+ printGitKey(key);
789
+ };
790
+ const keysInit = async (config) => {
791
+ const key = await api(config, "POST", "/keys/git");
792
+ say(`${green("✓")} Mend key ready ${dim("(private half stays on the server host)")}`);
793
+ printGitKey(key);
794
+ };
795
+ /**
796
+ * The ssh-agent bridge, client half (docs/GIT-ACCESS.md decision 2): relay
797
+ * the LOCAL ssh-agent to the Mend server over one standing WebSocket, so
798
+ * bridge-mode git ops sign with a key that never leaves this machine — a
799
+ * hardware key blinks here, not on the server. Agent-protocol messages are
800
+ * relayed verbatim (length-prefixed frames, base64 over JSON); the only
801
+ * inspection is each message's type byte, to say what is being asked.
802
+ */
803
+ const keysShare = async (config) => {
804
+ const agentSock = process.env["SSH_AUTH_SOCK"];
805
+ if (agentSock === undefined || agentSock === "") {
806
+ return fail("SSH_AUTH_SOCK is not set — start (or plug in) your ssh-agent first");
807
+ }
808
+ const url = new URL(`${config.url}/api/keys/bridge/ws`);
809
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
810
+ url.searchParams.set("host", os.hostname());
811
+ if (config.token !== null)
812
+ url.searchParams.set("token", config.token);
813
+ /**
814
+ * One complete agent exchange against the local agent: fresh connection,
815
+ * write the framed request verbatim, read one framed response. A hardware
816
+ * key blocks here until the touch — hence the generous timeout.
817
+ */
818
+ const askLocalAgent = (payload) => new Promise((resolve, reject) => {
819
+ const connection = net.connect(agentSock, () => {
820
+ connection.write(payload);
821
+ });
822
+ const timer = setTimeout(() => {
823
+ connection.destroy();
824
+ reject(new Error("the agent did not answer within 60s — touch missed?"));
825
+ }, 60_000);
826
+ let pending = Buffer.alloc(0);
827
+ connection.on("data", (chunk) => {
828
+ pending = Buffer.concat([pending, chunk]);
829
+ if (pending.length >= 4 && pending.length >= 4 + pending.readUInt32BE(0)) {
830
+ clearTimeout(timer);
831
+ connection.end();
832
+ resolve(pending.subarray(0, 4 + pending.readUInt32BE(0)));
833
+ }
834
+ });
835
+ connection.on("error", (error) => {
836
+ clearTimeout(timer);
837
+ reject(error);
838
+ });
839
+ });
840
+ // Strictly one request at a time, in arrival order — the agent protocol is
841
+ // request/response and a hardware key cannot answer two touches at once.
842
+ let chain = Promise.resolve();
843
+ const SSH_AGENTC_SIGN_REQUEST = 13;
844
+ const SSH_AGENTC_REQUEST_IDENTITIES = 11;
845
+ let attempt = 0;
846
+ for (;;) {
847
+ const ws = new WebSocket(url);
848
+ const closed = new Promise((resolve) => {
849
+ ws.addEventListener("close", () => resolve(), { once: true });
850
+ });
851
+ const opened = await new Promise((resolve) => {
852
+ ws.addEventListener("open", () => resolve(true), { once: true });
853
+ ws.addEventListener("error", () => resolve(false), { once: true });
854
+ });
855
+ if (opened) {
856
+ attempt = 0;
857
+ say(`${green("●")} sharing this machine's ssh-agent with ${config.url}`);
858
+ say(dim(` agent: ${agentSock} · signature requests print here · Ctrl-C stops sharing`));
859
+ ws.addEventListener("message", (event) => {
860
+ const text = typeof event.data === "string"
861
+ ? event.data
862
+ : Buffer.from(event.data).toString("utf8");
863
+ let frame;
864
+ try {
865
+ frame = JSON.parse(text);
866
+ }
867
+ catch {
868
+ return;
869
+ }
870
+ if (frame.t !== "req" ||
871
+ typeof frame.id !== "number" ||
872
+ typeof frame.payload !== "string") {
873
+ return;
874
+ }
875
+ const id = frame.id;
876
+ const payload = Buffer.from(frame.payload, "base64");
877
+ const type = payload[4];
878
+ const context = typeof frame.context === "string" ? frame.context : "mend";
879
+ chain = chain.then(async () => {
880
+ const started = Date.now();
881
+ if (type === SSH_AGENTC_SIGN_REQUEST) {
882
+ say(`${amber("✎")} signature requested by mend ${dim(`(${context})`)}`);
883
+ say(dim(" waiting — touch your key if it blinks (up to 60s)…"));
884
+ }
885
+ else if (type === SSH_AGENTC_REQUEST_IDENTITIES) {
886
+ say(dim(` identities requested (${context})`));
887
+ }
888
+ try {
889
+ const response = await askLocalAgent(payload);
890
+ if (ws.readyState !== WebSocket.OPEN)
891
+ return null;
892
+ ws.send(JSON.stringify({ t: "res", id, payload: response.toString("base64") }));
893
+ if (type === SSH_AGENTC_SIGN_REQUEST) {
894
+ say(`${green("✓")} signed ${dim(`(${((Date.now() - started) / 1000).toFixed(1)}s)`)}`);
895
+ }
896
+ }
897
+ catch (error) {
898
+ const message = error instanceof Error ? error.message : String(error);
899
+ if (ws.readyState === WebSocket.OPEN) {
900
+ ws.send(JSON.stringify({ t: "err", id, message }));
901
+ }
902
+ if (type === SSH_AGENTC_SIGN_REQUEST)
903
+ say(`${amber("✗")} not signed — ${message}`);
904
+ }
905
+ return null;
906
+ });
907
+ });
908
+ }
909
+ await closed;
910
+ attempt += 1;
911
+ const delay = Math.min(30_000, 1000 * 2 ** Math.min(attempt - 1, 5));
912
+ say(dim(`not connected — retrying in ${Math.round(delay / 1000)}s (Ctrl-C stops)`));
913
+ await new Promise((resolve) => setTimeout(resolve, delay));
914
+ }
915
+ };
916
+ const keysCommand = async (config, args) => {
917
+ const [verb] = args;
918
+ switch (verb) {
919
+ case "init":
920
+ return keysInit(config);
921
+ case "share":
922
+ return keysShare(config);
923
+ case "show":
924
+ case undefined:
925
+ return keysShow(config);
926
+ default:
927
+ return fail(`unknown keys command "${verb}" — try: mend keys init | show | share`);
928
+ }
929
+ };
930
+ const formatDotfileBytes = (bytes) => bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`;
931
+ const dotfilesShow = async (config) => {
932
+ const dotfiles = await api(config, "GET", "/dotfiles");
933
+ if (dotfiles.repository === null) {
934
+ say(`repo ${dim("none")}`);
935
+ }
936
+ else {
937
+ const branch = dotfiles.repository.ref ?? "default branch";
938
+ const bootstrap = dotfiles.repository.bootstrap ? "install.sh on" : "install.sh off";
939
+ say(`repo ${dotfiles.repository.url} ${dim(`(${branch} · ${bootstrap})`)}`);
940
+ }
941
+ if (dotfiles.snapshot === null) {
942
+ say(`snapshot ${dim("none — sync from this machine: mend dotfiles sync --all")}`);
943
+ return;
944
+ }
945
+ const snapshot = dotfiles.snapshot;
946
+ say(`snapshot ${snapshot.files.length} file${snapshot.files.length === 1 ? "" : "s"} · from ${snapshot.source} · ${dim(snapshot.sha.slice(0, 7))}`);
947
+ for (const file of snapshot.files) {
948
+ say(` ${file.path.padEnd(36)} ${dim(formatDotfileBytes(file.bytes))}`);
949
+ }
950
+ };
951
+ /**
952
+ * `mend dotfiles sync` — capture home files ON THIS MACHINE and stream them into the server's
953
+ * per-user dotfiles store. This is the whole point of the store: the server may be a VPS whose
954
+ * home directory belongs to a service account, so contents are read here, where they live.
955
+ */
956
+ const dotfilesSync = async (config, args) => {
957
+ const all = args.includes("--all");
958
+ const requestedPaths = args.filter((arg) => !arg.startsWith("--"));
959
+ const home = os.homedir();
960
+ if (!all && requestedPaths.length === 0) {
961
+ const found = scanDotfileCandidates(home);
962
+ if (found.length === 0) {
963
+ say(dim("no known config files found under ~"));
964
+ return;
965
+ }
966
+ let group = "";
967
+ for (const entry of found) {
968
+ if (entry.group !== group) {
969
+ group = entry.group;
970
+ say(dim(group));
971
+ }
972
+ say(` ${entry.path.padEnd(36)} ${dim(formatDotfileBytes(entry.bytes))}`);
973
+ }
974
+ say("");
975
+ say(`sync everything with ${cobalt("mend dotfiles sync --all")}, or pick: ${cobalt("mend dotfiles sync .zshrc .gitconfig")}`);
976
+ return;
977
+ }
978
+ const selected = all ? scanDotfileCandidates(home).map((entry) => entry.path) : requestedPaths;
979
+ if (selected.length === 0)
980
+ return fail("nothing to sync — no known config files found under ~");
981
+ const read = readSyncFiles(home, selected);
982
+ if ("error" in read)
983
+ return fail(read.error);
984
+ const result = await api(config, "POST", "/dotfiles/snapshot", {
985
+ files: read.files,
986
+ source: os.hostname(),
987
+ merge: false,
988
+ });
989
+ const snapshot = result.snapshot;
990
+ if (snapshot === null)
991
+ return fail("the server accepted the sync but reports no snapshot");
992
+ say(`${green("synced")} ${snapshot.files.length} file${snapshot.files.length === 1 ? "" : "s"} from ${os.hostname()} · ${dim(snapshot.sha.slice(0, 7))} ${dim("— applies from the next session launch")}`);
993
+ };
994
+ const dotfilesCommand = async (config, args) => {
995
+ const [verb, ...rest] = args;
996
+ switch (verb) {
997
+ case "sync":
998
+ return dotfilesSync(config, rest);
999
+ case "show":
1000
+ case undefined:
1001
+ return dotfilesShow(config);
1002
+ default:
1003
+ return fail(`unknown dotfiles command "${verb}" — try: mend dotfiles sync | show`);
1004
+ }
1005
+ };
1006
+ // ─── completions: live sessions under TAB ───────────────────────────────────
1007
+ /**
1008
+ * The data half of shell completion: one live session per line as
1009
+ * `id<TAB>description`. Scripts adapt the shape (zsh wants `id:desc`, bash
1010
+ * wants bare ids). Never fails — a dead server just completes nothing.
1011
+ */
1012
+ const completeCommand = async (config, args) => {
1013
+ if (args[0] !== "session")
1014
+ return;
1015
+ try {
1016
+ const [sessions, projects] = await Promise.all([
1017
+ request(config, "GET", "/sessions"),
1018
+ request(config, "GET", "/projects"),
1019
+ ]);
1020
+ const nameById = new Map(projects.map((p) => [p.id, p.name]));
1021
+ for (const session of sessions) {
1022
+ const project = nameById.get(session.projectId) ?? "";
1023
+ process.stdout.write(`${session.id}\t${session.harness} · ${project} · ${session.branch}\n`);
1024
+ }
1025
+ }
1026
+ catch {
1027
+ // Completion must never surface an error into the user's TAB press.
1028
+ }
1029
+ };
1030
+ const ZSH_COMPLETIONS = `#compdef mend
1031
+ _mend() {
1032
+ local -a commands
1033
+ commands=(
1034
+ 'adopt:adopt a repository into the store'
1035
+ 'codex:new session + codex' 'claude:new session + claude' 'opencode:new session + opencode'
1036
+ 'run:new session + arbitrary command'
1037
+ 'attach:reattach to a running session' 'shell:open a shell in a live session workspace'
1038
+ 'service:reachable ports — add, list, stop'
1039
+ 'keys:the machine Mend deploy key — init, show, share'
1040
+ 'continue:resume with the pending follow-up' 'resume:rejoin a settled session'
1041
+ 'rejoin:attach if live, otherwise resume'
1042
+ 'projects:adopted projects' 'sessions:sessions with review facts' 'status:active sessions'
1043
+ 'ui:the dashboard' 'help:help'
1044
+ )
1045
+ if (( CURRENT == 2 )); then
1046
+ _describe 'command' commands
1047
+ return
1048
+ fi
1049
+ case $words[2] in
1050
+ shell|attach|continue|resume|rejoin)
1051
+ local -a sessions
1052
+ sessions=(\${(f)"$(command mend __complete session 2>/dev/null | tr '\\t' ':')"})
1053
+ (( \${#sessions} )) && _describe 'session' sessions
1054
+ ;;
1055
+ esac
1056
+ }
1057
+ _mend "$@"
1058
+ `;
1059
+ const BASH_COMPLETIONS = `_mend() {
1060
+ local cur=\${COMP_WORDS[COMP_CWORD]}
1061
+ if [ "$COMP_CWORD" -eq 1 ]; then
1062
+ COMPREPLY=( $(compgen -W "adopt codex claude opencode run attach shell service keys continue resume rejoin projects sessions status ui help" -- "$cur") )
1063
+ return
1064
+ fi
1065
+ case \${COMP_WORDS[1]} in
1066
+ shell|attach|continue|resume|rejoin)
1067
+ COMPREPLY=( $(compgen -W "$(command mend __complete session 2>/dev/null | cut -f1)" -- "$cur") )
1068
+ ;;
1069
+ esac
1070
+ }
1071
+ complete -F _mend mend
1072
+ `;
1073
+ /** Print the hook for the named shell; the user wires it into their rc file. */
1074
+ const completionsCommand = (args) => {
1075
+ switch (args[0]) {
1076
+ case "zsh":
1077
+ process.stdout.write(ZSH_COMPLETIONS);
1078
+ return;
1079
+ case "bash":
1080
+ process.stdout.write(BASH_COMPLETIONS);
1081
+ return;
1082
+ default:
1083
+ return fail('usage: mend completions zsh|bash — e.g. mend completions zsh > "$fpath[1]/_mend"');
1084
+ }
1085
+ };
244
1086
  // ─── supervised run: platform workspace + PTY + record ──────────────────────
245
1087
  const supervisedRun = async (config, session, argv) => {
246
1088
  const launched = await withSpinner("provisioning workspace — a first launch builds the harness image (can take minutes)…", api(config, "POST", `/sessions/${session.id}/launch`, { argv }));
@@ -299,12 +1141,6 @@ const supervisedRun = async (config, session, argv) => {
299
1141
  say(`${cobalt(" review")} · ${config.url}/sessions/${session.id}`);
300
1142
  process.exit(settled === "failed" ? 1 : 0);
301
1143
  };
302
- /** How each harness takes an instruction as its opening prompt. */
303
- const CONTINUE_COMMANDS = {
304
- codex: (instruction) => ["codex", instruction],
305
- claude: (instruction) => ["claude", instruction],
306
- opencode: (instruction) => ["opencode", "run", instruction],
307
- };
308
1144
  /**
309
1145
  * The second half of the review loop (plan §7.3): find the session with a
310
1146
  * pending follow-up, deliver it (which reopens the session), and relaunch the
@@ -364,17 +1200,13 @@ const continueSession = async (config, args) => {
364
1200
  // the web session page shows the live terminal throughout.
365
1201
  const argv = build(followUp.instruction);
366
1202
  await withSpinner("provisioning workspace — the resumed run records like any other…", api(config, "POST", `/sessions/${session.id}/launch`, { argv }));
367
- say(`${green("✓ recording")} · workspace mounts the worktree · detach: ${dim("Ctrl+]")}`);
368
- say("");
369
- await attachTty(config, session.id, 0n);
370
- const settled = await api(config, "GET", `/sessions/${session.id}`);
1203
+ say(`${green("✓ recording")} · workspace mounts the worktree${detachHint()}`);
371
1204
  say("");
372
- say(`${green("✓")} session ${settled.session.status} · recorded`);
373
- say(`${cobalt(" review")} · ${config.url}/sessions/${session.id}`);
374
- process.exit(0);
1205
+ await attachOrExit(config, session.id, session.harness);
1206
+ exitAfterSessionEnd(config, session.id);
375
1207
  };
376
1208
  // ─── resume: rejoin a session — same worktree, restored harness state ───────
377
- const ACTIVE_STATUSES = new Set(["starting", "running", "waiting", "idle"]);
1209
+ const ACTIVE_STATUSES = LIVE_STATUSES;
378
1210
  /**
379
1211
  * Sessions are continuous work, not runs: `mend resume` rejoins one on a
380
1212
  * fresh workspace — saved harness state restored, a claude resume is native
@@ -401,39 +1233,273 @@ const resumeCommand = async (config, args) => {
401
1233
  say(`${green("✓")} resuming ${match.harness} · ${dim(match.id.slice(0, 8))}${withHarness === null ? "" : ` ${dim("as")} ${withHarness}`}`);
402
1234
  say(`${cobalt(" watch")} · ${config.url}/sessions/${match.id}`);
403
1235
  await withSpinner("resuming — a fresh workspace restores the saved session state…", api(config, "POST", `/sessions/${match.id}/resume`, { harness: withHarness }));
404
- say(`${green("✓ recording")} · same worktree, conversation restored · detach: ${dim("Ctrl+]")}`);
1236
+ say(`${green("✓ recording")} · same worktree, conversation restored${detachHint()}`);
405
1237
  say("");
406
- await attachTty(config, match.id, 0n);
407
- const settled = await api(config, "GET", `/sessions/${match.id}`);
1238
+ await attachOrExit(config, match.id, withHarness ?? match.harness);
1239
+ exitAfterSessionEnd(config, match.id);
1240
+ };
1241
+ // ─── rejoin: one entrypoint for an outer multiplexer ────────────────────────
1242
+ /**
1243
+ * Idempotent entrypoint for outer multiplexers: attach when the session is
1244
+ * live, otherwise restore and resume it before attaching. With no id, the
1245
+ * newest live session wins, falling back to the newest settled session;
1246
+ * --harness narrows the choice.
1247
+ */
1248
+ const resumeForRejoin = async (config, sessionId, label) => {
1249
+ try {
1250
+ await withSpinner(label, request(config, "POST", `/sessions/${sessionId}/resume`, { harness: null }));
1251
+ return true;
1252
+ }
1253
+ catch (error) {
1254
+ const refreshed = await api(config, "GET", `/sessions/${sessionId}`);
1255
+ if (ACTIVE_STATUSES.has(refreshed.session.status))
1256
+ return false;
1257
+ return fail(error instanceof Error ? error.message : String(error));
1258
+ }
1259
+ };
1260
+ const rejoinCommand = async (config, args) => {
1261
+ const harnessFlag = args.indexOf("--harness");
1262
+ const harness = harnessFlag !== -1 && args[harnessFlag + 1] !== undefined
1263
+ ? String(args[harnessFlag + 1])
1264
+ : null;
1265
+ const prefix = args.find((arg, index) => !arg.startsWith("--") && index !== harnessFlag + 1);
1266
+ const project = await findProject(config, null);
1267
+ const detail = await api(config, "GET", `/projects/${project.id}`);
1268
+ const eligible = detail.sessions
1269
+ .filter((session) => harness === null || session.harness === harness)
1270
+ .toSorted((left, right) => right.createdAt.localeCompare(left.createdAt));
1271
+ const newest = eligible.find((session) => ACTIVE_STATUSES.has(session.status)) ?? eligible[0];
1272
+ const matches = prefix === undefined
1273
+ ? newest === undefined
1274
+ ? []
1275
+ : [newest]
1276
+ : eligible.filter((session) => session.id.startsWith(prefix));
1277
+ if (matches.length === 0) {
1278
+ const harnessDescription = harness === null ? "" : ` ${harness}`;
1279
+ return fail(prefix === undefined
1280
+ ? `no${harnessDescription} session to rejoin in project ${project.name}`
1281
+ : `no${harnessDescription} session matches "${prefix}" in project ${project.name}`);
1282
+ }
1283
+ if (matches.length > 1) {
1284
+ return fail(`session prefix "${prefix}" is ambiguous — use more of the id`);
1285
+ }
1286
+ const session = matches[0];
1287
+ if (session === undefined)
1288
+ return fail("session selection failed");
1289
+ say(`${green("✓")} rejoining ${session.harness} · ${dim(session.id.slice(0, 8))} · ${ACTIVE_STATUSES.has(session.status) ? "already live" : "restoring"}`);
1290
+ say(`${cobalt(" watch")} · ${config.url}/sessions/${session.id}`);
1291
+ let restored = false;
1292
+ if (!ACTIVE_STATUSES.has(session.status)) {
1293
+ restored = await resumeForRejoin(config, session.id, "resuming — a fresh workspace restores the saved session state…");
1294
+ }
1295
+ say(restored
1296
+ ? `${green("✓ recording")} · same worktree, conversation restored${detachHint()}`
1297
+ : `${green("✓ recording")} · attached to the live session${detachHint()}`);
408
1298
  say("");
409
- say(`${green("✓")} session ${settled.session.status} · recorded`);
410
- say(`${cobalt(" review")} · ${config.url}/sessions/${match.id}`);
411
- process.exit(0);
1299
+ let outcome = await attachTty(config, session.id, session.harness, 0n);
1300
+ if (outcome === "unavailable") {
1301
+ const refreshed = await api(config, "GET", `/sessions/${session.id}`);
1302
+ if (!ACTIVE_STATUSES.has(refreshed.session.status)) {
1303
+ await resumeForRejoin(config, session.id, "session settled while attaching — restoring it once…");
1304
+ }
1305
+ outcome = await attachTty(config, session.id, session.harness, 0n);
1306
+ }
1307
+ finishAttach(config, session.id, outcome);
1308
+ exitAfterSessionEnd(config, session.id);
412
1309
  };
413
- // ─── status ─────────────────────────────────────────────────────────────────
414
- const status = async (config) => {
415
- const sessions = await api(config, "GET", "/sessions");
416
- if (sessions.length === 0) {
417
- say(dim("no active sessions"));
1310
+ // ─── projects · sessions: the workbench at a glance ─────────────────────────
1311
+ const projectsCommand = async (config) => {
1312
+ const [projects, active] = await Promise.all([
1313
+ api(config, "GET", "/projects"),
1314
+ api(config, "GET", "/sessions"),
1315
+ ]);
1316
+ if (projects.length === 0) {
1317
+ say(dim("no adopted projects — mend adopt brings one in"));
418
1318
  return;
419
1319
  }
420
- for (const session of sessions) {
421
- say(`${session.harness} ${dim(session.id.slice(0, 8))} ${session.status} ${dim(session.branch)}`);
1320
+ const liveByProject = new Map();
1321
+ for (const session of active) {
1322
+ liveByProject.set(session.projectId, (liveByProject.get(session.projectId) ?? 0) + 1);
422
1323
  }
1324
+ const nameWidth = Math.max(...projects.map((p) => p.name.length));
1325
+ const branchWidth = Math.max(...projects.map((p) => p.defaultBranch.length));
1326
+ for (const project of projects) {
1327
+ const live = liveByProject.get(project.id) ?? 0;
1328
+ const liveLabel = live > 0 ? green(`${live} live`) : dim("—");
1329
+ say(`${project.name.padEnd(nameWidth)} ${dim(project.defaultBranch.padEnd(branchWidth))} ${liveLabel} ${dim(project.storePath)}`);
1330
+ }
1331
+ };
1332
+ const printSessionRow = (row) => {
1333
+ const { session, annotation } = row;
1334
+ const live = ACTIVE_STATUSES.has(session.status);
1335
+ const status = session.status.padEnd(9);
1336
+ const facts = [];
1337
+ if (session.label !== null)
1338
+ facts.push(session.label);
1339
+ if (annotation !== undefined && annotation.openComments > 0) {
1340
+ facts.push(amber(`${annotation.openComments} open`));
1341
+ }
1342
+ if (annotation !== undefined && annotation.pendingFollowUp)
1343
+ facts.push(amber("follow-up pending"));
1344
+ say(`${session.harness.padEnd(8)} ${dim(session.id.slice(0, 8))} ${live ? green(status) : dim(status)} ${row.projectName} ${dim(session.branch)}${facts.length > 0 ? ` ${facts.join(dim(" · "))}` : ""}`);
1345
+ };
1346
+ /**
1347
+ * Active sessions by default; --all sweeps every project's detail so settled
1348
+ * sessions arrive with their review facts (open comments, pending follow-up).
1349
+ */
1350
+ const sessionsCommand = async (config, args) => {
1351
+ const all = args.includes("--all");
1352
+ const json = args.includes("--json");
1353
+ const projectFlag = args.indexOf("--project");
1354
+ const projectName = projectFlag !== -1 && args[projectFlag + 1] !== undefined
1355
+ ? String(args[projectFlag + 1])
1356
+ : null;
1357
+ const projects = await api(config, "GET", "/projects");
1358
+ const scope = projectName === null ? projects : projects.filter((p) => p.name === projectName);
1359
+ if (projectName !== null && scope.length === 0) {
1360
+ return fail(`no adopted project named "${projectName}"`);
1361
+ }
1362
+ let rows;
1363
+ if (all || projectName !== null) {
1364
+ const details = await Promise.all(scope.map((p) => api(config, "GET", `/projects/${p.id}`)));
1365
+ rows = details.flatMap((detail) => detail.sessions.map((session) => ({
1366
+ session,
1367
+ projectName: detail.project.name,
1368
+ annotation: detail.annotations.find((a) => a.sessionId === session.id),
1369
+ })));
1370
+ }
1371
+ else {
1372
+ const active = await api(config, "GET", "/sessions");
1373
+ const nameById = new Map(projects.map((p) => [p.id, p.name]));
1374
+ rows = active.map((session) => ({
1375
+ session,
1376
+ projectName: nameById.get(session.projectId) ?? session.projectId.slice(0, 8),
1377
+ annotation: undefined,
1378
+ }));
1379
+ }
1380
+ if (rows.length === 0) {
1381
+ if (json) {
1382
+ say(JSON.stringify({ version: 1, sessions: [] }, null, 2));
1383
+ return;
1384
+ }
1385
+ say(dim(all ? "no sessions" : "no active sessions — mend sessions --all includes settled ones"));
1386
+ return;
1387
+ }
1388
+ rows.sort((a, b) => {
1389
+ const aLive = ACTIVE_STATUSES.has(a.session.status) ? 1 : 0;
1390
+ const bLive = ACTIVE_STATUSES.has(b.session.status) ? 1 : 0;
1391
+ if (aLive !== bLive)
1392
+ return bLive - aLive;
1393
+ return b.session.createdAt.localeCompare(a.session.createdAt);
1394
+ });
1395
+ if (json) {
1396
+ const payload = {
1397
+ version: 1,
1398
+ sessions: rows.map(({ session, projectName: rowProjectName, annotation }) => ({
1399
+ id: session.id,
1400
+ projectId: session.projectId,
1401
+ projectName: rowProjectName,
1402
+ harness: session.harness,
1403
+ label: session.label,
1404
+ worktree: session.worktree,
1405
+ branch: session.branch,
1406
+ baseSha: session.baseSha,
1407
+ status: session.status,
1408
+ summary: session.summary,
1409
+ createdAt: session.createdAt,
1410
+ reviewUrl: `${config.url.replace(/\/$/, "")}/sessions/${session.id}`,
1411
+ review: annotation === undefined
1412
+ ? null
1413
+ : {
1414
+ openComments: annotation.openComments,
1415
+ totalComments: annotation.totalComments,
1416
+ pendingFollowUp: annotation.pendingFollowUp,
1417
+ },
1418
+ })),
1419
+ };
1420
+ say(JSON.stringify(payload, null, 2));
1421
+ return;
1422
+ }
1423
+ for (const row of rows)
1424
+ printSessionRow(row);
1425
+ };
1426
+ // ─── dashboard: the live TUI on bare `mend` ─────────────────────────────────
1427
+ const hasNodeFfi = () => {
1428
+ try {
1429
+ createRequire(import.meta.url)("node:ffi");
1430
+ return true;
1431
+ }
1432
+ catch {
1433
+ return false;
1434
+ }
1435
+ };
1436
+ /**
1437
+ * The dashboard needs @opentui/core, whose Node backend binds the native
1438
+ * renderer over node:ffi — present from Node 26, and only behind
1439
+ * --experimental-ffi. Gate here and re-exec the same argv with the flag so
1440
+ * the user never types it; every other command stays on plain Node >= 22.
1441
+ */
1442
+ const dashboard = async (config) => {
1443
+ if (process.stdout.isTTY !== true) {
1444
+ say(HELP);
1445
+ return;
1446
+ }
1447
+ if (!hasNodeFfi()) {
1448
+ const major = Number(process.versions.node.split(".")[0]);
1449
+ if (Number.isNaN(major) || major < 26) {
1450
+ return fail(`the dashboard needs Node >= 26 (node:ffi) — this is ${process.version}; every other command still works`);
1451
+ }
1452
+ const rerun = spawnSync(process.execPath, ["--experimental-ffi", "--disable-warning=ExperimentalWarning", ...process.argv.slice(1)], { stdio: "inherit" });
1453
+ process.exit(rerun.status ?? 0);
1454
+ }
1455
+ const { runDashboard } = await import("./dashboard.js");
1456
+ await runDashboard({
1457
+ config,
1458
+ cwd: process.cwd(),
1459
+ api: (method, route, body) => request(config, method, route, body),
1460
+ attachTty: (sessionId, harness) => attachTty(config, sessionId, harness, 0n),
1461
+ });
423
1462
  };
424
1463
  // ─── entry ──────────────────────────────────────────────────────────────────
425
1464
  const HELP = `mend — the agent workbench
426
1465
 
427
- mend adopt [source] [--name <name>] adopt a repository into the store (default: cwd)
1466
+ mend the dashboard: every project and session, live
1467
+ mend adopt [source] [--name <name>] [--auth ambient|mend-key|bridge]
1468
+ adopt a repository into the store (default: cwd; any git
1469
+ URL — GitHub, GitLab, self-hosted, ssh://, a local path)
1470
+ mend keys init generate the machine's Mend deploy key (ed25519)
1471
+ mend keys show print the public key — add it as a deploy key on your git host
1472
+ mend dotfiles your dotfiles on the server: repo + synced home files
1473
+ mend dotfiles sync [--all | paths…] capture config files from THIS machine into your store
1474
+ mend keys share relay THIS machine's ssh-agent to the server (bridge mode:
1475
+ hardware keys sign here; Ctrl-C stops sharing)
428
1476
  mend codex|claude|opencode new session worktree + launch the harness in it
429
1477
  mend run -- <command...> same, with an arbitrary command
430
1478
  mend attach <session-id-prefix> reattach this terminal to a running session
1479
+ mend shell [session-id-prefix] open a shell in a live session's workspace
1480
+ mend service run [session] --port <p> [--name <n>] [--udp] -- <command...>
1481
+ start + supervise a server in the session workspace
1482
+ mend service run [session] <name> start a declared Service (mend.toml recipe)
1483
+ mend service <name> shorthand for the above
1484
+ mend service init [--yes] scaffold mend.toml from package.json + compose ports
1485
+ mend service add [session] <port> [--name <n>] [--udp]
1486
+ adopt a listening workspace port — reachable on this machine
1487
+ mend service list every live service and its observed state
1488
+ mend service logs <name-or-id> follow a supervised service's output (replay, then live)
1489
+ mend service restart <name-or-id> re-run its recorded command — same URL
1490
+ mend service stop <name-or-id> stop a service (closes its host port)
431
1491
  mend continue [session-id] resume a session with its pending review follow-up
432
1492
  mend resume [session-id] [--with h] rejoin a settled session (state restored; --with switches harness)
433
- mend status active sessions
1493
+ mend rejoin [session-id] [--harness h] attach if live, otherwise resume; newest live wins
1494
+ mend projects adopted projects and their live sessions
1495
+ mend sessions [--all] [--project p] [--json]
1496
+ sessions with review facts; JSON is stable for integrations
1497
+ mend status active sessions (alias of mend sessions)
1498
+ mend completions zsh|bash print the TAB-completion hook (live session ids under TAB)
434
1499
 
435
1500
  server: MEND_URL (default http://localhost:3105) · auth: MEND_TOKEN
436
- config file: ~/.mend/cli.json { "url": ..., "token": ... }
1501
+ detach key: Ctrl+] (set MEND_DETACH_KEY=none when an outer multiplexer owns detaching)
1502
+ config file: ~/.config/mend/cli.json { "url": ..., "token": ... }
437
1503
  `;
438
1504
  const main = async () => {
439
1505
  const [command, ...rest] = process.argv.slice(2);
@@ -448,13 +1514,32 @@ const main = async () => {
448
1514
  return launch(config, command, rest);
449
1515
  case "attach":
450
1516
  return attach(config, rest);
1517
+ case "shell":
1518
+ return shellCommand(config, rest);
1519
+ case "service":
1520
+ return serviceCommand(config, rest);
1521
+ case "keys":
1522
+ return keysCommand(config, rest);
1523
+ case "dotfiles":
1524
+ return dotfilesCommand(config, rest);
1525
+ case "completions":
1526
+ return completionsCommand(rest);
1527
+ case "__complete":
1528
+ return completeCommand(config, rest);
451
1529
  case "continue":
452
1530
  return continueSession(config, rest);
453
1531
  case "resume":
454
1532
  return resumeCommand(config, rest);
1533
+ case "rejoin":
1534
+ return rejoinCommand(config, rest);
1535
+ case "projects":
1536
+ return projectsCommand(config);
1537
+ case "sessions":
455
1538
  case "status":
456
- return status(config);
1539
+ return sessionsCommand(config, rest);
457
1540
  case undefined:
1541
+ case "ui":
1542
+ return dashboard(config);
458
1543
  case "help":
459
1544
  case "--help":
460
1545
  say(HELP);