@oh-my-pi/pi-coding-agent 11.8.2 → 11.9.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 (141) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/docs/tui.md +9 -9
  3. package/package.json +7 -7
  4. package/src/capability/mcp.ts +9 -0
  5. package/src/cli/file-processor.ts +8 -13
  6. package/src/cli/oclif-help.ts +1 -1
  7. package/src/cli.ts +14 -0
  8. package/src/commit/git/index.ts +16 -16
  9. package/src/config/file-lock.ts +1 -1
  10. package/src/config/keybindings.ts +11 -11
  11. package/src/config/model-registry.ts +31 -66
  12. package/src/config/settings.ts +88 -95
  13. package/src/config.ts +2 -2
  14. package/src/cursor.ts +4 -4
  15. package/src/debug/index.ts +28 -28
  16. package/src/discovery/builtin.ts +48 -0
  17. package/src/discovery/codex.ts +5 -13
  18. package/src/discovery/cursor.ts +2 -7
  19. package/src/discovery/mcp-json.ts +33 -0
  20. package/src/exa/mcp-client.ts +2 -2
  21. package/src/exa/websets.ts +2 -2
  22. package/src/export/html/index.ts +3 -3
  23. package/src/export/ttsr.ts +27 -27
  24. package/src/extensibility/custom-tools/loader.ts +9 -9
  25. package/src/extensibility/extensions/runner.ts +64 -64
  26. package/src/extensibility/hooks/runner.ts +46 -46
  27. package/src/extensibility/plugins/manager.ts +49 -49
  28. package/src/extensibility/slash-commands.ts +1 -0
  29. package/src/index.ts +0 -3
  30. package/src/internal-urls/router.ts +5 -5
  31. package/src/ipy/kernel.ts +61 -57
  32. package/src/lsp/client.ts +1 -1
  33. package/src/lsp/clients/biome-client.ts +2 -2
  34. package/src/lsp/clients/lsp-linter-client.ts +7 -7
  35. package/src/lsp/index.ts +9 -9
  36. package/src/mcp/config-writer.ts +194 -0
  37. package/src/mcp/config.ts +20 -6
  38. package/src/mcp/index.ts +4 -0
  39. package/src/mcp/loader.ts +6 -0
  40. package/src/mcp/manager.ts +139 -50
  41. package/src/mcp/oauth-discovery.ts +274 -0
  42. package/src/mcp/oauth-flow.ts +229 -0
  43. package/src/mcp/tool-bridge.ts +20 -20
  44. package/src/mcp/transports/http.ts +107 -66
  45. package/src/mcp/transports/stdio.ts +74 -59
  46. package/src/mcp/types.ts +15 -1
  47. package/src/modes/components/assistant-message.ts +25 -25
  48. package/src/modes/components/bash-execution.ts +51 -51
  49. package/src/modes/components/bordered-loader.ts +7 -7
  50. package/src/modes/components/branch-summary-message.ts +7 -7
  51. package/src/modes/components/compaction-summary-message.ts +7 -7
  52. package/src/modes/components/countdown-timer.ts +15 -15
  53. package/src/modes/components/custom-editor.ts +22 -22
  54. package/src/modes/components/custom-message.ts +21 -21
  55. package/src/modes/components/dynamic-border.ts +3 -3
  56. package/src/modes/components/extensions/extension-dashboard.ts +72 -72
  57. package/src/modes/components/extensions/extension-list.ts +99 -97
  58. package/src/modes/components/extensions/inspector-panel.ts +26 -26
  59. package/src/modes/components/footer.ts +36 -36
  60. package/src/modes/components/history-search.ts +52 -52
  61. package/src/modes/components/hook-editor.ts +20 -20
  62. package/src/modes/components/hook-input.ts +20 -20
  63. package/src/modes/components/hook-message.ts +22 -22
  64. package/src/modes/components/hook-selector.ts +52 -52
  65. package/src/modes/components/index.ts +0 -1
  66. package/src/modes/components/login-dialog.ts +57 -57
  67. package/src/modes/components/mcp-add-wizard.ts +1286 -0
  68. package/src/modes/components/model-selector.ts +173 -173
  69. package/src/modes/components/oauth-selector.ts +45 -45
  70. package/src/modes/components/plugin-settings.ts +52 -52
  71. package/src/modes/components/python-execution.ts +53 -53
  72. package/src/modes/components/queue-mode-selector.ts +7 -7
  73. package/src/modes/components/read-tool-group.ts +23 -23
  74. package/src/modes/components/session-selector.ts +40 -37
  75. package/src/modes/components/settings-selector.ts +80 -80
  76. package/src/modes/components/show-images-selector.ts +7 -7
  77. package/src/modes/components/skill-message.ts +27 -27
  78. package/src/modes/components/status-line-segment-editor.ts +81 -81
  79. package/src/modes/components/status-line.ts +73 -73
  80. package/src/modes/components/theme-selector.ts +11 -11
  81. package/src/modes/components/thinking-selector.ts +7 -7
  82. package/src/modes/components/todo-display.ts +19 -19
  83. package/src/modes/components/todo-reminder.ts +9 -9
  84. package/src/modes/components/tool-execution.ts +212 -216
  85. package/src/modes/components/tree-selector.ts +144 -144
  86. package/src/modes/components/ttsr-notification.ts +17 -17
  87. package/src/modes/components/user-message-selector.ts +18 -18
  88. package/src/modes/components/welcome.ts +10 -10
  89. package/src/modes/controllers/command-controller.ts +0 -7
  90. package/src/modes/controllers/event-controller.ts +23 -23
  91. package/src/modes/controllers/extension-ui-controller.ts +13 -13
  92. package/src/modes/controllers/input-controller.ts +12 -9
  93. package/src/modes/controllers/mcp-command-controller.ts +1223 -0
  94. package/src/modes/interactive-mode.ts +240 -241
  95. package/src/modes/rpc/rpc-client.ts +77 -77
  96. package/src/modes/rpc/rpc-mode.ts +5 -5
  97. package/src/modes/theme/theme.ts +113 -113
  98. package/src/modes/types.ts +1 -1
  99. package/src/patch/index.ts +45 -45
  100. package/src/prompts/tools/task.md +22 -2
  101. package/src/sdk.ts +1 -0
  102. package/src/session/agent-session.ts +512 -476
  103. package/src/session/agent-storage.ts +72 -75
  104. package/src/session/auth-storage.ts +186 -252
  105. package/src/session/history-storage.ts +36 -38
  106. package/src/session/session-manager.ts +300 -299
  107. package/src/session/session-storage.ts +65 -90
  108. package/src/ssh/connection-manager.ts +9 -9
  109. package/src/system-prompt.ts +2 -3
  110. package/src/task/agents.ts +1 -1
  111. package/src/task/executor.ts +28 -40
  112. package/src/task/index.ts +13 -12
  113. package/src/task/subprocess-tool-registry.ts +5 -5
  114. package/src/task/worktree.ts +8 -5
  115. package/src/tools/ask.ts +7 -7
  116. package/src/tools/bash.ts +15 -10
  117. package/src/tools/browser.ts +130 -127
  118. package/src/tools/calculator.ts +46 -46
  119. package/src/tools/context.ts +9 -9
  120. package/src/tools/exit-plan-mode.ts +5 -5
  121. package/src/tools/fetch.ts +5 -5
  122. package/src/tools/find.ts +16 -16
  123. package/src/tools/grep.ts +12 -24
  124. package/src/tools/index.ts +1 -1
  125. package/src/tools/notebook.ts +6 -6
  126. package/src/tools/output-meta.ts +10 -2
  127. package/src/tools/python.ts +12 -11
  128. package/src/tools/read.ts +17 -17
  129. package/src/tools/ssh.ts +9 -9
  130. package/src/tools/submit-result.ts +13 -13
  131. package/src/tools/todo-write.ts +6 -6
  132. package/src/tools/write.ts +10 -10
  133. package/src/tui/output-block.ts +6 -6
  134. package/src/tui/utils.ts +9 -9
  135. package/src/utils/event-bus.ts +13 -11
  136. package/src/utils/frontmatter.ts +1 -1
  137. package/src/utils/ignore-files.ts +1 -1
  138. package/src/web/search/index.ts +5 -5
  139. package/src/web/search/providers/anthropic.ts +7 -2
  140. package/examples/hooks/snake.ts +0 -342
  141. package/src/modes/components/armin.ts +0 -379
@@ -0,0 +1,1286 @@
1
+ /**
2
+ * MCP Add Wizard Component
3
+ *
4
+ * Interactive multi-step wizard for adding MCP servers.
5
+ */
6
+ import {
7
+ Container,
8
+ Input,
9
+ matchesKey,
10
+ replaceTabs,
11
+ Spacer,
12
+ Text,
13
+ TruncatedText,
14
+ truncateToWidth,
15
+ } from "@oh-my-pi/pi-tui";
16
+ import { validateServerName } from "../../mcp/config-writer";
17
+ import { analyzeAuthError, discoverOAuthEndpoints } from "../../mcp/oauth-discovery";
18
+ import type { MCPHttpServerConfig, MCPServerConfig, MCPSseServerConfig, MCPStdioServerConfig } from "../../mcp/types";
19
+ import { theme } from "../theme/theme";
20
+ import { DynamicBorder } from "./dynamic-border";
21
+
22
+ type TransportType = "stdio" | "http" | "sse";
23
+ type AuthMethod = "none" | "oauth" | "manual";
24
+ type AuthLocation = "env" | "header";
25
+ type Scope = "user" | "project";
26
+
27
+ type WizardStep =
28
+ | "name"
29
+ | "transport"
30
+ | "command"
31
+ | "args"
32
+ | "url"
33
+ | "auth-method"
34
+ | "oauth-error"
35
+ | "oauth-auth-url"
36
+ | "oauth-token-url"
37
+ | "oauth-client-id"
38
+ | "oauth-client-secret"
39
+ | "oauth-scopes"
40
+ | "apikey"
41
+ | "auth-location"
42
+ | "env-var-name"
43
+ | "header-name"
44
+ | "scope"
45
+ | "confirm";
46
+
47
+ interface WizardState {
48
+ name: string;
49
+ transport: TransportType | null;
50
+ command: string;
51
+ args: string;
52
+ url: string;
53
+ authMethod: AuthMethod;
54
+ oauthAuthUrl: string;
55
+ oauthTokenUrl: string;
56
+ oauthClientId: string;
57
+ oauthClientSecret: string;
58
+ oauthScopes: string;
59
+ oauthCredentialId: string | null;
60
+ apiKey: string;
61
+ authLocation: AuthLocation | null;
62
+ envVarName: string;
63
+ headerName: string;
64
+ scope: Scope | null;
65
+ }
66
+
67
+ /** Max display width for sanitized error/URL text in wizard TUI */
68
+ const MAX_DISPLAY_WIDTH = 120;
69
+
70
+ /** Sanitize a string for TUI display: replace tabs and truncate */
71
+ function sanitize(text: string): string {
72
+ return truncateToWidth(replaceTabs(text), MAX_DISPLAY_WIDTH);
73
+ }
74
+
75
+ export class MCPAddWizard extends Container {
76
+ #currentStep: WizardStep = "name";
77
+ #state: WizardState = {
78
+ name: "",
79
+ transport: null,
80
+ command: "",
81
+ args: "",
82
+ url: "",
83
+ authMethod: "none",
84
+ oauthAuthUrl: "",
85
+ oauthTokenUrl: "",
86
+ oauthClientId: "",
87
+ oauthClientSecret: "",
88
+ oauthScopes: "",
89
+ oauthCredentialId: null,
90
+ apiKey: "",
91
+ authLocation: null,
92
+ envVarName: "API_KEY",
93
+ headerName: "Authorization",
94
+ scope: null,
95
+ };
96
+
97
+ #contentContainer: Container;
98
+ #inputField: Input | null = null;
99
+ #selectedIndex = 0;
100
+ #validationError: string | null = null;
101
+ #onCompleteCallback: (name: string, config: MCPServerConfig, scope: Scope) => void;
102
+ #onCancelCallback: () => void;
103
+ #onOAuthCallback:
104
+ | ((authUrl: string, tokenUrl: string, clientId: string, clientSecret: string, scopes: string) => Promise<string>)
105
+ | null = null;
106
+ #onTestConnectionCallback: ((config: MCPServerConfig) => Promise<void>) | null = null;
107
+ #onRenderCallback: (() => void) | null = null;
108
+
109
+ constructor(
110
+ onComplete: (name: string, config: MCPServerConfig, scope: Scope) => void,
111
+ onCancel: () => void,
112
+ onOAuth?: (
113
+ authUrl: string,
114
+ tokenUrl: string,
115
+ clientId: string,
116
+ clientSecret: string,
117
+ scopes: string,
118
+ ) => Promise<string>,
119
+ onTestConnection?: (config: MCPServerConfig) => Promise<void>,
120
+ onRender?: () => void,
121
+ initialName?: string,
122
+ ) {
123
+ super();
124
+ this.#onCompleteCallback = onComplete;
125
+ this.#onCancelCallback = onCancel;
126
+ this.#onOAuthCallback = onOAuth ?? null;
127
+ this.#onTestConnectionCallback = onTestConnection ?? null;
128
+ this.#onRenderCallback = onRender ?? null;
129
+ if (initialName && initialName.trim().length > 0) {
130
+ this.#state.name = initialName.trim();
131
+ this.#currentStep = "transport";
132
+ }
133
+
134
+ // Add border
135
+ this.addChild(new DynamicBorder());
136
+ this.addChild(new Spacer(1));
137
+
138
+ // Add title
139
+ this.addChild(new TruncatedText(theme.bold("Add MCP Server")));
140
+ this.addChild(new Spacer(1));
141
+
142
+ // Content container for step-specific content
143
+ this.#contentContainer = new Container();
144
+ this.addChild(this.#contentContainer);
145
+
146
+ this.addChild(new Spacer(1));
147
+
148
+ // Add bottom border
149
+ this.addChild(new DynamicBorder());
150
+
151
+ // Render first step
152
+ this.#renderStep();
153
+ }
154
+
155
+ #requestRender(): void {
156
+ this.#onRenderCallback?.();
157
+ }
158
+
159
+ #renderStep(): void {
160
+ this.#contentContainer.clear();
161
+ this.#inputField = null; // Reset input field
162
+
163
+ switch (this.#currentStep) {
164
+ case "name":
165
+ this.#renderNameStep();
166
+ break;
167
+ case "transport":
168
+ this.#renderTransportStep();
169
+ break;
170
+ case "command":
171
+ this.#renderCommandStep();
172
+ break;
173
+ case "args":
174
+ this.#renderArgsStep();
175
+ break;
176
+ case "url":
177
+ this.#renderUrlStep();
178
+ break;
179
+ case "auth-method":
180
+ this.#renderAuthMethodStep();
181
+ break;
182
+ case "oauth-error":
183
+ this.#renderOAuthErrorStep();
184
+ break;
185
+ case "oauth-auth-url":
186
+ this.#renderOAuthAuthUrlStep();
187
+ break;
188
+ case "oauth-token-url":
189
+ this.#renderOAuthTokenUrlStep();
190
+ break;
191
+ case "oauth-client-id":
192
+ this.#renderOAuthClientIdStep();
193
+ break;
194
+ case "oauth-client-secret":
195
+ this.#renderOAuthClientSecretStep();
196
+ break;
197
+ case "oauth-scopes":
198
+ this.#renderOAuthScopesStep();
199
+ break;
200
+ case "apikey":
201
+ this.#renderApiKeyStep();
202
+ break;
203
+ case "auth-location":
204
+ this.#renderAuthLocationStep();
205
+ break;
206
+ case "env-var-name":
207
+ this.#renderEnvVarNameStep();
208
+ break;
209
+ case "header-name":
210
+ this.#renderHeaderNameStep();
211
+ break;
212
+ case "scope":
213
+ this.#renderScopeStep();
214
+ break;
215
+ case "confirm":
216
+ this.#renderConfirmStep();
217
+ break;
218
+ }
219
+ }
220
+
221
+ #renderNameStep(): void {
222
+ this.#contentContainer.addChild(new Text(theme.fg("accent", "Step 1: Server Name")));
223
+ this.#contentContainer.addChild(new Spacer(1));
224
+ this.#contentContainer.addChild(new Text("Enter a unique name for this server:", 0, 0));
225
+ this.#contentContainer.addChild(new Spacer(1));
226
+
227
+ this.#inputField = new Input();
228
+ this.#inputField.setValue(this.#state.name);
229
+ this.#contentContainer.addChild(this.#inputField);
230
+ this.#contentContainer.addChild(new Spacer(1));
231
+
232
+ // Show validation error if any
233
+ if (this.#validationError) {
234
+ this.#contentContainer.addChild(new Text(theme.fg("error", `✗ ${sanitize(this.#validationError)}`), 0, 0));
235
+ this.#contentContainer.addChild(new Spacer(1));
236
+ }
237
+
238
+ this.#contentContainer.addChild(
239
+ new Text(theme.fg("muted", "[Only letters, numbers, dash, underscore, dot]"), 0, 0),
240
+ );
241
+ this.#contentContainer.addChild(new Text(theme.fg("muted", "[Enter to continue, Esc to cancel]"), 0, 0));
242
+ }
243
+
244
+ #renderTransportStep(): void {
245
+ this.#contentContainer.addChild(new Text(theme.fg("accent", "Step 2: Transport Type")));
246
+ this.#contentContainer.addChild(new Spacer(1));
247
+ this.#contentContainer.addChild(new Text("Select the transport type:", 0, 0));
248
+ this.#contentContainer.addChild(new Spacer(1));
249
+
250
+ const options = [
251
+ { value: "stdio" as const, label: "stdio (Local process)" },
252
+ { value: "http" as const, label: "http (HTTP server)" },
253
+ { value: "sse" as const, label: "sse (Server-Sent Events)" },
254
+ ];
255
+
256
+ for (let i = 0; i < options.length; i++) {
257
+ const option = options[i];
258
+ const isSelected = i === this.#selectedIndex;
259
+ const prefix = isSelected ? theme.fg("accent", `${theme.nav.cursor} `) : " ";
260
+ const text = isSelected ? theme.fg("accent", option.label) : option.label;
261
+ this.#contentContainer.addChild(new Text(prefix + text, 0, 0));
262
+ }
263
+
264
+ this.#contentContainer.addChild(new Spacer(1));
265
+ this.#contentContainer.addChild(
266
+ new Text(theme.fg("muted", "[↑↓ to navigate, Enter to select, Esc to cancel]"), 0, 0),
267
+ );
268
+ }
269
+
270
+ #renderCommandStep(): void {
271
+ this.#contentContainer.addChild(new Text(theme.fg("accent", "Step 3: Command")));
272
+ this.#contentContainer.addChild(new Spacer(1));
273
+ this.#contentContainer.addChild(new Text("Enter the command to run:", 0, 0));
274
+ this.#contentContainer.addChild(new Spacer(1));
275
+
276
+ this.#inputField = new Input();
277
+ this.#inputField.setValue(this.#state.command);
278
+ this.#contentContainer.addChild(this.#inputField);
279
+ this.#contentContainer.addChild(new Spacer(1));
280
+ this.#contentContainer.addChild(new Text(theme.fg("muted", "[Enter to continue, Esc to go back]"), 0, 0));
281
+ }
282
+
283
+ #renderArgsStep(): void {
284
+ this.#contentContainer.addChild(new Text(theme.fg("accent", "Step 4: Arguments (Optional)")));
285
+ this.#contentContainer.addChild(new Spacer(1));
286
+ this.#contentContainer.addChild(new Text("Enter command arguments (space-separated):", 0, 0));
287
+ this.#contentContainer.addChild(new Spacer(1));
288
+
289
+ this.#inputField = new Input();
290
+ this.#inputField.setValue(this.#state.args);
291
+ this.#contentContainer.addChild(this.#inputField);
292
+ this.#contentContainer.addChild(new Spacer(1));
293
+ this.#contentContainer.addChild(new Text(theme.fg("muted", "[Press Enter to skip or continue]"), 0, 0));
294
+ }
295
+
296
+ #renderUrlStep(): void {
297
+ this.#contentContainer.addChild(new Text(theme.fg("accent", "Step 3: Server URL")));
298
+ this.#contentContainer.addChild(new Spacer(1));
299
+ this.#contentContainer.addChild(new Text("Enter the server URL:", 0, 0));
300
+ this.#contentContainer.addChild(new Spacer(1));
301
+
302
+ this.#inputField = new Input();
303
+ this.#inputField.setValue(this.#state.url);
304
+ this.#contentContainer.addChild(this.#inputField);
305
+ this.#contentContainer.addChild(new Spacer(1));
306
+
307
+ // Show validation error if any
308
+ if (this.#validationError) {
309
+ this.#contentContainer.addChild(new Text(theme.fg("error", `✗ ${sanitize(this.#validationError)}`), 0, 0));
310
+ this.#contentContainer.addChild(new Spacer(1));
311
+ }
312
+
313
+ this.#contentContainer.addChild(new Text(theme.fg("muted", "[Must start with http:// or https://]"), 0, 0));
314
+ this.#contentContainer.addChild(new Text(theme.fg("muted", "[Enter to continue, Esc to go back]"), 0, 0));
315
+ }
316
+
317
+ #renderAuthLocationStep(): void {
318
+ this.#contentContainer.addChild(new Text(theme.fg("accent", "Step: How to provide the key?")));
319
+ this.#contentContainer.addChild(new Spacer(1));
320
+
321
+ const options = [
322
+ { value: "env" as const, label: "Environment variable" },
323
+ { value: "header" as const, label: "HTTP header" },
324
+ ];
325
+
326
+ for (let i = 0; i < options.length; i++) {
327
+ const option = options[i];
328
+ const isSelected = i === this.#selectedIndex;
329
+ const prefix = isSelected ? theme.fg("accent", `${theme.nav.cursor} `) : " ";
330
+ const text = isSelected ? theme.fg("accent", option.label) : option.label;
331
+ this.#contentContainer.addChild(new Text(prefix + text, 0, 0));
332
+ }
333
+
334
+ this.#contentContainer.addChild(new Spacer(1));
335
+ this.#contentContainer.addChild(
336
+ new Text(theme.fg("muted", "[↑↓ to navigate, Enter to select, Esc to go back]"), 0, 0),
337
+ );
338
+ }
339
+
340
+ #renderEnvVarNameStep(): void {
341
+ this.#contentContainer.addChild(new Text(theme.fg("accent", "Step: Environment Variable Name")));
342
+ this.#contentContainer.addChild(new Spacer(1));
343
+ this.#contentContainer.addChild(new Text("Enter the environment variable name:", 0, 0));
344
+ this.#contentContainer.addChild(new Spacer(1));
345
+
346
+ this.#inputField = new Input();
347
+ this.#inputField.setValue(this.#state.envVarName);
348
+ this.#contentContainer.addChild(this.#inputField);
349
+ this.#contentContainer.addChild(new Spacer(1));
350
+ this.#contentContainer.addChild(new Text(theme.fg("muted", "[Enter to continue, Esc to go back]"), 0, 0));
351
+ }
352
+
353
+ #renderHeaderNameStep(): void {
354
+ this.#contentContainer.addChild(new Text(theme.fg("accent", "Step: HTTP Header Name")));
355
+ this.#contentContainer.addChild(new Spacer(1));
356
+ this.#contentContainer.addChild(new Text("Enter the HTTP header name:", 0, 0));
357
+ this.#contentContainer.addChild(new Spacer(1));
358
+
359
+ this.#inputField = new Input();
360
+ this.#inputField.setValue(this.#state.headerName);
361
+ this.#contentContainer.addChild(this.#inputField);
362
+ this.#contentContainer.addChild(new Spacer(1));
363
+ this.#contentContainer.addChild(new Text(theme.fg("muted", "[Enter to continue, Esc to go back]"), 0, 0));
364
+ }
365
+
366
+ #renderScopeStep(): void {
367
+ this.#contentContainer.addChild(new Text(theme.fg("accent", "Step: Configuration Scope")));
368
+ this.#contentContainer.addChild(new Spacer(1));
369
+
370
+ const options = [
371
+ { value: "user" as const, label: "User level (~/.omp/mcp.json)" },
372
+ { value: "project" as const, label: "Project level (.omp/mcp.json)" },
373
+ ];
374
+
375
+ for (let i = 0; i < options.length; i++) {
376
+ const option = options[i];
377
+ const isSelected = i === this.#selectedIndex;
378
+ const prefix = isSelected ? theme.fg("accent", `${theme.nav.cursor} `) : " ";
379
+ const text = isSelected ? theme.fg("accent", option.label) : option.label;
380
+ this.#contentContainer.addChild(new Text(prefix + text, 0, 0));
381
+ }
382
+
383
+ this.#contentContainer.addChild(new Spacer(1));
384
+ this.#contentContainer.addChild(
385
+ new Text(theme.fg("muted", "[↑↓ to navigate, Enter to select, Esc to go back]"), 0, 0),
386
+ );
387
+ }
388
+
389
+ #renderConfirmStep(): void {
390
+ this.#contentContainer.addChild(new Text(theme.fg("accent", "Review Configuration")));
391
+ this.#contentContainer.addChild(new Spacer(1));
392
+
393
+ // Show summary
394
+ this.#contentContainer.addChild(new Text(`Name: ${theme.fg("accent", this.#state.name)}`, 0, 0));
395
+ this.#contentContainer.addChild(new Text(`Type: ${this.#state.transport}`, 0, 0));
396
+
397
+ if (this.#state.transport === "stdio") {
398
+ this.#contentContainer.addChild(new Text(`Command: ${this.#state.command}`, 0, 0));
399
+ if (this.#state.args) {
400
+ this.#contentContainer.addChild(new Text(`Args: ${this.#state.args}`, 0, 0));
401
+ }
402
+ } else {
403
+ this.#contentContainer.addChild(new Text(`URL: ${sanitize(this.#state.url)}`, 0, 0));
404
+ }
405
+
406
+ // Auth info
407
+ if (this.#state.authMethod === "none") {
408
+ this.#contentContainer.addChild(new Text("Auth: None", 0, 0));
409
+ } else if (this.#state.authMethod === "oauth") {
410
+ this.#contentContainer.addChild(new Text("Auth: OAuth (authenticated)", 0, 0));
411
+ } else if (this.#state.authMethod === "manual") {
412
+ if (this.#state.authLocation === "env") {
413
+ this.#contentContainer.addChild(new Text(`Auth: API key via env (${this.#state.envVarName})`, 0, 0));
414
+ } else {
415
+ this.#contentContainer.addChild(new Text(`Auth: API key via header (${this.#state.headerName})`, 0, 0));
416
+ }
417
+ }
418
+
419
+ const scopeLabel = this.#state.scope === "user" ? "User level" : "Project level";
420
+ this.#contentContainer.addChild(new Text(`Scope: ${scopeLabel}`, 0, 0));
421
+
422
+ this.#contentContainer.addChild(new Spacer(1));
423
+ this.#contentContainer.addChild(new Text("Save this configuration?", 0, 0));
424
+ this.#contentContainer.addChild(new Spacer(1));
425
+
426
+ const options = ["Yes", "No"];
427
+ for (let i = 0; i < options.length; i++) {
428
+ const isSelected = i === this.#selectedIndex;
429
+ const prefix = isSelected ? theme.fg("accent", `${theme.nav.cursor} `) : " ";
430
+ const text = isSelected ? theme.fg("accent", options[i]) : options[i];
431
+ this.#contentContainer.addChild(new Text(prefix + text, 0, 0));
432
+ }
433
+
434
+ this.#contentContainer.addChild(new Spacer(1));
435
+ this.#contentContainer.addChild(
436
+ new Text(theme.fg("muted", "[↑↓ to navigate, Enter to select, Esc to go back]"), 0, 0),
437
+ );
438
+ }
439
+
440
+ handleInput(keyData: string): void {
441
+ // Handle Ctrl+C to cancel wizard immediately
442
+ if (keyData === "\x03") {
443
+ // Ctrl+C pressed - cancel wizard
444
+ this.#onCancelCallback();
445
+ return;
446
+ }
447
+
448
+ // Handle Escape (always handled by wizard)
449
+ if (matchesKey(keyData, "escape")) {
450
+ if (this.#currentStep === "name") {
451
+ // Cancel wizard
452
+ this.#onCancelCallback();
453
+ return;
454
+ }
455
+ // Go back to previous step
456
+ this.#goBack();
457
+ return;
458
+ }
459
+
460
+ // If we have an input field, let it handle the input
461
+ if (this.#inputField) {
462
+ // Handle Enter to proceed
463
+ if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
464
+ this.#saveInputAndProceed();
465
+ return;
466
+ }
467
+ // Pass all other keys to the input field
468
+ this.#inputField.handleInput(keyData);
469
+ return;
470
+ }
471
+
472
+ // Selector steps - handle Enter
473
+ if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
474
+ this.#selectCurrentOption();
475
+ return;
476
+ }
477
+
478
+ // Handle up/down arrows for selectors
479
+ if (matchesKey(keyData, "up")) {
480
+ this.#moveSelection(-1);
481
+ return;
482
+ }
483
+ if (matchesKey(keyData, "down")) {
484
+ this.#moveSelection(1);
485
+ return;
486
+ }
487
+ }
488
+
489
+ #saveInputAndProceed(): void {
490
+ if (!this.#inputField) return;
491
+
492
+ const value = this.#inputField.getValue().trim();
493
+
494
+ switch (this.#currentStep) {
495
+ case "name": {
496
+ // Validate server name
497
+ const nameError = validateServerName(value);
498
+ if (nameError) {
499
+ this.#validationError = nameError;
500
+ this.#renderStep();
501
+ return;
502
+ }
503
+ this.#validationError = null;
504
+ this.#state.name = value;
505
+ this.#currentStep = "transport";
506
+ this.#selectedIndex = 0;
507
+ break;
508
+ }
509
+ case "command":
510
+ if (!value) {
511
+ // Command is required
512
+ return;
513
+ }
514
+ this.#state.command = value;
515
+ this.#currentStep = "args";
516
+ break;
517
+ case "args":
518
+ this.#state.args = value; // Optional
519
+ void this.#testConnectionAndDetectAuth();
520
+ return;
521
+ case "url": {
522
+ // Validate URL
523
+ if (!value) {
524
+ this.#validationError = "URL is required";
525
+ this.#renderStep();
526
+ return;
527
+ }
528
+ let parsedUrl: URL;
529
+ try {
530
+ parsedUrl = new URL(value);
531
+ } catch {
532
+ this.#validationError = "Invalid URL format (must start with http:// or https://)";
533
+ this.#renderStep();
534
+ return;
535
+ }
536
+ if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
537
+ this.#validationError = "URL must use http:// or https:// scheme";
538
+ this.#renderStep();
539
+ return;
540
+ }
541
+ this.#validationError = null;
542
+ this.#state.url = value;
543
+ void this.#testConnectionAndDetectAuth();
544
+ return;
545
+ }
546
+ case "oauth-auth-url":
547
+ if (!value) return;
548
+ this.#state.oauthAuthUrl = value;
549
+ this.#currentStep = "oauth-token-url";
550
+ break;
551
+ case "oauth-token-url":
552
+ if (!value) return;
553
+ this.#state.oauthTokenUrl = value;
554
+ this.#currentStep = "oauth-client-id";
555
+ break;
556
+ case "oauth-client-id":
557
+ if (!value) return;
558
+ this.#state.oauthClientId = value;
559
+ this.#currentStep = "oauth-client-secret";
560
+ break;
561
+ case "oauth-client-secret":
562
+ this.#state.oauthClientSecret = value; // Optional
563
+ this.#currentStep = "oauth-scopes";
564
+ break;
565
+ case "oauth-scopes":
566
+ this.#state.oauthScopes = value; // Optional
567
+ // Launch OAuth flow
568
+ void this.#launchOAuthFlow();
569
+ return;
570
+ case "apikey":
571
+ if (!value) {
572
+ // API key is required
573
+ return;
574
+ }
575
+ this.#state.apiKey = value;
576
+ // Determine auth location based on transport
577
+ if (this.#state.transport === "stdio") {
578
+ this.#currentStep = "env-var-name";
579
+ } else {
580
+ this.#currentStep = "auth-location";
581
+ this.#selectedIndex = 0;
582
+ }
583
+ break;
584
+ case "env-var-name":
585
+ if (!value) {
586
+ return;
587
+ }
588
+ this.#state.envVarName = value;
589
+ this.#state.authLocation = "env";
590
+ this.#currentStep = "scope";
591
+ this.#selectedIndex = 0;
592
+ break;
593
+ case "header-name":
594
+ if (!value) {
595
+ return;
596
+ }
597
+ this.#state.headerName = value;
598
+ this.#state.authLocation = "header";
599
+ this.#currentStep = "scope";
600
+ this.#selectedIndex = 0;
601
+ break;
602
+ }
603
+
604
+ this.#inputField = null;
605
+ this.#renderStep();
606
+ }
607
+
608
+ #selectCurrentOption(): void {
609
+ switch (this.#currentStep) {
610
+ case "transport": {
611
+ const transports: TransportType[] = ["stdio", "http", "sse"];
612
+ this.#state.transport = transports[this.#selectedIndex];
613
+ this.#currentStep = this.#state.transport === "stdio" ? "command" : "url";
614
+ break;
615
+ }
616
+ case "auth-method": {
617
+ const authMethods: Array<"oauth" | "manual"> = ["oauth", "manual"];
618
+ this.#state.authMethod = authMethods[this.#selectedIndex];
619
+ if (this.#state.authMethod === "oauth") {
620
+ this.#currentStep = "oauth-auth-url";
621
+ } else {
622
+ // manual
623
+ this.#currentStep = "apikey";
624
+ }
625
+ break;
626
+ }
627
+ case "oauth-error":
628
+ if (this.#selectedIndex === 0) {
629
+ void this.#launchOAuthFlow();
630
+ } else {
631
+ this.#currentStep = "oauth-auth-url";
632
+ }
633
+ return;
634
+ case "auth-location": {
635
+ const authLocations: Array<"env" | "header"> = ["env", "header"];
636
+ this.#state.authLocation = authLocations[this.#selectedIndex];
637
+ if (this.#state.authLocation === "env") {
638
+ this.#currentStep = "env-var-name";
639
+ } else {
640
+ this.#currentStep = "header-name";
641
+ }
642
+ break;
643
+ }
644
+ case "scope": {
645
+ const scopes: Scope[] = ["user", "project"];
646
+ this.#state.scope = scopes[this.#selectedIndex];
647
+ // Auto-save once scope is selected.
648
+ this.#complete();
649
+ return;
650
+ }
651
+ }
652
+
653
+ this.#renderStep();
654
+ }
655
+
656
+ #moveSelection(delta: number): void {
657
+ const maxIndex = this.#getMaxIndexForCurrentStep();
658
+ this.#selectedIndex = (this.#selectedIndex + delta + maxIndex + 1) % (maxIndex + 1);
659
+ this.#renderStep();
660
+ this.#requestRender();
661
+ }
662
+
663
+ #getMaxIndexForCurrentStep(): number {
664
+ switch (this.#currentStep) {
665
+ case "transport":
666
+ return 2; // 3 options
667
+ case "auth-method":
668
+ return 1; // 2 options
669
+ case "oauth-error":
670
+ return 1; // 2 options
671
+ case "auth-location":
672
+ return 1; // 2 options
673
+ case "scope":
674
+ return 1; // 2 options
675
+ case "confirm":
676
+ return 1; // 2 options
677
+ default:
678
+ return 0;
679
+ }
680
+ }
681
+
682
+ #goBack(): void {
683
+ // Navigate to previous step
684
+ switch (this.#currentStep) {
685
+ case "transport":
686
+ this.#currentStep = "name";
687
+ break;
688
+ case "command":
689
+ case "url":
690
+ this.#currentStep = "transport";
691
+ this.#selectedIndex = this.#state.transport === "stdio" ? 0 : this.#state.transport === "http" ? 1 : 2;
692
+ break;
693
+ case "args":
694
+ this.#currentStep = "command";
695
+ break;
696
+ case "auth-method":
697
+ // Go back to url or args depending on transport
698
+ if (this.#state.transport === "stdio") {
699
+ this.#currentStep = "args";
700
+ } else {
701
+ this.#currentStep = "url";
702
+ }
703
+ break;
704
+ case "oauth-auth-url":
705
+ case "apikey":
706
+ // Go back to transport-specific connection step
707
+ if (this.#state.transport === "stdio") {
708
+ this.#currentStep = "args";
709
+ } else {
710
+ this.#currentStep = "url";
711
+ }
712
+ break;
713
+ case "auth-location":
714
+ // Go back to API key input
715
+ this.#currentStep = "apikey";
716
+ break;
717
+ case "env-var-name":
718
+ case "header-name":
719
+ // Go back to auth location selection (for HTTP) or directly to apikey (for stdio)
720
+ if (this.#state.transport === "stdio") {
721
+ this.#currentStep = "apikey";
722
+ } else {
723
+ this.#currentStep = "auth-location";
724
+ this.#selectedIndex = this.#state.authLocation === "env" ? 0 : 1;
725
+ }
726
+ break;
727
+ case "oauth-token-url":
728
+ case "oauth-client-id":
729
+ case "oauth-client-secret":
730
+ case "oauth-scopes":
731
+ // Go back through OAuth flow
732
+ if (this.#currentStep === "oauth-token-url") {
733
+ this.#currentStep = "oauth-auth-url";
734
+ } else if (this.#currentStep === "oauth-client-id") {
735
+ this.#currentStep = "oauth-token-url";
736
+ } else if (this.#currentStep === "oauth-client-secret") {
737
+ this.#currentStep = "oauth-client-id";
738
+ } else if (this.#currentStep === "oauth-scopes") {
739
+ this.#currentStep = "oauth-client-secret";
740
+ }
741
+ break;
742
+ case "scope":
743
+ // Go back to last authentication step
744
+ if (this.#state.authMethod === "oauth") {
745
+ this.#currentStep = "oauth-scopes";
746
+ } else {
747
+ // manual - go back to env var name or header name
748
+ if (this.#state.authLocation === "env") {
749
+ this.#currentStep = "env-var-name";
750
+ } else {
751
+ this.#currentStep = "header-name";
752
+ }
753
+ }
754
+ break;
755
+ case "oauth-error":
756
+ this.#currentStep = "oauth-auth-url";
757
+ break;
758
+ case "confirm":
759
+ this.#currentStep = "scope";
760
+ this.#selectedIndex = this.#state.scope === "user" ? 0 : 1;
761
+ break;
762
+ }
763
+
764
+ this.#renderStep();
765
+ }
766
+
767
+ #renderAuthMethodStep(): void {
768
+ this.#contentContainer.addChild(new Text(theme.fg("accent", "Step: Authentication Method")));
769
+ this.#contentContainer.addChild(new Spacer(1));
770
+
771
+ const options = [
772
+ { value: "oauth" as const, label: "OAuth flow (web-based)", desc: "(opens browser)" },
773
+ { value: "manual" as const, label: "Manual API key/token", desc: "(paste or use shell command)" },
774
+ ];
775
+
776
+ for (let i = 0; i < options.length; i++) {
777
+ const option = options[i];
778
+ const isSelected = i === this.#selectedIndex;
779
+ const prefix = isSelected ? theme.fg("accent", `${theme.nav.cursor} `) : " ";
780
+ const text = isSelected ? theme.fg("accent", option.label) : option.label;
781
+ this.#contentContainer.addChild(new Text(prefix + text, 0, 0));
782
+ if (!isSelected) {
783
+ this.#contentContainer.addChild(new Text(` ${theme.fg("dim", option.desc)}`, 0, 0));
784
+ }
785
+ }
786
+
787
+ this.#contentContainer.addChild(new Spacer(1));
788
+ this.#contentContainer.addChild(
789
+ new Text(theme.fg("muted", "[↑↓ to navigate, Enter to select, Esc to go back]"), 0, 0),
790
+ );
791
+ }
792
+
793
+ #renderOAuthAuthUrlStep(): void {
794
+ this.#contentContainer.addChild(new Text(theme.fg("accent", "OAuth: Authorization URL")));
795
+ this.#contentContainer.addChild(new Spacer(1));
796
+ this.#contentContainer.addChild(new Text("Enter the OAuth authorization endpoint:", 0, 0));
797
+ this.#contentContainer.addChild(new Spacer(1));
798
+
799
+ this.#inputField = new Input();
800
+ this.#inputField.setValue(this.#state.oauthAuthUrl);
801
+ this.#contentContainer.addChild(this.#inputField);
802
+ this.#contentContainer.addChild(new Spacer(1));
803
+ this.#contentContainer.addChild(
804
+ new Text(theme.fg("muted", "e.g., https://auth.example.com/oauth/authorize"), 0, 0),
805
+ );
806
+ this.#contentContainer.addChild(new Spacer(1));
807
+ this.#contentContainer.addChild(new Text(theme.fg("muted", "[Enter to continue, Esc to go back]"), 0, 0));
808
+ }
809
+
810
+ #renderOAuthTokenUrlStep(): void {
811
+ this.#contentContainer.addChild(new Text(theme.fg("accent", "OAuth: Token URL")));
812
+ this.#contentContainer.addChild(new Spacer(1));
813
+ this.#contentContainer.addChild(new Text("Enter the OAuth token endpoint:", 0, 0));
814
+ this.#contentContainer.addChild(new Spacer(1));
815
+
816
+ this.#inputField = new Input();
817
+ this.#inputField.setValue(this.#state.oauthTokenUrl);
818
+ this.#contentContainer.addChild(this.#inputField);
819
+ this.#contentContainer.addChild(new Spacer(1));
820
+ this.#contentContainer.addChild(new Text(theme.fg("muted", "e.g., https://auth.example.com/oauth/token"), 0, 0));
821
+ this.#contentContainer.addChild(new Spacer(1));
822
+ this.#contentContainer.addChild(new Text(theme.fg("muted", "[Enter to continue, Esc to go back]"), 0, 0));
823
+ }
824
+
825
+ #renderOAuthClientIdStep(): void {
826
+ this.#contentContainer.addChild(new Text(theme.fg("accent", "OAuth: Client ID")));
827
+ this.#contentContainer.addChild(new Spacer(1));
828
+ this.#contentContainer.addChild(new Text("Enter your OAuth client ID:", 0, 0));
829
+ this.#contentContainer.addChild(new Spacer(1));
830
+
831
+ this.#inputField = new Input();
832
+ this.#inputField.setValue(this.#state.oauthClientId);
833
+ this.#contentContainer.addChild(this.#inputField);
834
+ this.#contentContainer.addChild(new Spacer(1));
835
+ this.#contentContainer.addChild(new Text(theme.fg("muted", "[Enter to continue, Esc to go back]"), 0, 0));
836
+ }
837
+
838
+ #renderOAuthClientSecretStep(): void {
839
+ this.#contentContainer.addChild(new Text(theme.fg("accent", "OAuth: Client Secret (Optional)")));
840
+ this.#contentContainer.addChild(new Spacer(1));
841
+ this.#contentContainer.addChild(new Text("Enter your OAuth client secret:", 0, 0));
842
+ this.#contentContainer.addChild(new Text(theme.fg("muted", "(Leave empty for PKCE-only flows)"), 0, 0));
843
+ this.#contentContainer.addChild(new Spacer(1));
844
+
845
+ this.#inputField = new Input();
846
+ this.#inputField.setValue(this.#state.oauthClientSecret);
847
+ this.#contentContainer.addChild(this.#inputField);
848
+ this.#contentContainer.addChild(new Spacer(1));
849
+ this.#contentContainer.addChild(new Text(theme.fg("muted", "[Enter to continue, Esc to go back]"), 0, 0));
850
+ }
851
+
852
+ #renderOAuthScopesStep(): void {
853
+ this.#contentContainer.addChild(new Text(theme.fg("accent", "OAuth: Scopes (Optional)")));
854
+ this.#contentContainer.addChild(new Spacer(1));
855
+ this.#contentContainer.addChild(new Text("Enter OAuth scopes (space-separated):", 0, 0));
856
+ this.#contentContainer.addChild(new Spacer(1));
857
+
858
+ this.#inputField = new Input();
859
+ this.#inputField.setValue(this.#state.oauthScopes);
860
+ this.#contentContainer.addChild(this.#inputField);
861
+ this.#contentContainer.addChild(new Spacer(1));
862
+ this.#contentContainer.addChild(new Text(theme.fg("muted", "e.g., read write"), 0, 0));
863
+ this.#contentContainer.addChild(new Spacer(1));
864
+ this.#contentContainer.addChild(new Text(theme.fg("muted", "[Enter to continue, Esc to go back]"), 0, 0));
865
+ }
866
+
867
+ #renderOAuthErrorStep(): void {
868
+ this.#contentContainer.addChild(new Text(theme.fg("error", "OAuth authentication failed"), 0, 0));
869
+ this.#contentContainer.addChild(new Spacer(1));
870
+ this.#contentContainer.addChild(new Text("Choose next action:", 0, 0));
871
+ this.#contentContainer.addChild(new Spacer(1));
872
+
873
+ const options = ["Retry OAuth authentication", "Edit OAuth settings"];
874
+ for (let i = 0; i < options.length; i++) {
875
+ const isSelected = i === this.#selectedIndex;
876
+ const prefix = isSelected ? theme.fg("accent", `${theme.nav.cursor} `) : " ";
877
+ const text = isSelected ? theme.fg("accent", options[i]) : options[i];
878
+ this.#contentContainer.addChild(new Text(prefix + text, 0, 0));
879
+ }
880
+
881
+ this.#contentContainer.addChild(new Spacer(1));
882
+ this.#contentContainer.addChild(
883
+ new Text(theme.fg("muted", "[↑↓ to navigate, Enter to select, Esc to go back]"), 0, 0),
884
+ );
885
+ }
886
+
887
+ #renderApiKeyStep(): void {
888
+ this.#contentContainer.addChild(new Text(theme.fg("accent", "API Key Required")));
889
+ this.#contentContainer.addChild(new Spacer(1));
890
+ this.#contentContainer.addChild(new Text("Enter your API key or token:", 0, 0));
891
+ this.#contentContainer.addChild(new Text(theme.fg("muted", "(Supports !command for password manager)"), 0, 0));
892
+ this.#contentContainer.addChild(new Spacer(1));
893
+
894
+ this.#inputField = new Input();
895
+ this.#inputField.setValue(this.#state.apiKey);
896
+ this.#contentContainer.addChild(this.#inputField);
897
+ this.#contentContainer.addChild(new Spacer(1));
898
+ this.#contentContainer.addChild(new Text(theme.fg("muted", "[Enter to continue, Esc to go back]"), 0, 0));
899
+ }
900
+
901
+ /**
902
+ * Test connection and automatically detect if auth is needed.
903
+ */
904
+ async #testConnectionAndDetectAuth(): Promise<void> {
905
+ const testConfig = this.#buildServerConfig();
906
+
907
+ if (!this.#onTestConnectionCallback) {
908
+ // Skip test, go to scope
909
+ this.#currentStep = "scope";
910
+ this.#selectedIndex = 0;
911
+ this.#renderStep();
912
+ return;
913
+ }
914
+
915
+ try {
916
+ // Try to connect - timeout is handled by the transport layer (5 seconds)
917
+ await this.#onTestConnectionCallback(testConfig);
918
+
919
+ // Success! No auth required
920
+ this.#contentContainer.clear();
921
+ this.#contentContainer.addChild(new Text(theme.fg("success", "✓ Connection successful!"), 0, 0));
922
+ this.#contentContainer.addChild(new Spacer(1));
923
+ this.#contentContainer.addChild(new Text("No authentication required", 0, 0));
924
+ this.#contentContainer.addChild(new Spacer(1));
925
+
926
+ setTimeout(() => {
927
+ this.#state.authMethod = "none";
928
+ this.#currentStep = "scope";
929
+ this.#selectedIndex = 0;
930
+ this.#renderStep();
931
+ }, 1000);
932
+ } catch (error) {
933
+ // Connection failed - check if it's an auth error
934
+ const authResult = analyzeAuthError(error as Error);
935
+
936
+ if (authResult.requiresAuth) {
937
+ // Prefer OAuth first: use error metadata, then well-known discovery fallback.
938
+ let oauth = authResult.authType === "oauth" ? (authResult.oauth ?? null) : null;
939
+ if (!oauth && this.#state.transport !== "stdio" && this.#state.url) {
940
+ try {
941
+ oauth = await discoverOAuthEndpoints(this.#state.url);
942
+ } catch {
943
+ // Ignore discovery failures and fallback to manual auth.
944
+ }
945
+ }
946
+
947
+ if (oauth) {
948
+ this.#state.oauthAuthUrl = oauth.authorizationUrl;
949
+ this.#state.oauthTokenUrl = oauth.tokenUrl;
950
+ this.#state.oauthClientId = oauth.clientId || "";
951
+ this.#state.oauthScopes = oauth.scopes || "";
952
+ this.#state.authMethod = "oauth";
953
+
954
+ this.#contentContainer.clear();
955
+ this.#contentContainer.addChild(new Text(theme.fg("success", "✓ OAuth detected"), 0, 0));
956
+ this.#contentContainer.addChild(new Spacer(1));
957
+ this.#contentContainer.addChild(new Text("Launching browser for authorization...", 0, 0));
958
+ this.#contentContainer.addChild(new Spacer(1));
959
+
960
+ void this.#launchOAuthFlow();
961
+ return;
962
+ }
963
+
964
+ // OAuth metadata unavailable: fallback to manual API key.
965
+ this.#contentContainer.clear();
966
+ this.#contentContainer.addChild(new Text(theme.fg("warning", "⚠ Authentication required"), 0, 0));
967
+ this.#contentContainer.addChild(new Spacer(1));
968
+ this.#contentContainer.addChild(new Text("OAuth parameters could not be discovered.", 0, 0));
969
+ this.#contentContainer.addChild(new Text("Provide API key/token manually.", 0, 0));
970
+ this.#contentContainer.addChild(new Spacer(1));
971
+ this.#currentStep = "apikey";
972
+ this.#renderStep();
973
+ } else {
974
+ // Not an auth error - just a connection failure
975
+ const errorMsg = sanitize(error instanceof Error ? error.message : String(error));
976
+ this.#contentContainer.clear();
977
+ this.#contentContainer.addChild(new Text(theme.fg("error", "✗ Connection failed"), 0, 0));
978
+ this.#contentContainer.addChild(new Spacer(1));
979
+ this.#contentContainer.addChild(new Text(errorMsg, 0, 0));
980
+ this.#contentContainer.addChild(new Spacer(1));
981
+ this.#contentContainer.addChild(new Text(theme.fg("muted", "Adding server anyway..."), 0, 0));
982
+
983
+ setTimeout(() => {
984
+ this.#state.authMethod = "none";
985
+ this.#currentStep = "scope";
986
+ this.#selectedIndex = 0;
987
+ this.#renderStep();
988
+ }, 2000);
989
+ }
990
+ }
991
+ }
992
+
993
+ /**
994
+ * Build a server config from current wizard state for connection testing (no auth).
995
+ */
996
+ #buildServerConfig(): MCPServerConfig {
997
+ return this.#buildServerConfigWithAuth(false);
998
+ }
999
+
1000
+ #buildServerConfigWithAuth(includeAuth: boolean): MCPServerConfig {
1001
+ const transport = this.#state.transport ?? "stdio";
1002
+
1003
+ if (transport === "stdio") {
1004
+ const config: MCPStdioServerConfig = {
1005
+ type: "stdio",
1006
+ command: this.#state.command,
1007
+ timeout: 5000,
1008
+ };
1009
+
1010
+ if (this.#state.args) {
1011
+ config.args = this.#state.args.split(/\s+/).filter(Boolean);
1012
+ }
1013
+
1014
+ if (includeAuth && this.#state.authMethod === "oauth" && this.#state.oauthCredentialId) {
1015
+ config.auth = {
1016
+ type: "oauth",
1017
+ credentialId: this.#state.oauthCredentialId,
1018
+ };
1019
+ }
1020
+
1021
+ if (includeAuth && this.#state.authMethod === "manual" && this.#state.apiKey) {
1022
+ config.env = {
1023
+ ...(config.env ?? {}),
1024
+ [this.#state.envVarName || "API_KEY"]: this.#state.apiKey,
1025
+ };
1026
+ }
1027
+
1028
+ return config;
1029
+ }
1030
+
1031
+ // http or sse
1032
+ const config: MCPHttpServerConfig | MCPSseServerConfig = {
1033
+ type: transport,
1034
+ url: this.#state.url,
1035
+ timeout: 5000,
1036
+ };
1037
+
1038
+ if (includeAuth && this.#state.authMethod === "oauth" && this.#state.oauthCredentialId) {
1039
+ config.auth = {
1040
+ type: "oauth",
1041
+ credentialId: this.#state.oauthCredentialId,
1042
+ };
1043
+ }
1044
+
1045
+ if (includeAuth && this.#state.authMethod === "manual" && this.#state.apiKey) {
1046
+ if (this.#state.authLocation === "env") {
1047
+ // For HTTP with env location, store in headers using the env var name as-is
1048
+ config.headers = {
1049
+ ...(config.headers ?? {}),
1050
+ [this.#state.headerName || "Authorization"]: this.#state.apiKey,
1051
+ };
1052
+ } else {
1053
+ const headerName = this.#state.headerName || "Authorization";
1054
+ config.headers = {
1055
+ ...(config.headers ?? {}),
1056
+ [headerName]: this.#state.apiKey,
1057
+ };
1058
+ }
1059
+ }
1060
+
1061
+ return config;
1062
+ }
1063
+
1064
+ async #launchOAuthFlow(): Promise<void> {
1065
+ if (!this.#onOAuthCallback) {
1066
+ this.#contentContainer.clear();
1067
+ this.#contentContainer.addChild(new Text(theme.fg("error", "OAuth flow not available"), 0, 0));
1068
+ this.#renderStep();
1069
+ this.#requestRender();
1070
+ return;
1071
+ }
1072
+
1073
+ // Validate OAuth configuration
1074
+ if (!this.#state.oauthAuthUrl || !this.#state.oauthTokenUrl) {
1075
+ this.#contentContainer.clear();
1076
+ this.#contentContainer.addChild(new Text(theme.fg("error", "OAuth configuration incomplete"), 0, 0));
1077
+ this.#contentContainer.addChild(new Spacer(1));
1078
+ this.#contentContainer.addChild(new Text("Authorization and Token URLs are required.", 0, 0));
1079
+ this.#contentContainer.addChild(new Spacer(1));
1080
+ this.#contentContainer.addChild(new Text(theme.fg("muted", "[Press Esc to go back]"), 0, 0));
1081
+ this.#requestRender();
1082
+ return;
1083
+ }
1084
+
1085
+ // Show "Authenticating..." message
1086
+ this.#contentContainer.clear();
1087
+ this.#contentContainer.addChild(new Text(theme.fg("accent", "OAuth Authentication"), 0, 0));
1088
+ this.#contentContainer.addChild(new Spacer(1));
1089
+ this.#contentContainer.addChild(new Text("Launching OAuth flow...", 0, 0));
1090
+ this.#contentContainer.addChild(new Text(theme.fg("muted", "Browser will open automatically."), 0, 0));
1091
+ this.#contentContainer.addChild(new Spacer(1));
1092
+ this.#contentContainer.addChild(
1093
+ new Text(theme.fg("warning", "If browser doesn't open, copy the URL from chat."), 0, 0),
1094
+ );
1095
+ this.#contentContainer.addChild(new Spacer(1));
1096
+ this.#contentContainer.addChild(new Text(theme.fg("muted", "(Press Esc to cancel)"), 0, 0));
1097
+ this.#requestRender();
1098
+
1099
+ try {
1100
+ // Call OAuth handler
1101
+ const credentialId = await this.#onOAuthCallback(
1102
+ this.#state.oauthAuthUrl,
1103
+ this.#state.oauthTokenUrl,
1104
+ this.#state.oauthClientId,
1105
+ this.#state.oauthClientSecret,
1106
+ this.#state.oauthScopes,
1107
+ );
1108
+
1109
+ // Store credential ID
1110
+ this.#state.oauthCredentialId = credentialId;
1111
+
1112
+ // Show success message
1113
+ this.#contentContainer.clear();
1114
+ this.#contentContainer.addChild(new Text(theme.fg("success", "✓ Authentication successful!"), 0, 0));
1115
+ this.#contentContainer.addChild(new Spacer(1));
1116
+ this.#contentContainer.addChild(new Text(theme.fg("muted", "Running connection health check..."), 0, 0));
1117
+ const healthText = new Text(theme.fg("muted", "| Checking server connection..."), 0, 0);
1118
+ this.#contentContainer.addChild(healthText);
1119
+
1120
+ const spinnerFrames = ["|", "/", "-", "\\"];
1121
+ let spinnerIndex = 0;
1122
+ const spinner = setInterval(() => {
1123
+ healthText.setText(
1124
+ theme.fg("muted", `${spinnerFrames[spinnerIndex % spinnerFrames.length]} Checking server connection...`),
1125
+ );
1126
+ spinnerIndex++;
1127
+ this.#requestRender();
1128
+ }, 120);
1129
+
1130
+ let healthPassed = true;
1131
+ let healthError = "";
1132
+ if (this.#onTestConnectionCallback) {
1133
+ try {
1134
+ const { promise: timeoutPromise, reject: timeoutReject } = Promise.withResolvers<never>();
1135
+ const timer = setTimeout(
1136
+ () => timeoutReject(new Error("Health check timed out after 10 seconds")),
1137
+ 10_000,
1138
+ );
1139
+ try {
1140
+ await Promise.race([
1141
+ this.#onTestConnectionCallback(this.#buildServerConfigWithAuth(true)),
1142
+ timeoutPromise,
1143
+ ]);
1144
+ } finally {
1145
+ clearTimeout(timer);
1146
+ }
1147
+ } catch (error) {
1148
+ healthPassed = false;
1149
+ healthError = sanitize(error instanceof Error ? error.message : String(error));
1150
+ }
1151
+ }
1152
+
1153
+ clearInterval(spinner);
1154
+ if (healthPassed) {
1155
+ healthText.setText(theme.fg("success", "✓ Health check passed"));
1156
+ } else {
1157
+ healthText.setText(theme.fg("warning", "⚠ Health check failed (will still save config)"));
1158
+ this.#contentContainer.addChild(new Spacer(1));
1159
+ this.#contentContainer.addChild(new Text(theme.fg("muted", healthError), 0, 0));
1160
+ }
1161
+ this.#requestRender();
1162
+
1163
+ // Move to scope selection after short delay
1164
+ setTimeout(
1165
+ () => {
1166
+ this.#currentStep = "scope";
1167
+ this.#selectedIndex = 0;
1168
+ this.#renderStep();
1169
+ this.#requestRender();
1170
+ },
1171
+ healthPassed ? 1000 : 2000,
1172
+ );
1173
+ } catch (error) {
1174
+ // Show error with options to retry or go back
1175
+ const errorMsg = sanitize(error instanceof Error ? error.message : String(error));
1176
+ this.#contentContainer.clear();
1177
+ this.#contentContainer.addChild(new Text(theme.fg("error", "✗ OAuth authentication failed"), 0, 0));
1178
+ this.#contentContainer.addChild(new Spacer(1));
1179
+ this.#contentContainer.addChild(new Text(errorMsg, 0, 0));
1180
+ this.#contentContainer.addChild(new Spacer(1));
1181
+
1182
+ // Provide helpful tips based on error type
1183
+ if (errorMsg.includes("timeout") || errorMsg.includes("timed out")) {
1184
+ this.#contentContainer.addChild(
1185
+ new Text(theme.fg("muted", "Tip: Complete authorization faster next time"), 0, 0),
1186
+ );
1187
+ } else if (errorMsg.includes("Invalid OAuth URLs")) {
1188
+ this.#contentContainer.addChild(
1189
+ new Text(theme.fg("muted", "Tip: Check that the OAuth URLs are correct"), 0, 0),
1190
+ );
1191
+ } else if (errorMsg.includes("ECONNREFUSED")) {
1192
+ this.#contentContainer.addChild(
1193
+ new Text(theme.fg("muted", "Tip: Verify the OAuth server is accessible"), 0, 0),
1194
+ );
1195
+ }
1196
+
1197
+ this.#contentContainer.addChild(new Spacer(1));
1198
+ this.#contentContainer.addChild(new Text(`${theme.fg("accent", "→ ")}Retry`, 0, 0));
1199
+ this.#contentContainer.addChild(new Text(" Edit OAuth settings", 0, 0));
1200
+ this.#contentContainer.addChild(new Spacer(1));
1201
+ this.#contentContainer.addChild(
1202
+ new Text(theme.fg("muted", "[↑↓ to navigate, Enter to select, Esc to go back]"), 0, 0),
1203
+ );
1204
+ this.#requestRender();
1205
+
1206
+ // Set up as a selector step
1207
+ this.#selectedIndex = 0;
1208
+ this.#currentStep = "oauth-error";
1209
+ }
1210
+ }
1211
+
1212
+ #complete(): void {
1213
+ if (!this.#state.scope) return;
1214
+
1215
+ // Build the config
1216
+ const config: MCPServerConfig = this.#buildConfig();
1217
+
1218
+ // Call completion callback
1219
+ this.#onCompleteCallback(this.#state.name, config, this.#state.scope);
1220
+ }
1221
+
1222
+ #buildConfig(): MCPServerConfig {
1223
+ if (this.#state.transport === "stdio") {
1224
+ const config: MCPStdioServerConfig = {
1225
+ type: "stdio",
1226
+ command: this.#state.command,
1227
+ };
1228
+
1229
+ if (this.#state.args) {
1230
+ config.args = this.#state.args.split(/\s+/).filter(Boolean);
1231
+ }
1232
+
1233
+ // Add OAuth auth if configured
1234
+ if (this.#state.authMethod === "oauth" && this.#state.oauthCredentialId) {
1235
+ config.auth = {
1236
+ type: "oauth",
1237
+ credentialId: this.#state.oauthCredentialId,
1238
+ };
1239
+ }
1240
+
1241
+ // Add API key to env if manual auth — use user-chosen env var name
1242
+ if (this.#state.authMethod === "manual" && this.#state.apiKey) {
1243
+ const envKey = this.#state.envVarName || "API_KEY";
1244
+ config.env = {
1245
+ [envKey]: this.#state.apiKey,
1246
+ };
1247
+ }
1248
+
1249
+ return config;
1250
+ }
1251
+
1252
+ // HTTP or SSE — use concrete type
1253
+ const config: MCPHttpServerConfig | MCPSseServerConfig = {
1254
+ type: this.#state.transport!,
1255
+ url: this.#state.url,
1256
+ };
1257
+
1258
+ // Add OAuth auth if configured
1259
+ if (this.#state.authMethod === "oauth" && this.#state.oauthCredentialId) {
1260
+ config.auth = {
1261
+ type: "oauth",
1262
+ credentialId: this.#state.oauthCredentialId,
1263
+ };
1264
+ }
1265
+
1266
+ // Add API key using user-chosen header name and auth location
1267
+ if (this.#state.authMethod === "manual" && this.#state.apiKey) {
1268
+ if (this.#state.authLocation === "env") {
1269
+ // Env-based auth for HTTP: store the key in env on the config
1270
+ // HTTP/SSE configs don't have an env field, so use headers as carrier
1271
+ const headerName = this.#state.headerName || "Authorization";
1272
+ config.headers = {
1273
+ [headerName]: this.#state.apiKey,
1274
+ };
1275
+ } else {
1276
+ // Header-based auth: use the user's chosen header name
1277
+ const headerName = this.#state.headerName || "Authorization";
1278
+ config.headers = {
1279
+ [headerName]: this.#state.apiKey,
1280
+ };
1281
+ }
1282
+ }
1283
+
1284
+ return config;
1285
+ }
1286
+ }