dsh-ops 0.0.0-stage → 0.2.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/CHANGELOG.md +202 -0
- package/LICENSE +30 -0
- package/NOTICE +106 -0
- package/PROVENANCE.md +435 -0
- package/README.en.md +126 -0
- package/README.md +115 -2
- package/README.zh.md +116 -0
- package/bin/dsh-ops.mjs +1216 -0
- package/cordis.patch.yml +160 -0
- package/docs/manual-validation.md +53 -0
- package/docs/release-0.2.1.md +72 -0
- package/docs/schema-baseline.json +64 -0
- package/docs/schema-current.json +84 -0
- package/docs/schema-measurement.md +17 -0
- package/dsh-plugin.json +88 -0
- package/icon.svg +12 -0
- package/lib/binary.js +409 -0
- package/lib/config.js +198 -0
- package/lib/handshake.js +252 -0
- package/lib/index.js +108 -0
- package/lib/jobs.js +42 -0
- package/lib/policy.js +64 -0
- package/lib/presentation.js +63 -0
- package/lib/profile-install.js +61 -0
- package/lib/rust.js +194 -0
- package/lib/session-shells.js +78 -0
- package/lib/shells.js +998 -0
- package/lib/tools.js +657 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +114 -4
- package/vendor/fastctx/Cargo.lock +3210 -0
- package/vendor/fastctx/Cargo.toml +94 -0
- package/vendor/fastctx/FORK.md +119 -0
- package/vendor/fastctx/LICENSE-APACHE +201 -0
- package/vendor/fastctx/NOTICE +40 -0
- package/vendor/fastctx/README.md +439 -0
- package/vendor/fastctx/THIRD_PARTY_LICENSES.md +17 -0
- package/vendor/fastctx/THIRD_PARTY_LICENSES_RUST.md +7914 -0
- package/vendor/fastctx/UPSTREAM.md +49 -0
- package/vendor/fastctx/build.rs +413 -0
- package/vendor/fastctx/src/background_status.rs +403 -0
- package/vendor/fastctx/src/binary.rs +75 -0
- package/vendor/fastctx/src/bounded_sort.rs +500 -0
- package/vendor/fastctx/src/budget.rs +781 -0
- package/vendor/fastctx/src/cli/mod.rs +110 -0
- package/vendor/fastctx/src/context_guard.rs +289 -0
- package/vendor/fastctx/src/control/mod.rs +6 -0
- package/vendor/fastctx/src/control/paths.rs +49 -0
- package/vendor/fastctx/src/control/settings.rs +753 -0
- package/vendor/fastctx/src/control/transaction.rs +531 -0
- package/vendor/fastctx/src/edit/document.rs +535 -0
- package/vendor/fastctx/src/edit/locks.rs +371 -0
- package/vendor/fastctx/src/edit/mod.rs +213 -0
- package/vendor/fastctx/src/edit/private_storage/unix.rs +315 -0
- package/vendor/fastctx/src/edit/private_storage/windows.rs +793 -0
- package/vendor/fastctx/src/edit/private_storage.rs +234 -0
- package/vendor/fastctx/src/edit/replace.rs +1030 -0
- package/vendor/fastctx/src/edit_server.rs +53 -0
- package/vendor/fastctx/src/encoding/reference_v011.rs +587 -0
- package/vendor/fastctx/src/encoding/snapshot_pipeline.rs +1678 -0
- package/vendor/fastctx/src/encoding.rs +1118 -0
- package/vendor/fastctx/src/file_executor.rs +1151 -0
- package/vendor/fastctx/src/file_snapshot.rs +1491 -0
- package/vendor/fastctx/src/glob_filter.rs +98 -0
- package/vendor/fastctx/src/glob_tool.rs +653 -0
- package/vendor/fastctx/src/grep_sink.rs +1162 -0
- package/vendor/fastctx/src/grep_tool.rs +2449 -0
- package/vendor/fastctx/src/lib.rs +45 -0
- package/vendor/fastctx/src/main.rs +15 -0
- package/vendor/fastctx/src/model.rs +51 -0
- package/vendor/fastctx/src/model_guidance.rs +62 -0
- package/vendor/fastctx/src/operation.rs +356 -0
- package/vendor/fastctx/src/ordered_window.rs +1235 -0
- package/vendor/fastctx/src/os_environment.rs +414 -0
- package/vendor/fastctx/src/path_codec.rs +850 -0
- package/vendor/fastctx/src/paths.rs +244 -0
- package/vendor/fastctx/src/process_identity.rs +763 -0
- package/vendor/fastctx/src/process_policy.rs +74 -0
- package/vendor/fastctx/src/read_tool/batch.rs +496 -0
- package/vendor/fastctx/src/read_tool/hex_file.rs +141 -0
- package/vendor/fastctx/src/read_tool/image_file.rs +88 -0
- package/vendor/fastctx/src/read_tool/mod.rs +245 -0
- package/vendor/fastctx/src/read_tool/pdf.rs +470 -0
- package/vendor/fastctx/src/read_tool/pdf_disabled.rs +47 -0
- package/vendor/fastctx/src/read_tool/pdf_engine.rs +664 -0
- package/vendor/fastctx/src/read_tool/text_file.rs +351 -0
- package/vendor/fastctx/src/render_plan.rs +468 -0
- package/vendor/fastctx/src/runtime/activity.rs +159 -0
- package/vendor/fastctx/src/runtime/hosts.rs +99 -0
- package/vendor/fastctx/src/runtime/journal.rs +556 -0
- package/vendor/fastctx/src/runtime/local_ipc.rs +186 -0
- package/vendor/fastctx/src/runtime/mod.rs +746 -0
- package/vendor/fastctx/src/runtime/protocol.rs +296 -0
- package/vendor/fastctx/src/runtime/session.rs +536 -0
- package/vendor/fastctx/src/runtime/windows_process.rs +66 -0
- package/vendor/fastctx/src/search_parallelism.rs +106 -0
- package/vendor/fastctx/src/search_text.rs +227 -0
- package/vendor/fastctx/src/server.rs +359 -0
- package/vendor/fastctx/src/server_manifest.rs +468 -0
- package/vendor/fastctx/src/server_support.rs +826 -0
- package/vendor/fastctx/src/session.rs +629 -0
- package/vendor/fastctx/src/shell/apply_patch_hint.rs +41 -0
- package/vendor/fastctx/src/shell/bash.rs +263 -0
- package/vendor/fastctx/src/shell/buffer.rs +108 -0
- package/vendor/fastctx/src/shell/encoding.rs +403 -0
- package/vendor/fastctx/src/shell/foreground.rs +115 -0
- package/vendor/fastctx/src/shell/jobs/admission.rs +91 -0
- package/vendor/fastctx/src/shell/jobs/background.rs +146 -0
- package/vendor/fastctx/src/shell/jobs/host.rs +830 -0
- package/vendor/fastctx/src/shell/jobs/identity.rs +29 -0
- package/vendor/fastctx/src/shell/jobs/mod.rs +1513 -0
- package/vendor/fastctx/src/shell/jobs/model.rs +244 -0
- package/vendor/fastctx/src/shell/jobs/output_log.rs +1148 -0
- package/vendor/fastctx/src/shell/jobs/store.rs +1300 -0
- package/vendor/fastctx/src/shell/mod.rs +345 -0
- package/vendor/fastctx/src/shell/normalize.rs +389 -0
- package/vendor/fastctx/src/shell/output.rs +406 -0
- package/vendor/fastctx/src/shell/process.rs +493 -0
- package/vendor/fastctx/src/shell_server.rs +156 -0
- package/vendor/fastctx/src/skip_report.rs +83 -0
- package/vendor/fastctx/src/stdio_transport.rs +177 -0
- package/vendor/fastctx/src/tool_schema.rs +204 -0
- package/vendor/fastctx/src/traversal.rs +846 -0
- package/vendor/fastctx/third-party/pdfium-7763/LICENSE +9 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/abseil.txt +202 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/agg23.txt +14 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/fast_float.txt +27 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/freetype.txt +169 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/icu.txt +542 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/lcms.txt +27 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.ijg +260 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.md +135 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libopenjpeg.txt +32 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libpng.txt +134 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libtiff.txt +21 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/llvm-libc.txt +278 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/pdfium.txt +230 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/simdutf.txt +18 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/zlib.txt +29 -0
package/lib/handshake.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP stdio client this plugin owns.
|
|
3
|
+
*
|
|
4
|
+
* It is the plugin's transport: `lib/tools.js` keeps one long-lived client
|
|
5
|
+
* connected to the FastCtx server for as long as the plugin is mounted, calls
|
|
6
|
+
* `tools/call` through it, and rebuilds the whole tool generation after a
|
|
7
|
+
* reconnect. The same client also serves the callers that need ground truth
|
|
8
|
+
* without a host — `dsh-ops status`, and the suite that pins the tool surface
|
|
9
|
+
* the plugin's prompt policy advertises.
|
|
10
|
+
*
|
|
11
|
+
* It speaks newline-delimited JSON-RPC 2.0 and implements only the four methods
|
|
12
|
+
* those callers use.
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-ops/handshake
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { spawn } from 'node:child_process'
|
|
18
|
+
|
|
19
|
+
/** The MCP revision this client announces. */
|
|
20
|
+
export const PROTOCOL_VERSION = '2025-06-18'
|
|
21
|
+
|
|
22
|
+
/** Error raised when the server does not answer in time. */
|
|
23
|
+
export class McpTimeoutError extends Error {
|
|
24
|
+
/**
|
|
25
|
+
* @param {string} method - the unanswered method.
|
|
26
|
+
* @param {number} timeoutMs - the deadline that expired.
|
|
27
|
+
*/
|
|
28
|
+
constructor(method, timeoutMs) {
|
|
29
|
+
super(`MCP server did not answer ${method} within ${timeoutMs} ms`)
|
|
30
|
+
this.name = 'McpTimeoutError'
|
|
31
|
+
this.method = method
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Error raised when the server answers with a JSON-RPC error. */
|
|
36
|
+
export class McpServerError extends Error {
|
|
37
|
+
/**
|
|
38
|
+
* @param {string} method - the failing method.
|
|
39
|
+
* @param {{code?: number, message?: string}} error - the JSON-RPC error object.
|
|
40
|
+
*/
|
|
41
|
+
constructor(method, error) {
|
|
42
|
+
super(`MCP ${method} failed: ${error?.message ?? JSON.stringify(error)}`)
|
|
43
|
+
this.name = 'McpServerError'
|
|
44
|
+
this.code = error?.code
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One newline-delimited JSON-RPC client over a spawned server's stdio. */
|
|
49
|
+
export class McpStdioClient {
|
|
50
|
+
/**
|
|
51
|
+
* @param {import('node:child_process').ChildProcess} child - the spawned server.
|
|
52
|
+
* @param {number} timeoutMs - the default per-request deadline.
|
|
53
|
+
*/
|
|
54
|
+
constructor(child, timeoutMs) {
|
|
55
|
+
this.child = child
|
|
56
|
+
this.timeoutMs = timeoutMs
|
|
57
|
+
this.nextId = 1
|
|
58
|
+
/** @type {Map<number, {resolve: (value: unknown) => void, reject: (error: Error) => void, method: string}>} */
|
|
59
|
+
this.pending = new Map()
|
|
60
|
+
/** @type {string[]} */
|
|
61
|
+
this.stderrLines = []
|
|
62
|
+
this.buffer = ''
|
|
63
|
+
this.closed = false
|
|
64
|
+
/** How this connection ended, or undefined while it is live. */
|
|
65
|
+
this.exitInfo = undefined
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Settles once this connection is over, however it ended: a clean exit, a
|
|
69
|
+
* crash, or a spawn failure. A long-lived owner watches it to notice a dead
|
|
70
|
+
* server between calls instead of discovering it on the next timeout.
|
|
71
|
+
* @type {Promise<{code: number|null, signal: string|null, error?: Error}>}
|
|
72
|
+
*/
|
|
73
|
+
this.exited = new Promise((resolve) => { this.resolveExit = resolve })
|
|
74
|
+
|
|
75
|
+
child.stdout.setEncoding('utf8')
|
|
76
|
+
child.stdout.on('data', (chunk) => this.#ingest(chunk))
|
|
77
|
+
child.stderr.setEncoding('utf8')
|
|
78
|
+
child.stderr.on('data', (chunk) => {
|
|
79
|
+
for (const line of String(chunk).split('\n')) if (line.trim() !== '') this.stderrLines.push(line)
|
|
80
|
+
})
|
|
81
|
+
// A write to a server that is already gone must surface as this request's
|
|
82
|
+
// rejection (below), not as an unhandled stream error that kills the host.
|
|
83
|
+
child.stdin.on('error', () => {})
|
|
84
|
+
|
|
85
|
+
const settleExit = (info) => {
|
|
86
|
+
if (this.exitInfo !== undefined) return
|
|
87
|
+
this.exitInfo = info
|
|
88
|
+
this.closed = true
|
|
89
|
+
const reason = info.error !== undefined
|
|
90
|
+
? new Error(`MCP server could not be started: ${info.error.message}`)
|
|
91
|
+
: new Error(`MCP server exited (code ${info.code ?? 'null'}, signal ${info.signal ?? 'none'})`)
|
|
92
|
+
for (const entry of this.pending.values()) entry.reject(reason)
|
|
93
|
+
this.pending.clear()
|
|
94
|
+
this.resolveExit(info)
|
|
95
|
+
}
|
|
96
|
+
child.once('exit', (code, signal) => settleExit({ code, signal }))
|
|
97
|
+
child.once('error', (error) => settleExit({ code: null, signal: null, error }))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Spawn one server and return a connected client (the MCP handshake is not
|
|
102
|
+
* performed; call {@link McpStdioClient#initialize}).
|
|
103
|
+
* @param {object} options - spawn inputs.
|
|
104
|
+
* @param {string} options.file - the executable.
|
|
105
|
+
* @param {string[]} [options.args] - its arguments.
|
|
106
|
+
* @param {string} [options.cwd] - working directory.
|
|
107
|
+
* @param {NodeJS.ProcessEnv} [options.env] - environment.
|
|
108
|
+
* @param {number} [options.timeoutMs] - the default per-request deadline.
|
|
109
|
+
* @returns {McpStdioClient} the client.
|
|
110
|
+
*/
|
|
111
|
+
static start({ file, args = [], cwd, env = process.env, timeoutMs = 60_000 }) {
|
|
112
|
+
const child = spawn(file, args, {
|
|
113
|
+
cwd,
|
|
114
|
+
env,
|
|
115
|
+
windowsHide: true,
|
|
116
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
117
|
+
})
|
|
118
|
+
return new McpStdioClient(child, timeoutMs)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Consume whatever arrived and settle every complete line.
|
|
123
|
+
* @param {string} chunk - decoded stdout.
|
|
124
|
+
* @returns {void}
|
|
125
|
+
*/
|
|
126
|
+
#ingest(chunk) {
|
|
127
|
+
this.buffer += chunk
|
|
128
|
+
let index
|
|
129
|
+
while ((index = this.buffer.indexOf('\n')) >= 0) {
|
|
130
|
+
const line = this.buffer.slice(0, index).trim()
|
|
131
|
+
this.buffer = this.buffer.slice(index + 1)
|
|
132
|
+
if (line === '') continue
|
|
133
|
+
let message
|
|
134
|
+
try {
|
|
135
|
+
message = JSON.parse(line)
|
|
136
|
+
} catch {
|
|
137
|
+
// A non-JSON line on stdout is the server's problem, not a protocol
|
|
138
|
+
// message; recording it keeps the diagnosis attached to a failure.
|
|
139
|
+
this.stderrLines.push(`non-JSON stdout: ${line.slice(0, 400)}`)
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
const entry = message.id === undefined ? undefined : this.pending.get(message.id)
|
|
143
|
+
if (entry === undefined) continue
|
|
144
|
+
if (message.error !== undefined) entry.reject(new McpServerError(entry.method, message.error))
|
|
145
|
+
else entry.resolve(message.result)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Send one request and await its result.
|
|
151
|
+
* @param {string} method - the JSON-RPC method.
|
|
152
|
+
* @param {Record<string, unknown>} [params] - its parameters.
|
|
153
|
+
* @param {object} [options] - per-request overrides.
|
|
154
|
+
* @param {number} [options.timeoutMs] - this request's deadline.
|
|
155
|
+
* @param {AbortSignal} [options.signal] - cancels this request when aborted.
|
|
156
|
+
* @returns {Promise<any>} the result.
|
|
157
|
+
*/
|
|
158
|
+
request(method, params = {}, options = {}) {
|
|
159
|
+
const { timeoutMs = this.timeoutMs, signal } = options
|
|
160
|
+
if (this.closed) return Promise.reject(new Error(`MCP server is gone; cannot send ${method}`))
|
|
161
|
+
if (signal?.aborted === true) {
|
|
162
|
+
return Promise.reject(new Error(`MCP ${method} was canceled before it was sent`))
|
|
163
|
+
}
|
|
164
|
+
const id = this.nextId++
|
|
165
|
+
return new Promise((resolve, reject) => {
|
|
166
|
+
let timer
|
|
167
|
+
let settled = false
|
|
168
|
+
const finish = (settle, value) => {
|
|
169
|
+
if (settled) return
|
|
170
|
+
settled = true
|
|
171
|
+
clearTimeout(timer)
|
|
172
|
+
signal?.removeEventListener('abort', onAbort)
|
|
173
|
+
this.pending.delete(id)
|
|
174
|
+
settle(value)
|
|
175
|
+
}
|
|
176
|
+
const onAbort = () => finish(reject, new Error(`MCP ${method} was canceled`))
|
|
177
|
+
timer = setTimeout(() => finish(reject, new McpTimeoutError(method, timeoutMs)), timeoutMs)
|
|
178
|
+
timer.unref?.()
|
|
179
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
180
|
+
this.pending.set(id, {
|
|
181
|
+
method,
|
|
182
|
+
resolve: (value) => finish(resolve, value),
|
|
183
|
+
reject: (error) => finish(reject, error),
|
|
184
|
+
})
|
|
185
|
+
this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`)
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Send one notification (no reply is expected).
|
|
191
|
+
* @param {string} method - the JSON-RPC method.
|
|
192
|
+
* @param {Record<string, unknown>} [params] - its parameters.
|
|
193
|
+
* @returns {void}
|
|
194
|
+
*/
|
|
195
|
+
notify(method, params = {}) {
|
|
196
|
+
if (this.closed) return
|
|
197
|
+
this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method, params })}\n`)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Perform the MCP handshake.
|
|
202
|
+
* @param {string} [clientName] - the name to announce.
|
|
203
|
+
* @returns {Promise<{protocolVersion: string, serverInfo: {name: string, version: string}, instructions?: string, capabilities: Record<string, unknown>}>} the server's answer.
|
|
204
|
+
*/
|
|
205
|
+
async initialize(clientName = 'dsh-ops') {
|
|
206
|
+
const result = await this.request('initialize', {
|
|
207
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
208
|
+
capabilities: {},
|
|
209
|
+
clientInfo: { name: clientName, version: '0.1.0' },
|
|
210
|
+
})
|
|
211
|
+
this.notify('notifications/initialized', {})
|
|
212
|
+
return result
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* List the server's tools.
|
|
217
|
+
* @returns {Promise<{name: string, description?: string, inputSchema?: Record<string, unknown>, outputSchema?: Record<string, unknown>}[]>} the tools.
|
|
218
|
+
*/
|
|
219
|
+
async listTools() {
|
|
220
|
+
const result = await this.request('tools/list', {})
|
|
221
|
+
return result?.tools ?? []
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Call one tool and return its raw result.
|
|
226
|
+
* @param {string} toolName - the server's own tool name.
|
|
227
|
+
* @param {Record<string, unknown>} args - the arguments.
|
|
228
|
+
* @param {object} [options] - per-request overrides (deadline, cancellation).
|
|
229
|
+
* @returns {Promise<{content?: unknown[], isError?: boolean, structuredContent?: unknown}>} the result.
|
|
230
|
+
*/
|
|
231
|
+
async callTool(toolName, args, options = {}) {
|
|
232
|
+
return this.request('tools/call', { name: toolName, arguments: args }, options)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Stop the server and wait for the process to settle.
|
|
237
|
+
*
|
|
238
|
+
* Idempotent, and safe to call on a connection that already ended: the
|
|
239
|
+
* process is only signalled while it is still alive.
|
|
240
|
+
* @returns {Promise<void>} resolves once the child is gone.
|
|
241
|
+
*/
|
|
242
|
+
async close() {
|
|
243
|
+
if (this.closed) return
|
|
244
|
+
const exited = this.exited
|
|
245
|
+
this.child.stdin.end()
|
|
246
|
+
this.child.kill()
|
|
247
|
+
const guard = setTimeout(() => this.child.kill('SIGKILL'), 4_000)
|
|
248
|
+
guard.unref?.()
|
|
249
|
+
await exited
|
|
250
|
+
clearTimeout(guard)
|
|
251
|
+
}
|
|
252
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/** dsh-ops host half. Own registrations only; no @deepseek-ai value imports. */
|
|
2
|
+
import { PACKAGE_ROOT, probeBinary, resolveBinary } from './binary.js'
|
|
3
|
+
import { resolveConfig } from './config.js'
|
|
4
|
+
import { SECTION_ORDERS, TOOLING_SECTION, hostShellRefusal, renderToolingPolicy } from './policy.js'
|
|
5
|
+
import { resolveShells } from './shells.js'
|
|
6
|
+
import { mountSessionBash } from './session-shells.js'
|
|
7
|
+
import { FastCtxTools } from './tools.js'
|
|
8
|
+
|
|
9
|
+
export const name = 'dsh-ops'
|
|
10
|
+
function report(ctx, message, level = 'error') {
|
|
11
|
+
const logger = ctx?.logger
|
|
12
|
+
if (typeof logger?.[level] === 'function') logger[level](message)
|
|
13
|
+
else if (level === 'warn') console.warn(message)
|
|
14
|
+
else console.error(message)
|
|
15
|
+
}
|
|
16
|
+
function visibleTools(ctx, agent) {
|
|
17
|
+
try {
|
|
18
|
+
return (ctx.get('tools')?.schemas(agent) ?? []).map(schema => schema.name)
|
|
19
|
+
.filter(tool => typeof tool === 'string')
|
|
20
|
+
} catch { return [] }
|
|
21
|
+
}
|
|
22
|
+
function prepareRuntime(ctx, config) {
|
|
23
|
+
try {
|
|
24
|
+
const resolved = resolveBinary({ binaryPath: config.binaryPath, packageRoot: PACKAGE_ROOT })
|
|
25
|
+
const probe = probeBinary(resolved.file)
|
|
26
|
+
if (!probe.ok) throw new Error(`Unusable FastCtx at ${resolved.file}: ${probe.detail}`)
|
|
27
|
+
return { file: resolved.file, source: resolved.source, version: probe.version }
|
|
28
|
+
} catch (error) {
|
|
29
|
+
report(ctx, `dsh-ops: ${error.message}`)
|
|
30
|
+
if (config.required) throw error
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Explicit host-shell denial is independent of whether ops commands exist. */
|
|
35
|
+
function installHostShellDenial(ctx, config) {
|
|
36
|
+
const denied = new Set(config.deniedHostTools)
|
|
37
|
+
const lifts = new Map()
|
|
38
|
+
const hide = agent => {
|
|
39
|
+
if (lifts.has(agent) || !agent?.ctx) return
|
|
40
|
+
try {
|
|
41
|
+
const names = (ctx.get('tools').schemas(agent) ?? []).map(tool => tool.name).filter(name => denied.has(name))
|
|
42
|
+
if (names.length) lifts.set(agent, agent.ctx.tools.restrict({ deny: names }))
|
|
43
|
+
} catch (error) {
|
|
44
|
+
report(ctx, `dsh-ops: host shell visibility restriction unavailable: ${error.message}; calls remain denied.`, 'warn')
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
ctx.on('agent/created', ({ agent }) => hide(agent))
|
|
48
|
+
ctx.on('agent/disposed', ({ agent }) => { lifts.get(agent)?.(); lifts.delete(agent) })
|
|
49
|
+
for (const agent of ctx.get('agents')?.list?.() ?? []) hide(agent)
|
|
50
|
+
ctx.effect(() => () => { for (const lift of lifts.values()) lift(); lifts.clear() }, 'dsh-ops: host shell restrictions')
|
|
51
|
+
ctx.on('tools/pre-execute', (exec, next) => hostShellRefusal({ toolName: String(exec?.name ?? ''), denied }) ?? next())
|
|
52
|
+
if (typeof ctx.tools.guard === 'function') {
|
|
53
|
+
ctx.effect(() => ctx.tools.guard(exec => hostShellRefusal({ toolName: String(exec?.name ?? ''), denied })?.reason), 'dsh-ops: host shell guard')
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function mountServer(ctx, config, runtime) {
|
|
58
|
+
if (!runtime) return
|
|
59
|
+
const surface = new FastCtxTools({ ctx, config, runtime, report: (message, level) => report(ctx, message, level) })
|
|
60
|
+
ctx.effect(() => () => surface.stop(), 'dsh-ops: fastctx server')
|
|
61
|
+
ctx.on('agent/created', ({ agent }) => surface.attachAgent(agent))
|
|
62
|
+
ctx.on('agent/disposed', ({ agent }) => surface.detachAgent(agent))
|
|
63
|
+
ctx.on('session/event', (session, event) => {
|
|
64
|
+
if (event.type === 'sandbox/mode') surface.refreshAgents(session)
|
|
65
|
+
})
|
|
66
|
+
// Authority may mount after the tools service or be hot-reloaded. Losing it
|
|
67
|
+
// immediately retires every command layer; it never falls back to env state.
|
|
68
|
+
surface.authorityAvailable = false
|
|
69
|
+
ctx.inject(['sandboxPolicy'], authorityCtx => {
|
|
70
|
+
surface.authorityAvailable = true
|
|
71
|
+
surface.refreshAgents()
|
|
72
|
+
authorityCtx.effect(() => () => {
|
|
73
|
+
surface.authorityAvailable = false
|
|
74
|
+
surface.refreshAgents()
|
|
75
|
+
}, 'dsh-ops: permission authority')
|
|
76
|
+
})
|
|
77
|
+
for (const agent of ctx.get('agents')?.list?.() ?? []) surface.attachAgent(agent)
|
|
78
|
+
try {
|
|
79
|
+
const tools = await surface.start()
|
|
80
|
+
report(ctx, `dsh-ops: FastCtx ${runtime.version} ready; ${tools.length} file tools, session-authorized command layers.`, 'info')
|
|
81
|
+
} catch (error) {
|
|
82
|
+
report(ctx, `dsh-ops: FastCtx unavailable: ${error.message}`)
|
|
83
|
+
if (config.required) throw error
|
|
84
|
+
surface.retryLater()
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function apply(root, rawConfig) {
|
|
89
|
+
const config = resolveConfig(rawConfig)
|
|
90
|
+
const runtime = prepareRuntime(root, config)
|
|
91
|
+
const shells = resolveShells(config)
|
|
92
|
+
if (config.promptPolicy) root.inject(['systemPrompt'], ctx => {
|
|
93
|
+
ctx.effect(() => ctx.systemPrompt.section({
|
|
94
|
+
name: TOOLING_SECTION,
|
|
95
|
+
order: SECTION_ORDERS[TOOLING_SECTION],
|
|
96
|
+
interpolate: false,
|
|
97
|
+
text: context => renderToolingPolicy({
|
|
98
|
+
published: visibleTools(ctx, context.agent), pwsh7: shells.pwsh.available,
|
|
99
|
+
extraGuidance: config.extraGuidance,
|
|
100
|
+
}),
|
|
101
|
+
}), 'dsh-ops: repository tooling')
|
|
102
|
+
})
|
|
103
|
+
root.inject(['tools'], ctx => {
|
|
104
|
+
mountSessionBash(ctx, config, shells)
|
|
105
|
+
if (config.shellPolicy === 'deny-host-shell') installHostShellDenial(ctx, config)
|
|
106
|
+
return mountServer(ctx, config, runtime)
|
|
107
|
+
})
|
|
108
|
+
}
|
package/lib/jobs.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** Plugin-side projections over FastCtx's text protocol. Never adopt foreign IDs. */
|
|
2
|
+
const ID = 'j-[a-z0-9]{6}'
|
|
3
|
+
const ENTRY = new RegExp(`^(${ID}) (?:running \\d+(?:s|m\\d+s|h\\d+m)|exited -?\\d+|killed|interrupted)$`)
|
|
4
|
+
|
|
5
|
+
export function startedJob(value) {
|
|
6
|
+
for (const block of value.content ?? []) {
|
|
7
|
+
if (block.type !== 'text') continue
|
|
8
|
+
const match = /(?:^|\n)\(Complete: job (j-[a-z0-9]{6}) started; log at [^\n]*\.\)\s*$/.exec(block.text)
|
|
9
|
+
if (match) return match[1]
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Only touch the final server decoration, never arbitrary lines in command output. */
|
|
14
|
+
export function cleanBackground(value, owned) {
|
|
15
|
+
const content = [...value.content]
|
|
16
|
+
const index = content.findLastIndex(block => block?.type === 'text')
|
|
17
|
+
if (index < 0) return value
|
|
18
|
+
const block = content[index]
|
|
19
|
+
const text = block.text
|
|
20
|
+
if (typeof text !== 'string') return value
|
|
21
|
+
const match = /(?:^|\n)\(Background: ([^\n]*)\.\)(?=\s*(?:\((?:Complete|Partial|Killed):[^\n]*\))?\s*$)/.exec(text)
|
|
22
|
+
if (!match) return value
|
|
23
|
+
const entries = match[1].split(', ')
|
|
24
|
+
if (!entries.every(entry => ENTRY.test(entry)) && !/^\d+ jobs? running$/.test(match[1])) return value
|
|
25
|
+
const kept = entries.filter(entry => {
|
|
26
|
+
const parsed = ENTRY.exec(entry)
|
|
27
|
+
return parsed && owned.has(parsed[1])
|
|
28
|
+
})
|
|
29
|
+
const replacement = kept.length ? `${match[0].startsWith('\n') ? '\n' : ''}(Background: ${kept.join(', ')}.)` : ''
|
|
30
|
+
content[index] = { ...block, text: text.slice(0, match.index) + replacement + text.slice(match.index + match[0].length) }
|
|
31
|
+
return { ...value, content }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Parse only the upstream job-list entry boundaries, not arbitrary output text. */
|
|
35
|
+
export function jobEntries(value) {
|
|
36
|
+
const text = value.content.filter(block => block?.type === 'text').map(block => block.text).join('\n')
|
|
37
|
+
const entries = []
|
|
38
|
+
const regex = /(?:^|\n\n)(j-[a-z0-9]{6}) (running|killed|exited -?\d+|interrupted); started ([^\n]*)\n ([^\n]*)/g
|
|
39
|
+
for (const match of text.matchAll(regex)) entries.push({ id: match[1], status: match[2], text: match[0].trimStart() })
|
|
40
|
+
const next = /\(Partial:[^\n]*\boffset[=: ]+(\d+)/.exec(text)
|
|
41
|
+
return { entries, next: next ? Number(next[1]) : undefined, partial: /(?:^|\n)\(Partial:/.test(text) }
|
|
42
|
+
}
|
package/lib/policy.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** Model-facing routing, kept in one section. No shared registry mutation. */
|
|
2
|
+
export const TOOLING_SECTION = 'dsh-ops:repository-tooling'
|
|
3
|
+
// Legacy section name kept for consumers; the plugin no longer publishes it.
|
|
4
|
+
export const HOST_SHELL_SECTION = 'dsh-ops:host-shell-policy'
|
|
5
|
+
export const SECTION_ORDERS = Object.freeze({ [TOOLING_SECTION]: 3250 })
|
|
6
|
+
export const TOOL_PREFIX = 'ops_'
|
|
7
|
+
export const FILE_TOOLS = Object.freeze(['inspect_local_file', 'grep', 'glob', 'replace'])
|
|
8
|
+
export const SHELL_TOOLS = Object.freeze(['run', 'run_background', 'job_output', 'job_list', 'job_kill'])
|
|
9
|
+
// The plugin's second layer executes POSIX commands; pwsh7 uses the host tool.
|
|
10
|
+
export const IN_PROCESS_TOOLS = Object.freeze(['bash'])
|
|
11
|
+
export function publicToolName(rawName) { return `${TOOL_PREFIX}${rawName}` }
|
|
12
|
+
export const BASH_TOOL = publicToolName('bash')
|
|
13
|
+
|
|
14
|
+
/** Resolve the actual command capability, not the presence of a shell executable. */
|
|
15
|
+
export function ladderLevels({ published, pwsh = false }) {
|
|
16
|
+
const names = new Set(published)
|
|
17
|
+
return {
|
|
18
|
+
fastctx: FILE_TOOLS.some(name => names.has(publicToolName(name))),
|
|
19
|
+
run: names.has(publicToolName('run')),
|
|
20
|
+
bash: names.has(BASH_TOOL),
|
|
21
|
+
pwsh: pwsh === true && names.has('pwsh'),
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A single compact table; absent tools never get advertised. */
|
|
26
|
+
export function renderToolingPolicy({ published, enableShellTools, mounted, levels, pwsh7 = false, extraGuidance = '' }) {
|
|
27
|
+
const names = new Set(published ?? (mounted || levels?.fastctx
|
|
28
|
+
? [...FILE_TOOLS, ...(enableShellTools ? SHELL_TOOLS : []), ...(levels?.bash ? IN_PROCESS_TOOLS : [])].map(publicToolName) : []))
|
|
29
|
+
if (levels?.pwsh) names.add('pwsh')
|
|
30
|
+
if (!FILE_TOOLS.some(name => names.has(publicToolName(name))) && !names.has(BASH_TOOL)) return ''
|
|
31
|
+
const lines = ['# Repository operations: tools → bash → PowerShell 7', '', '| Task | Tool |', '| --- | --- |']
|
|
32
|
+
const row = (raw, task) => {
|
|
33
|
+
if (names.has(publicToolName(raw))) lines.push(`| ${task} | ${publicToolName(raw)} |`)
|
|
34
|
+
}
|
|
35
|
+
row('inspect_local_file', 'Text / multiple ranges / encoding / PDF text / hex')
|
|
36
|
+
lines.push('| Images | read_image (host) |')
|
|
37
|
+
row('grep', 'Search contents')
|
|
38
|
+
row('glob', 'Find paths')
|
|
39
|
+
row('replace', 'Batch replacement; use host edit for precise edits')
|
|
40
|
+
row('bash', 'General commands / builds / git / pipelines / scripts — preferred command executor')
|
|
41
|
+
if (names.has('pwsh')) lines.push(`| Windows-native cmdlets / registry / services only | pwsh (${pwsh7 || levels?.pwsh ? 'bundled PowerShell 7 via host' : 'host PowerShell'}) |`)
|
|
42
|
+
row('run', 'Bounded bash command result (full access only)')
|
|
43
|
+
if (names.has(publicToolName('run_background'))) {
|
|
44
|
+
lines.push(`| Background commands / owned jobs | ${SHELL_TOOLS.slice(1).map(publicToolName).join(', ')} |`)
|
|
45
|
+
}
|
|
46
|
+
lines.push('', '- Do not build shell command lines for file operations.',
|
|
47
|
+
'- On a tool error, correct the arguments and retry; do not switch to a shell workaround.')
|
|
48
|
+
if (names.has(BASH_TOOL)) lines.push('- Prefer bash for command execution. Use PowerShell only when Windows-native functionality is necessary; fix bash errors in bash, do not switch shells or mix their syntax.')
|
|
49
|
+
if (names.has(publicToolName('run_background'))) lines.push('- Only operate on job IDs you started in this session.')
|
|
50
|
+
if (extraGuidance.trim()) lines.push('', extraGuidance.trim())
|
|
51
|
+
return lines.join('\n')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Legacy API: no second prompt section. */
|
|
55
|
+
export function renderHostShellPolicy() { return '' }
|
|
56
|
+
|
|
57
|
+
/** Explicit deny policy applies even without an ops command capability. */
|
|
58
|
+
export function hostShellEnforcement({ shellPolicy }) {
|
|
59
|
+
return { hide: shellPolicy === 'deny-host-shell', reason: undefined }
|
|
60
|
+
}
|
|
61
|
+
export function hostShellRefusal({ toolName, denied }) {
|
|
62
|
+
if (!denied.has(toolName)) return undefined
|
|
63
|
+
return { kind: 'deny', reason: `Host tool ${toolName} is disabled by dsh-ops shellPolicy. Use an available file tool for file operations; otherwise request an operator policy change.` }
|
|
64
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** Compact presentation only; arguments are forwarded unchanged. */
|
|
2
|
+
const DESCRIPTIONS = {
|
|
3
|
+
inspect_local_file: 'Read text ranges (prefer files[] batches, up to 32), known encodings, PDF text or hex. Images: use host read_image.',
|
|
4
|
+
grep: 'Search contents with ripgrep; glob[] supports batched filters and ! exclusions. Use summary/count/files_with_matches to reduce output.',
|
|
5
|
+
glob: 'Find paths. Prefer pattern[] batches with ! exclusions; choose paths or details output.',
|
|
6
|
+
replace: 'Mechanical replacement across files; use host edit for precise changes. Check dry-run results before applying.',
|
|
7
|
+
run: 'Run a bash command (not PowerShell), returning bounded output and exit status.',
|
|
8
|
+
run_background: 'Start a bash job; keep the returned job ID for output and termination in this session.',
|
|
9
|
+
job_output: 'Read output of a job started in this session; use after_seq for explicit continuation.',
|
|
10
|
+
job_list: 'List only jobs started in this session and connection; status and pagination apply to owned jobs.',
|
|
11
|
+
job_kill: 'Terminate the process tree of a job started in this session.',
|
|
12
|
+
}
|
|
13
|
+
const FIELDS = {
|
|
14
|
+
file_path: 'Absolute local path, not a URI; exclusive with files.',
|
|
15
|
+
path: 'Absolute local path, not a URI; omit for connection directory where optional. Reuse escaped filenames verbatim.',
|
|
16
|
+
fallback_encoding: 'Directory fallback for undecodable files; never overrides BOM or valid UTF-8.',
|
|
17
|
+
filter_mode: 'ignore respects .ignore only; all disables filtering. Both include hidden/.git files; neither reads Git ignores.',
|
|
18
|
+
output_mode: 'Select the output shape; grep defaults to files_with_matches, glob to paths. Grep summary ignores paging.',
|
|
19
|
+
head_limit: 'Maximum output entries; 0 removes this limit, not the token budget.',
|
|
20
|
+
timeout_ms: 'Command process-tree timeout in milliseconds, max 240000.',
|
|
21
|
+
cwd: 'Absolute working directory; omit for the FastCtx connection directory (not the calling session).',
|
|
22
|
+
encoding: 'Source encoding label (e.g. gbk); omit for detection. Output is UTF-8.',
|
|
23
|
+
files: 'Prefer batches: 1-32 text ranges, in order; exclusive with file_path and top-level offset/encoding/pages/pdf_mode/view. Top-level limit is a default.',
|
|
24
|
+
offset: 'Continuation offset; use the returned paging marker.',
|
|
25
|
+
limit: 'Maximum results/lines; read batch entries override the top-level default.',
|
|
26
|
+
pages: 'PDF page range, e.g. 1-5; max 20 pages. Required for text PDFs over 10 pages.',
|
|
27
|
+
pdf_mode: 'PDF text layer only; rendered images are unsupported.',
|
|
28
|
+
view: 'auto selects text/PDF; hex inspects raw bytes of any file.',
|
|
29
|
+
glob: 'Prefer glob[] batches; ! excludes paths.',
|
|
30
|
+
context: 'Content mode: lines before and after each match.',
|
|
31
|
+
job_id: 'ID returned by your own run_background in this session.',
|
|
32
|
+
wait_ms: 'Wait milliseconds, max 240000; default 30000.',
|
|
33
|
+
after_seq: 'Output sequence cursor; omit to use this connection\'s cursor.',
|
|
34
|
+
command: 'Bash command, not PowerShell syntax.',
|
|
35
|
+
login_shell: 'Use bash login shell; default true.',
|
|
36
|
+
}
|
|
37
|
+
function shorten(schema, field, tool) {
|
|
38
|
+
if (Array.isArray(schema)) return schema.map(value => shorten(value, field, tool))
|
|
39
|
+
if (!schema || typeof schema !== 'object') return schema
|
|
40
|
+
const result = {}
|
|
41
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
42
|
+
if (key === 'description') {
|
|
43
|
+
result[key] = field === 'pattern'
|
|
44
|
+
? (tool === 'grep' || tool === 'replace' ? 'Rust regex; escape literal braces. One pattern per call.' : 'Prefer pattern[] batches; ! excludes paths.')
|
|
45
|
+
: field === 'offset' ? (tool === 'inspect_local_file' ? 'Start at this 1-based line; use for continuation.' : 'Skip this many results (0-based); use for continuation.')
|
|
46
|
+
: field === 'encoding' && tool === 'grep' ? 'Single-file encoding label (e.g. gbk); directories use fallback_encoding.'
|
|
47
|
+
: FIELDS[field] ?? String(value).replace(/\s+/g, ' ').trim()
|
|
48
|
+
} else if (key === 'properties') {
|
|
49
|
+
result[key] = Object.fromEntries(Object.entries(value).map(([name, child]) => [name, shorten(child, name, tool)]))
|
|
50
|
+
} else result[key] = shorten(value, field, tool)
|
|
51
|
+
}
|
|
52
|
+
return result
|
|
53
|
+
}
|
|
54
|
+
export function projectTool(tool) {
|
|
55
|
+
const inputSchema = shorten(tool.inputSchema ?? { type: 'object', properties: {} }, '', tool.name)
|
|
56
|
+
if (tool.name === 'grep') {
|
|
57
|
+
// Asymmetric context remains accepted by FastCtx but is not advertised.
|
|
58
|
+
delete inputSchema.properties?.before_context
|
|
59
|
+
delete inputSchema.properties?.after_context
|
|
60
|
+
}
|
|
61
|
+
if (tool.name === 'inspect_local_file' && inputSchema.properties?.pdf_mode) inputSchema.properties.pdf_mode.enum = ['text']
|
|
62
|
+
return { ...tool, description: DESCRIPTIONS[tool.name] ?? tool.description ?? '', inputSchema }
|
|
63
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** Profile operations delegate to the official CLI instead of reimplementing its locks and rollback. */
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import { createRequire } from 'node:module'
|
|
6
|
+
import { spawnSync } from 'node:child_process'
|
|
7
|
+
import { PACKAGE_ROOT, dshHome } from './binary.js'
|
|
8
|
+
|
|
9
|
+
export function profileCommand(operation, { profile, dshCli, dryRun = false }) {
|
|
10
|
+
if (process.platform !== 'win32' || process.arch !== 'x64') throw new Error('Only Windows x64 is supported.')
|
|
11
|
+
const aliases = { desktop: 'desktop', web: 'web', tui: 'dsh-tui', 'dsh-tui': 'dsh-tui' }
|
|
12
|
+
if (!Object.hasOwn(aliases, profile ?? '')) throw new Error('Explicit --profile desktop|web|tui is required (tui maps to dsh-tui).')
|
|
13
|
+
const target = aliases[profile]
|
|
14
|
+
const directory = path.join(dshHome(), 'profiles', target)
|
|
15
|
+
if (!fs.existsSync(path.join(directory, 'package.json'))) throw new Error(`Initialize the target DSH application/profile first: ${directory}`)
|
|
16
|
+
const version = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8')).version
|
|
17
|
+
if (operation === 'status') {
|
|
18
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(directory, 'package.json'), 'utf8'))
|
|
19
|
+
console.log(JSON.stringify({ profile: target, directory, dependency: manifest.dependencies?.['dsh-ops'] ?? null, enabled: (manifest.dsh?.profile?.bundles ?? []).includes('dsh-ops') }, null, 2))
|
|
20
|
+
return 0
|
|
21
|
+
}
|
|
22
|
+
const args = ['plugin', '--profile', target, operation === 'install' ? 'add' : 'remove', operation === 'install' ? `dsh-ops@${version}` : 'dsh-ops']
|
|
23
|
+
let command = dshCli ?? process.env.DSH_OPS_DSH_CLI
|
|
24
|
+
let prefix = []
|
|
25
|
+
if (!command && target === 'desktop') {
|
|
26
|
+
const local = process.env.LOCALAPPDATA ?? path.join(os.homedir(), 'AppData/Local')
|
|
27
|
+
command = [path.join(local, 'Programs/DeepSeek Harness/resources/runtime/cli/bin/dsh.cmd')].find(file => fs.existsSync(file))
|
|
28
|
+
if (!command) throw new Error('Desktop requires its own CLI. Pass --dsh-cli <installation>/resources/runtime/cli/bin/dsh.cmd; ordinary dsh cannot manage desktop.')
|
|
29
|
+
}
|
|
30
|
+
if (!command) {
|
|
31
|
+
const globalNpm = process.env.APPDATA ? path.join(process.env.APPDATA, 'npm/node_modules/_anchor.json') : undefined
|
|
32
|
+
for (const anchor of [path.join(directory, 'package.json'), path.join(dshHome(), 'package.json'), globalNpm].filter(Boolean)) {
|
|
33
|
+
try {
|
|
34
|
+
const require = createRequire(anchor)
|
|
35
|
+
const file = require.resolve('@deepseek-ai/dsh/package.json')
|
|
36
|
+
const manifest = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
37
|
+
const bin = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.dsh
|
|
38
|
+
if (bin) { command = process.execPath; prefix = [path.resolve(path.dirname(file), bin)]; break }
|
|
39
|
+
} catch {}
|
|
40
|
+
}
|
|
41
|
+
if (!command) {
|
|
42
|
+
const desktopCli = path.join(process.env.LOCALAPPDATA ?? path.join(os.homedir(), 'AppData/Local'), 'Programs/DeepSeek Harness/resources/runtime/cli/bin/dsh.cmd')
|
|
43
|
+
command = fs.existsSync(desktopCli) ? desktopCli : 'dsh.cmd'
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
console.log(`Target: ${target} (${directory})`)
|
|
47
|
+
console.log(`Official CLI: ${command}; ${args.join(' ')}`)
|
|
48
|
+
if (dryRun) return 0
|
|
49
|
+
let result
|
|
50
|
+
if (/\.cmd$/i.test(command)) {
|
|
51
|
+
// cmd wrappers are Windows-owned launchers; reject shell metacharacters in caller-supplied paths.
|
|
52
|
+
if (/["\r\n&|<>^%!]/.test(command)) throw new Error('Unsafe CLI path; use a path without cmd metacharacters.')
|
|
53
|
+
result = spawnSync(process.env.ComSpec ?? 'cmd.exe', ['/d', '/s', '/c', `""${command}" ${args.join(' ')}"`], { stdio: 'inherit', windowsHide: true })
|
|
54
|
+
} else result = spawnSync(command, [...prefix, ...args], { stdio: 'inherit', windowsHide: true })
|
|
55
|
+
if (result.error) throw result.error
|
|
56
|
+
if (result.status !== 0) return result.status ?? 1
|
|
57
|
+
console.log(operation === 'install'
|
|
58
|
+
? 'Installed through the official profile manager. Reload/restart the target application if needed. Binaries are profile dependencies; no postinstall downloads.'
|
|
59
|
+
: 'Removed through the official profile manager, including its runtime dependency references. Package-manager shared caches are not deleted.')
|
|
60
|
+
return 0
|
|
61
|
+
}
|