@wipcomputer/wip-ldm-os 0.4.28 → 0.4.30

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
@@ -5,7 +5,7 @@ license: MIT
5
5
  interface: [cli, skill]
6
6
  metadata:
7
7
  display-name: "LDM OS"
8
- version: "0.4.28"
8
+ version: "0.4.30"
9
9
  homepage: "https://github.com/wipcomputer/wip-ldm-os"
10
10
  author: "Parker Todd Brooks"
11
11
  category: infrastructure
package/bin/ldm.js CHANGED
@@ -30,6 +30,7 @@ const LDM_ROOT = join(HOME, '.ldm');
30
30
  const LDM_EXTENSIONS = join(LDM_ROOT, 'extensions');
31
31
  const VERSION_PATH = join(LDM_ROOT, 'version.json');
32
32
  const REGISTRY_PATH = join(LDM_EXTENSIONS, 'registry.json');
33
+ const LDM_TMP = join(LDM_ROOT, 'tmp');
33
34
 
34
35
  // Install log (#101): append to ~/.ldm/logs/install.log
35
36
  import { appendFileSync } from 'node:fs';
@@ -227,7 +228,21 @@ function loadCatalog() {
227
228
  }
228
229
 
229
230
  function findInCatalog(id) {
230
- return loadCatalog().find(c => c.id === id);
231
+ const q = id.toLowerCase();
232
+ const catalog = loadCatalog();
233
+ // Exact id match
234
+ const exact = catalog.find(c => c.id === id);
235
+ if (exact) return exact;
236
+ // Partial id match (e.g. "xai-grok" matches "wip-xai-grok")
237
+ const partial = catalog.find(c => c.id.toLowerCase().includes(q) || q.includes(c.id.toLowerCase()));
238
+ if (partial) return partial;
239
+ // Name match (case-insensitive, e.g. "xAI Grok")
240
+ const byName = catalog.find(c => c.name && c.name.toLowerCase() === q);
241
+ if (byName) return byName;
242
+ // registryMatches match
243
+ const byRegistry = catalog.find(c => (c.registryMatches || []).some(m => m.toLowerCase() === q));
244
+ if (byRegistry) return byRegistry;
245
+ return null;
231
246
  }
232
247
 
233
248
  // ── ldm init ──
@@ -484,8 +499,22 @@ async function cmdInstall() {
484
499
  return cmdInstallCatalog();
485
500
  }
486
501
 
502
+ // If target is a private repo (org/name-private), redirect to public (#134)
503
+ let resolvedTarget = target;
504
+ if (target.match(/^[\w-]+\/[\w.-]+-private$/) && !existsSync(resolve(target))) {
505
+ const publicRepo = target.replace(/-private$/, '');
506
+ const catalogHit = findInCatalog(basename(publicRepo));
507
+ if (catalogHit) {
508
+ console.log(` Redirecting ${target} to public repo: ${catalogHit.repo}`);
509
+ resolvedTarget = catalogHit.repo;
510
+ } else {
511
+ console.log(` Redirecting ${target} to public repo: ${publicRepo}`);
512
+ resolvedTarget = publicRepo;
513
+ }
514
+ }
515
+
487
516
  // Check if target is a catalog ID (e.g. "memory-crystal")
488
- const catalogEntry = findInCatalog(target);
517
+ const catalogEntry = findInCatalog(resolvedTarget);
489
518
  if (catalogEntry) {
490
519
  console.log('');
491
520
  console.log(` Resolved "${target}" via catalog to ${catalogEntry.repo}`);
@@ -493,10 +522,11 @@ async function cmdInstall() {
493
522
  // Use the repo field to clone from GitHub
494
523
  const repoTarget = catalogEntry.repo;
495
524
  const repoName = basename(repoTarget);
496
- const repoPath = join('/tmp', `ldm-install-${repoName}`);
525
+ const repoPath = join(LDM_TMP, `ldm-install-${repoName}`);
497
526
  const httpsUrl = `https://github.com/${repoTarget}.git`;
498
527
  const sshUrl = `git@github.com:${repoTarget}.git`;
499
528
 
529
+ mkdirSync(LDM_TMP, { recursive: true });
500
530
  console.log(` Cloning ${repoTarget}...`);
501
531
  try {
502
532
  if (existsSync(repoPath)) {
@@ -523,8 +553,8 @@ async function cmdInstall() {
523
553
 
524
554
  await installFromPath(repoPath);
525
555
 
526
- // Clean up /tmp/ clone after install (#32)
527
- if (!DRY_RUN && (repoPath.startsWith('/tmp/') || repoPath.startsWith('/private/tmp/'))) {
556
+ // Clean up staging clone after install (#32, #135)
557
+ if (!DRY_RUN && repoPath.startsWith(LDM_TMP)) {
528
558
  try { execSync(`rm -rf "${repoPath}"`, { stdio: 'pipe' }); } catch {}
529
559
  }
530
560
  return;
@@ -534,10 +564,10 @@ async function cmdInstall() {
534
564
  let repoPath;
535
565
 
536
566
  // Check if target looks like an npm package (starts with @ or is a plain name without /)
537
- if (target.startsWith('@') || (!target.includes('/') && !existsSync(resolve(target)))) {
567
+ if (resolvedTarget.startsWith('@') || (!resolvedTarget.includes('/') && !existsSync(resolve(resolvedTarget)))) {
538
568
  // Try npm install to temp dir
539
- const npmName = target;
540
- const tempDir = join('/tmp', `ldm-install-npm-${Date.now()}`);
569
+ const npmName = resolvedTarget;
570
+ const tempDir = join(LDM_TMP, `ldm-install-npm-${Date.now()}`);
541
571
  console.log('');
542
572
  console.log(` Installing ${npmName} from npm...`);
543
573
  try {
@@ -560,19 +590,20 @@ async function cmdInstall() {
560
590
  }
561
591
  }
562
592
 
563
- if (!repoPath && (target.startsWith('http') || target.startsWith('git@') || target.match(/^[\w-]+\/[\w.-]+$/))) {
564
- const isShorthand = target.match(/^[\w-]+\/[\w.-]+$/);
593
+ if (!repoPath && (resolvedTarget.startsWith('http') || resolvedTarget.startsWith('git@') || resolvedTarget.match(/^[\w-]+\/[\w.-]+$/))) {
594
+ const isShorthand = resolvedTarget.match(/^[\w-]+\/[\w.-]+$/);
565
595
  const httpsUrl = isShorthand
566
- ? `https://github.com/${target}.git`
567
- : target;
596
+ ? `https://github.com/${resolvedTarget}.git`
597
+ : resolvedTarget;
568
598
  const sshUrl = isShorthand
569
- ? `git@github.com:${target}.git`
570
- : target.replace(/^https:\/\/github\.com\//, 'git@github.com:');
599
+ ? `git@github.com:${resolvedTarget}.git`
600
+ : resolvedTarget.replace(/^https:\/\/github\.com\//, 'git@github.com:');
571
601
  const repoName = basename(httpsUrl).replace('.git', '');
572
- repoPath = join('/tmp', `ldm-install-${repoName}`);
602
+ repoPath = join(LDM_TMP, `ldm-install-${repoName}`);
573
603
 
604
+ mkdirSync(LDM_TMP, { recursive: true });
574
605
  console.log('');
575
- console.log(` Cloning ${isShorthand ? target : httpsUrl}...`);
606
+ console.log(` Cloning ${isShorthand ? resolvedTarget : httpsUrl}...`);
576
607
  try {
577
608
  if (existsSync(repoPath)) {
578
609
  execSync(`rm -rf "${repoPath}"`, { stdio: 'pipe' });
@@ -590,7 +621,7 @@ async function cmdInstall() {
590
621
  process.exit(1);
591
622
  }
592
623
  } else if (!repoPath) {
593
- repoPath = resolve(target);
624
+ repoPath = resolve(resolvedTarget);
594
625
  if (!existsSync(repoPath)) {
595
626
  console.error(` x Path not found: ${repoPath}`);
596
627
  process.exit(1);
@@ -605,8 +636,8 @@ async function cmdInstall() {
605
636
 
606
637
  await installFromPath(repoPath);
607
638
 
608
- // Clean up /tmp/ clone after install (#32)
609
- if (!DRY_RUN && (repoPath.startsWith('/tmp/') || repoPath.startsWith('/private/tmp/'))) {
639
+ // Clean up staging clone after install (#32, #135)
640
+ if (!DRY_RUN && repoPath.startsWith(LDM_TMP)) {
610
641
  try { execSync(`rm -rf "${repoPath}"`, { stdio: 'pipe' }); } catch {}
611
642
  }
612
643
  }
@@ -918,13 +949,21 @@ async function cmdInstallCatalog() {
918
949
  }
919
950
  } catch {}
920
951
 
921
- // Check orphaned /tmp/ dirs
952
+ // Check orphaned staging dirs (old /tmp/ and new ~/.ldm/tmp/)
922
953
  try {
923
954
  const tmpCount = readdirSync('/private/tmp').filter(d => d.startsWith('ldm-install-')).length;
924
955
  if (tmpCount > 0) {
925
956
  healthIssues.push(` ! ${tmpCount} orphaned /tmp/ldm-install-* dirs (would clean up)`);
926
957
  }
927
958
  } catch {}
959
+ try {
960
+ if (existsSync(LDM_TMP)) {
961
+ const ldmTmpCount = readdirSync(LDM_TMP).filter(d => d.startsWith('ldm-install-')).length;
962
+ if (ldmTmpCount > 0) {
963
+ healthIssues.push(` ! ${ldmTmpCount} orphaned ~/.ldm/tmp/ldm-install-* dirs (would clean up)`);
964
+ }
965
+ }
966
+ } catch {}
928
967
 
929
968
  if (healthIssues.length > 0) {
930
969
  console.log('');
@@ -1075,7 +1114,7 @@ async function cmdInstallCatalog() {
1075
1114
  }
1076
1115
  } catch {}
1077
1116
 
1078
- // 3. Clean orphaned /tmp/ldm-install-* dirs
1117
+ // 3. Clean orphaned staging dirs (old /tmp/ and new ~/.ldm/tmp/)
1079
1118
  try {
1080
1119
  const tmpDirs = readdirSync('/private/tmp').filter(d => d.startsWith('ldm-install-'));
1081
1120
  if (tmpDirs.length > 0) {
@@ -1087,6 +1126,19 @@ async function cmdInstallCatalog() {
1087
1126
  console.log(` + Cleaned ${tmpDirs.length} orphaned /tmp/ clone(s)`);
1088
1127
  }
1089
1128
  } catch {}
1129
+ try {
1130
+ if (existsSync(LDM_TMP)) {
1131
+ const ldmTmpDirs = readdirSync(LDM_TMP).filter(d => d.startsWith('ldm-install-'));
1132
+ if (ldmTmpDirs.length > 0) {
1133
+ console.log(` Cleaning ${ldmTmpDirs.length} orphaned ~/.ldm/tmp/ dirs...`);
1134
+ for (const d of ldmTmpDirs) {
1135
+ try { execSync(`rm -rf "${join(LDM_TMP, d)}"`, { stdio: 'pipe', timeout: 10000 }); } catch {}
1136
+ }
1137
+ healthFixes++;
1138
+ console.log(` + Cleaned ${ldmTmpDirs.length} orphaned ~/.ldm/tmp/ clone(s)`);
1139
+ }
1140
+ }
1141
+ } catch {}
1090
1142
 
1091
1143
  if (healthFixes > 0) {
1092
1144
  console.log(` ${healthFixes} health issue(s) fixed.`);
@@ -1936,6 +1988,308 @@ async function main() {
1936
1988
  process.exit(0);
1937
1989
  }
1938
1990
 
1991
+ // ── ldm enable / disable (#111) ──
1992
+
1993
+ async function cmdEnable() {
1994
+ const target = args.slice(1).find(a => !a.startsWith('--'));
1995
+ if (!target) {
1996
+ console.log(' Usage: ldm enable <extension|stack>');
1997
+ console.log(' Example: ldm enable devops-toolbox');
1998
+ console.log(' Stacks: core, web, all');
1999
+ process.exit(1);
2000
+ }
2001
+
2002
+ const { enableExtension } = await import('../lib/deploy.mjs');
2003
+ const stacks = loadCatalog()?.stacks || {};
2004
+ const components = loadCatalog()?.components || [];
2005
+
2006
+ // Resolve stack to component list
2007
+ let names = [target];
2008
+ if (stacks[target]) {
2009
+ const stack = stacks[target];
2010
+ names = stack.components || [];
2011
+ if (stack.includes) {
2012
+ for (const inc of stack.includes) {
2013
+ if (stacks[inc]?.components) names.push(...stacks[inc].components);
2014
+ }
2015
+ }
2016
+ }
2017
+ // Map catalog IDs to registry names
2018
+ const resolvedNames = [];
2019
+ for (const n of names) {
2020
+ const comp = components.find(c => c.id === n);
2021
+ if (comp) {
2022
+ resolvedNames.push(comp.id);
2023
+ for (const m of (comp.registryMatches || [])) resolvedNames.push(m);
2024
+ } else {
2025
+ resolvedNames.push(n);
2026
+ }
2027
+ }
2028
+ const uniqueNames = [...new Set(resolvedNames)];
2029
+
2030
+ const registry = readJSON(REGISTRY_PATH);
2031
+ console.log('');
2032
+ for (const name of uniqueNames) {
2033
+ if (!registry?.extensions?.[name]) continue;
2034
+ const result = await enableExtension(name);
2035
+ if (result.ok) {
2036
+ console.log(` + ${name}: ${result.reason}`);
2037
+ } else {
2038
+ console.log(` ! ${name}: ${result.reason}`);
2039
+ }
2040
+ }
2041
+ console.log('');
2042
+ }
2043
+
2044
+ async function cmdDisable() {
2045
+ const target = args.slice(1).find(a => !a.startsWith('--'));
2046
+ if (!target) {
2047
+ console.log(' Usage: ldm disable <extension|stack>');
2048
+ process.exit(1);
2049
+ }
2050
+
2051
+ const { disableExtension } = await import('../lib/deploy.mjs');
2052
+ const stacks = loadCatalog()?.stacks || {};
2053
+ const components = loadCatalog()?.components || [];
2054
+
2055
+ let names = [target];
2056
+ if (stacks[target]) {
2057
+ const stack = stacks[target];
2058
+ names = stack.components || [];
2059
+ if (stack.includes) {
2060
+ for (const inc of stack.includes) {
2061
+ if (stacks[inc]?.components) names.push(...stacks[inc].components);
2062
+ }
2063
+ }
2064
+ }
2065
+ const resolvedNames = [];
2066
+ for (const n of names) {
2067
+ const comp = components.find(c => c.id === n);
2068
+ if (comp) {
2069
+ resolvedNames.push(comp.id);
2070
+ for (const m of (comp.registryMatches || [])) resolvedNames.push(m);
2071
+ } else {
2072
+ resolvedNames.push(n);
2073
+ }
2074
+ }
2075
+ const uniqueNames = [...new Set(resolvedNames)];
2076
+
2077
+ const registry = readJSON(REGISTRY_PATH);
2078
+ console.log('');
2079
+ for (const name of uniqueNames) {
2080
+ if (!registry?.extensions?.[name]) continue;
2081
+ const result = disableExtension(name);
2082
+ if (result.ok) {
2083
+ console.log(` - ${name}: ${result.reason}`);
2084
+ } else {
2085
+ console.log(` ! ${name}: ${result.reason}`);
2086
+ }
2087
+ }
2088
+ console.log('');
2089
+ }
2090
+
2091
+ // ── ldm uninstall (#114) ──
2092
+
2093
+ async function cmdUninstall() {
2094
+ const keepData = !args.includes('--all');
2095
+ const isDryRun = args.includes('--dry-run');
2096
+
2097
+ console.log('');
2098
+ console.log(' LDM OS Uninstall');
2099
+ console.log(' ────────────────────────────────────');
2100
+
2101
+ if (keepData) {
2102
+ console.log(' Your data will be PRESERVED:');
2103
+ console.log(' ~/.ldm/memory/ (crystal.db, shared memory)');
2104
+ console.log(' ~/.ldm/agents/ (identity, journals, daily logs)');
2105
+ console.log('');
2106
+ console.log(' Use --all to remove everything including data.');
2107
+ } else {
2108
+ console.log(' WARNING: --all flag set. ALL data will be removed.');
2109
+ console.log(' This includes crystal.db, agent files, journals, everything.');
2110
+ }
2111
+
2112
+ console.log('');
2113
+ console.log(' Will remove:');
2114
+
2115
+ // 1. MCP servers
2116
+ const claudeJsonPath = join(HOME, '.claude.json');
2117
+ try {
2118
+ const claudeJson = JSON.parse(readFileSync(claudeJsonPath, 'utf8'));
2119
+ const mcpNames = Object.keys(claudeJson.mcpServers || {}).filter(n =>
2120
+ n.includes('crystal') || n.includes('wip-') || n.includes('memory') ||
2121
+ n.includes('grok') || n.includes('lesa') || n.includes('1password')
2122
+ );
2123
+ if (mcpNames.length > 0) {
2124
+ console.log(` MCP servers: ${mcpNames.join(', ')}`);
2125
+ }
2126
+ } catch {}
2127
+
2128
+ // 2. CC hooks
2129
+ const settingsPath = join(HOME, '.claude', 'settings.json');
2130
+ try {
2131
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
2132
+ let hookCount = 0;
2133
+ for (const [event, entries] of Object.entries(settings.hooks || {})) {
2134
+ for (const entry of (Array.isArray(entries) ? entries : [])) {
2135
+ for (const h of (entry.hooks || [])) {
2136
+ if (h.command?.includes('.ldm') || h.command?.includes('wip-')) hookCount++;
2137
+ }
2138
+ }
2139
+ }
2140
+ if (hookCount > 0) console.log(` CC hooks: ${hookCount} hook(s)`);
2141
+ } catch {}
2142
+
2143
+ // 3. Skills
2144
+ const skillsDir = join(HOME, '.openclaw', 'skills');
2145
+ try {
2146
+ const skills = readdirSync(skillsDir).filter(d => d !== '.DS_Store');
2147
+ if (skills.length > 0) console.log(` Skills: ${skills.join(', ')}`);
2148
+ } catch {}
2149
+
2150
+ // 4. Cron jobs
2151
+ try {
2152
+ const crontab = execSync('crontab -l 2>/dev/null', { encoding: 'utf8' });
2153
+ const ldmLines = crontab.split('\n').filter(l => l.includes('.ldm') || l.includes('crystal-capture') || l.includes('process-monitor'));
2154
+ if (ldmLines.length > 0) console.log(` Cron jobs: ${ldmLines.length}`);
2155
+ } catch {}
2156
+
2157
+ // 5. Global npm packages
2158
+ try {
2159
+ const npmList = execSync('npm list -g --depth=0 --json 2>/dev/null', { encoding: 'utf8' });
2160
+ const deps = JSON.parse(npmList).dependencies || {};
2161
+ const wipPkgs = Object.keys(deps).filter(n => n.startsWith('@wipcomputer/'));
2162
+ if (wipPkgs.length > 0) console.log(` npm packages: ${wipPkgs.join(', ')}`);
2163
+ } catch {}
2164
+
2165
+ // 6. Directories
2166
+ console.log(` ~/.ldm/extensions/`);
2167
+ if (!keepData) {
2168
+ console.log(` ~/.ldm/memory/`);
2169
+ console.log(` ~/.ldm/agents/`);
2170
+ }
2171
+ console.log(` ~/.ldm/state/, bin/, hooks/, logs/, sessions/, messages/`);
2172
+
2173
+ if (isDryRun) {
2174
+ console.log('');
2175
+ console.log(' Dry run. Nothing removed.');
2176
+ console.log('');
2177
+ return;
2178
+ }
2179
+
2180
+ // Confirm
2181
+ if (process.stdin.isTTY) {
2182
+ const { createInterface } = await import('readline');
2183
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
2184
+ const answer = await new Promise(resolve => {
2185
+ rl.question('\n Type "uninstall" to confirm: ', resolve);
2186
+ });
2187
+ rl.close();
2188
+ if (answer.trim() !== 'uninstall') {
2189
+ console.log(' Cancelled.');
2190
+ return;
2191
+ }
2192
+ }
2193
+
2194
+ console.log('');
2195
+ console.log(' Removing...');
2196
+
2197
+ // 1. Unregister MCP servers
2198
+ try {
2199
+ const claudeJson = JSON.parse(readFileSync(claudeJsonPath, 'utf8'));
2200
+ const mcpNames = Object.keys(claudeJson.mcpServers || {}).filter(n =>
2201
+ n.includes('crystal') || n.includes('wip-') || n.includes('memory') ||
2202
+ n.includes('grok') || n.includes('lesa') || n.includes('1password')
2203
+ );
2204
+ for (const name of mcpNames) {
2205
+ delete claudeJson.mcpServers[name];
2206
+ }
2207
+ writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2) + '\n');
2208
+ console.log(` + Removed ${mcpNames.length} MCP server(s)`);
2209
+ } catch {}
2210
+
2211
+ // 2. Remove CC hooks
2212
+ try {
2213
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
2214
+ let removed = 0;
2215
+ for (const [event, entries] of Object.entries(settings.hooks || {})) {
2216
+ if (!Array.isArray(entries)) continue;
2217
+ for (const entry of entries) {
2218
+ if (!entry.hooks) continue;
2219
+ const before = entry.hooks.length;
2220
+ entry.hooks = entry.hooks.filter(h => !h.command?.includes('.ldm') && !h.command?.includes('wip-'));
2221
+ removed += before - entry.hooks.length;
2222
+ }
2223
+ settings.hooks[event] = entries.filter(e => e.hooks?.length > 0);
2224
+ }
2225
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
2226
+ console.log(` + Removed ${removed} CC hook(s)`);
2227
+ } catch {}
2228
+
2229
+ // 3. Remove skills
2230
+ try {
2231
+ const skills = readdirSync(skillsDir).filter(d => d !== '.DS_Store');
2232
+ for (const s of skills) {
2233
+ execSync(`rm -rf "${join(skillsDir, s)}"`, { stdio: 'pipe' });
2234
+ }
2235
+ console.log(` + Removed ${skills.length} skill(s)`);
2236
+ } catch {}
2237
+
2238
+ // 4. Remove cron jobs
2239
+ try {
2240
+ const crontab = execSync('crontab -l 2>/dev/null', { encoding: 'utf8' });
2241
+ const filtered = crontab.split('\n').filter(l =>
2242
+ !l.includes('.ldm') && !l.includes('crystal-capture') && !l.includes('process-monitor')
2243
+ ).join('\n');
2244
+ execSync(`echo "${filtered}" | crontab -`, { stdio: 'pipe' });
2245
+ console.log(' + Cleaned cron jobs');
2246
+ } catch {}
2247
+
2248
+ // 5. Remove npm packages
2249
+ try {
2250
+ const npmList = execSync('npm list -g --depth=0 --json 2>/dev/null', { encoding: 'utf8' });
2251
+ const deps = JSON.parse(npmList).dependencies || {};
2252
+ const wipPkgs = Object.keys(deps).filter(n => n.startsWith('@wipcomputer/'));
2253
+ for (const pkg of wipPkgs) {
2254
+ if (pkg === '@wipcomputer/wip-ldm-os') continue; // uninstall self last
2255
+ try { execSync(`npm uninstall -g ${pkg}`, { stdio: 'pipe', timeout: 30000 }); } catch {}
2256
+ }
2257
+ console.log(` + Removed ${wipPkgs.length - 1} npm package(s)`);
2258
+ } catch {}
2259
+
2260
+ // 6. Remove directories
2261
+ const dirsToRemove = ['extensions', 'state', 'bin', 'hooks', 'logs', 'sessions', 'messages', 'shared', '_trash'];
2262
+ if (!keepData) {
2263
+ dirsToRemove.push('memory', 'agents', 'secrets', 'backups');
2264
+ }
2265
+ for (const dir of dirsToRemove) {
2266
+ const p = join(LDM_ROOT, dir);
2267
+ if (existsSync(p)) {
2268
+ execSync(`rm -rf "${p}"`, { stdio: 'pipe' });
2269
+ }
2270
+ }
2271
+ // Remove config and version files
2272
+ for (const f of ['version.json', 'config.json']) {
2273
+ const p = join(LDM_ROOT, f);
2274
+ if (existsSync(p)) unlinkSync(p);
2275
+ }
2276
+ console.log(' + Removed ~/.ldm/ contents');
2277
+
2278
+ if (keepData) {
2279
+ console.log('');
2280
+ console.log(' Preserved:');
2281
+ console.log(' ~/.ldm/memory/ (your data)');
2282
+ console.log(' ~/.ldm/agents/ (identity + journals)');
2283
+ }
2284
+
2285
+ // 7. Self-uninstall
2286
+ console.log('');
2287
+ console.log(' To finish, run: npm uninstall -g @wipcomputer/wip-ldm-os');
2288
+ console.log('');
2289
+ console.log(' LDM OS uninstalled.');
2290
+ console.log('');
2291
+ }
2292
+
1939
2293
  if (command === '--version' || command === '-v') {
1940
2294
  console.log(PKG_VERSION);
1941
2295
  process.exit(0);
@@ -1973,6 +2327,15 @@ async function main() {
1973
2327
  case 'updates':
1974
2328
  await cmdUpdates();
1975
2329
  break;
2330
+ case 'enable':
2331
+ await cmdEnable();
2332
+ break;
2333
+ case 'disable':
2334
+ await cmdDisable();
2335
+ break;
2336
+ case 'uninstall':
2337
+ await cmdUninstall();
2338
+ break;
1976
2339
  default:
1977
2340
  console.error(` Unknown command: ${command}`);
1978
2341
  console.error(` Run: ldm --help`);
package/catalog.json CHANGED
@@ -4,7 +4,12 @@
4
4
  "core": {
5
5
  "name": "WIP Core",
6
6
  "description": "Essential tools for all WIP.computer team members.",
7
- "components": ["memory-crystal", "wip-ai-devops-toolbox", "wip-1password", "wip-markdown-viewer"],
7
+ "components": [
8
+ "memory-crystal",
9
+ "wip-ai-devops-toolbox",
10
+ "wip-1password",
11
+ "wip-markdown-viewer"
12
+ ],
8
13
  "mcpServers": []
9
14
  },
10
15
  "web": {
@@ -12,17 +17,54 @@
12
17
  "description": "Frontend tools for Next.js, React, Tailwind projects.",
13
18
  "components": [],
14
19
  "mcpServers": [
15
- { "name": "playwright", "command": "npx", "args": ["-y", "@playwright/mcp@latest", "--browser", "chromium"] },
16
- { "name": "next-devtools", "command": "npx", "args": ["-y", "next-devtools-mcp@latest"] },
17
- { "name": "shadcn", "command": "npx", "args": ["-y", "shadcn@latest", "mcp"] },
18
- { "name": "tailwindcss-docs", "command": "npx", "args": ["-y", "tailwindcss-docs-mcp"] }
20
+ {
21
+ "name": "playwright",
22
+ "command": "npx",
23
+ "args": [
24
+ "-y",
25
+ "@playwright/mcp@latest",
26
+ "--browser",
27
+ "chromium"
28
+ ]
29
+ },
30
+ {
31
+ "name": "next-devtools",
32
+ "command": "npx",
33
+ "args": [
34
+ "-y",
35
+ "next-devtools-mcp@latest"
36
+ ]
37
+ },
38
+ {
39
+ "name": "shadcn",
40
+ "command": "npx",
41
+ "args": [
42
+ "-y",
43
+ "shadcn@latest",
44
+ "mcp"
45
+ ]
46
+ },
47
+ {
48
+ "name": "tailwindcss-docs",
49
+ "command": "npx",
50
+ "args": [
51
+ "-y",
52
+ "tailwindcss-docs-mcp"
53
+ ]
54
+ }
19
55
  ]
20
56
  },
21
57
  "all": {
22
58
  "name": "Everything",
23
59
  "description": "All WIP tools + web dev stack.",
24
- "includes": ["core", "web"],
25
- "components": ["wip-xai-grok", "wip-xai-x"],
60
+ "includes": [
61
+ "core",
62
+ "web"
63
+ ],
64
+ "components": [
65
+ "wip-xai-grok",
66
+ "wip-xai-x"
67
+ ],
26
68
  "mcpServers": []
27
69
  }
28
70
  },
@@ -33,14 +75,24 @@
33
75
  "description": "Persistent memory for your AI. Search, capture, consolidation.",
34
76
  "npm": "@wipcomputer/memory-crystal",
35
77
  "repo": "wipcomputer/memory-crystal",
36
- "registryMatches": ["memory-crystal"],
37
- "cliMatches": ["crystal"],
78
+ "registryMatches": [
79
+ "memory-crystal"
80
+ ],
81
+ "cliMatches": [
82
+ "crystal"
83
+ ],
38
84
  "recommended": true,
39
85
  "status": "stable",
40
86
  "postInstall": "crystal doctor",
41
87
  "installs": {
42
- "cli": ["crystal"],
43
- "mcp": ["crystal_search", "crystal_remember", "crystal_forget"],
88
+ "cli": [
89
+ "crystal"
90
+ ],
91
+ "mcp": [
92
+ "crystal_search",
93
+ "crystal_remember",
94
+ "crystal_forget"
95
+ ],
44
96
  "ocPlugin": "agent_end hook (conversation capture every turn)",
45
97
  "ccHook": "Stop hook (crystal capture + daily log)",
46
98
  "cron": "crystal-capture.sh (every 1 min, backup capture)",
@@ -53,14 +105,41 @@
53
105
  "description": "Release pipeline, license compliance, repo management, identity file protection.",
54
106
  "npm": "@wipcomputer/wip-ai-devops-toolbox",
55
107
  "repo": "wipcomputer/wip-ai-devops-toolbox",
56
- "registryMatches": ["wip-repos", "wip-release", "wip-file-guard", "wip-license-hook", "wip-repo-permissions-hook", "deploy-public", "post-merge-rename", "wip-license-guard", "wip-repo-init", "wip-readme-format", "wip-branch-guard"],
57
- "cliMatches": ["wip-release", "wip-repos", "wip-file-guard", "wip-branch-guard"],
108
+ "registryMatches": [
109
+ "wip-repos",
110
+ "wip-release",
111
+ "wip-file-guard",
112
+ "wip-license-hook",
113
+ "wip-repo-permissions-hook",
114
+ "deploy-public",
115
+ "post-merge-rename",
116
+ "wip-license-guard",
117
+ "wip-repo-init",
118
+ "wip-readme-format",
119
+ "wip-branch-guard"
120
+ ],
121
+ "cliMatches": [
122
+ "wip-release",
123
+ "wip-repos",
124
+ "wip-file-guard",
125
+ "wip-branch-guard"
126
+ ],
58
127
  "recommended": false,
59
128
  "status": "stable",
60
129
  "postInstall": null,
61
130
  "installs": {
62
- "cli": ["wip-release", "wip-repos", "wip-file-guard", "wip-install"],
63
- "mcp": ["wip-release", "wip-repos", "wip-license-hook", "wip-repo-permissions-hook"],
131
+ "cli": [
132
+ "wip-release",
133
+ "wip-repos",
134
+ "wip-file-guard",
135
+ "wip-install"
136
+ ],
137
+ "mcp": [
138
+ "wip-release",
139
+ "wip-repos",
140
+ "wip-license-hook",
141
+ "wip-repo-permissions-hook"
142
+ ],
64
143
  "ccHook": "branch-guard (blocks writes on main), file-guard (protects identity files), repo-permissions (workspace boundaries)",
65
144
  "tools": "12 sub-tools: release, repos, file-guard, license-hook, license-guard, repo-permissions-hook, deploy-public, post-merge-rename, repo-init, readme-format, branch-guard, universal-installer"
66
145
  }
@@ -71,13 +150,20 @@
71
150
  "description": "1Password secrets for AI agents.",
72
151
  "npm": null,
73
152
  "repo": "wipcomputer/wip-1password",
74
- "registryMatches": ["wip-1password", "op-secrets"],
153
+ "registryMatches": [
154
+ "wip-1password",
155
+ "op-secrets"
156
+ ],
75
157
  "cliMatches": [],
76
158
  "recommended": false,
77
159
  "status": "stable",
78
160
  "postInstall": null,
79
161
  "installs": {
80
- "mcp": ["op_list_items", "op_read_secret", "op_test"],
162
+ "mcp": [
163
+ "op_list_items",
164
+ "op_read_secret",
165
+ "op_test"
166
+ ],
81
167
  "ocPlugin": "op-secrets (headless 1Password via service account)"
82
168
  }
83
169
  },
@@ -87,13 +173,20 @@
87
173
  "description": "Live markdown viewer for AI pair-editing. Updates render instantly in any browser.",
88
174
  "npm": "@wipcomputer/markdown-viewer",
89
175
  "repo": "wipcomputer/wip-markdown-viewer",
90
- "registryMatches": ["wip-markdown-viewer", "markdown-viewer"],
91
- "cliMatches": ["mdview"],
176
+ "registryMatches": [
177
+ "wip-markdown-viewer",
178
+ "markdown-viewer"
179
+ ],
180
+ "cliMatches": [
181
+ "mdview"
182
+ ],
92
183
  "recommended": false,
93
184
  "status": "stable",
94
185
  "postInstall": null,
95
186
  "installs": {
96
- "cli": ["mdview"],
187
+ "cli": [
188
+ "mdview"
189
+ ],
97
190
  "web": "localhost:3000 (live reload markdown renderer)"
98
191
  }
99
192
  },
@@ -103,13 +196,23 @@
103
196
  "description": "xAI Grok API. Search the web, search X, generate images, generate video.",
104
197
  "npm": null,
105
198
  "repo": "wipcomputer/wip-xai-grok",
106
- "registryMatches": ["wip-xai-grok", "grok-search"],
199
+ "registryMatches": [
200
+ "wip-xai-grok",
201
+ "grok-search"
202
+ ],
107
203
  "cliMatches": [],
108
204
  "recommended": false,
109
205
  "status": "stable",
110
206
  "postInstall": null,
111
207
  "installs": {
112
- "mcp": ["grok_search_web", "grok_search_x", "grok_imagine", "grok_edit_image", "grok_generate_video", "grok_poll_video"]
208
+ "mcp": [
209
+ "grok_search_web",
210
+ "grok_search_x",
211
+ "grok_imagine",
212
+ "grok_edit_image",
213
+ "grok_generate_video",
214
+ "grok_poll_video"
215
+ ]
113
216
  }
114
217
  },
115
218
  {
@@ -118,13 +221,23 @@
118
221
  "description": "X Platform API. Read posts, search tweets, post, upload media.",
119
222
  "npm": null,
120
223
  "repo": "wipcomputer/wip-xai-x",
121
- "registryMatches": ["wip-xai-x"],
224
+ "registryMatches": [
225
+ "wip-xai-x"
226
+ ],
122
227
  "cliMatches": [],
123
228
  "recommended": false,
124
229
  "status": "stable",
125
230
  "postInstall": null,
126
231
  "installs": {
127
- "mcp": ["x_fetch_post", "x_search_recent", "x_get_bookmarks", "x_get_user", "x_post_tweet", "x_delete_tweet", "x_upload_media"]
232
+ "mcp": [
233
+ "x_fetch_post",
234
+ "x_search_recent",
235
+ "x_get_bookmarks",
236
+ "x_get_user",
237
+ "x_post_tweet",
238
+ "x_delete_tweet",
239
+ "x_upload_media"
240
+ ]
128
241
  }
129
242
  },
130
243
  {
@@ -133,13 +246,19 @@
133
246
  "description": "AI agent platform. Run AI agents 24/7 with identity, memory, and tool access.",
134
247
  "npm": null,
135
248
  "repo": "openclaw/openclaw",
136
- "registryMatches": ["openclaw"],
137
- "cliMatches": ["openclaw"],
249
+ "registryMatches": [
250
+ "openclaw"
251
+ ],
252
+ "cliMatches": [
253
+ "openclaw"
254
+ ],
138
255
  "recommended": false,
139
256
  "status": "stable",
140
257
  "postInstall": null,
141
258
  "installs": {
142
- "cli": ["openclaw"],
259
+ "cli": [
260
+ "openclaw"
261
+ ],
143
262
  "runtime": "24/7 agent gateway on localhost:18789",
144
263
  "plugins": "Extensions system at ~/.openclaw/extensions/"
145
264
  }
@@ -150,7 +269,10 @@
150
269
  "description": "Memory consolidation protocol for AI agents with bounded context windows.",
151
270
  "npm": null,
152
271
  "repo": "wipcomputer/dream-weaver-protocol",
153
- "registryMatches": ["dream-weaver-protocol", "dream-weaver"],
272
+ "registryMatches": [
273
+ "dream-weaver-protocol",
274
+ "dream-weaver"
275
+ ],
154
276
  "cliMatches": [],
155
277
  "recommended": false,
156
278
  "status": "stable",
@@ -159,23 +281,6 @@
159
281
  "skill": "SKILL.md (protocol documentation for agents)",
160
282
  "docs": "19-page paper on memory consolidation. No runtime components."
161
283
  }
162
- },
163
- {
164
- "id": "wip-bridge",
165
- "name": "Bridge",
166
- "description": "Cross-platform agent bridge. Enables Claude Code CLI to talk to OpenClaw CLI without a human in the middle.",
167
- "npm": null,
168
- "repo": "wipcomputer/wip-bridge-deprecated",
169
- "registryMatches": ["wip-bridge", "lesa-bridge"],
170
- "cliMatches": [],
171
- "recommended": false,
172
- "status": "included",
173
- "postInstall": null,
174
- "installs": {
175
- "note": "Included with LDM OS v0.3.0+. No separate install needed.",
176
- "mcp": ["lesa_send_message", "lesa_check_inbox", "lesa_conversation_search", "lesa_memory_search", "lesa_read_workspace", "oc_skills_list"],
177
- "cli": ["lesa"]
178
- }
179
284
  }
180
285
  ]
181
286
  }
package/lib/deploy.mjs CHANGED
@@ -76,11 +76,17 @@ function saveRegistry(registry) {
76
76
  writeJSON(REGISTRY_PATH, registry);
77
77
  }
78
78
 
79
+ // Core extensions are always enabled. Everything else defaults to disabled on first install.
80
+ const CORE_EXTENSIONS = new Set(['memory-crystal']);
81
+
79
82
  function updateRegistry(name, info) {
80
83
  const registry = loadRegistry();
84
+ const existing = registry.extensions[name];
85
+ const isCore = CORE_EXTENSIONS.has(name);
81
86
  registry.extensions[name] = {
82
- ...registry.extensions[name],
87
+ ...existing,
83
88
  ...info,
89
+ enabled: existing?.enabled ?? isCore,
84
90
  updatedAt: new Date().toISOString(),
85
91
  };
86
92
  saveRegistry(registry);
@@ -772,16 +778,32 @@ export function installSingleTool(toolPath) {
772
778
  }
773
779
  }
774
780
 
781
+ // Only register MCP, hooks, and skills if extension is enabled (#111)
782
+ const registry = loadRegistry();
783
+ const isEnabled = registry.extensions?.[toolName]?.enabled ?? CORE_EXTENSIONS.has(toolName);
784
+
775
785
  if (interfaces.mcp) {
776
- if (registerMCP(toolPath, interfaces.mcp, toolName)) installed++;
786
+ if (isEnabled) {
787
+ if (registerMCP(toolPath, interfaces.mcp, toolName)) installed++;
788
+ } else {
789
+ skip(`MCP: ${toolName} installed but not enabled. Run: ldm enable ${toolName}`);
790
+ }
777
791
  }
778
792
 
779
793
  if (interfaces.claudeCodeHook) {
780
- if (installClaudeCodeHook(toolPath, interfaces.claudeCodeHook)) installed++;
794
+ if (isEnabled) {
795
+ if (installClaudeCodeHook(toolPath, interfaces.claudeCodeHook)) installed++;
796
+ } else {
797
+ skip(`Hook: ${toolName} installed but not enabled`);
798
+ }
781
799
  }
782
800
 
783
801
  if (interfaces.skill) {
784
- if (installSkill(toolPath, toolName)) installed++;
802
+ if (isEnabled) {
803
+ if (installSkill(toolPath, toolName)) installed++;
804
+ } else {
805
+ skip(`Skill: ${toolName} installed but not enabled`);
806
+ }
785
807
  }
786
808
 
787
809
  if (interfaces.module) {
@@ -868,6 +890,93 @@ export async function installFromPath(repoPath) {
868
890
  return { tools: 1, interfaces: installed };
869
891
  }
870
892
 
893
+ // ── Enable / Disable (#111) ──
894
+
895
+ function unregisterMCP(name) {
896
+ try {
897
+ execSync(`claude mcp remove --scope user ${name} 2>/dev/null`, { stdio: 'pipe', timeout: 10000 });
898
+ } catch {}
899
+ // Also clean ~/.claude.json directly
900
+ const claudeJson = join(HOME, '.claude.json');
901
+ try {
902
+ const config = readJSON(claudeJson);
903
+ if (config?.mcpServers?.[name]) {
904
+ delete config.mcpServers[name];
905
+ writeJSON(claudeJson, config);
906
+ }
907
+ } catch {}
908
+ }
909
+
910
+ function removeClaudeCodeHook(name) {
911
+ const settingsPath = join(HOME, '.claude', 'settings.json');
912
+ try {
913
+ const settings = readJSON(settingsPath);
914
+ if (!settings?.hooks) return;
915
+ let changed = false;
916
+ for (const [event, entries] of Object.entries(settings.hooks)) {
917
+ if (!Array.isArray(entries)) continue;
918
+ for (const entry of entries) {
919
+ if (!entry.hooks || !Array.isArray(entry.hooks)) continue;
920
+ const before = entry.hooks.length;
921
+ entry.hooks = entry.hooks.filter(h => !h.command?.includes(name));
922
+ if (entry.hooks.length < before) changed = true;
923
+ }
924
+ // Remove empty entries
925
+ settings.hooks[event] = entries.filter(e => e.hooks?.length > 0);
926
+ }
927
+ if (changed) writeJSON(settingsPath, settings);
928
+ } catch {}
929
+ }
930
+
931
+ function removeSkill(name) {
932
+ const skillDir = join(OC_ROOT, 'skills', name);
933
+ try {
934
+ if (existsSync(skillDir)) {
935
+ execSync(`rm -rf "${skillDir}"`, { stdio: 'pipe' });
936
+ }
937
+ } catch {}
938
+ }
939
+
940
+ export async function enableExtension(name) {
941
+ const reg = loadRegistry();
942
+ const entry = reg.extensions?.[name];
943
+ if (!entry) return { ok: false, reason: 'not installed' };
944
+ if (entry.enabled) return { ok: true, reason: 'already enabled' };
945
+
946
+ const extPath = entry.ldmPath || join(LDM_EXTENSIONS, name);
947
+ if (!existsSync(extPath)) return { ok: false, reason: 'extension dir missing' };
948
+
949
+ const { detectInterfaces } = await import('./detect.mjs');
950
+ const { interfaces } = detectInterfaces(extPath);
951
+
952
+ if (interfaces.mcp) registerMCP(extPath, interfaces.mcp, name);
953
+ if (interfaces.claudeCodeHook) installClaudeCodeHook(extPath, interfaces.claudeCodeHook);
954
+ if (interfaces.skill) installSkill(extPath, name);
955
+
956
+ entry.enabled = true;
957
+ entry.updatedAt = new Date().toISOString();
958
+ saveRegistry(reg);
959
+ return { ok: true, reason: 'enabled' };
960
+ }
961
+
962
+ export function disableExtension(name) {
963
+ if (CORE_EXTENSIONS.has(name)) return { ok: false, reason: 'core extension, cannot disable' };
964
+
965
+ const reg = loadRegistry();
966
+ const entry = reg.extensions?.[name];
967
+ if (!entry) return { ok: false, reason: 'not installed' };
968
+ if (!entry.enabled) return { ok: true, reason: 'already disabled' };
969
+
970
+ unregisterMCP(name);
971
+ removeClaudeCodeHook(name);
972
+ removeSkill(name);
973
+
974
+ entry.enabled = false;
975
+ entry.updatedAt = new Date().toISOString();
976
+ saveRegistry(reg);
977
+ return { ok: true, reason: 'disabled' };
978
+ }
979
+
871
980
  // ── Exports for ldm CLI ──
872
981
 
873
- export { loadRegistry, saveRegistry, updateRegistry, readJSON, writeJSON, runBuildIfNeeded };
982
+ export { loadRegistry, saveRegistry, updateRegistry, readJSON, writeJSON, runBuildIfNeeded, CORE_EXTENSIONS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wipcomputer/wip-ldm-os",
3
- "version": "0.4.28",
3
+ "version": "0.4.30",
4
4
  "type": "module",
5
5
  "description": "LDM OS: identity, memory, and sovereignty infrastructure for AI agents",
6
6
  "main": "src/boot/boot-hook.mjs",