@soleri/cli 9.15.0 → 9.16.7

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 (42) hide show
  1. package/dist/commands/add-domain.js +65 -0
  2. package/dist/commands/add-domain.js.map +1 -1
  3. package/dist/commands/chat.d.ts +11 -0
  4. package/dist/commands/chat.js +295 -0
  5. package/dist/commands/chat.js.map +1 -0
  6. package/dist/commands/create.js +11 -9
  7. package/dist/commands/create.js.map +1 -1
  8. package/dist/commands/dev.js +19 -0
  9. package/dist/commands/dev.js.map +1 -1
  10. package/dist/commands/hooks.js +29 -0
  11. package/dist/commands/hooks.js.map +1 -1
  12. package/dist/commands/knowledge.d.ts +9 -0
  13. package/dist/commands/knowledge.js +99 -0
  14. package/dist/commands/knowledge.js.map +1 -0
  15. package/dist/commands/pack.js +164 -3
  16. package/dist/commands/pack.js.map +1 -1
  17. package/dist/commands/schedule.d.ts +11 -0
  18. package/dist/commands/schedule.js +130 -0
  19. package/dist/commands/schedule.js.map +1 -0
  20. package/dist/commands/staging.d.ts +2 -17
  21. package/dist/commands/staging.js +4 -4
  22. package/dist/commands/staging.js.map +1 -1
  23. package/dist/main.js +6 -0
  24. package/dist/main.js.map +1 -1
  25. package/package.json +1 -1
  26. package/src/__tests__/hook-packs.test.ts +0 -18
  27. package/src/__tests__/hooks-convert.test.ts +0 -28
  28. package/src/__tests__/hooks-sync.test.ts +109 -0
  29. package/src/__tests__/hooks.test.ts +0 -20
  30. package/src/__tests__/update.test.ts +0 -19
  31. package/src/__tests__/validator.test.ts +0 -16
  32. package/src/commands/add-domain.ts +89 -1
  33. package/src/commands/chat.ts +373 -0
  34. package/src/commands/create.ts +11 -8
  35. package/src/commands/dev.ts +21 -0
  36. package/src/commands/hooks.ts +32 -0
  37. package/src/commands/knowledge.ts +124 -0
  38. package/src/commands/pack.ts +219 -1
  39. package/src/commands/schedule.ts +150 -0
  40. package/src/commands/staging.ts +5 -5
  41. package/src/main.ts +6 -0
  42. package/src/__tests__/dream.test.ts +0 -119
@@ -1,15 +1,19 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
1
3
  import type { Command } from 'commander';
2
4
  import * as p from '@clack/prompts';
3
5
  import { addDomain } from '@soleri/forge/lib';
4
6
  import { detectAgent } from '../utils/agent-context.js';
7
+ import { resolveVaultDbPath } from '../utils/vault-db.js';
5
8
 
6
9
  export function registerAddDomain(program: Command): void {
7
10
  program
8
11
  .command('add-domain')
9
12
  .argument('<domain>', 'Domain name in kebab-case (e.g., "security")')
10
13
  .option('--no-build', 'Skip the build step after adding the domain')
14
+ .option('--yes', 'Auto-seed vault entries into the knowledge bundle without prompting')
11
15
  .description('Add a new knowledge domain to the agent in the current directory')
12
- .action(async (domain: string, opts: { build: boolean }) => {
16
+ .action(async (domain: string, opts: { build: boolean; yes?: boolean }) => {
13
17
  const ctx = detectAgent();
14
18
  if (!ctx) {
15
19
  p.log.error('No agent project detected in current directory. Run this from an agent root.');
@@ -38,6 +42,9 @@ export function registerAddDomain(program: Command): void {
38
42
  if (!result.success) {
39
43
  process.exit(1);
40
44
  }
45
+
46
+ // ── Vault auto-seed ────────────────────────────────────────────────
47
+ await trySeedFromVault(ctx.agentId, ctx.agentPath, domain, opts.yes ?? false);
41
48
  } catch (err) {
42
49
  s.stop('Failed');
43
50
  p.log.error(err instanceof Error ? err.message : String(err));
@@ -45,3 +52,84 @@ export function registerAddDomain(program: Command): void {
45
52
  }
46
53
  });
47
54
  }
55
+
56
+ /**
57
+ * Query the agent's vault for entries matching the domain.
58
+ * If found, prompt the user (or auto-seed with --yes) to populate the bundle.
59
+ */
60
+ async function trySeedFromVault(
61
+ agentId: string,
62
+ agentPath: string,
63
+ domain: string,
64
+ autoYes: boolean,
65
+ ): Promise<void> {
66
+ const vaultDbPath = resolveVaultDbPath(agentId);
67
+ if (!vaultDbPath) return; // Vault not initialized yet — skip silently
68
+
69
+ try {
70
+ const { Vault } = await import('@soleri/core');
71
+ const vault = new Vault(vaultDbPath);
72
+
73
+ let entries: Array<{
74
+ id: string;
75
+ type: string;
76
+ domain?: string;
77
+ title: string;
78
+ description: string;
79
+ tags?: string[];
80
+ severity?: string;
81
+ }>;
82
+ try {
83
+ entries = vault.list({ domain, limit: 200 });
84
+ } finally {
85
+ vault.close();
86
+ }
87
+
88
+ if (entries.length === 0) return; // No matching entries
89
+
90
+ // Ask user (or auto-seed)
91
+ let shouldSeed = autoYes;
92
+ if (!autoYes) {
93
+ const answer = await p.confirm({
94
+ message: `Found ${entries.length} vault entries for domain "${domain}". Seed into knowledge bundle?`,
95
+ initialValue: true,
96
+ });
97
+ if (p.isCancel(answer)) return;
98
+ shouldSeed = answer;
99
+ }
100
+
101
+ if (!shouldSeed) return;
102
+
103
+ // Populate knowledge/{domain}.json
104
+ const bundlePath = join(agentPath, 'knowledge', `${domain}.json`);
105
+ const bundleEntries = entries.map((e) => ({
106
+ id: e.id,
107
+ type: e.type,
108
+ domain: e.domain,
109
+ title: e.title,
110
+ description: e.description,
111
+ ...(e.severity ? { severity: e.severity } : {}),
112
+ ...(e.tags?.length ? { tags: e.tags } : {}),
113
+ }));
114
+
115
+ try {
116
+ const existing = JSON.parse(readFileSync(bundlePath, 'utf-8')) as {
117
+ domain: string;
118
+ entries: unknown[];
119
+ };
120
+ existing.entries = bundleEntries;
121
+ writeFileSync(bundlePath, JSON.stringify(existing, null, 2) + '\n', 'utf-8');
122
+ } catch {
123
+ // File not readable — write fresh
124
+ writeFileSync(
125
+ bundlePath,
126
+ JSON.stringify({ domain, entries: bundleEntries }, null, 2) + '\n',
127
+ 'utf-8',
128
+ );
129
+ }
130
+
131
+ p.log.success(`Seeded ${entries.length} entries into knowledge/${domain}.json`);
132
+ } catch {
133
+ // Vault query failed — don't block the domain addition
134
+ }
135
+ }
@@ -0,0 +1,373 @@
1
+ /**
2
+ * `soleri chat` — interactive terminal chat with your agent.
3
+ *
4
+ * Spawns the agent's MCP server, connects via stdio JSON-RPC,
5
+ * and runs an interactive REPL using the agent loop.
6
+ *
7
+ * No external dependencies — MCP client is a minimal JSON-RPC/stdio
8
+ * implementation, Anthropic API is called via the core agent loop.
9
+ */
10
+
11
+ import { spawn } from 'node:child_process';
12
+ import type { ChildProcess } from 'node:child_process';
13
+ import { createInterface } from 'node:readline';
14
+ import { existsSync, readFileSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { homedir } from 'node:os';
17
+ import type { Command } from 'commander';
18
+ import * as p from '@clack/prompts';
19
+ import { detectAgent } from '../utils/agent-context.js';
20
+ import { runAgentLoop, SOLERI_HOME } from '@soleri/core';
21
+ import type { AgentTool, ChatMessage } from '@soleri/core';
22
+
23
+ // ─── MCP Types ──────────────────────────────────────────────────────────
24
+
25
+ interface JsonRpcRequest {
26
+ jsonrpc: '2.0';
27
+ id: number;
28
+ method: string;
29
+ params?: Record<string, unknown>;
30
+ }
31
+
32
+ interface JsonRpcNotification {
33
+ jsonrpc: '2.0';
34
+ method: string;
35
+ params?: Record<string, unknown>;
36
+ }
37
+
38
+ interface JsonRpcResponse {
39
+ jsonrpc: '2.0';
40
+ id: number;
41
+ result?: unknown;
42
+ error?: { code: number; message: string };
43
+ }
44
+
45
+ interface McpToolSchema {
46
+ name: string;
47
+ description?: string;
48
+ inputSchema?: { type?: string; properties?: Record<string, unknown>; required?: string[] };
49
+ }
50
+
51
+ // ─── Minimal MCP Stdio Client ───────────────────────────────────────────
52
+
53
+ class StdioMcpClient {
54
+ private proc: ChildProcess;
55
+ private pending = new Map<
56
+ number,
57
+ { resolve: (v: unknown) => void; reject: (e: Error) => void }
58
+ >();
59
+ private nextId = 1;
60
+ private buffer = '';
61
+
62
+ constructor(command: string, args: string[], env?: Record<string, string>) {
63
+ this.proc = spawn(command, args, {
64
+ stdio: ['pipe', 'pipe', 'inherit'],
65
+ env: { ...process.env, ...env },
66
+ });
67
+
68
+ this.proc.stdout!.on('data', (chunk: Buffer) => {
69
+ this.buffer += chunk.toString();
70
+ const lines = this.buffer.split('\n');
71
+ this.buffer = lines.pop() ?? '';
72
+ for (const line of lines) {
73
+ const trimmed = line.trim();
74
+ if (!trimmed) continue;
75
+ try {
76
+ const msg = JSON.parse(trimmed) as JsonRpcResponse;
77
+ if ('id' in msg && msg.id !== null && msg.id !== undefined) {
78
+ const handler = this.pending.get(msg.id);
79
+ if (handler) {
80
+ this.pending.delete(msg.id);
81
+ if (msg.error) {
82
+ handler.reject(new Error(msg.error.message));
83
+ } else {
84
+ handler.resolve(msg.result);
85
+ }
86
+ }
87
+ }
88
+ } catch {
89
+ // ignore non-JSON lines
90
+ }
91
+ }
92
+ });
93
+
94
+ this.proc.on('exit', () => {
95
+ for (const handler of this.pending.values()) {
96
+ handler.reject(new Error('MCP server exited'));
97
+ }
98
+ this.pending.clear();
99
+ });
100
+ }
101
+
102
+ private send(msg: JsonRpcRequest | JsonRpcNotification): void {
103
+ this.proc.stdin!.write(JSON.stringify(msg) + '\n');
104
+ }
105
+
106
+ private request(method: string, params?: Record<string, unknown>): Promise<unknown> {
107
+ const id = this.nextId++;
108
+ return new Promise((resolve, reject) => {
109
+ this.pending.set(id, { resolve, reject });
110
+ this.send({ jsonrpc: '2.0', id, method, params: params ?? {} });
111
+ setTimeout(() => {
112
+ if (this.pending.has(id)) {
113
+ this.pending.delete(id);
114
+ reject(new Error(`MCP request timeout: ${method}`));
115
+ }
116
+ }, 30_000);
117
+ });
118
+ }
119
+
120
+ async initialize(): Promise<void> {
121
+ await this.request('initialize', {
122
+ protocolVersion: '2024-11-05',
123
+ capabilities: {},
124
+ clientInfo: { name: 'soleri-chat', version: '0.0.1' },
125
+ });
126
+ this.send({ jsonrpc: '2.0', method: 'notifications/initialized' });
127
+ }
128
+
129
+ async listTools(): Promise<McpToolSchema[]> {
130
+ const result = (await this.request('tools/list', {})) as { tools?: McpToolSchema[] };
131
+ return result.tools ?? [];
132
+ }
133
+
134
+ async callTool(name: string, args: Record<string, unknown>): Promise<string> {
135
+ const result = (await this.request('tools/call', {
136
+ name,
137
+ arguments: args,
138
+ })) as { content?: Array<{ type: string; text?: string }>; isError?: boolean };
139
+
140
+ if (!result?.content) return '';
141
+ return result.content
142
+ .filter((c) => c.type === 'text')
143
+ .map((c) => c.text ?? '')
144
+ .join('\n');
145
+ }
146
+
147
+ kill(): void {
148
+ try {
149
+ this.proc.kill('SIGTERM');
150
+ } catch {
151
+ /* ignore */
152
+ }
153
+ }
154
+ }
155
+
156
+ // ─── Helper: Find Agent MCP Command ─────────────────────────────────────
157
+
158
+ interface McpCommand {
159
+ command: string;
160
+ args: string[];
161
+ env?: Record<string, string>;
162
+ }
163
+
164
+ function findMcpCommand(agentId: string): McpCommand | null {
165
+ const claudeConfigPath = join(homedir(), '.claude.json');
166
+ if (!existsSync(claudeConfigPath)) return null;
167
+
168
+ try {
169
+ const config = JSON.parse(readFileSync(claudeConfigPath, 'utf-8')) as {
170
+ mcpServers?: Record<
171
+ string,
172
+ { command?: string; args?: string[]; env?: Record<string, string> }
173
+ >;
174
+ };
175
+ const servers = config.mcpServers ?? {};
176
+ for (const [key, srv] of Object.entries(servers)) {
177
+ if (key === agentId || key.includes(agentId)) {
178
+ if (srv.command) {
179
+ return { command: srv.command, args: srv.args ?? [], env: srv.env };
180
+ }
181
+ }
182
+ }
183
+ } catch {
184
+ /* ignore */
185
+ }
186
+
187
+ // Fallback: try local dist/agent.js
188
+ return null;
189
+ }
190
+
191
+ // ─── Main Command ────────────────────────────────────────────────────────
192
+
193
+ export function registerChat(program: Command): void {
194
+ program
195
+ .command('chat')
196
+ .option('--model <model>', 'Claude model to use', 'claude-sonnet-4-20250514')
197
+ .option('--no-tools', 'Disable MCP tools (plain conversation)')
198
+ .description('Start an interactive chat session with your agent')
199
+ .action(async (opts: { model: string; tools: boolean }) => {
200
+ const ctx = detectAgent();
201
+ if (!ctx) {
202
+ p.log.error('No agent project detected in current directory.');
203
+ process.exit(1);
204
+ }
205
+
206
+ // ─── API Key ─────────────────────────────────────────────────
207
+ const apiKey =
208
+ process.env.ANTHROPIC_API_KEY ??
209
+ (() => {
210
+ try {
211
+ const keysPath = join(SOLERI_HOME, ctx.agentId, 'keys.json');
212
+ if (existsSync(keysPath)) {
213
+ const data = JSON.parse(readFileSync(keysPath, 'utf-8')) as {
214
+ anthropic?: string[];
215
+ };
216
+ return data.anthropic?.[0] ?? null;
217
+ }
218
+ } catch {
219
+ /* ignore */
220
+ }
221
+ return null;
222
+ })();
223
+
224
+ if (!apiKey) {
225
+ p.log.error(
226
+ 'ANTHROPIC_API_KEY is not set. Export it or add it to ' +
227
+ join(SOLERI_HOME, ctx.agentId, 'keys.json'),
228
+ );
229
+ process.exit(1);
230
+ }
231
+
232
+ // ─── System Prompt ────────────────────────────────────────────
233
+ const claudeMdPath = join(ctx.agentPath, 'CLAUDE.md');
234
+ const systemPrompt = existsSync(claudeMdPath)
235
+ ? readFileSync(claudeMdPath, 'utf-8')
236
+ : `You are ${ctx.agentId}, a helpful AI assistant powered by the Soleri engine.`;
237
+
238
+ // ─── MCP Tools ────────────────────────────────────────────────
239
+ let mcpClient: StdioMcpClient | null = null;
240
+ let tools: AgentTool[] = [];
241
+
242
+ if (opts.tools) {
243
+ const mcpCmd = findMcpCommand(ctx.agentId);
244
+
245
+ if (!mcpCmd) {
246
+ // Try local dist/agent.js
247
+ const localAgent = join(ctx.agentPath, 'dist', 'agent.js');
248
+ if (existsSync(localAgent)) {
249
+ mcpClient = new StdioMcpClient('node', [localAgent]);
250
+ }
251
+ } else {
252
+ mcpClient = new StdioMcpClient(mcpCmd.command, mcpCmd.args, mcpCmd.env);
253
+ }
254
+
255
+ if (mcpClient) {
256
+ const s = p.spinner();
257
+ s.start('Connecting to agent MCP server...');
258
+ try {
259
+ await mcpClient.initialize();
260
+ const mcpTools = await mcpClient.listTools();
261
+ tools = mcpTools.map((t) => ({
262
+ name: t.name,
263
+ description: t.description ?? '',
264
+ inputSchema: t.inputSchema ?? { type: 'object', properties: {} },
265
+ }));
266
+ s.stop(`Connected — ${tools.length} tools available`);
267
+ } catch (_err) {
268
+ s.stop('Could not connect to MCP server — running without tools');
269
+ mcpClient.kill();
270
+ mcpClient = null;
271
+ }
272
+ } else {
273
+ p.log.warn(
274
+ `No MCP server found for "${ctx.agentId}". ` +
275
+ 'Run `soleri dev` first, then re-run `soleri chat`. Running without tools.',
276
+ );
277
+ }
278
+ }
279
+
280
+ // ─── REPL ─────────────────────────────────────────────────────
281
+ console.log('');
282
+ p.intro(`Chat with ${ctx.agentId} (${opts.model})`);
283
+ console.log(' Type your message and press Enter. Ctrl+C or "exit" to quit.');
284
+ if (tools.length > 0) {
285
+ console.log(` Tools: ${tools.map((t) => t.name).join(', ')}`);
286
+ }
287
+ console.log('');
288
+
289
+ const history: ChatMessage[] = [];
290
+
291
+ const rl = createInterface({
292
+ input: process.stdin,
293
+ output: process.stdout,
294
+ terminal: true,
295
+ });
296
+
297
+ const prompt = (): Promise<string> =>
298
+ new Promise((resolve) => {
299
+ rl.question('\x1b[36mYou:\x1b[0m ', resolve);
300
+ });
301
+
302
+ const cleanup = () => {
303
+ rl.close();
304
+ if (mcpClient) mcpClient.kill();
305
+ console.log('\n Goodbye!');
306
+ process.exit(0);
307
+ };
308
+
309
+ rl.on('close', () => cleanup());
310
+ process.on('SIGINT', () => cleanup());
311
+
312
+ // eslint-disable-next-line no-constant-condition
313
+ while (true) {
314
+ // Sequential await is intentional — this is a REPL loop
315
+ // eslint-disable-next-line no-await-in-loop
316
+ const input = await prompt().catch(() => 'exit');
317
+ const trimmed = input.trim();
318
+
319
+ if (!trimmed || trimmed === 'exit' || trimmed === 'quit') {
320
+ cleanup();
321
+ return;
322
+ }
323
+
324
+ history.push({ role: 'user', content: trimmed, timestamp: Date.now() });
325
+
326
+ const thinking = p.spinner();
327
+ thinking.start('Thinking...');
328
+
329
+ try {
330
+ // eslint-disable-next-line no-await-in-loop
331
+ const result = await runAgentLoop(history, {
332
+ apiKey,
333
+ model: opts.model,
334
+ systemPrompt,
335
+ tools,
336
+ executor: async (toolName, toolInput) => {
337
+ if (!mcpClient) return { output: 'No MCP server connected', isError: true };
338
+ try {
339
+ const output = await mcpClient.callTool(toolName, toolInput);
340
+ return { output, isError: false };
341
+ } catch (err) {
342
+ return {
343
+ output: err instanceof Error ? err.message : String(err),
344
+ isError: true,
345
+ };
346
+ }
347
+ },
348
+ maxIterations: 20,
349
+ });
350
+
351
+ thinking.stop('');
352
+
353
+ const response = result.text;
354
+ if (response) {
355
+ console.log(`\n\x1b[32m${ctx.agentId}:\x1b[0m ${response}\n`);
356
+ history.push({
357
+ role: 'assistant',
358
+ content: response,
359
+ timestamp: Date.now(),
360
+ });
361
+ }
362
+
363
+ if (result.newMessages.length > 0) {
364
+ history.push(...result.newMessages);
365
+ }
366
+ } catch (err) {
367
+ thinking.stop('Error');
368
+ p.log.error(err instanceof Error ? err.message : String(err));
369
+ history.pop(); // remove the failed user message
370
+ }
371
+ }
372
+ });
373
+ }
@@ -187,15 +187,8 @@ export function registerCreate(program: Command): void {
187
187
  const outputDir = opts?.dir ? resolve(opts.dir) : (config.outputDir ?? process.cwd());
188
188
 
189
189
  // Preflight: ensure output directory exists and is writable
190
- if (!existsSync(outputDir)) {
191
- try {
192
- mkdirSync(outputDir, { recursive: true });
193
- } catch {
194
- p.log.error(`Cannot create ${outputDir} — check permissions`);
195
- process.exit(1);
196
- }
197
- }
198
190
  try {
191
+ mkdirSync(outputDir, { recursive: true });
199
192
  accessSync(outputDir, fsConstants.W_OK);
200
193
  } catch {
201
194
  p.log.error(`Cannot write to ${outputDir} — check permissions`);
@@ -398,6 +391,16 @@ export function registerCreate(program: Command): void {
398
391
  }
399
392
  }
400
393
 
394
+ // Preflight: ensure output directory exists and is writable
395
+ const legacyOutputDir = resolve(config.outputDir ?? process.cwd());
396
+ try {
397
+ mkdirSync(legacyOutputDir, { recursive: true });
398
+ accessSync(legacyOutputDir, fsConstants.W_OK);
399
+ } catch {
400
+ p.log.error(`Cannot write to ${legacyOutputDir} — check permissions`);
401
+ process.exit(1);
402
+ }
403
+
401
404
  // Scaffold + auto-build
402
405
  const s = p.spinner();
403
406
  s.start('Scaffolding and building agent...');
@@ -33,6 +33,7 @@ async function runFileTreeDev(agentPath: string, agentId: string): Promise<void>
33
33
  p.log.info('Press Ctrl+C to stop.\n');
34
34
 
35
35
  await regenerateClaudeMd(agentPath);
36
+ await syncSkills(agentPath, agentId);
36
37
 
37
38
  // Start the engine server
38
39
  let engineBin: string;
@@ -88,6 +89,7 @@ async function runFileTreeDev(agentPath: string, agentId: string): Promise<void>
88
89
  const changedFile = filename ? ` (${filename})` : '';
89
90
  p.log.info(`Change detected${changedFile} — regenerating CLAUDE.md`);
90
91
  await regenerateClaudeMd(agentPath);
92
+ await syncSkills(agentPath, agentId);
91
93
  }, 200);
92
94
  });
93
95
  } catch (err: unknown) {
@@ -130,6 +132,25 @@ async function regenerateClaudeMd(agentPath: string): Promise<void> {
130
132
  }
131
133
  }
132
134
 
135
+ async function syncSkills(agentPath: string, agentId: string): Promise<void> {
136
+ try {
137
+ const { syncSkillsToClaudeCode } = await import('@soleri/core');
138
+ const skillsDir = join(agentPath, 'skills');
139
+ const result = syncSkillsToClaudeCode([skillsDir], agentId, { projectRoot: agentPath });
140
+ const total = result.installed.length + result.updated.length;
141
+ if (total > 0) {
142
+ p.log.success(
143
+ `Skills synced: ${result.installed.length} installed, ${result.updated.length} updated`,
144
+ );
145
+ }
146
+ if (result.removed.length > 0) {
147
+ p.log.info(`Skills removed: ${result.removed.join(', ')}`);
148
+ }
149
+ } catch (err) {
150
+ p.log.warn(`Skill sync failed: ${err instanceof Error ? err.message : String(err)}`);
151
+ }
152
+ }
153
+
133
154
  function runLegacyDev(agentPath: string, agentId: string): void {
134
155
  p.log.info(`Starting ${agentId} in dev mode...`);
135
156
 
@@ -4,6 +4,7 @@ import { join } from 'node:path';
4
4
  import { SUPPORTED_EDITORS, type EditorId } from '../hooks/templates.js';
5
5
  import { installHooks, removeHooks, detectInstalledHooks } from '../hooks/generator.js';
6
6
  import { detectAgent } from '../utils/agent-context.js';
7
+ import { syncHooksToClaudeSettings } from '@soleri/core';
7
8
  import { listPacks, getPack } from '../hook-packs/registry.js';
8
9
  import { installPack, removePack, isPackInstalled } from '../hook-packs/installer.js';
9
10
  import { promotePack, demotePack } from '../hook-packs/graduation.js';
@@ -24,6 +25,37 @@ import * as log from '../utils/logger.js';
24
25
  export function registerHooks(program: Command): void {
25
26
  const hooks = program.command('hooks').description('Manage editor hooks and hook packs');
26
27
 
28
+ hooks
29
+ .command('sync')
30
+ .description(
31
+ 'Sync lifecycle hooks into ~/.claude/settings.json (runs automatically on postinstall)',
32
+ )
33
+ .action(() => {
34
+ const ctx = detectAgent();
35
+ if (!ctx) {
36
+ log.fail('No agent project detected. Run from an agent directory.');
37
+ process.exit(1);
38
+ }
39
+ const result = syncHooksToClaudeSettings(ctx.agentId);
40
+ if (result.error) {
41
+ log.fail(`Failed to sync hooks for ${ctx.agentId}: ${result.error}`);
42
+ process.exit(1);
43
+ }
44
+ const events = [...result.installed, ...result.updated, ...result.skipped];
45
+ if (result.installed.length > 0) {
46
+ log.pass(`Installed hooks for ${ctx.agentId}: ${result.installed.join(', ')}`);
47
+ }
48
+ if (result.updated.length > 0) {
49
+ log.pass(`Updated hooks for ${ctx.agentId}: ${result.updated.join(', ')}`);
50
+ }
51
+ if (result.skipped.length > 0) {
52
+ log.info(`Already up to date: ${result.skipped.join(', ')}`);
53
+ }
54
+ if (events.length === 0) {
55
+ log.info(`No hooks found for ${ctx.agentId}`);
56
+ }
57
+ });
58
+
27
59
  hooks
28
60
  .command('add')
29
61
  .argument('<editor>', `Editor: ${SUPPORTED_EDITORS.join(', ')}`)