@soleri/cli 9.0.1 → 9.2.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 +10 -3
  4. package/dist/commands/create.js.map +1 -1
  5. package/dist/commands/hooks.js +43 -49
  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 +192 -23
  18. package/dist/hook-packs/installer.js.map +1 -1
  19. package/dist/hook-packs/installer.ts +173 -60
  20. package/dist/hook-packs/registry.d.ts +16 -13
  21. package/dist/hook-packs/registry.js +13 -28
  22. package/dist/hook-packs/registry.js.map +1 -1
  23. package/dist/hook-packs/registry.ts +33 -46
  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 +66 -44
  33. package/src/__tests__/wizard-e2e.mjs +17 -11
  34. package/src/commands/agent.ts +146 -3
  35. package/src/commands/create.ts +8 -2
  36. package/src/commands/hooks.ts +88 -187
  37. package/src/commands/install.ts +62 -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 +173 -60
  42. package/src/hook-packs/registry.ts +33 -46
  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
@@ -9,193 +9,94 @@ 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
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
-
22
- const ctx = detectAgent();
23
- if (!ctx) {
24
- log.fail('No agent project detected in current directory.');
25
- process.exit(1);
26
- }
27
-
28
- const files = installHooks(editor, ctx.agentPath);
29
- for (const f of files) {
30
- log.pass(`Created ${f}`);
31
- }
32
- log.info(`${editor} hooks installed for ${ctx.agentId}`);
33
- });
34
-
35
- hooks
36
- .command('remove')
37
- .argument('<editor>', `Editor: ${SUPPORTED_EDITORS.join(', ')}`)
38
- .description('Remove editor hooks/config files')
39
- .action((editor: string) => {
40
- if (!isValidEditor(editor)) {
41
- log.fail(`Unknown editor "${editor}". Supported: ${SUPPORTED_EDITORS.join(', ')}`);
42
- process.exit(1);
43
- }
44
-
45
- const ctx = detectAgent();
46
- if (!ctx) {
47
- log.fail('No agent project detected in current directory.');
48
- process.exit(1);
49
- }
50
-
51
- const removed = removeHooks(editor, ctx.agentPath);
52
- if (removed.length === 0) {
53
- log.info(`No ${editor} hooks found to remove.`);
54
- } else {
55
- for (const f of removed) {
56
- log.warn(`Removed ${f}`);
57
- }
58
- log.info(`${editor} hooks removed from ${ctx.agentId}`);
59
- }
60
- });
61
-
62
- hooks
63
- .command('list')
64
- .description('Show which editor hooks are installed')
65
- .action(() => {
66
- const ctx = detectAgent();
67
- if (!ctx) {
68
- log.fail('No agent project detected in current directory.');
69
- process.exit(1);
70
- }
71
-
72
- const installed = detectInstalledHooks(ctx.agentPath);
73
-
74
- log.heading(`Editor hooks for ${ctx.agentId}`);
75
-
76
- for (const editor of SUPPORTED_EDITORS) {
77
- if (installed.includes(editor)) {
78
- log.pass(editor, 'installed');
79
- } else {
80
- log.dim(` ${editor} — not installed`);
81
- }
82
- }
83
- });
84
-
85
- // ── Hook Pack subcommands ──
86
-
87
- hooks
88
- .command('add-pack')
89
- .argument('<pack>', 'Hook pack name')
90
- .option('--project', 'Install to project .claude/ instead of global ~/.claude/')
91
- .description('Install a hook pack globally (~/.claude/) or per-project (--project)')
92
- .action((packName: string, opts: { project?: boolean }) => {
93
- const pack = getPack(packName);
94
- if (!pack) {
95
- const available = listPacks().map((p) => p.name);
96
- log.fail(`Unknown pack "${packName}". Available: ${available.join(', ')}`);
97
- process.exit(1);
98
- }
99
-
100
- const projectDir = opts.project ? process.cwd() : undefined;
101
- const target = opts.project ? '.claude/' : '~/.claude/';
102
- const { installed, skipped } = installPack(packName, { projectDir });
103
- for (const hook of installed) {
104
- log.pass(`Installed hookify.${hook}.local.md → ${target}`);
105
- }
106
- for (const hook of skipped) {
107
- log.dim(` hookify.${hook}.local.md — already exists, skipped`);
108
- }
109
- if (installed.length > 0) {
110
- log.info(`Pack "${packName}" installed (${installed.length} hooks) → ${target}`);
111
- } else {
112
- log.info(`Pack "${packName}" — all hooks already installed`);
113
- }
114
- });
115
-
116
- hooks
117
- .command('remove-pack')
118
- .argument('<pack>', 'Hook pack name')
119
- .option('--project', 'Remove from project .claude/ instead of global ~/.claude/')
120
- .description('Remove a hook pack')
121
- .action((packName: string, opts: { project?: boolean }) => {
122
- const pack = getPack(packName);
123
- if (!pack) {
124
- const available = listPacks().map((p) => p.name);
125
- log.fail(`Unknown pack "${packName}". Available: ${available.join(', ')}`);
126
- process.exit(1);
127
- }
128
-
129
- const projectDir = opts.project ? process.cwd() : undefined;
130
- const { removed } = removePack(packName, { projectDir });
131
- if (removed.length === 0) {
132
- log.info(`No hooks from pack "${packName}" found to remove.`);
133
- } else {
134
- for (const hook of removed) {
135
- log.warn(`Removed hookify.${hook}.local.md`);
136
- }
137
- log.info(`Pack "${packName}" removed (${removed.length} hooks)`);
138
- }
139
- });
140
-
141
- hooks
142
- .command('list-packs')
143
- .description('Show available hook packs and their status')
144
- .action(() => {
145
- const packs = listPacks();
146
-
147
- log.heading('Hook Packs');
148
-
149
- for (const pack of packs) {
150
- const status = isPackInstalled(pack.name);
151
-
152
- const versionLabel = pack.version ? ` v${pack.version}` : '';
153
- const sourceLabel = pack.source === 'local' ? ' [local]' : '';
154
-
155
- if (status === true) {
156
- log.pass(
157
- `${pack.name}${versionLabel}${sourceLabel}`,
158
- `${pack.description} (${pack.hooks.length} hooks)`,
159
- );
160
- } else if (status === 'partial') {
161
- log.warn(
162
- `${pack.name}${versionLabel}${sourceLabel}`,
163
- `${pack.description} (${pack.hooks.length} hooks) — partial`,
164
- );
165
- } else {
166
- log.dim(
167
- ` ${pack.name}${versionLabel}${sourceLabel} — ${pack.description} (${pack.hooks.length} hooks)`,
168
- );
169
- }
170
- }
171
- });
172
-
173
- hooks
174
- .command('upgrade-pack')
175
- .argument('<pack>', 'Hook pack name')
176
- .option('--project', 'Upgrade in project .claude/ instead of global ~/.claude/')
177
- .description('Upgrade a hook pack to the latest version (overwrites existing files)')
178
- .action((packName: string, opts: { project?: boolean }) => {
179
- const pack = getPack(packName);
180
- if (!pack) {
181
- const available = listPacks().map((p) => p.name);
182
- log.fail(`Unknown pack "${packName}". Available: ${available.join(', ')}`);
183
- process.exit(1);
184
- }
185
-
186
- const projectDir = opts.project ? process.cwd() : undefined;
187
- const packVersion = pack.manifest.version ?? 'unknown';
188
-
189
- // Remove then reinstall to force overwrite
190
- removePack(packName, { projectDir });
191
- const { installed } = installPack(packName, { projectDir });
192
-
193
- for (const hook of installed) {
194
- log.pass(`hookify.${hook}.local.md → v${packVersion}`);
195
- }
196
-
197
- log.info(`Pack "${packName}" upgraded to v${packVersion} (${installed.length} hooks)`);
198
- });
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
+ });
20
+
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
+ });
31
+
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
+ });
41
+
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
+ });
55
+
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
+ });
69
+
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
+ });
86
+
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
+ });
199
100
  }
200
101
 
201
102
  function isValidEditor(editor: string): editor is EditorId {
@@ -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,12 @@ 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: engine.command === 'node'
149
+ ? ['node', engine.bin, '--agent', join(agentDir, 'agent.yaml')]
150
+ : ['npx', '-y', '@soleri/engine', '--agent', join(agentDir, 'agent.yaml')],
132
151
  };
133
152
  } else {
134
153
  servers[agentId] = {
@@ -174,15 +193,30 @@ function installLauncher(agentId: string, agentDir: string): void {
174
193
  export function registerInstall(program: Command): void {
175
194
  program
176
195
  .command('install')
177
- .argument('[dir]', 'Agent directory (defaults to cwd)')
196
+ .argument('[dir]', 'Agent directory or agent name (checks ~/.soleri/<name> first, then cwd)')
178
197
  .option('--target <target>', 'Registration target: claude, opencode, codex, or all', 'claude')
179
198
  .description('Register agent as MCP server in editor config')
180
199
  .action(async (dir?: string, opts?: { target?: string }) => {
181
- const resolvedDir = dir ? resolve(dir) : undefined;
200
+ let resolvedDir: string | undefined;
201
+
202
+ if (dir) {
203
+ // If dir looks like a bare agent name (no slashes), check ~/.soleri/{name} first
204
+ if (!dir.includes('/') && !dir.includes('\\')) {
205
+ const soleriPath = join(SOLERI_HOME, dir);
206
+ if (existsSync(join(soleriPath, 'agent.yaml'))) {
207
+ resolvedDir = soleriPath;
208
+ }
209
+ }
210
+ if (!resolvedDir) {
211
+ resolvedDir = resolve(dir);
212
+ }
213
+ }
214
+
182
215
  const ctx = detectAgent(resolvedDir);
183
216
 
184
217
  if (!ctx) {
185
218
  p.log.error('Not in an agent project. Run from an agent directory or pass its path.');
219
+ p.log.info(`Tip: agents created with "soleri create" live in ${SOLERI_HOME}/`);
186
220
  process.exit(1);
187
221
  }
188
222
 
@@ -196,7 +230,13 @@ export function registerInstall(program: Command): void {
196
230
  }
197
231
 
198
232
  if (isFileTree) {
199
- p.log.info(`Detected file-tree agent (v7) — registering via @soleri/engine`);
233
+ const engine = resolveEngineBin();
234
+ if (engine.command === 'node') {
235
+ p.log.info(`Detected file-tree agent (v7) — using resolved engine at ${engine.bin}`);
236
+ } else {
237
+ p.log.warn(`Could not resolve @soleri/core locally — falling back to npx (slower startup)`);
238
+ p.log.info(`For instant startup: npm install -g @soleri/cli`);
239
+ }
200
240
  }
201
241
 
202
242
  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
  }