@sparkelf/dsh-plus 0.2.0-rc.9 → 0.2.1-alpha.1

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.
@@ -0,0 +1,688 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ /** Capabilities offered at install time, in interview order. */
5
+ export const CAPABILITIES = [
6
+ {
7
+ id: 'exa',
8
+ title: 'Web search (Exa)',
9
+ detail: 'Search the web through Exa instead of the built-in provider; needs an API key',
10
+ needsEndpoint: false,
11
+ },
12
+ {
13
+ id: 'mineru',
14
+ title: 'PDF parsing (MinerU)',
15
+ detail: 'Parse uploaded PDFs locally; the service is installed and started here',
16
+ needsEndpoint: true,
17
+ },
18
+ {
19
+ id: 'officecli',
20
+ title: 'Office documents (OfficeCLI)',
21
+ detail: 'Create and open DOCX, XLSX, and PPTX deliverables',
22
+ needsEndpoint: false,
23
+ },
24
+ {
25
+ id: 'computer-use',
26
+ title: 'Windows desktop control (computer-use)',
27
+ detail: 'Drive the Windows desktop from WSL through the cua driver',
28
+ needsEndpoint: false,
29
+ },
30
+ ];
31
+ /** Where this command starts MinerU when the capability is enabled. */
32
+ export const DEFAULT_MINERU_ENDPOINT = 'http://127.0.0.1:8000/file_parse';
33
+ /** File under the deployment home recording which capabilities were enabled. */
34
+ export const CAPABILITY_RECORD = 'capabilities.json';
35
+ /** Deployment env file the launcher reads as its `user-env` layer. */
36
+ export const CAPABILITY_ENV_FILE = '.env';
37
+ /** Comment that identifies a profile layer this interview wrote. */
38
+ export const CAPABILITY_MARKER = 'Written by dsh-plus start from the capability interview.';
39
+ /** One prompt on the terminal, resolving the trimmed line the user typed. */
40
+ function ask(question) {
41
+ return new Promise((resolveAnswer) => {
42
+ process.stdout.write(question);
43
+ process.stdin.once('data', (chunk) => {
44
+ resolveAnswer(String(chunk).trim());
45
+ });
46
+ });
47
+ }
48
+ /** Ask one yes/no question, defaulting to yes. */
49
+ async function confirmDefaultYes(question) {
50
+ const answer = (await ask(question + ' [Y/n] ')).toLowerCase();
51
+ return answer === '' || answer === 'y' || answer === 'yes';
52
+ }
53
+ /**
54
+ * Interview the user about the optional capabilities.
55
+ *
56
+ * Each is offered on its own line and defaults to enabled, so pressing Enter
57
+ * through the interview produces a deployment whose features work. The answers are
58
+ * returned rather than applied, so a caller can report what it will do before it
59
+ * touches the profile.
60
+ *
61
+ * @param interactive - when false, every capability is enabled without asking.
62
+ * @returns the ids to enable and the values the enabled ones need.
63
+ */
64
+ export async function interviewCapabilities(interactive) {
65
+ if (!interactive)
66
+ return { enabled: CAPABILITIES.map(capability => capability.id) };
67
+ console.log('');
68
+ console.log('Optional capabilities. Press Enter to accept the default (all enabled),');
69
+ console.log('or answer n for any you do not want.');
70
+ console.log('');
71
+ const enabled = [];
72
+ for (const capability of CAPABILITIES) {
73
+ if (await confirmDefaultYes(' Enable ' + capability.title + '? (' + capability.detail + ')')) {
74
+ enabled.push(capability.id);
75
+ }
76
+ }
77
+ let exaApiKey;
78
+ if (enabled.includes('exa')) {
79
+ console.log('');
80
+ console.log(' Exa needs an API key. Create one at https://dashboard.exa.ai/api-keys');
81
+ const typed = await ask(' Exa API key (leave empty to add it later): ');
82
+ if (typed !== '')
83
+ exaApiKey = typed;
84
+ }
85
+ let mineruEndpoint;
86
+ if (enabled.includes('mineru')) {
87
+ // The service is installed and started by this command on the local port, so the
88
+ // default is the address it will answer on; a user running MinerU elsewhere
89
+ // overrides it here rather than editing the profile afterwards.
90
+ const typed = await ask(' MinerU endpoint [' + DEFAULT_MINERU_ENDPOINT + ']: ');
91
+ mineruEndpoint = typed === '' ? DEFAULT_MINERU_ENDPOINT : typed;
92
+ }
93
+ return {
94
+ enabled,
95
+ ...exaApiKey === undefined ? {} : { exaApiKey },
96
+ ...mineruEndpoint === undefined ? {} : { mineruEndpoint },
97
+ };
98
+ }
99
+ /**
100
+ * Names this module owns in the deployment env file.
101
+ *
102
+ * Only names the loader accepts here: a `DSH_`-prefixed variable is refused in any `.env` because
103
+ * `DSH_HOME` is itself bootstrap-only and the file could otherwise relocate the home it is read
104
+ * from. MinerU's endpoint therefore travels in the profile layer, which is also where the plugin
105
+ * reads it.
106
+ */
107
+ const CAPABILITY_ENV_NAMES = ['EXA_API_KEY'];
108
+ /**
109
+ * The deployment env file, with one assignment per enabled capability that reaches
110
+ * its service through the launch environment.
111
+ *
112
+ * Exa reads its key from the launch environment, so a capability the user selected is enabled here
113
+ * as well as in the profile layer. Only the names this interview owns are rewritten: a
114
+ * deployment that keeps its own assignments in the same file keeps them, and
115
+ * deselecting a capability removes the assignment that turned it on.
116
+ *
117
+ * @param answers - the interview's answers.
118
+ * @param existing - the file's current text, when it already exists.
119
+ * @returns the environment file's text.
120
+ */
121
+ export function capabilityEnvironment(answers, existing = '') {
122
+ const enabled = new Set(answers.enabled);
123
+ const owned = new Set(CAPABILITY_ENV_NAMES);
124
+ // Values the file already carries for the names this module owns. Re-running a
125
+ // configured start must not delete a credential the deployment already holds: the
126
+ // interview returns no key when the user says they will add it later, and enabling an
127
+ // unrelated capability still rewrites this whole file. Reading the present value back
128
+ // is what keeps `start` from undoing an earlier answer.
129
+ const carried = new Map();
130
+ for (const line of existing.split('\n')) {
131
+ const parsed = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/.exec(line);
132
+ if (parsed?.[1] !== undefined && owned.has(parsed[1]))
133
+ carried.set(parsed[1], parsed[2] ?? '');
134
+ }
135
+ const kept = existing.split('\n').filter((line) => {
136
+ const name = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line)?.[1];
137
+ return line.trim() !== '' && !line.trimStart().startsWith('#') && (name === undefined || !owned.has(name));
138
+ });
139
+ const written = [
140
+ '# Written by dsh-plus start from the capability interview. Enabling or',
141
+ '# disabling a capability rewrites the lines below; other lines are kept.',
142
+ ];
143
+ if (enabled.has('exa')) {
144
+ const key = answers.exaApiKey ?? carried.get('EXA_API_KEY');
145
+ if (key !== undefined && key !== '')
146
+ written.push('EXA_API_KEY=' + key);
147
+ }
148
+ return [...written, ...kept].join('\n') + '\n';
149
+ }
150
+ /**
151
+ * The profile patch layer that turns the selected capabilities on.
152
+ *
153
+ * The file is data the profile's loader merges, so enabling a capability writes a
154
+ * row rather than editing the deployment. An empty selection still writes the file,
155
+ * because a layer that exists and mounts nothing is how a previously enabled
156
+ * capability is turned back off.
157
+ *
158
+ * The profile's own layer is a separate file with its own name: a deployment that
159
+ * configured something by hand keeps it, and the capability rows are rewritten
160
+ * whole on every start.
161
+ *
162
+ * @param answers - the interview's answers.
163
+ * @returns the patch layer's YAML text.
164
+ */
165
+ /**
166
+ * The patch rows a previous version of this file carried, minus the ones this module owns.
167
+ *
168
+ * The module writes a fixed set of rows, so everything else in the file belongs to the
169
+ * deployment. A row starts at a top-level `- ` entry and runs to the next one, which keeps
170
+ * each row's own config and comments with it.
171
+ *
172
+ * @param existing - the file's current text.
173
+ * @returns the rows to write back after this module's own.
174
+ */
175
+ function foreignPatchRows(existing) {
176
+ if (existing.trim() === '')
177
+ return [];
178
+ const lines = existing.split('\n');
179
+ const rows = [];
180
+ let current = null;
181
+ for (const line of lines) {
182
+ if (/^-\s/.test(line)) {
183
+ if (current !== null)
184
+ rows.push(current);
185
+ current = [line];
186
+ continue;
187
+ }
188
+ if (current !== null)
189
+ current.push(line);
190
+ }
191
+ if (current !== null)
192
+ rows.push(current);
193
+ // The capability rows this module writes are the `insert` block's children plus the
194
+ // `web` row that routes search. A block whose head is not one of them belongs to the
195
+ // deployment; a block that is one of them is replaced by the current selection whole.
196
+ const ownedHeads = new Set(['insert', 'web']);
197
+ return rows
198
+ .filter((row) => {
199
+ const head = row[0] ?? '';
200
+ const id = /^-\s+id:\s*(\S+)/.exec(head)?.[1];
201
+ if (id !== undefined)
202
+ return !ownedHeads.has(id);
203
+ // A block keyed by something else, such as `- insert:`, is this module's when the
204
+ // module is what writes it. Only its own capability rows are recognised, so a
205
+ // deployment block that happens to use `insert` is the one ambiguous case; the
206
+ // module resolves it by removing only the rows it names.
207
+ if (/^-\s+insert:\s*$/.test(head)) {
208
+ const ownedChildren = new Set(['web-search-exa', 'computer-use', 'computer-use-cua-driver-mcp', 'mineru']);
209
+ const kept = row.filter((line, index) => {
210
+ if (index === 0)
211
+ return false;
212
+ const childId = /^\s+-\s+id:\s*(\S+)/.exec(line)?.[1];
213
+ return childId === undefined || !ownedChildren.has(childId);
214
+ });
215
+ // Nothing but the block head is left: the module wrote it and now mounts nothing.
216
+ return kept.some(line => /^\s+-\s+/.test(line));
217
+ }
218
+ return true;
219
+ })
220
+ .map(row => row.join('\n').trimEnd());
221
+ }
222
+ export function capabilityPatchLayer(answers, existing = '') {
223
+ const enabled = new Set(answers.enabled);
224
+ const rows = [];
225
+ if (enabled.has('exa')) {
226
+ rows.push(' - id: web-search-exa', " name: '@deepseek-ai/dsh-web-search-exa'", ' config:', ' searchType: auto', ' numResults: 8');
227
+ }
228
+ if (enabled.has('computer-use')) {
229
+ 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'");
230
+ }
231
+ if (enabled.has('officecli')) {
232
+ // The plugin ships inside the mounted bundle and carries its own binary, so the
233
+ // capability needs no host step — but a selection that writes no row mounts nothing,
234
+ // and the capability then reports as ready while the tools stay absent. The row id
235
+ // and package match the plugin's own bundle layer.
236
+ rows.push(' - id: officecli', " name: '@sparkelf/dsh-officecli'");
237
+ }
238
+ // MinerU reads its endpoint from plugin config, not from the environment, and a name prefixed
239
+ // `DSH_` is refused in any .env file because `DSH_HOME` is itself bootstrap-only — the file could
240
+ // otherwise relocate the home it is read from. Writing the endpoint as a profile row is both the
241
+ // plugin's own contract and the only place the loader accepts it.
242
+ if (enabled.has('mineru')) {
243
+ rows.push(' - id: mineru', " name: '@sparkelf/dsh-mineru'", ' config:', ' endpoint: ' + (answers.mineruEndpoint ?? DEFAULT_MINERU_ENDPOINT));
244
+ }
245
+ const lines = [
246
+ '# ' + CAPABILITY_MARKER + ' Enabling or disabling a capability',
247
+ '# rewrites this file; edits here are replaced.',
248
+ ];
249
+ // The loader requires a top-level YAML array, so a selection that mounts nothing
250
+ // still has to produce one. Comments alone parse as `null`, and the profile then
251
+ // refuses to boot with "must be a top-level YAML array of loader patch entries" —
252
+ // which is the whole deployment, not just the missing capability.
253
+ // A deployment that configured something by hand keeps it: this file is the profile's
254
+ // only user layer, so a capability change must not drop rows it does not own. Only the
255
+ // rows this module writes are rewritten, which is what the file's own header promises.
256
+ // Parsing is line-based rather than a YAML round trip because the entries carry comments
257
+ // that a parse-and-serialize cycle would discard.
258
+ const foreign = foreignPatchRows(existing);
259
+ if (rows.length > 0) {
260
+ lines.push('- insert:', ...rows);
261
+ }
262
+ else {
263
+ lines.push('[]');
264
+ }
265
+ if (enabled.has('exa')) {
266
+ lines.push('', '# Route web_search through Exa rather than the built-in provider.', '- id: web', ' config:', ' searchProvider: exa');
267
+ }
268
+ if (foreign.length > 0) {
269
+ lines.push('', '# Rows this file carried before the capability interview; kept verbatim.', ...foreign);
270
+ }
271
+ return lines.join('\n') + '\n';
272
+ }
273
+ /** Run one command, reporting a failure rather than throwing. */
274
+ function runStep(command, args) {
275
+ const result = spawnSync(command, [...args], { stdio: 'inherit' });
276
+ return result.status === 0;
277
+ }
278
+ /**
279
+ * The capability record a previous configured run wrote, or `undefined` when none exists.
280
+ *
281
+ * The record is what lets a later run report the deployment's answers instead of asking
282
+ * again. A malformed or unreadable file is treated as absent rather than fatal: it is a
283
+ * convenience cache, and refusing to boot over it would strand a deployment whose record
284
+ * was corrupted by an interrupted write.
285
+ *
286
+ * @param home - the deployment home holding the record.
287
+ * @returns the enabled ids, and the MinerU endpoint when the record carries one.
288
+ */
289
+ export function readCapabilityRecord(home) {
290
+ const path = join(home, CAPABILITY_RECORD);
291
+ let parsed;
292
+ try {
293
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
294
+ }
295
+ catch {
296
+ // Absent and unreadable are the same answer here: the caller falls back to the
297
+ // profile and env files, which are the authoritative state a record only mirrors.
298
+ return undefined;
299
+ }
300
+ if (typeof parsed !== 'object' || parsed === null)
301
+ return undefined;
302
+ const record = parsed;
303
+ if (!Array.isArray(record.enabled))
304
+ return undefined;
305
+ const enabled = record.enabled.filter((id) => typeof id === 'string');
306
+ return {
307
+ enabled,
308
+ ...typeof record.mineruEndpoint === 'string' ? { mineruEndpoint: record.mineruEndpoint } : {},
309
+ };
310
+ }
311
+ /**
312
+ * The capabilities a deployment actually has mounted, read from the files the loader and
313
+ * the launch environment consume.
314
+ *
315
+ * A record is a cache of an earlier answer and can drift from the deployment — a profile
316
+ * layer edited by hand, a record deleted, a plugin removed from the distribution. What a
317
+ * capability is *doing* is decided by the profile patch rows and the env file, so that is
318
+ * what a report reads.
319
+ *
320
+ * @param profilePatchText - the profile layer's YAML text.
321
+ * @param envText - the deployment env file's text.
322
+ * @returns the ids found in the profile layer and the env names present.
323
+ */
324
+ export function detectEnabledCapabilities(profilePatchText, envText) {
325
+ const mounted = [];
326
+ // Matching the row id is what the loader does; matching a bare package name would also
327
+ // hit a comment or a name mentioned in an unrelated row's config.
328
+ if (/^\s*- id: web-search-exa\s*$/mu.test(profilePatchText))
329
+ mounted.push('exa');
330
+ if (/^\s*- id: mineru\s*$/mu.test(profilePatchText))
331
+ mounted.push('mineru');
332
+ if (/^\s*- id: officecli\s*$/mu.test(profilePatchText))
333
+ mounted.push('officecli');
334
+ if (/^\s*- id: computer-use\s*$/mu.test(profilePatchText))
335
+ mounted.push('computer-use');
336
+ const env = [];
337
+ for (const line of envText.split('\n')) {
338
+ const name = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line)?.[1];
339
+ if (name !== undefined && !env.includes(name))
340
+ env.push(name);
341
+ }
342
+ return { mounted, env };
343
+ }
344
+ /**
345
+ * The patch row one capability mounts, as a list of YAML lines, or `undefined` when the
346
+ * capability is turned on by something other than a row this module owns.
347
+ *
348
+ * MinerU is enabled by a row too, but its endpoint is per-deployment, so the caller passes
349
+ * the answers rather than a fixed row.
350
+ *
351
+ * @param id - the capability id.
352
+ * @returns the insert payload lines, without the surrounding `- insert:` entry.
353
+ */
354
+ function capabilityRows(id) {
355
+ if (id === 'exa') {
356
+ return [
357
+ ' - id: web-search-exa',
358
+ " name: '@deepseek-ai/dsh-web-search-exa'",
359
+ ' config:',
360
+ ' searchType: auto',
361
+ ' numResults: 8',
362
+ ];
363
+ }
364
+ if (id === 'officecli') {
365
+ return [
366
+ ' - id: officecli',
367
+ " name: '@sparkelf/dsh-officecli'",
368
+ ];
369
+ }
370
+ if (id === 'computer-use') {
371
+ return [
372
+ ' - id: computer-use',
373
+ " name: '@deepseek-ai/dsh-computer-use'",
374
+ ' - id: computer-use-cua-driver-mcp',
375
+ " name: '@deepseek-ai/dsh-experimental-computer-use-cua-driver-mcp'",
376
+ ];
377
+ }
378
+ return undefined;
379
+ }
380
+ /** The row ids a capability owns in the profile layer. */
381
+ function capabilityRowIds(id) {
382
+ if (id === 'exa')
383
+ return ['web-search-exa'];
384
+ if (id === 'officecli')
385
+ return ['officecli'];
386
+ if (id === 'mineru')
387
+ return ['mineru'];
388
+ if (id === 'computer-use')
389
+ return ['computer-use', 'computer-use-cua-driver-mcp'];
390
+ return [];
391
+ }
392
+ /**
393
+ * One capability's effect, added to or removed from an existing profile layer without
394
+ * disturbing the rest of the file.
395
+ *
396
+ * `dsh-plus start` owns the whole layer and rewrites it from the interview's answers, which
397
+ * is correct for an install that answered once and never edits afterwards. A deployment
398
+ * that maintains its own rows in that same layer cannot be served that way: replacing the
399
+ * file drops every row the operator wrote. This edits the rows this module owns and leaves
400
+ * every other entry, comment, and blank line exactly where it was.
401
+ *
402
+ * @param existing - the layer's current YAML text.
403
+ * @param id - the capability to add or remove.
404
+ * @param enable - whether to add the capability.
405
+ * @param mineruEndpoint - the endpoint to write when enabling MinerU.
406
+ * @returns the layer's new text.
407
+ */
408
+ export function editCapabilityPatchLayer(existing, id, enable, mineruEndpoint) {
409
+ const owned = capabilityRowIds(id);
410
+ const withoutRows = dropOwnedRows(existing, owned, id === 'exa');
411
+ const mineruRows = [
412
+ ' - id: mineru',
413
+ " name: '@sparkelf/dsh-mineru'",
414
+ ' config:',
415
+ ' endpoint: ' + (mineruEndpoint ?? DEFAULT_MINERU_ENDPOINT),
416
+ ];
417
+ const rows = id === 'mineru' && enable ? mineruRows : capabilityRows(id);
418
+ const text = enable && rows !== undefined
419
+ ? insertCapabilityRows(withoutRows, rows, id === 'exa')
420
+ : withoutRows.text;
421
+ // Every file this repository writes ends with exactly one newline; an edited layer must
422
+ // not lose that, and the loader's YAML parse is indifferent to it either way.
423
+ const trimmed = text.replace(/\n+$/u, '');
424
+ return trimmed === '' ? '\n' : trimmed + '\n';
425
+ }
426
+ /**
427
+ * Remove the rows one capability owns, and for Exa the `web` selector row that routes
428
+ * search to it.
429
+ *
430
+ * A row is dropped with the block that belongs to it: the `- id:` line plus its indented
431
+ * continuation lines, and any comment line directly above that documented it. Comment and
432
+ * blank lines separating it from the next entry stay, so the surrounding file keeps its
433
+ * shape.
434
+ *
435
+ * @param existing - the layer's current YAML text.
436
+ * @param ids - the row ids this capability owns.
437
+ * @param withSelector - whether the capability also owns the shared `web` selector row.
438
+ * @returns the remaining text and whether anything was actually removed.
439
+ */
440
+ function dropOwnedRows(existing, ids, withSelector) {
441
+ const lines = existing.split('\n');
442
+ const out = [];
443
+ let removed = false;
444
+ for (let index = 0; index < lines.length; index += 1) {
445
+ const line = lines[index] ?? '';
446
+ const match = /^(\s*)- id:\s*(\S+)\s*$/.exec(line);
447
+ // The `web` selector is shared with the built-in provider: dropping it turns Exa off
448
+ // without touching the row's other keys, because the bundle layer's row reapplies.
449
+ const isSelector = withSelector && match?.[2] === 'web' && match[1] === '';
450
+ if (match === null || (!ids.includes(match[2] ?? '') && !isSelector)) {
451
+ out.push(line);
452
+ continue;
453
+ }
454
+ removed = true;
455
+ const indent = match[1] ?? '';
456
+ // Drop the comments that documented this row, stopping at a blank line so an unrelated
457
+ // comment above a blank line is not consumed.
458
+ while (out.length > 0) {
459
+ const previous = out[out.length - 1] ?? '';
460
+ if (previous.trimStart().startsWith('#') && previous.trim() !== '')
461
+ out.pop();
462
+ else
463
+ break;
464
+ }
465
+ // Then the row's own continuation lines: deeper-indented or blank lines until the next
466
+ // entry at the same or a shallower level.
467
+ for (index += 1; index < lines.length; index += 1) {
468
+ const next = lines[index] ?? '';
469
+ if (next.trim() === '') {
470
+ // A blank line inside a row's block keeps it; a blank line starting a new block
471
+ // belongs to the file's spacing and goes back.
472
+ const after = lines[index + 1] ?? '';
473
+ if (after !== '' && (after.startsWith(indent + ' ') || after.startsWith(indent + '\t') || after.trimStart().startsWith('#'))) {
474
+ continue;
475
+ }
476
+ index -= 1;
477
+ break;
478
+ }
479
+ const nextIndent = /^\s*/.exec(next)?.[0] ?? '';
480
+ if (nextIndent.length > indent.length)
481
+ continue;
482
+ index -= 1;
483
+ break;
484
+ }
485
+ }
486
+ return { text: out.join('\n'), removed };
487
+ }
488
+ /**
489
+ * Add a capability's rows to the layer, creating the `- insert:` entry when the file has
490
+ * none and appending to the existing one otherwise.
491
+ *
492
+ * The loader indexes inserted rows under the layer that inserted them, so appending to an
493
+ * existing `- insert:` list and adding a second one both work; appending keeps the file
494
+ * readable instead of growing one `- insert:` per capability.
495
+ *
496
+ * @param state - the layer with this capability's own rows already removed.
497
+ * @param rows - the insert payload lines to add.
498
+ * @param withSelector - whether Exa's `web` selector row must also be present.
499
+ * @returns the layer's new text.
500
+ */
501
+ function insertCapabilityRows(state, rows, withSelector) {
502
+ const lines = state.text.split('\n');
503
+ const insertIndex = lines.findIndex(line => /^- insert:\s*$/.test(line));
504
+ if (insertIndex >= 0) {
505
+ // Append at the end of the existing insert block: walk past its payload lines.
506
+ let end = insertIndex + 1;
507
+ while (end < lines.length) {
508
+ const line = lines[end] ?? '';
509
+ if (line.trim() === '' || /^\s/.test(line))
510
+ end += 1;
511
+ else
512
+ break;
513
+ }
514
+ while (end > insertIndex + 1 && (lines[end - 1] ?? '').trim() === '')
515
+ end -= 1;
516
+ lines.splice(end, 0, ...rows);
517
+ }
518
+ else {
519
+ // A layer with no entries is the literal `[]`; replacing it keeps one valid array.
520
+ const emptyIndex = lines.findIndex(line => line.trim() === '[]');
521
+ if (emptyIndex >= 0)
522
+ lines.splice(emptyIndex, 1, '- insert:', ...rows);
523
+ else {
524
+ const trailing = lines.length > 0 && (lines[lines.length - 1] ?? '') === '' ? lines.pop() ?? '' : undefined;
525
+ lines.push('- insert:', ...rows);
526
+ if (trailing !== undefined)
527
+ lines.push(trailing);
528
+ }
529
+ }
530
+ if (withSelector && !/^\s*- id: web\s*$/mu.test(lines.join('\n'))) {
531
+ // The selector is a top-level row: it patches the bundle layer's own `web` entry, and
532
+ // it must restate `fetchProvider` because a patch replaces the whole `config` object
533
+ // rather than merging into it.
534
+ lines.push('', '# Route web_search through Exa rather than the built-in provider.', '- id: web', ' config:', ' searchProvider: exa', ' fetchProvider: http');
535
+ }
536
+ return lines.join('\n');
537
+ }
538
+ /**
539
+ * Install and start the backing services the selected capabilities need.
540
+ *
541
+ * Enabling a capability means the deployment expects its service to answer, so the
542
+ * command that offers the choice is also the command that provides it. Each step
543
+ * reports what it is doing and, on failure, the command a user can run by hand —
544
+ * a missing runtime is a fact about the host, not a reason to abandon the install.
545
+ *
546
+ * @param answers - the interview's answers.
547
+ * @param home - the deployment home, where generated service units are recorded.
548
+ * @returns the ids whose service is ready.
549
+ */
550
+ export async function installCapabilityServices(answers, home) {
551
+ const enabled = new Set(answers.enabled);
552
+ const ready = [];
553
+ // The record lets a later run name the capabilities this deployment enabled
554
+ // instead of interviewing again, and gives doctor something to check against.
555
+ mkdirSync(home, { recursive: true });
556
+ writeFileSync(join(home, CAPABILITY_RECORD), JSON.stringify({
557
+ enabled: [...answers.enabled],
558
+ ...answers.mineruEndpoint === undefined ? {} : { mineruEndpoint: answers.mineruEndpoint },
559
+ }, null, 2) + '\n');
560
+ if (enabled.has('mineru')) {
561
+ console.log('');
562
+ console.log(' MinerU: installing the parser and starting its service...');
563
+ const installed = installMineru(home);
564
+ if (installed && startMineru(home)) {
565
+ ready.push('mineru');
566
+ console.log(' MinerU: ready at ' + (answers.mineruEndpoint ?? DEFAULT_MINERU_ENDPOINT));
567
+ }
568
+ else {
569
+ console.log(' MinerU: not installed. Install it later with:');
570
+ console.log(' pip install -U "mineru[core]"');
571
+ console.log(' mineru-api --host 127.0.0.1 --port 8000');
572
+ }
573
+ }
574
+ if (enabled.has('officecli')) {
575
+ // OfficeCLI ships inside the mounted bundle and carries its own binary, so the
576
+ // capability needs no host step; the profile row is what turns it on.
577
+ ready.push('officecli');
578
+ }
579
+ if (enabled.has('computer-use')) {
580
+ if (desktopDriverAvailable()) {
581
+ ready.push('computer-use');
582
+ }
583
+ else {
584
+ console.log('');
585
+ console.log(' computer-use: the cua driver was not found on PATH. The plugin is');
586
+ console.log(' still mounted, so it starts once the driver is installed:');
587
+ console.log(' install the cua-driver release for this architecture, then restart');
588
+ }
589
+ }
590
+ return ready;
591
+ }
592
+ /**
593
+ * Install MinerU when it is absent and start its API service.
594
+ *
595
+ * MinerU is a Python package whose API server answers on a local port; the profile
596
+ * points at that port. The parser is installed into a virtual environment under the
597
+ * deployment home and an existing one is upgraded rather than skipped, so enabling
598
+ * the capability keeps the parser current without touching the system interpreter.
599
+ *
600
+ * @param home - the deployment home that owns the venv.
601
+ * @returns whether the package is installed; not whether it answered.
602
+ */
603
+ function installMineru(home) {
604
+ const python = pythonInterpreter();
605
+ if (python === undefined) {
606
+ console.log(' MinerU: no Python interpreter found on PATH.');
607
+ return false;
608
+ }
609
+ // The venv keeps MinerU's torch and model dependencies away from the system
610
+ // interpreter, which is also what PEP 668 requires: a system pip refuses the install
611
+ // outright, and `--break-system-packages` would put a multi-gigabyte torch tree into
612
+ // the OS packages. `all` is the extra both the 3.x and 4.x lines publish; `core`
613
+ // existed only through 3.x and installing it on 4.x silently drops the extras.
614
+ const venv = join(home, '.mineru-venv');
615
+ if (!runStep(python, ['-m', 'venv', venv]))
616
+ return false;
617
+ const venvPython = join(venv, 'bin', 'python');
618
+ return runStep(venvPython, ['-m', 'pip', 'install', '-U', 'mineru[all]']);
619
+ }
620
+ /**
621
+ * Start the MinerU API server as a background service when none answers yet.
622
+ *
623
+ * The service has to outlive the install command, so it is registered with the
624
+ * host's service manager when one is available and otherwise started detached.
625
+ *
626
+ * @returns whether the server is running after this call.
627
+ */
628
+ function startMineru(home) {
629
+ const api = mineruApiBinary(home);
630
+ if (api === undefined)
631
+ return false;
632
+ if (mineruAnswers())
633
+ return true;
634
+ if (spawnSync('systemctl', ['--version'], { stdio: 'ignore' }).status === 0) {
635
+ writeFileSync('/etc/systemd/system/mineru-api.service', [
636
+ '[Unit]',
637
+ 'Description=MinerU document parsing API for DeepSeek Harness Plus',
638
+ 'After=network-online.target',
639
+ 'Wants=network-online.target',
640
+ '',
641
+ '[Service]',
642
+ 'Type=simple',
643
+ 'User=root',
644
+ 'ExecStart=' + api + ' --host 127.0.0.1 --port 8000',
645
+ 'Restart=on-failure',
646
+ 'RestartSec=2',
647
+ 'TimeoutStopSec=15',
648
+ '',
649
+ '[Install]',
650
+ 'WantedBy=multi-user.target',
651
+ '',
652
+ ].join('\n'));
653
+ spawnSync('systemctl', ['daemon-reload'], { stdio: 'ignore' });
654
+ return spawnSync('systemctl', ['enable', '--now', 'mineru-api.service'], { stdio: 'ignore' }).status === 0;
655
+ }
656
+ console.log(' MinerU: start the API server with:');
657
+ console.log(' ' + api + ' --host 127.0.0.1 --port 8000');
658
+ return false;
659
+ }
660
+ /** The Python interpreter to install MinerU with, preferring python3. */
661
+ function pythonInterpreter() {
662
+ return ['python3', 'python'].find(candidate => spawnSync(candidate, ['--version'], { stdio: 'ignore' }).status === 0);
663
+ }
664
+ /**
665
+ * The MinerU API entry point, wherever the install placed it.
666
+ *
667
+ * The venv this command creates comes first: it is the interpreter the parser was
668
+ * installed into, so its `mineru-api` is the one that can import MinerU. A binary on
669
+ * PATH is a fallback for a deployment that installed MinerU some other way.
670
+ *
671
+ * @param home - the deployment home that owns the venv.
672
+ * @returns the command to run, or `undefined` when none answers.
673
+ */
674
+ function mineruApiBinary(home) {
675
+ const candidates = [join(home, '.mineru-venv', 'bin', 'mineru-api'), 'mineru-api'];
676
+ return candidates.find(candidate => spawnSync(candidate, ['--help'], { stdio: 'ignore' }).status === 0);
677
+ }
678
+ /** Whether a MinerU API server already answers on the default port. */
679
+ function mineruAnswers() {
680
+ const probe = spawnSync('curl', ['-sf', '-o', '/dev/null', '--max-time', '3', 'http://127.0.0.1:8000/docs'], { stdio: 'ignore' });
681
+ return probe.status === 0;
682
+ }
683
+ /** Whether the Windows-side desktop driver answers from this shell. */
684
+ function desktopDriverAvailable() {
685
+ const probe = spawnSync('cua-driver', ['--version'], { stdio: 'ignore' });
686
+ return probe.status === 0;
687
+ }
688
+ //# sourceMappingURL=standalone-capabilities.js.map