@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,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
|
+
});
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Comprehensive E2E tests for the interactive create wizard.
|
|
4
4
|
*
|
|
5
|
-
* Tests
|
|
5
|
+
* Tests custom path, custom greeting, custom domains/principles,
|
|
6
6
|
* hook pack selection, cancel flows, and decline flows.
|
|
7
7
|
*
|
|
8
8
|
* Run: node packages/cli/src/__tests__/wizard-e2e.mjs
|
package/src/commands/agent.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
generateInjectClaudeMd,
|
|
27
27
|
generateSkills,
|
|
28
28
|
} from '@soleri/forge/lib';
|
|
29
|
+
import { composeClaudeMd, getEngineRulesContent } from '@soleri/forge/lib';
|
|
29
30
|
import type { AgentConfig } from '@soleri/forge/lib';
|
|
30
31
|
import { detectAgent } from '../utils/agent-context.js';
|
|
31
32
|
import { installClaude } from './install.js';
|
|
@@ -194,7 +195,70 @@ export function registerAgent(program: Command): void {
|
|
|
194
195
|
return;
|
|
195
196
|
}
|
|
196
197
|
|
|
197
|
-
//
|
|
198
|
+
// ─── File-tree agent (v7+) ────────────────────────────────
|
|
199
|
+
if (ctx.format === 'filetree') {
|
|
200
|
+
const enginePath = join(ctx.agentPath, 'instructions', '_engine.md');
|
|
201
|
+
const claudeMdPath = join(ctx.agentPath, 'CLAUDE.md');
|
|
202
|
+
|
|
203
|
+
// Generate skills from latest forge templates
|
|
204
|
+
const skillFiles = opts.skipSkills
|
|
205
|
+
? []
|
|
206
|
+
: generateSkills({ id: ctx.agentId } as AgentConfig);
|
|
207
|
+
|
|
208
|
+
if (opts.dryRun) {
|
|
209
|
+
p.log.info(`Would regenerate: ${enginePath}`);
|
|
210
|
+
p.log.info(`Would regenerate: ${claudeMdPath}`);
|
|
211
|
+
if (skillFiles.length > 0) {
|
|
212
|
+
const newSkills = skillFiles.filter(
|
|
213
|
+
([relPath]) => !existsSync(join(ctx.agentPath, relPath)),
|
|
214
|
+
);
|
|
215
|
+
const updatedSkills = skillFiles.filter(([relPath]) =>
|
|
216
|
+
existsSync(join(ctx.agentPath, relPath)),
|
|
217
|
+
);
|
|
218
|
+
p.log.info(
|
|
219
|
+
`Skills: ${skillFiles.length} total (${newSkills.length} new, ${updatedSkills.length} updated)`,
|
|
220
|
+
);
|
|
221
|
+
for (const [relPath] of newSkills) {
|
|
222
|
+
p.log.info(` + ${relPath}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
p.log.info(`Agent: ${ctx.agentId} (file-tree format)`);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// 1. Sync skills from forge templates
|
|
230
|
+
if (skillFiles.length > 0) {
|
|
231
|
+
let newCount = 0;
|
|
232
|
+
let updatedCount = 0;
|
|
233
|
+
for (const [relPath, content] of skillFiles) {
|
|
234
|
+
const fullPath = join(ctx.agentPath, relPath);
|
|
235
|
+
const dirPath = dirname(fullPath);
|
|
236
|
+
const isNew = !existsSync(fullPath);
|
|
237
|
+
mkdirSync(dirPath, { recursive: true });
|
|
238
|
+
writeFileSync(fullPath, content, 'utf-8');
|
|
239
|
+
if (isNew) newCount++;
|
|
240
|
+
else updatedCount++;
|
|
241
|
+
}
|
|
242
|
+
p.log.success(
|
|
243
|
+
`Synced ${skillFiles.length} skills (${newCount} new, ${updatedCount} updated)`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// 2. Regenerate _engine.md from latest shared-rules
|
|
248
|
+
mkdirSync(join(ctx.agentPath, 'instructions'), { recursive: true });
|
|
249
|
+
writeFileSync(enginePath, getEngineRulesContent(), 'utf-8');
|
|
250
|
+
p.log.success(`Regenerated ${enginePath}`);
|
|
251
|
+
|
|
252
|
+
// 3. Recompose CLAUDE.md from agent.yaml + instructions + workflows + skills
|
|
253
|
+
const result = composeClaudeMd(ctx.agentPath);
|
|
254
|
+
writeFileSync(claudeMdPath, result.content, 'utf-8');
|
|
255
|
+
p.log.success(
|
|
256
|
+
`Regenerated ${claudeMdPath} (${result.sources.length} sources, ${result.content.length} bytes)`,
|
|
257
|
+
);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ─── Legacy TypeScript agent ──────────────────────────────
|
|
198
262
|
const config = readAgentConfig(ctx.agentPath, ctx.agentId);
|
|
199
263
|
if (!config) {
|
|
200
264
|
p.log.error('Could not read agent config from persona.ts and entry point.');
|
|
@@ -216,7 +280,6 @@ export function registerAgent(program: Command): void {
|
|
|
216
280
|
p.log.info(`Agent: ${config.name} (${config.domains.length} domains)`);
|
|
217
281
|
p.log.info(`Domains: ${config.domains.join(', ')}`);
|
|
218
282
|
if (skillFiles.length > 0) {
|
|
219
|
-
// Check which skills are new vs existing
|
|
220
283
|
const newSkills = skillFiles.filter(
|
|
221
284
|
([relPath]) => !existsSync(join(ctx.agentPath, relPath)),
|
|
222
285
|
);
|
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 = [
|