feat(acp): add ACP built-in slash command framework and all handlers
- Introduces AcpBuiltinCommandSpec registry and executeAcpBuiltinSlashCommand dispatcher - Defines shared types (ParsedAcpCommand, AcpBuiltinCommandRuntime, result union) and utility helpers - Implements 24 built-in command handlers: /mcp (add/list/enable/disable/remove/test/search), /todo (append/start/done/drop/copy/import/export), /marketplace, /ssh (list/add/remove profiles), /usage, /model, /memory, /session, /compact, /context, /format, /fast, /force, /browser, /changelog, /commands, /dump, /export, /jobs, /move, /plugins, /reload-plugins, /rename, /share, /tools
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import type { AvailableCommand } from "@agentclientprotocol/sdk";
|
||||
import { ACP_BUILTIN_COMMANDS } from "./acp-builtins/commands";
|
||||
import type {
|
||||
AcpBuiltinCommandRuntime,
|
||||
AcpBuiltinCommandSpec,
|
||||
AcpBuiltinSlashCommandResult,
|
||||
ParsedAcpCommand,
|
||||
} from "./acp-builtins/types";
|
||||
|
||||
export type { AcpBuiltinCommandRuntime, AcpBuiltinSlashCommandResult } from "./acp-builtins/types";
|
||||
|
||||
function parseAcpBuiltinSlashCommand(text: string): ParsedAcpCommand | null {
|
||||
if (!text.startsWith("/")) return null;
|
||||
const body = text.slice(1);
|
||||
if (!body) return null;
|
||||
const firstWhitespace = body.search(/\s/);
|
||||
const firstColon = body.indexOf(":");
|
||||
const firstSeparator =
|
||||
firstWhitespace === -1 ? firstColon : firstColon === -1 ? firstWhitespace : Math.min(firstWhitespace, firstColon);
|
||||
if (firstSeparator === -1) return { name: body, args: "", text };
|
||||
return { name: body.slice(0, firstSeparator), args: body.slice(firstSeparator + 1).trim(), text };
|
||||
}
|
||||
|
||||
export const ACP_BUILTIN_SLASH_COMMANDS: AvailableCommand[] = ACP_BUILTIN_COMMANDS.map(command => ({
|
||||
name: command.name,
|
||||
description: command.description,
|
||||
input: command.inputHint ? { hint: command.inputHint } : undefined,
|
||||
}));
|
||||
|
||||
const COMMAND_LOOKUP = new Map<string, AcpBuiltinCommandSpec>();
|
||||
for (const command of ACP_BUILTIN_COMMANDS) {
|
||||
COMMAND_LOOKUP.set(command.name, command);
|
||||
for (const alias of command.aliases ?? []) COMMAND_LOOKUP.set(alias, command);
|
||||
}
|
||||
|
||||
export async function executeAcpBuiltinSlashCommand(
|
||||
text: string,
|
||||
runtime: AcpBuiltinCommandRuntime,
|
||||
): Promise<AcpBuiltinSlashCommandResult> {
|
||||
const parsed = parseAcpBuiltinSlashCommand(text);
|
||||
if (!parsed) return false;
|
||||
const command = COMMAND_LOOKUP.get(parsed.name);
|
||||
if (!command) return false;
|
||||
return await command.handle(parsed, runtime);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { SettingPath, SettingValue } from "../../config/settings";
|
||||
import { commandConsumed, usage } from "./shared";
|
||||
import type { AcpBuiltinCommandSpec } from "./types";
|
||||
|
||||
export const browserCommand: AcpBuiltinCommandSpec = {
|
||||
name: "browser",
|
||||
description: "Toggle browser headless vs visible mode",
|
||||
inputHint: "[headless|visible]",
|
||||
handle: async (command, runtime) => {
|
||||
const arg = command.args.toLowerCase();
|
||||
const enabled = runtime.settings.get("browser.enabled" as SettingPath) as boolean;
|
||||
if (!enabled) return usage("Browser tool is disabled (enable in settings).", runtime);
|
||||
const current = runtime.settings.get("browser.headless" as SettingPath) as boolean;
|
||||
let next = current;
|
||||
if (!arg) next = !current;
|
||||
else if (["headless", "hidden"].includes(arg)) next = true;
|
||||
else if (["visible", "show", "headful"].includes(arg)) next = false;
|
||||
else return usage("Usage: /browser [headless|visible]", runtime);
|
||||
runtime.settings.set("browser.headless" as SettingPath, next as SettingValue<SettingPath>);
|
||||
const tool = runtime.session.getToolByName("browser");
|
||||
if (tool && "restartForModeChange" in tool) {
|
||||
await (tool as { restartForModeChange: () => Promise<void> }).restartForModeChange();
|
||||
}
|
||||
await runtime.output(`Browser mode: ${next ? "headless" : "visible"}`);
|
||||
return commandConsumed();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { getChangelogPath, parseChangelog } from "../../utils/changelog";
|
||||
import { commandConsumed } from "./shared";
|
||||
import type { AcpBuiltinCommandSpec } from "./types";
|
||||
|
||||
export const changelogCommand: AcpBuiltinCommandSpec = {
|
||||
name: "changelog",
|
||||
description: "Show changelog",
|
||||
inputHint: "[full]",
|
||||
handle: async (command, runtime) => {
|
||||
const changelogPath = getChangelogPath();
|
||||
const allEntries = await parseChangelog(changelogPath);
|
||||
const showFull = command.args.trim().toLowerCase() === "full";
|
||||
const entriesToShow = showFull ? allEntries : allEntries.slice(0, 3);
|
||||
if (entriesToShow.length === 0) {
|
||||
await runtime.output("No changelog entries found.");
|
||||
return commandConsumed();
|
||||
}
|
||||
await runtime.output(
|
||||
[...entriesToShow]
|
||||
.reverse()
|
||||
.map(entry => entry.content)
|
||||
.join("\n\n"),
|
||||
);
|
||||
return commandConsumed();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { browserCommand } from "./browser";
|
||||
import { changelogCommand } from "./changelog";
|
||||
import { compactCommand } from "./compact";
|
||||
import { contextCommand } from "./context";
|
||||
import { dumpCommand } from "./dump";
|
||||
import { exportCommand } from "./export";
|
||||
import { fastCommand } from "./fast";
|
||||
import { forceCommand } from "./force";
|
||||
import { jobsCommand } from "./jobs";
|
||||
import { marketplaceCommand } from "./marketplace";
|
||||
import { mcpCommand } from "./mcp";
|
||||
import { memoryCommand } from "./memory";
|
||||
import { modelCommand } from "./model";
|
||||
import { moveCommand } from "./move";
|
||||
import { pluginsCommand } from "./plugins";
|
||||
import { reloadPluginsCommand } from "./reload-plugins";
|
||||
import { renameCommand } from "./rename";
|
||||
import { sessionCommand } from "./session";
|
||||
import { shareCommand } from "./share";
|
||||
import { sshCommand } from "./ssh";
|
||||
import { todoCommand } from "./todo";
|
||||
import { toolsCommand } from "./tools";
|
||||
import type { AcpBuiltinCommandSpec } from "./types";
|
||||
import { usageCommand } from "./usage";
|
||||
|
||||
export const ACP_BUILTIN_COMMANDS: ReadonlyArray<AcpBuiltinCommandSpec> = [
|
||||
fastCommand,
|
||||
browserCommand,
|
||||
usageCommand,
|
||||
dumpCommand,
|
||||
contextCommand,
|
||||
toolsCommand,
|
||||
modelCommand,
|
||||
jobsCommand,
|
||||
changelogCommand,
|
||||
exportCommand,
|
||||
shareCommand,
|
||||
todoCommand,
|
||||
sessionCommand,
|
||||
mcpCommand,
|
||||
sshCommand,
|
||||
compactCommand,
|
||||
memoryCommand,
|
||||
renameCommand,
|
||||
moveCommand,
|
||||
marketplaceCommand,
|
||||
pluginsCommand,
|
||||
reloadPluginsCommand,
|
||||
forceCommand,
|
||||
];
|
||||
@@ -0,0 +1,22 @@
|
||||
import { commandConsumed } from "./shared";
|
||||
import type { AcpBuiltinCommandSpec } from "./types";
|
||||
|
||||
export const compactCommand: AcpBuiltinCommandSpec = {
|
||||
name: "compact",
|
||||
description: "Compact the conversation",
|
||||
inputHint: "[focus instructions]",
|
||||
handle: async (command, runtime) => {
|
||||
const before = runtime.session.getContextUsage?.();
|
||||
const beforeTokens = before?.tokens;
|
||||
await runtime.session.compact(command.args || undefined);
|
||||
const after = runtime.session.getContextUsage?.();
|
||||
const afterTokens = after?.tokens;
|
||||
if (beforeTokens != null && afterTokens != null) {
|
||||
const saved = beforeTokens - afterTokens;
|
||||
await runtime.output(`Compaction complete. Tokens: ${beforeTokens} -> ${afterTokens} (saved ${saved}).`);
|
||||
} else {
|
||||
await runtime.output("Compaction complete.");
|
||||
}
|
||||
return commandConsumed();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { computeContextBreakdown } from "../../modes/utils/context-usage";
|
||||
import { renderAsciiBar } from "./format";
|
||||
import { commandConsumed } from "./shared";
|
||||
import type { AcpBuiltinCommandRuntime, AcpBuiltinCommandSpec } from "./types";
|
||||
|
||||
function getContext(runtime: AcpBuiltinCommandRuntime): string {
|
||||
try {
|
||||
const breakdown = computeContextBreakdown(runtime.session);
|
||||
if (breakdown.contextWindow <= 0) {
|
||||
return "Context usage is unavailable: no model is selected for this session.";
|
||||
}
|
||||
const usedPct = Math.round((breakdown.usedTokens / breakdown.contextWindow) * 100);
|
||||
const lines = [`Context window: ${breakdown.contextWindow} tokens (${usedPct}% used)`];
|
||||
for (const category of breakdown.categories) {
|
||||
if (category.tokens === 0) continue;
|
||||
const fraction = category.tokens / breakdown.contextWindow;
|
||||
lines.push(` ${category.label.padEnd(16)} ${renderAsciiBar(fraction)} ${category.tokens} tokens`);
|
||||
}
|
||||
if (breakdown.autoCompactBufferTokens > 0) {
|
||||
const fraction = breakdown.autoCompactBufferTokens / breakdown.contextWindow;
|
||||
lines.push(
|
||||
` ${"Auto-compact buf".padEnd(16)} ${renderAsciiBar(fraction)} ${breakdown.autoCompactBufferTokens} tokens`,
|
||||
);
|
||||
}
|
||||
if (breakdown.freeTokens > 0) {
|
||||
const fraction = breakdown.freeTokens / breakdown.contextWindow;
|
||||
lines.push(` ${"Free".padEnd(16)} ${renderAsciiBar(fraction)} ${breakdown.freeTokens} tokens`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
} catch {
|
||||
const usage = runtime.session.getContextUsage();
|
||||
if (!usage) return "Context usage is unavailable.";
|
||||
return ["Context", `Window: ${usage.contextWindow}`, `Used: ${usage.tokens ?? 0}`].join("\n");
|
||||
}
|
||||
}
|
||||
|
||||
export const contextCommand: AcpBuiltinCommandSpec = {
|
||||
name: "context",
|
||||
description: "Show context usage",
|
||||
handle: async (_command, runtime) => {
|
||||
await runtime.output(getContext(runtime));
|
||||
return commandConsumed();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { commandConsumed } from "./shared";
|
||||
import type { AcpBuiltinCommandSpec } from "./types";
|
||||
|
||||
export const dumpCommand: AcpBuiltinCommandSpec = {
|
||||
name: "dump",
|
||||
description: "Return full transcript as plain text",
|
||||
handle: async (_command, runtime) => {
|
||||
const text = runtime.session.formatSessionAsText();
|
||||
await runtime.output(text || "No messages to dump yet.");
|
||||
return commandConsumed();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { commandConsumed, errorMessage, usage } from "./shared";
|
||||
import type { AcpBuiltinCommandSpec } from "./types";
|
||||
|
||||
export const exportCommand: AcpBuiltinCommandSpec = {
|
||||
name: "export",
|
||||
description: "Export session to HTML file",
|
||||
inputHint: "[path]",
|
||||
handle: async (command, runtime) => {
|
||||
try {
|
||||
const filePath = await runtime.session.exportToHtml(command.args || undefined);
|
||||
await runtime.output(`Session exported to: ${filePath}`);
|
||||
return commandConsumed();
|
||||
} catch (err) {
|
||||
return usage(`Failed to export session: ${errorMessage(err)}`, runtime);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { commandConsumed, usage } from "./shared";
|
||||
import type { AcpBuiltinCommandSpec } from "./types";
|
||||
|
||||
export const fastCommand: AcpBuiltinCommandSpec = {
|
||||
name: "fast",
|
||||
description: "Toggle fast mode",
|
||||
inputHint: "[on|off|status]",
|
||||
handle: async (command, runtime) => {
|
||||
const arg = command.args.toLowerCase();
|
||||
if (!arg || arg === "toggle") {
|
||||
const enabled = runtime.session.toggleFastMode();
|
||||
await runtime.output(`Fast mode ${enabled ? "enabled" : "disabled"}.`);
|
||||
return commandConsumed();
|
||||
}
|
||||
if (arg === "on") {
|
||||
runtime.session.setFastMode(true);
|
||||
await runtime.output("Fast mode enabled.");
|
||||
return commandConsumed();
|
||||
}
|
||||
if (arg === "off") {
|
||||
runtime.session.setFastMode(false);
|
||||
await runtime.output("Fast mode disabled.");
|
||||
return commandConsumed();
|
||||
}
|
||||
if (arg === "status") {
|
||||
await runtime.output(`Fast mode is ${runtime.session.isFastModeEnabled() ? "on" : "off"}.`);
|
||||
return commandConsumed();
|
||||
}
|
||||
return usage("Usage: /fast [on|off|status]", runtime);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { commandConsumed, usage } from "./shared";
|
||||
import type { AcpBuiltinCommandSpec } from "./types";
|
||||
|
||||
export const forceCommand: AcpBuiltinCommandSpec = {
|
||||
name: "force",
|
||||
description: "Force next turn to use a specific tool",
|
||||
inputHint: "<tool-name> [prompt]",
|
||||
aliases: ["force:"],
|
||||
handle: async (command, runtime) => {
|
||||
const spaceIdx = command.args.indexOf(" ");
|
||||
const toolName = spaceIdx === -1 ? command.args : command.args.slice(0, spaceIdx);
|
||||
const prompt = spaceIdx === -1 ? "" : command.args.slice(spaceIdx + 1).trim();
|
||||
if (!toolName) return usage("Usage: /force:<tool-name> [prompt]", runtime);
|
||||
runtime.session.setForcedToolChoice(toolName);
|
||||
await runtime.output(`Next turn forced to use ${toolName}.`);
|
||||
return prompt ? { prompt } : commandConsumed();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
export function formatDuration(ms: number): string {
|
||||
const seconds = Math.max(0, Math.round(ms / 1000));
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.round(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 48) return `${hours}h`;
|
||||
const days = Math.round(hours / 24);
|
||||
return `${days}d`;
|
||||
}
|
||||
|
||||
export function renderAsciiBar(fraction: number | undefined, width = 24): string {
|
||||
if (fraction === undefined) return `[${"·".repeat(width)}]`;
|
||||
const clamped = Math.min(Math.max(fraction, 0), 1);
|
||||
const filled = Math.round(clamped * width);
|
||||
const pct = Math.round(clamped * 100);
|
||||
return `[${"█".repeat(filled)}${"░".repeat(Math.max(0, width - filled))}] ${pct}%`;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { formatDuration } from "./format";
|
||||
import { commandConsumed } from "./shared";
|
||||
import type { AcpBuiltinCommandSpec } from "./types";
|
||||
|
||||
export const jobsCommand: AcpBuiltinCommandSpec = {
|
||||
name: "jobs",
|
||||
description: "Show background jobs",
|
||||
handle: async (_command, runtime) => {
|
||||
const snapshot = runtime.session.getAsyncJobSnapshot({ recentLimit: 5 });
|
||||
if (!snapshot || (snapshot.running.length === 0 && snapshot.recent.length === 0)) {
|
||||
await runtime.output(
|
||||
"No background jobs running. (Background jobs run async tools — e.g. long-running bash, debug, or task subagents that would otherwise tie up a turn. They appear here while alive and for ~5 minutes after.)",
|
||||
);
|
||||
return commandConsumed();
|
||||
}
|
||||
const now = Date.now();
|
||||
const lines: string[] = ["Background Jobs", `Running: ${snapshot.running.length}`];
|
||||
if (snapshot.running.length > 0) {
|
||||
lines.push("", "Running Jobs");
|
||||
for (const job of snapshot.running) {
|
||||
lines.push(` [${job.id}] ${job.type} (${job.status}) — ${formatDuration(now - job.startTime)}`);
|
||||
lines.push(` ${job.label}`);
|
||||
}
|
||||
}
|
||||
if (snapshot.recent.length > 0) {
|
||||
lines.push("", "Recent Jobs");
|
||||
for (const job of snapshot.recent) {
|
||||
lines.push(` [${job.id}] ${job.type} (${job.status}) — ${formatDuration(now - job.startTime)}`);
|
||||
lines.push(` ${job.label}`);
|
||||
}
|
||||
}
|
||||
await runtime.output(lines.join("\n"));
|
||||
return commandConsumed();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { clearPluginRootsAndCaches, resolveOrDefaultProjectRegistryPath } from "../../discovery/helpers";
|
||||
import {
|
||||
getInstalledPluginsRegistryPath,
|
||||
getMarketplacesCacheDir,
|
||||
getMarketplacesRegistryPath,
|
||||
getPluginsCacheDir,
|
||||
MarketplaceManager,
|
||||
} from "../../extensibility/plugins/marketplace";
|
||||
import type { AcpBuiltinCommandRuntime } from "./types";
|
||||
|
||||
export async function createMarketplaceManager(runtime: AcpBuiltinCommandRuntime): Promise<MarketplaceManager> {
|
||||
return new MarketplaceManager({
|
||||
marketplacesRegistryPath: getMarketplacesRegistryPath(),
|
||||
installedRegistryPath: getInstalledPluginsRegistryPath(),
|
||||
projectInstalledRegistryPath: await resolveOrDefaultProjectRegistryPath(runtime.cwd),
|
||||
marketplacesCacheDir: getMarketplacesCacheDir(),
|
||||
pluginsCacheDir: getPluginsCacheDir(),
|
||||
clearPluginRootsCache: clearPluginRootsAndCaches,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import type { MarketplaceManager } from "../../extensibility/plugins/marketplace";
|
||||
import { parseMarketplaceInstallArgs, parsePluginScopeArgs } from "../marketplace-install-parser";
|
||||
import { createMarketplaceManager } from "./marketplace-manager";
|
||||
import { commandConsumed, errorMessage, parseSubcommand, usage } from "./shared";
|
||||
import type { AcpBuiltinCommandRuntime, AcpBuiltinCommandSpec, AcpBuiltinSlashCommandResult } from "./types";
|
||||
|
||||
function marketplaceHelpText(): string {
|
||||
return [
|
||||
"Marketplace commands:",
|
||||
" /marketplace List configured marketplaces",
|
||||
" /marketplace add <source> Add a marketplace (e.g. owner/repo)",
|
||||
" /marketplace remove <name> Remove a marketplace",
|
||||
" /marketplace update [name] Re-fetch catalog(s)",
|
||||
" /marketplace list List configured marketplaces",
|
||||
" /marketplace discover [marketplace] Browse available plugins",
|
||||
" /marketplace install <name@marketplace> Install a plugin",
|
||||
" /marketplace uninstall <name@marketplace> Uninstall a plugin",
|
||||
" /marketplace installed List installed plugins",
|
||||
" /marketplace upgrade [name@marketplace] Upgrade plugin(s)",
|
||||
"",
|
||||
"Quick start:",
|
||||
" /marketplace add anthropics/claude-plugins-official",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function handleSummaryCommand(runtime: AcpBuiltinCommandRuntime): Promise<AcpBuiltinSlashCommandResult> {
|
||||
try {
|
||||
const manager = await createMarketplaceManager(runtime);
|
||||
const marketplaces = await manager.listMarketplaces();
|
||||
if (marketplaces.length === 0) {
|
||||
await runtime.output(
|
||||
"No marketplaces configured.\n\nGet started:\n /marketplace add anthropics/claude-plugins-official\n\nThen browse with /marketplace discover",
|
||||
);
|
||||
} else {
|
||||
const lines = marketplaces.map(marketplace => ` ${marketplace.name} ${marketplace.sourceUri}`);
|
||||
await runtime.output(
|
||||
`Marketplaces:\n${lines.join("\n")}\n\nUse /marketplace discover to browse plugins, or /marketplace help for all commands`,
|
||||
);
|
||||
}
|
||||
return commandConsumed();
|
||||
} catch (err) {
|
||||
return usage(`Marketplace error: ${errorMessage(err)}`, runtime);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDiscoverCommand(
|
||||
manager: MarketplaceManager,
|
||||
rest: string,
|
||||
runtime: AcpBuiltinCommandRuntime,
|
||||
): Promise<AcpBuiltinSlashCommandResult> {
|
||||
const plugins = await manager.listAvailablePlugins(rest || undefined);
|
||||
if (plugins.length === 0) {
|
||||
const marketplaces = await manager.listMarketplaces();
|
||||
await runtime.output(
|
||||
marketplaces.length === 0
|
||||
? "No marketplaces configured. Try:\n /marketplace add anthropics/claude-plugins-official"
|
||||
: "No plugins available in configured marketplaces",
|
||||
);
|
||||
return commandConsumed();
|
||||
}
|
||||
|
||||
const lines = ["Available plugins:"];
|
||||
for (const plugin of plugins) {
|
||||
lines.push(` - ${plugin.name}${plugin.version ? `@${plugin.version}` : ""}`);
|
||||
if (plugin.description) lines.push(` ${plugin.description}`);
|
||||
}
|
||||
await runtime.output(lines.join("\n"));
|
||||
return commandConsumed();
|
||||
}
|
||||
|
||||
async function handleInstallCommand(
|
||||
manager: MarketplaceManager,
|
||||
rest: string,
|
||||
runtime: AcpBuiltinCommandRuntime,
|
||||
): Promise<AcpBuiltinSlashCommandResult> {
|
||||
const parsed = parseMarketplaceInstallArgs(rest);
|
||||
if ("error" in parsed) return usage(parsed.error, runtime);
|
||||
const atIndex = parsed.installSpec.lastIndexOf("@");
|
||||
const pluginName = parsed.installSpec.slice(0, atIndex);
|
||||
const marketplace = parsed.installSpec.slice(atIndex + 1);
|
||||
await manager.installPlugin(pluginName, marketplace, { force: parsed.force, scope: parsed.scope });
|
||||
await runtime.output(`Installed ${pluginName} from ${marketplace}`);
|
||||
return commandConsumed();
|
||||
}
|
||||
|
||||
async function handleUninstallCommand(
|
||||
manager: MarketplaceManager,
|
||||
rest: string,
|
||||
runtime: AcpBuiltinCommandRuntime,
|
||||
): Promise<AcpBuiltinSlashCommandResult> {
|
||||
const parsed = parsePluginScopeArgs(rest, "Usage: /marketplace uninstall [--scope user|project] <name@marketplace>");
|
||||
if ("error" in parsed) return usage(parsed.error, runtime);
|
||||
await manager.uninstallPlugin(parsed.pluginId, parsed.scope);
|
||||
await runtime.output(`Uninstalled ${parsed.pluginId}`);
|
||||
return commandConsumed();
|
||||
}
|
||||
|
||||
async function handleInstalledCommand(
|
||||
manager: MarketplaceManager,
|
||||
runtime: AcpBuiltinCommandRuntime,
|
||||
): Promise<AcpBuiltinSlashCommandResult> {
|
||||
const installed = await manager.listInstalledPlugins();
|
||||
if (installed.length === 0) {
|
||||
await runtime.output("No marketplace plugins installed");
|
||||
} else {
|
||||
const lines = installed.map(
|
||||
plugin =>
|
||||
` ${plugin.id} [${plugin.scope}]${plugin.shadowedBy ? " [shadowed]" : ""} (${plugin.entries.length} entry)`,
|
||||
);
|
||||
await runtime.output(`Installed plugins:\n${lines.join("\n")}`);
|
||||
}
|
||||
return commandConsumed();
|
||||
}
|
||||
|
||||
async function handleUpgradeCommand(
|
||||
manager: MarketplaceManager,
|
||||
rest: string,
|
||||
runtime: AcpBuiltinCommandRuntime,
|
||||
): Promise<AcpBuiltinSlashCommandResult> {
|
||||
if (rest) {
|
||||
const parsed = parsePluginScopeArgs(
|
||||
rest,
|
||||
"Usage: /marketplace upgrade [--scope user|project] <name@marketplace>",
|
||||
);
|
||||
if ("error" in parsed) return usage(parsed.error, runtime);
|
||||
const result = await manager.upgradePlugin(parsed.pluginId, parsed.scope);
|
||||
await runtime.output(`Upgraded ${parsed.pluginId} to ${result.version}`);
|
||||
return commandConsumed();
|
||||
}
|
||||
|
||||
const results = await manager.upgradeAllPlugins();
|
||||
if (results.length === 0) {
|
||||
await runtime.output("All marketplace plugins are up to date");
|
||||
} else {
|
||||
const lines = results.map(result => ` ${result.pluginId}: ${result.from} -> ${result.to}`);
|
||||
await runtime.output(`Upgraded ${results.length} plugin(s):\n${lines.join("\n")}`);
|
||||
}
|
||||
return commandConsumed();
|
||||
}
|
||||
|
||||
type MarketplaceHandler = (
|
||||
manager: MarketplaceManager,
|
||||
rest: string,
|
||||
runtime: AcpBuiltinCommandRuntime,
|
||||
) => Promise<AcpBuiltinSlashCommandResult>;
|
||||
|
||||
async function handleAddCommand(
|
||||
manager: MarketplaceManager,
|
||||
rest: string,
|
||||
runtime: AcpBuiltinCommandRuntime,
|
||||
): Promise<AcpBuiltinSlashCommandResult> {
|
||||
if (!rest) return usage("Usage: /marketplace add <source>", runtime);
|
||||
const entry = await manager.addMarketplace(rest);
|
||||
await runtime.output(`Added marketplace: ${entry.name}`);
|
||||
return commandConsumed();
|
||||
}
|
||||
|
||||
async function handleRemoveCommand(
|
||||
manager: MarketplaceManager,
|
||||
rest: string,
|
||||
runtime: AcpBuiltinCommandRuntime,
|
||||
): Promise<AcpBuiltinSlashCommandResult> {
|
||||
if (!rest) return usage("Usage: /marketplace remove <name>", runtime);
|
||||
await manager.removeMarketplace(rest);
|
||||
await runtime.output(`Removed marketplace: ${rest}`);
|
||||
return commandConsumed();
|
||||
}
|
||||
|
||||
async function handleUpdateCommand(
|
||||
manager: MarketplaceManager,
|
||||
rest: string,
|
||||
runtime: AcpBuiltinCommandRuntime,
|
||||
): Promise<AcpBuiltinSlashCommandResult> {
|
||||
if (rest) {
|
||||
await manager.updateMarketplace(rest);
|
||||
await runtime.output(`Updated marketplace: ${rest}`);
|
||||
return commandConsumed();
|
||||
}
|
||||
const results = await manager.updateAllMarketplaces();
|
||||
await runtime.output(`Updated ${results.length} marketplace(s)`);
|
||||
return commandConsumed();
|
||||
}
|
||||
|
||||
async function handleListCommand(
|
||||
manager: MarketplaceManager,
|
||||
_rest: string,
|
||||
runtime: AcpBuiltinCommandRuntime,
|
||||
): Promise<AcpBuiltinSlashCommandResult> {
|
||||
const marketplaces = await manager.listMarketplaces();
|
||||
if (marketplaces.length === 0) {
|
||||
await runtime.output("No marketplaces configured.");
|
||||
} else {
|
||||
const lines = marketplaces.map(marketplace => ` ${marketplace.name} ${marketplace.sourceUri}`);
|
||||
await runtime.output(`Marketplaces:\n${lines.join("\n")}`);
|
||||
}
|
||||
return commandConsumed();
|
||||
}
|
||||
|
||||
const MARKETPLACE_HANDLERS = new Map<string, MarketplaceHandler>([
|
||||
["add", handleAddCommand],
|
||||
["remove", handleRemoveCommand],
|
||||
["rm", handleRemoveCommand],
|
||||
["update", handleUpdateCommand],
|
||||
["list", handleListCommand],
|
||||
["discover", handleDiscoverCommand],
|
||||
["install", handleInstallCommand],
|
||||
["uninstall", handleUninstallCommand],
|
||||
["installed", (manager, _rest, runtime) => handleInstalledCommand(manager, runtime)],
|
||||
["upgrade", handleUpgradeCommand],
|
||||
]);
|
||||
|
||||
export const marketplaceCommand: AcpBuiltinCommandSpec = {
|
||||
name: "marketplace",
|
||||
description: "Manage plugins from marketplaces",
|
||||
inputHint: "<subcommand>",
|
||||
handle: async (command, runtime) => {
|
||||
const { verb, rest } = parseSubcommand(command.args);
|
||||
if (!verb) return await handleSummaryCommand(runtime);
|
||||
if (verb === "help") {
|
||||
await runtime.output(marketplaceHelpText());
|
||||
return commandConsumed();
|
||||
}
|
||||
if ((verb === "install" || verb === "uninstall") && !rest) {
|
||||
return usage("Interactive plugin pickers are TUI-only. Pass an explicit name@marketplace argument.", runtime);
|
||||
}
|
||||
const handler = MARKETPLACE_HANDLERS.get(verb);
|
||||
if (!handler)
|
||||
return usage(
|
||||
`Unknown /marketplace subcommand: ${verb}. Use /marketplace help for available commands.`,
|
||||
runtime,
|
||||
);
|
||||
try {
|
||||
const manager = await createMarketplaceManager(runtime);
|
||||
return await handler(manager, rest, runtime);
|
||||
} catch (err) {
|
||||
return usage(`Marketplace error: ${errorMessage(err)}`, runtime);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,512 @@
|
||||
import { getMCPConfigPath } from "@oh-my-pi/pi-utils";
|
||||
import { connectToServer, disconnectServer, listPrompts, listResources, listTools } from "../../mcp/client";
|
||||
import {
|
||||
addMCPServer,
|
||||
readDisabledServers,
|
||||
readMCPConfigFile,
|
||||
removeMCPServer,
|
||||
setServerDisabled,
|
||||
updateMCPServer,
|
||||
} from "../../mcp/config-writer";
|
||||
import { MCPManager } from "../../mcp/manager";
|
||||
import { getSmitheryApiKey } from "../../mcp/smithery-auth";
|
||||
import { searchSmitheryRegistry } from "../../mcp/smithery-registry";
|
||||
import type { MCPServerConfig, MCPServerConnection } from "../../mcp/types";
|
||||
import { parseCommandArgs } from "../../utils/command-args";
|
||||
import { commandConsumed, errorMessage, parseNamedScopeArgs, parseSubcommand, usage } from "./shared";
|
||||
import type { AcpBuiltinCommandRuntime, AcpBuiltinCommandSpec } from "./types";
|
||||
|
||||
type AcpMcpScope = "user" | "project";
|
||||
|
||||
interface ParsedMcpAddArgs {
|
||||
name?: string;
|
||||
scope: AcpMcpScope;
|
||||
url?: string;
|
||||
transport: "http" | "sse";
|
||||
authToken?: string;
|
||||
commandTokens?: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ParsedMcpSearchArgs {
|
||||
keyword: string;
|
||||
scope: AcpMcpScope;
|
||||
limit: number;
|
||||
semantic: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
type McpAddOptionParser = (parsed: ParsedMcpAddArgs, value: string | undefined) => string | undefined;
|
||||
|
||||
const MCP_ADD_USAGE =
|
||||
"Usage: /mcp add <name> [--scope project|user] [--url <url> --transport http|sse] [--token <token>] [-- <command...>]";
|
||||
|
||||
const MCP_ADD_OPTION_PARSERS = new Map<string, McpAddOptionParser>([
|
||||
[
|
||||
"--scope",
|
||||
(parsed, value) => {
|
||||
if (!value || (value !== "project" && value !== "user")) return "Invalid --scope value. Use project or user.";
|
||||
parsed.scope = value;
|
||||
return undefined;
|
||||
},
|
||||
],
|
||||
[
|
||||
"--url",
|
||||
(parsed, value) => {
|
||||
if (!value) return "Missing value for --url.";
|
||||
parsed.url = value;
|
||||
return undefined;
|
||||
},
|
||||
],
|
||||
[
|
||||
"--transport",
|
||||
(parsed, value) => {
|
||||
if (!value || (value !== "http" && value !== "sse")) return "Invalid --transport value. Use http or sse.";
|
||||
parsed.transport = value;
|
||||
return undefined;
|
||||
},
|
||||
],
|
||||
[
|
||||
"--token",
|
||||
(parsed, value) => {
|
||||
if (!value) return "Missing value for --token.";
|
||||
parsed.authToken = value;
|
||||
return undefined;
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
async function getMcpConfiguredServers(
|
||||
cwd: string,
|
||||
): Promise<Array<{ name: string; config: MCPServerConfig; scope: AcpMcpScope }>> {
|
||||
const userPath = getMCPConfigPath("user", cwd);
|
||||
const projectPath = getMCPConfigPath("project", cwd);
|
||||
const [userConfig, projectConfig] = await Promise.all([readMCPConfigFile(userPath), readMCPConfigFile(projectPath)]);
|
||||
const servers: Array<{ name: string; config: MCPServerConfig; scope: AcpMcpScope }> = [];
|
||||
const seen = new Set<string>();
|
||||
for (const [name, config] of Object.entries(userConfig.mcpServers ?? {})) {
|
||||
if (config.enabled !== false) {
|
||||
servers.push({ name, config, scope: "user" });
|
||||
seen.add(name);
|
||||
}
|
||||
}
|
||||
for (const [name, config] of Object.entries(projectConfig.mcpServers ?? {})) {
|
||||
if (!seen.has(name) && config.enabled !== false) servers.push({ name, config, scope: "project" });
|
||||
}
|
||||
return servers;
|
||||
}
|
||||
|
||||
function mcpAddArgError(parsed: ParsedMcpAddArgs, error: string): ParsedMcpAddArgs {
|
||||
return { ...parsed, error };
|
||||
}
|
||||
|
||||
function validateParsedMcpAddArgs(parsed: ParsedMcpAddArgs): ParsedMcpAddArgs {
|
||||
const hasCommand = (parsed.commandTokens?.length ?? 0) > 0;
|
||||
const hasUrl = Boolean(parsed.url);
|
||||
if (!hasCommand && !hasUrl) {
|
||||
return mcpAddArgError(
|
||||
parsed,
|
||||
"Provide --url or -- <command...> for non-interactive add. Usage: /mcp add <name> [--scope project|user] [--url <url> --transport http|sse] [--token <token>] [-- <command...>]",
|
||||
);
|
||||
}
|
||||
if (!parsed.name) return mcpAddArgError(parsed, "Server name required. Usage: /mcp add <name> ...");
|
||||
if (hasCommand && hasUrl) return mcpAddArgError(parsed, "Use either --url or -- <command...>, not both.");
|
||||
if (parsed.authToken && !hasUrl) return mcpAddArgError(parsed, "--token requires --url (HTTP/SSE transport).");
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseMcpAddArgs(rest: string): ParsedMcpAddArgs {
|
||||
const tokens = parseCommandArgs(rest);
|
||||
const parsed: ParsedMcpAddArgs = { scope: "project", transport: "http" };
|
||||
if (tokens.length === 0) return parsed;
|
||||
|
||||
let index = 0;
|
||||
if (!tokens[0]!.startsWith("-")) {
|
||||
parsed.name = tokens[0];
|
||||
index = 1;
|
||||
}
|
||||
|
||||
while (index < tokens.length) {
|
||||
const arg = tokens[index]!;
|
||||
if (arg === "--") {
|
||||
parsed.commandTokens = tokens.slice(index + 1);
|
||||
break;
|
||||
}
|
||||
const parser = MCP_ADD_OPTION_PARSERS.get(arg);
|
||||
if (!parser) return mcpAddArgError(parsed, `Unknown option: ${arg}`);
|
||||
const error = parser(parsed, tokens[index + 1]);
|
||||
if (error) return mcpAddArgError(parsed, error);
|
||||
index += 2;
|
||||
}
|
||||
|
||||
return validateParsedMcpAddArgs(parsed);
|
||||
}
|
||||
|
||||
function parseMcpSearchArgs(rest: string): ParsedMcpSearchArgs {
|
||||
const tokens = parseCommandArgs(rest);
|
||||
const missingKeyword: ParsedMcpSearchArgs = {
|
||||
keyword: "",
|
||||
scope: "project",
|
||||
limit: 20,
|
||||
semantic: false,
|
||||
error: "Keyword required. Usage: /mcp smithery-search <keyword> [--scope project|user] [--limit <1-100>] [--semantic]",
|
||||
};
|
||||
if (tokens.length === 0) return missingKeyword;
|
||||
|
||||
const keywordParts: string[] = [];
|
||||
let scope: AcpMcpScope = "project";
|
||||
let limit = 20;
|
||||
let semantic = false;
|
||||
|
||||
for (let index = 0; index < tokens.length; index++) {
|
||||
const token = tokens[index]!;
|
||||
if (token === "--scope") {
|
||||
const value = tokens[index + 1];
|
||||
if (!value || (value !== "project" && value !== "user")) {
|
||||
return { keyword: "", scope, limit, semantic, error: "Invalid --scope value. Use project or user." };
|
||||
}
|
||||
scope = value;
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
if (token === "--limit") {
|
||||
const value = tokens[index + 1];
|
||||
if (!value) return { keyword: "", scope, limit, semantic, error: "Missing value for --limit." };
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 100) {
|
||||
return {
|
||||
keyword: "",
|
||||
scope,
|
||||
limit,
|
||||
semantic,
|
||||
error: "Invalid --limit value. Use an integer between 1 and 100.",
|
||||
};
|
||||
}
|
||||
limit = parsed;
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
if (token === "--semantic") {
|
||||
semantic = true;
|
||||
continue;
|
||||
}
|
||||
if (token.startsWith("--")) return { keyword: "", scope, limit, semantic, error: `Unknown option: ${token}` };
|
||||
keywordParts.push(token);
|
||||
}
|
||||
|
||||
const keyword = keywordParts.join(" ").trim();
|
||||
if (!keyword) return { ...missingKeyword, scope, limit, semantic };
|
||||
return { keyword, scope, limit, semantic };
|
||||
}
|
||||
|
||||
async function withPreparedMcpConnection<T>(
|
||||
runtime: AcpBuiltinCommandRuntime,
|
||||
name: string,
|
||||
config: MCPServerConfig,
|
||||
fn: (connection: MCPServerConnection) => Promise<T>,
|
||||
): Promise<T> {
|
||||
let connection: MCPServerConnection | undefined;
|
||||
try {
|
||||
const manager = new MCPManager(runtime.cwd);
|
||||
const resolvedConfig = await manager.prepareConfig(config);
|
||||
connection = await connectToServer(name, resolvedConfig);
|
||||
return await fn(connection);
|
||||
} finally {
|
||||
if (connection) void disconnectServer(connection);
|
||||
}
|
||||
}
|
||||
|
||||
async function collectConnectedMcpLines(
|
||||
runtime: AcpBuiltinCommandRuntime,
|
||||
collect: (serverName: string, connection: MCPServerConnection) => Promise<string[]>,
|
||||
): Promise<string[] | undefined> {
|
||||
const servers = await getMcpConfiguredServers(runtime.cwd);
|
||||
if (servers.length === 0) return undefined;
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const { name, config } of servers) {
|
||||
try {
|
||||
const collected = await withPreparedMcpConnection(runtime, name, config, connection =>
|
||||
collect(name, connection),
|
||||
);
|
||||
lines.push(...collected);
|
||||
} catch {
|
||||
// unreachable server: skip silently
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
async function handleResourcesCommand(runtime: AcpBuiltinCommandRuntime) {
|
||||
const lines = await collectConnectedMcpLines(runtime, async (name, connection) => {
|
||||
const resources = await listResources(connection);
|
||||
return resources.map(resource => `${name}/${resource.uri}`);
|
||||
});
|
||||
if (!lines) {
|
||||
await runtime.output("No MCP servers configured.");
|
||||
return commandConsumed();
|
||||
}
|
||||
await runtime.output(lines.length > 0 ? lines.join("\n") : "No resources available on connected servers.");
|
||||
return commandConsumed();
|
||||
}
|
||||
|
||||
async function handlePromptsCommand(runtime: AcpBuiltinCommandRuntime) {
|
||||
const lines = await collectConnectedMcpLines(runtime, async (name, connection) => {
|
||||
const prompts = await listPrompts(connection);
|
||||
return prompts.map(prompt => `${name}/${prompt.name}${prompt.description ? ` — ${prompt.description}` : ""}`);
|
||||
});
|
||||
if (!lines) {
|
||||
await runtime.output("No MCP servers configured.");
|
||||
return commandConsumed();
|
||||
}
|
||||
await runtime.output(lines.length > 0 ? lines.join("\n") : "No prompts available on connected servers.");
|
||||
return commandConsumed();
|
||||
}
|
||||
|
||||
async function handleTestCommand(rest: string, runtime: AcpBuiltinCommandRuntime) {
|
||||
const name = rest.split(/\s+/)[0]?.trim() ?? "";
|
||||
if (!name) return usage("Usage: /mcp test <name>", runtime);
|
||||
const servers = await getMcpConfiguredServers(runtime.cwd);
|
||||
const server = servers.find(item => item.name === name);
|
||||
if (!server) return usage(`Server "${name}" not found. Run /mcp list to see configured servers.`, runtime);
|
||||
|
||||
try {
|
||||
return await withPreparedMcpConnection(runtime, name, server.config, async connection => {
|
||||
const tools = await listTools(connection);
|
||||
const lines = [`Server "${name}" connected (${tools.length} tools).`];
|
||||
for (const tool of tools) lines.push(` - ${tool.name}`);
|
||||
await runtime.output(lines.join("\n"));
|
||||
return commandConsumed();
|
||||
});
|
||||
} catch (err) {
|
||||
return usage(`Connection to "${name}" failed: ${errorMessage(err)}`, runtime);
|
||||
}
|
||||
}
|
||||
|
||||
function buildMcpServerConfig(parsed: ParsedMcpAddArgs): MCPServerConfig | undefined {
|
||||
if (parsed.commandTokens && parsed.commandTokens.length > 0) {
|
||||
const [command, ...args] = parsed.commandTokens;
|
||||
return { type: "stdio", command: command!, args: args.length > 0 ? args : undefined } as MCPServerConfig;
|
||||
}
|
||||
if (!parsed.url) return undefined;
|
||||
const normalizedUrl = /^https?:\/\//i.test(parsed.url) ? parsed.url : `https://${parsed.url}`;
|
||||
return {
|
||||
type: parsed.transport === "sse" ? "sse" : "http",
|
||||
url: normalizedUrl,
|
||||
headers: parsed.authToken ? { Authorization: `Bearer ${parsed.authToken}` } : undefined,
|
||||
} as MCPServerConfig;
|
||||
}
|
||||
|
||||
async function handleAddCommand(rest: string, runtime: AcpBuiltinCommandRuntime) {
|
||||
if (!rest) return usage(MCP_ADD_USAGE, runtime);
|
||||
const parsed = parseMcpAddArgs(rest);
|
||||
if (parsed.error) return usage(parsed.error, runtime);
|
||||
if (!parsed.name) return usage(MCP_ADD_USAGE, runtime);
|
||||
const config = buildMcpServerConfig(parsed);
|
||||
if (!config) return usage(MCP_ADD_USAGE, runtime);
|
||||
try {
|
||||
const filePath = getMCPConfigPath(parsed.scope, runtime.cwd);
|
||||
await addMCPServer(filePath, parsed.name, config);
|
||||
await runtime.output(`Added MCP server "${parsed.name}" (${parsed.scope}).`);
|
||||
return commandConsumed();
|
||||
} catch (err) {
|
||||
return usage(`Failed to add server: ${errorMessage(err)}`, runtime);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSmitherySearchCommand(rest: string, runtime: AcpBuiltinCommandRuntime) {
|
||||
const parsed = parseMcpSearchArgs(rest);
|
||||
if (parsed.error) return usage(parsed.error, runtime);
|
||||
try {
|
||||
const apiKey = await getSmitheryApiKey();
|
||||
const results = await searchSmitheryRegistry(parsed.keyword, {
|
||||
limit: parsed.limit,
|
||||
apiKey: apiKey ?? undefined,
|
||||
includeSemantic: parsed.semantic,
|
||||
});
|
||||
if (results.length === 0) {
|
||||
await runtime.output(`No Smithery results found for "${parsed.keyword}".`);
|
||||
return commandConsumed();
|
||||
}
|
||||
await runtime.output(
|
||||
results
|
||||
.map(
|
||||
result =>
|
||||
`${result.display.displayName} (${result.name})${result.display.description ? ` — ${result.display.description}` : ""}`,
|
||||
)
|
||||
.join("\n"),
|
||||
);
|
||||
return commandConsumed();
|
||||
} catch (err) {
|
||||
const message = errorMessage(err);
|
||||
if (/401|403|unauthorized|forbidden/i.test(message)) {
|
||||
return usage(
|
||||
"Smithery authentication required. Run /mcp smithery-login in the TUI client or add an API key to ~/.omp/agent/smithery.json.",
|
||||
runtime,
|
||||
);
|
||||
}
|
||||
return usage(`Smithery search failed: ${message}`, runtime);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleListCommand(runtime: AcpBuiltinCommandRuntime) {
|
||||
try {
|
||||
const userPath = getMCPConfigPath("user", runtime.cwd);
|
||||
const projectPath = getMCPConfigPath("project", runtime.cwd);
|
||||
const [userConfig, projectConfig] = await Promise.all([
|
||||
readMCPConfigFile(userPath),
|
||||
readMCPConfigFile(projectPath),
|
||||
]);
|
||||
const disabledSet = new Set(await readDisabledServers(userPath));
|
||||
const entries: Array<{ name: string; config: MCPServerConfig; scope: string }> = [];
|
||||
for (const [name, config] of Object.entries(userConfig.mcpServers ?? {})) {
|
||||
entries.push({ name, config, scope: "user" });
|
||||
}
|
||||
for (const [name, config] of Object.entries(projectConfig.mcpServers ?? {})) {
|
||||
if (!entries.some(entry => entry.name === name)) entries.push({ name, config, scope: "project" });
|
||||
}
|
||||
if (entries.length === 0) {
|
||||
await runtime.output("No MCP servers configured.");
|
||||
return commandConsumed();
|
||||
}
|
||||
await runtime.output(
|
||||
entries
|
||||
.map(({ name, config, scope }) => {
|
||||
const type = config.type ?? "stdio";
|
||||
const enabled = config.enabled !== false && !disabledSet.has(name) ? "enabled" : "disabled";
|
||||
const location =
|
||||
config.type === "http" || config.type === "sse"
|
||||
? (config as { url: string }).url
|
||||
: (config as { command: string }).command;
|
||||
return `${name} | ${type} | ${enabled} | ${location ?? "(unknown)"} [${scope}]`;
|
||||
})
|
||||
.join("\n"),
|
||||
);
|
||||
return commandConsumed();
|
||||
} catch (err) {
|
||||
return usage(`Failed to list MCP servers: ${errorMessage(err)}`, runtime);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEnableDisableCommand(verb: "enable" | "disable", rest: string, runtime: AcpBuiltinCommandRuntime) {
|
||||
const name = rest.split(/\s+/)[0] ?? "";
|
||||
if (!name) return usage(`Usage: /mcp ${verb} <name>`, runtime);
|
||||
const enabled = verb === "enable";
|
||||
try {
|
||||
const userPath = getMCPConfigPath("user", runtime.cwd);
|
||||
const projectPath = getMCPConfigPath("project", runtime.cwd);
|
||||
const [userConfig, projectConfig] = await Promise.all([
|
||||
readMCPConfigFile(userPath),
|
||||
readMCPConfigFile(projectPath),
|
||||
]);
|
||||
if (userConfig.mcpServers?.[name] !== undefined) {
|
||||
await updateMCPServer(userPath, name, { ...userConfig.mcpServers[name], enabled } as MCPServerConfig);
|
||||
await runtime.output(`Server "${name}" ${enabled ? "enabled" : "disabled"} (user config).`);
|
||||
return commandConsumed();
|
||||
}
|
||||
if (projectConfig.mcpServers?.[name] !== undefined) {
|
||||
await updateMCPServer(projectPath, name, { ...projectConfig.mcpServers[name], enabled } as MCPServerConfig);
|
||||
await runtime.output(`Server "${name}" ${enabled ? "enabled" : "disabled"} (project config).`);
|
||||
return commandConsumed();
|
||||
}
|
||||
const disabledList = await readDisabledServers(userPath);
|
||||
if (!enabled || disabledList.includes(name)) {
|
||||
await setServerDisabled(userPath, name, !enabled);
|
||||
await runtime.output(`Server "${name}" ${enabled ? "enabled" : "disabled"}.`);
|
||||
return commandConsumed();
|
||||
}
|
||||
return usage(`Server "${name}" not found in user or project config.`, runtime);
|
||||
} catch (err) {
|
||||
return usage(`Failed to ${verb} MCP server: ${errorMessage(err)}`, runtime);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveCommand(rest: string, runtime: AcpBuiltinCommandRuntime) {
|
||||
const parsed = parseNamedScopeArgs(rest, "Invalid --scope value. Use project or user.");
|
||||
if (parsed.error) return usage(parsed.error, runtime);
|
||||
if (!parsed.name) return usage("Usage: /mcp remove <name> [--scope project|user]", runtime);
|
||||
try {
|
||||
const filePath = getMCPConfigPath(parsed.scope, runtime.cwd);
|
||||
await removeMCPServer(filePath, parsed.name);
|
||||
await runtime.output(`Removed server "${parsed.name}" from ${parsed.scope} config.`);
|
||||
return commandConsumed();
|
||||
} catch (err) {
|
||||
return usage(`Failed to remove MCP server: ${errorMessage(err)}`, runtime);
|
||||
}
|
||||
}
|
||||
|
||||
function mcpHelpText(): string {
|
||||
return [
|
||||
"MCP server management (ACP mode)",
|
||||
" /mcp list List configured servers",
|
||||
" /mcp enable <name> Enable a server",
|
||||
" /mcp disable <name> Disable a server",
|
||||
" /mcp remove <name> [--scope project|user] Remove a server",
|
||||
" /mcp reload Reload MCP runtime",
|
||||
" /mcp resources List resources from all servers",
|
||||
" /mcp prompts List prompts from all servers",
|
||||
" /mcp test <name> Test connection to a server",
|
||||
" /mcp add <name> [--scope project|user] [--url <url>] Add a server (non-interactive)",
|
||||
" /mcp add <name> [-- <command...>] Add a stdio server",
|
||||
" /mcp smithery-search <kw> [--scope project|user] Search Smithery registry",
|
||||
" /mcp help Show this help",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function isTuiOnlyMcpVerb(verb: string): boolean {
|
||||
return (
|
||||
verb === "reauth" ||
|
||||
verb === "unauth" ||
|
||||
verb === "smithery-login" ||
|
||||
verb === "smithery-logout" ||
|
||||
verb === "reconnect"
|
||||
);
|
||||
}
|
||||
|
||||
export const mcpCommand: AcpBuiltinCommandSpec = {
|
||||
name: "mcp",
|
||||
description: "Manage MCP servers",
|
||||
inputHint: "<subcommand>",
|
||||
handle: async (command, runtime) => {
|
||||
const { verb, rest } = parseSubcommand(command.args);
|
||||
if (!verb || verb === "help") {
|
||||
await runtime.output(mcpHelpText());
|
||||
return commandConsumed();
|
||||
}
|
||||
if (verb === "notifications") {
|
||||
return usage(
|
||||
"MCP notifications require the TUI client (live MCPManager). Use /mcp list to see server status.",
|
||||
runtime,
|
||||
);
|
||||
}
|
||||
if (isTuiOnlyMcpVerb(verb)) {
|
||||
return usage(`/mcp ${verb} requires OAuth or browser flows only available in the TUI client.`, runtime);
|
||||
}
|
||||
switch (verb) {
|
||||
case "resources":
|
||||
return await handleResourcesCommand(runtime);
|
||||
case "prompts":
|
||||
return await handlePromptsCommand(runtime);
|
||||
case "test":
|
||||
return await handleTestCommand(rest, runtime);
|
||||
case "add":
|
||||
return await handleAddCommand(rest, runtime);
|
||||
case "smithery-search":
|
||||
return await handleSmitherySearchCommand(rest, runtime);
|
||||
case "reload":
|
||||
await runtime.refreshCommands();
|
||||
await runtime.output("MCP runtime reload requested.");
|
||||
return commandConsumed();
|
||||
case "list":
|
||||
return await handleListCommand(runtime);
|
||||
case "enable":
|
||||
case "disable":
|
||||
return await handleEnableDisableCommand(verb, rest, runtime);
|
||||
case "remove":
|
||||
case "rm":
|
||||
return await handleRemoveCommand(rest, runtime);
|
||||
default:
|
||||
return usage(`Unknown /mcp subcommand: ${verb}. Use /mcp help for available subcommands.`, runtime);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { resolveMemoryBackend } from "../../memory-backend";
|
||||
import { commandConsumed, usage } from "./shared";
|
||||
import type { AcpBuiltinCommandSpec } from "./types";
|
||||
|
||||
export const memoryCommand: AcpBuiltinCommandSpec = {
|
||||
name: "memory",
|
||||
description: "Manage memory",
|
||||
inputHint: "<subcommand>",
|
||||
handle: async (command, runtime) => {
|
||||
const verb = (command.args.trim().split(/\s+/)[0] ?? "").toLowerCase() || "view";
|
||||
const backend = resolveMemoryBackend(runtime.settings);
|
||||
switch (verb) {
|
||||
case "view": {
|
||||
const payload = await backend.buildDeveloperInstructions(
|
||||
runtime.settings.getAgentDir(),
|
||||
runtime.settings,
|
||||
runtime.session,
|
||||
);
|
||||
await runtime.output(payload || "Memory payload is empty.");
|
||||
return commandConsumed();
|
||||
}
|
||||
case "clear":
|
||||
case "reset": {
|
||||
await backend.clear(runtime.settings.getAgentDir(), runtime.cwd, runtime.session);
|
||||
await runtime.session.refreshBaseSystemPrompt();
|
||||
await runtime.output("Memory cleared.");
|
||||
return commandConsumed();
|
||||
}
|
||||
case "enqueue":
|
||||
case "rebuild": {
|
||||
await backend.enqueue(runtime.settings.getAgentDir(), runtime.cwd, runtime.session);
|
||||
await runtime.output("Memory consolidation enqueued.");
|
||||
return commandConsumed();
|
||||
}
|
||||
case "mm":
|
||||
return usage(
|
||||
"Mental-model maintenance via /memory mm is unsupported in ACP mode; use the hindsight HTTP API directly.",
|
||||
runtime,
|
||||
);
|
||||
default:
|
||||
return usage("Usage: /memory <view|clear|reset|enqueue|rebuild>", runtime);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { commandConsumed, errorMessage, usage } from "./shared";
|
||||
import type { AcpBuiltinCommandSpec } from "./types";
|
||||
|
||||
export const modelCommand: AcpBuiltinCommandSpec = {
|
||||
name: "model",
|
||||
description: "Show current model selection",
|
||||
aliases: ["models"],
|
||||
handle: async (command, runtime) => {
|
||||
if (command.args) {
|
||||
const modelId = command.args.trim();
|
||||
const availableModels = runtime.session.getAvailableModels?.() ?? [];
|
||||
const match = availableModels.find(
|
||||
model => model.id === modelId || `${model.provider}/${model.id}` === modelId,
|
||||
);
|
||||
if (!match) {
|
||||
return usage(
|
||||
`Unknown model: ${modelId}. Use ACP \`session/setModel\` for picker-driven selection or list available models with /model.`,
|
||||
runtime,
|
||||
);
|
||||
}
|
||||
try {
|
||||
await runtime.session.setModel(match);
|
||||
await runtime.output(`Model set to ${match.provider}/${match.id}.`);
|
||||
await runtime.notifyTitleChanged?.();
|
||||
return commandConsumed();
|
||||
} catch (err) {
|
||||
return usage(`Failed to set model: ${errorMessage(err)}`, runtime);
|
||||
}
|
||||
}
|
||||
|
||||
const model = runtime.session.model;
|
||||
await runtime.output(model ? `Current model: ${model.provider}/${model.id}` : "No model is currently selected.");
|
||||
return commandConsumed();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { setProjectDir } from "@oh-my-pi/pi-utils";
|
||||
import { commandConsumed, usage } from "./shared";
|
||||
import type { AcpBuiltinCommandSpec } from "./types";
|
||||
|
||||
export const moveCommand: AcpBuiltinCommandSpec = {
|
||||
name: "move",
|
||||
description: "Move the current session file",
|
||||
inputHint: "<path>",
|
||||
handle: async (command, runtime) => {
|
||||
if (runtime.session.isStreaming) return usage("Cannot move while streaming.", runtime);
|
||||
if (!command.args) return usage("Usage: /move <path>", runtime);
|
||||
const resolvedPath = path.resolve(runtime.cwd, command.args);
|
||||
let isDirectory: boolean;
|
||||
try {
|
||||
isDirectory = (await fs.stat(resolvedPath)).isDirectory();
|
||||
} catch {
|
||||
return usage(`Directory does not exist or is not a directory: ${resolvedPath}`, runtime);
|
||||
}
|
||||
if (!isDirectory) return usage(`Directory does not exist or is not a directory: ${resolvedPath}`, runtime);
|
||||
await runtime.sessionManager.flush();
|
||||
await runtime.sessionManager.moveTo(resolvedPath);
|
||||
setProjectDir(resolvedPath);
|
||||
await runtime.notifyTitleChanged?.();
|
||||
await runtime.output(`Session moved to ${runtime.sessionManager.getCwd()}.`);
|
||||
return commandConsumed();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { PluginManager } from "../../extensibility/plugins";
|
||||
import { parsePluginScopeArgs } from "../marketplace-install-parser";
|
||||
import { createMarketplaceManager } from "./marketplace-manager";
|
||||
import { commandConsumed, errorMessage, parseSubcommand, usage } from "./shared";
|
||||
import type { AcpBuiltinCommandRuntime, AcpBuiltinCommandSpec, AcpBuiltinSlashCommandResult } from "./types";
|
||||
|
||||
async function handleEnableDisableCommand(
|
||||
sub: "enable" | "disable",
|
||||
rest: string,
|
||||
runtime: AcpBuiltinCommandRuntime,
|
||||
): Promise<AcpBuiltinSlashCommandResult> {
|
||||
const parsed = parsePluginScopeArgs(rest, `Usage: /plugins ${sub} [--scope user|project] <name@marketplace>`);
|
||||
if ("error" in parsed) return usage(parsed.error, runtime);
|
||||
const manager = await createMarketplaceManager(runtime);
|
||||
const isEnable = sub === "enable";
|
||||
await manager.setPluginEnabled(parsed.pluginId, isEnable, parsed.scope);
|
||||
await runtime.output(`${isEnable ? "Enabled" : "Disabled"} ${parsed.pluginId}`);
|
||||
return commandConsumed();
|
||||
}
|
||||
|
||||
async function handleListCommand(runtime: AcpBuiltinCommandRuntime): Promise<AcpBuiltinSlashCommandResult> {
|
||||
const lines: string[] = [];
|
||||
const npmManager = new PluginManager();
|
||||
const npmPlugins = await npmManager.list();
|
||||
if (npmPlugins.length > 0) {
|
||||
lines.push("npm plugins:");
|
||||
for (const plugin of npmPlugins) {
|
||||
const status = plugin.enabled === false ? " (disabled)" : "";
|
||||
lines.push(` ${plugin.name}@${plugin.version}${status}`);
|
||||
}
|
||||
}
|
||||
|
||||
const marketplaceManager = await createMarketplaceManager(runtime);
|
||||
const marketplacePlugins = await marketplaceManager.listInstalledPlugins();
|
||||
if (marketplacePlugins.length > 0) {
|
||||
if (lines.length > 0) lines.push("");
|
||||
lines.push("marketplace plugins:");
|
||||
for (const plugin of marketplacePlugins) {
|
||||
const entry = plugin.entries[0];
|
||||
const status = entry?.enabled === false ? " (disabled)" : "";
|
||||
const shadowed = plugin.shadowedBy ? " [shadowed]" : "";
|
||||
lines.push(` ${plugin.id} v${entry?.version ?? "?"}${status} [${plugin.scope}]${shadowed}`);
|
||||
}
|
||||
}
|
||||
|
||||
await runtime.output(lines.length === 0 ? "No plugins installed" : lines.join("\n"));
|
||||
return commandConsumed();
|
||||
}
|
||||
|
||||
export const pluginsCommand: AcpBuiltinCommandSpec = {
|
||||
name: "plugins",
|
||||
description: "Manage plugins",
|
||||
inputHint: "[list|enable|disable]",
|
||||
handle: async (command, runtime) => {
|
||||
const { verb, rest } = parseSubcommand(command.args);
|
||||
try {
|
||||
if (verb === "enable" || verb === "disable") return await handleEnableDisableCommand(verb, rest, runtime);
|
||||
return await handleListCommand(runtime);
|
||||
} catch (err) {
|
||||
return usage(`Plugin error: ${errorMessage(err)}`, runtime);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { commandConsumed } from "./shared";
|
||||
import type { AcpBuiltinCommandSpec } from "./types";
|
||||
|
||||
export const reloadPluginsCommand: AcpBuiltinCommandSpec = {
|
||||
name: "reload-plugins",
|
||||
description: "Reload all plugins",
|
||||
handle: async (_command, runtime) => {
|
||||
await runtime.refreshCommands();
|
||||
await runtime.output("Plugins reloaded.");
|
||||
return commandConsumed();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { commandConsumed, usage } from "./shared";
|
||||
import type { AcpBuiltinCommandSpec } from "./types";
|
||||
|
||||
export const renameCommand: AcpBuiltinCommandSpec = {
|
||||
name: "rename",
|
||||
description: "Rename the current session",
|
||||
inputHint: "<title>",
|
||||
handle: async (command, runtime) => {
|
||||
if (!command.args) return usage("Usage: /rename <title>", runtime);
|
||||
const ok = await runtime.sessionManager.setSessionName(command.args, "user");
|
||||
if (!ok) {
|
||||
await runtime.output("Session name not changed (a user-set name takes precedence).");
|
||||
return commandConsumed();
|
||||
}
|
||||
await runtime.notifyTitleChanged?.();
|
||||
await runtime.output(`Session renamed to ${command.args}.`);
|
||||
return commandConsumed();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { FileSessionStorage } from "../../session/session-storage";
|
||||
import { commandConsumed, usage } from "./shared";
|
||||
import type { AcpBuiltinCommandSpec } from "./types";
|
||||
|
||||
export const sessionCommand: AcpBuiltinCommandSpec = {
|
||||
name: "session",
|
||||
description: "Show session information",
|
||||
inputHint: "info|delete",
|
||||
handle: async (command, runtime) => {
|
||||
if (!command.args || command.args === "info") {
|
||||
await runtime.output(
|
||||
[
|
||||
`Session: ${runtime.session.sessionId}`,
|
||||
`Title: ${runtime.session.sessionName}`,
|
||||
`CWD: ${runtime.cwd}`,
|
||||
].join("\n"),
|
||||
);
|
||||
return commandConsumed();
|
||||
}
|
||||
if (command.args === "delete") {
|
||||
if (runtime.session.isStreaming) return usage("Cannot delete the session while streaming.", runtime);
|
||||
const sessionFile = runtime.sessionManager.getSessionFile();
|
||||
if (!sessionFile) return usage("No session file to delete (in-memory session).", runtime);
|
||||
const storage = new FileSessionStorage();
|
||||
const exists = await storage.exists(sessionFile);
|
||||
if (!exists) {
|
||||
await runtime.output("Session has not been saved yet.");
|
||||
return commandConsumed();
|
||||
}
|
||||
await storage.deleteSessionWithArtifacts(sessionFile);
|
||||
await runtime.output(
|
||||
`Session deleted: ${sessionFile}. Use ACP \`session/load\` to switch to another session.`,
|
||||
);
|
||||
return commandConsumed();
|
||||
}
|
||||
return usage("Usage: /session [info|delete]", runtime);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { Snowflake } from "@oh-my-pi/pi-utils";
|
||||
import { $ } from "bun";
|
||||
import { commandConsumed, errorMessage, usage } from "./shared";
|
||||
import type { AcpBuiltinCommandSpec } from "./types";
|
||||
|
||||
export const shareCommand: AcpBuiltinCommandSpec = {
|
||||
name: "share",
|
||||
description: "Share session as a secret GitHub gist",
|
||||
handle: async (_command, runtime) => {
|
||||
const tmpFile = path.join(os.tmpdir(), `${Snowflake.next()}.html`);
|
||||
try {
|
||||
try {
|
||||
await runtime.session.exportToHtml(tmpFile);
|
||||
} catch (err) {
|
||||
return usage(`Failed to export session: ${errorMessage(err)}`, runtime);
|
||||
}
|
||||
const result = await $`gh gist create --public=false ${tmpFile}`.quiet().nothrow();
|
||||
if (result.exitCode !== 0) {
|
||||
return usage(
|
||||
`Failed to create gist: ${result.stderr.toString("utf-8").trim() || "unknown error"}`,
|
||||
runtime,
|
||||
);
|
||||
}
|
||||
const gistUrl = result.stdout.toString("utf-8").trim();
|
||||
const gistId = gistUrl.split("/").pop();
|
||||
if (!gistId) return usage("Failed to parse gist ID from gh output", runtime);
|
||||
await runtime.output(`Share URL: https://gistpreview.github.io/?${gistId}\nGist: ${gistUrl}`);
|
||||
return commandConsumed();
|
||||
} catch {
|
||||
return usage("GitHub CLI (gh) is required for /share. Install it from https://cli.github.com/.", runtime);
|
||||
} finally {
|
||||
await fs.rm(tmpFile, { force: true }).catch(() => {});
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { AcpBuiltinCommandRuntime, AcpBuiltinSlashCommandResult } from "./types";
|
||||
|
||||
export interface ParsedSubcommand {
|
||||
verb: string;
|
||||
rest: string;
|
||||
}
|
||||
|
||||
export type ConfigScope = "user" | "project";
|
||||
|
||||
export interface NamedScopeArgs {
|
||||
name?: string;
|
||||
scope: ConfigScope;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function commandConsumed(): AcpBuiltinSlashCommandResult {
|
||||
return { consumed: true };
|
||||
}
|
||||
|
||||
export function usage(text: string, runtime: AcpBuiltinCommandRuntime): AcpBuiltinSlashCommandResult {
|
||||
void runtime.output(text);
|
||||
return commandConsumed();
|
||||
}
|
||||
|
||||
export function parseSubcommand(input: string): ParsedSubcommand {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return { verb: "", rest: "" };
|
||||
const spaceIdx = trimmed.search(/\s/);
|
||||
if (spaceIdx === -1) return { verb: trimmed.toLowerCase(), rest: "" };
|
||||
return { verb: trimmed.slice(0, spaceIdx).toLowerCase(), rest: trimmed.slice(spaceIdx + 1).trim() };
|
||||
}
|
||||
|
||||
export function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export function parseNamedScopeArgs(rest: string, invalidScopeMessage: string): NamedScopeArgs {
|
||||
const tokens = rest.split(/\s+/).filter(Boolean);
|
||||
let name: string | undefined;
|
||||
let scope: ConfigScope = "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") return { scope, error: `Unknown option: ${token}` };
|
||||
const value = tokens[i + 1];
|
||||
if (!value || (value !== "project" && value !== "user")) return { scope, error: invalidScopeMessage };
|
||||
scope = value;
|
||||
i += 2;
|
||||
}
|
||||
return { name, scope };
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { getSSHConfigPath } from "@oh-my-pi/pi-utils";
|
||||
import { addSSHHost, readSSHConfigFile, removeSSHHost, type SSHHostConfig } from "../../ssh/config-writer";
|
||||
import { parseCommandArgs } from "../../utils/command-args";
|
||||
import { commandConsumed, parseNamedScopeArgs, parseSubcommand, usage } from "./shared";
|
||||
import type { AcpBuiltinCommandRuntime, AcpBuiltinCommandSpec } from "./types";
|
||||
|
||||
interface ParsedSshAddArgs {
|
||||
name?: string;
|
||||
scope: "user" | "project";
|
||||
host?: string;
|
||||
username?: string;
|
||||
port?: number;
|
||||
keyPath?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
type SshAddOptionParser = (parsed: ParsedSshAddArgs, value: string | undefined) => string | undefined;
|
||||
|
||||
const SSH_ADD_USAGE =
|
||||
"Usage: /ssh add <name> --host <host> [--user <user>] [--port <port>] [--key <keyPath>] [--scope project|user]";
|
||||
|
||||
const SSH_ADD_OPTION_PARSERS = new Map<string, SshAddOptionParser>([
|
||||
[
|
||||
"--host",
|
||||
(parsed, value) => {
|
||||
if (!value) return "Missing value for --host.";
|
||||
parsed.host = value;
|
||||
return undefined;
|
||||
},
|
||||
],
|
||||
[
|
||||
"--user",
|
||||
(parsed, value) => {
|
||||
if (!value) return "Missing value for --user.";
|
||||
parsed.username = value;
|
||||
return undefined;
|
||||
},
|
||||
],
|
||||
[
|
||||
"--port",
|
||||
(parsed, value) => {
|
||||
if (!value) return "Missing value for --port.";
|
||||
const port = Number.parseInt(value, 10);
|
||||
if (Number.isNaN(port) || port < 1 || port > 65535) {
|
||||
return "Invalid --port value. Must be an integer between 1 and 65535.";
|
||||
}
|
||||
parsed.port = port;
|
||||
return undefined;
|
||||
},
|
||||
],
|
||||
[
|
||||
"--key",
|
||||
(parsed, value) => {
|
||||
if (!value) return "Missing value for --key.";
|
||||
parsed.keyPath = value;
|
||||
return undefined;
|
||||
},
|
||||
],
|
||||
[
|
||||
"--scope",
|
||||
(parsed, value) => {
|
||||
if (!value || (value !== "project" && value !== "user")) return "Invalid --scope value. Use project or user.";
|
||||
parsed.scope = value;
|
||||
return undefined;
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
function sshAddArgError(parsed: ParsedSshAddArgs, error: string): ParsedSshAddArgs {
|
||||
return { ...parsed, error };
|
||||
}
|
||||
|
||||
function parseSshAddArgs(rest: string): ParsedSshAddArgs {
|
||||
const tokens = parseCommandArgs(rest);
|
||||
const parsed: ParsedSshAddArgs = { scope: "project" };
|
||||
let index = 0;
|
||||
if (tokens.length > 0 && !tokens[0]!.startsWith("-")) {
|
||||
parsed.name = tokens[0];
|
||||
index = 1;
|
||||
}
|
||||
while (index < tokens.length) {
|
||||
const arg = tokens[index]!;
|
||||
const parser = SSH_ADD_OPTION_PARSERS.get(arg);
|
||||
if (!parser) return sshAddArgError(parsed, `Unknown option: ${arg}`);
|
||||
const error = parser(parsed, tokens[index + 1]);
|
||||
if (error) return sshAddArgError(parsed, error);
|
||||
index += 2;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function sshHelpText(): string {
|
||||
return [
|
||||
"SSH host management (ACP mode)",
|
||||
" /ssh add <name> --host <host> [--user <user>] [--port <port>] [--key <keyPath>] [--scope project|user]",
|
||||
" /ssh list List configured SSH hosts",
|
||||
" /ssh remove <name> [--scope project|user] Remove an SSH host",
|
||||
" /ssh help Show this help",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function handleListCommand(runtime: AcpBuiltinCommandRuntime) {
|
||||
try {
|
||||
const userPath = getSSHConfigPath("user", runtime.cwd);
|
||||
const projectPath = getSSHConfigPath("project", runtime.cwd);
|
||||
const [userConfig, projectConfig] = await Promise.all([
|
||||
readSSHConfigFile(userPath),
|
||||
readSSHConfigFile(projectPath),
|
||||
]);
|
||||
const entries: Array<{ name: string; host: string; user?: string; port?: number; scope: string }> = [];
|
||||
for (const [name, config] of Object.entries(userConfig.hosts ?? {})) {
|
||||
entries.push({ name, host: config.host, user: config.username, port: config.port, scope: "user" });
|
||||
}
|
||||
for (const [name, config] of Object.entries(projectConfig.hosts ?? {})) {
|
||||
if (!entries.some(entry => entry.name === name)) {
|
||||
entries.push({ name, host: config.host, user: config.username, port: config.port, scope: "project" });
|
||||
}
|
||||
}
|
||||
if (entries.length === 0) {
|
||||
await runtime.output("No SSH hosts configured.");
|
||||
return commandConsumed();
|
||||
}
|
||||
await runtime.output(
|
||||
entries
|
||||
.map(entry => `${entry.name} | ${entry.host} | ${entry.user ?? "-"} | ${entry.port ?? 22} [${entry.scope}]`)
|
||||
.join("\n"),
|
||||
);
|
||||
return commandConsumed();
|
||||
} catch (err) {
|
||||
return usage(`Failed to list SSH hosts: ${err instanceof Error ? err.message : String(err)}`, runtime);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveCommand(rest: string, runtime: AcpBuiltinCommandRuntime) {
|
||||
const parsed = parseNamedScopeArgs(rest, "Invalid --scope value. Use project or user.");
|
||||
if (parsed.error) return usage(parsed.error, runtime);
|
||||
if (!parsed.name) return usage("Usage: /ssh remove <name> [--scope project|user]", runtime);
|
||||
try {
|
||||
const filePath = getSSHConfigPath(parsed.scope, runtime.cwd);
|
||||
await removeSSHHost(filePath, parsed.name);
|
||||
await runtime.output(`Removed SSH host "${parsed.name}" from ${parsed.scope} config.`);
|
||||
return commandConsumed();
|
||||
} catch (err) {
|
||||
return usage(`Failed to remove SSH host: ${err instanceof Error ? err.message : String(err)}`, runtime);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddCommand(rest: string, runtime: AcpBuiltinCommandRuntime) {
|
||||
if (!rest) return usage(SSH_ADD_USAGE, runtime);
|
||||
const parsed = parseSshAddArgs(rest);
|
||||
if (parsed.error) return usage(parsed.error, runtime);
|
||||
if (!parsed.name) return usage("Host name required. Usage: /ssh add <name> --host <host> ...", runtime);
|
||||
if (!parsed.host) return usage("--host is required. Usage: /ssh add <name> --host <host> ...", runtime);
|
||||
const hostConfig: SSHHostConfig = { host: parsed.host };
|
||||
if (parsed.username) hostConfig.username = parsed.username;
|
||||
if (parsed.port) hostConfig.port = parsed.port;
|
||||
if (parsed.keyPath) hostConfig.keyPath = parsed.keyPath;
|
||||
try {
|
||||
const filePath = getSSHConfigPath(parsed.scope, runtime.cwd);
|
||||
await addSSHHost(filePath, parsed.name, hostConfig);
|
||||
await runtime.output(`Added SSH host "${parsed.name}" (${parsed.scope}).`);
|
||||
return commandConsumed();
|
||||
} catch (err) {
|
||||
return usage(`Failed to add SSH host: ${err instanceof Error ? err.message : String(err)}`, runtime);
|
||||
}
|
||||
}
|
||||
|
||||
export const sshCommand: AcpBuiltinCommandSpec = {
|
||||
name: "ssh",
|
||||
description: "Manage SSH connections",
|
||||
inputHint: "<subcommand>",
|
||||
handle: async (command, runtime) => {
|
||||
const { verb, rest } = parseSubcommand(command.args);
|
||||
if (!verb || verb === "help") {
|
||||
await runtime.output(sshHelpText());
|
||||
return commandConsumed();
|
||||
}
|
||||
switch (verb) {
|
||||
case "list":
|
||||
return await handleListCommand(runtime);
|
||||
case "remove":
|
||||
case "rm":
|
||||
return await handleRemoveCommand(rest, runtime);
|
||||
case "add":
|
||||
return await handleAddCommand(rest, runtime);
|
||||
default:
|
||||
return usage(`Unknown /ssh subcommand: ${verb}. Use /ssh help for available subcommands.`, runtime);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { TodoPhase } from "../../tools/todo-write";
|
||||
import { getLatestTodoPhasesFromEntries, USER_TODO_EDIT_CUSTOM_TYPE } from "../../tools/todo-write";
|
||||
import type { AcpBuiltinCommandRuntime } from "./types";
|
||||
|
||||
export interface TodoTaskMatch {
|
||||
task: { content: string; status: string };
|
||||
phase: TodoPhase;
|
||||
}
|
||||
|
||||
export function tokenize(input: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let current = "";
|
||||
let inQuote = false;
|
||||
for (let index = 0; index < input.length; index++) {
|
||||
const ch = input[index];
|
||||
if (ch === "\\" && index + 1 < input.length) {
|
||||
current += input[++index];
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inQuote = !inQuote;
|
||||
continue;
|
||||
}
|
||||
if (!inQuote && /\s/.test(ch)) {
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
current = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
if (current) tokens.push(current);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export function titleCase(text: string): string {
|
||||
return text
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map(word => word[0].toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function titleCaseSentence(text: string): string {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
return trimmed[0].toUpperCase() + trimmed.slice(1);
|
||||
}
|
||||
|
||||
export function findPhaseFuzzy(phases: TodoPhase[], query: string): TodoPhase | undefined {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
if (!normalizedQuery) return undefined;
|
||||
const exact = phases.find(phase => phase.name.toLowerCase() === normalizedQuery);
|
||||
if (exact) return exact;
|
||||
const prefixMatches = phases.filter(phase => phase.name.toLowerCase().startsWith(normalizedQuery));
|
||||
if (prefixMatches.length === 1) return prefixMatches[0];
|
||||
const substringMatches = phases.filter(phase => phase.name.toLowerCase().includes(normalizedQuery));
|
||||
if (substringMatches.length === 1) return substringMatches[0];
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function findTaskFuzzy(phases: TodoPhase[], query: string): TodoTaskMatch | undefined {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
if (!normalizedQuery) return undefined;
|
||||
for (const phase of phases) {
|
||||
for (const task of phase.tasks) {
|
||||
if (task.content.toLowerCase() === normalizedQuery) return { task, phase };
|
||||
}
|
||||
}
|
||||
const matches: TodoTaskMatch[] = [];
|
||||
for (const phase of phases) {
|
||||
for (const task of phase.tasks) {
|
||||
if (task.content.toLowerCase().includes(normalizedQuery)) matches.push({ task, phase });
|
||||
}
|
||||
}
|
||||
if (matches.length === 1) return matches[0];
|
||||
const active = matches.filter(match => match.task.status === "in_progress" || match.task.status === "pending");
|
||||
if (active.length === 1) return active[0];
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function currentPhases(runtime: AcpBuiltinCommandRuntime): TodoPhase[] {
|
||||
const fromEntries = getLatestTodoPhasesFromEntries(runtime.sessionManager.getBranch());
|
||||
return fromEntries.length > 0 ? fromEntries : runtime.session.getTodoPhases();
|
||||
}
|
||||
|
||||
export function commitTodos(runtime: AcpBuiltinCommandRuntime, phases: TodoPhase[]): void {
|
||||
runtime.session.setTodoPhases(phases);
|
||||
runtime.sessionManager.appendCustomEntry(USER_TODO_EDIT_CUSTOM_TYPE, { phases });
|
||||
}
|
||||
|
||||
export function copyPhases(phases: TodoPhase[]): TodoPhase[] {
|
||||
return phases.map(phase => ({ ...phase, tasks: phase.tasks.slice() }));
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import * as path from "node:path";
|
||||
import type { TodoPhase } from "../../tools/todo-write";
|
||||
import { applyOpsToPhases, markdownToPhases, phasesToMarkdown } from "../../tools/todo-write";
|
||||
import { commandConsumed, parseSubcommand, usage } from "./shared";
|
||||
import {
|
||||
commitTodos,
|
||||
copyPhases,
|
||||
currentPhases,
|
||||
findPhaseFuzzy,
|
||||
findTaskFuzzy,
|
||||
titleCase,
|
||||
titleCaseSentence,
|
||||
tokenize,
|
||||
} from "./todo-state";
|
||||
import type { AcpBuiltinCommandRuntime, AcpBuiltinCommandSpec } from "./types";
|
||||
|
||||
type TodoMutationVerb = "done" | "drop" | "rm";
|
||||
|
||||
function todoHelpText(): string {
|
||||
return [
|
||||
"Usage: /todo <verb> [args]",
|
||||
" /todo Show current todos",
|
||||
" /todo edit (TUI only) open in $EDITOR",
|
||||
" /todo copy Print todos as Markdown",
|
||||
" /todo export [<path>] Write todos to file (default: TODO.md)",
|
||||
" /todo import [<path>] Replace todos from file (default: TODO.md)",
|
||||
" /todo append [<phase>] <task...> Append a task",
|
||||
" /todo start <task> Mark task in_progress (fuzzy match)",
|
||||
" /todo done [<task|phase>] Mark task/phase/all completed",
|
||||
" /todo drop [<task|phase>] Mark task/phase/all abandoned",
|
||||
" /todo rm [<task|phase>] Remove task/phase/all",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function handleTodoCopyCommand(runtime: AcpBuiltinCommandRuntime) {
|
||||
const phases = currentPhases(runtime);
|
||||
const markdown = phases.length === 0 ? "" : phasesToMarkdown(phases).trimEnd();
|
||||
await runtime.output(`Copy not available in ACP mode; printing instead:\n\n${markdown || "No todos."}`);
|
||||
return commandConsumed();
|
||||
}
|
||||
|
||||
async function handleTodoExportCommand(restArgs: string, runtime: AcpBuiltinCommandRuntime) {
|
||||
const phases = currentPhases(runtime);
|
||||
if (phases.length === 0) {
|
||||
await runtime.output("No todos to export.");
|
||||
return commandConsumed();
|
||||
}
|
||||
const target = restArgs ? path.resolve(runtime.cwd, restArgs) : path.resolve(runtime.cwd, "TODO.md");
|
||||
await Bun.write(target, phasesToMarkdown(phases));
|
||||
await runtime.output(`Wrote todos to ${target}`);
|
||||
return commandConsumed();
|
||||
}
|
||||
|
||||
async function handleTodoImportCommand(restArgs: string, runtime: AcpBuiltinCommandRuntime) {
|
||||
const target = restArgs ? path.resolve(runtime.cwd, restArgs) : path.resolve(runtime.cwd, "TODO.md");
|
||||
let content: string;
|
||||
try {
|
||||
content = await Bun.file(target).text();
|
||||
} catch (err) {
|
||||
return usage(`Failed to read ${target}: ${err instanceof Error ? err.message : String(err)}`, runtime);
|
||||
}
|
||||
const { phases, errors } = markdownToPhases(content);
|
||||
if (errors.length > 0) return usage(`Could not parse ${target}:\n ${errors.join("\n ")}`, runtime);
|
||||
commitTodos(runtime, phases);
|
||||
const taskCount = phases.reduce((sum, phase) => sum + phase.tasks.length, 0);
|
||||
await runtime.output(`Imported ${phases.length} phase(s), ${taskCount} task(s) from ${target}.`);
|
||||
return commandConsumed();
|
||||
}
|
||||
|
||||
async function handleTodoAppendCommand(restArgs: string, runtime: AcpBuiltinCommandRuntime) {
|
||||
const tokens = tokenize(restArgs);
|
||||
if (tokens.length === 0) return usage("Usage: /todo append [<phase>] <task...>", runtime);
|
||||
|
||||
const current = currentPhases(runtime);
|
||||
const phaseName = tokens.length === 1 ? undefined : tokens[0];
|
||||
const content = tokens.length === 1 ? tokens[0]! : tokens.slice(1).join(" ");
|
||||
const next = copyPhases(current);
|
||||
let targetPhase: TodoPhase;
|
||||
|
||||
if (phaseName) {
|
||||
const existing = findPhaseFuzzy(next, phaseName);
|
||||
targetPhase = existing ?? { name: titleCase(phaseName), tasks: [] };
|
||||
if (!existing) next.push(targetPhase);
|
||||
} else if (next.length > 0) {
|
||||
targetPhase = next[next.length - 1]!;
|
||||
} else {
|
||||
targetPhase = { name: "Todos", tasks: [] };
|
||||
next.push(targetPhase);
|
||||
}
|
||||
|
||||
const finalContent = titleCaseSentence(content);
|
||||
targetPhase.tasks.push({ content: finalContent, status: "pending" });
|
||||
commitTodos(runtime, next);
|
||||
await runtime.output(`Appended to ${targetPhase.name}: ${finalContent}`);
|
||||
return commandConsumed();
|
||||
}
|
||||
|
||||
async function handleTodoStartCommand(restArgs: string, runtime: AcpBuiltinCommandRuntime) {
|
||||
if (!restArgs) return usage("Usage: /todo start <task>", runtime);
|
||||
const current = currentPhases(runtime);
|
||||
const query = tokenize(restArgs).join(" ") || restArgs;
|
||||
const hit = findTaskFuzzy(current, query);
|
||||
if (!hit) return usage(`No task matched "${restArgs}". Use /todo to list current tasks.`, runtime);
|
||||
const { phases } = applyOpsToPhases(current, [{ op: "start", task: hit.task.content }]);
|
||||
commitTodos(runtime, phases);
|
||||
await runtime.output(`Started: ${hit.task.content}`);
|
||||
return commandConsumed();
|
||||
}
|
||||
|
||||
async function handleTodoMutationCommand(verb: TodoMutationVerb, restArgs: string, runtime: AcpBuiltinCommandRuntime) {
|
||||
const current = currentPhases(runtime);
|
||||
const trimmedArg = restArgs.trim();
|
||||
if (!trimmedArg) {
|
||||
if (verb === "rm") {
|
||||
commitTodos(runtime, []);
|
||||
await runtime.output("Cleared all todos.");
|
||||
return commandConsumed();
|
||||
}
|
||||
const { phases } = applyOpsToPhases(current, [{ op: verb }]);
|
||||
commitTodos(runtime, phases);
|
||||
await runtime.output(verb === "done" ? "Marked all tasks completed." : "Marked all tasks abandoned.");
|
||||
return commandConsumed();
|
||||
}
|
||||
|
||||
const taskHit = findTaskFuzzy(current, trimmedArg);
|
||||
if (taskHit) {
|
||||
const { phases } = applyOpsToPhases(current, [{ op: verb, task: taskHit.task.content }]);
|
||||
commitTodos(runtime, phases);
|
||||
const label = verb === "done" ? "Marked completed" : verb === "drop" ? "Marked abandoned" : "Removed";
|
||||
await runtime.output(`${label}: ${taskHit.task.content}`);
|
||||
return commandConsumed();
|
||||
}
|
||||
|
||||
const phaseHit = findPhaseFuzzy(current, trimmedArg);
|
||||
if (phaseHit) {
|
||||
const { phases } = applyOpsToPhases(current, [{ op: verb, phase: phaseHit.name }]);
|
||||
commitTodos(runtime, phases);
|
||||
const message =
|
||||
verb === "done"
|
||||
? `Marked phase ${phaseHit.name} completed.`
|
||||
: verb === "drop"
|
||||
? `Marked phase ${phaseHit.name} abandoned.`
|
||||
: `Removed phase: ${phaseHit.name}`;
|
||||
await runtime.output(message);
|
||||
return commandConsumed();
|
||||
}
|
||||
|
||||
return usage(`No task or phase matched "${trimmedArg}".`, runtime);
|
||||
}
|
||||
|
||||
export const todoCommand: AcpBuiltinCommandSpec = {
|
||||
name: "todo",
|
||||
description: "Manage todos",
|
||||
inputHint: "<subcommand>",
|
||||
handle: async (command, runtime) => {
|
||||
const trimmed = command.args.trim();
|
||||
if (!trimmed) {
|
||||
const phases = currentPhases(runtime);
|
||||
await runtime.output(
|
||||
phases.length === 0
|
||||
? "No todos. Use /todo append <task> to start one."
|
||||
: phasesToMarkdown(phases).trimEnd(),
|
||||
);
|
||||
return commandConsumed();
|
||||
}
|
||||
|
||||
const { verb, rest } = parseSubcommand(trimmed);
|
||||
switch (verb) {
|
||||
case "copy":
|
||||
return await handleTodoCopyCommand(runtime);
|
||||
case "export":
|
||||
return await handleTodoExportCommand(rest, runtime);
|
||||
case "import":
|
||||
return await handleTodoImportCommand(rest, runtime);
|
||||
case "append":
|
||||
return await handleTodoAppendCommand(rest, runtime);
|
||||
case "start":
|
||||
return await handleTodoStartCommand(rest, runtime);
|
||||
case "done":
|
||||
case "drop":
|
||||
case "rm":
|
||||
return await handleTodoMutationCommand(verb, rest, runtime);
|
||||
case "edit":
|
||||
return usage(
|
||||
"/todo edit requires the TUI editor; use /todo export then /todo import for non-interactive edits.",
|
||||
runtime,
|
||||
);
|
||||
case "help":
|
||||
case "?":
|
||||
await runtime.output(todoHelpText());
|
||||
return commandConsumed();
|
||||
default:
|
||||
return usage("Unknown /todo subcommand. Use append, start, done, drop, rm, copy, export, import.", runtime);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { commandConsumed } from "./shared";
|
||||
import type { AcpBuiltinCommandRuntime, AcpBuiltinCommandSpec } from "./types";
|
||||
|
||||
function getToolsList(runtime: AcpBuiltinCommandRuntime): string {
|
||||
const active = runtime.session.getActiveToolNames();
|
||||
const all = runtime.session.getAllToolNames();
|
||||
if (all.length === 0) return "No tools are available.";
|
||||
return all.map(name => `${active.includes(name) ? "*" : "-"} ${name}`).join("\n");
|
||||
}
|
||||
|
||||
export const toolsCommand: AcpBuiltinCommandSpec = {
|
||||
name: "tools",
|
||||
description: "Show available tools",
|
||||
handle: async (_command, runtime) => {
|
||||
await runtime.output(getToolsList(runtime));
|
||||
return commandConsumed();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Settings } from "../../config/settings";
|
||||
import type { AgentSession } from "../../session/agent-session";
|
||||
import type { SessionManager } from "../../session/session-manager";
|
||||
|
||||
export interface ParsedAcpCommand {
|
||||
name: string;
|
||||
args: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface AcpBuiltinCommandRuntime {
|
||||
session: AgentSession;
|
||||
sessionManager: SessionManager;
|
||||
settings: Settings;
|
||||
cwd: string;
|
||||
output: (text: string) => Promise<void> | void;
|
||||
refreshCommands: () => Promise<void> | void;
|
||||
notifyTitleChanged?: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
export type AcpBuiltinSlashCommandResult = false | { consumed: true } | { prompt: string };
|
||||
|
||||
export interface AcpBuiltinCommandSpec {
|
||||
name: string;
|
||||
description: string;
|
||||
inputHint?: string;
|
||||
aliases?: string[];
|
||||
handle: (
|
||||
command: ParsedAcpCommand,
|
||||
runtime: AcpBuiltinCommandRuntime,
|
||||
) => Promise<AcpBuiltinSlashCommandResult> | AcpBuiltinSlashCommandResult;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { UsageLimit, UsageReport } from "@oh-my-pi/pi-ai";
|
||||
import { formatDuration, renderAsciiBar } from "./format";
|
||||
import { commandConsumed } from "./shared";
|
||||
import type { AcpBuiltinCommandRuntime, AcpBuiltinCommandSpec } from "./types";
|
||||
|
||||
function formatProviderName(provider: string): string {
|
||||
return provider
|
||||
.split(/[-_]/g)
|
||||
.map(part => (part ? part[0].toUpperCase() + part.slice(1) : ""))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function formatUsageAmount(limit: UsageLimit): string {
|
||||
const amount = limit.amount;
|
||||
const used = amount.used ?? (amount.usedFraction !== undefined ? amount.usedFraction * 100 : undefined);
|
||||
const remainingFraction =
|
||||
amount.remainingFraction ??
|
||||
(amount.usedFraction !== undefined ? Math.max(0, 1 - amount.usedFraction) : undefined);
|
||||
const unit = amount.unit === "percent" ? "%" : ` ${amount.unit}`;
|
||||
const usedText = used === undefined ? "unknown used" : `${used.toFixed(2)}${unit} used`;
|
||||
const remainingText = remainingFraction === undefined ? "" : ` (${(remainingFraction * 100).toFixed(1)}% left)`;
|
||||
return `${usedText}${remainingText}`;
|
||||
}
|
||||
|
||||
function formatUsageReportAccount(report: UsageReport, limit: UsageLimit, index: number): string {
|
||||
const email = report.metadata?.email;
|
||||
if (typeof email === "string" && email) return email;
|
||||
const accountId = report.metadata?.accountId ?? limit.scope.accountId;
|
||||
if (typeof accountId === "string" && accountId) return accountId;
|
||||
return `account ${index + 1}`;
|
||||
}
|
||||
|
||||
function renderUsageReports(reports: UsageReport[], nowMs: number): string {
|
||||
const latestFetchedAt = Math.max(...reports.map(report => report.fetchedAt ?? 0));
|
||||
const lines = [`Usage${latestFetchedAt ? ` (${formatDuration(nowMs - latestFetchedAt)} ago)` : ""}`];
|
||||
const grouped = new Map<string, UsageReport[]>();
|
||||
for (const report of reports) {
|
||||
const providerReports = grouped.get(report.provider) ?? [];
|
||||
providerReports.push(report);
|
||||
grouped.set(report.provider, providerReports);
|
||||
}
|
||||
|
||||
for (const [provider, providerReports] of [...grouped.entries()].sort(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
)) {
|
||||
lines.push("", formatProviderName(provider));
|
||||
for (const report of providerReports) {
|
||||
if (report.limits.length === 0) {
|
||||
const email = typeof report.metadata?.email === "string" ? report.metadata.email : "account";
|
||||
lines.push(`- ${email}: no limits reported`);
|
||||
continue;
|
||||
}
|
||||
for (let index = 0; index < report.limits.length; index++) {
|
||||
const limit = report.limits[index]!;
|
||||
const window = limit.window?.label ?? limit.scope.windowId;
|
||||
const tier = limit.scope.tier ? ` (${limit.scope.tier})` : "";
|
||||
lines.push(`- ${limit.label}${tier}${window ? ` — ${window}` : ""}`);
|
||||
lines.push(` ${formatUsageReportAccount(report, limit, index)}: ${formatUsageAmount(limit)}`);
|
||||
lines.push(` ${renderAsciiBar(limit.amount.usedFraction)}`);
|
||||
if (limit.window?.resetsAt) lines.push(` resets in ${formatDuration(limit.window.resetsAt - nowMs)}`);
|
||||
if (limit.notes && limit.notes.length > 0) lines.push(` ${limit.notes.join(" • ")}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ["```", ...lines, "```"].join("\n");
|
||||
}
|
||||
|
||||
async function getUsageStatistics(runtime: AcpBuiltinCommandRuntime): Promise<string> {
|
||||
const provider = runtime.session as AcpBuiltinCommandRuntime["session"] & {
|
||||
fetchUsageReports?: () => Promise<UsageReport[] | null>;
|
||||
};
|
||||
if (provider.fetchUsageReports) {
|
||||
const reports = await provider.fetchUsageReports();
|
||||
if (reports && reports.length > 0) return renderUsageReports(reports, Date.now());
|
||||
}
|
||||
|
||||
const stats = runtime.session.sessionManager.getUsageStatistics();
|
||||
return [
|
||||
"Usage",
|
||||
`Input tokens: ${stats.input}`,
|
||||
`Output tokens: ${stats.output}`,
|
||||
`Cache read tokens: ${stats.cacheRead}`,
|
||||
`Cache write tokens: ${stats.cacheWrite}`,
|
||||
`Premium requests: ${stats.premiumRequests}`,
|
||||
`Cost: $${stats.cost.toFixed(6)}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export const usageCommand: AcpBuiltinCommandSpec = {
|
||||
name: "usage",
|
||||
description: "Show token usage",
|
||||
handle: async (_command, runtime) => {
|
||||
await runtime.output(await getUsageStatistics(runtime));
|
||||
return commandConsumed();
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user