@monster-orange/den 0.4.4 → 0.4.6

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 (57) hide show
  1. package/CHANGELOG.md +243 -0
  2. package/README.md +24 -9
  3. package/docs/distributions.md +320 -26
  4. package/docs/reference.md +8 -3
  5. package/package.json +4 -3
  6. package/skills/install-den/SKILL.md +114 -0
  7. package/src/shared/agent-setup.ts +9 -0
  8. package/src/shared/auth/sudo-vault.ts +115 -11
  9. package/src/shared/cli-command.ts +186 -0
  10. package/src/shared/confirm-code.ts +34 -0
  11. package/src/shared/constants.ts +7 -0
  12. package/src/shared/den-config.ts +7 -0
  13. package/src/shared/dev-metrics/emit.ts +59 -2
  14. package/src/shared/edge/config.ts +39 -0
  15. package/src/shared/edge/render.ts +92 -18
  16. package/src/shared/edge/runtime.ts +57 -1
  17. package/src/shared/edge/types.ts +11 -1
  18. package/src/shared/github-app.ts +94 -0
  19. package/src/shared/github-device.ts +286 -0
  20. package/src/shared/port-pick.ts +60 -0
  21. package/src/shared/secrets/onepassword.ts +27 -4
  22. package/src/shared/web-owner.ts +91 -0
  23. package/src/shared/web-session.ts +135 -1
  24. package/src/ui/cli/create-main.ts +1 -1
  25. package/src/ui/cli/create.ts +263 -30
  26. package/src/ui/cli/doctor-command.ts +67 -4
  27. package/src/ui/cli/doctor-onboard.ts +291 -0
  28. package/src/ui/cli/flag-parser.ts +5 -1
  29. package/src/ui/cli/index.ts +246 -4
  30. package/src/ui/cli/json-out.ts +49 -0
  31. package/src/ui/cli/mcp-connect.ts +179 -15
  32. package/src/ui/cli/repo-command.ts +490 -0
  33. package/src/ui/cli/repo-detect.ts +192 -0
  34. package/src/ui/cli/web-command.ts +513 -33
  35. package/src/ui/mcp/connect.ts +134 -0
  36. package/src/ui/server/device-flows.ts +274 -0
  37. package/src/ui/server/edge-exposure.ts +16 -4
  38. package/src/ui/server/github-routes.ts +321 -0
  39. package/src/ui/server/http-errors.ts +10 -0
  40. package/src/ui/server/index.ts +463 -27
  41. package/src/ui/server/login-waits.ts +203 -0
  42. package/src/ui/server/pair-requests.ts +237 -0
  43. package/src/ui/server/pair-routes.ts +196 -0
  44. package/src/ui/server/public/error-page.js +452 -40
  45. package/src/ui/server/public/error.html +59 -0
  46. package/src/ui/server/public/i18n/es.js +74 -8
  47. package/src/ui/server/public/i18n/ja.js +74 -8
  48. package/src/ui/server/public/local-dev.html +16 -4
  49. package/src/ui/server/public/qr.js +317 -0
  50. package/src/ui/server/public/settings-page.js +10 -3
  51. package/src/ui/server/public/settings-registry.js +392 -1
  52. package/src/ui/server/public/shell.js +123 -10
  53. package/src/ui/server/public/topbar.js +7 -3
  54. package/src/ui/server/public/workspace-picker.js +1 -1
  55. package/src/ui/server/request-guard.ts +103 -36
  56. package/src/ui/server/route-classes.ts +102 -2
  57. package/src/ui/server/ui-assets.ts +2 -0
@@ -26,6 +26,7 @@ import { envSelector } from "../../shared/environment/env-selector.js";
26
26
  import {
27
27
  recordInvocation,
28
28
  recordFailure,
29
+ shipFromDetachedSender,
29
30
  startTimer,
30
31
  } from "../../shared/dev-metrics/emit.js";
31
32
  import type { Environment, Pillar } from "../../shared/types/index.js";
@@ -242,6 +243,8 @@ import {
242
243
  import { DEN_BANNER_LINES, DEN_DIVIDER } from "../../shared/banner.js";
243
244
 
244
245
  import { product } from "../../shared/product.js";
246
+ import { AGENT_SETUP_LINE } from "../../shared/agent-setup.js";
247
+ import { ExitRequested, printJson, quietStdout } from "./json-out.js";
245
248
  const DEN_HELP_DESCRIPTION = ` ${product().title} - local-first test, service, and workspace orchestration`;
246
249
 
247
250
  /**
@@ -360,6 +363,16 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
360
363
  "-w writes that workspace (active or not) and leaves focus alone.",
361
364
  ],
362
365
  },
366
+ repo: {
367
+ usage:
368
+ 'repo add <path|git-url> [--sidecar] [-w <workspace>] [--name <repo>] [--run "<cmd>"] [--health <url>] [--json]',
369
+ lines: [
370
+ "Add a repo with no prompts, never writing into it. A repo with no",
371
+ "manifest (or --sidecar) gets a side-car manifest in manifests/, detected",
372
+ "from its Makefile, compose file or package.json scripts. Safe to re-run.",
373
+ "`{cli} repo add --help` says more.",
374
+ ],
375
+ },
363
376
  relocate: {
364
377
  usage: "relocate <repo> <path> [--dry-run] [--force] [-w <workspace>]",
365
378
  lines: [
@@ -465,8 +478,11 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
465
478
  ],
466
479
  },
467
480
  doctor: {
468
- usage: "doctor [--probe]",
481
+ usage: "doctor [--probe] [--json]",
469
482
  lines: [
483
+ "--json: one object, { ok, checks: [{ id, status: ok|warn|fail, detail, fix?, ask? }] }:",
484
+ "Node 22.13+, git, Docker (only when a registered repo needs it), the web",
485
+ "server and Den's MCP through it, then each strategy below. Exit 1 on a fail.",
470
486
  "Each strategy kind (secrets, tls): the one selected, whether it was implicit,",
471
487
  "and whether it can work here (a PATH check; nothing is run or prompted).",
472
488
  "--probe: start a short probe session (through the session host) for a",
@@ -575,6 +591,7 @@ const TELEMETRY_COMMANDS = new Set<string>([
575
591
  "service",
576
592
  "theme",
577
593
  "plugin",
594
+ "repo",
578
595
  ]);
579
596
 
580
597
  /**
@@ -678,7 +695,18 @@ function printValidation(
678
695
  }
679
696
  }
680
697
 
698
+ /** What `up --json` collects while up runs (runServicesCommand fills it). */
699
+ interface UpReport {
700
+ started: string[];
701
+ failed: string[];
702
+ alreadyRunning: string[];
703
+ skipped: string[];
704
+ }
705
+
681
706
  class DenCLI {
707
+ /** Set while `up --json` runs. */
708
+ private upReport: UpReport | null = null;
709
+
682
710
  private static readonly serviceCommands = new Set([
683
711
  "up",
684
712
  "down",
@@ -715,6 +743,8 @@ class DenCLI {
715
743
  // synchronously. `telCmd` is always a REGISTERED command name (never raw
716
744
  // argv — PII); it is refined once the args parse successfully.
717
745
  const stopTimer = startTimer();
746
+ // The exit hook can't await a send; hand it to a detached sender.
747
+ shipFromDetachedSender();
718
748
  let telCmd = telemetryCommandName(args.find((a) => !a.startsWith("-")));
719
749
  process.once("exit", (code) => {
720
750
  recordInvocation({
@@ -868,13 +898,31 @@ class DenCLI {
868
898
  process.exit(0);
869
899
  break;
870
900
 
871
- case "web":
901
+ case "web": {
902
+ const sub = args[1];
903
+ if (
904
+ args.includes("--json") &&
905
+ (sub === undefined ||
906
+ sub.startsWith("-") ||
907
+ sub === "up" ||
908
+ sub === "restart")
909
+ ) {
910
+ process.exitCode = await this.cmdWebUpJson(
911
+ args.slice(1).filter((a) => a !== "--json"),
912
+ );
913
+ break;
914
+ }
872
915
  await cmdWeb(args.slice(1));
873
916
  break;
917
+ }
874
918
 
875
919
  // ── Environment manager commands ──────────────────────────────────
876
920
  case "up":
877
- await this.cmdServiceUp(args.slice(1));
921
+ if (args.slice(1).includes("--json"))
922
+ process.exitCode = await this.cmdServiceUpJson(
923
+ args.slice(1).filter((a) => a !== "--json"),
924
+ );
925
+ else await this.cmdServiceUp(args.slice(1));
878
926
  break;
879
927
 
880
928
  case "down":
@@ -906,6 +954,12 @@ class DenCLI {
906
954
  await this.cmdRegister(args.slice(1));
907
955
  break;
908
956
 
957
+ case "repo": {
958
+ const { cmdRepo } = await import("./repo-command.js");
959
+ process.exitCode = await cmdRepo(args.slice(1), validateManifest);
960
+ break;
961
+ }
962
+
909
963
  case "relocate":
910
964
  await this.cmdRelocate(args.slice(1));
911
965
  break;
@@ -967,7 +1021,7 @@ class DenCLI {
967
1021
  case "create": {
968
1022
  // bin/den routes `create` to its own entry (it needs no den.yml);
969
1023
  // this covers the CLI run directly from a checkout.
970
- const code = runCreate(args.slice(1), getInvokeCwd());
1024
+ const code = await runCreate(args.slice(1), getInvokeCwd());
971
1025
  if (code !== 0) process.exit(code);
972
1026
  break;
973
1027
  }
@@ -1829,8 +1883,10 @@ class DenCLI {
1829
1883
  if (isNamedTarget(address, opts?.named ?? [])) {
1830
1884
  console.log(chalk.red(` ✗ ${remote}`));
1831
1885
  process.exitCode = 1;
1886
+ this.upReport?.failed.push(address);
1832
1887
  } else {
1833
1888
  console.log(chalk.gray(` • ${remote}`));
1889
+ this.upReport?.skipped.push(address);
1834
1890
  }
1835
1891
  continue;
1836
1892
  }
@@ -1854,6 +1910,7 @@ class DenCLI {
1854
1910
  );
1855
1911
  // A dependency only warns; the service you named failed.
1856
1912
  if (isNamedTarget(address, opts?.named ?? [])) process.exitCode = 1;
1913
+ this.upReport?.failed.push(address);
1857
1914
  continue;
1858
1915
  }
1859
1916
  if (health.health === "ok" && claim !== "conflict") {
@@ -1870,6 +1927,7 @@ class DenCLI {
1870
1927
  ` • ${chalk.bold(address)} already healthy — skipping dependency up${suffix}`,
1871
1928
  ),
1872
1929
  );
1930
+ if (command === "up") this.upReport?.alreadyRunning.push(address);
1873
1931
  continue;
1874
1932
  }
1875
1933
  }
@@ -2103,6 +2161,8 @@ class DenCLI {
2103
2161
  // Any target that failed makes the whole command fail (scripts and
2104
2162
  // agents read the exit code).
2105
2163
  if (!started) process.exitCode = 1;
2164
+ if (command === "up")
2165
+ this.upReport?.[started ? "started" : "failed"].push(address);
2106
2166
  if (plan?.failFast && !started) {
2107
2167
  console.log(
2108
2168
  chalk.red(
@@ -2746,6 +2806,10 @@ class DenCLI {
2746
2806
  "act in that (active) workspace instead of the focused one; focus stays",
2747
2807
  );
2748
2808
  row("--skip-secrets", "skip the secrets-backend sign-in preflight");
2809
+ row(
2810
+ "--json",
2811
+ "log on stderr; stdout: { ok, services: [{ address, result, health }], fix? }",
2812
+ );
2749
2813
  row("-h, --help", "show this help");
2750
2814
  console.log();
2751
2815
  }
@@ -3099,6 +3163,177 @@ class DenCLI {
3099
3163
  );
3100
3164
  }
3101
3165
 
3166
+ /**
3167
+ * `up --json` (ON-2): up as it always runs, its log on stderr, then one
3168
+ * JSON object on stdout: each service it touched, what happened to it
3169
+ * (started, already-running, failed, skipped) and its health once started
3170
+ * (a started service with a health check is waited on, up to 30s). Safe to
3171
+ * re-run: a healthy service is already-running. Every failure names a fix.
3172
+ */
3173
+ private async cmdServiceUpJson(args: string[]): Promise<number> {
3174
+ const report: UpReport = {
3175
+ started: [],
3176
+ failed: [],
3177
+ alreadyRunning: [],
3178
+ skipped: [],
3179
+ };
3180
+ const bin = fs.existsSync(
3181
+ nodePath.join(getDistRoot(), "bin", product().cli),
3182
+ )
3183
+ ? `./bin/${product().cli}`
3184
+ : product().cli;
3185
+ const before = process.exitCode;
3186
+ process.exitCode = undefined;
3187
+ let error: string | undefined;
3188
+ this.upReport = report;
3189
+ try {
3190
+ await quietStdout(() => this.cmdServiceUp(args));
3191
+ } catch (e) {
3192
+ if (!(e instanceof ExitRequested)) error = (e as Error).message;
3193
+ else if (e.code !== 0) process.exitCode = e.code;
3194
+ } finally {
3195
+ this.upReport = null;
3196
+ }
3197
+ const failedExit = process.exitCode !== undefined && process.exitCode !== 0;
3198
+ process.exitCode = before;
3199
+ const workspace =
3200
+ process.env.DEN_WORKSPACE_OVERRIDE ??
3201
+ getFocusedWorkspaceName() ??
3202
+ undefined;
3203
+ const result = (address: string): string =>
3204
+ report.failed.includes(address)
3205
+ ? "failed"
3206
+ : report.started.includes(address)
3207
+ ? "started"
3208
+ : report.alreadyRunning.includes(address)
3209
+ ? "already-running"
3210
+ : "skipped";
3211
+ const touched = [
3212
+ ...new Set([
3213
+ ...report.started,
3214
+ ...report.alreadyRunning,
3215
+ ...report.failed,
3216
+ ...report.skipped,
3217
+ ]),
3218
+ ];
3219
+ const services = await Promise.all(
3220
+ touched.map(async (address) => {
3221
+ const resolved = resolveService(address, workspace);
3222
+ let health = "unknown";
3223
+ let detail: string | undefined;
3224
+ if (resolved && result(address) !== "skipped") {
3225
+ const ready =
3226
+ result(address) === "started"
3227
+ ? await waitForReady(resolved, "local", { timeoutMs: 30_000 })
3228
+ : null;
3229
+ const h = await checkServiceHealth(resolved, "local");
3230
+ health = h.health;
3231
+ detail = h.healthDetail ?? ready?.detail;
3232
+ }
3233
+ return {
3234
+ address,
3235
+ result: result(address),
3236
+ health,
3237
+ ...(detail ? { detail } : {}),
3238
+ };
3239
+ }),
3240
+ );
3241
+ const unhealthy = services.filter(
3242
+ (s) => s.result !== "skipped" && s.health === "error",
3243
+ );
3244
+ const out: Record<string, unknown> = {
3245
+ ok:
3246
+ !error &&
3247
+ !failedExit &&
3248
+ report.failed.length === 0 &&
3249
+ unhealthy.length === 0 &&
3250
+ touched.length > 0,
3251
+ workspace: workspace ?? null,
3252
+ services,
3253
+ };
3254
+ if (error) {
3255
+ out.error = error;
3256
+ out.fix = `${bin} doctor --json`;
3257
+ } else if (report.failed.length > 0 || unhealthy.length > 0) {
3258
+ const names = [
3259
+ ...new Set([...report.failed, ...unhealthy.map((s) => s.address)]),
3260
+ ];
3261
+ out.error = `${names.join(", ")} did not start healthy`;
3262
+ out.fix = `${bin} logs ${names[0].split("/")[0]}`;
3263
+ } else if (failedExit) {
3264
+ out.error = "up failed (its log is on stderr)";
3265
+ out.fix = `${bin} up ${args.join(" ")} --plan`.replace(/\s+/g, " ");
3266
+ } else if (touched.length === 0) {
3267
+ out.error =
3268
+ "nothing was started: no repo named, and the folder up ran in is not a repo in the workspace";
3269
+ out.fix = `${bin} up -a --json`;
3270
+ out.ask = "Which repo should start? (its name, as `repo add` printed it)";
3271
+ }
3272
+ out.next = out.ok
3273
+ ? [`${bin} web up --no-open --json`, `${bin} doctor --json`]
3274
+ : [];
3275
+ printJson(out);
3276
+ return out.ok ? 0 : 1;
3277
+ }
3278
+
3279
+ /**
3280
+ * `web up|restart --json` (ON-2): the web server started as usual (its
3281
+ * log on stderr), then `{ ok, url, port, version? }` once it answers
3282
+ * (up to 30s), so an agent hands back the right URL.
3283
+ */
3284
+ private async cmdWebUpJson(args: string[]): Promise<number> {
3285
+ const { parseWebCommandArgs } = await import("./web-command.js");
3286
+ const { probeWebServer } = await import("./mcp-connect.js");
3287
+ const bin = fs.existsSync(
3288
+ nodePath.join(getDistRoot(), "bin", product().cli),
3289
+ )
3290
+ ? `./bin/${product().cli}`
3291
+ : product().cli;
3292
+ let port: number;
3293
+ try {
3294
+ port = parseWebCommandArgs(args).port;
3295
+ } catch (e) {
3296
+ printJson({
3297
+ ok: false,
3298
+ error: (e as Error).message,
3299
+ fix: `${bin} web up --no-open --json`,
3300
+ });
3301
+ return 2;
3302
+ }
3303
+ const url = `http://localhost:${port}`;
3304
+ let error: string | undefined;
3305
+ try {
3306
+ await quietStdout(() => cmdWeb(args));
3307
+ } catch (e) {
3308
+ if (!(e instanceof ExitRequested) || e.code !== 0)
3309
+ error = (e as Error).message;
3310
+ }
3311
+ let probe = await probeWebServer(url);
3312
+ const deadline = Date.now() + 30_000;
3313
+ while (!error && probe.kind !== "up" && probe.kind !== "foreign") {
3314
+ if (Date.now() > deadline) break;
3315
+ await new Promise((r) => setTimeout(r, 500));
3316
+ probe = await probeWebServer(url);
3317
+ }
3318
+ const out: Record<string, unknown> = {
3319
+ ok: probe.kind === "up",
3320
+ url,
3321
+ port,
3322
+ ...(probe.kind === "up" && probe.version
3323
+ ? { version: probe.version }
3324
+ : {}),
3325
+ };
3326
+ if (probe.kind === "foreign") {
3327
+ out.error = `${probe.who} answers at ${url}, not this distribution's web server`;
3328
+ out.ask = `Another Den distribution uses port ${port}. Which port should ${product().title} use? (web.port in config/local/user.yml)`;
3329
+ } else if (probe.kind !== "up") {
3330
+ out.error = error ?? `the web server is not answering at ${url}`;
3331
+ out.fix = `${bin} web restart --no-open --json`;
3332
+ }
3333
+ printJson(out);
3334
+ return out.ok ? 0 : 1;
3335
+ }
3336
+
3102
3337
  /** Legacy: start the given services via manifest depends_on. */
3103
3338
  private async cmdServiceUpManifest(
3104
3339
  addresses: string[],
@@ -4880,6 +5115,10 @@ class DenCLI {
4880
5115
  */
4881
5116
  private displayHelp(): void {
4882
5117
  this.displayHelpBanner();
5118
+ // ON-5: the line a developer pastes into their agent.
5119
+ console.log(`Set up by your agent: paste this into it:`);
5120
+ console.log(` ${AGENT_SETUP_LINE}`);
5121
+ console.log();
4883
5122
  console.log("Usage:");
4884
5123
  console.log(` ${product().cli} [command] [options]`);
4885
5124
  console.log("\nEnvironment commands:");
@@ -4917,6 +5156,9 @@ class DenCLI {
4917
5156
  console.log(
4918
5157
  " register [path] Register a repo in the service catalog",
4919
5158
  );
5159
+ console.log(
5160
+ " repo add <path|url> Add a repo with no prompts, never changing it (--sidecar, --json)",
5161
+ );
4920
5162
  console.log(
4921
5163
  " relocate <repo> <path> Point a moved checkout's workspace entries at its new path",
4922
5164
  );
@@ -0,0 +1,49 @@
1
+ /**
2
+ * `--json` for setup commands an agent drives (ON-2): stdout carries exactly
3
+ * one JSON object, and everything a command would print for a person goes
4
+ * to stderr instead, so an agent can parse stdout and still show the log.
5
+ */
6
+
7
+ /** Print one JSON object on stdout (one line, newline-terminated). */
8
+ export function printJson(value: unknown): void {
9
+ process.stdout.write(`${JSON.stringify(value)}\n`);
10
+ }
11
+
12
+ /** Thrown in place of process.exit while quietStdout runs. */
13
+ export class ExitRequested extends Error {
14
+ constructor(readonly code: number) {
15
+ super(`exit ${code}`);
16
+ this.name = "ExitRequested";
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Run `fn` with console.log/info and process.stdout.write sent to stderr,
22
+ * and process.exit turned into a thrown ExitRequested (so the caller still
23
+ * prints its JSON). Everything is restored afterwards.
24
+ */
25
+ export async function quietStdout<T>(fn: () => Promise<T>): Promise<T> {
26
+ const log = console.log;
27
+ const info = console.info;
28
+ const write = process.stdout.write;
29
+ const exit = process.exit;
30
+ const toErr = (...a: unknown[]) => console.error(...a);
31
+ console.log = toErr;
32
+ console.info = toErr;
33
+ process.stdout.write = ((chunk: unknown, ...rest: unknown[]) =>
34
+ (process.stderr.write as (...x: unknown[]) => boolean)(
35
+ chunk,
36
+ ...rest,
37
+ )) as typeof process.stdout.write;
38
+ process.exit = ((code?: number) => {
39
+ throw new ExitRequested(code ?? 0);
40
+ }) as typeof process.exit;
41
+ try {
42
+ return await fn();
43
+ } finally {
44
+ console.log = log;
45
+ console.info = info;
46
+ process.stdout.write = write;
47
+ process.exit = exit;
48
+ }
49
+ }
@@ -10,11 +10,15 @@ import * as path from "path";
10
10
  import chalk from "chalk";
11
11
  import {
12
12
  MCP_CLIENTS,
13
+ RUNTIME_CLIENTS,
14
+ clientForRuntime,
13
15
  findClient,
14
16
  genericSnippet,
15
17
  jsonHasEntry,
16
18
  mergeJsonConfig,
19
+ mergeTomlConfig,
17
20
  serverSpec,
21
+ tomlHasEntry,
18
22
  type ConnectAction,
19
23
  type ConnectEnv,
20
24
  type McpClient,
@@ -33,6 +37,7 @@ import {
33
37
  type OwnServer,
34
38
  } from "./server-identity.js";
35
39
  import { readDenConfig, readMcpConfig } from "../../shared/den-config.js";
40
+ import { printJson } from "./json-out.js";
36
41
 
37
42
  export const MCP_CONNECT_SUBCOMMANDS = [
38
43
  "connect",
@@ -77,15 +82,28 @@ interface Flags {
77
82
  project: string | null;
78
83
  port?: number;
79
84
  dryRun: boolean;
85
+ json: boolean;
86
+ runtime?: string;
80
87
  }
81
88
 
82
89
  function parseFlags(argv: string[]): Flags {
83
- const f: Flags = { clients: [], all: false, project: null, dryRun: false };
90
+ const f: Flags = {
91
+ clients: [],
92
+ all: false,
93
+ project: null,
94
+ dryRun: false,
95
+ json: false,
96
+ };
84
97
  for (let i = 0; i < argv.length; i++) {
85
98
  const a = argv[i];
86
99
  if (a === "--all" || a === "-a") f.all = true;
87
100
  else if (a === "--dry-run" || a === "-n") f.dryRun = true;
88
- else if (a === "--project" || a === "-p") {
101
+ else if (a === "--json") f.json = true;
102
+ else if (a === "--runtime" || a.startsWith("--runtime=")) {
103
+ const v = a.includes("=") ? a.slice(a.indexOf("=") + 1) : argv[++i];
104
+ if (!v) throw new Error("--runtime needs a value");
105
+ f.runtime = v;
106
+ } else if (a === "--project" || a === "-p") {
89
107
  const next = argv[i + 1];
90
108
  if (next && !next.startsWith("-") && !findClient(next)) {
91
109
  f.project = path.resolve(next);
@@ -150,19 +168,21 @@ function apply(
150
168
  a: ConnectAction,
151
169
  remove: boolean,
152
170
  dryRun: boolean,
153
- ): { ok: boolean; said: string } {
154
- if (a.kind === "json") {
171
+ ): { ok: boolean; said: string; file?: string; changed?: boolean } {
172
+ if (a.kind === "json" || a.kind === "toml") {
155
173
  const before = readText(a.file);
156
- const after = mergeJsonConfig(
157
- before,
158
- a.key,
159
- a.name,
160
- remove ? null : a.entry,
161
- );
162
- if (!dryRun) writeConfig(a.file, after);
174
+ const after =
175
+ a.kind === "json"
176
+ ? mergeJsonConfig(before, a.key, a.name, remove ? null : a.entry)
177
+ : mergeTomlConfig(before, a.name, remove ? null : a.entry);
178
+ const changed = after !== (before ?? "");
179
+ // Unchanged is not rewritten: a re-run leaves the file (and its .bak) be.
180
+ if (!dryRun && changed) writeConfig(a.file, after);
163
181
  return {
164
182
  ok: true,
165
- said: `${dryRun ? "would write" : remove ? "removed from" : "wrote"} ${a.file}`,
183
+ file: a.file,
184
+ changed,
185
+ said: `${dryRun ? "would write" : !changed ? "already in" : remove ? "removed from" : "wrote"} ${a.file}`,
166
186
  };
167
187
  }
168
188
  if (remove) {
@@ -192,8 +212,11 @@ function status(
192
212
  env: ConnectEnv,
193
213
  ): string {
194
214
  const a = c.plan(s, scope, env);
195
- if (a.kind === "json") {
196
- const st = jsonHasEntry(readText(a.file), a.key, a.name, a.entry);
215
+ if (a.kind === "json" || a.kind === "toml") {
216
+ const st =
217
+ a.kind === "json"
218
+ ? jsonHasEntry(readText(a.file), a.key, a.name, a.entry)
219
+ : tomlHasEntry(readText(a.file), a.name, a.entry);
197
220
  return st === "connected"
198
221
  ? chalk.green("connected")
199
222
  : st === "different"
@@ -314,7 +337,8 @@ function usage(): string {
314
337
  ` ${cli} mcp clients which agents are installed and connected`,
315
338
  ` ${cli} mcp connect [<client>...|--all] register ${cli}'s MCP server (user scope)`,
316
339
  ` [--project [dir]] in a project's config instead (default: here)`,
317
- ` [--port <n>] [--dry-run]`,
340
+ ` [--port <n>] [--dry-run] [--json]`,
341
+ ` ${cli} mcp connect --runtime <runtime> by the agent's own name: ${Object.keys(RUNTIME_CLIENTS).join(", ")}`,
318
342
  ` ${cli} mcp disconnect <client>... [--project [dir]]`,
319
343
  ` ${cli} mcp config [--port <n>] JSON for any other MCP client`,
320
344
  ``,
@@ -332,9 +356,31 @@ export async function runMcpConnect(argv: string[]): Promise<number> {
332
356
  try {
333
357
  flags = parseFlags(rest);
334
358
  } catch (e) {
359
+ if (rest.includes("--json")) {
360
+ printJson({
361
+ ok: false,
362
+ error: (e as Error).message,
363
+ fix: `${product().cli} mcp connect --runtime <${Object.keys(RUNTIME_CLIENTS).join("|")}> --json`,
364
+ });
365
+ return 1;
366
+ }
335
367
  console.error(chalk.red(`${(e as Error).message}\n`) + usage());
336
368
  return 1;
337
369
  }
370
+ if (flags.json && (sub === "connect" || sub === "disconnect"))
371
+ return runMcpConnectJson(sub, flags);
372
+ if (flags.runtime) {
373
+ const c = clientForRuntime(flags.runtime);
374
+ if (!c) {
375
+ console.error(
376
+ chalk.red(
377
+ `unknown runtime: ${flags.runtime} (${Object.keys(RUNTIME_CLIENTS).join(", ")})\n`,
378
+ ) + usage(),
379
+ );
380
+ return 1;
381
+ }
382
+ flags.clients.push(c.id);
383
+ }
338
384
  const s = spec(flags.port);
339
385
  const scope: Scope = flags.project ? "project" : "user";
340
386
  const env = connectEnv(
@@ -441,3 +487,121 @@ export async function runMcpConnect(argv: string[]): Promise<number> {
441
487
  }
442
488
  return failed ? 1 : 0;
443
489
  }
490
+
491
+ /** One client's result in `mcp connect --json`. */
492
+ export interface ConnectJsonClient {
493
+ id: string;
494
+ title: string;
495
+ ok: boolean;
496
+ said: string;
497
+ file?: string;
498
+ /** For a config file: whether this run changed it (false on a re-run). */
499
+ changed?: boolean;
500
+ }
501
+
502
+ /**
503
+ * `mcp connect|disconnect … --json` (ON-2): one JSON object, `{ ok, server,
504
+ * clients, restart, web, fix? }`. Safe to re-run: a config file that
505
+ * already has the entry is left as it is (changed: false), and a client's
506
+ * own `mcp add` is preceded by its `mcp remove`.
507
+ */
508
+ async function runMcpConnectJson(
509
+ sub: "connect" | "disconnect",
510
+ flags: Flags,
511
+ ): Promise<number> {
512
+ const cli = product().cli;
513
+ const fixAll = `./bin/${cli} mcp connect --runtime <${Object.keys(RUNTIME_CLIENTS).join("|")}> --json`;
514
+ const fail = (error: string, extra: Record<string, unknown> = {}) => {
515
+ printJson({ ok: false, error, ...extra });
516
+ return 1;
517
+ };
518
+ let s: ServerSpec;
519
+ try {
520
+ s = spec(flags.port);
521
+ } catch (e) {
522
+ return fail((e as Error).message, {
523
+ ask: "Run this from your Den folder (the one with bin/ in it)?",
524
+ });
525
+ }
526
+ const scope: Scope = flags.project ? "project" : "user";
527
+ const env = connectEnv(
528
+ flags.project ?? process.env.DEN_INVOKE_CWD ?? process.cwd(),
529
+ );
530
+ const ids = [...flags.clients];
531
+ if (flags.runtime) {
532
+ const c = clientForRuntime(flags.runtime);
533
+ if (!c)
534
+ return fail(
535
+ `unknown runtime "${flags.runtime}": Den connects ${Object.keys(RUNTIME_CLIENTS).join(", ")}`,
536
+ {
537
+ fix: `./bin/${cli} mcp config`,
538
+ ask: "Your agent is not one Den knows. Add the JSON that `mcp config` prints to its MCP settings by hand?",
539
+ },
540
+ );
541
+ ids.push(c.id);
542
+ }
543
+ const unknown = ids.filter((id) => !findClient(id));
544
+ if (unknown.length)
545
+ return fail(`unknown client: ${unknown.join(", ")}`, { fix: fixAll });
546
+ const targets = flags.all
547
+ ? MCP_CLIENTS.filter((c) => c.detect(env) && c.scopes.includes(scope))
548
+ : ids.map((id) => findClient(id)!);
549
+ if (targets.length === 0) return fail("no client named", { fix: fixAll });
550
+
551
+ const url = webUrl(flags.port);
552
+ const probe = await probeWebServer(url);
553
+ if (sub === "connect" && probe.kind === "foreign")
554
+ return fail(
555
+ `${probe.who} answers at ${url}, not this distribution's web server: agents would read it`,
556
+ { ask: pickAnotherPortHint(cli) },
557
+ );
558
+
559
+ const clients: ConnectJsonClient[] = [];
560
+ for (const c of targets) {
561
+ if (!c.scopes.includes(scope)) {
562
+ clients.push({
563
+ id: c.id,
564
+ title: c.title,
565
+ ok: false,
566
+ said: `no ${scope} scope`,
567
+ });
568
+ continue;
569
+ }
570
+ try {
571
+ const r = apply(
572
+ c.plan(s, scope, env),
573
+ sub === "disconnect",
574
+ flags.dryRun,
575
+ );
576
+ clients.push({ id: c.id, title: c.title, ...r });
577
+ } catch (e) {
578
+ clients.push({
579
+ id: c.id,
580
+ title: c.title,
581
+ ok: false,
582
+ said: (e as Error).message,
583
+ });
584
+ }
585
+ }
586
+ const ok = clients.every((c) => c.ok);
587
+ const failed = clients.find((c) => !c.ok);
588
+ printJson({
589
+ ok,
590
+ server: { name: s.name, command: s.command, args: s.args },
591
+ clients,
592
+ dryRun: flags.dryRun,
593
+ // Most agents load a new MCP server only when they start.
594
+ restart: sub === "connect" && !flags.dryRun && ok,
595
+ web: { url, up: probe.kind === "up" },
596
+ ...(failed
597
+ ? {
598
+ error: `${failed.title}: ${failed.said}`,
599
+ fix: `./bin/${cli} mcp config`,
600
+ ask: `Add Den's MCP server to ${failed.title} by hand, with the JSON \`mcp config\` prints?`,
601
+ }
602
+ : probe.kind !== "up" && sub === "connect"
603
+ ? { next: [`./bin/${cli} web up --no-open`] }
604
+ : {}),
605
+ });
606
+ return ok ? 0 : 1;
607
+ }