@ran-sh/dsh-crew 0.5.4 → 0.5.5

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,316 +1,366 @@
1
- // ZCode host integration. This module is deliberately file-only: it never
2
- // launches ZCode, mutates credentials, or overwrites an unowned MCP server.
3
- // The generated files make ZCode dispatch through the same dsh-crew MCP server
4
- // used by Codex and Claude while keeping ZCode's native config precedence.
5
-
6
- import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
7
- import { createHash } from 'node:crypto';
8
- import { dirname, join, resolve } from 'node:path';
9
- import { fileURLToPath } from 'node:url';
10
- import { homedir } from 'node:os';
11
-
12
- const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
13
- const HOST = 'zcode';
14
- const SERVER = 'dsh-crew';
15
- const POLICY_START = '<!-- DSH CREW MANAGED ZCODE POLICY:START -->';
16
- const POLICY_END = '<!-- DSH CREW MANAGED ZCODE POLICY:END -->';
17
- const OWNERSHIP_FILE = ({ home = homedir() } = {}) => join(home, '.config', 'dsh-crew', 'integrations', 'zcode.json');
18
-
19
- // Keep the allowlist explicit. ZCode rejects wildcard tool permissions, and a
20
- // future server tool is not silently exposed until a template intentionally
21
- // opts into it.
22
- export const ZCODE_MCP_TOOLS = Object.freeze([
23
- 'dsh_run_worker',
24
- 'dsh_spawn_worker',
25
- 'dsh_worker_status',
26
- 'dsh_worker_result',
27
- 'dsh_worker_cancel',
28
- 'dsh_worker_config',
29
- ]);
30
-
31
- function readText(file) {
32
- try { return readFileSync(file, 'utf8'); } catch { return null; }
33
- }
34
-
35
- function readJson(file, fallback = null) {
36
- try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return fallback; }
37
- }
38
-
39
- function contentHash(text) {
40
- return createHash('sha256').update(text, 'utf8').digest('hex');
41
- }
42
-
43
- function writeJson(file, value) {
44
- mkdirSync(dirname(file), { recursive: true });
45
- writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
46
- }
47
-
48
- function backup(file) {
49
- if (!existsSync(file)) return null;
50
- const path = `${file}.dsh-crew-backup-${Date.now()}`;
51
- copyFileSync(file, path);
52
- return path;
53
- }
54
-
55
- function normalizePath(value) {
56
- if (typeof value !== 'string' || !value.trim()) return null;
57
- try {
58
- const path = resolve(value);
59
- return process.platform === 'win32' ? path.toLowerCase() : path;
60
- } catch { return null; }
61
- }
62
-
63
- function serverTarget(server) {
64
- if (!server || typeof server !== 'object') return null;
65
- if (server.command !== 'node' || !Array.isArray(server.args) || typeof server.args[0] !== 'string') return null;
66
- return normalizePath(server.args[0]);
67
- }
68
-
69
- function nativeServers(config) {
70
- const servers = config?.mcp?.servers;
71
- return servers && typeof servers === 'object' && !Array.isArray(servers) ? servers : {};
72
- }
73
-
74
- function sharedServers(config) {
75
- const servers = config?.mcpServers;
76
- return servers && typeof servers === 'object' && !Array.isArray(servers) ? servers : {};
77
- }
78
-
79
- function configSource({ home = homedir() } = {}) {
80
- const nativeFile = join(home, '.zcode', 'cli', 'config.json');
81
- const sharedFile = join(home, '.agents', 'mcp.json');
82
- const native = readJson(nativeFile, {});
83
- const shared = readJson(sharedFile, {});
84
- const nativeMap = nativeServers(native);
85
- const sharedMap = sharedServers(shared);
86
- // ZCode shadows .agents/mcp.json whenever its native config has at least
87
- // one server. If native is empty, the shared file is the least-surprising
88
- // compatibility path for users who already manage MCP centrally.
89
- if (Object.keys(nativeMap).length > 0 || Object.keys(sharedMap).length === 0) {
90
- return { file: nativeFile, kind: 'native', config: native, servers: nativeMap };
91
- }
92
- return { file: sharedFile, kind: 'shared', config: shared, servers: sharedMap };
93
- }
94
-
95
- export function resolveZCodeMcpTarget({ home = homedir() } = {}) {
96
- const source = configSource({ home });
97
- return serverTarget(source.servers[SERVER]);
98
- }
99
-
100
- function expectedTarget({ root = ROOT } = {}) {
101
- return resolve(join(root, 'src', 'server.mjs'));
102
- }
103
-
104
- function managedPolicyBlock(root) {
105
- const template = readText(join(root, 'zcode', 'AGENTS.md'))?.trim();
106
- return template ? `${POLICY_START}\n${template}\n${POLICY_END}` : null;
107
- }
108
-
109
- function installPolicy({ home, root }) {
110
- const file = join(home, '.zcode', 'AGENTS.md');
111
- const block = managedPolicyBlock(root);
112
- if (!block) return { ok: false, code: 'ZCODE_POLICY_TEMPLATE_MISSING' };
113
- mkdirSync(dirname(file), { recursive: true });
114
- const current = readText(file) ?? '';
115
- const escapedStart = POLICY_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
116
- const escapedEnd = POLICY_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
117
- const managed = new RegExp(`${escapedStart}[\\s\\S]*?${escapedEnd}`, 'm');
118
- const next = managed.test(current)
119
- ? current.replace(managed, block)
120
- : `${current.trimEnd()}${current.trim() ? '\n\n' : ''}${block}\n`;
121
- if (next !== current) { backup(file); writeFileSync(file, next); }
122
- return { ok: true, file };
123
- }
124
-
125
- function removePolicy({ home }) {
126
- const file = join(home, '.zcode', 'AGENTS.md');
127
- const current = readText(file);
128
- if (typeof current !== 'string') return false;
129
- const start = POLICY_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
130
- const end = POLICY_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
131
- const managed = new RegExp(`(?:\\r?\\n){0,2}${start}[\\s\\S]*?${end}(?:\\r?\\n)?`, 'm');
132
- if (!managed.test(current)) return false;
133
- const next = current.replace(managed, '').trimEnd();
134
- backup(file);
135
- if (next.trim()) writeFileSync(file, `${next}\n`);
136
- else rmSync(file, { force: true });
137
- return true;
138
- }
139
-
140
- function ownership({ home = homedir() } = {}) {
141
- const data = readJson(OWNERSHIP_FILE({ home }), null);
142
- return data && typeof data === 'object' && data.host === HOST ? data : null;
143
- }
144
-
145
- function writeOwnership({ home, configFile, configKind, target, files = [] }) {
146
- writeJson(OWNERSHIP_FILE({ home }), {
147
- schema_version: 1,
148
- host: HOST,
149
- server: SERVER,
150
- config_file: configFile,
151
- config_kind: configKind,
152
- target,
153
- files,
154
- managed_at: new Date().toISOString(),
155
- });
156
- }
157
-
158
- function templateFiles({ home, root }) {
159
- return [
160
- ['agents', 'ds-worker.md'],
161
- ['agents', 'ds-reviewer.md'],
162
- ['commands', 'dsh-config.md'],
163
- ['commands', 'dsh-status.md'],
164
- ].map(([dir, file]) => ({ source: join(root, 'zcode', dir, file), dest: join(home, '.zcode', dir, file) }));
165
- }
166
-
167
- function installTemplates({ home, root, priorFiles = [] }) {
168
- const actions = [];
169
- const records = [];
170
- for (const { source, dest } of templateFiles({ home, root })) {
171
- if (!existsSync(source)) return { ok: false, code: 'ZCODE_TEMPLATE_MISSING', source };
172
- mkdirSync(dirname(dest), { recursive: true });
173
- const before = readText(dest);
174
- const rendered = readFileSync(source, 'utf8');
175
- const previous = priorFiles.find((entry) => entry?.path === dest);
176
- let backupFile = previous?.backup ?? null;
177
- if (before !== rendered) {
178
- if (before !== null && !backupFile) backupFile = backup(dest);
179
- writeFileSync(dest, rendered);
180
- }
181
- actions.push(dest);
182
- records.push({
183
- path: dest,
184
- preexisting: previous?.preexisting === true || (previous === undefined && before !== null),
185
- ...(backupFile ? { backup: backupFile } : {}),
186
- managed_sha256: contentHash(rendered),
187
- });
188
- }
189
- return { ok: true, actions, records };
190
- }
191
-
192
- function updateMcp({ home, root }) {
193
- const source = configSource({ home });
194
- const target = expectedTarget({ root });
195
- const current = source.servers[SERVER];
196
- const currentTarget = serverTarget(current);
197
- const owned = ownership({ home });
198
- if (current && currentTarget !== target) {
199
- // Only an exact previously-owned entry may be repaired. Any foreign
200
- // command, even if it is also named dsh-crew, is a hard collision.
201
- if (!(owned && owned.config_file === source.file && normalizePath(owned.target) === currentTarget)) {
202
- return { ok: false, code: 'ZCODE_MCP_COLLISION', config_file: source.file };
203
- }
204
- }
205
-
206
- const next = { ...source.config };
207
- if (source.kind === 'native') next.mcp = { ...(next.mcp ?? {}), servers: { ...source.servers, [SERVER]: { command: 'node', args: [target] } } };
208
- else next.mcpServers = { ...source.servers, [SERVER]: { command: 'node', args: [target] } };
209
- const changed = JSON.stringify(next) !== JSON.stringify(source.config);
210
- if (changed) { backup(source.file); writeJson(source.file, next); }
211
- return { ok: true, changed, config_file: source.file, config_kind: source.kind, target };
212
- }
213
-
214
- export function installZCode({ home = homedir(), root = ROOT } = {}) {
215
- const templates = templateFiles({ home, root });
216
- const missing = templates.find(({ source }) => !existsSync(source));
217
- if (missing) return { ok: false, code: 'ZCODE_TEMPLATE_MISSING', source: missing.source };
218
- const priorFiles = ownership({ home })?.files ?? [];
219
- // Check for collisions before writing any other integration surface so a
220
- // failed install is transaction-like and leaves user files untouched.
221
- const mcp = updateMcp({ home, root });
222
- if (!mcp.ok) return mcp;
223
- const policy = installPolicy({ home, root });
224
- if (!policy.ok) return policy;
225
- const installed = installTemplates({ home, root, priorFiles });
226
- if (!installed.ok) return installed;
227
- writeOwnership({
228
- home,
229
- configFile: mcp.config_file,
230
- configKind: mcp.config_kind,
231
- target: mcp.target,
232
- files: installed.records,
233
- });
234
- return { ok: true, ...mcp, policy_file: policy.file, files: installed.actions };
235
- }
236
-
237
- function zcodeComponents({ home = homedir(), root = ROOT } = {}) {
238
- const expected = normalizePath(expectedTarget({ root }));
239
- const source = configSource({ home });
240
- const configured = serverTarget(source.servers[SERVER]);
241
- const owned = ownership({ home });
242
- const components = {
243
- mcp: configured === expected,
244
- policy: typeof readText(join(home, '.zcode', 'AGENTS.md')) === 'string' && readText(join(home, '.zcode', 'AGENTS.md')).includes(POLICY_START),
245
- worker_agent: existsSync(join(home, '.zcode', 'agents', 'ds-worker.md')),
246
- reviewer_agent: existsSync(join(home, '.zcode', 'agents', 'ds-reviewer.md')),
247
- config_prompt: existsSync(join(home, '.zcode', 'commands', 'dsh-config.md')),
248
- status_prompt: existsSync(join(home, '.zcode', 'commands', 'dsh-status.md')),
249
- ownership: !!owned && owned.config_file === source.file && normalizePath(owned.target) === expected,
250
- };
251
- return { components, source, expected, configured, owned };
252
- }
253
-
254
- export function zcodeStatus({ home = homedir(), root = ROOT } = {}) {
255
- const { components, source, expected, configured } = zcodeComponents({ home, root });
256
- const missing = Object.entries(components).filter(([, value]) => !value).map(([key]) => key);
257
- const installed = Object.values(components).some(Boolean);
258
- return {
259
- installed,
260
- ready: missing.length === 0,
261
- components,
262
- missing,
263
- config_file: source.file,
264
- config_kind: source.kind,
265
- target: configured ?? expected,
266
- };
267
- }
268
-
269
- export function uninstallZCode({ home = homedir() } = {}) {
270
- const actions = [];
271
- const owned = ownership({ home });
272
- if (owned) {
273
- const file = owned.config_file;
274
- const config = readJson(file, null);
275
- if (config && typeof config === 'object') {
276
- const kind = owned.config_kind === 'shared' ? 'shared' : 'native';
277
- const servers = kind === 'shared' ? sharedServers(config) : nativeServers(config);
278
- const current = servers[SERVER];
279
- if (normalizePath(owned.target) === serverTarget(current)) {
280
- const next = { ...config };
281
- if (kind === 'shared') {
282
- next.mcpServers = { ...servers }; delete next.mcpServers[SERVER];
283
- if (Object.keys(next.mcpServers).length === 0) delete next.mcpServers;
284
- } else {
285
- next.mcp = { ...(next.mcp ?? {}), servers: { ...servers } }; delete next.mcp.servers[SERVER];
286
- if (Object.keys(next.mcp.servers).length === 0) delete next.mcp.servers;
287
- if (Object.keys(next.mcp).length === 0) delete next.mcp;
288
- }
289
- backup(file); writeJson(file, next); actions.push(`mcp: removed ${SERVER} from ${file}`);
290
- }
291
- }
292
- rmSync(OWNERSHIP_FILE({ home }), { force: true });
293
- }
294
- if (removePolicy({ home })) actions.push('policy: removed managed ZCode block');
295
- const managedFiles = Array.isArray(owned?.files) && owned.files.length
296
- ? owned.files
297
- : ['agents/ds-worker.md', 'agents/ds-reviewer.md', 'commands/dsh-config.md', 'commands/dsh-status.md']
298
- .map((rel) => ({ path: join(home, '.zcode', rel) }));
299
- for (const entry of managedFiles) {
300
- const file = entry?.path;
301
- if (!file || !existsSync(file)) continue;
302
- const current = readText(file);
303
- const unchanged = !entry.managed_sha256 || (current !== null && contentHash(current) === entry.managed_sha256);
304
- if (!unchanged) continue;
305
- if (entry.preexisting && entry.backup && existsSync(entry.backup)) {
306
- copyFileSync(entry.backup, file);
307
- actions.push(`restored: ${file}`);
308
- } else {
309
- rmSync(file, { force: true });
310
- actions.push(`removed: ${file}`);
311
- }
312
- }
313
- return { ok: true, actions };
314
- }
315
-
316
- export function zcodeOwnershipFile({ home = homedir() } = {}) { return OWNERSHIP_FILE({ home }); }
1
+ // ZCode host integration. This module is deliberately file-only: it never
2
+ // launches ZCode, mutates credentials, or overwrites an unowned MCP server.
3
+ // The generated files make ZCode dispatch through the same dsh-crew MCP server
4
+ // used by Codex and Claude while keeping ZCode's native config precedence.
5
+
6
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
7
+ import { createHash } from 'node:crypto';
8
+ import { dirname, join, resolve } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { homedir } from 'node:os';
11
+
12
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
13
+ const HOST = 'zcode';
14
+ const SERVER = 'dsh-crew';
15
+ const POLICY_START = '<!-- DSH CREW MANAGED ZCODE POLICY:START -->';
16
+ const POLICY_END = '<!-- DSH CREW MANAGED ZCODE POLICY:END -->';
17
+ const OWNERSHIP_FILE = ({ home = homedir() } = {}) => join(home, '.config', 'dsh-crew', 'integrations', 'zcode.json');
18
+
19
+ // Keep the allowlist explicit. ZCode rejects wildcard tool permissions, and a
20
+ // future server tool is not silently exposed until a template intentionally
21
+ // opts into it.
22
+ export const ZCODE_MCP_TOOLS = Object.freeze([
23
+ 'dsh_run_worker',
24
+ 'dsh_spawn_worker',
25
+ 'dsh_worker_status',
26
+ 'dsh_worker_result',
27
+ 'dsh_worker_cancel',
28
+ 'dsh_worker_config',
29
+ ]);
30
+
31
+ function readText(file) {
32
+ try { return readFileSync(file, 'utf8'); } catch { return null; }
33
+ }
34
+
35
+ function readJson(file, fallback = null) {
36
+ try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return fallback; }
37
+ }
38
+
39
+ function contentHash(text) {
40
+ return createHash('sha256').update(text, 'utf8').digest('hex');
41
+ }
42
+
43
+ function writeJson(file, value) {
44
+ mkdirSync(dirname(file), { recursive: true });
45
+ writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
46
+ }
47
+
48
+ function backup(file) {
49
+ if (!existsSync(file)) return null;
50
+ const path = `${file}.dsh-crew-backup-${Date.now()}`;
51
+ copyFileSync(file, path);
52
+ return path;
53
+ }
54
+
55
+ function normalizePath(value) {
56
+ if (typeof value !== 'string' || !value.trim()) return null;
57
+ try {
58
+ const path = resolve(value);
59
+ return process.platform === 'win32' ? path.toLowerCase() : path;
60
+ } catch { return null; }
61
+ }
62
+
63
+ function serverTarget(server) {
64
+ if (!server || typeof server !== 'object') return null;
65
+ if (server.command !== 'node' || !Array.isArray(server.args) || typeof server.args[0] !== 'string') return null;
66
+ return normalizePath(server.args[0]);
67
+ }
68
+
69
+ function nativeServers(config) {
70
+ const servers = config?.mcp?.servers;
71
+ return servers && typeof servers === 'object' && !Array.isArray(servers) ? servers : {};
72
+ }
73
+
74
+ function sharedServers(config) {
75
+ const servers = config?.mcpServers;
76
+ return servers && typeof servers === 'object' && !Array.isArray(servers) ? servers : {};
77
+ }
78
+
79
+ function configSource({ home = homedir() } = {}) {
80
+ const nativeFile = join(home, '.zcode', 'cli', 'config.json');
81
+ const sharedFile = join(home, '.agents', 'mcp.json');
82
+ const native = readJson(nativeFile, {});
83
+ const shared = readJson(sharedFile, {});
84
+ const nativeMap = nativeServers(native);
85
+ const sharedMap = sharedServers(shared);
86
+ // ZCode shadows .agents/mcp.json whenever its native config has at least
87
+ // one server. If native is empty, the shared file is the least-surprising
88
+ // compatibility path for users who already manage MCP centrally.
89
+ if (Object.keys(nativeMap).length > 0 || Object.keys(sharedMap).length === 0) {
90
+ return { file: nativeFile, kind: 'native', config: native, servers: nativeMap };
91
+ }
92
+ return { file: sharedFile, kind: 'shared', config: shared, servers: sharedMap };
93
+ }
94
+
95
+ export function resolveZCodeMcpTarget({ home = homedir() } = {}) {
96
+ const source = configSource({ home });
97
+ return serverTarget(source.servers[SERVER]);
98
+ }
99
+
100
+ function expectedTarget({ root = ROOT } = {}) {
101
+ return resolve(join(root, 'src', 'server.mjs'));
102
+ }
103
+
104
+ function managedPolicyBlock(root) {
105
+ const template = readText(join(root, 'zcode', 'AGENTS.md'))?.trim();
106
+ return template ? `${POLICY_START}\n${template}\n${POLICY_END}` : null;
107
+ }
108
+
109
+ function installPolicy({ home, root }) {
110
+ const file = join(home, '.zcode', 'AGENTS.md');
111
+ const block = managedPolicyBlock(root);
112
+ if (!block) return { ok: false, code: 'ZCODE_POLICY_TEMPLATE_MISSING' };
113
+ mkdirSync(dirname(file), { recursive: true });
114
+ const current = readText(file) ?? '';
115
+ const escapedStart = POLICY_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
116
+ const escapedEnd = POLICY_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
117
+ const managed = new RegExp(`${escapedStart}[\\s\\S]*?${escapedEnd}`, 'm');
118
+ const next = managed.test(current)
119
+ ? current.replace(managed, block)
120
+ : `${current.trimEnd()}${current.trim() ? '\n\n' : ''}${block}\n`;
121
+ if (next !== current) { backup(file); writeFileSync(file, next); }
122
+ return { ok: true, file };
123
+ }
124
+
125
+ function removePolicy({ home }) {
126
+ const file = join(home, '.zcode', 'AGENTS.md');
127
+ const current = readText(file);
128
+ if (typeof current !== 'string') return false;
129
+ const start = POLICY_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
130
+ const end = POLICY_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
131
+ const managed = new RegExp(`(?:\\r?\\n){0,2}${start}[\\s\\S]*?${end}(?:\\r?\\n)?`, 'm');
132
+ if (!managed.test(current)) return false;
133
+ const next = current.replace(managed, '').trimEnd();
134
+ backup(file);
135
+ if (next.trim()) writeFileSync(file, `${next}\n`);
136
+ else rmSync(file, { force: true });
137
+ return true;
138
+ }
139
+
140
+ function ownership({ home = homedir() } = {}) {
141
+ const data = readJson(OWNERSHIP_FILE({ home }), null);
142
+ return data && typeof data === 'object' && data.host === HOST ? data : null;
143
+ }
144
+
145
+ function ownedSourceRecords(owned) {
146
+ if (!owned) return [];
147
+ const records = [];
148
+ const seen = new Set();
149
+ const current = owned.config_file ? {
150
+ file: owned.config_file,
151
+ kind: owned.config_kind === 'shared' ? 'shared' : 'native',
152
+ } : null;
153
+ for (const record of [current, ...(Array.isArray(owned.config_files) ? owned.config_files : [])]) {
154
+ if (!record || typeof record.file !== 'string' || !record.file.trim()) continue;
155
+ const key = normalizePath(record.file);
156
+ if (!key || seen.has(key)) continue;
157
+ seen.add(key);
158
+ records.push({ file: record.file, kind: record.kind === 'shared' ? 'shared' : 'native' });
159
+ }
160
+ return records;
161
+ }
162
+
163
+ function removeOwnedMcp({ file, kind, target }) {
164
+ const config = readJson(file, null);
165
+ if (!config || typeof config !== 'object') return false;
166
+ const isShared = kind === 'shared';
167
+ const servers = isShared ? sharedServers(config) : nativeServers(config);
168
+ const current = servers[SERVER];
169
+ if (normalizePath(target) !== serverTarget(current)) return false;
170
+ const next = { ...config };
171
+ if (isShared) {
172
+ next.mcpServers = { ...servers };
173
+ delete next.mcpServers[SERVER];
174
+ if (Object.keys(next.mcpServers).length === 0) delete next.mcpServers;
175
+ } else {
176
+ next.mcp = { ...(next.mcp ?? {}), servers: { ...servers } };
177
+ delete next.mcp.servers[SERVER];
178
+ if (Object.keys(next.mcp.servers).length === 0) delete next.mcp.servers;
179
+ if (Object.keys(next.mcp).length === 0) delete next.mcp;
180
+ }
181
+ const changed = JSON.stringify(next) !== JSON.stringify(config);
182
+ if (changed) {
183
+ backup(file);
184
+ writeJson(file, next);
185
+ }
186
+ return changed;
187
+ }
188
+
189
+ function cleanupPriorMcpSources({ owned, exceptFile }) {
190
+ if (!owned) return [];
191
+ const actions = [];
192
+ const except = normalizePath(exceptFile);
193
+ for (const source of ownedSourceRecords(owned)) {
194
+ if (except && normalizePath(source.file) === except) continue;
195
+ if (removeOwnedMcp({ file: source.file, kind: source.kind, target: owned.target })) {
196
+ actions.push(`mcp: removed ${SERVER} from prior ${source.file}`);
197
+ }
198
+ }
199
+ return actions;
200
+ }
201
+
202
+ function writeOwnership({ home, configFile, configKind, target, files = [], configFiles = [] }) {
203
+ writeJson(OWNERSHIP_FILE({ home }), {
204
+ schema_version: 1,
205
+ host: HOST,
206
+ server: SERVER,
207
+ config_file: configFile,
208
+ config_kind: configKind,
209
+ target,
210
+ files,
211
+ config_files: configFiles,
212
+ managed_at: new Date().toISOString(),
213
+ });
214
+ }
215
+
216
+ function templateFiles({ home, root }) {
217
+ return [
218
+ ['agents', 'ds-worker.md'],
219
+ ['agents', 'ds-reviewer.md'],
220
+ ['commands', 'dsh-config.md'],
221
+ ['commands', 'dsh-status.md'],
222
+ ].map(([dir, file]) => ({ source: join(root, 'zcode', dir, file), dest: join(home, '.zcode', dir, file) }));
223
+ }
224
+
225
+ function installTemplates({ home, root, priorFiles = [] }) {
226
+ const actions = [];
227
+ const records = [];
228
+ for (const { source, dest } of templateFiles({ home, root })) {
229
+ if (!existsSync(source)) return { ok: false, code: 'ZCODE_TEMPLATE_MISSING', source };
230
+ mkdirSync(dirname(dest), { recursive: true });
231
+ const before = readText(dest);
232
+ const rendered = readFileSync(source, 'utf8');
233
+ const previous = priorFiles.find((entry) => entry?.path === dest);
234
+ let backupFile = previous?.backup ?? null;
235
+ if (before !== rendered) {
236
+ if (before !== null && !backupFile) backupFile = backup(dest);
237
+ writeFileSync(dest, rendered);
238
+ }
239
+ actions.push(dest);
240
+ records.push({
241
+ path: dest,
242
+ preexisting: previous?.preexisting === true || (previous === undefined && before !== null),
243
+ ...(backupFile ? { backup: backupFile } : {}),
244
+ managed_sha256: contentHash(rendered),
245
+ });
246
+ }
247
+ return { ok: true, actions, records };
248
+ }
249
+
250
+ function updateMcp({ home, root }) {
251
+ const source = configSource({ home });
252
+ const target = expectedTarget({ root });
253
+ const current = source.servers[SERVER];
254
+ const currentTarget = serverTarget(current);
255
+ const owned = ownership({ home });
256
+ if (current && currentTarget !== target) {
257
+ // Only an exact previously-owned entry may be repaired. Any foreign
258
+ // command, even if it is also named dsh-crew, is a hard collision.
259
+ if (!(owned && owned.config_file === source.file && normalizePath(owned.target) === currentTarget)) {
260
+ return { ok: false, code: 'ZCODE_MCP_COLLISION', config_file: source.file };
261
+ }
262
+ }
263
+
264
+ const next = { ...source.config };
265
+ if (source.kind === 'native') next.mcp = { ...(next.mcp ?? {}), servers: { ...source.servers, [SERVER]: { command: 'node', args: [target] } } };
266
+ else next.mcpServers = { ...source.servers, [SERVER]: { command: 'node', args: [target] } };
267
+ const changed = JSON.stringify(next) !== JSON.stringify(source.config);
268
+ if (changed) { backup(source.file); writeJson(source.file, next); }
269
+ return { ok: true, changed, config_file: source.file, config_kind: source.kind, target };
270
+ }
271
+
272
+ export function installZCode({ home = homedir(), root = ROOT } = {}) {
273
+ const templates = templateFiles({ home, root });
274
+ const missing = templates.find(({ source }) => !existsSync(source));
275
+ if (missing) return { ok: false, code: 'ZCODE_TEMPLATE_MISSING', source: missing.source };
276
+ const previousOwnership = ownership({ home });
277
+ const priorFiles = previousOwnership?.files ?? [];
278
+ // Check for collisions before writing any other integration surface so a
279
+ // failed install is transaction-like and leaves user files untouched.
280
+ const mcp = updateMcp({ home, root });
281
+ if (!mcp.ok) return mcp;
282
+ const policy = installPolicy({ home, root });
283
+ if (!policy.ok) return policy;
284
+ const installed = installTemplates({ home, root, priorFiles });
285
+ if (!installed.ok) return installed;
286
+ cleanupPriorMcpSources({ owned: previousOwnership, exceptFile: mcp.config_file });
287
+ const currentSourceRecord = { file: mcp.config_file, kind: mcp.config_kind };
288
+ const priorSourceRecords = ownedSourceRecords(previousOwnership)
289
+ .filter((record) => normalizePath(record.file) !== normalizePath(currentSourceRecord.file));
290
+ writeOwnership({
291
+ home,
292
+ configFile: mcp.config_file,
293
+ configKind: mcp.config_kind,
294
+ target: mcp.target,
295
+ files: installed.records,
296
+ configFiles: [currentSourceRecord, ...priorSourceRecords],
297
+ });
298
+ return { ok: true, ...mcp, policy_file: policy.file, files: installed.actions };
299
+ }
300
+
301
+ function zcodeComponents({ home = homedir(), root = ROOT } = {}) {
302
+ const expected = normalizePath(expectedTarget({ root }));
303
+ const source = configSource({ home });
304
+ const configured = serverTarget(source.servers[SERVER]);
305
+ const owned = ownership({ home });
306
+ const components = {
307
+ mcp: configured === expected,
308
+ policy: typeof readText(join(home, '.zcode', 'AGENTS.md')) === 'string' && readText(join(home, '.zcode', 'AGENTS.md')).includes(POLICY_START),
309
+ worker_agent: existsSync(join(home, '.zcode', 'agents', 'ds-worker.md')),
310
+ reviewer_agent: existsSync(join(home, '.zcode', 'agents', 'ds-reviewer.md')),
311
+ config_prompt: existsSync(join(home, '.zcode', 'commands', 'dsh-config.md')),
312
+ status_prompt: existsSync(join(home, '.zcode', 'commands', 'dsh-status.md')),
313
+ ownership: !!owned && owned.config_file === source.file && normalizePath(owned.target) === expected,
314
+ };
315
+ return { components, source, expected, configured, owned };
316
+ }
317
+
318
+ export function zcodeStatus({ home = homedir(), root = ROOT } = {}) {
319
+ const { components, source, expected, configured } = zcodeComponents({ home, root });
320
+ const missing = Object.entries(components).filter(([, value]) => !value).map(([key]) => key);
321
+ const installed = Object.values(components).some(Boolean);
322
+ return {
323
+ installed,
324
+ ready: missing.length === 0,
325
+ components,
326
+ missing,
327
+ config_file: source.file,
328
+ config_kind: source.kind,
329
+ target: configured ?? expected,
330
+ };
331
+ }
332
+
333
+ export function uninstallZCode({ home = homedir() } = {}) {
334
+ const actions = [];
335
+ const owned = ownership({ home });
336
+ if (owned) {
337
+ for (const source of ownedSourceRecords(owned)) {
338
+ if (removeOwnedMcp({ file: source.file, kind: source.kind, target: owned.target })) {
339
+ actions.push(`mcp: removed ${SERVER} from ${source.file}`);
340
+ }
341
+ }
342
+ rmSync(OWNERSHIP_FILE({ home }), { force: true });
343
+ }
344
+ if (removePolicy({ home })) actions.push('policy: removed managed ZCode block');
345
+ const managedFiles = Array.isArray(owned?.files) && owned.files.length
346
+ ? owned.files
347
+ : ['agents/ds-worker.md', 'agents/ds-reviewer.md', 'commands/dsh-config.md', 'commands/dsh-status.md']
348
+ .map((rel) => ({ path: join(home, '.zcode', rel) }));
349
+ for (const entry of managedFiles) {
350
+ const file = entry?.path;
351
+ if (!file || !existsSync(file)) continue;
352
+ const current = readText(file);
353
+ const unchanged = !entry.managed_sha256 || (current !== null && contentHash(current) === entry.managed_sha256);
354
+ if (!unchanged) continue;
355
+ if (entry.preexisting && entry.backup && existsSync(entry.backup)) {
356
+ copyFileSync(entry.backup, file);
357
+ actions.push(`restored: ${file}`);
358
+ } else {
359
+ rmSync(file, { force: true });
360
+ actions.push(`removed: ${file}`);
361
+ }
362
+ }
363
+ return { ok: true, actions };
364
+ }
365
+
366
+ export function zcodeOwnershipFile({ home = homedir() } = {}) { return OWNERSHIP_FILE({ home }); }