claude-dev-env 1.17.1 → 1.17.5

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
@@ -5,14 +5,77 @@ import { join, dirname, resolve, relative } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
6
  import { execSync } from 'node:child_process';
7
7
  import { fileURLToPath } from 'node:url';
8
+ import { createRequire } from 'node:module';
8
9
 
9
10
  const CLAUDE_HOME = join(homedir(), '.claude');
10
11
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
11
12
  const MANIFEST_FILE = join(CLAUDE_HOME, '.claude-dev-env-manifest.json');
12
13
  const PACKAGE_NAME = 'claude-dev-env';
14
+ const packageRequire = createRequire(import.meta.url);
13
15
 
14
16
  const CONTENT_DIRECTORIES = ['rules', 'docs', 'commands', 'agents'];
15
17
 
18
+ function resolveDependencyPackageRoot(dependencyPackageName) {
19
+ const dependencyPackageJsonPath = packageRequire.resolve(
20
+ `${dependencyPackageName}/package.json`
21
+ );
22
+ return dirname(dependencyPackageJsonPath);
23
+ }
24
+
25
+ function discoverDependencyGroups() {
26
+ const ownPackageJsonPath = join(PACKAGE_ROOT, 'package.json');
27
+ const ownPackageJson = JSON.parse(readFileSync(ownPackageJsonPath, 'utf8'));
28
+ const dependencies = ownPackageJson.dependencies || {};
29
+ const discoveredGroups = {};
30
+ for (const dependencyName of Object.keys(dependencies)) {
31
+ let dependencyRoot;
32
+ try {
33
+ dependencyRoot = resolveDependencyPackageRoot(dependencyName);
34
+ } catch {
35
+ console.error(` WARNING: Could not resolve dependency ${dependencyName}, skipping`);
36
+ continue;
37
+ }
38
+ const dependencyPackageJson = JSON.parse(
39
+ readFileSync(join(dependencyRoot, 'package.json'), 'utf8')
40
+ );
41
+ const groupName = dependencyPackageJson.claudeDevEnv?.groupName
42
+ || dependencyName.replace(/^@[^/]+\//, '');
43
+ const group = {
44
+ description: dependencyPackageJson.description || dependencyName,
45
+ packageRoot: dependencyRoot,
46
+ };
47
+ const skillsDirectory = join(dependencyRoot, 'skills');
48
+ if (existsSync(skillsDirectory)) {
49
+ group.skills = readdirSync(skillsDirectory, { withFileTypes: true })
50
+ .filter(entry => entry.isDirectory())
51
+ .map(entry => entry.name);
52
+ }
53
+ const hooksDirectory = join(dependencyRoot, 'hooks');
54
+ if (existsSync(hooksDirectory)) {
55
+ const hookFiles = collectFiles(hooksDirectory)
56
+ .filter(file => !file.endsWith('hooks.json'))
57
+ .filter(file => {
58
+ const baseName = file.replace(/\\/g, '/').split('/').pop();
59
+ return !baseName.startsWith('test_');
60
+ })
61
+ .map(file => relative(hooksDirectory, file).replace(/\\/g, '/'));
62
+ if (hookFiles.length > 0) {
63
+ group.includeHookFiles = hookFiles;
64
+ }
65
+ }
66
+ const rulesDirectory = join(dependencyRoot, 'rules');
67
+ if (existsSync(rulesDirectory)) {
68
+ const ruleFiles = readdirSync(rulesDirectory)
69
+ .filter(file => file.endsWith('.md'));
70
+ if (ruleFiles.length > 0) {
71
+ group.includeRules = ruleFiles;
72
+ }
73
+ }
74
+ discoveredGroups[groupName] = group;
75
+ }
76
+ return discoveredGroups;
77
+ }
78
+
16
79
  const INSTALL_GROUPS = {
17
80
  core: {
18
81
  description: 'Development standards, hooks, agents, commands',
@@ -25,17 +88,6 @@ const INSTALL_GROUPS = {
25
88
  includeDirectories: ['rules', 'docs', 'commands', 'agents'],
26
89
  includeAllHooks: true,
27
90
  },
28
- prompts: {
29
- description: 'Prompt engineering tools',
30
- skills: ['prompt-generator', 'agent-prompt'],
31
- includeHookFiles: [
32
- 'blocking/prompt_workflow_gate_config.py',
33
- 'blocking/prompt_workflow_gate_core.py',
34
- 'blocking/prompt-workflow-stop-guard.py',
35
- 'HOOK_SPECS_PROMPT_WORKFLOW.md',
36
- ],
37
- includeRules: ['prompt-workflow-context-controls.md'],
38
- },
39
91
  journal: {
40
92
  description: 'Session logging and memory',
41
93
  skills: ['dream', 'session-log', 'session-tidy'],
@@ -44,6 +96,7 @@ const INSTALL_GROUPS = {
44
96
  description: 'Deep research and citation tools',
45
97
  skills: ['deep-research', 'research-mode'],
46
98
  },
99
+ ...discoverDependencyGroups(),
47
100
  };
48
101
 
49
102
  function detectPython() {
@@ -99,8 +152,8 @@ function copyTree(sourceBase, destBase) {
99
152
  return stats;
100
153
  }
101
154
 
102
- function mergeHooks(pythonCommand) {
103
- const hooksJsonPath = join(PACKAGE_ROOT, 'hooks', 'hooks.json');
155
+ function mergeHooks(hooksSourceRoot, pythonCommand) {
156
+ const hooksJsonPath = join(hooksSourceRoot, 'hooks', 'hooks.json');
104
157
  if (!existsSync(hooksJsonPath)) return 0;
105
158
  const hooksConfig = JSON.parse(readFileSync(hooksJsonPath, 'utf8'));
106
159
  const settingsPath = join(CLAUDE_HOME, 'settings.json');
@@ -163,58 +216,85 @@ function install(selectedGroups) {
163
216
  console.log(` Python: ${pythonCommand}`);
164
217
  mkdirSync(CLAUDE_HOME, { recursive: true });
165
218
 
219
+ const activeGroups = selectedGroups
220
+ ? selectedGroups.map(groupName => ({ groupName, ...INSTALL_GROUPS[groupName] }))
221
+ : Object.entries(INSTALL_GROUPS).map(([groupName, group]) => ({ groupName, ...group }));
222
+
166
223
  const allowedSkills = selectedGroups
167
- ? new Set(selectedGroups.flatMap(groupName => INSTALL_GROUPS[groupName].skills || []))
224
+ ? new Set(activeGroups.flatMap(group => group.skills || []))
168
225
  : null;
169
226
  const allowedDirectories = selectedGroups
170
- ? new Set(selectedGroups.flatMap(groupName => INSTALL_GROUPS[groupName].includeDirectories || []))
227
+ ? new Set(activeGroups.flatMap(group => group.includeDirectories || []))
171
228
  : null;
172
229
  const shouldInstallAllHooks = selectedGroups
173
- ? selectedGroups.some(groupName => INSTALL_GROUPS[groupName].includeAllHooks)
230
+ ? activeGroups.some(group => group.includeAllHooks)
174
231
  : true;
175
232
  const allowedHookFiles = selectedGroups
176
- ? new Set(selectedGroups.flatMap(groupName => INSTALL_GROUPS[groupName].includeHookFiles || []))
233
+ ? new Set(activeGroups.flatMap(group => group.includeHookFiles || []))
177
234
  : null;
178
235
  const allowedRules = selectedGroups
179
- ? new Set(selectedGroups.flatMap(groupName => INSTALL_GROUPS[groupName].includeRules || []))
236
+ ? new Set(activeGroups.flatMap(group => group.includeRules || []))
180
237
  : null;
181
238
 
239
+ const dependencyRoots = [...new Set(
240
+ activeGroups.filter(group => group.packageRoot).map(group => group.packageRoot)
241
+ )];
242
+ const builtinGroupsActive = activeGroups.some(group => !group.packageRoot);
243
+ const allSourceRoots = [
244
+ ...(builtinGroupsActive ? [PACKAGE_ROOT] : []),
245
+ ...dependencyRoots,
246
+ ];
247
+
182
248
  const allInstalledFiles = [];
183
249
  const summary = {};
184
250
  for (const directory of CONTENT_DIRECTORIES) {
185
251
  const hasFullAccess = !allowedDirectories || allowedDirectories.has(directory);
186
252
  const hasPartialRules = directory === 'rules' && allowedRules && allowedRules.size > 0;
187
253
  if (!hasFullAccess && !hasPartialRules) continue;
188
- const sourceDir = join(PACKAGE_ROOT, directory);
189
- if (!existsSync(sourceDir)) continue;
190
- const destDir = join(CLAUDE_HOME, directory);
191
- if (hasFullAccess) {
192
- const stats = copyTree(sourceDir, destDir);
193
- summary[directory] = stats;
194
- allInstalledFiles.push(...stats.paths);
195
- } else if (hasPartialRules) {
196
- let rulesCreated = 0;
197
- let rulesUpdated = 0;
198
- for (const ruleFile of allowedRules) {
199
- const sourcePath = join(sourceDir, ruleFile);
200
- if (!existsSync(sourcePath)) continue;
201
- const destPath = join(destDir, ruleFile);
202
- mkdirSync(dirname(destPath), { recursive: true });
203
- const existed = existsSync(destPath);
204
- copyFileSync(sourcePath, destPath);
205
- allInstalledFiles.push(destPath);
206
- if (existed) { rulesUpdated++; } else { rulesCreated++; }
207
- console.log(` ${existed ? '\u21bb' : '\u2713'} ${join(directory, ruleFile)} (${existed ? 'updated' : 'new'})`);
254
+ for (const sourceRoot of allSourceRoots) {
255
+ const sourceDir = join(sourceRoot, directory);
256
+ if (!existsSync(sourceDir)) continue;
257
+ const destDir = join(CLAUDE_HOME, directory);
258
+ if (hasFullAccess) {
259
+ const stats = copyTree(sourceDir, destDir);
260
+ if (!summary[directory]) {
261
+ summary[directory] = stats;
262
+ } else {
263
+ summary[directory].created += stats.created;
264
+ summary[directory].updated += stats.updated;
265
+ summary[directory].paths.push(...stats.paths);
266
+ }
267
+ allInstalledFiles.push(...stats.paths);
268
+ } else if (hasPartialRules) {
269
+ let rulesCreated = 0;
270
+ let rulesUpdated = 0;
271
+ for (const ruleFile of allowedRules) {
272
+ const sourcePath = join(sourceDir, ruleFile);
273
+ if (!existsSync(sourcePath)) continue;
274
+ const destPath = join(destDir, ruleFile);
275
+ mkdirSync(dirname(destPath), { recursive: true });
276
+ const existed = existsSync(destPath);
277
+ copyFileSync(sourcePath, destPath);
278
+ allInstalledFiles.push(destPath);
279
+ if (existed) { rulesUpdated++; } else { rulesCreated++; }
280
+ console.log(` ${existed ? '\u21bb' : '\u2713'} ${join(directory, ruleFile)} (${existed ? 'updated' : 'new'})`);
281
+ }
282
+ if (!summary[directory]) {
283
+ summary[directory] = { created: rulesCreated, updated: rulesUpdated, paths: [] };
284
+ } else {
285
+ summary[directory].created += rulesCreated;
286
+ summary[directory].updated += rulesUpdated;
287
+ }
208
288
  }
209
- summary[directory] = { created: rulesCreated, updated: rulesUpdated, paths: [] };
210
289
  }
211
290
  }
212
- const skillsSource = join(PACKAGE_ROOT, 'skills');
213
- if (existsSync(skillsSource)) {
291
+ let skillsCreated = 0;
292
+ let skillsUpdated = 0;
293
+ const skillPaths = [];
294
+ for (const sourceRoot of allSourceRoots) {
295
+ const skillsSource = join(sourceRoot, 'skills');
296
+ if (!existsSync(skillsSource)) continue;
214
297
  const skillDirs = readdirSync(skillsSource, { withFileTypes: true }).filter(entry => entry.isDirectory());
215
- let skillsCreated = 0;
216
- let skillsUpdated = 0;
217
- const skillPaths = [];
218
298
  for (const skillDir of skillDirs) {
219
299
  if (allowedSkills && !allowedSkills.has(skillDir.name)) continue;
220
300
  const stats = copyTree(join(skillsSource, skillDir.name), join(CLAUDE_HOME, 'skills', skillDir.name));
@@ -222,13 +302,17 @@ function install(selectedGroups) {
222
302
  skillsUpdated += stats.updated;
223
303
  skillPaths.push(...stats.paths);
224
304
  }
225
- summary.skills = { created: skillsCreated, updated: skillsUpdated, paths: skillPaths };
226
- allInstalledFiles.push(...skillPaths);
227
305
  }
306
+ summary.skills = { created: skillsCreated, updated: skillsUpdated, paths: skillPaths };
307
+ allInstalledFiles.push(...skillPaths);
228
308
  const shouldInstallAnyHooks = shouldInstallAllHooks || (allowedHookFiles && allowedHookFiles.size > 0);
229
309
  if (shouldInstallAnyHooks) {
230
- const hooksSource = join(PACKAGE_ROOT, 'hooks');
231
- if (existsSync(hooksSource)) {
310
+ let totalHooksCreated = 0;
311
+ let totalHooksUpdated = 0;
312
+ let totalHookGroups = 0;
313
+ for (const sourceRoot of allSourceRoots) {
314
+ const hooksSource = join(sourceRoot, 'hooks');
315
+ if (!existsSync(hooksSource)) continue;
232
316
  const hooksDestination = join(CLAUDE_HOME, 'hooks');
233
317
  const filesToCopy = collectFiles(hooksSource)
234
318
  .filter(file => !file.endsWith('hooks.json'))
@@ -237,8 +321,6 @@ function install(selectedGroups) {
237
321
  const relativePath = relative(hooksSource, file).replace(/\\/g, '/');
238
322
  return allowedHookFiles.has(relativePath);
239
323
  });
240
- let hooksCreated = 0;
241
- let hooksUpdated = 0;
242
324
  for (const sourceFile of filesToCopy) {
243
325
  const relativePath = relative(hooksSource, sourceFile);
244
326
  const destFile = join(hooksDestination, relativePath);
@@ -246,14 +328,15 @@ function install(selectedGroups) {
246
328
  const existed = existsSync(destFile);
247
329
  copyFileSync(sourceFile, destFile);
248
330
  allInstalledFiles.push(destFile);
249
- if (existed) { hooksUpdated++; } else { hooksCreated++; }
331
+ if (existed) { totalHooksUpdated++; } else { totalHooksCreated++; }
250
332
  }
251
- summary.hookFiles = { created: hooksCreated, updated: hooksUpdated };
252
- console.log(` Hook files: ${hooksCreated} new, ${hooksUpdated} updated`);
253
- const groupCount = mergeHooks(pythonCommand);
254
- summary.hookGroups = groupCount;
255
- console.log(` Hook groups: ${groupCount} merged into settings.json`);
333
+ const groupCount = mergeHooks(sourceRoot, pythonCommand);
334
+ totalHookGroups += groupCount;
256
335
  }
336
+ summary.hookFiles = { created: totalHooksCreated, updated: totalHooksUpdated };
337
+ console.log(` Hook files: ${totalHooksCreated} new, ${totalHooksUpdated} updated`);
338
+ summary.hookGroups = totalHookGroups;
339
+ console.log(` Hook groups: ${totalHookGroups} merged into settings.json`);
257
340
  }
258
341
  writeManifest(allInstalledFiles);
259
342
  console.log(`\nInstalled ${PACKAGE_NAME}:`);
@@ -330,14 +413,14 @@ Usage:
330
413
  npx ${PACKAGE_NAME} --help Show this help
331
414
 
332
415
  Groups:
333
- core Development standards, hooks, agents, commands
334
- prompts Prompt engineering tools
335
- journal Session logging and memory
336
- research Deep research and citation tools
416
+ core Development standards, hooks, agents, commands
417
+ prompt-generator Prompt engineering tools
418
+ journal Session logging and memory
419
+ research Deep research and citation tools
337
420
 
338
421
  Examples:
339
- npx ${PACKAGE_NAME} --only prompts
340
- npx ${PACKAGE_NAME} --only prompts,research
422
+ npx ${PACKAGE_NAME} --only prompt-generator
423
+ npx ${PACKAGE_NAME} --only prompt-generator,research
341
424
 
342
425
  Install location: ~/.claude/
343
426
  `);
@@ -6,18 +6,20 @@ Deterministic runtime gates for prompt workflows.
6
6
 
7
7
  The former `agent-execution-intent-gate.py` hook is **removed**. Native Agent/Task launches do not carry stable custom metadata; enforcing scope text on every spawn blocked legitimate `/agent-prompt` and refinement delegations. Scope and checklist rules remain enforced by the Stop guard when a prompt-workflow response is detected.
8
8
 
9
- ## Gate: Leakage + Checklist + Scope (Stop)
9
+ ## Gate: Leakage + Checklist + Scope (file-based validation loop)
10
10
 
11
- - Hook: `hooks/blocking/prompt-workflow-stop-guard.py`
12
- - Event: `Stop`
13
- - Fail condition:
11
+ - Validator: `hooks/blocking/prompt_workflow_validate.py`
12
+ - Invocation: CLI against `data/prompts/.draft-prompt.xml` (exit 0 allowed, exit 2 blocked)
13
+ - Fail conditions:
14
14
  - Raw internal refinement object appears in assistant output without explicit debug intent
15
15
  - Prompt-workflow response detected but deterministic checklist container is missing
16
16
  - Prompt-workflow response detected and required deterministic checklist rows are missing
17
17
  - Prompt-workflow response detected and required scope anchors are missing
18
18
  - Prompt-workflow response detected and runtime context-control signals are missing
19
19
  - Scope-bound text uses banned ambiguous scope terms
20
- - Action: `block` with correction reason.
20
+ - Banned negative keywords found inside fenced XML artifact
21
+ - Fenced XML artifact missing required sections
22
+ - Enforcement: The drafting subagent writes the draft file, runs the validator, reads stderr violations (each prefixed with `[reason_code]`), edits the file, and re-runs until exit 0.
21
23
 
22
24
  ## Required Scope Anchors
23
25
 
@@ -49,7 +51,7 @@ The former `agent-execution-intent-gate.py` hook is **removed**. Native Agent/Ta
49
51
  - `base_minimal_instruction_layer: true`
50
52
  - `on_demand_skill_loading: true`
51
53
 
52
- These two signals are runtime-checked by the Stop guard whenever a prompt-workflow response is detected.
54
+ These two signals are checked by the validator CLI whenever a prompt-workflow response is detected.
53
55
 
54
56
  ## Deterministic Boundary
55
57
 
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: deny Grep, Search, and shell search in indexed trees; steer to Zoekt MCP."""
3
+
4
+ import json
5
+ import os
6
+ import sys
7
+
8
+ from content_search_zoekt_bash_block_reason import block_reason_for_bash_command
9
+ from content_search_zoekt_block_payload import build_block_payload
10
+ from content_search_zoekt_indexed_paths import is_in_indexed_repo, is_specific_file
11
+ from content_search_zoekt_redirect_guidance import get_zoekt_redirect_guidance
12
+
13
+
14
+ def main() -> None:
15
+ try:
16
+ input_data = json.load(sys.stdin)
17
+ except json.JSONDecodeError:
18
+ sys.exit(0)
19
+
20
+ tool_name = input_data.get("tool_name", "")
21
+ tool_input = input_data.get("tool_input", {})
22
+
23
+ content_search_tools = frozenset({"Grep", "Search"})
24
+ block_reason = None
25
+
26
+ if tool_name in content_search_tools:
27
+ pattern = tool_input.get("pattern", "")
28
+ path = tool_input.get("path", "")
29
+
30
+ if not path:
31
+ path = os.getcwd()
32
+
33
+ if is_specific_file(path):
34
+ sys.exit(0)
35
+
36
+ if is_in_indexed_repo(path):
37
+ block_reason = f"{tool_name}(pattern: \"{pattern}\", path: \"{path}\")"
38
+
39
+ elif tool_name == "Bash":
40
+ command = tool_input.get("command", "")
41
+ block_reason = block_reason_for_bash_command(command)
42
+
43
+ if block_reason is None:
44
+ sys.exit(0)
45
+ short_label = f"blocked {block_reason}; use Zoekt MCP"
46
+ payload = build_block_payload(
47
+ brief_label=short_label,
48
+ permission_decision_reason=get_zoekt_redirect_guidance(),
49
+ )
50
+ print(json.dumps(payload))
51
+ sys.exit(0)
52
+
53
+
54
+ if __name__ == "__main__":
55
+ main()
@@ -0,0 +1,25 @@
1
+ """Match Bash one-liners that act as content search (grep, rg, findstr, etc.)."""
2
+
3
+ import re
4
+
5
+
6
+ def block_reason_for_bash_command(command: str) -> str | None:
7
+ bash_content_search_patterns = (
8
+ (re.compile(r"^\s*grep\s", re.IGNORECASE), "grep"),
9
+ (re.compile(r"^\s*grep$", re.IGNORECASE), "grep"),
10
+ (re.compile(r"\|\s*grep\s", re.IGNORECASE), "piped grep"),
11
+ (re.compile(r"\|\s*grep$", re.IGNORECASE), "piped grep"),
12
+ (re.compile(r"^\s*rg\s", re.IGNORECASE), "ripgrep"),
13
+ (re.compile(r"^\s*rg$", re.IGNORECASE), "ripgrep"),
14
+ (re.compile(r"\|\s*rg\s", re.IGNORECASE), "piped ripgrep"),
15
+ (re.compile(r"^\s*findstr\s", re.IGNORECASE), "findstr"),
16
+ (re.compile(r"^\s*Select-String", re.IGNORECASE), "PowerShell Select-String"),
17
+ (re.compile(r"^\s*sls\s", re.IGNORECASE), "PowerShell sls"),
18
+ (re.compile(r"^\s*ack\s", re.IGNORECASE), "ack"),
19
+ (re.compile(r"^\s*ag\s", re.IGNORECASE), "silver searcher"),
20
+ (re.compile(r"^\s*git\s+grep\s", re.IGNORECASE), "git grep"),
21
+ )
22
+ for regex, command_name in bash_content_search_patterns:
23
+ if regex.search(command):
24
+ return f"Bash({command_name})"
25
+ return None
@@ -0,0 +1,17 @@
1
+ """JSON shape for Claude Code PreToolUse deny: hookSpecificOutput plus short systemMessage."""
2
+
3
+
4
+ def build_block_payload(
5
+ brief_label: str,
6
+ permission_decision_reason: str,
7
+ ) -> dict:
8
+ destructive_gate_label_prefix = "[destructive-gate]"
9
+ return {
10
+ "hookSpecificOutput": {
11
+ "hookEventName": "PreToolUse",
12
+ "permissionDecision": "deny",
13
+ "permissionDecisionReason": permission_decision_reason,
14
+ },
15
+ "systemMessage": f"{destructive_gate_label_prefix} {brief_label}",
16
+ "suppressOutput": True,
17
+ }
@@ -0,0 +1,24 @@
1
+ """Normalize paths and test membership under Zoekt-indexed roots (from env, JSON file, or defaults)."""
2
+
3
+ import re
4
+
5
+
6
+ def normalize_path(path: str) -> str:
7
+ return path.replace("\\", "/").lower()
8
+
9
+
10
+ def is_specific_file(path: str) -> bool:
11
+ file_extension_pattern = re.compile(r"\.\w{1,10}$")
12
+ return bool(file_extension_pattern.search(path))
13
+
14
+
15
+ def is_in_indexed_repo(path: str) -> bool:
16
+ from content_search_zoekt_indexed_roots_config import indexed_root_prefixes
17
+
18
+ norm = normalize_path(path)
19
+ if not norm.endswith("/"):
20
+ norm += "/"
21
+ for prefix in indexed_root_prefixes():
22
+ if norm.startswith(prefix):
23
+ return True
24
+ return False
@@ -0,0 +1,131 @@
1
+ """Resolve Zoekt-indexed filesystem roots from environment or JSON file (no built-in roots in this package)."""
2
+
3
+ import json
4
+ import os
5
+ from functools import lru_cache
6
+ from pathlib import Path
7
+
8
+ from content_search_zoekt_indexed_paths import normalize_path
9
+
10
+
11
+ def _environment_variable_indexed_roots() -> str:
12
+ return "ZOEKT_REDIRECT_INDEXED_ROOTS"
13
+
14
+
15
+ def _environment_variable_roots_file() -> str:
16
+ return "ZOEKT_REDIRECT_INDEXED_ROOTS_FILE"
17
+
18
+
19
+ def _json_object_roots_key() -> str:
20
+ return "roots"
21
+
22
+
23
+ def _default_config_relative_parts() -> tuple[str, str]:
24
+ return (".claude", "zoekt-indexed-roots.json")
25
+
26
+
27
+ def _built_in_fallback_roots() -> tuple[str, ...]:
28
+ return ()
29
+
30
+
31
+ def _parse_json_roots_list(raw: str) -> list[str] | None:
32
+ try:
33
+ parsed = json.loads(raw)
34
+ except json.JSONDecodeError:
35
+ return None
36
+ if not isinstance(parsed, list):
37
+ return None
38
+ if not all(isinstance(item, str) for item in parsed):
39
+ return None
40
+ return list(parsed)
41
+
42
+
43
+ def _config_file_path() -> Path:
44
+ override = os.environ.get(_environment_variable_roots_file())
45
+ if override is not None and override.strip() != "":
46
+ return Path(override).expanduser()
47
+ relative_dot_claude, relative_file_name = _default_config_relative_parts()
48
+ return Path.home() / relative_dot_claude / relative_file_name
49
+
50
+
51
+ def _roots_from_json_file() -> list[str] | None:
52
+ path = _config_file_path()
53
+ if not path.is_file():
54
+ return None
55
+ try:
56
+ text = path.read_text(encoding="utf-8")
57
+ except OSError:
58
+ return None
59
+ try:
60
+ data = json.loads(text)
61
+ except json.JSONDecodeError:
62
+ return None
63
+ if not isinstance(data, dict):
64
+ return None
65
+ roots_key = _json_object_roots_key()
66
+ roots_value = data.get(roots_key)
67
+ if roots_value is None:
68
+ return None
69
+ if not isinstance(roots_value, list):
70
+ return None
71
+ if not all(isinstance(item, str) for item in roots_value):
72
+ return None
73
+ return list(roots_value)
74
+
75
+
76
+ def _roots_from_environment_variable() -> list[str] | None:
77
+ variable_name = _environment_variable_indexed_roots()
78
+ if variable_name not in os.environ:
79
+ return None
80
+ raw = os.environ.get(variable_name, "")
81
+ if raw.strip() == "":
82
+ return []
83
+ parsed = _parse_json_roots_list(raw)
84
+ if parsed is None:
85
+ return None
86
+ return parsed
87
+
88
+
89
+ def _expand_root_to_prefix_variants(root: str) -> list[str]:
90
+ trimmed = root.strip()
91
+ if trimmed == "":
92
+ return []
93
+ norm = normalize_path(trimmed)
94
+ if not norm.endswith("/"):
95
+ norm = norm + "/"
96
+ variants = [norm]
97
+ if len(norm) >= 3 and norm[1] == ":" and norm[0].isalpha() and norm[2] == "/":
98
+ drive_letter = norm[0]
99
+ remainder = norm[2:]
100
+ wsl_prefix = f"/mnt/{drive_letter}{remainder}"
101
+ if wsl_prefix not in variants:
102
+ variants.append(wsl_prefix)
103
+ return variants
104
+
105
+
106
+ def _expand_all_roots(raw_roots: list[str]) -> tuple[str, ...]:
107
+ prefixes: list[str] = []
108
+ for root in raw_roots:
109
+ prefixes.extend(_expand_root_to_prefix_variants(root))
110
+ unique = frozenset(prefixes)
111
+ return tuple(sorted(unique, key=len, reverse=True))
112
+
113
+
114
+ def _raw_roots_resolution_order() -> list[str]:
115
+ from_env = _roots_from_environment_variable()
116
+ if from_env is not None:
117
+ return from_env
118
+ from_file = _roots_from_json_file()
119
+ if from_file is not None:
120
+ return from_file
121
+ return list(_built_in_fallback_roots())
122
+
123
+
124
+ @lru_cache(maxsize=1)
125
+ def indexed_root_prefixes() -> tuple[str, ...]:
126
+ raw_roots = _raw_roots_resolution_order()
127
+ return _expand_all_roots(raw_roots)
128
+
129
+
130
+ def clear_indexed_root_prefixes_cache() -> None:
131
+ indexed_root_prefixes.cache_clear()
@@ -0,0 +1,19 @@
1
+ """Zoekt MCP usage and repo-to-disk path mapping for PreToolUse permissionDecisionReason."""
2
+
3
+
4
+ def get_zoekt_redirect_guidance() -> str:
5
+ return (
6
+ "Use Zoekt MCP instead: mcp__zoekt__search(query=\"your pattern\"). "
7
+ "Supports regex, 'file:pattern' for file filtering, 'lang:py' for language. "
8
+ "Also available: mcp__zoekt__search_symbols, mcp__zoekt__find_references, mcp__zoekt__file_content. "
9
+ "Example: mcp__zoekt__search(query=\"verify_theme_assets file:\\.py$\")\n\n"
10
+ "INDEX ROOTS (when Grep/Search in a tree is redirected): set ZOEKT_REDIRECT_INDEXED_ROOTS to a JSON array "
11
+ "of absolute paths, or ~/.claude/zoekt-indexed-roots.json as {\"roots\": [\"/abs/path/to/repo/\", ...]}. "
12
+ "Optional ZOEKT_REDIRECT_INDEXED_ROOTS_FILE points to a different JSON file. "
13
+ "WSL /mnt/<drive>/... prefixes are derived from Windows roots automatically. "
14
+ "This package ships no built-in roots (public repo); you must configure roots locally.\n\n"
15
+ "ZOEKT REPO LABEL -> LOCAL DISK (for editing files after a Zoekt hit): "
16
+ "keep the same directories in zoekt-indexed-roots.json as you index in Zoekt. "
17
+ "Example pattern only — yours will differ: if Zoekt shows \"acme-lib - src/foo.py\" and that repo "
18
+ "lives at /srv/checkout/acme-lib/ on your machine, edit /srv/checkout/acme-lib/src/foo.py."
19
+ )