@soleri/cli 9.4.0 → 9.5.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 (64) hide show
  1. package/dist/commands/hooks.js +126 -0
  2. package/dist/commands/hooks.js.map +1 -1
  3. package/dist/commands/install.js +5 -0
  4. package/dist/commands/install.js.map +1 -1
  5. package/dist/hook-packs/converter/README.md +99 -0
  6. package/dist/hook-packs/converter/template.d.ts +36 -0
  7. package/dist/hook-packs/converter/template.js +127 -0
  8. package/dist/hook-packs/converter/template.js.map +1 -0
  9. package/dist/hook-packs/converter/template.test.ts +133 -0
  10. package/dist/hook-packs/converter/template.ts +163 -0
  11. package/dist/hook-packs/flock-guard/README.md +65 -0
  12. package/dist/hook-packs/flock-guard/manifest.json +36 -0
  13. package/dist/hook-packs/flock-guard/scripts/flock-guard-post.sh +48 -0
  14. package/dist/hook-packs/flock-guard/scripts/flock-guard-pre.sh +85 -0
  15. package/dist/hook-packs/full/manifest.json +8 -1
  16. package/dist/hook-packs/graduation.d.ts +11 -0
  17. package/dist/hook-packs/graduation.js +48 -0
  18. package/dist/hook-packs/graduation.js.map +1 -0
  19. package/dist/hook-packs/graduation.ts +65 -0
  20. package/dist/hook-packs/installer.js +3 -1
  21. package/dist/hook-packs/installer.js.map +1 -1
  22. package/dist/hook-packs/installer.ts +3 -1
  23. package/dist/hook-packs/marketing-research/README.md +37 -0
  24. package/dist/hook-packs/marketing-research/manifest.json +24 -0
  25. package/dist/hook-packs/marketing-research/scripts/marketing-research.sh +70 -0
  26. package/dist/hook-packs/registry.d.ts +1 -0
  27. package/dist/hook-packs/registry.js +14 -4
  28. package/dist/hook-packs/registry.js.map +1 -1
  29. package/dist/hook-packs/registry.ts +18 -4
  30. package/dist/hook-packs/safety/README.md +50 -0
  31. package/dist/hook-packs/safety/manifest.json +23 -0
  32. package/{src/hook-packs/yolo-safety → dist/hook-packs/safety}/scripts/anti-deletion.sh +7 -1
  33. package/dist/hook-packs/validator.d.ts +32 -0
  34. package/dist/hook-packs/validator.js +126 -0
  35. package/dist/hook-packs/validator.js.map +1 -0
  36. package/dist/hook-packs/validator.ts +158 -0
  37. package/dist/hook-packs/yolo-safety/manifest.json +3 -19
  38. package/package.json +1 -1
  39. package/src/__tests__/flock-guard.test.ts +225 -0
  40. package/src/__tests__/graduation.test.ts +199 -0
  41. package/src/__tests__/hook-packs.test.ts +44 -19
  42. package/src/__tests__/hooks-convert.test.ts +342 -0
  43. package/src/__tests__/validator.test.ts +265 -0
  44. package/src/commands/hooks.ts +172 -0
  45. package/src/commands/install.ts +6 -0
  46. package/src/hook-packs/converter/README.md +99 -0
  47. package/src/hook-packs/converter/template.test.ts +133 -0
  48. package/src/hook-packs/converter/template.ts +163 -0
  49. package/src/hook-packs/flock-guard/README.md +65 -0
  50. package/src/hook-packs/flock-guard/manifest.json +36 -0
  51. package/src/hook-packs/flock-guard/scripts/flock-guard-post.sh +48 -0
  52. package/src/hook-packs/flock-guard/scripts/flock-guard-pre.sh +85 -0
  53. package/src/hook-packs/full/manifest.json +8 -1
  54. package/src/hook-packs/graduation.ts +65 -0
  55. package/src/hook-packs/installer.ts +3 -1
  56. package/src/hook-packs/marketing-research/README.md +37 -0
  57. package/src/hook-packs/marketing-research/manifest.json +24 -0
  58. package/src/hook-packs/marketing-research/scripts/marketing-research.sh +70 -0
  59. package/src/hook-packs/registry.ts +18 -4
  60. package/src/hook-packs/safety/README.md +50 -0
  61. package/src/hook-packs/safety/manifest.json +23 -0
  62. package/src/hook-packs/safety/scripts/anti-deletion.sh +280 -0
  63. package/src/hook-packs/validator.ts +158 -0
  64. package/src/hook-packs/yolo-safety/manifest.json +3 -19
@@ -0,0 +1,280 @@
1
+ #!/bin/sh
2
+ # Anti-Deletion Staging Hook for Claude Code (Soleri Hook Pack: safety)
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)
15
+ #
16
+ # Catastrophic commands (rm -rf /, rm -rf ~) should stay in deny rules —
17
+ # this hook handles targeted deletes only.
18
+ #
19
+ # Dependencies: jq (required)
20
+ # POSIX sh compatible — no bash-specific features.
21
+
22
+ set -eu
23
+
24
+ STAGING_ROOT="$HOME/.soleri/staging"
25
+
26
+ # --- Auto-cleanup: remove staging backups older than 7 days ---
27
+ if [ -d "$STAGING_ROOT" ]; then
28
+ find "$STAGING_ROOT" -mindepth 1 -maxdepth 1 -type d -mtime +7 -exec rm -rf {} + 2>/dev/null || true
29
+ fi
30
+
31
+ INPUT=$(cat)
32
+
33
+ # Extract the command from stdin JSON
34
+ CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
35
+
36
+ # No command found — let it through
37
+ if [ -z "$CMD" ]; then
38
+ exit 0
39
+ fi
40
+
41
+ # --- Strip heredocs and quoted strings to avoid false positives ---
42
+ # Commands like: gh issue comment --body "$(cat <<'EOF' ... rmdir ... EOF)"
43
+ # contain destructive keywords in text, not as actual commands.
44
+
45
+ # Remove heredoc blocks (best-effort with sed)
46
+ STRIPPED=$(printf '%s' "$CMD" | sed -e "s/<<'[A-Za-z_]*'.*//g" -e 's/<<[A-Za-z_]*.*//g' 2>/dev/null || printf '%s' "$CMD")
47
+ # Remove double-quoted strings
48
+ STRIPPED=$(printf '%s' "$STRIPPED" | sed 's/"[^"]*"//g' 2>/dev/null || printf '%s' "$STRIPPED")
49
+ # Remove single-quoted strings
50
+ STRIPPED=$(printf '%s' "$STRIPPED" | sed "s/'[^']*'//g" 2>/dev/null || printf '%s' "$STRIPPED")
51
+
52
+ # --- Helper: check if pattern matches stripped command ---
53
+ matches() {
54
+ printf '%s' "$STRIPPED" | grep -qE "$1"
55
+ }
56
+
57
+ # --- Detect destructive commands (on stripped command only) ---
58
+
59
+ IS_RM=false
60
+ IS_RMDIR=false
61
+ IS_MV_PROJECT=false
62
+ IS_GIT_CLEAN=false
63
+ IS_RESET_HARD=false
64
+ IS_GIT_CHECKOUT_DOT=false
65
+ IS_GIT_RESTORE_DOT=false
66
+ IS_GIT_PUSH_FORCE=false
67
+ IS_DROP_TABLE=false
68
+ IS_DOCKER_RM=false
69
+
70
+ # rm (but not git rm which stages, doesn't destroy)
71
+ if matches '(^|\s|;|&&|\|\|)rm\s'; then
72
+ if ! matches '(^|\s)git\s+rm\s'; then
73
+ IS_RM=true
74
+ fi
75
+ fi
76
+
77
+ # rmdir
78
+ if matches '(^|\s|;|&&|\|\|)rmdir\s'; then
79
+ IS_RMDIR=true
80
+ fi
81
+
82
+ # mv of project directories or git repos
83
+ if matches '(^|\s|;|&&|\|\|)mv\s'; then
84
+ MV_TAIL=$(printf '%s' "$STRIPPED" | sed 's/^.*\bmv //' | sed 's/-[finv] //g')
85
+ if printf '%s' "$MV_TAIL" | grep -qE '(~/projects|\.git)'; then
86
+ IS_MV_PROJECT=true
87
+ fi
88
+ fi
89
+
90
+ # git clean
91
+ if matches '(^|\s|;|&&|\|\|)git\s+clean\b'; then
92
+ IS_GIT_CLEAN=true
93
+ fi
94
+
95
+ # git reset --hard
96
+ if matches '(^|\s|;|&&|\|\|)git\s+reset\s+--hard'; then
97
+ IS_RESET_HARD=true
98
+ fi
99
+
100
+ # git checkout -- .
101
+ if matches '(^|\s|;|&&|\|\|)git\s+checkout\s+--\s+\.'; then
102
+ IS_GIT_CHECKOUT_DOT=true
103
+ fi
104
+
105
+ # git restore .
106
+ if matches '(^|\s|;|&&|\|\|)git\s+restore\s+\.'; then
107
+ IS_GIT_RESTORE_DOT=true
108
+ fi
109
+
110
+ # git push --force / -f (but not --force-with-lease which is safer)
111
+ if matches '(^|\s|;|&&|\|\|)git\s+push\s'; then
112
+ if matches 'git\s+push\s.*--force([^-]|$)' || matches 'git\s+push\s+-f(\s|$)' || matches 'git\s+push\s.*\s-f(\s|$)'; then
113
+ IS_GIT_PUSH_FORCE=true
114
+ fi
115
+ fi
116
+
117
+ # SQL drop table (case-insensitive)
118
+ if printf '%s' "$STRIPPED" | grep -qiE '(^|\s|;)drop\s+table'; then
119
+ IS_DROP_TABLE=true
120
+ fi
121
+
122
+ # docker rm / docker rmi
123
+ if matches '(^|\s|;|&&|\|\|)docker\s+(rm|rmi)\b'; then
124
+ IS_DOCKER_RM=true
125
+ fi
126
+
127
+ # --- Not a destructive command — let it through ---
128
+
129
+ if [ "$IS_RM" = false ] && [ "$IS_RMDIR" = false ] && [ "$IS_MV_PROJECT" = false ] && \
130
+ [ "$IS_GIT_CLEAN" = false ] && [ "$IS_RESET_HARD" = false ] && \
131
+ [ "$IS_GIT_CHECKOUT_DOT" = false ] && [ "$IS_GIT_RESTORE_DOT" = false ] && \
132
+ [ "$IS_GIT_PUSH_FORCE" = false ] && [ "$IS_DROP_TABLE" = false ] && \
133
+ [ "$IS_DOCKER_RM" = false ]; then
134
+ exit 0
135
+ fi
136
+
137
+ # --- Block: git clean ---
138
+ if [ "$IS_GIT_CLEAN" = true ]; then
139
+ jq -n '{
140
+ continue: false,
141
+ 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."
142
+ }'
143
+ exit 0
144
+ fi
145
+
146
+ # --- Block: git reset --hard ---
147
+ if [ "$IS_RESET_HARD" = true ]; then
148
+ jq -n '{
149
+ continue: false,
150
+ stopReason: "BLOCKED: git reset --hard would discard uncommitted changes. Use git stash to save them first, or ask the user to run this manually."
151
+ }'
152
+ exit 0
153
+ fi
154
+
155
+ # --- Block: git checkout -- . ---
156
+ if [ "$IS_GIT_CHECKOUT_DOT" = true ]; then
157
+ jq -n '{
158
+ continue: false,
159
+ stopReason: "BLOCKED: git checkout -- . would discard all uncommitted changes. Use git stash to save them first, or ask the user to run this manually."
160
+ }'
161
+ exit 0
162
+ fi
163
+
164
+ # --- Block: git restore . ---
165
+ if [ "$IS_GIT_RESTORE_DOT" = true ]; then
166
+ jq -n '{
167
+ continue: false,
168
+ stopReason: "BLOCKED: git restore . would discard all uncommitted changes. Use git stash to save them first, or ask the user to run this manually."
169
+ }'
170
+ exit 0
171
+ fi
172
+
173
+ # --- Block: git push --force ---
174
+ if [ "$IS_GIT_PUSH_FORCE" = true ]; then
175
+ jq -n '{
176
+ continue: false,
177
+ 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."
178
+ }'
179
+ exit 0
180
+ fi
181
+
182
+ # --- Block: mv of project directories ---
183
+ if [ "$IS_MV_PROJECT" = true ]; then
184
+ jq -n '{
185
+ continue: false,
186
+ 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."
187
+ }'
188
+ exit 0
189
+ fi
190
+
191
+ # --- Block: rmdir ---
192
+ if [ "$IS_RMDIR" = true ]; then
193
+ jq -n '{
194
+ continue: false,
195
+ stopReason: "BLOCKED: rmdir detected. Removing directories can break project structure. Ask the user to confirm this operation manually."
196
+ }'
197
+ exit 0
198
+ fi
199
+
200
+ # --- Block: drop table ---
201
+ if [ "$IS_DROP_TABLE" = true ]; then
202
+ jq -n '{
203
+ continue: false,
204
+ stopReason: "BLOCKED: DROP TABLE detected. This would permanently destroy database data. Ask the user to run this SQL statement manually after confirming intent."
205
+ }'
206
+ exit 0
207
+ fi
208
+
209
+ # --- Block: docker rm / rmi ---
210
+ if [ "$IS_DOCKER_RM" = true ]; then
211
+ jq -n '{
212
+ continue: false,
213
+ stopReason: "BLOCKED: docker rm/rmi detected. Removing containers or images can cause data loss. Ask the user to run this manually."
214
+ }'
215
+ exit 0
216
+ fi
217
+
218
+ # --- Handle rm commands — copy to staging, then block ---
219
+
220
+ # Create timestamped staging directory
221
+ TIMESTAMP=$(date +%Y-%m-%d_%H%M%S)
222
+ STAGE_DIR="$STAGING_ROOT/$TIMESTAMP"
223
+
224
+ # Extract file paths from the rm command
225
+ # Strip rm and its flags, keeping only the file arguments
226
+ FILES=$(printf '%s' "$CMD" | sed 's/^.*\brm //' | sed 's/-[rRfivd]* //g' | tr ' ' '\n' | grep -v '^-' | grep -v '^$' || true)
227
+
228
+ if [ -z "$FILES" ]; then
229
+ jq -n '{
230
+ continue: false,
231
+ stopReason: "BLOCKED: rm command detected but could not parse file targets. Please specify files explicitly."
232
+ }'
233
+ exit 0
234
+ fi
235
+
236
+ STAGED_COUNT=0
237
+ STAGED_LIST=""
238
+ MISSING_COUNT=0
239
+
240
+ mkdir -p "$STAGE_DIR"
241
+
242
+ printf '%s\n' "$FILES" | while IFS= read -r filepath; do
243
+ # Expand path (handle ~, relative paths)
244
+ expanded=$(eval printf '%s' "$filepath" 2>/dev/null || printf '%s' "$filepath")
245
+
246
+ if [ -e "$expanded" ]; then
247
+ # Preserve directory structure in staging
248
+ target_dir="$STAGE_DIR/$(dirname "$expanded")"
249
+ mkdir -p "$target_dir"
250
+ # COPY instead of MOVE — originals stay intact, staging is a backup
251
+ if [ -d "$expanded" ]; then
252
+ # Use rsync if available (excludes node_modules/dist/.git), fall back to cp
253
+ if command -v rsync >/dev/null 2>&1; then
254
+ rsync -a --exclude='node_modules' --exclude='dist' --exclude='.git' "$expanded/" "$target_dir/$(basename "$expanded")/" 2>/dev/null
255
+ else
256
+ cp -R "$expanded" "$target_dir/" 2>/dev/null
257
+ fi
258
+ else
259
+ cp "$expanded" "$target_dir/" 2>/dev/null
260
+ fi
261
+ fi
262
+ done
263
+
264
+ # Count what was staged (check if staging dir has content)
265
+ if [ -d "$STAGE_DIR" ] && [ "$(ls -A "$STAGE_DIR" 2>/dev/null)" ]; then
266
+ STAGED_COUNT=$(find "$STAGE_DIR" -mindepth 1 -maxdepth 1 | wc -l | tr -d ' ')
267
+ fi
268
+
269
+ if [ "$STAGED_COUNT" -eq 0 ]; then
270
+ # All files were missing — let the rm fail naturally
271
+ rmdir "$STAGE_DIR" 2>/dev/null || true
272
+ exit 0
273
+ fi
274
+
275
+ jq -n \
276
+ --arg dir "$STAGE_DIR" \
277
+ '{
278
+ continue: false,
279
+ 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.")
280
+ }'
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Hook pack validation framework.
3
+ * Generates test fixtures, runs dry-run tests, reports false positives/negatives.
4
+ */
5
+ import { execSync } from 'node:child_process';
6
+ import type { HookEvent } from './converter/template.js';
7
+
8
+ export interface TestFixture {
9
+ name: string;
10
+ event: HookEvent;
11
+ payload: Record<string, unknown>;
12
+ shouldMatch: boolean;
13
+ }
14
+
15
+ export interface DryRunResult {
16
+ fixture: TestFixture;
17
+ exitCode: number;
18
+ stdout: string;
19
+ matched: boolean;
20
+ }
21
+
22
+ export interface ValidationReport {
23
+ total: number;
24
+ passed: number;
25
+ falsePositives: DryRunResult[];
26
+ falseNegatives: DryRunResult[];
27
+ }
28
+
29
+ /**
30
+ * Generate test fixtures for a hook event.
31
+ * Returns 5 matching + 10 non-matching payloads.
32
+ */
33
+ export function generateFixtures(
34
+ event: HookEvent,
35
+ toolMatcher?: string,
36
+ filePatterns?: string[],
37
+ ): TestFixture[] {
38
+ const fixtures: TestFixture[] = [];
39
+
40
+ if (event === 'PreToolUse' || event === 'PostToolUse') {
41
+ const matchTools = toolMatcher ? toolMatcher.split('|').map((t) => t.trim()) : ['Write'];
42
+ const matchPath = filePatterns?.[0] ?? '**/src/**';
43
+ // Convert glob to a sample path
44
+ const samplePath = matchPath
45
+ .replace('**/', 'src/')
46
+ .replace('**', 'components')
47
+ .replace('*', 'file.tsx');
48
+
49
+ // 5 matching fixtures
50
+ for (let i = 0; i < 5; i++) {
51
+ const tool = matchTools[i % matchTools.length];
52
+ fixtures.push({
53
+ name: `match-${tool}-${i}`,
54
+ event,
55
+ payload: {
56
+ tool_name: tool,
57
+ tool_input: {
58
+ file_path: `${samplePath.replace('file.tsx', `file-${i}.tsx`)}`,
59
+ command: `echo test-${i}`,
60
+ },
61
+ },
62
+ shouldMatch: true,
63
+ });
64
+ }
65
+
66
+ // 10 non-matching fixtures
67
+ const nonMatchTools = [
68
+ 'Bash',
69
+ 'Read',
70
+ 'Glob',
71
+ 'Grep',
72
+ 'Agent',
73
+ 'WebSearch',
74
+ 'WebFetch',
75
+ 'TaskCreate',
76
+ 'Skill',
77
+ 'ToolSearch',
78
+ ];
79
+ for (let i = 0; i < 10; i++) {
80
+ fixtures.push({
81
+ name: `no-match-${nonMatchTools[i]}-${i}`,
82
+ event,
83
+ payload: {
84
+ tool_name: nonMatchTools[i],
85
+ tool_input: {
86
+ file_path: `/unrelated/path/other-${i}.js`,
87
+ command: `ls -la`,
88
+ },
89
+ },
90
+ shouldMatch: false,
91
+ });
92
+ }
93
+ } else {
94
+ // PreCompact, Notification, Stop — simpler payloads
95
+ // 5 matching (any invocation matches these events)
96
+ for (let i = 0; i < 5; i++) {
97
+ fixtures.push({
98
+ name: `match-event-${i}`,
99
+ event,
100
+ payload: { session_id: `test-session-${i}`, context: `test context ${i}` },
101
+ shouldMatch: true,
102
+ });
103
+ }
104
+ // 10 non-matching (empty/malformed payloads)
105
+ for (let i = 0; i < 10; i++) {
106
+ fixtures.push({
107
+ name: `no-match-empty-${i}`,
108
+ event,
109
+ payload: {},
110
+ shouldMatch: false,
111
+ });
112
+ }
113
+ }
114
+
115
+ return fixtures;
116
+ }
117
+
118
+ /**
119
+ * Run a hook script against a single fixture in dry-run mode.
120
+ */
121
+ export function runSingleDryRun(scriptPath: string, fixture: TestFixture): DryRunResult {
122
+ const input = JSON.stringify(fixture.payload);
123
+ try {
124
+ const stdout = execSync(`printf '%s' '${input.replace(/'/g, "'\\''")}' | sh "${scriptPath}"`, {
125
+ encoding: 'utf-8',
126
+ timeout: 5000,
127
+ stdio: ['pipe', 'pipe', 'pipe'],
128
+ });
129
+ const matched = stdout.trim().length > 0 && stdout.includes('"continue"');
130
+ return { fixture, exitCode: 0, stdout: stdout.trim(), matched };
131
+ } catch (err: unknown) {
132
+ const error = err as { status?: number; stdout?: string };
133
+ return {
134
+ fixture,
135
+ exitCode: error.status ?? 1,
136
+ stdout: (error.stdout as string) ?? '',
137
+ matched: false,
138
+ };
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Run all fixtures against a script and produce a validation report.
144
+ */
145
+ export function validateHookScript(scriptPath: string, fixtures: TestFixture[]): ValidationReport {
146
+ const results = fixtures.map((f) => runSingleDryRun(scriptPath, f));
147
+
148
+ const falsePositives = results.filter((r) => !r.fixture.shouldMatch && r.matched);
149
+ const falseNegatives = results.filter((r) => r.fixture.shouldMatch && !r.matched);
150
+ const passed = results.length - falsePositives.length - falseNegatives.length;
151
+
152
+ return {
153
+ total: results.length,
154
+ passed,
155
+ falsePositives,
156
+ falseNegatives,
157
+ };
158
+ }
@@ -1,23 +1,7 @@
1
1
  {
2
2
  "name": "yolo-safety",
3
- "version": "1.0.0",
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",
3
+ "version": "1.1.0",
4
+ "description": "Safety guardrails for YOLO mode — composes the safety pack (anti-deletion, staging) with YOLO-specific defaults",
5
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": "sh ~/.claude/hooks/anti-deletion.sh",
19
- "timeout": 10,
20
- "statusMessage": "Checking for destructive commands..."
21
- }
22
- ]
6
+ "composedFrom": ["safety"]
23
7
  }