dsh-wsl-workspace 0.3.0 → 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.
@@ -1,109 +1,109 @@
1
- import { c as joinUnc, d as parseWslUnc, i as canonicalWindowsPath, o as isValidWslUsername } from "./wsl-C5_mxGPM.js";
2
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
- import { dirname, join } from "node:path";
4
- import { homedir } from "node:os";
5
- //#region src/shared/wsl-credentials.ts
6
- /**
7
- * Per-workspace WSL credentials (host side only). The dialog stores the
8
- * optional Linux username of a WSL workspace under the harness home; the
9
- * per-session env contributor and the WSL shell executor read it back so
10
- * `wsl.exe -u <username>` can run commands as that user. Keys are canonical
11
- * UNC workspace paths. This module touches node builtins, so the browser
12
- * half never imports it.
13
- * @module dsh-wsl-workspace/shared/wsl-credentials
14
- */
15
- /** The store file lives under the harness home so both host halves share it. */
16
- function storePath() {
17
- return join(process.env.DSH_HOME ?? join(homedir(), ".dsh"), "wsl-workspaces.json");
18
- }
19
- /** Read the store; a missing or corrupt file reads as empty (never throws). */
20
- function readStore() {
21
- try {
22
- const parsed = JSON.parse(readFileSync(storePath(), "utf8"));
23
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return {};
24
- return parsed;
25
- } catch {
26
- return {};
27
- }
28
- }
29
- /**
30
- * Canonicalize any accepted WSL UNC spelling into the store's key form.
31
- * @param path - candidate workspace path (either UNC host form).
32
- * @returns the canonical UNC path, or null when the path is not a WSL UNC.
33
- */
34
- function canonicalWslUnc(path) {
35
- const parsed = parseWslUnc(path);
36
- return parsed === null ? null : joinUnc(parsed.distro, parsed.linuxPath);
37
- }
38
- /**
39
- * Read the stored username for a WSL workspace.
40
- * @param uncPath - the workspace path (any accepted WSL UNC spelling).
41
- * @returns the username, or undefined when none is stored.
42
- */
43
- function getWorkspaceUsername(uncPath) {
44
- const key = canonicalWslUnc(uncPath);
45
- if (key === null) return void 0;
46
- const username = readStore()[key]?.username;
47
- return username === void 0 || username === "" ? void 0 : username;
48
- }
49
- /**
50
- * Store (or clear) the username of a WSL workspace.
51
- * @param uncPath - the workspace path (any accepted WSL UNC spelling).
52
- * @param username - the username; empty or undefined clears the stored value.
53
- */
54
- function setWorkspaceUsername(uncPath, username) {
55
- const key = canonicalWslUnc(uncPath);
56
- if (key === null) throw new Error("wsl-workspace: workspace path is not a WSL UNC path");
57
- const store = readStore();
58
- if (username === void 0 || username.trim() === "") delete store[key];
59
- else {
60
- const trimmed = username.trim();
61
- if (!isValidWslUsername(trimmed)) throw new Error("wsl-workspace: username must match the Linux username pattern [A-Za-z_][A-Za-z0-9_.-]*");
62
- store[key] = { username: trimmed };
63
- }
64
- const path = storePath();
65
- mkdirSync(dirname(path), { recursive: true });
66
- writeFileSync(path, JSON.stringify(store, null, 2) + "\n", "utf8");
67
- }
68
- /**
69
- * Register the WSL distribution (and optional Linux username) of a
70
- * Windows-drive workspace (`/mnt/<drive>` path). Keys are canonical Windows
71
- * drive paths; the per-session env contributor reads the entry back so
72
- * `wsl.exe -d <distro>` can run when the session cwd is a drive path.
73
- * @param winPath - the Windows drive path (any spelling).
74
- * @param distro - the WSL distribution the workspace belongs to.
75
- * @param username - optional Linux username (distro default when absent).
76
- */
77
- function registerWindowsWorkspace(winPath, distro, username) {
78
- const key = canonicalWindowsPath(winPath);
79
- if (key === null) throw new Error("wsl-workspace: workspace path is not a Windows drive path");
80
- const entry = { distro };
81
- if (username !== void 0 && username.trim() !== "") {
82
- const trimmed = username.trim();
83
- if (!isValidWslUsername(trimmed)) throw new Error("wsl-workspace: username must match the Linux username pattern [A-Za-z_][A-Za-z0-9_.-]*");
84
- entry.username = trimmed;
85
- }
86
- const store = readStore();
87
- store[key] = entry;
88
- const path = storePath();
89
- mkdirSync(dirname(path), { recursive: true });
90
- writeFileSync(path, JSON.stringify(store, null, 2) + "\n", "utf8");
91
- }
92
- /**
93
- * Read the stored credentials of a Windows-drive workspace.
94
- * @param winPath - the Windows drive path (any spelling).
95
- * @returns the stored entry, or undefined when none is registered.
96
- */
97
- function getWindowsWorkspace(winPath) {
98
- const key = canonicalWindowsPath(winPath);
99
- if (key === null) return void 0;
100
- return readStore()[key];
101
- }
102
- /** Every stored workspace key (canonical UNC and Windows drive paths). */
103
- function listWorkspaceKeys() {
104
- return Object.keys(readStore());
105
- }
106
- //#endregion
107
- export { registerWindowsWorkspace as a, listWorkspaceKeys as i, getWindowsWorkspace as n, setWorkspaceUsername as o, getWorkspaceUsername as r, canonicalWslUnc as t };
108
-
1
+ import { c as joinUnc, d as parseWslUnc, i as canonicalWindowsPath, o as isValidWslUsername } from "./wsl-C5_mxGPM.js";
2
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { homedir } from "node:os";
5
+ //#region src/shared/wsl-credentials.ts
6
+ /**
7
+ * Per-workspace WSL credentials (host side only). The dialog stores the
8
+ * optional Linux username of a WSL workspace under the harness home; the
9
+ * per-session env contributor and the WSL shell executor read it back so
10
+ * `wsl.exe -u <username>` can run commands as that user. Keys are canonical
11
+ * UNC workspace paths. This module touches node builtins, so the browser
12
+ * half never imports it.
13
+ * @module dsh-wsl-workspace/shared/wsl-credentials
14
+ */
15
+ /** The store file lives under the harness home so both host halves share it. */
16
+ function storePath() {
17
+ return join(process.env.DSH_HOME ?? join(homedir(), ".dsh"), "wsl-workspaces.json");
18
+ }
19
+ /** Read the store; a missing or corrupt file reads as empty (never throws). */
20
+ function readStore() {
21
+ try {
22
+ const parsed = JSON.parse(readFileSync(storePath(), "utf8"));
23
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return {};
24
+ return parsed;
25
+ } catch {
26
+ return {};
27
+ }
28
+ }
29
+ /**
30
+ * Canonicalize any accepted WSL UNC spelling into the store's key form.
31
+ * @param path - candidate workspace path (either UNC host form).
32
+ * @returns the canonical UNC path, or null when the path is not a WSL UNC.
33
+ */
34
+ function canonicalWslUnc(path) {
35
+ const parsed = parseWslUnc(path);
36
+ return parsed === null ? null : joinUnc(parsed.distro, parsed.linuxPath);
37
+ }
38
+ /**
39
+ * Read the stored username for a WSL workspace.
40
+ * @param uncPath - the workspace path (any accepted WSL UNC spelling).
41
+ * @returns the username, or undefined when none is stored.
42
+ */
43
+ function getWorkspaceUsername(uncPath) {
44
+ const key = canonicalWslUnc(uncPath);
45
+ if (key === null) return void 0;
46
+ const username = readStore()[key]?.username;
47
+ return username === void 0 || username === "" ? void 0 : username;
48
+ }
49
+ /**
50
+ * Store (or clear) the username of a WSL workspace.
51
+ * @param uncPath - the workspace path (any accepted WSL UNC spelling).
52
+ * @param username - the username; empty or undefined clears the stored value.
53
+ */
54
+ function setWorkspaceUsername(uncPath, username) {
55
+ const key = canonicalWslUnc(uncPath);
56
+ if (key === null) throw new Error("wsl-workspace: workspace path is not a WSL UNC path");
57
+ const store = readStore();
58
+ if (username === void 0 || username.trim() === "") delete store[key];
59
+ else {
60
+ const trimmed = username.trim();
61
+ if (!isValidWslUsername(trimmed)) throw new Error("wsl-workspace: username must match the Linux username pattern [A-Za-z_][A-Za-z0-9_.-]*");
62
+ store[key] = { username: trimmed };
63
+ }
64
+ const path = storePath();
65
+ mkdirSync(dirname(path), { recursive: true });
66
+ writeFileSync(path, JSON.stringify(store, null, 2) + "\n", "utf8");
67
+ }
68
+ /**
69
+ * Register the WSL distribution (and optional Linux username) of a
70
+ * Windows-drive workspace (`/mnt/<drive>` path). Keys are canonical Windows
71
+ * drive paths; the per-session env contributor reads the entry back so
72
+ * `wsl.exe -d <distro>` can run when the session cwd is a drive path.
73
+ * @param winPath - the Windows drive path (any spelling).
74
+ * @param distro - the WSL distribution the workspace belongs to.
75
+ * @param username - optional Linux username (distro default when absent).
76
+ */
77
+ function registerWindowsWorkspace(winPath, distro, username) {
78
+ const key = canonicalWindowsPath(winPath);
79
+ if (key === null) throw new Error("wsl-workspace: workspace path is not a Windows drive path");
80
+ const entry = { distro };
81
+ if (username !== void 0 && username.trim() !== "") {
82
+ const trimmed = username.trim();
83
+ if (!isValidWslUsername(trimmed)) throw new Error("wsl-workspace: username must match the Linux username pattern [A-Za-z_][A-Za-z0-9_.-]*");
84
+ entry.username = trimmed;
85
+ }
86
+ const store = readStore();
87
+ store[key] = entry;
88
+ const path = storePath();
89
+ mkdirSync(dirname(path), { recursive: true });
90
+ writeFileSync(path, JSON.stringify(store, null, 2) + "\n", "utf8");
91
+ }
92
+ /**
93
+ * Read the stored credentials of a Windows-drive workspace.
94
+ * @param winPath - the Windows drive path (any spelling).
95
+ * @returns the stored entry, or undefined when none is registered.
96
+ */
97
+ function getWindowsWorkspace(winPath) {
98
+ const key = canonicalWindowsPath(winPath);
99
+ if (key === null) return void 0;
100
+ return readStore()[key];
101
+ }
102
+ /** Every stored workspace key (canonical UNC and Windows drive paths). */
103
+ function listWorkspaceKeys() {
104
+ return Object.keys(readStore());
105
+ }
106
+ //#endregion
107
+ export { registerWindowsWorkspace as a, listWorkspaceKeys as i, getWindowsWorkspace as n, setWorkspaceUsername as o, getWorkspaceUsername as r, canonicalWslUnc as t };
108
+
109
109
  //# sourceMappingURL=wsl-credentials-BI4v5TNZ.js.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-wsl-workspace",
3
3
  "description": "WSL workspace support for DeepSeek Harness: add a WSL workspace from the web GUI and run the whole agent session (bash + file tools) inside the WSL distribution, VS Code Remote-WSL style. No toolchain install inside WSL required.",
4
- "version": "0.3.0",
4
+ "version": "0.3.2",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "type": "git",
@@ -56,6 +56,7 @@
56
56
  "src",
57
57
  "LICENSE",
58
58
  "NOTICE",
59
+ "TESTING.md",
59
60
  "README.md",
60
61
  "README.zh.md",
61
62
  "README.ja.md",
@@ -0,0 +1,430 @@
1
+ /**
2
+ * WSL workspace skill provider (host half).
3
+ *
4
+ * DSH's shipped skill-filesystem provider scans only the session cwd's
5
+ * project root (the nearest `.git` ancestor) for `.dsh/skills` / `.agents/skills`
6
+ * and never descends into nested projects. A WSL workspace whose project
7
+ * folders live below the registered workspace root therefore shows an empty
8
+ * skill catalog, even though the same layout works when the session cwd is
9
+ * the project folder itself (issue #10).
10
+ *
11
+ * This provider mirrors the host's discovery rules for WSL UNC session
12
+ * workspaces: it starts at the session cwd's nearest `.git` ancestor (the
13
+ * host's project-root rule; the cwd itself when no ancestor has a `.git`
14
+ * marker), then walks that root (depth- and budget-bounded), collects every
15
+ * `.dsh/skills` and `.agents/skills` directory it finds — including nested
16
+ * projects — and publishes their skills with the same
17
+ * project ranks and sources the host uses, so precedence and duplicate
18
+ * resolution behave identically. Non-WSL lookups return nothing and leave
19
+ * the host's own providers untouched.
20
+ *
21
+ * All filesystem reads go through `node:fs` against the `\\wsl.localhost\…`
22
+ * 9P share (the same substrate `WslFileSystem` uses); an injectable IO face
23
+ * keeps the discovery logic unit-testable without a live distro.
24
+ *
25
+ * @module dsh-wsl-workspace/host/wsl-skills
26
+ */
27
+
28
+ import { readdir, readFile, stat } from 'node:fs/promises'
29
+ import type { Dirent } from 'node:fs'
30
+ import { join as joinWindowsPath, posix } from 'node:path'
31
+ import { joinUnc, parseWslUnc } from '../shared/paths.ts'
32
+
33
+ /** Project ranks copied from @deepseek-ai/dsh-skill-filesystem so WSL and host entries interleave identically. */
34
+ const PROJECT_DSH_RANK = 100
35
+ const PROJECT_AGENTS_RANK = 200
36
+
37
+ /** How many directory levels below the workspace root are scanned. */
38
+ const MAX_SCAN_DEPTH = 4
39
+ /** Maximum distinct skill directories published per lookup. */
40
+ const MAX_SKILL_ROOTS = 64
41
+ /** Maximum directories visited per lookup (an absolute blast-radius cap). */
42
+ const MAX_VISITED_DIRECTORIES = 4096
43
+ /** How many parent levels above the session cwd are searched for a `.git` project marker. */
44
+ const MAX_ANCESTOR_WALK = 64
45
+
46
+ /** Kebab-case skill names, matching the host grammar. */
47
+ const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
48
+
49
+ /** Directory names that never contain project skill roots (safe to prune while walking). */
50
+ const PRUNED_DIRECTORY_NAMES = new Set([
51
+ '.git', '.hg', '.svn', '.bzr', 'node_modules', '.venv', 'venv', '.tox',
52
+ '.pants.d', '.next', '.nuxt', 'dist', 'build', 'out', 'coverage',
53
+ '__pycache__', '.mypy_cache', '.pytest_cache', '.ruff_cache', '.cache',
54
+ '.idea', '.vscode', '.serverless', '.terraform', '.yarn', '.pnpm-store',
55
+ ])
56
+
57
+ /** One `name: value` frontmatter line pair the parser understands. */
58
+ interface ParsedSkill {
59
+ name: string
60
+ description: string
61
+ whenToUse?: string
62
+ invocation: { modelInvocable: boolean; userInvocable: boolean }
63
+ content: string
64
+ }
65
+
66
+ /** The provider's minimal skill-candidate contract (mirrors @deepseek-ai/dsh-skill). */
67
+ export interface WslSkillCandidate {
68
+ readonly name: string
69
+ readonly description: string
70
+ readonly whenToUse?: string
71
+ readonly invocation: { modelInvocable: boolean; userInvocable: boolean }
72
+ readonly source: string
73
+ readonly provider: string
74
+ readonly rank: number
75
+ readonly locator: { path: string; directory: string }
76
+ readonly path: string
77
+ }
78
+
79
+ /** The provider's minimal skill-definition contract (candidate plus body). */
80
+ export interface WslSkillDefinition extends WslSkillCandidate {
81
+ readonly content: string
82
+ }
83
+
84
+ /** Lookup options the registry passes to `list`/`get`. */
85
+ export interface WslSkillLookupOptions {
86
+ readonly cwd?: string
87
+ readonly signal?: AbortSignal
88
+ }
89
+
90
+ /** Registration-scoped lifecycle face passed to the provider constructor. */
91
+ export interface WslSkillProviderControl {
92
+ readonly signal: AbortSignal
93
+ readonly invalidate: () => void
94
+ }
95
+
96
+ /** The `ctx.skills` registry face this provider registers on (optional service). */
97
+ export interface WslSkillsRegistryFace {
98
+ registerProvider(create: (control: WslSkillProviderControl) => {
99
+ readonly name: string
100
+ list(options: WslSkillLookupOptions): Promise<unknown>
101
+ get(candidate: WslSkillCandidate, options: WslSkillLookupOptions): Promise<unknown>
102
+ }): () => void
103
+ }
104
+
105
+ /** Injectable filesystem face (defaults to node:fs/promises on the real 9P share). */
106
+ export interface WslSkillIo {
107
+ readdir(path: string, options: { withFileTypes: true }): Promise<Dirent[]>
108
+ readFile(path: string, options: { encoding: 'utf8' }): Promise<string>
109
+ stat(path: string): Promise<{ isDirectory(): boolean }>
110
+ }
111
+
112
+ /** The node:fs/promises implementation the provider uses in production. */
113
+ export const nodeSkillIo: WslSkillIo = {
114
+ readdir: async (path, options) => readdir(path, options),
115
+ readFile: async (path, options) => readFile(path, options),
116
+ stat: async path => stat(path),
117
+ }
118
+
119
+ /** One discovered skill directory under a WSL workspace. */
120
+ interface SkillRoot {
121
+ /** Absolute UNC path of the skills directory (`…\.dsh\skills`). */
122
+ path: string
123
+ /** Host source label ('project-dsh' | 'project-agents'). */
124
+ source: 'project-dsh' | 'project-agents'
125
+ /** Host project rank so same-name wins and precedence stay consistent. */
126
+ rank: number
127
+ }
128
+
129
+ /** Whether a skills-directory entry is a directory-bundle or a flat markdown skill. */
130
+ interface SkillEntry {
131
+ name: string
132
+ kind: 'bundle' | 'flat'
133
+ path: string
134
+ }
135
+
136
+ /**
137
+ * Locate the nearest ancestor of `linuxDir` (the directory itself included)
138
+ * containing a `.git` marker, mirroring the host skill-filesystem's
139
+ * project-root rule. `.git` may be a directory or a worktree pointer file;
140
+ * existence is enough. Bounded so a pathological path cannot spin the walk.
141
+ * @param distro - the WSL distribution name.
142
+ * @param linuxDir - the session cwd's absolute Linux path.
143
+ * @param io - filesystem face.
144
+ * @returns the project root's Linux path, or `undefined` when no ancestor carries a `.git`.
145
+ */
146
+ async function nearestGitAncestor(distro: string, linuxDir: string, io: WslSkillIo): Promise<string | undefined> {
147
+ let current = linuxDir
148
+ for (let levels = 0; levels <= MAX_ANCESTOR_WALK; levels += 1) {
149
+ try {
150
+ await io.stat(joinUnc(distro, posix.join(current, '.git')))
151
+ return current
152
+ } catch {
153
+ // No `.git` marker at this level; keep walking towards the filesystem root.
154
+ }
155
+ const parent = posix.dirname(current)
156
+ if (parent === current) return undefined
157
+ current = parent
158
+ }
159
+ return undefined
160
+ }
161
+
162
+ /**
163
+ * Scan a WSL workspace root for nested skill directories.
164
+ * @param distro - the WSL distribution name.
165
+ * @param linuxRoot - the workspace's absolute Linux path.
166
+ * @param io - filesystem face.
167
+ * @returns discovered skill directories, bounded by depth and budget.
168
+ */
169
+ async function discoverSkillRoots(distro: string, linuxRoot: string, io: WslSkillIo): Promise<SkillRoot[]> {
170
+ const roots: SkillRoot[] = []
171
+ const visited = new Set<string>()
172
+ // BFS layers so the budget prunes the widest, most redundant levels first
173
+ // (shallow skill dirs matter most): [path, depth] pairs.
174
+ let frontier: [string, number][] = [[linuxRoot, 0]]
175
+ while (frontier.length > 0 && roots.length < MAX_SKILL_ROOTS) {
176
+ const next: [string, number][] = []
177
+ for (const [dir, depth] of frontier) {
178
+ if (visited.size >= MAX_VISITED_DIRECTORIES) return roots
179
+ if (visited.has(dir)) continue
180
+ visited.add(dir)
181
+ if (roots.length < MAX_SKILL_ROOTS) {
182
+ const directoryRoots = await skillRootsOfDirectory(distro, dir, io)
183
+ roots.push(...directoryRoots.slice(0, MAX_SKILL_ROOTS - roots.length))
184
+ }
185
+ if (depth >= MAX_SCAN_DEPTH) continue
186
+ let entries: Dirent[]
187
+ try {
188
+ entries = await io.readdir(joinUnc(distro, dir), { withFileTypes: true })
189
+ } catch {
190
+ // An unreadable directory (permissions, vanished mid-walk) prunes its subtree.
191
+ continue
192
+ }
193
+ for (const entry of entries) {
194
+ if (!entry.isDirectory()) continue
195
+ if (PRUNED_DIRECTORY_NAMES.has(entry.name)) continue
196
+ if (entry.name.startsWith('.') && entry.name !== '.dsh' && entry.name !== '.agents') continue
197
+ if (entry.name === '.dsh' || entry.name === '.agents') continue
198
+ next.push([posix.join(dir, entry.name), depth + 1])
199
+ }
200
+ }
201
+ frontier = next
202
+ }
203
+ return roots
204
+ }
205
+
206
+ /**
207
+ * Publish the skill roots of one scanned directory (its `.dsh/skills` and
208
+ * `.agents/skills`, each with the host's project ranks).
209
+ * @param distro - the WSL distribution name.
210
+ * @param linuxDir - the scanned directory's Linux path.
211
+ * @param io - filesystem face.
212
+ * @returns the directory's skill roots that exist.
213
+ */
214
+ async function skillRootsOfDirectory(distro: string, linuxDir: string, io: WslSkillIo): Promise<SkillRoot[]> {
215
+ const result: SkillRoot[] = []
216
+ for (const [marker, source, rank] of [
217
+ ['.dsh', 'project-dsh', PROJECT_DSH_RANK],
218
+ ['.agents', 'project-agents', PROJECT_AGENTS_RANK],
219
+ ] as const) {
220
+ const path = joinUnc(distro, posix.join(linuxDir, marker, 'skills'))
221
+ try {
222
+ const info = await io.stat(path)
223
+ if (info.isDirectory()) result.push({ path, source, rank })
224
+ } catch {
225
+ // Absent skills directory: nothing to publish.
226
+ }
227
+ }
228
+ return result
229
+ }
230
+
231
+ /** List one skills directory's entries (directory bundles and flat `.md` skills). */
232
+ async function listSkillEntries(root: SkillRoot, io: WslSkillIo): Promise<SkillEntry[]> {
233
+ let dirents: Dirent[]
234
+ try {
235
+ dirents = await io.readdir(root.path, { withFileTypes: true })
236
+ } catch {
237
+ return []
238
+ }
239
+ const entries: SkillEntry[] = []
240
+ for (const entry of dirents) {
241
+ if (entry.isDirectory()) {
242
+ entries.push({ name: entry.name, kind: 'bundle', path: joinWindowsPath(root.path, entry.name, 'SKILL.md') })
243
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
244
+ entries.push({ name: entry.name.slice(0, -3), kind: 'flat', path: joinWindowsPath(root.path, entry.name) })
245
+ }
246
+ }
247
+ return entries.sort((a, b) => a.name.localeCompare(b.name))
248
+ }
249
+
250
+ /** Read and parse one skill file; `undefined` when missing or unparsable. */
251
+ async function readSkill(path: string, io: WslSkillIo, signal?: AbortSignal): Promise<ParsedSkill | undefined> {
252
+ signal?.throwIfAborted()
253
+ let raw: string
254
+ try {
255
+ raw = await io.readFile(path, { encoding: 'utf8' })
256
+ } catch {
257
+ return undefined
258
+ }
259
+ signal?.throwIfAborted()
260
+ return parseSkillFrontmatter(raw, path)
261
+ }
262
+
263
+ /**
264
+ * Parse the frontmatter subset skill files use: `---` fenced YAML with
265
+ * `name` / `description` / `whenToUse` / `user-invocable` /
266
+ * `disable-model-invocation`. Host-incompatible files are skipped, matching
267
+ * the shipped provider's leniency: a bad file must not fail the catalog.
268
+ */
269
+ function parseSkillFrontmatter(raw: string, path: string): ParsedSkill | undefined {
270
+ const firstLineEnd = raw.indexOf('\n')
271
+ if (firstLineEnd < 0) return undefined
272
+ if (raw.slice(0, firstLineEnd).replace(/\r$/, '') !== '---') return undefined
273
+ const start = firstLineEnd + 1
274
+ const closing = findFrontmatterEnd(raw, start)
275
+ if (closing === undefined) return undefined
276
+ const fields = new Map<string, string>()
277
+ for (const line of raw.slice(start, closing).split('\n')) {
278
+ const match = /^([A-Za-z0-9-]+):\s*(.*)$/.exec(line.replace(/\r$/, ''))
279
+ if (match === null) continue
280
+ const value = match[2]?.trim() ?? ''
281
+ if (value !== '') fields.set(match[1] ?? '', unquote(value))
282
+ }
283
+ const name = fields.get('name') ?? ''
284
+ const description = fields.get('description') ?? ''
285
+ if (!SKILL_NAME.test(name) || description === '') {
286
+ return undefined
287
+ }
288
+ const whenToUse = fields.get('whenToUse')
289
+ return {
290
+ name,
291
+ description,
292
+ ...whenToUse !== undefined && whenToUse !== '' ? { whenToUse } : {},
293
+ invocation: {
294
+ modelInvocable: !frontmatterBoolean(fields, 'disable-model-invocation'),
295
+ userInvocable: frontmatterBoolean(fields, 'user-invocable', true),
296
+ },
297
+ content: raw.slice(closing + 1).trim(),
298
+ }
299
+ }
300
+
301
+ /** Locate the closing `---` line of a frontmatter block. */
302
+ function findFrontmatterEnd(raw: string, start: number): number | undefined {
303
+ let lineStart = start
304
+ while (lineStart <= raw.length) {
305
+ const nextNewline = raw.indexOf('\n', lineStart)
306
+ const lineEnd = nextNewline < 0 ? raw.length : nextNewline
307
+ if (raw.slice(lineStart, lineEnd).replace(/\r$/, '') === '---') return lineEnd + 1
308
+ if (nextNewline < 0) return undefined
309
+ lineStart = nextNewline + 1
310
+ }
311
+ return undefined
312
+ }
313
+
314
+ /** Strip one level of matching quotes from a scalar value. */
315
+ function unquote(value: string): string {
316
+ if (value.length >= 2) {
317
+ const first = value[0]
318
+ const last = value[value.length - 1]
319
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
320
+ return value.slice(1, -1)
321
+ }
322
+ }
323
+ return value
324
+ }
325
+
326
+ /** Boolean semantics for `user-invocable` / `disable-model-invocation` (matches the host parser). */
327
+ function frontmatterBoolean(fields: Map<string, string>, key: string, dflt = false): boolean {
328
+ const value = fields.get(key)
329
+ if (value === undefined) return dflt
330
+ switch (value.toLowerCase()) {
331
+ case 'true':
332
+ case 'yes':
333
+ case 'on':
334
+ case '1':
335
+ return true
336
+ case 'false':
337
+ case 'no':
338
+ case 'off':
339
+ case '0':
340
+ return false
341
+ default:
342
+ return dflt
343
+ }
344
+ }
345
+
346
+ /**
347
+ * The WSL workspace skill provider. Registered on the host's `ctx.skills`
348
+ * registry; serves only lookups whose cwd is a WSL UNC workspace path.
349
+ */
350
+ export class WslSkillsProvider {
351
+ readonly name = 'wsl-workspace'
352
+ private readonly control: WslSkillProviderControl
353
+ private readonly io: WslSkillIo
354
+
355
+ constructor(control: WslSkillProviderControl, io: WslSkillIo = nodeSkillIo) {
356
+ this.control = control
357
+ this.io = io
358
+ }
359
+
360
+ /**
361
+ * Discover nested project skills for a WSL UNC session workspace.
362
+ * @param options - lookup options; `cwd` selects the WSL workspace.
363
+ * @returns candidates for every `.dsh/skills` / `.agents/skills` under the
364
+ * session's scan root — the nearest `.git` ancestor of the cwd, else the
365
+ * cwd itself — or an empty array for non-WSL lookups.
366
+ */
367
+ async list(options: WslSkillLookupOptions): Promise<WslSkillCandidate[]> {
368
+ this.control.signal.throwIfAborted()
369
+ options.signal?.throwIfAborted()
370
+ const unc = options.cwd === undefined ? null : parseWslUnc(options.cwd)
371
+ if (unc === null) return []
372
+ // Host parity: the session's project root is the nearest `.git` ancestor
373
+ // of the cwd, so lookups from inside a project subtree still see that
374
+ // project's skills; nested projects below it join via the bounded BFS.
375
+ // Without a `.git` ancestor the session cwd itself is the scan root (the
376
+ // issue #10 workspace layout).
377
+ const scanRoot = (await nearestGitAncestor(unc.distro, unc.linuxPath, this.io)) ?? unc.linuxPath
378
+ const roots = await discoverSkillRoots(unc.distro, scanRoot, this.io)
379
+ const candidates: WslSkillCandidate[] = []
380
+ for (const root of roots) {
381
+ const entries = await listSkillEntries(root, this.io)
382
+ for (const entry of entries) {
383
+ options.signal?.throwIfAborted()
384
+ const parsed = await readSkill(entry.path, this.io, options.signal)
385
+ if (parsed === undefined) continue
386
+ candidates.push({
387
+ name: parsed.name,
388
+ description: parsed.description,
389
+ ...parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {},
390
+ invocation: parsed.invocation,
391
+ source: root.source,
392
+ provider: this.name,
393
+ rank: root.rank,
394
+ locator: {
395
+ path: entry.path,
396
+ directory: entry.kind === 'bundle'
397
+ ? joinWindowsPath(entry.path, '..')
398
+ : root.path,
399
+ },
400
+ path: entry.path,
401
+ })
402
+ }
403
+ }
404
+ return candidates
405
+ }
406
+
407
+ /**
408
+ * Load a complete skill body for a previously listed candidate.
409
+ * @param candidate - the candidate this provider returned.
410
+ * @param options - lookup options whose signal cancels the read.
411
+ * @returns the full skill, or `undefined` if the file disappeared.
412
+ */
413
+ async get(candidate: WslSkillCandidate, options: WslSkillLookupOptions): Promise<WslSkillDefinition | undefined> {
414
+ this.control.signal.throwIfAborted()
415
+ const parsed = await readSkill(candidate.locator.path, this.io, options.signal)
416
+ if (parsed === undefined || parsed.name !== candidate.name) return undefined
417
+ return {
418
+ name: parsed.name,
419
+ description: parsed.description,
420
+ ...parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {},
421
+ invocation: parsed.invocation,
422
+ source: candidate.source,
423
+ provider: candidate.provider,
424
+ rank: candidate.rank,
425
+ locator: candidate.locator,
426
+ path: candidate.path,
427
+ content: parsed.content,
428
+ }
429
+ }
430
+ }