@wipcomputer/wip-ldm-os 0.4.84 → 0.4.85-alpha.10

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/bin/ldm.js CHANGED
@@ -738,6 +738,24 @@ function deployDocs() {
738
738
  console.log(` + ${agentDocsCount} personalized doc(s) deployed to ${agentLibraryDest.replace(HOME, '~')}/`);
739
739
  }
740
740
 
741
+ // Migration-window compatibility write (added 2026-04-30).
742
+ // dev-guide-wipcomputerinc.md was migrated from ~/.ldm/shared/ to
743
+ // ~/.ldm/library/documentation/ on 2026-04-19, but agent boot files,
744
+ // ~/.claude/rules/, and other consumers still reference the old shared
745
+ // path. Without this write, the old path serves stale content and
746
+ // agents reading by the old path get pre-migration policy.
747
+ // Forward-migration (grep-update consumers, then remove this compat
748
+ // write) is tracked separately. See bugs/installer/ ticket
749
+ // 2026-04-30--cc-mini--dev-guide-split-path-migration.md.
750
+ const devGuideName = 'dev-guide-wipcomputerinc.md';
751
+ const devGuideNew = join(agentLibraryDest, devGuideName);
752
+ const devGuideOld = join(LDM_ROOT, 'shared', devGuideName);
753
+ if (existsSync(devGuideNew)) {
754
+ mkdirSync(dirname(devGuideOld), { recursive: true });
755
+ cpSync(devGuideNew, devGuideOld);
756
+ console.log(` + Compat write: ${devGuideName} also deployed to ${devGuideOld.replace(HOME, '~')} (migration window)`);
757
+ }
758
+
741
759
  return docsCount + agentDocsCount;
742
760
  }
743
761
 
@@ -1537,13 +1555,21 @@ async function cmdInstall() {
1537
1555
  // npm install --prefix silently fails for scoped packages in temp directories...
1538
1556
  // it creates the lock file but doesn't extract files. npm pack is reliable.
1539
1557
  const npmName = resolvedTarget;
1558
+ // --alpha and --beta select the corresponding npm dist-tag instead of @latest.
1559
+ // Without this, `ldm install --alpha <pkg>` was pulling the @latest version
1560
+ // from npm pack and an existing global install would never advance to the
1561
+ // current alpha. Now the tag flows through pack + the downstream
1562
+ // installSingleTool's `npm install -g <pkg>@<version>` step uses the
1563
+ // version baked into the alpha tarball.
1564
+ const npmTag = ALPHA_FLAG ? 'alpha' : (BETA_FLAG ? 'beta' : '');
1565
+ const packTarget = npmTag ? `${npmName}@${npmTag}` : npmName;
1540
1566
  const tempDir = join(LDM_TMP, `npm-${Date.now()}`);
1541
1567
  console.log('');
1542
- console.log(` Installing ${npmName} from npm...`);
1568
+ console.log(` Installing ${packTarget} from npm...`);
1543
1569
  try {
1544
1570
  mkdirSync(tempDir, { recursive: true });
1545
1571
  // Use npm pack + tar instead of npm install --prefix
1546
- const tarball = execSync(`npm pack ${npmName} --pack-destination "${tempDir}" 2>/dev/null`, {
1572
+ const tarball = execSync(`npm pack ${packTarget} --pack-destination "${tempDir}" 2>/dev/null`, {
1547
1573
  encoding: 'utf8', timeout: 60000, cwd: tempDir,
1548
1574
  }).trim();
1549
1575
  const tarPath = join(tempDir, tarball);
@@ -3979,7 +4005,7 @@ async function main() {
3979
4005
  console.log(' Module ... ESM main/exports -> importable');
3980
4006
  console.log(' MCP Server ... mcp-server.mjs -> claude mcp add --scope user');
3981
4007
  console.log(' OpenClaw ... openclaw.plugin.json -> ~/.ldm/extensions/ + ~/.openclaw/extensions/');
3982
- console.log(' Skill ... SKILL.md -> ~/.openclaw/skills/<tool>/');
4008
+ console.log(' Skill ... SKILL.md or skills/<name>/SKILL.md -> agent skill paths');
3983
4009
  console.log(' CC Hook ... guard.mjs or claudeCode.hook -> ~/.claude/settings.json');
3984
4010
  console.log('');
3985
4011
  console.log(` v${PKG_VERSION}`);
@@ -4289,6 +4315,164 @@ async function main() {
4289
4315
  console.log('');
4290
4316
  }
4291
4317
 
4318
+ // ── ldm uninstall <pkg> ──
4319
+ //
4320
+ // Removes a single LDM-installed package. Used to reset a single
4321
+ // extension between dogfood cycles without taking down the rest of
4322
+ // LDM OS. Pairs with the per-package cleanup hook each package may
4323
+ // ship (e.g. `codex-daemon uninstall --purge` for Codex Remote
4324
+ // Control), which the user runs first to clean up product-specific
4325
+ // state. This command then removes the LDM-side install record + the
4326
+ // global npm package + the LDM extension dir.
4327
+ //
4328
+ // Safety:
4329
+ // - Never touches ~/.codex/ or other unrelated user state.
4330
+ // - Never removes ~/.ldm/memory/ or ~/.ldm/agents/.
4331
+ // - Never removes other extensions.
4332
+ // - Idempotent: running twice exits cleanly.
4333
+ // - Refuses to uninstall LDM OS itself (use `ldm uninstall` for that).
4334
+
4335
+ async function cmdUninstallPackage(pkgName) {
4336
+ const isDryRun = args.includes('--dry-run');
4337
+
4338
+ if (pkgName === 'wip-ldm-os' || pkgName === '@wipcomputer/wip-ldm-os') {
4339
+ console.error(' Refusing to uninstall LDM OS itself with `ldm uninstall <pkg>`.');
4340
+ console.error(' To remove all of LDM OS: ldm uninstall');
4341
+ process.exit(1);
4342
+ }
4343
+
4344
+ console.log('');
4345
+ console.log(` ldm uninstall ${pkgName}`);
4346
+ console.log(' ────────────────────────────────────');
4347
+
4348
+ // 1. Look up the package in the registry.
4349
+ const registryPath = join(LDM_EXTENSIONS, 'registry.json');
4350
+ let registry = { _format: 'v1', extensions: {} };
4351
+ try {
4352
+ if (existsSync(registryPath)) {
4353
+ registry = JSON.parse(readFileSync(registryPath, 'utf8'));
4354
+ }
4355
+ } catch (e) {
4356
+ console.error(` ! could not read registry at ${registryPath}: ${e.message}`);
4357
+ }
4358
+ const entry = registry.extensions?.[pkgName] || null;
4359
+
4360
+ // 2. Resolve npm package name.
4361
+ // Registry entries from npm installs put the npm name in `source.npm`
4362
+ // or in the top-level `name` field. Fall back to the user-supplied
4363
+ // pkgName (works for unscoped packages).
4364
+ const npmPkg = entry?.source?.npm || entry?.name || pkgName;
4365
+
4366
+ // 3. Resolve LDM extension dir(s).
4367
+ const ldmExtPath = entry?.paths?.ldm
4368
+ || entry?.ldmPath
4369
+ || join(LDM_EXTENSIONS, pkgName);
4370
+ const ocExtPath = entry?.paths?.openclaw
4371
+ || entry?.ocPath
4372
+ || null;
4373
+
4374
+ // 4. Build the action plan.
4375
+ const actions = [];
4376
+ let pkgInstalledGlobally = false;
4377
+ try {
4378
+ const npmList = execSync(`npm list -g --depth=0 --json 2>/dev/null`, { encoding: 'utf8' });
4379
+ const deps = JSON.parse(npmList).dependencies || {};
4380
+ pkgInstalledGlobally = !!deps[npmPkg];
4381
+ } catch {}
4382
+
4383
+ if (pkgInstalledGlobally) {
4384
+ actions.push({ kind: 'npm-uninstall', npmPkg });
4385
+ } else {
4386
+ actions.push({ kind: 'skip', label: `npm: ${npmPkg} not installed globally` });
4387
+ }
4388
+ if (existsSync(ldmExtPath)) {
4389
+ actions.push({ kind: 'rm-dir', label: 'LDM extension dir', path: ldmExtPath });
4390
+ } else {
4391
+ actions.push({ kind: 'skip', label: `LDM extension dir: ${ldmExtPath} already gone` });
4392
+ }
4393
+ if (ocExtPath && existsSync(ocExtPath)) {
4394
+ actions.push({ kind: 'rm-dir', label: 'OpenClaw extension dir', path: ocExtPath });
4395
+ }
4396
+ if (entry) {
4397
+ actions.push({ kind: 'registry-remove', name: pkgName });
4398
+ } else {
4399
+ actions.push({ kind: 'skip', label: `registry: no entry for ${pkgName}` });
4400
+ }
4401
+
4402
+ const realActions = actions.filter(a => a.kind !== 'skip');
4403
+ const skips = actions.filter(a => a.kind === 'skip');
4404
+
4405
+ console.log('');
4406
+ if (realActions.length === 0) {
4407
+ for (const s of skips) console.log(` - ${s.label}`);
4408
+ console.log('');
4409
+ console.log(' Nothing to do.');
4410
+ console.log('');
4411
+ console.log(' Will NOT touch ~/.codex/ or ~/.ldm/memory/ or other extensions.');
4412
+ return;
4413
+ }
4414
+ console.log(' Will:');
4415
+ for (const a of realActions) {
4416
+ switch (a.kind) {
4417
+ case 'npm-uninstall':
4418
+ console.log(` - npm uninstall -g ${a.npmPkg}`);
4419
+ break;
4420
+ case 'rm-dir':
4421
+ console.log(` - remove ${a.label}: ${a.path}`);
4422
+ break;
4423
+ case 'registry-remove':
4424
+ console.log(` - remove registry entry for ${a.name}`);
4425
+ break;
4426
+ }
4427
+ }
4428
+ for (const s of skips) console.log(` - skipped: ${s.label}`);
4429
+ console.log('');
4430
+ console.log(' Will NOT touch ~/.codex/ or ~/.ldm/memory/ or other extensions.');
4431
+
4432
+ if (isDryRun) {
4433
+ console.log('');
4434
+ console.log(' Dry run. Nothing removed.');
4435
+ console.log('');
4436
+ console.log(' Re-run without --dry-run to apply.');
4437
+ return;
4438
+ }
4439
+
4440
+ console.log('');
4441
+ for (const a of realActions) {
4442
+ switch (a.kind) {
4443
+ case 'npm-uninstall':
4444
+ try {
4445
+ execSync(`npm uninstall -g ${a.npmPkg}`, { stdio: 'pipe', timeout: 60000 });
4446
+ console.log(` + npm uninstall -g ${a.npmPkg}`);
4447
+ } catch (e) {
4448
+ console.error(` ! npm uninstall -g ${a.npmPkg} failed: ${e.message}`);
4449
+ }
4450
+ break;
4451
+ case 'rm-dir':
4452
+ try {
4453
+ execSync(`rm -rf "${a.path}"`, { stdio: 'pipe' });
4454
+ console.log(` + removed ${a.label}: ${a.path}`);
4455
+ } catch (e) {
4456
+ console.error(` ! could not remove ${a.path}: ${e.message}`);
4457
+ }
4458
+ break;
4459
+ case 'registry-remove':
4460
+ try {
4461
+ delete registry.extensions[a.name];
4462
+ writeFileSync(registryPath, JSON.stringify(registry, null, 2) + '\n');
4463
+ console.log(` + removed registry entry for ${a.name}`);
4464
+ } catch (e) {
4465
+ console.error(` ! could not update registry: ${e.message}`);
4466
+ }
4467
+ break;
4468
+ }
4469
+ }
4470
+
4471
+ console.log('');
4472
+ console.log(' Uninstalled.');
4473
+ console.log('');
4474
+ }
4475
+
4292
4476
  // ── ldm worktree ──
4293
4477
 
4294
4478
  async function cmdWorktree() {
@@ -4619,9 +4803,17 @@ async function main() {
4619
4803
  case 'disable':
4620
4804
  await cmdDisable();
4621
4805
  break;
4622
- case 'uninstall':
4623
- await cmdUninstall();
4806
+ case 'uninstall': {
4807
+ // ldm uninstall <pkg> [--dry-run] removes one package
4808
+ // ldm uninstall removes the whole LDM OS install
4809
+ const target = args.slice(1).find(a => !a.startsWith('--'));
4810
+ if (target) {
4811
+ await cmdUninstallPackage(target);
4812
+ } else {
4813
+ await cmdUninstall();
4814
+ }
4624
4815
  break;
4816
+ }
4625
4817
  case 'worktree':
4626
4818
  await cmdWorktree();
4627
4819
  break;
@@ -144,13 +144,15 @@ A plugin for OpenClaw agents. Lifecycle hooks, tool registration, settings.
144
144
 
145
145
  A markdown file that teaches agents when and how to use the tool. The instruction interface. Follows the [Agent Skills Spec](https://agentskills.io/specification).
146
146
 
147
- **Convention:** `SKILL.md` at the repo root. YAML frontmatter with name, description. Optional `references/` directory for context files.
147
+ **Convention:** either `SKILL.md` at the repo root, or one or more skill folders at `skills/<skill-name>/SKILL.md`. YAML frontmatter includes name and description. Optional `references/`, `agents/`, `scripts/`, and `assets/` directories live beside `SKILL.md`.
148
148
 
149
149
  **Platform variants:** Codex CLI reads `AGENTS.md` instead of `SKILL.md`, with the same role and the same content shape. Treat `AGENTS.md` as the Codex-flavored filename for this same interface, not a separate interface. A repo may ship both (or symlink one to the other) so it works in Codex and SKILL.md-aware agents.
150
150
 
151
- **Detection:** `SKILL.md` exists.
151
+ **Detection:** `SKILL.md` exists, or at least one `skills/<skill-name>/SKILL.md` exists.
152
152
 
153
- **Install:** `SKILL.md` deployed to `~/.openclaw/skills/<name>/`. If `references/` exists, deployed alongside SKILL.md and to `settings/docs/skills/<name>/` in the workspace.
153
+ **Install:** the skill folder is deployed to every supported local agent skill surface, including OpenClaw, Codex, Claude Code, and WIP agent compatibility paths when present. If `references/` exists, it is deployed alongside SKILL.md and to `settings/docs/skills/<name>/` in the workspace.
154
+
155
+ **Npm package shape:** a public skill package can expose `SKILL.md` at package root. For example, `@wipcomputer/wip-ai-chat-ui` is sourced from a private repo folder at `design/skills/wip-ai-chat-ui/`, but publishes a tarball with `SKILL.md`, `agents/`, and `references/` at package root so `ldm install @wipcomputer/wip-ai-chat-ui` installs the skill directly.
154
156
 
155
157
  **Structure:**
156
158
  ```
@@ -162,6 +164,17 @@ repo/
162
164
  └── ...
163
165
  ```
164
166
 
167
+ Multiple skills can also live in one repo:
168
+
169
+ ```text
170
+ repo/
171
+ └── skills/
172
+ └── wip-ai-chat-ui/
173
+ ├── SKILL.md
174
+ ├── agents/
175
+ └── references/
176
+ ```
177
+
165
178
  **Key rules (from Agent Skills Spec):**
166
179
  - SKILL.md body < 5000 tokens. Process goes in SKILL.md, context goes in references/.
167
180
  - Imperative language: "Run this command" not "This product enables..."
@@ -148,13 +148,13 @@ A plugin for OpenClaw agents. Lifecycle hooks, tool registration, settings.
148
148
 
149
149
  A markdown file that teaches agents when and how to use the tool. The instruction interface. Follows the [Agent Skills Spec](https://agentskills.io/specification).
150
150
 
151
- **Convention:** `SKILL.md` at the repo root. Optional `references/` directory for context files.
151
+ **Convention:** either `SKILL.md` at the repo root, or one or more skill folders at `skills/<skill-name>/SKILL.md`. Optional `references/`, `agents/`, `scripts/`, and `assets/` directories live beside `SKILL.md`.
152
152
 
153
153
  **Platform variants:** Codex CLI reads `AGENTS.md` with the same role and content shape. Treat as the Codex-flavored filename for this same interface, not a separate one.
154
154
 
155
- **Detection:** `SKILL.md` exists.
155
+ **Detection:** `SKILL.md` exists, or at least one `skills/<skill-name>/SKILL.md` exists.
156
156
 
157
- **Install:** `ldm install` deploys `SKILL.md` to `~/.openclaw/skills/<name>/`. If `references/` exists, it is deployed alongside and also to `settings/docs/skills/<name>/` in the workspace (so all agents can read them).
157
+ **Install:** `ldm install` deploys the skill folder to every supported local agent skill surface, including OpenClaw, Codex, Claude Code, and WIP agent compatibility paths when present. If `references/` exists, it is deployed alongside and also to `settings/docs/skills/<name>/` in the workspace so all agents can read them.
158
158
 
159
159
  **Key rules:**
160
160
  - SKILL.md body < 5000 tokens. Process in SKILL.md, context in references/.
@@ -364,7 +364,7 @@ ldm install # update all
364
364
  | `mcp-server.mjs` | MCP (local stdio) | Adds `command` + `args` entry to `.mcp.json` |
365
365
  | `mcp.remote.url` in `package.json` | Remote MCP | Adds `url` + `transport` entry to `.mcp.json`; prints Claude Desktop hint. **Implementation in flight ([ticket](../../ai/product/bugs/installer/2026-04-28--cc-mini--installer-remote-mcp-detection.md)).** |
366
366
  | `openclaw.plugin.json` | OpenClaw | Copies to `~/.openclaw/extensions/` |
367
- | `SKILL.md` | Skill | Reports path |
367
+ | `SKILL.md` or `skills/<name>/SKILL.md` | Skill | Deploys skill folder to supported agent skill paths |
368
368
  | `guard.mjs` or `claudeCode.hook` | CC Hook | Adds to `~/.claude/settings.json` |
369
369
  | `.claude-plugin/plugin.json` | CC Plugin | Registers with Claude Code marketplace |
370
370
 
package/lib/deploy.mjs CHANGED
@@ -177,6 +177,14 @@ export function detectHarnesses() {
177
177
  skills: join(codexHome, 'skills'),
178
178
  };
179
179
 
180
+ // WIP agent skill compatibility directory
181
+ const agentsHome = join(HOME, '.agents');
182
+ harnesses['wip-agents'] = {
183
+ detected: existsSync(agentsHome),
184
+ home: agentsHome,
185
+ skills: join(agentsHome, 'skills'),
186
+ };
187
+
180
188
  // Cursor
181
189
  const cursorHome = join(HOME, '.cursor');
182
190
  harnesses['cursor'] = {
@@ -207,7 +215,7 @@ export function detectHarnesses() {
207
215
  function getHarnesses() {
208
216
  try {
209
217
  const config = readJSON(LDM_CONFIG_PATH) || {};
210
- if (config.harnesses) {
218
+ if (config.harnesses && config.harnesses['wip-agents']) {
211
219
  const workspace = (config.workspace || '').replace('~', HOME);
212
220
  return { harnesses: config.harnesses, workspace };
213
221
  }
@@ -1097,18 +1105,18 @@ function registerMCP(repoPath, door, toolName) {
1097
1105
  *
1098
1106
  * Returns true if at least one door installed successfully.
1099
1107
  */
1100
- function installClaudeCodeHook(repoPath, doorOrDoors) {
1108
+ function installClaudeCodeHook(repoPath, doorOrDoors, toolName = basename(repoPath)) {
1101
1109
  const doors = Array.isArray(doorOrDoors) ? doorOrDoors : [doorOrDoors];
1102
1110
  let anyOk = false;
1103
1111
  for (const door of doors) {
1104
- if (installClaudeCodeHookEvent(repoPath, door)) {
1112
+ if (installClaudeCodeHookEvent(repoPath, door, toolName)) {
1105
1113
  anyOk = true;
1106
1114
  }
1107
1115
  }
1108
1116
  return anyOk;
1109
1117
  }
1110
1118
 
1111
- function installClaudeCodeHookEvent(repoPath, door) {
1119
+ function installClaudeCodeHookEvent(repoPath, door, toolName = basename(repoPath)) {
1112
1120
  const settingsPath = join(HOME, '.claude', 'settings.json');
1113
1121
  let settings = readJSON(settingsPath);
1114
1122
 
@@ -1117,7 +1125,6 @@ function installClaudeCodeHookEvent(repoPath, door) {
1117
1125
  return false;
1118
1126
  }
1119
1127
 
1120
- const toolName = basename(repoPath);
1121
1128
  const extDir = join(LDM_EXTENSIONS, toolName);
1122
1129
  const installedGuard = join(extDir, 'guard.mjs');
1123
1130
 
@@ -1237,11 +1244,77 @@ function installClaudeCodeHookEvent(repoPath, door) {
1237
1244
  }
1238
1245
  }
1239
1246
 
1240
- function installSkill(repoPath, toolName) {
1247
+ function copySkillTree(skillDir, dest, copyFullFolder = false) {
1248
+ mkdirSync(dest, { recursive: true });
1249
+ if (copyFullFolder) {
1250
+ cpSync(skillDir, dest, { recursive: true });
1251
+ return;
1252
+ }
1253
+
1254
+ cpSync(join(skillDir, 'SKILL.md'), join(dest, 'SKILL.md'));
1255
+ for (const child of ['references', 'agents', 'scripts', 'assets']) {
1256
+ const src = join(skillDir, child);
1257
+ if (existsSync(src)) cpSync(src, join(dest, child), { recursive: true });
1258
+ }
1259
+ }
1260
+
1261
+ const SKILL_COMPANION_DIRS = ['references', 'agents', 'scripts', 'assets'];
1262
+
1263
+ function listSkillCopyEntries(skillDir, copyFullFolder = false) {
1264
+ if (copyFullFolder) {
1265
+ try {
1266
+ return readdirSync(skillDir)
1267
+ .filter((entry) => entry !== '.DS_Store')
1268
+ .sort();
1269
+ } catch {
1270
+ return ['SKILL.md'];
1271
+ }
1272
+ }
1273
+
1274
+ const entries = ['SKILL.md'];
1275
+ for (const child of SKILL_COMPANION_DIRS) {
1276
+ if (existsSync(join(skillDir, child))) entries.push(`${child}/`);
1277
+ }
1278
+ return entries;
1279
+ }
1280
+
1281
+ function printSkillDryRunPlan({ sourceSkillDir, refsSrc, toolName, harnesses, workspace, entries }) {
1282
+ const formatTargetEntries = (base) => entries.map((entry) => join(base, entry));
1283
+ const harnessTargets = Object.entries(harnesses)
1284
+ .filter(([, h]) => h.detected && h.skills)
1285
+ .map(([name, h]) => ({ name, base: join(h.skills, toolName) }));
1286
+
1287
+ ok(`Skill: ${toolName}`);
1288
+ log(`Source: ${sourceSkillDir}`);
1289
+ log('Would copy:');
1290
+ for (const entry of entries) log(`- ${entry}`);
1291
+
1292
+ const permanentBase = join(LDM_EXTENSIONS, toolName);
1293
+ log('Permanent copy:');
1294
+ for (const target of formatTargetEntries(permanentBase)) log(`- ${target}`);
1295
+
1296
+ if (harnessTargets.length > 0) {
1297
+ log('Agent skill targets:');
1298
+ for (const target of harnessTargets) {
1299
+ log(`- ${target.name}: ${target.base}`);
1300
+ for (const entryTarget of formatTargetEntries(target.base)) log(` - ${entryTarget}`);
1301
+ }
1302
+ } else {
1303
+ log('Agent skill targets: no detected skill harnesses');
1304
+ }
1305
+
1306
+ if (existsSync(refsSrc) && workspace && existsSync(workspace)) {
1307
+ const homeRefsDest = join(workspace, 'settings', 'docs', 'skills', toolName);
1308
+ log('Workspace docs target:');
1309
+ log(`- ${homeRefsDest} (references/ only)`);
1310
+ }
1311
+ }
1312
+
1313
+ function installSkillFolder(skillDir, toolName, opts = {}) {
1241
1314
  const { harnesses, workspace } = getHarnesses();
1242
1315
 
1243
- // Find SKILL.md source: repo path first, then permanent copy at ~/.ldm/extensions/
1244
- let skillSrc = join(repoPath, 'SKILL.md');
1316
+ // Find SKILL.md source: skill dir first, then permanent copy at ~/.ldm/extensions/
1317
+ let skillSrc = join(skillDir, 'SKILL.md');
1245
1318
  const permanentSkill = join(LDM_EXTENSIONS, toolName, 'SKILL.md');
1246
1319
  if (!existsSync(skillSrc) && existsSync(permanentSkill)) skillSrc = permanentSkill;
1247
1320
  if (!existsSync(skillSrc)) return false;
@@ -1252,14 +1325,16 @@ function installSkill(repoPath, toolName) {
1252
1325
  return false;
1253
1326
  }
1254
1327
 
1255
- // Find references/ source: repo path first, then permanent copy
1256
- let refsSrc = join(repoPath, 'references');
1328
+ const sourceSkillDir = dirname(skillSrc);
1329
+
1330
+ // Find references/ source: skill dir first, then permanent copy
1331
+ let refsSrc = join(skillDir, 'references');
1257
1332
  const permanentRefs = join(LDM_EXTENSIONS, toolName, 'references');
1258
1333
  if (!existsSync(refsSrc) && existsSync(permanentRefs)) refsSrc = permanentRefs;
1259
1334
 
1260
1335
  if (DRY_RUN) {
1261
- const targets = Object.entries(harnesses).filter(([,h]) => h.detected && h.skills).map(([name]) => name);
1262
- ok(`Skill: would deploy ${toolName} to ${targets.join(', ')} (dry run)`);
1336
+ const entries = listSkillCopyEntries(sourceSkillDir, opts.copyFullFolder);
1337
+ printSkillDryRunPlan({ sourceSkillDir, refsSrc, toolName, harnesses, workspace, entries });
1263
1338
  return true;
1264
1339
  }
1265
1340
 
@@ -1268,21 +1343,13 @@ function installSkill(repoPath, toolName) {
1268
1343
 
1269
1344
  // 1. Save permanent copy to ~/.ldm/extensions/<name>/ (survives tmp cleanup)
1270
1345
  const ldmSkillDir = join(LDM_EXTENSIONS, toolName);
1271
- mkdirSync(ldmSkillDir, { recursive: true });
1272
- cpSync(skillSrc, join(ldmSkillDir, 'SKILL.md'));
1273
- if (existsSync(refsSrc) && refsSrc !== permanentRefs) {
1274
- cpSync(refsSrc, join(ldmSkillDir, 'references'), { recursive: true });
1275
- }
1346
+ copySkillTree(sourceSkillDir, ldmSkillDir, opts.copyFullFolder);
1276
1347
 
1277
1348
  // 2. Deploy to every detected harness that has a skills path
1278
1349
  for (const [name, harness] of Object.entries(harnesses)) {
1279
1350
  if (!harness.detected || !harness.skills) continue;
1280
1351
  const dest = join(harness.skills, toolName);
1281
- mkdirSync(dest, { recursive: true });
1282
- cpSync(skillSrc, join(dest, 'SKILL.md'));
1283
- if (existsSync(refsSrc)) {
1284
- cpSync(refsSrc, join(dest, 'references'), { recursive: true });
1285
- }
1352
+ copySkillTree(sourceSkillDir, dest, opts.copyFullFolder);
1286
1353
  deployed.push(name);
1287
1354
  }
1288
1355
 
@@ -1302,6 +1369,18 @@ function installSkill(repoPath, toolName) {
1302
1369
  }
1303
1370
  }
1304
1371
 
1372
+ function installSkill(repoPath, toolName, skillInfo = null) {
1373
+ if (Array.isArray(skillInfo?.skills) && skillInfo.skills.length > 0) {
1374
+ let installed = 0;
1375
+ for (const skill of skillInfo.skills) {
1376
+ if (installSkillFolder(skill.path, skill.name, { copyFullFolder: true })) installed++;
1377
+ }
1378
+ return installed > 0;
1379
+ }
1380
+
1381
+ return installSkillFolder(repoPath, toolName);
1382
+ }
1383
+
1305
1384
  // ── Single tool install ──
1306
1385
 
1307
1386
  export function installSingleTool(toolPath) {
@@ -1362,6 +1441,10 @@ export function installSingleTool(toolPath) {
1362
1441
  }
1363
1442
  }
1364
1443
 
1444
+ if (interfaces.skill) {
1445
+ installSkill(toolPath, toolName, interfaces.skill);
1446
+ }
1447
+
1365
1448
  return ifaceNames.length;
1366
1449
  }
1367
1450
 
@@ -1422,7 +1505,7 @@ export function installSingleTool(toolPath) {
1422
1505
 
1423
1506
  if (interfaces.claudeCodeHook) {
1424
1507
  if (isEnabled || isAlreadyDeployed) {
1425
- if (installClaudeCodeHook(toolPath, interfaces.claudeCodeHook)) installed++;
1508
+ if (installClaudeCodeHook(toolPath, interfaces.claudeCodeHook, toolName)) installed++;
1426
1509
  } else {
1427
1510
  skip(`Hook: ${toolName} not enabled`);
1428
1511
  }
@@ -1430,7 +1513,7 @@ export function installSingleTool(toolPath) {
1430
1513
 
1431
1514
  if (interfaces.skill) {
1432
1515
  // Skills always deploy. They're instruction files, not running code.
1433
- if (installSkill(toolPath, toolName)) installed++;
1516
+ if (installSkill(toolPath, toolName, interfaces.skill)) installed++;
1434
1517
  }
1435
1518
 
1436
1519
  if (interfaces.module) {
@@ -1591,7 +1674,7 @@ export async function enableExtension(name) {
1591
1674
 
1592
1675
  if (interfaces.mcp) registerMCP(extPath, interfaces.mcp, name);
1593
1676
  if (interfaces.claudeCodeHook) installClaudeCodeHook(extPath, interfaces.claudeCodeHook);
1594
- if (interfaces.skill) installSkill(extPath, name);
1677
+ if (interfaces.skill) installSkill(extPath, name, interfaces.skill);
1595
1678
 
1596
1679
  entry.enabled = true;
1597
1680
  entry.updatedAt = new Date().toISOString();
package/lib/detect.mjs CHANGED
@@ -15,6 +15,23 @@ function readJSON(path) {
15
15
  }
16
16
  }
17
17
 
18
+ function detectSkillDirectories(repoPath) {
19
+ const skillsDir = join(repoPath, 'skills');
20
+ if (!existsSync(skillsDir)) return [];
21
+
22
+ try {
23
+ return readdirSync(skillsDir, { withFileTypes: true })
24
+ .filter(e => e.isDirectory() && existsSync(join(skillsDir, e.name, 'SKILL.md')))
25
+ .map(e => ({
26
+ name: e.name,
27
+ path: join(skillsDir, e.name),
28
+ skillPath: join(skillsDir, e.name, 'SKILL.md'),
29
+ }));
30
+ } catch {
31
+ return [];
32
+ }
33
+ }
34
+
18
35
  /**
19
36
  * Detect all interfaces in a repo.
20
37
  * Returns { interfaces, pkg } where interfaces is an object keyed by interface type.
@@ -48,9 +65,18 @@ export function detectInterfaces(repoPath) {
48
65
  interfaces.openclaw = { config: readJSON(ocPlugin), path: ocPlugin };
49
66
  }
50
67
 
51
- // 5. Skill: SKILL.md exists
52
- if (existsSync(join(repoPath, 'SKILL.md'))) {
53
- interfaces.skill = { path: join(repoPath, 'SKILL.md') };
68
+ // 5. Skill: root SKILL.md, or a skills/<name>/SKILL.md collection.
69
+ const rootSkillPath = join(repoPath, 'SKILL.md');
70
+ if (existsSync(rootSkillPath)) {
71
+ interfaces.skill = { path: rootSkillPath };
72
+ }
73
+
74
+ const skillDirs = detectSkillDirectories(repoPath);
75
+ if (skillDirs.length > 0) {
76
+ interfaces.skill = {
77
+ path: join(repoPath, 'skills'),
78
+ skills: skillDirs,
79
+ };
54
80
  }
55
81
 
56
82
  // 6. Claude Code Hook: guard.mjs or claudeCode.hook(s) in package.json
@@ -103,7 +129,12 @@ export function describeInterfaces(interfaces) {
103
129
  if (interfaces.module) lines.push(`Module: ${JSON.stringify(interfaces.module.main)}`);
104
130
  if (interfaces.mcp) lines.push(`MCP Server: ${interfaces.mcp.file}`);
105
131
  if (interfaces.openclaw) lines.push(`OpenClaw Plugin: ${interfaces.openclaw.config?.name || 'detected'}`);
106
- if (interfaces.skill) lines.push(`Skill: SKILL.md`);
132
+ if (interfaces.skill?.skills?.length) {
133
+ const skills = interfaces.skill.skills.map(s => `${s.name} (${s.skillPath})`);
134
+ lines.push(`Skill: ${skills.join(', ')}`);
135
+ } else if (interfaces.skill) {
136
+ lines.push(`Skill: SKILL.md`);
137
+ }
107
138
  if (interfaces.claudeCodeHook) {
108
139
  const events = interfaces.claudeCodeHook.map(h => h.event || 'PreToolUse');
109
140
  lines.push(`Claude Code Hook: ${events.join(', ')}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wipcomputer/wip-ldm-os",
3
- "version": "0.4.84",
3
+ "version": "0.4.85-alpha.10",
4
4
  "type": "module",
5
5
  "description": "LDM OS: identity, memory, and sovereignty infrastructure for AI agents",
6
6
  "engines": {
@@ -22,9 +22,17 @@
22
22
  "validate:bin-manifest": "node scripts/validate-bin-manifest.mjs",
23
23
  "test:skill-frontmatter": "node scripts/test-skill-frontmatter.mjs",
24
24
  "test:installer-update-tracks": "node scripts/test-installer-update-tracks.mjs",
25
+ "test:installer-hook-toolname": "node scripts/test-installer-hook-toolname.mjs",
26
+ "test:installer-skill-directory": "node scripts/test-installer-skill-directory.mjs",
27
+ "test:installer-skill-dry-run-destinations": "node scripts/test-installer-skill-dry-run-destinations.mjs",
25
28
  "test:ldm-install-bin-shim": "node scripts/test-ldm-install-preserves-foreign-bin.mjs",
26
29
  "test:doctor-cron-target": "node scripts/test-doctor-cron-target.mjs",
27
30
  "test:bin-manifest": "node scripts/test-bin-manifest.mjs",
31
+ "test:crc-agentid-tenant-boundary": "node scripts/test-crc-agentid-tenant-boundary.mjs",
32
+ "test:crc-pair-login-flow": "node scripts/test-crc-pair-login-flow.mjs",
33
+ "test:crc-pair-status-poll-token": "node scripts/test-crc-pair-status-poll-token.mjs",
34
+ "test:crc-e2ee-key-persistence": "node scripts/test-crc-e2ee-key-persistence.mjs",
35
+ "test:crc-e2ee-session-route": "node scripts/test-crc-e2ee-session-route.mjs",
28
36
  "fmt": "npx prettier --write 'src/**/*.{ts,mjs}' 'lib/**/*.mjs' 'bin/**/*.js'",
29
37
  "fmt:check": "npx prettier --check 'src/**/*.{ts,mjs}' 'lib/**/*.mjs' 'bin/**/*.js'"
30
38
  },