diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 145c1e74c..761dbe3c3 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,11 +1,18 @@ # Changelog ## [Unreleased] - ### Breaking Changes - Removed automatic line relocation when hash references become stale; edits with mismatched line hashes now fail with an error instead of silently relocating to matching lines elsewhere in the file +### Added + +- Added `ssh` command for managing SSH host configurations (add, list, remove) +- Added `/ssh` slash command in interactive mode to manage SSH hosts with subcommands +- Added support for SSH host configuration at project and user scopes (.omp/ssh.json and ~/.omp/agent/ssh.json) +- Added `--host`, `--user`, `--port`, `--key`, `--desc`, `--compat`, and `--scope` flags for SSH host configuration +- Added discovery of SSH hosts from project configuration files alongside manually configured hosts + ### Fixed - Fixed terminal output handling to properly queue and serialize writes, preventing dropped or corrupted output in interactive bash sessions diff --git a/packages/coding-agent/src/cli.ts b/packages/coding-agent/src/cli.ts index bfad7a085..3429fdcc3 100755 --- a/packages/coding-agent/src/cli.ts +++ b/packages/coding-agent/src/cli.ts @@ -23,6 +23,7 @@ const commands: CommandEntry[] = [ { name: "plugin", load: () => import("./commands/plugin").then(m => m.default) }, { name: "setup", load: () => import("./commands/setup").then(m => m.default) }, { name: "shell", load: () => import("./commands/shell").then(m => m.default) }, + { name: "ssh", load: () => import("./commands/ssh").then(m => m.default) }, { name: "stats", load: () => import("./commands/stats").then(m => m.default) }, { name: "update", load: () => import("./commands/update").then(m => m.default) }, { name: "search", load: () => import("./commands/web-search").then(m => m.default), aliases: ["q"] }, diff --git a/packages/coding-agent/src/cli/ssh-cli.ts b/packages/coding-agent/src/cli/ssh-cli.ts new file mode 100644 index 000000000..212e28d09 --- /dev/null +++ b/packages/coding-agent/src/cli/ssh-cli.ts @@ -0,0 +1,179 @@ +/** + * SSH CLI command handlers. + * + * Handles `omp ssh ` subcommands for SSH host configuration management. + */ + +import { getSSHConfigPath } from "@oh-my-pi/pi-utils/dirs"; +import chalk from "chalk"; +import { addSSHHost, readSSHConfigFile, removeSSHHost, type SSHHostConfig } from "../ssh/config-writer"; + +// ============================================================================= +// Types +// ============================================================================= + +export type SSHAction = "add" | "remove" | "list"; + +export interface SSHCommandArgs { + action: SSHAction; + args: string[]; + flags: { + json?: boolean; + host?: string; + user?: string; + port?: string; + key?: string; + desc?: string; + compat?: boolean; + scope?: "project" | "user"; + }; +} + +// ============================================================================= +// Main dispatcher +// ============================================================================= + +export async function runSSHCommand(cmd: SSHCommandArgs): Promise { + switch (cmd.action) { + case "add": + await handleAdd(cmd); + break; + case "remove": + await handleRemove(cmd); + break; + case "list": + await handleList(cmd); + break; + default: + process.stdout.write(chalk.red(`Unknown action: ${cmd.action}\n`)); + process.stdout.write(`Valid actions: add, remove, list\n`); + process.exitCode = 1; + } +} + +// ============================================================================= +// Handlers +// ============================================================================= + +async function handleAdd(cmd: SSHCommandArgs): Promise { + const name = cmd.args[0]; + if (!name) { + process.stdout.write(chalk.red("Error: Host name required\n")); + process.stdout.write( + chalk.dim("Usage: omp ssh add --host
[--user ] [--port ] [--key ]\n"), + ); + process.exitCode = 1; + return; + } + + const host = cmd.flags.host; + if (!host) { + process.stdout.write(chalk.red("Error: --host is required\n")); + process.stdout.write(chalk.dim("Usage: omp ssh add --host
\n")); + process.exitCode = 1; + return; + } + + // Validate port if provided + if (cmd.flags.port !== undefined) { + const port = Number.parseInt(cmd.flags.port, 10); + if (Number.isNaN(port) || port < 1 || port > 65535) { + process.stdout.write(chalk.red("Error: Port must be an integer between 1 and 65535\n")); + process.exitCode = 1; + return; + } + } + + const hostConfig: SSHHostConfig = { host }; + if (cmd.flags.user) hostConfig.username = cmd.flags.user; + if (cmd.flags.port) hostConfig.port = Number.parseInt(cmd.flags.port, 10); + if (cmd.flags.key) hostConfig.keyPath = cmd.flags.key; + if (cmd.flags.desc) hostConfig.description = cmd.flags.desc; + if (cmd.flags.compat) hostConfig.compat = true; + + const scope = cmd.flags.scope ?? "project"; + const filePath = getSSHConfigPath(scope); + + try { + await addSSHHost(filePath, name, hostConfig); + process.stdout.write(chalk.green(`Added SSH host "${name}" to ${scope} config\n`)); + } catch (err) { + process.stdout.write(chalk.red(`Error: ${err instanceof Error ? err.message : String(err)}\n`)); + process.exitCode = 1; + } +} + +async function handleRemove(cmd: SSHCommandArgs): Promise { + const name = cmd.args[0]; + if (!name) { + process.stdout.write(chalk.red("Error: Host name required\n")); + process.stdout.write(chalk.dim("Usage: omp ssh remove [--scope project|user]\n")); + process.exitCode = 1; + return; + } + + const scope = cmd.flags.scope ?? "project"; + const filePath = getSSHConfigPath(scope); + + try { + await removeSSHHost(filePath, name); + process.stdout.write(chalk.green(`Removed SSH host "${name}" from ${scope} config\n`)); + } catch (err) { + process.stdout.write(chalk.red(`Error: ${err instanceof Error ? err.message : String(err)}\n`)); + process.exitCode = 1; + } +} + +async function handleList(cmd: SSHCommandArgs): Promise { + const projectPath = getSSHConfigPath("project"); + const userPath = getSSHConfigPath("user"); + + const [projectConfig, userConfig] = await Promise.all([readSSHConfigFile(projectPath), readSSHConfigFile(userPath)]); + + const projectHosts = projectConfig.hosts ?? {}; + const userHosts = userConfig.hosts ?? {}; + + if (cmd.flags.json) { + process.stdout.write(JSON.stringify({ project: projectHosts, user: userHosts }, null, 2)); + process.stdout.write("\n"); + return; + } + + const hasProject = Object.keys(projectHosts).length > 0; + const hasUser = Object.keys(userHosts).length > 0; + + if (!hasProject && !hasUser) { + process.stdout.write(chalk.dim("No SSH hosts configured\n")); + process.stdout.write(chalk.dim("Add one with: omp ssh add --host
\n")); + return; + } + + if (hasProject) { + process.stdout.write(chalk.bold("Project SSH Hosts (.omp/ssh.json):\n")); + printHosts(projectHosts); + } + + if (hasProject && hasUser) { + process.stdout.write("\n"); + } + + if (hasUser) { + process.stdout.write(chalk.bold("User SSH Hosts (~/.omp/agent/ssh.json):\n")); + printHosts(userHosts); + } +} + +// ============================================================================= +// Helpers +// ============================================================================= + +function printHosts(hosts: Record): void { + for (const [name, config] of Object.entries(hosts)) { + const parts = [chalk.cyan(name), config.host]; + if (config.username) parts.push(chalk.dim(config.username)); + if (config.port && config.port !== 22) parts.push(chalk.dim(`port:${config.port}`)); + if (config.keyPath) parts.push(chalk.dim(config.keyPath)); + if (config.description) parts.push(chalk.dim(`- ${config.description}`)); + process.stdout.write(` ${parts.join(" ")}\n`); + } +} diff --git a/packages/coding-agent/src/commands/ssh.ts b/packages/coding-agent/src/commands/ssh.ts new file mode 100644 index 000000000..0446fb1aa --- /dev/null +++ b/packages/coding-agent/src/commands/ssh.ts @@ -0,0 +1,60 @@ +/** + * Manage SSH host configurations. + */ +import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli"; +import { runSSHCommand, type SSHAction, type SSHCommandArgs } from "../cli/ssh-cli"; +import { initTheme } from "../modes/theme/theme"; + +const ACTIONS: SSHAction[] = ["add", "remove", "list"]; + +export default class SSH extends Command { + static description = "Manage SSH host configurations"; + + static args = { + action: Args.string({ + description: "SSH action", + required: false, + options: ACTIONS, + }), + targets: Args.string({ + description: "Host name or arguments", + required: false, + multiple: true, + }), + }; + + static flags = { + json: Flags.boolean({ description: "Output JSON" }), + host: Flags.string({ description: "Host address" }), + user: Flags.string({ description: "Username" }), + port: Flags.string({ description: "Port number" }), + key: Flags.string({ description: "Identity key path" }), + desc: Flags.string({ description: "Host description" }), + compat: Flags.boolean({ description: "Enable compatibility mode" }), + scope: Flags.string({ description: "Config scope (project|user)", options: ["project", "user"] }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(SSH); + const action = (args.action ?? "list") as SSHAction; + const targets = Array.isArray(args.targets) ? args.targets : args.targets ? [args.targets] : []; + + const cmd: SSHCommandArgs = { + action, + args: targets, + flags: { + json: flags.json, + host: flags.host, + user: flags.user, + port: flags.port, + key: flags.key, + desc: flags.desc, + compat: flags.compat, + scope: flags.scope as "project" | "user" | undefined, + }, + }; + + await initTheme(); + await runSSHCommand(cmd); + } +} diff --git a/packages/coding-agent/src/modes/controllers/ssh-command-controller.ts b/packages/coding-agent/src/modes/controllers/ssh-command-controller.ts new file mode 100644 index 000000000..1c13ebe91 --- /dev/null +++ b/packages/coding-agent/src/modes/controllers/ssh-command-controller.ts @@ -0,0 +1,452 @@ +/** + * SSH Command Controller + * + * Handles /ssh subcommands for managing SSH host configurations. + */ +import { Spacer, Text } from "@oh-my-pi/pi-tui"; +import { getProjectDir, getSSHConfigPath } from "@oh-my-pi/pi-utils/dirs"; +import { type SSHHost, sshCapability } from "../../capability/ssh"; +import { loadCapability } from "../../discovery"; +import { addSSHHost, readSSHConfigFile, removeSSHHost, type SSHHostConfig } from "../../ssh/config-writer"; +import { DynamicBorder } from "../components/dynamic-border"; +import { theme } from "../theme/theme"; +import type { InteractiveModeContext } from "../types"; + +function parseCommandArgs(argsString: string): string[] { + const args: string[] = []; + let current = ""; + let inQuote: string | null = null; + + for (let i = 0; i < argsString.length; i++) { + const char = argsString[i]; + + if (inQuote) { + if (char === inQuote) { + inQuote = null; + } else { + current += char; + } + } else if (char === '"' || char === "'") { + inQuote = char; + } else if (char === " " || char === "\t") { + if (current) { + args.push(current); + current = ""; + } + } else { + current += char; + } + } + + if (current) { + args.push(current); + } + + return args; +} + +type SSHAddScope = "user" | "project"; + +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: SSHAddScope = "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 value = tokens[i + 1]; + if (!value || (value !== "project" && value !== "user")) { + this.ctx.showError("Invalid --scope value. Use project or user."); + return; + } + scope = value; + 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); + + 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) { + // Group by source + const bySource = new Map(); + for (const host of discoveredHosts) { + const key = `${host._source.providerName}|${host._source.path}`; + let group = bySource.get(key); + if (!group) { + group = []; + bySource.set(key, group); + } + group.push(host); + } + + for (const [key, hosts] of bySource) { + const sepIdx = key.indexOf("|"); + const providerName = key.slice(0, sepIdx); + const sourcePath = key.slice(sepIdx + 1); + const shortPath = sourcePath.replace(process.env.HOME ?? "", "~"); + 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 tokens = parseCommandArgs(rest); + + let name: string | undefined; + let scope: "project" | "user" = "project"; + let i = 0; + + if (tokens.length > 0 && !tokens[0].startsWith("-")) { + name = tokens[0]; + i = 1; + } + + while (i < tokens.length) { + const token = tokens[i]; + if (token === "--scope") { + const value = tokens[i + 1]; + if (!value || (value !== "project" && value !== "user")) { + this.ctx.showError("Invalid --scope value. Use project or user."); + return; + } + scope = value; + i += 2; + continue; + } + this.ctx.showError(`Unknown option: ${token}`); + return; + } + + 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); + + 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 { + this.ctx.chatContainer.addChild(new Spacer(1)); + this.ctx.chatContainer.addChild(new DynamicBorder()); + this.ctx.chatContainer.addChild(new Text(text, 1, 1)); + this.ctx.chatContainer.addChild(new DynamicBorder()); + this.ctx.ui.requestRender(); + } +} diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index f0be26bdd..8342d2187 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -50,6 +50,7 @@ import { ExtensionUiController } from "./controllers/extension-ui-controller"; import { InputController } from "./controllers/input-controller"; import { MCPCommandController } from "./controllers/mcp-command-controller"; import { SelectorController } from "./controllers/selector-controller"; +import { SSHCommandController } from "./controllers/ssh-command-controller"; import { setMermaidRenderCallback } from "./theme/mermaid-cache"; import type { Theme } from "./theme/theme"; import { getEditorTheme, getMarkdownTheme, onThemeChange, theme } from "./theme/theme"; @@ -1052,6 +1053,11 @@ export class InteractiveMode implements InteractiveModeContext { await controller.handle(text); } + async handleSSHCommand(text: string): Promise { + const controller = new SSHCommandController(this); + await controller.handle(text); + } + handleCompactCommand(customInstructions?: string): Promise { return this.#commandController.handleCompactCommand(customInstructions); } diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index 6a441aefc..62d539220 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -148,6 +148,7 @@ export interface InteractiveModeContext { handleBashCommand(command: string, excludeFromContext?: boolean): Promise; handlePythonCommand(code: string, excludeFromContext?: boolean): Promise; handleMCPCommand(text: string): Promise; + handleSSHCommand(text: string): Promise; handleCompactCommand(customInstructions?: string): Promise; handleHandoffCommand(customInstructions?: string): Promise; handleMoveCommand(targetPath: string): Promise; diff --git a/packages/coding-agent/src/slash-commands/builtin-registry.ts b/packages/coding-agent/src/slash-commands/builtin-registry.ts index d9fc2e87a..1c7dbd766 100644 --- a/packages/coding-agent/src/slash-commands/builtin-registry.ts +++ b/packages/coding-agent/src/slash-commands/builtin-registry.ts @@ -290,6 +290,26 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ await runtime.ctx.handleMCPCommand(command.text); }, }, + { + name: "ssh", + description: "Manage SSH hosts (add, list, remove)", + subcommands: [ + { + name: "add", + description: "Add an SSH host", + usage: " --host [--user ] [--port ] [--key ]", + }, + { name: "list", description: "List all configured SSH hosts" }, + { name: "remove", description: "Remove an SSH host", usage: " [--scope project|user]" }, + { name: "help", description: "Show help message" }, + ], + allowArgs: true, + handle: async (command, runtime) => { + runtime.ctx.editor.addToHistory(command.text); + runtime.ctx.editor.setText(""); + await runtime.ctx.handleSSHCommand(command.text); + }, + }, { name: "new", description: "Start a new session", diff --git a/packages/coding-agent/src/ssh/config-writer.ts b/packages/coding-agent/src/ssh/config-writer.ts new file mode 100644 index 000000000..3752c0691 --- /dev/null +++ b/packages/coding-agent/src/ssh/config-writer.ts @@ -0,0 +1,183 @@ +/** + * SSH Configuration File Writer + * + * Utilities for reading/writing ssh.json files at user or project level. + */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { isEnoent } from "@oh-my-pi/pi-utils"; + +export interface SSHHostConfig { + host: string; + username?: string; + port?: number; + keyPath?: string; + description?: string; + compat?: boolean; +} + +export interface SSHConfigFile { + hosts?: Record; +} + +/** + * Read an SSH config file. + * Returns empty config if file doesn't exist. + */ +export async function readSSHConfigFile(filePath: string): Promise { + try { + const content = await fs.promises.readFile(filePath, "utf-8"); + const parsed = JSON.parse(content) as SSHConfigFile; + return parsed; + } catch (error) { + if (isEnoent(error)) { + // File doesn't exist, return empty config + return { hosts: {} }; + } + if (error instanceof SyntaxError) { + throw new Error(`Failed to parse SSH config file ${filePath}: ${error.message}`); + } + throw error; + } +} + +/** + * Write an SSH config file atomically. + * Creates parent directories if they don't exist. + */ +export async function writeSSHConfigFile(filePath: string, config: SSHConfigFile): Promise { + // Ensure parent directory exists + const dir = path.dirname(filePath); + await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 }); + + // Write to temp file first (atomic write) + const tmpPath = `${filePath}.tmp`; + const content = JSON.stringify(config, null, 2); + await fs.promises.writeFile(tmpPath, content, { encoding: "utf-8", mode: 0o600 }); + + // Rename to final path (atomic on most systems) + await fs.promises.rename(tmpPath, filePath); +} + +/** + * Validate host name. + * @returns Error message if invalid, undefined if valid + */ +export function validateHostName(name: string): string | undefined { + if (!name) { + return "Host name cannot be empty"; + } + if (name.length > 100) { + return "Host name is too long (max 100 characters)"; + } + // Check for invalid characters (only allow alphanumeric, dash, underscore, dot) + if (!/^[a-zA-Z0-9_.-]+$/.test(name)) { + return "Host name can only contain letters, numbers, dash, underscore, and dot"; + } + return undefined; +} + +/** + * Add an SSH host to a config file. + * + * @throws Error if host name already exists or validation fails + */ +export async function addSSHHost(filePath: string, name: string, hostConfig: SSHHostConfig): Promise { + // Validate host name + const nameError = validateHostName(name); + if (nameError) { + throw new Error(nameError); + } + + // Validate host field + if (!hostConfig.host) { + throw new Error("Host address cannot be empty"); + } + + // Read existing config + const existing = await readSSHConfigFile(filePath); + + // Check for duplicate name + if (existing.hosts?.[name]) { + throw new Error(`Host "${name}" already exists in ${filePath}`); + } + + // Add host + const updated: SSHConfigFile = { + ...existing, + hosts: { + ...existing.hosts, + [name]: hostConfig, + }, + }; + + // Write back + await writeSSHConfigFile(filePath, updated); +} + +/** + * Update an existing SSH host in a config file. + * If the host doesn't exist, this will add it. + * + * @throws Error if validation fails + */ +export async function updateSSHHost(filePath: string, name: string, hostConfig: SSHHostConfig): Promise { + // Validate host name + const nameError = validateHostName(name); + if (nameError) { + throw new Error(nameError); + } + + // Validate host field + if (!hostConfig.host) { + throw new Error("Host address cannot be empty"); + } + + // Read existing config + const existing = await readSSHConfigFile(filePath); + + // Update host + const updated: SSHConfigFile = { + ...existing, + hosts: { + ...existing.hosts, + [name]: hostConfig, + }, + }; + + // Write back + await writeSSHConfigFile(filePath, updated); +} + +/** + * Remove an SSH host from a config file. + * + * @throws Error if host doesn't exist + */ +export async function removeSSHHost(filePath: string, name: string): Promise { + // Read existing config + const existing = await readSSHConfigFile(filePath); + + // Check if host exists + if (!existing.hosts?.[name]) { + throw new Error(`Host "${name}" not found in ${filePath}`); + } + + // Remove host + const { [name]: _removed, ...remaining } = existing.hosts; + const updated: SSHConfigFile = { + ...existing, + hosts: remaining, + }; + + // Write back + await writeSSHConfigFile(filePath, updated); +} + +/** + * List all host names in a config file. + */ +export async function listSSHHosts(filePath: string): Promise { + const config = await readSSHConfigFile(filePath); + return Object.keys(config.hosts ?? {}); +} diff --git a/packages/utils/src/dirs.ts b/packages/utils/src/dirs.ts index a70e57cb5..6a1a1e925 100644 --- a/packages/utils/src/dirs.ts +++ b/packages/utils/src/dirs.ts @@ -284,3 +284,11 @@ export function getMCPConfigPath(scope: "user" | "project", cwd: string = getPro } return path.join(getProjectAgentDir(cwd), "mcp.json"); } + +/** Get the SSH config file path. */ +export function getSSHConfigPath(scope: "user" | "project", cwd: string = getProjectDir()): string { + if (scope === "user") { + return path.join(getAgentDir(), "ssh.json"); + } + return path.join(getProjectAgentDir(cwd), "ssh.json"); +}