@oh-my-pi/pi-coding-agent 12.12.2 → 12.13.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.
@@ -0,0 +1,452 @@
1
+ /**
2
+ * SSH Command Controller
3
+ *
4
+ * Handles /ssh subcommands for managing SSH host configurations.
5
+ */
6
+ import { Spacer, Text } from "@oh-my-pi/pi-tui";
7
+ import { getProjectDir, getSSHConfigPath } from "@oh-my-pi/pi-utils/dirs";
8
+ import { type SSHHost, sshCapability } from "../../capability/ssh";
9
+ import { loadCapability } from "../../discovery";
10
+ import { addSSHHost, readSSHConfigFile, removeSSHHost, type SSHHostConfig } from "../../ssh/config-writer";
11
+ import { DynamicBorder } from "../components/dynamic-border";
12
+ import { theme } from "../theme/theme";
13
+ import type { InteractiveModeContext } from "../types";
14
+
15
+ function parseCommandArgs(argsString: string): string[] {
16
+ const args: string[] = [];
17
+ let current = "";
18
+ let inQuote: string | null = null;
19
+
20
+ for (let i = 0; i < argsString.length; i++) {
21
+ const char = argsString[i];
22
+
23
+ if (inQuote) {
24
+ if (char === inQuote) {
25
+ inQuote = null;
26
+ } else {
27
+ current += char;
28
+ }
29
+ } else if (char === '"' || char === "'") {
30
+ inQuote = char;
31
+ } else if (char === " " || char === "\t") {
32
+ if (current) {
33
+ args.push(current);
34
+ current = "";
35
+ }
36
+ } else {
37
+ current += char;
38
+ }
39
+ }
40
+
41
+ if (current) {
42
+ args.push(current);
43
+ }
44
+
45
+ return args;
46
+ }
47
+
48
+ type SSHAddScope = "user" | "project";
49
+
50
+ export class SSHCommandController {
51
+ constructor(private ctx: InteractiveModeContext) {}
52
+
53
+ /**
54
+ * Handle /ssh command and route to subcommands
55
+ */
56
+ async handle(text: string): Promise<void> {
57
+ const parts = text.trim().split(/\s+/);
58
+ const subcommand = parts[1]?.toLowerCase();
59
+
60
+ if (!subcommand || subcommand === "help") {
61
+ this.#showHelp();
62
+ return;
63
+ }
64
+
65
+ switch (subcommand) {
66
+ case "add":
67
+ await this.#handleAdd(text);
68
+ break;
69
+ case "list":
70
+ await this.#handleList();
71
+ break;
72
+ case "remove":
73
+ case "rm":
74
+ await this.#handleRemove(text);
75
+ break;
76
+ default:
77
+ this.ctx.showError(`Unknown subcommand: ${subcommand}. Type /ssh help for usage.`);
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Show help text
83
+ */
84
+ #showHelp(): void {
85
+ const helpText = [
86
+ "",
87
+ theme.bold("SSH Host Management"),
88
+ "",
89
+ "Manage SSH host configurations for remote command execution.",
90
+ "",
91
+ theme.fg("accent", "Commands:"),
92
+ " /ssh add <name> --host <host> [--user <user>] [--port <port>] [--key <keyPath>] [--desc <description>] [--compat] [--scope project|user]",
93
+ " /ssh list List all configured SSH hosts",
94
+ " /ssh remove <name> [--scope project|user] Remove an SSH host (default: project)",
95
+ " /ssh help Show this help message",
96
+ "",
97
+ ].join("\n");
98
+
99
+ this.#showMessage(helpText);
100
+ }
101
+
102
+ /**
103
+ * Handle /ssh add - parse flags and add host to config
104
+ */
105
+ async #handleAdd(text: string): Promise<void> {
106
+ const prefixMatch = text.match(/^\/ssh\s+add\b\s*(.*)$/i);
107
+ const rest = prefixMatch?.[1]?.trim() ?? "";
108
+ if (!rest) {
109
+ this.ctx.showError(
110
+ "Usage: /ssh add <name> --host <host> [--user <user>] [--port <port>] [--key <keyPath>] [--desc <description>] [--compat] [--scope project|user]",
111
+ );
112
+ return;
113
+ }
114
+
115
+ const tokens = parseCommandArgs(rest);
116
+ if (tokens.length === 0) {
117
+ this.ctx.showError(
118
+ "Usage: /ssh add <name> --host <host> [--user <user>] [--port <port>] [--key <keyPath>] [--desc <description>] [--compat] [--scope project|user]",
119
+ );
120
+ return;
121
+ }
122
+
123
+ let name: string | undefined;
124
+ let scope: SSHAddScope = "project";
125
+ let host: string | undefined;
126
+ let username: string | undefined;
127
+ let port: number | undefined;
128
+ let keyPath: string | undefined;
129
+ let description: string | undefined;
130
+ let compat = false;
131
+
132
+ let i = 0;
133
+ if (!tokens[0].startsWith("-")) {
134
+ name = tokens[0];
135
+ i = 1;
136
+ }
137
+
138
+ while (i < tokens.length) {
139
+ const argToken = tokens[i];
140
+ if (argToken === "--host") {
141
+ const value = tokens[i + 1];
142
+ if (!value) {
143
+ this.ctx.showError("Missing value for --host.");
144
+ return;
145
+ }
146
+ host = value;
147
+ i += 2;
148
+ continue;
149
+ }
150
+ if (argToken === "--user") {
151
+ const value = tokens[i + 1];
152
+ if (!value) {
153
+ this.ctx.showError("Missing value for --user.");
154
+ return;
155
+ }
156
+ username = value;
157
+ i += 2;
158
+ continue;
159
+ }
160
+ if (argToken === "--port") {
161
+ const value = tokens[i + 1];
162
+ if (!value) {
163
+ this.ctx.showError("Missing value for --port.");
164
+ return;
165
+ }
166
+ const parsed = Number.parseInt(value, 10);
167
+ if (Number.isNaN(parsed) || parsed < 1 || parsed > 65535) {
168
+ this.ctx.showError("Invalid --port value. Must be an integer between 1 and 65535.");
169
+ return;
170
+ }
171
+ port = parsed;
172
+ i += 2;
173
+ continue;
174
+ }
175
+ if (argToken === "--key") {
176
+ const value = tokens[i + 1];
177
+ if (!value) {
178
+ this.ctx.showError("Missing value for --key.");
179
+ return;
180
+ }
181
+ keyPath = value;
182
+ i += 2;
183
+ continue;
184
+ }
185
+ if (argToken === "--desc") {
186
+ const value = tokens[i + 1];
187
+ if (!value) {
188
+ this.ctx.showError("Missing value for --desc.");
189
+ return;
190
+ }
191
+ description = value;
192
+ i += 2;
193
+ continue;
194
+ }
195
+ if (argToken === "--compat") {
196
+ compat = true;
197
+ i += 1;
198
+ continue;
199
+ }
200
+ if (argToken === "--scope") {
201
+ const value = tokens[i + 1];
202
+ if (!value || (value !== "project" && value !== "user")) {
203
+ this.ctx.showError("Invalid --scope value. Use project or user.");
204
+ return;
205
+ }
206
+ scope = value;
207
+ i += 2;
208
+ continue;
209
+ }
210
+ this.ctx.showError(`Unknown option: ${argToken}`);
211
+ return;
212
+ }
213
+
214
+ if (!name) {
215
+ this.ctx.showError("Host name required. Usage: /ssh add <name> --host <host> ...");
216
+ return;
217
+ }
218
+
219
+ if (!host) {
220
+ this.ctx.showError("--host is required. Usage: /ssh add <name> --host <host> ...");
221
+ return;
222
+ }
223
+
224
+ try {
225
+ const cwd = getProjectDir();
226
+ const filePath = getSSHConfigPath(scope, cwd);
227
+
228
+ const hostConfig: SSHHostConfig = { host };
229
+ if (username) hostConfig.username = username;
230
+ if (port) hostConfig.port = port;
231
+ if (keyPath) hostConfig.keyPath = keyPath;
232
+ if (description) hostConfig.description = description;
233
+ if (compat) hostConfig.compat = true;
234
+
235
+ await addSSHHost(filePath, name, hostConfig);
236
+
237
+ const scopeLabel = scope === "user" ? "user" : "project";
238
+ const lines = [
239
+ "",
240
+ theme.fg("success", `✓ Added SSH host "${name}" to ${scopeLabel} config`),
241
+ "",
242
+ ` Host: ${host}`,
243
+ ];
244
+ if (username) lines.push(` User: ${username}`);
245
+ if (port) lines.push(` Port: ${port}`);
246
+ if (keyPath) lines.push(` Key: ${keyPath}`);
247
+ if (description) lines.push(` Desc: ${description}`);
248
+ if (compat) lines.push(` Compat: true`);
249
+ lines.push("");
250
+ lines.push(theme.fg("muted", `Run ${theme.fg("accent", "/ssh list")} to see all configured hosts.`));
251
+ lines.push("");
252
+
253
+ this.#showMessage(lines.join("\n"));
254
+ } catch (error) {
255
+ const errorMsg = error instanceof Error ? error.message : String(error);
256
+
257
+ let helpText = "";
258
+ if (errorMsg.includes("already exists")) {
259
+ helpText = `\n\nTip: Use ${theme.fg("accent", "/ssh remove")} first, or choose a different name.`;
260
+ }
261
+
262
+ this.ctx.showError(`Failed to add host: ${errorMsg}${helpText}`);
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Handle /ssh list - show all configured SSH hosts
268
+ */
269
+ async #handleList(): Promise<void> {
270
+ try {
271
+ const cwd = getProjectDir();
272
+
273
+ // Load from both user and project configs
274
+ const userPath = getSSHConfigPath("user", cwd);
275
+ const projectPath = getSSHConfigPath("project", cwd);
276
+
277
+ const [userConfig, projectConfig] = await Promise.all([
278
+ readSSHConfigFile(userPath),
279
+ readSSHConfigFile(projectPath),
280
+ ]);
281
+
282
+ const userHosts = Object.keys(userConfig.hosts ?? {});
283
+ const projectHosts = Object.keys(projectConfig.hosts ?? {});
284
+
285
+ // Load discovered hosts via capability system
286
+ const configHostNames = new Set([...userHosts, ...projectHosts]);
287
+ let discoveredHosts: SSHHost[] = [];
288
+ try {
289
+ const result = await loadCapability<SSHHost>(sshCapability.id, { cwd });
290
+ discoveredHosts = result.items.filter(h => !configHostNames.has(h.name));
291
+ } catch {
292
+ // Ignore discovery errors
293
+ }
294
+
295
+ if (userHosts.length === 0 && projectHosts.length === 0 && discoveredHosts.length === 0) {
296
+ this.#showMessage(
297
+ [
298
+ "",
299
+ theme.fg("muted", "No SSH hosts configured."),
300
+ "",
301
+ `Use ${theme.fg("accent", "/ssh add")} to add a host.`,
302
+ "",
303
+ ].join("\n"),
304
+ );
305
+ return;
306
+ }
307
+
308
+ const lines: string[] = ["", theme.bold("Configured SSH Hosts"), ""];
309
+
310
+ // Show user-level hosts
311
+ if (userHosts.length > 0) {
312
+ lines.push(theme.fg("accent", "User level") + theme.fg("muted", ` (~/.omp/agent/ssh.json):`));
313
+ for (const name of userHosts) {
314
+ const config = userConfig.hosts![name];
315
+ const details = this.#formatHostDetails(config);
316
+ lines.push(` ${theme.fg("accent", name)} ${details}`);
317
+ }
318
+ lines.push("");
319
+ }
320
+
321
+ // Show project-level hosts
322
+ if (projectHosts.length > 0) {
323
+ lines.push(theme.fg("accent", "Project level") + theme.fg("muted", ` (.omp/ssh.json):`));
324
+ for (const name of projectHosts) {
325
+ const config = projectConfig.hosts![name];
326
+ const details = this.#formatHostDetails(config);
327
+ lines.push(` ${theme.fg("accent", name)} ${details}`);
328
+ }
329
+ lines.push("");
330
+ }
331
+
332
+ // Show discovered hosts (from ssh.json, .ssh.json in project root, etc.)
333
+ if (discoveredHosts.length > 0) {
334
+ // Group by source
335
+ const bySource = new Map<string, SSHHost[]>();
336
+ for (const host of discoveredHosts) {
337
+ const key = `${host._source.providerName}|${host._source.path}`;
338
+ let group = bySource.get(key);
339
+ if (!group) {
340
+ group = [];
341
+ bySource.set(key, group);
342
+ }
343
+ group.push(host);
344
+ }
345
+
346
+ for (const [key, hosts] of bySource) {
347
+ const sepIdx = key.indexOf("|");
348
+ const providerName = key.slice(0, sepIdx);
349
+ const sourcePath = key.slice(sepIdx + 1);
350
+ const shortPath = sourcePath.replace(process.env.HOME ?? "", "~");
351
+ lines.push(
352
+ theme.fg("accent", "Discovered") +
353
+ theme.fg("muted", ` (${providerName}: ${shortPath}):`) +
354
+ theme.fg("dim", " read-only"),
355
+ );
356
+ for (const host of hosts) {
357
+ const details = this.#formatHostDetails({
358
+ host: host.host,
359
+ username: host.username,
360
+ port: host.port,
361
+ });
362
+ lines.push(` ${theme.fg("accent", host.name)} ${details}`);
363
+ }
364
+ lines.push("");
365
+ }
366
+ }
367
+
368
+ this.#showMessage(lines.join("\n"));
369
+ } catch (error) {
370
+ this.ctx.showError(`Failed to list hosts: ${error instanceof Error ? error.message : String(error)}`);
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Format host details (host, user, port) for display
376
+ */
377
+ #formatHostDetails(config: { host?: string; username?: string; port?: number }): string {
378
+ const parts: string[] = [];
379
+ if (config.host) parts.push(config.host);
380
+ if (config.username) parts.push(`user=${config.username}`);
381
+ if (config.port && config.port !== 22) parts.push(`port=${config.port}`);
382
+ return theme.fg("dim", parts.length > 0 ? `[${parts.join(", ")}]` : "");
383
+ }
384
+
385
+ /**
386
+ * Handle /ssh remove <name> - remove a host from config
387
+ */
388
+ async #handleRemove(text: string): Promise<void> {
389
+ const match = text.match(/^\/ssh\s+(?:remove|rm)\b\s*(.*)$/i);
390
+ const rest = match?.[1]?.trim() ?? "";
391
+ const tokens = parseCommandArgs(rest);
392
+
393
+ let name: string | undefined;
394
+ let scope: "project" | "user" = "project";
395
+ let i = 0;
396
+
397
+ if (tokens.length > 0 && !tokens[0].startsWith("-")) {
398
+ name = tokens[0];
399
+ i = 1;
400
+ }
401
+
402
+ while (i < tokens.length) {
403
+ const token = tokens[i];
404
+ if (token === "--scope") {
405
+ const value = tokens[i + 1];
406
+ if (!value || (value !== "project" && value !== "user")) {
407
+ this.ctx.showError("Invalid --scope value. Use project or user.");
408
+ return;
409
+ }
410
+ scope = value;
411
+ i += 2;
412
+ continue;
413
+ }
414
+ this.ctx.showError(`Unknown option: ${token}`);
415
+ return;
416
+ }
417
+
418
+ if (!name) {
419
+ this.ctx.showError("Host name required. Usage: /ssh remove <name> [--scope project|user]");
420
+ return;
421
+ }
422
+
423
+ try {
424
+ const cwd = getProjectDir();
425
+ const filePath = getSSHConfigPath(scope, cwd);
426
+ const config = await readSSHConfigFile(filePath);
427
+ if (!config.hosts?.[name]) {
428
+ this.ctx.showError(`Host "${name}" not found in ${scope} config.`);
429
+ return;
430
+ }
431
+
432
+ await removeSSHHost(filePath, name);
433
+
434
+ this.#showMessage(
435
+ ["", theme.fg("success", `✓ Removed SSH host "${name}" from ${scope} config`), ""].join("\n"),
436
+ );
437
+ } catch (error) {
438
+ this.ctx.showError(`Failed to remove host: ${error instanceof Error ? error.message : String(error)}`);
439
+ }
440
+ }
441
+
442
+ /**
443
+ * Show a message in the chat
444
+ */
445
+ #showMessage(text: string): void {
446
+ this.ctx.chatContainer.addChild(new Spacer(1));
447
+ this.ctx.chatContainer.addChild(new DynamicBorder());
448
+ this.ctx.chatContainer.addChild(new Text(text, 1, 1));
449
+ this.ctx.chatContainer.addChild(new DynamicBorder());
450
+ this.ctx.ui.requestRender();
451
+ }
452
+ }
@@ -50,6 +50,7 @@ import { ExtensionUiController } from "./controllers/extension-ui-controller";
50
50
  import { InputController } from "./controllers/input-controller";
51
51
  import { MCPCommandController } from "./controllers/mcp-command-controller";
52
52
  import { SelectorController } from "./controllers/selector-controller";
53
+ import { SSHCommandController } from "./controllers/ssh-command-controller";
53
54
  import { setMermaidRenderCallback } from "./theme/mermaid-cache";
54
55
  import type { Theme } from "./theme/theme";
55
56
  import { getEditorTheme, getMarkdownTheme, onThemeChange, theme } from "./theme/theme";
@@ -1052,6 +1053,11 @@ export class InteractiveMode implements InteractiveModeContext {
1052
1053
  await controller.handle(text);
1053
1054
  }
1054
1055
 
1056
+ async handleSSHCommand(text: string): Promise<void> {
1057
+ const controller = new SSHCommandController(this);
1058
+ await controller.handle(text);
1059
+ }
1060
+
1055
1061
  handleCompactCommand(customInstructions?: string): Promise<void> {
1056
1062
  return this.#commandController.handleCompactCommand(customInstructions);
1057
1063
  }
@@ -148,6 +148,7 @@ export interface InteractiveModeContext {
148
148
  handleBashCommand(command: string, excludeFromContext?: boolean): Promise<void>;
149
149
  handlePythonCommand(code: string, excludeFromContext?: boolean): Promise<void>;
150
150
  handleMCPCommand(text: string): Promise<void>;
151
+ handleSSHCommand(text: string): Promise<void>;
151
152
  handleCompactCommand(customInstructions?: string): Promise<void>;
152
153
  handleHandoffCommand(customInstructions?: string): Promise<void>;
153
154
  handleMoveCommand(targetPath: string): Promise<void>;