@soleri/cli 9.4.0 → 9.6.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/create.js +3 -6
- package/dist/commands/create.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/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/{src/hook-packs/yolo-safety → dist/hook-packs/safety}/scripts/anti-deletion.sh +7 -1
- 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/prompts/create-wizard.js +1 -1
- package/dist/prompts/create-wizard.js.map +1 -1
- package/dist/utils/checks.js +6 -1
- package/dist/utils/checks.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/flock-guard.test.ts +232 -0
- package/src/__tests__/graduation.test.ts +199 -0
- package/src/__tests__/hook-packs.test.ts +44 -19
- package/src/__tests__/hooks-convert.test.ts +344 -0
- package/src/__tests__/validator.test.ts +265 -0
- package/src/commands/create.ts +3 -7
- package/src/commands/hooks.ts +172 -0
- package/src/commands/install.ts +6 -0
- package/src/hook-packs/converter/README.md +99 -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/prompts/create-wizard.ts +1 -1
- package/src/utils/checks.ts +6 -1
- package/src/prompts/playbook.ts +0 -487
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { execSync } from 'node:child_process';
|
|
3
|
+
import { generateFixtures, validateHookScript } from '../hook-packs/validator.js';
|
|
4
|
+
import type { TestFixture } from '../hook-packs/validator.js';
|
|
5
|
+
|
|
6
|
+
// Mock execSync to avoid needing actual shell scripts in tests
|
|
7
|
+
vi.mock('node:child_process', () => ({
|
|
8
|
+
execSync: vi.fn(() => ''),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
const mockedExecSync = vi.mocked(execSync);
|
|
12
|
+
|
|
13
|
+
describe('validator', () => {
|
|
14
|
+
describe('generateFixtures', () => {
|
|
15
|
+
it('should return 15 fixtures for PreToolUse (5 matching + 10 non-matching)', () => {
|
|
16
|
+
const fixtures = generateFixtures('PreToolUse', 'Write');
|
|
17
|
+
expect(fixtures).toHaveLength(15);
|
|
18
|
+
|
|
19
|
+
const matching = fixtures.filter((f) => f.shouldMatch);
|
|
20
|
+
const nonMatching = fixtures.filter((f) => !f.shouldMatch);
|
|
21
|
+
expect(matching).toHaveLength(5);
|
|
22
|
+
expect(nonMatching).toHaveLength(10);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should return 15 fixtures for PostToolUse', () => {
|
|
26
|
+
const fixtures = generateFixtures('PostToolUse', 'Edit|Write');
|
|
27
|
+
expect(fixtures).toHaveLength(15);
|
|
28
|
+
|
|
29
|
+
const matching = fixtures.filter((f) => f.shouldMatch);
|
|
30
|
+
const nonMatching = fixtures.filter((f) => !f.shouldMatch);
|
|
31
|
+
expect(matching).toHaveLength(5);
|
|
32
|
+
expect(nonMatching).toHaveLength(10);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should return 15 fixtures for PreCompact', () => {
|
|
36
|
+
const fixtures = generateFixtures('PreCompact');
|
|
37
|
+
expect(fixtures).toHaveLength(15);
|
|
38
|
+
|
|
39
|
+
const matching = fixtures.filter((f) => f.shouldMatch);
|
|
40
|
+
const nonMatching = fixtures.filter((f) => !f.shouldMatch);
|
|
41
|
+
expect(matching).toHaveLength(5);
|
|
42
|
+
expect(nonMatching).toHaveLength(10);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should return 15 fixtures for Notification', () => {
|
|
46
|
+
const fixtures = generateFixtures('Notification');
|
|
47
|
+
expect(fixtures).toHaveLength(15);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should return 15 fixtures for Stop', () => {
|
|
51
|
+
const fixtures = generateFixtures('Stop');
|
|
52
|
+
expect(fixtures).toHaveLength(15);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('matching PreToolUse fixtures should have shouldMatch: true', () => {
|
|
56
|
+
const fixtures = generateFixtures('PreToolUse', 'Bash');
|
|
57
|
+
const matching = fixtures.filter((f) => f.shouldMatch);
|
|
58
|
+
for (const f of matching) {
|
|
59
|
+
expect(f.shouldMatch).toBe(true);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('non-matching PreToolUse fixtures should have shouldMatch: false', () => {
|
|
64
|
+
const fixtures = generateFixtures('PreToolUse', 'Write');
|
|
65
|
+
const nonMatching = fixtures.filter((f) => !f.shouldMatch);
|
|
66
|
+
for (const f of nonMatching) {
|
|
67
|
+
expect(f.shouldMatch).toBe(false);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('PreToolUse matching fixtures should contain tool_name and tool_input', () => {
|
|
72
|
+
const fixtures = generateFixtures('PreToolUse', 'Write|Edit');
|
|
73
|
+
const matching = fixtures.filter((f) => f.shouldMatch);
|
|
74
|
+
for (const f of matching) {
|
|
75
|
+
expect(f.payload).toHaveProperty('tool_name');
|
|
76
|
+
expect(f.payload).toHaveProperty('tool_input');
|
|
77
|
+
const toolInput = f.payload.tool_input as Record<string, unknown>;
|
|
78
|
+
expect(toolInput).toHaveProperty('file_path');
|
|
79
|
+
expect(toolInput).toHaveProperty('command');
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('PreToolUse non-matching fixtures should contain tool_name and tool_input', () => {
|
|
84
|
+
const fixtures = generateFixtures('PreToolUse', 'Write');
|
|
85
|
+
const nonMatching = fixtures.filter((f) => !f.shouldMatch);
|
|
86
|
+
for (const f of nonMatching) {
|
|
87
|
+
expect(f.payload).toHaveProperty('tool_name');
|
|
88
|
+
expect(f.payload).toHaveProperty('tool_input');
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should use provided toolMatcher tools in matching fixtures', () => {
|
|
93
|
+
const fixtures = generateFixtures('PreToolUse', 'Edit|Write');
|
|
94
|
+
const matching = fixtures.filter((f) => f.shouldMatch);
|
|
95
|
+
const toolNames = matching.map((f) => f.payload.tool_name);
|
|
96
|
+
for (const name of toolNames) {
|
|
97
|
+
expect(['Edit', 'Write']).toContain(name);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should default to Write when no toolMatcher provided for PreToolUse', () => {
|
|
102
|
+
const fixtures = generateFixtures('PreToolUse');
|
|
103
|
+
const matching = fixtures.filter((f) => f.shouldMatch);
|
|
104
|
+
for (const f of matching) {
|
|
105
|
+
expect(f.payload.tool_name).toBe('Write');
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('PreCompact matching fixtures should have session_id', () => {
|
|
110
|
+
const fixtures = generateFixtures('PreCompact');
|
|
111
|
+
const matching = fixtures.filter((f) => f.shouldMatch);
|
|
112
|
+
for (const f of matching) {
|
|
113
|
+
expect(f.payload).toHaveProperty('session_id');
|
|
114
|
+
expect(f.payload).toHaveProperty('context');
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('PreCompact non-matching fixtures should have empty payloads', () => {
|
|
119
|
+
const fixtures = generateFixtures('PreCompact');
|
|
120
|
+
const nonMatching = fixtures.filter((f) => !f.shouldMatch);
|
|
121
|
+
for (const f of nonMatching) {
|
|
122
|
+
expect(Object.keys(f.payload)).toHaveLength(0);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('all fixtures should have event matching the requested event', () => {
|
|
127
|
+
for (const event of [
|
|
128
|
+
'PreToolUse',
|
|
129
|
+
'PostToolUse',
|
|
130
|
+
'PreCompact',
|
|
131
|
+
'Notification',
|
|
132
|
+
'Stop',
|
|
133
|
+
] as const) {
|
|
134
|
+
const fixtures = generateFixtures(event);
|
|
135
|
+
for (const f of fixtures) {
|
|
136
|
+
expect(f.event).toBe(event);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('all fixtures should have unique names', () => {
|
|
142
|
+
const fixtures = generateFixtures('PreToolUse', 'Write|Edit');
|
|
143
|
+
const names = fixtures.map((f) => f.name);
|
|
144
|
+
expect(new Set(names).size).toBe(names.length);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('validateHookScript', () => {
|
|
149
|
+
it('should report correctly with a script that produces no output (exit 0)', () => {
|
|
150
|
+
// execSync mock returns '' (empty string) — no match detected
|
|
151
|
+
mockedExecSync.mockReturnValue('');
|
|
152
|
+
|
|
153
|
+
const fixtures: TestFixture[] = [
|
|
154
|
+
{
|
|
155
|
+
name: 'should-match',
|
|
156
|
+
event: 'PreToolUse',
|
|
157
|
+
payload: { tool_name: 'Write', tool_input: { file_path: 'test.ts' } },
|
|
158
|
+
shouldMatch: true,
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
name: 'should-not-match',
|
|
162
|
+
event: 'PreToolUse',
|
|
163
|
+
payload: { tool_name: 'Read', tool_input: { file_path: 'test.ts' } },
|
|
164
|
+
shouldMatch: false,
|
|
165
|
+
},
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
const report = validateHookScript('/fake/script.sh', fixtures);
|
|
169
|
+
|
|
170
|
+
expect(report.total).toBe(2);
|
|
171
|
+
// Script produces no output, so matched = false for all
|
|
172
|
+
// should-match expected match but got none -> false negative
|
|
173
|
+
// should-not-match expected no match and got none -> correct
|
|
174
|
+
expect(report.falseNegatives).toHaveLength(1);
|
|
175
|
+
expect(report.falseNegatives[0].fixture.name).toBe('should-match');
|
|
176
|
+
expect(report.falsePositives).toHaveLength(0);
|
|
177
|
+
expect(report.passed).toBe(1);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('should detect false positives when script always matches', () => {
|
|
181
|
+
mockedExecSync.mockReturnValue('{"continue": true, "message": "always matches"}');
|
|
182
|
+
|
|
183
|
+
const fixtures: TestFixture[] = [
|
|
184
|
+
{
|
|
185
|
+
name: 'should-match',
|
|
186
|
+
event: 'PreToolUse',
|
|
187
|
+
payload: { tool_name: 'Write', tool_input: {} },
|
|
188
|
+
shouldMatch: true,
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
name: 'should-not-match',
|
|
192
|
+
event: 'PreToolUse',
|
|
193
|
+
payload: { tool_name: 'Read', tool_input: {} },
|
|
194
|
+
shouldMatch: false,
|
|
195
|
+
},
|
|
196
|
+
];
|
|
197
|
+
|
|
198
|
+
const report = validateHookScript('/fake/script.sh', fixtures);
|
|
199
|
+
|
|
200
|
+
expect(report.total).toBe(2);
|
|
201
|
+
// Script always outputs "continue", so matched = true for all
|
|
202
|
+
// should-not-match expected no match but got one -> false positive
|
|
203
|
+
expect(report.falsePositives).toHaveLength(1);
|
|
204
|
+
expect(report.falsePositives[0].fixture.name).toBe('should-not-match');
|
|
205
|
+
expect(report.falseNegatives).toHaveLength(0);
|
|
206
|
+
expect(report.passed).toBe(1);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('should report all passed when script matches correctly', () => {
|
|
210
|
+
mockedExecSync.mockImplementation((cmd: unknown) => {
|
|
211
|
+
if (typeof cmd === 'string' && cmd.includes('Write')) {
|
|
212
|
+
return '{"continue": true, "message": "matched"}';
|
|
213
|
+
}
|
|
214
|
+
return '';
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const fixtures: TestFixture[] = [
|
|
218
|
+
{
|
|
219
|
+
name: 'should-match',
|
|
220
|
+
event: 'PreToolUse',
|
|
221
|
+
payload: { tool_name: 'Write', tool_input: {} },
|
|
222
|
+
shouldMatch: true,
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
name: 'should-not-match',
|
|
226
|
+
event: 'PreToolUse',
|
|
227
|
+
payload: { tool_name: 'Read', tool_input: {} },
|
|
228
|
+
shouldMatch: false,
|
|
229
|
+
},
|
|
230
|
+
];
|
|
231
|
+
|
|
232
|
+
const report = validateHookScript('/fake/script.sh', fixtures);
|
|
233
|
+
|
|
234
|
+
expect(report.total).toBe(2);
|
|
235
|
+
expect(report.passed).toBe(2);
|
|
236
|
+
expect(report.falsePositives).toHaveLength(0);
|
|
237
|
+
expect(report.falseNegatives).toHaveLength(0);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('should handle script errors gracefully (exit code != 0)', () => {
|
|
241
|
+
mockedExecSync.mockImplementation(() => {
|
|
242
|
+
const err = new Error('script failed') as Error & { status: number; stdout: string };
|
|
243
|
+
err.status = 1;
|
|
244
|
+
err.stdout = '';
|
|
245
|
+
throw err;
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const fixtures: TestFixture[] = [
|
|
249
|
+
{
|
|
250
|
+
name: 'error-fixture',
|
|
251
|
+
event: 'PreToolUse',
|
|
252
|
+
payload: { tool_name: 'Write', tool_input: {} },
|
|
253
|
+
shouldMatch: true,
|
|
254
|
+
},
|
|
255
|
+
];
|
|
256
|
+
|
|
257
|
+
const report = validateHookScript('/fake/script.sh', fixtures);
|
|
258
|
+
|
|
259
|
+
expect(report.total).toBe(1);
|
|
260
|
+
// Error means matched = false, but shouldMatch = true -> false negative
|
|
261
|
+
expect(report.falseNegatives).toHaveLength(1);
|
|
262
|
+
expect(report.passed).toBe(0);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
});
|
package/src/commands/create.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
-
import { resolve
|
|
3
|
-
import { homedir } from 'node:os';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
4
3
|
import type { Command } from 'commander';
|
|
5
4
|
import * as p from '@clack/prompts';
|
|
6
5
|
import {
|
|
@@ -15,9 +14,6 @@ import { runCreateWizard } from '../prompts/create-wizard.js';
|
|
|
15
14
|
import { listPacks } from '../hook-packs/registry.js';
|
|
16
15
|
import { installPack } from '../hook-packs/installer.js';
|
|
17
16
|
|
|
18
|
-
/** Default parent directory for new agents: ~/.soleri/ */
|
|
19
|
-
const SOLERI_HOME = process.env.SOLERI_HOME ?? join(homedir(), '.soleri');
|
|
20
|
-
|
|
21
17
|
function parseSetupTarget(value?: string): SetupTarget | undefined {
|
|
22
18
|
if (!value) return undefined;
|
|
23
19
|
if ((SETUP_TARGETS as readonly string[]).includes(value)) {
|
|
@@ -41,7 +37,7 @@ export function registerCreate(program: Command): void {
|
|
|
41
37
|
`Setup target: ${SETUP_TARGETS.join(', ')} (default: claude)`,
|
|
42
38
|
)
|
|
43
39
|
.option('-y, --yes', 'Skip confirmation prompts (use with --config for fully non-interactive)')
|
|
44
|
-
.option('--dir <path>', `Parent directory for the agent (default:
|
|
40
|
+
.option('--dir <path>', `Parent directory for the agent (default: current directory)`)
|
|
45
41
|
.option('--filetree', 'Create a file-tree agent (v7 — no TypeScript, no build step)')
|
|
46
42
|
.option('--legacy', 'Create a legacy TypeScript agent (v6 — requires npm install + build)')
|
|
47
43
|
.description('Create a new Soleri agent')
|
|
@@ -154,7 +150,7 @@ export function registerCreate(program: Command): void {
|
|
|
154
150
|
})),
|
|
155
151
|
};
|
|
156
152
|
|
|
157
|
-
const outputDir = opts?.dir ? resolve(opts.dir) : (config.outputDir ??
|
|
153
|
+
const outputDir = opts?.dir ? resolve(opts.dir) : (config.outputDir ?? process.cwd());
|
|
158
154
|
const nonInteractive = !!(opts?.yes || opts?.config);
|
|
159
155
|
|
|
160
156
|
if (!nonInteractive) {
|
package/src/commands/hooks.ts
CHANGED
|
@@ -1,9 +1,24 @@
|
|
|
1
1
|
import type { Command } from 'commander';
|
|
2
|
+
import { mkdirSync, writeFileSync, chmodSync, existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
2
4
|
import { SUPPORTED_EDITORS, type EditorId } from '../hooks/templates.js';
|
|
3
5
|
import { installHooks, removeHooks, detectInstalledHooks } from '../hooks/generator.js';
|
|
4
6
|
import { detectAgent } from '../utils/agent-context.js';
|
|
5
7
|
import { listPacks, getPack } from '../hook-packs/registry.js';
|
|
6
8
|
import { installPack, removePack, isPackInstalled } from '../hook-packs/installer.js';
|
|
9
|
+
import { promotePack, demotePack } from '../hook-packs/graduation.js';
|
|
10
|
+
import {
|
|
11
|
+
generateHookScript,
|
|
12
|
+
generateManifest,
|
|
13
|
+
HOOK_EVENTS,
|
|
14
|
+
ACTION_LEVELS,
|
|
15
|
+
} from '../hook-packs/converter/template.js';
|
|
16
|
+
import type {
|
|
17
|
+
HookEvent,
|
|
18
|
+
ActionLevel,
|
|
19
|
+
HookConversionConfig,
|
|
20
|
+
} from '../hook-packs/converter/template.js';
|
|
21
|
+
import { generateFixtures, validateHookScript } from '../hook-packs/validator.js';
|
|
7
22
|
import * as log from '../utils/logger.js';
|
|
8
23
|
|
|
9
24
|
export function registerHooks(program: Command): void {
|
|
@@ -201,6 +216,163 @@ export function registerHooks(program: Command): void {
|
|
|
201
216
|
const total = installed.length + scripts.length + lifecycleHooks.length;
|
|
202
217
|
log.info(`Pack "${packName}" upgraded to v${packVersion} (${total} items)`);
|
|
203
218
|
});
|
|
219
|
+
|
|
220
|
+
hooks
|
|
221
|
+
.command('convert')
|
|
222
|
+
.argument('<name>', 'Name for the converted hook pack (kebab-case)')
|
|
223
|
+
.requiredOption(
|
|
224
|
+
'--event <event>',
|
|
225
|
+
'Hook event: PreToolUse, PostToolUse, PreCompact, Notification, Stop',
|
|
226
|
+
)
|
|
227
|
+
.option(
|
|
228
|
+
'--matcher <tools>',
|
|
229
|
+
'Tool name matcher (e.g., "Write|Edit") — for PreToolUse/PostToolUse',
|
|
230
|
+
)
|
|
231
|
+
.option('--pattern <globs...>', 'File glob patterns to match (e.g., "**/marketing/**")')
|
|
232
|
+
.option('--action <level>', 'Action level: remind (default), warn, block', 'remind')
|
|
233
|
+
.requiredOption('--message <text>', 'Context message when hook fires')
|
|
234
|
+
.option('--project', 'Output to .soleri/hook-packs/ instead of built-in packs dir')
|
|
235
|
+
.description('Convert a skill into an automated hook pack')
|
|
236
|
+
.action(
|
|
237
|
+
(
|
|
238
|
+
name: string,
|
|
239
|
+
opts: {
|
|
240
|
+
event: string;
|
|
241
|
+
matcher?: string;
|
|
242
|
+
pattern?: string[];
|
|
243
|
+
action: string;
|
|
244
|
+
message: string;
|
|
245
|
+
project?: boolean;
|
|
246
|
+
},
|
|
247
|
+
) => {
|
|
248
|
+
if (!HOOK_EVENTS.includes(opts.event as HookEvent)) {
|
|
249
|
+
log.fail(`Invalid event "${opts.event}". Must be one of: ${HOOK_EVENTS.join(', ')}`);
|
|
250
|
+
process.exit(1);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (!ACTION_LEVELS.includes(opts.action as ActionLevel)) {
|
|
254
|
+
log.fail(`Invalid action "${opts.action}". Must be one of: ${ACTION_LEVELS.join(', ')}`);
|
|
255
|
+
process.exit(1);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const config: HookConversionConfig = {
|
|
259
|
+
name,
|
|
260
|
+
event: opts.event as HookEvent,
|
|
261
|
+
toolMatcher: opts.matcher,
|
|
262
|
+
filePatterns: opts.pattern,
|
|
263
|
+
action: opts.action as ActionLevel,
|
|
264
|
+
message: opts.message,
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const script = generateHookScript(config);
|
|
268
|
+
const manifest = generateManifest(config);
|
|
269
|
+
|
|
270
|
+
const baseDir = opts.project
|
|
271
|
+
? join(process.cwd(), '.soleri', 'hook-packs', name)
|
|
272
|
+
: join(process.cwd(), 'packages', 'cli', 'src', 'hook-packs', name);
|
|
273
|
+
|
|
274
|
+
const scriptsDir = join(baseDir, 'scripts');
|
|
275
|
+
mkdirSync(scriptsDir, { recursive: true });
|
|
276
|
+
|
|
277
|
+
const manifestPath = join(baseDir, 'manifest.json');
|
|
278
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
|
279
|
+
log.pass(`Created ${manifestPath}`);
|
|
280
|
+
|
|
281
|
+
const scriptPath = join(scriptsDir, `${name}.sh`);
|
|
282
|
+
writeFileSync(scriptPath, script);
|
|
283
|
+
if (process.platform !== 'win32') {
|
|
284
|
+
chmodSync(scriptPath, 0o755);
|
|
285
|
+
}
|
|
286
|
+
log.pass(`Created ${scriptPath}`);
|
|
287
|
+
|
|
288
|
+
log.info(`Hook pack "${name}" generated (event: ${opts.event}, action: ${opts.action})`);
|
|
289
|
+
},
|
|
290
|
+
);
|
|
291
|
+
hooks
|
|
292
|
+
.command('promote')
|
|
293
|
+
.argument('<pack>', 'Hook pack name')
|
|
294
|
+
.description('Promote hook action level: remind → warn → block')
|
|
295
|
+
.action((packName: string) => {
|
|
296
|
+
try {
|
|
297
|
+
const result = promotePack(packName);
|
|
298
|
+
log.pass(`${packName}: ${result.previousLevel} → ${result.newLevel}`);
|
|
299
|
+
} catch (err: unknown) {
|
|
300
|
+
log.fail((err as Error).message);
|
|
301
|
+
process.exit(1);
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
hooks
|
|
306
|
+
.command('demote')
|
|
307
|
+
.argument('<pack>', 'Hook pack name')
|
|
308
|
+
.description('Demote hook action level: block → warn → remind')
|
|
309
|
+
.action((packName: string) => {
|
|
310
|
+
try {
|
|
311
|
+
const result = demotePack(packName);
|
|
312
|
+
log.pass(`${packName}: ${result.previousLevel} → ${result.newLevel}`);
|
|
313
|
+
} catch (err: unknown) {
|
|
314
|
+
log.fail((err as Error).message);
|
|
315
|
+
process.exit(1);
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
hooks
|
|
320
|
+
.command('test')
|
|
321
|
+
.argument('<pack>', 'Hook pack name to test')
|
|
322
|
+
.description('Run validation tests against a hook pack')
|
|
323
|
+
.action((packName: string) => {
|
|
324
|
+
const pack = getPack(packName);
|
|
325
|
+
if (!pack) {
|
|
326
|
+
log.fail(`Unknown pack "${packName}"`);
|
|
327
|
+
process.exit(1);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Find the script
|
|
331
|
+
const scripts = pack.manifest.scripts;
|
|
332
|
+
if (!scripts || scripts.length === 0) {
|
|
333
|
+
log.fail(`Pack "${packName}" has no scripts to test`);
|
|
334
|
+
process.exit(1);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const scriptFile = scripts[0].file;
|
|
338
|
+
// Resolve script path from pack directory
|
|
339
|
+
const scriptPath = join(pack.dir, 'scripts', scriptFile);
|
|
340
|
+
|
|
341
|
+
if (!existsSync(scriptPath)) {
|
|
342
|
+
log.fail(`Script not found: ${scriptPath}`);
|
|
343
|
+
process.exit(1);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Determine event and matcher from lifecycle hooks
|
|
347
|
+
const lc = pack.manifest.lifecycleHooks?.[0];
|
|
348
|
+
const event = (lc?.event ?? 'PreToolUse') as HookEvent;
|
|
349
|
+
const toolMatcher = lc?.matcher;
|
|
350
|
+
|
|
351
|
+
// Generate fixtures and run
|
|
352
|
+
const fixtures = generateFixtures(event, toolMatcher);
|
|
353
|
+
log.heading(`Testing ${packName} (${fixtures.length} fixtures)`);
|
|
354
|
+
|
|
355
|
+
const report = validateHookScript(scriptPath, fixtures);
|
|
356
|
+
log.info(`Results: ${report.passed}/${report.total} passed`);
|
|
357
|
+
|
|
358
|
+
if (report.falsePositives.length > 0) {
|
|
359
|
+
log.fail(`False positives: ${report.falsePositives.length}`);
|
|
360
|
+
for (const fp of report.falsePositives) {
|
|
361
|
+
log.warn(` ${fp.fixture.name}: expected no match, got output`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (report.falseNegatives.length > 0) {
|
|
366
|
+
log.warn(`False negatives: ${report.falseNegatives.length}`);
|
|
367
|
+
for (const fn of report.falseNegatives) {
|
|
368
|
+
log.warn(` ${fn.fixture.name}: expected match, got no output`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (report.falsePositives.length === 0 && report.falseNegatives.length === 0) {
|
|
373
|
+
log.pass('All fixtures passed — zero false positives');
|
|
374
|
+
}
|
|
375
|
+
});
|
|
204
376
|
}
|
|
205
377
|
|
|
206
378
|
function isValidEditor(editor: string): editor is EditorId {
|
package/src/commands/install.ts
CHANGED
|
@@ -170,6 +170,12 @@ function escapeRegExp(s: string): string {
|
|
|
170
170
|
* e.g., typing `ernesto` opens Claude Code with that agent's MCP config.
|
|
171
171
|
*/
|
|
172
172
|
function installLauncher(agentId: string, agentDir: string): void {
|
|
173
|
+
// Launcher scripts to /usr/local/bin are Unix-only
|
|
174
|
+
if (process.platform === 'win32') {
|
|
175
|
+
p.log.info('Launcher scripts are not supported on Windows — skipping');
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
173
179
|
const binPath = join('/usr/local/bin', agentId);
|
|
174
180
|
|
|
175
181
|
const script = [
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Skill-to-Hook Conversion System
|
|
2
|
+
|
|
3
|
+
Convert repeatedly-invoked skills into automated Claude Code hooks. Hooks fire automatically on matching events — no manual invocation, no LLM round trip.
|
|
4
|
+
|
|
5
|
+
## Workflow
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Score → Convert → Test → Graduate
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
### 1. Score the Candidate
|
|
12
|
+
|
|
13
|
+
Evaluate 4 dimensions (each HIGH or LOW):
|
|
14
|
+
|
|
15
|
+
| Dimension | HIGH when... |
|
|
16
|
+
| --------------------- | -------------------------------------------------------- |
|
|
17
|
+
| **Frequency** | 3+ manual calls per session for same event type |
|
|
18
|
+
| **Event Correlation** | Skill consistently triggers on a recognizable hook event |
|
|
19
|
+
| **Determinism** | Skill produces consistent, non-exploratory guidance |
|
|
20
|
+
| **Autonomy** | Skill requires no interactive user decisions |
|
|
21
|
+
|
|
22
|
+
**Threshold:** 3/4 HIGH = candidate for conversion.
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
import { scoreCandidateForConversion } from '@soleri/core';
|
|
26
|
+
|
|
27
|
+
const result = scoreCandidateForConversion({
|
|
28
|
+
frequency: 'HIGH',
|
|
29
|
+
eventCorrelation: 'HIGH',
|
|
30
|
+
determinism: 'HIGH',
|
|
31
|
+
autonomy: 'LOW',
|
|
32
|
+
});
|
|
33
|
+
// result.candidate === true (3/4)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### 2. Convert
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
soleri hooks convert marketing-research \
|
|
40
|
+
--event PreToolUse \
|
|
41
|
+
--matcher "Write|Edit" \
|
|
42
|
+
--pattern "**/marketing/**" \
|
|
43
|
+
--action remind \
|
|
44
|
+
--message "Check brand guidelines and A/B testing data"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
This creates a hook pack with `manifest.json` and a POSIX shell script.
|
|
48
|
+
|
|
49
|
+
### 3. Test
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
soleri hooks test marketing-research
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Runs 15 fixtures (5 matching + 10 non-matching) against the hook script. Reports false positives and false negatives. **Zero false positives required before graduation.**
|
|
56
|
+
|
|
57
|
+
### 4. Graduate
|
|
58
|
+
|
|
59
|
+
Hooks default to `remind` (gentle context injection). After proving zero false positives:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
soleri hooks promote marketing-research # remind → warn
|
|
63
|
+
soleri hooks promote marketing-research # warn → block
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
To step back:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
soleri hooks demote marketing-research # block → warn
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Hook Events
|
|
73
|
+
|
|
74
|
+
| Event | When it fires |
|
|
75
|
+
| -------------- | -------------------------------------------- |
|
|
76
|
+
| `PreToolUse` | Before a tool call (Write, Edit, Bash, etc.) |
|
|
77
|
+
| `PostToolUse` | After a tool call completes |
|
|
78
|
+
| `PreCompact` | Before context compaction |
|
|
79
|
+
| `Notification` | On notification events |
|
|
80
|
+
| `Stop` | When the session ends |
|
|
81
|
+
|
|
82
|
+
## Action Levels
|
|
83
|
+
|
|
84
|
+
| Level | Behavior |
|
|
85
|
+
| -------- | ------------------------------------- |
|
|
86
|
+
| `remind` | Inject context, don't block (default) |
|
|
87
|
+
| `warn` | Inject warning context, don't block |
|
|
88
|
+
| `block` | Block the operation with a reason |
|
|
89
|
+
|
|
90
|
+
## CLI Commands
|
|
91
|
+
|
|
92
|
+
| Command | Description |
|
|
93
|
+
| --------------------------------- | ------------------------------------- |
|
|
94
|
+
| `soleri hooks convert <name>` | Create a new hook pack from a skill |
|
|
95
|
+
| `soleri hooks test <pack>` | Validate a hook pack against fixtures |
|
|
96
|
+
| `soleri hooks promote <pack>` | Step up action level |
|
|
97
|
+
| `soleri hooks demote <pack>` | Step down action level |
|
|
98
|
+
| `soleri hooks add-pack <pack>` | Install a hook pack |
|
|
99
|
+
| `soleri hooks remove-pack <pack>` | Uninstall a hook pack |
|