@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,36 @@
1
+ {
2
+ "name": "flock-guard",
3
+ "version": "1.0.0",
4
+ "description": "Parallel agent lock guard — prevents lockfile corruption when multiple agents run in worktrees by using atomic mkdir-based locking across PreToolUse/PostToolUse",
5
+ "hooks": [],
6
+ "scripts": [
7
+ {
8
+ "name": "flock-guard-pre",
9
+ "file": "flock-guard-pre.sh",
10
+ "targetDir": "hooks"
11
+ },
12
+ {
13
+ "name": "flock-guard-post",
14
+ "file": "flock-guard-post.sh",
15
+ "targetDir": "hooks"
16
+ }
17
+ ],
18
+ "lifecycleHooks": [
19
+ {
20
+ "event": "PreToolUse",
21
+ "matcher": "Bash",
22
+ "type": "command",
23
+ "command": "sh ~/.claude/hooks/flock-guard-pre.sh",
24
+ "timeout": 10,
25
+ "statusMessage": "Checking lockfile guard..."
26
+ },
27
+ {
28
+ "event": "PostToolUse",
29
+ "matcher": "Bash",
30
+ "type": "command",
31
+ "command": "sh ~/.claude/hooks/flock-guard-post.sh",
32
+ "timeout": 10,
33
+ "statusMessage": "Releasing lockfile guard..."
34
+ }
35
+ ]
36
+ }
@@ -0,0 +1,48 @@
1
+ #!/bin/sh
2
+ # Flock Guard — PostToolUse lock release (Soleri Hook Pack: flock-guard)
3
+ # Releases the lock after a lockfile-modifying command completes.
4
+ # Dependencies: jq
5
+ # POSIX sh compatible.
6
+
7
+ set -eu
8
+
9
+ INPUT=$(cat)
10
+
11
+ CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
12
+ if [ -z "$CMD" ]; then
13
+ exit 0
14
+ fi
15
+
16
+ # Strip quoted strings (same logic as pre script)
17
+ STRIPPED=$(printf '%s' "$CMD" | sed -e "s/<<'[A-Za-z_]*'.*//g" -e 's/<<[A-Za-z_]*.*//g' 2>/dev/null || printf '%s' "$CMD")
18
+ STRIPPED=$(printf '%s' "$STRIPPED" | sed 's/"[^"]*"//g' 2>/dev/null || printf '%s' "$STRIPPED")
19
+ STRIPPED=$(printf '%s' "$STRIPPED" | sed "s/'[^']*'//g" 2>/dev/null || printf '%s' "$STRIPPED")
20
+
21
+ # Check if command modifies lockfiles (same patterns as pre)
22
+ IS_LOCKFILE_CMD=false
23
+ printf '%s' "$STRIPPED" | grep -qE '(^|\s|;|&&|\|\|)npm\s+(install|ci)(\s|$|;)' && IS_LOCKFILE_CMD=true
24
+ printf '%s' "$STRIPPED" | grep -qE '(^|\s|;|&&|\|\|)yarn(\s+install)?(\s|$|;)' && IS_LOCKFILE_CMD=true
25
+ printf '%s' "$STRIPPED" | grep -qE '(^|\s|;|&&|\|\|)pnpm\s+install(\s|$|;)' && IS_LOCKFILE_CMD=true
26
+ printf '%s' "$STRIPPED" | grep -qE '(^|\s|;|&&|\|\|)cargo\s+(build|update)(\s|$|;)' && IS_LOCKFILE_CMD=true
27
+ printf '%s' "$STRIPPED" | grep -qE '(^|\s|;|&&|\|\|)pip[3]?\s+install(\s|$|;)' && IS_LOCKFILE_CMD=true
28
+
29
+ if [ "$IS_LOCKFILE_CMD" = false ]; then
30
+ exit 0
31
+ fi
32
+
33
+ # Release lock
34
+ PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
35
+ PROJECT_HASH=$(printf '%s' "$PROJECT_ROOT" | shasum | cut -c1-8)
36
+ LOCK_DIR="${TMPDIR:-${TEMP:-/tmp}}/soleri-guard-${PROJECT_HASH}.lock"
37
+
38
+ # Only release if we own it
39
+ SESSION_ID="${CLAUDE_SESSION_ID:-$$}"
40
+ if [ -f "$LOCK_DIR/lock.json" ]; then
41
+ LOCK_AGENT=$(jq -r '.agentId // ""' "$LOCK_DIR/lock.json" 2>/dev/null || echo "")
42
+ if [ "$LOCK_AGENT" = "$SESSION_ID" ]; then
43
+ rm -rf "$LOCK_DIR"
44
+ fi
45
+ fi
46
+
47
+ # PostToolUse never blocks
48
+ exit 0
@@ -0,0 +1,85 @@
1
+ #!/bin/sh
2
+ # Flock Guard — PreToolUse lock acquisition (Soleri Hook Pack: flock-guard)
3
+ # Acquires atomic mkdir-based lock before lockfile-modifying commands.
4
+ # Dependencies: jq
5
+ # POSIX sh compatible.
6
+
7
+ set -eu
8
+
9
+ INPUT=$(cat)
10
+
11
+ # Extract command from stdin JSON
12
+ CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
13
+ if [ -z "$CMD" ]; then
14
+ exit 0
15
+ fi
16
+
17
+ # Strip quoted strings to avoid false positives (same as anti-deletion.sh)
18
+ STRIPPED=$(printf '%s' "$CMD" | sed -e "s/<<'[A-Za-z_]*'.*//g" -e 's/<<[A-Za-z_]*.*//g' 2>/dev/null || printf '%s' "$CMD")
19
+ STRIPPED=$(printf '%s' "$STRIPPED" | sed 's/"[^"]*"//g' 2>/dev/null || printf '%s' "$STRIPPED")
20
+ STRIPPED=$(printf '%s' "$STRIPPED" | sed "s/'[^']*'//g" 2>/dev/null || printf '%s' "$STRIPPED")
21
+
22
+ # Check if command modifies lockfiles
23
+ IS_LOCKFILE_CMD=false
24
+ # npm install (but not npm run, npm test, etc.)
25
+ printf '%s' "$STRIPPED" | grep -qE '(^|\s|;|&&|\|\|)npm\s+install(\s|$|;)' && IS_LOCKFILE_CMD=true
26
+ printf '%s' "$STRIPPED" | grep -qE '(^|\s|;|&&|\|\|)npm\s+ci(\s|$|;)' && IS_LOCKFILE_CMD=true
27
+ # yarn (bare yarn or yarn install)
28
+ printf '%s' "$STRIPPED" | grep -qE '(^|\s|;|&&|\|\|)yarn(\s+install)?(\s|$|;)' && IS_LOCKFILE_CMD=true
29
+ # pnpm install
30
+ printf '%s' "$STRIPPED" | grep -qE '(^|\s|;|&&|\|\|)pnpm\s+install(\s|$|;)' && IS_LOCKFILE_CMD=true
31
+ # cargo build / cargo update
32
+ printf '%s' "$STRIPPED" | grep -qE '(^|\s|;|&&|\|\|)cargo\s+(build|update)(\s|$|;)' && IS_LOCKFILE_CMD=true
33
+ # pip install
34
+ printf '%s' "$STRIPPED" | grep -qE '(^|\s|;|&&|\|\|)pip[3]?\s+install(\s|$|;)' && IS_LOCKFILE_CMD=true
35
+
36
+ if [ "$IS_LOCKFILE_CMD" = false ]; then
37
+ exit 0
38
+ fi
39
+
40
+ # Compute project-specific lock path
41
+ PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
42
+ PROJECT_HASH=$(printf '%s' "$PROJECT_ROOT" | shasum | cut -c1-8)
43
+ LOCK_DIR="${TMPDIR:-${TEMP:-/tmp}}/soleri-guard-${PROJECT_HASH}.lock"
44
+ LOCK_JSON="$LOCK_DIR/lock.json"
45
+ STALE_TIMEOUT=30
46
+ SESSION_ID="${CLAUDE_SESSION_ID:-$$}"
47
+
48
+ # Try to acquire lock (mkdir is atomic on POSIX)
49
+ if mkdir "$LOCK_DIR" 2>/dev/null; then
50
+ # Lock acquired — write state
51
+ printf '{"agentId":"%s","timestamp":%d,"command":"%s"}' "$SESSION_ID" "$(date +%s)" "$CMD" > "$LOCK_JSON"
52
+ exit 0
53
+ fi
54
+
55
+ # Lock held — check staleness
56
+ if [ -f "$LOCK_JSON" ]; then
57
+ LOCK_TIME=$(jq -r '.timestamp // 0' "$LOCK_JSON" 2>/dev/null || echo 0)
58
+ NOW=$(date +%s)
59
+ AGE=$((NOW - LOCK_TIME))
60
+
61
+ LOCK_AGENT=$(jq -r '.agentId // "unknown"' "$LOCK_JSON" 2>/dev/null || echo "unknown")
62
+
63
+ # Same agent — allow reentry
64
+ if [ "$LOCK_AGENT" = "$SESSION_ID" ]; then
65
+ # Refresh timestamp
66
+ printf '{"agentId":"%s","timestamp":%d,"command":"%s"}' "$SESSION_ID" "$NOW" "$CMD" > "$LOCK_JSON"
67
+ exit 0
68
+ fi
69
+
70
+ # Stale lock — clean and retry
71
+ if [ "$AGE" -gt "$STALE_TIMEOUT" ]; then
72
+ rm -rf "$LOCK_DIR"
73
+ if mkdir "$LOCK_DIR" 2>/dev/null; then
74
+ printf '{"agentId":"%s","timestamp":%d,"command":"%s"}' "$SESSION_ID" "$NOW" "$CMD" > "$LOCK_JSON"
75
+ exit 0
76
+ fi
77
+ fi
78
+ fi
79
+
80
+ # Lock held by another active agent — block
81
+ LOCK_AGENT=$(jq -r '.agentId // "another agent"' "$LOCK_JSON" 2>/dev/null || echo "another agent")
82
+ jq -n --arg agent "$LOCK_AGENT" '{
83
+ continue: false,
84
+ stopReason: ("BLOCKED: Another agent (" + $agent + ") is modifying lockfiles. Wait for it to finish, then retry. Lock auto-expires after 30s if the agent crashes.")
85
+ }'
@@ -12,5 +12,12 @@
12
12
  "ux-touch-targets",
13
13
  "no-ai-attribution"
14
14
  ],
15
- "composedFrom": ["typescript-safety", "a11y", "css-discipline", "clean-commits", "yolo-safety"]
15
+ "composedFrom": [
16
+ "typescript-safety",
17
+ "a11y",
18
+ "css-discipline",
19
+ "clean-commits",
20
+ "safety",
21
+ "yolo-safety"
22
+ ]
16
23
  }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Hook pack graduation — promote/demote action levels.
3
+ * remind → warn → block (promote)
4
+ * block → warn → remind (demote)
5
+ */
6
+ import { writeFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { getPack } from './registry.js';
9
+ import type { HookPackManifest } from './registry.js';
10
+
11
+ const LEVELS = ['remind', 'warn', 'block'] as const;
12
+ type ActionLevel = (typeof LEVELS)[number];
13
+
14
+ export interface GraduationResult {
15
+ packName: string;
16
+ previousLevel: ActionLevel;
17
+ newLevel: ActionLevel;
18
+ manifestPath: string;
19
+ }
20
+
21
+ function getCurrentLevel(manifest: HookPackManifest): ActionLevel {
22
+ const level = manifest.actionLevel;
23
+ if (level && (LEVELS as readonly string[]).includes(level)) return level as ActionLevel;
24
+ return 'remind'; // default
25
+ }
26
+
27
+ export function promotePack(packName: string): GraduationResult {
28
+ const pack = getPack(packName);
29
+ if (!pack) throw new Error(`Unknown hook pack: "${packName}"`);
30
+
31
+ const manifestPath = join(pack.dir, 'manifest.json');
32
+ const manifest = pack.manifest;
33
+ const current = getCurrentLevel(manifest);
34
+ const currentIndex = LEVELS.indexOf(current);
35
+
36
+ if (currentIndex >= LEVELS.length - 1) {
37
+ throw new Error(`Pack "${packName}" is already at maximum level: ${current}`);
38
+ }
39
+
40
+ const newLevel = LEVELS[currentIndex + 1];
41
+ manifest.actionLevel = newLevel;
42
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
43
+
44
+ return { packName, previousLevel: current, newLevel, manifestPath };
45
+ }
46
+
47
+ export function demotePack(packName: string): GraduationResult {
48
+ const pack = getPack(packName);
49
+ if (!pack) throw new Error(`Unknown hook pack: "${packName}"`);
50
+
51
+ const manifestPath = join(pack.dir, 'manifest.json');
52
+ const manifest = pack.manifest;
53
+ const current = getCurrentLevel(manifest);
54
+ const currentIndex = LEVELS.indexOf(current);
55
+
56
+ if (currentIndex <= 0) {
57
+ throw new Error(`Pack "${packName}" is already at minimum level: ${current}`);
58
+ }
59
+
60
+ const newLevel = LEVELS[currentIndex - 1];
61
+ manifest.actionLevel = newLevel;
62
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
63
+
64
+ return { packName, previousLevel: current, newLevel, manifestPath };
65
+ }
@@ -195,7 +195,9 @@ export function installPack(
195
195
  mkdirSync(destDir, { recursive: true });
196
196
  const destPath = join(destDir, file);
197
197
  copyFileSync(sourcePath, destPath);
198
- chmodSync(destPath, 0o755);
198
+ if (process.platform !== 'win32') {
199
+ chmodSync(destPath, 0o755);
200
+ }
199
201
  installedScripts.push(`${targetDir}/${file}`);
200
202
  }
201
203
  const lcHooks = resolveLifecycleHooks(packName);
@@ -0,0 +1,37 @@
1
+ # Marketing Research Hook Pack
2
+
3
+ Example hook demonstrating the skill-to-hook conversion workflow. Automatically reminds you to check brand guidelines and A/B testing data when editing marketing files.
4
+
5
+ ## Conversion Score
6
+
7
+ | Dimension | Score | Rationale |
8
+ | ----------------- | ----- | ----------------------------------------------------------- |
9
+ | Frequency | HIGH | 4+ manual checks per session when editing marketing content |
10
+ | Event Correlation | HIGH | Always triggers on Write/Edit to marketing files |
11
+ | Determinism | HIGH | Lookups and context injection, not creative guidance |
12
+ | Autonomy | HIGH | No interactive decisions needed |
13
+
14
+ **Score: 4/4 — Strong candidate**
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ soleri hooks add-pack marketing-research
20
+ ```
21
+
22
+ ## What It Does
23
+
24
+ Fires on `Write` and `Edit` tool calls when the target file matches marketing patterns:
25
+
26
+ - `**/marketing/**`
27
+ - `**/*marketing*`
28
+ - `**/campaign*/**`
29
+
30
+ Injects a reminder with brand guidelines context. Does not block — action level is `remind`.
31
+
32
+ ## Graduation
33
+
34
+ ```bash
35
+ soleri hooks promote marketing-research # remind → warn
36
+ soleri hooks promote marketing-research # warn → block
37
+ ```
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "marketing-research",
3
+ "version": "1.0.0",
4
+ "description": "Auto-research hook for marketing files — reminds to check brand guidelines, A/B testing data, and audience segmentation before editing marketing content",
5
+ "hooks": [],
6
+ "scripts": [
7
+ {
8
+ "name": "marketing-research",
9
+ "file": "marketing-research.sh",
10
+ "targetDir": "hooks"
11
+ }
12
+ ],
13
+ "lifecycleHooks": [
14
+ {
15
+ "event": "PreToolUse",
16
+ "matcher": "Write|Edit",
17
+ "type": "command",
18
+ "command": "sh ~/.claude/hooks/marketing-research.sh",
19
+ "timeout": 10,
20
+ "statusMessage": "Checking marketing context..."
21
+ }
22
+ ],
23
+ "actionLevel": "remind"
24
+ }
@@ -0,0 +1,70 @@
1
+ #!/bin/sh
2
+ # Marketing Research Hook for Claude Code (Soleri Hook Pack: marketing-research)
3
+ # PreToolUse -> Write|Edit: reminds to check brand guidelines, A/B testing data,
4
+ # and audience segmentation before editing marketing content.
5
+ #
6
+ # Matched file patterns:
7
+ # - **/marketing/**
8
+ # - **/*marketing*
9
+ # - **/campaign*/**
10
+ #
11
+ # Dependencies: jq (required)
12
+ # POSIX sh compatible — no bash-specific features.
13
+
14
+ set -eu
15
+
16
+ INPUT=$(cat)
17
+
18
+ # Extract tool name
19
+ TOOL_NAME=$(printf '%s' "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
20
+
21
+ # Only act on Write or Edit
22
+ case "$TOOL_NAME" in
23
+ Write|Edit) ;;
24
+ *) exit 0 ;;
25
+ esac
26
+
27
+ # Extract file_path from tool_input
28
+ FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
29
+
30
+ # No file path — let it through
31
+ if [ -z "$FILE_PATH" ]; then
32
+ exit 0
33
+ fi
34
+
35
+ # Check if file matches marketing patterns
36
+ IS_MARKETING=false
37
+
38
+ # Pattern: **/marketing/** — file is inside a marketing directory
39
+ case "$FILE_PATH" in
40
+ */marketing/*) IS_MARKETING=true ;;
41
+ esac
42
+
43
+ # Pattern: **/*marketing* — filename or path component contains "marketing"
44
+ if [ "$IS_MARKETING" = false ]; then
45
+ BASENAME=$(basename "$FILE_PATH")
46
+ case "$BASENAME" in
47
+ *marketing*) IS_MARKETING=true ;;
48
+ esac
49
+ fi
50
+
51
+ # Pattern: **/campaign*/** — file is inside a campaign* directory
52
+ if [ "$IS_MARKETING" = false ]; then
53
+ # Check if any path component starts with "campaign"
54
+ if printf '%s' "$FILE_PATH" | grep -qE '/(campaign[^/]*)/'; then
55
+ IS_MARKETING=true
56
+ fi
57
+ fi
58
+
59
+ # Not a marketing file — let it through
60
+ if [ "$IS_MARKETING" = false ]; then
61
+ exit 0
62
+ fi
63
+
64
+ # Output remind JSON
65
+ jq -n \
66
+ --arg file "$FILE_PATH" \
67
+ '{
68
+ continue: true,
69
+ message: ("Marketing file detected: " + $file + "\n\nBefore editing, consider checking:\n- Brand guidelines — tone, voice, approved terminology\n- A/B testing data — what messaging has performed well\n- Audience segmentation — who is the target for this content\n- Campaign calendar — timing and coordination with other assets")
70
+ }'
@@ -30,6 +30,7 @@ export interface HookPackManifest {
30
30
  scripts?: HookPackScript[];
31
31
  lifecycleHooks?: HookPackLifecycleHook[];
32
32
  source?: 'built-in' | 'local';
33
+ actionLevel?: 'remind' | 'warn' | 'block';
33
34
  }
34
35
 
35
36
  const __filename = fileURLToPath(import.meta.url);
@@ -97,7 +98,9 @@ export function getPack(name: string): { manifest: HookPackManifest; dir: string
97
98
  export function getInstalledPacks(): string[] {
98
99
  const claudeDir = join(homedir(), '.claude');
99
100
  const packs = listPacks();
100
- const installed: string[] = [];
101
+ const installed = new Set<string>();
102
+
103
+ // First pass: detect directly installed packs (hooks or scripts)
101
104
  for (const pack of packs) {
102
105
  if (pack.hooks.length === 0) {
103
106
  if (pack.scripts && pack.scripts.length > 0) {
@@ -105,7 +108,7 @@ export function getInstalledPacks(): string[] {
105
108
  existsSync(join(claudeDir, script.targetDir, script.file)),
106
109
  );
107
110
  if (allScripts) {
108
- installed.push(pack.name);
111
+ installed.add(pack.name);
109
112
  }
110
113
  }
111
114
  continue;
@@ -114,8 +117,19 @@ export function getInstalledPacks(): string[] {
114
117
  existsSync(join(claudeDir, `hookify.${hook}.local.md`)),
115
118
  );
116
119
  if (allPresent) {
117
- installed.push(pack.name);
120
+ installed.add(pack.name);
118
121
  }
119
122
  }
120
- return installed;
123
+
124
+ // Second pass: composed packs are installed if all sub-packs are installed
125
+ for (const pack of packs) {
126
+ if (pack.composedFrom && pack.composedFrom.length > 0 && !installed.has(pack.name)) {
127
+ const allSubsInstalled = pack.composedFrom.every((sub) => installed.has(sub));
128
+ if (allSubsInstalled) {
129
+ installed.add(pack.name);
130
+ }
131
+ }
132
+ }
133
+
134
+ return Array.from(installed);
121
135
  }
@@ -0,0 +1,50 @@
1
+ # Safety Hook Pack
2
+
3
+ Anti-deletion safety net for Claude Code. Intercepts destructive commands, stages files before deletion, and blocks dangerous operations.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ soleri hooks add-pack safety
9
+ ```
10
+
11
+ ## What It Intercepts
12
+
13
+ | Command | Action |
14
+ | -------------------------- | -------------------------------------------- |
15
+ | `rm` / `rmdir` | Copies files to staging, then blocks |
16
+ | `git push --force` | Blocks (use `--force-with-lease` instead) |
17
+ | `git reset --hard` | Blocks (use `git stash` first) |
18
+ | `git clean` | Blocks (use `git stash --include-untracked`) |
19
+ | `git checkout -- .` | Blocks |
20
+ | `git restore .` | Blocks |
21
+ | `mv ~/projects/...` | Blocks |
22
+ | `DROP TABLE` | Blocks |
23
+ | `docker rm` / `docker rmi` | Blocks |
24
+
25
+ ## Where Backups Go
26
+
27
+ Staged files are saved to `~/.soleri/staging/<timestamp>/` with directory structure preserved.
28
+
29
+ Backups use rsync (excludes `node_modules`, `dist`, `.git`) when available, falls back to `cp -R`.
30
+
31
+ ## Restore
32
+
33
+ ```bash
34
+ soleri staging list # see available backups
35
+ soleri staging restore # restore from a backup
36
+ soleri staging clean # manually clean old backups
37
+ ```
38
+
39
+ ## Auto-Cleanup
40
+
41
+ Backups older than 7 days are automatically deleted on each hook invocation. No manual cleanup needed for normal usage.
42
+
43
+ ## Dependencies
44
+
45
+ - `jq` (required for JSON parsing)
46
+ - POSIX sh compatible — works on macOS and Linux
47
+
48
+ ## False Positive Prevention
49
+
50
+ The hook strips here-documents and quoted strings before pattern matching. Commands like `gh issue comment --body "rm -rf explanation"` will not trigger a false positive.
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "safety",
3
+ "version": "1.0.0",
4
+ "description": "Anti-deletion safety net — intercepts destructive commands (rm, git push --force, git reset --hard, git clean, drop table, docker rm), stages files before deletion, blocks everything else",
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
+ ]
23
+ }