@xynogen/pix-ssh 0.5.5 → 0.5.7

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.
Files changed (2) hide show
  1. package/package.json +4 -4
  2. package/src/index.ts +56 -53
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xynogen/pix-ssh",
3
- "version": "0.5.5",
4
- "description": "Pi tool — ssh_run: run remote commands over SSH with password/key auth and remote sudo",
3
+ "version": "0.5.7",
4
+ "description": "Pi tool \u2014 ssh_run: run remote commands over SSH with password/key auth and remote sudo",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
7
7
  "scripts": {
@@ -37,8 +37,8 @@
37
37
  "access": "public"
38
38
  },
39
39
  "dependencies": {
40
- "@xynogen/pix-pretty": "^1.25.0",
41
- "@xynogen/pix-runtime": "^0.12.2"
40
+ "@xynogen/pix-pretty": "^1.30.0",
41
+ "@xynogen/pix-runtime": "^0.13.0"
42
42
  },
43
43
  "peerDependencies": {
44
44
  "typebox": "*",
package/src/index.ts CHANGED
@@ -27,24 +27,28 @@
27
27
 
28
28
  import type { AgentToolUpdateCallback, ExtensionAPI } from "@earendil-works/pi-coding-agent";
29
29
  import { Text } from "@earendil-works/pi-tui";
30
- import { FG_DIM, RST, resolveBaseBackground } from "@xynogen/pix-pretty/ansi";
30
+ import { resolveBaseBackground } from "@xynogen/pix-pretty/ansi";
31
+ import { collapsedCommandRow, commandPreview } from "@xynogen/pix-pretty/command-preview";
31
32
  import { MAX_PREVIEW_LINES } from "@xynogen/pix-pretty/config";
32
33
  import { type OverlayResult, showOverlay } from "@xynogen/pix-pretty/gate-overlay";
33
34
  import { icon } from "@xynogen/pix-pretty/icon-catalog";
34
35
  import { renderBashOutput } from "@xynogen/pix-pretty/renderers";
35
36
  import type { RenderContextLike, ThemeLike, ToolResultLike } from "@xynogen/pix-pretty/types";
36
37
  import {
38
+ BODY_PAD,
39
+ bodyLine,
37
40
  dotJoin,
38
41
  fillToolBackground,
42
+ formatCollapsedToolRow,
43
+ formatToolCallTitle,
39
44
  frameToolResult,
40
45
  getErrorMessage,
41
46
  getTextContent,
42
47
  hideCollapsedToolCall,
48
+ moreLines,
43
49
  normalizeLineEndings,
44
- renderCollapsedToolRow,
45
50
  renderToolError,
46
51
  ruleFrame,
47
- sectionRule,
48
52
  termW,
49
53
  unframeToolResult,
50
54
  } from "@xynogen/pix-pretty/utils";
@@ -400,75 +404,70 @@ export default function (pi: ExtensionAPI): void {
400
404
  ...({ exposure: "deferred" } as const),
401
405
  label: "Run over SSH",
402
406
  description:
403
- "Run a command or transfer files/directories on a REMOTE host over SSH. " +
404
- "For the LOCAL machine use `bash` (or `sudo_run` for local root) instead — do not use ssh_run for local work. " +
405
- "Requires a `host`. Windows/PowerShell shells are best-effort (elevation and PowerShell stream/encoding semantics unsupported). " +
406
- "Set `sudo: true` to run the command as root on the remote machine. " +
407
- 'For transfer, set `action: "file"`, `direction`, `source`, `destination`, and optional `recursive` — ' +
408
- "transfers may overwrite the destination. " +
409
- 'To discover hosts without reading `~/.ssh/config`, use `action: "info"` — omit `host` to list configured aliases, or pass a `host` to get its effective config (no connection). ' +
410
- "Always provide a clear `reason`.",
407
+ "Operate on a REMOTE host over SSH (for the LOCAL machine use `bash`, or `sudo_run` for local root). " +
408
+ "Pick one `action`; each has its own parameters:\n" +
409
+ "- command: run a command. Required: host, command. Optional: sudo (remote POSIX root).\n" +
410
+ "- file: upload/download a file or directory (may overwrite the destination). Required: host, direction, source, destination. Optional: recursive.\n" +
411
+ "- info: read SSH config, no connection. Optional: host (omit = list aliases, set = effective config).\n" +
412
+ "Always set `reason`. Windows/PowerShell remotes are best-effort.",
411
413
  promptSnippet: "Run a remote command, transfer files, or read SSH config over SSH",
412
414
  promptGuidelines: [
413
- 'ssh_run: REMOTE host only — use `bash`/`sudo_run` for the local machine. `host` as `[user@]host[:port]`; `sudo` covers remote POSIX sudo only. For transfer use `action: "file"` with `direction: "upload"|"download"`, `source`, `destination`, optional `recursive` (may overwrite). Use `action: "info"` (no `host` = list aliases, with `host` = its effective config) instead of reading `~/.ssh/config` yourself. Always set `reason`.',
415
+ 'ssh_run: REMOTE host only — use `bash`/`sudo_run` locally. `action` is required: "command" (host, command, sudo?), "file" (host, direction, source, destination, recursive?), "info" (host? — no host = list aliases). `host` is `[user@]host[:port]`. Use "info" instead of reading `~/.ssh/config`. Always set `reason`.',
414
416
  ],
415
417
 
416
418
  renderShell: "self",
417
419
 
418
- // Single Type.Object (root `type: "object"`) rather than Type.Union — a union
420
+ // Single flat Type.Object (root `type: "object"`) rather than Type.Union — a union
419
421
  // serializes to `anyOf` with no root type, which strict OpenAI-compatible
420
- // providers (e.g. DeepSeek) reject with `type: null`. Conditional fields are
421
- // optional and normalized/validated at runtime via normalizeOperation.
422
+ // providers (e.g. DeepSeek) reject with `type: null`. Only `action` is
423
+ // schema-required; per-action fields are tagged in their descriptions and
424
+ // validated at runtime via normalizeOperation.
422
425
  parameters: Type.Object({
423
- action: Type.Optional(
424
- Type.Union([Type.Literal("command"), Type.Literal("file"), Type.Literal("info")], {
425
- description:
426
- '"command" (default) runs a remote command; "file" transfers a file/directory; "info" reports SSH config (no connection) — list configured host aliases, or resolve one host\'s effective config when `host` is given.',
427
- }),
428
- ),
426
+ action: Type.Union([Type.Literal("command"), Type.Literal("file"), Type.Literal("info")], {
427
+ description:
428
+ '"command" runs a remote command, "file" transfers a file/directory, "info" reads SSH config (no connection).',
429
+ }),
429
430
  host: Type.Optional(
430
431
  Type.String({
431
432
  description:
432
- "Remote target as `[user@]host[:port]` (e.g. `deploy@10.0.0.5:2222`). Required for command/file; for info, omit to list all aliases or set it to resolve one host.",
433
+ "`[user@]host[:port]`, e.g. `deploy@10.0.0.5:2222`. REQUIRED for command and file. For info: omit to list aliases, set to resolve one host.",
433
434
  }),
434
435
  ),
436
+ // command
435
437
  command: Type.Optional(
436
438
  Type.String({
437
- description:
438
- 'Command sent to the remote host\'s configured SSH shell. Required when action is "command".',
439
+ description: "[command] REQUIRED. Sent to the remote host's configured SSH shell.",
439
440
  }),
440
441
  ),
441
442
  sudo: Type.Optional(
442
443
  Type.Boolean({
443
- description:
444
- "Run the command as root on the remote host via sudo. Default false. Command action only.",
444
+ description: "[command] Optional, default false. Run as root on the remote via sudo.",
445
445
  }),
446
446
  ),
447
+ // file
447
448
  direction: Type.Optional(
448
449
  Type.Union([Type.Literal("upload"), Type.Literal("download")], {
449
- description: 'Transfer direction. Required when action is "file".',
450
+ description: "[file] REQUIRED. Transfer direction.",
450
451
  }),
451
452
  ),
452
453
  source: Type.Optional(
453
454
  Type.String({
454
- description:
455
- 'Source path (local for upload, remote for download). Required when action is "file".',
455
+ description: "[file] REQUIRED. Local path for upload, remote path for download.",
456
456
  }),
457
457
  ),
458
458
  destination: Type.Optional(
459
459
  Type.String({
460
- description:
461
- 'Destination path (remote for upload, local for download). Required when action is "file".',
460
+ description: "[file] REQUIRED. Remote path for upload, local path for download.",
462
461
  }),
463
462
  ),
464
463
  recursive: Type.Optional(
465
464
  Type.Boolean({
466
- description: "Copy a directory recursively. Default false. File action only.",
465
+ description: "[file] Optional, default false. Copy a directory recursively.",
467
466
  }),
468
467
  ),
469
468
  reason: Type.Optional(
470
469
  Type.String({
471
- description: "Short plain-English explanation of intent, shown to the user.",
470
+ description: "All actions. Short plain-English intent, shown to the user.",
472
471
  }),
473
472
  ),
474
473
  }),
@@ -884,20 +883,23 @@ export default function (pi: ExtensionAPI): void {
884
883
  if (args.action === "info") {
885
884
  text.setText(
886
885
  fillToolBackground(
887
- `${theme.fg("toolTitle", theme.bold("ssh info"))} ${theme.fg("dim", host || "list aliases")}`,
886
+ `${formatToolCallTitle(theme, "ssh info", renderCtx)} ${theme.fg("dim", host || "list aliases")}`,
888
887
  ),
889
888
  );
890
889
  return text;
891
890
  }
892
891
  const operation = normalizeOperation(args);
893
- const command = safeOneLine(operation.command) || "(empty command)";
892
+ const command = operation.command || "(empty command)";
894
893
  const prefix = operation.sudo ? "sudo " : "";
895
- text.setText(
896
- fillToolBackground(
897
- `${theme.fg("toolTitle", theme.bold(operation.action === "file" ? "ssh file" : "ssh"))} ${theme.fg("dim", host)} ${theme.fg("muted", prefix + command)}`,
898
- ),
894
+ return commandPreview(
895
+ `${formatToolCallTitle(theme, operation.action === "file" ? "ssh file" : "ssh", renderCtx)} ${theme.fg("dim", host)}`,
896
+ prefix + command,
897
+ "bash",
898
+ theme,
899
+ renderCtx.state,
900
+ renderCtx.invalidate,
901
+ renderCtx.expanded,
899
902
  );
900
- return text;
901
903
  }) as never,
902
904
 
903
905
  renderResult: ((
@@ -917,7 +919,7 @@ export default function (pi: ExtensionAPI): void {
917
919
  text.setText(renderToolError(getTextContent(result) || "Error", theme));
918
920
  } else {
919
921
  text.setText(
920
- fillToolBackground(` ${theme.fg("muted", getTextContent(result) || "done")}`),
922
+ fillToolBackground(`${BODY_PAD}${theme.fg("muted", getTextContent(result) || "done")}`),
921
923
  );
922
924
  }
923
925
  return isPartial ? text : completed(renderCtx.isError);
@@ -934,21 +936,24 @@ export default function (pi: ExtensionAPI): void {
934
936
  )
935
937
  ) {
936
938
  const status = details.outcome === "success" ? "success" : "error";
937
- text.setText(
938
- renderCollapsedToolRow(
939
+ return collapsedCommandRow(
940
+ formatCollapsedToolRow(
939
941
  theme,
940
942
  "ssh",
941
- `${details.host} ${safeOneLine(details.command)}`,
943
+ details.host,
942
944
  terminalMeta(details),
943
945
  status,
944
- ),
946
+ ).trimEnd(),
947
+ safeOneLine(details.command),
948
+ theme,
945
949
  );
946
- return text;
947
950
  }
948
951
 
949
952
  if (details.outcome === "awaiting-approval" || details.outcome === "running") {
950
953
  text.setText(
951
- fillToolBackground(` ${theme.fg("muted", getTextContent(result) || "working")}`),
954
+ fillToolBackground(
955
+ `${BODY_PAD}${theme.fg("muted", getTextContent(result) || "working")}`,
956
+ ),
952
957
  );
953
958
  return text;
954
959
  }
@@ -958,7 +963,7 @@ export default function (pi: ExtensionAPI): void {
958
963
  text.setText(
959
964
  details.outcome === "error"
960
965
  ? renderToolError(diagnostic, theme)
961
- : fillToolBackground(` ${theme.fg("warning", diagnostic)}`),
966
+ : fillToolBackground(`${BODY_PAD}${theme.fg("warning", diagnostic)}`),
962
967
  );
963
968
  return isPartial ? text : completed(true);
964
969
  }
@@ -970,18 +975,16 @@ export default function (pi: ExtensionAPI): void {
970
975
  const lineCount = lines.length;
971
976
 
972
977
  if (!rendered) {
973
- text.setText(fillToolBackground(` ${summary}`));
978
+ text.setText(fillToolBackground(`${BODY_PAD}${summary}`));
974
979
  return isPartial ? text : completed(details.outcome !== "success");
975
980
  }
976
981
 
977
982
  const maxShow = renderCtx.expanded ? lineCount : MAX_PREVIEW_LINES;
978
983
  const show = lines.slice(0, maxShow);
979
- const footer =
980
- lineCount > maxShow ? [`${FG_DIM} … ${lineCount - maxShow} more lines${RST}`] : [];
984
+ const footer = lineCount > maxShow ? [moreLines(lineCount - maxShow, theme)] : [];
981
985
  const statusKey = details.outcome === "success" ? "success" : "error";
982
986
  const paint = (s: string) => theme.fg(statusKey, s);
983
- const sw = Math.max(8, termW() - 4); // section-rule width inside the 2-space indent
984
- const body = show.map((line) => ` ${sectionRule(line, theme, sw) ?? line}`);
987
+ const body = show.map((line) => bodyLine(line, theme));
985
988
  const out = isPartial ? [...body, ...footer] : ruleFrame(body, footer, termW(), paint);
986
989
  text.setText(fillToolBackground(out.join("\n")));
987
990
  return text;