pi-ssh-remote 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/README.zh-CN.md +9 -0
- package/index.ts +104 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -23,6 +23,15 @@ pi install npm:pi-ssh-remote
|
|
|
23
23
|
/remote off # return to local tools
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
+
Remote command previews follow Pi's local Bash behavior and show the last 5 visual lines by default. Configure the default or override one `/remote exec` invocation:
|
|
27
|
+
|
|
28
|
+
```text
|
|
29
|
+
/remote config display-lines 10
|
|
30
|
+
/remote exec --lines 20 COMMAND
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The `ssh_remote_control` tool's `exec` action also accepts `displayLines`. Preview settings affect only the collapsed UI; model output keeps Pi's 2000-line/50KB safety limits, with oversized output saved to a temporary file.
|
|
34
|
+
|
|
26
35
|
Run `/remote config` to list endpoints and `/remote` to see all available subcommands.
|
|
27
36
|
|
|
28
37
|
## Authentication and security
|
package/README.zh-CN.md
CHANGED
|
@@ -23,6 +23,15 @@ pi install npm:pi-ssh-remote
|
|
|
23
23
|
/remote off # 返回本地工具
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
+
远程命令预览与 Pi 本地 Bash 一致,默认展示最后 5 个视觉行。可以修改默认值,或只覆盖某一次 `/remote exec`:
|
|
27
|
+
|
|
28
|
+
```text
|
|
29
|
+
/remote config display-lines 10
|
|
30
|
+
/remote exec --lines 20 COMMAND
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`ssh_remote_control` 工具的 `exec` 操作也支持 `displayLines`。预览设置只影响折叠界面;提供给模型的输出仍采用 Pi 的 2000 行/50KB 安全限制,超限完整输出会保存到临时文件。
|
|
34
|
+
|
|
26
35
|
使用 `/remote config` 查看服务器,使用 `/remote` 查看全部子命令。
|
|
27
36
|
|
|
28
37
|
## 认证与安全
|
package/index.ts
CHANGED
|
@@ -23,13 +23,14 @@ import {
|
|
|
23
23
|
createReadTool,
|
|
24
24
|
createWriteTool,
|
|
25
25
|
formatSize,
|
|
26
|
+
keyHint,
|
|
26
27
|
truncateTail,
|
|
27
28
|
type BashOperations,
|
|
28
29
|
type EditOperations,
|
|
29
30
|
type ReadOperations,
|
|
30
31
|
type WriteOperations,
|
|
31
32
|
} from "@earendil-works/pi-coding-agent";
|
|
32
|
-
import { CURSOR_MARKER, Key, matchesKey, truncateToWidth, type Component, type Focusable } from "@earendil-works/pi-tui";
|
|
33
|
+
import { CURSOR_MARKER, Key, Text, matchesKey, truncateToWidth, type Component, type Focusable } from "@earendil-works/pi-tui";
|
|
33
34
|
|
|
34
35
|
interface ParsedSsh {
|
|
35
36
|
host: string;
|
|
@@ -58,6 +59,7 @@ interface RemoteEndpointConfig {
|
|
|
58
59
|
interface RemoteConfig {
|
|
59
60
|
activeEndpoint?: string;
|
|
60
61
|
endpoints?: Record<string, RemoteEndpointConfig>;
|
|
62
|
+
displayLines?: number;
|
|
61
63
|
/** Legacy fields migrated into endpoints on the next config write. */
|
|
62
64
|
sshCommand?: string;
|
|
63
65
|
remoteCwd?: string;
|
|
@@ -74,6 +76,7 @@ const AGENT_DIR = join(process.env.HOME || ".", CONFIG_DIR_NAME, "agent");
|
|
|
74
76
|
const KNOWN_HOSTS_FILE = join(AGENT_DIR, "ssh-remote-known-hosts.json");
|
|
75
77
|
const REMOTE_CONFIG_FILE = join(AGENT_DIR, "ssh-remote-config.json");
|
|
76
78
|
const FALLBACK_REMOTE_CWD = "~";
|
|
79
|
+
const DEFAULT_DISPLAY_LINES = 5;
|
|
77
80
|
const CACHE_KEY = "__piHpcCredentialCacheV1";
|
|
78
81
|
const cacheHost = globalThis as typeof globalThis & { [CACHE_KEY]?: CredentialCache };
|
|
79
82
|
const credentialCache = cacheHost[CACHE_KEY] ??= { passwords: new Map<string, string>() };
|
|
@@ -151,6 +154,19 @@ function quote(value: string): string {
|
|
|
151
154
|
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
152
155
|
}
|
|
153
156
|
|
|
157
|
+
function parseDisplayLines(value: unknown): number {
|
|
158
|
+
const lines = typeof value === "number" ? value : Number(value);
|
|
159
|
+
if (!Number.isInteger(lines) || lines < 1 || lines > DEFAULT_MAX_LINES) {
|
|
160
|
+
throw new Error(`Display lines must be an integer from 1 to ${DEFAULT_MAX_LINES}`);
|
|
161
|
+
}
|
|
162
|
+
return lines;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function configuredDisplayLines(config = loadRemoteConfig()): number {
|
|
166
|
+
try { return parseDisplayLines(config.displayLines ?? DEFAULT_DISPLAY_LINES); }
|
|
167
|
+
catch { return DEFAULT_DISPLAY_LINES; }
|
|
168
|
+
}
|
|
169
|
+
|
|
154
170
|
function commandFromEndpointKey(key: string): string | undefined {
|
|
155
171
|
const match = key.match(/^([^@]+)@(.+):(\d+)$/);
|
|
156
172
|
if (!match) return undefined;
|
|
@@ -182,10 +198,14 @@ function normalizeRemoteConfig(config: RemoteConfig): RemoteConfig {
|
|
|
182
198
|
}
|
|
183
199
|
if (activeEndpoint && !endpoints[activeEndpoint]) activeEndpoint = undefined;
|
|
184
200
|
activeEndpoint ??= Object.keys(endpoints)[0];
|
|
201
|
+
let displayLines: number | undefined;
|
|
202
|
+
try { displayLines = config.displayLines === undefined ? undefined : parseDisplayLines(config.displayLines); }
|
|
203
|
+
catch { displayLines = undefined; }
|
|
185
204
|
|
|
186
205
|
return {
|
|
187
206
|
...(activeEndpoint ? { activeEndpoint } : {}),
|
|
188
207
|
...(Object.keys(endpoints).length ? { endpoints } : {}),
|
|
208
|
+
...(displayLines !== undefined ? { displayLines } : {}),
|
|
189
209
|
};
|
|
190
210
|
}
|
|
191
211
|
|
|
@@ -241,15 +261,54 @@ function displayFingerprint(hex: string): string {
|
|
|
241
261
|
return `SHA256:${Buffer.from(hex, "hex").toString("base64").replace(/=+$/, "")}`;
|
|
242
262
|
}
|
|
243
263
|
|
|
244
|
-
function formatRemoteOutput(output: string)
|
|
245
|
-
const
|
|
246
|
-
|
|
264
|
+
function formatRemoteOutput(output: string) {
|
|
265
|
+
const truncation = truncateTail(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
|
|
266
|
+
const content = truncation.content || "Remote command completed.";
|
|
267
|
+
if (!truncation.truncated) return { text: content, content, truncation };
|
|
247
268
|
|
|
248
269
|
const outputDir = mkdtempSync(join(tmpdir(), "pi-ssh-remote-output-"));
|
|
249
270
|
const fullOutputPath = join(outputDir, "output.log");
|
|
250
271
|
writeFileSync(fullOutputPath, output, { encoding: "utf8", mode: 0o600 });
|
|
251
|
-
const
|
|
252
|
-
|
|
272
|
+
const startLine = truncation.totalLines - truncation.outputLines + 1;
|
|
273
|
+
const limit = truncation.truncatedBy === "bytes" ? ` (${formatSize(DEFAULT_MAX_BYTES)} limit)` : "";
|
|
274
|
+
const text = `${content}\n\n[Showing lines ${startLine}-${truncation.totalLines} of ${truncation.totalLines}${limit}. Full output: ${fullOutputPath}]`;
|
|
275
|
+
return { text, content, truncation, fullOutputPath };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function previewRemoteOutput(output: string, displayLines: number): string {
|
|
279
|
+
return truncateTail(output, { maxLines: displayLines, maxBytes: DEFAULT_MAX_BYTES }).content || "Remote command completed.";
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function renderRemoteControlResult(result: any, expanded: boolean, theme: any): Component {
|
|
283
|
+
const fallback = result.content?.find((item: any) => item.type === "text")?.text ?? "";
|
|
284
|
+
const details = result.details;
|
|
285
|
+
if (details?.action !== "exec") return new Text(fallback, 0, 0);
|
|
286
|
+
|
|
287
|
+
const output = details.output || fallback;
|
|
288
|
+
const displayLines = details.displayLines || DEFAULT_DISPLAY_LINES;
|
|
289
|
+
const warnings = [
|
|
290
|
+
...(details.fullOutputPath ? [`Full output: ${details.fullOutputPath}`] : []),
|
|
291
|
+
...(details.truncation?.truncated ? [`Truncated: showing ${details.truncation.outputLines} of ${details.truncation.totalLines} lines`] : []),
|
|
292
|
+
];
|
|
293
|
+
const warning = warnings.length ? warnings.join(". ") : undefined;
|
|
294
|
+
|
|
295
|
+
if (expanded) {
|
|
296
|
+
return new Text(`${output}${warning ? `\n${theme.fg("warning", `[${warning}]`)}` : ""}`, 0, 0);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
render(width: number) {
|
|
301
|
+
const styled = output.split("\n").map((line: string) => theme.fg("toolOutput", line)).join("\n");
|
|
302
|
+
const visualLines = new Text(styled, 0, 0).render(width);
|
|
303
|
+
const shown = visualLines.slice(-displayLines);
|
|
304
|
+
const skipped = visualLines.length - shown.length;
|
|
305
|
+
const hint = skipped > 0
|
|
306
|
+
? [theme.fg("muted", `... (${skipped} earlier lines, ${keyHint("app.tools.expand", "to expand")})`)]
|
|
307
|
+
: [];
|
|
308
|
+
return [...hint, ...shown, ...(warning ? [theme.fg("warning", `[${warning}]`)] : [])];
|
|
309
|
+
},
|
|
310
|
+
invalidate() {},
|
|
311
|
+
};
|
|
253
312
|
}
|
|
254
313
|
|
|
255
314
|
function probeFingerprint(config: ParsedSsh): Promise<string> {
|
|
@@ -664,7 +723,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
664
723
|
pi.registerTool({
|
|
665
724
|
name: "ssh_remote_control",
|
|
666
725
|
label: "SSH Remote Control",
|
|
667
|
-
description: "Connect, reconnect, change the persistent remote working directory, inspect, forward ports, run remote SSH commands, or disconnect the configured SSH environment.
|
|
726
|
+
description: "Connect, reconnect, change the persistent remote working directory, inspect, forward ports, run remote SSH commands, or disconnect the configured SSH environment. Exec output uses a configurable collapsed preview (5 visual lines by default), while model output is limited to 50KB or 2000 lines and saved to a local temporary file when truncated. Passwords are never accepted as arguments and are cached only in process memory.",
|
|
668
727
|
promptSnippet: "Control the configured remote SSH connection, working directory, and local port forwarding",
|
|
669
728
|
promptGuidelines: [
|
|
670
729
|
"Use ssh_remote_control when the user asks the agent to enter, reconnect, inspect, or leave a remote SSH environment.",
|
|
@@ -677,6 +736,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
677
736
|
cwd: Type.Optional(Type.String({ description: "Remote working directory; required for chdir, and a one-command override for exec" })),
|
|
678
737
|
forwards: Type.Optional(Type.String({ description: "Space-separated LOCAL_PORT:REMOTE_HOST:REMOTE_PORT mappings; defaults to ssh-remote-config.json" })),
|
|
679
738
|
remoteCommand: Type.Optional(Type.String({ description: "Remote shell command for the exec action" })),
|
|
739
|
+
displayLines: Type.Optional(Type.Integer({ minimum: 1, maximum: DEFAULT_MAX_LINES, description: "Collapsed visual lines for exec output; defaults to the /remote config display-lines setting (5 initially)" })),
|
|
680
740
|
}),
|
|
681
741
|
async execute(_id, params, _signal, _update, ctx) {
|
|
682
742
|
if (params.action === "status") {
|
|
@@ -722,9 +782,21 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
722
782
|
const resolved = await changeRemoteCwd(cdTarget, ctx);
|
|
723
783
|
return { content: [{ type: "text", text: resolved }], details: { connected: true, cwd: resolved } };
|
|
724
784
|
}
|
|
785
|
+
const displayLines = parseDisplayLines(params.displayLines ?? configuredDisplayLines());
|
|
725
786
|
const output = await withReconnect((client) => execRemote(client, `cd -- ${quote(params.cwd ?? state.cwd)} && ${params.remoteCommand}`));
|
|
726
787
|
const formatted = formatRemoteOutput(output.toString());
|
|
727
|
-
return {
|
|
788
|
+
return {
|
|
789
|
+
content: [{ type: "text", text: formatted.text }],
|
|
790
|
+
details: {
|
|
791
|
+
action: "exec",
|
|
792
|
+
connected: true,
|
|
793
|
+
cwd: state.cwd,
|
|
794
|
+
displayLines,
|
|
795
|
+
output: formatted.content,
|
|
796
|
+
truncation: formatted.truncation.truncated ? formatted.truncation : undefined,
|
|
797
|
+
fullOutputPath: formatted.fullOutputPath,
|
|
798
|
+
},
|
|
799
|
+
};
|
|
728
800
|
}
|
|
729
801
|
const command = params.command || lastCommand || activeSshCommand();
|
|
730
802
|
if (!command) throw new Error(`No SSH endpoint configured. Set ${REMOTE_CONFIG_FILE} or pass command.`);
|
|
@@ -732,10 +804,13 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
732
804
|
if (!state) throw new Error(lastConnectionError || "SSH remote connection was cancelled or failed");
|
|
733
805
|
return { content: [{ type: "text", text: `Connected: ${state.label}:${state.cwd}` }], details: { connected: true, cwd: state.cwd } };
|
|
734
806
|
},
|
|
807
|
+
renderResult(result, { expanded }, theme) {
|
|
808
|
+
return renderRemoteControlResult(result, expanded, theme);
|
|
809
|
+
},
|
|
735
810
|
});
|
|
736
811
|
|
|
737
812
|
pi.registerCommand("remote", {
|
|
738
|
-
description: "Connect over SSH and manage endpoints: /remote | ssh USER@HOST [-p PORT] | config | use USER@HOST:PORT | config cwd PATH | forward [MAPPINGS] | unforward | exec COMMAND | cd PATH | status | reload | off | forget",
|
|
813
|
+
description: "Connect over SSH and manage endpoints: /remote | ssh USER@HOST [-p PORT] | config | use USER@HOST:PORT | config cwd PATH | config display-lines N | forward [MAPPINGS] | unforward | exec [--lines N] COMMAND | cd PATH | status | reload | off | forget",
|
|
739
814
|
handler: async (args, ctx) => {
|
|
740
815
|
const input = args.trim().replace(/^\/?remote(?:\s+|$)/i, "").trim();
|
|
741
816
|
const action = input.toLowerCase();
|
|
@@ -745,7 +820,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
745
820
|
const active = key === config.activeEndpoint ? "*" : " ";
|
|
746
821
|
return `${active} ${key}\n SSH: ${endpoint.sshCommand}\n cwd: ${endpoint.remoteCwd || FALLBACK_REMOTE_CWD}\n forward: ${endpoint.forwards?.join(", ") || "none"}`;
|
|
747
822
|
});
|
|
748
|
-
ctx.ui.notify(`SSH remote configuration: ${REMOTE_CONFIG_FILE}\n${rows.join("\n") || "No saved endpoints"}`, "info");
|
|
823
|
+
ctx.ui.notify(`SSH remote configuration: ${REMOTE_CONFIG_FILE}\nDisplay lines: ${configuredDisplayLines(config)}\n${rows.join("\n") || "No saved endpoints"}`, "info");
|
|
749
824
|
return;
|
|
750
825
|
}
|
|
751
826
|
if (/^ssh\s+/i.test(input)) {
|
|
@@ -784,6 +859,14 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
784
859
|
ctx.ui.notify(`Selected SSH remote endpoint: ${key}; use /remote to connect`, "info");
|
|
785
860
|
return;
|
|
786
861
|
}
|
|
862
|
+
if (/^config\s+display-lines\s+/i.test(input)) {
|
|
863
|
+
try {
|
|
864
|
+
const displayLines = parseDisplayLines(input.replace(/^config\s+display-lines\s+/i, "").trim());
|
|
865
|
+
saveRemoteConfig({ ...loadRemoteConfig(), displayLines });
|
|
866
|
+
ctx.ui.notify(`SSH remote command preview updated: ${displayLines} lines`, "info");
|
|
867
|
+
} catch (error) { ctx.ui.notify((error as Error).message, "error"); }
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
787
870
|
if (/^config\s+cwd\s+/i.test(input)) {
|
|
788
871
|
const remoteCwd = input.replace(/^config\s+cwd\s+/i, "").trim();
|
|
789
872
|
const command = lastCommand || activeSshCommand();
|
|
@@ -824,9 +907,18 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
824
907
|
if (/^exec\s+/i.test(input)) {
|
|
825
908
|
try {
|
|
826
909
|
const state = await ensureConnected(ctx);
|
|
827
|
-
const
|
|
910
|
+
const execInput = input.replace(/^exec\s+/i, "");
|
|
911
|
+
const linesMatch = execInput.match(/^--lines\s+(\S+)\s+([\s\S]+)$/i);
|
|
912
|
+
const displayLines = linesMatch ? parseDisplayLines(linesMatch[1]) : configuredDisplayLines();
|
|
913
|
+
const remoteCommand = linesMatch ? linesMatch[2]! : execInput;
|
|
828
914
|
const output = (await withReconnect((client) => execRemote(client, `cd -- ${quote(state.cwd)} && ${remoteCommand}`))).toString().trim();
|
|
829
|
-
|
|
915
|
+
const formatted = formatRemoteOutput(output);
|
|
916
|
+
const preview = previewRemoteOutput(formatted.content, displayLines);
|
|
917
|
+
const omitted = formatted.truncation.totalLines > displayLines
|
|
918
|
+
? `\n\n[Showing last ${Math.min(displayLines, formatted.truncation.totalLines)} of ${formatted.truncation.totalLines} lines]`
|
|
919
|
+
: "";
|
|
920
|
+
const fullOutput = formatted.fullOutputPath ? `\n[Full output: ${formatted.fullOutputPath}]` : "";
|
|
921
|
+
ctx.ui.notify(`${preview}${omitted}${fullOutput}`, "info");
|
|
830
922
|
} catch (error) { ctx.ui.notify(`SSH remote command failed: ${(error as Error).message}`, "error"); }
|
|
831
923
|
return;
|
|
832
924
|
}
|