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.
- py_wlcli-0.8.0.dist-info/METADATA +139 -0
- py_wlcli-0.8.0.dist-info/RECORD +320 -0
- py_wlcli-0.8.0.dist-info/WHEEL +4 -0
- py_wlcli-0.8.0.dist-info/entry_points.txt +2 -0
- py_wlcli-0.8.0.dist-info/licenses/LICENSE +21 -0
- wl_cli/__init__.py +27 -0
- wl_cli/__main__.py +130 -0
- wl_cli/capabilities.py +79 -0
- wl_cli/cc/__init__.py +5 -0
- wl_cli/cc/checks.py +1019 -0
- wl_cli/cc/cli.py +370 -0
- wl_cli/cc/mutate.py +1350 -0
- wl_cli/cli_click.py +156 -0
- wl_cli/commands/__init__.py +84 -0
- wl_cli/commands/base.py +80 -0
- wl_cli/commands/buildcommands/__init__.py +1 -0
- wl_cli/commands/buildcommands/build.py +71 -0
- wl_cli/commands/buildcommands/build_utils.py +47 -0
- wl_cli/commands/buildcommands/builders/__init__.py +18 -0
- wl_cli/commands/buildcommands/builders/cleanup.py +35 -0
- wl_cli/commands/buildcommands/builders/coordinator.py +122 -0
- wl_cli/commands/buildcommands/builders/environment.py +81 -0
- wl_cli/commands/buildcommands/builders/rust_builder.py +39 -0
- wl_cli/commands/buildcommands/builders/stubs.py +58 -0
- wl_cli/commands/buildcommands/dist.py +326 -0
- wl_cli/commands/buildcommands/test.py +89 -0
- wl_cli/commands/clean/__init__.py +1 -0
- wl_cli/commands/clean/build.py +151 -0
- wl_cli/commands/clean/clean.py +294 -0
- wl_cli/commands/clean/clean_utils.py +77 -0
- wl_cli/commands/clean/core.py +236 -0
- wl_cli/commands/clean/dead_code/__init__.py +0 -0
- wl_cli/commands/clean/dead_code/dead_code_command.py +70 -0
- wl_cli/commands/clean/dead_code/detector.py +161 -0
- wl_cli/commands/clean/dead_code/fixer.py +176 -0
- wl_cli/commands/clean/dead_code/reporter.py +295 -0
- wl_cli/commands/clean/dead_code/utils.py +0 -0
- wl_cli/commands/clean/lfs.py +215 -0
- wl_cli/commands/clean/python.py +81 -0
- wl_cli/commands/clean/rust.py +91 -0
- wl_cli/commands/clean/utils/__init__.py +34 -0
- wl_cli/commands/clean/venv.py +91 -0
- wl_cli/commands/cleanup.py +73 -0
- wl_cli/commands/click_base.py +79 -0
- wl_cli/commands/config/__init__.py +1 -0
- wl_cli/commands/config/config.py +291 -0
- wl_cli/commands/debug/__init__.py +6 -0
- wl_cli/commands/debug/debug.py +309 -0
- wl_cli/commands/debug/debug_cache.py +86 -0
- wl_cli/commands/debug/debug_subagent.py +156 -0
- wl_cli/commands/debug/output_formatter.py +165 -0
- wl_cli/commands/debug/source_hasher.py +92 -0
- wl_cli/commands/format/__init__.py +13 -0
- wl_cli/commands/format/format_cache_adapter.py +125 -0
- wl_cli/commands/format/format_command.py +197 -0
- wl_cli/commands/format/format_coordinator.py +191 -0
- wl_cli/commands/format/format_executor.py +470 -0
- wl_cli/commands/format/format_outcome.py +44 -0
- wl_cli/commands/format/format_path_handler.py +1016 -0
- wl_cli/commands/format/format_progress.py +62 -0
- wl_cli/commands/format/format_reporter.py +31 -0
- wl_cli/commands/format/python_formatter.py +506 -0
- wl_cli/commands/format/rust_formatter.py +106 -0
- wl_cli/commands/gate/__init__.py +1 -0
- wl_cli/commands/gate/gate_command.py +862 -0
- wl_cli/commands/gate/preflight.py +445 -0
- wl_cli/commands/git/__init__.py +1 -0
- wl_cli/commands/git/config.py +169 -0
- wl_cli/commands/git/git_facts.py +60 -0
- wl_cli/commands/git/git_flow.py +86 -0
- wl_cli/commands/git/hook.py +112 -0
- wl_cli/commands/git/policy.py +216 -0
- wl_cli/commands/git/push_update.py +82 -0
- wl_cli/commands/initenv/__init__.py +1 -0
- wl_cli/commands/initenv/common/__init__.py +23 -0
- wl_cli/commands/initenv/common/check_git_bash.py +78 -0
- wl_cli/commands/initenv/common/error_handler.py +68 -0
- wl_cli/commands/initenv/common/exceptions.py +52 -0
- wl_cli/commands/initenv/components/__init__.py +4 -0
- wl_cli/commands/initenv/components/cargo_sync.py +240 -0
- wl_cli/commands/initenv/components/config_manager.py +106 -0
- wl_cli/commands/initenv/components/directory_utils.py +71 -0
- wl_cli/commands/initenv/components/git/__init__.py +9 -0
- wl_cli/commands/initenv/components/git/branch_creator.py +143 -0
- wl_cli/commands/initenv/components/git/git_flow_interface.py +24 -0
- wl_cli/commands/initenv/components/git/git_flow_manager.py +159 -0
- wl_cli/commands/initenv/components/git/git_lfs_manager.py +272 -0
- wl_cli/commands/initenv/components/git/git_utility.py +254 -0
- wl_cli/commands/initenv/components/git/gitignore_manager.py +53 -0
- wl_cli/commands/initenv/components/git_initializer.py +212 -0
- wl_cli/commands/initenv/components/i18n_manager.py +104 -0
- wl_cli/commands/initenv/components/log_manager.py +241 -0
- wl_cli/commands/initenv/components/project_structure/__init__.py +23 -0
- wl_cli/commands/initenv/components/project_structure/config_generator.py +360 -0
- wl_cli/commands/initenv/components/project_structure/directory_creator.py +184 -0
- wl_cli/commands/initenv/components/project_structure/hook_source.py +110 -0
- wl_cli/commands/initenv/components/project_structure/hooks_manager.py +995 -0
- wl_cli/commands/initenv/components/project_structure/readme_handler.py +56 -0
- wl_cli/commands/initenv/components/project_structure/rust_files_handler.py +30 -0
- wl_cli/commands/initenv/components/project_structure/setup_handler.py +103 -0
- wl_cli/commands/initenv/components/project_structure/sop_generator.py +73 -0
- wl_cli/commands/initenv/components/project_structure/template_copier.py +104 -0
- wl_cli/commands/initenv/components/project_structure/template_manager.py +195 -0
- wl_cli/commands/initenv/components/pyproject_generator.py +194 -0
- wl_cli/commands/initenv/components/rust_initializer.py +145 -0
- wl_cli/commands/initenv/components/symlink_cleaner.py +99 -0
- wl_cli/commands/initenv/components/symlink_manager.py +52 -0
- wl_cli/commands/initenv/components/venv_manager.py +118 -0
- wl_cli/commands/initenv/initenv.py +169 -0
- wl_cli/commands/initenv/services/__init__.py +11 -0
- wl_cli/commands/initenv/services/environment_initializer.py +288 -0
- wl_cli/commands/initenv/services/git_lfs_initializer.py +26 -0
- wl_cli/commands/initenv/services/project_structure_initializer.py +52 -0
- wl_cli/commands/lfs/README.md +221 -0
- wl_cli/commands/lfs/__init__.py +5 -0
- wl_cli/commands/lfs/cli.py +106 -0
- wl_cli/commands/lfs/config.py +164 -0
- wl_cli/commands/lfs/hook.py +61 -0
- wl_cli/commands/lfs/initializer.py +531 -0
- wl_cli/commands/lfs/manager.py +95 -0
- wl_cli/commands/lfs/policy.py +165 -0
- wl_cli/commands/lfs/watcher.py +294 -0
- wl_cli/commands/lint/__init__.py +1 -0
- wl_cli/commands/lint/lint_command.py +297 -0
- wl_cli/commands/lint/lint_executor.py +409 -0
- wl_cli/commands/lint/lint_reporter.py +579 -0
- wl_cli/commands/lint/lint_run.py +258 -0
- wl_cli/commands/lint/mypy_config.py +90 -0
- wl_cli/commands/registry.py +103 -0
- wl_cli/commands/self/__init__.py +7 -0
- wl_cli/commands/self/common/__init__.py +8 -0
- wl_cli/commands/self/common/capabilities.py +30 -0
- wl_cli/commands/self/common/config.py +27 -0
- wl_cli/commands/self/common/version_cache.py +85 -0
- wl_cli/commands/self/handlers/__init__.py +19 -0
- wl_cli/commands/self/handlers/base_handler.py +28 -0
- wl_cli/commands/self/handlers/install_handler.py +85 -0
- wl_cli/commands/self/handlers/status_handler.py +66 -0
- wl_cli/commands/self/handlers/uninstall_handler.py +58 -0
- wl_cli/commands/self/handlers/update_handler.py +102 -0
- wl_cli/commands/self/handlers/version_handler.py +16 -0
- wl_cli/commands/self/self_command.py +237 -0
- wl_cli/commands/self/services/__init__.py +15 -0
- wl_cli/commands/self/services/installation_manager.py +187 -0
- wl_cli/commands/self/services/local_installer.py +165 -0
- wl_cli/commands/self/services/pypi_updater.py +278 -0
- wl_cli/commands/self/services/windows_update.py +174 -0
- wl_cli/commands/testcommands/__init__.py +8 -0
- wl_cli/commands/testcommands/base_dependency_analyzer.py +198 -0
- wl_cli/commands/testcommands/batch_processor.py +278 -0
- wl_cli/commands/testcommands/cache_manager.py +1156 -0
- wl_cli/commands/testcommands/coverage_analyzer.py +955 -0
- wl_cli/commands/testcommands/coverage_cache_manager.py +183 -0
- wl_cli/commands/testcommands/progress_manager.py +79 -0
- wl_cli/commands/testcommands/python_env_detector.py +207 -0
- wl_cli/commands/testcommands/report_generator.py +101 -0
- wl_cli/commands/testcommands/single_test_executor.py +250 -0
- wl_cli/commands/testcommands/speed_checker.py +118 -0
- wl_cli/commands/testcommands/test_command_builder.py +165 -0
- wl_cli/commands/testcommands/test_commands.py +786 -0
- wl_cli/commands/testcommands/test_reporter.py +181 -0
- wl_cli/commands/testcommands/test_result_handler.py +96 -0
- wl_cli/commands/testcommands/test_runner.py +1343 -0
- wl_cli/commands/testcommands/test_utils.py +197 -0
- wl_cli/commands/testcommands/utils/__init__.py +17 -0
- wl_cli/commands/testcommands/utils/coverage_parser.py +98 -0
- wl_cli/commands/testcommands/utils/env_info.py +36 -0
- wl_cli/commands/testcommands/utils/junit_parser.py +75 -0
- wl_cli/commands/testcommands/utils/markdown_utils.py +20 -0
- wl_cli/commands/testcommands/utils/project_root.py +61 -0
- wl_cli/commands/update/__init__.py +9 -0
- wl_cli/commands/update/services/__init__.py +21 -0
- wl_cli/commands/update/services/backup_manager.py +207 -0
- wl_cli/commands/update/services/component_manager.py +83 -0
- wl_cli/commands/update/services/conflict_resolver.py +189 -0
- wl_cli/commands/update/services/vendors_updater.py +85 -0
- wl_cli/commands/update/services/version_tracker.py +127 -0
- wl_cli/commands/update/update_command.py +270 -0
- wl_cli/commands/update/utils/__init__.py +3 -0
- wl_cli/commands/version/__init__.py +9 -0
- wl_cli/commands/version/version_command.py +155 -0
- wl_cli/commands/workflow.py +477 -0
- wl_cli/env/__init__.py +5 -0
- wl_cli/env/cli.py +167 -0
- wl_cli/env_status.py +157 -0
- wl_cli/envelope.py +91 -0
- wl_cli/exceptions.py +85 -0
- wl_cli/lib/__init__.py +3 -0
- wl_cli/lib/rust_utils.py +35 -0
- wl_cli/mcp/__init__.py +6 -0
- wl_cli/mcp/capabilities.py +19 -0
- wl_cli/mcp/cli.py +22 -0
- wl_cli/mcp/gitlab.py +553 -0
- wl_cli/mcp/k8s.py +344 -0
- wl_cli/mcp/protocol.py +159 -0
- wl_cli/platform.py +139 -0
- wl_cli/quality/__init__.py +18 -0
- wl_cli/utils/__init__.py +7 -0
- wl_cli/utils/cache.py +491 -0
- wl_cli/utils/cache_ledger.py +355 -0
- wl_cli/utils/cache_store.py +546 -0
- wl_cli/utils/config/__init__.py +27 -0
- wl_cli/utils/config/config_loader.py +163 -0
- wl_cli/utils/config/core.py +862 -0
- wl_cli/utils/config/defaults.py +142 -0
- wl_cli/utils/config/env_exporter.py +86 -0
- wl_cli/utils/config/env_parser.py +108 -0
- wl_cli/utils/config/validation.py +280 -0
- wl_cli/utils/error_handler.py +165 -0
- wl_cli/utils/file_copier.py +56 -0
- wl_cli/utils/file_operations.py +506 -0
- wl_cli/utils/language_utils.py +122 -0
- wl_cli/utils/lazy_import.py +240 -0
- wl_cli/utils/log_levels.py +35 -0
- wl_cli/utils/log_rotators.py +74 -0
- wl_cli/utils/logging.py +458 -0
- wl_cli/utils/output_context.py +142 -0
- wl_cli/utils/performance/__init__.py +1 -0
- wl_cli/utils/performance/decorators.py +111 -0
- wl_cli/utils/performance/exporter.py +38 -0
- wl_cli/utils/performance/models.py +16 -0
- wl_cli/utils/performance/monitor.py +161 -0
- wl_cli/utils/performance_monitor.py +32 -0
- wl_cli/utils/platform_adapter.py +131 -0
- wl_cli/utils/pre_commit.py +266 -0
- wl_cli/utils/pre_commit_config.py +72 -0
- wl_cli/utils/root.py +521 -0
- wl_cli/utils/root_fallback.py +256 -0
- wl_cli/utils/run_evidence.py +236 -0
- wl_cli/utils/structured_logger.py +18 -0
- wl_cli/utils/structured_logging/__init__.py +19 -0
- wl_cli/utils/structured_logging/config.py +337 -0
- wl_cli/utils/structured_logging/core.py +177 -0
- wl_cli/utils/structured_logging/env.py +56 -0
- wl_cli/utils/structured_logging/filtering.py +75 -0
- wl_cli/utils/structured_logging/filters.py +55 -0
- wl_cli/utils/structured_logging/formatters.py +251 -0
- wl_cli/utils/structured_logging/handlers.py +308 -0
- wl_cli/utils/structured_logging/logger.py +354 -0
- wl_cli/utils/subprocess.py +71 -0
- wl_cli/utils/subprocess_utils.py +260 -0
- wl_cli/utils/test_utils/__init__.py +5 -0
- wl_cli/utils/test_utils/test_filter.py +36 -0
- wl_cli/utils/uv_run.py +79 -0
- wl_cli/utils/uv_tool.py +117 -0
- wl_cli/utils/uv_tree.py +71 -0
- wl_cli/utils/uv_workspace.py +174 -0
- wl_cli/utils/version/__init__.py +15 -0
- wl_cli/utils/version/pypi_version_checker.py +95 -0
- wl_cli/utils/version/version_comparator.py +133 -0
- wl_cli/utils/version/version_detectors.py +312 -0
- wl_cli/utils/version/version_service.py +131 -0
- wl_cli/utils/version/version_updater.py +198 -0
- wl_cli/utils/wl_dir_updater.py +1440 -0
- wl_cli/utils/workspace_detector/__init__.py +28 -0
- wl_cli/utils/workspace_detector/core.py +161 -0
- wl_cli/utils/workspace_detector/detection_strategies.py +107 -0
- wl_cli/utils/workspace_detector/exceptions.py +5 -0
- wl_cli/utils/workspace_detector/types.py +17 -0
- wl_cli/utils/workspace_detector/venv_resolver.py +84 -0
- wl_cli/vendors/config/.codespellrc +2 -0
- wl_cli/vendors/config/dependencies.toml +10 -0
- wl_cli/vendors/config/mypy.ini +62 -0
- wl_cli/vendors/config/pyproject_defaults.toml +28 -0
- wl_cli/vendors/config/template_config.toml +24 -0
- wl_cli/vendors/config/tool_configs.toml +76 -0
- wl_cli/vendors/git/.gitignore +122 -0
- wl_cli/vendors/git/.pre-commit-config.yaml +37 -0
- wl_cli/vendors/hooks/applypatch-msg.sample +15 -0
- wl_cli/vendors/hooks/commit-msg.sample +24 -0
- wl_cli/vendors/hooks/fsmonitor-watchman.sample +174 -0
- wl_cli/vendors/hooks/post-update.sample +8 -0
- wl_cli/vendors/hooks/pre-applypatch.sample +14 -0
- wl_cli/vendors/hooks/pre-commit +45 -0
- wl_cli/vendors/hooks/pre-commit.legacy +31 -0
- wl_cli/vendors/hooks/pre-commit.sample +49 -0
- wl_cli/vendors/hooks/pre-merge-commit.sample +13 -0
- wl_cli/vendors/hooks/pre-push.sample +60 -0
- wl_cli/vendors/hooks/pre-rebase.sample +169 -0
- wl_cli/vendors/hooks/pre-receive.sample +24 -0
- wl_cli/vendors/hooks/prepare-commit-msg.sample +42 -0
- wl_cli/vendors/hooks/push-to-checkout.sample +78 -0
- wl_cli/vendors/hooks/sendemail-validate.sample +77 -0
- wl_cli/vendors/hooks/unix/applypatch-msg.sample +15 -0
- wl_cli/vendors/hooks/unix/check_lockfile.py +58 -0
- wl_cli/vendors/hooks/unix/check_lockfile.sh +36 -0
- wl_cli/vendors/hooks/unix/commit-msg.sample +24 -0
- wl_cli/vendors/hooks/unix/fsmonitor-watchman.sample +174 -0
- wl_cli/vendors/hooks/unix/post-update.sample +8 -0
- wl_cli/vendors/hooks/unix/pre-applypatch.sample +14 -0
- wl_cli/vendors/hooks/unix/pre-commit +190 -0
- wl_cli/vendors/hooks/unix/pre-commit.legacy +31 -0
- wl_cli/vendors/hooks/unix/pre-commit.py +81 -0
- wl_cli/vendors/hooks/unix/pre-commit.sample +49 -0
- wl_cli/vendors/hooks/unix/pre-merge-commit.sample +13 -0
- wl_cli/vendors/hooks/unix/pre-push +417 -0
- wl_cli/vendors/hooks/unix/pre-push.sample +53 -0
- wl_cli/vendors/hooks/unix/pre-rebase.sample +169 -0
- wl_cli/vendors/hooks/unix/pre-receive.sample +24 -0
- wl_cli/vendors/hooks/unix/prepare-commit-msg.sample +42 -0
- wl_cli/vendors/hooks/update.sample +128 -0
- wl_cli/vendors/hooks/win/work.bat +63 -0
- wl_cli/vendors/project/.gitignore +90 -0
- wl_cli/vendors/project/.python-version +1 -0
- wl_cli/vendors/project/LICENSE +21 -0
- wl_cli/vendors/project/README.md +0 -0
- wl_cli/vendors/project/pyproject.toml +27 -0
- wl_cli/vendors/project/pyproject_no_workspace.toml +16 -0
- wl_cli/vendors/project/pyproject_workspace.toml +14 -0
- wl_cli/vendors/readme/README.md +120 -0
- wl_cli/vendors/rust/Cargo.toml +14 -0
- wl_cli/vendors/rust/lib/__init__.py +3 -0
- wl_cli/vendors/rust/lib/rust_utils.py +31 -0
- wl_cli/vendors/rust/src/lib.rs +13 -0
- wl_cli/vendors/rust/src/main.rs +3 -0
- wl_cli/vendors/rust/tests/test_rust_fallback.py +40 -0
- wl_cli/vendors/sop_doc/git-dev-sop.md +220 -0
- wl_cli/vendors/sop_doc/industrial-standard-sop.md +145 -0
- wl_cli/vendors/sop_doc/python-sop.md +342 -0
- 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)
|