@monster-orange/den 0.4.14 → 0.4.16

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 +194 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +226 -19
  4. package/package.json +1 -1
  5. package/src/shared/actor-token.ts +12 -0
  6. package/src/shared/agents/conversations.ts +155 -8
  7. package/src/shared/agents/den-claude.ts +25 -3
  8. package/src/shared/agents/records.ts +37 -0
  9. package/src/shared/agents/runtime.ts +11 -1
  10. package/src/shared/claim-code.ts +163 -0
  11. package/src/shared/cli-command.ts +31 -0
  12. package/src/shared/den-config.ts +12 -0
  13. package/src/shared/environment-manager/workspaces.ts +20 -0
  14. package/src/shared/github-app.ts +7 -0
  15. package/src/shared/web-session.ts +64 -5
  16. package/src/ui/cli/agent-ops.ts +372 -23
  17. package/src/ui/cli/agents-command.ts +71 -4
  18. package/src/ui/cli/doctor-command.ts +29 -3
  19. package/src/ui/cli/doctor-onboard.ts +26 -0
  20. package/src/ui/cli/flag-parser.ts +9 -1
  21. package/src/ui/cli/index.ts +29 -3
  22. package/src/ui/cli/onboard-command.ts +180 -0
  23. package/src/ui/cli/onboard-reads.ts +613 -0
  24. package/src/ui/cli/repo-command.ts +156 -10
  25. package/src/ui/cli/web-command.ts +92 -1
  26. package/src/ui/mcp/api.ts +14 -2
  27. package/src/ui/mcp/server.ts +12 -2
  28. package/src/ui/mcp/tools.ts +368 -1
  29. package/src/ui/server/agent-routes.ts +91 -7
  30. package/src/ui/server/approval-routes.ts +390 -0
  31. package/src/ui/server/approvals.ts +416 -0
  32. package/src/ui/server/github-routes.ts +13 -5
  33. package/src/ui/server/held-actions.ts +203 -0
  34. package/src/ui/server/http-helpers.ts +35 -0
  35. package/src/ui/server/index.ts +308 -21
  36. package/src/ui/server/lifecycle-exec.ts +70 -0
  37. package/src/ui/server/onboard-routes.ts +132 -0
  38. package/src/ui/server/pair-routes.ts +7 -4
  39. package/src/ui/server/public/agents-page.js +378 -30
  40. package/src/ui/server/public/agents.html +1 -0
  41. package/src/ui/server/public/error-page.js +97 -18
  42. package/src/ui/server/public/error.html +18 -0
  43. package/src/ui/server/public/header.js +1 -1
  44. package/src/ui/server/public/i18n/es.js +65 -5
  45. package/src/ui/server/public/i18n/ja.js +67 -5
  46. package/src/ui/server/public/index.html +1 -1
  47. package/src/ui/server/public/local-dev.html +5 -4
  48. package/src/ui/server/public/persona-ui.css +32 -0
  49. package/src/ui/server/public/release.html +0 -1
  50. package/src/ui/server/public/settings-registry.js +3 -1
  51. package/src/ui/server/public/settings.html +9 -1
  52. package/src/ui/server/public/shell.js +348 -5
  53. package/src/ui/server/request-guard.ts +2 -0
  54. package/src/ui/server/route-classes.ts +34 -1
  55. package/src/ui/server/session-routes.ts +14 -3
  56. package/src/ui/server/workspace-routes.ts +21 -10
  57. package/themes/den/den-theme.json +6 -6
@@ -64,6 +64,7 @@
64
64
  */
65
65
  import * as fs from "fs";
66
66
  import { DEFAULT_RUNTIME_ID } from "../../shared/agents/runtimes.js";
67
+ import { ACTOR_TOKEN_ENV } from "../../shared/actor-token.js";
67
68
  import { initLoginPath } from "../../shared/agents/runtime-search.js";
68
69
  import { HostError } from "../../shared/sessions/client.js";
69
70
  import { checkHandback } from "../../shared/agents/handback.js";
@@ -77,6 +78,7 @@ import {
77
78
  import {
78
79
  AgentError,
79
80
  importAgent,
81
+ changePersona,
80
82
  listAgents,
81
83
  listConversations,
82
84
  listPersonas as personaList,
@@ -116,14 +118,14 @@ export {
116
118
 
117
119
  export const AGENTS_HELP = {
118
120
  usage:
119
- "agents <list|start|stop|resume|relaunch|import|rm|personas|doctor|handback-check|handoff-check> ...",
121
+ "agents <list|start|stop|resume|relaunch|import|persona|rm|personas|doctor|handback-check|handoff-check> ...",
120
122
  lines: [
121
123
  "Agents: a persona and a workspace, run by a runtime in a session that",
122
124
  "outlives the web server (see `{cli} sessions`).",
123
125
  "",
124
126
  "{cli} agents list -w <ws> [--stopped] [--json]",
125
127
  " --stopped: only the stopped, interrupted and exited ones",
126
- "{cli} agents start -w <ws> --persona <p> [--runtime <r>] [--model <m>] [--id <id>]",
128
+ "{cli} agents start -w <ws> --persona <p> [--runtime <r>] [--model <m>] [--name <name>] [--id <id>]",
127
129
  " runs in the workspace's dir as <dist>-<ws>:<id>; its MCP is pinned to <ws>",
128
130
  " (plus agents.<id>.grants); capped by agents.max_concurrent",
129
131
  "{cli} agents stop <id> [-w <ws>] [--no-wait]",
@@ -142,11 +144,17 @@ export const AGENTS_HELP = {
142
144
  " passes the check, a fresh conversation (same persona, workspace, folder,",
143
145
  " grants) whose first prompt is the handoff",
144
146
  "{cli} agents import -w <ws> [--runtime <r>] [--json]",
145
- "{cli} agents import -w <ws> --session <id> --persona <p> [--runtime <r>] [--id <id>]",
147
+ "{cli} agents import -w <ws> --session <id> --persona <p> [--runtime <r>] [--id <id>] [--model <m>]",
146
148
  " bring in a conversation started outside Den: the first lists the runtime's",
147
149
  " own (default den-claude) in the workspace's folder; the second continues",
148
150
  " one as a Den agent (its resume, in the folder it ran in). Refused while it",
149
- " is still open in another terminal: close it there first",
151
+ " is still open in another terminal: close it there first. It keeps the",
152
+ " conversation's own model unless --model gives one",
153
+ "{cli} agents persona <id> <persona> [-w <ws>] [--model <m>] [--now | --after-turn]",
154
+ " change an agent's persona: the same conversation resumes with the new",
155
+ " persona's role, permissions and commands; id, name, folder and model stay.",
156
+ " Mid-turn it asks: --after-turn waits for the turn to end, --now stops it.",
157
+ " A person's only (refused in an agent's session)",
150
158
  "{cli} agents rm <id> [-w <ws>]",
151
159
  "{cli} agents rm --stopped [-w <ws>]",
152
160
  " forget a stopped, interrupted or exited agent (never a running one);",
@@ -273,12 +281,14 @@ async function start(args: string[], d: AgentsResolved): Promise<number> {
273
281
  "--persona": true,
274
282
  "--runtime": true,
275
283
  "--model": true,
284
+ "--name": true,
276
285
  "--id": true,
277
286
  });
278
287
  noExtra(where, p);
279
288
  const ws = workspaceFlag(where, p);
280
289
  if (!ws) throw new UsageError(`${where} needs -w <workspace>`);
281
290
  const persona = str(p, "--persona");
291
+ const name = str(p, "--name");
282
292
  if (!persona) throw new UsageError(`${where} needs --persona <p>`);
283
293
  const s = await startAgent(d, {
284
294
  workspace: ws,
@@ -286,6 +296,7 @@ async function start(args: string[], d: AgentsResolved): Promise<number> {
286
296
  runtime: str(p, "--runtime"),
287
297
  model: str(p, "--model"),
288
298
  id: str(p, "--id"),
299
+ ...(name !== undefined ? { name } : {}),
289
300
  });
290
301
  return report(
291
302
  d,
@@ -303,6 +314,8 @@ async function bringIn(args: string[], d: AgentsResolved): Promise<number> {
303
314
  "--session": true,
304
315
  "--persona": true,
305
316
  "--id": true,
317
+ "--model": true,
318
+ "--version": true,
306
319
  "--json": false,
307
320
  });
308
321
  noExtra(where, p);
@@ -352,6 +365,11 @@ async function bringIn(args: string[], d: AgentsResolved): Promise<number> {
352
365
  persona,
353
366
  runtime,
354
367
  id: str(p, "--id"),
368
+ // AG-17.1c: the conversation's own model unless one is given.
369
+ ...(str(p, "--model") !== undefined ? { model: str(p, "--model") } : {}),
370
+ ...(str(p, "--version") !== undefined
371
+ ? { version: str(p, "--version") }
372
+ : {}),
355
373
  });
356
374
  return report(
357
375
  d,
@@ -360,6 +378,52 @@ async function bringIn(args: string[], d: AgentsResolved): Promise<number> {
360
378
  );
361
379
  }
362
380
 
381
+ /**
382
+ * `agents persona <id> <persona> [-w <ws>] [--model <m>] [--version <v>]
383
+ * [--now | --after-turn]` (AG-18). A person's only: refused inside an
384
+ * agent's session (its DEN_ACTOR_TOKEN), so an agent can't widen its own
385
+ * permissions.
386
+ */
387
+ async function persona(args: string[], d: AgentsResolved): Promise<number> {
388
+ const where = "agents persona";
389
+ if (d.env[ACTOR_TOKEN_ENV] !== undefined)
390
+ throw new AgentError(
391
+ `${where}: only a person can change an agent's persona (this is an agent's session)`,
392
+ );
393
+ const p = parseArgs(where, args, {
394
+ "--workspace": true,
395
+ "--model": true,
396
+ "--version": true,
397
+ "--now": false,
398
+ "--after-turn": false,
399
+ });
400
+ noExtra(where, p, 2);
401
+ const [id, to] = p.positional;
402
+ if (!id || !to) throw new UsageError(`${where} needs <id> <persona>`);
403
+ if (p.flags["--now"] && p.flags["--after-turn"])
404
+ throw new UsageError(`${where}: --now or --after-turn, not both`);
405
+ const when = p.flags["--now"]
406
+ ? "now"
407
+ : p.flags["--after-turn"]
408
+ ? "after-turn"
409
+ : undefined;
410
+ if (when === "after-turn") d.out(`waiting for agent ${id}'s turn to end…`);
411
+ const s = await changePersona(d, id, workspaceFlag(where, p), {
412
+ persona: to,
413
+ ...(str(p, "--model") !== undefined ? { model: str(p, "--model") } : {}),
414
+ ...(str(p, "--version") !== undefined
415
+ ? { version: str(p, "--version") }
416
+ : {}),
417
+ ...(when ? { when } : {}),
418
+ by: "cli",
419
+ });
420
+ return report(
421
+ d,
422
+ s,
423
+ `agent ${s.rec.id} is now ${s.rec.persona} (was ${s.from}); same conversation, as ${s.rec.sessionName} in ${s.rec.cwd}`,
424
+ );
425
+ }
426
+
363
427
  /** `<id> [-w <ws>]`. */
364
428
  function idAndWorkspace(
365
429
  where: string,
@@ -700,6 +764,9 @@ export async function cmdAgents(
700
764
  case "import":
701
765
  await loadAgentWorkspaces(deps);
702
766
  return await bringIn(rest, d);
767
+ case "persona":
768
+ await loadAgentWorkspaces(deps);
769
+ return await persona(rest, d);
703
770
  case "rm":
704
771
  await loadAgentWorkspaces(deps);
705
772
  return await rm(rest, d);
@@ -22,7 +22,12 @@
22
22
  * nothing is sent.
23
23
  */
24
24
  import chalk from "chalk";
25
- import { readDenConfig, type DenConfig } from "../../shared/den-config.js";
25
+ import {
26
+ readDenConfig,
27
+ readMcpConfig,
28
+ type DenConfig,
29
+ } from "../../shared/den-config.js";
30
+ import { approvalsState } from "../mcp/tools.js";
26
31
  import { getStateDir } from "../../shared/paths.js";
27
32
  import { product } from "../../shared/product.js";
28
33
  import { listRecords } from "../../shared/agents/records.js";
@@ -216,6 +221,25 @@ export function agentsConfigured(
216
221
  }
217
222
  }
218
223
 
224
+ /**
225
+ * The held writes' state (mcp.approvals), as doctor prints it. A bad value
226
+ * is a problem: the MCP server refuses to start on it.
227
+ */
228
+ export function approvalsLines(read: () => boolean): {
229
+ lines: string[];
230
+ problem: boolean;
231
+ } {
232
+ try {
233
+ const state = approvalsState({ approvals: read() });
234
+ return { lines: ["", ` ℹ MCP ${state}`], problem: false };
235
+ } catch (err) {
236
+ return {
237
+ lines: ["", chalk.red(` ✗ MCP ${(err as Error).message}`)],
238
+ problem: true,
239
+ };
240
+ }
241
+ }
242
+
219
243
  /** Informational lines after the rows: never a failure. */
220
244
  export function doctorNotes(cli: string, configured: boolean): string[] {
221
245
  return configured
@@ -312,6 +336,8 @@ export async function cmdDoctor(args: string[]): Promise<number> {
312
336
  }
313
337
  const rows = doctorRows();
314
338
  for (const l of formatDoctor(rows, readBrand().name)) console.log(l);
339
+ const held = approvalsLines(() => readMcpConfig().approvals);
340
+ for (const l of held.lines) console.log(l);
315
341
  for (const l of pollingLines(eventsFile(), readBrand().name)) console.log(l);
316
342
  const cli = product().cli;
317
343
  if (!probe) {
@@ -320,7 +346,7 @@ export async function cmdDoctor(args: string[]): Promise<number> {
320
346
  agentsConfigured(readConfigOrNull(), getStateDir()),
321
347
  ))
322
348
  console.log(l);
323
- return rows.some((r) => r.problem) ? 1 : 0;
349
+ return rows.some((r) => r.problem) || held.problem ? 1 : 0;
324
350
  }
325
351
  const { runEnvProbe, formatProbe } = await import("./env-probe.js");
326
352
  const { resolveDeps, plainSessionEnv } =
@@ -339,5 +365,5 @@ export async function cmdDoctor(args: string[]): Promise<number> {
339
365
  console.log("");
340
366
  for (const l of formatProbe(results)) console.log(l);
341
367
  const found = results.some((r) => r.findings.length);
342
- return rows.some((r) => r.problem) || found ? 1 : 0;
368
+ return rows.some((r) => r.problem) || held.problem || found ? 1 : 0;
343
369
  }
@@ -28,6 +28,8 @@ import {
28
28
  import { readWebPort } from "../../shared/edge/config.js";
29
29
  import { DEFAULT_PORT } from "../../shared/constants.js";
30
30
  import { needsDocker } from "./repo-detect.js";
31
+ import { readMcpConfig } from "../../shared/den-config.js";
32
+ import { approvalsState } from "../mcp/tools.js";
31
33
  import type { WebProbe } from "./mcp-connect.js";
32
34
 
33
35
  export type CheckStatus = "ok" | "warn" | "fail";
@@ -59,6 +61,8 @@ export interface OnboardDeps {
59
61
  probeWeb: (url: string) => Promise<WebProbe>;
60
62
  /** The MCP one-shot against the web server: exit 0, or why not. */
61
63
  mcpCall: () => { ok: boolean; detail?: string };
64
+ /** `mcp.approvals` (throws on a bad value): whether the held writes are on. */
65
+ approvals: () => boolean;
62
66
  /** The command an agent runs, e.g. `./bin/acme`. */
63
67
  bin: string;
64
68
  }
@@ -193,9 +197,30 @@ export async function onboardingChecks(
193
197
  },
194
198
  );
195
199
  }
200
+ out.push(approvalsCheck(d));
196
201
  return out;
197
202
  }
198
203
 
204
+ /** `mcp:approvals`: which state the held writes are in. Never needs the server. */
205
+ function approvalsCheck(d: OnboardDeps): OnboardCheck {
206
+ let on: boolean;
207
+ try {
208
+ on = d.approvals();
209
+ } catch (err) {
210
+ return {
211
+ id: "mcp:approvals",
212
+ status: "fail",
213
+ detail: (err as Error).message,
214
+ ask: "Set mcp.approvals in the distribution config to true or false.",
215
+ };
216
+ }
217
+ return {
218
+ id: "mcp:approvals",
219
+ status: "ok",
220
+ detail: approvalsState({ approvals: on }),
221
+ };
222
+ }
223
+
199
224
  function onPath(bin: string): boolean {
200
225
  for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
201
226
  if (!dir) continue;
@@ -286,6 +311,7 @@ export function defaultOnboardDeps(
286
311
  `exit ${r.status ?? "timeout"}`,
287
312
  };
288
313
  },
314
+ approvals: () => readMcpConfig().approvals,
289
315
  bin: runnableCli(),
290
316
  };
291
317
  }
@@ -181,7 +181,15 @@ function validateFlagScopes(parsed: ParsedCliArgs): void {
181
181
  */
182
182
  export const SUBCOMMAND_OWN_HELP: Readonly<Record<string, readonly string[]>> =
183
183
  {
184
- web: ["token", "login", "sessions", "logout", "owner", "approve"],
184
+ web: [
185
+ "token",
186
+ "login",
187
+ "sessions",
188
+ "logout",
189
+ "owner",
190
+ "approve",
191
+ "claim-link",
192
+ ],
185
193
  mcp: ["connect", "disconnect", "clients", "config"],
186
194
  repo: ["add"],
187
195
  };
@@ -366,12 +366,22 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
366
366
  },
367
367
  repo: {
368
368
  usage:
369
- 'repo add <path|git-url> [--sidecar] [-w <workspace>] [--name <repo>] [--run "<cmd>"] [--health <url>] [--json]',
369
+ 'repo add <path|git-url> [--sidecar] [-w <workspace>] [--name <repo>] [--run "<cmd>"] [--health <url>] [--dry-run] [--json] | repo detect <folder> [--json]',
370
370
  lines: [
371
371
  "Add a repo with no prompts, never writing into it. A repo with no",
372
372
  "manifest (or --sidecar) gets a side-car manifest in manifests/, detected",
373
373
  "from its Makefile, compose file or package.json scripts. Safe to re-run.",
374
- "`{cli} repo add --help` says more.",
374
+ "--dry-run prints the side-car it would write, validated, and writes nothing.",
375
+ "detect: the git repos in a folder in your home, how each runs, and",
376
+ "suggested workspaces. `{cli} repo add --help` says more.",
377
+ ],
378
+ },
379
+ onboard: {
380
+ usage: "onboard status [--json]",
381
+ lines: [
382
+ "What's set up (a distribution, the web server, workspaces, repos, a",
383
+ "running service, Den's MCP) and the one next step, with its command.",
384
+ "Reads only; it never runs an agent's CLI.",
375
385
  ],
376
386
  },
377
387
  relocate: {
@@ -595,6 +605,7 @@ const TELEMETRY_COMMANDS = new Set<string>([
595
605
  "theme",
596
606
  "plugin",
597
607
  "repo",
608
+ "onboard",
598
609
  ]);
599
610
 
600
611
  /**
@@ -975,6 +986,15 @@ class DenCLI {
975
986
  break;
976
987
  }
977
988
 
989
+ case "onboard": {
990
+ const { cmdOnboard } = await import("./onboard-command.js");
991
+ const { probeWebServer } = await import("./mcp-connect.js");
992
+ process.exitCode = await cmdOnboard(args.slice(1), (u) =>
993
+ probeWebServer(u),
994
+ );
995
+ break;
996
+ }
997
+
978
998
  case "relocate":
979
999
  await this.cmdRelocate(args.slice(1));
980
1000
  break;
@@ -5271,7 +5291,13 @@ class DenCLI {
5271
5291
  " register [path] Register a repo in the service catalog",
5272
5292
  );
5273
5293
  console.log(
5274
- " repo add <path|url> Add a repo with no prompts, never changing it (--sidecar, --json)",
5294
+ " repo add <path|url> Add a repo with no prompts, never changing it (--sidecar, --dry-run, --json)",
5295
+ );
5296
+ console.log(
5297
+ " repo detect <folder> Find the repos in a folder and suggest workspaces (--json)",
5298
+ );
5299
+ console.log(
5300
+ " onboard status What's set up, and the next step (--json)",
5275
5301
  );
5276
5302
  console.log(
5277
5303
  " relocate <repo> <path> Point a moved checkout's workspace entries at its new path",
@@ -0,0 +1,180 @@
1
+ /**
2
+ * `<cli> onboard status [--json]` (ON-3): what's set up and the one next
3
+ * step, the CLI twin of the MCP tool `onboard_status` and of
4
+ * GET /api/onboard/status. `doctor --json` has the machine checks (Node,
5
+ * git, Docker, the web server, Den's MCP); this adds what onboarding needs
6
+ * on top (a distribution, workspaces, repos, a running service) and picks
7
+ * the next step. Reads only. It never runs an agent's CLI: Den's MCP is
8
+ * checked in the agents' config files (onboard-reads.ts mcpRegistration).
9
+ * Exit 0 whenever it answers, whatever is left to do.
10
+ */
11
+ import { execFile } from "child_process";
12
+ import * as fs from "fs";
13
+ import * as os from "os";
14
+ import * as path from "path";
15
+ import { product } from "../../shared/product.js";
16
+ import { getDistRoot } from "../../shared/paths.js";
17
+ import {
18
+ DEN_SERVICES_DIR,
19
+ readWorkspacesFile,
20
+ } from "../../shared/environment-manager/workspaces.js";
21
+ import { listDaemons } from "../../shared/environment-manager/service-runner.js";
22
+ import { defaultOnboardDeps, runnableCli } from "./doctor-onboard.js";
23
+ import type { WebProbe } from "./mcp-connect.js";
24
+ import {
25
+ mcpRegistration,
26
+ onboardStatus,
27
+ type OnboardStatus,
28
+ type OnboardStatusDeps,
29
+ type RegisteredRepo,
30
+ } from "./onboard-reads.js";
31
+ import { printJson } from "./json-out.js";
32
+
33
+ /** Every repo in a workspace, at the folder Den runs it from. */
34
+ export function registeredRepos(): RegisteredRepo[] {
35
+ let file;
36
+ try {
37
+ file = readWorkspacesFile();
38
+ } catch {
39
+ return [];
40
+ }
41
+ const out: RegisteredRepo[] = [];
42
+ for (const [workspace, ws] of Object.entries(file.workspaces))
43
+ for (const [addr, entry] of Object.entries(ws?.services ?? {})) {
44
+ const repo = addr.split("/")[0];
45
+ const dir =
46
+ entry.mode === "dev-managed"
47
+ ? entry.path
48
+ : path.join(DEN_SERVICES_DIR, repo, entry.branch ?? "main");
49
+ if (dir) out.push({ path: dir, workspace, repo });
50
+ }
51
+ return out;
52
+ }
53
+
54
+ function readText(file: string): string | null {
55
+ try {
56
+ return fs.readFileSync(file, "utf-8");
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ /** Whether the Docker daemon answers, without blocking the event loop. */
63
+ function dockerUpAsync(): Promise<boolean> {
64
+ return new Promise((resolve) => {
65
+ execFile(
66
+ "docker",
67
+ ["info", "--format", "{{.ServerVersion}}"],
68
+ { timeout: 10_000 },
69
+ (err) => resolve(!err),
70
+ );
71
+ });
72
+ }
73
+
74
+ /** This machine's deps for onboardStatus. */
75
+ export async function defaultStatusDeps(o: {
76
+ probeWeb: (url: string) => Promise<WebProbe>;
77
+ webUrl?: string;
78
+ viaMcp?: boolean;
79
+ }): Promise<OnboardStatusDeps> {
80
+ const doctor = defaultOnboardDeps(o.probeWeb);
81
+ if (o.webUrl) doctor.webUrl = o.webUrl;
82
+ // Docker is asked only when a registered repo needs it, and asynchronously:
83
+ // the web server answers this route too.
84
+ const dockerRepos = doctor.dockerRepos();
85
+ const dockerUp =
86
+ dockerRepos.length && doctor.which("docker")
87
+ ? await dockerUpAsync()
88
+ : false;
89
+ const bin = runnableCli();
90
+ return {
91
+ bin,
92
+ distribution: fs.existsSync(path.join(getDistRoot(), "bin", product().cli)),
93
+ doctor: {
94
+ ...doctor,
95
+ dockerRepos: () => dockerRepos,
96
+ dockerUp: () => dockerUp,
97
+ },
98
+ workspaces: () => {
99
+ let file;
100
+ try {
101
+ file = readWorkspacesFile();
102
+ } catch {
103
+ return { names: [], active: [], repos: [] };
104
+ }
105
+ const repos = new Set<string>();
106
+ for (const ws of Object.values(file.workspaces))
107
+ for (const addr of Object.keys(ws?.services ?? {}))
108
+ repos.add(addr.split("/")[0]);
109
+ return {
110
+ names: Object.keys(file.workspaces),
111
+ active: file.active.filter((n) => n in file.workspaces),
112
+ repos: [...repos],
113
+ };
114
+ },
115
+ running: () => {
116
+ try {
117
+ return listDaemons().map((d) => d.address);
118
+ } catch {
119
+ return [];
120
+ }
121
+ },
122
+ mcp: () =>
123
+ mcpRegistration({
124
+ cli: bin,
125
+ home: os.homedir(),
126
+ platform: process.platform,
127
+ appData: process.env.APPDATA,
128
+ projectDir: getDistRoot(),
129
+ which: doctor.which,
130
+ read: readText,
131
+ viaMcp: o.viaMcp,
132
+ }),
133
+ };
134
+ }
135
+
136
+ const MARK: Record<string, string> = { ok: "✓", warn: "!", todo: "·" };
137
+
138
+ function printHuman(r: OnboardStatus): void {
139
+ for (const c of r.checks)
140
+ console.log(` ${MARK[c.status] ?? " "} ${c.id.padEnd(12)} ${c.detail}`);
141
+ console.log(`\n Next: ${r.next.step}`);
142
+ if (r.next.command) console.log(` Run: ${r.next.command}`);
143
+ }
144
+
145
+ export const ONBOARD_USAGE = () => `${product().cli} onboard status [--json]`;
146
+
147
+ /** `onboard <sub> …`; returns the exit code. */
148
+ export async function cmdOnboard(
149
+ argv: string[],
150
+ probeWeb: (url: string) => Promise<WebProbe>,
151
+ ): Promise<number> {
152
+ const [sub, ...rest] = argv;
153
+ const json = rest.includes("--json");
154
+ const unknown = rest.filter((a) => a !== "--json");
155
+ if (sub !== "status" || unknown.length) {
156
+ const error =
157
+ sub === undefined || sub === "--help" || sub === "-h"
158
+ ? null
159
+ : sub !== "status"
160
+ ? `unknown onboard subcommand "${sub}"`
161
+ : `unknown flag ${unknown[0]}`;
162
+ if (json && error)
163
+ printJson({
164
+ ok: false,
165
+ error,
166
+ fix: `${runnableCli()} onboard status --json`,
167
+ });
168
+ else {
169
+ if (error) console.error(`✗ ${error}`);
170
+ console.log(
171
+ `\n ${ONBOARD_USAGE()}\n\n What's set up (a distribution, the web server, workspaces, repos,\n a running service, Den's MCP) and the one next step, with its command.\n`,
172
+ );
173
+ }
174
+ return error ? 2 : sub === undefined ? 1 : 0;
175
+ }
176
+ const r = await onboardStatus(await defaultStatusDeps({ probeWeb }));
177
+ if (json) printJson(r);
178
+ else printHuman(r);
179
+ return 0;
180
+ }