@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.
- package/dist/commands/agent.js +51 -2
- package/dist/commands/agent.js.map +1 -1
- package/dist/commands/hooks.js +126 -0
- package/dist/commands/hooks.js.map +1 -1
- package/dist/commands/install.js +5 -0
- package/dist/commands/install.js.map +1 -1
- package/dist/commands/pack.js +62 -13
- package/dist/commands/pack.js.map +1 -1
- package/dist/commands/staging.d.ts +49 -0
- package/dist/commands/staging.js +108 -18
- package/dist/commands/staging.js.map +1 -1
- package/dist/commands/yolo.d.ts +2 -0
- package/dist/commands/yolo.js +86 -0
- package/dist/commands/yolo.js.map +1 -0
- package/dist/hook-packs/converter/README.md +99 -0
- package/dist/hook-packs/converter/template.d.ts +36 -0
- package/dist/hook-packs/converter/template.js +127 -0
- package/dist/hook-packs/converter/template.js.map +1 -0
- package/dist/hook-packs/converter/template.test.ts +133 -0
- package/dist/hook-packs/converter/template.ts +163 -0
- package/dist/hook-packs/flock-guard/README.md +65 -0
- package/dist/hook-packs/flock-guard/manifest.json +36 -0
- package/dist/hook-packs/flock-guard/scripts/flock-guard-post.sh +48 -0
- package/dist/hook-packs/flock-guard/scripts/flock-guard-pre.sh +85 -0
- package/dist/hook-packs/full/manifest.json +8 -1
- package/dist/hook-packs/graduation.d.ts +11 -0
- package/dist/hook-packs/graduation.js +48 -0
- package/dist/hook-packs/graduation.js.map +1 -0
- package/dist/hook-packs/graduation.ts +65 -0
- package/dist/hook-packs/installer.js +3 -1
- package/dist/hook-packs/installer.js.map +1 -1
- package/dist/hook-packs/installer.ts +3 -1
- package/dist/hook-packs/marketing-research/README.md +37 -0
- package/dist/hook-packs/marketing-research/manifest.json +24 -0
- package/dist/hook-packs/marketing-research/scripts/marketing-research.sh +70 -0
- package/dist/hook-packs/registry.d.ts +1 -0
- package/dist/hook-packs/registry.js +14 -4
- package/dist/hook-packs/registry.js.map +1 -1
- package/dist/hook-packs/registry.ts +18 -4
- package/dist/hook-packs/safety/README.md +50 -0
- package/dist/hook-packs/safety/manifest.json +23 -0
- package/dist/hook-packs/safety/scripts/anti-deletion.sh +280 -0
- package/dist/hook-packs/validator.d.ts +32 -0
- package/dist/hook-packs/validator.js +126 -0
- package/dist/hook-packs/validator.js.map +1 -0
- package/dist/hook-packs/validator.ts +158 -0
- package/dist/hook-packs/yolo-safety/manifest.json +3 -19
- package/dist/hook-packs/yolo-safety/scripts/anti-deletion.sh +121 -61
- package/dist/main.js +2 -0
- package/dist/main.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/flock-guard.test.ts +225 -0
- package/src/__tests__/graduation.test.ts +199 -0
- package/src/__tests__/hook-packs.test.ts +45 -20
- package/src/__tests__/hooks-convert.test.ts +342 -0
- package/src/__tests__/validator.test.ts +265 -0
- package/src/__tests__/wizard-e2e.mjs +1 -1
- package/src/commands/agent.ts +65 -2
- package/src/commands/hooks.ts +172 -0
- package/src/commands/install.ts +6 -0
- package/src/commands/pack.ts +80 -14
- package/src/commands/staging.ts +143 -20
- package/src/commands/yolo.ts +103 -0
- package/src/hook-packs/converter/README.md +99 -0
- package/src/hook-packs/converter/template.test.ts +133 -0
- package/src/hook-packs/converter/template.ts +163 -0
- package/src/hook-packs/flock-guard/README.md +65 -0
- package/src/hook-packs/flock-guard/manifest.json +36 -0
- package/src/hook-packs/flock-guard/scripts/flock-guard-post.sh +48 -0
- package/src/hook-packs/flock-guard/scripts/flock-guard-pre.sh +85 -0
- package/src/hook-packs/full/manifest.json +8 -1
- package/src/hook-packs/graduation.ts +65 -0
- package/src/hook-packs/installer.ts +3 -1
- package/src/hook-packs/marketing-research/README.md +37 -0
- package/src/hook-packs/marketing-research/manifest.json +24 -0
- package/src/hook-packs/marketing-research/scripts/marketing-research.sh +70 -0
- package/src/hook-packs/registry.ts +18 -4
- package/src/hook-packs/safety/README.md +50 -0
- package/src/hook-packs/safety/manifest.json +23 -0
- package/src/hook-packs/safety/scripts/anti-deletion.sh +280 -0
- package/src/hook-packs/validator.ts +158 -0
- package/src/hook-packs/yolo-safety/manifest.json +3 -19
- package/src/main.ts +2 -0
- package/vitest.config.ts +1 -0
- package/src/__tests__/archetypes.test.ts +0 -84
- package/src/__tests__/create.test.ts +0 -207
- package/src/hook-packs/yolo-safety/scripts/anti-deletion.sh +0 -214
- 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
|
|
26
|
+
it('should list all 9 built-in packs', () => {
|
|
27
27
|
const packs = listPacks();
|
|
28
|
-
expect(packs.length).toBe(
|
|
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
|
|
71
|
-
const pack = getPack('
|
|
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('
|
|
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
|
|
154
|
-
const result = installPack('
|
|
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('
|
|
166
|
-
expect(settings.hooks.PreToolUse[0]._soleriPack).toBe('
|
|
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
|
|
170
|
-
installPack('
|
|
171
|
-
const result = removePack('
|
|
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
|
|
181
|
-
installPack('
|
|
182
|
-
const result2 = installPack('
|
|
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
|
|
211
|
-
installPack('
|
|
212
|
-
expect(isPackInstalled('
|
|
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
|
+
});
|