claude-dev-env 4.0.0 → 4.2.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.
Files changed (32) hide show
  1. package/.agents/skills/everything-search/SKILL.md +18 -3
  2. package/.agents/skills/everything-search/scripts/everything_search.py +204 -0
  3. package/.agents/skills/everything-search/scripts/everything_search_command_constants/__init__.py +1 -0
  4. package/.agents/skills/everything-search/scripts/everything_search_command_constants/config/__init__.py +1 -0
  5. package/.agents/skills/everything-search/scripts/everything_search_command_constants/config/constants.py +11 -0
  6. package/.agents/skills/everything-search/scripts/test_everything_search.py +172 -0
  7. package/.agents/skills/syncing-submodules/SKILL.md +101 -0
  8. package/.agents/skills/syncing-submodules/reference/run-tasks.md +9 -0
  9. package/.agents/skills/syncing-submodules/scripts/submodule_sync.py +383 -0
  10. package/.agents/skills/syncing-submodules/scripts/submodule_sync_constants/__init__.py +1 -0
  11. package/.agents/skills/syncing-submodules/scripts/submodule_sync_constants/config/__init__.py +1 -0
  12. package/.agents/skills/syncing-submodules/scripts/submodule_sync_constants/config/constants.py +66 -0
  13. package/.agents/skills/syncing-submodules/scripts/sync_parent_pointer.py +88 -0
  14. package/.agents/skills/syncing-submodules/scripts/test_submodule_sync.py +303 -0
  15. package/.agents/skills/syncing-submodules/scripts/test_sync_parent_pointer.py +83 -0
  16. package/bin/ever-shipped-skills.mjs +1 -0
  17. package/bin/ever-shipped-skills.test.mjs +30 -0
  18. package/bin/git_hooks_installer.mjs +0 -1
  19. package/bin/git_hooks_installer.test.mjs +16 -1
  20. package/bin/install.mjs +5 -2
  21. package/bin/install.prune.test.mjs +95 -24
  22. package/bin/install.test.mjs +7 -0
  23. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +5 -8
  24. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +7 -13
  25. package/package.json +1 -1
  26. package/rules/filesystem-search.md +5 -5
  27. package/hooks/blocking/es_exe_path_rewriter.py +0 -159
  28. package/hooks/blocking/test_es_exe_path_rewriter.py +0 -447
  29. package/hooks/git-hooks/post_commit.py +0 -241
  30. package/hooks/git-hooks/test_post_commit.py +0 -298
  31. package/hooks/hooks_constants/path_rewriter_constants.py +0 -13
  32. package/hooks/hooks_constants/test_path_rewriter_constants.py +0 -57
@@ -0,0 +1,303 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ SCRIPT_DIRECTORY = Path(__file__).resolve().parent
11
+ if str(SCRIPT_DIRECTORY) not in sys.path:
12
+ sys.path.insert(0, str(SCRIPT_DIRECTORY))
13
+
14
+ import submodule_sync
15
+ from submodule_sync_constants.config.constants import (
16
+ GH_COMMAND_TIMEOUT_SECONDS,
17
+ GIT_COMMAND_TIMEOUT_SECONDS,
18
+ )
19
+
20
+
21
+ def _build_git_environment() -> dict[str, str]:
22
+ return {
23
+ each_name: each_content
24
+ for each_name, each_content in os.environ.items()
25
+ if not each_name.upper().startswith("GIT_")
26
+ }
27
+
28
+
29
+ def _run_fixture_git(
30
+ repository_path: Path,
31
+ *all_arguments: str,
32
+ ) -> subprocess.CompletedProcess[str]:
33
+ return subprocess.run(
34
+ ["git", *all_arguments],
35
+ cwd=repository_path,
36
+ check=False,
37
+ capture_output=True,
38
+ text=True,
39
+ env=_build_git_environment(),
40
+ timeout=GIT_COMMAND_TIMEOUT_SECONDS,
41
+ )
42
+
43
+
44
+ def _require_fixture_git(repository_path: Path, *all_arguments: str) -> str:
45
+ completed_process = _run_fixture_git(repository_path, *all_arguments)
46
+ assert completed_process.returncode == 0, completed_process.stderr
47
+ return completed_process.stdout.strip()
48
+
49
+
50
+ def _configure_fixture_identity(repository_path: Path) -> None:
51
+ _require_fixture_git(repository_path, "config", "user.name", "Fixture User")
52
+ _require_fixture_git(
53
+ repository_path,
54
+ "config",
55
+ "user.email",
56
+ "fixture@example.invalid",
57
+ )
58
+ _require_fixture_git(repository_path, "config", "commit.gpgsign", "false")
59
+
60
+
61
+ def _initialize_fixture_repository(repository_path: Path) -> None:
62
+ repository_path.mkdir(parents=True)
63
+ _require_fixture_git(repository_path, "init", "-b", "main")
64
+ _configure_fixture_identity(repository_path)
65
+
66
+
67
+ def _create_submodule_fixture(tmp_path: Path) -> tuple[Path, Path]:
68
+ source_repository = tmp_path / "source-repository"
69
+ parent_repository = tmp_path / "parent-repository"
70
+ _initialize_fixture_repository(source_repository)
71
+ (source_repository / "README.md").write_text("source\n", encoding="utf-8")
72
+ _require_fixture_git(source_repository, "add", "README.md")
73
+ _require_fixture_git(source_repository, "commit", "-m", "Create source")
74
+ _initialize_fixture_repository(parent_repository)
75
+ (parent_repository / "README.md").write_text("parent\n", encoding="utf-8")
76
+ _require_fixture_git(parent_repository, "add", "README.md")
77
+ _require_fixture_git(parent_repository, "commit", "-m", "Create parent")
78
+ _require_fixture_git(
79
+ parent_repository,
80
+ "-c",
81
+ "protocol.file.allow=always",
82
+ "submodule",
83
+ "add",
84
+ str(source_repository),
85
+ "nested/sub[module]",
86
+ )
87
+ _require_fixture_git(parent_repository, "commit", "-am", "Add submodule")
88
+ submodule_repository = parent_repository / "nested" / "sub[module]"
89
+ _configure_fixture_identity(submodule_repository)
90
+ return parent_repository, submodule_repository
91
+
92
+
93
+ def _commit_child_change(submodule_repository: Path) -> str:
94
+ (submodule_repository / "change.txt").write_text("change\n", encoding="utf-8")
95
+ _require_fixture_git(submodule_repository, "add", "change.txt")
96
+ _require_fixture_git(submodule_repository, "commit", "-m", "Record child change")
97
+ return _require_fixture_git(submodule_repository, "rev-parse", "HEAD")
98
+
99
+
100
+ def _assert_parent_pointer_commit(
101
+ parent_repository: Path,
102
+ child_commit_hash: str,
103
+ ) -> None:
104
+ recorded_pointer = _require_fixture_git(
105
+ parent_repository,
106
+ "rev-parse",
107
+ "HEAD:nested/sub[module]",
108
+ )
109
+ changed_path = _require_fixture_git(
110
+ parent_repository,
111
+ "diff",
112
+ "HEAD^",
113
+ "HEAD",
114
+ "--name-only",
115
+ )
116
+ parent_commit_body = _require_fixture_git(
117
+ parent_repository,
118
+ "log",
119
+ "-1",
120
+ "--pretty=%B",
121
+ )
122
+ assert recorded_pointer == child_commit_hash
123
+ assert changed_path == "nested/sub[module]"
124
+ assert "Submodule commit: Record child change" in parent_commit_body
125
+ assert "Co-Authored-By" not in parent_commit_body
126
+
127
+
128
+ def test_sync_updates_bracketed_pointer_and_preserves_staged_path(
129
+ tmp_path: Path,
130
+ ) -> None:
131
+ parent_repository, submodule_repository = _create_submodule_fixture(tmp_path)
132
+ staged_note_path = parent_repository / "staged-note.txt"
133
+ staged_note_path.write_text("keep staged\n", encoding="utf-8")
134
+ _require_fixture_git(parent_repository, "add", "staged-note.txt")
135
+ child_commit_hash = _commit_child_change(submodule_repository)
136
+
137
+ sync_report = submodule_sync.sync_repository(submodule_repository)
138
+
139
+ assert sync_report.status is submodule_sync.SyncStatus.UPDATED
140
+ assert sync_report.commit == child_commit_hash
141
+ assert sync_report.submodule_path == "nested/sub[module]"
142
+ assert (
143
+ _require_fixture_git(parent_repository, "status", "--short")
144
+ == "A staged-note.txt"
145
+ )
146
+ _assert_parent_pointer_commit(parent_repository, child_commit_hash)
147
+
148
+
149
+ def test_second_sync_is_unchanged_without_new_commit(tmp_path: Path) -> None:
150
+ parent_repository, submodule_repository = _create_submodule_fixture(tmp_path)
151
+ _commit_child_change(submodule_repository)
152
+
153
+ first_report = submodule_sync.sync_repository(submodule_repository)
154
+ parent_commit = _require_fixture_git(parent_repository, "rev-parse", "HEAD")
155
+ second_report = submodule_sync.sync_repository(submodule_repository)
156
+
157
+ assert first_report.status is submodule_sync.SyncStatus.UPDATED
158
+ assert second_report.status is submodule_sync.SyncStatus.UNCHANGED
159
+ assert second_report.parent_commit == parent_commit
160
+ assert _require_fixture_git(parent_repository, "rev-parse", "HEAD") == parent_commit
161
+
162
+
163
+ def test_standalone_repository_is_a_noop(tmp_path: Path) -> None:
164
+ standalone_repository = tmp_path / "standalone"
165
+ _initialize_fixture_repository(standalone_repository)
166
+ (standalone_repository / "note.txt").write_text("unchanged\n", encoding="utf-8")
167
+ _require_fixture_git(standalone_repository, "add", "note.txt")
168
+ before_status = _require_fixture_git(standalone_repository, "status", "--short")
169
+
170
+ sync_report = submodule_sync.sync_repository(standalone_repository)
171
+
172
+ assert sync_report.status is submodule_sync.SyncStatus.NOT_SUBMODULE
173
+ assert sync_report.repository == standalone_repository.resolve().as_posix()
174
+ assert (
175
+ _require_fixture_git(standalone_repository, "status", "--short")
176
+ == before_status
177
+ )
178
+
179
+
180
+ @pytest.mark.parametrize(
181
+ ("failed_git_operation", "failure_exit_code"),
182
+ [("add", 1), ("diff", 2), ("commit", 1)],
183
+ )
184
+ def test_git_failures_return_error_status(
185
+ tmp_path: Path,
186
+ monkeypatch: pytest.MonkeyPatch,
187
+ failed_git_operation: str,
188
+ failure_exit_code: int,
189
+ ) -> None:
190
+ _, submodule_repository = _create_submodule_fixture(tmp_path)
191
+ _commit_child_change(submodule_repository)
192
+ original_execute_git = submodule_sync._execute_git
193
+
194
+ def fail_selected_operation(
195
+ all_arguments: tuple[str, ...],
196
+ cwd: Path,
197
+ ) -> subprocess.CompletedProcess[str]:
198
+ if all_arguments[0] == failed_git_operation:
199
+ return subprocess.CompletedProcess(
200
+ ["git", *all_arguments],
201
+ failure_exit_code,
202
+ "",
203
+ f"fatal: fixture {failed_git_operation} failure",
204
+ )
205
+ return original_execute_git(all_arguments, cwd)
206
+
207
+ monkeypatch.setattr(submodule_sync, "_execute_git", fail_selected_operation)
208
+ sync_report = submodule_sync.sync_repository(submodule_repository)
209
+
210
+ assert sync_report.status is submodule_sync.SyncStatus.ERROR
211
+ assert sync_report.diagnostic == f"fatal: fixture {failed_git_operation} failure"
212
+
213
+
214
+ def test_inherited_git_dir_does_not_redirect_commands(
215
+ tmp_path: Path,
216
+ monkeypatch: pytest.MonkeyPatch,
217
+ ) -> None:
218
+ unrelated_repository = tmp_path / "unrelated"
219
+ _initialize_fixture_repository(unrelated_repository)
220
+ parent_repository, submodule_repository = _create_submodule_fixture(tmp_path)
221
+ child_commit_hash = _commit_child_change(submodule_repository)
222
+ monkeypatch.setenv("GIT_DIR", str(unrelated_repository / ".git"))
223
+
224
+ sync_report = submodule_sync.sync_repository(submodule_repository)
225
+
226
+ assert sync_report.status is submodule_sync.SyncStatus.UPDATED
227
+ recorded_pointer = _require_fixture_git(
228
+ parent_repository,
229
+ "rev-parse",
230
+ "HEAD:nested/sub[module]",
231
+ )
232
+ assert recorded_pointer == child_commit_hash
233
+
234
+
235
+ def test_pull_request_lookup_returns_url_on_success(
236
+ tmp_path: Path,
237
+ monkeypatch: pytest.MonkeyPatch,
238
+ ) -> None:
239
+ completed_process = subprocess.CompletedProcess(
240
+ ["gh"],
241
+ 0,
242
+ "https://github.com/example/repo/pull/1\n",
243
+ "",
244
+ )
245
+
246
+ def return_completed_process(
247
+ *all_arguments: object,
248
+ **all_keyword_arguments: object,
249
+ ) -> subprocess.CompletedProcess[str]:
250
+ return completed_process
251
+
252
+ monkeypatch.setattr(submodule_sync.subprocess, "run", return_completed_process)
253
+
254
+ assert submodule_sync.lookup_pull_request_url(tmp_path) == (
255
+ "https://github.com/example/repo/pull/1"
256
+ )
257
+
258
+
259
+ @pytest.mark.parametrize(
260
+ "completed_process",
261
+ [
262
+ subprocess.CompletedProcess(["gh"], 1, "", "no pull request"),
263
+ subprocess.CompletedProcess(["gh"], 1, "", "auth failed"),
264
+ subprocess.CompletedProcess(["gh"], 0, "", ""),
265
+ ],
266
+ )
267
+ def test_pull_request_lookup_soft_failures_return_none(
268
+ tmp_path: Path,
269
+ monkeypatch: pytest.MonkeyPatch,
270
+ completed_process: subprocess.CompletedProcess[str],
271
+ ) -> None:
272
+ def return_completed_process(
273
+ *all_arguments: object,
274
+ **all_keyword_arguments: object,
275
+ ) -> subprocess.CompletedProcess[str]:
276
+ return completed_process
277
+
278
+ monkeypatch.setattr(submodule_sync.subprocess, "run", return_completed_process)
279
+
280
+ assert submodule_sync.lookup_pull_request_url(tmp_path) is None
281
+
282
+
283
+ @pytest.mark.parametrize(
284
+ "lookup_exception",
285
+ [
286
+ FileNotFoundError(),
287
+ subprocess.TimeoutExpired("gh", GH_COMMAND_TIMEOUT_SECONDS),
288
+ ],
289
+ )
290
+ def test_pull_request_lookup_unavailable_returns_none(
291
+ tmp_path: Path,
292
+ monkeypatch: pytest.MonkeyPatch,
293
+ lookup_exception: Exception,
294
+ ) -> None:
295
+ def raise_lookup_exception(
296
+ *all_arguments: object,
297
+ **all_keyword_arguments: object,
298
+ ) -> None:
299
+ raise lookup_exception
300
+
301
+ monkeypatch.setattr(submodule_sync.subprocess, "run", raise_lookup_exception)
302
+
303
+ assert submodule_sync.lookup_pull_request_url(tmp_path) is None
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ SCRIPT_DIRECTORY = Path(__file__).resolve().parent
10
+ if str(SCRIPT_DIRECTORY) not in sys.path:
11
+ sys.path.insert(0, str(SCRIPT_DIRECTORY))
12
+
13
+ import sync_parent_pointer
14
+ from submodule_sync import SyncReport, SyncStatus
15
+
16
+
17
+ def _read_single_json_line(
18
+ capsys: pytest.CaptureFixture[str],
19
+ ) -> tuple[dict[str, object], str]:
20
+ captured_streams = capsys.readouterr()
21
+ all_stdout_lines = captured_streams.out.splitlines()
22
+ assert len(all_stdout_lines) == 1
23
+ parsed_record = json.loads(all_stdout_lines[0])
24
+ assert isinstance(parsed_record, dict)
25
+ return parsed_record, captured_streams.err
26
+
27
+
28
+ def test_invalid_repository_emits_one_json_record_and_diagnostic(
29
+ tmp_path: Path,
30
+ capsys: pytest.CaptureFixture[str],
31
+ ) -> None:
32
+ exit_code = sync_parent_pointer.main(["--repository", str(tmp_path / "missing")])
33
+
34
+ sync_record, captured_error = _read_single_json_line(capsys)
35
+ assert exit_code == 1
36
+ assert sync_record["status"] == "error"
37
+ assert sync_record["diagnostic"]
38
+ assert "syncing-submodules:" in captured_error
39
+
40
+
41
+ def test_invalid_arguments_emit_one_json_record_and_exit_two(
42
+ capsys: pytest.CaptureFixture[str],
43
+ ) -> None:
44
+ exit_code = sync_parent_pointer.main(["--unknown"])
45
+
46
+ sync_record, captured_error = _read_single_json_line(capsys)
47
+ assert exit_code == 2
48
+ assert sync_record["status"] == "error"
49
+ assert "invalid command arguments" in str(sync_record["diagnostic"])
50
+ assert captured_error
51
+
52
+
53
+ def test_success_emits_one_json_record(
54
+ tmp_path: Path,
55
+ monkeypatch: pytest.MonkeyPatch,
56
+ capsys: pytest.CaptureFixture[str],
57
+ ) -> None:
58
+ expected_report = SyncReport(
59
+ status=SyncStatus.NOT_SUBMODULE,
60
+ repository=tmp_path.as_posix(),
61
+ )
62
+
63
+ def return_expected_report(repository: Path) -> SyncReport:
64
+ return expected_report
65
+
66
+ monkeypatch.setattr(
67
+ sync_parent_pointer,
68
+ "sync_repository",
69
+ return_expected_report,
70
+ )
71
+ exit_code = sync_parent_pointer.main(["--repository", str(tmp_path)])
72
+
73
+ sync_record, captured_error = _read_single_json_line(capsys)
74
+ assert exit_code == 0
75
+ assert sync_record["status"] == "not_submodule"
76
+ assert captured_error == ""
77
+
78
+
79
+ def test_native_post_commit_source_and_test_are_removed() -> None:
80
+ hook_directory = Path(__file__).resolve().parents[4] / "hooks" / "git-hooks"
81
+
82
+ assert not (hook_directory / "post_commit.py").exists()
83
+ assert not (hook_directory / "test_post_commit.py").exists()
@@ -119,6 +119,7 @@ export const EVER_SHIPPED_SKILL_NAMES = new Set([
119
119
  'source-command-sr-loop',
120
120
  'split-pr',
121
121
  'structure-prompt',
122
+ 'syncing-submodules',
122
123
  'task-build',
123
124
  'team-advisor',
124
125
  'update',
@@ -0,0 +1,30 @@
1
+ import { strict as assert } from 'node:assert';
2
+ import { test } from 'node:test';
3
+ import { existsSync, readdirSync } from 'node:fs';
4
+ import { dirname, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { EVER_SHIPPED_SKILL_NAMES } from './ever-shipped-skills.mjs';
7
+ import {
8
+ MANAGED_SKILLS_DIRECTORY_NAME,
9
+ PACKAGE_AGENTS_HOME_DIRECTORY_NAME,
10
+ } from './install-constants.mjs';
11
+
12
+ const THIS_DIRECTORY = dirname(fileURLToPath(import.meta.url));
13
+ const PACKAGE_DIRECTORY = dirname(THIS_DIRECTORY);
14
+
15
+ test('EVER_SHIPPED_SKILL_NAMES includes every current shipped skill', () => {
16
+ const sourceSkillsDirectory = join(
17
+ PACKAGE_DIRECTORY,
18
+ PACKAGE_AGENTS_HOME_DIRECTORY_NAME,
19
+ MANAGED_SKILLS_DIRECTORY_NAME,
20
+ );
21
+ const allCurrentSkillNames = readdirSync(sourceSkillsDirectory, { withFileTypes: true })
22
+ .filter(eachEntry => eachEntry.isDirectory())
23
+ .filter(eachEntry => existsSync(join(sourceSkillsDirectory, eachEntry.name, 'SKILL.md')))
24
+ .map(eachEntry => eachEntry.name);
25
+ const allUncoveredSkillNames = allCurrentSkillNames.filter(
26
+ eachSkillName => !EVER_SHIPPED_SKILL_NAMES.has(eachSkillName),
27
+ );
28
+
29
+ assert.deepEqual(allUncoveredSkillNames, [], 'every current skill must enter the fallback set');
30
+ });
@@ -6,7 +6,6 @@ import { join } from 'node:path';
6
6
  export const KNOWN_GIT_HOOK_NAMES = Object.freeze([
7
7
  'pre-commit',
8
8
  'pre-push',
9
- 'post-commit',
10
9
  ]);
11
10
 
12
11
  export const SHIM_DOCSTRING = (
@@ -58,6 +58,22 @@ test('writeAllGitHookShims creates one shim per known hook name', () => {
58
58
  });
59
59
 
60
60
 
61
+ test('known git hooks exclude post-commit', () => {
62
+ assert.equal(KNOWN_GIT_HOOK_NAMES.includes('post-commit'), false);
63
+ });
64
+
65
+
66
+ test('writeAllGitHookShims does not create a post-commit shim', () => {
67
+ const { temporaryRoot, gitHooksDirectory } = makeTemporaryGitHooksDirectory();
68
+ try {
69
+ writeAllGitHookShims({ gitHooksDirectory });
70
+ assert.equal(existsSync(join(gitHooksDirectory, 'post-commit')), false);
71
+ } finally {
72
+ rmSync(temporaryRoot, { recursive: true, force: true });
73
+ }
74
+ });
75
+
76
+
61
77
  test('configureGlobalGitHooksPath sets the path when nothing is currently configured', () => {
62
78
  const commandsRun = [];
63
79
  const gitConfigReaderReturningEmpty = () => '';
@@ -205,4 +221,3 @@ test('writeGitHookShim rejects hooks directory that is a symlink (loopP5c-5)', (
205
221
  }
206
222
  });
207
223
 
208
-
package/bin/install.mjs CHANGED
@@ -6,7 +6,10 @@ import { homedir } from 'node:os';
6
6
  import { execSync, execFileSync, spawnSync } from 'node:child_process';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { createRequire } from 'node:module';
9
- import { installAllGitHooks } from './git_hooks_installer.mjs';
9
+ import {
10
+ installAllGitHooks,
11
+ KNOWN_GIT_HOOK_NAMES,
12
+ } from './git_hooks_installer.mjs';
10
13
  import { installMypyIniForClaudeHooks } from './install_mypy_ini.mjs';
11
14
  import { expandHomeDirectoryTokensInSettings } from './expand_home_directory_tokens.mjs';
12
15
  import { EVER_SHIPPED_SKILL_NAMES } from './ever-shipped-skills.mjs';
@@ -2368,7 +2371,7 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2368
2371
  } else {
2369
2372
  console.warn(` Git hooks: ${gitHookInstallationResult.hooksPathConfigurationResult.reason}`);
2370
2373
  }
2371
- console.log(` Git hook shims: ${gitHookInstallationResult.createdShimPaths.length} files (pre-commit, pre-push, post-commit)`);
2374
+ console.log(` Git hook shims: ${gitHookInstallationResult.createdShimPaths.length} files (${KNOWN_GIT_HOOK_NAMES.join(', ')})`);
2372
2375
  syncWrittenPaths([...allInstalledFiles, ...publishedPointerPaths]);
2373
2376
  throwIfFault(FAULT_PHASES.AFTER_GIT_CONFIG);
2374
2377
  throwIfFault(FAULT_PHASES.AFTER_LINK_PUBLICATION);
@@ -13,15 +13,8 @@ import {
13
13
  import { tmpdir } from 'node:os';
14
14
  import { join, dirname, basename } from 'node:path';
15
15
  import { fileURLToPath } from 'node:url';
16
- import {
17
- MANAGED_SKILLS_DIRECTORY_NAME,
18
- PACKAGE_AGENTS_HOME_DIRECTORY_NAME,
19
- } from './install-constants.mjs';
20
- import { EVER_SHIPPED_SKILL_NAMES } from './ever-shipped-skills.mjs';
21
-
22
16
  const THIS_DIRECTORY = dirname(fileURLToPath(import.meta.url));
23
17
  const INSTALLER_PATH = join(THIS_DIRECTORY, 'install.mjs');
24
- const PACKAGE_DIRECTORY = dirname(THIS_DIRECTORY);
25
18
  const INSTALLER_PROCESS_TIMEOUT_MS = 60_000;
26
19
  const EXCLUDED_PACKAGE_COPY_DIRECTORY = 'node_modules';
27
20
 
@@ -50,6 +43,11 @@ const PRIOR_RUN_BACKUP_DIRECTORY_NAMES = [
50
43
  ];
51
44
  const SKILLS_DIRECTORY_NAME = 'skills';
52
45
  const HOOKS_DIRECTORY_NAME = 'hooks';
46
+ const GIT_HOOKS_DIRECTORY_NAME = 'git-hooks';
47
+ const PRE_COMMIT_HOOK_NAME = 'pre-commit';
48
+ const PRE_PUSH_HOOK_NAME = 'pre-push';
49
+ const POST_COMMIT_HOOK_NAME = 'post-commit';
50
+ const FOREIGN_HOOK_NAME = 'foreign-hook';
53
51
  const SETTINGS_FILE_NAME = 'settings.json';
54
52
  const MANIFEST_FILE_NAME = '.claude-dev-env-manifest.json';
55
53
  const CLAUDE_HUB_FILE_NAME = 'CLAUDE.md';
@@ -117,23 +115,6 @@ function createSandbox(homeDirectoryPrefix = 'cdev-prune-home-') {
117
115
  return { homeDirectory, claudeDirectory, skillsDirectory, manifestPath };
118
116
  }
119
117
 
120
- test('the retired-skill fallback covers every current shipped skill', () => {
121
- const sourceSkillsDirectory = join(
122
- PACKAGE_DIRECTORY,
123
- PACKAGE_AGENTS_HOME_DIRECTORY_NAME,
124
- MANAGED_SKILLS_DIRECTORY_NAME,
125
- );
126
- const allCurrentSkillNames = readdirSync(sourceSkillsDirectory, { withFileTypes: true })
127
- .filter(eachEntry => eachEntry.isDirectory())
128
- .filter(eachEntry => existsSync(join(sourceSkillsDirectory, eachEntry.name, 'SKILL.md')))
129
- .map(eachEntry => eachEntry.name);
130
- const allUncoveredSkillNames = allCurrentSkillNames.filter(
131
- eachSkillName => !EVER_SHIPPED_SKILL_NAMES.has(eachSkillName),
132
- );
133
-
134
- assert.deepEqual(allUncoveredSkillNames, [], 'every current skill must enter the fallback set');
135
- });
136
-
137
118
  test('a core install runs the spaced-path Write dispatcher and captures its red denial', () => {
138
119
  const sandbox = createSandbox('cdev prune & (home)-');
139
120
  try {
@@ -1003,6 +984,37 @@ test('a full reinstall leaves a manifest-recorded path under no managed root in
1003
984
  }
1004
985
  });
1005
986
 
987
+ test('a reinstall removes a retired post-commit shim and preserves a foreign git hook', () => {
988
+ const sandbox = createSandbox();
989
+ try {
990
+ runInstaller(sandbox.homeDirectory, []);
991
+ const gitHooksDirectory = join(
992
+ sandbox.claudeDirectory, HOOKS_DIRECTORY_NAME, GIT_HOOKS_DIRECTORY_NAME,
993
+ );
994
+ const postCommitShimPath = join(gitHooksDirectory, POST_COMMIT_HOOK_NAME);
995
+ const foreignHookPath = join(gitHooksDirectory, FOREIGN_HOOK_NAME);
996
+ const foreignHookContentBytes = Buffer.from('foreign hook content\n', 'utf8');
997
+ writeFileWithParents(postCommitShimPath, 'generated by an earlier install\n');
998
+ writeFileWithParents(foreignHookPath, foreignHookContentBytes);
999
+ appendManifestFiles(sandbox.manifestPath, [postCommitShimPath]);
1000
+
1001
+ runInstaller(sandbox.homeDirectory, []);
1002
+
1003
+ const freshManifest = readManifest(sandbox.manifestPath);
1004
+ assert.equal(existsSync(postCommitShimPath), false);
1005
+ assert.deepEqual(readFileSync(foreignHookPath), foreignHookContentBytes);
1006
+ assert.equal(freshManifest.files.includes(postCommitShimPath), false);
1007
+ assert.equal(freshManifest.files.includes(foreignHookPath), false);
1008
+ for (const gitHookName of [PRE_COMMIT_HOOK_NAME, PRE_PUSH_HOOK_NAME]) {
1009
+ const generatedShimPath = join(gitHooksDirectory, gitHookName);
1010
+ assert.equal(existsSync(generatedShimPath), true);
1011
+ assert.equal(freshManifest.files.includes(generatedShimPath), true);
1012
+ }
1013
+ } finally {
1014
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
1015
+ }
1016
+ });
1017
+
1006
1018
  test('a full reinstall drops a retired hook script and its settings entry in one run', () => {
1007
1019
  const sandbox = createSandbox();
1008
1020
  try {
@@ -1042,6 +1054,65 @@ test('a full reinstall drops a retired hook script and its settings entry in one
1042
1054
  }
1043
1055
  });
1044
1056
 
1057
+ test('a full reinstall removes the retired Everything path rewriter and keeps a foreign hook neighbor', () => {
1058
+ const sandbox = createSandbox();
1059
+ try {
1060
+ runInstaller(sandbox.homeDirectory, []);
1061
+ const retiredHookPath = join(
1062
+ sandbox.claudeDirectory,
1063
+ HOOKS_DIRECTORY_NAME,
1064
+ 'blocking',
1065
+ 'es_exe_path_rewriter.py',
1066
+ );
1067
+ writeFileWithParents(retiredHookPath, 'a hook an earlier install wrote\n');
1068
+ appendManifestFiles(sandbox.manifestPath, [retiredHookPath]);
1069
+
1070
+ const foreignHookPath = join(
1071
+ sandbox.claudeDirectory,
1072
+ HOOKS_DIRECTORY_NAME,
1073
+ 'blocking',
1074
+ 'foreign_search_hook.py',
1075
+ );
1076
+ const foreignHookBytes = Buffer.from('a user hook the installer never wrote\n');
1077
+ writeFileWithParents(foreignHookPath, foreignHookBytes);
1078
+
1079
+ runInstaller(sandbox.homeDirectory, []);
1080
+
1081
+ const freshManifest = readManifest(sandbox.manifestPath);
1082
+ const installedDispatcherConstantsPath = join(
1083
+ sandbox.claudeDirectory,
1084
+ HOOKS_DIRECTORY_NAME,
1085
+ 'hooks_constants',
1086
+ 'bash_pre_tool_use_dispatcher_constants.py',
1087
+ );
1088
+ assert.equal(existsSync(retiredHookPath), false, 'the retired search hook leaves the installed hooks');
1089
+ assert.equal(
1090
+ freshManifest.files.includes(retiredHookPath),
1091
+ false,
1092
+ 'the fresh manifest drops the retired search hook',
1093
+ );
1094
+ assert.equal(
1095
+ freshManifest.files.includes(foreignHookPath),
1096
+ false,
1097
+ 'the fresh manifest does not claim the foreign hook',
1098
+ );
1099
+ assert.deepEqual(
1100
+ readFileSync(foreignHookPath),
1101
+ foreignHookBytes,
1102
+ 'the foreign hook keeps its original bytes',
1103
+ );
1104
+ assert.equal(
1105
+ readFileSync(installedDispatcherConstantsPath, 'utf8').includes(
1106
+ 'es_exe_path_rewriter.py',
1107
+ ),
1108
+ false,
1109
+ 'the installed dispatcher constants omit the retired search hook',
1110
+ );
1111
+ } finally {
1112
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
1113
+ }
1114
+ });
1115
+
1045
1116
  test('a full reinstall removes a retired hook entry under an event type the current config leaves out', () => {
1046
1117
  const sandbox = createSandbox();
1047
1118
  try {
@@ -51,6 +51,13 @@ import {
51
51
  } from './expand_home_directory_tokens.mjs';
52
52
 
53
53
 
54
+ test('installer reports the authoritative Git hook names', () => {
55
+ const installerSource = readFileSync(new URL('./install.mjs', import.meta.url), 'utf8');
56
+ assert.match(installerSource, /KNOWN_GIT_HOOK_NAMES\.join\(', '\)/);
57
+ assert.doesNotMatch(installerSource, /pre-commit, pre-push, post-commit/);
58
+ });
59
+
60
+
54
61
  function createTemporaryGitRepository() {
55
62
  const repositoryRoot = mkdtempSync(join(tmpdir(), 'cdev-installer-validation-'));
56
63
  const gitOptions = { cwd: repositoryRoot, stdio: 'ignore' };