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.
- package/README.de.md +3 -0
- package/README.es.md +3 -0
- package/README.fr.md +3 -0
- package/README.ja.md +3 -0
- package/README.ko.md +3 -0
- package/README.md +12 -0
- package/README.pt.md +3 -0
- package/README.ru.md +3 -0
- package/README.zh.md +3 -0
- package/TESTING.md +103 -0
- package/lib/fs.js +1 -1
- package/lib/index.js +351 -1
- package/lib/index.js.map +1 -1
- package/lib/shell.js +382 -382
- package/lib/wsl-C5_mxGPM.js +227 -227
- package/lib/wsl-credentials-BI4v5TNZ.js +108 -108
- package/package.json +2 -1
- package/src/host/wsl-skills.ts +430 -0
- package/src/index.ts +15 -0
- package/lib/paths-BDE1NVOv.js +0 -120
- package/lib/paths-BDE1NVOv.js.map +0 -1
- package/lib/wsl-DdNPGaPo.js +0 -1019
- package/lib/wsl-DdNPGaPo.js.map +0 -1
|
@@ -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.
|
|
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
|
+
}
|