@ran-sh/dsh-crew 1.5.1 → 1.6.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.
@@ -1,739 +1,728 @@
1
- // One-click installers: register the Claude Code plugin persistently and
2
- // render Codex agent roles with real paths. Called from the CLI entry or the
3
- // DSH settings page. All edits are backed up and idempotent.
4
-
5
- import { readFileSync, writeFileSync, mkdirSync, copyFileSync, existsSync, readdirSync, rmSync, statSync, lstatSync } from 'node:fs';
6
- import { createHash } from 'node:crypto';
7
- import { dirname, join, resolve, relative } from 'node:path';
8
- import { fileURLToPath } from 'node:url';
9
- import { homedir } from 'node:os';
10
- import { normalizeModelPriority } from '../model-routing.mjs';
11
- import { zcodeStatus } from './zcode.mjs';
12
-
13
- const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
14
- const MARKETPLACE_NAME = 'dsh-crew';
15
- const PLUGIN_KEY = `dsh-crew@${MARKETPLACE_NAME}`;
16
- const POLICY_START = '<!-- DSH CREW MANAGED POLICY:START -->';
17
- const POLICY_END = '<!-- DSH CREW MANAGED POLICY:END -->';
18
-
19
- /**
20
- * The directory Codex actually reads.
21
- *
22
- * Codex honours `CODEX_HOME` and falls back to `~/.codex`; writing only to the
23
- * latter silently does nothing for anyone who set it — the registration stays
24
- * frozen on whatever release was current when they set it, while every install
25
- * reports success because the file it wrote really did change. Resolve the same
26
- * way Codex does, and treat an empty or whitespace value as unset.
27
- */
28
- export function codexHomeDir(home = homedir(), env = process.env) {
29
- const override = typeof env?.CODEX_HOME === 'string' ? env.CODEX_HOME.trim() : '';
30
- return override ? resolve(override) : join(home, '.codex');
31
- }
32
- export const CODEX_LEGACY_POLICY_HASHES = Object.freeze([
33
- '2d6f3839bb3df4bda90f481726281292b1a4b4585298b1cf9ec56215295b5c78',
34
- ]);
35
- // dsh_worker_config is included so the session commands (/dsh-crew:config,
36
- // /dsh-config) and any orchestrator policy lookup run without an extra
37
- // authorization prompt.
38
- export const MCP_TOOLS = ['dsh_run_worker', 'dsh_spawn_worker', 'dsh_worker_status', 'dsh_worker_result', 'dsh_worker_cancel', 'dsh_worker_config'];
39
-
40
- function backup(file) {
41
- if (existsSync(file)) {
42
- const bak = `${file}.dsh-crew-backup-${Date.now()}`;
43
- copyFileSync(file, bak);
44
- return bak;
45
- }
46
- return null;
47
- }
48
-
49
- function readJson(file, fallback) {
50
- try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return fallback; }
51
- }
52
-
53
- function readText(file) {
54
- try { return readFileSync(file, 'utf8'); } catch { return null; }
55
- }
56
-
57
- function normalizedPath(value) {
58
- if (typeof value !== 'string' || !value.trim()) return null;
59
- try {
60
- const path = resolve(value);
61
- return process.platform === 'win32' ? path.toLowerCase() : path;
62
- } catch { return null; }
63
- }
64
-
65
- function renderedCodexRole(root, file) {
66
- const source = readText(join(root, 'codex', 'agents', file));
67
- if (source === null) return null;
68
- const renderedPath = join(root, 'src', 'server.mjs').replace(/\\/g, '/');
69
- return source.replace(/args = \[.*server\.mjs"\]/, `args = ["${renderedPath}"]`);
70
- }
71
-
72
- function managedPolicyBlock(root) {
73
- const policy = readText(join(root, 'codex', 'AGENTS.md'))?.trim();
74
- if (!policy) return null;
75
- return `${POLICY_START}\n${policy}\n${POLICY_END}`;
76
- }
77
-
78
- export function codexLegacyPolicyDigest(text) {
79
- const canonical = typeof text === 'string' ? text.replace(/\r\n/g, '\n').trim() : '';
80
- return canonical ? createHash('sha256').update(canonical, 'utf8').digest('hex') : null;
81
- }
82
-
83
- export function stripKnownLegacyCodexPolicy(text, { knownHashes = CODEX_LEGACY_POLICY_HASHES } = {}) {
84
- if (typeof text !== 'string') return text;
85
- const start = POLICY_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
86
- const end = POLICY_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
87
- const match = new RegExp(`${start}[\\s\\S]*?${end}`, 'm').exec(text);
88
- if (!match) return text;
89
- const allowed = new Set(Array.isArray(knownHashes) ? knownHashes : []);
90
- const prefix = text.slice(0, match.index);
91
- const suffix = text.slice(match.index + match[0].length);
92
- const keptPrefix = allowed.has(codexLegacyPolicyDigest(prefix)) ? '' : prefix;
93
- const keptSuffix = allowed.has(codexLegacyPolicyDigest(suffix)) ? '' : suffix;
94
- return `${keptPrefix}${match[0]}${keptSuffix}`;
95
- }
96
-
97
- function installGlobalCodexPolicy({ home, root, env = process.env }) {
98
- const file = join(codexHomeDir(home, env), 'AGENTS.md');
99
- const block = managedPolicyBlock(root);
100
- if (!block) return { ok: false, action: 'global policy template missing' };
101
- mkdirSync(dirname(file), { recursive: true });
102
- const current = readText(file) ?? '';
103
- const managed = new RegExp(`${POLICY_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${POLICY_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'm');
104
- const template = readText(join(root, 'codex', 'AGENTS.md'))?.trim() ?? '';
105
- let next;
106
- if (managed.test(current)) next = current.replace(managed, block);
107
- else if (current.trim() === template) next = `${block}\n`;
108
- else next = `${current.trimEnd()}${current.trim() ? '\n\n' : ''}${block}\n`;
109
- next = stripKnownLegacyCodexPolicy(next);
110
- if (next !== current) {
111
- backup(file);
112
- writeFileSync(file, next);
113
- }
114
- return { ok: true, action: `global policy: ${file}` };
115
- }
116
-
117
- function globalCodexPolicyReady({ home, root = ROOT, env = process.env }) {
118
- const block = managedPolicyBlock(root);
119
- const text = readText(join(codexHomeDir(home, env), 'AGENTS.md'));
120
- return !!block && typeof text === 'string' && text.includes(block);
121
- }
122
-
123
- function uninstallGlobalCodexPolicy({ home, env = process.env }) {
124
- const file = join(codexHomeDir(home, env), 'AGENTS.md');
125
- const current = readText(file);
126
- if (typeof current !== 'string') return null;
127
- const managed = new RegExp(`(?:\\r?\\n){0,2}${POLICY_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${POLICY_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\r?\\n)?`, 'm');
128
- if (!managed.test(current)) return null;
129
- const next = current.replace(managed, '').trimEnd();
130
- backup(file);
131
- if (next.trim()) writeFileSync(file, `${next}\n`);
132
- else rmSync(file);
133
- return `codex global policy: removed managed block`;
134
- }
135
-
136
- function tomlSection(text, name) {
137
- if (typeof text !== 'string') return null;
138
- const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
139
- const header = new RegExp(`^\\s*\\[${escaped}\\]\\s*(?:#.*)?$`, 'm').exec(text);
140
- if (!header) return null;
141
- const rest = text.slice(header.index + header[0].length);
142
- const nextHeader = rest.search(/^\s*\[[^\]\r\n]+\]\s*(?:#.*)?$/m);
143
- return nextHeader === -1 ? rest : rest.slice(0, nextHeader);
144
- }
145
-
146
- function existingServerTarget(block) {
147
- if (typeof block !== 'string' || !/^\s*command\s*=\s*"node"\s*$/m.test(block)) return null;
148
- const match = /^\s*args\s*=\s*\[\s*"([^"\r\n]*server\.mjs)"\s*\]\s*$/m.exec(block);
149
- if (!match) return null;
150
- try {
151
- if (!statSync(match[1]).isFile()) return null;
152
- const target = resolve(match[1]);
153
- return process.platform === 'win32' ? target.toLowerCase() : target;
154
- } catch { return null; }
155
- }
156
-
157
- function codexRoleTarget(file, expectedName) {
158
- const text = readText(file);
159
- if (typeof text !== 'string'
160
- || !new RegExp(`^\\s*name\\s*=\\s*"${expectedName}"\\s*$`, 'm').test(text)) return null;
161
- return existingServerTarget(tomlSection(text, 'mcp_servers.dsh-crew'));
162
- }
163
-
164
- function codexMcpTarget(configText) {
165
- const section = tomlSection(configText, 'mcp_servers');
166
- if (!section) return null;
167
- const entry = /^\s*dsh-crew\s*=\s*\{\s*command\s*=\s*"node"\s*,\s*args\s*=\s*\[\s*"([^"\r\n]*server\.mjs)"\s*\]\s*\}\s*(?:#.*)?$/m.exec(section);
168
- if (!entry) return null;
169
- try {
170
- if (!statSync(entry[1]).isFile()) return null;
171
- const target = resolve(entry[1]);
172
- return process.platform === 'win32' ? target.toLowerCase() : target;
173
- } catch { return null; }
174
- }
175
-
176
- function isFile(file) {
177
- try { return statSync(file).isFile(); } catch { return false; }
178
- }
179
-
180
- function claudePluginRootReady(root) {
181
- if (typeof root !== 'string' || !root.trim() || !isFile(join(root, 'src', 'server.mjs'))) return false;
182
- const plugin = readJson(join(root, '.claude-plugin', 'plugin.json'), null);
183
- const mcp = plugin?.mcpServers?.['dsh-crew'];
184
- return !!plugin
185
- && typeof plugin.version === 'string'
186
- && mcp?.command === 'node'
187
- && Array.isArray(mcp.args)
188
- && mcp.args.length === 1
189
- && mcp.args[0] === '${CLAUDE_PLUGIN_ROOT}/src/server.mjs';
190
- }
191
-
192
- function managedClaudeFileManifest(root, { maxFiles = 512, maxBytes = 8 * 1024 * 1024 } = {}) {
193
- const files = [];
194
- let bytes = 0;
195
- const add = (file, relativePath) => {
196
- const info = lstatSync(file);
197
- if (info.isSymbolicLink() || !info.isFile()) throw new Error('unsupported snapshot entry');
198
- const content = readFileSync(file);
199
- bytes += content.length;
200
- if (bytes > maxBytes || files.length >= maxFiles) throw new Error('snapshot manifest bound exceeded');
201
- files.push([relativePath.replace(/\\/g, '/'), createHash('sha256').update(content).digest('hex')]);
202
- };
203
- const walk = (directory, relativeDirectory) => {
204
- if (!existsSync(directory)) return;
205
- const info = lstatSync(directory);
206
- if (info.isSymbolicLink() || !info.isDirectory()) throw new Error('unsupported snapshot directory');
207
- for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
208
- const file = join(directory, entry.name);
209
- const relativePath = join(relativeDirectory, entry.name);
210
- if (entry.isSymbolicLink()) throw new Error('snapshot symlink not allowed');
211
- if (entry.isDirectory()) walk(file, relativePath);
212
- else if (entry.isFile()) add(file, relativePath);
213
- else throw new Error('unsupported snapshot entry');
214
- }
215
- };
216
- try {
217
- add(join(root, '.claude-plugin', 'plugin.json'), join('.claude-plugin', 'plugin.json'));
218
- if (isFile(join(root, 'package.json'))) add(join(root, 'package.json'), 'package.json');
219
- for (const directory of ['agents', 'commands', 'src']) walk(join(root, directory), directory);
220
- return files;
221
- } catch { return null; }
222
- }
223
-
224
- function sameManagedClaudeFiles(expectedRoot, snapshotRoot) {
225
- if (!claudePluginRootReady(expectedRoot) || !claudePluginRootReady(snapshotRoot)) return false;
226
- const expected = managedClaudeFileManifest(expectedRoot);
227
- const snapshot = managedClaudeFileManifest(snapshotRoot);
228
- return expected !== null && snapshot !== null && JSON.stringify(snapshot) === JSON.stringify(expected);
229
- }
230
-
231
- function claudeSnapshotReady(home, root) {
232
- const installed = readJson(join(home, '.claude', 'plugins', 'installed_plugins.json'), {});
233
- const record = installed?.plugins?.[PLUGIN_KEY];
234
- const entries = Array.isArray(record) ? record : [record];
235
- return entries.some((entry) => sameManagedClaudeFiles(root, entry?.installPath));
236
- }
237
-
238
- function claudePermissionsReady(settings) {
239
- const allowed = Array.isArray(settings?.permissions?.allow) ? settings.permissions.allow : [];
240
- return MCP_TOOLS.every((tool) => allowed.includes(`mcp__plugin_dsh-crew_dsh-crew__${tool}`));
241
- }
242
-
243
- // One-time migration from the pre-rename config dir (dsh-workers → dsh-crew).
244
- try {
245
- const oldDir = join(homedir(), '.config', 'dsh-workers');
246
- const newDir = join(homedir(), '.config', 'dsh-crew');
247
- if (existsSync(oldDir) && !existsSync(newDir)) {
248
- const { renameSync } = await import('node:fs');
249
- renameSync(oldDir, newDir);
250
- }
251
- } catch {}
252
-
253
- const GLOBAL_CONFIG_FILE = join(CONFIG_DIR_HOME(), 'config.json');
254
- function CONFIG_DIR_HOME() { return join(homedir(), '.config', 'dsh-crew'); }
255
-
256
- export const GLOBAL_CONFIG_DEFAULTS = {
257
- default_tier: 'flash',
258
- default_effort: 'max',
259
- mode: 'auto',
260
- default_timeout_seconds: 1800,
261
- hub_url: 'http://127.0.0.1:3080',
262
- // auto = orchestrator picks the tier; flash-only / pro-only clamp every
263
- // dispatch to one tier at the tool layer regardless of what was requested.
264
- tier_policy: 'flash-only',
265
- // When a blocking flash run fails, retry the same task once on pro.
266
- escalate_on_failure: false,
267
- // ---- configurable-crew orchestration (see src/policy.mjs) ----
268
- // Master switch for the whole worker dispatch path (hard, backend-enforced).
269
- subagents_enabled: true,
270
- // flash-only | pro-only | balanced | review-pipeline | custom
271
- collaboration_mode: 'flash-only',
272
- // direct-allowed | coordinator-first | dispatcher-only (host routing
273
- // guidance — the Crew MCP backend cannot restrict the host's own tools).
274
- main_agent_mode: 'direct-allowed',
275
- // Per-tier state: disabled | manual | auto. Owned by the collaboration
276
- // preset unless collaboration_mode is "custom".
277
- flash_state: 'auto',
278
- pro_state: 'disabled',
279
- // Roles per tier: routing guidance only, not a keyword classifier.
280
- flash_roles: ['implementation', 'simple_fix', 'tests', 'search_inspection'],
281
- pro_roles: ['architecture', 'complex_debugging', 'refactor', 'code_review', 'implementation'],
282
- // Automatic Pro review after a successful Flash run (review-pipeline forces
283
- // this on; balanced/custom only when this flag is set).
284
- pro_reviews_flash: false,
285
- // Worker provider routing for HUB workers: which DSH provider backs each
286
- // worker session. Fresh installs stay on the built-in DeepSeek provider;
287
- // follow-dsh is an explicit opt-in that uses the provider selected in DSH
288
- // Models (Flash/Pro still map to the selected Harness models).
289
- // Standalone mode always uses deepseek-official + DEEPSEEK_API_KEY.
290
- worker_provider_mode: 'deepseek-official',
291
- // Ordered Harness provider/model selections. The configured flags preserve
292
- // the distinction between a fresh recommendation and a user-cleared list.
293
- flash_model_priority: [],
294
- flash_model_priority_configured: false,
295
- flash_model_fallback: 'harness-default',
296
- pro_model_priority: [],
297
- pro_model_priority_configured: false,
298
- pro_model_fallback: 'harness-default',
299
- // ---- configurable-crew multimodal switches ----
300
- // False = the Crew describe_image tool and vision route are not registered
301
- // at DSH boot (takes effect after a DSH restart). Provider/model values are
302
- // kept untouched so re-enabling restores the previous setup.
303
- vision_enabled: false,
304
- imagegen_enabled: false,
305
- // Multimodal bridge: which subscription CLI lends the text-only DS model
306
- // eyes (describe_image) and a brush (generate_image).
307
- vision_provider: 'off', // claude-code | codex | grok | agy | <custom id> | off
308
- vision_model: 'haiku', // model passed to the vision CLI; free-form
309
- imagegen_provider: 'off', // codex | agy | grok | <custom id> | off
310
- // User-defined providers, each either an OpenAI-compatible HTTP endpoint or a
311
- // local command. Entry shape:
312
- // { id, name, type: 'api' | 'cli', models: [],
313
- // api: base_url, api_key, imagegen_model
314
- // cli: vision_command, imagegen_command }
315
- // CLI commands run via bash with shell-quoted placeholders: vision
316
- // {image} {question} {model} (stdout is the answer), imagegen
317
- // {prompt} {output} {size} (must write the file to {output}).
318
- custom_providers: [],
319
- // User-added model ids per provider, merged into the panel's model list.
320
- extra_models: {},
321
- // Hub-mode agent preset per tier: 'default' follows the DSH roster default.
322
- // Harness Default uses the session-aware filesystem/shell stack. The DSH
323
- // minimal preset's provider-native tools use the Host process cwd instead,
324
- // so it is unsafe as the fresh default for jobs targeting another workspace.
325
- preset_flash: 'default',
326
- preset_pro: 'default',
327
- // ---- v0.2 runtime execution (writable via the settings config endpoint) ----
328
- // Concurrency cap for parallel workflows; isolation: worktree = run coding
329
- // workers in per-job git worktrees (fail-closed for non-git workspaces),
330
- // shared = legacy in-place behaviour.
331
- max_parallel: 3,
332
- isolation: 'worktree',
333
- // ---- v0.2 role state overrides (writable; unset = derived by migration) ----
334
- // Explicit worker/reviewer role states (auto | manual | disabled) and the
335
- // automatic-review switch. When unset, migrateLegacyConfig derives them from
336
- // collaboration_mode / tier_policy / pro_reviews_flash.
337
- worker_state: undefined,
338
- review_state: undefined,
339
- auto_review: undefined,
340
- // ---- per-model peak/off-peak scheduling ----
341
- // Some providers price by wall clock (DeepSeek doubles its rate during peak
342
- // hours). `models` lists only the restricted models — one absent from it is
343
- // unrestricted — each at `warn` (selectable, flagged) or `block` (skipped, so
344
- // the next candidate serves the job). Times are the operator's local wall
345
- // clock at `timezone_offset_minutes`, defaulting to UTC+8 with DeepSeek's
346
- // published peak hours expressed in it.
347
- model_schedule: undefined,
348
- };
349
-
350
- export function mergeStoredGlobalConfig(stored) {
351
- if (!stored || typeof stored !== 'object' || Array.isArray(stored)) return { ...GLOBAL_CONFIG_DEFAULTS };
352
- const has = (key) => Object.prototype.hasOwnProperty.call(stored, key);
353
- const merged = { ...GLOBAL_CONFIG_DEFAULTS, ...stored };
354
- // Fields introduced by configurable Crew keep the prior release's behavior
355
- // when an existing file predates them. Only a genuinely fresh config gets
356
- // the new minimal defaults above.
357
- if (!has('collaboration_mode')) {
358
- merged.collaboration_mode = stored.tier_policy === 'flash-only' ? 'flash-only'
359
- : stored.tier_policy === 'pro-only' ? 'pro-only' : 'balanced';
360
- }
361
- if (!has('flash_state') || !has('pro_state')) {
362
- if (stored.tier_policy === 'flash-only') { merged.flash_state = 'auto'; merged.pro_state = 'disabled'; }
363
- else if (stored.tier_policy === 'pro-only') { merged.flash_state = 'disabled'; merged.pro_state = 'auto'; }
364
- else { merged.flash_state = 'auto'; merged.pro_state = 'auto'; }
365
- }
366
- if (!has('main_agent_mode')) merged.main_agent_mode = 'coordinator-first';
367
- if (!has('worker_provider_mode')) merged.worker_provider_mode = 'deepseek-official';
368
- // Existing configs that predate preset_flash should inherit the current safe
369
- // Harness Default. The older implicit `minimal` migration is unsafe for
370
- // workspace-targeted jobs on Windows; an explicit user-set `minimal` value
371
- // is still preserved by the normal object merge above.
372
- if (!has('preset_flash')) merged.preset_flash = 'default';
373
- if (!has('vision_enabled')) merged.vision_enabled = stored.vision_provider !== 'off';
374
- if (!has('imagegen_enabled')) merged.imagegen_enabled = stored.imagegen_provider !== 'off';
375
- return merged;
376
- }
377
-
378
- export function readGlobalConfig({ configFile = GLOBAL_CONFIG_FILE } = {}) {
379
- if (!existsSync(configFile)) return { ...GLOBAL_CONFIG_DEFAULTS };
380
- return mergeStoredGlobalConfig(readJson(configFile, {}));
381
- }
382
-
383
- export function writeGlobalConfig(patch) {
384
- mkdirSync(CONFIG_DIR_HOME(), { recursive: true });
385
- const next = { ...readGlobalConfig() };
386
- for (const [k, v] of Object.entries(patch ?? {})) {
387
- if (v !== undefined && k in GLOBAL_CONFIG_DEFAULTS) next[k] = v;
388
- }
389
- for (const tier of ['flash', 'pro']) {
390
- const key = `${tier}_model_priority`;
391
- if (patch?.[key] !== undefined) {
392
- next[key] = normalizeModelPriority(patch[key]);
393
- next[`${tier}_model_priority_configured`] = true;
394
- }
395
- }
396
- // Legacy clamp fields stay writable (session commands still read them), and
397
- // a tier_policy write resyncs the new fields so old UIs can't drift apart
398
- // from the new policy. Deprecated but harmless.
399
- if (patch?.tier_policy !== undefined) {
400
- next.tier_policy = patch.tier_policy;
401
- if (patch.tier_policy === 'flash-only') { next.collaboration_mode = 'flash-only'; }
402
- else if (patch.tier_policy === 'pro-only') { next.collaboration_mode = 'pro-only'; }
403
- else if (next.collaboration_mode === 'flash-only' || next.collaboration_mode === 'pro-only') { next.collaboration_mode = 'balanced'; }
404
- }
405
- writeFileSync(GLOBAL_CONFIG_FILE, JSON.stringify(next, null, 2) + '\n');
406
- return next;
407
- }
408
-
409
- /** What is currently installed where — drives the settings-page buttons. */
410
- export function installStatus({ home = homedir(), root = ROOT, env = process.env } = {}) {
411
- const settings = readJson(join(home, '.claude', 'settings.json'), {});
412
- const enabled = settings.enabledPlugins;
413
- const claudeInstalled = !!(enabled && !Array.isArray(enabled) && enabled[PLUGIN_KEY]);
414
- const hudWired = typeof settings.statusLine?.command === 'string'
415
- && settings.statusLine.command.includes('worker-segment.sh');
416
- const marketplaceRoot = settings?.extraKnownMarketplaces?.[MARKETPLACE_NAME]?.source?.path;
417
- const installedPluginRecord = readJson(join(home, '.claude', 'plugins', 'installed_plugins.json'), {})?.plugins?.[PLUGIN_KEY];
418
- const claudeFootprint = claudeInstalled || typeof marketplaceRoot === 'string' || installedPluginRecord !== undefined;
419
- const claudeComponents = {
420
- enabled: claudeInstalled,
421
- marketplace: normalizedPath(marketplaceRoot) === normalizedPath(root) && claudePluginRootReady(root),
422
- snapshot: claudeSnapshotReady(home, root),
423
- permissions: claudePermissionsReady(settings),
424
- };
425
- const claudeMissing = Object.entries(claudeComponents).filter(([, present]) => !present).map(([key]) => key);
426
- const codexRoot = codexHomeDir(home, env);
427
- const configFile = join(codexRoot, 'config.toml');
428
- const configText = readText(configFile) ?? '';
429
- const workerFile = join(codexRoot, 'agents', 'ds-worker.toml');
430
- const reviewerFile = join(codexRoot, 'agents', 'ds-reviewer.toml');
431
- const workerTarget = codexRoleTarget(workerFile, 'ds-worker');
432
- const reviewerTarget = codexRoleTarget(reviewerFile, 'ds-reviewer');
433
- const mcpTarget = codexMcpTarget(configText);
434
- const expectedTarget = normalizedPath(join(root, 'src', 'server.mjs'));
435
- const expectedWorkerRole = renderedCodexRole(root, 'ds-worker.toml');
436
- const expectedReviewerRole = renderedCodexRole(root, 'ds-reviewer.toml');
437
- const expectedConfigPrompt = readText(join(root, 'codex', 'prompts', 'dsh-config.md'));
438
- const expectedStatusPrompt = readText(join(root, 'codex', 'prompts', 'dsh-status.md'));
439
- const components = {
440
- worker_role: expectedWorkerRole !== null && readText(workerFile) === expectedWorkerRole,
441
- reviewer_role: expectedReviewerRole !== null && readText(reviewerFile) === expectedReviewerRole,
442
- config_prompt: expectedConfigPrompt !== null && readText(join(codexRoot, 'prompts', 'dsh-config.md')) === expectedConfigPrompt,
443
- status_prompt: expectedStatusPrompt !== null && readText(join(codexRoot, 'prompts', 'dsh-status.md')) === expectedStatusPrompt,
444
- mcp: !!expectedTarget && mcpTarget === expectedTarget,
445
- target_alignment: !!expectedTarget && workerTarget === expectedTarget && reviewerTarget === expectedTarget && mcpTarget === expectedTarget,
446
- global_policy: globalCodexPolicyReady({ home, root, env }),
447
- };
448
- const codexInstalled = Object.values(components).some(Boolean)
449
- || existsSync(join(codexRoot, 'agents', 'ds-flash.toml'))
450
- || existsSync(join(codexRoot, 'agents', 'ds-pro.toml'));
451
- const missing = Object.entries(components).filter(([, present]) => !present).map(([key]) => key);
452
- return {
453
- claude: {
454
- installed: claudeFootprint,
455
- ready: claudeMissing.length === 0,
456
- hud: hudWired,
457
- components: claudeComponents,
458
- missing: claudeMissing,
459
- },
460
- codex: { installed: codexInstalled, ready: missing.length === 0, components, missing },
461
- zcode: zcodeStatus({ home, root }),
462
- };
463
- }
464
-
465
- export function uninstallCodex({ home = homedir(), env = process.env } = {}) {
466
- const actions = [];
467
- // Both the v0.2 roles (ds-worker / ds-reviewer) and the deprecated v0.1
468
- // aliases (ds-flash / ds-pro) are dsh-crew managed; uninstall removes only
469
- // these, never a user's own role files.
470
- for (const f of ['ds-flash.toml', 'ds-pro.toml', 'ds-worker.toml', 'ds-reviewer.toml']) {
471
- const p = join(codexHomeDir(home, env), 'agents', f);
472
- if (existsSync(p)) { backup(p); rmSync(p); actions.push(`removed: ${p} (backup kept)`); }
473
- }
474
- for (const f of ['dsh-config.md', 'dsh-status.md']) {
475
- const p = join(codexHomeDir(home, env), 'prompts', f);
476
- if (existsSync(p)) { rmSync(p); actions.push(`removed: ${p}`); }
477
- }
478
- const policyAction = uninstallGlobalCodexPolicy({ home, env });
479
- if (policyAction) actions.push(policyAction);
480
- // Remove only the dsh-crew entry from [mcp_servers], keeping any other
481
- // MCP servers the user configured.
482
- const configFile = join(codexHomeDir(home, env), 'config.toml');
483
- if (existsSync(configFile)) {
484
- const before = readFileSync(configFile, 'utf8');
485
- const lineRe = /(^|\n)[ \t]*dsh-crew\s*=\s*\{[^\n]*\}/;
486
- const after = lineRe.test(before) ? before.replace(lineRe, '') : before;
487
- if (after !== before) {
488
- backup(configFile);
489
- writeFileSync(configFile, after.replace(/(^|\n){2,}/g, '\n\n').replace(/^\[mcp_servers\]\n?$/, ''));
490
- actions.push(`codex config: removed dsh-crew MCP entry`);
491
- }
492
- }
493
- return { ok: true, actions: actions.length ? actions : ['nothing to remove'] };
494
- }
495
-
496
- export async function installClaudeCode({ home = homedir(), statusline = false, root = ROOT } = {}) {
497
- const actions = [];
498
-
499
- // The repository carries its own marketplace manifest (.claude-plugin/
500
- // marketplace.json with source "."), so the marketplace root IS the plugin
501
- // checkout itself — no parent-directory layout assumption. This keeps the
502
- // installer working when the repo is cloned anywhere (e.g. Desktop\dsh-crew).
503
- // `root` lets an npx-managed install point every rendered path at the
504
- // durable Crew-owned payload instead of a transient package extraction dir.
505
- const mpDir = root;
506
- actions.push(`marketplace: ${mpDir} (self-marketplace, source .)`);
507
-
508
- // 2. settings.json: marketplace + enabledPlugins + permissions allowlist.
509
- const settingsFile = join(home, '.claude', 'settings.json');
510
- mkdirSync(dirname(settingsFile), { recursive: true });
511
- const bak = backup(settingsFile);
512
- if (bak) actions.push(`backup: ${bak}`);
513
- const settings = readJson(settingsFile, {});
514
-
515
- // Both fields are records (see json.schemastore.org/claude-code-settings.json).
516
- // Older versions of this installer wrote arrays, which Claude Code ignores
517
- // with a warning — migrate those in place.
518
- const markets = (settings.extraKnownMarketplaces && !Array.isArray(settings.extraKnownMarketplaces)
519
- && typeof settings.extraKnownMarketplaces === 'object') ? settings.extraKnownMarketplaces : {};
520
- if (Array.isArray(settings.extraKnownMarketplaces)) actions.push('migrated legacy extraKnownMarketplaces array');
521
- markets[MARKETPLACE_NAME] = { source: { source: 'directory', path: mpDir } };
522
- if (markets['dsh-workers']) {
523
- delete markets['dsh-workers'];
524
- actions.push('removed pre-rename dsh-workers marketplace entry');
525
- }
526
- settings.extraKnownMarketplaces = markets;
527
-
528
- const enabled = (settings.enabledPlugins && !Array.isArray(settings.enabledPlugins)
529
- && typeof settings.enabledPlugins === 'object') ? settings.enabledPlugins : {};
530
- if (Array.isArray(settings.enabledPlugins)) {
531
- for (const key of settings.enabledPlugins) if (typeof key === 'string') enabled[key] = true;
532
- actions.push('migrated legacy enabledPlugins array');
533
- }
534
- enabled[PLUGIN_KEY] = true;
535
- if (enabled['dsh-workers@dsh-workers']) {
536
- delete enabled['dsh-workers@dsh-workers'];
537
- actions.push('removed pre-rename dsh-workers plugin entry');
538
- }
539
- settings.enabledPlugins = enabled;
540
-
541
- settings.permissions = settings.permissions ?? {};
542
- let allow = Array.isArray(settings.permissions.allow) ? settings.permissions.allow : [];
543
- const preRename = allow.filter((r) => typeof r === 'string' && r.startsWith('mcp__plugin_dsh-workers_'));
544
- if (preRename.length) {
545
- allow = allow.filter((r) => !preRename.includes(r));
546
- actions.push(`removed ${preRename.length} pre-rename permission rules`);
547
- }
548
- for (const tool of MCP_TOOLS) {
549
- const rule = `mcp__plugin_dsh-crew_dsh-crew__${tool}`;
550
- if (!allow.includes(rule)) allow.push(rule);
551
- }
552
- settings.permissions.allow = allow;
553
-
554
- if (statusline && !settings.statusLine) {
555
- settings.statusLine = { type: 'command', command: `bash ${join(root, 'statusline', 'statusline.sh')}` };
556
- actions.push('statusline: installed');
557
- } else if (statusline) {
558
- actions.push('statusline: skipped (one already configured)');
559
- }
560
-
561
- writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + '\n');
562
- actions.push(`settings: registered ${PLUGIN_KEY} + ${MCP_TOOLS.length} permission rules`);
563
-
564
- // Materialize the install through the claude CLI: registers the marketplace
565
- // in the plugin cache (settings alone leave a stale/absent cache entry) and
566
- // pulls the plugin so the next session loads it. Best-effort: settings are
567
- // already correct, so a missing CLI just means one manual `plugin install`.
568
- const registered = readJson(join(home, '.claude', 'plugins', 'known_marketplaces.json'), {})?.[MARKETPLACE_NAME];
569
- const installedRecord = readJson(join(home, '.claude', 'plugins', 'installed_plugins.json'), {})?.plugins?.[PLUGIN_KEY];
570
- const installedEntries = Array.isArray(installedRecord) ? installedRecord : [installedRecord];
571
- const marketplaceCurrent = registered?.source?.source === 'directory'
572
- && normalizedPath(registered.source.path) === normalizedPath(root)
573
- && normalizedPath(registered.installLocation) === normalizedPath(root);
574
- if (marketplaceCurrent && installedEntries.some((entry) => entry?.scope === 'user'
575
- && sameManagedClaudeFiles(root, entry.installPath))) {
576
- actions.push('cli: skipped (registered marketplace and snapshot already current)');
577
- return { ok: true, actions };
578
- }
579
- if (home !== homedir()) {
580
- actions.push('cli: skipped (non-default home; test mode)');
581
- return { ok: true, actions };
582
- }
583
- try {
584
- const { execSync } = await import('node:child_process');
585
- const run = (cmd) => execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 120_000 });
586
- try { run(`claude plugin marketplace add ${JSON.stringify(mpDir)}`); actions.push('cli: marketplace registered'); }
587
- catch { actions.push('cli: marketplace add skipped (already registered)'); }
588
- // `plugin install` on an already-installed plugin is a no-op and leaves a
589
- // stale snapshot in ~/.claude/plugins/cache — uninstall first so an update
590
- // always re-copies the current code.
591
- try { run(`claude plugin uninstall ${PLUGIN_KEY}`); } catch {}
592
- // Newer Claude Code (>= 2.1.x) dropped the -y flag; older builds accepted
593
- // it. Try without it first, fall back to the legacy flag.
594
- try {
595
- run(`claude plugin install ${PLUGIN_KEY} --scope user`);
596
- } catch (errNoFlag) {
597
- if (!String(errNoFlag?.message ?? '').includes('unknown option')) throw errNoFlag;
598
- run(`claude plugin install ${PLUGIN_KEY} --scope user -y`);
599
- }
600
- actions.push(`cli: plugin snapshot refreshed (${PLUGIN_KEY})`);
601
- } catch (err) {
602
- actions.push(`cli: plugin install failed — run manually: claude plugin install ${PLUGIN_KEY} (${String(err?.message ?? err).slice(0, 120)})`);
603
- }
604
- return { ok: true, actions };
605
- }
606
-
607
- export function installCodex({ home = homedir(), scope, root = ROOT, env = process.env } = {}) {
608
- const actions = [];
609
- const agentsDir = scope === 'project' ? join(process.cwd(), '.codex', 'agents') : join(codexHomeDir(home, env), 'agents');
610
- mkdirSync(agentsDir, { recursive: true });
611
- const srcDir = join(root, 'codex', 'agents');
612
- // Windows drive paths must render with forward slashes: backslashes in a
613
- // TOML basic string are escape sequences (\\U\\u... are invalid), which made
614
- // the role files unparseable for Codex Desktop. node accepts forward slashes
615
- // on Windows, so the absolute path rendered as D:/... is both valid TOML and
616
- // runnable. `root` targets the durable installed payload for npx installs.
617
- const renderedPath = join(root, 'src', 'server.mjs').replace(/\\/g, '/');
618
- for (const f of readdirSync(srcDir).filter((f) => f.endsWith('.toml'))) {
619
- const dest = join(agentsDir, f);
620
- const bak = backup(dest);
621
- if (bak) actions.push(`backup: ${bak}`);
622
- const rendered = renderedCodexRole(root, f);
623
- writeFileSync(dest, rendered);
624
- actions.push(`role: ${dest}`);
625
- }
626
- const promptsDir = scope === 'project' ? join(process.cwd(), '.codex', 'prompts') : join(codexHomeDir(home, env), 'prompts');
627
- mkdirSync(promptsDir, { recursive: true });
628
- const promptsSrc = join(root, 'codex', 'prompts');
629
- for (const f of readdirSync(promptsSrc).filter((f) => f.endsWith('.md'))) {
630
- writeFileSync(join(promptsDir, f), readFileSync(join(promptsSrc, f), 'utf8'));
631
- actions.push(`prompt: ${join(promptsDir, f)}`);
632
- }
633
- if (scope !== 'project') {
634
- const act = writeGlobalCodexMcpServer(home, renderedPath, env);
635
- actions.push(...act);
636
- const policy = installGlobalCodexPolicy({ home, root, env });
637
- if (!policy.ok) return { ok: false, actions: [...actions, policy.action] };
638
- actions.push(policy.action);
639
- }
640
- return { ok: true, actions };
641
- }
642
-
643
- /**
644
- * Codex Desktop reads the shared config at ~/.codex/config.toml, and the role
645
- * files alone only expose dsh MCP tools inside the ds-flash/ds-pro subagents.
646
- * To make dsh_worker_config (policy) visible to the MAIN Codex session, add a
647
- * top-level [mcp_servers] entry under dsh-crew — preserving any other servers
648
- * the user already configured. Idempotent (updates in place). Never requires
649
- * the codex CLI.
650
- */
651
- export function writeGlobalCodexMcpServer(home, renderedPath, env = process.env) {
652
- const configFile = join(codexHomeDir(home, env), 'config.toml');
653
- const entry = `dsh-crew = { command = "node", args = ["${renderedPath}"] }`;
654
- const backupFile = backup(configFile);
655
- const existing = existsSync(configFile) ? readFileSync(configFile, 'utf8') : '';
656
- const hasSection = /(^|\n)\s*\[mcp_servers\]/.test(existing);
657
- const sectionIdx = hasSection ? existing.search(/(^|\n)\s*\[mcp_servers\]/) : -1;
658
-
659
- let next;
660
- if (hasSection) {
661
- // Section exists: find its extent (next top-level [section]) and patch the
662
- // dsh-crew entry within it.
663
- const afterHeader = sectionIdx + existing.slice(sectionIdx).indexOf(']') + 1;
664
- const rest = existing.slice(afterHeader);
665
- const nextHeader = rest.search(/(^|\n)\s*\[[^\]]+\]/);
666
- const sectionBody = nextHeader === -1 ? rest : rest.slice(0, nextHeader);
667
- const tail = nextHeader === -1 ? '' : rest.slice(nextHeader);
668
- const lineRe = /(^|\n)([ \t]*)dsh-crew\s*=\s*\{[^\n]*\}/;
669
- const patched = lineRe.test(sectionBody)
670
- ? sectionBody.replace(lineRe, `$1$2${entry}`)
671
- : `${sectionBody.replace(/\s*$/, '')}\n${entry}`;
672
- next = existing.slice(0, afterHeader) + patched + tail;
673
- } else {
674
- // No [mcp_servers] section: append a fresh one.
675
- const sep = existing && !/[\n]$/.test(existing) ? '\n' : '';
676
- next = `${existing}${sep}[mcp_servers]\n${entry}\n`;
677
- }
678
- writeFileSync(configFile, next);
679
- const actions = [];
680
- if (backupFile) actions.push(`config backup: ${backupFile}`);
681
- actions.push(`codex config: [mcp_servers] dsh-crew → ${renderedPath}`);
682
- return actions;
683
- }
684
-
685
- export function installHudSegment({ home = homedir() } = {}) {
686
- const settingsFile = join(home, '.claude', 'settings.json');
687
- const settings = readJson(settingsFile, null);
688
- if (!settings) return { ok: false, actions: ['settings.json not found'] };
689
- const cmd = settings.statusLine?.command;
690
- if (typeof cmd !== 'string' || !cmd.includes('claude-hud')) {
691
- return { ok: false, actions: ['statusLine is not claude-hud; use statusline/statusline.sh directly instead'] };
692
- }
693
- const segment = join(ROOT, 'statusline', 'worker-segment.sh');
694
- if (cmd.includes(segment)) return { ok: true, actions: ['already wired'] };
695
- if (cmd.includes('worker-segment.sh')) {
696
- // Wired to a stale copy (e.g. pre-rename path) — repoint the --extra-cmd at the current segment.
697
- const next = cmd.replace(/--extra-cmd "bash [^"]*worker-segment\.sh"/, `--extra-cmd "bash ${segment}"`);
698
- if (next === cmd) return { ok: false, actions: ['worker-segment.sh present but path not replaceable; rewire manually'] };
699
- const bak = backup(settingsFile);
700
- settings.statusLine.command = next;
701
- writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + '\n');
702
- return { ok: true, actions: [...(bak ? [`backup: ${bak}`] : []), `statusLine: repointed worker-segment.sh to ${segment}`] };
703
- }
704
- let next = cmd;
705
- if (!next.includes('CLAUDE_HUD_ALLOW_EXTRA_CMD')) {
706
- next = next.replace(/exec\s+/, 'exec env CLAUDE_HUD_ALLOW_EXTRA_CMD=1 ');
707
- if (!next.includes('CLAUDE_HUD_ALLOW_EXTRA_CMD')) return { ok: false, actions: ['could not find exec in statusLine command; wire manually'] };
708
- }
709
- const extra = ` --extra-cmd "bash ${segment}"`;
710
- if (next.endsWith("'")) next = next.slice(0, -1) + extra + "'";
711
- else next += extra;
712
- const bak = backup(settingsFile);
713
- settings.statusLine.command = next;
714
- writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + '\n');
715
- return { ok: true, actions: [...(bak ? [`backup: ${bak}`] : []), 'statusLine: claude-hud now runs worker-segment.sh via --extra-cmd'] };
716
- }
717
-
718
- export function uninstallClaudeCode({ home = homedir() } = {}) {
719
- const settingsFile = join(home, '.claude', 'settings.json');
720
- const settings = readJson(settingsFile, null);
721
- if (!settings) return { ok: true, actions: ['settings.json not found'] };
722
- backup(settingsFile);
723
- const mpDir = join(home, '.config', 'dsh-crew', 'marketplace');
724
- if (Array.isArray(settings.extraKnownMarketplaces)) {
725
- settings.extraKnownMarketplaces = settings.extraKnownMarketplaces.filter((m) => m?.path !== mpDir);
726
- } else if (settings.extraKnownMarketplaces) {
727
- delete settings.extraKnownMarketplaces[MARKETPLACE_NAME];
728
- }
729
- if (Array.isArray(settings.enabledPlugins)) {
730
- settings.enabledPlugins = settings.enabledPlugins.filter((p) => p !== PLUGIN_KEY);
731
- } else if (settings.enabledPlugins) {
732
- delete settings.enabledPlugins[PLUGIN_KEY];
733
- }
734
- if (settings.permissions?.allow) {
735
- settings.permissions.allow = settings.permissions.allow.filter((r) => !r.startsWith('mcp__plugin_dsh-crew_'));
736
- }
737
- writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + '\n');
738
- return { ok: true, actions: ['unregistered from settings.json (backup kept)'] };
739
- }
1
+ // One-click installers: register the Claude Code plugin persistently and
2
+ // render Codex agent roles with real paths. Called from the CLI entry or the
3
+ // DSH settings page. All edits are backed up and idempotent.
4
+
5
+ import { readFileSync, writeFileSync, mkdirSync, copyFileSync, existsSync, readdirSync, rmSync, statSync, lstatSync } from 'node:fs';
6
+ import { createHash } from 'node:crypto';
7
+ import { dirname, join, resolve, relative } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { homedir } from 'node:os';
10
+ import { normalizeModelPriority } from '../model-routing.mjs';
11
+ import { zcodeStatus } from './zcode.mjs';
12
+
13
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
14
+ const MARKETPLACE_NAME = 'dsh-crew';
15
+ const PLUGIN_KEY = `dsh-crew@${MARKETPLACE_NAME}`;
16
+ const POLICY_START = '<!-- DSH CREW MANAGED POLICY:START -->';
17
+ const POLICY_END = '<!-- DSH CREW MANAGED POLICY:END -->';
18
+
19
+ /**
20
+ * The directory Codex actually reads.
21
+ *
22
+ * Codex honours `CODEX_HOME` and falls back to `~/.codex`; writing only to the
23
+ * latter silently does nothing for anyone who set it — the registration stays
24
+ * frozen on whatever release was current when they set it, while every install
25
+ * reports success because the file it wrote really did change. Resolve the same
26
+ * way Codex does, and treat an empty or whitespace value as unset.
27
+ */
28
+ export function codexHomeDir(home = homedir(), env = process.env) {
29
+ const override = typeof env?.CODEX_HOME === 'string' ? env.CODEX_HOME.trim() : '';
30
+ return override ? resolve(override) : join(home, '.codex');
31
+ }
32
+ export const CODEX_LEGACY_POLICY_HASHES = Object.freeze([
33
+ '2d6f3839bb3df4bda90f481726281292b1a4b4585298b1cf9ec56215295b5c78',
34
+ ]);
35
+ // dsh_worker_config is included so the session commands (/dsh-crew:config,
36
+ // /dsh-config) and any orchestrator policy lookup run without an extra
37
+ // authorization prompt.
38
+ export const MCP_TOOLS = ['dsh_run_worker', 'dsh_spawn_worker', 'dsh_worker_status', 'dsh_worker_result', 'dsh_worker_cancel', 'dsh_worker_config'];
39
+
40
+ function backup(file) {
41
+ if (existsSync(file)) {
42
+ const bak = `${file}.dsh-crew-backup-${Date.now()}`;
43
+ copyFileSync(file, bak);
44
+ return bak;
45
+ }
46
+ return null;
47
+ }
48
+
49
+ function readJson(file, fallback) {
50
+ try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return fallback; }
51
+ }
52
+
53
+ function readText(file) {
54
+ try { return readFileSync(file, 'utf8'); } catch { return null; }
55
+ }
56
+
57
+ function normalizedPath(value) {
58
+ if (typeof value !== 'string' || !value.trim()) return null;
59
+ try {
60
+ const path = resolve(value);
61
+ return process.platform === 'win32' ? path.toLowerCase() : path;
62
+ } catch { return null; }
63
+ }
64
+
65
+ function renderedCodexRole(root, file) {
66
+ const source = readText(join(root, 'codex', 'agents', file));
67
+ if (source === null) return null;
68
+ const renderedPath = join(root, 'src', 'server.mjs').replace(/\\/g, '/');
69
+ return source.replace(/args = \[.*server\.mjs"\]/, `args = ["${renderedPath}"]`);
70
+ }
71
+
72
+ function managedPolicyBlock(root) {
73
+ const policy = readText(join(root, 'codex', 'AGENTS.md'))?.trim();
74
+ if (!policy) return null;
75
+ return `${POLICY_START}\n${policy}\n${POLICY_END}`;
76
+ }
77
+
78
+ export function codexLegacyPolicyDigest(text) {
79
+ const canonical = typeof text === 'string' ? text.replace(/\r\n/g, '\n').trim() : '';
80
+ return canonical ? createHash('sha256').update(canonical, 'utf8').digest('hex') : null;
81
+ }
82
+
83
+ export function stripKnownLegacyCodexPolicy(text, { knownHashes = CODEX_LEGACY_POLICY_HASHES } = {}) {
84
+ if (typeof text !== 'string') return text;
85
+ const start = POLICY_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
86
+ const end = POLICY_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
87
+ const match = new RegExp(`${start}[\\s\\S]*?${end}`, 'm').exec(text);
88
+ if (!match) return text;
89
+ const allowed = new Set(Array.isArray(knownHashes) ? knownHashes : []);
90
+ const prefix = text.slice(0, match.index);
91
+ const suffix = text.slice(match.index + match[0].length);
92
+ const keptPrefix = allowed.has(codexLegacyPolicyDigest(prefix)) ? '' : prefix;
93
+ const keptSuffix = allowed.has(codexLegacyPolicyDigest(suffix)) ? '' : suffix;
94
+ return `${keptPrefix}${match[0]}${keptSuffix}`;
95
+ }
96
+
97
+ function installGlobalCodexPolicy({ home, root, env = process.env }) {
98
+ const file = join(codexHomeDir(home, env), 'AGENTS.md');
99
+ const block = managedPolicyBlock(root);
100
+ if (!block) return { ok: false, action: 'global policy template missing' };
101
+ mkdirSync(dirname(file), { recursive: true });
102
+ const current = readText(file) ?? '';
103
+ const managed = new RegExp(`${POLICY_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${POLICY_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'm');
104
+ const template = readText(join(root, 'codex', 'AGENTS.md'))?.trim() ?? '';
105
+ let next;
106
+ if (managed.test(current)) next = current.replace(managed, block);
107
+ else if (current.trim() === template) next = `${block}\n`;
108
+ else next = `${current.trimEnd()}${current.trim() ? '\n\n' : ''}${block}\n`;
109
+ next = stripKnownLegacyCodexPolicy(next);
110
+ if (next !== current) {
111
+ backup(file);
112
+ writeFileSync(file, next);
113
+ }
114
+ return { ok: true, action: `global policy: ${file}` };
115
+ }
116
+
117
+ function globalCodexPolicyReady({ home, root = ROOT, env = process.env }) {
118
+ const block = managedPolicyBlock(root);
119
+ const text = readText(join(codexHomeDir(home, env), 'AGENTS.md'));
120
+ return !!block && typeof text === 'string' && text.includes(block);
121
+ }
122
+
123
+ function uninstallGlobalCodexPolicy({ home, env = process.env }) {
124
+ const file = join(codexHomeDir(home, env), 'AGENTS.md');
125
+ const current = readText(file);
126
+ if (typeof current !== 'string') return null;
127
+ const managed = new RegExp(`(?:\\r?\\n){0,2}${POLICY_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${POLICY_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\r?\\n)?`, 'm');
128
+ if (!managed.test(current)) return null;
129
+ const next = current.replace(managed, '').trimEnd();
130
+ backup(file);
131
+ if (next.trim()) writeFileSync(file, `${next}\n`);
132
+ else rmSync(file);
133
+ return `codex global policy: removed managed block`;
134
+ }
135
+
136
+ function tomlSection(text, name) {
137
+ if (typeof text !== 'string') return null;
138
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
139
+ const header = new RegExp(`^\\s*\\[${escaped}\\]\\s*(?:#.*)?$`, 'm').exec(text);
140
+ if (!header) return null;
141
+ const rest = text.slice(header.index + header[0].length);
142
+ const nextHeader = rest.search(/^\s*\[[^\]\r\n]+\]\s*(?:#.*)?$/m);
143
+ return nextHeader === -1 ? rest : rest.slice(0, nextHeader);
144
+ }
145
+
146
+ function existingServerTarget(block) {
147
+ if (typeof block !== 'string' || !/^\s*command\s*=\s*"node"\s*$/m.test(block)) return null;
148
+ const match = /^\s*args\s*=\s*\[\s*"([^"\r\n]*server\.mjs)"\s*\]\s*$/m.exec(block);
149
+ if (!match) return null;
150
+ try {
151
+ if (!statSync(match[1]).isFile()) return null;
152
+ const target = resolve(match[1]);
153
+ return process.platform === 'win32' ? target.toLowerCase() : target;
154
+ } catch { return null; }
155
+ }
156
+
157
+ function codexRoleTarget(file, expectedName) {
158
+ const text = readText(file);
159
+ if (typeof text !== 'string'
160
+ || !new RegExp(`^\\s*name\\s*=\\s*"${expectedName}"\\s*$`, 'm').test(text)) return null;
161
+ return existingServerTarget(tomlSection(text, 'mcp_servers.dsh-crew'));
162
+ }
163
+
164
+ function codexMcpTarget(configText) {
165
+ const section = tomlSection(configText, 'mcp_servers');
166
+ if (!section) return null;
167
+ const entry = /^\s*dsh-crew\s*=\s*\{\s*command\s*=\s*"node"\s*,\s*args\s*=\s*\[\s*"([^"\r\n]*server\.mjs)"\s*\]\s*\}\s*(?:#.*)?$/m.exec(section);
168
+ if (!entry) return null;
169
+ try {
170
+ if (!statSync(entry[1]).isFile()) return null;
171
+ const target = resolve(entry[1]);
172
+ return process.platform === 'win32' ? target.toLowerCase() : target;
173
+ } catch { return null; }
174
+ }
175
+
176
+ function isFile(file) {
177
+ try { return statSync(file).isFile(); } catch { return false; }
178
+ }
179
+
180
+ function claudePluginRootReady(root) {
181
+ if (typeof root !== 'string' || !root.trim() || !isFile(join(root, 'src', 'server.mjs'))) return false;
182
+ const plugin = readJson(join(root, '.claude-plugin', 'plugin.json'), null);
183
+ const mcp = plugin?.mcpServers?.['dsh-crew'];
184
+ return !!plugin
185
+ && typeof plugin.version === 'string'
186
+ && mcp?.command === 'node'
187
+ && Array.isArray(mcp.args)
188
+ && mcp.args.length === 1
189
+ && mcp.args[0] === '${CLAUDE_PLUGIN_ROOT}/src/server.mjs';
190
+ }
191
+
192
+ function managedClaudeFileManifest(root, { maxFiles = 512, maxBytes = 8 * 1024 * 1024 } = {}) {
193
+ const files = [];
194
+ let bytes = 0;
195
+ const add = (file, relativePath) => {
196
+ const info = lstatSync(file);
197
+ if (info.isSymbolicLink() || !info.isFile()) throw new Error('unsupported snapshot entry');
198
+ const content = readFileSync(file);
199
+ bytes += content.length;
200
+ if (bytes > maxBytes || files.length >= maxFiles) throw new Error('snapshot manifest bound exceeded');
201
+ files.push([relativePath.replace(/\\/g, '/'), createHash('sha256').update(content).digest('hex')]);
202
+ };
203
+ const walk = (directory, relativeDirectory) => {
204
+ if (!existsSync(directory)) return;
205
+ const info = lstatSync(directory);
206
+ if (info.isSymbolicLink() || !info.isDirectory()) throw new Error('unsupported snapshot directory');
207
+ for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
208
+ const file = join(directory, entry.name);
209
+ const relativePath = join(relativeDirectory, entry.name);
210
+ if (entry.isSymbolicLink()) throw new Error('snapshot symlink not allowed');
211
+ if (entry.isDirectory()) walk(file, relativePath);
212
+ else if (entry.isFile()) add(file, relativePath);
213
+ else throw new Error('unsupported snapshot entry');
214
+ }
215
+ };
216
+ try {
217
+ add(join(root, '.claude-plugin', 'plugin.json'), join('.claude-plugin', 'plugin.json'));
218
+ if (isFile(join(root, 'package.json'))) add(join(root, 'package.json'), 'package.json');
219
+ for (const directory of ['agents', 'commands', 'src']) walk(join(root, directory), directory);
220
+ return files;
221
+ } catch { return null; }
222
+ }
223
+
224
+ function sameManagedClaudeFiles(expectedRoot, snapshotRoot) {
225
+ if (!claudePluginRootReady(expectedRoot) || !claudePluginRootReady(snapshotRoot)) return false;
226
+ const expected = managedClaudeFileManifest(expectedRoot);
227
+ const snapshot = managedClaudeFileManifest(snapshotRoot);
228
+ return expected !== null && snapshot !== null && JSON.stringify(snapshot) === JSON.stringify(expected);
229
+ }
230
+
231
+ function claudeSnapshotReady(home, root) {
232
+ const installed = readJson(join(home, '.claude', 'plugins', 'installed_plugins.json'), {});
233
+ const record = installed?.plugins?.[PLUGIN_KEY];
234
+ const entries = Array.isArray(record) ? record : [record];
235
+ return entries.some((entry) => sameManagedClaudeFiles(root, entry?.installPath));
236
+ }
237
+
238
+ function claudePermissionsReady(settings) {
239
+ const allowed = Array.isArray(settings?.permissions?.allow) ? settings.permissions.allow : [];
240
+ return MCP_TOOLS.every((tool) => allowed.includes(`mcp__plugin_dsh-crew_dsh-crew__${tool}`));
241
+ }
242
+
243
+ // One-time migration from the pre-rename config dir (dsh-workers → dsh-crew).
244
+ try {
245
+ const oldDir = join(homedir(), '.config', 'dsh-workers');
246
+ const newDir = join(homedir(), '.config', 'dsh-crew');
247
+ if (existsSync(oldDir) && !existsSync(newDir)) {
248
+ const { renameSync } = await import('node:fs');
249
+ renameSync(oldDir, newDir);
250
+ }
251
+ } catch {}
252
+
253
+ const GLOBAL_CONFIG_FILE = join(CONFIG_DIR_HOME(), 'config.json');
254
+ function CONFIG_DIR_HOME() { return join(homedir(), '.config', 'dsh-crew'); }
255
+
256
+ export const GLOBAL_CONFIG_DEFAULTS = {
257
+ default_tier: 'flash',
258
+ default_effort: 'max',
259
+ mode: 'auto',
260
+ default_timeout_seconds: 1800,
261
+ hub_url: 'http://127.0.0.1:3080',
262
+ // auto = orchestrator picks the tier; flash-only / pro-only clamp every
263
+ // dispatch to one tier at the tool layer regardless of what was requested.
264
+ tier_policy: 'flash-only',
265
+ // When a blocking flash run fails, retry the same task once on pro.
266
+ escalate_on_failure: false,
267
+ // ---- configurable-crew orchestration (see src/policy.mjs) ----
268
+ // Master switch for the whole worker dispatch path (hard, backend-enforced).
269
+ subagents_enabled: true,
270
+ // flash-only | pro-only | balanced | review-pipeline | custom
271
+ collaboration_mode: 'flash-only',
272
+ // direct-allowed | coordinator-first | dispatcher-only (host routing
273
+ // guidance — the Crew MCP backend cannot restrict the host's own tools).
274
+ main_agent_mode: 'direct-allowed',
275
+ // Per-tier state: disabled | manual | auto. Owned by the collaboration
276
+ // preset unless collaboration_mode is "custom".
277
+ flash_state: 'auto',
278
+ pro_state: 'disabled',
279
+ // Roles per tier: routing guidance only, not a keyword classifier.
280
+ flash_roles: ['implementation', 'simple_fix', 'tests', 'search_inspection'],
281
+ pro_roles: ['architecture', 'complex_debugging', 'refactor', 'code_review', 'implementation'],
282
+ // Automatic Pro review after a successful Flash run (review-pipeline forces
283
+ // this on; balanced/custom only when this flag is set).
284
+ pro_reviews_flash: false,
285
+ // Worker provider routing for HUB workers: which DSH provider backs each
286
+ // worker session. Fresh installs stay on the built-in DeepSeek provider;
287
+ // follow-dsh is an explicit opt-in that uses the provider selected in DSH
288
+ // Models (Flash/Pro still map to the selected Harness models).
289
+ // Standalone mode always uses deepseek-official + DEEPSEEK_API_KEY.
290
+ worker_provider_mode: 'deepseek-official',
291
+ // Ordered Harness provider/model selections. The configured flags preserve
292
+ // the distinction between a fresh recommendation and a user-cleared list.
293
+ flash_model_priority: [],
294
+ flash_model_priority_configured: false,
295
+ flash_model_fallback: 'harness-default',
296
+ pro_model_priority: [],
297
+ pro_model_priority_configured: false,
298
+ pro_model_fallback: 'harness-default',
299
+ // User-added model ids per provider, merged into the panel's model list.
300
+ extra_models: {},
301
+ // Hub-mode agent preset per tier: 'default' follows the DSH roster default.
302
+ // Harness Default uses the session-aware filesystem/shell stack. The DSH
303
+ // minimal preset's provider-native tools use the Host process cwd instead,
304
+ // so it is unsafe as the fresh default for jobs targeting another workspace.
305
+ preset_flash: 'default',
306
+ preset_pro: 'default',
307
+ // ---- v0.2 runtime execution (writable via the settings config endpoint) ----
308
+ // Concurrency cap for parallel workflows; isolation: worktree = run coding
309
+ // workers in per-job git worktrees (fail-closed for non-git workspaces),
310
+ // shared = legacy in-place behaviour.
311
+ max_parallel: 3,
312
+ isolation: 'worktree',
313
+ // ---- v0.2 role state overrides (writable; unset = derived by migration) ----
314
+ // Explicit worker/reviewer role states (auto | manual | disabled) and the
315
+ // automatic-review switch. When unset, migrateLegacyConfig derives them from
316
+ // collaboration_mode / tier_policy / pro_reviews_flash.
317
+ worker_state: undefined,
318
+ review_state: undefined,
319
+ auto_review: undefined,
320
+ // ---- per-model peak/off-peak scheduling ----
321
+ // Some providers price by wall clock (DeepSeek doubles its rate during peak
322
+ // hours). `models` lists only the restricted models — one absent from it is
323
+ // unrestricted — each at `warn` (selectable, flagged) or `block` (skipped, so
324
+ // the next candidate serves the job). Times are the operator's local wall
325
+ // clock at `timezone_offset_minutes`, defaulting to UTC+8 with DeepSeek's
326
+ // published peak hours expressed in it.
327
+ model_schedule: undefined,
328
+ };
329
+
330
+ /** Config keys removed with the vision / image-generation bridge. */
331
+ export const REMOVED_MULTIMODAL_KEYS = Object.freeze([
332
+ 'vision_enabled', 'imagegen_enabled', 'vision_provider', 'vision_model',
333
+ 'imagegen_provider', 'custom_providers',
334
+ ]);
335
+
336
+ export function mergeStoredGlobalConfig(stored) {
337
+ if (!stored || typeof stored !== 'object' || Array.isArray(stored)) return { ...GLOBAL_CONFIG_DEFAULTS };
338
+ const has = (key) => Object.prototype.hasOwnProperty.call(stored, key);
339
+ const merged = { ...GLOBAL_CONFIG_DEFAULTS, ...stored };
340
+ // Keys that belonged to the removed vision / image-generation bridge. The
341
+ // merge above keeps unknown stored keys for forward compatibility, so a file
342
+ // written by an older release would otherwise carry them forever. These are
343
+ // known-dead, so drop exactly them and leave every other unknown key alone.
344
+ for (const dead of REMOVED_MULTIMODAL_KEYS) delete merged[dead];
345
+ // Fields introduced by configurable Crew keep the prior release's behavior
346
+ // when an existing file predates them. Only a genuinely fresh config gets
347
+ // the new minimal defaults above.
348
+ if (!has('collaboration_mode')) {
349
+ merged.collaboration_mode = stored.tier_policy === 'flash-only' ? 'flash-only'
350
+ : stored.tier_policy === 'pro-only' ? 'pro-only' : 'balanced';
351
+ }
352
+ if (!has('flash_state') || !has('pro_state')) {
353
+ if (stored.tier_policy === 'flash-only') { merged.flash_state = 'auto'; merged.pro_state = 'disabled'; }
354
+ else if (stored.tier_policy === 'pro-only') { merged.flash_state = 'disabled'; merged.pro_state = 'auto'; }
355
+ else { merged.flash_state = 'auto'; merged.pro_state = 'auto'; }
356
+ }
357
+ if (!has('main_agent_mode')) merged.main_agent_mode = 'coordinator-first';
358
+ if (!has('worker_provider_mode')) merged.worker_provider_mode = 'deepseek-official';
359
+ // Existing configs that predate preset_flash should inherit the current safe
360
+ // Harness Default. The older implicit `minimal` migration is unsafe for
361
+ // workspace-targeted jobs on Windows; an explicit user-set `minimal` value
362
+ // is still preserved by the normal object merge above.
363
+ if (!has('preset_flash')) merged.preset_flash = 'default';
364
+ return merged;
365
+ }
366
+
367
+ export function readGlobalConfig({ configFile = GLOBAL_CONFIG_FILE } = {}) {
368
+ if (!existsSync(configFile)) return { ...GLOBAL_CONFIG_DEFAULTS };
369
+ return mergeStoredGlobalConfig(readJson(configFile, {}));
370
+ }
371
+
372
+ export function writeGlobalConfig(patch) {
373
+ mkdirSync(CONFIG_DIR_HOME(), { recursive: true });
374
+ const next = { ...readGlobalConfig() };
375
+ for (const [k, v] of Object.entries(patch ?? {})) {
376
+ if (v !== undefined && k in GLOBAL_CONFIG_DEFAULTS) next[k] = v;
377
+ }
378
+ for (const tier of ['flash', 'pro']) {
379
+ const key = `${tier}_model_priority`;
380
+ if (patch?.[key] !== undefined) {
381
+ next[key] = normalizeModelPriority(patch[key]);
382
+ next[`${tier}_model_priority_configured`] = true;
383
+ }
384
+ }
385
+ // Legacy clamp fields stay writable (session commands still read them), and
386
+ // a tier_policy write resyncs the new fields so old UIs can't drift apart
387
+ // from the new policy. Deprecated but harmless.
388
+ if (patch?.tier_policy !== undefined) {
389
+ next.tier_policy = patch.tier_policy;
390
+ if (patch.tier_policy === 'flash-only') { next.collaboration_mode = 'flash-only'; }
391
+ else if (patch.tier_policy === 'pro-only') { next.collaboration_mode = 'pro-only'; }
392
+ else if (next.collaboration_mode === 'flash-only' || next.collaboration_mode === 'pro-only') { next.collaboration_mode = 'balanced'; }
393
+ }
394
+ writeFileSync(GLOBAL_CONFIG_FILE, JSON.stringify(next, null, 2) + '\n');
395
+ return next;
396
+ }
397
+
398
+ /** What is currently installed where — drives the settings-page buttons. */
399
+ export function installStatus({ home = homedir(), root = ROOT, env = process.env } = {}) {
400
+ const settings = readJson(join(home, '.claude', 'settings.json'), {});
401
+ const enabled = settings.enabledPlugins;
402
+ const claudeInstalled = !!(enabled && !Array.isArray(enabled) && enabled[PLUGIN_KEY]);
403
+ const hudWired = typeof settings.statusLine?.command === 'string'
404
+ && settings.statusLine.command.includes('worker-segment.sh');
405
+ const marketplaceRoot = settings?.extraKnownMarketplaces?.[MARKETPLACE_NAME]?.source?.path;
406
+ const installedPluginRecord = readJson(join(home, '.claude', 'plugins', 'installed_plugins.json'), {})?.plugins?.[PLUGIN_KEY];
407
+ const claudeFootprint = claudeInstalled || typeof marketplaceRoot === 'string' || installedPluginRecord !== undefined;
408
+ const claudeComponents = {
409
+ enabled: claudeInstalled,
410
+ marketplace: normalizedPath(marketplaceRoot) === normalizedPath(root) && claudePluginRootReady(root),
411
+ snapshot: claudeSnapshotReady(home, root),
412
+ permissions: claudePermissionsReady(settings),
413
+ };
414
+ const claudeMissing = Object.entries(claudeComponents).filter(([, present]) => !present).map(([key]) => key);
415
+ const codexRoot = codexHomeDir(home, env);
416
+ const configFile = join(codexRoot, 'config.toml');
417
+ const configText = readText(configFile) ?? '';
418
+ const workerFile = join(codexRoot, 'agents', 'ds-worker.toml');
419
+ const reviewerFile = join(codexRoot, 'agents', 'ds-reviewer.toml');
420
+ const workerTarget = codexRoleTarget(workerFile, 'ds-worker');
421
+ const reviewerTarget = codexRoleTarget(reviewerFile, 'ds-reviewer');
422
+ const mcpTarget = codexMcpTarget(configText);
423
+ const expectedTarget = normalizedPath(join(root, 'src', 'server.mjs'));
424
+ const expectedWorkerRole = renderedCodexRole(root, 'ds-worker.toml');
425
+ const expectedReviewerRole = renderedCodexRole(root, 'ds-reviewer.toml');
426
+ const expectedConfigPrompt = readText(join(root, 'codex', 'prompts', 'dsh-config.md'));
427
+ const expectedStatusPrompt = readText(join(root, 'codex', 'prompts', 'dsh-status.md'));
428
+ const components = {
429
+ worker_role: expectedWorkerRole !== null && readText(workerFile) === expectedWorkerRole,
430
+ reviewer_role: expectedReviewerRole !== null && readText(reviewerFile) === expectedReviewerRole,
431
+ config_prompt: expectedConfigPrompt !== null && readText(join(codexRoot, 'prompts', 'dsh-config.md')) === expectedConfigPrompt,
432
+ status_prompt: expectedStatusPrompt !== null && readText(join(codexRoot, 'prompts', 'dsh-status.md')) === expectedStatusPrompt,
433
+ mcp: !!expectedTarget && mcpTarget === expectedTarget,
434
+ target_alignment: !!expectedTarget && workerTarget === expectedTarget && reviewerTarget === expectedTarget && mcpTarget === expectedTarget,
435
+ global_policy: globalCodexPolicyReady({ home, root, env }),
436
+ };
437
+ const codexInstalled = Object.values(components).some(Boolean)
438
+ || existsSync(join(codexRoot, 'agents', 'ds-flash.toml'))
439
+ || existsSync(join(codexRoot, 'agents', 'ds-pro.toml'));
440
+ const missing = Object.entries(components).filter(([, present]) => !present).map(([key]) => key);
441
+ return {
442
+ claude: {
443
+ installed: claudeFootprint,
444
+ ready: claudeMissing.length === 0,
445
+ hud: hudWired,
446
+ components: claudeComponents,
447
+ missing: claudeMissing,
448
+ },
449
+ codex: { installed: codexInstalled, ready: missing.length === 0, components, missing },
450
+ zcode: zcodeStatus({ home, root }),
451
+ };
452
+ }
453
+
454
+ export function uninstallCodex({ home = homedir(), env = process.env } = {}) {
455
+ const actions = [];
456
+ // Both the v0.2 roles (ds-worker / ds-reviewer) and the deprecated v0.1
457
+ // aliases (ds-flash / ds-pro) are dsh-crew managed; uninstall removes only
458
+ // these, never a user's own role files.
459
+ for (const f of ['ds-flash.toml', 'ds-pro.toml', 'ds-worker.toml', 'ds-reviewer.toml']) {
460
+ const p = join(codexHomeDir(home, env), 'agents', f);
461
+ if (existsSync(p)) { backup(p); rmSync(p); actions.push(`removed: ${p} (backup kept)`); }
462
+ }
463
+ for (const f of ['dsh-config.md', 'dsh-status.md']) {
464
+ const p = join(codexHomeDir(home, env), 'prompts', f);
465
+ if (existsSync(p)) { rmSync(p); actions.push(`removed: ${p}`); }
466
+ }
467
+ const policyAction = uninstallGlobalCodexPolicy({ home, env });
468
+ if (policyAction) actions.push(policyAction);
469
+ // Remove only the dsh-crew entry from [mcp_servers], keeping any other
470
+ // MCP servers the user configured.
471
+ const configFile = join(codexHomeDir(home, env), 'config.toml');
472
+ if (existsSync(configFile)) {
473
+ const before = readFileSync(configFile, 'utf8');
474
+ const lineRe = /(^|\n)[ \t]*dsh-crew\s*=\s*\{[^\n]*\}/;
475
+ const after = lineRe.test(before) ? before.replace(lineRe, '') : before;
476
+ if (after !== before) {
477
+ backup(configFile);
478
+ writeFileSync(configFile, after.replace(/(^|\n){2,}/g, '\n\n').replace(/^\[mcp_servers\]\n?$/, ''));
479
+ actions.push(`codex config: removed dsh-crew MCP entry`);
480
+ }
481
+ }
482
+ return { ok: true, actions: actions.length ? actions : ['nothing to remove'] };
483
+ }
484
+
485
+ export async function installClaudeCode({ home = homedir(), statusline = false, root = ROOT } = {}) {
486
+ const actions = [];
487
+
488
+ // The repository carries its own marketplace manifest (.claude-plugin/
489
+ // marketplace.json with source "."), so the marketplace root IS the plugin
490
+ // checkout itself — no parent-directory layout assumption. This keeps the
491
+ // installer working when the repo is cloned anywhere (e.g. Desktop\dsh-crew).
492
+ // `root` lets an npx-managed install point every rendered path at the
493
+ // durable Crew-owned payload instead of a transient package extraction dir.
494
+ const mpDir = root;
495
+ actions.push(`marketplace: ${mpDir} (self-marketplace, source .)`);
496
+
497
+ // 2. settings.json: marketplace + enabledPlugins + permissions allowlist.
498
+ const settingsFile = join(home, '.claude', 'settings.json');
499
+ mkdirSync(dirname(settingsFile), { recursive: true });
500
+ const bak = backup(settingsFile);
501
+ if (bak) actions.push(`backup: ${bak}`);
502
+ const settings = readJson(settingsFile, {});
503
+
504
+ // Both fields are records (see json.schemastore.org/claude-code-settings.json).
505
+ // Older versions of this installer wrote arrays, which Claude Code ignores
506
+ // with a warning — migrate those in place.
507
+ const markets = (settings.extraKnownMarketplaces && !Array.isArray(settings.extraKnownMarketplaces)
508
+ && typeof settings.extraKnownMarketplaces === 'object') ? settings.extraKnownMarketplaces : {};
509
+ if (Array.isArray(settings.extraKnownMarketplaces)) actions.push('migrated legacy extraKnownMarketplaces array');
510
+ markets[MARKETPLACE_NAME] = { source: { source: 'directory', path: mpDir } };
511
+ if (markets['dsh-workers']) {
512
+ delete markets['dsh-workers'];
513
+ actions.push('removed pre-rename dsh-workers marketplace entry');
514
+ }
515
+ settings.extraKnownMarketplaces = markets;
516
+
517
+ const enabled = (settings.enabledPlugins && !Array.isArray(settings.enabledPlugins)
518
+ && typeof settings.enabledPlugins === 'object') ? settings.enabledPlugins : {};
519
+ if (Array.isArray(settings.enabledPlugins)) {
520
+ for (const key of settings.enabledPlugins) if (typeof key === 'string') enabled[key] = true;
521
+ actions.push('migrated legacy enabledPlugins array');
522
+ }
523
+ enabled[PLUGIN_KEY] = true;
524
+ if (enabled['dsh-workers@dsh-workers']) {
525
+ delete enabled['dsh-workers@dsh-workers'];
526
+ actions.push('removed pre-rename dsh-workers plugin entry');
527
+ }
528
+ settings.enabledPlugins = enabled;
529
+
530
+ settings.permissions = settings.permissions ?? {};
531
+ let allow = Array.isArray(settings.permissions.allow) ? settings.permissions.allow : [];
532
+ const preRename = allow.filter((r) => typeof r === 'string' && r.startsWith('mcp__plugin_dsh-workers_'));
533
+ if (preRename.length) {
534
+ allow = allow.filter((r) => !preRename.includes(r));
535
+ actions.push(`removed ${preRename.length} pre-rename permission rules`);
536
+ }
537
+ for (const tool of MCP_TOOLS) {
538
+ const rule = `mcp__plugin_dsh-crew_dsh-crew__${tool}`;
539
+ if (!allow.includes(rule)) allow.push(rule);
540
+ }
541
+ settings.permissions.allow = allow;
542
+
543
+ if (statusline && !settings.statusLine) {
544
+ settings.statusLine = { type: 'command', command: `bash ${join(root, 'statusline', 'statusline.sh')}` };
545
+ actions.push('statusline: installed');
546
+ } else if (statusline) {
547
+ actions.push('statusline: skipped (one already configured)');
548
+ }
549
+
550
+ writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + '\n');
551
+ actions.push(`settings: registered ${PLUGIN_KEY} + ${MCP_TOOLS.length} permission rules`);
552
+
553
+ // Materialize the install through the claude CLI: registers the marketplace
554
+ // in the plugin cache (settings alone leave a stale/absent cache entry) and
555
+ // pulls the plugin so the next session loads it. Best-effort: settings are
556
+ // already correct, so a missing CLI just means one manual `plugin install`.
557
+ const registered = readJson(join(home, '.claude', 'plugins', 'known_marketplaces.json'), {})?.[MARKETPLACE_NAME];
558
+ const installedRecord = readJson(join(home, '.claude', 'plugins', 'installed_plugins.json'), {})?.plugins?.[PLUGIN_KEY];
559
+ const installedEntries = Array.isArray(installedRecord) ? installedRecord : [installedRecord];
560
+ const marketplaceCurrent = registered?.source?.source === 'directory'
561
+ && normalizedPath(registered.source.path) === normalizedPath(root)
562
+ && normalizedPath(registered.installLocation) === normalizedPath(root);
563
+ if (marketplaceCurrent && installedEntries.some((entry) => entry?.scope === 'user'
564
+ && sameManagedClaudeFiles(root, entry.installPath))) {
565
+ actions.push('cli: skipped (registered marketplace and snapshot already current)');
566
+ return { ok: true, actions };
567
+ }
568
+ if (home !== homedir()) {
569
+ actions.push('cli: skipped (non-default home; test mode)');
570
+ return { ok: true, actions };
571
+ }
572
+ try {
573
+ const { execSync } = await import('node:child_process');
574
+ const run = (cmd) => execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 120_000 });
575
+ try { run(`claude plugin marketplace add ${JSON.stringify(mpDir)}`); actions.push('cli: marketplace registered'); }
576
+ catch { actions.push('cli: marketplace add skipped (already registered)'); }
577
+ // `plugin install` on an already-installed plugin is a no-op and leaves a
578
+ // stale snapshot in ~/.claude/plugins/cache — uninstall first so an update
579
+ // always re-copies the current code.
580
+ try { run(`claude plugin uninstall ${PLUGIN_KEY}`); } catch {}
581
+ // Newer Claude Code (>= 2.1.x) dropped the -y flag; older builds accepted
582
+ // it. Try without it first, fall back to the legacy flag.
583
+ try {
584
+ run(`claude plugin install ${PLUGIN_KEY} --scope user`);
585
+ } catch (errNoFlag) {
586
+ if (!String(errNoFlag?.message ?? '').includes('unknown option')) throw errNoFlag;
587
+ run(`claude plugin install ${PLUGIN_KEY} --scope user -y`);
588
+ }
589
+ actions.push(`cli: plugin snapshot refreshed (${PLUGIN_KEY})`);
590
+ } catch (err) {
591
+ actions.push(`cli: plugin install failed — run manually: claude plugin install ${PLUGIN_KEY} (${String(err?.message ?? err).slice(0, 120)})`);
592
+ }
593
+ return { ok: true, actions };
594
+ }
595
+
596
+ export function installCodex({ home = homedir(), scope, root = ROOT, env = process.env } = {}) {
597
+ const actions = [];
598
+ const agentsDir = scope === 'project' ? join(process.cwd(), '.codex', 'agents') : join(codexHomeDir(home, env), 'agents');
599
+ mkdirSync(agentsDir, { recursive: true });
600
+ const srcDir = join(root, 'codex', 'agents');
601
+ // Windows drive paths must render with forward slashes: backslashes in a
602
+ // TOML basic string are escape sequences (\\U\\u... are invalid), which made
603
+ // the role files unparseable for Codex Desktop. node accepts forward slashes
604
+ // on Windows, so the absolute path rendered as D:/... is both valid TOML and
605
+ // runnable. `root` targets the durable installed payload for npx installs.
606
+ const renderedPath = join(root, 'src', 'server.mjs').replace(/\\/g, '/');
607
+ for (const f of readdirSync(srcDir).filter((f) => f.endsWith('.toml'))) {
608
+ const dest = join(agentsDir, f);
609
+ const bak = backup(dest);
610
+ if (bak) actions.push(`backup: ${bak}`);
611
+ const rendered = renderedCodexRole(root, f);
612
+ writeFileSync(dest, rendered);
613
+ actions.push(`role: ${dest}`);
614
+ }
615
+ const promptsDir = scope === 'project' ? join(process.cwd(), '.codex', 'prompts') : join(codexHomeDir(home, env), 'prompts');
616
+ mkdirSync(promptsDir, { recursive: true });
617
+ const promptsSrc = join(root, 'codex', 'prompts');
618
+ for (const f of readdirSync(promptsSrc).filter((f) => f.endsWith('.md'))) {
619
+ writeFileSync(join(promptsDir, f), readFileSync(join(promptsSrc, f), 'utf8'));
620
+ actions.push(`prompt: ${join(promptsDir, f)}`);
621
+ }
622
+ if (scope !== 'project') {
623
+ const act = writeGlobalCodexMcpServer(home, renderedPath, env);
624
+ actions.push(...act);
625
+ const policy = installGlobalCodexPolicy({ home, root, env });
626
+ if (!policy.ok) return { ok: false, actions: [...actions, policy.action] };
627
+ actions.push(policy.action);
628
+ }
629
+ return { ok: true, actions };
630
+ }
631
+
632
+ /**
633
+ * Codex Desktop reads the shared config at ~/.codex/config.toml, and the role
634
+ * files alone only expose dsh MCP tools inside the ds-flash/ds-pro subagents.
635
+ * To make dsh_worker_config (policy) visible to the MAIN Codex session, add a
636
+ * top-level [mcp_servers] entry under dsh-crew — preserving any other servers
637
+ * the user already configured. Idempotent (updates in place). Never requires
638
+ * the codex CLI.
639
+ */
640
+ export function writeGlobalCodexMcpServer(home, renderedPath, env = process.env) {
641
+ const configFile = join(codexHomeDir(home, env), 'config.toml');
642
+ const entry = `dsh-crew = { command = "node", args = ["${renderedPath}"] }`;
643
+ const backupFile = backup(configFile);
644
+ const existing = existsSync(configFile) ? readFileSync(configFile, 'utf8') : '';
645
+ const hasSection = /(^|\n)\s*\[mcp_servers\]/.test(existing);
646
+ const sectionIdx = hasSection ? existing.search(/(^|\n)\s*\[mcp_servers\]/) : -1;
647
+
648
+ let next;
649
+ if (hasSection) {
650
+ // Section exists: find its extent (next top-level [section]) and patch the
651
+ // dsh-crew entry within it.
652
+ const afterHeader = sectionIdx + existing.slice(sectionIdx).indexOf(']') + 1;
653
+ const rest = existing.slice(afterHeader);
654
+ const nextHeader = rest.search(/(^|\n)\s*\[[^\]]+\]/);
655
+ const sectionBody = nextHeader === -1 ? rest : rest.slice(0, nextHeader);
656
+ const tail = nextHeader === -1 ? '' : rest.slice(nextHeader);
657
+ const lineRe = /(^|\n)([ \t]*)dsh-crew\s*=\s*\{[^\n]*\}/;
658
+ const patched = lineRe.test(sectionBody)
659
+ ? sectionBody.replace(lineRe, `$1$2${entry}`)
660
+ : `${sectionBody.replace(/\s*$/, '')}\n${entry}`;
661
+ next = existing.slice(0, afterHeader) + patched + tail;
662
+ } else {
663
+ // No [mcp_servers] section: append a fresh one.
664
+ const sep = existing && !/[\n]$/.test(existing) ? '\n' : '';
665
+ next = `${existing}${sep}[mcp_servers]\n${entry}\n`;
666
+ }
667
+ writeFileSync(configFile, next);
668
+ const actions = [];
669
+ if (backupFile) actions.push(`config backup: ${backupFile}`);
670
+ actions.push(`codex config: [mcp_servers] dsh-crew → ${renderedPath}`);
671
+ return actions;
672
+ }
673
+
674
+ export function installHudSegment({ home = homedir() } = {}) {
675
+ const settingsFile = join(home, '.claude', 'settings.json');
676
+ const settings = readJson(settingsFile, null);
677
+ if (!settings) return { ok: false, actions: ['settings.json not found'] };
678
+ const cmd = settings.statusLine?.command;
679
+ if (typeof cmd !== 'string' || !cmd.includes('claude-hud')) {
680
+ return { ok: false, actions: ['statusLine is not claude-hud; use statusline/statusline.sh directly instead'] };
681
+ }
682
+ const segment = join(ROOT, 'statusline', 'worker-segment.sh');
683
+ if (cmd.includes(segment)) return { ok: true, actions: ['already wired'] };
684
+ if (cmd.includes('worker-segment.sh')) {
685
+ // Wired to a stale copy (e.g. pre-rename path) — repoint the --extra-cmd at the current segment.
686
+ const next = cmd.replace(/--extra-cmd "bash [^"]*worker-segment\.sh"/, `--extra-cmd "bash ${segment}"`);
687
+ if (next === cmd) return { ok: false, actions: ['worker-segment.sh present but path not replaceable; rewire manually'] };
688
+ const bak = backup(settingsFile);
689
+ settings.statusLine.command = next;
690
+ writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + '\n');
691
+ return { ok: true, actions: [...(bak ? [`backup: ${bak}`] : []), `statusLine: repointed worker-segment.sh to ${segment}`] };
692
+ }
693
+ let next = cmd;
694
+ if (!next.includes('CLAUDE_HUD_ALLOW_EXTRA_CMD')) {
695
+ next = next.replace(/exec\s+/, 'exec env CLAUDE_HUD_ALLOW_EXTRA_CMD=1 ');
696
+ if (!next.includes('CLAUDE_HUD_ALLOW_EXTRA_CMD')) return { ok: false, actions: ['could not find exec in statusLine command; wire manually'] };
697
+ }
698
+ const extra = ` --extra-cmd "bash ${segment}"`;
699
+ if (next.endsWith("'")) next = next.slice(0, -1) + extra + "'";
700
+ else next += extra;
701
+ const bak = backup(settingsFile);
702
+ settings.statusLine.command = next;
703
+ writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + '\n');
704
+ return { ok: true, actions: [...(bak ? [`backup: ${bak}`] : []), 'statusLine: claude-hud now runs worker-segment.sh via --extra-cmd'] };
705
+ }
706
+
707
+ export function uninstallClaudeCode({ home = homedir() } = {}) {
708
+ const settingsFile = join(home, '.claude', 'settings.json');
709
+ const settings = readJson(settingsFile, null);
710
+ if (!settings) return { ok: true, actions: ['settings.json not found'] };
711
+ backup(settingsFile);
712
+ const mpDir = join(home, '.config', 'dsh-crew', 'marketplace');
713
+ if (Array.isArray(settings.extraKnownMarketplaces)) {
714
+ settings.extraKnownMarketplaces = settings.extraKnownMarketplaces.filter((m) => m?.path !== mpDir);
715
+ } else if (settings.extraKnownMarketplaces) {
716
+ delete settings.extraKnownMarketplaces[MARKETPLACE_NAME];
717
+ }
718
+ if (Array.isArray(settings.enabledPlugins)) {
719
+ settings.enabledPlugins = settings.enabledPlugins.filter((p) => p !== PLUGIN_KEY);
720
+ } else if (settings.enabledPlugins) {
721
+ delete settings.enabledPlugins[PLUGIN_KEY];
722
+ }
723
+ if (settings.permissions?.allow) {
724
+ settings.permissions.allow = settings.permissions.allow.filter((r) => !r.startsWith('mcp__plugin_dsh-crew_'));
725
+ }
726
+ writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + '\n');
727
+ return { ok: true, actions: ['unregistered from settings.json (backup kept)'] };
728
+ }