@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
@@ -1,29 +1,33 @@
1
1
  /**
2
- * Hook pack installer — copies hookify files to ~/.claude/ (global) or project .claude/ (local).
2
+ * Hook pack installer — copies hookify files and scripts to ~/.claude/ (global) or project .claude/ (local).
3
+ * Also manages lifecycle hooks in ~/.claude/settings.json.
3
4
  */
4
- import { existsSync, copyFileSync, unlinkSync, mkdirSync } from 'node:fs';
5
+ import {
6
+ existsSync,
7
+ copyFileSync,
8
+ unlinkSync,
9
+ mkdirSync,
10
+ readFileSync,
11
+ writeFileSync,
12
+ chmodSync,
13
+ } from 'node:fs';
5
14
  import { join } from 'node:path';
6
15
  import { homedir } from 'node:os';
7
16
  import { getPack } from './registry.js';
17
+ import type { HookPackLifecycleHook } from './registry.js';
18
+
19
+ const PACK_MARKER = '_soleriPack';
8
20
 
9
- /** Resolve the target .claude/ directory. */
10
21
  function resolveClaudeDir(projectDir?: string): string {
11
22
  if (projectDir) return join(projectDir, '.claude');
12
23
  return join(homedir(), '.claude');
13
24
  }
14
25
 
15
- /**
16
- * Resolve all hookify file paths for a pack, handling composed packs.
17
- * Returns a map of hook name → source file path.
18
- */
19
26
  function resolveHookFiles(packName: string): Map<string, string> {
20
27
  const pack = getPack(packName);
21
28
  if (!pack) return new Map();
22
-
23
29
  const files = new Map<string, string>();
24
-
25
30
  if (pack.manifest.composedFrom) {
26
- // Composed pack: gather files from constituent packs
27
31
  for (const subPackName of pack.manifest.composedFrom) {
28
32
  const subFiles = resolveHookFiles(subPackName);
29
33
  for (const [hook, path] of subFiles) {
@@ -31,7 +35,6 @@ function resolveHookFiles(packName: string): Map<string, string> {
31
35
  }
32
36
  }
33
37
  } else {
34
- // Direct pack: look for hookify files in the pack directory
35
38
  for (const hook of pack.manifest.hooks) {
36
39
  const filePath = join(pack.dir, `hookify.${hook}.local.md`);
37
40
  if (existsSync(filePath)) {
@@ -39,30 +42,143 @@ function resolveHookFiles(packName: string): Map<string, string> {
39
42
  }
40
43
  }
41
44
  }
42
-
43
45
  return files;
44
46
  }
45
47
 
46
- /**
47
- * Install a hook pack to ~/.claude/ (default) or project .claude/ (--project).
48
- * Skips files that already exist (idempotent).
49
- */
48
+ function resolveScripts(
49
+ packName: string,
50
+ ): Map<string, { sourcePath: string; targetDir: string; file: string }> {
51
+ const pack = getPack(packName);
52
+ if (!pack) return new Map();
53
+ const scripts = new Map<string, { sourcePath: string; targetDir: string; file: string }>();
54
+ if (pack.manifest.composedFrom) {
55
+ for (const subPackName of pack.manifest.composedFrom) {
56
+ const subScripts = resolveScripts(subPackName);
57
+ for (const [name, info] of subScripts) {
58
+ scripts.set(name, info);
59
+ }
60
+ }
61
+ } else if (pack.manifest.scripts) {
62
+ for (const script of pack.manifest.scripts) {
63
+ const sourcePath = join(pack.dir, 'scripts', script.file);
64
+ if (existsSync(sourcePath)) {
65
+ scripts.set(script.name, { sourcePath, targetDir: script.targetDir, file: script.file });
66
+ }
67
+ }
68
+ }
69
+ return scripts;
70
+ }
71
+
72
+ function resolveLifecycleHooks(
73
+ packName: string,
74
+ ): { packName: string; hook: HookPackLifecycleHook }[] {
75
+ const pack = getPack(packName);
76
+ if (!pack) return [];
77
+ const hooks: { packName: string; hook: HookPackLifecycleHook }[] = [];
78
+ if (pack.manifest.composedFrom) {
79
+ for (const subPackName of pack.manifest.composedFrom) {
80
+ hooks.push(...resolveLifecycleHooks(subPackName));
81
+ }
82
+ } else if (pack.manifest.lifecycleHooks) {
83
+ for (const hook of pack.manifest.lifecycleHooks) {
84
+ hooks.push({ packName: pack.manifest.name, hook });
85
+ }
86
+ }
87
+ return hooks;
88
+ }
89
+
90
+ interface SettingsHookEntry {
91
+ type: 'command';
92
+ command: string;
93
+ timeout?: number;
94
+ [key: string]: unknown;
95
+ }
96
+
97
+ function readClaudeSettings(claudeDir: string): Record<string, unknown> {
98
+ const settingsPath = join(claudeDir, 'settings.json');
99
+ if (!existsSync(settingsPath)) return {};
100
+ try {
101
+ return JSON.parse(readFileSync(settingsPath, 'utf-8'));
102
+ } catch {
103
+ return {};
104
+ }
105
+ }
106
+
107
+ function writeClaudeSettings(claudeDir: string, settings: Record<string, unknown>): void {
108
+ const settingsPath = join(claudeDir, 'settings.json');
109
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
110
+ }
111
+
112
+ function addLifecycleHooks(
113
+ claudeDir: string,
114
+ lifecycleHooks: { packName: string; hook: HookPackLifecycleHook }[],
115
+ ): string[] {
116
+ if (lifecycleHooks.length === 0) return [];
117
+ const settings = readClaudeSettings(claudeDir);
118
+ const hooks = (settings['hooks'] ?? {}) as Record<string, unknown>;
119
+ const added: string[] = [];
120
+ for (const { packName: sourcePack, hook } of lifecycleHooks) {
121
+ const eventKey = hook.event;
122
+ const eventHooks = (hooks[eventKey] ?? []) as SettingsHookEntry[];
123
+ const alreadyExists = eventHooks.some(
124
+ (h) => h.command === hook.command && h[PACK_MARKER] === sourcePack,
125
+ );
126
+ if (!alreadyExists) {
127
+ const entry: SettingsHookEntry = {
128
+ type: hook.type,
129
+ command: hook.command,
130
+ [PACK_MARKER]: sourcePack,
131
+ };
132
+ if (hook.timeout) {
133
+ entry.timeout = hook.timeout;
134
+ }
135
+ eventHooks.push(entry);
136
+ hooks[eventKey] = eventHooks;
137
+ added.push(`${eventKey}:${hook.matcher}`);
138
+ }
139
+ }
140
+ settings['hooks'] = hooks;
141
+ writeClaudeSettings(claudeDir, settings);
142
+ return added;
143
+ }
144
+
145
+ function removeLifecycleHooks(claudeDir: string, packName: string): string[] {
146
+ const settings = readClaudeSettings(claudeDir);
147
+ const hooks = (settings['hooks'] ?? {}) as Record<string, SettingsHookEntry[]>;
148
+ const removed: string[] = [];
149
+ for (const [eventKey, eventHooks] of Object.entries(hooks)) {
150
+ if (!Array.isArray(eventHooks)) continue;
151
+ const before = eventHooks.length;
152
+ const filtered = eventHooks.filter((h) => h[PACK_MARKER] !== packName);
153
+ if (filtered.length < before) {
154
+ removed.push(eventKey);
155
+ if (filtered.length === 0) {
156
+ delete hooks[eventKey];
157
+ } else {
158
+ hooks[eventKey] = filtered;
159
+ }
160
+ }
161
+ }
162
+ if (removed.length > 0) {
163
+ settings['hooks'] = hooks;
164
+ writeClaudeSettings(claudeDir, settings);
165
+ }
166
+ return removed;
167
+ }
168
+
50
169
  export function installPack(
51
170
  packName: string,
52
171
  options?: { projectDir?: string },
53
- ): { installed: string[]; skipped: string[] } {
172
+ ): { installed: string[]; skipped: string[]; scripts: string[]; lifecycleHooks: string[] } {
54
173
  const pack = getPack(packName);
55
174
  if (!pack) {
56
175
  throw new Error(`Unknown hook pack: "${packName}"`);
57
176
  }
58
-
59
177
  const claudeDir = resolveClaudeDir(options?.projectDir);
60
178
  mkdirSync(claudeDir, { recursive: true });
61
-
62
179
  const hookFiles = resolveHookFiles(packName);
63
180
  const installed: string[] = [];
64
181
  const skipped: string[] = [];
65
-
66
182
  for (const [hook, sourcePath] of hookFiles) {
67
183
  const destPath = join(claudeDir, `hookify.${hook}.local.md`);
68
184
  if (existsSync(destPath)) {
@@ -72,25 +188,31 @@ export function installPack(
72
188
  installed.push(hook);
73
189
  }
74
190
  }
75
-
76
- return { installed, skipped };
191
+ const scriptFiles = resolveScripts(packName);
192
+ const installedScripts: string[] = [];
193
+ for (const [, { sourcePath, targetDir, file }] of scriptFiles) {
194
+ const destDir = join(claudeDir, targetDir);
195
+ mkdirSync(destDir, { recursive: true });
196
+ const destPath = join(destDir, file);
197
+ copyFileSync(sourcePath, destPath);
198
+ chmodSync(destPath, 0o755);
199
+ installedScripts.push(`${targetDir}/${file}`);
200
+ }
201
+ const lcHooks = resolveLifecycleHooks(packName);
202
+ const addedHooks = addLifecycleHooks(claudeDir, lcHooks);
203
+ return { installed, skipped, scripts: installedScripts, lifecycleHooks: addedHooks };
77
204
  }
78
205
 
79
- /**
80
- * Remove a hook pack's files from target directory.
81
- */
82
206
  export function removePack(
83
207
  packName: string,
84
208
  options?: { projectDir?: string },
85
- ): { removed: string[] } {
209
+ ): { removed: string[]; scripts: string[]; lifecycleHooks: string[] } {
86
210
  const pack = getPack(packName);
87
211
  if (!pack) {
88
212
  throw new Error(`Unknown hook pack: "${packName}"`);
89
213
  }
90
-
91
214
  const claudeDir = resolveClaudeDir(options?.projectDir);
92
215
  const removed: string[] = [];
93
-
94
216
  for (const hook of pack.manifest.hooks) {
95
217
  const filePath = join(claudeDir, `hookify.${hook}.local.md`);
96
218
  if (existsSync(filePath)) {
@@ -98,31 +220,94 @@ export function removePack(
98
220
  removed.push(hook);
99
221
  }
100
222
  }
101
-
102
- return { removed };
223
+ const removedScripts: string[] = [];
224
+ if (pack.manifest.scripts) {
225
+ for (const script of pack.manifest.scripts) {
226
+ const filePath = join(claudeDir, script.targetDir, script.file);
227
+ if (existsSync(filePath)) {
228
+ unlinkSync(filePath);
229
+ removedScripts.push(`${script.targetDir}/${script.file}`);
230
+ }
231
+ }
232
+ }
233
+ if (pack.manifest.composedFrom) {
234
+ for (const subPackName of pack.manifest.composedFrom) {
235
+ const subPack = getPack(subPackName);
236
+ if (subPack?.manifest.scripts) {
237
+ for (const script of subPack.manifest.scripts) {
238
+ const filePath = join(claudeDir, script.targetDir, script.file);
239
+ if (existsSync(filePath)) {
240
+ unlinkSync(filePath);
241
+ removedScripts.push(`${script.targetDir}/${script.file}`);
242
+ }
243
+ }
244
+ }
245
+ }
246
+ }
247
+ const removedHooks = removeLifecycleHooks(claudeDir, packName);
248
+ if (pack.manifest.composedFrom) {
249
+ for (const subPackName of pack.manifest.composedFrom) {
250
+ removedHooks.push(...removeLifecycleHooks(claudeDir, subPackName));
251
+ }
252
+ }
253
+ return { removed, scripts: removedScripts, lifecycleHooks: removedHooks };
103
254
  }
104
255
 
105
- /**
106
- * Check if a pack is installed.
107
- * Returns true (all hooks present), false (none present), or 'partial'.
108
- */
109
256
  export function isPackInstalled(
110
257
  packName: string,
111
258
  options?: { projectDir?: string },
112
259
  ): boolean | 'partial' {
113
260
  const pack = getPack(packName);
114
261
  if (!pack) return false;
115
-
116
262
  const claudeDir = resolveClaudeDir(options?.projectDir);
263
+ let total = 0;
117
264
  let present = 0;
118
-
119
265
  for (const hook of pack.manifest.hooks) {
266
+ total++;
120
267
  if (existsSync(join(claudeDir, `hookify.${hook}.local.md`))) {
121
268
  present++;
122
269
  }
123
270
  }
124
-
271
+ if (pack.manifest.scripts) {
272
+ for (const script of pack.manifest.scripts) {
273
+ total++;
274
+ if (existsSync(join(claudeDir, script.targetDir, script.file))) {
275
+ present++;
276
+ }
277
+ }
278
+ }
279
+ if (pack.manifest.composedFrom) {
280
+ for (const subPackName of pack.manifest.composedFrom) {
281
+ const subPack = getPack(subPackName);
282
+ if (subPack?.manifest.scripts) {
283
+ for (const script of subPack.manifest.scripts) {
284
+ total++;
285
+ if (existsSync(join(claudeDir, script.targetDir, script.file))) {
286
+ present++;
287
+ }
288
+ }
289
+ }
290
+ }
291
+ }
292
+ if (total === 0) {
293
+ const lcHooks = resolveLifecycleHooks(packName);
294
+ if (lcHooks.length > 0) {
295
+ const settings = readClaudeSettings(claudeDir);
296
+ const hooksObj = (settings['hooks'] ?? {}) as Record<string, SettingsHookEntry[]>;
297
+ for (const { packName: sourcePack, hook } of lcHooks) {
298
+ total++;
299
+ const eventHooks = hooksObj[hook.event];
300
+ if (
301
+ Array.isArray(eventHooks) &&
302
+ eventHooks.some((h) => h.command === hook.command && h[PACK_MARKER] === sourcePack)
303
+ ) {
304
+ present++;
305
+ }
306
+ }
307
+ }
308
+ }
309
+ if (total === 0) return false;
125
310
  if (present === 0) return false;
126
- if (present === pack.manifest.hooks.length) return true;
311
+ if (present === total) return true;
127
312
  return 'partial';
128
313
  }
@@ -6,73 +6,71 @@ import { join, dirname } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { homedir } from 'node:os';
8
8
 
9
- interface HookPackManifest {
9
+ export interface HookPackScript {
10
+ name: string;
11
+ file: string;
12
+ targetDir: string;
13
+ }
14
+
15
+ export interface HookPackLifecycleHook {
16
+ event: string;
17
+ matcher: string;
18
+ type: 'command';
19
+ command: string;
20
+ timeout?: number;
21
+ statusMessage?: string;
22
+ }
23
+
24
+ export interface HookPackManifest {
10
25
  name: string;
11
26
  description: string;
12
27
  hooks: string[];
13
28
  composedFrom?: string[];
14
29
  version?: string;
15
- /** Whether this pack is built-in or user-defined */
30
+ scripts?: HookPackScript[];
31
+ lifecycleHooks?: HookPackLifecycleHook[];
16
32
  source?: 'built-in' | 'local';
17
33
  }
18
34
 
19
35
  const __filename = fileURLToPath(import.meta.url);
20
36
  const __dirname = dirname(__filename);
21
37
 
22
- /** Root directory containing all built-in hook packs. */
23
38
  function getBuiltinRoot(): string {
24
39
  return __dirname;
25
40
  }
26
-
27
- /** Local custom packs directory. */
28
41
  function getLocalRoot(): string {
29
42
  return join(process.cwd(), '.soleri', 'hook-packs');
30
43
  }
31
44
 
32
- /** Scan a directory for pack manifests. */
33
45
  function scanPacksDir(root: string, source: 'built-in' | 'local'): HookPackManifest[] {
34
46
  if (!existsSync(root)) return [];
35
47
  const entries = readdirSync(root, { withFileTypes: true });
36
48
  const packs: HookPackManifest[] = [];
37
-
38
49
  for (const entry of entries) {
39
50
  if (!entry.isDirectory()) continue;
40
51
  const manifestPath = join(root, entry.name, 'manifest.json');
41
52
  if (!existsSync(manifestPath)) continue;
42
-
43
53
  try {
44
54
  const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as HookPackManifest;
45
55
  manifest.source = source;
46
56
  packs.push(manifest);
47
57
  } catch {
48
- // Skip malformed manifests
58
+ /* Skip malformed manifests */
49
59
  }
50
60
  }
51
-
52
61
  return packs;
53
62
  }
54
63
 
55
- /**
56
- * List all available hook packs (built-in + local custom).
57
- * Local packs in .soleri/hook-packs/ override built-in packs with the same name.
58
- */
59
64
  export function listPacks(): HookPackManifest[] {
60
65
  const builtIn = scanPacksDir(getBuiltinRoot(), 'built-in');
61
66
  const local = scanPacksDir(getLocalRoot(), 'local');
62
-
63
- // Local packs override built-in packs with same name
64
67
  const byName = new Map<string, HookPackManifest>();
65
68
  for (const pack of builtIn) byName.set(pack.name, pack);
66
69
  for (const pack of local) byName.set(pack.name, pack);
67
-
68
70
  return Array.from(byName.values());
69
71
  }
70
72
 
71
- /**
72
- * Get a specific pack by name. Local packs take precedence.
73
- */
74
73
  export function getPack(name: string): { manifest: HookPackManifest; dir: string } | null {
75
- // Check local first
76
74
  const localDir = join(getLocalRoot(), name);
77
75
  const localManifest = join(localDir, 'manifest.json');
78
76
  if (existsSync(localManifest)) {
@@ -81,15 +79,12 @@ export function getPack(name: string): { manifest: HookPackManifest; dir: string
81
79
  manifest.source = 'local';
82
80
  return { manifest, dir: localDir };
83
81
  } catch {
84
- // Fall through to built-in
82
+ /* Fall through */
85
83
  }
86
84
  }
87
-
88
- // Then built-in
89
85
  const builtinDir = join(getBuiltinRoot(), name);
90
86
  const builtinManifest = join(builtinDir, 'manifest.json');
91
87
  if (!existsSync(builtinManifest)) return null;
92
-
93
88
  try {
94
89
  const manifest = JSON.parse(readFileSync(builtinManifest, 'utf-8')) as HookPackManifest;
95
90
  manifest.source = 'built-in';
@@ -99,15 +94,22 @@ export function getPack(name: string): { manifest: HookPackManifest; dir: string
99
94
  }
100
95
  }
101
96
 
102
- /**
103
- * Get names of packs that are fully installed in ~/.claude/.
104
- */
105
97
  export function getInstalledPacks(): string[] {
106
98
  const claudeDir = join(homedir(), '.claude');
107
99
  const packs = listPacks();
108
100
  const installed: string[] = [];
109
-
110
101
  for (const pack of packs) {
102
+ if (pack.hooks.length === 0) {
103
+ if (pack.scripts && pack.scripts.length > 0) {
104
+ const allScripts = pack.scripts.every((script) =>
105
+ existsSync(join(claudeDir, script.targetDir, script.file)),
106
+ );
107
+ if (allScripts) {
108
+ installed.push(pack.name);
109
+ }
110
+ }
111
+ continue;
112
+ }
111
113
  const allPresent = pack.hooks.every((hook) =>
112
114
  existsSync(join(claudeDir, `hookify.${hook}.local.md`)),
113
115
  );
@@ -115,6 +117,5 @@ export function getInstalledPacks(): string[] {
115
117
  installed.push(pack.name);
116
118
  }
117
119
  }
118
-
119
120
  return installed;
120
121
  }
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "yolo-safety",
3
+ "version": "1.0.0",
4
+ "description": "Anti-deletion guardrail for YOLO mode — intercepts destructive commands, stages files for review",
5
+ "hooks": [],
6
+ "scripts": [
7
+ {
8
+ "name": "anti-deletion",
9
+ "file": "anti-deletion.sh",
10
+ "targetDir": "hooks"
11
+ }
12
+ ],
13
+ "lifecycleHooks": [
14
+ {
15
+ "event": "PreToolUse",
16
+ "matcher": "Bash",
17
+ "type": "command",
18
+ "command": "bash ~/.claude/hooks/anti-deletion.sh",
19
+ "timeout": 10,
20
+ "statusMessage": "Checking for destructive commands..."
21
+ }
22
+ ]
23
+ }