/** * SSH Command Controller * * Handles /ssh subcommands for managing SSH host configurations. */ import { getProjectDir, getSSHConfigPath } from "@oh-my-pi/pi-utils"; import { type SSHHost, sshCapability } from "../../capability/ssh"; import { loadCapability } from "../../discovery"; import { addSSHHost, readSSHConfigFile, removeSSHHost, type SSHHostConfig } from "../../ssh/config-writer"; import { parseCommandArgs } from "../shared"; import { theme } from "../theme/theme"; import type { InteractiveModeContext } from "../types"; import { groupBySource, parseRemoveArgs, readScopeFlag, type ScopeValue, showCommandMessage, } from "./command-controller-shared"; export class SSHCommandController { constructor(private ctx: InteractiveModeContext) {} /** * Handle /ssh command and route to subcommands */ async handle(text: string): Promise { const parts = text.trim().split(/\s+/); const subcommand = parts[1]?.toLowerCase(); if (!subcommand || subcommand === "help") { this.#showHelp(); return; } switch (subcommand) { case "add": await this.#handleAdd(text); break; case "list": await this.#handleList(); break; case "remove": case "rm": await this.#handleRemove(text); break; default: this.ctx.showError(`Unknown subcommand: ${subcommand}. Type /ssh help for usage.`); } } /** * Show help text */ #showHelp(): void { const helpText = [ "", theme.bold("SSH Host Management"), "", "Manage SSH host configurations for remote command execution.", "", theme.fg("accent", "Commands:"), " /ssh add --host [--user ] [--port ] [--key ] [--desc ] [--compat] [--scope project|user]", " /ssh list List all configured SSH hosts", " /ssh remove [--scope project|user] Remove an SSH host (default: project)", " /ssh help Show this help message", "", ].join("\n"); this.#showMessage(helpText); } /** * Handle /ssh add - parse flags and add host to config */ async #handleAdd(text: string): Promise { const prefixMatch = text.match(/^\/ssh\s+add\b\s*(.*)$/i); const rest = prefixMatch?.[1]?.trim() ?? ""; if (!rest) { this.ctx.showError( "Usage: /ssh add --host [--user ] [--port ] [--key ] [--desc ] [--compat] [--scope project|user]", ); return; } const tokens = parseCommandArgs(rest); if (tokens.length === 0) { this.ctx.showError( "Usage: /ssh add --host [--user ] [--port ] [--key ] [--desc ] [--compat] [--scope project|user]", ); return; } let name: string | undefined; let scope: ScopeValue = "project"; let host: string | undefined; let username: string | undefined; let port: number | undefined; let keyPath: string | undefined; let description: string | undefined; let compat = false; let i = 0; if (!tokens[0].startsWith("-")) { name = tokens[0]; i = 1; } while (i < tokens.length) { const argToken = tokens[i]; if (argToken === "--host") { const value = tokens[i + 1]; if (!value) { this.ctx.showError("Missing value for --host."); return; } host = value; i += 2; continue; } if (argToken === "--user") { const value = tokens[i + 1]; if (!value) { this.ctx.showError("Missing value for --user."); return; } username = value; i += 2; continue; } if (argToken === "--port") { const value = tokens[i + 1]; if (!value) { this.ctx.showError("Missing value for --port."); return; } const parsed = Number.parseInt(value, 10); if (Number.isNaN(parsed) || parsed < 1 || parsed > 65535) { this.ctx.showError("Invalid --port value. Must be an integer between 1 and 65535."); return; } port = parsed; i += 2; continue; } if (argToken === "--key") { const value = tokens[i + 1]; if (!value) { this.ctx.showError("Missing value for --key."); return; } keyPath = value; i += 2; continue; } if (argToken === "--desc") { const value = tokens[i + 1]; if (!value) { this.ctx.showError("Missing value for --desc."); return; } description = value; i += 2; continue; } if (argToken === "--compat") { compat = true; i += 1; continue; } if (argToken === "--scope") { const r = readScopeFlag(tokens[i + 1]); if (!r.ok) { this.ctx.showError(r.error); return; } scope = r.scope; i += 2; continue; } this.ctx.showError(`Unknown option: ${argToken}`); return; } if (!name) { this.ctx.showError("Host name required. Usage: /ssh add --host ..."); return; } if (!host) { this.ctx.showError("--host is required. Usage: /ssh add --host ..."); return; } try { const cwd = getProjectDir(); const filePath = getSSHConfigPath(scope, cwd); const hostConfig: SSHHostConfig = { host }; if (username) hostConfig.username = username; if (port) hostConfig.port = port; if (keyPath) hostConfig.keyPath = keyPath; if (description) hostConfig.description = description; if (compat) hostConfig.compat = true; await addSSHHost(filePath, name, hostConfig); await this.ctx.session.refreshSshTool({ activateIfAvailable: true }); const scopeLabel = scope === "user" ? "user" : "project"; const lines = [ "", theme.fg("success", `✓ Added SSH host "${name}" to ${scopeLabel} config`), "", ` Host: ${host}`, ]; if (username) lines.push(` User: ${username}`); if (port) lines.push(` Port: ${port}`); if (keyPath) lines.push(` Key: ${keyPath}`); if (description) lines.push(` Desc: ${description}`); if (compat) lines.push(` Compat: true`); lines.push(""); lines.push(theme.fg("muted", `Run ${theme.fg("accent", "/ssh list")} to see all configured hosts.`)); lines.push(""); this.#showMessage(lines.join("\n")); } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); let helpText = ""; if (errorMsg.includes("already exists")) { helpText = `\n\nTip: Use ${theme.fg("accent", "/ssh remove")} first, or choose a different name.`; } this.ctx.showError(`Failed to add host: ${errorMsg}${helpText}`); } } /** * Handle /ssh list - show all configured SSH hosts */ async #handleList(): Promise { try { const cwd = getProjectDir(); // Load from both user and project configs const userPath = getSSHConfigPath("user", cwd); const projectPath = getSSHConfigPath("project", cwd); const [userConfig, projectConfig] = await Promise.all([ readSSHConfigFile(userPath), readSSHConfigFile(projectPath), ]); const userHosts = Object.keys(userConfig.hosts ?? {}); const projectHosts = Object.keys(projectConfig.hosts ?? {}); // Load discovered hosts via capability system const configHostNames = new Set([...userHosts, ...projectHosts]); let discoveredHosts: SSHHost[] = []; try { const result = await loadCapability(sshCapability.id, { cwd }); discoveredHosts = result.items.filter(h => !configHostNames.has(h.name)); } catch { // Ignore discovery errors } if (userHosts.length === 0 && projectHosts.length === 0 && discoveredHosts.length === 0) { this.#showMessage( [ "", theme.fg("muted", "No SSH hosts configured."), "", `Use ${theme.fg("accent", "/ssh add")} to add a host.`, "", ].join("\n"), ); return; } const lines: string[] = ["", theme.bold("Configured SSH Hosts"), ""]; // Show user-level hosts if (userHosts.length > 0) { lines.push(theme.fg("accent", "User level") + theme.fg("muted", ` (~/.omp/agent/ssh.json):`)); for (const name of userHosts) { const config = userConfig.hosts![name]; const details = this.#formatHostDetails(config); lines.push(` ${theme.fg("accent", name)} ${details}`); } lines.push(""); } // Show project-level hosts if (projectHosts.length > 0) { lines.push(theme.fg("accent", "Project level") + theme.fg("muted", ` (.omp/ssh.json):`)); for (const name of projectHosts) { const config = projectConfig.hosts![name]; const details = this.#formatHostDetails(config); lines.push(` ${theme.fg("accent", name)} ${details}`); } lines.push(""); } // Show discovered hosts (from ssh.json, .ssh.json in project root, etc.) if (discoveredHosts.length > 0) { for (const { providerName, shortPath, items: hosts } of groupBySource(discoveredHosts, h => h._source)) { lines.push( theme.fg("accent", "Discovered") + theme.fg("muted", ` (${providerName}: ${shortPath}):`) + theme.fg("dim", " read-only"), ); for (const host of hosts) { const details = this.#formatHostDetails({ host: host.host, username: host.username, port: host.port, }); lines.push(` ${theme.fg("accent", host.name)} ${details}`); } lines.push(""); } } this.#showMessage(lines.join("\n")); } catch (error) { this.ctx.showError(`Failed to list hosts: ${error instanceof Error ? error.message : String(error)}`); } } /** * Format host details (host, user, port) for display */ #formatHostDetails(config: { host?: string; username?: string; port?: number }): string { const parts: string[] = []; if (config.host) parts.push(config.host); if (config.username) parts.push(`user=${config.username}`); if (config.port && config.port !== 22) parts.push(`port=${config.port}`); return theme.fg("dim", parts.length > 0 ? `[${parts.join(", ")}]` : ""); } /** * Handle /ssh remove - remove a host from config */ async #handleRemove(text: string): Promise { const match = text.match(/^\/ssh\s+(?:remove|rm)\b\s*(.*)$/i); const rest = match?.[1]?.trim() ?? ""; const parsed = parseRemoveArgs(rest); if (!parsed.ok) { this.ctx.showError(parsed.error); return; } const { name, scope } = parsed.value; if (!name) { this.ctx.showError("Host name required. Usage: /ssh remove [--scope project|user]"); return; } try { const cwd = getProjectDir(); const filePath = getSSHConfigPath(scope, cwd); const config = await readSSHConfigFile(filePath); if (!config.hosts?.[name]) { this.ctx.showError(`Host "${name}" not found in ${scope} config.`); return; } await removeSSHHost(filePath, name); await this.ctx.session.refreshSshTool(); this.#showMessage( ["", theme.fg("success", `✓ Removed SSH host "${name}" from ${scope} config`), ""].join("\n"), ); } catch (error) { this.ctx.showError(`Failed to remove host: ${error instanceof Error ? error.message : String(error)}`); } } /** * Show a message in the chat */ #showMessage(text: string): void { showCommandMessage(this.ctx, text); } }