@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.
- package/.claude-plugin/plugin.json +1 -1
- package/lib/client.js +2 -905
- package/official-web-bridge/lib/client.js +1 -71
- package/package.json +1 -1
- package/src/client/collapsible-sections.mjs +5 -7
- package/src/client/index.tsx +3 -357
- package/src/client/quick-entry.tsx +1 -1
- package/src/client/quick-panel.tsx +3 -38
- package/src/client/surface-detection.mjs +1 -1
- package/src/hub/index.mjs +1 -76
- package/src/i18n.mjs +1 -1
- package/src/install/install-legacy.mjs +728 -739
- package/src/policy-legacy.mjs +1 -51
- package/src/provider-inventory.mjs +1 -3
- package/src/runtime-controls.mjs +1 -7
- package/src/runtime-identity.mjs +1 -1
- package/src/server.mjs +1 -2
- package/src/image-output.mjs +0 -71
- package/src/multimodal.mjs +0 -686
- package/src/vision-route.mjs +0 -138
|
@@ -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
|
-
//
|
|
300
|
-
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
//
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
if (!has('
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
if (patch
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
const
|
|
415
|
-
|
|
416
|
-
const
|
|
417
|
-
const
|
|
418
|
-
const
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
const
|
|
426
|
-
const
|
|
427
|
-
const
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
const
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
//
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
const
|
|
476
|
-
if (
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
//
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
settings.permissions =
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
actions.push('cli:
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
const
|
|
618
|
-
for (const f of readdirSync(
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
actions.push(
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
const
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
return { ok:
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
if (
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
const
|
|
710
|
-
if (
|
|
711
|
-
|
|
712
|
-
const
|
|
713
|
-
settings.
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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
|
+
}
|