@soleri/cli 9.0.1 → 9.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.
Files changed (49) hide show
  1. package/dist/commands/agent.js +116 -3
  2. package/dist/commands/agent.js.map +1 -1
  3. package/dist/commands/create.js +10 -3
  4. package/dist/commands/create.js.map +1 -1
  5. package/dist/commands/hooks.js +43 -49
  6. package/dist/commands/hooks.js.map +1 -1
  7. package/dist/commands/install.d.ts +1 -0
  8. package/dist/commands/install.js +61 -12
  9. package/dist/commands/install.js.map +1 -1
  10. package/dist/commands/pack.js +0 -1
  11. package/dist/commands/pack.js.map +1 -1
  12. package/dist/commands/staging.d.ts +2 -0
  13. package/dist/commands/staging.js +175 -0
  14. package/dist/commands/staging.js.map +1 -0
  15. package/dist/hook-packs/full/manifest.json +2 -2
  16. package/dist/hook-packs/installer.d.ts +4 -11
  17. package/dist/hook-packs/installer.js +192 -23
  18. package/dist/hook-packs/installer.js.map +1 -1
  19. package/dist/hook-packs/installer.ts +173 -60
  20. package/dist/hook-packs/registry.d.ts +16 -13
  21. package/dist/hook-packs/registry.js +13 -28
  22. package/dist/hook-packs/registry.js.map +1 -1
  23. package/dist/hook-packs/registry.ts +33 -46
  24. package/dist/hook-packs/yolo-safety/manifest.json +23 -0
  25. package/dist/hook-packs/yolo-safety/scripts/anti-deletion.sh +214 -0
  26. package/dist/hooks/templates.js +1 -1
  27. package/dist/hooks/templates.js.map +1 -1
  28. package/dist/main.js +2 -0
  29. package/dist/main.js.map +1 -1
  30. package/package.json +1 -1
  31. package/src/__tests__/create.test.ts +6 -2
  32. package/src/__tests__/hook-packs.test.ts +66 -44
  33. package/src/__tests__/wizard-e2e.mjs +17 -11
  34. package/src/commands/agent.ts +146 -3
  35. package/src/commands/create.ts +8 -2
  36. package/src/commands/hooks.ts +88 -187
  37. package/src/commands/install.ts +62 -22
  38. package/src/commands/pack.ts +0 -1
  39. package/src/commands/staging.ts +208 -0
  40. package/src/hook-packs/full/manifest.json +2 -2
  41. package/src/hook-packs/installer.ts +173 -60
  42. package/src/hook-packs/registry.ts +33 -46
  43. package/src/hook-packs/yolo-safety/manifest.json +23 -0
  44. package/src/hook-packs/yolo-safety/scripts/anti-deletion.sh +214 -0
  45. package/src/hooks/templates.ts +1 -1
  46. package/src/main.ts +2 -0
  47. package/dist/commands/cognee.d.ts +0 -10
  48. package/dist/commands/cognee.js +0 -364
  49. package/dist/commands/cognee.js.map +0 -1
@@ -1,128 +1,241 @@
1
1
  /**
2
- * Hook pack installer — copies hookify files to ~/.claude/ (global) or project .claude/ (local).
2
+ * Hook pack installer — copies hookify files and scripts to ~/.claude/ (global) or project .claude/ (local).
3
+ * Also manages lifecycle hooks in ~/.claude/settings.json.
3
4
  */
4
- import { existsSync, copyFileSync, unlinkSync, mkdirSync } from 'node:fs';
5
+ import {
6
+ existsSync,
7
+ copyFileSync,
8
+ unlinkSync,
9
+ mkdirSync,
10
+ readFileSync,
11
+ writeFileSync,
12
+ chmodSync,
13
+ } from 'node:fs';
5
14
  import { join } from 'node:path';
6
15
  import { homedir } from 'node:os';
7
16
  import { getPack } from './registry.js';
17
+ import type { HookPackLifecycleHook } from './registry.js';
18
+
19
+ const PACK_MARKER = '_soleriPack';
8
20
 
9
- /** Resolve the target .claude/ directory. */
10
21
  function resolveClaudeDir(projectDir?: string): string {
11
22
  if (projectDir) return join(projectDir, '.claude');
12
23
  return join(homedir(), '.claude');
13
24
  }
14
25
 
15
- /**
16
- * Resolve all hookify file paths for a pack, handling composed packs.
17
- * Returns a map of hook name → source file path.
18
- */
19
26
  function resolveHookFiles(packName: string): Map<string, string> {
20
27
  const pack = getPack(packName);
21
28
  if (!pack) return new Map();
22
-
23
29
  const files = new Map<string, string>();
24
-
25
30
  if (pack.manifest.composedFrom) {
26
- // Composed pack: gather files from constituent packs
27
31
  for (const subPackName of pack.manifest.composedFrom) {
28
32
  const subFiles = resolveHookFiles(subPackName);
29
- for (const [hook, path] of subFiles) {
30
- files.set(hook, path);
31
- }
33
+ for (const [hook, path] of subFiles) { files.set(hook, path); }
32
34
  }
33
35
  } else {
34
- // Direct pack: look for hookify files in the pack directory
35
36
  for (const hook of pack.manifest.hooks) {
36
37
  const filePath = join(pack.dir, `hookify.${hook}.local.md`);
37
- if (existsSync(filePath)) {
38
- files.set(hook, filePath);
38
+ if (existsSync(filePath)) { files.set(hook, filePath); }
39
+ }
40
+ }
41
+ return files;
42
+ }
43
+
44
+ function resolveScripts(packName: string): Map<string, { sourcePath: string; targetDir: string; file: string }> {
45
+ const pack = getPack(packName);
46
+ if (!pack) return new Map();
47
+ const scripts = new Map<string, { sourcePath: string; targetDir: string; file: string }>();
48
+ if (pack.manifest.composedFrom) {
49
+ for (const subPackName of pack.manifest.composedFrom) {
50
+ const subScripts = resolveScripts(subPackName);
51
+ for (const [name, info] of subScripts) { scripts.set(name, info); }
52
+ }
53
+ } else if (pack.manifest.scripts) {
54
+ for (const script of pack.manifest.scripts) {
55
+ const sourcePath = join(pack.dir, 'scripts', script.file);
56
+ if (existsSync(sourcePath)) {
57
+ scripts.set(script.name, { sourcePath, targetDir: script.targetDir, file: script.file });
39
58
  }
40
59
  }
41
60
  }
61
+ return scripts;
62
+ }
42
63
 
43
- return files;
64
+ function resolveLifecycleHooks(packName: string): { packName: string; hook: HookPackLifecycleHook }[] {
65
+ const pack = getPack(packName);
66
+ if (!pack) return [];
67
+ const hooks: { packName: string; hook: HookPackLifecycleHook }[] = [];
68
+ if (pack.manifest.composedFrom) {
69
+ for (const subPackName of pack.manifest.composedFrom) { hooks.push(...resolveLifecycleHooks(subPackName)); }
70
+ } else if (pack.manifest.lifecycleHooks) {
71
+ for (const hook of pack.manifest.lifecycleHooks) { hooks.push({ packName: pack.manifest.name, hook }); }
72
+ }
73
+ return hooks;
74
+ }
75
+
76
+ interface SettingsHookEntry { type: 'command'; command: string; timeout?: number; [key: string]: unknown; }
77
+
78
+ function readClaudeSettings(claudeDir: string): Record<string, unknown> {
79
+ const settingsPath = join(claudeDir, 'settings.json');
80
+ if (!existsSync(settingsPath)) return {};
81
+ try { return JSON.parse(readFileSync(settingsPath, 'utf-8')); } catch { return {}; }
82
+ }
83
+
84
+ function writeClaudeSettings(claudeDir: string, settings: Record<string, unknown>): void {
85
+ const settingsPath = join(claudeDir, 'settings.json');
86
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
87
+ }
88
+
89
+ function addLifecycleHooks(claudeDir: string, lifecycleHooks: { packName: string; hook: HookPackLifecycleHook }[]): string[] {
90
+ if (lifecycleHooks.length === 0) return [];
91
+ const settings = readClaudeSettings(claudeDir);
92
+ const hooks = (settings['hooks'] ?? {}) as Record<string, unknown>;
93
+ const added: string[] = [];
94
+ for (const { packName: sourcePack, hook } of lifecycleHooks) {
95
+ const eventKey = hook.event;
96
+ const eventHooks = (hooks[eventKey] ?? []) as SettingsHookEntry[];
97
+ const alreadyExists = eventHooks.some((h) => h.command === hook.command && h[PACK_MARKER] === sourcePack);
98
+ if (!alreadyExists) {
99
+ const entry: SettingsHookEntry = { type: hook.type, command: hook.command, [PACK_MARKER]: sourcePack };
100
+ if (hook.timeout) { entry.timeout = hook.timeout; }
101
+ eventHooks.push(entry);
102
+ hooks[eventKey] = eventHooks;
103
+ added.push(`${eventKey}:${hook.matcher}`);
104
+ }
105
+ }
106
+ settings['hooks'] = hooks;
107
+ writeClaudeSettings(claudeDir, settings);
108
+ return added;
109
+ }
110
+
111
+ function removeLifecycleHooks(claudeDir: string, packName: string): string[] {
112
+ const settings = readClaudeSettings(claudeDir);
113
+ const hooks = (settings['hooks'] ?? {}) as Record<string, SettingsHookEntry[]>;
114
+ const removed: string[] = [];
115
+ for (const [eventKey, eventHooks] of Object.entries(hooks)) {
116
+ if (!Array.isArray(eventHooks)) continue;
117
+ const before = eventHooks.length;
118
+ const filtered = eventHooks.filter((h) => h[PACK_MARKER] !== packName);
119
+ if (filtered.length < before) {
120
+ removed.push(eventKey);
121
+ if (filtered.length === 0) { delete hooks[eventKey]; } else { hooks[eventKey] = filtered; }
122
+ }
123
+ }
124
+ if (removed.length > 0) { settings['hooks'] = hooks; writeClaudeSettings(claudeDir, settings); }
125
+ return removed;
44
126
  }
45
127
 
46
- /**
47
- * Install a hook pack to ~/.claude/ (default) or project .claude/ (--project).
48
- * Skips files that already exist (idempotent).
49
- */
50
128
  export function installPack(
51
129
  packName: string,
52
130
  options?: { projectDir?: string },
53
- ): { installed: string[]; skipped: string[] } {
131
+ ): { installed: string[]; skipped: string[]; scripts: string[]; lifecycleHooks: string[] } {
54
132
  const pack = getPack(packName);
55
- if (!pack) {
56
- throw new Error(`Unknown hook pack: "${packName}"`);
57
- }
58
-
133
+ if (!pack) { throw new Error(`Unknown hook pack: "${packName}"`); }
59
134
  const claudeDir = resolveClaudeDir(options?.projectDir);
60
135
  mkdirSync(claudeDir, { recursive: true });
61
-
62
136
  const hookFiles = resolveHookFiles(packName);
63
137
  const installed: string[] = [];
64
138
  const skipped: string[] = [];
65
-
66
139
  for (const [hook, sourcePath] of hookFiles) {
67
140
  const destPath = join(claudeDir, `hookify.${hook}.local.md`);
68
- if (existsSync(destPath)) {
69
- skipped.push(hook);
70
- } else {
71
- copyFileSync(sourcePath, destPath);
72
- installed.push(hook);
73
- }
141
+ if (existsSync(destPath)) { skipped.push(hook); } else { copyFileSync(sourcePath, destPath); installed.push(hook); }
74
142
  }
75
-
76
- return { installed, skipped };
143
+ const scriptFiles = resolveScripts(packName);
144
+ const installedScripts: string[] = [];
145
+ for (const [, { sourcePath, targetDir, file }] of scriptFiles) {
146
+ const destDir = join(claudeDir, targetDir);
147
+ mkdirSync(destDir, { recursive: true });
148
+ const destPath = join(destDir, file);
149
+ copyFileSync(sourcePath, destPath);
150
+ chmodSync(destPath, 0o755);
151
+ installedScripts.push(`${targetDir}/${file}`);
152
+ }
153
+ const lcHooks = resolveLifecycleHooks(packName);
154
+ const addedHooks = addLifecycleHooks(claudeDir, lcHooks);
155
+ return { installed, skipped, scripts: installedScripts, lifecycleHooks: addedHooks };
77
156
  }
78
157
 
79
- /**
80
- * Remove a hook pack's files from target directory.
81
- */
82
158
  export function removePack(
83
159
  packName: string,
84
160
  options?: { projectDir?: string },
85
- ): { removed: string[] } {
161
+ ): { removed: string[]; scripts: string[]; lifecycleHooks: string[] } {
86
162
  const pack = getPack(packName);
87
- if (!pack) {
88
- throw new Error(`Unknown hook pack: "${packName}"`);
89
- }
90
-
163
+ if (!pack) { throw new Error(`Unknown hook pack: "${packName}"`); }
91
164
  const claudeDir = resolveClaudeDir(options?.projectDir);
92
165
  const removed: string[] = [];
93
-
94
166
  for (const hook of pack.manifest.hooks) {
95
167
  const filePath = join(claudeDir, `hookify.${hook}.local.md`);
96
- if (existsSync(filePath)) {
97
- unlinkSync(filePath);
98
- removed.push(hook);
168
+ if (existsSync(filePath)) { unlinkSync(filePath); removed.push(hook); }
169
+ }
170
+ const removedScripts: string[] = [];
171
+ if (pack.manifest.scripts) {
172
+ for (const script of pack.manifest.scripts) {
173
+ const filePath = join(claudeDir, script.targetDir, script.file);
174
+ if (existsSync(filePath)) { unlinkSync(filePath); removedScripts.push(`${script.targetDir}/${script.file}`); }
99
175
  }
100
176
  }
101
-
102
- return { removed };
177
+ if (pack.manifest.composedFrom) {
178
+ for (const subPackName of pack.manifest.composedFrom) {
179
+ const subPack = getPack(subPackName);
180
+ if (subPack?.manifest.scripts) {
181
+ for (const script of subPack.manifest.scripts) {
182
+ const filePath = join(claudeDir, script.targetDir, script.file);
183
+ if (existsSync(filePath)) { unlinkSync(filePath); removedScripts.push(`${script.targetDir}/${script.file}`); }
184
+ }
185
+ }
186
+ }
187
+ }
188
+ const removedHooks = removeLifecycleHooks(claudeDir, packName);
189
+ if (pack.manifest.composedFrom) {
190
+ for (const subPackName of pack.manifest.composedFrom) { removedHooks.push(...removeLifecycleHooks(claudeDir, subPackName)); }
191
+ }
192
+ return { removed, scripts: removedScripts, lifecycleHooks: removedHooks };
103
193
  }
104
194
 
105
- /**
106
- * Check if a pack is installed.
107
- * Returns true (all hooks present), false (none present), or 'partial'.
108
- */
109
195
  export function isPackInstalled(
110
196
  packName: string,
111
197
  options?: { projectDir?: string },
112
198
  ): boolean | 'partial' {
113
199
  const pack = getPack(packName);
114
200
  if (!pack) return false;
115
-
116
201
  const claudeDir = resolveClaudeDir(options?.projectDir);
202
+ let total = 0;
117
203
  let present = 0;
118
-
119
204
  for (const hook of pack.manifest.hooks) {
120
- if (existsSync(join(claudeDir, `hookify.${hook}.local.md`))) {
121
- present++;
205
+ total++;
206
+ if (existsSync(join(claudeDir, `hookify.${hook}.local.md`))) { present++; }
207
+ }
208
+ if (pack.manifest.scripts) {
209
+ for (const script of pack.manifest.scripts) {
210
+ total++;
211
+ if (existsSync(join(claudeDir, script.targetDir, script.file))) { present++; }
122
212
  }
123
213
  }
124
-
214
+ if (pack.manifest.composedFrom) {
215
+ for (const subPackName of pack.manifest.composedFrom) {
216
+ const subPack = getPack(subPackName);
217
+ if (subPack?.manifest.scripts) {
218
+ for (const script of subPack.manifest.scripts) {
219
+ total++;
220
+ if (existsSync(join(claudeDir, script.targetDir, script.file))) { present++; }
221
+ }
222
+ }
223
+ }
224
+ }
225
+ if (total === 0) {
226
+ const lcHooks = resolveLifecycleHooks(packName);
227
+ if (lcHooks.length > 0) {
228
+ const settings = readClaudeSettings(claudeDir);
229
+ const hooksObj = (settings['hooks'] ?? {}) as Record<string, SettingsHookEntry[]>;
230
+ for (const { packName: sourcePack, hook } of lcHooks) {
231
+ total++;
232
+ const eventHooks = hooksObj[hook.event];
233
+ if (Array.isArray(eventHooks) && eventHooks.some((h) => h.command === hook.command && h[PACK_MARKER] === sourcePack)) { present++; }
234
+ }
235
+ }
236
+ }
237
+ if (total === 0) return false;
125
238
  if (present === 0) return false;
126
- if (present === pack.manifest.hooks.length) return true;
239
+ if (present === total) return true;
127
240
  return 'partial';
128
241
  }
@@ -6,73 +6,65 @@ import { join, dirname } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { homedir } from 'node:os';
8
8
 
9
- interface HookPackManifest {
9
+ export interface HookPackScript {
10
+ name: string;
11
+ file: string;
12
+ targetDir: string;
13
+ }
14
+
15
+ export interface HookPackLifecycleHook {
16
+ event: string;
17
+ matcher: string;
18
+ type: 'command';
19
+ command: string;
20
+ timeout?: number;
21
+ statusMessage?: string;
22
+ }
23
+
24
+ export interface HookPackManifest {
10
25
  name: string;
11
26
  description: string;
12
27
  hooks: string[];
13
28
  composedFrom?: string[];
14
29
  version?: string;
15
- /** Whether this pack is built-in or user-defined */
30
+ scripts?: HookPackScript[];
31
+ lifecycleHooks?: HookPackLifecycleHook[];
16
32
  source?: 'built-in' | 'local';
17
33
  }
18
34
 
19
35
  const __filename = fileURLToPath(import.meta.url);
20
36
  const __dirname = dirname(__filename);
21
37
 
22
- /** Root directory containing all built-in hook packs. */
23
- function getBuiltinRoot(): string {
24
- return __dirname;
25
- }
26
-
27
- /** Local custom packs directory. */
28
- function getLocalRoot(): string {
29
- return join(process.cwd(), '.soleri', 'hook-packs');
30
- }
38
+ function getBuiltinRoot(): string { return __dirname; }
39
+ function getLocalRoot(): string { return join(process.cwd(), '.soleri', 'hook-packs'); }
31
40
 
32
- /** Scan a directory for pack manifests. */
33
41
  function scanPacksDir(root: string, source: 'built-in' | 'local'): HookPackManifest[] {
34
42
  if (!existsSync(root)) return [];
35
43
  const entries = readdirSync(root, { withFileTypes: true });
36
44
  const packs: HookPackManifest[] = [];
37
-
38
45
  for (const entry of entries) {
39
46
  if (!entry.isDirectory()) continue;
40
47
  const manifestPath = join(root, entry.name, 'manifest.json');
41
48
  if (!existsSync(manifestPath)) continue;
42
-
43
49
  try {
44
50
  const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as HookPackManifest;
45
51
  manifest.source = source;
46
52
  packs.push(manifest);
47
- } catch {
48
- // Skip malformed manifests
49
- }
53
+ } catch { /* Skip malformed manifests */ }
50
54
  }
51
-
52
55
  return packs;
53
56
  }
54
57
 
55
- /**
56
- * List all available hook packs (built-in + local custom).
57
- * Local packs in .soleri/hook-packs/ override built-in packs with the same name.
58
- */
59
58
  export function listPacks(): HookPackManifest[] {
60
59
  const builtIn = scanPacksDir(getBuiltinRoot(), 'built-in');
61
60
  const local = scanPacksDir(getLocalRoot(), 'local');
62
-
63
- // Local packs override built-in packs with same name
64
61
  const byName = new Map<string, HookPackManifest>();
65
62
  for (const pack of builtIn) byName.set(pack.name, pack);
66
63
  for (const pack of local) byName.set(pack.name, pack);
67
-
68
64
  return Array.from(byName.values());
69
65
  }
70
66
 
71
- /**
72
- * Get a specific pack by name. Local packs take precedence.
73
- */
74
67
  export function getPack(name: string): { manifest: HookPackManifest; dir: string } | null {
75
- // Check local first
76
68
  const localDir = join(getLocalRoot(), name);
77
69
  const localManifest = join(localDir, 'manifest.json');
78
70
  if (existsSync(localManifest)) {
@@ -80,41 +72,36 @@ export function getPack(name: string): { manifest: HookPackManifest; dir: string
80
72
  const manifest = JSON.parse(readFileSync(localManifest, 'utf-8')) as HookPackManifest;
81
73
  manifest.source = 'local';
82
74
  return { manifest, dir: localDir };
83
- } catch {
84
- // Fall through to built-in
85
- }
75
+ } catch { /* Fall through */ }
86
76
  }
87
-
88
- // Then built-in
89
77
  const builtinDir = join(getBuiltinRoot(), name);
90
78
  const builtinManifest = join(builtinDir, 'manifest.json');
91
79
  if (!existsSync(builtinManifest)) return null;
92
-
93
80
  try {
94
81
  const manifest = JSON.parse(readFileSync(builtinManifest, 'utf-8')) as HookPackManifest;
95
82
  manifest.source = 'built-in';
96
83
  return { manifest, dir: builtinDir };
97
- } catch {
98
- return null;
99
- }
84
+ } catch { return null; }
100
85
  }
101
86
 
102
- /**
103
- * Get names of packs that are fully installed in ~/.claude/.
104
- */
105
87
  export function getInstalledPacks(): string[] {
106
88
  const claudeDir = join(homedir(), '.claude');
107
89
  const packs = listPacks();
108
90
  const installed: string[] = [];
109
-
110
91
  for (const pack of packs) {
92
+ if (pack.hooks.length === 0) {
93
+ if (pack.scripts && pack.scripts.length > 0) {
94
+ const allScripts = pack.scripts.every((script) =>
95
+ existsSync(join(claudeDir, script.targetDir, script.file)),
96
+ );
97
+ if (allScripts) { installed.push(pack.name); }
98
+ }
99
+ continue;
100
+ }
111
101
  const allPresent = pack.hooks.every((hook) =>
112
102
  existsSync(join(claudeDir, `hookify.${hook}.local.md`)),
113
103
  );
114
- if (allPresent) {
115
- installed.push(pack.name);
116
- }
104
+ if (allPresent) { installed.push(pack.name); }
117
105
  }
118
-
119
106
  return installed;
120
107
  }
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "yolo-safety",
3
+ "version": "1.0.0",
4
+ "description": "Anti-deletion guardrail for YOLO mode — intercepts destructive commands, stages files for review",
5
+ "hooks": [],
6
+ "scripts": [
7
+ {
8
+ "name": "anti-deletion",
9
+ "file": "anti-deletion.sh",
10
+ "targetDir": "hooks"
11
+ }
12
+ ],
13
+ "lifecycleHooks": [
14
+ {
15
+ "event": "PreToolUse",
16
+ "matcher": "Bash",
17
+ "type": "command",
18
+ "command": "bash ~/.claude/hooks/anti-deletion.sh",
19
+ "timeout": 10,
20
+ "statusMessage": "Checking for destructive commands..."
21
+ }
22
+ ]
23
+ }
@@ -0,0 +1,214 @@
1
+ #!/usr/bin/env bash
2
+ # Anti-Deletion Staging Hook for Claude Code (Soleri Hook Pack: yolo-safety)
3
+ # PreToolUse -> Bash: intercepts rm, rmdir, mv (of project dirs), git clean, reset --hard
4
+ # Copies target files to ~/.soleri/staging/<timestamp>/ then blocks the command.
5
+ #
6
+ # Catastrophic commands (rm -rf /, rm -rf ~) should stay in deny rules —
7
+ # this hook handles targeted deletes only.
8
+ #
9
+ # Dependencies: jq (required), perl (optional, for heredoc stripping)
10
+
11
+ set -euo pipefail
12
+
13
+ STAGING_ROOT="$HOME/.soleri/staging"
14
+ PROJECTS_DIR="$HOME/projects"
15
+ INPUT=$(cat)
16
+
17
+ # Extract the command from stdin JSON
18
+ CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
19
+
20
+ # No command found — let it through
21
+ if [ -z "$CMD" ]; then
22
+ exit 0
23
+ fi
24
+
25
+ # --- Strip heredocs and quoted strings to avoid false positives ---
26
+ # Commands like: gh issue comment --body "$(cat <<'EOF' ... rmdir ... EOF)"
27
+ # contain destructive keywords in text, not as actual commands.
28
+
29
+ # Remove heredoc blocks: <<'EOF'...EOF and <<EOF...EOF (multiline)
30
+ STRIPPED=$(echo "$CMD" | perl -0777 -pe "s/<<'?\\w+'?.*?^\\w+$//gms" 2>/dev/null || echo "$CMD")
31
+ # Remove double-quoted strings (greedy but good enough for this check)
32
+ STRIPPED=$(echo "$STRIPPED" | sed -E 's/"[^"]*"//g' 2>/dev/null || echo "$STRIPPED")
33
+ # Remove single-quoted strings
34
+ STRIPPED=$(echo "$STRIPPED" | sed -E "s/'[^']*'//g" 2>/dev/null || echo "$STRIPPED")
35
+
36
+ # --- Detect destructive commands (on stripped command only) ---
37
+
38
+ IS_RM=false
39
+ IS_RMDIR=false
40
+ IS_MV_PROJECT=false
41
+ IS_GIT_CLEAN=false
42
+ IS_RESET_HARD=false
43
+ IS_GIT_CHECKOUT_DOT=false
44
+ IS_GIT_RESTORE_DOT=false
45
+
46
+ # Check for rm commands (but not git rm which is safe — it stages, doesn't destroy)
47
+ if echo "$STRIPPED" | grep -qE '(^|\s|;|&&|\|\|)rm\s'; then
48
+ if ! echo "$STRIPPED" | grep -qE '(^|\s)git\s+rm\s'; then
49
+ IS_RM=true
50
+ fi
51
+ fi
52
+
53
+ # Check for rmdir commands
54
+ if echo "$STRIPPED" | grep -qE '(^|\s|;|&&|\|\|)rmdir\s'; then
55
+ IS_RMDIR=true
56
+ fi
57
+
58
+ # Check for mv commands that move project directories or git repos
59
+ if echo "$STRIPPED" | grep -qE '(^|\s|;|&&|\|\|)mv\s'; then
60
+ MV_SOURCES=$(echo "$STRIPPED" | sed -E 's/^.*\bmv\s+//' | sed -E 's/-(f|i|n|v)\s+//g')
61
+ if echo "$MV_SOURCES" | grep -qE "(~/projects|$HOME/projects|\\\$HOME/projects|\\.git)"; then
62
+ IS_MV_PROJECT=true
63
+ fi
64
+ fi
65
+
66
+ # Check for git clean
67
+ if echo "$STRIPPED" | grep -qE '(^|\s|;|&&|\|\|)git\s+clean\b'; then
68
+ IS_GIT_CLEAN=true
69
+ fi
70
+
71
+ # Check for git reset --hard
72
+ if echo "$STRIPPED" | grep -qE '(^|\s|;|&&|\|\|)git\s+reset\s+--hard'; then
73
+ IS_RESET_HARD=true
74
+ fi
75
+
76
+ # Check for git checkout -- . (restores all files, discards changes)
77
+ if echo "$STRIPPED" | grep -qE '(^|\s|;|&&|\|\|)git\s+checkout\s+--\s+\.'; then
78
+ IS_GIT_CHECKOUT_DOT=true
79
+ fi
80
+
81
+ # Check for git restore . (restores all files, discards changes)
82
+ if echo "$STRIPPED" | grep -qE '(^|\s|;|&&|\|\|)git\s+restore\s+\.'; then
83
+ IS_GIT_RESTORE_DOT=true
84
+ fi
85
+
86
+ # Not a destructive command — let it through
87
+ if [ "$IS_RM" = false ] && [ "$IS_RMDIR" = false ] && [ "$IS_MV_PROJECT" = false ] && \
88
+ [ "$IS_GIT_CLEAN" = false ] && [ "$IS_RESET_HARD" = false ] && \
89
+ [ "$IS_GIT_CHECKOUT_DOT" = false ] && [ "$IS_GIT_RESTORE_DOT" = false ]; then
90
+ exit 0
91
+ fi
92
+
93
+ # --- Handle git clean (block outright) ---
94
+
95
+ if [ "$IS_GIT_CLEAN" = true ]; then
96
+ jq -n '{
97
+ continue: false,
98
+ stopReason: "BLOCKED: git clean would remove untracked files. Use git stash --include-untracked to save them first, or ask the user to run git clean manually."
99
+ }'
100
+ exit 0
101
+ fi
102
+
103
+ # --- Handle git reset --hard (block outright) ---
104
+
105
+ if [ "$IS_RESET_HARD" = true ]; then
106
+ jq -n '{
107
+ continue: false,
108
+ stopReason: "BLOCKED: git reset --hard would discard uncommitted changes. Use git stash to save them first, or ask the user to run this manually."
109
+ }'
110
+ exit 0
111
+ fi
112
+
113
+ # --- Handle git checkout -- . (block outright) ---
114
+
115
+ if [ "$IS_GIT_CHECKOUT_DOT" = true ]; then
116
+ jq -n '{
117
+ continue: false,
118
+ stopReason: "BLOCKED: git checkout -- . would discard all uncommitted changes. Use git stash to save them first, or ask the user to run this manually."
119
+ }'
120
+ exit 0
121
+ fi
122
+
123
+ # --- Handle git restore . (block outright) ---
124
+
125
+ if [ "$IS_GIT_RESTORE_DOT" = true ]; then
126
+ jq -n '{
127
+ continue: false,
128
+ stopReason: "BLOCKED: git restore . would discard all uncommitted changes. Use git stash to save them first, or ask the user to run this manually."
129
+ }'
130
+ exit 0
131
+ fi
132
+
133
+ # --- Handle mv of project directories (block outright) ---
134
+
135
+ if [ "$IS_MV_PROJECT" = true ]; then
136
+ jq -n '{
137
+ continue: false,
138
+ stopReason: "BLOCKED: mv of a project directory or git repo detected. Moving project directories can cause data loss if the operation fails midway. Ask the user to run this manually, or use cp + verify + rm instead."
139
+ }'
140
+ exit 0
141
+ fi
142
+
143
+ # --- Handle rmdir (block outright) ---
144
+
145
+ if [ "$IS_RMDIR" = true ]; then
146
+ jq -n '{
147
+ continue: false,
148
+ stopReason: "BLOCKED: rmdir detected. Removing directories can break project structure. Ask the user to confirm this operation manually."
149
+ }'
150
+ exit 0
151
+ fi
152
+
153
+ # --- Handle rm commands — copy to staging, then block ---
154
+
155
+ # Create timestamped staging directory
156
+ TIMESTAMP=$(date +%Y-%m-%d_%H%M%S)
157
+ STAGE_DIR="$STAGING_ROOT/$TIMESTAMP"
158
+
159
+ # Extract file paths from the rm command
160
+ # Strip rm and its flags, keeping only the file arguments
161
+ FILES=$(echo "$CMD" | sed -E 's/^.*\brm\s+//' | sed -E 's/-(r|f|rf|fr|v|i|rv|fv|rfv|frv)\s+//g' | tr ' ' '\n' | grep -v '^-' | grep -v '^$')
162
+
163
+ if [ -z "$FILES" ]; then
164
+ jq -n '{
165
+ continue: false,
166
+ stopReason: "BLOCKED: rm command detected but could not parse file targets. Please specify files explicitly."
167
+ }'
168
+ exit 0
169
+ fi
170
+
171
+ STAGED=()
172
+ MISSING=()
173
+
174
+ mkdir -p "$STAGE_DIR"
175
+
176
+ while IFS= read -r filepath; do
177
+ # Expand path (handle ~, relative paths)
178
+ expanded=$(eval echo "$filepath" 2>/dev/null || echo "$filepath")
179
+
180
+ if [ -e "$expanded" ]; then
181
+ # Preserve directory structure in staging
182
+ target_dir="$STAGE_DIR/$(dirname "$expanded")"
183
+ mkdir -p "$target_dir"
184
+ # COPY instead of MOVE — originals stay intact, staging is a backup
185
+ if [ -d "$expanded" ]; then
186
+ cp -R "$expanded" "$target_dir/" 2>/dev/null && STAGED+=("$expanded") || MISSING+=("$expanded")
187
+ else
188
+ cp "$expanded" "$target_dir/" 2>/dev/null && STAGED+=("$expanded") || MISSING+=("$expanded")
189
+ fi
190
+ else
191
+ MISSING+=("$expanded")
192
+ fi
193
+ done <<< "$FILES"
194
+
195
+ # Build response
196
+ STAGED_COUNT=${#STAGED[@]}
197
+ MISSING_COUNT=${#MISSING[@]}
198
+
199
+ if [ "$STAGED_COUNT" -eq 0 ] && [ "$MISSING_COUNT" -gt 0 ]; then
200
+ # All files were missing — let the rm fail naturally
201
+ rmdir "$STAGE_DIR" 2>/dev/null || true
202
+ exit 0
203
+ fi
204
+
205
+ STAGED_LIST=$(printf '%s, ' "${STAGED[@]}" | sed 's/, $//')
206
+
207
+ jq -n \
208
+ --arg staged "$STAGED_LIST" \
209
+ --arg dir "$STAGE_DIR" \
210
+ --argjson count "$STAGED_COUNT" \
211
+ '{
212
+ continue: false,
213
+ stopReason: ("BLOCKED & BACKED UP: " + ($count | tostring) + " item(s) copied to " + $dir + " — files: " + $staged + ". The originals are untouched. To proceed with deletion, ask the user to run the rm command manually.")
214
+ }'