5ff277349c
- Added the `xd://` virtual device protocol (`internal-urls/xd-protocol.ts`, `tools/xdev.ts`): tools declaring `loadMode: "discoverable"` are unmounted from the request tools array and driven via `read xd://` (list/docs+schema) and `write xd://<tool>` (execute), gated by the `tools.xdev` setting (default on) and inlined into the system prompt. - Merged the `irc`, `job`, and `launch` tools into a single `hub` tool (`tools/hub/`, `async/job-manager.ts`): messaging keeps `send`/`inbox`/`list`, job control maps to `wait`/`cancel`/`jobs`, process supervision keeps `start`/`logs`/`stop`/`restart`/`describe` with `ps`, and the unified `wait` races background jobs against peer messages; SDK `IrcTool`/`JobTool`/`LaunchTool` are replaced by `HubTool`. - Removed the hidden `resolve` tool in favor of the `xd://resolve`/`xd://reject`/`xd://propose` resolution devices, auto-including `write` whenever a deferrable tool or plan mode is present. - Removed the BM25 tool-discovery system: the `search_tool_bm25` tool, the `tool-discovery` module, the `tools.discoveryMode`/`mcp.discoveryMode`/`mcp.discoveryDefaultServers`/`tools.essentialOverride` settings, per-tool MCP selection, and the `mcp_tool_selection` message type. - Unified tool presentation on `ToolLoadMode` (`essential`|`discoverable`), replacing the custom-tool `xdev?: boolean` opt-out; custom, extension, MCP, RPC host, image-generation, and TTS tools now default to `discoverable`, and added a `satisfies` predicate to `SoftToolRequirement`. - Removed the standalone `ssh` command tool and `ssh/ssh-executor` (the `ssh://` read/write/search protocol stays), and made `--tools` address hidden built-ins. - Updated collab-web to render `xd://` dispatches and `hub` op families, dropped the `search_tool_bm25`/`ssh`/`report-finding` renderers, refreshed tool docs and prompts, and migrated the affected tests and changelogs.
386 lines
11 KiB
TypeScript
386 lines
11 KiB
TypeScript
/**
|
|
* SSH Command Controller
|
|
*
|
|
* Handles /ssh subcommands for managing SSH host configurations.
|
|
*/
|
|
import { getProjectDir, getSSHConfigPath } from "@oh-my-pi/pi-utils";
|
|
import { reset as resetCapabilities } from "../../capability";
|
|
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<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: 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 <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);
|
|
resetCapabilities();
|
|
|
|
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) {
|
|
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 <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 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 <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);
|
|
resetCapabilities();
|
|
|
|
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);
|
|
}
|
|
}
|