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/checks.py ADDED
@@ -0,0 +1,1019 @@
1
+ """Read-only Claude Code host checks for ``wl cc check`` (issue #222).
2
+
3
+ ``wl cc check --json`` answers "is this machine's Claude Code setup in the
4
+ state wlcc expects?" and writes nothing. The envelope is the shared five-state
5
+ one (:mod:`wl_cli.envelope`); the nested ``env`` field is produced by
6
+ :func:`wl_cli.env_status.build_payload` in this same process -- never by
7
+ shelling out to ``wl``.
8
+
9
+ The six host sections mirror the read-only half of wlcc's
10
+ ``skills/wl-env/scripts/check.sh`` plus the statusline step of
11
+ ``wl-env-init``. The mapping is deliberate, so the parenthetical § numbers in
12
+ the ticket cannot drift from the implementation:
13
+
14
+ =========== ============================================================
15
+ section wlcc source
16
+ =========== ============================================================
17
+ plugins §1 required plugins + §7 context-mode (optional plugin)
18
+ mcp §3 plugin root, version stamp and interpreter visibility
19
+ settings §5 gstack + §6 claude-mem (host-side configuration)
20
+ stamps §8 stamp registry (report only -- never delete)
21
+ hooks §9 dangling hook commands (read-only plan)
22
+ statusline wl-env-init §3 (claude-hud statusLine)
23
+ =========== ============================================================
24
+
25
+ Failure isolation is part of the contract: every section runs inside its own
26
+ ``try`` and a failure becomes ``status: unverifiable`` with a readable
27
+ ``reason`` -- it never aborts the report and never hides the other sections.
28
+ The top-level status aggregates ``error > unverifiable > dangling > ok`` (the
29
+ #222 ruling), and a sub-check inside ``settings`` is isolated the same way.
30
+ ``wl cc check`` never writes, so ``repaired`` cannot be a section state here.
31
+
32
+ State roots are deliberately the ones wlcc uses, not one normalised root (the
33
+ #222 ruling keeps the split):
34
+
35
+ * ``${CLAUDE_CONFIG_DIR:-$HOME/.claude}`` -- plugins, skills, ``settings.json``;
36
+ * ``$HOME/.config/wlcc`` -- wlcc stamps and ``mcp-registered-version``;
37
+ * ``$HOME/.claude-mem`` -- claude-mem settings.
38
+
39
+ The required plugin list mirrors the ``wl-required-plugins`` key block of
40
+ wlcc's ``references/stamps.md``, which stays the owner there; an installed
41
+ ``wl`` cannot read the wlcc plugin tree at runtime, so this module carries the
42
+ consumer-side copy. Keep both in sync when the wlcc list changes.
43
+
44
+ Read-only means read-only: the module performs no ``mkdir``/``write``/
45
+ ``replace``/``unlink`` anywhere, and ``uv python find`` (the one external
46
+ interpreter probe inherited from check.sh §3) only resolves a local path.
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ import json
52
+ import os
53
+ import re
54
+ import shutil
55
+ import subprocess
56
+ from dataclasses import dataclass
57
+ from pathlib import Path
58
+ from typing import TYPE_CHECKING, Any
59
+
60
+ from ..env_status import build_payload
61
+ from ..envelope import OK_STATUSES, dump, exit_code
62
+
63
+ if TYPE_CHECKING:
64
+ from collections.abc import Callable, Iterator
65
+
66
+ #: Stable tool name in the envelope's ``tool`` key.
67
+ TOOL = "cc check"
68
+
69
+ #: Required plugins -- the consumer-side copy of wlcc
70
+ #: ``references/stamps.md``'s ``wl-required-plugins`` key block (#226 there).
71
+ #: Entries are the full ``name@marketplace`` form, so a name cannot match a
72
+ #: plugin from the wrong marketplace.
73
+ REQUIRED_PLUGINS: tuple[str, ...] = (
74
+ "superpowers@claude-plugins-official",
75
+ "astral@astral-sh",
76
+ "ecc@ecc",
77
+ "claude-mem@thedotmack",
78
+ "mattpocock-skills@mattpocock",
79
+ "claude-hud@claude-hud",
80
+ )
81
+
82
+ #: §7 is an *optional* dependency: its absence is reported, but it never
83
+ #: gates the ``plugins`` section (check.sh only prompts, it never installs).
84
+ CONTEXT_MODE_PLUGIN = "context-mode@context-mode"
85
+
86
+ #: gstack pin from check.sh §5; the check only reports it, the clone belongs to
87
+ #: the repair/init half (#223).
88
+ GSTACK_PIN = "ad8400543cd9ce8d07641362db48d44a95417e33"
89
+
90
+ #: `~/.config/wlcc` stamps registered in wlcc ``references/stamps.md``.
91
+ KNOWN_STAMPS = frozenset(
92
+ {"init-done", "mcp-registered-version", "context-prompted", "token.enc"}
93
+ )
94
+
95
+ #: Retired stamps from the same registry: reported, never deleted here.
96
+ RETIRED_STAMPS = frozenset({"gemini-key.enc", "mcp-visionary-pending"})
97
+
98
+ #: MCP servers check.sh §3 registers.
99
+ MCP_SERVERS: tuple[str, ...] = ("gitlab", "k8s-diagnose")
100
+
101
+ #: Literal absolute path anchor from wlcc ``hooks-drift.py``: the dangling rule
102
+ #: is exactly ``*/.claude/*``. Kept with the trailing slash on purpose.
103
+ CLAUDE_SEG = "/.claude/"
104
+
105
+ #: A command token is anything between whitespace, quotes, redirection and
106
+ #: separators -- the same split wlcc's hooks-drift uses for scan and rewrite.
107
+ _TOKEN_RE = re.compile(r"[^\s'\"<>|;&()]+")
108
+
109
+ #: Command heads whose paths are not argv paths (package resolvers / JS eval).
110
+ _NEVER_TOUCH_HEAD = ("npx", "bunx")
111
+ _EVAL_HEAD = ("node", "nodejs", "bun", "deno")
112
+
113
+ #: A cache directory name is a version only when it is all numeric segments.
114
+ _VERSION_RE = re.compile(r"^\d+(?:\.\d+)*$")
115
+
116
+ #: Statuses a section probe may report. ``repaired`` is impossible for a
117
+ #: read-only command and is rejected rather than silently aggregated.
118
+ _SECTION_STATUSES = frozenset({"ok", "dangling", "unverifiable", "error"})
119
+
120
+ #: Aggregation priority from the #222 ruling.
121
+ _AGGREGATE_PRIORITY = ("error", "unverifiable", "dangling", "repaired", "ok")
122
+
123
+ #: Interpreter probe inherited from check.sh §3: exit 0 when >= 3.9, 7 when too
124
+ #: old, and always print ``X.Y.Z`` so the state string can name the version.
125
+ _VERSION_PROBE = (
126
+ "import sys; "
127
+ "sys.stdout.write('%d.%d.%d' % tuple(sys.version_info[:3])); "
128
+ "sys.exit(0 if sys.version_info >= (3, 9) else 7)"
129
+ )
130
+
131
+
132
+ @dataclass
133
+ class HostContext:
134
+ """Read-only host state shared by the sections.
135
+
136
+ ``settings`` / ``installed`` are ``None`` when the file exists but cannot
137
+ be read or parsed; a *missing* file yields an empty object because "not
138
+ configured yet" is a legitimate state, not a probe failure. The error
139
+ strings keep the reason readable without re-raising.
140
+ """
141
+
142
+ config_root: Path
143
+ state_root: Path
144
+ claude_mem_settings: Path
145
+ settings: dict[str, Any] | None
146
+ settings_error: str | None
147
+ installed: dict[str, Any] | None
148
+ installed_error: str | None
149
+
150
+ @property
151
+ def enabled_plugins(self) -> dict[str, bool]:
152
+ """The authoritative enable map (``settings.json#enabledPlugins``)."""
153
+ if self.settings is None:
154
+ return {}
155
+ raw = self.settings.get("enabledPlugins")
156
+ if not isinstance(raw, dict):
157
+ return {}
158
+ return {key: value for key, value in raw.items() if isinstance(value, bool)}
159
+
160
+
161
+ def config_root() -> Path:
162
+ """Return ``${CLAUDE_CONFIG_DIR:-$HOME/.claude}`` as an absolute path."""
163
+ raw = os.environ.get("CLAUDE_CONFIG_DIR") or "~/.claude"
164
+ return Path(os.path.abspath(os.path.expanduser(raw)))
165
+
166
+
167
+ def state_root() -> Path:
168
+ """Return wlcc's stamp directory (``$HOME/.config/wlcc``)."""
169
+ return Path(os.path.expanduser("~/.config/wlcc"))
170
+
171
+
172
+ def claude_mem_settings_path() -> Path:
173
+ """Return claude-mem's settings file (``$HOME/.claude-mem/settings.json``).
174
+
175
+ wlcc deliberately anchors this at ``$HOME`` rather than the Claude config
176
+ root; the #222 ruling keeps that split instead of normalising it.
177
+ """
178
+ return Path(os.path.expanduser("~/.claude-mem/settings.json"))
179
+
180
+
181
+ def load_host_context() -> HostContext:
182
+ """Read the shared host files once, turning failures into error strings."""
183
+ root = config_root()
184
+ settings, settings_error = _read_json_object(root / "settings.json")
185
+ installed, installed_error = _read_installed_plugins(
186
+ root / "plugins" / "installed_plugins.json"
187
+ )
188
+ return HostContext(
189
+ config_root=root,
190
+ state_root=state_root(),
191
+ claude_mem_settings=claude_mem_settings_path(),
192
+ settings=settings,
193
+ settings_error=settings_error,
194
+ installed=installed,
195
+ installed_error=installed_error,
196
+ )
197
+
198
+
199
+ def _read_json_object(path: Path) -> tuple[dict[str, Any] | None, str | None]:
200
+ """Return ``(object, error)``; a missing file reads as an empty object."""
201
+ try:
202
+ text = path.read_text(encoding="utf-8")
203
+ except FileNotFoundError:
204
+ return {}, None
205
+ except OSError as exc:
206
+ return None, f"cannot read {path}: {exc}"
207
+ try:
208
+ data = json.loads(text)
209
+ except ValueError as exc:
210
+ return None, f"{path} is not valid JSON: {exc}"
211
+ if not isinstance(data, dict):
212
+ return None, f"{path} is not a JSON object"
213
+ return data, None
214
+
215
+
216
+ def _read_installed_plugins(path: Path) -> tuple[dict[str, Any] | None, str | None]:
217
+ """Return the ``plugins`` map of Claude Code's install registry."""
218
+ data, error = _read_json_object(path)
219
+ if data is None:
220
+ return None, error
221
+ plugins = data.get("plugins")
222
+ if plugins is None:
223
+ return {}, None
224
+ if not isinstance(plugins, dict):
225
+ return None, f"{path}: 'plugins' is not an object"
226
+ return plugins, None
227
+
228
+
229
+ def _result(status: str, reason: str | None = None, **fields: Any) -> dict[str, Any]:
230
+ """Build one section/sub-check result with ``status`` and ``reason`` first."""
231
+ return {"status": status, "reason": reason, **fields}
232
+
233
+
234
+ def _join_reasons(*parts: str | None) -> str | None:
235
+ """Join the non-empty reasons with ``; ``, or return None."""
236
+ present = [part for part in parts if part]
237
+ return "; ".join(present) or None
238
+
239
+
240
+ def _aggregate(statuses: list[str]) -> str:
241
+ """Return the first status in the #222 priority order."""
242
+ for candidate in _AGGREGATE_PRIORITY:
243
+ if candidate in statuses:
244
+ return candidate
245
+ return "ok"
246
+
247
+
248
+ def _run_section(name: str, probe: Callable[[], dict[str, Any]]) -> dict[str, Any]:
249
+ """Run one probe, converting any failure into an isolated ``unverifiable``."""
250
+ try:
251
+ result = probe()
252
+ except Exception as exc: # noqa: BLE001 - isolation is the contract
253
+ return _result("unverifiable", f"{name}: {exc}")
254
+ status = result.get("status")
255
+ if status not in _SECTION_STATUSES:
256
+ return _result(
257
+ "unverifiable", f"{name}: probe returned an unknown status {status!r}"
258
+ )
259
+ return result
260
+
261
+
262
+ def _probe_env() -> dict[str, Any]:
263
+ """The nested ``env`` field: the same-process ``wl env check`` payload."""
264
+ status, reason, fields = build_payload()
265
+ return _result(status, reason, **fields)
266
+
267
+
268
+ def _version_key(name: str) -> tuple[int, ...]:
269
+ """Sort key for numeric version directory names."""
270
+ return tuple(int(part) for part in name.split("."))
271
+
272
+
273
+ def _max_version_dir(cache: Path) -> str | None:
274
+ """Return the highest numeric version directory under *cache*.
275
+
276
+ Raises:
277
+ OSError: If the directory exists but cannot be listed.
278
+ """
279
+ if not cache.is_dir():
280
+ return None
281
+ versions = [
282
+ entry.name
283
+ for entry in cache.iterdir()
284
+ if entry.is_dir() and _VERSION_RE.match(entry.name)
285
+ ]
286
+ if not versions:
287
+ return None
288
+ return max(versions, key=_version_key)
289
+
290
+
291
+ def _trusted_root(cache: Path, version: str) -> tuple[Path | None, str | None]:
292
+ """Resolve ``cache/version`` and require it to stay inside the cache.
293
+
294
+ Both sides are resolved so a symlinked config root does not read as an
295
+ escape; a version directory that resolves elsewhere is rejected (the
296
+ ``rejected`` state of check.sh §3).
297
+ """
298
+ root = cache / version
299
+ try:
300
+ cache_real = cache.resolve(strict=True)
301
+ root_real = root.resolve(strict=True)
302
+ except OSError as exc:
303
+ return None, f"cannot resolve the plugin root {root}: {exc}"
304
+ if root_real != cache_real / version:
305
+ return None, f"plugin root {root} resolves outside the cache: {root_real}"
306
+ return root, None
307
+
308
+
309
+ def _wlcc_cache(host: HostContext) -> Path:
310
+ """Return the wlcc plugin cache directory in the Claude config root."""
311
+ return host.config_root / "plugins" / "cache" / "wlcc" / "wlcc"
312
+
313
+
314
+ def _locate_wlcc_root(host: HostContext) -> tuple[Path | None, str | None]:
315
+ """Return the trusted wlcc plugin root, or a readable failure reason."""
316
+ cache = _wlcc_cache(host)
317
+ try:
318
+ version = _max_version_dir(cache)
319
+ except OSError as exc:
320
+ return None, f"cannot read the wlcc plugin cache {cache}: {exc}"
321
+ if version is None:
322
+ return None, f"wlcc plugin is not installed in {cache}"
323
+ return _trusted_root(cache, version)
324
+
325
+
326
+ def _read_stamp(path: Path) -> tuple[str | None, str | None]:
327
+ """Read a one-line stamp; a missing file is ``(None, None)``."""
328
+ try:
329
+ value = path.read_text(encoding="utf-8").strip()
330
+ except FileNotFoundError:
331
+ return None, None
332
+ except OSError as exc:
333
+ return None, f"cannot read {path}: {exc}"
334
+ return (value or None), None
335
+
336
+
337
+ def _parse_plugin_list(text: str) -> set[str]:
338
+ """Extract ``name@marketplace`` entries from ``claude plugin list`` text."""
339
+ present: set[str] = set()
340
+ for line in text.splitlines():
341
+ if "❯" not in line:
342
+ continue
343
+ tail = line.split("❯", 1)[1].strip()
344
+ token = tail.split()[0] if tail else ""
345
+ if token:
346
+ present.add(token)
347
+ return present
348
+
349
+
350
+ def _plugin_list_from_cli() -> tuple[set[str] | None, str | None]:
351
+ """Fallback for an unreadable registry: parse ``claude plugin list``.
352
+
353
+ Only presence is read here -- the CLI's output has no machine-readable
354
+ schema (wlcc's stamps registry says not to pin it), so enable state stays
355
+ unknown on this path and the ``plugins`` section never guesses.
356
+ """
357
+ claude = shutil.which("claude")
358
+ if not claude:
359
+ return None, "the claude CLI is not on PATH"
360
+ try:
361
+ proc = subprocess.run(
362
+ [claude, "plugin", "list"],
363
+ capture_output=True,
364
+ text=True,
365
+ check=False,
366
+ timeout=30,
367
+ )
368
+ except (OSError, subprocess.SubprocessError) as exc:
369
+ return None, f"claude plugin list could not run: {exc}"
370
+ if proc.returncode != 0:
371
+ excerpt = " ".join((proc.stderr or proc.stdout).split())[:200]
372
+ return None, f"claude plugin list exited {proc.returncode}: {excerpt}"
373
+ return _parse_plugin_list(proc.stdout), None
374
+
375
+
376
+ def _context_mode(host: HostContext, enabled: dict[str, bool] | None) -> dict[str, Any]:
377
+ """§7: report the optional context-mode plugin and its prompt stamp."""
378
+ fields: dict[str, Any] = {
379
+ "enabled": None,
380
+ "prompted": (host.state_root / "context-prompted").exists(),
381
+ }
382
+ if enabled is None:
383
+ reason = "enabled state is unavailable from the claude plugin list fallback"
384
+ if host.settings is None:
385
+ reason = f"cannot read enabled state: {host.settings_error}"
386
+ return _result("unverifiable", reason, **fields)
387
+ fields["enabled"] = enabled.get(CONTEXT_MODE_PLUGIN) is True
388
+ if fields["enabled"]:
389
+ return _result("ok", None, **fields)
390
+ return _result("dangling", "context-mode is not enabled", **fields)
391
+
392
+
393
+ def probe_plugins(host: HostContext) -> dict[str, Any]:
394
+ """§1 + §7: required plugin presence and enable state, read-only.
395
+
396
+ Presence comes from ``installed_plugins.json`` and enable state from
397
+ ``settings.json#enabledPlugins`` (the two authoritative sources per the
398
+ #222 ruling). ``claude plugin list`` is only a fallback when the registry
399
+ file cannot be read; when neither source works the section is
400
+ ``unverifiable`` rather than guessing.
401
+ """
402
+ fields: dict[str, Any] = {
403
+ "required": list(REQUIRED_PLUGINS),
404
+ "source": None,
405
+ "installed": [],
406
+ "missing": [],
407
+ "disabled": [],
408
+ "enabled_state": "unknown",
409
+ }
410
+ if host.installed is None:
411
+ listed, fallback_error = _plugin_list_from_cli()
412
+ if listed is None:
413
+ reason = _join_reasons(
414
+ f"plugin registry unreadable: {host.installed_error}",
415
+ f"claude plugin list fallback failed: {fallback_error}",
416
+ )
417
+ return _result("unverifiable", reason, **fields)
418
+ present = listed
419
+ enabled: dict[str, bool] | None = None
420
+ fields["source"] = "claude plugin list"
421
+ else:
422
+ present = set(host.installed)
423
+ fields["source"] = "installed_plugins.json"
424
+ if host.settings is None:
425
+ fields["installed"] = [
426
+ entry for entry in REQUIRED_PLUGINS if entry in present
427
+ ]
428
+ return _result(
429
+ "unverifiable",
430
+ f"cannot read enabled state: {host.settings_error}",
431
+ **fields,
432
+ )
433
+ enabled = host.enabled_plugins
434
+
435
+ installed_required = [entry for entry in REQUIRED_PLUGINS if entry in present]
436
+ missing = [entry for entry in REQUIRED_PLUGINS if entry not in present]
437
+ disabled: list[str] = []
438
+ if enabled is not None:
439
+ disabled = [
440
+ entry for entry in installed_required if enabled.get(entry) is not True
441
+ ]
442
+ fields["enabled_state"] = "known"
443
+ fields.update(installed=installed_required, missing=missing, disabled=disabled)
444
+ fields["context_mode"] = _context_mode(host, enabled)
445
+
446
+ if missing or disabled:
447
+ reason = _join_reasons(
448
+ f"missing: {', '.join(missing)}" if missing else None,
449
+ f"disabled: {', '.join(disabled)}" if disabled else None,
450
+ )
451
+ return _result("dangling", reason, **fields)
452
+ return _result("ok", None, **fields)
453
+
454
+
455
+ def _interpreter_state(path: str, version: str, returncode: int) -> dict[str, Any]:
456
+ """Classify one interpreter probe result the way check.sh §3 does."""
457
+ fields: dict[str, Any] = {
458
+ "state": "fail:unusable",
459
+ "path": path,
460
+ "version": version,
461
+ }
462
+ if not _VERSION_RE.match(version):
463
+ fields["state"] = "fail:probe_error"
464
+ fields["reason"] = f"{path} did not report a version"
465
+ return fields
466
+ if returncode == 7:
467
+ fields["state"] = f"too_old:{version}"
468
+ return fields
469
+ if returncode != 0:
470
+ fields["reason"] = f"{path} exited {returncode}"
471
+ return fields
472
+ prefix = "supplied" if os.path.isabs(path) else "ok"
473
+ fields["state"] = f"{prefix}:{version}"
474
+ return fields
475
+
476
+
477
+ def _probe_python() -> dict[str, Any]:
478
+ """Resolve the interpreter MCP servers will run under (check.sh §3).
479
+
480
+ ``uv --no-cache python find 3.12`` resolves a local installation without
481
+ downloading and keeps its cache in a temporary directory for the duration
482
+ of the call -- so the probe leaves the host untouched (``--no-cache`` is
483
+ required: plain ``uv python find`` creates ``$HOME/.cache/uv``). The
484
+ interpreter itself decides the 3.9 floor, mirroring check.sh. Every
485
+ failure is a state value, never an exception.
486
+ """
487
+ fields: dict[str, Any] = {"state": "fail:absent", "path": None, "version": None}
488
+ uv = shutil.which("uv")
489
+ if uv is None:
490
+ return fields
491
+ try:
492
+ found = subprocess.run(
493
+ [uv, "--no-cache", "python", "find", "3.12"],
494
+ capture_output=True,
495
+ text=True,
496
+ check=False,
497
+ timeout=30,
498
+ )
499
+ except (OSError, subprocess.SubprocessError) as exc:
500
+ fields.update(state="fail:unusable", reason=str(exc))
501
+ return fields
502
+ lines = [line.strip() for line in found.stdout.splitlines() if line.strip()]
503
+ path = lines[-1] if lines else ""
504
+ if found.returncode != 0 or not path:
505
+ fields["reason"] = f"uv python find exited {found.returncode}"
506
+ return fields
507
+ try:
508
+ probe = subprocess.run(
509
+ [path, "-c", _VERSION_PROBE],
510
+ capture_output=True,
511
+ text=True,
512
+ check=False,
513
+ timeout=30,
514
+ )
515
+ except (OSError, subprocess.SubprocessError) as exc:
516
+ fields.update(state="fail:unusable", path=path, reason=str(exc))
517
+ return fields
518
+ return _interpreter_state(path, probe.stdout.strip(), probe.returncode)
519
+
520
+
521
+ def probe_mcp(host: HostContext) -> dict[str, Any]:
522
+ """§3: plugin-root trust, version stamp and interpreter visibility."""
523
+ cache = _wlcc_cache(host)
524
+ fields: dict[str, Any] = {
525
+ "cache_root": str(cache),
526
+ "plugin_version": None,
527
+ "registered_version": None,
528
+ "plugin_root": None,
529
+ "rejected": False,
530
+ "servers": list(MCP_SERVERS),
531
+ "python": _probe_python(),
532
+ }
533
+ try:
534
+ version = _max_version_dir(cache)
535
+ except OSError as exc:
536
+ return _result(
537
+ "unverifiable",
538
+ f"cannot read the wlcc plugin cache {cache}: {exc}",
539
+ **fields,
540
+ )
541
+ fields["plugin_version"] = version
542
+
543
+ registered, stamp_error = _read_stamp(host.state_root / "mcp-registered-version")
544
+ fields["registered_version"] = registered
545
+ if stamp_error:
546
+ return _result("unverifiable", stamp_error, **fields)
547
+ if version is None:
548
+ return _result("dangling", f"wlcc plugin is not installed in {cache}", **fields)
549
+
550
+ root, root_error = _trusted_root(cache, version)
551
+ if root is None:
552
+ fields["rejected"] = True
553
+ return _result("unverifiable", root_error, **fields)
554
+ fields["plugin_root"] = str(root)
555
+ server = root / ".claude" / "mcp-tools" / "server.py"
556
+ if server.is_symlink():
557
+ fields["rejected"] = True
558
+ return _result(
559
+ "unverifiable", f"MCP server script is a symlink: {server}", **fields
560
+ )
561
+ if not server.is_file():
562
+ fields["rejected"] = True
563
+ return _result(
564
+ "unverifiable", f"MCP server script is missing: {server}", **fields
565
+ )
566
+
567
+ if registered != version:
568
+ return _result(
569
+ "dangling",
570
+ f"MCP stamp is {registered or 'absent'}, plugin cache is {version}; "
571
+ "wl cc repair will re-register",
572
+ **fields,
573
+ )
574
+ python_state = str(fields["python"].get("state", ""))
575
+ if not python_state.startswith(("supplied:", "ok:")):
576
+ return _result(
577
+ "dangling", f"MCP interpreter is not usable: {python_state}", **fields
578
+ )
579
+ return _result("ok", None, **fields)
580
+
581
+
582
+ def _probe_gstack(host: HostContext) -> dict[str, Any]:
583
+ """§5: report whether the pinned gstack skill is provisioned."""
584
+ path = host.config_root / "skills" / "gstack"
585
+ setup = path / "setup"
586
+ executable = setup.is_file() and os.access(setup, os.X_OK)
587
+ fields = {"path": str(path), "pin": GSTACK_PIN, "setup_executable": executable}
588
+ if executable:
589
+ return _result("ok", None, **fields)
590
+ if path.exists():
591
+ return _result(
592
+ "dangling", f"gstack is present but {setup} is not executable", **fields
593
+ )
594
+ return _result("dangling", f"gstack skill is not provisioned at {path}", **fields)
595
+
596
+
597
+ def _template_model(host: HostContext) -> tuple[str | None, str | None]:
598
+ """Return ``CLAUDE_MEM_MODEL`` from the wlcc claude-mem template.
599
+
600
+ The template lives under the wlcc plugin root; when that root is not
601
+ trusted the caller must report ``unverifiable`` instead of guessing.
602
+ """
603
+ root, root_error = _locate_wlcc_root(host)
604
+ if root is None:
605
+ return None, root_error
606
+ template = root / "skills" / "wl-env" / "templates" / "claude-mem-settings.json"
607
+ if not template.exists():
608
+ return None, f"claude-mem template is missing: {template}"
609
+ data, error = _read_json_object(template)
610
+ if data is None:
611
+ return None, error
612
+ model = data.get("CLAUDE_MEM_MODEL")
613
+ if not isinstance(model, str) or not model:
614
+ return None, f"claude-mem template has no CLAUDE_MEM_MODEL: {template}"
615
+ return model, None
616
+
617
+
618
+ def _probe_claude_mem(host: HostContext) -> dict[str, Any]:
619
+ """§6: compare claude-mem's applied settings with the shipped template."""
620
+ path = host.claude_mem_settings
621
+ exists = path.exists()
622
+ fields: dict[str, Any] = {
623
+ "path": str(path),
624
+ "exists": exists,
625
+ "model": None,
626
+ "template_model": None,
627
+ }
628
+ data, error = _read_json_object(path)
629
+ if data is None:
630
+ return _result("unverifiable", error, **fields)
631
+
632
+ template_model, template_error = _template_model(host)
633
+ fields["template_model"] = template_model
634
+ if not exists:
635
+ # Nothing to merge yet: the pending provisioning is visible without a
636
+ # template, and the template error stays in the section when relevant.
637
+ return _result(
638
+ "dangling", f"claude-mem settings are not provisioned at {path}", **fields
639
+ )
640
+
641
+ model = data.get("CLAUDE_MEM_MODEL")
642
+ fields["model"] = model if isinstance(model, str) else None
643
+ if template_model is None:
644
+ return _result(
645
+ "unverifiable",
646
+ template_error or "claude-mem template model is unavailable",
647
+ **fields,
648
+ )
649
+ if fields["model"] is None:
650
+ return _result(
651
+ "dangling", "claude-mem settings do not set CLAUDE_MEM_MODEL", **fields
652
+ )
653
+ if fields["model"] != template_model:
654
+ return _result(
655
+ "dangling",
656
+ f"claude-mem model is {fields['model']!r}, template expects "
657
+ f"{template_model!r}; merge manually",
658
+ **fields,
659
+ )
660
+ return _result("ok", None, **fields)
661
+
662
+
663
+ def probe_settings(host: HostContext) -> dict[str, Any]:
664
+ """§5 + §6: the host-side configuration checks, each isolated."""
665
+ subchecks = {
666
+ "gstack": _run_section("gstack", lambda: _probe_gstack(host)),
667
+ "claude_mem": _run_section("claude_mem", lambda: _probe_claude_mem(host)),
668
+ }
669
+ statuses = [subcheck["status"] for subcheck in subchecks.values()]
670
+ status = _aggregate(statuses)
671
+ reason = None
672
+ if status != "ok":
673
+ reason = _join_reasons(
674
+ *[
675
+ f"{name}: {subcheck['reason']}"
676
+ for name, subcheck in subchecks.items()
677
+ if subcheck["status"] == status and subcheck.get("reason")
678
+ ]
679
+ )
680
+ return _result(status, reason, **subchecks)
681
+
682
+
683
+ def probe_stamps(host: HostContext) -> dict[str, Any]:
684
+ """§8: report stamp drift; the check never deletes anything."""
685
+ root = host.state_root
686
+ fields: dict[str, Any] = {
687
+ "path": str(root),
688
+ "known": sorted(KNOWN_STAMPS),
689
+ "pruned": [],
690
+ "retired": [],
691
+ "orphans": [],
692
+ "retired_skill": False,
693
+ }
694
+ try:
695
+ entries = sorted(entry.name for entry in root.iterdir())
696
+ except FileNotFoundError:
697
+ entries = []
698
+ except OSError as exc:
699
+ return _result("unverifiable", f"cannot read {root}: {exc}", **fields)
700
+
701
+ fields["retired"] = [name for name in entries if name in RETIRED_STAMPS]
702
+ fields["orphans"] = [
703
+ name
704
+ for name in entries
705
+ if name not in KNOWN_STAMPS and name not in RETIRED_STAMPS
706
+ ]
707
+ retired_skill = host.config_root / "skills" / "visionary-cli"
708
+ fields["retired_skill"] = (retired_skill / ".wlcc-version").exists()
709
+
710
+ if not (fields["retired"] or fields["orphans"] or fields["retired_skill"]):
711
+ return _result("ok", None, **fields)
712
+ reason = _join_reasons(
713
+ (
714
+ "retired stamps present: " + ", ".join(fields["retired"])
715
+ if fields["retired"]
716
+ else None
717
+ ),
718
+ (
719
+ "unregistered entries (reported only): " + ", ".join(fields["orphans"])
720
+ if fields["orphans"]
721
+ else None
722
+ ),
723
+ (
724
+ "retired visionary-cli skill copy present"
725
+ if fields["retired_skill"]
726
+ else None
727
+ ),
728
+ )
729
+ return _result("dangling", reason, **fields)
730
+
731
+
732
+ def _is_never_touch(command: str) -> bool:
733
+ """Return True for commands whose path-like tokens are not argv paths."""
734
+ tokens = command.split()
735
+ if not tokens:
736
+ return False
737
+ head = os.path.basename(tokens[0])
738
+ if head in _NEVER_TOUCH_HEAD:
739
+ return True
740
+ if head in _EVAL_HEAD:
741
+ return any(token.startswith("-") for token in tokens[1:])
742
+ return False
743
+
744
+
745
+ def _scan_tokens(command: str) -> list[tuple[str, str, str]]:
746
+ """Classify path-like tokens as healthy / dangling / skip (hooks-drift §9)."""
747
+ found: list[tuple[str, str, str]] = []
748
+ seen: set[str] = set()
749
+ for token in _TOKEN_RE.findall(command):
750
+ if ".claude" not in token and not token.startswith("/"):
751
+ continue
752
+ if token in seen:
753
+ continue
754
+ seen.add(token)
755
+ if "$" in token:
756
+ found.append(("skip", token, "contains a variable; runtime-only"))
757
+ elif token.startswith("~"):
758
+ found.append(("skip", token, "~ form is not a literal absolute path"))
759
+ elif not token.startswith("/"):
760
+ continue
761
+ elif CLAUDE_SEG not in token:
762
+ if not os.path.exists(token):
763
+ found.append(
764
+ ("skip", token, "dangling absolute path outside */.claude/*")
765
+ )
766
+ elif not os.path.exists(token):
767
+ found.append(("dangling", token, ""))
768
+ else:
769
+ found.append(("healthy", token, ""))
770
+ return found
771
+
772
+
773
+ def _entry(
774
+ action: str,
775
+ source: str,
776
+ target: str | None,
777
+ reason: str,
778
+ event: str,
779
+ matcher: str | None,
780
+ ) -> dict[str, Any]:
781
+ """Build one hook plan entry, the same shape hooks-drift reports."""
782
+ return {
783
+ "action": action,
784
+ "from": source,
785
+ "to": target,
786
+ "reason": reason,
787
+ "event": event,
788
+ "matcher": matcher,
789
+ }
790
+
791
+
792
+ def _rewrite_path(path: str, root: str) -> str | None:
793
+ """Replace a ``*/.claude`` prefix with the local config root."""
794
+ index = path.find(CLAUDE_SEG)
795
+ if index < 0:
796
+ return None
797
+ return root + path[index + len("/.claude") :]
798
+
799
+
800
+ def _plan_hook_command(
801
+ command: str, root: str, event: str, matcher: str | None
802
+ ) -> tuple[str, list[dict[str, Any]]]:
803
+ """Read-only port of hooks-drift's rewrite-preferred-over-prune planner."""
804
+ if _is_never_touch(command):
805
+ return "keep", []
806
+ scanned = _scan_tokens(command)
807
+ dangling = [token for kind, token, _ in scanned if kind == "dangling"]
808
+ if not dangling:
809
+ return "keep", [
810
+ _entry("skip", token, None, reason, event, matcher)
811
+ for kind, token, reason in scanned
812
+ if kind == "skip"
813
+ ]
814
+
815
+ targets = {token: _rewrite_path(token, root) for token in dangling}
816
+ saveable = [
817
+ token
818
+ for token in dangling
819
+ if targets[token] is not None and os.path.exists(targets[token] or "")
820
+ ]
821
+ action = "rewrite" if saveable else "prune"
822
+ entries: list[dict[str, Any]] = []
823
+ for kind, token, reason in scanned:
824
+ if kind == "skip":
825
+ entries.append(_entry("skip", token, None, reason, event, matcher))
826
+ elif kind == "healthy":
827
+ continue
828
+ elif action == "prune":
829
+ entries.append(
830
+ _entry(
831
+ "prune",
832
+ token,
833
+ None,
834
+ "no local counterpart; repair would prune it",
835
+ event,
836
+ matcher,
837
+ )
838
+ )
839
+ else:
840
+ target = targets[token]
841
+ if target is not None and os.path.exists(target):
842
+ entries.append(
843
+ _entry(
844
+ "rewrite",
845
+ token,
846
+ target,
847
+ "prefix maps to an existing local path",
848
+ event,
849
+ matcher,
850
+ )
851
+ )
852
+ else:
853
+ entries.append(
854
+ _entry(
855
+ "skip",
856
+ token,
857
+ None,
858
+ "no local counterpart; kept because another path is repairable",
859
+ event,
860
+ matcher,
861
+ )
862
+ )
863
+ return action, entries
864
+
865
+
866
+ def _iter_hook_commands(hooks: dict[str, Any]) -> Iterator[tuple[str, str | None, str]]:
867
+ """Yield ``(event, matcher, command)`` for every well-shaped hook."""
868
+ for event, groups in hooks.items():
869
+ if not isinstance(groups, list):
870
+ continue
871
+ for group in groups:
872
+ if not isinstance(group, dict):
873
+ continue
874
+ hook_list = group.get("hooks")
875
+ if not isinstance(hook_list, list):
876
+ continue
877
+ matcher = group.get("matcher")
878
+ matcher_value = matcher if isinstance(matcher, str) else None
879
+ for hook in hook_list:
880
+ command = hook.get("command") if isinstance(hook, dict) else None
881
+ if isinstance(command, str):
882
+ yield str(event), matcher_value, command
883
+
884
+
885
+ def probe_hooks(host: HostContext) -> dict[str, Any]:
886
+ """§9: plan dangling hook repairs without writing (read-only)."""
887
+ settings_path = host.config_root / "settings.json"
888
+ fields: dict[str, Any] = {
889
+ "settings": str(settings_path),
890
+ "total": 0,
891
+ "rewrite": 0,
892
+ "prune": 0,
893
+ "skip": 0,
894
+ "entries": [],
895
+ }
896
+ if host.settings is None:
897
+ return _result(
898
+ "unverifiable",
899
+ f"cannot read {settings_path}: {host.settings_error}",
900
+ **fields,
901
+ )
902
+ hooks = host.settings.get("hooks")
903
+ if not isinstance(hooks, dict):
904
+ return _result("ok", None, **fields)
905
+
906
+ entries: list[dict[str, Any]] = []
907
+ for event, matcher, command in _iter_hook_commands(hooks):
908
+ _, planned = _plan_hook_command(command, str(host.config_root), event, matcher)
909
+ entries.extend(planned)
910
+ counts = {"rewrite": 0, "prune": 0, "skip": 0}
911
+ for entry in entries:
912
+ counts[entry["action"]] += 1
913
+ fields.update(total=len(entries), entries=entries, **counts)
914
+ if not entries:
915
+ return _result("ok", None, **fields)
916
+ return _result(
917
+ "dangling", f"{len(entries)} hook command entries need attention", **fields
918
+ )
919
+
920
+
921
+ def probe_statusline(host: HostContext) -> dict[str, Any]:
922
+ """wl-env-init §3: classify the configured statusline without writing."""
923
+ settings_path = host.config_root / "settings.json"
924
+ fields: dict[str, Any] = {
925
+ "settings": str(settings_path),
926
+ "action": "unverifiable",
927
+ "command": None,
928
+ "command_family": None,
929
+ }
930
+ if host.settings is None:
931
+ return _result(
932
+ "unverifiable",
933
+ f"cannot read {settings_path}: {host.settings_error}",
934
+ **fields,
935
+ )
936
+ status_line = host.settings.get("statusLine")
937
+ command = status_line.get("command") if isinstance(status_line, dict) else None
938
+ if not isinstance(command, str) or not command.strip():
939
+ fields["action"] = "missing"
940
+ return _result("dangling", "statusLine is not configured", **fields)
941
+ fields["command"] = command
942
+ if "claude-hud" not in command:
943
+ fields.update(action="conflict", command_family="other")
944
+ return _result(
945
+ "dangling",
946
+ "another statusline is configured; wl cc repair will not overwrite it",
947
+ **fields,
948
+ )
949
+
950
+ fields["command_family"] = "claude-hud"
951
+ cache = host.config_root / "plugins" / "cache" / "claude-hud" / "claude-hud"
952
+ try:
953
+ version = _max_version_dir(cache)
954
+ except OSError as exc:
955
+ return _result(
956
+ "unverifiable",
957
+ f"cannot read the claude-hud plugin cache {cache}: {exc}",
958
+ **fields,
959
+ )
960
+ if version is None:
961
+ fields["action"] = "missing_plugin"
962
+ return _result(
963
+ "dangling",
964
+ f"claude-hud statusLine is configured but {cache} has no version",
965
+ **fields,
966
+ )
967
+ root, root_error = _trusted_root(cache, version)
968
+ if root is None:
969
+ return _result("unverifiable", root_error, **fields)
970
+ fields.update(action="ok", plugin_version=version, plugin_root=str(root))
971
+ return _result("ok", None, **fields)
972
+
973
+
974
+ def _reason_for(sections: dict[str, dict[str, Any]], status: str) -> str | None:
975
+ """Return the top-level reason: the failed sections' reasons, joined.
976
+
977
+ Section reasons raised by :func:`_run_section` already start with the
978
+ section name, so the prefix is only added when it is not there -- the
979
+ top-level reason never reads ``plugins: plugins: ...``.
980
+ """
981
+ if status not in ("error", "unverifiable"):
982
+ return None
983
+ parts: list[str] = []
984
+ for name, section in sections.items():
985
+ if section["status"] not in ("error", "unverifiable"):
986
+ continue
987
+ reason = section["reason"] or "failed"
988
+ parts.append(reason if reason.startswith(f"{name}:") else f"{name}: {reason}")
989
+ return _join_reasons(*parts)
990
+
991
+
992
+ def build_report() -> tuple[str, str | None, dict[str, Any]]:
993
+ """Build the whole ``wl cc check`` report.
994
+
995
+ Returns:
996
+ ``(status, reason, fields)``. ``fields`` holds ``env`` plus the six
997
+ host sections, each carrying its own five-state ``status``/``reason``.
998
+ A section probe that raises is reported as ``unverifiable`` with the
999
+ exception text -- it never escapes this function.
1000
+ """
1001
+ host = load_host_context()
1002
+ sections: dict[str, dict[str, Any]] = {
1003
+ "env": _run_section("env", _probe_env),
1004
+ "plugins": _run_section("plugins", lambda: probe_plugins(host)),
1005
+ "mcp": _run_section("mcp", lambda: probe_mcp(host)),
1006
+ "settings": _run_section("settings", lambda: probe_settings(host)),
1007
+ "stamps": _run_section("stamps", lambda: probe_stamps(host)),
1008
+ "hooks": _run_section("hooks", lambda: probe_hooks(host)),
1009
+ "statusline": _run_section("statusline", lambda: probe_statusline(host)),
1010
+ }
1011
+ status = _aggregate([section["status"] for section in sections.values()])
1012
+ return status, _reason_for(sections, status), sections
1013
+
1014
+
1015
+ def emit_check() -> int:
1016
+ """Write the ``cc check`` envelope to stdout and return its exit code."""
1017
+ status, reason, fields = build_report()
1018
+ dump(status, status in OK_STATUSES, tool=TOOL, reason=reason, **fields)
1019
+ return exit_code(status)