@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,225 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { execSync } from 'node:child_process';
3
+ import { mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { createHash } from 'node:crypto';
6
+ import { tmpdir } from 'node:os';
7
+
8
+ const SCRIPTS_DIR = join(__dirname, '..', 'hook-packs', 'flock-guard', 'scripts');
9
+ const PRE_SCRIPT = join(SCRIPTS_DIR, 'flock-guard-pre.sh');
10
+ const POST_SCRIPT = join(SCRIPTS_DIR, 'flock-guard-post.sh');
11
+
12
+ // The scripts use `git rev-parse --show-toplevel` which resolves to the repo root.
13
+ // Compute the same hash the scripts will produce.
14
+ const PROJECT_ROOT = execSync('git rev-parse --show-toplevel', {
15
+ cwd: join(__dirname, '..'),
16
+ encoding: 'utf-8',
17
+ }).trim();
18
+ const PROJECT_HASH = execSync(`printf '%s' '${PROJECT_ROOT}' | shasum | cut -c1-8`, {
19
+ encoding: 'utf-8',
20
+ }).trim();
21
+ // Scripts use ${TMPDIR:-${TEMP:-/tmp}} — match that resolution for the test environment
22
+ const LOCK_DIR = `${process.env.TMPDIR || process.env.TEMP || tmpdir()}/soleri-guard-${PROJECT_HASH}.lock`;
23
+
24
+ function makePayload(command: string): string {
25
+ return JSON.stringify({ tool_name: 'Bash', tool_input: { command } });
26
+ }
27
+
28
+ function runPre(
29
+ command: string,
30
+ env?: Record<string, string>,
31
+ ): { stdout: string; exitCode: number } {
32
+ try {
33
+ const stdout = execSync(
34
+ `printf '%s' '${escapeShell(makePayload(command))}' | sh '${PRE_SCRIPT}'`,
35
+ {
36
+ encoding: 'utf-8',
37
+ stdio: 'pipe',
38
+ cwd: PROJECT_ROOT,
39
+ env: { ...process.env, ...env },
40
+ },
41
+ );
42
+ return { stdout, exitCode: 0 };
43
+ } catch (err: any) {
44
+ return { stdout: err.stdout ?? '', exitCode: err.status ?? 1 };
45
+ }
46
+ }
47
+
48
+ function runPost(
49
+ command: string,
50
+ env?: Record<string, string>,
51
+ ): { stdout: string; exitCode: number } {
52
+ try {
53
+ const stdout = execSync(
54
+ `printf '%s' '${escapeShell(makePayload(command))}' | sh '${POST_SCRIPT}'`,
55
+ {
56
+ encoding: 'utf-8',
57
+ stdio: 'pipe',
58
+ cwd: PROJECT_ROOT,
59
+ env: { ...process.env, ...env },
60
+ },
61
+ );
62
+ return { stdout, exitCode: 0 };
63
+ } catch (err: any) {
64
+ return { stdout: err.stdout ?? '', exitCode: err.status ?? 1 };
65
+ }
66
+ }
67
+
68
+ function escapeShell(s: string): string {
69
+ // Escape single quotes for use inside single-quoted shell string
70
+ return s.replace(/'/g, "'\\''");
71
+ }
72
+
73
+ function cleanLock(): void {
74
+ if (existsSync(LOCK_DIR)) {
75
+ rmSync(LOCK_DIR, { recursive: true, force: true });
76
+ }
77
+ }
78
+
79
+ describe('flock-guard hook pack', () => {
80
+ afterEach(() => {
81
+ cleanLock();
82
+ });
83
+
84
+ // 1. Pre: allows non-lockfile commands
85
+ it('pre: allows non-lockfile commands (exit 0, no output)', () => {
86
+ const { stdout, exitCode } = runPre('echo hello');
87
+ expect(exitCode).toBe(0);
88
+ expect(stdout.trim()).toBe('');
89
+ expect(existsSync(LOCK_DIR)).toBe(false);
90
+ });
91
+
92
+ // 2. Pre: acquires lock on npm install
93
+ it('pre: acquires lock on npm install', () => {
94
+ const sessionId = `test-acquire-${Date.now()}`;
95
+ const { exitCode } = runPre('npm install', { CLAUDE_SESSION_ID: sessionId });
96
+ expect(exitCode).toBe(0);
97
+ expect(existsSync(LOCK_DIR)).toBe(true);
98
+ });
99
+
100
+ // 3. Pre: lock dir contains valid JSON with agentId and timestamp
101
+ it('pre: lock dir contains valid JSON with agentId and timestamp', () => {
102
+ const sessionId = `test-json-${Date.now()}`;
103
+ runPre('npm install', { CLAUDE_SESSION_ID: sessionId });
104
+
105
+ const lockJson = JSON.parse(readFileSync(join(LOCK_DIR, 'lock.json'), 'utf-8'));
106
+ expect(lockJson).toHaveProperty('agentId', sessionId);
107
+ expect(lockJson).toHaveProperty('timestamp');
108
+ expect(typeof lockJson.timestamp).toBe('number');
109
+ expect(lockJson.timestamp).toBeGreaterThan(0);
110
+ });
111
+
112
+ // 4. Post: releases lock after npm install
113
+ it('post: releases lock after npm install', () => {
114
+ const sessionId = `test-release-${Date.now()}`;
115
+ runPre('npm install', { CLAUDE_SESSION_ID: sessionId });
116
+ expect(existsSync(LOCK_DIR)).toBe(true);
117
+
118
+ const { exitCode } = runPost('npm install', { CLAUDE_SESSION_ID: sessionId });
119
+ expect(exitCode).toBe(0);
120
+ expect(existsSync(LOCK_DIR)).toBe(false);
121
+ });
122
+
123
+ // 5. Pre: blocks when lock held by another agent
124
+ it('pre: blocks when lock held by another agent', () => {
125
+ // Manually create lock with a different agentId
126
+ mkdirSync(LOCK_DIR, { recursive: true });
127
+ const now = Math.floor(Date.now() / 1000);
128
+ writeFileSync(
129
+ join(LOCK_DIR, 'lock.json'),
130
+ JSON.stringify({ agentId: 'other-agent-999', timestamp: now, command: 'npm install' }),
131
+ );
132
+
133
+ const mySession = `test-blocked-${Date.now()}`;
134
+ const { stdout, exitCode } = runPre('npm install', { CLAUDE_SESSION_ID: mySession });
135
+
136
+ // Script exits 0 but outputs JSON with continue: false
137
+ expect(exitCode).toBe(0);
138
+ const output = JSON.parse(stdout.trim());
139
+ expect(output.continue).toBe(false);
140
+ expect(output.stopReason).toContain('BLOCKED');
141
+ expect(output.stopReason).toContain('other-agent-999');
142
+ });
143
+
144
+ // 6. Pre: cleans stale lock (timestamp older than 30s)
145
+ it('pre: cleans stale lock and acquires', () => {
146
+ mkdirSync(LOCK_DIR, { recursive: true });
147
+ const staleTime = Math.floor(Date.now() / 1000) - 60; // 60s ago
148
+ writeFileSync(
149
+ join(LOCK_DIR, 'lock.json'),
150
+ JSON.stringify({ agentId: 'stale-agent', timestamp: staleTime, command: 'npm install' }),
151
+ );
152
+
153
+ const mySession = `test-stale-${Date.now()}`;
154
+ const { stdout, exitCode } = runPre('npm install', { CLAUDE_SESSION_ID: mySession });
155
+ expect(exitCode).toBe(0);
156
+ // Should not contain "continue: false" — lock was stale and cleaned
157
+ if (stdout.trim()) {
158
+ const output = JSON.parse(stdout.trim());
159
+ expect(output.continue).not.toBe(false);
160
+ }
161
+ // Lock should now be held by our session
162
+ expect(existsSync(LOCK_DIR)).toBe(true);
163
+ const lockJson = JSON.parse(readFileSync(join(LOCK_DIR, 'lock.json'), 'utf-8'));
164
+ expect(lockJson.agentId).toBe(mySession);
165
+ });
166
+
167
+ // 7. Pre: allows same agent reentry
168
+ it('pre: allows same agent reentry', () => {
169
+ const sessionId = `test-reentry-${Date.now()}`;
170
+ const env = { CLAUDE_SESSION_ID: sessionId };
171
+
172
+ // First acquisition
173
+ const first = runPre('npm install', env);
174
+ expect(first.exitCode).toBe(0);
175
+ expect(existsSync(LOCK_DIR)).toBe(true);
176
+
177
+ // Second acquisition with same session — should succeed (reentry)
178
+ const second = runPre('npm install', env);
179
+ expect(second.exitCode).toBe(0);
180
+ // No "continue: false" in output
181
+ if (second.stdout.trim()) {
182
+ const output = JSON.parse(second.stdout.trim());
183
+ expect(output.continue).not.toBe(false);
184
+ }
185
+ });
186
+
187
+ // 8. Post: only releases own lock (does not release lock held by other agent)
188
+ it('post: only releases own lock — does not remove lock held by another agent', () => {
189
+ // Create lock with a different agent
190
+ mkdirSync(LOCK_DIR, { recursive: true });
191
+ const now = Math.floor(Date.now() / 1000);
192
+ writeFileSync(
193
+ join(LOCK_DIR, 'lock.json'),
194
+ JSON.stringify({ agentId: 'other-agent-777', timestamp: now, command: 'npm install' }),
195
+ );
196
+
197
+ const mySession = `test-norelease-${Date.now()}`;
198
+ const { exitCode } = runPost('npm install', { CLAUDE_SESSION_ID: mySession });
199
+ expect(exitCode).toBe(0);
200
+ // Lock dir should still exist — we don't own it
201
+ expect(existsSync(LOCK_DIR)).toBe(true);
202
+ const lockJson = JSON.parse(readFileSync(join(LOCK_DIR, 'lock.json'), 'utf-8'));
203
+ expect(lockJson.agentId).toBe('other-agent-777');
204
+ });
205
+
206
+ // 9. Pre: detects other lockfile commands (yarn, pnpm, cargo, pip)
207
+ it('pre: detects yarn, pnpm install, cargo build, pip install', () => {
208
+ const commands = ['yarn', 'yarn install', 'pnpm install', 'cargo build', 'pip install'];
209
+ for (const cmd of commands) {
210
+ cleanLock();
211
+ const sessionId = `test-detect-${Date.now()}`;
212
+ const { exitCode } = runPre(cmd, { CLAUDE_SESSION_ID: sessionId });
213
+ expect(exitCode).toBe(0);
214
+ expect(existsSync(LOCK_DIR)).toBe(true);
215
+ cleanLock();
216
+ }
217
+ });
218
+
219
+ // 10. Post: ignores non-lockfile commands
220
+ it('post: ignores non-lockfile commands (no crash, no lock interaction)', () => {
221
+ const { exitCode, stdout } = runPost('echo hello');
222
+ expect(exitCode).toBe(0);
223
+ expect(stdout.trim()).toBe('');
224
+ });
225
+ });
@@ -0,0 +1,199 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+
6
+ // Mock getPack before importing graduation
7
+ const mockGetPack = vi.fn();
8
+ vi.mock('../hook-packs/registry.js', () => ({
9
+ getPack: (...args: unknown[]) => mockGetPack(...args),
10
+ }));
11
+
12
+ import { promotePack, demotePack } from '../hook-packs/graduation.js';
13
+
14
+ describe('graduation — promote/demote action levels', () => {
15
+ let tempDir: string;
16
+
17
+ function createPackDir(actionLevel?: string): string {
18
+ const packDir = join(tempDir, 'test-pack');
19
+ mkdirSync(packDir, { recursive: true });
20
+ const manifest: Record<string, unknown> = {
21
+ name: 'test-pack',
22
+ description: 'A test hook pack',
23
+ hooks: ['PreToolUse'],
24
+ version: '1.0.0',
25
+ };
26
+ if (actionLevel !== undefined) {
27
+ manifest.actionLevel = actionLevel;
28
+ }
29
+ writeFileSync(join(packDir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
30
+ return packDir;
31
+ }
32
+
33
+ function readManifest(packDir: string): Record<string, unknown> {
34
+ return JSON.parse(readFileSync(join(packDir, 'manifest.json'), 'utf-8'));
35
+ }
36
+
37
+ beforeEach(() => {
38
+ tempDir = join(tmpdir(), `graduation-test-${Date.now()}`);
39
+ mkdirSync(tempDir, { recursive: true });
40
+ mockGetPack.mockReset();
41
+ });
42
+
43
+ afterEach(() => {
44
+ rmSync(tempDir, { recursive: true, force: true });
45
+ });
46
+
47
+ describe('promotePack', () => {
48
+ it('should promote remind → warn', () => {
49
+ const packDir = createPackDir('remind');
50
+ mockGetPack.mockReturnValue({
51
+ manifest: JSON.parse(readFileSync(join(packDir, 'manifest.json'), 'utf-8')),
52
+ dir: packDir,
53
+ });
54
+
55
+ const result = promotePack('test-pack');
56
+
57
+ expect(result.previousLevel).toBe('remind');
58
+ expect(result.newLevel).toBe('warn');
59
+ expect(readManifest(packDir).actionLevel).toBe('warn');
60
+ });
61
+
62
+ it('should promote warn → block', () => {
63
+ const packDir = createPackDir('warn');
64
+ mockGetPack.mockReturnValue({
65
+ manifest: JSON.parse(readFileSync(join(packDir, 'manifest.json'), 'utf-8')),
66
+ dir: packDir,
67
+ });
68
+
69
+ const result = promotePack('test-pack');
70
+
71
+ expect(result.previousLevel).toBe('warn');
72
+ expect(result.newLevel).toBe('block');
73
+ expect(readManifest(packDir).actionLevel).toBe('block');
74
+ });
75
+
76
+ it('should throw at maximum level (block)', () => {
77
+ const packDir = createPackDir('block');
78
+ mockGetPack.mockReturnValue({
79
+ manifest: JSON.parse(readFileSync(join(packDir, 'manifest.json'), 'utf-8')),
80
+ dir: packDir,
81
+ });
82
+
83
+ expect(() => promotePack('test-pack')).toThrow('already at maximum level: block');
84
+ });
85
+
86
+ it('should default to remind when actionLevel is missing', () => {
87
+ const packDir = createPackDir();
88
+ mockGetPack.mockReturnValue({
89
+ manifest: JSON.parse(readFileSync(join(packDir, 'manifest.json'), 'utf-8')),
90
+ dir: packDir,
91
+ });
92
+
93
+ const result = promotePack('test-pack');
94
+
95
+ expect(result.previousLevel).toBe('remind');
96
+ expect(result.newLevel).toBe('warn');
97
+ });
98
+
99
+ it('should throw for unknown pack', () => {
100
+ mockGetPack.mockReturnValue(null);
101
+
102
+ expect(() => promotePack('nonexistent')).toThrow('Unknown hook pack: "nonexistent"');
103
+ });
104
+ });
105
+
106
+ describe('demotePack', () => {
107
+ it('should demote block → warn', () => {
108
+ const packDir = createPackDir('block');
109
+ mockGetPack.mockReturnValue({
110
+ manifest: JSON.parse(readFileSync(join(packDir, 'manifest.json'), 'utf-8')),
111
+ dir: packDir,
112
+ });
113
+
114
+ const result = demotePack('test-pack');
115
+
116
+ expect(result.previousLevel).toBe('block');
117
+ expect(result.newLevel).toBe('warn');
118
+ expect(readManifest(packDir).actionLevel).toBe('warn');
119
+ });
120
+
121
+ it('should demote warn → remind', () => {
122
+ const packDir = createPackDir('warn');
123
+ mockGetPack.mockReturnValue({
124
+ manifest: JSON.parse(readFileSync(join(packDir, 'manifest.json'), 'utf-8')),
125
+ dir: packDir,
126
+ });
127
+
128
+ const result = demotePack('test-pack');
129
+
130
+ expect(result.previousLevel).toBe('warn');
131
+ expect(result.newLevel).toBe('remind');
132
+ expect(readManifest(packDir).actionLevel).toBe('remind');
133
+ });
134
+
135
+ it('should throw at minimum level (remind)', () => {
136
+ const packDir = createPackDir('remind');
137
+ mockGetPack.mockReturnValue({
138
+ manifest: JSON.parse(readFileSync(join(packDir, 'manifest.json'), 'utf-8')),
139
+ dir: packDir,
140
+ });
141
+
142
+ expect(() => demotePack('test-pack')).toThrow('already at minimum level: remind');
143
+ });
144
+
145
+ it('should throw for unknown pack', () => {
146
+ mockGetPack.mockReturnValue(null);
147
+
148
+ expect(() => demotePack('nonexistent')).toThrow('Unknown hook pack: "nonexistent"');
149
+ });
150
+ });
151
+
152
+ describe('manifest persistence', () => {
153
+ it('should write updated manifest to disk', () => {
154
+ const packDir = createPackDir('remind');
155
+ mockGetPack.mockReturnValue({
156
+ manifest: JSON.parse(readFileSync(join(packDir, 'manifest.json'), 'utf-8')),
157
+ dir: packDir,
158
+ });
159
+
160
+ promotePack('test-pack');
161
+
162
+ const manifest = readManifest(packDir);
163
+ expect(manifest.actionLevel).toBe('warn');
164
+ expect(manifest.name).toBe('test-pack');
165
+ expect(manifest.description).toBe('A test hook pack');
166
+ expect(manifest.version).toBe('1.0.0');
167
+ });
168
+
169
+ it('should preserve all manifest fields after promotion', () => {
170
+ const packDir = createPackDir('remind');
171
+ const originalManifest = readManifest(packDir);
172
+ mockGetPack.mockReturnValue({
173
+ manifest: JSON.parse(readFileSync(join(packDir, 'manifest.json'), 'utf-8')),
174
+ dir: packDir,
175
+ });
176
+
177
+ promotePack('test-pack');
178
+
179
+ const updatedManifest = readManifest(packDir);
180
+ expect(updatedManifest.name).toBe(originalManifest.name);
181
+ expect(updatedManifest.description).toBe(originalManifest.description);
182
+ expect(updatedManifest.hooks).toEqual(originalManifest.hooks);
183
+ expect(updatedManifest.version).toBe(originalManifest.version);
184
+ expect(updatedManifest.actionLevel).toBe('warn');
185
+ });
186
+
187
+ it('should return the manifest path in the result', () => {
188
+ const packDir = createPackDir('remind');
189
+ mockGetPack.mockReturnValue({
190
+ manifest: JSON.parse(readFileSync(join(packDir, 'manifest.json'), 'utf-8')),
191
+ dir: packDir,
192
+ });
193
+
194
+ const result = promotePack('test-pack');
195
+
196
+ expect(result.manifestPath).toBe(join(packDir, 'manifest.json'));
197
+ });
198
+ });
199
+ });
@@ -23,15 +23,18 @@ describe('hook-packs', () => {
23
23
  });
24
24
 
25
25
  describe('registry', () => {
26
- it('should list all 6 built-in packs', () => {
26
+ it('should list all 9 built-in packs', () => {
27
27
  const packs = listPacks();
28
- expect(packs.length).toBe(6);
28
+ expect(packs.length).toBe(9);
29
29
  const names = packs.map((p) => p.name).sort();
30
30
  expect(names).toEqual([
31
31
  'a11y',
32
32
  'clean-commits',
33
33
  'css-discipline',
34
+ 'flock-guard',
34
35
  'full',
36
+ 'marketing-research',
37
+ 'safety',
35
38
  'typescript-safety',
36
39
  'yolo-safety',
37
40
  ]);
@@ -49,7 +52,7 @@ describe('hook-packs', () => {
49
52
  expect(getPack('nonexistent')).toBeNull();
50
53
  });
51
54
 
52
- it('should return full pack with composedFrom including yolo-safety', () => {
55
+ it('should return full pack with composedFrom including safety and yolo-safety', () => {
53
56
  const pack = getPack('full');
54
57
  expect(pack).not.toBeNull();
55
58
  expect(pack!.manifest.composedFrom).toEqual([
@@ -57,6 +60,7 @@ describe('hook-packs', () => {
57
60
  'a11y',
58
61
  'css-discipline',
59
62
  'clean-commits',
63
+ 'safety',
60
64
  'yolo-safety',
61
65
  ]);
62
66
  expect(pack!.manifest.hooks).toHaveLength(8);
@@ -64,19 +68,29 @@ describe('hook-packs', () => {
64
68
 
65
69
  it('should return empty installed packs when none installed', () => {
66
70
  const installed = getInstalledPacks();
67
- expect(installed.filter((p) => p !== 'yolo-safety')).toEqual([]);
71
+ expect(installed.filter((p) => p !== 'yolo-safety' && p !== 'safety')).toEqual([]);
68
72
  });
69
73
 
70
- it('should get yolo-safety pack with scripts and lifecycleHooks', () => {
71
- const pack = getPack('yolo-safety');
74
+ it('should get safety pack with scripts and lifecycleHooks', () => {
75
+ const pack = getPack('safety');
72
76
  expect(pack).not.toBeNull();
73
- expect(pack!.manifest.name).toBe('yolo-safety');
77
+ expect(pack!.manifest.name).toBe('safety');
74
78
  expect(pack!.manifest.hooks).toEqual([]);
75
79
  expect(pack!.manifest.scripts).toHaveLength(1);
76
80
  expect(pack!.manifest.scripts![0].name).toBe('anti-deletion');
77
81
  expect(pack!.manifest.lifecycleHooks).toHaveLength(1);
78
82
  expect(pack!.manifest.lifecycleHooks![0].event).toBe('PreToolUse');
79
83
  });
84
+
85
+ it('should get yolo-safety pack as composed from safety', () => {
86
+ const pack = getPack('yolo-safety');
87
+ expect(pack).not.toBeNull();
88
+ expect(pack!.manifest.name).toBe('yolo-safety');
89
+ expect(pack!.manifest.hooks).toEqual([]);
90
+ expect(pack!.manifest.composedFrom).toEqual(['safety']);
91
+ expect(pack!.manifest.scripts).toBeUndefined();
92
+ expect(pack!.manifest.lifecycleHooks).toBeUndefined();
93
+ });
80
94
  });
81
95
 
82
96
  describe('installer', () => {
@@ -150,8 +164,8 @@ describe('hook-packs', () => {
150
164
  expect(() => removePack('nonexistent')).toThrow('Unknown hook pack: "nonexistent"');
151
165
  });
152
166
 
153
- it('should install yolo-safety pack with scripts and lifecycle hooks', () => {
154
- const result = installPack('yolo-safety');
167
+ it('should install safety pack with scripts and lifecycle hooks', () => {
168
+ const result = installPack('safety');
155
169
  expect(result.installed).toEqual([]);
156
170
  expect(result.scripts).toHaveLength(1);
157
171
  expect(result.scripts[0]).toBe('hooks/anti-deletion.sh');
@@ -163,12 +177,12 @@ describe('hook-packs', () => {
163
177
  expect(settings.hooks).toBeDefined();
164
178
  expect(settings.hooks.PreToolUse).toHaveLength(1);
165
179
  expect(settings.hooks.PreToolUse[0].command).toBe('sh ~/.claude/hooks/anti-deletion.sh');
166
- expect(settings.hooks.PreToolUse[0]._soleriPack).toBe('yolo-safety');
180
+ expect(settings.hooks.PreToolUse[0]._soleriPack).toBe('safety');
167
181
  });
168
182
 
169
- it('should remove yolo-safety pack including scripts and lifecycle hooks', () => {
170
- installPack('yolo-safety');
171
- const result = removePack('yolo-safety');
183
+ it('should remove safety pack including scripts and lifecycle hooks', () => {
184
+ installPack('safety');
185
+ const result = removePack('safety');
172
186
  expect(result.scripts).toHaveLength(1);
173
187
  expect(result.lifecycleHooks).toHaveLength(1);
174
188
  const claudeDir = join(tempHome, '.claude');
@@ -177,14 +191,24 @@ describe('hook-packs', () => {
177
191
  expect(settings.hooks.PreToolUse).toBeUndefined();
178
192
  });
179
193
 
180
- it('should be idempotent for yolo-safety lifecycle hooks', () => {
181
- installPack('yolo-safety');
182
- const result2 = installPack('yolo-safety');
194
+ it('should be idempotent for safety lifecycle hooks', () => {
195
+ installPack('safety');
196
+ const result2 = installPack('safety');
183
197
  expect(result2.lifecycleHooks).toEqual([]);
184
198
  const claudeDir = join(tempHome, '.claude');
185
199
  const settings = JSON.parse(readFileSync(join(claudeDir, 'settings.json'), 'utf-8'));
186
200
  expect(settings.hooks.PreToolUse).toHaveLength(1);
187
201
  });
202
+
203
+ it('should install yolo-safety via composition from safety', () => {
204
+ const result = installPack('yolo-safety');
205
+ // yolo-safety composes from safety — script and lifecycle come from safety
206
+ expect(result.scripts).toHaveLength(1);
207
+ expect(result.scripts[0]).toBe('hooks/anti-deletion.sh');
208
+ expect(result.lifecycleHooks).toHaveLength(1);
209
+ const claudeDir = join(tempHome, '.claude');
210
+ expect(existsSync(join(claudeDir, 'hooks', 'anti-deletion.sh'))).toBe(true);
211
+ });
188
212
  });
189
213
 
190
214
  describe('isPackInstalled', () => {
@@ -207,9 +231,9 @@ describe('hook-packs', () => {
207
231
  expect(isPackInstalled('nonexistent')).toBe(false);
208
232
  });
209
233
 
210
- it('should detect yolo-safety as installed when script is present', () => {
211
- installPack('yolo-safety');
212
- expect(isPackInstalled('yolo-safety')).toBe(true);
234
+ it('should detect safety as installed when script is present', () => {
235
+ installPack('safety');
236
+ expect(isPackInstalled('safety')).toBe(true);
213
237
  });
214
238
  });
215
239
 
@@ -231,6 +255,7 @@ describe('hook-packs', () => {
231
255
  expect(installed).toContain('a11y');
232
256
  expect(installed).toContain('css-discipline');
233
257
  expect(installed).toContain('clean-commits');
258
+ expect(installed).toContain('safety');
234
259
  expect(installed).toContain('yolo-safety');
235
260
  });
236
261
  });