@wipcomputer/wip-ldm-os 0.4.79 → 0.4.81
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/SKILL.md +1 -1
- package/bin/ldm.js +57 -0
- package/lib/deploy.mjs +149 -3
- package/package.json +2 -1
- package/scripts/test-skill-frontmatter.mjs +44 -0
package/SKILL.md
CHANGED
|
@@ -9,7 +9,7 @@ license: MIT
|
|
|
9
9
|
compatibility: Requires git, npm, node. Node.js 18+.
|
|
10
10
|
metadata:
|
|
11
11
|
display-name: "LDM OS"
|
|
12
|
-
version: "0.4.
|
|
12
|
+
version: "0.4.81"
|
|
13
13
|
homepage: "https://github.com/wipcomputer/wip-ldm-os"
|
|
14
14
|
author: "Parker Todd Brooks"
|
|
15
15
|
category: infrastructure
|
package/bin/ldm.js
CHANGED
|
@@ -2892,6 +2892,63 @@ async function cmdDoctor() {
|
|
|
2892
2892
|
cleanupStaleClaudeCodeEnv();
|
|
2893
2893
|
}
|
|
2894
2894
|
|
|
2895
|
+
// 3b. MCP health check (Phase 3c)
|
|
2896
|
+
//
|
|
2897
|
+
// Walk ~/.claude.json#mcpServers. For every entry whose command is node
|
|
2898
|
+
// and whose first arg resolves under ~/.ldm/extensions/ or ~/.openclaw/
|
|
2899
|
+
// extensions/, verify the file exists and parses. Report any that do not.
|
|
2900
|
+
//
|
|
2901
|
+
// This catches the failure mode where an extension renamed its MCP file,
|
|
2902
|
+
// updated its deployed artifacts, but the old .claude.json entry still
|
|
2903
|
+
// points at a path that was rotated into _trash. Surfacing these in
|
|
2904
|
+
// doctor makes the class of failure loud instead of silent.
|
|
2905
|
+
{
|
|
2906
|
+
const ccUserPath = join(HOME, '.claude.json');
|
|
2907
|
+
const ccUser = readJSON(ccUserPath);
|
|
2908
|
+
const ldmExtRoot = LDM_EXTENSIONS;
|
|
2909
|
+
const ocExtRoot = join(HOME, '.openclaw', 'extensions');
|
|
2910
|
+
const broken = [];
|
|
2911
|
+
if (ccUser?.mcpServers) {
|
|
2912
|
+
for (const [name, cfg] of Object.entries(ccUser.mcpServers)) {
|
|
2913
|
+
if (cfg?.command !== 'node') continue;
|
|
2914
|
+
const args = Array.isArray(cfg.args) ? cfg.args : [];
|
|
2915
|
+
const first = args[0];
|
|
2916
|
+
if (!first || typeof first !== 'string') continue;
|
|
2917
|
+
const underExt = first.startsWith(ldmExtRoot + '/') || first.startsWith(ocExtRoot + '/');
|
|
2918
|
+
if (!underExt) continue;
|
|
2919
|
+
if (!existsSync(first)) {
|
|
2920
|
+
broken.push({ name, path: first, reason: 'missing' });
|
|
2921
|
+
continue;
|
|
2922
|
+
}
|
|
2923
|
+
try {
|
|
2924
|
+
execSync(`node --check "${first}"`, { stdio: 'pipe', timeout: 5000 });
|
|
2925
|
+
} catch (e) {
|
|
2926
|
+
const stderr = (e && e.stderr && e.stderr.toString && e.stderr.toString().trim()) || (e && e.message) || 'unparseable';
|
|
2927
|
+
broken.push({ name, path: first, reason: 'unparseable', detail: stderr.split('\n')[0] });
|
|
2928
|
+
}
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2931
|
+
if (broken.length > 0) {
|
|
2932
|
+
for (const b of broken) {
|
|
2933
|
+
const detail = b.detail ? ` (${b.detail})` : '';
|
|
2934
|
+
console.log(` ! MCP ${b.name}: ${b.reason} at ${b.path}${detail}`);
|
|
2935
|
+
}
|
|
2936
|
+
if (FIX_FLAG && ccUser?.mcpServers) {
|
|
2937
|
+
for (const b of broken) {
|
|
2938
|
+
delete ccUser.mcpServers[b.name];
|
|
2939
|
+
console.log(` + Removed dangling MCP: ${b.name}`);
|
|
2940
|
+
}
|
|
2941
|
+
writeFileSync(ccUserPath, JSON.stringify(ccUser, null, 2) + '\n');
|
|
2942
|
+
// --fix resolved them, do not count as outstanding issues
|
|
2943
|
+
} else {
|
|
2944
|
+
console.log(` Run: ldm doctor --fix to remove ${broken.length} dangling MCP entr${broken.length === 1 ? 'y' : 'ies'}`);
|
|
2945
|
+
issues += broken.length;
|
|
2946
|
+
}
|
|
2947
|
+
} else if (ccUser?.mcpServers && Object.keys(ccUser.mcpServers).length > 0) {
|
|
2948
|
+
console.log(` + MCP entries under LDM/OC extensions: all paths exist and parse`);
|
|
2949
|
+
}
|
|
2950
|
+
}
|
|
2951
|
+
|
|
2895
2952
|
// 4. Check sacred locations
|
|
2896
2953
|
const sacred = [
|
|
2897
2954
|
{ path: join(LDM_ROOT, 'memory'), label: 'memory/' },
|
package/lib/deploy.mjs
CHANGED
|
@@ -59,6 +59,57 @@ function writeJSON(path, data) {
|
|
|
59
59
|
writeFileSync(path, JSON.stringify(data, null, 2) + '\n');
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
export function validateSkillFrontmatter(path) {
|
|
63
|
+
let content;
|
|
64
|
+
try {
|
|
65
|
+
content = readFileSync(path, 'utf8');
|
|
66
|
+
} catch (e) {
|
|
67
|
+
return { ok: false, line: 1, message: `cannot read SKILL.md: ${e.message}` };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const lines = content.split(/\r?\n/);
|
|
71
|
+
if (lines[0] !== '---') {
|
|
72
|
+
return { ok: false, line: 1, message: 'SKILL.md must start with YAML frontmatter' };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let end = -1;
|
|
76
|
+
for (let i = 1; i < lines.length; i++) {
|
|
77
|
+
if (lines[i] === '---') {
|
|
78
|
+
end = i;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (end === -1) {
|
|
83
|
+
return { ok: false, line: 1, message: 'SKILL.md frontmatter is missing a closing --- marker' };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (let i = 1; i < end; i++) {
|
|
87
|
+
const line = lines[i];
|
|
88
|
+
const trimmed = line.trim();
|
|
89
|
+
if (!trimmed || trimmed.startsWith('#') || /^\s/.test(line)) continue;
|
|
90
|
+
|
|
91
|
+
const match = /^([A-Za-z0-9_-]+):(?:\s*(.*))?$/.exec(line);
|
|
92
|
+
if (!match) {
|
|
93
|
+
return { ok: false, line: i + 1, message: 'frontmatter line is not a simple key/value mapping' };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const value = match[2] || '';
|
|
97
|
+
const valueTrimmed = value.trimStart();
|
|
98
|
+
const first = valueTrimmed[0] || '';
|
|
99
|
+
const isQuoted = first === '"' || first === "'";
|
|
100
|
+
const isStructured = first === '[' || first === '{' || first === '|' || first === '>';
|
|
101
|
+
if (valueTrimmed.includes(': ') && !isQuoted && !isStructured) {
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
line: i + 1,
|
|
105
|
+
message: 'value contains an unquoted colon; quote the scalar value',
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { ok: true };
|
|
111
|
+
}
|
|
112
|
+
|
|
62
113
|
function ensureBinExecutable(binNames) {
|
|
63
114
|
try {
|
|
64
115
|
const npmPrefix = execSync('npm config get prefix', { encoding: 'utf8' }).trim();
|
|
@@ -245,9 +296,17 @@ function buildSourceInfo(repoPath, pkg) {
|
|
|
245
296
|
hasInfo = true;
|
|
246
297
|
}
|
|
247
298
|
|
|
248
|
-
// If the repo path is
|
|
249
|
-
//
|
|
250
|
-
|
|
299
|
+
// If the repo path is itself a git working tree, trust its origin URL.
|
|
300
|
+
// Previously this ran git remote unconditionally, which walks up the
|
|
301
|
+
// directory tree. For npm-sourced installs extracted under ~/.ldm/tmp/
|
|
302
|
+
// (inside the tracked ~/.ldm repo), git happily returned the parent
|
|
303
|
+
// tracking repo's remote (wipcomputer/...-system-private) as the
|
|
304
|
+
// source for every extension. Registry source.repo was therefore
|
|
305
|
+
// unreliable. Now we only consult git if repoPath itself has a .git
|
|
306
|
+
// entry (directory for normal clones, file for worktrees). If it
|
|
307
|
+
// does not, we leave source.repo unset rather than capturing an
|
|
308
|
+
// ancestor's remote.
|
|
309
|
+
if (!source.repo && existsSync(join(repoPath, '.git'))) {
|
|
251
310
|
try {
|
|
252
311
|
const remote = execSync('git remote get-url origin 2>/dev/null', {
|
|
253
312
|
cwd: repoPath,
|
|
@@ -882,6 +941,60 @@ function verifyOcConfig(pluginDirName) {
|
|
|
882
941
|
}
|
|
883
942
|
}
|
|
884
943
|
|
|
944
|
+
/**
|
|
945
|
+
* Phase 3b: remove a stale MCP registration for an extension whose current
|
|
946
|
+
* source no longer exposes an MCP interface. Keyed on path, not source
|
|
947
|
+
* metadata (buildSourceInfo is known to capture the parent repo's remote
|
|
948
|
+
* when extraction lands inside another git working tree).
|
|
949
|
+
*
|
|
950
|
+
* Removes the entry from ~/.claude.json#mcpServers if the args path
|
|
951
|
+
* resolves under LDM_EXTENSIONS/<toolName> or OC_EXTENSIONS/<toolName>.
|
|
952
|
+
* No-op if no entry exists or the path points elsewhere.
|
|
953
|
+
*/
|
|
954
|
+
function unregisterStaleMCP(toolName) {
|
|
955
|
+
const ccUserPath = join(HOME, '.claude.json');
|
|
956
|
+
const ccUser = readJSON(ccUserPath);
|
|
957
|
+
const entry = ccUser?.mcpServers?.[toolName];
|
|
958
|
+
if (!entry) return;
|
|
959
|
+
|
|
960
|
+
const firstArg = Array.isArray(entry.args) ? entry.args[0] : null;
|
|
961
|
+
if (!firstArg || typeof firstArg !== 'string') return;
|
|
962
|
+
|
|
963
|
+
const ldmExt = join(LDM_EXTENSIONS, toolName);
|
|
964
|
+
const ocExt = join(OC_EXTENSIONS, toolName);
|
|
965
|
+
const pointsHere = firstArg.startsWith(ldmExt + '/') || firstArg.startsWith(ocExt + '/');
|
|
966
|
+
if (!pointsHere) return;
|
|
967
|
+
|
|
968
|
+
if (DRY_RUN) {
|
|
969
|
+
ok(`MCP: would unregister stale ${toolName} entry pointing at ${firstArg} (dry run)`);
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
try {
|
|
974
|
+
execSync(`claude mcp remove ${toolName} --scope user`, { stdio: 'pipe' });
|
|
975
|
+
ok(`MCP: unregistered stale ${toolName} entry (source no longer exposes MCP)`);
|
|
976
|
+
} catch {
|
|
977
|
+
// Fallback: direct edit to ~/.claude.json
|
|
978
|
+
try {
|
|
979
|
+
const cfg = readJSON(ccUserPath) || {};
|
|
980
|
+
if (cfg.mcpServers && cfg.mcpServers[toolName]) {
|
|
981
|
+
delete cfg.mcpServers[toolName];
|
|
982
|
+
writeJSON(ccUserPath, cfg);
|
|
983
|
+
ok(`MCP: unregistered stale ${toolName} entry via direct .claude.json edit`);
|
|
984
|
+
}
|
|
985
|
+
} catch (e) {
|
|
986
|
+
log(`Warning: could not unregister stale MCP ${toolName}: ${e.message}`);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// Also clean OpenClaw side if installed.
|
|
991
|
+
try {
|
|
992
|
+
execSync(`openclaw mcp unset ${toolName}`, { stdio: 'pipe' });
|
|
993
|
+
} catch {
|
|
994
|
+
// Non-fatal. OpenClaw may not be installed, or may not have had this mcp.
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
885
998
|
function registerMCP(repoPath, door, toolName) {
|
|
886
999
|
let rawName = toolName || door.name || basename(repoPath);
|
|
887
1000
|
// Strip /tmp/ clone prefixes (ldm-install-, wip-install-)
|
|
@@ -894,6 +1007,27 @@ function registerMCP(repoPath, door, toolName) {
|
|
|
894
1007
|
: existsSync(ldmFallbackPath) ? ldmFallbackPath
|
|
895
1008
|
: repoServerPath;
|
|
896
1009
|
|
|
1010
|
+
// Postcondition: the resolved entrypoint must exist and parse before we
|
|
1011
|
+
// touch ~/.claude.json. Previously, if the published tarball did not
|
|
1012
|
+
// contain the declared mcp-server file (see wip-1password 0.2.3-alpha.2
|
|
1013
|
+
// bug writeup), we still wrote the registration and left a dangling
|
|
1014
|
+
// "Failed to connect" entry that was invisible until the user ran
|
|
1015
|
+
// `claude mcp list`. Fail loudly instead.
|
|
1016
|
+
if (!existsSync(mcpPath)) {
|
|
1017
|
+
fail(`MCP: ${name} registration aborted. Resolved path does not exist: ${mcpPath}`);
|
|
1018
|
+
fail(`MCP: candidates checked: ${ldmServerPath}, ${ldmFallbackPath}, ${repoServerPath}`);
|
|
1019
|
+
fail(`MCP: verify the published package includes "${door.file}" (check files array).`);
|
|
1020
|
+
return false;
|
|
1021
|
+
}
|
|
1022
|
+
try {
|
|
1023
|
+
execSync(`node --check "${mcpPath}"`, { stdio: 'pipe', timeout: 5000 });
|
|
1024
|
+
} catch (e) {
|
|
1025
|
+
const stderr = (e && e.stderr && e.stderr.toString && e.stderr.toString().trim()) || (e && e.message) || 'unknown error';
|
|
1026
|
+
fail(`MCP: ${name} registration aborted. Entrypoint failed node --check: ${mcpPath}`);
|
|
1027
|
+
fail(`MCP: ${stderr}`);
|
|
1028
|
+
return false;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
897
1031
|
// Check ~/.claude.json (user-level MCP)
|
|
898
1032
|
const ccUserPath = join(HOME, '.claude.json');
|
|
899
1033
|
const ccUser = readJSON(ccUserPath);
|
|
@@ -1112,6 +1246,12 @@ function installSkill(repoPath, toolName) {
|
|
|
1112
1246
|
if (!existsSync(skillSrc) && existsSync(permanentSkill)) skillSrc = permanentSkill;
|
|
1113
1247
|
if (!existsSync(skillSrc)) return false;
|
|
1114
1248
|
|
|
1249
|
+
const frontmatter = validateSkillFrontmatter(skillSrc);
|
|
1250
|
+
if (!frontmatter.ok) {
|
|
1251
|
+
fail(`Skill: invalid SKILL.md frontmatter at ${skillSrc}:${frontmatter.line}: ${frontmatter.message}`);
|
|
1252
|
+
return false;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1115
1255
|
// Find references/ source: repo path first, then permanent copy
|
|
1116
1256
|
let refsSrc = join(repoPath, 'references');
|
|
1117
1257
|
const permanentRefs = join(LDM_EXTENSIONS, toolName, 'references');
|
|
@@ -1272,6 +1412,12 @@ export function installSingleTool(toolPath) {
|
|
|
1272
1412
|
} else {
|
|
1273
1413
|
skip(`MCP: ${toolName} not enabled. Run: ldm enable ${toolName}`);
|
|
1274
1414
|
}
|
|
1415
|
+
} else {
|
|
1416
|
+
// Phase 3b: source no longer exposes an MCP interface (file renamed,
|
|
1417
|
+
// moved to src/, removed, etc). If a prior install registered an MCP
|
|
1418
|
+
// whose args point into this extension's directory, un-register it so
|
|
1419
|
+
// claude mcp list does not keep a dangling "Failed to connect" entry.
|
|
1420
|
+
unregisterStaleMCP(toolName);
|
|
1275
1421
|
}
|
|
1276
1422
|
|
|
1277
1423
|
if (interfaces.claudeCodeHook) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wipcomputer/wip-ldm-os",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.81",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "LDM OS: identity, memory, and sovereignty infrastructure for AI agents",
|
|
6
6
|
"engines": {
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"build:bridge": "cd src/bridge && npm install && npx tsup core.ts mcp-server.ts cli.ts openclaw.ts --format esm --dts --clean --outDir ../../dist/bridge",
|
|
20
20
|
"build": "npm run build:bridge",
|
|
21
21
|
"prepublishOnly": "npm run build:bridge",
|
|
22
|
+
"test:skill-frontmatter": "node scripts/test-skill-frontmatter.mjs",
|
|
22
23
|
"fmt": "npx prettier --write 'src/**/*.{ts,mjs}' 'lib/**/*.mjs' 'bin/**/*.js'",
|
|
23
24
|
"fmt:check": "npx prettier --check 'src/**/*.{ts,mjs}' 'lib/**/*.mjs' 'bin/**/*.js'"
|
|
24
25
|
},
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdtempSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { validateSkillFrontmatter } from '../lib/deploy.mjs';
|
|
6
|
+
|
|
7
|
+
const dir = mkdtempSync(join(tmpdir(), 'ldm-skill-frontmatter-'));
|
|
8
|
+
const bad = join(dir, 'bad-SKILL.md');
|
|
9
|
+
const good = join(dir, 'good-SKILL.md');
|
|
10
|
+
|
|
11
|
+
writeFileSync(bad, [
|
|
12
|
+
'---',
|
|
13
|
+
'name: bad',
|
|
14
|
+
'description: Read when: guard blocks a tool call',
|
|
15
|
+
'---',
|
|
16
|
+
'',
|
|
17
|
+
'# Bad',
|
|
18
|
+
'',
|
|
19
|
+
].join('\n'));
|
|
20
|
+
|
|
21
|
+
writeFileSync(good, [
|
|
22
|
+
'---',
|
|
23
|
+
'name: good',
|
|
24
|
+
'description: "Read when: guard blocks a tool call"',
|
|
25
|
+
'---',
|
|
26
|
+
'',
|
|
27
|
+
'# Good',
|
|
28
|
+
'',
|
|
29
|
+
].join('\n'));
|
|
30
|
+
|
|
31
|
+
const badResult = validateSkillFrontmatter(bad);
|
|
32
|
+
if (badResult.ok) {
|
|
33
|
+
throw new Error('expected unquoted colon frontmatter to be rejected');
|
|
34
|
+
}
|
|
35
|
+
if (badResult.line !== 3) {
|
|
36
|
+
throw new Error(`expected failure on line 3, got line ${badResult.line}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const goodResult = validateSkillFrontmatter(good);
|
|
40
|
+
if (!goodResult.ok) {
|
|
41
|
+
throw new Error(`expected quoted frontmatter to pass: ${goodResult.message}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
console.log('skill frontmatter regression passed');
|