clean-room-skill 0.1.12 → 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.
Files changed (58) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/README.md +32 -5
  5. package/agents/clean-architect.md +3 -0
  6. package/agents/clean-implementer-verifier-shell.md +3 -0
  7. package/agents/clean-polish-reviewer.md +3 -0
  8. package/agents/clean-qa-editor.md +3 -0
  9. package/agents/contaminated-handoff-sanitizer.md +3 -0
  10. package/agents/contaminated-manager-verifier.md +3 -0
  11. package/agents/contaminated-source-analyst.md +3 -0
  12. package/bin/install.js +11 -1621
  13. package/docs/ARCHITECTURE.md +1 -1
  14. package/docs/HOOKS.md +14 -10
  15. package/docs/REFERENCE.md +24 -4
  16. package/examples/codex/.codex/agents/clean-architect.toml +3 -3
  17. package/examples/codex/.codex/agents/clean-polish-reviewer.toml +2 -2
  18. package/examples/codex/.codex/agents/clean-qa-editor.toml +2 -2
  19. package/examples/codex/.codex/agents/contaminated-handoff-sanitizer.toml +2 -2
  20. package/examples/codex/.codex/agents/contaminated-manager-verifier.toml +3 -3
  21. package/examples/codex/.codex/agents/contaminated-source-analyst.toml +2 -2
  22. package/lib/bootstrap.cjs +5 -1
  23. package/lib/doctor.cjs +157 -5
  24. package/lib/hooks.cjs +18 -0
  25. package/lib/install-artifacts.cjs +178 -4
  26. package/lib/install-claude-plugin.cjs +374 -0
  27. package/lib/install-cli.cjs +99 -0
  28. package/lib/install-operations.cjs +376 -0
  29. package/lib/install-options.cjs +149 -0
  30. package/lib/install-runtime-selection.cjs +180 -0
  31. package/lib/install-status.cjs +292 -0
  32. package/lib/install-tui.cjs +359 -0
  33. package/lib/preflight-bootstrap.cjs +39 -0
  34. package/lib/preflight-cli.cjs +95 -0
  35. package/lib/preflight-constants.cjs +25 -0
  36. package/lib/preflight-output.cjs +37 -0
  37. package/lib/preflight-paths.cjs +67 -0
  38. package/lib/preflight-template.cjs +103 -0
  39. package/lib/preflight-validation.cjs +276 -0
  40. package/lib/preflight.cjs +18 -461
  41. package/lib/run-clean-artifacts.cjs +276 -0
  42. package/lib/run-cli.cjs +90 -0
  43. package/lib/run-constants.cjs +171 -0
  44. package/lib/run-controller.cjs +247 -0
  45. package/lib/run-coverage.cjs +350 -0
  46. package/lib/run-hooks.cjs +96 -0
  47. package/lib/run-manifest.cjs +111 -0
  48. package/lib/run-progress.cjs +160 -0
  49. package/lib/run-results.cjs +433 -0
  50. package/lib/run-roots.cjs +230 -0
  51. package/lib/run-stages.cjs +409 -0
  52. package/lib/run.cjs +4 -2254
  53. package/lib/runtime-layout.cjs +12 -5
  54. package/package.json +8 -2
  55. package/plugin.json +1 -1
  56. package/skills/attended/SKILL.md +2 -0
  57. package/skills/clean-room/SKILL.md +2 -2
  58. package/skills/unattended/SKILL.md +2 -0
@@ -0,0 +1,376 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const readline = require('node:readline/promises');
6
+ const { spawnSync } = require('node:child_process');
7
+
8
+ const { withDirectoryLock } = require('./dir-lock.cjs');
9
+ const { assertManagedPath, removeEmptyParents } = require('./fs-utils.cjs');
10
+ const {
11
+ buildHookEntries,
12
+ configPathForRuntime,
13
+ hasManagedOpenCodePlugin,
14
+ mergeHookEntries,
15
+ pluginPathForRuntime,
16
+ removeHookEntries,
17
+ } = require('./hooks.cjs');
18
+ const { buildDesiredFiles } = require('./install-artifacts.cjs');
19
+ const {
20
+ applyInstall,
21
+ applyUninstall,
22
+ planInstall,
23
+ planUninstall,
24
+ readManifest,
25
+ writeInstallManifest,
26
+ } = require('./install-plan.cjs');
27
+ const {
28
+ ensureClaudeGlobalPlugin,
29
+ removeClaudeGlobalPlugin,
30
+ } = require('./install-claude-plugin.cjs');
31
+ const { resolveRuntimeLayout } = require('./runtime-layout.cjs');
32
+
33
+ const INSTALL_LOCK_NAME = '.clean-room-install.lock';
34
+ const INSTALL_LOCK_WAIT_MS = envPositiveInteger('CLEAN_ROOM_INSTALL_LOCK_WAIT_MS', 30_000);
35
+ const INSTALL_LOCK_POLL_MS = 100;
36
+ const PYTHON_PROBE_TIMEOUT_MS = envPositiveInteger('CLEAN_ROOM_INSTALL_PYTHON_TIMEOUT_MS', 10_000);
37
+
38
+ function envPositiveInteger(name, fallback) {
39
+ const value = process.env[name];
40
+ if (value === undefined || value === '') {
41
+ return fallback;
42
+ }
43
+ return /^[1-9][0-9]*$/.test(value) ? Number(value) : fallback;
44
+ }
45
+
46
+ async function withTargetInstallLock(targetRoot, dryRun, fn) {
47
+ if (dryRun) {
48
+ return fn();
49
+ }
50
+
51
+ fs.mkdirSync(targetRoot, { recursive: true });
52
+ const lockPath = assertManagedPath(targetRoot, INSTALL_LOCK_NAME);
53
+ return withDirectoryLock({
54
+ lockPath,
55
+ waitMs: INSTALL_LOCK_WAIT_MS,
56
+ pollMs: INSTALL_LOCK_POLL_MS,
57
+ label: 'install lock',
58
+ }, fn);
59
+ }
60
+
61
+ async function confirmUnknownConflicts(conflicts, options) {
62
+ if (conflicts.length === 0) return false;
63
+ if (options.dryRun) return false;
64
+ if (options.yes || !process.stdin.isTTY) {
65
+ throw new Error(
66
+ `unknown existing file(s) would be overwritten: ${conflicts.join(', ')}. ` +
67
+ 'Run interactively to confirm or remove the conflict.'
68
+ );
69
+ }
70
+ console.log('Unknown existing files would be overwritten:');
71
+ for (const conflict of conflicts) {
72
+ console.log(` ${conflict}`);
73
+ }
74
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
75
+ try {
76
+ const answer = await rl.question('Overwrite these files? Type yes to continue: ');
77
+ if (answer.trim() !== 'yes') {
78
+ throw new Error('aborted by user');
79
+ }
80
+ return true;
81
+ } finally {
82
+ rl.close();
83
+ }
84
+ }
85
+
86
+ function resolvePython3() {
87
+ const result = spawnSync('python3', ['-c', 'import sys; print(sys.executable)'], {
88
+ encoding: 'utf8',
89
+ stdio: ['ignore', 'pipe', 'pipe'],
90
+ timeout: PYTHON_PROBE_TIMEOUT_MS,
91
+ });
92
+ if (result.status !== 0) {
93
+ throw new Error('python3 is required to install clean-room hooks');
94
+ }
95
+ const pythonPath = String(result.stdout || '').trim();
96
+ if (!path.isAbsolute(pythonPath)) {
97
+ throw new Error('python3 did not resolve to an absolute executable path');
98
+ }
99
+ return pythonPath;
100
+ }
101
+
102
+ function prepareHookRegistration(layout, hookMode, options = {}) {
103
+ if (hookMode === 'copy-only') {
104
+ return { status: 'copy-only' };
105
+ }
106
+ if (!layout.supportsHookRegistration) {
107
+ return { status: 'unsupported' };
108
+ }
109
+ if (layout.hookRegistration === 'local-plugin') {
110
+ const pluginPath = pluginPathForRuntime(layout.runtime, layout.targetRoot);
111
+ if (!pluginPath) return { status: 'unsupported' };
112
+ return {
113
+ status: options.dryRun ? 'planned' : 'local-plugin',
114
+ kind: 'local-plugin',
115
+ pluginPath,
116
+ };
117
+ }
118
+ if (layout.hookRegistration !== 'json-config') {
119
+ return { status: 'unsupported' };
120
+ }
121
+ const configPath = configPathForRuntime(layout.runtime, layout.targetRoot);
122
+ if (!configPath) return { status: 'unsupported' };
123
+ if (options.dryRun) {
124
+ return { status: 'planned', kind: 'json-config', configPath };
125
+ }
126
+ const pythonPath = resolvePython3();
127
+ const wrapperPath = path.join(layout.targetRoot, 'hooks', 'clean-room', 'clean-room-hook.py');
128
+ const entries = buildHookEntries({ pythonPath, wrapperPath, mode: hookMode });
129
+ return { status: 'registered', kind: 'json-config', configPath, entries };
130
+ }
131
+
132
+ function hookRegistrationFailureState(hookResult, err) {
133
+ return {
134
+ hook_registration: {
135
+ status: 'failed',
136
+ config_path: hookResult.configPath,
137
+ error: err.message,
138
+ recorded_at: new Date().toISOString(),
139
+ },
140
+ };
141
+ }
142
+
143
+ function partialInstallMessage(targetRoot, state, cause) {
144
+ const causeMessage = cause && cause.message ? cause.message : String(cause);
145
+ const parts = [
146
+ `partial install state for ${targetRoot}`,
147
+ state.files,
148
+ state.hooks,
149
+ state.manifest,
150
+ state.recovery,
151
+ ].filter(Boolean);
152
+ return `${parts.join('; ')}. Cause: ${causeMessage}`;
153
+ }
154
+
155
+ async function installRuntime(runtime, options) {
156
+ const layout = resolveRuntimeLayout(runtime, options.scope, { configDir: options.configDir });
157
+ const targetRoot = layout.targetRoot;
158
+ await withTargetInstallLock(targetRoot, options.dryRun, async () => {
159
+ const manifest = readManifest(targetRoot);
160
+ const desired = buildDesiredFiles(layout, options.hookMode);
161
+ const plan = planInstall(targetRoot, desired, manifest);
162
+ const adoptedUnknowns = await confirmUnknownConflicts(plan.unknownConflicts, options);
163
+
164
+ const verb = options.operation === 'update' ? 'update' : 'install';
165
+ console.log(`${options.dryRun ? `Would ${verb}` : activeVerb(verb)} ${runtime} to ${targetRoot}`);
166
+ console.log(` files: ${plan.writes.length}`);
167
+ if (plan.removals.length) console.log(` stale managed removals: ${plan.removals.length}`);
168
+ if (plan.backups.length || adoptedUnknowns) {
169
+ console.log(` backups: ${plan.backups.length + (adoptedUnknowns ? plan.unknownConflicts.length : 0)}`);
170
+ }
171
+ if (options.dryRun && plan.unknownConflicts.length) {
172
+ console.log(` unknown conflicts: ${plan.unknownConflicts.length}`);
173
+ }
174
+
175
+ const hookResult = prepareHookRegistration(layout, options.hookMode, { dryRun: options.dryRun });
176
+ const pluginState = ensureClaudeGlobalPlugin(layout, manifest, options, verb);
177
+ const installState = pluginState ? { claude_plugin: pluginState } : {};
178
+ // Install order is files, installing manifest, hook config, then complete manifest.
179
+ // The installing manifest gives repair/uninstall a durable handle if hook config write fails.
180
+ let result;
181
+ try {
182
+ result = applyInstall(targetRoot, desired, manifest, plan, options);
183
+ } catch (err) {
184
+ throw new Error(partialInstallMessage(targetRoot, {
185
+ files: 'managed files may be partially written',
186
+ hooks: 'hook config was not updated',
187
+ manifest: 'install manifest was not written',
188
+ recovery: 're-run the same install command after fixing the filesystem error',
189
+ }, err));
190
+ }
191
+ if (result) {
192
+ try {
193
+ writeInstallManifest(targetRoot, result.manifest, runtime, options.scope, options.hookMode, options.dryRun, {
194
+ phase: 'installing',
195
+ ...installState,
196
+ });
197
+ } catch (err) {
198
+ throw new Error(partialInstallMessage(targetRoot, {
199
+ files: 'managed files were written',
200
+ hooks: 'hook config was not updated',
201
+ manifest: 'install manifest was not written',
202
+ recovery: 're-run the same install command to repair manifest tracking before uninstalling',
203
+ }, err));
204
+ }
205
+ }
206
+
207
+ let hookConfigWritten = false;
208
+ if (!options.dryRun && hookResult.status === 'registered') {
209
+ try {
210
+ mergeHookEntries(hookResult.configPath, hookResult.entries);
211
+ hookConfigWritten = true;
212
+ } catch (err) {
213
+ let manifestStatus = 'install manifest records phase installing';
214
+ if (result) {
215
+ try {
216
+ writeInstallManifest(
217
+ targetRoot,
218
+ result.manifest,
219
+ runtime,
220
+ options.scope,
221
+ options.hookMode,
222
+ false,
223
+ {
224
+ phase: 'installing',
225
+ ...installState,
226
+ ...hookRegistrationFailureState(hookResult, err),
227
+ }
228
+ );
229
+ manifestStatus = 'install manifest records the failed hook registration';
230
+ } catch {
231
+ manifestStatus = 'install manifest could not record the failed hook registration';
232
+ }
233
+ }
234
+ throw new Error(partialInstallMessage(targetRoot, {
235
+ files: 'managed files were written',
236
+ hooks: 'hook config write failed',
237
+ manifest: manifestStatus,
238
+ recovery: 're-run the same install command to repair hook registration',
239
+ }, err));
240
+ }
241
+ }
242
+ if (hookResult.status === 'unsupported' && options.hookMode === 'safe') {
243
+ console.log(' hook registration unsupported for this runtime; copied hooks only');
244
+ }
245
+ if (hookResult.status === 'planned' && hookResult.kind === 'json-config') {
246
+ console.log(` hook registration: would update ${hookResult.configPath}`);
247
+ console.log(' hook registration: python3 required when applying the install');
248
+ }
249
+ if (hookResult.status === 'planned' && hookResult.kind === 'local-plugin') {
250
+ console.log(` hook registration: would install local plugin ${hookResult.pluginPath}`);
251
+ }
252
+ if (hookResult.status === 'local-plugin') {
253
+ hookConfigWritten = true;
254
+ }
255
+ if (options.hookMode === 'safe') {
256
+ console.log(' WARNING: safe hooks are installed; clean-room init/onboarding must set role environment variables before enforcement starts');
257
+ }
258
+ if (result) {
259
+ try {
260
+ writeInstallManifest(targetRoot, result.manifest, runtime, options.scope, options.hookMode, options.dryRun, {
261
+ phase: 'complete',
262
+ ...installState,
263
+ });
264
+ } catch (err) {
265
+ throw new Error(partialInstallMessage(targetRoot, {
266
+ files: 'managed files were written',
267
+ hooks: hookConfigWritten ? 'hook config was updated' : 'hook config was not updated',
268
+ manifest: hookConfigWritten ? 'install manifest was not completed' : 'install manifest was not written',
269
+ recovery: 're-run the same install command to repair manifest tracking before uninstalling',
270
+ }, err));
271
+ }
272
+ if (result.backupRoot) {
273
+ console.log(` backed up modified files to ${result.backupRoot}`);
274
+ }
275
+ }
276
+ });
277
+ }
278
+
279
+ function activeVerb(verb) {
280
+ if (verb === 'update') return 'Updating';
281
+ return 'Installing';
282
+ }
283
+
284
+ async function updateRuntime(runtime, options) {
285
+ const layout = resolveRuntimeLayout(runtime, options.scope, { configDir: options.configDir });
286
+ const manifest = readManifest(layout.targetRoot);
287
+ if (!manifest) {
288
+ console.log(`${options.dryRun ? 'Would skip update' : 'Skipping update'} ${runtime} from ${layout.targetRoot}`);
289
+ console.log(' no install manifest found');
290
+ return;
291
+ }
292
+ const hookMode = options.hookModeSpecified ? options.hookMode : (manifest.hooks_mode || options.hookMode);
293
+ await installRuntime(runtime, {
294
+ ...options,
295
+ operation: 'update',
296
+ hookMode,
297
+ hookModeSpecified: true,
298
+ });
299
+ }
300
+
301
+ function removeHookRegistrations(layout, dryRun) {
302
+ if (!layout.supportsHookRegistration) return null;
303
+ if (layout.hookRegistration === 'local-plugin') {
304
+ const pluginPath = pluginPathForRuntime(layout.runtime, layout.targetRoot);
305
+ if (!hasManagedOpenCodePlugin(pluginPath)) return null;
306
+ if (!dryRun) {
307
+ fs.rmSync(assertManagedPath(layout.targetRoot, path.relative(layout.targetRoot, pluginPath)), { force: true });
308
+ removeEmptyParents(path.dirname(pluginPath), layout.targetRoot);
309
+ }
310
+ return { removed: pluginPath };
311
+ }
312
+ const configPath = configPathForRuntime(layout.runtime, layout.targetRoot);
313
+ if (!configPath) return null;
314
+ return removeHookEntries(configPath, { dryRun });
315
+ }
316
+
317
+ function desiredFilesForUninstall(layout, hookMode) {
318
+ try {
319
+ return buildDesiredFiles(layout, hookMode);
320
+ } catch (err) {
321
+ console.log(` untracked file scan skipped: ${err.message}`);
322
+ return new Map();
323
+ }
324
+ }
325
+
326
+ async function uninstallRuntime(runtime, options) {
327
+ const layout = resolveRuntimeLayout(runtime, options.scope, { configDir: options.configDir });
328
+ const targetRoot = layout.targetRoot;
329
+ if (!options.dryRun && !fs.existsSync(targetRoot)) {
330
+ console.log(`Uninstalling ${runtime} from ${targetRoot}`);
331
+ console.log(' no install manifest found');
332
+ return;
333
+ }
334
+ await withTargetInstallLock(targetRoot, options.dryRun, async () => {
335
+ const manifest = readManifest(targetRoot);
336
+ console.log(`${options.dryRun ? 'Would uninstall' : 'Uninstalling'} ${runtime} from ${targetRoot}`);
337
+ if (!manifest) {
338
+ console.log(' no install manifest found');
339
+ removeHookRegistrations(layout, options.dryRun);
340
+ return;
341
+ }
342
+ const desired = desiredFilesForUninstall(layout, manifest.hooks_mode || options.hookMode);
343
+ const plan = planUninstall(targetRoot, manifest, desired);
344
+ console.log(` managed removals: ${plan.removals.length}`);
345
+ if (plan.backups.length) {
346
+ console.log(` backups: ${plan.backups.length}`);
347
+ }
348
+ if (plan.untracked.length) {
349
+ console.log(` untracked package-path files left in place: ${plan.untracked.length}`);
350
+ }
351
+
352
+ removeClaudeGlobalPlugin(layout, manifest, options);
353
+ const result = applyUninstall(targetRoot, plan, options.dryRun);
354
+ if (!options.dryRun) {
355
+ removeHookRegistrations(layout, false);
356
+ }
357
+ if (result?.backupRoot) {
358
+ console.log(` backed up modified files to ${result.backupRoot}`);
359
+ }
360
+ });
361
+ }
362
+
363
+ module.exports = {
364
+ activeVerb,
365
+ confirmUnknownConflicts,
366
+ desiredFilesForUninstall,
367
+ hookRegistrationFailureState,
368
+ installRuntime,
369
+ partialInstallMessage,
370
+ prepareHookRegistration,
371
+ removeHookRegistrations,
372
+ resolvePython3,
373
+ uninstallRuntime,
374
+ updateRuntime,
375
+ withTargetInstallLock,
376
+ };
@@ -0,0 +1,149 @@
1
+ 'use strict';
2
+
3
+ const {
4
+ RUNTIMES,
5
+ RUNTIME_FLAGS,
6
+ resolveRuntimeLayout,
7
+ } = require('./runtime-layout.cjs');
8
+
9
+ const HOOK_MODES = new Set(['safe', 'copy-only', 'strict']);
10
+
11
+ function parseArgs(argv) {
12
+ const options = {
13
+ runtimes: [],
14
+ scope: null,
15
+ dryRun: false,
16
+ yes: false,
17
+ uninstall: false,
18
+ operation: null,
19
+ hookMode: 'safe',
20
+ hookModeSpecified: false,
21
+ configDir: null,
22
+ };
23
+
24
+ for (let i = 0; i < argv.length; i += 1) {
25
+ const arg = argv[i];
26
+ if (RUNTIME_FLAGS[arg]) options.runtimes.push(RUNTIME_FLAGS[arg]);
27
+ else if (arg === '--all') options.runtimes = [...RUNTIMES];
28
+ else if (arg === '--global') options.scope = setExclusive(options.scope, 'global', '--global');
29
+ else if (arg === '--local') options.scope = setExclusive(options.scope, 'local', '--local');
30
+ else if (arg === '--dry-run') options.dryRun = true;
31
+ else if (arg === '--yes') options.yes = true;
32
+ else if (arg === '--uninstall') options.uninstall = true;
33
+ else if (arg === '--no-hooks') {
34
+ options.hookMode = 'copy-only';
35
+ options.hookModeSpecified = true;
36
+ } else if (arg === '--config-dir') {
37
+ i += 1;
38
+ if (i >= argv.length) throw new Error('--config-dir requires a path');
39
+ options.configDir = argv[i];
40
+ } else if (arg.startsWith('--config-dir=')) {
41
+ options.configDir = arg.slice('--config-dir='.length);
42
+ } else if (arg === '--hooks') {
43
+ i += 1;
44
+ if (i >= argv.length) throw new Error('--hooks requires safe, copy-only, or strict');
45
+ options.hookMode = argv[i];
46
+ options.hookModeSpecified = true;
47
+ } else if (arg.startsWith('--hooks=')) {
48
+ options.hookMode = arg.slice('--hooks='.length);
49
+ options.hookModeSpecified = true;
50
+ } else if (arg === '-h' || arg === '--help') {
51
+ printHelp();
52
+ process.exit(0);
53
+ } else {
54
+ throw new Error(`unknown option: ${arg}`);
55
+ }
56
+ }
57
+
58
+ options.runtimes = [...new Set(options.runtimes)];
59
+ if (!HOOK_MODES.has(options.hookMode)) {
60
+ throw new Error('--hooks must be one of safe, copy-only, or strict');
61
+ }
62
+ if (options.configDir && options.runtimes.length > 1) {
63
+ throw new Error('--config-dir can only be used with one runtime');
64
+ }
65
+ return options;
66
+ }
67
+
68
+ function setExclusive(current, next, flag) {
69
+ if (current && current !== next) {
70
+ throw new Error(`${flag} conflicts with --${current}`);
71
+ }
72
+ return next;
73
+ }
74
+
75
+ function printHelp() {
76
+ console.log(`Usage: clean-room-skill [runtime] [scope] [options]
77
+ clean-room-skill init [options]
78
+ clean-room-skill status [runtime] [scope] [options]
79
+ clean-room-skill update [runtime] [scope] [options]
80
+ clean-room-skill preflight [options]
81
+ clean-room-skill run [options]
82
+
83
+ Commands:
84
+ init Create clean-room bootstrap folders and repo guidance
85
+ status Report installed runtime version, drift, and hook state
86
+ update Update installed runtime files without onboarding
87
+ preflight Create or validate a preflight goal contract
88
+ doctor Smoke test generated Codex, Claude, or OpenCode hook registration
89
+ run Execute the bounded inner clean-room controller loop
90
+
91
+ Runtime:
92
+ --codex Install for Codex
93
+ --claude Install for Claude Code
94
+ --antigravity Install for Antigravity
95
+ --gemini Install for Gemini CLI
96
+ --opencode Install for OpenCode
97
+ --kilo Install for Kilo
98
+ --cursor Install for Cursor
99
+ --copilot Install for GitHub Copilot
100
+ --windsurf Install for Windsurf
101
+ --augment Install for Augment
102
+ --trae Install for Trae
103
+ --qwen Install for Qwen Code
104
+ --hermes Install for Hermes Agent
105
+ --codebuddy Install for CodeBuddy
106
+ --all Install for all known runtime layouts
107
+
108
+ Scope:
109
+ --global Install to the runtime user config
110
+ --local Install to the current project config
111
+
112
+ Options:
113
+ --hooks=<mode> safe, copy-only, or strict (default: safe)
114
+ --no-hooks Alias for --hooks=copy-only
115
+ --config-dir <path> Override the target root for one runtime
116
+ --dry-run Print actions without writing files
117
+ --yes Non-interactive mode; unknown conflicts still abort
118
+ --uninstall Remove manifest-managed files and clean-room hook entries
119
+
120
+ Run without runtime and scope flags for interactive install or uninstall.
121
+ Interactive runtime selection accepts names, numbers, ranges, all, or installed.
122
+ `);
123
+ }
124
+
125
+ function operationForOptions(options) {
126
+ if (options.operation) return options.operation;
127
+ return options.uninstall ? 'uninstall' : 'install';
128
+ }
129
+
130
+ function validateRuntimeOptions(options) {
131
+ if (options.configDir && options.runtimes.length > 1) {
132
+ throw new Error('--config-dir can only be used with one runtime');
133
+ }
134
+ for (const runtime of options.runtimes) {
135
+ const layout = resolveRuntimeLayout(runtime, options.scope, { configDir: options.configDir });
136
+ if (options.hookMode === 'strict' && !layout.supportsHookRegistration) {
137
+ throw new Error(`--hooks=strict is not supported for ${runtime}; hook registration is verified only for codex, claude, and opencode`);
138
+ }
139
+ }
140
+ }
141
+
142
+ module.exports = {
143
+ HOOK_MODES,
144
+ operationForOptions,
145
+ parseArgs,
146
+ printHelp,
147
+ setExclusive,
148
+ validateRuntimeOptions,
149
+ };
@@ -0,0 +1,180 @@
1
+ 'use strict';
2
+
3
+ const path = require('node:path');
4
+
5
+ const { RUNTIMES } = require('./runtime-layout.cjs');
6
+
7
+ function displayPath(filePath) {
8
+ const home = process.env.HOME;
9
+ if (home && filePath === home) {
10
+ return '~';
11
+ }
12
+ if (home && filePath.startsWith(`${home}${path.sep}`)) {
13
+ return `~/${path.relative(home, filePath)}`;
14
+ }
15
+ return filePath;
16
+ }
17
+
18
+ function printRuntimeChoices(statuses) {
19
+ console.log('Runtime choices:');
20
+ statuses.forEach((status, index) => {
21
+ const number = String(index + 1).padStart(2, ' ');
22
+ const runtime = status.runtime.padEnd(12, ' ');
23
+ console.log(` ${number}. ${runtime} ${status.detail} (${displayPath(status.targetRoot)})`);
24
+ });
25
+ }
26
+
27
+ function defaultRuntimeSelectionLabel(statuses, action) {
28
+ if ((action === 'uninstall' || action === 'update') && defaultRuntimeSelections(statuses, action).length > 0) {
29
+ return 'installed';
30
+ }
31
+ return 'codex';
32
+ }
33
+
34
+ function defaultRuntimeSelections(statuses, action = 'install') {
35
+ if (action === 'uninstall') {
36
+ return statuses.filter((status) => isInstalledStatus(status)).map((status) => status.runtime);
37
+ }
38
+ if (action === 'update') {
39
+ return selectableRuntimeSelections(statuses, action);
40
+ }
41
+ if (action === 'status') {
42
+ return statuses.map((status) => status.runtime);
43
+ }
44
+ return ['codex'];
45
+ }
46
+
47
+ function detectedRuntimeSelections(statuses, action = 'install') {
48
+ if (action === 'update') {
49
+ return selectableRuntimeSelections(statuses, action);
50
+ }
51
+ return statuses.filter((status) => isInstalledStatus(status)).map((status) => status.runtime);
52
+ }
53
+
54
+ function isUpdateTargetStatus(status) {
55
+ return status?.state === 'installed' || status?.state === 'update-available';
56
+ }
57
+
58
+ function isSelectableRuntimeStatus(status, action = 'install') {
59
+ if (action === 'update') {
60
+ return isUpdateTargetStatus(status);
61
+ }
62
+ return true;
63
+ }
64
+
65
+ function selectableRuntimeSelections(statuses, action = 'install') {
66
+ return statuses
67
+ .filter((status) => isSelectableRuntimeStatus(status, action))
68
+ .map((status) => status.runtime);
69
+ }
70
+
71
+ function statusForRuntime(statuses, runtime) {
72
+ return statuses.find((status) => status.runtime === runtime) || {
73
+ runtime,
74
+ state: 'not-installed',
75
+ };
76
+ }
77
+
78
+ function unavailableRuntimeSelectionMessage(status, action) {
79
+ if (action === 'update') {
80
+ return `${status.runtime} is not installed in this scope; choose Install to add it.`;
81
+ }
82
+ return `${status.runtime} cannot be selected for ${action}.`;
83
+ }
84
+
85
+ function emptyRuntimeSelectionMessage(statuses, action) {
86
+ if (action === 'update' && selectableRuntimeSelections(statuses, action).length === 0) {
87
+ return 'No installed runtimes detected for update. Choose Install instead.';
88
+ }
89
+ return 'Select at least one runtime.';
90
+ }
91
+
92
+ function addRuntimeSelection(selected, runtime, statuses, action) {
93
+ const status = statusForRuntime(statuses, runtime);
94
+ if (!isSelectableRuntimeStatus(status, action)) {
95
+ throw new Error(unavailableRuntimeSelectionMessage(status, action));
96
+ }
97
+ selected.push(runtime);
98
+ }
99
+
100
+ function parseRuntimeSelection(answer, statuses, action = 'install') {
101
+ const text = answer.trim().toLowerCase();
102
+ if (text === '') {
103
+ if (action === 'uninstall' || action === 'update') {
104
+ const installed = defaultRuntimeSelections(statuses, action);
105
+ if (installed.length === 0) {
106
+ throw new Error('no installed runtimes detected; select a runtime explicitly');
107
+ }
108
+ return installed;
109
+ }
110
+ return ['codex'];
111
+ }
112
+
113
+ const selected = [];
114
+ const tokens = text.split(/[,\s]+/).filter(Boolean);
115
+ for (const token of tokens) {
116
+ if (token === 'all') {
117
+ selected.push(...(action === 'update' ? selectableRuntimeSelections(statuses, action) : RUNTIMES));
118
+ continue;
119
+ }
120
+ if (token === 'installed') {
121
+ selected.push(...detectedRuntimeSelections(statuses, action));
122
+ continue;
123
+ }
124
+ const rangeMatch = token.match(/^(\d+)-(\d+)$/);
125
+ if (rangeMatch) {
126
+ const start = Number(rangeMatch[1]);
127
+ const end = Number(rangeMatch[2]);
128
+ if (start > end) {
129
+ throw new Error(`invalid runtime range: ${token}`);
130
+ }
131
+ for (let index = start; index <= end; index += 1) {
132
+ addRuntimeSelection(selected, runtimeForSelectionIndex(statuses, index), statuses, action);
133
+ }
134
+ continue;
135
+ }
136
+ if (/^\d+$/.test(token)) {
137
+ addRuntimeSelection(selected, runtimeForSelectionIndex(statuses, Number(token)), statuses, action);
138
+ continue;
139
+ }
140
+ if (RUNTIMES.includes(token)) {
141
+ addRuntimeSelection(selected, token, statuses, action);
142
+ continue;
143
+ }
144
+ throw new Error(`unsupported runtime selection: ${token}`);
145
+ }
146
+ const unique = [...new Set(selected)];
147
+ if (unique.length === 0) {
148
+ throw new Error('no runtimes selected');
149
+ }
150
+ return unique;
151
+ }
152
+
153
+ function runtimeForSelectionIndex(statuses, index) {
154
+ if (!Number.isInteger(index) || index < 1 || index > statuses.length) {
155
+ throw new Error(`runtime selection out of range: ${index}`);
156
+ }
157
+ return statuses[index - 1].runtime;
158
+ }
159
+
160
+ function isInstalledStatus(status) {
161
+ return status.state === 'installed' || status.state === 'hooks-only';
162
+ }
163
+
164
+ module.exports = {
165
+ addRuntimeSelection,
166
+ defaultRuntimeSelectionLabel,
167
+ defaultRuntimeSelections,
168
+ detectedRuntimeSelections,
169
+ displayPath,
170
+ emptyRuntimeSelectionMessage,
171
+ isInstalledStatus,
172
+ isSelectableRuntimeStatus,
173
+ isUpdateTargetStatus,
174
+ parseRuntimeSelection,
175
+ printRuntimeChoices,
176
+ runtimeForSelectionIndex,
177
+ selectableRuntimeSelections,
178
+ statusForRuntime,
179
+ unavailableRuntimeSelectionMessage,
180
+ };