@prestyj/cli 4.11.4 → 4.12.1

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 (100) hide show
  1. package/dist/app-sidecar.js +386 -55
  2. package/dist/app-sidecar.js.map +1 -1
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +5 -3
  5. package/dist/cli.js.map +1 -1
  6. package/dist/config.d.ts +5 -0
  7. package/dist/config.d.ts.map +1 -1
  8. package/dist/config.js +3 -0
  9. package/dist/config.js.map +1 -1
  10. package/dist/core/agent-session.d.ts +53 -11
  11. package/dist/core/agent-session.d.ts.map +1 -1
  12. package/dist/core/agent-session.js +150 -35
  13. package/dist/core/agent-session.js.map +1 -1
  14. package/dist/core/api-benchmark.d.ts +64 -0
  15. package/dist/core/api-benchmark.d.ts.map +1 -0
  16. package/dist/core/api-benchmark.js +381 -0
  17. package/dist/core/api-benchmark.js.map +1 -0
  18. package/dist/core/mcp/client.d.ts +21 -0
  19. package/dist/core/mcp/client.d.ts.map +1 -1
  20. package/dist/core/mcp/client.js +167 -5
  21. package/dist/core/mcp/client.js.map +1 -1
  22. package/dist/core/mcp/index.d.ts +3 -1
  23. package/dist/core/mcp/index.d.ts.map +1 -1
  24. package/dist/core/mcp/index.js +2 -0
  25. package/dist/core/mcp/index.js.map +1 -1
  26. package/dist/core/mcp/oauth-provider.d.ts +51 -0
  27. package/dist/core/mcp/oauth-provider.d.ts.map +1 -0
  28. package/dist/core/mcp/oauth-provider.js +95 -0
  29. package/dist/core/mcp/oauth-provider.js.map +1 -0
  30. package/dist/core/mcp/oauth-store.d.ts +39 -0
  31. package/dist/core/mcp/oauth-store.d.ts.map +1 -0
  32. package/dist/core/mcp/oauth-store.js +63 -0
  33. package/dist/core/mcp/oauth-store.js.map +1 -0
  34. package/dist/core/mcp/oauth-store.test.d.ts +2 -0
  35. package/dist/core/mcp/oauth-store.test.d.ts.map +1 -0
  36. package/dist/core/mcp/oauth-store.test.js +94 -0
  37. package/dist/core/mcp/oauth-store.test.js.map +1 -0
  38. package/dist/core/process-manager.d.ts.map +1 -1
  39. package/dist/core/process-manager.js +5 -1
  40. package/dist/core/process-manager.js.map +1 -1
  41. package/dist/core/prompt-commands.d.ts.map +1 -1
  42. package/dist/core/prompt-commands.js +112 -0
  43. package/dist/core/prompt-commands.js.map +1 -1
  44. package/dist/core/prompt-commands.test.js +22 -0
  45. package/dist/core/prompt-commands.test.js.map +1 -1
  46. package/dist/core/settings-manager.d.ts +4 -0
  47. package/dist/core/settings-manager.d.ts.map +1 -1
  48. package/dist/core/settings-manager.js +5 -0
  49. package/dist/core/settings-manager.js.map +1 -1
  50. package/dist/core/shell.d.ts +51 -0
  51. package/dist/core/shell.d.ts.map +1 -0
  52. package/dist/core/shell.js +82 -0
  53. package/dist/core/shell.js.map +1 -0
  54. package/dist/core/shell.test.d.ts +2 -0
  55. package/dist/core/shell.test.d.ts.map +1 -0
  56. package/dist/core/shell.test.js +87 -0
  57. package/dist/core/shell.test.js.map +1 -0
  58. package/dist/core/speed-benchmark.d.ts +133 -0
  59. package/dist/core/speed-benchmark.d.ts.map +1 -0
  60. package/dist/core/speed-benchmark.js +410 -0
  61. package/dist/core/speed-benchmark.js.map +1 -0
  62. package/dist/core/speed-benchmark.test.d.ts +2 -0
  63. package/dist/core/speed-benchmark.test.d.ts.map +1 -0
  64. package/dist/core/speed-benchmark.test.js +97 -0
  65. package/dist/core/speed-benchmark.test.js.map +1 -0
  66. package/dist/interactive.d.ts.map +1 -1
  67. package/dist/interactive.js +4 -3
  68. package/dist/interactive.js.map +1 -1
  69. package/dist/tools/bash.d.ts.map +1 -1
  70. package/dist/tools/bash.js +17 -1
  71. package/dist/tools/bash.js.map +1 -1
  72. package/dist/tools/generate-image.d.ts +39 -0
  73. package/dist/tools/generate-image.d.ts.map +1 -0
  74. package/dist/tools/generate-image.js +301 -0
  75. package/dist/tools/generate-image.js.map +1 -0
  76. package/dist/tools/generate-image.test.d.ts +2 -0
  77. package/dist/tools/generate-image.test.d.ts.map +1 -0
  78. package/dist/tools/generate-image.test.js +223 -0
  79. package/dist/tools/generate-image.test.js.map +1 -0
  80. package/dist/tools/index.d.ts +12 -1
  81. package/dist/tools/index.d.ts.map +1 -1
  82. package/dist/tools/index.js +16 -1
  83. package/dist/tools/index.js.map +1 -1
  84. package/dist/tools/plan-mode.test.js +5 -5
  85. package/dist/tools/plan-mode.test.js.map +1 -1
  86. package/dist/tools/prompt-hints.d.ts.map +1 -1
  87. package/dist/tools/prompt-hints.js +2 -0
  88. package/dist/tools/prompt-hints.js.map +1 -1
  89. package/dist/tools/safe-env.d.ts.map +1 -1
  90. package/dist/tools/safe-env.js +27 -0
  91. package/dist/tools/safe-env.js.map +1 -1
  92. package/dist/ui/App.d.ts +1 -1
  93. package/dist/ui/App.d.ts.map +1 -1
  94. package/dist/ui/hooks/usePixelFixFlow.d.ts +1 -1
  95. package/dist/ui/hooks/usePixelFixFlow.d.ts.map +1 -1
  96. package/dist/ui/hooks/usePixelFixFlow.js +1 -1
  97. package/dist/ui/hooks/usePixelFixFlow.js.map +1 -1
  98. package/dist/ui/render.d.ts +1 -1
  99. package/dist/ui/render.d.ts.map +1 -1
  100. package/package.json +5 -5
@@ -38,8 +38,10 @@ import { loadTasksSync, saveTasksSync, getNextPendingTask, markTaskInProgress, }
38
38
  import { initLogger, log } from "./core/logger.js";
39
39
  import { RADIO_STATIONS, getCurrentStation, playRadio, stopRadio } from "./core/radio.js";
40
40
  import { enrichProcessPath } from "./core/shell-path.js";
41
+ import { downscaleForPreview } from "./utils/image.js";
41
42
  import { startServeMode } from "./modes/serve-mode.js";
42
43
  import { loadTelegramConfig, saveTelegramConfig, verifyBotToken } from "./core/telegram-config.js";
44
+ import { loadServers, addServer, removeServer, getServer, parseMcpAddCommand, MCPClientManager, McpOAuthStore, } from "./core/mcp/index.js";
43
45
  const ALL_PROVIDERS = [
44
46
  "anthropic",
45
47
  "xiaomi",
@@ -268,6 +270,58 @@ function detectPromptCommand(text, candidates) {
268
270
  }
269
271
  return null;
270
272
  }
273
+ /** A short transport summary for display (URL for http/sse, command+args for stdio). */
274
+ function mcpRowSummary(config) {
275
+ if (config.url)
276
+ return config.url;
277
+ return [config.command, ...(config.args ?? [])].filter(Boolean).join(" ");
278
+ }
279
+ /** Load + connect every server, returning one wire row per server. Mirrors the
280
+ * CLI dashboard's buildRows (connectAllDetailed, then dispose). Empty list
281
+ * short-circuits before spawning any stdio process / opening any HTTP conn. */
282
+ async function buildMcpRows(cwd) {
283
+ const scoped = await loadServers(cwd);
284
+ if (scoped.length === 0)
285
+ return [];
286
+ const manager = new MCPClientManager();
287
+ try {
288
+ const results = await manager.connectAllDetailed(scoped.map((s) => s.config));
289
+ return scoped.map((s) => {
290
+ const result = results.find((r) => r.name === s.config.name);
291
+ return {
292
+ name: s.config.name,
293
+ scope: s.scope,
294
+ ok: result?.ok ?? false,
295
+ toolCount: result?.toolCount ?? 0,
296
+ error: result?.error,
297
+ kind: s.config.url ? "http" : "stdio",
298
+ summary: mcpRowSummary(s.config),
299
+ requiresAuth: result?.requiresAuth,
300
+ };
301
+ });
302
+ }
303
+ finally {
304
+ await manager.dispose();
305
+ }
306
+ }
307
+ /** Probe a single server's connection before persisting it. Never throws — a
308
+ * failed probe returns ok:false with a human-readable error so the config can
309
+ * still be saved. Mirrors the CLI's probeServer. */
310
+ async function probeMcp(config) {
311
+ const manager = new MCPClientManager();
312
+ try {
313
+ const result = await manager.probe(config);
314
+ return {
315
+ ok: result.ok,
316
+ toolCount: result.toolCount,
317
+ error: result.error,
318
+ requiresAuth: result.requiresAuth,
319
+ };
320
+ }
321
+ finally {
322
+ await manager.dispose();
323
+ }
324
+ }
271
325
  /**
272
326
  * Sub-agents spawn the ezcoder CLI in JSON mode to run a delegated task. In the
273
327
  * packaged desktop app the only runnable entry is THIS bundle (there's no
@@ -323,6 +377,25 @@ async function main() {
323
377
  // ~/.ezcoder/debug.log (initLogger truncates on each start).
324
378
  const sidecarLog = path.join(paths.agentDir, "ezcoder-app-sidecar.log");
325
379
  initLogger(sidecarLog);
380
+ // Global last-resort guards, installed as early as the logger allows so they
381
+ // cover the WHOLE lifecycle — including startup/initialize, the phase the
382
+ // "sidecar did not start in time" bug lives in. The sidecar is a long-lived
383
+ // HTTP server the Rust shell can respawn: a stray rejection or thrown error
384
+ // from one request (e.g. an MCP probe spawning a misbehaving child) must not
385
+ // tear down the whole process and strand the window on its next call. Log and
386
+ // keep serving (mirrors astro/vscode/gstack long-lived-server handlers).
387
+ process.on("unhandledRejection", (reason) => {
388
+ log("ERROR", "app-sidecar", "unhandledRejection", {
389
+ message: reason instanceof Error ? reason.message : String(reason),
390
+ stack: reason instanceof Error ? reason.stack : undefined,
391
+ });
392
+ });
393
+ process.on("uncaughtException", (err) => {
394
+ log("ERROR", "app-sidecar", "uncaughtException", {
395
+ message: err.message,
396
+ stack: err.stack,
397
+ });
398
+ });
326
399
  // The packaged desktop app launches from Finder/Dock with a minimal PATH that
327
400
  // omits Homebrew/Cargo/version-manager dirs, so the agent can't find node,
328
401
  // git, python, rg, etc. Enrich process.env.PATH from the login shell once,
@@ -373,6 +446,12 @@ async function main() {
373
446
  thinkingLevel,
374
447
  sessionId: resumeSessionPath,
375
448
  signal: abort.signal,
449
+ // The shell gates window readiness on the GG_APP_LISTENING handshake, which
450
+ // can't fire until initialize() resolves. Connect MCP in the background so a
451
+ // slow or hanging stdio server (e.g. a first-run `npx -y @playwright/mcp`
452
+ // download) can't delay the sidecar past the webview's startup timeout
453
+ // ("sidecar did not start in time"). Tools attach when the servers come up.
454
+ backgroundMcpConnect: true,
376
455
  // Plan mode: the agent's enter_plan/exit_plan tools drive these. We flip
377
456
  // session plan state (rebuilds the system prompt + enforces read-only
378
457
  // tools) and surface the transition to the webview.
@@ -419,9 +498,32 @@ async function main() {
419
498
  // Forward every relevant bus event to the webview.
420
499
  session.eventBus.on("text_delta", (d) => broadcast("text_delta", d));
421
500
  session.eventBus.on("thinking_delta", (d) => broadcast("thinking_delta", d));
422
- session.eventBus.on("tool_call_start", (d) => broadcast("tool_call_start", d));
501
+ // tool_call_end omits the tool name, so remember it from tool_call_start (keyed
502
+ // by toolCallId) to detect when the `tasks` tool just mutated the task store.
503
+ const toolNamesById = new Map();
504
+ session.eventBus.on("tool_call_start", (d) => {
505
+ if (d?.toolCallId && typeof d.name === "string")
506
+ toolNamesById.set(d.toolCallId, d.name);
507
+ broadcast("tool_call_start", d);
508
+ });
423
509
  session.eventBus.on("tool_call_update", (d) => broadcast("tool_call_update", d));
424
- session.eventBus.on("tool_call_end", (d) => broadcast("tool_call_end", d));
510
+ session.eventBus.on("tool_call_end", (d) => {
511
+ broadcast("tool_call_end", d);
512
+ // The agent can add/done/remove project tasks via the `tasks` tool during a
513
+ // normal chat run. Push the refreshed list so the webview's Tasks modal and
514
+ // footer badge update live instead of going stale until the modal reopens.
515
+ const name = d?.toolCallId ? toolNamesById.get(d.toolCallId) : undefined;
516
+ if (d?.toolCallId)
517
+ toolNamesById.delete(d.toolCallId);
518
+ if (name === "tasks") {
519
+ broadcast("tasks_list", { tasks: loadTasksSync(cwd) });
520
+ }
521
+ });
522
+ // Native server tools (e.g. Anthropic web_search) do NOT end the turn — text
523
+ // streams before and after them in the SAME turn. The webview must reset its
524
+ // streaming bubble here, or the two text blocks concatenate with no separator
525
+ // ("…command.Let me pull…"). Mirrors the TUI's server_tool_call handling.
526
+ session.eventBus.on("server_tool_call", (d) => broadcast("server_tool_call", d));
425
527
  session.eventBus.on("turn_end", (d) => broadcast("turn_end", d));
426
528
  session.eventBus.on("agent_done", (d) => broadcast("agent_done", d));
427
529
  session.eventBus.on("error", (d) => broadcast("error", { message: d.error instanceof Error ? d.error.message : String(d.error) }));
@@ -764,60 +866,126 @@ async function main() {
764
866
  return;
765
867
  }
766
868
  if (method === "GET" && url === "/history") {
767
- // Flatten the resumed conversation into the webview's transcript shape:
768
- // user + assistant TEXT only (tools live in the live panel, never the
769
- // transcript; system + tool-result messages are omitted). Self-correction
770
- // hook prompts (injected as user messages) are tagged with their `hook`
771
- // kind so the webview renders the short "Hook engaged" line, not the raw
772
- // prompt body — matching how they appear live.
869
+ // Reconstruct the transcript from persisted messages so resume is 1:1 with
870
+ // the live SSE stream. Walks ALL message types (not just user/assistant):
871
+ // tool result messages carry ImageContent blocks (screenshots,
872
+ // generate_image) that must re-render inline, and assistant tool_call
873
+ // blocks carry sub-agent delegations that must re-appear as group items.
773
874
  //
774
- // Prompt-template commands persist their FULL expanded body as the user
775
- // message, so on resume we reverse the expansion (built-in + custom
776
- // candidates) back to the short `/name [args]` chip the user saw live.
875
+ // The `details` object (imagePreviews with path + downscaled preview) is
876
+ // event-only and never persisted we reconstruct from the raw
877
+ // ImageContent in the tool result, downsampling on the sidecar side and
878
+ // extracting the path from the text block ("Generated image → /path").
777
879
  void (async () => {
778
880
  const commandCandidates = [...PROMPT_COMMANDS, ...(await loadCustomCommands(cwd))];
779
- const history = session
780
- .getMessages()
781
- .filter((m) => m.role === "user" || m.role === "assistant")
782
- .map((m) => {
783
- // `m.content` is a union of differently-typed arrays (user vs
784
- // assistant parts), so a type-predicate filter won't narrow cleanly.
785
- // A structural `"text" in c` check extracts text from any text-bearing
786
- // part regardless of the surrounding union.
787
- const text = typeof m.content === "string"
788
- ? m.content
789
- : m.content
881
+ const messages = session.getMessages();
882
+ // Pre-index tool results by toolCallId so we can pair tool calls with
883
+ // their results (for sub-agent status + image extraction).
884
+ const toolResultMap = new Map();
885
+ for (const msg of messages) {
886
+ if (msg.role !== "tool")
887
+ continue;
888
+ for (const tr of msg.content) {
889
+ toolResultMap.set(tr.toolCallId, {
890
+ content: tr.content,
891
+ isError: tr.isError ?? false,
892
+ });
893
+ }
894
+ }
895
+ const history = [];
896
+ for (const msg of messages) {
897
+ if (msg.role === "system")
898
+ continue;
899
+ if (msg.role === "tool") {
900
+ // Tool result messages: check for ImageContent blocks (screenshots,
901
+ // generated images) and emit a toolImages entry.
902
+ for (const tr of msg.content) {
903
+ if (typeof tr.content === "string")
904
+ continue;
905
+ const imageBlocks = tr.content.filter((c) => c.type === "image");
906
+ if (imageBlocks.length === 0)
907
+ continue;
908
+ // Extract the path from the text block (e.g. "Generated image → /path").
909
+ const textBlock = tr.content.find((c) => c.type === "text" && "text" in c && typeof c.text === "string");
910
+ const textContent = textBlock && textBlock.type === "text" ? textBlock.text : "";
911
+ const pathMatch = textContent.match(/→\s*(\S+)/);
912
+ const imgPath = pathMatch?.[1];
913
+ // Downscale each image for the webview preview.
914
+ const toolImages = [];
915
+ for (const block of imageBlocks) {
916
+ if (block.type !== "image")
917
+ continue;
918
+ try {
919
+ const rawBuf = Buffer.from(block.data, "base64");
920
+ const previewBuf = await downscaleForPreview(rawBuf);
921
+ toolImages.push({
922
+ src: `data:${block.mediaType};base64,${previewBuf.toString("base64")}`,
923
+ path: imgPath,
924
+ });
925
+ }
926
+ catch {
927
+ // Downscale failed — use the raw data.
928
+ toolImages.push({
929
+ src: `data:${block.mediaType};base64,${block.data}`,
930
+ path: imgPath,
931
+ });
932
+ }
933
+ }
934
+ if (toolImages.length > 0) {
935
+ history.push({
936
+ role: "assistant",
937
+ text: "",
938
+ toolImages,
939
+ });
940
+ }
941
+ }
942
+ continue;
943
+ }
944
+ // User or assistant message — existing text/hook/command/compacted
945
+ // extraction, plus sub-agent group detection for assistant tool_calls.
946
+ const text = typeof msg.content === "string"
947
+ ? msg.content
948
+ : msg.content
790
949
  .map((c) => c.type === "text" && "text" in c && typeof c.text === "string" ? c.text : "")
791
950
  .join("");
792
- // Reconstruct image attachments as data URLs so they re-render on
793
- // resume — the webview only ever saw the live SSE stream, and the
794
- // persisted message holds the base64 bytes. Without this, attached
795
- // images vanish when returning to a session.
796
- const images = typeof m.content === "string"
951
+ const images = typeof msg.content === "string"
797
952
  ? []
798
- : m.content.flatMap((c) => c.type === "image" ? [`data:${c.mediaType};base64,${c.data}`] : []);
799
- const hook = m.role === "user" ? detectHookKind(text) : null;
800
- // A compacted session persists its summary as a user message prefixed
801
- // with this marker. Tag it so the webview renders the quiet "Compacted
802
- // context" notice instead of dumping the full summary body.
803
- const compacted = m.role === "user" && !hook && text.startsWith("[Previous conversation summary]");
804
- // Recover a `/name [args]` command invocation from its expanded body
805
- // (skip messages already claimed as hooks or compaction summaries).
806
- const command = m.role === "user" && !hook && !compacted
953
+ : msg.content.flatMap((c) => c.type === "image" ? [`data:${c.mediaType};base64,${c.data}`] : []);
954
+ const hook = msg.role === "user" ? detectHookKind(text) : null;
955
+ const compacted = msg.role === "user" && !hook && text.startsWith("[Previous conversation summary]");
956
+ const command = msg.role === "user" && !hook && !compacted
807
957
  ? detectPromptCommand(text, commandCandidates)
808
958
  : null;
809
- return {
810
- role: m.role,
811
- text: command ?? text,
812
- images,
813
- hook,
814
- command: command !== null,
815
- compacted,
816
- };
817
- })
818
- // Keep messages with text OR images — an image-only user turn has empty
819
- // text but must still appear.
820
- .filter((m) => m.text.trim().length > 0 || m.images.length > 0);
959
+ if (text.trim() || images.length > 0) {
960
+ history.push({
961
+ role: msg.role,
962
+ text: command ?? text,
963
+ images,
964
+ hook,
965
+ command: command !== null,
966
+ compacted,
967
+ });
968
+ }
969
+ // Assistant tool_call blocks: detect sub-agent delegations.
970
+ if (msg.role === "assistant" && typeof msg.content !== "string") {
971
+ const subagentCalls = msg.content.filter((c) => c.type === "tool_call" && c.name === "subagent");
972
+ if (subagentCalls.length > 0) {
973
+ const agents = subagentCalls.map((c) => {
974
+ const result = toolResultMap.get(c.id);
975
+ return {
976
+ agentName: typeof c.args?.agent === "string" ? c.args.agent : undefined,
977
+ status: result?.isError ? "error" : "done",
978
+ toolUseCount: 0,
979
+ };
980
+ });
981
+ history.push({
982
+ role: "assistant",
983
+ text: "",
984
+ subagentGroup: agents,
985
+ });
986
+ }
987
+ }
988
+ }
821
989
  json(res, 200, { history });
822
990
  })();
823
991
  return;
@@ -864,13 +1032,11 @@ async function main() {
864
1032
  return;
865
1033
  }
866
1034
  if (running) {
867
- // Queue text prompts as mid-run steering (mirrors the CLI). Attachments
868
- // aren't supported mid-run reject those so the user resends after.
869
- if (attachments.length > 0) {
870
- json(res, 409, { error: "cannot attach files while the agent is running" });
871
- return;
872
- }
873
- const count = session.queueMessage(text);
1035
+ // Queue prompts as mid-run steering (mirrors the CLI). Attachments are
1036
+ // persisted to .gg/uploads first so the queued media rides the same
1037
+ // native-block path as a non-queued attachment prompt when it drains.
1038
+ const prepared = attachments.length > 0 ? await prepareAttachments(cwd, attachments) : [];
1039
+ const count = session.queueMessage(text, prepared);
874
1040
  broadcast("queued", { count });
875
1041
  json(res, 202, { queued: true, count });
876
1042
  return;
@@ -1373,6 +1539,171 @@ async function main() {
1373
1539
  })();
1374
1540
  return;
1375
1541
  }
1542
+ // ── MCP server management (mirrors `ezcoder mcp`) ──────────────────
1543
+ // `targetCwd` (project scope) overrides the window cwd so a server can be
1544
+ // added/removed for ANY discovered project, not just this window's. Global
1545
+ // scope ignores it (always ~/.ezcoder/mcp.json).
1546
+ if (method === "GET" && (url === "/mcp" || url.startsWith("/mcp?"))) {
1547
+ const targetCwd = new URL(url, `http://${host}`).searchParams.get("cwd") ?? cwd;
1548
+ void buildMcpRows(targetCwd)
1549
+ .then((servers) => json(res, 200, { servers }))
1550
+ .catch((err) => {
1551
+ log("ERROR", "app-sidecar", "buildMcpRows failed", {
1552
+ message: err instanceof Error ? err.message : String(err),
1553
+ });
1554
+ json(res, 200, { servers: [] });
1555
+ });
1556
+ return;
1557
+ }
1558
+ if (method === "POST" && url === "/mcp/add") {
1559
+ void readBody(req).then(async (raw) => {
1560
+ let line;
1561
+ let scopeValue;
1562
+ let bodyCwd;
1563
+ try {
1564
+ const body = JSON.parse(raw);
1565
+ line = body.line ?? "";
1566
+ scopeValue = body.scope ?? "global";
1567
+ bodyCwd = body.cwd;
1568
+ }
1569
+ catch {
1570
+ json(res, 400, { error: "invalid JSON body" });
1571
+ return;
1572
+ }
1573
+ const scope = scopeValue === "project" ? "project" : "global";
1574
+ if (scope === "project" && !bodyCwd) {
1575
+ json(res, 400, { error: "project scope requires a project (cwd)." });
1576
+ return;
1577
+ }
1578
+ const targetCwd = bodyCwd ?? cwd;
1579
+ const parsed = parseMcpAddCommand(line);
1580
+ if (!parsed.ok) {
1581
+ json(res, 400, { error: parsed.error });
1582
+ return;
1583
+ }
1584
+ const config = parsed.value.config;
1585
+ try {
1586
+ // Best-effort probe — never blocks the save. A failed connect is
1587
+ // surfaced to the UI but the config is still persisted (mirrors the
1588
+ // CLI). probeMcp swallows connect errors; the try/catch guards the
1589
+ // persist step so a write failure returns a 500 instead of becoming
1590
+ // an unhandled rejection that would crash the sidecar.
1591
+ const probe = await probeMcp(config);
1592
+ const saved = await addServer(config, scope, targetCwd, true);
1593
+ if (!saved.ok) {
1594
+ json(res, 400, { error: saved.error });
1595
+ return;
1596
+ }
1597
+ json(res, 200, {
1598
+ ok: true,
1599
+ name: config.name,
1600
+ connected: probe.ok,
1601
+ toolCount: probe.toolCount,
1602
+ error: probe.error,
1603
+ requiresAuth: probe.requiresAuth,
1604
+ });
1605
+ }
1606
+ catch (err) {
1607
+ json(res, 500, {
1608
+ error: err instanceof Error ? err.message : String(err),
1609
+ });
1610
+ }
1611
+ });
1612
+ return;
1613
+ }
1614
+ if (method === "POST" && url === "/mcp/remove") {
1615
+ void readBody(req).then(async (raw) => {
1616
+ let name;
1617
+ let scopeValue;
1618
+ let bodyCwd;
1619
+ try {
1620
+ const body = JSON.parse(raw);
1621
+ name = body.name ?? "";
1622
+ scopeValue = body.scope ?? "global";
1623
+ bodyCwd = body.cwd;
1624
+ }
1625
+ catch {
1626
+ json(res, 400, { error: "invalid JSON body" });
1627
+ return;
1628
+ }
1629
+ if (!name.trim()) {
1630
+ json(res, 400, { error: "missing server name" });
1631
+ return;
1632
+ }
1633
+ const scope = scopeValue === "project" ? "project" : "global";
1634
+ if (scope === "project" && !bodyCwd) {
1635
+ json(res, 400, { error: "project scope requires a project (cwd)." });
1636
+ return;
1637
+ }
1638
+ const targetCwd = bodyCwd ?? cwd;
1639
+ const removed = await removeServer(name, scope, targetCwd);
1640
+ // Drop any saved OAuth tokens for this server so a re-add starts clean.
1641
+ await new McpOAuthStore().clear(name).catch(() => { });
1642
+ json(res, 200, { removed });
1643
+ });
1644
+ return;
1645
+ }
1646
+ // Interactive OAuth login for a remote (HTTP) MCP server. The browser is
1647
+ // opened by the webview in response to the broadcast `mcp_auth_url` event;
1648
+ // progress + outcome stream via `mcp_auth_status` / `mcp_auth_done` /
1649
+ // `mcp_auth_error`. Responds 202 immediately and runs the flow in the
1650
+ // background (the browser round-trip can take a while).
1651
+ if (method === "POST" && url === "/mcp/login") {
1652
+ void readBody(req).then(async (raw) => {
1653
+ let name;
1654
+ let scopeValue;
1655
+ let bodyCwd;
1656
+ try {
1657
+ const body = JSON.parse(raw);
1658
+ name = body.name ?? "";
1659
+ scopeValue = body.scope ?? "global";
1660
+ bodyCwd = body.cwd;
1661
+ }
1662
+ catch {
1663
+ json(res, 400, { error: "invalid JSON body" });
1664
+ return;
1665
+ }
1666
+ if (!name.trim()) {
1667
+ json(res, 400, { error: "missing server name" });
1668
+ return;
1669
+ }
1670
+ const scope = scopeValue === "project" ? "project" : "global";
1671
+ const targetCwd = bodyCwd ?? cwd;
1672
+ const scoped = await getServer(name, targetCwd);
1673
+ if (!scoped || scoped.scope !== scope) {
1674
+ json(res, 404, { error: `No "${name}" server found.` });
1675
+ return;
1676
+ }
1677
+ if (!scoped.config.url) {
1678
+ json(res, 400, { error: "Login is only supported for HTTP MCP servers." });
1679
+ return;
1680
+ }
1681
+ json(res, 202, { accepted: true });
1682
+ broadcast("mcp_auth_status", { name, message: "Starting login\u2026" });
1683
+ const manager = new MCPClientManager();
1684
+ try {
1685
+ const result = await manager.login(scoped.config, (authUrl) => {
1686
+ broadcast("mcp_auth_url", { name, url: authUrl });
1687
+ });
1688
+ if (result.ok) {
1689
+ broadcast("mcp_auth_done", { name, toolCount: result.toolCount });
1690
+ }
1691
+ else {
1692
+ broadcast("mcp_auth_error", { name, message: result.error ?? "Login failed." });
1693
+ }
1694
+ }
1695
+ catch (err) {
1696
+ broadcast("mcp_auth_error", {
1697
+ name,
1698
+ message: err instanceof Error ? err.message : String(err),
1699
+ });
1700
+ }
1701
+ finally {
1702
+ await manager.dispose().catch(() => { });
1703
+ }
1704
+ });
1705
+ return;
1706
+ }
1376
1707
  json(res, 404, { error: "not found" });
1377
1708
  });
1378
1709
  server.listen(port, host, () => {