claude-dev-env 1.57.2 → 1.59.0

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 (77) hide show
  1. package/CLAUDE.md +2 -2
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +36 -3
  3. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +6 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
  5. package/_shared/pr-loop/scripts/reviews_disabled.py +12 -0
  6. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +265 -0
  7. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +29 -0
  8. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  9. package/bin/install.mjs +317 -54
  10. package/bin/install.test.mjs +478 -3
  11. package/docs/CODE_RULES.md +3 -3
  12. package/hooks/blocking/code_rules_annotations_length.py +153 -0
  13. package/hooks/blocking/code_rules_dead_dataclass_field.py +319 -0
  14. package/hooks/blocking/code_rules_duplicate_body.py +287 -0
  15. package/hooks/blocking/code_rules_enforcer.py +175 -21
  16. package/hooks/blocking/code_rules_magic_values.py +98 -0
  17. package/hooks/blocking/code_rules_shared.py +41 -0
  18. package/hooks/blocking/destructive_command_blocker.py +1027 -12
  19. package/hooks/blocking/hook_prose_detector_consistency.py +150 -0
  20. package/hooks/blocking/intent_only_ending_blocker.py +155 -0
  21. package/hooks/blocking/session_handoff_blocker.py +190 -0
  22. package/hooks/blocking/subprocess_budget_completeness.py +380 -0
  23. package/hooks/blocking/test_code_rules_enforcer_annotations.py +225 -0
  24. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
  25. package/hooks/blocking/test_code_rules_enforcer_dead_dataclass_field.py +467 -0
  26. package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +330 -0
  27. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +179 -0
  28. package/hooks/blocking/test_code_rules_enforcer_magic_slice_bounds.py +133 -0
  29. package/hooks/blocking/test_destructive_command_blocker.py +622 -3
  30. package/hooks/blocking/test_hook_prose_detector_consistency.py +265 -0
  31. package/hooks/blocking/test_intent_only_ending_blocker.py +175 -0
  32. package/hooks/blocking/test_session_handoff_blocker.py +312 -0
  33. package/hooks/blocking/test_subprocess_budget_completeness.py +588 -0
  34. package/hooks/blocking/test_workflow_substitution_slot_blocker.py +242 -0
  35. package/hooks/blocking/workflow_substitution_slot_blocker.py +159 -0
  36. package/hooks/hooks.json +25 -0
  37. package/hooks/hooks_constants/code_rules_enforcer_constants.py +16 -0
  38. package/hooks/hooks_constants/dead_dataclass_field_constants.py +25 -0
  39. package/hooks/hooks_constants/destructive_command_segment_constants.py +178 -0
  40. package/hooks/hooks_constants/duplicate_function_body_constants.py +17 -0
  41. package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +30 -0
  42. package/hooks/hooks_constants/messages.py +4 -0
  43. package/hooks/hooks_constants/session_handoff_blocker_constants.py +10 -0
  44. package/hooks/hooks_constants/subprocess_budget_completeness_constants.py +5 -0
  45. package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +22 -0
  46. package/hooks/workflow/auto_formatter.py +26 -1
  47. package/hooks/workflow/test_auto_formatter.py +134 -0
  48. package/package.json +1 -1
  49. package/rules/conservative-action.md +1 -0
  50. package/rules/docstring-prose-matches-implementation.md +43 -0
  51. package/rules/hook-prose-matches-detector.md +26 -0
  52. package/rules/long-horizon-autonomy.md +43 -0
  53. package/rules/no-inline-destructive-literals.md +11 -0
  54. package/rules/workflow-substitution-slots.md +7 -0
  55. package/skills/autoconverge/SKILL.md +68 -6
  56. package/skills/autoconverge/reference/closing-report.md +44 -0
  57. package/skills/autoconverge/reference/convergence.md +7 -3
  58. package/skills/autoconverge/reference/stop-conditions.md +7 -2
  59. package/skills/autoconverge/workflow/autoconverge_report_constants/__init__.py +0 -0
  60. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +105 -0
  61. package/skills/autoconverge/workflow/converge.contract.test.mjs +30 -1
  62. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +265 -0
  63. package/skills/autoconverge/workflow/converge.mjs +106 -38
  64. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -0
  65. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -0
  66. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -0
  67. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -0
  68. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -0
  69. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -0
  70. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -0
  71. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -0
  72. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +259 -0
  73. package/skills/autoconverge/workflow/render_report.py +903 -0
  74. package/skills/autoconverge/workflow/test_render_report.py +484 -0
  75. package/skills/pr-converge/scripts/check_convergence.py +195 -64
  76. package/skills/pr-converge/scripts/test_check_convergence.py +173 -2
  77. package/skills/update/SKILL.md +37 -5
package/bin/install.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, copyFileSync, unlinkSync, rmSync } from 'node:fs';
3
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, copyFileSync, unlinkSync, rmSync, realpathSync } from 'node:fs';
4
4
  import { join, dirname, resolve, relative } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
6
  import { execSync, execFileSync } from 'node:child_process';
@@ -165,18 +165,76 @@ const INSTALL_GROUPS = {
165
165
  ...discoverDependencyGroups(),
166
166
  };
167
167
 
168
- function detectPython() {
169
- const candidates = [
168
+ /**
169
+ * Returns the ordered python interpreter candidates to probe for the given
170
+ * platform. On win32 the `py -3` launcher is probed first because it resolves
171
+ * through the Windows registry and is immune to the Microsoft Store
172
+ * `python.exe` App Execution Alias that otherwise gets baked into settings.json.
173
+ *
174
+ * @param {string} platform A value from `process.platform` (e.g. 'win32', 'linux').
175
+ * @returns {{command: string, versionFlag: string}[]} Candidates in probe order.
176
+ */
177
+ export function pythonCandidatesForPlatform(platform) {
178
+ const windowsOrder = [
179
+ { command: 'py -3', versionFlag: '--version' },
180
+ { command: 'python3', versionFlag: '--version' },
181
+ { command: 'python', versionFlag: '--version' },
182
+ ];
183
+ const defaultOrder = [
170
184
  { command: 'python3', versionFlag: '--version' },
171
185
  { command: 'python', versionFlag: '--version' },
172
186
  { command: 'py -3', versionFlag: '--version' },
173
187
  ];
188
+ return platform === 'win32' ? windowsOrder : defaultOrder;
189
+ }
190
+
191
+ /**
192
+ * Reports whether a resolved interpreter path belongs to the Microsoft Store
193
+ * Python, whose `python.exe` App Execution Alias reparse stub cannot be spawned
194
+ * as a hook subprocess. Both the alias under `Microsoft\WindowsApps` and the
195
+ * package executable under `Program Files\WindowsApps` sit beneath a
196
+ * `WindowsApps` directory, so the installer skips any candidate resolving there.
197
+ *
198
+ * @param {string} executablePath Absolute interpreter path from sys.executable.
199
+ * @returns {boolean} True when the path lives under a WindowsApps directory.
200
+ */
201
+ export function isWindowsStorePythonStub(executablePath) {
202
+ return /[\\/]windowsapps[\\/]/i.test(executablePath);
203
+ }
204
+
205
+ /**
206
+ * Formats an absolute interpreter path as a settings.json hook command prefix:
207
+ * forward-slash separators, double-quoted when the path contains a space so the
208
+ * harness parses the interpreter as a single argument.
209
+ *
210
+ * @param {string} executablePath Absolute interpreter path from sys.executable.
211
+ * @returns {string} The command-prefix form of the interpreter path.
212
+ */
213
+ export function interpreterCommandFromPath(executablePath) {
214
+ const forwardSlashedPath = executablePath.replace(/\\/g, '/');
215
+ return forwardSlashedPath.includes(' ') ? `"${forwardSlashedPath}"` : forwardSlashedPath;
216
+ }
217
+
218
+ /**
219
+ * Picks the interpreter command baked into every managed hook in settings.json.
220
+ * On win32 the first working candidate is resolved to its absolute
221
+ * sys.executable and that path is baked in, so a later PATH change or Microsoft
222
+ * Store update that re-points the `py`/`python` launcher cannot silently break
223
+ * the hooks; candidates resolving to the non-spawnable WindowsApps stub are
224
+ * skipped. Other platforms keep the bare command (e.g. `python3`).
225
+ *
226
+ * @returns {string|null} The interpreter command, or null when none is usable.
227
+ */
228
+ function detectPython() {
229
+ const candidates = pythonCandidatesForPlatform(process.platform);
174
230
  for (const { command, versionFlag } of candidates) {
175
231
  try {
176
232
  const version = execSync(`${command} ${versionFlag}`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
177
- if (version.includes('Python 3.')) {
178
- return command;
179
- }
233
+ if (!version.includes('Python 3.')) continue;
234
+ if (process.platform !== 'win32') return command;
235
+ const executablePath = execSync(`${command} -c "import sys; print(sys.executable)"`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
236
+ if (!executablePath || isWindowsStorePythonStub(executablePath)) continue;
237
+ return interpreterCommandFromPath(executablePath);
180
238
  } catch { /* try next */ }
181
239
  }
182
240
  return null;
@@ -235,22 +293,155 @@ function backupClaudeHubBeforeOverwrite(destPath, incomingPath) {
235
293
  return backupPath;
236
294
  }
237
295
 
238
- function mergeHooks(hooksSourceRoot, pythonCommand) {
239
- const hooksJsonPath = join(hooksSourceRoot, 'hooks', 'hooks.json');
240
- if (!existsSync(hooksJsonPath)) return 0;
241
- const hooksConfig = JSON.parse(readFileSync(hooksJsonPath, 'utf8'));
242
- const settingsPath = join(CLAUDE_HOME, 'settings.json');
243
- let settings = {};
244
- if (existsSync(settingsPath)) {
245
- const raw = readFileSync(settingsPath, 'utf8').trim();
246
- if (raw) {
247
- try { settings = JSON.parse(raw); }
248
- catch { console.error(' ERROR: settings.json is malformed JSON. Fix it and rerun.'); process.exit(1); }
296
+ /**
297
+ * Builds the set of hook script paths this installer manages, each relative to
298
+ * the hooks directory (e.g. 'blocking/code_rules_enforcer.py'), parsed from the
299
+ * `${CLAUDE_PLUGIN_ROOT}/hooks/<path>` references in hooks.json. Inline
300
+ * `python3 -c` commands reference the hooks directory without a script tail and
301
+ * contribute nothing.
302
+ *
303
+ * @param {{hooks: object}} hooksConfig Parsed hooks.json.
304
+ * @returns {Set<string>} Forward-slash relative script paths under hooks/.
305
+ */
306
+ export function managedHookScriptRelativePaths(hooksConfig) {
307
+ const relativePaths = new Set();
308
+ const scriptReferencePattern = /\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/(\S+?\.py)/g;
309
+ for (const matcherGroups of Object.values(hooksConfig.hooks)) {
310
+ for (const sourceGroup of matcherGroups) {
311
+ for (const hook of sourceGroup.hooks) {
312
+ for (const scriptMatch of hook.command.matchAll(scriptReferencePattern)) {
313
+ relativePaths.add(scriptMatch[1]);
314
+ }
315
+ }
316
+ }
317
+ }
318
+ return relativePaths;
319
+ }
320
+
321
+ /**
322
+ * Builds the union of managed hook script paths across the given package source
323
+ * roots by parsing each root's hooks/hooks.json. The installer copies hook
324
+ * scripts into ~/.claude/hooks/ but never copies hooks.json itself, so the
325
+ * uninstall and update-refresh purge must read the managed-hook set from the
326
+ * package source the same way the merge does, never from ~/.claude/hooks/.
327
+ * Roots without a hooks.json contribute nothing.
328
+ *
329
+ * @param {string[]} sourceRoots Package roots that hold a hooks/hooks.json.
330
+ * @returns {Set<string>} Forward-slash relative script paths under hooks/.
331
+ */
332
+ export function managedHookScriptRelativePathsFromSourceRoots(sourceRoots) {
333
+ const relativePaths = new Set();
334
+ for (const sourceRoot of sourceRoots) {
335
+ const hooksJsonPath = join(sourceRoot, 'hooks', 'hooks.json');
336
+ if (!existsSync(hooksJsonPath)) continue;
337
+ const hooksConfig = JSON.parse(readFileSync(hooksJsonPath, 'utf8'));
338
+ for (const relativePath of managedHookScriptRelativePaths(hooksConfig)) {
339
+ relativePaths.add(relativePath);
340
+ }
341
+ }
342
+ return relativePaths;
343
+ }
344
+
345
+ /**
346
+ * Resolves every package source root the installer can copy hooks from: this
347
+ * package plus each resolvable dependency package that ships hooks. The purge
348
+ * reads hooks.json from these roots so it prunes managed entries no matter which
349
+ * package contributed them.
350
+ *
351
+ * @returns {string[]} Distinct package roots, this package first.
352
+ */
353
+ function managedPackageSourceRoots() {
354
+ const dependencyRoots = Object.values(INSTALL_GROUPS)
355
+ .filter(group => group.packageRoot)
356
+ .map(group => group.packageRoot);
357
+ return [...new Set([PACKAGE_ROOT, ...dependencyRoots])];
358
+ }
359
+
360
+ /**
361
+ * Reports whether a settings.json hook command points at one of this installer's
362
+ * managed scripts, no matter how the home directory was written ($HOME, ~,
363
+ * ${HOME}, or an absolute path) or which path separator was used. Matching on
364
+ * the `/.claude/hooks/<relative>` tail lets a reinstall prune stale entries from
365
+ * earlier installs that used a different interpreter prefix, while leaving
366
+ * user-authored hooks outside the managed set untouched.
367
+ *
368
+ * @param {string} commandString The hook command from settings.json.
369
+ * @param {Set<string>} managedHookRelativePaths Managed script paths under hooks/.
370
+ * @returns {boolean} True when the command references a managed script.
371
+ */
372
+ export function commandReferencesManagedHook(commandString, managedHookRelativePaths) {
373
+ const normalizedCommand = commandString.replace(/\\/g, '/');
374
+ if (commandIsInlineManagedValidatorRunner(normalizedCommand)) {
375
+ return true;
376
+ }
377
+ for (const relativePath of managedHookRelativePaths) {
378
+ if (commandTailEndsAtManagedHook(normalizedCommand, relativePath)) {
379
+ return true;
380
+ }
381
+ }
382
+ return false;
383
+ }
384
+
385
+ /**
386
+ * Reports whether a command contains the `/.claude/hooks/<relative>` tail ending
387
+ * at a path boundary: end of string, or an argument separator (whitespace, quote,
388
+ * or semicolon). Anchoring the tail keeps a user hook whose path is the managed
389
+ * tail plus a suffix (`code_rules_enforcer.py.bak`, `a.py/extra/thing.py`) outside
390
+ * the managed set, so it is never pruned.
391
+ *
392
+ * @param {string} normalizedCommand Forward-slash-normalized hook command.
393
+ * @param {string} relativePath Managed script path under hooks/.
394
+ * @returns {boolean} True when the managed tail ends at a path boundary.
395
+ */
396
+ function commandTailEndsAtManagedHook(normalizedCommand, relativePath) {
397
+ const commandArgumentBoundary = /[\s'";]/;
398
+ const managedTail = `/.claude/hooks/${relativePath}`;
399
+ let searchStart = normalizedCommand.indexOf(managedTail);
400
+ while (searchStart !== -1) {
401
+ const characterAfterTail = normalizedCommand[searchStart + managedTail.length];
402
+ if (characterAfterTail === undefined || commandArgumentBoundary.test(characterAfterTail)) {
403
+ return true;
249
404
  }
405
+ searchStart = normalizedCommand.indexOf(managedTail, searchStart + 1);
250
406
  }
407
+ return false;
408
+ }
409
+
410
+ /**
411
+ * Reports whether a settings.json hook command is the inline validators-runner
412
+ * the installer writes in place of a standalone script. That hook inserts the
413
+ * managed hooks directory onto sys.path and imports run_all_validators, so it
414
+ * carries no `<script>.py` tail for managedHookScriptRelativePaths to record.
415
+ * Matching its shape lets a reinstall prune the prior copy before appending the
416
+ * freshly rewritten one, keeping the merge idempotent.
417
+ *
418
+ * @param {string} normalizedCommand Forward-slash-normalized hook command.
419
+ * @returns {boolean} True when the command is the inline validators runner.
420
+ */
421
+ export function commandIsInlineManagedValidatorRunner(normalizedCommand) {
422
+ const inlineValidatorRunnerMarker = /sys\.path\.insert\([^)]*\.claude\/hooks[^)]*\)[\s\S]*run_all_validators/;
423
+ return (
424
+ normalizedCommand.includes('/.claude/hooks') &&
425
+ inlineValidatorRunnerMarker.test(normalizedCommand)
426
+ );
427
+ }
428
+
429
+ /**
430
+ * Merges the installer's managed hook groups into a settings object in memory,
431
+ * pruning every prior managed hook (standalone script or inline validators
432
+ * runner) from each matcher group before appending the freshly rewritten copies
433
+ * so repeated merges stay idempotent. User-authored hooks in the same group are
434
+ * preserved untouched.
435
+ *
436
+ * @param {object} settings The parsed settings.json object (mutated in place).
437
+ * @param {{hooks: object}} hooksConfig Parsed hooks.json.
438
+ * @param {string} pluginRootDir Directory ${CLAUDE_PLUGIN_ROOT} resolves to.
439
+ * @param {string} pythonCommand Interpreter command that replaces python3.
440
+ * @returns {number} Count of matcher groups merged.
441
+ */
442
+ export function mergeHooksIntoSettings(settings, hooksConfig, pluginRootDir, pythonCommand) {
443
+ const managedHookRelativePaths = managedHookScriptRelativePaths(hooksConfig);
251
444
  if (!settings.hooks) settings.hooks = {};
252
- const installedHooksDir = join(CLAUDE_HOME, 'hooks');
253
- const pluginRootDir = CLAUDE_HOME;
254
445
  let groupCount = 0;
255
446
  for (const [eventType, matcherGroups] of Object.entries(hooksConfig.hooks)) {
256
447
  if (!settings.hooks[eventType]) settings.hooks[eventType] = [];
@@ -267,7 +458,7 @@ function mergeHooks(hooksSourceRoot, pythonCommand) {
267
458
  if (existingIndex >= 0) {
268
459
  const existing = settings.hooks[eventType][existingIndex];
269
460
  const userHooks = existing.hooks.filter(
270
- hook => !hook.command.includes(installedHooksDir.replace(/\\/g, '/'))
461
+ hook => !commandReferencesManagedHook(hook.command, managedHookRelativePaths)
271
462
  );
272
463
  settings.hooks[eventType][existingIndex] = {
273
464
  ...existing,
@@ -279,6 +470,51 @@ function mergeHooks(hooksSourceRoot, pythonCommand) {
279
470
  groupCount++;
280
471
  }
281
472
  }
473
+ return groupCount;
474
+ }
475
+
476
+ /**
477
+ * Removes every managed hook (standalone script or inline validators runner)
478
+ * from a settings object in memory, matching each command through
479
+ * commandReferencesManagedHook so entries written with any home-path style
480
+ * ($HOME, ~, ${HOME}, or absolute) and any path separator are pruned. Matcher
481
+ * groups left empty are dropped, and an empty hooks map is removed entirely.
482
+ * User-authored hooks outside the managed set are preserved untouched.
483
+ *
484
+ * @param {object} settings The parsed settings.json object (mutated in place).
485
+ * @param {Set<string>} managedHookRelativePaths Managed script paths under hooks/.
486
+ * @returns {void}
487
+ */
488
+ export function pruneManagedHooksFromSettings(settings, managedHookRelativePaths) {
489
+ if (!settings.hooks) return;
490
+ for (const [eventType, matcherGroups] of Object.entries(settings.hooks)) {
491
+ settings.hooks[eventType] = matcherGroups
492
+ .map(group => ({
493
+ ...group,
494
+ hooks: group.hooks.filter(
495
+ hook => !commandReferencesManagedHook(hook.command, managedHookRelativePaths)
496
+ ),
497
+ }))
498
+ .filter(group => group.hooks.length > 0);
499
+ if (settings.hooks[eventType].length === 0) delete settings.hooks[eventType];
500
+ }
501
+ if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
502
+ }
503
+
504
+ function mergeHooks(hooksSourceRoot, pythonCommand) {
505
+ const hooksJsonPath = join(hooksSourceRoot, 'hooks', 'hooks.json');
506
+ if (!existsSync(hooksJsonPath)) return 0;
507
+ const hooksConfig = JSON.parse(readFileSync(hooksJsonPath, 'utf8'));
508
+ const settingsPath = join(CLAUDE_HOME, 'settings.json');
509
+ let settings = {};
510
+ if (existsSync(settingsPath)) {
511
+ const raw = readFileSync(settingsPath, 'utf8').trim();
512
+ if (raw) {
513
+ try { settings = JSON.parse(raw); }
514
+ catch { console.error(' ERROR: settings.json is malformed JSON. Fix it and rerun.'); process.exit(1); }
515
+ }
516
+ }
517
+ const groupCount = mergeHooksIntoSettings(settings, hooksConfig, CLAUDE_HOME, pythonCommand);
282
518
  writeFileSync(settingsPath, JSON.stringify(settings, null, 4) + '\n');
283
519
  return groupCount;
284
520
  }
@@ -304,7 +540,7 @@ function install(selectedGroups, options = {}) {
304
540
  abortWhenPackageSourceHasConflicts(PACKAGE_ROOT);
305
541
  const pythonCommand = detectPython();
306
542
  if (!pythonCommand) {
307
- console.error('ERROR: Python 3 not found. Install Python 3.8+ and ensure python3, python, or py is on PATH.');
543
+ console.error('ERROR: No usable Python 3 found. Install Python 3.8+ from python.org and ensure py, python3, or python is on PATH. On Windows the Microsoft Store python.exe alias is rejected because it cannot run hooks.');
308
544
  process.exit(1);
309
545
  }
310
546
  console.log(` Python: ${pythonCommand}`);
@@ -564,17 +800,10 @@ function purgeManagedInstallation({ requireManifest }) {
564
800
  if (existsSync(settingsPath)) {
565
801
  const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
566
802
  if (settings.hooks) {
567
- const installedHooksDir = join(CLAUDE_HOME, 'hooks').replace(/\\/g, '/');
568
- for (const [eventType, matcherGroups] of Object.entries(settings.hooks)) {
569
- settings.hooks[eventType] = matcherGroups
570
- .map(group => ({
571
- ...group,
572
- hooks: group.hooks.filter(hook => !hook.command.includes(installedHooksDir)),
573
- }))
574
- .filter(group => group.hooks.length > 0);
575
- if (settings.hooks[eventType].length === 0) delete settings.hooks[eventType];
576
- }
577
- if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
803
+ const managedHookRelativePaths = managedHookScriptRelativePathsFromSourceRoots(
804
+ managedPackageSourceRoots()
805
+ );
806
+ pruneManagedHooksFromSettings(settings, managedHookRelativePaths);
578
807
  writeFileSync(settingsPath, JSON.stringify(settings, null, 4) + '\n');
579
808
  console.log(' Hook entries removed from settings.json');
580
809
  }
@@ -625,28 +854,62 @@ writes the previous contents to ~/.claude/backups/CLAUDE.md.<timestamp>.bak firs
625
854
  `);
626
855
  }
627
856
 
628
- const rawArgs = process.argv.slice(2);
629
- const args = rawArgs.filter((flag) => flag !== '--update');
630
- const isUpdateRefresh = rawArgs.includes('--update');
631
- if (args.includes('--help') || args.includes('-h')) {
632
- printHelp();
633
- } else if (args.includes('--uninstall')) {
634
- uninstall();
635
- } else {
636
- const onlyIndex = args.indexOf('--only');
637
- let selectedGroups = null;
638
- if (onlyIndex !== -1) {
639
- const onlyValue = args[onlyIndex + 1];
640
- if (!onlyValue || onlyValue.startsWith('--')) {
641
- console.error(`ERROR: --only requires a comma-separated list of groups.\nAvailable groups: ${Object.keys(INSTALL_GROUPS).join(', ')}`);
642
- process.exit(1);
643
- }
644
- selectedGroups = onlyValue.split(',').map(name => name.trim());
645
- const invalidGroups = selectedGroups.filter(name => !INSTALL_GROUPS[name]);
646
- if (invalidGroups.length > 0) {
647
- console.error(`ERROR: Unknown group(s): ${invalidGroups.join(', ')}\nAvailable groups: ${Object.keys(INSTALL_GROUPS).join(', ')}`);
648
- process.exit(1);
857
+ /**
858
+ * Reports whether this module is the process entry point (run as
859
+ * `node install.mjs`, or through a bin symlink such as the npm-installed
860
+ * `claude-dev-env` launcher) rather than imported by another module such as the
861
+ * test suite. The install/uninstall dispatch runs only when true, so importing
862
+ * the module carries no side effects.
863
+ *
864
+ * Both sides resolve to their real on-disk paths before comparison, so a
865
+ * symlinked launcher whose target is this module still counts as the entry
866
+ * point even though `process.argv[1]` keeps the symlink path while
867
+ * `import.meta.url` reports the resolved target. When either path cannot be
868
+ * resolved on disk (for example a synthetic path in a unit test), the raw
869
+ * paths are compared instead.
870
+ *
871
+ * @param {string} moduleUrl The module's import.meta.url.
872
+ * @param {string|undefined} entryScriptPath The invoked script path (process.argv[1]).
873
+ * @returns {boolean} True when the module is the process entry point.
874
+ */
875
+ export function invokedAsEntryPoint(moduleUrl, entryScriptPath) {
876
+ if (!entryScriptPath) return false;
877
+ const modulePath = fileURLToPath(moduleUrl);
878
+ return realPathOrSelf(modulePath) === realPathOrSelf(entryScriptPath);
879
+ }
880
+
881
+ function realPathOrSelf(filesystemPath) {
882
+ try {
883
+ return realpathSync(filesystemPath);
884
+ } catch {
885
+ return filesystemPath;
886
+ }
887
+ }
888
+
889
+ if (invokedAsEntryPoint(import.meta.url, process.argv[1])) {
890
+ const rawArgs = process.argv.slice(2);
891
+ const args = rawArgs.filter((flag) => flag !== '--update');
892
+ const isUpdateRefresh = rawArgs.includes('--update');
893
+ if (args.includes('--help') || args.includes('-h')) {
894
+ printHelp();
895
+ } else if (args.includes('--uninstall')) {
896
+ uninstall();
897
+ } else {
898
+ const onlyIndex = args.indexOf('--only');
899
+ let selectedGroups = null;
900
+ if (onlyIndex !== -1) {
901
+ const onlyValue = args[onlyIndex + 1];
902
+ if (!onlyValue || onlyValue.startsWith('--')) {
903
+ console.error(`ERROR: --only requires a comma-separated list of groups.\nAvailable groups: ${Object.keys(INSTALL_GROUPS).join(', ')}`);
904
+ process.exit(1);
905
+ }
906
+ selectedGroups = onlyValue.split(',').map(name => name.trim());
907
+ const invalidGroups = selectedGroups.filter(name => !INSTALL_GROUPS[name]);
908
+ if (invalidGroups.length > 0) {
909
+ console.error(`ERROR: Unknown group(s): ${invalidGroups.join(', ')}\nAvailable groups: ${Object.keys(INSTALL_GROUPS).join(', ')}`);
910
+ process.exit(1);
911
+ }
649
912
  }
913
+ install(selectedGroups, { isUpdateRefresh });
650
914
  }
651
- install(selectedGroups, { isUpdateRefresh });
652
915
  }