@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +30 -1
  2. package/package.json +7 -7
  3. package/src/cli/args.ts +7 -0
  4. package/src/cli/stats-cli.ts +5 -0
  5. package/src/config/model-registry.ts +99 -9
  6. package/src/config/settings-schema.ts +22 -2
  7. package/src/extensibility/extensions/types.ts +2 -0
  8. package/src/internal-urls/docs-index.generated.ts +2 -2
  9. package/src/internal-urls/index.ts +2 -1
  10. package/src/internal-urls/mcp-protocol.ts +156 -0
  11. package/src/internal-urls/router.ts +1 -1
  12. package/src/internal-urls/types.ts +3 -3
  13. package/src/mcp/client.ts +235 -2
  14. package/src/mcp/index.ts +1 -1
  15. package/src/mcp/manager.ts +399 -5
  16. package/src/mcp/oauth-flow.ts +26 -1
  17. package/src/mcp/smithery-auth.ts +104 -0
  18. package/src/mcp/smithery-connect.ts +145 -0
  19. package/src/mcp/smithery-registry.ts +455 -0
  20. package/src/mcp/types.ts +140 -0
  21. package/src/modes/components/footer.ts +10 -4
  22. package/src/modes/components/settings-defs.ts +15 -1
  23. package/src/modes/components/status-line/git-utils.ts +42 -0
  24. package/src/modes/components/status-line/presets.ts +6 -6
  25. package/src/modes/components/status-line/segments.ts +27 -4
  26. package/src/modes/components/status-line/types.ts +2 -0
  27. package/src/modes/components/status-line-segment-editor.ts +1 -0
  28. package/src/modes/components/status-line.ts +109 -5
  29. package/src/modes/controllers/command-controller.ts +12 -2
  30. package/src/modes/controllers/extension-ui-controller.ts +12 -21
  31. package/src/modes/controllers/mcp-command-controller.ts +577 -14
  32. package/src/modes/controllers/selector-controller.ts +5 -0
  33. package/src/modes/theme/theme.ts +6 -0
  34. package/src/prompts/tools/hashline.md +4 -3
  35. package/src/sdk.ts +115 -3
  36. package/src/session/agent-session.ts +19 -4
  37. package/src/session/session-manager.ts +17 -5
  38. package/src/slash-commands/builtin-registry.ts +10 -0
  39. package/src/task/executor.ts +37 -3
  40. package/src/task/index.ts +37 -5
  41. package/src/task/isolation-backend.ts +72 -0
  42. package/src/task/render.ts +6 -1
  43. package/src/task/types.ts +1 -0
  44. package/src/task/worktree.ts +67 -5
  45. package/src/tools/index.ts +1 -1
  46. package/src/tools/path-utils.ts +2 -1
  47. package/src/tools/read.ts +3 -7
  48. package/src/utils/open.ts +1 -1
@@ -10,12 +10,36 @@ import type { SourceMeta } from "../capability/types";
10
10
  import { resolveConfigValue } from "../config/resolve-config-value";
11
11
  import type { CustomTool } from "../extensibility/custom-tools/types";
12
12
  import type { AuthStorage } from "../session/auth-storage";
13
- import { connectToServer, disconnectServer, listTools } from "./client";
13
+ import {
14
+ connectToServer,
15
+ disconnectServer,
16
+ getPrompt,
17
+ listPrompts,
18
+ listResources,
19
+ listResourceTemplates,
20
+ listTools,
21
+ readResource,
22
+ serverSupportsPrompts,
23
+ serverSupportsResources,
24
+ subscribeToResources,
25
+ unsubscribeFromResources,
26
+ } from "./client";
14
27
  import { loadAllMCPConfigs, validateServerConfig } from "./config";
15
28
  import type { MCPToolDetails } from "./tool-bridge";
16
29
  import { DeferredMCPTool, MCPTool } from "./tool-bridge";
17
30
  import type { MCPToolCache } from "./tool-cache";
18
- import type { MCPServerConfig, MCPServerConnection, MCPToolDefinition } from "./types";
31
+ import type {
32
+ MCPGetPromptResult,
33
+ MCPPrompt,
34
+ MCPRequestOptions,
35
+ MCPResource,
36
+ MCPResourceReadResult,
37
+ MCPResourceTemplate,
38
+ MCPServerConfig,
39
+ MCPServerConnection,
40
+ MCPToolDefinition,
41
+ } from "./types";
42
+ import { MCPNotificationMethods } from "./types";
19
43
 
20
44
  type ToolLoadResult = {
21
45
  connection: MCPServerConnection;
@@ -50,6 +74,15 @@ function delay(ms: number): Promise<void> {
50
74
  return Bun.sleep(ms);
51
75
  }
52
76
 
77
+ export function resolveSubscriptionPostAction(
78
+ notificationsEnabled: boolean,
79
+ currentEpoch: number,
80
+ subscriptionEpoch: number,
81
+ ): "rollback" | "ignore" | "apply" {
82
+ if (!notificationsEnabled) return "rollback";
83
+ if (currentEpoch !== subscriptionEpoch) return "ignore";
84
+ return "apply";
85
+ }
53
86
  /** Result of loading MCP tools */
54
87
  export interface MCPLoadResult {
55
88
  /** Loaded tools as CustomTool instances */
@@ -86,12 +119,108 @@ export class MCPManager {
86
119
  #pendingToolLoads = new Map<string, Promise<ToolLoadResult>>();
87
120
  #sources = new Map<string, SourceMeta>();
88
121
  #authStorage: AuthStorage | null = null;
122
+ #onNotification?: (serverName: string, method: string, params: unknown) => void;
123
+ #onToolsChanged?: (tools: CustomTool<TSchema, MCPToolDetails>[]) => void;
124
+ #onResourcesChanged?: (serverName: string, uri: string) => void;
125
+ #onPromptsChanged?: (serverName: string) => void;
126
+ #notificationsEnabled = false;
127
+ #notificationsEpoch = 0;
128
+ #subscribedResources = new Map<string, Set<string>>();
129
+ #pendingResourceRefresh = new Map<string, Promise<void>>();
89
130
 
90
131
  constructor(
91
132
  private cwd: string,
92
133
  private toolCache: MCPToolCache | null = null,
93
134
  ) {}
94
135
 
136
+ /**
137
+ * Set a callback to receive all server notifications.
138
+ */
139
+ setOnNotification(handler: (serverName: string, method: string, params: unknown) => void): void {
140
+ this.#onNotification = handler;
141
+ }
142
+
143
+ /**
144
+ * Set a callback to fire when any server's tools change.
145
+ */
146
+ setOnToolsChanged(handler: (tools: CustomTool<TSchema, MCPToolDetails>[]) => void): void {
147
+ this.#onToolsChanged = handler;
148
+ }
149
+
150
+ /**
151
+ * Set a callback to fire when any server's resources change.
152
+ */
153
+ setOnResourcesChanged(handler: (serverName: string, uri: string) => void): void {
154
+ this.#onResourcesChanged = handler;
155
+ }
156
+
157
+ /**
158
+ * Set a callback to fire when any server's prompts change.
159
+ */
160
+ setOnPromptsChanged(handler: (serverName: string) => void): void {
161
+ this.#onPromptsChanged = handler;
162
+ // Fire immediately for servers that already have prompts loaded
163
+ for (const [name, connection] of this.#connections) {
164
+ if (connection.prompts?.length) {
165
+ handler(name);
166
+ }
167
+ }
168
+ }
169
+
170
+ setNotificationsEnabled(enabled: boolean): void {
171
+ const wasEnabled = this.#notificationsEnabled;
172
+ this.#notificationsEnabled = enabled;
173
+ if (enabled === wasEnabled) return;
174
+
175
+ this.#notificationsEpoch += 1;
176
+ const notificationEpoch = this.#notificationsEpoch;
177
+
178
+ if (enabled) {
179
+ // Subscribe to all connected servers that support it
180
+ for (const [name, connection] of this.#connections) {
181
+ if (connection.capabilities.resources?.subscribe && connection.resources) {
182
+ const uris = connection.resources.map(r => r.uri);
183
+ void subscribeToResources(connection, uris)
184
+ .then(() => {
185
+ const action = resolveSubscriptionPostAction(
186
+ this.#notificationsEnabled,
187
+ this.#notificationsEpoch,
188
+ notificationEpoch,
189
+ );
190
+ if (action === "rollback") {
191
+ void unsubscribeFromResources(connection, uris).catch(error => {
192
+ logger.debug("Failed to rollback stale MCP resource subscription", {
193
+ path: `mcp:${name}`,
194
+ error,
195
+ });
196
+ });
197
+ return;
198
+ }
199
+ if (action === "ignore") {
200
+ return;
201
+ }
202
+ this.#subscribedResources.set(name, new Set(uris));
203
+ })
204
+ .catch(error => {
205
+ logger.debug("Failed to subscribe to MCP resources", { path: `mcp:${name}`, error });
206
+ });
207
+ }
208
+ }
209
+ return;
210
+ }
211
+
212
+ // Unsubscribe from all servers
213
+ for (const [name, connection] of this.#connections) {
214
+ const uris = this.#subscribedResources.get(name);
215
+ if (uris && uris.size > 0) {
216
+ void unsubscribeFromResources(connection, Array.from(uris)).catch(error => {
217
+ logger.debug("Failed to unsubscribe MCP resources", { path: `mcp:${name}`, error });
218
+ });
219
+ }
220
+ }
221
+ this.#subscribedResources.clear();
222
+ }
223
+
95
224
  /**
96
225
  * Set the auth storage for resolving OAuth credentials.
97
226
  */
@@ -169,7 +298,11 @@ export class MCPManager {
169
298
  // Resolve auth config before connecting, but do so per-server in parallel.
170
299
  const connectionPromise = (async () => {
171
300
  const resolvedConfig = await this.#resolveAuthConfig(config);
172
- return connectToServer(name, resolvedConfig);
301
+ return connectToServer(name, resolvedConfig, {
302
+ onNotification: (method, params) => {
303
+ this.#handleServerNotification(name, method, params);
304
+ },
305
+ });
173
306
  })().then(
174
307
  connection => {
175
308
  // Store original config (without resolved tokens) to keep
@@ -203,12 +336,64 @@ export class MCPManager {
203
336
  connectionTasks.push({ name, config, tracked, toolsPromise });
204
337
 
205
338
  void toolsPromise
206
- .then(({ connection, serverTools }) => {
339
+ .then(async ({ connection, serverTools }) => {
207
340
  if (this.#pendingToolLoads.get(name) !== toolsPromise) return;
208
341
  this.#pendingToolLoads.delete(name);
209
342
  const customTools = MCPTool.fromTools(connection, serverTools);
210
343
  this.#replaceServerTools(name, customTools);
344
+ this.#onToolsChanged?.(this.#tools);
211
345
  void this.toolCache?.set(name, config, serverTools);
346
+
347
+ // Load resources and create resource tool (best-effort)
348
+ if (serverSupportsResources(connection.capabilities)) {
349
+ try {
350
+ const [resources] = await Promise.all([
351
+ listResources(connection),
352
+ listResourceTemplates(connection),
353
+ ]);
354
+
355
+ if (this.#notificationsEnabled && connection.capabilities.resources?.subscribe) {
356
+ const uris = resources.map(r => r.uri);
357
+ const notificationEpoch = this.#notificationsEpoch;
358
+ void subscribeToResources(connection, uris)
359
+ .then(() => {
360
+ const action = resolveSubscriptionPostAction(
361
+ this.#notificationsEnabled,
362
+ this.#notificationsEpoch,
363
+ notificationEpoch,
364
+ );
365
+ if (action === "rollback") {
366
+ void unsubscribeFromResources(connection, uris).catch(error => {
367
+ logger.debug("Failed to rollback stale MCP resource subscription", {
368
+ path: `mcp:${name}`,
369
+ error,
370
+ });
371
+ });
372
+ return;
373
+ }
374
+ if (action === "ignore") {
375
+ return;
376
+ }
377
+ this.#subscribedResources.set(name, new Set(uris));
378
+ })
379
+ .catch(error => {
380
+ logger.debug("Failed to subscribe to MCP resources", { path: `mcp:${name}`, error });
381
+ });
382
+ }
383
+ } catch (error) {
384
+ logger.debug("Failed to load MCP resources", { path: `mcp:${name}`, error });
385
+ }
386
+ }
387
+
388
+ // Load prompts (best-effort)
389
+ if (serverSupportsPrompts(connection.capabilities)) {
390
+ try {
391
+ await listPrompts(connection);
392
+ this.#onPromptsChanged?.(name);
393
+ } catch (error) {
394
+ logger.debug("Failed to load MCP prompts", { path: `mcp:${name}`, error });
395
+ }
396
+ }
212
397
  })
213
398
  .catch(error => {
214
399
  if (this.#pendingToolLoads.get(name) !== toolsPromise) return;
@@ -291,6 +476,49 @@ export class MCPManager {
291
476
  this.#tools.push(...tools);
292
477
  }
293
478
 
479
+ #triggerNotificationRefresh(serverName: string, kind: "tools" | "resources" | "prompts"): void {
480
+ const refresh = (() => {
481
+ switch (kind) {
482
+ case "tools":
483
+ return this.refreshServerTools(serverName);
484
+ case "resources":
485
+ return this.refreshServerResources(serverName);
486
+ case "prompts":
487
+ return this.refreshServerPrompts(serverName);
488
+ }
489
+ })();
490
+ void refresh.catch(error => {
491
+ logger.debug("Failed MCP notification refresh", { path: `mcp:${serverName}`, kind, error });
492
+ });
493
+ }
494
+ #handleServerNotification(serverName: string, method: string, params: unknown): void {
495
+ logger.debug("MCP notification received", { path: `mcp:${serverName}`, method });
496
+
497
+ switch (method) {
498
+ case MCPNotificationMethods.TOOLS_LIST_CHANGED:
499
+ this.#triggerNotificationRefresh(serverName, "tools");
500
+ break;
501
+ case MCPNotificationMethods.RESOURCES_LIST_CHANGED:
502
+ this.#triggerNotificationRefresh(serverName, "resources");
503
+ break;
504
+ case MCPNotificationMethods.RESOURCES_UPDATED: {
505
+ const uri = (params as { uri?: string })?.uri;
506
+ const subscribed = this.#subscribedResources.get(serverName);
507
+ if (uri && subscribed?.has(uri)) {
508
+ this.#onResourcesChanged?.(serverName, uri);
509
+ }
510
+ break;
511
+ }
512
+ case MCPNotificationMethods.PROMPTS_LIST_CHANGED:
513
+ this.#triggerNotificationRefresh(serverName, "prompts");
514
+ break;
515
+ default:
516
+ break;
517
+ }
518
+
519
+ this.#onNotification?.(serverName, method, params);
520
+ }
521
+
294
522
  /**
295
523
  * Get all loaded tools.
296
524
  */
@@ -364,13 +592,25 @@ export class MCPManager {
364
592
  this.#sources.delete(name);
365
593
 
366
594
  const connection = this.#connections.get(name);
595
+
596
+ const subscribedUris = this.#subscribedResources.get(name);
597
+ if (subscribedUris && subscribedUris.size > 0 && connection) {
598
+ void unsubscribeFromResources(connection, Array.from(subscribedUris)).catch(() => {});
599
+ }
600
+ this.#subscribedResources.delete(name);
601
+
367
602
  if (connection) {
368
603
  await disconnectServer(connection);
369
604
  this.#connections.delete(name);
370
605
  }
371
606
 
372
- // Remove tools from this server
607
+ // Remove tools from this server and notify consumers
608
+ const hadTools = this.#tools.some(t => t.name.startsWith(`mcp_${name}_`));
373
609
  this.#tools = this.#tools.filter(t => !t.name.startsWith(`mcp_${name}_`));
610
+ if (hadTools) this.#onToolsChanged?.(this.#tools);
611
+
612
+ // Notify prompt consumers so stale commands are cleared
613
+ if (connection?.prompts?.length) this.#onPromptsChanged?.(name);
374
614
  }
375
615
 
376
616
  /**
@@ -385,6 +625,7 @@ export class MCPManager {
385
625
  this.#sources.clear();
386
626
  this.#connections.clear();
387
627
  this.#tools = [];
628
+ this.#subscribedResources.clear();
388
629
  }
389
630
 
390
631
  /**
@@ -404,6 +645,7 @@ export class MCPManager {
404
645
 
405
646
  // Replace tools from this server
406
647
  this.#replaceServerTools(name, customTools);
648
+ this.#onToolsChanged?.(this.#tools);
407
649
  }
408
650
 
409
651
  /**
@@ -414,6 +656,158 @@ export class MCPManager {
414
656
  await Promise.allSettled(promises);
415
657
  }
416
658
 
659
+ /**
660
+ * Refresh resources from a specific server.
661
+ */
662
+ async refreshServerResources(name: string): Promise<void> {
663
+ const existing = this.#pendingResourceRefresh.get(name);
664
+ if (existing) return existing;
665
+
666
+ const doRefresh = async (): Promise<void> => {
667
+ const connection = this.#connections.get(name);
668
+ if (!connection || !serverSupportsResources(connection.capabilities)) return;
669
+
670
+ // Clear cached resources
671
+ connection.resources = undefined;
672
+ connection.resourceTemplates = undefined;
673
+
674
+ // Reload
675
+ const [resources] = await Promise.all([listResources(connection), listResourceTemplates(connection)]);
676
+ if (this.#notificationsEnabled && connection.capabilities.resources?.subscribe) {
677
+ const newUris = new Set(resources.map(r => r.uri));
678
+ const oldUris = this.#subscribedResources.get(name);
679
+ const notificationEpoch = this.#notificationsEpoch;
680
+
681
+ // Unsubscribe URIs that were removed
682
+ if (oldUris) {
683
+ const removed = [...oldUris].filter(uri => !newUris.has(uri));
684
+ if (removed.length > 0) {
685
+ try {
686
+ await unsubscribeFromResources(connection, removed);
687
+ } catch (error) {
688
+ logger.debug("Failed to unsubscribe stale MCP resources", { path: `mcp:${name}`, error });
689
+ }
690
+ }
691
+ }
692
+
693
+ // Subscribe to the current set and update tracking atomically
694
+ try {
695
+ const allUris = [...newUris];
696
+ await subscribeToResources(connection, allUris);
697
+ const action = resolveSubscriptionPostAction(
698
+ this.#notificationsEnabled,
699
+ this.#notificationsEpoch,
700
+ notificationEpoch,
701
+ );
702
+ if (action === "rollback") {
703
+ await unsubscribeFromResources(connection, allUris).catch(error => {
704
+ logger.debug("Failed to rollback stale MCP resource subscription", { path: `mcp:${name}`, error });
705
+ });
706
+ return;
707
+ }
708
+ if (action === "ignore") {
709
+ return;
710
+ }
711
+ this.#subscribedResources.set(name, newUris);
712
+ } catch (error) {
713
+ logger.debug("Failed to re-subscribe to MCP resources", { path: `mcp:${name}`, error });
714
+ }
715
+ }
716
+ };
717
+
718
+ const promise = doRefresh().finally(() => {
719
+ if (this.#pendingResourceRefresh.get(name) === promise) {
720
+ this.#pendingResourceRefresh.delete(name);
721
+ }
722
+ });
723
+ this.#pendingResourceRefresh.set(name, promise);
724
+ return promise;
725
+ }
726
+
727
+ /**
728
+ * Refresh prompts from a specific server.
729
+ */
730
+ async refreshServerPrompts(name: string): Promise<void> {
731
+ const connection = this.#connections.get(name);
732
+ if (!connection || !serverSupportsPrompts(connection.capabilities)) return;
733
+
734
+ connection.prompts = undefined;
735
+ await listPrompts(connection);
736
+
737
+ this.#onPromptsChanged?.(name);
738
+ }
739
+
740
+ /**
741
+ * Get resources and templates for a specific server.
742
+ */
743
+ getServerResources(name: string): { resources: MCPResource[]; templates: MCPResourceTemplate[] } | undefined {
744
+ const connection = this.#connections.get(name);
745
+ if (!connection) return undefined;
746
+ return {
747
+ resources: connection.resources ?? [],
748
+ templates: connection.resourceTemplates ?? [],
749
+ };
750
+ }
751
+
752
+ /**
753
+ * Read a specific resource from a server.
754
+ */
755
+ async readServerResource(
756
+ name: string,
757
+ uri: string,
758
+ options?: MCPRequestOptions,
759
+ ): Promise<MCPResourceReadResult | undefined> {
760
+ const connection = this.#connections.get(name);
761
+ if (!connection) return undefined;
762
+ return readResource(connection, uri, options);
763
+ }
764
+
765
+ /**
766
+ * Get prompts for a specific server.
767
+ */
768
+ getServerPrompts(name: string): MCPPrompt[] | undefined {
769
+ const connection = this.#connections.get(name);
770
+ if (!connection) return undefined;
771
+ return connection.prompts ?? [];
772
+ }
773
+
774
+ /**
775
+ * Get a specific prompt from a server.
776
+ */
777
+ async executePrompt(
778
+ name: string,
779
+ promptName: string,
780
+ args?: Record<string, string>,
781
+ options?: MCPRequestOptions,
782
+ ): Promise<MCPGetPromptResult | undefined> {
783
+ const connection = this.#connections.get(name);
784
+ if (!connection) return undefined;
785
+ return getPrompt(connection, promptName, args, options);
786
+ }
787
+
788
+ /**
789
+ * Get all server instructions (for system prompt injection).
790
+ */
791
+ getServerInstructions(): Map<string, string> {
792
+ const instructions = new Map<string, string>();
793
+ for (const [name, connection] of this.#connections) {
794
+ if (connection.instructions) {
795
+ instructions.set(name, connection.instructions);
796
+ }
797
+ }
798
+ return instructions;
799
+ }
800
+
801
+ /**
802
+ * Get notification state for display.
803
+ */
804
+ getNotificationState(): { enabled: boolean; subscriptions: Map<string, ReadonlySet<string>> } {
805
+ return {
806
+ enabled: this.#notificationsEnabled,
807
+ subscriptions: this.#subscribedResources as Map<string, ReadonlySet<string>>,
808
+ };
809
+ }
810
+
417
811
  /**
418
812
  * Resolve OAuth credentials and shell commands in config.
419
813
  */
@@ -54,7 +54,8 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
54
54
  if (!params.get("response_type")) {
55
55
  params.set("response_type", "code");
56
56
  }
57
- if (this.#resolvedClientId && !params.get("client_id")) {
57
+ const existingClientId = params.get("client_id")?.trim();
58
+ if (this.#resolvedClientId && !existingClientId) {
58
59
  params.set("client_id", this.#resolvedClientId);
59
60
  }
60
61
  if (this.config.scopes && !params.get("scope")) {
@@ -72,6 +73,10 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
72
73
  // Store code verifier for token exchange
73
74
  this.#codeVerifier = codeVerifier;
74
75
 
76
+ if (!params.get("client_id")) {
77
+ await this.#assertClientIdNotRequired(authUrl.toString());
78
+ }
79
+
75
80
  return { url: authUrl.toString() };
76
81
  }
77
82
 
@@ -228,4 +233,24 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
228
233
 
229
234
  return null;
230
235
  }
236
+
237
+ async #assertClientIdNotRequired(authorizationUrl: string): Promise<void> {
238
+ try {
239
+ const response = await fetch(authorizationUrl, {
240
+ method: "GET",
241
+ redirect: "manual",
242
+ headers: { Accept: "text/plain,text/html,application/json" },
243
+ });
244
+ if (response.status < 400) return;
245
+ const body = await response.text();
246
+ if (/client[_-]?id/i.test(body) && /(required|missing|invalid)/i.test(body)) {
247
+ throw new Error("OAuth provider requires client_id");
248
+ }
249
+ } catch (error) {
250
+ if (error instanceof Error && /client[_-]?id/i.test(error.message)) {
251
+ throw error;
252
+ }
253
+ // Ignore network/probe failures to avoid blocking flows that still work.
254
+ }
255
+ }
231
256
  }
@@ -0,0 +1,104 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { isEnoent, logger } from "@oh-my-pi/pi-utils";
4
+ import { getAgentDir } from "@oh-my-pi/pi-utils/dirs";
5
+
6
+ const SMITHERY_AUTH_FILENAME = "smithery.json";
7
+ const SMITHERY_URL = process.env.SMITHERY_URL || "https://smithery.ai";
8
+
9
+ type SmitheryCliAuthSession = {
10
+ sessionId: string;
11
+ authUrl: string;
12
+ };
13
+
14
+ type SmitheryCliPollResponse = {
15
+ status: "pending" | "success" | "error";
16
+ apiKey?: string;
17
+ message?: string;
18
+ };
19
+
20
+ type SmitheryAuthPayload = {
21
+ apiKey?: string;
22
+ };
23
+
24
+ function getSmitheryAuthPath(): string {
25
+ return path.join(getAgentDir(), SMITHERY_AUTH_FILENAME);
26
+ }
27
+
28
+ function normalizeApiKey(value: string | undefined): string | undefined {
29
+ if (!value) return undefined;
30
+ const trimmed = value.trim();
31
+ return trimmed.length > 0 ? trimmed : undefined;
32
+ }
33
+
34
+ export function getSmitheryLoginUrl(): string {
35
+ return SMITHERY_URL;
36
+ }
37
+
38
+ export async function createSmitheryCliAuthSession(): Promise<SmitheryCliAuthSession> {
39
+ const response = await fetch(`${SMITHERY_URL}/api/auth/cli/session`, {
40
+ method: "POST",
41
+ });
42
+ if (!response.ok) {
43
+ throw new Error(`Failed to create Smithery auth session: ${response.status} ${response.statusText}`);
44
+ }
45
+ return (await response.json()) as SmitheryCliAuthSession;
46
+ }
47
+
48
+ export async function pollSmitheryCliAuthSession(
49
+ sessionId: string,
50
+ signal?: AbortSignal,
51
+ ): Promise<SmitheryCliPollResponse> {
52
+ const response = await fetch(`${SMITHERY_URL}/api/auth/cli/poll/${sessionId}`, {
53
+ signal,
54
+ });
55
+ if (!response.ok) {
56
+ if (response.status === 404 || response.status === 410) {
57
+ throw new Error("Smithery login session expired. Please try again.");
58
+ }
59
+ throw new Error(`Smithery auth polling failed: ${response.status} ${response.statusText}`);
60
+ }
61
+ return (await response.json()) as SmitheryCliPollResponse;
62
+ }
63
+
64
+ export async function getSmitheryApiKey(): Promise<string | undefined> {
65
+ const envKey = normalizeApiKey(process.env.SMITHERY_API_KEY);
66
+ if (envKey) return envKey;
67
+
68
+ const authPath = getSmitheryAuthPath();
69
+ try {
70
+ const payload = (await Bun.file(authPath).json()) as SmitheryAuthPayload;
71
+ return normalizeApiKey(payload.apiKey);
72
+ } catch (error) {
73
+ if (isEnoent(error)) return undefined;
74
+ logger.warn("Failed to read Smithery auth file, treating as missing", { path: authPath, error });
75
+ return undefined;
76
+ }
77
+ }
78
+
79
+ export async function saveSmitheryApiKey(apiKey: string): Promise<void> {
80
+ const normalized = normalizeApiKey(apiKey);
81
+ if (!normalized) {
82
+ throw new Error("Smithery API key cannot be empty.");
83
+ }
84
+
85
+ const authPath = getSmitheryAuthPath();
86
+ const payload: SmitheryAuthPayload = { apiKey: normalized };
87
+ await Bun.write(authPath, `${JSON.stringify(payload, null, 2)}\n`);
88
+ try {
89
+ await fs.chmod(authPath, 0o600);
90
+ } catch (error) {
91
+ logger.warn("Could not set restrictive permissions on Smithery auth file", { path: authPath, error });
92
+ }
93
+ }
94
+
95
+ export async function clearSmitheryApiKey(): Promise<boolean> {
96
+ const authPath = getSmitheryAuthPath();
97
+ try {
98
+ await fs.rm(authPath);
99
+ return true;
100
+ } catch (error) {
101
+ if (isEnoent(error)) return false;
102
+ throw error;
103
+ }
104
+ }