@wipcomputer/wip-ai-devops-toolbox 1.9.20
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/.license-guard.json +7 -0
- package/.publish-skill.json +4 -0
- package/CHANGELOG.md +1120 -0
- package/CLA.md +19 -0
- package/DEV-GUIDE-GENERAL-PUBLIC.md +882 -0
- package/LICENSE +52 -0
- package/README.md +238 -0
- package/SKILL.md +728 -0
- package/TECHNICAL.md +282 -0
- package/UNIVERSAL-INTERFACE.md +180 -0
- package/_trash/RELEASE-NOTES-v1-8-0.md +29 -0
- package/_trash/RELEASE-NOTES-v1-8-1.md +7 -0
- package/_trash/RELEASE-NOTES-v1-8-2.md +7 -0
- package/_trash/RELEASE-NOTES-v1-9-0.md +37 -0
- package/_trash/RELEASE-NOTES-v1-9-1.md +38 -0
- package/_trash/RELEASE-NOTES-v1-9-10.md +40 -0
- package/_trash/RELEASE-NOTES-v1-9-2.md +40 -0
- package/_trash/RELEASE-NOTES-v1-9-6.md +72 -0
- package/_trash/RELEASE-NOTES-v1-9-7.md +23 -0
- package/_trash/RELEASE-NOTES-v1-9-9.md +75 -0
- package/_trash/guide 2/DEV-GUIDE.md +487 -0
- package/_trash/guide 2/scripts/deploy-public.sh +152 -0
- package/package.json +27 -0
- package/scripts/SKILL-deploy-public.md +61 -0
- package/scripts/SKILL-post-merge-rename.md +47 -0
- package/scripts/deploy-public.sh +264 -0
- package/scripts/post-merge-rename.sh +205 -0
- package/scripts/publish-skill.sh +134 -0
- package/tools/deploy-public/LICENSE +52 -0
- package/tools/deploy-public/README.md +31 -0
- package/tools/deploy-public/SKILL.md +71 -0
- package/tools/deploy-public/deploy-public.sh +264 -0
- package/tools/deploy-public/package.json +9 -0
- package/tools/ldm-jobs/LICENSE +52 -0
- package/tools/ldm-jobs/README.md +46 -0
- package/tools/ldm-jobs/backup.sh +16 -0
- package/tools/ldm-jobs/branch-protect.sh +39 -0
- package/tools/ldm-jobs/crystal-capture.sh +19 -0
- package/tools/ldm-jobs/setup-shell.sh +27 -0
- package/tools/ldm-jobs/visibility-audit.sh +27 -0
- package/tools/post-merge-rename/LICENSE +52 -0
- package/tools/post-merge-rename/README.md +29 -0
- package/tools/post-merge-rename/SKILL.md +57 -0
- package/tools/post-merge-rename/package.json +9 -0
- package/tools/post-merge-rename/post-merge-rename.sh +122 -0
- package/tools/wip-branch-guard/INSTALL.md +41 -0
- package/tools/wip-branch-guard/guard.mjs +259 -0
- package/tools/wip-branch-guard/package.json +11 -0
- package/tools/wip-file-guard/CHANGELOG.md +6 -0
- package/tools/wip-file-guard/LICENSE +52 -0
- package/tools/wip-file-guard/README.md +113 -0
- package/tools/wip-file-guard/REFERENCE.md +86 -0
- package/tools/wip-file-guard/SKILL.md +105 -0
- package/tools/wip-file-guard/guard.mjs +128 -0
- package/tools/wip-file-guard/openclaw.plugin.json +8 -0
- package/tools/wip-file-guard/package.json +27 -0
- package/tools/wip-file-guard/test.sh +119 -0
- package/tools/wip-license-guard/LICENSE +52 -0
- package/tools/wip-license-guard/README.md +32 -0
- package/tools/wip-license-guard/SKILL.md +65 -0
- package/tools/wip-license-guard/cli.mjs +464 -0
- package/tools/wip-license-guard/core.mjs +310 -0
- package/tools/wip-license-guard/hook.mjs +146 -0
- package/tools/wip-license-guard/package.json +15 -0
- package/tools/wip-license-hook/CHANGELOG.md +17 -0
- package/tools/wip-license-hook/LICENSE +52 -0
- package/tools/wip-license-hook/README.md +200 -0
- package/tools/wip-license-hook/SKILL.md +111 -0
- package/tools/wip-license-hook/dist/cli/index.d.ts +15 -0
- package/tools/wip-license-hook/dist/cli/index.js +170 -0
- package/tools/wip-license-hook/dist/cli/index.js.map +1 -0
- package/tools/wip-license-hook/dist/core/detector.d.ts +12 -0
- package/tools/wip-license-hook/dist/core/detector.js +104 -0
- package/tools/wip-license-hook/dist/core/detector.js.map +1 -0
- package/tools/wip-license-hook/dist/core/index.d.ts +4 -0
- package/tools/wip-license-hook/dist/core/index.js +5 -0
- package/tools/wip-license-hook/dist/core/index.js.map +1 -0
- package/tools/wip-license-hook/dist/core/ledger.d.ts +49 -0
- package/tools/wip-license-hook/dist/core/ledger.js +72 -0
- package/tools/wip-license-hook/dist/core/ledger.js.map +1 -0
- package/tools/wip-license-hook/dist/core/reporter.d.ts +14 -0
- package/tools/wip-license-hook/dist/core/reporter.js +227 -0
- package/tools/wip-license-hook/dist/core/reporter.js.map +1 -0
- package/tools/wip-license-hook/dist/core/scanner.d.ts +39 -0
- package/tools/wip-license-hook/dist/core/scanner.js +325 -0
- package/tools/wip-license-hook/dist/core/scanner.js.map +1 -0
- package/tools/wip-license-hook/hooks/pre-pull.sh +55 -0
- package/tools/wip-license-hook/hooks/pre-push.sh +51 -0
- package/tools/wip-license-hook/mcp-server.mjs +119 -0
- package/tools/wip-license-hook/package-lock.json +54 -0
- package/tools/wip-license-hook/package.json +43 -0
- package/tools/wip-license-hook/src/cli/index.ts +189 -0
- package/tools/wip-license-hook/src/core/detector.ts +130 -0
- package/tools/wip-license-hook/src/core/index.ts +4 -0
- package/tools/wip-license-hook/src/core/ledger.ts +116 -0
- package/tools/wip-license-hook/src/core/reporter.ts +255 -0
- package/tools/wip-license-hook/src/core/scanner.ts +367 -0
- package/tools/wip-license-hook/tsconfig.json +16 -0
- package/tools/wip-readme-format/README.md +49 -0
- package/tools/wip-readme-format/SKILL.md +84 -0
- package/tools/wip-readme-format/format.mjs +570 -0
- package/tools/wip-readme-format/package.json +15 -0
- package/tools/wip-release/CHANGELOG.md +42 -0
- package/tools/wip-release/LICENSE +52 -0
- package/tools/wip-release/README.md +45 -0
- package/tools/wip-release/REFERENCE.md +100 -0
- package/tools/wip-release/SKILL.md +139 -0
- package/tools/wip-release/cli.js +161 -0
- package/tools/wip-release/core.mjs +1174 -0
- package/tools/wip-release/mcp-server.mjs +109 -0
- package/tools/wip-release/package.json +36 -0
- package/tools/wip-repo-init/README.md +38 -0
- package/tools/wip-repo-init/SKILL.md +77 -0
- package/tools/wip-repo-init/init.mjs +142 -0
- package/tools/wip-repo-init/package.json +11 -0
- package/tools/wip-repo-permissions-hook/LICENSE +52 -0
- package/tools/wip-repo-permissions-hook/README.md +86 -0
- package/tools/wip-repo-permissions-hook/SKILL.md +73 -0
- package/tools/wip-repo-permissions-hook/cli.js +83 -0
- package/tools/wip-repo-permissions-hook/core.mjs +122 -0
- package/tools/wip-repo-permissions-hook/guard.mjs +64 -0
- package/tools/wip-repo-permissions-hook/mcp-server.mjs +92 -0
- package/tools/wip-repo-permissions-hook/openclaw.plugin.json +8 -0
- package/tools/wip-repo-permissions-hook/package.json +31 -0
- package/tools/wip-repos/LICENSE +52 -0
- package/tools/wip-repos/README.md +77 -0
- package/tools/wip-repos/SKILL.md +80 -0
- package/tools/wip-repos/cli.mjs +176 -0
- package/tools/wip-repos/core.mjs +290 -0
- package/tools/wip-repos/mcp-server.mjs +157 -0
- package/tools/wip-repos/package.json +34 -0
- package/tools/wip-universal-installer/CHANGELOG.md +57 -0
- package/tools/wip-universal-installer/LICENSE +52 -0
- package/tools/wip-universal-installer/README.md +81 -0
- package/tools/wip-universal-installer/REFERENCE.md +122 -0
- package/tools/wip-universal-installer/SKILL.md +87 -0
- package/tools/wip-universal-installer/SPEC.md +180 -0
- package/tools/wip-universal-installer/detect.mjs +130 -0
- package/tools/wip-universal-installer/examples/minimal/README.md +20 -0
- package/tools/wip-universal-installer/examples/minimal/SKILL.md +28 -0
- package/tools/wip-universal-installer/examples/minimal/cli.mjs +4 -0
- package/tools/wip-universal-installer/examples/minimal/core.mjs +8 -0
- package/tools/wip-universal-installer/examples/minimal/mcp-server.mjs +27 -0
- package/tools/wip-universal-installer/examples/minimal/package.json +12 -0
- package/tools/wip-universal-installer/install.js +930 -0
- package/tools/wip-universal-installer/package.json +36 -0
|
@@ -0,0 +1,930 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// wip-universal-installer/install.js
|
|
3
|
+
// Reference installer for agent-native software.
|
|
4
|
+
// Reads a repo, detects available interfaces, installs them all.
|
|
5
|
+
// Deploys to LDM OS (~/.ldm/extensions/) and OpenClaw (~/.openclaw/extensions/).
|
|
6
|
+
// Registers MCP servers at user scope via `claude mcp add --scope user`.
|
|
7
|
+
// Maintains a registry at ~/.ldm/extensions/registry.json.
|
|
8
|
+
|
|
9
|
+
import { execSync } from 'node:child_process';
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync, cpSync, mkdirSync, lstatSync, readlinkSync, unlinkSync, chmodSync, readdirSync } from 'node:fs';
|
|
11
|
+
import { join, basename, resolve } from 'node:path';
|
|
12
|
+
import { detectInterfaces, describeInterfaces, detectInterfacesJSON, detectToolbox } from './detect.mjs';
|
|
13
|
+
|
|
14
|
+
const HOME = process.env.HOME || '';
|
|
15
|
+
const LDM_ROOT = join(HOME, '.ldm');
|
|
16
|
+
const LDM_EXTENSIONS = join(LDM_ROOT, 'extensions');
|
|
17
|
+
const OC_ROOT = join(HOME, '.openclaw');
|
|
18
|
+
const OC_EXTENSIONS = join(OC_ROOT, 'extensions');
|
|
19
|
+
const OC_MCP = join(OC_ROOT, '.mcp.json');
|
|
20
|
+
const REGISTRY_PATH = join(LDM_EXTENSIONS, 'registry.json');
|
|
21
|
+
|
|
22
|
+
// Flags
|
|
23
|
+
const args = process.argv.slice(2);
|
|
24
|
+
const DRY_RUN = args.includes('--dry-run');
|
|
25
|
+
const JSON_OUTPUT = args.includes('--json');
|
|
26
|
+
const target = args.find(a => !a.startsWith('--'));
|
|
27
|
+
|
|
28
|
+
function log(msg) { if (!JSON_OUTPUT) console.log(` ${msg}`); }
|
|
29
|
+
function ok(msg) { if (!JSON_OUTPUT) console.log(` ✓ ${msg}`); }
|
|
30
|
+
function skip(msg) { if (!JSON_OUTPUT) console.log(` - ${msg}`); }
|
|
31
|
+
function fail(msg) { if (!JSON_OUTPUT) console.error(` ✗ ${msg}`); }
|
|
32
|
+
|
|
33
|
+
function ensureBinExecutable(binNames) {
|
|
34
|
+
try {
|
|
35
|
+
const npmPrefix = execSync('npm config get prefix', { encoding: 'utf8' }).trim();
|
|
36
|
+
for (const bin of binNames) {
|
|
37
|
+
const binPath = join(npmPrefix, 'bin', bin);
|
|
38
|
+
try { chmodSync(binPath, 0o755); } catch {}
|
|
39
|
+
}
|
|
40
|
+
} catch {}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readJSON(path) {
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function writeJSON(path, data) {
|
|
52
|
+
mkdirSync(join(path, '..'), { recursive: true });
|
|
53
|
+
writeFileSync(path, JSON.stringify(data, null, 2) + '\n');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Registry ──
|
|
57
|
+
|
|
58
|
+
function loadRegistry() {
|
|
59
|
+
return readJSON(REGISTRY_PATH) || { _format: 'v1', extensions: {} };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function saveRegistry(registry) {
|
|
63
|
+
writeJSON(REGISTRY_PATH, registry);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function updateRegistry(name, info) {
|
|
67
|
+
const registry = loadRegistry();
|
|
68
|
+
registry.extensions[name] = {
|
|
69
|
+
...registry.extensions[name],
|
|
70
|
+
...info,
|
|
71
|
+
updatedAt: new Date().toISOString(),
|
|
72
|
+
};
|
|
73
|
+
saveRegistry(registry);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Migration detection ──
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Scan existing extension directories for installs that match this tool
|
|
80
|
+
* but live under a different directory name. Matches on:
|
|
81
|
+
* 1. Same package name in package.json
|
|
82
|
+
* 2. Same plugin id in openclaw.plugin.json
|
|
83
|
+
* Returns array of { dirName, matchType, path } for each match.
|
|
84
|
+
*/
|
|
85
|
+
function findExistingInstalls(toolName, pkg, ocPluginConfig) {
|
|
86
|
+
const matches = [];
|
|
87
|
+
const packageName = pkg?.name;
|
|
88
|
+
const pluginId = ocPluginConfig?.id;
|
|
89
|
+
|
|
90
|
+
// Scan both LDM and OC extension dirs
|
|
91
|
+
for (const extDir of [LDM_EXTENSIONS, OC_EXTENSIONS]) {
|
|
92
|
+
if (!existsSync(extDir)) continue;
|
|
93
|
+
let entries;
|
|
94
|
+
try {
|
|
95
|
+
entries = readdirSync(extDir, { withFileTypes: true });
|
|
96
|
+
} catch { continue; }
|
|
97
|
+
|
|
98
|
+
for (const entry of entries) {
|
|
99
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
100
|
+
const dirName = entry.name;
|
|
101
|
+
// Skip if it's already the target name
|
|
102
|
+
if (dirName === toolName) continue;
|
|
103
|
+
// Skip registry.json
|
|
104
|
+
if (dirName === 'registry.json') continue;
|
|
105
|
+
|
|
106
|
+
const dirPath = join(extDir, dirName);
|
|
107
|
+
|
|
108
|
+
// Check package.json name match
|
|
109
|
+
if (packageName) {
|
|
110
|
+
const dirPkg = readJSON(join(dirPath, 'package.json'));
|
|
111
|
+
if (dirPkg?.name === packageName) {
|
|
112
|
+
if (!matches.some(m => m.dirName === dirName)) {
|
|
113
|
+
matches.push({ dirName, matchType: 'package', path: dirPath });
|
|
114
|
+
}
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Check openclaw.plugin.json id match
|
|
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
|
+
/**
|
|
136
|
+
* Migrate an existing install from an old directory name to the new one.
|
|
137
|
+
* Removes old extension dirs (LDM + OC), old MCP registrations, old skills.
|
|
138
|
+
*/
|
|
139
|
+
function migrateExistingInstall(oldName, newName) {
|
|
140
|
+
// 1. Remove old extension directories (check lstat too for broken symlinks)
|
|
141
|
+
for (const extDir of [LDM_EXTENSIONS, OC_EXTENSIONS]) {
|
|
142
|
+
const oldPath = join(extDir, oldName);
|
|
143
|
+
let pathExists = existsSync(oldPath);
|
|
144
|
+
if (!pathExists) {
|
|
145
|
+
try { lstatSync(oldPath); pathExists = true; } catch {}
|
|
146
|
+
}
|
|
147
|
+
if (pathExists) {
|
|
148
|
+
try {
|
|
149
|
+
execSync(`rm -rf "${oldPath}"`, { stdio: 'pipe' });
|
|
150
|
+
ok(`Migrated: removed ${oldPath}`);
|
|
151
|
+
} catch (e) {
|
|
152
|
+
fail(`Migration: could not remove ${oldPath}. ${e.message}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 2. Remove old MCP registrations (Claude Code)
|
|
158
|
+
try {
|
|
159
|
+
execSync(`claude mcp remove ${oldName} --scope user`, { stdio: 'pipe' });
|
|
160
|
+
ok(`Migrated: removed MCP registration "${oldName}" from Claude Code`);
|
|
161
|
+
} catch {}
|
|
162
|
+
|
|
163
|
+
// Also clean ~/.claude/.mcp.json fallback
|
|
164
|
+
const ccMcpPath = join(HOME, '.claude', '.mcp.json');
|
|
165
|
+
const ccMcp = readJSON(ccMcpPath);
|
|
166
|
+
if (ccMcp?.mcpServers?.[oldName]) {
|
|
167
|
+
delete ccMcp.mcpServers[oldName];
|
|
168
|
+
writeJSON(ccMcpPath, ccMcp);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Also clean ~/.claude.json (user-level MCP)
|
|
172
|
+
const ccUserPath = join(HOME, '.claude.json');
|
|
173
|
+
const ccUser = readJSON(ccUserPath);
|
|
174
|
+
if (ccUser?.mcpServers?.[oldName]) {
|
|
175
|
+
delete ccUser.mcpServers[oldName];
|
|
176
|
+
writeJSON(ccUserPath, ccUser);
|
|
177
|
+
ok(`Migrated: removed MCP registration "${oldName}" from ~/.claude.json`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 3. Remove old OpenClaw MCP registration
|
|
181
|
+
if (existsSync(OC_MCP)) {
|
|
182
|
+
const ocMcp = readJSON(OC_MCP);
|
|
183
|
+
if (ocMcp?.mcpServers?.[oldName]) {
|
|
184
|
+
delete ocMcp.mcpServers[oldName];
|
|
185
|
+
writeJSON(OC_MCP, ocMcp);
|
|
186
|
+
ok(`Migrated: removed MCP registration "${oldName}" from OpenClaw`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// 4. Remove old skill directory
|
|
191
|
+
const oldSkillDir = join(OC_ROOT, 'skills', oldName);
|
|
192
|
+
if (existsSync(oldSkillDir)) {
|
|
193
|
+
try {
|
|
194
|
+
execSync(`rm -rf "${oldSkillDir}"`, { stdio: 'pipe' });
|
|
195
|
+
ok(`Migrated: removed old skill at ${oldSkillDir}`);
|
|
196
|
+
} catch {}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 5. Remove old registry entry
|
|
200
|
+
const registry = loadRegistry();
|
|
201
|
+
if (registry.extensions[oldName]) {
|
|
202
|
+
delete registry.extensions[oldName];
|
|
203
|
+
saveRegistry(registry);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── Install functions ──
|
|
208
|
+
|
|
209
|
+
function installCLI(repoPath, door) {
|
|
210
|
+
const pkg = readJSON(join(repoPath, 'package.json'));
|
|
211
|
+
const binNames = typeof door.bin === 'string' ? [basename(repoPath)] : Object.keys(door.bin || {});
|
|
212
|
+
const newVersion = pkg?.version;
|
|
213
|
+
|
|
214
|
+
// Check if already installed at this version
|
|
215
|
+
if (newVersion && binNames.length > 0) {
|
|
216
|
+
try {
|
|
217
|
+
const installed = execSync(`npm list -g ${pkg.name} --json 2>/dev/null`, { encoding: 'utf8' });
|
|
218
|
+
const data = JSON.parse(installed);
|
|
219
|
+
const deps = data.dependencies || {};
|
|
220
|
+
if (deps[pkg.name]?.version === newVersion) {
|
|
221
|
+
// Still ensure bins are executable (git doesn't preserve +x)
|
|
222
|
+
ensureBinExecutable(binNames);
|
|
223
|
+
skip(`CLI: ${binNames.join(', ')} already at v${newVersion}`);
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
} catch {}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (DRY_RUN) {
|
|
230
|
+
ok(`CLI: would install ${binNames.join(', ')} globally (dry run)`);
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// If the package has a build script and dist/ is missing, build first
|
|
235
|
+
if (pkg?.scripts?.build && !existsSync(join(repoPath, 'dist'))) {
|
|
236
|
+
try {
|
|
237
|
+
log(`CLI: building ${binNames.join(', ')} (TypeScript)...`);
|
|
238
|
+
execSync('npm run build', { cwd: repoPath, stdio: 'pipe' });
|
|
239
|
+
} catch (e) {
|
|
240
|
+
fail(`CLI: build failed. ${e.stderr?.toString()?.slice(0, 200) || e.message}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
execSync('npm install -g .', { cwd: repoPath, stdio: 'pipe' });
|
|
246
|
+
// Safety net: ensure bin files are executable (git doesn't always preserve +x)
|
|
247
|
+
ensureBinExecutable(binNames);
|
|
248
|
+
ok(`CLI: ${binNames.join(', ')} installed globally`);
|
|
249
|
+
return true;
|
|
250
|
+
} catch (e) {
|
|
251
|
+
const stderr = e.stderr?.toString() || '';
|
|
252
|
+
// EEXIST: a binary with the same name exists from a different package.
|
|
253
|
+
// Remove the stale symlink and retry.
|
|
254
|
+
if (stderr.includes('EEXIST')) {
|
|
255
|
+
for (const bin of binNames) {
|
|
256
|
+
try {
|
|
257
|
+
const binPath = execSync(`npm config get prefix`, { encoding: 'utf8' }).trim() + '/bin/' + bin;
|
|
258
|
+
if (existsSync(binPath) && lstatSync(binPath).isSymbolicLink()) {
|
|
259
|
+
const target = readlinkSync(binPath);
|
|
260
|
+
// Only remove if it points to a different package
|
|
261
|
+
if (!target.includes(pkg.name.replace(/^@[^/]+\//, ''))) {
|
|
262
|
+
unlinkSync(binPath);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
} catch {}
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
execSync('npm install -g .', { cwd: repoPath, stdio: 'pipe' });
|
|
269
|
+
ensureBinExecutable(binNames);
|
|
270
|
+
ok(`CLI: ${binNames.join(', ')} installed globally (replaced stale symlink)`);
|
|
271
|
+
return true;
|
|
272
|
+
} catch {}
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
execSync('npm link', { cwd: repoPath, stdio: 'pipe' });
|
|
276
|
+
ensureBinExecutable(binNames);
|
|
277
|
+
ok(`CLI: linked globally via npm link`);
|
|
278
|
+
return true;
|
|
279
|
+
} catch {
|
|
280
|
+
fail(`CLI: install failed. Run manually: cd "${repoPath}" && npm install -g .`);
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function deployExtension(repoPath, name) {
|
|
287
|
+
const ldmDest = join(LDM_EXTENSIONS, name);
|
|
288
|
+
const ocDest = join(OC_EXTENSIONS, name);
|
|
289
|
+
|
|
290
|
+
// Check if already deployed at the same version
|
|
291
|
+
const sourcePkg = readJSON(join(repoPath, 'package.json'));
|
|
292
|
+
const installedPkg = readJSON(join(ldmDest, 'package.json'));
|
|
293
|
+
const newVersion = sourcePkg?.version;
|
|
294
|
+
const currentVersion = installedPkg?.version;
|
|
295
|
+
|
|
296
|
+
if (newVersion && currentVersion && newVersion === currentVersion) {
|
|
297
|
+
skip(`LDM: ${name} already at v${currentVersion}`);
|
|
298
|
+
// Still check OpenClaw copy exists
|
|
299
|
+
if (existsSync(ocDest)) {
|
|
300
|
+
skip(`OpenClaw: ${name} already at v${currentVersion}`);
|
|
301
|
+
} else if (!DRY_RUN) {
|
|
302
|
+
// LDM has it but OpenClaw doesn't. Copy it over.
|
|
303
|
+
mkdirSync(ocDest, { recursive: true });
|
|
304
|
+
cpSync(ldmDest, ocDest, { recursive: true });
|
|
305
|
+
ok(`OpenClaw: deployed to ${ocDest} (synced from LDM)`);
|
|
306
|
+
}
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (DRY_RUN) {
|
|
311
|
+
if (currentVersion) {
|
|
312
|
+
ok(`LDM: would upgrade ${name} v${currentVersion} -> v${newVersion} (dry run)`);
|
|
313
|
+
} else {
|
|
314
|
+
ok(`LDM: would deploy ${name} v${newVersion || 'unknown'} to ${ldmDest} (dry run)`);
|
|
315
|
+
}
|
|
316
|
+
ok(`OpenClaw: would deploy to ${ocDest} (dry run)`);
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
try {
|
|
321
|
+
// LDM path (remove existing to get clean copy)
|
|
322
|
+
if (existsSync(ldmDest)) {
|
|
323
|
+
execSync(`rm -rf "${ldmDest}"`, { stdio: 'pipe' });
|
|
324
|
+
}
|
|
325
|
+
mkdirSync(ldmDest, { recursive: true });
|
|
326
|
+
cpSync(repoPath, ldmDest, {
|
|
327
|
+
recursive: true,
|
|
328
|
+
filter: (src) => !src.includes('.git') && !src.includes('node_modules') && !src.includes('ai/')
|
|
329
|
+
});
|
|
330
|
+
if (currentVersion) {
|
|
331
|
+
ok(`LDM: upgraded ${name} v${currentVersion} -> v${newVersion}`);
|
|
332
|
+
} else {
|
|
333
|
+
ok(`LDM: deployed to ${ldmDest}`);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Install deps in LDM
|
|
337
|
+
if (existsSync(join(ldmDest, 'package.json'))) {
|
|
338
|
+
try {
|
|
339
|
+
execSync('npm install --omit=dev', { cwd: ldmDest, stdio: 'pipe' });
|
|
340
|
+
ok(`LDM: dependencies installed`);
|
|
341
|
+
} catch {
|
|
342
|
+
skip(`LDM: no deps needed`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// OpenClaw path (copy from LDM to keep them identical)
|
|
347
|
+
if (existsSync(ocDest)) {
|
|
348
|
+
execSync(`rm -rf "${ocDest}"`, { stdio: 'pipe' });
|
|
349
|
+
}
|
|
350
|
+
mkdirSync(ocDest, { recursive: true });
|
|
351
|
+
cpSync(ldmDest, ocDest, { recursive: true });
|
|
352
|
+
ok(`OpenClaw: deployed to ${ocDest}`);
|
|
353
|
+
|
|
354
|
+
return true;
|
|
355
|
+
} catch (e) {
|
|
356
|
+
fail(`Deploy failed: ${e.message}`);
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function installOpenClaw(repoPath, door, toolName) {
|
|
362
|
+
// Use toolName (from package.json name, stripped of scope) for the directory.
|
|
363
|
+
// Never use door.config.name (display name) ... it can have spaces.
|
|
364
|
+
const name = toolName || door.config?.id || basename(repoPath);
|
|
365
|
+
return deployExtension(repoPath, name);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function registerMCP(repoPath, door, toolName) {
|
|
369
|
+
// Use toolName for the MCP registration name and LDM path lookup.
|
|
370
|
+
// Strip npm scope (@org/) from name for claude mcp add compatibility.
|
|
371
|
+
const rawName = toolName || door.name || basename(repoPath);
|
|
372
|
+
const name = rawName.replace(/^@[\w-]+\//, '');
|
|
373
|
+
const serverPath = join(repoPath, door.file);
|
|
374
|
+
// Use LDM-deployed path if it exists, otherwise repo path.
|
|
375
|
+
// Try toolName first (correct), then basename(repoPath) as fallback.
|
|
376
|
+
const ldmServerPath = join(LDM_EXTENSIONS, name, door.file);
|
|
377
|
+
const ldmFallbackPath = join(LDM_EXTENSIONS, basename(repoPath), door.file);
|
|
378
|
+
const mcpPath = existsSync(ldmServerPath) ? ldmServerPath
|
|
379
|
+
: existsSync(ldmFallbackPath) ? ldmFallbackPath
|
|
380
|
+
: serverPath;
|
|
381
|
+
|
|
382
|
+
// Check if already registered with the same path
|
|
383
|
+
const ccMcpPath = join(HOME, '.claude', '.mcp.json');
|
|
384
|
+
const ccMcp = readJSON(ccMcpPath);
|
|
385
|
+
const ccAlreadyRegistered = ccMcp?.mcpServers?.[name]?.args?.includes(mcpPath);
|
|
386
|
+
|
|
387
|
+
let ocAlreadyRegistered = false;
|
|
388
|
+
if (existsSync(OC_MCP)) {
|
|
389
|
+
const ocMcp = readJSON(OC_MCP);
|
|
390
|
+
ocAlreadyRegistered = ocMcp?.mcpServers?.[name]?.args?.includes(mcpPath);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (ccAlreadyRegistered && (ocAlreadyRegistered || !existsSync(OC_MCP))) {
|
|
394
|
+
skip(`MCP: ${name} already registered at ${mcpPath}`);
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (DRY_RUN) {
|
|
399
|
+
if (!ccAlreadyRegistered) ok(`MCP (CC): would register ${name} at user scope (dry run)`);
|
|
400
|
+
if (!ocAlreadyRegistered && existsSync(OC_MCP)) ok(`MCP (OC): would add to ${OC_MCP} (dry run)`);
|
|
401
|
+
return true;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// 1. Register with Claude Code CLI at user scope
|
|
405
|
+
let ccRegistered = ccAlreadyRegistered;
|
|
406
|
+
if (!ccAlreadyRegistered) {
|
|
407
|
+
try {
|
|
408
|
+
// Remove first if exists (update behavior)
|
|
409
|
+
try {
|
|
410
|
+
execSync(`claude mcp remove ${name} --scope user`, { stdio: 'pipe' });
|
|
411
|
+
} catch {}
|
|
412
|
+
const envFlag = existsSync(OC_ROOT) ? ` -e OPENCLAW_HOME="${OC_ROOT}"` : '';
|
|
413
|
+
execSync(`claude mcp add --scope user ${name}${envFlag} -- node "${mcpPath}"`, { stdio: 'pipe' });
|
|
414
|
+
ok(`MCP (CC): registered ${name} at user scope`);
|
|
415
|
+
ccRegistered = true;
|
|
416
|
+
} catch (e) {
|
|
417
|
+
// Fallback: write to ~/.claude/.mcp.json
|
|
418
|
+
try {
|
|
419
|
+
const mcpConfig = readJSON(ccMcpPath) || { mcpServers: {} };
|
|
420
|
+
mcpConfig.mcpServers[name] = {
|
|
421
|
+
command: 'node',
|
|
422
|
+
args: [mcpPath],
|
|
423
|
+
};
|
|
424
|
+
writeJSON(ccMcpPath, mcpConfig);
|
|
425
|
+
ok(`MCP (CC): registered ${name} in ~/.claude/.mcp.json (fallback)`);
|
|
426
|
+
ccRegistered = true;
|
|
427
|
+
} catch (e2) {
|
|
428
|
+
fail(`MCP (CC): registration failed. ${e.message}`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
} else {
|
|
432
|
+
skip(`MCP (CC): ${name} already registered`);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// 2. Register in OpenClaw's .mcp.json (only if the file already exists)
|
|
436
|
+
if (existsSync(OC_MCP) && !ocAlreadyRegistered) {
|
|
437
|
+
try {
|
|
438
|
+
const ocMcp = readJSON(OC_MCP) || { mcpServers: {} };
|
|
439
|
+
ocMcp.mcpServers[name] = {
|
|
440
|
+
command: 'node',
|
|
441
|
+
args: [mcpPath],
|
|
442
|
+
};
|
|
443
|
+
if (existsSync(OC_ROOT)) {
|
|
444
|
+
ocMcp.mcpServers[name].env = { OPENCLAW_HOME: OC_ROOT };
|
|
445
|
+
}
|
|
446
|
+
writeJSON(OC_MCP, ocMcp);
|
|
447
|
+
ok(`MCP (OC): registered ${name} in ${OC_MCP}`);
|
|
448
|
+
} catch (e) {
|
|
449
|
+
fail(`MCP (OC): registration failed. ${e.message}`);
|
|
450
|
+
}
|
|
451
|
+
} else if (existsSync(OC_MCP) && ocAlreadyRegistered) {
|
|
452
|
+
skip(`MCP (OC): ${name} already registered`);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return ccRegistered;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function installClaudeCodeHook(repoPath, door) {
|
|
459
|
+
const settingsPath = join(HOME, '.claude', 'settings.json');
|
|
460
|
+
let settings = readJSON(settingsPath);
|
|
461
|
+
|
|
462
|
+
if (!settings) {
|
|
463
|
+
skip(`Claude Code: no settings.json found at ${settingsPath}`);
|
|
464
|
+
return false;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Always use the installed extension path, never repo clones or /tmp/
|
|
468
|
+
const toolName = basename(repoPath);
|
|
469
|
+
const installedGuard = join(LDM_EXTENSIONS, toolName, 'guard.mjs');
|
|
470
|
+
const hookCommand = existsSync(installedGuard)
|
|
471
|
+
? `node ${installedGuard}`
|
|
472
|
+
: (door.command || `node "${join(repoPath, 'guard.mjs')}"`);
|
|
473
|
+
|
|
474
|
+
if (DRY_RUN) {
|
|
475
|
+
ok(`Claude Code: would add ${door.event || 'PreToolUse'} hook (dry run)`);
|
|
476
|
+
return true;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (!settings.hooks) settings.hooks = {};
|
|
480
|
+
const event = door.event || 'PreToolUse';
|
|
481
|
+
|
|
482
|
+
if (!settings.hooks[event]) settings.hooks[event] = [];
|
|
483
|
+
|
|
484
|
+
// Match by tool name in the command path, not exact string.
|
|
485
|
+
// This prevents duplicates when the same tool is installed from different paths.
|
|
486
|
+
const guardFile = basename(door.command || 'guard.mjs').replace(/^node\s+/, '').replace(/"/g, '');
|
|
487
|
+
const existingIdx = settings.hooks[event].findIndex(entry =>
|
|
488
|
+
entry.hooks?.some(h => {
|
|
489
|
+
const cmd = h.command || '';
|
|
490
|
+
return cmd.includes(`/${toolName}/`) || cmd === hookCommand;
|
|
491
|
+
})
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
if (existingIdx !== -1) {
|
|
495
|
+
const existingCmd = settings.hooks[event][existingIdx].hooks?.[0]?.command || '';
|
|
496
|
+
if (existingCmd === hookCommand) {
|
|
497
|
+
skip(`Claude Code: ${event} hook already configured`);
|
|
498
|
+
return true;
|
|
499
|
+
}
|
|
500
|
+
// Update the existing hook to point to the installed location
|
|
501
|
+
settings.hooks[event][existingIdx].hooks[0].command = hookCommand;
|
|
502
|
+
settings.hooks[event][existingIdx].hooks[0].timeout = door.timeout || 10;
|
|
503
|
+
try {
|
|
504
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
505
|
+
ok(`Claude Code: ${event} hook updated to installed path`);
|
|
506
|
+
return true;
|
|
507
|
+
} catch (e) {
|
|
508
|
+
fail(`Claude Code: failed to update settings.json. ${e.message}`);
|
|
509
|
+
return false;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
settings.hooks[event].push({
|
|
514
|
+
matcher: door.matcher || undefined,
|
|
515
|
+
hooks: [{
|
|
516
|
+
type: 'command',
|
|
517
|
+
command: hookCommand,
|
|
518
|
+
timeout: door.timeout || 10
|
|
519
|
+
}]
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
try {
|
|
523
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
524
|
+
ok(`Claude Code: ${event} hook added to settings.json`);
|
|
525
|
+
return true;
|
|
526
|
+
} catch (e) {
|
|
527
|
+
fail(`Claude Code: failed to update settings.json. ${e.message}`);
|
|
528
|
+
return false;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function installSkill(repoPath, toolName) {
|
|
533
|
+
const skillSrc = join(repoPath, 'SKILL.md');
|
|
534
|
+
const ocSkillDir = join(OC_ROOT, 'skills', toolName);
|
|
535
|
+
const ocSkillDest = join(ocSkillDir, 'SKILL.md');
|
|
536
|
+
|
|
537
|
+
// Check if already deployed with same content
|
|
538
|
+
if (existsSync(ocSkillDest)) {
|
|
539
|
+
try {
|
|
540
|
+
const srcContent = readFileSync(skillSrc, 'utf8');
|
|
541
|
+
const destContent = readFileSync(ocSkillDest, 'utf8');
|
|
542
|
+
if (srcContent === destContent) {
|
|
543
|
+
skip(`Skill: ${toolName} already deployed to OpenClaw`);
|
|
544
|
+
return true;
|
|
545
|
+
}
|
|
546
|
+
} catch {}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (DRY_RUN) {
|
|
550
|
+
ok(`Skill: would deploy ${toolName}/SKILL.md to ${ocSkillDir} (dry run)`);
|
|
551
|
+
return true;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
try {
|
|
555
|
+
mkdirSync(ocSkillDir, { recursive: true });
|
|
556
|
+
cpSync(skillSrc, ocSkillDest);
|
|
557
|
+
ok(`Skill: deployed to ${ocSkillDir}`);
|
|
558
|
+
return true;
|
|
559
|
+
} catch (e) {
|
|
560
|
+
fail(`Skill: deploy failed. ${e.message}`);
|
|
561
|
+
return false;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ── Worktree gitignore ──
|
|
566
|
+
|
|
567
|
+
function ensureWorktreeGitignore(repoPath) {
|
|
568
|
+
// Only for local repos, not /tmp/ clones
|
|
569
|
+
if (repoPath.startsWith('/tmp/')) return;
|
|
570
|
+
if (!existsSync(join(repoPath, '.git'))) return;
|
|
571
|
+
|
|
572
|
+
const gitignorePath = join(repoPath, '.gitignore');
|
|
573
|
+
const entry = '.claude/worktrees/';
|
|
574
|
+
|
|
575
|
+
if (existsSync(gitignorePath)) {
|
|
576
|
+
const content = readFileSync(gitignorePath, 'utf8');
|
|
577
|
+
if (content.includes(entry)) return; // already present
|
|
578
|
+
if (DRY_RUN) {
|
|
579
|
+
ok(`Gitignore: would add ${entry} to ${gitignorePath} (dry run)`);
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
const separator = content.endsWith('\n') ? '' : '\n';
|
|
583
|
+
writeFileSync(gitignorePath, content + separator + entry + '\n');
|
|
584
|
+
} else {
|
|
585
|
+
if (DRY_RUN) {
|
|
586
|
+
ok(`Gitignore: would create ${gitignorePath} with ${entry} (dry run)`);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
writeFileSync(gitignorePath, entry + '\n');
|
|
590
|
+
}
|
|
591
|
+
ok(`Gitignore: added ${entry} to .gitignore`);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// ── Single tool install ──
|
|
595
|
+
|
|
596
|
+
function installSingleTool(toolPath) {
|
|
597
|
+
const { interfaces, pkg } = detectInterfaces(toolPath);
|
|
598
|
+
const ifaceNames = Object.keys(interfaces);
|
|
599
|
+
|
|
600
|
+
if (ifaceNames.length === 0) return 0;
|
|
601
|
+
|
|
602
|
+
const toolName = pkg?.name?.replace(/^@\w+\//, '') || basename(toolPath);
|
|
603
|
+
|
|
604
|
+
if (!JSON_OUTPUT) {
|
|
605
|
+
console.log('');
|
|
606
|
+
console.log(` Installing: ${toolName}${DRY_RUN ? ' (dry run)' : ''}`);
|
|
607
|
+
console.log(` ${'─'.repeat(40)}`);
|
|
608
|
+
log(`Detected ${ifaceNames.length} interface(s): ${ifaceNames.join(', ')}`);
|
|
609
|
+
console.log('');
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
if (DRY_RUN && !JSON_OUTPUT) {
|
|
613
|
+
console.log(describeInterfaces(interfaces));
|
|
614
|
+
|
|
615
|
+
// Show migration preview in dry run
|
|
616
|
+
const existing = findExistingInstalls(toolName, pkg, interfaces.openclaw?.config);
|
|
617
|
+
if (existing.length > 0) {
|
|
618
|
+
console.log('');
|
|
619
|
+
for (const m of existing) {
|
|
620
|
+
log(`Migration: would rename "${m.dirName}" -> "${toolName}" (matched by ${m.matchType})`);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
return ifaceNames.length;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Detect and migrate existing installs under different names
|
|
628
|
+
const existing = findExistingInstalls(toolName, pkg, interfaces.openclaw?.config);
|
|
629
|
+
if (existing.length > 0) {
|
|
630
|
+
console.log('');
|
|
631
|
+
const migrated = new Set();
|
|
632
|
+
for (const m of existing) {
|
|
633
|
+
if (!migrated.has(m.dirName)) {
|
|
634
|
+
log(`Found existing install: "${m.dirName}" (matched by ${m.matchType}). Migrating to "${toolName}"...`);
|
|
635
|
+
migrateExistingInstall(m.dirName, toolName);
|
|
636
|
+
migrated.add(m.dirName);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
console.log('');
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
let installed = 0;
|
|
643
|
+
const registryInfo = {
|
|
644
|
+
name: toolName,
|
|
645
|
+
version: pkg?.version || 'unknown',
|
|
646
|
+
source: toolPath,
|
|
647
|
+
interfaces: ifaceNames,
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
if (interfaces.cli) {
|
|
651
|
+
if (installCLI(toolPath, interfaces.cli)) installed++;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Deploy to LDM + OpenClaw (for plugins or any extension with MCP)
|
|
655
|
+
if (interfaces.openclaw) {
|
|
656
|
+
if (installOpenClaw(toolPath, interfaces.openclaw, toolName)) {
|
|
657
|
+
installed++;
|
|
658
|
+
registryInfo.ldmPath = join(LDM_EXTENSIONS, toolName);
|
|
659
|
+
registryInfo.ocPath = join(OC_EXTENSIONS, toolName);
|
|
660
|
+
}
|
|
661
|
+
} else if (interfaces.mcp) {
|
|
662
|
+
// Even without openclaw.plugin.json, deploy to LDM for MCP server access
|
|
663
|
+
const extName = basename(toolPath);
|
|
664
|
+
if (deployExtension(toolPath, extName)) {
|
|
665
|
+
registryInfo.ldmPath = join(LDM_EXTENSIONS, extName);
|
|
666
|
+
registryInfo.ocPath = join(OC_EXTENSIONS, extName);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
if (interfaces.mcp) {
|
|
671
|
+
if (registerMCP(toolPath, interfaces.mcp, toolName)) installed++;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (interfaces.claudeCodeHook) {
|
|
675
|
+
if (installClaudeCodeHook(toolPath, interfaces.claudeCodeHook)) installed++;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
if (interfaces.skill) {
|
|
679
|
+
if (installSkill(toolPath, toolName)) installed++;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
if (interfaces.module) {
|
|
683
|
+
ok(`Module: import from "${interfaces.module.main}"`);
|
|
684
|
+
installed++;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Update registry
|
|
688
|
+
if (!DRY_RUN) {
|
|
689
|
+
try {
|
|
690
|
+
mkdirSync(LDM_EXTENSIONS, { recursive: true });
|
|
691
|
+
updateRegistry(toolName, registryInfo);
|
|
692
|
+
ok(`Registry: updated`);
|
|
693
|
+
} catch (e) {
|
|
694
|
+
fail(`Registry: update failed. ${e.message}`);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Ensure .claude/worktrees/ is in the repo's .gitignore
|
|
699
|
+
ensureWorktreeGitignore(toolPath);
|
|
700
|
+
|
|
701
|
+
return installed;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// ── Main ──
|
|
705
|
+
|
|
706
|
+
async function main() {
|
|
707
|
+
if (!target || target === '--help' || target === '-h') {
|
|
708
|
+
console.log('');
|
|
709
|
+
console.log(' wip-install ... the reference installer for agent-native software');
|
|
710
|
+
console.log('');
|
|
711
|
+
console.log(' Usage:');
|
|
712
|
+
console.log(' wip-install /path/to/repo');
|
|
713
|
+
console.log(' wip-install https://github.com/org/repo');
|
|
714
|
+
console.log(' wip-install org/repo');
|
|
715
|
+
console.log('');
|
|
716
|
+
console.log(' Flags:');
|
|
717
|
+
console.log(' --dry-run Detect interfaces without installing anything');
|
|
718
|
+
console.log(' --json Output detection results as JSON');
|
|
719
|
+
console.log('');
|
|
720
|
+
console.log(' Interfaces it detects and installs:');
|
|
721
|
+
console.log(' CLI ... package.json bin entry -> npm install -g');
|
|
722
|
+
console.log(' Module ... ESM main/exports -> importable');
|
|
723
|
+
console.log(' MCP Server ... mcp-server.mjs -> claude mcp add --scope user');
|
|
724
|
+
console.log(' OpenClaw ... openclaw.plugin.json -> ~/.ldm/extensions/ + ~/.openclaw/extensions/');
|
|
725
|
+
console.log(' Skill ... SKILL.md -> ~/.openclaw/skills/<tool>/');
|
|
726
|
+
console.log(' CC Hook ... guard.mjs or claudeCode.hook -> ~/.claude/settings.json');
|
|
727
|
+
console.log('');
|
|
728
|
+
console.log(' Modes:');
|
|
729
|
+
console.log(' Single repo ... installs one tool');
|
|
730
|
+
console.log(' Toolbox ... detects tools/ subdirectories, installs each sub-tool');
|
|
731
|
+
console.log('');
|
|
732
|
+
console.log(' Paths:');
|
|
733
|
+
console.log(' LDM: ~/.ldm/extensions/<name>/ (primary, for Claude Code)');
|
|
734
|
+
console.log(' OpenClaw: ~/.openclaw/extensions/<name>/ (for Lesa/OpenClaw)');
|
|
735
|
+
console.log(' Registry: ~/.ldm/extensions/registry.json');
|
|
736
|
+
console.log('');
|
|
737
|
+
process.exit(0);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// ── LDM bootstrap ──
|
|
741
|
+
// If ldm is not on PATH, try to install it silently before falling back.
|
|
742
|
+
function bootstrapLdmOs() {
|
|
743
|
+
try {
|
|
744
|
+
execSync('npm install -g @wipcomputer/wip-ldm-os', { stdio: 'pipe', timeout: 120000 });
|
|
745
|
+
execSync('ldm --version', { stdio: 'pipe', timeout: 5000 });
|
|
746
|
+
return true;
|
|
747
|
+
} catch {
|
|
748
|
+
return false;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// ── LDM delegation ──
|
|
753
|
+
// If the ldm CLI is on PATH, delegate the entire install to it.
|
|
754
|
+
// ldm install understands all the same interfaces and adds LDM OS orchestration.
|
|
755
|
+
// Check early, before URL/path resolution, so we don't clone unnecessarily.
|
|
756
|
+
let ldmAvailable = false;
|
|
757
|
+
try {
|
|
758
|
+
execSync('ldm --version', { stdio: 'pipe' });
|
|
759
|
+
ldmAvailable = true;
|
|
760
|
+
|
|
761
|
+
// ldm is available, delegate
|
|
762
|
+
if (!JSON_OUTPUT) {
|
|
763
|
+
console.log('');
|
|
764
|
+
console.log(' LDM OS detected. Delegating to ldm install...');
|
|
765
|
+
console.log('');
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const flags = args.filter(a => a.startsWith('--'));
|
|
769
|
+
const rawTarget = process.argv[2];
|
|
770
|
+
|
|
771
|
+
execSync(`ldm install ${rawTarget} ${flags.join(' ')}`, { stdio: 'inherit' });
|
|
772
|
+
|
|
773
|
+
if (!JSON_OUTPUT) {
|
|
774
|
+
console.log('');
|
|
775
|
+
console.log(' Tip: Run "ldm install" to see more components you can add.');
|
|
776
|
+
}
|
|
777
|
+
process.exit(0);
|
|
778
|
+
|
|
779
|
+
} catch (e) {
|
|
780
|
+
if (!ldmAvailable) {
|
|
781
|
+
// ldm not on PATH, try bootstrap
|
|
782
|
+
if (!JSON_OUTPUT) {
|
|
783
|
+
console.log('');
|
|
784
|
+
console.log(' Installing LDM OS infrastructure...');
|
|
785
|
+
console.log('');
|
|
786
|
+
}
|
|
787
|
+
if (bootstrapLdmOs()) {
|
|
788
|
+
ldmAvailable = true;
|
|
789
|
+
if (!JSON_OUTPUT) {
|
|
790
|
+
console.log(' LDM OS installed. Delegating to ldm install...');
|
|
791
|
+
console.log('');
|
|
792
|
+
}
|
|
793
|
+
// Now delegate
|
|
794
|
+
const flags = args.filter(a => a.startsWith('--'));
|
|
795
|
+
const rawTarget = process.argv[2];
|
|
796
|
+
try {
|
|
797
|
+
execSync(`ldm install ${rawTarget} ${flags.join(' ')}`, { stdio: 'inherit' });
|
|
798
|
+
process.exit(0);
|
|
799
|
+
} catch (delegateErr) {
|
|
800
|
+
if (!JSON_OUTPUT) console.error(' ldm install failed. Falling back to standalone installer.');
|
|
801
|
+
}
|
|
802
|
+
} else {
|
|
803
|
+
if (!JSON_OUTPUT) {
|
|
804
|
+
console.log(' LDM OS install skipped (npm offline or permissions issue). Using standalone.');
|
|
805
|
+
console.log('');
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
} else {
|
|
809
|
+
// ldm exists but install command failed
|
|
810
|
+
if (!JSON_OUTPUT) console.error(' ldm install failed. Falling back to standalone installer.');
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// Resolve target: GitHub URL, org/repo shorthand, or local path
|
|
815
|
+
let repoPath;
|
|
816
|
+
|
|
817
|
+
if (target.startsWith('http') || target.startsWith('git@') || target.match(/^[\w-]+\/[\w.-]+$/)) {
|
|
818
|
+
const isShorthand = target.match(/^[\w-]+\/[\w.-]+$/);
|
|
819
|
+
const httpsUrl = isShorthand
|
|
820
|
+
? `https://github.com/${target}.git`
|
|
821
|
+
: target;
|
|
822
|
+
const sshUrl = isShorthand
|
|
823
|
+
? `git@github.com:${target}.git`
|
|
824
|
+
: target.replace(/^https:\/\/github\.com\//, 'git@github.com:');
|
|
825
|
+
const repoName = basename(httpsUrl).replace('.git', '');
|
|
826
|
+
repoPath = join('/tmp', `wip-install-${repoName}`);
|
|
827
|
+
|
|
828
|
+
log('');
|
|
829
|
+
log(`Cloning ${httpsUrl}...`);
|
|
830
|
+
try {
|
|
831
|
+
if (existsSync(repoPath)) {
|
|
832
|
+
execSync(`rm -rf "${repoPath}"`);
|
|
833
|
+
}
|
|
834
|
+
try {
|
|
835
|
+
execSync(`git clone "${httpsUrl}" "${repoPath}"`, { stdio: 'pipe' });
|
|
836
|
+
} catch {
|
|
837
|
+
// HTTPS failed (private repo or no auth). Fall back to SSH.
|
|
838
|
+
log(`HTTPS clone failed. Trying SSH...`);
|
|
839
|
+
if (existsSync(repoPath)) execSync(`rm -rf "${repoPath}"`);
|
|
840
|
+
execSync(`git clone "${sshUrl}" "${repoPath}"`, { stdio: 'pipe' });
|
|
841
|
+
}
|
|
842
|
+
ok(`Cloned to ${repoPath}`);
|
|
843
|
+
} catch (e) {
|
|
844
|
+
fail(`Clone failed (tried HTTPS + SSH): ${e.message}`);
|
|
845
|
+
process.exit(1);
|
|
846
|
+
}
|
|
847
|
+
} else {
|
|
848
|
+
repoPath = resolve(target);
|
|
849
|
+
if (!existsSync(repoPath)) {
|
|
850
|
+
fail(`Path not found: ${repoPath}`);
|
|
851
|
+
process.exit(1);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// Check for toolbox mode (tools/ subdirectories with package.json)
|
|
856
|
+
const subTools = detectToolbox(repoPath);
|
|
857
|
+
|
|
858
|
+
if (subTools.length > 0) {
|
|
859
|
+
// Toolbox mode: install each sub-tool
|
|
860
|
+
const toolboxPkg = readJSON(join(repoPath, 'package.json'));
|
|
861
|
+
const toolboxName = toolboxPkg?.name?.replace(/^@\w+\//, '') || basename(repoPath);
|
|
862
|
+
|
|
863
|
+
if (!JSON_OUTPUT) {
|
|
864
|
+
console.log('');
|
|
865
|
+
console.log(` Toolbox: ${toolboxName}`);
|
|
866
|
+
console.log(` ${'═'.repeat(50)}`);
|
|
867
|
+
log(`Found ${subTools.length} sub-tool(s): ${subTools.map(t => t.name).join(', ')}`);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
let totalInstalled = 0;
|
|
871
|
+
let toolsProcessed = 0;
|
|
872
|
+
|
|
873
|
+
for (const subTool of subTools) {
|
|
874
|
+
const count = installSingleTool(subTool.path);
|
|
875
|
+
totalInstalled += count;
|
|
876
|
+
if (count > 0) toolsProcessed++;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
if (!JSON_OUTPUT) {
|
|
880
|
+
console.log('');
|
|
881
|
+
console.log(` ${'═'.repeat(50)}`);
|
|
882
|
+
if (DRY_RUN) {
|
|
883
|
+
console.log(` Dry run complete. ${toolsProcessed} tool(s) scanned, ${totalInstalled} interface(s) detected.`);
|
|
884
|
+
} else {
|
|
885
|
+
console.log(` Done. ${toolsProcessed} tool(s), ${totalInstalled} interface(s) processed.`);
|
|
886
|
+
}
|
|
887
|
+
console.log('');
|
|
888
|
+
}
|
|
889
|
+
} else {
|
|
890
|
+
// Single repo mode
|
|
891
|
+
if (JSON_OUTPUT) {
|
|
892
|
+
const result = detectInterfacesJSON(repoPath);
|
|
893
|
+
console.log(JSON.stringify(result, null, 2));
|
|
894
|
+
if (DRY_RUN) process.exit(0);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const installed = installSingleTool(repoPath);
|
|
898
|
+
|
|
899
|
+
if (installed === 0) {
|
|
900
|
+
skip('No installable interfaces detected.');
|
|
901
|
+
process.exit(0);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
if (!JSON_OUTPUT) {
|
|
905
|
+
console.log('');
|
|
906
|
+
if (DRY_RUN) {
|
|
907
|
+
console.log(' Dry run complete. No changes made.');
|
|
908
|
+
} else {
|
|
909
|
+
console.log(` Done. ${installed} interface(s) processed.`);
|
|
910
|
+
}
|
|
911
|
+
console.log('');
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// ── LDM OS tip (standalone install only) ──
|
|
916
|
+
// We only reach here if ldm was not available or ldm install failed.
|
|
917
|
+
if (!JSON_OUTPUT && !DRY_RUN) {
|
|
918
|
+
if (ldmAvailable) {
|
|
919
|
+
console.log(' Tip: Run "ldm install" to see more components you can add.');
|
|
920
|
+
} else {
|
|
921
|
+
console.log(' Tip: LDM OS could not be installed automatically. Try: npm install -g @wipcomputer/wip-ldm-os');
|
|
922
|
+
}
|
|
923
|
+
console.log('');
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
main().catch(e => {
|
|
928
|
+
fail(e.message);
|
|
929
|
+
process.exit(1);
|
|
930
|
+
});
|