@sparkelf/dsh-plus 0.2.0-rc.5 → 0.2.0-rc.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/bin.js CHANGED
@@ -75,6 +75,565 @@ function newerVersion(name, installed, registry = "https://registry.npmjs.org")
75
75
  return publishedVersions(name, registry).filter((entry) => semver.gt(entry.version, installed)).at(-1);
76
76
  }
77
77
  //#endregion
78
+ //#region lib/types/standalone-capabilities.js
79
+ /** Capabilities offered at install time, in interview order. */
80
+ const CAPABILITIES = [
81
+ {
82
+ id: "exa",
83
+ title: "Web search (Exa)",
84
+ detail: "Search the web through Exa instead of the built-in provider; needs an API key",
85
+ needsEndpoint: false
86
+ },
87
+ {
88
+ id: "mineru",
89
+ title: "PDF parsing (MinerU)",
90
+ detail: "Parse uploaded PDFs locally; the service is installed and started here",
91
+ needsEndpoint: true
92
+ },
93
+ {
94
+ id: "officecli",
95
+ title: "Office documents (OfficeCLI)",
96
+ detail: "Create and open DOCX, XLSX, and PPTX deliverables",
97
+ needsEndpoint: false
98
+ },
99
+ {
100
+ id: "computer-use",
101
+ title: "Windows desktop control (computer-use)",
102
+ detail: "Drive the Windows desktop from WSL through the cua driver",
103
+ needsEndpoint: false
104
+ }
105
+ ];
106
+ /** Where this command starts MinerU when the capability is enabled. */
107
+ const DEFAULT_MINERU_ENDPOINT = "http://127.0.0.1:8000/file_parse";
108
+ /** File under the deployment home recording which capabilities were enabled. */
109
+ const CAPABILITY_RECORD = "capabilities.json";
110
+ /** Deployment env file the launcher reads as its `user-env` layer. */
111
+ const CAPABILITY_ENV_FILE = ".env";
112
+ /** One prompt on the terminal, resolving the trimmed line the user typed. */
113
+ function ask(question) {
114
+ return new Promise((resolveAnswer) => {
115
+ process.stdout.write(question);
116
+ process.stdin.once("data", (chunk) => {
117
+ resolveAnswer(String(chunk).trim());
118
+ });
119
+ });
120
+ }
121
+ /** Ask one yes/no question, defaulting to yes. */
122
+ async function confirmDefaultYes(question) {
123
+ const answer = (await ask(question + " [Y/n] ")).toLowerCase();
124
+ return answer === "" || answer === "y" || answer === "yes";
125
+ }
126
+ /**
127
+ * Interview the user about the optional capabilities.
128
+ *
129
+ * Each is offered on its own line and defaults to enabled, so pressing Enter
130
+ * through the interview produces a deployment whose features work. The answers are
131
+ * returned rather than applied, so a caller can report what it will do before it
132
+ * touches the profile.
133
+ *
134
+ * @param interactive - when false, every capability is enabled without asking.
135
+ * @returns the ids to enable and the values the enabled ones need.
136
+ */
137
+ async function interviewCapabilities(interactive) {
138
+ if (!interactive) return { enabled: CAPABILITIES.map((capability) => capability.id) };
139
+ console.log("");
140
+ console.log("Optional capabilities. Press Enter to accept the default (all enabled),");
141
+ console.log("or answer n for any you do not want.");
142
+ console.log("");
143
+ const enabled = [];
144
+ for (const capability of CAPABILITIES) if (await confirmDefaultYes(" Enable " + capability.title + "? (" + capability.detail + ")")) enabled.push(capability.id);
145
+ let exaApiKey;
146
+ if (enabled.includes("exa")) {
147
+ console.log("");
148
+ console.log(" Exa needs an API key. Create one at https://dashboard.exa.ai/api-keys");
149
+ const typed = await ask(" Exa API key (leave empty to add it later): ");
150
+ if (typed !== "") exaApiKey = typed;
151
+ }
152
+ let mineruEndpoint;
153
+ if (enabled.includes("mineru")) {
154
+ const typed = await ask(" MinerU endpoint [http://127.0.0.1:8000/file_parse]: ");
155
+ mineruEndpoint = typed === "" ? DEFAULT_MINERU_ENDPOINT : typed;
156
+ }
157
+ return {
158
+ enabled,
159
+ ...exaApiKey === void 0 ? {} : { exaApiKey },
160
+ ...mineruEndpoint === void 0 ? {} : { mineruEndpoint }
161
+ };
162
+ }
163
+ /**
164
+ * Names this module owns in the deployment env file.
165
+ *
166
+ * Only names the loader accepts here: a `DSH_`-prefixed variable is refused in any `.env` because
167
+ * `DSH_HOME` is itself bootstrap-only and the file could otherwise relocate the home it is read
168
+ * from. MinerU's endpoint therefore travels in the profile layer, which is also where the plugin
169
+ * reads it.
170
+ */
171
+ const CAPABILITY_ENV_NAMES = ["EXA_API_KEY"];
172
+ /**
173
+ * The deployment env file, with one assignment per enabled capability that reaches
174
+ * its service through the launch environment.
175
+ *
176
+ * Exa reads its key from the launch environment, so a capability the user selected is enabled here
177
+ * as well as in the profile layer. Only the names this interview owns are rewritten: a
178
+ * deployment that keeps its own assignments in the same file keeps them, and
179
+ * deselecting a capability removes the assignment that turned it on.
180
+ *
181
+ * @param answers - the interview's answers.
182
+ * @param existing - the file's current text, when it already exists.
183
+ * @returns the environment file's text.
184
+ */
185
+ function capabilityEnvironment(answers, existing = "") {
186
+ const enabled = new Set(answers.enabled);
187
+ const owned = new Set(CAPABILITY_ENV_NAMES);
188
+ const carried = /* @__PURE__ */ new Map();
189
+ for (const line of existing.split("\n")) {
190
+ const parsed = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/.exec(line);
191
+ if (parsed?.[1] !== void 0 && owned.has(parsed[1])) carried.set(parsed[1], parsed[2] ?? "");
192
+ }
193
+ const kept = existing.split("\n").filter((line) => {
194
+ const name = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line)?.[1];
195
+ return line.trim() !== "" && !line.trimStart().startsWith("#") && (name === void 0 || !owned.has(name));
196
+ });
197
+ const written = ["# Written by dsh-plus start from the capability interview. Enabling or", "# disabling a capability rewrites the lines below; other lines are kept."];
198
+ if (enabled.has("exa")) {
199
+ const key = answers.exaApiKey ?? carried.get("EXA_API_KEY");
200
+ if (key !== void 0 && key !== "") written.push("EXA_API_KEY=" + key);
201
+ }
202
+ return [...written, ...kept].join("\n") + "\n";
203
+ }
204
+ /**
205
+ * The profile patch layer that turns the selected capabilities on.
206
+ *
207
+ * The file is data the profile's loader merges, so enabling a capability writes a
208
+ * row rather than editing the deployment. An empty selection still writes the file,
209
+ * because a layer that exists and mounts nothing is how a previously enabled
210
+ * capability is turned back off.
211
+ *
212
+ * The profile's own layer is a separate file with its own name: a deployment that
213
+ * configured something by hand keeps it, and the capability rows are rewritten
214
+ * whole on every start.
215
+ *
216
+ * @param answers - the interview's answers.
217
+ * @returns the patch layer's YAML text.
218
+ */
219
+ /**
220
+ * The patch rows a previous version of this file carried, minus the ones this module owns.
221
+ *
222
+ * The module writes a fixed set of rows, so everything else in the file belongs to the
223
+ * deployment. A row starts at a top-level `- ` entry and runs to the next one, which keeps
224
+ * each row's own config and comments with it.
225
+ *
226
+ * @param existing - the file's current text.
227
+ * @returns the rows to write back after this module's own.
228
+ */
229
+ function foreignPatchRows(existing) {
230
+ if (existing.trim() === "") return [];
231
+ const lines = existing.split("\n");
232
+ const rows = [];
233
+ let current = null;
234
+ for (const line of lines) {
235
+ if (/^-\s/.test(line)) {
236
+ if (current !== null) rows.push(current);
237
+ current = [line];
238
+ continue;
239
+ }
240
+ if (current !== null) current.push(line);
241
+ }
242
+ if (current !== null) rows.push(current);
243
+ const ownedHeads = new Set(["insert", "web"]);
244
+ return rows.filter((row) => {
245
+ const head = row[0] ?? "";
246
+ const id = /^-\s+id:\s*(\S+)/.exec(head)?.[1];
247
+ if (id !== void 0) return !ownedHeads.has(id);
248
+ if (/^-\s+insert:\s*$/.test(head)) {
249
+ const ownedChildren = new Set([
250
+ "web-search-exa",
251
+ "computer-use",
252
+ "computer-use-cua-driver-mcp",
253
+ "mineru"
254
+ ]);
255
+ return row.filter((line, index) => {
256
+ if (index === 0) return false;
257
+ const childId = /^\s+-\s+id:\s*(\S+)/.exec(line)?.[1];
258
+ return childId === void 0 || !ownedChildren.has(childId);
259
+ }).some((line) => /^\s+-\s+/.test(line));
260
+ }
261
+ return true;
262
+ }).map((row) => row.join("\n").trimEnd());
263
+ }
264
+ function capabilityPatchLayer(answers, existing = "") {
265
+ const enabled = new Set(answers.enabled);
266
+ const rows = [];
267
+ if (enabled.has("exa")) rows.push(" - id: web-search-exa", " name: '@deepseek-ai/dsh-web-search-exa'", " config:", " searchType: auto", " numResults: 8");
268
+ if (enabled.has("computer-use")) rows.push(" - id: computer-use", " name: '@deepseek-ai/dsh-computer-use'", " - id: computer-use-cua-driver-mcp", " name: '@deepseek-ai/dsh-experimental-computer-use-cua-driver-mcp'");
269
+ if (enabled.has("officecli")) rows.push(" - id: officecli", " name: '@sparkelf/dsh-officecli'");
270
+ if (enabled.has("mineru")) rows.push(" - id: mineru", " name: '@sparkelf/dsh-mineru'", " config:", " endpoint: " + (answers.mineruEndpoint ?? "http://127.0.0.1:8000/file_parse"));
271
+ const lines = ["# Written by dsh-plus start from the capability interview. Enabling or disabling a capability", "# rewrites this file; edits here are replaced."];
272
+ const foreign = foreignPatchRows(existing);
273
+ if (rows.length > 0) lines.push("- insert:", ...rows);
274
+ else lines.push("[]");
275
+ if (enabled.has("exa")) lines.push("", "# Route web_search through Exa rather than the built-in provider.", "- id: web", " config:", " searchProvider: exa");
276
+ if (foreign.length > 0) lines.push("", "# Rows this file carried before the capability interview; kept verbatim.", ...foreign);
277
+ return lines.join("\n") + "\n";
278
+ }
279
+ /** Run one command, reporting a failure rather than throwing. */
280
+ function runStep(command, args) {
281
+ return spawnSync(command, [...args], { stdio: "inherit" }).status === 0;
282
+ }
283
+ /**
284
+ * The capability record a previous configured run wrote, or `undefined` when none exists.
285
+ *
286
+ * The record is what lets a later run report the deployment's answers instead of asking
287
+ * again. A malformed or unreadable file is treated as absent rather than fatal: it is a
288
+ * convenience cache, and refusing to boot over it would strand a deployment whose record
289
+ * was corrupted by an interrupted write.
290
+ *
291
+ * @param home - the deployment home holding the record.
292
+ * @returns the enabled ids, and the MinerU endpoint when the record carries one.
293
+ */
294
+ function readCapabilityRecord(home) {
295
+ const path = join(home, CAPABILITY_RECORD);
296
+ let parsed;
297
+ try {
298
+ parsed = JSON.parse(readFileSync(path, "utf8"));
299
+ } catch {
300
+ return;
301
+ }
302
+ if (typeof parsed !== "object" || parsed === null) return void 0;
303
+ const record = parsed;
304
+ if (!Array.isArray(record.enabled)) return void 0;
305
+ return {
306
+ enabled: record.enabled.filter((id) => typeof id === "string"),
307
+ ...typeof record.mineruEndpoint === "string" ? { mineruEndpoint: record.mineruEndpoint } : {}
308
+ };
309
+ }
310
+ /**
311
+ * The capabilities a deployment actually has mounted, read from the files the loader and
312
+ * the launch environment consume.
313
+ *
314
+ * A record is a cache of an earlier answer and can drift from the deployment — a profile
315
+ * layer edited by hand, a record deleted, a plugin removed from the distribution. What a
316
+ * capability is *doing* is decided by the profile patch rows and the env file, so that is
317
+ * what a report reads.
318
+ *
319
+ * @param profilePatchText - the profile layer's YAML text.
320
+ * @param envText - the deployment env file's text.
321
+ * @returns the ids found in the profile layer and the env names present.
322
+ */
323
+ function detectEnabledCapabilities(profilePatchText, envText) {
324
+ const mounted = [];
325
+ if (/^\s*- id: web-search-exa\s*$/mu.test(profilePatchText)) mounted.push("exa");
326
+ if (/^\s*- id: mineru\s*$/mu.test(profilePatchText)) mounted.push("mineru");
327
+ if (/^\s*- id: officecli\s*$/mu.test(profilePatchText)) mounted.push("officecli");
328
+ if (/^\s*- id: computer-use\s*$/mu.test(profilePatchText)) mounted.push("computer-use");
329
+ const env = [];
330
+ for (const line of envText.split("\n")) {
331
+ const name = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line)?.[1];
332
+ if (name !== void 0 && !env.includes(name)) env.push(name);
333
+ }
334
+ return {
335
+ mounted,
336
+ env
337
+ };
338
+ }
339
+ /**
340
+ * The patch row one capability mounts, as a list of YAML lines, or `undefined` when the
341
+ * capability is turned on by something other than a row this module owns.
342
+ *
343
+ * MinerU is enabled by a row too, but its endpoint is per-deployment, so the caller passes
344
+ * the answers rather than a fixed row.
345
+ *
346
+ * @param id - the capability id.
347
+ * @returns the insert payload lines, without the surrounding `- insert:` entry.
348
+ */
349
+ function capabilityRows(id) {
350
+ if (id === "exa") return [
351
+ " - id: web-search-exa",
352
+ " name: '@deepseek-ai/dsh-web-search-exa'",
353
+ " config:",
354
+ " searchType: auto",
355
+ " numResults: 8"
356
+ ];
357
+ if (id === "officecli") return [" - id: officecli", " name: '@sparkelf/dsh-officecli'"];
358
+ if (id === "computer-use") return [
359
+ " - id: computer-use",
360
+ " name: '@deepseek-ai/dsh-computer-use'",
361
+ " - id: computer-use-cua-driver-mcp",
362
+ " name: '@deepseek-ai/dsh-experimental-computer-use-cua-driver-mcp'"
363
+ ];
364
+ }
365
+ /** The row ids a capability owns in the profile layer. */
366
+ function capabilityRowIds(id) {
367
+ if (id === "exa") return ["web-search-exa"];
368
+ if (id === "officecli") return ["officecli"];
369
+ if (id === "mineru") return ["mineru"];
370
+ if (id === "computer-use") return ["computer-use", "computer-use-cua-driver-mcp"];
371
+ return [];
372
+ }
373
+ /**
374
+ * One capability's effect, added to or removed from an existing profile layer without
375
+ * disturbing the rest of the file.
376
+ *
377
+ * `dsh-plus start` owns the whole layer and rewrites it from the interview's answers, which
378
+ * is correct for an install that answered once and never edits afterwards. A deployment
379
+ * that maintains its own rows in that same layer cannot be served that way: replacing the
380
+ * file drops every row the operator wrote. This edits the rows this module owns and leaves
381
+ * every other entry, comment, and blank line exactly where it was.
382
+ *
383
+ * @param existing - the layer's current YAML text.
384
+ * @param id - the capability to add or remove.
385
+ * @param enable - whether to add the capability.
386
+ * @param mineruEndpoint - the endpoint to write when enabling MinerU.
387
+ * @returns the layer's new text.
388
+ */
389
+ function editCapabilityPatchLayer(existing, id, enable, mineruEndpoint) {
390
+ const withoutRows = dropOwnedRows(existing, capabilityRowIds(id), id === "exa");
391
+ const mineruRows = [
392
+ " - id: mineru",
393
+ " name: '@sparkelf/dsh-mineru'",
394
+ " config:",
395
+ " endpoint: " + (mineruEndpoint ?? "http://127.0.0.1:8000/file_parse")
396
+ ];
397
+ const rows = id === "mineru" && enable ? mineruRows : capabilityRows(id);
398
+ const trimmed = (enable && rows !== void 0 ? insertCapabilityRows(withoutRows, rows, id === "exa") : withoutRows.text).replace(/\n+$/u, "");
399
+ return trimmed === "" ? "\n" : trimmed + "\n";
400
+ }
401
+ /**
402
+ * Remove the rows one capability owns, and for Exa the `web` selector row that routes
403
+ * search to it.
404
+ *
405
+ * A row is dropped with the block that belongs to it: the `- id:` line plus its indented
406
+ * continuation lines, and any comment line directly above that documented it. Comment and
407
+ * blank lines separating it from the next entry stay, so the surrounding file keeps its
408
+ * shape.
409
+ *
410
+ * @param existing - the layer's current YAML text.
411
+ * @param ids - the row ids this capability owns.
412
+ * @param withSelector - whether the capability also owns the shared `web` selector row.
413
+ * @returns the remaining text and whether anything was actually removed.
414
+ */
415
+ function dropOwnedRows(existing, ids, withSelector) {
416
+ const lines = existing.split("\n");
417
+ const out = [];
418
+ let removed = false;
419
+ for (let index = 0; index < lines.length; index += 1) {
420
+ const line = lines[index] ?? "";
421
+ const match = /^(\s*)- id:\s*(\S+)\s*$/.exec(line);
422
+ const isSelector = withSelector && match?.[2] === "web" && match[1] === "";
423
+ if (match === null || !ids.includes(match[2] ?? "") && !isSelector) {
424
+ out.push(line);
425
+ continue;
426
+ }
427
+ removed = true;
428
+ const indent = match[1] ?? "";
429
+ while (out.length > 0) {
430
+ const previous = out[out.length - 1] ?? "";
431
+ if (previous.trimStart().startsWith("#") && previous.trim() !== "") out.pop();
432
+ else break;
433
+ }
434
+ for (index += 1; index < lines.length; index += 1) {
435
+ const next = lines[index] ?? "";
436
+ if (next.trim() === "") {
437
+ const after = lines[index + 1] ?? "";
438
+ if (after !== "" && (after.startsWith(indent + " ") || after.startsWith(indent + " ") || after.trimStart().startsWith("#"))) continue;
439
+ index -= 1;
440
+ break;
441
+ }
442
+ if ((/^\s*/.exec(next)?.[0] ?? "").length > indent.length) continue;
443
+ index -= 1;
444
+ break;
445
+ }
446
+ }
447
+ return {
448
+ text: out.join("\n"),
449
+ removed
450
+ };
451
+ }
452
+ /**
453
+ * Add a capability's rows to the layer, creating the `- insert:` entry when the file has
454
+ * none and appending to the existing one otherwise.
455
+ *
456
+ * The loader indexes inserted rows under the layer that inserted them, so appending to an
457
+ * existing `- insert:` list and adding a second one both work; appending keeps the file
458
+ * readable instead of growing one `- insert:` per capability.
459
+ *
460
+ * @param state - the layer with this capability's own rows already removed.
461
+ * @param rows - the insert payload lines to add.
462
+ * @param withSelector - whether Exa's `web` selector row must also be present.
463
+ * @returns the layer's new text.
464
+ */
465
+ function insertCapabilityRows(state, rows, withSelector) {
466
+ const lines = state.text.split("\n");
467
+ const insertIndex = lines.findIndex((line) => /^- insert:\s*$/.test(line));
468
+ if (insertIndex >= 0) {
469
+ let end = insertIndex + 1;
470
+ while (end < lines.length) {
471
+ const line = lines[end] ?? "";
472
+ if (line.trim() === "" || /^\s/.test(line)) end += 1;
473
+ else break;
474
+ }
475
+ while (end > insertIndex + 1 && (lines[end - 1] ?? "").trim() === "") end -= 1;
476
+ lines.splice(end, 0, ...rows);
477
+ } else {
478
+ const emptyIndex = lines.findIndex((line) => line.trim() === "[]");
479
+ if (emptyIndex >= 0) lines.splice(emptyIndex, 1, "- insert:", ...rows);
480
+ else {
481
+ const trailing = lines.length > 0 && (lines[lines.length - 1] ?? "") === "" ? lines.pop() ?? "" : void 0;
482
+ lines.push("- insert:", ...rows);
483
+ if (trailing !== void 0) lines.push(trailing);
484
+ }
485
+ }
486
+ if (withSelector && !/^\s*- id: web\s*$/mu.test(lines.join("\n"))) lines.push("", "# Route web_search through Exa rather than the built-in provider.", "- id: web", " config:", " searchProvider: exa", " fetchProvider: http");
487
+ return lines.join("\n");
488
+ }
489
+ /**
490
+ * Install and start the backing services the selected capabilities need.
491
+ *
492
+ * Enabling a capability means the deployment expects its service to answer, so the
493
+ * command that offers the choice is also the command that provides it. Each step
494
+ * reports what it is doing and, on failure, the command a user can run by hand —
495
+ * a missing runtime is a fact about the host, not a reason to abandon the install.
496
+ *
497
+ * @param answers - the interview's answers.
498
+ * @param home - the deployment home, where generated service units are recorded.
499
+ * @returns the ids whose service is ready.
500
+ */
501
+ async function installCapabilityServices(answers, home) {
502
+ const enabled = new Set(answers.enabled);
503
+ const ready = [];
504
+ mkdirSync(home, { recursive: true });
505
+ writeFileSync(join(home, CAPABILITY_RECORD), JSON.stringify({
506
+ enabled: [...answers.enabled],
507
+ ...answers.mineruEndpoint === void 0 ? {} : { mineruEndpoint: answers.mineruEndpoint }
508
+ }, null, 2) + "\n");
509
+ if (enabled.has("mineru")) {
510
+ console.log("");
511
+ console.log(" MinerU: installing the parser and starting its service...");
512
+ if (installMineru(home) && startMineru(home)) {
513
+ ready.push("mineru");
514
+ console.log(" MinerU: ready at " + (answers.mineruEndpoint ?? "http://127.0.0.1:8000/file_parse"));
515
+ } else {
516
+ console.log(" MinerU: not installed. Install it later with:");
517
+ console.log(" pip install -U \"mineru[core]\"");
518
+ console.log(" mineru-api --host 127.0.0.1 --port 8000");
519
+ }
520
+ }
521
+ if (enabled.has("officecli")) ready.push("officecli");
522
+ if (enabled.has("computer-use")) if (desktopDriverAvailable()) ready.push("computer-use");
523
+ else {
524
+ console.log("");
525
+ console.log(" computer-use: the cua driver was not found on PATH. The plugin is");
526
+ console.log(" still mounted, so it starts once the driver is installed:");
527
+ console.log(" install the cua-driver release for this architecture, then restart");
528
+ }
529
+ return ready;
530
+ }
531
+ /**
532
+ * Install MinerU when it is absent and start its API service.
533
+ *
534
+ * MinerU is a Python package whose API server answers on a local port; the profile
535
+ * points at that port. The parser is installed into a virtual environment under the
536
+ * deployment home and an existing one is upgraded rather than skipped, so enabling
537
+ * the capability keeps the parser current without touching the system interpreter.
538
+ *
539
+ * @param home - the deployment home that owns the venv.
540
+ * @returns whether the package is installed; not whether it answered.
541
+ */
542
+ function installMineru(home) {
543
+ const python = pythonInterpreter();
544
+ if (python === void 0) {
545
+ console.log(" MinerU: no Python interpreter found on PATH.");
546
+ return false;
547
+ }
548
+ const venv = join(home, ".mineru-venv");
549
+ if (!runStep(python, [
550
+ "-m",
551
+ "venv",
552
+ venv
553
+ ])) return false;
554
+ return runStep(join(venv, "bin", "python"), [
555
+ "-m",
556
+ "pip",
557
+ "install",
558
+ "-U",
559
+ "mineru[all]"
560
+ ]);
561
+ }
562
+ /**
563
+ * Start the MinerU API server as a background service when none answers yet.
564
+ *
565
+ * The service has to outlive the install command, so it is registered with the
566
+ * host's service manager when one is available and otherwise started detached.
567
+ *
568
+ * @returns whether the server is running after this call.
569
+ */
570
+ function startMineru(home) {
571
+ const api = mineruApiBinary(home);
572
+ if (api === void 0) return false;
573
+ if (mineruAnswers()) return true;
574
+ if (spawnSync("systemctl", ["--version"], { stdio: "ignore" }).status === 0) {
575
+ writeFileSync("/etc/systemd/system/mineru-api.service", [
576
+ "[Unit]",
577
+ "Description=MinerU document parsing API for DeepSeek Harness Plus",
578
+ "After=network-online.target",
579
+ "Wants=network-online.target",
580
+ "",
581
+ "[Service]",
582
+ "Type=simple",
583
+ "User=root",
584
+ "ExecStart=" + api + " --host 127.0.0.1 --port 8000",
585
+ "Restart=on-failure",
586
+ "RestartSec=2",
587
+ "TimeoutStopSec=15",
588
+ "",
589
+ "[Install]",
590
+ "WantedBy=multi-user.target",
591
+ ""
592
+ ].join("\n"));
593
+ spawnSync("systemctl", ["daemon-reload"], { stdio: "ignore" });
594
+ return spawnSync("systemctl", [
595
+ "enable",
596
+ "--now",
597
+ "mineru-api.service"
598
+ ], { stdio: "ignore" }).status === 0;
599
+ }
600
+ console.log(" MinerU: start the API server with:");
601
+ console.log(" " + api + " --host 127.0.0.1 --port 8000");
602
+ return false;
603
+ }
604
+ /** The Python interpreter to install MinerU with, preferring python3. */
605
+ function pythonInterpreter() {
606
+ return ["python3", "python"].find((candidate) => spawnSync(candidate, ["--version"], { stdio: "ignore" }).status === 0);
607
+ }
608
+ /**
609
+ * The MinerU API entry point, wherever the install placed it.
610
+ *
611
+ * The venv this command creates comes first: it is the interpreter the parser was
612
+ * installed into, so its `mineru-api` is the one that can import MinerU. A binary on
613
+ * PATH is a fallback for a deployment that installed MinerU some other way.
614
+ *
615
+ * @param home - the deployment home that owns the venv.
616
+ * @returns the command to run, or `undefined` when none answers.
617
+ */
618
+ function mineruApiBinary(home) {
619
+ return [join(home, ".mineru-venv", "bin", "mineru-api"), "mineru-api"].find((candidate) => spawnSync(candidate, ["--help"], { stdio: "ignore" }).status === 0);
620
+ }
621
+ /** Whether a MinerU API server already answers on the default port. */
622
+ function mineruAnswers() {
623
+ return spawnSync("curl", [
624
+ "-sf",
625
+ "-o",
626
+ "/dev/null",
627
+ "--max-time",
628
+ "3",
629
+ "http://127.0.0.1:8000/docs"
630
+ ], { stdio: "ignore" }).status === 0;
631
+ }
632
+ /** Whether the Windows-side desktop driver answers from this shell. */
633
+ function desktopDriverAvailable() {
634
+ return spawnSync("cua-driver", ["--version"], { stdio: "ignore" }).status === 0;
635
+ }
636
+ //#endregion
78
637
  //#region lib/types/standalone-server.js
79
638
  /**
80
639
  * Process lifecycle for one standalone Plus server.
@@ -242,8 +801,55 @@ async function waitForAuthenticatedUrl(logPath, timeoutMilliseconds) {
242
801
  * the launcher mounts exactly the bundles the profile names and nothing expands a
243
802
  * bundle's own list.
244
803
  */
245
- /** Profile name a standalone installation owns. */
804
+ /** Profile name a standalone installation owns when its package declares none. */
246
805
  const STANDALONE_PROFILE = "plus";
806
+ /**
807
+ * Read the deployment facts the installing package declares.
808
+ *
809
+ * A standalone package describes the profile it materializes and the capabilities it
810
+ * omits, so a reduced variant owns a differently named profile and a reduced install
811
+ * without the distribution naming either. The declaration travels with the package
812
+ * because npm resolves the tree long before any command of ours runs.
813
+ *
814
+ * @param anchor - path inside the installing package's tree.
815
+ * @param fallback - profile name to use when the declaration is absent.
816
+ * @returns the declared profile name and omitted capabilities.
817
+ */
818
+ function readStandaloneDeclaration(anchor, fallback = STANDALONE_PROFILE) {
819
+ let current = resolve(anchor);
820
+ for (;;) {
821
+ const declared = readStandaloneFacts(join(current, "package.json"));
822
+ if (declared !== void 0) return declared;
823
+ const parent = dirname(current);
824
+ if (parent === current) return {
825
+ profileName: fallback,
826
+ omittedPackages: {}
827
+ };
828
+ current = parent;
829
+ }
830
+ }
831
+ /** Read one manifest's standalone declaration, or undefined when it carries none. */
832
+ function readStandaloneFacts(manifestPath) {
833
+ if (!existsSync(manifestPath)) return void 0;
834
+ let standalone;
835
+ try {
836
+ standalone = JSON.parse(readFileSync(manifestPath, "utf8")).dshPlusStandalone;
837
+ } catch {
838
+ return;
839
+ }
840
+ const profile = standalone?.profile;
841
+ if (standalone === void 0 || typeof profile !== "string" || profile === "") return void 0;
842
+ const rawOmitted = standalone.omittedPackages;
843
+ const omittedPackages = {};
844
+ if (rawOmitted !== void 0 && typeof rawOmitted === "object" && !Array.isArray(rawOmitted)) for (const [name, spec] of Object.entries(rawOmitted)) {
845
+ if (typeof spec !== "string" || spec === "") throw new Error("dshPlusStandalone.omittedPackages." + name + " must be a non-empty string");
846
+ omittedPackages[name] = spec;
847
+ }
848
+ return {
849
+ profileName: profile,
850
+ omittedPackages
851
+ };
852
+ }
247
853
  function requireRecord(value, label) {
248
854
  if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(label + " must be an object");
249
855
  return value;
@@ -524,9 +1130,12 @@ function linkBundle(consumerModules, name, source) {
524
1130
  /** Resolve every path a command needs, without creating anything. */
525
1131
  function resolvePaths(anchor, env = process.env) {
526
1132
  const home = resolveHome(env);
1133
+ const declaration = readStandaloneDeclaration(anchor);
527
1134
  return {
528
1135
  home,
529
- profileDirectory: join(home, "profiles", STANDALONE_PROFILE),
1136
+ profileName: declaration.profileName,
1137
+ omittedPackages: declaration.omittedPackages,
1138
+ profileDirectory: join(home, "profiles", declaration.profileName),
530
1139
  distributionDirectory: resolveDistributionDirectory(anchor)
531
1140
  };
532
1141
  }
@@ -550,7 +1159,7 @@ function ensureProfile(paths, consumerDirectory) {
550
1159
  const manifestPath = join(paths.profileDirectory, "package.json");
551
1160
  const distribution = readDistributionProfile(paths.distributionDirectory);
552
1161
  if (existsSync(manifestPath)) {
553
- writeProfileOverrides(paths.profileDirectory, distribution.overrides, distribution.allowBuilds);
1162
+ writeProfileOverrides(paths.profileDirectory, distribution.overrides, distribution.allowBuilds, paths.omittedPackages);
554
1163
  installProfilePackages(paths, consumerDirectory);
555
1164
  return false;
556
1165
  }
@@ -570,7 +1179,7 @@ function ensureProfile(paths, consumerDirectory) {
570
1179
  } }
571
1180
  };
572
1181
  writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
573
- writeProfileOverrides(paths.profileDirectory, distribution.overrides, distribution.allowBuilds);
1182
+ writeProfileOverrides(paths.profileDirectory, distribution.overrides, distribution.allowBuilds, paths.omittedPackages);
574
1183
  installProfilePackages(paths, consumerDirectory);
575
1184
  return true;
576
1185
  }
@@ -584,13 +1193,14 @@ function ensureProfile(paths, consumerDirectory) {
584
1193
  * @param profileDirectory - the standalone profile directory.
585
1194
  * @param overrides - official package name to published replacement spec.
586
1195
  */
587
- function writeProfileOverrides(profileDirectory, overrides, allowBuilds) {
1196
+ function writeProfileOverrides(profileDirectory, overrides, allowBuilds, omittedPackages) {
588
1197
  const workspacePath = join(profileDirectory, "pnpm-workspace.yaml");
589
1198
  const document = parseDocument(existsSync(workspacePath) ? readFileSync(workspacePath, "utf8") : "");
590
1199
  const [documentError] = document.errors;
591
1200
  if (documentError !== void 0) throw new Error("Plus profile workspace is not valid YAML", { cause: documentError });
592
1201
  if (document.get("packages") === void 0) document.set("packages", ["."]);
593
1202
  for (const [name, spec] of Object.entries(overrides)) document.setIn(["overrides", name], spec);
1203
+ for (const [name, spec] of Object.entries(omittedPackages)) document.setIn(["overrides", name], spec);
594
1204
  for (const [name, allowed] of Object.entries(allowBuilds)) document.setIn(["allowBuilds", name], allowed);
595
1205
  if (document.get("nodeLinker") === void 0) document.set("nodeLinker", "hoisted");
596
1206
  if (document.get("autoInstallPeers") === void 0) document.set("autoInstallPeers", false);
@@ -681,11 +1291,14 @@ function resolveInstalledPackage(from, packageName) {
681
1291
  */
682
1292
  /** Milliseconds a start waits for the server to answer before reporting failure. */
683
1293
  const READY_TIMEOUT_MILLISECONDS = 9e4;
1294
+ /** Profile patch file the capability interview rewrites (the profile's user layer). */
1295
+ const CAPABILITY_PATCH_FILE = "cordis.patch.yml";
684
1296
  function parseStartOptions(argv) {
685
1297
  let port = DEFAULT_PORT;
686
1298
  let host = "127.0.0.1";
687
1299
  let open = true;
688
1300
  let foreground = false;
1301
+ let capabilities;
689
1302
  for (let index = 0; index < argv.length; index += 1) {
690
1303
  const token = argv[index];
691
1304
  if (token === "--port" || token === "-p") {
@@ -710,13 +1323,41 @@ function parseStartOptions(argv) {
710
1323
  foreground = true;
711
1324
  continue;
712
1325
  }
1326
+ if (token === "--capabilities") {
1327
+ const value = argv[index + 1];
1328
+ if (value === void 0) throw new Error("--capabilities requires a comma-separated list, or an empty string for none");
1329
+ const stated = value === "" ? [] : value.split(",").map((entry) => entry.trim()).filter((entry) => entry !== "");
1330
+ const offered = new Set(CAPABILITIES.map((capability) => capability.id));
1331
+ for (const id of stated) if (!offered.has(id)) throw new Error("unknown capability \"" + id + "\"; this release offers " + [...offered].join(", "));
1332
+ capabilities = stated;
1333
+ index += 1;
1334
+ continue;
1335
+ }
713
1336
  throw new Error("unknown option: " + String(token));
714
1337
  }
715
1338
  return {
716
1339
  port,
717
1340
  host,
718
1341
  open,
719
- foreground
1342
+ foreground,
1343
+ capabilities
1344
+ };
1345
+ }
1346
+ /**
1347
+ * Resolve a stated capability selection.
1348
+ *
1349
+ * An unattended install states what it wants instead of answering the interview, so a
1350
+ * name it does not offer has to fail here rather than silently enable nothing: the
1351
+ * deployment would otherwise start with a capability the caller believes it selected.
1352
+ *
1353
+ * @param ids - capability ids the caller selected.
1354
+ * @returns the answers the interview would have returned.
1355
+ */
1356
+ function selectCapabilities(ids) {
1357
+ const selected = [...new Set(ids)];
1358
+ return {
1359
+ enabled: selected,
1360
+ ...selected.includes("mineru") ? { mineruEndpoint: DEFAULT_MINERU_ENDPOINT } : {}
720
1361
  };
721
1362
  }
722
1363
  /**
@@ -747,16 +1388,35 @@ function installationRoot() {
747
1388
  function installationAnchor() {
748
1389
  return join(installationRoot(), "package.json");
749
1390
  }
1391
+ /**
1392
+ * Path to the installed command's own file, which is where a declaration lookup starts.
1393
+ *
1394
+ * The declaration belongs to the package the user installed, and that package is a
1395
+ * sibling of this module rather than an ancestor: a variant's forwarder imports this
1396
+ * CLI in-process, so `import.meta.url` names `@sparkelf/dsh-plus` while the installed
1397
+ * command is the variant. `process.argv[1]` is the entry that was actually invoked,
1398
+ * which is the package whose declaration applies.
1399
+ *
1400
+ * Walking out from the installation root cannot reach it either: that root is the
1401
+ * project the user ran the command in, so every variant would fall back to the full
1402
+ * profile and keep the capabilities it excluded.
1403
+ *
1404
+ * @returns absolute path to the invoked command, or this module when argv carries none.
1405
+ */
1406
+ function declarationAnchor() {
1407
+ const invoked = process.argv[1];
1408
+ return invoked === void 0 || invoked === "" ? fileURLToPath(import.meta.url) : resolve(invoked);
1409
+ }
750
1410
  /** The launcher entry this installation must drive. */
751
1411
  function launcherEntry(anchor) {
752
1412
  return createRequire(anchor).resolve("@deepseek-ai/dsh/lib/bin.js");
753
1413
  }
754
1414
  /** Run the server in this process, inheriting stdio. */
755
- function runForeground(entry, port, host, open) {
1415
+ function runForeground(entry, profileName, port, host, open) {
756
1416
  const args = [
757
1417
  entry,
758
1418
  "--profile",
759
- STANDALONE_PROFILE,
1419
+ profileName,
760
1420
  "--port",
761
1421
  String(port),
762
1422
  "--host",
@@ -768,7 +1428,7 @@ function runForeground(entry, port, host, open) {
768
1428
  async function start(argv) {
769
1429
  const options = parseStartOptions(argv);
770
1430
  const anchor = installationAnchor();
771
- const paths = resolvePaths(anchor);
1431
+ const paths = resolvePaths(declarationAnchor());
772
1432
  if (!pnpmAvailable()) {
773
1433
  const command = pnpmInstallCommand();
774
1434
  console.log("pnpm is required to install the plus profile, and was not found.");
@@ -799,19 +1459,29 @@ async function start(argv) {
799
1459
  console.log("pnpm installed.");
800
1460
  }
801
1461
  const created = ensureProfile(paths, installationRoot());
802
- console.log(created ? "Created the plus profile at " + paths.profileDirectory : "Using the existing plus profile");
1462
+ console.log(created ? "Created the " + paths.profileName + " profile at " + paths.profileDirectory : "Using the existing " + paths.profileName + " profile");
803
1463
  for (const label of applyProfileNpmPatches(paths.distributionDirectory, paths.profileDirectory)) console.log("Applied the reviewed patch " + label);
1464
+ const answers = created || !existsSync(join(paths.home, "capabilities.json")) ? options.capabilities === void 0 ? await interviewCapabilities(true) : selectCapabilities(options.capabilities) : void 0;
1465
+ if (answers !== void 0) {
1466
+ const ready = await installCapabilityServices(answers, paths.home);
1467
+ writeCapabilityPatch(paths.profileDirectory, answers);
1468
+ writeCapabilityEnvironment(paths.home, answers);
1469
+ console.log(" Enabled: " + (answers.enabled.length === 0 ? "(none)" : answers.enabled.join(", ")));
1470
+ if (answers.enabled.length > 0) console.log(" Services ready: " + (ready.length === 0 ? "(none)" : ready.join(", ")));
1471
+ if (answers.enabled.includes("exa") && answers.exaApiKey === void 0) console.log(" Exa: add EXA_API_KEY to " + join(paths.home, CAPABILITY_ENV_FILE) + " when you have a key.");
1472
+ }
804
1473
  const entry = launcherEntry(anchor);
805
- if (options.foreground) return runForeground(entry, options.port, options.host, options.open);
1474
+ if (options.foreground) return runForeground(entry, paths.profileName, options.port, options.host, options.open);
806
1475
  const existing = readState(paths.home);
807
1476
  if (existing !== void 0) {
808
1477
  console.log("Plus is already running at " + existing.url);
809
1478
  console.log("Stop it with: dsh-plus stop");
810
1479
  return 0;
811
1480
  }
812
- return startDetached(paths.home, entry, options);
1481
+ return startDetached(paths, entry, options);
813
1482
  }
814
- async function startDetached(home, entry, options) {
1483
+ async function startDetached(paths, entry, options) {
1484
+ const home = paths.home;
815
1485
  const port = await choosePort(options.port, options.host);
816
1486
  if (port === void 0) {
817
1487
  console.error("No free port in the range " + String(options.port) + "-" + String(options.port + 9) + ".");
@@ -819,15 +1489,15 @@ async function startDetached(home, entry, options) {
819
1489
  return 1;
820
1490
  }
821
1491
  if (port !== options.port) console.log("Port " + String(options.port) + " is in use; using " + String(port) + ".");
822
- const logPath = join(stateDirectory(home), "server.log");
1492
+ const logPath = join(stateDirectory(paths.home), "server.log");
823
1493
  const env = {
824
1494
  ...process.env,
825
- DSH_HOME: home
1495
+ DSH_HOME: paths.home
826
1496
  };
827
1497
  const args = [
828
1498
  entry,
829
1499
  "--profile",
830
- STANDALONE_PROFILE,
1500
+ paths.profileName,
831
1501
  "--port",
832
1502
  String(port),
833
1503
  "--host",
@@ -942,6 +1612,40 @@ async function update(argv) {
942
1612
  if (readState(paths.home) !== void 0) console.log("The running server still serves " + installed + "; run dsh-plus restart to load the new release.");
943
1613
  return 0;
944
1614
  }
1615
+ /**
1616
+ * Write the profile's capability patch layer.
1617
+ *
1618
+ * The profile's loader merges this file, so enabling a capability is a data change
1619
+ * rather than an edit to the deployment. Writing it on every configured run also
1620
+ * turns a capability back off when the interview no longer selects it.
1621
+ *
1622
+ * @param profileDirectory - the profile whose layer is replaced.
1623
+ * @param answers - the interview's answers.
1624
+ */
1625
+ function writeCapabilityPatch(profileDirectory, answers) {
1626
+ const path = join(profileDirectory, CAPABILITY_PATCH_FILE);
1627
+ const existing = existsSync(path) ? readFileSync(path, "utf8") : void 0;
1628
+ if (existing !== void 0 && !existing.includes("Written by dsh-plus start from the capability interview.")) {
1629
+ writeFileSync(path + ".before-capabilities", existing);
1630
+ console.log(" Kept the existing profile layer at cordis.patch.yml.before-capabilities");
1631
+ }
1632
+ writeFileSync(path, capabilityPatchLayer(answers, existing ?? ""));
1633
+ }
1634
+ /**
1635
+ * Write the deployment's capability environment file.
1636
+ *
1637
+ * MinerU is switched on by the presence of its endpoint and Exa reads its key from
1638
+ * the launch environment, so the answers reach those two through `$DSH_HOME/.env`
1639
+ * rather than through the profile layer. The file is replaced whole on every
1640
+ * configured start, which is also how a capability the user dropped stops applying.
1641
+ *
1642
+ * @param home - the deployment home whose env file the launcher reads.
1643
+ * @param answers - the interview's answers.
1644
+ */
1645
+ function writeCapabilityEnvironment(home, answers) {
1646
+ const path = join(home, CAPABILITY_ENV_FILE);
1647
+ writeFileSync(path, capabilityEnvironment(answers, existsSync(path) ? readFileSync(path, "utf8") : ""));
1648
+ }
945
1649
  /** Ask one yes/no question on the terminal. */
946
1650
  function confirm(question) {
947
1651
  return new Promise((resolveAnswer) => {
@@ -952,6 +1656,134 @@ function confirm(question) {
952
1656
  });
953
1657
  });
954
1658
  }
1659
+ /**
1660
+ * Report and change which optional capabilities this deployment has enabled.
1661
+ *
1662
+ * The interview runs once, at install time, and a deployment that answered it keeps its
1663
+ * answers — so this is the only way to see what it chose or to change one answer later.
1664
+ * The report is read from the profile layer and the env file rather than from the record,
1665
+ * because those two are what the loader and the launch environment actually consume: a
1666
+ * record deleted, or a layer edited by hand, would otherwise describe a deployment that
1667
+ * does not exist.
1668
+ *
1669
+ * `--enable` and `--disable` edit only the rows this module owns. The profile layer is
1670
+ * shared with rows an operator wrote themselves, so it is never replaced wholesale.
1671
+ *
1672
+ * @param argv - arguments after the command word.
1673
+ * @returns the process exit code.
1674
+ */
1675
+ function capabilities(argv) {
1676
+ const paths = resolvePaths(installationAnchor());
1677
+ const patchPath = join(paths.profileDirectory, CAPABILITY_PATCH_FILE);
1678
+ const envPath = join(paths.home, CAPABILITY_ENV_FILE);
1679
+ const patchText = existsSync(patchPath) ? readFileSync(patchPath, "utf8") : "";
1680
+ const envText = existsSync(envPath) ? readFileSync(envPath, "utf8") : "";
1681
+ const actual = detectEnabledCapabilities(patchText, envText);
1682
+ const record = readCapabilityRecord(paths.home);
1683
+ let enable = [];
1684
+ let disable = [];
1685
+ let asJson = false;
1686
+ for (let index = 0; index < argv.length; index += 1) {
1687
+ const token = argv[index];
1688
+ if (token === "--json") {
1689
+ asJson = true;
1690
+ continue;
1691
+ }
1692
+ if (token === "--enable" || token === "--disable") {
1693
+ const value = argv[index + 1];
1694
+ const ids = (value ?? "").split(",").map((id) => id.trim()).filter((id) => id !== "");
1695
+ if (value === void 0 || ids.length === 0) {
1696
+ console.error(token + " requires a comma-separated list of capability ids");
1697
+ return 1;
1698
+ }
1699
+ const unknown = ids.filter((id) => !CAPABILITIES.some((capability) => capability.id === id));
1700
+ if (unknown.length > 0) {
1701
+ console.error("unknown capability: " + unknown.join(", "));
1702
+ console.error("Known: " + CAPABILITIES.map((capability) => capability.id).join(", "));
1703
+ return 1;
1704
+ }
1705
+ if (token === "--enable") enable = [...new Set([...enable, ...ids])];
1706
+ else disable = [...new Set([...disable, ...ids])];
1707
+ index += 1;
1708
+ continue;
1709
+ }
1710
+ console.error("unknown option: " + String(token));
1711
+ return 1;
1712
+ }
1713
+ const conflicting = enable.filter((id) => disable.includes(id));
1714
+ if (conflicting.length > 0) {
1715
+ console.error("cannot both enable and disable: " + conflicting.join(", "));
1716
+ return 1;
1717
+ }
1718
+ if (enable.length > 0 || disable.length > 0) {
1719
+ const enabledNow = new Set(actual.mounted);
1720
+ let nextPatch = patchText;
1721
+ for (const id of enable) {
1722
+ nextPatch = editCapabilityPatchLayer(nextPatch, id, true, record?.mineruEndpoint);
1723
+ enabledNow.add(id);
1724
+ }
1725
+ for (const id of disable) {
1726
+ nextPatch = editCapabilityPatchLayer(nextPatch, id, false);
1727
+ enabledNow.delete(id);
1728
+ }
1729
+ if (!existsSync(patchPath) || !patchText.includes("Written by dsh-plus start from the capability interview.")) {
1730
+ const backup = patchPath + ".before-capabilities";
1731
+ if (patchText !== "" && !existsSync(backup)) writeFileSync(backup, patchText);
1732
+ }
1733
+ writeFileSync(patchPath, nextPatch);
1734
+ const nextEnv = capabilityEnvironment({
1735
+ enabled: [...enabledNow],
1736
+ ...record?.mineruEndpoint === void 0 ? {} : { mineruEndpoint: record.mineruEndpoint }
1737
+ }, envText);
1738
+ writeFileSync(envPath, nextEnv);
1739
+ const after = detectEnabledCapabilities(nextPatch, nextEnv);
1740
+ console.log("Updated cordis.patch.yml and .env.");
1741
+ console.log("Run dsh-plus start (or restart the server) to apply the change.");
1742
+ return reportCapabilities(after, record, asJson);
1743
+ }
1744
+ return reportCapabilities(actual, record, asJson);
1745
+ }
1746
+ /**
1747
+ * Print the capability report.
1748
+ *
1749
+ * @param actual - the capabilities found in the profile layer and env file.
1750
+ * @param record - what the last configured run recorded, when a record exists.
1751
+ * @param asJson - when true, print machine-readable JSON instead of the table.
1752
+ * @returns the process exit code, always 0.
1753
+ */
1754
+ function reportCapabilities(actual, record, asJson) {
1755
+ const rows = CAPABILITIES.map((capability) => {
1756
+ const mounted = actual.mounted.includes(capability.id);
1757
+ const recorded = record?.enabled.includes(capability.id);
1758
+ return {
1759
+ id: capability.id,
1760
+ title: capability.title,
1761
+ enabled: mounted,
1762
+ recorded: recorded ?? null
1763
+ };
1764
+ });
1765
+ if (asJson) {
1766
+ console.log(JSON.stringify({
1767
+ capabilities: rows,
1768
+ env: actual.env,
1769
+ recordPresent: record !== void 0
1770
+ }, null, 2));
1771
+ return 0;
1772
+ }
1773
+ console.log("Optional capabilities:");
1774
+ for (const row of rows) {
1775
+ const state = row.enabled ? "enabled " : "disabled";
1776
+ const drift = row.recorded !== null && row.recorded !== row.enabled ? " (record says " + (row.recorded ? "enabled" : "disabled") + ")" : "";
1777
+ console.log(" " + (row.enabled ? "ok " : "-- ") + row.id.padEnd(14) + state + drift);
1778
+ }
1779
+ if (record === void 0) {
1780
+ console.log("");
1781
+ console.log("No capabilities.json yet; the next dsh-plus start will run the interview.");
1782
+ }
1783
+ console.log("");
1784
+ console.log("Change with: dsh-plus capabilities --enable <id> | --disable <id>");
1785
+ return 0;
1786
+ }
955
1787
  async function doctor() {
956
1788
  const paths = resolvePaths(installationAnchor());
957
1789
  let failures = 0;
@@ -964,6 +1796,12 @@ async function doctor() {
964
1796
  check(existsSync(join(paths.profileDirectory, "package.json")), "profile at " + paths.profileDirectory + " (dsh-plus start creates it)");
965
1797
  const free = await portAvailable(DEFAULT_PORT);
966
1798
  check(true, "port " + String(DEFAULT_PORT) + (free ? " is free" : " is in use; start will choose the next free one"));
1799
+ const patchPath = join(paths.profileDirectory, CAPABILITY_PATCH_FILE);
1800
+ const envPath = join(paths.home, CAPABILITY_ENV_FILE);
1801
+ const actual = detectEnabledCapabilities(existsSync(patchPath) ? readFileSync(patchPath, "utf8") : "", existsSync(envPath) ? readFileSync(envPath, "utf8") : "");
1802
+ const record = readCapabilityRecord(paths.home);
1803
+ const drifted = record === void 0 ? [] : CAPABILITIES.filter((capability) => record.enabled.includes(capability.id) !== actual.mounted.includes(capability.id));
1804
+ check(drifted.length === 0, drifted.length === 0 ? "capabilities: " + (actual.mounted.length === 0 ? "(none enabled)" : actual.mounted.join(", ")) : "capabilities disagree with capabilities.json: " + drifted.map((capability) => capability.id).join(", ") + " (run dsh-plus capabilities)");
967
1805
  console.log(failures === 0 ? "No problems found." : String(failures) + " problem(s) found.");
968
1806
  return failures === 0 ? 0 : 1;
969
1807
  }
@@ -971,11 +1809,12 @@ const USAGE = [
971
1809
  "Usage: dsh-plus <command> [options]",
972
1810
  "",
973
1811
  "Commands:",
974
- " start start the server (first run creates the profile)",
975
- " stop stop the server",
976
- " status report whether the server is running",
977
- " update move to a newer distribution release",
978
- " doctor check this installation",
1812
+ " start start the server (first run creates the profile)",
1813
+ " stop stop the server",
1814
+ " status report whether the server is running",
1815
+ " capabilities report or change the optional capabilities",
1816
+ " update move to a newer distribution release",
1817
+ " doctor check this installation",
979
1818
  "",
980
1819
  "start options:",
981
1820
  " --port <n> port to prefer (default " + String(DEFAULT_PORT) + "; a taken port moves to the next free one)",
@@ -983,6 +1822,11 @@ const USAGE = [
983
1822
  " --no-open do not open a browser",
984
1823
  " --foreground run in this terminal instead of in the background",
985
1824
  "",
1825
+ "capabilities options:",
1826
+ " --enable <ids> enable the comma-separated capability ids",
1827
+ " --disable <ids> disable the comma-separated capability ids",
1828
+ " --json print the report as JSON",
1829
+ "",
986
1830
  "update options:",
987
1831
  " --check report the available release without installing it",
988
1832
  " --yes install without asking"
@@ -1002,6 +1846,7 @@ async function runStandaloneCli(argv) {
1002
1846
  if (command === "start") return await start(rest);
1003
1847
  if (command === "stop") return stop();
1004
1848
  if (command === "status") return status();
1849
+ if (command === "capabilities") return capabilities(rest);
1005
1850
  if (command === "update") return await update(rest);
1006
1851
  if (command === "doctor") return doctor();
1007
1852
  console.error("unknown command: " + command);