@soleri/cli 9.3.0 → 9.4.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.
@@ -4,9 +4,12 @@ import { join, relative } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import * as log from '../utils/logger.js';
6
6
 
7
- const STAGING_ROOT = join(homedir(), '.soleri', 'staging');
7
+ export const STAGING_ROOT = join(homedir(), '.soleri', 'staging');
8
8
 
9
- interface StagedEntry {
9
+ /** Default max age for stale staging entries (7 days). */
10
+ const DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
11
+
12
+ export interface StagedEntry {
10
13
  id: string;
11
14
  timestamp: string;
12
15
  path: string;
@@ -17,7 +20,7 @@ interface StagedEntry {
17
20
  /**
18
21
  * Walk a directory tree and collect all items with their relative paths.
19
22
  */
20
- function walkDir(dir: string, base: string): { relPath: string; size: number }[] {
23
+ export function walkDir(dir: string, base: string): { relPath: string; size: number }[] {
21
24
  const results: { relPath: string; size: number }[] = [];
22
25
  if (!existsSync(dir)) return results;
23
26
 
@@ -38,7 +41,7 @@ function walkDir(dir: string, base: string): { relPath: string; size: number }[]
38
41
  /**
39
42
  * List all staged entries.
40
43
  */
41
- function listStaged(): StagedEntry[] {
44
+ export function listStaged(): StagedEntry[] {
42
45
  if (!existsSync(STAGING_ROOT)) return [];
43
46
 
44
47
  const entries: StagedEntry[] = [];
@@ -66,7 +69,7 @@ function listStaged(): StagedEntry[] {
66
69
  /**
67
70
  * Parse a duration string like "7d", "24h", "30m" into milliseconds.
68
71
  */
69
- function parseDuration(duration: string): number | null {
72
+ export function parseDuration(duration: string): number | null {
70
73
  const match = duration.match(/^(\d+)(d|h|m)$/);
71
74
  if (!match) return null;
72
75
 
@@ -85,12 +88,78 @@ function parseDuration(duration: string): number | null {
85
88
  }
86
89
  }
87
90
 
88
- function formatSize(bytes: number): string {
91
+ export function formatSize(bytes: number): string {
89
92
  if (bytes < 1024) return `${bytes} B`;
90
93
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
91
94
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
92
95
  }
93
96
 
97
+ // ─── Reusable Utility Functions ──────────────────────────────────────
98
+
99
+ export interface StaleStagingInfo {
100
+ /** Entries older than maxAge. */
101
+ staleEntries: StagedEntry[];
102
+ /** Total bytes across stale entries. */
103
+ totalBytes: number;
104
+ /** Human-readable total size. */
105
+ totalSize: string;
106
+ /** Number of stale entries. */
107
+ count: number;
108
+ }
109
+
110
+ /**
111
+ * Check for staging entries older than a given age.
112
+ * Pure function — no I/O side effects beyond reading the filesystem.
113
+ *
114
+ * @param maxAgeMs - Maximum age in milliseconds (default: 7 days)
115
+ * @returns Info about stale entries, or null if none found.
116
+ */
117
+ export function getStaleStagingInfo(
118
+ maxAgeMs: number = DEFAULT_MAX_AGE_MS,
119
+ ): StaleStagingInfo | null {
120
+ const entries = listStaged();
121
+ if (entries.length === 0) return null;
122
+
123
+ const cutoff = Date.now() - maxAgeMs;
124
+ const staleEntries = entries.filter((entry) => {
125
+ try {
126
+ const stat = statSync(entry.path);
127
+ return stat.mtimeMs < cutoff;
128
+ } catch {
129
+ return false;
130
+ }
131
+ });
132
+
133
+ if (staleEntries.length === 0) return null;
134
+
135
+ const totalBytes = staleEntries.reduce((sum, e) => sum + e.size, 0);
136
+ return {
137
+ staleEntries,
138
+ totalBytes,
139
+ totalSize: formatSize(totalBytes),
140
+ count: staleEntries.length,
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Purge stale staging entries. Returns the number of entries removed.
146
+ *
147
+ * @param entries - Entries to purge (from getStaleStagingInfo().staleEntries)
148
+ * @returns Number of entries successfully removed.
149
+ */
150
+ export function purgeStagingEntries(entries: StagedEntry[]): number {
151
+ let removed = 0;
152
+ for (const entry of entries) {
153
+ try {
154
+ rmSync(entry.path, { recursive: true, force: true });
155
+ removed++;
156
+ } catch {
157
+ // Skip failures silently — entry may have been removed concurrently
158
+ }
159
+ }
160
+ return removed;
161
+ }
162
+
94
163
  export function registerStaging(program: Command): void {
95
164
  const staging = program.command('staging').description('Manage anti-deletion staging folder');
96
165
 
@@ -161,25 +230,31 @@ export function registerStaging(program: Command): void {
161
230
  });
162
231
 
163
232
  staging
164
- .command('purge')
165
- .option('--older-than <duration>', 'Only purge snapshots older than duration (e.g. 7d, 24h)')
166
- .description('Permanently delete staged files')
167
- .action((opts: { olderThan?: string }) => {
233
+ .command('clean')
234
+ .option(
235
+ '--older-than <duration>',
236
+ 'Only remove snapshots older than duration (default: 7d)',
237
+ '7d',
238
+ )
239
+ .option('--all', 'Remove all snapshots regardless of age')
240
+ .option('--dry-run', 'Show what would be removed without deleting')
241
+ .description('Remove staging backups older than 7 days (or --all)')
242
+ .action((opts: { olderThan: string; all?: boolean; dryRun?: boolean }) => {
168
243
  if (!existsSync(STAGING_ROOT)) {
169
- log.info('No staging directory found. Nothing to purge.');
244
+ log.info('No staging directory found. Nothing to clean.');
170
245
  return;
171
246
  }
172
247
 
173
248
  const entries = listStaged();
174
249
 
175
250
  if (entries.length === 0) {
176
- log.info('No staged files to purge.');
251
+ log.info('No staged files to clean.');
177
252
  return;
178
253
  }
179
254
 
180
- let toPurge = entries;
255
+ let toClean = entries;
181
256
 
182
- if (opts.olderThan) {
257
+ if (!opts.all) {
183
258
  const maxAge = parseDuration(opts.olderThan);
184
259
  if (!maxAge) {
185
260
  log.fail(`Invalid duration: "${opts.olderThan}". Use format like 7d, 24h, 30m`);
@@ -187,22 +262,70 @@ export function registerStaging(program: Command): void {
187
262
  }
188
263
 
189
264
  const cutoff = Date.now() - maxAge;
190
- toPurge = entries.filter((entry) => {
265
+ toClean = entries.filter((entry) => {
191
266
  const stat = statSync(entry.path);
192
267
  return stat.mtimeMs < cutoff;
193
268
  });
194
269
  }
195
270
 
196
- if (toPurge.length === 0) {
197
- log.info('No snapshots match the purge criteria.');
271
+ if (toClean.length === 0) {
272
+ log.info('No snapshots match the clean criteria.');
198
273
  return;
199
274
  }
200
275
 
201
- for (const entry of toPurge) {
276
+ if (opts.dryRun) {
277
+ log.heading('Dry run — would remove:');
278
+ for (const entry of toClean) {
279
+ log.warn(`${entry.id}`, formatSize(entry.size));
280
+ }
281
+ log.info(`Would remove ${toClean.length} staging snapshot(s)`);
282
+ return;
283
+ }
284
+
285
+ for (const entry of toClean) {
202
286
  rmSync(entry.path, { recursive: true, force: true });
203
- log.warn(`Purged ${entry.id}`);
287
+ log.warn(`Removed ${entry.id}`);
288
+ }
289
+
290
+ log.info(`Removed ${toClean.length} staging snapshot(s)`);
291
+ });
292
+
293
+ staging
294
+ .command('cleanup')
295
+ .option('--older-than <duration>', 'Max age for stale entries (default: 7d)', '7d')
296
+ .option('--yes', 'Skip confirmation prompt')
297
+ .description('Check for and remove stale staging backups (default: older than 7 days)')
298
+ .action((opts: { olderThan: string; yes?: boolean }) => {
299
+ const maxAge = parseDuration(opts.olderThan);
300
+ if (!maxAge) {
301
+ log.fail(`Invalid duration: "${opts.olderThan}". Use format like 7d, 24h, 30m`);
302
+ process.exit(1);
303
+ }
304
+
305
+ const info = getStaleStagingInfo(maxAge);
306
+
307
+ if (!info) {
308
+ log.info('No stale staging backups found.');
309
+ return;
310
+ }
311
+
312
+ log.heading('Stale Staging Backups');
313
+ log.info(
314
+ `Found ${info.count} staging backup(s) older than ${opts.olderThan} (${info.totalSize}).`,
315
+ );
316
+
317
+ for (const entry of info.staleEntries) {
318
+ log.dim(` ${entry.id} ${formatSize(entry.size)}`);
319
+ }
320
+
321
+ if (!opts.yes) {
322
+ log.info(
323
+ `Run with --yes to remove, or use: soleri staging purge --older-than ${opts.olderThan}`,
324
+ );
325
+ return;
204
326
  }
205
327
 
206
- log.info(`Purged ${toPurge.length} staging snapshot(s)`);
328
+ const removed = purgeStagingEntries(info.staleEntries);
329
+ log.pass(`Cleaned up ${removed} stale staging backup(s), freed ${info.totalSize}.`);
207
330
  });
208
331
  }
@@ -0,0 +1,103 @@
1
+ import { spawn } from 'node:child_process';
2
+ import type { Command } from 'commander';
3
+ import { isPackInstalled, installPack } from '../hook-packs/installer.js';
4
+ import { getPack } from '../hook-packs/registry.js';
5
+ import * as log from '../utils/logger.js';
6
+
7
+ const YOLO_PACK = 'yolo-safety';
8
+
9
+ const RESET = '\x1b[0m';
10
+ const BOLD = '\x1b[1m';
11
+ const RED = '\x1b[31m';
12
+ const YELLOW = '\x1b[33m';
13
+
14
+ export function registerYolo(program: Command): void {
15
+ program
16
+ .command('yolo')
17
+ .description('Launch Claude Code in YOLO mode with safety guardrails')
18
+ .option('--dry-run', 'Show what would happen without launching Claude')
19
+ .option('--project', 'Install safety hooks to project .claude/ instead of global ~/.claude/')
20
+ .action((opts: { dryRun?: boolean; project?: boolean }) => {
21
+ runYolo(opts);
22
+ });
23
+ }
24
+
25
+ function runYolo(opts: { dryRun?: boolean; project?: boolean }): void {
26
+ // 1. Verify the yolo-safety pack exists in registry
27
+ const pack = getPack(YOLO_PACK);
28
+ if (!pack) {
29
+ log.fail(`Hook pack "${YOLO_PACK}" not found in registry. Is @soleri/cli up to date?`);
30
+ process.exit(1);
31
+ }
32
+
33
+ // 2. Check if already installed, install if not
34
+ const projectDir = opts.project ? process.cwd() : undefined;
35
+ const installed = isPackInstalled(YOLO_PACK, { projectDir });
36
+
37
+ if (installed === true) {
38
+ log.pass(`${YOLO_PACK} hook pack already installed`);
39
+ } else {
40
+ if (installed === 'partial') {
41
+ log.warn(`${YOLO_PACK} hook pack partially installed — reinstalling`);
42
+ }
43
+ const result = installPack(YOLO_PACK, { projectDir });
44
+ const target = opts.project ? '.claude/' : '~/.claude/';
45
+ for (const script of result.scripts) {
46
+ log.pass(`Installed ${script} → ${target}`);
47
+ }
48
+ for (const lc of result.lifecycleHooks) {
49
+ log.pass(`Registered lifecycle hook: ${lc}`);
50
+ }
51
+ const totalInstalled =
52
+ result.installed.length + result.scripts.length + result.lifecycleHooks.length;
53
+ if (totalInstalled > 0) {
54
+ log.pass(`${YOLO_PACK} hook pack installed (${totalInstalled} items)`);
55
+ }
56
+ }
57
+
58
+ // 3. Print safety warning
59
+ console.log();
60
+ console.log(` ${RED}${BOLD}⚡ YOLO MODE${RESET}`);
61
+ console.log();
62
+ console.log(
63
+ ` ${YELLOW}Approval gates skipped — Claude will execute commands without asking.${RESET}`,
64
+ );
65
+ console.log(
66
+ ` ${YELLOW}Safety hooks active — destructive commands (rm, git push --force,${RESET}`,
67
+ );
68
+ console.log(` ${YELLOW}git reset --hard, drop table, docker rm) are intercepted.${RESET}`);
69
+ console.log();
70
+
71
+ if (opts.dryRun) {
72
+ log.info('Dry run — would launch:');
73
+ log.dim(' claude --dangerously-skip-permissions');
74
+ return;
75
+ }
76
+
77
+ // 4. Launch Claude Code with permissions skipped
78
+ log.info('Launching Claude Code in YOLO mode...');
79
+ console.log();
80
+
81
+ const child = spawn('claude', ['--dangerously-skip-permissions'], {
82
+ stdio: 'inherit',
83
+ env: { ...process.env },
84
+ });
85
+
86
+ child.on('error', (err) => {
87
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
88
+ log.fail(
89
+ 'Claude CLI not found. Install it first: https://docs.anthropic.com/en/docs/claude-code',
90
+ );
91
+ } else {
92
+ log.fail(`Failed to launch Claude: ${err.message}`);
93
+ }
94
+ process.exit(1);
95
+ });
96
+
97
+ child.on('exit', (code, signal) => {
98
+ if (signal) {
99
+ process.exit(1);
100
+ }
101
+ process.exit(code ?? 0);
102
+ });
103
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "yolo-safety",
3
3
  "version": "1.0.0",
4
- "description": "Anti-deletion guardrail for YOLO mode — intercepts destructive commands, stages files for review",
4
+ "description": "Safety guardrails for YOLO mode — intercepts destructive commands (rm, git push --force, git reset --hard, git clean, drop table, docker rm), stages files before deletion, blocks everything else",
5
5
  "hooks": [],
6
6
  "scripts": [
7
7
  {
@@ -15,7 +15,7 @@
15
15
  "event": "PreToolUse",
16
16
  "matcher": "Bash",
17
17
  "type": "command",
18
- "command": "bash ~/.claude/hooks/anti-deletion.sh",
18
+ "command": "sh ~/.claude/hooks/anti-deletion.sh",
19
19
  "timeout": 10,
20
20
  "statusMessage": "Checking for destructive commands..."
21
21
  }
@@ -1,21 +1,31 @@
1
- #!/usr/bin/env bash
1
+ #!/bin/sh
2
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.
3
+ # PreToolUse -> Bash: intercepts destructive commands, stages files, blocks execution.
4
+ #
5
+ # Intercepted patterns:
6
+ # - rm / rmdir (files/dirs — stages first, then blocks)
7
+ # - git push --force (blocks outright)
8
+ # - git reset --hard (blocks outright)
9
+ # - git clean (blocks outright)
10
+ # - git checkout -- . (blocks outright)
11
+ # - git restore . (blocks outright)
12
+ # - mv ~/projects/... (blocks outright)
13
+ # - drop table (SQL — blocks outright)
14
+ # - docker rm / rmi (blocks outright)
5
15
  #
6
16
  # Catastrophic commands (rm -rf /, rm -rf ~) should stay in deny rules —
7
17
  # this hook handles targeted deletes only.
8
18
  #
9
- # Dependencies: jq (required), perl (optional, for heredoc stripping)
19
+ # Dependencies: jq (required)
20
+ # POSIX sh compatible — no bash-specific features.
10
21
 
11
- set -euo pipefail
22
+ set -eu
12
23
 
13
24
  STAGING_ROOT="$HOME/.soleri/staging"
14
- PROJECTS_DIR="$HOME/projects"
15
25
  INPUT=$(cat)
16
26
 
17
27
  # Extract the command from stdin JSON
18
- CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
28
+ CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
19
29
 
20
30
  # No command found — let it through
21
31
  if [ -z "$CMD" ]; then
@@ -26,12 +36,17 @@ fi
26
36
  # Commands like: gh issue comment --body "$(cat <<'EOF' ... rmdir ... EOF)"
27
37
  # contain destructive keywords in text, not as actual commands.
28
38
 
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")
39
+ # Remove heredoc blocks (best-effort with sed)
40
+ STRIPPED=$(printf '%s' "$CMD" | sed -e "s/<<'[A-Za-z_]*'.*//g" -e 's/<<[A-Za-z_]*.*//g' 2>/dev/null || printf '%s' "$CMD")
41
+ # Remove double-quoted strings
42
+ STRIPPED=$(printf '%s' "$STRIPPED" | sed 's/"[^"]*"//g' 2>/dev/null || printf '%s' "$STRIPPED")
33
43
  # Remove single-quoted strings
34
- STRIPPED=$(echo "$STRIPPED" | sed -E "s/'[^']*'//g" 2>/dev/null || echo "$STRIPPED")
44
+ STRIPPED=$(printf '%s' "$STRIPPED" | sed "s/'[^']*'//g" 2>/dev/null || printf '%s' "$STRIPPED")
45
+
46
+ # --- Helper: check if pattern matches stripped command ---
47
+ matches() {
48
+ printf '%s' "$STRIPPED" | grep -qE "$1"
49
+ }
35
50
 
36
51
  # --- Detect destructive commands (on stripped command only) ---
37
52
 
@@ -42,56 +57,78 @@ IS_GIT_CLEAN=false
42
57
  IS_RESET_HARD=false
43
58
  IS_GIT_CHECKOUT_DOT=false
44
59
  IS_GIT_RESTORE_DOT=false
60
+ IS_GIT_PUSH_FORCE=false
61
+ IS_DROP_TABLE=false
62
+ IS_DOCKER_RM=false
45
63
 
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
64
+ # rm (but not git rm which stages, doesn't destroy)
65
+ if matches '(^|\s|;|&&|\|\|)rm\s'; then
66
+ if ! matches '(^|\s)git\s+rm\s'; then
49
67
  IS_RM=true
50
68
  fi
51
69
  fi
52
70
 
53
- # Check for rmdir commands
54
- if echo "$STRIPPED" | grep -qE '(^|\s|;|&&|\|\|)rmdir\s'; then
71
+ # rmdir
72
+ if matches '(^|\s|;|&&|\|\|)rmdir\s'; then
55
73
  IS_RMDIR=true
56
74
  fi
57
75
 
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
76
+ # mv of project directories or git repos
77
+ if matches '(^|\s|;|&&|\|\|)mv\s'; then
78
+ MV_TAIL=$(printf '%s' "$STRIPPED" | sed 's/^.*\bmv //' | sed 's/-[finv] //g')
79
+ if printf '%s' "$MV_TAIL" | grep -qE '(~/projects|\.git)'; then
62
80
  IS_MV_PROJECT=true
63
81
  fi
64
82
  fi
65
83
 
66
- # Check for git clean
67
- if echo "$STRIPPED" | grep -qE '(^|\s|;|&&|\|\|)git\s+clean\b'; then
84
+ # git clean
85
+ if matches '(^|\s|;|&&|\|\|)git\s+clean\b'; then
68
86
  IS_GIT_CLEAN=true
69
87
  fi
70
88
 
71
- # Check for git reset --hard
72
- if echo "$STRIPPED" | grep -qE '(^|\s|;|&&|\|\|)git\s+reset\s+--hard'; then
89
+ # git reset --hard
90
+ if matches '(^|\s|;|&&|\|\|)git\s+reset\s+--hard'; then
73
91
  IS_RESET_HARD=true
74
92
  fi
75
93
 
76
- # Check for git checkout -- . (restores all files, discards changes)
77
- if echo "$STRIPPED" | grep -qE '(^|\s|;|&&|\|\|)git\s+checkout\s+--\s+\.'; then
94
+ # git checkout -- .
95
+ if matches '(^|\s|;|&&|\|\|)git\s+checkout\s+--\s+\.'; then
78
96
  IS_GIT_CHECKOUT_DOT=true
79
97
  fi
80
98
 
81
- # Check for git restore . (restores all files, discards changes)
82
- if echo "$STRIPPED" | grep -qE '(^|\s|;|&&|\|\|)git\s+restore\s+\.'; then
99
+ # git restore .
100
+ if matches '(^|\s|;|&&|\|\|)git\s+restore\s+\.'; then
83
101
  IS_GIT_RESTORE_DOT=true
84
102
  fi
85
103
 
86
- # Not a destructive command let it through
104
+ # git push --force / -f (but not --force-with-lease which is safer)
105
+ if matches '(^|\s|;|&&|\|\|)git\s+push\s'; then
106
+ if matches 'git\s+push\s.*--force([^-]|$)' || matches 'git\s+push\s+-f(\s|$)' || matches 'git\s+push\s.*\s-f(\s|$)'; then
107
+ IS_GIT_PUSH_FORCE=true
108
+ fi
109
+ fi
110
+
111
+ # SQL drop table (case-insensitive)
112
+ if printf '%s' "$STRIPPED" | grep -qiE '(^|\s|;)drop\s+table'; then
113
+ IS_DROP_TABLE=true
114
+ fi
115
+
116
+ # docker rm / docker rmi
117
+ if matches '(^|\s|;|&&|\|\|)docker\s+(rm|rmi)\b'; then
118
+ IS_DOCKER_RM=true
119
+ fi
120
+
121
+ # --- Not a destructive command — let it through ---
122
+
87
123
  if [ "$IS_RM" = false ] && [ "$IS_RMDIR" = false ] && [ "$IS_MV_PROJECT" = false ] && \
88
124
  [ "$IS_GIT_CLEAN" = false ] && [ "$IS_RESET_HARD" = false ] && \
89
- [ "$IS_GIT_CHECKOUT_DOT" = false ] && [ "$IS_GIT_RESTORE_DOT" = false ]; then
125
+ [ "$IS_GIT_CHECKOUT_DOT" = false ] && [ "$IS_GIT_RESTORE_DOT" = false ] && \
126
+ [ "$IS_GIT_PUSH_FORCE" = false ] && [ "$IS_DROP_TABLE" = false ] && \
127
+ [ "$IS_DOCKER_RM" = false ]; then
90
128
  exit 0
91
129
  fi
92
130
 
93
- # --- Handle git clean (block outright) ---
94
-
131
+ # --- Block: git clean ---
95
132
  if [ "$IS_GIT_CLEAN" = true ]; then
96
133
  jq -n '{
97
134
  continue: false,
@@ -100,8 +137,7 @@ if [ "$IS_GIT_CLEAN" = true ]; then
100
137
  exit 0
101
138
  fi
102
139
 
103
- # --- Handle git reset --hard (block outright) ---
104
-
140
+ # --- Block: git reset --hard ---
105
141
  if [ "$IS_RESET_HARD" = true ]; then
106
142
  jq -n '{
107
143
  continue: false,
@@ -110,8 +146,7 @@ if [ "$IS_RESET_HARD" = true ]; then
110
146
  exit 0
111
147
  fi
112
148
 
113
- # --- Handle git checkout -- . (block outright) ---
114
-
149
+ # --- Block: git checkout -- . ---
115
150
  if [ "$IS_GIT_CHECKOUT_DOT" = true ]; then
116
151
  jq -n '{
117
152
  continue: false,
@@ -120,8 +155,7 @@ if [ "$IS_GIT_CHECKOUT_DOT" = true ]; then
120
155
  exit 0
121
156
  fi
122
157
 
123
- # --- Handle git restore . (block outright) ---
124
-
158
+ # --- Block: git restore . ---
125
159
  if [ "$IS_GIT_RESTORE_DOT" = true ]; then
126
160
  jq -n '{
127
161
  continue: false,
@@ -130,8 +164,16 @@ if [ "$IS_GIT_RESTORE_DOT" = true ]; then
130
164
  exit 0
131
165
  fi
132
166
 
133
- # --- Handle mv of project directories (block outright) ---
167
+ # --- Block: git push --force ---
168
+ if [ "$IS_GIT_PUSH_FORCE" = true ]; then
169
+ jq -n '{
170
+ continue: false,
171
+ stopReason: "BLOCKED: git push --force can overwrite remote history and cause data loss for collaborators. Use --force-with-lease instead, or ask the user to run this manually."
172
+ }'
173
+ exit 0
174
+ fi
134
175
 
176
+ # --- Block: mv of project directories ---
135
177
  if [ "$IS_MV_PROJECT" = true ]; then
136
178
  jq -n '{
137
179
  continue: false,
@@ -140,8 +182,7 @@ if [ "$IS_MV_PROJECT" = true ]; then
140
182
  exit 0
141
183
  fi
142
184
 
143
- # --- Handle rmdir (block outright) ---
144
-
185
+ # --- Block: rmdir ---
145
186
  if [ "$IS_RMDIR" = true ]; then
146
187
  jq -n '{
147
188
  continue: false,
@@ -150,6 +191,24 @@ if [ "$IS_RMDIR" = true ]; then
150
191
  exit 0
151
192
  fi
152
193
 
194
+ # --- Block: drop table ---
195
+ if [ "$IS_DROP_TABLE" = true ]; then
196
+ jq -n '{
197
+ continue: false,
198
+ stopReason: "BLOCKED: DROP TABLE detected. This would permanently destroy database data. Ask the user to run this SQL statement manually after confirming intent."
199
+ }'
200
+ exit 0
201
+ fi
202
+
203
+ # --- Block: docker rm / rmi ---
204
+ if [ "$IS_DOCKER_RM" = true ]; then
205
+ jq -n '{
206
+ continue: false,
207
+ stopReason: "BLOCKED: docker rm/rmi detected. Removing containers or images can cause data loss. Ask the user to run this manually."
208
+ }'
209
+ exit 0
210
+ fi
211
+
153
212
  # --- Handle rm commands — copy to staging, then block ---
154
213
 
155
214
  # Create timestamped staging directory
@@ -158,7 +217,7 @@ STAGE_DIR="$STAGING_ROOT/$TIMESTAMP"
158
217
 
159
218
  # Extract file paths from the rm command
160
219
  # 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 '^$')
220
+ FILES=$(printf '%s' "$CMD" | sed 's/^.*\brm //' | sed 's/-[rRfivd]* //g' | tr ' ' '\n' | grep -v '^-' | grep -v '^$' || true)
162
221
 
163
222
  if [ -z "$FILES" ]; then
164
223
  jq -n '{
@@ -168,14 +227,15 @@ if [ -z "$FILES" ]; then
168
227
  exit 0
169
228
  fi
170
229
 
171
- STAGED=()
172
- MISSING=()
230
+ STAGED_COUNT=0
231
+ STAGED_LIST=""
232
+ MISSING_COUNT=0
173
233
 
174
234
  mkdir -p "$STAGE_DIR"
175
235
 
176
- while IFS= read -r filepath; do
236
+ printf '%s\n' "$FILES" | while IFS= read -r filepath; do
177
237
  # Expand path (handle ~, relative paths)
178
- expanded=$(eval echo "$filepath" 2>/dev/null || echo "$filepath")
238
+ expanded=$(eval printf '%s' "$filepath" 2>/dev/null || printf '%s' "$filepath")
179
239
 
180
240
  if [ -e "$expanded" ]; then
181
241
  # Preserve directory structure in staging
@@ -183,32 +243,32 @@ while IFS= read -r filepath; do
183
243
  mkdir -p "$target_dir"
184
244
  # COPY instead of MOVE — originals stay intact, staging is a backup
185
245
  if [ -d "$expanded" ]; then
186
- cp -R "$expanded" "$target_dir/" 2>/dev/null && STAGED+=("$expanded") || MISSING+=("$expanded")
246
+ # Use rsync if available (excludes node_modules/dist/.git), fall back to cp
247
+ if command -v rsync >/dev/null 2>&1; then
248
+ rsync -a --exclude='node_modules' --exclude='dist' --exclude='.git' "$expanded/" "$target_dir/$(basename "$expanded")/" 2>/dev/null
249
+ else
250
+ cp -R "$expanded" "$target_dir/" 2>/dev/null
251
+ fi
187
252
  else
188
- cp "$expanded" "$target_dir/" 2>/dev/null && STAGED+=("$expanded") || MISSING+=("$expanded")
253
+ cp "$expanded" "$target_dir/" 2>/dev/null
189
254
  fi
190
- else
191
- MISSING+=("$expanded")
192
255
  fi
193
- done <<< "$FILES"
256
+ done
194
257
 
195
- # Build response
196
- STAGED_COUNT=${#STAGED[@]}
197
- MISSING_COUNT=${#MISSING[@]}
258
+ # Count what was staged (check if staging dir has content)
259
+ if [ -d "$STAGE_DIR" ] && [ "$(ls -A "$STAGE_DIR" 2>/dev/null)" ]; then
260
+ STAGED_COUNT=$(find "$STAGE_DIR" -mindepth 1 -maxdepth 1 | wc -l | tr -d ' ')
261
+ fi
198
262
 
199
- if [ "$STAGED_COUNT" -eq 0 ] && [ "$MISSING_COUNT" -gt 0 ]; then
263
+ if [ "$STAGED_COUNT" -eq 0 ]; then
200
264
  # All files were missing — let the rm fail naturally
201
265
  rmdir "$STAGE_DIR" 2>/dev/null || true
202
266
  exit 0
203
267
  fi
204
268
 
205
- STAGED_LIST=$(printf '%s, ' "${STAGED[@]}" | sed 's/, $//')
206
-
207
269
  jq -n \
208
- --arg staged "$STAGED_LIST" \
209
270
  --arg dir "$STAGE_DIR" \
210
- --argjson count "$STAGED_COUNT" \
211
271
  '{
212
272
  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.")
273
+ stopReason: ("BLOCKED & BACKED UP: Files copied to " + $dir + ". The originals are untouched. To proceed with deletion, ask the user to run the rm command manually.")
214
274
  }'