dsh-winrm 0.3.1 → 0.4.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.
@@ -113,6 +113,28 @@ export declare const zh: {
113
113
  readonly 'transfer.mib': "MB";
114
114
  readonly 'transfer.dir': "目录";
115
115
  readonly 'transfer.file': "文件";
116
+ readonly 'tool.read': "读取远程文件";
117
+ readonly 'tool.write': "写入远程文件";
118
+ readonly 'tool.edit': "编辑远程文件";
119
+ readonly 'tool.running': "执行中…";
120
+ readonly 'tool.noDetail': "无卡片数据";
121
+ readonly 'tool.created': "新建 {bytes} 字节";
122
+ readonly 'tool.updated': "覆盖 {bytes} 字节";
123
+ readonly 'tool.replacements': "替换 {count} 处";
124
+ readonly 'tool.beyondBasis': "文件超过 diff 体积上限,仅显示路径";
125
+ readonly 'tool.truncatedByBytes': "已按字节上限截断";
126
+ readonly 'tool.truncatedLines': "{count} 行过长已截断";
127
+ readonly 'tool.codeLabel': "代码";
128
+ readonly 'tool.wrapLabel': "自动换行";
129
+ readonly 'tool.unwrapLabel': "取消自动换行";
130
+ readonly 'tool.window': "第 {shown} / {total} 行";
131
+ readonly 'tool.lines': "共 {count} 行";
132
+ readonly 'tool.copy': "复制";
133
+ readonly 'tool.copied': "已复制";
134
+ readonly 'tool.collapseAria': "收起";
135
+ readonly 'tool.expandAria': "展开剩余 {hidden} 行";
136
+ readonly 'tool.collapse': "收起";
137
+ readonly 'tool.expand': "展开其余 {hidden} 行";
116
138
  };
117
139
  export declare const en: {
118
140
  readonly 'entry.label': "Windows";
@@ -225,6 +247,28 @@ export declare const en: {
225
247
  readonly 'transfer.mib': "MB";
226
248
  readonly 'transfer.dir': "dir";
227
249
  readonly 'transfer.file': "file";
250
+ readonly 'tool.read': "Read remote file";
251
+ readonly 'tool.write': "Write remote file";
252
+ readonly 'tool.edit': "Edit remote file";
253
+ readonly 'tool.running': "Working…";
254
+ readonly 'tool.noDetail': "No card data";
255
+ readonly 'tool.created': "Created {bytes} bytes";
256
+ readonly 'tool.updated': "Overwrote {bytes} bytes";
257
+ readonly 'tool.replacements': "{count} replacements";
258
+ readonly 'tool.beyondBasis': "File is past the diff size cap; showing the path only";
259
+ readonly 'tool.truncatedByBytes': "Cut at the byte budget";
260
+ readonly 'tool.truncatedLines': "{count} long lines truncated";
261
+ readonly 'tool.codeLabel': "Code";
262
+ readonly 'tool.wrapLabel': "Wrap lines";
263
+ readonly 'tool.unwrapLabel': "Do not wrap";
264
+ readonly 'tool.window': "{shown} of {total} lines";
265
+ readonly 'tool.lines': "{count} lines";
266
+ readonly 'tool.copy': "Copy";
267
+ readonly 'tool.copied': "Copied";
268
+ readonly 'tool.collapseAria': "Collapse";
269
+ readonly 'tool.expandAria': "Expand the remaining {hidden} lines";
270
+ readonly 'tool.collapse': "Collapse";
271
+ readonly 'tool.expand': "Show the remaining {hidden} lines";
228
272
  };
229
273
  export type WinKey = keyof typeof zh;
230
274
  /** Translate a key in one dictionary with optional {name} params. */
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Card models for the three remote file tools.
3
+ *
4
+ * The host projects a small JSON payload through `output.presentationMeta` and
5
+ * the session log persists it; these functions narrow that unknown payload into
6
+ * something the card components can hand to the harness's `ReadBlock` and
7
+ * `DiffBlock` primitives. Kept apart from the components so the narrowing can be
8
+ * unit tested without a DOM.
9
+ * @module dsh-winrm/src/client/tool/card-model
10
+ */
11
+ /** One line of a read window. */
12
+ export interface ReadCardLine {
13
+ number: number;
14
+ text: string;
15
+ }
16
+ /** A completed `winrm_read`: a line-numbered window of a remote file. */
17
+ export interface ReadCard {
18
+ kind: 'read';
19
+ path: string;
20
+ offset: number;
21
+ lines: ReadCardLine[];
22
+ totalLines: number;
23
+ encoding: string;
24
+ truncatedByBytes: boolean;
25
+ truncatedLines: number;
26
+ }
27
+ /** One hunk of a remote write or edit, in the primitive's own shape. */
28
+ export interface DiffHunk {
29
+ /** Prior fragment including shared context; `''` when the hunk had no prior content. */
30
+ oldText: string;
31
+ /** Resulting fragment including the same shared context. */
32
+ newText: string;
33
+ }
34
+ /** A completed `winrm_write` or `winrm_edit`: the changes it applied. */
35
+ export interface DiffCard {
36
+ kind: 'diff';
37
+ /** Which tool produced it, so the header can name the operation. */
38
+ tool: 'write' | 'edit';
39
+ path: string;
40
+ operation: 'create' | 'update';
41
+ bytes: number;
42
+ size: number;
43
+ replacements: number;
44
+ encoding: string;
45
+ hunks: DiffHunk[];
46
+ /** True when the previous text was past the basis cap, so no hunks exist. */
47
+ beyondBasis: boolean;
48
+ }
49
+ /** A failed call: keeps the path and the message, drops card-specific detail. */
50
+ export interface ErrorCard {
51
+ kind: 'error';
52
+ path: string;
53
+ message: string;
54
+ }
55
+ /** Any card these tools render. */
56
+ export type FileCard = ReadCard | DiffCard | ErrorCard;
57
+ /**
58
+ * Narrow a `winrm_read` metadata payload into a card.
59
+ * @param meta - the persisted payload, of unknown shape.
60
+ * @param fallbackPath - the authored path, used when the payload omits one.
61
+ * @returns the card, or `undefined` when the payload is not a read card.
62
+ */
63
+ export declare function readCard(meta: unknown, fallbackPath: string): FileCard | undefined;
64
+ /**
65
+ * Narrow a `winrm_write` / `winrm_edit` metadata payload into a card.
66
+ * @param meta - the persisted payload, of unknown shape.
67
+ * @param fallbackPath - the authored path, used when the payload omits one.
68
+ * @param tool - which tool produced the payload.
69
+ * @returns the card, or `undefined` when the payload belongs to another card.
70
+ */
71
+ export declare function diffCard(meta: unknown, fallbackPath: string, tool: 'write' | 'edit'): FileCard | undefined;
@@ -0,0 +1,9 @@
1
+ import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client';
2
+ /** The Tool view props these cards read. */
3
+ export type WinrmFileCardProps = ToolCallViewProps;
4
+ /** Card for `winrm_read`. */
5
+ export declare function WinrmReadCard(props: WinrmFileCardProps): import("react").JSX.Element;
6
+ /** Card for `winrm_write`. */
7
+ export declare function WinrmWriteCard(props: WinrmFileCardProps): import("react").JSX.Element;
8
+ /** Card for `winrm_edit`. */
9
+ export declare function WinrmEditCard(props: WinrmFileCardProps): import("react").JSX.Element;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Contextual diff derivation for the write/edit presentation metadata.
3
+ *
4
+ * The GUI renders a diff card from `tool/result` metadata rather than from the
5
+ * model-facing text, so the hunks are projected here, at result time, and
6
+ * persisted with the session log. This mirrors the harness's own file tools:
7
+ * one entry per applied change with a few surrounding context lines.
8
+ *
9
+ * The alignment is deliberately cheap rather than a full LCS: common leading
10
+ * and trailing lines are trimmed, and when the remaining halves have the same
11
+ * length each differing run becomes its own hunk. That covers the cases these
12
+ * two tools actually produce — an exact-string edit, and a rewrite that keeps
13
+ * most lines — without a diff dependency in a plugin whose point is a small
14
+ * footprint. A rewrite that changes the line count collapses to one hunk over
15
+ * the changed span, which is still a truthful summary.
16
+ * @module dsh-winrm/src/diff
17
+ */
18
+ /** Context lines shown on each side of an applied hunk. */
19
+ export declare const DIFF_CONTEXT = 3;
20
+ /**
21
+ * Largest file whose text may serve as a diff basis. Above this the card shows
22
+ * the path and operation without hunks: fetching and projecting a bigger basis
23
+ * would cost another transfer and a large metadata payload for a summary the
24
+ * user reads at a glance.
25
+ */
26
+ export declare const DIFF_BASIS_MAX_BYTES: number;
27
+ /**
28
+ * Largest serialized hunk list kept on one result. A pathological edit (a
29
+ * whole-file rewrite of a large file) can produce thousands of hunks; past
30
+ * this bound the card degrades to path-only instead of bloating the log.
31
+ */
32
+ export declare const DIFF_META_MAX_BYTES: number;
33
+ /**
34
+ * One applied hunk: the fragment before the change and after it.
35
+ *
36
+ * Declared as a type alias rather than an interface so it satisfies the
37
+ * `JsonValue` index signature the tool-output metadata is typed against.
38
+ */
39
+ export type RemoteFileDiff = {
40
+ /** Remote path the hunk belongs to (the authored argument). */
41
+ path: string;
42
+ /** Prior content including context lines; empty for a pure insertion. */
43
+ oldText: string;
44
+ /** Content after the change, including the shared context lines. */
45
+ newText: string;
46
+ };
47
+ /**
48
+ * Compute the hunks between two texts.
49
+ * @param path - remote path stamped on every hunk.
50
+ * @param before - file text before the change.
51
+ * @param after - file text after the change.
52
+ * @returns one hunk per contiguous change in file order; empty when equal.
53
+ */
54
+ export declare function computeHunkDiffs(path: string, before: string, after: string): RemoteFileDiff[];
55
+ /**
56
+ * Compute hunks and drop them when the serialized payload would exceed the cap.
57
+ * @param path - remote path stamped on every hunk.
58
+ * @param before - prior text.
59
+ * @param after - text after the change.
60
+ * @returns the kept hunks; empty when nothing changed or the payload is too big.
61
+ */
62
+ export declare function boundedHunkDiffs(path: string, before: string, after: string): RemoteFileDiff[];
@@ -5,10 +5,18 @@
5
5
  * (client, console); this class composes them behind one WinRmEngine instance
6
6
  * per plugin apply.
7
7
  */
8
- import type { ClusterResult, ExecResult, ProcessInfo, RemoteDirEntry, ServiceInfo, TestResult, TransferProgress, WinHostSummary } from './protocol.ts';
8
+ import type { ClusterResult, ExecResult, ProcessInfo, RemoteDirEntry, RemoteEditOutcome, RemoteTextEncoding, RemoteTextWindow, RemoteWriteOutcome, ServiceInfo, TestResult, TransferProgress, WinHostSummary } from './protocol.ts';
9
9
  import type { HostStore } from './store.ts';
10
10
  import { type TransferChannel } from './smb.ts';
11
11
  import { ConsoleSession } from './engine/console.ts';
12
+ /** Longest single line the reader returns before cutting it (characters). */
13
+ export declare const READ_MAX_LINE_LENGTH = 2000;
14
+ /** Lines the reader returns when the caller does not choose a page size. */
15
+ export declare const READ_DEFAULT_LIMIT = 2000;
16
+ /** Most lines the reader will return in one call. */
17
+ export declare const READ_MAX_LIMIT = 5000;
18
+ /** Byte budget for one read window; keeps the envelope well under the transport limit. */
19
+ export declare const READ_MAX_BYTES: number;
12
20
  /** The engine. One instance per plugin apply; dispose() clears nothing pooled (WinRM is per-call HTTP). */
13
21
  export declare class WinRmEngine {
14
22
  readonly store: HostStore;
@@ -39,6 +47,48 @@ export declare class WinRmEngine {
39
47
  killProcess(alias: string, id: number): Promise<void>;
40
48
  /** List a remote directory. */
41
49
  ls(alias: string, dir: string): Promise<RemoteDirEntry[]>;
50
+ /**
51
+ * Read a bounded, line-numbered window of a remote text file.
52
+ *
53
+ * One PowerShell round trip: the remote side decodes the file, counts every
54
+ * line, and returns only the requested window, so a large log costs one
55
+ * transfer of the window rather than of the file.
56
+ * @param alias - configured host.
57
+ * @param remotePath - path on the target.
58
+ * @param offset - 1-based first line to return.
59
+ * @param limit - maximum number of lines to return.
60
+ * @returns the window plus the file's total line count and encoding.
61
+ */
62
+ readFile(alias: string, remotePath: string, offset: number, limit: number): Promise<RemoteTextWindow>;
63
+ /**
64
+ * Write a whole remote text file, creating parent directories as needed.
65
+ *
66
+ * Reuses the transfer transport (single streamed stdin when available, the
67
+ * 48 KiB chunk path otherwise) so a large write does not pay one shell cycle
68
+ * per chunk.
69
+ * @param alias - configured host.
70
+ * @param remotePath - path on the target; overwritten when it exists.
71
+ * @param content - UTF-8 text to write.
72
+ * @param encoding - byte-order mark to emit (`utf8`, `utf8bom`, `utf16le`).
73
+ * @returns the operation kind, byte count, encoding written, and both texts
74
+ * the diff card compares.
75
+ */
76
+ writeFile(alias: string, remotePath: string, content: string, encoding: RemoteTextEncoding): Promise<RemoteWriteOutcome>;
77
+ /**
78
+ * Replace `oldString` with `newString` inside a remote text file.
79
+ *
80
+ * The replacement is literal, never a pattern, and the file's existing
81
+ * encoding is preserved. Without `replaceAll` the edit is refused unless the
82
+ * needle is unique, so a stale needle cannot rewrite the wrong region.
83
+ * @param alias - configured host.
84
+ * @param remotePath - path on the target.
85
+ * @param oldString - exact text to find.
86
+ * @param newString - text to substitute.
87
+ * @param replaceAll - replace every occurrence instead of requiring one.
88
+ * @returns the replacement count, the file's new size, and both texts the
89
+ * diff card compares.
90
+ */
91
+ editFile(alias: string, remotePath: string, oldString: string, newString: string, replaceAll: boolean): Promise<RemoteEditOutcome>;
42
92
  /** Upload one local file to a remote path. Defaults to SMB with WinRM fallback. */
43
93
  upload(alias: string, localPath: string, remotePath: string, onProgress?: (progress: TransferProgress) => void, channel?: TransferChannel): Promise<{
44
94
  bytes: number;
@@ -3,8 +3,9 @@
3
3
  * execution with a UTF-8 envelope, streaming console, service/process
4
4
  * management, streamed file transfer, cluster), the /api/dsh-winrm
5
5
  * route family plus the console WebSocket upgrade, the agent tools
6
- * (winrm_list, winrm_exec, winrm_service, winrm_process, winrm_upload,
7
- * winrm_download, winrm_cluster), and a system-prompt announcement. The
6
+ * (winrm_list, winrm_exec, winrm_service, winrm_process, winrm_read,
7
+ * winrm_write, winrm_edit, winrm_upload, winrm_download, winrm_cluster), and
8
+ * a system-prompt announcement. The
8
9
  * browser half (./client) renders the host manager and operations panel.
9
10
  * Everything rides official NPM SDK packages — no dsh source changes.
10
11
  *
@@ -34,7 +35,7 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
34
35
  enabled: z<boolean, boolean, "volatile-defined">;
35
36
  }>>, "plain">;
36
37
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
37
- export declare const WINRM_GUIDANCE = "\u672C\u673A\u5DF2\u5B89\u88C5 dsh-winrm \u63D2\u4EF6\uFF08DSH \u8FDC\u7A0B Windows \u8FD0\u7EF4\uFF0CWinRM/PowerShell Remoting\uFF09\uFF1A\u4FA7\u8FB9\u680F\u300CWindows\u300D\u5165\u53E3\uFF1B\u4EFF\u7167 dsh-ssh \u63D2\u4EF6\u5F00\u53D1\u3002\u80FD\u529B\uFF1A\u4E3B\u673A\u914D\u7F6E\u5B58 ~/.dsh/dsh-winrm.json\uFF08GUI \u914D\u7F6E\u540E agent \u65B9\u53EF\u4F7F\u7528\uFF09\uFF1Bwinrm_list \u5217\u51FA\u4E3B\u673A\u3001winrm_exec \u6267\u884C PowerShell \u547D\u4EE4\uFF08UTF-8 \u4FE1\u5C01\uFF0C\u4E2D\u6587\u4E0D\u4E71\u7801\uFF09\u3001winrm_service \u670D\u52A1\u7BA1\u7406\uFF08start/stop/restart/\u542F\u52A8\u7C7B\u578B\uFF09\u3001winrm_process \u8FDB\u7A0B\u7BA1\u7406\uFF08list/kill\uFF09\u3001winrm_upload/winrm_download \u6587\u4EF6\u4F20\u8F93\uFF08SMB \u7BA1\u7406\u5171\u4EAB\u53EF\u8FBE\u65F6\u4F18\u5148\uFF0C\u5426\u5219\u8D70 WinRM \u6D41\u5F0F\u4F20\u8F93\uFF0C\u65E0 SMB \u4F9D\u8D56\uFF0C\u5927\u6587\u4EF6\u7ECF SHA256 \u6821\u9A8C\uFF09\u3001winrm_cluster \u96C6\u7FA4\u5E76\u53D1\u6267\u884C\uFF1BWeb \u63A7\u5236\u53F0\u8D70 WebSocket\u3002\u8BA4\u8BC1\uFF1A\u901A\u8FC7\u539F\u751F Node.js WinRM \u5BA2\u6237\u7AEF\uFF08winrm-client\uFF09\u7684 NTLM/Negotiate \u4E0E Basic \u5B9E\u73B0\uFF0C\u517C\u5BB9\u672C\u5730\u8D26\u6237\u4E0E\u57DF\u8D26\u6237\uFF1B\u4F20\u8F93\u652F\u6301 http(5985)/https(5986)\u3002\u524D\u63D0\uFF1A\u76EE\u6807\u673A\u9700\u542F\u7528 WinRM\uFF08scripts/enable-winrm.ps1\uFF09\uFF0C\u672C\u673A\u65E0\u9700 Python \u2014\u2014 WinRM \u8D70\u7EAF Node \u4F9D\u8D56\u3002HTTP \u4EC5\u5EFA\u8BAE\u53D7\u4FE1\u5185\u7F51\u4F7F\u7528\uFF1B\u5BC6\u7801\u4EE5\u660E\u6587\u5B58\u5728\u7528\u6237\u4E3B\u76EE\u5F55\u79C1\u6709\u6587\u4EF6\uFF08\u6743\u9650 0600\uFF09\uFF1B\u547D\u4EE4\u8F93\u51FA\u53EF\u80FD\u542B\u654F\u611F\u4FE1\u606F\uFF1B\u4F20\u8F93/\u6267\u884C\u6D88\u8017\u771F\u5B9E\u8FDC\u7A0B\u8D44\u6E90\uFF0C\u5148\u786E\u8BA4\u518D\u64CD\u4F5C\u3002\u7528\u6237\u63D0\u5230\u300CWindows \u670D\u52A1\u5668 / WinRM / PowerShell \u8FDC\u7A0B / \u670D\u52A1\u7BA1\u7406 / \u8FDB\u7A0B\u7BA1\u7406\u300D\u65F6\u5373\u6307\u672C\u63D2\u4EF6\uFF0C\u8BF7\u636E\u6B64\u534F\u4F5C\u3002";
38
+ export declare const WINRM_GUIDANCE = "\u672C\u673A\u5DF2\u5B89\u88C5 dsh-winrm \u63D2\u4EF6\uFF08DSH \u8FDC\u7A0B Windows \u8FD0\u7EF4\uFF0CWinRM/PowerShell Remoting\uFF09\uFF1A\u4FA7\u8FB9\u680F\u300CWindows\u300D\u5165\u53E3\uFF1B\u4EFF\u7167 dsh-ssh \u63D2\u4EF6\u5F00\u53D1\u3002\u80FD\u529B\uFF1A\u4E3B\u673A\u914D\u7F6E\u5B58 ~/.dsh/dsh-winrm.json\uFF08GUI \u914D\u7F6E\u540E agent \u65B9\u53EF\u4F7F\u7528\uFF09\uFF1Bwinrm_list \u5217\u51FA\u4E3B\u673A\u3001winrm_exec \u6267\u884C PowerShell \u547D\u4EE4\uFF08UTF-8 \u4FE1\u5C01\uFF0C\u4E2D\u6587\u4E0D\u4E71\u7801\uFF09\u3001winrm_service \u670D\u52A1\u7BA1\u7406\uFF08start/stop/restart/\u542F\u52A8\u7C7B\u578B\uFF09\u3001winrm_process \u8FDB\u7A0B\u7BA1\u7406\uFF08list/kill\uFF09\u3001winrm_read/winrm_write/winrm_edit \u8FDC\u7A0B\u6587\u672C\u6587\u4EF6\u8BFB\u5199\uFF08\u6309\u884C\u5206\u9875\u3001\u7F16\u7801\u611F\u77E5\u3001\u7CBE\u786E\u4E32\u66FF\u6362\uFF0C\u4F5C\u7528\u4E8E\u8FDC\u7AEF\u4E3B\u673A\u800C\u975E\u672C\u673A\uFF0C\u672C\u673A\u6587\u4EF6\u4ECD\u7528 read/write/edit\uFF09\u3001winrm_upload/winrm_download \u6587\u4EF6\u4F20\u8F93\uFF08SMB \u7BA1\u7406\u5171\u4EAB\u53EF\u8FBE\u65F6\u4F18\u5148\uFF0C\u5426\u5219\u8D70 WinRM \u6D41\u5F0F\u4F20\u8F93\uFF0C\u65E0 SMB \u4F9D\u8D56\uFF0C\u5927\u6587\u4EF6\u7ECF SHA256 \u6821\u9A8C\uFF09\u3001winrm_cluster \u96C6\u7FA4\u5E76\u53D1\u6267\u884C\uFF1BWeb \u63A7\u5236\u53F0\u8D70 WebSocket\u3002\u8BA4\u8BC1\uFF1A\u901A\u8FC7\u539F\u751F Node.js WinRM \u5BA2\u6237\u7AEF\uFF08winrm-client\uFF09\u7684 NTLM/Negotiate \u4E0E Basic \u5B9E\u73B0\uFF0C\u517C\u5BB9\u672C\u5730\u8D26\u6237\u4E0E\u57DF\u8D26\u6237\uFF1B\u4F20\u8F93\u652F\u6301 http(5985)/https(5986)\u3002\u524D\u63D0\uFF1A\u76EE\u6807\u673A\u9700\u542F\u7528 WinRM\uFF08scripts/enable-winrm.ps1\uFF09\uFF0C\u672C\u673A\u65E0\u9700 Python \u2014\u2014 WinRM \u8D70\u7EAF Node \u4F9D\u8D56\u3002HTTP \u4EC5\u5EFA\u8BAE\u53D7\u4FE1\u5185\u7F51\u4F7F\u7528\uFF1B\u5BC6\u7801\u4EE5\u660E\u6587\u5B58\u5728\u7528\u6237\u4E3B\u76EE\u5F55\u79C1\u6709\u6587\u4EF6\uFF08\u6743\u9650 0600\uFF09\uFF1B\u547D\u4EE4\u8F93\u51FA\u53EF\u80FD\u542B\u654F\u611F\u4FE1\u606F\uFF1B\u4F20\u8F93/\u6267\u884C\u6D88\u8017\u771F\u5B9E\u8FDC\u7A0B\u8D44\u6E90\uFF0C\u5148\u786E\u8BA4\u518D\u64CD\u4F5C\u3002\u7528\u6237\u63D0\u5230\u300CWindows \u670D\u52A1\u5668 / WinRM / PowerShell \u8FDC\u7A0B / \u670D\u52A1\u7BA1\u7406 / \u8FDB\u7A0B\u7BA1\u7406\u300D\u65F6\u5373\u6307\u672C\u63D2\u4EF6\uFF0C\u8BF7\u636E\u6B64\u534F\u4F5C\u3002";
38
39
  /**
39
40
  * Mount the WinRM engine, routes, tools, and announcement.
40
41
  * @param ctx - host plugin context carrying webServer/tools/systemPrompt.
@@ -108,6 +108,70 @@ export declare function psReadChunk(p: string, offset: number, count: number): s
108
108
  * prints the number of bytes written.
109
109
  */
110
110
  export declare function psWriteChunk(p: string, b64: string, append: boolean): string;
111
+ /**
112
+ * JSON: one bounded, line-numbered window of a remote TEXT file.
113
+ *
114
+ * Decodes UTF-8 (with or without BOM), UTF-16LE and UTF-16BE by BOM, and
115
+ * rejects invalid UTF-8 instead of returning mojibake — a binary file fails
116
+ * loudly rather than as unreadable text. Lines are numbered from 1 over the
117
+ * whole file, `offset` selects the first returned line, and the window stops
118
+ * early once the joined text would exceed `maxBytes` (reported as
119
+ * `truncated`, so the caller knows more lines remain). A line longer than
120
+ * `maxLineLength` is cut to `maxLineLength + 1` code units here; the Node half
121
+ * appends the truncation note.
122
+ *
123
+ * A missing path or a directory emits `{"exists":false}`; anything the reader
124
+ * rejects emits `{"error":…}`. Both keep the exit code at 0 so the caller
125
+ * always receives parseable JSON rather than a PowerShell error record.
126
+ * @param p - remote path.
127
+ * @param offset - 1-based first line to return.
128
+ * @param limit - maximum number of lines to return.
129
+ * @param maxLineLength - maximum characters kept per line.
130
+ * @param maxBytes - maximum UTF-8 bytes of joined line text.
131
+ * @returns the PowerShell fragment.
132
+ */
133
+ export declare function psReadTextWindow(p: string, offset: number, limit: number, maxLineLength: number, maxBytes: number): string;
134
+ /**
135
+ * JSON: replace `oldB64` with `newB64` (both UTF-8, base64-carried) in a remote
136
+ * text file, preserving its encoding and writing through the same file.
137
+ *
138
+ * The search is ordinal and never pattern-based, so a replacement cannot be
139
+ * reinterpreted as a regular expression. Without `replaceAll` the edit is
140
+ * refused unless the needle appears exactly once, which is what keeps a stale
141
+ * or too-short needle from rewriting the wrong region. The file is read whole,
142
+ * so this is meant for source files and configuration, not for multi-hundred-
143
+ * megabyte logs.
144
+ *
145
+ * Failures (missing file, absent needle, ambiguous needle, undecodable bytes)
146
+ * are reported as `{"error":…}` with exit code 0 so the caller always receives
147
+ * parseable JSON.
148
+ * @param p - remote path.
149
+ * @param oldB64 - base64 of the UTF-8 text to find.
150
+ * @param newB64 - base64 of the UTF-8 text to substitute.
151
+ * @param replaceAll - replace every occurrence instead of requiring exactly one.
152
+ * @param basisMaxBytes - include the before/after text when the original file is
153
+ * at most this large; `0` omits the basis entirely.
154
+ * @returns the PowerShell fragment.
155
+ */
156
+ export declare function psEditText(p: string, oldB64: string, newB64: string, replaceAll: boolean, basisMaxBytes?: number): string;
157
+ /**
158
+ * JSON: the whole remote text file as base64, or a refusal when it is missing
159
+ * or larger than `maxBytes`.
160
+ *
161
+ * The write tool needs the previous text to project a diff card, and the line
162
+ * window reader cannot serve that: it numbers lines and caps each one, so a
163
+ * long line would silently truncate inside the diff basis. The BOM detection
164
+ * repeats {@link psEditText}'s on purpose — both must decode identically, and a
165
+ * shared emitter would hide that requirement behind a helper neither could be
166
+ * reviewed against.
167
+ *
168
+ * Failures are reported as `{"error":…}` with exit code 0, so the caller always
169
+ * receives parseable JSON.
170
+ * @param p - remote path.
171
+ * @param maxBytes - largest file whose text is returned.
172
+ * @returns the PowerShell fragment.
173
+ */
174
+ export declare function psReadTextBasis(p: string, maxBytes: number): string;
111
175
  /** Magic stamped into the frame header that precedes every stream chunk. */
112
176
  export declare const STREAM_FRAME_MAGIC = "DSHZ";
113
177
  /** Frame header size: 4 magic bytes + 4 little-endian length bytes + 1 mode byte + 1 reserved. */
@@ -88,6 +88,60 @@ export interface RemoteDirEntry {
88
88
  size: number;
89
89
  mtimeMs: number;
90
90
  }
91
+ /** One numbered line of a remote text file. */
92
+ export interface RemoteTextLine {
93
+ /** 1-based line number within the remote file. */
94
+ number: number;
95
+ /** Line text without its terminator; over-long lines are cut and annotated. */
96
+ text: string;
97
+ }
98
+ /** Result of reading a bounded window of a remote text file. */
99
+ export interface RemoteTextWindow {
100
+ path: string;
101
+ /** 1-based line number of the first returned line. */
102
+ offset: number;
103
+ lines: RemoteTextLine[];
104
+ /** Lines in the whole file, so the caller can page. */
105
+ totalLines: number;
106
+ /** Detected encoding: `utf8`, `utf8bom`, `utf16le` or `utf16be`. */
107
+ encoding: string;
108
+ /** True when the window stopped at the byte budget instead of at `limit`. */
109
+ truncatedByBytes: boolean;
110
+ /** Lines whose text was cut at the per-line cap and annotated in `lines`. */
111
+ truncatedLines: number;
112
+ }
113
+ /** Text encodings the writer can emit. */
114
+ export type RemoteTextEncoding = 'utf8' | 'utf8bom' | 'utf16le';
115
+ /** Result of writing a remote text file. */
116
+ export interface RemoteWriteOutcome {
117
+ path: string;
118
+ /** `create` when the path was absent, `update` when it was overwritten. */
119
+ operation: 'create' | 'update';
120
+ bytes: number;
121
+ encoding: RemoteTextEncoding;
122
+ /**
123
+ * Previous file text, or `null` for a create and for a file too large to
124
+ * serve as a diff basis. Only the presentation layer reads it.
125
+ */
126
+ before: string | null;
127
+ /** Text after the write, on the same basis as {@link RemoteWriteOutcome.before}. */
128
+ after: string;
129
+ }
130
+ /** Result of an in-place remote text edit. */
131
+ export interface RemoteEditOutcome {
132
+ path: string;
133
+ /** Encoding the file already had, preserved across the edit. */
134
+ encoding: string;
135
+ replacements: number;
136
+ size: number;
137
+ /**
138
+ * File text before the edit, or `null` when the file was too large to serve
139
+ * as a diff basis. Only the presentation layer reads it.
140
+ */
141
+ before: string | null;
142
+ /** Text after the edit, on the same basis as {@link RemoteEditOutcome.before}. */
143
+ after: string | null;
144
+ }
91
145
  /** One server entry in a cluster run. */
92
146
  export interface ClusterResult {
93
147
  alias: string;
@@ -3,7 +3,7 @@
3
3
  * the same engine the web UI uses, so a host configured in the GUI is
4
4
  * immediately operable by any agent, and vice versa.
5
5
  */
6
- import type { WinRmEngine } from './engine.ts';
6
+ import { type WinRmEngine } from './engine.ts';
7
7
  /** The host-list tool. */
8
8
  export declare function winrmListTool(engine: WinRmEngine): import("@deepseek-ai/dsh-tools").ToolDefinition;
9
9
  /** The command-execution tool (PowerShell). */
@@ -18,3 +18,9 @@ export declare function winrmUploadTool(engine: WinRmEngine): import("@deepseek-
18
18
  export declare function winrmDownloadTool(engine: WinRmEngine): import("@deepseek-ai/dsh-tools").ToolDefinition;
19
19
  /** The cluster tool. */
20
20
  export declare function winrmClusterTool(engine: WinRmEngine): import("@deepseek-ai/dsh-tools").ToolDefinition;
21
+ /** The remote read tool: a line-numbered window of a text file on the target. */
22
+ export declare function winrmReadTool(engine: WinRmEngine): import("@deepseek-ai/dsh-tools").ToolDefinition;
23
+ /** The remote write tool: replace a whole text file on the target. */
24
+ export declare function winrmWriteTool(engine: WinRmEngine): import("@deepseek-ai/dsh-tools").ToolDefinition;
25
+ /** The remote edit tool: replace an exact string inside a file on the target. */
26
+ export declare function winrmEditTool(engine: WinRmEngine): import("@deepseek-ai/dsh-tools").ToolDefinition;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-winrm",
3
- "description": "Remote Windows administration for the dsh web GUI: WinRM/PowerShell Remoting host config store (~/.dsh/dsh-winrm.json), PowerShell exec / streaming console / service & process management / streamed file transfer / cluster execution, plus agent tools (winrm_list, winrm_exec, winrm_service, winrm_process, winrm_upload, winrm_download, winrm_cluster). Runs entirely on a native Node.js WinRM client (winrm-client) — no Python/pywinrm required. Standalone Cordis plugin — mounted via cordis.patch.yml + a profile node_modules link, no dsh source changes.",
4
- "version": "0.3.1",
3
+ "description": "Remote Windows administration for the dsh web GUI: WinRM/PowerShell Remoting host config store (~/.dsh/dsh-winrm.json), PowerShell exec / streaming console / service & process management / remote text read-write-edit / streamed file transfer / cluster execution, plus agent tools (winrm_list, winrm_exec, winrm_service, winrm_process, winrm_read, winrm_write, winrm_edit, winrm_upload, winrm_download, winrm_cluster). Runs entirely on a native Node.js WinRM client (winrm-client) — no Python/pywinrm required. Standalone Cordis plugin — mounted via cordis.patch.yml + a profile node_modules link, no dsh source changes.",
4
+ "version": "0.4.0",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.7.0",
7
7
  "engines": {
@@ -34,7 +34,8 @@
34
34
  "@deepseek-ai/dsh-client-ui-renderer",
35
35
  "@deepseek-ai/dsh-client-locale",
36
36
  "@deepseek-ai/dsh-client-ui-layout",
37
- "@deepseek-ai/dsh-client-ui-sidebar"
37
+ "@deepseek-ai/dsh-client-ui-sidebar",
38
+ "@deepseek-ai/dsh-client-ui-tool"
38
39
  ],
39
40
  "platform": "web"
40
41
  }
@@ -56,9 +57,11 @@
56
57
  "@deepseek-ai/cordis-plugin-loader": "^1.0.5",
57
58
  "@deepseek-ai/dsh-client-locale": "^0.2.0-rc.2",
58
59
  "@deepseek-ai/dsh-client-ui-layout": "^0.2.0-rc.2",
60
+ "@deepseek-ai/dsh-client-ui-primitives": "0.2.1-rc.2",
59
61
  "@deepseek-ai/dsh-client-ui-renderer": "^0.2.0-rc.2",
60
62
  "@deepseek-ai/dsh-client-ui-sidebar": "^0.2.0-rc.2",
61
63
  "@deepseek-ai/dsh-client-ui-slots": "^0.2.0-rc.2",
64
+ "@deepseek-ai/dsh-client-ui-tool": "^0.2.0-rc.2",
62
65
  "@deepseek-ai/dsh-host-webserver": "^0.2.0-rc.2",
63
66
  "@deepseek-ai/dsh-llm": "^0.2.0-rc.2",
64
67
  "@deepseek-ai/dsh-system-prompt": "^0.2.0-rc.2",
@@ -79,8 +82,9 @@
79
82
  "lib/**/*.d.ts",
80
83
  "src",
81
84
  "cordis.patch.yml",
85
+ "docs/screenshots/*.png",
82
86
  "README.md",
83
- "README.zh.md",
87
+ "README.en.md",
84
88
  "RELEASING.md",
85
89
  "scripts"
86
90
  ],
@@ -6,6 +6,7 @@
6
6
  // Run from the repo root so `winrm-client` resolves:
7
7
  // node --experimental-strip-types scripts/verify-live.mts
8
8
  import { HostStore } from '../src/store.ts'
9
+ import { WinRmEngine } from '../src/engine.ts'
9
10
  import { connOf, runScript, TRANSFER_CHUNK, uploadBuffer, downloadChunks, remoteFileSize } from '../src/engine/client.ts'
10
11
  import { psWriteChunk, psReadChunk, psFileSize } from '../src/powershell.ts'
11
12
 
@@ -106,6 +107,121 @@ console.log('=== upload/download round trip ===')
106
107
  await runScript(conn, `Remove-Item -LiteralPath 'C:\\temp\\dsh-winrm-verify' -Recurse -Force -ErrorAction SilentlyContinue`, { timeoutMs: 60_000 })
107
108
  }
108
109
 
110
+ // Remote text read/write/edit: the three tools that act on the target's files
111
+ // rather than on the host's. Round-trips through the real encoding detection.
112
+ console.log('=== remote text read/write/edit ===')
113
+ {
114
+ const engine = new WinRmEngine(store)
115
+ const dir = 'C:\\temp\\dsh-winrm-verify-text'
116
+ const alias = entry.alias
117
+ const body = ['alpha', '中文行 ✓', '', 'beta alpha', 'omega'].join('\r\n') + '\r\n'
118
+
119
+ const created = await engine.writeFile(alias, `${dir}\\utf8.txt`, body, 'utf8')
120
+ const createOk = created.operation === 'create' && created.bytes === Buffer.byteLength(body, 'utf8')
121
+ if (!createOk) bad++
122
+ console.log(`${createOk ? 'ok ' : 'BAD '} write create utf8: op=${created.operation} bytes=${created.bytes}`)
123
+
124
+ const updated = await engine.writeFile(alias, `${dir}\\utf8.txt`, body, 'utf8')
125
+ const updateOk = updated.operation === 'update'
126
+ if (!updateOk) bad++
127
+ console.log(`${updateOk ? 'ok ' : 'BAD '} write update utf8: op=${updated.operation}`)
128
+
129
+ const window = await engine.readFile(alias, `${dir}\\utf8.txt`, 1, 100)
130
+ const readOk = window.totalLines === 5
131
+ && window.encoding === 'utf8'
132
+ && window.lines[0]?.text === 'alpha'
133
+ && window.lines[1]?.text === '中文行 ✓'
134
+ && window.lines[2]?.text === ''
135
+ && window.lines[4]?.text === 'omega'
136
+ if (!readOk) bad++
137
+ console.log(`${readOk ? 'ok ' : 'BAD '} read whole: total=${window.totalLines} enc=${window.encoding} lines=${JSON.stringify(window.lines.map(l => l.text))}`)
138
+
139
+ // Paging must start where the caller asked, or a model paging a log would
140
+ // silently re-read the top forever.
141
+ const tail = await engine.readFile(alias, `${dir}\\utf8.txt`, 4, 2)
142
+ const pageOk = tail.lines.length === 2 && tail.lines[0]?.number === 4 && tail.lines[0]?.text === 'beta alpha'
143
+ if (!pageOk) bad++
144
+ console.log(`${pageOk ? 'ok ' : 'BAD '} read page offset=4 limit=2: ${JSON.stringify(tail.lines)}`)
145
+
146
+ const edited = await engine.editFile(alias, `${dir}\\utf8.txt`, 'omega', 'OMEGA', false)
147
+ const editOk = edited.replacements === 1 && edited.encoding === 'utf8'
148
+ if (!editOk) bad++
149
+ console.log(`${editOk ? 'ok ' : 'BAD '} edit single: replacements=${edited.replacements} size=${edited.size}`)
150
+
151
+ const afterEdit = await engine.readFile(alias, `${dir}\\utf8.txt`, 1, 100)
152
+ const persisted = afterEdit.lines[4]?.text === 'OMEGA'
153
+ if (!persisted) bad++
154
+ console.log(`${persisted ? 'ok ' : 'BAD '} edit persisted: line5=${JSON.stringify(afterEdit.lines[4]?.text)}`)
155
+
156
+ // `alpha` appears twice, so a single-replacement edit must refuse rather than
157
+ // guess which one the caller meant.
158
+ let ambiguous = ''
159
+ try {
160
+ await engine.editFile(alias, `${dir}\\utf8.txt`, 'alpha', 'ALPHA', false)
161
+ } catch (error) {
162
+ ambiguous = error instanceof Error ? error.message : String(error)
163
+ }
164
+ const ambiguousOk = ambiguous.includes('more than once')
165
+ if (!ambiguousOk) bad++
166
+ console.log(`${ambiguousOk ? 'ok ' : 'BAD '} edit refuses ambiguous needle: ${JSON.stringify(ambiguous)}`)
167
+
168
+ const all = await engine.editFile(alias, `${dir}\\utf8.txt`, 'alpha', 'ALPHA', true)
169
+ const allOk = all.replacements === 2
170
+ if (!allOk) bad++
171
+ console.log(`${allOk ? 'ok ' : 'BAD '} edit replaceAll: replacements=${all.replacements}`)
172
+
173
+ // A needle that is gone must fail loudly: silently succeeding is how a stale
174
+ // edit looks successful while changing nothing.
175
+ let missing = ''
176
+ try {
177
+ await engine.editFile(alias, `${dir}\\utf8.txt`, 'not-there-at-all', 'x', false)
178
+ } catch (error) {
179
+ missing = error instanceof Error ? error.message : String(error)
180
+ }
181
+ const missingOk = missing.includes('was not found')
182
+ if (!missingOk) bad++
183
+ console.log(`${missingOk ? 'ok ' : 'BAD '} edit reports a missing needle: ${JSON.stringify(missing)}`)
184
+
185
+ // UTF-16LE with a BOM must come back as UTF-16LE, not as mojibake.
186
+ const utf16 = await engine.writeFile(alias, `${dir}\\utf16.txt`, '第一行\r\n第二行\r\n', 'utf16le')
187
+ const utf16Read = await engine.readFile(alias, `${dir}\\utf16.txt`, 1, 10)
188
+ const utf16Ok = utf16Read.encoding === 'utf16le' && utf16Read.lines[0]?.text === '第一行' && utf16Read.lines[1]?.text === '第二行'
189
+ if (!utf16Ok) bad++
190
+ console.log(`${utf16Ok ? 'ok ' : 'BAD '} utf16le round trip: bytes=${utf16.bytes} enc=${utf16Read.encoding} lines=${JSON.stringify(utf16Read.lines.map(l => l.text))}`)
191
+
192
+ // A UTF-8 BOM must be preserved by an edit, or the next reader misdetects it.
193
+ await engine.writeFile(alias, `${dir}\\bom.txt`, 'one\r\ntwo\r\n', 'utf8bom')
194
+ const bomEdit = await engine.editFile(alias, `${dir}\\bom.txt`, 'two', 'TWO', false)
195
+ const bomRead = await engine.readFile(alias, `${dir}\\bom.txt`, 1, 10)
196
+ const bomOk = bomEdit.encoding === 'utf8bom' && bomRead.encoding === 'utf8bom' && bomRead.lines[1]?.text === 'TWO'
197
+ if (!bomOk) bad++
198
+ console.log(`${bomOk ? 'ok ' : 'BAD '} utf8bom preserved through an edit: edit=${bomEdit.encoding} read=${bomRead.encoding}`)
199
+
200
+ // A binary file has no line structure; the reader must say so instead of
201
+ // returning U+FFFD soup.
202
+ let binary = ''
203
+ try {
204
+ await engine.readFile(alias, 'C:\\Windows\\System32\\notepad.exe', 1, 5)
205
+ } catch (error) {
206
+ binary = error instanceof Error ? error.message : String(error)
207
+ }
208
+ const binaryOk = binary.length > 0
209
+ if (!binaryOk) bad++
210
+ console.log(`${binaryOk ? 'ok ' : 'BAD '} binary read fails loudly: ${JSON.stringify(binary.slice(0, 120))}`)
211
+
212
+ let absent = ''
213
+ try {
214
+ await engine.readFile(alias, `${dir}\\nope.txt`, 1, 5)
215
+ } catch (error) {
216
+ absent = error instanceof Error ? error.message : String(error)
217
+ }
218
+ const absentOk = absent.includes('not found')
219
+ if (!absentOk) bad++
220
+ console.log(`${absentOk ? 'ok ' : 'BAD '} read reports a missing file: ${JSON.stringify(absent)}`)
221
+
222
+ await runScript(conn, `Remove-Item -LiteralPath '${dir}' -Recurse -Force -ErrorAction SilentlyContinue`, { timeoutMs: 60_000 })
223
+ }
224
+
109
225
  // Sanity: a script that never reaches the envelope must not claim success.
110
226
  {
111
227
  const r = await runScript(conn, `Write-Output 'ok'`, { timeoutMs: 60_000 })