one-code-extension 0.1.4 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +20 -0
- package/README.md +200 -172
- package/agents/explore.md +9 -6
- package/agents/general-purpose.md +1 -1
- package/agents/plan.md +6 -4
- package/docs/guide/README.md +52 -0
- package/docs/guide/bring-your-claude-code-setup.md +126 -0
- package/docs/guide/configuration.md +171 -0
- package/docs/guide/differences-from-claude-code.md +150 -0
- package/docs/guide/doctor.md +86 -0
- package/docs/guide/hooks.md +109 -0
- package/docs/guide/installation.md +124 -0
- package/docs/guide/permissions-modes-and-auto-mode.md +450 -0
- package/docs/guide/providers-and-models.md +194 -0
- package/docs/guide/reference.md +224 -0
- package/docs/guide/sessions-and-context.md +190 -0
- package/docs/guide/skills-plugins-and-mcp.md +176 -0
- package/docs/guide/subagents-and-workflows.md +193 -0
- package/docs/guide/tasks-and-background-work.md +111 -0
- package/docs/guide/terminal-interface.md +155 -0
- package/docs/guide/tools.md +175 -0
- package/docs/guide/troubleshooting.md +175 -0
- package/extensions/ask-user/index.ts +65 -71
- package/extensions/ask-user/questions.ts +15 -28
- package/extensions/ask-user/widget.ts +533 -0
- package/extensions/auto-mode/actions.ts +9 -26
- package/extensions/auto-mode/classifier-prompt.ts +127 -0
- package/extensions/auto-mode/classifier.ts +305 -227
- package/extensions/auto-mode/config.ts +279 -77
- package/extensions/auto-mode/defaults.ts +17 -60
- package/extensions/auto-mode/instructions.ts +2 -1
- package/extensions/auto-mode/model-picker.ts +53 -31
- package/extensions/auto-mode/model-select.ts +153 -82
- package/extensions/auto-mode/paths.ts +13 -1
- package/extensions/auto-mode/pause.ts +8 -0
- package/extensions/auto-mode/prompt.ts +205 -238
- package/extensions/auto-mode/recoverability.ts +229 -0
- package/extensions/auto-mode/rules.ts +92 -75
- package/extensions/auto-mode/safety-floor.ts +43 -12
- package/extensions/auto-mode/sensitive.ts +11 -0
- package/extensions/auto-mode/setup-run.ts +245 -0
- package/extensions/auto-mode/setup.ts +235 -0
- package/extensions/auto-mode/shell-analysis.ts +332 -26
- package/extensions/auto-mode/transcript.ts +102 -0
- package/extensions/background/index.ts +256 -43
- package/extensions/background/monitor-batch.ts +58 -0
- package/extensions/background/registry.ts +9 -0
- package/extensions/background/wakeup.ts +45 -0
- package/extensions/bash/background.ts +47 -22
- package/extensions/bash/guards.ts +229 -0
- package/extensions/bash/index.ts +81 -19
- package/extensions/branding/index.ts +44 -18
- package/extensions/branding/startup.ts +23 -8
- package/extensions/claude-compat/index.ts +17 -2
- package/extensions/claude-context/index.ts +41 -12
- package/extensions/clear/index.ts +27 -0
- package/extensions/compaction/fit.ts +121 -0
- package/extensions/compaction/index.ts +176 -71
- package/extensions/compaction/prompt.ts +21 -1
- package/extensions/context-budget/budget.ts +61 -0
- package/extensions/context-budget/index.ts +66 -0
- package/extensions/context-management/index.ts +40 -16
- package/extensions/doctor/build.ts +226 -0
- package/extensions/doctor/cli.ts +99 -0
- package/extensions/doctor/compat.ts +532 -0
- package/extensions/doctor/dependencies.ts +186 -0
- package/extensions/doctor/fix-prompt.ts +125 -0
- package/extensions/doctor/index.ts +308 -0
- package/extensions/doctor/models.ts +231 -0
- package/extensions/doctor/presets.ts +208 -0
- package/extensions/doctor/report.ts +189 -0
- package/extensions/doctor/update-lookup.ts +46 -0
- package/extensions/doctor/viewer.ts +124 -0
- package/extensions/effort/index.ts +41 -4
- package/extensions/effort/slider.ts +33 -2
- package/extensions/file-tracker/index.ts +143 -20
- package/extensions/file-tracker/replay.ts +46 -0
- package/extensions/file-tracker/tracker.ts +51 -4
- package/extensions/footer/footer-line.ts +207 -0
- package/extensions/footer/index.ts +152 -0
- package/extensions/footer/pr.ts +53 -0
- package/extensions/hooks/executor.ts +57 -20
- package/extensions/hooks/formatter-notice.ts +76 -0
- package/extensions/hooks/index.ts +314 -73
- package/extensions/hooks/matcher.ts +35 -1
- package/extensions/hooks/plugin-hooks.ts +3 -3
- package/extensions/hooks/protocol.ts +58 -0
- package/extensions/hooks/subagent-bridge.ts +77 -0
- package/extensions/hooks/trust.ts +15 -4
- package/extensions/init/index.ts +26 -0
- package/extensions/init/prompt.ts +40 -0
- package/extensions/interrupted/index.ts +35 -0
- package/extensions/interrupted/line.ts +9 -0
- package/extensions/lib/abort.ts +15 -0
- package/extensions/lib/agent-loader.ts +183 -0
- package/extensions/lib/anthropic-payload.ts +15 -0
- package/extensions/lib/atomic-write.ts +56 -0
- package/extensions/lib/bridge-watch.ts +20 -0
- package/extensions/lib/cache-retention.ts +60 -0
- package/extensions/lib/capability-index.ts +466 -0
- package/extensions/lib/claude-context.ts +318 -27
- package/extensions/lib/claude-settings.ts +64 -0
- package/extensions/lib/consent-preview.ts +30 -0
- package/extensions/lib/deferred.ts +144 -13
- package/extensions/lib/favorites.ts +43 -0
- package/extensions/lib/fetch-timeout.ts +15 -0
- package/extensions/lib/frontmatter.ts +31 -0
- package/extensions/lib/git.ts +44 -2
- package/extensions/lib/hook-gate.ts +90 -0
- package/extensions/lib/interrupt.ts +94 -0
- package/extensions/lib/mcp-overrides.ts +61 -0
- package/extensions/lib/mcp-share.ts +25 -0
- package/extensions/lib/mcp-status.ts +34 -0
- package/extensions/lib/memory.ts +41 -6
- package/extensions/lib/model-facts.generated.json +2480 -0
- package/extensions/lib/model-facts.ts +162 -0
- package/extensions/lib/model-policy.ts +145 -189
- package/extensions/lib/model-tier.ts +480 -64
- package/extensions/lib/model-unusable.ts +37 -0
- package/extensions/lib/notifications.ts +320 -1
- package/extensions/lib/one-code-settings.ts +63 -0
- package/extensions/lib/original-command.ts +71 -0
- package/extensions/lib/package-version.ts +31 -0
- package/extensions/lib/paths.ts +39 -4
- package/extensions/lib/permission-gate.ts +192 -0
- package/extensions/lib/persisted-output.ts +18 -0
- package/extensions/lib/pi-ai-estimate.ts +160 -0
- package/extensions/lib/pi-version.ts +1 -1
- package/extensions/lib/plan-mode-channels.ts +11 -0
- package/extensions/lib/plugin-overrides.ts +24 -0
- package/extensions/lib/plugin-root.ts +44 -0
- package/extensions/lib/plugins.ts +233 -54
- package/extensions/lib/prefix-warm-gate.ts +157 -0
- package/extensions/lib/process-tree.ts +56 -0
- package/extensions/lib/reminders.ts +321 -68
- package/extensions/lib/scratchpad.ts +11 -8
- package/extensions/lib/session-lifecycle.ts +28 -0
- package/extensions/lib/settings-channels.ts +14 -0
- package/extensions/lib/shell-tasks.ts +54 -0
- package/extensions/lib/skill-overrides.ts +86 -0
- package/extensions/lib/skill-scan.ts +110 -0
- package/extensions/lib/text-width.ts +267 -0
- package/extensions/lib/tui-render.ts +281 -54
- package/extensions/lib/usage-bus.ts +75 -0
- package/extensions/lib/usage-tracker.ts +67 -0
- package/extensions/lib/worktree-isolation.ts +170 -0
- package/extensions/lsp/client.ts +69 -12
- package/extensions/lsp/format.ts +12 -2
- package/extensions/lsp/index.ts +273 -84
- package/extensions/lsp/install-hints.ts +43 -0
- package/extensions/lsp/keep-alive.ts +19 -0
- package/extensions/lsp/plugin-servers.ts +331 -0
- package/extensions/lsp/watcher.ts +157 -0
- package/extensions/mcp/announce.ts +99 -0
- package/extensions/mcp/client.ts +181 -22
- package/extensions/mcp/config.ts +55 -24
- package/extensions/mcp/index.ts +586 -86
- package/extensions/mcp/oauth/browser.ts +26 -0
- package/extensions/mcp/oauth/callback.ts +104 -0
- package/extensions/mcp/oauth/flow.ts +68 -0
- package/extensions/mcp/oauth/provider.ts +147 -0
- package/extensions/mcp/oauth/store.ts +86 -0
- package/extensions/mcp/panel/keys.ts +34 -0
- package/extensions/mcp/panel/model.ts +62 -0
- package/extensions/mcp/panel/render.ts +141 -0
- package/extensions/mcp/panel/state.ts +108 -0
- package/extensions/mcp/schema.ts +83 -6
- package/extensions/mcp/trust.ts +340 -0
- package/extensions/memory/entries.ts +163 -0
- package/extensions/memory/index.ts +119 -2
- package/extensions/memory/open-external.ts +97 -0
- package/extensions/memory/panel.ts +124 -0
- package/extensions/model-default/index.ts +74 -0
- package/extensions/notebook/index.ts +36 -18
- package/extensions/notebook/notebook.ts +110 -17
- package/extensions/permissions/index.ts +1012 -247
- package/extensions/permissions/matcher.ts +488 -57
- package/extensions/permissions/modes.ts +55 -7
- package/extensions/permissions/project-trust.ts +81 -0
- package/extensions/permissions/protected-paths.ts +16 -3
- package/extensions/permissions/rule-prose.ts +79 -0
- package/extensions/permissions/session-grant.ts +91 -0
- package/extensions/permissions/settings.ts +112 -33
- package/extensions/permissions/subagent-gate.ts +57 -0
- package/extensions/plan-mode/index.ts +143 -33
- package/extensions/plan-mode/reminder.ts +16 -19
- package/extensions/plan-mode/viewer.ts +40 -31
- package/extensions/plugins/counts.ts +68 -0
- package/extensions/plugins/index.ts +496 -32
- package/extensions/plugins/install/install.ts +167 -0
- package/extensions/plugins/install/paths.ts +23 -0
- package/extensions/plugins/install/registry.ts +72 -0
- package/extensions/plugins/marketplace/git.ts +103 -0
- package/extensions/plugins/marketplace/official.ts +36 -0
- package/extensions/plugins/marketplace/parse.ts +94 -0
- package/extensions/plugins/marketplace/registry.ts +60 -0
- package/extensions/plugins/marketplace/sync.ts +146 -0
- package/extensions/plugins/marketplace/types.ts +128 -0
- package/extensions/plugins/panel/keys.ts +52 -0
- package/extensions/plugins/panel/render.ts +315 -0
- package/extensions/plugins/panel/rows.ts +230 -0
- package/extensions/plugins/panel/state.ts +333 -0
- package/extensions/recap/index.ts +215 -0
- package/extensions/recap/prompt.ts +44 -0
- package/extensions/recap/scheduler.ts +87 -0
- package/extensions/search-tools/index.ts +0 -0
- package/extensions/search-tools/tools.ts +7 -5
- package/extensions/skill/index.ts +360 -34
- package/extensions/skill/invoke.ts +185 -0
- package/extensions/skill/listing.ts +21 -0
- package/extensions/skill/panel/keys.ts +41 -0
- package/extensions/skill/panel/render.ts +91 -0
- package/extensions/skill/panel/state.ts +115 -0
- package/extensions/spinner/index.ts +110 -0
- package/extensions/spinner/line.ts +51 -0
- package/extensions/spinner/verbs.ts +201 -0
- package/extensions/subagents/agents.ts +88 -44
- package/extensions/subagents/default-model.ts +62 -32
- package/extensions/subagents/delegation-steer.ts +23 -0
- package/extensions/subagents/hand-back-review.ts +67 -0
- package/extensions/subagents/index.ts +1545 -346
- package/extensions/subagents/live-runs.ts +293 -0
- package/extensions/subagents/model-select.ts +252 -82
- package/extensions/subagents/outcome.ts +90 -0
- package/extensions/subagents/panel-keys.ts +41 -0
- package/extensions/subagents/panel-render.ts +357 -0
- package/extensions/subagents/panel-widget.ts +330 -0
- package/extensions/subagents/pending-claim.ts +45 -0
- package/extensions/subagents/prose.ts +105 -0
- package/extensions/subagents/runner.ts +674 -0
- package/extensions/subagents/runs.ts +68 -1
- package/extensions/subagents/send-to-main-tool.ts +55 -0
- package/extensions/subagents/session-turns.ts +138 -0
- package/extensions/subagents/shell-panel.ts +198 -0
- package/extensions/subagents/usage.ts +16 -4
- package/extensions/subagents/worktree.ts +13 -0
- package/extensions/system-prompt/environment.ts +5 -3
- package/extensions/system-prompt/git-status.ts +93 -0
- package/extensions/system-prompt/index.ts +23 -1
- package/extensions/system-prompt/template.ts +34 -4
- package/extensions/system-prompt/tiers/common.ts +31 -6
- package/extensions/system-prompt/tiers/low.ts +13 -3
- package/extensions/system-prompt/tiers/mid.ts +21 -8
- package/extensions/system-reminder/index.ts +53 -9
- package/extensions/tasks/index.ts +65 -16
- package/extensions/tasks/store.ts +76 -3
- package/extensions/tool-search/announce.ts +42 -0
- package/extensions/tool-search/index.ts +136 -13
- package/extensions/turn-duration/index.ts +60 -0
- package/extensions/turn-duration/line.ts +22 -0
- package/extensions/turn-duration/span.ts +41 -0
- package/extensions/turn-duration/verbs.ts +24 -0
- package/extensions/web/backends.ts +421 -0
- package/extensions/web/index.ts +178 -21
- package/extensions/web-fetch/index.ts +74 -18
- package/extensions/web-fetch/summarize.ts +1 -1
- package/extensions/workflow/agent-session.ts +182 -112
- package/extensions/workflow/globals.ts +129 -106
- package/extensions/workflow/index.ts +343 -28
- package/extensions/workflow/records.ts +118 -0
- package/extensions/workflow/run-manager.ts +80 -31
- package/extensions/workflow/saved-workflows.ts +2 -1
- package/extensions/workflow/script-worker.mjs +110 -0
- package/extensions/workflow/types.ts +74 -8
- package/extensions/workflow/viewer.ts +562 -0
- package/extensions/workflow/vm-runtime.ts +118 -22
- package/extensions/workflow/widget.ts +126 -18
- package/extensions/worktree/guards.ts +240 -0
- package/extensions/worktree/index.ts +30 -5
- package/extensions/worktree/rewrite.ts +32 -16
- package/package.json +20 -10
- package/skills/code-review/SKILL.md +165 -0
- package/skills/fewer-permission-prompts/SKILL.md +61 -0
- package/skills/security-review/SKILL.md +178 -0
- package/skills/simplify/SKILL.md +70 -0
- package/themes/{one-code-light.json → onecode-light.json} +6 -1
- package/themes/{one-code.json → onecode.json} +6 -1
- package/types/app.d.ts +8 -1
- package/types/community-packages.d.ts +45 -2
- package/docs/decisions/auto-mode.md +0 -526
- package/docs/decisions/branding.md +0 -127
- package/docs/decisions/code-review-remediation.md +0 -106
- package/docs/decisions/compaction.md +0 -140
- package/docs/decisions/distribution.md +0 -168
- package/docs/decisions/hooks.md +0 -64
- package/docs/decisions/lsp.md +0 -58
- package/docs/decisions/mcp.md +0 -104
- package/docs/decisions/memory-state.md +0 -170
- package/docs/decisions/model-policy.md +0 -126
- package/docs/decisions/modes.md +0 -178
- package/docs/decisions/skills-plugins.md +0 -56
- package/docs/decisions/subagents-workflows.md +0 -225
- package/docs/decisions/system-prompt.md +0 -98
- package/docs/decisions/tools.md +0 -407
- package/docs/decisions/tui.md +0 -217
- package/docs/decisions.md +0 -148
- package/extensions/subagents/child.ts +0 -414
- package/extensions/subagents/rpc-turns.ts +0 -123
- package/extensions/todo/index.ts +0 -133
- package/extensions/workflow/permission-gate.ts +0 -71
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open a URL in the user's default browser (no dependency — platform command).
|
|
3
|
+
*
|
|
4
|
+
* Detached and unref'd so the auth flow never blocks on the browser process,
|
|
5
|
+
* and errors are swallowed to a boolean: a headless box has no browser, in which
|
|
6
|
+
* case the caller falls back to printing the URL for manual paste.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
|
|
11
|
+
export function openBrowser(url: string): boolean {
|
|
12
|
+
const [command, args] =
|
|
13
|
+
process.platform === "darwin"
|
|
14
|
+
? (["open", [url]] as const)
|
|
15
|
+
: process.platform === "win32"
|
|
16
|
+
? (["cmd", ["/c", "start", "", url]] as const)
|
|
17
|
+
: (["xdg-open", [url]] as const);
|
|
18
|
+
try {
|
|
19
|
+
const child = spawn(command, [...args], { stdio: "ignore", detached: true });
|
|
20
|
+
child.on("error", () => {});
|
|
21
|
+
child.unref();
|
|
22
|
+
return true;
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loopback HTTP server that catches the OAuth authorization-code redirect.
|
|
3
|
+
*
|
|
4
|
+
* The MCP OAuth flow redirects the browser to `http://127.0.0.1:<port>/callback?code=…`
|
|
5
|
+
* after the user authorizes. This binds an ephemeral loopback port, hands its URL
|
|
6
|
+
* back as the redirect_uri, and resolves `waitForCode()` with the code (or rejects
|
|
7
|
+
* on an `error=` param / timeout). RFC 8252 §7.3 lets loopback redirects use any
|
|
8
|
+
* port, so a fresh port per flow is fine even against a previously-registered client.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createServer, type Server } from "node:http";
|
|
12
|
+
|
|
13
|
+
export interface CallbackServer {
|
|
14
|
+
/** The exact redirect_uri to register and hand to the authorization request. */
|
|
15
|
+
redirectUrl: string;
|
|
16
|
+
/** Resolves with the authorization code (and the echoed `state`, if any) once the browser is redirected back. */
|
|
17
|
+
waitForCode: (timeoutMs?: number) => Promise<{ code: string; state?: string }>;
|
|
18
|
+
close: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const DEFAULT_TIMEOUT_MS = 5 * 60_000;
|
|
22
|
+
|
|
23
|
+
/** HTML the browser lands on after redirect — self-closes where the browser allows it. */
|
|
24
|
+
function landingPage(message: string): string {
|
|
25
|
+
return `<!doctype html><meta charset="utf-8"><title>One Code</title><body style="font-family:system-ui;padding:3rem;text-align:center"><h2>${message}</h2><p>You can close this tab and return to the terminal.</p><script>setTimeout(()=>window.close(),1500)</script></body>`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function startCallbackServer(): Promise<CallbackServer> {
|
|
29
|
+
// The result may arrive before waitForCode() installs its handlers (an IdP
|
|
30
|
+
// with a live session can redirect instantly), so buffer it: settle() records
|
|
31
|
+
// the outcome, and waitForCode() replays a buffered one immediately.
|
|
32
|
+
type Redirect = { code: string; state?: string };
|
|
33
|
+
let resolveCode: ((redirect: Redirect) => void) | undefined;
|
|
34
|
+
let rejectCode: ((error: Error) => void) | undefined;
|
|
35
|
+
let outcome: Redirect | { error: Error } | undefined;
|
|
36
|
+
const settle = (result: Redirect | { error: Error }) => {
|
|
37
|
+
if (outcome) return;
|
|
38
|
+
outcome = result;
|
|
39
|
+
if ("code" in result) resolveCode?.(result);
|
|
40
|
+
else rejectCode?.(result.error);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const server: Server = createServer((req, res) => {
|
|
44
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
45
|
+
if (url.pathname !== "/callback") {
|
|
46
|
+
res.writeHead(404).end();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const error = url.searchParams.get("error");
|
|
50
|
+
const code = url.searchParams.get("code");
|
|
51
|
+
if (error) {
|
|
52
|
+
res.writeHead(400, { "content-type": "text/html" }).end(landingPage("Authorization failed"));
|
|
53
|
+
settle({ error: new Error(`authorization denied: ${url.searchParams.get("error_description") ?? error}`) });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (!code) {
|
|
57
|
+
res.writeHead(400, { "content-type": "text/html" }).end(landingPage("Missing authorization code"));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
res.writeHead(200, { "content-type": "text/html" }).end(landingPage("Authorization complete"));
|
|
61
|
+
settle({ code, state: url.searchParams.get("state") ?? undefined });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Bind to loopback on an ephemeral port; the OS picks a free one.
|
|
65
|
+
await new Promise<void>((resolve, reject) => {
|
|
66
|
+
server.once("error", reject);
|
|
67
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
68
|
+
});
|
|
69
|
+
const address = server.address();
|
|
70
|
+
if (!address || typeof address === "string") {
|
|
71
|
+
server.close();
|
|
72
|
+
throw new Error("could not determine loopback callback port");
|
|
73
|
+
}
|
|
74
|
+
const redirectUrl = `http://127.0.0.1:${address.port}/callback`;
|
|
75
|
+
|
|
76
|
+
const waitForCode = (timeoutMs = DEFAULT_TIMEOUT_MS): Promise<Redirect> =>
|
|
77
|
+
new Promise<Redirect>((resolve, reject) => {
|
|
78
|
+
// Replay a result that already arrived before this call.
|
|
79
|
+
if (outcome) {
|
|
80
|
+
if ("code" in outcome) resolve(outcome);
|
|
81
|
+
else reject(outcome.error);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
resolveCode = resolve;
|
|
85
|
+
rejectCode = reject;
|
|
86
|
+
const timer = setTimeout(
|
|
87
|
+
() => settle({ error: new Error(`timed out after ${Math.round(timeoutMs / 1000)}s waiting for authorization`) }),
|
|
88
|
+
timeoutMs,
|
|
89
|
+
);
|
|
90
|
+
timer.unref?.();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
redirectUrl,
|
|
95
|
+
waitForCode,
|
|
96
|
+
close: () => {
|
|
97
|
+
try {
|
|
98
|
+
server.close();
|
|
99
|
+
} catch {
|
|
100
|
+
// already closing
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive OAuth orchestration for an http MCP server.
|
|
3
|
+
*
|
|
4
|
+
* Ties together the loopback callback catcher (callback.ts), the browser opener
|
|
5
|
+
* (browser.ts), the SDK-backed provider (provider.ts), and the transport-level
|
|
6
|
+
* auth entry points (client.ts): start a callback server, let the SDK register +
|
|
7
|
+
* redirect the user to authorize, catch the returned code, finish the exchange,
|
|
8
|
+
* then reconnect with the freshly stored tokens. The result is a live
|
|
9
|
+
* Connection the caller registers exactly like any other.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { HttpServer } from "../config.ts";
|
|
13
|
+
import { beginInteractiveAuth, type Connection, connect } from "../client.ts";
|
|
14
|
+
import { openBrowser } from "./browser.ts";
|
|
15
|
+
import { startCallbackServer } from "./callback.ts";
|
|
16
|
+
import { McpOAuthProvider } from "./provider.ts";
|
|
17
|
+
|
|
18
|
+
export interface AuthenticateOptions {
|
|
19
|
+
server: HttpServer;
|
|
20
|
+
home?: string;
|
|
21
|
+
env?: NodeJS.ProcessEnv;
|
|
22
|
+
/** Progress/URL messages for the caller to surface (browser opened, or manual URL). */
|
|
23
|
+
onPrompt?: (message: string) => void;
|
|
24
|
+
timeoutMs?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A provider for a silent (non-interactive) connect using already-stored tokens. */
|
|
28
|
+
export function silentProvider(serverName: string, home?: string, env?: NodeJS.ProcessEnv): McpOAuthProvider {
|
|
29
|
+
return new McpOAuthProvider({ serverName, home, env });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function authenticate(options: AuthenticateOptions): Promise<Connection> {
|
|
33
|
+
const { server, home, env, onPrompt, timeoutMs } = options;
|
|
34
|
+
const callback = await startCallbackServer();
|
|
35
|
+
try {
|
|
36
|
+
const provider = new McpOAuthProvider({
|
|
37
|
+
serverName: server.name,
|
|
38
|
+
redirectUrl: callback.redirectUrl,
|
|
39
|
+
openAuthorization: (url) => {
|
|
40
|
+
const opened = openBrowser(url.toString());
|
|
41
|
+
onPrompt?.(
|
|
42
|
+
opened
|
|
43
|
+
? `Opened your browser to authorize "${server.name}". If it didn't open, visit:\n${url}`
|
|
44
|
+
: `Open this URL to authorize "${server.name}":\n${url}`,
|
|
45
|
+
);
|
|
46
|
+
},
|
|
47
|
+
home,
|
|
48
|
+
env,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const started = await beginInteractiveAuth(server, provider);
|
|
52
|
+
if ("connection" in started) return started.connection; // valid tokens already present
|
|
53
|
+
|
|
54
|
+
const { code, state } = await callback.waitForCode(timeoutMs);
|
|
55
|
+
// Login-CSRF guard (RFC 8252 §8.9): the redirect must echo the state this
|
|
56
|
+
// provider issued, or an attacker-planted code could bind their account.
|
|
57
|
+
const expected = provider.expectedState();
|
|
58
|
+
if (expected !== undefined && state !== expected) {
|
|
59
|
+
throw new Error(`authorization response state mismatch for "${server.name}" — the redirect did not come from this authorization request`);
|
|
60
|
+
}
|
|
61
|
+
await started.transport.finishAuth(code);
|
|
62
|
+
|
|
63
|
+
// Reconnect fresh: the provider now has stored tokens, so this is silent.
|
|
64
|
+
return await connect(server, silentProvider(server.name, home, env));
|
|
65
|
+
} finally {
|
|
66
|
+
callback.close();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An OAuthClientProvider for the MCP SDK, backed by the on-disk token store.
|
|
3
|
+
*
|
|
4
|
+
* The SDK's StreamableHTTPClientTransport drives the whole OAuth 2.1 flow
|
|
5
|
+
* (discovery, dynamic client registration, PKCE, token exchange and refresh) and
|
|
6
|
+
* calls into this provider for the three things only the host knows: where to
|
|
7
|
+
* persist credentials (the store), where the redirect lands (the loopback URL),
|
|
8
|
+
* and how to send the user to the authorization page (open a browser).
|
|
9
|
+
*
|
|
10
|
+
* One instance is scoped to one server for one flow; `redirectUrl` is fixed from
|
|
11
|
+
* the loopback callback server. A non-interactive instance (startup reconnect
|
|
12
|
+
* using stored tokens) is constructed without an opener and refuses to redirect.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type {
|
|
16
|
+
OAuthClientInformationFull,
|
|
17
|
+
OAuthClientInformationMixed,
|
|
18
|
+
OAuthClientMetadata,
|
|
19
|
+
OAuthTokens,
|
|
20
|
+
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
21
|
+
import { randomBytes } from "node:crypto";
|
|
22
|
+
import type { OAuthClientProvider, OAuthDiscoveryState } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
23
|
+
import { readAuth, type StoredAuth, writeAuth } from "./store.ts";
|
|
24
|
+
|
|
25
|
+
export interface McpOAuthProviderOptions {
|
|
26
|
+
serverName: string;
|
|
27
|
+
/** Loopback redirect_uri; required for an interactive flow, omitted for silent reconnect. */
|
|
28
|
+
redirectUrl?: string;
|
|
29
|
+
/** Sends the user to the authorization URL (opens a browser). Interactive flows only. */
|
|
30
|
+
openAuthorization?: (url: URL) => void;
|
|
31
|
+
home?: string;
|
|
32
|
+
env?: NodeJS.ProcessEnv;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class McpOAuthProvider implements OAuthClientProvider {
|
|
36
|
+
private readonly serverName: string;
|
|
37
|
+
private readonly _redirectUrl?: string;
|
|
38
|
+
private readonly openAuthorization?: (url: URL) => void;
|
|
39
|
+
private readonly home?: string;
|
|
40
|
+
private readonly env?: NodeJS.ProcessEnv;
|
|
41
|
+
|
|
42
|
+
// The SDK calls tokens() on every request it sends, so the parsed store is
|
|
43
|
+
// cached in-instance and re-read only after this provider's own writes,
|
|
44
|
+
// instead of a readFileSync + JSON.parse per getter.
|
|
45
|
+
private cache?: StoredAuth;
|
|
46
|
+
/** The `state` issued for this flow's authorization request; the callback must echo it (RFC 8252 §8.9). */
|
|
47
|
+
private issuedState?: string;
|
|
48
|
+
|
|
49
|
+
constructor(options: McpOAuthProviderOptions) {
|
|
50
|
+
this.serverName = options.serverName;
|
|
51
|
+
this._redirectUrl = options.redirectUrl;
|
|
52
|
+
this.openAuthorization = options.openAuthorization;
|
|
53
|
+
this.home = options.home;
|
|
54
|
+
this.env = options.env;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private load(): StoredAuth {
|
|
58
|
+
return (this.cache ??= readAuth(this.serverName, this.home, this.env));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Merge a patch into the cached store and persist it. */
|
|
62
|
+
private save(patch: Partial<StoredAuth>): void {
|
|
63
|
+
this.cache = { ...this.load(), ...patch };
|
|
64
|
+
writeAuth(this.serverName, this.cache, this.home, this.env);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
get redirectUrl(): string | URL | undefined {
|
|
68
|
+
return this._redirectUrl;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
get clientMetadata(): OAuthClientMetadata {
|
|
72
|
+
// A persisted metadata block keeps a re-registered client stable across
|
|
73
|
+
// sessions; otherwise register as a public client using the loopback
|
|
74
|
+
// redirect with the PKCE authorization-code grant.
|
|
75
|
+
const stored = this.load().clientMetadata;
|
|
76
|
+
if (stored) return stored;
|
|
77
|
+
return {
|
|
78
|
+
client_name: "One Code",
|
|
79
|
+
redirect_uris: this._redirectUrl ? [this._redirectUrl] : [],
|
|
80
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
81
|
+
response_types: ["code"],
|
|
82
|
+
token_endpoint_auth_method: "none",
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
clientInformation(): OAuthClientInformationMixed | undefined {
|
|
87
|
+
return this.load().clientInformation;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
saveClientInformation(clientInformation: OAuthClientInformationMixed): void {
|
|
91
|
+
this.save({ clientInformation: clientInformation as OAuthClientInformationFull, clientMetadata: this.clientMetadata });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
tokens(): OAuthTokens | undefined {
|
|
95
|
+
return this.load().tokens;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
saveTokens(tokens: OAuthTokens): void {
|
|
99
|
+
this.save({ tokens });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Fresh per flow; the SDK puts it on the authorization URL. */
|
|
103
|
+
state(): string {
|
|
104
|
+
const issued = this.issuedState ?? randomBytes(16).toString("hex");
|
|
105
|
+
this.issuedState = issued;
|
|
106
|
+
return issued;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The state the loopback callback must carry back, or undefined before an authorization was started. */
|
|
110
|
+
expectedState(): string | undefined {
|
|
111
|
+
return this.issuedState;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
redirectToAuthorization(authorizationUrl: URL): void {
|
|
115
|
+
if (!this.openAuthorization) {
|
|
116
|
+
throw new Error(`MCP server "${this.serverName}" needs authentication; run /mcp and choose Authenticate.`);
|
|
117
|
+
}
|
|
118
|
+
this.openAuthorization(authorizationUrl);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
saveCodeVerifier(codeVerifier: string): void {
|
|
122
|
+
this.save({ codeVerifier });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
codeVerifier(): string {
|
|
126
|
+
const verifier = this.load().codeVerifier;
|
|
127
|
+
if (!verifier) throw new Error("no PKCE code verifier saved for this authorization");
|
|
128
|
+
return verifier;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
saveDiscoveryState(state: OAuthDiscoveryState): void {
|
|
132
|
+
this.save({ discoveryState: state });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
discoveryState(): OAuthDiscoveryState | undefined {
|
|
136
|
+
return this.load().discoveryState;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
invalidateCredentials(scope: "all" | "client" | "tokens" | "verifier" | "discovery"): void {
|
|
140
|
+
this.save({
|
|
141
|
+
clientInformation: scope === "all" || scope === "client" ? undefined : this.load().clientInformation,
|
|
142
|
+
tokens: scope === "all" || scope === "tokens" ? undefined : this.load().tokens,
|
|
143
|
+
codeVerifier: scope === "all" || scope === "verifier" ? undefined : this.load().codeVerifier,
|
|
144
|
+
discoveryState: scope === "all" || scope === "discovery" ? undefined : this.load().discoveryState,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-disk OAuth credential store for MCP servers.
|
|
3
|
+
*
|
|
4
|
+
* One JSON file per server under `~/.onecode/mcp-auth/<slug>.json`, holding the
|
|
5
|
+
* dynamically-registered client info, the tokens (access + refresh), the PKCE
|
|
6
|
+
* verifier mid-flow, and the cached discovery state. The MCP SDK's OAuth
|
|
7
|
+
* provider (oauth/provider.ts) reads and writes through this; refresh happens
|
|
8
|
+
* automatically once a refresh token is stored, so a one-time Authenticate keeps
|
|
9
|
+
* the server connected across sessions.
|
|
10
|
+
*
|
|
11
|
+
* Kept in ~/.onecode (never ~/.claude) like all One Code state, and separate
|
|
12
|
+
* from settings.json because tokens are secrets, not configuration.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { mkdirSync, chmodSync } from "node:fs";
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import type { OAuthClientInformationFull, OAuthClientMetadata, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
19
|
+
import type { OAuthDiscoveryState } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
20
|
+
import { readJsonFile, writeJsonAtomic } from "../../lib/atomic-write.ts";
|
|
21
|
+
import { oneCodeStateDir } from "../../lib/paths.ts";
|
|
22
|
+
|
|
23
|
+
export interface StoredAuth {
|
|
24
|
+
clientInformation?: OAuthClientInformationFull;
|
|
25
|
+
clientMetadata?: OAuthClientMetadata;
|
|
26
|
+
tokens?: OAuthTokens;
|
|
27
|
+
codeVerifier?: string;
|
|
28
|
+
discoveryState?: OAuthDiscoveryState;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Filesystem-safe, collision-free file name for a server: a readable sanitized
|
|
33
|
+
* stem plus a short hash of the raw name, so two names that sanitize alike
|
|
34
|
+
* (e.g. "a/b" and "a_b") still get distinct files.
|
|
35
|
+
*/
|
|
36
|
+
function slug(serverName: string): string {
|
|
37
|
+
let hash = 5381;
|
|
38
|
+
for (let i = 0; i < serverName.length; i++) hash = ((hash << 5) + hash + serverName.charCodeAt(i)) >>> 0;
|
|
39
|
+
const safe = serverName.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 48);
|
|
40
|
+
return `${safe || "server"}-${hash.toString(16).padStart(8, "0")}.json`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function authStoreDir(home: string = os.homedir(), env: NodeJS.ProcessEnv = process.env): string {
|
|
44
|
+
return join(oneCodeStateDir(env, home), "mcp-auth");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function authFilePath(serverName: string, home: string, env: NodeJS.ProcessEnv): string {
|
|
48
|
+
return join(authStoreDir(home, env), slug(serverName));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function readAuth(serverName: string, home: string = os.homedir(), env: NodeJS.ProcessEnv = process.env): StoredAuth {
|
|
52
|
+
return readJsonFile<StoredAuth>(authFilePath(serverName, home, env)) ?? {};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function writeAuth(
|
|
56
|
+
serverName: string,
|
|
57
|
+
auth: StoredAuth,
|
|
58
|
+
home: string = os.homedir(),
|
|
59
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
60
|
+
): void {
|
|
61
|
+
// Bearer tokens: owner-only file and directory (review M10). mkdir's mode
|
|
62
|
+
// applies only on creation, so an existing dir is tightened explicitly.
|
|
63
|
+
const dir = authStoreDir(home, env);
|
|
64
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
65
|
+
try {
|
|
66
|
+
chmodSync(dir, 0o700);
|
|
67
|
+
} catch {
|
|
68
|
+
// Not fatal (a filesystem without modes); the file mode below still applies where it can.
|
|
69
|
+
}
|
|
70
|
+
writeJsonAtomic(authFilePath(serverName, home, env), auth, { mode: 0o600 });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Merge a partial update into the stored record. */
|
|
74
|
+
export function updateAuth(
|
|
75
|
+
serverName: string,
|
|
76
|
+
patch: Partial<StoredAuth>,
|
|
77
|
+
home: string = os.homedir(),
|
|
78
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
79
|
+
): void {
|
|
80
|
+
writeAuth(serverName, { ...readAuth(serverName, home, env), ...patch }, home, env);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** True when a usable (non-empty) access token is stored. */
|
|
84
|
+
export function hasStoredTokens(serverName: string, home: string = os.homedir(), env: NodeJS.ProcessEnv = process.env): boolean {
|
|
85
|
+
return Boolean(readAuth(serverName, home, env).tokens?.access_token);
|
|
86
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raw-byte key decoding for the /mcp panel (pure).
|
|
3
|
+
*
|
|
4
|
+
* The panel is a two-level menu (server list → server detail with a numbered
|
|
5
|
+
* action list), navigated with the arrows and Enter, Esc to go back/close. A
|
|
6
|
+
* digit selects an action directly in the detail view. No text field, so any
|
|
7
|
+
* other printable input is ignored.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type McpKey =
|
|
11
|
+
| { kind: "up" | "down" | "enter" | "back" | "close" }
|
|
12
|
+
| { kind: "digit"; value: number };
|
|
13
|
+
|
|
14
|
+
export function decodeMcpKey(data: string): McpKey | undefined {
|
|
15
|
+
switch (data) {
|
|
16
|
+
case "\x1b[A":
|
|
17
|
+
case "\x1bOA":
|
|
18
|
+
return { kind: "up" };
|
|
19
|
+
case "\x1b[B":
|
|
20
|
+
case "\x1bOB":
|
|
21
|
+
return { kind: "down" };
|
|
22
|
+
case "\r":
|
|
23
|
+
case "\n":
|
|
24
|
+
return { kind: "enter" };
|
|
25
|
+
case "\x1b":
|
|
26
|
+
return { kind: "back" };
|
|
27
|
+
case "\x03": // ctrl+c
|
|
28
|
+
return { kind: "close" };
|
|
29
|
+
default:
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
if (data.length === 1 && data >= "1" && data <= "9") return { kind: "digit", value: Number(data) };
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /mcp panel data model (pure — no pi, no fs).
|
|
3
|
+
*
|
|
4
|
+
* The mcp extension builds an `McpEntry[]` from its live connection state each
|
|
5
|
+
* repaint; this module owns the shared vocabulary the state machine and the
|
|
6
|
+
* renderer agree on: the status/auth glyphs and the per-entry action list. The
|
|
7
|
+
* list view groups entries under a heading (computed by the wiring, e.g. "User
|
|
8
|
+
* MCPs (~/.claude.json)"); the detail view shows one entry's fields and actions.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type McpEntryStatus = "connected" | "failed" | "authNeeded" | "connecting" | "disabled";
|
|
12
|
+
|
|
13
|
+
export interface McpEntry {
|
|
14
|
+
name: string;
|
|
15
|
+
/** Heading this entry sits under in the grouped list. */
|
|
16
|
+
group: string;
|
|
17
|
+
status: McpEntryStatus;
|
|
18
|
+
toolCount?: number;
|
|
19
|
+
/** Failure reason, shown as the detail view's "Issue" line. */
|
|
20
|
+
issue?: string;
|
|
21
|
+
/** http server url, shown as the detail view's "URL" line. */
|
|
22
|
+
url?: string;
|
|
23
|
+
/** Where the server was configured, shown as "Config location". */
|
|
24
|
+
configLocation: string;
|
|
25
|
+
/** Present when auth state is known; drives the "Auth" line. */
|
|
26
|
+
authState?: "authenticated" | "notAuthenticated";
|
|
27
|
+
/** True only for an OAuth-capable http server (Authenticate can actually help). */
|
|
28
|
+
canAuthenticate: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type McpAction = "reconnect" | "authenticate" | "disable" | "enable";
|
|
32
|
+
|
|
33
|
+
export interface McpActionItem {
|
|
34
|
+
key: McpAction;
|
|
35
|
+
label: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The numbered action list for an entry, matching Claude Code's detail view. */
|
|
39
|
+
export function actionsFor(entry: McpEntry): McpActionItem[] {
|
|
40
|
+
if (entry.status === "disabled") return [{ key: "enable", label: "Enable" }];
|
|
41
|
+
const primary: McpActionItem =
|
|
42
|
+
entry.status === "authNeeded" && entry.canAuthenticate
|
|
43
|
+
? { key: "authenticate", label: "Authenticate" }
|
|
44
|
+
: { key: "reconnect", label: "Reconnect" };
|
|
45
|
+
return [primary, { key: "disable", label: "Disable" }];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Glyph + label for a status; the wiring paints the glyph in the status colour. */
|
|
49
|
+
export function statusText(entry: McpEntry): { glyph: string; label: string; color: string } {
|
|
50
|
+
switch (entry.status) {
|
|
51
|
+
case "connected":
|
|
52
|
+
return { glyph: "✔", label: "connected", color: "success" };
|
|
53
|
+
case "authNeeded":
|
|
54
|
+
return { glyph: "⚠", label: "needs authentication", color: "warning" };
|
|
55
|
+
case "connecting":
|
|
56
|
+
return { glyph: "…", label: "connecting", color: "dim" };
|
|
57
|
+
case "disabled":
|
|
58
|
+
return { glyph: "○", label: "disabled", color: "dim" };
|
|
59
|
+
case "failed":
|
|
60
|
+
return { glyph: "✘", label: "failed", color: "error" };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /mcp panel rendering (pure).
|
|
3
|
+
*
|
|
4
|
+
* Returns plain lines already cut to width; the wiring wraps them in a memoized
|
|
5
|
+
* component and applies a final ANSI-aware truncate. Mirrors Claude Code's MCP
|
|
6
|
+
* manager: a grouped server list ("Manage MCP servers") and a per-server detail
|
|
7
|
+
* view (Status / Issue / Auth / URL / Config location + a numbered action list).
|
|
8
|
+
* Only width-1 glyphs are used so cutPlainText's code-point count stays exact.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { cutPlainText, padPlainText, panelTopRule, type RenderBlock as Block, windowBlocks } from "../../lib/tui-render.ts";
|
|
12
|
+
import { actionsFor, type McpEntry, statusText } from "./model.ts";
|
|
13
|
+
import { clampMcpCursor, detailEntry, type McpPanelState } from "./state.ts";
|
|
14
|
+
|
|
15
|
+
export interface McpPaint {
|
|
16
|
+
fg: (color: string, text: string) => string;
|
|
17
|
+
bold: (text: string) => string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface McpRenderInput {
|
|
21
|
+
state: McpPanelState;
|
|
22
|
+
entries: McpEntry[];
|
|
23
|
+
width: number;
|
|
24
|
+
height: number;
|
|
25
|
+
/** Transient messages (auth in progress / URL / errors). */
|
|
26
|
+
notices: string[];
|
|
27
|
+
settled: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function statusFragment(entry: McpEntry, paint: McpPaint): string {
|
|
31
|
+
const { glyph, label, color } = statusText(entry);
|
|
32
|
+
const tools = entry.status === "connected" && entry.toolCount !== undefined ? ` · ${entry.toolCount} tools` : "";
|
|
33
|
+
return `${paint.fg(color, glyph)} ${label}${tools}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function listBlocks(input: McpRenderInput, paint: McpPaint): Block[] {
|
|
37
|
+
const { state, entries, width } = input;
|
|
38
|
+
const blocks: Block[] = [];
|
|
39
|
+
let lastGroup: string | undefined;
|
|
40
|
+
entries.forEach((entry, i) => {
|
|
41
|
+
if (entry.group !== lastGroup) {
|
|
42
|
+
lastGroup = entry.group;
|
|
43
|
+
blocks.push({ lines: ["", paint.bold(cutPlainText(` ${entry.group}`, width - 1))], selectable: false });
|
|
44
|
+
}
|
|
45
|
+
const isCursor = i === state.cursor;
|
|
46
|
+
const marker = isCursor ? "❯ " : " ";
|
|
47
|
+
if (isCursor) {
|
|
48
|
+
// Cursor row is one flat accent highlight, so build it from plain text.
|
|
49
|
+
const { glyph, label } = statusText(entry);
|
|
50
|
+
const tools = entry.status === "connected" && entry.toolCount !== undefined ? ` · ${entry.toolCount} tools` : "";
|
|
51
|
+
const plain = `${marker}${entry.name} · ${glyph} ${label}${tools}`;
|
|
52
|
+
blocks.push({ lines: [paint.fg("accent", cutPlainText(plain, width - 1))], selectable: true });
|
|
53
|
+
} else {
|
|
54
|
+
blocks.push({ lines: [`${marker}${entry.name} · ${statusFragment(entry, paint)}`], selectable: true });
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
return blocks;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Aligns the detail view's value column past the longest label ("Config location:"). */
|
|
61
|
+
const LABEL_WIDTH = 18;
|
|
62
|
+
|
|
63
|
+
function detailLines(input: McpRenderInput, paint: McpPaint): { lines: string[]; footer: string } {
|
|
64
|
+
const { state, entries, width } = input;
|
|
65
|
+
const entry = detailEntry(state, entries);
|
|
66
|
+
if (!entry) return { lines: [paint.fg("dim", " (server no longer available)")], footer: "Esc to go back" };
|
|
67
|
+
|
|
68
|
+
const lines: string[] = [];
|
|
69
|
+
// The value may already carry paint escapes, so it isn't cut here (cutPlainText
|
|
70
|
+
// counts code points, escapes included) — the caller's ANSI-aware truncateLine
|
|
71
|
+
// trims to the real width. Long plain values are pre-cut before painting below.
|
|
72
|
+
const field = (label: string, value: string) => {
|
|
73
|
+
lines.push(` ${paint.bold(padPlainText(`${label}:`, LABEL_WIDTH))}${value}`);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
lines.push(` ${paint.bold(cutPlainText(`${entry.name} MCP Server`, width - 1))}`);
|
|
77
|
+
lines.push("");
|
|
78
|
+
const status = statusText(entry);
|
|
79
|
+
field("Status", `${paint.fg(status.color, status.glyph)} ${status.label}`);
|
|
80
|
+
if (entry.issue) field("Issue", paint.fg("dim", cutPlainText(entry.issue, width - LABEL_WIDTH - 2)));
|
|
81
|
+
if (entry.authState) {
|
|
82
|
+
field(
|
|
83
|
+
"Auth",
|
|
84
|
+
entry.authState === "authenticated"
|
|
85
|
+
? paint.fg("success", "✔ authenticated")
|
|
86
|
+
: paint.fg("error", "✘ not authenticated"),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
if (entry.url) field("URL", paint.fg("dim", entry.url));
|
|
90
|
+
field("Config location", paint.fg("dim", entry.configLocation));
|
|
91
|
+
lines.push("");
|
|
92
|
+
|
|
93
|
+
const actions = actionsFor(entry);
|
|
94
|
+
const actionCursor = state.detail?.actionCursor ?? 0;
|
|
95
|
+
actions.forEach((action, i) => {
|
|
96
|
+
const isCursor = i === actionCursor;
|
|
97
|
+
const line = `${isCursor ? "❯ " : " "}${i + 1}. ${action.label}`;
|
|
98
|
+
lines.push(isCursor ? paint.fg("accent", line) : line);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
return { lines, footer: "↑/↓ to navigate · Enter to select · Esc to back" };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function renderMcpPanel(input: McpRenderInput, paint: McpPaint): string[] {
|
|
105
|
+
const { state, entries, width, height } = input;
|
|
106
|
+
clampMcpCursor(state, entries);
|
|
107
|
+
|
|
108
|
+
const out: string[] = [panelTopRule(paint.fg, width)];
|
|
109
|
+
let footer: string;
|
|
110
|
+
|
|
111
|
+
if (state.detail) {
|
|
112
|
+
out.push("");
|
|
113
|
+
const detail = detailLines(input, paint);
|
|
114
|
+
out.push(...detail.lines);
|
|
115
|
+
footer = detail.footer;
|
|
116
|
+
} else {
|
|
117
|
+
out.push(` ${paint.bold("Manage MCP servers")}`);
|
|
118
|
+
const count = `${entries.length} server${entries.length === 1 ? "" : "s"}${input.settled ? "" : " · connecting…"}`;
|
|
119
|
+
out.push(paint.fg("dim", cutPlainText(` ${count}`, width - 1)));
|
|
120
|
+
|
|
121
|
+
const footerReserve = 3 + input.notices.length + 1;
|
|
122
|
+
const budget = Math.max(1, height - out.length - footerReserve);
|
|
123
|
+
if (entries.length === 0) {
|
|
124
|
+
out.push("", paint.fg("dim", " No MCP servers configured. Add them to .mcp.json or ~/.claude.json."));
|
|
125
|
+
} else {
|
|
126
|
+
const windowed = windowBlocks(listBlocks(input, paint), state.cursor, budget);
|
|
127
|
+
out.push(...windowed.lines);
|
|
128
|
+
if (windowed.more > 0) out.push(paint.fg("dim", ` ↓ ${windowed.more} more`));
|
|
129
|
+
}
|
|
130
|
+
out.push("");
|
|
131
|
+
out.push(paint.fg("dim", cutPlainText(" Servers are configured in .mcp.json or ~/.claude.json.", width - 1)));
|
|
132
|
+
footer = "↑/↓ to navigate · Enter to confirm · Esc to cancel";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (const notice of input.notices) {
|
|
136
|
+
for (const line of notice.split("\n")) out.push(paint.fg("warning", cutPlainText(` ${line}`, width - 1)));
|
|
137
|
+
}
|
|
138
|
+
out.push("");
|
|
139
|
+
out.push(paint.fg("dim", cutPlainText(` ${footer}`, width - 1)));
|
|
140
|
+
return out;
|
|
141
|
+
}
|