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,461 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP server setup utilities.
|
|
3
|
+
*
|
|
4
|
+
* Handles prerequisites checking, package verification, API key validation,
|
|
5
|
+
* and health checks to ensure MCP servers are properly configured and working.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { execSync, spawn } from 'child_process';
|
|
9
|
+
|
|
10
|
+
// ── Prerequisites ────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Verify that node and npx are available at required versions.
|
|
14
|
+
* Returns { node, npx, errors[] }.
|
|
15
|
+
*/
|
|
16
|
+
export function checkPrerequisites() {
|
|
17
|
+
const results = { node: false, npx: false, errors: [] };
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const nodeVersion = execSync('node --version', {
|
|
21
|
+
encoding: 'utf8',
|
|
22
|
+
timeout: 5000,
|
|
23
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
24
|
+
}).trim();
|
|
25
|
+
const major = parseInt(nodeVersion.replace('v', '').split('.')[0]);
|
|
26
|
+
results.node = major >= 18;
|
|
27
|
+
results.nodeVersion = nodeVersion;
|
|
28
|
+
if (!results.node) {
|
|
29
|
+
results.errors.push(`Node.js ${nodeVersion} found — v18+ required for MCP servers`);
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
results.errors.push('Node.js not found in PATH — required for MCP servers');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const npxVersion = execSync('npx --version', {
|
|
37
|
+
encoding: 'utf8',
|
|
38
|
+
timeout: 5000,
|
|
39
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
40
|
+
}).trim();
|
|
41
|
+
results.npx = true;
|
|
42
|
+
results.npxVersion = npxVersion;
|
|
43
|
+
} catch {
|
|
44
|
+
results.errors.push('npx not found in PATH — required to run stdio MCP servers');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return results;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── API Key Validation ───────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
const KEY_VALIDATORS = {
|
|
53
|
+
github: {
|
|
54
|
+
keyName: 'GITHUB_PERSONAL_ACCESS_TOKEN',
|
|
55
|
+
check: (v) => /^(ghp_|github_pat_)/.test(v),
|
|
56
|
+
hint: 'GitHub PATs typically start with ghp_ or github_pat_',
|
|
57
|
+
},
|
|
58
|
+
supabase: {
|
|
59
|
+
keyName: 'SUPABASE_ACCESS_TOKEN',
|
|
60
|
+
check: (v) => /^sbp_/.test(v) || v.startsWith('eyJ'),
|
|
61
|
+
hint: 'Supabase tokens typically start with sbp_ or eyJ (JWT)',
|
|
62
|
+
},
|
|
63
|
+
linear: {
|
|
64
|
+
keyName: 'LINEAR_API_KEY',
|
|
65
|
+
check: (v) => /^lin_api_/.test(v),
|
|
66
|
+
hint: 'Linear API keys typically start with lin_api_',
|
|
67
|
+
},
|
|
68
|
+
'brave-search': {
|
|
69
|
+
keyName: 'BRAVE_API_KEY',
|
|
70
|
+
check: (v) => /^BSA/.test(v) || v.length >= 20,
|
|
71
|
+
hint: 'Brave Search API keys typically start with BSA',
|
|
72
|
+
},
|
|
73
|
+
notion: {
|
|
74
|
+
keyName: 'NOTION_TOKEN',
|
|
75
|
+
check: (v) => /^(ntn_|secret_)/.test(v),
|
|
76
|
+
hint: 'Notion tokens typically start with ntn_ or secret_',
|
|
77
|
+
},
|
|
78
|
+
// ── Database validators ────────────────────────────────────────
|
|
79
|
+
postgres: {
|
|
80
|
+
keyName: 'POSTGRES_CONNECTION_STRING',
|
|
81
|
+
check: (v) => /^postgres(ql)?:\/\//.test(v),
|
|
82
|
+
hint: 'PostgreSQL connection strings start with postgres:// or postgresql://',
|
|
83
|
+
},
|
|
84
|
+
mongodb: {
|
|
85
|
+
keyName: 'MONGODB_CONNECTION_STRING',
|
|
86
|
+
check: (v) => /^mongodb(\+srv)?:\/\//.test(v),
|
|
87
|
+
hint: 'MongoDB connection strings start with mongodb:// or mongodb+srv://',
|
|
88
|
+
},
|
|
89
|
+
mssql: {
|
|
90
|
+
keyName: 'MSSQL_CONNECTION_STRING',
|
|
91
|
+
check: (v) => /^(Server|Data Source|mssql:)/i.test(v) || v.includes('Initial Catalog') || v.includes('Database='),
|
|
92
|
+
hint: 'SQL Server connection strings typically contain Server= and Database= (or use mssql:// URL format)',
|
|
93
|
+
},
|
|
94
|
+
mysql: {
|
|
95
|
+
keyName: 'MYSQL_CONNECTION_STRING',
|
|
96
|
+
check: (v) => /^mysql:\/\//.test(v),
|
|
97
|
+
hint: 'MySQL connection strings start with mysql://',
|
|
98
|
+
},
|
|
99
|
+
redis: {
|
|
100
|
+
keyName: 'REDIS_URL',
|
|
101
|
+
check: (v) => /^rediss?:\/\//.test(v),
|
|
102
|
+
hint: 'Redis URLs start with redis:// or rediss://',
|
|
103
|
+
},
|
|
104
|
+
// ── PM tool validators ──────────────────────────────────────────
|
|
105
|
+
'jira-url': {
|
|
106
|
+
keyName: 'JIRA_BASE_URL',
|
|
107
|
+
check: (v) => /^https?:\/\/.+\.atlassian\.net/.test(v),
|
|
108
|
+
hint: 'Jira base URL must be a full HTTPS URL, e.g. https://yourteam.atlassian.net',
|
|
109
|
+
},
|
|
110
|
+
jira: {
|
|
111
|
+
keyName: 'JIRA_PAT',
|
|
112
|
+
check: (v) => v.length >= 20,
|
|
113
|
+
hint: 'Jira PATs are long base64 strings (20+ characters)',
|
|
114
|
+
},
|
|
115
|
+
clickup: {
|
|
116
|
+
keyName: 'CLICKUP_API_KEY',
|
|
117
|
+
check: (v) => /^pk_/.test(v) || v.length >= 20,
|
|
118
|
+
hint: 'ClickUp API keys typically start with pk_',
|
|
119
|
+
},
|
|
120
|
+
asana: {
|
|
121
|
+
keyName: 'ASANA_ACCESS_TOKEN',
|
|
122
|
+
check: (v) => v.startsWith('1/') || v.length >= 20,
|
|
123
|
+
hint: 'Asana PATs typically start with 1/ followed by a long string',
|
|
124
|
+
},
|
|
125
|
+
monday: {
|
|
126
|
+
keyName: 'MONDAY_TOKEN',
|
|
127
|
+
check: (v) => v.startsWith('eyJ') || v.length >= 100,
|
|
128
|
+
hint: 'Monday.com API tokens are JWT-format strings starting with eyJ',
|
|
129
|
+
},
|
|
130
|
+
shortcut: {
|
|
131
|
+
keyName: 'SHORTCUT_API_TOKEN',
|
|
132
|
+
check: (v) => v.length >= 32,
|
|
133
|
+
hint: 'Shortcut API tokens are UUID-format strings (32+ characters)',
|
|
134
|
+
},
|
|
135
|
+
gitlab: {
|
|
136
|
+
keyName: 'GITLAB_PERSONAL_ACCESS_TOKEN',
|
|
137
|
+
check: (v) => /^glpat-/.test(v) || v.length >= 20,
|
|
138
|
+
hint: 'GitLab PATs typically start with glpat-',
|
|
139
|
+
},
|
|
140
|
+
'trello-key': {
|
|
141
|
+
keyName: 'TRELLO_API_KEY',
|
|
142
|
+
check: (v) => /^[0-9a-f]{32}$/.test(v),
|
|
143
|
+
hint: 'Trello API keys are 32-character hex strings',
|
|
144
|
+
},
|
|
145
|
+
trello: {
|
|
146
|
+
keyName: 'TRELLO_TOKEN',
|
|
147
|
+
check: (v) => /^[0-9a-f]{64}$/.test(v) || v.length >= 32,
|
|
148
|
+
hint: 'Trello tokens are typically 64-character hex strings',
|
|
149
|
+
},
|
|
150
|
+
todoist: {
|
|
151
|
+
keyName: 'TODOIST_API_TOKEN',
|
|
152
|
+
check: (v) => /^[0-9a-f]{40}$/.test(v) || v.length >= 20,
|
|
153
|
+
hint: 'Todoist API tokens are 40-character hex strings',
|
|
154
|
+
},
|
|
155
|
+
'youtrack-url': {
|
|
156
|
+
keyName: 'YOUTRACK_URL',
|
|
157
|
+
check: (v) => /^https?:\/\//.test(v),
|
|
158
|
+
hint: 'YouTrack URL must start with https://, e.g. https://yourteam.youtrack.cloud',
|
|
159
|
+
},
|
|
160
|
+
youtrack: {
|
|
161
|
+
keyName: 'YOUTRACK_TOKEN',
|
|
162
|
+
check: (v) => /^perm:/.test(v) || v.length >= 20,
|
|
163
|
+
hint: 'YouTrack permanent tokens typically start with perm:',
|
|
164
|
+
},
|
|
165
|
+
plane: {
|
|
166
|
+
keyName: 'PLANE_API_KEY',
|
|
167
|
+
check: (v) => v.length >= 20,
|
|
168
|
+
hint: 'Plane API keys are long strings (20+ characters)',
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Validate API key format for known MCP providers.
|
|
174
|
+
* Returns { valid: boolean, warning: string|null }.
|
|
175
|
+
* A warning means the format looks unusual but is still accepted.
|
|
176
|
+
*/
|
|
177
|
+
export function validateApiKeyFormat(mcpId, keyName, keyValue) {
|
|
178
|
+
if (!keyValue || !keyValue.trim()) {
|
|
179
|
+
return { valid: false, warning: null };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const value = keyValue.trim();
|
|
183
|
+
// Try mcpId first, then look up by keyName for multi-key MCPs
|
|
184
|
+
let validator = KEY_VALIDATORS[mcpId];
|
|
185
|
+
if (!validator || (validator.keyName !== keyName)) {
|
|
186
|
+
const byKeyName = Object.values(KEY_VALIDATORS).find((v) => v.keyName === keyName);
|
|
187
|
+
if (byKeyName) validator = byKeyName;
|
|
188
|
+
}
|
|
189
|
+
if (!validator) return { valid: true, warning: null };
|
|
190
|
+
|
|
191
|
+
if (!validator.check(value)) {
|
|
192
|
+
return { valid: true, warning: validator.hint };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return { valid: true, warning: null };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── Package Verification ─────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Extract npm package name from an npx command string.
|
|
202
|
+
* e.g. 'npx -y @upstash/context7-mcp@latest' → '@upstash/context7-mcp'
|
|
203
|
+
*/
|
|
204
|
+
export function extractPackageName(command) {
|
|
205
|
+
const parts = command.split(/\s+/);
|
|
206
|
+
for (const part of parts) {
|
|
207
|
+
if (part === 'npx' || part.startsWith('-')) continue;
|
|
208
|
+
// Strip version suffix (@latest, @^1.0.0, etc.)
|
|
209
|
+
// Handle scoped packages: @scope/name@version
|
|
210
|
+
if (part.startsWith('@')) {
|
|
211
|
+
const slashIdx = part.indexOf('/');
|
|
212
|
+
if (slashIdx === -1) continue;
|
|
213
|
+
const afterSlash = part.slice(slashIdx + 1);
|
|
214
|
+
const atIdx = afterSlash.indexOf('@');
|
|
215
|
+
if (atIdx > 0) {
|
|
216
|
+
return part.slice(0, slashIdx + 1 + atIdx);
|
|
217
|
+
}
|
|
218
|
+
return part;
|
|
219
|
+
}
|
|
220
|
+
// Unscoped: name@version → name
|
|
221
|
+
const atIdx = part.indexOf('@');
|
|
222
|
+
if (atIdx > 0) return part.slice(0, atIdx);
|
|
223
|
+
return part;
|
|
224
|
+
}
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Verify an MCP package exists in the npm registry.
|
|
230
|
+
* Returns { success, version?, packageName?, error? }.
|
|
231
|
+
*/
|
|
232
|
+
export async function verifyMcpPackage(mcp) {
|
|
233
|
+
if (mcp.transport === 'url') {
|
|
234
|
+
return { success: true, type: 'url', url: mcp.url };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const packageName = extractPackageName(mcp.command);
|
|
238
|
+
if (!packageName) {
|
|
239
|
+
return { success: false, error: 'Could not parse package name from command' };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
const version = execSync(`npm view "${packageName}" version`, {
|
|
244
|
+
encoding: 'utf8',
|
|
245
|
+
timeout: 15000,
|
|
246
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
247
|
+
}).trim();
|
|
248
|
+
return { success: true, version, packageName };
|
|
249
|
+
} catch {
|
|
250
|
+
return {
|
|
251
|
+
success: false,
|
|
252
|
+
error: `Package "${packageName}" not found in npm registry`,
|
|
253
|
+
packageName,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── Health Checks ────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Health check a URL-based MCP endpoint.
|
|
262
|
+
* Returns { success, error? }.
|
|
263
|
+
*/
|
|
264
|
+
async function healthCheckUrl(url) {
|
|
265
|
+
try {
|
|
266
|
+
const controller = new AbortController();
|
|
267
|
+
const timeout = setTimeout(() => controller.abort(), 8000);
|
|
268
|
+
const response = await fetch(url, {
|
|
269
|
+
method: 'GET',
|
|
270
|
+
signal: controller.signal,
|
|
271
|
+
});
|
|
272
|
+
clearTimeout(timeout);
|
|
273
|
+
// MCP SSE endpoints may return various codes; anything < 500 is reachable
|
|
274
|
+
return { success: response.status < 500 };
|
|
275
|
+
} catch (err) {
|
|
276
|
+
return { success: false, error: `URL unreachable: ${err.message}` };
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Resolve placeholder variables in MCP args.
|
|
282
|
+
* e.g. '${PROJECT_DIR}' → '/actual/path'
|
|
283
|
+
*/
|
|
284
|
+
function resolveArgs(args, targetDir) {
|
|
285
|
+
if (!args || args.length === 0) return [];
|
|
286
|
+
return args.map((arg) => arg.replace(/\$\{PROJECT_DIR\}/g, targetDir));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Extract a meaningful error message from stderr output.
|
|
291
|
+
* Node.js stack traces start with file paths — find the actual Error: line instead.
|
|
292
|
+
*/
|
|
293
|
+
function extractStderrMessage(stderr, exitCode) {
|
|
294
|
+
if (!stderr.trim()) return `Exited with code ${exitCode}`;
|
|
295
|
+
const lines = stderr.trim().split('\n');
|
|
296
|
+
// Look for an "Error:" line (the most informative part of a Node stack trace)
|
|
297
|
+
const errorLine = lines.find((l) => /^\w*Error:/.test(l.trim()));
|
|
298
|
+
if (errorLine) return errorLine.trim();
|
|
299
|
+
// Fallback: last non-empty line is often more useful than first
|
|
300
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
301
|
+
const l = lines[i].trim();
|
|
302
|
+
if (l && !l.startsWith('at ') && !l.startsWith('^')) return l;
|
|
303
|
+
}
|
|
304
|
+
return lines[0] || `Exited with code ${exitCode}`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Health check a stdio MCP server by spawning it briefly.
|
|
309
|
+
* The server is started and if it hasn't crashed after ~3s, it's considered working.
|
|
310
|
+
* Returns { success, error? }.
|
|
311
|
+
*/
|
|
312
|
+
async function healthCheckStdio(mcp, env = {}, targetDir = process.cwd()) {
|
|
313
|
+
return new Promise((resolve) => {
|
|
314
|
+
const resolvedArgs = resolveArgs(mcp.args, targetDir);
|
|
315
|
+
const fullCommand = [mcp.command, ...resolvedArgs].join(' ');
|
|
316
|
+
|
|
317
|
+
let settled = false;
|
|
318
|
+
const settle = (result) => {
|
|
319
|
+
if (!settled) {
|
|
320
|
+
settled = true;
|
|
321
|
+
resolve(result);
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
let child;
|
|
326
|
+
try {
|
|
327
|
+
child = spawn(fullCommand, [], {
|
|
328
|
+
env: { ...process.env, ...env },
|
|
329
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
330
|
+
shell: true,
|
|
331
|
+
windowsHide: true,
|
|
332
|
+
});
|
|
333
|
+
} catch (err) {
|
|
334
|
+
settle({ success: false, error: `Failed to spawn: ${err.message}` });
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
let stderr = '';
|
|
339
|
+
child.stderr.on('data', (data) => {
|
|
340
|
+
stderr += data.toString();
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
child.on('error', (err) => {
|
|
344
|
+
settle({ success: false, error: err.message });
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
child.on('exit', (code) => {
|
|
348
|
+
if (code !== 0 && code !== null) {
|
|
349
|
+
settle({ success: false, error: extractStderrMessage(stderr, code) });
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// If still running after 3s, it's working — kill it
|
|
354
|
+
setTimeout(() => {
|
|
355
|
+
try {
|
|
356
|
+
child.kill();
|
|
357
|
+
} catch {
|
|
358
|
+
// ignore kill errors
|
|
359
|
+
}
|
|
360
|
+
settle({ success: true });
|
|
361
|
+
}, 3000);
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Run a health check on a single MCP server.
|
|
367
|
+
* Returns { success, error? }.
|
|
368
|
+
*/
|
|
369
|
+
export async function healthCheckMcp(mcp, env = {}, targetDir = process.cwd()) {
|
|
370
|
+
if (mcp.transport === 'url') {
|
|
371
|
+
return healthCheckUrl(mcp.url);
|
|
372
|
+
}
|
|
373
|
+
return healthCheckStdio(mcp, env, targetDir);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ── Full Setup Pipeline ──────────────────────────────────────────────
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Run the full MCP setup pipeline for selected servers.
|
|
380
|
+
*
|
|
381
|
+
* Steps per MCP:
|
|
382
|
+
* 1. Verify package exists in npm registry
|
|
383
|
+
* 2. Check API key status
|
|
384
|
+
* 3. Health check (if package verified and key available)
|
|
385
|
+
*
|
|
386
|
+
* @param {Array} selectedMcps - MCP catalog entries
|
|
387
|
+
* @param {Object} mcpKeys - { [mcpId]: { KEY_NAME: 'value' } }
|
|
388
|
+
* @param {Object} opts
|
|
389
|
+
* @param {Function} opts.onStatus - callback(mcpId, status, detail)
|
|
390
|
+
* @param {boolean} opts.healthCheck - whether to run health checks (default: true)
|
|
391
|
+
* @param {string} opts.targetDir - project directory for resolving ${PROJECT_DIR} in args
|
|
392
|
+
* @returns {Array} Per-MCP results
|
|
393
|
+
*/
|
|
394
|
+
export async function setupMcps(selectedMcps, mcpKeys = {}, opts = {}) {
|
|
395
|
+
const { onStatus, healthCheck = true, targetDir = process.cwd() } = opts;
|
|
396
|
+
const results = [];
|
|
397
|
+
|
|
398
|
+
// Run package verification in parallel for speed
|
|
399
|
+
const verifyPromises = selectedMcps.map(async (mcp) => {
|
|
400
|
+
if (onStatus) onStatus(mcp.id, 'verifying');
|
|
401
|
+
return { mcp, pkgResult: await verifyMcpPackage(mcp) };
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
const verified = await Promise.all(verifyPromises);
|
|
405
|
+
|
|
406
|
+
// Run health checks sequentially (spawning many processes at once is risky)
|
|
407
|
+
for (const { mcp, pkgResult } of verified) {
|
|
408
|
+
const result = {
|
|
409
|
+
id: mcp.id,
|
|
410
|
+
description: mcp.description,
|
|
411
|
+
package: pkgResult,
|
|
412
|
+
apiKey: null,
|
|
413
|
+
healthCheck: null,
|
|
414
|
+
status: 'unknown',
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
// API key status
|
|
418
|
+
if (mcp.requiresKey) {
|
|
419
|
+
const providedKey = mcpKeys[mcp.id] && Object.values(mcpKeys[mcp.id])[0];
|
|
420
|
+
result.apiKey = {
|
|
421
|
+
required: true,
|
|
422
|
+
provided: !!providedKey,
|
|
423
|
+
keyName: mcp.keyName,
|
|
424
|
+
};
|
|
425
|
+
} else {
|
|
426
|
+
result.apiKey = { required: false, provided: true };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Health check (only if package OK and key available)
|
|
430
|
+
if (healthCheck && pkgResult.success && result.apiKey.provided) {
|
|
431
|
+
if (onStatus) onStatus(mcp.id, 'testing');
|
|
432
|
+
const env = mcpKeys[mcp.id] || {};
|
|
433
|
+
result.healthCheck = await healthCheckMcp(mcp, env, targetDir);
|
|
434
|
+
} else if (!healthCheck) {
|
|
435
|
+
result.healthCheck = { success: null, skipped: true };
|
|
436
|
+
} else {
|
|
437
|
+
result.healthCheck = { success: false, skipped: true };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Determine overall status
|
|
441
|
+
if (!pkgResult.success) {
|
|
442
|
+
result.status = 'package-error';
|
|
443
|
+
} else if (mcp.requiresKey && !result.apiKey.provided) {
|
|
444
|
+
result.status = 'needs-key';
|
|
445
|
+
} else if (result.healthCheck.success === true) {
|
|
446
|
+
result.status = 'ready';
|
|
447
|
+
} else if (result.healthCheck.skipped) {
|
|
448
|
+
result.status = pkgResult.success ? 'verified' : 'package-error';
|
|
449
|
+
} else {
|
|
450
|
+
// Health check failed but package is verified — degrade gracefully.
|
|
451
|
+
// npx spawn during install is unreliable (missing deps, sandbox issues);
|
|
452
|
+
// Claude Code's own MCP runtime handles this correctly.
|
|
453
|
+
result.status = 'verified-with-warning';
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
results.push(result);
|
|
457
|
+
if (onStatus) onStatus(mcp.id, result.status);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return results;
|
|
461
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared pre-flight checks for all claude-craft commands.
|
|
3
|
+
*
|
|
4
|
+
* Verifies:
|
|
5
|
+
* 1. Claude Code is installed and authorized
|
|
6
|
+
* 2. Target directory is a claude-craft project (optional)
|
|
7
|
+
* 3. API key exists in config
|
|
8
|
+
* 4. API key is valid against the server (also proves the server is reachable)
|
|
9
|
+
*
|
|
10
|
+
* Exits the process with code 1 on failure.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync } from 'fs';
|
|
13
|
+
import { join } from 'path';
|
|
14
|
+
import chalk from 'chalk';
|
|
15
|
+
import { loadConfig, validateKey, ApiError } from './api-client.js';
|
|
16
|
+
import { getClaudeAuthStatus } from './run-claude.js';
|
|
17
|
+
import { promptForApiKey } from './prompt-api-key.js';
|
|
18
|
+
import { dotPad } from '../ui/format.js';
|
|
19
|
+
import { colors } from '../ui/theme.js';
|
|
20
|
+
import * as logger from './logger.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Run all pre-flight checks and display results.
|
|
24
|
+
*
|
|
25
|
+
* @param {object} [options]
|
|
26
|
+
* @param {boolean} [options.interactive=true] - Allow prompting for missing API key
|
|
27
|
+
* @param {boolean} [options.requireClaude=true] - Require Claude Code (skip for headless)
|
|
28
|
+
* @param {boolean} [options.requireCraftProject=false] - Require .claude/.claude-craft.json marker
|
|
29
|
+
* @param {string} [options.targetDir] - Project directory (needed for requireCraftProject)
|
|
30
|
+
* @returns {Promise<{ apiConfig: object }>} - Validated config for downstream use
|
|
31
|
+
*/
|
|
32
|
+
export async function runPreflight(options = {}) {
|
|
33
|
+
const { interactive = true, requireClaude = true, requireCraftProject = false, targetDir } = options;
|
|
34
|
+
|
|
35
|
+
console.log(chalk.bold(' Environment'));
|
|
36
|
+
|
|
37
|
+
const envErrors = [];
|
|
38
|
+
|
|
39
|
+
// ── 1. Claude Code ──────────────────────────────────────────────────
|
|
40
|
+
if (requireClaude) {
|
|
41
|
+
const claudeStatus = getClaudeAuthStatus();
|
|
42
|
+
if (claudeStatus.installed && claudeStatus.authorized) {
|
|
43
|
+
const suffix = claudeStatus.detail?.email ? chalk.dim(` (${claudeStatus.detail.email})`) : '';
|
|
44
|
+
console.log(' ' + dotPad('Claude Code', colors.success('ok') + suffix));
|
|
45
|
+
} else if (claudeStatus.installed && !claudeStatus.authorized) {
|
|
46
|
+
console.log(' ' + dotPad('Claude Code', colors.error('not authorized')));
|
|
47
|
+
envErrors.push('Claude Code is installed but not authorized. Run: claude auth login');
|
|
48
|
+
} else {
|
|
49
|
+
console.log(' ' + dotPad('Claude Code', colors.error('not found')));
|
|
50
|
+
envErrors.push('Claude Code is required. Install from: https://claude.ai/download');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── 2. claude-craft project marker ─────────────────────────────────
|
|
55
|
+
if (requireCraftProject) {
|
|
56
|
+
const markerPath = join(targetDir || process.cwd(), '.claude', '.claude-craft.json');
|
|
57
|
+
if (existsSync(markerPath)) {
|
|
58
|
+
console.log(' ' + dotPad('claude-craft project', colors.success('ok')));
|
|
59
|
+
} else {
|
|
60
|
+
console.log(' ' + dotPad('claude-craft project', colors.error('not found')));
|
|
61
|
+
envErrors.push('No claude-craft project found. Run: claude-craft install first.');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── 3. API key existence ────────────────────────────────────────────
|
|
66
|
+
let apiConfig = loadConfig();
|
|
67
|
+
|
|
68
|
+
if (!apiConfig?.apiKey) {
|
|
69
|
+
if (interactive) {
|
|
70
|
+
apiConfig = await promptForApiKey();
|
|
71
|
+
} else {
|
|
72
|
+
console.log(' ' + dotPad('API key', colors.error('missing')));
|
|
73
|
+
envErrors.push('No API key configured. Run: claude-craft auth <key>');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── 4. API key validation (also proves server is reachable) ─────────
|
|
78
|
+
if (apiConfig?.apiKey) {
|
|
79
|
+
const serverUrl = apiConfig.serverUrl || process.env.CLAUDE_CRAFT_SERVER_URL || 'https://api.claude-craft.dev';
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const valid = await validateKey(apiConfig.apiKey, serverUrl);
|
|
83
|
+
if (valid) {
|
|
84
|
+
console.log(' ' + dotPad('API key', colors.success('valid')));
|
|
85
|
+
} else {
|
|
86
|
+
console.log(' ' + dotPad('API key', colors.error('invalid')));
|
|
87
|
+
envErrors.push('API key is invalid or expired. Run: claude-craft auth <new-key>');
|
|
88
|
+
}
|
|
89
|
+
} catch (err) {
|
|
90
|
+
if (err instanceof ApiError && err.code === 'NETWORK_ERROR') {
|
|
91
|
+
console.log(' ' + dotPad('API key', colors.error('server unreachable')));
|
|
92
|
+
envErrors.push(`Could not reach claude-craft server at ${serverUrl}. Check your connection.`);
|
|
93
|
+
} else {
|
|
94
|
+
console.log(' ' + dotPad('API key', colors.error('check failed')));
|
|
95
|
+
envErrors.push(`API key validation failed: ${err.message}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Fail fast if any checks failed ──────────────────────────────────
|
|
101
|
+
if (envErrors.length > 0) {
|
|
102
|
+
console.log();
|
|
103
|
+
for (const msg of envErrors) {
|
|
104
|
+
logger.error(msg);
|
|
105
|
+
}
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
console.log();
|
|
110
|
+
|
|
111
|
+
return { apiConfig };
|
|
112
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import { validateKey, ApiError, loadConfig, saveConfig } from './api-client.js';
|
|
4
|
+
import { themedPassword } from '../ui/prompts.js';
|
|
5
|
+
import * as logger from './logger.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Prompt user for API key inline when none is configured.
|
|
9
|
+
* Validates format + server, saves to ~/.claude-craft/config.json.
|
|
10
|
+
*/
|
|
11
|
+
export async function promptForApiKey() {
|
|
12
|
+
logger.warn('No API key found.');
|
|
13
|
+
console.log(chalk.dim(' You need a claude-craft API key to continue.'));
|
|
14
|
+
console.log(chalk.dim(' Get one at: ' + chalk.underline('https://claude-craft.dev/keys')));
|
|
15
|
+
console.log();
|
|
16
|
+
|
|
17
|
+
const key = await themedPassword({
|
|
18
|
+
message: 'API key:',
|
|
19
|
+
hint: 'Paste your ck_live_... key. It will be saved to ~/.claude-craft/config.json.',
|
|
20
|
+
mask: '*',
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
if (!key || !key.trim()) {
|
|
24
|
+
logger.error('No key provided. Run: ' + chalk.bold('claude-craft auth <key>'));
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const trimmed = key.trim();
|
|
29
|
+
|
|
30
|
+
if (!trimmed.startsWith('ck_live_')) {
|
|
31
|
+
logger.error('Invalid key format. API keys must start with ' + chalk.bold('ck_live_'));
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const spinner = ora('Validating API key...').start();
|
|
36
|
+
try {
|
|
37
|
+
const valid = await validateKey(trimmed);
|
|
38
|
+
if (!valid) {
|
|
39
|
+
spinner.fail('API key is not valid.');
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
spinner.succeed('API key validated.');
|
|
43
|
+
} catch (err) {
|
|
44
|
+
if (err instanceof ApiError && err.code === 'NETWORK_ERROR') {
|
|
45
|
+
spinner.fail('Could not reach server. Ensure the server is running and try again.');
|
|
46
|
+
} else {
|
|
47
|
+
spinner.fail('Validation failed: ' + err.message);
|
|
48
|
+
}
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const existing = loadConfig() || {};
|
|
53
|
+
const config = { ...existing, apiKey: trimmed };
|
|
54
|
+
saveConfig(config);
|
|
55
|
+
logger.success('API key saved to ~/.claude-craft/config.json');
|
|
56
|
+
console.log();
|
|
57
|
+
|
|
58
|
+
return config;
|
|
59
|
+
}
|