dsh-wsl-workspace 0.3.1 → 0.3.2

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/README.de.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # dsh-wsl-workspace
2
2
 
3
+ [![dsh.so security](https://www.dsh.so/badge/dsh-wsl-workspace.svg)](https://www.dsh.so/artifact/dsh-wsl-workspace)
4
+ [![dsh.so install](https://www.dsh.so/badge/install/dsh-wsl-workspace.svg)](https://www.dsh.so/artifact/dsh-wsl-workspace)
5
+
3
6
  [English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md) · [한국어](README.ko.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [Español](README.es.md) · [Português](README.pt.md) · [Русский](README.ru.md)
4
7
 
5
8
  ![alt text](image-3.png)
package/README.es.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # dsh-wsl-workspace
2
2
 
3
+ [![dsh.so security](https://www.dsh.so/badge/dsh-wsl-workspace.svg)](https://www.dsh.so/artifact/dsh-wsl-workspace)
4
+ [![dsh.so install](https://www.dsh.so/badge/install/dsh-wsl-workspace.svg)](https://www.dsh.so/artifact/dsh-wsl-workspace)
5
+
3
6
  [English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md) · [한국어](README.ko.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [Español](README.es.md) · [Português](README.pt.md) · [Русский](README.ru.md)
4
7
 
5
8
  ![alt text](image-3.png)
package/README.fr.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # dsh-wsl-workspace
2
2
 
3
+ [![dsh.so security](https://www.dsh.so/badge/dsh-wsl-workspace.svg)](https://www.dsh.so/artifact/dsh-wsl-workspace)
4
+ [![dsh.so install](https://www.dsh.so/badge/install/dsh-wsl-workspace.svg)](https://www.dsh.so/artifact/dsh-wsl-workspace)
5
+
3
6
  [English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md) · [한국어](README.ko.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [Español](README.es.md) · [Português](README.pt.md) · [Русский](README.ru.md)
4
7
 
5
8
  ![alt text](image-3.png)
package/README.ja.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # dsh-wsl-workspace
2
2
 
3
+ [![dsh.so security](https://www.dsh.so/badge/dsh-wsl-workspace.svg)](https://www.dsh.so/artifact/dsh-wsl-workspace)
4
+ [![dsh.so install](https://www.dsh.so/badge/install/dsh-wsl-workspace.svg)](https://www.dsh.so/artifact/dsh-wsl-workspace)
5
+
3
6
  [English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md) · [한국어](README.ko.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [Español](README.es.md) · [Português](README.pt.md) · [Русский](README.ru.md)
4
7
 
5
8
  ![alt text](image-3.png)
package/README.ko.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # dsh-wsl-workspace
2
2
 
3
+ [![dsh.so security](https://www.dsh.so/badge/dsh-wsl-workspace.svg)](https://www.dsh.so/artifact/dsh-wsl-workspace)
4
+ [![dsh.so install](https://www.dsh.so/badge/install/dsh-wsl-workspace.svg)](https://www.dsh.so/artifact/dsh-wsl-workspace)
5
+
3
6
  [English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md) · [한국어](README.ko.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [Español](README.es.md) · [Português](README.pt.md) · [Русский](README.ru.md)
4
7
 
5
8
  ![alt text](image-3.png)
package/README.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # dsh-wsl-workspace
2
2
 
3
+ [![dsh.so security](https://www.dsh.so/badge/dsh-wsl-workspace.svg)](https://www.dsh.so/artifact/dsh-wsl-workspace)
4
+ [![dsh.so install](https://www.dsh.so/badge/install/dsh-wsl-workspace.svg)](https://www.dsh.so/artifact/dsh-wsl-workspace)
5
+
3
6
  [English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md) · [한국어](README.ko.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [Español](README.es.md) · [Português](README.pt.md) · [Русский](README.ru.md)
4
7
  ![alt text](image-3.png)
5
8
  Add a WSL workspace from the DeepSeek Harness web GUI and run the whole agent session — bash commands and file reads/writes — inside a local WSL distribution with Linux paths. Nothing needs to be installed inside WSL. The session can reach both WSL and Windows at the same time: bash commands run inside the WSL distribution, while Windows files stay accessible via `/mnt/<drive>` (for example `/mnt/c/Users/...`).
@@ -33,6 +36,15 @@ Click "Create & open" to start a new session in the workspace. In the new sessio
33
36
  - **File tools (`read`/`write`/`edit`)**: go through the Windows-side WSL 9P share and run under the DSH file policy. Under `workspace-write`, reads work anywhere but writes are restricted to the session workspace; switch the file policy to `danger-full-access` to also allow writes outside it. The username field does not affect the file tools.
34
37
  - The garbled `localhost` port-forwarding banner `wsl.exe` prints to stderr when the distro was not running yet is harmless.
35
38
 
39
+ ## Changelog
40
+
41
+ ### 0.3.2 — 2026-08-29
42
+
43
+ - **WSL workspace sessions now inject nested-project skill catalogs** ([#10](https://github.com/6Mikao9/dsh-wsl-workspace/issues/10)): `.dsh/skills` and `.agents/skills` directories of projects nested below the registered workspace root are discovered and published with the host's project ranks and sources, so the model sees the same skill catalog it would see when the session cwd is the project folder itself. Discovery is depth- and budget-bounded, prunes `node_modules`/dot-directories, and leaves non-WSL sessions untouched.
44
+ - **Host-parity scan root**: lookups from inside a project subtree resolve the nearest `.git` ancestor first, so the enclosing project's skills stay visible from deeper cwds; skills above that ancestor do not leak.
45
+ - **Hardening**: the skill-root budget is enforced per push, and the `skills.registerProvider` call is guarded so a host whose `skills` service has a different shape can no longer break plugin load.
46
+ - **Housekeeping**: removed stale prebuilt `lib/` chunks that shipped dead vendor code (including an inlined schemastery copy that triggered dsh.so's `new Function` static rule); added `scripts/repro-setup.sh` plus a nested skill-catalog regression suite, and a matching TESTING.md section.
47
+
36
48
  ## License & attribution
37
49
 
38
50
  MIT — see [LICENSE](LICENSE) and [NOTICE](NOTICE). The NOTICE precisely lists:
package/README.pt.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # dsh-wsl-workspace
2
2
 
3
+ [![dsh.so security](https://www.dsh.so/badge/dsh-wsl-workspace.svg)](https://www.dsh.so/artifact/dsh-wsl-workspace)
4
+ [![dsh.so install](https://www.dsh.so/badge/install/dsh-wsl-workspace.svg)](https://www.dsh.so/artifact/dsh-wsl-workspace)
5
+
3
6
  [English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md) · [한국어](README.ko.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [Español](README.es.md) · [Português](README.pt.md) · [Русский](README.ru.md)
4
7
 
5
8
  ![alt text](image-3.png)
package/README.ru.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # dsh-wsl-workspace
2
2
 
3
+ [![dsh.so security](https://www.dsh.so/badge/dsh-wsl-workspace.svg)](https://www.dsh.so/artifact/dsh-wsl-workspace)
4
+ [![dsh.so install](https://www.dsh.so/badge/install/dsh-wsl-workspace.svg)](https://www.dsh.so/artifact/dsh-wsl-workspace)
5
+
3
6
  [English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md) · [한국어](README.ko.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [Español](README.es.md) · [Português](README.pt.md) · [Русский](README.ru.md)
4
7
 
5
8
  ![alt text](image-3.png)
package/README.zh.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # dsh-wsl-workspace
2
2
 
3
+ [![dsh.so security](https://www.dsh.so/badge/dsh-wsl-workspace.svg)](https://www.dsh.so/artifact/dsh-wsl-workspace)
4
+ [![dsh.so install](https://www.dsh.so/badge/install/dsh-wsl-workspace.svg)](https://www.dsh.so/artifact/dsh-wsl-workspace)
5
+
3
6
  [English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md) · [한국어](README.ko.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [Español](README.es.md) · [Português](README.pt.md) · [Русский](README.ru.md)
4
7
  ![alt text](image-3.png)
5
8
  在 DeepSeek Harness Web GUI 中「添加 WSL 工作区」:让 agent 会话的 bash 命令与文件读写都运行在本机 WSL 发行版里,路径均为 Linux 形式,WSL 内无需安装任何工具链。会话可同时访问 WSL 与 Windows 两个系统——bash 命令在 WSL 发行版内执行,Windows 文件随时可通过 `/mnt/<drive>`(如 `/mnt/c/Users/...`)访问。
package/TESTING.md CHANGED
@@ -13,7 +13,7 @@ This document describes how to verify `dsh-wsl-workspace` after a change or befo
13
13
  Run the unit tests from the plugin directory:
14
14
 
15
15
  ```powershell
16
- node --experimental-strip-types --test tests/variants.test.ts tests/fs-execution-context.test.ts tests/shell.test.ts tests/paths.test.ts
16
+ node --experimental-strip-types --test tests/variants.test.ts tests/fs-execution-context.test.ts tests/shell.test.ts tests/paths.test.ts tests/wsl-skills.test.ts
17
17
  ```
18
18
 
19
19
  Coverage:
@@ -24,6 +24,7 @@ Coverage:
24
24
  | `tests/fs-execution-context.test.ts` | `WslFileSystem` inherits the calling session's cwd through `AsyncLocalStorage` on `tools/execute`; agentless calls fall back to the configured distro. |
25
25
  | `tests/shell.test.ts` | The login-shell `cd` prefix preserves the resolved workdir (including single-quote escaping); non-login shells leave the command unchanged. |
26
26
  | `tests/paths.test.ts` | UNC ↔ Linux path translation, `/mnt/<drive>` mapping, canonical Windows path keys, WSL username validation. |
27
+ | `tests/wsl-skills.test.ts` | The WSL skill provider (issue #10): non-WSL lookups return nothing, nested `.dsh/skills` / `.agents/skills` discovery with host ranks/sources, `get()` body loading, pruning of `node_modules` / dot-directories, frontmatter validation, depth and skill-root budgets, the nearest-`.git`-ancestor rule (a cwd deeper than the project root still sees the project's skills, and skills above that ancestor do not leak), and the skill-root cap. |
27
28
 
28
29
  ## Preset materialization integration test
29
30
 
@@ -59,6 +60,27 @@ The `build` script chains it after `tsdown`:
59
60
  pnpm build # tsdown && node scripts/verify-lib.mjs
60
61
  ```
61
62
 
63
+ ## Nested skill-catalog regression (issue #10)
64
+
65
+ The WSL skill provider publishes `.dsh/skills` / `.agents/skills` from nested projects below a WSL workspace (and from the cwd's nearest `.git` ancestor). Regression-test it on the real 9P share:
66
+
67
+ 1. Rebuild the repro tree inside the distribution (`scripts/repro-setup.sh` creates `~/repro-ws-root` with nested projects, pruned traps, and an over-budget deep skill):
68
+
69
+ ```powershell
70
+ cp scripts/repro-setup.sh //wsl.localhost/<distro>/tmp/
71
+ wsl -d <distro> -- bash -c "bash /tmp/repro-setup.sh"
72
+ ```
73
+
74
+ 2. Drive the provider against the real `\\wsl.localhost` share — four assertions print (workspace-root cwd finds root + nested skills; nested-project cwd finds only that project; `get()` loads a body; non-WSL cwd returns nothing):
75
+
76
+ ```powershell
77
+ node scripts/repro-e2e.mjs
78
+ ```
79
+
80
+ The script hardcodes `\\wsl.localhost\Ubuntu\home\mille\repro-ws-root`; adjust the two paths at the top when running as another user or distro.
81
+ 3. In the running harness, open a session on the repro workspace and ask the agent to load the nested skills (`brainstorming`, `systematic-debugging`, `writing-plans`) through its skill tool — each must load with the `wsl-workspace` provider attribution, and no duplicate entries may appear. In a non-WSL workspace session the same skills must be "unknown".
82
+ 4. Clean-install check (simulates another user): `npm pack`, `npm install <tarball>` in an empty temp project (peers must resolve), then `dsh plugin --profile web add <extracted tarball dir>`, restart `dsh web`, and repeat the end-to-end checks below plus the nested-skill probe above.
83
+
62
84
  ## End-to-end verification in the running harness
63
85
 
64
86
  After installing the plugin into a profile and restarting `dsh web`:
@@ -73,8 +95,9 @@ After installing the plugin into a profile and restarting `dsh web`:
73
95
  ## Release checklist
74
96
 
75
97
  1. `pnpm build` — rebuilds `lib/` and runs the verification gate.
76
- 2. `node --experimental-strip-types --test tests/variants.test.ts tests/fs-execution-context.test.ts tests/shell.test.ts tests/paths.test.ts` — all green.
98
+ 2. `node --experimental-strip-types --test tests/variants.test.ts tests/fs-execution-context.test.ts tests/shell.test.ts tests/paths.test.ts tests/wsl-skills.test.ts` — all green.
77
99
  3. `node tests/host-materialize.mjs` — all assertions pass.
78
100
  4. `node --experimental-strip-types tests/smoke.ts` — real-WSL round-trip passes.
79
- 5. `npm pack --dry-run` — confirm the tarball carries only live `lib/` chunks, `src/`, `cordis.patch.yml`, READMEs, `LICENSE`, and `NOTICE`.
80
- 6. Install the tarball into a clean profile (`dsh plugin --profile web add <tarball>`), restart `dsh web`, and run the end-to-end checks above.
101
+ 5. `node scripts/repro-e2e.mjs` (after `scripts/repro-setup.sh`) — nested skill-catalog assertions pass.
102
+ 6. `npm pack --dry-run` — confirm the tarball carries only live `lib/` chunks, `src/`, `cordis.patch.yml`, READMEs, `LICENSE`, and `NOTICE` (tsdown uses `clean: false`, so remove stale chunks from `lib/` before packing).
103
+ 7. Install the tarball into a clean profile (`dsh plugin --profile web add <tarball>`), restart `dsh web`, and run the end-to-end checks above plus the nested-skill probe.
package/lib/fs.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { a as isAbsoluteLinuxPath, c as joinUnc, d as parseWslUnc, f as windowsToMntPath, l as mntToWindowsPath, n as defaultDistroSync } from "./wsl-C5_mxGPM.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
- import { AsyncLocalStorage } from "node:async_hooks";
4
3
  import { link, lstat, rename } from "node:fs/promises";
4
+ import { AsyncLocalStorage } from "node:async_hooks";
5
5
  import { FsError } from "@deepseek-ai/dsh-fs";
6
6
  import { LocalFileSystem } from "@deepseek-ai/dsh-fs-local";
7
7
  //#region src/fs.ts
package/lib/index.js CHANGED
@@ -3,8 +3,9 @@ import { a as registerWindowsWorkspace, i as listWorkspaceKeys, n as getWindowsW
3
3
  import z from "@deepseek-ai/schemastery";
4
4
  import { cpSync, existsSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
5
5
  import { fileURLToPath } from "node:url";
6
- import { dirname, join } from "node:path";
6
+ import { dirname, join, posix } from "node:path";
7
7
  import { homedir } from "node:os";
8
+ import { readFile, readdir, stat } from "node:fs/promises";
8
9
  //#region src/host/variants.ts
9
10
  /**
10
11
  * WSL preset-variant generator. For every healthy source preset the roster
@@ -169,6 +170,353 @@ function variantIdFor(sourceId) {
169
170
  return `wsl-${sourceId.toLowerCase()}`;
170
171
  }
171
172
  //#endregion
173
+ //#region src/host/wsl-skills.ts
174
+ /**
175
+ * WSL workspace skill provider (host half).
176
+ *
177
+ * DSH's shipped skill-filesystem provider scans only the session cwd's
178
+ * project root (the nearest `.git` ancestor) for `.dsh/skills` / `.agents/skills`
179
+ * and never descends into nested projects. A WSL workspace whose project
180
+ * folders live below the registered workspace root therefore shows an empty
181
+ * skill catalog, even though the same layout works when the session cwd is
182
+ * the project folder itself (issue #10).
183
+ *
184
+ * This provider mirrors the host's discovery rules for WSL UNC session
185
+ * workspaces: it starts at the session cwd's nearest `.git` ancestor (the
186
+ * host's project-root rule; the cwd itself when no ancestor has a `.git`
187
+ * marker), then walks that root (depth- and budget-bounded), collects every
188
+ * `.dsh/skills` and `.agents/skills` directory it finds — including nested
189
+ * projects — and publishes their skills with the same
190
+ * project ranks and sources the host uses, so precedence and duplicate
191
+ * resolution behave identically. Non-WSL lookups return nothing and leave
192
+ * the host's own providers untouched.
193
+ *
194
+ * All filesystem reads go through `node:fs` against the `\\wsl.localhost\…`
195
+ * 9P share (the same substrate `WslFileSystem` uses); an injectable IO face
196
+ * keeps the discovery logic unit-testable without a live distro.
197
+ *
198
+ * @module dsh-wsl-workspace/host/wsl-skills
199
+ */
200
+ /** Project ranks copied from @deepseek-ai/dsh-skill-filesystem so WSL and host entries interleave identically. */
201
+ const PROJECT_DSH_RANK = 100;
202
+ const PROJECT_AGENTS_RANK = 200;
203
+ /** How many directory levels below the workspace root are scanned. */
204
+ const MAX_SCAN_DEPTH = 4;
205
+ /** Maximum distinct skill directories published per lookup. */
206
+ const MAX_SKILL_ROOTS = 64;
207
+ /** Maximum directories visited per lookup (an absolute blast-radius cap). */
208
+ const MAX_VISITED_DIRECTORIES = 4096;
209
+ /** How many parent levels above the session cwd are searched for a `.git` project marker. */
210
+ const MAX_ANCESTOR_WALK = 64;
211
+ /** Kebab-case skill names, matching the host grammar. */
212
+ const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
213
+ /** Directory names that never contain project skill roots (safe to prune while walking). */
214
+ const PRUNED_DIRECTORY_NAMES = new Set([
215
+ ".git",
216
+ ".hg",
217
+ ".svn",
218
+ ".bzr",
219
+ "node_modules",
220
+ ".venv",
221
+ "venv",
222
+ ".tox",
223
+ ".pants.d",
224
+ ".next",
225
+ ".nuxt",
226
+ "dist",
227
+ "build",
228
+ "out",
229
+ "coverage",
230
+ "__pycache__",
231
+ ".mypy_cache",
232
+ ".pytest_cache",
233
+ ".ruff_cache",
234
+ ".cache",
235
+ ".idea",
236
+ ".vscode",
237
+ ".serverless",
238
+ ".terraform",
239
+ ".yarn",
240
+ ".pnpm-store"
241
+ ]);
242
+ /** The node:fs/promises implementation the provider uses in production. */
243
+ const nodeSkillIo = {
244
+ readdir: async (path, options) => readdir(path, options),
245
+ readFile: async (path, options) => readFile(path, options),
246
+ stat: async (path) => stat(path)
247
+ };
248
+ /**
249
+ * Locate the nearest ancestor of `linuxDir` (the directory itself included)
250
+ * containing a `.git` marker, mirroring the host skill-filesystem's
251
+ * project-root rule. `.git` may be a directory or a worktree pointer file;
252
+ * existence is enough. Bounded so a pathological path cannot spin the walk.
253
+ * @param distro - the WSL distribution name.
254
+ * @param linuxDir - the session cwd's absolute Linux path.
255
+ * @param io - filesystem face.
256
+ * @returns the project root's Linux path, or `undefined` when no ancestor carries a `.git`.
257
+ */
258
+ async function nearestGitAncestor(distro, linuxDir, io) {
259
+ let current = linuxDir;
260
+ for (let levels = 0; levels <= MAX_ANCESTOR_WALK; levels += 1) {
261
+ try {
262
+ await io.stat(joinUnc(distro, posix.join(current, ".git")));
263
+ return current;
264
+ } catch {}
265
+ const parent = posix.dirname(current);
266
+ if (parent === current) return void 0;
267
+ current = parent;
268
+ }
269
+ }
270
+ /**
271
+ * Scan a WSL workspace root for nested skill directories.
272
+ * @param distro - the WSL distribution name.
273
+ * @param linuxRoot - the workspace's absolute Linux path.
274
+ * @param io - filesystem face.
275
+ * @returns discovered skill directories, bounded by depth and budget.
276
+ */
277
+ async function discoverSkillRoots(distro, linuxRoot, io) {
278
+ const roots = [];
279
+ const visited = /* @__PURE__ */ new Set();
280
+ let frontier = [[linuxRoot, 0]];
281
+ while (frontier.length > 0 && roots.length < MAX_SKILL_ROOTS) {
282
+ const next = [];
283
+ for (const [dir, depth] of frontier) {
284
+ if (visited.size >= MAX_VISITED_DIRECTORIES) return roots;
285
+ if (visited.has(dir)) continue;
286
+ visited.add(dir);
287
+ if (roots.length < MAX_SKILL_ROOTS) {
288
+ const directoryRoots = await skillRootsOfDirectory(distro, dir, io);
289
+ roots.push(...directoryRoots.slice(0, MAX_SKILL_ROOTS - roots.length));
290
+ }
291
+ if (depth >= MAX_SCAN_DEPTH) continue;
292
+ let entries;
293
+ try {
294
+ entries = await io.readdir(joinUnc(distro, dir), { withFileTypes: true });
295
+ } catch {
296
+ continue;
297
+ }
298
+ for (const entry of entries) {
299
+ if (!entry.isDirectory()) continue;
300
+ if (PRUNED_DIRECTORY_NAMES.has(entry.name)) continue;
301
+ if (entry.name.startsWith(".") && entry.name !== ".dsh" && entry.name !== ".agents") continue;
302
+ if (entry.name === ".dsh" || entry.name === ".agents") continue;
303
+ next.push([posix.join(dir, entry.name), depth + 1]);
304
+ }
305
+ }
306
+ frontier = next;
307
+ }
308
+ return roots;
309
+ }
310
+ /**
311
+ * Publish the skill roots of one scanned directory (its `.dsh/skills` and
312
+ * `.agents/skills`, each with the host's project ranks).
313
+ * @param distro - the WSL distribution name.
314
+ * @param linuxDir - the scanned directory's Linux path.
315
+ * @param io - filesystem face.
316
+ * @returns the directory's skill roots that exist.
317
+ */
318
+ async function skillRootsOfDirectory(distro, linuxDir, io) {
319
+ const result = [];
320
+ for (const [marker, source, rank] of [[
321
+ ".dsh",
322
+ "project-dsh",
323
+ PROJECT_DSH_RANK
324
+ ], [
325
+ ".agents",
326
+ "project-agents",
327
+ PROJECT_AGENTS_RANK
328
+ ]]) {
329
+ const path = joinUnc(distro, posix.join(linuxDir, marker, "skills"));
330
+ try {
331
+ if ((await io.stat(path)).isDirectory()) result.push({
332
+ path,
333
+ source,
334
+ rank
335
+ });
336
+ } catch {}
337
+ }
338
+ return result;
339
+ }
340
+ /** List one skills directory's entries (directory bundles and flat `.md` skills). */
341
+ async function listSkillEntries(root, io) {
342
+ let dirents;
343
+ try {
344
+ dirents = await io.readdir(root.path, { withFileTypes: true });
345
+ } catch {
346
+ return [];
347
+ }
348
+ const entries = [];
349
+ for (const entry of dirents) if (entry.isDirectory()) entries.push({
350
+ name: entry.name,
351
+ kind: "bundle",
352
+ path: join(root.path, entry.name, "SKILL.md")
353
+ });
354
+ else if (entry.isFile() && entry.name.endsWith(".md")) entries.push({
355
+ name: entry.name.slice(0, -3),
356
+ kind: "flat",
357
+ path: join(root.path, entry.name)
358
+ });
359
+ return entries.sort((a, b) => a.name.localeCompare(b.name));
360
+ }
361
+ /** Read and parse one skill file; `undefined` when missing or unparsable. */
362
+ async function readSkill(path, io, signal) {
363
+ signal?.throwIfAborted();
364
+ let raw;
365
+ try {
366
+ raw = await io.readFile(path, { encoding: "utf8" });
367
+ } catch {
368
+ return;
369
+ }
370
+ signal?.throwIfAborted();
371
+ return parseSkillFrontmatter(raw, path);
372
+ }
373
+ /**
374
+ * Parse the frontmatter subset skill files use: `---` fenced YAML with
375
+ * `name` / `description` / `whenToUse` / `user-invocable` /
376
+ * `disable-model-invocation`. Host-incompatible files are skipped, matching
377
+ * the shipped provider's leniency: a bad file must not fail the catalog.
378
+ */
379
+ function parseSkillFrontmatter(raw, path) {
380
+ const firstLineEnd = raw.indexOf("\n");
381
+ if (firstLineEnd < 0) return void 0;
382
+ if (raw.slice(0, firstLineEnd).replace(/\r$/, "") !== "---") return void 0;
383
+ const start = firstLineEnd + 1;
384
+ const closing = findFrontmatterEnd(raw, start);
385
+ if (closing === void 0) return void 0;
386
+ const fields = /* @__PURE__ */ new Map();
387
+ for (const line of raw.slice(start, closing).split("\n")) {
388
+ const match = /^([A-Za-z0-9-]+):\s*(.*)$/.exec(line.replace(/\r$/, ""));
389
+ if (match === null) continue;
390
+ const value = match[2]?.trim() ?? "";
391
+ if (value !== "") fields.set(match[1] ?? "", unquote(value));
392
+ }
393
+ const name = fields.get("name") ?? "";
394
+ const description = fields.get("description") ?? "";
395
+ if (!SKILL_NAME.test(name) || description === "") return;
396
+ const whenToUse = fields.get("whenToUse");
397
+ return {
398
+ name,
399
+ description,
400
+ ...whenToUse !== void 0 && whenToUse !== "" ? { whenToUse } : {},
401
+ invocation: {
402
+ modelInvocable: !frontmatterBoolean(fields, "disable-model-invocation"),
403
+ userInvocable: frontmatterBoolean(fields, "user-invocable", true)
404
+ },
405
+ content: raw.slice(closing + 1).trim()
406
+ };
407
+ }
408
+ /** Locate the closing `---` line of a frontmatter block. */
409
+ function findFrontmatterEnd(raw, start) {
410
+ let lineStart = start;
411
+ while (lineStart <= raw.length) {
412
+ const nextNewline = raw.indexOf("\n", lineStart);
413
+ const lineEnd = nextNewline < 0 ? raw.length : nextNewline;
414
+ if (raw.slice(lineStart, lineEnd).replace(/\r$/, "") === "---") return lineEnd + 1;
415
+ if (nextNewline < 0) return void 0;
416
+ lineStart = nextNewline + 1;
417
+ }
418
+ }
419
+ /** Strip one level of matching quotes from a scalar value. */
420
+ function unquote(value) {
421
+ if (value.length >= 2) {
422
+ const first = value[0];
423
+ const last = value[value.length - 1];
424
+ if (first === "\"" && last === "\"" || first === "'" && last === "'") return value.slice(1, -1);
425
+ }
426
+ return value;
427
+ }
428
+ /** Boolean semantics for `user-invocable` / `disable-model-invocation` (matches the host parser). */
429
+ function frontmatterBoolean(fields, key, dflt = false) {
430
+ const value = fields.get(key);
431
+ if (value === void 0) return dflt;
432
+ switch (value.toLowerCase()) {
433
+ case "true":
434
+ case "yes":
435
+ case "on":
436
+ case "1": return true;
437
+ case "false":
438
+ case "no":
439
+ case "off":
440
+ case "0": return false;
441
+ default: return dflt;
442
+ }
443
+ }
444
+ /**
445
+ * The WSL workspace skill provider. Registered on the host's `ctx.skills`
446
+ * registry; serves only lookups whose cwd is a WSL UNC workspace path.
447
+ */
448
+ var WslSkillsProvider = class {
449
+ name = "wsl-workspace";
450
+ control;
451
+ io;
452
+ constructor(control, io = nodeSkillIo) {
453
+ this.control = control;
454
+ this.io = io;
455
+ }
456
+ /**
457
+ * Discover nested project skills for a WSL UNC session workspace.
458
+ * @param options - lookup options; `cwd` selects the WSL workspace.
459
+ * @returns candidates for every `.dsh/skills` / `.agents/skills` under the
460
+ * session's scan root — the nearest `.git` ancestor of the cwd, else the
461
+ * cwd itself — or an empty array for non-WSL lookups.
462
+ */
463
+ async list(options) {
464
+ this.control.signal.throwIfAborted();
465
+ options.signal?.throwIfAborted();
466
+ const unc = options.cwd === void 0 ? null : parseWslUnc(options.cwd);
467
+ if (unc === null) return [];
468
+ const scanRoot = await nearestGitAncestor(unc.distro, unc.linuxPath, this.io) ?? unc.linuxPath;
469
+ const roots = await discoverSkillRoots(unc.distro, scanRoot, this.io);
470
+ const candidates = [];
471
+ for (const root of roots) {
472
+ const entries = await listSkillEntries(root, this.io);
473
+ for (const entry of entries) {
474
+ options.signal?.throwIfAborted();
475
+ const parsed = await readSkill(entry.path, this.io, options.signal);
476
+ if (parsed === void 0) continue;
477
+ candidates.push({
478
+ name: parsed.name,
479
+ description: parsed.description,
480
+ ...parsed.whenToUse !== void 0 ? { whenToUse: parsed.whenToUse } : {},
481
+ invocation: parsed.invocation,
482
+ source: root.source,
483
+ provider: this.name,
484
+ rank: root.rank,
485
+ locator: {
486
+ path: entry.path,
487
+ directory: entry.kind === "bundle" ? join(entry.path, "..") : root.path
488
+ },
489
+ path: entry.path
490
+ });
491
+ }
492
+ }
493
+ return candidates;
494
+ }
495
+ /**
496
+ * Load a complete skill body for a previously listed candidate.
497
+ * @param candidate - the candidate this provider returned.
498
+ * @param options - lookup options whose signal cancels the read.
499
+ * @returns the full skill, or `undefined` if the file disappeared.
500
+ */
501
+ async get(candidate, options) {
502
+ this.control.signal.throwIfAborted();
503
+ const parsed = await readSkill(candidate.locator.path, this.io, options.signal);
504
+ if (parsed === void 0 || parsed.name !== candidate.name) return void 0;
505
+ return {
506
+ name: parsed.name,
507
+ description: parsed.description,
508
+ ...parsed.whenToUse !== void 0 ? { whenToUse: parsed.whenToUse } : {},
509
+ invocation: parsed.invocation,
510
+ source: candidate.source,
511
+ provider: candidate.provider,
512
+ rank: candidate.rank,
513
+ locator: candidate.locator,
514
+ path: candidate.path,
515
+ content: parsed.content
516
+ };
517
+ }
518
+ };
519
+ //#endregion
172
520
  //#region src/index.ts
173
521
  /** The HTTP route this plugin serves (a relative, same-origin path). */
174
522
  const DEFAULT_ROUTE = "/wsl-workspace/api";
@@ -489,6 +837,8 @@ function apply(ctx, config) {
489
837
  });
490
838
  return () => {};
491
839
  }, "dsh-wsl-workspace: WSL preset variants");
840
+ const skills = ctx.get("skills");
841
+ if (skills !== void 0 && typeof skills.registerProvider === "function") ctx.effect(() => skills.registerProvider((control) => new WslSkillsProvider(control)), "dsh-wsl-workspace: WSL workspace skills provider");
492
842
  const shellEnv = ctx.get("shellEnv");
493
843
  if (shellEnv !== void 0) ctx.effect(() => shellEnv.register({
494
844
  name: "wsl-workspace-distro",