diff --git a/packages/coding-agent/src/slash-commands/acp-builtins.ts b/packages/coding-agent/src/slash-commands/acp-builtins.ts new file mode 100644 index 000000000..b5e0010b8 --- /dev/null +++ b/packages/coding-agent/src/slash-commands/acp-builtins.ts @@ -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(); +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 { + 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); +} diff --git a/packages/coding-agent/src/slash-commands/acp-builtins/browser.ts b/packages/coding-agent/src/slash-commands/acp-builtins/browser.ts new file mode 100644 index 000000000..1575fb040 --- /dev/null +++ b/packages/coding-agent/src/slash-commands/acp-builtins/browser.ts @@ -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); + const tool = runtime.session.getToolByName("browser"); + if (tool && "restartForModeChange" in tool) { + await (tool as { restartForModeChange: () => Promise }).restartForModeChange(); + } + await runtime.output(`Browser mode: ${next ? "headless" : "visible"}`); + return commandConsumed(); + }, +}; diff --git a/packages/coding-agent/src/slash-commands/acp-builtins/changelog.ts b/packages/coding-agent/src/slash-commands/acp-builtins/changelog.ts new file mode 100644 index 000000000..37f8b5d39 --- /dev/null +++ b/packages/coding-agent/src/slash-commands/acp-builtins/changelog.ts @@ -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(); + }, +}; diff --git a/packages/coding-agent/src/slash-commands/acp-builtins/commands.ts b/packages/coding-agent/src/slash-commands/acp-builtins/commands.ts new file mode 100644 index 000000000..4c90f94c4 --- /dev/null +++ b/packages/coding-agent/src/slash-commands/acp-builtins/commands.ts @@ -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 = [ + fastCommand, + browserCommand, + usageCommand, + dumpCommand, + contextCommand, + toolsCommand, + modelCommand, + jobsCommand, + changelogCommand, + exportCommand, + shareCommand, + todoCommand, + sessionCommand, + mcpCommand, + sshCommand, + compactCommand, + memoryCommand, + renameCommand, + moveCommand, + marketplaceCommand, + pluginsCommand, + reloadPluginsCommand, + forceCommand, +]; diff --git a/packages/coding-agent/src/slash-commands/acp-builtins/compact.ts b/packages/coding-agent/src/slash-commands/acp-builtins/compact.ts new file mode 100644 index 000000000..dcf3d0b40 --- /dev/null +++ b/packages/coding-agent/src/slash-commands/acp-builtins/compact.ts @@ -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(); + }, +}; diff --git a/packages/coding-agent/src/slash-commands/acp-builtins/context.ts b/packages/coding-agent/src/slash-commands/acp-builtins/context.ts new file mode 100644 index 000000000..ec07b20c3 --- /dev/null +++ b/packages/coding-agent/src/slash-commands/acp-builtins/context.ts @@ -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(); + }, +}; diff --git a/packages/coding-agent/src/slash-commands/acp-builtins/dump.ts b/packages/coding-agent/src/slash-commands/acp-builtins/dump.ts new file mode 100644 index 000000000..fc6be9b24 --- /dev/null +++ b/packages/coding-agent/src/slash-commands/acp-builtins/dump.ts @@ -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(); + }, +}; diff --git a/packages/coding-agent/src/slash-commands/acp-builtins/export.ts b/packages/coding-agent/src/slash-commands/acp-builtins/export.ts new file mode 100644 index 000000000..172517922 --- /dev/null +++ b/packages/coding-agent/src/slash-commands/acp-builtins/export.ts @@ -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); + } + }, +}; diff --git a/packages/coding-agent/src/slash-commands/acp-builtins/fast.ts b/packages/coding-agent/src/slash-commands/acp-builtins/fast.ts new file mode 100644 index 000000000..fad51b531 --- /dev/null +++ b/packages/coding-agent/src/slash-commands/acp-builtins/fast.ts @@ -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); + }, +}; diff --git a/packages/coding-agent/src/slash-commands/acp-builtins/force.ts b/packages/coding-agent/src/slash-commands/acp-builtins/force.ts new file mode 100644 index 000000000..e7ed51125 --- /dev/null +++ b/packages/coding-agent/src/slash-commands/acp-builtins/force.ts @@ -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: " [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: [prompt]", runtime); + runtime.session.setForcedToolChoice(toolName); + await runtime.output(`Next turn forced to use ${toolName}.`); + return prompt ? { prompt } : commandConsumed(); + }, +}; diff --git a/packages/coding-agent/src/slash-commands/acp-builtins/format.ts b/packages/coding-agent/src/slash-commands/acp-builtins/format.ts new file mode 100644 index 000000000..e35389d55 --- /dev/null +++ b/packages/coding-agent/src/slash-commands/acp-builtins/format.ts @@ -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}%`; +} diff --git a/packages/coding-agent/src/slash-commands/acp-builtins/jobs.ts b/packages/coding-agent/src/slash-commands/acp-builtins/jobs.ts new file mode 100644 index 000000000..05f92b08a --- /dev/null +++ b/packages/coding-agent/src/slash-commands/acp-builtins/jobs.ts @@ -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(); + }, +}; diff --git a/packages/coding-agent/src/slash-commands/acp-builtins/marketplace-manager.ts b/packages/coding-agent/src/slash-commands/acp-builtins/marketplace-manager.ts new file mode 100644 index 000000000..ba0dad079 --- /dev/null +++ b/packages/coding-agent/src/slash-commands/acp-builtins/marketplace-manager.ts @@ -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 { + return new MarketplaceManager({ + marketplacesRegistryPath: getMarketplacesRegistryPath(), + installedRegistryPath: getInstalledPluginsRegistryPath(), + projectInstalledRegistryPath: await resolveOrDefaultProjectRegistryPath(runtime.cwd), + marketplacesCacheDir: getMarketplacesCacheDir(), + pluginsCacheDir: getPluginsCacheDir(), + clearPluginRootsCache: clearPluginRootsAndCaches, + }); +} diff --git a/packages/coding-agent/src/slash-commands/acp-builtins/marketplace.ts b/packages/coding-agent/src/slash-commands/acp-builtins/marketplace.ts new file mode 100644 index 000000000..8f63c09ac --- /dev/null +++ b/packages/coding-agent/src/slash-commands/acp-builtins/marketplace.ts @@ -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 Add a marketplace (e.g. owner/repo)", + " /marketplace remove Remove a marketplace", + " /marketplace update [name] Re-fetch catalog(s)", + " /marketplace list List configured marketplaces", + " /marketplace discover [marketplace] Browse available plugins", + " /marketplace install Install a plugin", + " /marketplace uninstall 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 { + 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 { + 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 { + 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 { + const parsed = parsePluginScopeArgs(rest, "Usage: /marketplace uninstall [--scope user|project] "); + 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 { + 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 { + if (rest) { + const parsed = parsePluginScopeArgs( + rest, + "Usage: /marketplace upgrade [--scope user|project] ", + ); + 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; + +async function handleAddCommand( + manager: MarketplaceManager, + rest: string, + runtime: AcpBuiltinCommandRuntime, +): Promise { + if (!rest) return usage("Usage: /marketplace add ", 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 { + if (!rest) return usage("Usage: /marketplace remove ", runtime); + await manager.removeMarketplace(rest); + await runtime.output(`Removed marketplace: ${rest}`); + return commandConsumed(); +} + +async function handleUpdateCommand( + manager: MarketplaceManager, + rest: string, + runtime: AcpBuiltinCommandRuntime, +): Promise { + 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 { + 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([ + ["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: "", + 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); + } + }, +}; diff --git a/packages/coding-agent/src/slash-commands/acp-builtins/mcp.ts b/packages/coding-agent/src/slash-commands/acp-builtins/mcp.ts new file mode 100644 index 000000000..f7e7a13ad --- /dev/null +++ b/packages/coding-agent/src/slash-commands/acp-builtins/mcp.ts @@ -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 [--scope project|user] [--url --transport http|sse] [--token ] [-- ]"; + +const MCP_ADD_OPTION_PARSERS = new Map([ + [ + "--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> { + 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(); + 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 -- for non-interactive add. Usage: /mcp add [--scope project|user] [--url --transport http|sse] [--token ] [-- ]", + ); + } + if (!parsed.name) return mcpAddArgError(parsed, "Server name required. Usage: /mcp add ..."); + if (hasCommand && hasUrl) return mcpAddArgError(parsed, "Use either --url or -- , 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 [--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( + runtime: AcpBuiltinCommandRuntime, + name: string, + config: MCPServerConfig, + fn: (connection: MCPServerConnection) => Promise, +): Promise { + 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, +): Promise { + 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 ", 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} `, 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 [--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 Enable a server", + " /mcp disable Disable a server", + " /mcp remove [--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 Test connection to a server", + " /mcp add [--scope project|user] [--url ] Add a server (non-interactive)", + " /mcp add [-- ] Add a stdio server", + " /mcp smithery-search [--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: "", + 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); + } + }, +}; diff --git a/packages/coding-agent/src/slash-commands/acp-builtins/memory.ts b/packages/coding-agent/src/slash-commands/acp-builtins/memory.ts new file mode 100644 index 000000000..a061e496d --- /dev/null +++ b/packages/coding-agent/src/slash-commands/acp-builtins/memory.ts @@ -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: "", + 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 ", runtime); + } + }, +}; diff --git a/packages/coding-agent/src/slash-commands/acp-builtins/model.ts b/packages/coding-agent/src/slash-commands/acp-builtins/model.ts new file mode 100644 index 000000000..a7d7568ca --- /dev/null +++ b/packages/coding-agent/src/slash-commands/acp-builtins/model.ts @@ -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(); + }, +}; diff --git a/packages/coding-agent/src/slash-commands/acp-builtins/move.ts b/packages/coding-agent/src/slash-commands/acp-builtins/move.ts new file mode 100644 index 000000000..645eca0c3 --- /dev/null +++ b/packages/coding-agent/src/slash-commands/acp-builtins/move.ts @@ -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: "", + handle: async (command, runtime) => { + if (runtime.session.isStreaming) return usage("Cannot move while streaming.", runtime); + if (!command.args) return usage("Usage: /move ", 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(); + }, +}; diff --git a/packages/coding-agent/src/slash-commands/acp-builtins/plugins.ts b/packages/coding-agent/src/slash-commands/acp-builtins/plugins.ts new file mode 100644 index 000000000..5b0c0120f --- /dev/null +++ b/packages/coding-agent/src/slash-commands/acp-builtins/plugins.ts @@ -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 { + const parsed = parsePluginScopeArgs(rest, `Usage: /plugins ${sub} [--scope user|project] `); + 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 { + 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); + } + }, +}; diff --git a/packages/coding-agent/src/slash-commands/acp-builtins/reload-plugins.ts b/packages/coding-agent/src/slash-commands/acp-builtins/reload-plugins.ts new file mode 100644 index 000000000..4f14d37a8 --- /dev/null +++ b/packages/coding-agent/src/slash-commands/acp-builtins/reload-plugins.ts @@ -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(); + }, +}; diff --git a/packages/coding-agent/src/slash-commands/acp-builtins/rename.ts b/packages/coding-agent/src/slash-commands/acp-builtins/rename.ts new file mode 100644 index 000000000..0996a3e6d --- /dev/null +++ b/packages/coding-agent/src/slash-commands/acp-builtins/rename.ts @@ -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: "", + 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(); + }, +}; diff --git a/packages/coding-agent/src/slash-commands/acp-builtins/session.ts b/packages/coding-agent/src/slash-commands/acp-builtins/session.ts new file mode 100644 index 000000000..7de41f48b --- /dev/null +++ b/packages/coding-agent/src/slash-commands/acp-builtins/session.ts @@ -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); + }, +}; diff --git a/packages/coding-agent/src/slash-commands/acp-builtins/share.ts b/packages/coding-agent/src/slash-commands/acp-builtins/share.ts new file mode 100644 index 000000000..a8febf3c6 --- /dev/null +++ b/packages/coding-agent/src/slash-commands/acp-builtins/share.ts @@ -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(() => {}); + } + }, +}; diff --git a/packages/coding-agent/src/slash-commands/acp-builtins/shared.ts b/packages/coding-agent/src/slash-commands/acp-builtins/shared.ts new file mode 100644 index 000000000..347b7dbb1 --- /dev/null +++ b/packages/coding-agent/src/slash-commands/acp-builtins/shared.ts @@ -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 }; +} diff --git a/packages/coding-agent/src/slash-commands/acp-builtins/ssh.ts b/packages/coding-agent/src/slash-commands/acp-builtins/ssh.ts new file mode 100644 index 000000000..a8a4e39e7 --- /dev/null +++ b/packages/coding-agent/src/slash-commands/acp-builtins/ssh.ts @@ -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); + } + }, +}; diff --git a/packages/coding-agent/src/slash-commands/acp-builtins/todo-state.ts b/packages/coding-agent/src/slash-commands/acp-builtins/todo-state.ts new file mode 100644 index 000000000..ef51e4684 --- /dev/null +++ b/packages/coding-agent/src/slash-commands/acp-builtins/todo-state.ts @@ -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() })); +} diff --git a/packages/coding-agent/src/slash-commands/acp-builtins/todo.ts b/packages/coding-agent/src/slash-commands/acp-builtins/todo.ts new file mode 100644 index 000000000..d48a2ed63 --- /dev/null +++ b/packages/coding-agent/src/slash-commands/acp-builtins/todo.ts @@ -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); + } + }, +}; diff --git a/packages/coding-agent/src/slash-commands/acp-builtins/tools.ts b/packages/coding-agent/src/slash-commands/acp-builtins/tools.ts new file mode 100644 index 000000000..0fa708f3c --- /dev/null +++ b/packages/coding-agent/src/slash-commands/acp-builtins/tools.ts @@ -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(); + }, +}; diff --git a/packages/coding-agent/src/slash-commands/acp-builtins/types.ts b/packages/coding-agent/src/slash-commands/acp-builtins/types.ts new file mode 100644 index 000000000..1fb99cb11 --- /dev/null +++ b/packages/coding-agent/src/slash-commands/acp-builtins/types.ts @@ -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; +} diff --git a/packages/coding-agent/src/slash-commands/acp-builtins/usage.ts b/packages/coding-agent/src/slash-commands/acp-builtins/usage.ts new file mode 100644 index 000000000..cecd02b37 --- /dev/null +++ b/packages/coding-agent/src/slash-commands/acp-builtins/usage.ts @@ -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(); + }, +};