@oh-my-pi/pi-coding-agent 14.9.9 → 15.0.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 (230) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/examples/extensions/plan-mode.ts +0 -1
  3. package/package.json +9 -9
  4. package/scripts/build-binary.ts +5 -0
  5. package/scripts/format-prompts.ts +1 -1
  6. package/src/autoresearch/helpers.ts +17 -0
  7. package/src/autoresearch/tools/log-experiment.ts +9 -17
  8. package/src/autoresearch/tools/run-experiment.ts +2 -17
  9. package/src/capability/skill.ts +7 -0
  10. package/src/cli/args.ts +2 -2
  11. package/src/cli/list-models.ts +1 -1
  12. package/src/cli/shell-cli.ts +3 -13
  13. package/src/cli/update-cli.ts +1 -1
  14. package/src/cli.ts +11 -29
  15. package/src/commands/acp.ts +24 -0
  16. package/src/commands/launch.ts +6 -4
  17. package/src/commit/agentic/prompts/system.md +1 -1
  18. package/src/commit/agentic/tools/propose-changelog.ts +8 -1
  19. package/src/commit/analysis/conventional.ts +8 -66
  20. package/src/commit/map-reduce/reduce-phase.ts +6 -65
  21. package/src/commit/pipeline.ts +2 -2
  22. package/src/commit/shared-llm.ts +89 -0
  23. package/src/config/config-file.ts +210 -0
  24. package/src/config/model-equivalence.ts +8 -11
  25. package/src/config/model-registry.ts +13 -2
  26. package/src/config/model-resolver.ts +31 -4
  27. package/src/config/settings-schema.ts +102 -1
  28. package/src/config/settings.ts +1 -1
  29. package/src/config.ts +3 -219
  30. package/src/edit/index.ts +22 -1
  31. package/src/edit/modes/patch.ts +10 -0
  32. package/src/edit/modes/replace.ts +3 -0
  33. package/src/edit/renderer.ts +17 -1
  34. package/src/eval/js/context-manager.ts +1 -1
  35. package/src/eval/js/executor.ts +3 -0
  36. package/src/eval/js/shared/rewrite-imports.ts +122 -50
  37. package/src/eval/js/shared/runtime.ts +31 -4
  38. package/src/eval/js/tool-bridge.ts +43 -21
  39. package/src/eval/py/executor.ts +5 -0
  40. package/src/exa/factory.ts +2 -2
  41. package/src/exa/mcp-client.ts +74 -1
  42. package/src/exec/bash-executor.ts +5 -1
  43. package/src/export/html/template.generated.ts +1 -1
  44. package/src/export/html/template.js +0 -11
  45. package/src/extensibility/extensions/runner.ts +55 -2
  46. package/src/extensibility/extensions/types.ts +98 -221
  47. package/src/extensibility/hooks/types.ts +89 -314
  48. package/src/extensibility/shared-events.ts +343 -0
  49. package/src/extensibility/skills.ts +42 -1
  50. package/src/goals/index.ts +3 -0
  51. package/src/goals/runtime.ts +500 -0
  52. package/src/goals/state.ts +37 -0
  53. package/src/goals/tools/goal-tool.ts +237 -0
  54. package/src/hashline/anchors.ts +2 -2
  55. package/src/hindsight/mental-models.ts +1 -1
  56. package/src/internal-urls/agent-protocol.ts +1 -20
  57. package/src/internal-urls/artifact-protocol.ts +1 -19
  58. package/src/internal-urls/docs-index.generated.ts +9 -10
  59. package/src/internal-urls/index.ts +1 -0
  60. package/src/internal-urls/issue-pr-protocol.ts +577 -0
  61. package/src/internal-urls/registry-helpers.ts +25 -0
  62. package/src/internal-urls/router.ts +6 -3
  63. package/src/internal-urls/types.ts +22 -1
  64. package/src/main.ts +24 -11
  65. package/src/mcp/oauth-flow.ts +20 -0
  66. package/src/modes/acp/acp-agent.ts +412 -71
  67. package/src/modes/acp/acp-client-bridge.ts +152 -0
  68. package/src/modes/acp/acp-event-mapper.ts +180 -15
  69. package/src/modes/acp/terminal-auth.ts +37 -0
  70. package/src/modes/components/assistant-message.ts +14 -8
  71. package/src/modes/components/bash-execution.ts +24 -63
  72. package/src/modes/components/custom-message.ts +14 -40
  73. package/src/modes/components/eval-execution.ts +27 -57
  74. package/src/modes/components/execution-shared.ts +102 -0
  75. package/src/modes/components/hook-message.ts +17 -49
  76. package/src/modes/components/mcp-add-wizard.ts +26 -5
  77. package/src/modes/components/message-frame.ts +88 -0
  78. package/src/modes/components/model-selector.ts +1 -1
  79. package/src/modes/components/read-tool-group.ts +29 -1
  80. package/src/modes/components/session-observer-overlay.ts +6 -2
  81. package/src/modes/components/session-selector.ts +1 -1
  82. package/src/modes/components/status-line/segments.ts +55 -4
  83. package/src/modes/components/status-line/types.ts +4 -0
  84. package/src/modes/components/status-line.ts +28 -10
  85. package/src/modes/components/tool-execution.ts +7 -8
  86. package/src/modes/controllers/command-controller-shared.ts +108 -0
  87. package/src/modes/controllers/command-controller.ts +27 -10
  88. package/src/modes/controllers/event-controller.ts +60 -18
  89. package/src/modes/controllers/extension-ui-controller.ts +8 -2
  90. package/src/modes/controllers/input-controller.ts +85 -39
  91. package/src/modes/controllers/mcp-command-controller.ts +56 -61
  92. package/src/modes/controllers/ssh-command-controller.ts +18 -57
  93. package/src/modes/interactive-mode.ts +675 -39
  94. package/src/modes/print-mode.ts +16 -86
  95. package/src/modes/rpc/rpc-mode.ts +30 -88
  96. package/src/modes/runtime-init.ts +115 -0
  97. package/src/modes/theme/defaults/dark-poimandres.json +2 -0
  98. package/src/modes/theme/defaults/light-poimandres.json +2 -0
  99. package/src/modes/theme/theme.ts +18 -6
  100. package/src/modes/types.ts +20 -5
  101. package/src/modes/utils/context-usage.ts +13 -13
  102. package/src/modes/utils/ui-helpers.ts +25 -6
  103. package/src/plan-mode/approved-plan.ts +35 -1
  104. package/src/prompts/agents/designer.md +5 -5
  105. package/src/prompts/agents/explore.md +7 -7
  106. package/src/prompts/agents/init.md +9 -9
  107. package/src/prompts/agents/librarian.md +14 -14
  108. package/src/prompts/agents/plan.md +4 -4
  109. package/src/prompts/agents/reviewer.md +5 -5
  110. package/src/prompts/agents/task.md +10 -10
  111. package/src/prompts/commands/orchestrate.md +2 -2
  112. package/src/prompts/compaction/branch-summary.md +3 -3
  113. package/src/prompts/compaction/compaction-short-summary.md +7 -7
  114. package/src/prompts/compaction/compaction-summary-context.md +1 -1
  115. package/src/prompts/compaction/compaction-summary.md +5 -5
  116. package/src/prompts/compaction/compaction-turn-prefix.md +3 -3
  117. package/src/prompts/compaction/compaction-update-summary.md +11 -11
  118. package/src/prompts/goals/goal-budget-limit.md +16 -0
  119. package/src/prompts/goals/goal-continuation.md +28 -0
  120. package/src/prompts/goals/goal-mode-active.md +23 -0
  121. package/src/prompts/memories/consolidation.md +2 -2
  122. package/src/prompts/memories/read-path.md +1 -1
  123. package/src/prompts/memories/stage_one_input.md +1 -1
  124. package/src/prompts/memories/stage_one_system.md +5 -5
  125. package/src/prompts/review-request.md +4 -4
  126. package/src/prompts/system/agent-creation-architect.md +17 -17
  127. package/src/prompts/system/agent-creation-user.md +2 -2
  128. package/src/prompts/system/commit-message-system.md +2 -2
  129. package/src/prompts/system/custom-system-prompt.md +2 -2
  130. package/src/prompts/system/eager-todo.md +6 -6
  131. package/src/prompts/system/handoff-document.md +1 -1
  132. package/src/prompts/system/plan-mode-active.md +25 -24
  133. package/src/prompts/system/plan-mode-approved.md +4 -4
  134. package/src/prompts/system/plan-mode-compact-instructions.md +16 -0
  135. package/src/prompts/system/plan-mode-reference.md +2 -2
  136. package/src/prompts/system/plan-mode-subagent.md +8 -8
  137. package/src/prompts/system/plan-mode-tool-decision-reminder.md +3 -3
  138. package/src/prompts/system/project-prompt.md +4 -4
  139. package/src/prompts/system/subagent-system-prompt.md +7 -7
  140. package/src/prompts/system/subagent-yield-reminder.md +4 -4
  141. package/src/prompts/system/system-prompt.md +72 -71
  142. package/src/prompts/system/ttsr-interrupt.md +1 -1
  143. package/src/prompts/tools/apply-patch.md +1 -1
  144. package/src/prompts/tools/ast-edit.md +3 -3
  145. package/src/prompts/tools/ast-grep.md +3 -3
  146. package/src/prompts/tools/bash.md +6 -0
  147. package/src/prompts/tools/browser.md +3 -3
  148. package/src/prompts/tools/checkpoint.md +3 -3
  149. package/src/prompts/tools/find.md +3 -3
  150. package/src/prompts/tools/github.md +2 -5
  151. package/src/prompts/tools/goal.md +13 -0
  152. package/src/prompts/tools/hashline.md +104 -116
  153. package/src/prompts/tools/image-gen.md +3 -3
  154. package/src/prompts/tools/irc.md +1 -1
  155. package/src/prompts/tools/lsp.md +2 -2
  156. package/src/prompts/tools/patch.md +6 -6
  157. package/src/prompts/tools/read.md +8 -7
  158. package/src/prompts/tools/replace.md +5 -5
  159. package/src/prompts/tools/resolve.md +6 -5
  160. package/src/prompts/tools/retain.md +1 -1
  161. package/src/prompts/tools/rewind.md +2 -2
  162. package/src/prompts/tools/search.md +2 -2
  163. package/src/prompts/tools/ssh.md +2 -2
  164. package/src/prompts/tools/task.md +12 -6
  165. package/src/prompts/tools/web-search.md +2 -2
  166. package/src/prompts/tools/write.md +3 -3
  167. package/src/sdk.ts +81 -17
  168. package/src/session/agent-session.ts +656 -125
  169. package/src/session/blob-store.ts +36 -3
  170. package/src/session/client-bridge.ts +81 -0
  171. package/src/session/compaction/errors.ts +31 -0
  172. package/src/session/compaction/index.ts +1 -0
  173. package/src/session/messages.ts +67 -2
  174. package/src/session/session-manager.ts +131 -12
  175. package/src/session/session-storage.ts +33 -15
  176. package/src/session/streaming-output.ts +309 -13
  177. package/src/slash-commands/acp-builtins.ts +46 -0
  178. package/src/slash-commands/builtin-registry.ts +717 -116
  179. package/src/slash-commands/helpers/context-report.ts +39 -0
  180. package/src/slash-commands/helpers/format.ts +23 -0
  181. package/src/slash-commands/helpers/marketplace-manager.ts +25 -0
  182. package/src/slash-commands/helpers/mcp.ts +532 -0
  183. package/src/slash-commands/helpers/parse.ts +85 -0
  184. package/src/slash-commands/helpers/ssh.ts +193 -0
  185. package/src/slash-commands/helpers/todo.ts +279 -0
  186. package/src/slash-commands/helpers/usage-report.ts +91 -0
  187. package/src/slash-commands/types.ts +126 -0
  188. package/src/ssh/ssh-executor.ts +5 -0
  189. package/src/system-prompt.ts +4 -2
  190. package/src/task/executor.ts +27 -10
  191. package/src/task/index.ts +20 -1
  192. package/src/task/render.ts +27 -18
  193. package/src/task/types.ts +4 -0
  194. package/src/tools/ast-edit.ts +21 -120
  195. package/src/tools/ast-grep.ts +21 -119
  196. package/src/tools/bash-interactive.ts +9 -1
  197. package/src/tools/bash.ts +203 -6
  198. package/src/tools/browser/attach.ts +3 -3
  199. package/src/tools/browser/launch.ts +81 -18
  200. package/src/tools/browser/registry.ts +1 -5
  201. package/src/tools/browser/tab-supervisor.ts +51 -14
  202. package/src/tools/conflict-detect.ts +21 -10
  203. package/src/tools/eval.ts +3 -1
  204. package/src/tools/fetch.ts +15 -4
  205. package/src/tools/find.ts +39 -39
  206. package/src/tools/gh-renderer.ts +0 -12
  207. package/src/tools/gh.ts +689 -182
  208. package/src/tools/github-cache.ts +548 -0
  209. package/src/tools/index.ts +25 -11
  210. package/src/tools/inspect-image.ts +3 -10
  211. package/src/tools/output-meta.ts +176 -37
  212. package/src/tools/path-utils.ts +125 -2
  213. package/src/tools/read.ts +605 -239
  214. package/src/tools/render-utils.ts +92 -0
  215. package/src/tools/renderers.ts +2 -0
  216. package/src/tools/resolve.ts +72 -44
  217. package/src/tools/search.ts +120 -186
  218. package/src/tools/write.ts +67 -10
  219. package/src/tui/code-cell.ts +70 -2
  220. package/src/utils/file-mentions.ts +1 -1
  221. package/src/utils/image-loading.ts +7 -3
  222. package/src/utils/image-resize.ts +32 -43
  223. package/src/vim/parser.ts +0 -17
  224. package/src/vim/render.ts +1 -1
  225. package/src/vim/types.ts +1 -1
  226. package/src/web/search/providers/gemini.ts +35 -95
  227. package/src/prompts/tools/exit-plan-mode.md +0 -6
  228. package/src/tools/exit-plan-mode.ts +0 -97
  229. package/src/utils/fuzzy.ts +0 -108
  230. package/src/utils/image-convert.ts +0 -27
@@ -240,45 +240,12 @@ export class InputController {
240
240
  text = slashResult;
241
241
  }
242
242
 
243
- // Handle skill commands (/skill:name [args])
244
- if (text.startsWith("/skill:")) {
245
- const spaceIndex = text.indexOf(" ");
246
- const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
247
- const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim();
248
- const skillPath = this.ctx.skillCommands?.get(commandName);
249
- if (skillPath) {
250
- this.ctx.editor.addToHistory(text);
251
- this.ctx.editor.setText("");
252
- try {
253
- const content = await Bun.file(skillPath).text();
254
- const body = content.replace(/^---\n[\s\S]*?\n---\n/, "").trim();
255
- const metaLines = [`Skill: ${skillPath}`];
256
- if (args) {
257
- metaLines.push(`User: ${args}`);
258
- }
259
- const message = `${body}\n\n---\n\n${metaLines.join("\n")}`;
260
- const skillName = commandName.slice("skill:".length);
261
- const details: SkillPromptDetails = {
262
- name: skillName || commandName,
263
- path: skillPath,
264
- args: args || undefined,
265
- lineCount: body ? body.split("\n").length : 0,
266
- };
267
- await this.ctx.session.promptCustomMessage(
268
- {
269
- customType: SKILL_PROMPT_MESSAGE_TYPE,
270
- content: message,
271
- display: true,
272
- details,
273
- attribution: "user",
274
- },
275
- { streamingBehavior: "followUp" },
276
- );
277
- } catch (err) {
278
- this.ctx.showError(`Failed to load skill: ${err instanceof Error ? err.message : String(err)}`);
279
- }
280
- return;
281
- }
243
+ // Handle skill commands (/skill:name [args]). Enter ⇒ steer (matches the
244
+ // free-text Enter semantics applied a few lines below at the streaming
245
+ // branch). Ctrl+Enter routes through `handleFollowUp` and dispatches the
246
+ // same helper with `"followUp"`.
247
+ if (await this.#invokeSkillCommand(text, "steer")) {
248
+ return;
282
249
  }
283
250
 
284
251
  // Handle bash command (! for normal, !! for excluded from context)
@@ -439,16 +406,95 @@ export class InputController {
439
406
  }
440
407
  }
441
408
 
409
+ /**
410
+ * Dispatch a `/skill:<name> [args]` invocation through `promptCustomMessage`
411
+ * using the supplied `streamingBehavior`. Returns true if the text was a
412
+ * recognised skill command and was dispatched. A failure to load the skill
413
+ * file is surfaced via `showError` but still returns true — the editor was
414
+ * already cleared on the success path, so falling through to plain-text
415
+ * handling at that point would double-submit. Returns false when the text
416
+ * isn't a `/skill:` prefix or the command name isn't a registered skill,
417
+ * so the caller can fall through to plain-text handling (this branch
418
+ * leaves the editor state untouched). `streamingBehavior` is only consulted
419
+ * while the agent is streaming; the idle path of `promptCustomMessage`
420
+ * ignores it.
421
+ */
422
+ async #invokeSkillCommand(text: string, streamingBehavior: "steer" | "followUp"): Promise<boolean> {
423
+ if (!text.startsWith("/skill:")) return false;
424
+ const spaceIndex = text.indexOf(" ");
425
+ const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
426
+ const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim();
427
+ const skillPath = this.ctx.skillCommands?.get(commandName);
428
+ if (!skillPath) return false;
429
+ this.ctx.editor.addToHistory(text);
430
+ this.ctx.editor.setText("");
431
+ try {
432
+ const content = await Bun.file(skillPath).text();
433
+ const body = content.replace(/^---\n[\s\S]*?\n---\n/, "").trim();
434
+ const metaLines = [`Skill: ${skillPath}`];
435
+ if (args) {
436
+ metaLines.push(`User: ${args}`);
437
+ }
438
+ const message = `${body}\n\n---\n\n${metaLines.join("\n")}`;
439
+ const skillName = commandName.slice("skill:".length);
440
+ const details: SkillPromptDetails = {
441
+ name: skillName || commandName,
442
+ path: skillPath,
443
+ args: args || undefined,
444
+ lineCount: body ? body.split("\n").length : 0,
445
+ };
446
+ // When the agent is streaming, register the compact slash-form text as
447
+ // the pending-display twin BEFORE dispatching the CustomMessage. The
448
+ // returned tag is embedded in details so AgentSession.#handleAgentEvent
449
+ // can remove the matching display entry when the agent consumes this
450
+ // message (mirrors the user-message dequeue path).
451
+ if (this.ctx.session.isStreaming) {
452
+ const tag = this.ctx.session.enqueueCustomMessageDisplay(text, streamingBehavior);
453
+ details.__pendingDisplayTag = tag;
454
+ }
455
+ await this.ctx.session.promptCustomMessage(
456
+ {
457
+ customType: SKILL_PROMPT_MESSAGE_TYPE,
458
+ content: message,
459
+ display: true,
460
+ details,
461
+ attribution: "user",
462
+ },
463
+ { streamingBehavior },
464
+ );
465
+ if (this.ctx.session.isStreaming) {
466
+ this.ctx.updatePendingMessagesDisplay();
467
+ this.ctx.ui.requestRender();
468
+ }
469
+ } catch (err) {
470
+ this.ctx.showError(`Failed to load skill: ${err instanceof Error ? err.message : String(err)}`);
471
+ }
472
+ return true;
473
+ }
474
+
442
475
  /** Send editor text as a follow-up message (queued behind current stream). */
443
476
  async handleFollowUp(): Promise<void> {
444
477
  const text = this.ctx.editor.getText().trim();
445
478
  if (!text) return;
446
479
 
480
+ // Compaction first: while compacting, free text gets queued via
481
+ // `queueCompactionMessage`, and `/skill:*` rides the same queue so a
482
+ // skill typed during compaction is not lost or short-circuited through
483
+ // `promptCustomMessage`. The skill text is queued verbatim; whether
484
+ // the queued entry is later re-parsed into a skill invocation is a
485
+ // separate concern owned by the compaction-resume path.
447
486
  if (this.ctx.session.isCompacting) {
448
487
  this.ctx.queueCompactionMessage(text, "followUp");
449
488
  return;
450
489
  }
451
490
 
491
+ // Skill commands invoke through the custom-message path regardless of
492
+ // which keybinding submitted them. Enter routes them as `steer`;
493
+ // Ctrl+Enter (this handler) routes them as `followUp`.
494
+ if (await this.#invokeSkillCommand(text, "followUp")) {
495
+ return;
496
+ }
497
+
452
498
  if (this.ctx.session.isStreaming) {
453
499
  this.ctx.editor.addToHistory(text);
454
500
  this.ctx.editor.setText("");
@@ -37,11 +37,11 @@ import type { MCPAuthConfig, MCPServerConfig, MCPServerConnection } from "../../
37
37
  import type { OAuthCredential } from "../../session/auth-storage";
38
38
  import { shortenPath } from "../../tools/render-utils";
39
39
  import { openPath } from "../../utils/open";
40
- import { DynamicBorder } from "../components/dynamic-border";
41
40
  import { MCPAddWizard } from "../components/mcp-add-wizard";
42
41
  import { parseCommandArgs } from "../shared";
43
42
  import { theme } from "../theme/theme";
44
43
  import type { InteractiveModeContext } from "../types";
44
+ import { groupBySource, parseRemoveArgs, readScopeFlag, showCommandMessage } from "./command-controller-shared";
45
45
 
46
46
  function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
47
47
  const { promise: timeoutPromise, reject } = Promise.withResolvers<T>();
@@ -49,6 +49,22 @@ function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string)
49
49
  return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timer));
50
50
  }
51
51
 
52
+ /**
53
+ * Outcome of {@link MCPCommandController}'s OAuth handler.
54
+ *
55
+ * `clientId`/`clientSecret` are populated when the OAuth provider required (or
56
+ * accepted) dynamic client registration; callers MUST persist them alongside
57
+ * `credentialId` so subsequent token refreshes and reauthorizations can reuse
58
+ * the same registered client. Both are also set when the caller pre-supplied a
59
+ * client id via the wizard or `oauth.clientId` in `mcp.json`, in which case the
60
+ * write-back is a no-op.
61
+ */
62
+ interface OAuthFlowResult {
63
+ credentialId: string;
64
+ clientId?: string;
65
+ clientSecret?: string;
66
+ }
67
+
52
68
  type MCPAddScope = "user" | "project";
53
69
  type MCPAddTransport = "http" | "sse";
54
70
 
@@ -207,11 +223,11 @@ export class MCPCommandController {
207
223
  break;
208
224
  }
209
225
  if (argToken === "--scope") {
210
- const value = tokens[i + 1];
211
- if (!value || (value !== "project" && value !== "user")) {
212
- return { scope, error: "Invalid --scope value. Use project or user." };
226
+ const r = readScopeFlag(tokens[i + 1]);
227
+ if (!r.ok) {
228
+ return { scope, error: r.error };
213
229
  }
214
- scope = value;
230
+ scope = r.scope;
215
231
  i += 2;
216
232
  continue;
217
233
  }
@@ -406,7 +422,7 @@ export class MCPCommandController {
406
422
 
407
423
  try {
408
424
  const oauthClientSecret = finalConfig.oauth?.clientSecret ?? "";
409
- const credentialId = await this.#handleOAuthFlow(
425
+ const oauthResult = await this.#handleOAuthFlow(
410
426
  oauth.authorizationUrl,
411
427
  oauth.tokenUrl,
412
428
  oauth.clientId ?? finalConfig.oauth?.clientId ?? "",
@@ -416,14 +432,21 @@ export class MCPCommandController {
416
432
  finalConfig.oauth?.callbackPath,
417
433
  finalConfig.oauth?.redirectUri,
418
434
  );
435
+ const persistedClientId = oauthResult.clientId ?? oauth.clientId ?? finalConfig.oauth?.clientId;
436
+ const persistedClientSecret = oauthResult.clientSecret ?? finalConfig.oauth?.clientSecret;
419
437
  finalConfig = {
420
438
  ...finalConfig,
421
439
  auth: {
422
440
  type: "oauth",
423
- credentialId,
441
+ credentialId: oauthResult.credentialId,
424
442
  tokenUrl: oauth.tokenUrl,
425
- clientId: oauth.clientId ?? finalConfig.oauth?.clientId,
426
- clientSecret: finalConfig.oauth?.clientSecret,
443
+ clientId: persistedClientId,
444
+ clientSecret: persistedClientSecret,
445
+ },
446
+ oauth: {
447
+ ...finalConfig.oauth,
448
+ clientId: persistedClientId ?? finalConfig.oauth?.clientId,
449
+ clientSecret: persistedClientSecret ?? finalConfig.oauth?.clientSecret,
427
450
  },
428
451
  };
429
452
  } catch (oauthError) {
@@ -488,7 +511,7 @@ export class MCPCommandController {
488
511
  callbackPort?: number,
489
512
  callbackPath?: string,
490
513
  redirectUri?: string,
491
- ): Promise<string> {
514
+ ): Promise<OAuthFlowResult> {
492
515
  const authStorage = this.ctx.session.modelRegistry.authStorage;
493
516
  let parsedAuthUrl: URL;
494
517
 
@@ -600,7 +623,11 @@ export class MCPCommandController {
600
623
  // Store under a synthetic provider name
601
624
  await authStorage.set(credentialId, oauthCredential);
602
625
 
603
- return credentialId;
626
+ return {
627
+ credentialId,
628
+ clientId: flow.resolvedClientId,
629
+ clientSecret: flow.registeredClientSecret,
630
+ };
604
631
  } catch (error) {
605
632
  const errorMsg = error instanceof Error ? error.message : String(error);
606
633
 
@@ -984,23 +1011,7 @@ export class MCPCommandController {
984
1011
 
985
1012
  // Show discovered servers (from .claude.json, .cursor/mcp.json, .vscode/mcp.json, etc.)
986
1013
  if (discoveredServers.length > 0) {
987
- // Group by source display name + path
988
- const bySource = new Map<string, typeof discoveredServers>();
989
- for (const entry of discoveredServers) {
990
- const key = `${entry.source.providerName}|${entry.source.path}`;
991
- let group = bySource.get(key);
992
- if (!group) {
993
- group = [];
994
- bySource.set(key, group);
995
- }
996
- group.push(entry);
997
- }
998
-
999
- for (const [key, entries] of bySource) {
1000
- const sepIdx = key.indexOf("|");
1001
- const providerName = key.slice(0, sepIdx);
1002
- const sourcePath = key.slice(sepIdx + 1);
1003
- const shortPath = shortenPath(sourcePath);
1014
+ for (const { providerName, shortPath, items: entries } of groupBySource(discoveredServers, e => e.source)) {
1004
1015
  lines.push(theme.fg("accent", providerName) + theme.fg("muted", ` (${shortPath}):`));
1005
1016
  for (const { name } of entries) {
1006
1017
  const state = this.ctx.mcpManager!.getConnectionStatus(name);
@@ -1037,32 +1048,12 @@ export class MCPCommandController {
1037
1048
  async #handleRemove(text: string): Promise<void> {
1038
1049
  const match = text.match(/^\/mcp\s+(?:remove|rm)\b\s*(.*)$/i);
1039
1050
  const rest = match?.[1]?.trim() ?? "";
1040
- const tokens = parseCommandArgs(rest);
1041
-
1042
- let name: string | undefined;
1043
- let scope: "project" | "user" = "project";
1044
- let i = 0;
1045
-
1046
- if (tokens.length > 0 && !tokens[0].startsWith("-")) {
1047
- name = tokens[0];
1048
- i = 1;
1049
- }
1050
-
1051
- while (i < tokens.length) {
1052
- const token = tokens[i];
1053
- if (token === "--scope") {
1054
- const value = tokens[i + 1];
1055
- if (!value || (value !== "project" && value !== "user")) {
1056
- this.ctx.showError("Invalid --scope value. Use project or user.");
1057
- return;
1058
- }
1059
- scope = value;
1060
- i += 2;
1061
- continue;
1062
- }
1063
- this.ctx.showError(`Unknown option: ${token}`);
1051
+ const parsed = parseRemoveArgs(rest);
1052
+ if (!parsed.ok) {
1053
+ this.ctx.showError(parsed.error);
1064
1054
  return;
1065
1055
  }
1056
+ const { name, scope } = parsed.value;
1066
1057
 
1067
1058
  if (!name) {
1068
1059
  this.ctx.showError("Server name required. Usage: /mcp remove <name> [--scope project|user]");
@@ -1348,7 +1339,7 @@ export class MCPCommandController {
1348
1339
 
1349
1340
  this.#showMessage(["", theme.fg("muted", `Reauthorizing "${name}"...`), ""].join("\n"));
1350
1341
 
1351
- const credentialId = await this.#handleOAuthFlow(
1342
+ const oauthResult = await this.#handleOAuthFlow(
1352
1343
  oauth.authorizationUrl,
1353
1344
  oauth.tokenUrl,
1354
1345
  oauth.clientId ?? found.config.oauth?.clientId ?? "",
@@ -1359,14 +1350,22 @@ export class MCPCommandController {
1359
1350
  found.config.oauth?.redirectUri,
1360
1351
  );
1361
1352
 
1353
+ const persistedClientId = oauthResult.clientId ?? oauth.clientId ?? found.config.oauth?.clientId;
1354
+ const persistedClientSecret = oauthResult.clientSecret ?? (oauthClientSecret || undefined);
1355
+
1362
1356
  const updated: MCPServerConfig = {
1363
1357
  ...baseConfig,
1364
1358
  auth: {
1365
1359
  type: "oauth",
1366
- credentialId,
1360
+ credentialId: oauthResult.credentialId,
1367
1361
  tokenUrl: oauth.tokenUrl,
1368
- clientId: oauth.clientId ?? found.config.oauth?.clientId,
1369
- clientSecret: oauthClientSecret || undefined,
1362
+ clientId: persistedClientId,
1363
+ clientSecret: persistedClientSecret,
1364
+ },
1365
+ oauth: {
1366
+ ...found.config.oauth,
1367
+ clientId: persistedClientId ?? found.config.oauth?.clientId,
1368
+ clientSecret: persistedClientSecret ?? found.config.oauth?.clientSecret,
1370
1369
  },
1371
1370
  };
1372
1371
  await updateMCPServer(found.filePath, name, updated);
@@ -1929,10 +1928,6 @@ export class MCPCommandController {
1929
1928
  * Show a message in the chat
1930
1929
  */
1931
1930
  #showMessage(text: string): void {
1932
- this.ctx.chatContainer.addChild(new Spacer(1));
1933
- this.ctx.chatContainer.addChild(new DynamicBorder());
1934
- this.ctx.chatContainer.addChild(new Text(text, 1, 1));
1935
- this.ctx.chatContainer.addChild(new DynamicBorder());
1936
- this.ctx.ui.requestRender();
1931
+ showCommandMessage(this.ctx, text);
1937
1932
  }
1938
1933
  }
@@ -3,18 +3,20 @@
3
3
  *
4
4
  * Handles /ssh subcommands for managing SSH host configurations.
5
5
  */
6
- import { Spacer, Text } from "@oh-my-pi/pi-tui";
7
6
  import { getProjectDir, getSSHConfigPath } from "@oh-my-pi/pi-utils";
8
7
  import { type SSHHost, sshCapability } from "../../capability/ssh";
9
8
  import { loadCapability } from "../../discovery";
10
9
  import { addSSHHost, readSSHConfigFile, removeSSHHost, type SSHHostConfig } from "../../ssh/config-writer";
11
- import { shortenPath } from "../../tools/render-utils";
12
- import { DynamicBorder } from "../components/dynamic-border";
13
10
  import { parseCommandArgs } from "../shared";
14
11
  import { theme } from "../theme/theme";
15
12
  import type { InteractiveModeContext } from "../types";
16
-
17
- type SSHAddScope = "user" | "project";
13
+ import {
14
+ groupBySource,
15
+ parseRemoveArgs,
16
+ readScopeFlag,
17
+ type ScopeValue,
18
+ showCommandMessage,
19
+ } from "./command-controller-shared";
18
20
 
19
21
  export class SSHCommandController {
20
22
  constructor(private ctx: InteractiveModeContext) {}
@@ -90,7 +92,7 @@ export class SSHCommandController {
90
92
  }
91
93
 
92
94
  let name: string | undefined;
93
- let scope: SSHAddScope = "project";
95
+ let scope: ScopeValue = "project";
94
96
  let host: string | undefined;
95
97
  let username: string | undefined;
96
98
  let port: number | undefined;
@@ -167,12 +169,12 @@ export class SSHCommandController {
167
169
  continue;
168
170
  }
169
171
  if (argToken === "--scope") {
170
- const value = tokens[i + 1];
171
- if (!value || (value !== "project" && value !== "user")) {
172
- this.ctx.showError("Invalid --scope value. Use project or user.");
172
+ const r = readScopeFlag(tokens[i + 1]);
173
+ if (!r.ok) {
174
+ this.ctx.showError(r.error);
173
175
  return;
174
176
  }
175
- scope = value;
177
+ scope = r.scope;
176
178
  i += 2;
177
179
  continue;
178
180
  }
@@ -300,23 +302,7 @@ export class SSHCommandController {
300
302
 
301
303
  // Show discovered hosts (from ssh.json, .ssh.json in project root, etc.)
302
304
  if (discoveredHosts.length > 0) {
303
- // Group by source
304
- const bySource = new Map<string, SSHHost[]>();
305
- for (const host of discoveredHosts) {
306
- const key = `${host._source.providerName}|${host._source.path}`;
307
- let group = bySource.get(key);
308
- if (!group) {
309
- group = [];
310
- bySource.set(key, group);
311
- }
312
- group.push(host);
313
- }
314
-
315
- for (const [key, hosts] of bySource) {
316
- const sepIdx = key.indexOf("|");
317
- const providerName = key.slice(0, sepIdx);
318
- const sourcePath = key.slice(sepIdx + 1);
319
- const shortPath = shortenPath(sourcePath);
305
+ for (const { providerName, shortPath, items: hosts } of groupBySource(discoveredHosts, h => h._source)) {
320
306
  lines.push(
321
307
  theme.fg("accent", "Discovered") +
322
308
  theme.fg("muted", ` (${providerName}: ${shortPath}):`) +
@@ -357,33 +343,12 @@ export class SSHCommandController {
357
343
  async #handleRemove(text: string): Promise<void> {
358
344
  const match = text.match(/^\/ssh\s+(?:remove|rm)\b\s*(.*)$/i);
359
345
  const rest = match?.[1]?.trim() ?? "";
360
- const tokens = parseCommandArgs(rest);
361
-
362
- let name: string | undefined;
363
- let scope: "project" | "user" = "project";
364
- let i = 0;
365
-
366
- if (tokens.length > 0 && !tokens[0].startsWith("-")) {
367
- name = tokens[0];
368
- i = 1;
369
- }
370
-
371
- while (i < tokens.length) {
372
- const token = tokens[i];
373
- if (token === "--scope") {
374
- const value = tokens[i + 1];
375
- if (!value || (value !== "project" && value !== "user")) {
376
- this.ctx.showError("Invalid --scope value. Use project or user.");
377
- return;
378
- }
379
- scope = value;
380
- i += 2;
381
- continue;
382
- }
383
- this.ctx.showError(`Unknown option: ${token}`);
346
+ const parsed = parseRemoveArgs(rest);
347
+ if (!parsed.ok) {
348
+ this.ctx.showError(parsed.error);
384
349
  return;
385
350
  }
386
-
351
+ const { name, scope } = parsed.value;
387
352
  if (!name) {
388
353
  this.ctx.showError("Host name required. Usage: /ssh remove <name> [--scope project|user]");
389
354
  return;
@@ -412,10 +377,6 @@ export class SSHCommandController {
412
377
  * Show a message in the chat
413
378
  */
414
379
  #showMessage(text: string): void {
415
- this.ctx.chatContainer.addChild(new Spacer(1));
416
- this.ctx.chatContainer.addChild(new DynamicBorder());
417
- this.ctx.chatContainer.addChild(new Text(text, 1, 1));
418
- this.ctx.chatContainer.addChild(new DynamicBorder());
419
- this.ctx.ui.requestRender();
380
+ showCommandMessage(this.ctx, text);
420
381
  }
421
382
  }