claude-dev-env 4.1.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.
- package/.agents/skills/everything-search/SKILL.md +18 -3
- package/.agents/skills/everything-search/scripts/everything_search.py +204 -0
- package/.agents/skills/everything-search/scripts/everything_search_command_constants/__init__.py +1 -0
- package/.agents/skills/everything-search/scripts/everything_search_command_constants/config/__init__.py +1 -0
- package/.agents/skills/everything-search/scripts/everything_search_command_constants/config/constants.py +11 -0
- package/.agents/skills/everything-search/scripts/test_everything_search.py +172 -0
- package/bin/install.prune.test.mjs +59 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +5 -8
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +7 -13
- package/package.json +1 -1
- package/rules/filesystem-search.md +5 -5
- package/hooks/blocking/es_exe_path_rewriter.py +0 -159
- package/hooks/blocking/test_es_exe_path_rewriter.py +0 -447
- package/hooks/hooks_constants/path_rewriter_constants.py +0 -13
- package/hooks/hooks_constants/test_path_rewriter_constants.py +0 -57
|
@@ -13,13 +13,23 @@ Search files instantly on Windows using the Everything command-line interface (e
|
|
|
13
13
|
|
|
14
14
|
## Hard limits
|
|
15
15
|
|
|
16
|
-
Every search carries a scope: a project path or registry token, an `ext:` filter, a `dm:` date filter, a `size:` filter, or a name pattern. A bare whole-drive scan or a network-share sweep is out of bounds
|
|
16
|
+
Every search carries a scope: a project path or registry token, an `ext:` filter, a `dm:` date filter, a `size:` filter, or a name pattern. A bare whole-drive scan or a network-share sweep is out of bounds. Narrow the search to what you need.
|
|
17
17
|
|
|
18
18
|
When `es.exe` fails or returns nothing, self-heal first: fall back to the `Glob` tool (name and path patterns) or `Grep` (file contents), and report the outage so the reader knows the index was unavailable. When self-healing also fails, ask the user through `AskUserQuestion` with a short analysis and next-step options.
|
|
19
19
|
|
|
20
20
|
## Registry tokens
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
Run project-name searches through this command:
|
|
23
|
+
|
|
24
|
+
```text
|
|
25
|
+
python "${CLAUDE_SKILL_DIR}/scripts/everything_search.py" <project-name> <search arguments>
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The command reads `~/.claude/project-paths.json`. An exact project name or `{project-name}` argument becomes one absolute path. Unknown names, absolute paths, flags, and spaces stay as they are. The command then starts `es.exe` without a shell.
|
|
29
|
+
|
|
30
|
+
Put the search scope first. The first argument names a project, path, file, extension, date, or size. An options-only request exits before `es.exe` starts. Use `-version` alone to read the installed Everything version.
|
|
31
|
+
|
|
32
|
+
Path and operator searches run when the registry file is missing. Malformed registry content, an empty search, a missing `es.exe`, or a process start failure returns a nonzero exit. After a successful start, the command returns the `es.exe` exit status.
|
|
23
33
|
|
|
24
34
|
## Instructions
|
|
25
35
|
|
|
@@ -131,7 +141,7 @@ Run separate searches for each extension type:
|
|
|
131
141
|
|
|
132
142
|
- Everything must be running (system tray) for es.exe to work
|
|
133
143
|
- Run one extension per search for cleaner results
|
|
134
|
-
- Use `dm:`
|
|
144
|
+
- Use `dm:` to filter recent files by the index timestamp
|
|
135
145
|
- Combine with path to narrow scope
|
|
136
146
|
- Results return instantly regardless of drive size
|
|
137
147
|
|
|
@@ -152,3 +162,8 @@ Everything indexes REAL paths only, not junctions or mapped drives.
|
|
|
152
162
|
**Common drive mappings:**
|
|
153
163
|
- `Z:\Projects\` -> `Y:\Work\Projects\`
|
|
154
164
|
- When in Z:\, translate to Y:\ equivalent for Everything searches
|
|
165
|
+
|
|
166
|
+
## Files
|
|
167
|
+
|
|
168
|
+
- `scripts/everything_search.py` runs one Everything search without a shell.
|
|
169
|
+
- `scripts/test_everything_search.py` verifies registry expansion and command failures.
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
9
|
+
from typing import TextIO
|
|
10
|
+
|
|
11
|
+
from everything_search_command_constants.config.constants import (
|
|
12
|
+
CLAUDE_DIRECTORY_NAME,
|
|
13
|
+
EXECUTABLE_NAME,
|
|
14
|
+
EXECUTION_ERROR_EXIT_CODE,
|
|
15
|
+
INFORMATIONAL_ARGUMENT,
|
|
16
|
+
INVALID_INPUT_EXIT_CODE,
|
|
17
|
+
PROJECT_PATHS_FILE_NAME,
|
|
18
|
+
REGISTRY_META_KEY,
|
|
19
|
+
SEARCH_SCOPE_REQUIRED_MESSAGE,
|
|
20
|
+
UTF8_ENCODING,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RegistryRunFatal(ValueError):
|
|
25
|
+
"""Stop the run when the project-path registry is unreadable or invalid."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_registry(registry_path: Path) -> dict[str, str]:
|
|
29
|
+
"""Read project names and absolute paths from a registry file.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
registry_path: Registry file to read.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
The registered paths keyed by project name.
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
RegistryRunFatal: The file is unreadable or its content is invalid.
|
|
39
|
+
"""
|
|
40
|
+
if not registry_path.is_file():
|
|
41
|
+
return {}
|
|
42
|
+
try:
|
|
43
|
+
all_registry_entries = json.loads(
|
|
44
|
+
registry_path.read_text(encoding=UTF8_ENCODING)
|
|
45
|
+
)
|
|
46
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
|
47
|
+
raise RegistryRunFatal(f"Malformed registry: {error}") from error
|
|
48
|
+
return _validated_registry_entries(all_registry_entries)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _validated_registry_entries(all_registry_entries: object) -> dict[str, str]:
|
|
52
|
+
if not isinstance(all_registry_entries, dict):
|
|
53
|
+
raise RegistryRunFatal("Invalid registry: expected an object")
|
|
54
|
+
all_registered_paths_by_name: dict[str, str] = {}
|
|
55
|
+
for each_registry_name, each_registered_path in all_registry_entries.items():
|
|
56
|
+
if each_registry_name == REGISTRY_META_KEY:
|
|
57
|
+
continue
|
|
58
|
+
if not isinstance(each_registry_name, str) or not isinstance(
|
|
59
|
+
each_registered_path, str
|
|
60
|
+
):
|
|
61
|
+
raise RegistryRunFatal("Invalid registry: names and paths must be strings")
|
|
62
|
+
if not _is_absolute_path(each_registered_path):
|
|
63
|
+
raise RegistryRunFatal("Invalid registry: paths must be absolute")
|
|
64
|
+
all_registered_paths_by_name[each_registry_name] = each_registered_path
|
|
65
|
+
return all_registered_paths_by_name
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _is_absolute_path(search_argument: str) -> bool:
|
|
69
|
+
try:
|
|
70
|
+
return (
|
|
71
|
+
PureWindowsPath(search_argument).is_absolute()
|
|
72
|
+
or PurePosixPath(search_argument).is_absolute()
|
|
73
|
+
)
|
|
74
|
+
except ValueError:
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _registry_name(search_argument: str) -> str:
|
|
79
|
+
if search_argument.startswith("{") and search_argument.endswith("}"):
|
|
80
|
+
return search_argument[1:-1]
|
|
81
|
+
return search_argument
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def expand_search_arguments(
|
|
85
|
+
all_search_arguments: Sequence[str],
|
|
86
|
+
all_registered_paths_by_name: Mapping[str, str],
|
|
87
|
+
) -> list[str]:
|
|
88
|
+
"""Return the registered path for an exact project name or `{project-name}` token.
|
|
89
|
+
|
|
90
|
+
Unknown names, absolute paths, flags, and spaces stay as their own arguments.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
all_search_arguments: Everything search arguments.
|
|
94
|
+
all_registered_paths_by_name: Absolute paths keyed by project name.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Arguments with exact project names as their registered paths.
|
|
98
|
+
"""
|
|
99
|
+
all_expanded_arguments: list[str] = []
|
|
100
|
+
for each_search_argument in all_search_arguments:
|
|
101
|
+
registry_name = _registry_name(each_search_argument)
|
|
102
|
+
if (
|
|
103
|
+
not _is_absolute_path(each_search_argument)
|
|
104
|
+
and registry_name in all_registered_paths_by_name
|
|
105
|
+
):
|
|
106
|
+
all_expanded_arguments.append(all_registered_paths_by_name[registry_name])
|
|
107
|
+
else:
|
|
108
|
+
all_expanded_arguments.append(each_search_argument)
|
|
109
|
+
return all_expanded_arguments
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _run_search(
|
|
113
|
+
executable_path: str,
|
|
114
|
+
all_search_arguments: Sequence[str],
|
|
115
|
+
search_stdout: TextIO,
|
|
116
|
+
search_stderr: TextIO,
|
|
117
|
+
) -> int:
|
|
118
|
+
try:
|
|
119
|
+
search_run = subprocess.run(
|
|
120
|
+
[executable_path, *all_search_arguments],
|
|
121
|
+
capture_output=True,
|
|
122
|
+
check=False,
|
|
123
|
+
encoding=UTF8_ENCODING,
|
|
124
|
+
shell=False,
|
|
125
|
+
text=True,
|
|
126
|
+
)
|
|
127
|
+
except OSError as error:
|
|
128
|
+
search_stderr.write(f"{error}\n")
|
|
129
|
+
return EXECUTION_ERROR_EXIT_CODE
|
|
130
|
+
search_stdout.write(search_run.stdout)
|
|
131
|
+
search_stderr.write(search_run.stderr)
|
|
132
|
+
return search_run.returncode
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _run_registered_search(
|
|
136
|
+
all_search_arguments: Sequence[str],
|
|
137
|
+
registry_path: Path,
|
|
138
|
+
executable_path: str | None,
|
|
139
|
+
search_stdout: TextIO,
|
|
140
|
+
search_stderr: TextIO,
|
|
141
|
+
) -> int:
|
|
142
|
+
try:
|
|
143
|
+
all_registered_paths_by_name = load_registry(registry_path)
|
|
144
|
+
except RegistryRunFatal as error:
|
|
145
|
+
search_stderr.write(f"{error}\n")
|
|
146
|
+
return INVALID_INPUT_EXIT_CODE
|
|
147
|
+
selected_executable_path = executable_path or shutil.which(EXECUTABLE_NAME)
|
|
148
|
+
if selected_executable_path is None:
|
|
149
|
+
search_stderr.write(f"{EXECUTABLE_NAME} was not found.\n")
|
|
150
|
+
return EXECUTION_ERROR_EXIT_CODE
|
|
151
|
+
all_expanded_arguments = expand_search_arguments(
|
|
152
|
+
all_search_arguments, all_registered_paths_by_name
|
|
153
|
+
)
|
|
154
|
+
return _run_search(
|
|
155
|
+
selected_executable_path,
|
|
156
|
+
all_expanded_arguments,
|
|
157
|
+
search_stdout,
|
|
158
|
+
search_stderr,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _has_search_scope(all_search_arguments: Sequence[str]) -> bool:
|
|
163
|
+
first_argument = all_search_arguments[0]
|
|
164
|
+
return bool(first_argument.strip()) and not first_argument.startswith("-")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _is_informational_operation(all_search_arguments: Sequence[str]) -> bool:
|
|
168
|
+
return (
|
|
169
|
+
len(all_search_arguments) == 1
|
|
170
|
+
and all_search_arguments[0].casefold() == INFORMATIONAL_ARGUMENT
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def main(all_search_arguments: Sequence[str]) -> int:
|
|
175
|
+
"""Expand project names and run one Everything search.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
all_search_arguments: Arguments passed to Everything.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
The Everything process exit status or a command error status.
|
|
182
|
+
"""
|
|
183
|
+
if not all_search_arguments:
|
|
184
|
+
sys.stderr.write("At least one search argument is required.\n")
|
|
185
|
+
return INVALID_INPUT_EXIT_CODE
|
|
186
|
+
if not _has_search_scope(all_search_arguments) and not _is_informational_operation(
|
|
187
|
+
all_search_arguments
|
|
188
|
+
):
|
|
189
|
+
sys.stderr.write(SEARCH_SCOPE_REQUIRED_MESSAGE)
|
|
190
|
+
return INVALID_INPUT_EXIT_CODE
|
|
191
|
+
selected_registry_path = (
|
|
192
|
+
Path.home() / CLAUDE_DIRECTORY_NAME / PROJECT_PATHS_FILE_NAME
|
|
193
|
+
)
|
|
194
|
+
return _run_registered_search(
|
|
195
|
+
all_search_arguments,
|
|
196
|
+
selected_registry_path,
|
|
197
|
+
None,
|
|
198
|
+
sys.stdout,
|
|
199
|
+
sys.stderr,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
if __name__ == "__main__":
|
|
204
|
+
raise SystemExit(main(sys.argv[1:]))
|
package/.agents/skills/everything-search/scripts/everything_search_command_constants/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Constants for the Everything search command."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Configuration for the Everything search command."""
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Configuration constants for the Everything search command."""
|
|
2
|
+
|
|
3
|
+
CLAUDE_DIRECTORY_NAME = ".claude"
|
|
4
|
+
EXECUTABLE_NAME = "es.exe"
|
|
5
|
+
EXECUTION_ERROR_EXIT_CODE = 1
|
|
6
|
+
INFORMATIONAL_ARGUMENT = "-version"
|
|
7
|
+
INVALID_INPUT_EXIT_CODE = 2
|
|
8
|
+
PROJECT_PATHS_FILE_NAME = "project-paths.json"
|
|
9
|
+
REGISTRY_META_KEY = "_meta"
|
|
10
|
+
SEARCH_SCOPE_REQUIRED_MESSAGE = "The first search argument must define a path, project, name, extension, date, or size scope.\n"
|
|
11
|
+
UTF8_ENCODING = "utf-8"
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from unittest.mock import patch
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
SCRIPTS_DIRECTORY = Path(__file__).resolve().parent
|
|
11
|
+
if str(SCRIPTS_DIRECTORY) not in sys.path:
|
|
12
|
+
sys.path.insert(0, str(SCRIPTS_DIRECTORY))
|
|
13
|
+
|
|
14
|
+
search = importlib.import_module("everything_search")
|
|
15
|
+
|
|
16
|
+
KNOWN_PATH = "Y:\\Projects\\my-repo"
|
|
17
|
+
REGISTRY = {"my-repo": KNOWN_PATH}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_expand_search_arguments_rewrites_bare_registry_token() -> None:
|
|
21
|
+
assert search.expand_search_arguments(["my-repo", "config.py"], REGISTRY) == [
|
|
22
|
+
KNOWN_PATH,
|
|
23
|
+
"config.py",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_expand_search_arguments_rewrites_exact_placeholder_token() -> None:
|
|
28
|
+
assert search.expand_search_arguments(["{my-repo}", "config.py"], REGISTRY) == [
|
|
29
|
+
KNOWN_PATH,
|
|
30
|
+
"config.py",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_expand_search_arguments_preserves_unknown_tokens_and_spaces() -> None:
|
|
35
|
+
all_arguments = ["unknown-name", "foo bar", "config.py"]
|
|
36
|
+
assert search.expand_search_arguments(all_arguments, REGISTRY) == all_arguments
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.mark.parametrize(
|
|
40
|
+
"absolute_path",
|
|
41
|
+
[
|
|
42
|
+
"C:/Users/example/file.py",
|
|
43
|
+
"\\\\server\\share\\folder",
|
|
44
|
+
"/mnt/c/Projects/repo",
|
|
45
|
+
],
|
|
46
|
+
)
|
|
47
|
+
def test_expand_search_arguments_preserves_absolute_paths(absolute_path: str) -> None:
|
|
48
|
+
assert search.expand_search_arguments([absolute_path], REGISTRY) == [absolute_path]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_expand_search_arguments_preserves_embedded_flag_tokens() -> None:
|
|
52
|
+
all_arguments = ["--flag={my-repo}", "--regex=^{my-repo}$"]
|
|
53
|
+
assert search.expand_search_arguments(all_arguments, REGISTRY) == all_arguments
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_load_registry_returns_empty_mapping_for_missing_file(tmp_path: Path) -> None:
|
|
57
|
+
assert search.load_registry(tmp_path / "missing.json") == {}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@pytest.mark.parametrize(
|
|
61
|
+
"registry_bytes",
|
|
62
|
+
[
|
|
63
|
+
b"\xff",
|
|
64
|
+
b"{not valid",
|
|
65
|
+
json.dumps({"my-repo": 42}).encode(),
|
|
66
|
+
json.dumps({"my-repo": ""}).encode(),
|
|
67
|
+
json.dumps({"my-repo": "relative/path"}).encode(),
|
|
68
|
+
],
|
|
69
|
+
)
|
|
70
|
+
def test_load_registry_reports_invalid_input(
|
|
71
|
+
tmp_path: Path, registry_bytes: bytes
|
|
72
|
+
) -> None:
|
|
73
|
+
registry_path = tmp_path / "project-paths.json"
|
|
74
|
+
registry_path.write_bytes(registry_bytes)
|
|
75
|
+
with pytest.raises(search.RegistryRunFatal, match="(Malformed|Invalid)"):
|
|
76
|
+
search.load_registry(registry_path)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_main_runs_direct_search_without_registry_or_shell() -> None:
|
|
80
|
+
completed_process = search.subprocess.CompletedProcess(
|
|
81
|
+
args=["es.exe", "foo bar"],
|
|
82
|
+
returncode=0,
|
|
83
|
+
stdout="found path\n",
|
|
84
|
+
stderr="",
|
|
85
|
+
)
|
|
86
|
+
captured_stdout = io.StringIO()
|
|
87
|
+
with (
|
|
88
|
+
patch.object(search, "load_registry", return_value={}),
|
|
89
|
+
patch.object(search.shutil, "which", return_value="es.exe"),
|
|
90
|
+
patch.object(
|
|
91
|
+
search.subprocess, "run", return_value=completed_process
|
|
92
|
+
) as run_process,
|
|
93
|
+
patch.object(search.sys, "stdout", captured_stdout),
|
|
94
|
+
):
|
|
95
|
+
exit_code = search.main(["foo bar"])
|
|
96
|
+
assert exit_code == 0
|
|
97
|
+
assert captured_stdout.getvalue() == "found path\n"
|
|
98
|
+
run_process.assert_called_once_with(
|
|
99
|
+
["es.exe", "foo bar"],
|
|
100
|
+
capture_output=True,
|
|
101
|
+
check=False,
|
|
102
|
+
encoding="utf-8",
|
|
103
|
+
shell=False,
|
|
104
|
+
text=True,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_main_passes_through_child_exit_status() -> None:
|
|
109
|
+
completed_process = search.subprocess.CompletedProcess(
|
|
110
|
+
args=["es.exe", "config.py"], returncode=7, stdout="", stderr="failed\n"
|
|
111
|
+
)
|
|
112
|
+
captured_stderr = io.StringIO()
|
|
113
|
+
with (
|
|
114
|
+
patch.object(search, "load_registry", return_value={}),
|
|
115
|
+
patch.object(search.shutil, "which", return_value="es.exe"),
|
|
116
|
+
patch.object(search.subprocess, "run", return_value=completed_process),
|
|
117
|
+
patch.object(search.sys, "stderr", captured_stderr),
|
|
118
|
+
):
|
|
119
|
+
exit_code = search.main(["config.py"])
|
|
120
|
+
assert exit_code == 7
|
|
121
|
+
assert captured_stderr.getvalue() == "failed\n"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_main_reports_missing_executable() -> None:
|
|
125
|
+
captured_stderr = io.StringIO()
|
|
126
|
+
with (
|
|
127
|
+
patch.object(search, "load_registry", return_value={}),
|
|
128
|
+
patch.object(search.shutil, "which", return_value=None),
|
|
129
|
+
patch.object(search.sys, "stderr", captured_stderr),
|
|
130
|
+
):
|
|
131
|
+
exit_code = search.main(["config.py"])
|
|
132
|
+
assert exit_code != 0
|
|
133
|
+
assert "es.exe" in captured_stderr.getvalue()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_main_reports_empty_search_arguments() -> None:
|
|
137
|
+
captured_stderr = io.StringIO()
|
|
138
|
+
with patch.object(search.sys, "stderr", captured_stderr):
|
|
139
|
+
exit_code = search.main([])
|
|
140
|
+
assert exit_code == search.INVALID_INPUT_EXIT_CODE
|
|
141
|
+
assert "search argument" in captured_stderr.getvalue()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@pytest.mark.parametrize("all_arguments", [["-n", "50"], [""], [" "]])
|
|
145
|
+
def test_main_rejects_unscoped_search_without_launching_child(
|
|
146
|
+
all_arguments: list[str],
|
|
147
|
+
) -> None:
|
|
148
|
+
captured_stderr = io.StringIO()
|
|
149
|
+
with (
|
|
150
|
+
patch.object(search.subprocess, "run") as run_process,
|
|
151
|
+
patch.object(search.sys, "stderr", captured_stderr),
|
|
152
|
+
):
|
|
153
|
+
exit_code = search.main(all_arguments)
|
|
154
|
+
assert exit_code == search.INVALID_INPUT_EXIT_CODE
|
|
155
|
+
assert "first search argument" in captured_stderr.getvalue().lower()
|
|
156
|
+
run_process.assert_not_called()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_main_allows_version_information_operation() -> None:
|
|
160
|
+
completed_process = search.subprocess.CompletedProcess(
|
|
161
|
+
args=["es.exe", "-version"], returncode=0, stdout="1.1.0.30\n", stderr=""
|
|
162
|
+
)
|
|
163
|
+
with (
|
|
164
|
+
patch.object(search, "load_registry", return_value={}),
|
|
165
|
+
patch.object(search.shutil, "which", return_value="es.exe"),
|
|
166
|
+
patch.object(
|
|
167
|
+
search.subprocess, "run", return_value=completed_process
|
|
168
|
+
) as run_process,
|
|
169
|
+
):
|
|
170
|
+
exit_code = search.main(["-version"])
|
|
171
|
+
assert exit_code == 0
|
|
172
|
+
run_process.assert_called_once()
|
|
@@ -1054,6 +1054,65 @@ test('a full reinstall drops a retired hook script and its settings entry in one
|
|
|
1054
1054
|
}
|
|
1055
1055
|
});
|
|
1056
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
|
+
|
|
1057
1116
|
test('a full reinstall removes a retired hook entry under an event type the current config leaves out', () => {
|
|
1058
1117
|
const sandbox = createSandbox();
|
|
1059
1118
|
try {
|
|
@@ -35,7 +35,9 @@ BASH_TOOL_NAME = "Bash"
|
|
|
35
35
|
POWERSHELL_TOOL_NAME = "PowerShell"
|
|
36
36
|
|
|
37
37
|
ALL_BASH_ONLY_TOOL_NAMES: frozenset[str] = frozenset({BASH_TOOL_NAME})
|
|
38
|
-
ALL_BASH_AND_POWERSHELL_TOOL_NAMES: frozenset[str] = frozenset(
|
|
38
|
+
ALL_BASH_AND_POWERSHELL_TOOL_NAMES: frozenset[str] = frozenset(
|
|
39
|
+
{BASH_TOOL_NAME, POWERSHELL_TOOL_NAME}
|
|
40
|
+
)
|
|
39
41
|
|
|
40
42
|
|
|
41
43
|
@dataclass(frozen=True)
|
|
@@ -53,7 +55,6 @@ class BashHostedHookEntry:
|
|
|
53
55
|
|
|
54
56
|
|
|
55
57
|
ALL_BASH_HOSTED_HOOK_ENTRIES: tuple[BashHostedHookEntry, ...] = (
|
|
56
|
-
BashHostedHookEntry("blocking/es_exe_path_rewriter.py", ALL_BASH_ONLY_TOOL_NAMES),
|
|
57
58
|
BashHostedHookEntry("blocking/destructive_command_blocker.py", ALL_BASH_ONLY_TOOL_NAMES),
|
|
58
59
|
BashHostedHookEntry("blocking/gh_body_arg_blocker.py", ALL_BASH_ONLY_TOOL_NAMES),
|
|
59
60
|
BashHostedHookEntry("blocking/shell_substitution_blocker.py", ALL_BASH_ONLY_TOOL_NAMES),
|
|
@@ -62,14 +63,10 @@ ALL_BASH_HOSTED_HOOK_ENTRIES: tuple[BashHostedHookEntry, ...] = (
|
|
|
62
63
|
"blocking/cursor_cli_python_misfire_blocker.py",
|
|
63
64
|
ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
|
|
64
65
|
),
|
|
65
|
-
BashHostedHookEntry(
|
|
66
|
-
"blocking/unscoped_search_blocker.py", ALL_BASH_AND_POWERSHELL_TOOL_NAMES
|
|
67
|
-
),
|
|
66
|
+
BashHostedHookEntry("blocking/unscoped_search_blocker.py", ALL_BASH_AND_POWERSHELL_TOOL_NAMES),
|
|
68
67
|
BashHostedHookEntry("blocking/nas_ssh_binary_enforcer.py", ALL_BASH_ONLY_TOOL_NAMES),
|
|
69
68
|
BashHostedHookEntry("blocking/volatile_path_in_post_blocker.py", ALL_BASH_ONLY_TOOL_NAMES),
|
|
70
|
-
BashHostedHookEntry(
|
|
71
|
-
"blocking/pii_prevention_blocker.py", ALL_BASH_AND_POWERSHELL_TOOL_NAMES
|
|
72
|
-
),
|
|
69
|
+
BashHostedHookEntry("blocking/pii_prevention_blocker.py", ALL_BASH_AND_POWERSHELL_TOOL_NAMES),
|
|
73
70
|
BashHostedHookEntry("blocking/conventional_pr_title_gate.py", ALL_BASH_ONLY_TOOL_NAMES),
|
|
74
71
|
BashHostedHookEntry("blocking/block_main_commit.py", ALL_BASH_ONLY_TOOL_NAMES),
|
|
75
72
|
BashHostedHookEntry("blocking/session_edit_stage_gate.py", ALL_BASH_ONLY_TOOL_NAMES),
|
|
@@ -7,22 +7,18 @@ registration order the standalone chain used.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
-
import sys
|
|
11
10
|
from pathlib import Path
|
|
11
|
+
from runpy import run_path
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
sys.path.insert(0, _HOOKS_DIR)
|
|
16
|
-
|
|
17
|
-
from hooks_constants.bash_pre_tool_use_dispatcher_constants import ( # noqa: E402
|
|
18
|
-
ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
|
|
19
|
-
ALL_BASH_HOSTED_HOOK_ENTRIES,
|
|
20
|
-
BASH_TOOL_NAME,
|
|
21
|
-
POWERSHELL_TOOL_NAME,
|
|
13
|
+
ALL_CONSTANT_BINDINGS = run_path(
|
|
14
|
+
str(Path(__file__).with_name("bash_pre_tool_use_dispatcher_constants.py"))
|
|
22
15
|
)
|
|
16
|
+
ALL_BASH_AND_POWERSHELL_TOOL_NAMES = ALL_CONSTANT_BINDINGS["ALL_BASH_AND_POWERSHELL_TOOL_NAMES"]
|
|
17
|
+
ALL_BASH_HOSTED_HOOK_ENTRIES = ALL_CONSTANT_BINDINGS["ALL_BASH_HOSTED_HOOK_ENTRIES"]
|
|
18
|
+
BASH_TOOL_NAME = ALL_CONSTANT_BINDINGS["BASH_TOOL_NAME"]
|
|
19
|
+
POWERSHELL_TOOL_NAME = ALL_CONSTANT_BINDINGS["POWERSHELL_TOOL_NAME"]
|
|
23
20
|
|
|
24
21
|
_EXPECTED_BASH_ORDER = (
|
|
25
|
-
"blocking/es_exe_path_rewriter.py",
|
|
26
22
|
"blocking/destructive_command_blocker.py",
|
|
27
23
|
"blocking/gh_body_arg_blocker.py",
|
|
28
24
|
"blocking/shell_substitution_blocker.py",
|
|
@@ -80,8 +76,6 @@ def test_powershell_hooks_carry_the_shared_tool_set() -> None:
|
|
|
80
76
|
assert each_entry.applicable_tool_names == ALL_BASH_AND_POWERSHELL_TOOL_NAMES
|
|
81
77
|
|
|
82
78
|
|
|
83
|
-
|
|
84
|
-
|
|
85
79
|
def test_every_roster_path_points_at_an_existing_hook() -> None:
|
|
86
80
|
"""Each roster path names a hook script present on disk, catching a typo."""
|
|
87
81
|
hooks_root = Path(__file__).resolve().parent.parent
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Filesystem Search
|
|
2
2
|
|
|
3
|
-
**When this applies:** Any search for files by name, path, extension, size, or date
|
|
3
|
+
**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.
|
|
4
4
|
|
|
5
5
|
## The scope invariant
|
|
6
6
|
|
|
@@ -14,7 +14,7 @@ Three tools are equally sanctioned; pick by what you know:
|
|
|
14
14
|
|
|
15
15
|
| You know | Use |
|
|
16
16
|
|---|---|
|
|
17
|
-
| The exact path | `Read
|
|
17
|
+
| The exact path | `Read`. Do not search. |
|
|
18
18
|
| A name, extension, or date, on Windows | `es.exe` with a path scope |
|
|
19
19
|
| A name or path pattern | The harness `Glob` tool |
|
|
20
20
|
| Text inside files | The harness `Grep` tool |
|
|
@@ -39,13 +39,13 @@ When `es.exe` fails or returns nothing, fall back to `Glob` or `Grep` without pa
|
|
|
39
39
|
| Git Bash drive root | `find /c -name '*.py'` |
|
|
40
40
|
| Windows drive root | `find C:\ -name foo` / `Get-ChildItem C:\ -Recurse` |
|
|
41
41
|
| Bare home | `find ~ -name README.md` / `find $HOME -type f` |
|
|
42
|
-
| Network share root | `find //server/share -name x
|
|
42
|
+
| Network share root | `find //server/share -name x`. A path under the share (`//server/share/project/src`) is allowed |
|
|
43
43
|
|
|
44
44
|
## Shell batching
|
|
45
45
|
|
|
46
46
|
Issue one shell search at a time when the walk is large. Parallel full-tree searches contend for the shell and can lock the host. Harness `Grep` and `Glob` calls carry no such cost and run in parallel freely.
|
|
47
47
|
|
|
48
|
-
##
|
|
48
|
+
## Search controls
|
|
49
49
|
|
|
50
50
|
- `unscoped_search_blocker` (PreToolUse on Bash and PowerShell, hosted by `bash_pre_tool_use_dispatcher`) denies a walk from an unscoped root and returns the scoped alternative.
|
|
51
|
-
-
|
|
51
|
+
- 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.
|
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""PreToolUse hook: rewrite es.exe commands by substituting registry tokens with absolute paths.
|
|
3
|
-
|
|
4
|
-
Reads tool_input.command from stdin JSON. When the command invokes the Everything
|
|
5
|
-
command-line binary, substitutes {project-name} placeholder tokens and bare registry-key
|
|
6
|
-
tokens with their quoted absolute paths from ~/.claude/project-paths.json before
|
|
7
|
-
the Bash call runs. Never blocks or denies — on any error exits 0 with empty output.
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
from __future__ import annotations
|
|
11
|
-
|
|
12
|
-
import json
|
|
13
|
-
import logging
|
|
14
|
-
import re
|
|
15
|
-
import sys
|
|
16
|
-
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
17
|
-
|
|
18
|
-
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
19
|
-
if _hooks_dir not in sys.path:
|
|
20
|
-
sys.path.insert(0, _hooks_dir)
|
|
21
|
-
|
|
22
|
-
from hooks_constants.dynamic_stderr_handler import DynamicStderrHandler # noqa: E402
|
|
23
|
-
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin # noqa: E402
|
|
24
|
-
from hooks_constants.path_rewriter_constants import ( # noqa: E402
|
|
25
|
-
BASH_TOOL_NAME,
|
|
26
|
-
HOOK_EVENT_NAME,
|
|
27
|
-
PERMISSION_ALLOW,
|
|
28
|
-
PLACEHOLDER_TOKEN_PATTERN,
|
|
29
|
-
)
|
|
30
|
-
from hooks_constants.project_paths_reader import load_registry # noqa: E402
|
|
31
|
-
|
|
32
|
-
_ES_EXE_TRIGGER_PATTERN = re.compile(
|
|
33
|
-
r"(?i)(?<![\w.])(?:Everything[/\\])?es\.exe(?![\w.])",
|
|
34
|
-
)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
_logger = logging.getLogger("es_exe_path_rewriter")
|
|
38
|
-
if not _logger.handlers:
|
|
39
|
-
_stderr_handler = DynamicStderrHandler()
|
|
40
|
-
_stderr_handler.setFormatter(logging.Formatter("%(name)s: %(message)s"))
|
|
41
|
-
_logger.addHandler(_stderr_handler)
|
|
42
|
-
_logger.setLevel(logging.INFO)
|
|
43
|
-
_logger.propagate = False
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def command_invokes_es_exe(command: str) -> bool:
|
|
47
|
-
"""Return True when the command string contains an es.exe invocation."""
|
|
48
|
-
return bool(_ES_EXE_TRIGGER_PATTERN.search(command))
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def _token_is_absolute_path(token: str) -> bool:
|
|
52
|
-
stripped = token.strip("\"'")
|
|
53
|
-
try:
|
|
54
|
-
return (
|
|
55
|
-
PureWindowsPath(stripped).is_absolute()
|
|
56
|
-
or PurePosixPath(stripped).is_absolute()
|
|
57
|
-
)
|
|
58
|
-
except (ValueError, TypeError):
|
|
59
|
-
return False
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def _quote_path(absolute_path: str) -> str:
|
|
63
|
-
return f'"{absolute_path}"'
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def _rewrite_placeholder_tokens(command_suffix: str, registry: dict[str, str]) -> str:
|
|
67
|
-
def replace_placeholder(match: re.Match) -> str:
|
|
68
|
-
inner_name = match.group(1)
|
|
69
|
-
if inner_name not in registry:
|
|
70
|
-
return match.group(0)
|
|
71
|
-
return _quote_path(registry[inner_name])
|
|
72
|
-
|
|
73
|
-
return PLACEHOLDER_TOKEN_PATTERN.sub(replace_placeholder, command_suffix)
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def _split_on_es_exe(command: str) -> tuple[str, str]:
|
|
77
|
-
match = _ES_EXE_TRIGGER_PATTERN.search(command)
|
|
78
|
-
if not match:
|
|
79
|
-
return command, ""
|
|
80
|
-
return command[: match.end()], command[match.end() :]
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
def _strip_matching_outer_quotes(token: str) -> tuple[str, bool]:
|
|
84
|
-
"""Return (inner_text, was_quoted) after removing matched outer quotes."""
|
|
85
|
-
if len(token) > 1 and token[0] in ('"', "'") and token[-1] == token[0]:
|
|
86
|
-
return token[1:-1], True
|
|
87
|
-
return token, False
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def _rewrite_bare_tokens(command_suffix: str, registry: dict[str, str]) -> str:
|
|
91
|
-
all_raw_parts = re.split(r"(\s+)", command_suffix)
|
|
92
|
-
all_rewritten_parts: list[str] = []
|
|
93
|
-
for each_raw_part in all_raw_parts:
|
|
94
|
-
if not each_raw_part or each_raw_part.isspace():
|
|
95
|
-
all_rewritten_parts.append(each_raw_part)
|
|
96
|
-
continue
|
|
97
|
-
unquoted_text, _was_quoted = _strip_matching_outer_quotes(each_raw_part)
|
|
98
|
-
if unquoted_text in registry and not _token_is_absolute_path(unquoted_text):
|
|
99
|
-
all_rewritten_parts.append(_quote_path(registry[unquoted_text]))
|
|
100
|
-
else:
|
|
101
|
-
all_rewritten_parts.append(each_raw_part)
|
|
102
|
-
return "".join(all_rewritten_parts)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
def rewrite_command(command: str, registry: dict[str, str]) -> str:
|
|
106
|
-
"""Apply registry substitutions to any es.exe argument tokens.
|
|
107
|
-
|
|
108
|
-
Applies placeholder form {name} first, then bare-token form.
|
|
109
|
-
Absolute-path arguments and unknown tokens are left untouched.
|
|
110
|
-
Returns the original command when no substitution applies.
|
|
111
|
-
"""
|
|
112
|
-
if not registry:
|
|
113
|
-
return command
|
|
114
|
-
prefix, suffix = _split_on_es_exe(command)
|
|
115
|
-
rewritten_suffix = _rewrite_placeholder_tokens(suffix, registry)
|
|
116
|
-
rewritten_suffix = _rewrite_bare_tokens(rewritten_suffix, registry)
|
|
117
|
-
return prefix + rewritten_suffix
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
def _build_allow_response(rewritten_command: str, original_tool_input: dict) -> dict:
|
|
121
|
-
updated_input = {**original_tool_input, "command": rewritten_command}
|
|
122
|
-
return {
|
|
123
|
-
"hookSpecificOutput": {
|
|
124
|
-
"hookEventName": HOOK_EVENT_NAME,
|
|
125
|
-
"permissionDecision": PERMISSION_ALLOW,
|
|
126
|
-
"updatedInput": updated_input,
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
def main() -> None:
|
|
132
|
-
try:
|
|
133
|
-
hook_input = read_hook_input_dictionary_from_stdin()
|
|
134
|
-
if hook_input is None:
|
|
135
|
-
sys.exit(0)
|
|
136
|
-
raw_tool_name = hook_input.get("tool_name", "")
|
|
137
|
-
raw_tool_input = hook_input.get("tool_input", {})
|
|
138
|
-
tool_name = raw_tool_name if isinstance(raw_tool_name, str) else ""
|
|
139
|
-
tool_input = raw_tool_input if isinstance(raw_tool_input, dict) else {}
|
|
140
|
-
if tool_name != BASH_TOOL_NAME:
|
|
141
|
-
sys.exit(0)
|
|
142
|
-
raw_command = tool_input.get("command", "")
|
|
143
|
-
command = raw_command if isinstance(raw_command, str) else ""
|
|
144
|
-
if not command_invokes_es_exe(command):
|
|
145
|
-
sys.exit(0)
|
|
146
|
-
known_registry = load_registry()
|
|
147
|
-
if not known_registry:
|
|
148
|
-
sys.exit(0)
|
|
149
|
-
rewritten_command = rewrite_command(command, known_registry)
|
|
150
|
-
if rewritten_command == command:
|
|
151
|
-
sys.exit(0)
|
|
152
|
-
print(json.dumps(_build_allow_response(rewritten_command, tool_input)))
|
|
153
|
-
except Exception as e:
|
|
154
|
-
_logger.error("%s", e)
|
|
155
|
-
sys.exit(0)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
if __name__ == "__main__":
|
|
159
|
-
main()
|
|
@@ -1,447 +0,0 @@
|
|
|
1
|
-
"""Tests for es_exe_path_rewriter — PreToolUse hook that rewrites es.exe paths."""
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
import sys
|
|
5
|
-
from io import StringIO
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
from unittest.mock import patch
|
|
8
|
-
|
|
9
|
-
import pytest
|
|
10
|
-
|
|
11
|
-
_BLOCKING_DIR = Path(__file__).resolve().parent
|
|
12
|
-
_HOOKS_ROOT = _BLOCKING_DIR.parent
|
|
13
|
-
for each_sys_path_entry in (str(_BLOCKING_DIR), str(_HOOKS_ROOT)):
|
|
14
|
-
if each_sys_path_entry not in sys.path:
|
|
15
|
-
sys.path.insert(0, each_sys_path_entry)
|
|
16
|
-
|
|
17
|
-
import es_exe_path_rewriter as rewriter
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def test_rewriter_does_not_redefine_dynamic_stderr_handler_locally() -> None:
|
|
21
|
-
"""Pin PR #230 round 3 DRY fix: handler is imported from the shared module.
|
|
22
|
-
|
|
23
|
-
Both project_paths_reader and es_exe_path_rewriter previously defined
|
|
24
|
-
identical `_DynamicStderrHandler` classes. This test fails if the
|
|
25
|
-
duplicate class reappears in es_exe_path_rewriter.
|
|
26
|
-
"""
|
|
27
|
-
assert not hasattr(rewriter, "_DynamicStderrHandler")
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
REGISTRY_WITH_ONE_REPO = {"my-repo": "Y:\\Projects\\my-repo"}
|
|
31
|
-
REGISTRY_WITH_TWO_REPOS = {
|
|
32
|
-
"my-repo": "Y:\\Projects\\my-repo",
|
|
33
|
-
"other-repo": "C:\\Dev\\other-repo",
|
|
34
|
-
}
|
|
35
|
-
ABSOLUTE_PATH_ARGUMENT = "Y:\\Projects\\already-absolute\\file.py"
|
|
36
|
-
KNOWN_REPO_NAME = "my-repo"
|
|
37
|
-
KNOWN_REPO_PATH = "Y:\\Projects\\my-repo"
|
|
38
|
-
OTHER_REPO_NAME = "other-repo"
|
|
39
|
-
OTHER_REPO_PATH = "C:\\Dev\\other-repo"
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def _run_main_with_stdin_text(stdin_text: str) -> tuple[str, str, int]:
|
|
43
|
-
"""Return (stdout, stderr, exit_code) from running main() with the given stdin text."""
|
|
44
|
-
captured_stdout = StringIO()
|
|
45
|
-
captured_stderr = StringIO()
|
|
46
|
-
exit_code = 0
|
|
47
|
-
try:
|
|
48
|
-
with (
|
|
49
|
-
patch("sys.stdin", StringIO(stdin_text)),
|
|
50
|
-
patch("sys.stdout", captured_stdout),
|
|
51
|
-
patch("sys.stderr", captured_stderr),
|
|
52
|
-
):
|
|
53
|
-
rewriter.main()
|
|
54
|
-
except SystemExit as e:
|
|
55
|
-
exit_code = e.code or 0
|
|
56
|
-
return captured_stdout.getvalue(), captured_stderr.getvalue(), exit_code
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def _run_main_with_input(hook_input: dict) -> tuple[str, str, int]:
|
|
60
|
-
"""Return (stdout, stderr, exit_code) from running main() with the given hook input."""
|
|
61
|
-
return _run_main_with_stdin_text(json.dumps(hook_input))
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def _make_bash_input(command: str, description: str = "search files") -> dict:
|
|
65
|
-
return {
|
|
66
|
-
"tool_name": "Bash",
|
|
67
|
-
"tool_input": {"command": command, "description": description},
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
class TestTriggerRegex:
|
|
72
|
-
def test_matches_bare_es_exe(self) -> None:
|
|
73
|
-
assert rewriter.command_invokes_es_exe("es.exe my-repo")
|
|
74
|
-
|
|
75
|
-
def test_matches_everything_forward_slash_path(self) -> None:
|
|
76
|
-
assert rewriter.command_invokes_es_exe("Everything/es.exe my-repo")
|
|
77
|
-
|
|
78
|
-
def test_matches_everything_backslash_path(self) -> None:
|
|
79
|
-
assert rewriter.command_invokes_es_exe("Everything\\es.exe my-repo")
|
|
80
|
-
|
|
81
|
-
def test_does_not_match_unrelated_command(self) -> None:
|
|
82
|
-
assert not rewriter.command_invokes_es_exe("git status")
|
|
83
|
-
|
|
84
|
-
def test_does_not_match_es_exe_inside_longer_word(self) -> None:
|
|
85
|
-
assert not rewriter.command_invokes_es_exe("not_es.exe_here")
|
|
86
|
-
|
|
87
|
-
def test_matches_case_insensitively(self) -> None:
|
|
88
|
-
assert rewriter.command_invokes_es_exe("ES.EXE my-repo")
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
class TestRewriteCommand:
|
|
92
|
-
def test_bare_token_rewrite_substitutes_registry_path(self) -> None:
|
|
93
|
-
rewritten = rewriter.rewrite_command(
|
|
94
|
-
"es.exe my-repo config.py", REGISTRY_WITH_ONE_REPO
|
|
95
|
-
)
|
|
96
|
-
assert rewritten == f'es.exe "{KNOWN_REPO_PATH}" config.py'
|
|
97
|
-
|
|
98
|
-
def test_placeholder_token_rewrite_substitutes_registry_path(self) -> None:
|
|
99
|
-
rewritten = rewriter.rewrite_command(
|
|
100
|
-
'es.exe "{my-repo}" config.py', REGISTRY_WITH_ONE_REPO
|
|
101
|
-
)
|
|
102
|
-
assert rewritten == f'es.exe "{KNOWN_REPO_PATH}" config.py'
|
|
103
|
-
|
|
104
|
-
def test_single_quoted_placeholder_rewrite_substitutes_registry_path(self) -> None:
|
|
105
|
-
rewritten = rewriter.rewrite_command(
|
|
106
|
-
"es.exe '{my-repo}' config.py", REGISTRY_WITH_ONE_REPO
|
|
107
|
-
)
|
|
108
|
-
assert rewritten == f'es.exe "{KNOWN_REPO_PATH}" config.py'
|
|
109
|
-
|
|
110
|
-
def test_placeholder_without_quotes_rewrite_substitutes_registry_path(self) -> None:
|
|
111
|
-
rewritten = rewriter.rewrite_command(
|
|
112
|
-
"es.exe {my-repo} config.py", REGISTRY_WITH_ONE_REPO
|
|
113
|
-
)
|
|
114
|
-
assert rewritten == f'es.exe "{KNOWN_REPO_PATH}" config.py'
|
|
115
|
-
|
|
116
|
-
def test_absolute_path_argument_is_never_modified(self) -> None:
|
|
117
|
-
command = f'es.exe "{ABSOLUTE_PATH_ARGUMENT}" config.py'
|
|
118
|
-
rewritten = rewriter.rewrite_command(command, REGISTRY_WITH_ONE_REPO)
|
|
119
|
-
assert rewritten == command
|
|
120
|
-
|
|
121
|
-
def test_unknown_token_is_never_touched(self) -> None:
|
|
122
|
-
command = "es.exe unknown-name config.py"
|
|
123
|
-
rewritten = rewriter.rewrite_command(command, REGISTRY_WITH_ONE_REPO)
|
|
124
|
-
assert rewritten == command
|
|
125
|
-
|
|
126
|
-
def test_multiple_tokens_all_rewrite(self) -> None:
|
|
127
|
-
command = "es.exe my-repo other-repo config.py"
|
|
128
|
-
rewritten = rewriter.rewrite_command(command, REGISTRY_WITH_TWO_REPOS)
|
|
129
|
-
assert rewritten == f'es.exe "{KNOWN_REPO_PATH}" "{OTHER_REPO_PATH}" config.py'
|
|
130
|
-
|
|
131
|
-
def test_empty_registry_returns_unchanged_command(self) -> None:
|
|
132
|
-
command = "es.exe my-repo config.py"
|
|
133
|
-
rewritten = rewriter.rewrite_command(command, {})
|
|
134
|
-
assert rewritten == command
|
|
135
|
-
|
|
136
|
-
def test_double_spaces_inside_quoted_arg_pass_through_unchanged_on_no_registry_hit(
|
|
137
|
-
self,
|
|
138
|
-
) -> None:
|
|
139
|
-
command = 'es.exe "foo bar" baz'
|
|
140
|
-
rewritten = rewriter.rewrite_command(command, REGISTRY_WITH_ONE_REPO)
|
|
141
|
-
assert rewritten == command
|
|
142
|
-
|
|
143
|
-
def test_tab_separator_is_preserved_when_bare_token_is_rewritten(self) -> None:
|
|
144
|
-
registry = {"my-repo": "Y:\\Projects\\my-repo"}
|
|
145
|
-
command = "es.exe my-repo\tconfig.py"
|
|
146
|
-
rewritten = rewriter.rewrite_command(command, registry)
|
|
147
|
-
assert rewritten == f'es.exe "Y:\\Projects\\my-repo"\tconfig.py'
|
|
148
|
-
|
|
149
|
-
def test_multiple_substitutions_preserve_all_inter_token_whitespace(self) -> None:
|
|
150
|
-
command = "es.exe my-repo other-repo config.py"
|
|
151
|
-
rewritten = rewriter.rewrite_command(command, REGISTRY_WITH_TWO_REPOS)
|
|
152
|
-
assert rewritten == f'es.exe "{KNOWN_REPO_PATH}" "{OTHER_REPO_PATH}" config.py'
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
class TestEmittedJsonShape:
|
|
156
|
-
def test_emitted_json_has_correct_hook_event_name(self) -> None:
|
|
157
|
-
hook_input = _make_bash_input(f"es.exe {KNOWN_REPO_NAME} config.py")
|
|
158
|
-
with patch(
|
|
159
|
-
"es_exe_path_rewriter.load_registry", return_value=REGISTRY_WITH_ONE_REPO
|
|
160
|
-
):
|
|
161
|
-
stdout, _, _ = _run_main_with_input(hook_input)
|
|
162
|
-
emitted = json.loads(stdout)
|
|
163
|
-
assert emitted["hookSpecificOutput"]["hookEventName"] == "PreToolUse"
|
|
164
|
-
|
|
165
|
-
def test_emitted_json_has_allow_permission_decision(self) -> None:
|
|
166
|
-
hook_input = _make_bash_input(f"es.exe {KNOWN_REPO_NAME} config.py")
|
|
167
|
-
with patch(
|
|
168
|
-
"es_exe_path_rewriter.load_registry", return_value=REGISTRY_WITH_ONE_REPO
|
|
169
|
-
):
|
|
170
|
-
stdout, _, _ = _run_main_with_input(hook_input)
|
|
171
|
-
emitted = json.loads(stdout)
|
|
172
|
-
assert emitted["hookSpecificOutput"]["permissionDecision"] == "allow"
|
|
173
|
-
|
|
174
|
-
def test_emitted_json_has_rewritten_command(self) -> None:
|
|
175
|
-
hook_input = _make_bash_input(f"es.exe {KNOWN_REPO_NAME} config.py")
|
|
176
|
-
with patch(
|
|
177
|
-
"es_exe_path_rewriter.load_registry", return_value=REGISTRY_WITH_ONE_REPO
|
|
178
|
-
):
|
|
179
|
-
stdout, _, _ = _run_main_with_input(hook_input)
|
|
180
|
-
emitted = json.loads(stdout)
|
|
181
|
-
updated_command = emitted["hookSpecificOutput"]["updatedInput"]["command"]
|
|
182
|
-
assert updated_command == f'es.exe "{KNOWN_REPO_PATH}" config.py'
|
|
183
|
-
|
|
184
|
-
def test_description_field_round_trips_unchanged(self) -> None:
|
|
185
|
-
original_description = "my special search description"
|
|
186
|
-
hook_input = _make_bash_input(
|
|
187
|
-
f"es.exe {KNOWN_REPO_NAME} config.py", original_description
|
|
188
|
-
)
|
|
189
|
-
with patch(
|
|
190
|
-
"es_exe_path_rewriter.load_registry", return_value=REGISTRY_WITH_ONE_REPO
|
|
191
|
-
):
|
|
192
|
-
stdout, _, _ = _run_main_with_input(hook_input)
|
|
193
|
-
emitted = json.loads(stdout)
|
|
194
|
-
assert (
|
|
195
|
-
emitted["hookSpecificOutput"]["updatedInput"]["description"]
|
|
196
|
-
== original_description
|
|
197
|
-
)
|
|
198
|
-
|
|
199
|
-
def test_additional_unknown_fields_pass_through_into_updated_input(self) -> None:
|
|
200
|
-
hook_input = {
|
|
201
|
-
"tool_name": "Bash",
|
|
202
|
-
"tool_input": {
|
|
203
|
-
"command": f"es.exe {KNOWN_REPO_NAME} config.py",
|
|
204
|
-
"description": "search",
|
|
205
|
-
"extra_field": "extra_value",
|
|
206
|
-
},
|
|
207
|
-
}
|
|
208
|
-
with patch(
|
|
209
|
-
"es_exe_path_rewriter.load_registry", return_value=REGISTRY_WITH_ONE_REPO
|
|
210
|
-
):
|
|
211
|
-
stdout, _, _ = _run_main_with_input(hook_input)
|
|
212
|
-
emitted = json.loads(stdout)
|
|
213
|
-
assert (
|
|
214
|
-
emitted["hookSpecificOutput"]["updatedInput"]["extra_field"]
|
|
215
|
-
== "extra_value"
|
|
216
|
-
)
|
|
217
|
-
|
|
218
|
-
def test_no_code_path_returns_deny_decision(self) -> None:
|
|
219
|
-
for command in [
|
|
220
|
-
f"es.exe {KNOWN_REPO_NAME} config.py",
|
|
221
|
-
"es.exe unknown-token config.py",
|
|
222
|
-
"git status",
|
|
223
|
-
]:
|
|
224
|
-
hook_input = _make_bash_input(command)
|
|
225
|
-
with patch(
|
|
226
|
-
"es_exe_path_rewriter.load_registry",
|
|
227
|
-
return_value=REGISTRY_WITH_ONE_REPO,
|
|
228
|
-
):
|
|
229
|
-
stdout, _, _ = _run_main_with_input(hook_input)
|
|
230
|
-
if stdout.strip():
|
|
231
|
-
emitted = json.loads(stdout)
|
|
232
|
-
decision = emitted.get("hookSpecificOutput", {}).get(
|
|
233
|
-
"permissionDecision", ""
|
|
234
|
-
)
|
|
235
|
-
assert decision != "deny", f"deny returned for command: {command!r}"
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
class TestStdinParsingRobustness:
|
|
239
|
-
def test_empty_stdin_exits_zero_without_stdout_or_stderr(self) -> None:
|
|
240
|
-
stdout, stderr, exit_code = _run_main_with_stdin_text("")
|
|
241
|
-
assert exit_code == 0
|
|
242
|
-
assert stdout.strip() == ""
|
|
243
|
-
assert stderr.strip() == ""
|
|
244
|
-
|
|
245
|
-
def test_whitespace_only_stdin_exits_zero_without_stdout_or_stderr(self) -> None:
|
|
246
|
-
stdout, stderr, exit_code = _run_main_with_stdin_text(" \n\t ")
|
|
247
|
-
assert exit_code == 0
|
|
248
|
-
assert stdout.strip() == ""
|
|
249
|
-
assert stderr.strip() == ""
|
|
250
|
-
|
|
251
|
-
def test_invalid_json_stdin_exits_zero_without_stdout_or_stderr(self) -> None:
|
|
252
|
-
stdout, stderr, exit_code = _run_main_with_stdin_text("{not valid")
|
|
253
|
-
assert exit_code == 0
|
|
254
|
-
assert stdout.strip() == ""
|
|
255
|
-
assert stderr.strip() == ""
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
class TestMalformedHookPayloadTypes:
|
|
259
|
-
def test_null_tool_input_exits_zero_without_stdout_or_stderr(self) -> None:
|
|
260
|
-
hook_input = {
|
|
261
|
-
"tool_name": "Bash",
|
|
262
|
-
"tool_input": None,
|
|
263
|
-
}
|
|
264
|
-
with patch(
|
|
265
|
-
"es_exe_path_rewriter.load_registry", return_value=REGISTRY_WITH_ONE_REPO
|
|
266
|
-
):
|
|
267
|
-
stdout, stderr, exit_code = _run_main_with_input(hook_input)
|
|
268
|
-
assert exit_code == 0
|
|
269
|
-
assert stdout.strip() == ""
|
|
270
|
-
assert stderr.strip() == ""
|
|
271
|
-
|
|
272
|
-
def test_list_tool_input_exits_zero_without_stdout_or_stderr(self) -> None:
|
|
273
|
-
hook_input = {
|
|
274
|
-
"tool_name": "Bash",
|
|
275
|
-
"tool_input": ["es.exe", KNOWN_REPO_NAME],
|
|
276
|
-
}
|
|
277
|
-
with patch(
|
|
278
|
-
"es_exe_path_rewriter.load_registry", return_value=REGISTRY_WITH_ONE_REPO
|
|
279
|
-
):
|
|
280
|
-
stdout, stderr, exit_code = _run_main_with_input(hook_input)
|
|
281
|
-
assert exit_code == 0
|
|
282
|
-
assert stdout.strip() == ""
|
|
283
|
-
assert stderr.strip() == ""
|
|
284
|
-
|
|
285
|
-
def test_string_tool_input_exits_zero_without_stdout_or_stderr(self) -> None:
|
|
286
|
-
hook_input = {
|
|
287
|
-
"tool_name": "Bash",
|
|
288
|
-
"tool_input": f"es.exe {KNOWN_REPO_NAME}",
|
|
289
|
-
}
|
|
290
|
-
with patch(
|
|
291
|
-
"es_exe_path_rewriter.load_registry", return_value=REGISTRY_WITH_ONE_REPO
|
|
292
|
-
):
|
|
293
|
-
stdout, stderr, exit_code = _run_main_with_input(hook_input)
|
|
294
|
-
assert exit_code == 0
|
|
295
|
-
assert stdout.strip() == ""
|
|
296
|
-
assert stderr.strip() == ""
|
|
297
|
-
|
|
298
|
-
def test_non_string_command_exits_zero_without_stdout_or_stderr(self) -> None:
|
|
299
|
-
hook_input = {
|
|
300
|
-
"tool_name": "Bash",
|
|
301
|
-
"tool_input": {"command": 12345, "description": "search"},
|
|
302
|
-
}
|
|
303
|
-
with patch(
|
|
304
|
-
"es_exe_path_rewriter.load_registry", return_value=REGISTRY_WITH_ONE_REPO
|
|
305
|
-
):
|
|
306
|
-
stdout, stderr, exit_code = _run_main_with_input(hook_input)
|
|
307
|
-
assert exit_code == 0
|
|
308
|
-
assert stdout.strip() == ""
|
|
309
|
-
assert stderr.strip() == ""
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
class TestNoOutputCases:
|
|
313
|
-
def test_non_es_exe_command_produces_no_output(self) -> None:
|
|
314
|
-
hook_input = _make_bash_input("git status")
|
|
315
|
-
with patch(
|
|
316
|
-
"es_exe_path_rewriter.load_registry", return_value=REGISTRY_WITH_ONE_REPO
|
|
317
|
-
):
|
|
318
|
-
stdout, _, _ = _run_main_with_input(hook_input)
|
|
319
|
-
assert stdout.strip() == ""
|
|
320
|
-
|
|
321
|
-
def test_empty_registry_produces_no_output(self) -> None:
|
|
322
|
-
hook_input = _make_bash_input(f"es.exe {KNOWN_REPO_NAME} config.py")
|
|
323
|
-
with patch("es_exe_path_rewriter.load_registry", return_value={}):
|
|
324
|
-
stdout, _, _ = _run_main_with_input(hook_input)
|
|
325
|
-
assert stdout.strip() == ""
|
|
326
|
-
|
|
327
|
-
def test_unchanged_command_produces_no_output(self) -> None:
|
|
328
|
-
hook_input = _make_bash_input("es.exe unknown-token config.py")
|
|
329
|
-
with patch(
|
|
330
|
-
"es_exe_path_rewriter.load_registry", return_value=REGISTRY_WITH_ONE_REPO
|
|
331
|
-
):
|
|
332
|
-
stdout, _, _ = _run_main_with_input(hook_input)
|
|
333
|
-
assert stdout.strip() == ""
|
|
334
|
-
|
|
335
|
-
def test_malformed_registry_produces_no_output_and_one_stderr_line(self) -> None:
|
|
336
|
-
hook_input = _make_bash_input(f"es.exe {KNOWN_REPO_NAME} config.py")
|
|
337
|
-
with patch(
|
|
338
|
-
"es_exe_path_rewriter.load_registry",
|
|
339
|
-
side_effect=Exception("simulated read error"),
|
|
340
|
-
):
|
|
341
|
-
stdout, stderr, _ = _run_main_with_input(hook_input)
|
|
342
|
-
assert stdout.strip() == ""
|
|
343
|
-
assert stderr.strip() != ""
|
|
344
|
-
assert stderr.strip().count("\n") == 0
|
|
345
|
-
|
|
346
|
-
def test_non_bash_tool_produces_no_output(self) -> None:
|
|
347
|
-
hook_input = {
|
|
348
|
-
"tool_name": "Write",
|
|
349
|
-
"tool_input": {"command": f"es.exe {KNOWN_REPO_NAME}"},
|
|
350
|
-
}
|
|
351
|
-
with patch(
|
|
352
|
-
"es_exe_path_rewriter.load_registry", return_value=REGISTRY_WITH_ONE_REPO
|
|
353
|
-
):
|
|
354
|
-
stdout, _, _ = _run_main_with_input(hook_input)
|
|
355
|
-
assert stdout.strip() == ""
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
class TestQuoteAwareTokenizer:
|
|
359
|
-
def test_double_quoted_multiword_arg_with_registry_key_prefix_is_not_substituted(
|
|
360
|
-
self,
|
|
361
|
-
) -> None:
|
|
362
|
-
registry = {"my-repo": "Y:\\Projects\\my-repo"}
|
|
363
|
-
command = 'es.exe "my-repo foo" baz'
|
|
364
|
-
rewritten = rewriter.rewrite_command(command, registry)
|
|
365
|
-
assert rewritten == command
|
|
366
|
-
|
|
367
|
-
def test_single_quoted_multiword_arg_with_registry_key_prefix_is_not_substituted(
|
|
368
|
-
self,
|
|
369
|
-
) -> None:
|
|
370
|
-
registry = {"my-repo": "Y:\\Projects\\my-repo"}
|
|
371
|
-
command = "es.exe 'my-repo baz' config.py"
|
|
372
|
-
rewritten = rewriter.rewrite_command(command, registry)
|
|
373
|
-
assert rewritten == command
|
|
374
|
-
|
|
375
|
-
def test_bare_token_matching_registry_key_is_rewritten(self) -> None:
|
|
376
|
-
registry = {"my-repo": "Y:\\Projects\\my-repo"}
|
|
377
|
-
command = "es.exe my-repo config.py"
|
|
378
|
-
rewritten = rewriter.rewrite_command(command, registry)
|
|
379
|
-
assert rewritten == f'es.exe "Y:\\Projects\\my-repo" config.py'
|
|
380
|
-
|
|
381
|
-
def test_whitespace_between_bare_tokens_is_preserved_after_rewrite(self) -> None:
|
|
382
|
-
registry = {"my-repo": "Y:\\Projects\\my-repo", "other-repo": "C:\\Dev\\other"}
|
|
383
|
-
command = "es.exe my-repo other-repo config.py"
|
|
384
|
-
rewritten = rewriter.rewrite_command(command, registry)
|
|
385
|
-
assert rewritten == f'es.exe "Y:\\Projects\\my-repo" "C:\\Dev\\other" config.py'
|
|
386
|
-
|
|
387
|
-
def test_tab_separator_is_preserved_when_bare_token_is_rewritten(self) -> None:
|
|
388
|
-
registry = {"my-repo": "Y:\\Projects\\my-repo"}
|
|
389
|
-
command = "es.exe my-repo\tconfig.py"
|
|
390
|
-
rewritten = rewriter.rewrite_command(command, registry)
|
|
391
|
-
assert rewritten == f'es.exe "Y:\\Projects\\my-repo"\tconfig.py'
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
class TestQuotedSingleWordRewrite:
|
|
395
|
-
def test_double_quoted_single_word_registry_key_is_rewritten(self) -> None:
|
|
396
|
-
registry = {"my-repo": "Y:\\Projects\\my-repo"}
|
|
397
|
-
command = 'es.exe "my-repo" config.py'
|
|
398
|
-
rewritten = rewriter.rewrite_command(command, registry)
|
|
399
|
-
assert rewritten == f'es.exe "Y:\\Projects\\my-repo" config.py'
|
|
400
|
-
|
|
401
|
-
def test_single_quoted_single_word_registry_key_is_rewritten(self) -> None:
|
|
402
|
-
registry = {"my-repo": "Y:\\Projects\\my-repo"}
|
|
403
|
-
command = "es.exe 'my-repo' config.py"
|
|
404
|
-
rewritten = rewriter.rewrite_command(command, registry)
|
|
405
|
-
assert rewritten == f'es.exe "Y:\\Projects\\my-repo" config.py'
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
class TestPlaceholderBoundaryEnforcement:
|
|
409
|
-
def test_placeholder_inside_flag_argument_is_not_rewritten(self) -> None:
|
|
410
|
-
registry = {"my-repo": "Y:\\Projects\\my-repo"}
|
|
411
|
-
command = "es.exe --regex=^{my-repo}$"
|
|
412
|
-
rewritten = rewriter.rewrite_command(command, registry)
|
|
413
|
-
assert rewritten == command
|
|
414
|
-
|
|
415
|
-
def test_placeholder_embedded_in_token_is_not_rewritten(self) -> None:
|
|
416
|
-
registry = {"my-repo": "Y:\\Projects\\my-repo"}
|
|
417
|
-
command = "es.exe foo{my-repo}bar"
|
|
418
|
-
rewritten = rewriter.rewrite_command(command, registry)
|
|
419
|
-
assert rewritten == command
|
|
420
|
-
|
|
421
|
-
def test_standalone_placeholder_is_still_rewritten(self) -> None:
|
|
422
|
-
registry = {"my-repo": "Y:\\Projects\\my-repo"}
|
|
423
|
-
command = "es.exe {my-repo} config.py"
|
|
424
|
-
rewritten = rewriter.rewrite_command(command, registry)
|
|
425
|
-
assert rewritten == f'es.exe "Y:\\Projects\\my-repo" config.py'
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
class TestAbsolutePathDetection:
|
|
429
|
-
def test_windows_drive_letter_path_detected_as_absolute(self) -> None:
|
|
430
|
-
assert rewriter._token_is_absolute_path("C:\\Users\\x")
|
|
431
|
-
|
|
432
|
-
def test_windows_drive_letter_path_with_forward_slashes_detected_as_absolute(
|
|
433
|
-
self,
|
|
434
|
-
) -> None:
|
|
435
|
-
assert rewriter._token_is_absolute_path("Y:/Projects/foo")
|
|
436
|
-
|
|
437
|
-
def test_unc_path_detected_as_absolute(self) -> None:
|
|
438
|
-
assert rewriter._token_is_absolute_path("\\\\server\\share\\path")
|
|
439
|
-
|
|
440
|
-
def test_posix_absolute_path_detected_as_absolute(self) -> None:
|
|
441
|
-
assert rewriter._token_is_absolute_path("/etc/hosts")
|
|
442
|
-
|
|
443
|
-
def test_relative_path_not_detected_as_absolute(self) -> None:
|
|
444
|
-
assert not rewriter._token_is_absolute_path("./foo")
|
|
445
|
-
|
|
446
|
-
def test_bare_registry_token_not_detected_as_absolute(self) -> None:
|
|
447
|
-
assert not rewriter._token_is_absolute_path("my-repo")
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
"""Configuration constants for the es_exe_path_rewriter PreToolUse hook."""
|
|
2
|
-
|
|
3
|
-
import re
|
|
4
|
-
|
|
5
|
-
BASH_TOOL_NAME = "Bash"
|
|
6
|
-
|
|
7
|
-
HOOK_EVENT_NAME = "PreToolUse"
|
|
8
|
-
|
|
9
|
-
PERMISSION_ALLOW = "allow"
|
|
10
|
-
|
|
11
|
-
PLACEHOLDER_TOKEN_PATTERN = re.compile(
|
|
12
|
-
r"""(?:(?<=\s)|^)['"]?(?<!\$)\{([^}]+)\}['"]?(?=\s|$)""",
|
|
13
|
-
)
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
"""Pin tests for path_rewriter_constants — values consumed by es_exe_path_rewriter."""
|
|
2
|
-
|
|
3
|
-
import sys
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
|
|
6
|
-
_HOOKS_ROOT = Path(__file__).resolve().parent.parent
|
|
7
|
-
if str(_HOOKS_ROOT) not in sys.path:
|
|
8
|
-
sys.path.insert(0, str(_HOOKS_ROOT))
|
|
9
|
-
|
|
10
|
-
from hooks_constants.path_rewriter_constants import (
|
|
11
|
-
BASH_TOOL_NAME,
|
|
12
|
-
HOOK_EVENT_NAME,
|
|
13
|
-
PERMISSION_ALLOW,
|
|
14
|
-
PLACEHOLDER_TOKEN_PATTERN,
|
|
15
|
-
)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def test_bash_tool_name_is_bash() -> None:
|
|
19
|
-
assert BASH_TOOL_NAME == "Bash"
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def test_hook_event_name_is_pre_tool_use() -> None:
|
|
23
|
-
assert HOOK_EVENT_NAME == "PreToolUse"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def test_permission_allow_is_allow() -> None:
|
|
27
|
-
assert PERMISSION_ALLOW == "allow"
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def test_placeholder_token_pattern_matches_curly_brace_form() -> None:
|
|
31
|
-
match = PLACEHOLDER_TOKEN_PATTERN.match("{my-repo}")
|
|
32
|
-
assert match is not None
|
|
33
|
-
assert match.group(1) == "my-repo"
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def test_placeholder_token_pattern_matches_double_quoted_form() -> None:
|
|
37
|
-
match = PLACEHOLDER_TOKEN_PATTERN.match('"{my-repo}"')
|
|
38
|
-
assert match is not None
|
|
39
|
-
assert match.group(1) == "my-repo"
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def test_placeholder_token_pattern_matches_single_quoted_form() -> None:
|
|
43
|
-
match = PLACEHOLDER_TOKEN_PATTERN.match("'{my-repo}'")
|
|
44
|
-
assert match is not None
|
|
45
|
-
assert match.group(1) == "my-repo"
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def test_placeholder_token_pattern_does_not_match_shell_parameter_expansion() -> None:
|
|
49
|
-
assert PLACEHOLDER_TOKEN_PATTERN.search("${myrepo}") is None
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def test_placeholder_token_pattern_does_not_match_embedded_in_flag() -> None:
|
|
53
|
-
assert PLACEHOLDER_TOKEN_PATTERN.search("--flag={my-repo}") is None
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def test_placeholder_token_pattern_does_not_match_embedded_in_token() -> None:
|
|
57
|
-
assert PLACEHOLDER_TOKEN_PATTERN.search("foo{my-repo}bar") is None
|