clean-room-skill 0.1.11 → 0.1.13
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/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +35 -8
- package/agents/clean-architect.md +7 -1
- package/agents/clean-implementer-verifier-shell.md +4 -0
- package/agents/clean-polish-reviewer.md +3 -0
- package/agents/clean-qa-editor.md +4 -0
- package/agents/contaminated-handoff-sanitizer.md +3 -0
- package/agents/contaminated-manager-verifier.md +10 -1
- package/agents/contaminated-source-analyst.md +8 -1
- package/bin/install.js +11 -1621
- package/docs/ARCHITECTURE.md +7 -1
- package/docs/HOOKS.md +14 -10
- package/docs/REFERENCE.md +31 -6
- package/examples/codex/.codex/agents/clean-architect.toml +7 -5
- package/examples/codex/.codex/agents/clean-polish-reviewer.toml +2 -2
- package/examples/codex/.codex/agents/clean-qa-editor.toml +3 -2
- package/examples/codex/.codex/agents/contaminated-handoff-sanitizer.toml +2 -2
- package/examples/codex/.codex/agents/contaminated-manager-verifier.toml +10 -4
- package/examples/codex/.codex/agents/contaminated-source-analyst.toml +7 -3
- package/hooks/validate-json-schema.py +14 -0
- package/lib/bootstrap.cjs +5 -1
- package/lib/doctor.cjs +157 -5
- package/lib/hooks.cjs +18 -0
- package/lib/install-artifacts.cjs +178 -4
- package/lib/install-claude-plugin.cjs +374 -0
- package/lib/install-cli.cjs +99 -0
- package/lib/install-operations.cjs +376 -0
- package/lib/install-options.cjs +149 -0
- package/lib/install-runtime-selection.cjs +180 -0
- package/lib/install-status.cjs +292 -0
- package/lib/install-tui.cjs +359 -0
- package/lib/preflight-bootstrap.cjs +39 -0
- package/lib/preflight-cli.cjs +95 -0
- package/lib/preflight-constants.cjs +25 -0
- package/lib/preflight-output.cjs +37 -0
- package/lib/preflight-paths.cjs +67 -0
- package/lib/preflight-template.cjs +103 -0
- package/lib/preflight-validation.cjs +276 -0
- package/lib/preflight.cjs +18 -461
- package/lib/run-clean-artifacts.cjs +276 -0
- package/lib/run-cli.cjs +90 -0
- package/lib/run-constants.cjs +171 -0
- package/lib/run-controller.cjs +247 -0
- package/lib/run-coverage.cjs +350 -0
- package/lib/run-hooks.cjs +96 -0
- package/lib/run-manifest.cjs +111 -0
- package/lib/run-progress.cjs +160 -0
- package/lib/run-results.cjs +433 -0
- package/lib/run-roots.cjs +230 -0
- package/lib/run-stages.cjs +409 -0
- package/lib/run.cjs +4 -1998
- package/lib/runtime-layout.cjs +12 -5
- package/package.json +8 -2
- package/plugin.json +1 -1
- package/skills/attended/SKILL.md +2 -0
- package/skills/clean-room/SKILL.md +6 -6
- package/skills/clean-room/assets/coverage-ledger.schema.json +95 -0
- package/skills/clean-room/assets/task-manifest.schema.json +25 -0
- package/skills/clean-room/examples/contaminated-side/task-manifest.json +14 -2
- package/skills/clean-room/references/CONTROLLER-LOOP.md +5 -0
- package/skills/clean-room/references/PROCESS.md +12 -4
- package/skills/clean-room/references/SPEC-SCHEMA.md +11 -2
- package/skills/refocus/SKILL.md +2 -0
- package/skills/unattended/SKILL.md +2 -0
package/bin/install.js
CHANGED
|
@@ -1,1630 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
-
const fs = require('node:fs');
|
|
5
|
-
const path = require('node:path');
|
|
6
|
-
const readline = require('node:readline/promises');
|
|
7
|
-
const { spawnSync } = require('node:child_process');
|
|
8
|
-
|
|
9
4
|
const { runInit } = require('../lib/bootstrap.cjs');
|
|
10
|
-
const {
|
|
11
|
-
const {
|
|
12
|
-
const {
|
|
5
|
+
const { buildDesiredFiles } = require('../lib/install-artifacts.cjs');
|
|
6
|
+
const { planInstall } = require('../lib/install-plan.cjs');
|
|
7
|
+
const { main } = require('../lib/install-cli.cjs');
|
|
8
|
+
const { parseArgs } = require('../lib/install-options.cjs');
|
|
9
|
+
const { parseRuntimeSelection } = require('../lib/install-runtime-selection.cjs');
|
|
10
|
+
const {
|
|
11
|
+
collectRuntimeStatus,
|
|
12
|
+
resolveTargetRoot,
|
|
13
|
+
runStatus,
|
|
14
|
+
runtimeInstallStatus,
|
|
15
|
+
} = require('../lib/install-status.cjs');
|
|
13
16
|
const { parsePreflightArgs, runPreflight } = require('../lib/preflight.cjs');
|
|
14
17
|
const { parseRunArgs, runCleanRoom } = require('../lib/run.cjs');
|
|
15
|
-
const {
|
|
16
|
-
buildHookEntries,
|
|
17
|
-
configPathForRuntime,
|
|
18
|
-
hasManagedHookEntries,
|
|
19
|
-
mergeHookEntries,
|
|
20
|
-
removeHookEntries,
|
|
21
|
-
} = require('../lib/hooks.cjs');
|
|
22
|
-
const {
|
|
23
|
-
RUNTIMES,
|
|
24
|
-
RUNTIME_FLAGS,
|
|
25
|
-
resolveRuntimeLayout,
|
|
26
|
-
} = require('../lib/runtime-layout.cjs');
|
|
27
|
-
const { buildDesiredFiles, packageVersion } = require('../lib/install-artifacts.cjs');
|
|
28
|
-
const {
|
|
29
|
-
applyInstall,
|
|
30
|
-
applyUninstall,
|
|
31
|
-
manifestHash,
|
|
32
|
-
planInstall,
|
|
33
|
-
planUninstall,
|
|
34
|
-
readManifest,
|
|
35
|
-
writeInstallManifest,
|
|
36
|
-
} = require('../lib/install-plan.cjs');
|
|
37
|
-
|
|
38
|
-
const HOOK_MODES = new Set(['safe', 'copy-only', 'strict']);
|
|
39
|
-
const INSTALL_LOCK_NAME = '.clean-room-install.lock';
|
|
40
|
-
const INSTALL_LOCK_WAIT_MS = envPositiveInteger('CLEAN_ROOM_INSTALL_LOCK_WAIT_MS', 30_000);
|
|
41
|
-
const INSTALL_LOCK_POLL_MS = 100;
|
|
42
|
-
const PYTHON_PROBE_TIMEOUT_MS = envPositiveInteger('CLEAN_ROOM_INSTALL_PYTHON_TIMEOUT_MS', 10_000);
|
|
43
|
-
const CLAUDE_PLUGIN_TIMEOUT_MS = envPositiveInteger('CLEAN_ROOM_INSTALL_CLAUDE_PLUGIN_TIMEOUT_MS', 120_000);
|
|
44
|
-
const CLAUDE_EXECUTABLE_ENV = 'CLEAN_ROOM_CLAUDE_EXECUTABLE';
|
|
45
|
-
const CLAUDE_PLUGIN_MARKETPLACE_NAME = 'clean-room-skill';
|
|
46
|
-
const CLAUDE_PLUGIN_NAME = 'clean-room';
|
|
47
|
-
const CLAUDE_PLUGIN_ID = `${CLAUDE_PLUGIN_NAME}@${CLAUDE_PLUGIN_MARKETPLACE_NAME}`;
|
|
48
|
-
const CLAUDE_PLUGIN_SOURCE_URL = 'https://github.com/whit3rabbit/clean-room-skill.git';
|
|
49
|
-
const CLAUDE_PLUGIN_SCOPE = 'user';
|
|
50
|
-
|
|
51
|
-
function envPositiveInteger(name, fallback) {
|
|
52
|
-
const value = process.env[name];
|
|
53
|
-
if (value === undefined || value === '') {
|
|
54
|
-
return fallback;
|
|
55
|
-
}
|
|
56
|
-
return /^[1-9][0-9]*$/.test(value) ? Number(value) : fallback;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
async function withTargetInstallLock(targetRoot, dryRun, fn) {
|
|
60
|
-
if (dryRun) {
|
|
61
|
-
return fn();
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
fs.mkdirSync(targetRoot, { recursive: true });
|
|
65
|
-
const lockPath = assertManagedPath(targetRoot, INSTALL_LOCK_NAME);
|
|
66
|
-
return withDirectoryLock({
|
|
67
|
-
lockPath,
|
|
68
|
-
waitMs: INSTALL_LOCK_WAIT_MS,
|
|
69
|
-
pollMs: INSTALL_LOCK_POLL_MS,
|
|
70
|
-
label: 'install lock',
|
|
71
|
-
}, fn);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function parseArgs(argv) {
|
|
75
|
-
const options = {
|
|
76
|
-
runtimes: [],
|
|
77
|
-
scope: null,
|
|
78
|
-
dryRun: false,
|
|
79
|
-
yes: false,
|
|
80
|
-
uninstall: false,
|
|
81
|
-
operation: null,
|
|
82
|
-
hookMode: 'safe',
|
|
83
|
-
hookModeSpecified: false,
|
|
84
|
-
configDir: null,
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
for (let i = 0; i < argv.length; i += 1) {
|
|
88
|
-
const arg = argv[i];
|
|
89
|
-
if (RUNTIME_FLAGS[arg]) options.runtimes.push(RUNTIME_FLAGS[arg]);
|
|
90
|
-
else if (arg === '--all') options.runtimes = [...RUNTIMES];
|
|
91
|
-
else if (arg === '--global') options.scope = setExclusive(options.scope, 'global', '--global');
|
|
92
|
-
else if (arg === '--local') options.scope = setExclusive(options.scope, 'local', '--local');
|
|
93
|
-
else if (arg === '--dry-run') options.dryRun = true;
|
|
94
|
-
else if (arg === '--yes') options.yes = true;
|
|
95
|
-
else if (arg === '--uninstall') options.uninstall = true;
|
|
96
|
-
else if (arg === '--no-hooks') {
|
|
97
|
-
options.hookMode = 'copy-only';
|
|
98
|
-
options.hookModeSpecified = true;
|
|
99
|
-
} else if (arg === '--config-dir') {
|
|
100
|
-
i += 1;
|
|
101
|
-
if (i >= argv.length) throw new Error('--config-dir requires a path');
|
|
102
|
-
options.configDir = argv[i];
|
|
103
|
-
} else if (arg.startsWith('--config-dir=')) {
|
|
104
|
-
options.configDir = arg.slice('--config-dir='.length);
|
|
105
|
-
} else if (arg === '--hooks') {
|
|
106
|
-
i += 1;
|
|
107
|
-
if (i >= argv.length) throw new Error('--hooks requires safe, copy-only, or strict');
|
|
108
|
-
options.hookMode = argv[i];
|
|
109
|
-
options.hookModeSpecified = true;
|
|
110
|
-
} else if (arg.startsWith('--hooks=')) {
|
|
111
|
-
options.hookMode = arg.slice('--hooks='.length);
|
|
112
|
-
options.hookModeSpecified = true;
|
|
113
|
-
} else if (arg === '-h' || arg === '--help') {
|
|
114
|
-
printHelp();
|
|
115
|
-
process.exit(0);
|
|
116
|
-
} else {
|
|
117
|
-
throw new Error(`unknown option: ${arg}`);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
options.runtimes = [...new Set(options.runtimes)];
|
|
122
|
-
if (!HOOK_MODES.has(options.hookMode)) {
|
|
123
|
-
throw new Error('--hooks must be one of safe, copy-only, or strict');
|
|
124
|
-
}
|
|
125
|
-
if (options.configDir && options.runtimes.length > 1) {
|
|
126
|
-
throw new Error('--config-dir can only be used with one runtime');
|
|
127
|
-
}
|
|
128
|
-
return options;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function setExclusive(current, next, flag) {
|
|
132
|
-
if (current && current !== next) {
|
|
133
|
-
throw new Error(`${flag} conflicts with --${current}`);
|
|
134
|
-
}
|
|
135
|
-
return next;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function printHelp() {
|
|
139
|
-
console.log(`Usage: clean-room-skill [runtime] [scope] [options]
|
|
140
|
-
clean-room-skill init [options]
|
|
141
|
-
clean-room-skill status [runtime] [scope] [options]
|
|
142
|
-
clean-room-skill update [runtime] [scope] [options]
|
|
143
|
-
clean-room-skill preflight [options]
|
|
144
|
-
clean-room-skill run [options]
|
|
145
|
-
|
|
146
|
-
Commands:
|
|
147
|
-
init Create clean-room bootstrap folders and repo guidance
|
|
148
|
-
status Report installed runtime version, drift, and hook state
|
|
149
|
-
update Update installed runtime files without onboarding
|
|
150
|
-
preflight Create or validate a preflight goal contract
|
|
151
|
-
doctor Smoke test generated Codex or Claude hook registration
|
|
152
|
-
run Execute the bounded inner clean-room controller loop
|
|
153
|
-
|
|
154
|
-
Runtime:
|
|
155
|
-
--codex Install for Codex
|
|
156
|
-
--claude Install for Claude Code
|
|
157
|
-
--antigravity Install for Antigravity
|
|
158
|
-
--gemini Install for Gemini CLI
|
|
159
|
-
--opencode Install for OpenCode
|
|
160
|
-
--kilo Install for Kilo
|
|
161
|
-
--cursor Install for Cursor
|
|
162
|
-
--copilot Install for GitHub Copilot
|
|
163
|
-
--windsurf Install for Windsurf
|
|
164
|
-
--augment Install for Augment
|
|
165
|
-
--trae Install for Trae
|
|
166
|
-
--qwen Install for Qwen Code
|
|
167
|
-
--hermes Install for Hermes Agent
|
|
168
|
-
--codebuddy Install for CodeBuddy
|
|
169
|
-
--all Install for all known runtime layouts
|
|
170
|
-
|
|
171
|
-
Scope:
|
|
172
|
-
--global Install to the runtime user config
|
|
173
|
-
--local Install to the current project config
|
|
174
|
-
|
|
175
|
-
Options:
|
|
176
|
-
--hooks=<mode> safe, copy-only, or strict (default: safe)
|
|
177
|
-
--no-hooks Alias for --hooks=copy-only
|
|
178
|
-
--config-dir <path> Override the target root for one runtime
|
|
179
|
-
--dry-run Print actions without writing files
|
|
180
|
-
--yes Non-interactive mode; unknown conflicts still abort
|
|
181
|
-
--uninstall Remove manifest-managed files and clean-room hook entries
|
|
182
|
-
|
|
183
|
-
Run without runtime and scope flags for interactive install or uninstall.
|
|
184
|
-
Interactive runtime selection accepts names, numbers, ranges, all, or installed.
|
|
185
|
-
`);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function operationForOptions(options) {
|
|
189
|
-
if (options.operation) return options.operation;
|
|
190
|
-
return options.uninstall ? 'uninstall' : 'install';
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
async function resolveInteractiveOptions(options) {
|
|
194
|
-
if (options.runtimes.length > 0 && options.scope) {
|
|
195
|
-
return options;
|
|
196
|
-
}
|
|
197
|
-
if (!process.stdin.isTTY || options.yes) {
|
|
198
|
-
throw new Error('specify runtime and scope flags when running non-interactively');
|
|
199
|
-
}
|
|
200
|
-
return runInstallerTui(options);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
async function runInstallerTui(options) {
|
|
204
|
-
const React = await import('react');
|
|
205
|
-
const ink = await import('ink');
|
|
206
|
-
const h = React.createElement;
|
|
207
|
-
|
|
208
|
-
return new Promise((resolve, reject) => {
|
|
209
|
-
let result = null;
|
|
210
|
-
let error = null;
|
|
211
|
-
let instance = null;
|
|
212
|
-
|
|
213
|
-
function complete(nextOptions) {
|
|
214
|
-
result = nextOptions;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function abort(err) {
|
|
218
|
-
error = err;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
function App() {
|
|
222
|
-
return h(InstallerTui, {
|
|
223
|
-
React,
|
|
224
|
-
ink,
|
|
225
|
-
h,
|
|
226
|
-
initialOptions: options,
|
|
227
|
-
onComplete: complete,
|
|
228
|
-
onAbort: abort,
|
|
229
|
-
});
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
instance = ink.render(h(App), {
|
|
233
|
-
stdin: process.stdin,
|
|
234
|
-
stdout: process.stdout,
|
|
235
|
-
stderr: process.stderr,
|
|
236
|
-
exitOnCtrlC: false,
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
instance.waitUntilExit().then(() => {
|
|
240
|
-
if (error) {
|
|
241
|
-
reject(error);
|
|
242
|
-
return;
|
|
243
|
-
}
|
|
244
|
-
resolve(result || options);
|
|
245
|
-
}, reject);
|
|
246
|
-
});
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
function InstallerTui({ React, ink, h, initialOptions, onComplete, onAbort }) {
|
|
250
|
-
const { Box, Text, useApp, useInput } = ink;
|
|
251
|
-
const { useMemo, useState } = React;
|
|
252
|
-
const { exit } = useApp();
|
|
253
|
-
const initialFlags = useMemo(() => ({
|
|
254
|
-
actionResolved: !!initialOptions.operation ||
|
|
255
|
-
!(initialOptions.runtimes.length === 0 && !initialOptions.uninstall),
|
|
256
|
-
promptedRuntimes: false,
|
|
257
|
-
uninstallConfirmed: true,
|
|
258
|
-
}), [initialOptions]);
|
|
259
|
-
const [draft, setDraft] = useState(() => ({
|
|
260
|
-
...initialOptions,
|
|
261
|
-
runtimes: [...initialOptions.runtimes],
|
|
262
|
-
}));
|
|
263
|
-
const [flags, setFlags] = useState(initialFlags);
|
|
264
|
-
const [stage, setStage] = useState(() => nextTuiStage(initialOptions, initialFlags));
|
|
265
|
-
|
|
266
|
-
function fail(message) {
|
|
267
|
-
onAbort(new Error(message));
|
|
268
|
-
exit();
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
useInput((input, key) => {
|
|
272
|
-
if (key.ctrl && input === 'c') {
|
|
273
|
-
fail('aborted by user');
|
|
274
|
-
}
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
function advance(nextDraft, nextFlags = {}) {
|
|
278
|
-
const mergedFlags = { ...flags, ...nextFlags };
|
|
279
|
-
const nextStage = nextTuiStage(nextDraft, mergedFlags);
|
|
280
|
-
setDraft(nextDraft);
|
|
281
|
-
setFlags(mergedFlags);
|
|
282
|
-
if (nextStage === 'complete') {
|
|
283
|
-
onComplete(nextDraft);
|
|
284
|
-
exit();
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
setStage(nextStage);
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
const action = operationForOptions(draft);
|
|
291
|
-
|
|
292
|
-
return h(Box, { flexDirection: 'column', gap: 1 },
|
|
293
|
-
h(Box, { flexDirection: 'column' },
|
|
294
|
-
h(Text, { bold: true }, 'clean-room-skill installer'),
|
|
295
|
-
h(Text, { dimColor: true }, 'Use arrows or j/k to move. Enter selects. Ctrl+C cancels.')
|
|
296
|
-
),
|
|
297
|
-
stage === 'action' && h(SingleChoice, {
|
|
298
|
-
React,
|
|
299
|
-
Box,
|
|
300
|
-
Text,
|
|
301
|
-
useInput,
|
|
302
|
-
h,
|
|
303
|
-
title: 'Action',
|
|
304
|
-
initialIndex: defaultActionIndex(draft),
|
|
305
|
-
items: [
|
|
306
|
-
{ label: 'Update', value: 'update', detail: 'refresh installed runtimes without onboarding' },
|
|
307
|
-
{ label: 'Install', value: 'install', detail: 'add or repair runtime files' },
|
|
308
|
-
{ label: 'Uninstall', value: 'uninstall', detail: 'remove managed files and generated hooks' },
|
|
309
|
-
{ label: 'Status', value: 'status', detail: 'inspect runtime installs without changing files' },
|
|
310
|
-
],
|
|
311
|
-
onSubmit: (item) => advance({
|
|
312
|
-
...draft,
|
|
313
|
-
operation: item.value,
|
|
314
|
-
uninstall: item.value === 'uninstall',
|
|
315
|
-
}, {
|
|
316
|
-
actionResolved: true,
|
|
317
|
-
uninstallConfirmed: item.value !== 'uninstall',
|
|
318
|
-
}),
|
|
319
|
-
}),
|
|
320
|
-
stage === 'scope' && h(SingleChoice, {
|
|
321
|
-
React,
|
|
322
|
-
Box,
|
|
323
|
-
Text,
|
|
324
|
-
useInput,
|
|
325
|
-
h,
|
|
326
|
-
title: 'Scope',
|
|
327
|
-
items: [
|
|
328
|
-
{ label: 'Global', value: 'global', detail: 'runtime user config' },
|
|
329
|
-
{ label: 'Local', value: 'local', detail: 'current project config' },
|
|
330
|
-
],
|
|
331
|
-
onSubmit: (item) => advance({ ...draft, scope: item.value }),
|
|
332
|
-
}),
|
|
333
|
-
stage === 'runtimes' && h(RuntimeMultiSelect, {
|
|
334
|
-
React,
|
|
335
|
-
Box,
|
|
336
|
-
Text,
|
|
337
|
-
useInput,
|
|
338
|
-
h,
|
|
339
|
-
action,
|
|
340
|
-
statuses: runtimeInstallStatuses(draft.scope, draft.configDir),
|
|
341
|
-
onSubmit: (runtimes) => advance({ ...draft, runtimes }, {
|
|
342
|
-
promptedRuntimes: true,
|
|
343
|
-
uninstallConfirmed: operationForOptions(draft) !== 'uninstall',
|
|
344
|
-
}),
|
|
345
|
-
}),
|
|
346
|
-
stage === 'confirm-uninstall' && h(ConfirmUninstall, {
|
|
347
|
-
React,
|
|
348
|
-
Box,
|
|
349
|
-
Text,
|
|
350
|
-
useInput,
|
|
351
|
-
h,
|
|
352
|
-
runtimes: draft.runtimes,
|
|
353
|
-
onSubmit: () => advance(draft, { uninstallConfirmed: true }),
|
|
354
|
-
}),
|
|
355
|
-
stage === 'hooks' && h(SingleChoice, {
|
|
356
|
-
React,
|
|
357
|
-
Box,
|
|
358
|
-
Text,
|
|
359
|
-
useInput,
|
|
360
|
-
h,
|
|
361
|
-
title: 'Hook mode',
|
|
362
|
-
items: [
|
|
363
|
-
{ label: 'Safe', value: 'safe', detail: 'enforces during clean-room role sessions' },
|
|
364
|
-
{ label: 'Copy-only', value: 'copy-only', detail: 'copy scripts without host hook registration' },
|
|
365
|
-
{ label: 'Strict', value: 'strict', detail: 'fail closed in dedicated Codex or Claude homes' },
|
|
366
|
-
],
|
|
367
|
-
onSubmit: (item) => advance({ ...draft, hookMode: item.value, hookModeSpecified: true }),
|
|
368
|
-
})
|
|
369
|
-
);
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
function runtimeInstallStatuses(scope, configDir) {
|
|
373
|
-
return RUNTIMES.map((runtime) => runtimeInstallStatus(runtime, scope, configDir));
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
function defaultActionIndex(options) {
|
|
377
|
-
if (operationForOptions(options) === 'status') return 3;
|
|
378
|
-
if (operationForOptions(options) === 'uninstall') return 2;
|
|
379
|
-
if (runtimeInstallStatuses(options.scope || 'global', options.configDir).some((status) => status.state === 'installed')) {
|
|
380
|
-
return 0;
|
|
381
|
-
}
|
|
382
|
-
return 1;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
function runtimeInstallStatus(runtime, scope, configDir) {
|
|
386
|
-
const layout = resolveRuntimeLayout(runtime, scope, { configDir });
|
|
387
|
-
const status = {
|
|
388
|
-
runtime,
|
|
389
|
-
targetRoot: layout.targetRoot,
|
|
390
|
-
state: 'not-installed',
|
|
391
|
-
detail: 'not installed',
|
|
392
|
-
};
|
|
393
|
-
try {
|
|
394
|
-
const manifest = readManifest(layout.targetRoot);
|
|
395
|
-
if (manifest) {
|
|
396
|
-
const phase = manifest.phase ? `phase ${manifest.phase}` : 'manifest present';
|
|
397
|
-
const hooksMode = manifest.hooks_mode ? `, hooks ${manifest.hooks_mode}` : '';
|
|
398
|
-
return {
|
|
399
|
-
...status,
|
|
400
|
-
state: 'installed',
|
|
401
|
-
detail: `${phase}${hooksMode}`,
|
|
402
|
-
};
|
|
403
|
-
}
|
|
404
|
-
} catch (err) {
|
|
405
|
-
return {
|
|
406
|
-
...status,
|
|
407
|
-
state: 'error',
|
|
408
|
-
detail: err.message,
|
|
409
|
-
};
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
if (!layout.supportsHookRegistration) {
|
|
413
|
-
return status;
|
|
414
|
-
}
|
|
415
|
-
const configPath = configPathForRuntime(runtime, layout.targetRoot);
|
|
416
|
-
if (!configPath) {
|
|
417
|
-
return status;
|
|
418
|
-
}
|
|
419
|
-
try {
|
|
420
|
-
if (hasManagedHookEntries(configPath)) {
|
|
421
|
-
return {
|
|
422
|
-
...status,
|
|
423
|
-
state: 'hooks-only',
|
|
424
|
-
detail: 'managed hooks without install manifest',
|
|
425
|
-
};
|
|
426
|
-
}
|
|
427
|
-
} catch (err) {
|
|
428
|
-
return {
|
|
429
|
-
...status,
|
|
430
|
-
state: 'error',
|
|
431
|
-
detail: err.message,
|
|
432
|
-
};
|
|
433
|
-
}
|
|
434
|
-
return status;
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
function printRuntimeChoices(statuses) {
|
|
438
|
-
console.log('Runtime choices:');
|
|
439
|
-
statuses.forEach((status, index) => {
|
|
440
|
-
const number = String(index + 1).padStart(2, ' ');
|
|
441
|
-
const runtime = status.runtime.padEnd(12, ' ');
|
|
442
|
-
console.log(` ${number}. ${runtime} ${status.detail} (${displayPath(status.targetRoot)})`);
|
|
443
|
-
});
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
function defaultRuntimeSelectionLabel(statuses, action) {
|
|
447
|
-
if ((action === 'uninstall' || action === 'update') && defaultRuntimeSelections(statuses, action).length > 0) {
|
|
448
|
-
return 'installed';
|
|
449
|
-
}
|
|
450
|
-
return 'codex';
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
function defaultRuntimeSelections(statuses, action = 'install') {
|
|
454
|
-
if (action === 'uninstall') {
|
|
455
|
-
return statuses.filter((status) => isInstalledStatus(status)).map((status) => status.runtime);
|
|
456
|
-
}
|
|
457
|
-
if (action === 'update') {
|
|
458
|
-
return selectableRuntimeSelections(statuses, action);
|
|
459
|
-
}
|
|
460
|
-
if (action === 'status') {
|
|
461
|
-
return statuses.map((status) => status.runtime);
|
|
462
|
-
}
|
|
463
|
-
return ['codex'];
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
function detectedRuntimeSelections(statuses, action = 'install') {
|
|
467
|
-
if (action === 'update') {
|
|
468
|
-
return selectableRuntimeSelections(statuses, action);
|
|
469
|
-
}
|
|
470
|
-
return statuses.filter((status) => isInstalledStatus(status)).map((status) => status.runtime);
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
function isUpdateTargetStatus(status) {
|
|
474
|
-
return status?.state === 'installed' || status?.state === 'update-available';
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
function isSelectableRuntimeStatus(status, action = 'install') {
|
|
478
|
-
if (action === 'update') {
|
|
479
|
-
return isUpdateTargetStatus(status);
|
|
480
|
-
}
|
|
481
|
-
return true;
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
function selectableRuntimeSelections(statuses, action = 'install') {
|
|
485
|
-
return statuses
|
|
486
|
-
.filter((status) => isSelectableRuntimeStatus(status, action))
|
|
487
|
-
.map((status) => status.runtime);
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
function statusForRuntime(statuses, runtime) {
|
|
491
|
-
return statuses.find((status) => status.runtime === runtime) || {
|
|
492
|
-
runtime,
|
|
493
|
-
state: 'not-installed',
|
|
494
|
-
};
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
function unavailableRuntimeSelectionMessage(status, action) {
|
|
498
|
-
if (action === 'update') {
|
|
499
|
-
return `${status.runtime} is not installed in this scope; choose Install to add it.`;
|
|
500
|
-
}
|
|
501
|
-
return `${status.runtime} cannot be selected for ${action}.`;
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
function emptyRuntimeSelectionMessage(statuses, action) {
|
|
505
|
-
if (action === 'update' && selectableRuntimeSelections(statuses, action).length === 0) {
|
|
506
|
-
return 'No installed runtimes detected for update. Choose Install instead.';
|
|
507
|
-
}
|
|
508
|
-
return 'Select at least one runtime.';
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
function addRuntimeSelection(selected, runtime, statuses, action) {
|
|
512
|
-
const status = statusForRuntime(statuses, runtime);
|
|
513
|
-
if (!isSelectableRuntimeStatus(status, action)) {
|
|
514
|
-
throw new Error(unavailableRuntimeSelectionMessage(status, action));
|
|
515
|
-
}
|
|
516
|
-
selected.push(runtime);
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
function parseRuntimeSelection(answer, statuses, action = 'install') {
|
|
520
|
-
const text = answer.trim().toLowerCase();
|
|
521
|
-
if (text === '') {
|
|
522
|
-
if (action === 'uninstall' || action === 'update') {
|
|
523
|
-
const installed = defaultRuntimeSelections(statuses, action);
|
|
524
|
-
if (installed.length === 0) {
|
|
525
|
-
throw new Error('no installed runtimes detected; select a runtime explicitly');
|
|
526
|
-
}
|
|
527
|
-
return installed;
|
|
528
|
-
}
|
|
529
|
-
return ['codex'];
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
const selected = [];
|
|
533
|
-
const tokens = text.split(/[,\s]+/).filter(Boolean);
|
|
534
|
-
for (const token of tokens) {
|
|
535
|
-
if (token === 'all') {
|
|
536
|
-
selected.push(...(action === 'update' ? selectableRuntimeSelections(statuses, action) : RUNTIMES));
|
|
537
|
-
continue;
|
|
538
|
-
}
|
|
539
|
-
if (token === 'installed') {
|
|
540
|
-
selected.push(...detectedRuntimeSelections(statuses, action));
|
|
541
|
-
continue;
|
|
542
|
-
}
|
|
543
|
-
const rangeMatch = token.match(/^(\d+)-(\d+)$/);
|
|
544
|
-
if (rangeMatch) {
|
|
545
|
-
const start = Number(rangeMatch[1]);
|
|
546
|
-
const end = Number(rangeMatch[2]);
|
|
547
|
-
if (start > end) {
|
|
548
|
-
throw new Error(`invalid runtime range: ${token}`);
|
|
549
|
-
}
|
|
550
|
-
for (let index = start; index <= end; index += 1) {
|
|
551
|
-
addRuntimeSelection(selected, runtimeForSelectionIndex(statuses, index), statuses, action);
|
|
552
|
-
}
|
|
553
|
-
continue;
|
|
554
|
-
}
|
|
555
|
-
if (/^\d+$/.test(token)) {
|
|
556
|
-
addRuntimeSelection(selected, runtimeForSelectionIndex(statuses, Number(token)), statuses, action);
|
|
557
|
-
continue;
|
|
558
|
-
}
|
|
559
|
-
if (RUNTIMES.includes(token)) {
|
|
560
|
-
addRuntimeSelection(selected, token, statuses, action);
|
|
561
|
-
continue;
|
|
562
|
-
}
|
|
563
|
-
throw new Error(`unsupported runtime selection: ${token}`);
|
|
564
|
-
}
|
|
565
|
-
const unique = [...new Set(selected)];
|
|
566
|
-
if (unique.length === 0) {
|
|
567
|
-
throw new Error('no runtimes selected');
|
|
568
|
-
}
|
|
569
|
-
return unique;
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
function runtimeForSelectionIndex(statuses, index) {
|
|
573
|
-
if (!Number.isInteger(index) || index < 1 || index > statuses.length) {
|
|
574
|
-
throw new Error(`runtime selection out of range: ${index}`);
|
|
575
|
-
}
|
|
576
|
-
return statuses[index - 1].runtime;
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
function isInstalledStatus(status) {
|
|
580
|
-
return status.state === 'installed' || status.state === 'hooks-only';
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
function nextTuiStage(options, flags) {
|
|
584
|
-
if (!options.scope) {
|
|
585
|
-
return 'scope';
|
|
586
|
-
}
|
|
587
|
-
if (!flags.actionResolved) {
|
|
588
|
-
return 'action';
|
|
589
|
-
}
|
|
590
|
-
if (operationForOptions(options) === 'status') {
|
|
591
|
-
return 'complete';
|
|
592
|
-
}
|
|
593
|
-
if (options.runtimes.length === 0) {
|
|
594
|
-
return 'runtimes';
|
|
595
|
-
}
|
|
596
|
-
if (operationForOptions(options) === 'uninstall' && flags.promptedRuntimes && !flags.uninstallConfirmed) {
|
|
597
|
-
return 'confirm-uninstall';
|
|
598
|
-
}
|
|
599
|
-
if (operationForOptions(options) === 'install' && !options.hookModeSpecified) {
|
|
600
|
-
return 'hooks';
|
|
601
|
-
}
|
|
602
|
-
return 'complete';
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
function SingleChoice({ React, Box, Text, useInput, h, title, items, initialIndex = 0, onSubmit }) {
|
|
606
|
-
const [index, setIndex] = React.useState(initialIndex);
|
|
607
|
-
useInput((input, key) => {
|
|
608
|
-
if (key.upArrow || input === 'k') {
|
|
609
|
-
setIndex((current) => Math.max(0, current - 1));
|
|
610
|
-
} else if (key.downArrow || input === 'j') {
|
|
611
|
-
setIndex((current) => Math.min(items.length - 1, current + 1));
|
|
612
|
-
} else if (key.home) {
|
|
613
|
-
setIndex(0);
|
|
614
|
-
} else if (key.end) {
|
|
615
|
-
setIndex(items.length - 1);
|
|
616
|
-
} else if (key.return || /[\r\n]/.test(input)) {
|
|
617
|
-
onSubmit(items[index]);
|
|
618
|
-
}
|
|
619
|
-
});
|
|
620
|
-
|
|
621
|
-
return h(Box, { flexDirection: 'column' },
|
|
622
|
-
h(Text, { bold: true }, title),
|
|
623
|
-
...items.map((item, itemIndex) => h(Text, {
|
|
624
|
-
key: item.value,
|
|
625
|
-
color: itemIndex === index ? 'cyan' : undefined,
|
|
626
|
-
}, `${itemIndex === index ? '>' : ' '} ${item.label.padEnd(10)} ${item.detail}`))
|
|
627
|
-
);
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
function RuntimeMultiSelect({ React, Box, Text, useInput, h, action, statuses, onSubmit }) {
|
|
631
|
-
const initialSelected = React.useMemo(() => new Set(defaultRuntimeSelections(statuses, action)), [statuses, action]);
|
|
632
|
-
const [index, setIndex] = React.useState(0);
|
|
633
|
-
const [selected, setSelected] = React.useState(initialSelected);
|
|
634
|
-
const [error, setError] = React.useState('');
|
|
635
|
-
|
|
636
|
-
function toggle(status) {
|
|
637
|
-
setError('');
|
|
638
|
-
if (!isSelectableRuntimeStatus(status, action)) {
|
|
639
|
-
setError(unavailableRuntimeSelectionMessage(status, action));
|
|
640
|
-
return;
|
|
641
|
-
}
|
|
642
|
-
setSelected((current) => {
|
|
643
|
-
const next = new Set(current);
|
|
644
|
-
if (next.has(status.runtime)) {
|
|
645
|
-
next.delete(status.runtime);
|
|
646
|
-
} else {
|
|
647
|
-
next.add(status.runtime);
|
|
648
|
-
}
|
|
649
|
-
return next;
|
|
650
|
-
});
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
useInput((input, key) => {
|
|
654
|
-
if (key.upArrow || input === 'k') {
|
|
655
|
-
setIndex((current) => Math.max(0, current - 1));
|
|
656
|
-
} else if (key.downArrow || input === 'j') {
|
|
657
|
-
setIndex((current) => Math.min(statuses.length - 1, current + 1));
|
|
658
|
-
} else if (key.home) {
|
|
659
|
-
setIndex(0);
|
|
660
|
-
} else if (key.end) {
|
|
661
|
-
setIndex(statuses.length - 1);
|
|
662
|
-
} else if (input === ' ') {
|
|
663
|
-
toggle(statuses[index]);
|
|
664
|
-
} else if (input === 'a') {
|
|
665
|
-
setError('');
|
|
666
|
-
setSelected(new Set(action === 'update' ? selectableRuntimeSelections(statuses, action) : RUNTIMES));
|
|
667
|
-
} else if (input === 'i') {
|
|
668
|
-
setError('');
|
|
669
|
-
setSelected(new Set(detectedRuntimeSelections(statuses, action)));
|
|
670
|
-
} else if (key.return || /[\r\n]/.test(input)) {
|
|
671
|
-
const runtimes = RUNTIMES.filter((runtime) => selected.has(runtime));
|
|
672
|
-
if (runtimes.length === 0) {
|
|
673
|
-
setError(emptyRuntimeSelectionMessage(statuses, action));
|
|
674
|
-
return;
|
|
675
|
-
}
|
|
676
|
-
onSubmit(runtimes);
|
|
677
|
-
}
|
|
678
|
-
});
|
|
679
|
-
|
|
680
|
-
return h(Box, { flexDirection: 'column' },
|
|
681
|
-
h(Text, { bold: true }, `Runtimes to ${action}`),
|
|
682
|
-
h(Text, { dimColor: true }, `${action === 'update' ? 'Space toggles installed runtimes. a selects installed runtimes.' : 'Space toggles. a selects all.'} i selects detected installs. Enter continues.`),
|
|
683
|
-
...statuses.map((status, itemIndex) => {
|
|
684
|
-
const checked = selected.has(status.runtime) ? '[x]' : '[ ]';
|
|
685
|
-
const cursor = itemIndex === index ? '>' : ' ';
|
|
686
|
-
return h(Text, {
|
|
687
|
-
key: status.runtime,
|
|
688
|
-
color: itemIndex === index ? 'cyan' : undefined,
|
|
689
|
-
}, `${cursor} ${checked} ${status.runtime.padEnd(12)} ${status.detail} (${displayPath(status.targetRoot)})`);
|
|
690
|
-
}),
|
|
691
|
-
error ? h(Text, { color: 'red' }, error) : null
|
|
692
|
-
);
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
function ConfirmUninstall({ React, Box, Text, useInput, h, runtimes, onSubmit }) {
|
|
696
|
-
const [text, setText] = React.useState('');
|
|
697
|
-
const [error, setError] = React.useState('');
|
|
698
|
-
|
|
699
|
-
useInput((input, key) => {
|
|
700
|
-
const submit = key.return || /[\r\n]/.test(input);
|
|
701
|
-
if (submit) {
|
|
702
|
-
const printable = input.replace(/[^\x20-\x7E]/g, '');
|
|
703
|
-
const nextText = `${text}${printable}`;
|
|
704
|
-
if (nextText.trim().toLowerCase() === 'uninstall') {
|
|
705
|
-
onSubmit();
|
|
706
|
-
return;
|
|
707
|
-
}
|
|
708
|
-
setError('Type uninstall to continue.');
|
|
709
|
-
} else if (key.backspace || key.delete) {
|
|
710
|
-
setError('');
|
|
711
|
-
setText((current) => current.slice(0, -1));
|
|
712
|
-
} else if (!key.ctrl && input) {
|
|
713
|
-
const printable = input.replace(/[^\x20-\x7E]/g, '');
|
|
714
|
-
if (printable) {
|
|
715
|
-
setError('');
|
|
716
|
-
setText((current) => `${current}${printable}`);
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
});
|
|
720
|
-
|
|
721
|
-
return h(Box, { flexDirection: 'column' },
|
|
722
|
-
h(Text, { bold: true, color: 'yellow' }, 'Confirm uninstall'),
|
|
723
|
-
h(Text, null, `Selected runtimes: ${runtimes.join(', ')}`),
|
|
724
|
-
h(Text, { dimColor: true }, 'Only manifest-managed files and generated clean-room hook entries are removed.'),
|
|
725
|
-
h(Text, null, `Type uninstall: ${text}`),
|
|
726
|
-
error ? h(Text, { color: 'red' }, error) : null
|
|
727
|
-
);
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
function displayPath(filePath) {
|
|
731
|
-
const home = process.env.HOME;
|
|
732
|
-
if (home && filePath === home) {
|
|
733
|
-
return '~';
|
|
734
|
-
}
|
|
735
|
-
if (home && filePath.startsWith(`${home}${path.sep}`)) {
|
|
736
|
-
return `~/${path.relative(home, filePath)}`;
|
|
737
|
-
}
|
|
738
|
-
return filePath;
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
function usesClaudeGlobalPlugin(layout) {
|
|
742
|
-
return layout.runtime === 'claude' && layout.scope === 'global';
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
function claudePluginSource() {
|
|
746
|
-
return `${CLAUDE_PLUGIN_SOURCE_URL}#v${packageVersion()}`;
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
function truncateCommandOutput(value) {
|
|
750
|
-
const text = String(value || '').trim();
|
|
751
|
-
if (text.length <= 2000) return text;
|
|
752
|
-
return `${text.slice(0, 2000)}...`;
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
function pathIsUnder(candidate, root) {
|
|
756
|
-
return candidate === root || candidate.startsWith(`${root}${path.sep}`);
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
function currentWorkingRoots() {
|
|
760
|
-
const cwd = path.resolve(process.cwd());
|
|
761
|
-
const roots = [cwd];
|
|
762
|
-
try {
|
|
763
|
-
const real = fs.realpathSync.native(cwd);
|
|
764
|
-
if (!roots.includes(real)) roots.push(real);
|
|
765
|
-
} catch {
|
|
766
|
-
// Keep the resolved cwd as the policy root if realpath is unavailable.
|
|
767
|
-
}
|
|
768
|
-
return roots;
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
function pathIsUnderAny(candidate, roots) {
|
|
772
|
-
return roots.some((root) => pathIsUnder(candidate, root));
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
function pathContainsNodeModulesBin(candidate) {
|
|
776
|
-
const parts = path.resolve(candidate).split(path.sep);
|
|
777
|
-
for (let i = 0; i < parts.length - 1; i += 1) {
|
|
778
|
-
if (parts[i] === 'node_modules' && parts[i + 1] === '.bin') return true;
|
|
779
|
-
}
|
|
780
|
-
return false;
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
function unsafeClaudeExecutableReason(filePath, label) {
|
|
784
|
-
if (!filePath || typeof filePath !== 'string' || !path.isAbsolute(filePath)) {
|
|
785
|
-
return `${label} must be an absolute path`;
|
|
786
|
-
}
|
|
787
|
-
const resolved = path.resolve(filePath);
|
|
788
|
-
const cwdRoots = currentWorkingRoots();
|
|
789
|
-
if (pathIsUnderAny(resolved, cwdRoots)) {
|
|
790
|
-
return `${label} must not be under the current working directory`;
|
|
791
|
-
}
|
|
792
|
-
if (pathContainsNodeModulesBin(resolved)) {
|
|
793
|
-
return `${label} must not be under node_modules/.bin`;
|
|
794
|
-
}
|
|
795
|
-
let real;
|
|
796
|
-
try {
|
|
797
|
-
real = fs.realpathSync.native(resolved);
|
|
798
|
-
} catch {
|
|
799
|
-
return `${label} must resolve to an executable file`;
|
|
800
|
-
}
|
|
801
|
-
if (pathIsUnderAny(real, cwdRoots)) {
|
|
802
|
-
return `${label} target must not be under the current working directory`;
|
|
803
|
-
}
|
|
804
|
-
if (pathContainsNodeModulesBin(real)) {
|
|
805
|
-
return `${label} target must not be under node_modules/.bin`;
|
|
806
|
-
}
|
|
807
|
-
try {
|
|
808
|
-
const stat = fs.statSync(real);
|
|
809
|
-
fs.accessSync(real, fs.constants.X_OK);
|
|
810
|
-
if (!stat.isFile()) {
|
|
811
|
-
return `${label} must be an executable regular file`;
|
|
812
|
-
}
|
|
813
|
-
} catch {
|
|
814
|
-
return `${label} must be an executable regular file`;
|
|
815
|
-
}
|
|
816
|
-
return null;
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
function assertClaudeExecutable(filePath, label) {
|
|
820
|
-
const reason = unsafeClaudeExecutableReason(filePath, label);
|
|
821
|
-
if (reason) throw new Error(reason);
|
|
822
|
-
return path.resolve(filePath);
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
function sanitizedPathEntriesForClaude(value) {
|
|
826
|
-
const entries = String(value || '').split(path.delimiter).filter(Boolean);
|
|
827
|
-
const cwdRoots = currentWorkingRoots();
|
|
828
|
-
const seen = new Set();
|
|
829
|
-
return entries.filter((entry) => {
|
|
830
|
-
if (!path.isAbsolute(entry)) return false;
|
|
831
|
-
const normalized = path.resolve(entry);
|
|
832
|
-
if (pathIsUnderAny(normalized, cwdRoots)) return false;
|
|
833
|
-
try {
|
|
834
|
-
if (pathIsUnderAny(fs.realpathSync.native(normalized), cwdRoots)) return false;
|
|
835
|
-
} catch {
|
|
836
|
-
// Nonexistent PATH entries cannot provide claude; leave candidate validation to fail later.
|
|
837
|
-
}
|
|
838
|
-
if (pathContainsNodeModulesBin(normalized)) return false;
|
|
839
|
-
if (seen.has(normalized)) return false;
|
|
840
|
-
seen.add(normalized);
|
|
841
|
-
return true;
|
|
842
|
-
});
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
function sanitizePathForClaude(value) {
|
|
846
|
-
return sanitizedPathEntriesForClaude(value).join(path.delimiter);
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
function resolveClaudeExecutable() {
|
|
850
|
-
const configuredExecutable = process.env[CLAUDE_EXECUTABLE_ENV];
|
|
851
|
-
const searchPath = sanitizePathForClaude(process.env.PATH);
|
|
852
|
-
if (configuredExecutable) {
|
|
853
|
-
return {
|
|
854
|
-
executable: assertClaudeExecutable(configuredExecutable, CLAUDE_EXECUTABLE_ENV),
|
|
855
|
-
searchPath,
|
|
856
|
-
};
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
const entries = sanitizedPathEntriesForClaude(process.env.PATH);
|
|
860
|
-
if (entries.length === 0) {
|
|
861
|
-
throw new Error(`Claude plugin command requires ${CLAUDE_EXECUTABLE_ENV} or a non-empty sanitized PATH`);
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
const candidates = [];
|
|
865
|
-
const seenCandidates = new Set();
|
|
866
|
-
for (const entry of entries) {
|
|
867
|
-
const candidate = path.join(entry, 'claude');
|
|
868
|
-
if (unsafeClaudeExecutableReason(candidate, 'Claude executable')) continue;
|
|
869
|
-
const resolved = path.resolve(candidate);
|
|
870
|
-
let realCandidate;
|
|
871
|
-
try {
|
|
872
|
-
realCandidate = fs.realpathSync.native(resolved);
|
|
873
|
-
} catch {
|
|
874
|
-
continue;
|
|
875
|
-
}
|
|
876
|
-
if (seenCandidates.has(realCandidate)) continue;
|
|
877
|
-
seenCandidates.add(realCandidate);
|
|
878
|
-
candidates.push(resolved);
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
if (candidates.length === 1) {
|
|
882
|
-
return { executable: candidates[0], searchPath };
|
|
883
|
-
}
|
|
884
|
-
if (candidates.length > 1) {
|
|
885
|
-
throw new Error(`Claude plugin command found multiple claude executables on sanitized PATH; set ${CLAUDE_EXECUTABLE_ENV} to the intended absolute executable`);
|
|
886
|
-
}
|
|
887
|
-
throw new Error(`Claude plugin command requires ${CLAUDE_EXECUTABLE_ENV} or a claude executable on sanitized PATH`);
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
function claudePluginEnv(layout, searchPath) {
|
|
891
|
-
return {
|
|
892
|
-
...process.env,
|
|
893
|
-
PATH: searchPath,
|
|
894
|
-
CLAUDE_CONFIG_DIR: layout.targetRoot,
|
|
895
|
-
};
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
function claudeCommandLabel(command, args) {
|
|
899
|
-
return [command, ...args].join(' ');
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
function claudePluginCommandFailure(command, args, result) {
|
|
903
|
-
const parts = [`Claude plugin command failed: ${claudeCommandLabel(command, args)}`];
|
|
904
|
-
if (result.error) {
|
|
905
|
-
parts.push(result.error.message);
|
|
906
|
-
}
|
|
907
|
-
if (result.status !== null && result.status !== undefined) {
|
|
908
|
-
parts.push(`status ${result.status}`);
|
|
909
|
-
}
|
|
910
|
-
if (result.signal) {
|
|
911
|
-
parts.push(`signal ${result.signal}`);
|
|
912
|
-
}
|
|
913
|
-
const stdout = truncateCommandOutput(result.stdout);
|
|
914
|
-
const stderr = truncateCommandOutput(result.stderr);
|
|
915
|
-
if (stdout) parts.push(`stdout: ${stdout}`);
|
|
916
|
-
if (stderr) parts.push(`stderr: ${stderr}`);
|
|
917
|
-
return parts.join('; ');
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
function runClaudePluginCommand(layout, args, options = {}) {
|
|
921
|
-
const { executable: claudeExecutable, searchPath } = resolveClaudeExecutable();
|
|
922
|
-
const result = spawnSync(claudeExecutable, args, {
|
|
923
|
-
encoding: 'utf8',
|
|
924
|
-
env: claudePluginEnv(layout, searchPath),
|
|
925
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
926
|
-
timeout: CLAUDE_PLUGIN_TIMEOUT_MS,
|
|
927
|
-
});
|
|
928
|
-
result.command = claudeExecutable;
|
|
929
|
-
if (result.error || result.status !== 0) {
|
|
930
|
-
throw new Error(claudePluginCommandFailure(claudeExecutable, args, result));
|
|
931
|
-
}
|
|
932
|
-
if (!options.silent) {
|
|
933
|
-
if (result.stdout) process.stdout.write(result.stdout);
|
|
934
|
-
if (result.stderr) process.stderr.write(result.stderr);
|
|
935
|
-
}
|
|
936
|
-
return result;
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
function readClaudePluginJson(layout, args) {
|
|
940
|
-
const result = runClaudePluginCommand(layout, args, { silent: true });
|
|
941
|
-
try {
|
|
942
|
-
const parsed = JSON.parse(result.stdout || '[]');
|
|
943
|
-
return Array.isArray(parsed) ? parsed : [];
|
|
944
|
-
} catch (err) {
|
|
945
|
-
throw new Error(
|
|
946
|
-
`Claude plugin command returned invalid JSON: ${claudeCommandLabel(result.command || 'claude', args)}; ` +
|
|
947
|
-
`stdout: ${truncateCommandOutput(result.stdout)}; ${err.message}`
|
|
948
|
-
);
|
|
949
|
-
}
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
function claudeMarketplaceExists(layout) {
|
|
953
|
-
return readClaudePluginJson(layout, ['plugin', 'marketplace', 'list', '--json'])
|
|
954
|
-
.some((entry) => entry && entry.name === CLAUDE_PLUGIN_MARKETPLACE_NAME);
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
function claudePluginEntry(layout) {
|
|
958
|
-
return readClaudePluginJson(layout, ['plugin', 'list', '--json'])
|
|
959
|
-
.find((entry) => entry && entry.id === CLAUDE_PLUGIN_ID) || null;
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
function claudePluginExists(layout) {
|
|
963
|
-
return Boolean(claudePluginEntry(layout));
|
|
964
|
-
}
|
|
965
|
-
|
|
966
|
-
function claudePluginMetadata(manifest, state = {}) {
|
|
967
|
-
const previous = manifest?.claude_plugin || {};
|
|
968
|
-
const metadata = {
|
|
969
|
-
plugin_id: CLAUDE_PLUGIN_ID,
|
|
970
|
-
plugin_name: CLAUDE_PLUGIN_NAME,
|
|
971
|
-
marketplace_name: CLAUDE_PLUGIN_MARKETPLACE_NAME,
|
|
972
|
-
source_url: CLAUDE_PLUGIN_SOURCE_URL,
|
|
973
|
-
source: claudePluginSource(),
|
|
974
|
-
scope: CLAUDE_PLUGIN_SCOPE,
|
|
975
|
-
version: packageVersion(),
|
|
976
|
-
marketplace_added_by_installer: previous.marketplace_added_by_installer === true ||
|
|
977
|
-
state.marketplaceAdded === true,
|
|
978
|
-
plugin_installed_by_installer: previous.plugin_installed_by_installer === true ||
|
|
979
|
-
state.pluginInstalled === true,
|
|
980
|
-
recorded_at: new Date().toISOString(),
|
|
981
|
-
};
|
|
982
|
-
if (state.installPath || previous.install_path) {
|
|
983
|
-
metadata.install_path = state.installPath || previous.install_path;
|
|
984
|
-
}
|
|
985
|
-
return metadata;
|
|
986
|
-
}
|
|
987
|
-
|
|
988
|
-
function ensureClaudeGlobalPlugin(layout, manifest, options, action) {
|
|
989
|
-
if (!usesClaudeGlobalPlugin(layout)) return null;
|
|
990
|
-
|
|
991
|
-
const source = claudePluginSource();
|
|
992
|
-
if (options.dryRun) {
|
|
993
|
-
const marketplaceVerb = action === 'update' ? 'refresh' : 'add';
|
|
994
|
-
const pluginVerb = action === 'update' ? 'update or install' : 'install';
|
|
995
|
-
console.log(` Claude plugin marketplace: would ${marketplaceVerb} ${source}`);
|
|
996
|
-
console.log(` Claude plugin: would ${pluginVerb} ${CLAUDE_PLUGIN_ID}`);
|
|
997
|
-
return claudePluginMetadata(manifest);
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
const marketplaceWasPresent = claudeMarketplaceExists(layout);
|
|
1001
|
-
console.log(` Claude plugin marketplace: ${source}`);
|
|
1002
|
-
runClaudePluginCommand(layout, [
|
|
1003
|
-
'plugin',
|
|
1004
|
-
'marketplace',
|
|
1005
|
-
'add',
|
|
1006
|
-
source,
|
|
1007
|
-
'--scope',
|
|
1008
|
-
CLAUDE_PLUGIN_SCOPE,
|
|
1009
|
-
]);
|
|
1010
|
-
|
|
1011
|
-
const pluginBefore = claudePluginEntry(layout);
|
|
1012
|
-
const pluginWasPresent = Boolean(pluginBefore);
|
|
1013
|
-
if (action === 'update' && pluginWasPresent) {
|
|
1014
|
-
console.log(` Claude plugin: updating ${CLAUDE_PLUGIN_ID}`);
|
|
1015
|
-
runClaudePluginCommand(layout, ['plugin', 'update', CLAUDE_PLUGIN_ID]);
|
|
1016
|
-
} else if (!pluginWasPresent) {
|
|
1017
|
-
console.log(` Claude plugin: installing ${CLAUDE_PLUGIN_ID}`);
|
|
1018
|
-
runClaudePluginCommand(layout, [
|
|
1019
|
-
'plugin',
|
|
1020
|
-
'install',
|
|
1021
|
-
CLAUDE_PLUGIN_ID,
|
|
1022
|
-
'--scope',
|
|
1023
|
-
CLAUDE_PLUGIN_SCOPE,
|
|
1024
|
-
]);
|
|
1025
|
-
} else {
|
|
1026
|
-
console.log(` Claude plugin: already installed ${CLAUDE_PLUGIN_ID}`);
|
|
1027
|
-
}
|
|
1028
|
-
|
|
1029
|
-
const pluginAfter = claudePluginEntry(layout) || pluginBefore;
|
|
1030
|
-
return claudePluginMetadata(manifest, {
|
|
1031
|
-
marketplaceAdded: !marketplaceWasPresent,
|
|
1032
|
-
pluginInstalled: !pluginWasPresent,
|
|
1033
|
-
installPath: pluginAfter?.installPath,
|
|
1034
|
-
});
|
|
1035
|
-
}
|
|
1036
|
-
|
|
1037
|
-
function removeClaudeGlobalPlugin(layout, manifest, options) {
|
|
1038
|
-
if (!usesClaudeGlobalPlugin(layout)) return;
|
|
1039
|
-
const plugin = manifest?.claude_plugin;
|
|
1040
|
-
if (!plugin) return;
|
|
1041
|
-
|
|
1042
|
-
if (options.dryRun) {
|
|
1043
|
-
if (plugin.plugin_installed_by_installer) {
|
|
1044
|
-
console.log(` Claude plugin: would uninstall ${plugin.plugin_id || CLAUDE_PLUGIN_ID}`);
|
|
1045
|
-
}
|
|
1046
|
-
if (plugin.marketplace_added_by_installer) {
|
|
1047
|
-
console.log(` Claude plugin marketplace: would remove ${plugin.marketplace_name || CLAUDE_PLUGIN_MARKETPLACE_NAME}`);
|
|
1048
|
-
}
|
|
1049
|
-
return;
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
if (plugin.plugin_installed_by_installer) {
|
|
1053
|
-
const pluginId = plugin.plugin_id || CLAUDE_PLUGIN_ID;
|
|
1054
|
-
if (claudePluginExists(layout)) {
|
|
1055
|
-
console.log(` Claude plugin: uninstalling ${pluginId}`);
|
|
1056
|
-
runClaudePluginCommand(layout, ['plugin', 'uninstall', pluginId]);
|
|
1057
|
-
} else {
|
|
1058
|
-
console.log(` Claude plugin: already absent ${pluginId}`);
|
|
1059
|
-
}
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
if (plugin.marketplace_added_by_installer) {
|
|
1063
|
-
const marketplaceName = plugin.marketplace_name || CLAUDE_PLUGIN_MARKETPLACE_NAME;
|
|
1064
|
-
if (claudeMarketplaceExists(layout)) {
|
|
1065
|
-
console.log(` Claude plugin marketplace: removing ${marketplaceName}`);
|
|
1066
|
-
runClaudePluginCommand(layout, ['plugin', 'marketplace', 'remove', marketplaceName]);
|
|
1067
|
-
} else {
|
|
1068
|
-
console.log(` Claude plugin marketplace: already absent ${marketplaceName}`);
|
|
1069
|
-
}
|
|
1070
|
-
}
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
function collectRuntimeStatus(runtime, scope, configDir) {
|
|
1074
|
-
const layout = resolveRuntimeLayout(runtime, scope, { configDir });
|
|
1075
|
-
const base = {
|
|
1076
|
-
runtime,
|
|
1077
|
-
scope,
|
|
1078
|
-
targetRoot: layout.targetRoot,
|
|
1079
|
-
supportsHookRegistration: layout.supportsHookRegistration,
|
|
1080
|
-
state: 'not-installed',
|
|
1081
|
-
detail: 'not installed',
|
|
1082
|
-
installedVersion: null,
|
|
1083
|
-
currentVersion: packageVersion(),
|
|
1084
|
-
hooksMode: null,
|
|
1085
|
-
phase: null,
|
|
1086
|
-
files: 0,
|
|
1087
|
-
missing: 0,
|
|
1088
|
-
modified: 0,
|
|
1089
|
-
stale: 0,
|
|
1090
|
-
unknownConflicts: 0,
|
|
1091
|
-
hookRegistration: layout.supportsHookRegistration ? 'none' : 'unsupported',
|
|
1092
|
-
updateAvailable: false,
|
|
1093
|
-
claudePlugin: null,
|
|
1094
|
-
issues: [],
|
|
1095
|
-
};
|
|
1096
|
-
|
|
1097
|
-
let manifest;
|
|
1098
|
-
try {
|
|
1099
|
-
manifest = readManifest(layout.targetRoot);
|
|
1100
|
-
} catch (err) {
|
|
1101
|
-
return {
|
|
1102
|
-
...base,
|
|
1103
|
-
state: 'error',
|
|
1104
|
-
detail: err.message,
|
|
1105
|
-
issues: [err.message],
|
|
1106
|
-
};
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
const configPath = configPathForRuntime(runtime, layout.targetRoot);
|
|
1110
|
-
const hookState = detectHookRegistration(layout, configPath);
|
|
1111
|
-
if (!manifest) {
|
|
1112
|
-
if (hookState === 'present') {
|
|
1113
|
-
return {
|
|
1114
|
-
...base,
|
|
1115
|
-
state: 'hooks-only',
|
|
1116
|
-
detail: 'managed hooks without install manifest',
|
|
1117
|
-
hookRegistration: 'present',
|
|
1118
|
-
issues: ['managed hooks exist without an install manifest'],
|
|
1119
|
-
};
|
|
1120
|
-
}
|
|
1121
|
-
return base;
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
|
-
const hooksMode = manifest.hooks_mode || 'safe';
|
|
1125
|
-
let desired;
|
|
1126
|
-
let plan;
|
|
1127
|
-
let fileStats;
|
|
1128
|
-
try {
|
|
1129
|
-
desired = buildDesiredFiles(layout, hooksMode);
|
|
1130
|
-
plan = planInstall(layout.targetRoot, desired, manifest);
|
|
1131
|
-
fileStats = manifestFileStats(layout.targetRoot, manifest);
|
|
1132
|
-
} catch (err) {
|
|
1133
|
-
return {
|
|
1134
|
-
...base,
|
|
1135
|
-
state: 'error',
|
|
1136
|
-
detail: err.message,
|
|
1137
|
-
installedVersion: manifest.version || null,
|
|
1138
|
-
hooksMode,
|
|
1139
|
-
phase: manifest.phase || null,
|
|
1140
|
-
hookRegistration: hookState,
|
|
1141
|
-
issues: [err.message],
|
|
1142
|
-
};
|
|
1143
|
-
}
|
|
1144
|
-
const issues = [];
|
|
1145
|
-
if (manifest.phase && manifest.phase !== 'complete') {
|
|
1146
|
-
issues.push(`manifest phase is ${manifest.phase}`);
|
|
1147
|
-
}
|
|
1148
|
-
if (fileStats.missing > 0) {
|
|
1149
|
-
issues.push(`${fileStats.missing} managed file(s) missing`);
|
|
1150
|
-
}
|
|
1151
|
-
if (fileStats.modified > 0) {
|
|
1152
|
-
issues.push(`${fileStats.modified} managed file(s) locally modified`);
|
|
1153
|
-
}
|
|
1154
|
-
if (plan.removals.length > 0) {
|
|
1155
|
-
issues.push(`${plan.removals.length} stale managed file(s)`);
|
|
1156
|
-
}
|
|
1157
|
-
if (plan.unknownConflicts.length > 0) {
|
|
1158
|
-
issues.push(`${plan.unknownConflicts.length} unmanaged package-path conflict(s)`);
|
|
1159
|
-
}
|
|
1160
|
-
if (layout.supportsHookRegistration && hooksMode !== 'copy-only' && hookState !== 'present') {
|
|
1161
|
-
issues.push('managed hook registration missing');
|
|
1162
|
-
}
|
|
1163
|
-
|
|
1164
|
-
const updateAvailable = manifest.version !== packageVersion() ||
|
|
1165
|
-
plan.removals.length > 0 ||
|
|
1166
|
-
plan.unknownConflicts.length > 0 ||
|
|
1167
|
-
fileStats.missing > 0;
|
|
1168
|
-
|
|
1169
|
-
return {
|
|
1170
|
-
...base,
|
|
1171
|
-
state: updateAvailable ? 'update-available' : 'installed',
|
|
1172
|
-
detail: updateAvailable ? 'update available' : 'installed',
|
|
1173
|
-
installedVersion: manifest.version || null,
|
|
1174
|
-
hooksMode,
|
|
1175
|
-
phase: manifest.phase || null,
|
|
1176
|
-
files: Object.keys(manifest.files || {}).length,
|
|
1177
|
-
missing: fileStats.missing,
|
|
1178
|
-
modified: fileStats.modified,
|
|
1179
|
-
stale: plan.removals.length,
|
|
1180
|
-
unknownConflicts: plan.unknownConflicts.length,
|
|
1181
|
-
hookRegistration: hookState,
|
|
1182
|
-
updateAvailable,
|
|
1183
|
-
claudePlugin: manifest.claude_plugin || null,
|
|
1184
|
-
issues,
|
|
1185
|
-
};
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1188
|
-
function detectHookRegistration(layout, configPath) {
|
|
1189
|
-
if (!layout.supportsHookRegistration) {
|
|
1190
|
-
return 'unsupported';
|
|
1191
|
-
}
|
|
1192
|
-
if (!configPath) {
|
|
1193
|
-
return 'unsupported';
|
|
1194
|
-
}
|
|
1195
|
-
try {
|
|
1196
|
-
return hasManagedHookEntries(configPath) ? 'present' : 'missing';
|
|
1197
|
-
} catch (err) {
|
|
1198
|
-
return `error: ${err.message}`;
|
|
1199
|
-
}
|
|
1200
|
-
}
|
|
1201
|
-
|
|
1202
|
-
function manifestFileStats(targetRoot, manifest) {
|
|
1203
|
-
let missing = 0;
|
|
1204
|
-
let modified = 0;
|
|
1205
|
-
for (const relPath of Object.keys(manifest?.files || {})) {
|
|
1206
|
-
const fullPath = assertManagedPath(targetRoot, relPath);
|
|
1207
|
-
if (!fs.existsSync(fullPath)) {
|
|
1208
|
-
missing += 1;
|
|
1209
|
-
continue;
|
|
1210
|
-
}
|
|
1211
|
-
const expected = manifestHash(manifest, relPath);
|
|
1212
|
-
if (expected && fileHash(fullPath) !== expected) {
|
|
1213
|
-
modified += 1;
|
|
1214
|
-
}
|
|
1215
|
-
}
|
|
1216
|
-
return { missing, modified };
|
|
1217
|
-
}
|
|
1218
|
-
|
|
1219
|
-
function printStatusReport(statuses) {
|
|
1220
|
-
console.log(`clean-room-skill package version: ${packageVersion()}`);
|
|
1221
|
-
for (const status of statuses) {
|
|
1222
|
-
console.log(`${status.runtime} (${status.scope}) ${status.state}`);
|
|
1223
|
-
console.log(` target: ${status.targetRoot}`);
|
|
1224
|
-
if (status.installedVersion) {
|
|
1225
|
-
console.log(` version: ${status.installedVersion}${status.installedVersion !== status.currentVersion ? ` -> ${status.currentVersion}` : ''}`);
|
|
1226
|
-
console.log(` phase: ${status.phase || 'unknown'}`);
|
|
1227
|
-
console.log(` hooks: ${status.hooksMode || 'unknown'}; registration ${status.hookRegistration}`);
|
|
1228
|
-
console.log(` files: ${status.files}; missing ${status.missing}; modified ${status.modified}; stale ${status.stale}; conflicts ${status.unknownConflicts}`);
|
|
1229
|
-
if (status.claudePlugin) {
|
|
1230
|
-
console.log(` plugin: ${status.claudePlugin.plugin_id || CLAUDE_PLUGIN_ID}; marketplace ${status.claudePlugin.marketplace_name || CLAUDE_PLUGIN_MARKETPLACE_NAME}`);
|
|
1231
|
-
}
|
|
1232
|
-
} else if (status.hookRegistration === 'present') {
|
|
1233
|
-
console.log(' hooks: managed hook registration present without install manifest');
|
|
1234
|
-
}
|
|
1235
|
-
if (status.issues.length > 0) {
|
|
1236
|
-
console.log(` issues: ${status.issues.join('; ')}`);
|
|
1237
|
-
}
|
|
1238
|
-
}
|
|
1239
|
-
}
|
|
1240
|
-
|
|
1241
|
-
function selectedStatusRuntimes(options) {
|
|
1242
|
-
return options.runtimes.length > 0 ? options.runtimes : [...RUNTIMES];
|
|
1243
|
-
}
|
|
1244
|
-
|
|
1245
|
-
function selectedUpdateRuntimes(options) {
|
|
1246
|
-
if (options.runtimes.length > 0) {
|
|
1247
|
-
return options.runtimes;
|
|
1248
|
-
}
|
|
1249
|
-
return runtimeInstallStatuses(options.scope, options.configDir)
|
|
1250
|
-
.filter((status) => isUpdateTargetStatus(status))
|
|
1251
|
-
.map((status) => status.runtime);
|
|
1252
|
-
}
|
|
1253
|
-
|
|
1254
|
-
function runStatus(options) {
|
|
1255
|
-
const runtimes = selectedStatusRuntimes(options);
|
|
1256
|
-
const statuses = runtimes.map((runtime) =>
|
|
1257
|
-
collectRuntimeStatus(runtime, options.scope, options.configDir)
|
|
1258
|
-
);
|
|
1259
|
-
printStatusReport(statuses);
|
|
1260
|
-
return statuses;
|
|
1261
|
-
}
|
|
1262
|
-
|
|
1263
|
-
function resolveTargetRoot(runtime, scope, configDir) {
|
|
1264
|
-
return resolveRuntimeLayout(runtime, scope, { configDir }).targetRoot;
|
|
1265
|
-
}
|
|
1266
|
-
|
|
1267
|
-
async function confirmUnknownConflicts(conflicts, options) {
|
|
1268
|
-
if (conflicts.length === 0) return false;
|
|
1269
|
-
if (options.dryRun) return false;
|
|
1270
|
-
if (options.yes || !process.stdin.isTTY) {
|
|
1271
|
-
throw new Error(
|
|
1272
|
-
`unknown existing file(s) would be overwritten: ${conflicts.join(', ')}. ` +
|
|
1273
|
-
'Run interactively to confirm or remove the conflict.'
|
|
1274
|
-
);
|
|
1275
|
-
}
|
|
1276
|
-
console.log('Unknown existing files would be overwritten:');
|
|
1277
|
-
for (const conflict of conflicts) {
|
|
1278
|
-
console.log(` ${conflict}`);
|
|
1279
|
-
}
|
|
1280
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1281
|
-
try {
|
|
1282
|
-
const answer = await rl.question('Overwrite these files? Type yes to continue: ');
|
|
1283
|
-
if (answer.trim() !== 'yes') {
|
|
1284
|
-
throw new Error('aborted by user');
|
|
1285
|
-
}
|
|
1286
|
-
return true;
|
|
1287
|
-
} finally {
|
|
1288
|
-
rl.close();
|
|
1289
|
-
}
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
|
-
function resolvePython3() {
|
|
1293
|
-
const result = spawnSync('python3', ['-c', 'import sys; print(sys.executable)'], {
|
|
1294
|
-
encoding: 'utf8',
|
|
1295
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1296
|
-
timeout: PYTHON_PROBE_TIMEOUT_MS,
|
|
1297
|
-
});
|
|
1298
|
-
if (result.status !== 0) {
|
|
1299
|
-
throw new Error('python3 is required to install clean-room hooks');
|
|
1300
|
-
}
|
|
1301
|
-
const pythonPath = String(result.stdout || '').trim();
|
|
1302
|
-
if (!path.isAbsolute(pythonPath)) {
|
|
1303
|
-
throw new Error('python3 did not resolve to an absolute executable path');
|
|
1304
|
-
}
|
|
1305
|
-
return pythonPath;
|
|
1306
|
-
}
|
|
1307
|
-
|
|
1308
|
-
function validateRuntimeOptions(options) {
|
|
1309
|
-
if (options.configDir && options.runtimes.length > 1) {
|
|
1310
|
-
throw new Error('--config-dir can only be used with one runtime');
|
|
1311
|
-
}
|
|
1312
|
-
for (const runtime of options.runtimes) {
|
|
1313
|
-
const layout = resolveRuntimeLayout(runtime, options.scope, { configDir: options.configDir });
|
|
1314
|
-
if (options.hookMode === 'strict' && !layout.supportsHookRegistration) {
|
|
1315
|
-
throw new Error(`--hooks=strict is not supported for ${runtime}; hook registration is verified only for codex and claude`);
|
|
1316
|
-
}
|
|
1317
|
-
}
|
|
1318
|
-
}
|
|
1319
|
-
|
|
1320
|
-
function prepareHookRegistration(layout, hookMode, options = {}) {
|
|
1321
|
-
if (hookMode === 'copy-only') {
|
|
1322
|
-
return { status: 'copy-only' };
|
|
1323
|
-
}
|
|
1324
|
-
if (!layout.supportsHookRegistration) {
|
|
1325
|
-
return { status: 'unsupported' };
|
|
1326
|
-
}
|
|
1327
|
-
const configPath = configPathForRuntime(layout.runtime, layout.targetRoot);
|
|
1328
|
-
if (!configPath) return { status: 'unsupported' };
|
|
1329
|
-
if (options.dryRun) {
|
|
1330
|
-
return { status: 'planned', configPath };
|
|
1331
|
-
}
|
|
1332
|
-
const pythonPath = resolvePython3();
|
|
1333
|
-
const wrapperPath = path.join(layout.targetRoot, 'hooks', 'clean-room', 'clean-room-hook.py');
|
|
1334
|
-
const entries = buildHookEntries({ pythonPath, wrapperPath, mode: hookMode });
|
|
1335
|
-
return { status: 'registered', configPath, entries };
|
|
1336
|
-
}
|
|
1337
|
-
|
|
1338
|
-
function hookRegistrationFailureState(hookResult, err) {
|
|
1339
|
-
return {
|
|
1340
|
-
hook_registration: {
|
|
1341
|
-
status: 'failed',
|
|
1342
|
-
config_path: hookResult.configPath,
|
|
1343
|
-
error: err.message,
|
|
1344
|
-
recorded_at: new Date().toISOString(),
|
|
1345
|
-
},
|
|
1346
|
-
};
|
|
1347
|
-
}
|
|
1348
|
-
|
|
1349
|
-
function partialInstallMessage(targetRoot, state, cause) {
|
|
1350
|
-
const causeMessage = cause && cause.message ? cause.message : String(cause);
|
|
1351
|
-
const parts = [
|
|
1352
|
-
`partial install state for ${targetRoot}`,
|
|
1353
|
-
state.files,
|
|
1354
|
-
state.hooks,
|
|
1355
|
-
state.manifest,
|
|
1356
|
-
state.recovery,
|
|
1357
|
-
].filter(Boolean);
|
|
1358
|
-
return `${parts.join('; ')}. Cause: ${causeMessage}`;
|
|
1359
|
-
}
|
|
1360
|
-
|
|
1361
|
-
async function installRuntime(runtime, options) {
|
|
1362
|
-
const layout = resolveRuntimeLayout(runtime, options.scope, { configDir: options.configDir });
|
|
1363
|
-
const targetRoot = layout.targetRoot;
|
|
1364
|
-
await withTargetInstallLock(targetRoot, options.dryRun, async () => {
|
|
1365
|
-
const manifest = readManifest(targetRoot);
|
|
1366
|
-
const desired = buildDesiredFiles(layout, options.hookMode);
|
|
1367
|
-
const plan = planInstall(targetRoot, desired, manifest);
|
|
1368
|
-
const adoptedUnknowns = await confirmUnknownConflicts(plan.unknownConflicts, options);
|
|
1369
|
-
|
|
1370
|
-
const verb = options.operation === 'update' ? 'update' : 'install';
|
|
1371
|
-
console.log(`${options.dryRun ? `Would ${verb}` : activeVerb(verb)} ${runtime} to ${targetRoot}`);
|
|
1372
|
-
console.log(` files: ${plan.writes.length}`);
|
|
1373
|
-
if (plan.removals.length) console.log(` stale managed removals: ${plan.removals.length}`);
|
|
1374
|
-
if (plan.backups.length || adoptedUnknowns) {
|
|
1375
|
-
console.log(` backups: ${plan.backups.length + (adoptedUnknowns ? plan.unknownConflicts.length : 0)}`);
|
|
1376
|
-
}
|
|
1377
|
-
if (options.dryRun && plan.unknownConflicts.length) {
|
|
1378
|
-
console.log(` unknown conflicts: ${plan.unknownConflicts.length}`);
|
|
1379
|
-
}
|
|
1380
|
-
|
|
1381
|
-
const hookResult = prepareHookRegistration(layout, options.hookMode, { dryRun: options.dryRun });
|
|
1382
|
-
const pluginState = ensureClaudeGlobalPlugin(layout, manifest, options, verb);
|
|
1383
|
-
const installState = pluginState ? { claude_plugin: pluginState } : {};
|
|
1384
|
-
// Install order is files, installing manifest, hook config, then complete manifest.
|
|
1385
|
-
// The installing manifest gives repair/uninstall a durable handle if hook config write fails.
|
|
1386
|
-
let result;
|
|
1387
|
-
try {
|
|
1388
|
-
result = applyInstall(targetRoot, desired, manifest, plan, options);
|
|
1389
|
-
} catch (err) {
|
|
1390
|
-
throw new Error(partialInstallMessage(targetRoot, {
|
|
1391
|
-
files: 'managed files may be partially written',
|
|
1392
|
-
hooks: 'hook config was not updated',
|
|
1393
|
-
manifest: 'install manifest was not written',
|
|
1394
|
-
recovery: 're-run the same install command after fixing the filesystem error',
|
|
1395
|
-
}, err));
|
|
1396
|
-
}
|
|
1397
|
-
if (result) {
|
|
1398
|
-
try {
|
|
1399
|
-
writeInstallManifest(targetRoot, result.manifest, runtime, options.scope, options.hookMode, options.dryRun, {
|
|
1400
|
-
phase: 'installing',
|
|
1401
|
-
...installState,
|
|
1402
|
-
});
|
|
1403
|
-
} catch (err) {
|
|
1404
|
-
throw new Error(partialInstallMessage(targetRoot, {
|
|
1405
|
-
files: 'managed files were written',
|
|
1406
|
-
hooks: 'hook config was not updated',
|
|
1407
|
-
manifest: 'install manifest was not written',
|
|
1408
|
-
recovery: 're-run the same install command to repair manifest tracking before uninstalling',
|
|
1409
|
-
}, err));
|
|
1410
|
-
}
|
|
1411
|
-
}
|
|
1412
|
-
|
|
1413
|
-
let hookConfigWritten = false;
|
|
1414
|
-
if (!options.dryRun && hookResult.status === 'registered') {
|
|
1415
|
-
try {
|
|
1416
|
-
mergeHookEntries(hookResult.configPath, hookResult.entries);
|
|
1417
|
-
hookConfigWritten = true;
|
|
1418
|
-
} catch (err) {
|
|
1419
|
-
let manifestStatus = 'install manifest records phase installing';
|
|
1420
|
-
if (result) {
|
|
1421
|
-
try {
|
|
1422
|
-
writeInstallManifest(
|
|
1423
|
-
targetRoot,
|
|
1424
|
-
result.manifest,
|
|
1425
|
-
runtime,
|
|
1426
|
-
options.scope,
|
|
1427
|
-
options.hookMode,
|
|
1428
|
-
false,
|
|
1429
|
-
{
|
|
1430
|
-
phase: 'installing',
|
|
1431
|
-
...installState,
|
|
1432
|
-
...hookRegistrationFailureState(hookResult, err),
|
|
1433
|
-
}
|
|
1434
|
-
);
|
|
1435
|
-
manifestStatus = 'install manifest records the failed hook registration';
|
|
1436
|
-
} catch {
|
|
1437
|
-
manifestStatus = 'install manifest could not record the failed hook registration';
|
|
1438
|
-
}
|
|
1439
|
-
}
|
|
1440
|
-
throw new Error(partialInstallMessage(targetRoot, {
|
|
1441
|
-
files: 'managed files were written',
|
|
1442
|
-
hooks: 'hook config write failed',
|
|
1443
|
-
manifest: manifestStatus,
|
|
1444
|
-
recovery: 're-run the same install command to repair hook registration',
|
|
1445
|
-
}, err));
|
|
1446
|
-
}
|
|
1447
|
-
}
|
|
1448
|
-
if (hookResult.status === 'unsupported' && options.hookMode === 'safe') {
|
|
1449
|
-
console.log(' hook registration unsupported for this runtime; copied hooks only');
|
|
1450
|
-
}
|
|
1451
|
-
if (hookResult.status === 'planned') {
|
|
1452
|
-
console.log(` hook registration: would update ${hookResult.configPath}`);
|
|
1453
|
-
console.log(' hook registration: python3 required when applying the install');
|
|
1454
|
-
}
|
|
1455
|
-
if (options.hookMode === 'safe') {
|
|
1456
|
-
console.log(' WARNING: safe hooks are installed; clean-room init/onboarding must set role environment variables before enforcement starts');
|
|
1457
|
-
}
|
|
1458
|
-
if (result) {
|
|
1459
|
-
try {
|
|
1460
|
-
writeInstallManifest(targetRoot, result.manifest, runtime, options.scope, options.hookMode, options.dryRun, {
|
|
1461
|
-
phase: 'complete',
|
|
1462
|
-
...installState,
|
|
1463
|
-
});
|
|
1464
|
-
} catch (err) {
|
|
1465
|
-
throw new Error(partialInstallMessage(targetRoot, {
|
|
1466
|
-
files: 'managed files were written',
|
|
1467
|
-
hooks: hookConfigWritten ? 'hook config was updated' : 'hook config was not updated',
|
|
1468
|
-
manifest: hookConfigWritten ? 'install manifest was not completed' : 'install manifest was not written',
|
|
1469
|
-
recovery: 're-run the same install command to repair manifest tracking before uninstalling',
|
|
1470
|
-
}, err));
|
|
1471
|
-
}
|
|
1472
|
-
if (result.backupRoot) {
|
|
1473
|
-
console.log(` backed up modified files to ${result.backupRoot}`);
|
|
1474
|
-
}
|
|
1475
|
-
}
|
|
1476
|
-
});
|
|
1477
|
-
}
|
|
1478
|
-
|
|
1479
|
-
function activeVerb(verb) {
|
|
1480
|
-
if (verb === 'update') return 'Updating';
|
|
1481
|
-
return 'Installing';
|
|
1482
|
-
}
|
|
1483
|
-
|
|
1484
|
-
async function updateRuntime(runtime, options) {
|
|
1485
|
-
const layout = resolveRuntimeLayout(runtime, options.scope, { configDir: options.configDir });
|
|
1486
|
-
const manifest = readManifest(layout.targetRoot);
|
|
1487
|
-
if (!manifest) {
|
|
1488
|
-
console.log(`${options.dryRun ? 'Would skip update' : 'Skipping update'} ${runtime} from ${layout.targetRoot}`);
|
|
1489
|
-
console.log(' no install manifest found');
|
|
1490
|
-
return;
|
|
1491
|
-
}
|
|
1492
|
-
const hookMode = options.hookModeSpecified ? options.hookMode : (manifest.hooks_mode || options.hookMode);
|
|
1493
|
-
await installRuntime(runtime, {
|
|
1494
|
-
...options,
|
|
1495
|
-
operation: 'update',
|
|
1496
|
-
hookMode,
|
|
1497
|
-
hookModeSpecified: true,
|
|
1498
|
-
});
|
|
1499
|
-
}
|
|
1500
|
-
|
|
1501
|
-
function removeHookRegistrations(layout, dryRun) {
|
|
1502
|
-
if (!layout.supportsHookRegistration) return null;
|
|
1503
|
-
const configPath = configPathForRuntime(layout.runtime, layout.targetRoot);
|
|
1504
|
-
if (!configPath) return null;
|
|
1505
|
-
return removeHookEntries(configPath, { dryRun });
|
|
1506
|
-
}
|
|
1507
|
-
|
|
1508
|
-
function desiredFilesForUninstall(layout, hookMode) {
|
|
1509
|
-
try {
|
|
1510
|
-
return buildDesiredFiles(layout, hookMode);
|
|
1511
|
-
} catch (err) {
|
|
1512
|
-
console.log(` untracked file scan skipped: ${err.message}`);
|
|
1513
|
-
return new Map();
|
|
1514
|
-
}
|
|
1515
|
-
}
|
|
1516
|
-
|
|
1517
|
-
async function uninstallRuntime(runtime, options) {
|
|
1518
|
-
const layout = resolveRuntimeLayout(runtime, options.scope, { configDir: options.configDir });
|
|
1519
|
-
const targetRoot = layout.targetRoot;
|
|
1520
|
-
if (!options.dryRun && !fs.existsSync(targetRoot)) {
|
|
1521
|
-
console.log(`Uninstalling ${runtime} from ${targetRoot}`);
|
|
1522
|
-
console.log(' no install manifest found');
|
|
1523
|
-
return;
|
|
1524
|
-
}
|
|
1525
|
-
await withTargetInstallLock(targetRoot, options.dryRun, async () => {
|
|
1526
|
-
const manifest = readManifest(targetRoot);
|
|
1527
|
-
console.log(`${options.dryRun ? 'Would uninstall' : 'Uninstalling'} ${runtime} from ${targetRoot}`);
|
|
1528
|
-
if (!manifest) {
|
|
1529
|
-
console.log(' no install manifest found');
|
|
1530
|
-
removeHookRegistrations(layout, options.dryRun);
|
|
1531
|
-
return;
|
|
1532
|
-
}
|
|
1533
|
-
const desired = desiredFilesForUninstall(layout, manifest.hooks_mode || options.hookMode);
|
|
1534
|
-
const plan = planUninstall(targetRoot, manifest, desired);
|
|
1535
|
-
console.log(` managed removals: ${plan.removals.length}`);
|
|
1536
|
-
if (plan.backups.length) {
|
|
1537
|
-
console.log(` backups: ${plan.backups.length}`);
|
|
1538
|
-
}
|
|
1539
|
-
if (plan.untracked.length) {
|
|
1540
|
-
console.log(` untracked package-path files left in place: ${plan.untracked.length}`);
|
|
1541
|
-
}
|
|
1542
|
-
|
|
1543
|
-
removeClaudeGlobalPlugin(layout, manifest, options);
|
|
1544
|
-
const result = applyUninstall(targetRoot, plan, options.dryRun);
|
|
1545
|
-
if (!options.dryRun) {
|
|
1546
|
-
removeHookRegistrations(layout, false);
|
|
1547
|
-
}
|
|
1548
|
-
if (result?.backupRoot) {
|
|
1549
|
-
console.log(` backed up modified files to ${result.backupRoot}`);
|
|
1550
|
-
}
|
|
1551
|
-
});
|
|
1552
|
-
}
|
|
1553
|
-
|
|
1554
|
-
async function main() {
|
|
1555
|
-
const argv = process.argv.slice(2);
|
|
1556
|
-
if (argv[0] === 'init') {
|
|
1557
|
-
runInit(argv.slice(1));
|
|
1558
|
-
return;
|
|
1559
|
-
}
|
|
1560
|
-
if (argv[0] === 'doctor') {
|
|
1561
|
-
runDoctor(argv.slice(1));
|
|
1562
|
-
return;
|
|
1563
|
-
}
|
|
1564
|
-
if (argv[0] === 'preflight') {
|
|
1565
|
-
runPreflight(argv.slice(1));
|
|
1566
|
-
return;
|
|
1567
|
-
}
|
|
1568
|
-
if (argv[0] === 'run') {
|
|
1569
|
-
await runCleanRoom(parseRunArgs(argv.slice(1)));
|
|
1570
|
-
return;
|
|
1571
|
-
}
|
|
1572
|
-
if (argv[0] === 'status') {
|
|
1573
|
-
const options = parseArgs(argv.slice(1));
|
|
1574
|
-
options.operation = 'status';
|
|
1575
|
-
if (options.configDir && options.runtimes.length === 0) {
|
|
1576
|
-
throw new Error('--config-dir can only be used with one runtime');
|
|
1577
|
-
}
|
|
1578
|
-
if (!options.scope) options.scope = 'global';
|
|
1579
|
-
validateRuntimeOptions(options);
|
|
1580
|
-
runStatus(options);
|
|
1581
|
-
return;
|
|
1582
|
-
}
|
|
1583
|
-
if (argv[0] === 'update') {
|
|
1584
|
-
const options = parseArgs(argv.slice(1));
|
|
1585
|
-
options.operation = 'update';
|
|
1586
|
-
if (options.configDir && options.runtimes.length === 0) {
|
|
1587
|
-
throw new Error('--config-dir can only be used with one runtime');
|
|
1588
|
-
}
|
|
1589
|
-
if (!options.scope) options.scope = 'global';
|
|
1590
|
-
options.runtimes = selectedUpdateRuntimes(options);
|
|
1591
|
-
validateRuntimeOptions(options);
|
|
1592
|
-
if (options.runtimes.length === 0) {
|
|
1593
|
-
console.log(`No installed ${options.scope} runtimes found to update.`);
|
|
1594
|
-
return;
|
|
1595
|
-
}
|
|
1596
|
-
for (const runtime of options.runtimes) {
|
|
1597
|
-
await updateRuntime(runtime, options);
|
|
1598
|
-
}
|
|
1599
|
-
return;
|
|
1600
|
-
}
|
|
1601
|
-
const options = await resolveInteractiveOptions(parseArgs(argv));
|
|
1602
|
-
if (!options.scope) {
|
|
1603
|
-
options.scope = 'global';
|
|
1604
|
-
}
|
|
1605
|
-
validateRuntimeOptions(options);
|
|
1606
|
-
if (operationForOptions(options) === 'status') {
|
|
1607
|
-
if (options.runtimes.length === 0) options.runtimes = [...RUNTIMES];
|
|
1608
|
-
runStatus(options);
|
|
1609
|
-
return;
|
|
1610
|
-
}
|
|
1611
|
-
if (operationForOptions(options) === 'update') {
|
|
1612
|
-
options.runtimes = selectedUpdateRuntimes(options);
|
|
1613
|
-
if (options.runtimes.length === 0) {
|
|
1614
|
-
console.log(`No installed ${options.scope} runtimes found to update.`);
|
|
1615
|
-
return;
|
|
1616
|
-
}
|
|
1617
|
-
}
|
|
1618
|
-
for (const runtime of options.runtimes) {
|
|
1619
|
-
if (operationForOptions(options) === 'uninstall') {
|
|
1620
|
-
await uninstallRuntime(runtime, options);
|
|
1621
|
-
} else if (operationForOptions(options) === 'update') {
|
|
1622
|
-
await updateRuntime(runtime, options);
|
|
1623
|
-
} else {
|
|
1624
|
-
await installRuntime(runtime, options);
|
|
1625
|
-
}
|
|
1626
|
-
}
|
|
1627
|
-
}
|
|
1628
18
|
|
|
1629
19
|
if (require.main === module) {
|
|
1630
20
|
main().catch((err) => {
|