feat(coding-agent): added SSH host management with CLI and slash commands
- Added SSH host management feature with `ssh` CLI command and `/ssh` slash command for add, list, and remove operations. - Added SSH configuration support at project (.omp/ssh.json) and user (~/.omp/agent/ssh.json) scopes with host discovery from project files. - Added SSH host configuration flags: --host, --user, --port, --key, --desc, --compat, and --scope for flexible host setup. - Removed automatic line relocation on stale hash references; now fails with error instead.
This commit is contained in:
@@ -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<void> {
|
||||
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 <name> --host <host> [--user <user>] [--port <port>] [--key <keyPath>] [--desc <description>] [--compat] [--scope project|user]",
|
||||
" /ssh list List all configured SSH hosts",
|
||||
" /ssh remove <name> [--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<void> {
|
||||
const prefixMatch = text.match(/^\/ssh\s+add\b\s*(.*)$/i);
|
||||
const rest = prefixMatch?.[1]?.trim() ?? "";
|
||||
if (!rest) {
|
||||
this.ctx.showError(
|
||||
"Usage: /ssh add <name> --host <host> [--user <user>] [--port <port>] [--key <keyPath>] [--desc <description>] [--compat] [--scope project|user]",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const tokens = parseCommandArgs(rest);
|
||||
if (tokens.length === 0) {
|
||||
this.ctx.showError(
|
||||
"Usage: /ssh add <name> --host <host> [--user <user>] [--port <port>] [--key <keyPath>] [--desc <description>] [--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 <name> --host <host> ...");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!host) {
|
||||
this.ctx.showError("--host is required. Usage: /ssh add <name> --host <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<void> {
|
||||
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<SSHHost>(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<string, SSHHost[]>();
|
||||
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 <name> - remove a host from config
|
||||
*/
|
||||
async #handleRemove(text: string): Promise<void> {
|
||||
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 <name> [--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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user