claude-dev-env 2.9.0 → 2.10.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 (289) hide show
  1. package/CLAUDE.md +1 -1
  2. package/_shared/pr-loop/CLAUDE.md +1 -1
  3. package/_shared/pr-loop/audit-contract.md +17 -6
  4. package/_shared/pr-loop/audit-reply-template.md +4 -4
  5. package/_shared/pr-loop/code-rules-gate.md +3 -5
  6. package/_shared/pr-loop/fix-protocol.md +2 -3
  7. package/_shared/pr-loop/gh-payloads.md +1 -1
  8. package/_shared/pr-loop/scripts/CLAUDE.md +1 -1
  9. package/_shared/pr-loop/scripts/README.md +1 -1
  10. package/_shared/pr-loop/scripts/code_rules_gate.py +2 -0
  11. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_running.py +16 -1
  12. package/_shared/pr-loop/scripts/code_rules_gate_parts/git_blob_readers.py +11 -5
  13. package/_shared/pr-loop/scripts/preflight.py +9 -4
  14. package/_shared/pr-loop/scripts/reviews_disabled.py +50 -22
  15. package/_shared/pr-loop/scripts/tests/conftest.py +20 -0
  16. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +6 -6
  17. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +50 -6
  18. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +1 -1
  19. package/_shared/pr-loop/state-schema.md +5 -14
  20. package/agents/CLAUDE.md +2 -2
  21. package/agents/clean-coder.md +58 -548
  22. package/agents/code-quality-agent.md +10 -2
  23. package/agents/code-verifier.md +1 -1
  24. package/agents/test_agent_frontmatter.py +32 -40
  25. package/audit-rubrics/CLAUDE.md +2 -1
  26. package/audit-rubrics/audit-categories.json +704 -0
  27. package/audit-rubrics/prompts/category-i-concurrency.md +1 -1
  28. package/bin/CLAUDE.md +16 -5
  29. package/bin/ever-shipped-skills.mjs +2 -0
  30. package/bin/install-plan.mjs +402 -0
  31. package/bin/install-transaction.mjs +455 -0
  32. package/bin/install.mjs +593 -147
  33. package/bin/install.plan.test.mjs +194 -0
  34. package/bin/install.profile-root.test.mjs +154 -0
  35. package/bin/install.profiles.test.mjs +253 -0
  36. package/bin/install.settings-defaults.test.mjs +200 -0
  37. package/bin/install.transaction.test.mjs +400 -0
  38. package/bin/install.uninstall-transaction.test.mjs +418 -0
  39. package/bin/merge_managed_permissions.mjs +130 -0
  40. package/bin/resolve-install-root.mjs +181 -0
  41. package/bin/select-install-targets.mjs +401 -0
  42. package/commands/CLAUDE.md +0 -2
  43. package/docs/references/CLAUDE.md +1 -0
  44. package/docs/references/advisor-tool.md +1 -1
  45. package/docs/references/prose-style-enforcement.md +25 -0
  46. package/hooks/blocking/CLAUDE.md +6 -6
  47. package/hooks/blocking/_path_setup.py +9 -5
  48. package/hooks/blocking/code_rules_docstrings.py +124 -30
  49. package/hooks/blocking/code_rules_enforcer.py +161 -16
  50. package/hooks/blocking/code_rules_shared.py +40 -23
  51. package/hooks/blocking/config/CLAUDE.md +3 -5
  52. package/hooks/blocking/config/prose_style_enforcement_constants.py +38 -0
  53. package/hooks/blocking/config/test_prose_style_enforcement_constants.py +45 -0
  54. package/hooks/blocking/eli11_reply_enforcer.py +70 -113
  55. package/hooks/blocking/hedging_language_blocker.py +103 -20
  56. package/hooks/blocking/hook_prose_detector_consistency.py +6 -0
  57. package/hooks/blocking/intent_only_ending_blocker.py +6 -0
  58. package/hooks/blocking/plain_language_blocker.py +139 -20
  59. package/hooks/blocking/pre_tool_use_dispatcher.py +102 -20
  60. package/hooks/blocking/state_description_blocker.py +7 -1
  61. package/hooks/blocking/tdd_enforcer.py +8 -0
  62. package/hooks/blocking/test__path_setup.py +28 -0
  63. package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +99 -0
  64. package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +232 -10
  65. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +1 -1
  66. package/hooks/blocking/test_code_rules_enforcer_join_separator_magic.py +41 -0
  67. package/hooks/blocking/test_code_rules_enforcer_string_magic.py +98 -0
  68. package/hooks/blocking/test_eli11_reply_enforcer.py +98 -165
  69. package/hooks/blocking/test_hedging_language_blocker.py +120 -1
  70. package/hooks/blocking/test_hook_prose_detector_consistency.py +28 -8
  71. package/hooks/blocking/test_intent_only_ending_blocker.py +27 -2
  72. package/hooks/blocking/test_package_inventory_stale_blocker.py +11 -4
  73. package/hooks/blocking/test_plain_language_blocker.py +129 -19
  74. package/hooks/blocking/test_plain_language_blocker_allowlist.py +70 -26
  75. package/hooks/blocking/test_pre_tool_use_dispatcher.py +99 -26
  76. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +87 -50
  77. package/hooks/blocking/test_state_description_blocker.py +45 -2
  78. package/hooks/blocking/test_stop_dispatcher.py +11 -7
  79. package/hooks/blocking/test_volatile_path_in_post_blocker.py +12 -12
  80. package/hooks/blocking/volatile_path_in_post_blocker.py +2 -2
  81. package/hooks/hooks.json +15 -0
  82. package/hooks/hooks_constants/CLAUDE.md +14 -3
  83. package/hooks/hooks_constants/ask_user_question_shape.py +281 -0
  84. package/hooks/hooks_constants/code_rules_enforcer_constants.py +2 -1
  85. package/hooks/hooks_constants/eli11_reply_enforcer_constants.py +5 -12
  86. package/hooks/hooks_constants/hedging_uncertainty_constants.py +42 -0
  87. package/hooks/hooks_constants/issue_tracker_session_starter_constants.py +23 -0
  88. package/hooks/hooks_constants/orchestrator_auto_starter_constants.py +23 -0
  89. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +4 -1
  90. package/hooks/hooks_constants/plain_language_blocker_constants.py +4 -1
  91. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +6 -0
  92. package/hooks/hooks_constants/project_paths_reader.py +31 -4
  93. package/hooks/hooks_constants/prose_matcher_precision_constants.py +40 -0
  94. package/hooks/hooks_constants/pytest_invocation.py +354 -0
  95. package/hooks/hooks_constants/session_start_injector.py +163 -0
  96. package/hooks/hooks_constants/session_start_injector_constants.py +46 -0
  97. package/hooks/hooks_constants/shell_command_pipeline.py +397 -0
  98. package/hooks/hooks_constants/shell_command_segments.py +5 -0
  99. package/hooks/hooks_constants/test_ask_user_question_shape.py +167 -0
  100. package/hooks/hooks_constants/test_project_paths_reader.py +29 -0
  101. package/hooks/hooks_constants/test_prose_metrics_parity.py +8 -0
  102. package/hooks/hooks_constants/test_pytest_invocation.py +130 -0
  103. package/hooks/hooks_constants/test_session_start_injector.py +168 -0
  104. package/hooks/hooks_constants/test_shell_command_pipeline.py +135 -0
  105. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +1 -1
  106. package/hooks/hooks_constants/working_style_prompt_constants.py +30 -0
  107. package/hooks/observability/CLAUDE.md +2 -0
  108. package/hooks/observability/prose_matcher_advisory.py +237 -0
  109. package/hooks/observability/test_prose_matcher_advisory.py +143 -0
  110. package/hooks/session/CLAUDE.md +9 -1
  111. package/hooks/session/_path_setup.py +13 -0
  112. package/hooks/session/issue_tracker_session_starter.py +135 -0
  113. package/hooks/session/orchestrator_auto_starter.py +100 -0
  114. package/hooks/session/test__path_setup.py +28 -0
  115. package/hooks/session/test_issue_tracker_session_starter.py +104 -0
  116. package/hooks/session/test_orchestrator_auto_starter.py +99 -0
  117. package/hooks/session/test_working_style_prompt.py +47 -0
  118. package/hooks/session/untracked_repo_detector.py +1 -24
  119. package/hooks/session/working_style_prompt.py +36 -0
  120. package/hooks/validators/_path_setup.py +19 -0
  121. package/hooks/validators/run_all_validators.py +8 -13
  122. package/installable-surfaces.manifest.json +21 -0
  123. package/output-styles/CLAUDE.md +1 -3
  124. package/package.json +4 -2
  125. package/rules/CLAUDE.md +1 -0
  126. package/rules/durable-post-artifacts.md +2 -2
  127. package/rules/eli11-replies.md +6 -1
  128. package/rules/hedging-claims.md +4 -2
  129. package/rules/long-horizon-autonomy.md +3 -1
  130. package/rules/opus5-communication-contract.md +45 -0
  131. package/rules/plain-language.md +2 -2
  132. package/rules/research-mode.md +1 -1
  133. package/scripts/CLAUDE.md +11 -0
  134. package/scripts/Sync-RepoMain.ps1 +215 -0
  135. package/scripts/active_capability_references.py +218 -0
  136. package/scripts/ci/windows-installer-lifecycle.ps1 +78 -0
  137. package/scripts/claude_chain_runner.py +394 -6
  138. package/scripts/claude_chain_usage.py +1 -1
  139. package/scripts/codex_compat_materializer.py +105 -85
  140. package/scripts/dev_env_scripts_constants/CLAUDE.md +2 -0
  141. package/scripts/dev_env_scripts_constants/active_capability_constants.py +46 -0
  142. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +74 -0
  143. package/scripts/dev_env_scripts_constants/verify_installable_package_constants.py +116 -0
  144. package/scripts/profile-isolation-launchers/config/mcp-bundles.json +25 -0
  145. package/scripts/profile-isolation-launchers/config/profile-isolation-constants.mjs +60 -0
  146. package/scripts/profile-isolation-launchers/config/profiles.manifest.json +54 -0
  147. package/scripts/profile-isolation-launchers/config/shared-allowlist.json +64 -0
  148. package/scripts/profile-isolation-launchers/launcher-runtime.mjs +180 -0
  149. package/scripts/profile-isolation-launchers/lib/profile-manifest.mjs +288 -0
  150. package/scripts/profile-isolation-launchers/mcp-bundles.mjs +275 -0
  151. package/scripts/profile-isolation-launchers/profile-isolation-contract.test.mjs +221 -0
  152. package/scripts/profile-isolation-launchers/tests/launcher-runtime.test.mjs +108 -0
  153. package/scripts/profile-isolation-launchers/tests/mcp-bundles.test.mjs +147 -0
  154. package/scripts/profile-isolation-launchers/tests/shortcut-contract.test.ps1 +102 -0
  155. package/scripts/profile-isolation-launchers/tests/version-compatibility.test.mjs +210 -0
  156. package/scripts/profile-isolation-launchers/version-compatibility.mjs +299 -0
  157. package/scripts/profile-isolation-launchers/windows/shortcut-inventory.ps1 +127 -0
  158. package/scripts/profile-isolation-launchers/windows/shortcut-manifest.json +51 -0
  159. package/scripts/profile-isolation-launchers/windows/shortcut-reconcile.ps1 +77 -0
  160. package/scripts/spawn_grok_batch.py +3 -0
  161. package/scripts/test_active_capability_references.py +108 -0
  162. package/scripts/test_claude_chain_runner.py +414 -82
  163. package/scripts/test_claude_chain_usage.py +12 -12
  164. package/scripts/test_resolve_worker_spawn.py +2 -2
  165. package/scripts/test_verify_installable_package.py +208 -0
  166. package/scripts/tests/test_codex_compat_materializer.py +33 -0
  167. package/scripts/verify_installable_package.py +612 -0
  168. package/settings.json +10 -0
  169. package/skills/CLAUDE.md +2 -0
  170. package/skills/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  171. package/skills/_shared/pr-loop/scripts/audit_category_schema.py +355 -0
  172. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +1 -0
  173. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/audit_category_schema_constants.py +32 -0
  174. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +7 -19
  175. package/skills/_shared/pr-loop/scripts/test_audit_category_schema.py +94 -0
  176. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +21 -0
  177. package/skills/autoconverge/reference/convergence.md +2 -1
  178. package/skills/autoconverge/reference/stop-conditions.md +5 -3
  179. package/skills/beat-sheet/SKILL.md +54 -0
  180. package/skills/beat-sheet/reference/visual-beats.md +29 -0
  181. package/skills/bugteam/CONSTRAINTS.md +4 -4
  182. package/skills/bugteam/EXAMPLES.md +1 -1
  183. package/skills/bugteam/reference/README.md +1 -1
  184. package/skills/e-code-review/SKILL.md +26 -5
  185. package/skills/e-code-review/reference/effort-evaluation.md +35 -0
  186. package/skills/e-code-review/reference/medium.md +15 -4
  187. package/skills/e-code-review/scripts/config/e_code_review_effort_constants/__init__.py +41 -0
  188. package/skills/e-code-review/scripts/config/e_code_review_effort_constants/effort_constants.py +40 -0
  189. package/skills/e-code-review/scripts/e_code_review_scripts_constants/finding_pipeline_constants.py +49 -0
  190. package/skills/e-code-review/scripts/effort_defaults_evidence.json +186 -0
  191. package/skills/e-code-review/scripts/effort_evaluation.py +362 -0
  192. package/skills/e-code-review/scripts/finding_pipeline.py +140 -0
  193. package/skills/e-code-review/scripts/fixtures/demanding.json +26 -0
  194. package/skills/e-code-review/scripts/fixtures/easy.json +14 -0
  195. package/skills/e-code-review/scripts/fixtures/medium.json +20 -0
  196. package/skills/e-code-review/scripts/grok_code_review.py +16 -7
  197. package/skills/e-code-review/scripts/test_effort_evaluation.py +180 -0
  198. package/skills/e-code-review/scripts/test_finding_pipeline.py +197 -0
  199. package/skills/e-code-review/scripts/test_grok_code_review.py +77 -0
  200. package/skills/plan-to-pr/scripts/create_packet.py +4 -4
  201. package/skills/plan-to-pr/scripts/load_skill_constants.py +41 -0
  202. package/skills/plan-to-pr/scripts/validate_packet.py +4 -4
  203. package/skills/plan-to-pr/scripts/validate_protocol.py +4 -1
  204. package/skills/plan-to-pr/scripts/validate_run.py +4 -1
  205. package/skills/pr-converge/scripts/check_convergence.py +21 -19
  206. package/skills/pr-converge/scripts/check_convergence_availability.py +50 -7
  207. package/skills/pr-converge/scripts/conftest.py +35 -0
  208. package/skills/pr-converge/scripts/test_check_convergence_availability.py +65 -0
  209. package/skills/pr-converge/scripts/test_check_convergence_codex.py +11 -1
  210. package/skills/pr-converge/scripts/test_check_convergence_contract.py +9 -2
  211. package/skills/pr-loop-cloud-transport/SKILL.md +1 -1
  212. package/skills/rebase/SKILL.md +15 -3
  213. package/skills/reviewer-gates/SKILL.md +2 -2
  214. package/skills/show/SKILL.md +51 -0
  215. package/skills/show/references/accessibility.md +7 -0
  216. package/skills/show/references/art.md +3 -0
  217. package/skills/show/references/charts.md +3 -0
  218. package/skills/show/references/core-design.md +14 -0
  219. package/skills/show/references/erds.md +3 -0
  220. package/skills/show/references/flowcharts.md +3 -0
  221. package/skills/show/references/host-and-html.md +3 -0
  222. package/skills/show/references/illustrative-diagrams.md +10 -0
  223. package/skills/show/references/interaction.md +3 -0
  224. package/skills/show/references/mockups.md +3 -0
  225. package/skills/show/references/quality-gates.md +7 -0
  226. package/skills/show/references/structural-diagrams.md +3 -0
  227. package/skills/show/references/subject-inventory.md +21 -0
  228. package/skills/show/references/svg-contract.md +22 -0
  229. package/skills/show/routing.yaml +30 -0
  230. package/skills/show/samples/pr1262-v2.svg +222 -0
  231. package/skills/show/scripts/README.md +6 -0
  232. package/skills/show/scripts/validate-artifact.py +91 -0
  233. package/skills/show/scripts/validate-package.py +18 -0
  234. package/skills/show/templates/html-widget.html +4 -0
  235. package/skills/show/templates/svg-base.svg +19 -0
  236. package/skills/show/tests/fixtures/css-var.svg +6 -0
  237. package/skills/show/tests/fixtures/dead-ref.svg +7 -0
  238. package/skills/show/tests/fixtures/filled-glyph.svg +8 -0
  239. package/skills/show/tests/fixtures/inherited-fill.svg +18 -0
  240. package/skills/show/tests/fixtures/invalid.svg +1 -0
  241. package/skills/show/tests/fixtures/large-canvas.svg +21 -0
  242. package/skills/show/tests/fixtures/unfilled-connector.svg +15 -0
  243. package/skills/show/tests/fixtures/valid.html +1 -0
  244. package/skills/show/tests/test_validate-artifact.py +74 -0
  245. package/skills/show/tests/test_validators.py +59 -0
  246. package/skills/show/workflows/create-visual.md +13 -0
  247. package/skills/show/workflows/review-visual.md +20 -0
  248. package/skills/split-pr/SKILL.md +85 -0
  249. package/skills/split-pr/reference/path-layers.md +16 -0
  250. package/skills/split-pr/reference/proposal-format.md +15 -0
  251. package/skills/split-pr/reference/split-further-loop.md +10 -0
  252. package/skills/split-pr/reference/splitting-principles.md +26 -0
  253. package/skills/split-pr/scripts/analyze_pr.py +279 -0
  254. package/skills/split-pr/scripts/categorize_files.py +106 -0
  255. package/skills/split-pr/scripts/config/__init__.py +1 -0
  256. package/skills/split-pr/scripts/config/dependency_constants.py +14 -0
  257. package/skills/split-pr/scripts/config/git_operations_constants.py +36 -0
  258. package/skills/split-pr/scripts/config/packing_constants.py +61 -0
  259. package/skills/split-pr/scripts/config/plan_constants.py +49 -0
  260. package/skills/split-pr/scripts/config/split_pr_constants.py +110 -0
  261. package/skills/split-pr/scripts/execute_split_slices.py +82 -0
  262. package/skills/split-pr/scripts/pack_files_into_slices.py +212 -0
  263. package/skills/split-pr/scripts/split_pr_dependency_graph.py +70 -0
  264. package/skills/split-pr/scripts/split_pr_git_operations.py +184 -0
  265. package/skills/split-pr/scripts/split_pr_layer_order.py +58 -0
  266. package/skills/split-pr/scripts/split_pr_paginate.py +119 -0
  267. package/skills/split-pr/scripts/split_pr_process_runner.py +52 -0
  268. package/skills/split-pr/scripts/split_pr_script_types.py +126 -0
  269. package/skills/split-pr/scripts/split_pr_title.py +41 -0
  270. package/skills/split-pr/scripts/test_analyze_pr.py +228 -0
  271. package/skills/split-pr/scripts/test_categorize_files.py +55 -0
  272. package/skills/split-pr/scripts/test_categorize_files_packing.py +59 -0
  273. package/skills/split-pr/scripts/test_execute_split_slices.py +99 -0
  274. package/skills/split-pr/scripts/test_split_pr_dependency_graph.py +47 -0
  275. package/skills/split-pr/scripts/test_split_pr_git_operations.py +125 -0
  276. package/skills/split-pr/scripts/test_split_pr_layer_order.py +36 -0
  277. package/skills/split-pr/scripts/test_split_pr_paginate.py +65 -0
  278. package/skills/split-pr/scripts/test_split_pr_script_types.py +73 -0
  279. package/skills/split-pr/scripts/test_split_pr_title.py +28 -0
  280. package/skills/split-pr/scripts/test_verify_dependency_graph.py +46 -0
  281. package/skills/split-pr/scripts/test_verify_plan.py +56 -0
  282. package/skills/split-pr/scripts/test_verify_plan_contract.py +50 -0
  283. package/skills/split-pr/scripts/test_verify_plan_path_normalization.py +45 -0
  284. package/skills/split-pr/scripts/verify_dependency_graph.py +111 -0
  285. package/skills/split-pr/scripts/verify_plan.py +139 -0
  286. package/system-prompts/software-engineer.xml +11 -2
  287. package/commands/initialize.md +0 -90
  288. package/commands/stubcheck.md +0 -88
  289. package/output-styles/caveman-agent.md +0 -37
@@ -0,0 +1,418 @@
1
+ /**
2
+ * Uninstall plan preflight and transactional recovery (control-plane F / P-19).
3
+ */
4
+
5
+ import { test } from 'node:test';
6
+ import { strict as assert } from 'node:assert';
7
+ import {
8
+ existsSync,
9
+ mkdtempSync,
10
+ mkdirSync,
11
+ readFileSync,
12
+ rmSync,
13
+ writeFileSync,
14
+ } from 'node:fs';
15
+ import { tmpdir } from 'node:os';
16
+ import { dirname, join } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { execFileSync, spawnSync } from 'node:child_process';
19
+ import {
20
+ buildUninstallPlan,
21
+ InstallPlanPreflightError,
22
+ MALFORMED_SETTINGS_ERROR_MESSAGE,
23
+ PREFLIGHT_ERROR_CODES,
24
+ SETTINGS_NOT_OBJECT_ERROR_MESSAGE,
25
+ } from './install-plan.mjs';
26
+ import {
27
+ FAULT_PHASES,
28
+ INSTALL_FAULT_ENV,
29
+ InstallTransactionFaultError,
30
+ readGlobalCoreHooksPath,
31
+ writeGlobalCoreHooksPath,
32
+ } from './install-transaction.mjs';
33
+
34
+ const THIS_DIRECTORY = dirname(fileURLToPath(import.meta.url));
35
+ const INSTALLER_PATH = join(THIS_DIRECTORY, 'install.mjs');
36
+ const MANIFEST_FILE_NAME = '.claude-dev-env-manifest.json';
37
+ const SETTINGS_FILE_NAME = 'settings.json';
38
+ const USER_OWNED_MARKER = 'user-owned-body\n';
39
+ const PRIOR_RULES_BODY = 'prior-rules-body\n';
40
+ const PRIOR_SETTINGS_MARKER = 'prior-uninstall-settings';
41
+
42
+ /**
43
+ * @returns {{ root: string, managedRoot: string, settingsPath: string, manifestFilePath: string }}
44
+ */
45
+ function sandbox() {
46
+ const root = mkdtempSync(join(tmpdir(), 'cdev-uninstall-txn-'));
47
+ const managedRoot = join(root, 'managed');
48
+ mkdirSync(managedRoot, { recursive: true });
49
+ return {
50
+ root,
51
+ managedRoot,
52
+ settingsPath: join(managedRoot, SETTINGS_FILE_NAME),
53
+ manifestFilePath: join(managedRoot, MANIFEST_FILE_NAME),
54
+ };
55
+ }
56
+
57
+ /**
58
+ * @param {string} filePath
59
+ * @param {string} contents
60
+ */
61
+ function writeFileWithParents(filePath, contents) {
62
+ mkdirSync(dirname(filePath), { recursive: true });
63
+ writeFileSync(filePath, contents);
64
+ }
65
+
66
+ /**
67
+ * @param {string} homeDirectory
68
+ * @param {string[]} extraArguments
69
+ * @param {{ faultPhase?: string|null }} [options]
70
+ */
71
+ function runInstaller(homeDirectory, extraArguments, options = {}) {
72
+ const childEnvironment = {
73
+ ...process.env,
74
+ HOME: homeDirectory,
75
+ USERPROFILE: homeDirectory,
76
+ GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
77
+ };
78
+ if (options.faultPhase) {
79
+ childEnvironment[INSTALL_FAULT_ENV] = options.faultPhase;
80
+ } else {
81
+ delete childEnvironment[INSTALL_FAULT_ENV];
82
+ }
83
+ return spawnSync(process.execPath, [INSTALLER_PATH, ...extraArguments], {
84
+ cwd: THIS_DIRECTORY,
85
+ encoding: 'utf8',
86
+ env: childEnvironment,
87
+ });
88
+ }
89
+
90
+ /**
91
+ * @param {string} homeDirectory
92
+ * @returns {{ claudeDirectory: string, rulesFile: string, userOwnedFile: string }}
93
+ */
94
+ function seedPriorInstall(homeDirectory) {
95
+ const claudeDirectory = join(homeDirectory, '.claude');
96
+ const rulesFile = join(claudeDirectory, 'rules', 'prior.md');
97
+ const userOwnedFile = join(claudeDirectory, 'rules', 'user-owned.md');
98
+ writeFileWithParents(rulesFile, PRIOR_RULES_BODY);
99
+ writeFileWithParents(userOwnedFile, USER_OWNED_MARKER);
100
+ writeFileWithParents(
101
+ join(claudeDirectory, SETTINGS_FILE_NAME),
102
+ `${JSON.stringify({
103
+ hooks: { UserPromptSubmit: [] },
104
+ customMarker: PRIOR_SETTINGS_MARKER,
105
+ }, null, 2)}\n`,
106
+ );
107
+ writeFileWithParents(
108
+ join(claudeDirectory, MANIFEST_FILE_NAME),
109
+ `${JSON.stringify({
110
+ packageName: 'claude-dev-env',
111
+ files: [rulesFile],
112
+ skills: [],
113
+ }, null, 2)}\n`,
114
+ );
115
+ writeFileSync(join(homeDirectory, '.gitconfig'), '');
116
+ return { claudeDirectory, rulesFile, userOwnedFile };
117
+ }
118
+
119
+ test('buildUninstallPlan rejects malformed settings before any plan freezes', () => {
120
+ const box = sandbox();
121
+ try {
122
+ const rulesFile = join(box.managedRoot, 'rules', 'a.md');
123
+ writeFileWithParents(rulesFile, 'x\n');
124
+ writeFileSync(box.settingsPath, '{not-json\n');
125
+ writeFileSync(
126
+ box.manifestFilePath,
127
+ `${JSON.stringify({ files: [rulesFile], skills: [] })}\n`,
128
+ );
129
+ assert.throws(
130
+ () => buildUninstallPlan({
131
+ managedRoot: box.managedRoot,
132
+ manifestFilePath: box.manifestFilePath,
133
+ requireManifest: true,
134
+ isRemovableRecord: () => true,
135
+ }),
136
+ (error) => error instanceof InstallPlanPreflightError
137
+ && error.code === PREFLIGHT_ERROR_CODES.MALFORMED_SETTINGS
138
+ && error.message === MALFORMED_SETTINGS_ERROR_MESSAGE,
139
+ );
140
+ assert.equal(existsSync(rulesFile), true);
141
+ assert.equal(readFileSync(box.settingsPath, 'utf8'), '{not-json\n');
142
+ } finally {
143
+ rmSync(box.root, { recursive: true, force: true });
144
+ }
145
+ });
146
+
147
+ test('buildUninstallPlan rejects non-object settings before any plan freezes', () => {
148
+ const box = sandbox();
149
+ try {
150
+ const rulesFile = join(box.managedRoot, 'rules', 'a.md');
151
+ writeFileWithParents(rulesFile, 'x\n');
152
+ writeFileSync(box.settingsPath, '["array-not-object"]\n');
153
+ writeFileSync(
154
+ box.manifestFilePath,
155
+ `${JSON.stringify({ files: [rulesFile], skills: [] })}\n`,
156
+ );
157
+ assert.throws(
158
+ () => buildUninstallPlan({
159
+ managedRoot: box.managedRoot,
160
+ manifestFilePath: box.manifestFilePath,
161
+ requireManifest: true,
162
+ isRemovableRecord: () => true,
163
+ }),
164
+ (error) => error instanceof InstallPlanPreflightError
165
+ && error.code === PREFLIGHT_ERROR_CODES.MALFORMED_SETTINGS
166
+ && error.message === SETTINGS_NOT_OBJECT_ERROR_MESSAGE,
167
+ );
168
+ assert.equal(existsSync(rulesFile), true);
169
+ } finally {
170
+ rmSync(box.root, { recursive: true, force: true });
171
+ }
172
+ });
173
+
174
+ test('buildUninstallPlan freezes removable files and skips non-removable records', () => {
175
+ const box = sandbox();
176
+ try {
177
+ const removable = join(box.managedRoot, 'rules', 'keep.md');
178
+ const foreign = join(box.root, 'outside.txt');
179
+ writeFileWithParents(removable, 'managed\n');
180
+ writeFileSync(foreign, 'foreign\n');
181
+ writeFileSync(box.settingsPath, '{"hooks":{}}\n');
182
+ writeFileSync(
183
+ box.manifestFilePath,
184
+ `${JSON.stringify({ files: [removable, foreign], skills: ['s'] })}\n`,
185
+ );
186
+ const plan = buildUninstallPlan({
187
+ managedRoot: box.managedRoot,
188
+ manifestFilePath: box.manifestFilePath,
189
+ requireManifest: true,
190
+ isRemovableRecord: (eachPath) => eachPath === removable,
191
+ });
192
+ assert.ok(Object.isFrozen(plan));
193
+ assert.deepEqual(plan.removableFiles, [removable]);
194
+ assert.deepEqual(plan.skippedFiles, [foreign]);
195
+ assert.equal(plan.settingsPath, box.settingsPath);
196
+ assert.equal(plan.manifestExisted, true);
197
+ } finally {
198
+ rmSync(box.root, { recursive: true, force: true });
199
+ }
200
+ });
201
+
202
+ test('installer refuses malformed settings before removing managed files', () => {
203
+ const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-uninstall-malformed-'));
204
+ try {
205
+ const { claudeDirectory, rulesFile, userOwnedFile } = seedPriorInstall(homeDirectory);
206
+ writeFileSync(join(claudeDirectory, SETTINGS_FILE_NAME), '{broken\n');
207
+
208
+ const failedRun = runInstaller(homeDirectory, ['--uninstall']);
209
+ assert.notEqual(failedRun.status, 0, failedRun.stdout + failedRun.stderr);
210
+ assert.match(`${failedRun.stdout}${failedRun.stderr}`, /malformed/i);
211
+ assert.equal(readFileSync(rulesFile, 'utf8'), PRIOR_RULES_BODY);
212
+ assert.equal(readFileSync(userOwnedFile, 'utf8'), USER_OWNED_MARKER);
213
+ assert.equal(existsSync(join(claudeDirectory, MANIFEST_FILE_NAME)), true);
214
+ } finally {
215
+ rmSync(homeDirectory, { recursive: true, force: true });
216
+ }
217
+ });
218
+
219
+ test('installer refuses non-object settings before removing managed files', () => {
220
+ const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-uninstall-nonobj-'));
221
+ try {
222
+ const { claudeDirectory, rulesFile } = seedPriorInstall(homeDirectory);
223
+ writeFileSync(join(claudeDirectory, SETTINGS_FILE_NAME), '"string-settings"\n');
224
+
225
+ const failedRun = runInstaller(homeDirectory, ['--uninstall']);
226
+ assert.notEqual(failedRun.status, 0, failedRun.stdout + failedRun.stderr);
227
+ assert.match(`${failedRun.stdout}${failedRun.stderr}`, /object/i);
228
+ assert.equal(readFileSync(rulesFile, 'utf8'), PRIOR_RULES_BODY);
229
+ assert.equal(existsSync(join(claudeDirectory, MANIFEST_FILE_NAME)), true);
230
+ } finally {
231
+ rmSync(homeDirectory, { recursive: true, force: true });
232
+ }
233
+ });
234
+
235
+ test('uninstall fault after_file_staging restores files, settings, and manifest', () => {
236
+ const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-uninstall-files-'));
237
+ try {
238
+ const { claudeDirectory, rulesFile, userOwnedFile } = seedPriorInstall(homeDirectory);
239
+ const priorSettings = readFileSync(join(claudeDirectory, SETTINGS_FILE_NAME), 'utf8');
240
+ const priorManifest = readFileSync(join(claudeDirectory, MANIFEST_FILE_NAME), 'utf8');
241
+
242
+ const failedRun = runInstaller(homeDirectory, ['--uninstall'], {
243
+ faultPhase: FAULT_PHASES.AFTER_FILE_STAGING,
244
+ });
245
+ assert.notEqual(failedRun.status, 0, failedRun.stdout + failedRun.stderr);
246
+ assert.match(`${failedRun.stdout}${failedRun.stderr}`, /fault|restored|aborted/i);
247
+ assert.equal(readFileSync(rulesFile, 'utf8'), PRIOR_RULES_BODY);
248
+ assert.equal(readFileSync(userOwnedFile, 'utf8'), USER_OWNED_MARKER);
249
+ assert.equal(readFileSync(join(claudeDirectory, SETTINGS_FILE_NAME), 'utf8'), priorSettings);
250
+ assert.equal(readFileSync(join(claudeDirectory, MANIFEST_FILE_NAME), 'utf8'), priorManifest);
251
+ } finally {
252
+ rmSync(homeDirectory, { recursive: true, force: true });
253
+ }
254
+ });
255
+
256
+ test('uninstall fault after_settings_write restores prior settings and files', () => {
257
+ const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-uninstall-settings-'));
258
+ try {
259
+ const { claudeDirectory, rulesFile } = seedPriorInstall(homeDirectory);
260
+ const priorSettings = readFileSync(join(claudeDirectory, SETTINGS_FILE_NAME), 'utf8');
261
+
262
+ const failedRun = runInstaller(homeDirectory, ['--uninstall'], {
263
+ faultPhase: FAULT_PHASES.AFTER_SETTINGS_WRITE,
264
+ });
265
+ assert.notEqual(failedRun.status, 0, failedRun.stdout + failedRun.stderr);
266
+ assert.equal(readFileSync(join(claudeDirectory, SETTINGS_FILE_NAME), 'utf8'), priorSettings);
267
+ assert.equal(readFileSync(rulesFile, 'utf8'), PRIOR_RULES_BODY);
268
+ assert.equal(existsSync(join(claudeDirectory, MANIFEST_FILE_NAME)), true);
269
+ assert.match(
270
+ readFileSync(join(claudeDirectory, MANIFEST_FILE_NAME), 'utf8'),
271
+ /prior\.md|claude-dev-env/,
272
+ );
273
+ } finally {
274
+ rmSync(homeDirectory, { recursive: true, force: true });
275
+ }
276
+ });
277
+
278
+ test('uninstall fault after_manifest_write restores prior manifest and files', () => {
279
+ const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-uninstall-manifest-'));
280
+ try {
281
+ const { claudeDirectory, rulesFile } = seedPriorInstall(homeDirectory);
282
+ const priorManifest = readFileSync(join(claudeDirectory, MANIFEST_FILE_NAME), 'utf8');
283
+
284
+ const failedRun = runInstaller(homeDirectory, ['--uninstall'], {
285
+ faultPhase: FAULT_PHASES.AFTER_MANIFEST_WRITE,
286
+ });
287
+ assert.notEqual(failedRun.status, 0, failedRun.stdout + failedRun.stderr);
288
+ assert.equal(readFileSync(join(claudeDirectory, MANIFEST_FILE_NAME), 'utf8'), priorManifest);
289
+ assert.equal(readFileSync(rulesFile, 'utf8'), PRIOR_RULES_BODY);
290
+ } finally {
291
+ rmSync(homeDirectory, { recursive: true, force: true });
292
+ }
293
+ });
294
+
295
+ test('uninstall fault after_git_config restores prior core.hooksPath', () => {
296
+ const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-uninstall-git-'));
297
+ try {
298
+ seedPriorInstall(homeDirectory);
299
+ const gitConfigPath = join(homeDirectory, '.gitconfig');
300
+ const priorHooksPath = join(homeDirectory, '.claude', 'hooks', 'git-hooks');
301
+ mkdirSync(priorHooksPath, { recursive: true });
302
+ const io = {
303
+ env: {
304
+ ...process.env,
305
+ HOME: homeDirectory,
306
+ USERPROFILE: homeDirectory,
307
+ GIT_CONFIG_GLOBAL: gitConfigPath,
308
+ },
309
+ execFileSync,
310
+ };
311
+ writeGlobalCoreHooksPath(priorHooksPath, io);
312
+
313
+ const failedRun = runInstaller(homeDirectory, ['--uninstall'], {
314
+ faultPhase: FAULT_PHASES.AFTER_GIT_CONFIG,
315
+ });
316
+ assert.notEqual(failedRun.status, 0, failedRun.stdout + failedRun.stderr);
317
+
318
+ const restored = readGlobalCoreHooksPath(io);
319
+ assert.equal(restored, priorHooksPath);
320
+ } finally {
321
+ rmSync(homeDirectory, { recursive: true, force: true });
322
+ }
323
+ });
324
+
325
+ test('retry after restored uninstall fault completes and clears the installation', () => {
326
+ const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-uninstall-retry-'));
327
+ try {
328
+ const { claudeDirectory, rulesFile, userOwnedFile } = seedPriorInstall(homeDirectory);
329
+
330
+ const failedRun = runInstaller(homeDirectory, ['--uninstall'], {
331
+ faultPhase: FAULT_PHASES.AFTER_SETTINGS_WRITE,
332
+ });
333
+ assert.notEqual(failedRun.status, 0, failedRun.stdout + failedRun.stderr);
334
+ assert.equal(existsSync(rulesFile), true);
335
+ assert.equal(existsSync(join(claudeDirectory, MANIFEST_FILE_NAME)), true);
336
+
337
+ const okRun = runInstaller(homeDirectory, ['--uninstall']);
338
+ assert.equal(okRun.status, 0, okRun.stdout + okRun.stderr);
339
+ assert.equal(existsSync(rulesFile), false);
340
+ assert.equal(existsSync(join(claudeDirectory, MANIFEST_FILE_NAME)), false);
341
+ assert.equal(readFileSync(userOwnedFile, 'utf8'), USER_OWNED_MARKER);
342
+ assert.equal(existsSync(join(claudeDirectory, '.claude-dev-env-txn')), false);
343
+ } finally {
344
+ rmSync(homeDirectory, { recursive: true, force: true });
345
+ }
346
+ });
347
+
348
+ test('uninstall of a selected managed root leaves a sibling root unchanged', () => {
349
+ const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-uninstall-target-'));
350
+ try {
351
+ const managedRootsParent = join(homeDirectory, 'managed-roots');
352
+ const profileARoot = join(managedRootsParent, 'profile-a');
353
+ const profileBRoot = join(managedRootsParent, 'profile-b');
354
+ mkdirSync(profileARoot, { recursive: true });
355
+ mkdirSync(profileBRoot, { recursive: true });
356
+
357
+ const profileARules = join(profileARoot, 'rules', 'profile-a.md');
358
+ const profileBRules = join(profileBRoot, 'rules', 'profile-b.md');
359
+ writeFileWithParents(profileARules, 'profile-a-body\n');
360
+ writeFileWithParents(profileBRules, 'profile-b-body\n');
361
+ writeFileWithParents(
362
+ join(profileARoot, SETTINGS_FILE_NAME),
363
+ `${JSON.stringify({ hooks: {}, marker: 'profile-a' }, null, 2)}\n`,
364
+ );
365
+ writeFileWithParents(
366
+ join(profileBRoot, SETTINGS_FILE_NAME),
367
+ `${JSON.stringify({ hooks: {}, marker: 'profile-b' }, null, 2)}\n`,
368
+ );
369
+ writeFileWithParents(
370
+ join(profileARoot, MANIFEST_FILE_NAME),
371
+ `${JSON.stringify({
372
+ packageName: 'claude-dev-env',
373
+ targetIdentity: 'profile-a',
374
+ files: [profileARules],
375
+ skills: [],
376
+ }, null, 2)}\n`,
377
+ );
378
+ writeFileWithParents(
379
+ join(profileBRoot, MANIFEST_FILE_NAME),
380
+ `${JSON.stringify({
381
+ packageName: 'claude-dev-env',
382
+ targetIdentity: 'profile-b',
383
+ files: [profileBRules],
384
+ skills: [],
385
+ }, null, 2)}\n`,
386
+ );
387
+ writeFileSync(join(homeDirectory, '.gitconfig'), '');
388
+
389
+ const failedOnFault = runInstaller(
390
+ homeDirectory,
391
+ ['--target', profileARoot, '--uninstall'],
392
+ { faultPhase: FAULT_PHASES.AFTER_FILE_STAGING },
393
+ );
394
+ assert.notEqual(failedOnFault.status, 0, failedOnFault.stdout + failedOnFault.stderr);
395
+ assert.equal(readFileSync(profileARules, 'utf8'), 'profile-a-body\n');
396
+ assert.equal(readFileSync(profileBRules, 'utf8'), 'profile-b-body\n');
397
+ assert.equal(existsSync(join(profileBRoot, MANIFEST_FILE_NAME)), true);
398
+
399
+ const okRun = runInstaller(
400
+ homeDirectory,
401
+ ['--target', profileARoot, '--uninstall'],
402
+ );
403
+ assert.equal(okRun.status, 0, okRun.stdout + okRun.stderr);
404
+ assert.equal(existsSync(profileARules), false);
405
+ assert.equal(existsSync(join(profileARoot, MANIFEST_FILE_NAME)), false);
406
+ assert.equal(readFileSync(profileBRules, 'utf8'), 'profile-b-body\n');
407
+ assert.equal(existsSync(join(profileBRoot, MANIFEST_FILE_NAME)), true);
408
+ assert.match(readFileSync(join(profileBRoot, SETTINGS_FILE_NAME), 'utf8'), /profile-b/);
409
+ } finally {
410
+ rmSync(homeDirectory, { recursive: true, force: true });
411
+ }
412
+ });
413
+
414
+ test('InstallTransactionFaultError remains the fault type for uninstall phases', () => {
415
+ const error = new InstallTransactionFaultError(FAULT_PHASES.AFTER_SETTINGS_WRITE);
416
+ assert.equal(error.phase, FAULT_PHASES.AFTER_SETTINGS_WRITE);
417
+ assert.equal(error.name, 'InstallTransactionFaultError');
418
+ });
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Merge package-owned permission defaults into a settings object.
3
+ *
4
+ * Ownership is the stable deny-entry string published in the package
5
+ * settings.json. Install ensures each package deny appears once. Uninstall
6
+ * removes only those package-owned identity strings and leaves every other
7
+ * allow/ask/deny entry unchanged.
8
+ */
9
+
10
+ /**
11
+ * @typedef {{
12
+ * allow?: string[],
13
+ * ask?: string[],
14
+ * deny?: string[],
15
+ * }} PermissionLists
16
+ */
17
+
18
+ /**
19
+ * @typedef {{
20
+ * permissions?: PermissionLists,
21
+ * [key: string]: unknown,
22
+ * }} SettingsObject
23
+ */
24
+
25
+ /**
26
+ * Read the managed deny list from a package settings source object.
27
+ *
28
+ * @param {SettingsObject | null | undefined} packageSettings
29
+ * @returns {string[]}
30
+ */
31
+ export function managedDenyEntriesFromPackageSettings(packageSettings) {
32
+ const denyList = packageSettings?.permissions?.deny;
33
+ if (!Array.isArray(denyList)) {
34
+ return [];
35
+ }
36
+ return denyList.filter((eachEntry) => typeof eachEntry === 'string' && eachEntry.length > 0);
37
+ }
38
+
39
+ /**
40
+ * Merge package-owned deny entries into target settings in place.
41
+ *
42
+ * Existing allow/ask entries and non-package deny entries stay as written.
43
+ * Package deny strings already present are not duplicated.
44
+ *
45
+ * @param {SettingsObject} targetSettings
46
+ * @param {string[]} managedDenyEntries
47
+ * @returns {{addedCount: number, alreadyPresentCount: number, managedDenyEntries: string[]}}
48
+ */
49
+ export function mergeManagedPermissionsIntoSettings(targetSettings, managedDenyEntries) {
50
+ if (!targetSettings || typeof targetSettings !== 'object' || Array.isArray(targetSettings)) {
51
+ throw new TypeError('targetSettings must be a plain object');
52
+ }
53
+ if (!Array.isArray(managedDenyEntries)) {
54
+ throw new TypeError('managedDenyEntries must be an array');
55
+ }
56
+
57
+ if (
58
+ !targetSettings.permissions
59
+ || typeof targetSettings.permissions !== 'object'
60
+ || Array.isArray(targetSettings.permissions)
61
+ ) {
62
+ targetSettings.permissions = {};
63
+ }
64
+ const permissions = targetSettings.permissions;
65
+ if (!Array.isArray(permissions.deny)) {
66
+ permissions.deny = [];
67
+ }
68
+
69
+ let addedCount = 0;
70
+ let alreadyPresentCount = 0;
71
+ for (const eachEntry of managedDenyEntries) {
72
+ if (typeof eachEntry !== 'string' || eachEntry.length === 0) {
73
+ continue;
74
+ }
75
+ if (permissions.deny.includes(eachEntry)) {
76
+ alreadyPresentCount += 1;
77
+ continue;
78
+ }
79
+ permissions.deny.push(eachEntry);
80
+ addedCount += 1;
81
+ }
82
+
83
+ return {
84
+ addedCount,
85
+ alreadyPresentCount,
86
+ managedDenyEntries: [...managedDenyEntries],
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Remove package-owned deny entries from target settings in place.
92
+ *
93
+ * User allow, ask, and non-package deny entries remain. Empty permission
94
+ * lists are dropped so uninstall leaves a clean settings object.
95
+ *
96
+ * @param {SettingsObject} targetSettings
97
+ * @param {string[]} managedDenyEntries
98
+ * @returns {{removedCount: number}}
99
+ */
100
+ export function pruneManagedPermissionsFromSettings(targetSettings, managedDenyEntries) {
101
+ if (!targetSettings?.permissions || typeof targetSettings.permissions !== 'object') {
102
+ return { removedCount: 0 };
103
+ }
104
+ const permissions = targetSettings.permissions;
105
+ if (!Array.isArray(permissions.deny) || permissions.deny.length === 0) {
106
+ return { removedCount: 0 };
107
+ }
108
+
109
+ const managedSet = new Set(
110
+ managedDenyEntries.filter((eachEntry) => typeof eachEntry === 'string'),
111
+ );
112
+ const denyCountBefore = permissions.deny.length;
113
+ permissions.deny = permissions.deny.filter((eachEntry) => !managedSet.has(eachEntry));
114
+ const removedCount = denyCountBefore - permissions.deny.length;
115
+
116
+ if (permissions.deny.length === 0) {
117
+ delete permissions.deny;
118
+ }
119
+ if (Array.isArray(permissions.allow) && permissions.allow.length === 0) {
120
+ delete permissions.allow;
121
+ }
122
+ if (Array.isArray(permissions.ask) && permissions.ask.length === 0) {
123
+ delete permissions.ask;
124
+ }
125
+ if (Object.keys(permissions).length === 0) {
126
+ delete targetSettings.permissions;
127
+ }
128
+
129
+ return { removedCount };
130
+ }