@sealant/mend 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/dashboard-model.js +8 -0
  2. package/dist/dashboard.js +12 -3
  3. package/dist/help.js +824 -0
  4. package/dist/main.js +129 -127
  5. package/dist/version.js +48 -0
  6. package/man/mend-accounts.1 +11 -0
  7. package/man/mend-adopt.1 +27 -0
  8. package/man/mend-attach.1 +13 -0
  9. package/man/mend-codex.1 +60 -0
  10. package/man/mend-completions.1 +14 -0
  11. package/man/mend-connect.1 +25 -0
  12. package/man/mend-continue.1 +11 -0
  13. package/man/mend-doctor.1 +11 -0
  14. package/man/mend-dotfiles-sync.1 +15 -0
  15. package/man/mend-dotfiles.1 +11 -0
  16. package/man/mend-env-cluster.1 +26 -0
  17. package/man/mend-env-load.1 +21 -0
  18. package/man/mend-env-show.1 +15 -0
  19. package/man/mend-help.1 +11 -0
  20. package/man/mend-keys-init.1 +11 -0
  21. package/man/mend-keys-share.1 +11 -0
  22. package/man/mend-keys-show.1 +11 -0
  23. package/man/mend-login.1 +20 -0
  24. package/man/mend-logout.1 +11 -0
  25. package/man/mend-man.1 +11 -0
  26. package/man/mend-pair.1 +15 -0
  27. package/man/mend-projects.1 +11 -0
  28. package/man/mend-refresh.1 +11 -0
  29. package/man/mend-rejoin.1 +15 -0
  30. package/man/mend-resume.1 +15 -0
  31. package/man/mend-run.1 +18 -0
  32. package/man/mend-service-add.1 +21 -0
  33. package/man/mend-service-connect.1 +15 -0
  34. package/man/mend-service-init.1 +15 -0
  35. package/man/mend-service-list.1 +11 -0
  36. package/man/mend-service-logs.1 +15 -0
  37. package/man/mend-service-restart.1 +11 -0
  38. package/man/mend-service-run.1 +38 -0
  39. package/man/mend-service-stop.1 +11 -0
  40. package/man/mend-sessions.1 +27 -0
  41. package/man/mend-shell.1 +13 -0
  42. package/man/mend-skills-push.1 +21 -0
  43. package/man/mend-skills.1 +15 -0
  44. package/man/mend-ssh-setup.1 +15 -0
  45. package/man/mend-ssh.1 +11 -0
  46. package/man/mend-stop.1 +22 -0
  47. package/man/mend-ui.1 +13 -0
  48. package/man/mend-version.1 +11 -0
  49. package/man/mend-worktrees.1 +18 -0
  50. package/man/mend.1 +165 -0
  51. package/package.json +8 -3
package/dist/main.js CHANGED
@@ -8,12 +8,14 @@ import * as path from "node:path";
8
8
  import { doctorCommand } from "./doctor.js";
9
9
  import { readSyncFiles, scanDotfileCandidates } from "./dotfiles.js";
10
10
  import { formatLoadReport } from "./env.js";
11
+ import { findCommand, manFileName, renderCommand, renderGroup, renderIndex, renderManIndex, renderManPage, usageOf, } from "./help.js";
11
12
  import { loginCommand } from "./login.js";
12
13
  import { pairCommand, qrCommand } from "./pair.js";
13
14
  import { isComposeFile, proposeFromCompose, proposeFromPackageJson, proposeFromWorkspacePackage, renderMendToml, workspaceGlobs, } from "./service-init.js";
14
15
  import { agentIsLive, agentOutcome, cwdFacts, gitTopLevel, HARNESS_COMMANDS, isDetachChunk, LIVE_STATUSES, sessionDisplayName, matchProjectByCwd, normalizeProjectName, gitCurrentBranch, parseLaunchArgs, } from "./shared.js";
15
16
  import { DEFAULT_SKILLS_DIR, scanSkillLibrary } from "./skills.js";
16
17
  import { sshCommand } from "./ssh-setup.js";
18
+ import { cliVersion, fetchServerVersion, versionLines } from "./version.js";
17
19
  // `$XDG_CONFIG_HOME/mend`, default `~/.config/mend`; a pre-XDG `~/.mend` stays authoritative
18
20
  // when it is the only one present (mirrors @mend/store's resolver — the CLI stays dependency-light).
19
21
  const mendCliHome = () => {
@@ -270,14 +272,14 @@ const launch = async (config, harness, args) => {
270
272
  parsed.ask ||
271
273
  parsed.fast;
272
274
  if (harness === "run" && structured) {
273
- return fail("mend run takes no prompt or harness flags — usage: mend run [--project p] -- <command...>");
275
+ return fail(`mend run takes no prompt or harness flags · ${usageOf("run")}`);
274
276
  }
275
277
  if (harness === "run" && (parsed.detach || parsed.foreground)) {
276
278
  return fail("mend run takes no lifecycle flags — it tails the record; Ctrl+C stops watching, not the command");
277
279
  }
278
280
  const argv = harness === "run" ? parsed.custom : (HARNESS_COMMANDS[harness] ?? []);
279
281
  if (argv.length === 0) {
280
- return fail(harness === "run" ? "usage: mend run -- <command...>" : `unknown harness ${harness}`);
282
+ return fail(harness === "run" ? usageOf("run") : `unknown harness ${harness}`);
281
283
  }
282
284
  const project = await findProject(config, parsed.project, true);
283
285
  // Say which project the cwd resolved to before anything is created — a
@@ -618,16 +620,41 @@ const exitAfterSessionEnd = (config, sessionId) => {
618
620
  /** Reattach a terminal to a running session (full scrollback replay, then live). */
619
621
  const attach = async (config, args) => {
620
622
  const prefix = args.find((a) => !a.startsWith("--"));
621
- if (prefix === undefined)
622
- return fail("usage: mend attach <session-id-prefix>");
623
+ // No id: the picker IS the selection surface (the resolution `mend shell`
624
+ // already uses), so attaching never demands an id the user must go look up.
625
+ if (prefix === undefined) {
626
+ const picked = await resolveLiveSession(config, undefined, "attach");
627
+ return attachPicked(config, picked);
628
+ }
623
629
  const sessions = await api(config, "GET", "/sessions");
624
630
  const match = sessions.find((s) => s.id.startsWith(prefix));
625
631
  if (match === undefined)
626
632
  return fail(`no active session matches "${prefix}"`);
627
- say(`${green("✓")} attaching to ${sessionDisplayName(match)} · ${match.harness} ${dim(match.id.slice(0, 8))}${detachHint()}`);
633
+ return attachPicked(config, match);
634
+ };
635
+ /** Attach to one resolved session — the tail both `mend attach` paths share. */
636
+ const attachPicked = async (config, session) => {
637
+ say(`${green("✓")} attaching to ${sessionDisplayName(session)} · ${session.harness} ${dim(session.id.slice(0, 8))}${detachHint()}`);
628
638
  say("");
629
- await attachOrExit(config, match.id, match.harness);
630
- exitAfterSessionEnd(config, match.id);
639
+ const outcome = await attachTty(config, session.id, session.harness, 0n, undefined, {
640
+ handleSignals: true,
641
+ });
642
+ // A live protocol agent (a phone pickup) has no PTY behind it — the attach
643
+ // reports "unavailable" with nothing wrong. Take the session over: end the
644
+ // protocol agent, resume the same conversation as a TUI, then attach to it.
645
+ if (outcome === "unavailable") {
646
+ const detail = await api(config, "GET", `/sessions/${session.id}`);
647
+ if (detail.currentAgent?.kind === "agent-protocol" &&
648
+ agentIsLive(detail.session, detail.currentAgent)) {
649
+ say(`${amber("taking over")} from the protocol session`);
650
+ await withSpinner("reopening as a terminal — same conversation…", api(config, "POST", `/sessions/${session.id}/handoff`, { to: "pty" }));
651
+ say("");
652
+ await attachOrExit(config, session.id, session.harness);
653
+ return exitAfterSessionEnd(config, session.id);
654
+ }
655
+ }
656
+ await finishAttach(config, session.id, outcome);
657
+ return exitAfterSessionEnd(config, session.id);
631
658
  };
632
659
  /**
633
660
  * Explicit stop — the one intent detaching never carries. Ends the agent; the
@@ -640,8 +667,15 @@ const stopCommand = async (config, args) => {
640
667
  ? String(args[projectFlag + 1])
641
668
  : null;
642
669
  const prefix = args.find((arg, index) => !arg.startsWith("--") && index !== projectFlag + 1);
670
+ // Neither an id nor --all: pick the session to close, the same resolution
671
+ // `mend attach` uses. --all and --project keep their bulk meaning.
672
+ if (!all && prefix === undefined && projectName === null) {
673
+ const picked = await resolveLiveSession(config, undefined, "stop");
674
+ await stopSessions(config, [picked], false);
675
+ return;
676
+ }
643
677
  if (!all && prefix === undefined) {
644
- return fail("usage: mend stop <session-id-prefix> | --all [--project <p>]");
678
+ return fail(usageOf("stop"));
645
679
  }
646
680
  const [sessions, projects] = await Promise.all([
647
681
  api(config, "GET", "/sessions"),
@@ -671,19 +705,24 @@ const stopCommand = async (config, args) => {
671
705
  }
672
706
  targets = matches;
673
707
  }
708
+ await stopSessions(config, targets, all);
709
+ };
710
+ /** Stop each target and print its record link — the tail every stop path shares. */
711
+ const stopSessions = async (config, targets, summarise) => {
674
712
  for (const session of targets) {
675
713
  await api(config, "POST", `/sessions/${session.id}/stop`);
676
714
  say(`${green("✓")} stopped · ${session.harness} · ${dim(session.id.slice(0, 8))} · ${session.branch}`);
677
715
  say(`${cobalt(" review")} · ${config.url}/sessions/${session.id}`);
678
716
  }
679
- if (all)
717
+ if (summarise) {
680
718
  say(`${green("✓")} stopped ${targets.length} session${targets.length === 1 ? "" : "s"}`);
719
+ }
681
720
  };
682
721
  /** Compact picker: numbered live sessions, one keystroke of typing. */
683
722
  const pickSessionInteractively = async (rows) => {
684
723
  say(dim("more than one live session — pick one:"));
685
724
  rows.forEach((row, index) => {
686
- say(` ${index + 1}. ${row.session.harness.padEnd(8)} ${dim(row.session.id.slice(0, 8))} ${row.projectName} ${dim(row.session.branch)}`);
725
+ say(` ${index + 1}. ${row.session.harness.padEnd(8)} ${dim(row.session.id.slice(0, 8))} ${row.projectName} ${dim(row.session.branch)} ${dim(row.session.status)}`);
687
726
  });
688
727
  const readline = await import("node:readline/promises");
689
728
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
@@ -699,7 +738,7 @@ const pickSessionInteractively = async (rows) => {
699
738
  * the cwd's project narrows, one candidate is taken, several go to the
700
739
  * picker, and a non-interactive caller never gets a silent guess.
701
740
  */
702
- const resolveLiveSession = async (config, prefix) => {
741
+ const resolveLiveSession = async (config, prefix, command) => {
703
742
  const sessions = await api(config, "GET", "/sessions?retained=1");
704
743
  if (prefix !== undefined) {
705
744
  const matches = sessions.filter((s) => s.id.startsWith(prefix));
@@ -721,7 +760,7 @@ const resolveLiveSession = async (config, prefix) => {
721
760
  if (candidates.length === 1)
722
761
  return only;
723
762
  if (process.stdin.isTTY !== true) {
724
- return fail("several live sessions — name one: mend shell <session-id-prefix>");
763
+ return fail(`several live sessions — name one: mend ${command} <session-id-prefix>`);
725
764
  }
726
765
  const nameById = new Map(projects.map((p) => [p.id, p.name]));
727
766
  return pickSessionInteractively(candidates.map((session) => ({
@@ -737,7 +776,7 @@ const resolveLiveSession = async (config, prefix) => {
737
776
  */
738
777
  const shellCommand = async (config, args) => {
739
778
  const prefix = args.find((a) => !a.startsWith("--"));
740
- const session = await resolveLiveSession(config, prefix);
779
+ const session = await resolveLiveSession(config, prefix, "shell");
741
780
  say(`${green("✓")} shell in ${session.harness} session ${dim(session.id.slice(0, 8))} · ${dim(session.branch)}${detachHint()}`);
742
781
  const shellProcess = await withSpinner("opening a shell in the session workspace…", api(config, "POST", `/sessions/${session.id}/shell`));
743
782
  say("");
@@ -865,14 +904,14 @@ const serviceAdd = async (config, args) => {
865
904
  const positional = args.filter((a, i) => !a.startsWith("--") && (nameFlag === -1 || i !== nameFlag + 1));
866
905
  const portRaw = positional.find((a) => /^\d+$/.test(a));
867
906
  if (portRaw === undefined) {
868
- return fail("usage: mend service add [session] <port> [--name <n>] [--udp] [--http|--https]");
907
+ return fail(usageOf("service add"));
869
908
  }
870
909
  const port = Number(portRaw);
871
910
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
872
911
  return fail(`"${portRaw}" is not a port`);
873
912
  }
874
913
  const prefix = positional.find((a) => a !== portRaw);
875
- const session = await resolveLiveSession(config, prefix);
914
+ const session = await resolveLiveSession(config, prefix, "service add");
876
915
  const service = await mutateService(config, "POST", `/sessions/${session.id}/services`, {
877
916
  port,
878
917
  name,
@@ -900,7 +939,7 @@ const serviceList = async (config) => {
900
939
  const serviceStop = async (config, args) => {
901
940
  const needle = args.find((a) => !a.startsWith("--"));
902
941
  if (needle === undefined)
903
- return fail("usage: mend service stop <name-or-id-prefix>");
942
+ return fail(usageOf("service stop"));
904
943
  const services = await fetchServices(config);
905
944
  const matches = services.filter((service) => service.label === needle || service.id.startsWith(needle));
906
945
  if (matches.length === 0)
@@ -943,8 +982,7 @@ const autoConnect = async (config, service) => {
943
982
  };
944
983
  const serviceRun = async (config, args) => {
945
984
  const dashdash = args.indexOf("--");
946
- const usage = "usage: mend service run [session] --port <port> [--name <n>] [--udp] [--http|--https] [--no-connect] -- <command...>\n" +
947
- " mend service run [session] <name> [--no-connect] (a declared recipe)";
985
+ const usage = usageOf("service run");
948
986
  // No explicit command = a DECLARED Service: resolve the name against the
949
987
  // session worktree's mend.toml and start (or adopt) its recipe.
950
988
  if (dashdash === -1) {
@@ -953,7 +991,7 @@ const serviceRun = async (config, args) => {
953
991
  if (name === undefined)
954
992
  return fail(usage);
955
993
  const prefix = positionals.length > 1 ? positionals[0] : undefined;
956
- const session = await resolveLiveSession(config, prefix);
994
+ const session = await resolveLiveSession(config, prefix, "service run");
957
995
  const recipes = await api(config, "GET", `/sessions/${session.id}/recipes`);
958
996
  const recipe = recipes.find((entry) => entry.name === name);
959
997
  if (recipe === undefined) {
@@ -996,7 +1034,7 @@ const serviceRun = async (config, args) => {
996
1034
  if (protocol === "udp" && browserScheme !== null)
997
1035
  return fail(usage);
998
1036
  const prefix = head.find((a, i) => !a.startsWith("--") && i !== portFlag + 1 && i !== nameFlag + 1);
999
- const session = await resolveLiveSession(config, prefix);
1037
+ const session = await resolveLiveSession(config, prefix, "service run");
1000
1038
  const service = await withSpinner(protocol === "udp"
1001
1039
  ? `starting ${name ?? argv[0]} (udp :${port})…`
1002
1040
  : `starting ${name ?? argv[0]} — waiting for :${port} to answer…`, mutateService(config, "POST", `/sessions/${session.id}/services/run`, {
@@ -1019,7 +1057,7 @@ const serviceLogs = async (config, args) => {
1019
1057
  const from = fromFlag === -1 ? "0" : args[fromFlag + 1];
1020
1058
  const needle = args.find((argument, index) => !argument.startsWith("--") && index !== fromFlag + 1);
1021
1059
  if (needle === undefined || from === undefined || !/^(0|[1-9]\d*)$/.test(from)) {
1022
- return fail("usage: mend service logs <name-or-id-prefix> [--from <decimal-sequence>]");
1060
+ return fail(usageOf("service logs"));
1023
1061
  }
1024
1062
  const everything = await fetchServiceViews(config, true);
1025
1063
  const matches = everything.filter((view) => view.service.name === needle ||
@@ -1075,7 +1113,7 @@ const serviceLogs = async (config, args) => {
1075
1113
  const serviceRestart = async (config, args) => {
1076
1114
  const needle = args.find((a) => !a.startsWith("--"));
1077
1115
  if (needle === undefined)
1078
- return fail("usage: mend service restart <name-or-id-prefix>");
1116
+ return fail(usageOf("service restart"));
1079
1117
  const service = await findLiveService(config, needle);
1080
1118
  const restarted = await withSpinner(`restarting ${service.label ?? service.id.slice(0, 8)}…`, mutateService(config, "POST", `/services/${service.id}/restart`));
1081
1119
  say(`${green("✓")} restarted · ${restarted.status}`);
@@ -1321,7 +1359,7 @@ const accountsCommand = async (config) => {
1321
1359
  const connectCommand = async (config, args) => {
1322
1360
  const [providerArg, ...flags] = args;
1323
1361
  if (!isProvider(providerArg)) {
1324
- return fail("usage: mend connect claude|codex|github [--from-stdin] [--remove]");
1362
+ return fail(usageOf("connect"));
1325
1363
  }
1326
1364
  const provider = providerArg;
1327
1365
  if (flags.includes("--remove")) {
@@ -1554,7 +1592,7 @@ const keysCommand = async (config, args) => {
1554
1592
  case undefined:
1555
1593
  return keysShow(config);
1556
1594
  default:
1557
- return fail(`unknown keys command "${verb}" — try: mend keys init | show | share`);
1595
+ return fail(`unknown keys command "${verb}" · mend help keys lists them`);
1558
1596
  }
1559
1597
  };
1560
1598
  const formatDotfileBytes = (bytes) => bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`;
@@ -1717,14 +1755,14 @@ const envCluster = async (config, args) => {
1717
1755
  const explicitProject = takeFlagValue(args, "--project");
1718
1756
  const positional = args.filter((arg, i) => !arg.startsWith("--") && args[i - 1] !== "--project");
1719
1757
  const [verb, ...rest] = positional;
1720
- const usage = "try: mend env cluster add secret <name> | add configmap <name> | remove <kind>/<name> | sa <name> | sa --clear";
1758
+ const usage = usageOf("env cluster");
1721
1759
  const project = await findProject(config, explicitProject);
1722
1760
  const route = `/projects/${project.id}/cluster-bindings`;
1723
1761
  const appliesLine = () => say(dim(" applies from the next workspace launch; running workspaces keep what they started with"));
1724
1762
  if (verb === "add") {
1725
1763
  const [kind, objectName] = rest;
1726
1764
  if ((kind !== "secret" && kind !== "configmap") || objectName === undefined) {
1727
- return fail(`env cluster add takes a kind and an object name — ${usage}`);
1765
+ return fail(`env cluster add takes a kind and an object name\n${usage}`);
1728
1766
  }
1729
1767
  const result = await api(config, "POST", route, {
1730
1768
  kind,
@@ -1738,7 +1776,7 @@ const envCluster = async (config, args) => {
1738
1776
  const [ref] = rest;
1739
1777
  const [kind, objectName] = ref?.split("/", 2) ?? [];
1740
1778
  if ((kind !== "secret" && kind !== "configmap") || objectName === undefined) {
1741
- return fail(`env cluster remove takes <kind>/<name>, e.g. secret/app-env — ${usage}`);
1779
+ return fail(`env cluster remove takes <kind>/<name>, e.g. secret/app-env\n${usage}`);
1742
1780
  }
1743
1781
  const snapshot = await api(config, "GET", route);
1744
1782
  const binding = snapshot.bindings.find((b) => b.kind === kind && b.objectName === objectName);
@@ -1752,7 +1790,7 @@ const envCluster = async (config, args) => {
1752
1790
  const clear = args.includes("--clear");
1753
1791
  const [name] = rest;
1754
1792
  if (!clear && name === undefined)
1755
- return fail(`env cluster sa takes a name or --clear — ${usage}`);
1793
+ return fail(`env cluster sa takes a name or --clear\n${usage}`);
1756
1794
  const result = await api(config, "PUT", `${route}/service-account`, { serviceAccount: clear ? null : name });
1757
1795
  say(result.serviceAccount === null
1758
1796
  ? `${green("✓")} cleared the workspace service account ${dim(`· cluster r${result.revision}`)}`
@@ -1762,7 +1800,7 @@ const envCluster = async (config, args) => {
1762
1800
  }
1763
1801
  return appliesLine();
1764
1802
  }
1765
- return fail(`unknown env cluster command "${verb ?? ""}" — ${usage}`);
1803
+ return fail(`unknown env cluster command "${verb ?? ""}"\n${usage}`);
1766
1804
  };
1767
1805
  const envCommand = async (config, args) => {
1768
1806
  const [verb, ...rest] = args;
@@ -1775,7 +1813,7 @@ const envCommand = async (config, args) => {
1775
1813
  case undefined:
1776
1814
  return envShow(config, rest);
1777
1815
  default:
1778
- return fail(`unknown env command "${verb}" — try: mend env load | show | cluster`);
1816
+ return fail(`unknown env command "${verb}" · mend help env lists them`);
1779
1817
  }
1780
1818
  };
1781
1819
  const dotfilesCommand = async (config, args) => {
@@ -1787,7 +1825,7 @@ const dotfilesCommand = async (config, args) => {
1787
1825
  case undefined:
1788
1826
  return dotfilesShow(config);
1789
1827
  default:
1790
- return fail(`unknown dotfiles command "${verb}" — try: mend dotfiles sync | show`);
1828
+ return fail(`unknown dotfiles command "${verb}" · mend help dotfiles lists them`);
1791
1829
  }
1792
1830
  };
1793
1831
  /** With `--project` the project's library; bare, your own. */
@@ -1856,7 +1894,7 @@ const skillsCommand = async (config, args) => {
1856
1894
  // Bare flags (`mend skills --project web`) read as the list.
1857
1895
  if (verb.startsWith("--"))
1858
1896
  return skillsList(config, args);
1859
- return fail(`unknown skills command "${verb}" — try: mend skills push | list`);
1897
+ return fail(`unknown skills command "${verb}" · mend help skills lists them`);
1860
1898
  }
1861
1899
  };
1862
1900
  // ─── completions: live sessions under TAB ───────────────────────────────────
@@ -1941,7 +1979,7 @@ const completionsCommand = (args) => {
1941
1979
  process.stdout.write(BASH_COMPLETIONS);
1942
1980
  return;
1943
1981
  default:
1944
- return fail('usage: mend completions zsh|bash — e.g. mend completions zsh > "$fpath[1]/_mend"');
1982
+ return fail(`${usageOf("completions")} · e.g. mend completions zsh > "$fpath[1]/_mend"`);
1945
1983
  }
1946
1984
  };
1947
1985
  // ─── supervised run: platform workspace + PTY + record ──────────────────────
@@ -2482,7 +2520,7 @@ const hasNodeFfi = () => {
2482
2520
  */
2483
2521
  const dashboard = async (config) => {
2484
2522
  if (process.stdout.isTTY !== true) {
2485
- say(HELP);
2523
+ say(renderIndex());
2486
2524
  return;
2487
2525
  }
2488
2526
  if (!hasNodeFfi()) {
@@ -2503,98 +2541,54 @@ const dashboard = async (config) => {
2503
2541
  });
2504
2542
  };
2505
2543
  // ─── entry ──────────────────────────────────────────────────────────────────
2506
- const HELP = `mend — the agent workbench
2507
-
2508
- start
2509
- mend login [--url <server>] sign this terminal in through the browser: opens
2510
- <server>/authorize, you press Authorize there, and the
2511
- CLI saves a revocable device token (0600); no password
2512
- is ever typed here
2513
- mend connect <provider> [--from-stdin] [--remove]
2514
- send THIS machine's claude/codex/github credential to the
2515
- platform under your own user (reads the file the provider's
2516
- CLI wrote at login; --from-stdin pastes one instead)
2517
- mend adopt [source] [--name <name>] [--auth ambient|mend-key|bridge]
2518
- adopt a repository into the store (default: cwd; any git
2519
- URL — GitHub, GitLab, self-hosted, ssh://, a local path)
2520
- mend codex|claude|opencode ["prompt"] [--name <worktree>] [--worktree <existing>]
2521
- [--model <id>] [--effort low|medium|high|xhigh|max]
2522
- [--base <ref>] [--ask] [--fast] [--detach|-d] [--foreground]
2523
- launch the harness in a worktree; the worktree's name is
2524
- asked first (--name skips the ask — an EXISTING name joins
2525
- that worktree as a new session; --worktree joins only,
2526
- failing if absent), a
2527
- quoted prompt becomes its first message,
2528
- --ask restores the harness's permission prompts, --fast
2529
- requests priority processing (codex service tier),
2530
- --detach launches without attaching (reattach anywhere),
2531
- --foreground stops the session when this CLI exits
2532
- mend pair [--url <base url>] pair a phone or a second machine: prints a QR, the code, and
2533
- the URL to reach this server (one device, once, 10 minutes)
2534
- mend doctor read-only checklist of this machine's setup — one line per
2535
- fact, each unfinished one ending in the command that fixes it
2536
-
2537
- everything else
2538
- mend the dashboard: every project and session, live
2539
- mend logout revoke this terminal's device token and forget it
2540
- mend keys init generate the machine's Mend deploy key (ed25519)
2541
- mend keys show print the public key — add it as a deploy key on your git host
2542
- mend env load [file] [--secret [A,B]] load a .env into the project: ordinary names → configuration,
2543
- secret-shaped names → secrets; --secret sends all (or the
2544
- named ones, e.g. DATABASE_URL) to secrets
2545
- mend env show what the project store holds — names only, never values
2546
- mend accounts your connected accounts on the platform (claude, codex, github)
2547
- mend dotfiles your dotfiles on the server: repo + synced home files
2548
- mend dotfiles sync [--all | paths…] capture config files from THIS machine into your store
2549
- mend skills [--project [p]] your skill library on the server (or a project's)
2550
- mend skills push [--project [p]] [--prune] [--dir <path>]
2551
- upload ~/.agents/skills bundles into the library; sessions
2552
- receive them at launch (--prune removes what's gone)
2553
- mend keys share relay THIS machine's ssh-agent to the server (bridge mode:
2554
- hardware keys sign here; Ctrl-C stops sharing)
2555
- mend run -- <command...> same, with an arbitrary command
2556
- mend worktrees [--project <p>] [--json]
2557
- every worktree and the sessions inside it (mend sessions
2558
- --json stays the v1 flat list; --json=v2 groups like this)
2559
- mend attach <session-id-prefix> reattach this terminal to a running session
2560
- mend stop <session-id-prefix> | --all [--project <p>]
2561
- stop the agent — the workspace harvests and closes; the
2562
- record and review remain
2563
- mend shell [session-id-prefix] open a shell in a live session's workspace
2564
- mend service run [session] --port <p> [--name <n>] [--udp] -- <command...>
2565
- start + supervise a server in the session workspace
2566
- mend service run [session] <name> start a declared Service (mend.toml recipe)
2567
- mend service <name> shorthand for the above
2568
- mend service init [--yes] scaffold mend.toml from package.json + compose ports
2569
- mend service add [session] <port> [--name <n>] [--udp]
2570
- adopt a listening workspace port — reachable on this machine
2571
- mend service connect [name…] [--port <p>]
2572
- bring live Services to THIS machine's loopback — each
2573
- connection tunnels through the server, authenticated as you
2574
- mend service list every live service and its observed state
2575
- mend service logs <name-or-id> follow a supervised service's output (replay, then live)
2576
- mend service restart <name-or-id> re-run its recorded command — same URL
2577
- mend service stop <name-or-id> stop a service (closes its host port)
2578
- mend continue [session-id] resume a session with its pending review follow-up
2579
- mend resume [session-id] [--with h] rejoin a settled session (state restored; --with switches harness)
2580
- mend rejoin [session-id] [--harness h] attach if live, otherwise resume; newest live wins
2581
- mend refresh [project] fetch origin's branches into the store — new sessions
2582
- base on current tips (default: the cwd's project)
2583
- mend projects adopted projects and their live sessions
2584
- mend sessions [--all] [--project p] [--json]
2585
- sessions with review facts; JSON is stable for integrations
2586
- mend status active sessions (alias of mend sessions)
2587
- mend ssh workspace SSH status: gateway, registered keys, ssh config
2588
- mend ssh setup [--key <path>] make this machine ready once: offer a key (ssh-agent
2589
- preferred — nothing new created), write Host mend-ws
2590
- mend completions zsh|bash print the TAB-completion hook (live session ids under TAB)
2591
-
2592
- server: MEND_URL (default http://localhost:3105) · auth: MEND_TOKEN
2593
- detach key: Ctrl+] (set MEND_DETACH_KEY=none when an outer multiplexer owns detaching)
2594
- config file: ~/.config/mend/cli.json { "url": ..., "token": ..., "deviceId": ... }
2595
- `;
2544
+ /** `mend help [command...]`: the index, a group, or one page. */
2545
+ const helpCommand = (words) => {
2546
+ if (words.length === 0) {
2547
+ say(renderIndex());
2548
+ return;
2549
+ }
2550
+ const doc = findCommand(words);
2551
+ if (doc !== null && (doc.name.split(" ").length > 1 || words.length === 1)) {
2552
+ say(renderCommand(doc));
2553
+ return;
2554
+ }
2555
+ const group = renderGroup(words[0] ?? "");
2556
+ if (doc !== null) {
2557
+ say(renderCommand(doc));
2558
+ return;
2559
+ }
2560
+ if (group !== null) {
2561
+ say(group);
2562
+ return;
2563
+ }
2564
+ return fail(`no command "${words.join(" ")}" · mend help lists them`);
2565
+ };
2566
+ /** `mend man [command...]`: the same page through man(1), or the text page without it. */
2567
+ const manCommand = (words) => {
2568
+ const doc = words.length === 0 ? null : findCommand(words);
2569
+ if (words.length > 0 && doc === null) {
2570
+ return fail(`no command "${words.join(" ")}" · mend help lists them`);
2571
+ }
2572
+ const version = cliVersion();
2573
+ const page = doc === null ? renderManIndex(version) : renderManPage(doc, version);
2574
+ const file = path.join(os.tmpdir(), `mend-man-${process.pid}-${manFileName(doc)}`);
2575
+ fs.writeFileSync(file, page);
2576
+ const result = spawnSync("man", ["-l", file], { stdio: "inherit" });
2577
+ fs.rmSync(file, { force: true });
2578
+ if (result.error !== undefined || result.status !== 0) {
2579
+ say(doc === null ? renderIndex() : renderCommand(doc));
2580
+ }
2581
+ };
2596
2582
  const main = async () => {
2597
2583
  const [command, ...rest] = process.argv.slice(2);
2584
+ // `mend <command> --help` (or -h) before the separator is that command's page,
2585
+ // never an argument: `mend run -- cmd --help` keeps its --help for cmd.
2586
+ const ownArgs = rest.slice(0, rest.indexOf("--") === -1 ? rest.length : rest.indexOf("--"));
2587
+ if (command !== undefined &&
2588
+ command !== "help" &&
2589
+ ownArgs.some((a) => a === "--help" || a === "-h")) {
2590
+ return helpCommand([command, ...ownArgs.filter((a) => !a.startsWith("-"))]);
2591
+ }
2598
2592
  const config = loadConfig();
2599
2593
  switch (command) {
2600
2594
  case "adopt":
@@ -2628,7 +2622,7 @@ const main = async () => {
2628
2622
  return connectCommand(config, rest);
2629
2623
  case "pair":
2630
2624
  return pairCommand(rest, boundApi(config));
2631
- // Deliberately absent from HELP: the installer renders its own QR through this.
2625
+ // Hidden in the catalog: the installer renders its own QR through this.
2632
2626
  case "qr":
2633
2627
  return qrCommand(rest);
2634
2628
  case "doctor":
@@ -2661,10 +2655,18 @@ const main = async () => {
2661
2655
  return dashboard(config);
2662
2656
  case "help":
2663
2657
  case "--help":
2664
- say(HELP);
2658
+ case "-h":
2659
+ return helpCommand(rest);
2660
+ case "man":
2661
+ return manCommand(rest);
2662
+ case "version":
2663
+ case "--version":
2664
+ case "-v":
2665
+ for (const line of versionLines(cliVersion(), await fetchServerVersion(config.url)))
2666
+ say(line);
2665
2667
  return;
2666
2668
  default:
2667
- return fail(`unknown command "${command}" — try: mend help`);
2669
+ return fail(`unknown command "${command}" · mend help lists them`);
2668
2670
  }
2669
2671
  };
2670
2672
  await main();
@@ -0,0 +1,48 @@
1
+ import * as fs from "node:fs";
2
+ /**
3
+ * `mend version`: this CLI's version, then the server's when it answers.
4
+ * The two drift apart in practice — an npm install on one machine, a cluster
5
+ * roll on another — and "which one am I on" is the first question any bug
6
+ * report needs answered. The server line is best-effort: a missing server is
7
+ * a fact to print, never an error.
8
+ */
9
+ /** The published version, read from the package this file ships in (dist/ and src/ sit one level under it). */
10
+ export const cliVersion = () => {
11
+ const raw = fs.readFileSync(new URL("../package.json", import.meta.url), "utf8");
12
+ const parsed = JSON.parse(raw);
13
+ if (typeof parsed === "object" && parsed !== null && "version" in parsed) {
14
+ const version = parsed.version;
15
+ if (typeof version === "string")
16
+ return version;
17
+ }
18
+ return "unknown";
19
+ };
20
+ /** Ask the server's health route, briefly; a slow or absent server is `null`. */
21
+ export const fetchServerVersion = async (url, timeoutMs = 2_000) => {
22
+ try {
23
+ const response = await fetch(`${url}/api/health`, {
24
+ signal: AbortSignal.timeout(timeoutMs),
25
+ });
26
+ if (!response.ok)
27
+ return { url, version: null };
28
+ const body = await response.json();
29
+ const version = typeof body === "object" && body !== null && "version" in body ? body.version : null;
30
+ return { url, version: typeof version === "string" ? version : null };
31
+ }
32
+ catch {
33
+ return { url, version: null };
34
+ }
35
+ };
36
+ /** The lines `mend version` prints — terse facts, no verdict beyond the mismatch note. */
37
+ export const versionLines = (cli, server) => {
38
+ const lines = [`mend ${cli}`];
39
+ if (server.version === null) {
40
+ lines.push(`server · unreachable · ${server.url}`);
41
+ }
42
+ else {
43
+ lines.push(`server ${server.version} · ${server.url}`);
44
+ if (server.version !== cli)
45
+ lines.push("versions differ — the server's API wins");
46
+ }
47
+ return lines;
48
+ };
@@ -0,0 +1,11 @@
1
+ .TH MEND-ACCOUNTS 1 "" "mend 0.18.0" "mend manual"
2
+ .SH NAME
3
+ mend\-accounts \- your connected accounts on the platform
4
+ .SH SYNOPSIS
5
+ .B mend accounts
6
+ .br
7
+ .SH DESCRIPTION
8
+ .PP
9
+ claude, codex, and github: connected, invalid, or archived, and when each was last used.
10
+ .SH SEE ALSO
11
+ mend(1), mend\-connect(1)
@@ -0,0 +1,27 @@
1
+ .TH MEND-ADOPT 1 "" "mend 0.18.0" "mend manual"
2
+ .SH NAME
3
+ mend\-adopt \- adopt a repository into the store
4
+ .SH SYNOPSIS
5
+ .B mend adopt [source] [\-\-name <name>] [\-\-auth ambient|mend\-key|bridge]
6
+ .br
7
+ .SH DESCRIPTION
8
+ .PP
9
+ Clones the repository into Mend's store. Every session then gets its own worktree of it. The default source is the current directory; any git URL works, GitHub, GitLab, self\-hosted, ssh://, or a local path.
10
+ .PP
11
+ \-\-auth says how the store fetches from the remote. ambient uses the server's own credentials. mend\-key uses this machine's deploy key (see mend keys). bridge relays this machine's ssh\-agent while mend keys share runs, so hardware keys stay here.
12
+ .SH OPTIONS
13
+ .TP
14
+ .B \-\-name <name>
15
+ the project's name in Mend. Default: the repository's
16
+ .TP
17
+ .B \-\-auth <mode>
18
+ ambient, mend\-key, or bridge. Default: ambient
19
+ .SH EXAMPLES
20
+ .PP
21
+ .B mend adopt
22
+ .br
23
+ the repository you are standing in
24
+ .PP
25
+ .B mend adopt git@github.com:acme/api.git \-\-auth mend\-key
26
+ .SH SEE ALSO
27
+ mend(1), mend\-keys\-init(1), mend\-keys\-share(1), mend\-refresh(1)
@@ -0,0 +1,13 @@
1
+ .TH MEND-ATTACH 1 "" "mend 0.18.0" "mend manual"
2
+ .SH NAME
3
+ mend\-attach \- reattach this terminal to a running session
4
+ .SH SYNOPSIS
5
+ .B mend attach [session\-id\-prefix]
6
+ .br
7
+ .SH DESCRIPTION
8
+ .PP
9
+ With no id, the one live session is taken; with several, a picker opens. A prefix of the id is enough.
10
+ .PP
11
+ A session that was picked up on the phone is taken back into this terminal: the phone's agent ends and the same conversation continues here.
12
+ .SH SEE ALSO
13
+ mend(1), mend\-stop(1), mend\-rejoin(1), mend\-shell(1)