@wipcomputer/wip-ldm-os 0.2.1 → 0.2.3

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 CHANGED
@@ -23,16 +23,23 @@ Open your AI and paste this:
23
23
  Read the SKILL.md at github.com/wipcomputer/wip-ldm-os/blob/main/SKILL.md.
24
24
 
25
25
  Then explain to me:
26
- 1. What is LDM OS?
27
- 2. What does it do?
28
- 3. What would it change about how my AI works?
26
+ 1. What are these tools?
27
+ 2. What do they do?
28
+ 3. What would change about how we work together?
29
+ 4. What would it change about how all my AI work?
30
+
31
+ Then ask me:
32
+ - Do you have more questions?
33
+ - Do you want to install it?
29
34
 
30
35
  If I say yes, run: ldm install --dry-run
31
36
 
32
- Show me exactly what will change. When I'm ready, install for real.
37
+ And show me exactly what will change on my system.
38
+
39
+ When I'm ready, I'll tell you to install for real.
33
40
  ```
34
41
 
35
- That's it. Your AI reads the spec, explains what LDM OS is, and walks you through a dry run before touching anything.
42
+ That's it. Your AI reads the spec, explains what it does, and walks you through a dry run before touching anything.
36
43
 
37
44
  ## Included Skills
38
45
 
package/SKILL.md CHANGED
@@ -5,7 +5,7 @@ license: MIT
5
5
  interface: [cli, skill]
6
6
  metadata:
7
7
  display-name: "LDM OS"
8
- version: "0.2.1"
8
+ version: "0.2.3"
9
9
  homepage: "https://github.com/wipcomputer/wip-ldm-os"
10
10
  author: "Parker Todd Brooks"
11
11
  category: infrastructure
@@ -89,7 +89,13 @@ LDM OS ships with a skill catalog. Show the user what's available:
89
89
  |-------|-----------|--------|
90
90
  | **Memory Crystal** (recommended) | Persistent memory. Search, capture, consolidation. | Stable |
91
91
  | **AI DevOps Toolbox** | Release, deploy, license, repo management. | Stable |
92
- | **Agent Pay** | Micropayments for AI agents. Apple Pay. | Coming Soon |
92
+ | **1Password** | 1Password secrets for AI agents. | Stable |
93
+ | **Markdown Viewer** | Live markdown viewer for AI pair-editing. | Stable |
94
+ | **xAI Grok** | xAI Grok API. Search the web, search X, generate images. | Stable |
95
+ | **X Platform** | X Platform API. Read posts, search tweets, post, upload media. | Stable |
96
+ | **OpenClaw** | AI agent platform. Run AI agents 24/7 with identity, memory, and tool access. | Stable |
97
+ | **Dream Weaver Protocol** | Memory consolidation protocol for AI agents. | Stable |
98
+ | **Bridge** | Cross-platform agent bridge. Claude Code to OpenClaw communication. | Stable |
93
99
 
94
100
  To install a skill:
95
101
  ```bash
@@ -103,6 +109,8 @@ ldm install wipcomputer/memory-crystal
103
109
 
104
110
  The installer detects what a repo supports (CLI, MCP Server, OpenClaw Plugin, Skill, CC Hook, Module) and deploys each interface to the right location automatically.
105
111
 
112
+ **Note:** Skills installed before LDM OS (via `crystal init`, `wip-install`, or manual setup) may not appear in the registry. Run `ldm install <org/repo>` to re-register them.
113
+
106
114
  ### Step 3: Verify
107
115
 
108
116
  ```bash
@@ -158,5 +166,12 @@ LDM OS is the runtime. Skills plug into it:
158
166
 
159
167
  - **Memory Crystal** ... `wipcomputer/memory-crystal`
160
168
  - **AI DevOps Toolbox** ... `wipcomputer/wip-ai-devops-toolbox`
169
+ - **1Password** ... `wipcomputer/wip-1password`
170
+ - **Markdown Viewer** ... `wipcomputer/wip-markdown-viewer`
171
+ - **xAI Grok** ... `wipcomputer/wip-xai-grok`
172
+ - **X Platform** ... `wipcomputer/wip-xai-x`
173
+ - **OpenClaw** ... `openclaw/openclaw`
174
+ - **Dream Weaver Protocol** ... `wipcomputer/dream-weaver-protocol`
175
+ - **Bridge** ... `wipcomputer/wip-bridge`
161
176
 
162
177
  Run `ldm install` anytime to add more skills.
package/bin/ldm.mjs CHANGED
@@ -349,48 +349,132 @@ async function cmdInstall() {
349
349
  await installFromPath(repoPath);
350
350
  }
351
351
 
352
- // ── ldm install (bare): show catalog + update registered ──
352
+ // ── Auto-detect unregistered extensions ──
353
353
 
354
- async function cmdInstallCatalog() {
354
+ function autoDetectExtensions() {
355
+ if (!existsSync(LDM_EXTENSIONS)) return;
355
356
  const registry = readJSON(REGISTRY_PATH);
356
- const installed = Object.keys(registry?.extensions || {});
357
- const components = loadCatalog();
357
+ if (!registry) return;
358
358
 
359
- console.log('');
359
+ const registered = Object.keys(registry.extensions || {});
360
+ let found = 0;
360
361
 
361
- // Show installed
362
- if (installed.length > 0) {
363
- console.log(' Installed components:');
364
- for (const name of installed) {
365
- const info = registry.extensions[name];
366
- console.log(` [x] ${name} v${info?.version || '?'}`);
362
+ try {
363
+ const dirs = readdirSync(LDM_EXTENSIONS, { withFileTypes: true });
364
+ for (const dir of dirs) {
365
+ if (!dir.isDirectory()) continue;
366
+ if (dir.name === '_trash' || dir.name.startsWith('.')) continue;
367
+
368
+ const extPath = join(LDM_EXTENSIONS, dir.name);
369
+ const pkgPath = join(extPath, 'package.json');
370
+ if (!existsSync(pkgPath)) continue;
371
+
372
+ // Check if already registered (by directory name or by ldmPath)
373
+ const alreadyRegistered = registered.some(name => {
374
+ const info = registry.extensions[name];
375
+ return name === dir.name || info?.ldmPath === extPath;
376
+ });
377
+ if (alreadyRegistered) continue;
378
+
379
+ // Auto-register
380
+ const pkg = readJSON(pkgPath);
381
+ if (!pkg) continue;
382
+
383
+ registry.extensions[dir.name] = {
384
+ name: dir.name,
385
+ version: pkg.version || '?',
386
+ source: null,
387
+ interfaces: [],
388
+ ldmPath: extPath,
389
+ updatedAt: new Date().toISOString(),
390
+ autoDetected: true,
391
+ };
392
+ found++;
367
393
  }
368
- console.log('');
394
+ } catch {}
395
+
396
+ if (found > 0) {
397
+ writeJSON(REGISTRY_PATH, registry);
369
398
  }
399
+ return found;
400
+ }
401
+
402
+ // ── ldm install (bare): scan system, show real state, update if needed ──
403
+
404
+ async function cmdInstallCatalog() {
405
+ autoDetectExtensions();
406
+
407
+ const { detectSystemState, reconcileState, formatReconciliation } = await import('../lib/state.mjs');
408
+ const state = detectSystemState();
409
+ const reconciled = reconcileState(state);
410
+
411
+ // Show the real system state
412
+ console.log(formatReconciliation(reconciled));
413
+
414
+ // Show catalog items not yet managed
415
+ const registry = readJSON(REGISTRY_PATH);
416
+ const registeredNames = Object.keys(registry?.extensions || {});
417
+ const components = loadCatalog();
418
+ const available = components.filter(c =>
419
+ c.status !== 'coming-soon'
420
+ && !registeredNames.includes(c.id)
421
+ // Also check if it's in reconciled as external (already installed outside LDM)
422
+ && !reconciled[c.id]
423
+ );
370
424
 
371
- // Show available (not installed)
372
- const available = components.filter(c => !installed.includes(c.id));
373
425
  if (available.length > 0) {
374
- console.log(' Available components:');
375
- let idx = 1;
376
- const selectable = [];
426
+ console.log(' Available in catalog (not yet installed):');
377
427
  for (const c of available) {
378
- if (c.status === 'coming-soon') {
379
- console.log(` [ ] ${c.name} (coming soon)`);
380
- } else {
381
- console.log(` [ ] ${c.name}`);
382
- selectable.push(c);
383
- }
384
- idx++;
428
+ console.log(` [ ] ${c.name} ... ${c.description}`);
385
429
  }
386
430
  console.log('');
431
+ }
387
432
 
388
- // Interactive prompt if TTY and not --yes/--none
389
- if (selectable.length > 0 && !YES_FLAG && !NONE_FLAG && !DRY_RUN && process.stdin.isTTY) {
433
+ if (DRY_RUN) {
434
+ // Show what an update would do
435
+ const updatable = Object.values(reconciled).filter(e =>
436
+ e.status === 'healthy' && e.registryHasSource
437
+ );
438
+ const unlinked = Object.values(reconciled).filter(e =>
439
+ e.status === 'installed-unlinked'
440
+ );
441
+
442
+ if (updatable.length > 0) {
443
+ console.log(` Would update ${updatable.length} extension(s) from source repos.`);
444
+ console.log(' No data (crystal.db, secrets, agent files) would be touched.');
445
+ console.log(' Old versions would be moved to ~/.ldm/_trash/ (never deleted).');
446
+ } else {
447
+ console.log(' Nothing to update from source repos.');
448
+ }
449
+
450
+ if (unlinked.length > 0) {
451
+ console.log('');
452
+ console.log(` ${unlinked.length} extension(s) are installed but have no source repo linked.`);
453
+ console.log(' These are safe. Link them with: ldm install <org/repo>');
454
+ }
455
+
456
+ console.log('');
457
+ console.log(' Dry run complete. No changes made.');
458
+ console.log('');
459
+ return;
460
+ }
461
+
462
+ // Real update: only touch things with linked source repos
463
+ const updatable = Object.values(reconciled).filter(e =>
464
+ e.registryHasSource
465
+ );
466
+
467
+ if (updatable.length === 0) {
468
+ console.log(' No extensions have linked source repos to update from.');
469
+ console.log(' Link them with: ldm install <org/repo>');
470
+ console.log('');
471
+
472
+ // Still offer catalog install if TTY
473
+ if (available.length > 0 && !YES_FLAG && !NONE_FLAG && process.stdin.isTTY) {
390
474
  const { createInterface } = await import('node:readline');
391
475
  const rl = createInterface({ input: process.stdin, output: process.stdout });
392
476
  const answer = await new Promise((resolve) => {
393
- rl.question(' Install more? [number,all,none]: ', (a) => {
477
+ rl.question(' Install from catalog? [number,all,none]: ', (a) => {
394
478
  rl.close();
395
479
  resolve(a.trim().toLowerCase());
396
480
  });
@@ -399,10 +483,10 @@ async function cmdInstallCatalog() {
399
483
  if (answer && answer !== 'none' && answer !== 'n') {
400
484
  let toInstall = [];
401
485
  if (answer === 'all' || answer === 'a') {
402
- toInstall = selectable;
486
+ toInstall = available;
403
487
  } else {
404
488
  const nums = answer.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n));
405
- toInstall = nums.map(n => selectable[n - 1]).filter(Boolean);
489
+ toInstall = nums.map(n => available[n - 1]).filter(Boolean);
406
490
  }
407
491
  for (const c of toInstall) {
408
492
  console.log(` Installing ${c.name}...`);
@@ -414,55 +498,55 @@ async function cmdInstallCatalog() {
414
498
  }
415
499
  }
416
500
  }
501
+ return;
417
502
  }
418
503
 
419
- // Update installed extensions
420
- if (installed.length > 0) {
421
- await cmdUpdateAll();
422
- }
423
-
424
- if (installed.length === 0 && available.filter(c => c.status !== 'coming-soon').length === 0) {
425
- console.log(' No extensions registered. Nothing to update.');
426
- console.log(' Use: ldm install <org/repo> or ldm install /path/to/repo');
427
- console.log('');
428
- }
429
- }
430
-
431
- async function cmdUpdateAll() {
432
- const registry = readJSON(REGISTRY_PATH);
433
- if (!registry?.extensions || Object.keys(registry.extensions).length === 0) return;
504
+ // Write revert manifest before starting
505
+ const { createRevertManifest } = await import('../lib/safe.mjs');
506
+ const manifestPath = createRevertManifest(
507
+ `ldm install (update ${updatable.length} extensions)`,
508
+ updatable.map(e => ({
509
+ action: 'update',
510
+ name: e.name,
511
+ currentVersion: e.ldmVersion || e.registryVersion,
512
+ source: e.registrySource,
513
+ }))
514
+ );
515
+ console.log(` Revert plan saved: ${manifestPath}`);
516
+ console.log('');
434
517
 
435
518
  const { setFlags, installFromPath } = await import('../lib/deploy.mjs');
436
519
  setFlags({ dryRun: DRY_RUN, jsonOutput: JSON_OUTPUT });
437
520
 
438
- const extensions = Object.entries(registry.extensions);
439
- console.log(` Updating ${extensions.length} registered extension(s)...`);
440
- console.log('');
441
-
442
521
  let updated = 0;
443
- for (const [name, info] of extensions) {
444
- const source = info.source;
445
- if (!source || !existsSync(source)) {
446
- console.log(` x ${name}: source not found at ${source || '(none)'}`);
447
- continue;
448
- }
449
-
450
- await installFromPath(source);
522
+ for (const entry of updatable) {
523
+ await installFromPath(entry.registrySource);
451
524
  updated++;
452
525
  }
453
526
 
454
527
  console.log('');
455
- console.log(` Updated ${updated}/${extensions.length} extension(s).`);
528
+ console.log(` Updated ${updated}/${updatable.length} extension(s).`);
456
529
  console.log('');
457
530
  }
458
531
 
532
+ async function cmdUpdateAll() {
533
+ // Delegate to the catalog flow which now has full state awareness
534
+ return cmdInstallCatalog();
535
+ }
536
+
459
537
  // ── ldm doctor ──
460
538
 
461
- function cmdDoctor() {
539
+ async function cmdDoctor() {
462
540
  console.log('');
463
541
  console.log(' ldm doctor');
464
542
  console.log(' ────────────────────────────────────');
465
543
 
544
+ // Auto-detect unregistered extensions before checking
545
+ const detected = autoDetectExtensions();
546
+ if (detected > 0) {
547
+ console.log(` + Auto-detected ${detected} unregistered extension(s)`);
548
+ }
549
+
466
550
  let issues = 0;
467
551
 
468
552
  // 1. Check LDM root
@@ -482,43 +566,19 @@ function cmdDoctor() {
482
566
  console.log(` + version.json: v${version.version} (installed ${version.installed?.split('T')[0]})`);
483
567
  }
484
568
 
485
- // 3. Check registry
486
- const registry = readJSON(REGISTRY_PATH);
487
- if (!registry) {
488
- console.log(' x registry.json missing');
489
- issues++;
490
- } else {
491
- const extCount = Object.keys(registry.extensions || {}).length;
492
- console.log(` + registry.json: ${extCount} extension(s)`);
493
-
494
- // Check each extension
495
- for (const [name, info] of Object.entries(registry.extensions || {})) {
496
- const ldmPath = info.ldmPath || join(LDM_EXTENSIONS, name);
497
- const hasLdm = existsSync(ldmPath);
498
- const hasPkg = hasLdm && existsSync(join(ldmPath, 'package.json'));
499
-
500
- if (!hasLdm) {
501
- console.log(` x ${name}: not found at ${ldmPath}`);
502
- issues++;
503
- } else if (!hasPkg) {
504
- console.log(` x ${name}: deployed but missing package.json`);
505
- issues++;
506
- } else {
507
- const pkg = readJSON(join(ldmPath, 'package.json'));
508
- const ver = pkg?.version || '?';
509
- const registeredVer = info.version || '?';
510
- if (ver !== registeredVer) {
511
- console.log(` ! ${name}: deployed v${ver} but registry says v${registeredVer}`);
512
- } else {
513
- console.log(` + ${name}: v${ver}`);
514
- }
515
- }
569
+ // 3. Full system state scan
570
+ const { detectSystemState, reconcileState, formatReconciliation } = await import('../lib/state.mjs');
571
+ const state = detectSystemState();
572
+ const reconciled = reconcileState(state);
516
573
 
517
- // Check OC copy
518
- if (info.ocPath && !existsSync(info.ocPath)) {
519
- console.log(` x OpenClaw copy missing at ${info.ocPath}`);
520
- issues++;
521
- }
574
+ // Show reconciled view
575
+ console.log(formatReconciliation(reconciled, { verbose: true }));
576
+
577
+ // Count issues from reconciliation
578
+ for (const entry of Object.values(reconciled)) {
579
+ if (entry.status === 'registered-missing') issues++;
580
+ if (entry.issues.length > 0 && entry.status !== 'installed-unlinked' && entry.status !== 'external') {
581
+ issues += entry.issues.length;
522
582
  }
523
583
  }
524
584
 
@@ -534,6 +594,7 @@ function cmdDoctor() {
534
594
  console.log(` + ${s.label} exists`);
535
595
  } else {
536
596
  console.log(` ! ${s.label} missing (run: ldm init)`);
597
+ issues++;
537
598
  }
538
599
  }
539
600
 
@@ -547,12 +608,14 @@ function cmdDoctor() {
547
608
  console.log(` - Claude Code hooks: none configured`);
548
609
  }
549
610
 
550
- // 6. Check MCP servers
551
- const ccUserPath = join(HOME, '.claude.json');
552
- const ccUser = readJSON(ccUserPath);
553
- if (ccUser?.mcpServers) {
554
- const mcpCount = Object.keys(ccUser.mcpServers).length;
555
- console.log(` + MCP servers (user): ${mcpCount} registered`);
611
+ // 6. MCP servers
612
+ const mcpCount = Object.keys(state.mcp).length;
613
+ console.log(` + MCP servers: ${mcpCount} registered`);
614
+
615
+ // 7. CLI binaries
616
+ const binCount = Object.keys(state.cliBinaries).length;
617
+ if (binCount > 0) {
618
+ console.log(` + CLI binaries: ${Object.keys(state.cliBinaries).join(', ')}`);
556
619
  }
557
620
 
558
621
  console.log('');
package/lib/deploy.mjs CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  import { join, basename, resolve, dirname } from 'node:path';
18
18
  import { tmpdir } from 'node:os';
19
19
  import { detectInterfaces, describeInterfaces, detectToolbox } from './detect.mjs';
20
+ import { moveToTrash, appendToManifest } from './safe.mjs';
20
21
 
21
22
  const HOME = process.env.HOME || '';
22
23
  const LDM_ROOT = join(HOME, '.ldm');
@@ -199,9 +200,10 @@ function safeDeployDir(repoPath, destDir, name) {
199
200
  }
200
201
  renameSync(tempPath, finalPath);
201
202
 
202
- // 5. Clean up backup
203
+ // 5. Trash the old version (never delete)
203
204
  if (existsSync(backupPath)) {
204
- rmSync(backupPath, { recursive: true, force: true });
205
+ const trashed = moveToTrash(backupPath);
206
+ if (trashed) ok(`Old version moved to ${trashed}`);
205
207
  }
206
208
 
207
209
  return true;
package/lib/safe.mjs ADDED
@@ -0,0 +1,113 @@
1
+ /**
2
+ * lib/safe.mjs
3
+ * Safe file operations. Never delete. Always trash. Always write revert plans.
4
+ * Follows the _trash/ pattern from the DevOps Toolkit.
5
+ * Zero dependencies.
6
+ */
7
+
8
+ import { existsSync, mkdirSync, renameSync, writeFileSync, readFileSync, cpSync } from 'node:fs';
9
+ import { join, basename, dirname } from 'node:path';
10
+
11
+ const HOME = process.env.HOME || '';
12
+ const LDM_ROOT = join(HOME, '.ldm');
13
+ const TRASH_ROOT = join(LDM_ROOT, '_trash');
14
+
15
+ function readJSON(path) {
16
+ try {
17
+ return JSON.parse(readFileSync(path, 'utf8'));
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ // ── Trash ──
24
+
25
+ /**
26
+ * Move a file or directory to ~/.ldm/_trash/YYYY-MM-DD/ instead of deleting.
27
+ * Returns the trash destination path, or null if source didn't exist.
28
+ */
29
+ export function moveToTrash(sourcePath) {
30
+ if (!existsSync(sourcePath)) return null;
31
+
32
+ const date = new Date().toISOString().split('T')[0];
33
+ const trashDir = join(TRASH_ROOT, date);
34
+ const name = basename(sourcePath);
35
+
36
+ mkdirSync(trashDir, { recursive: true });
37
+
38
+ let dest = join(trashDir, name);
39
+ if (existsSync(dest)) {
40
+ dest = join(trashDir, `${name}-${Date.now()}`);
41
+ }
42
+
43
+ renameSync(sourcePath, dest);
44
+ return dest;
45
+ }
46
+
47
+ // ── Revert Manifests ──
48
+
49
+ /**
50
+ * Create a revert manifest before a batch of operations.
51
+ * Each operation: { action, name, description, originalPath, trashPath }
52
+ * Returns the manifest file path.
53
+ */
54
+ export function createRevertManifest(description, operations = []) {
55
+ const date = new Date().toISOString().split('T')[0];
56
+ const time = new Date().toISOString().replace(/[:.]/g, '-');
57
+ const manifestDir = join(TRASH_ROOT, date);
58
+ const manifestPath = join(manifestDir, `revert-${time}.json`);
59
+
60
+ mkdirSync(manifestDir, { recursive: true });
61
+
62
+ const manifest = {
63
+ timestamp: new Date().toISOString(),
64
+ description,
65
+ operations,
66
+ howToRevert: 'For each operation with a trashPath, move it back to originalPath.',
67
+ };
68
+
69
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
70
+ return manifestPath;
71
+ }
72
+
73
+ /**
74
+ * Append a completed operation to an existing revert manifest.
75
+ */
76
+ export function appendToManifest(manifestPath, operation) {
77
+ if (!existsSync(manifestPath)) return;
78
+
79
+ try {
80
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
81
+ manifest.operations.push(operation);
82
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
83
+ } catch {}
84
+ }
85
+
86
+ // ── Safe copy (backup before overwrite) ──
87
+
88
+ /**
89
+ * Copy a directory, but if the destination already exists, trash it first.
90
+ * Returns { trashPath, destPath } or null on failure.
91
+ */
92
+ export function safeCopy(srcPath, destPath) {
93
+ let trashPath = null;
94
+
95
+ if (existsSync(destPath)) {
96
+ trashPath = moveToTrash(destPath);
97
+ }
98
+
99
+ try {
100
+ mkdirSync(dirname(destPath), { recursive: true });
101
+ cpSync(srcPath, destPath, {
102
+ recursive: true,
103
+ filter: (s) => !s.includes('.git') && !s.includes('node_modules') && !s.includes('/ai/'),
104
+ });
105
+ return { trashPath, destPath };
106
+ } catch (e) {
107
+ // Rollback: restore from trash if copy failed
108
+ if (trashPath && existsSync(trashPath) && !existsSync(destPath)) {
109
+ try { renameSync(trashPath, destPath); } catch {}
110
+ }
111
+ throw e;
112
+ }
113
+ }
package/lib/state.mjs ADDED
@@ -0,0 +1,306 @@
1
+ /**
2
+ * lib/state.mjs
3
+ * System state detection and reconciliation.
4
+ * Scans the actual system (MCP servers, extensions, CLIs) to find what's
5
+ * really installed, regardless of what the LDM registry thinks.
6
+ * Zero dependencies.
7
+ */
8
+
9
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
10
+ import { join, basename } from 'node:path';
11
+ import { execSync } from 'node:child_process';
12
+
13
+ const HOME = process.env.HOME || '';
14
+ const LDM_ROOT = join(HOME, '.ldm');
15
+ const LDM_EXTENSIONS = join(LDM_ROOT, 'extensions');
16
+ const OC_ROOT = join(HOME, '.openclaw');
17
+ const OC_EXTENSIONS = join(OC_ROOT, 'extensions');
18
+ const CC_USER_PATH = join(HOME, '.claude.json');
19
+ const REGISTRY_PATH = join(LDM_EXTENSIONS, 'registry.json');
20
+
21
+ function readJSON(path) {
22
+ try {
23
+ return JSON.parse(readFileSync(path, 'utf8'));
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ // ── Scanners ──
30
+
31
+ function detectMCPServers() {
32
+ const ccUser = readJSON(CC_USER_PATH);
33
+ const servers = {};
34
+ if (!ccUser?.mcpServers) return servers;
35
+
36
+ for (const [name, config] of Object.entries(ccUser.mcpServers)) {
37
+ const args = config.args || [];
38
+ servers[name] = {
39
+ name,
40
+ command: config.command,
41
+ args,
42
+ path: args[0] || null,
43
+ env: config.env || {},
44
+ };
45
+ }
46
+ return servers;
47
+ }
48
+
49
+ function scanExtensionDir(dir) {
50
+ const extensions = {};
51
+ if (!existsSync(dir)) return extensions;
52
+
53
+ try {
54
+ const entries = readdirSync(dir, { withFileTypes: true });
55
+ for (const entry of entries) {
56
+ if (entry.name === 'registry.json') continue;
57
+ if (entry.name.startsWith('.') || entry.name.startsWith('_')) continue;
58
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
59
+
60
+ const dirPath = join(dir, entry.name);
61
+ const pkg = readJSON(join(dirPath, 'package.json'));
62
+ const plugin = readJSON(join(dirPath, 'openclaw.plugin.json'));
63
+
64
+ extensions[entry.name] = {
65
+ name: entry.name,
66
+ path: dirPath,
67
+ version: pkg?.version || null,
68
+ packageName: pkg?.name || null,
69
+ pluginId: plugin?.id || null,
70
+ hasPackageJson: !!pkg,
71
+ hasPluginJson: !!plugin,
72
+ hasMcpServer: existsSync(join(dirPath, 'mcp-server.mjs'))
73
+ || existsSync(join(dirPath, 'mcp-server.js'))
74
+ || existsSync(join(dirPath, 'dist', 'mcp-server.js')),
75
+ hasSkill: existsSync(join(dirPath, 'SKILL.md')),
76
+ isSymlink: entry.isSymbolicLink(),
77
+ };
78
+ }
79
+ } catch {}
80
+ return extensions;
81
+ }
82
+
83
+ function detectCLIBinaries() {
84
+ const knownBins = [
85
+ 'crystal', 'mdview', 'wip-release', 'wip-repos', 'wip-file-guard',
86
+ 'ldm', 'ldm-scaffold', 'wip-install', 'wip-license',
87
+ ];
88
+
89
+ const binaries = {};
90
+ for (const bin of knownBins) {
91
+ try {
92
+ const path = execSync(`which ${bin} 2>/dev/null`, { encoding: 'utf8' }).trim();
93
+ if (path) {
94
+ binaries[bin] = { path };
95
+ try {
96
+ const ver = execSync(`${bin} --version 2>/dev/null`, { encoding: 'utf8' }).trim().split('\n')[0];
97
+ binaries[bin].version = ver;
98
+ } catch {}
99
+ }
100
+ } catch {}
101
+ }
102
+ return binaries;
103
+ }
104
+
105
+ function detectSkills() {
106
+ const skillsDir = join(OC_ROOT, 'skills');
107
+ const skills = {};
108
+ if (!existsSync(skillsDir)) return skills;
109
+
110
+ try {
111
+ const entries = readdirSync(skillsDir, { withFileTypes: true });
112
+ for (const entry of entries) {
113
+ if (!entry.isDirectory()) continue;
114
+ const skillPath = join(skillsDir, entry.name, 'SKILL.md');
115
+ if (existsSync(skillPath)) {
116
+ skills[entry.name] = { path: skillPath };
117
+ }
118
+ }
119
+ } catch {}
120
+ return skills;
121
+ }
122
+
123
+ // ── Main detection ──
124
+
125
+ export function detectSystemState() {
126
+ const registry = readJSON(REGISTRY_PATH) || { _format: 'v1', extensions: {} };
127
+
128
+ return {
129
+ mcp: detectMCPServers(),
130
+ ldmExtensions: scanExtensionDir(LDM_EXTENSIONS),
131
+ ocExtensions: scanExtensionDir(OC_EXTENSIONS),
132
+ cliBinaries: detectCLIBinaries(),
133
+ skills: detectSkills(),
134
+ registry: registry.extensions || {},
135
+ };
136
+ }
137
+
138
+ // ── Reconciliation ──
139
+
140
+ export function reconcileState(systemState) {
141
+ const all = new Set();
142
+
143
+ // Collect all known names from every source
144
+ for (const name of Object.keys(systemState.registry)) all.add(name);
145
+ for (const name of Object.keys(systemState.ldmExtensions)) all.add(name);
146
+ for (const name of Object.keys(systemState.ocExtensions)) all.add(name);
147
+ for (const name of Object.keys(systemState.mcp)) all.add(name);
148
+
149
+ const reconciled = {};
150
+
151
+ for (const name of all) {
152
+ const reg = systemState.registry[name];
153
+ const ldm = systemState.ldmExtensions[name];
154
+ const oc = systemState.ocExtensions[name];
155
+ const mcp = systemState.mcp[name];
156
+
157
+ const entry = {
158
+ name,
159
+ // Registry
160
+ inRegistry: !!reg,
161
+ registryVersion: reg?.version || null,
162
+ registrySource: reg?.source || null,
163
+ registryHasSource: !!(reg?.source && existsSync(reg.source)),
164
+ registryInterfaces: reg?.interfaces || [],
165
+ // Deployed
166
+ deployedLdm: !!ldm,
167
+ ldmVersion: ldm?.version || null,
168
+ deployedOc: !!oc,
169
+ ocVersion: oc?.version || null,
170
+ // MCP
171
+ mcpRegistered: !!mcp,
172
+ mcpPath: mcp?.path || null,
173
+ // Computed
174
+ status: 'unknown',
175
+ issues: [],
176
+ };
177
+
178
+ // ── Determine status ──
179
+
180
+ if (entry.inRegistry && entry.deployedLdm && entry.registryHasSource) {
181
+ entry.status = 'healthy';
182
+ } else if (entry.inRegistry && (entry.deployedLdm || entry.deployedOc) && !entry.registryHasSource) {
183
+ // Deployed and registered, but no source repo linked
184
+ entry.status = 'installed-unlinked';
185
+ if (!reg?.source) {
186
+ entry.issues.push('No source repo linked. Run: ldm install <org/repo> to link.');
187
+ } else {
188
+ entry.issues.push(`Source not found at: ${reg.source}`);
189
+ }
190
+ } else if (entry.inRegistry && !entry.deployedLdm && !entry.deployedOc) {
191
+ entry.status = 'registered-missing';
192
+ entry.issues.push('In registry but not deployed anywhere.');
193
+ } else if (!entry.inRegistry && (entry.deployedLdm || entry.deployedOc)) {
194
+ entry.status = 'deployed-unregistered';
195
+ entry.issues.push('Deployed but not in LDM registry.');
196
+ } else if (!entry.inRegistry && !entry.deployedLdm && !entry.deployedOc && entry.mcpRegistered) {
197
+ entry.status = 'mcp-only';
198
+ entry.issues.push('MCP server registered but not managed by LDM.');
199
+ }
200
+
201
+ // Version mismatches
202
+ if (entry.ldmVersion && entry.ocVersion && entry.ldmVersion !== entry.ocVersion) {
203
+ entry.issues.push(`Version mismatch: LDM v${entry.ldmVersion} vs OC v${entry.ocVersion}`);
204
+ }
205
+ if (entry.registryVersion && entry.ldmVersion && entry.registryVersion !== entry.ldmVersion) {
206
+ entry.issues.push(`Registry says v${entry.registryVersion} but deployed is v${entry.ldmVersion}`);
207
+ }
208
+
209
+ // MCP path sanity
210
+ if (entry.mcpRegistered && entry.deployedLdm && entry.mcpPath) {
211
+ const expectedBase = join(LDM_EXTENSIONS, name);
212
+ if (!entry.mcpPath.startsWith(expectedBase)) {
213
+ entry.issues.push(`MCP path does not match LDM extension location.`);
214
+ }
215
+ }
216
+
217
+ reconciled[name] = entry;
218
+ }
219
+
220
+ return reconciled;
221
+ }
222
+
223
+ // ── Display ──
224
+
225
+ export function formatReconciliation(reconciled, { verbose = false } = {}) {
226
+ const healthy = [];
227
+ const unlinked = [];
228
+ const needsAttention = [];
229
+ const external = [];
230
+
231
+ for (const entry of Object.values(reconciled)) {
232
+ switch (entry.status) {
233
+ case 'healthy':
234
+ healthy.push(entry);
235
+ break;
236
+ case 'installed-unlinked':
237
+ unlinked.push(entry);
238
+ break;
239
+ case 'mcp-only':
240
+ case 'deployed-unregistered':
241
+ external.push(entry);
242
+ break;
243
+ default:
244
+ needsAttention.push(entry);
245
+ }
246
+ }
247
+
248
+ const sort = (a, b) => a.name.localeCompare(b.name);
249
+ const lines = [];
250
+
251
+ lines.push('');
252
+ lines.push(' System State');
253
+ lines.push(' ────────────────────────────────────');
254
+
255
+ if (healthy.length > 0) {
256
+ lines.push('');
257
+ lines.push(` Managed by LDM (${healthy.length}):`);
258
+ for (const e of healthy.sort(sort)) {
259
+ const ver = e.ldmVersion || e.registryVersion || '?';
260
+ const ifaces = e.registryInterfaces.length > 0 ? ` (${e.registryInterfaces.join(', ')})` : '';
261
+ const mcp = e.mcpRegistered ? ' [MCP]' : '';
262
+ lines.push(` [x] ${e.name} v${ver}${ifaces}${mcp}`);
263
+ }
264
+ }
265
+
266
+ if (unlinked.length > 0) {
267
+ lines.push('');
268
+ lines.push(` Installed, no source repo linked (${unlinked.length}):`);
269
+ lines.push(` (These work fine. Link them to enable updates via ldm install.)`);
270
+ for (const e of unlinked.sort(sort)) {
271
+ const ver = e.ldmVersion || e.ocVersion || e.registryVersion || '?';
272
+ const mcp = e.mcpRegistered ? ' [MCP]' : '';
273
+ lines.push(` [-] ${e.name} v${ver}${mcp}`);
274
+ if (verbose) {
275
+ for (const issue of e.issues) {
276
+ lines.push(` ${issue}`);
277
+ }
278
+ }
279
+ }
280
+ }
281
+
282
+ if (external.length > 0) {
283
+ lines.push('');
284
+ lines.push(` Not managed by LDM (${external.length}):`);
285
+ for (const e of external.sort(sort)) {
286
+ const ver = e.ldmVersion || e.ocVersion || '?';
287
+ const detail = e.mcpRegistered ? 'MCP registered' : 'extension deployed';
288
+ lines.push(` [ ] ${e.name} v${ver} ... ${detail}`);
289
+ }
290
+ }
291
+
292
+ if (needsAttention.length > 0) {
293
+ lines.push('');
294
+ lines.push(` Needs attention (${needsAttention.length}):`);
295
+ for (const e of needsAttention.sort(sort)) {
296
+ const ver = e.ldmVersion || e.ocVersion || e.registryVersion || '?';
297
+ lines.push(` [!] ${e.name} v${ver} ... ${e.status}`);
298
+ for (const issue of e.issues) {
299
+ lines.push(` ${issue}`);
300
+ }
301
+ }
302
+ }
303
+
304
+ lines.push('');
305
+ return lines.join('\n');
306
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wipcomputer/wip-ldm-os",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "type": "module",
5
5
  "description": "LDM OS: identity, memory, and sovereignty infrastructure for AI agents",
6
6
  "main": "src/boot/boot-hook.mjs",