@sealant/mend 0.1.1 → 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/README.md CHANGED
@@ -33,12 +33,12 @@ mend status active sessions (alias of mend sessions)
33
33
 
34
34
  ## Configuration
35
35
 
36
- | Source | What |
37
- | ------------------ | ------------------------------------------------- |
38
- | `MEND_URL` | The Mend server (default `http://localhost:3105`) |
39
- | `MEND_TOKEN` | Bearer token for that server |
40
- | `MEND_DETACH_KEY` | Set to `none` when an outer multiplexer detaches |
41
- | `~/.mend/cli.json` | `{ "url": ..., "token": ... }` — env vars win |
36
+ | Source | What |
37
+ | ------------------------- | ----------------------------------------------------------------------------------------- |
38
+ | `MEND_URL` | The Mend server (default `http://localhost:3105`) |
39
+ | `MEND_TOKEN` | Bearer token for that server |
40
+ | `MEND_DETACH_KEY` | Set to `none` when an outer multiplexer detaches |
41
+ | `~/.config/mend/cli.json` | `{ "url": ..., "token": ... }` — env vars win; a pre-XDG `~/.mend/cli.json` keeps working |
42
42
 
43
43
  ## Herdr
44
44
 
package/dist/dashboard.js CHANGED
@@ -43,8 +43,21 @@ const timeAgo = (iso) => {
43
43
  const WORKBENCH_KEY = ["workbench"];
44
44
  const fetchWorkbench = async (ctx) => {
45
45
  const projects = await ctx.api("GET", "/projects");
46
- const fetched = await Promise.all(projects.map((project) => ctx.api("GET", `/projects/${project.id}`)));
47
- return { projects, details: new Map(fetched.map((detail) => [detail.project.id, detail])) };
46
+ const [fetched, services] = await Promise.all([
47
+ Promise.all(projects.map((project) => ctx.api("GET", `/projects/${project.id}`))),
48
+ ctx.api("GET", "/services"),
49
+ ]);
50
+ const servicesBySession = new Map();
51
+ for (const service of services) {
52
+ const bucket = servicesBySession.get(service.sessionId) ?? [];
53
+ bucket.push(service);
54
+ servicesBySession.set(service.sessionId, bucket);
55
+ }
56
+ return {
57
+ projects,
58
+ details: new Map(fetched.map((detail) => [detail.project.id, detail])),
59
+ servicesBySession,
60
+ };
48
61
  };
49
62
  const itemKey = (item) => item.kind === "session"
50
63
  ? item.session.id
@@ -84,6 +97,7 @@ const deriveItems = (view, data) => {
84
97
  kind: "session",
85
98
  session,
86
99
  annotation: detail.annotations.find((a) => a.sessionId === session.id),
100
+ services: data.servicesBySession.get(session.id) ?? [],
87
101
  }));
88
102
  }
89
103
  const resuming = view.session;
@@ -100,7 +114,9 @@ const deriveItems = (view, data) => {
100
114
  kind: "harness",
101
115
  harness,
102
116
  label: harness,
103
- hint: "the conversation crosses as a distilled prompt",
117
+ hint: harness === "shell"
118
+ ? "a bash in the worktree — resume either agent from inside"
119
+ : "the conversation crosses as a distilled prompt",
104
120
  })),
105
121
  ];
106
122
  }
@@ -108,7 +124,9 @@ const deriveItems = (view, data) => {
108
124
  kind: "harness",
109
125
  harness,
110
126
  label: harness,
111
- hint: `mend ${harness} — new worktree, recorded session`,
127
+ hint: harness === "shell"
128
+ ? "a plain bash session — new worktree, recorded"
129
+ : `mend ${harness} — new worktree, recorded session`,
112
130
  }));
113
131
  };
114
132
  // ─── rows: JSX spans instead of StyledText chunks ───────────────────────────
@@ -122,13 +140,20 @@ const Facts = ({ facts }) => {
122
140
  _jsxs("span", { children: [index > 0 ? _jsx("span", { fg: FAINT, children: " \u00B7 " }) : null, fact] }, index))) }));
123
141
  };
124
142
  const SessionRow = ({ item, selected, }) => {
125
- const { session, annotation } = item;
143
+ const { session, annotation, services } = item;
126
144
  const color = STATUS_COLOR[session.status] ?? MUTED;
127
145
  const glyph = STATUS_GLYPH[session.status] ?? "·";
128
146
  const facts = [];
129
147
  const age = timeAgo(session.createdAt);
130
148
  if (age !== "")
131
149
  facts.push(_jsx("span", { fg: FAINT, children: age }));
150
+ // What runs right now: name :port → host port, observed state colored.
151
+ for (const service of services.slice(0, 2)) {
152
+ facts.push(_jsx("span", { fg: service.status === "reachable" ? GREEN : AMBER, children: `${service.label ?? service.id.slice(0, 6)} :${service.workspacePort ?? "?"}${service.protocol === "udp" ? "u" : ""}→${service.hostPort ?? "?"} ${service.status}` }));
153
+ }
154
+ if (services.length > 2) {
155
+ facts.push(_jsx("span", { fg: FAINT, children: `+${services.length - 2} services` }));
156
+ }
132
157
  if (session.label !== null)
133
158
  facts.push(_jsx("span", { fg: MUTED, children: session.label }));
134
159
  if (annotation !== undefined && annotation.openComments > 0) {
@@ -0,0 +1,90 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ /**
4
+ * Local dotfiles scanning for `mend dotfiles sync`. This runs on the machine that HAS the
5
+ * files — the whole point: the Mend server may be a VPS whose home directory is a service
6
+ * account's, so contents are captured here and streamed to the server's per-user dotfiles store.
7
+ *
8
+ * The candidate list mirrors the product's curated set (plain configuration only — never keys,
9
+ * credential stores, or histories). The CLI stays dependency-light by design, so the list is
10
+ * duplicated here rather than imported from a workspace package.
11
+ */
12
+ export const DOTFILE_CANDIDATES = [
13
+ {
14
+ group: "shell",
15
+ paths: [
16
+ ".zshrc",
17
+ ".zshenv",
18
+ ".zprofile",
19
+ ".bashrc",
20
+ ".bash_profile",
21
+ ".profile",
22
+ ".aliases",
23
+ ".config/fish/config.fish",
24
+ ".config/starship.toml",
25
+ ],
26
+ },
27
+ {
28
+ group: "git",
29
+ paths: [".gitconfig", ".gitignore_global", ".config/git/config", ".config/git/ignore"],
30
+ },
31
+ {
32
+ group: "editors",
33
+ paths: [".vimrc", ".ideavimrc", ".editorconfig", ".config/helix/config.toml"],
34
+ },
35
+ {
36
+ group: "terminal",
37
+ paths: [".tmux.conf", ".config/tmux/tmux.conf", ".inputrc", ".dir_colors"],
38
+ },
39
+ {
40
+ group: "tools",
41
+ paths: [
42
+ ".ripgreprc",
43
+ ".config/bat/config",
44
+ ".config/lazygit/config.yml",
45
+ ".config/mise/config.toml",
46
+ ".config/atuin/config.toml",
47
+ ".config/zellij/config.kdl",
48
+ ],
49
+ },
50
+ ];
51
+ /** The per-file cap the server enforces; checked here so a mistake fails before the upload. */
52
+ export const MAX_FILE_BYTES = 1024 * 1024;
53
+ const statFile = (home, relative) => {
54
+ try {
55
+ const stat = fs.statSync(path.join(home, relative));
56
+ return stat.isFile() ? stat : null;
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ };
62
+ /** Probe the curated candidates under `home`; only existing regular files appear. */
63
+ export const scanDotfileCandidates = (home) => DOTFILE_CANDIDATES.flatMap(({ group, paths }) => paths.flatMap((relative) => {
64
+ const stat = statFile(home, relative);
65
+ return stat === null ? [] : [{ path: relative, group, bytes: stat.size }];
66
+ }));
67
+ /**
68
+ * Read the selected paths for upload. Explicitly requested paths must exist (a typo should fail,
69
+ * not silently sync nothing); oversized files fail with the server's own rule. Modes ride along
70
+ * so an executable script stays executable.
71
+ */
72
+ export const readSyncFiles = (home, paths_) => {
73
+ const files = [];
74
+ for (const relative of paths_) {
75
+ const stat = statFile(home, relative);
76
+ if (stat === null) {
77
+ return { error: `${relative} is not a file under ${home}` };
78
+ }
79
+ if (stat.size > MAX_FILE_BYTES) {
80
+ return { error: `${relative} is over 1MB — dotfiles are text; trim the selection` };
81
+ }
82
+ files.push({
83
+ path: relative,
84
+ contentsBase64: fs.readFileSync(path.join(home, relative)).toString("base64"),
85
+ // eslint-disable-next-line no-bitwise -- permission bits are the point
86
+ mode: (stat.mode & 0o777).toString(8),
87
+ });
88
+ }
89
+ return { files };
90
+ };
package/dist/main.js CHANGED
@@ -2,10 +2,21 @@
2
2
  import { spawn, spawnSync } from "node:child_process";
3
3
  import * as fs from "node:fs";
4
4
  import { createRequire } from "node:module";
5
+ import * as net from "node:net";
5
6
  import * as os from "node:os";
6
7
  import * as path from "node:path";
8
+ import { readSyncFiles, scanDotfileCandidates } from "./dotfiles.js";
9
+ import { isComposeFile, proposeFromCompose, proposeFromPackageJson, proposeFromWorkspacePackage, renderMendToml, workspaceGlobs, } from "./service-init.js";
7
10
  import { CONTINUE_COMMANDS, gitTopLevel, HARNESS_COMMANDS, LIVE_STATUSES, matchProjectByCwd, normalizeProjectName, } from "./shared.js";
8
- const CONFIG_PATH = path.join(os.homedir(), ".mend", "cli.json");
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");
9
20
  const loadConfig = () => {
10
21
  let fileConfig = {};
11
22
  if (fs.existsSync(CONFIG_PATH)) {
@@ -132,18 +143,35 @@ const withSpinner = async (label, work) => {
132
143
  const adopt = async (config, args) => {
133
144
  const nameFlag = args.indexOf("--name");
134
145
  const name = nameFlag !== -1 && args[nameFlag + 1] !== undefined ? String(args[nameFlag + 1]) : null;
135
- const positional = args.filter((a, i) => !a.startsWith("--") && (nameFlag === -1 || i !== nameFlag + 1));
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));
136
150
  // Bare `mend adopt` adopts the repo the cwd is inside, not the subdirectory.
137
151
  const rawSource = positional[0] ?? gitTopLevel(process.cwd()) ?? ".";
138
152
  const source = /^(https?|git|ssh):|^git@/.test(rawSource) ? rawSource : path.resolve(rawSource);
139
153
  // Derived defaults are normalized ("Mend" → "mend"); explicit --name is sent as typed.
140
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
+ }
141
161
  const project = await api(config, "POST", "/projects", {
142
162
  name: projectName,
143
163
  source,
164
+ ...(auth === null ? {} : { gitAuthMode: auth }),
144
165
  });
145
166
  say(`${green("✓")} adopted · ${project.name} · ${dim(project.storePath)}`);
146
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
+ }
147
175
  say(`${dim(" sessions start with:")} mend codex ${dim("(from anywhere —")} --project ${project.name}${dim(")")}`);
148
176
  };
149
177
  // ─── session launch ─────────────────────────────────────────────────────────
@@ -218,10 +246,15 @@ const launch = async (config, harness, args) => {
218
246
  * caller decides what each outcome means (commands exit, the dashboard
219
247
  * resumes).
220
248
  */
221
- const attachTty = async (config, sessionId, harness, from) => {
249
+ const attachTty = async (config, sessionId, harness, from, processId, options) => {
222
250
  const url = new URL(`${config.url}/api/tty`);
223
251
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
224
- 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);
225
258
  url.searchParams.set("from", from.toString());
226
259
  if (config.token !== null)
227
260
  url.searchParams.set("token", config.token);
@@ -294,12 +327,16 @@ const attachTty = async (config, sessionId, harness, from) => {
294
327
  try {
295
328
  sendResize();
296
329
  process.stdout.on("resize", onWinch);
297
- if (rawTty) {
298
- process.stdin.setRawMode(true);
299
- rawModeEnabled = true;
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);
300
339
  }
301
- process.stdin.resume();
302
- process.stdin.on("data", onKeys);
303
340
  ws.addEventListener("message", onTtyFrame);
304
341
  await finished;
305
342
  return detached ? "detached" : "ended";
@@ -350,6 +387,702 @@ const attach = async (config, args) => {
350
387
  await attachOrExit(config, match.id, match.harness);
351
388
  exitAfterSessionEnd(config, match.id);
352
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
+ }
456
+ say("");
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`);
462
+ process.exit(0);
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
+ };
353
1086
  // ─── supervised run: platform workspace + PTY + record ──────────────────────
354
1087
  const supervisedRun = async (config, session, argv) => {
355
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 }));
@@ -731,10 +1464,30 @@ const dashboard = async (config) => {
731
1464
  const HELP = `mend — the agent workbench
732
1465
 
733
1466
  mend the dashboard: every project and session, live
734
- mend adopt [source] [--name <name>] adopt a repository into the store (default: cwd)
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)
735
1476
  mend codex|claude|opencode new session worktree + launch the harness in it
736
1477
  mend run -- <command...> same, with an arbitrary command
737
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)
738
1491
  mend continue [session-id] resume a session with its pending review follow-up
739
1492
  mend resume [session-id] [--with h] rejoin a settled session (state restored; --with switches harness)
740
1493
  mend rejoin [session-id] [--harness h] attach if live, otherwise resume; newest live wins
@@ -742,10 +1495,11 @@ const HELP = `mend — the agent workbench
742
1495
  mend sessions [--all] [--project p] [--json]
743
1496
  sessions with review facts; JSON is stable for integrations
744
1497
  mend status active sessions (alias of mend sessions)
1498
+ mend completions zsh|bash print the TAB-completion hook (live session ids under TAB)
745
1499
 
746
1500
  server: MEND_URL (default http://localhost:3105) · auth: MEND_TOKEN
747
1501
  detach key: Ctrl+] (set MEND_DETACH_KEY=none when an outer multiplexer owns detaching)
748
- config file: ~/.mend/cli.json { "url": ..., "token": ... }
1502
+ config file: ~/.config/mend/cli.json { "url": ..., "token": ... }
749
1503
  `;
750
1504
  const main = async () => {
751
1505
  const [command, ...rest] = process.argv.slice(2);
@@ -760,6 +1514,18 @@ const main = async () => {
760
1514
  return launch(config, command, rest);
761
1515
  case "attach":
762
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);
763
1529
  case "continue":
764
1530
  return continueSession(config, rest);
765
1531
  case "resume":
@@ -0,0 +1,227 @@
1
+ /**
2
+ * `mend service init` — the mend.toml scaffolder (docs/SESSION-SERVICES.md).
3
+ * Static suggestion, not detection: an offline read of manifests the project
4
+ * already ships (package.json scripts, compose files), producing a proposal
5
+ * the user confirms and commits. Nothing runs; nothing is observed.
6
+ *
7
+ * Pure functions here; the command in main.ts does the I/O.
8
+ */
9
+ /** Scripts that mean "a server" in practice; dev first, the rest as extras. */
10
+ const SERVER_SCRIPTS = ["dev", "start", "serve", "preview"];
11
+ /** Tool defaults for when the script names no port. */
12
+ const TOOL_PORTS = [
13
+ [/\bvite\b(?!.*build)/, 5173],
14
+ [/\bnext dev\b/, 3000],
15
+ [/\bnuxt\b/, 3000],
16
+ [/\bastro dev\b/, 4321],
17
+ [/\bremix\b/, 3000],
18
+ ];
19
+ const portFromScript = (script) => {
20
+ const explicit = /(?:--port[= ]|(?<![\w-])-p[= ])(\d{2,5})/.exec(script) ?? /\bPORT=(\d{2,5})/.exec(script);
21
+ if (explicit?.[1] !== undefined) {
22
+ const port = Number(explicit[1]);
23
+ if (port >= 1 && port <= 65535)
24
+ return { port, guessed: false };
25
+ }
26
+ for (const [pattern, port] of TOOL_PORTS) {
27
+ if (pattern.test(script))
28
+ return { port, guessed: true };
29
+ }
30
+ return null;
31
+ };
32
+ const packageManagerOf = (lockfiles) => {
33
+ if (lockfiles.includes("pnpm-lock.yaml"))
34
+ return "pnpm";
35
+ if (lockfiles.includes("yarn.lock"))
36
+ return "yarn";
37
+ if (lockfiles.includes("bun.lockb") || lockfiles.includes("bun.lock"))
38
+ return "bun";
39
+ return "npm";
40
+ };
41
+ /** Proposals from package.json scripts. `files` = names present in the repo root. */
42
+ export const proposeFromPackageJson = (packageJsonText, rootFiles) => {
43
+ let parsed;
44
+ try {
45
+ parsed = JSON.parse(packageJsonText);
46
+ }
47
+ catch {
48
+ return [];
49
+ }
50
+ if (typeof parsed !== "object" || parsed === null)
51
+ return [];
52
+ const scripts = parsed.scripts;
53
+ if (typeof scripts !== "object" || scripts === null)
54
+ return [];
55
+ const pm = packageManagerOf(rootFiles);
56
+ const proposals = [];
57
+ for (const scriptName of SERVER_SCRIPTS) {
58
+ const script = scripts[scriptName];
59
+ if (typeof script !== "string")
60
+ continue;
61
+ const port = portFromScript(script);
62
+ if (port === null)
63
+ continue; // no port, no Service — the user can add one by hand
64
+ proposals.push({
65
+ name: scriptName === "dev" ? "web" : scriptName,
66
+ command: `${pm} run ${scriptName}`,
67
+ port: port.port,
68
+ protocol: "tcp",
69
+ guessed: port.guessed,
70
+ source: `package.json "${scriptName}": ${script}`,
71
+ });
72
+ break; // one entry from scripts — the first server-ish one wins
73
+ }
74
+ return proposals;
75
+ };
76
+ /**
77
+ * Proposals from a compose file: every service with a published port becomes
78
+ * `docker compose up <name>` at the HOST side of the first mapping — that is
79
+ * where the workspace's docker sidecar publishes it.
80
+ */
81
+ export const proposeFromCompose = (composeText) => {
82
+ // Deliberately narrow YAML reading: top-level `services:`, two-space
83
+ // indented names, `ports:` entries as "- host:container". A full YAML
84
+ // parser is not worth a dependency for a suggestion.
85
+ const proposals = [];
86
+ const lines = composeText.split("\n");
87
+ let inServices = false;
88
+ let current = null;
89
+ let inPorts = false;
90
+ for (const line of lines) {
91
+ if (/^services:\s*$/.test(line)) {
92
+ inServices = true;
93
+ continue;
94
+ }
95
+ if (inServices && /^\S/.test(line)) {
96
+ inServices = false; // left the services block
97
+ }
98
+ if (!inServices)
99
+ continue;
100
+ const serviceHead = /^ {2}([A-Za-z0-9._-]+):\s*$/.exec(line);
101
+ if (serviceHead?.[1] !== undefined) {
102
+ current = serviceHead[1];
103
+ inPorts = false;
104
+ continue;
105
+ }
106
+ if (current === null)
107
+ continue;
108
+ if (/^\s+ports:\s*$/.test(line)) {
109
+ inPorts = true;
110
+ continue;
111
+ }
112
+ if (inPorts) {
113
+ const mapping = /^\s+-\s+["']?(\d{2,5}):/.exec(line) ??
114
+ /^\s+-\s+["']?\$\{[A-Z_a-z0-9]+:-(\d{2,5})\}:/.exec(line) ??
115
+ /^\s+-\s+["']?(\d{2,5})["']?\s*$/.exec(line);
116
+ if (mapping?.[1] !== undefined) {
117
+ const port = Number(mapping[1]);
118
+ const protocol = /\/udp["']?\s*$/.test(line) ? "udp" : "tcp";
119
+ if (port >= 1 && port <= 65535 && !proposals.some((p) => p.name === current)) {
120
+ proposals.push({
121
+ name: current,
122
+ command: `docker compose up ${current}`,
123
+ port,
124
+ protocol,
125
+ guessed: false,
126
+ source: `compose service "${current}" publishes :${mapping[1]}${protocol === "udp" ? "/udp" : ""}`,
127
+ });
128
+ }
129
+ inPorts = false; // first mapping wins
130
+ continue;
131
+ }
132
+ if (!/^\s+-/.test(line))
133
+ inPorts = false;
134
+ }
135
+ }
136
+ return proposals;
137
+ };
138
+ /** Any compose flavor counts: compose.yaml, docker-compose.yml, compose.dev.yaml… */
139
+ export const isComposeFile = (name) => /^(docker-)?compose(\.[\w.-]+)?\.ya?ml$/.test(name);
140
+ /** Workspace dir globs from pnpm-workspace.yaml / package.json "workspaces". Single-level only. */
141
+ export const workspaceGlobs = (pnpmWorkspaceText, packageJsonText) => {
142
+ const globs = [];
143
+ if (pnpmWorkspaceText !== null) {
144
+ let inPackages = false;
145
+ for (const line of pnpmWorkspaceText.split("\n")) {
146
+ if (/^packages:\s*$/.test(line)) {
147
+ inPackages = true;
148
+ continue;
149
+ }
150
+ if (inPackages && /^\S/.test(line))
151
+ inPackages = false;
152
+ if (!inPackages)
153
+ continue;
154
+ const entry = /^\s+-\s+["']?([^"'#]+?)["']?\s*$/.exec(line);
155
+ if (entry?.[1] !== undefined && !entry[1].startsWith("!"))
156
+ globs.push(entry[1].trim());
157
+ }
158
+ }
159
+ if (packageJsonText !== null) {
160
+ try {
161
+ const parsed = JSON.parse(packageJsonText);
162
+ if (Array.isArray(parsed.workspaces)) {
163
+ globs.push(...parsed.workspaces.filter((g) => typeof g === "string"));
164
+ }
165
+ }
166
+ catch {
167
+ // no workspaces to read
168
+ }
169
+ }
170
+ return globs;
171
+ };
172
+ /** A workspace package's proposal: named after its folder, run through the package manager. */
173
+ export const proposeFromWorkspacePackage = (dirName, packageJsonText, rootFiles) => {
174
+ let parsed;
175
+ try {
176
+ parsed = JSON.parse(packageJsonText);
177
+ }
178
+ catch {
179
+ return [];
180
+ }
181
+ if (typeof parsed !== "object" || parsed === null)
182
+ return [];
183
+ const pkg = parsed;
184
+ const scripts = pkg.scripts;
185
+ if (typeof scripts !== "object" || scripts === null)
186
+ return [];
187
+ const pkgName = typeof pkg.name === "string" ? pkg.name : dirName;
188
+ const pm = packageManagerOf(rootFiles);
189
+ const runner = pm === "pnpm"
190
+ ? (script) => `pnpm --filter ${pkgName} ${script}`
191
+ : pm === "yarn"
192
+ ? (script) => `yarn workspace ${pkgName} ${script}`
193
+ : (script) => `${pm} run ${script} --workspace ${pkgName}`;
194
+ for (const scriptName of SERVER_SCRIPTS) {
195
+ const script = scripts[scriptName];
196
+ if (typeof script !== "string")
197
+ continue;
198
+ const port = portFromScript(script);
199
+ if (port === null)
200
+ continue;
201
+ return [
202
+ {
203
+ name: dirName,
204
+ command: runner(scriptName),
205
+ port: port.port,
206
+ protocol: "tcp",
207
+ guessed: port.guessed,
208
+ source: `${dirName}/package.json "${scriptName}": ${script}`,
209
+ },
210
+ ];
211
+ }
212
+ return [];
213
+ };
214
+ /** The proposal as the file the user will commit. */
215
+ export const renderMendToml = (proposals) => {
216
+ const blocks = proposals.map((proposal) => {
217
+ const lines = [
218
+ `# ${proposal.source}`,
219
+ `[service.${proposal.name}]`,
220
+ `command = ${JSON.stringify(proposal.command)}`,
221
+ proposal.guessed ? `port = ${proposal.port} # guessed — verify` : `port = ${proposal.port}`,
222
+ ...(proposal.protocol === "udp" ? [`protocol = "udp"`] : []),
223
+ ];
224
+ return lines.join("\n");
225
+ });
226
+ return `${blocks.join("\n\n")}\n`;
227
+ };
package/dist/shared.js CHANGED
@@ -9,6 +9,8 @@ export const HARNESS_COMMANDS = {
9
9
  codex: ["codex"],
10
10
  claude: ["claude"],
11
11
  opencode: ["opencode"],
12
+ // Not a coding agent: a plain bash session in its own recorded worktree.
13
+ shell: ["bash"],
12
14
  };
13
15
  /** How each harness takes an instruction as its opening prompt. */
14
16
  export const CONTINUE_COMMANDS = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sealant/mend",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "The mend CLI: adopt a repository into Mend's store, launch your coding agent in a recorded session worktree, detach and reattach from any terminal",
5
5
  "keywords": [
6
6
  "claude-code",