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.
- package/bin/install.mjs +145 -63
- package/hooks/blocking/content-search-to-zoekt-redirector.py +55 -0
- package/hooks/blocking/content_search_zoekt_bash_block_reason.py +25 -0
- package/hooks/blocking/content_search_zoekt_block_payload.py +17 -0
- package/hooks/blocking/content_search_zoekt_indexed_paths.py +24 -0
- package/hooks/blocking/content_search_zoekt_indexed_roots_config.py +131 -0
- package/hooks/blocking/content_search_zoekt_redirect_guidance.py +19 -0
- package/hooks/blocking/destructive-command-blocker.py +53 -4
- package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +54 -0
- package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +51 -0
- package/hooks/blocking/test_content_search_zoekt_indexed_roots_config.py +102 -0
- package/hooks/blocking/test_destructive_command_blocker.py +108 -0
- package/package.json +4 -1
- package/skills/rule-audit/SKILL.md +2 -2
- package/hooks/HOOK_SPECS_PROMPT_WORKFLOW.md +0 -64
- package/hooks/blocking/prompt_workflow_clipboard.py +0 -63
- package/hooks/blocking/prompt_workflow_gate_config.py +0 -113
- package/hooks/blocking/prompt_workflow_gate_core.py +0 -289
- package/hooks/blocking/prompt_workflow_validate.py +0 -218
- package/hooks/blocking/test_prompt_workflow_clipboard.py +0 -54
- package/hooks/blocking/test_prompt_workflow_gate_core.py +0 -195
- package/hooks/blocking/test_prompt_workflow_validate.py +0 -339
- package/rules/prompt-workflow-context-controls.md +0 -48
- package/skills/agent-prompt/SKILL.md +0 -199
- package/skills/prompt-generator/ARCHITECTURE.md +0 -18
- package/skills/prompt-generator/REFERENCE.md +0 -254
- package/skills/prompt-generator/REFINEMENT_PIPELINE_RUNBOOK.md +0 -177
- package/skills/prompt-generator/SKILL.md +0 -354
- package/skills/prompt-generator/TARGET_OUTPUT.md +0 -133
- package/skills/prompt-generator/evals/prompt-generator.json +0 -207
- package/skills/prompt-generator/templates/skill-from-ground-up.md +0 -104
- 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(
|
|
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(
|
|
224
|
+
? new Set(activeGroups.flatMap(group => group.skills || []))
|
|
169
225
|
: null;
|
|
170
226
|
const allowedDirectories = selectedGroups
|
|
171
|
-
? new Set(
|
|
227
|
+
? new Set(activeGroups.flatMap(group => group.includeDirectories || []))
|
|
172
228
|
: null;
|
|
173
229
|
const shouldInstallAllHooks = selectedGroups
|
|
174
|
-
?
|
|
230
|
+
? activeGroups.some(group => group.includeAllHooks)
|
|
175
231
|
: true;
|
|
176
232
|
const allowedHookFiles = selectedGroups
|
|
177
|
-
? new Set(
|
|
233
|
+
? new Set(activeGroups.flatMap(group => group.includeHookFiles || []))
|
|
178
234
|
: null;
|
|
179
235
|
const allowedRules = selectedGroups
|
|
180
|
-
? new Set(
|
|
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
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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
|
-
|
|
232
|
-
|
|
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) {
|
|
331
|
+
if (existed) { totalHooksUpdated++; } else { totalHooksCreated++; }
|
|
251
332
|
}
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
335
|
-
|
|
336
|
-
journal
|
|
337
|
-
research
|
|
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
|
|
341
|
-
npx ${PACKAGE_NAME} --only
|
|
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):
|