@soleri/cli 9.3.1 → 9.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/dist/commands/agent.js +51 -2
  2. package/dist/commands/agent.js.map +1 -1
  3. package/dist/commands/hooks.js +126 -0
  4. package/dist/commands/hooks.js.map +1 -1
  5. package/dist/commands/install.js +5 -0
  6. package/dist/commands/install.js.map +1 -1
  7. package/dist/commands/pack.js +62 -13
  8. package/dist/commands/pack.js.map +1 -1
  9. package/dist/commands/staging.d.ts +49 -0
  10. package/dist/commands/staging.js +108 -18
  11. package/dist/commands/staging.js.map +1 -1
  12. package/dist/commands/yolo.d.ts +2 -0
  13. package/dist/commands/yolo.js +86 -0
  14. package/dist/commands/yolo.js.map +1 -0
  15. package/dist/hook-packs/converter/README.md +99 -0
  16. package/dist/hook-packs/converter/template.d.ts +36 -0
  17. package/dist/hook-packs/converter/template.js +127 -0
  18. package/dist/hook-packs/converter/template.js.map +1 -0
  19. package/dist/hook-packs/converter/template.test.ts +133 -0
  20. package/dist/hook-packs/converter/template.ts +163 -0
  21. package/dist/hook-packs/flock-guard/README.md +65 -0
  22. package/dist/hook-packs/flock-guard/manifest.json +36 -0
  23. package/dist/hook-packs/flock-guard/scripts/flock-guard-post.sh +48 -0
  24. package/dist/hook-packs/flock-guard/scripts/flock-guard-pre.sh +85 -0
  25. package/dist/hook-packs/full/manifest.json +8 -1
  26. package/dist/hook-packs/graduation.d.ts +11 -0
  27. package/dist/hook-packs/graduation.js +48 -0
  28. package/dist/hook-packs/graduation.js.map +1 -0
  29. package/dist/hook-packs/graduation.ts +65 -0
  30. package/dist/hook-packs/installer.js +3 -1
  31. package/dist/hook-packs/installer.js.map +1 -1
  32. package/dist/hook-packs/installer.ts +3 -1
  33. package/dist/hook-packs/marketing-research/README.md +37 -0
  34. package/dist/hook-packs/marketing-research/manifest.json +24 -0
  35. package/dist/hook-packs/marketing-research/scripts/marketing-research.sh +70 -0
  36. package/dist/hook-packs/registry.d.ts +1 -0
  37. package/dist/hook-packs/registry.js +14 -4
  38. package/dist/hook-packs/registry.js.map +1 -1
  39. package/dist/hook-packs/registry.ts +18 -4
  40. package/dist/hook-packs/safety/README.md +50 -0
  41. package/dist/hook-packs/safety/manifest.json +23 -0
  42. package/dist/hook-packs/safety/scripts/anti-deletion.sh +280 -0
  43. package/dist/hook-packs/validator.d.ts +32 -0
  44. package/dist/hook-packs/validator.js +126 -0
  45. package/dist/hook-packs/validator.js.map +1 -0
  46. package/dist/hook-packs/validator.ts +158 -0
  47. package/dist/hook-packs/yolo-safety/manifest.json +3 -19
  48. package/dist/hook-packs/yolo-safety/scripts/anti-deletion.sh +121 -61
  49. package/dist/main.js +2 -0
  50. package/dist/main.js.map +1 -1
  51. package/package.json +1 -1
  52. package/src/__tests__/flock-guard.test.ts +225 -0
  53. package/src/__tests__/graduation.test.ts +199 -0
  54. package/src/__tests__/hook-packs.test.ts +45 -20
  55. package/src/__tests__/hooks-convert.test.ts +342 -0
  56. package/src/__tests__/validator.test.ts +265 -0
  57. package/src/__tests__/wizard-e2e.mjs +1 -1
  58. package/src/commands/agent.ts +65 -2
  59. package/src/commands/hooks.ts +172 -0
  60. package/src/commands/install.ts +6 -0
  61. package/src/commands/pack.ts +80 -14
  62. package/src/commands/staging.ts +143 -20
  63. package/src/commands/yolo.ts +103 -0
  64. package/src/hook-packs/converter/README.md +99 -0
  65. package/src/hook-packs/converter/template.test.ts +133 -0
  66. package/src/hook-packs/converter/template.ts +163 -0
  67. package/src/hook-packs/flock-guard/README.md +65 -0
  68. package/src/hook-packs/flock-guard/manifest.json +36 -0
  69. package/src/hook-packs/flock-guard/scripts/flock-guard-post.sh +48 -0
  70. package/src/hook-packs/flock-guard/scripts/flock-guard-pre.sh +85 -0
  71. package/src/hook-packs/full/manifest.json +8 -1
  72. package/src/hook-packs/graduation.ts +65 -0
  73. package/src/hook-packs/installer.ts +3 -1
  74. package/src/hook-packs/marketing-research/README.md +37 -0
  75. package/src/hook-packs/marketing-research/manifest.json +24 -0
  76. package/src/hook-packs/marketing-research/scripts/marketing-research.sh +70 -0
  77. package/src/hook-packs/registry.ts +18 -4
  78. package/src/hook-packs/safety/README.md +50 -0
  79. package/src/hook-packs/safety/manifest.json +23 -0
  80. package/src/hook-packs/safety/scripts/anti-deletion.sh +280 -0
  81. package/src/hook-packs/validator.ts +158 -0
  82. package/src/hook-packs/yolo-safety/manifest.json +3 -19
  83. package/src/main.ts +2 -0
  84. package/vitest.config.ts +1 -0
  85. package/src/__tests__/archetypes.test.ts +0 -84
  86. package/src/__tests__/create.test.ts +0 -207
  87. package/src/hook-packs/yolo-safety/scripts/anti-deletion.sh +0 -214
  88. package/src/prompts/archetypes.ts +0 -343
@@ -0,0 +1,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 all 7 archetypes, custom path, custom greeting, custom domains/principles,
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
@@ -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
- // Reconstruct AgentConfig from the existing agent
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
  );
@@ -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 {
@@ -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 = [