@wipcomputer/wip-ldm-os 0.4.79 → 0.4.80

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.80"
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
@@ -245,9 +245,17 @@ function buildSourceInfo(repoPath, pkg) {
245
245
  hasInfo = true;
246
246
  }
247
247
 
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) {
248
+ // If the repo path is itself a git working tree, trust its origin URL.
249
+ // Previously this ran git remote unconditionally, which walks up the
250
+ // directory tree. For npm-sourced installs extracted under ~/.ldm/tmp/
251
+ // (inside the tracked ~/.ldm repo), git happily returned the parent
252
+ // tracking repo's remote (wipcomputer/...-system-private) as the
253
+ // source for every extension. Registry source.repo was therefore
254
+ // unreliable. Now we only consult git if repoPath itself has a .git
255
+ // entry (directory for normal clones, file for worktrees). If it
256
+ // does not, we leave source.repo unset rather than capturing an
257
+ // ancestor's remote.
258
+ if (!source.repo && existsSync(join(repoPath, '.git'))) {
251
259
  try {
252
260
  const remote = execSync('git remote get-url origin 2>/dev/null', {
253
261
  cwd: repoPath,
@@ -882,6 +890,60 @@ function verifyOcConfig(pluginDirName) {
882
890
  }
883
891
  }
884
892
 
893
+ /**
894
+ * Phase 3b: remove a stale MCP registration for an extension whose current
895
+ * source no longer exposes an MCP interface. Keyed on path, not source
896
+ * metadata (buildSourceInfo is known to capture the parent repo's remote
897
+ * when extraction lands inside another git working tree).
898
+ *
899
+ * Removes the entry from ~/.claude.json#mcpServers if the args path
900
+ * resolves under LDM_EXTENSIONS/<toolName> or OC_EXTENSIONS/<toolName>.
901
+ * No-op if no entry exists or the path points elsewhere.
902
+ */
903
+ function unregisterStaleMCP(toolName) {
904
+ const ccUserPath = join(HOME, '.claude.json');
905
+ const ccUser = readJSON(ccUserPath);
906
+ const entry = ccUser?.mcpServers?.[toolName];
907
+ if (!entry) return;
908
+
909
+ const firstArg = Array.isArray(entry.args) ? entry.args[0] : null;
910
+ if (!firstArg || typeof firstArg !== 'string') return;
911
+
912
+ const ldmExt = join(LDM_EXTENSIONS, toolName);
913
+ const ocExt = join(OC_EXTENSIONS, toolName);
914
+ const pointsHere = firstArg.startsWith(ldmExt + '/') || firstArg.startsWith(ocExt + '/');
915
+ if (!pointsHere) return;
916
+
917
+ if (DRY_RUN) {
918
+ ok(`MCP: would unregister stale ${toolName} entry pointing at ${firstArg} (dry run)`);
919
+ return;
920
+ }
921
+
922
+ try {
923
+ execSync(`claude mcp remove ${toolName} --scope user`, { stdio: 'pipe' });
924
+ ok(`MCP: unregistered stale ${toolName} entry (source no longer exposes MCP)`);
925
+ } catch {
926
+ // Fallback: direct edit to ~/.claude.json
927
+ try {
928
+ const cfg = readJSON(ccUserPath) || {};
929
+ if (cfg.mcpServers && cfg.mcpServers[toolName]) {
930
+ delete cfg.mcpServers[toolName];
931
+ writeJSON(ccUserPath, cfg);
932
+ ok(`MCP: unregistered stale ${toolName} entry via direct .claude.json edit`);
933
+ }
934
+ } catch (e) {
935
+ log(`Warning: could not unregister stale MCP ${toolName}: ${e.message}`);
936
+ }
937
+ }
938
+
939
+ // Also clean OpenClaw side if installed.
940
+ try {
941
+ execSync(`openclaw mcp unset ${toolName}`, { stdio: 'pipe' });
942
+ } catch {
943
+ // Non-fatal. OpenClaw may not be installed, or may not have had this mcp.
944
+ }
945
+ }
946
+
885
947
  function registerMCP(repoPath, door, toolName) {
886
948
  let rawName = toolName || door.name || basename(repoPath);
887
949
  // Strip /tmp/ clone prefixes (ldm-install-, wip-install-)
@@ -894,6 +956,27 @@ function registerMCP(repoPath, door, toolName) {
894
956
  : existsSync(ldmFallbackPath) ? ldmFallbackPath
895
957
  : repoServerPath;
896
958
 
959
+ // Postcondition: the resolved entrypoint must exist and parse before we
960
+ // touch ~/.claude.json. Previously, if the published tarball did not
961
+ // contain the declared mcp-server file (see wip-1password 0.2.3-alpha.2
962
+ // bug writeup), we still wrote the registration and left a dangling
963
+ // "Failed to connect" entry that was invisible until the user ran
964
+ // `claude mcp list`. Fail loudly instead.
965
+ if (!existsSync(mcpPath)) {
966
+ fail(`MCP: ${name} registration aborted. Resolved path does not exist: ${mcpPath}`);
967
+ fail(`MCP: candidates checked: ${ldmServerPath}, ${ldmFallbackPath}, ${repoServerPath}`);
968
+ fail(`MCP: verify the published package includes "${door.file}" (check files array).`);
969
+ return false;
970
+ }
971
+ try {
972
+ execSync(`node --check "${mcpPath}"`, { stdio: 'pipe', timeout: 5000 });
973
+ } catch (e) {
974
+ const stderr = (e && e.stderr && e.stderr.toString && e.stderr.toString().trim()) || (e && e.message) || 'unknown error';
975
+ fail(`MCP: ${name} registration aborted. Entrypoint failed node --check: ${mcpPath}`);
976
+ fail(`MCP: ${stderr}`);
977
+ return false;
978
+ }
979
+
897
980
  // Check ~/.claude.json (user-level MCP)
898
981
  const ccUserPath = join(HOME, '.claude.json');
899
982
  const ccUser = readJSON(ccUserPath);
@@ -1272,6 +1355,12 @@ export function installSingleTool(toolPath) {
1272
1355
  } else {
1273
1356
  skip(`MCP: ${toolName} not enabled. Run: ldm enable ${toolName}`);
1274
1357
  }
1358
+ } else {
1359
+ // Phase 3b: source no longer exposes an MCP interface (file renamed,
1360
+ // moved to src/, removed, etc). If a prior install registered an MCP
1361
+ // whose args point into this extension's directory, un-register it so
1362
+ // claude mcp list does not keep a dangling "Failed to connect" entry.
1363
+ unregisterStaleMCP(toolName);
1275
1364
  }
1276
1365
 
1277
1366
  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.80",
4
4
  "type": "module",
5
5
  "description": "LDM OS: identity, memory, and sovereignty infrastructure for AI agents",
6
6
  "engines": {