@megacode/cli 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +268 -0
  3. package/dist/adapters/accounts.js +34 -0
  4. package/dist/adapters/auth/anthropic.js +36 -0
  5. package/dist/adapters/auth/chatgpt.js +100 -0
  6. package/dist/adapters/auth/oauth.js +68 -0
  7. package/dist/adapters/auth/openrouter.js +27 -0
  8. package/dist/adapters/auth/store.js +25 -0
  9. package/dist/adapters/git/worktree.js +113 -0
  10. package/dist/adapters/mcp/config.js +59 -0
  11. package/dist/adapters/mcp/manager.js +165 -0
  12. package/dist/adapters/mcp/results.js +21 -0
  13. package/dist/adapters/mcp/transport.js +19 -0
  14. package/dist/adapters/project.js +15 -0
  15. package/dist/adapters/providers/anthropic.js +103 -0
  16. package/dist/adapters/providers/catalog.js +80 -0
  17. package/dist/adapters/providers/chatgpt.js +117 -0
  18. package/dist/adapters/providers/credentials.js +55 -0
  19. package/dist/adapters/providers/gemini.js +123 -0
  20. package/dist/adapters/providers/openai.js +128 -0
  21. package/dist/adapters/providers/registry.js +68 -0
  22. package/dist/adapters/providers/shared.js +12 -0
  23. package/dist/adapters/providers/usage.js +95 -0
  24. package/dist/adapters/settings.js +15 -0
  25. package/dist/adapters/skills.js +163 -0
  26. package/dist/adapters/storage.js +30 -0
  27. package/dist/adapters/tools/files.js +113 -0
  28. package/dist/adapters/tools/image.js +63 -0
  29. package/dist/adapters/tools/index.js +26 -0
  30. package/dist/adapters/tools/output.js +56 -0
  31. package/dist/adapters/tools/paths.js +5 -0
  32. package/dist/adapters/tools/questions.js +52 -0
  33. package/dist/adapters/tools/shell.js +73 -0
  34. package/dist/adapters/tools/validation.js +34 -0
  35. package/dist/args.js +30 -0
  36. package/dist/cli.js +111 -0
  37. package/dist/composition.js +18 -0
  38. package/dist/core/agent.js +128 -0
  39. package/dist/core/conversation.js +37 -0
  40. package/dist/core/prompts.js +12 -0
  41. package/dist/core/provider.js +8 -0
  42. package/dist/core/settings.js +15 -0
  43. package/dist/core/suggestion.js +29 -0
  44. package/dist/core/tools.js +10 -0
  45. package/dist/lib/async.js +6 -0
  46. package/dist/ui/App.js +218 -0
  47. package/dist/ui/ApprovalDialog.js +39 -0
  48. package/dist/ui/ConfigMenu.js +106 -0
  49. package/dist/ui/Dialog.js +10 -0
  50. package/dist/ui/DiffLines.js +12 -0
  51. package/dist/ui/EffortPicker.js +8 -0
  52. package/dist/ui/Help.js +21 -0
  53. package/dist/ui/KeyList.js +10 -0
  54. package/dist/ui/LoginDialog.js +154 -0
  55. package/dist/ui/McpMenu.js +138 -0
  56. package/dist/ui/ModelPicker.js +117 -0
  57. package/dist/ui/PromptInput.js +161 -0
  58. package/dist/ui/Questionnaire.js +35 -0
  59. package/dist/ui/Select.js +28 -0
  60. package/dist/ui/Spinner.js +18 -0
  61. package/dist/ui/StatusLine.js +19 -0
  62. package/dist/ui/TextField.js +22 -0
  63. package/dist/ui/Transcript.js +40 -0
  64. package/dist/ui/WorktreeMenu.js +83 -0
  65. package/dist/ui/autocomplete.js +6 -0
  66. package/dist/ui/commands.js +117 -0
  67. package/dist/ui/diff.js +130 -0
  68. package/dist/ui/format.js +138 -0
  69. package/dist/ui/history.js +9 -0
  70. package/dist/ui/hooks/useAgentSession.js +34 -0
  71. package/dist/ui/hooks/usePromptSuggestion.js +23 -0
  72. package/dist/ui/hooks/useStreamedText.js +38 -0
  73. package/dist/ui/hooks/useWorktree.js +30 -0
  74. package/dist/ui/loginFlow.js +37 -0
  75. package/dist/ui/mcpWizard.js +18 -0
  76. package/dist/ui/plain.js +60 -0
  77. package/dist/ui/session.js +168 -0
  78. package/package.json +57 -0
@@ -0,0 +1,113 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { mkdir, realpath, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { loadSettings } from "../settings.js";
7
+ export const WORKTREE_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
8
+ const execGit = promisify(execFile);
9
+ // Async so a slow git (large repo, network filesystem) never freezes the UI.
10
+ async function git(cwd, ...args) {
11
+ return (await execGit("git", args, { cwd, encoding: "utf8" })).stdout.trim();
12
+ }
13
+ const tryGit = (cwd, ...args) => git(cwd, ...args).catch(() => null);
14
+ /** git's own message for a failed command. */
15
+ const gitError = (e) => e.stderr?.trim() || e.message;
16
+ /** Root of the main checkout, also when called from inside a worktree. */
17
+ export async function mainRoot(cwd = process.cwd()) {
18
+ const common = await tryGit(cwd, "rev-parse", "--path-format=absolute", "--git-common-dir");
19
+ if (!common)
20
+ throw new Error("Not a git repository; worktrees need git.");
21
+ return path.dirname(common);
22
+ }
23
+ const worktreesDir = (root) => path.join(root, ".megacode", "worktrees");
24
+ const worktree = (root, name) => ({
25
+ name,
26
+ path: path.join(worktreesDir(root), name),
27
+ branch: `worktree-${name}`,
28
+ root,
29
+ });
30
+ /** Worktrees under .megacode/worktrees that git knows about. */
31
+ export async function listWorktrees(cwd = process.cwd()) {
32
+ const root = await mainRoot(cwd);
33
+ const dir = worktreesDir(root);
34
+ if (!existsSync(dir))
35
+ return [];
36
+ const real = await realpath(dir);
37
+ return (await git(root, "worktree", "list", "--porcelain"))
38
+ .split("\n")
39
+ .filter((l) => l.startsWith("worktree "))
40
+ .map((l) => l.slice("worktree ".length))
41
+ .filter((p) => path.dirname(p) === real || path.dirname(p) === dir)
42
+ .map((p) => worktree(root, path.basename(p)));
43
+ }
44
+ const ADJECTIVES = ["brisk", "calm", "clever", "eager", "gentle", "happy", "keen", "lucky", "nimble", "quiet", "swift", "witty"];
45
+ const NOUNS = ["badger", "comet", "falcon", "harbor", "lantern", "maple", "otter", "pebble", "river", "sparrow", "tiger", "willow"];
46
+ const pick = (list) => list[Math.floor(Math.random() * list.length)];
47
+ function randomName(root) {
48
+ for (;;) {
49
+ const name = `${pick(ADJECTIVES)}-${pick(NOUNS)}-${Math.floor(Math.random() * 1000)}`;
50
+ if (!existsSync(worktree(root, name).path))
51
+ return name;
52
+ }
53
+ }
54
+ /** The commit new worktrees start from: the remote's default branch, or the current commit (see /config). */
55
+ export async function baseRef(cwd = process.cwd()) {
56
+ if (loadSettings().worktreeBase === "fresh")
57
+ for (const ref of ["origin/HEAD", "origin/main", "origin/master"])
58
+ if (await tryGit(cwd, "rev-parse", "--verify", "--quiet", `${ref}^{commit}`))
59
+ return ref;
60
+ return "HEAD";
61
+ }
62
+ /** Opens the worktree with this name, creating it (and its branch) if needed. No name picks a random one. */
63
+ export async function openWorktree(name, cwd = process.cwd()) {
64
+ const root = await mainRoot(cwd);
65
+ if (name !== undefined && !WORKTREE_NAME.test(name))
66
+ throw new Error(`Invalid worktree name "${name}": use letters, digits, - and _ (max 64).`);
67
+ const wt = worktree(root, name ?? randomName(root));
68
+ if ((await listWorktrees(root)).some((w) => w.name === wt.name))
69
+ return { ...wt, created: false };
70
+ if (existsSync(wt.path))
71
+ throw new Error(`${wt.path} exists but isn't a git worktree; remove it or pick another name.`);
72
+ // Keep worktrees out of the main checkout's `git status`.
73
+ await mkdir(worktreesDir(root), { recursive: true });
74
+ const ignore = path.join(worktreesDir(root), ".gitignore");
75
+ if (!existsSync(ignore))
76
+ await writeFile(ignore, "*\n");
77
+ // Reuse a branch left behind by an earlier worktree of the same name rather than resetting it.
78
+ const branchExists = await tryGit(root, "rev-parse", "--verify", "--quiet", `refs/heads/${wt.branch}`);
79
+ const args = branchExists ? [wt.path, wt.branch] : ["-b", wt.branch, wt.path, await baseRef(cwd)];
80
+ await git(root, "worktree", "add", ...args).catch((e) => {
81
+ throw new Error(`git worktree add failed: ${gitError(e)}`);
82
+ });
83
+ return { ...wt, created: true };
84
+ }
85
+ /** Uncommitted files, and commits on the worktree's branch that no other branch or remote has. Null if git fails. */
86
+ export async function worktreeChanges(wt) {
87
+ const [status, commits] = await Promise.all([
88
+ tryGit(wt.path, "status", "--porcelain"),
89
+ tryGit(wt.path, "rev-list", "--count", "HEAD", "--not", `--exclude=${wt.branch}`, "--branches", "--remotes"),
90
+ ]);
91
+ if (status === null || commits === null)
92
+ return null;
93
+ return { files: status.split("\n").filter(Boolean).length, commits: Number(commits) };
94
+ }
95
+ export function describeChanges(c) {
96
+ if (!c)
97
+ return "status unknown";
98
+ const parts = [];
99
+ if (c.files)
100
+ parts.push(`${c.files} changed file${c.files === 1 ? "" : "s"}`);
101
+ if (c.commits)
102
+ parts.push(`${c.commits} new commit${c.commits === 1 ? "" : "s"}`);
103
+ return parts.join(", ") || "no changes";
104
+ }
105
+ /** Deletes the worktree directory (discarding uncommitted changes) and its branch. */
106
+ export async function removeWorktree(wt) {
107
+ if (process.cwd() === wt.path || process.cwd().startsWith(wt.path + path.sep))
108
+ process.chdir(wt.root);
109
+ await git(wt.root, "worktree", "remove", "--force", wt.path).catch((e) => {
110
+ throw new Error(`git worktree remove failed: ${gitError(e)}`);
111
+ });
112
+ await tryGit(wt.root, "branch", "-D", wt.branch);
113
+ }
@@ -0,0 +1,59 @@
1
+ import { readJson, writeJson } from "../storage.js";
2
+ export const MCP_FILE = "mcp.json";
3
+ export const SERVER_NAME = /^[A-Za-z0-9_-]{1,32}$/;
4
+ export const loadServers = () => readJson(MCP_FILE, {}).mcpServers ?? {};
5
+ /** Writes the server list, keeping any other keys in the file. */
6
+ export function saveServers(servers) {
7
+ writeJson(MCP_FILE, { ...readJson(MCP_FILE, {}), mcpServers: servers });
8
+ }
9
+ export const transportOf = (c) => ("url" in c ? c.type : "stdio");
10
+ export const describeServer = (c) => ("url" in c ? c.url : [c.command, ...(c.args ?? [])].join(" "));
11
+ /** Draft → config: `target` is a URL for http/sse, else a command line; `extras` are headers or env vars. */
12
+ export function draftToConfig({ type, target, extras }) {
13
+ const hasExtras = Object.keys(extras).length > 0;
14
+ if (type !== "stdio")
15
+ return { type, url: target, ...(hasExtras ? { headers: extras } : {}) };
16
+ const [command, ...args] = splitCommand(target);
17
+ return { command: command, args, ...(hasExtras ? { env: extras } : {}) };
18
+ }
19
+ /** Parses "Name: value" (a header) or "KEY=value" (an env var). Null if malformed. */
20
+ export function parseExtra(type, line) {
21
+ const m = type === "stdio" ? line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s) : line.match(/^([^:\s]+):\s*(.+)$/);
22
+ return m ? [m[1], m[2]] : null;
23
+ }
24
+ export function isHttpUrl(text) {
25
+ try {
26
+ return /^https?:$/.test(new URL(text).protocol);
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
32
+ /** Splits a command line into words, honoring single and double quotes. */
33
+ export function splitCommand(line) {
34
+ const words = [];
35
+ let word = null;
36
+ let quote = null;
37
+ for (const ch of line) {
38
+ if (quote) {
39
+ if (ch === quote)
40
+ quote = null;
41
+ else
42
+ word += ch;
43
+ }
44
+ else if (ch === "'" || ch === '"') {
45
+ quote = ch;
46
+ word ??= "";
47
+ }
48
+ else if (/\s/.test(ch)) {
49
+ if (word !== null)
50
+ words.push(word);
51
+ word = null;
52
+ }
53
+ else
54
+ word = (word ?? "") + ch;
55
+ }
56
+ if (word !== null)
57
+ words.push(word);
58
+ return words;
59
+ }
@@ -0,0 +1,165 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js";
3
+ import { withTimeout } from "../../lib/async.js";
4
+ import { compactOutput } from "../tools/output.js";
5
+ import { loadServers, saveServers } from "./config.js";
6
+ import { formatResult } from "./results.js";
7
+ import { createTransport } from "./transport.js";
8
+ const CONNECT_TIMEOUT = 30_000;
9
+ const CALL_TIMEOUT = 10 * 60_000;
10
+ /** Tool names must match ^[A-Za-z0-9_-]{1,64}$ for every provider. */
11
+ const toolName = (server, tool) => `mcp__${server}__${tool.replace(/[^A-Za-z0-9_-]/g, "_")}`.slice(0, 64);
12
+ const lastLine = (s) => s.trim().split("\n").at(-1)?.slice(0, 300) ?? "";
13
+ /** Connections to the servers in ~/.megacode/mcp.json, offered to the agent as a ToolSource. */
14
+ export class McpManager {
15
+ status = new Map();
16
+ connections = new Map();
17
+ generation = new Map(); // ignore events from superseded connections
18
+ listeners = new Set();
19
+ /** Re-render hook for the UI. Returns an unsubscribe function. */
20
+ subscribe(listener) {
21
+ this.listeners.add(listener);
22
+ return () => void this.listeners.delete(listener);
23
+ }
24
+ set(name, status) {
25
+ if (status)
26
+ this.status.set(name, status);
27
+ else
28
+ this.status.delete(name);
29
+ for (const l of this.listeners)
30
+ l();
31
+ }
32
+ servers() {
33
+ return Object.entries(loadServers()).map(([name, config]) => ({
34
+ name,
35
+ config,
36
+ status: config.disabled ? { state: "disabled" } : (this.status.get(name) ?? { state: "connecting" }),
37
+ }));
38
+ }
39
+ /** Connects every enabled server. Resolves once each has connected or failed. */
40
+ start() {
41
+ return Promise.all(this.servers().filter((s) => !s.config.disabled).map((s) => this.connect(s.name)));
42
+ }
43
+ async connect(name) {
44
+ const config = loadServers()[name];
45
+ await this.disconnect(name);
46
+ if (!config)
47
+ return { state: "failed", error: "not configured" };
48
+ if (config.disabled)
49
+ return { state: "disabled" };
50
+ const gen = this.bumpGeneration(name);
51
+ const current = () => this.generation.get(name) === gen;
52
+ this.set(name, { state: "connecting" });
53
+ const { transport, stderr } = createTransport(config);
54
+ const conn = { client: new Client({ name: "megacode", version: "1.0.0" }), stderr };
55
+ this.connections.set(name, conn);
56
+ try {
57
+ await withTimeout(conn.client.connect(transport).then(() => this.refreshTools(name, conn, gen)), CONNECT_TIMEOUT, "timed out connecting");
58
+ conn.client.setNotificationHandler(ToolListChangedNotificationSchema, () => this.refreshTools(name, conn, gen).catch(() => { }));
59
+ conn.client.onclose = () => {
60
+ if (!current())
61
+ return;
62
+ this.connections.delete(name);
63
+ this.set(name, { state: "failed", error: lastLine(stderr()) || "connection closed" });
64
+ };
65
+ }
66
+ catch (e) {
67
+ if (current()) {
68
+ await this.disconnect(name);
69
+ this.set(name, { state: "failed", error: [e.message, lastLine(stderr())].filter(Boolean).join(" · ") });
70
+ }
71
+ }
72
+ return this.status.get(name);
73
+ }
74
+ async refreshTools(name, conn, gen) {
75
+ const tools = [];
76
+ let cursor;
77
+ do {
78
+ const page = await conn.client.listTools(cursor ? { cursor } : undefined);
79
+ for (const t of page.tools)
80
+ tools.push({
81
+ name: toolName(name, t.name),
82
+ server: name,
83
+ tool: t.name,
84
+ description: t.description ?? t.title ?? t.name,
85
+ parameters: { ...t.inputSchema, type: "object", properties: t.inputSchema.properties ?? {} },
86
+ });
87
+ cursor = page.nextCursor;
88
+ } while (cursor);
89
+ if (this.generation.get(name) === gen)
90
+ this.set(name, { state: "connected", tools, instructions: conn.client.getInstructions() });
91
+ }
92
+ bumpGeneration(name) {
93
+ const gen = (this.generation.get(name) ?? 0) + 1;
94
+ this.generation.set(name, gen);
95
+ return gen;
96
+ }
97
+ async disconnect(name) {
98
+ const conn = this.connections.get(name);
99
+ this.connections.delete(name);
100
+ this.bumpGeneration(name);
101
+ await conn?.client.close().catch(() => { });
102
+ }
103
+ add(name, config) {
104
+ saveServers({ ...loadServers(), [name]: config });
105
+ return this.connect(name);
106
+ }
107
+ async remove(name) {
108
+ const { [name]: _, ...rest } = loadServers();
109
+ saveServers(rest);
110
+ await this.disconnect(name);
111
+ this.set(name, null);
112
+ }
113
+ async setEnabled(name, enabled) {
114
+ const servers = loadServers();
115
+ if (!servers[name])
116
+ return;
117
+ const { disabled: _, ...config } = servers[name];
118
+ saveServers({ ...servers, [name]: enabled ? config : { ...config, disabled: true } });
119
+ if (enabled)
120
+ return void (await this.connect(name));
121
+ await this.disconnect(name);
122
+ this.set(name, { state: "disabled" });
123
+ }
124
+ closeAll() {
125
+ return Promise.all([...this.connections.keys()].map((n) => this.disconnect(n)));
126
+ }
127
+ connectedTools() {
128
+ return [...this.status.values()].flatMap((s) => (s.state === "connected" ? s.tools : []));
129
+ }
130
+ // ToolSource
131
+ specs() {
132
+ return this.connectedTools().map(({ name, description, parameters }) => ({ name, description, parameters }));
133
+ }
134
+ has(name) {
135
+ return this.connectedTools().some((t) => t.name === name);
136
+ }
137
+ /** Server instructions for the system prompt. */
138
+ instructions() {
139
+ return [...this.status.entries()]
140
+ .flatMap(([name, s]) => (s.state === "connected" && s.instructions ? [`Instructions from the ${name} MCP server:\n${s.instructions}`] : []))
141
+ .join("\n\n");
142
+ }
143
+ async execute(call, { approve, signal }) {
144
+ const tool = this.connectedTools().find((t) => t.name === call.name);
145
+ const conn = tool && this.connections.get(tool.server);
146
+ if (!tool || !conn)
147
+ return { output: `MCP tool ${call.name} is no longer available.`, isError: true };
148
+ const args = call.input ?? {};
149
+ const body = Object.keys(args).length ? JSON.stringify(args, null, 2) : "(no arguments)";
150
+ if (!(await approve({ tool: call.name, title: `MCP tool ${tool.server} · ${tool.tool}`, body })))
151
+ return { output: "User denied the tool call.", isError: false };
152
+ try {
153
+ const res = await conn.client.callTool({ name: tool.tool, arguments: args }, undefined, {
154
+ signal,
155
+ timeout: CALL_TIMEOUT,
156
+ resetTimeoutOnProgress: true,
157
+ });
158
+ return { output: await compactOutput(formatResult(res)), isError: !!res.isError };
159
+ }
160
+ catch (e) {
161
+ return { output: `Error: ${e.message}`, isError: true };
162
+ }
163
+ }
164
+ }
165
+ export const mcp = new McpManager();
@@ -0,0 +1,21 @@
1
+ function formatPart(p) {
2
+ switch (p.type) {
3
+ case "text":
4
+ return p.text ?? "";
5
+ case "resource":
6
+ return p.resource?.text ?? `[resource ${p.resource?.uri}]`;
7
+ case "resource_link":
8
+ return `[resource ${p.uri}]`;
9
+ default:
10
+ return `[${p.type}${p.mimeType ? ` ${p.mimeType}` : ""} omitted]`;
11
+ }
12
+ }
13
+ /** An MCP tool result as text for the model: content parts, else structured content as JSON. */
14
+ export function formatResult(res) {
15
+ const parts = Array.isArray(res.content) ? res.content : [];
16
+ const text = parts.map(formatPart).join("\n");
17
+ if (text)
18
+ return text;
19
+ const structured = res.structuredContent ?? res.toolResult;
20
+ return structured === undefined ? "(no output)" : JSON.stringify(structured, null, 2);
21
+ }
@@ -0,0 +1,19 @@
1
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
2
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
4
+ const STDERR_TAIL = 2000;
5
+ /** A transport for the config, plus a getter for the tail of a stdio server's stderr (for error messages). */
6
+ export function createTransport(c) {
7
+ if ("url" in c) {
8
+ const requestInit = c.headers ? { headers: c.headers } : undefined;
9
+ const transport = c.type === "sse"
10
+ ? new SSEClientTransport(new URL(c.url), { requestInit })
11
+ : new StreamableHTTPClientTransport(new URL(c.url), { requestInit });
12
+ return { transport, stderr: () => "" };
13
+ }
14
+ const env = Object.fromEntries(Object.entries({ ...process.env, ...c.env }).filter((e) => e[1] !== undefined));
15
+ const transport = new StdioClientTransport({ command: c.command, args: c.args ?? [], env, cwd: process.cwd(), stderr: "pipe" });
16
+ let stderr = "";
17
+ transport.stderr?.on("data", (d) => (stderr = (stderr + d).slice(-STDERR_TAIL)));
18
+ return { transport, stderr: () => stderr };
19
+ }
@@ -0,0 +1,15 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import os from "node:os";
3
+ import { discoverSkills } from "./skills.js";
4
+ const INSTRUCTION_FILES = ["AGENTS.md", "CLAUDE.md"];
5
+ /** The working directory as the system prompt describes it. */
6
+ export function readProjectContext({ includeInstructions }) {
7
+ return {
8
+ cwd: process.cwd(),
9
+ platform: `${os.platform()} ${os.release()}`,
10
+ skills: discoverSkills().skills,
11
+ instructions: includeInstructions
12
+ ? INSTRUCTION_FILES.filter((file) => existsSync(file)).map((file) => ({ file, text: readFileSync(file, "utf8") }))
13
+ : [],
14
+ };
15
+ }
@@ -0,0 +1,103 @@
1
+ import Anthropic from "@anthropic-ai/sdk";
2
+ import { explicitEffort } from "../../core/provider.js";
3
+ /** Output cap when the model's own limit is unknown, and the most we ask for even when it's higher. */
4
+ const MAX_OUTPUT = 64_000;
5
+ export class AnthropicProvider {
6
+ client;
7
+ /** Model limits and capabilities from the Models API, fetched once per model. Null if unavailable. */
8
+ models = new Map();
9
+ // Without a key the SDK falls back to $ANTHROPIC_API_KEY or an `ant auth login` profile.
10
+ constructor(apiKey) {
11
+ this.client = new Anthropic(apiKey ? { apiKey } : {});
12
+ }
13
+ modelInfo(model) {
14
+ let info = this.models.get(model);
15
+ if (!info) {
16
+ // Without the lookup (e.g. a proxy that lacks the endpoint) fall back to the defaults.
17
+ info = this.client.models.retrieve(model).catch(() => null);
18
+ this.models.set(model, info);
19
+ }
20
+ return info;
21
+ }
22
+ async listModels() {
23
+ const ids = [];
24
+ for await (const m of this.client.models.list())
25
+ ids.push(m.id);
26
+ return ids;
27
+ }
28
+ async turn(req) {
29
+ const info = await this.modelInfo(req.model);
30
+ const effort = supportedEffort(explicitEffort(req.effort), info);
31
+ const stream = this.client.messages.stream({
32
+ model: req.model,
33
+ ...(effort ? { output_config: { effort } } : {}),
34
+ // Older models allow fewer output tokens; asking for more is a 400 on every turn.
35
+ max_tokens: Math.min(info?.max_tokens ?? MAX_OUTPUT, MAX_OUTPUT),
36
+ system: req.system,
37
+ cache_control: { type: "ephemeral" },
38
+ tools: req.tools.map((t) => ({ name: t.name, description: t.description, input_schema: t.parameters })),
39
+ messages: toAnthropic(req.messages),
40
+ }, { signal: req.signal });
41
+ stream.on("text", req.onText);
42
+ const msg = await stream.finalMessage();
43
+ const text = msg.content.flatMap((b) => (b.type === "text" ? [b.text] : [])).join("");
44
+ const toolCalls = msg.content.flatMap((b) => b.type === "tool_use" ? [{ id: b.id, name: b.name, input: b.input }] : []);
45
+ return {
46
+ message: { role: "assistant", text, toolCalls, raw: { provider: "anthropic", content: msg.content } },
47
+ stop: mapStop(msg.stop_reason),
48
+ usage: { input: msg.usage.input_tokens, output: msg.usage.output_tokens },
49
+ };
50
+ }
51
+ }
52
+ /** The effort to send, or undefined when the model reports it can't take that level (sending it is a 400). */
53
+ function supportedEffort(effort, info) {
54
+ const support = info?.capabilities?.effort;
55
+ if (!effort || !support)
56
+ return effort; // unknown capabilities: send what the user chose
57
+ return support.supported && support[effort].supported ? effort : undefined;
58
+ }
59
+ function mapStop(r) {
60
+ switch (r) {
61
+ case "end_turn":
62
+ case "stop_sequence":
63
+ return "end";
64
+ case "tool_use":
65
+ case "max_tokens":
66
+ case "refusal":
67
+ return r;
68
+ default:
69
+ return "other";
70
+ }
71
+ }
72
+ export function toAnthropic(messages) {
73
+ const out = [];
74
+ const push = (role, blocks) => {
75
+ if (!blocks.length)
76
+ return; // the API rejects empty turns, e.g. an empty reply
77
+ const last = out.at(-1);
78
+ // Merge consecutive same-role turns (e.g. an interrupted tool result followed by new user text).
79
+ if (last && last.role === role && Array.isArray(last.content))
80
+ last.content.push(...blocks);
81
+ else
82
+ out.push({ role, content: [...blocks] });
83
+ };
84
+ for (const m of messages) {
85
+ if (m.role === "user")
86
+ push("user", [{ type: "text", text: m.text }]);
87
+ else if (m.role === "tool")
88
+ push("user", m.results.map((r) => ({ type: "tool_result", tool_use_id: r.id, content: r.images?.length
89
+ ? [{ type: "text", text: r.output }, ...r.images.map((image) => ({ type: "image", source: { type: "base64", media_type: image.mediaType, data: image.data } }))]
90
+ : r.output, is_error: r.isError })));
91
+ else if (m.raw?.provider === "anthropic")
92
+ push("assistant", m.raw.content);
93
+ else {
94
+ const blocks = [];
95
+ if (m.text)
96
+ blocks.push({ type: "text", text: m.text });
97
+ for (const c of m.toolCalls)
98
+ blocks.push({ type: "tool_use", id: c.id, name: c.name, input: c.input });
99
+ push("assistant", blocks);
100
+ }
101
+ }
102
+ return out;
103
+ }
@@ -0,0 +1,80 @@
1
+ export const PROVIDER_INFO = [
2
+ {
3
+ name: "anthropic",
4
+ label: "Anthropic",
5
+ methods: ["key", "ant-cli"],
6
+ adapter: "anthropic",
7
+ env: ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
8
+ keyUrl: "https://console.anthropic.com/settings/keys",
9
+ defaultModel: "claude-opus-5",
10
+ },
11
+ {
12
+ name: "openai",
13
+ label: "OpenAI",
14
+ methods: ["chatgpt", "key"],
15
+ adapter: "openai",
16
+ env: ["OPENAI_API_KEY"],
17
+ keyUrl: "https://platform.openai.com/api-keys",
18
+ defaultModel: "gpt-5",
19
+ filter: (id) => /^(gpt-|o\d|chatgpt|codex)/.test(id) && !/(audio|realtime|tts|transcribe|image|search|embedding|instruct)/.test(id),
20
+ },
21
+ {
22
+ name: "gemini",
23
+ label: "Google Gemini",
24
+ methods: ["key"],
25
+ adapter: "gemini",
26
+ env: ["GEMINI_API_KEY", "GOOGLE_API_KEY"],
27
+ keyUrl: "https://aistudio.google.com/apikey",
28
+ defaultModel: "gemini-2.5-pro",
29
+ filter: (id) => /^gemini/.test(id) && !/(tts|image|embedding)/.test(id),
30
+ },
31
+ {
32
+ name: "openrouter",
33
+ label: "OpenRouter",
34
+ methods: ["openrouter-oauth", "key"],
35
+ adapter: "openai",
36
+ env: ["OPENROUTER_API_KEY"],
37
+ baseURL: "https://openrouter.ai/api/v1",
38
+ keyUrl: "https://openrouter.ai/settings/keys",
39
+ },
40
+ {
41
+ name: "groq",
42
+ label: "Groq",
43
+ methods: ["key"],
44
+ adapter: "openai",
45
+ env: ["GROQ_API_KEY"],
46
+ baseURL: "https://api.groq.com/openai/v1",
47
+ keyUrl: "https://console.groq.com/keys",
48
+ },
49
+ {
50
+ name: "deepseek",
51
+ label: "DeepSeek",
52
+ methods: ["key"],
53
+ adapter: "openai",
54
+ env: ["DEEPSEEK_API_KEY"],
55
+ baseURL: "https://api.deepseek.com",
56
+ keyUrl: "https://platform.deepseek.com/api_keys",
57
+ },
58
+ { name: "ollama", label: "Ollama (local)", methods: ["url"], adapter: "openai", env: [], baseURL: "http://localhost:11434/v1", local: true },
59
+ { name: "lmstudio", label: "LM Studio (local)", methods: ["url"], adapter: "openai", env: [], baseURL: "http://localhost:1234/v1", local: true },
60
+ {
61
+ name: "compat",
62
+ label: "Custom OpenAI-compatible",
63
+ methods: ["url"],
64
+ adapter: "openai",
65
+ env: ["OPENAI_COMPAT_API_KEY"],
66
+ baseURLEnv: "OPENAI_COMPAT_BASE_URL",
67
+ keyOptional: true,
68
+ },
69
+ ];
70
+ export const PROVIDERS = PROVIDER_INFO.map((p) => p.name);
71
+ export function providerInfo(name) {
72
+ const info = PROVIDER_INFO.find((p) => p.name === name);
73
+ if (!info)
74
+ throw new Error(`Unknown provider "${name}". Available: ${PROVIDERS.join(", ")}`);
75
+ return info;
76
+ }
77
+ /** Providers reached through a custom endpoint rather than the vendor's default one. */
78
+ export const usesEndpoint = (info) => !!(info.baseURL || info.baseURLEnv);
79
+ /** The first env var that's set for this provider's key, if any. */
80
+ export const envKeyName = (info) => info.env.find((k) => process.env[k]);