@oh-my-pi/pi-coding-agent 14.9.9 → 15.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +82 -0
- package/package.json +7 -7
- package/scripts/format-prompts.ts +1 -1
- package/src/cli/args.ts +2 -2
- package/src/cli.ts +1 -0
- package/src/commands/acp.ts +24 -0
- package/src/commands/launch.ts +6 -4
- package/src/commit/agentic/prompts/system.md +1 -1
- package/src/config/model-resolver.ts +30 -0
- package/src/config/settings-schema.ts +31 -0
- package/src/edit/index.ts +22 -1
- package/src/edit/modes/patch.ts +10 -0
- package/src/edit/modes/replace.ts +3 -0
- package/src/edit/renderer.ts +10 -0
- package/src/eval/js/context-manager.ts +1 -1
- package/src/eval/js/shared/rewrite-imports.ts +120 -48
- package/src/eval/js/shared/runtime.ts +31 -4
- package/src/eval/js/tool-bridge.ts +43 -21
- package/src/extensibility/extensions/runner.ts +54 -1
- package/src/extensibility/extensions/types.ts +11 -0
- package/src/extensibility/skills.ts +33 -1
- package/src/internal-urls/docs-index.generated.ts +6 -6
- package/src/internal-urls/index.ts +1 -0
- package/src/internal-urls/issue-pr-protocol.ts +577 -0
- package/src/internal-urls/router.ts +6 -3
- package/src/internal-urls/types.ts +22 -1
- package/src/main.ts +13 -9
- package/src/modes/acp/acp-agent.ts +361 -54
- package/src/modes/acp/acp-client-bridge.ts +152 -0
- package/src/modes/acp/acp-event-mapper.ts +180 -15
- package/src/modes/acp/terminal-auth.ts +37 -0
- package/src/modes/components/read-tool-group.ts +29 -1
- package/src/modes/controllers/command-controller.ts +14 -6
- package/src/modes/controllers/event-controller.ts +24 -11
- package/src/modes/controllers/extension-ui-controller.ts +8 -2
- package/src/modes/controllers/input-controller.ts +72 -39
- package/src/modes/interactive-mode.ts +71 -7
- package/src/modes/rpc/rpc-mode.ts +17 -2
- package/src/modes/types.ts +6 -2
- package/src/modes/utils/ui-helpers.ts +15 -3
- package/src/prompts/agents/designer.md +5 -5
- package/src/prompts/agents/explore.md +7 -7
- package/src/prompts/agents/init.md +9 -9
- package/src/prompts/agents/librarian.md +14 -14
- package/src/prompts/agents/plan.md +4 -4
- package/src/prompts/agents/reviewer.md +5 -5
- package/src/prompts/agents/task.md +10 -10
- package/src/prompts/commands/orchestrate.md +2 -2
- package/src/prompts/compaction/branch-summary.md +3 -3
- package/src/prompts/compaction/compaction-short-summary.md +7 -7
- package/src/prompts/compaction/compaction-summary-context.md +1 -1
- package/src/prompts/compaction/compaction-summary.md +5 -5
- package/src/prompts/compaction/compaction-turn-prefix.md +3 -3
- package/src/prompts/compaction/compaction-update-summary.md +11 -11
- package/src/prompts/memories/consolidation.md +2 -2
- package/src/prompts/memories/read-path.md +1 -1
- package/src/prompts/memories/stage_one_input.md +1 -1
- package/src/prompts/memories/stage_one_system.md +5 -5
- package/src/prompts/review-request.md +4 -4
- package/src/prompts/system/agent-creation-architect.md +17 -17
- package/src/prompts/system/agent-creation-user.md +2 -2
- package/src/prompts/system/commit-message-system.md +2 -2
- package/src/prompts/system/custom-system-prompt.md +2 -2
- package/src/prompts/system/eager-todo.md +6 -6
- package/src/prompts/system/handoff-document.md +1 -1
- package/src/prompts/system/plan-mode-active.md +22 -21
- package/src/prompts/system/plan-mode-approved.md +4 -4
- package/src/prompts/system/plan-mode-compact-instructions.md +16 -0
- package/src/prompts/system/plan-mode-reference.md +2 -2
- package/src/prompts/system/plan-mode-subagent.md +8 -8
- package/src/prompts/system/plan-mode-tool-decision-reminder.md +2 -2
- package/src/prompts/system/project-prompt.md +4 -4
- package/src/prompts/system/subagent-system-prompt.md +7 -7
- package/src/prompts/system/subagent-yield-reminder.md +4 -4
- package/src/prompts/system/system-prompt.md +72 -71
- package/src/prompts/system/ttsr-interrupt.md +1 -1
- package/src/prompts/tools/apply-patch.md +1 -1
- package/src/prompts/tools/ast-edit.md +3 -3
- package/src/prompts/tools/ast-grep.md +3 -3
- package/src/prompts/tools/browser.md +3 -3
- package/src/prompts/tools/checkpoint.md +3 -3
- package/src/prompts/tools/exit-plan-mode.md +2 -2
- package/src/prompts/tools/find.md +3 -3
- package/src/prompts/tools/github.md +2 -5
- package/src/prompts/tools/hashline.md +6 -6
- package/src/prompts/tools/image-gen.md +3 -3
- package/src/prompts/tools/irc.md +1 -1
- package/src/prompts/tools/lsp.md +2 -2
- package/src/prompts/tools/patch.md +6 -6
- package/src/prompts/tools/read.md +7 -7
- package/src/prompts/tools/replace.md +5 -5
- package/src/prompts/tools/retain.md +1 -1
- package/src/prompts/tools/rewind.md +2 -2
- package/src/prompts/tools/search.md +2 -2
- package/src/prompts/tools/ssh.md +2 -2
- package/src/prompts/tools/task.md +12 -6
- package/src/prompts/tools/web-search.md +2 -2
- package/src/prompts/tools/write.md +3 -3
- package/src/sdk.ts +69 -12
- package/src/session/agent-session.ts +231 -22
- package/src/session/client-bridge.ts +81 -0
- package/src/session/compaction/errors.ts +31 -0
- package/src/session/compaction/index.ts +1 -0
- package/src/slash-commands/acp-builtins.ts +46 -0
- package/src/slash-commands/builtin-registry.ts +699 -116
- package/src/slash-commands/helpers/context-report.ts +39 -0
- package/src/slash-commands/helpers/format.ts +23 -0
- package/src/slash-commands/helpers/marketplace-manager.ts +25 -0
- package/src/slash-commands/helpers/mcp.ts +532 -0
- package/src/slash-commands/helpers/parse.ts +85 -0
- package/src/slash-commands/helpers/ssh.ts +193 -0
- package/src/slash-commands/helpers/todo.ts +279 -0
- package/src/slash-commands/helpers/usage-report.ts +91 -0
- package/src/slash-commands/types.ts +126 -0
- package/src/task/executor.ts +10 -3
- package/src/task/index.ts +17 -1
- package/src/task/render.ts +6 -3
- package/src/tools/bash.ts +176 -2
- package/src/tools/conflict-detect.ts +6 -6
- package/src/tools/fetch.ts +15 -4
- package/src/tools/find.ts +19 -1
- package/src/tools/gh-renderer.ts +0 -12
- package/src/tools/gh.ts +682 -176
- package/src/tools/github-cache.ts +548 -0
- package/src/tools/index.ts +3 -0
- package/src/tools/read.ts +110 -27
- package/src/tools/write.ts +23 -1
- package/src/tui/code-cell.ts +70 -2
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { computeContextBreakdown } from "../../modes/utils/context-usage";
|
|
2
|
+
import type { SlashCommandRuntime } from "../types";
|
|
3
|
+
import { renderAsciiBar } from "./format";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Build the `/context` ACP-mode text. Tries the rich breakdown first
|
|
7
|
+
* (categories + auto-compact buffer + free slack) and falls back to the
|
|
8
|
+
* minimal "window/used" lines when the breakdown helper throws.
|
|
9
|
+
*/
|
|
10
|
+
export function buildContextReportText(runtime: SlashCommandRuntime): string {
|
|
11
|
+
try {
|
|
12
|
+
const breakdown = computeContextBreakdown(runtime.session);
|
|
13
|
+
if (breakdown.contextWindow <= 0) {
|
|
14
|
+
return "Context usage is unavailable: no model is selected for this session.";
|
|
15
|
+
}
|
|
16
|
+
const usedPct = Math.round((breakdown.usedTokens / breakdown.contextWindow) * 100);
|
|
17
|
+
const lines = [`Context window: ${breakdown.contextWindow} tokens (${usedPct}% used)`];
|
|
18
|
+
for (const category of breakdown.categories) {
|
|
19
|
+
if (category.tokens === 0) continue;
|
|
20
|
+
const fraction = category.tokens / breakdown.contextWindow;
|
|
21
|
+
lines.push(` ${category.label.padEnd(16)} ${renderAsciiBar(fraction)} ${category.tokens} tokens`);
|
|
22
|
+
}
|
|
23
|
+
if (breakdown.autoCompactBufferTokens > 0) {
|
|
24
|
+
const fraction = breakdown.autoCompactBufferTokens / breakdown.contextWindow;
|
|
25
|
+
lines.push(
|
|
26
|
+
` ${"Auto-compact buf".padEnd(16)} ${renderAsciiBar(fraction)} ${breakdown.autoCompactBufferTokens} tokens`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
if (breakdown.freeTokens > 0) {
|
|
30
|
+
const fraction = breakdown.freeTokens / breakdown.contextWindow;
|
|
31
|
+
lines.push(` ${"Free".padEnd(16)} ${renderAsciiBar(fraction)} ${breakdown.freeTokens} tokens`);
|
|
32
|
+
}
|
|
33
|
+
return lines.join("\n");
|
|
34
|
+
} catch {
|
|
35
|
+
const fallback = runtime.session.getContextUsage();
|
|
36
|
+
if (!fallback) return "Context usage is unavailable.";
|
|
37
|
+
return ["Context", `Window: ${fallback.contextWindow}`, `Used: ${fallback.tokens ?? 0}`].join("\n");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Format a millisecond duration as a coarse-grained human label. */
|
|
2
|
+
export function formatDuration(ms: number): string {
|
|
3
|
+
const seconds = Math.max(0, Math.round(ms / 1000));
|
|
4
|
+
if (seconds < 60) return `${seconds}s`;
|
|
5
|
+
const minutes = Math.round(seconds / 60);
|
|
6
|
+
if (minutes < 60) return `${minutes}m`;
|
|
7
|
+
const hours = Math.round(minutes / 60);
|
|
8
|
+
if (hours < 48) return `${hours}h`;
|
|
9
|
+
const days = Math.round(hours / 24);
|
|
10
|
+
return `${days}d`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Render an ASCII progress bar with a trailing percent label.
|
|
15
|
+
* `fraction` is clamped to `[0, 1]`. `undefined` renders a dotted placeholder.
|
|
16
|
+
*/
|
|
17
|
+
export function renderAsciiBar(fraction: number | undefined, width = 24): string {
|
|
18
|
+
if (fraction === undefined) return `[${"·".repeat(width)}]`;
|
|
19
|
+
const clamped = Math.min(Math.max(fraction, 0), 1);
|
|
20
|
+
const filled = Math.round(clamped * width);
|
|
21
|
+
const pct = Math.round(clamped * 100);
|
|
22
|
+
return `[${"█".repeat(filled)}${"░".repeat(Math.max(0, width - filled))}] ${pct}%`;
|
|
23
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { clearPluginRootsAndCaches, resolveOrDefaultProjectRegistryPath } from "../../discovery/helpers";
|
|
2
|
+
import {
|
|
3
|
+
getInstalledPluginsRegistryPath,
|
|
4
|
+
getMarketplacesCacheDir,
|
|
5
|
+
getMarketplacesRegistryPath,
|
|
6
|
+
getPluginsCacheDir,
|
|
7
|
+
MarketplaceManager,
|
|
8
|
+
} from "../../extensibility/plugins/marketplace";
|
|
9
|
+
import type { SlashCommandRuntime } from "../types";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Build a `MarketplaceManager` wired up with the active project's registry
|
|
13
|
+
* paths and the shared plugin-root cache invalidator. Reused by both `/plugins`
|
|
14
|
+
* and `/marketplace` handlers so cache invalidation stays consistent.
|
|
15
|
+
*/
|
|
16
|
+
export async function createMarketplaceManager(runtime: SlashCommandRuntime): Promise<MarketplaceManager> {
|
|
17
|
+
return new MarketplaceManager({
|
|
18
|
+
marketplacesRegistryPath: getMarketplacesRegistryPath(),
|
|
19
|
+
installedRegistryPath: getInstalledPluginsRegistryPath(),
|
|
20
|
+
projectInstalledRegistryPath: await resolveOrDefaultProjectRegistryPath(runtime.cwd),
|
|
21
|
+
marketplacesCacheDir: getMarketplacesCacheDir(),
|
|
22
|
+
pluginsCacheDir: getPluginsCacheDir(),
|
|
23
|
+
clearPluginRootsCache: clearPluginRootsAndCaches,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
import { getMCPConfigPath, logger } from "@oh-my-pi/pi-utils";
|
|
2
|
+
import { connectToServer, disconnectServer, listPrompts, listResources, listTools } from "../../mcp/client";
|
|
3
|
+
import {
|
|
4
|
+
addMCPServer,
|
|
5
|
+
readDisabledServers,
|
|
6
|
+
readMCPConfigFile,
|
|
7
|
+
removeMCPServer,
|
|
8
|
+
setServerDisabled,
|
|
9
|
+
updateMCPServer,
|
|
10
|
+
} from "../../mcp/config-writer";
|
|
11
|
+
import { MCPManager } from "../../mcp/manager";
|
|
12
|
+
import { getSmitheryApiKey } from "../../mcp/smithery-auth";
|
|
13
|
+
import { searchSmitheryRegistry } from "../../mcp/smithery-registry";
|
|
14
|
+
import type { MCPServerConfig, MCPServerConnection } from "../../mcp/types";
|
|
15
|
+
import { parseCommandArgs } from "../../utils/command-args";
|
|
16
|
+
import type { ParsedSlashCommand, SlashCommandResult, SlashCommandRuntime } from "../types";
|
|
17
|
+
import { commandConsumed, errorMessage, parseNamedScopeArgs, parseSubcommand, usage } from "./parse";
|
|
18
|
+
|
|
19
|
+
type AcpMcpScope = "user" | "project";
|
|
20
|
+
|
|
21
|
+
interface ParsedMcpAddArgs {
|
|
22
|
+
name?: string;
|
|
23
|
+
scope: AcpMcpScope;
|
|
24
|
+
url?: string;
|
|
25
|
+
transport: "http" | "sse";
|
|
26
|
+
authToken?: string;
|
|
27
|
+
commandTokens?: string[];
|
|
28
|
+
error?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface ParsedMcpSearchArgs {
|
|
32
|
+
keyword: string;
|
|
33
|
+
scope: AcpMcpScope;
|
|
34
|
+
limit: number;
|
|
35
|
+
semantic: boolean;
|
|
36
|
+
error?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type McpAddOptionParser = (parsed: ParsedMcpAddArgs, value: string | undefined) => string | undefined;
|
|
40
|
+
|
|
41
|
+
const MCP_ADD_USAGE =
|
|
42
|
+
"Usage: /mcp add <name> [--scope project|user] [--url <url> --transport http|sse] [--token <token>] [-- <command...>]";
|
|
43
|
+
|
|
44
|
+
const MCP_ADD_OPTION_PARSERS = new Map<string, McpAddOptionParser>([
|
|
45
|
+
[
|
|
46
|
+
"--scope",
|
|
47
|
+
(parsed, value) => {
|
|
48
|
+
if (!value || (value !== "project" && value !== "user")) return "Invalid --scope value. Use project or user.";
|
|
49
|
+
parsed.scope = value;
|
|
50
|
+
return undefined;
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
[
|
|
54
|
+
"--url",
|
|
55
|
+
(parsed, value) => {
|
|
56
|
+
if (!value) return "Missing value for --url.";
|
|
57
|
+
parsed.url = value;
|
|
58
|
+
return undefined;
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
[
|
|
62
|
+
"--transport",
|
|
63
|
+
(parsed, value) => {
|
|
64
|
+
if (!value || (value !== "http" && value !== "sse")) return "Invalid --transport value. Use http or sse.";
|
|
65
|
+
parsed.transport = value;
|
|
66
|
+
return undefined;
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
[
|
|
70
|
+
"--token",
|
|
71
|
+
(parsed, value) => {
|
|
72
|
+
if (!value) return "Missing value for --token.";
|
|
73
|
+
parsed.authToken = value;
|
|
74
|
+
return undefined;
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
async function getMcpConfiguredServers(
|
|
80
|
+
cwd: string,
|
|
81
|
+
): Promise<Array<{ name: string; config: MCPServerConfig; scope: AcpMcpScope }>> {
|
|
82
|
+
const userPath = getMCPConfigPath("user", cwd);
|
|
83
|
+
const projectPath = getMCPConfigPath("project", cwd);
|
|
84
|
+
const [userConfig, projectConfig] = await Promise.all([readMCPConfigFile(userPath), readMCPConfigFile(projectPath)]);
|
|
85
|
+
const servers: Array<{ name: string; config: MCPServerConfig; scope: AcpMcpScope }> = [];
|
|
86
|
+
const seen = new Set<string>();
|
|
87
|
+
for (const [name, config] of Object.entries(projectConfig.mcpServers ?? {})) {
|
|
88
|
+
if (config.enabled !== false) {
|
|
89
|
+
servers.push({ name, config, scope: "project" });
|
|
90
|
+
seen.add(name);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
for (const [name, config] of Object.entries(userConfig.mcpServers ?? {})) {
|
|
94
|
+
if (!seen.has(name) && config.enabled !== false) servers.push({ name, config, scope: "user" });
|
|
95
|
+
}
|
|
96
|
+
return servers;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function validateParsedMcpAddArgs(parsed: ParsedMcpAddArgs): ParsedMcpAddArgs {
|
|
100
|
+
const hasCommand = (parsed.commandTokens?.length ?? 0) > 0;
|
|
101
|
+
const hasUrl = Boolean(parsed.url);
|
|
102
|
+
if (!hasCommand && !hasUrl) {
|
|
103
|
+
return {
|
|
104
|
+
...parsed,
|
|
105
|
+
error: "Provide --url or -- <command...> for non-interactive add. Usage: /mcp add <name> [--scope project|user] [--url <url> --transport http|sse] [--token <token>] [-- <command...>]",
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
if (!parsed.name) return { ...parsed, error: "Server name required. Usage: /mcp add <name> ..." };
|
|
109
|
+
if (hasCommand && hasUrl) return { ...parsed, error: "Use either --url or -- <command...>, not both." };
|
|
110
|
+
if (parsed.authToken && !hasUrl) return { ...parsed, error: "--token requires --url (HTTP/SSE transport)." };
|
|
111
|
+
return parsed;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function parseMcpAddArgs(rest: string): ParsedMcpAddArgs {
|
|
115
|
+
const tokens = parseCommandArgs(rest);
|
|
116
|
+
const parsed: ParsedMcpAddArgs = { scope: "project", transport: "http" };
|
|
117
|
+
if (tokens.length === 0) return parsed;
|
|
118
|
+
|
|
119
|
+
let index = 0;
|
|
120
|
+
if (!tokens[0]!.startsWith("-")) {
|
|
121
|
+
parsed.name = tokens[0];
|
|
122
|
+
index = 1;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
while (index < tokens.length) {
|
|
126
|
+
const arg = tokens[index]!;
|
|
127
|
+
if (arg === "--") {
|
|
128
|
+
parsed.commandTokens = tokens.slice(index + 1);
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
const parser = MCP_ADD_OPTION_PARSERS.get(arg);
|
|
132
|
+
if (!parser) return { ...parsed, error: `Unknown option: ${arg}` };
|
|
133
|
+
const error = parser(parsed, tokens[index + 1]);
|
|
134
|
+
if (error) return { ...parsed, error };
|
|
135
|
+
index += 2;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return validateParsedMcpAddArgs(parsed);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function parseMcpSearchArgs(rest: string): ParsedMcpSearchArgs {
|
|
142
|
+
const tokens = parseCommandArgs(rest);
|
|
143
|
+
const missingKeyword: ParsedMcpSearchArgs = {
|
|
144
|
+
keyword: "",
|
|
145
|
+
scope: "project",
|
|
146
|
+
limit: 20,
|
|
147
|
+
semantic: false,
|
|
148
|
+
error: "Keyword required. Usage: /mcp smithery-search <keyword> [--scope project|user] [--limit <1-100>] [--semantic]",
|
|
149
|
+
};
|
|
150
|
+
if (tokens.length === 0) return missingKeyword;
|
|
151
|
+
|
|
152
|
+
const keywordParts: string[] = [];
|
|
153
|
+
let scope: AcpMcpScope = "project";
|
|
154
|
+
let limit = 20;
|
|
155
|
+
let semantic = false;
|
|
156
|
+
|
|
157
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
158
|
+
const token = tokens[index]!;
|
|
159
|
+
if (token === "--scope") {
|
|
160
|
+
const value = tokens[index + 1];
|
|
161
|
+
if (!value || (value !== "project" && value !== "user")) {
|
|
162
|
+
return { keyword: "", scope, limit, semantic, error: "Invalid --scope value. Use project or user." };
|
|
163
|
+
}
|
|
164
|
+
scope = value;
|
|
165
|
+
index++;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (token === "--limit") {
|
|
169
|
+
const value = tokens[index + 1];
|
|
170
|
+
if (!value) return { keyword: "", scope, limit, semantic, error: "Missing value for --limit." };
|
|
171
|
+
const parsed = Number(value);
|
|
172
|
+
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 100) {
|
|
173
|
+
return {
|
|
174
|
+
keyword: "",
|
|
175
|
+
scope,
|
|
176
|
+
limit,
|
|
177
|
+
semantic,
|
|
178
|
+
error: "Invalid --limit value. Use an integer between 1 and 100.",
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
limit = parsed;
|
|
182
|
+
index++;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (token === "--semantic") {
|
|
186
|
+
semantic = true;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (token.startsWith("--")) return { keyword: "", scope, limit, semantic, error: `Unknown option: ${token}` };
|
|
190
|
+
keywordParts.push(token);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const keyword = keywordParts.join(" ").trim();
|
|
194
|
+
if (!keyword) return { ...missingKeyword, scope, limit, semantic };
|
|
195
|
+
return { keyword, scope, limit, semantic };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function withPreparedMcpConnection<T>(
|
|
199
|
+
runtime: SlashCommandRuntime,
|
|
200
|
+
name: string,
|
|
201
|
+
config: MCPServerConfig,
|
|
202
|
+
fn: (connection: MCPServerConnection) => Promise<T>,
|
|
203
|
+
): Promise<T> {
|
|
204
|
+
let connection: MCPServerConnection | undefined;
|
|
205
|
+
try {
|
|
206
|
+
const manager = new MCPManager(runtime.cwd);
|
|
207
|
+
// Auth storage must be wired in before prepareConfig so OAuth-backed
|
|
208
|
+
// servers can refresh credentials and inject Authorization headers.
|
|
209
|
+
// Without this, `/mcp test|resources|prompts` silently fails for any
|
|
210
|
+
// server saved by the TUI/reauth path.
|
|
211
|
+
manager.setAuthStorage(runtime.session.modelRegistry.authStorage);
|
|
212
|
+
const resolvedConfig = await manager.prepareConfig(config);
|
|
213
|
+
connection = await connectToServer(name, resolvedConfig);
|
|
214
|
+
return await fn(connection);
|
|
215
|
+
} finally {
|
|
216
|
+
if (connection) {
|
|
217
|
+
// Await cleanup so the stdio subprocess / HTTP DELETE has actually
|
|
218
|
+
// released the resource before this helper returns. Fire-and-forget
|
|
219
|
+
// here races with subsequent connect attempts and turns close
|
|
220
|
+
// failures into unhandled rejections.
|
|
221
|
+
try {
|
|
222
|
+
await disconnectServer(connection);
|
|
223
|
+
} catch (err) {
|
|
224
|
+
logger.warn("MCP disconnect after temporary connection failed", { name, err });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function collectConnectedMcpLines(
|
|
231
|
+
runtime: SlashCommandRuntime,
|
|
232
|
+
collect: (serverName: string, connection: MCPServerConnection) => Promise<string[]>,
|
|
233
|
+
): Promise<string[] | undefined> {
|
|
234
|
+
const servers = await getMcpConfiguredServers(runtime.cwd);
|
|
235
|
+
if (servers.length === 0) return undefined;
|
|
236
|
+
|
|
237
|
+
const lines: string[] = [];
|
|
238
|
+
for (const { name, config } of servers) {
|
|
239
|
+
try {
|
|
240
|
+
const collected = await withPreparedMcpConnection(runtime, name, config, connection =>
|
|
241
|
+
collect(name, connection),
|
|
242
|
+
);
|
|
243
|
+
lines.push(...collected);
|
|
244
|
+
} catch {
|
|
245
|
+
// unreachable server: skip silently
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return lines;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function handleResourcesCommand(runtime: SlashCommandRuntime): Promise<SlashCommandResult> {
|
|
252
|
+
const lines = await collectConnectedMcpLines(runtime, async (name, connection) => {
|
|
253
|
+
const resources = await listResources(connection);
|
|
254
|
+
return resources.map(resource => `${name}/${resource.uri}`);
|
|
255
|
+
});
|
|
256
|
+
if (!lines) {
|
|
257
|
+
await runtime.output("No MCP servers configured.");
|
|
258
|
+
return commandConsumed();
|
|
259
|
+
}
|
|
260
|
+
await runtime.output(lines.length > 0 ? lines.join("\n") : "No resources available on connected servers.");
|
|
261
|
+
return commandConsumed();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function handlePromptsCommand(runtime: SlashCommandRuntime): Promise<SlashCommandResult> {
|
|
265
|
+
const lines = await collectConnectedMcpLines(runtime, async (name, connection) => {
|
|
266
|
+
const prompts = await listPrompts(connection);
|
|
267
|
+
return prompts.map(prompt => `${name}/${prompt.name}${prompt.description ? ` — ${prompt.description}` : ""}`);
|
|
268
|
+
});
|
|
269
|
+
if (!lines) {
|
|
270
|
+
await runtime.output("No MCP servers configured.");
|
|
271
|
+
return commandConsumed();
|
|
272
|
+
}
|
|
273
|
+
await runtime.output(lines.length > 0 ? lines.join("\n") : "No prompts available on connected servers.");
|
|
274
|
+
return commandConsumed();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function handleTestCommand(rest: string, runtime: SlashCommandRuntime): Promise<SlashCommandResult> {
|
|
278
|
+
const name = rest.split(/\s+/)[0]?.trim() ?? "";
|
|
279
|
+
if (!name) return usage("Usage: /mcp test <name>", runtime);
|
|
280
|
+
const servers = await getMcpConfiguredServers(runtime.cwd);
|
|
281
|
+
const server = servers.find(item => item.name === name);
|
|
282
|
+
if (!server) return usage(`Server "${name}" not found. Run /mcp list to see configured servers.`, runtime);
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
return await withPreparedMcpConnection(runtime, name, server.config, async connection => {
|
|
286
|
+
const tools = await listTools(connection);
|
|
287
|
+
const lines = [`Server "${name}" connected (${tools.length} tools).`];
|
|
288
|
+
for (const tool of tools) lines.push(` - ${tool.name}`);
|
|
289
|
+
await runtime.output(lines.join("\n"));
|
|
290
|
+
return commandConsumed();
|
|
291
|
+
});
|
|
292
|
+
} catch (err) {
|
|
293
|
+
return usage(`Connection to "${name}" failed: ${errorMessage(err)}`, runtime);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function buildMcpServerConfig(parsed: ParsedMcpAddArgs): MCPServerConfig | undefined {
|
|
298
|
+
if (parsed.commandTokens && parsed.commandTokens.length > 0) {
|
|
299
|
+
const [command, ...args] = parsed.commandTokens;
|
|
300
|
+
return { type: "stdio", command: command!, args: args.length > 0 ? args : undefined } as MCPServerConfig;
|
|
301
|
+
}
|
|
302
|
+
if (!parsed.url) return undefined;
|
|
303
|
+
const normalizedUrl = /^https?:\/\//i.test(parsed.url) ? parsed.url : `https://${parsed.url}`;
|
|
304
|
+
return {
|
|
305
|
+
type: parsed.transport === "sse" ? "sse" : "http",
|
|
306
|
+
url: normalizedUrl,
|
|
307
|
+
headers: parsed.authToken ? { Authorization: `Bearer ${parsed.authToken}` } : undefined,
|
|
308
|
+
} as MCPServerConfig;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function handleAddCommand(rest: string, runtime: SlashCommandRuntime): Promise<SlashCommandResult> {
|
|
312
|
+
if (!rest) return usage(MCP_ADD_USAGE, runtime);
|
|
313
|
+
const parsed = parseMcpAddArgs(rest);
|
|
314
|
+
if (parsed.error) return usage(parsed.error, runtime);
|
|
315
|
+
if (!parsed.name) return usage(MCP_ADD_USAGE, runtime);
|
|
316
|
+
const config = buildMcpServerConfig(parsed);
|
|
317
|
+
if (!config) return usage(MCP_ADD_USAGE, runtime);
|
|
318
|
+
try {
|
|
319
|
+
const filePath = getMCPConfigPath(parsed.scope, runtime.cwd);
|
|
320
|
+
await addMCPServer(filePath, parsed.name, config);
|
|
321
|
+
await runtime.output(`Added MCP server "${parsed.name}" (${parsed.scope}).`);
|
|
322
|
+
return commandConsumed();
|
|
323
|
+
} catch (err) {
|
|
324
|
+
return usage(`Failed to add server: ${errorMessage(err)}`, runtime);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function handleSmitherySearchCommand(rest: string, runtime: SlashCommandRuntime): Promise<SlashCommandResult> {
|
|
329
|
+
const parsed = parseMcpSearchArgs(rest);
|
|
330
|
+
if (parsed.error) return usage(parsed.error, runtime);
|
|
331
|
+
try {
|
|
332
|
+
const apiKey = await getSmitheryApiKey();
|
|
333
|
+
const results = await searchSmitheryRegistry(parsed.keyword, {
|
|
334
|
+
limit: parsed.limit,
|
|
335
|
+
apiKey: apiKey ?? undefined,
|
|
336
|
+
includeSemantic: parsed.semantic,
|
|
337
|
+
});
|
|
338
|
+
if (results.length === 0) {
|
|
339
|
+
await runtime.output(`No Smithery results found for "${parsed.keyword}".`);
|
|
340
|
+
return commandConsumed();
|
|
341
|
+
}
|
|
342
|
+
await runtime.output(
|
|
343
|
+
results
|
|
344
|
+
.map(
|
|
345
|
+
result =>
|
|
346
|
+
`${result.display.displayName} (${result.name})${result.display.description ? ` — ${result.display.description}` : ""}`,
|
|
347
|
+
)
|
|
348
|
+
.join("\n"),
|
|
349
|
+
);
|
|
350
|
+
return commandConsumed();
|
|
351
|
+
} catch (err) {
|
|
352
|
+
const message = errorMessage(err);
|
|
353
|
+
if (/401|403|unauthorized|forbidden/i.test(message)) {
|
|
354
|
+
return usage(
|
|
355
|
+
"Smithery authentication required. Run /mcp smithery-login in the TUI client or add an API key to ~/.omp/agent/smithery.json.",
|
|
356
|
+
runtime,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
return usage(`Smithery search failed: ${message}`, runtime);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function handleListCommand(runtime: SlashCommandRuntime): Promise<SlashCommandResult> {
|
|
364
|
+
try {
|
|
365
|
+
const userPath = getMCPConfigPath("user", runtime.cwd);
|
|
366
|
+
const projectPath = getMCPConfigPath("project", runtime.cwd);
|
|
367
|
+
const [userConfig, projectConfig] = await Promise.all([
|
|
368
|
+
readMCPConfigFile(userPath),
|
|
369
|
+
readMCPConfigFile(projectPath),
|
|
370
|
+
]);
|
|
371
|
+
const disabledSet = new Set(await readDisabledServers(userPath));
|
|
372
|
+
const entries: Array<{ name: string; config: MCPServerConfig; scope: string }> = [];
|
|
373
|
+
for (const [name, config] of Object.entries(userConfig.mcpServers ?? {})) {
|
|
374
|
+
entries.push({ name, config, scope: "user" });
|
|
375
|
+
}
|
|
376
|
+
for (const [name, config] of Object.entries(projectConfig.mcpServers ?? {})) {
|
|
377
|
+
if (!entries.some(entry => entry.name === name)) entries.push({ name, config, scope: "project" });
|
|
378
|
+
}
|
|
379
|
+
if (entries.length === 0) {
|
|
380
|
+
await runtime.output("No MCP servers configured.");
|
|
381
|
+
return commandConsumed();
|
|
382
|
+
}
|
|
383
|
+
await runtime.output(
|
|
384
|
+
entries
|
|
385
|
+
.map(({ name, config, scope }) => {
|
|
386
|
+
const type = config.type ?? "stdio";
|
|
387
|
+
const enabled = config.enabled !== false && !disabledSet.has(name) ? "enabled" : "disabled";
|
|
388
|
+
let location: string | undefined;
|
|
389
|
+
if (config.type === "http" || config.type === "sse") {
|
|
390
|
+
// Strip query string and userinfo from URLs to avoid leaking
|
|
391
|
+
// API keys carried in the query (e.g. `?apiKey=…`). Skip the
|
|
392
|
+
// redaction entirely for missing/empty URLs so the row falls
|
|
393
|
+
// back to `(unknown)` rather than the misleading `(hidden)`
|
|
394
|
+
// label reserved for unparseable values.
|
|
395
|
+
const raw = (config as { url?: string }).url;
|
|
396
|
+
if (raw) {
|
|
397
|
+
try {
|
|
398
|
+
const parsed = new URL(raw);
|
|
399
|
+
const pathOnly = parsed.pathname && parsed.pathname !== "/" ? parsed.pathname : "";
|
|
400
|
+
location = `${parsed.origin}${pathOnly}`;
|
|
401
|
+
} catch {
|
|
402
|
+
location = "(hidden)";
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
} else {
|
|
406
|
+
location = (config as { command: string }).command;
|
|
407
|
+
}
|
|
408
|
+
return `${name} | ${type} | ${enabled} | ${location ?? "(unknown)"} [${scope}]`;
|
|
409
|
+
})
|
|
410
|
+
.join("\n"),
|
|
411
|
+
);
|
|
412
|
+
return commandConsumed();
|
|
413
|
+
} catch (err) {
|
|
414
|
+
return usage(`Failed to list MCP servers: ${errorMessage(err)}`, runtime);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function handleEnableDisableCommand(
|
|
419
|
+
verb: "enable" | "disable",
|
|
420
|
+
rest: string,
|
|
421
|
+
runtime: SlashCommandRuntime,
|
|
422
|
+
): Promise<SlashCommandResult> {
|
|
423
|
+
const name = rest.split(/\s+/)[0] ?? "";
|
|
424
|
+
if (!name) return usage(`Usage: /mcp ${verb} <name>`, runtime);
|
|
425
|
+
const enabled = verb === "enable";
|
|
426
|
+
try {
|
|
427
|
+
const userPath = getMCPConfigPath("user", runtime.cwd);
|
|
428
|
+
const projectPath = getMCPConfigPath("project", runtime.cwd);
|
|
429
|
+
const [userConfig, projectConfig] = await Promise.all([
|
|
430
|
+
readMCPConfigFile(userPath),
|
|
431
|
+
readMCPConfigFile(projectPath),
|
|
432
|
+
]);
|
|
433
|
+
if (projectConfig.mcpServers?.[name] !== undefined) {
|
|
434
|
+
await updateMCPServer(projectPath, name, { ...projectConfig.mcpServers[name], enabled } as MCPServerConfig);
|
|
435
|
+
await runtime.output(`Server "${name}" ${enabled ? "enabled" : "disabled"} (project config).`);
|
|
436
|
+
return commandConsumed();
|
|
437
|
+
}
|
|
438
|
+
if (userConfig.mcpServers?.[name] !== undefined) {
|
|
439
|
+
await updateMCPServer(userPath, name, { ...userConfig.mcpServers[name], enabled } as MCPServerConfig);
|
|
440
|
+
await runtime.output(`Server "${name}" ${enabled ? "enabled" : "disabled"} (user config).`);
|
|
441
|
+
return commandConsumed();
|
|
442
|
+
}
|
|
443
|
+
const disabledList = await readDisabledServers(userPath);
|
|
444
|
+
if (!enabled || disabledList.includes(name)) {
|
|
445
|
+
await setServerDisabled(userPath, name, !enabled);
|
|
446
|
+
await runtime.output(`Server "${name}" ${enabled ? "enabled" : "disabled"}.`);
|
|
447
|
+
return commandConsumed();
|
|
448
|
+
}
|
|
449
|
+
return usage(`Server "${name}" not found in user or project config.`, runtime);
|
|
450
|
+
} catch (err) {
|
|
451
|
+
return usage(`Failed to ${verb} MCP server: ${errorMessage(err)}`, runtime);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function handleRemoveCommand(rest: string, runtime: SlashCommandRuntime): Promise<SlashCommandResult> {
|
|
456
|
+
const parsed = parseNamedScopeArgs(rest, "Invalid --scope value. Use project or user.");
|
|
457
|
+
if (parsed.error) return usage(parsed.error, runtime);
|
|
458
|
+
if (!parsed.name) return usage("Usage: /mcp remove <name> [--scope project|user]", runtime);
|
|
459
|
+
try {
|
|
460
|
+
const filePath = getMCPConfigPath(parsed.scope, runtime.cwd);
|
|
461
|
+
await removeMCPServer(filePath, parsed.name);
|
|
462
|
+
await runtime.output(`Removed server "${parsed.name}" from ${parsed.scope} config.`);
|
|
463
|
+
return commandConsumed();
|
|
464
|
+
} catch (err) {
|
|
465
|
+
return usage(`Failed to remove MCP server: ${errorMessage(err)}`, runtime);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const MCP_HELP_TEXT = [
|
|
470
|
+
"MCP server management (ACP mode)",
|
|
471
|
+
" /mcp list List configured servers",
|
|
472
|
+
" /mcp enable <name> Enable a server",
|
|
473
|
+
" /mcp disable <name> Disable a server",
|
|
474
|
+
" /mcp remove <name> [--scope project|user] Remove a server",
|
|
475
|
+
" /mcp reload Reload MCP runtime",
|
|
476
|
+
" /mcp resources List resources from all servers",
|
|
477
|
+
" /mcp prompts List prompts from all servers",
|
|
478
|
+
" /mcp test <name> Test connection to a server",
|
|
479
|
+
" /mcp add <name> [--scope project|user] [--url <url>] Add a server (non-interactive)",
|
|
480
|
+
" /mcp add <name> [-- <command...>] Add a stdio server",
|
|
481
|
+
" /mcp smithery-search <kw> [--scope project|user] Search Smithery registry",
|
|
482
|
+
" /mcp help Show this help",
|
|
483
|
+
].join("\n");
|
|
484
|
+
|
|
485
|
+
const TUI_ONLY_MCP_VERBS = new Set(["reauth", "unauth", "smithery-login", "smithery-logout", "reconnect"]);
|
|
486
|
+
|
|
487
|
+
/** ACP/text-mode `/mcp` handler. Shared by both dispatchers via the spec. */
|
|
488
|
+
export async function handleMcpAcp(
|
|
489
|
+
command: ParsedSlashCommand,
|
|
490
|
+
runtime: SlashCommandRuntime,
|
|
491
|
+
): Promise<SlashCommandResult> {
|
|
492
|
+
const { verb, rest } = parseSubcommand(command.args);
|
|
493
|
+
if (!verb || verb === "help") {
|
|
494
|
+
await runtime.output(MCP_HELP_TEXT);
|
|
495
|
+
return commandConsumed();
|
|
496
|
+
}
|
|
497
|
+
if (verb === "notifications") {
|
|
498
|
+
return usage(
|
|
499
|
+
"MCP notifications require the TUI client (live MCPManager). Use /mcp list to see server status.",
|
|
500
|
+
runtime,
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
if (TUI_ONLY_MCP_VERBS.has(verb)) {
|
|
504
|
+
return usage(`/mcp ${verb} requires OAuth or browser flows only available in the TUI client.`, runtime);
|
|
505
|
+
}
|
|
506
|
+
switch (verb) {
|
|
507
|
+
case "resources":
|
|
508
|
+
return await handleResourcesCommand(runtime);
|
|
509
|
+
case "prompts":
|
|
510
|
+
return await handlePromptsCommand(runtime);
|
|
511
|
+
case "test":
|
|
512
|
+
return await handleTestCommand(rest, runtime);
|
|
513
|
+
case "add":
|
|
514
|
+
return await handleAddCommand(rest, runtime);
|
|
515
|
+
case "smithery-search":
|
|
516
|
+
return await handleSmitherySearchCommand(rest, runtime);
|
|
517
|
+
case "reload":
|
|
518
|
+
await runtime.refreshCommands();
|
|
519
|
+
await runtime.output("MCP runtime reload requested.");
|
|
520
|
+
return commandConsumed();
|
|
521
|
+
case "list":
|
|
522
|
+
return await handleListCommand(runtime);
|
|
523
|
+
case "enable":
|
|
524
|
+
case "disable":
|
|
525
|
+
return await handleEnableDisableCommand(verb, rest, runtime);
|
|
526
|
+
case "remove":
|
|
527
|
+
case "rm":
|
|
528
|
+
return await handleRemoveCommand(rest, runtime);
|
|
529
|
+
default:
|
|
530
|
+
return usage(`Unknown /mcp subcommand: ${verb}. Use /mcp help for available subcommands.`, runtime);
|
|
531
|
+
}
|
|
532
|
+
}
|