@soleri/cli 9.0.2 → 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.
Files changed (49) hide show
  1. package/dist/commands/agent.js +116 -3
  2. package/dist/commands/agent.js.map +1 -1
  3. package/dist/commands/create.js +6 -2
  4. package/dist/commands/create.js.map +1 -1
  5. package/dist/commands/hooks.js +36 -13
  6. package/dist/commands/hooks.js.map +1 -1
  7. package/dist/commands/install.d.ts +1 -0
  8. package/dist/commands/install.js +61 -12
  9. package/dist/commands/install.js.map +1 -1
  10. package/dist/commands/pack.js +0 -1
  11. package/dist/commands/pack.js.map +1 -1
  12. package/dist/commands/staging.d.ts +2 -0
  13. package/dist/commands/staging.js +175 -0
  14. package/dist/commands/staging.js.map +1 -0
  15. package/dist/hook-packs/full/manifest.json +2 -2
  16. package/dist/hook-packs/installer.d.ts +4 -11
  17. package/dist/hook-packs/installer.js +197 -23
  18. package/dist/hook-packs/installer.js.map +1 -1
  19. package/dist/hook-packs/installer.ts +223 -38
  20. package/dist/hook-packs/registry.d.ts +16 -13
  21. package/dist/hook-packs/registry.js +11 -18
  22. package/dist/hook-packs/registry.js.map +1 -1
  23. package/dist/hook-packs/registry.ts +31 -30
  24. package/dist/hook-packs/yolo-safety/manifest.json +23 -0
  25. package/dist/hook-packs/yolo-safety/scripts/anti-deletion.sh +214 -0
  26. package/dist/hooks/templates.js +1 -1
  27. package/dist/hooks/templates.js.map +1 -1
  28. package/dist/main.js +2 -0
  29. package/dist/main.js.map +1 -1
  30. package/package.json +1 -1
  31. package/src/__tests__/create.test.ts +6 -2
  32. package/src/__tests__/hook-packs.test.ts +67 -25
  33. package/src/__tests__/wizard-e2e.mjs +153 -58
  34. package/src/commands/agent.ts +146 -3
  35. package/src/commands/create.ts +8 -2
  36. package/src/commands/hooks.ts +36 -31
  37. package/src/commands/install.ts +65 -22
  38. package/src/commands/pack.ts +0 -1
  39. package/src/commands/staging.ts +208 -0
  40. package/src/hook-packs/full/manifest.json +2 -2
  41. package/src/hook-packs/installer.ts +223 -38
  42. package/src/hook-packs/registry.ts +31 -30
  43. package/src/hook-packs/yolo-safety/manifest.json +23 -0
  44. package/src/hook-packs/yolo-safety/scripts/anti-deletion.sh +214 -0
  45. package/src/hooks/templates.ts +1 -1
  46. package/src/main.ts +2 -0
  47. package/dist/commands/cognee.d.ts +0 -10
  48. package/dist/commands/cognee.js +0 -364
  49. package/dist/commands/cognee.js.map +0 -1
@@ -18,13 +18,11 @@ export function registerHooks(program: Command): void {
18
18
  log.fail(`Unknown editor "${editor}". Supported: ${SUPPORTED_EDITORS.join(', ')}`);
19
19
  process.exit(1);
20
20
  }
21
-
22
21
  const ctx = detectAgent();
23
22
  if (!ctx) {
24
23
  log.fail('No agent project detected in current directory.');
25
24
  process.exit(1);
26
25
  }
27
-
28
26
  const files = installHooks(editor, ctx.agentPath);
29
27
  for (const f of files) {
30
28
  log.pass(`Created ${f}`);
@@ -41,13 +39,11 @@ export function registerHooks(program: Command): void {
41
39
  log.fail(`Unknown editor "${editor}". Supported: ${SUPPORTED_EDITORS.join(', ')}`);
42
40
  process.exit(1);
43
41
  }
44
-
45
42
  const ctx = detectAgent();
46
43
  if (!ctx) {
47
44
  log.fail('No agent project detected in current directory.');
48
45
  process.exit(1);
49
46
  }
50
-
51
47
  const removed = removeHooks(editor, ctx.agentPath);
52
48
  if (removed.length === 0) {
53
49
  log.info(`No ${editor} hooks found to remove.`);
@@ -68,11 +64,8 @@ export function registerHooks(program: Command): void {
68
64
  log.fail('No agent project detected in current directory.');
69
65
  process.exit(1);
70
66
  }
71
-
72
67
  const installed = detectInstalledHooks(ctx.agentPath);
73
-
74
68
  log.heading(`Editor hooks for ${ctx.agentId}`);
75
-
76
69
  for (const editor of SUPPORTED_EDITORS) {
77
70
  if (installed.includes(editor)) {
78
71
  log.pass(editor, 'installed');
@@ -82,8 +75,6 @@ export function registerHooks(program: Command): void {
82
75
  }
83
76
  });
84
77
 
85
- // ── Hook Pack subcommands ──
86
-
87
78
  hooks
88
79
  .command('add-pack')
89
80
  .argument('<pack>', 'Hook pack name')
@@ -96,18 +87,24 @@ export function registerHooks(program: Command): void {
96
87
  log.fail(`Unknown pack "${packName}". Available: ${available.join(', ')}`);
97
88
  process.exit(1);
98
89
  }
99
-
100
90
  const projectDir = opts.project ? process.cwd() : undefined;
101
91
  const target = opts.project ? '.claude/' : '~/.claude/';
102
- const { installed, skipped } = installPack(packName, { projectDir });
92
+ const { installed, skipped, scripts, lifecycleHooks } = installPack(packName, { projectDir });
103
93
  for (const hook of installed) {
104
94
  log.pass(`Installed hookify.${hook}.local.md → ${target}`);
105
95
  }
106
96
  for (const hook of skipped) {
107
97
  log.dim(` hookify.${hook}.local.md — already exists, skipped`);
108
98
  }
109
- if (installed.length > 0) {
110
- log.info(`Pack "${packName}" installed (${installed.length} hooks) → ${target}`);
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}`);
111
108
  } else {
112
109
  log.info(`Pack "${packName}" — all hooks already installed`);
113
110
  }
@@ -125,16 +122,22 @@ export function registerHooks(program: Command): void {
125
122
  log.fail(`Unknown pack "${packName}". Available: ${available.join(', ')}`);
126
123
  process.exit(1);
127
124
  }
128
-
129
125
  const projectDir = opts.project ? process.cwd() : undefined;
130
- const { removed } = removePack(packName, { projectDir });
131
- if (removed.length === 0) {
126
+ const { removed, scripts, lifecycleHooks } = removePack(packName, { projectDir });
127
+ const totalRemoved = removed.length + scripts.length + lifecycleHooks.length;
128
+ if (totalRemoved === 0) {
132
129
  log.info(`No hooks from pack "${packName}" found to remove.`);
133
130
  } else {
134
131
  for (const hook of removed) {
135
132
  log.warn(`Removed hookify.${hook}.local.md`);
136
133
  }
137
- log.info(`Pack "${packName}" removed (${removed.length} hooks)`);
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)`);
138
141
  }
139
142
  });
140
143
 
@@ -143,28 +146,28 @@ export function registerHooks(program: Command): void {
143
146
  .description('Show available hook packs and their status')
144
147
  .action(() => {
145
148
  const packs = listPacks();
146
-
147
149
  log.heading('Hook Packs');
148
-
149
150
  for (const pack of packs) {
150
151
  const status = isPackInstalled(pack.name);
151
-
152
152
  const versionLabel = pack.version ? ` v${pack.version}` : '';
153
153
  const sourceLabel = pack.source === 'local' ? ' [local]' : '';
154
-
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`;
155
158
  if (status === true) {
156
159
  log.pass(
157
160
  `${pack.name}${versionLabel}${sourceLabel}`,
158
- `${pack.description} (${pack.hooks.length} hooks)`,
161
+ `${pack.description} (${itemLabel})`,
159
162
  );
160
163
  } else if (status === 'partial') {
161
164
  log.warn(
162
165
  `${pack.name}${versionLabel}${sourceLabel}`,
163
- `${pack.description} (${pack.hooks.length} hooks) — partial`,
166
+ `${pack.description} (${itemLabel}) — partial`,
164
167
  );
165
168
  } else {
166
169
  log.dim(
167
- ` ${pack.name}${versionLabel}${sourceLabel} — ${pack.description} (${pack.hooks.length} hooks)`,
170
+ ` ${pack.name}${versionLabel}${sourceLabel} — ${pack.description} (${itemLabel})`,
168
171
  );
169
172
  }
170
173
  }
@@ -182,19 +185,21 @@ export function registerHooks(program: Command): void {
182
185
  log.fail(`Unknown pack "${packName}". Available: ${available.join(', ')}`);
183
186
  process.exit(1);
184
187
  }
185
-
186
188
  const projectDir = opts.project ? process.cwd() : undefined;
187
189
  const packVersion = pack.manifest.version ?? 'unknown';
188
-
189
- // Remove then reinstall to force overwrite
190
190
  removePack(packName, { projectDir });
191
- const { installed } = installPack(packName, { projectDir });
192
-
191
+ const { installed, scripts, lifecycleHooks } = installPack(packName, { projectDir });
193
192
  for (const hook of installed) {
194
193
  log.pass(`hookify.${hook}.local.md → v${packVersion}`);
195
194
  }
196
-
197
- log.info(`Pack "${packName}" upgraded to v${packVersion} (${installed.length} hooks)`);
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)`);
198
203
  });
199
204
  }
200
205
 
@@ -1,14 +1,40 @@
1
1
  import type { Command } from 'commander';
2
+ import { createRequire } from 'node:module';
2
3
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
3
4
  import { join, resolve } from 'node:path';
4
5
  import { homedir } from 'node:os';
5
6
  import * as p from '@clack/prompts';
6
7
  import { detectAgent } from '../utils/agent-context.js';
7
8
 
9
+ /** Default parent directory for agents: ~/.soleri/ */
10
+ const SOLERI_HOME = process.env.SOLERI_HOME ?? join(homedir(), '.soleri');
11
+
8
12
  type Target = 'claude' | 'codex' | 'opencode' | 'both' | 'all';
9
13
 
10
- /** MCP server entry for file-tree agents (uses npx @soleri/engine) */
14
+ /**
15
+ * Resolve the absolute path to the soleri-engine binary.
16
+ * Falls back to `npx @soleri/engine` if resolution fails (e.g. not installed globally).
17
+ */
18
+ function resolveEngineBin(): { command: string; bin: string } {
19
+ try {
20
+ const require = createRequire(import.meta.url);
21
+ const bin = require.resolve('@soleri/core/dist/engine/bin/soleri-engine.js');
22
+ return { command: 'node', bin };
23
+ } catch {
24
+ return { command: 'npx', bin: '@soleri/engine' };
25
+ }
26
+ }
27
+
28
+ /** MCP server entry for file-tree agents (resolved engine path, no npx) */
11
29
  function fileTreeMcpEntry(agentDir: string): Record<string, unknown> {
30
+ const engine = resolveEngineBin();
31
+ if (engine.command === 'node') {
32
+ return {
33
+ type: 'stdio',
34
+ command: 'node',
35
+ args: [engine.bin, '--agent', join(agentDir, 'agent.yaml')],
36
+ };
37
+ }
12
38
  return {
13
39
  type: 'stdio',
14
40
  command: 'npx',
@@ -26,7 +52,7 @@ function legacyMcpEntry(agentDir: string): Record<string, unknown> {
26
52
  };
27
53
  }
28
54
 
29
- function installClaude(agentId: string, agentDir: string, isFileTree: boolean): void {
55
+ export function installClaude(agentId: string, agentDir: string, isFileTree: boolean): void {
30
56
  const configPath = join(homedir(), '.claude.json');
31
57
  let config: Record<string, unknown> = {};
32
58
 
@@ -72,7 +98,12 @@ function installCodex(agentId: string, agentDir: string, isFileTree: boolean): v
72
98
  let section: string;
73
99
  if (isFileTree) {
74
100
  const agentYamlPath = join(agentDir, 'agent.yaml');
75
- section = `\n\n${sectionHeader}\ncommand = "npx"\nargs = ["@soleri/engine", "--agent", "${agentYamlPath}"]\n`;
101
+ const engine = resolveEngineBin();
102
+ if (engine.command === 'node') {
103
+ section = `\n\n${sectionHeader}\ncommand = "node"\nargs = ["${engine.bin}", "--agent", "${agentYamlPath}"]\n`;
104
+ } else {
105
+ section = `\n\n${sectionHeader}\ncommand = "npx"\nargs = ["@soleri/engine", "--agent", "${agentYamlPath}"]\n`;
106
+ }
76
107
  } else {
77
108
  const entryPoint = join(agentDir, 'dist', 'index.js');
78
109
  section = `\n\n${sectionHeader}\ncommand = "node"\nargs = ["${entryPoint}"]\n`;
@@ -111,24 +142,13 @@ function installOpencode(agentId: string, agentDir: string, isFileTree: boolean)
111
142
 
112
143
  const servers = config.mcp as Record<string, unknown>;
113
144
  if (isFileTree) {
145
+ const engine = resolveEngineBin();
114
146
  servers[agentId] = {
115
147
  type: 'local',
116
- command: [
117
- 'node',
118
- join(
119
- agentDir,
120
- '..',
121
- 'soleri',
122
- 'packages',
123
- 'core',
124
- 'dist',
125
- 'engine',
126
- 'bin',
127
- 'soleri-engine.js',
128
- ),
129
- '--agent',
130
- join(agentDir, 'agent.yaml'),
131
- ],
148
+ command:
149
+ engine.command === 'node'
150
+ ? ['node', engine.bin, '--agent', join(agentDir, 'agent.yaml')]
151
+ : ['npx', '-y', '@soleri/engine', '--agent', join(agentDir, 'agent.yaml')],
132
152
  };
133
153
  } else {
134
154
  servers[agentId] = {
@@ -174,15 +194,30 @@ function installLauncher(agentId: string, agentDir: string): void {
174
194
  export function registerInstall(program: Command): void {
175
195
  program
176
196
  .command('install')
177
- .argument('[dir]', 'Agent directory (defaults to cwd)')
197
+ .argument('[dir]', 'Agent directory or agent name (checks ~/.soleri/<name> first, then cwd)')
178
198
  .option('--target <target>', 'Registration target: claude, opencode, codex, or all', 'claude')
179
199
  .description('Register agent as MCP server in editor config')
180
200
  .action(async (dir?: string, opts?: { target?: string }) => {
181
- const resolvedDir = dir ? resolve(dir) : undefined;
201
+ let resolvedDir: string | undefined;
202
+
203
+ if (dir) {
204
+ // If dir looks like a bare agent name (no slashes), check ~/.soleri/{name} first
205
+ if (!dir.includes('/') && !dir.includes('\\')) {
206
+ const soleriPath = join(SOLERI_HOME, dir);
207
+ if (existsSync(join(soleriPath, 'agent.yaml'))) {
208
+ resolvedDir = soleriPath;
209
+ }
210
+ }
211
+ if (!resolvedDir) {
212
+ resolvedDir = resolve(dir);
213
+ }
214
+ }
215
+
182
216
  const ctx = detectAgent(resolvedDir);
183
217
 
184
218
  if (!ctx) {
185
219
  p.log.error('Not in an agent project. Run from an agent directory or pass its path.');
220
+ p.log.info(`Tip: agents created with "soleri create" live in ${SOLERI_HOME}/`);
186
221
  process.exit(1);
187
222
  }
188
223
 
@@ -196,7 +231,15 @@ export function registerInstall(program: Command): void {
196
231
  }
197
232
 
198
233
  if (isFileTree) {
199
- p.log.info(`Detected file-tree agent (v7) — registering via @soleri/engine`);
234
+ const engine = resolveEngineBin();
235
+ if (engine.command === 'node') {
236
+ p.log.info(`Detected file-tree agent (v7) — using resolved engine at ${engine.bin}`);
237
+ } else {
238
+ p.log.warn(
239
+ `Could not resolve @soleri/core locally — falling back to npx (slower startup)`,
240
+ );
241
+ p.log.info(`For instant startup: npm install -g @soleri/cli`);
242
+ }
200
243
  }
201
244
 
202
245
  if (target === 'claude' || target === 'both' || target === 'all') {
@@ -636,7 +636,6 @@ export function registerPack(program: Command): void {
636
636
 
637
637
  function listMdFiles(dir: string): string[] {
638
638
  try {
639
- const { readdirSync } = require('node:fs');
640
639
  const { basename } = require('node:path');
641
640
  return readdirSync(dir)
642
641
  .filter((f: string) => f.endsWith('.md'))
@@ -0,0 +1,208 @@
1
+ import type { Command } from 'commander';
2
+ import { existsSync, readdirSync, statSync, rmSync, cpSync, mkdirSync } from 'node:fs';
3
+ import { join, relative } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import * as log from '../utils/logger.js';
6
+
7
+ const STAGING_ROOT = join(homedir(), '.soleri', 'staging');
8
+
9
+ interface StagedEntry {
10
+ id: string;
11
+ timestamp: string;
12
+ path: string;
13
+ size: number;
14
+ isDirectory: boolean;
15
+ }
16
+
17
+ /**
18
+ * Walk a directory tree and collect all items with their relative paths.
19
+ */
20
+ function walkDir(dir: string, base: string): { relPath: string; size: number }[] {
21
+ const results: { relPath: string; size: number }[] = [];
22
+ if (!existsSync(dir)) return results;
23
+
24
+ const entries = readdirSync(dir, { withFileTypes: true });
25
+ for (const entry of entries) {
26
+ const fullPath = join(dir, entry.name);
27
+ const relPath = relative(base, fullPath);
28
+ if (entry.isDirectory()) {
29
+ results.push(...walkDir(fullPath, base));
30
+ } else {
31
+ const stat = statSync(fullPath);
32
+ results.push({ relPath, size: stat.size });
33
+ }
34
+ }
35
+ return results;
36
+ }
37
+
38
+ /**
39
+ * List all staged entries.
40
+ */
41
+ function listStaged(): StagedEntry[] {
42
+ if (!existsSync(STAGING_ROOT)) return [];
43
+
44
+ const entries: StagedEntry[] = [];
45
+ const dirs = readdirSync(STAGING_ROOT, { withFileTypes: true });
46
+
47
+ for (const dir of dirs) {
48
+ if (!dir.isDirectory()) continue;
49
+ const stagePath = join(STAGING_ROOT, dir.name);
50
+ const _stat = statSync(stagePath);
51
+ const files = walkDir(stagePath, stagePath);
52
+ const totalSize = files.reduce((sum, f) => sum + f.size, 0);
53
+
54
+ entries.push({
55
+ id: dir.name,
56
+ timestamp: dir.name,
57
+ path: stagePath,
58
+ size: totalSize,
59
+ isDirectory: true,
60
+ });
61
+ }
62
+
63
+ return entries.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
64
+ }
65
+
66
+ /**
67
+ * Parse a duration string like "7d", "24h", "30m" into milliseconds.
68
+ */
69
+ function parseDuration(duration: string): number | null {
70
+ const match = duration.match(/^(\d+)(d|h|m)$/);
71
+ if (!match) return null;
72
+
73
+ const value = parseInt(match[1], 10);
74
+ const unit = match[2];
75
+
76
+ switch (unit) {
77
+ case 'd':
78
+ return value * 24 * 60 * 60 * 1000;
79
+ case 'h':
80
+ return value * 60 * 60 * 1000;
81
+ case 'm':
82
+ return value * 60 * 1000;
83
+ default:
84
+ return null;
85
+ }
86
+ }
87
+
88
+ function formatSize(bytes: number): string {
89
+ if (bytes < 1024) return `${bytes} B`;
90
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
91
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
92
+ }
93
+
94
+ export function registerStaging(program: Command): void {
95
+ const staging = program.command('staging').description('Manage anti-deletion staging folder');
96
+
97
+ staging
98
+ .command('list')
99
+ .description('Show staged files with timestamps')
100
+ .action(() => {
101
+ const entries = listStaged();
102
+
103
+ if (entries.length === 0) {
104
+ log.info('No staged files found.');
105
+ log.dim(` Staging directory: ${STAGING_ROOT}`);
106
+ return;
107
+ }
108
+
109
+ log.heading('Staged Files');
110
+
111
+ for (const entry of entries) {
112
+ const files = walkDir(entry.path, entry.path);
113
+ log.pass(entry.id, `${files.length} file(s), ${formatSize(entry.size)}`);
114
+ for (const file of files.slice(0, 10)) {
115
+ log.dim(` ${file.relPath}`);
116
+ }
117
+ if (files.length > 10) {
118
+ log.dim(` ... and ${files.length - 10} more`);
119
+ }
120
+ }
121
+
122
+ log.info(`${entries.length} staging snapshot(s) in ${STAGING_ROOT}`);
123
+ });
124
+
125
+ staging
126
+ .command('restore')
127
+ .argument('<id>', 'Staging snapshot ID (timestamp)')
128
+ .description('Restore files from staging to their original locations')
129
+ .action((id: string) => {
130
+ const stagePath = join(STAGING_ROOT, id);
131
+
132
+ if (!existsSync(stagePath)) {
133
+ log.fail(`Staging snapshot "${id}" not found.`);
134
+ const entries = listStaged();
135
+ if (entries.length > 0) {
136
+ log.info(`Available: ${entries.map((e) => e.id).join(', ')}`);
137
+ }
138
+ process.exit(1);
139
+ }
140
+
141
+ const files = walkDir(stagePath, stagePath);
142
+ let restored = 0;
143
+
144
+ for (const file of files) {
145
+ // The staging preserves absolute paths, so the relPath starts from root
146
+ const destPath = join('/', file.relPath);
147
+ const srcPath = join(stagePath, file.relPath);
148
+
149
+ try {
150
+ const destDir = join(destPath, '..');
151
+ mkdirSync(destDir, { recursive: true });
152
+ cpSync(srcPath, destPath, { force: true });
153
+ restored++;
154
+ log.pass(`Restored ${destPath}`);
155
+ } catch (err) {
156
+ log.fail(`Failed to restore ${destPath}: ${err}`);
157
+ }
158
+ }
159
+
160
+ log.info(`Restored ${restored}/${files.length} file(s) from snapshot "${id}"`);
161
+ });
162
+
163
+ 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 }) => {
168
+ if (!existsSync(STAGING_ROOT)) {
169
+ log.info('No staging directory found. Nothing to purge.');
170
+ return;
171
+ }
172
+
173
+ const entries = listStaged();
174
+
175
+ if (entries.length === 0) {
176
+ log.info('No staged files to purge.');
177
+ return;
178
+ }
179
+
180
+ let toPurge = entries;
181
+
182
+ if (opts.olderThan) {
183
+ const maxAge = parseDuration(opts.olderThan);
184
+ if (!maxAge) {
185
+ log.fail(`Invalid duration: "${opts.olderThan}". Use format like 7d, 24h, 30m`);
186
+ process.exit(1);
187
+ }
188
+
189
+ const cutoff = Date.now() - maxAge;
190
+ toPurge = entries.filter((entry) => {
191
+ const stat = statSync(entry.path);
192
+ return stat.mtimeMs < cutoff;
193
+ });
194
+ }
195
+
196
+ if (toPurge.length === 0) {
197
+ log.info('No snapshots match the purge criteria.');
198
+ return;
199
+ }
200
+
201
+ for (const entry of toPurge) {
202
+ rmSync(entry.path, { recursive: true, force: true });
203
+ log.warn(`Purged ${entry.id}`);
204
+ }
205
+
206
+ log.info(`Purged ${toPurge.length} staging snapshot(s)`);
207
+ });
208
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "full",
3
3
  "version": "1.0.0",
4
- "description": "Complete quality suite — all 8 hooks",
4
+ "description": "Complete quality suite — all hooks",
5
5
  "hooks": [
6
6
  "no-any-types",
7
7
  "no-console-log",
@@ -12,5 +12,5 @@
12
12
  "ux-touch-targets",
13
13
  "no-ai-attribution"
14
14
  ],
15
- "composedFrom": ["typescript-safety", "a11y", "css-discipline", "clean-commits"]
15
+ "composedFrom": ["typescript-safety", "a11y", "css-discipline", "clean-commits", "yolo-safety"]
16
16
  }