claude-dev-env 8.105.2 → 8.106.1

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/bin/install.mjs CHANGED
@@ -1064,6 +1064,7 @@ export function pruneStaleInstalledFiles(
1064
1064
  currentInstalledFiles.map(currentFile => comparisonKeyForPath(currentFile, options)),
1065
1065
  );
1066
1066
  const resolvedRoot = resolve(destinationRoot);
1067
+ const resolvedRootTarget = realPathOrNull(resolvedRoot);
1067
1068
  let prunedCount = 0;
1068
1069
  const failedPaths = [];
1069
1070
  for (const priorFile of priorInstalledFiles) {
@@ -1072,6 +1073,17 @@ export function pruneStaleInstalledFiles(
1072
1073
  if (currentFileKeys.has(comparisonKeyForPath(stalePath, options))) continue;
1073
1074
  if (isRetainedUnmanagedPath(stalePath, managedHomeDirectory)) continue;
1074
1075
  if (!isMovableStaleFile(stalePath)) continue;
1076
+ const staleTarget = realPathOrNull(stalePath);
1077
+ if (!staleTarget || !resolvedRootTarget) {
1078
+ failedPaths.push(stalePath);
1079
+ continue;
1080
+ }
1081
+ if (!isInsideDirectory(staleTarget, resolvedRootTarget)) {
1082
+ console.warn(
1083
+ ` Warning: leaving ${relative(managedHomeDirectory, stalePath)} in place — a link on its path leads outside ${resolvedRoot}`,
1084
+ );
1085
+ continue;
1086
+ }
1075
1087
  const backupRelativePath = relative(resolvedRoot, stalePath);
1076
1088
  const didMove = moveIntoRunBackup(
1077
1089
  stalePath, backupRoot, backupRelativePath, STALE_FILE_REASON_LABEL, managedHomeDirectory,
@@ -3013,6 +3025,10 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
3013
3025
  if (allowedSkills && !allowedSkills.has(skillDir.name)) continue;
3014
3026
  const skillSourceDirectory = join(skillsSource, skillDir.name);
3015
3027
  const skillDestinationDirectory = join(CLAUDE_HOME, MANAGED_SKILLS_DIRECTORY_NAME, skillDir.name);
3028
+ if (lstatSync(skillDestinationDirectory, { throwIfNoEntry: false })?.isSymbolicLink()) {
3029
+ console.log(` = ${join(MANAGED_SKILLS_DIRECTORY_NAME, skillDir.name)} (linked skill left in place)`);
3030
+ continue;
3031
+ }
3016
3032
  const stats = copyTree(skillSourceDirectory, skillDestinationDirectory);
3017
3033
  skillsCreated += stats.created;
3018
3034
  skillsUpdated += stats.updated;
@@ -9,6 +9,8 @@ import {
9
9
  readFileSync,
10
10
  readdirSync,
11
11
  rmSync,
12
+ symlinkSync,
13
+ lstatSync,
12
14
  } from 'node:fs';
13
15
  import { tmpdir } from 'node:os';
14
16
  import { join, dirname, basename } from 'node:path';
@@ -828,6 +830,49 @@ test('a fresh scoped core install includes efficient-review', () => {
828
830
  }
829
831
  });
830
832
 
833
+ test('a full reinstall leaves a skill folder linked into another repository untouched', () => {
834
+ const sandbox = createSandbox();
835
+ const linkedRepositoryDirectory = mkdtempSync(join(tmpdir(), 'cdev-linked-skill-repo-'));
836
+ try {
837
+ runInstaller(sandbox.homeDirectory, []);
838
+ const linkedSkillName = 'efficient-review';
839
+ const repositorySkillDirectory = join(linkedRepositoryDirectory, 'skills', linkedSkillName);
840
+ const repositorySkillText = 'name: efficient-review\nthe repository copy\n';
841
+ writeFileWithParents(join(repositorySkillDirectory, 'SKILL.md'), repositorySkillText);
842
+ const skillLinkPath = join(sandbox.skillsDirectory, linkedSkillName);
843
+ rmSync(skillLinkPath, { recursive: true, force: true });
844
+ symlinkSync(repositorySkillDirectory, skillLinkPath, 'junction');
845
+
846
+ const installerOutput = runInstallerReadingBothStreams(sandbox.homeDirectory, []);
847
+
848
+ assert.equal(lstatSync(skillLinkPath).isSymbolicLink(), true, 'the skill link stays a link');
849
+ assert.equal(
850
+ readFileSync(join(repositorySkillDirectory, 'SKILL.md'), 'utf8'),
851
+ repositorySkillText,
852
+ 'the repository SKILL.md keeps its own text',
853
+ );
854
+ assert.deepEqual(
855
+ readdirSync(repositorySkillDirectory),
856
+ ['SKILL.md'],
857
+ 'the install writes no package file into the linked repository',
858
+ );
859
+ assert.equal(
860
+ prunedSkillBackupContains(sandbox.claudeDirectory, join(linkedSkillName, 'SKILL.md')),
861
+ false,
862
+ 'the stale prune moves no file out of the linked repository',
863
+ );
864
+ assert.match(installerOutput, /skills[\\/]efficient-review \(linked skill left in place\)/);
865
+ assert.equal(
866
+ readManifest(sandbox.manifestPath).files.some(recordedPath => recordedPath.includes(linkedSkillName)),
867
+ false,
868
+ 'the manifest records no file under the linked skill',
869
+ );
870
+ } finally {
871
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
872
+ rmSync(linkedRepositoryDirectory, { recursive: true, force: true });
873
+ }
874
+ });
875
+
831
876
  test('a fresh scoped core install ships active skills and omits retired skills', () => {
832
877
  const sandbox = createSandbox();
833
878
  try {
@@ -6,7 +6,7 @@ Back to the [rules index](../../rules/index.md).
6
6
 
7
7
  Scope every search to a project, worktree, package, or narrowing filter. Never start at a filesystem root, drive root, bare home, or share root. Read a known path directly; use scoped `es.exe`, Glob, or Grep for discovery. After `es.exe` Error 8, retry twice before falling back within the same scope. Run one large shell walk at a time.
8
8
 
9
- **Enforcement:** none.
9
+ **Enforcement:** `hooks/blocking/transcript_root_search_gate.py` for Claude transcript roots only. The rest is guidance.
10
10
 
11
11
  **When this applies:** Any search for files by name, path, extension, size, or date. That includes `es.exe`, a shell `find`, a recursive `Get-ChildItem` / `gci` / `dir` / `ls -R`, or the harness Grep and Glob tools.
12
12
 
@@ -55,5 +55,6 @@ Issue one shell search at a time when the walk is large. Parallel full-tree sear
55
55
 
56
56
  ## Search controls
57
57
 
58
- - No hook denies a walk from an unscoped root. The scope invariant above is guidance a reader follows.
58
+ - `hooks/blocking/transcript_root_search_gate.py` denies a recursive walk or content search that starts at a Claude transcript root, such as `~/.claude/projects` or `~/.claude-profiles/<profile>/projects`. List the project folders by name first, then read the one transcript.
59
+ - No hook denies a walk from any other unscoped root. The scope invariant above is guidance a reader follows.
59
60
  - For Everything searches that use a project name, run `python "${CLAUDE_SKILL_DIR}/scripts/everything_search.py" <project-name> <search arguments>`. An exact project name becomes its path from `~/.claude/project-paths.json`. The command then starts `es.exe` without a shell. `scripts/setup_project_paths.py` writes the registry.
@@ -22,7 +22,7 @@ from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
22
22
  )
23
23
 
24
24
 
25
- def test_bash_roster_starts_only_the_msys_rewriter_and_the_four_gates() -> None:
25
+ def test_bash_roster_starts_only_the_msys_rewriter_and_the_five_gates() -> None:
26
26
  assert ALL_BASH_HOSTED_HOOK_ENTRIES == (
27
27
  BashHostedHookEntry(
28
28
  script_relative_path="blocking/msys_rev_path_rewriter.py",
@@ -46,4 +46,8 @@ def test_bash_roster_starts_only_the_msys_rewriter_and_the_four_gates() -> None:
46
46
  script_relative_path="blocking/python_windows_invocation_gate.py",
47
47
  applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
48
48
  ),
49
+ BashHostedHookEntry(
50
+ script_relative_path="blocking/transcript_root_search_gate.py",
51
+ applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
52
+ ),
49
53
  )
@@ -274,6 +274,7 @@ def test_should_drop_the_gates_the_shell_guards_mod_holds_while_it_is_on(
274
274
  "blocking/msys_rev_path_rewriter.py",
275
275
  "blocking/cloud_graphql_gate.py",
276
276
  "blocking/python_windows_invocation_gate.py",
277
+ "blocking/transcript_root_search_gate.py",
277
278
  ]
278
279
 
279
280
 
@@ -0,0 +1,79 @@
1
+ """Tests for transcript_root_powershell_starts, which reads a cmdlet call's search starts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from blocking.transcript_root_powershell_starts import (
6
+ powershell_listing_search_starts,
7
+ select_string_search_starts,
8
+ )
9
+ from blocking.transcript_root_search_starts import SearchStart
10
+
11
+
12
+ def test_reads_an_unknown_option_value_as_no_path() -> None:
13
+ assert powershell_listing_search_starts(["-Recurse", "-ea", "0", "-Filter", "*.jsonl"]) == [
14
+ SearchStart(".", True)
15
+ ]
16
+
17
+
18
+ def test_splits_a_comma_path_list() -> None:
19
+ assert powershell_listing_search_starts(["-Path", "a,b"]) == [
20
+ SearchStart("a", False),
21
+ SearchStart("b", False),
22
+ ]
23
+
24
+
25
+ def test_lets_depth_zero_override_recurse() -> None:
26
+ assert powershell_listing_search_starts(["x", "-Recurse", "-Depth", "0"]) == [
27
+ SearchStart("x", False)
28
+ ]
29
+
30
+
31
+ def test_reads_a_glued_depth_as_recursive() -> None:
32
+ assert powershell_listing_search_starts(["x", "-Depth:3"]) == [SearchStart("x", True)]
33
+
34
+
35
+ def test_reads_a_false_recurse_value_as_one_level() -> None:
36
+ assert powershell_listing_search_starts(["x", "-Recurse:$false"]) == [SearchStart("x", False)]
37
+
38
+
39
+ def test_select_string_search_starts_reads_only_its_named_paths() -> None:
40
+ assert select_string_search_starts(["-Path", "p/*.jsonl", "-Pattern", "needle"]) == [
41
+ SearchStart("p/*.jsonl", False)
42
+ ]
43
+
44
+
45
+ def test_reads_recurse_after_a_switch_off_the_list() -> None:
46
+ assert powershell_listing_search_starts(["x", "-Verbose", "-Recurse"]) == [
47
+ SearchStart("x", True)
48
+ ]
49
+
50
+
51
+ def test_reads_a_switch_abbreviation_as_its_switch() -> None:
52
+ assert powershell_listing_search_starts(["x", "-Recu", "-Filter", "*.jsonl"]) == [
53
+ SearchStart("x", True)
54
+ ]
55
+
56
+
57
+ def test_reads_a_directory_abbreviation_without_taking_the_path() -> None:
58
+ assert powershell_listing_search_starts(["-Dire", "x"]) == [SearchStart("x", False)]
59
+
60
+
61
+ def test_reads_a_depth_abbreviation_as_depth() -> None:
62
+ assert powershell_listing_search_starts(["x", "-Dep", "2"]) == [SearchStart("x", True)]
63
+
64
+
65
+ def test_reads_the_path_after_a_common_parameter_switch() -> None:
66
+ assert powershell_listing_search_starts(["-Verbose", "x", "-Recurse"]) == [
67
+ SearchStart("x", True)
68
+ ]
69
+ assert powershell_listing_search_starts(["-WhatIf", "x", "-Recurse"]) == [
70
+ SearchStart("x", True)
71
+ ]
72
+
73
+
74
+ def test_reads_a_short_path_abbreviation_as_path() -> None:
75
+ assert powershell_listing_search_starts(["-Pa", "x", "-Recurse"]) == [SearchStart("x", True)]
76
+
77
+
78
+ def test_reads_an_ambiguous_abbreviation_as_an_option_value() -> None:
79
+ assert powershell_listing_search_starts(["-De", "2", "x"]) == [SearchStart("x", False)]
@@ -0,0 +1,180 @@
1
+ """Tests for transcript_root_search_gate, which stops walks over a Claude transcript root."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from io import StringIO
7
+ from unittest.mock import patch
8
+
9
+ import pytest
10
+
11
+ import transcript_root_search_gate as gate
12
+
13
+ _OVERDUE_SEARCH_COMMAND = (
14
+ '$p="C:/Users/dev/.claude-profiles/work/projects"; '
15
+ "Get-ChildItem $p -Recurse -File -Filter *.jsonl | "
16
+ "Where-Object LastWriteTime -gt (Get-Date).AddDays(-5) | "
17
+ "Select-String -Pattern 'lock-clock-digit-port' -List"
18
+ )
19
+ _SCOPED_LISTING_COMMAND = (
20
+ '$p="C:/Users/dev/.claude-profiles/work/projects"; '
21
+ 'Get-ChildItem $p -Directory -Filter "*01WUQeBG*"'
22
+ )
23
+
24
+
25
+ def _stdout_from_main(payload: dict[str, object]) -> str:
26
+ """Return what main() wrote to stdout for one hook payload."""
27
+ captured_stdout = StringIO()
28
+ with (
29
+ patch("sys.stdin", StringIO(json.dumps(payload))),
30
+ patch("sys.stdout", captured_stdout),
31
+ patch.object(gate, "log_hook_block"),
32
+ ):
33
+ gate.main()
34
+ return captured_stdout.getvalue()
35
+
36
+
37
+ def _shell_payload(tool_name: str, command: str) -> dict[str, object]:
38
+ return {"tool_name": tool_name, "tool_input": {"command": command}, "cwd": "C:/dev/repo"}
39
+
40
+
41
+ def _is_denied(stdout_text: str) -> bool:
42
+ if not stdout_text.strip():
43
+ return False
44
+ return json.loads(stdout_text)["hookSpecificOutput"]["permissionDecision"] == "deny"
45
+
46
+
47
+ @pytest.mark.parametrize(
48
+ "command",
49
+ [
50
+ _OVERDUE_SEARCH_COMMAND,
51
+ r"Get-ChildItem C:\Users\dev\.claude\projects -Recurse -Filter *.jsonl",
52
+ r'gci -Path "$env:USERPROFILE\.claude\projects" -Recurse:$true',
53
+ r"dir C:\Users\dev\.claude\projects -Depth 3",
54
+ "$p = '~/.claude/projects'; Get-ChildItem -LiteralPath $p -Recurse",
55
+ r'Select-String -Path "$HOME\.claude\projects\*\*.jsonl" -Pattern needle',
56
+ "Get-ChildItem ~/.claude/projects/* -Recurse",
57
+ ],
58
+ )
59
+ def test_should_deny_a_powershell_walk_over_a_transcript_root(command: str) -> None:
60
+ assert _is_denied(_stdout_from_main(_shell_payload("PowerShell", command)))
61
+
62
+
63
+ @pytest.mark.parametrize(
64
+ "command",
65
+ [
66
+ "find ~/.claude/projects -name '*.jsonl'",
67
+ "find -L /c/Users/dev/.claude/projects/ -type f",
68
+ "grep -rn needle ~/.claude-profiles/work/projects/",
69
+ "grep -Rl needle /home/u/.claude/projects",
70
+ "rg needle /c/Users/dev/.claude-work/projects",
71
+ "ls -laR ~/.claude/projects",
72
+ 'p=~/.claude/projects; grep -rl needle "$p"',
73
+ 'export ROOT="$HOME/.claude/projects" && find "${ROOT}" -newer x',
74
+ "bash -c 'find ~/.claude/projects -name x'",
75
+ "grep needle ~/.claude/projects/*/*.jsonl",
76
+ ],
77
+ )
78
+ def test_should_deny_a_bash_walk_over_a_transcript_root(command: str) -> None:
79
+ assert _is_denied(_stdout_from_main(_shell_payload("Bash", command)))
80
+
81
+
82
+ @pytest.mark.parametrize(
83
+ ("tool_name", "command"),
84
+ [
85
+ ("PowerShell", _SCOPED_LISTING_COMMAND),
86
+ ("PowerShell", r"Get-ChildItem C:\Users\dev\.claude\projects"),
87
+ ("PowerShell", r"Get-ChildItem C:\Users\dev\.claude\projects\*01WUQeBG*"),
88
+ ("PowerShell", r"Get-ChildItem C:\Users\dev\.claude\projects\C--dev-repo -Recurse"),
89
+ ("PowerShell", "Select-String -Path ~/.claude/projects/C--dev-repo/a.jsonl -Pattern x"),
90
+ ("PowerShell", r"dir C:\Users\dev\.claude\projects -Depth 0"),
91
+ ("Bash", "find ~/.claude/projects -maxdepth 1 -name '*01WUQeBG*'"),
92
+ ("Bash", "grep -rn needle ~/.claude/projects/C--dev-repo"),
93
+ ("Bash", "grep -rn needle ~/.claude/skills"),
94
+ ("Bash", "grep -n projects notes.txt"),
95
+ ("Bash", "find . -name projects"),
96
+ ("Bash", "ls -la ~/.claude/projects"),
97
+ ("Bash", "rg needle packages/claude-dev-env"),
98
+ ],
99
+ )
100
+ def test_should_allow_a_scoped_command(tool_name: str, command: str) -> None:
101
+ assert _stdout_from_main(_shell_payload(tool_name, command)) == ""
102
+
103
+
104
+ @pytest.mark.parametrize(
105
+ "tool_input",
106
+ [
107
+ {"pattern": "needle", "path": "C:/Users/dev/.claude/projects"},
108
+ {"pattern": "needle", "path": "C:\\Users\\dev\\.claude-profiles\\work\\projects\\"},
109
+ {"pattern": "needle", "path": "/home/u/.claude-work/projects", "glob": "*.jsonl"},
110
+ ],
111
+ )
112
+ def test_should_deny_a_grep_tool_search_over_a_transcript_root(
113
+ tool_input: dict[str, str],
114
+ ) -> None:
115
+ payload = {"tool_name": "Grep", "tool_input": tool_input, "cwd": "C:/dev/repo"}
116
+ assert _is_denied(_stdout_from_main(payload))
117
+
118
+
119
+ def test_should_deny_a_grep_tool_search_that_starts_at_a_transcript_root_cwd() -> None:
120
+ payload = {
121
+ "tool_name": "Grep",
122
+ "tool_input": {"pattern": "needle"},
123
+ "cwd": "C:/Users/dev/.claude/projects",
124
+ }
125
+ assert _is_denied(_stdout_from_main(payload))
126
+
127
+
128
+ def test_should_allow_a_grep_tool_search_inside_one_project_folder() -> None:
129
+ payload = {
130
+ "tool_name": "Grep",
131
+ "tool_input": {"pattern": "needle", "path": "C:/Users/dev/.claude/projects/C--dev-repo"},
132
+ "cwd": "C:/dev/repo",
133
+ }
134
+ assert _stdout_from_main(payload) == ""
135
+
136
+
137
+ @pytest.mark.parametrize(
138
+ "tool_input",
139
+ [
140
+ {"pattern": "**/*.jsonl", "path": "C:/Users/dev/.claude/projects"},
141
+ {"pattern": "C:/Users/dev/.claude/projects/**/*.jsonl"},
142
+ {"pattern": "*/*.jsonl", "path": "/home/u/.claude-profiles/work/projects"},
143
+ {"pattern": "**", "path": "/home/u/.claude/projects"},
144
+ ],
145
+ )
146
+ def test_should_deny_a_glob_tool_walk_over_a_transcript_root(
147
+ tool_input: dict[str, str],
148
+ ) -> None:
149
+ payload = {"tool_name": "Glob", "tool_input": tool_input, "cwd": "C:/dev/repo"}
150
+ assert _is_denied(_stdout_from_main(payload))
151
+
152
+
153
+ @pytest.mark.parametrize(
154
+ "tool_input",
155
+ [
156
+ {"pattern": "*01WUQeBG*", "path": "C:/Users/dev/.claude/projects"},
157
+ {"pattern": "*01WUQeBG*/*.jsonl", "path": "C:/Users/dev/.claude/projects"},
158
+ {"pattern": "**/*.py", "path": "C:/dev/repo"},
159
+ ],
160
+ )
161
+ def test_should_allow_a_glob_tool_listing_by_name(tool_input: dict[str, str]) -> None:
162
+ payload = {"tool_name": "Glob", "tool_input": tool_input, "cwd": "C:/dev/repo"}
163
+ assert _stdout_from_main(payload) == ""
164
+
165
+
166
+ def test_should_name_the_list_then_read_fix_in_the_deny_reason() -> None:
167
+ stdout_text = _stdout_from_main(_shell_payload("PowerShell", _OVERDUE_SEARCH_COMMAND))
168
+ deny_reason = json.loads(stdout_text)["hookSpecificOutput"]["permissionDecisionReason"]
169
+ assert "-Directory -Filter" in deny_reason
170
+ assert "C:/Users/dev/.claude-profiles/work/projects" in deny_reason
171
+ assert "read the one transcript" in deny_reason
172
+
173
+
174
+ def test_should_stay_quiet_for_another_tool() -> None:
175
+ payload = {
176
+ "tool_name": "Read",
177
+ "tool_input": {"file_path": "C:/Users/dev/.claude/projects"},
178
+ "cwd": "C:/dev/repo",
179
+ }
180
+ assert _stdout_from_main(payload) == ""
@@ -0,0 +1,85 @@
1
+ """Tests for transcript_root_search_starts, which reads where a search begins."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from blocking.transcript_root_search_starts import (
8
+ SearchStart,
9
+ file_search_tool_start,
10
+ joined_path,
11
+ normalized_path,
12
+ walked_transcript_root,
13
+ )
14
+
15
+
16
+ @pytest.mark.parametrize(
17
+ ("search_start", "expected_root"),
18
+ [
19
+ (SearchStart("~/.claude/projects", True), "~/.claude/projects"),
20
+ (SearchStart("~/.claude/projects/", True), "~/.claude/projects"),
21
+ (SearchStart("~/.claude/projects/.", True), "~/.claude/projects"),
22
+ (SearchStart("~/.claude/projects/C--x/..", True), "~/.claude/projects"),
23
+ (
24
+ SearchStart(r"C:\Users\dev\.claude-work\projects", True),
25
+ "C:/Users/dev/.claude-work/projects",
26
+ ),
27
+ (
28
+ SearchStart("/h/.claude-profiles/work/projects/*/*.jsonl", False),
29
+ "/h/.claude-profiles/work/projects",
30
+ ),
31
+ (SearchStart("~/.claude/projects/**", True), "~/.claude/projects"),
32
+ (SearchStart("~/.claude/projects/**", False), None),
33
+ (SearchStart("~/.claude/projects", False), None),
34
+ (SearchStart("~/.claude/projects/*01WU*", False), None),
35
+ (SearchStart("~/.claude/projects/C--repo", True), None),
36
+ (SearchStart("~/.claude/skills", True), None),
37
+ (SearchStart("~/notes/projects", True), None),
38
+ ],
39
+ )
40
+ def test_walked_transcript_root_reads_the_root_a_start_walks(
41
+ search_start: SearchStart, expected_root: str | None
42
+ ) -> None:
43
+ assert walked_transcript_root(search_start) == expected_root
44
+
45
+
46
+ def test_normalized_path_folds_quotes_backslashes_and_dot_segments() -> None:
47
+ assert normalized_path('"C:\\h\\.claude\\projects\\C--x\\..\\"') == "C:/h/.claude/projects"
48
+
49
+
50
+ def test_joined_path_reads_a_relative_path_from_the_base_directory() -> None:
51
+ assert joined_path("/home/u/.claude", "projects") == "/home/u/.claude/projects"
52
+
53
+
54
+ def test_joined_path_keeps_an_absolute_path() -> None:
55
+ assert joined_path("/cwd", "~/.claude/projects") == "~/.claude/projects"
56
+
57
+
58
+ def test_file_search_tool_start_reads_grep_as_recursive_from_its_path() -> None:
59
+ assert file_search_tool_start("Grep", {"path": "/h/.claude/projects"}, "/cwd") == SearchStart(
60
+ "/h/.claude/projects", True
61
+ )
62
+
63
+
64
+ def test_file_search_tool_start_reads_a_relative_grep_path_from_the_session_directory() -> None:
65
+ assert file_search_tool_start("Grep", {"path": "projects"}, "/h/.claude") == SearchStart(
66
+ "/h/.claude/projects", True
67
+ )
68
+
69
+
70
+ def test_file_search_tool_start_reads_grep_with_no_path_from_the_session_directory() -> None:
71
+ assert file_search_tool_start("Grep", {"pattern": "x"}, "/h/.claude/projects") == (
72
+ SearchStart("/h/.claude/projects", True)
73
+ )
74
+
75
+
76
+ def test_file_search_tool_start_joins_a_glob_pattern_below_its_path() -> None:
77
+ assert file_search_tool_start(
78
+ "Glob", {"pattern": "*/*.jsonl", "path": "C:\\h\\.claude\\projects\\"}, "/cwd"
79
+ ) == SearchStart("C:/h/.claude/projects/*/*.jsonl", False)
80
+
81
+
82
+ def test_file_search_tool_start_keeps_an_absolute_glob_pattern_and_reads_its_recursion() -> None:
83
+ assert file_search_tool_start("Glob", {"pattern": "/h/.claude/projects/**"}, "/cwd") == (
84
+ SearchStart("/h/.claude/projects/**", True)
85
+ )
@@ -0,0 +1,131 @@
1
+ """Tests for transcript_root_shell_starts, which reads where a shell search begins."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from blocking.transcript_root_search_starts import SearchStart, walked_transcript_root
8
+ from blocking.transcript_root_shell_starts import shell_command_search_starts
9
+
10
+ ROOT = "C:/Users/dev/.claude/projects"
11
+
12
+
13
+ def _walked_roots(command: str, cwd: str = "", is_powershell: bool = False) -> list[str]:
14
+ return [
15
+ each_root
16
+ for each_root in (
17
+ walked_transcript_root(each_start)
18
+ for each_start in shell_command_search_starts(command, cwd, is_powershell)
19
+ )
20
+ if each_root
21
+ ]
22
+
23
+
24
+ def test_expands_a_variable_the_command_assigns() -> None:
25
+ all_starts = shell_command_search_starts(
26
+ f'$p="{ROOT}"; Get-ChildItem $p -Recurse -Filter *.jsonl', "", True
27
+ )
28
+ assert SearchStart(ROOT, True) in all_starts
29
+
30
+
31
+ def test_reads_a_name_listing_as_one_level() -> None:
32
+ all_starts = shell_command_search_starts(
33
+ f'Get-ChildItem {ROOT} -Directory -Filter "*01WUQeBG*"', "", True
34
+ )
35
+ assert SearchStart(ROOT, False) in all_starts
36
+
37
+
38
+ def test_skips_a_command_that_never_names_a_projects_folder() -> None:
39
+ assert shell_command_search_starts("grep -r needle /src", "/home/u", False) == []
40
+
41
+
42
+ @pytest.mark.parametrize(
43
+ ("command", "cwd"),
44
+ [
45
+ pytest.param(f"grep -rC2 needle {ROOT}", "", id="short-cluster-with-digit"),
46
+ pytest.param(f"grep -R1 needle {ROOT}", "", id="short-cluster-r-digit"),
47
+ pytest.param(f"grep -r needle {ROOT}/.", "", id="dot-segment"),
48
+ pytest.param("cd ~/.claude/projects && grep -r needle", "", id="cd-then-default-path"),
49
+ pytest.param("cd ~/.claude/projects && grep -rn -C 2 needle", "", id="grep-option-value"),
50
+ pytest.param("cd ~/.claude/projects && rg -g '*.jsonl' needle", "", id="rg-option-value"),
51
+ pytest.param("grep -r -e a -e b", "/home/u/.claude/projects", id="grep-pattern-options"),
52
+ pytest.param("grep -r needle", "/home/u/.claude/projects", id="session-directory"),
53
+ pytest.param("cd && grep -r needle .claude/projects", "/x", id="bare-cd-goes-home"),
54
+ pytest.param(f"p={ROOT}; grep -r x $p; p={ROOT}/C--x", "", id="assignment-after-use"),
55
+ pytest.param(f"grep -d recurse needle {ROOT}", "", id="grep-directories-recurse"),
56
+ pytest.param(f"grep --directories=recurse needle {ROOT}", "", id="grep-glued-directories"),
57
+ pytest.param(f"rg needle {ROOT}", "", id="rg-default"),
58
+ pytest.param(f"rg --files {ROOT}", "", id="rg-files"),
59
+ pytest.param(f"rg --files -g '*.jsonl' {ROOT}", "", id="rg-files-glob"),
60
+ pytest.param(f"fd . {ROOT}", "", id="fd"),
61
+ pytest.param(f"tree {ROOT}", "", id="tree"),
62
+ pytest.param(f"find {ROOT} -name '*.jsonl'", "", id="find"),
63
+ pytest.param(f"ls -R {ROOT}", "", id="ls-recursive"),
64
+ pytest.param(f"cat {ROOT}/*/*.jsonl | grep needle", "", id="cat-every-transcript"),
65
+ pytest.param(f"pushd {ROOT} && grep -r needle .", "", id="pushd-then-walk"),
66
+ pytest.param(
67
+ f"pushd {ROOT} && pushd {ROOT}/C--x && popd && grep -r needle .",
68
+ "",
69
+ id="popd-one-level",
70
+ ),
71
+ ],
72
+ )
73
+ def test_reads_a_bash_walk_of_the_root(command: str, cwd: str) -> None:
74
+ assert _walked_roots(command, cwd)
75
+
76
+
77
+ @pytest.mark.parametrize(
78
+ ("command", "cwd"),
79
+ [
80
+ pytest.param(f'$P="{ROOT}"; gci $p -Recurse', "", id="variable-case"),
81
+ pytest.param(
82
+ '$h="C:/Users/dev"; $p="$h/.claude/projects"; gci $p -Recurse', "", id="chained"
83
+ ),
84
+ pytest.param(
85
+ r"Set-Location C:\Users\dev\.claude\projects; Get-ChildItem -Recurse -Filter *.jsonl",
86
+ "",
87
+ id="backslash-set-location",
88
+ ),
89
+ pytest.param("Set-Location C:/Users/dev/.claude; gci projects -Recurse", "", id="relative"),
90
+ pytest.param("Get-ChildItem -Recurse -ea 0 -Filter *.jsonl", ROOT, id="unknown-option"),
91
+ pytest.param(f"gci C:/x,{ROOT} -Recurse", "", id="comma-path-list"),
92
+ pytest.param(f"gci {ROOT} -Depth:3", "", id="glued-depth"),
93
+ pytest.param(f"ls -r {ROOT}", "", id="ls-alias-recurse"),
94
+ pytest.param(f"ls {ROOT} -Depth 2", "", id="ls-alias-depth"),
95
+ pytest.param(f"Get-Content {ROOT}/*/*.jsonl | Select-String x", "", id="get-content"),
96
+ ],
97
+ )
98
+ def test_reads_a_powershell_walk_of_the_root(command: str, cwd: str) -> None:
99
+ assert _walked_roots(command, cwd, is_powershell=True)
100
+
101
+
102
+ @pytest.mark.parametrize(
103
+ "command",
104
+ [
105
+ pytest.param(f"rg --max-depth 1 needle {ROOT}", id="rg-spaced-max-depth"),
106
+ pytest.param(f"rg --max-depth=1 needle {ROOT}", id="rg-glued-max-depth"),
107
+ pytest.param(f"find {ROOT} -maxdepth 1 -type d", id="find-maxdepth"),
108
+ pytest.param(f"tree -L 1 {ROOT}", id="tree-one-level"),
109
+ pytest.param(f"ls {ROOT}/**", id="ls-double-star"),
110
+ pytest.param(f"grep -r needle {ROOT}/C--repo", id="one-project-folder"),
111
+ pytest.param(f"grep -eremote {ROOT}", id="glued-pattern-is-no-recursion"),
112
+ pytest.param(f"git status {ROOT}", id="plain-program-on-the-root"),
113
+ pytest.param(f"p={ROOT}/C--x; grep -r x $p; p={ROOT}", id="assignment-after-scoped-use"),
114
+ pytest.param(f"pushd {ROOT} && popd && grep -r needle .", id="popd-returns"),
115
+ ],
116
+ )
117
+ def test_reads_a_scoped_bash_search_as_no_walk(command: str) -> None:
118
+ assert _walked_roots(command) == []
119
+
120
+
121
+ @pytest.mark.parametrize(
122
+ "command",
123
+ [
124
+ pytest.param(f"gci {ROOT} -Recurse -Depth 0", id="depth-zero-after-recurse"),
125
+ pytest.param(f"gci {ROOT} -Depth 0 -Recurse", id="depth-zero-before-recurse"),
126
+ pytest.param(f"ls {ROOT} -ReadOnly", id="ls-alias-switch"),
127
+ pytest.param(f"Select-String -Path {ROOT}/*01WU*/x.jsonl -Pattern a", id="select-string"),
128
+ ],
129
+ )
130
+ def test_reads_a_scoped_powershell_search_as_no_walk(command: str) -> None:
131
+ assert _walked_roots(command, is_powershell=True) == []