@soleri/cli 9.3.1 → 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 (88) hide show
  1. package/dist/commands/agent.js +51 -2
  2. package/dist/commands/agent.js.map +1 -1
  3. package/dist/commands/hooks.js +126 -0
  4. package/dist/commands/hooks.js.map +1 -1
  5. package/dist/commands/install.js +5 -0
  6. package/dist/commands/install.js.map +1 -1
  7. package/dist/commands/pack.js +62 -13
  8. package/dist/commands/pack.js.map +1 -1
  9. package/dist/commands/staging.d.ts +49 -0
  10. package/dist/commands/staging.js +108 -18
  11. package/dist/commands/staging.js.map +1 -1
  12. package/dist/commands/yolo.d.ts +2 -0
  13. package/dist/commands/yolo.js +86 -0
  14. package/dist/commands/yolo.js.map +1 -0
  15. package/dist/hook-packs/converter/README.md +99 -0
  16. package/dist/hook-packs/converter/template.d.ts +36 -0
  17. package/dist/hook-packs/converter/template.js +127 -0
  18. package/dist/hook-packs/converter/template.js.map +1 -0
  19. package/dist/hook-packs/converter/template.test.ts +133 -0
  20. package/dist/hook-packs/converter/template.ts +163 -0
  21. package/dist/hook-packs/flock-guard/README.md +65 -0
  22. package/dist/hook-packs/flock-guard/manifest.json +36 -0
  23. package/dist/hook-packs/flock-guard/scripts/flock-guard-post.sh +48 -0
  24. package/dist/hook-packs/flock-guard/scripts/flock-guard-pre.sh +85 -0
  25. package/dist/hook-packs/full/manifest.json +8 -1
  26. package/dist/hook-packs/graduation.d.ts +11 -0
  27. package/dist/hook-packs/graduation.js +48 -0
  28. package/dist/hook-packs/graduation.js.map +1 -0
  29. package/dist/hook-packs/graduation.ts +65 -0
  30. package/dist/hook-packs/installer.js +3 -1
  31. package/dist/hook-packs/installer.js.map +1 -1
  32. package/dist/hook-packs/installer.ts +3 -1
  33. package/dist/hook-packs/marketing-research/README.md +37 -0
  34. package/dist/hook-packs/marketing-research/manifest.json +24 -0
  35. package/dist/hook-packs/marketing-research/scripts/marketing-research.sh +70 -0
  36. package/dist/hook-packs/registry.d.ts +1 -0
  37. package/dist/hook-packs/registry.js +14 -4
  38. package/dist/hook-packs/registry.js.map +1 -1
  39. package/dist/hook-packs/registry.ts +18 -4
  40. package/dist/hook-packs/safety/README.md +50 -0
  41. package/dist/hook-packs/safety/manifest.json +23 -0
  42. package/dist/hook-packs/safety/scripts/anti-deletion.sh +280 -0
  43. package/dist/hook-packs/validator.d.ts +32 -0
  44. package/dist/hook-packs/validator.js +126 -0
  45. package/dist/hook-packs/validator.js.map +1 -0
  46. package/dist/hook-packs/validator.ts +158 -0
  47. package/dist/hook-packs/yolo-safety/manifest.json +3 -19
  48. package/dist/hook-packs/yolo-safety/scripts/anti-deletion.sh +121 -61
  49. package/dist/main.js +2 -0
  50. package/dist/main.js.map +1 -1
  51. package/package.json +1 -1
  52. package/src/__tests__/flock-guard.test.ts +225 -0
  53. package/src/__tests__/graduation.test.ts +199 -0
  54. package/src/__tests__/hook-packs.test.ts +45 -20
  55. package/src/__tests__/hooks-convert.test.ts +342 -0
  56. package/src/__tests__/validator.test.ts +265 -0
  57. package/src/__tests__/wizard-e2e.mjs +1 -1
  58. package/src/commands/agent.ts +65 -2
  59. package/src/commands/hooks.ts +172 -0
  60. package/src/commands/install.ts +6 -0
  61. package/src/commands/pack.ts +80 -14
  62. package/src/commands/staging.ts +143 -20
  63. package/src/commands/yolo.ts +103 -0
  64. package/src/hook-packs/converter/README.md +99 -0
  65. package/src/hook-packs/converter/template.test.ts +133 -0
  66. package/src/hook-packs/converter/template.ts +163 -0
  67. package/src/hook-packs/flock-guard/README.md +65 -0
  68. package/src/hook-packs/flock-guard/manifest.json +36 -0
  69. package/src/hook-packs/flock-guard/scripts/flock-guard-post.sh +48 -0
  70. package/src/hook-packs/flock-guard/scripts/flock-guard-pre.sh +85 -0
  71. package/src/hook-packs/full/manifest.json +8 -1
  72. package/src/hook-packs/graduation.ts +65 -0
  73. package/src/hook-packs/installer.ts +3 -1
  74. package/src/hook-packs/marketing-research/README.md +37 -0
  75. package/src/hook-packs/marketing-research/manifest.json +24 -0
  76. package/src/hook-packs/marketing-research/scripts/marketing-research.sh +70 -0
  77. package/src/hook-packs/registry.ts +18 -4
  78. package/src/hook-packs/safety/README.md +50 -0
  79. package/src/hook-packs/safety/manifest.json +23 -0
  80. package/src/hook-packs/safety/scripts/anti-deletion.sh +280 -0
  81. package/src/hook-packs/validator.ts +158 -0
  82. package/src/hook-packs/yolo-safety/manifest.json +3 -19
  83. package/src/main.ts +2 -0
  84. package/vitest.config.ts +1 -0
  85. package/src/__tests__/archetypes.test.ts +0 -84
  86. package/src/__tests__/create.test.ts +0 -207
  87. package/src/hook-packs/yolo-safety/scripts/anti-deletion.sh +0 -214
  88. package/src/prompts/archetypes.ts +0 -343
@@ -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');
@@ -162,13 +176,13 @@ describe('hook-packs', () => {
162
176
  const settings = JSON.parse(readFileSync(join(claudeDir, 'settings.json'), 'utf-8'));
163
177
  expect(settings.hooks).toBeDefined();
164
178
  expect(settings.hooks.PreToolUse).toHaveLength(1);
165
- expect(settings.hooks.PreToolUse[0].command).toBe('bash ~/.claude/hooks/anti-deletion.sh');
166
- expect(settings.hooks.PreToolUse[0]._soleriPack).toBe('yolo-safety');
179
+ expect(settings.hooks.PreToolUse[0].command).toBe('sh ~/.claude/hooks/anti-deletion.sh');
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
  });
@@ -0,0 +1,342 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdirSync, rmSync, existsSync, readFileSync, statSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import {
6
+ generateHookScript,
7
+ generateManifest,
8
+ HOOK_EVENTS,
9
+ ACTION_LEVELS,
10
+ } from '../hook-packs/converter/template.js';
11
+ import type { HookConversionConfig } from '../hook-packs/converter/template.js';
12
+
13
+ const tempDir = join(tmpdir(), `hooks-convert-test-${Date.now()}`);
14
+
15
+ describe('hooks convert', () => {
16
+ beforeEach(() => {
17
+ mkdirSync(tempDir, { recursive: true });
18
+ });
19
+
20
+ afterEach(() => {
21
+ rmSync(tempDir, { recursive: true, force: true });
22
+ });
23
+
24
+ describe('generateManifest', () => {
25
+ it('should produce valid JSON with correct fields', () => {
26
+ const config: HookConversionConfig = {
27
+ name: 'brand-voice',
28
+ event: 'PreToolUse',
29
+ toolMatcher: 'Write|Edit',
30
+ action: 'remind',
31
+ message: 'Follow brand voice guidelines',
32
+ };
33
+
34
+ const manifest = generateManifest(config);
35
+
36
+ expect(manifest.name).toBe('brand-voice');
37
+ expect(manifest.version).toBe('1.0.0');
38
+ expect(manifest.description).toBe('Follow brand voice guidelines');
39
+ expect(manifest.hooks).toEqual([]);
40
+ expect(manifest.scripts).toHaveLength(1);
41
+ expect(manifest.scripts![0].name).toBe('brand-voice');
42
+ expect(manifest.scripts![0].file).toBe('brand-voice.sh');
43
+ expect(manifest.scripts![0].targetDir).toBe('hooks');
44
+ expect(manifest.lifecycleHooks).toHaveLength(1);
45
+ expect(manifest.lifecycleHooks![0].event).toBe('PreToolUse');
46
+ expect(manifest.lifecycleHooks![0].matcher).toBe('Write|Edit');
47
+ expect(manifest.actionLevel).toBe('remind');
48
+
49
+ // Verify it serializes to valid JSON
50
+ const json = JSON.stringify(manifest);
51
+ expect(() => JSON.parse(json)).not.toThrow();
52
+ });
53
+
54
+ it('should use message as description when no description provided', () => {
55
+ const config: HookConversionConfig = {
56
+ name: 'test-hook',
57
+ event: 'PreCompact',
58
+ action: 'warn',
59
+ message: 'Save session state',
60
+ };
61
+
62
+ const manifest = generateManifest(config);
63
+ expect(manifest.description).toBe('Save session state');
64
+ });
65
+
66
+ it('should use custom description when provided', () => {
67
+ const config: HookConversionConfig = {
68
+ name: 'test-hook',
69
+ event: 'PreCompact',
70
+ action: 'warn',
71
+ message: 'Save session state',
72
+ description: 'Custom description here',
73
+ };
74
+
75
+ const manifest = generateManifest(config);
76
+ expect(manifest.description).toBe('Custom description here');
77
+ });
78
+
79
+ it('should set empty matcher for non-tool events', () => {
80
+ const config: HookConversionConfig = {
81
+ name: 'compact-hook',
82
+ event: 'PreCompact',
83
+ action: 'remind',
84
+ message: 'Capture session',
85
+ };
86
+
87
+ const manifest = generateManifest(config);
88
+ expect(manifest.lifecycleHooks![0].matcher).toBe('');
89
+ });
90
+ });
91
+
92
+ describe('generateHookScript', () => {
93
+ it('should produce a valid shell script with shebang', () => {
94
+ const config: HookConversionConfig = {
95
+ name: 'test-hook',
96
+ event: 'PreToolUse',
97
+ toolMatcher: 'Write',
98
+ action: 'remind',
99
+ message: 'Check before writing',
100
+ };
101
+
102
+ const script = generateHookScript(config);
103
+
104
+ expect(script).toMatch(/^#!\/bin\/sh/);
105
+ expect(script).toContain('set -eu');
106
+ expect(script).toContain('INPUT=$(cat)');
107
+ });
108
+
109
+ it('should include tool matcher case statement for PreToolUse', () => {
110
+ const config: HookConversionConfig = {
111
+ name: 'write-guard',
112
+ event: 'PreToolUse',
113
+ toolMatcher: 'Write|Edit',
114
+ action: 'warn',
115
+ message: 'Be careful with writes',
116
+ };
117
+
118
+ const script = generateHookScript(config);
119
+
120
+ expect(script).toContain('TOOL_NAME=');
121
+ expect(script).toContain('case "$TOOL_NAME" in');
122
+ expect(script).toContain('Write|Edit');
123
+ });
124
+
125
+ it('should include file pattern matching when patterns provided', () => {
126
+ const config: HookConversionConfig = {
127
+ name: 'marketing-guard',
128
+ event: 'PreToolUse',
129
+ toolMatcher: 'Write',
130
+ filePatterns: ['**/marketing/**'],
131
+ action: 'block',
132
+ message: 'Marketing files require review',
133
+ };
134
+
135
+ const script = generateHookScript(config);
136
+
137
+ expect(script).toContain('FILE_PATH=');
138
+ expect(script).toContain('MATCHED=false');
139
+ expect(script).toContain('grep -qE');
140
+ });
141
+
142
+ it('should output block JSON for action=block', () => {
143
+ const config: HookConversionConfig = {
144
+ name: 'blocker',
145
+ event: 'PreToolUse',
146
+ action: 'block',
147
+ message: 'Blocked operation',
148
+ };
149
+
150
+ const script = generateHookScript(config);
151
+
152
+ expect(script).toContain('continue: false');
153
+ expect(script).toContain('BLOCKED:');
154
+ });
155
+
156
+ it('should output warn JSON for action=warn', () => {
157
+ const config: HookConversionConfig = {
158
+ name: 'warner',
159
+ event: 'PreToolUse',
160
+ action: 'warn',
161
+ message: 'Warning message',
162
+ };
163
+
164
+ const script = generateHookScript(config);
165
+
166
+ expect(script).toContain('continue: true');
167
+ expect(script).toContain('WARNING:');
168
+ });
169
+
170
+ it('should output remind JSON for action=remind', () => {
171
+ const config: HookConversionConfig = {
172
+ name: 'reminder',
173
+ event: 'PreToolUse',
174
+ action: 'remind',
175
+ message: 'Reminder message',
176
+ };
177
+
178
+ const script = generateHookScript(config);
179
+
180
+ expect(script).toContain('continue: true');
181
+ expect(script).toContain('REMINDER:');
182
+ });
183
+
184
+ it('should not include tool matching for PreCompact event', () => {
185
+ const config: HookConversionConfig = {
186
+ name: 'compact-hook',
187
+ event: 'PreCompact',
188
+ action: 'remind',
189
+ message: 'Save state before compaction',
190
+ };
191
+
192
+ const script = generateHookScript(config);
193
+
194
+ expect(script).not.toContain('TOOL_NAME');
195
+ expect(script).not.toContain('case');
196
+ });
197
+
198
+ it('should not include tool matching for Notification event', () => {
199
+ const config: HookConversionConfig = {
200
+ name: 'notify-hook',
201
+ event: 'Notification',
202
+ action: 'remind',
203
+ message: 'Notification handler',
204
+ };
205
+
206
+ const script = generateHookScript(config);
207
+
208
+ expect(script).not.toContain('TOOL_NAME');
209
+ });
210
+
211
+ it('should not include tool matching for Stop event', () => {
212
+ const config: HookConversionConfig = {
213
+ name: 'stop-hook',
214
+ event: 'Stop',
215
+ action: 'remind',
216
+ message: 'Stop handler',
217
+ };
218
+
219
+ const script = generateHookScript(config);
220
+
221
+ expect(script).not.toContain('TOOL_NAME');
222
+ });
223
+ });
224
+
225
+ describe('constants', () => {
226
+ it('HOOK_EVENTS should contain all 5 events', () => {
227
+ expect(HOOK_EVENTS).toEqual([
228
+ 'PreToolUse',
229
+ 'PostToolUse',
230
+ 'PreCompact',
231
+ 'Notification',
232
+ 'Stop',
233
+ ]);
234
+ });
235
+
236
+ it('ACTION_LEVELS should contain all 3 levels', () => {
237
+ expect(ACTION_LEVELS).toEqual(['remind', 'warn', 'block']);
238
+ });
239
+ });
240
+
241
+ describe('validation', () => {
242
+ it('should reject invalid event', () => {
243
+ const invalidEvent = 'InvalidEvent';
244
+ expect(HOOK_EVENTS.includes(invalidEvent as any)).toBe(false);
245
+ });
246
+
247
+ it('should reject invalid action level', () => {
248
+ const invalidAction = 'destroy';
249
+ expect(ACTION_LEVELS.includes(invalidAction as any)).toBe(false);
250
+ });
251
+
252
+ it('should accept all valid events', () => {
253
+ for (const event of HOOK_EVENTS) {
254
+ expect(HOOK_EVENTS.includes(event)).toBe(true);
255
+ }
256
+ });
257
+
258
+ it('should accept all valid action levels', () => {
259
+ for (const action of ACTION_LEVELS) {
260
+ expect(ACTION_LEVELS.includes(action)).toBe(true);
261
+ }
262
+ });
263
+ });
264
+
265
+ describe('directory structure', () => {
266
+ it('should create correct built-in directory structure', () => {
267
+ const config: HookConversionConfig = {
268
+ name: 'test-pack',
269
+ event: 'PreToolUse',
270
+ toolMatcher: 'Write',
271
+ action: 'remind',
272
+ message: 'Test message',
273
+ };
274
+
275
+ const script = generateHookScript(config);
276
+ const manifest = generateManifest(config);
277
+
278
+ // Simulate built-in output
279
+ const baseDir = join(tempDir, 'hook-packs', config.name);
280
+ const scriptsDir = join(baseDir, 'scripts');
281
+ mkdirSync(scriptsDir, { recursive: true });
282
+
283
+ const { writeFileSync: wfs, chmodSync: cms } = require('node:fs');
284
+ wfs(join(baseDir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
285
+ wfs(join(scriptsDir, `${config.name}.sh`), script);
286
+ cms(join(scriptsDir, `${config.name}.sh`), 0o755);
287
+
288
+ // Verify structure
289
+ expect(existsSync(join(baseDir, 'manifest.json'))).toBe(true);
290
+ expect(existsSync(join(scriptsDir, `${config.name}.sh`))).toBe(true);
291
+
292
+ // Verify manifest is valid JSON
293
+ const manifestContent = readFileSync(join(baseDir, 'manifest.json'), 'utf-8');
294
+ const parsed = JSON.parse(manifestContent);
295
+ expect(parsed.name).toBe('test-pack');
296
+ expect(parsed.version).toBe('1.0.0');
297
+ expect(parsed.scripts).toHaveLength(1);
298
+ expect(parsed.lifecycleHooks).toHaveLength(1);
299
+
300
+ // Verify script content
301
+ const scriptContent = readFileSync(join(scriptsDir, `${config.name}.sh`), 'utf-8');
302
+ expect(scriptContent).toContain('#!/bin/sh');
303
+ expect(scriptContent).toContain('test-pack');
304
+
305
+ // Verify script is executable
306
+ const stat = statSync(join(scriptsDir, `${config.name}.sh`));
307
+ expect(stat.mode & 0o755).toBe(0o755);
308
+ });
309
+
310
+ it('should create correct project directory structure with --project flag', () => {
311
+ const config: HookConversionConfig = {
312
+ name: 'project-hook',
313
+ event: 'PostToolUse',
314
+ action: 'warn',
315
+ message: 'Project-local hook',
316
+ };
317
+
318
+ const script = generateHookScript(config);
319
+ const manifest = generateManifest(config);
320
+
321
+ // Simulate --project output
322
+ const baseDir = join(tempDir, '.soleri', 'hook-packs', config.name);
323
+ const scriptsDir = join(baseDir, 'scripts');
324
+ mkdirSync(scriptsDir, { recursive: true });
325
+
326
+ const { writeFileSync: wfs, chmodSync: cms } = require('node:fs');
327
+ wfs(join(baseDir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
328
+ wfs(join(scriptsDir, `${config.name}.sh`), script);
329
+ cms(join(scriptsDir, `${config.name}.sh`), 0o755);
330
+
331
+ // Verify --project path structure
332
+ expect(
333
+ existsSync(join(tempDir, '.soleri', 'hook-packs', 'project-hook', 'manifest.json')),
334
+ ).toBe(true);
335
+ expect(
336
+ existsSync(
337
+ join(tempDir, '.soleri', 'hook-packs', 'project-hook', 'scripts', 'project-hook.sh'),
338
+ ),
339
+ ).toBe(true);
340
+ });
341
+ });
342
+ });