@oh-my-pi/pi-coding-agent 16.1.23 → 16.2.1

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 (260) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/dist/cli.js +3785 -3695
  3. package/dist/types/advisor/__tests__/emission-guard.test.d.ts +1 -0
  4. package/dist/types/advisor/emission-guard.d.ts +73 -0
  5. package/dist/types/advisor/index.d.ts +1 -0
  6. package/dist/types/advisor/runtime.d.ts +7 -0
  7. package/dist/types/advisor/watchdog.d.ts +13 -0
  8. package/dist/types/cli/flag-tables.d.ts +1 -0
  9. package/dist/types/cli/gallery-fixtures/search.d.ts +1 -1
  10. package/dist/types/collab/display-name.d.ts +3 -0
  11. package/dist/types/collab/host.d.ts +0 -2
  12. package/dist/types/commands/worktree.d.ts +0 -3
  13. package/dist/types/config/inline-tool-descriptors-mode.d.ts +13 -0
  14. package/dist/types/config/model-discovery.d.ts +6 -1
  15. package/dist/types/config/model-registry.d.ts +6 -2
  16. package/dist/types/config/model-resolver.d.ts +12 -0
  17. package/dist/types/config/models-config-schema.d.ts +29 -2
  18. package/dist/types/config/models-config.d.ts +22 -1
  19. package/dist/types/config/settings-schema.d.ts +91 -21
  20. package/dist/types/config/settings.d.ts +2 -0
  21. package/dist/types/dap/config.d.ts +1 -1
  22. package/dist/types/edit/hashline/filesystem.d.ts +4 -2
  23. package/dist/types/edit/renderer.d.ts +4 -0
  24. package/dist/types/extensibility/extensions/types.d.ts +17 -17
  25. package/dist/types/extensibility/hooks/types.d.ts +10 -10
  26. package/dist/types/extensibility/plugins/marketplace/types.d.ts +1 -0
  27. package/dist/types/internal-urls/__tests__/ssh-protocol.test.d.ts +1 -0
  28. package/dist/types/internal-urls/index.d.ts +1 -0
  29. package/dist/types/internal-urls/registry-helpers.d.ts +2 -0
  30. package/dist/types/internal-urls/router.d.ts +1 -1
  31. package/dist/types/internal-urls/ssh-protocol.d.ts +10 -0
  32. package/dist/types/internal-urls/types.d.ts +19 -2
  33. package/dist/types/irc/bus.d.ts +6 -0
  34. package/dist/types/modes/acp/acp-event-mapper.d.ts +1 -0
  35. package/dist/types/modes/components/__tests__/move-overlay.test.d.ts +1 -0
  36. package/dist/types/modes/components/custom-editor.d.ts +7 -0
  37. package/dist/types/modes/components/move-overlay.d.ts +23 -0
  38. package/dist/types/modes/components/plugin-selector.d.ts +2 -1
  39. package/dist/types/modes/components/queue-mode-selector.d.ts +2 -1
  40. package/dist/types/modes/components/select-list-mouse-routing.d.ts +2 -0
  41. package/dist/types/modes/components/settings-defs.d.ts +4 -1
  42. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  43. package/dist/types/modes/components/show-images-selector.d.ts +2 -1
  44. package/dist/types/modes/components/status-line/component.d.ts +2 -0
  45. package/dist/types/modes/components/status-line/types.d.ts +2 -0
  46. package/dist/types/modes/components/theme-selector.d.ts +2 -1
  47. package/dist/types/modes/components/thinking-selector.d.ts +2 -1
  48. package/dist/types/modes/controllers/command-controller.d.ts +10 -1
  49. package/dist/types/modes/controllers/streaming-reveal.d.ts +6 -0
  50. package/dist/types/modes/interactive-mode.d.ts +10 -1
  51. package/dist/types/modes/internal-url-autocomplete.d.ts +1 -1
  52. package/dist/types/modes/running-subagent-badge.d.ts +6 -0
  53. package/dist/types/modes/theme/theme.d.ts +1 -1
  54. package/dist/types/modes/types.d.ts +9 -1
  55. package/dist/types/sdk.d.ts +2 -2
  56. package/dist/types/session/agent-session.d.ts +8 -1
  57. package/dist/types/session/blob-store.d.ts +4 -0
  58. package/dist/types/session/session-manager.d.ts +13 -0
  59. package/dist/types/slash-commands/builtin-registry.d.ts +1 -1
  60. package/dist/types/ssh/__tests__/connection-manager-args.test.d.ts +1 -0
  61. package/dist/types/ssh/__tests__/file-transfer-posix-guard.test.d.ts +1 -0
  62. package/dist/types/ssh/connection-manager.d.ts +2 -0
  63. package/dist/types/ssh/file-transfer.d.ts +79 -0
  64. package/dist/types/ssh/utils.d.ts +6 -0
  65. package/dist/types/system-prompt.d.ts +3 -0
  66. package/dist/types/task/executor.d.ts +16 -0
  67. package/dist/types/tiny/text.d.ts +1 -1
  68. package/dist/types/tools/builtin-names.d.ts +5 -1
  69. package/dist/types/tools/{find.d.ts → glob.d.ts} +15 -15
  70. package/dist/types/tools/{search.d.ts → grep.d.ts} +14 -14
  71. package/dist/types/tools/index.d.ts +3 -3
  72. package/dist/types/tools/path-utils.d.ts +29 -0
  73. package/dist/types/tools/read.d.ts +2 -2
  74. package/dist/types/tools/render-utils.d.ts +8 -0
  75. package/dist/types/tools/renderers.d.ts +11 -0
  76. package/dist/types/tools/ssh.d.ts +2 -0
  77. package/dist/types/tools/todo.d.ts +0 -16
  78. package/dist/types/tools/write.d.ts +2 -2
  79. package/dist/types/utils/active-repo-context.d.ts +8 -0
  80. package/dist/types/utils/image-resize.d.ts +1 -0
  81. package/dist/types/utils/markit-cache.d.ts +23 -0
  82. package/dist/types/utils/markit.d.ts +5 -1
  83. package/dist/types/utils/prompt-path.d.ts +1 -0
  84. package/dist/types/web/search/providers/duckduckgo.d.ts +14 -0
  85. package/dist/types/web/search/providers/firecrawl.d.ts +28 -0
  86. package/dist/types/web/search/providers/tinyfish.d.ts +29 -0
  87. package/dist/types/web/search/providers/xai.d.ts +13 -0
  88. package/dist/types/web/search/types.d.ts +18 -2
  89. package/package.json +30 -15
  90. package/scripts/build-binary.ts +9 -9
  91. package/scripts/bundle-dist.ts +2 -2
  92. package/src/advisor/__tests__/advisor.test.ts +61 -4
  93. package/src/advisor/__tests__/emission-guard.test.ts +147 -0
  94. package/src/advisor/advise-tool.ts +1 -1
  95. package/src/advisor/emission-guard.ts +172 -0
  96. package/src/advisor/index.ts +1 -0
  97. package/src/advisor/runtime.ts +11 -0
  98. package/src/advisor/watchdog.ts +27 -1
  99. package/src/cli/args.ts +5 -2
  100. package/src/cli/auth-broker-cli.ts +17 -0
  101. package/src/cli/config-cli.ts +4 -0
  102. package/src/cli/flag-tables.ts +7 -4
  103. package/src/cli/gallery-cli.ts +14 -2
  104. package/src/cli/gallery-fixtures/edit.ts +60 -0
  105. package/src/cli/gallery-fixtures/fs.ts +17 -17
  106. package/src/cli/gallery-fixtures/search.ts +4 -4
  107. package/src/cli/usage-cli.ts +20 -1
  108. package/src/cli/web-search-cli.ts +1 -1
  109. package/src/collab/display-name.ts +13 -0
  110. package/src/collab/guest.ts +5 -1
  111. package/src/collab/host.ts +2 -13
  112. package/src/commands/worktree.ts +6 -0
  113. package/src/config/inline-tool-descriptors-mode.ts +27 -0
  114. package/src/config/model-discovery.ts +77 -8
  115. package/src/config/model-registry.ts +89 -11
  116. package/src/config/model-resolver.ts +39 -1
  117. package/src/config/models-config-schema.ts +41 -5
  118. package/src/config/models-config.ts +4 -1
  119. package/src/config/settings-schema.ts +85 -27
  120. package/src/config/settings.ts +172 -1
  121. package/src/cursor.ts +1 -1
  122. package/src/dap/config.ts +152 -8
  123. package/src/dap/session.ts +1 -1
  124. package/src/discovery/claude-plugins.ts +3 -2
  125. package/src/discovery/helpers.ts +3 -1
  126. package/src/edit/hashline/diff.ts +14 -3
  127. package/src/edit/hashline/execute.ts +42 -6
  128. package/src/edit/hashline/filesystem.ts +38 -2
  129. package/src/edit/index.ts +1 -0
  130. package/src/edit/modes/patch.ts +11 -1
  131. package/src/edit/renderer.ts +140 -13
  132. package/src/eval/__tests__/agent-bridge.test.ts +101 -0
  133. package/src/eval/agent-bridge.ts +26 -3
  134. package/src/eval/js/tool-bridge.ts +2 -2
  135. package/src/export/html/index.ts +1 -1
  136. package/src/export/html/template.js +3 -1
  137. package/src/export/html/tool-views.generated.js +20 -20
  138. package/src/extensibility/extensions/types.ts +22 -22
  139. package/src/extensibility/hooks/types.ts +11 -11
  140. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +8 -3
  141. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +25 -10
  142. package/src/extensibility/plugins/marketplace/manager.ts +22 -0
  143. package/src/extensibility/plugins/marketplace/types.ts +1 -0
  144. package/src/internal-urls/__tests__/ssh-protocol.test.ts +331 -0
  145. package/src/internal-urls/docs-index.generated.txt +2 -2
  146. package/src/internal-urls/docs-index.ts +2 -3
  147. package/src/internal-urls/index.ts +1 -0
  148. package/src/internal-urls/registry-helpers.ts +19 -4
  149. package/src/internal-urls/router.ts +5 -3
  150. package/src/internal-urls/ssh-protocol.ts +367 -0
  151. package/src/internal-urls/types.ts +19 -2
  152. package/src/irc/bus.ts +11 -3
  153. package/src/mcp/tool-bridge.ts +32 -2
  154. package/src/memories/index.ts +1 -1
  155. package/src/modes/acp/acp-agent.ts +14 -1
  156. package/src/modes/acp/acp-event-mapper.ts +91 -27
  157. package/src/modes/components/__tests__/move-overlay.test.ts +166 -0
  158. package/src/modes/components/agent-hub.ts +3 -0
  159. package/src/modes/components/agent-transcript-viewer.ts +9 -7
  160. package/src/modes/components/custom-editor.ts +90 -5
  161. package/src/modes/components/move-overlay.ts +282 -0
  162. package/src/modes/components/plan-review-overlay.ts +35 -35
  163. package/src/modes/components/plugin-selector.ts +6 -1
  164. package/src/modes/components/queue-mode-selector.ts +6 -1
  165. package/src/modes/components/select-list-mouse-routing.ts +35 -0
  166. package/src/modes/components/session-selector.ts +11 -10
  167. package/src/modes/components/settings-defs.ts +14 -1
  168. package/src/modes/components/settings-selector.ts +196 -29
  169. package/src/modes/components/show-images-selector.ts +6 -1
  170. package/src/modes/components/status-line/component.ts +108 -28
  171. package/src/modes/components/status-line/segments.ts +5 -1
  172. package/src/modes/components/status-line/types.ts +2 -0
  173. package/src/modes/components/theme-selector.ts +6 -1
  174. package/src/modes/components/thinking-selector.ts +6 -1
  175. package/src/modes/components/tool-execution.ts +25 -9
  176. package/src/modes/components/tree-selector.ts +5 -5
  177. package/src/modes/controllers/command-controller.ts +140 -47
  178. package/src/modes/controllers/event-controller.ts +59 -3
  179. package/src/modes/controllers/input-controller.ts +68 -2
  180. package/src/modes/controllers/selector-controller.ts +5 -2
  181. package/src/modes/controllers/streaming-reveal.ts +17 -0
  182. package/src/modes/controllers/tool-args-reveal.ts +1 -1
  183. package/src/modes/interactive-mode.ts +116 -50
  184. package/src/modes/internal-url-autocomplete.ts +17 -2
  185. package/src/modes/prompt-action-autocomplete.ts +3 -1
  186. package/src/modes/running-subagent-badge.ts +13 -0
  187. package/src/modes/setup-wizard/scenes/glyph.ts +2 -13
  188. package/src/modes/setup-wizard/scenes/providers.ts +2 -1
  189. package/src/modes/setup-wizard/scenes/theme.ts +6 -12
  190. package/src/modes/setup-wizard/scenes/web-search.ts +8 -13
  191. package/src/modes/setup-wizard/wizard-overlay.ts +6 -5
  192. package/src/modes/theme/theme.ts +9 -1
  193. package/src/modes/types.ts +9 -1
  194. package/src/modes/utils/interactive-context-helpers.ts +1 -1
  195. package/src/priority.json +15 -0
  196. package/src/prompts/advisor/active-repo-watchdog.md +6 -0
  197. package/src/prompts/advisor/context-files.md +8 -0
  198. package/src/prompts/advisor/system.md +21 -7
  199. package/src/prompts/agents/designer.md +1 -1
  200. package/src/prompts/agents/explore.md +1 -1
  201. package/src/prompts/agents/librarian.md +2 -2
  202. package/src/prompts/agents/plan.md +2 -2
  203. package/src/prompts/agents/reviewer.md +1 -1
  204. package/src/prompts/agents/task.md +2 -2
  205. package/src/prompts/system/active-repo-context.md +4 -0
  206. package/src/prompts/system/gemini-tool-call-reminder.md +9 -0
  207. package/src/prompts/system/plan-mode-active.md +3 -3
  208. package/src/prompts/system/project-prompt.md +2 -2
  209. package/src/prompts/system/system-prompt.md +6 -6
  210. package/src/prompts/tools/bash.md +2 -2
  211. package/src/prompts/tools/checkpoint.md +1 -1
  212. package/src/prompts/tools/{find.md → glob.md} +1 -1
  213. package/src/prompts/tools/{search.md → grep.md} +3 -3
  214. package/src/prompts/tools/read.md +4 -2
  215. package/src/sdk.ts +65 -25
  216. package/src/session/agent-session.ts +519 -132
  217. package/src/session/blob-store.ts +24 -0
  218. package/src/session/session-history-format.ts +2 -2
  219. package/src/session/session-manager.ts +46 -0
  220. package/src/slash-commands/builtin-registry.ts +138 -20
  221. package/src/slash-commands/helpers/usage-report.ts +23 -2
  222. package/src/ssh/__tests__/connection-manager-args.test.ts +69 -0
  223. package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +68 -0
  224. package/src/ssh/connection-manager.ts +12 -15
  225. package/src/ssh/file-transfer.ts +209 -0
  226. package/src/ssh/utils.ts +24 -0
  227. package/src/system-prompt.ts +56 -28
  228. package/src/task/executor.ts +96 -36
  229. package/src/task/index.ts +3 -3
  230. package/src/task/render.ts +14 -13
  231. package/src/task/worktree.ts +38 -1
  232. package/src/tiny/text.ts +49 -4
  233. package/src/tiny/worker.ts +3 -3
  234. package/src/tools/bash.ts +2 -2
  235. package/src/tools/builtin-names.ts +26 -2
  236. package/src/tools/{find.ts → glob.ts} +48 -42
  237. package/src/tools/{search.ts → grep.ts} +311 -129
  238. package/src/tools/index.ts +13 -14
  239. package/src/tools/irc.ts +6 -2
  240. package/src/tools/path-utils.ts +64 -1
  241. package/src/tools/read.ts +35 -6
  242. package/src/tools/render-utils.ts +14 -0
  243. package/src/tools/renderers.ts +15 -4
  244. package/src/tools/ssh.ts +17 -2
  245. package/src/tools/todo.ts +4 -26
  246. package/src/tools/write.ts +25 -9
  247. package/src/utils/active-repo-context.ts +143 -0
  248. package/src/utils/image-resize.ts +88 -7
  249. package/src/utils/lang-from-path.ts +3 -3
  250. package/src/utils/markit-cache.ts +166 -0
  251. package/src/utils/markit.ts +86 -18
  252. package/src/utils/prompt-path.ts +3 -0
  253. package/src/utils/title-generator.ts +1 -1
  254. package/src/web/search/provider.ts +54 -34
  255. package/src/web/search/providers/duckduckgo.ts +140 -0
  256. package/src/web/search/providers/firecrawl.ts +144 -0
  257. package/src/web/search/providers/tinyfish.ts +159 -0
  258. package/src/web/search/providers/xai.ts +292 -0
  259. package/src/web/search/providers/zai.ts +142 -56
  260. package/src/web/search/types.ts +6 -2
@@ -0,0 +1,331 @@
1
+ import { afterEach, describe, expect, it, vi } from "bun:test";
2
+ import * as capability from "../../capability";
3
+ import type { SSHHost } from "../../capability/ssh";
4
+ import type { CapabilityResult, SourceMeta } from "../../capability/types";
5
+ import * as fileTransfer from "../../ssh/file-transfer";
6
+ import { parseInternalUrl } from "../parse";
7
+ import { SshProtocolHandler } from "../ssh-protocol";
8
+
9
+ const SOURCE: SourceMeta = {
10
+ provider: "ssh-json",
11
+ providerName: "SSH Config",
12
+ path: "/test/ssh.json",
13
+ level: "user",
14
+ };
15
+
16
+ function mockHosts(hosts: SSHHost[] = []): void {
17
+ const result: CapabilityResult<SSHHost> = {
18
+ items: hosts,
19
+ all: hosts,
20
+ warnings: [],
21
+ providers: hosts.length ? ["ssh-json"] : [],
22
+ };
23
+ vi.spyOn(capability, "loadCapability").mockResolvedValue(result as CapabilityResult<unknown>);
24
+ }
25
+
26
+ function mockReadBytes(text: string, truncated = false) {
27
+ vi.spyOn(fileTransfer, "statRemotePath").mockResolvedValue("file");
28
+ return vi
29
+ .spyOn(fileTransfer, "readRemoteFile")
30
+ .mockResolvedValue({ bytes: new TextEncoder().encode(text), truncated });
31
+ }
32
+
33
+ describe("SshProtocolHandler", () => {
34
+ const handler = new SshProtocolHandler();
35
+
36
+ afterEach(() => {
37
+ vi.restoreAllMocks();
38
+ });
39
+
40
+ it("resolves a remote text file byte-exact with no sourcePath", async () => {
41
+ mockHosts();
42
+ mockReadBytes("127.0.0.1 a\n");
43
+ const resource = await handler.resolve(parseInternalUrl("ssh://icaro/etc/hosts"));
44
+ expect(resource.content).toBe("127.0.0.1 a\n");
45
+ expect(resource.contentType).toBe("text/plain");
46
+ // No sourcePath keeps search on the virtual-resource path (stays `ssh://…`).
47
+ expect(resource.sourcePath).toBeUndefined();
48
+ });
49
+
50
+ it("derives contentType from the file extension", async () => {
51
+ mockHosts();
52
+ mockReadBytes("# title\n");
53
+ expect((await handler.resolve(parseInternalUrl("ssh://icaro/tmp/readme.md"))).contentType).toBe("text/markdown");
54
+ mockReadBytes("{}\n");
55
+ expect((await handler.resolve(parseInternalUrl("ssh://icaro/tmp/data.json"))).contentType).toBe(
56
+ "application/json",
57
+ );
58
+ });
59
+
60
+ it("rejects user/port overrides on a configured host", async () => {
61
+ mockHosts([{ _source: SOURCE, name: "icaro", host: "10.0.0.1" }]);
62
+ mockReadBytes("x");
63
+ await expect(handler.resolve(parseInternalUrl("ssh://user@icaro:22/x"))).rejects.toThrow(/user\/port overrides/);
64
+ });
65
+
66
+ it("treats an unconfigured authority as an opaque OpenSSH destination", async () => {
67
+ mockHosts();
68
+ const spy = mockReadBytes("data\n");
69
+ await handler.resolve(parseInternalUrl("ssh://bob@h1:2222/x"));
70
+ expect(spy.mock.calls[0]?.[0]).toMatchObject({ name: "bob@h1:2222", host: "h1", username: "bob", port: 2222 });
71
+ });
72
+
73
+ it("matches a configured reserved-char host via its percent-encoded name", async () => {
74
+ mockHosts([{ _source: SOURCE, name: "alice@prod", host: "10.0.0.9", username: "alice" }]);
75
+ const spy = mockReadBytes("ok\n");
76
+ await handler.resolve(parseInternalUrl("ssh://alice%40prod/etc/hostname"));
77
+ // Encoded `%40` authority decodes to the alias name → uses the alias's host/user.
78
+ expect(spy.mock.calls[0]?.[0]).toMatchObject({ name: "alice@prod", host: "10.0.0.9", username: "alice" });
79
+ });
80
+
81
+ it("treats a literal user@host as opaque, not the encoded alias", async () => {
82
+ mockHosts([{ _source: SOURCE, name: "alice@prod", host: "10.0.0.9", username: "alice" }]);
83
+ const spy = mockReadBytes("ok\n");
84
+ // Literal `@`: username=alice, bare host=prod (unconfigured) → opaque, NOT the alias's 10.0.0.9.
85
+ await handler.resolve(parseInternalUrl("ssh://alice@prod/etc/hostname"));
86
+ expect(spy.mock.calls[0]?.[0]).toMatchObject({ name: "alice@prod", host: "prod", username: "alice" });
87
+ });
88
+
89
+ it("lists the remote root directory for ssh://host/", async () => {
90
+ mockHosts();
91
+ vi.spyOn(fileTransfer, "readRemoteFile").mockRejectedValue(new Error("Is a directory"));
92
+ vi.spyOn(fileTransfer, "statRemotePath").mockResolvedValue("directory");
93
+ const listSpy = vi.spyOn(fileTransfer, "listRemoteDir").mockResolvedValue([{ name: "etc", isDirectory: true }]);
94
+ const res = await handler.resolve(parseInternalUrl("ssh://icaro/"));
95
+ expect(res.isDirectory).toBe(true);
96
+ expect(res.content).toBe("etc/");
97
+ expect(listSpy.mock.calls[0]?.[1]).toBe("/");
98
+ });
99
+
100
+ it("rejects a binary / non-UTF-8 file instead of returning a resource", async () => {
101
+ mockHosts();
102
+ vi.spyOn(fileTransfer, "statRemotePath").mockResolvedValue("file");
103
+ vi.spyOn(fileTransfer, "readRemoteFile").mockResolvedValue({
104
+ bytes: new Uint8Array([0x7f, 0x45, 0x4c, 0x46, 0x00, 0x01]),
105
+ truncated: false,
106
+ });
107
+ await expect(handler.resolve(parseInternalUrl("ssh://icaro/bin/true"))).rejects.toThrow(/binary or non-UTF-8/);
108
+ });
109
+
110
+ it("rejects a file whose first invalid byte falls past the old 8 KiB sniff window", async () => {
111
+ mockHosts();
112
+ vi.spyOn(fileTransfer, "statRemotePath").mockResolvedValue("file");
113
+ const bytes = new Uint8Array(9001);
114
+ bytes.fill(0x61); // 9000 'a' bytes — valid UTF-8 within the former 8 KiB window
115
+ bytes[9000] = 0xff; // lone invalid UTF-8 byte the old prefix sniff never inspected
116
+ vi.spyOn(fileTransfer, "readRemoteFile").mockResolvedValue({ bytes, truncated: false });
117
+ await expect(handler.resolve(parseInternalUrl("ssh://icaro/var/log/app.log"))).rejects.toThrow(
118
+ /binary or non-UTF-8/,
119
+ );
120
+ });
121
+
122
+ it("rejects a file that exceeds the size cap", async () => {
123
+ mockHosts();
124
+ vi.spyOn(fileTransfer, "statRemotePath").mockResolvedValue("file");
125
+ vi.spyOn(fileTransfer, "readRemoteFile").mockResolvedValue({
126
+ bytes: new TextEncoder().encode("partial"),
127
+ truncated: true,
128
+ });
129
+ await expect(handler.resolve(parseInternalUrl("ssh://icaro/big.log"))).rejects.toThrow(/exceeds the 1 MiB limit/);
130
+ });
131
+
132
+ it("writes content byte-exact through writeRemoteFile", async () => {
133
+ mockHosts();
134
+ const spy = vi.spyOn(fileTransfer, "writeRemoteFile").mockResolvedValue(undefined);
135
+ await handler.write(parseInternalUrl("ssh://icaro/tmp/x"), "hi\n\t!\n");
136
+ expect(spy).toHaveBeenCalledTimes(1);
137
+ expect(spy.mock.calls[0]?.[2]).toEqual(new TextEncoder().encode("hi\n\t!\n"));
138
+ });
139
+
140
+ it("lists a remote directory when the path is not a readable file", async () => {
141
+ mockHosts();
142
+ vi.spyOn(fileTransfer, "readRemoteFile").mockRejectedValue(
143
+ new Error("head: error reading '/etc': Is a directory"),
144
+ );
145
+ vi.spyOn(fileTransfer, "statRemotePath").mockResolvedValue("directory");
146
+ const listSpy = vi.spyOn(fileTransfer, "listRemoteDir").mockResolvedValue([
147
+ { name: "conf.d", isDirectory: true },
148
+ { name: "hosts", isDirectory: false },
149
+ ]);
150
+ const res = await handler.resolve(parseInternalUrl("ssh://icaro/etc"));
151
+ expect(res.isDirectory).toBe(true);
152
+ expect(res.immutable).toBe(true);
153
+ expect(res.sourcePath).toBeUndefined();
154
+ expect(res.content).toBe("conf.d/\nhosts");
155
+ // read fail → stat → list must target the same remote path, not a peeled/normalized variant.
156
+ expect(listSpy.mock.calls[0]?.[1]).toBe("/etc");
157
+ });
158
+
159
+ it("renders an empty remote directory", async () => {
160
+ mockHosts();
161
+ vi.spyOn(fileTransfer, "readRemoteFile").mockRejectedValue(new Error("Is a directory"));
162
+ vi.spyOn(fileTransfer, "statRemotePath").mockResolvedValue("directory");
163
+ vi.spyOn(fileTransfer, "listRemoteDir").mockResolvedValue([]);
164
+ const res = await handler.resolve(parseInternalUrl("ssh://icaro/empty"));
165
+ expect(res.content).toBe("(empty directory)");
166
+ expect(res.isDirectory).toBe(true);
167
+ });
168
+
169
+ it("rethrows the original read error when the path is missing, not a directory", async () => {
170
+ mockHosts();
171
+ vi.spyOn(fileTransfer, "readRemoteFile").mockRejectedValue(
172
+ new Error("head: cannot open '/nope': No such file or directory"),
173
+ );
174
+ vi.spyOn(fileTransfer, "statRemotePath").mockResolvedValue("missing");
175
+ await expect(handler.resolve(parseInternalUrl("ssh://icaro/nope"))).rejects.toThrow(/No such file or directory/);
176
+ });
177
+
178
+ it("rejects a remote special file (FIFO/device) without reading it", async () => {
179
+ mockHosts();
180
+ vi.spyOn(fileTransfer, "statRemotePath").mockResolvedValue("other");
181
+ const readSpy = vi
182
+ .spyOn(fileTransfer, "readRemoteFile")
183
+ .mockResolvedValue({ bytes: new Uint8Array(), truncated: false });
184
+ await expect(handler.resolve(parseInternalUrl("ssh://icaro/dev/zero"))).rejects.toThrow(/not a regular file/);
185
+ expect(readSpy).not.toHaveBeenCalled();
186
+ });
187
+
188
+ it("autocompletes configured hosts and threads cwd to the capability load", async () => {
189
+ const spy = vi.spyOn(capability, "loadCapability").mockResolvedValue({
190
+ items: [
191
+ { name: "web1", host: "10.0.0.1", username: "deploy", _source: SOURCE },
192
+ { name: "db", host: "db.internal", _source: SOURCE },
193
+ ],
194
+ all: [],
195
+ warnings: [],
196
+ providers: [],
197
+ } as CapabilityResult<SSHHost>);
198
+ const candidates = await handler.complete("", { cwd: "/tmp/proj" });
199
+ expect(candidates.map(c => c.value).sort()).toEqual(["db", "web1"]);
200
+ expect(candidates.find(c => c.value === "web1")?.description).toContain("deploy@10.0.0.1");
201
+ expect(spy.mock.calls[0]?.[1]).toEqual({ cwd: "/tmp/proj" });
202
+ });
203
+
204
+ it("lists configured hosts for a bare ssh:// read using the context cwd", async () => {
205
+ const spy = vi.spyOn(capability, "loadCapability").mockResolvedValue({
206
+ items: [{ name: "web1", host: "10.0.0.1", _source: SOURCE }],
207
+ all: [],
208
+ warnings: [],
209
+ providers: [],
210
+ } as CapabilityResult<SSHHost>);
211
+ const res = await handler.resolve(parseInternalUrl("ssh://"), { cwd: "/tmp/proj" });
212
+ expect(res.immutable).toBe(true);
213
+ expect(res.sourcePath).toBeUndefined();
214
+ expect(res.content).toContain("[web1](ssh://web1/)");
215
+ expect(spy.mock.calls[0]?.[1]).toEqual({ cwd: "/tmp/proj" });
216
+ });
217
+
218
+ it("shows a helpful message when no hosts are configured", async () => {
219
+ mockHosts([]);
220
+ const res = await handler.resolve(parseInternalUrl("ssh://"));
221
+ expect(res.content).toMatch(/No SSH hosts are configured/);
222
+ });
223
+
224
+ it("rejects a host-less ssh:// URL that carries a path", async () => {
225
+ mockHosts();
226
+ await expect(handler.resolve(parseInternalUrl("ssh:///etc/hosts"))).rejects.toThrow(/requires a host/);
227
+ });
228
+
229
+ it("rejects an explicit ssh:// port 0 before connecting", async () => {
230
+ mockHosts();
231
+ await expect(handler.resolve(parseInternalUrl("ssh://icaro:0/etc/hostname"))).rejects.toThrow(/port 0/);
232
+ });
233
+
234
+ it("strips IPv6 URL brackets before building the ssh target", async () => {
235
+ mockHosts();
236
+ const spy = mockReadBytes("ok\n");
237
+ await handler.resolve(parseInternalUrl("ssh://[::1]/etc/hostname"));
238
+ expect(spy.mock.calls[0]?.[0]?.host).toBe("::1");
239
+ });
240
+
241
+ it("matches a configured bracketed-colon alias instead of stripping it as IPv6", async () => {
242
+ mockHosts([{ name: "[prod:2222]", host: "prod.internal", _source: SOURCE }]);
243
+ const spy = mockReadBytes("ok\n");
244
+ await handler.resolve(parseInternalUrl("ssh://%5Bprod%3A2222%5D/etc/hostname"));
245
+ expect(spy.mock.calls[0]?.[0]?.host).toBe("prod.internal");
246
+ });
247
+
248
+ it("rejects a malformed or out-of-range ssh:// port before connecting", async () => {
249
+ mockHosts();
250
+ await expect(handler.resolve(parseInternalUrl("ssh://prod:abc/etc"))).rejects.toThrow(/invalid host or port/);
251
+ await expect(handler.resolve(parseInternalUrl("ssh://prod:65536/etc"))).rejects.toThrow(/invalid host or port/);
252
+ });
253
+
254
+ it("rejects an empty ssh:// port before connecting", async () => {
255
+ mockHosts();
256
+ await expect(handler.resolve(parseInternalUrl("ssh://prod:/etc/hosts"))).rejects.toThrow(/empty port/);
257
+ await expect(handler.resolve(parseInternalUrl("ssh://user@prod:/etc/hosts"))).rejects.toThrow(/empty port/);
258
+ await expect(handler.resolve(parseInternalUrl("ssh://[::1]:/etc/hosts"))).rejects.toThrow(/empty port/);
259
+ await expect(handler.resolve(parseInternalUrl("ssh://prod%2Dblue:/etc/hosts"))).rejects.toThrow(/empty port/);
260
+ await expect(handler.resolve(parseInternalUrl("ssh://u%2Dname@prod:/etc/hosts"))).rejects.toThrow(/empty port/);
261
+ });
262
+
263
+ it("rejects ssh:// password and empty-username userinfo before matching a host", async () => {
264
+ mockHosts([{ name: "prod", host: "10.0.0.5", _source: SOURCE }]);
265
+ await expect(handler.resolve(parseInternalUrl("ssh://user:pass@prod/etc/hosts"))).rejects.toThrow(/password/);
266
+ await expect(handler.resolve(parseInternalUrl("ssh://:pw@prod/etc/hosts"))).rejects.toThrow(/password/);
267
+ await expect(handler.resolve(parseInternalUrl("ssh://@prod/etc/hosts"))).rejects.toThrow(/empty username/);
268
+ await expect(handler.resolve(parseInternalUrl("ssh://@prod:22/etc/hosts"))).rejects.toThrow(/empty username/);
269
+ await expect(handler.resolve(parseInternalUrl("ssh://user:@prod/etc/hosts"))).rejects.toThrow(
270
+ /malformed authority/,
271
+ );
272
+ await expect(handler.resolve(parseInternalUrl("ssh://:@prod/etc/hosts"))).rejects.toThrow(/malformed authority/);
273
+ await expect(handler.resolve(parseInternalUrl("ssh://prod%ZZ/etc/hosts"))).rejects.toThrow(/percent-escape/i);
274
+ await expect(handler.resolve(parseInternalUrl("ssh://user%ZZ@prod/etc/hosts"))).rejects.toThrow(
275
+ /percent-escape/i,
276
+ );
277
+ });
278
+
279
+ it("matches a configured colon-suffixed alias via %3A instead of treating it as an empty port", async () => {
280
+ mockHosts([{ name: "prod:", host: "prod.internal", _source: SOURCE }]);
281
+ const spy = mockReadBytes("ok\n");
282
+ await handler.resolve(parseInternalUrl("ssh://prod%3A/etc/hostname"));
283
+ expect(spy.mock.calls[0]?.[0]?.host).toBe("prod.internal");
284
+ });
285
+
286
+ it("decodes the percent-encoded username and host of an override target", async () => {
287
+ mockHosts();
288
+ const spy = mockReadBytes("ok\n");
289
+ await handler.resolve(parseInternalUrl("ssh://user%40corp@prod%2Dblue/etc/hostname"));
290
+ const target = spy.mock.calls[0]?.[0];
291
+ expect(target?.username).toBe("user@corp");
292
+ expect(target?.host).toBe("prod-blue");
293
+ expect(target?.name).toBe("user@corp@prod-blue");
294
+ });
295
+
296
+ it("rejects a user/port override on an encoded configured alias", async () => {
297
+ mockHosts([{ name: "alice@prod", host: "alice.prod.internal", _source: SOURCE }]);
298
+ await expect(handler.resolve(parseInternalUrl("ssh://bob@alice%40prod/tmp/x"))).rejects.toThrow(
299
+ /user\/port overrides/,
300
+ );
301
+ await expect(handler.resolve(parseInternalUrl("ssh://alice%40prod:22/tmp/x"))).rejects.toThrow(
302
+ /user\/port overrides/,
303
+ );
304
+ });
305
+
306
+ it("skips the remote directory listing when skipDirectoryListing is set", async () => {
307
+ mockHosts();
308
+ vi.spyOn(fileTransfer, "readRemoteFile").mockRejectedValue(new Error("Is a directory"));
309
+ vi.spyOn(fileTransfer, "statRemotePath").mockResolvedValue("directory");
310
+ const listSpy = vi.spyOn(fileTransfer, "listRemoteDir").mockResolvedValue([]);
311
+
312
+ const res = await handler.resolve(parseInternalUrl("ssh://h/etc"), { skipDirectoryListing: true });
313
+ expect(res.isDirectory).toBe(true);
314
+ expect(listSpy).not.toHaveBeenCalled();
315
+
316
+ await handler.resolve(parseInternalUrl("ssh://h/etc"));
317
+ expect(listSpy).toHaveBeenCalledTimes(1);
318
+ });
319
+
320
+ it("rejects ssh:// URL queries and fragments instead of operating on the truncated path", async () => {
321
+ mockHosts();
322
+ // `?`/`#` are URL delimiters, so the query/fragment is stripped from the path;
323
+ // `ssh://h/tmp/a?draft` would otherwise read/write `/tmp/a`, the wrong file.
324
+ await expect(handler.resolve(parseInternalUrl("ssh://h/tmp/a?draft"))).rejects.toThrow(/quer/i);
325
+ await expect(handler.resolve(parseInternalUrl("ssh://h/tmp/a#draft"))).rejects.toThrow(/fragment/i);
326
+ // A literal `?` in a filename must be percent-encoded (`%3F`) and is then accepted.
327
+ const spy = mockReadBytes("ok\n");
328
+ await handler.resolve(parseInternalUrl("ssh://h/tmp/a%3Fdraft"));
329
+ expect(spy.mock.calls[0]?.[1]).toBe("/tmp/a?draft");
330
+ });
331
+ });