dsh-wsl-tool 1.8.0
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/LICENSE +21 -0
- package/README.md +318 -0
- package/README.zh-CN.md +304 -0
- package/assets/screenshot-1.png +0 -0
- package/cordis.patch.yml +15 -0
- package/index.js +39 -0
- package/lib/config.js +130 -0
- package/lib/diagnostics.js +150 -0
- package/lib/guard.js +107 -0
- package/lib/paths.js +85 -0
- package/lib/result.js +104 -0
- package/lib/runner.js +273 -0
- package/lib/tools/wsl-env.js +113 -0
- package/lib/tools/wsl-path.js +77 -0
- package/lib/tools/wsl.js +297 -0
- package/package.json +50 -0
- package/screenshots.json +3 -0
package/lib/runner.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// The single spawn path. Every call in this plugin goes through it, so the
|
|
2
|
+
// stdio shape, grace period, deadline classification and truncation facts are
|
|
3
|
+
// identical for the model-facing tool and the plugin's own probes.
|
|
4
|
+
//
|
|
5
|
+
// Launching is split from settling on purpose: a background job must hand the
|
|
6
|
+
// jobs registry a synchronous `cancel`/`done` pair, while a foreground call just
|
|
7
|
+
// awaits the same settle step. `launch()` is that shared synchronous half.
|
|
8
|
+
|
|
9
|
+
import { FORWARDED_ENV_KEYS } from './config.js'
|
|
10
|
+
import { buildCdCommand, shellQuote, windowsPathToWsl } from './paths.js'
|
|
11
|
+
import { cleanStderr, normalizeExitCode, streamFacts } from './result.js'
|
|
12
|
+
|
|
13
|
+
// Only allow distribution names that cannot inject shell syntax into argv.
|
|
14
|
+
const DISTRO_RE = /^[A-Za-z0-9._-]+$/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolve the distribution to use for one call.
|
|
18
|
+
*
|
|
19
|
+
* The caller's argument wins, then `DSH_WSL_DISTRO`, then the system default —
|
|
20
|
+
* signalled by `null`, which drops `-d` entirely. That last case is what makes
|
|
21
|
+
* the package portable: a hardcoded `Ubuntu-22.04` is a distro the author has
|
|
22
|
+
* and a storefront user may not.
|
|
23
|
+
*
|
|
24
|
+
* @returns a distro name, or null to let wsl.exe pick its default.
|
|
25
|
+
*/
|
|
26
|
+
export function resolveDistro(arg, config) {
|
|
27
|
+
const distro = arg ?? config.distro
|
|
28
|
+
if (distro === undefined || distro === null) return null
|
|
29
|
+
if (typeof distro !== 'string' || !DISTRO_RE.test(distro) || distro.trim() === '') {
|
|
30
|
+
throw new Error('wsl: distro must be a simple name (letters, digits, dot, dash, underscore)')
|
|
31
|
+
}
|
|
32
|
+
return distro
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The calling session's workspace, taken from the execution the host hands us.
|
|
37
|
+
*
|
|
38
|
+
* This is `dsh-tool-fs`'s own source (`exec.agent.session.header.cwd`), and the
|
|
39
|
+
* distinction matters: `process.cwd()` is the directory the DSH *host* was
|
|
40
|
+
* launched from, which for a web deployment is the installation directory rather
|
|
41
|
+
* than the session's project. Anything keyed off the launch directory points a
|
|
42
|
+
* session at the wrong tree.
|
|
43
|
+
*
|
|
44
|
+
* @returns the session workspace, or undefined for a caller that has none.
|
|
45
|
+
*/
|
|
46
|
+
export function sessionCwdOf(exec) {
|
|
47
|
+
const cwd = exec?.agent?.session?.header?.cwd
|
|
48
|
+
return typeof cwd === 'string' && cwd !== '' ? cwd : undefined
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Per-execution `DSH_*` facts, resolved the way the platform's shell tools
|
|
53
|
+
* resolve them.
|
|
54
|
+
*
|
|
55
|
+
* `process.env` is the WRONG source: these values are per-session, and the
|
|
56
|
+
* `ctx.shellEnv` registry builds them for each execution — a host-level read
|
|
57
|
+
* finds nothing and forwards nothing, silently. `process.env` remains only as a
|
|
58
|
+
* fallback for a deployment where the registry is absent.
|
|
59
|
+
*/
|
|
60
|
+
export function collectForwardEnv(ctx, exec) {
|
|
61
|
+
let facts
|
|
62
|
+
try {
|
|
63
|
+
facts = ctx?.get?.('shellEnv')?.collect?.(exec)
|
|
64
|
+
} catch {
|
|
65
|
+
facts = undefined
|
|
66
|
+
}
|
|
67
|
+
const source = facts !== null && typeof facts === 'object' ? facts : process.env
|
|
68
|
+
|
|
69
|
+
const forwarded = {}
|
|
70
|
+
for (const key of FORWARDED_ENV_KEYS) {
|
|
71
|
+
const value = source[key]
|
|
72
|
+
if (typeof value === 'string' && value !== '') forwarded[key] = value
|
|
73
|
+
}
|
|
74
|
+
if (forwarded.DSH_HOME !== undefined) forwarded.DSH_HOME = windowsPathToWsl(forwarded.DSH_HOME)
|
|
75
|
+
return forwarded
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// A launcher-level failure means bash never ran, so surface it as a tool error
|
|
79
|
+
// instead of handing the model a meaningless exit code. The Wsl/Service/WSL_E_*
|
|
80
|
+
// code is locale-independent, unlike the message around it.
|
|
81
|
+
const LAUNCHER_ERROR_RE = /Wsl\/Service\/(WSL_E_[A-Z0-9_]+)/
|
|
82
|
+
|
|
83
|
+
export function assertLauncherReachable(distro, result) {
|
|
84
|
+
if (result.exitCode === 0) return
|
|
85
|
+
const match = LAUNCHER_ERROR_RE.exec(`${result.stderr}\n${result.stdout}`)
|
|
86
|
+
if (match === null) return
|
|
87
|
+
|
|
88
|
+
const detail = result.stderr.trim().split(/\r?\n/)[0]
|
|
89
|
+
if (match[1] === 'WSL_E_DISTRO_NOT_FOUND') {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`wsl: distribution ${distro === null ? '(system default)' : `"${distro}"`} is not registered. ` +
|
|
92
|
+
'Run the wsl-env tool (or `wsl -l -v`) to list the available distributions.',
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
throw new Error(
|
|
96
|
+
`wsl: the WSL launcher failed (${match[1]})${detail === '' ? '' : `: ${detail}`}. ` +
|
|
97
|
+
'Check that WSL and its service are healthy (`wsl --status`).',
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Build the runner bound to one mount's context and configuration.
|
|
103
|
+
* @param ctx - plugin context providing the host `subprocess` service.
|
|
104
|
+
* @param config - resolved configuration (see `resolveConfig`).
|
|
105
|
+
*/
|
|
106
|
+
export function createRunner(ctx, config) {
|
|
107
|
+
const { maxOutputBytes, maxSpillBytes, graceMs, maxTimerDelayMs, maxCommandChars, maxCommandTimeoutMs } = config
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Spawn immediately and return the handle plus a `settle()` that awaits it and
|
|
111
|
+
* builds the model-facing value. Synchronous, so background jobs can attach
|
|
112
|
+
* their hooks without waiting a tick.
|
|
113
|
+
*/
|
|
114
|
+
function launch(argv, timeoutMs, stdinData) {
|
|
115
|
+
const controller = new AbortController()
|
|
116
|
+
const state = { timedOut: false, cancelled: false }
|
|
117
|
+
// A per-call deadline is capped like the platform shell tools cap theirs,
|
|
118
|
+
// and by the same value the default obeys, so the `timeoutMs` reported back
|
|
119
|
+
// is always the deadline that was actually armed.
|
|
120
|
+
const effectiveTimeoutMs = typeof timeoutMs === 'number' && timeoutMs > 0
|
|
121
|
+
? Math.min(timeoutMs, maxCommandTimeoutMs, maxTimerDelayMs)
|
|
122
|
+
: null
|
|
123
|
+
|
|
124
|
+
let handle
|
|
125
|
+
try {
|
|
126
|
+
handle = ctx.subprocess.spawn({
|
|
127
|
+
argv,
|
|
128
|
+
// Required by the dsh 0.1.5 subprocess seam: every spawn spec states its
|
|
129
|
+
// own working directory. The Linux-side directory is set by the `cd`
|
|
130
|
+
// prefix the caller builds, so the Windows-side cwd only has to be a
|
|
131
|
+
// real directory (it also becomes the initial WSL cwd).
|
|
132
|
+
cwd: process.cwd(),
|
|
133
|
+
env: { WSL_UTF8: '1' },
|
|
134
|
+
stdio: {
|
|
135
|
+
stdin: typeof stdinData === 'string' ? { data: stdinData } : 'ignore',
|
|
136
|
+
stdout: { maxBytes: maxOutputBytes, spill: { maxBytes: maxSpillBytes } },
|
|
137
|
+
stderr: { maxBytes: maxOutputBytes, spill: { maxBytes: maxSpillBytes } },
|
|
138
|
+
},
|
|
139
|
+
graceMs,
|
|
140
|
+
signal: controller.signal,
|
|
141
|
+
})
|
|
142
|
+
} catch (error) {
|
|
143
|
+
throw new Error(`wsl: could not launch ${argv[0]}: ${error?.message ?? String(error)}`)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Armed AFTER the child exists: the deadline measures the child's lifetime,
|
|
147
|
+
// and an already-aborted signal can never reach the provider (which throws
|
|
148
|
+
// "aborted before spawn" instead of starting anything).
|
|
149
|
+
let timer = null
|
|
150
|
+
if (effectiveTimeoutMs !== null) {
|
|
151
|
+
timer = setTimeout(() => {
|
|
152
|
+
state.timedOut = true
|
|
153
|
+
controller.abort()
|
|
154
|
+
}, effectiveTimeoutMs)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function settle() {
|
|
158
|
+
try {
|
|
159
|
+
let outcome
|
|
160
|
+
try {
|
|
161
|
+
outcome = await handle.done
|
|
162
|
+
} catch (error) {
|
|
163
|
+
throw new Error(`wsl: ${argv[0]} failed to run: ${error?.message ?? String(error)}`)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const stdout = streamFacts(handle.collected?.stdout?.readFrom(0))
|
|
167
|
+
const stderr = streamFacts(handle.collected?.stderr?.readFrom(0))
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
exitCode: normalizeExitCode(outcome.exitCode ?? null),
|
|
171
|
+
signal: outcome.signal ?? null,
|
|
172
|
+
timedOut: state.timedOut,
|
|
173
|
+
timeoutMs: effectiveTimeoutMs,
|
|
174
|
+
stdout: stdout.text,
|
|
175
|
+
stderr: cleanStderr(stderr.text),
|
|
176
|
+
truncated: stdout.lossy || stderr.lossy,
|
|
177
|
+
stdoutTotalBytes: stdout.totalBytes,
|
|
178
|
+
stdoutDroppedBytes: stdout.droppedBytes,
|
|
179
|
+
stderrTotalBytes: stderr.totalBytes,
|
|
180
|
+
stderrDroppedBytes: stderr.droppedBytes,
|
|
181
|
+
stdoutSpillPath: stdout.spillPath,
|
|
182
|
+
stderrSpillPath: stderr.spillPath,
|
|
183
|
+
// Present for every call so the declared output schema holds: only a
|
|
184
|
+
// background START carries an id.
|
|
185
|
+
jobId: null,
|
|
186
|
+
}
|
|
187
|
+
} finally {
|
|
188
|
+
if (timer !== null) clearTimeout(timer)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
handle,
|
|
194
|
+
state,
|
|
195
|
+
settle,
|
|
196
|
+
effectiveTimeoutMs,
|
|
197
|
+
/** Idempotent, like the seam's own `terminate()`. */
|
|
198
|
+
cancel() {
|
|
199
|
+
state.cancelled = true
|
|
200
|
+
handle.terminate()
|
|
201
|
+
},
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function spawnWsl(argv, timeoutMs, stdinData) {
|
|
206
|
+
return await launch(argv, timeoutMs, stdinData).settle()
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Build the argv (and stdin form) for one `wsl` command. */
|
|
210
|
+
function planCommand(command, opts = {}) {
|
|
211
|
+
const distro = resolveDistro(opts.distro, config)
|
|
212
|
+
const requested = opts.workdir !== undefined && opts.workdir !== '' ? opts.workdir : null
|
|
213
|
+
// "session" mode means the CALLING SESSION's workspace, not the directory the
|
|
214
|
+
// host process happened to be launched from.
|
|
215
|
+
const sessionCwd = sessionCwdOf(opts.exec)
|
|
216
|
+
const workdir = requested !== null
|
|
217
|
+
? windowsPathToWsl(requested)
|
|
218
|
+
: config.defaultWorkdir === null
|
|
219
|
+
? windowsPathToWsl(sessionCwd ?? process.cwd())
|
|
220
|
+
: config.defaultWorkdir
|
|
221
|
+
|
|
222
|
+
let full = `${buildCdCommand(workdir)} && ${command}`
|
|
223
|
+
// Forwarded host facts first, then the caller's own entries: an explicit
|
|
224
|
+
// `env` value always wins over an inherited one.
|
|
225
|
+
const env = { ...collectForwardEnv(ctx, opts.exec), ...(opts.env && typeof opts.env === 'object' ? opts.env : {}) }
|
|
226
|
+
const exports = Object.entries(env)
|
|
227
|
+
.map(([k, v]) => (v === undefined ? `unset ${k}` : `export ${k}=${shellQuote(v)}`))
|
|
228
|
+
if (exports.length > 0) full = exports.join('; ') + '; ' + full
|
|
229
|
+
|
|
230
|
+
// The script travels as an argv string, except past the Windows
|
|
231
|
+
// command-line limit where it is fed to `bash -ls` on stdin instead. That
|
|
232
|
+
// fallback owns stdin, so it cannot coexist with caller-supplied stdin.
|
|
233
|
+
const viaStdin = full.length > maxCommandChars
|
|
234
|
+
if (viaStdin && typeof opts.stdin === 'string') {
|
|
235
|
+
throw new Error(
|
|
236
|
+
`wsl: this command is over the ${maxCommandChars}-character command-line limit, so the script itself must travel on stdin — ` +
|
|
237
|
+
'it cannot also carry `stdin` data. Write the script to a file and run that, or split the command.',
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const distroArgs = distro === null ? [] : ['-d', distro]
|
|
242
|
+
return {
|
|
243
|
+
distro,
|
|
244
|
+
argv: viaStdin
|
|
245
|
+
? ['wsl.exe', ...distroArgs, '-e', 'bash', '-ls']
|
|
246
|
+
: ['wsl.exe', ...distroArgs, '-e', 'bash', '-lc', full],
|
|
247
|
+
stdinData: viaStdin ? full : opts.stdin,
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Run a Linux command to completion. */
|
|
252
|
+
async function runWsl(command, opts = {}) {
|
|
253
|
+
const plan = planCommand(command, opts)
|
|
254
|
+
const result = await spawnWsl(plan.argv, opts.timeoutMs, plan.stdinData)
|
|
255
|
+
assertLauncherReachable(plan.distro, result)
|
|
256
|
+
return result
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Launch a Linux command without awaiting it (background jobs). */
|
|
260
|
+
function startWsl(command, opts = {}) {
|
|
261
|
+
const plan = planCommand(command, opts)
|
|
262
|
+
return { plan, launched: launch(plan.argv, opts.timeoutMs, plan.stdinData) }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
spawnWsl,
|
|
267
|
+
runWsl,
|
|
268
|
+
startWsl,
|
|
269
|
+
planCommand,
|
|
270
|
+
/** Bound to this mount's config, so callers never re-resolve env vars. */
|
|
271
|
+
resolveDistro: (arg) => resolveDistro(arg, config),
|
|
272
|
+
}
|
|
273
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// The `wsl-env` tool: summarize the WSL environment so an agent knows what it
|
|
2
|
+
// is running on before it starts issuing commands.
|
|
3
|
+
|
|
4
|
+
import { CAPABILITY_PROBE, capabilityLines, launcherSummary, parseFacts, workspaceLine } from '../diagnostics.js'
|
|
5
|
+
import { sessionCwdOf } from '../runner.js'
|
|
6
|
+
|
|
7
|
+
/** The `*`-marked row of `wsl -l -v` is the default distribution. */
|
|
8
|
+
export function parseDefaultDistro(listOutput) {
|
|
9
|
+
for (const line of String(listOutput).split(/\r?\n/)) {
|
|
10
|
+
const match = /^\s*\*\s*(\S+)/.exec(line)
|
|
11
|
+
if (match !== null) return match[1]
|
|
12
|
+
}
|
|
13
|
+
return null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createWslEnvTool({ config, runner }) {
|
|
17
|
+
return {
|
|
18
|
+
name: 'wsl-env',
|
|
19
|
+
description:
|
|
20
|
+
'Summarize the WSL environment: registered distributions, kernel and architecture, CPU count, ' +
|
|
21
|
+
'memory and disk usage, and what the machine can actually do — WSL1 or WSL2, systemd, cgroup ' +
|
|
22
|
+
'version, GPU passthrough, docker, mounted drives, and the /etc/wsl.conf + .wslconfig settings. ' +
|
|
23
|
+
'Use it to decide what is available before running commands.',
|
|
24
|
+
parameters: {
|
|
25
|
+
type: 'object',
|
|
26
|
+
properties: {
|
|
27
|
+
distro: {
|
|
28
|
+
type: 'string',
|
|
29
|
+
description: 'WSL distribution to probe. Defaults to the system default distribution.',
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
output: {
|
|
34
|
+
schema: {
|
|
35
|
+
type: 'object',
|
|
36
|
+
additionalProperties: false,
|
|
37
|
+
properties: {
|
|
38
|
+
summary: { type: 'string' },
|
|
39
|
+
},
|
|
40
|
+
required: ['summary'],
|
|
41
|
+
},
|
|
42
|
+
render: (_args, value) => [{ type: 'text', text: value.summary }],
|
|
43
|
+
},
|
|
44
|
+
async execute(args, exec) {
|
|
45
|
+
const resolved = runner.resolveDistro(args.distro)
|
|
46
|
+
const label = resolved === null ? 'the default WSL distribution' : `distro "${resolved}"`
|
|
47
|
+
const opts = { distro: args.distro, timeoutMs: config.internalTimeoutMs, exec }
|
|
48
|
+
|
|
49
|
+
// The probes are independent, so run them concurrently: each wsl.exe
|
|
50
|
+
// launch costs a WSL round trip, and sequential ones would dominate this
|
|
51
|
+
// tool's latency. Measured: 5 parallel spawns cost the same as one.
|
|
52
|
+
const [uname, caps, launcher, mem, disk, list] = await Promise.all([
|
|
53
|
+
runner.runWsl('uname -srm && echo "nproc: $(nproc)"', opts),
|
|
54
|
+
// Every capability fact that needs no separate launch travels in one
|
|
55
|
+
// script, so the diagnostics stay free rather than costing 8 spawns.
|
|
56
|
+
runner.runWsl(CAPABILITY_PROBE, opts),
|
|
57
|
+
runner.spawnWsl(['wsl.exe', '--version'], config.internalTimeoutMs),
|
|
58
|
+
runner.runWsl("free -h | awk 'NR==1 || NR==2'", opts),
|
|
59
|
+
runner.runWsl('df -h / /home 2>/dev/null || df -h /', opts),
|
|
60
|
+
runner.spawnWsl(['wsl.exe', '-l', '-v'], config.internalTimeoutMs),
|
|
61
|
+
])
|
|
62
|
+
|
|
63
|
+
const failure = (name, result) => {
|
|
64
|
+
const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.exitCode}`
|
|
65
|
+
return `[${name} unavailable: ${detail.split(/\r?\n/)[0]}]`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Nothing worked: report it instead of returning a summary that silently
|
|
69
|
+
// omits the kernel, memory and disk the caller asked for.
|
|
70
|
+
if (uname.exitCode !== 0) {
|
|
71
|
+
const detail = uname.stderr.trim() || uname.stdout.trim() || `exit code ${uname.exitCode}`
|
|
72
|
+
throw new Error(`wsl-env: could not probe ${label}: ${detail}`)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Name the distribution actually used, so "system default" is never a
|
|
76
|
+
// mystery the caller has to resolve with a second call.
|
|
77
|
+
const name = resolved ?? parseDefaultDistro(list.stdout)
|
|
78
|
+
const header = [
|
|
79
|
+
name === null ? 'distro: (system default)' : `distro: ${name}${resolved === null ? ' (system default)' : ''}`,
|
|
80
|
+
uname.stdout.trim(),
|
|
81
|
+
]
|
|
82
|
+
// Capabilities degrade one line at a time: a probe that failed leaves its
|
|
83
|
+
// line out instead of failing the whole summary.
|
|
84
|
+
if (caps.exitCode === 0) header.push(...capabilityLines(parseFacts(caps.stdout)))
|
|
85
|
+
else header.push(failure('capabilities', caps))
|
|
86
|
+
const launcherLine = launcher.exitCode === 0 ? launcherSummary(launcher.stdout) : null
|
|
87
|
+
if (launcherLine !== null) header.push(`launcher: ${launcherLine}`)
|
|
88
|
+
// Where the session's own files live is the one fact here that changes what
|
|
89
|
+
// the caller should choose to DO, so it is stated rather than implied — and
|
|
90
|
+
// it is the CALLING SESSION's workspace, never the host's launch directory.
|
|
91
|
+
const workspace = workspaceLine(sessionCwdOf(exec) ?? process.cwd())
|
|
92
|
+
if (workspace !== null) header.push(workspace)
|
|
93
|
+
|
|
94
|
+
const lines = [header.join('\n')]
|
|
95
|
+
lines.push(mem.exitCode === 0 ? mem.stdout.trimEnd() : failure('memory', mem))
|
|
96
|
+
lines.push(disk.exitCode === 0 ? disk.stdout.trimEnd() : failure('disk', disk))
|
|
97
|
+
// `wsl -l -v` can exit 0 with nothing on stdout; a bare header would be
|
|
98
|
+
// the only thing this section contributes, so report it as a failure.
|
|
99
|
+
if (list.exitCode === 0 && list.stdout.trim() !== '') {
|
|
100
|
+
lines.push('--- distributions ---\n' + list.stdout.trimEnd())
|
|
101
|
+
} else if (list.exitCode === 0) {
|
|
102
|
+
lines.push('[distribution list unavailable: wsl -l -v produced no output]')
|
|
103
|
+
} else {
|
|
104
|
+
lines.push(failure('distribution list', list))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { summary: lines.filter((line) => line.length > 0).join('\n\n') }
|
|
108
|
+
},
|
|
109
|
+
// See wsl-path: only generic/terminal/diff exist, and `description` is a
|
|
110
|
+
// terminal-card field rather than a generic one.
|
|
111
|
+
presentCall: () => ({ card: 'generic', title: 'Inspect the WSL environment' }),
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// The `wsl-path` tool: convert a path with `wslpath` in either direction.
|
|
2
|
+
|
|
3
|
+
import { quotePath } from '../paths.js'
|
|
4
|
+
|
|
5
|
+
const WINDOWS_PATH_RE = /^[a-zA-Z]:[\\/]/
|
|
6
|
+
const UNC_PATH_RE = /^\\\\/
|
|
7
|
+
|
|
8
|
+
export function createWslPathTool({ config, runner }) {
|
|
9
|
+
return {
|
|
10
|
+
name: 'wsl-path',
|
|
11
|
+
description:
|
|
12
|
+
'Convert between Windows and WSL/Linux paths using `wslpath`. ' +
|
|
13
|
+
'Pass a Windows path (e.g. `C:\\Users\\me\\a.txt`) to get its `/mnt/c/...` form, or a Linux path ' +
|
|
14
|
+
'(e.g. `/home/me/a.txt`) to get its `\\\\wsl.localhost\\...` form. ' +
|
|
15
|
+
'Use `direction` to force `win` or `linux`; leave it unset for auto-detection.',
|
|
16
|
+
parameters: {
|
|
17
|
+
type: 'object',
|
|
18
|
+
properties: {
|
|
19
|
+
path: {
|
|
20
|
+
type: 'string',
|
|
21
|
+
description: 'The path to convert.',
|
|
22
|
+
},
|
|
23
|
+
direction: {
|
|
24
|
+
type: 'string',
|
|
25
|
+
enum: ['auto', 'win', 'linux'],
|
|
26
|
+
description: "Conversion direction: 'win' (Linux->Windows result) or 'linux' (Windows->WSL result). Default auto.",
|
|
27
|
+
},
|
|
28
|
+
distro: {
|
|
29
|
+
type: 'string',
|
|
30
|
+
description: 'WSL distribution to use. Defaults to the system default distribution.',
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
required: ['path'],
|
|
34
|
+
},
|
|
35
|
+
output: {
|
|
36
|
+
schema: {
|
|
37
|
+
type: 'object',
|
|
38
|
+
additionalProperties: false,
|
|
39
|
+
properties: {
|
|
40
|
+
input: { type: 'string' },
|
|
41
|
+
converted: { type: 'string' },
|
|
42
|
+
},
|
|
43
|
+
required: ['input', 'converted'],
|
|
44
|
+
},
|
|
45
|
+
render: (_args, value) => [{ type: 'text', text: `${value.input} -> ${value.converted}` }],
|
|
46
|
+
},
|
|
47
|
+
async execute(args, exec) {
|
|
48
|
+
if (typeof args.path !== 'string' || args.path.trim() === '') {
|
|
49
|
+
throw new Error('wsl-path: path must be a non-empty string')
|
|
50
|
+
}
|
|
51
|
+
const looksWindows = WINDOWS_PATH_RE.test(args.path) || UNC_PATH_RE.test(args.path)
|
|
52
|
+
const direction = args.direction === 'win' || args.direction === 'linux'
|
|
53
|
+
? args.direction
|
|
54
|
+
: (looksWindows ? 'linux' : 'win')
|
|
55
|
+
// wslpath -u <windows> -> WSL path; wslpath -w <linux> -> Windows path
|
|
56
|
+
const flag = direction === 'win' ? '-w' : '-u'
|
|
57
|
+
const res = await runner.runWsl(`wslpath ${flag} ${quotePath(args.path)}`, {
|
|
58
|
+
distro: args.distro,
|
|
59
|
+
timeoutMs: config.internalTimeoutMs,
|
|
60
|
+
exec,
|
|
61
|
+
})
|
|
62
|
+
if (res.exitCode !== 0) {
|
|
63
|
+
const detail = (res.stderr || res.stdout).trim() || `exit code ${res.exitCode}`
|
|
64
|
+
throw new Error(`wsl-path: wslpath failed: ${detail}`)
|
|
65
|
+
}
|
|
66
|
+
const converted = res.stdout.trim()
|
|
67
|
+
if (converted === '') {
|
|
68
|
+
throw new Error(`wsl-path: wslpath returned no path for ${JSON.stringify(args.path)}`)
|
|
69
|
+
}
|
|
70
|
+
return { input: args.path, converted }
|
|
71
|
+
},
|
|
72
|
+
// `ToolCallView` knows exactly three cards: generic, terminal, diff. A card
|
|
73
|
+
// outside that set has no renderer, and a generic card has no `description`
|
|
74
|
+
// slot — both of those were wrong here until the platform type was read.
|
|
75
|
+
presentCall: (args) => ({ card: 'generic', title: `Convert path ${args.path}` }),
|
|
76
|
+
}
|
|
77
|
+
}
|