@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
@@ -13,6 +13,18 @@ import type { Command } from 'commander';
13
13
  import * as p from '@clack/prompts';
14
14
  import { PackLockfile, inferPackType, resolvePack, checkNpmVersion } from '@soleri/core';
15
15
  import type { LockEntry, PackSource } from '@soleri/core';
16
+
17
+ // ─── Tier display helpers ────────────────────────────────────────────
18
+
19
+ const TIER_BADGES: Record<string, string> = {
20
+ default: '[default]',
21
+ community: '[community]',
22
+ premium: '[premium]',
23
+ };
24
+
25
+ function tierBadge(tier?: string): string {
26
+ return TIER_BADGES[tier ?? 'community'] ?? '[community]';
27
+ }
16
28
  import { detectAgent } from '../utils/agent-context.js';
17
29
 
18
30
  const LOCKFILE_NAME = 'soleri.lock';
@@ -45,14 +57,18 @@ export function registerPack(program: Command): void {
45
57
  pack
46
58
  .command('list')
47
59
  .option('--type <type>', 'Filter by pack type (hooks, skills, knowledge, domain, bundle)')
60
+ .option('--tier <tier>', 'Filter by tier (default, community, premium)')
48
61
  .description('List installed packs')
49
- .action((opts: { type?: string }) => {
62
+ .action((opts: { type?: string; tier?: string }) => {
50
63
  const lockfile = new PackLockfile(getLockfilePath());
51
64
  let entries = lockfile.list();
52
65
 
53
66
  if (opts.type) {
54
67
  entries = entries.filter((e) => e.type === opts.type);
55
68
  }
69
+ if (opts.tier) {
70
+ entries = entries.filter((e) => (e.tier ?? 'community') === opts.tier);
71
+ }
56
72
 
57
73
  if (entries.length === 0) {
58
74
  p.log.info('No packs installed.');
@@ -61,9 +77,10 @@ export function registerPack(program: Command): void {
61
77
 
62
78
  p.log.info(`${entries.length} pack(s) installed:\n`);
63
79
  for (const entry of entries) {
64
- const badge =
80
+ const source =
65
81
  entry.source === 'built-in' ? ' [built-in]' : entry.source === 'npm' ? ' [npm]' : '';
66
- console.log(` ${entry.id}@${entry.version} ${entry.type}${badge}`);
82
+ const tier = ` ${tierBadge(entry.tier)}`;
83
+ console.log(` ${entry.id}@${entry.version} ${entry.type}${tier}${source}`);
67
84
  if (entry.vaultEntries > 0) console.log(` vault: ${entry.vaultEntries} entries`);
68
85
  if (entry.skills.length > 0) console.log(` skills: ${entry.skills.join(', ')}`);
69
86
  if (entry.hooks.length > 0) console.log(` hooks: ${entry.hooks.join(', ')}`);
@@ -163,6 +180,7 @@ export function registerPack(program: Command): void {
163
180
  skills,
164
181
  hooks,
165
182
  facadesRegistered: (manifest.facades?.length ?? 0) > 0,
183
+ tier: manifest.tier ?? 'community',
166
184
  };
167
185
 
168
186
  lockfile.set(entry);
@@ -219,9 +237,14 @@ export function registerPack(program: Command): void {
219
237
  return;
220
238
  }
221
239
 
240
+ const tierLabel = entry.tier ?? 'community';
241
+ const tierNote =
242
+ tierLabel === 'premium' ? ' (currently unlocked — premium platform coming soon)' : '';
243
+
222
244
  console.log(`\n Pack: ${entry.id}`);
223
245
  console.log(` Version: ${entry.version}`);
224
246
  console.log(` Type: ${entry.type}`);
247
+ console.log(` Tier: ${tierLabel}${tierNote}`);
225
248
  console.log(` Source: ${entry.source}`);
226
249
  console.log(` Directory: ${entry.directory}`);
227
250
  console.log(` Installed: ${entry.installedAt}`);
@@ -378,7 +401,16 @@ export function registerPack(program: Command): void {
378
401
  return;
379
402
  }
380
403
 
381
- let total = 0;
404
+ // Collect all packs with their tier
405
+ const allPacks: Array<{
406
+ id: string;
407
+ version: string;
408
+ description: string;
409
+ domains: string;
410
+ tier: string;
411
+ category: string;
412
+ }> = [];
413
+
382
414
  for (const baseDir of searchDirs) {
383
415
  const categories = readdirSync(baseDir, { withFileTypes: true })
384
416
  .filter((d) => d.isDirectory())
@@ -390,18 +422,19 @@ export function registerPack(program: Command): void {
390
422
  (d) => d.isDirectory() && existsSync(join(categoryDir, d.name, 'soleri-pack.json')),
391
423
  );
392
424
 
393
- if (packs.length === 0) continue;
394
-
395
- console.log(`\n ${category}/`);
396
425
  for (const pk of packs) {
397
426
  try {
398
427
  const manifest = JSON.parse(
399
428
  readFileSync(join(categoryDir, pk.name, 'soleri-pack.json'), 'utf-8'),
400
429
  );
401
- const domains = (manifest.domains as string[])?.join(', ') || '—';
402
- console.log(` ${manifest.id}@${manifest.version} ${manifest.description || ''}`);
403
- console.log(` domains: ${domains}`);
404
- total++;
430
+ allPacks.push({
431
+ id: manifest.id,
432
+ version: manifest.version,
433
+ description: manifest.description || '',
434
+ domains: (manifest.domains as string[])?.join(', ') || '—',
435
+ tier: manifest.tier ?? 'community',
436
+ category,
437
+ });
405
438
  } catch {
406
439
  // skip malformed packs
407
440
  }
@@ -409,11 +442,31 @@ export function registerPack(program: Command): void {
409
442
  }
410
443
  }
411
444
 
412
- if (total === 0) {
445
+ if (allPacks.length === 0) {
413
446
  p.log.info('No packs found.');
414
- } else {
415
- console.log(`\n ${total} pack(s) available.\n`);
447
+ return;
416
448
  }
449
+
450
+ // Group by tier and display
451
+ const tierOrder: Array<{ key: string; label: string }> = [
452
+ { key: 'default', label: 'Default (included with Soleri)' },
453
+ { key: 'community', label: 'Community (free)' },
454
+ { key: 'premium', label: 'Premium (included — premium platform coming soon)' },
455
+ ];
456
+
457
+ for (const { key, label } of tierOrder) {
458
+ const tierPacks = allPacks.filter((pk) => pk.tier === key);
459
+ if (tierPacks.length === 0) continue;
460
+
461
+ console.log(`\n ${label}`);
462
+ console.log(` ${'─'.repeat(label.length)}`);
463
+ for (const pk of tierPacks) {
464
+ console.log(` ${pk.id}@${pk.version} ${pk.description}`);
465
+ console.log(` domains: ${pk.domains}`);
466
+ }
467
+ }
468
+
469
+ console.log(`\n ${allPacks.length} pack(s) available.\n`);
417
470
  });
418
471
 
419
472
  // ─── create ─────────────────────────────────────────────────
@@ -441,6 +494,18 @@ export function registerPack(program: Command): void {
441
494
  });
442
495
  if (p.isCancel(description)) return;
443
496
 
497
+ const tier = await p.select({
498
+ message: 'Pack tier:',
499
+ options: [
500
+ { value: 'community', label: 'Community — free, published to npm' },
501
+ {
502
+ value: 'premium',
503
+ label: 'Premium — requires Soleri platform account (coming soon)',
504
+ },
505
+ ],
506
+ });
507
+ if (p.isCancel(tier)) return;
508
+
444
509
  const author = await p.text({ message: 'Author:', placeholder: '@username' });
445
510
  if (p.isCancel(author)) return;
446
511
 
@@ -454,6 +519,7 @@ export function registerPack(program: Command): void {
454
519
  id: name,
455
520
  version: '1.0.0',
456
521
  description: description || '',
522
+ tier: tier || 'community',
457
523
  author: author || '',
458
524
  license: 'MIT',
459
525
  soleri: '>=2.0.0',
@@ -4,9 +4,12 @@ import { join, relative } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import * as log from '../utils/logger.js';
6
6
 
7
- const STAGING_ROOT = join(homedir(), '.soleri', 'staging');
7
+ export const STAGING_ROOT = join(homedir(), '.soleri', 'staging');
8
8
 
9
- interface StagedEntry {
9
+ /** Default max age for stale staging entries (7 days). */
10
+ const DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
11
+
12
+ export interface StagedEntry {
10
13
  id: string;
11
14
  timestamp: string;
12
15
  path: string;
@@ -17,7 +20,7 @@ interface StagedEntry {
17
20
  /**
18
21
  * Walk a directory tree and collect all items with their relative paths.
19
22
  */
20
- function walkDir(dir: string, base: string): { relPath: string; size: number }[] {
23
+ export function walkDir(dir: string, base: string): { relPath: string; size: number }[] {
21
24
  const results: { relPath: string; size: number }[] = [];
22
25
  if (!existsSync(dir)) return results;
23
26
 
@@ -38,7 +41,7 @@ function walkDir(dir: string, base: string): { relPath: string; size: number }[]
38
41
  /**
39
42
  * List all staged entries.
40
43
  */
41
- function listStaged(): StagedEntry[] {
44
+ export function listStaged(): StagedEntry[] {
42
45
  if (!existsSync(STAGING_ROOT)) return [];
43
46
 
44
47
  const entries: StagedEntry[] = [];
@@ -66,7 +69,7 @@ function listStaged(): StagedEntry[] {
66
69
  /**
67
70
  * Parse a duration string like "7d", "24h", "30m" into milliseconds.
68
71
  */
69
- function parseDuration(duration: string): number | null {
72
+ export function parseDuration(duration: string): number | null {
70
73
  const match = duration.match(/^(\d+)(d|h|m)$/);
71
74
  if (!match) return null;
72
75
 
@@ -85,12 +88,78 @@ function parseDuration(duration: string): number | null {
85
88
  }
86
89
  }
87
90
 
88
- function formatSize(bytes: number): string {
91
+ export function formatSize(bytes: number): string {
89
92
  if (bytes < 1024) return `${bytes} B`;
90
93
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
91
94
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
92
95
  }
93
96
 
97
+ // ─── Reusable Utility Functions ──────────────────────────────────────
98
+
99
+ export interface StaleStagingInfo {
100
+ /** Entries older than maxAge. */
101
+ staleEntries: StagedEntry[];
102
+ /** Total bytes across stale entries. */
103
+ totalBytes: number;
104
+ /** Human-readable total size. */
105
+ totalSize: string;
106
+ /** Number of stale entries. */
107
+ count: number;
108
+ }
109
+
110
+ /**
111
+ * Check for staging entries older than a given age.
112
+ * Pure function — no I/O side effects beyond reading the filesystem.
113
+ *
114
+ * @param maxAgeMs - Maximum age in milliseconds (default: 7 days)
115
+ * @returns Info about stale entries, or null if none found.
116
+ */
117
+ export function getStaleStagingInfo(
118
+ maxAgeMs: number = DEFAULT_MAX_AGE_MS,
119
+ ): StaleStagingInfo | null {
120
+ const entries = listStaged();
121
+ if (entries.length === 0) return null;
122
+
123
+ const cutoff = Date.now() - maxAgeMs;
124
+ const staleEntries = entries.filter((entry) => {
125
+ try {
126
+ const stat = statSync(entry.path);
127
+ return stat.mtimeMs < cutoff;
128
+ } catch {
129
+ return false;
130
+ }
131
+ });
132
+
133
+ if (staleEntries.length === 0) return null;
134
+
135
+ const totalBytes = staleEntries.reduce((sum, e) => sum + e.size, 0);
136
+ return {
137
+ staleEntries,
138
+ totalBytes,
139
+ totalSize: formatSize(totalBytes),
140
+ count: staleEntries.length,
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Purge stale staging entries. Returns the number of entries removed.
146
+ *
147
+ * @param entries - Entries to purge (from getStaleStagingInfo().staleEntries)
148
+ * @returns Number of entries successfully removed.
149
+ */
150
+ export function purgeStagingEntries(entries: StagedEntry[]): number {
151
+ let removed = 0;
152
+ for (const entry of entries) {
153
+ try {
154
+ rmSync(entry.path, { recursive: true, force: true });
155
+ removed++;
156
+ } catch {
157
+ // Skip failures silently — entry may have been removed concurrently
158
+ }
159
+ }
160
+ return removed;
161
+ }
162
+
94
163
  export function registerStaging(program: Command): void {
95
164
  const staging = program.command('staging').description('Manage anti-deletion staging folder');
96
165
 
@@ -161,25 +230,31 @@ export function registerStaging(program: Command): void {
161
230
  });
162
231
 
163
232
  staging
164
- .command('purge')
165
- .option('--older-than <duration>', 'Only purge snapshots older than duration (e.g. 7d, 24h)')
166
- .description('Permanently delete staged files')
167
- .action((opts: { olderThan?: string }) => {
233
+ .command('clean')
234
+ .option(
235
+ '--older-than <duration>',
236
+ 'Only remove snapshots older than duration (default: 7d)',
237
+ '7d',
238
+ )
239
+ .option('--all', 'Remove all snapshots regardless of age')
240
+ .option('--dry-run', 'Show what would be removed without deleting')
241
+ .description('Remove staging backups older than 7 days (or --all)')
242
+ .action((opts: { olderThan: string; all?: boolean; dryRun?: boolean }) => {
168
243
  if (!existsSync(STAGING_ROOT)) {
169
- log.info('No staging directory found. Nothing to purge.');
244
+ log.info('No staging directory found. Nothing to clean.');
170
245
  return;
171
246
  }
172
247
 
173
248
  const entries = listStaged();
174
249
 
175
250
  if (entries.length === 0) {
176
- log.info('No staged files to purge.');
251
+ log.info('No staged files to clean.');
177
252
  return;
178
253
  }
179
254
 
180
- let toPurge = entries;
255
+ let toClean = entries;
181
256
 
182
- if (opts.olderThan) {
257
+ if (!opts.all) {
183
258
  const maxAge = parseDuration(opts.olderThan);
184
259
  if (!maxAge) {
185
260
  log.fail(`Invalid duration: "${opts.olderThan}". Use format like 7d, 24h, 30m`);
@@ -187,22 +262,70 @@ export function registerStaging(program: Command): void {
187
262
  }
188
263
 
189
264
  const cutoff = Date.now() - maxAge;
190
- toPurge = entries.filter((entry) => {
265
+ toClean = entries.filter((entry) => {
191
266
  const stat = statSync(entry.path);
192
267
  return stat.mtimeMs < cutoff;
193
268
  });
194
269
  }
195
270
 
196
- if (toPurge.length === 0) {
197
- log.info('No snapshots match the purge criteria.');
271
+ if (toClean.length === 0) {
272
+ log.info('No snapshots match the clean criteria.');
198
273
  return;
199
274
  }
200
275
 
201
- for (const entry of toPurge) {
276
+ if (opts.dryRun) {
277
+ log.heading('Dry run — would remove:');
278
+ for (const entry of toClean) {
279
+ log.warn(`${entry.id}`, formatSize(entry.size));
280
+ }
281
+ log.info(`Would remove ${toClean.length} staging snapshot(s)`);
282
+ return;
283
+ }
284
+
285
+ for (const entry of toClean) {
202
286
  rmSync(entry.path, { recursive: true, force: true });
203
- log.warn(`Purged ${entry.id}`);
287
+ log.warn(`Removed ${entry.id}`);
288
+ }
289
+
290
+ log.info(`Removed ${toClean.length} staging snapshot(s)`);
291
+ });
292
+
293
+ staging
294
+ .command('cleanup')
295
+ .option('--older-than <duration>', 'Max age for stale entries (default: 7d)', '7d')
296
+ .option('--yes', 'Skip confirmation prompt')
297
+ .description('Check for and remove stale staging backups (default: older than 7 days)')
298
+ .action((opts: { olderThan: string; yes?: boolean }) => {
299
+ const maxAge = parseDuration(opts.olderThan);
300
+ if (!maxAge) {
301
+ log.fail(`Invalid duration: "${opts.olderThan}". Use format like 7d, 24h, 30m`);
302
+ process.exit(1);
303
+ }
304
+
305
+ const info = getStaleStagingInfo(maxAge);
306
+
307
+ if (!info) {
308
+ log.info('No stale staging backups found.');
309
+ return;
310
+ }
311
+
312
+ log.heading('Stale Staging Backups');
313
+ log.info(
314
+ `Found ${info.count} staging backup(s) older than ${opts.olderThan} (${info.totalSize}).`,
315
+ );
316
+
317
+ for (const entry of info.staleEntries) {
318
+ log.dim(` ${entry.id} ${formatSize(entry.size)}`);
319
+ }
320
+
321
+ if (!opts.yes) {
322
+ log.info(
323
+ `Run with --yes to remove, or use: soleri staging purge --older-than ${opts.olderThan}`,
324
+ );
325
+ return;
204
326
  }
205
327
 
206
- log.info(`Purged ${toPurge.length} staging snapshot(s)`);
328
+ const removed = purgeStagingEntries(info.staleEntries);
329
+ log.pass(`Cleaned up ${removed} stale staging backup(s), freed ${info.totalSize}.`);
207
330
  });
208
331
  }
@@ -0,0 +1,103 @@
1
+ import { spawn } from 'node:child_process';
2
+ import type { Command } from 'commander';
3
+ import { isPackInstalled, installPack } from '../hook-packs/installer.js';
4
+ import { getPack } from '../hook-packs/registry.js';
5
+ import * as log from '../utils/logger.js';
6
+
7
+ const YOLO_PACK = 'yolo-safety';
8
+
9
+ const RESET = '\x1b[0m';
10
+ const BOLD = '\x1b[1m';
11
+ const RED = '\x1b[31m';
12
+ const YELLOW = '\x1b[33m';
13
+
14
+ export function registerYolo(program: Command): void {
15
+ program
16
+ .command('yolo')
17
+ .description('Launch Claude Code in YOLO mode with safety guardrails')
18
+ .option('--dry-run', 'Show what would happen without launching Claude')
19
+ .option('--project', 'Install safety hooks to project .claude/ instead of global ~/.claude/')
20
+ .action((opts: { dryRun?: boolean; project?: boolean }) => {
21
+ runYolo(opts);
22
+ });
23
+ }
24
+
25
+ function runYolo(opts: { dryRun?: boolean; project?: boolean }): void {
26
+ // 1. Verify the yolo-safety pack exists in registry
27
+ const pack = getPack(YOLO_PACK);
28
+ if (!pack) {
29
+ log.fail(`Hook pack "${YOLO_PACK}" not found in registry. Is @soleri/cli up to date?`);
30
+ process.exit(1);
31
+ }
32
+
33
+ // 2. Check if already installed, install if not
34
+ const projectDir = opts.project ? process.cwd() : undefined;
35
+ const installed = isPackInstalled(YOLO_PACK, { projectDir });
36
+
37
+ if (installed === true) {
38
+ log.pass(`${YOLO_PACK} hook pack already installed`);
39
+ } else {
40
+ if (installed === 'partial') {
41
+ log.warn(`${YOLO_PACK} hook pack partially installed — reinstalling`);
42
+ }
43
+ const result = installPack(YOLO_PACK, { projectDir });
44
+ const target = opts.project ? '.claude/' : '~/.claude/';
45
+ for (const script of result.scripts) {
46
+ log.pass(`Installed ${script} → ${target}`);
47
+ }
48
+ for (const lc of result.lifecycleHooks) {
49
+ log.pass(`Registered lifecycle hook: ${lc}`);
50
+ }
51
+ const totalInstalled =
52
+ result.installed.length + result.scripts.length + result.lifecycleHooks.length;
53
+ if (totalInstalled > 0) {
54
+ log.pass(`${YOLO_PACK} hook pack installed (${totalInstalled} items)`);
55
+ }
56
+ }
57
+
58
+ // 3. Print safety warning
59
+ console.log();
60
+ console.log(` ${RED}${BOLD}⚡ YOLO MODE${RESET}`);
61
+ console.log();
62
+ console.log(
63
+ ` ${YELLOW}Approval gates skipped — Claude will execute commands without asking.${RESET}`,
64
+ );
65
+ console.log(
66
+ ` ${YELLOW}Safety hooks active — destructive commands (rm, git push --force,${RESET}`,
67
+ );
68
+ console.log(` ${YELLOW}git reset --hard, drop table, docker rm) are intercepted.${RESET}`);
69
+ console.log();
70
+
71
+ if (opts.dryRun) {
72
+ log.info('Dry run — would launch:');
73
+ log.dim(' claude --dangerously-skip-permissions');
74
+ return;
75
+ }
76
+
77
+ // 4. Launch Claude Code with permissions skipped
78
+ log.info('Launching Claude Code in YOLO mode...');
79
+ console.log();
80
+
81
+ const child = spawn('claude', ['--dangerously-skip-permissions'], {
82
+ stdio: 'inherit',
83
+ env: { ...process.env },
84
+ });
85
+
86
+ child.on('error', (err) => {
87
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
88
+ log.fail(
89
+ 'Claude CLI not found. Install it first: https://docs.anthropic.com/en/docs/claude-code',
90
+ );
91
+ } else {
92
+ log.fail(`Failed to launch Claude: ${err.message}`);
93
+ }
94
+ process.exit(1);
95
+ });
96
+
97
+ child.on('exit', (code, signal) => {
98
+ if (signal) {
99
+ process.exit(1);
100
+ }
101
+ process.exit(code ?? 0);
102
+ });
103
+ }
@@ -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 |