claude-dev-env 2.4.0 → 2.5.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 (195) hide show
  1. package/CLAUDE.md +53 -49
  2. package/_shared/pr-loop/scripts/_claude_permissions_common.py +84 -0
  3. package/_shared/pr-loop/scripts/code_rules_gate.py +4 -2
  4. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -306
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +44 -0
  6. package/_shared/pr-loop/scripts/pr_loop_shared_constants/copilot_quota_constants.py +24 -24
  7. package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -107
  8. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +290 -48
  9. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +42 -2
  10. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +36 -0
  11. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +100 -1
  12. package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -497
  13. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +311 -2
  14. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -301
  15. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -85
  16. package/_shared/pr-loop/worker-spawn.md +1 -1
  17. package/agents/CLAUDE.md +2 -1
  18. package/agents/caveman.md +0 -1
  19. package/agents/clasp-deployment-orchestrator.md +0 -1
  20. package/agents/clean-coder.md +0 -1
  21. package/agents/code-advisor.md +0 -1
  22. package/agents/code-quality-agent.md +1 -2
  23. package/agents/code-verifier.md +0 -1
  24. package/agents/deep-research.md +0 -1
  25. package/agents/docs-agent.md +0 -1
  26. package/agents/git-commit-crafter.md +0 -1
  27. package/agents/issue-tracker.md +42 -0
  28. package/agents/plan-packet-validator.md +0 -1
  29. package/agents/pr-description-writer.md +0 -1
  30. package/agents/test_agent_frontmatter.py +67 -18
  31. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +143 -141
  32. package/bin/CLAUDE.md +68 -5
  33. package/bin/ever-shipped-skills.mjs +1 -0
  34. package/bin/install-constants.mjs +88 -0
  35. package/bin/install.mjs +1138 -114
  36. package/bin/install.prune.test.mjs +869 -19
  37. package/bin/install.test.mjs +906 -2
  38. package/commands/implement.md +1 -1
  39. package/commands/right-size.md +1 -1
  40. package/docs/CLAUDE.md +1 -0
  41. package/docs/host-pool-health-monitor.md +102 -0
  42. package/docs/references/CLAUDE.md +4 -2
  43. package/docs/references/advisor-tool.md +13 -0
  44. package/docs/references/code-review-enforcement.md +10 -0
  45. package/docs/references/team-advisor-skill.md +14 -0
  46. package/hooks/blocking/CLAUDE.md +1 -0
  47. package/hooks/blocking/code_review_pr_create_gate.py +7 -3
  48. package/hooks/blocking/code_review_push_gate.py +9 -4
  49. package/hooks/blocking/code_review_stamp_directory_write_blocker.py +8 -0
  50. package/hooks/blocking/config/__init__.py +5 -5
  51. package/hooks/blocking/config/code_review_enforcement_constants.py +4 -1
  52. package/hooks/blocking/config/test_code_review_enforcement_constants.py +5 -0
  53. package/hooks/blocking/config/verified_commit_constants.py +160 -159
  54. package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +256 -0
  55. package/hooks/blocking/pre_tool_use_dispatcher.py +24 -24
  56. package/hooks/blocking/test_code_review_pr_create_gate.py +14 -0
  57. package/hooks/blocking/test_code_review_push_gate.py +16 -0
  58. package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +19 -0
  59. package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +231 -0
  60. package/hooks/blocking/test_pre_tool_use_dispatcher.py +10 -1
  61. package/hooks/blocking/test_verdict_directory_write_blocker.py +808 -808
  62. package/hooks/blocking/test_verification_verdict_store.py +54 -0
  63. package/hooks/blocking/test_verified_commit_gate.py +581 -581
  64. package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -131
  65. package/hooks/blocking/verdict_directory_write_blocker.py +687 -687
  66. package/hooks/blocking/verification_verdict_store.py +1039 -1036
  67. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +167 -167
  68. package/hooks/blocking/verifier_verdict_minter.py +280 -280
  69. package/hooks/git-hooks/test_pre_push.py +25 -0
  70. package/hooks/hooks.json +10 -0
  71. package/hooks/hooks_constants/CLAUDE.md +2 -1
  72. package/hooks/hooks_constants/enter_worktree_prefetch_constants.py +18 -18
  73. package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +48 -0
  74. package/hooks/hooks_constants/ruff_integration_constants.py +16 -0
  75. package/hooks/lifecycle/enter_worktree_origin_prefetch.py +163 -146
  76. package/hooks/lifecycle/test_enter_worktree_origin_prefetch.py +185 -178
  77. package/hooks/pyproject.toml +1 -0
  78. package/hooks/validators/CLAUDE.md +1 -0
  79. package/hooks/validators/config/__init__.py +0 -0
  80. package/hooks/validators/config/directory_exemption_constants.py +183 -0
  81. package/hooks/validators/config/test_directory_exemption_constants.py +21 -0
  82. package/hooks/validators/conftest.py +4 -0
  83. package/hooks/validators/ruff_integration.py +49 -5
  84. package/hooks/validators/run_all_validators.py +206 -9
  85. package/hooks/validators/test_directory_exemption_constants.py +185 -0
  86. package/hooks/validators/test_python_antipattern_checks.py +110 -5
  87. package/hooks/validators/test_ruff_integration.py +92 -1
  88. package/hooks/validators/test_run_all_validators.py +115 -68
  89. package/hooks/validators/test_run_all_validators_pretooluse.py +159 -1
  90. package/package.json +10 -2
  91. package/rules/CLAUDE.md +1 -0
  92. package/rules/docstring-prose-matches-implementation.md +45 -44
  93. package/rules/state-what-is.md +25 -0
  94. package/rules/verified-commit-gate-skip.md +1 -1
  95. package/scripts/CLAUDE.md +1 -0
  96. package/scripts/Capture-PoolHealth.ps1 +410 -0
  97. package/scripts/_code_review_test_support.py +404 -0
  98. package/scripts/claude_chain_runner.py +141 -1
  99. package/scripts/conftest.py +16 -1
  100. package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -1
  101. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +9 -0
  102. package/scripts/resolve_worker_spawn.py +626 -626
  103. package/scripts/spawn_grok_batch.py +672 -672
  104. package/scripts/test_claude_chain_runner.py +131 -0
  105. package/scripts/test_invoke_code_review_chain.py +70 -0
  106. package/scripts/test_invoke_code_review_cli.py +192 -0
  107. package/scripts/test_invoke_code_review_contract.py +256 -0
  108. package/scripts/test_invoke_code_review_git.py +123 -0
  109. package/scripts/test_invoke_code_review_mode.py +99 -0
  110. package/scripts/test_resolve_worker_spawn.py +1014 -1014
  111. package/skills/CLAUDE.md +2 -0
  112. package/skills/auditing-claude-config/SKILL.md +114 -114
  113. package/skills/autoconverge/SKILL.md +427 -427
  114. package/skills/autoconverge/reference/convergence.md +24 -3
  115. package/skills/autoconverge/workflow/CLAUDE.md +1 -0
  116. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +3 -3
  117. package/skills/autoconverge/workflow/converge.contract.test.mjs +1263 -1263
  118. package/skills/autoconverge/workflow/converge.mjs +167 -0
  119. package/skills/autoconverge/workflow/converge.p2-advance.test.mjs +202 -0
  120. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -2
  121. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -2
  122. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -2
  123. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -2
  124. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -2
  125. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -2
  126. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -2
  127. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -2
  128. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +265 -265
  129. package/skills/closeout/SKILL.md +33 -50
  130. package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +8 -0
  131. package/skills/codex-review/scripts/run_codex_review.py +233 -1
  132. package/skills/codex-review/scripts/test_run_codex_review.py +189 -0
  133. package/skills/condensing-instructions/SKILL.md +81 -0
  134. package/skills/copilot-review/SKILL.md +119 -119
  135. package/skills/e-code-review/SKILL.md +52 -0
  136. package/skills/e-code-review/reference/fix.md +54 -0
  137. package/skills/e-code-review/reference/loop.md +43 -0
  138. package/skills/e-code-review/reference/low.md +57 -0
  139. package/skills/e-code-review/reference/medium.md +153 -0
  140. package/skills/e-code-review/reference/xhigh.md +182 -0
  141. package/skills/e-simplify/SKILL.md +97 -0
  142. package/skills/issue-tracker/SKILL.md +92 -0
  143. package/skills/issue-tracker/reference/epic-and-sub-issue-model.md +55 -0
  144. package/skills/issue-tracker/reference/handoff-schema.md +64 -0
  145. package/skills/issue-tracker/reference/operation-matrix.md +41 -0
  146. package/skills/orchestrator/SKILL.md +162 -21
  147. package/skills/orchestrator/scripts/status_gate.py +625 -0
  148. package/skills/orchestrator/scripts/status_gate_constants/__init__.py +1 -0
  149. package/skills/orchestrator/scripts/status_gate_constants/config/__init__.py +1 -0
  150. package/skills/orchestrator/scripts/status_gate_constants/config/constants.py +47 -0
  151. package/skills/orchestrator/scripts/test_status_gate.py +439 -0
  152. package/skills/orchestrator-refresh/SKILL.md +110 -35
  153. package/skills/plan-to-pr/SKILL.md +155 -0
  154. package/skills/plan-to-pr/reference/final-validation-tasks.md +15 -0
  155. package/skills/plan-to-pr/reference/model-routing.md +36 -0
  156. package/skills/plan-to-pr/reference/packet-contract.md +43 -0
  157. package/skills/plan-to-pr/reference/packet-schema.json +57 -0
  158. package/skills/plan-to-pr/reference/process-inventory.md +22 -0
  159. package/skills/plan-to-pr/reference/review-loop.md +33 -0
  160. package/skills/plan-to-pr/reference/run-record.schema.json +27 -0
  161. package/skills/plan-to-pr/reference/self-audit-tasks.md +15 -0
  162. package/skills/plan-to-pr/reference/task-seeds.md +14 -0
  163. package/skills/plan-to-pr/reference/task-ticket.md +38 -0
  164. package/skills/plan-to-pr/scripts/config/__init__.py +1 -0
  165. package/skills/plan-to-pr/scripts/config/constants.py +193 -0
  166. package/skills/plan-to-pr/scripts/create_packet.py +173 -0
  167. package/skills/plan-to-pr/scripts/test_create_packet.py +102 -0
  168. package/skills/plan-to-pr/scripts/test_validate_packet.py +256 -0
  169. package/skills/plan-to-pr/scripts/test_validate_protocol.py +135 -0
  170. package/skills/plan-to-pr/scripts/test_validate_run.py +158 -0
  171. package/skills/plan-to-pr/scripts/validate_packet.py +655 -0
  172. package/skills/plan-to-pr/scripts/validate_protocol.py +622 -0
  173. package/skills/plan-to-pr/scripts/validate_run.py +173 -0
  174. package/skills/plan-to-pr/test_skill_contract.py +207 -0
  175. package/skills/plan-to-pr/test_task_ticket_contract.py +151 -0
  176. package/skills/pr-converge/SKILL.md +472 -469
  177. package/skills/pr-converge/reference/examples.md +3 -3
  178. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  179. package/skills/pr-converge/reference/ground-rules.md +7 -4
  180. package/skills/pr-converge/reference/multi-pr-orchestration.md +4 -1
  181. package/skills/pr-converge/reference/per-tick.md +5 -5
  182. package/skills/pr-converge/reference/progress-checklist.md +1 -1
  183. package/skills/pr-converge/scripts/check_convergence_gates.py +279 -279
  184. package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -507
  185. package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -84
  186. package/skills/pr-converge/test_step5_host_branch.py +1 -1
  187. package/skills/pr-fix-protocol/SKILL.md +1 -1
  188. package/skills/privacy-hygiene/SKILL.md +68 -68
  189. package/skills/prototype/workflows/promotion.md +1 -1
  190. package/skills/release-notes-html/SKILL.md +164 -0
  191. package/skills/task-build/CLAUDE.md +8 -7
  192. package/skills/task-build/SKILL.md +16 -8
  193. package/skills/task-build/reference/tool-routing.md +19 -0
  194. package/scripts/test_invoke_code_review.py +0 -966
  195. package/skills/closeout/reference/issue-body-templates.md +0 -108
package/bin/install.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, copyFileSync, unlinkSync, rmSync, renameSync, realpathSync } from 'node:fs';
4
- import { join, dirname, resolve, relative, basename } from 'node:path';
3
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, copyFileSync, unlinkSync, rmSync, rmdirSync, renameSync, realpathSync, lstatSync } from 'node:fs';
4
+ import { join, dirname, resolve, relative, basename, isAbsolute, extname } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
6
  import { execSync, execFileSync } from 'node:child_process';
7
7
  import { fileURLToPath } from 'node:url';
@@ -10,24 +10,60 @@ import { installAllGitHooks } from './git_hooks_installer.mjs';
10
10
  import { installMypyIniForClaudeHooks } from './install_mypy_ini.mjs';
11
11
  import { expandHomeDirectoryTokensInSettings } from './expand_home_directory_tokens.mjs';
12
12
  import { EVER_SHIPPED_SKILL_NAMES } from './ever-shipped-skills.mjs';
13
+ import {
14
+ SKIPPED_SOURCE_ENTRY_NAMES,
15
+ SKIPPED_SOURCE_FILE_EXTENSIONS,
16
+ RUN_BACKUP_DIRECTORY_NAME_PATTERN,
17
+ MANAGED_SKILLS_DIRECTORY_NAME,
18
+ MANAGED_HOOKS_DIRECTORY_NAME,
19
+ SETTINGS_FILE_NAME,
20
+ MYPY_INI_FILE_NAME,
21
+ } from './install-constants.mjs';
13
22
 
14
23
  const CLAUDE_HOME = join(homedir(), '.claude');
15
24
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
16
25
  const MANIFEST_FILE = join(CLAUDE_HOME, '.claude-dev-env-manifest.json');
26
+ const MYPY_INI_INSTALL_PATH = join(homedir(), MYPY_INI_FILE_NAME);
17
27
  const PACKAGE_NAME = 'claude-dev-env';
18
28
  const PACKAGE_VERSION = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8')).version;
19
29
  const packageRequire = createRequire(import.meta.url);
20
30
 
21
31
  export const CONTENT_DIRECTORIES = ['rules', 'docs', 'commands', 'agents', 'system-prompts', 'scripts', '_shared', 'audit-rubrics'];
22
32
 
33
+ /**
34
+ * Every top-level directory under ~/.claude the installer writes into: the
35
+ * content directories plus the two it fills through their own copy loops. The
36
+ * uninstall purge walks this list to find the root a recorded file belongs to and
37
+ * to drop a managed directory the purge empties, and the full-install stale-file
38
+ * prune walks it to give each root its own diff.
39
+ */
40
+ export const MANAGED_TOP_LEVEL_DIRECTORY_NAMES = [
41
+ ...CONTENT_DIRECTORIES,
42
+ MANAGED_SKILLS_DIRECTORY_NAME,
43
+ MANAGED_HOOKS_DIRECTORY_NAME,
44
+ ];
45
+
23
46
  const SKILL_MANIFEST_FILENAME = 'SKILL.md';
24
47
  const NEVER_PRUNED_SKILL_DIRECTORIES = new Set(['_shared']);
25
48
  const PRUNED_SKILLS_BACKUP_DIRECTORY_NAME = '.claude-dev-env-pruned';
49
+ const RETIRED_SKILL_REASON_LABEL = 'retired';
50
+ const STALE_FILE_REASON_LABEL = 'stale';
51
+ const MANIFEST_FILES_KEY = 'files';
52
+ const MANIFEST_SKILLS_KEY = 'skills';
26
53
 
27
54
  export const CORE_INCLUDE_DIRECTORIES = [
28
55
  'rules', 'docs', 'commands', 'agents', 'audit-rubrics', '_shared', 'scripts',
29
56
  ];
30
57
 
58
+ export const CORE_SKILLS = [
59
+ 'orchestrator', 'orchestrator-refresh', 'team-advisor', 'grokify',
60
+ 'grok-spawn',
61
+ 'anthropic-plan', 'everything-search',
62
+ 'privacy-hygiene',
63
+ 'issue-tracker',
64
+ 'recall', 'remember', 'task-build',
65
+ ];
66
+
31
67
  export function collectPackageSourceConflicts(packageDirectory) {
32
68
  const gitConflictStatusCodes = new Set(['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU']);
33
69
  const porcelainStatusLineMinLength = 4;
@@ -137,13 +173,13 @@ function discoverDependencyGroups() {
137
173
  description: dependencyPackageJson.description || dependencyName,
138
174
  packageRoot: dependencyRoot,
139
175
  };
140
- const skillsDirectory = join(dependencyRoot, 'skills');
176
+ const skillsDirectory = join(dependencyRoot, MANAGED_SKILLS_DIRECTORY_NAME);
141
177
  if (existsSync(skillsDirectory)) {
142
178
  group.skills = readdirSync(skillsDirectory, { withFileTypes: true })
143
179
  .filter(entry => entry.isDirectory())
144
180
  .map(entry => entry.name);
145
181
  }
146
- const hooksDirectory = join(dependencyRoot, 'hooks');
182
+ const hooksDirectory = join(dependencyRoot, MANAGED_HOOKS_DIRECTORY_NAME);
147
183
  if (existsSync(hooksDirectory)) {
148
184
  const hookFiles = collectFiles(hooksDirectory)
149
185
  .filter(file => !file.endsWith('hooks.json'))
@@ -172,16 +208,10 @@ function discoverDependencyGroups() {
172
208
  const dependencyDiscovery = discoverDependencyGroups();
173
209
  const UNRESOLVED_DEPENDENCY_NAMES = dependencyDiscovery.unresolvedDependencyNames;
174
210
 
175
- const INSTALL_GROUPS = {
211
+ export const INSTALL_GROUPS = {
176
212
  core: {
177
213
  description: 'Development standards, hooks, agents, commands',
178
- skills: [
179
- 'orchestrator', 'orchestrator-refresh', 'team-advisor', 'grokify',
180
- 'grok-spawn',
181
- 'anthropic-plan', 'everything-search',
182
- 'privacy-hygiene',
183
- 'recall', 'remember', 'task-build'
184
- ],
214
+ skills: CORE_SKILLS,
185
215
  includeDirectories: CORE_INCLUDE_DIRECTORIES,
186
216
  includeAllHooks: true,
187
217
  },
@@ -267,11 +297,45 @@ function detectPython() {
267
297
  return null;
268
298
  }
269
299
 
270
- function collectFiles(directory) {
300
+ /**
301
+ * Report whether a source entry belongs to a build artifact the installer leaves
302
+ * in the package source.
303
+ *
304
+ * @param {string} entryName One directory entry's name.
305
+ * @returns {boolean} True when the walk skips the entry and everything under it.
306
+ */
307
+ function isSkippedSourceEntry(entryName) {
308
+ if (SKIPPED_SOURCE_ENTRY_NAMES.has(entryName)) return true;
309
+ return SKIPPED_SOURCE_FILE_EXTENSIONS.has(extname(entryName).toLowerCase());
310
+ }
311
+
312
+ /**
313
+ * List every file under a source directory, skipping the build artifacts a
314
+ * contributor's local tooling writes beside the source.
315
+ *
316
+ * Running the Python suites fills the package source with `__pycache__` trees and
317
+ * tool caches. The `files` negations in `package.json` keep them out of the
318
+ * published tarball, with `.npmignore` carrying the same patterns for tooling
319
+ * that reads it, so an `npx` install never sees them; a local
320
+ * `node bin/install.mjs` reads the working tree directly, so the walk itself
321
+ * skips them. `SKIPPED_SOURCE_ENTRY_NAMES` and
322
+ * `SKIPPED_SOURCE_FILE_EXTENSIONS` name what drops out.
323
+ *
324
+ * The skip and the cleanup of artifacts an earlier install already copied are one
325
+ * code path. A `.pyc` a prior manifest records under any managed root sits
326
+ * outside the set this walk returns, so the next full install reads it as stale,
327
+ * moves it into that run's backup root, and drops it from the manifest the run
328
+ * writes.
329
+ *
330
+ * @param {string} directory The absolute source directory to walk.
331
+ * @returns {string[]} Absolute paths of the files the installer copies.
332
+ */
333
+ export function collectFiles(directory) {
271
334
  const collected = [];
272
335
  if (!existsSync(directory)) return collected;
273
336
  const entries = readdirSync(directory, { withFileTypes: true });
274
337
  for (const entry of entries) {
338
+ if (isSkippedSourceEntry(entry.name)) continue;
275
339
  const entryPath = join(directory, entry.name);
276
340
  if (entry.isDirectory()) {
277
341
  collected.push(...collectFiles(entryPath));
@@ -282,14 +346,497 @@ function collectFiles(directory) {
282
346
  return collected;
283
347
  }
284
348
 
285
- function copyTree(sourceBase, destBase) {
349
+ let cachedRunBackupRoot = null;
350
+
351
+ /**
352
+ * Return the one backup directory this install run moves pruned content into.
353
+ *
354
+ * Retired skill directories and the stale files of every managed root share a
355
+ * single timestamped root, so one run leaves one recovery point rather than
356
+ * several.
357
+ *
358
+ * @returns {string} Absolute path to the run's backup root.
359
+ */
360
+ function currentRunBackupRoot() {
361
+ if (cachedRunBackupRoot === null) {
362
+ const runTimestamp = new Date().toISOString().replace(/[:.]/g, '-');
363
+ cachedRunBackupRoot = join(CLAUDE_HOME, PRUNED_SKILLS_BACKUP_DIRECTORY_NAME, runTimestamp);
364
+ }
365
+ return cachedRunBackupRoot;
366
+ }
367
+
368
+ /**
369
+ * Remove every run backup directory sitting beside the one this run wrote, which
370
+ * places the sweep in ~/.claude/.claude-dev-env-pruned.
371
+ *
372
+ * A pruning install leaves one timestamped recovery point, and a recovery point
373
+ * is worth keeping only while it describes content close to what sits on disk.
374
+ * Keeping the newest run holds the recovery the user reaches for and bounds a
375
+ * directory that otherwise grows with every install.
376
+ *
377
+ * Only a direct child whose name carries this installer's timestamp shape is
378
+ * removed, so anything else under the backup directory stays. The pruned-backup
379
+ * directory itself stays too, since it holds the current run. A removal that
380
+ * fails logs a warning and the sweep continues, so retention never ends an
381
+ * install.
382
+ *
383
+ * @param {string} keptRunBackupRoot Absolute path to this run's backup directory.
384
+ * @returns {number} How many superseded run backup directories were removed.
385
+ */
386
+ function removeSupersededRunBackups(keptRunBackupRoot) {
387
+ const prunedBackupDirectory = dirname(keptRunBackupRoot);
388
+ const keptComparisonKey = comparisonKeyForPath(keptRunBackupRoot);
389
+ let allEntries;
390
+ try {
391
+ allEntries = readdirSync(prunedBackupDirectory, { withFileTypes: true });
392
+ } catch (readError) {
393
+ console.warn(` Warning: could not read ${PRUNED_SKILLS_BACKUP_DIRECTORY_NAME} to retire older backups (${readError.message})`);
394
+ return 0;
395
+ }
396
+ let removedCount = 0;
397
+ for (const entry of allEntries) {
398
+ if (!entry.isDirectory()) continue;
399
+ if (!RUN_BACKUP_DIRECTORY_NAME_PATTERN.test(entry.name)) continue;
400
+ const runBackupPath = join(prunedBackupDirectory, entry.name);
401
+ if (comparisonKeyForPath(runBackupPath) === keptComparisonKey) continue;
402
+ try {
403
+ rmSync(runBackupPath, { recursive: true });
404
+ removedCount++;
405
+ } catch (removalError) {
406
+ console.warn(` Warning: could not remove older prune backup ${relative(CLAUDE_HOME, runBackupPath)} (${removalError.message})`);
407
+ }
408
+ }
409
+ return removedCount;
410
+ }
411
+
412
+ /**
413
+ * Remove a directory tree that holds no file, walking depth first.
414
+ *
415
+ * `rmdirSync` removes only an empty directory, so a directory holding content
416
+ * survives and the walk stops there. `moveIntoRunBackup` creates the directories
417
+ * leading to a backup path before it renames, so a run whose every move failed
418
+ * leaves those directories standing with nothing inside them; clearing the run's
419
+ * backup root this way keeps the pruned-backup directory holding recovery points
420
+ * alone.
421
+ *
422
+ * @param {string} directoryPath The absolute directory to clear.
423
+ * @returns {void}
424
+ */
425
+ function removeEmptyDirectoryTree(directoryPath) {
426
+ let childEntries;
427
+ try {
428
+ childEntries = readdirSync(directoryPath, { withFileTypes: true });
429
+ } catch {
430
+ return;
431
+ }
432
+ for (const childEntry of childEntries) {
433
+ if (!childEntry.isDirectory()) continue;
434
+ removeEmptyDirectoryTree(join(directoryPath, childEntry.name));
435
+ }
436
+ try {
437
+ rmdirSync(directoryPath);
438
+ } catch {
439
+ return;
440
+ }
441
+ }
442
+
443
+ /**
444
+ * Retire older run backups once this run has moved content into its own.
445
+ *
446
+ * The sweep answers to what the run moved, so a recovery point is retired only
447
+ * once a newer one holds content. A run that moved nothing leaves every recovery
448
+ * point the user holds where it is, and gives up the empty directories its
449
+ * attempted moves created, so the pruned-backup directory holds recovery points
450
+ * alone.
451
+ *
452
+ * The log line names the run backup and the window it lasts, so a user reading the
453
+ * install output knows where to recover moved content and how long it stays.
454
+ *
455
+ * @param {string} runBackupRoot The run's timestamped backup directory.
456
+ * @param {boolean} didRunMoveContent Whether a move into that directory succeeded.
457
+ * @returns {void}
458
+ */
459
+ export function retainNewestRunBackupOnly(runBackupRoot, didRunMoveContent) {
460
+ if (!existsSync(runBackupRoot)) return;
461
+ if (!didRunMoveContent) {
462
+ removeEmptyDirectoryTree(runBackupRoot);
463
+ return;
464
+ }
465
+ const removedCount = removeSupersededRunBackups(runBackupRoot);
466
+ if (removedCount > 0) {
467
+ console.log(` Prune backups: ${removedCount} older run backup(s) removed — recover moved content from ${PRUNED_SKILLS_BACKUP_DIRECTORY_NAME}/${basename(runBackupRoot)}, which stays until the next pruning install`);
468
+ }
469
+ }
470
+
471
+ /**
472
+ * Move one managed path into the run's backup root, leaving it in place when the
473
+ * move fails.
474
+ *
475
+ * A retired skill directory and a stale file share this mover, so both land under
476
+ * the same timestamped recovery point and both report through the same wording.
477
+ * Content is moved rather than deleted, so a user-authored file that happens to
478
+ * sit inside a managed directory stays recoverable, and a failed move costs at
479
+ * most a cosmetic leftover.
480
+ *
481
+ * A path resolving outside the managed home is left alone with a warning, so a
482
+ * malformed record is caught here rather than inside a rename.
483
+ *
484
+ * @param {string} sourcePath Absolute path under the managed home to move.
485
+ * @param {string} backupRoot The run's timestamped backup directory.
486
+ * @param {string} backupRelativePath Path to mirror the content at inside the backup root.
487
+ * @param {string} reasonLabel Why the path is being moved (`retired` or `stale`).
488
+ * @param {string} managedHomeDirectory The managed home the source must sit under.
489
+ * @returns {boolean} True when the move succeeded.
490
+ */
491
+ function moveIntoRunBackup(
492
+ sourcePath, backupRoot, backupRelativePath, reasonLabel, managedHomeDirectory,
493
+ ) {
494
+ if (!isManagedPath(sourcePath, managedHomeDirectory)) {
495
+ console.warn(` Warning: leaving ${sourcePath} in place — the ${reasonLabel} path resolves outside ${managedHomeDirectory}`);
496
+ return false;
497
+ }
498
+ const backupPath = join(backupRoot, backupRelativePath);
499
+ const displayPath = relative(managedHomeDirectory, sourcePath);
500
+ const backupDisplayPath = relative(managedHomeDirectory, backupPath);
501
+ try {
502
+ mkdirSync(dirname(backupPath), { recursive: true });
503
+ renameSync(sourcePath, backupPath);
504
+ console.log(` ✗ ${displayPath} (${reasonLabel} — moved to ${backupDisplayPath})`);
505
+ return true;
506
+ } catch (moveError) {
507
+ console.warn(` Warning: could not move ${reasonLabel} ${displayPath} to backup, leaving in place (${moveError.message})`);
508
+ return false;
509
+ }
510
+ }
511
+
512
+ /**
513
+ * Report whether a filesystem comparison on this platform ignores letter case.
514
+ *
515
+ * @returns {boolean} True on the platforms whose default filesystem is case-insensitive.
516
+ */
517
+ function isCaseInsensitiveFilesystem() {
518
+ return process.platform === 'win32' || process.platform === 'darwin';
519
+ }
520
+
521
+ /**
522
+ * Build the lookup key two absolute paths are compared through: resolved,
523
+ * forward-slashed, and lowercased wherever the filesystem ignores letter case.
524
+ *
525
+ * Case folding is what keeps a case-only rename safe. A skill whose readme ships
526
+ * as `README.md` over an installed `Readme.md` writes its bytes through the
527
+ * existing on-disk name, so a case-sensitive key would read the installed name as
528
+ * a file this run never wrote and move the freshly written content aside.
529
+ *
530
+ * The `isCaseInsensitive` option carries that decision as a value, so a test names
531
+ * the branch it drives and both branches stay covered on a host of either kind.
532
+ *
533
+ * @param {string} filesystemPath A path to build a comparison key for.
534
+ * @param {{isCaseInsensitive?: boolean}} options Whether keys fold letter case; defaults to this host's filesystem.
535
+ * @returns {string} The comparison key.
536
+ */
537
+ export function comparisonKeyForPath(filesystemPath, options = {}) {
538
+ const { isCaseInsensitive = isCaseInsensitiveFilesystem() } = options;
539
+ const normalizedPath = normalizePathForComparison(resolve(filesystemPath));
540
+ return isCaseInsensitive ? normalizedPath.toLowerCase() : normalizedPath;
541
+ }
542
+
543
+ /**
544
+ * Report whether a path sits strictly inside a directory.
545
+ *
546
+ * @param {string} candidatePath The absolute path to test.
547
+ * @param {string} directoryPath The absolute directory to test against.
548
+ * @returns {boolean} True when the candidate is a descendant of the directory.
549
+ */
550
+ function isInsideDirectory(candidatePath, directoryPath) {
551
+ const relativePath = relative(directoryPath, candidatePath);
552
+ return relativePath !== '' && !relativePath.startsWith('..') && !isAbsolute(relativePath);
553
+ }
554
+
555
+ /**
556
+ * Report whether an absolute path a record names resolves inside ~/.claude.
557
+ *
558
+ * The manifest and the prune both hand raw path strings to code that unlinks or
559
+ * renames them, and a record can arrive malformed — hand-edited, written by an
560
+ * installer running against a different home, or carrying a relative fragment
561
+ * that resolves elsewhere. This guard runs first so a path outside the managed
562
+ * home is skipped with a warning before any root-specific logic sees it. The
563
+ * stale-file prune keeps its own stricter containment test against the
564
+ * destination root it was handed.
565
+ *
566
+ * @param {string} candidatePath The absolute path a record names.
567
+ * @param {string} [managedHomeDirectory] The managed home to test against; defaults to ~/.claude.
568
+ * @returns {boolean} True when the path resolves under the managed home.
569
+ */
570
+ function isManagedPath(candidatePath, managedHomeDirectory = CLAUDE_HOME) {
571
+ return isInsideDirectory(resolve(candidatePath), resolve(managedHomeDirectory));
572
+ }
573
+
574
+ /**
575
+ * Report whether a manifest record names a file this installer writes and may
576
+ * therefore remove.
577
+ *
578
+ * Most of what an install writes sits under ~/.claude, and `isManagedPath`
579
+ * answers for all of it. `installMypyIniForClaudeHooks` writes `~/.mypy.ini` in
580
+ * the home directory, because that is where mypy reads its configuration, and the
581
+ * install records the path. Naming that one file keeps the permitted set an
582
+ * enumeration: every other path in the home directory stays outside it, so a
583
+ * record pointing anywhere else is still skipped with a warning.
584
+ *
585
+ * @param {string} candidatePath The absolute path a manifest record names.
586
+ * @returns {boolean} True when the installer itself writes the path.
587
+ */
588
+ function isRemovableManifestRecord(candidatePath) {
589
+ if (isManagedPath(candidatePath)) return true;
590
+ return comparisonKeyForPath(candidatePath) === comparisonKeyForPath(MYPY_INI_INSTALL_PATH);
591
+ }
592
+
593
+ /**
594
+ * Return the managed top-level directory an installed path sits under.
595
+ *
596
+ * The uninstall walk-up needs a stop root, and ~/.claude is the wrong one: a walk
597
+ * that reaches it would try to remove the user's home configuration directory.
598
+ * Naming the owning managed root keeps the walk inside the tree the installer
599
+ * wrote, and a path under no managed root gets no walk at all.
600
+ *
601
+ * @param {string} installedFilePath The absolute path a manifest record names.
602
+ * @returns {string|null} The absolute managed root, or null when the path sits under none.
603
+ */
604
+ function owningManagedRoot(installedFilePath) {
605
+ const resolvedPath = resolve(installedFilePath);
606
+ for (const directoryName of MANAGED_TOP_LEVEL_DIRECTORY_NAMES) {
607
+ const managedRoot = join(CLAUDE_HOME, directoryName);
608
+ if (isInsideDirectory(resolvedPath, managedRoot)) return managedRoot;
609
+ }
610
+ return null;
611
+ }
612
+
613
+ /**
614
+ * Report whether a path the prior manifest recorded is still a plain file this
615
+ * run may move.
616
+ *
617
+ * A path the user already deleted is skipped in silence. A directory or a symlink
618
+ * standing where a file was recorded is skipped with a warning, so the mover
619
+ * never renames a tree and never follows a link out of ~/.claude.
620
+ *
621
+ * @param {string} candidatePath The absolute path the prior manifest recorded.
622
+ * @returns {boolean} True when the path is a plain file that may be moved.
623
+ */
624
+ function isMovableStaleFile(candidatePath) {
625
+ let entryStats;
626
+ try {
627
+ entryStats = lstatSync(candidatePath);
628
+ } catch {
629
+ return false;
630
+ }
631
+ if (entryStats.isFile()) return true;
632
+ console.warn(
633
+ ` Warning: leaving ${relative(CLAUDE_HOME, candidatePath)} in place — a directory or link stands where the prior install recorded a file`,
634
+ );
635
+ return false;
636
+ }
637
+
638
+ /**
639
+ * Remove directories emptied by a move, walking from a directory up toward a
640
+ * destination root and stopping at the first directory that still holds content.
641
+ *
642
+ * `rmdirSync` removes only an empty directory, so a directory holding a user file
643
+ * survives. Stopping at the destination root keeps the managed root itself in
644
+ * place for the next install.
645
+ *
646
+ * @param {string} startDirectory The absolute directory the moved file sat in.
647
+ * @param {string} destinationRoot The absolute managed root to stop below.
648
+ * @returns {void}
649
+ */
650
+ function removeEmptiedParentDirectories(startDirectory, destinationRoot) {
651
+ let currentDirectory = startDirectory;
652
+ while (isInsideDirectory(currentDirectory, destinationRoot)) {
653
+ try {
654
+ rmdirSync(currentDirectory);
655
+ } catch {
656
+ return;
657
+ }
658
+ currentDirectory = dirname(currentDirectory);
659
+ }
660
+ }
661
+
662
+ /**
663
+ * Move files a prior install wrote under a managed root that the current install
664
+ * no longer writes into the run's backup root.
665
+ *
666
+ * `copyTree` overwrites and adds but never removes, so a file dropped or renamed
667
+ * between two installs survives inside an otherwise current directory. That
668
+ * leaves a skill whose modules come from one revision and whose companions come
669
+ * from another — for example scripts importing constants a stale constants module
670
+ * never defines, which fails at import with nothing in the directory to explain
671
+ * why. Diffing the prior manifest against the paths this run copied confines the
672
+ * move to content the installer itself wrote, so a runtime artifact such as a
673
+ * `__pycache__` entry, a user symlink, and any user-authored file all stay in
674
+ * place.
675
+ *
676
+ * A run whose prunes read the prior record all the way through replaces the
677
+ * manifest's file list wholesale, so the next diff reads as "the package stopped
678
+ * shipping this". Every other run unions what it wrote onto the prior record,
679
+ * which keeps every entry a later pruning install needs. An entry that leaves the
680
+ * record — a path the run rewrote under a fresh spelling, or a record already lost
681
+ * to an older install — sits outside every later diff, so it stays inside the tree
682
+ * once the package stops shipping it.
683
+ *
684
+ * A move that fails is reported through `failedPaths` so the caller records those
685
+ * paths in the fresh manifest. Keeping a failed path on the record holds it inside
686
+ * the next run's diff, which retries the move once the antivirus scanner or open
687
+ * editor that held the file lets go. Leaving it off the record would place the
688
+ * file outside every later diff and strand it inside a live skill.
689
+ *
690
+ * @param {string[]|null} priorInstalledFiles Files the prior manifest recorded, or null when unknown.
691
+ * @param {string[]} currentInstalledFiles Every file this run copied under the root.
692
+ * @param {string} destinationRoot The managed root the diff is confined to.
693
+ * @param {string} backupRoot The run's timestamped backup directory.
694
+ * @param {{isCaseInsensitive?: boolean, managedHomeDirectory?: string}} options `isCaseInsensitive`
695
+ * sets whether path keys fold letter case, defaulting to this host's filesystem;
696
+ * `managedHomeDirectory` sets the home the containment guard tests against,
697
+ * defaulting to ~/.claude.
698
+ * @returns {{prunedCount: number, failedPaths: string[]}} How many files moved, and the paths whose move failed.
699
+ */
700
+ export function pruneStaleInstalledFiles(
701
+ priorInstalledFiles, currentInstalledFiles, destinationRoot, backupRoot, options = {},
702
+ ) {
703
+ if (priorInstalledFiles === null) return { prunedCount: 0, failedPaths: [] };
704
+ const { managedHomeDirectory = CLAUDE_HOME } = options;
705
+ const currentFileKeys = new Set(
706
+ currentInstalledFiles.map(currentFile => comparisonKeyForPath(currentFile, options)),
707
+ );
708
+ const resolvedRoot = resolve(destinationRoot);
709
+ let prunedCount = 0;
710
+ const failedPaths = [];
711
+ for (const priorFile of priorInstalledFiles) {
712
+ const stalePath = resolve(priorFile);
713
+ if (!isInsideDirectory(stalePath, resolvedRoot)) continue;
714
+ if (currentFileKeys.has(comparisonKeyForPath(stalePath, options))) continue;
715
+ if (!isMovableStaleFile(stalePath)) continue;
716
+ const backupRelativePath = relative(resolvedRoot, stalePath);
717
+ const didMove = moveIntoRunBackup(
718
+ stalePath, backupRoot, backupRelativePath, STALE_FILE_REASON_LABEL, managedHomeDirectory,
719
+ );
720
+ if (!didMove) {
721
+ failedPaths.push(stalePath);
722
+ continue;
723
+ }
724
+ prunedCount++;
725
+ removeEmptiedParentDirectories(dirname(stalePath), resolvedRoot);
726
+ }
727
+ return { prunedCount, failedPaths };
728
+ }
729
+
730
+ /**
731
+ * Return the existing directory entry a shipped file name would overwrite
732
+ * through a spelling that differs only in letter case.
733
+ *
734
+ * `copyFileSync` writes its bytes through whatever entry the filesystem resolves
735
+ * the destination to, so on a case-insensitive volume a package shipping
736
+ * `README.md` over an installed `Readme.md` fills the installed entry and leaves
737
+ * the earlier spelling standing. Naming that entry lets the copy rename it to the
738
+ * shipped spelling first. On a case-sensitive volume the two names are two files,
739
+ * so the answer is always null.
740
+ *
741
+ * The `isCaseInsensitive` option carries the platform decision as a value, so a
742
+ * test names the branch it drives and both branches stay covered on a host of
743
+ * either kind.
744
+ *
745
+ * @param {string} shippedFileName The file name the package ships.
746
+ * @param {string[]} existingEntryNames The names already in the destination directory.
747
+ * @param {{isCaseInsensitive?: boolean}} options Whether name comparison folds letter case; defaults to this host's filesystem.
748
+ * @returns {string|null} The existing entry name to rename, or null when none applies.
749
+ */
750
+ export function caseOnlyRenameSourceName(shippedFileName, existingEntryNames, options = {}) {
751
+ const { isCaseInsensitive = isCaseInsensitiveFilesystem() } = options;
752
+ if (!isCaseInsensitive) return null;
753
+ if (existingEntryNames.includes(shippedFileName)) return null;
754
+ const foldedShippedName = shippedFileName.toLowerCase();
755
+ const caseOnlyMatchName = existingEntryNames.find(
756
+ existingName => existingName.toLowerCase() === foldedShippedName,
757
+ );
758
+ return caseOnlyMatchName === undefined ? null : caseOnlyMatchName;
759
+ }
760
+
761
+ /**
762
+ * List a directory's entry names, reading each directory once and serving every
763
+ * later request for it from the cache.
764
+ *
765
+ * A copy run asks about the destination directory of every file it writes, and a
766
+ * directory holds many of them, so one listing per directory keeps the case check
767
+ * off the per-file syscall path.
768
+ *
769
+ * @param {string} directoryPath The absolute directory to list.
770
+ * @param {Map<string, string[]>} entryNamesByDirectory The run's listing cache.
771
+ * @returns {string[]} The directory's entry names, empty when it cannot be read.
772
+ */
773
+ function cachedDirectoryEntryNames(directoryPath, entryNamesByDirectory) {
774
+ const cachedEntryNames = entryNamesByDirectory.get(directoryPath);
775
+ if (cachedEntryNames !== undefined) return cachedEntryNames;
776
+ let allEntryNames;
777
+ try {
778
+ allEntryNames = readdirSync(directoryPath);
779
+ } catch {
780
+ allEntryNames = [];
781
+ }
782
+ entryNamesByDirectory.set(directoryPath, allEntryNames);
783
+ return allEntryNames;
784
+ }
785
+
786
+ /**
787
+ * Give the destination entry the shipped file's letter case before the bytes
788
+ * land.
789
+ *
790
+ * `renameSync` inside one directory is atomic, so a run interrupted between the
791
+ * rename and the copy leaves the file present under the shipped name holding the
792
+ * earlier content, which the next install overwrites. A rename that fails logs a
793
+ * warning and the copy carries on, so the content is always current even when the
794
+ * name stays as it was.
795
+ *
796
+ * @param {string} destinationFilePath The absolute path the package ships to.
797
+ * @param {Map<string, string[]>} entryNamesByDirectory The run's listing cache.
798
+ * @param {{isCaseInsensitive?: boolean}} options Whether name comparison folds letter case.
799
+ * @returns {void}
800
+ */
801
+ function renameCaseOnlyMatchToShippedName(destinationFilePath, entryNamesByDirectory, options) {
802
+ const destinationDirectory = dirname(destinationFilePath);
803
+ const shippedFileName = basename(destinationFilePath);
804
+ const existingEntryNames = cachedDirectoryEntryNames(destinationDirectory, entryNamesByDirectory);
805
+ const caseOnlyMatchName = caseOnlyRenameSourceName(shippedFileName, existingEntryNames, options);
806
+ if (caseOnlyMatchName === null) return;
807
+ try {
808
+ renameSync(join(destinationDirectory, caseOnlyMatchName), destinationFilePath);
809
+ } catch (renameError) {
810
+ console.warn(` Warning: leaving ${caseOnlyMatchName} under its installed name — the rename to ${shippedFileName} failed (${renameError.message})`);
811
+ return;
812
+ }
813
+ existingEntryNames[existingEntryNames.indexOf(caseOnlyMatchName)] = shippedFileName;
814
+ }
815
+
816
+ /**
817
+ * Copy every file under a source directory into a destination directory,
818
+ * reporting what the run created and what it updated.
819
+ *
820
+ * A destination entry whose name differs from the shipped name only in letter
821
+ * case is renamed to the shipped name before the copy, so the tree carries the
822
+ * spelling the package ships. The directory listing behind that decision is read
823
+ * once per destination directory and reused for every file the run copies there.
824
+ *
825
+ * @param {string} sourceBase The absolute source directory to copy from.
826
+ * @param {string} destBase The absolute destination directory to copy into.
827
+ * @param {{isCaseInsensitive?: boolean}} options Whether name comparison folds letter case; defaults to this host's filesystem.
828
+ * @returns {{created: number, updated: number, paths: string[]}} The counts and the destination paths written.
829
+ */
830
+ export function copyTree(sourceBase, destBase, options = {}) {
286
831
  const files = collectFiles(sourceBase);
287
832
  const stats = { created: 0, updated: 0, paths: [] };
833
+ const entryNamesByDirectory = new Map();
288
834
  for (const sourceFile of files) {
289
835
  const relativePath = relative(sourceBase, sourceFile);
290
836
  const destFile = join(destBase, relativePath);
291
837
  mkdirSync(dirname(destFile), { recursive: true });
292
838
  const existed = existsSync(destFile);
839
+ renameCaseOnlyMatchToShippedName(destFile, entryNamesByDirectory, options);
293
840
  copyFileSync(sourceFile, destFile);
294
841
  stats.paths.push(destFile);
295
842
  if (existed) {
@@ -409,7 +956,7 @@ export function managedHookScriptRelativePaths(hooksConfig) {
409
956
  export function managedHookScriptRelativePathsFromSourceRoots(sourceRoots) {
410
957
  const relativePaths = new Set();
411
958
  for (const sourceRoot of sourceRoots) {
412
- const hooksJsonPath = join(sourceRoot, 'hooks', 'hooks.json');
959
+ const hooksJsonPath = join(sourceRoot, MANAGED_HOOKS_DIRECTORY_NAME, 'hooks.json');
413
960
  if (!existsSync(hooksJsonPath)) continue;
414
961
  const hooksConfig = JSON.parse(readFileSync(hooksJsonPath, 'utf8'));
415
962
  for (const relativePath of managedHookScriptRelativePaths(hooksConfig)) {
@@ -442,11 +989,15 @@ function managedPackageSourceRoots() {
442
989
  * earlier installs that used a different interpreter prefix, while leaving
443
990
  * user-authored hooks outside the managed set untouched.
444
991
  *
445
- * @param {string} commandString The hook command from settings.json.
992
+ * A command that is not a string belongs to an entry this installer never wrote,
993
+ * so it counts as unmanaged and its entry stays.
994
+ *
995
+ * @param {unknown} commandString The hook command from settings.json.
446
996
  * @param {Set<string>} managedHookRelativePaths Managed script paths under hooks/.
447
997
  * @returns {boolean} True when the command references a managed script.
448
998
  */
449
999
  export function commandReferencesManagedHook(commandString, managedHookRelativePaths) {
1000
+ if (typeof commandString !== 'string') return false;
450
1001
  const normalizedCommand = commandString.replace(/\\/g, '/');
451
1002
  if (commandIsInlineManagedValidatorRunner(normalizedCommand)) {
452
1003
  return true;
@@ -508,7 +1059,9 @@ export function commandIsInlineManagedValidatorRunner(normalizedCommand) {
508
1059
  * all existing matcher groups of one event in a settings object, dropping any
509
1060
  * group left empty. Run before the per-group merge so a managed hook that an
510
1061
  * upgrade moves to a different matcher group is pruned from its old group rather
511
- * than left to double-run. User-authored hooks outside the managed set stay.
1062
+ * than left to double-run. User-authored hooks outside the managed set stay, and
1063
+ * an event whose value is not an array of groups is left as the settings file
1064
+ * holds it.
512
1065
  *
513
1066
  * @param {object} settings The parsed settings.json object (mutated in place).
514
1067
  * @param {string} eventType The lifecycle event whose groups are pruned.
@@ -517,15 +1070,36 @@ export function commandIsInlineManagedValidatorRunner(normalizedCommand) {
517
1070
  */
518
1071
  function pruneManagedHooksFromEvent(settings, eventType, managedHookRelativePaths) {
519
1072
  const existingGroups = settings.hooks[eventType];
520
- if (!existingGroups) return;
521
- settings.hooks[eventType] = existingGroups
522
- .map(group => ({
523
- ...group,
524
- hooks: group.hooks.filter(
525
- hook => !commandReferencesManagedHook(hook.command, managedHookRelativePaths)
526
- ),
527
- }))
528
- .filter(group => group.hooks.length > 0);
1073
+ if (!Array.isArray(existingGroups)) return;
1074
+ settings.hooks[eventType] = retainedMatcherGroups(
1075
+ existingGroups,
1076
+ commandString => commandReferencesManagedHook(commandString, managedHookRelativePaths),
1077
+ ).keptGroups;
1078
+ }
1079
+
1080
+ /**
1081
+ * Give one event type a list of hook groups to merge into, warning when a value
1082
+ * of another shape leaves settings.json.
1083
+ *
1084
+ * The settings schema holds a list of matcher groups at each event type, so a
1085
+ * value of another shape has no place for the groups the package ships for that
1086
+ * event, and the merge writes the list in its place. The warning names the event
1087
+ * type so the user can recover the value from their own history.
1088
+ *
1089
+ * @param {object} settings The parsed settings.json object (mutated in place).
1090
+ * @param {string} eventType The lifecycle event the package ships groups for.
1091
+ * @returns {void}
1092
+ */
1093
+ function startEventFromHookGroupList(settings, eventType) {
1094
+ const existingEventValue = settings.hooks[eventType];
1095
+ if (Array.isArray(existingEventValue)) return;
1096
+ if (existingEventValue !== undefined) {
1097
+ console.warn(
1098
+ ` Warning: replacing the ${eventType} value in settings.json — it held a value that`
1099
+ + ' was not a list of hook groups. Recover it from your own history.'
1100
+ );
1101
+ }
1102
+ settings.hooks[eventType] = [];
529
1103
  }
530
1104
 
531
1105
  /**
@@ -538,6 +1112,13 @@ function pruneManagedHooksFromEvent(settings, eventType, managedHookRelativePath
538
1112
  * command are expanded to absolute home paths so hosts that require referenced
539
1113
  * env vars at load time (a third-party host on Windows) can execute them.
540
1114
  *
1115
+ * The merge reads a settings file another tool or a person may have written, so
1116
+ * it recognizes the shapes it writes and steps around the rest: a `hooks` value
1117
+ * that is not an object starts from an empty map, and a group carrying no hooks
1118
+ * array contributes no user entries. At an event type the package ships groups
1119
+ * for, a value that is not a list of hook groups is replaced by that list, with
1120
+ * a warning naming the event type.
1121
+ *
541
1122
  * @param {object} settings The parsed settings.json object (mutated in place).
542
1123
  * @param {{hooks: object}} hooksConfig Parsed hooks.json.
543
1124
  * @param {string} pluginRootDir Directory ${CLAUDE_PLUGIN_ROOT} resolves to
@@ -548,10 +1129,10 @@ function pruneManagedHooksFromEvent(settings, eventType, managedHookRelativePath
548
1129
  export function mergeHooksIntoSettings(settings, hooksConfig, pluginRootDir, pythonCommand) {
549
1130
  const managedHookRelativePaths = managedHookScriptRelativePaths(hooksConfig);
550
1131
  const pluginRootForward = pluginRootDir.replace(/\\/g, '/');
551
- if (!settings.hooks) settings.hooks = {};
1132
+ if (!settings.hooks || typeof settings.hooks !== 'object') settings.hooks = {};
552
1133
  let groupCount = 0;
553
1134
  for (const [eventType, matcherGroups] of Object.entries(hooksConfig.hooks)) {
554
- if (!settings.hooks[eventType]) settings.hooks[eventType] = [];
1135
+ startEventFromHookGroupList(settings, eventType);
555
1136
  pruneManagedHooksFromEvent(settings, eventType, managedHookRelativePaths);
556
1137
  for (const sourceGroup of matcherGroups) {
557
1138
  const rewrittenHooks = sourceGroup.hooks.map(hook => {
@@ -564,12 +1145,12 @@ export function mergeHooksIntoSettings(settings, hooksConfig, pluginRootDir, pyt
564
1145
  return { ...hook, command };
565
1146
  });
566
1147
  const existingIndex = settings.hooks[eventType].findIndex(
567
- group => group.matcher === sourceGroup.matcher
1148
+ group => group?.matcher === sourceGroup.matcher
568
1149
  );
569
1150
  if (existingIndex >= 0) {
570
1151
  const existing = settings.hooks[eventType][existingIndex];
571
- const userHooks = existing.hooks.filter(
572
- hook => !commandReferencesManagedHook(hook.command, managedHookRelativePaths)
1152
+ const userHooks = (groupHookEntries(existing) || []).filter(
1153
+ hook => !commandReferencesManagedHook(hook?.command, managedHookRelativePaths)
573
1154
  );
574
1155
  settings.hooks[eventType][existingIndex] = {
575
1156
  ...existing,
@@ -591,39 +1172,217 @@ export function mergeHooksIntoSettings(settings, hooksConfig, pluginRootDir, pyt
591
1172
  * commandReferencesManagedHook so entries written with any home-path style
592
1173
  * ($HOME, ~, ${HOME}, or absolute) and any path separator are pruned. Matcher
593
1174
  * groups left empty are dropped, and an empty hooks map is removed entirely.
594
- * User-authored hooks outside the managed set are preserved untouched.
1175
+ * User-authored hooks outside the managed set are preserved untouched, and an
1176
+ * event whose value is not an array of groups is left as the settings file holds
1177
+ * it.
595
1178
  *
596
1179
  * @param {object} settings The parsed settings.json object (mutated in place).
597
1180
  * @param {Set<string>} managedHookRelativePaths Managed script paths under hooks/.
598
1181
  * @returns {void}
599
1182
  */
600
1183
  export function pruneManagedHooksFromSettings(settings, managedHookRelativePaths) {
601
- if (!settings.hooks) return;
1184
+ if (!settings.hooks || typeof settings.hooks !== 'object') return;
602
1185
  for (const [eventType, matcherGroups] of Object.entries(settings.hooks)) {
603
- settings.hooks[eventType] = matcherGroups
604
- .map(group => ({
605
- ...group,
606
- hooks: group.hooks.filter(
607
- hook => !commandReferencesManagedHook(hook.command, managedHookRelativePaths)
608
- ),
609
- }))
610
- .filter(group => group.hooks.length > 0);
611
- if (settings.hooks[eventType].length === 0) delete settings.hooks[eventType];
1186
+ if (!Array.isArray(matcherGroups)) continue;
1187
+ const { keptGroups } = retainedMatcherGroups(
1188
+ matcherGroups,
1189
+ commandString => commandReferencesManagedHook(commandString, managedHookRelativePaths),
1190
+ );
1191
+ settings.hooks[eventType] = keptGroups;
1192
+ if (keptGroups.length === 0) delete settings.hooks[eventType];
612
1193
  }
613
1194
  if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
614
1195
  }
615
1196
 
1197
+ /**
1198
+ * Build the hook script paths a prior install wrote under ~/.claude/hooks that
1199
+ * this run leaves unwritten, each relative to that hooks root.
1200
+ *
1201
+ * The set comes from the manifest diff alone, so it names this installer's own
1202
+ * retired scripts and nothing else: a script the run still writes stays out of it,
1203
+ * and a path no install of ours ever recorded never enters it. That is what keeps
1204
+ * a user-authored hook out of reach of the settings prune.
1205
+ *
1206
+ * @param {string[]|null} priorInstalledFiles Files the prior manifest recorded, or null when unknown.
1207
+ * @param {string[]} currentInstalledFiles Every file this run copied.
1208
+ * @param {string} hooksRoot The absolute installed hooks directory.
1209
+ * @returns {Set<string>} Forward-slash relative script paths under the hooks root.
1210
+ */
1211
+ export function retiredManagedHookRelativePaths(
1212
+ priorInstalledFiles, currentInstalledFiles, hooksRoot,
1213
+ ) {
1214
+ const retiredRelativePaths = new Set();
1215
+ if (priorInstalledFiles === null) return retiredRelativePaths;
1216
+ const currentFileKeys = new Set(
1217
+ currentInstalledFiles.map(currentFile => comparisonKeyForPath(currentFile)),
1218
+ );
1219
+ const resolvedHooksRoot = resolve(hooksRoot);
1220
+ for (const priorFile of priorInstalledFiles) {
1221
+ const priorPath = resolve(priorFile);
1222
+ if (!isInsideDirectory(priorPath, resolvedHooksRoot)) continue;
1223
+ if (currentFileKeys.has(comparisonKeyForPath(priorPath))) continue;
1224
+ retiredRelativePaths.add(relative(resolvedHooksRoot, priorPath).replace(/\\/g, '/'));
1225
+ }
1226
+ return retiredRelativePaths;
1227
+ }
1228
+
1229
+ /**
1230
+ * Report whether a settings.json hook command runs one of the retired managed
1231
+ * hook scripts.
1232
+ *
1233
+ * The anchored `/.claude/hooks/<relative>` tail is the same test the merge uses to
1234
+ * tell this installer's entries from a user's, so a command whose path is a
1235
+ * retired tail plus a suffix stays outside the set. The inline validators-runner
1236
+ * shape sits outside this test on purpose: it names no script, so no manifest
1237
+ * record can retire it, and the merge writes it fresh on every run.
1238
+ *
1239
+ * A command that is not a string belongs to an entry this installer never wrote —
1240
+ * a hand-edited settings.json, or a third-party entry carrying another shape — so
1241
+ * it names no retired script and its entry stays.
1242
+ *
1243
+ * @param {unknown} commandString The hook command from settings.json.
1244
+ * @param {Set<string>} retiredHookRelativePaths Retired script paths under hooks/.
1245
+ * @returns {boolean} True when the command runs a retired managed script.
1246
+ */
1247
+ function commandReferencesRetiredHook(commandString, retiredHookRelativePaths) {
1248
+ if (typeof commandString !== 'string') return false;
1249
+ const normalizedCommand = commandString.replace(/\\/g, '/');
1250
+ for (const relativePath of retiredHookRelativePaths) {
1251
+ if (commandTailEndsAtManagedHook(normalizedCommand, relativePath)) return true;
1252
+ }
1253
+ return false;
1254
+ }
1255
+
1256
+ /**
1257
+ * Keep the matcher groups of one event, dropping each hook a predicate names and
1258
+ * each group the drop leaves empty.
1259
+ *
1260
+ * Every settings walk shares this pass, so a settings.json a person or another
1261
+ * tool wrote meets one set of shape rules. A group carrying no hooks array is
1262
+ * handed back untouched, and an entry whose command is not a string reaches the
1263
+ * predicate as-is — each predicate reads a non-string command as an entry this
1264
+ * installer never wrote — so a shape this installer does not recognize survives.
1265
+ *
1266
+ * @param {object[]} matcherGroups The event's matcher groups from settings.json.
1267
+ * @param {(commandString: unknown) => boolean} shouldRemoveHook Names the hooks that leave.
1268
+ * @returns {{keptGroups: object[], removedCount: number}} The surviving groups and how many hooks left.
1269
+ */
1270
+ function retainedMatcherGroups(matcherGroups, shouldRemoveHook) {
1271
+ const keptGroups = [];
1272
+ let removedCount = 0;
1273
+ for (const group of matcherGroups) {
1274
+ const hookEntries = groupHookEntries(group);
1275
+ if (hookEntries === null) {
1276
+ keptGroups.push(group);
1277
+ continue;
1278
+ }
1279
+ const keptHooks = hookEntries.filter(hook => !shouldRemoveHook(hook?.command));
1280
+ removedCount += hookEntries.length - keptHooks.length;
1281
+ if (keptHooks.length > 0) keptGroups.push({ ...group, hooks: keptHooks });
1282
+ }
1283
+ return { keptGroups, removedCount };
1284
+ }
1285
+
1286
+ /**
1287
+ * Return the hook entries one matcher group holds, or null when the group carries
1288
+ * no hooks array.
1289
+ *
1290
+ * The installer writes every group with a `hooks` array, and a settings.json it
1291
+ * reads back can hold a group of any shape. Reading the array through one
1292
+ * accessor lets each walk recognize the shape it wrote and hand every other shape
1293
+ * back untouched.
1294
+ *
1295
+ * @param {unknown} group One matcher group read from settings.json.
1296
+ * @returns {object[]|null} The group's hook entries, or null when it holds none.
1297
+ */
1298
+ function groupHookEntries(group) {
1299
+ return Array.isArray(group?.hooks) ? group.hooks : null;
1300
+ }
1301
+
1302
+ /**
1303
+ * Strip every retired managed hook from a settings object in memory.
1304
+ *
1305
+ * The walk covers each event type the settings file holds rather than the ones the
1306
+ * current hooks.json names, so an entry under an event type the package stopped
1307
+ * shipping is reached too. An event type left with no groups is dropped.
1308
+ *
1309
+ * @param {object} settings The parsed settings.json object (mutated in place).
1310
+ * @param {Set<string>} retiredHookRelativePaths Retired script paths under hooks/.
1311
+ * @returns {number} How many hook entries were removed.
1312
+ */
1313
+ function stripRetiredHookEntries(settings, retiredHookRelativePaths) {
1314
+ let removedCount = 0;
1315
+ for (const [eventType, matcherGroups] of Object.entries(settings.hooks)) {
1316
+ if (!Array.isArray(matcherGroups)) continue;
1317
+ const eventOutcome = retainedMatcherGroups(
1318
+ matcherGroups,
1319
+ commandString => commandReferencesRetiredHook(commandString, retiredHookRelativePaths),
1320
+ );
1321
+ removedCount += eventOutcome.removedCount;
1322
+ if (eventOutcome.keptGroups.length === 0) {
1323
+ delete settings.hooks[eventType];
1324
+ continue;
1325
+ }
1326
+ settings.hooks[eventType] = eventOutcome.keptGroups;
1327
+ }
1328
+ return removedCount;
1329
+ }
1330
+
1331
+ /**
1332
+ * Remove every settings.json entry that runs a retired managed hook script,
1333
+ * writing the file only when an entry left it.
1334
+ *
1335
+ * A run that retires no hook leaves settings.json byte-identical, so an install
1336
+ * touches the user's settings for a reason a reader can name. A settings file the
1337
+ * installer cannot parse is left alone with a warning.
1338
+ *
1339
+ * @param {string} settingsPath The absolute settings.json path.
1340
+ * @param {Set<string>} retiredHookRelativePaths Retired script paths under hooks/.
1341
+ * @returns {number} How many hook entries were removed.
1342
+ */
1343
+ export function pruneRetiredHookEntriesFromSettings(settingsPath, retiredHookRelativePaths) {
1344
+ if (retiredHookRelativePaths.size === 0) return 0;
1345
+ if (!existsSync(settingsPath)) return 0;
1346
+ let settings;
1347
+ try {
1348
+ settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
1349
+ } catch (parseError) {
1350
+ console.warn(` Warning: leaving settings.json as it stands — the file holds JSON the installer cannot read (${parseError.message})`);
1351
+ return 0;
1352
+ }
1353
+ if (!settings.hooks || typeof settings.hooks !== 'object') return 0;
1354
+ const removedCount = stripRetiredHookEntries(settings, retiredHookRelativePaths);
1355
+ if (removedCount === 0) return 0;
1356
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 4) + '\n');
1357
+ return removedCount;
1358
+ }
1359
+
1360
+ /**
1361
+ * Merge one package source root's hook groups into ~/.claude/settings.json.
1362
+ *
1363
+ * A settings file holding anything other than a JSON object ends the install with
1364
+ * a message naming the file, so the run stops before it writes hook entries onto
1365
+ * a shape the harness cannot read.
1366
+ *
1367
+ * @param {string} hooksSourceRoot The package root whose hooks/hooks.json is merged.
1368
+ * @param {string} pythonCommand Interpreter command that replaces python3.
1369
+ * @returns {number} Count of matcher groups merged.
1370
+ */
616
1371
  function mergeHooks(hooksSourceRoot, pythonCommand) {
617
- const hooksJsonPath = join(hooksSourceRoot, 'hooks', 'hooks.json');
1372
+ const hooksJsonPath = join(hooksSourceRoot, MANAGED_HOOKS_DIRECTORY_NAME, 'hooks.json');
618
1373
  if (!existsSync(hooksJsonPath)) return 0;
619
1374
  const hooksConfig = JSON.parse(readFileSync(hooksJsonPath, 'utf8'));
620
- const settingsPath = join(CLAUDE_HOME, 'settings.json');
1375
+ const settingsPath = join(CLAUDE_HOME, SETTINGS_FILE_NAME);
621
1376
  let settings = {};
622
1377
  if (existsSync(settingsPath)) {
623
1378
  const raw = readFileSync(settingsPath, 'utf8').trim();
624
1379
  if (raw) {
625
1380
  try { settings = JSON.parse(raw); }
626
1381
  catch { console.error(' ERROR: settings.json is malformed JSON. Fix it and rerun.'); process.exit(1); }
1382
+ if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
1383
+ console.error(' ERROR: settings.json holds a value other than a JSON object. Fix it and rerun.');
1384
+ process.exit(1);
1385
+ }
627
1386
  }
628
1387
  }
629
1388
  const groupCount = mergeHooksIntoSettings(settings, hooksConfig, CLAUDE_HOME, pythonCommand);
@@ -632,32 +1391,111 @@ function mergeHooks(hooksSourceRoot, pythonCommand) {
632
1391
  }
633
1392
 
634
1393
  function writeManifest(installedFiles, skillNames) {
635
- const manifest = { package: PACKAGE_NAME, version: PACKAGE_VERSION, installedAt: new Date().toISOString(), files: installedFiles };
1394
+ const manifest = {
1395
+ package: PACKAGE_NAME,
1396
+ version: PACKAGE_VERSION,
1397
+ installedAt: new Date().toISOString(),
1398
+ [MANIFEST_FILES_KEY]: installedFiles,
1399
+ };
636
1400
  if (skillNames) {
637
- manifest.skills = skillNames;
1401
+ manifest[MANIFEST_SKILLS_KEY] = skillNames;
638
1402
  }
639
1403
  writeFileSync(MANIFEST_FILE, JSON.stringify(manifest, null, 2) + '\n');
640
1404
  }
641
1405
 
642
1406
  /**
643
- * Read the skill directory names the previous install recorded.
1407
+ * Read the file list and the skill list the previous install recorded, in one
1408
+ * parse of the manifest.
1409
+ *
1410
+ * A full install's manifest holds thousands of path strings, so the record is
1411
+ * read and parsed once and both lists are handed back together.
644
1412
  *
645
- * Returns null when no manifest exists or the manifest predates the skills key,
646
- * so a caller can treat "unknown prior skills" the same in both cases and lean
647
- * on the ever-shipped set to find retired skills.
1413
+ * Either list is null when the manifest is missing, unreadable, or holds no array
1414
+ * at that key, so a caller treats every such case as "no prior record": the skills
1415
+ * list leans on the ever-shipped set to find retired skills, and the files list
1416
+ * holds the stale-file prune for that run rather than guessing at what a prior
1417
+ * install wrote. A `--update` run purges the manifest before reinstalling, so this
1418
+ * read happens at the top of `install()` while the record is still on disk.
648
1419
  *
649
- * @returns {string[]|null} The prior manifest's skill names, or null when absent.
1420
+ * @returns {{files: string[]|null, skills: string[]|null}} The recorded lists, each null when absent.
650
1421
  */
651
- function readPriorManifestSkills() {
652
- if (!existsSync(MANIFEST_FILE)) return null;
1422
+ function readPriorManifestArrays() {
1423
+ const missingRecord = { files: null, skills: null };
1424
+ if (!existsSync(MANIFEST_FILE)) return missingRecord;
653
1425
  try {
654
1426
  const priorManifest = JSON.parse(readFileSync(MANIFEST_FILE, 'utf8'));
655
- return Array.isArray(priorManifest.skills) ? priorManifest.skills : null;
1427
+ return {
1428
+ files: arrayOrNull(priorManifest[MANIFEST_FILES_KEY]),
1429
+ skills: arrayOrNull(priorManifest[MANIFEST_SKILLS_KEY]),
1430
+ };
656
1431
  } catch {
657
- return null;
1432
+ return missingRecord;
658
1433
  }
659
1434
  }
660
1435
 
1436
+ /**
1437
+ * Return a recorded manifest value when it is an array, and null otherwise.
1438
+ *
1439
+ * @param {unknown} recordedEntries The value read at a manifest key.
1440
+ * @returns {string[]|null} The array, or null when the key holds anything else.
1441
+ */
1442
+ function arrayOrNull(recordedEntries) {
1443
+ return Array.isArray(recordedEntries) ? recordedEntries : null;
1444
+ }
1445
+
1446
+ /**
1447
+ * Merge two path lists into one, keyed on `comparisonKeyForPath`, with the
1448
+ * current run's spelling winning a collision.
1449
+ *
1450
+ * The current run just wrote the file, so its casing is the on-disk truth; a path
1451
+ * carried over from an earlier record keeps its position in the list and takes the
1452
+ * fresh spelling. One spelling per file keeps the record and the uninstall purge
1453
+ * loop the size of the tree they describe.
1454
+ *
1455
+ * @param {string[]} carriedOverPaths Paths sourced from an earlier record.
1456
+ * @param {string[]} currentRunPaths Paths this run wrote, whose spelling wins.
1457
+ * @returns {string[]} The merged list, one entry per comparison key.
1458
+ */
1459
+ function unionOnComparisonKey(carriedOverPaths, currentRunPaths) {
1460
+ const pathByComparisonKey = new Map();
1461
+ for (const mergedPath of [...carriedOverPaths, ...currentRunPaths]) {
1462
+ pathByComparisonKey.set(comparisonKeyForPath(mergedPath), mergedPath);
1463
+ }
1464
+ return [...pathByComparisonKey.values()];
1465
+ }
1466
+
1467
+ /**
1468
+ * Merge two skill-name lists into one, deduped by exact name.
1469
+ *
1470
+ * A skill name is a directory name the package ships, compared exactly the way
1471
+ * `pruneRetiredSkills` compares it, so this union stays case-aware and applies
1472
+ * none of the path normalization `unionOnComparisonKey` uses.
1473
+ *
1474
+ * @param {string[]} carriedOverSkillNames Skill names sourced from an earlier record.
1475
+ * @param {string[]} currentRunSkillNames Skill names this run installed.
1476
+ * @returns {string[]} The merged list, one entry per name.
1477
+ */
1478
+ function unionOfSkillNames(carriedOverSkillNames, currentRunSkillNames) {
1479
+ return [...new Set([...carriedOverSkillNames, ...currentRunSkillNames])];
1480
+ }
1481
+
1482
+ /**
1483
+ * Build the file list a full install records: everything this run installed plus
1484
+ * every stale path whose move failed and that still sits on disk.
1485
+ *
1486
+ * A failed move keeps the file inside a live skill, so the record carries it into
1487
+ * the next run's diff for a retry. A path that vanished between the stat guard
1488
+ * and the rename is dropped, which keeps a phantom out of every later manifest.
1489
+ *
1490
+ * @param {string[]} installedFiles Every file this run wrote.
1491
+ * @param {string[]} failedPrunePaths Stale paths whose move into the backup failed.
1492
+ * @returns {string[]} The deduped file list to record.
1493
+ */
1494
+ function manifestFilesWithFailedPrunes(installedFiles, failedPrunePaths) {
1495
+ const survivingFailedPaths = failedPrunePaths.filter(failedPath => existsSync(failedPath));
1496
+ return unionOnComparisonKey(survivingFailedPaths, installedFiles);
1497
+ }
1498
+
661
1499
  /**
662
1500
  * Move retired skill directories a prior install left under ~/.claude/skills into
663
1501
  * a timestamped backup directory rather than deleting them.
@@ -673,58 +1511,156 @@ function readPriorManifestSkills() {
673
1511
  * ~/.claude/skills/_shared, are left in place.
674
1512
  *
675
1513
  * Each pruned directory is renamed to
676
- * ~/.claude/.claude-dev-env-pruned/<timestamp>/<skill-name>/ — a backup root
677
- * outside ~/.claude/skills, so a moved directory is never re-discovered as a
678
- * skill — under one shared timestamp per run. A backup is never cleaned up, so a
679
- * user can recover a wrongly-matched directory. One directory whose rename fails
1514
+ * ~/.claude/.claude-dev-env-pruned/<timestamp>/skills/<skill-name>/ — a backup
1515
+ * root outside ~/.claude/skills, so a moved directory is never re-discovered as a
1516
+ * skill — under one shared timestamp per run. That mirrors the ~/.claude layout
1517
+ * the per-root stale-file prune writes, so one recovery point reads as a copy of
1518
+ * the tree it came from. The recovery window runs until the next pruning install:
1519
+ * `retainNewestRunBackupOnly` keeps that run's backup and retires the rest, so a
1520
+ * user recovers a wrongly-matched directory from the newest backup on disk. One
1521
+ * directory whose rename fails
680
1522
  * (for example a read-only file or a cross-device move) is logged and left in
681
1523
  * place, never deleted, so a prune failure costs at most a cosmetic leftover.
682
1524
  *
683
1525
  * @param {Set<string>} installedSkillNames Skill names this install just wrote.
684
1526
  * @param {string[]|null} priorManifestSkills The prior manifest's skill names, or null.
1527
+ * @returns {number} How many retired skill directories reached the backup root.
685
1528
  */
686
1529
  function pruneRetiredSkills(installedSkillNames, priorManifestSkills) {
687
- const skillsDirectory = join(CLAUDE_HOME, 'skills');
688
- if (!existsSync(skillsDirectory)) return;
1530
+ const skillsDirectory = join(CLAUDE_HOME, MANAGED_SKILLS_DIRECTORY_NAME);
1531
+ if (!existsSync(skillsDirectory)) return 0;
689
1532
  const retiredSkillNames = new Set(
690
1533
  [...EVER_SHIPPED_SKILL_NAMES].filter(skillName => !installedSkillNames.has(skillName))
691
1534
  );
692
1535
  const priorSkillNames = new Set(priorManifestSkills || []);
693
- const runTimestamp = new Date().toISOString().replace(/[:.]/g, '-');
694
- const backupRoot = join(CLAUDE_HOME, PRUNED_SKILLS_BACKUP_DIRECTORY_NAME, runTimestamp);
1536
+ const backupRoot = currentRunBackupRoot();
695
1537
  const existingSkillDirs = readdirSync(skillsDirectory, { withFileTypes: true })
696
1538
  .filter(entry => entry.isDirectory());
1539
+ let movedDirectoryCount = 0;
697
1540
  for (const skillDir of existingSkillDirs) {
698
1541
  const skillName = skillDir.name;
699
1542
  if (NEVER_PRUNED_SKILL_DIRECTORIES.has(skillName)) continue;
700
1543
  if (installedSkillNames.has(skillName)) continue;
701
1544
  const isPruneCandidate = priorSkillNames.has(skillName) || retiredSkillNames.has(skillName);
702
1545
  if (!isPruneCandidate) continue;
703
- moveRetiredSkillToBackup(skillsDirectory, backupRoot, skillName);
1546
+ const didMove = moveIntoRunBackup(
1547
+ join(skillsDirectory, skillName),
1548
+ backupRoot,
1549
+ join(MANAGED_SKILLS_DIRECTORY_NAME, skillName),
1550
+ RETIRED_SKILL_REASON_LABEL,
1551
+ CLAUDE_HOME,
1552
+ );
1553
+ if (didMove) movedDirectoryCount++;
704
1554
  }
1555
+ return movedDirectoryCount;
705
1556
  }
706
1557
 
707
1558
  /**
708
- * Move one retired skill directory into the run's backup root, leaving it in
709
- * place when the move fails.
1559
+ * Move every file a prior install wrote under a managed root that this run leaves
1560
+ * unwritten into the run's backup root, one call per root.
1561
+ *
1562
+ * `copyTree` adds and overwrites but never removes, so every managed root carries
1563
+ * the same drift the skills root does. One call per root hands the containment
1564
+ * guard and the emptied-parent walk the root that owns each file, and each root's
1565
+ * content lands under `<backupRoot>/<root-name>/<relative>`, so the recovery point
1566
+ * mirrors ~/.claude.
710
1567
  *
711
- * @param {string} skillsDirectory The ~/.claude/skills directory holding the skill.
1568
+ * Nothing moves unless a prior install recorded it. A user-authored file, a
1569
+ * runtime artifact, and a recorded path under no managed root — ~/.claude/CLAUDE.md,
1570
+ * settings.json, the manifest itself, and ~/.mypy.ini outside the home — all sit
1571
+ * outside every root's diff and stay where they are. ~/.claude/_shared and
1572
+ * ~/.claude/skills/_shared are distinct absolute paths, so the `_shared` root call
1573
+ * and the skills root call each see their own files and neither sees the other's.
1574
+ *
1575
+ * @param {string[]|null} priorInstalledFiles Files the prior manifest recorded, or null when unknown.
1576
+ * @param {string[]} currentInstalledFiles Every file this run copied.
712
1577
  * @param {string} backupRoot The run's timestamped backup directory.
713
- * @param {string} skillName The retired skill directory name to move.
1578
+ * @returns {{prunedCount: number, skillsPrunedCount: number, failedPaths: string[]}}
1579
+ * The summed count, the skills root's own count, and every path whose move failed.
714
1580
  */
715
- function moveRetiredSkillToBackup(skillsDirectory, backupRoot, skillName) {
716
- const skillPath = join(skillsDirectory, skillName);
717
- const backupPath = join(backupRoot, skillName);
718
- try {
719
- mkdirSync(backupRoot, { recursive: true });
720
- renameSync(skillPath, backupPath);
721
- console.log(` ✗ ${join('skills', skillName)} (retired — moved to ${join(PRUNED_SKILLS_BACKUP_DIRECTORY_NAME, basename(backupRoot), skillName)})`);
722
- } catch (moveError) {
723
- console.warn(` Warning: could not move retired ${join('skills', skillName)} to backup, leaving in place (${moveError.message})`);
1581
+ function pruneStaleFilesAcrossManagedRoots(priorInstalledFiles, currentInstalledFiles, backupRoot) {
1582
+ let prunedCount = 0;
1583
+ let skillsPrunedCount = 0;
1584
+ const failedPaths = [];
1585
+ for (const rootName of MANAGED_TOP_LEVEL_DIRECTORY_NAMES) {
1586
+ const rootOutcome = pruneStaleInstalledFiles(
1587
+ priorInstalledFiles,
1588
+ currentInstalledFiles,
1589
+ join(CLAUDE_HOME, rootName),
1590
+ join(backupRoot, rootName),
1591
+ );
1592
+ prunedCount += rootOutcome.prunedCount;
1593
+ failedPaths.push(...rootOutcome.failedPaths);
1594
+ if (rootName === MANAGED_SKILLS_DIRECTORY_NAME) {
1595
+ skillsPrunedCount = rootOutcome.prunedCount;
1596
+ }
1597
+ }
1598
+ return { prunedCount, skillsPrunedCount, failedPaths };
1599
+ }
1600
+
1601
+ /**
1602
+ * Run every prune a full install performs, in the order that keeps ~/.claude
1603
+ * consistent at each step.
1604
+ *
1605
+ * The settings entries of retired hooks go before the file move: a settings.json
1606
+ * naming a hook script that has already left ~/.claude/hooks makes every session
1607
+ * start invoke a missing script, so the reference leaves first and the script
1608
+ * follows.
1609
+ *
1610
+ * Both prunes report how much content reached the run's backup root, and their sum
1611
+ * is what backup retention answers to: the sweep of older recovery points runs
1612
+ * only once this run holds one of its own.
1613
+ *
1614
+ * @param {Set<string>} copiedSkillNames Skill directory names this run wrote.
1615
+ * @param {string[]|null} priorManifestSkills The prior manifest's skill names, or null.
1616
+ * @param {string[]|null} priorManifestFiles The prior manifest's file list, or null.
1617
+ * @param {string[]} installedFiles Every file this run copied.
1618
+ * @returns {{prunedCount: number, skillsPrunedCount: number, failedPaths: string[]}}
1619
+ * The stale files moved across all roots, the skills root's share, and every
1620
+ * path whose move failed.
1621
+ */
1622
+ function runFullInstallPrunes(
1623
+ copiedSkillNames, priorManifestSkills, priorManifestFiles, installedFiles,
1624
+ ) {
1625
+ const retiredSkillMovedCount = pruneRetiredSkills(copiedSkillNames, priorManifestSkills);
1626
+ const removedHookEntryCount = pruneRetiredHookEntriesFromSettings(
1627
+ join(CLAUDE_HOME, SETTINGS_FILE_NAME),
1628
+ retiredManagedHookRelativePaths(
1629
+ priorManifestFiles, installedFiles, join(CLAUDE_HOME, MANAGED_HOOKS_DIRECTORY_NAME),
1630
+ ),
1631
+ );
1632
+ if (removedHookEntryCount > 0) {
1633
+ console.log(` Hook entries: ${removedHookEntryCount} retired entry(s) removed from settings.json`);
724
1634
  }
1635
+ const staleOutcome = pruneStaleFilesAcrossManagedRoots(
1636
+ priorManifestFiles, installedFiles, currentRunBackupRoot(),
1637
+ );
1638
+ const didRunMoveContent = retiredSkillMovedCount + staleOutcome.prunedCount > 0;
1639
+ retainNewestRunBackupOnly(currentRunBackupRoot(), didRunMoveContent);
1640
+ return staleOutcome;
725
1641
  }
726
1642
 
1643
+ /**
1644
+ * Copy the package into ~/.claude, merge hook groups into settings.json, and
1645
+ * write the manifest record the uninstall purge and the next run's prune read.
1646
+ *
1647
+ * Three booleans steer the run and answer different questions. `isFullInstall`
1648
+ * answers "should this run do work?" and gates the prunes. `didPruneRun` answers
1649
+ * "did the prunes start?" and gates the prune call itself. `didPruneFinish`
1650
+ * answers "may this run forget a record?" and gates both manifest keys: only a run
1651
+ * whose prunes read the prior record all the way through may replace a key
1652
+ * wholesale, because wholesale replacement is what makes the next diff mean "the
1653
+ * package stopped shipping this". A scoped install, a full install holding its
1654
+ * prunes behind an unresolved dependency group, and a run whose prune step throws
1655
+ * each merge their record with the prior one, so every path and skill name stays
1656
+ * available to a later prune and to uninstall.
1657
+ *
1658
+ * @param {string[]|null} selectedGroups The `--only` group names, or null for a full install.
1659
+ * @param {{isUpdateRefresh?: boolean}} [options] Run options; `isUpdateRefresh` purges before reinstalling.
1660
+ * @returns {void}
1661
+ */
727
1662
  function install(selectedGroups, options = {}) {
1663
+ const { files: priorManifestFiles, skills: priorManifestSkills } = readPriorManifestArrays();
728
1664
  const isUpdateRefresh = Boolean(options.isUpdateRefresh);
729
1665
  if (isUpdateRefresh && !selectedGroups && existsSync(MANIFEST_FILE)) {
730
1666
  console.log(
@@ -824,22 +1760,24 @@ function install(selectedGroups, options = {}) {
824
1760
  const installedSkillNames = new Set();
825
1761
  const copiedSkillNames = new Set();
826
1762
  for (const sourceRoot of allSourceRoots) {
827
- const skillsSource = join(sourceRoot, 'skills');
1763
+ const skillsSource = join(sourceRoot, MANAGED_SKILLS_DIRECTORY_NAME);
828
1764
  if (!existsSync(skillsSource)) continue;
829
1765
  const skillDirs = readdirSync(skillsSource, { withFileTypes: true }).filter(entry => entry.isDirectory());
830
1766
  for (const skillDir of skillDirs) {
831
1767
  if (allowedSkills && !allowedSkills.has(skillDir.name)) continue;
832
- const stats = copyTree(join(skillsSource, skillDir.name), join(CLAUDE_HOME, 'skills', skillDir.name));
1768
+ const skillSourceDirectory = join(skillsSource, skillDir.name);
1769
+ const skillDestinationDirectory = join(CLAUDE_HOME, MANAGED_SKILLS_DIRECTORY_NAME, skillDir.name);
1770
+ const stats = copyTree(skillSourceDirectory, skillDestinationDirectory);
833
1771
  skillsCreated += stats.created;
834
1772
  skillsUpdated += stats.updated;
835
1773
  skillPaths.push(...stats.paths);
836
1774
  copiedSkillNames.add(skillDir.name);
837
- if (existsSync(join(skillsSource, skillDir.name, SKILL_MANIFEST_FILENAME))) {
1775
+ if (existsSync(join(skillSourceDirectory, SKILL_MANIFEST_FILENAME))) {
838
1776
  installedSkillNames.add(skillDir.name);
839
1777
  }
840
1778
  }
841
1779
  }
842
- summary.skills = { created: skillsCreated, updated: skillsUpdated, paths: skillPaths };
1780
+ summary.skills = { created: skillsCreated, updated: skillsUpdated, pruned: 0, paths: skillPaths };
843
1781
  allInstalledFiles.push(...skillPaths);
844
1782
  const shouldInstallAnyHooks = shouldInstallAllHooks || (allowedHookFiles && allowedHookFiles.size > 0);
845
1783
  if (shouldInstallAnyHooks) {
@@ -847,9 +1785,9 @@ function install(selectedGroups, options = {}) {
847
1785
  let totalHooksUpdated = 0;
848
1786
  let totalHookGroups = 0;
849
1787
  for (const sourceRoot of allSourceRoots) {
850
- const hooksSource = join(sourceRoot, 'hooks');
1788
+ const hooksSource = join(sourceRoot, MANAGED_HOOKS_DIRECTORY_NAME);
851
1789
  if (!existsSync(hooksSource)) continue;
852
- const hooksDestination = join(CLAUDE_HOME, 'hooks');
1790
+ const hooksDestination = join(CLAUDE_HOME, MANAGED_HOOKS_DIRECTORY_NAME);
853
1791
  const filesToCopy = collectFiles(hooksSource)
854
1792
  .filter(file => !file.endsWith('hooks.json'))
855
1793
  .filter(file => {
@@ -884,11 +1822,10 @@ function install(selectedGroups, options = {}) {
884
1822
  hooksPathConfiguration: gitHookInstallationResult.hooksPathConfigurationResult,
885
1823
  };
886
1824
  const hooksPathConfigurationAction = gitHookInstallationResult.hooksPathConfigurationResult.action;
1825
+ allInstalledFiles.push(...gitHookInstallationResult.createdShimPaths);
887
1826
  if (hooksPathConfigurationAction === 'set') {
888
- allInstalledFiles.push(...gitHookInstallationResult.createdShimPaths);
889
1827
  console.log(` Git hooks: configured core.hooksPath -> ${gitHookInstallationResult.gitHooksDirectory}`);
890
1828
  } else if (hooksPathConfigurationAction === 'already-set') {
891
- allInstalledFiles.push(...gitHookInstallationResult.createdShimPaths);
892
1829
  console.log(' Git hooks: core.hooksPath already points to claude-dev-env, no change');
893
1830
  } else {
894
1831
  console.warn(` Git hooks: ${gitHookInstallationResult.hooksPathConfigurationResult.reason}`);
@@ -897,12 +1834,13 @@ function install(selectedGroups, options = {}) {
897
1834
 
898
1835
  const mypyIniInstallResult = installMypyIniForClaudeHooks({
899
1836
  homeDirectory: homedir(),
900
- claudeHooksDirectory: join(CLAUDE_HOME, 'hooks'),
1837
+ claudeHooksDirectory: join(CLAUDE_HOME, MANAGED_HOOKS_DIRECTORY_NAME),
901
1838
  });
902
1839
  if (mypyIniInstallResult.action === 'created') {
903
1840
  allInstalledFiles.push(mypyIniInstallResult.path);
904
1841
  console.log(` ✓ ${relative(homedir(), mypyIniInstallResult.path)} (new — enables mypy to resolve config.messages imports)`);
905
1842
  } else if (mypyIniInstallResult.action === 'already-configured') {
1843
+ allInstalledFiles.push(mypyIniInstallResult.path);
906
1844
  console.log(` .mypy.ini: already configured for Claude hooks`);
907
1845
  } else {
908
1846
  console.warn(` WARNING: .mypy.ini exists at ${mypyIniInstallResult.path} without the expected mypy_path.`);
@@ -924,20 +1862,38 @@ function install(selectedGroups, options = {}) {
924
1862
  console.log(` \u2713 ${relative(CLAUDE_HOME, claudeHubDest)} (hub)`);
925
1863
  }
926
1864
  const isFullInstall = !selectedGroups;
927
- const priorManifestSkills = readPriorManifestSkills();
928
- let manifestSkillNames = priorManifestSkills;
929
- if (isFullInstall) {
930
- if (UNRESOLVED_DEPENDENCY_NAMES.length > 0) {
931
- console.log(
932
- ` Skipping retired-skill prune — unresolved dependency group(s): ${UNRESOLVED_DEPENDENCY_NAMES.join(', ')}. `
933
- + 'A skill that migrated to a dependency package would look retired and be moved to backup, so the prune is held until every dependency resolves.',
1865
+ const didPruneRun = isFullInstall && UNRESOLVED_DEPENDENCY_NAMES.length === 0;
1866
+ let failedPrunePaths = [];
1867
+ let stalePrunedTotal = 0;
1868
+ if (isFullInstall && !didPruneRun) {
1869
+ console.log(
1870
+ ` Skipping retired-skill and stale-file prune — unresolved dependency group(s): ${UNRESOLVED_DEPENDENCY_NAMES.join(', ')}. `
1871
+ + 'A skill that migrated to a dependency package would look retired and its files would look stale, so both prunes are held until every dependency resolves.',
1872
+ );
1873
+ }
1874
+ let didPruneFinish = false;
1875
+ if (didPruneRun) {
1876
+ try {
1877
+ const prunes = runFullInstallPrunes(
1878
+ copiedSkillNames, priorManifestSkills, priorManifestFiles, allInstalledFiles,
1879
+ );
1880
+ summary.skills.pruned = prunes.skillsPrunedCount;
1881
+ stalePrunedTotal = prunes.prunedCount;
1882
+ failedPrunePaths = prunes.failedPaths;
1883
+ didPruneFinish = true;
1884
+ } catch (pruneError) {
1885
+ console.warn(
1886
+ ` Warning: the prune step ended early (${pruneError.message}) — this run merges its manifest record with the prior one, so a later full install still names every file.`,
934
1887
  );
935
- } else {
936
- pruneRetiredSkills(copiedSkillNames, priorManifestSkills);
937
1888
  }
938
- manifestSkillNames = [...installedSkillNames].sort();
939
1889
  }
940
- writeManifest(allInstalledFiles, manifestSkillNames);
1890
+ const manifestSkillNames = didPruneFinish
1891
+ ? [...installedSkillNames].sort()
1892
+ : unionOfSkillNames(priorManifestSkills || [], [...installedSkillNames]).sort();
1893
+ const manifestFiles = didPruneFinish
1894
+ ? manifestFilesWithFailedPrunes(allInstalledFiles, failedPrunePaths)
1895
+ : unionOnComparisonKey(priorManifestFiles || [], allInstalledFiles);
1896
+ writeManifest(manifestFiles, manifestSkillNames);
941
1897
  console.log(`\nInstalled ${PACKAGE_NAME}:`);
942
1898
  for (const directory of CONTENT_DIRECTORIES) {
943
1899
  if (summary[directory]) {
@@ -946,8 +1902,12 @@ function install(selectedGroups, options = {}) {
946
1902
  }
947
1903
  }
948
1904
  if (summary.skills) {
949
- const { created, updated } = summary.skills;
950
- console.log(` skills: ${created + updated} files (${created} new, ${updated} updated)`);
1905
+ const { created, updated, pruned } = summary.skills;
1906
+ const staleClause = pruned > 0 ? `, ${pruned} stale moved aside` : '';
1907
+ console.log(` skills: ${created + updated} files (${created} new, ${updated} updated${staleClause})`);
1908
+ }
1909
+ if (stalePrunedTotal > 0) {
1910
+ console.log(` stale files moved aside: ${stalePrunedTotal} across managed roots, kept under ${PRUNED_SKILLS_BACKUP_DIRECTORY_NAME}/${basename(currentRunBackupRoot())}`);
951
1911
  }
952
1912
  if (summary.hookFiles) {
953
1913
  console.log(` hooks: ${summary.hookFiles.created + summary.hookFiles.updated} files, ${summary.hookGroups} groups in settings.json`);
@@ -966,13 +1926,13 @@ function pathsAreEquivalent(storedPath, installedPath) {
966
1926
  if (normalizedStored === normalizedInstalled) {
967
1927
  return true;
968
1928
  }
969
- const isMaybeCaseInsensitive = process.platform === 'win32' || process.platform === 'darwin';
970
- return isMaybeCaseInsensitive && normalizedStored.toLowerCase() === normalizedInstalled.toLowerCase();
1929
+ return isCaseInsensitiveFilesystem()
1930
+ && normalizedStored.toLowerCase() === normalizedInstalled.toLowerCase();
971
1931
  }
972
1932
 
973
1933
 
974
1934
  function unsetGlobalGitHooksPathIfOurs() {
975
- const installedGitHooksDirectory = join(CLAUDE_HOME, 'hooks', 'git-hooks');
1935
+ const installedGitHooksDirectory = join(CLAUDE_HOME, MANAGED_HOOKS_DIRECTORY_NAME, 'git-hooks');
976
1936
  let currentHooksPath = '';
977
1937
  try {
978
1938
  currentHooksPath = execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], {
@@ -999,6 +1959,50 @@ function unsetGlobalGitHooksPathIfOurs() {
999
1959
  }
1000
1960
 
1001
1961
 
1962
+ /**
1963
+ * Remove one file the manifest records, tolerating a path that is already gone.
1964
+ *
1965
+ * A record can outlive the file it names: the user deleted it by hand, or a
1966
+ * scoped install carried the entry forward past the file's own removal. A missing
1967
+ * path is skipped in silence so an uninstall runs to the end and clears the
1968
+ * manifest, and any other failure is reported and stepped over for the same
1969
+ * reason.
1970
+ *
1971
+ * @param {string} filePath The absolute path the manifest records.
1972
+ * @returns {boolean} True when this call removed a file.
1973
+ */
1974
+ function removeRecordedFile(filePath) {
1975
+ try {
1976
+ unlinkSync(filePath);
1977
+ } catch (removalError) {
1978
+ if (removalError.code !== 'ENOENT') {
1979
+ console.warn(` Warning: could not remove ${relative(CLAUDE_HOME, filePath)} (${removalError.message})`);
1980
+ }
1981
+ return false;
1982
+ }
1983
+ console.log(` ✗ ${relative(CLAUDE_HOME, filePath)} (removed)`);
1984
+ return true;
1985
+ }
1986
+
1987
+ /**
1988
+ * Remove every file the manifest records, then drop the directories the removals
1989
+ * emptied.
1990
+ *
1991
+ * A record is removed when it names a path the installer writes: anything under
1992
+ * ~/.claude, plus the `~/.mypy.ini` the install writes in the home directory.
1993
+ * Every other record is skipped with a warning and counted, so one malformed
1994
+ * entry costs that entry alone: the purge removes every legitimate record, clears
1995
+ * the manifest, and leaves the user with a whole uninstall rather than a
1996
+ * half-removed install.
1997
+ *
1998
+ * Directory cleanup runs after the file loop so a directory holding two recorded
1999
+ * files is judged once both are gone. Each walk stops at the managed root the
2000
+ * purged file sits under, which keeps ~/.claude itself and every unmanaged
2001
+ * sibling directory in place.
2002
+ *
2003
+ * @param {{requireManifest: boolean}} options `requireManifest` exits when no manifest exists.
2004
+ * @returns {number|void} 0 when no manifest exists and none is required.
2005
+ */
1002
2006
  function purgeManagedInstallation({ requireManifest }) {
1003
2007
  if (!existsSync(MANIFEST_FILE)) {
1004
2008
  if (requireManifest) {
@@ -1009,14 +2013,25 @@ function purgeManagedInstallation({ requireManifest }) {
1009
2013
  }
1010
2014
  const manifest = JSON.parse(readFileSync(MANIFEST_FILE, 'utf8'));
1011
2015
  let removed = 0;
2016
+ let skippedUnmanagedCount = 0;
2017
+ const managedRootByEmptiedDirectory = new Map();
1012
2018
  for (const filePath of manifest.files) {
1013
- if (existsSync(filePath)) {
1014
- unlinkSync(filePath);
1015
- console.log(` \u2717 ${relative(CLAUDE_HOME, filePath)} (removed)`);
1016
- removed++;
2019
+ if (!isRemovableManifestRecord(filePath)) {
2020
+ console.warn(` Warning: skipping ${filePath} — the manifest record names no path this installer writes`);
2021
+ skippedUnmanagedCount++;
2022
+ continue;
1017
2023
  }
2024
+ if (removeRecordedFile(filePath)) removed++;
2025
+ const managedRoot = owningManagedRoot(filePath);
2026
+ if (managedRoot) managedRootByEmptiedDirectory.set(dirname(resolve(filePath)), managedRoot);
2027
+ }
2028
+ for (const [emptiedDirectory, managedRoot] of managedRootByEmptiedDirectory) {
2029
+ removeEmptiedParentDirectories(emptiedDirectory, managedRoot);
2030
+ }
2031
+ if (skippedUnmanagedCount > 0) {
2032
+ console.warn(` ${skippedUnmanagedCount} manifest record(s) skipped — each names a path outside ${CLAUDE_HOME} and outside ${MYPY_INI_INSTALL_PATH}`);
1018
2033
  }
1019
- const settingsPath = join(CLAUDE_HOME, 'settings.json');
2034
+ const settingsPath = join(CLAUDE_HOME, SETTINGS_FILE_NAME);
1020
2035
  if (existsSync(settingsPath)) {
1021
2036
  const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
1022
2037
  if (settings.hooks) {
@@ -1030,7 +2045,7 @@ function purgeManagedInstallation({ requireManifest }) {
1030
2045
  }
1031
2046
  unsetGlobalGitHooksPathIfOurs();
1032
2047
  unlinkSync(MANIFEST_FILE);
1033
- for (const directory of [...CONTENT_DIRECTORIES, 'skills', 'hooks']) {
2048
+ for (const directory of MANAGED_TOP_LEVEL_DIRECTORY_NAMES) {
1034
2049
  const dirPath = join(CLAUDE_HOME, directory);
1035
2050
  try {
1036
2051
  if (existsSync(dirPath) && readdirSync(dirPath).length === 0) {
@@ -1046,7 +2061,19 @@ function uninstall() {
1046
2061
  purgeManagedInstallation({ requireManifest: true });
1047
2062
  }
1048
2063
 
2064
+ /**
2065
+ * Print the usage text, listing the install groups this run resolved.
2066
+ *
2067
+ * The group list is read from `INSTALL_GROUPS`, so it names the built-in groups
2068
+ * and every dependency group that resolved on this machine — the same set
2069
+ * `--only` accepts.
2070
+ *
2071
+ * @returns {void}
2072
+ */
1049
2073
  function printHelp() {
2074
+ const groupLines = Object.entries(INSTALL_GROUPS)
2075
+ .map(([groupName, group]) => ` ${groupName} — ${group.description}`)
2076
+ .join('\n');
1050
2077
  console.log(`
1051
2078
  ${PACKAGE_NAME} - Claude Code development standards installer
1052
2079
 
@@ -1058,14 +2085,11 @@ Usage:
1058
2085
  npx ${PACKAGE_NAME} --help Show this help
1059
2086
 
1060
2087
  Groups:
1061
- core Development standards, hooks, agents, commands
1062
- prompt-generator Prompt engineering tools
1063
- journal Session logging and memory
1064
- research Deep research and citation tools
2088
+ ${groupLines}
1065
2089
 
1066
2090
  Examples:
1067
- npx ${PACKAGE_NAME} --only prompt-generator
1068
- npx ${PACKAGE_NAME} --only prompt-generator,research
2091
+ npx ${PACKAGE_NAME} --only core
2092
+ npx ${PACKAGE_NAME} --only core,journal
1069
2093
 
1070
2094
  Install location: ~/.claude/
1071
2095