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