@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 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.79"
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 inside ~/.ldm/tmp/, it was cloned from somewhere.
249
- // Try to get the remote URL from git.
250
- if (!source.repo) {
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.79",
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');