@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 +12 -5
- package/SKILL.md +17 -2
- package/bin/ldm.mjs +163 -100
- package/lib/deploy.mjs +4 -2
- package/lib/safe.mjs +113 -0
- package/lib/state.mjs +306 -0
- package/package.json +1 -1
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
|
|
27
|
-
2. What
|
|
28
|
-
3. What would
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
| **
|
|
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
|
-
// ──
|
|
352
|
+
// ── Auto-detect unregistered extensions ──
|
|
353
353
|
|
|
354
|
-
|
|
354
|
+
function autoDetectExtensions() {
|
|
355
|
+
if (!existsSync(LDM_EXTENSIONS)) return;
|
|
355
356
|
const registry = readJSON(REGISTRY_PATH);
|
|
356
|
-
|
|
357
|
-
const components = loadCatalog();
|
|
357
|
+
if (!registry) return;
|
|
358
358
|
|
|
359
|
-
|
|
359
|
+
const registered = Object.keys(registry.extensions || {});
|
|
360
|
+
let found = 0;
|
|
360
361
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
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
|
|
375
|
-
let idx = 1;
|
|
376
|
-
const selectable = [];
|
|
426
|
+
console.log(' Available in catalog (not yet installed):');
|
|
377
427
|
for (const c of available) {
|
|
378
|
-
|
|
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
|
-
|
|
389
|
-
|
|
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
|
|
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 =
|
|
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 =>
|
|
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
|
-
//
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
|
444
|
-
|
|
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}/${
|
|
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.
|
|
486
|
-
const
|
|
487
|
-
|
|
488
|
-
|
|
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
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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.
|
|
551
|
-
const
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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.
|
|
203
|
+
// 5. Trash the old version (never delete)
|
|
203
204
|
if (existsSync(backupPath)) {
|
|
204
|
-
|
|
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
|
+
}
|