@monster-orange/den 0.4.13 → 0.4.15

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 +176 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +185 -7
  4. package/package.json +1 -1
  5. package/src/shared/actor-token.ts +12 -0
  6. package/src/shared/agents/conversations.ts +256 -0
  7. package/src/shared/agents/den-claude.ts +19 -0
  8. package/src/shared/agents/records.ts +23 -0
  9. package/src/shared/agents/runtime.ts +14 -0
  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 +14 -0
  13. package/src/shared/environment-manager/service-runner.ts +31 -12
  14. package/src/shared/environment-manager/workspaces.ts +31 -0
  15. package/src/shared/web-session.ts +64 -5
  16. package/src/ui/cli/agent-ops.ts +320 -18
  17. package/src/ui/cli/agents-command.ts +89 -2
  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 +26 -3
  27. package/src/ui/mcp/server.ts +12 -2
  28. package/src/ui/mcp/tools.ts +427 -1
  29. package/src/ui/server/agent-resume-grant.ts +35 -0
  30. package/src/ui/server/agent-routes.ts +149 -1
  31. package/src/ui/server/approval-routes.ts +390 -0
  32. package/src/ui/server/approvals.ts +416 -0
  33. package/src/ui/server/github-routes.ts +12 -4
  34. package/src/ui/server/held-actions.ts +203 -0
  35. package/src/ui/server/http-helpers.ts +35 -0
  36. package/src/ui/server/index.ts +286 -16
  37. package/src/ui/server/lifecycle-exec.ts +70 -0
  38. package/src/ui/server/onboard-routes.ts +132 -0
  39. package/src/ui/server/pair-routes.ts +7 -4
  40. package/src/ui/server/public/agents-page.js +288 -8
  41. package/src/ui/server/public/agents.html +18 -0
  42. package/src/ui/server/public/error-page.js +27 -3
  43. package/src/ui/server/public/header.js +1 -1
  44. package/src/ui/server/public/i18n/es.js +52 -0
  45. package/src/ui/server/public/i18n/ja.js +55 -0
  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 +45 -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 +336 -0
  53. package/src/ui/server/request-guard.ts +19 -4
  54. package/src/ui/server/route-classes.ts +41 -1
  55. package/src/ui/server/session-routes.ts +11 -3
  56. package/src/ui/server/workspace-routes.ts +21 -10
  57. package/themes/den/den-theme.json +6 -6
@@ -28,6 +28,13 @@
28
28
  * same conversation id (there is nothing to continue).
29
29
  * --all-interrupted (AG-14.3): every agent interrupted when Den or
30
30
  * the machine restarted that can resume.
31
+ * import (AG-17.1) bring in a conversation started outside Den: with
32
+ * no --session, the runtime's own conversations in the
33
+ * workspace's folder (newest first); with --session <id> and
34
+ * --persona <p>, the runtime's resume of it (den-claude:
35
+ * --resume <id>) in the folder it ran in, as a new agent under
36
+ * Den's token, hooks and guards. Refused while it is still open
37
+ * in another terminal (written in the last minute).
31
38
  * rm (AG-14.1b) forgets a stopped, interrupted or exited agent (never
32
39
  * a running one); --stopped forgets every one of -w <ws> (or of
33
40
  * every workspace). The runtime's own history stays.
@@ -69,7 +76,9 @@ import {
69
76
  } from "../../shared/agents/records.js";
70
77
  import {
71
78
  AgentError,
79
+ importAgent,
72
80
  listAgents,
81
+ listConversations,
73
82
  listPersonas as personaList,
74
83
  listRuntimes,
75
84
  loadAgentWorkspaces,
@@ -107,14 +116,14 @@ export {
107
116
 
108
117
  export const AGENTS_HELP = {
109
118
  usage:
110
- "agents <list|start|stop|resume|relaunch|rm|personas|doctor|handback-check|handoff-check> ...",
119
+ "agents <list|start|stop|resume|relaunch|import|rm|personas|doctor|handback-check|handoff-check> ...",
111
120
  lines: [
112
121
  "Agents: a persona and a workspace, run by a runtime in a session that",
113
122
  "outlives the web server (see `{cli} sessions`).",
114
123
  "",
115
124
  "{cli} agents list -w <ws> [--stopped] [--json]",
116
125
  " --stopped: only the stopped, interrupted and exited ones",
117
- "{cli} agents start -w <ws> --persona <p> [--runtime <r>] [--model <m>] [--id <id>]",
126
+ "{cli} agents start -w <ws> --persona <p> [--runtime <r>] [--model <m>] [--name <name>] [--id <id>]",
118
127
  " runs in the workspace's dir as <dist>-<ws>:<id>; its MCP is pinned to <ws>",
119
128
  " (plus agents.<id>.grants); capped by agents.max_concurrent",
120
129
  "{cli} agents stop <id> [-w <ws>] [--no-wait]",
@@ -132,6 +141,12 @@ export const AGENTS_HELP = {
132
141
  " compactions (default 3) or its pull request merged, and its handoff.md",
133
142
  " passes the check, a fresh conversation (same persona, workspace, folder,",
134
143
  " grants) whose first prompt is the handoff",
144
+ "{cli} agents import -w <ws> [--runtime <r>] [--json]",
145
+ "{cli} agents import -w <ws> --session <id> --persona <p> [--runtime <r>] [--id <id>]",
146
+ " bring in a conversation started outside Den: the first lists the runtime's",
147
+ " own (default den-claude) in the workspace's folder; the second continues",
148
+ " 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",
135
150
  "{cli} agents rm <id> [-w <ws>]",
136
151
  "{cli} agents rm --stopped [-w <ws>]",
137
152
  " forget a stopped, interrupted or exited agent (never a running one);",
@@ -258,12 +273,14 @@ async function start(args: string[], d: AgentsResolved): Promise<number> {
258
273
  "--persona": true,
259
274
  "--runtime": true,
260
275
  "--model": true,
276
+ "--name": true,
261
277
  "--id": true,
262
278
  });
263
279
  noExtra(where, p);
264
280
  const ws = workspaceFlag(where, p);
265
281
  if (!ws) throw new UsageError(`${where} needs -w <workspace>`);
266
282
  const persona = str(p, "--persona");
283
+ const name = str(p, "--name");
267
284
  if (!persona) throw new UsageError(`${where} needs --persona <p>`);
268
285
  const s = await startAgent(d, {
269
286
  workspace: ws,
@@ -271,6 +288,7 @@ async function start(args: string[], d: AgentsResolved): Promise<number> {
271
288
  runtime: str(p, "--runtime"),
272
289
  model: str(p, "--model"),
273
290
  id: str(p, "--id"),
291
+ ...(name !== undefined ? { name } : {}),
274
292
  });
275
293
  return report(
276
294
  d,
@@ -279,6 +297,72 @@ async function start(args: string[], d: AgentsResolved): Promise<number> {
279
297
  );
280
298
  }
281
299
 
300
+ /** `agents import` (AG-17.1): list, or bring one in. */
301
+ async function bringIn(args: string[], d: AgentsResolved): Promise<number> {
302
+ const where = "agents import";
303
+ const p = parseArgs(where, args, {
304
+ "--workspace": true,
305
+ "--runtime": true,
306
+ "--session": true,
307
+ "--persona": true,
308
+ "--id": true,
309
+ "--json": false,
310
+ });
311
+ noExtra(where, p);
312
+ const ws = workspaceFlag(where, p);
313
+ if (!ws) throw new UsageError(`${where} needs -w <workspace>`);
314
+ const runtime = str(p, "--runtime");
315
+ const session = str(p, "--session");
316
+ if (!session) {
317
+ if (str(p, "--persona") || str(p, "--id"))
318
+ throw new UsageError(`${where}: --persona and --id need --session <id>`);
319
+ const { runtimes, conversations } = listConversations(d, ws, runtime);
320
+ if (p.flags["--json"]) {
321
+ d.out(
322
+ JSON.stringify({ workspace: ws, runtimes, conversations }, null, 2),
323
+ );
324
+ return 0;
325
+ }
326
+ for (const r of runtimes)
327
+ if (!r.bringIn && (!runtime || runtime === r.id)) d.err(`${r.why}`);
328
+ if (!conversations.length) {
329
+ d.out(`no conversations to bring in for workspace ${ws}`);
330
+ return 0;
331
+ }
332
+ d.out(
333
+ columns([
334
+ ["ID", "RUNTIME", "LAST", "TITLE", "FOLDER", "NOTE"],
335
+ ...conversations.map((c) => [
336
+ c.id,
337
+ c.runtime,
338
+ c.lastAt.slice(0, 16).replace("T", " "),
339
+ c.title ?? "(untitled)",
340
+ c.cwd,
341
+ c.agent ? `agent ${c.agent}` : c.open ? "open elsewhere" : "",
342
+ ]),
343
+ ]).join("\n"),
344
+ );
345
+ d.out(
346
+ `bring one in: ${d.cli} agents import -w ${ws} --session <id> --persona <p>`,
347
+ );
348
+ return 0;
349
+ }
350
+ const persona = str(p, "--persona");
351
+ if (!persona) throw new UsageError(`${where} needs --persona <p>`);
352
+ const s = await importAgent(d, {
353
+ workspace: ws,
354
+ session,
355
+ persona,
356
+ runtime,
357
+ id: str(p, "--id"),
358
+ });
359
+ return report(
360
+ d,
361
+ s,
362
+ `brought in ${session} as agent ${s.rec.id} (${s.rec.persona}, ${s.rec.runtime}) as ${s.rec.sessionName} in ${s.rec.cwd}`,
363
+ );
364
+ }
365
+
282
366
  /** `<id> [-w <ws>]`. */
283
367
  function idAndWorkspace(
284
368
  where: string,
@@ -616,6 +700,9 @@ export async function cmdAgents(
616
700
  case "relaunch":
617
701
  await loadAgentWorkspaces(deps);
618
702
  return await relaunch(rest, d);
703
+ case "import":
704
+ await loadAgentWorkspaces(deps);
705
+ return await bringIn(rest, d);
619
706
  case "rm":
620
707
  await loadAgentWorkspaces(deps);
621
708
  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
+ }