a83175c94c
- Consolidated @oh-my-pi/pi-utils subpath imports into single package root import across 100+ files. - Moved tryParseJson utility from local web scrapers module to @oh-my-pi/pi-utils package for centralized JSON parsing. - Renamed loadSkillsFromDir to scanSkillsFromDir and refactored skill discovery to use fs.promises.readdir instead of glob-based approach. - Replaced custom parseJSON with tryParseJson across discovery modules for consistent error handling. - Removed emitCustomToolSessionEvent method and cleanupSshResources function, consolidating shutdown logic into dispose method. - Updated glob pattern construction to use GlobBuilder with literal_separator(true) for improved path handling.
422 lines
12 KiB
TypeScript
422 lines
12 KiB
TypeScript
/**
|
|
* 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";
|
|
import { type SSHHost, sshCapability } from "../../capability/ssh";
|
|
import { loadCapability } from "../../discovery";
|
|
import { addSSHHost, readSSHConfigFile, removeSSHHost, type SSHHostConfig } from "../../ssh/config-writer";
|
|
import { shortenPath } from "../../tools/render-utils";
|
|
import { DynamicBorder } from "../components/dynamic-border";
|
|
import { parseCommandArgs } from "../shared";
|
|
import { theme } from "../theme/theme";
|
|
import type { InteractiveModeContext } from "../types";
|
|
|
|
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 = shortenPath(sourcePath);
|
|
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();
|
|
}
|
|
}
|