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

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 (36) hide show
  1. package/README.md +22 -2
  2. package/SKILL.md +8 -5
  3. package/bin/ldm.js +95 -51
  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 +12 -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/shared/templates/install-prompt.md +20 -2
  20. package/src/hosted-mcp/README.md +15 -0
  21. package/src/hosted-mcp/app/footer.js +74 -0
  22. package/src/hosted-mcp/app/kaleidoscope-login.html +846 -0
  23. package/src/hosted-mcp/app/pair.html +165 -57
  24. package/src/hosted-mcp/app/sprites.png +0 -0
  25. package/src/hosted-mcp/codex-relay-e2ee-registry.mjs +208 -0
  26. package/src/hosted-mcp/demo/index.html +3 -7
  27. package/src/hosted-mcp/demo/login.html +318 -20
  28. package/src/hosted-mcp/deploy.sh +307 -56
  29. package/src/hosted-mcp/docs/self-host.md +268 -0
  30. package/src/hosted-mcp/nginx/codex-relay.conf +25 -0
  31. package/src/hosted-mcp/nginx/conf.d/redact-logs.conf +60 -0
  32. package/src/hosted-mcp/nginx/mcp-oauth.conf +58 -0
  33. package/src/hosted-mcp/nginx/wip.computer.conf +25 -1
  34. package/src/hosted-mcp/scripts/audit-logs.sh +205 -0
  35. package/src/hosted-mcp/scripts/verify-deploy.sh +102 -0
  36. 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 { execSync, spawnSync } from 'node:child_process';
26
26
  import { fileURLToPath } from 'node:url';
27
27
 
28
28
  const __filename = fileURLToPath(import.meta.url);
@@ -229,6 +229,62 @@ function checkCliVersion() {
229
229
  }
230
230
  }
231
231
 
232
+ function selectedLdmNpmTrack() {
233
+ return ALPHA_FLAG ? 'alpha' : BETA_FLAG ? 'beta' : 'latest';
234
+ }
235
+
236
+ function selectedLdmTrackLabel(npmTag) {
237
+ return npmTag === 'latest' ? '' : ` (${npmTag} track)`;
238
+ }
239
+
240
+ function latestLdmCliForSelectedTrack() {
241
+ const npmTag = selectedLdmNpmTrack();
242
+ const npmViewCmd = npmTag === 'latest'
243
+ ? 'npm view @wipcomputer/wip-ldm-os version 2>/dev/null'
244
+ : `npm view @wipcomputer/wip-ldm-os dist-tags.${npmTag} 2>/dev/null`;
245
+ const latest = execSync(npmViewCmd, {
246
+ encoding: 'utf8',
247
+ timeout: 15000,
248
+ }).trim();
249
+ return { latest, npmTag };
250
+ }
251
+
252
+ function maybeSelfUpdateLdmCliBeforeInstall() {
253
+ // This shared preflight covers both bare `ldm install` and targeted
254
+ // `ldm install <app>`. Dry runs never update, but still disclose skew.
255
+ if (process.env.LDM_SELF_UPDATED) return;
256
+
257
+ try {
258
+ const { latest, npmTag } = latestLdmCliForSelectedTrack();
259
+ if (!latest || !semverNewer(latest, PKG_VERSION)) return;
260
+
261
+ const trackLabel = selectedLdmTrackLabel(npmTag);
262
+
263
+ if (DRY_RUN) {
264
+ console.log(` LDM OS CLI v${PKG_VERSION} -> v${latest}${trackLabel} is available.`);
265
+ console.log(` Dry run only: continuing with v${PKG_VERSION}.`);
266
+ console.log('');
267
+ return;
268
+ }
269
+
270
+ console.log(` LDM OS CLI v${PKG_VERSION} -> v${latest}${trackLabel}. Updating first...`);
271
+ try {
272
+ execSync(`npm install -g @wipcomputer/wip-ldm-os@${latest}`, { stdio: 'inherit', timeout: 60000 });
273
+ console.log(` CLI updated to v${latest}. Re-running with new code...`);
274
+ console.log('');
275
+ const reArgs = process.argv.slice(2);
276
+ const child = spawnSync('ldm', reArgs.length > 0 ? reArgs : ['install'], {
277
+ stdio: 'inherit',
278
+ env: { ...process.env, LDM_SELF_UPDATED: '1' },
279
+ });
280
+ if (child.error) throw child.error;
281
+ process.exit(child.status ?? 1);
282
+ } catch (e) {
283
+ console.log(` ! Self-update failed: ${e.message}. Continuing with v${PKG_VERSION}.`);
284
+ }
285
+ } catch {}
286
+ }
287
+
232
288
  // ── Dead backup trigger cleanup (#207) ──
233
289
  // Three backup systems were competing. Only ai.openclaw.ldm-backup (3am) works.
234
290
  // This removes: broken cron entry (LDMDevTools.app), old com.wipcomputer.daily-backup.
@@ -738,6 +794,24 @@ function deployDocs() {
738
794
  console.log(` + ${agentDocsCount} personalized doc(s) deployed to ${agentLibraryDest.replace(HOME, '~')}/`);
739
795
  }
740
796
 
797
+ // Migration-window compatibility write (added 2026-04-30).
798
+ // dev-guide-wipcomputerinc.md was migrated from ~/.ldm/shared/ to
799
+ // ~/.ldm/library/documentation/ on 2026-04-19, but agent boot files,
800
+ // ~/.claude/rules/, and other consumers still reference the old shared
801
+ // path. Without this write, the old path serves stale content and
802
+ // agents reading by the old path get pre-migration policy.
803
+ // Forward-migration (grep-update consumers, then remove this compat
804
+ // write) is tracked separately. See bugs/installer/ ticket
805
+ // 2026-04-30--cc-mini--dev-guide-split-path-migration.md.
806
+ const devGuideName = 'dev-guide-wipcomputerinc.md';
807
+ const devGuideNew = join(agentLibraryDest, devGuideName);
808
+ const devGuideOld = join(LDM_ROOT, 'shared', devGuideName);
809
+ if (existsSync(devGuideNew)) {
810
+ mkdirSync(dirname(devGuideOld), { recursive: true });
811
+ cpSync(devGuideNew, devGuideOld);
812
+ console.log(` + Compat write: ${devGuideName} also deployed to ${devGuideOld.replace(HOME, '~')} (migration window)`);
813
+ }
814
+
741
815
  return docsCount + agentDocsCount;
742
816
  }
743
817
 
@@ -1422,23 +1496,6 @@ async function showCatalogPicker() {
1422
1496
  // ── ldm install ──
1423
1497
 
1424
1498
  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
1499
  // --help flag (#81)
1443
1500
  if (args.includes('--help') || args.includes('-h')) {
1444
1501
  console.log(`
@@ -1458,6 +1515,25 @@ async function cmdInstall() {
1458
1515
  process.exit(0);
1459
1516
  }
1460
1517
 
1518
+ maybeSelfUpdateLdmCliBeforeInstall();
1519
+
1520
+ if (!DRY_RUN && !acquireInstallLock()) return;
1521
+
1522
+ // Ensure LDM is initialized
1523
+ if (!existsSync(VERSION_PATH)) {
1524
+ console.log(' LDM OS not initialized. Running init first...');
1525
+ console.log('');
1526
+ cmdInit();
1527
+ }
1528
+
1529
+ const { setFlags, installFromPath, installSingleTool, installToolbox, detectHarnesses } = await import('../lib/deploy.mjs');
1530
+ const { detectInterfacesJSON } = await import('../lib/detect.mjs');
1531
+
1532
+ // Refresh harness detection (catches newly installed harnesses)
1533
+ detectHarnesses();
1534
+
1535
+ setFlags({ dryRun: DRY_RUN, jsonOutput: JSON_OUTPUT, origin: 'manual' });
1536
+
1461
1537
  // Find the target (skip flags)
1462
1538
  const target = args.slice(1).find(a => !a.startsWith('--'));
1463
1539
 
@@ -1903,38 +1979,6 @@ async function cmdInstallCatalog() {
1903
1979
  // No lock here. cmdInstall() already holds it when calling this.
1904
1980
  installLog(`ldm install started (v${PKG_VERSION}, DRY_RUN=${DRY_RUN})`);
1905
1981
 
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
1982
  autoDetectExtensions();
1939
1983
 
1940
1984
  // Migrate old registry entries to v2 format (#262)
@@ -3987,7 +4031,7 @@ async function main() {
3987
4031
  console.log(' Module ... ESM main/exports -> importable');
3988
4032
  console.log(' MCP Server ... mcp-server.mjs -> claude mcp add --scope user');
3989
4033
  console.log(' OpenClaw ... openclaw.plugin.json -> ~/.ldm/extensions/ + ~/.openclaw/extensions/');
3990
- console.log(' Skill ... SKILL.md -> ~/.openclaw/skills/<tool>/');
4034
+ console.log(' Skill ... SKILL.md or skills/<name>/SKILL.md -> agent skill paths');
3991
4035
  console.log(' CC Hook ... guard.mjs or claudeCode.hook -> ~/.claude/settings.json');
3992
4036
  console.log('');
3993
4037
  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();
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.85-alpha.2",
3
+ "version": "0.4.85-alpha.20",
4
4
  "type": "module",
5
5
  "description": "LDM OS: identity, memory, and sovereignty infrastructure for AI agents",
6
6
  "engines": {
@@ -18,14 +18,24 @@
18
18
  "scripts": {
19
19
  "build:bridge": "cd src/bridge && npm install && npx tsup core.ts mcp-server.ts cli.ts openclaw.ts --format esm --dts --clean --outDir ../../dist/bridge",
20
20
  "build": "npm run build:bridge",
21
- "prepublishOnly": "npm run build:bridge && npm run validate:bin-manifest",
21
+ "prepublishOnly": "npm run build:bridge && npm run validate:bin-manifest && npm run test:install-prompt-policy",
22
22
  "validate:bin-manifest": "node scripts/validate-bin-manifest.mjs",
23
23
  "test:skill-frontmatter": "node scripts/test-skill-frontmatter.mjs",
24
+ "test:install-prompt-policy": "node scripts/test-install-prompt-policy.mjs",
24
25
  "test:installer-update-tracks": "node scripts/test-installer-update-tracks.mjs",
25
26
  "test:installer-hook-toolname": "node scripts/test-installer-hook-toolname.mjs",
27
+ "test:installer-target-self-update": "node scripts/test-installer-target-self-update.mjs",
28
+ "test:installer-skill-directory": "node scripts/test-installer-skill-directory.mjs",
29
+ "test:installer-skill-dry-run-destinations": "node scripts/test-installer-skill-dry-run-destinations.mjs",
26
30
  "test:ldm-install-bin-shim": "node scripts/test-ldm-install-preserves-foreign-bin.mjs",
27
31
  "test:doctor-cron-target": "node scripts/test-doctor-cron-target.mjs",
28
32
  "test:bin-manifest": "node scripts/test-bin-manifest.mjs",
33
+ "test:crc-agentid-tenant-boundary": "node scripts/test-crc-agentid-tenant-boundary.mjs",
34
+ "test:crc-pair-login-flow": "node scripts/test-crc-pair-login-flow.mjs",
35
+ "test:crc-pair-status-poll-token": "node scripts/test-crc-pair-status-poll-token.mjs",
36
+ "test:crc-pair-relink-audit-and-rotation": "node scripts/test-crc-pair-relink-audit-and-rotation.mjs",
37
+ "test:crc-e2ee-key-persistence": "node scripts/test-crc-e2ee-key-persistence.mjs",
38
+ "test:crc-e2ee-session-route": "node scripts/test-crc-e2ee-session-route.mjs",
29
39
  "fmt": "npx prettier --write 'src/**/*.{ts,mjs}' 'lib/**/*.mjs' 'bin/**/*.js'",
30
40
  "fmt:check": "npx prettier --check 'src/**/*.{ts,mjs}' 'lib/**/*.mjs' 'bin/**/*.js'"
31
41
  },