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.
- package/bin/claude-craft.js +85 -0
- package/package.json +39 -0
- package/src/commands/auth.js +43 -0
- package/src/commands/create.js +543 -0
- package/src/commands/install.js +480 -0
- package/src/commands/logout.js +24 -0
- package/src/commands/update.js +339 -0
- package/src/constants.js +299 -0
- package/src/generators/directories.js +30 -0
- package/src/generators/metadata.js +57 -0
- package/src/generators/security.js +39 -0
- package/src/prompts/gather.js +308 -0
- package/src/ui/brand.js +62 -0
- package/src/ui/cards.js +179 -0
- package/src/ui/format.js +55 -0
- package/src/ui/phase-header.js +20 -0
- package/src/ui/prompts.js +56 -0
- package/src/ui/tables.js +89 -0
- package/src/ui/tasks.js +258 -0
- package/src/ui/theme.js +83 -0
- package/src/utils/analysis-cache.js +519 -0
- package/src/utils/api-client.js +253 -0
- package/src/utils/api-file-writer.js +197 -0
- package/src/utils/bootstrap-runner.js +148 -0
- package/src/utils/claude-analyzer.js +255 -0
- package/src/utils/claude-optimizer.js +341 -0
- package/src/utils/claude-rewriter.js +553 -0
- package/src/utils/claude-scorer.js +101 -0
- package/src/utils/description-analyzer.js +116 -0
- package/src/utils/detect-project.js +1276 -0
- package/src/utils/existing-setup.js +341 -0
- package/src/utils/file-writer.js +64 -0
- package/src/utils/json-extract.js +56 -0
- package/src/utils/logger.js +27 -0
- package/src/utils/mcp-setup.js +461 -0
- package/src/utils/preflight.js +112 -0
- package/src/utils/prompt-api-key.js +59 -0
- package/src/utils/run-claude.js +152 -0
- package/src/utils/security.js +82 -0
- package/src/utils/toolkit-rule-generator.js +364 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Existing Claude setup detection and context extraction.
|
|
3
|
+
*
|
|
4
|
+
* When claude-craft finds an existing .claude/ directory during installation,
|
|
5
|
+
* this module:
|
|
6
|
+
* 1. Detects what configuration files exist
|
|
7
|
+
* 2. Reads existing files and uses Claude to summarize project/user context
|
|
8
|
+
* 3. Removes the old setup so craft can generate a fresh configuration
|
|
9
|
+
*
|
|
10
|
+
* The extracted context enriches the new installation — the Claude analyzer
|
|
11
|
+
* receives prior knowledge about the project, producing better results.
|
|
12
|
+
*/
|
|
13
|
+
import { join } from 'path';
|
|
14
|
+
import { existsSync, readFileSync, readdirSync, rmSync, statSync, unlinkSync } from 'fs';
|
|
15
|
+
import { isClaudeAvailable, runClaude } from './run-claude.js';
|
|
16
|
+
import { extractJsonObject } from './json-extract.js';
|
|
17
|
+
import * as logger from './logger.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Detect if the target directory has an existing Claude setup.
|
|
21
|
+
* Returns null if no meaningful setup found, or an object describing what exists.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} targetDir - Project root
|
|
24
|
+
* @returns {{ claudeMd: boolean, settings: boolean, metadata: boolean, agents: number, skills: number, rules: number, commands: number } | null}
|
|
25
|
+
*/
|
|
26
|
+
export function detectExistingSetup(targetDir) {
|
|
27
|
+
const claudeDir = join(targetDir, '.claude');
|
|
28
|
+
if (!existsSync(claudeDir)) return null;
|
|
29
|
+
|
|
30
|
+
const checks = {
|
|
31
|
+
claudeMd: existsSync(join(targetDir, 'CLAUDE.md')),
|
|
32
|
+
settings: existsSync(join(claudeDir, 'settings.json')),
|
|
33
|
+
metadata: existsSync(join(claudeDir, '.claude-craft.json')),
|
|
34
|
+
agents: countFilesInDir(join(claudeDir, 'agents')),
|
|
35
|
+
skills: countFilesInDir(join(claudeDir, 'skills')),
|
|
36
|
+
rules: countFilesInDir(join(claudeDir, 'rules')),
|
|
37
|
+
commands: countFilesInDir(join(claudeDir, 'commands')),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const hasContent = checks.claudeMd || checks.settings || checks.metadata
|
|
41
|
+
|| checks.agents > 0 || checks.skills > 0
|
|
42
|
+
|| checks.rules > 0 || checks.commands > 0;
|
|
43
|
+
|
|
44
|
+
if (!hasContent) return null;
|
|
45
|
+
|
|
46
|
+
return checks;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Extract context from existing Claude setup.
|
|
51
|
+
* Reads key files directly, then uses Claude to produce a structured summary.
|
|
52
|
+
* Falls back to a metadata-based summary if Claude is unavailable.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} targetDir - Project root
|
|
55
|
+
* @returns {Promise<ExistingContext>}
|
|
56
|
+
*
|
|
57
|
+
* @typedef {object} ExistingContext
|
|
58
|
+
* @property {object|null} previousMetadata - Parsed .claude-craft.json
|
|
59
|
+
* @property {string|null} claudeMdContent - Raw CLAUDE.md text
|
|
60
|
+
* @property {object|null} settings - Parsed settings.json
|
|
61
|
+
* @property {{ agents: number, skills: number, rules: number, commands: number }} componentCounts
|
|
62
|
+
* @property {object} summary - Structured summary for downstream use
|
|
63
|
+
*/
|
|
64
|
+
export async function extractExistingContext(targetDir) {
|
|
65
|
+
const rawContext = readExistingFiles(targetDir);
|
|
66
|
+
|
|
67
|
+
if (isClaudeAvailable()) {
|
|
68
|
+
try {
|
|
69
|
+
const summary = await claudeSummarize(targetDir, rawContext);
|
|
70
|
+
if (summary) {
|
|
71
|
+
return { ...rawContext, summary };
|
|
72
|
+
}
|
|
73
|
+
} catch (err) {
|
|
74
|
+
logger.debug(`Claude summarization of existing setup failed: ${err.message}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { ...rawContext, summary: buildFallbackSummary(rawContext) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Remove existing .claude/ directory and root CLAUDE.md.
|
|
83
|
+
* Leaves .gitignore untouched (security patterns should persist).
|
|
84
|
+
*
|
|
85
|
+
* @param {string} targetDir - Project root
|
|
86
|
+
*/
|
|
87
|
+
export function removeExistingSetup(targetDir) {
|
|
88
|
+
const claudeDir = join(targetDir, '.claude');
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
if (existsSync(claudeDir)) {
|
|
92
|
+
rmSync(claudeDir, { recursive: true, force: true });
|
|
93
|
+
}
|
|
94
|
+
} catch (err) {
|
|
95
|
+
logger.warn(`Failed to remove .claude/ directory: ${err.message}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const claudeMdPath = join(targetDir, 'CLAUDE.md');
|
|
100
|
+
if (existsSync(claudeMdPath)) {
|
|
101
|
+
unlinkSync(claudeMdPath);
|
|
102
|
+
}
|
|
103
|
+
} catch (err) {
|
|
104
|
+
logger.warn(`Failed to remove CLAUDE.md: ${err.message}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const userGuidePath = join(targetDir, 'USER_GUIDE.md');
|
|
109
|
+
if (existsSync(userGuidePath)) {
|
|
110
|
+
const content = readFileSync(userGuidePath, 'utf8');
|
|
111
|
+
if (content.includes('Generated by claude-craft')) {
|
|
112
|
+
unlinkSync(userGuidePath);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
} catch (err) {
|
|
116
|
+
logger.warn(`Failed to remove USER_GUIDE.md: ${err.message}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Format extracted context into a block suitable for embedding
|
|
122
|
+
* in the Claude analyzer prompt.
|
|
123
|
+
*
|
|
124
|
+
* @param {ExistingContext} context - From extractExistingContext()
|
|
125
|
+
* @returns {string} Markdown block to append to the analysis prompt
|
|
126
|
+
*/
|
|
127
|
+
export function formatContextForAnalyzer(context) {
|
|
128
|
+
if (!context) return '';
|
|
129
|
+
|
|
130
|
+
const parts = [];
|
|
131
|
+
|
|
132
|
+
if (context.summary?.projectContext) {
|
|
133
|
+
parts.push(context.summary.projectContext);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (context.summary?.userPreferences?.role) {
|
|
137
|
+
parts.push(`Previous user role: ${context.summary.userPreferences.role}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (context.summary?.userPreferences?.tools?.length) {
|
|
141
|
+
parts.push(`Tools used: ${context.summary.userPreferences.tools.join(', ')}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (context.summary?.customizations?.length) {
|
|
145
|
+
parts.push(`Notable customizations: ${context.summary.customizations.join('; ')}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (context.summary?.preserveNotes) {
|
|
149
|
+
parts.push(`Important notes: ${context.summary.preserveNotes}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (parts.length === 0) return '';
|
|
153
|
+
|
|
154
|
+
return [
|
|
155
|
+
'',
|
|
156
|
+
'## Previous Configuration Context',
|
|
157
|
+
'This project had a previous Claude Code setup. Use this to validate and enrich your analysis:',
|
|
158
|
+
...parts,
|
|
159
|
+
].join('\n');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── Internal helpers ─────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Read key files from the existing .claude/ setup.
|
|
166
|
+
*/
|
|
167
|
+
function readExistingFiles(targetDir) {
|
|
168
|
+
const claudeDir = join(targetDir, '.claude');
|
|
169
|
+
const context = {
|
|
170
|
+
previousMetadata: null,
|
|
171
|
+
claudeMdContent: null,
|
|
172
|
+
settings: null,
|
|
173
|
+
componentCounts: { agents: 0, skills: 0, rules: 0, commands: 0 },
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const path = join(claudeDir, '.claude-craft.json');
|
|
178
|
+
if (existsSync(path)) {
|
|
179
|
+
context.previousMetadata = JSON.parse(readFileSync(path, 'utf8'));
|
|
180
|
+
}
|
|
181
|
+
} catch { /* skip corrupt metadata */ }
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
const path = join(targetDir, 'CLAUDE.md');
|
|
185
|
+
if (existsSync(path)) {
|
|
186
|
+
const raw = readFileSync(path, 'utf8');
|
|
187
|
+
// Cap at 200 lines to keep prompts manageable
|
|
188
|
+
const lines = raw.split('\n');
|
|
189
|
+
context.claudeMdContent = lines.length > 200
|
|
190
|
+
? lines.slice(0, 200).join('\n') + '\n... (truncated)'
|
|
191
|
+
: raw;
|
|
192
|
+
}
|
|
193
|
+
} catch { /* skip unreadable file */ }
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
const path = join(claudeDir, 'settings.json');
|
|
197
|
+
if (existsSync(path)) {
|
|
198
|
+
context.settings = JSON.parse(readFileSync(path, 'utf8'));
|
|
199
|
+
}
|
|
200
|
+
} catch { /* skip corrupt settings */ }
|
|
201
|
+
|
|
202
|
+
for (const category of ['agents', 'skills', 'rules', 'commands']) {
|
|
203
|
+
context.componentCounts[category] = countFilesInDir(join(claudeDir, category));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return context;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Use Claude CLI to summarize the existing setup.
|
|
211
|
+
* All file content is embedded inline — no tool calls needed.
|
|
212
|
+
*/
|
|
213
|
+
async function claudeSummarize(targetDir, rawContext) {
|
|
214
|
+
const contextParts = [];
|
|
215
|
+
|
|
216
|
+
if (rawContext.claudeMdContent) {
|
|
217
|
+
contextParts.push(`## Existing CLAUDE.md\n${rawContext.claudeMdContent}`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (rawContext.previousMetadata) {
|
|
221
|
+
const meta = rawContext.previousMetadata;
|
|
222
|
+
const metaLines = [];
|
|
223
|
+
if (meta.project?.name) metaLines.push(`- Name: ${meta.project.name}`);
|
|
224
|
+
if (meta.project?.description) metaLines.push(`- Description: ${meta.project.description}`);
|
|
225
|
+
if (meta.project?.type) metaLines.push(`- Type: ${meta.project.type}`);
|
|
226
|
+
if (meta.role) metaLines.push(`- User role: ${meta.role}`);
|
|
227
|
+
if (meta.intents?.length) metaLines.push(`- Intents: ${meta.intents.join(', ')}`);
|
|
228
|
+
if (meta.stack?.languages?.length) metaLines.push(`- Languages: ${meta.stack.languages.join(', ')}`);
|
|
229
|
+
if (meta.stack?.frameworks?.length) metaLines.push(`- Frameworks: ${meta.stack.frameworks.join(', ')}`);
|
|
230
|
+
if (meta.architecture) metaLines.push(`- Architecture: ${meta.architecture}`);
|
|
231
|
+
if (meta.stack?.packageManager) metaLines.push(`- Package manager: ${meta.stack.packageManager}`);
|
|
232
|
+
if (meta.stack?.testFramework) metaLines.push(`- Test framework: ${meta.stack.testFramework}`);
|
|
233
|
+
if (metaLines.length > 0) {
|
|
234
|
+
contextParts.push(`## Previous Installation Metadata\n${metaLines.join('\n')}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (rawContext.settings) {
|
|
239
|
+
const mcpNames = Object.keys(rawContext.settings.mcpServers || {});
|
|
240
|
+
const envVars = Object.keys(rawContext.settings.env || {});
|
|
241
|
+
const settingsLines = [];
|
|
242
|
+
if (mcpNames.length) settingsLines.push(`- MCP servers: ${mcpNames.join(', ')}`);
|
|
243
|
+
if (envVars.length) settingsLines.push(`- Environment variables: ${envVars.join(', ')}`);
|
|
244
|
+
if (settingsLines.length > 0) {
|
|
245
|
+
contextParts.push(`## Settings Summary\n${settingsLines.join('\n')}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (contextParts.length === 0) return null;
|
|
250
|
+
|
|
251
|
+
const prompt = `You are analyzing an existing Claude Code configuration for a project.
|
|
252
|
+
Extract useful context about the project and the user's preferences so we can
|
|
253
|
+
generate a better configuration on reinstall.
|
|
254
|
+
|
|
255
|
+
${contextParts.join('\n\n')}
|
|
256
|
+
|
|
257
|
+
## Installed Components
|
|
258
|
+
- Agents: ${rawContext.componentCounts.agents}
|
|
259
|
+
- Skills: ${rawContext.componentCounts.skills}
|
|
260
|
+
- Rules: ${rawContext.componentCounts.rules}
|
|
261
|
+
- Commands: ${rawContext.componentCounts.commands}
|
|
262
|
+
|
|
263
|
+
## Task
|
|
264
|
+
|
|
265
|
+
Based on the configuration above, return a JSON summary. Focus on:
|
|
266
|
+
1. Project-specific context (what the project does, tech stack, architecture)
|
|
267
|
+
2. User preferences (role, work style, tools)
|
|
268
|
+
3. Notable customizations beyond default generated config
|
|
269
|
+
4. Anything important to carry forward to the new installation
|
|
270
|
+
|
|
271
|
+
Return ONLY a JSON object:
|
|
272
|
+
{
|
|
273
|
+
"projectContext": "2-3 sentence summary of the project from the existing config",
|
|
274
|
+
"userPreferences": {
|
|
275
|
+
"role": "detected user role or null",
|
|
276
|
+
"workStyle": "any work style preferences or null",
|
|
277
|
+
"tools": ["tools/services the user works with"]
|
|
278
|
+
},
|
|
279
|
+
"customizations": ["list of notable customizations beyond defaults"],
|
|
280
|
+
"preserveNotes": "anything important to carry forward, or null"
|
|
281
|
+
}`;
|
|
282
|
+
|
|
283
|
+
const output = await runClaude([
|
|
284
|
+
'-p',
|
|
285
|
+
'--max-turns', '2',
|
|
286
|
+
'--allowedTools', '',
|
|
287
|
+
], { cwd: targetDir, stdinInput: prompt, timeout: 60_000 });
|
|
288
|
+
|
|
289
|
+
return extractJsonObject(output, 'projectContext');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Build a summary from raw metadata when Claude is unavailable.
|
|
294
|
+
*/
|
|
295
|
+
function buildFallbackSummary(rawContext) {
|
|
296
|
+
const meta = rawContext.previousMetadata;
|
|
297
|
+
const contextParts = [];
|
|
298
|
+
|
|
299
|
+
if (meta?.project?.name) contextParts.push(`Project: ${meta.project.name}`);
|
|
300
|
+
if (meta?.project?.description) contextParts.push(meta.project.description);
|
|
301
|
+
if (meta?.stack?.languages?.length) contextParts.push(`Languages: ${meta.stack.languages.join(', ')}`);
|
|
302
|
+
if (meta?.stack?.frameworks?.length) contextParts.push(`Frameworks: ${meta.stack.frameworks.join(', ')}`);
|
|
303
|
+
if (meta?.architecture) contextParts.push(`Architecture: ${meta.architecture}`);
|
|
304
|
+
|
|
305
|
+
const tools = [];
|
|
306
|
+
if (rawContext.settings?.mcpServers) {
|
|
307
|
+
tools.push(...Object.keys(rawContext.settings.mcpServers));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
projectContext: contextParts.join('. ') || 'No previous project context available.',
|
|
312
|
+
userPreferences: {
|
|
313
|
+
role: meta?.role || null,
|
|
314
|
+
workStyle: null,
|
|
315
|
+
tools,
|
|
316
|
+
},
|
|
317
|
+
customizations: [],
|
|
318
|
+
preserveNotes: null,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Count meaningful files in a directory (excludes .gitkeep).
|
|
324
|
+
*/
|
|
325
|
+
function countFilesInDir(dirPath) {
|
|
326
|
+
try {
|
|
327
|
+
if (!existsSync(dirPath) || !statSync(dirPath).isDirectory()) return 0;
|
|
328
|
+
return readdirSync(dirPath, { recursive: true })
|
|
329
|
+
.filter((f) => {
|
|
330
|
+
const full = join(dirPath, String(f));
|
|
331
|
+
try {
|
|
332
|
+
return statSync(full).isFile() && !String(f).endsWith('.gitkeep');
|
|
333
|
+
} catch {
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
})
|
|
337
|
+
.length;
|
|
338
|
+
} catch {
|
|
339
|
+
return 0;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { pathExists, ensureDir, readJson, writeJson, outputFile } from 'fs-extra/esm';
|
|
2
|
+
import { dirname } from 'path';
|
|
3
|
+
import { warn } from './logger.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Write a file. Overwrites by default.
|
|
7
|
+
* If force=false AND interactive=true, could prompt (but we default interactive to false).
|
|
8
|
+
* Returns { path, status: 'created' | 'skipped' | 'updated' }
|
|
9
|
+
*/
|
|
10
|
+
export async function safeWriteFile(filePath, content, { force = false } = {}) {
|
|
11
|
+
await ensureDir(dirname(filePath));
|
|
12
|
+
|
|
13
|
+
const exists = await pathExists(filePath);
|
|
14
|
+
if (exists && !force) {
|
|
15
|
+
// Non-destructive: skip existing files unless force is set.
|
|
16
|
+
// No interactive prompt — prompts during generation hang behind the spinner.
|
|
17
|
+
return { path: filePath, status: 'skipped' };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
await outputFile(filePath, content, 'utf8');
|
|
21
|
+
return { path: filePath, status: exists ? 'updated' : 'created' };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Merge new keys into an existing JSON file, or create it.
|
|
26
|
+
* Always merges — never prompts.
|
|
27
|
+
*/
|
|
28
|
+
export async function mergeJsonFile(filePath, data, { force = false } = {}) {
|
|
29
|
+
await ensureDir(dirname(filePath));
|
|
30
|
+
|
|
31
|
+
let existing = {};
|
|
32
|
+
const exists = await pathExists(filePath);
|
|
33
|
+
|
|
34
|
+
if (exists) {
|
|
35
|
+
try {
|
|
36
|
+
existing = await readJson(filePath);
|
|
37
|
+
} catch {
|
|
38
|
+
warn(`Could not parse ${filePath}, overwriting`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const merged = deepMerge(existing, data);
|
|
43
|
+
await writeJson(filePath, merged, { spaces: 2 });
|
|
44
|
+
return { path: filePath, status: exists ? 'updated' : 'created' };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function deepMerge(target, source) {
|
|
48
|
+
const result = { ...target };
|
|
49
|
+
for (const key of Object.keys(source)) {
|
|
50
|
+
if (
|
|
51
|
+
source[key] &&
|
|
52
|
+
typeof source[key] === 'object' &&
|
|
53
|
+
!Array.isArray(source[key]) &&
|
|
54
|
+
target[key] &&
|
|
55
|
+
typeof target[key] === 'object' &&
|
|
56
|
+
!Array.isArray(target[key])
|
|
57
|
+
) {
|
|
58
|
+
result[key] = deepMerge(target[key], source[key]);
|
|
59
|
+
} else {
|
|
60
|
+
result[key] = source[key];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brace-balanced JSON object extraction from mixed text.
|
|
3
|
+
*
|
|
4
|
+
* More reliable than regex for extracting JSON when the text contains
|
|
5
|
+
* nested braces inside string values (e.g. reasoning fields).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Extract a JSON object from mixed text using brace-balanced parsing.
|
|
10
|
+
*
|
|
11
|
+
* @param {string} text - Text that may contain a JSON object
|
|
12
|
+
* @param {string} requiredKey - If provided, only return objects containing this key
|
|
13
|
+
* @returns {object|null} Parsed object, or null if not found
|
|
14
|
+
*/
|
|
15
|
+
export function extractJsonObject(text, requiredKey = null) {
|
|
16
|
+
if (!text || typeof text !== 'string') return null;
|
|
17
|
+
|
|
18
|
+
for (let i = 0; i < text.length; i++) {
|
|
19
|
+
if (text[i] !== '{') continue;
|
|
20
|
+
|
|
21
|
+
// Quick check: if requiredKey specified, verify it exists after this brace
|
|
22
|
+
if (requiredKey && !text.slice(i).includes(`"${requiredKey}"`)) return null;
|
|
23
|
+
|
|
24
|
+
let depth = 0;
|
|
25
|
+
let inString = false;
|
|
26
|
+
let escape = false;
|
|
27
|
+
|
|
28
|
+
for (let j = i; j < text.length; j++) {
|
|
29
|
+
const ch = text[j];
|
|
30
|
+
|
|
31
|
+
if (escape) { escape = false; continue; }
|
|
32
|
+
if (ch === '\\' && inString) { escape = true; continue; }
|
|
33
|
+
if (ch === '"' && !escape) { inString = !inString; continue; }
|
|
34
|
+
if (inString) continue;
|
|
35
|
+
|
|
36
|
+
if (ch === '{') depth++;
|
|
37
|
+
else if (ch === '}') {
|
|
38
|
+
depth--;
|
|
39
|
+
if (depth === 0) {
|
|
40
|
+
const candidate = text.slice(i, j + 1);
|
|
41
|
+
try {
|
|
42
|
+
const obj = JSON.parse(candidate);
|
|
43
|
+
if (!requiredKey || (obj && typeof obj === 'object' && requiredKey in obj)) {
|
|
44
|
+
return obj;
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
// Not valid JSON at this position, try next `{`
|
|
48
|
+
}
|
|
49
|
+
break; // This brace pair didn't parse; move to next `{`
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { colors, icons } from '../ui/theme.js';
|
|
2
|
+
|
|
3
|
+
export function info(msg) {
|
|
4
|
+
console.log(icons.info, msg);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function success(msg) {
|
|
8
|
+
console.log(icons.check, msg);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function warn(msg) {
|
|
12
|
+
console.log(icons.warning, msg);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function error(msg) {
|
|
16
|
+
console.error(icons.cross, msg);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function heading(msg) {
|
|
20
|
+
console.log('\n' + colors.bold(colors.underline(msg)));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function debug(msg) {
|
|
24
|
+
if (process.env.CLAUDE_CRAFT_DEBUG) {
|
|
25
|
+
console.log(colors.muted('[debug]'), msg);
|
|
26
|
+
}
|
|
27
|
+
}
|