@soleri/cli 9.2.0 → 9.3.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.
@@ -27,7 +27,14 @@ describe('hook-packs', () => {
27
27
  const packs = listPacks();
28
28
  expect(packs.length).toBe(6);
29
29
  const names = packs.map((p) => p.name).sort();
30
- expect(names).toEqual(['a11y', 'clean-commits', 'css-discipline', 'full', 'typescript-safety', 'yolo-safety']);
30
+ expect(names).toEqual([
31
+ 'a11y',
32
+ 'clean-commits',
33
+ 'css-discipline',
34
+ 'full',
35
+ 'typescript-safety',
36
+ 'yolo-safety',
37
+ ]);
31
38
  });
32
39
 
33
40
  it('should get a specific pack by name', () => {
@@ -46,7 +53,11 @@ describe('hook-packs', () => {
46
53
  const pack = getPack('full');
47
54
  expect(pack).not.toBeNull();
48
55
  expect(pack!.manifest.composedFrom).toEqual([
49
- 'typescript-safety', 'a11y', 'css-discipline', 'clean-commits', 'yolo-safety',
56
+ 'typescript-safety',
57
+ 'a11y',
58
+ 'css-discipline',
59
+ 'clean-commits',
60
+ 'yolo-safety',
50
61
  ]);
51
62
  expect(pack!.manifest.hooks).toHaveLength(8);
52
63
  });
@@ -93,7 +104,16 @@ describe('hook-packs', () => {
93
104
  expect(result.installed).toHaveLength(8);
94
105
  expect(result.skipped).toEqual([]);
95
106
  const claudeDir = join(tempHome, '.claude');
96
- for (const hook of ['no-any-types', 'no-console-log', 'no-important', 'no-inline-styles', 'semantic-html', 'focus-ring-required', 'ux-touch-targets', 'no-ai-attribution']) {
107
+ for (const hook of [
108
+ 'no-any-types',
109
+ 'no-console-log',
110
+ 'no-important',
111
+ 'no-inline-styles',
112
+ 'semantic-html',
113
+ 'focus-ring-required',
114
+ 'ux-touch-targets',
115
+ 'no-ai-attribution',
116
+ ]) {
97
117
  expect(existsSync(join(claudeDir, `hookify.${hook}.local.md`))).toBe(true);
98
118
  }
99
119
  expect(result.scripts).toHaveLength(1);
@@ -70,8 +70,12 @@ function runWizard(name, actions, opts = {}) {
70
70
  stdio: ['pipe', 'pipe', 'pipe'],
71
71
  });
72
72
 
73
- proc.stdout.on('data', (d) => { buffer += d.toString(); });
74
- proc.stderr.on('data', (d) => { buffer += d.toString(); });
73
+ proc.stdout.on('data', (d) => {
74
+ buffer += d.toString();
75
+ });
76
+ proc.stderr.on('data', (d) => {
77
+ buffer += d.toString();
78
+ });
75
79
 
76
80
  async function drive() {
77
81
  while (actionIndex < actions.length && !state.completed) {
@@ -120,7 +124,9 @@ function runWizard(name, actions, opts = {}) {
120
124
  clearInterval(poller);
121
125
  proc.kill('SIGTERM');
122
126
  setTimeout(() => {
123
- try { proc.kill('SIGKILL'); } catch {}
127
+ try {
128
+ proc.kill('SIGKILL');
129
+ } catch {}
124
130
  resolve({
125
131
  exitCode: -1,
126
132
  output: stripAnsi(buffer) + '\n[TIMEOUT]',
@@ -157,13 +163,62 @@ function archetypeActions(outDir, { downCount = 0 } = {}) {
157
163
  // Note: agentId is slugify(label), not the archetype value.
158
164
  // e.g., "Full-Stack Assistant" → "full-stack-assistant"
159
165
  const ARCHETYPES = [
160
- { value: 'code-reviewer', agentId: 'code-reviewer', label: 'Code Reviewer', tone: 'mentor', totalSkills: 10, downCount: 0 },
161
- { value: 'security-auditor', agentId: 'security-auditor', label: 'Security Auditor', tone: 'precise', totalSkills: 10, downCount: 1 },
162
- { value: 'api-architect', agentId: 'api-architect', label: 'API Architect', tone: 'pragmatic', totalSkills: 10, downCount: 2 },
163
- { value: 'test-engineer', agentId: 'test-engineer', label: 'Test Engineer', tone: 'mentor', totalSkills: 10, downCount: 3 },
164
- { value: 'devops-pilot', agentId: 'devops-pilot', label: 'DevOps Pilot', tone: 'pragmatic', totalSkills: 10, downCount: 4 },
165
- { value: 'database-architect', agentId: 'database-architect', label: 'Database Architect', tone: 'precise', totalSkills: 10, downCount: 5 },
166
- { value: 'full-stack', agentId: 'full-stack-assistant', label: 'Full-Stack Assistant', tone: 'mentor', totalSkills: 11, downCount: 6 },
166
+ {
167
+ value: 'code-reviewer',
168
+ agentId: 'code-reviewer',
169
+ label: 'Code Reviewer',
170
+ tone: 'mentor',
171
+ totalSkills: 10,
172
+ downCount: 0,
173
+ },
174
+ {
175
+ value: 'security-auditor',
176
+ agentId: 'security-auditor',
177
+ label: 'Security Auditor',
178
+ tone: 'precise',
179
+ totalSkills: 10,
180
+ downCount: 1,
181
+ },
182
+ {
183
+ value: 'api-architect',
184
+ agentId: 'api-architect',
185
+ label: 'API Architect',
186
+ tone: 'pragmatic',
187
+ totalSkills: 10,
188
+ downCount: 2,
189
+ },
190
+ {
191
+ value: 'test-engineer',
192
+ agentId: 'test-engineer',
193
+ label: 'Test Engineer',
194
+ tone: 'mentor',
195
+ totalSkills: 10,
196
+ downCount: 3,
197
+ },
198
+ {
199
+ value: 'devops-pilot',
200
+ agentId: 'devops-pilot',
201
+ label: 'DevOps Pilot',
202
+ tone: 'pragmatic',
203
+ totalSkills: 10,
204
+ downCount: 4,
205
+ },
206
+ {
207
+ value: 'database-architect',
208
+ agentId: 'database-architect',
209
+ label: 'Database Architect',
210
+ tone: 'precise',
211
+ totalSkills: 10,
212
+ downCount: 5,
213
+ },
214
+ {
215
+ value: 'full-stack',
216
+ agentId: 'full-stack-assistant',
217
+ label: 'Full-Stack Assistant',
218
+ tone: 'mentor',
219
+ totalSkills: 11,
220
+ downCount: 6,
221
+ },
167
222
  ];
168
223
 
169
224
  // ══════════════════════════════════════════════════════════
@@ -172,43 +227,55 @@ const ARCHETYPES = [
172
227
 
173
228
  async function testCancelArchetype() {
174
229
  console.log('\n [1/14] Cancel at archetype (Ctrl+C)');
175
- const r = await runWizard('cancel-arch', [
176
- { waitFor: 'kind of agent', send: CTRL_C },
177
- ], { timeout: 15000 });
230
+ const r = await runWizard('cancel-arch', [{ waitFor: 'kind of agent', send: CTRL_C }], {
231
+ timeout: 15000,
232
+ });
178
233
  assert(r.actionsCompleted >= 1, 'prompt reached', 'cancel-archetype');
179
234
  }
180
235
 
181
236
  async function testCancelName() {
182
237
  console.log('\n [2/14] Cancel at display name');
183
- const r = await runWizard('cancel-name', [
184
- { waitFor: 'kind of agent', send: SPACE + ENTER },
185
- { waitFor: 'Display name', send: CTRL_C },
186
- ], { timeout: 15000 });
238
+ const r = await runWizard(
239
+ 'cancel-name',
240
+ [
241
+ { waitFor: 'kind of agent', send: SPACE + ENTER },
242
+ { waitFor: 'Display name', send: CTRL_C },
243
+ ],
244
+ { timeout: 15000 },
245
+ );
187
246
  assert(r.actionsCompleted >= 2, 'reached name prompt', 'cancel-name');
188
247
  }
189
248
 
190
249
  async function testCancelRole() {
191
250
  console.log('\n [3/14] Cancel at role');
192
- const r = await runWizard('cancel-role', [
193
- { waitFor: 'kind of agent', send: SPACE + ENTER },
194
- { waitFor: 'Display name', send: ENTER },
195
- { waitFor: 'Role', send: CTRL_C },
196
- ], { timeout: 15000 });
251
+ const r = await runWizard(
252
+ 'cancel-role',
253
+ [
254
+ { waitFor: 'kind of agent', send: SPACE + ENTER },
255
+ { waitFor: 'Display name', send: ENTER },
256
+ { waitFor: 'Role', send: CTRL_C },
257
+ ],
258
+ { timeout: 15000 },
259
+ );
197
260
  assert(r.actionsCompleted >= 3, 'reached role prompt', 'cancel-role');
198
261
  }
199
262
 
200
263
  async function testCancelSkills() {
201
264
  console.log('\n [4/14] Cancel at skills');
202
- const r = await runWizard('cancel-skills', [
203
- { waitFor: 'kind of agent', send: SPACE + ENTER },
204
- { waitFor: 'Display name', send: ENTER },
205
- { waitFor: 'Role', send: ENTER },
206
- { waitFor: 'Description', send: ENTER },
207
- { waitFor: /domain|expertise/i, send: ENTER },
208
- { waitFor: /principle|guiding/i, send: ENTER },
209
- { waitFor: /tone/i, send: ENTER },
210
- { waitFor: /skill/i, send: CTRL_C },
211
- ], { timeout: 15000 });
265
+ const r = await runWizard(
266
+ 'cancel-skills',
267
+ [
268
+ { waitFor: 'kind of agent', send: SPACE + ENTER },
269
+ { waitFor: 'Display name', send: ENTER },
270
+ { waitFor: 'Role', send: ENTER },
271
+ { waitFor: 'Description', send: ENTER },
272
+ { waitFor: /domain|expertise/i, send: ENTER },
273
+ { waitFor: /principle|guiding/i, send: ENTER },
274
+ { waitFor: /tone/i, send: ENTER },
275
+ { waitFor: /skill/i, send: CTRL_C },
276
+ ],
277
+ { timeout: 15000 },
278
+ );
212
279
  assert(r.actionsCompleted >= 8, 'reached skills prompt', 'cancel-skills');
213
280
  }
214
281
 
@@ -221,20 +288,24 @@ async function testDeclineConfirm() {
221
288
  const outDir = join(TEST_ROOT, 'decline');
222
289
  mkdirSync(outDir, { recursive: true });
223
290
 
224
- const r = await runWizard('decline', [
225
- { waitFor: 'kind of agent', send: SPACE + ENTER },
226
- { waitFor: 'Display name', send: ENTER },
227
- { waitFor: 'Role', send: ENTER },
228
- { waitFor: 'Description', send: ENTER },
229
- { waitFor: /domain|expertise/i, send: ENTER },
230
- { waitFor: /principle|guiding/i, send: ENTER },
231
- { waitFor: /tone/i, send: ENTER },
232
- { waitFor: /skill/i, send: ENTER },
233
- { waitFor: /greeting/i, send: ENTER },
234
- { waitFor: /output|directory/i, send: CTRL_U + outDir + ENTER, delay: 300 },
235
- { waitFor: /hook|pack/i, send: ENTER },
236
- { waitFor: /create agent/i, send: LEFT + ENTER },
237
- ], { timeout: 15000 });
291
+ const r = await runWizard(
292
+ 'decline',
293
+ [
294
+ { waitFor: 'kind of agent', send: SPACE + ENTER },
295
+ { waitFor: 'Display name', send: ENTER },
296
+ { waitFor: 'Role', send: ENTER },
297
+ { waitFor: 'Description', send: ENTER },
298
+ { waitFor: /domain|expertise/i, send: ENTER },
299
+ { waitFor: /principle|guiding/i, send: ENTER },
300
+ { waitFor: /tone/i, send: ENTER },
301
+ { waitFor: /skill/i, send: ENTER },
302
+ { waitFor: /greeting/i, send: ENTER },
303
+ { waitFor: /output|directory/i, send: CTRL_U + outDir + ENTER, delay: 300 },
304
+ { waitFor: /hook|pack/i, send: ENTER },
305
+ { waitFor: /create agent/i, send: LEFT + ENTER },
306
+ ],
307
+ { timeout: 15000 },
308
+ );
238
309
 
239
310
  assert(r.actionsCompleted >= 12, `all prompts reached (${r.actionsCompleted}/12)`, 'decline');
240
311
  assert(!existsSync(join(outDir, 'code-reviewer', 'package.json')), 'no agent created', 'decline');
@@ -264,8 +335,11 @@ async function testArchetype(arch, idx) {
264
335
  const personaPath = join(ad, 'src', 'identity', 'persona.ts');
265
336
  if (existsSync(personaPath)) {
266
337
  const persona = readFileSync(personaPath, 'utf-8');
267
- assert(persona.includes(`'${arch.label}'`) || persona.includes(`"${arch.label}"`),
268
- `name = ${arch.label}`, ctx);
338
+ assert(
339
+ persona.includes(`'${arch.label}'`) || persona.includes(`"${arch.label}"`),
340
+ `name = ${arch.label}`,
341
+ ctx,
342
+ );
269
343
  assert(persona.includes(`tone: '${arch.tone}'`), `tone = ${arch.tone}`, ctx);
270
344
  } else {
271
345
  assert(false, 'persona.ts exists', ctx);
@@ -275,9 +349,21 @@ async function testArchetype(arch, idx) {
275
349
  const skillsDir = join(ad, 'skills');
276
350
  if (existsSync(skillsDir)) {
277
351
  const skills = readdirSync(skillsDir);
278
- assert(skills.length === arch.totalSkills, `${arch.totalSkills} skills (got ${skills.length})`, ctx);
352
+ assert(
353
+ skills.length === arch.totalSkills,
354
+ `${arch.totalSkills} skills (got ${skills.length})`,
355
+ ctx,
356
+ );
279
357
  // Core skills always present
280
- for (const core of ['brainstorming', 'systematic-debugging', 'verification-before-completion', 'health-check', 'context-resume', 'writing-plans', 'executing-plans']) {
358
+ for (const core of [
359
+ 'brainstorming',
360
+ 'systematic-debugging',
361
+ 'verification-before-completion',
362
+ 'health-check',
363
+ 'context-resume',
364
+ 'writing-plans',
365
+ 'executing-plans',
366
+ ]) {
281
367
  assert(skills.includes(core), `core skill: ${core}`, ctx);
282
368
  }
283
369
  } else {
@@ -314,8 +400,9 @@ async function testCustomArchetype() {
314
400
  const customName = 'GraphQL Guardian';
315
401
  const customId = 'graphql-guardian';
316
402
  const customRole = 'Validates GraphQL schemas against federation rules';
317
- const customDesc = 'This agent checks GraphQL schemas for breaking changes, naming conventions, and federation compatibility across subgraphs.';
318
- const customGreeting = "Hey! Drop your GraphQL schema and I will check it for issues.";
403
+ const customDesc =
404
+ 'This agent checks GraphQL schemas for breaking changes, naming conventions, and federation compatibility across subgraphs.';
405
+ const customGreeting = 'Hey! Drop your GraphQL schema and I will check it for issues.';
319
406
 
320
407
  const r = await runWizard('custom', [
321
408
  // Step 1: Select "✦ Create Custom" (9 downs — 9 archetypes before _custom)
@@ -426,7 +513,11 @@ async function testHookPacks() {
426
513
  // Validate hooks were installed
427
514
  const output = r.output;
428
515
  assert(output.includes('a11y') && output.includes('installed'), 'a11y pack installed', ctx);
429
- assert(output.includes('typescript-safety') && output.includes('installed'), 'typescript-safety pack installed', ctx);
516
+ assert(
517
+ output.includes('typescript-safety') && output.includes('installed'),
518
+ 'typescript-safety pack installed',
519
+ ctx,
520
+ );
430
521
 
431
522
  // Check .claude directory has hooks
432
523
  const claudeDir = join(ad, '.claude');
@@ -435,7 +526,9 @@ async function testHookPacks() {
435
526
  assert(files.length > 0, `.claude/ has hook files (${files.length})`, ctx);
436
527
  }
437
528
 
438
- console.log(` exit=${r.exitCode}, agent=${existsSync(ad)}, hooks=${r.output.includes('installed')}`);
529
+ console.log(
530
+ ` exit=${r.exitCode}, agent=${existsSync(ad)}, hooks=${r.output.includes('installed')}`,
531
+ );
439
532
  }
440
533
 
441
534
  // ══════════════════════════════════════════════════════════
@@ -474,10 +567,7 @@ rmSync(TEST_ROOT, { recursive: true, force: true });
474
567
 
475
568
  // Clean up any MCP registrations
476
569
  try {
477
- const claudeJson = join(
478
- process.env.HOME || process.env.USERPROFILE || '',
479
- '.claude.json',
480
- );
570
+ const claudeJson = join(process.env.HOME || process.env.USERPROFILE || '', '.claude.json');
481
571
  if (existsSync(claudeJson)) {
482
572
  const c = JSON.parse(readFileSync(claudeJson, 'utf-8'));
483
573
  let changed = false;
@@ -9,94 +9,198 @@ import * as log from '../utils/logger.js';
9
9
  export function registerHooks(program: Command): void {
10
10
  const hooks = program.command('hooks').description('Manage editor hooks and hook packs');
11
11
 
12
- hooks.command('add').argument('<editor>', `Editor: ${SUPPORTED_EDITORS.join(', ')}`).description('Generate editor hooks/config files').action((editor: string) => {
13
- if (!isValidEditor(editor)) { log.fail(`Unknown editor "${editor}". Supported: ${SUPPORTED_EDITORS.join(', ')}`); process.exit(1); }
14
- const ctx = detectAgent();
15
- if (!ctx) { log.fail('No agent project detected in current directory.'); process.exit(1); }
16
- const files = installHooks(editor, ctx.agentPath);
17
- for (const f of files) { log.pass(`Created ${f}`); }
18
- log.info(`${editor} hooks installed for ${ctx.agentId}`);
19
- });
12
+ hooks
13
+ .command('add')
14
+ .argument('<editor>', `Editor: ${SUPPORTED_EDITORS.join(', ')}`)
15
+ .description('Generate editor hooks/config files')
16
+ .action((editor: string) => {
17
+ if (!isValidEditor(editor)) {
18
+ log.fail(`Unknown editor "${editor}". Supported: ${SUPPORTED_EDITORS.join(', ')}`);
19
+ process.exit(1);
20
+ }
21
+ const ctx = detectAgent();
22
+ if (!ctx) {
23
+ log.fail('No agent project detected in current directory.');
24
+ process.exit(1);
25
+ }
26
+ const files = installHooks(editor, ctx.agentPath);
27
+ for (const f of files) {
28
+ log.pass(`Created ${f}`);
29
+ }
30
+ log.info(`${editor} hooks installed for ${ctx.agentId}`);
31
+ });
20
32
 
21
- hooks.command('remove').argument('<editor>', `Editor: ${SUPPORTED_EDITORS.join(', ')}`).description('Remove editor hooks/config files').action((editor: string) => {
22
- if (!isValidEditor(editor)) { log.fail(`Unknown editor "${editor}". Supported: ${SUPPORTED_EDITORS.join(', ')}`); process.exit(1); }
23
- const ctx = detectAgent();
24
- if (!ctx) { log.fail('No agent project detected in current directory.'); process.exit(1); }
25
- const removed = removeHooks(editor, ctx.agentPath);
26
- if (removed.length === 0) { log.info(`No ${editor} hooks found to remove.`); } else {
27
- for (const f of removed) { log.warn(`Removed ${f}`); }
28
- log.info(`${editor} hooks removed from ${ctx.agentId}`);
29
- }
30
- });
33
+ hooks
34
+ .command('remove')
35
+ .argument('<editor>', `Editor: ${SUPPORTED_EDITORS.join(', ')}`)
36
+ .description('Remove editor hooks/config files')
37
+ .action((editor: string) => {
38
+ if (!isValidEditor(editor)) {
39
+ log.fail(`Unknown editor "${editor}". Supported: ${SUPPORTED_EDITORS.join(', ')}`);
40
+ process.exit(1);
41
+ }
42
+ const ctx = detectAgent();
43
+ if (!ctx) {
44
+ log.fail('No agent project detected in current directory.');
45
+ process.exit(1);
46
+ }
47
+ const removed = removeHooks(editor, ctx.agentPath);
48
+ if (removed.length === 0) {
49
+ log.info(`No ${editor} hooks found to remove.`);
50
+ } else {
51
+ for (const f of removed) {
52
+ log.warn(`Removed ${f}`);
53
+ }
54
+ log.info(`${editor} hooks removed from ${ctx.agentId}`);
55
+ }
56
+ });
31
57
 
32
- hooks.command('list').description('Show which editor hooks are installed').action(() => {
33
- const ctx = detectAgent();
34
- if (!ctx) { log.fail('No agent project detected in current directory.'); process.exit(1); }
35
- const installed = detectInstalledHooks(ctx.agentPath);
36
- log.heading(`Editor hooks for ${ctx.agentId}`);
37
- for (const editor of SUPPORTED_EDITORS) {
38
- if (installed.includes(editor)) { log.pass(editor, 'installed'); } else { log.dim(` ${editor} not installed`); }
39
- }
40
- });
58
+ hooks
59
+ .command('list')
60
+ .description('Show which editor hooks are installed')
61
+ .action(() => {
62
+ const ctx = detectAgent();
63
+ if (!ctx) {
64
+ log.fail('No agent project detected in current directory.');
65
+ process.exit(1);
66
+ }
67
+ const installed = detectInstalledHooks(ctx.agentPath);
68
+ log.heading(`Editor hooks for ${ctx.agentId}`);
69
+ for (const editor of SUPPORTED_EDITORS) {
70
+ if (installed.includes(editor)) {
71
+ log.pass(editor, 'installed');
72
+ } else {
73
+ log.dim(` ${editor} — not installed`);
74
+ }
75
+ }
76
+ });
41
77
 
42
- hooks.command('add-pack').argument('<pack>', 'Hook pack name').option('--project', 'Install to project .claude/ instead of global ~/.claude/').description('Install a hook pack globally (~/.claude/) or per-project (--project)').action((packName: string, opts: { project?: boolean }) => {
43
- const pack = getPack(packName);
44
- if (!pack) { const available = listPacks().map((p) => p.name); log.fail(`Unknown pack "${packName}". Available: ${available.join(', ')}`); process.exit(1); }
45
- const projectDir = opts.project ? process.cwd() : undefined;
46
- const target = opts.project ? '.claude/' : '~/.claude/';
47
- const { installed, skipped, scripts, lifecycleHooks } = installPack(packName, { projectDir });
48
- for (const hook of installed) { log.pass(`Installed hookify.${hook}.local.md → ${target}`); }
49
- for (const hook of skipped) { log.dim(` hookify.${hook}.local.md — already exists, skipped`); }
50
- for (const script of scripts) { log.pass(`Installed ${script} → ${target}`); }
51
- for (const lc of lifecycleHooks) { log.pass(`Registered lifecycle hook: ${lc}`); }
52
- const totalInstalled = installed.length + scripts.length + lifecycleHooks.length;
53
- if (totalInstalled > 0) { log.info(`Pack "${packName}" installed (${totalInstalled} items) → ${target}`); } else { log.info(`Pack "${packName}" — all hooks already installed`); }
54
- });
78
+ hooks
79
+ .command('add-pack')
80
+ .argument('<pack>', 'Hook pack name')
81
+ .option('--project', 'Install to project .claude/ instead of global ~/.claude/')
82
+ .description('Install a hook pack globally (~/.claude/) or per-project (--project)')
83
+ .action((packName: string, opts: { project?: boolean }) => {
84
+ const pack = getPack(packName);
85
+ if (!pack) {
86
+ const available = listPacks().map((p) => p.name);
87
+ log.fail(`Unknown pack "${packName}". Available: ${available.join(', ')}`);
88
+ process.exit(1);
89
+ }
90
+ const projectDir = opts.project ? process.cwd() : undefined;
91
+ const target = opts.project ? '.claude/' : '~/.claude/';
92
+ const { installed, skipped, scripts, lifecycleHooks } = installPack(packName, { projectDir });
93
+ for (const hook of installed) {
94
+ log.pass(`Installed hookify.${hook}.local.md → ${target}`);
95
+ }
96
+ for (const hook of skipped) {
97
+ log.dim(` hookify.${hook}.local.md — already exists, skipped`);
98
+ }
99
+ for (const script of scripts) {
100
+ log.pass(`Installed ${script} → ${target}`);
101
+ }
102
+ for (const lc of lifecycleHooks) {
103
+ log.pass(`Registered lifecycle hook: ${lc}`);
104
+ }
105
+ const totalInstalled = installed.length + scripts.length + lifecycleHooks.length;
106
+ if (totalInstalled > 0) {
107
+ log.info(`Pack "${packName}" installed (${totalInstalled} items) → ${target}`);
108
+ } else {
109
+ log.info(`Pack "${packName}" — all hooks already installed`);
110
+ }
111
+ });
55
112
 
56
- hooks.command('remove-pack').argument('<pack>', 'Hook pack name').option('--project', 'Remove from project .claude/ instead of global ~/.claude/').description('Remove a hook pack').action((packName: string, opts: { project?: boolean }) => {
57
- const pack = getPack(packName);
58
- if (!pack) { const available = listPacks().map((p) => p.name); log.fail(`Unknown pack "${packName}". Available: ${available.join(', ')}`); process.exit(1); }
59
- const projectDir = opts.project ? process.cwd() : undefined;
60
- const { removed, scripts, lifecycleHooks } = removePack(packName, { projectDir });
61
- const totalRemoved = removed.length + scripts.length + lifecycleHooks.length;
62
- if (totalRemoved === 0) { log.info(`No hooks from pack "${packName}" found to remove.`); } else {
63
- for (const hook of removed) { log.warn(`Removed hookify.${hook}.local.md`); }
64
- for (const script of scripts) { log.warn(`Removed ${script}`); }
65
- for (const lc of lifecycleHooks) { log.warn(`Removed lifecycle hook: ${lc}`); }
66
- log.info(`Pack "${packName}" removed (${totalRemoved} items)`);
67
- }
68
- });
113
+ hooks
114
+ .command('remove-pack')
115
+ .argument('<pack>', 'Hook pack name')
116
+ .option('--project', 'Remove from project .claude/ instead of global ~/.claude/')
117
+ .description('Remove a hook pack')
118
+ .action((packName: string, opts: { project?: boolean }) => {
119
+ const pack = getPack(packName);
120
+ if (!pack) {
121
+ const available = listPacks().map((p) => p.name);
122
+ log.fail(`Unknown pack "${packName}". Available: ${available.join(', ')}`);
123
+ process.exit(1);
124
+ }
125
+ const projectDir = opts.project ? process.cwd() : undefined;
126
+ const { removed, scripts, lifecycleHooks } = removePack(packName, { projectDir });
127
+ const totalRemoved = removed.length + scripts.length + lifecycleHooks.length;
128
+ if (totalRemoved === 0) {
129
+ log.info(`No hooks from pack "${packName}" found to remove.`);
130
+ } else {
131
+ for (const hook of removed) {
132
+ log.warn(`Removed hookify.${hook}.local.md`);
133
+ }
134
+ for (const script of scripts) {
135
+ log.warn(`Removed ${script}`);
136
+ }
137
+ for (const lc of lifecycleHooks) {
138
+ log.warn(`Removed lifecycle hook: ${lc}`);
139
+ }
140
+ log.info(`Pack "${packName}" removed (${totalRemoved} items)`);
141
+ }
142
+ });
69
143
 
70
- hooks.command('list-packs').description('Show available hook packs and their status').action(() => {
71
- const packs = listPacks();
72
- log.heading('Hook Packs');
73
- for (const pack of packs) {
74
- const status = isPackInstalled(pack.name);
75
- const versionLabel = pack.version ? ` v${pack.version}` : '';
76
- const sourceLabel = pack.source === 'local' ? ' [local]' : '';
77
- const hookCount = pack.hooks.length;
78
- const scriptCount = pack.scripts?.length ?? 0;
79
- const itemCount = hookCount + scriptCount;
80
- const itemLabel = itemCount === 1 ? '1 item' : `${itemCount} items`;
81
- if (status === true) { log.pass(`${pack.name}${versionLabel}${sourceLabel}`, `${pack.description} (${itemLabel})`); }
82
- else if (status === 'partial') { log.warn(`${pack.name}${versionLabel}${sourceLabel}`, `${pack.description} (${itemLabel}) — partial`); }
83
- else { log.dim(` ${pack.name}${versionLabel}${sourceLabel} ${pack.description} (${itemLabel})`); }
84
- }
85
- });
144
+ hooks
145
+ .command('list-packs')
146
+ .description('Show available hook packs and their status')
147
+ .action(() => {
148
+ const packs = listPacks();
149
+ log.heading('Hook Packs');
150
+ for (const pack of packs) {
151
+ const status = isPackInstalled(pack.name);
152
+ const versionLabel = pack.version ? ` v${pack.version}` : '';
153
+ const sourceLabel = pack.source === 'local' ? ' [local]' : '';
154
+ const hookCount = pack.hooks.length;
155
+ const scriptCount = pack.scripts?.length ?? 0;
156
+ const itemCount = hookCount + scriptCount;
157
+ const itemLabel = itemCount === 1 ? '1 item' : `${itemCount} items`;
158
+ if (status === true) {
159
+ log.pass(
160
+ `${pack.name}${versionLabel}${sourceLabel}`,
161
+ `${pack.description} (${itemLabel})`,
162
+ );
163
+ } else if (status === 'partial') {
164
+ log.warn(
165
+ `${pack.name}${versionLabel}${sourceLabel}`,
166
+ `${pack.description} (${itemLabel}) — partial`,
167
+ );
168
+ } else {
169
+ log.dim(
170
+ ` ${pack.name}${versionLabel}${sourceLabel} — ${pack.description} (${itemLabel})`,
171
+ );
172
+ }
173
+ }
174
+ });
86
175
 
87
- hooks.command('upgrade-pack').argument('<pack>', 'Hook pack name').option('--project', 'Upgrade in project .claude/ instead of global ~/.claude/').description('Upgrade a hook pack to the latest version (overwrites existing files)').action((packName: string, opts: { project?: boolean }) => {
88
- const pack = getPack(packName);
89
- if (!pack) { const available = listPacks().map((p) => p.name); log.fail(`Unknown pack "${packName}". Available: ${available.join(', ')}`); process.exit(1); }
90
- const projectDir = opts.project ? process.cwd() : undefined;
91
- const packVersion = pack.manifest.version ?? 'unknown';
92
- removePack(packName, { projectDir });
93
- const { installed, scripts, lifecycleHooks } = installPack(packName, { projectDir });
94
- for (const hook of installed) { log.pass(`hookify.${hook}.local.md → v${packVersion}`); }
95
- for (const script of scripts) { log.pass(`${script} v${packVersion}`); }
96
- for (const lc of lifecycleHooks) { log.pass(`lifecycle hook ${lc} v${packVersion}`); }
97
- const total = installed.length + scripts.length + lifecycleHooks.length;
98
- log.info(`Pack "${packName}" upgraded to v${packVersion} (${total} items)`);
99
- });
176
+ hooks
177
+ .command('upgrade-pack')
178
+ .argument('<pack>', 'Hook pack name')
179
+ .option('--project', 'Upgrade in project .claude/ instead of global ~/.claude/')
180
+ .description('Upgrade a hook pack to the latest version (overwrites existing files)')
181
+ .action((packName: string, opts: { project?: boolean }) => {
182
+ const pack = getPack(packName);
183
+ if (!pack) {
184
+ const available = listPacks().map((p) => p.name);
185
+ log.fail(`Unknown pack "${packName}". Available: ${available.join(', ')}`);
186
+ process.exit(1);
187
+ }
188
+ const projectDir = opts.project ? process.cwd() : undefined;
189
+ const packVersion = pack.manifest.version ?? 'unknown';
190
+ removePack(packName, { projectDir });
191
+ const { installed, scripts, lifecycleHooks } = installPack(packName, { projectDir });
192
+ for (const hook of installed) {
193
+ log.pass(`hookify.${hook}.local.md → v${packVersion}`);
194
+ }
195
+ for (const script of scripts) {
196
+ log.pass(`${script} → v${packVersion}`);
197
+ }
198
+ for (const lc of lifecycleHooks) {
199
+ log.pass(`lifecycle hook ${lc} → v${packVersion}`);
200
+ }
201
+ const total = installed.length + scripts.length + lifecycleHooks.length;
202
+ log.info(`Pack "${packName}" upgraded to v${packVersion} (${total} items)`);
203
+ });
100
204
  }
101
205
 
102
206
  function isValidEditor(editor: string): editor is EditorId {