@aikdna/kdna-cli 0.9.0 → 0.12.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/README.md +109 -31
- package/SECURITY.md +41 -0
- package/package.json +3 -2
- package/src/agent.js +430 -2
- package/src/cli.js +280 -38
- package/src/cmds/_common.js +68 -3
- package/src/cmds/badge.js +244 -0
- package/src/cmds/changelog.js +226 -0
- package/src/cmds/cluster.js +214 -3
- package/src/cmds/doctor.js +233 -0
- package/src/cmds/domain.js +110 -33
- package/src/cmds/governance.js +471 -0
- package/src/cmds/identity.js +5 -4
- package/src/cmds/quality.js +34 -9
- package/src/cmds/registry.js +62 -22
- package/src/cmds/studio.js +577 -0
- package/src/cmds/test.js +177 -0
- package/src/cmds/trace.js +225 -0
- package/src/compare.js +46 -32
- package/src/diff.js +136 -38
- package/src/identity.js +20 -4
- package/src/install.js +181 -91
- package/src/publish.js +39 -3
- package/src/search.js +33 -2
- package/src/verify.js +98 -24
- package/src/version.js +110 -3
package/src/cmds/cluster.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
|
-
const { error, readJson } = require('./_common');
|
|
3
|
+
const { error, readJson, EXIT } = require('./_common');
|
|
4
4
|
const {
|
|
5
5
|
loadCluster,
|
|
6
6
|
classifySignalsAcrossDomains,
|
|
@@ -29,6 +29,15 @@ function cmdCluster(args) {
|
|
|
29
29
|
} else if (sub === 'match') {
|
|
30
30
|
if (!target) error('Usage: kdna cluster match <cluster.json> --input "<task>"');
|
|
31
31
|
cmdClusterMatch(target, args);
|
|
32
|
+
} else if (sub === 'compose') {
|
|
33
|
+
if (!target) error('Usage: kdna cluster compose <cluster.json> --input "<task>" [--profile=compact] [--json]');
|
|
34
|
+
cmdClusterCompose(target, args);
|
|
35
|
+
} else if (sub === 'conflicts') {
|
|
36
|
+
if (!target) error('Usage: kdna cluster conflicts <cluster.json> --input "<task>" [--json]');
|
|
37
|
+
cmdClusterConflicts(target, args);
|
|
38
|
+
} else if (sub === 'graph') {
|
|
39
|
+
if (!target) error('Usage: kdna cluster graph <cluster.json> [--format=dot|json]');
|
|
40
|
+
cmdClusterGraph(target, args);
|
|
32
41
|
} else if (sub === 'apply') {
|
|
33
42
|
error(
|
|
34
43
|
'kdna cluster apply was removed in v0.9.\n' +
|
|
@@ -42,12 +51,15 @@ function cmdCluster(args) {
|
|
|
42
51
|
' kdna cluster init <name>\n' +
|
|
43
52
|
' kdna cluster info <cluster.json>\n' +
|
|
44
53
|
' kdna cluster match <cluster.json> --input "<task>"\n' +
|
|
45
|
-
' kdna cluster load <cluster.json> --input "<task>"'
|
|
54
|
+
' kdna cluster load <cluster.json> --input "<task>"\n' +
|
|
55
|
+
' kdna cluster compose <cluster.json> --input "<task>"\n' +
|
|
56
|
+
' kdna cluster conflicts <cluster.json> --input "<task>"\n' +
|
|
57
|
+
' kdna cluster graph <cluster.json>',
|
|
46
58
|
);
|
|
47
59
|
}
|
|
48
60
|
}
|
|
49
61
|
|
|
50
|
-
function cmdClusterInfo(target,
|
|
62
|
+
function cmdClusterInfo(target, _format = 'human') {
|
|
51
63
|
const abs = path.resolve(target);
|
|
52
64
|
if (!fs.existsSync(abs)) error(`Cluster manifest not found: ${abs}`);
|
|
53
65
|
|
|
@@ -232,4 +244,203 @@ function cmdClusterMatch(target, args = []) {
|
|
|
232
244
|
});
|
|
233
245
|
}
|
|
234
246
|
|
|
247
|
+
/**
|
|
248
|
+
* Compose: classify input signals, then compose context with source attribution.
|
|
249
|
+
* Unlike load (which includes trace), compose focuses on the composed context.
|
|
250
|
+
*/
|
|
251
|
+
function cmdClusterCompose(target, args = []) {
|
|
252
|
+
const jsonMode = args.includes('--json');
|
|
253
|
+
const abs = path.resolve(target);
|
|
254
|
+
if (!fs.existsSync(abs)) error(`Cluster manifest not found: ${abs}`);
|
|
255
|
+
|
|
256
|
+
const profileIdx = args.indexOf('--profile');
|
|
257
|
+
let profile = 'compact';
|
|
258
|
+
if (profileIdx >= 0) {
|
|
259
|
+
const val = args[profileIdx + 1];
|
|
260
|
+
if (val && !val.startsWith('--')) profile = val;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const inputIdx = args.indexOf('--input');
|
|
264
|
+
const input = inputIdx >= 0 ? args[inputIdx + 1] : '';
|
|
265
|
+
if (!input) error('Usage: kdna cluster compose <cluster.json> --input "<task>"');
|
|
266
|
+
|
|
267
|
+
const manifest = readJson(abs);
|
|
268
|
+
if (!manifest || !manifest.cluster_id) error('Not a valid cluster manifest');
|
|
269
|
+
|
|
270
|
+
const domains = loadClusterDomains(manifest);
|
|
271
|
+
|
|
272
|
+
const classification = classifySignalsAcrossDomains(input, domains);
|
|
273
|
+
const { context } = composeContextWithAttribution(classification.selected);
|
|
274
|
+
|
|
275
|
+
if (jsonMode) {
|
|
276
|
+
console.log(JSON.stringify({
|
|
277
|
+
cluster: manifest.cluster_id,
|
|
278
|
+
input: input.slice(0, 200),
|
|
279
|
+
selected: classification.selected.map((d) => ({
|
|
280
|
+
id: d.id,
|
|
281
|
+
role: d.role,
|
|
282
|
+
reason: d.reason,
|
|
283
|
+
})),
|
|
284
|
+
excluded: classification.excluded.map((d) => ({
|
|
285
|
+
id: d.id,
|
|
286
|
+
reason: d.reason,
|
|
287
|
+
})),
|
|
288
|
+
context,
|
|
289
|
+
}, null, 2));
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
console.log(`Cluster: ${manifest.cluster_id}`);
|
|
294
|
+
console.log(`Profile: ${profile}`);
|
|
295
|
+
console.log(`Input: ${input.slice(0, 100)}${input.length > 100 ? '...' : ''}`);
|
|
296
|
+
console.log('');
|
|
297
|
+
console.log(`Selected: ${classification.selected.length} | Excluded: ${classification.excluded.length}`);
|
|
298
|
+
console.log('');
|
|
299
|
+
console.log('─'.repeat(64));
|
|
300
|
+
console.log(context);
|
|
301
|
+
console.log('─'.repeat(64));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Conflicts: detect conflicts between selected domains for given input.
|
|
306
|
+
*/
|
|
307
|
+
function cmdClusterConflicts(target, args = []) {
|
|
308
|
+
const jsonMode = args.includes('--json');
|
|
309
|
+
const abs = path.resolve(target);
|
|
310
|
+
if (!fs.existsSync(abs)) error(`Cluster manifest not found: ${abs}`);
|
|
311
|
+
|
|
312
|
+
const inputIdx = args.indexOf('--input');
|
|
313
|
+
const input = inputIdx >= 0 ? args[inputIdx + 1] : '';
|
|
314
|
+
if (!input) error('Usage: kdna cluster conflicts <cluster.json> --input "<task>"');
|
|
315
|
+
|
|
316
|
+
const manifest = readJson(abs);
|
|
317
|
+
if (!manifest || !manifest.cluster_id) error('Not a valid cluster manifest');
|
|
318
|
+
|
|
319
|
+
const domains = loadClusterDomains(manifest);
|
|
320
|
+
const classification = classifySignalsAcrossDomains(input, domains);
|
|
321
|
+
const conflicts = detectDomainConflicts(classification.selected);
|
|
322
|
+
|
|
323
|
+
if (jsonMode) {
|
|
324
|
+
console.log(JSON.stringify({
|
|
325
|
+
cluster: manifest.cluster_id,
|
|
326
|
+
input: input.slice(0, 200),
|
|
327
|
+
selected: classification.selected.map((d) => ({ id: d.id, role: d.role })),
|
|
328
|
+
conflicts: conflicts.map((c) => ({
|
|
329
|
+
type: c.type,
|
|
330
|
+
domains: c.domains,
|
|
331
|
+
description: c.description,
|
|
332
|
+
severity: c.severity || 'warn',
|
|
333
|
+
})),
|
|
334
|
+
conflict_count: conflicts.length,
|
|
335
|
+
safe: conflicts.length === 0,
|
|
336
|
+
}, null, 2));
|
|
337
|
+
process.exit(conflicts.length ? EXIT.HUMAN_LOCK_REQUIRED : EXIT.OK);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
console.log(`Cluster: ${manifest.cluster_id}`);
|
|
341
|
+
console.log(`Input: ${input.slice(0, 100)}${input.length > 100 ? '...' : ''}`);
|
|
342
|
+
console.log(`Selected: ${classification.selected.length} domains | Conflicts: ${conflicts.length}`);
|
|
343
|
+
console.log('');
|
|
344
|
+
|
|
345
|
+
if (!conflicts.length) {
|
|
346
|
+
console.log('✓ No conflicts detected.');
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
for (const c of conflicts) {
|
|
351
|
+
const severity = c.severity === 'error' ? '✗' : '⚠';
|
|
352
|
+
console.log(`${severity} [${c.type}] ${c.domains.join(' vs ')}`);
|
|
353
|
+
console.log(` ${c.description}`);
|
|
354
|
+
console.log('');
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Graph: output the domain relationship graph from a cluster manifest.
|
|
360
|
+
*/
|
|
361
|
+
function cmdClusterGraph(target, args = []) {
|
|
362
|
+
const abs = path.resolve(target);
|
|
363
|
+
if (!fs.existsSync(abs)) error(`Cluster manifest not found: ${abs}`);
|
|
364
|
+
|
|
365
|
+
const manifest = readJson(abs);
|
|
366
|
+
if (!manifest || !manifest.cluster_id) error('Not a valid cluster manifest');
|
|
367
|
+
|
|
368
|
+
const formatIdx = args.indexOf('--format');
|
|
369
|
+
let format = 'dot';
|
|
370
|
+
if (formatIdx >= 0) {
|
|
371
|
+
const val = args[formatIdx + 1];
|
|
372
|
+
if (val && ['dot', 'json'].includes(val)) format = val;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (format === 'json') {
|
|
376
|
+
const graph = {
|
|
377
|
+
cluster: manifest.cluster_id,
|
|
378
|
+
version: manifest.version || '?',
|
|
379
|
+
type: manifest.type || 'horizontal',
|
|
380
|
+
nodes: (manifest.domains || []).map((d) => ({
|
|
381
|
+
id: d.id || d.role,
|
|
382
|
+
role: d.role,
|
|
383
|
+
required: d.required !== false,
|
|
384
|
+
})),
|
|
385
|
+
edges: (manifest.relationships || []).map((r) => ({
|
|
386
|
+
from: r.from,
|
|
387
|
+
to: r.to,
|
|
388
|
+
type: r.type,
|
|
389
|
+
})),
|
|
390
|
+
};
|
|
391
|
+
console.log(JSON.stringify(graph, null, 2));
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// DOT format output
|
|
396
|
+
console.log(`digraph "${manifest.cluster_id}" {`);
|
|
397
|
+
console.log(' rankdir=LR;');
|
|
398
|
+
console.log(` label="${manifest.cluster_id} v${manifest.version || '?'}";`);
|
|
399
|
+
console.log(' fontsize=14;');
|
|
400
|
+
console.log('');
|
|
401
|
+
|
|
402
|
+
// Nodes
|
|
403
|
+
for (const d of manifest.domains || []) {
|
|
404
|
+
const shape = d.role === 'primary' ? 'box' : d.role === 'critic' ? 'diamond' : 'ellipse';
|
|
405
|
+
const required = d.required !== false ? ',style=filled,fillcolor="#e8f0fe"' : ',style=dashed';
|
|
406
|
+
console.log(` "${d.id || d.role}" [label="${d.id || d.role}\\n[${d.role}]",shape=${shape}${required}];`);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Edges
|
|
410
|
+
if (manifest.relationships) {
|
|
411
|
+
console.log('');
|
|
412
|
+
for (const r of manifest.relationships) {
|
|
413
|
+
const style = r.type === 'conflicts' ? ',style=dashed,color=red' :
|
|
414
|
+
r.type === 'extends' ? ',style=bold' : '';
|
|
415
|
+
console.log(` "${r.from}" -> "${r.to}" [label="${r.type}"${style}];`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
console.log('}');
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Shared domain loader for cluster commands.
|
|
424
|
+
*/
|
|
425
|
+
function loadClusterDomains(manifest) {
|
|
426
|
+
const INSTALL_DIR = path.join(
|
|
427
|
+
process.env.HOME || process.env.USERPROFILE || '.',
|
|
428
|
+
'.kdna',
|
|
429
|
+
'domains',
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
return (manifest.domains || []).map((d) => {
|
|
433
|
+
const domainId = d.id;
|
|
434
|
+
if (!domainId) return null;
|
|
435
|
+
const [scope, ident] = domainId.startsWith('@')
|
|
436
|
+
? [domainId.slice(0, domainId.indexOf('/')), domainId.slice(domainId.indexOf('/') + 1)]
|
|
437
|
+
: ['@aikdna', domainId];
|
|
438
|
+
const dir = path.join(INSTALL_DIR, scope, ident);
|
|
439
|
+
const core = readJson(path.join(dir, 'KDNA_Core.json'));
|
|
440
|
+
const pat = readJson(path.join(dir, 'KDNA_Patterns.json'));
|
|
441
|
+
if (!core || !pat) return null;
|
|
442
|
+
return { id: domainId, role: d.role, required: d.required !== false, core, patterns: pat };
|
|
443
|
+
}).filter(Boolean);
|
|
444
|
+
}
|
|
445
|
+
|
|
235
446
|
module.exports = { cmdCluster };
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { EXIT, error } = require('./_common');
|
|
4
|
+
const USER_KDNA_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '.', '.kdna');
|
|
5
|
+
const INSTALL_DIR = path.join(USER_KDNA_DIR, 'domains');
|
|
6
|
+
|
|
7
|
+
const AGENTS = [
|
|
8
|
+
{ name: 'OpenCode', dir: path.join(process.env.HOME || '', '.agents'), skillsDir: 'skills' },
|
|
9
|
+
{ name: 'Codex', dir: path.join(process.env.HOME || '', '.codex'), skillsDir: 'skills' },
|
|
10
|
+
{ name: 'Claude Code', dir: path.join(process.env.HOME || '', '.claude'), skillsDir: 'skills' },
|
|
11
|
+
{ name: 'Cursor', dir: path.join(process.env.HOME || '', '.cursor'), skillsDir: 'skills' },
|
|
12
|
+
{ name: 'Gemini Antigravity', dir: path.join(process.env.HOME || '', '.gemini', 'antigravity'), skillsDir: 'skills' },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const V2_1_MARKER = 'kdna available';
|
|
16
|
+
|
|
17
|
+
function detectAgents() {
|
|
18
|
+
return AGENTS.filter((a) => fs.existsSync(a.dir));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function checkAgentSkill(agent) {
|
|
22
|
+
const skillPath = path.join(agent.dir, agent.skillsDir, 'kdna-loader', 'SKILL.md');
|
|
23
|
+
if (!fs.existsSync(skillPath)) return { installed: false, version: null, path: skillPath };
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const content = fs.readFileSync(skillPath, 'utf8');
|
|
27
|
+
const isV2 = content.includes(V2_1_MARKER);
|
|
28
|
+
return {
|
|
29
|
+
installed: true,
|
|
30
|
+
version: isV2 ? 'v2026.05' : 'outdated',
|
|
31
|
+
path: skillPath,
|
|
32
|
+
};
|
|
33
|
+
} catch {
|
|
34
|
+
return { installed: false, version: null, path: skillPath };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function cmdDoctor(args) {
|
|
39
|
+
const json = args.includes('--json');
|
|
40
|
+
const quiet = args.includes('--quiet');
|
|
41
|
+
const agentsOnly = args.includes('--agents');
|
|
42
|
+
const domainsOnly = args.includes('--domains');
|
|
43
|
+
|
|
44
|
+
const checks = [];
|
|
45
|
+
|
|
46
|
+
if (!agentsOnly) {
|
|
47
|
+
// 1. Node.js version
|
|
48
|
+
const nodeVersion = process.version;
|
|
49
|
+
const major = parseInt(nodeVersion.slice(1).split('.')[0], 10);
|
|
50
|
+
checks.push({
|
|
51
|
+
name: 'Node.js',
|
|
52
|
+
status: major >= 18 ? 'ok' : 'fail',
|
|
53
|
+
detail: `${nodeVersion} (${major >= 18 ? '>=18 required' : 'requires >=18'})`,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// 2. @aikdna/kdna-core available
|
|
57
|
+
let coreVersion = null;
|
|
58
|
+
try {
|
|
59
|
+
const corePkg = require.resolve('@aikdna/kdna-core/package.json');
|
|
60
|
+
coreVersion = JSON.parse(fs.readFileSync(corePkg, 'utf8')).version;
|
|
61
|
+
checks.push({ name: '@aikdna/kdna-core', status: 'ok', detail: `v${coreVersion}` });
|
|
62
|
+
} catch {
|
|
63
|
+
checks.push({ name: '@aikdna/kdna-core', status: 'fail', detail: 'not installed' });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 3. ~/.kdna/ exists
|
|
67
|
+
if (fs.existsSync(USER_KDNA_DIR)) {
|
|
68
|
+
checks.push({ name: 'KDNA data directory', status: 'ok', detail: USER_KDNA_DIR });
|
|
69
|
+
} else {
|
|
70
|
+
checks.push({ name: 'KDNA data directory', status: 'warn', detail: '~/.kdna/ not found' });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 4. ~/.kdna/domains/ exists and has domains
|
|
74
|
+
if (fs.existsSync(INSTALL_DIR)) {
|
|
75
|
+
const domains = fs
|
|
76
|
+
.readdirSync(INSTALL_DIR, { withFileTypes: true })
|
|
77
|
+
.filter((d) => d.isDirectory())
|
|
78
|
+
.reduce((acc, scopeDir) => {
|
|
79
|
+
if (scopeDir.name.startsWith('@')) {
|
|
80
|
+
try {
|
|
81
|
+
return acc + fs.readdirSync(path.join(INSTALL_DIR, scopeDir.name)).length;
|
|
82
|
+
} catch {
|
|
83
|
+
return acc;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return acc + 1;
|
|
87
|
+
}, 0);
|
|
88
|
+
checks.push({
|
|
89
|
+
name: 'Installed domains',
|
|
90
|
+
status: domains > 0 ? 'ok' : 'warn',
|
|
91
|
+
detail: `${domains} domain${domains !== 1 ? 's' : ''} installed`,
|
|
92
|
+
});
|
|
93
|
+
} else {
|
|
94
|
+
checks.push({ name: 'Domains directory', status: 'warn', detail: '~/.kdna/domains/ not found' });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!domainsOnly) {
|
|
99
|
+
// 5. Agent integration check
|
|
100
|
+
const detected = detectAgents();
|
|
101
|
+
for (const agent of AGENTS) {
|
|
102
|
+
const agentDirExists = fs.existsSync(agent.dir);
|
|
103
|
+
const skill = agentDirExists ? checkAgentSkill(agent) : { installed: false, version: null, path: null };
|
|
104
|
+
|
|
105
|
+
let status, detail;
|
|
106
|
+
if (!agentDirExists) {
|
|
107
|
+
status = 'warn';
|
|
108
|
+
detail = 'agent not detected';
|
|
109
|
+
} else if (skill.installed && skill.version === 'v2026.05') {
|
|
110
|
+
status = 'ok';
|
|
111
|
+
detail = `kdna-loader installed (${skill.version})`;
|
|
112
|
+
} else if (skill.installed && skill.version === 'outdated') {
|
|
113
|
+
status = 'warn';
|
|
114
|
+
detail = 'kdna-loader outdated (run kdna setup --force)';
|
|
115
|
+
} else {
|
|
116
|
+
status = 'warn';
|
|
117
|
+
detail = 'kdna-loader not installed (run kdna setup)';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
checks.push({
|
|
121
|
+
name: `Agent: ${agent.name}`,
|
|
122
|
+
status,
|
|
123
|
+
detail,
|
|
124
|
+
agent: agent.name,
|
|
125
|
+
skillInstalled: skill.installed,
|
|
126
|
+
skillVersion: skill.version,
|
|
127
|
+
skillPath: skill.path,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!agentsOnly && !domainsOnly) {
|
|
133
|
+
// 6. Identity key available
|
|
134
|
+
const identityDir = path.join(USER_KDNA_DIR, 'identity');
|
|
135
|
+
const identityDirOfficial = path.join(USER_KDNA_DIR, 'identity-official');
|
|
136
|
+
const hasIdentity =
|
|
137
|
+
(fs.existsSync(identityDir) && fs.readdirSync(identityDir).length > 0) ||
|
|
138
|
+
(fs.existsSync(identityDirOfficial) && fs.readdirSync(identityDirOfficial).length > 0);
|
|
139
|
+
checks.push({
|
|
140
|
+
name: 'Signing identity',
|
|
141
|
+
status: hasIdentity ? 'ok' : 'warn',
|
|
142
|
+
detail: hasIdentity ? 'key available' : 'no identity (run: kdna identity init)',
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// 7. Registry cache
|
|
146
|
+
const registryCache = path.join(USER_KDNA_DIR, 'registry-cache.json');
|
|
147
|
+
if (fs.existsSync(registryCache)) {
|
|
148
|
+
try {
|
|
149
|
+
const stat = fs.statSync(registryCache);
|
|
150
|
+
const ageMs = Date.now() - stat.mtimeMs;
|
|
151
|
+
const ageH = Math.round(ageMs / 3600000);
|
|
152
|
+
const fresh = ageH < 24;
|
|
153
|
+
checks.push({
|
|
154
|
+
name: 'Registry cache',
|
|
155
|
+
status: fresh ? 'ok' : 'warn',
|
|
156
|
+
detail: `updated ${ageH < 1 ? '<1h' : ageH + 'h'} ago`,
|
|
157
|
+
});
|
|
158
|
+
} catch {
|
|
159
|
+
checks.push({ name: 'Registry cache', status: 'warn', detail: 'cannot read cache' });
|
|
160
|
+
}
|
|
161
|
+
} else {
|
|
162
|
+
checks.push({ name: 'Registry cache', status: 'warn', detail: 'not cached (run: kdna registry refresh)' });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 8. Schema files available
|
|
166
|
+
let schemaCount = 0;
|
|
167
|
+
try {
|
|
168
|
+
const schemaDir = path.join(
|
|
169
|
+
path.dirname(require.resolve('@aikdna/kdna-core/package.json')),
|
|
170
|
+
'schema',
|
|
171
|
+
);
|
|
172
|
+
schemaCount = fs.readdirSync(schemaDir).filter((f) => f.endsWith('.schema.json')).length;
|
|
173
|
+
checks.push({
|
|
174
|
+
name: 'Schema files',
|
|
175
|
+
status: schemaCount >= 6 ? 'ok' : 'warn',
|
|
176
|
+
detail: `${schemaCount} schemas`,
|
|
177
|
+
});
|
|
178
|
+
} catch {
|
|
179
|
+
checks.push({ name: 'Schema files', status: 'fail', detail: 'not found' });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// 9. Project .kdna/config.json
|
|
183
|
+
const projectConfig = path.join(process.cwd(), '.kdna', 'config.json');
|
|
184
|
+
if (fs.existsSync(projectConfig)) {
|
|
185
|
+
checks.push({ name: 'Project config', status: 'ok', detail: projectConfig });
|
|
186
|
+
} else {
|
|
187
|
+
checks.push({ name: 'Project config', status: 'warn', detail: 'No .kdna/config.json in current project' });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── Output ───────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
if (json) {
|
|
194
|
+
const result = {
|
|
195
|
+
checks: checks.map((c) => ({
|
|
196
|
+
name: c.name,
|
|
197
|
+
status: c.status,
|
|
198
|
+
detail: c.detail,
|
|
199
|
+
...(c.agent && { agent: c.agent, skillInstalled: c.skillInstalled, skillVersion: c.skillVersion }),
|
|
200
|
+
})),
|
|
201
|
+
ok: checks.filter((c) => c.status === 'ok').length,
|
|
202
|
+
warnings: checks.filter((c) => c.status === 'warn').length,
|
|
203
|
+
failures: checks.filter((c) => c.status === 'fail').length,
|
|
204
|
+
healthy: checks.every((c) => c.status !== 'fail'),
|
|
205
|
+
};
|
|
206
|
+
console.log(JSON.stringify(result, null, 2));
|
|
207
|
+
process.exit(result.healthy ? 0 : EXIT.VALIDATION_FAILED);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!quiet) {
|
|
211
|
+
for (const c of checks) {
|
|
212
|
+
const mark = c.status === 'ok' ? '✓' : c.status === 'warn' ? '⚠' : '✗';
|
|
213
|
+
console.log(`${mark} ${c.name}: ${c.detail}`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const ok = checks.filter((c) => c.status === 'ok').length;
|
|
217
|
+
const warns = checks.filter((c) => c.status === 'warn').length;
|
|
218
|
+
const fails = checks.filter((c) => c.status === 'fail').length;
|
|
219
|
+
console.log('');
|
|
220
|
+
if (fails > 0) {
|
|
221
|
+
console.log(`${ok}/${checks.length} checks passed (${fails} failure${fails !== 1 ? 's' : ''}, ${warns} warning${warns !== 1 ? 's' : ''})`);
|
|
222
|
+
} else if (warns > 0) {
|
|
223
|
+
console.log(`${ok}/${checks.length} checks passed (${warns} warning${warns !== 1 ? 's' : ''})`);
|
|
224
|
+
} else {
|
|
225
|
+
console.log(`${ok}/${checks.length} checks passed`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const hasFail = checks.some((c) => c.status === 'fail');
|
|
230
|
+
process.exit(hasFail ? EXIT.VALIDATION_FAILED : EXIT.OK);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
module.exports = { cmdDoctor };
|