@xynogen/pix-ssh 0.3.0 → 0.3.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/package.json +2 -2
- package/src/index.ts +17 -28
- package/src/lib.test.ts +67 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xynogen/pix-ssh",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Pi tool — ssh_run: run remote commands over SSH with password/key auth and remote sudo",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"typebox": "^1.1.38",
|
|
40
|
-
"@xynogen/pix-pretty": "^1.
|
|
40
|
+
"@xynogen/pix-pretty": "^1.19.0",
|
|
41
41
|
"@xynogen/pix-runtime": "^0.8.0"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
package/src/index.ts
CHANGED
|
@@ -35,6 +35,7 @@ import type { RenderContextLike, ThemeLike, ToolResultLike } from "@xynogen/pix-
|
|
|
35
35
|
import {
|
|
36
36
|
dotJoin,
|
|
37
37
|
fillToolBackground,
|
|
38
|
+
frameToolResult,
|
|
38
39
|
getTextContent,
|
|
39
40
|
hideCollapsedToolCall,
|
|
40
41
|
normalizeLineEndings,
|
|
@@ -43,6 +44,7 @@ import {
|
|
|
43
44
|
ruleFrame,
|
|
44
45
|
sectionRule,
|
|
45
46
|
termW,
|
|
47
|
+
unframeToolResult,
|
|
46
48
|
} from "@xynogen/pix-pretty/utils";
|
|
47
49
|
import { SPINNER } from "@xynogen/pix-pretty/widget-format";
|
|
48
50
|
import { getUnattendedMode, withAgentBlock } from "@xynogen/pix-runtime";
|
|
@@ -310,14 +312,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
310
312
|
"`destination`, and optional `recursive`. Transfers may overwrite the destination. Always provide a clear `reason`.",
|
|
311
313
|
promptSnippet: "Run a remote command or transfer files over SSH",
|
|
312
314
|
promptGuidelines: [
|
|
313
|
-
|
|
314
|
-
"pwsh commands may work, but treat Windows shells as best-effort. Back away and tell the user " +
|
|
315
|
-
"when correctness depends on explicit shell selection, complex quoting, PowerShell error/stream/encoding " +
|
|
316
|
-
"semantics, interactive prompts, or Windows administrator/UAC elevation. `sudo` covers POSIX sudo " +
|
|
317
|
-
'only. Provide `host` as `[user@]host[:port]`. For file transfer, use `action: "file"`, ' +
|
|
318
|
-
'`direction: "upload" | "download"`, `source`, `destination`, and optional `recursive`. ' +
|
|
319
|
-
"Transfers may overwrite the destination and are warning-level: AFK/YOLO auto-approve when " +
|
|
320
|
-
"no login password is missing. Always explain the intent in `reason`.",
|
|
315
|
+
'ssh_run: `host` as `[user@]host[:port]`; `sudo` covers POSIX sudo only. For transfer use `action: "file"` with `direction: "upload"|"download"`, `source`, `destination`, optional `recursive` (may overwrite). Always set `reason`.',
|
|
321
316
|
],
|
|
322
317
|
|
|
323
318
|
renderShell: "self",
|
|
@@ -486,7 +481,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
486
481
|
hostApproved(approvedHosts, key) &&
|
|
487
482
|
promptFor.length === 0;
|
|
488
483
|
if (alreadyApproved) {
|
|
489
|
-
ctx.ui.notify(
|
|
484
|
+
ctx.ui.notify(`ssh_run: reused session approval for ${host}`, "info");
|
|
490
485
|
} else if (transferDecision === "allow") {
|
|
491
486
|
ctx.ui.notify(
|
|
492
487
|
`⚠ ssh_run file transfer auto-approved — ${mode.toUpperCase()} warning policy`,
|
|
@@ -737,21 +732,24 @@ export default function (pi: ExtensionAPI): void {
|
|
|
737
732
|
renderCtx: RenderContextLike,
|
|
738
733
|
) => {
|
|
739
734
|
resolveBaseBackground(theme);
|
|
740
|
-
const text = renderCtx.lastComponent ?? new Text("", 0, 0);
|
|
735
|
+
const text = unframeToolResult(renderCtx.lastComponent ?? new Text("", 0, 0));
|
|
741
736
|
const details = result.details as SshResultDetails | undefined;
|
|
737
|
+
const isPartial = (_opt as { isPartial?: boolean } | undefined)?.isPartial === true;
|
|
738
|
+
const completed = (isError: boolean) => frameToolResult(text, theme, isError);
|
|
742
739
|
|
|
743
740
|
if (details?._type !== "sshResult") {
|
|
744
741
|
if (renderCtx.isError) {
|
|
745
742
|
text.setText(renderToolError(getTextContent(result) || "Error", theme));
|
|
746
743
|
} else {
|
|
747
744
|
text.setText(
|
|
748
|
-
fillToolBackground(` ${theme.fg("
|
|
745
|
+
fillToolBackground(` ${theme.fg("muted", getTextContent(result) || "done")}`),
|
|
749
746
|
);
|
|
750
747
|
}
|
|
751
|
-
return text;
|
|
748
|
+
return isPartial ? text : completed(renderCtx.isError);
|
|
752
749
|
}
|
|
753
750
|
|
|
754
751
|
if (
|
|
752
|
+
!isPartial &&
|
|
755
753
|
isTerminal(details) &&
|
|
756
754
|
tickCollapse(
|
|
757
755
|
"ssh",
|
|
@@ -760,12 +758,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
760
758
|
renderCtx.expanded,
|
|
761
759
|
)
|
|
762
760
|
) {
|
|
763
|
-
const status =
|
|
764
|
-
details.outcome === "success"
|
|
765
|
-
? "success"
|
|
766
|
-
: details.outcome === "error"
|
|
767
|
-
? "error"
|
|
768
|
-
: "warning";
|
|
761
|
+
const status = details.outcome === "success" ? "success" : "error";
|
|
769
762
|
text.setText(
|
|
770
763
|
renderCollapsedToolRow(
|
|
771
764
|
theme,
|
|
@@ -780,7 +773,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
780
773
|
|
|
781
774
|
if (details.outcome === "awaiting-approval" || details.outcome === "running") {
|
|
782
775
|
text.setText(
|
|
783
|
-
fillToolBackground(` ${theme.fg("
|
|
776
|
+
fillToolBackground(` ${theme.fg("muted", getTextContent(result) || "working")}`),
|
|
784
777
|
);
|
|
785
778
|
return text;
|
|
786
779
|
}
|
|
@@ -792,7 +785,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
792
785
|
? renderToolError(diagnostic, theme)
|
|
793
786
|
: fillToolBackground(` ${theme.fg("warning", diagnostic)}`),
|
|
794
787
|
);
|
|
795
|
-
return text;
|
|
788
|
+
return isPartial ? text : completed(true);
|
|
796
789
|
}
|
|
797
790
|
|
|
798
791
|
const code = typeof details.exitCode === "number" ? details.exitCode : null;
|
|
@@ -803,22 +796,18 @@ export default function (pi: ExtensionAPI): void {
|
|
|
803
796
|
|
|
804
797
|
if (!rendered) {
|
|
805
798
|
text.setText(fillToolBackground(` ${summary}`));
|
|
806
|
-
return text;
|
|
799
|
+
return isPartial ? text : completed(details.outcome !== "success");
|
|
807
800
|
}
|
|
808
801
|
|
|
809
802
|
const maxShow = renderCtx.expanded ? lineCount : MAX_PREVIEW_LINES;
|
|
810
803
|
const show = lines.slice(0, maxShow);
|
|
811
804
|
const footer =
|
|
812
805
|
lineCount > maxShow ? [`${FG_DIM} … ${lineCount - maxShow} more lines${RST}`] : [];
|
|
813
|
-
const statusKey =
|
|
806
|
+
const statusKey = details.outcome === "success" ? "success" : "error";
|
|
814
807
|
const paint = (s: string) => theme.fg(statusKey, s);
|
|
815
808
|
const sw = Math.max(8, termW() - 4); // section-rule width inside the 2-space indent
|
|
816
|
-
const
|
|
817
|
-
|
|
818
|
-
footer,
|
|
819
|
-
termW(),
|
|
820
|
-
paint,
|
|
821
|
-
);
|
|
809
|
+
const body = show.map((line) => ` ${sectionRule(line, theme, sw) ?? line}`);
|
|
810
|
+
const out = isPartial ? [...body, ...footer] : ruleFrame(body, footer, termW(), paint);
|
|
822
811
|
text.setText(fillToolBackground(out.join("\n")));
|
|
823
812
|
return text;
|
|
824
813
|
}) as never,
|
package/src/lib.test.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import registerSsh from "./index.ts";
|
|
2
4
|
import {
|
|
3
5
|
baseScpArgs,
|
|
4
6
|
baseSshArgs,
|
|
@@ -251,3 +253,68 @@ describe("truncate", () => {
|
|
|
251
253
|
expect(out.text.split("\n").length).toBe(2000);
|
|
252
254
|
});
|
|
253
255
|
});
|
|
256
|
+
|
|
257
|
+
describe("ssh result renderer", () => {
|
|
258
|
+
const theme = {
|
|
259
|
+
fg: (key: string, text: string) => `[${key}]${text}[/]`,
|
|
260
|
+
bold: (text: string) => text,
|
|
261
|
+
};
|
|
262
|
+
const register = () => {
|
|
263
|
+
let renderer: ((...args: unknown[]) => { render(width: number): string[] }) | undefined;
|
|
264
|
+
registerSsh({
|
|
265
|
+
registerTool(tool: unknown) {
|
|
266
|
+
renderer = (tool as { renderResult: typeof renderer }).renderResult;
|
|
267
|
+
},
|
|
268
|
+
} as unknown as ExtensionAPI);
|
|
269
|
+
if (!renderer) throw new Error("renderResult not registered");
|
|
270
|
+
return renderer;
|
|
271
|
+
};
|
|
272
|
+
const render = (
|
|
273
|
+
renderer: (...args: unknown[]) => { render(width: number): string[] },
|
|
274
|
+
details: Record<string, unknown> | undefined,
|
|
275
|
+
isError = false,
|
|
276
|
+
isPartial = false,
|
|
277
|
+
state: Record<string, unknown> = {},
|
|
278
|
+
expanded = true,
|
|
279
|
+
) =>
|
|
280
|
+
renderer(
|
|
281
|
+
{ content: [{ type: "text", text: String(details?.outcome ?? "done") }], details },
|
|
282
|
+
{ isPartial },
|
|
283
|
+
theme,
|
|
284
|
+
{ expanded, isError, invalidate: () => {}, state },
|
|
285
|
+
)
|
|
286
|
+
.render(24)
|
|
287
|
+
.join("\n");
|
|
288
|
+
|
|
289
|
+
it("frames open terminal outcomes and generic results by status", () => {
|
|
290
|
+
const renderer = register();
|
|
291
|
+
expect(render(renderer, undefined)).toContain("[success]─");
|
|
292
|
+
expect(render(renderer, undefined, true)).toContain("[error]─");
|
|
293
|
+
for (const outcome of ["denied", "timed-out", "cancelled", "error"]) {
|
|
294
|
+
expect(
|
|
295
|
+
render(renderer, {
|
|
296
|
+
_type: "sshResult",
|
|
297
|
+
command: "id",
|
|
298
|
+
host: "host",
|
|
299
|
+
sudo: false,
|
|
300
|
+
outcome,
|
|
301
|
+
}),
|
|
302
|
+
).toContain("[error]─");
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("leaves partial, running, and collapsed rows unframed", () => {
|
|
307
|
+
const renderer = register();
|
|
308
|
+
const running = {
|
|
309
|
+
_type: "sshResult",
|
|
310
|
+
command: "id",
|
|
311
|
+
host: "host",
|
|
312
|
+
sudo: false,
|
|
313
|
+
outcome: "running",
|
|
314
|
+
};
|
|
315
|
+
expect(render(renderer, running, false, true)).not.toContain("[success]─");
|
|
316
|
+
expect(render(renderer, running)).not.toContain("[success]─");
|
|
317
|
+
const success = { ...running, outcome: "success", exitCode: 0, _render: "done" };
|
|
318
|
+
expect(render(renderer, success, false, false, { collapsed: true }, false)).not.toContain("─");
|
|
319
|
+
});
|
|
320
|
+
});
|