ccraft 1.0.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 (40) hide show
  1. package/bin/claude-craft.js +85 -0
  2. package/package.json +39 -0
  3. package/src/commands/auth.js +43 -0
  4. package/src/commands/create.js +543 -0
  5. package/src/commands/install.js +480 -0
  6. package/src/commands/logout.js +24 -0
  7. package/src/commands/update.js +339 -0
  8. package/src/constants.js +299 -0
  9. package/src/generators/directories.js +30 -0
  10. package/src/generators/metadata.js +57 -0
  11. package/src/generators/security.js +39 -0
  12. package/src/prompts/gather.js +308 -0
  13. package/src/ui/brand.js +62 -0
  14. package/src/ui/cards.js +179 -0
  15. package/src/ui/format.js +55 -0
  16. package/src/ui/phase-header.js +20 -0
  17. package/src/ui/prompts.js +56 -0
  18. package/src/ui/tables.js +89 -0
  19. package/src/ui/tasks.js +258 -0
  20. package/src/ui/theme.js +83 -0
  21. package/src/utils/analysis-cache.js +519 -0
  22. package/src/utils/api-client.js +253 -0
  23. package/src/utils/api-file-writer.js +197 -0
  24. package/src/utils/bootstrap-runner.js +148 -0
  25. package/src/utils/claude-analyzer.js +255 -0
  26. package/src/utils/claude-optimizer.js +341 -0
  27. package/src/utils/claude-rewriter.js +553 -0
  28. package/src/utils/claude-scorer.js +101 -0
  29. package/src/utils/description-analyzer.js +116 -0
  30. package/src/utils/detect-project.js +1276 -0
  31. package/src/utils/existing-setup.js +341 -0
  32. package/src/utils/file-writer.js +64 -0
  33. package/src/utils/json-extract.js +56 -0
  34. package/src/utils/logger.js +27 -0
  35. package/src/utils/mcp-setup.js +461 -0
  36. package/src/utils/preflight.js +112 -0
  37. package/src/utils/prompt-api-key.js +59 -0
  38. package/src/utils/run-claude.js +152 -0
  39. package/src/utils/security.js +82 -0
  40. package/src/utils/toolkit-rule-generator.js +364 -0
@@ -0,0 +1,253 @@
1
+ /**
2
+ * API client for claude-craft server.
3
+ * Uses Node 18+ native fetch. No external dependencies.
4
+ */
5
+ import { readFileSync, writeFileSync, mkdirSync } from 'fs';
6
+ import { join } from 'path';
7
+ import { homedir } from 'os';
8
+ import { VERSION } from '../constants.js';
9
+
10
+ const CONFIG_DIR = join(homedir(), '.claude-craft');
11
+ const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
12
+ function getDefaultServerUrl() {
13
+ return process.env.CLAUDE_CRAFT_SERVER_URL || 'https://api.claude-craft.dev';
14
+ }
15
+ const TIMEOUT_MS = 30_000;
16
+
17
+ export class ApiError extends Error {
18
+ constructor(message, code, statusCode = null) {
19
+ super(message);
20
+ this.name = 'ApiError';
21
+ this.code = code;
22
+ this.statusCode = statusCode;
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Load stored config from ~/.claude-craft/config.json
28
+ */
29
+ export function loadConfig() {
30
+ try {
31
+ return JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Save config to ~/.claude-craft/config.json
39
+ */
40
+ export function saveConfig(config) {
41
+ mkdirSync(CONFIG_DIR, { recursive: true });
42
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n', 'utf8');
43
+ }
44
+
45
+ /**
46
+ * Call POST /api/generate on the server.
47
+ *
48
+ * @param {object} profile - { role, intents }
49
+ * @param {object} analysis - Project analysis data
50
+ * @param {object} [options] - { preset }
51
+ * @returns {Promise<{ files, summary, mcpConfigs, serverVersion }>}
52
+ */
53
+ export async function callGenerate(profile, analysis, options = {}) {
54
+ const config = loadConfig();
55
+ if (!config?.apiKey) {
56
+ throw new ApiError(
57
+ 'No API key configured. Run: claude-craft auth <key>',
58
+ 'NO_API_KEY',
59
+ );
60
+ }
61
+
62
+ const serverUrl = config.serverUrl || getDefaultServerUrl();
63
+ const url = `${serverUrl}/api/generate`;
64
+
65
+ let res;
66
+ try {
67
+ const controller = new AbortController();
68
+ const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
69
+
70
+ res = await fetch(url, {
71
+ method: 'POST',
72
+ headers: {
73
+ 'Content-Type': 'application/json',
74
+ 'Authorization': `Bearer ${config.apiKey}`,
75
+ 'X-Claude-Craft-Version': VERSION,
76
+ 'X-Claude-Craft-Api-Version': '1',
77
+ },
78
+ body: JSON.stringify({ profile, analysis, options }),
79
+ signal: controller.signal,
80
+ });
81
+
82
+ clearTimeout(timeout);
83
+ } catch (err) {
84
+ if (err.name === 'AbortError') {
85
+ throw new ApiError(
86
+ 'Request timed out. Check connection and ~/.claude-craft/config.json',
87
+ 'TIMEOUT',
88
+ );
89
+ }
90
+ throw new ApiError(
91
+ 'Could not reach server. Check connection and ~/.claude-craft/config.json',
92
+ 'NETWORK_ERROR',
93
+ );
94
+ }
95
+
96
+ if (!res.ok) {
97
+ const body = await res.json().catch(() => ({}));
98
+ switch (res.status) {
99
+ case 401:
100
+ case 403:
101
+ throw new ApiError(
102
+ body.error || 'API key invalid or expired. Run: claude-craft auth <new-key>',
103
+ 'AUTH_ERROR',
104
+ res.status,
105
+ );
106
+ case 426:
107
+ throw new ApiError(
108
+ body.error || 'Client incompatible with server. Run: npm update -g claude-craft',
109
+ 'VERSION_MISMATCH',
110
+ 426,
111
+ );
112
+ case 400:
113
+ throw new ApiError(
114
+ `Bad request: ${body.error || 'unknown'}${body.details ? ' — ' + body.details.join(', ') : ''}`,
115
+ 'BAD_REQUEST',
116
+ 400,
117
+ );
118
+ default:
119
+ throw new ApiError(
120
+ body.error || 'Server error. Try again later.',
121
+ 'SERVER_ERROR',
122
+ res.status,
123
+ );
124
+ }
125
+ }
126
+
127
+ return res.json();
128
+ }
129
+
130
+ /**
131
+ * Call POST /api/update on the server.
132
+ * Returns delta components (new files not already installed) + change summary.
133
+ *
134
+ * @param {object} profile - { role, intents, sourceControl, documentTools }
135
+ * @param {object} currentAnalysis - Freshly computed project analysis
136
+ * @param {object} previousAnalysis - Previously stored project analysis
137
+ * @param {string[]} installedRelativePaths - Relative file paths already on disk
138
+ * @returns {Promise<{ changes, guaranteed, candidates, prompts, mcpConfigs, summary, serverVersion }>}
139
+ */
140
+ export async function callUpdate(profile, currentAnalysis, previousAnalysis, installedRelativePaths) {
141
+ const config = loadConfig();
142
+ if (!config?.apiKey) {
143
+ throw new ApiError(
144
+ 'No API key configured. Run: claude-craft auth <key>',
145
+ 'NO_API_KEY',
146
+ );
147
+ }
148
+
149
+ const serverUrl = config.serverUrl || getDefaultServerUrl();
150
+ const url = `${serverUrl}/api/update`;
151
+
152
+ let res;
153
+ try {
154
+ const controller = new AbortController();
155
+ const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
156
+
157
+ res = await fetch(url, {
158
+ method: 'POST',
159
+ headers: {
160
+ 'Content-Type': 'application/json',
161
+ 'Authorization': `Bearer ${config.apiKey}`,
162
+ 'X-Claude-Craft-Version': VERSION,
163
+ 'X-Claude-Craft-Api-Version': '1',
164
+ },
165
+ body: JSON.stringify({ profile, currentAnalysis, previousAnalysis, installedRelativePaths }),
166
+ signal: controller.signal,
167
+ });
168
+
169
+ clearTimeout(timeout);
170
+ } catch (err) {
171
+ if (err.name === 'AbortError') {
172
+ throw new ApiError(
173
+ 'Request timed out. Check connection and ~/.claude-craft/config.json',
174
+ 'TIMEOUT',
175
+ );
176
+ }
177
+ throw new ApiError(
178
+ 'Could not reach server. Check connection and ~/.claude-craft/config.json',
179
+ 'NETWORK_ERROR',
180
+ );
181
+ }
182
+
183
+ if (!res.ok) {
184
+ const body = await res.json().catch(() => ({}));
185
+ switch (res.status) {
186
+ case 401:
187
+ case 403:
188
+ throw new ApiError(
189
+ body.error || 'API key invalid or expired. Run: claude-craft auth <new-key>',
190
+ 'AUTH_ERROR',
191
+ res.status,
192
+ );
193
+ case 426:
194
+ throw new ApiError(
195
+ body.error || 'Client incompatible with server. Run: npm update -g claude-craft',
196
+ 'VERSION_MISMATCH',
197
+ 426,
198
+ );
199
+ case 400:
200
+ throw new ApiError(
201
+ `Bad request: ${body.error || 'unknown'}${body.details ? ' — ' + body.details.join(', ') : ''}`,
202
+ 'BAD_REQUEST',
203
+ 400,
204
+ );
205
+ default:
206
+ throw new ApiError(
207
+ body.error || 'Server error. Try again later.',
208
+ 'SERVER_ERROR',
209
+ res.status,
210
+ );
211
+ }
212
+ }
213
+
214
+ return res.json();
215
+ }
216
+
217
+ /**
218
+ * Validate an API key against the server.
219
+ *
220
+ * @param {string} apiKey
221
+ * @param {string} [serverUrl]
222
+ * @returns {Promise<boolean>}
223
+ */
224
+ export async function validateKey(apiKey, serverUrl) {
225
+ const url = `${serverUrl || getDefaultServerUrl()}/api/auth/validate`;
226
+
227
+ let res;
228
+ try {
229
+ const controller = new AbortController();
230
+ const timeout = setTimeout(() => controller.abort(), 10_000);
231
+
232
+ res = await fetch(url, {
233
+ method: 'POST',
234
+ headers: { 'Content-Type': 'application/json' },
235
+ body: JSON.stringify({ apiKey }),
236
+ signal: controller.signal,
237
+ });
238
+
239
+ clearTimeout(timeout);
240
+ } catch {
241
+ throw new ApiError(
242
+ 'Could not reach server to validate key.',
243
+ 'NETWORK_ERROR',
244
+ );
245
+ }
246
+
247
+ if (!res.ok) {
248
+ return false;
249
+ }
250
+
251
+ const body = await res.json();
252
+ return body.valid === true;
253
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * API file writer — writes files from server response to disk.
3
+ *
4
+ * Handles two types:
5
+ * - type: 'file' → safeWriteFile(path, content)
6
+ * - type: 'json-merge' → accumulate, then mergeJsonFile
7
+ *
8
+ * Also handles MCP filtering (only selected MCPs) and API key injection.
9
+ */
10
+ import { join } from 'path';
11
+ import { ensureDir } from 'fs-extra/esm';
12
+ import { safeWriteFile, mergeJsonFile } from './file-writer.js';
13
+ import { generateSecurityGitignore } from './security.js';
14
+
15
+ /**
16
+ * Write all files from the API response to disk.
17
+ *
18
+ * @param {Array<{relativePath: string, content: string, type: 'file'|'json-merge'}>} files
19
+ * @param {string} targetDir - Project root directory
20
+ * @param {object} [opts]
21
+ * @param {boolean} [opts.force] - Overwrite existing files
22
+ * @param {string[]} [opts.selectedMcpIds] - MCP IDs user selected (for filtering)
23
+ * @param {object} [opts.mcpKeys] - { mcpId: { KEY_NAME: 'value' } }
24
+ * @param {object} [opts.securityConfig] - { addSecurityGitignore: boolean }
25
+ * @param {object} [opts.detected] - Detection result (for security gitignore)
26
+ * @returns {Promise<Array<{path: string, status: string}>>}
27
+ */
28
+ export async function writeApiFiles(files, targetDir, opts = {}) {
29
+ const results = [];
30
+
31
+ // Ensure base directories exist
32
+ const directories = [
33
+ '.claude/scripts',
34
+ '.claude/mcps',
35
+ '.claude/workflows',
36
+ ];
37
+ for (const dir of directories) {
38
+ await ensureDir(join(targetDir, dir));
39
+ }
40
+
41
+ // Separate json-merge files from regular files
42
+ const regularFiles = [];
43
+ const jsonMergeAccum = new Map(); // relativePath → [parsed objects]
44
+
45
+ for (const file of files) {
46
+ if (file.type === 'json-merge') {
47
+ const existing = jsonMergeAccum.get(file.relativePath) || [];
48
+ existing.push(JSON.parse(file.content));
49
+ jsonMergeAccum.set(file.relativePath, existing);
50
+ } else {
51
+ regularFiles.push(file);
52
+ }
53
+ }
54
+
55
+ // Write regular files
56
+ for (const file of regularFiles) {
57
+ const filePath = join(targetDir, file.relativePath);
58
+ const result = await safeWriteFile(filePath, file.content, { force: opts.force ?? true });
59
+ results.push(result);
60
+ }
61
+
62
+ // Process json-merge files — inject MCP keys and filter MCPs
63
+ for (const [relativePath, objects] of jsonMergeAccum) {
64
+ // Merge all objects for this path into one
65
+ let merged = {};
66
+ for (const obj of objects) {
67
+ merged = deepMerge(merged, obj);
68
+ }
69
+
70
+ // If this is settings.json and has mcpServers, filter to selected MCPs and inject keys
71
+ if (relativePath === '.claude/settings.json' && merged.mcpServers && opts.selectedMcpIds) {
72
+ const filtered = {};
73
+ for (const [id, serverConfig] of Object.entries(merged.mcpServers)) {
74
+ if (opts.selectedMcpIds.includes(id)) {
75
+ // Inject user-provided API keys
76
+ if (opts.mcpKeys && opts.mcpKeys[id]) {
77
+ serverConfig.env = { ...serverConfig.env, ...opts.mcpKeys[id] };
78
+ }
79
+ filtered[id] = serverConfig;
80
+ }
81
+ }
82
+ merged.mcpServers = filtered;
83
+ }
84
+
85
+ const filePath = join(targetDir, relativePath);
86
+ const result = await mergeJsonFile(filePath, merged, { force: opts.force ?? true });
87
+ results.push(result);
88
+ }
89
+
90
+ // Security gitignore (handled locally, not from server)
91
+ if (opts.securityConfig?.addSecurityGitignore) {
92
+ const { join: pathJoin } = await import('path');
93
+ const { pathExists, outputFile } = await import('fs-extra/esm');
94
+ const { readFileSync } = await import('fs');
95
+
96
+ const gitignorePath = pathJoin(targetDir, '.gitignore');
97
+ const securityBlock = generateSecurityGitignore(opts.detected || {});
98
+
99
+ if (await pathExists(gitignorePath)) {
100
+ const content = readFileSync(gitignorePath, 'utf8');
101
+ const existingLines = new Set(
102
+ content.split('\n').map((l) => l.trim()).filter(Boolean)
103
+ );
104
+
105
+ // Filter out lines that already exist in .gitignore (dedup)
106
+ const newLines = securityBlock
107
+ .split('\n')
108
+ .filter((line) => {
109
+ const trimmed = line.trim();
110
+ if (!trimmed || trimmed.startsWith('#')) return true; // keep comments & blanks
111
+ return !existingLines.has(trimmed);
112
+ });
113
+
114
+ if (newLines.some((l) => l.trim() && !l.trim().startsWith('#'))) {
115
+ let updated = content;
116
+ if (!updated.endsWith('\n')) updated += '\n';
117
+ updated += '\n' + newLines.join('\n');
118
+ await outputFile(gitignorePath, updated, 'utf8');
119
+ results.push({ path: gitignorePath, status: 'updated' });
120
+ } else {
121
+ results.push({ path: gitignorePath, status: 'skipped' });
122
+ }
123
+ } else {
124
+ await outputFile(gitignorePath, securityBlock, 'utf8');
125
+ results.push({ path: gitignorePath, status: 'created' });
126
+ }
127
+ }
128
+
129
+ return results;
130
+ }
131
+
132
+ /**
133
+ * Build a flat file list from a V3 API response.
134
+ *
135
+ * @param {object} apiResponse - V3 response with guaranteed.files and candidates.items
136
+ * @param {string[]|null} selectedCandidateIds - IDs selected by Claude, or null for all
137
+ * @returns {Array<{relativePath: string, content: string, type: string}>}
138
+ */
139
+ export function buildFileList(apiResponse, selectedCandidateIds) {
140
+ const files = [...(apiResponse.guaranteed?.files || [])];
141
+
142
+ const candidates = apiResponse.candidates?.items || [];
143
+ const selectedSet = selectedCandidateIds ? new Set(selectedCandidateIds) : null;
144
+
145
+ for (const candidate of candidates) {
146
+ // Skip unselected candidates (null = include all)
147
+ if (selectedSet && !selectedSet.has(candidate.id)) continue;
148
+
149
+ if (Array.isArray(candidate.files) && candidate.files.length > 0) {
150
+ // Multi-file candidates (skills with references/)
151
+ for (const f of candidate.files) {
152
+ if (f && typeof f.relativePath === 'string' && typeof f.content === 'string') {
153
+ files.push(f);
154
+ }
155
+ }
156
+ } else if (candidate.file) {
157
+ // Single-file candidates
158
+ files.push(candidate.file);
159
+ } else if (candidate.category === 'mcp' && candidate.mcpConfig) {
160
+ // MCP candidates: generate json-merge entry for settings.json
161
+ const serverConfig = {};
162
+ if (candidate.mcpConfig.command) {
163
+ serverConfig.command = candidate.mcpConfig.command;
164
+ if (candidate.mcpConfig.args?.length) serverConfig.args = candidate.mcpConfig.args;
165
+ } else if (candidate.mcpConfig.url) {
166
+ serverConfig.url = candidate.mcpConfig.url;
167
+ serverConfig.type = candidate.mcpConfig.transport || 'url';
168
+ }
169
+ files.push({
170
+ relativePath: '.claude/settings.json',
171
+ content: JSON.stringify({ mcpServers: { [candidate.id]: serverConfig } }),
172
+ type: 'json-merge',
173
+ });
174
+ }
175
+ }
176
+
177
+ return files;
178
+ }
179
+
180
+ function deepMerge(target, source) {
181
+ const result = { ...target };
182
+ for (const key of Object.keys(source)) {
183
+ if (
184
+ source[key] &&
185
+ typeof source[key] === 'object' &&
186
+ !Array.isArray(source[key]) &&
187
+ target[key] &&
188
+ typeof target[key] === 'object' &&
189
+ !Array.isArray(target[key])
190
+ ) {
191
+ result[key] = deepMerge(target[key], source[key]);
192
+ } else {
193
+ result[key] = source[key];
194
+ }
195
+ }
196
+ return result;
197
+ }
@@ -0,0 +1,148 @@
1
+ import { spawn } from 'child_process';
2
+ import { createInterface } from 'readline';
3
+ import chalk from 'chalk';
4
+ import { platformCmd } from './run-claude.js';
5
+
6
+ const DEFAULT_TIMEOUT = 600_000; // 10 minutes — bootstrap is long-running
7
+
8
+ /**
9
+ * Shorten a file path to the last two segments for concise log output.
10
+ */
11
+ function shortenPath(p) {
12
+ if (!p) return '';
13
+ const segments = p.replace(/\\/g, '/').split('/');
14
+ return segments.length > 2 ? '\u2026/' + segments.slice(-2).join('/') : p;
15
+ }
16
+
17
+ /**
18
+ * Format a tool-use event into a concise, human-friendly log line.
19
+ */
20
+ function formatToolLog(name, input) {
21
+ switch (name) {
22
+ case 'Read':
23
+ return `Reading ${shortenPath(input?.file_path)}`;
24
+ case 'Write':
25
+ return `Creating ${shortenPath(input?.file_path)}`;
26
+ case 'Edit':
27
+ return `Editing ${shortenPath(input?.file_path)}`;
28
+ case 'Bash': {
29
+ const cmd = input?.command || '';
30
+ return `Running ${chalk.cyan(cmd.length > 60 ? cmd.slice(0, 57) + '\u2026' : cmd)}`;
31
+ }
32
+ case 'Glob':
33
+ return `Searching for ${input?.pattern || 'files'}`;
34
+ case 'Grep':
35
+ return `Searching code for "${(input?.pattern || '').slice(0, 40)}"`;
36
+ case 'Agent':
37
+ return `Spawning ${input?.subagent_type || 'agent'}: ${(input?.description || '').slice(0, 50)}`;
38
+ case 'Skill':
39
+ return `Running /${input?.skill || 'skill'}`;
40
+ case 'TaskCreate':
41
+ return `Creating task: ${(input?.description || '').slice(0, 50)}`;
42
+ case 'TaskUpdate':
43
+ return `Updating task #${input?.task_id || '?'}`;
44
+ default:
45
+ return name;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Spawn `claude` CLI to run /bootstrap:auto in the target project directory.
51
+ * Streams Claude's activity as a real-time log so the user can follow progress.
52
+ *
53
+ * Uses `--output-format stream-json` to capture tool-use events and display
54
+ * them as concise log lines (e.g. "Creating src/index.ts", "Running npm install").
55
+ *
56
+ * @param {string} targetDir – Absolute path to the new project directory
57
+ * @param {string} description – User's project description (passed to /bootstrap:auto)
58
+ * @param {object} [opts]
59
+ * @param {number} [opts.timeout] – Hard timeout in ms (default: 600000)
60
+ * @returns {Promise<void>} Resolves on exit code 0, rejects otherwise
61
+ */
62
+ export function runBootstrap(targetDir, description, opts = {}) {
63
+ const timeout = opts.timeout ?? DEFAULT_TIMEOUT;
64
+
65
+ const prompt = `/bootstrap:auto ${description}`;
66
+ const { file, args } = platformCmd('claude', [
67
+ '--dangerously-skip-permissions',
68
+ '-p',
69
+ prompt,
70
+ '--verbose',
71
+ '--output-format', 'stream-json',
72
+ ]);
73
+
74
+ return new Promise((resolve, reject) => {
75
+ const child = spawn(file, args, {
76
+ cwd: targetDir,
77
+ stdio: ['ignore', 'pipe', 'inherit'],
78
+ windowsHide: true,
79
+ });
80
+
81
+ let killed = false;
82
+ const toolBlocks = new Map(); // index → { name, chunks[] }
83
+
84
+ const timer = setTimeout(() => {
85
+ killed = true;
86
+ child.kill('SIGTERM');
87
+ setTimeout(() => {
88
+ try { child.kill('SIGKILL'); } catch {}
89
+ }, 5000);
90
+ }, timeout);
91
+
92
+ // Parse streaming JSON and surface tool-use events as log lines
93
+ const rl = createInterface({ input: child.stdout });
94
+
95
+ rl.on('line', (line) => {
96
+ if (!line.trim()) return;
97
+ let event;
98
+ try { event = JSON.parse(line); } catch { return; }
99
+
100
+ // ── High-level message events (Claude Code wrapper format) ──
101
+ if (event.type === 'assistant' && event.message?.content) {
102
+ for (const block of event.message.content) {
103
+ if (block.type === 'tool_use') {
104
+ console.log(chalk.dim(` \u25b8 ${formatToolLog(block.name, block.input)}`));
105
+ }
106
+ }
107
+ return;
108
+ }
109
+
110
+ // ── Low-level streaming events (Anthropic API format) ───────
111
+ if (event.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
112
+ toolBlocks.set(event.index, { name: event.content_block.name, chunks: [] });
113
+ return;
114
+ }
115
+ if (event.type === 'content_block_delta' && event.delta?.type === 'input_json_delta') {
116
+ const block = toolBlocks.get(event.index);
117
+ if (block) block.chunks.push(event.delta.partial_json);
118
+ return;
119
+ }
120
+ if (event.type === 'content_block_stop') {
121
+ const block = toolBlocks.get(event.index);
122
+ if (block) {
123
+ let input = {};
124
+ try { input = JSON.parse(block.chunks.join('')); } catch {}
125
+ console.log(chalk.dim(` \u25b8 ${formatToolLog(block.name, input)}`));
126
+ toolBlocks.delete(event.index);
127
+ }
128
+ return;
129
+ }
130
+ });
131
+
132
+ child.on('error', (err) => {
133
+ clearTimeout(timer);
134
+ reject(new Error(`Failed to launch Claude CLI: ${err.message}`));
135
+ });
136
+
137
+ child.on('close', (code) => {
138
+ clearTimeout(timer);
139
+ if (killed) {
140
+ return reject(new Error('Bootstrap timed out after 10 minutes. You can re-run /bootstrap:auto manually inside the project.'));
141
+ }
142
+ if (code !== 0) {
143
+ return reject(new Error(`Bootstrap exited with code ${code}. You can re-run /bootstrap:auto manually inside the project.`));
144
+ }
145
+ resolve();
146
+ });
147
+ });
148
+ }