claude-dev-env 1.17.2 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/bin/install.mjs +145 -63
  2. package/hooks/blocking/content-search-to-zoekt-redirector.py +55 -0
  3. package/hooks/blocking/content_search_zoekt_bash_block_reason.py +25 -0
  4. package/hooks/blocking/content_search_zoekt_block_payload.py +17 -0
  5. package/hooks/blocking/content_search_zoekt_indexed_paths.py +24 -0
  6. package/hooks/blocking/content_search_zoekt_indexed_roots_config.py +131 -0
  7. package/hooks/blocking/content_search_zoekt_redirect_guidance.py +19 -0
  8. package/hooks/blocking/destructive-command-blocker.py +53 -4
  9. package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +54 -0
  10. package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +51 -0
  11. package/hooks/blocking/test_content_search_zoekt_indexed_roots_config.py +102 -0
  12. package/hooks/blocking/test_destructive_command_blocker.py +108 -0
  13. package/package.json +4 -1
  14. package/skills/rule-audit/SKILL.md +2 -2
  15. package/hooks/HOOK_SPECS_PROMPT_WORKFLOW.md +0 -64
  16. package/hooks/blocking/prompt_workflow_clipboard.py +0 -63
  17. package/hooks/blocking/prompt_workflow_gate_config.py +0 -113
  18. package/hooks/blocking/prompt_workflow_gate_core.py +0 -289
  19. package/hooks/blocking/prompt_workflow_validate.py +0 -218
  20. package/hooks/blocking/test_prompt_workflow_clipboard.py +0 -54
  21. package/hooks/blocking/test_prompt_workflow_gate_core.py +0 -195
  22. package/hooks/blocking/test_prompt_workflow_validate.py +0 -339
  23. package/rules/prompt-workflow-context-controls.md +0 -48
  24. package/skills/agent-prompt/SKILL.md +0 -199
  25. package/skills/prompt-generator/ARCHITECTURE.md +0 -18
  26. package/skills/prompt-generator/REFERENCE.md +0 -254
  27. package/skills/prompt-generator/REFINEMENT_PIPELINE_RUNBOOK.md +0 -177
  28. package/skills/prompt-generator/SKILL.md +0 -354
  29. package/skills/prompt-generator/TARGET_OUTPUT.md +0 -133
  30. package/skills/prompt-generator/evals/prompt-generator.json +0 -207
  31. package/skills/prompt-generator/templates/skill-from-ground-up.md +0 -104
  32. package/skills/prompt-generator/templates/skill-refinement-package.md +0 -109
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,18 +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_validate.py',
35
- 'blocking/prompt_workflow_clipboard.py',
36
- 'HOOK_SPECS_PROMPT_WORKFLOW.md',
37
- ],
38
- includeRules: ['prompt-workflow-context-controls.md'],
39
- },
40
91
  journal: {
41
92
  description: 'Session logging and memory',
42
93
  skills: ['dream', 'session-log', 'session-tidy'],
@@ -45,6 +96,7 @@ const INSTALL_GROUPS = {
45
96
  description: 'Deep research and citation tools',
46
97
  skills: ['deep-research', 'research-mode'],
47
98
  },
99
+ ...discoverDependencyGroups(),
48
100
  };
49
101
 
50
102
  function detectPython() {
@@ -100,8 +152,8 @@ function copyTree(sourceBase, destBase) {
100
152
  return stats;
101
153
  }
102
154
 
103
- function mergeHooks(pythonCommand) {
104
- const hooksJsonPath = join(PACKAGE_ROOT, 'hooks', 'hooks.json');
155
+ function mergeHooks(hooksSourceRoot, pythonCommand) {
156
+ const hooksJsonPath = join(hooksSourceRoot, 'hooks', 'hooks.json');
105
157
  if (!existsSync(hooksJsonPath)) return 0;
106
158
  const hooksConfig = JSON.parse(readFileSync(hooksJsonPath, 'utf8'));
107
159
  const settingsPath = join(CLAUDE_HOME, 'settings.json');
@@ -164,58 +216,85 @@ function install(selectedGroups) {
164
216
  console.log(` Python: ${pythonCommand}`);
165
217
  mkdirSync(CLAUDE_HOME, { recursive: true });
166
218
 
219
+ const activeGroups = selectedGroups
220
+ ? selectedGroups.map(groupName => ({ groupName, ...INSTALL_GROUPS[groupName] }))
221
+ : Object.entries(INSTALL_GROUPS).map(([groupName, group]) => ({ groupName, ...group }));
222
+
167
223
  const allowedSkills = selectedGroups
168
- ? new Set(selectedGroups.flatMap(groupName => INSTALL_GROUPS[groupName].skills || []))
224
+ ? new Set(activeGroups.flatMap(group => group.skills || []))
169
225
  : null;
170
226
  const allowedDirectories = selectedGroups
171
- ? new Set(selectedGroups.flatMap(groupName => INSTALL_GROUPS[groupName].includeDirectories || []))
227
+ ? new Set(activeGroups.flatMap(group => group.includeDirectories || []))
172
228
  : null;
173
229
  const shouldInstallAllHooks = selectedGroups
174
- ? selectedGroups.some(groupName => INSTALL_GROUPS[groupName].includeAllHooks)
230
+ ? activeGroups.some(group => group.includeAllHooks)
175
231
  : true;
176
232
  const allowedHookFiles = selectedGroups
177
- ? new Set(selectedGroups.flatMap(groupName => INSTALL_GROUPS[groupName].includeHookFiles || []))
233
+ ? new Set(activeGroups.flatMap(group => group.includeHookFiles || []))
178
234
  : null;
179
235
  const allowedRules = selectedGroups
180
- ? new Set(selectedGroups.flatMap(groupName => INSTALL_GROUPS[groupName].includeRules || []))
236
+ ? new Set(activeGroups.flatMap(group => group.includeRules || []))
181
237
  : null;
182
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
+
183
248
  const allInstalledFiles = [];
184
249
  const summary = {};
185
250
  for (const directory of CONTENT_DIRECTORIES) {
186
251
  const hasFullAccess = !allowedDirectories || allowedDirectories.has(directory);
187
252
  const hasPartialRules = directory === 'rules' && allowedRules && allowedRules.size > 0;
188
253
  if (!hasFullAccess && !hasPartialRules) continue;
189
- const sourceDir = join(PACKAGE_ROOT, directory);
190
- if (!existsSync(sourceDir)) continue;
191
- const destDir = join(CLAUDE_HOME, directory);
192
- if (hasFullAccess) {
193
- const stats = copyTree(sourceDir, destDir);
194
- summary[directory] = stats;
195
- allInstalledFiles.push(...stats.paths);
196
- } else if (hasPartialRules) {
197
- let rulesCreated = 0;
198
- let rulesUpdated = 0;
199
- for (const ruleFile of allowedRules) {
200
- const sourcePath = join(sourceDir, ruleFile);
201
- if (!existsSync(sourcePath)) continue;
202
- const destPath = join(destDir, ruleFile);
203
- mkdirSync(dirname(destPath), { recursive: true });
204
- const existed = existsSync(destPath);
205
- copyFileSync(sourcePath, destPath);
206
- allInstalledFiles.push(destPath);
207
- if (existed) { rulesUpdated++; } else { rulesCreated++; }
208
- 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
+ }
209
288
  }
210
- summary[directory] = { created: rulesCreated, updated: rulesUpdated, paths: [] };
211
289
  }
212
290
  }
213
- const skillsSource = join(PACKAGE_ROOT, 'skills');
214
- 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;
215
297
  const skillDirs = readdirSync(skillsSource, { withFileTypes: true }).filter(entry => entry.isDirectory());
216
- let skillsCreated = 0;
217
- let skillsUpdated = 0;
218
- const skillPaths = [];
219
298
  for (const skillDir of skillDirs) {
220
299
  if (allowedSkills && !allowedSkills.has(skillDir.name)) continue;
221
300
  const stats = copyTree(join(skillsSource, skillDir.name), join(CLAUDE_HOME, 'skills', skillDir.name));
@@ -223,13 +302,17 @@ function install(selectedGroups) {
223
302
  skillsUpdated += stats.updated;
224
303
  skillPaths.push(...stats.paths);
225
304
  }
226
- summary.skills = { created: skillsCreated, updated: skillsUpdated, paths: skillPaths };
227
- allInstalledFiles.push(...skillPaths);
228
305
  }
306
+ summary.skills = { created: skillsCreated, updated: skillsUpdated, paths: skillPaths };
307
+ allInstalledFiles.push(...skillPaths);
229
308
  const shouldInstallAnyHooks = shouldInstallAllHooks || (allowedHookFiles && allowedHookFiles.size > 0);
230
309
  if (shouldInstallAnyHooks) {
231
- const hooksSource = join(PACKAGE_ROOT, 'hooks');
232
- 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;
233
316
  const hooksDestination = join(CLAUDE_HOME, 'hooks');
234
317
  const filesToCopy = collectFiles(hooksSource)
235
318
  .filter(file => !file.endsWith('hooks.json'))
@@ -238,8 +321,6 @@ function install(selectedGroups) {
238
321
  const relativePath = relative(hooksSource, file).replace(/\\/g, '/');
239
322
  return allowedHookFiles.has(relativePath);
240
323
  });
241
- let hooksCreated = 0;
242
- let hooksUpdated = 0;
243
324
  for (const sourceFile of filesToCopy) {
244
325
  const relativePath = relative(hooksSource, sourceFile);
245
326
  const destFile = join(hooksDestination, relativePath);
@@ -247,14 +328,15 @@ function install(selectedGroups) {
247
328
  const existed = existsSync(destFile);
248
329
  copyFileSync(sourceFile, destFile);
249
330
  allInstalledFiles.push(destFile);
250
- if (existed) { hooksUpdated++; } else { hooksCreated++; }
331
+ if (existed) { totalHooksUpdated++; } else { totalHooksCreated++; }
251
332
  }
252
- summary.hookFiles = { created: hooksCreated, updated: hooksUpdated };
253
- console.log(` Hook files: ${hooksCreated} new, ${hooksUpdated} updated`);
254
- const groupCount = mergeHooks(pythonCommand);
255
- summary.hookGroups = groupCount;
256
- console.log(` Hook groups: ${groupCount} merged into settings.json`);
333
+ const groupCount = mergeHooks(sourceRoot, pythonCommand);
334
+ totalHookGroups += groupCount;
257
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`);
258
340
  }
259
341
  writeManifest(allInstalledFiles);
260
342
  console.log(`\nInstalled ${PACKAGE_NAME}:`);
@@ -331,14 +413,14 @@ Usage:
331
413
  npx ${PACKAGE_NAME} --help Show this help
332
414
 
333
415
  Groups:
334
- core Development standards, hooks, agents, commands
335
- prompts Prompt engineering tools
336
- journal Session logging and memory
337
- 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
338
420
 
339
421
  Examples:
340
- npx ${PACKAGE_NAME} --only prompts
341
- npx ${PACKAGE_NAME} --only prompts,research
422
+ npx ${PACKAGE_NAME} --only prompt-generator
423
+ npx ${PACKAGE_NAME} --only prompt-generator,research
342
424
 
343
425
  Install location: ~/.claude/
344
426
  `);
@@ -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
+ )
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env python3
2
+ import datetime
2
3
  import json
3
4
  import os
4
5
  import re
5
6
  import sys
7
+ from pathlib import Path
6
8
 
7
9
  CLAUDE_DIRECTORY_PATH = os.path.normpath(os.path.expanduser("~/.claude"))
8
10
 
@@ -25,10 +27,6 @@ DESTRUCTIVE_BASH_PATTERNS = [
25
27
  (re.compile(r'\bDROP\s+TABLE\b', re.IGNORECASE), "DROP TABLE (destroys database table)"),
26
28
  (re.compile(r'\bDROP\s+DATABASE\b', re.IGNORECASE), "DROP DATABASE (destroys entire database)"),
27
29
  (re.compile(r'\bTRUNCATE\s+TABLE\b', re.IGNORECASE), "TRUNCATE TABLE (removes all table rows)"),
28
- (re.compile(r'\bgh\s+api\b.*/(comments|reviews)\b.*-X\s+POST', re.IGNORECASE), "gh api comment/review POST (visible to others)"),
29
- (re.compile(r'\bgh\s+pr\s+comment\b', re.IGNORECASE), "gh pr comment (visible to others)"),
30
- (re.compile(r'\bgh\s+pr\s+review\b', re.IGNORECASE), "gh pr review (visible to others)"),
31
- (re.compile(r'\bgh\s+issue\s+comment\b', re.IGNORECASE), "gh issue comment (visible to others)"),
32
30
  ]
33
31
 
34
32
  def find_destructive_pattern(command: str) -> str | None:
@@ -38,6 +36,51 @@ def find_destructive_pattern(command: str) -> str | None:
38
36
  return None
39
37
 
40
38
 
39
+ def find_redirected_gh_pattern(command: str) -> str | None:
40
+ redirected_gh_bash_patterns = [
41
+ (re.compile(r'\bgh\s+api\b.*/(comments|reviews)\b.*-X\s+POST', re.IGNORECASE), "gh api comment/review POST"),
42
+ (re.compile(r'\bgh\s+pr\s+comment\b', re.IGNORECASE), "gh pr comment"),
43
+ (re.compile(r'\bgh\s+pr\s+review\b', re.IGNORECASE), "gh pr review"),
44
+ (re.compile(r'\bgh\s+issue\s+comment\b', re.IGNORECASE), "gh issue comment"),
45
+ ]
46
+ for pattern_regex, pattern_description in redirected_gh_bash_patterns:
47
+ if pattern_regex.search(command):
48
+ return pattern_description
49
+ return None
50
+
51
+
52
+ def _append_destructive_gate_log_entry(brief_label: str, full_reason: str) -> None:
53
+ destructive_gate_log_path = Path.home() / ".claude" / "logs" / "destructive-gate.log"
54
+ try:
55
+ destructive_gate_log_path.parent.mkdir(parents=True, exist_ok=True)
56
+ timestamp_iso = datetime.datetime.now().isoformat()
57
+ log_entry = f"{timestamp_iso}\t{brief_label}\t{full_reason}\n"
58
+ with destructive_gate_log_path.open("a", encoding="utf-8") as log_handle:
59
+ log_handle.write(log_entry)
60
+ except OSError:
61
+ pass
62
+
63
+
64
+ def _build_silent_gh_deny_response(matched_description: str) -> dict:
65
+ gh_gate_user_facing_prefix = "[gh-gate]"
66
+ brief_label = f"blocked redirected {matched_description}"
67
+ full_reason = (
68
+ f"GH-REDIRECT GATE: {matched_description} already executed by "
69
+ "gh-wsl-to-windows-redirect.py via PowerShell. Denying the original "
70
+ "Bash call prevents duplicate execution."
71
+ )
72
+ _append_destructive_gate_log_entry(brief_label, full_reason)
73
+ return {
74
+ "hookSpecificOutput": {
75
+ "hookEventName": "PreToolUse",
76
+ "permissionDecision": "deny",
77
+ "permissionDecisionReason": full_reason,
78
+ },
79
+ "suppressOutput": True,
80
+ "systemMessage": f"{gh_gate_user_facing_prefix} {brief_label}",
81
+ }
82
+
83
+
41
84
  def targets_only_claude_directory(command: str) -> bool:
42
85
  """Check if rm command targets only paths under ~/.claude/."""
43
86
  all_rm_target_paths = re.findall(
@@ -72,6 +115,12 @@ def main() -> None:
72
115
  sys.exit(0)
73
116
 
74
117
  command = tool_input.get("command", "")
118
+
119
+ redirected_gh_description = find_redirected_gh_pattern(command)
120
+ if redirected_gh_description is not None:
121
+ print(json.dumps(_build_silent_gh_deny_response(redirected_gh_description)))
122
+ sys.exit(0)
123
+
75
124
  matched_description = find_destructive_pattern(command)
76
125
 
77
126
  if matched_description is not None and targets_only_claude_directory(command):