py-wlcli 0.8.0__py3-none-any.whl

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 (320) hide show
  1. py_wlcli-0.8.0.dist-info/METADATA +139 -0
  2. py_wlcli-0.8.0.dist-info/RECORD +320 -0
  3. py_wlcli-0.8.0.dist-info/WHEEL +4 -0
  4. py_wlcli-0.8.0.dist-info/entry_points.txt +2 -0
  5. py_wlcli-0.8.0.dist-info/licenses/LICENSE +21 -0
  6. wl_cli/__init__.py +27 -0
  7. wl_cli/__main__.py +130 -0
  8. wl_cli/capabilities.py +79 -0
  9. wl_cli/cc/__init__.py +5 -0
  10. wl_cli/cc/checks.py +1019 -0
  11. wl_cli/cc/cli.py +370 -0
  12. wl_cli/cc/mutate.py +1350 -0
  13. wl_cli/cli_click.py +156 -0
  14. wl_cli/commands/__init__.py +84 -0
  15. wl_cli/commands/base.py +80 -0
  16. wl_cli/commands/buildcommands/__init__.py +1 -0
  17. wl_cli/commands/buildcommands/build.py +71 -0
  18. wl_cli/commands/buildcommands/build_utils.py +47 -0
  19. wl_cli/commands/buildcommands/builders/__init__.py +18 -0
  20. wl_cli/commands/buildcommands/builders/cleanup.py +35 -0
  21. wl_cli/commands/buildcommands/builders/coordinator.py +122 -0
  22. wl_cli/commands/buildcommands/builders/environment.py +81 -0
  23. wl_cli/commands/buildcommands/builders/rust_builder.py +39 -0
  24. wl_cli/commands/buildcommands/builders/stubs.py +58 -0
  25. wl_cli/commands/buildcommands/dist.py +326 -0
  26. wl_cli/commands/buildcommands/test.py +89 -0
  27. wl_cli/commands/clean/__init__.py +1 -0
  28. wl_cli/commands/clean/build.py +151 -0
  29. wl_cli/commands/clean/clean.py +294 -0
  30. wl_cli/commands/clean/clean_utils.py +77 -0
  31. wl_cli/commands/clean/core.py +236 -0
  32. wl_cli/commands/clean/dead_code/__init__.py +0 -0
  33. wl_cli/commands/clean/dead_code/dead_code_command.py +70 -0
  34. wl_cli/commands/clean/dead_code/detector.py +161 -0
  35. wl_cli/commands/clean/dead_code/fixer.py +176 -0
  36. wl_cli/commands/clean/dead_code/reporter.py +295 -0
  37. wl_cli/commands/clean/dead_code/utils.py +0 -0
  38. wl_cli/commands/clean/lfs.py +215 -0
  39. wl_cli/commands/clean/python.py +81 -0
  40. wl_cli/commands/clean/rust.py +91 -0
  41. wl_cli/commands/clean/utils/__init__.py +34 -0
  42. wl_cli/commands/clean/venv.py +91 -0
  43. wl_cli/commands/cleanup.py +73 -0
  44. wl_cli/commands/click_base.py +79 -0
  45. wl_cli/commands/config/__init__.py +1 -0
  46. wl_cli/commands/config/config.py +291 -0
  47. wl_cli/commands/debug/__init__.py +6 -0
  48. wl_cli/commands/debug/debug.py +309 -0
  49. wl_cli/commands/debug/debug_cache.py +86 -0
  50. wl_cli/commands/debug/debug_subagent.py +156 -0
  51. wl_cli/commands/debug/output_formatter.py +165 -0
  52. wl_cli/commands/debug/source_hasher.py +92 -0
  53. wl_cli/commands/format/__init__.py +13 -0
  54. wl_cli/commands/format/format_cache_adapter.py +125 -0
  55. wl_cli/commands/format/format_command.py +197 -0
  56. wl_cli/commands/format/format_coordinator.py +191 -0
  57. wl_cli/commands/format/format_executor.py +470 -0
  58. wl_cli/commands/format/format_outcome.py +44 -0
  59. wl_cli/commands/format/format_path_handler.py +1016 -0
  60. wl_cli/commands/format/format_progress.py +62 -0
  61. wl_cli/commands/format/format_reporter.py +31 -0
  62. wl_cli/commands/format/python_formatter.py +506 -0
  63. wl_cli/commands/format/rust_formatter.py +106 -0
  64. wl_cli/commands/gate/__init__.py +1 -0
  65. wl_cli/commands/gate/gate_command.py +862 -0
  66. wl_cli/commands/gate/preflight.py +445 -0
  67. wl_cli/commands/git/__init__.py +1 -0
  68. wl_cli/commands/git/config.py +169 -0
  69. wl_cli/commands/git/git_facts.py +60 -0
  70. wl_cli/commands/git/git_flow.py +86 -0
  71. wl_cli/commands/git/hook.py +112 -0
  72. wl_cli/commands/git/policy.py +216 -0
  73. wl_cli/commands/git/push_update.py +82 -0
  74. wl_cli/commands/initenv/__init__.py +1 -0
  75. wl_cli/commands/initenv/common/__init__.py +23 -0
  76. wl_cli/commands/initenv/common/check_git_bash.py +78 -0
  77. wl_cli/commands/initenv/common/error_handler.py +68 -0
  78. wl_cli/commands/initenv/common/exceptions.py +52 -0
  79. wl_cli/commands/initenv/components/__init__.py +4 -0
  80. wl_cli/commands/initenv/components/cargo_sync.py +240 -0
  81. wl_cli/commands/initenv/components/config_manager.py +106 -0
  82. wl_cli/commands/initenv/components/directory_utils.py +71 -0
  83. wl_cli/commands/initenv/components/git/__init__.py +9 -0
  84. wl_cli/commands/initenv/components/git/branch_creator.py +143 -0
  85. wl_cli/commands/initenv/components/git/git_flow_interface.py +24 -0
  86. wl_cli/commands/initenv/components/git/git_flow_manager.py +159 -0
  87. wl_cli/commands/initenv/components/git/git_lfs_manager.py +272 -0
  88. wl_cli/commands/initenv/components/git/git_utility.py +254 -0
  89. wl_cli/commands/initenv/components/git/gitignore_manager.py +53 -0
  90. wl_cli/commands/initenv/components/git_initializer.py +212 -0
  91. wl_cli/commands/initenv/components/i18n_manager.py +104 -0
  92. wl_cli/commands/initenv/components/log_manager.py +241 -0
  93. wl_cli/commands/initenv/components/project_structure/__init__.py +23 -0
  94. wl_cli/commands/initenv/components/project_structure/config_generator.py +360 -0
  95. wl_cli/commands/initenv/components/project_structure/directory_creator.py +184 -0
  96. wl_cli/commands/initenv/components/project_structure/hook_source.py +110 -0
  97. wl_cli/commands/initenv/components/project_structure/hooks_manager.py +995 -0
  98. wl_cli/commands/initenv/components/project_structure/readme_handler.py +56 -0
  99. wl_cli/commands/initenv/components/project_structure/rust_files_handler.py +30 -0
  100. wl_cli/commands/initenv/components/project_structure/setup_handler.py +103 -0
  101. wl_cli/commands/initenv/components/project_structure/sop_generator.py +73 -0
  102. wl_cli/commands/initenv/components/project_structure/template_copier.py +104 -0
  103. wl_cli/commands/initenv/components/project_structure/template_manager.py +195 -0
  104. wl_cli/commands/initenv/components/pyproject_generator.py +194 -0
  105. wl_cli/commands/initenv/components/rust_initializer.py +145 -0
  106. wl_cli/commands/initenv/components/symlink_cleaner.py +99 -0
  107. wl_cli/commands/initenv/components/symlink_manager.py +52 -0
  108. wl_cli/commands/initenv/components/venv_manager.py +118 -0
  109. wl_cli/commands/initenv/initenv.py +169 -0
  110. wl_cli/commands/initenv/services/__init__.py +11 -0
  111. wl_cli/commands/initenv/services/environment_initializer.py +288 -0
  112. wl_cli/commands/initenv/services/git_lfs_initializer.py +26 -0
  113. wl_cli/commands/initenv/services/project_structure_initializer.py +52 -0
  114. wl_cli/commands/lfs/README.md +221 -0
  115. wl_cli/commands/lfs/__init__.py +5 -0
  116. wl_cli/commands/lfs/cli.py +106 -0
  117. wl_cli/commands/lfs/config.py +164 -0
  118. wl_cli/commands/lfs/hook.py +61 -0
  119. wl_cli/commands/lfs/initializer.py +531 -0
  120. wl_cli/commands/lfs/manager.py +95 -0
  121. wl_cli/commands/lfs/policy.py +165 -0
  122. wl_cli/commands/lfs/watcher.py +294 -0
  123. wl_cli/commands/lint/__init__.py +1 -0
  124. wl_cli/commands/lint/lint_command.py +297 -0
  125. wl_cli/commands/lint/lint_executor.py +409 -0
  126. wl_cli/commands/lint/lint_reporter.py +579 -0
  127. wl_cli/commands/lint/lint_run.py +258 -0
  128. wl_cli/commands/lint/mypy_config.py +90 -0
  129. wl_cli/commands/registry.py +103 -0
  130. wl_cli/commands/self/__init__.py +7 -0
  131. wl_cli/commands/self/common/__init__.py +8 -0
  132. wl_cli/commands/self/common/capabilities.py +30 -0
  133. wl_cli/commands/self/common/config.py +27 -0
  134. wl_cli/commands/self/common/version_cache.py +85 -0
  135. wl_cli/commands/self/handlers/__init__.py +19 -0
  136. wl_cli/commands/self/handlers/base_handler.py +28 -0
  137. wl_cli/commands/self/handlers/install_handler.py +85 -0
  138. wl_cli/commands/self/handlers/status_handler.py +66 -0
  139. wl_cli/commands/self/handlers/uninstall_handler.py +58 -0
  140. wl_cli/commands/self/handlers/update_handler.py +102 -0
  141. wl_cli/commands/self/handlers/version_handler.py +16 -0
  142. wl_cli/commands/self/self_command.py +237 -0
  143. wl_cli/commands/self/services/__init__.py +15 -0
  144. wl_cli/commands/self/services/installation_manager.py +187 -0
  145. wl_cli/commands/self/services/local_installer.py +165 -0
  146. wl_cli/commands/self/services/pypi_updater.py +278 -0
  147. wl_cli/commands/self/services/windows_update.py +174 -0
  148. wl_cli/commands/testcommands/__init__.py +8 -0
  149. wl_cli/commands/testcommands/base_dependency_analyzer.py +198 -0
  150. wl_cli/commands/testcommands/batch_processor.py +278 -0
  151. wl_cli/commands/testcommands/cache_manager.py +1156 -0
  152. wl_cli/commands/testcommands/coverage_analyzer.py +955 -0
  153. wl_cli/commands/testcommands/coverage_cache_manager.py +183 -0
  154. wl_cli/commands/testcommands/progress_manager.py +79 -0
  155. wl_cli/commands/testcommands/python_env_detector.py +207 -0
  156. wl_cli/commands/testcommands/report_generator.py +101 -0
  157. wl_cli/commands/testcommands/single_test_executor.py +250 -0
  158. wl_cli/commands/testcommands/speed_checker.py +118 -0
  159. wl_cli/commands/testcommands/test_command_builder.py +165 -0
  160. wl_cli/commands/testcommands/test_commands.py +786 -0
  161. wl_cli/commands/testcommands/test_reporter.py +181 -0
  162. wl_cli/commands/testcommands/test_result_handler.py +96 -0
  163. wl_cli/commands/testcommands/test_runner.py +1343 -0
  164. wl_cli/commands/testcommands/test_utils.py +197 -0
  165. wl_cli/commands/testcommands/utils/__init__.py +17 -0
  166. wl_cli/commands/testcommands/utils/coverage_parser.py +98 -0
  167. wl_cli/commands/testcommands/utils/env_info.py +36 -0
  168. wl_cli/commands/testcommands/utils/junit_parser.py +75 -0
  169. wl_cli/commands/testcommands/utils/markdown_utils.py +20 -0
  170. wl_cli/commands/testcommands/utils/project_root.py +61 -0
  171. wl_cli/commands/update/__init__.py +9 -0
  172. wl_cli/commands/update/services/__init__.py +21 -0
  173. wl_cli/commands/update/services/backup_manager.py +207 -0
  174. wl_cli/commands/update/services/component_manager.py +83 -0
  175. wl_cli/commands/update/services/conflict_resolver.py +189 -0
  176. wl_cli/commands/update/services/vendors_updater.py +85 -0
  177. wl_cli/commands/update/services/version_tracker.py +127 -0
  178. wl_cli/commands/update/update_command.py +270 -0
  179. wl_cli/commands/update/utils/__init__.py +3 -0
  180. wl_cli/commands/version/__init__.py +9 -0
  181. wl_cli/commands/version/version_command.py +155 -0
  182. wl_cli/commands/workflow.py +477 -0
  183. wl_cli/env/__init__.py +5 -0
  184. wl_cli/env/cli.py +167 -0
  185. wl_cli/env_status.py +157 -0
  186. wl_cli/envelope.py +91 -0
  187. wl_cli/exceptions.py +85 -0
  188. wl_cli/lib/__init__.py +3 -0
  189. wl_cli/lib/rust_utils.py +35 -0
  190. wl_cli/mcp/__init__.py +6 -0
  191. wl_cli/mcp/capabilities.py +19 -0
  192. wl_cli/mcp/cli.py +22 -0
  193. wl_cli/mcp/gitlab.py +553 -0
  194. wl_cli/mcp/k8s.py +344 -0
  195. wl_cli/mcp/protocol.py +159 -0
  196. wl_cli/platform.py +139 -0
  197. wl_cli/quality/__init__.py +18 -0
  198. wl_cli/utils/__init__.py +7 -0
  199. wl_cli/utils/cache.py +491 -0
  200. wl_cli/utils/cache_ledger.py +355 -0
  201. wl_cli/utils/cache_store.py +546 -0
  202. wl_cli/utils/config/__init__.py +27 -0
  203. wl_cli/utils/config/config_loader.py +163 -0
  204. wl_cli/utils/config/core.py +862 -0
  205. wl_cli/utils/config/defaults.py +142 -0
  206. wl_cli/utils/config/env_exporter.py +86 -0
  207. wl_cli/utils/config/env_parser.py +108 -0
  208. wl_cli/utils/config/validation.py +280 -0
  209. wl_cli/utils/error_handler.py +165 -0
  210. wl_cli/utils/file_copier.py +56 -0
  211. wl_cli/utils/file_operations.py +506 -0
  212. wl_cli/utils/language_utils.py +122 -0
  213. wl_cli/utils/lazy_import.py +240 -0
  214. wl_cli/utils/log_levels.py +35 -0
  215. wl_cli/utils/log_rotators.py +74 -0
  216. wl_cli/utils/logging.py +458 -0
  217. wl_cli/utils/output_context.py +142 -0
  218. wl_cli/utils/performance/__init__.py +1 -0
  219. wl_cli/utils/performance/decorators.py +111 -0
  220. wl_cli/utils/performance/exporter.py +38 -0
  221. wl_cli/utils/performance/models.py +16 -0
  222. wl_cli/utils/performance/monitor.py +161 -0
  223. wl_cli/utils/performance_monitor.py +32 -0
  224. wl_cli/utils/platform_adapter.py +131 -0
  225. wl_cli/utils/pre_commit.py +266 -0
  226. wl_cli/utils/pre_commit_config.py +72 -0
  227. wl_cli/utils/root.py +521 -0
  228. wl_cli/utils/root_fallback.py +256 -0
  229. wl_cli/utils/run_evidence.py +236 -0
  230. wl_cli/utils/structured_logger.py +18 -0
  231. wl_cli/utils/structured_logging/__init__.py +19 -0
  232. wl_cli/utils/structured_logging/config.py +337 -0
  233. wl_cli/utils/structured_logging/core.py +177 -0
  234. wl_cli/utils/structured_logging/env.py +56 -0
  235. wl_cli/utils/structured_logging/filtering.py +75 -0
  236. wl_cli/utils/structured_logging/filters.py +55 -0
  237. wl_cli/utils/structured_logging/formatters.py +251 -0
  238. wl_cli/utils/structured_logging/handlers.py +308 -0
  239. wl_cli/utils/structured_logging/logger.py +354 -0
  240. wl_cli/utils/subprocess.py +71 -0
  241. wl_cli/utils/subprocess_utils.py +260 -0
  242. wl_cli/utils/test_utils/__init__.py +5 -0
  243. wl_cli/utils/test_utils/test_filter.py +36 -0
  244. wl_cli/utils/uv_run.py +79 -0
  245. wl_cli/utils/uv_tool.py +117 -0
  246. wl_cli/utils/uv_tree.py +71 -0
  247. wl_cli/utils/uv_workspace.py +174 -0
  248. wl_cli/utils/version/__init__.py +15 -0
  249. wl_cli/utils/version/pypi_version_checker.py +95 -0
  250. wl_cli/utils/version/version_comparator.py +133 -0
  251. wl_cli/utils/version/version_detectors.py +312 -0
  252. wl_cli/utils/version/version_service.py +131 -0
  253. wl_cli/utils/version/version_updater.py +198 -0
  254. wl_cli/utils/wl_dir_updater.py +1440 -0
  255. wl_cli/utils/workspace_detector/__init__.py +28 -0
  256. wl_cli/utils/workspace_detector/core.py +161 -0
  257. wl_cli/utils/workspace_detector/detection_strategies.py +107 -0
  258. wl_cli/utils/workspace_detector/exceptions.py +5 -0
  259. wl_cli/utils/workspace_detector/types.py +17 -0
  260. wl_cli/utils/workspace_detector/venv_resolver.py +84 -0
  261. wl_cli/vendors/config/.codespellrc +2 -0
  262. wl_cli/vendors/config/dependencies.toml +10 -0
  263. wl_cli/vendors/config/mypy.ini +62 -0
  264. wl_cli/vendors/config/pyproject_defaults.toml +28 -0
  265. wl_cli/vendors/config/template_config.toml +24 -0
  266. wl_cli/vendors/config/tool_configs.toml +76 -0
  267. wl_cli/vendors/git/.gitignore +122 -0
  268. wl_cli/vendors/git/.pre-commit-config.yaml +37 -0
  269. wl_cli/vendors/hooks/applypatch-msg.sample +15 -0
  270. wl_cli/vendors/hooks/commit-msg.sample +24 -0
  271. wl_cli/vendors/hooks/fsmonitor-watchman.sample +174 -0
  272. wl_cli/vendors/hooks/post-update.sample +8 -0
  273. wl_cli/vendors/hooks/pre-applypatch.sample +14 -0
  274. wl_cli/vendors/hooks/pre-commit +45 -0
  275. wl_cli/vendors/hooks/pre-commit.legacy +31 -0
  276. wl_cli/vendors/hooks/pre-commit.sample +49 -0
  277. wl_cli/vendors/hooks/pre-merge-commit.sample +13 -0
  278. wl_cli/vendors/hooks/pre-push.sample +60 -0
  279. wl_cli/vendors/hooks/pre-rebase.sample +169 -0
  280. wl_cli/vendors/hooks/pre-receive.sample +24 -0
  281. wl_cli/vendors/hooks/prepare-commit-msg.sample +42 -0
  282. wl_cli/vendors/hooks/push-to-checkout.sample +78 -0
  283. wl_cli/vendors/hooks/sendemail-validate.sample +77 -0
  284. wl_cli/vendors/hooks/unix/applypatch-msg.sample +15 -0
  285. wl_cli/vendors/hooks/unix/check_lockfile.py +58 -0
  286. wl_cli/vendors/hooks/unix/check_lockfile.sh +36 -0
  287. wl_cli/vendors/hooks/unix/commit-msg.sample +24 -0
  288. wl_cli/vendors/hooks/unix/fsmonitor-watchman.sample +174 -0
  289. wl_cli/vendors/hooks/unix/post-update.sample +8 -0
  290. wl_cli/vendors/hooks/unix/pre-applypatch.sample +14 -0
  291. wl_cli/vendors/hooks/unix/pre-commit +190 -0
  292. wl_cli/vendors/hooks/unix/pre-commit.legacy +31 -0
  293. wl_cli/vendors/hooks/unix/pre-commit.py +81 -0
  294. wl_cli/vendors/hooks/unix/pre-commit.sample +49 -0
  295. wl_cli/vendors/hooks/unix/pre-merge-commit.sample +13 -0
  296. wl_cli/vendors/hooks/unix/pre-push +417 -0
  297. wl_cli/vendors/hooks/unix/pre-push.sample +53 -0
  298. wl_cli/vendors/hooks/unix/pre-rebase.sample +169 -0
  299. wl_cli/vendors/hooks/unix/pre-receive.sample +24 -0
  300. wl_cli/vendors/hooks/unix/prepare-commit-msg.sample +42 -0
  301. wl_cli/vendors/hooks/update.sample +128 -0
  302. wl_cli/vendors/hooks/win/work.bat +63 -0
  303. wl_cli/vendors/project/.gitignore +90 -0
  304. wl_cli/vendors/project/.python-version +1 -0
  305. wl_cli/vendors/project/LICENSE +21 -0
  306. wl_cli/vendors/project/README.md +0 -0
  307. wl_cli/vendors/project/pyproject.toml +27 -0
  308. wl_cli/vendors/project/pyproject_no_workspace.toml +16 -0
  309. wl_cli/vendors/project/pyproject_workspace.toml +14 -0
  310. wl_cli/vendors/readme/README.md +120 -0
  311. wl_cli/vendors/rust/Cargo.toml +14 -0
  312. wl_cli/vendors/rust/lib/__init__.py +3 -0
  313. wl_cli/vendors/rust/lib/rust_utils.py +31 -0
  314. wl_cli/vendors/rust/src/lib.rs +13 -0
  315. wl_cli/vendors/rust/src/main.rs +3 -0
  316. wl_cli/vendors/rust/tests/test_rust_fallback.py +40 -0
  317. wl_cli/vendors/sop_doc/git-dev-sop.md +220 -0
  318. wl_cli/vendors/sop_doc/industrial-standard-sop.md +145 -0
  319. wl_cli/vendors/sop_doc/python-sop.md +342 -0
  320. wl_cli/vendors/sop_doc/rust-readme.md +219 -0
wl_cli/cc/mutate.py ADDED
@@ -0,0 +1,1350 @@
1
+ """Write paths for ``wl cc`` (issue #223).
2
+
3
+ ``wl cc check`` is the read-only half (:mod:`wl_cli.cc.checks`, #222); this
4
+ module is the other half. It does not re-implement a single judgement: every
5
+ mutator starts from the same probe function the report uses, acts only on the
6
+ difference, and re-probes before it reports -- so ``init``, ``repair`` and the
7
+ granular commands share one source of truth, and a second run is a no-op.
8
+
9
+ The invariants below are carried over verbatim from wlcc's ``check.sh`` and
10
+ ``wl-env-init`` (the migration ruling is #223 note 19199):
11
+
12
+ * a plugin that is installed but **disabled** is never reinstalled and never
13
+ re-enabled -- only ``missing`` entries are installed;
14
+ * an existing claude-mem ``settings.json`` is never overwritten (``exists`` is
15
+ a merge-it-yourself state, not a repair);
16
+ * a conflicting statusline is never overwritten, an existing claude-hud
17
+ statusLine is never rewritten (a missing plugin cache does not turn a no-op
18
+ into a write), and a settings file that is not valid JSON is never rewritten;
19
+ * hooks are rewritten before they are pruned, with a timestamped backup, an
20
+ atomic replace, a pre-write "nothing but ``hooks`` changed" self-check and a
21
+ post-write read-back;
22
+ * under ``~/.config/wlcc`` only the retired stamp list is ever deleted;
23
+ unregistered entries are reported and kept;
24
+ * the MCP version stamp is written only when *both* servers registered.
25
+
26
+ ``repaired`` is reported only for a section that actually wrote something and
27
+ whose re-probe then succeeded; a section that wrote but still has pending work
28
+ stays ``dangling``.
29
+
30
+ The one write path that is deliberately not here is installing ``wl`` itself:
31
+ that literal already lives in :data:`wl_cli.env_status.INSTALL_ARGV` and
32
+ ``wl env repair`` owns it. ``cc init`` reports the ``env`` section and points at
33
+ that repair command instead of forking a second literal.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import datetime
39
+ import json
40
+ import os
41
+ import shutil
42
+ import subprocess
43
+ from functools import partial
44
+ from pathlib import Path
45
+ from typing import TYPE_CHECKING, Any
46
+
47
+ from ..env_status import build_payload
48
+ from . import checks
49
+
50
+ if TYPE_CHECKING:
51
+ from collections.abc import Callable, Iterable
52
+
53
+ #: Sections ``wl cc init`` / ``wl cc repair`` report, in stable payload order.
54
+ #: ``env`` is report-only: ``wl env repair`` owns the install literal.
55
+ SECTION_ORDER: tuple[str, ...] = (
56
+ "env",
57
+ "plugins",
58
+ "mcp",
59
+ "settings",
60
+ "statusline",
61
+ "hooks",
62
+ "stamps",
63
+ )
64
+
65
+ #: Sections a mutator may touch (and the ``--section`` choices).
66
+ MUTABLE_SECTIONS: tuple[str, ...] = (
67
+ "plugins",
68
+ "mcp",
69
+ "settings",
70
+ "statusline",
71
+ "hooks",
72
+ "stamps",
73
+ )
74
+
75
+ #: Marketplace sources check.sh §1 registers, in its order.
76
+ MARKETPLACES: tuple[str, ...] = (
77
+ "https://github.com/affaan-m/ECC.git",
78
+ "astral-sh/claude-code-plugins",
79
+ "thedotmack/claude-mem",
80
+ "https://github.com/mattpocock/skills",
81
+ "jarrodwatts/claude-hud",
82
+ )
83
+
84
+ #: gstack clone source (check.sh §5).
85
+ GSTACK_REPO = "https://github.com/garrytan/gstack.git"
86
+
87
+ #: wlcc marketplace source when it is not registered yet (``/wl-upgrade`` §2).
88
+ WLCC_MARKETPLACE_REPO = "https://gitlab.gewinnrobo.com/Humuyuan/wlcc.git"
89
+
90
+ #: Per-server extra argv for ``claude mcp add`` (check.sh §3). The GitLab host
91
+ #: is deliberately *not* passed: ``server.py`` owns that default (#242).
92
+ MCP_SERVER_EXTRA: dict[str, tuple[str, ...]] = {
93
+ "gitlab": (),
94
+ "k8s-diagnose": ("-e", "K8S_SSH_HOST=ubuntu-server"),
95
+ }
96
+
97
+ #: How many timestamped ``settings.json`` backups are kept (hooks-drift).
98
+ BACKUP_KEEP = 5
99
+
100
+ #: Every status a mutator may report (``repaired`` is allowed here, unlike the
101
+ #: read-only probe set in :mod:`wl_cli.cc.checks`).
102
+ _WRITE_STATUSES = frozenset({"ok", "dangling", "repaired", "unverifiable", "error"})
103
+
104
+ #: How much of a failed command's output is quoted in a reason.
105
+ _EXCERPT_LIMIT = 400
106
+
107
+ #: Network steps (marketplace, plugin install/update, gstack clone/setup) get a
108
+ #: longer ceiling than the local ones; check.sh has no timeout at all, so a
109
+ #: slow link must not turn a healthy install into a failure.
110
+ _NETWORK_TIMEOUT = 600
111
+
112
+ #: Runtime candidates for the claude-hud statusline, bun first (SKILL.md §3).
113
+ _HUD_RUNTIMES: tuple[tuple[str, str], ...] = (
114
+ ("bun", "src/index.ts"),
115
+ ("node", "dist/index.js"),
116
+ )
117
+
118
+ #: The statusline command from wlcc ``wl-env-init`` §3, with three slots. The
119
+ #: embedded ``'\''`` sequences are part of the generated text: the inner body
120
+ #: is a ``bash -c`` single-quoted string, so an embedded quote is spelled that
121
+ #: way on purpose. ``__FLAG__`` is empty for node and ``--env-file /dev/null``
122
+ #: for bun. ``__RUNTIME__`` is an absolute path, so the shell always finds it.
123
+ _HUD_TEMPLATE = r"""bash -c 'cols=${COLUMNS:-}; case "$cols" in ""|*[!0-9]*) cols=$(stty size 2>/dev/null </dev/tty | awk '\''{print $2}'\'');; esac; case "$cols" in ""|*[!0-9]*) cols=120;; esac; export COLUMNS=$(( cols > 4 ? cols - 4 : 1 )); plugin_dir=$(ls -d "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/*/claude-hud/*/ 2>/dev/null | awk -F/ '\''{ print $(NF-1) "\t" $0 }'\'' | grep -E '\''^[0-9]+\.[0-9]+\.[0-9]+[[:space:]]'\'' | sort -t. -k1,1n -k2,2n -k3,3n -k4,4n | tail -1 | cut -f2-); [ -n "$plugin_dir" ] || exit 0; exec "__RUNTIME__" __FLAG__"${plugin_dir}__SOURCE__"'"""
124
+
125
+
126
+ class WriteError(Exception):
127
+ """A write could not be completed safely; the caller reports the reason."""
128
+
129
+
130
+ def _result(
131
+ status: str,
132
+ reason: str | None = None,
133
+ changed: list[str] | None = None,
134
+ **fields: Any,
135
+ ) -> dict[str, Any]:
136
+ """Build one section/mutation result with ``status``, ``reason``, ``changed``."""
137
+ return checks._result(status, reason, changed=list(changed or []), **fields)
138
+
139
+
140
+ def _excerpt(proc: subprocess.CompletedProcess[str]) -> str:
141
+ """Flatten a failed command's output into one readable line."""
142
+ return " ".join((proc.stderr or proc.stdout or "").split())[:_EXCERPT_LIMIT]
143
+
144
+
145
+ def _failure(proc: subprocess.CompletedProcess[str]) -> str:
146
+ """Render ``exited N[: excerpt]`` for a failed command."""
147
+ excerpt = _excerpt(proc)
148
+ return f"exited {proc.returncode}" + (f": {excerpt}" if excerpt else "")
149
+
150
+
151
+ def _run(
152
+ argv: list[str], *, cwd: str | None = None, timeout: int = 120
153
+ ) -> subprocess.CompletedProcess[str]:
154
+ """Run one external host command and return its result without raising.
155
+
156
+ The single seam tests replace; production code never reads the return code
157
+ of a *probe* through this (the probes live in :mod:`wl_cli.cc.checks`).
158
+ """
159
+ return subprocess.run(
160
+ argv,
161
+ cwd=cwd,
162
+ capture_output=True,
163
+ text=True,
164
+ check=False,
165
+ timeout=timeout,
166
+ )
167
+
168
+
169
+ def _quiet_remove(target: Path | None) -> None:
170
+ """Remove *target* if it exists, ignoring every error."""
171
+ if target is None:
172
+ return
173
+ try:
174
+ os.remove(target)
175
+ except OSError:
176
+ pass
177
+
178
+
179
+ def _without_key(data: dict[str, Any], key: str) -> str:
180
+ """Normalised JSON of everything except one top-level key (self-check)."""
181
+ subset = {name: value for name, value in data.items() if name != key}
182
+ return json.dumps(subset, sort_keys=True, ensure_ascii=False)
183
+
184
+
185
+ def _backup_path(path: Path) -> Path:
186
+ """Return an unused ``<name>.bak.<YYYYmmdd-HHMMSS>[-n]`` sibling."""
187
+ stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
188
+ candidate = path.with_name(f"{path.name}.bak.{stamp}")
189
+ sequence = 0
190
+ while candidate.exists():
191
+ sequence += 1
192
+ candidate = path.with_name(f"{path.name}.bak.{stamp}-{sequence}")
193
+ return candidate
194
+
195
+
196
+ def _prune_backups(path: Path) -> None:
197
+ """Keep only the newest :data:`BACKUP_KEEP` backups of *path*."""
198
+ prefix = f"{path.name}.bak."
199
+ try:
200
+ names = sorted(
201
+ name for name in os.listdir(path.parent) if name.startswith(prefix)
202
+ )
203
+ except OSError:
204
+ return
205
+ for name in names[:-BACKUP_KEEP]:
206
+ _quiet_remove(path.parent / name)
207
+
208
+
209
+ def _read_settings(path: Path) -> dict[str, Any]:
210
+ """Read a JSON object, raising :class:`WriteError` with a readable reason."""
211
+ try:
212
+ text = path.read_text(encoding="utf-8")
213
+ except OSError as exc:
214
+ raise WriteError(f"cannot read {path}: {exc}") from exc
215
+ try:
216
+ data = json.loads(text)
217
+ except ValueError as exc:
218
+ raise WriteError(f"{path} is not valid JSON: {exc}") from exc
219
+ if not isinstance(data, dict):
220
+ raise WriteError(f"{path} is not a JSON object")
221
+ return data
222
+
223
+
224
+ def _atomic_write_text(path: Path, text: str) -> None:
225
+ """Write *text* through a same-directory temp file and ``os.replace``."""
226
+ path.parent.mkdir(parents=True, exist_ok=True)
227
+ tmp = path.with_name(f"{path.name}.tmp.{os.getpid()}")
228
+ try:
229
+ with open(tmp, "w", encoding="utf-8") as handle:
230
+ handle.write(text)
231
+ os.replace(tmp, path)
232
+ except OSError as exc:
233
+ _quiet_remove(tmp)
234
+ raise WriteError(f"cannot write {path}: {exc}") from exc
235
+
236
+
237
+ def _write_stamp(path: Path, content: str) -> None:
238
+ """Write a one-line stamp atomically, with wlcc's ``0700`` state root."""
239
+ path.parent.mkdir(parents=True, exist_ok=True)
240
+ try:
241
+ os.chmod(path.parent, 0o700)
242
+ except OSError:
243
+ pass
244
+ _atomic_write_text(path, content + "\n")
245
+
246
+
247
+ def _atomic_write_json(
248
+ path: Path,
249
+ data: dict[str, Any],
250
+ *,
251
+ preserve_except: str | None = None,
252
+ ) -> str | None:
253
+ """Back up, write atomically, self-check, then verify. Returns the backup.
254
+
255
+ ``preserve_except`` names the one top-level key a caller may change. The
256
+ self-check compares the normalised JSON of every *other* key against the
257
+ file as it is on disk right now, so a concurrent edit or a planning bug
258
+ aborts the write instead of silently dropping someone else's keys. The
259
+ post-write read-back proves the disk holds exactly the planned object.
260
+
261
+ Raises:
262
+ WriteError: On backup, write, self-check or verification failure; the
263
+ reason always names the file and keeps the backup path when one
264
+ exists.
265
+ """
266
+ path.parent.mkdir(parents=True, exist_ok=True)
267
+ backup: Path | None = None
268
+ if path.exists():
269
+ backup = _backup_path(path)
270
+ try:
271
+ shutil.copy2(path, backup)
272
+ except OSError as exc:
273
+ raise WriteError(f"cannot back up {path}: {exc}") from exc
274
+
275
+ tmp = path.with_name(f"{path.name}.tmp.{os.getpid()}")
276
+ try:
277
+ with open(tmp, "w", encoding="utf-8") as handle:
278
+ json.dump(data, handle, indent=2, ensure_ascii=False)
279
+ handle.write("\n")
280
+ written = json.loads(tmp.read_text(encoding="utf-8"))
281
+ except (OSError, ValueError) as exc:
282
+ _quiet_remove(tmp)
283
+ _quiet_remove(backup)
284
+ raise WriteError(f"cannot write {tmp}: {exc}") from exc
285
+
286
+ if preserve_except is not None:
287
+ before = _read_settings(path) if path.exists() else {}
288
+ if _without_key(before, preserve_except) != _without_key(
289
+ written, preserve_except
290
+ ):
291
+ _quiet_remove(tmp)
292
+ _quiet_remove(backup)
293
+ raise WriteError(
294
+ "self-check failed: keys other than "
295
+ f"{preserve_except!r} changed; nothing was written"
296
+ )
297
+
298
+ try:
299
+ os.replace(tmp, path)
300
+ except OSError as exc:
301
+ _quiet_remove(tmp)
302
+ _quiet_remove(backup)
303
+ raise WriteError(f"cannot replace {path}: {exc}") from exc
304
+
305
+ try:
306
+ on_disk = json.loads(path.read_text(encoding="utf-8"))
307
+ except (OSError, ValueError) as exc:
308
+ raise WriteError(
309
+ f"post-write verification failed for {path}: {exc}; backup at {backup}"
310
+ ) from exc
311
+ if on_disk != data:
312
+ raise WriteError(
313
+ f"post-write verification failed for {path}: content mismatch; "
314
+ f"backup at {backup}"
315
+ )
316
+
317
+ _prune_backups(path)
318
+ return str(backup) if backup is not None else None
319
+
320
+
321
+ def _isolated(name: str, action: Callable[[], dict[str, Any]]) -> dict[str, Any]:
322
+ """Run one mutator, converting any failure into an isolated ``unverifiable``."""
323
+ try:
324
+ result = action()
325
+ except Exception as exc: # noqa: BLE001 - isolation is the contract
326
+ return _result("unverifiable", f"{name}: {exc}")
327
+ if result.get("status") not in _WRITE_STATUSES:
328
+ return _result(
329
+ "unverifiable",
330
+ f"{name}: mutator returned an unknown status {result.get('status')!r}",
331
+ )
332
+ return result
333
+
334
+
335
+ def _probe_env() -> dict[str, Any]:
336
+ """The report-only ``env`` section: the shared ``wl env check`` payload."""
337
+ status, reason, fields = build_payload()
338
+ return _result(status, reason, [], **fields)
339
+
340
+
341
+ def _probe_plugins() -> dict[str, Any]:
342
+ return checks.probe_plugins(checks.load_host_context())
343
+
344
+
345
+ def _probe_mcp() -> dict[str, Any]:
346
+ return checks.probe_mcp(checks.load_host_context())
347
+
348
+
349
+ def _probe_settings() -> dict[str, Any]:
350
+ return checks.probe_settings(checks.load_host_context())
351
+
352
+
353
+ def _probe_statusline() -> dict[str, Any]:
354
+ return checks.probe_statusline(checks.load_host_context())
355
+
356
+
357
+ def _probe_hooks() -> dict[str, Any]:
358
+ return checks.probe_hooks(checks.load_host_context())
359
+
360
+
361
+ def _probe_stamps() -> dict[str, Any]:
362
+ return checks.probe_stamps(checks.load_host_context())
363
+
364
+
365
+ #: Read-only probes per section, reused by ``check``-style subcommands and by
366
+ #: ``--dry-run``. ``env`` is special only in that it has no mutator.
367
+ _PROBES: dict[str, Callable[[], dict[str, Any]]] = {
368
+ "env": _probe_env,
369
+ "plugins": _probe_plugins,
370
+ "mcp": _probe_mcp,
371
+ "settings": _probe_settings,
372
+ "statusline": _probe_statusline,
373
+ "hooks": _probe_hooks,
374
+ "stamps": _probe_stamps,
375
+ }
376
+
377
+
378
+ def probe_section(name: str, *, dry_run: bool = False) -> dict[str, Any]:
379
+ """Return one section's read-only probe as a mutation-shaped result.
380
+
381
+ Args:
382
+ name: One of :data:`SECTION_ORDER`.
383
+ dry_run: Mark the result as a preview (no mutator ran).
384
+ """
385
+ probe = _PROBES[name]()
386
+ fields = {
387
+ key: value for key, value in _section_fields(probe).items() if key != "changed"
388
+ }
389
+ if dry_run:
390
+ fields["dry_run"] = True
391
+ return _result(
392
+ str(probe["status"]),
393
+ probe.get("reason") if isinstance(probe.get("reason"), str) else None,
394
+ [],
395
+ **fields,
396
+ )
397
+
398
+
399
+ def _section_fields(probe: dict[str, Any]) -> dict[str, Any]:
400
+ """Copy a probe's payload without its own status/reason pair."""
401
+ return {
402
+ key: value for key, value in probe.items() if key not in ("status", "reason")
403
+ }
404
+
405
+
406
+ # --------------------------------------------------------------------------- #
407
+ # plugins
408
+ # --------------------------------------------------------------------------- #
409
+
410
+
411
+ def ensure_plugins() -> dict[str, Any]:
412
+ """Install only the **missing** required plugins; never touch disabled ones.
413
+
414
+ Disabled entries are already installed, and wlcc's ruling is explicit:
415
+ ``check.sh`` only ever calls ``claude plugin install`` for an entry absent
416
+ from the registry, so a user's ``enabledPlugins: false`` survives every
417
+ repair. The marketplaces are added only when at least one plugin is
418
+ missing, which is what keeps a second ``wl cc init`` free of side effects.
419
+ """
420
+ before = checks.probe_plugins(checks.load_host_context())
421
+ fields = _section_fields(before)
422
+ changed: list[str] = []
423
+ if before["status"] == "unverifiable":
424
+ return _result("unverifiable", before.get("reason"), changed, **fields)
425
+
426
+ missing = list(before.get("missing") or [])
427
+ if not missing:
428
+ return _result(str(before["status"]), before.get("reason"), changed, **fields)
429
+
430
+ claude = shutil.which("claude")
431
+ if claude is None:
432
+ return _result(
433
+ "unverifiable",
434
+ "the claude CLI is not on PATH; cannot install "
435
+ + ", ".join(str(entry) for entry in missing),
436
+ changed,
437
+ **fields,
438
+ )
439
+
440
+ warnings: list[str] = []
441
+ for repo in MARKETPLACES:
442
+ try:
443
+ proc = _run(
444
+ [claude, "plugin", "marketplace", "add", repo],
445
+ timeout=_NETWORK_TIMEOUT,
446
+ )
447
+ except (OSError, subprocess.SubprocessError) as exc:
448
+ warnings.append(f"marketplace add {repo} could not run: {exc}")
449
+ continue
450
+ if proc.returncode == 0:
451
+ changed.append(f"plugins: added marketplace {repo}")
452
+ if warnings:
453
+ fields["warnings"] = warnings
454
+
455
+ failures: list[str] = []
456
+ for entry in missing:
457
+ try:
458
+ proc = _run(
459
+ [claude, "plugin", "install", str(entry), "-s", "user"],
460
+ timeout=_NETWORK_TIMEOUT,
461
+ )
462
+ except (OSError, subprocess.SubprocessError) as exc:
463
+ failures.append(f"{entry}: {exc}")
464
+ continue
465
+ if proc.returncode == 0:
466
+ changed.append(f"plugins: installed {entry}")
467
+ else:
468
+ failures.append(f"{entry}: {_failure(proc)}")
469
+ if failures:
470
+ fields["failed"] = failures
471
+
472
+ after = checks.probe_plugins(checks.load_host_context())
473
+ fields.update(_section_fields(after))
474
+ if changed and after["status"] == "ok":
475
+ return _result("repaired", None, changed, **fields)
476
+
477
+ reason = after.get("reason")
478
+ if failures:
479
+ reason = checks._join_reasons(
480
+ reason if isinstance(reason, str) else None,
481
+ "install failed: " + "; ".join(failures),
482
+ )
483
+ return _result(str(after["status"]), reason, changed, **fields)
484
+
485
+
486
+ # --------------------------------------------------------------------------- #
487
+ # mcp
488
+ # --------------------------------------------------------------------------- #
489
+
490
+
491
+ def _mcp_script(plugin_root: Path, name: str) -> Path:
492
+ """Return the registered server script for *name* under the plugin root."""
493
+ tools = plugin_root / ".claude" / "mcp-tools"
494
+ if name == "gitlab":
495
+ return tools / "server.py"
496
+ return tools / name / "server.py"
497
+
498
+
499
+ def ensure_mcp() -> dict[str, Any]:
500
+ """Re-register both MCP servers and write the version stamp only on success.
501
+
502
+ A rejected plugin root or an unusable interpreter is reported as
503
+ ``unverifiable`` and never registered -- the same closed set of states
504
+ ``check.sh`` §3 uses. The stamp is the idempotency latch, so it is written
505
+ only after *both* ``claude mcp add`` calls returned 0; one failure leaves
506
+ the old stamp in place and the next run retries.
507
+ """
508
+ before = checks.probe_mcp(checks.load_host_context())
509
+ fields = _section_fields(before)
510
+ changed: list[str] = []
511
+ if before["status"] == "unverifiable":
512
+ return _result("unverifiable", before.get("reason"), changed, **fields)
513
+ if before["status"] == "ok":
514
+ return _result("ok", None, changed, **fields)
515
+
516
+ plugin_root = before.get("plugin_root")
517
+ if not isinstance(plugin_root, str) or not plugin_root:
518
+ return _result(
519
+ "unverifiable",
520
+ before.get("reason") or "the wlcc plugin root is unavailable",
521
+ changed,
522
+ **fields,
523
+ )
524
+ python = before.get("python")
525
+ python_path = python.get("path") if isinstance(python, dict) else None
526
+ python_state = python.get("state") if isinstance(python, dict) else None
527
+ if not isinstance(python_path, str) or not str(python_state).startswith(
528
+ ("supplied:", "ok:")
529
+ ):
530
+ return _result(
531
+ "unverifiable",
532
+ f"MCP interpreter is not usable: {python_state}",
533
+ changed,
534
+ **fields,
535
+ )
536
+
537
+ claude = shutil.which("claude")
538
+ if claude is None:
539
+ return _result(
540
+ "unverifiable", "the claude CLI is not on PATH", changed, **fields
541
+ )
542
+
543
+ failures: list[str] = []
544
+ root = Path(plugin_root)
545
+ for name in checks.MCP_SERVERS:
546
+ script = str(_mcp_script(root, name))
547
+ try:
548
+ _run([claude, "mcp", "remove", name])
549
+ proc = _run(
550
+ [
551
+ claude,
552
+ "mcp",
553
+ "add",
554
+ name,
555
+ *MCP_SERVER_EXTRA[name],
556
+ "-s",
557
+ "user",
558
+ "--",
559
+ python_path,
560
+ script,
561
+ ]
562
+ )
563
+ except (OSError, subprocess.SubprocessError) as exc:
564
+ failures.append(f"{name}: {exc}")
565
+ continue
566
+ if proc.returncode == 0:
567
+ changed.append(f"mcp: registered {name} -> {script}")
568
+ else:
569
+ failures.append(f"{name}: {_failure(proc)}")
570
+
571
+ if failures:
572
+ fields["failed"] = failures
573
+ failed_names = ", ".join(item.split(":", 1)[0] for item in failures)
574
+ return _result(
575
+ "dangling",
576
+ f"could not register {failed_names}",
577
+ changed,
578
+ **fields,
579
+ )
580
+
581
+ version = str(before.get("plugin_version") or "unknown")
582
+ stamp = checks.state_root() / "mcp-registered-version"
583
+ try:
584
+ _write_stamp(stamp, version)
585
+ except WriteError as exc:
586
+ return _result(
587
+ "unverifiable",
588
+ f"registered both servers but could not write {stamp}: {exc}",
589
+ changed,
590
+ **fields,
591
+ )
592
+ changed.append(f"mcp: wrote stamp {version}")
593
+
594
+ after = checks.probe_mcp(checks.load_host_context())
595
+ fields.update(_section_fields(after))
596
+ if after["status"] == "ok":
597
+ return _result("repaired", None, changed, **fields)
598
+ return _result(
599
+ str(after["status"]),
600
+ after.get("reason") or "the MCP stamp did not verify after registration",
601
+ changed,
602
+ **fields,
603
+ )
604
+
605
+
606
+ # --------------------------------------------------------------------------- #
607
+ # settings: gstack + claude-mem
608
+ # --------------------------------------------------------------------------- #
609
+
610
+
611
+ def _ensure_gstack() -> dict[str, Any]:
612
+ """Provision the pinned gstack skill; a half clone is cleaned and retried."""
613
+ host = checks.load_host_context()
614
+ path = host.config_root / "skills" / "gstack"
615
+ fields: dict[str, Any] = {"path": str(path), "pin": checks.GSTACK_PIN}
616
+ probe = checks._probe_gstack(host)
617
+ if probe["status"] == "ok":
618
+ return _result("ok", None, [], **fields)
619
+
620
+ changed: list[str] = []
621
+ if (path / ".git").exists():
622
+ try:
623
+ shutil.rmtree(path)
624
+ except OSError as exc:
625
+ return _result(
626
+ "unverifiable",
627
+ f"cannot remove the half-finished gstack clone {path}: {exc}",
628
+ changed,
629
+ **fields,
630
+ )
631
+ changed.append("settings: removed a half-finished gstack clone")
632
+
633
+ if not path.exists():
634
+ git = shutil.which("git")
635
+ if git is None:
636
+ return _result(
637
+ "dangling",
638
+ f"gstack is not provisioned at {path} and git is not on PATH",
639
+ changed,
640
+ **fields,
641
+ )
642
+ failures = _provision_gstack(git, path)
643
+ if failures:
644
+ shutil.rmtree(path, ignore_errors=True)
645
+ return _result(
646
+ "dangling",
647
+ "gstack provisioning failed: " + "; ".join(failures),
648
+ changed,
649
+ **fields,
650
+ )
651
+ changed.append(f"settings: provisioned gstack at {path} ({checks.GSTACK_PIN})")
652
+
653
+ after = checks._probe_gstack(checks.load_host_context())
654
+ fields["setup_executable"] = after.get("setup_executable")
655
+ if changed and after["status"] == "ok":
656
+ return _result("repaired", None, changed, **fields)
657
+ return _result(str(after["status"]), after.get("reason"), changed, **fields)
658
+
659
+
660
+ def _provision_gstack(git: str, path: Path) -> list[str]:
661
+ """Clone, pin and ``./setup`` gstack; return a list of readable failures."""
662
+ steps: tuple[tuple[list[str], str | None, int], ...] = (
663
+ ([git, "clone", GSTACK_REPO, str(path)], None, _NETWORK_TIMEOUT),
664
+ ([git, "checkout", "--quiet", checks.GSTACK_PIN], str(path), 120),
665
+ ([str(path / "setup")], str(path), _NETWORK_TIMEOUT),
666
+ )
667
+ for argv, cwd, timeout in steps:
668
+ try:
669
+ proc = _run(argv, cwd=cwd, timeout=timeout)
670
+ except (OSError, subprocess.SubprocessError) as exc:
671
+ return [f"{argv[0]}: {exc}"]
672
+ if proc.returncode != 0:
673
+ return [f"{argv[0]}: {_failure(proc)}"]
674
+ setup = path / "setup"
675
+ if not setup.is_file() or not os.access(setup, os.X_OK):
676
+ return [f"{setup} is not executable after setup"]
677
+ return []
678
+
679
+
680
+ def _ensure_claude_mem() -> dict[str, Any]:
681
+ """Write claude-mem's settings from the wlcc template only when absent.
682
+
683
+ The template is wlcc's own file under the trusted plugin root, and its
684
+ placeholders are substituted exactly as ``check.sh`` §6 does. An existing
685
+ file is never rewritten -- a differing model is a manual-merge state.
686
+ """
687
+ host = checks.load_host_context()
688
+ path = host.claude_mem_settings
689
+ fields: dict[str, Any] = {"path": str(path)}
690
+ if path.exists():
691
+ probe = checks._probe_claude_mem(host)
692
+ return _result(
693
+ str(probe["status"]), probe.get("reason"), [], **_section_fields(probe)
694
+ )
695
+
696
+ root, root_error = checks._locate_wlcc_root(host)
697
+ if root is None:
698
+ return _result("unverifiable", root_error, [], **fields)
699
+ template = root / "skills" / "wl-env" / "templates" / "claude-mem-settings.json"
700
+ if not template.is_file():
701
+ return _result(
702
+ "unverifiable",
703
+ f"claude-mem template is missing: {template}",
704
+ [],
705
+ **fields,
706
+ )
707
+ try:
708
+ text = template.read_text(encoding="utf-8")
709
+ except OSError as exc:
710
+ return _result("unverifiable", f"cannot read {template}: {exc}", [], **fields)
711
+
712
+ data_dir = Path(os.path.expanduser("~/.claude-mem"))
713
+ text = text.replace("__CLAUDE_MEM_DATA_DIR__", str(data_dir))
714
+ text = text.replace(
715
+ "__CLAUDE_MEM_TRANSCRIPTS_PATH__", str(data_dir / "transcript-watch.json")
716
+ )
717
+ if "__CLAUDE_MEM_" in text:
718
+ return _result(
719
+ "unverifiable",
720
+ f"unresolved template placeholder remains in {template}; nothing written",
721
+ [],
722
+ **fields,
723
+ )
724
+ try:
725
+ _atomic_write_text(path, text)
726
+ except WriteError as exc:
727
+ return _result("unverifiable", str(exc), [], **fields)
728
+
729
+ changed = [f"settings: applied the claude-mem template to {path}"]
730
+ after = checks._probe_claude_mem(checks.load_host_context())
731
+ fields.update(_section_fields(after))
732
+ if after["status"] == "ok":
733
+ return _result("repaired", None, changed, **fields)
734
+ return _result(str(after["status"]), after.get("reason"), changed, **fields)
735
+
736
+
737
+ def ensure_settings() -> dict[str, Any]:
738
+ """§5 + §6 as one section: gstack and claude-mem, each isolated."""
739
+ subchecks: dict[str, dict[str, Any]] = {
740
+ "gstack": _isolated("gstack", _ensure_gstack),
741
+ "claude_mem": _isolated("claude_mem", _ensure_claude_mem),
742
+ }
743
+ status = checks._aggregate(
744
+ [str(subcheck["status"]) for subcheck in subchecks.values()]
745
+ )
746
+ changed = [
747
+ item for subcheck in subchecks.values() for item in subcheck.get("changed", [])
748
+ ]
749
+ reason: str | None = None
750
+ if status != "ok":
751
+ reason = checks._join_reasons(
752
+ *[
753
+ f"{name}: {subcheck.get('reason')}"
754
+ for name, subcheck in subchecks.items()
755
+ if subcheck["status"] == status and subcheck.get("reason")
756
+ ]
757
+ )
758
+ return _result(status, reason, changed, **subchecks)
759
+
760
+
761
+ # --------------------------------------------------------------------------- #
762
+ # statusline
763
+ # --------------------------------------------------------------------------- #
764
+
765
+
766
+ def _runtime() -> tuple[str | None, str | None]:
767
+ """Return ``(name, absolute path)`` for bun or node, preferring bun."""
768
+ for name, _source in _HUD_RUNTIMES:
769
+ path = shutil.which(name)
770
+ if path:
771
+ return name, path
772
+ return None, None
773
+
774
+
775
+ def _hud_command(runtime_name: str, runtime_path: str) -> str:
776
+ """Render the self-contained claude-hud command for *runtime_name*."""
777
+ source = next(source for name, source in _HUD_RUNTIMES if name == runtime_name)
778
+ flag = "--env-file /dev/null" if runtime_name == "bun" else ""
779
+ return (
780
+ _HUD_TEMPLATE.replace("__RUNTIME__", runtime_path)
781
+ .replace("__FLAG__", flag)
782
+ .replace("__SOURCE__", source)
783
+ )
784
+
785
+
786
+ def write_statusline() -> dict[str, Any]:
787
+ """Write the claude-hud statusLine; never overwrite another statusline.
788
+
789
+ ``conflict`` and invalid JSON are terminal states for this mutator: the
790
+ file is left byte-identical. ``missing_plugin`` is write-free as well: the
791
+ existing command already names claude-hud (that is how the probe
792
+ classified it), wlcc's rule is to skip a statusLine that already contains
793
+ ``claude-hud``, and rewriting the same command on every run would churn a
794
+ backup and report a change forever. Only the plugin itself is missing, so
795
+ the probe's ``dangling`` status is reported unchanged.
796
+
797
+ When a write *is* needed, the command merges into the existing
798
+ ``statusLine`` object instead of replacing it, so keys a user added there
799
+ survive.
800
+ """
801
+ host = checks.load_host_context()
802
+ before = checks.probe_statusline(host)
803
+ fields = _section_fields(before)
804
+ changed: list[str] = []
805
+ if before["status"] == "unverifiable":
806
+ return _result("unverifiable", before.get("reason"), changed, **fields)
807
+ action = before.get("action")
808
+ if action == "ok":
809
+ return _result("ok", None, changed, **fields)
810
+ if action == "conflict":
811
+ return _result("dangling", before.get("reason"), changed, **fields)
812
+ if action == "missing_plugin":
813
+ # Nothing to write: the command already contains claude-hud.
814
+ return _result("dangling", before.get("reason"), changed, **fields)
815
+
816
+ runtime_name, runtime_path = _runtime()
817
+ if runtime_name is None or runtime_path is None:
818
+ return _result(
819
+ "dangling",
820
+ "no bun/node runtime is on PATH; the claude-hud command was not written",
821
+ changed,
822
+ **fields,
823
+ )
824
+ if "'" in runtime_path:
825
+ return _result(
826
+ "dangling",
827
+ "runtime path contains a single quote, refusing to build the command: "
828
+ f"{runtime_path}",
829
+ changed,
830
+ **fields,
831
+ )
832
+ # The path lands inside a double-quoted word of the generated command, so
833
+ # these characters would be re-interpreted by the inner shell at runtime.
834
+ if any(char in runtime_path for char in ('"', "$", "`", "\n")):
835
+ return _result(
836
+ "dangling",
837
+ "runtime path contains a shell-unsafe character, refusing to build "
838
+ f"the command: {runtime_path}",
839
+ changed,
840
+ **fields,
841
+ )
842
+
843
+ settings = host.settings if isinstance(host.settings, dict) else {}
844
+ planned = dict(settings)
845
+ existing = settings.get("statusLine")
846
+ status_line = dict(existing) if isinstance(existing, dict) else {}
847
+ status_line["type"] = "command"
848
+ status_line["command"] = _hud_command(runtime_name, runtime_path)
849
+ planned["statusLine"] = status_line
850
+ if planned == settings:
851
+ # Semantically identical already (e.g. a previous run wrote exactly
852
+ # this command): never create a backup or report a change for a no-op.
853
+ return _result(str(before["status"]), before.get("reason"), changed, **fields)
854
+ try:
855
+ backup = _atomic_write_json(
856
+ host.config_root / "settings.json", planned, preserve_except="statusLine"
857
+ )
858
+ except WriteError as exc:
859
+ return _result("unverifiable", str(exc), changed, **fields)
860
+ changed.append(f"statusline: wrote the claude-hud command via {runtime_name}")
861
+ if backup is not None:
862
+ fields["backup"] = backup
863
+
864
+ after = checks.probe_statusline(checks.load_host_context())
865
+ fields.update(_section_fields(after))
866
+ if after["status"] == "ok":
867
+ return _result("repaired", None, changed, **fields)
868
+ return _result(str(after["status"]), after.get("reason"), changed, **fields)
869
+
870
+
871
+ # --------------------------------------------------------------------------- #
872
+ # hooks
873
+ # --------------------------------------------------------------------------- #
874
+
875
+
876
+ def _rewritten_command(command: str, root: str) -> str:
877
+ """Apply hooks-drift's rewrite rule to one command string."""
878
+ updated = command
879
+ for kind, token, _reason in checks._scan_tokens(command):
880
+ if kind != "dangling":
881
+ continue
882
+ target = checks._rewrite_path(token, root)
883
+ if target is not None and os.path.exists(target):
884
+ updated = updated.replace(token, target)
885
+ return updated
886
+
887
+
888
+ def _plan_hook_group(
889
+ event: str, group: Any, root: str, entries: list[dict[str, Any]]
890
+ ) -> Any | None:
891
+ """Plan one matcher group; return the kept group, or None when it emptied."""
892
+ if not isinstance(group, dict):
893
+ return group
894
+ hook_list = group.get("hooks")
895
+ if not isinstance(hook_list, list):
896
+ return group
897
+ matcher = group.get("matcher") if isinstance(group.get("matcher"), str) else None
898
+ kept_hooks: list[Any] = []
899
+ for hook in hook_list:
900
+ command = hook.get("command") if isinstance(hook, dict) else None
901
+ if not isinstance(command, str):
902
+ kept_hooks.append(hook)
903
+ continue
904
+ action, hook_entries = checks._plan_hook_command(command, root, event, matcher)
905
+ entries.extend(hook_entries)
906
+ if action == "prune":
907
+ continue
908
+ if action == "rewrite":
909
+ hook = dict(hook)
910
+ hook["command"] = _rewritten_command(command, root)
911
+ kept_hooks.append(hook)
912
+ if not kept_hooks:
913
+ return None
914
+ if kept_hooks != hook_list:
915
+ group = dict(group)
916
+ group["hooks"] = kept_hooks
917
+ return group
918
+
919
+
920
+ def _plan_hook_settings(
921
+ settings: dict[str, Any], root: str
922
+ ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
923
+ """Port of hooks-drift's ``plan_settings`` on top of the #222 planner.
924
+
925
+ Rewrite wins over prune: as long as one path in a command can be saved, the
926
+ command is kept. Empty matchers and empty event keys are reclaimed.
927
+ """
928
+ planned = json.loads(json.dumps(settings))
929
+ entries: list[dict[str, Any]] = []
930
+ hooks = planned.get("hooks")
931
+ if not isinstance(hooks, dict):
932
+ return planned, entries
933
+
934
+ for event in list(hooks):
935
+ groups = hooks.get(event)
936
+ if not isinstance(groups, list):
937
+ continue
938
+ kept_groups = [
939
+ kept
940
+ for kept in (
941
+ _plan_hook_group(str(event), group, root, entries) for group in groups
942
+ )
943
+ if kept is not None
944
+ ]
945
+ if kept_groups:
946
+ hooks[event] = kept_groups
947
+ elif groups:
948
+ del hooks[event]
949
+ return planned, entries
950
+
951
+
952
+ def _hook_changes(entries: Iterable[dict[str, Any]]) -> list[str]:
953
+ """Turn the plan entries into readable ``changed`` actions."""
954
+ changed: list[str] = []
955
+ for entry in entries:
956
+ if entry.get("action") == "rewrite":
957
+ changed.append(f"hooks: rewrote {entry.get('from')} -> {entry.get('to')}")
958
+ elif entry.get("action") == "prune":
959
+ changed.append(f"hooks: pruned {entry.get('from')}")
960
+ return changed
961
+
962
+
963
+ def repair_hooks() -> dict[str, Any]:
964
+ """Rewrite or prune dangling hook commands with backup + atomic replace.
965
+
966
+ The plan is the same :func:`wl_cli.cc.checks.probe_hooks` plan the report
967
+ shows; this mutator only materialises it. A plan that is identical to the
968
+ file (every entry ``skip``) is ``dangling`` with no write -- reporting
969
+ ``repaired`` there would lie.
970
+ """
971
+ host = checks.load_host_context()
972
+ before = checks.probe_hooks(host)
973
+ fields = _section_fields(before)
974
+ changed: list[str] = []
975
+ if before["status"] == "unverifiable":
976
+ return _result("unverifiable", before.get("reason"), changed, **fields)
977
+ if not before.get("entries"):
978
+ return _result("ok", None, changed, **fields)
979
+
980
+ settings_path = host.config_root / "settings.json"
981
+ try:
982
+ original = _read_settings(settings_path)
983
+ except WriteError as exc:
984
+ return _result("error", str(exc), changed, **fields)
985
+ planned, entries = _plan_hook_settings(original, str(host.config_root))
986
+ if planned == original:
987
+ return _result("dangling", before.get("reason"), changed, **fields)
988
+
989
+ try:
990
+ backup = _atomic_write_json(settings_path, planned, preserve_except="hooks")
991
+ except WriteError as exc:
992
+ return _result("error", str(exc), changed, **fields)
993
+ changed = _hook_changes(entries)
994
+ if backup is not None:
995
+ fields["backup"] = backup
996
+
997
+ after = checks.probe_hooks(checks.load_host_context())
998
+ fields["wrote"] = True
999
+ fields["after_status"] = after["status"]
1000
+ if not after.get("entries"):
1001
+ return _result("repaired", None, changed, **fields)
1002
+ return _result(str(after["status"]), after.get("reason"), changed, **fields)
1003
+
1004
+
1005
+ # --------------------------------------------------------------------------- #
1006
+ # stamps
1007
+ # --------------------------------------------------------------------------- #
1008
+
1009
+
1010
+ def prune_stamps() -> dict[str, Any]:
1011
+ """Delete only retired stamps; report unregistered entries and keep them.
1012
+
1013
+ This is check.sh §8's rule verbatim: the retired list is closed, the
1014
+ ``visionary-cli`` skill copy is removed only when it carries its
1015
+ ``.wlcc-version`` marker, and everything else is an orphan reported in the
1016
+ payload -- ``~/.config/wlcc`` belongs to the user.
1017
+ """
1018
+ host = checks.load_host_context()
1019
+ changed: list[str] = []
1020
+ failures: list[str] = []
1021
+
1022
+ for name in sorted(checks.RETIRED_STAMPS):
1023
+ path = host.state_root / name
1024
+ if not path.exists():
1025
+ continue
1026
+ try:
1027
+ path.unlink()
1028
+ except OSError as exc:
1029
+ failures.append(f"cannot remove {path}: {exc}")
1030
+ continue
1031
+ changed.append(f"stamps: removed retired stamp {path}")
1032
+
1033
+ # check.sh §8 attempts this unconditionally: the retired deepseek stack
1034
+ # registered the server from this repo, so a leftover registration is
1035
+ # cleaned here too. `claude mcp remove` is idempotent (non-zero when the
1036
+ # server is absent) and there is no scope-independent read-only query for
1037
+ # it, so the call stays unconditional and only a zero exit counts as a
1038
+ # change.
1039
+ claude = shutil.which("claude")
1040
+ if claude is not None:
1041
+ try:
1042
+ proc = _run([claude, "mcp", "remove", "deepseek-visionary"])
1043
+ except (OSError, subprocess.SubprocessError) as exc:
1044
+ failures.append(f"claude mcp remove deepseek-visionary: {exc}")
1045
+ else:
1046
+ if proc.returncode == 0:
1047
+ changed.append(
1048
+ "stamps: removed the retired deepseek-visionary MCP registration"
1049
+ )
1050
+
1051
+ skill = host.config_root / "skills" / "visionary-cli"
1052
+ if (skill / ".wlcc-version").exists():
1053
+ try:
1054
+ shutil.rmtree(skill)
1055
+ except OSError as exc:
1056
+ failures.append(f"cannot remove {skill}: {exc}")
1057
+ else:
1058
+ changed.append(f"stamps: removed the retired skill copy {skill}")
1059
+
1060
+ after = checks.probe_stamps(checks.load_host_context())
1061
+ fields = _section_fields(after)
1062
+ if failures:
1063
+ fields["failed"] = failures
1064
+ reason = after.get("reason")
1065
+ if failures:
1066
+ reason = checks._join_reasons(
1067
+ reason if isinstance(reason, str) else None, "; ".join(failures)
1068
+ )
1069
+ if after["status"] == "ok":
1070
+ return _result("repaired" if changed else "ok", None, changed, **fields)
1071
+ return _result(str(after["status"]), reason, changed, **fields)
1072
+
1073
+
1074
+ # --------------------------------------------------------------------------- #
1075
+ # upgrade
1076
+ # --------------------------------------------------------------------------- #
1077
+
1078
+
1079
+ #: Marker from the ``/wl-upgrade`` contract: the ``wlcc@wlcc`` array has no
1080
+ #: ``scope: user`` entry, so the plugin is treated as unprovisioned and the
1081
+ #: version cannot be verified (never "already latest").
1082
+ NO_USER_SCOPE_ENTRY = "NO_USER_SCOPE_ENTRY"
1083
+
1084
+
1085
+ def _wlcc_user_version(host: checks.HostContext) -> str | None:
1086
+ """Return the ``scope: user`` version of ``wlcc@wlcc``, or ``None``.
1087
+
1088
+ ``installed_plugins.json``'s ``wlcc@wlcc`` array is authoritative, and only
1089
+ its ``scope: user`` element is the one the MCP registration follows
1090
+ (wlcc stamps.md, ``/wl-upgrade`` §1). The cache-directory maximum is *not*
1091
+ a fallback: a downgrade or a leftover directory can make it disagree with
1092
+ the registered version, so it is reported only as a diagnostic.
1093
+ """
1094
+ entry = (host.installed or {}).get("wlcc@wlcc")
1095
+ if isinstance(entry, list):
1096
+ for item in entry:
1097
+ if isinstance(item, dict) and item.get("scope") == "user":
1098
+ version = item.get("version")
1099
+ if isinstance(version, str) and version:
1100
+ return version
1101
+ return None
1102
+
1103
+
1104
+ def _cached_wlcc_version(host: checks.HostContext) -> str | None:
1105
+ """Return the largest plugin-cache version directory (diagnostic only)."""
1106
+ try:
1107
+ return checks._max_version_dir(checks._wlcc_cache(host))
1108
+ except OSError:
1109
+ return None
1110
+
1111
+
1112
+ def upgrade_wlcc() -> dict[str, Any]:
1113
+ """Upgrade the wlcc Claude Code plugin (``/wl-upgrade``'s two steps).
1114
+
1115
+ This upgrades the *plugin*, not the ``wl`` binary: ``wl self update`` /
1116
+ ``wl env repair`` own the CLI, and the two must never be confused. The
1117
+ marketplace is refreshed when registered and added when it is not.
1118
+
1119
+ A registry without a ``scope: user`` ``wlcc@wlcc`` entry is
1120
+ ``unverifiable`` (``NO_USER_SCOPE_ENTRY``): the plugin is not provisioned
1121
+ for this user, so "already latest" would be a guess. The plugin cache is
1122
+ reported as ``cache_version`` but never decides the verdict.
1123
+ """
1124
+ host = checks.load_host_context()
1125
+ before = _wlcc_user_version(host)
1126
+ fields: dict[str, Any] = {
1127
+ "before": before,
1128
+ "after": None,
1129
+ "version_source": "installed_plugins.json:user" if before else None,
1130
+ "cache_version": _cached_wlcc_version(host),
1131
+ "requires_restart": False,
1132
+ }
1133
+ changed: list[str] = []
1134
+ if before is None:
1135
+ return _result(
1136
+ "unverifiable",
1137
+ f"{NO_USER_SCOPE_ENTRY}: installed_plugins.json has no wlcc@wlcc "
1138
+ "entry with scope 'user'; the plugin is not provisioned for this "
1139
+ "user, so its version cannot be verified",
1140
+ changed,
1141
+ **fields,
1142
+ )
1143
+
1144
+ claude = shutil.which("claude")
1145
+ if claude is None:
1146
+ return _result(
1147
+ "unverifiable", "the claude CLI is not on PATH", changed, **fields
1148
+ )
1149
+ try:
1150
+ listed = _run([claude, "plugin", "marketplace", "list"])
1151
+ except (OSError, subprocess.SubprocessError) as exc:
1152
+ return _result(
1153
+ "unverifiable",
1154
+ f"claude plugin marketplace list could not run: {exc}",
1155
+ changed,
1156
+ **fields,
1157
+ )
1158
+ if listed.returncode != 0:
1159
+ return _result(
1160
+ "unverifiable",
1161
+ f"claude plugin marketplace list {_failure(listed)}",
1162
+ changed,
1163
+ **fields,
1164
+ )
1165
+
1166
+ if "wlcc" in listed.stdout:
1167
+ marketplace_argv = [claude, "plugin", "marketplace", "update", "wlcc"]
1168
+ marketplace_action = "updated"
1169
+ else:
1170
+ marketplace_argv = [
1171
+ claude,
1172
+ "plugin",
1173
+ "marketplace",
1174
+ "add",
1175
+ WLCC_MARKETPLACE_REPO,
1176
+ ]
1177
+ marketplace_action = "added"
1178
+ fields["marketplace_action"] = marketplace_action
1179
+ try:
1180
+ refreshed = _run(marketplace_argv, timeout=_NETWORK_TIMEOUT)
1181
+ except (OSError, subprocess.SubprocessError) as exc:
1182
+ return _result(
1183
+ "unverifiable",
1184
+ f"claude plugin marketplace {marketplace_action} could not run: {exc}",
1185
+ changed,
1186
+ **fields,
1187
+ )
1188
+ if refreshed.returncode != 0:
1189
+ return _result(
1190
+ "unverifiable",
1191
+ f"claude plugin marketplace {marketplace_action} {_failure(refreshed)}",
1192
+ changed,
1193
+ **fields,
1194
+ )
1195
+
1196
+ try:
1197
+ updated = _run(
1198
+ [claude, "plugin", "update", "wlcc@wlcc", "-s", "user"],
1199
+ timeout=_NETWORK_TIMEOUT,
1200
+ )
1201
+ except (OSError, subprocess.SubprocessError) as exc:
1202
+ return _result(
1203
+ "unverifiable",
1204
+ f"claude plugin update wlcc@wlcc could not run: {exc}",
1205
+ changed,
1206
+ **fields,
1207
+ )
1208
+ if updated.returncode != 0:
1209
+ return _result(
1210
+ "unverifiable",
1211
+ f"claude plugin update wlcc@wlcc {_failure(updated)}",
1212
+ changed,
1213
+ **fields,
1214
+ )
1215
+
1216
+ fresh = checks.load_host_context()
1217
+ after = _wlcc_user_version(fresh)
1218
+ fields.update(after=after, cache_version=_cached_wlcc_version(fresh))
1219
+ if after is None:
1220
+ return _result(
1221
+ "unverifiable",
1222
+ f"{NO_USER_SCOPE_ENTRY}: `claude plugin update wlcc@wlcc -s user` "
1223
+ "succeeded but installed_plugins.json still has no user-scope "
1224
+ "entry; the resulting version cannot be verified",
1225
+ changed,
1226
+ **fields,
1227
+ )
1228
+ fields["requires_restart"] = before != after
1229
+ if before != after:
1230
+ changed.append(f"upgrade: wlcc {before or 'unknown'} -> {after or 'unknown'}")
1231
+ return _result("repaired", None, changed, **fields)
1232
+ return _result("ok", None, changed, **fields)
1233
+
1234
+
1235
+ # --------------------------------------------------------------------------- #
1236
+ # init-done stamp
1237
+ # --------------------------------------------------------------------------- #
1238
+
1239
+
1240
+ def write_init_done() -> dict[str, Any]:
1241
+ """Write ``<plugin version> <YYYY-MM-DD>`` when the content actually differs.
1242
+
1243
+ ``wl-env-init`` §5 uses the largest cache directory as the version source
1244
+ (not ``VERSION``). Rewriting an identical stamp would make every ``init``
1245
+ look like a change, so an already-current file is reported ``ok``.
1246
+ """
1247
+ host = checks.load_host_context()
1248
+ fields: dict[str, Any] = {}
1249
+ try:
1250
+ version = checks._max_version_dir(checks._wlcc_cache(host))
1251
+ except OSError as exc:
1252
+ return _result("unverifiable", f"cannot read the wlcc plugin cache: {exc}")
1253
+ content = f"{version or 'unknown'} {datetime.date.today().isoformat()}"
1254
+ path = checks.state_root() / "init-done"
1255
+ fields.update(path=str(path), content=content)
1256
+ try:
1257
+ existing = path.read_text(encoding="utf-8").strip()
1258
+ except FileNotFoundError:
1259
+ existing = None
1260
+ except OSError as exc:
1261
+ return _result("unverifiable", f"cannot read {path}: {exc}", **fields)
1262
+ if existing == content:
1263
+ return _result("ok", None, **fields)
1264
+ try:
1265
+ _write_stamp(path, content)
1266
+ except WriteError as exc:
1267
+ return _result("unverifiable", str(exc), **fields)
1268
+ return _result("repaired", None, [f"init-done: wrote {content}"], **fields)
1269
+
1270
+
1271
+ # --------------------------------------------------------------------------- #
1272
+ # Orchestration
1273
+ # --------------------------------------------------------------------------- #
1274
+
1275
+ #: One action per section; ``env`` is report-only on purpose (see module doc).
1276
+ _ACTIONS: dict[str, Callable[[], dict[str, Any]]] = {
1277
+ "env": _probe_env,
1278
+ "plugins": ensure_plugins,
1279
+ "mcp": ensure_mcp,
1280
+ "settings": ensure_settings,
1281
+ "statusline": write_statusline,
1282
+ "hooks": repair_hooks,
1283
+ "stamps": prune_stamps,
1284
+ }
1285
+
1286
+
1287
+ def run_repair(
1288
+ selected: Iterable[str] | None = None,
1289
+ *,
1290
+ dry_run: bool = False,
1291
+ ) -> tuple[str, str | None, dict[str, Any]]:
1292
+ """Run the selected sections and aggregate them into one envelope payload.
1293
+
1294
+ ``env`` is always reported (it answers "is the host ready" as a whole) but
1295
+ is never mutated. ``--dry-run`` reports the same probes without touching a
1296
+ file. Each section is isolated: one raising mutator becomes
1297
+ ``unverifiable`` and the others still run.
1298
+ """
1299
+ chosen = set(MUTABLE_SECTIONS if selected is None else selected)
1300
+ names = ["env", *(name for name in MUTABLE_SECTIONS if name in chosen)]
1301
+ sections: dict[str, dict[str, Any]] = {}
1302
+ for name in names:
1303
+ action = (
1304
+ partial(probe_section, name, dry_run=True) if dry_run else _ACTIONS[name]
1305
+ )
1306
+ sections[name] = _isolated(name, action)
1307
+
1308
+ status = checks._aggregate(
1309
+ [str(section["status"]) for section in sections.values()]
1310
+ )
1311
+ reason = checks._reason_for(sections, status)
1312
+ changed = [
1313
+ item for section in sections.values() for item in section.get("changed", [])
1314
+ ]
1315
+ return status, reason, {"dry_run": dry_run, "changed": changed, **sections}
1316
+
1317
+
1318
+ def run_init(*, dry_run: bool = False) -> tuple[str, str | None, dict[str, Any]]:
1319
+ """Provision every section, then write the ``init-done`` stamp.
1320
+
1321
+ The stamp is skipped when any section is ``error`` or ``unverifiable``: an
1322
+ init that could not establish its own probe must not leave a marker saying
1323
+ it did. Known problems (``dangling``, e.g. a deliberately disabled plugin)
1324
+ do not block the stamp -- they are reported in the payload.
1325
+ """
1326
+ status, reason, fields = run_repair(None, dry_run=dry_run)
1327
+ sections = {name: fields[name] for name in SECTION_ORDER}
1328
+ if dry_run:
1329
+ init_done = _result("dangling", "dry run: init-done was not written")
1330
+ return status, reason, {**fields, "init_done": init_done}
1331
+
1332
+ blocking = [
1333
+ name
1334
+ for name, section in sections.items()
1335
+ if section["status"] in ("error", "unverifiable")
1336
+ ]
1337
+ if blocking:
1338
+ init_done = _result(
1339
+ "dangling",
1340
+ "init-done was not written; sections not established: "
1341
+ + ", ".join(blocking),
1342
+ )
1343
+ else:
1344
+ init_done = _isolated("init_done", write_init_done)
1345
+
1346
+ final_status = checks._aggregate([status, str(init_done["status"])])
1347
+ if final_status != status and isinstance(init_done.get("reason"), str):
1348
+ reason = checks._join_reasons(reason, f"init-done: {init_done['reason']}")
1349
+ changed = [*fields["changed"], *init_done.get("changed", [])]
1350
+ return final_status, reason, {**fields, "changed": changed, "init_done": init_done}