@oh-my-pi/pi-coding-agent 13.5.8 โ 13.6.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 +30 -1
- package/package.json +7 -7
- package/src/cli/args.ts +7 -0
- package/src/cli/stats-cli.ts +5 -0
- package/src/config/model-registry.ts +99 -9
- package/src/config/settings-schema.ts +22 -2
- package/src/extensibility/extensions/types.ts +2 -0
- package/src/internal-urls/docs-index.generated.ts +2 -2
- package/src/internal-urls/index.ts +2 -1
- package/src/internal-urls/mcp-protocol.ts +156 -0
- package/src/internal-urls/router.ts +1 -1
- package/src/internal-urls/types.ts +3 -3
- package/src/mcp/client.ts +235 -2
- package/src/mcp/index.ts +1 -1
- package/src/mcp/manager.ts +399 -5
- package/src/mcp/oauth-flow.ts +26 -1
- package/src/mcp/smithery-auth.ts +104 -0
- package/src/mcp/smithery-connect.ts +145 -0
- package/src/mcp/smithery-registry.ts +455 -0
- package/src/mcp/types.ts +140 -0
- package/src/modes/components/footer.ts +10 -4
- package/src/modes/components/settings-defs.ts +15 -1
- package/src/modes/components/status-line/git-utils.ts +42 -0
- package/src/modes/components/status-line/presets.ts +6 -6
- package/src/modes/components/status-line/segments.ts +27 -4
- package/src/modes/components/status-line/types.ts +2 -0
- package/src/modes/components/status-line-segment-editor.ts +1 -0
- package/src/modes/components/status-line.ts +109 -5
- package/src/modes/controllers/command-controller.ts +12 -2
- package/src/modes/controllers/extension-ui-controller.ts +12 -21
- package/src/modes/controllers/mcp-command-controller.ts +577 -14
- package/src/modes/controllers/selector-controller.ts +5 -0
- package/src/modes/theme/theme.ts +6 -0
- package/src/prompts/tools/hashline.md +4 -3
- package/src/sdk.ts +115 -3
- package/src/session/agent-session.ts +19 -4
- package/src/session/session-manager.ts +17 -5
- package/src/slash-commands/builtin-registry.ts +10 -0
- package/src/task/executor.ts +37 -3
- package/src/task/index.ts +37 -5
- package/src/task/isolation-backend.ts +72 -0
- package/src/task/render.ts +6 -1
- package/src/task/types.ts +1 -0
- package/src/task/worktree.ts +67 -5
- package/src/tools/index.ts +1 -1
- package/src/tools/path-utils.ts +2 -1
- package/src/tools/read.ts +3 -7
- package/src/utils/open.ts +1 -1
|
@@ -17,6 +17,21 @@ import {
|
|
|
17
17
|
updateMCPServer,
|
|
18
18
|
} from "../../mcp/config-writer";
|
|
19
19
|
import { MCPOAuthFlow } from "../../mcp/oauth-flow";
|
|
20
|
+
import {
|
|
21
|
+
clearSmitheryApiKey,
|
|
22
|
+
createSmitheryCliAuthSession,
|
|
23
|
+
getSmitheryApiKey,
|
|
24
|
+
getSmitheryLoginUrl,
|
|
25
|
+
pollSmitheryCliAuthSession,
|
|
26
|
+
saveSmitheryApiKey,
|
|
27
|
+
} from "../../mcp/smithery-auth";
|
|
28
|
+
import { SmitheryConnectError } from "../../mcp/smithery-connect";
|
|
29
|
+
import {
|
|
30
|
+
SmitheryRegistryError,
|
|
31
|
+
type SmitherySearchResult,
|
|
32
|
+
searchSmitheryRegistry,
|
|
33
|
+
toConfigName,
|
|
34
|
+
} from "../../mcp/smithery-registry";
|
|
20
35
|
import type { MCPServerConfig, MCPServerConnection } from "../../mcp/types";
|
|
21
36
|
import type { OAuthCredential } from "../../session/auth-storage";
|
|
22
37
|
import { shortenPath } from "../../tools/render-utils";
|
|
@@ -45,6 +60,14 @@ type MCPAddParsed = {
|
|
|
45
60
|
error?: string;
|
|
46
61
|
};
|
|
47
62
|
|
|
63
|
+
type MCPSearchParsed = {
|
|
64
|
+
keyword: string;
|
|
65
|
+
scope: MCPAddScope;
|
|
66
|
+
limit: number;
|
|
67
|
+
semantic: boolean;
|
|
68
|
+
error?: string;
|
|
69
|
+
};
|
|
70
|
+
|
|
48
71
|
export class MCPCommandController {
|
|
49
72
|
constructor(private ctx: InteractiveModeContext) {}
|
|
50
73
|
|
|
@@ -86,6 +109,24 @@ export class MCPCommandController {
|
|
|
86
109
|
case "disable":
|
|
87
110
|
await this.#handleSetEnabled(parts[2], false);
|
|
88
111
|
break;
|
|
112
|
+
case "resources":
|
|
113
|
+
await this.#handleResources();
|
|
114
|
+
break;
|
|
115
|
+
case "prompts":
|
|
116
|
+
await this.#handlePrompts();
|
|
117
|
+
break;
|
|
118
|
+
case "notifications":
|
|
119
|
+
await this.#handleNotifications();
|
|
120
|
+
break;
|
|
121
|
+
case "smithery-search":
|
|
122
|
+
await this.#handleSearch(text);
|
|
123
|
+
break;
|
|
124
|
+
case "smithery-login":
|
|
125
|
+
await this.#handleSmitheryLogin();
|
|
126
|
+
break;
|
|
127
|
+
case "smithery-logout":
|
|
128
|
+
await this.#handleSmitheryLogout();
|
|
129
|
+
break;
|
|
89
130
|
case "reload":
|
|
90
131
|
await this.#handleReload();
|
|
91
132
|
break;
|
|
@@ -114,7 +155,14 @@ export class MCPCommandController {
|
|
|
114
155
|
" /mcp unauth <name> Remove OAuth auth from an MCP server",
|
|
115
156
|
" /mcp enable <name> Enable an MCP server",
|
|
116
157
|
" /mcp disable <name> Disable an MCP server",
|
|
158
|
+
" /mcp smithery-search <keyword> [--scope project|user] [--limit <1-100>] [--semantic]",
|
|
159
|
+
" Search Smithery registry and deploy from picker",
|
|
160
|
+
" /mcp smithery-login Login to Smithery and cache API key",
|
|
161
|
+
" /mcp smithery-logout Remove cached Smithery API key",
|
|
117
162
|
" /mcp reload Force reload and rediscover MCP runtime tools",
|
|
163
|
+
" /mcp resources List available resources from connected servers",
|
|
164
|
+
" /mcp prompts List available prompts from connected servers",
|
|
165
|
+
" /mcp notifications Show notification capabilities and subscription state",
|
|
118
166
|
" /mcp help Show this help message",
|
|
119
167
|
"",
|
|
120
168
|
].join("\n");
|
|
@@ -235,6 +283,79 @@ export class MCPCommandController {
|
|
|
235
283
|
};
|
|
236
284
|
}
|
|
237
285
|
|
|
286
|
+
#parseSearchCommand(text: string): MCPSearchParsed {
|
|
287
|
+
const prefixMatch = text.match(/^\/mcp\s+smithery-search\b\s*(.*)$/i);
|
|
288
|
+
const rest = prefixMatch?.[1]?.trim() ?? "";
|
|
289
|
+
const tokens = parseCommandArgs(rest);
|
|
290
|
+
if (tokens.length === 0) {
|
|
291
|
+
return {
|
|
292
|
+
keyword: "",
|
|
293
|
+
scope: "project",
|
|
294
|
+
limit: 20,
|
|
295
|
+
semantic: false,
|
|
296
|
+
error: "Keyword required. Usage: /mcp smithery-search <keyword> [--scope project|user] [--limit <1-100>] [--semantic]",
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const keywordParts: string[] = [];
|
|
301
|
+
let scope: MCPAddScope = "project";
|
|
302
|
+
let limit = 20;
|
|
303
|
+
let semantic = false;
|
|
304
|
+
|
|
305
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
306
|
+
const token = tokens[i];
|
|
307
|
+
if (token === "--scope") {
|
|
308
|
+
const value = tokens[i + 1];
|
|
309
|
+
if (!value || (value !== "project" && value !== "user")) {
|
|
310
|
+
return { keyword: "", scope, limit, semantic, error: "Invalid --scope value. Use project or user." };
|
|
311
|
+
}
|
|
312
|
+
scope = value;
|
|
313
|
+
i++;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
if (token === "--limit") {
|
|
317
|
+
const value = tokens[i + 1];
|
|
318
|
+
if (!value) {
|
|
319
|
+
return { keyword: "", scope, limit, semantic, error: "Missing value for --limit." };
|
|
320
|
+
}
|
|
321
|
+
const parsed = Number(value);
|
|
322
|
+
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 100) {
|
|
323
|
+
return {
|
|
324
|
+
keyword: "",
|
|
325
|
+
scope,
|
|
326
|
+
limit,
|
|
327
|
+
semantic,
|
|
328
|
+
error: "Invalid --limit value. Use an integer between 1 and 100.",
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
limit = parsed;
|
|
332
|
+
i++;
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (token === "--semantic") {
|
|
336
|
+
semantic = true;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
if (token.startsWith("--")) {
|
|
340
|
+
return { keyword: "", scope, limit, semantic, error: `Unknown option: ${token}` };
|
|
341
|
+
}
|
|
342
|
+
keywordParts.push(token);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const keyword = keywordParts.join(" ").trim();
|
|
346
|
+
if (!keyword) {
|
|
347
|
+
return {
|
|
348
|
+
keyword: "",
|
|
349
|
+
scope,
|
|
350
|
+
limit,
|
|
351
|
+
semantic,
|
|
352
|
+
error: "Keyword required. Usage: /mcp smithery-search <keyword> [--scope project|user] [--limit <1-100>] [--semantic]",
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return { keyword, scope, limit, semantic };
|
|
357
|
+
}
|
|
358
|
+
|
|
238
359
|
/**
|
|
239
360
|
* Handle /mcp add - Launch interactive wizard or quick-add from args
|
|
240
361
|
*/
|
|
@@ -405,8 +526,6 @@ export class MCPCommandController {
|
|
|
405
526
|
new Text(theme.fg("accent", "โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ"), 1, 0),
|
|
406
527
|
);
|
|
407
528
|
this.ctx.ui.requestRender();
|
|
408
|
-
const isWindows = process.platform === "win32";
|
|
409
|
-
|
|
410
529
|
// Try to open browser automatically
|
|
411
530
|
try {
|
|
412
531
|
openPath(info.url);
|
|
@@ -424,12 +543,6 @@ export class MCPCommandController {
|
|
|
424
543
|
new Text(theme.fg("success", "Copy this exact URL in your browser:"), 1, 0),
|
|
425
544
|
);
|
|
426
545
|
this.ctx.chatContainer.addChild(new Text(theme.fg("accent", info.url), 1, 0));
|
|
427
|
-
if (isWindows) {
|
|
428
|
-
const openCmd = `rundll32.exe url.dll,FileProtocolHandler "${info.url.replace(/"/g, '""')}"`;
|
|
429
|
-
this.ctx.chatContainer.addChild(new Spacer(1));
|
|
430
|
-
this.ctx.chatContainer.addChild(new Text("Windows manual open command:", 1, 0));
|
|
431
|
-
this.ctx.chatContainer.addChild(new Text(openCmd, 1, 0));
|
|
432
|
-
}
|
|
433
546
|
this.ctx.ui.requestRender();
|
|
434
547
|
} catch (_error) {
|
|
435
548
|
// Show error if browser doesn't open
|
|
@@ -441,12 +554,6 @@ export class MCPCommandController {
|
|
|
441
554
|
new Text(theme.fg("success", "Copy this exact URL in your browser:"), 1, 0),
|
|
442
555
|
);
|
|
443
556
|
this.ctx.chatContainer.addChild(new Text(theme.fg("accent", info.url), 1, 0));
|
|
444
|
-
if (isWindows) {
|
|
445
|
-
const openCmd = `rundll32.exe url.dll,FileProtocolHandler "${info.url.replace(/"/g, '""')}"`;
|
|
446
|
-
this.ctx.chatContainer.addChild(new Spacer(1));
|
|
447
|
-
this.ctx.chatContainer.addChild(new Text("Windows manual open command:", 1, 0));
|
|
448
|
-
this.ctx.chatContainer.addChild(new Text(openCmd, 1, 0));
|
|
449
|
-
}
|
|
450
557
|
this.ctx.ui.requestRender();
|
|
451
558
|
}
|
|
452
559
|
},
|
|
@@ -1276,6 +1383,462 @@ export class MCPCommandController {
|
|
|
1276
1383
|
}
|
|
1277
1384
|
}
|
|
1278
1385
|
|
|
1386
|
+
/**
|
|
1387
|
+
* Handle /mcp resources - Show available resources from connected servers
|
|
1388
|
+
*/
|
|
1389
|
+
async #handleResources(): Promise<void> {
|
|
1390
|
+
if (!this.ctx.mcpManager) {
|
|
1391
|
+
this.ctx.showError("No MCP manager available.");
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
const servers = this.ctx.mcpManager.getConnectedServers();
|
|
1396
|
+
const lines: string[] = ["", theme.bold("MCP Resources"), ""];
|
|
1397
|
+
let hasAny = false;
|
|
1398
|
+
|
|
1399
|
+
for (const name of servers) {
|
|
1400
|
+
const data = this.ctx.mcpManager.getServerResources(name);
|
|
1401
|
+
if (!data) continue;
|
|
1402
|
+
const { resources, templates } = data;
|
|
1403
|
+
if (resources.length === 0 && templates.length === 0) continue;
|
|
1404
|
+
hasAny = true;
|
|
1405
|
+
|
|
1406
|
+
lines.push(`${theme.fg("accent", name)}:`);
|
|
1407
|
+
for (const r of resources) {
|
|
1408
|
+
const desc = r.description ? ` ${theme.fg("dim", r.description)}` : "";
|
|
1409
|
+
const mime = r.mimeType ? ` ${theme.fg("dim", `[${r.mimeType}]`)}` : "";
|
|
1410
|
+
lines.push(` ${theme.fg("success", r.uri)}${mime}${desc}`);
|
|
1411
|
+
}
|
|
1412
|
+
if (templates.length > 0) {
|
|
1413
|
+
lines.push(` ${theme.fg("muted", "Templates:")}`);
|
|
1414
|
+
for (const t of templates) {
|
|
1415
|
+
const desc = t.description ? ` ${theme.fg("dim", t.description)}` : "";
|
|
1416
|
+
lines.push(` ${theme.fg("accent", t.uriTemplate)}${desc}`);
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
lines.push("");
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
if (!hasAny) {
|
|
1423
|
+
lines.push(theme.fg("muted", "No resources available on connected servers."));
|
|
1424
|
+
lines.push("");
|
|
1425
|
+
}
|
|
1426
|
+
this.#showMessage(lines.join("\n"));
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
/**
|
|
1430
|
+
* Handle /mcp prompts - Show available prompts from connected servers
|
|
1431
|
+
*/
|
|
1432
|
+
async #handlePrompts(): Promise<void> {
|
|
1433
|
+
if (!this.ctx.mcpManager) {
|
|
1434
|
+
this.ctx.showError("No MCP manager available.");
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
const servers = this.ctx.mcpManager.getConnectedServers();
|
|
1439
|
+
const lines: string[] = ["", theme.bold("MCP Prompts"), ""];
|
|
1440
|
+
let hasAny = false;
|
|
1441
|
+
|
|
1442
|
+
for (const name of servers) {
|
|
1443
|
+
const prompts = this.ctx.mcpManager.getServerPrompts(name);
|
|
1444
|
+
if (!prompts?.length) continue;
|
|
1445
|
+
hasAny = true;
|
|
1446
|
+
|
|
1447
|
+
lines.push(`${theme.fg("accent", name)}:`);
|
|
1448
|
+
for (const p of prompts) {
|
|
1449
|
+
const commandName = `${name}:${p.name}`;
|
|
1450
|
+
const desc = p.description ? ` ${theme.fg("dim", p.description)}` : "";
|
|
1451
|
+
lines.push(` ${theme.fg("success", `/${commandName}`)}${desc}`);
|
|
1452
|
+
if (p.arguments?.length) {
|
|
1453
|
+
for (const arg of p.arguments) {
|
|
1454
|
+
const required = arg.required ? theme.fg("warning", " *") : "";
|
|
1455
|
+
const argDesc = arg.description ? ` - ${arg.description}` : "";
|
|
1456
|
+
lines.push(` ${arg.name}=${required}${theme.fg("dim", argDesc)}`);
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
lines.push("");
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
if (!hasAny) {
|
|
1464
|
+
lines.push(theme.fg("muted", "No prompts available on connected servers."));
|
|
1465
|
+
lines.push("");
|
|
1466
|
+
}
|
|
1467
|
+
this.#showMessage(lines.join("\n"));
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
/**
|
|
1471
|
+
* Handle /mcp notifications - Show notification and subscription state
|
|
1472
|
+
*/
|
|
1473
|
+
async #handleNotifications(): Promise<void> {
|
|
1474
|
+
if (!this.ctx.mcpManager) {
|
|
1475
|
+
this.ctx.showError("No MCP manager available.");
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
const { enabled, subscriptions } = this.ctx.mcpManager.getNotificationState();
|
|
1480
|
+
const servers = this.ctx.mcpManager.getConnectedServers();
|
|
1481
|
+
const statusIcon = enabled ? theme.fg("success", "enabled") : theme.fg("warning", "disabled");
|
|
1482
|
+
const lines: string[] = ["", theme.bold("MCP Notifications"), ""];
|
|
1483
|
+
lines.push(` Status: ${statusIcon} ${theme.fg("dim", "(mcp.notifications setting)")}`);
|
|
1484
|
+
lines.push("");
|
|
1485
|
+
|
|
1486
|
+
let hasAny = false;
|
|
1487
|
+
for (const name of servers) {
|
|
1488
|
+
const connection = this.ctx.mcpManager.getConnection(name);
|
|
1489
|
+
if (!connection) continue;
|
|
1490
|
+
const caps = connection.capabilities;
|
|
1491
|
+
const supportsResources = caps.resources !== undefined;
|
|
1492
|
+
const supportsSubscribe = caps.resources?.subscribe === true;
|
|
1493
|
+
const supportsToolsChanged = caps.tools?.listChanged === true;
|
|
1494
|
+
const supportsPromptsChanged = caps.prompts?.listChanged === true;
|
|
1495
|
+
const supportsResourcesChanged = caps.resources?.listChanged === true;
|
|
1496
|
+
|
|
1497
|
+
const hasNotifications =
|
|
1498
|
+
supportsToolsChanged || supportsPromptsChanged || supportsResourcesChanged || supportsSubscribe;
|
|
1499
|
+
if (!hasNotifications) continue;
|
|
1500
|
+
hasAny = true;
|
|
1501
|
+
|
|
1502
|
+
lines.push(`${theme.fg("accent", name)}:`);
|
|
1503
|
+
const check = theme.fg("success", "\u2713");
|
|
1504
|
+
const cross = theme.fg("dim", "\u2717");
|
|
1505
|
+
if (supportsToolsChanged) lines.push(` ${check} tools/list_changed`);
|
|
1506
|
+
if (supportsResourcesChanged) lines.push(` ${check} resources/list_changed`);
|
|
1507
|
+
if (supportsPromptsChanged) lines.push(` ${check} prompts/list_changed`);
|
|
1508
|
+
|
|
1509
|
+
if (supportsSubscribe) {
|
|
1510
|
+
const subscribedUris = subscriptions.get(name);
|
|
1511
|
+
const subCount = subscribedUris?.size ?? 0;
|
|
1512
|
+
const subStatus =
|
|
1513
|
+
enabled && subCount > 0
|
|
1514
|
+
? theme.fg("success", `subscribed (${subCount} URI${subCount !== 1 ? "s" : ""})`)
|
|
1515
|
+
: enabled
|
|
1516
|
+
? theme.fg("muted", "no active subscriptions")
|
|
1517
|
+
: theme.fg("dim", "inactive (notifications disabled)");
|
|
1518
|
+
lines.push(` ${check} resources/subscribe ${subStatus}`);
|
|
1519
|
+
if (enabled && subscribedUris && subscribedUris.size > 0) {
|
|
1520
|
+
for (const uri of subscribedUris) {
|
|
1521
|
+
lines.push(` ${theme.fg("success", "\u2713")} ${theme.fg("dim", uri)}`);
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
} else if (supportsResources) {
|
|
1525
|
+
lines.push(` ${cross} resources/subscribe ${theme.fg("dim", "not supported")}`);
|
|
1526
|
+
}
|
|
1527
|
+
lines.push("");
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
if (!hasAny) {
|
|
1531
|
+
lines.push(theme.fg("muted", "No servers support notifications."));
|
|
1532
|
+
lines.push("");
|
|
1533
|
+
}
|
|
1534
|
+
this.#showMessage(lines.join("\n"));
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
async #validateSmitheryApiKey(apiKey: string): Promise<void> {
|
|
1538
|
+
await searchSmitheryRegistry("mcp", { limit: 1, apiKey });
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
async #promptSmitheryApiKey(promptLabel: string): Promise<string | null> {
|
|
1542
|
+
for (;;) {
|
|
1543
|
+
const input = await this.ctx.showHookInput(promptLabel);
|
|
1544
|
+
if (input === undefined) return null;
|
|
1545
|
+
const apiKey = input.trim();
|
|
1546
|
+
if (!apiKey) {
|
|
1547
|
+
this.ctx.showError("Smithery API key cannot be empty.");
|
|
1548
|
+
continue;
|
|
1549
|
+
}
|
|
1550
|
+
try {
|
|
1551
|
+
await this.#validateSmitheryApiKey(apiKey);
|
|
1552
|
+
return apiKey;
|
|
1553
|
+
} catch (error) {
|
|
1554
|
+
this.ctx.showError(
|
|
1555
|
+
`Smithery API key validation failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
1556
|
+
);
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
async #handleSmitheryLoginWithApiKey(): Promise<boolean> {
|
|
1562
|
+
const apiKey = await this.#promptSmitheryApiKey("Smithery API key (Esc to cancel)");
|
|
1563
|
+
if (!apiKey) return false;
|
|
1564
|
+
await saveSmitheryApiKey(apiKey);
|
|
1565
|
+
this.ctx.showStatus("Smithery API key saved.");
|
|
1566
|
+
return true;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
async #waitForSmitheryCliApiKey(sessionId: string, signal: AbortSignal): Promise<string> {
|
|
1570
|
+
const pollIntervalMs = 2_000;
|
|
1571
|
+
const timeoutMs = 300_000;
|
|
1572
|
+
const startedAt = Date.now();
|
|
1573
|
+
|
|
1574
|
+
while (!signal.aborted) {
|
|
1575
|
+
if (Date.now() - startedAt >= timeoutMs) {
|
|
1576
|
+
throw new Error("Smithery authorization timed out after 5 minutes.");
|
|
1577
|
+
}
|
|
1578
|
+
const response = await pollSmitheryCliAuthSession(sessionId, signal);
|
|
1579
|
+
if (response.status === "success" && response.apiKey) {
|
|
1580
|
+
return response.apiKey;
|
|
1581
|
+
}
|
|
1582
|
+
if (response.status === "error") {
|
|
1583
|
+
throw new Error(response.message ?? "Smithery authorization failed.");
|
|
1584
|
+
}
|
|
1585
|
+
await Bun.sleep(pollIntervalMs);
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
throw new Error("Smithery authorization cancelled.");
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
async #handleSmitheryBrowserLogin(): Promise<boolean> {
|
|
1592
|
+
const session = await createSmitheryCliAuthSession();
|
|
1593
|
+
const fallbackLoginUrl = getSmitheryLoginUrl();
|
|
1594
|
+
this.#showMessage(
|
|
1595
|
+
[
|
|
1596
|
+
"",
|
|
1597
|
+
theme.bold("Smithery Login"),
|
|
1598
|
+
theme.fg("muted", "Browser authorization started. Complete auth in your browser."),
|
|
1599
|
+
theme.fg("dim", "Authorize URL:"),
|
|
1600
|
+
theme.fg("accent", session.authUrl),
|
|
1601
|
+
theme.fg("dim", `Fallback: ${fallbackLoginUrl}`),
|
|
1602
|
+
"",
|
|
1603
|
+
].join("\n"),
|
|
1604
|
+
);
|
|
1605
|
+
try {
|
|
1606
|
+
openPath(session.authUrl);
|
|
1607
|
+
} catch {
|
|
1608
|
+
// URL is already shown above.
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
const apiKey = await this.#waitForSmitheryCliApiKey(session.sessionId, new AbortController().signal);
|
|
1612
|
+
await this.#validateSmitheryApiKey(apiKey);
|
|
1613
|
+
await saveSmitheryApiKey(apiKey);
|
|
1614
|
+
this.ctx.showStatus("Smithery API key saved.");
|
|
1615
|
+
return true;
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
async #promptSmitheryLogin(reason: string): Promise<boolean> {
|
|
1619
|
+
this.#showMessage(
|
|
1620
|
+
[
|
|
1621
|
+
"",
|
|
1622
|
+
theme.fg("muted", `Smithery authentication required (${reason}).`),
|
|
1623
|
+
theme.fg("muted", "If browser auth fails, you can paste an API key."),
|
|
1624
|
+
"",
|
|
1625
|
+
].join("\n"),
|
|
1626
|
+
);
|
|
1627
|
+
try {
|
|
1628
|
+
return await this.#handleSmitheryBrowserLogin();
|
|
1629
|
+
} catch (error) {
|
|
1630
|
+
this.ctx.showWarning(
|
|
1631
|
+
`Browser authorization failed: ${error instanceof Error ? error.message : String(error)}. Falling back to API key.`,
|
|
1632
|
+
);
|
|
1633
|
+
return await this.#handleSmitheryLoginWithApiKey();
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
#getSmitheryErrorStatus(error: unknown): number | undefined {
|
|
1638
|
+
if (error instanceof SmitheryRegistryError || error instanceof SmitheryConnectError) {
|
|
1639
|
+
return error.status;
|
|
1640
|
+
}
|
|
1641
|
+
return undefined;
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
#toSmitheryAuthReason(status: number): string {
|
|
1645
|
+
return status === 429 ? "rate limited by Smithery" : "forbidden/unauthorized with Smithery";
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
async #requireSmitheryApiKey(reason: string): Promise<string> {
|
|
1649
|
+
let apiKey = await getSmitheryApiKey();
|
|
1650
|
+
if (apiKey) return apiKey;
|
|
1651
|
+
|
|
1652
|
+
const loggedIn = await this.#promptSmitheryLogin(reason);
|
|
1653
|
+
if (!loggedIn) {
|
|
1654
|
+
throw new Error("Smithery login cancelled. Run /mcp smithery-login, then retry /mcp smithery-search.");
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
apiKey = await getSmitheryApiKey();
|
|
1658
|
+
if (!apiKey) {
|
|
1659
|
+
throw new Error("Smithery API key not found after login.");
|
|
1660
|
+
}
|
|
1661
|
+
return apiKey;
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
async #runSmitheryOperationWithAuthRetry<T>(operation: (apiKey: string) => Promise<T>, reason: string): Promise<T> {
|
|
1665
|
+
const apiKey = await this.#requireSmitheryApiKey(reason);
|
|
1666
|
+
try {
|
|
1667
|
+
return await operation(apiKey);
|
|
1668
|
+
} catch (error) {
|
|
1669
|
+
const status = this.#getSmitheryErrorStatus(error);
|
|
1670
|
+
if (status === undefined || ![401, 403, 429].includes(status)) {
|
|
1671
|
+
throw error;
|
|
1672
|
+
}
|
|
1673
|
+
const loggedIn = await this.#promptSmitheryLogin(this.#toSmitheryAuthReason(status));
|
|
1674
|
+
if (!loggedIn) {
|
|
1675
|
+
throw error;
|
|
1676
|
+
}
|
|
1677
|
+
const retryApiKey = await this.#requireSmitheryApiKey(reason);
|
|
1678
|
+
return await operation(retryApiKey);
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
async #handleSmitheryLogin(): Promise<void> {
|
|
1683
|
+
const ok = await this.#promptSmitheryLogin("login");
|
|
1684
|
+
if (!ok) {
|
|
1685
|
+
this.ctx.showStatus("Smithery login cancelled.");
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
async #handleSmitheryLogout(): Promise<void> {
|
|
1690
|
+
const removed = await clearSmitheryApiKey();
|
|
1691
|
+
this.ctx.showStatus(removed ? "Smithery API key removed." : "No cached Smithery API key found.");
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
async #nextAvailableServerName(scope: MCPAddScope, baseName: string): Promise<string> {
|
|
1695
|
+
const filePath = getMCPConfigPath(scope, getProjectDir());
|
|
1696
|
+
const config = await readMCPConfigFile(filePath);
|
|
1697
|
+
const existingNames = new Set(Object.keys(config.mcpServers ?? {}));
|
|
1698
|
+
if (!existingNames.has(baseName)) return baseName;
|
|
1699
|
+
for (let i = 2; i <= 999; i++) {
|
|
1700
|
+
const candidate = `${baseName}-${i}`;
|
|
1701
|
+
if (!existingNames.has(candidate)) return candidate;
|
|
1702
|
+
}
|
|
1703
|
+
return `${baseName}-${Date.now()}`;
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
async #promptDeploymentServerName(scope: MCPAddScope, defaultName: string): Promise<string | null> {
|
|
1707
|
+
for (;;) {
|
|
1708
|
+
const input = await this.ctx.showHookInput(`Server name for deploy (default: ${defaultName})`, defaultName);
|
|
1709
|
+
if (input === undefined) return null;
|
|
1710
|
+
const proposed = input.trim() || defaultName;
|
|
1711
|
+
if (!proposed) {
|
|
1712
|
+
this.ctx.showError("Server name cannot be empty.");
|
|
1713
|
+
continue;
|
|
1714
|
+
}
|
|
1715
|
+
const filePath = getMCPConfigPath(scope, getProjectDir());
|
|
1716
|
+
const config = await readMCPConfigFile(filePath);
|
|
1717
|
+
if (config.mcpServers?.[proposed]) {
|
|
1718
|
+
this.ctx.showError(`Server "${proposed}" already exists in ${scope} config.`);
|
|
1719
|
+
continue;
|
|
1720
|
+
}
|
|
1721
|
+
return proposed;
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
async #promptRequiredRegistryInputs(result: SmitherySearchResult): Promise<Record<string, string> | null> {
|
|
1726
|
+
const values: Record<string, string> = {};
|
|
1727
|
+
for (const input of result.requiredInputs) {
|
|
1728
|
+
const label = input.required ? `${input.key} (required)` : `${input.key} (optional)`;
|
|
1729
|
+
const prompt = `${label}${input.description ? ` - ${input.description}` : ""}`;
|
|
1730
|
+
const userInput = await this.ctx.showHookInput(prompt, input.defaultValue);
|
|
1731
|
+
if (userInput === undefined) {
|
|
1732
|
+
if (input.required) return null;
|
|
1733
|
+
continue;
|
|
1734
|
+
}
|
|
1735
|
+
const value = userInput.trim();
|
|
1736
|
+
if (!value) {
|
|
1737
|
+
if (input.required) {
|
|
1738
|
+
this.ctx.showError(`Missing required value for "${input.key}".`);
|
|
1739
|
+
return null;
|
|
1740
|
+
}
|
|
1741
|
+
continue;
|
|
1742
|
+
}
|
|
1743
|
+
values[input.key] = value;
|
|
1744
|
+
}
|
|
1745
|
+
return values;
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
#applyRegistryInputOverrides(config: MCPServerConfig, values: Record<string, string>): MCPServerConfig {
|
|
1749
|
+
if (Object.keys(values).length === 0) return config;
|
|
1750
|
+
if (config.type !== "stdio") {
|
|
1751
|
+
return config;
|
|
1752
|
+
}
|
|
1753
|
+
const args = [...(config.args ?? [])];
|
|
1754
|
+
const configJson = JSON.stringify(values);
|
|
1755
|
+
const index = args.indexOf("--config");
|
|
1756
|
+
if (index >= 0) {
|
|
1757
|
+
if (index + 1 < args.length) {
|
|
1758
|
+
args[index + 1] = configJson;
|
|
1759
|
+
} else {
|
|
1760
|
+
args.push(configJson);
|
|
1761
|
+
}
|
|
1762
|
+
} else {
|
|
1763
|
+
args.push("--config", configJson);
|
|
1764
|
+
}
|
|
1765
|
+
return { ...config, args };
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
async #pickRegistryResult(results: SmitherySearchResult[], keyword: string): Promise<SmitherySearchResult | null> {
|
|
1769
|
+
const options = results.map((result, index) => {
|
|
1770
|
+
const label = `${index + 1}. ${result.display.displayName} (${result.display.transport}, uses ${result.display.useCount})`;
|
|
1771
|
+
return label.length > 120 ? `${label.slice(0, 117)}...` : label;
|
|
1772
|
+
});
|
|
1773
|
+
const selected = await this.ctx.showHookSelector(`Registry results for "${keyword}"`, options);
|
|
1774
|
+
if (!selected) return null;
|
|
1775
|
+
const prefix = selected.split(".", 1)[0];
|
|
1776
|
+
const index = Number(prefix) - 1;
|
|
1777
|
+
if (!Number.isInteger(index) || index < 0 || index >= results.length) return null;
|
|
1778
|
+
return results[index] ?? null;
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
async #deployRegistryResult(result: SmitherySearchResult, scope: MCPAddScope): Promise<void> {
|
|
1782
|
+
const baseName = toConfigName(result.name);
|
|
1783
|
+
const defaultName = await this.#nextAvailableServerName(scope, baseName);
|
|
1784
|
+
const serverName = await this.#promptDeploymentServerName(scope, defaultName);
|
|
1785
|
+
if (!serverName) {
|
|
1786
|
+
this.ctx.showStatus("MCP deploy cancelled.");
|
|
1787
|
+
return;
|
|
1788
|
+
}
|
|
1789
|
+
const inputValues = await this.#promptRequiredRegistryInputs(result);
|
|
1790
|
+
if (inputValues === null) {
|
|
1791
|
+
this.ctx.showStatus("MCP deploy cancelled.");
|
|
1792
|
+
return;
|
|
1793
|
+
}
|
|
1794
|
+
const config = this.#applyRegistryInputOverrides(result.config, inputValues);
|
|
1795
|
+
await this.#handleWizardComplete(serverName, config, scope);
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
async #handleSearch(text: string): Promise<void> {
|
|
1799
|
+
const parsed = this.#parseSearchCommand(text);
|
|
1800
|
+
if (parsed.error) {
|
|
1801
|
+
this.ctx.showError(parsed.error);
|
|
1802
|
+
return;
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
try {
|
|
1806
|
+
this.#showMessage(
|
|
1807
|
+
["", theme.fg("muted", `Searching Smithery registry for "${parsed.keyword}"...`), ""].join("\n"),
|
|
1808
|
+
);
|
|
1809
|
+
const results = await this.#runSmitheryOperationWithAuthRetry(
|
|
1810
|
+
apiKey =>
|
|
1811
|
+
searchSmitheryRegistry(parsed.keyword, {
|
|
1812
|
+
limit: parsed.limit,
|
|
1813
|
+
apiKey,
|
|
1814
|
+
includeSemantic: parsed.semantic,
|
|
1815
|
+
}),
|
|
1816
|
+
"required for smithery-search",
|
|
1817
|
+
);
|
|
1818
|
+
if (results.length === 0) {
|
|
1819
|
+
this.#showMessage(
|
|
1820
|
+
["", theme.fg("warning", `No Smithery results found for "${parsed.keyword}".`), ""].join("\n"),
|
|
1821
|
+
);
|
|
1822
|
+
return;
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
const selected = await this.#pickRegistryResult(results, parsed.keyword);
|
|
1826
|
+
if (!selected) {
|
|
1827
|
+
this.ctx.showStatus("MCP Smithery selection cancelled.");
|
|
1828
|
+
return;
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
await this.#deployRegistryResult(selected, parsed.scope);
|
|
1832
|
+
} catch (error) {
|
|
1833
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1834
|
+
if (/authentication was cancelled|login cancelled/i.test(message)) {
|
|
1835
|
+
this.ctx.showError(`${message} Run /mcp smithery-login to authenticate first.`);
|
|
1836
|
+
return;
|
|
1837
|
+
}
|
|
1838
|
+
this.ctx.showError(`Smithery search failed: ${message}`);
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1279
1842
|
/**
|
|
1280
1843
|
* Show a message in the chat
|
|
1281
1844
|
*/
|
|
@@ -351,6 +351,11 @@ export class SelectorController {
|
|
|
351
351
|
setPreferredImageProvider(value as "auto" | "gemini" | "openrouter");
|
|
352
352
|
break;
|
|
353
353
|
|
|
354
|
+
// MCP update injection - live subscribe/unsubscribe
|
|
355
|
+
case "mcp.notifications":
|
|
356
|
+
this.ctx.mcpManager?.setNotificationsEnabled(value as boolean);
|
|
357
|
+
break;
|
|
358
|
+
|
|
354
359
|
// All other settings are handled by the definitions (get/set on SettingsManager)
|
|
355
360
|
// No additional side effects needed
|
|
356
361
|
}
|
package/src/modes/theme/theme.ts
CHANGED
|
@@ -91,6 +91,7 @@ export type SymbolKey =
|
|
|
91
91
|
| "icon.file"
|
|
92
92
|
| "icon.git"
|
|
93
93
|
| "icon.branch"
|
|
94
|
+
| "icon.pr"
|
|
94
95
|
| "icon.tokens"
|
|
95
96
|
| "icon.context"
|
|
96
97
|
| "icon.cost"
|
|
@@ -250,6 +251,7 @@ const UNICODE_SYMBOLS: SymbolMap = {
|
|
|
250
251
|
"icon.file": "๐",
|
|
251
252
|
"icon.git": "โ",
|
|
252
253
|
"icon.branch": "โ",
|
|
254
|
+
"icon.pr": "โคด",
|
|
253
255
|
"icon.tokens": "๐ช",
|
|
254
256
|
"icon.context": "โซ",
|
|
255
257
|
"icon.cost": "๐ฒ",
|
|
@@ -464,6 +466,8 @@ const NERD_SYMBOLS: SymbolMap = {
|
|
|
464
466
|
"icon.git": "\uf1d3",
|
|
465
467
|
// pick: ๏ฆ | alt: ๏ โ
|
|
466
468
|
"icon.branch": "\uf126",
|
|
469
|
+
// pick: ๎ฉค (nf-cod-git_pull_request) | alt: (nf-oct-git_pull_request)
|
|
470
|
+
"icon.pr": "\uea64",
|
|
467
471
|
// pick: ๎ซ | alt: โ โ ๏
|
|
468
472
|
"icon.tokens": "\ue26b",
|
|
469
473
|
// pick: ๎ | alt: โซ โฆ
|
|
@@ -659,6 +663,7 @@ const ASCII_SYMBOLS: SymbolMap = {
|
|
|
659
663
|
"icon.file": "[F]",
|
|
660
664
|
"icon.git": "git:",
|
|
661
665
|
"icon.branch": "@",
|
|
666
|
+
"icon.pr": "PR",
|
|
662
667
|
"icon.tokens": "tok:",
|
|
663
668
|
"icon.context": "ctx:",
|
|
664
669
|
"icon.cost": "$",
|
|
@@ -1359,6 +1364,7 @@ export class Theme {
|
|
|
1359
1364
|
file: this.#symbols["icon.file"],
|
|
1360
1365
|
git: this.#symbols["icon.git"],
|
|
1361
1366
|
branch: this.#symbols["icon.branch"],
|
|
1367
|
+
pr: this.#symbols["icon.pr"],
|
|
1362
1368
|
tokens: this.#symbols["icon.tokens"],
|
|
1363
1369
|
context: this.#symbols["icon.context"],
|
|
1364
1370
|
cost: this.#symbols["icon.cost"],
|