@wipcomputer/wip-ldm-os 0.4.85-alpha.2 → 0.4.85-alpha.21

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.
Files changed (37) hide show
  1. package/README.md +22 -2
  2. package/SKILL.md +8 -5
  3. package/bin/ldm.js +169 -65
  4. package/docs/universal-installer/SPEC.md +16 -3
  5. package/docs/universal-installer/TECHNICAL.md +4 -4
  6. package/lib/deploy.mjs +104 -20
  7. package/lib/detect.mjs +35 -4
  8. package/package.json +13 -2
  9. package/scripts/test-crc-agentid-tenant-boundary.mjs +80 -0
  10. package/scripts/test-crc-e2ee-key-persistence.mjs +150 -0
  11. package/scripts/test-crc-e2ee-session-route.mjs +129 -0
  12. package/scripts/test-crc-pair-login-flow.mjs +40 -0
  13. package/scripts/test-crc-pair-relink-audit-and-rotation.mjs +164 -0
  14. package/scripts/test-crc-pair-status-poll-token.mjs +73 -0
  15. package/scripts/test-install-prompt-policy.mjs +60 -0
  16. package/scripts/test-installer-skill-directory.mjs +55 -0
  17. package/scripts/test-installer-skill-dry-run-destinations.mjs +100 -0
  18. package/scripts/test-installer-target-self-update.mjs +131 -0
  19. package/scripts/test-ldm-status-timeout.mjs +80 -0
  20. package/shared/templates/install-prompt.md +20 -2
  21. package/src/hosted-mcp/README.md +15 -0
  22. package/src/hosted-mcp/app/footer.js +74 -0
  23. package/src/hosted-mcp/app/kaleidoscope-login.html +846 -0
  24. package/src/hosted-mcp/app/pair.html +165 -57
  25. package/src/hosted-mcp/app/sprites.png +0 -0
  26. package/src/hosted-mcp/codex-relay-e2ee-registry.mjs +208 -0
  27. package/src/hosted-mcp/demo/index.html +3 -7
  28. package/src/hosted-mcp/demo/login.html +318 -20
  29. package/src/hosted-mcp/deploy.sh +307 -56
  30. package/src/hosted-mcp/docs/self-host.md +268 -0
  31. package/src/hosted-mcp/nginx/codex-relay.conf +25 -0
  32. package/src/hosted-mcp/nginx/conf.d/redact-logs.conf +60 -0
  33. package/src/hosted-mcp/nginx/mcp-oauth.conf +58 -0
  34. package/src/hosted-mcp/nginx/wip.computer.conf +25 -1
  35. package/src/hosted-mcp/scripts/audit-logs.sh +205 -0
  36. package/src/hosted-mcp/scripts/verify-deploy.sh +102 -0
  37. package/src/hosted-mcp/server.mjs +963 -146
package/README.md CHANGED
@@ -22,7 +22,18 @@ Open your AI and paste this:
22
22
  ```
23
23
  Read https://wip.computer/install/wip-ldm-os.txt
24
24
 
25
- Check if LDM OS is already installed. If it is, run ldm install --dry-run and show me what I have and what's new.
25
+ Use the install document and live local checks as the source of truth. Do not search memory or prior notes for this install.
26
+
27
+ Check if LDM OS is installed (`which ldm && ldm --version`). Branch.
28
+
29
+ If installed: run `ldm status` and show me the version table and what's new for each component. Then ask:
30
+ - Do you have questions?
31
+ - Want to see a dry run?
32
+
33
+ If yes to dry run, run `ldm install --dry-run`.
34
+
35
+ If I say install, run:
36
+ `npm install -g @wipcomputer/wip-ldm-os@latest && ldm install && ldm doctor`
26
37
 
27
38
  If not, walk me through setup and explain:
28
39
 
@@ -35,7 +46,14 @@ Then ask:
35
46
  - Do you have questions?
36
47
  - Want to see a dry run?
37
48
 
38
- If I say yes: Install the CLI first (npm install -g @wipcomputer/wip-ldm-os) and then run ldm install --dry-run.
49
+ If yes to dry run, install the CLI first:
50
+ `npm install -g @wipcomputer/wip-ldm-os`
51
+
52
+ Then run:
53
+ `ldm init --dry-run`
54
+
55
+ If I say install, run:
56
+ `ldm init`
39
57
 
40
58
  Show me exactly what will change. Don't install anything until I say "install".
41
59
  ```
@@ -105,6 +123,8 @@ The OS connects your AIs. Add-ons are what they actually use. Each one is a full
105
123
  ## More Info
106
124
 
107
125
  - [Architecture, principles, and technical details](TECHNICAL.md)
126
+ - [Hosted MCP and relay source](src/hosted-mcp/README.md)
127
+ - [Hosted relay self-host guide](src/hosted-mcp/docs/self-host.md)
108
128
 
109
129
  ## License
110
130
 
package/SKILL.md CHANGED
@@ -27,6 +27,12 @@ metadata:
27
27
 
28
28
  # LDM OS
29
29
 
30
+ ## Source of truth
31
+
32
+ Use this install document and live local checks as the source of truth. Do not search memory or prior notes for this install.
33
+
34
+ Do not run GitHub release commands during the install-state flow. Do not run `gh release list` or `gh release view` unless the user explicitly asks for release notes. Install-state answers should come from local commands, `ldm status`, and npm package metadata.
35
+
30
36
  ## Step 1: Check if installed
31
37
 
32
38
  ```bash
@@ -62,12 +68,9 @@ Always a table. Every component with an update gets its own row.
62
68
 
63
69
  **Do NOT skip this step.**
64
70
 
65
- ```bash
66
- gh release list --repo wipcomputer/<repo-name> --limit 5 --json tagName,name --jq '.[]'
67
- gh release view <tag> --repo wipcomputer/<repo-name> --json body --jq .body
68
- ```
71
+ Use the output of `ldm status`, installed package metadata, and npm metadata. Do not use GitHub release commands here.
69
72
 
70
- Translate release notes to user language. Every bullet answers "what changed for ME?"
73
+ Translate available update information to user language. Every bullet answers "what changed for ME?" If the status output does not include enough detail for a component, say that clearly and do not invent release notes.
71
74
 
72
75
  Good: "Your AIs now explain what LDM OS actually does when you ask them to install it"
73
76
  Bad: "Restored rich product content to SKILL.md"
package/bin/ldm.js CHANGED
@@ -22,7 +22,7 @@
22
22
 
23
23
  import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, cpSync, chmodSync, unlinkSync, readlinkSync, renameSync, statSync, lstatSync, symlinkSync } from 'node:fs';
24
24
  import { join, basename, resolve, dirname } from 'node:path';
25
- import { execSync } from 'node:child_process';
25
+ import { execFileSync, execSync, spawnSync } from 'node:child_process';
26
26
  import { fileURLToPath } from 'node:url';
27
27
 
28
28
  const __filename = fileURLToPath(import.meta.url);
@@ -211,6 +211,11 @@ function writeJSON(path, data) {
211
211
  writeFileSync(path, JSON.stringify(data, null, 2) + '\n');
212
212
  }
213
213
 
214
+ function parsePositiveInt(value, fallback) {
215
+ const parsed = Number.parseInt(value || '', 10);
216
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
217
+ }
218
+
214
219
  // ── CLI version check (#29) ──
215
220
 
216
221
  function checkCliVersion() {
@@ -229,6 +234,62 @@ function checkCliVersion() {
229
234
  }
230
235
  }
231
236
 
237
+ function selectedLdmNpmTrack() {
238
+ return ALPHA_FLAG ? 'alpha' : BETA_FLAG ? 'beta' : 'latest';
239
+ }
240
+
241
+ function selectedLdmTrackLabel(npmTag) {
242
+ return npmTag === 'latest' ? '' : ` (${npmTag} track)`;
243
+ }
244
+
245
+ function latestLdmCliForSelectedTrack() {
246
+ const npmTag = selectedLdmNpmTrack();
247
+ const npmViewCmd = npmTag === 'latest'
248
+ ? 'npm view @wipcomputer/wip-ldm-os version 2>/dev/null'
249
+ : `npm view @wipcomputer/wip-ldm-os dist-tags.${npmTag} 2>/dev/null`;
250
+ const latest = execSync(npmViewCmd, {
251
+ encoding: 'utf8',
252
+ timeout: 15000,
253
+ }).trim();
254
+ return { latest, npmTag };
255
+ }
256
+
257
+ function maybeSelfUpdateLdmCliBeforeInstall() {
258
+ // This shared preflight covers both bare `ldm install` and targeted
259
+ // `ldm install <app>`. Dry runs never update, but still disclose skew.
260
+ if (process.env.LDM_SELF_UPDATED) return;
261
+
262
+ try {
263
+ const { latest, npmTag } = latestLdmCliForSelectedTrack();
264
+ if (!latest || !semverNewer(latest, PKG_VERSION)) return;
265
+
266
+ const trackLabel = selectedLdmTrackLabel(npmTag);
267
+
268
+ if (DRY_RUN) {
269
+ console.log(` LDM OS CLI v${PKG_VERSION} -> v${latest}${trackLabel} is available.`);
270
+ console.log(` Dry run only: continuing with v${PKG_VERSION}.`);
271
+ console.log('');
272
+ return;
273
+ }
274
+
275
+ console.log(` LDM OS CLI v${PKG_VERSION} -> v${latest}${trackLabel}. Updating first...`);
276
+ try {
277
+ execSync(`npm install -g @wipcomputer/wip-ldm-os@${latest}`, { stdio: 'inherit', timeout: 60000 });
278
+ console.log(` CLI updated to v${latest}. Re-running with new code...`);
279
+ console.log('');
280
+ const reArgs = process.argv.slice(2);
281
+ const child = spawnSync('ldm', reArgs.length > 0 ? reArgs : ['install'], {
282
+ stdio: 'inherit',
283
+ env: { ...process.env, LDM_SELF_UPDATED: '1' },
284
+ });
285
+ if (child.error) throw child.error;
286
+ process.exit(child.status ?? 1);
287
+ } catch (e) {
288
+ console.log(` ! Self-update failed: ${e.message}. Continuing with v${PKG_VERSION}.`);
289
+ }
290
+ } catch {}
291
+ }
292
+
232
293
  // ── Dead backup trigger cleanup (#207) ──
233
294
  // Three backup systems were competing. Only ai.openclaw.ldm-backup (3am) works.
234
295
  // This removes: broken cron entry (LDMDevTools.app), old com.wipcomputer.daily-backup.
@@ -738,6 +799,24 @@ function deployDocs() {
738
799
  console.log(` + ${agentDocsCount} personalized doc(s) deployed to ${agentLibraryDest.replace(HOME, '~')}/`);
739
800
  }
740
801
 
802
+ // Migration-window compatibility write (added 2026-04-30).
803
+ // dev-guide-wipcomputerinc.md was migrated from ~/.ldm/shared/ to
804
+ // ~/.ldm/library/documentation/ on 2026-04-19, but agent boot files,
805
+ // ~/.claude/rules/, and other consumers still reference the old shared
806
+ // path. Without this write, the old path serves stale content and
807
+ // agents reading by the old path get pre-migration policy.
808
+ // Forward-migration (grep-update consumers, then remove this compat
809
+ // write) is tracked separately. See bugs/installer/ ticket
810
+ // 2026-04-30--cc-mini--dev-guide-split-path-migration.md.
811
+ const devGuideName = 'dev-guide-wipcomputerinc.md';
812
+ const devGuideNew = join(agentLibraryDest, devGuideName);
813
+ const devGuideOld = join(LDM_ROOT, 'shared', devGuideName);
814
+ if (existsSync(devGuideNew)) {
815
+ mkdirSync(dirname(devGuideOld), { recursive: true });
816
+ cpSync(devGuideNew, devGuideOld);
817
+ console.log(` + Compat write: ${devGuideName} also deployed to ${devGuideOld.replace(HOME, '~')} (migration window)`);
818
+ }
819
+
741
820
  return docsCount + agentDocsCount;
742
821
  }
743
822
 
@@ -1422,23 +1501,6 @@ async function showCatalogPicker() {
1422
1501
  // ── ldm install ──
1423
1502
 
1424
1503
  async function cmdInstall() {
1425
- if (!DRY_RUN && !acquireInstallLock()) return;
1426
-
1427
- // Ensure LDM is initialized
1428
- if (!existsSync(VERSION_PATH)) {
1429
- console.log(' LDM OS not initialized. Running init first...');
1430
- console.log('');
1431
- cmdInit();
1432
- }
1433
-
1434
- const { setFlags, installFromPath, installSingleTool, installToolbox, detectHarnesses } = await import('../lib/deploy.mjs');
1435
- const { detectInterfacesJSON } = await import('../lib/detect.mjs');
1436
-
1437
- // Refresh harness detection (catches newly installed harnesses)
1438
- detectHarnesses();
1439
-
1440
- setFlags({ dryRun: DRY_RUN, jsonOutput: JSON_OUTPUT, origin: 'manual' });
1441
-
1442
1504
  // --help flag (#81)
1443
1505
  if (args.includes('--help') || args.includes('-h')) {
1444
1506
  console.log(`
@@ -1458,6 +1520,25 @@ async function cmdInstall() {
1458
1520
  process.exit(0);
1459
1521
  }
1460
1522
 
1523
+ maybeSelfUpdateLdmCliBeforeInstall();
1524
+
1525
+ if (!DRY_RUN && !acquireInstallLock()) return;
1526
+
1527
+ // Ensure LDM is initialized
1528
+ if (!existsSync(VERSION_PATH)) {
1529
+ console.log(' LDM OS not initialized. Running init first...');
1530
+ console.log('');
1531
+ cmdInit();
1532
+ }
1533
+
1534
+ const { setFlags, installFromPath, installSingleTool, installToolbox, detectHarnesses } = await import('../lib/deploy.mjs');
1535
+ const { detectInterfacesJSON } = await import('../lib/detect.mjs');
1536
+
1537
+ // Refresh harness detection (catches newly installed harnesses)
1538
+ detectHarnesses();
1539
+
1540
+ setFlags({ dryRun: DRY_RUN, jsonOutput: JSON_OUTPUT, origin: 'manual' });
1541
+
1461
1542
  // Find the target (skip flags)
1462
1543
  const target = args.slice(1).find(a => !a.startsWith('--'));
1463
1544
 
@@ -1903,38 +1984,6 @@ async function cmdInstallCatalog() {
1903
1984
  // No lock here. cmdInstall() already holds it when calling this.
1904
1985
  installLog(`ldm install started (v${PKG_VERSION}, DRY_RUN=${DRY_RUN})`);
1905
1986
 
1906
- // Self-update: check if CLI itself is outdated. Update first, then re-exec.
1907
- // This breaks the chicken-and-egg: new features in ldm install are always
1908
- // available because the installer upgrades itself before doing anything else.
1909
- // --alpha and --beta flags check the corresponding npm dist-tag instead of @latest.
1910
- if (!DRY_RUN && !process.env.LDM_SELF_UPDATED) {
1911
- try {
1912
- const npmTag = ALPHA_FLAG ? 'alpha' : BETA_FLAG ? 'beta' : 'latest';
1913
- const trackLabel = npmTag === 'latest' ? '' : ` (${npmTag} track)`;
1914
- const npmViewCmd = npmTag === 'latest'
1915
- ? 'npm view @wipcomputer/wip-ldm-os version 2>/dev/null'
1916
- : `npm view @wipcomputer/wip-ldm-os dist-tags.${npmTag} 2>/dev/null`;
1917
- const latest = execSync(npmViewCmd, {
1918
- encoding: 'utf8', timeout: 15000,
1919
- }).trim();
1920
- if (latest && semverNewer(latest, PKG_VERSION)) {
1921
- console.log(` LDM OS CLI v${PKG_VERSION} -> v${latest}${trackLabel}. Updating first...`);
1922
- try {
1923
- execSync(`npm install -g @wipcomputer/wip-ldm-os@${latest}`, { stdio: 'inherit', timeout: 60000 });
1924
- console.log(` CLI updated to v${latest}. Re-running with new code...`);
1925
- console.log('');
1926
- // Re-exec with the new binary. LDM_SELF_UPDATED prevents infinite loop.
1927
- // process.argv.slice(2) skips 'node' and the script path, keeps just 'install' + flags
1928
- const reArgs = process.argv.slice(2).join(' ') || 'install';
1929
- execSync(`LDM_SELF_UPDATED=1 ldm ${reArgs}`, { stdio: 'inherit' });
1930
- process.exit(0);
1931
- } catch (e) {
1932
- console.log(` ! Self-update failed: ${e.message}. Continuing with v${PKG_VERSION}.`);
1933
- }
1934
- }
1935
- } catch {}
1936
- }
1937
-
1938
1987
  autoDetectExtensions();
1939
1988
 
1940
1989
  // Migrate old registry entries to v2 format (#262)
@@ -3233,6 +3282,28 @@ async function cmdDoctor() {
3233
3282
 
3234
3283
  // ── ldm status ──
3235
3284
 
3285
+ const STATUS_NPM_TIMEOUT_MS = parsePositiveInt(process.env.LDM_STATUS_NPM_TIMEOUT_MS, 5000);
3286
+ const STATUS_TOTAL_BUDGET_MS = parsePositiveInt(process.env.LDM_STATUS_TOTAL_BUDGET_MS, 60000);
3287
+
3288
+ function npmViewVersionForStatus(pkg, timeoutMs) {
3289
+ return execFileSync('npm', ['view', pkg, 'version'], {
3290
+ encoding: 'utf8',
3291
+ timeout: timeoutMs,
3292
+ stdio: ['ignore', 'pipe', 'ignore'],
3293
+ }).trim();
3294
+ }
3295
+
3296
+ function remainingStatusBudgetMs(startedAt) {
3297
+ return Math.max(0, STATUS_TOTAL_BUDGET_MS - (Date.now() - startedAt));
3298
+ }
3299
+
3300
+ function classifyStatusCheckError(error) {
3301
+ if (error?.signal === 'SIGTERM' || error?.code === 'ETIMEDOUT' || String(error?.message || '').includes('ETIMEDOUT')) {
3302
+ return 'timeout';
3303
+ }
3304
+ return 'unavailable';
3305
+ }
3306
+
3236
3307
  function cmdStatus() {
3237
3308
  const version = readJSON(VERSION_PATH);
3238
3309
  const registry = readJSON(REGISTRY_PATH);
@@ -3254,14 +3325,29 @@ function cmdStatus() {
3254
3325
  return;
3255
3326
  }
3256
3327
 
3257
- // Check CLI version against npm
3328
+ console.log('');
3329
+ console.log(` LDM OS v${version.version}`);
3330
+ console.log(` Installed: ${version.installed?.split('T')[0] || 'unknown'}`);
3331
+ console.log(` Updated: ${version.updated?.split('T')[0] || 'unknown'}`);
3332
+ console.log(` Extensions: ${extCount}`);
3333
+ console.log(` Root: ${LDM_ROOT}`);
3334
+
3335
+ const statusStartedAt = Date.now();
3336
+ const skipped = [];
3337
+
3338
+ console.log('');
3339
+ console.log(' Checking updates:');
3340
+
3258
3341
  let cliUpdate = null;
3259
3342
  try {
3260
- const latest = execSync('npm view @wipcomputer/wip-ldm-os version 2>/dev/null', {
3261
- encoding: 'utf8', timeout: 10000,
3262
- }).trim();
3343
+ const timeout = Math.min(STATUS_NPM_TIMEOUT_MS, remainingStatusBudgetMs(statusStartedAt));
3344
+ if (timeout <= 0) throw new Error('status update-check budget exhausted');
3345
+ console.log(' ldm cli: checking npm');
3346
+ const latest = npmViewVersionForStatus('@wipcomputer/wip-ldm-os', timeout);
3263
3347
  if (latest && semverNewer(latest, PKG_VERSION)) cliUpdate = latest;
3264
- } catch {}
3348
+ } catch (error) {
3349
+ skipped.push({ name: 'ldm cli', npm: '@wipcomputer/wip-ldm-os', reason: classifyStatusCheckError(error) });
3350
+ }
3265
3351
 
3266
3352
  // Check extensions against npm using registry source info (#262)
3267
3353
  const updates = [];
@@ -3277,21 +3363,31 @@ function cmdStatus() {
3277
3363
  const currentVersion = info?.installed?.version || info.version;
3278
3364
  if (!currentVersion) continue;
3279
3365
  try {
3280
- const latest = execSync(`npm view ${npmPkg} version 2>/dev/null`, {
3281
- encoding: 'utf8', timeout: 10000,
3282
- }).trim();
3366
+ const timeout = Math.min(STATUS_NPM_TIMEOUT_MS, remainingStatusBudgetMs(statusStartedAt));
3367
+ if (timeout <= 0) {
3368
+ skipped.push({ name, npm: npmPkg, reason: 'budget' });
3369
+ continue;
3370
+ }
3371
+ console.log(` ${name}: checking npm`);
3372
+ const latest = npmViewVersionForStatus(npmPkg, timeout);
3283
3373
  if (latest && semverNewer(latest, currentVersion)) {
3284
3374
  updates.push({ name, current: currentVersion, latest, npm: npmPkg });
3285
3375
  }
3286
- } catch {}
3376
+ } catch (error) {
3377
+ skipped.push({ name, npm: npmPkg, reason: classifyStatusCheckError(error) });
3378
+ }
3287
3379
  }
3288
3380
 
3289
3381
  console.log('');
3290
- console.log(` LDM OS v${version.version}${cliUpdate ? ` (v${cliUpdate} available)` : ' (latest)'}`);
3291
- console.log(` Installed: ${version.installed?.split('T')[0]}`);
3292
- console.log(` Updated: ${version.updated?.split('T')[0]}`);
3293
- console.log(` Extensions: ${extCount}${updates.length > 0 ? `, ${updates.length} update(s) available` : ', all up to date'}`);
3294
- console.log(` Root: ${LDM_ROOT}`);
3382
+ if (updates.length === 0 && !cliUpdate && skipped.length === 0) {
3383
+ console.log(' Update summary: all up to date');
3384
+ } else {
3385
+ const summaryParts = [];
3386
+ if (updates.length > 0) summaryParts.push(`${updates.length} extension update(s) available`);
3387
+ if (cliUpdate) summaryParts.push('CLI update available');
3388
+ if (skipped.length > 0) summaryParts.push(`${skipped.length} update check(s) skipped`);
3389
+ console.log(` Update summary: ${summaryParts.join(', ')}`);
3390
+ }
3295
3391
 
3296
3392
  if (updates.length > 0) {
3297
3393
  console.log('');
@@ -3307,6 +3403,14 @@ function cmdStatus() {
3307
3403
  console.log(` CLI update: npm install -g @wipcomputer/wip-ldm-os@${cliUpdate}`);
3308
3404
  }
3309
3405
 
3406
+ if (skipped.length > 0) {
3407
+ console.log('');
3408
+ console.log(' Update checks skipped:');
3409
+ for (const item of skipped) {
3410
+ console.log(` ${item.name}: [${item.reason}] ${item.npm}`);
3411
+ }
3412
+ }
3413
+
3310
3414
  console.log('');
3311
3415
  }
3312
3416
 
@@ -3987,7 +4091,7 @@ async function main() {
3987
4091
  console.log(' Module ... ESM main/exports -> importable');
3988
4092
  console.log(' MCP Server ... mcp-server.mjs -> claude mcp add --scope user');
3989
4093
  console.log(' OpenClaw ... openclaw.plugin.json -> ~/.ldm/extensions/ + ~/.openclaw/extensions/');
3990
- console.log(' Skill ... SKILL.md -> ~/.openclaw/skills/<tool>/');
4094
+ console.log(' Skill ... SKILL.md or skills/<name>/SKILL.md -> agent skill paths');
3991
4095
  console.log(' CC Hook ... guard.mjs or claudeCode.hook -> ~/.claude/settings.json');
3992
4096
  console.log('');
3993
4097
  console.log(` v${PKG_VERSION}`);
@@ -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
  }
@@ -1236,11 +1244,77 @@ function installClaudeCodeHookEvent(repoPath, door, toolName = basename(repoPath
1236
1244
  }
1237
1245
  }
1238
1246
 
1239
- 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 = {}) {
1240
1314
  const { harnesses, workspace } = getHarnesses();
1241
1315
 
1242
- // Find SKILL.md source: repo path first, then permanent copy at ~/.ldm/extensions/
1243
- 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');
1244
1318
  const permanentSkill = join(LDM_EXTENSIONS, toolName, 'SKILL.md');
1245
1319
  if (!existsSync(skillSrc) && existsSync(permanentSkill)) skillSrc = permanentSkill;
1246
1320
  if (!existsSync(skillSrc)) return false;
@@ -1251,14 +1325,16 @@ function installSkill(repoPath, toolName) {
1251
1325
  return false;
1252
1326
  }
1253
1327
 
1254
- // Find references/ source: repo path first, then permanent copy
1255
- 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');
1256
1332
  const permanentRefs = join(LDM_EXTENSIONS, toolName, 'references');
1257
1333
  if (!existsSync(refsSrc) && existsSync(permanentRefs)) refsSrc = permanentRefs;
1258
1334
 
1259
1335
  if (DRY_RUN) {
1260
- const targets = Object.entries(harnesses).filter(([,h]) => h.detected && h.skills).map(([name]) => name);
1261
- 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 });
1262
1338
  return true;
1263
1339
  }
1264
1340
 
@@ -1267,21 +1343,13 @@ function installSkill(repoPath, toolName) {
1267
1343
 
1268
1344
  // 1. Save permanent copy to ~/.ldm/extensions/<name>/ (survives tmp cleanup)
1269
1345
  const ldmSkillDir = join(LDM_EXTENSIONS, toolName);
1270
- mkdirSync(ldmSkillDir, { recursive: true });
1271
- cpSync(skillSrc, join(ldmSkillDir, 'SKILL.md'));
1272
- if (existsSync(refsSrc) && refsSrc !== permanentRefs) {
1273
- cpSync(refsSrc, join(ldmSkillDir, 'references'), { recursive: true });
1274
- }
1346
+ copySkillTree(sourceSkillDir, ldmSkillDir, opts.copyFullFolder);
1275
1347
 
1276
1348
  // 2. Deploy to every detected harness that has a skills path
1277
1349
  for (const [name, harness] of Object.entries(harnesses)) {
1278
1350
  if (!harness.detected || !harness.skills) continue;
1279
1351
  const dest = join(harness.skills, toolName);
1280
- mkdirSync(dest, { recursive: true });
1281
- cpSync(skillSrc, join(dest, 'SKILL.md'));
1282
- if (existsSync(refsSrc)) {
1283
- cpSync(refsSrc, join(dest, 'references'), { recursive: true });
1284
- }
1352
+ copySkillTree(sourceSkillDir, dest, opts.copyFullFolder);
1285
1353
  deployed.push(name);
1286
1354
  }
1287
1355
 
@@ -1301,6 +1369,18 @@ function installSkill(repoPath, toolName) {
1301
1369
  }
1302
1370
  }
1303
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
+
1304
1384
  // ── Single tool install ──
1305
1385
 
1306
1386
  export function installSingleTool(toolPath) {
@@ -1361,6 +1441,10 @@ export function installSingleTool(toolPath) {
1361
1441
  }
1362
1442
  }
1363
1443
 
1444
+ if (interfaces.skill) {
1445
+ installSkill(toolPath, toolName, interfaces.skill);
1446
+ }
1447
+
1364
1448
  return ifaceNames.length;
1365
1449
  }
1366
1450
 
@@ -1429,7 +1513,7 @@ export function installSingleTool(toolPath) {
1429
1513
 
1430
1514
  if (interfaces.skill) {
1431
1515
  // Skills always deploy. They're instruction files, not running code.
1432
- if (installSkill(toolPath, toolName)) installed++;
1516
+ if (installSkill(toolPath, toolName, interfaces.skill)) installed++;
1433
1517
  }
1434
1518
 
1435
1519
  if (interfaces.module) {
@@ -1590,7 +1674,7 @@ export async function enableExtension(name) {
1590
1674
 
1591
1675
  if (interfaces.mcp) registerMCP(extPath, interfaces.mcp, name);
1592
1676
  if (interfaces.claudeCodeHook) installClaudeCodeHook(extPath, interfaces.claudeCodeHook);
1593
- if (interfaces.skill) installSkill(extPath, name);
1677
+ if (interfaces.skill) installSkill(extPath, name, interfaces.skill);
1594
1678
 
1595
1679
  entry.enabled = true;
1596
1680
  entry.updatedAt = new Date().toISOString();