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:
Ogrodev
2026-05-11 12:31:59 -03:00
committed by can1357
parent a1ba6bf003
commit 0e43db53c3
30 changed files with 2076 additions and 0 deletions
@@ -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();
},
};