claude-dev-env 2.4.0 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +26 -59
- package/_shared/pr-loop/scripts/_claude_permissions_common.py +84 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +6 -3
- package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +12 -2
- package/_shared/pr-loop/scripts/code_rules_gate_parts/baseline_import_isolation.py +309 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +540 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +206 -70
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/__init__.py +1 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/_repo_test_helpers.py +76 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_baseline_import_isolation.py +248 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_regression.py +309 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_running.py +91 -58
- package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -306
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +44 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +202 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/copilot_quota_constants.py +24 -24
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -107
- package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +290 -48
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +42 -2
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +36 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +100 -1
- package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -497
- package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +311 -2
- package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -301
- package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -85
- package/_shared/pr-loop/worker-spawn.md +1 -1
- package/agents/CLAUDE.md +3 -2
- package/agents/caveman.md +0 -1
- package/agents/clasp-deployment-orchestrator.md +0 -1
- package/agents/clean-coder.md +0 -1
- package/agents/code-advisor.md +0 -1
- package/agents/code-quality-agent.md +1 -2
- package/agents/code-verifier.md +36 -8
- package/agents/deep-research.md +0 -1
- package/agents/docs-agent.md +0 -1
- package/agents/git-commit-crafter.md +0 -1
- package/agents/issue-tracker.md +42 -0
- package/agents/plan-packet-validator.md +0 -1
- package/agents/pr-description-writer.md +0 -1
- package/agents/test_agent_frontmatter.py +67 -18
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +143 -141
- package/bin/CLAUDE.md +68 -5
- package/bin/codex-compat.mjs +104 -0
- package/bin/codex-compat.test.mjs +51 -0
- package/bin/ever-shipped-skills.mjs +1 -0
- package/bin/install-constants.mjs +88 -0
- package/bin/install.mjs +1138 -114
- package/bin/install.prune.test.mjs +869 -19
- package/bin/install.test.mjs +906 -2
- package/codex-capability-map.json +13 -0
- package/commands/implement.md +1 -1
- package/commands/right-size.md +1 -1
- package/docs/CLAUDE.md +1 -0
- package/docs/CODE_RULES.md +2 -0
- package/docs/codex-compatibility.md +25 -0
- package/docs/host-pool-health-monitor.md +102 -0
- package/docs/nas-ssh-invocation.md +96 -12
- package/docs/references/CLAUDE.md +4 -2
- package/docs/references/advisor-tool.md +13 -0
- package/docs/references/code-review-enforcement.md +35 -0
- package/docs/references/team-advisor-skill.md +14 -0
- package/hooks/blocking/CLAUDE.md +4 -0
- package/hooks/blocking/code_review_pr_create_gate.py +7 -3
- package/hooks/blocking/code_review_push_gate.py +9 -4
- package/hooks/blocking/code_review_stamp_directory_write_blocker.py +8 -0
- package/hooks/blocking/config/__init__.py +5 -5
- package/hooks/blocking/config/code_review_enforcement_constants.py +40 -7
- package/hooks/blocking/config/test_code_review_enforcement_constants.py +58 -0
- package/hooks/blocking/config/verified_commit_constants.py +160 -159
- package/hooks/blocking/eli11_reply_enforcer.py +479 -0
- package/hooks/blocking/gh_body_arg_blocker.py +1 -1
- package/hooks/blocking/nas_ssh_binary_enforcer.py +8 -46
- package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +256 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +24 -24
- package/hooks/blocking/shell_substitution_blocker.py +129 -0
- package/hooks/blocking/state_description_blocker.py +1 -1
- package/hooks/blocking/stop_dispatcher.py +1 -1
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +2 -3
- package/hooks/blocking/test_code_review_pr_create_gate.py +14 -0
- package/hooks/blocking/test_code_review_push_gate.py +16 -0
- package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +19 -0
- package/hooks/blocking/test_eli11_reply_enforcer.py +457 -0
- package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +231 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +10 -1
- package/hooks/blocking/test_shell_substitution_blocker.py +124 -0
- package/hooks/blocking/test_stop_dispatcher.py +23 -0
- package/hooks/blocking/test_unscoped_search_blocker.py +102 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +804 -808
- package/hooks/blocking/test_verification_verdict_store.py +54 -0
- package/hooks/blocking/test_verified_commit_gate.py +581 -581
- package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -131
- package/hooks/blocking/unscoped_search_blocker.py +391 -0
- package/hooks/blocking/verdict_directory_write_blocker.py +687 -687
- package/hooks/blocking/verification_verdict_store.py +1039 -1036
- package/hooks/blocking/verified_commit_message_accuracy_blocker.py +167 -167
- package/hooks/blocking/verifier_verdict_minter.py +280 -280
- package/hooks/git-hooks/CLAUDE.md +3 -0
- package/hooks/git-hooks/conftest.py +30 -0
- package/hooks/git-hooks/gate_utils.py +2 -2
- package/hooks/git-hooks/git_hooks_constants/__init__.py +41 -2
- package/hooks/git-hooks/pre_push.py +75 -4
- package/hooks/git-hooks/pre_push_base_reference.py +166 -0
- package/hooks/git-hooks/test_config.py +0 -15
- package/hooks/git-hooks/test_gate_utils.py +3 -15
- package/hooks/git-hooks/test_pre_commit.py +1 -15
- package/hooks/git-hooks/test_pre_push.py +257 -23
- package/hooks/git-hooks/test_pre_push_base_reference.py +339 -0
- package/hooks/hooks.json +10 -12
- package/hooks/hooks_constants/CLAUDE.md +7 -2
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -4
- package/hooks/hooks_constants/eli11_reply_enforcer_constants.py +101 -0
- package/hooks/hooks_constants/enter_worktree_prefetch_constants.py +18 -18
- package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +2 -8
- package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +48 -0
- package/hooks/hooks_constants/ruff_integration_constants.py +16 -0
- package/hooks/hooks_constants/shell_command_segments.py +82 -0
- package/hooks/hooks_constants/shell_substitution_blocker_constants.py +67 -0
- package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -6
- package/hooks/hooks_constants/test_stop_dispatcher_constants.py +1 -0
- package/hooks/hooks_constants/unscoped_search_blocker_constants.py +153 -0
- package/hooks/lifecycle/enter_worktree_origin_prefetch.py +163 -146
- package/hooks/lifecycle/test_enter_worktree_origin_prefetch.py +185 -178
- package/hooks/pyproject.toml +1 -0
- package/hooks/validators/CLAUDE.md +1 -0
- package/hooks/validators/config/__init__.py +0 -0
- package/hooks/validators/config/directory_exemption_constants.py +183 -0
- package/hooks/validators/config/test_directory_exemption_constants.py +21 -0
- package/hooks/validators/conftest.py +4 -0
- package/hooks/validators/ruff_integration.py +49 -5
- package/hooks/validators/run_all_validators.py +206 -9
- package/hooks/validators/test_directory_exemption_constants.py +185 -0
- package/hooks/validators/test_python_antipattern_checks.py +110 -5
- package/hooks/validators/test_ruff_integration.py +92 -1
- package/hooks/validators/test_run_all_validators.py +115 -68
- package/hooks/validators/test_run_all_validators_pretooluse.py +159 -1
- package/package.json +13 -3
- package/rules/CLAUDE.md +17 -22
- package/rules/agent-spawn-protocol.md +6 -6
- package/rules/anti-corollary-tests.md +1 -1
- package/rules/bdd.md +1 -1
- package/rules/cleanup-temp-files.md +10 -4
- package/rules/code-standards.md +7 -0
- package/rules/conservative-action.md +1 -5
- package/rules/context7.md +0 -4
- package/rules/destructive-commands.md +47 -0
- package/rules/doc-inventory-integrity.md +48 -0
- package/rules/doc-prose-cuts.md +58 -0
- package/rules/docstring-prose-matches-implementation.md +53 -44
- package/rules/durable-post-artifacts.md +0 -4
- package/rules/eli11-replies.md +31 -0
- package/rules/explore-thoroughly.md +4 -4
- package/rules/falsify-before-green.md +68 -0
- package/rules/file-global-constants.md +1 -1
- package/rules/filesystem-search.md +51 -0
- package/rules/gh-cli-conventions.md +27 -0
- package/rules/git-workflow.md +26 -0
- package/rules/hedging-claims.md +9 -0
- package/rules/long-horizon-autonomy.md +0 -4
- package/rules/measurement-denominators.md +48 -0
- package/rules/nas-ssh-invocation.md +23 -5
- package/rules/parallel-tools.md +2 -2
- package/rules/plain-illustrative-docstrings.md +3 -7
- package/rules/plain-language.md +2 -0
- package/rules/proof-of-work-pr-comments.md +0 -4
- package/rules/re-stage-before-commit.md +2 -0
- package/rules/research-mode.md +10 -0
- package/rules/shell-invocation.md +21 -0
- package/rules/testing.md +4 -0
- package/rules/verified-commit-gate-skip.md +3 -27
- package/rules/verify-before-asking.md +5 -0
- package/rules/windows-filesystem-safe.md +1 -1
- package/rules/workers-done-before-complete.md +4 -0
- package/scripts/CLAUDE.md +1 -0
- package/scripts/Capture-PoolHealth.ps1 +410 -0
- package/scripts/Migrate-ShellPolicy.ps1 +1 -1
- package/scripts/_code_review_test_support.py +404 -0
- package/scripts/claude_chain_runner.py +141 -1
- package/scripts/codex_capability_bridge.py +171 -0
- package/scripts/codex_compat_materializer.py +1087 -0
- package/scripts/codex_compat_watcher.py +502 -0
- package/scripts/conftest.py +16 -1
- package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -1
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +9 -0
- package/scripts/dev_env_scripts_constants/code_review_constants.py +37 -0
- package/scripts/invoke_code_review.py +11 -4
- package/scripts/resolve_worker_spawn.py +626 -626
- package/scripts/spawn_grok_batch.py +672 -672
- package/scripts/sync_to_cursor/rules.py +0 -10
- package/scripts/test_claude_chain_runner.py +131 -0
- package/scripts/test_invoke_code_review.py +85 -908
- package/scripts/test_invoke_code_review_chain.py +70 -0
- package/scripts/test_invoke_code_review_cli.py +192 -0
- package/scripts/test_invoke_code_review_contract.py +256 -0
- package/scripts/test_invoke_code_review_git.py +123 -0
- package/scripts/test_invoke_code_review_mode.py +99 -0
- package/scripts/test_resolve_worker_spawn.py +1014 -1014
- package/scripts/tests/test_code_review_constants.py +80 -0
- package/scripts/tests/test_codex_capability_bridge.py +91 -0
- package/scripts/tests/test_codex_compat_materializer.py +632 -0
- package/scripts/tests/test_codex_compat_watcher.py +599 -0
- package/scripts/tests/test_sync_to_cursor.py +0 -1
- package/skills/CLAUDE.md +2 -0
- package/skills/auditing-claude-config/SKILL.md +114 -114
- package/skills/autoconverge/SKILL.md +427 -427
- package/skills/autoconverge/reference/convergence.md +24 -3
- package/skills/autoconverge/workflow/CLAUDE.md +1 -0
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +3 -3
- package/skills/autoconverge/workflow/converge.contract.test.mjs +1263 -1263
- package/skills/autoconverge/workflow/converge.mjs +168 -1
- package/skills/autoconverge/workflow/converge.p2-advance.test.mjs +202 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +265 -265
- package/skills/bugteam/reference/copilot-gap-analysis.md +1 -1
- package/skills/closeout/SKILL.md +33 -50
- package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +8 -0
- package/skills/codex-review/scripts/run_codex_review.py +233 -1
- package/skills/codex-review/scripts/test_run_codex_review.py +189 -0
- package/skills/condensing-instructions/SKILL.md +72 -0
- package/skills/copilot-review/SKILL.md +119 -119
- package/skills/e-code-review/SKILL.md +52 -0
- package/skills/e-code-review/reference/fix.md +54 -0
- package/skills/e-code-review/reference/loop.md +43 -0
- package/skills/e-code-review/reference/low.md +57 -0
- package/skills/e-code-review/reference/medium.md +153 -0
- package/skills/e-code-review/reference/xhigh.md +182 -0
- package/skills/e-simplify/SKILL.md +97 -0
- package/skills/fresh-branch/CLAUDE.md +1 -1
- package/skills/fresh-branch/SKILL.md +5 -6
- package/skills/fresh-branch/scripts/create_fresh_branch.py +42 -24
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -3
- package/skills/fresh-branch/scripts/test_create_fresh_branch.py +30 -126
- package/skills/issue-tracker/SKILL.md +92 -0
- package/skills/issue-tracker/reference/epic-and-sub-issue-model.md +55 -0
- package/skills/issue-tracker/reference/handoff-schema.md +64 -0
- package/skills/issue-tracker/reference/operation-matrix.md +41 -0
- package/skills/orchestrator/SKILL.md +177 -22
- package/skills/orchestrator/scripts/status_gate.py +625 -0
- package/skills/orchestrator/scripts/status_gate_constants/__init__.py +1 -0
- package/skills/orchestrator/scripts/status_gate_constants/config/__init__.py +1 -0
- package/skills/orchestrator/scripts/status_gate_constants/config/constants.py +47 -0
- package/skills/orchestrator/scripts/test_status_gate.py +439 -0
- package/skills/orchestrator-refresh/SKILL.md +129 -35
- package/skills/plan-to-pr/SKILL.md +155 -0
- package/skills/plan-to-pr/reference/final-validation-tasks.md +15 -0
- package/skills/plan-to-pr/reference/model-routing.md +36 -0
- package/skills/plan-to-pr/reference/packet-contract.md +43 -0
- package/skills/plan-to-pr/reference/packet-schema.json +57 -0
- package/skills/plan-to-pr/reference/process-inventory.md +22 -0
- package/skills/plan-to-pr/reference/review-loop.md +33 -0
- package/skills/plan-to-pr/reference/run-record.schema.json +27 -0
- package/skills/plan-to-pr/reference/self-audit-tasks.md +15 -0
- package/skills/plan-to-pr/reference/task-seeds.md +14 -0
- package/skills/plan-to-pr/reference/task-ticket.md +38 -0
- package/skills/plan-to-pr/scripts/config/__init__.py +1 -0
- package/skills/plan-to-pr/scripts/config/constants.py +193 -0
- package/skills/plan-to-pr/scripts/create_packet.py +173 -0
- package/skills/plan-to-pr/scripts/test_create_packet.py +102 -0
- package/skills/plan-to-pr/scripts/test_validate_packet.py +256 -0
- package/skills/plan-to-pr/scripts/test_validate_protocol.py +135 -0
- package/skills/plan-to-pr/scripts/test_validate_run.py +158 -0
- package/skills/plan-to-pr/scripts/validate_packet.py +655 -0
- package/skills/plan-to-pr/scripts/validate_protocol.py +622 -0
- package/skills/plan-to-pr/scripts/validate_run.py +173 -0
- package/skills/plan-to-pr/test_skill_contract.py +207 -0
- package/skills/plan-to-pr/test_task_ticket_contract.py +151 -0
- package/skills/pr-converge/SKILL.md +472 -469
- package/skills/pr-converge/reference/examples.md +3 -3
- package/skills/pr-converge/reference/fix-protocol.md +1 -1
- package/skills/pr-converge/reference/ground-rules.md +7 -4
- package/skills/pr-converge/reference/multi-pr-orchestration.md +4 -1
- package/skills/pr-converge/reference/per-tick.md +5 -5
- package/skills/pr-converge/reference/progress-checklist.md +1 -1
- package/skills/pr-converge/scripts/check_convergence_gates.py +279 -279
- package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -507
- package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -84
- package/skills/pr-converge/test_step5_host_branch.py +1 -1
- package/skills/pr-fix-protocol/SKILL.md +1 -1
- package/skills/privacy-hygiene/SKILL.md +68 -68
- package/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
- package/skills/prototype/workflows/promotion.md +1 -1
- package/skills/release-notes-html/SKILL.md +164 -0
- package/skills/session-log/SKILL.md +1 -1
- package/skills/task-build/CLAUDE.md +8 -7
- package/skills/task-build/SKILL.md +16 -8
- package/skills/task-build/reference/tool-routing.md +19 -0
- package/rules/claude-md-orphan-file.md +0 -28
- package/rules/cleanup-command-forms.md +0 -23
- package/rules/code-reviews.md +0 -11
- package/rules/env-var-table-code-drift.md +0 -10
- package/rules/gh-body-file.md +0 -5
- package/rules/gh-paginate.md +0 -3
- package/rules/hook-prose-matches-detector.md +0 -15
- package/rules/no-historical-clutter.md +0 -26
- package/rules/no-inline-destructive-literals.md +0 -9
- package/rules/no-justification-noise.md +0 -61
- package/rules/package-inventory-stale-entry.md +0 -25
- package/rules/right-sized-engineering.md +0 -28
- package/rules/self-contained-docs.md +0 -17
- package/rules/shell-invocation-policy.md +0 -5
- package/rules/tdd.md +0 -7
- package/skills/closeout/reference/issue-body-templates.md +0 -108
package/bin/install.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync,
|
|
4
|
-
import { join, dirname, resolve, relative, basename } from 'node:path';
|
|
3
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, copyFileSync, unlinkSync, rmSync, rmdirSync, renameSync, realpathSync, lstatSync } from 'node:fs';
|
|
4
|
+
import { join, dirname, resolve, relative, basename, isAbsolute, extname } from 'node:path';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
6
|
import { execSync, execFileSync } from 'node:child_process';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
@@ -10,24 +10,60 @@ import { installAllGitHooks } from './git_hooks_installer.mjs';
|
|
|
10
10
|
import { installMypyIniForClaudeHooks } from './install_mypy_ini.mjs';
|
|
11
11
|
import { expandHomeDirectoryTokensInSettings } from './expand_home_directory_tokens.mjs';
|
|
12
12
|
import { EVER_SHIPPED_SKILL_NAMES } from './ever-shipped-skills.mjs';
|
|
13
|
+
import {
|
|
14
|
+
SKIPPED_SOURCE_ENTRY_NAMES,
|
|
15
|
+
SKIPPED_SOURCE_FILE_EXTENSIONS,
|
|
16
|
+
RUN_BACKUP_DIRECTORY_NAME_PATTERN,
|
|
17
|
+
MANAGED_SKILLS_DIRECTORY_NAME,
|
|
18
|
+
MANAGED_HOOKS_DIRECTORY_NAME,
|
|
19
|
+
SETTINGS_FILE_NAME,
|
|
20
|
+
MYPY_INI_FILE_NAME,
|
|
21
|
+
} from './install-constants.mjs';
|
|
13
22
|
|
|
14
23
|
const CLAUDE_HOME = join(homedir(), '.claude');
|
|
15
24
|
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
16
25
|
const MANIFEST_FILE = join(CLAUDE_HOME, '.claude-dev-env-manifest.json');
|
|
26
|
+
const MYPY_INI_INSTALL_PATH = join(homedir(), MYPY_INI_FILE_NAME);
|
|
17
27
|
const PACKAGE_NAME = 'claude-dev-env';
|
|
18
28
|
const PACKAGE_VERSION = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8')).version;
|
|
19
29
|
const packageRequire = createRequire(import.meta.url);
|
|
20
30
|
|
|
21
31
|
export const CONTENT_DIRECTORIES = ['rules', 'docs', 'commands', 'agents', 'system-prompts', 'scripts', '_shared', 'audit-rubrics'];
|
|
22
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Every top-level directory under ~/.claude the installer writes into: the
|
|
35
|
+
* content directories plus the two it fills through their own copy loops. The
|
|
36
|
+
* uninstall purge walks this list to find the root a recorded file belongs to and
|
|
37
|
+
* to drop a managed directory the purge empties, and the full-install stale-file
|
|
38
|
+
* prune walks it to give each root its own diff.
|
|
39
|
+
*/
|
|
40
|
+
export const MANAGED_TOP_LEVEL_DIRECTORY_NAMES = [
|
|
41
|
+
...CONTENT_DIRECTORIES,
|
|
42
|
+
MANAGED_SKILLS_DIRECTORY_NAME,
|
|
43
|
+
MANAGED_HOOKS_DIRECTORY_NAME,
|
|
44
|
+
];
|
|
45
|
+
|
|
23
46
|
const SKILL_MANIFEST_FILENAME = 'SKILL.md';
|
|
24
47
|
const NEVER_PRUNED_SKILL_DIRECTORIES = new Set(['_shared']);
|
|
25
48
|
const PRUNED_SKILLS_BACKUP_DIRECTORY_NAME = '.claude-dev-env-pruned';
|
|
49
|
+
const RETIRED_SKILL_REASON_LABEL = 'retired';
|
|
50
|
+
const STALE_FILE_REASON_LABEL = 'stale';
|
|
51
|
+
const MANIFEST_FILES_KEY = 'files';
|
|
52
|
+
const MANIFEST_SKILLS_KEY = 'skills';
|
|
26
53
|
|
|
27
54
|
export const CORE_INCLUDE_DIRECTORIES = [
|
|
28
55
|
'rules', 'docs', 'commands', 'agents', 'audit-rubrics', '_shared', 'scripts',
|
|
29
56
|
];
|
|
30
57
|
|
|
58
|
+
export const CORE_SKILLS = [
|
|
59
|
+
'orchestrator', 'orchestrator-refresh', 'team-advisor', 'grokify',
|
|
60
|
+
'grok-spawn',
|
|
61
|
+
'anthropic-plan', 'everything-search',
|
|
62
|
+
'privacy-hygiene',
|
|
63
|
+
'issue-tracker',
|
|
64
|
+
'recall', 'remember', 'task-build',
|
|
65
|
+
];
|
|
66
|
+
|
|
31
67
|
export function collectPackageSourceConflicts(packageDirectory) {
|
|
32
68
|
const gitConflictStatusCodes = new Set(['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU']);
|
|
33
69
|
const porcelainStatusLineMinLength = 4;
|
|
@@ -137,13 +173,13 @@ function discoverDependencyGroups() {
|
|
|
137
173
|
description: dependencyPackageJson.description || dependencyName,
|
|
138
174
|
packageRoot: dependencyRoot,
|
|
139
175
|
};
|
|
140
|
-
const skillsDirectory = join(dependencyRoot,
|
|
176
|
+
const skillsDirectory = join(dependencyRoot, MANAGED_SKILLS_DIRECTORY_NAME);
|
|
141
177
|
if (existsSync(skillsDirectory)) {
|
|
142
178
|
group.skills = readdirSync(skillsDirectory, { withFileTypes: true })
|
|
143
179
|
.filter(entry => entry.isDirectory())
|
|
144
180
|
.map(entry => entry.name);
|
|
145
181
|
}
|
|
146
|
-
const hooksDirectory = join(dependencyRoot,
|
|
182
|
+
const hooksDirectory = join(dependencyRoot, MANAGED_HOOKS_DIRECTORY_NAME);
|
|
147
183
|
if (existsSync(hooksDirectory)) {
|
|
148
184
|
const hookFiles = collectFiles(hooksDirectory)
|
|
149
185
|
.filter(file => !file.endsWith('hooks.json'))
|
|
@@ -172,16 +208,10 @@ function discoverDependencyGroups() {
|
|
|
172
208
|
const dependencyDiscovery = discoverDependencyGroups();
|
|
173
209
|
const UNRESOLVED_DEPENDENCY_NAMES = dependencyDiscovery.unresolvedDependencyNames;
|
|
174
210
|
|
|
175
|
-
const INSTALL_GROUPS = {
|
|
211
|
+
export const INSTALL_GROUPS = {
|
|
176
212
|
core: {
|
|
177
213
|
description: 'Development standards, hooks, agents, commands',
|
|
178
|
-
skills:
|
|
179
|
-
'orchestrator', 'orchestrator-refresh', 'team-advisor', 'grokify',
|
|
180
|
-
'grok-spawn',
|
|
181
|
-
'anthropic-plan', 'everything-search',
|
|
182
|
-
'privacy-hygiene',
|
|
183
|
-
'recall', 'remember', 'task-build'
|
|
184
|
-
],
|
|
214
|
+
skills: CORE_SKILLS,
|
|
185
215
|
includeDirectories: CORE_INCLUDE_DIRECTORIES,
|
|
186
216
|
includeAllHooks: true,
|
|
187
217
|
},
|
|
@@ -267,11 +297,45 @@ function detectPython() {
|
|
|
267
297
|
return null;
|
|
268
298
|
}
|
|
269
299
|
|
|
270
|
-
|
|
300
|
+
/**
|
|
301
|
+
* Report whether a source entry belongs to a build artifact the installer leaves
|
|
302
|
+
* in the package source.
|
|
303
|
+
*
|
|
304
|
+
* @param {string} entryName One directory entry's name.
|
|
305
|
+
* @returns {boolean} True when the walk skips the entry and everything under it.
|
|
306
|
+
*/
|
|
307
|
+
function isSkippedSourceEntry(entryName) {
|
|
308
|
+
if (SKIPPED_SOURCE_ENTRY_NAMES.has(entryName)) return true;
|
|
309
|
+
return SKIPPED_SOURCE_FILE_EXTENSIONS.has(extname(entryName).toLowerCase());
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* List every file under a source directory, skipping the build artifacts a
|
|
314
|
+
* contributor's local tooling writes beside the source.
|
|
315
|
+
*
|
|
316
|
+
* Running the Python suites fills the package source with `__pycache__` trees and
|
|
317
|
+
* tool caches. The `files` negations in `package.json` keep them out of the
|
|
318
|
+
* published tarball, with `.npmignore` carrying the same patterns for tooling
|
|
319
|
+
* that reads it, so an `npx` install never sees them; a local
|
|
320
|
+
* `node bin/install.mjs` reads the working tree directly, so the walk itself
|
|
321
|
+
* skips them. `SKIPPED_SOURCE_ENTRY_NAMES` and
|
|
322
|
+
* `SKIPPED_SOURCE_FILE_EXTENSIONS` name what drops out.
|
|
323
|
+
*
|
|
324
|
+
* The skip and the cleanup of artifacts an earlier install already copied are one
|
|
325
|
+
* code path. A `.pyc` a prior manifest records under any managed root sits
|
|
326
|
+
* outside the set this walk returns, so the next full install reads it as stale,
|
|
327
|
+
* moves it into that run's backup root, and drops it from the manifest the run
|
|
328
|
+
* writes.
|
|
329
|
+
*
|
|
330
|
+
* @param {string} directory The absolute source directory to walk.
|
|
331
|
+
* @returns {string[]} Absolute paths of the files the installer copies.
|
|
332
|
+
*/
|
|
333
|
+
export function collectFiles(directory) {
|
|
271
334
|
const collected = [];
|
|
272
335
|
if (!existsSync(directory)) return collected;
|
|
273
336
|
const entries = readdirSync(directory, { withFileTypes: true });
|
|
274
337
|
for (const entry of entries) {
|
|
338
|
+
if (isSkippedSourceEntry(entry.name)) continue;
|
|
275
339
|
const entryPath = join(directory, entry.name);
|
|
276
340
|
if (entry.isDirectory()) {
|
|
277
341
|
collected.push(...collectFiles(entryPath));
|
|
@@ -282,14 +346,497 @@ function collectFiles(directory) {
|
|
|
282
346
|
return collected;
|
|
283
347
|
}
|
|
284
348
|
|
|
285
|
-
|
|
349
|
+
let cachedRunBackupRoot = null;
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Return the one backup directory this install run moves pruned content into.
|
|
353
|
+
*
|
|
354
|
+
* Retired skill directories and the stale files of every managed root share a
|
|
355
|
+
* single timestamped root, so one run leaves one recovery point rather than
|
|
356
|
+
* several.
|
|
357
|
+
*
|
|
358
|
+
* @returns {string} Absolute path to the run's backup root.
|
|
359
|
+
*/
|
|
360
|
+
function currentRunBackupRoot() {
|
|
361
|
+
if (cachedRunBackupRoot === null) {
|
|
362
|
+
const runTimestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
363
|
+
cachedRunBackupRoot = join(CLAUDE_HOME, PRUNED_SKILLS_BACKUP_DIRECTORY_NAME, runTimestamp);
|
|
364
|
+
}
|
|
365
|
+
return cachedRunBackupRoot;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Remove every run backup directory sitting beside the one this run wrote, which
|
|
370
|
+
* places the sweep in ~/.claude/.claude-dev-env-pruned.
|
|
371
|
+
*
|
|
372
|
+
* A pruning install leaves one timestamped recovery point, and a recovery point
|
|
373
|
+
* is worth keeping only while it describes content close to what sits on disk.
|
|
374
|
+
* Keeping the newest run holds the recovery the user reaches for and bounds a
|
|
375
|
+
* directory that otherwise grows with every install.
|
|
376
|
+
*
|
|
377
|
+
* Only a direct child whose name carries this installer's timestamp shape is
|
|
378
|
+
* removed, so anything else under the backup directory stays. The pruned-backup
|
|
379
|
+
* directory itself stays too, since it holds the current run. A removal that
|
|
380
|
+
* fails logs a warning and the sweep continues, so retention never ends an
|
|
381
|
+
* install.
|
|
382
|
+
*
|
|
383
|
+
* @param {string} keptRunBackupRoot Absolute path to this run's backup directory.
|
|
384
|
+
* @returns {number} How many superseded run backup directories were removed.
|
|
385
|
+
*/
|
|
386
|
+
function removeSupersededRunBackups(keptRunBackupRoot) {
|
|
387
|
+
const prunedBackupDirectory = dirname(keptRunBackupRoot);
|
|
388
|
+
const keptComparisonKey = comparisonKeyForPath(keptRunBackupRoot);
|
|
389
|
+
let allEntries;
|
|
390
|
+
try {
|
|
391
|
+
allEntries = readdirSync(prunedBackupDirectory, { withFileTypes: true });
|
|
392
|
+
} catch (readError) {
|
|
393
|
+
console.warn(` Warning: could not read ${PRUNED_SKILLS_BACKUP_DIRECTORY_NAME} to retire older backups (${readError.message})`);
|
|
394
|
+
return 0;
|
|
395
|
+
}
|
|
396
|
+
let removedCount = 0;
|
|
397
|
+
for (const entry of allEntries) {
|
|
398
|
+
if (!entry.isDirectory()) continue;
|
|
399
|
+
if (!RUN_BACKUP_DIRECTORY_NAME_PATTERN.test(entry.name)) continue;
|
|
400
|
+
const runBackupPath = join(prunedBackupDirectory, entry.name);
|
|
401
|
+
if (comparisonKeyForPath(runBackupPath) === keptComparisonKey) continue;
|
|
402
|
+
try {
|
|
403
|
+
rmSync(runBackupPath, { recursive: true });
|
|
404
|
+
removedCount++;
|
|
405
|
+
} catch (removalError) {
|
|
406
|
+
console.warn(` Warning: could not remove older prune backup ${relative(CLAUDE_HOME, runBackupPath)} (${removalError.message})`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return removedCount;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Remove a directory tree that holds no file, walking depth first.
|
|
414
|
+
*
|
|
415
|
+
* `rmdirSync` removes only an empty directory, so a directory holding content
|
|
416
|
+
* survives and the walk stops there. `moveIntoRunBackup` creates the directories
|
|
417
|
+
* leading to a backup path before it renames, so a run whose every move failed
|
|
418
|
+
* leaves those directories standing with nothing inside them; clearing the run's
|
|
419
|
+
* backup root this way keeps the pruned-backup directory holding recovery points
|
|
420
|
+
* alone.
|
|
421
|
+
*
|
|
422
|
+
* @param {string} directoryPath The absolute directory to clear.
|
|
423
|
+
* @returns {void}
|
|
424
|
+
*/
|
|
425
|
+
function removeEmptyDirectoryTree(directoryPath) {
|
|
426
|
+
let childEntries;
|
|
427
|
+
try {
|
|
428
|
+
childEntries = readdirSync(directoryPath, { withFileTypes: true });
|
|
429
|
+
} catch {
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
for (const childEntry of childEntries) {
|
|
433
|
+
if (!childEntry.isDirectory()) continue;
|
|
434
|
+
removeEmptyDirectoryTree(join(directoryPath, childEntry.name));
|
|
435
|
+
}
|
|
436
|
+
try {
|
|
437
|
+
rmdirSync(directoryPath);
|
|
438
|
+
} catch {
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Retire older run backups once this run has moved content into its own.
|
|
445
|
+
*
|
|
446
|
+
* The sweep answers to what the run moved, so a recovery point is retired only
|
|
447
|
+
* once a newer one holds content. A run that moved nothing leaves every recovery
|
|
448
|
+
* point the user holds where it is, and gives up the empty directories its
|
|
449
|
+
* attempted moves created, so the pruned-backup directory holds recovery points
|
|
450
|
+
* alone.
|
|
451
|
+
*
|
|
452
|
+
* The log line names the run backup and the window it lasts, so a user reading the
|
|
453
|
+
* install output knows where to recover moved content and how long it stays.
|
|
454
|
+
*
|
|
455
|
+
* @param {string} runBackupRoot The run's timestamped backup directory.
|
|
456
|
+
* @param {boolean} didRunMoveContent Whether a move into that directory succeeded.
|
|
457
|
+
* @returns {void}
|
|
458
|
+
*/
|
|
459
|
+
export function retainNewestRunBackupOnly(runBackupRoot, didRunMoveContent) {
|
|
460
|
+
if (!existsSync(runBackupRoot)) return;
|
|
461
|
+
if (!didRunMoveContent) {
|
|
462
|
+
removeEmptyDirectoryTree(runBackupRoot);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
const removedCount = removeSupersededRunBackups(runBackupRoot);
|
|
466
|
+
if (removedCount > 0) {
|
|
467
|
+
console.log(` Prune backups: ${removedCount} older run backup(s) removed — recover moved content from ${PRUNED_SKILLS_BACKUP_DIRECTORY_NAME}/${basename(runBackupRoot)}, which stays until the next pruning install`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Move one managed path into the run's backup root, leaving it in place when the
|
|
473
|
+
* move fails.
|
|
474
|
+
*
|
|
475
|
+
* A retired skill directory and a stale file share this mover, so both land under
|
|
476
|
+
* the same timestamped recovery point and both report through the same wording.
|
|
477
|
+
* Content is moved rather than deleted, so a user-authored file that happens to
|
|
478
|
+
* sit inside a managed directory stays recoverable, and a failed move costs at
|
|
479
|
+
* most a cosmetic leftover.
|
|
480
|
+
*
|
|
481
|
+
* A path resolving outside the managed home is left alone with a warning, so a
|
|
482
|
+
* malformed record is caught here rather than inside a rename.
|
|
483
|
+
*
|
|
484
|
+
* @param {string} sourcePath Absolute path under the managed home to move.
|
|
485
|
+
* @param {string} backupRoot The run's timestamped backup directory.
|
|
486
|
+
* @param {string} backupRelativePath Path to mirror the content at inside the backup root.
|
|
487
|
+
* @param {string} reasonLabel Why the path is being moved (`retired` or `stale`).
|
|
488
|
+
* @param {string} managedHomeDirectory The managed home the source must sit under.
|
|
489
|
+
* @returns {boolean} True when the move succeeded.
|
|
490
|
+
*/
|
|
491
|
+
function moveIntoRunBackup(
|
|
492
|
+
sourcePath, backupRoot, backupRelativePath, reasonLabel, managedHomeDirectory,
|
|
493
|
+
) {
|
|
494
|
+
if (!isManagedPath(sourcePath, managedHomeDirectory)) {
|
|
495
|
+
console.warn(` Warning: leaving ${sourcePath} in place — the ${reasonLabel} path resolves outside ${managedHomeDirectory}`);
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
const backupPath = join(backupRoot, backupRelativePath);
|
|
499
|
+
const displayPath = relative(managedHomeDirectory, sourcePath);
|
|
500
|
+
const backupDisplayPath = relative(managedHomeDirectory, backupPath);
|
|
501
|
+
try {
|
|
502
|
+
mkdirSync(dirname(backupPath), { recursive: true });
|
|
503
|
+
renameSync(sourcePath, backupPath);
|
|
504
|
+
console.log(` ✗ ${displayPath} (${reasonLabel} — moved to ${backupDisplayPath})`);
|
|
505
|
+
return true;
|
|
506
|
+
} catch (moveError) {
|
|
507
|
+
console.warn(` Warning: could not move ${reasonLabel} ${displayPath} to backup, leaving in place (${moveError.message})`);
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Report whether a filesystem comparison on this platform ignores letter case.
|
|
514
|
+
*
|
|
515
|
+
* @returns {boolean} True on the platforms whose default filesystem is case-insensitive.
|
|
516
|
+
*/
|
|
517
|
+
function isCaseInsensitiveFilesystem() {
|
|
518
|
+
return process.platform === 'win32' || process.platform === 'darwin';
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Build the lookup key two absolute paths are compared through: resolved,
|
|
523
|
+
* forward-slashed, and lowercased wherever the filesystem ignores letter case.
|
|
524
|
+
*
|
|
525
|
+
* Case folding is what keeps a case-only rename safe. A skill whose readme ships
|
|
526
|
+
* as `README.md` over an installed `Readme.md` writes its bytes through the
|
|
527
|
+
* existing on-disk name, so a case-sensitive key would read the installed name as
|
|
528
|
+
* a file this run never wrote and move the freshly written content aside.
|
|
529
|
+
*
|
|
530
|
+
* The `isCaseInsensitive` option carries that decision as a value, so a test names
|
|
531
|
+
* the branch it drives and both branches stay covered on a host of either kind.
|
|
532
|
+
*
|
|
533
|
+
* @param {string} filesystemPath A path to build a comparison key for.
|
|
534
|
+
* @param {{isCaseInsensitive?: boolean}} options Whether keys fold letter case; defaults to this host's filesystem.
|
|
535
|
+
* @returns {string} The comparison key.
|
|
536
|
+
*/
|
|
537
|
+
export function comparisonKeyForPath(filesystemPath, options = {}) {
|
|
538
|
+
const { isCaseInsensitive = isCaseInsensitiveFilesystem() } = options;
|
|
539
|
+
const normalizedPath = normalizePathForComparison(resolve(filesystemPath));
|
|
540
|
+
return isCaseInsensitive ? normalizedPath.toLowerCase() : normalizedPath;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Report whether a path sits strictly inside a directory.
|
|
545
|
+
*
|
|
546
|
+
* @param {string} candidatePath The absolute path to test.
|
|
547
|
+
* @param {string} directoryPath The absolute directory to test against.
|
|
548
|
+
* @returns {boolean} True when the candidate is a descendant of the directory.
|
|
549
|
+
*/
|
|
550
|
+
function isInsideDirectory(candidatePath, directoryPath) {
|
|
551
|
+
const relativePath = relative(directoryPath, candidatePath);
|
|
552
|
+
return relativePath !== '' && !relativePath.startsWith('..') && !isAbsolute(relativePath);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Report whether an absolute path a record names resolves inside ~/.claude.
|
|
557
|
+
*
|
|
558
|
+
* The manifest and the prune both hand raw path strings to code that unlinks or
|
|
559
|
+
* renames them, and a record can arrive malformed — hand-edited, written by an
|
|
560
|
+
* installer running against a different home, or carrying a relative fragment
|
|
561
|
+
* that resolves elsewhere. This guard runs first so a path outside the managed
|
|
562
|
+
* home is skipped with a warning before any root-specific logic sees it. The
|
|
563
|
+
* stale-file prune keeps its own stricter containment test against the
|
|
564
|
+
* destination root it was handed.
|
|
565
|
+
*
|
|
566
|
+
* @param {string} candidatePath The absolute path a record names.
|
|
567
|
+
* @param {string} [managedHomeDirectory] The managed home to test against; defaults to ~/.claude.
|
|
568
|
+
* @returns {boolean} True when the path resolves under the managed home.
|
|
569
|
+
*/
|
|
570
|
+
function isManagedPath(candidatePath, managedHomeDirectory = CLAUDE_HOME) {
|
|
571
|
+
return isInsideDirectory(resolve(candidatePath), resolve(managedHomeDirectory));
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Report whether a manifest record names a file this installer writes and may
|
|
576
|
+
* therefore remove.
|
|
577
|
+
*
|
|
578
|
+
* Most of what an install writes sits under ~/.claude, and `isManagedPath`
|
|
579
|
+
* answers for all of it. `installMypyIniForClaudeHooks` writes `~/.mypy.ini` in
|
|
580
|
+
* the home directory, because that is where mypy reads its configuration, and the
|
|
581
|
+
* install records the path. Naming that one file keeps the permitted set an
|
|
582
|
+
* enumeration: every other path in the home directory stays outside it, so a
|
|
583
|
+
* record pointing anywhere else is still skipped with a warning.
|
|
584
|
+
*
|
|
585
|
+
* @param {string} candidatePath The absolute path a manifest record names.
|
|
586
|
+
* @returns {boolean} True when the installer itself writes the path.
|
|
587
|
+
*/
|
|
588
|
+
function isRemovableManifestRecord(candidatePath) {
|
|
589
|
+
if (isManagedPath(candidatePath)) return true;
|
|
590
|
+
return comparisonKeyForPath(candidatePath) === comparisonKeyForPath(MYPY_INI_INSTALL_PATH);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Return the managed top-level directory an installed path sits under.
|
|
595
|
+
*
|
|
596
|
+
* The uninstall walk-up needs a stop root, and ~/.claude is the wrong one: a walk
|
|
597
|
+
* that reaches it would try to remove the user's home configuration directory.
|
|
598
|
+
* Naming the owning managed root keeps the walk inside the tree the installer
|
|
599
|
+
* wrote, and a path under no managed root gets no walk at all.
|
|
600
|
+
*
|
|
601
|
+
* @param {string} installedFilePath The absolute path a manifest record names.
|
|
602
|
+
* @returns {string|null} The absolute managed root, or null when the path sits under none.
|
|
603
|
+
*/
|
|
604
|
+
function owningManagedRoot(installedFilePath) {
|
|
605
|
+
const resolvedPath = resolve(installedFilePath);
|
|
606
|
+
for (const directoryName of MANAGED_TOP_LEVEL_DIRECTORY_NAMES) {
|
|
607
|
+
const managedRoot = join(CLAUDE_HOME, directoryName);
|
|
608
|
+
if (isInsideDirectory(resolvedPath, managedRoot)) return managedRoot;
|
|
609
|
+
}
|
|
610
|
+
return null;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Report whether a path the prior manifest recorded is still a plain file this
|
|
615
|
+
* run may move.
|
|
616
|
+
*
|
|
617
|
+
* A path the user already deleted is skipped in silence. A directory or a symlink
|
|
618
|
+
* standing where a file was recorded is skipped with a warning, so the mover
|
|
619
|
+
* never renames a tree and never follows a link out of ~/.claude.
|
|
620
|
+
*
|
|
621
|
+
* @param {string} candidatePath The absolute path the prior manifest recorded.
|
|
622
|
+
* @returns {boolean} True when the path is a plain file that may be moved.
|
|
623
|
+
*/
|
|
624
|
+
function isMovableStaleFile(candidatePath) {
|
|
625
|
+
let entryStats;
|
|
626
|
+
try {
|
|
627
|
+
entryStats = lstatSync(candidatePath);
|
|
628
|
+
} catch {
|
|
629
|
+
return false;
|
|
630
|
+
}
|
|
631
|
+
if (entryStats.isFile()) return true;
|
|
632
|
+
console.warn(
|
|
633
|
+
` Warning: leaving ${relative(CLAUDE_HOME, candidatePath)} in place — a directory or link stands where the prior install recorded a file`,
|
|
634
|
+
);
|
|
635
|
+
return false;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Remove directories emptied by a move, walking from a directory up toward a
|
|
640
|
+
* destination root and stopping at the first directory that still holds content.
|
|
641
|
+
*
|
|
642
|
+
* `rmdirSync` removes only an empty directory, so a directory holding a user file
|
|
643
|
+
* survives. Stopping at the destination root keeps the managed root itself in
|
|
644
|
+
* place for the next install.
|
|
645
|
+
*
|
|
646
|
+
* @param {string} startDirectory The absolute directory the moved file sat in.
|
|
647
|
+
* @param {string} destinationRoot The absolute managed root to stop below.
|
|
648
|
+
* @returns {void}
|
|
649
|
+
*/
|
|
650
|
+
function removeEmptiedParentDirectories(startDirectory, destinationRoot) {
|
|
651
|
+
let currentDirectory = startDirectory;
|
|
652
|
+
while (isInsideDirectory(currentDirectory, destinationRoot)) {
|
|
653
|
+
try {
|
|
654
|
+
rmdirSync(currentDirectory);
|
|
655
|
+
} catch {
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
currentDirectory = dirname(currentDirectory);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Move files a prior install wrote under a managed root that the current install
|
|
664
|
+
* no longer writes into the run's backup root.
|
|
665
|
+
*
|
|
666
|
+
* `copyTree` overwrites and adds but never removes, so a file dropped or renamed
|
|
667
|
+
* between two installs survives inside an otherwise current directory. That
|
|
668
|
+
* leaves a skill whose modules come from one revision and whose companions come
|
|
669
|
+
* from another — for example scripts importing constants a stale constants module
|
|
670
|
+
* never defines, which fails at import with nothing in the directory to explain
|
|
671
|
+
* why. Diffing the prior manifest against the paths this run copied confines the
|
|
672
|
+
* move to content the installer itself wrote, so a runtime artifact such as a
|
|
673
|
+
* `__pycache__` entry, a user symlink, and any user-authored file all stay in
|
|
674
|
+
* place.
|
|
675
|
+
*
|
|
676
|
+
* A run whose prunes read the prior record all the way through replaces the
|
|
677
|
+
* manifest's file list wholesale, so the next diff reads as "the package stopped
|
|
678
|
+
* shipping this". Every other run unions what it wrote onto the prior record,
|
|
679
|
+
* which keeps every entry a later pruning install needs. An entry that leaves the
|
|
680
|
+
* record — a path the run rewrote under a fresh spelling, or a record already lost
|
|
681
|
+
* to an older install — sits outside every later diff, so it stays inside the tree
|
|
682
|
+
* once the package stops shipping it.
|
|
683
|
+
*
|
|
684
|
+
* A move that fails is reported through `failedPaths` so the caller records those
|
|
685
|
+
* paths in the fresh manifest. Keeping a failed path on the record holds it inside
|
|
686
|
+
* the next run's diff, which retries the move once the antivirus scanner or open
|
|
687
|
+
* editor that held the file lets go. Leaving it off the record would place the
|
|
688
|
+
* file outside every later diff and strand it inside a live skill.
|
|
689
|
+
*
|
|
690
|
+
* @param {string[]|null} priorInstalledFiles Files the prior manifest recorded, or null when unknown.
|
|
691
|
+
* @param {string[]} currentInstalledFiles Every file this run copied under the root.
|
|
692
|
+
* @param {string} destinationRoot The managed root the diff is confined to.
|
|
693
|
+
* @param {string} backupRoot The run's timestamped backup directory.
|
|
694
|
+
* @param {{isCaseInsensitive?: boolean, managedHomeDirectory?: string}} options `isCaseInsensitive`
|
|
695
|
+
* sets whether path keys fold letter case, defaulting to this host's filesystem;
|
|
696
|
+
* `managedHomeDirectory` sets the home the containment guard tests against,
|
|
697
|
+
* defaulting to ~/.claude.
|
|
698
|
+
* @returns {{prunedCount: number, failedPaths: string[]}} How many files moved, and the paths whose move failed.
|
|
699
|
+
*/
|
|
700
|
+
export function pruneStaleInstalledFiles(
|
|
701
|
+
priorInstalledFiles, currentInstalledFiles, destinationRoot, backupRoot, options = {},
|
|
702
|
+
) {
|
|
703
|
+
if (priorInstalledFiles === null) return { prunedCount: 0, failedPaths: [] };
|
|
704
|
+
const { managedHomeDirectory = CLAUDE_HOME } = options;
|
|
705
|
+
const currentFileKeys = new Set(
|
|
706
|
+
currentInstalledFiles.map(currentFile => comparisonKeyForPath(currentFile, options)),
|
|
707
|
+
);
|
|
708
|
+
const resolvedRoot = resolve(destinationRoot);
|
|
709
|
+
let prunedCount = 0;
|
|
710
|
+
const failedPaths = [];
|
|
711
|
+
for (const priorFile of priorInstalledFiles) {
|
|
712
|
+
const stalePath = resolve(priorFile);
|
|
713
|
+
if (!isInsideDirectory(stalePath, resolvedRoot)) continue;
|
|
714
|
+
if (currentFileKeys.has(comparisonKeyForPath(stalePath, options))) continue;
|
|
715
|
+
if (!isMovableStaleFile(stalePath)) continue;
|
|
716
|
+
const backupRelativePath = relative(resolvedRoot, stalePath);
|
|
717
|
+
const didMove = moveIntoRunBackup(
|
|
718
|
+
stalePath, backupRoot, backupRelativePath, STALE_FILE_REASON_LABEL, managedHomeDirectory,
|
|
719
|
+
);
|
|
720
|
+
if (!didMove) {
|
|
721
|
+
failedPaths.push(stalePath);
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
prunedCount++;
|
|
725
|
+
removeEmptiedParentDirectories(dirname(stalePath), resolvedRoot);
|
|
726
|
+
}
|
|
727
|
+
return { prunedCount, failedPaths };
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Return the existing directory entry a shipped file name would overwrite
|
|
732
|
+
* through a spelling that differs only in letter case.
|
|
733
|
+
*
|
|
734
|
+
* `copyFileSync` writes its bytes through whatever entry the filesystem resolves
|
|
735
|
+
* the destination to, so on a case-insensitive volume a package shipping
|
|
736
|
+
* `README.md` over an installed `Readme.md` fills the installed entry and leaves
|
|
737
|
+
* the earlier spelling standing. Naming that entry lets the copy rename it to the
|
|
738
|
+
* shipped spelling first. On a case-sensitive volume the two names are two files,
|
|
739
|
+
* so the answer is always null.
|
|
740
|
+
*
|
|
741
|
+
* The `isCaseInsensitive` option carries the platform decision as a value, so a
|
|
742
|
+
* test names the branch it drives and both branches stay covered on a host of
|
|
743
|
+
* either kind.
|
|
744
|
+
*
|
|
745
|
+
* @param {string} shippedFileName The file name the package ships.
|
|
746
|
+
* @param {string[]} existingEntryNames The names already in the destination directory.
|
|
747
|
+
* @param {{isCaseInsensitive?: boolean}} options Whether name comparison folds letter case; defaults to this host's filesystem.
|
|
748
|
+
* @returns {string|null} The existing entry name to rename, or null when none applies.
|
|
749
|
+
*/
|
|
750
|
+
export function caseOnlyRenameSourceName(shippedFileName, existingEntryNames, options = {}) {
|
|
751
|
+
const { isCaseInsensitive = isCaseInsensitiveFilesystem() } = options;
|
|
752
|
+
if (!isCaseInsensitive) return null;
|
|
753
|
+
if (existingEntryNames.includes(shippedFileName)) return null;
|
|
754
|
+
const foldedShippedName = shippedFileName.toLowerCase();
|
|
755
|
+
const caseOnlyMatchName = existingEntryNames.find(
|
|
756
|
+
existingName => existingName.toLowerCase() === foldedShippedName,
|
|
757
|
+
);
|
|
758
|
+
return caseOnlyMatchName === undefined ? null : caseOnlyMatchName;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* List a directory's entry names, reading each directory once and serving every
|
|
763
|
+
* later request for it from the cache.
|
|
764
|
+
*
|
|
765
|
+
* A copy run asks about the destination directory of every file it writes, and a
|
|
766
|
+
* directory holds many of them, so one listing per directory keeps the case check
|
|
767
|
+
* off the per-file syscall path.
|
|
768
|
+
*
|
|
769
|
+
* @param {string} directoryPath The absolute directory to list.
|
|
770
|
+
* @param {Map<string, string[]>} entryNamesByDirectory The run's listing cache.
|
|
771
|
+
* @returns {string[]} The directory's entry names, empty when it cannot be read.
|
|
772
|
+
*/
|
|
773
|
+
function cachedDirectoryEntryNames(directoryPath, entryNamesByDirectory) {
|
|
774
|
+
const cachedEntryNames = entryNamesByDirectory.get(directoryPath);
|
|
775
|
+
if (cachedEntryNames !== undefined) return cachedEntryNames;
|
|
776
|
+
let allEntryNames;
|
|
777
|
+
try {
|
|
778
|
+
allEntryNames = readdirSync(directoryPath);
|
|
779
|
+
} catch {
|
|
780
|
+
allEntryNames = [];
|
|
781
|
+
}
|
|
782
|
+
entryNamesByDirectory.set(directoryPath, allEntryNames);
|
|
783
|
+
return allEntryNames;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Give the destination entry the shipped file's letter case before the bytes
|
|
788
|
+
* land.
|
|
789
|
+
*
|
|
790
|
+
* `renameSync` inside one directory is atomic, so a run interrupted between the
|
|
791
|
+
* rename and the copy leaves the file present under the shipped name holding the
|
|
792
|
+
* earlier content, which the next install overwrites. A rename that fails logs a
|
|
793
|
+
* warning and the copy carries on, so the content is always current even when the
|
|
794
|
+
* name stays as it was.
|
|
795
|
+
*
|
|
796
|
+
* @param {string} destinationFilePath The absolute path the package ships to.
|
|
797
|
+
* @param {Map<string, string[]>} entryNamesByDirectory The run's listing cache.
|
|
798
|
+
* @param {{isCaseInsensitive?: boolean}} options Whether name comparison folds letter case.
|
|
799
|
+
* @returns {void}
|
|
800
|
+
*/
|
|
801
|
+
function renameCaseOnlyMatchToShippedName(destinationFilePath, entryNamesByDirectory, options) {
|
|
802
|
+
const destinationDirectory = dirname(destinationFilePath);
|
|
803
|
+
const shippedFileName = basename(destinationFilePath);
|
|
804
|
+
const existingEntryNames = cachedDirectoryEntryNames(destinationDirectory, entryNamesByDirectory);
|
|
805
|
+
const caseOnlyMatchName = caseOnlyRenameSourceName(shippedFileName, existingEntryNames, options);
|
|
806
|
+
if (caseOnlyMatchName === null) return;
|
|
807
|
+
try {
|
|
808
|
+
renameSync(join(destinationDirectory, caseOnlyMatchName), destinationFilePath);
|
|
809
|
+
} catch (renameError) {
|
|
810
|
+
console.warn(` Warning: leaving ${caseOnlyMatchName} under its installed name — the rename to ${shippedFileName} failed (${renameError.message})`);
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
existingEntryNames[existingEntryNames.indexOf(caseOnlyMatchName)] = shippedFileName;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Copy every file under a source directory into a destination directory,
|
|
818
|
+
* reporting what the run created and what it updated.
|
|
819
|
+
*
|
|
820
|
+
* A destination entry whose name differs from the shipped name only in letter
|
|
821
|
+
* case is renamed to the shipped name before the copy, so the tree carries the
|
|
822
|
+
* spelling the package ships. The directory listing behind that decision is read
|
|
823
|
+
* once per destination directory and reused for every file the run copies there.
|
|
824
|
+
*
|
|
825
|
+
* @param {string} sourceBase The absolute source directory to copy from.
|
|
826
|
+
* @param {string} destBase The absolute destination directory to copy into.
|
|
827
|
+
* @param {{isCaseInsensitive?: boolean}} options Whether name comparison folds letter case; defaults to this host's filesystem.
|
|
828
|
+
* @returns {{created: number, updated: number, paths: string[]}} The counts and the destination paths written.
|
|
829
|
+
*/
|
|
830
|
+
export function copyTree(sourceBase, destBase, options = {}) {
|
|
286
831
|
const files = collectFiles(sourceBase);
|
|
287
832
|
const stats = { created: 0, updated: 0, paths: [] };
|
|
833
|
+
const entryNamesByDirectory = new Map();
|
|
288
834
|
for (const sourceFile of files) {
|
|
289
835
|
const relativePath = relative(sourceBase, sourceFile);
|
|
290
836
|
const destFile = join(destBase, relativePath);
|
|
291
837
|
mkdirSync(dirname(destFile), { recursive: true });
|
|
292
838
|
const existed = existsSync(destFile);
|
|
839
|
+
renameCaseOnlyMatchToShippedName(destFile, entryNamesByDirectory, options);
|
|
293
840
|
copyFileSync(sourceFile, destFile);
|
|
294
841
|
stats.paths.push(destFile);
|
|
295
842
|
if (existed) {
|
|
@@ -409,7 +956,7 @@ export function managedHookScriptRelativePaths(hooksConfig) {
|
|
|
409
956
|
export function managedHookScriptRelativePathsFromSourceRoots(sourceRoots) {
|
|
410
957
|
const relativePaths = new Set();
|
|
411
958
|
for (const sourceRoot of sourceRoots) {
|
|
412
|
-
const hooksJsonPath = join(sourceRoot,
|
|
959
|
+
const hooksJsonPath = join(sourceRoot, MANAGED_HOOKS_DIRECTORY_NAME, 'hooks.json');
|
|
413
960
|
if (!existsSync(hooksJsonPath)) continue;
|
|
414
961
|
const hooksConfig = JSON.parse(readFileSync(hooksJsonPath, 'utf8'));
|
|
415
962
|
for (const relativePath of managedHookScriptRelativePaths(hooksConfig)) {
|
|
@@ -442,11 +989,15 @@ function managedPackageSourceRoots() {
|
|
|
442
989
|
* earlier installs that used a different interpreter prefix, while leaving
|
|
443
990
|
* user-authored hooks outside the managed set untouched.
|
|
444
991
|
*
|
|
445
|
-
*
|
|
992
|
+
* A command that is not a string belongs to an entry this installer never wrote,
|
|
993
|
+
* so it counts as unmanaged and its entry stays.
|
|
994
|
+
*
|
|
995
|
+
* @param {unknown} commandString The hook command from settings.json.
|
|
446
996
|
* @param {Set<string>} managedHookRelativePaths Managed script paths under hooks/.
|
|
447
997
|
* @returns {boolean} True when the command references a managed script.
|
|
448
998
|
*/
|
|
449
999
|
export function commandReferencesManagedHook(commandString, managedHookRelativePaths) {
|
|
1000
|
+
if (typeof commandString !== 'string') return false;
|
|
450
1001
|
const normalizedCommand = commandString.replace(/\\/g, '/');
|
|
451
1002
|
if (commandIsInlineManagedValidatorRunner(normalizedCommand)) {
|
|
452
1003
|
return true;
|
|
@@ -508,7 +1059,9 @@ export function commandIsInlineManagedValidatorRunner(normalizedCommand) {
|
|
|
508
1059
|
* all existing matcher groups of one event in a settings object, dropping any
|
|
509
1060
|
* group left empty. Run before the per-group merge so a managed hook that an
|
|
510
1061
|
* upgrade moves to a different matcher group is pruned from its old group rather
|
|
511
|
-
* than left to double-run. User-authored hooks outside the managed set stay
|
|
1062
|
+
* than left to double-run. User-authored hooks outside the managed set stay, and
|
|
1063
|
+
* an event whose value is not an array of groups is left as the settings file
|
|
1064
|
+
* holds it.
|
|
512
1065
|
*
|
|
513
1066
|
* @param {object} settings The parsed settings.json object (mutated in place).
|
|
514
1067
|
* @param {string} eventType The lifecycle event whose groups are pruned.
|
|
@@ -517,15 +1070,36 @@ export function commandIsInlineManagedValidatorRunner(normalizedCommand) {
|
|
|
517
1070
|
*/
|
|
518
1071
|
function pruneManagedHooksFromEvent(settings, eventType, managedHookRelativePaths) {
|
|
519
1072
|
const existingGroups = settings.hooks[eventType];
|
|
520
|
-
if (!existingGroups) return;
|
|
521
|
-
settings.hooks[eventType] =
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
1073
|
+
if (!Array.isArray(existingGroups)) return;
|
|
1074
|
+
settings.hooks[eventType] = retainedMatcherGroups(
|
|
1075
|
+
existingGroups,
|
|
1076
|
+
commandString => commandReferencesManagedHook(commandString, managedHookRelativePaths),
|
|
1077
|
+
).keptGroups;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* Give one event type a list of hook groups to merge into, warning when a value
|
|
1082
|
+
* of another shape leaves settings.json.
|
|
1083
|
+
*
|
|
1084
|
+
* The settings schema holds a list of matcher groups at each event type, so a
|
|
1085
|
+
* value of another shape has no place for the groups the package ships for that
|
|
1086
|
+
* event, and the merge writes the list in its place. The warning names the event
|
|
1087
|
+
* type so the user can recover the value from their own history.
|
|
1088
|
+
*
|
|
1089
|
+
* @param {object} settings The parsed settings.json object (mutated in place).
|
|
1090
|
+
* @param {string} eventType The lifecycle event the package ships groups for.
|
|
1091
|
+
* @returns {void}
|
|
1092
|
+
*/
|
|
1093
|
+
function startEventFromHookGroupList(settings, eventType) {
|
|
1094
|
+
const existingEventValue = settings.hooks[eventType];
|
|
1095
|
+
if (Array.isArray(existingEventValue)) return;
|
|
1096
|
+
if (existingEventValue !== undefined) {
|
|
1097
|
+
console.warn(
|
|
1098
|
+
` Warning: replacing the ${eventType} value in settings.json — it held a value that`
|
|
1099
|
+
+ ' was not a list of hook groups. Recover it from your own history.'
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1102
|
+
settings.hooks[eventType] = [];
|
|
529
1103
|
}
|
|
530
1104
|
|
|
531
1105
|
/**
|
|
@@ -538,6 +1112,13 @@ function pruneManagedHooksFromEvent(settings, eventType, managedHookRelativePath
|
|
|
538
1112
|
* command are expanded to absolute home paths so hosts that require referenced
|
|
539
1113
|
* env vars at load time (a third-party host on Windows) can execute them.
|
|
540
1114
|
*
|
|
1115
|
+
* The merge reads a settings file another tool or a person may have written, so
|
|
1116
|
+
* it recognizes the shapes it writes and steps around the rest: a `hooks` value
|
|
1117
|
+
* that is not an object starts from an empty map, and a group carrying no hooks
|
|
1118
|
+
* array contributes no user entries. At an event type the package ships groups
|
|
1119
|
+
* for, a value that is not a list of hook groups is replaced by that list, with
|
|
1120
|
+
* a warning naming the event type.
|
|
1121
|
+
*
|
|
541
1122
|
* @param {object} settings The parsed settings.json object (mutated in place).
|
|
542
1123
|
* @param {{hooks: object}} hooksConfig Parsed hooks.json.
|
|
543
1124
|
* @param {string} pluginRootDir Directory ${CLAUDE_PLUGIN_ROOT} resolves to
|
|
@@ -548,10 +1129,10 @@ function pruneManagedHooksFromEvent(settings, eventType, managedHookRelativePath
|
|
|
548
1129
|
export function mergeHooksIntoSettings(settings, hooksConfig, pluginRootDir, pythonCommand) {
|
|
549
1130
|
const managedHookRelativePaths = managedHookScriptRelativePaths(hooksConfig);
|
|
550
1131
|
const pluginRootForward = pluginRootDir.replace(/\\/g, '/');
|
|
551
|
-
if (!settings.hooks) settings.hooks = {};
|
|
1132
|
+
if (!settings.hooks || typeof settings.hooks !== 'object') settings.hooks = {};
|
|
552
1133
|
let groupCount = 0;
|
|
553
1134
|
for (const [eventType, matcherGroups] of Object.entries(hooksConfig.hooks)) {
|
|
554
|
-
|
|
1135
|
+
startEventFromHookGroupList(settings, eventType);
|
|
555
1136
|
pruneManagedHooksFromEvent(settings, eventType, managedHookRelativePaths);
|
|
556
1137
|
for (const sourceGroup of matcherGroups) {
|
|
557
1138
|
const rewrittenHooks = sourceGroup.hooks.map(hook => {
|
|
@@ -564,12 +1145,12 @@ export function mergeHooksIntoSettings(settings, hooksConfig, pluginRootDir, pyt
|
|
|
564
1145
|
return { ...hook, command };
|
|
565
1146
|
});
|
|
566
1147
|
const existingIndex = settings.hooks[eventType].findIndex(
|
|
567
|
-
group => group
|
|
1148
|
+
group => group?.matcher === sourceGroup.matcher
|
|
568
1149
|
);
|
|
569
1150
|
if (existingIndex >= 0) {
|
|
570
1151
|
const existing = settings.hooks[eventType][existingIndex];
|
|
571
|
-
const userHooks = existing.
|
|
572
|
-
hook => !commandReferencesManagedHook(hook
|
|
1152
|
+
const userHooks = (groupHookEntries(existing) || []).filter(
|
|
1153
|
+
hook => !commandReferencesManagedHook(hook?.command, managedHookRelativePaths)
|
|
573
1154
|
);
|
|
574
1155
|
settings.hooks[eventType][existingIndex] = {
|
|
575
1156
|
...existing,
|
|
@@ -591,39 +1172,217 @@ export function mergeHooksIntoSettings(settings, hooksConfig, pluginRootDir, pyt
|
|
|
591
1172
|
* commandReferencesManagedHook so entries written with any home-path style
|
|
592
1173
|
* ($HOME, ~, ${HOME}, or absolute) and any path separator are pruned. Matcher
|
|
593
1174
|
* groups left empty are dropped, and an empty hooks map is removed entirely.
|
|
594
|
-
* User-authored hooks outside the managed set are preserved untouched
|
|
1175
|
+
* User-authored hooks outside the managed set are preserved untouched, and an
|
|
1176
|
+
* event whose value is not an array of groups is left as the settings file holds
|
|
1177
|
+
* it.
|
|
595
1178
|
*
|
|
596
1179
|
* @param {object} settings The parsed settings.json object (mutated in place).
|
|
597
1180
|
* @param {Set<string>} managedHookRelativePaths Managed script paths under hooks/.
|
|
598
1181
|
* @returns {void}
|
|
599
1182
|
*/
|
|
600
1183
|
export function pruneManagedHooksFromSettings(settings, managedHookRelativePaths) {
|
|
601
|
-
if (!settings.hooks) return;
|
|
1184
|
+
if (!settings.hooks || typeof settings.hooks !== 'object') return;
|
|
602
1185
|
for (const [eventType, matcherGroups] of Object.entries(settings.hooks)) {
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
.filter(group => group.hooks.length > 0);
|
|
611
|
-
if (settings.hooks[eventType].length === 0) delete settings.hooks[eventType];
|
|
1186
|
+
if (!Array.isArray(matcherGroups)) continue;
|
|
1187
|
+
const { keptGroups } = retainedMatcherGroups(
|
|
1188
|
+
matcherGroups,
|
|
1189
|
+
commandString => commandReferencesManagedHook(commandString, managedHookRelativePaths),
|
|
1190
|
+
);
|
|
1191
|
+
settings.hooks[eventType] = keptGroups;
|
|
1192
|
+
if (keptGroups.length === 0) delete settings.hooks[eventType];
|
|
612
1193
|
}
|
|
613
1194
|
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
|
614
1195
|
}
|
|
615
1196
|
|
|
1197
|
+
/**
|
|
1198
|
+
* Build the hook script paths a prior install wrote under ~/.claude/hooks that
|
|
1199
|
+
* this run leaves unwritten, each relative to that hooks root.
|
|
1200
|
+
*
|
|
1201
|
+
* The set comes from the manifest diff alone, so it names this installer's own
|
|
1202
|
+
* retired scripts and nothing else: a script the run still writes stays out of it,
|
|
1203
|
+
* and a path no install of ours ever recorded never enters it. That is what keeps
|
|
1204
|
+
* a user-authored hook out of reach of the settings prune.
|
|
1205
|
+
*
|
|
1206
|
+
* @param {string[]|null} priorInstalledFiles Files the prior manifest recorded, or null when unknown.
|
|
1207
|
+
* @param {string[]} currentInstalledFiles Every file this run copied.
|
|
1208
|
+
* @param {string} hooksRoot The absolute installed hooks directory.
|
|
1209
|
+
* @returns {Set<string>} Forward-slash relative script paths under the hooks root.
|
|
1210
|
+
*/
|
|
1211
|
+
export function retiredManagedHookRelativePaths(
|
|
1212
|
+
priorInstalledFiles, currentInstalledFiles, hooksRoot,
|
|
1213
|
+
) {
|
|
1214
|
+
const retiredRelativePaths = new Set();
|
|
1215
|
+
if (priorInstalledFiles === null) return retiredRelativePaths;
|
|
1216
|
+
const currentFileKeys = new Set(
|
|
1217
|
+
currentInstalledFiles.map(currentFile => comparisonKeyForPath(currentFile)),
|
|
1218
|
+
);
|
|
1219
|
+
const resolvedHooksRoot = resolve(hooksRoot);
|
|
1220
|
+
for (const priorFile of priorInstalledFiles) {
|
|
1221
|
+
const priorPath = resolve(priorFile);
|
|
1222
|
+
if (!isInsideDirectory(priorPath, resolvedHooksRoot)) continue;
|
|
1223
|
+
if (currentFileKeys.has(comparisonKeyForPath(priorPath))) continue;
|
|
1224
|
+
retiredRelativePaths.add(relative(resolvedHooksRoot, priorPath).replace(/\\/g, '/'));
|
|
1225
|
+
}
|
|
1226
|
+
return retiredRelativePaths;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/**
|
|
1230
|
+
* Report whether a settings.json hook command runs one of the retired managed
|
|
1231
|
+
* hook scripts.
|
|
1232
|
+
*
|
|
1233
|
+
* The anchored `/.claude/hooks/<relative>` tail is the same test the merge uses to
|
|
1234
|
+
* tell this installer's entries from a user's, so a command whose path is a
|
|
1235
|
+
* retired tail plus a suffix stays outside the set. The inline validators-runner
|
|
1236
|
+
* shape sits outside this test on purpose: it names no script, so no manifest
|
|
1237
|
+
* record can retire it, and the merge writes it fresh on every run.
|
|
1238
|
+
*
|
|
1239
|
+
* A command that is not a string belongs to an entry this installer never wrote —
|
|
1240
|
+
* a hand-edited settings.json, or a third-party entry carrying another shape — so
|
|
1241
|
+
* it names no retired script and its entry stays.
|
|
1242
|
+
*
|
|
1243
|
+
* @param {unknown} commandString The hook command from settings.json.
|
|
1244
|
+
* @param {Set<string>} retiredHookRelativePaths Retired script paths under hooks/.
|
|
1245
|
+
* @returns {boolean} True when the command runs a retired managed script.
|
|
1246
|
+
*/
|
|
1247
|
+
function commandReferencesRetiredHook(commandString, retiredHookRelativePaths) {
|
|
1248
|
+
if (typeof commandString !== 'string') return false;
|
|
1249
|
+
const normalizedCommand = commandString.replace(/\\/g, '/');
|
|
1250
|
+
for (const relativePath of retiredHookRelativePaths) {
|
|
1251
|
+
if (commandTailEndsAtManagedHook(normalizedCommand, relativePath)) return true;
|
|
1252
|
+
}
|
|
1253
|
+
return false;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/**
|
|
1257
|
+
* Keep the matcher groups of one event, dropping each hook a predicate names and
|
|
1258
|
+
* each group the drop leaves empty.
|
|
1259
|
+
*
|
|
1260
|
+
* Every settings walk shares this pass, so a settings.json a person or another
|
|
1261
|
+
* tool wrote meets one set of shape rules. A group carrying no hooks array is
|
|
1262
|
+
* handed back untouched, and an entry whose command is not a string reaches the
|
|
1263
|
+
* predicate as-is — each predicate reads a non-string command as an entry this
|
|
1264
|
+
* installer never wrote — so a shape this installer does not recognize survives.
|
|
1265
|
+
*
|
|
1266
|
+
* @param {object[]} matcherGroups The event's matcher groups from settings.json.
|
|
1267
|
+
* @param {(commandString: unknown) => boolean} shouldRemoveHook Names the hooks that leave.
|
|
1268
|
+
* @returns {{keptGroups: object[], removedCount: number}} The surviving groups and how many hooks left.
|
|
1269
|
+
*/
|
|
1270
|
+
function retainedMatcherGroups(matcherGroups, shouldRemoveHook) {
|
|
1271
|
+
const keptGroups = [];
|
|
1272
|
+
let removedCount = 0;
|
|
1273
|
+
for (const group of matcherGroups) {
|
|
1274
|
+
const hookEntries = groupHookEntries(group);
|
|
1275
|
+
if (hookEntries === null) {
|
|
1276
|
+
keptGroups.push(group);
|
|
1277
|
+
continue;
|
|
1278
|
+
}
|
|
1279
|
+
const keptHooks = hookEntries.filter(hook => !shouldRemoveHook(hook?.command));
|
|
1280
|
+
removedCount += hookEntries.length - keptHooks.length;
|
|
1281
|
+
if (keptHooks.length > 0) keptGroups.push({ ...group, hooks: keptHooks });
|
|
1282
|
+
}
|
|
1283
|
+
return { keptGroups, removedCount };
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
/**
|
|
1287
|
+
* Return the hook entries one matcher group holds, or null when the group carries
|
|
1288
|
+
* no hooks array.
|
|
1289
|
+
*
|
|
1290
|
+
* The installer writes every group with a `hooks` array, and a settings.json it
|
|
1291
|
+
* reads back can hold a group of any shape. Reading the array through one
|
|
1292
|
+
* accessor lets each walk recognize the shape it wrote and hand every other shape
|
|
1293
|
+
* back untouched.
|
|
1294
|
+
*
|
|
1295
|
+
* @param {unknown} group One matcher group read from settings.json.
|
|
1296
|
+
* @returns {object[]|null} The group's hook entries, or null when it holds none.
|
|
1297
|
+
*/
|
|
1298
|
+
function groupHookEntries(group) {
|
|
1299
|
+
return Array.isArray(group?.hooks) ? group.hooks : null;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
/**
|
|
1303
|
+
* Strip every retired managed hook from a settings object in memory.
|
|
1304
|
+
*
|
|
1305
|
+
* The walk covers each event type the settings file holds rather than the ones the
|
|
1306
|
+
* current hooks.json names, so an entry under an event type the package stopped
|
|
1307
|
+
* shipping is reached too. An event type left with no groups is dropped.
|
|
1308
|
+
*
|
|
1309
|
+
* @param {object} settings The parsed settings.json object (mutated in place).
|
|
1310
|
+
* @param {Set<string>} retiredHookRelativePaths Retired script paths under hooks/.
|
|
1311
|
+
* @returns {number} How many hook entries were removed.
|
|
1312
|
+
*/
|
|
1313
|
+
function stripRetiredHookEntries(settings, retiredHookRelativePaths) {
|
|
1314
|
+
let removedCount = 0;
|
|
1315
|
+
for (const [eventType, matcherGroups] of Object.entries(settings.hooks)) {
|
|
1316
|
+
if (!Array.isArray(matcherGroups)) continue;
|
|
1317
|
+
const eventOutcome = retainedMatcherGroups(
|
|
1318
|
+
matcherGroups,
|
|
1319
|
+
commandString => commandReferencesRetiredHook(commandString, retiredHookRelativePaths),
|
|
1320
|
+
);
|
|
1321
|
+
removedCount += eventOutcome.removedCount;
|
|
1322
|
+
if (eventOutcome.keptGroups.length === 0) {
|
|
1323
|
+
delete settings.hooks[eventType];
|
|
1324
|
+
continue;
|
|
1325
|
+
}
|
|
1326
|
+
settings.hooks[eventType] = eventOutcome.keptGroups;
|
|
1327
|
+
}
|
|
1328
|
+
return removedCount;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
/**
|
|
1332
|
+
* Remove every settings.json entry that runs a retired managed hook script,
|
|
1333
|
+
* writing the file only when an entry left it.
|
|
1334
|
+
*
|
|
1335
|
+
* A run that retires no hook leaves settings.json byte-identical, so an install
|
|
1336
|
+
* touches the user's settings for a reason a reader can name. A settings file the
|
|
1337
|
+
* installer cannot parse is left alone with a warning.
|
|
1338
|
+
*
|
|
1339
|
+
* @param {string} settingsPath The absolute settings.json path.
|
|
1340
|
+
* @param {Set<string>} retiredHookRelativePaths Retired script paths under hooks/.
|
|
1341
|
+
* @returns {number} How many hook entries were removed.
|
|
1342
|
+
*/
|
|
1343
|
+
export function pruneRetiredHookEntriesFromSettings(settingsPath, retiredHookRelativePaths) {
|
|
1344
|
+
if (retiredHookRelativePaths.size === 0) return 0;
|
|
1345
|
+
if (!existsSync(settingsPath)) return 0;
|
|
1346
|
+
let settings;
|
|
1347
|
+
try {
|
|
1348
|
+
settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
1349
|
+
} catch (parseError) {
|
|
1350
|
+
console.warn(` Warning: leaving settings.json as it stands — the file holds JSON the installer cannot read (${parseError.message})`);
|
|
1351
|
+
return 0;
|
|
1352
|
+
}
|
|
1353
|
+
if (!settings.hooks || typeof settings.hooks !== 'object') return 0;
|
|
1354
|
+
const removedCount = stripRetiredHookEntries(settings, retiredHookRelativePaths);
|
|
1355
|
+
if (removedCount === 0) return 0;
|
|
1356
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 4) + '\n');
|
|
1357
|
+
return removedCount;
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
/**
|
|
1361
|
+
* Merge one package source root's hook groups into ~/.claude/settings.json.
|
|
1362
|
+
*
|
|
1363
|
+
* A settings file holding anything other than a JSON object ends the install with
|
|
1364
|
+
* a message naming the file, so the run stops before it writes hook entries onto
|
|
1365
|
+
* a shape the harness cannot read.
|
|
1366
|
+
*
|
|
1367
|
+
* @param {string} hooksSourceRoot The package root whose hooks/hooks.json is merged.
|
|
1368
|
+
* @param {string} pythonCommand Interpreter command that replaces python3.
|
|
1369
|
+
* @returns {number} Count of matcher groups merged.
|
|
1370
|
+
*/
|
|
616
1371
|
function mergeHooks(hooksSourceRoot, pythonCommand) {
|
|
617
|
-
const hooksJsonPath = join(hooksSourceRoot,
|
|
1372
|
+
const hooksJsonPath = join(hooksSourceRoot, MANAGED_HOOKS_DIRECTORY_NAME, 'hooks.json');
|
|
618
1373
|
if (!existsSync(hooksJsonPath)) return 0;
|
|
619
1374
|
const hooksConfig = JSON.parse(readFileSync(hooksJsonPath, 'utf8'));
|
|
620
|
-
const settingsPath = join(CLAUDE_HOME,
|
|
1375
|
+
const settingsPath = join(CLAUDE_HOME, SETTINGS_FILE_NAME);
|
|
621
1376
|
let settings = {};
|
|
622
1377
|
if (existsSync(settingsPath)) {
|
|
623
1378
|
const raw = readFileSync(settingsPath, 'utf8').trim();
|
|
624
1379
|
if (raw) {
|
|
625
1380
|
try { settings = JSON.parse(raw); }
|
|
626
1381
|
catch { console.error(' ERROR: settings.json is malformed JSON. Fix it and rerun.'); process.exit(1); }
|
|
1382
|
+
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
|
|
1383
|
+
console.error(' ERROR: settings.json holds a value other than a JSON object. Fix it and rerun.');
|
|
1384
|
+
process.exit(1);
|
|
1385
|
+
}
|
|
627
1386
|
}
|
|
628
1387
|
}
|
|
629
1388
|
const groupCount = mergeHooksIntoSettings(settings, hooksConfig, CLAUDE_HOME, pythonCommand);
|
|
@@ -632,32 +1391,111 @@ function mergeHooks(hooksSourceRoot, pythonCommand) {
|
|
|
632
1391
|
}
|
|
633
1392
|
|
|
634
1393
|
function writeManifest(installedFiles, skillNames) {
|
|
635
|
-
const manifest = {
|
|
1394
|
+
const manifest = {
|
|
1395
|
+
package: PACKAGE_NAME,
|
|
1396
|
+
version: PACKAGE_VERSION,
|
|
1397
|
+
installedAt: new Date().toISOString(),
|
|
1398
|
+
[MANIFEST_FILES_KEY]: installedFiles,
|
|
1399
|
+
};
|
|
636
1400
|
if (skillNames) {
|
|
637
|
-
manifest
|
|
1401
|
+
manifest[MANIFEST_SKILLS_KEY] = skillNames;
|
|
638
1402
|
}
|
|
639
1403
|
writeFileSync(MANIFEST_FILE, JSON.stringify(manifest, null, 2) + '\n');
|
|
640
1404
|
}
|
|
641
1405
|
|
|
642
1406
|
/**
|
|
643
|
-
* Read the
|
|
1407
|
+
* Read the file list and the skill list the previous install recorded, in one
|
|
1408
|
+
* parse of the manifest.
|
|
1409
|
+
*
|
|
1410
|
+
* A full install's manifest holds thousands of path strings, so the record is
|
|
1411
|
+
* read and parsed once and both lists are handed back together.
|
|
644
1412
|
*
|
|
645
|
-
*
|
|
646
|
-
* so a caller
|
|
647
|
-
* on the ever-shipped set to find retired skills
|
|
1413
|
+
* Either list is null when the manifest is missing, unreadable, or holds no array
|
|
1414
|
+
* at that key, so a caller treats every such case as "no prior record": the skills
|
|
1415
|
+
* list leans on the ever-shipped set to find retired skills, and the files list
|
|
1416
|
+
* holds the stale-file prune for that run rather than guessing at what a prior
|
|
1417
|
+
* install wrote. A `--update` run purges the manifest before reinstalling, so this
|
|
1418
|
+
* read happens at the top of `install()` while the record is still on disk.
|
|
648
1419
|
*
|
|
649
|
-
* @returns {string[]|null} The
|
|
1420
|
+
* @returns {{files: string[]|null, skills: string[]|null}} The recorded lists, each null when absent.
|
|
650
1421
|
*/
|
|
651
|
-
function
|
|
652
|
-
|
|
1422
|
+
function readPriorManifestArrays() {
|
|
1423
|
+
const missingRecord = { files: null, skills: null };
|
|
1424
|
+
if (!existsSync(MANIFEST_FILE)) return missingRecord;
|
|
653
1425
|
try {
|
|
654
1426
|
const priorManifest = JSON.parse(readFileSync(MANIFEST_FILE, 'utf8'));
|
|
655
|
-
return
|
|
1427
|
+
return {
|
|
1428
|
+
files: arrayOrNull(priorManifest[MANIFEST_FILES_KEY]),
|
|
1429
|
+
skills: arrayOrNull(priorManifest[MANIFEST_SKILLS_KEY]),
|
|
1430
|
+
};
|
|
656
1431
|
} catch {
|
|
657
|
-
return
|
|
1432
|
+
return missingRecord;
|
|
658
1433
|
}
|
|
659
1434
|
}
|
|
660
1435
|
|
|
1436
|
+
/**
|
|
1437
|
+
* Return a recorded manifest value when it is an array, and null otherwise.
|
|
1438
|
+
*
|
|
1439
|
+
* @param {unknown} recordedEntries The value read at a manifest key.
|
|
1440
|
+
* @returns {string[]|null} The array, or null when the key holds anything else.
|
|
1441
|
+
*/
|
|
1442
|
+
function arrayOrNull(recordedEntries) {
|
|
1443
|
+
return Array.isArray(recordedEntries) ? recordedEntries : null;
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
/**
|
|
1447
|
+
* Merge two path lists into one, keyed on `comparisonKeyForPath`, with the
|
|
1448
|
+
* current run's spelling winning a collision.
|
|
1449
|
+
*
|
|
1450
|
+
* The current run just wrote the file, so its casing is the on-disk truth; a path
|
|
1451
|
+
* carried over from an earlier record keeps its position in the list and takes the
|
|
1452
|
+
* fresh spelling. One spelling per file keeps the record and the uninstall purge
|
|
1453
|
+
* loop the size of the tree they describe.
|
|
1454
|
+
*
|
|
1455
|
+
* @param {string[]} carriedOverPaths Paths sourced from an earlier record.
|
|
1456
|
+
* @param {string[]} currentRunPaths Paths this run wrote, whose spelling wins.
|
|
1457
|
+
* @returns {string[]} The merged list, one entry per comparison key.
|
|
1458
|
+
*/
|
|
1459
|
+
function unionOnComparisonKey(carriedOverPaths, currentRunPaths) {
|
|
1460
|
+
const pathByComparisonKey = new Map();
|
|
1461
|
+
for (const mergedPath of [...carriedOverPaths, ...currentRunPaths]) {
|
|
1462
|
+
pathByComparisonKey.set(comparisonKeyForPath(mergedPath), mergedPath);
|
|
1463
|
+
}
|
|
1464
|
+
return [...pathByComparisonKey.values()];
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
/**
|
|
1468
|
+
* Merge two skill-name lists into one, deduped by exact name.
|
|
1469
|
+
*
|
|
1470
|
+
* A skill name is a directory name the package ships, compared exactly the way
|
|
1471
|
+
* `pruneRetiredSkills` compares it, so this union stays case-aware and applies
|
|
1472
|
+
* none of the path normalization `unionOnComparisonKey` uses.
|
|
1473
|
+
*
|
|
1474
|
+
* @param {string[]} carriedOverSkillNames Skill names sourced from an earlier record.
|
|
1475
|
+
* @param {string[]} currentRunSkillNames Skill names this run installed.
|
|
1476
|
+
* @returns {string[]} The merged list, one entry per name.
|
|
1477
|
+
*/
|
|
1478
|
+
function unionOfSkillNames(carriedOverSkillNames, currentRunSkillNames) {
|
|
1479
|
+
return [...new Set([...carriedOverSkillNames, ...currentRunSkillNames])];
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
/**
|
|
1483
|
+
* Build the file list a full install records: everything this run installed plus
|
|
1484
|
+
* every stale path whose move failed and that still sits on disk.
|
|
1485
|
+
*
|
|
1486
|
+
* A failed move keeps the file inside a live skill, so the record carries it into
|
|
1487
|
+
* the next run's diff for a retry. A path that vanished between the stat guard
|
|
1488
|
+
* and the rename is dropped, which keeps a phantom out of every later manifest.
|
|
1489
|
+
*
|
|
1490
|
+
* @param {string[]} installedFiles Every file this run wrote.
|
|
1491
|
+
* @param {string[]} failedPrunePaths Stale paths whose move into the backup failed.
|
|
1492
|
+
* @returns {string[]} The deduped file list to record.
|
|
1493
|
+
*/
|
|
1494
|
+
function manifestFilesWithFailedPrunes(installedFiles, failedPrunePaths) {
|
|
1495
|
+
const survivingFailedPaths = failedPrunePaths.filter(failedPath => existsSync(failedPath));
|
|
1496
|
+
return unionOnComparisonKey(survivingFailedPaths, installedFiles);
|
|
1497
|
+
}
|
|
1498
|
+
|
|
661
1499
|
/**
|
|
662
1500
|
* Move retired skill directories a prior install left under ~/.claude/skills into
|
|
663
1501
|
* a timestamped backup directory rather than deleting them.
|
|
@@ -673,58 +1511,156 @@ function readPriorManifestSkills() {
|
|
|
673
1511
|
* ~/.claude/skills/_shared, are left in place.
|
|
674
1512
|
*
|
|
675
1513
|
* Each pruned directory is renamed to
|
|
676
|
-
* ~/.claude/.claude-dev-env-pruned/<timestamp
|
|
677
|
-
* outside ~/.claude/skills, so a moved directory is never re-discovered as a
|
|
678
|
-
* skill — under one shared timestamp per run.
|
|
679
|
-
*
|
|
1514
|
+
* ~/.claude/.claude-dev-env-pruned/<timestamp>/skills/<skill-name>/ — a backup
|
|
1515
|
+
* root outside ~/.claude/skills, so a moved directory is never re-discovered as a
|
|
1516
|
+
* skill — under one shared timestamp per run. That mirrors the ~/.claude layout
|
|
1517
|
+
* the per-root stale-file prune writes, so one recovery point reads as a copy of
|
|
1518
|
+
* the tree it came from. The recovery window runs until the next pruning install:
|
|
1519
|
+
* `retainNewestRunBackupOnly` keeps that run's backup and retires the rest, so a
|
|
1520
|
+
* user recovers a wrongly-matched directory from the newest backup on disk. One
|
|
1521
|
+
* directory whose rename fails
|
|
680
1522
|
* (for example a read-only file or a cross-device move) is logged and left in
|
|
681
1523
|
* place, never deleted, so a prune failure costs at most a cosmetic leftover.
|
|
682
1524
|
*
|
|
683
1525
|
* @param {Set<string>} installedSkillNames Skill names this install just wrote.
|
|
684
1526
|
* @param {string[]|null} priorManifestSkills The prior manifest's skill names, or null.
|
|
1527
|
+
* @returns {number} How many retired skill directories reached the backup root.
|
|
685
1528
|
*/
|
|
686
1529
|
function pruneRetiredSkills(installedSkillNames, priorManifestSkills) {
|
|
687
|
-
const skillsDirectory = join(CLAUDE_HOME,
|
|
688
|
-
if (!existsSync(skillsDirectory)) return;
|
|
1530
|
+
const skillsDirectory = join(CLAUDE_HOME, MANAGED_SKILLS_DIRECTORY_NAME);
|
|
1531
|
+
if (!existsSync(skillsDirectory)) return 0;
|
|
689
1532
|
const retiredSkillNames = new Set(
|
|
690
1533
|
[...EVER_SHIPPED_SKILL_NAMES].filter(skillName => !installedSkillNames.has(skillName))
|
|
691
1534
|
);
|
|
692
1535
|
const priorSkillNames = new Set(priorManifestSkills || []);
|
|
693
|
-
const
|
|
694
|
-
const backupRoot = join(CLAUDE_HOME, PRUNED_SKILLS_BACKUP_DIRECTORY_NAME, runTimestamp);
|
|
1536
|
+
const backupRoot = currentRunBackupRoot();
|
|
695
1537
|
const existingSkillDirs = readdirSync(skillsDirectory, { withFileTypes: true })
|
|
696
1538
|
.filter(entry => entry.isDirectory());
|
|
1539
|
+
let movedDirectoryCount = 0;
|
|
697
1540
|
for (const skillDir of existingSkillDirs) {
|
|
698
1541
|
const skillName = skillDir.name;
|
|
699
1542
|
if (NEVER_PRUNED_SKILL_DIRECTORIES.has(skillName)) continue;
|
|
700
1543
|
if (installedSkillNames.has(skillName)) continue;
|
|
701
1544
|
const isPruneCandidate = priorSkillNames.has(skillName) || retiredSkillNames.has(skillName);
|
|
702
1545
|
if (!isPruneCandidate) continue;
|
|
703
|
-
|
|
1546
|
+
const didMove = moveIntoRunBackup(
|
|
1547
|
+
join(skillsDirectory, skillName),
|
|
1548
|
+
backupRoot,
|
|
1549
|
+
join(MANAGED_SKILLS_DIRECTORY_NAME, skillName),
|
|
1550
|
+
RETIRED_SKILL_REASON_LABEL,
|
|
1551
|
+
CLAUDE_HOME,
|
|
1552
|
+
);
|
|
1553
|
+
if (didMove) movedDirectoryCount++;
|
|
704
1554
|
}
|
|
1555
|
+
return movedDirectoryCount;
|
|
705
1556
|
}
|
|
706
1557
|
|
|
707
1558
|
/**
|
|
708
|
-
* Move
|
|
709
|
-
*
|
|
1559
|
+
* Move every file a prior install wrote under a managed root that this run leaves
|
|
1560
|
+
* unwritten into the run's backup root, one call per root.
|
|
1561
|
+
*
|
|
1562
|
+
* `copyTree` adds and overwrites but never removes, so every managed root carries
|
|
1563
|
+
* the same drift the skills root does. One call per root hands the containment
|
|
1564
|
+
* guard and the emptied-parent walk the root that owns each file, and each root's
|
|
1565
|
+
* content lands under `<backupRoot>/<root-name>/<relative>`, so the recovery point
|
|
1566
|
+
* mirrors ~/.claude.
|
|
710
1567
|
*
|
|
711
|
-
*
|
|
1568
|
+
* Nothing moves unless a prior install recorded it. A user-authored file, a
|
|
1569
|
+
* runtime artifact, and a recorded path under no managed root — ~/.claude/CLAUDE.md,
|
|
1570
|
+
* settings.json, the manifest itself, and ~/.mypy.ini outside the home — all sit
|
|
1571
|
+
* outside every root's diff and stay where they are. ~/.claude/_shared and
|
|
1572
|
+
* ~/.claude/skills/_shared are distinct absolute paths, so the `_shared` root call
|
|
1573
|
+
* and the skills root call each see their own files and neither sees the other's.
|
|
1574
|
+
*
|
|
1575
|
+
* @param {string[]|null} priorInstalledFiles Files the prior manifest recorded, or null when unknown.
|
|
1576
|
+
* @param {string[]} currentInstalledFiles Every file this run copied.
|
|
712
1577
|
* @param {string} backupRoot The run's timestamped backup directory.
|
|
713
|
-
* @
|
|
1578
|
+
* @returns {{prunedCount: number, skillsPrunedCount: number, failedPaths: string[]}}
|
|
1579
|
+
* The summed count, the skills root's own count, and every path whose move failed.
|
|
714
1580
|
*/
|
|
715
|
-
function
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
1581
|
+
function pruneStaleFilesAcrossManagedRoots(priorInstalledFiles, currentInstalledFiles, backupRoot) {
|
|
1582
|
+
let prunedCount = 0;
|
|
1583
|
+
let skillsPrunedCount = 0;
|
|
1584
|
+
const failedPaths = [];
|
|
1585
|
+
for (const rootName of MANAGED_TOP_LEVEL_DIRECTORY_NAMES) {
|
|
1586
|
+
const rootOutcome = pruneStaleInstalledFiles(
|
|
1587
|
+
priorInstalledFiles,
|
|
1588
|
+
currentInstalledFiles,
|
|
1589
|
+
join(CLAUDE_HOME, rootName),
|
|
1590
|
+
join(backupRoot, rootName),
|
|
1591
|
+
);
|
|
1592
|
+
prunedCount += rootOutcome.prunedCount;
|
|
1593
|
+
failedPaths.push(...rootOutcome.failedPaths);
|
|
1594
|
+
if (rootName === MANAGED_SKILLS_DIRECTORY_NAME) {
|
|
1595
|
+
skillsPrunedCount = rootOutcome.prunedCount;
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
return { prunedCount, skillsPrunedCount, failedPaths };
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
/**
|
|
1602
|
+
* Run every prune a full install performs, in the order that keeps ~/.claude
|
|
1603
|
+
* consistent at each step.
|
|
1604
|
+
*
|
|
1605
|
+
* The settings entries of retired hooks go before the file move: a settings.json
|
|
1606
|
+
* naming a hook script that has already left ~/.claude/hooks makes every session
|
|
1607
|
+
* start invoke a missing script, so the reference leaves first and the script
|
|
1608
|
+
* follows.
|
|
1609
|
+
*
|
|
1610
|
+
* Both prunes report how much content reached the run's backup root, and their sum
|
|
1611
|
+
* is what backup retention answers to: the sweep of older recovery points runs
|
|
1612
|
+
* only once this run holds one of its own.
|
|
1613
|
+
*
|
|
1614
|
+
* @param {Set<string>} copiedSkillNames Skill directory names this run wrote.
|
|
1615
|
+
* @param {string[]|null} priorManifestSkills The prior manifest's skill names, or null.
|
|
1616
|
+
* @param {string[]|null} priorManifestFiles The prior manifest's file list, or null.
|
|
1617
|
+
* @param {string[]} installedFiles Every file this run copied.
|
|
1618
|
+
* @returns {{prunedCount: number, skillsPrunedCount: number, failedPaths: string[]}}
|
|
1619
|
+
* The stale files moved across all roots, the skills root's share, and every
|
|
1620
|
+
* path whose move failed.
|
|
1621
|
+
*/
|
|
1622
|
+
function runFullInstallPrunes(
|
|
1623
|
+
copiedSkillNames, priorManifestSkills, priorManifestFiles, installedFiles,
|
|
1624
|
+
) {
|
|
1625
|
+
const retiredSkillMovedCount = pruneRetiredSkills(copiedSkillNames, priorManifestSkills);
|
|
1626
|
+
const removedHookEntryCount = pruneRetiredHookEntriesFromSettings(
|
|
1627
|
+
join(CLAUDE_HOME, SETTINGS_FILE_NAME),
|
|
1628
|
+
retiredManagedHookRelativePaths(
|
|
1629
|
+
priorManifestFiles, installedFiles, join(CLAUDE_HOME, MANAGED_HOOKS_DIRECTORY_NAME),
|
|
1630
|
+
),
|
|
1631
|
+
);
|
|
1632
|
+
if (removedHookEntryCount > 0) {
|
|
1633
|
+
console.log(` Hook entries: ${removedHookEntryCount} retired entry(s) removed from settings.json`);
|
|
724
1634
|
}
|
|
1635
|
+
const staleOutcome = pruneStaleFilesAcrossManagedRoots(
|
|
1636
|
+
priorManifestFiles, installedFiles, currentRunBackupRoot(),
|
|
1637
|
+
);
|
|
1638
|
+
const didRunMoveContent = retiredSkillMovedCount + staleOutcome.prunedCount > 0;
|
|
1639
|
+
retainNewestRunBackupOnly(currentRunBackupRoot(), didRunMoveContent);
|
|
1640
|
+
return staleOutcome;
|
|
725
1641
|
}
|
|
726
1642
|
|
|
1643
|
+
/**
|
|
1644
|
+
* Copy the package into ~/.claude, merge hook groups into settings.json, and
|
|
1645
|
+
* write the manifest record the uninstall purge and the next run's prune read.
|
|
1646
|
+
*
|
|
1647
|
+
* Three booleans steer the run and answer different questions. `isFullInstall`
|
|
1648
|
+
* answers "should this run do work?" and gates the prunes. `didPruneRun` answers
|
|
1649
|
+
* "did the prunes start?" and gates the prune call itself. `didPruneFinish`
|
|
1650
|
+
* answers "may this run forget a record?" and gates both manifest keys: only a run
|
|
1651
|
+
* whose prunes read the prior record all the way through may replace a key
|
|
1652
|
+
* wholesale, because wholesale replacement is what makes the next diff mean "the
|
|
1653
|
+
* package stopped shipping this". A scoped install, a full install holding its
|
|
1654
|
+
* prunes behind an unresolved dependency group, and a run whose prune step throws
|
|
1655
|
+
* each merge their record with the prior one, so every path and skill name stays
|
|
1656
|
+
* available to a later prune and to uninstall.
|
|
1657
|
+
*
|
|
1658
|
+
* @param {string[]|null} selectedGroups The `--only` group names, or null for a full install.
|
|
1659
|
+
* @param {{isUpdateRefresh?: boolean}} [options] Run options; `isUpdateRefresh` purges before reinstalling.
|
|
1660
|
+
* @returns {void}
|
|
1661
|
+
*/
|
|
727
1662
|
function install(selectedGroups, options = {}) {
|
|
1663
|
+
const { files: priorManifestFiles, skills: priorManifestSkills } = readPriorManifestArrays();
|
|
728
1664
|
const isUpdateRefresh = Boolean(options.isUpdateRefresh);
|
|
729
1665
|
if (isUpdateRefresh && !selectedGroups && existsSync(MANIFEST_FILE)) {
|
|
730
1666
|
console.log(
|
|
@@ -824,22 +1760,24 @@ function install(selectedGroups, options = {}) {
|
|
|
824
1760
|
const installedSkillNames = new Set();
|
|
825
1761
|
const copiedSkillNames = new Set();
|
|
826
1762
|
for (const sourceRoot of allSourceRoots) {
|
|
827
|
-
const skillsSource = join(sourceRoot,
|
|
1763
|
+
const skillsSource = join(sourceRoot, MANAGED_SKILLS_DIRECTORY_NAME);
|
|
828
1764
|
if (!existsSync(skillsSource)) continue;
|
|
829
1765
|
const skillDirs = readdirSync(skillsSource, { withFileTypes: true }).filter(entry => entry.isDirectory());
|
|
830
1766
|
for (const skillDir of skillDirs) {
|
|
831
1767
|
if (allowedSkills && !allowedSkills.has(skillDir.name)) continue;
|
|
832
|
-
const
|
|
1768
|
+
const skillSourceDirectory = join(skillsSource, skillDir.name);
|
|
1769
|
+
const skillDestinationDirectory = join(CLAUDE_HOME, MANAGED_SKILLS_DIRECTORY_NAME, skillDir.name);
|
|
1770
|
+
const stats = copyTree(skillSourceDirectory, skillDestinationDirectory);
|
|
833
1771
|
skillsCreated += stats.created;
|
|
834
1772
|
skillsUpdated += stats.updated;
|
|
835
1773
|
skillPaths.push(...stats.paths);
|
|
836
1774
|
copiedSkillNames.add(skillDir.name);
|
|
837
|
-
if (existsSync(join(
|
|
1775
|
+
if (existsSync(join(skillSourceDirectory, SKILL_MANIFEST_FILENAME))) {
|
|
838
1776
|
installedSkillNames.add(skillDir.name);
|
|
839
1777
|
}
|
|
840
1778
|
}
|
|
841
1779
|
}
|
|
842
|
-
summary.skills = { created: skillsCreated, updated: skillsUpdated, paths: skillPaths };
|
|
1780
|
+
summary.skills = { created: skillsCreated, updated: skillsUpdated, pruned: 0, paths: skillPaths };
|
|
843
1781
|
allInstalledFiles.push(...skillPaths);
|
|
844
1782
|
const shouldInstallAnyHooks = shouldInstallAllHooks || (allowedHookFiles && allowedHookFiles.size > 0);
|
|
845
1783
|
if (shouldInstallAnyHooks) {
|
|
@@ -847,9 +1785,9 @@ function install(selectedGroups, options = {}) {
|
|
|
847
1785
|
let totalHooksUpdated = 0;
|
|
848
1786
|
let totalHookGroups = 0;
|
|
849
1787
|
for (const sourceRoot of allSourceRoots) {
|
|
850
|
-
const hooksSource = join(sourceRoot,
|
|
1788
|
+
const hooksSource = join(sourceRoot, MANAGED_HOOKS_DIRECTORY_NAME);
|
|
851
1789
|
if (!existsSync(hooksSource)) continue;
|
|
852
|
-
const hooksDestination = join(CLAUDE_HOME,
|
|
1790
|
+
const hooksDestination = join(CLAUDE_HOME, MANAGED_HOOKS_DIRECTORY_NAME);
|
|
853
1791
|
const filesToCopy = collectFiles(hooksSource)
|
|
854
1792
|
.filter(file => !file.endsWith('hooks.json'))
|
|
855
1793
|
.filter(file => {
|
|
@@ -884,11 +1822,10 @@ function install(selectedGroups, options = {}) {
|
|
|
884
1822
|
hooksPathConfiguration: gitHookInstallationResult.hooksPathConfigurationResult,
|
|
885
1823
|
};
|
|
886
1824
|
const hooksPathConfigurationAction = gitHookInstallationResult.hooksPathConfigurationResult.action;
|
|
1825
|
+
allInstalledFiles.push(...gitHookInstallationResult.createdShimPaths);
|
|
887
1826
|
if (hooksPathConfigurationAction === 'set') {
|
|
888
|
-
allInstalledFiles.push(...gitHookInstallationResult.createdShimPaths);
|
|
889
1827
|
console.log(` Git hooks: configured core.hooksPath -> ${gitHookInstallationResult.gitHooksDirectory}`);
|
|
890
1828
|
} else if (hooksPathConfigurationAction === 'already-set') {
|
|
891
|
-
allInstalledFiles.push(...gitHookInstallationResult.createdShimPaths);
|
|
892
1829
|
console.log(' Git hooks: core.hooksPath already points to claude-dev-env, no change');
|
|
893
1830
|
} else {
|
|
894
1831
|
console.warn(` Git hooks: ${gitHookInstallationResult.hooksPathConfigurationResult.reason}`);
|
|
@@ -897,12 +1834,13 @@ function install(selectedGroups, options = {}) {
|
|
|
897
1834
|
|
|
898
1835
|
const mypyIniInstallResult = installMypyIniForClaudeHooks({
|
|
899
1836
|
homeDirectory: homedir(),
|
|
900
|
-
claudeHooksDirectory: join(CLAUDE_HOME,
|
|
1837
|
+
claudeHooksDirectory: join(CLAUDE_HOME, MANAGED_HOOKS_DIRECTORY_NAME),
|
|
901
1838
|
});
|
|
902
1839
|
if (mypyIniInstallResult.action === 'created') {
|
|
903
1840
|
allInstalledFiles.push(mypyIniInstallResult.path);
|
|
904
1841
|
console.log(` ✓ ${relative(homedir(), mypyIniInstallResult.path)} (new — enables mypy to resolve config.messages imports)`);
|
|
905
1842
|
} else if (mypyIniInstallResult.action === 'already-configured') {
|
|
1843
|
+
allInstalledFiles.push(mypyIniInstallResult.path);
|
|
906
1844
|
console.log(` .mypy.ini: already configured for Claude hooks`);
|
|
907
1845
|
} else {
|
|
908
1846
|
console.warn(` WARNING: .mypy.ini exists at ${mypyIniInstallResult.path} without the expected mypy_path.`);
|
|
@@ -924,20 +1862,38 @@ function install(selectedGroups, options = {}) {
|
|
|
924
1862
|
console.log(` \u2713 ${relative(CLAUDE_HOME, claudeHubDest)} (hub)`);
|
|
925
1863
|
}
|
|
926
1864
|
const isFullInstall = !selectedGroups;
|
|
927
|
-
const
|
|
928
|
-
let
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
1865
|
+
const didPruneRun = isFullInstall && UNRESOLVED_DEPENDENCY_NAMES.length === 0;
|
|
1866
|
+
let failedPrunePaths = [];
|
|
1867
|
+
let stalePrunedTotal = 0;
|
|
1868
|
+
if (isFullInstall && !didPruneRun) {
|
|
1869
|
+
console.log(
|
|
1870
|
+
` Skipping retired-skill and stale-file prune — unresolved dependency group(s): ${UNRESOLVED_DEPENDENCY_NAMES.join(', ')}. `
|
|
1871
|
+
+ 'A skill that migrated to a dependency package would look retired and its files would look stale, so both prunes are held until every dependency resolves.',
|
|
1872
|
+
);
|
|
1873
|
+
}
|
|
1874
|
+
let didPruneFinish = false;
|
|
1875
|
+
if (didPruneRun) {
|
|
1876
|
+
try {
|
|
1877
|
+
const prunes = runFullInstallPrunes(
|
|
1878
|
+
copiedSkillNames, priorManifestSkills, priorManifestFiles, allInstalledFiles,
|
|
1879
|
+
);
|
|
1880
|
+
summary.skills.pruned = prunes.skillsPrunedCount;
|
|
1881
|
+
stalePrunedTotal = prunes.prunedCount;
|
|
1882
|
+
failedPrunePaths = prunes.failedPaths;
|
|
1883
|
+
didPruneFinish = true;
|
|
1884
|
+
} catch (pruneError) {
|
|
1885
|
+
console.warn(
|
|
1886
|
+
` Warning: the prune step ended early (${pruneError.message}) — this run merges its manifest record with the prior one, so a later full install still names every file.`,
|
|
934
1887
|
);
|
|
935
|
-
} else {
|
|
936
|
-
pruneRetiredSkills(copiedSkillNames, priorManifestSkills);
|
|
937
1888
|
}
|
|
938
|
-
manifestSkillNames = [...installedSkillNames].sort();
|
|
939
1889
|
}
|
|
940
|
-
|
|
1890
|
+
const manifestSkillNames = didPruneFinish
|
|
1891
|
+
? [...installedSkillNames].sort()
|
|
1892
|
+
: unionOfSkillNames(priorManifestSkills || [], [...installedSkillNames]).sort();
|
|
1893
|
+
const manifestFiles = didPruneFinish
|
|
1894
|
+
? manifestFilesWithFailedPrunes(allInstalledFiles, failedPrunePaths)
|
|
1895
|
+
: unionOnComparisonKey(priorManifestFiles || [], allInstalledFiles);
|
|
1896
|
+
writeManifest(manifestFiles, manifestSkillNames);
|
|
941
1897
|
console.log(`\nInstalled ${PACKAGE_NAME}:`);
|
|
942
1898
|
for (const directory of CONTENT_DIRECTORIES) {
|
|
943
1899
|
if (summary[directory]) {
|
|
@@ -946,8 +1902,12 @@ function install(selectedGroups, options = {}) {
|
|
|
946
1902
|
}
|
|
947
1903
|
}
|
|
948
1904
|
if (summary.skills) {
|
|
949
|
-
const { created, updated } = summary.skills;
|
|
950
|
-
|
|
1905
|
+
const { created, updated, pruned } = summary.skills;
|
|
1906
|
+
const staleClause = pruned > 0 ? `, ${pruned} stale moved aside` : '';
|
|
1907
|
+
console.log(` skills: ${created + updated} files (${created} new, ${updated} updated${staleClause})`);
|
|
1908
|
+
}
|
|
1909
|
+
if (stalePrunedTotal > 0) {
|
|
1910
|
+
console.log(` stale files moved aside: ${stalePrunedTotal} across managed roots, kept under ${PRUNED_SKILLS_BACKUP_DIRECTORY_NAME}/${basename(currentRunBackupRoot())}`);
|
|
951
1911
|
}
|
|
952
1912
|
if (summary.hookFiles) {
|
|
953
1913
|
console.log(` hooks: ${summary.hookFiles.created + summary.hookFiles.updated} files, ${summary.hookGroups} groups in settings.json`);
|
|
@@ -966,13 +1926,13 @@ function pathsAreEquivalent(storedPath, installedPath) {
|
|
|
966
1926
|
if (normalizedStored === normalizedInstalled) {
|
|
967
1927
|
return true;
|
|
968
1928
|
}
|
|
969
|
-
|
|
970
|
-
|
|
1929
|
+
return isCaseInsensitiveFilesystem()
|
|
1930
|
+
&& normalizedStored.toLowerCase() === normalizedInstalled.toLowerCase();
|
|
971
1931
|
}
|
|
972
1932
|
|
|
973
1933
|
|
|
974
1934
|
function unsetGlobalGitHooksPathIfOurs() {
|
|
975
|
-
const installedGitHooksDirectory = join(CLAUDE_HOME,
|
|
1935
|
+
const installedGitHooksDirectory = join(CLAUDE_HOME, MANAGED_HOOKS_DIRECTORY_NAME, 'git-hooks');
|
|
976
1936
|
let currentHooksPath = '';
|
|
977
1937
|
try {
|
|
978
1938
|
currentHooksPath = execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], {
|
|
@@ -999,6 +1959,50 @@ function unsetGlobalGitHooksPathIfOurs() {
|
|
|
999
1959
|
}
|
|
1000
1960
|
|
|
1001
1961
|
|
|
1962
|
+
/**
|
|
1963
|
+
* Remove one file the manifest records, tolerating a path that is already gone.
|
|
1964
|
+
*
|
|
1965
|
+
* A record can outlive the file it names: the user deleted it by hand, or a
|
|
1966
|
+
* scoped install carried the entry forward past the file's own removal. A missing
|
|
1967
|
+
* path is skipped in silence so an uninstall runs to the end and clears the
|
|
1968
|
+
* manifest, and any other failure is reported and stepped over for the same
|
|
1969
|
+
* reason.
|
|
1970
|
+
*
|
|
1971
|
+
* @param {string} filePath The absolute path the manifest records.
|
|
1972
|
+
* @returns {boolean} True when this call removed a file.
|
|
1973
|
+
*/
|
|
1974
|
+
function removeRecordedFile(filePath) {
|
|
1975
|
+
try {
|
|
1976
|
+
unlinkSync(filePath);
|
|
1977
|
+
} catch (removalError) {
|
|
1978
|
+
if (removalError.code !== 'ENOENT') {
|
|
1979
|
+
console.warn(` Warning: could not remove ${relative(CLAUDE_HOME, filePath)} (${removalError.message})`);
|
|
1980
|
+
}
|
|
1981
|
+
return false;
|
|
1982
|
+
}
|
|
1983
|
+
console.log(` ✗ ${relative(CLAUDE_HOME, filePath)} (removed)`);
|
|
1984
|
+
return true;
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
/**
|
|
1988
|
+
* Remove every file the manifest records, then drop the directories the removals
|
|
1989
|
+
* emptied.
|
|
1990
|
+
*
|
|
1991
|
+
* A record is removed when it names a path the installer writes: anything under
|
|
1992
|
+
* ~/.claude, plus the `~/.mypy.ini` the install writes in the home directory.
|
|
1993
|
+
* Every other record is skipped with a warning and counted, so one malformed
|
|
1994
|
+
* entry costs that entry alone: the purge removes every legitimate record, clears
|
|
1995
|
+
* the manifest, and leaves the user with a whole uninstall rather than a
|
|
1996
|
+
* half-removed install.
|
|
1997
|
+
*
|
|
1998
|
+
* Directory cleanup runs after the file loop so a directory holding two recorded
|
|
1999
|
+
* files is judged once both are gone. Each walk stops at the managed root the
|
|
2000
|
+
* purged file sits under, which keeps ~/.claude itself and every unmanaged
|
|
2001
|
+
* sibling directory in place.
|
|
2002
|
+
*
|
|
2003
|
+
* @param {{requireManifest: boolean}} options `requireManifest` exits when no manifest exists.
|
|
2004
|
+
* @returns {number|void} 0 when no manifest exists and none is required.
|
|
2005
|
+
*/
|
|
1002
2006
|
function purgeManagedInstallation({ requireManifest }) {
|
|
1003
2007
|
if (!existsSync(MANIFEST_FILE)) {
|
|
1004
2008
|
if (requireManifest) {
|
|
@@ -1009,14 +2013,25 @@ function purgeManagedInstallation({ requireManifest }) {
|
|
|
1009
2013
|
}
|
|
1010
2014
|
const manifest = JSON.parse(readFileSync(MANIFEST_FILE, 'utf8'));
|
|
1011
2015
|
let removed = 0;
|
|
2016
|
+
let skippedUnmanagedCount = 0;
|
|
2017
|
+
const managedRootByEmptiedDirectory = new Map();
|
|
1012
2018
|
for (const filePath of manifest.files) {
|
|
1013
|
-
if (
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
2019
|
+
if (!isRemovableManifestRecord(filePath)) {
|
|
2020
|
+
console.warn(` Warning: skipping ${filePath} — the manifest record names no path this installer writes`);
|
|
2021
|
+
skippedUnmanagedCount++;
|
|
2022
|
+
continue;
|
|
1017
2023
|
}
|
|
2024
|
+
if (removeRecordedFile(filePath)) removed++;
|
|
2025
|
+
const managedRoot = owningManagedRoot(filePath);
|
|
2026
|
+
if (managedRoot) managedRootByEmptiedDirectory.set(dirname(resolve(filePath)), managedRoot);
|
|
2027
|
+
}
|
|
2028
|
+
for (const [emptiedDirectory, managedRoot] of managedRootByEmptiedDirectory) {
|
|
2029
|
+
removeEmptiedParentDirectories(emptiedDirectory, managedRoot);
|
|
2030
|
+
}
|
|
2031
|
+
if (skippedUnmanagedCount > 0) {
|
|
2032
|
+
console.warn(` ${skippedUnmanagedCount} manifest record(s) skipped — each names a path outside ${CLAUDE_HOME} and outside ${MYPY_INI_INSTALL_PATH}`);
|
|
1018
2033
|
}
|
|
1019
|
-
const settingsPath = join(CLAUDE_HOME,
|
|
2034
|
+
const settingsPath = join(CLAUDE_HOME, SETTINGS_FILE_NAME);
|
|
1020
2035
|
if (existsSync(settingsPath)) {
|
|
1021
2036
|
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
1022
2037
|
if (settings.hooks) {
|
|
@@ -1030,7 +2045,7 @@ function purgeManagedInstallation({ requireManifest }) {
|
|
|
1030
2045
|
}
|
|
1031
2046
|
unsetGlobalGitHooksPathIfOurs();
|
|
1032
2047
|
unlinkSync(MANIFEST_FILE);
|
|
1033
|
-
for (const directory of
|
|
2048
|
+
for (const directory of MANAGED_TOP_LEVEL_DIRECTORY_NAMES) {
|
|
1034
2049
|
const dirPath = join(CLAUDE_HOME, directory);
|
|
1035
2050
|
try {
|
|
1036
2051
|
if (existsSync(dirPath) && readdirSync(dirPath).length === 0) {
|
|
@@ -1046,7 +2061,19 @@ function uninstall() {
|
|
|
1046
2061
|
purgeManagedInstallation({ requireManifest: true });
|
|
1047
2062
|
}
|
|
1048
2063
|
|
|
2064
|
+
/**
|
|
2065
|
+
* Print the usage text, listing the install groups this run resolved.
|
|
2066
|
+
*
|
|
2067
|
+
* The group list is read from `INSTALL_GROUPS`, so it names the built-in groups
|
|
2068
|
+
* and every dependency group that resolved on this machine — the same set
|
|
2069
|
+
* `--only` accepts.
|
|
2070
|
+
*
|
|
2071
|
+
* @returns {void}
|
|
2072
|
+
*/
|
|
1049
2073
|
function printHelp() {
|
|
2074
|
+
const groupLines = Object.entries(INSTALL_GROUPS)
|
|
2075
|
+
.map(([groupName, group]) => ` ${groupName} — ${group.description}`)
|
|
2076
|
+
.join('\n');
|
|
1050
2077
|
console.log(`
|
|
1051
2078
|
${PACKAGE_NAME} - Claude Code development standards installer
|
|
1052
2079
|
|
|
@@ -1058,14 +2085,11 @@ Usage:
|
|
|
1058
2085
|
npx ${PACKAGE_NAME} --help Show this help
|
|
1059
2086
|
|
|
1060
2087
|
Groups:
|
|
1061
|
-
|
|
1062
|
-
prompt-generator Prompt engineering tools
|
|
1063
|
-
journal Session logging and memory
|
|
1064
|
-
research Deep research and citation tools
|
|
2088
|
+
${groupLines}
|
|
1065
2089
|
|
|
1066
2090
|
Examples:
|
|
1067
|
-
npx ${PACKAGE_NAME} --only
|
|
1068
|
-
npx ${PACKAGE_NAME} --only
|
|
2091
|
+
npx ${PACKAGE_NAME} --only core
|
|
2092
|
+
npx ${PACKAGE_NAME} --only core,journal
|
|
1069
2093
|
|
|
1070
2094
|
Install location: ~/.claude/
|
|
1071
2095
|
|