dsh-wsl-workspace 0.1.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/NOTICE +68 -0
- package/README.md +30 -0
- package/README.zh.md +30 -0
- package/cordis.patch.yml +7 -0
- package/lib/client.js +982 -0
- package/lib/client.js.map +1 -0
- package/lib/fs.js +175 -0
- package/lib/fs.js.map +1 -0
- package/lib/index.js +493 -0
- package/lib/index.js.map +1 -0
- package/lib/paths-DBaSmi7x.js +105 -0
- package/lib/paths-DBaSmi7x.js.map +1 -0
- package/lib/shell.js +382 -0
- package/lib/shell.js.map +1 -0
- package/lib/wsl-GjkUifnx.js +179 -0
- package/lib/wsl-GjkUifnx.js.map +1 -0
- package/package.json +57 -0
- package/src/client/AddWslWorkspace.tsx +346 -0
- package/src/client/api.ts +104 -0
- package/src/client/index.ts +193 -0
- package/src/client/locales.ts +68 -0
- package/src/client/styles.ts +282 -0
- package/src/fs.ts +228 -0
- package/src/host/variants.ts +199 -0
- package/src/index.ts +410 -0
- package/src/shared/paths.ts +159 -0
- package/src/shared/wsl-credentials.ts +85 -0
- package/src/shared/wsl.ts +105 -0
- package/src/shell.ts +441 -0
package/src/fs.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WSL Service Provider for the `ctx.fs` capability seam. Backed by the host
|
|
3
|
+
* filesystem over the `\\wsl.localhost\<distro>\…` 9P share — zero install
|
|
4
|
+
* inside the distribution — while every model/UI-facing path is the Linux
|
|
5
|
+
* path a WSL process would open (`processPath`, `displayPath`, `fileUrl`).
|
|
6
|
+
* Reuses `LocalFileSystem`'s mechanics (realpath identity, atomic writes,
|
|
7
|
+
* per-target locks, version guards) unchanged, because those operate on the
|
|
8
|
+
* UNC path Node can open directly.
|
|
9
|
+
*
|
|
10
|
+
* Both UNC paths and Linux absolute paths resolve; Windows drive paths
|
|
11
|
+
* resolve through their `/mnt/<drive>` form, so a WSL-composed session can
|
|
12
|
+
* still touch the Windows filesystem coherently.
|
|
13
|
+
* @module dsh-wsl-workspace/fs
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { Context } from '@deepseek-ai/cordis'
|
|
17
|
+
import z from '@deepseek-ai/schemastery'
|
|
18
|
+
import { link, lstat, rename } from 'node:fs/promises'
|
|
19
|
+
import { FsError } from '@deepseek-ai/dsh-fs'
|
|
20
|
+
import type { FsPathInfo, FsTarget } from '@deepseek-ai/dsh-fs'
|
|
21
|
+
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
|
|
22
|
+
import {
|
|
23
|
+
isAbsoluteLinuxPath,
|
|
24
|
+
joinUnc,
|
|
25
|
+
mntToWindowsPath,
|
|
26
|
+
parseWslUnc,
|
|
27
|
+
windowsToMntPath,
|
|
28
|
+
} from './shared/paths.ts'
|
|
29
|
+
|
|
30
|
+
/** Plugin config. `cwd`/`distro` are optional because UNC workdirs carry both. */
|
|
31
|
+
export interface Config {
|
|
32
|
+
/** Base directory for relative paths without a per-call cwd (UNC or Linux). */
|
|
33
|
+
cwd?: string
|
|
34
|
+
/** Default distribution for Linux-absolute paths without a UNC cwd. */
|
|
35
|
+
distro?: string
|
|
36
|
+
/** Exclusive UTF-8 byte limit on each overwrite-diff side (see fs-local). */
|
|
37
|
+
diffBasisMaxBytes?: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One translated coordinate: the input the local backend opens plus its cwd. */
|
|
41
|
+
interface Translated {
|
|
42
|
+
/** Absolute path to hand to the local backend (UNC or Windows drive). */
|
|
43
|
+
input: string
|
|
44
|
+
/** Absolute Windows-side base for relative inputs (UNC or Windows drive). */
|
|
45
|
+
cwd: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The WSL filesystem backend. Identity keys are canonical UNC paths; the
|
|
50
|
+
* Linux form is derived on demand, so both worlds stay in sync across
|
|
51
|
+
* aliases and symlinks.
|
|
52
|
+
*/
|
|
53
|
+
export class WslFileSystem extends LocalFileSystem {
|
|
54
|
+
static override Config: z<Config> = z.object({
|
|
55
|
+
cwd: z.string(),
|
|
56
|
+
distro: z.string(),
|
|
57
|
+
diffBasisMaxBytes: z.number().default(10 * 1024 * 1024),
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
private readonly distro: string | undefined
|
|
61
|
+
|
|
62
|
+
constructor(ctx: Context, config: Config) {
|
|
63
|
+
// schemastery fills the defaults before construction; the parent validates
|
|
64
|
+
// `diffBasisMaxBytes` and stores the resolved shape.
|
|
65
|
+
super(ctx, config)
|
|
66
|
+
this.distro = config.distro
|
|
67
|
+
// The 9P/drvfs substrate has no hard links and no Win32 security semantics:
|
|
68
|
+
// replace the atomic-publication boundaries the parent's fsio defaults to.
|
|
69
|
+
this.internals = {
|
|
70
|
+
linkFile: WslFileSystem.publishNoReplace,
|
|
71
|
+
replaceFile: WslFileSystem.replaceOverWrite,
|
|
72
|
+
copyFileDacl: WslFileSystem.skipDaclCopy,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* No-replace publication for filesystems without hard links. A real
|
|
78
|
+
* collision (a concurrent external creator won) must still surface as the
|
|
79
|
+
* original EEXIST so the guarded-create failure path classifies it; an
|
|
80
|
+
* absent target falls back to rename, which on Windows publishes without
|
|
81
|
+
* replacing anything. Safe against this backend's own writers because the
|
|
82
|
+
* per-target lock serializes them.
|
|
83
|
+
* @param tempPath - the staged file.
|
|
84
|
+
* @param destPath - the destination to create.
|
|
85
|
+
*/
|
|
86
|
+
private static async publishNoReplace(tempPath: string, destPath: string): Promise<void> {
|
|
87
|
+
try {
|
|
88
|
+
await link(tempPath, destPath)
|
|
89
|
+
return
|
|
90
|
+
} catch (error) {
|
|
91
|
+
let exists = false
|
|
92
|
+
try {
|
|
93
|
+
await lstat(destPath)
|
|
94
|
+
exists = true
|
|
95
|
+
} catch {
|
|
96
|
+
// Absent destination: rename publishes the staged file.
|
|
97
|
+
}
|
|
98
|
+
if (exists) throw error
|
|
99
|
+
await rename(tempPath, destPath)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Security-preserving replacement boundary: Windows rename replaces an
|
|
105
|
+
* existing destination atomically; no DACL preservation is needed over 9P.
|
|
106
|
+
* @param destPath - the file being replaced.
|
|
107
|
+
* @param tempPath - the staged replacement.
|
|
108
|
+
*/
|
|
109
|
+
private static async replaceOverWrite(destPath: string, tempPath: string): Promise<void> {
|
|
110
|
+
await rename(tempPath, destPath)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 9P files inherit their directory's DACL; nothing to preserve. */
|
|
114
|
+
private static async skipDaclCopy(): Promise<void> {}
|
|
115
|
+
|
|
116
|
+
/** Translate a model/plugin path into Windows-side coordinates. */
|
|
117
|
+
private translate(path: string, cwd?: string): Translated {
|
|
118
|
+
const unc = parseWslUnc(path)
|
|
119
|
+
if (unc !== null) {
|
|
120
|
+
return { input: joinUnc(unc.distro, unc.linuxPath), cwd: this.cwdOr(cwd) }
|
|
121
|
+
}
|
|
122
|
+
if (isAbsoluteLinuxPath(path)) {
|
|
123
|
+
// /mnt/<drive>/… names the Windows filesystem inside the Linux world
|
|
124
|
+
// (the dual-access path for migration): open the drive path directly so
|
|
125
|
+
// both worlds stay coherent — the display stays the /mnt form.
|
|
126
|
+
const win = mntToWindowsPath(path)
|
|
127
|
+
if (win !== null) return { input: win, cwd: this.cwdOr(cwd) }
|
|
128
|
+
return { input: joinUnc(this.distroFor(cwd), path), cwd: this.cwdOr(cwd) }
|
|
129
|
+
}
|
|
130
|
+
if (windowsToMntPath(path) !== null) {
|
|
131
|
+
// Windows drive paths open directly; the Linux world reaches them via /mnt.
|
|
132
|
+
return { input: path, cwd: this.cwdOr(cwd) }
|
|
133
|
+
}
|
|
134
|
+
// Relative: resolve against the caller cwd (or the configured base).
|
|
135
|
+
const base = this.uncCwd(cwd)
|
|
136
|
+
return { input: path, cwd: base }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A base for absolute inputs (unused by resolution, but the parent needs one). */
|
|
140
|
+
private cwdOr(cwd?: string): string {
|
|
141
|
+
return cwd ?? this.config.cwd ?? process.cwd()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private uncCwd(cwd?: string): string {
|
|
145
|
+
const base = cwd ?? this.config.cwd
|
|
146
|
+
if (base === undefined || base === '') {
|
|
147
|
+
throw new FsError('wsl-fs: no cwd and no configured base for relative resolution', 'FS_IO_ERROR')
|
|
148
|
+
}
|
|
149
|
+
const unc = parseWslUnc(base)
|
|
150
|
+
if (unc !== null) return joinUnc(unc.distro, unc.linuxPath)
|
|
151
|
+
if (isAbsoluteLinuxPath(base)) return joinUnc(this.distroFor(base), base)
|
|
152
|
+
if (windowsToMntPath(base) !== null) return base
|
|
153
|
+
throw new FsError(`wsl-fs: cwd "${base}" is not in the WSL execution world`, 'FS_IO_ERROR')
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private distroFor(cwd?: string): string {
|
|
157
|
+
const fromCwd = parseWslUnc(cwd ?? '')
|
|
158
|
+
if (fromCwd !== null) return fromCwd.distro
|
|
159
|
+
const distro = this.distro
|
|
160
|
+
if (distro === undefined || distro === '') {
|
|
161
|
+
throw new FsError('wsl-fs: Linux path carries no distribution and none is configured', 'FS_IO_ERROR')
|
|
162
|
+
}
|
|
163
|
+
return distro
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The Linux display path for a resolved Windows-side path. */
|
|
167
|
+
private linuxDisplay(raw: string): string {
|
|
168
|
+
const unc = parseWslUnc(raw)
|
|
169
|
+
if (unc !== null) return unc.linuxPath
|
|
170
|
+
const mnt = windowsToMntPath(raw)
|
|
171
|
+
if (mnt !== null) return mnt
|
|
172
|
+
throw new FsError(`wsl-fs: resolved path "${raw}" is outside the WSL execution world`, 'FS_IO_ERROR')
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
override async resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget> {
|
|
176
|
+
if (opts?.signal?.aborted) throw new FsError('resolve aborted', 'FS_ABORTED')
|
|
177
|
+
const { input, cwd } = this.translate(path, opts?.cwd)
|
|
178
|
+
const local = await super.resolve(input, {
|
|
179
|
+
cwd,
|
|
180
|
+
...opts?.signal !== undefined ? { signal: opts.signal } : {},
|
|
181
|
+
})
|
|
182
|
+
return { targetKey: local.targetKey, displayPath: this.linuxDisplay(String(local.displayPath)) }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
override processPath(target: FsTarget): string {
|
|
186
|
+
const key = String(target.targetKey)
|
|
187
|
+
const unc = parseWslUnc(key)
|
|
188
|
+
if (unc !== null) return unc.linuxPath
|
|
189
|
+
const mnt = windowsToMntPath(key)
|
|
190
|
+
if (mnt !== null) return mnt
|
|
191
|
+
throw new FsError(`wsl-fs: target "${target.displayPath}" is outside the WSL execution world`, 'FS_IO_ERROR')
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
override fileUrl(target: FsTarget): string {
|
|
195
|
+
const linux = this.processPath(target)
|
|
196
|
+
const encoded = linux.split('/').map(encodeURIComponent).join('/')
|
|
197
|
+
return `file://${encoded}`
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
override contains(parent: FsTarget, child: FsTarget): boolean {
|
|
201
|
+
const parentWorld = this.worldPath(parent)
|
|
202
|
+
const childWorld = this.worldPath(child)
|
|
203
|
+
if (parentWorld.distro !== childWorld.distro) return false
|
|
204
|
+
const parentPath = parentWorld.linuxPath
|
|
205
|
+
const childPath = childWorld.linuxPath
|
|
206
|
+
if (childPath === parentPath) return true
|
|
207
|
+
return parentPath === '/' ? true : childPath.startsWith(`${parentPath}/`)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** One target's (distro, linuxPath) pair for containment; `undefined` distro = Windows world. */
|
|
211
|
+
private worldPath(target: FsTarget): { distro: string | undefined; linuxPath: string } {
|
|
212
|
+
const key = String(target.targetKey)
|
|
213
|
+
const unc = parseWslUnc(key)
|
|
214
|
+
if (unc !== null) return { distro: unc.distro, linuxPath: unc.linuxPath }
|
|
215
|
+
const mnt = windowsToMntPath(key)
|
|
216
|
+
if (mnt !== null) return { distro: undefined, linuxPath: mnt }
|
|
217
|
+
throw new FsError(`wsl-fs: target "${target.displayPath}" is outside the WSL execution world`, 'FS_IO_ERROR')
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
override async lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise<FsPathInfo | undefined> {
|
|
221
|
+
if (signal?.aborted) throw new FsError('lstat aborted', 'FS_ABORTED')
|
|
222
|
+
if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND')
|
|
223
|
+
const { input, cwd } = this.translate(path, opts?.cwd)
|
|
224
|
+
return super.lstat(input, { cwd }, signal)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export default WslFileSystem
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WSL preset-variant generator. For every healthy source preset the roster
|
|
3
|
+
* supplies, a `wsl-<id>` variant is materialized under the roster's user
|
|
4
|
+
* root: the source composition with its shell/filesystem world replaced by
|
|
5
|
+
* the WSL providers, so any mode (standard, minimal, code, cordis, user
|
|
6
|
+
* presets) can run on top of a WSL execution world. The execution world is
|
|
7
|
+
* therefore orthogonal to the mode instead of a mode itself.
|
|
8
|
+
*
|
|
9
|
+
* The transformation is text-level on the top-level rows of the composition
|
|
10
|
+
* (the shape all shipped presets share), with surgical edits for the known
|
|
11
|
+
* special groups; unknown shapes are kept verbatim where possible.
|
|
12
|
+
* @module dsh-wsl-workspace/host/variants
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Top-level rows that name the execution world and are replaced by the variant's own. */
|
|
16
|
+
const WORLD_ROWS = new Set(['tool-bash', 'tool-pwsh', 'tool-fs', 'tool-fs-search', 'filesystem', 'persistent-shell'])
|
|
17
|
+
|
|
18
|
+
/** The injected WSL world group: providers + the bash/fs consumers, entry-local. */
|
|
19
|
+
function wslWorldGroup(shellPath: string, fsPath: string, includeEditor: boolean): string {
|
|
20
|
+
return [
|
|
21
|
+
'# ── WSL execution world (dsh-wsl-workspace variant) ─────────────────────',
|
|
22
|
+
'# The shell and fs services are provided entry-locally (the isolate',
|
|
23
|
+
'# realm); host services (tools registry, shell-env, jobs) fall through.',
|
|
24
|
+
'# tool-fs-search is intentionally absent: the packaged ripgrep runs on',
|
|
25
|
+
'# the Windows host and cannot open Linux paths; WSL sessions search with',
|
|
26
|
+
'# shell tools instead.',
|
|
27
|
+
'- id: wsl-world',
|
|
28
|
+
" name: cordis:group",
|
|
29
|
+
' group: true',
|
|
30
|
+
' isolate:',
|
|
31
|
+
' shell: true',
|
|
32
|
+
' fs: true',
|
|
33
|
+
' config:',
|
|
34
|
+
` - id: shell-wsl`,
|
|
35
|
+
` name: '${shellPath.replace(/'/g, "''")}'`,
|
|
36
|
+
' - id: fs-wsl',
|
|
37
|
+
` name: '${fsPath.replace(/'/g, "''")}'`,
|
|
38
|
+
' - id: tool-bash',
|
|
39
|
+
" name: '@deepseek-ai/dsh-tool-bash'",
|
|
40
|
+
' - id: tool-fs',
|
|
41
|
+
" name: '@deepseek-ai/dsh-tool-fs'",
|
|
42
|
+
...(includeEditor
|
|
43
|
+
? [
|
|
44
|
+
' - id: str-replace-editor',
|
|
45
|
+
" name: '@deepseek-ai/dsh-tool-str-replace-editor'",
|
|
46
|
+
' config:',
|
|
47
|
+
' maxOutputChars: 16000',
|
|
48
|
+
]
|
|
49
|
+
: []),
|
|
50
|
+
'',
|
|
51
|
+
].join('\n')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The persistent-shell group re-pointed at WSL: the PTY spawns wsl.exe's
|
|
56
|
+
* bash instead of a host `bash` (which does not exist on Windows).
|
|
57
|
+
*/
|
|
58
|
+
function persistentShellGroup(): string {
|
|
59
|
+
return [
|
|
60
|
+
'# ── persistent shell over WSL (variant) ─────────────────────────────────',
|
|
61
|
+
'- id: persistent-shell',
|
|
62
|
+
' name: cordis:group',
|
|
63
|
+
' group: true',
|
|
64
|
+
' isolate:',
|
|
65
|
+
' terminals: true',
|
|
66
|
+
' config:',
|
|
67
|
+
' - id: pty',
|
|
68
|
+
" name: '@deepseek-ai/dsh-terminal'",
|
|
69
|
+
'',
|
|
70
|
+
' - id: terminal-bash',
|
|
71
|
+
" name: '@deepseek-ai/dsh-terminal-bash'",
|
|
72
|
+
' config:',
|
|
73
|
+
' timeoutMs: 300000',
|
|
74
|
+
" shellPath: 'wsl.exe'",
|
|
75
|
+
" shellArgs: ['-e', 'bash', '-l']",
|
|
76
|
+
'',
|
|
77
|
+
' - id: persistent-bash',
|
|
78
|
+
" name: '@deepseek-ai/dsh-tool-bash-persistent'",
|
|
79
|
+
' config:',
|
|
80
|
+
' timeoutMs: 300000',
|
|
81
|
+
' description: |-',
|
|
82
|
+
' Run commands in a bash shell inside the WSL distribution',
|
|
83
|
+
' * When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped.',
|
|
84
|
+
" * You don't have access to the internet via this tool.",
|
|
85
|
+
' * You do have access to a mirror of common linux and python packages via apt and pip.',
|
|
86
|
+
' * State is persistent across command calls and discussions with the user.',
|
|
87
|
+
" * To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'.",
|
|
88
|
+
' * Please avoid commands that may produce a very large amount of output.',
|
|
89
|
+
" * Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.",
|
|
90
|
+
'',
|
|
91
|
+
].join('\n')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The sentence appended to a standard-like persona when the variant runs in WSL. */
|
|
95
|
+
const PERSONA_APPEND = ' Your working directory {{cwd}} is inside a WSL (Windows Subsystem for Linux) distribution: the bash tool and the file read/write/edit tools use Linux paths, and the Windows filesystem is reachable as /mnt/<drive> for file migration.'
|
|
96
|
+
|
|
97
|
+
/** The top-level rows of one composition, as (startLine, endLineExclusive) spans. */
|
|
98
|
+
function topLevelSpans(lines: readonly string[]): { start: number; end: number }[] {
|
|
99
|
+
const spans: { start: number; end: number }[] = []
|
|
100
|
+
let start = -1
|
|
101
|
+
for (let index = 0; index < lines.length; index++) {
|
|
102
|
+
if (lines[index]?.startsWith('- id: ') === true) {
|
|
103
|
+
if (start >= 0) spans.push({ start, end: index })
|
|
104
|
+
start = index
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (start >= 0) spans.push({ start, end: lines.length })
|
|
108
|
+
return spans
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The row id of a top-level span, or undefined when the first line is malformed. */
|
|
112
|
+
function spanId(lines: readonly string[], span: { start: number; end: number }): string | undefined {
|
|
113
|
+
return /^- id: ([A-Za-z0-9_.-]+)/.exec(lines[span.start] ?? '')?.[1]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Whether a top-level span is a `persona` row with an appendable folded text. */
|
|
117
|
+
function appendablePersona(lines: readonly string[], span: { start: number; end: number }): boolean {
|
|
118
|
+
const block = lines.slice(span.start, span.end).join('\n')
|
|
119
|
+
if (!block.includes('complete: true') && /text: [>|-]/.test(block)) {
|
|
120
|
+
// Append only when the folded text actually has content lines.
|
|
121
|
+
const textLine = block.split('\n').find(line => /^(\s*)text: [>|-]/.test(line))
|
|
122
|
+
if (textLine !== undefined) {
|
|
123
|
+
const indent = /^(\s*)/.exec(textLine)?.[1]?.length ?? 0
|
|
124
|
+
return block.split('\n').some(line => line.length > indent && /^\s+/.test(line) && !line.includes(':'))
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return false
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Append the WSL sentence to a persona row's folded text (in place of its last text line). */
|
|
131
|
+
function appendPersona(lines: readonly string[], span: { start: number; end: number }): string[] {
|
|
132
|
+
const block = lines.slice(span.start, span.end)
|
|
133
|
+
const textIndex = block.findIndex(line => /^(\s*)text: [>|-]/.test(line))
|
|
134
|
+
if (textIndex < 0) return [...block]
|
|
135
|
+
const indent = /^(\s*)/.exec(block[textIndex] ?? '')?.[1]?.length ?? 0
|
|
136
|
+
let lastText = -1
|
|
137
|
+
for (let index = textIndex + 1; index < block.length; index++) {
|
|
138
|
+
const line = block[index] ?? ''
|
|
139
|
+
if (line.trim() === '') continue
|
|
140
|
+
if (line.length > indent && /^\s+/.test(line)) lastText = index
|
|
141
|
+
}
|
|
142
|
+
if (lastText < 0) return [...block]
|
|
143
|
+
const updated = [...block]
|
|
144
|
+
const textIndent = /^(\s*)/.exec(block[lastText] ?? '')?.[1] ?? ' '
|
|
145
|
+
updated.splice(lastText + 1, 0, `${textIndent}${PERSONA_APPEND}`)
|
|
146
|
+
return updated
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Transform one source preset composition into its WSL variant: drop the
|
|
151
|
+
* execution-world rows, keep everything else verbatim, and append the WSL
|
|
152
|
+
* world group (plus the persistent-shell group when the source had one).
|
|
153
|
+
* @param source - the source composition text.
|
|
154
|
+
* @param shellPath - absolute path of the plugin's built WSL shell provider.
|
|
155
|
+
* @param fsPath - absolute path of the plugin's built WSL fs provider.
|
|
156
|
+
* @returns the variant composition text.
|
|
157
|
+
*/
|
|
158
|
+
export function transformPresetForWsl(source: string, shellPath: string, fsPath: string): string {
|
|
159
|
+
const lines = source.split('\n')
|
|
160
|
+
const spans = topLevelSpans(lines)
|
|
161
|
+
const kept: string[] = []
|
|
162
|
+
let sawEditor = false
|
|
163
|
+
let sawPersistent = false
|
|
164
|
+
let personaAppended = false
|
|
165
|
+
for (const span of spans) {
|
|
166
|
+
const id = spanId(lines, span)
|
|
167
|
+
if (id === undefined) {
|
|
168
|
+
kept.push(...lines.slice(span.start, span.end))
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
if (WORLD_ROWS.has(id)) {
|
|
172
|
+
if (id === 'persistent-shell') sawPersistent = true
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
175
|
+
if (id === 'persona' && !personaAppended && appendablePersona(lines, span)) {
|
|
176
|
+
kept.push(...appendPersona(lines, span))
|
|
177
|
+
personaAppended = true
|
|
178
|
+
continue
|
|
179
|
+
}
|
|
180
|
+
kept.push(...lines.slice(span.start, span.end))
|
|
181
|
+
if (id === 'str-replace-editor') sawEditor = true
|
|
182
|
+
}
|
|
183
|
+
if (source.includes('str-replace-editor')) sawEditor = true
|
|
184
|
+
const result = [...kept]
|
|
185
|
+
if (result.length > 0 && result[result.length - 1] !== '') result.push('')
|
|
186
|
+
result.push(wslWorldGroup(shellPath, fsPath, sawEditor))
|
|
187
|
+
if (sawPersistent) result.push(persistentShellGroup())
|
|
188
|
+
return result.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, '\n')
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Whether an id is one of this plugin's own preset directories. */
|
|
192
|
+
export function isWslVariantId(id: string): boolean {
|
|
193
|
+
return id === 'wsl' || /^wsl-[a-z0-9-]+$/.test(id)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The variant id for one source preset id. */
|
|
197
|
+
export function variantIdFor(sourceId: string): string {
|
|
198
|
+
return `wsl-${sourceId.toLowerCase()}`
|
|
199
|
+
}
|