@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.
- package/.github/workflows/ci.yml +116 -0
- package/dist/commands/agent.js +51 -2
- package/dist/commands/agent.js.map +1 -1
- package/dist/commands/pack.js +62 -13
- package/dist/commands/pack.js.map +1 -1
- package/dist/commands/staging.d.ts +49 -0
- package/dist/commands/staging.js +108 -18
- package/dist/commands/staging.js.map +1 -1
- package/dist/commands/yolo.d.ts +2 -0
- package/dist/commands/yolo.js +86 -0
- package/dist/commands/yolo.js.map +1 -0
- package/dist/hook-packs/yolo-safety/manifest.json +2 -2
- package/dist/hook-packs/yolo-safety/scripts/anti-deletion.sh +121 -61
- package/dist/main.js +2 -0
- package/dist/main.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/hook-packs.test.ts +1 -1
- package/src/__tests__/wizard-e2e.mjs +1 -1
- package/src/commands/agent.ts +65 -2
- package/src/commands/pack.ts +80 -14
- package/src/commands/staging.ts +143 -20
- package/src/commands/yolo.ts +103 -0
- package/src/hook-packs/yolo-safety/manifest.json +2 -2
- package/src/hook-packs/yolo-safety/scripts/anti-deletion.sh +121 -61
- package/src/main.ts +2 -0
- package/vitest.config.ts +3 -0
- package/src/__tests__/archetypes.test.ts +0 -84
- package/src/__tests__/create.test.ts +0 -207
- package/src/prompts/archetypes.ts +0 -343
package/src/commands/staging.ts
CHANGED
|
@@ -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
|
-
|
|
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('
|
|
165
|
-
.option(
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
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
|
|
251
|
+
log.info('No staged files to clean.');
|
|
177
252
|
return;
|
|
178
253
|
}
|
|
179
254
|
|
|
180
|
-
let
|
|
255
|
+
let toClean = entries;
|
|
181
256
|
|
|
182
|
-
if (opts.
|
|
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
|
-
|
|
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 (
|
|
197
|
-
log.info('No snapshots match the
|
|
271
|
+
if (toClean.length === 0) {
|
|
272
|
+
log.info('No snapshots match the clean criteria.');
|
|
198
273
|
return;
|
|
199
274
|
}
|
|
200
275
|
|
|
201
|
-
|
|
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(`
|
|
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
|
-
|
|
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": "
|
|
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": "
|
|
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
|
-
#!/
|
|
1
|
+
#!/bin/sh
|
|
2
2
|
# Anti-Deletion Staging Hook for Claude Code (Soleri Hook Pack: yolo-safety)
|
|
3
|
-
# PreToolUse -> Bash: intercepts
|
|
4
|
-
#
|
|
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)
|
|
19
|
+
# Dependencies: jq (required)
|
|
20
|
+
# POSIX sh compatible — no bash-specific features.
|
|
10
21
|
|
|
11
|
-
set -
|
|
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=$(
|
|
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
|
|
30
|
-
STRIPPED=$(
|
|
31
|
-
# Remove double-quoted strings
|
|
32
|
-
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=$(
|
|
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
|
-
#
|
|
47
|
-
if
|
|
48
|
-
if !
|
|
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
|
-
#
|
|
54
|
-
if
|
|
71
|
+
# rmdir
|
|
72
|
+
if matches '(^|\s|;|&&|\|\|)rmdir\s'; then
|
|
55
73
|
IS_RMDIR=true
|
|
56
74
|
fi
|
|
57
75
|
|
|
58
|
-
#
|
|
59
|
-
if
|
|
60
|
-
|
|
61
|
-
if
|
|
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
|
-
#
|
|
67
|
-
if
|
|
84
|
+
# git clean
|
|
85
|
+
if matches '(^|\s|;|&&|\|\|)git\s+clean\b'; then
|
|
68
86
|
IS_GIT_CLEAN=true
|
|
69
87
|
fi
|
|
70
88
|
|
|
71
|
-
#
|
|
72
|
-
if
|
|
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
|
-
#
|
|
77
|
-
if
|
|
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
|
-
#
|
|
82
|
-
if
|
|
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
|
-
#
|
|
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 ]
|
|
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
|
-
# ---
|
|
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
|
-
# ---
|
|
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
|
-
# ---
|
|
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
|
-
# ---
|
|
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
|
-
# ---
|
|
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
|
-
# ---
|
|
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=$(
|
|
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
|
-
|
|
172
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
253
|
+
cp "$expanded" "$target_dir/" 2>/dev/null
|
|
189
254
|
fi
|
|
190
|
-
else
|
|
191
|
-
MISSING+=("$expanded")
|
|
192
255
|
fi
|
|
193
|
-
done
|
|
256
|
+
done
|
|
194
257
|
|
|
195
|
-
#
|
|
196
|
-
|
|
197
|
-
|
|
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 ]
|
|
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:
|
|
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
|
}'
|