@aikdna/kdna-cli 0.9.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 (46) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +9 -0
  3. package/README.md +73 -0
  4. package/package.json +58 -0
  5. package/skills/kdna-loader/SKILL.md +257 -0
  6. package/src/agent.js +434 -0
  7. package/src/cli.js +260 -0
  8. package/src/cluster.js +235 -0
  9. package/src/cmds/_common.js +100 -0
  10. package/src/cmds/cluster.js +235 -0
  11. package/src/cmds/domain.js +638 -0
  12. package/src/cmds/identity.js +31 -0
  13. package/src/cmds/legacy.js +83 -0
  14. package/src/cmds/quality.js +87 -0
  15. package/src/cmds/registry.js +114 -0
  16. package/src/cmds/setup.js +8 -0
  17. package/src/compare.js +324 -0
  18. package/src/diff.js +288 -0
  19. package/src/identity.js +211 -0
  20. package/src/init.js +168 -0
  21. package/src/install.js +849 -0
  22. package/src/loader.js +70 -0
  23. package/src/publish.js +600 -0
  24. package/src/registry.js +258 -0
  25. package/src/search.js +73 -0
  26. package/src/setup.js +197 -0
  27. package/src/verify.js +423 -0
  28. package/src/version.js +112 -0
  29. package/templates/cluster/KDNA_Cluster.json +25 -0
  30. package/templates/cluster/README.md +32 -0
  31. package/templates/minimal-domain/KDNA_Core.json +54 -0
  32. package/templates/minimal-domain/KDNA_Patterns.json +37 -0
  33. package/templates/minimal-domain/kdna.json +31 -0
  34. package/templates/minimal-domain/tests/before-after.json +16 -0
  35. package/templates/standard-domain/KDNA_Core.json +76 -0
  36. package/templates/standard-domain/KDNA_Patterns.json +44 -0
  37. package/templates/standard-domain/README.md +74 -0
  38. package/templates/standard-domain/USAGE.md +59 -0
  39. package/templates/standard-domain/evals/1_excluded_case.json +16 -0
  40. package/templates/standard-domain/evals/3_boundary_cases.json +38 -0
  41. package/templates/standard-domain/evals/3_core_cases.json +35 -0
  42. package/templates/standard-domain/evals/3_failure_cases.json +35 -0
  43. package/templates/standard-domain/evals/scoring.json +60 -0
  44. package/templates/standard-domain/kdna.json +28 -0
  45. package/validators/kdna-lint.js +53 -0
  46. package/validators/kdna-validate.js +92 -0
package/src/install.js ADDED
@@ -0,0 +1,849 @@
1
+ /**
2
+ * KDNA Install — v0.7 .kdna-first installer.
3
+ *
4
+ * Sources (priority order):
5
+ * kdna install <bare> → @aikdna/<bare>, from registry
6
+ * kdna install @scope/name → from registry (any scope)
7
+ * kdna install @scope/name@1.2.3 → version pinned (TODO post-v0.7.0)
8
+ * kdna install ./folder → local directory (dev)
9
+ * kdna install ./file.kdna → local .kdna file
10
+ *
11
+ * Removed in v0.7 (breaking): github:user/repo, --from-git, cluster:github:...,
12
+ * tarball/SSH fallbacks. Install is now strictly .kdna-driven from the registry.
13
+ *
14
+ * Schema v2.0 — see kdna-registry/SCHEMA.md
15
+ */
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const crypto = require('crypto');
20
+ const { execSync, execFileSync } = require('child_process');
21
+ const { RegistryResolver, parseName } = require('./registry');
22
+
23
+ const USER_KDNA_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '.', '.kdna');
24
+ const INSTALL_DIR = path.join(USER_KDNA_DIR, 'domains');
25
+
26
+ // Agent skill directories (search order)
27
+ const AGENT_SKILL_DIRS = [
28
+ path.join(process.env.HOME || '', '.agents', 'skills'),
29
+ path.join(process.env.HOME || '', '.claude', 'skills'),
30
+ path.join(process.env.HOME || '', '.codex', 'skills'),
31
+ path.join(process.env.HOME || '', '.cursor', 'skills'),
32
+ path.join(process.env.HOME || '', '.gemini', 'antigravity', 'skills'),
33
+ ];
34
+
35
+ /**
36
+ * Ensure the kdna-loader skill is installed in ALL detected agent directories.
37
+ * Without this, installed KDNA domains are invisible to agents.
38
+ */
39
+ function ensureLoaderSkill() {
40
+ const alreadyInstalled = [];
41
+ const toInstall = [];
42
+ const toUpdate = []; // present but outdated (pre-v2.1)
43
+
44
+ // v2.1 marker — present in current SKILL.md, absent in old one
45
+ const V2_1_MARKER = 'applies_when';
46
+
47
+ for (const dir of AGENT_SKILL_DIRS) {
48
+ const skillFile = path.join(dir, 'kdna-loader', 'SKILL.md');
49
+ if (fs.existsSync(skillFile)) {
50
+ let isCurrent = false;
51
+ try {
52
+ const content = fs.readFileSync(skillFile, 'utf8');
53
+ isCurrent = content.includes(V2_1_MARKER);
54
+ } catch {
55
+ /* unreadable — treat as missing */
56
+ }
57
+ if (isCurrent) alreadyInstalled.push(dir);
58
+ else toUpdate.push(dir);
59
+ } else {
60
+ toInstall.push(dir);
61
+ }
62
+ }
63
+
64
+ // If all up-to-date, nothing to do
65
+ if (toInstall.length === 0 && toUpdate.length === 0) return;
66
+
67
+ // Notify which are current
68
+ if (alreadyInstalled.length > 0) {
69
+ console.log(
70
+ ` ✓ kdna-loader (v2.1) found in: ${alreadyInstalled.map((d) => path.basename(path.dirname(d))).join(', ')}`,
71
+ );
72
+ }
73
+
74
+ // Install + update share the same target list
75
+ const targets = [...toInstall, ...toUpdate];
76
+ const verb =
77
+ toUpdate.length && !toInstall.length
78
+ ? 'Updating'
79
+ : toInstall.length && !toUpdate.length
80
+ ? 'Installing'
81
+ : 'Installing/updating';
82
+ console.log(` ${verb} kdna-loader skill (v2.1)...`);
83
+
84
+ let installed = 0;
85
+ const sources = [];
86
+
87
+ // Source 1: download from kdna-skills repo (single source of truth, v0.7.4+).
88
+ // This must come FIRST so we don't ship stale local copies to users.
89
+ sources.push({
90
+ type: 'remote',
91
+ url: 'https://raw.githubusercontent.com/knowledge-dna/kdna-skills/main/kdna-loader/SKILL.md',
92
+ });
93
+
94
+ // Source 2: offline fallback — KDNA repo local checkout, only used if the
95
+ // CDN is unreachable. The npm-published tarball does NOT include SKILL.md
96
+ // files anymore (they live solely in kdna-skills).
97
+ const localTemplate = path.resolve(__dirname, '..', 'skills', 'kdna-loader', 'SKILL.md');
98
+ if (fs.existsSync(localTemplate)) {
99
+ sources.push({ type: 'local', path: localTemplate });
100
+ }
101
+
102
+ for (const dir of targets) {
103
+ const skillDir = path.join(dir, 'kdna-loader');
104
+ for (const src of sources) {
105
+ try {
106
+ fs.mkdirSync(skillDir, { recursive: true });
107
+ if (src.type === 'local') {
108
+ fs.copyFileSync(src.path, path.join(skillDir, 'SKILL.md'));
109
+ } else {
110
+ execSync(`curl -fsSL -o "${path.join(skillDir, 'SKILL.md')}" "${src.url}"`, {
111
+ stdio: 'pipe',
112
+ timeout: 10000,
113
+ });
114
+ }
115
+ installed++;
116
+ break; // Move to next agent dir
117
+ } catch {
118
+ // Try next source
119
+ }
120
+ }
121
+ }
122
+
123
+ if (installed > 0) {
124
+ console.log(
125
+ ` ✓ kdna-loader installed/updated in ${installed} agent director${installed > 1 ? 'ies' : 'y'}`,
126
+ );
127
+ }
128
+
129
+ if (installed < targets.length) {
130
+ console.log(
131
+ ` ⚠ Could not install to ${targets.length - installed} agent director${targets.length - installed > 1 ? 'ies' : 'y'}.`,
132
+ );
133
+ console.log(' Run: kdna setup --force');
134
+ }
135
+
136
+ if (installed === 0 && alreadyInstalled.length === 0) {
137
+ console.log(' ⚠ Could not install kdna-loader anywhere.');
138
+ console.log(' Run: kdna setup');
139
+ }
140
+ }
141
+
142
+ function error(msg) {
143
+ console.error(`Error: ${msg}`);
144
+ process.exit(1);
145
+ }
146
+
147
+ function ensureDir(dir) {
148
+ fs.mkdirSync(dir, { recursive: true });
149
+ }
150
+
151
+ function readJson(p) {
152
+ try {
153
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
154
+ } catch {
155
+ return null;
156
+ }
157
+ }
158
+
159
+ function sha256File(filePath) {
160
+ return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
161
+ }
162
+
163
+ function scopeDir(scope) {
164
+ return path.join(INSTALL_DIR, scope);
165
+ }
166
+
167
+ function domainDir(scope, ident) {
168
+ return path.join(INSTALL_DIR, scope, ident);
169
+ }
170
+
171
+ // ─── Legacy detection ───────────────────────────────────────────────────
172
+
173
+ function detectLegacyInstalls() {
174
+ if (!fs.existsSync(INSTALL_DIR)) return [];
175
+ const entries = fs.readdirSync(INSTALL_DIR);
176
+ // Legacy: any direct child of INSTALL_DIR that is a directory AND does NOT start with @
177
+ return entries.filter((e) => {
178
+ if (e.startsWith('@') || e.startsWith('.')) return false;
179
+ try {
180
+ return fs.statSync(path.join(INSTALL_DIR, e)).isDirectory();
181
+ } catch {
182
+ return false;
183
+ }
184
+ });
185
+ }
186
+
187
+ function warnLegacy() {
188
+ const legacy = detectLegacyInstalls();
189
+ if (!legacy.length) return;
190
+ console.error('');
191
+ console.error('═'.repeat(64));
192
+ console.error(' v0.7 breaking change: legacy (un-scoped) domains detected');
193
+ console.error('═'.repeat(64));
194
+ console.error('');
195
+ console.error(' These directories use the old un-scoped path layout:');
196
+ legacy.forEach((d) => console.error(` ~/.kdna/domains/${d}/`));
197
+ console.error('');
198
+ console.error(' Run: kdna remove <name> then kdna install <name>');
199
+ console.error(' (CLI will not read or update legacy directories.)');
200
+ console.error('');
201
+ }
202
+
203
+ // ─── Source parsing ─────────────────────────────────────────────────────
204
+
205
+ function parseSource(input) {
206
+ // Local file
207
+ if (
208
+ input.endsWith('.kdna') &&
209
+ (input.startsWith('./') || input.startsWith('/') || input.startsWith('~/'))
210
+ ) {
211
+ const resolved = path.resolve(input.replace(/^~/, process.env.HOME || ''));
212
+ if (!fs.existsSync(resolved)) error(`Local file not found: ${resolved}`);
213
+ return { type: 'local-file', path: resolved };
214
+ }
215
+
216
+ // Local directory
217
+ if (input.startsWith('./') || input.startsWith('/') || input.startsWith('~/')) {
218
+ const resolved = path.resolve(input.replace(/^~/, process.env.HOME || ''));
219
+ if (!fs.existsSync(resolved)) error(`Local path not found: ${resolved}`);
220
+ if (!fs.statSync(resolved).isDirectory()) error(`Not a directory: ${resolved}`);
221
+ return { type: 'local-dir', path: resolved };
222
+ }
223
+
224
+ // Registry name (bare or @scope/name)
225
+ const parsed = parseName(input);
226
+ if (!parsed) {
227
+ error(
228
+ `Cannot parse "${input}". Use:\n` +
229
+ ` kdna install <name> # @aikdna/<name>\n` +
230
+ ` kdna install @scope/name # any scope\n` +
231
+ ` kdna install ./folder # local directory\n` +
232
+ ` kdna install ./file.kdna # local .kdna file`,
233
+ );
234
+ }
235
+ return { type: 'registry', parsed };
236
+ }
237
+
238
+ // ─── Download helpers ──────────────────────────────────────────────────
239
+
240
+ function downloadFile(url, dest) {
241
+ ensureDir(path.dirname(dest));
242
+ let lastErr;
243
+ for (let attempt = 1; attempt <= 3; attempt++) {
244
+ try {
245
+ execFileSync('curl', ['-fsSL', '--retry', '2', '--retry-delay', '1', '-o', dest, url], {
246
+ timeout: 90000,
247
+ stdio: 'pipe',
248
+ });
249
+ return;
250
+ } catch (e) {
251
+ lastErr = e;
252
+ if (attempt < 3) {
253
+ // brief pause between attempts
254
+ try {
255
+ execFileSync('sleep', ['1'], { stdio: 'ignore' });
256
+ } catch {
257
+ /* ignore */
258
+ }
259
+ }
260
+ }
261
+ }
262
+ const stderr = lastErr?.stderr?.toString().trim() || lastErr?.message || 'unknown';
263
+ throw new Error(`download failed after 3 attempts: ${stderr}`);
264
+ }
265
+
266
+ // ─── Extraction ────────────────────────────────────────────────────────
267
+
268
+ function extractKdna(kdnaPath, destDir) {
269
+ ensureDir(destDir);
270
+ const script = `import zipfile
271
+ zf = zipfile.ZipFile(${JSON.stringify(kdnaPath)}, 'r')
272
+ zf.extractall(${JSON.stringify(destDir)})
273
+ zf.close()
274
+ print('ok')
275
+ `;
276
+ try {
277
+ execSync(`python3 -c ${JSON.stringify(script)}`, { stdio: 'pipe' });
278
+ return;
279
+ } catch {
280
+ /* try unzip */
281
+ }
282
+ try {
283
+ execSync(`unzip -q -o "${kdnaPath}" -d "${destDir}"`, { stdio: 'pipe' });
284
+ return;
285
+ } catch {
286
+ error('Cannot extract .kdna file. Install python3 or unzip.');
287
+ }
288
+ }
289
+
290
+ // ─── Signature verification ────────────────────────────────────────────
291
+
292
+ function verifySignature({ destDir, scope, entry, lenient = true }) {
293
+ const manifest = readJson(path.join(destDir, 'kdna.json'));
294
+ if (!manifest) {
295
+ if (lenient) {
296
+ console.warn(' ⚠ No kdna.json — cannot verify signature.');
297
+ return;
298
+ }
299
+ error('No kdna.json in package — cannot verify signature.');
300
+ }
301
+
302
+ const trustKey = scope.trust_pubkey;
303
+ const isPlaceholder = !trustKey || trustKey.includes('PLACEHOLDER');
304
+
305
+ // v0.7 bootstrap: signatures may be absent. Warn but allow.
306
+ if (!entry.signature || !manifest.signature) {
307
+ if (isPlaceholder) {
308
+ console.warn(
309
+ ` ⚠ Bootstrap mode: scope ${entry.name.split('/')[0]} has placeholder trust key. Signature not verified.`,
310
+ );
311
+ } else {
312
+ console.warn(
313
+ ` ⚠ ${entry.name}: no signature on package. (Will be required post-bootstrap.)`,
314
+ );
315
+ }
316
+ return;
317
+ }
318
+
319
+ // Author pubkey fingerprint must match scope trust_pubkey
320
+ if (manifest.author?.pubkey !== trustKey) {
321
+ error(`${entry.name}: author.pubkey does not match scope trust key. Refusing to install.`);
322
+ }
323
+
324
+ // Full Ed25519 verify (requires public_key_pem embedded in the package)
325
+ const pem = manifest.author?.public_key_pem;
326
+ if (!pem) {
327
+ // Legacy package (signed but no embedded PEM). Trust the fingerprint match.
328
+ console.log(' ✓ Signature OK (legacy fingerprint-only mode — no PEM)');
329
+ return;
330
+ }
331
+
332
+ // 1. Confirm the embedded PEM hashes to the claimed pubkey fingerprint
333
+ const computedFingerprint = 'ed25519:' + crypto.createHash('sha256').update(pem).digest('hex');
334
+ if (computedFingerprint !== manifest.author.pubkey) {
335
+ error(
336
+ `${entry.name}: embedded public_key_pem does not match author.pubkey fingerprint. Refusing.`,
337
+ );
338
+ }
339
+
340
+ // 2. Verify the Ed25519 signature over the canonical payload
341
+ // Canonical payload reconstruction must match publish.js exactly:
342
+ // - sorted .json filenames
343
+ // - for kdna.json: strip "signature" field before hashing
344
+ // - others: raw bytes
345
+ // - hash each, format "name:hex", join with "\n"
346
+ const sigHex = manifest.signature.replace(/^ed25519:/, '');
347
+ try {
348
+ const files = fs
349
+ .readdirSync(destDir)
350
+ .filter((f) => f.endsWith('.json'))
351
+ .sort();
352
+ const parts = [];
353
+ for (const f of files) {
354
+ const full = path.join(destDir, f);
355
+ let buf;
356
+ if (f === 'kdna.json') {
357
+ const obj = JSON.parse(fs.readFileSync(full, 'utf8'));
358
+ delete obj.signature;
359
+ delete obj._source; // install-time metadata, not part of signed payload
360
+ buf = Buffer.from(JSON.stringify(obj));
361
+ } else {
362
+ buf = fs.readFileSync(full);
363
+ }
364
+ const hash = crypto.createHash('sha256').update(buf).digest('hex');
365
+ parts.push(`${f}:${hash}`);
366
+ }
367
+ const payload = parts.join('\n');
368
+
369
+ const publicKey = crypto.createPublicKey(pem);
370
+ const ok = crypto.verify(null, Buffer.from(payload), publicKey, Buffer.from(sigHex, 'hex'));
371
+ if (!ok) {
372
+ error(`${entry.name}: Ed25519 signature INVALID. Package may be tampered. Refusing.`);
373
+ }
374
+ console.log(' ✓ Signature OK (Ed25519 verified)');
375
+ } catch (e) {
376
+ if (e.message?.includes('INVALID')) throw e;
377
+ error(`${entry.name}: signature verification failed: ${e.message}`);
378
+ }
379
+ }
380
+
381
+ // ─── Status confirmation (interactive) ─────────────────────────────────
382
+
383
+ function confirmStatus(entry, yes) {
384
+ const status = entry.status || 'experimental';
385
+ if (yes || (status !== 'experimental' && status !== 'draft')) return true;
386
+
387
+ console.log(` ${entry.name} is ${status} — judgment quality is not yet verified.`);
388
+ console.log(` Pass --yes to skip this prompt.`);
389
+ try {
390
+ const buf = Buffer.alloc(1);
391
+ process.stdout.write('Continue? [y/N] ');
392
+ fs.readSync(0, buf, 0, 1);
393
+ return buf.toString().trim().toLowerCase() === 'y';
394
+ } catch {
395
+ return false;
396
+ }
397
+ }
398
+
399
+ // ─── Cleanup stale temps ───────────────────────────────────────────────
400
+
401
+ function cleanStaleTemps() {
402
+ if (!fs.existsSync(INSTALL_DIR)) return;
403
+ try {
404
+ for (const scopeName of fs.readdirSync(INSTALL_DIR)) {
405
+ if (!scopeName.startsWith('@')) continue;
406
+ const sd = path.join(INSTALL_DIR, scopeName);
407
+ if (!fs.statSync(sd).isDirectory()) continue;
408
+ for (const child of fs.readdirSync(sd)) {
409
+ if (child.endsWith('.tmp') || child.endsWith('.kdna.tmp')) {
410
+ try {
411
+ fs.rmSync(path.join(sd, child), { recursive: true, force: true });
412
+ } catch {
413
+ /* ignore */
414
+ }
415
+ }
416
+ }
417
+ }
418
+ } catch {
419
+ /* ignore */
420
+ }
421
+ }
422
+
423
+ // ─── Main install ──────────────────────────────────────────────────────
424
+
425
+ function cmdInstallExtended(input, args = []) {
426
+ warnLegacy();
427
+ ensureDir(INSTALL_DIR);
428
+ cleanStaleTemps();
429
+
430
+ // Auto-install loader skill if missing (without it, agents can't see installed domains)
431
+ ensureLoaderSkill();
432
+
433
+ const yes = args.includes('--yes');
434
+ const source = parseSource(input);
435
+
436
+ switch (source.type) {
437
+ case 'registry':
438
+ return installFromRegistry(source.parsed, yes);
439
+ case 'local-file':
440
+ return installFromLocalFile(source.path, yes);
441
+ case 'local-dir':
442
+ return installFromLocalDir(source.path, yes);
443
+ }
444
+ }
445
+
446
+ function installFromRegistry(parsed, yes) {
447
+ const resolver = new RegistryResolver({ allowNetwork: true });
448
+ let scope, entry;
449
+ try {
450
+ ({ scope, entry } = resolver.resolve(parsed.full));
451
+ } catch (e) {
452
+ error(e.message);
453
+ }
454
+
455
+ if (parsed.wasShort) {
456
+ console.log(` Resolved "${parsed.ident}" → ${entry.name}`);
457
+ }
458
+
459
+ if (entry.deprecated) {
460
+ console.warn(
461
+ ` ⚠ ${entry.name} is deprecated.${entry.replaced_by ? ` Use ${entry.replaced_by} instead.` : ''}`,
462
+ );
463
+ }
464
+ if (entry.access && entry.access !== 'open') {
465
+ error(`${entry.name} requires "${entry.access}" access. Not installable via CLI yet.`);
466
+ }
467
+
468
+ if (entry.type === 'cluster') {
469
+ return installCluster(entry, resolver, yes);
470
+ }
471
+
472
+ if (!entry.kdna_url) {
473
+ error(
474
+ `${entry.name}@${entry.version} has no kdna_url in registry.\n` +
475
+ `release_status: ${entry.release_status || 'unknown'}\n` +
476
+ `(This domain has not been published as a .kdna file yet. It will be available after v0.7 republish.)`,
477
+ );
478
+ }
479
+
480
+ if (!confirmStatus(entry, yes)) {
481
+ console.log('Installation cancelled.');
482
+ process.exit(0);
483
+ }
484
+
485
+ installSingleFromUrl({ entry, scope });
486
+ }
487
+
488
+ function installSingleFromUrl({ entry, scope }) {
489
+ const [scopeName, ident] = entry.name.split('/');
490
+ const dest = domainDir(scopeName, ident);
491
+ const tmpFile = path.join(scopeDir(scopeName), `.${ident}-${Date.now()}.kdna.tmp`);
492
+
493
+ console.log(` Downloading ${entry.name}@${entry.version}...`);
494
+ ensureDir(scopeDir(scopeName));
495
+ try {
496
+ downloadFile(entry.kdna_url, tmpFile);
497
+ } catch {
498
+ error(`Failed to download ${entry.kdna_url}`);
499
+ }
500
+
501
+ // sha256 check
502
+ const actual = sha256File(tmpFile);
503
+ if (entry.sha256 && actual !== entry.sha256) {
504
+ try {
505
+ fs.unlinkSync(tmpFile);
506
+ } catch {
507
+ /* ignore */
508
+ }
509
+ error(`sha256 mismatch for ${entry.name}: expected ${entry.sha256}, got ${actual}`);
510
+ }
511
+ console.log(` ✓ sha256 verified`);
512
+
513
+ // Replace existing install atomically-ish
514
+ if (fs.existsSync(dest)) {
515
+ fs.rmSync(dest, { recursive: true, force: true });
516
+ }
517
+ ensureDir(dest);
518
+
519
+ extractKdna(tmpFile, dest);
520
+ try {
521
+ fs.unlinkSync(tmpFile);
522
+ } catch {
523
+ /* ignore */
524
+ }
525
+
526
+ verifySignature({ destDir: dest, scope, entry, lenient: true });
527
+
528
+ // Stamp install metadata
529
+ const manifest = readJson(path.join(dest, 'kdna.json')) || {};
530
+ manifest._source = {
531
+ type: 'registry',
532
+ name: entry.name,
533
+ version: entry.version,
534
+ kdna_url: entry.kdna_url,
535
+ sha256: entry.sha256,
536
+ installed_at: new Date().toISOString(),
537
+ };
538
+ fs.writeFileSync(path.join(dest, 'kdna.json'), JSON.stringify(manifest, null, 2) + '\n');
539
+
540
+ console.log(`✓ Installed ${entry.name}@${entry.version}`);
541
+ console.log(` Location: ${dest}`);
542
+ }
543
+
544
+ function installCluster(clusterEntry, resolver, _yes) {
545
+ const subdomains = clusterEntry.cluster?.domains || [];
546
+ if (!subdomains.length) {
547
+ error(`Cluster ${clusterEntry.name} has no sub-domains listed.`);
548
+ }
549
+
550
+ console.log(`Cluster ${clusterEntry.name} → ${subdomains.length} sub-domains`);
551
+
552
+ for (const sub of subdomains) {
553
+ try {
554
+ const resolved = resolver.resolve(sub);
555
+ if (!resolved.entry.kdna_url) {
556
+ console.warn(` ⚠ ${sub}: no kdna_url (skipping)`);
557
+ continue;
558
+ }
559
+ console.log('');
560
+ installSingleFromUrl({ entry: resolved.entry, scope: resolved.scope });
561
+ } catch (e) {
562
+ console.warn(` ⚠ ${sub}: ${e.message.split('\n')[0]}`);
563
+ }
564
+ }
565
+
566
+ // Record the cluster itself
567
+ const [scopeName, ident] = clusterEntry.name.split('/');
568
+ const clusterDest = domainDir(scopeName, ident);
569
+ ensureDir(clusterDest);
570
+ fs.writeFileSync(
571
+ path.join(clusterDest, 'cluster.json'),
572
+ JSON.stringify(
573
+ {
574
+ name: clusterEntry.name,
575
+ version: clusterEntry.version,
576
+ type: 'cluster',
577
+ domains: subdomains,
578
+ composition_rules: clusterEntry.cluster.composition_rules || [],
579
+ installed_at: new Date().toISOString(),
580
+ },
581
+ null,
582
+ 2,
583
+ ) + '\n',
584
+ );
585
+ console.log('');
586
+ console.log(`✓ Cluster ${clusterEntry.name} installed`);
587
+ }
588
+
589
+ function installFromLocalFile(filePath, _yes) {
590
+ const abs = path.resolve(filePath);
591
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) error(`Not a file: ${abs}`);
592
+
593
+ const tmpDir = path.join(INSTALL_DIR, '.local-tmp-' + Date.now());
594
+ ensureDir(tmpDir);
595
+ extractKdna(abs, tmpDir);
596
+
597
+ const manifest = readJson(path.join(tmpDir, 'kdna.json'));
598
+ const declared = manifest?.name;
599
+ if (!declared || !/^@[a-z][a-z0-9-]*\/[a-z][a-z0-9_]*$/.test(declared)) {
600
+ fs.rmSync(tmpDir, { recursive: true, force: true });
601
+ error(
602
+ `Package kdna.json.name "${declared || '?'}" must be @scope/name format.\n` +
603
+ `(v0.7 requires scoped names.)`,
604
+ );
605
+ }
606
+ const [scopeName, ident] = declared.split('/');
607
+ const dest = domainDir(scopeName, ident);
608
+ if (fs.existsSync(dest)) fs.rmSync(dest, { recursive: true, force: true });
609
+ ensureDir(path.dirname(dest));
610
+ fs.renameSync(tmpDir, dest);
611
+
612
+ const destManifest = readJson(path.join(dest, 'kdna.json')) || {};
613
+ destManifest._source = {
614
+ type: 'local-file',
615
+ path: abs,
616
+ installed_at: new Date().toISOString(),
617
+ };
618
+ fs.writeFileSync(path.join(dest, 'kdna.json'), JSON.stringify(destManifest, null, 2) + '\n');
619
+
620
+ console.log(`✓ Installed ${declared} from local file`);
621
+ console.log(` Location: ${dest}`);
622
+ }
623
+
624
+ function installFromLocalDir(dirPath, _yes) {
625
+ const abs = path.resolve(dirPath);
626
+ const manifest = readJson(path.join(abs, 'kdna.json'));
627
+ const declared = manifest?.name;
628
+ if (!declared || !/^@[a-z][a-z0-9-]*\/[a-z][a-z0-9_]*$/.test(declared)) {
629
+ error(`Source kdna.json.name "${declared || '?'}" must be @scope/name format.`);
630
+ }
631
+ const [scopeName, ident] = declared.split('/');
632
+ const dest = domainDir(scopeName, ident);
633
+ if (fs.existsSync(dest)) fs.rmSync(dest, { recursive: true, force: true });
634
+ ensureDir(path.dirname(dest));
635
+ fs.cpSync(abs, dest, { recursive: true });
636
+
637
+ const destManifest = readJson(path.join(dest, 'kdna.json')) || {};
638
+ destManifest._source = {
639
+ type: 'local-dir',
640
+ path: abs,
641
+ installed_at: new Date().toISOString(),
642
+ };
643
+ fs.writeFileSync(path.join(dest, 'kdna.json'), JSON.stringify(destManifest, null, 2) + '\n');
644
+
645
+ console.log(`✓ Installed ${declared} from local directory (dev mode)`);
646
+ console.log(` Location: ${dest}`);
647
+ }
648
+
649
+ // ─── Remove ─────────────────────────────────────────────────────────────
650
+
651
+ function cmdRemove(input) {
652
+ warnLegacy();
653
+ const parsed = parseName(input);
654
+ if (!parsed) error(`Invalid name "${input}". Use @scope/name or bare name.`);
655
+ const dest = domainDir(parsed.scope, parsed.ident);
656
+ if (!fs.existsSync(dest)) {
657
+ console.log(`${parsed.full} is not installed.`);
658
+ return;
659
+ }
660
+ fs.rmSync(dest, { recursive: true, force: true });
661
+ console.log(`✓ Removed ${parsed.full}`);
662
+ }
663
+
664
+ // ─── Info ───────────────────────────────────────────────────────────────
665
+
666
+ function cmdInfo(input) {
667
+ warnLegacy();
668
+ const parsed = parseName(input);
669
+ if (!parsed) error(`Invalid name "${input}".`);
670
+ const dest = domainDir(parsed.scope, parsed.ident);
671
+ if (!fs.existsSync(dest)) error(`${parsed.full} is not installed.`);
672
+
673
+ const manifest = readJson(path.join(dest, 'kdna.json'));
674
+ const core = readJson(path.join(dest, 'KDNA_Core.json'));
675
+ const pat = readJson(path.join(dest, 'KDNA_Patterns.json'));
676
+ const source = manifest?._source || {};
677
+
678
+ // ─── Header ─────────────────────────────────────────────────────
679
+ console.log('═'.repeat(64));
680
+ console.log(` ${parsed.full}`);
681
+ console.log('═'.repeat(64));
682
+ console.log(` Version: ${manifest?.version || core?.meta?.version || '?'}`);
683
+ if (manifest?.judgment_version) {
684
+ console.log(` Judgment version: ${manifest.judgment_version}`);
685
+ }
686
+ console.log(` Status: ${manifest?.status || '?'}`);
687
+ console.log(` License: ${manifest?.license?.type || '?'}`);
688
+ console.log(` Author: ${manifest?.author?.name || '?'}`);
689
+
690
+ // ─── Identity & trust ──────────────────────────────────────────
691
+ console.log('');
692
+ console.log(' ── Identity & trust ──');
693
+ if (manifest?.author?.pubkey) {
694
+ console.log(` Author pubkey: ${manifest.author.pubkey.slice(0, 28)}…`);
695
+ }
696
+ if (manifest?.author?.public_key_pem) {
697
+ console.log(` Embedded PEM: yes (full Ed25519 verify available)`);
698
+ } else {
699
+ console.log(` Embedded PEM: no (legacy pre-v0.7.1 package)`);
700
+ }
701
+ if (source.kdna_url) console.log(` Source URL: ${source.kdna_url}`);
702
+ if (source.sha256) console.log(` Source sha256: ${source.sha256.slice(0, 32)}…`);
703
+ console.log(` Installed: ${source.installed_at || '?'}`);
704
+ console.log(` Path: ${dest}`);
705
+
706
+ // ─── Judgment surface ──────────────────────────────────────────
707
+ console.log('');
708
+ console.log(' ── Judgment surface ──');
709
+ const axiomCount = (core?.axioms || []).length;
710
+ const ontologyCount = (core?.ontology || []).length;
711
+ const stanceCount = (core?.stances || []).length;
712
+ const misCount = (pat?.misunderstandings || []).length;
713
+ const selfCheckCount = (pat?.self_check || []).length;
714
+ console.log(` Axioms: ${axiomCount}`);
715
+ console.log(` Ontology: ${ontologyCount}`);
716
+ console.log(` Stances: ${stanceCount}`);
717
+ console.log(` Misunderstandings: ${misCount}`);
718
+ console.log(` Self-checks: ${selfCheckCount}`);
719
+
720
+ // ─── v2.1 governance score ─────────────────────────────────────
721
+ if (axiomCount > 0) {
722
+ const withApplies = (core?.axioms || []).filter(
723
+ (a) => Array.isArray(a.applies_when) && a.applies_when.length,
724
+ ).length;
725
+ const withDoesNotApply = (core?.axioms || []).filter(
726
+ (a) => Array.isArray(a.does_not_apply_when) && a.does_not_apply_when.length,
727
+ ).length;
728
+ const withFailureRisk = (core?.axioms || []).filter((a) => a.failure_risk).length;
729
+ const pct = Math.round(
730
+ ((withApplies + withDoesNotApply + withFailureRisk) / (axiomCount * 3)) * 100,
731
+ );
732
+ console.log('');
733
+ console.log(' ── v2.1 governance ──');
734
+ console.log(` axioms with applies_when: ${withApplies}/${axiomCount}`);
735
+ console.log(` axioms with does_not_apply: ${withDoesNotApply}/${axiomCount}`);
736
+ console.log(` axioms with failure_risk: ${withFailureRisk}/${axiomCount}`);
737
+ console.log(` governance coverage: ${pct}%`);
738
+ }
739
+
740
+ // ─── Eval cases ────────────────────────────────────────────────
741
+ const evalDir = path.join(dest, 'evals');
742
+ if (fs.existsSync(evalDir)) {
743
+ const evalFiles = fs.readdirSync(evalDir).filter((f) => f.endsWith('.json'));
744
+ let totalCases = 0;
745
+ for (const f of evalFiles) {
746
+ const data = readJson(path.join(evalDir, f));
747
+ if (data?.cases) totalCases += data.cases.length;
748
+ }
749
+ console.log('');
750
+ console.log(' ── Eval cases ──');
751
+ console.log(` Files: ${evalFiles.length}`);
752
+ console.log(` Total cases: ${totalCases}`);
753
+ }
754
+
755
+ // ─── Known risks (from kdna.json or axioms) ────────────────────
756
+ const risks = [];
757
+ if (core?.axioms) {
758
+ for (const a of core.axioms) {
759
+ if (a.failure_risk) risks.push({ source: a.id, text: a.failure_risk });
760
+ }
761
+ }
762
+ if (risks.length) {
763
+ console.log('');
764
+ console.log(' ── Known failure risks ──');
765
+ for (const r of risks.slice(0, 4)) {
766
+ const short = r.text.length > 110 ? r.text.slice(0, 107) + '…' : r.text;
767
+ console.log(` ⚠ [${r.source}]`);
768
+ console.log(` ${short}`);
769
+ }
770
+ if (risks.length > 4) console.log(` (+ ${risks.length - 4} more — see KDNA_Core.json)`);
771
+ }
772
+
773
+ // ─── Files ─────────────────────────────────────────────────────
774
+ const expected = [
775
+ 'KDNA_Core.json',
776
+ 'KDNA_Patterns.json',
777
+ 'KDNA_Scenarios.json',
778
+ 'KDNA_Cases.json',
779
+ 'KDNA_Reasoning.json',
780
+ 'KDNA_Evolution.json',
781
+ ];
782
+ const present = expected.filter((f) => fs.existsSync(path.join(dest, f)));
783
+ console.log('');
784
+ console.log(` Files: ${present.length}/${expected.length} (${present.join(', ') || 'none'})`);
785
+
786
+ console.log('');
787
+ console.log(` Run 'kdna verify ${parsed.full}' for full structure/trust/judgment scoring.`);
788
+ }
789
+
790
+ // ─── Update ─────────────────────────────────────────────────────────────
791
+
792
+ function cmdUpdate(input) {
793
+ warnLegacy();
794
+ const parsed = parseName(input);
795
+ if (!parsed) error(`Invalid name "${input}".`);
796
+ const dest = domainDir(parsed.scope, parsed.ident);
797
+ if (!fs.existsSync(dest)) {
798
+ console.log(`${parsed.full} not installed. Run: kdna install ${input}`);
799
+ return;
800
+ }
801
+ const manifest = readJson(path.join(dest, 'kdna.json')) || {};
802
+ const installedVersion = manifest.version || manifest._source?.version || '?';
803
+
804
+ const resolver = new RegistryResolver({ allowNetwork: true, refresh: true });
805
+ let entry;
806
+ try {
807
+ ({ entry } = resolver.resolve(parsed.full));
808
+ } catch (e) {
809
+ error(e.message);
810
+ }
811
+
812
+ if (entry.version === installedVersion) {
813
+ console.log(`${parsed.full}@${installedVersion} is up to date.`);
814
+ return;
815
+ }
816
+ console.log(`Updating ${parsed.full}: ${installedVersion} → ${entry.version}`);
817
+ cmdInstallExtended(parsed.full, ['--yes']);
818
+ }
819
+
820
+ function cmdUpdateAll() {
821
+ warnLegacy();
822
+ if (!fs.existsSync(INSTALL_DIR)) {
823
+ console.log('No installs.');
824
+ return;
825
+ }
826
+ const scopes = fs.readdirSync(INSTALL_DIR).filter((d) => d.startsWith('@'));
827
+ for (const scope of scopes) {
828
+ const sd = path.join(INSTALL_DIR, scope);
829
+ if (!fs.statSync(sd).isDirectory()) continue;
830
+ for (const ident of fs.readdirSync(sd)) {
831
+ if (ident.startsWith('.')) continue;
832
+ try {
833
+ cmdUpdate(`${scope}/${ident}`);
834
+ } catch (e) {
835
+ console.warn(` ⚠ ${scope}/${ident}: ${e.message.split('\n')[0]}`);
836
+ }
837
+ }
838
+ console.log('');
839
+ }
840
+ }
841
+
842
+ module.exports = {
843
+ cmdInstallExtended,
844
+ cmdRemove,
845
+ cmdInfo,
846
+ cmdUpdate,
847
+ cmdUpdateAll,
848
+ INSTALL_DIR,
849
+ };