@wipcomputer/wip-ldm-os 0.2.1

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/lib/deploy.mjs ADDED
@@ -0,0 +1,697 @@
1
+ /**
2
+ * lib/deploy.mjs
3
+ * Deployment engine for LDM OS extensions.
4
+ * Adapted from wip-universal-installer/install.js with three bugs fixed:
5
+ * #6: Runs build step for TypeScript extensions
6
+ * #7: Never rm -rf. Uses incremental copy (deploy to temp, verify, swap).
7
+ * #8: Respects OpenClaw plugin directory naming from config.
8
+ * Zero external dependencies.
9
+ */
10
+
11
+ import { execSync } from 'node:child_process';
12
+ import {
13
+ existsSync, readFileSync, writeFileSync, cpSync, mkdirSync,
14
+ lstatSync, readlinkSync, unlinkSync, chmodSync, readdirSync,
15
+ renameSync, rmSync, statSync,
16
+ } from 'node:fs';
17
+ import { join, basename, resolve, dirname } from 'node:path';
18
+ import { tmpdir } from 'node:os';
19
+ import { detectInterfaces, describeInterfaces, detectToolbox } from './detect.mjs';
20
+
21
+ const HOME = process.env.HOME || '';
22
+ const LDM_ROOT = join(HOME, '.ldm');
23
+ const LDM_EXTENSIONS = join(LDM_ROOT, 'extensions');
24
+ const OC_ROOT = join(HOME, '.openclaw');
25
+ const OC_EXTENSIONS = join(OC_ROOT, 'extensions');
26
+ const REGISTRY_PATH = join(LDM_EXTENSIONS, 'registry.json');
27
+
28
+ // ── Logging ──
29
+
30
+ let DRY_RUN = false;
31
+ let JSON_OUTPUT = false;
32
+
33
+ export function setFlags(opts = {}) {
34
+ DRY_RUN = opts.dryRun || false;
35
+ JSON_OUTPUT = opts.jsonOutput || false;
36
+ }
37
+
38
+ function log(msg) { if (!JSON_OUTPUT) console.log(` ${msg}`); }
39
+ function ok(msg) { if (!JSON_OUTPUT) console.log(` + ${msg}`); }
40
+ function skip(msg) { if (!JSON_OUTPUT) console.log(` - ${msg}`); }
41
+ function fail(msg) { if (!JSON_OUTPUT) console.error(` x ${msg}`); }
42
+
43
+ // ── Helpers ──
44
+
45
+ function readJSON(path) {
46
+ try {
47
+ return JSON.parse(readFileSync(path, 'utf8'));
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ function writeJSON(path, data) {
54
+ mkdirSync(dirname(path), { recursive: true });
55
+ writeFileSync(path, JSON.stringify(data, null, 2) + '\n');
56
+ }
57
+
58
+ function ensureBinExecutable(binNames) {
59
+ try {
60
+ const npmPrefix = execSync('npm config get prefix', { encoding: 'utf8' }).trim();
61
+ for (const bin of binNames) {
62
+ const binPath = join(npmPrefix, 'bin', bin);
63
+ try { chmodSync(binPath, 0o755); } catch {}
64
+ }
65
+ } catch {}
66
+ }
67
+
68
+ // ── Registry ──
69
+
70
+ function loadRegistry() {
71
+ return readJSON(REGISTRY_PATH) || { _format: 'v1', extensions: {} };
72
+ }
73
+
74
+ function saveRegistry(registry) {
75
+ writeJSON(REGISTRY_PATH, registry);
76
+ }
77
+
78
+ function updateRegistry(name, info) {
79
+ const registry = loadRegistry();
80
+ registry.extensions[name] = {
81
+ ...registry.extensions[name],
82
+ ...info,
83
+ updatedAt: new Date().toISOString(),
84
+ };
85
+ saveRegistry(registry);
86
+ }
87
+
88
+ // ── Migration detection ──
89
+
90
+ function findExistingInstalls(toolName, pkg, ocPluginConfig) {
91
+ const matches = [];
92
+ const packageName = pkg?.name;
93
+ const pluginId = ocPluginConfig?.id;
94
+
95
+ for (const extDir of [LDM_EXTENSIONS, OC_EXTENSIONS]) {
96
+ if (!existsSync(extDir)) continue;
97
+ let entries;
98
+ try {
99
+ entries = readdirSync(extDir, { withFileTypes: true });
100
+ } catch { continue; }
101
+
102
+ for (const entry of entries) {
103
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
104
+ const dirName = entry.name;
105
+ if (dirName === toolName) continue;
106
+ if (dirName === 'registry.json') continue;
107
+
108
+ const dirPath = join(extDir, dirName);
109
+
110
+ if (packageName) {
111
+ const dirPkg = readJSON(join(dirPath, 'package.json'));
112
+ if (dirPkg?.name === packageName) {
113
+ if (!matches.some(m => m.dirName === dirName)) {
114
+ matches.push({ dirName, matchType: 'package', path: dirPath });
115
+ }
116
+ continue;
117
+ }
118
+ }
119
+
120
+ if (pluginId) {
121
+ const dirPlugin = readJSON(join(dirPath, 'openclaw.plugin.json'));
122
+ if (dirPlugin?.id === pluginId) {
123
+ if (!matches.some(m => m.dirName === dirName)) {
124
+ matches.push({ dirName, matchType: 'plugin-id', path: dirPath });
125
+ }
126
+ continue;
127
+ }
128
+ }
129
+ }
130
+ }
131
+
132
+ return matches;
133
+ }
134
+
135
+ // ── Build step (fix #6) ──
136
+
137
+ function runBuildIfNeeded(repoPath) {
138
+ const pkg = readJSON(join(repoPath, 'package.json'));
139
+ if (!pkg) return;
140
+
141
+ const hasBuildScript = !!pkg.scripts?.build;
142
+ const hasTsConfig = existsSync(join(repoPath, 'tsconfig.json'));
143
+ const hasDist = existsSync(join(repoPath, 'dist'));
144
+
145
+ // Build if: has a build script AND (no dist/ or tsconfig exists implying TS)
146
+ if (hasBuildScript && (!hasDist || hasTsConfig)) {
147
+ log(`Building ${pkg.name || basename(repoPath)}...`);
148
+ try {
149
+ // Install deps first if node_modules is missing
150
+ if (!existsSync(join(repoPath, 'node_modules'))) {
151
+ execSync('npm install', { cwd: repoPath, stdio: 'pipe' });
152
+ }
153
+ execSync('npm run build', { cwd: repoPath, stdio: 'pipe' });
154
+ ok(`Build complete`);
155
+ } catch (e) {
156
+ fail(`Build failed: ${e.stderr?.toString()?.slice(0, 200) || e.message}`);
157
+ }
158
+ }
159
+ }
160
+
161
+ // ── Safe deploy (fix #7) ──
162
+ // Deploy to temp dir first, then atomic rename. Never rm -rf the live dir.
163
+
164
+ function copyFiltered(src, dest) {
165
+ cpSync(src, dest, {
166
+ recursive: true,
167
+ filter: (s) => !s.includes('.git') && !s.includes('node_modules') && !s.includes('/ai/'),
168
+ });
169
+ }
170
+
171
+ function safeDeployDir(repoPath, destDir, name) {
172
+ const finalPath = join(destDir, name);
173
+ const tempPath = join(tmpdir(), `ldm-deploy-${name}-${Date.now()}`);
174
+ const backupPath = finalPath + '.bak';
175
+
176
+ try {
177
+ // 1. Copy to temp
178
+ mkdirSync(tempPath, { recursive: true });
179
+ copyFiltered(repoPath, tempPath);
180
+
181
+ // 2. Install deps in temp
182
+ if (existsSync(join(tempPath, 'package.json'))) {
183
+ try {
184
+ execSync('npm install --omit=dev', { cwd: tempPath, stdio: 'pipe' });
185
+ } catch {}
186
+ }
187
+
188
+ // 3. Verify temp has package.json (basic sanity)
189
+ if (!existsSync(join(tempPath, 'package.json'))) {
190
+ fail(`Deploy verification failed: no package.json in staged copy`);
191
+ rmSync(tempPath, { recursive: true, force: true });
192
+ return false;
193
+ }
194
+
195
+ // 4. Swap: old -> backup, temp -> final
196
+ mkdirSync(destDir, { recursive: true });
197
+ if (existsSync(finalPath)) {
198
+ renameSync(finalPath, backupPath);
199
+ }
200
+ renameSync(tempPath, finalPath);
201
+
202
+ // 5. Clean up backup
203
+ if (existsSync(backupPath)) {
204
+ rmSync(backupPath, { recursive: true, force: true });
205
+ }
206
+
207
+ return true;
208
+ } catch (e) {
209
+ // Rollback: if temp was moved but something else failed, try to restore backup
210
+ if (!existsSync(finalPath) && existsSync(backupPath)) {
211
+ try { renameSync(backupPath, finalPath); } catch {}
212
+ }
213
+ // Clean up temp if it still exists
214
+ if (existsSync(tempPath)) {
215
+ rmSync(tempPath, { recursive: true, force: true });
216
+ }
217
+ fail(`Deploy failed: ${e.message}`);
218
+ return false;
219
+ }
220
+ }
221
+
222
+ // ── OpenClaw plugin naming (fix #8) ──
223
+
224
+ function resolveOcPluginName(repoPath, toolName) {
225
+ // OpenClaw matches plugins by directory name, not plugin id.
226
+ // Check openclaw.json for existing references to this plugin.
227
+ const ocConfigPath = join(OC_ROOT, 'openclaw.json');
228
+ const ocConfig = readJSON(ocConfigPath);
229
+ if (!ocConfig?.extensions) return toolName;
230
+
231
+ const ocPlugin = readJSON(join(repoPath, 'openclaw.plugin.json'));
232
+ if (!ocPlugin?.id) return toolName;
233
+
234
+ // Scan openclaw.json extensions array for a matching plugin id
235
+ // and use whatever directory name it expects
236
+ for (const ext of ocConfig.extensions) {
237
+ if (ext.id === ocPlugin.id && ext.path) {
238
+ const existingName = basename(ext.path);
239
+ if (existingName !== toolName) {
240
+ log(`OpenClaw expects plugin at "${existingName}" (not "${toolName}"). Using existing name.`);
241
+ return existingName;
242
+ }
243
+ }
244
+ }
245
+
246
+ // Also check if a directory already exists with this plugin's package
247
+ if (existsSync(OC_EXTENSIONS)) {
248
+ try {
249
+ const entries = readdirSync(OC_EXTENSIONS, { withFileTypes: true });
250
+ for (const entry of entries) {
251
+ if (!entry.isDirectory()) continue;
252
+ const dirPlugin = readJSON(join(OC_EXTENSIONS, entry.name, 'openclaw.plugin.json'));
253
+ if (dirPlugin?.id === ocPlugin.id && entry.name !== toolName) {
254
+ log(`OpenClaw has plugin at "${entry.name}" (not "${toolName}"). Using existing name.`);
255
+ return entry.name;
256
+ }
257
+ }
258
+ } catch {}
259
+ }
260
+
261
+ return toolName;
262
+ }
263
+
264
+ // ── Install functions ──
265
+
266
+ function installCLI(repoPath, door) {
267
+ const pkg = readJSON(join(repoPath, 'package.json'));
268
+ const binNames = typeof door.bin === 'string' ? [basename(repoPath)] : Object.keys(door.bin || {});
269
+ const newVersion = pkg?.version;
270
+
271
+ // Check if already installed at this version
272
+ if (newVersion && binNames.length > 0) {
273
+ try {
274
+ const installed = execSync(`npm list -g ${pkg.name} --json 2>/dev/null`, { encoding: 'utf8' });
275
+ const data = JSON.parse(installed);
276
+ const deps = data.dependencies || {};
277
+ if (deps[pkg.name]?.version === newVersion) {
278
+ ensureBinExecutable(binNames);
279
+ skip(`CLI: ${binNames.join(', ')} already at v${newVersion}`);
280
+ return true;
281
+ }
282
+ } catch {}
283
+ }
284
+
285
+ if (DRY_RUN) {
286
+ ok(`CLI: would install ${binNames.join(', ')} globally (dry run)`);
287
+ return true;
288
+ }
289
+
290
+ // Build if needed (fix #6)
291
+ runBuildIfNeeded(repoPath);
292
+
293
+ try {
294
+ execSync('npm install -g .', { cwd: repoPath, stdio: 'pipe' });
295
+ ensureBinExecutable(binNames);
296
+ ok(`CLI: ${binNames.join(', ')} installed globally`);
297
+ return true;
298
+ } catch (e) {
299
+ const stderr = e.stderr?.toString() || '';
300
+ if (stderr.includes('EEXIST')) {
301
+ for (const bin of binNames) {
302
+ try {
303
+ const binPath = execSync('npm config get prefix', { encoding: 'utf8' }).trim() + '/bin/' + bin;
304
+ if (existsSync(binPath) && lstatSync(binPath).isSymbolicLink()) {
305
+ const target = readlinkSync(binPath);
306
+ if (!target.includes(pkg.name.replace(/^@[^/]+\//, ''))) {
307
+ unlinkSync(binPath);
308
+ }
309
+ }
310
+ } catch {}
311
+ }
312
+ try {
313
+ execSync('npm install -g .', { cwd: repoPath, stdio: 'pipe' });
314
+ ensureBinExecutable(binNames);
315
+ ok(`CLI: ${binNames.join(', ')} installed globally (replaced stale symlink)`);
316
+ return true;
317
+ } catch {}
318
+ }
319
+ try {
320
+ execSync('npm link', { cwd: repoPath, stdio: 'pipe' });
321
+ ensureBinExecutable(binNames);
322
+ ok(`CLI: linked globally via npm link`);
323
+ return true;
324
+ } catch {
325
+ fail(`CLI: install failed. Run manually: cd "${repoPath}" && npm install -g .`);
326
+ return false;
327
+ }
328
+ }
329
+ }
330
+
331
+ function deployExtension(repoPath, name) {
332
+ const sourcePkg = readJSON(join(repoPath, 'package.json'));
333
+ const ldmDest = join(LDM_EXTENSIONS, name);
334
+ const installedPkg = readJSON(join(ldmDest, 'package.json'));
335
+ const newVersion = sourcePkg?.version;
336
+ const currentVersion = installedPkg?.version;
337
+
338
+ if (newVersion && currentVersion && newVersion === currentVersion) {
339
+ skip(`LDM: ${name} already at v${currentVersion}`);
340
+ // Ensure OC copy exists too
341
+ const ocName = resolveOcPluginName(repoPath, name);
342
+ const ocDest = join(OC_EXTENSIONS, ocName);
343
+ if (!existsSync(ocDest) && !DRY_RUN) {
344
+ mkdirSync(ocDest, { recursive: true });
345
+ cpSync(ldmDest, ocDest, { recursive: true });
346
+ ok(`OpenClaw: synced to ${ocDest}`);
347
+ } else {
348
+ skip(`OpenClaw: ${ocName} already at v${currentVersion}`);
349
+ }
350
+ return true;
351
+ }
352
+
353
+ if (DRY_RUN) {
354
+ if (currentVersion) {
355
+ ok(`LDM: would upgrade ${name} v${currentVersion} -> v${newVersion} (dry run)`);
356
+ } else {
357
+ ok(`LDM: would deploy ${name} v${newVersion || 'unknown'} to ${ldmDest} (dry run)`);
358
+ }
359
+ ok(`OpenClaw: would deploy (dry run)`);
360
+ return true;
361
+ }
362
+
363
+ // Build if needed (fix #6)
364
+ runBuildIfNeeded(repoPath);
365
+
366
+ // Safe deploy to LDM (fix #7: no rm -rf)
367
+ if (!safeDeployDir(repoPath, LDM_EXTENSIONS, name)) {
368
+ return false;
369
+ }
370
+
371
+ if (currentVersion) {
372
+ ok(`LDM: upgraded ${name} v${currentVersion} -> v${newVersion}`);
373
+ } else {
374
+ ok(`LDM: deployed to ${ldmDest}`);
375
+ }
376
+
377
+ // OpenClaw copy (fix #8: respect plugin naming)
378
+ const ocName = resolveOcPluginName(repoPath, name);
379
+ if (!safeDeployDir(ldmDest, OC_EXTENSIONS, ocName)) {
380
+ fail(`OpenClaw: deploy failed for ${ocName}`);
381
+ } else {
382
+ ok(`OpenClaw: deployed to ${join(OC_EXTENSIONS, ocName)}`);
383
+ }
384
+
385
+ return true;
386
+ }
387
+
388
+ function registerMCP(repoPath, door, toolName) {
389
+ const rawName = toolName || door.name || basename(repoPath);
390
+ const name = rawName.replace(/^@[\w-]+\//, '');
391
+ const ldmServerPath = join(LDM_EXTENSIONS, name, door.file);
392
+ const ldmFallbackPath = join(LDM_EXTENSIONS, basename(repoPath), door.file);
393
+ const repoServerPath = join(repoPath, door.file);
394
+ const mcpPath = existsSync(ldmServerPath) ? ldmServerPath
395
+ : existsSync(ldmFallbackPath) ? ldmFallbackPath
396
+ : repoServerPath;
397
+
398
+ // Check ~/.claude.json (user-level MCP)
399
+ const ccUserPath = join(HOME, '.claude.json');
400
+ const ccUser = readJSON(ccUserPath);
401
+ const alreadyRegistered = ccUser?.mcpServers?.[name]?.args?.includes(mcpPath);
402
+
403
+ if (alreadyRegistered) {
404
+ skip(`MCP: ${name} already registered at ${mcpPath}`);
405
+ return true;
406
+ }
407
+
408
+ if (DRY_RUN) {
409
+ ok(`MCP: would register ${name} at user scope (dry run)`);
410
+ return true;
411
+ }
412
+
413
+ // Register with Claude Code CLI at user scope
414
+ try {
415
+ try {
416
+ execSync(`claude mcp remove ${name} --scope user`, { stdio: 'pipe' });
417
+ } catch {}
418
+ const envFlag = existsSync(OC_ROOT) ? ` -e OPENCLAW_HOME="${OC_ROOT}"` : '';
419
+ execSync(`claude mcp add --scope user ${name}${envFlag} -- node "${mcpPath}"`, { stdio: 'pipe' });
420
+ ok(`MCP: registered ${name} at user scope`);
421
+ return true;
422
+ } catch (e) {
423
+ // Fallback: write to ~/.claude.json directly
424
+ try {
425
+ const mcpConfig = readJSON(ccUserPath) || {};
426
+ if (!mcpConfig.mcpServers) mcpConfig.mcpServers = {};
427
+ mcpConfig.mcpServers[name] = {
428
+ command: 'node',
429
+ args: [mcpPath],
430
+ };
431
+ writeJSON(ccUserPath, mcpConfig);
432
+ ok(`MCP: registered ${name} in ~/.claude.json (fallback)`);
433
+ return true;
434
+ } catch (e2) {
435
+ fail(`MCP: registration failed. ${e.message}`);
436
+ return false;
437
+ }
438
+ }
439
+ }
440
+
441
+ function installClaudeCodeHook(repoPath, door) {
442
+ const settingsPath = join(HOME, '.claude', 'settings.json');
443
+ let settings = readJSON(settingsPath);
444
+
445
+ if (!settings) {
446
+ skip(`Claude Code: no settings.json found`);
447
+ return false;
448
+ }
449
+
450
+ const toolName = basename(repoPath);
451
+ const installedGuard = join(LDM_EXTENSIONS, toolName, 'guard.mjs');
452
+ const hookCommand = existsSync(installedGuard)
453
+ ? `node ${installedGuard}`
454
+ : (door.command || `node "${join(repoPath, 'guard.mjs')}"`);
455
+
456
+ if (DRY_RUN) {
457
+ ok(`Claude Code: would add ${door.event || 'PreToolUse'} hook (dry run)`);
458
+ return true;
459
+ }
460
+
461
+ if (!settings.hooks) settings.hooks = {};
462
+ const event = door.event || 'PreToolUse';
463
+ if (!settings.hooks[event]) settings.hooks[event] = [];
464
+
465
+ const existingIdx = settings.hooks[event].findIndex(entry =>
466
+ entry.hooks?.some(h => {
467
+ const cmd = h.command || '';
468
+ return cmd.includes(`/${toolName}/`) || cmd === hookCommand;
469
+ })
470
+ );
471
+
472
+ if (existingIdx !== -1) {
473
+ const existingCmd = settings.hooks[event][existingIdx].hooks?.[0]?.command || '';
474
+ if (existingCmd === hookCommand) {
475
+ skip(`Claude Code: ${event} hook already configured`);
476
+ return true;
477
+ }
478
+ settings.hooks[event][existingIdx].hooks[0].command = hookCommand;
479
+ settings.hooks[event][existingIdx].hooks[0].timeout = door.timeout || 10;
480
+ try {
481
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
482
+ ok(`Claude Code: ${event} hook updated`);
483
+ return true;
484
+ } catch (e) {
485
+ fail(`Claude Code: failed to update settings.json. ${e.message}`);
486
+ return false;
487
+ }
488
+ }
489
+
490
+ settings.hooks[event].push({
491
+ matcher: door.matcher || undefined,
492
+ hooks: [{
493
+ type: 'command',
494
+ command: hookCommand,
495
+ timeout: door.timeout || 10,
496
+ }],
497
+ });
498
+
499
+ try {
500
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
501
+ ok(`Claude Code: ${event} hook added`);
502
+ return true;
503
+ } catch (e) {
504
+ fail(`Claude Code: failed to update settings.json. ${e.message}`);
505
+ return false;
506
+ }
507
+ }
508
+
509
+ function installSkill(repoPath, toolName) {
510
+ const skillSrc = join(repoPath, 'SKILL.md');
511
+ const ocSkillDir = join(OC_ROOT, 'skills', toolName);
512
+ const ocSkillDest = join(ocSkillDir, 'SKILL.md');
513
+
514
+ if (existsSync(ocSkillDest)) {
515
+ try {
516
+ const srcContent = readFileSync(skillSrc, 'utf8');
517
+ const destContent = readFileSync(ocSkillDest, 'utf8');
518
+ if (srcContent === destContent) {
519
+ skip(`Skill: ${toolName} already deployed`);
520
+ return true;
521
+ }
522
+ } catch {}
523
+ }
524
+
525
+ if (DRY_RUN) {
526
+ ok(`Skill: would deploy ${toolName}/SKILL.md (dry run)`);
527
+ return true;
528
+ }
529
+
530
+ try {
531
+ mkdirSync(ocSkillDir, { recursive: true });
532
+ cpSync(skillSrc, ocSkillDest);
533
+ ok(`Skill: deployed to ${ocSkillDir}`);
534
+ return true;
535
+ } catch (e) {
536
+ fail(`Skill: deploy failed. ${e.message}`);
537
+ return false;
538
+ }
539
+ }
540
+
541
+ // ── Single tool install ──
542
+
543
+ export function installSingleTool(toolPath) {
544
+ const { interfaces, pkg } = detectInterfaces(toolPath);
545
+ const ifaceNames = Object.keys(interfaces);
546
+
547
+ if (ifaceNames.length === 0) return 0;
548
+
549
+ const toolName = pkg?.name?.replace(/^@\w+\//, '') || basename(toolPath);
550
+
551
+ if (!JSON_OUTPUT) {
552
+ console.log('');
553
+ console.log(` Installing: ${toolName}${DRY_RUN ? ' (dry run)' : ''}`);
554
+ console.log(` ${'─'.repeat(40)}`);
555
+ log(`Detected ${ifaceNames.length} interface(s): ${ifaceNames.join(', ')}`);
556
+ console.log('');
557
+ }
558
+
559
+ if (DRY_RUN && !JSON_OUTPUT) {
560
+ console.log(describeInterfaces(interfaces));
561
+
562
+ const existing = findExistingInstalls(toolName, pkg, interfaces.openclaw?.config);
563
+ if (existing.length > 0) {
564
+ console.log('');
565
+ for (const m of existing) {
566
+ log(`Migration: would rename "${m.dirName}" -> "${toolName}" (matched by ${m.matchType})`);
567
+ }
568
+ }
569
+
570
+ return ifaceNames.length;
571
+ }
572
+
573
+ let installed = 0;
574
+ const registryInfo = {
575
+ name: toolName,
576
+ version: pkg?.version || 'unknown',
577
+ source: toolPath,
578
+ interfaces: ifaceNames,
579
+ };
580
+
581
+ if (interfaces.cli) {
582
+ if (installCLI(toolPath, interfaces.cli)) installed++;
583
+ }
584
+
585
+ if (interfaces.openclaw) {
586
+ if (deployExtension(toolPath, toolName)) {
587
+ installed++;
588
+ registryInfo.ldmPath = join(LDM_EXTENSIONS, toolName);
589
+ registryInfo.ocPath = join(OC_EXTENSIONS, toolName);
590
+ }
591
+ } else if (interfaces.mcp) {
592
+ const extName = basename(toolPath);
593
+ if (deployExtension(toolPath, extName)) {
594
+ registryInfo.ldmPath = join(LDM_EXTENSIONS, extName);
595
+ registryInfo.ocPath = join(OC_EXTENSIONS, extName);
596
+ }
597
+ }
598
+
599
+ if (interfaces.mcp) {
600
+ if (registerMCP(toolPath, interfaces.mcp, toolName)) installed++;
601
+ }
602
+
603
+ if (interfaces.claudeCodeHook) {
604
+ if (installClaudeCodeHook(toolPath, interfaces.claudeCodeHook)) installed++;
605
+ }
606
+
607
+ if (interfaces.skill) {
608
+ if (installSkill(toolPath, toolName)) installed++;
609
+ }
610
+
611
+ if (interfaces.module) {
612
+ ok(`Module: import from "${interfaces.module.main}"`);
613
+ installed++;
614
+ }
615
+
616
+ // Update registry
617
+ if (!DRY_RUN) {
618
+ try {
619
+ mkdirSync(LDM_EXTENSIONS, { recursive: true });
620
+ updateRegistry(toolName, registryInfo);
621
+ ok(`Registry: updated`);
622
+ } catch (e) {
623
+ fail(`Registry: update failed. ${e.message}`);
624
+ }
625
+ }
626
+
627
+ return installed;
628
+ }
629
+
630
+ // ── Toolbox install ──
631
+
632
+ export function installToolbox(repoPath) {
633
+ const subTools = detectToolbox(repoPath);
634
+ if (subTools.length === 0) return { tools: 0, interfaces: 0 };
635
+
636
+ const toolboxPkg = readJSON(join(repoPath, 'package.json'));
637
+ const toolboxName = toolboxPkg?.name?.replace(/^@\w+\//, '') || basename(repoPath);
638
+
639
+ if (!JSON_OUTPUT) {
640
+ console.log('');
641
+ console.log(` Toolbox: ${toolboxName}`);
642
+ console.log(` ${'='.repeat(50)}`);
643
+ log(`Found ${subTools.length} sub-tool(s): ${subTools.map(t => t.name).join(', ')}`);
644
+ }
645
+
646
+ let totalInstalled = 0;
647
+ let toolsProcessed = 0;
648
+
649
+ for (const subTool of subTools) {
650
+ const count = installSingleTool(subTool.path);
651
+ totalInstalled += count;
652
+ if (count > 0) toolsProcessed++;
653
+ }
654
+
655
+ if (!JSON_OUTPUT) {
656
+ console.log('');
657
+ console.log(` ${'='.repeat(50)}`);
658
+ if (DRY_RUN) {
659
+ console.log(` Dry run complete. ${toolsProcessed} tool(s) scanned, ${totalInstalled} interface(s) detected.`);
660
+ } else {
661
+ console.log(` Done. ${toolsProcessed} tool(s), ${totalInstalled} interface(s) processed.`);
662
+ }
663
+ console.log('');
664
+ }
665
+
666
+ return { tools: toolsProcessed, interfaces: totalInstalled };
667
+ }
668
+
669
+ // ── Full install pipeline ──
670
+
671
+ export async function installFromPath(repoPath) {
672
+ const subTools = detectToolbox(repoPath);
673
+
674
+ if (subTools.length > 0) {
675
+ return installToolbox(repoPath);
676
+ }
677
+
678
+ const installed = installSingleTool(repoPath);
679
+
680
+ if (installed === 0) {
681
+ skip('No installable interfaces detected.');
682
+ } else if (!JSON_OUTPUT) {
683
+ console.log('');
684
+ if (DRY_RUN) {
685
+ console.log(' Dry run complete. No changes made.');
686
+ } else {
687
+ console.log(` Done. ${installed} interface(s) processed.`);
688
+ }
689
+ console.log('');
690
+ }
691
+
692
+ return { tools: 1, interfaces: installed };
693
+ }
694
+
695
+ // ── Exports for ldm CLI ──
696
+
697
+ export { loadRegistry, saveRegistry, updateRegistry, readJSON, writeJSON, runBuildIfNeeded };