@sealant/mend 0.1.1 → 0.3.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
@@ -2,10 +2,22 @@
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 { formatLoadReport } from "./env.js";
10
+ import { isComposeFile, proposeFromCompose, proposeFromPackageJson, proposeFromWorkspacePackage, renderMendToml, workspaceGlobs, } from "./service-init.js";
7
11
  import { CONTINUE_COMMANDS, gitTopLevel, HARNESS_COMMANDS, LIVE_STATUSES, matchProjectByCwd, normalizeProjectName, } from "./shared.js";
8
- const CONFIG_PATH = path.join(os.homedir(), ".mend", "cli.json");
12
+ // `$XDG_CONFIG_HOME/mend`, default `~/.config/mend`; a pre-XDG `~/.mend` stays authoritative
13
+ // when it is the only one present (mirrors @mend/store's resolver — the CLI stays dependency-light).
14
+ const mendCliHome = () => {
15
+ const xdg = process.env["XDG_CONFIG_HOME"];
16
+ const preferred = path.join(xdg === undefined || xdg === "" ? path.join(os.homedir(), ".config") : xdg, "mend");
17
+ const legacy = path.join(os.homedir(), ".mend");
18
+ return !fs.existsSync(preferred) && fs.existsSync(legacy) ? legacy : preferred;
19
+ };
20
+ const CONFIG_PATH = path.join(mendCliHome(), "cli.json");
9
21
  const loadConfig = () => {
10
22
  let fileConfig = {};
11
23
  if (fs.existsSync(CONFIG_PATH)) {
@@ -82,7 +94,9 @@ const request = async (config, method, route, body) => {
82
94
  throw new Error(`cannot reach the Mend server at ${config.url} — is it running?`);
83
95
  }
84
96
  if (response.status === 401) {
85
- throw new Error(`unauthorized — set MEND_TOKEN (or "token" in ${CONFIG_PATH}) to a bearer token for ${config.url}`);
97
+ throw new Error(config.token === null
98
+ ? `not signed in to ${config.url} — run: mend login`
99
+ : `unauthorized at ${config.url} — the saved token was rejected; run: mend login`);
86
100
  }
87
101
  const text = await response.text();
88
102
  if (!response.ok) {
@@ -132,18 +146,35 @@ const withSpinner = async (label, work) => {
132
146
  const adopt = async (config, args) => {
133
147
  const nameFlag = args.indexOf("--name");
134
148
  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));
149
+ const authFlagIndex = args.indexOf("--auth");
150
+ const positional = args.filter((a, i) => !a.startsWith("--") &&
151
+ (nameFlag === -1 || i !== nameFlag + 1) &&
152
+ (authFlagIndex === -1 || i !== authFlagIndex + 1));
136
153
  // Bare `mend adopt` adopts the repo the cwd is inside, not the subdirectory.
137
154
  const rawSource = positional[0] ?? gitTopLevel(process.cwd()) ?? ".";
138
155
  const source = /^(https?|git|ssh):|^git@/.test(rawSource) ? rawSource : path.resolve(rawSource);
139
156
  // Derived defaults are normalized ("Mend" → "mend"); explicit --name is sent as typed.
140
157
  const projectName = name ?? normalizeProjectName(path.basename(source, ".git"));
158
+ const auth = authFlagIndex !== -1 && args[authFlagIndex + 1] !== undefined
159
+ ? String(args[authFlagIndex + 1])
160
+ : null;
161
+ if (auth !== null && auth !== "ambient" && auth !== "mend-key" && auth !== "bridge") {
162
+ return fail(`--auth takes "ambient", "mend-key", or "bridge", not "${auth}"`);
163
+ }
141
164
  const project = await api(config, "POST", "/projects", {
142
165
  name: projectName,
143
166
  source,
167
+ ...(auth === null ? {} : { gitAuthMode: auth }),
144
168
  });
145
169
  say(`${green("✓")} adopted · ${project.name} · ${dim(project.storePath)}`);
146
170
  say(`${dim(" default branch")} ${project.defaultBranch}`);
171
+ // Say which signer did the work — the clone already proved it answers.
172
+ if (project.gitAuthMode === "mend-key") {
173
+ say(`${dim(" git auth")} mend key ${dim("(the machine's deploy key signed this clone)")}`);
174
+ }
175
+ else if (project.gitAuthMode === "bridge") {
176
+ say(`${dim(" git auth")} bridge ${dim("(signed through the connected `mend keys share`)")}`);
177
+ }
147
178
  say(`${dim(" sessions start with:")} mend codex ${dim("(from anywhere —")} --project ${project.name}${dim(")")}`);
148
179
  };
149
180
  // ─── session launch ─────────────────────────────────────────────────────────
@@ -218,10 +249,15 @@ const launch = async (config, harness, args) => {
218
249
  * caller decides what each outcome means (commands exit, the dashboard
219
250
  * resumes).
220
251
  */
221
- const attachTty = async (config, sessionId, harness, from) => {
252
+ const attachTty = async (config, sessionId, harness, from, processId, options) => {
222
253
  const url = new URL(`${config.url}/api/tty`);
223
254
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
224
- url.searchParams.set("session", sessionId);
255
+ // Process addressing reaches any PTY in the workspace (a shell); the
256
+ // session form remains the agent's PTY.
257
+ if (processId !== undefined)
258
+ url.searchParams.set("process", processId);
259
+ else
260
+ url.searchParams.set("session", sessionId);
225
261
  url.searchParams.set("from", from.toString());
226
262
  if (config.token !== null)
227
263
  url.searchParams.set("token", config.token);
@@ -294,12 +330,16 @@ const attachTty = async (config, sessionId, harness, from) => {
294
330
  try {
295
331
  sendResize();
296
332
  process.stdout.on("resize", onWinch);
297
- if (rawTty) {
298
- process.stdin.setRawMode(true);
299
- rawModeEnabled = true;
333
+ // Read-only (logs): never forward stdin — Ctrl+C exits the CLI, the
334
+ // socket drops, and the process inside keeps running untouched.
335
+ if (options?.readOnly !== true) {
336
+ if (rawTty) {
337
+ process.stdin.setRawMode(true);
338
+ rawModeEnabled = true;
339
+ }
340
+ process.stdin.resume();
341
+ process.stdin.on("data", onKeys);
300
342
  }
301
- process.stdin.resume();
302
- process.stdin.on("data", onKeys);
303
343
  ws.addEventListener("message", onTtyFrame);
304
344
  await finished;
305
345
  return detached ? "detached" : "ended";
@@ -350,6 +390,887 @@ const attach = async (config, args) => {
350
390
  await attachOrExit(config, match.id, match.harness);
351
391
  exitAfterSessionEnd(config, match.id);
352
392
  };
393
+ /** Compact picker: numbered live sessions, one keystroke of typing. */
394
+ const pickSessionInteractively = async (rows) => {
395
+ say(dim("more than one live session — pick one:"));
396
+ rows.forEach((row, index) => {
397
+ say(` ${index + 1}. ${row.session.harness.padEnd(8)} ${dim(row.session.id.slice(0, 8))} ${row.projectName} ${dim(row.session.branch)}`);
398
+ });
399
+ const readline = await import("node:readline/promises");
400
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
401
+ const answer = await rl.question(" session #: ");
402
+ rl.close();
403
+ const chosen = rows[Number(answer.trim()) - 1];
404
+ if (chosen === undefined)
405
+ return fail(`"${answer.trim()}" is not one of the choices`);
406
+ return chosen.session;
407
+ };
408
+ /**
409
+ * The resolution order (docs/SESSION-SERVICES.md): an explicit id wins, then
410
+ * the cwd's project narrows, one candidate is taken, several go to the
411
+ * picker, and a non-interactive caller never gets a silent guess.
412
+ */
413
+ const resolveLiveSession = async (config, prefix) => {
414
+ const sessions = await api(config, "GET", "/sessions");
415
+ if (prefix !== undefined) {
416
+ const matches = sessions.filter((s) => s.id.startsWith(prefix));
417
+ if (matches.length === 0)
418
+ return fail(`no live session matches "${prefix}"`);
419
+ const exact = matches[0];
420
+ if (matches.length > 1 || exact === undefined) {
421
+ return fail(`session prefix "${prefix}" is ambiguous — use more of the id`);
422
+ }
423
+ return exact;
424
+ }
425
+ const projects = await api(config, "GET", "/projects");
426
+ const project = matchProjectByCwd(projects, process.cwd());
427
+ const candidates = project === undefined ? sessions : sessions.filter((s) => s.projectId === project.id);
428
+ const only = candidates[0];
429
+ if (only === undefined) {
430
+ return fail("no live session — start one with mend codex|claude|opencode");
431
+ }
432
+ if (candidates.length === 1)
433
+ return only;
434
+ if (process.stdin.isTTY !== true) {
435
+ return fail("several live sessions — name one: mend shell <session-id-prefix>");
436
+ }
437
+ const nameById = new Map(projects.map((p) => [p.id, p.name]));
438
+ return pickSessionInteractively(candidates.map((session) => ({
439
+ session,
440
+ projectName: nameById.get(session.projectId) ?? session.projectId.slice(0, 8),
441
+ })));
442
+ };
443
+ /**
444
+ * A real interactive shell in the session's CURRENT workspace — same
445
+ * /workspace/repo the agent is editing, same dependencies, same network. Not
446
+ * a shell in the host checkout. The shell holds a workspace lease, so the
447
+ * container survives the agent settling while the shell lives.
448
+ */
449
+ const shellCommand = async (config, args) => {
450
+ const prefix = args.find((a) => !a.startsWith("--"));
451
+ const session = await resolveLiveSession(config, prefix);
452
+ say(`${green("✓")} shell in ${session.harness} session ${dim(session.id.slice(0, 8))} · ${dim(session.branch)}${detachHint()}`);
453
+ const shellProcess = await withSpinner("opening a shell in the session workspace…", api(config, "POST", `/sessions/${session.id}/shell`));
454
+ say("");
455
+ const outcome = await attachTty(config, session.id, "shell", 0n, shellProcess.id);
456
+ if (outcome === "unavailable") {
457
+ return fail(`tty attach unavailable: could not connect to ${config.url}`);
458
+ }
459
+ say("");
460
+ if (outcome === "detached") {
461
+ say(`${amber("detached")} — the shell keeps running and holds the workspace open`);
462
+ process.exit(0);
463
+ }
464
+ say(`${green("✓")} shell ended`);
465
+ process.exit(0);
466
+ };
467
+ const serviceUrl = (config, service) => {
468
+ const host = new URL(config.url).hostname || "localhost";
469
+ // A UDP Service has no page to open — the endpoint is the whole fact.
470
+ return service.protocol === "udp"
471
+ ? `${host}:${service.hostPort ?? "?"} (udp)`
472
+ : `http://${host}:${service.hostPort ?? "?"}`;
473
+ };
474
+ const printService = (config, service) => {
475
+ const status = service.status === "reachable" ? green(service.status) : amber(service.status);
476
+ 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))}`);
477
+ };
478
+ /**
479
+ * Adopt an already-listening workspace port as a Service: Mend binds a host
480
+ * port on its private interfaces and pumps every connection into the
481
+ * session's workspace. No supervision — reachability is the observation.
482
+ */
483
+ const serviceAdd = async (config, args) => {
484
+ const nameFlag = args.indexOf("--name");
485
+ const name = nameFlag !== -1 && args[nameFlag + 1] !== undefined ? String(args[nameFlag + 1]) : null;
486
+ const protocol = args.includes("--udp") ? "udp" : "tcp";
487
+ const positional = args.filter((a, i) => !a.startsWith("--") && (nameFlag === -1 || i !== nameFlag + 1));
488
+ const portRaw = positional.find((a) => /^\d+$/.test(a));
489
+ if (portRaw === undefined) {
490
+ return fail("usage: mend service add [session] <port> [--name <n>] [--udp]");
491
+ }
492
+ const port = Number(portRaw);
493
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
494
+ return fail(`"${portRaw}" is not a port`);
495
+ }
496
+ const prefix = positional.find((a) => a !== portRaw);
497
+ const session = await resolveLiveSession(config, prefix);
498
+ const service = await api(config, "POST", `/sessions/${session.id}/services`, {
499
+ port,
500
+ name,
501
+ protocol,
502
+ });
503
+ say(`${green("✓")} Service ${service.label ?? ""} · ${service.status}`);
504
+ say(` ${cobalt(serviceUrl(config, service))}`);
505
+ if (protocol === "udp") {
506
+ say(dim(` udp — a reply is the only reachability signal; silence just relays`));
507
+ }
508
+ else if (service.status !== "reachable") {
509
+ say(dim(` nothing answered on :${port} yet — the URL goes live when something listens`));
510
+ }
511
+ };
512
+ const serviceList = async (config) => {
513
+ const services = await api(config, "GET", "/services");
514
+ if (services.length === 0) {
515
+ say(dim("no live services — mend service add <port> adopts a listening one"));
516
+ return;
517
+ }
518
+ for (const service of services)
519
+ printService(config, service);
520
+ };
521
+ const serviceStop = async (config, args) => {
522
+ const needle = args.find((a) => !a.startsWith("--"));
523
+ if (needle === undefined)
524
+ return fail("usage: mend service stop <name-or-id-prefix>");
525
+ const services = await api(config, "GET", "/services");
526
+ const matches = services.filter((service) => service.label === needle || service.id.startsWith(needle));
527
+ if (matches.length === 0)
528
+ return fail(`no live service matches "${needle}"`);
529
+ const match = matches[0];
530
+ if (matches.length > 1 || match === undefined) {
531
+ return fail(`"${needle}" is ambiguous — use more of the id`);
532
+ }
533
+ const stoppedService = await api(config, "POST", `/services/${match.id}/stop`);
534
+ say(`${green("✓")} stopped · ${stoppedService.label ?? stoppedService.id.slice(0, 8)}`);
535
+ };
536
+ const findLiveService = async (config, needle) => {
537
+ const services = await api(config, "GET", "/services");
538
+ const matches = services.filter((service) => service.label === needle || service.id.startsWith(needle));
539
+ if (matches.length === 0)
540
+ return fail(`no live service matches "${needle}"`);
541
+ const match = matches[0];
542
+ if (matches.length > 1 || match === undefined) {
543
+ return fail(`"${needle}" is ambiguous — use more of the id`);
544
+ }
545
+ return match;
546
+ };
547
+ /**
548
+ * Start and supervise a Service: the command runs as its own PTY process in
549
+ * the session's workspace (own record = its logs), Mend waits for the
550
+ * declared port to answer, then exposes it like any Service. The command
551
+ * never occupies the agent's terminal or a tool call.
552
+ */
553
+ const serviceRun = async (config, args) => {
554
+ const dashdash = args.indexOf("--");
555
+ const usage = "usage: mend service run [session] --port <port> [--name <n>] [--udp] -- <command...>\n" +
556
+ " mend service run [session] <name> (a declared recipe)";
557
+ // No explicit command = a DECLARED Service: resolve the name against the
558
+ // session worktree's mend.toml and start (or adopt) its recipe.
559
+ if (dashdash === -1) {
560
+ const positionals = args.filter((a) => !a.startsWith("--"));
561
+ const name = positionals.at(-1);
562
+ if (name === undefined)
563
+ return fail(usage);
564
+ const prefix = positionals.length > 1 ? positionals[0] : undefined;
565
+ const session = await resolveLiveSession(config, prefix);
566
+ const recipes = await api(config, "GET", `/sessions/${session.id}/recipes`);
567
+ const recipe = recipes.find((entry) => entry.name === name);
568
+ if (recipe === undefined) {
569
+ const known = recipes.map((entry) => entry.name).join(", ");
570
+ return fail(recipes.length === 0
571
+ ? `no mend.toml recipes in this worktree — declare [service.${name}] first`
572
+ : `no recipe named "${name}" — declared: ${known}`);
573
+ }
574
+ if (recipe.command === null) {
575
+ // A port-only recipe is an adopt: something else starts the listener.
576
+ const service = await api(config, "POST", `/sessions/${session.id}/services`, {
577
+ port: recipe.port,
578
+ name: recipe.name,
579
+ protocol: recipe.protocol,
580
+ });
581
+ say(`${green("✓")} Service ${service.label ?? ""} · ${service.status}`);
582
+ say(` ${cobalt(serviceUrl(config, service))}`);
583
+ return;
584
+ }
585
+ const service = await withSpinner(recipe.protocol === "udp"
586
+ ? `starting ${recipe.name} (udp :${recipe.port})…`
587
+ : `starting ${recipe.name} — waiting for :${recipe.port} to answer…`, api(config, "POST", `/sessions/${session.id}/services/run`, {
588
+ argv: ["sh", "-c", recipe.command],
589
+ port: recipe.port,
590
+ name: recipe.name,
591
+ protocol: recipe.protocol,
592
+ }));
593
+ say(`${green("✓")} Service ${service.label ?? ""} · ${service.status}`);
594
+ say(` ${cobalt(serviceUrl(config, service))}`);
595
+ say(dim(` logs: mend service logs ${service.label ?? service.id.slice(0, 8)}`));
596
+ return;
597
+ }
598
+ const argv = args.slice(dashdash + 1);
599
+ if (argv.length === 0)
600
+ return fail(usage);
601
+ const head = args.slice(0, dashdash);
602
+ const portFlag = head.indexOf("--port");
603
+ const port = portFlag !== -1 ? Number(head[portFlag + 1]) : Number.NaN;
604
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
605
+ return fail(usage);
606
+ const nameFlag = head.indexOf("--name");
607
+ const name = nameFlag !== -1 && head[nameFlag + 1] !== undefined ? String(head[nameFlag + 1]) : null;
608
+ const protocol = head.includes("--udp") ? "udp" : "tcp";
609
+ const prefix = head.find((a, i) => !a.startsWith("--") && i !== portFlag + 1 && i !== nameFlag + 1);
610
+ const session = await resolveLiveSession(config, prefix);
611
+ const service = await withSpinner(protocol === "udp"
612
+ ? `starting ${name ?? argv[0]} (udp :${port})…`
613
+ : `starting ${name ?? argv[0]} — waiting for :${port} to answer…`, api(config, "POST", `/sessions/${session.id}/services/run`, {
614
+ argv,
615
+ port,
616
+ name,
617
+ protocol,
618
+ }));
619
+ say(`${green("✓")} Service ${service.label ?? ""} · ${service.status}`);
620
+ say(` ${cobalt(serviceUrl(config, service))}`);
621
+ say(dim(` logs: mend service logs ${service.label ?? service.id.slice(0, 8)}`));
622
+ };
623
+ /**
624
+ * Watch a supervised Service's output: record replay, then live tail. A DEAD
625
+ * Service still answers — its record outlives the process — printed once
626
+ * instead of followed.
627
+ */
628
+ const serviceLogs = async (config, args) => {
629
+ const needle = args.find((a) => !a.startsWith("--"));
630
+ if (needle === undefined)
631
+ return fail("usage: mend service logs <name-or-id-prefix>");
632
+ const everything = await api(config, "GET", "/services?all=1");
633
+ const matches = everything.filter((service) => service.label === needle || service.id.startsWith(needle));
634
+ if (matches.length === 0)
635
+ return fail(`no service matches "${needle}"`);
636
+ // Prefer the live one; otherwise the newest ended one (list is newest-first).
637
+ const service = matches.find((m) => m.status !== "exited" && m.status !== "stopped") ?? matches[0];
638
+ if (service === undefined)
639
+ return fail(`no service matches "${needle}"`);
640
+ if (service.sealantSessionId === null) {
641
+ return fail(`"${needle}" is an adopted port — no process of Mend's, no logs. mend service run supervises.`);
642
+ }
643
+ if (service.status === "exited" || service.status === "stopped") {
644
+ // Post-mortem: print the record, don't attach.
645
+ const output = await api(config, "GET", `/processes/${service.id}/output`);
646
+ say(dim(`${service.label ?? service.id.slice(0, 8)} · ${service.status} — recorded output:`));
647
+ say("");
648
+ process.stdout.write(output.text.endsWith("\n") ? output.text : `${output.text}\n`);
649
+ process.exit(0);
650
+ }
651
+ say(dim(`following ${service.label ?? service.id.slice(0, 8)} — Ctrl+C detaches, the service keeps running`));
652
+ say("");
653
+ const outcome = await attachTty(config, service.sessionId, "service", 0n, service.id, {
654
+ readOnly: true,
655
+ });
656
+ if (outcome === "unavailable") {
657
+ return fail(`tty attach unavailable: could not connect to ${config.url}`);
658
+ }
659
+ say("");
660
+ say(dim("stream ended"));
661
+ process.exit(0);
662
+ };
663
+ const serviceRestart = async (config, args) => {
664
+ const needle = args.find((a) => !a.startsWith("--"));
665
+ if (needle === undefined)
666
+ return fail("usage: mend service restart <name-or-id-prefix>");
667
+ const service = await findLiveService(config, needle);
668
+ const restarted = await withSpinner(`restarting ${service.label ?? service.id.slice(0, 8)}…`, api(config, "POST", `/services/${service.id}/restart`));
669
+ say(`${green("✓")} restarted · ${restarted.status}`);
670
+ say(` ${cobalt(serviceUrl(config, restarted))}`);
671
+ };
672
+ /**
673
+ * Scaffold mend.toml from the project's own manifests. Static suggestion,
674
+ * not detection: package.json scripts and compose port mappings become
675
+ * recipe proposals the user confirms and commits. Nothing runs.
676
+ */
677
+ const serviceInit = async (args) => {
678
+ const root = gitTopLevel(process.cwd()) ?? process.cwd();
679
+ const target = path.join(root, "mend.toml");
680
+ if (fs.existsSync(target)) {
681
+ return fail(`${target} already exists — edit it directly (init never merges)`);
682
+ }
683
+ const rootFiles = fs.readdirSync(root);
684
+ const proposals = [];
685
+ const readRoot = (name) => fs.readFileSync(path.join(root, name), "utf8");
686
+ if (rootFiles.includes("package.json")) {
687
+ proposals.push(...proposeFromPackageJson(readRoot("package.json"), rootFiles));
688
+ }
689
+ // Monorepo sweep: every workspace package, named after its folder.
690
+ const globs = workspaceGlobs(rootFiles.includes("pnpm-workspace.yaml") ? readRoot("pnpm-workspace.yaml") : null, rootFiles.includes("package.json") ? readRoot("package.json") : null);
691
+ for (const glob of globs) {
692
+ const parent = glob.replace(/\/?\*+$/, "");
693
+ const dirs = glob.endsWith("*")
694
+ ? fs.existsSync(path.join(root, parent))
695
+ ? fs.readdirSync(path.join(root, parent)).map((dir) => path.join(parent, dir))
696
+ : []
697
+ : [glob];
698
+ for (const dir of dirs) {
699
+ const manifest = path.join(root, dir, "package.json");
700
+ if (!fs.existsSync(manifest))
701
+ continue;
702
+ for (const proposal of proposeFromWorkspacePackage(path.basename(dir), fs.readFileSync(manifest, "utf8"), rootFiles)) {
703
+ if (!proposals.some((existing) => existing.name === proposal.name)) {
704
+ proposals.push(proposal);
705
+ }
706
+ }
707
+ }
708
+ }
709
+ // Every compose flavor in the root, aggregated; non-default files need -f.
710
+ for (const composeName of rootFiles.filter(isComposeFile).toSorted()) {
711
+ const isDefault = composeName === "compose.yaml" || composeName === "docker-compose.yml";
712
+ for (const proposal of proposeFromCompose(readRoot(composeName))) {
713
+ if (!proposals.some((existing) => existing.name === proposal.name)) {
714
+ proposals.push({
715
+ ...proposal,
716
+ command: isDefault
717
+ ? proposal.command
718
+ : proposal.command.replace("docker compose ", `docker compose -f ${composeName} `),
719
+ source: `${composeName}: ${proposal.source}`,
720
+ });
721
+ }
722
+ }
723
+ }
724
+ if (proposals.length === 0) {
725
+ return fail("nothing to propose — no server-ish package.json script with a nameable port, no compose ports");
726
+ }
727
+ const toml = renderMendToml(proposals);
728
+ say(dim(`proposed ${target}:`));
729
+ say("");
730
+ process.stdout.write(toml);
731
+ say("");
732
+ if (!args.includes("--yes")) {
733
+ if (process.stdin.isTTY !== true) {
734
+ return fail("non-interactive — pass --yes to write the file");
735
+ }
736
+ const readline = await import("node:readline/promises");
737
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
738
+ const answer = await rl.question("write it? [y/N] ");
739
+ rl.close();
740
+ if (answer.trim().toLowerCase() !== "y") {
741
+ say(dim("nothing written"));
742
+ return;
743
+ }
744
+ }
745
+ fs.writeFileSync(target, toml);
746
+ say(`${green("✓")} wrote ${target} — commit it, then: mend service run ${proposals[0]?.name}`);
747
+ };
748
+ const serviceCommand = async (config, args) => {
749
+ const [verb, ...rest] = args;
750
+ switch (verb) {
751
+ case "run":
752
+ return serviceRun(config, rest);
753
+ case "add":
754
+ return serviceAdd(config, rest);
755
+ case "init":
756
+ return serviceInit(rest);
757
+ case "list":
758
+ case undefined:
759
+ return serviceList(config);
760
+ case "logs":
761
+ return serviceLogs(config, rest);
762
+ case "restart":
763
+ return serviceRestart(config, rest);
764
+ case "stop":
765
+ return serviceStop(config, rest);
766
+ default:
767
+ // Sugar: `mend service mysql` reads as `mend service run mysql` — a
768
+ // bare word that isn't a verb is a recipe name. A miss still explains
769
+ // itself (declared recipes are listed in the failure).
770
+ return serviceRun(config, [verb, ...rest]);
771
+ }
772
+ };
773
+ // ─── login: obtain and save the bearer token ────────────────────────────────
774
+ const takeFlagValue = (args, flag) => {
775
+ const at = args.indexOf(flag);
776
+ return at !== -1 && args[at + 1] !== undefined ? String(args[at + 1]) : null;
777
+ };
778
+ /**
779
+ * Read a password: raw-mode without echo on a TTY; on a pipe, one line through the SAME readline
780
+ * that read the email (a second reader would find the buffered pipe already drained).
781
+ */
782
+ const readSecretLine = async (prompt, rl) => {
783
+ if (process.stdin.isTTY !== true) {
784
+ return (await rl.question(prompt)).trim();
785
+ }
786
+ process.stdout.write(prompt);
787
+ return new Promise((resolve) => {
788
+ const stdin = process.stdin;
789
+ stdin.setRawMode(true);
790
+ stdin.resume();
791
+ stdin.setEncoding("utf8");
792
+ let buffer = "";
793
+ const onData = (chunk) => {
794
+ for (const char of chunk) {
795
+ if (char === "\r" || char === "\n") {
796
+ stdin.setRawMode(false);
797
+ stdin.pause();
798
+ stdin.off("data", onData);
799
+ process.stdout.write("\n");
800
+ resolve(buffer);
801
+ return;
802
+ }
803
+ if (char === "\u0003") {
804
+ stdin.setRawMode(false);
805
+ process.stdout.write("\n");
806
+ process.exit(130);
807
+ }
808
+ if (char === "\u007f" || char === "\b") {
809
+ buffer = buffer.slice(0, -1);
810
+ continue;
811
+ }
812
+ buffer += char;
813
+ }
814
+ };
815
+ stdin.on("data", onData);
816
+ });
817
+ };
818
+ const saveCliConfig = (next) => {
819
+ fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true, mode: 0o700 });
820
+ fs.writeFileSync(CONFIG_PATH, `${JSON.stringify({ url: next.url, token: next.token }, null, 2)}\n`, { mode: 0o600 });
821
+ fs.chmodSync(CONFIG_PATH, 0o600);
822
+ };
823
+ /**
824
+ * `mend login [--url <server>] [--email <address>]`: sign in with email + password against the
825
+ * server's auth endpoint (the bearer plugin answers with a `set-auth-token` header) and save the
826
+ * token 0600 in the CLI config. Nothing else asks for a password again.
827
+ */
828
+ const login = async (config, args) => {
829
+ const url = takeFlagValue(args, "--url") ?? process.env["MEND_URL"] ?? config.url;
830
+ const emailFlag = takeFlagValue(args, "--email");
831
+ const readline = await import("node:readline/promises");
832
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
833
+ const email = emailFlag ?? (await rl.question(`email for ${url}: `)).trim();
834
+ if (email === "") {
835
+ rl.close();
836
+ return fail("an email is required");
837
+ }
838
+ const password = await readSecretLine("password: ", rl);
839
+ rl.close();
840
+ if (password === "")
841
+ return fail("a password is required");
842
+ let response;
843
+ try {
844
+ response = await fetch(`${url}/api/auth/sign-in/email`, {
845
+ method: "POST",
846
+ // better-auth's CSRF check rejects the `Origin: null` a non-browser fetch sends; the
847
+ // server's own URL is always a trusted origin, so present that.
848
+ headers: { "content-type": "application/json", origin: url },
849
+ body: JSON.stringify({ email, password }),
850
+ });
851
+ }
852
+ catch {
853
+ return fail(`cannot reach the Mend server at ${url} — is it running?`);
854
+ }
855
+ if (response.status === 401 || response.status === 403 || response.status === 400) {
856
+ return fail(`sign-in refused for ${email} at ${url}`);
857
+ }
858
+ if (!response.ok)
859
+ return fail(`sign-in failed: ${url} responded ${response.status}`);
860
+ const token = response.headers.get("set-auth-token");
861
+ if (token === null || token === "") {
862
+ return fail("the server signed you in but returned no bearer token (bearer plugin missing?)");
863
+ }
864
+ saveCliConfig({ ...config, url, token });
865
+ say(`${green("✓")} signed in as ${email} · ${dim(`token saved to ${CONFIG_PATH}`)}`);
866
+ };
867
+ const logout = (config) => {
868
+ if (!fs.existsSync(CONFIG_PATH)) {
869
+ say(dim("nothing saved — already signed out"));
870
+ return;
871
+ }
872
+ saveCliConfig({ ...config, token: null });
873
+ say(`${green("✓")} signed out · ${dim(`token removed from ${CONFIG_PATH}`)}`);
874
+ };
875
+ // ─── keys: the machine's Mend deploy key (docs/GIT-ACCESS.md) ───────────────
876
+ /** Print the public key with the one instruction that makes it useful. */
877
+ const printGitKey = (key) => {
878
+ if (key.publicKey === null)
879
+ return;
880
+ say(key.publicKey);
881
+ if (key.fingerprint !== null)
882
+ say(dim(` ${key.fingerprint}`));
883
+ say(dim(" add this as a deploy key on your git host (GitHub/GitLab/Gitea: repo"));
884
+ say(dim(" settings → deploy keys; grant write access if sessions should push),"));
885
+ say(dim(" then adopt with: mend adopt <ssh-url> --auth mend-key"));
886
+ };
887
+ const keysShow = async (config) => {
888
+ const key = await api(config, "GET", "/keys/git");
889
+ if (!key.exists) {
890
+ say(`no Mend key yet ${dim("— mend keys init generates one (ed25519, stays on the server host)")}`);
891
+ return;
892
+ }
893
+ printGitKey(key);
894
+ };
895
+ const keysInit = async (config) => {
896
+ const key = await api(config, "POST", "/keys/git");
897
+ say(`${green("✓")} Mend key ready ${dim("(private half stays on the server host)")}`);
898
+ printGitKey(key);
899
+ };
900
+ /**
901
+ * The ssh-agent bridge, client half (docs/GIT-ACCESS.md decision 2): relay
902
+ * the LOCAL ssh-agent to the Mend server over one standing WebSocket, so
903
+ * bridge-mode git ops sign with a key that never leaves this machine — a
904
+ * hardware key blinks here, not on the server. Agent-protocol messages are
905
+ * relayed verbatim (length-prefixed frames, base64 over JSON); the only
906
+ * inspection is each message's type byte, to say what is being asked.
907
+ */
908
+ const keysShare = async (config) => {
909
+ const agentSock = process.env["SSH_AUTH_SOCK"];
910
+ if (agentSock === undefined || agentSock === "") {
911
+ return fail("SSH_AUTH_SOCK is not set — start (or plug in) your ssh-agent first");
912
+ }
913
+ const url = new URL(`${config.url}/api/keys/bridge/ws`);
914
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
915
+ url.searchParams.set("host", os.hostname());
916
+ if (config.token !== null)
917
+ url.searchParams.set("token", config.token);
918
+ /**
919
+ * One complete agent exchange against the local agent: fresh connection,
920
+ * write the framed request verbatim, read one framed response. A hardware
921
+ * key blocks here until the touch — hence the generous timeout.
922
+ */
923
+ const askLocalAgent = (payload) => new Promise((resolve, reject) => {
924
+ const connection = net.connect(agentSock, () => {
925
+ connection.write(payload);
926
+ });
927
+ const timer = setTimeout(() => {
928
+ connection.destroy();
929
+ reject(new Error("the agent did not answer within 60s — touch missed?"));
930
+ }, 60_000);
931
+ let pending = Buffer.alloc(0);
932
+ connection.on("data", (chunk) => {
933
+ pending = Buffer.concat([pending, chunk]);
934
+ if (pending.length >= 4 && pending.length >= 4 + pending.readUInt32BE(0)) {
935
+ clearTimeout(timer);
936
+ connection.end();
937
+ resolve(pending.subarray(0, 4 + pending.readUInt32BE(0)));
938
+ }
939
+ });
940
+ connection.on("error", (error) => {
941
+ clearTimeout(timer);
942
+ reject(error);
943
+ });
944
+ });
945
+ // Strictly one request at a time, in arrival order — the agent protocol is
946
+ // request/response and a hardware key cannot answer two touches at once.
947
+ let chain = Promise.resolve();
948
+ const SSH_AGENTC_SIGN_REQUEST = 13;
949
+ const SSH_AGENTC_REQUEST_IDENTITIES = 11;
950
+ let attempt = 0;
951
+ for (;;) {
952
+ const ws = new WebSocket(url);
953
+ const closed = new Promise((resolve) => {
954
+ ws.addEventListener("close", () => resolve(), { once: true });
955
+ });
956
+ const opened = await new Promise((resolve) => {
957
+ ws.addEventListener("open", () => resolve(true), { once: true });
958
+ ws.addEventListener("error", () => resolve(false), { once: true });
959
+ });
960
+ if (opened) {
961
+ attempt = 0;
962
+ say(`${green("●")} sharing this machine's ssh-agent with ${config.url}`);
963
+ say(dim(` agent: ${agentSock} · signature requests print here · Ctrl-C stops sharing`));
964
+ ws.addEventListener("message", (event) => {
965
+ const text = typeof event.data === "string"
966
+ ? event.data
967
+ : Buffer.from(event.data).toString("utf8");
968
+ let frame;
969
+ try {
970
+ frame = JSON.parse(text);
971
+ }
972
+ catch {
973
+ return;
974
+ }
975
+ if (frame.t !== "req" ||
976
+ typeof frame.id !== "number" ||
977
+ typeof frame.payload !== "string") {
978
+ return;
979
+ }
980
+ const id = frame.id;
981
+ const payload = Buffer.from(frame.payload, "base64");
982
+ const type = payload[4];
983
+ const context = typeof frame.context === "string" ? frame.context : "mend";
984
+ chain = chain.then(async () => {
985
+ const started = Date.now();
986
+ if (type === SSH_AGENTC_SIGN_REQUEST) {
987
+ say(`${amber("✎")} signature requested by mend ${dim(`(${context})`)}`);
988
+ say(dim(" waiting — touch your key if it blinks (up to 60s)…"));
989
+ }
990
+ else if (type === SSH_AGENTC_REQUEST_IDENTITIES) {
991
+ say(dim(` identities requested (${context})`));
992
+ }
993
+ try {
994
+ const response = await askLocalAgent(payload);
995
+ if (ws.readyState !== WebSocket.OPEN)
996
+ return null;
997
+ ws.send(JSON.stringify({ t: "res", id, payload: response.toString("base64") }));
998
+ if (type === SSH_AGENTC_SIGN_REQUEST) {
999
+ say(`${green("✓")} signed ${dim(`(${((Date.now() - started) / 1000).toFixed(1)}s)`)}`);
1000
+ }
1001
+ }
1002
+ catch (error) {
1003
+ const message = error instanceof Error ? error.message : String(error);
1004
+ if (ws.readyState === WebSocket.OPEN) {
1005
+ ws.send(JSON.stringify({ t: "err", id, message }));
1006
+ }
1007
+ if (type === SSH_AGENTC_SIGN_REQUEST)
1008
+ say(`${amber("✗")} not signed — ${message}`);
1009
+ }
1010
+ return null;
1011
+ });
1012
+ });
1013
+ }
1014
+ await closed;
1015
+ attempt += 1;
1016
+ const delay = Math.min(30_000, 1000 * 2 ** Math.min(attempt - 1, 5));
1017
+ say(dim(`not connected — retrying in ${Math.round(delay / 1000)}s (Ctrl-C stops)`));
1018
+ await new Promise((resolve) => setTimeout(resolve, delay));
1019
+ }
1020
+ };
1021
+ const keysCommand = async (config, args) => {
1022
+ const [verb] = args;
1023
+ switch (verb) {
1024
+ case "init":
1025
+ return keysInit(config);
1026
+ case "share":
1027
+ return keysShare(config);
1028
+ case "show":
1029
+ case undefined:
1030
+ return keysShow(config);
1031
+ default:
1032
+ return fail(`unknown keys command "${verb}" — try: mend keys init | show | share`);
1033
+ }
1034
+ };
1035
+ const formatDotfileBytes = (bytes) => bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`;
1036
+ const dotfilesShow = async (config) => {
1037
+ const dotfiles = await api(config, "GET", "/dotfiles");
1038
+ if (dotfiles.repository === null) {
1039
+ say(`repo ${dim("none")}`);
1040
+ }
1041
+ else {
1042
+ const branch = dotfiles.repository.ref ?? "default branch";
1043
+ const bootstrap = dotfiles.repository.bootstrap ? "install.sh on" : "install.sh off";
1044
+ say(`repo ${dotfiles.repository.url} ${dim(`(${branch} · ${bootstrap})`)}`);
1045
+ }
1046
+ if (dotfiles.snapshot === null) {
1047
+ say(`snapshot ${dim("none — sync from this machine: mend dotfiles sync --all")}`);
1048
+ return;
1049
+ }
1050
+ const snapshot = dotfiles.snapshot;
1051
+ say(`snapshot ${snapshot.files.length} file${snapshot.files.length === 1 ? "" : "s"} · from ${snapshot.source} · ${dim(snapshot.sha.slice(0, 7))}`);
1052
+ for (const file of snapshot.files) {
1053
+ say(` ${file.path.padEnd(36)} ${dim(formatDotfileBytes(file.bytes))}`);
1054
+ }
1055
+ };
1056
+ /**
1057
+ * `mend dotfiles sync` — capture home files ON THIS MACHINE and stream them into the server's
1058
+ * per-user dotfiles store. This is the whole point of the store: the server may be a VPS whose
1059
+ * home directory belongs to a service account, so contents are read here, where they live.
1060
+ */
1061
+ const dotfilesSync = async (config, args) => {
1062
+ const all = args.includes("--all");
1063
+ const requestedPaths = args.filter((arg) => !arg.startsWith("--"));
1064
+ const home = os.homedir();
1065
+ if (!all && requestedPaths.length === 0) {
1066
+ const found = scanDotfileCandidates(home);
1067
+ if (found.length === 0) {
1068
+ say(dim("no known config files found under ~"));
1069
+ return;
1070
+ }
1071
+ let group = "";
1072
+ for (const entry of found) {
1073
+ if (entry.group !== group) {
1074
+ group = entry.group;
1075
+ say(dim(group));
1076
+ }
1077
+ say(` ${entry.path.padEnd(36)} ${dim(formatDotfileBytes(entry.bytes))}`);
1078
+ }
1079
+ say("");
1080
+ say(`sync everything with ${cobalt("mend dotfiles sync --all")}, or pick: ${cobalt("mend dotfiles sync .zshrc .gitconfig")}`);
1081
+ return;
1082
+ }
1083
+ const selected = all ? scanDotfileCandidates(home).map((entry) => entry.path) : requestedPaths;
1084
+ if (selected.length === 0)
1085
+ return fail("nothing to sync — no known config files found under ~");
1086
+ const read = readSyncFiles(home, selected);
1087
+ if ("error" in read)
1088
+ return fail(read.error);
1089
+ const result = await api(config, "POST", "/dotfiles/snapshot", {
1090
+ files: read.files,
1091
+ source: os.hostname(),
1092
+ merge: false,
1093
+ });
1094
+ const snapshot = result.snapshot;
1095
+ if (snapshot === null)
1096
+ return fail("the server accepted the sync but reports no snapshot");
1097
+ 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")}`);
1098
+ };
1099
+ const envLoad = async (config, args) => {
1100
+ const explicitProject = takeFlagValue(args, "--project");
1101
+ // `--secret` alone sends everything to Secrets; `--secret A,B` only those names (for the
1102
+ // ordinary-looking ones that embed credentials, like DATABASE_URL). Routing is by NAME.
1103
+ const secretFlag = args.indexOf("--secret");
1104
+ const secretArg = secretFlag !== -1 ? args[secretFlag + 1] : undefined;
1105
+ const secretNames = secretArg !== undefined &&
1106
+ !secretArg.startsWith("--") &&
1107
+ !secretArg.includes("/") &&
1108
+ !secretArg.includes(".")
1109
+ ? secretArg
1110
+ .split(",")
1111
+ .map((s) => s.trim())
1112
+ .filter((s) => s !== "")
1113
+ : [];
1114
+ const allSecret = secretFlag !== -1 && secretNames.length === 0;
1115
+ const consumed = new Set();
1116
+ if (secretFlag !== -1 && secretNames.length > 0)
1117
+ consumed.add(secretFlag + 1);
1118
+ const positional = args.filter((arg, i) => !arg.startsWith("--") && args[i - 1] !== "--project" && !consumed.has(i));
1119
+ const file = path.resolve(positional[0] ?? ".env");
1120
+ let contents;
1121
+ try {
1122
+ contents = fs.readFileSync(file, "utf8");
1123
+ }
1124
+ catch {
1125
+ return fail(`cannot read ${file} — pass a path: mend env load path/to/.env`);
1126
+ }
1127
+ if (contents.trim() === "") {
1128
+ say(dim(`${file} is empty`));
1129
+ return;
1130
+ }
1131
+ const project = await findProject(config, explicitProject);
1132
+ const report = await api(config, "POST", `/projects/${project.id}/environment/load`, { contents, allSecret, secretNames });
1133
+ if (report.loaded.length === 0 &&
1134
+ report.rejected.length === 0 &&
1135
+ report.malformedLines.length === 0) {
1136
+ say(dim(`${path.basename(file)} has no variables`));
1137
+ return;
1138
+ }
1139
+ say(`${green("✓")} loaded ${path.basename(file)} into ${project.name}`);
1140
+ for (const line of formatLoadReport(report, { dim, warn: amber }))
1141
+ say(line);
1142
+ const plaintextUrls = report.loaded.filter((entry) => entry.lane === "configuration" && /_(URL|URI|DSN)$/.test(entry.name));
1143
+ if (plaintextUrls.length > 0) {
1144
+ say(amber(` ${plaintextUrls.map((entry) => entry.name).join(", ")} stored as plaintext configuration — if a value embeds a password, store it as a secret: mend env load --secret ${plaintextUrls.map((entry) => entry.name).join(",")}`));
1145
+ }
1146
+ if (report.malformedLines.length > 0) {
1147
+ say(amber(` skipped ${report.malformedLines.length} malformed line${report.malformedLines.length === 1 ? "" : "s"}: ${report.malformedLines.join(", ")}`));
1148
+ }
1149
+ say(dim(` configuration r${report.environmentRevision} · secrets r${report.secretRevision} — applies from the next workspace launch, including resume; running workspaces keep what they started with`));
1150
+ };
1151
+ const envShow = async (config, args) => {
1152
+ const project = await findProject(config, takeFlagValue(args, "--project"));
1153
+ const [environment, secrets] = await Promise.all([
1154
+ api(config, "GET", `/projects/${project.id}/environment`),
1155
+ api(config, "GET", `/projects/${project.id}/secrets`),
1156
+ ]);
1157
+ say(`${project.name} ${dim(`· configuration r${environment.revision} · secrets r${secrets.revision}`)}`);
1158
+ if (environment.variables.length === 0 && secrets.secrets.length === 0) {
1159
+ say(dim(` nothing stored — load a file: ${cobalt("mend env load")}`));
1160
+ return;
1161
+ }
1162
+ const width = Math.max(...environment.variables.map((v) => v.name.length), ...secrets.secrets.map((s) => s.name.length));
1163
+ for (const variable of environment.variables) {
1164
+ say(` ${variable.name.padEnd(width)} configuration ${dim("· plaintext")}`);
1165
+ }
1166
+ for (const secret of secrets.secrets) {
1167
+ say(` ${secret.name.padEnd(width)} secret ${dim("· value set, never shown")}`);
1168
+ }
1169
+ };
1170
+ const envCommand = async (config, args) => {
1171
+ const [verb, ...rest] = args;
1172
+ switch (verb) {
1173
+ case "load":
1174
+ return envLoad(config, rest);
1175
+ case "show":
1176
+ case undefined:
1177
+ return envShow(config, rest);
1178
+ default:
1179
+ return fail(`unknown env command "${verb}" — try: mend env load | show`);
1180
+ }
1181
+ };
1182
+ const dotfilesCommand = async (config, args) => {
1183
+ const [verb, ...rest] = args;
1184
+ switch (verb) {
1185
+ case "sync":
1186
+ return dotfilesSync(config, rest);
1187
+ case "show":
1188
+ case undefined:
1189
+ return dotfilesShow(config);
1190
+ default:
1191
+ return fail(`unknown dotfiles command "${verb}" — try: mend dotfiles sync | show`);
1192
+ }
1193
+ };
1194
+ // ─── completions: live sessions under TAB ───────────────────────────────────
1195
+ /**
1196
+ * The data half of shell completion: one live session per line as
1197
+ * `id<TAB>description`. Scripts adapt the shape (zsh wants `id:desc`, bash
1198
+ * wants bare ids). Never fails — a dead server just completes nothing.
1199
+ */
1200
+ const completeCommand = async (config, args) => {
1201
+ if (args[0] !== "session")
1202
+ return;
1203
+ try {
1204
+ const [sessions, projects] = await Promise.all([
1205
+ request(config, "GET", "/sessions"),
1206
+ request(config, "GET", "/projects"),
1207
+ ]);
1208
+ const nameById = new Map(projects.map((p) => [p.id, p.name]));
1209
+ for (const session of sessions) {
1210
+ const project = nameById.get(session.projectId) ?? "";
1211
+ process.stdout.write(`${session.id}\t${session.harness} · ${project} · ${session.branch}\n`);
1212
+ }
1213
+ }
1214
+ catch {
1215
+ // Completion must never surface an error into the user's TAB press.
1216
+ }
1217
+ };
1218
+ const ZSH_COMPLETIONS = `#compdef mend
1219
+ _mend() {
1220
+ local -a commands
1221
+ commands=(
1222
+ 'adopt:adopt a repository into the store'
1223
+ 'codex:new session + codex' 'claude:new session + claude' 'opencode:new session + opencode'
1224
+ 'run:new session + arbitrary command'
1225
+ 'attach:reattach to a running session' 'shell:open a shell in a live session workspace'
1226
+ 'service:reachable ports — add, list, stop'
1227
+ 'keys:the machine Mend deploy key — init, show, share'
1228
+ 'continue:resume with the pending follow-up' 'resume:rejoin a settled session'
1229
+ 'rejoin:attach if live, otherwise resume'
1230
+ 'projects:adopted projects' 'sessions:sessions with review facts' 'status:active sessions'
1231
+ 'ui:the dashboard' 'help:help'
1232
+ )
1233
+ if (( CURRENT == 2 )); then
1234
+ _describe 'command' commands
1235
+ return
1236
+ fi
1237
+ case $words[2] in
1238
+ shell|attach|continue|resume|rejoin)
1239
+ local -a sessions
1240
+ sessions=(\${(f)"$(command mend __complete session 2>/dev/null | tr '\\t' ':')"})
1241
+ (( \${#sessions} )) && _describe 'session' sessions
1242
+ ;;
1243
+ esac
1244
+ }
1245
+ _mend "$@"
1246
+ `;
1247
+ const BASH_COMPLETIONS = `_mend() {
1248
+ local cur=\${COMP_WORDS[COMP_CWORD]}
1249
+ if [ "$COMP_CWORD" -eq 1 ]; then
1250
+ COMPREPLY=( $(compgen -W "adopt codex claude opencode run attach shell service keys continue resume rejoin projects sessions status ui help" -- "$cur") )
1251
+ return
1252
+ fi
1253
+ case \${COMP_WORDS[1]} in
1254
+ shell|attach|continue|resume|rejoin)
1255
+ COMPREPLY=( $(compgen -W "$(command mend __complete session 2>/dev/null | cut -f1)" -- "$cur") )
1256
+ ;;
1257
+ esac
1258
+ }
1259
+ complete -F _mend mend
1260
+ `;
1261
+ /** Print the hook for the named shell; the user wires it into their rc file. */
1262
+ const completionsCommand = (args) => {
1263
+ switch (args[0]) {
1264
+ case "zsh":
1265
+ process.stdout.write(ZSH_COMPLETIONS);
1266
+ return;
1267
+ case "bash":
1268
+ process.stdout.write(BASH_COMPLETIONS);
1269
+ return;
1270
+ default:
1271
+ return fail('usage: mend completions zsh|bash — e.g. mend completions zsh > "$fpath[1]/_mend"');
1272
+ }
1273
+ };
353
1274
  // ─── supervised run: platform workspace + PTY + record ──────────────────────
354
1275
  const supervisedRun = async (config, session, argv) => {
355
1276
  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 +1652,36 @@ const dashboard = async (config) => {
731
1652
  const HELP = `mend — the agent workbench
732
1653
 
733
1654
  mend the dashboard: every project and session, live
734
- mend adopt [source] [--name <name>] adopt a repository into the store (default: cwd)
1655
+ mend adopt [source] [--name <name>] [--auth ambient|mend-key|bridge]
1656
+ adopt a repository into the store (default: cwd; any git
1657
+ URL — GitHub, GitLab, self-hosted, ssh://, a local path)
1658
+ mend login [--url <server>] sign in with email + password; saves the token (0600)
1659
+ mend logout forget the saved token
1660
+ mend keys init generate the machine's Mend deploy key (ed25519)
1661
+ mend keys show print the public key — add it as a deploy key on your git host
1662
+ mend env load [file] [--secret [A,B]] load a .env into the project: ordinary names → configuration,
1663
+ secret-shaped names → secrets; --secret sends all (or the
1664
+ named ones, e.g. DATABASE_URL) to secrets
1665
+ mend env show what the project store holds — names only, never values
1666
+ mend dotfiles your dotfiles on the server: repo + synced home files
1667
+ mend dotfiles sync [--all | paths…] capture config files from THIS machine into your store
1668
+ mend keys share relay THIS machine's ssh-agent to the server (bridge mode:
1669
+ hardware keys sign here; Ctrl-C stops sharing)
735
1670
  mend codex|claude|opencode new session worktree + launch the harness in it
736
1671
  mend run -- <command...> same, with an arbitrary command
737
1672
  mend attach <session-id-prefix> reattach this terminal to a running session
1673
+ mend shell [session-id-prefix] open a shell in a live session's workspace
1674
+ mend service run [session] --port <p> [--name <n>] [--udp] -- <command...>
1675
+ start + supervise a server in the session workspace
1676
+ mend service run [session] <name> start a declared Service (mend.toml recipe)
1677
+ mend service <name> shorthand for the above
1678
+ mend service init [--yes] scaffold mend.toml from package.json + compose ports
1679
+ mend service add [session] <port> [--name <n>] [--udp]
1680
+ adopt a listening workspace port — reachable on this machine
1681
+ mend service list every live service and its observed state
1682
+ mend service logs <name-or-id> follow a supervised service's output (replay, then live)
1683
+ mend service restart <name-or-id> re-run its recorded command — same URL
1684
+ mend service stop <name-or-id> stop a service (closes its host port)
738
1685
  mend continue [session-id] resume a session with its pending review follow-up
739
1686
  mend resume [session-id] [--with h] rejoin a settled session (state restored; --with switches harness)
740
1687
  mend rejoin [session-id] [--harness h] attach if live, otherwise resume; newest live wins
@@ -742,10 +1689,11 @@ const HELP = `mend — the agent workbench
742
1689
  mend sessions [--all] [--project p] [--json]
743
1690
  sessions with review facts; JSON is stable for integrations
744
1691
  mend status active sessions (alias of mend sessions)
1692
+ mend completions zsh|bash print the TAB-completion hook (live session ids under TAB)
745
1693
 
746
1694
  server: MEND_URL (default http://localhost:3105) · auth: MEND_TOKEN
747
1695
  detach key: Ctrl+] (set MEND_DETACH_KEY=none when an outer multiplexer owns detaching)
748
- config file: ~/.mend/cli.json { "url": ..., "token": ... }
1696
+ config file: ~/.config/mend/cli.json { "url": ..., "token": ... }
749
1697
  `;
750
1698
  const main = async () => {
751
1699
  const [command, ...rest] = process.argv.slice(2);
@@ -760,6 +1708,24 @@ const main = async () => {
760
1708
  return launch(config, command, rest);
761
1709
  case "attach":
762
1710
  return attach(config, rest);
1711
+ case "shell":
1712
+ return shellCommand(config, rest);
1713
+ case "service":
1714
+ return serviceCommand(config, rest);
1715
+ case "login":
1716
+ return login(config, rest);
1717
+ case "logout":
1718
+ return logout(config);
1719
+ case "keys":
1720
+ return keysCommand(config, rest);
1721
+ case "dotfiles":
1722
+ return dotfilesCommand(config, rest);
1723
+ case "env":
1724
+ return envCommand(config, rest);
1725
+ case "completions":
1726
+ return completionsCommand(rest);
1727
+ case "__complete":
1728
+ return completeCommand(config, rest);
763
1729
  case "continue":
764
1730
  return continueSession(config, rest);
765
1731
  case "resume":