@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,117 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import OpenAI from "openai";
3
+ import { explicitEffort } from "../../core/provider.js";
4
+ import { CHATGPT_BASE_URL, chatGPTHeaders, chatGPTTokens } from "../auth/chatgpt.js";
5
+ import { imageDataUrl, parseToolArguments } from "./shared.js";
6
+ const FALLBACK_MODELS = ["gpt-5", "gpt-5-codex"];
7
+ /** OpenAI models through a ChatGPT subscription, via the Responses API. */
8
+ export class ChatGPTProvider {
9
+ sessionId = randomUUID();
10
+ async client(forceRefresh = false) {
11
+ const t = await chatGPTTokens(forceRefresh);
12
+ return new OpenAI({
13
+ apiKey: t.access,
14
+ baseURL: CHATGPT_BASE_URL,
15
+ defaultHeaders: { ...chatGPTHeaders(t), session_id: this.sessionId },
16
+ });
17
+ }
18
+ async listModels() {
19
+ const t = await chatGPTTokens();
20
+ try {
21
+ const res = await fetch(`${CHATGPT_BASE_URL}/models?client_version=1.0.0`, {
22
+ headers: { authorization: `Bearer ${t.access}`, ...chatGPTHeaders(t) },
23
+ });
24
+ if (!res.ok)
25
+ throw new Error(String(res.status));
26
+ const body = (await res.json());
27
+ const ids = (body.models ?? []).filter((m) => m.visibility !== "hide").map((m) => m.slug ?? m.id).filter(Boolean);
28
+ return ids.length ? ids : FALLBACK_MODELS;
29
+ }
30
+ catch {
31
+ return FALLBACK_MODELS;
32
+ }
33
+ }
34
+ async turn(req) {
35
+ const params = {
36
+ model: req.model,
37
+ instructions: req.system,
38
+ input: toResponses(req.messages),
39
+ tools: req.tools.map((t) => ({ type: "function", name: t.name, description: t.description, parameters: t.parameters, strict: false })),
40
+ tool_choice: "auto",
41
+ parallel_tool_calls: true,
42
+ reasoning: { effort: explicitEffort(req.effort) ?? "medium", summary: "auto" },
43
+ store: false,
44
+ stream: true,
45
+ include: ["reasoning.encrypted_content"],
46
+ prompt_cache_key: this.sessionId,
47
+ };
48
+ let stream;
49
+ try {
50
+ stream = await (await this.client()).responses.create(params, { signal: req.signal });
51
+ }
52
+ catch (e) {
53
+ if (e.status !== 401)
54
+ throw e;
55
+ stream = await (await this.client(true)).responses.create(params, { signal: req.signal }); // token revoked early
56
+ }
57
+ let text = "";
58
+ let stop = "end";
59
+ let usage;
60
+ const items = [];
61
+ const toolCalls = [];
62
+ for await (const ev of stream) {
63
+ switch (ev.type) {
64
+ case "response.output_text.delta":
65
+ text += ev.delta;
66
+ req.onText(ev.delta);
67
+ break;
68
+ case "response.output_item.done":
69
+ items.push(ev.item);
70
+ if (ev.item.type === "function_call")
71
+ toolCalls.push({ id: ev.item.call_id, name: ev.item.name, input: parseToolArguments(ev.item.arguments) });
72
+ break;
73
+ case "response.completed":
74
+ case "response.incomplete": {
75
+ const u = ev.response.usage;
76
+ if (u)
77
+ usage = { input: u.input_tokens, output: u.output_tokens };
78
+ if (ev.type === "response.incomplete")
79
+ stop = ev.response.incomplete_details?.reason === "content_filter" ? "refusal" : "max_tokens";
80
+ break;
81
+ }
82
+ case "response.failed":
83
+ throw new Error(ev.response.error?.message ?? "Response failed");
84
+ case "error":
85
+ throw new Error(ev.message);
86
+ }
87
+ }
88
+ if (stop === "end" && toolCalls.length)
89
+ stop = "tool_use";
90
+ return { message: { role: "assistant", text, toolCalls, raw: { provider: "openai-responses", content: items } }, stop, usage };
91
+ }
92
+ }
93
+ export function toResponses(messages) {
94
+ return messages.flatMap((m) => {
95
+ if (m.role === "user")
96
+ return [{ role: "user", content: m.text }];
97
+ if (m.role === "tool") {
98
+ const results = m.results.map((r) => ({ type: "function_call_output", call_id: r.id, output: r.output }));
99
+ // Function outputs are text-only; images follow in a user message.
100
+ for (const r of m.results) {
101
+ if (r.images?.length)
102
+ results.push({ role: "user", content: [
103
+ { type: "input_text", text: r.output },
104
+ ...r.images.map((image) => ({ type: "input_image", image_url: imageDataUrl(image), detail: "auto" })),
105
+ ] });
106
+ }
107
+ return results;
108
+ }
109
+ if (m.raw?.provider === "openai-responses")
110
+ // With store: false the server keeps nothing, so item ids can't be referenced; resend items without them.
111
+ return m.raw.content.map(({ id: _id, ...item }) => item);
112
+ return [
113
+ ...(m.text ? [{ role: "assistant", content: m.text }] : []),
114
+ ...m.toolCalls.map((c) => ({ type: "function_call", call_id: c.id, name: c.name, arguments: JSON.stringify(c.input) })),
115
+ ];
116
+ });
117
+ }
@@ -0,0 +1,55 @@
1
+ import { parseModelSpec } from "../../core/provider.js";
2
+ import { hasAntProfile } from "../auth/anthropic.js";
3
+ import { savedCredentials } from "../auth/store.js";
4
+ import { envKeyName, PROVIDERS, providerInfo } from "./catalog.js";
5
+ /** Resolves credentials in precedence order: environment, ChatGPT sign-in, saved key, local server, external profile. */
6
+ export function credentials(name) {
7
+ const info = providerInfo(name);
8
+ const saved = savedCredentials(name) ?? {};
9
+ const envKey = info.env.map((k) => process.env[k]).find(Boolean);
10
+ const envURL = info.baseURLEnv && process.env[info.baseURLEnv];
11
+ const baseURL = envURL || saved.baseURL || info.baseURL;
12
+ if (envKey)
13
+ return { apiKey: envKey, baseURL, source: "env" };
14
+ if (saved.chatgpt)
15
+ return { source: "chatgpt" };
16
+ if (saved.apiKey)
17
+ return { apiKey: saved.apiKey, baseURL, source: "saved" };
18
+ if (info.local)
19
+ return { baseURL, source: "local" };
20
+ // Credentials from `ant auth login`, which the Anthropic SDK picks up on its own.
21
+ if (info.methods.includes("ant-cli") && hasAntProfile())
22
+ return { source: "ant-profile" };
23
+ if (info.keyOptional && baseURL)
24
+ return { baseURL, source: envURL ? "env" : "saved" };
25
+ return { baseURL };
26
+ }
27
+ export const isConfigured = (name) => !!credentials(name).source;
28
+ /** The provider half of a "provider:model" spec. */
29
+ export const providerOf = (spec) => parseModelSpec(spec).provider;
30
+ /** True when the spec names a known provider that has no credentials yet. */
31
+ export function needsLogin(spec) {
32
+ const name = providerOf(spec);
33
+ return PROVIDERS.includes(name) && !isConfigured(name);
34
+ }
35
+ /** Short human description of how a provider is authenticated. */
36
+ export function authStatus(name) {
37
+ const info = providerInfo(name);
38
+ const c = credentials(name);
39
+ switch (c.source) {
40
+ case "env":
41
+ return `via $${envKeyName(info) ?? info.baseURLEnv}`;
42
+ case "saved":
43
+ return c.apiKey ? `saved key ••••${c.apiKey.slice(-4)}` : `saved endpoint ${c.baseURL}`;
44
+ case "chatgpt": {
45
+ const t = savedCredentials(name)?.chatgpt;
46
+ return ["ChatGPT", t?.plan, t?.email].filter(Boolean).join(" · ");
47
+ }
48
+ case "ant-profile":
49
+ return "ant CLI profile";
50
+ case "local":
51
+ return c.baseURL;
52
+ default:
53
+ return "not configured";
54
+ }
55
+ }
@@ -0,0 +1,123 @@
1
+ import { GoogleGenAI, FinishReason, ThinkingLevel } from "@google/genai";
2
+ import { explicitEffort } from "../../core/provider.js";
3
+ import { fallbackCallId } from "./shared.js";
4
+ export class GeminiProvider {
5
+ client;
6
+ filter;
7
+ // Without a key the SDK reads $GEMINI_API_KEY / $GOOGLE_API_KEY.
8
+ constructor(apiKey, filter) {
9
+ this.client = new GoogleGenAI(apiKey ? { apiKey } : {});
10
+ this.filter = filter;
11
+ }
12
+ async listModels() {
13
+ const ids = [];
14
+ for await (const m of await this.client.models.list())
15
+ if (m.name && m.supportedActions?.includes("generateContent"))
16
+ ids.push(m.name.replace(/^models\//, ""));
17
+ return ids.filter((id) => !this.filter || this.filter(id)).reverse(); // newest first
18
+ }
19
+ async turn(req) {
20
+ const thinkingConfig = thinkingFor(req.model, explicitEffort(req.effort));
21
+ const stream = await this.client.models.generateContentStream({
22
+ model: req.model,
23
+ contents: toGemini(req.messages),
24
+ config: {
25
+ abortSignal: req.signal,
26
+ ...(thinkingConfig ? { thinkingConfig } : {}),
27
+ systemInstruction: req.system,
28
+ tools: [
29
+ {
30
+ functionDeclarations: req.tools.map((t) => ({
31
+ name: t.name,
32
+ description: t.description,
33
+ parametersJsonSchema: t.parameters,
34
+ })),
35
+ },
36
+ ],
37
+ },
38
+ });
39
+ let text = "";
40
+ let finish;
41
+ let usage;
42
+ const parts = [];
43
+ const toolCalls = [];
44
+ for await (const chunk of stream) {
45
+ const cand = chunk.candidates?.[0];
46
+ if (chunk.usageMetadata)
47
+ usage = {
48
+ input: chunk.usageMetadata.promptTokenCount ?? 0,
49
+ output: chunk.usageMetadata.candidatesTokenCount ?? 0,
50
+ };
51
+ if (cand?.finishReason)
52
+ finish = cand.finishReason;
53
+ for (const part of cand?.content?.parts ?? []) {
54
+ parts.push(part); // keep everything (incl. thought signatures) for replay
55
+ if (part.thought)
56
+ continue;
57
+ if (part.text) {
58
+ text += part.text;
59
+ req.onText(part.text);
60
+ }
61
+ if (part.functionCall?.name)
62
+ toolCalls.push({
63
+ id: part.functionCall.id ?? fallbackCallId(toolCalls.length),
64
+ name: part.functionCall.name,
65
+ input: part.functionCall.args ?? {},
66
+ });
67
+ }
68
+ }
69
+ return {
70
+ message: { role: "assistant", text, toolCalls, raw: { provider: "gemini", content: parts } },
71
+ stop: mapStop(finish, toolCalls.length > 0),
72
+ usage,
73
+ };
74
+ }
75
+ }
76
+ /** Gemini 3 takes a thinking level; earlier models take a token budget. */
77
+ function thinkingFor(model, effort) {
78
+ if (!effort)
79
+ return undefined;
80
+ return model.startsWith("gemini-3")
81
+ ? { thinkingLevel: { low: ThinkingLevel.LOW, medium: ThinkingLevel.MEDIUM, high: ThinkingLevel.HIGH }[effort] }
82
+ : { thinkingBudget: { low: 1024, medium: 8192, high: 24576 }[effort] };
83
+ }
84
+ function mapStop(r, hasTools) {
85
+ if (r === FinishReason.MAX_TOKENS)
86
+ return "max_tokens";
87
+ if (hasTools)
88
+ return "tool_use";
89
+ if (r === FinishReason.STOP)
90
+ return "end";
91
+ if (r === FinishReason.SAFETY || r === FinishReason.PROHIBITED_CONTENT || r === FinishReason.BLOCKLIST)
92
+ return "refusal";
93
+ return "other";
94
+ }
95
+ export function toGemini(messages) {
96
+ const out = [];
97
+ const push = (role, parts) => {
98
+ if (!parts.length)
99
+ return; // the API rejects empty turns, e.g. an empty reply
100
+ const last = out.at(-1);
101
+ if (last?.role === role)
102
+ last.parts.push(...parts);
103
+ else
104
+ out.push({ role, parts: [...parts] });
105
+ };
106
+ for (const m of messages) {
107
+ if (m.role === "user")
108
+ push("user", [{ text: m.text }]);
109
+ else if (m.role === "tool")
110
+ push("user", m.results.flatMap((r) => [
111
+ { functionResponse: { id: r.id, name: r.name, response: r.isError ? { error: r.output } : { output: r.output } } },
112
+ ...(r.images ?? []).map((image) => ({ inlineData: { mimeType: image.mediaType, data: image.data } })),
113
+ ]));
114
+ else if (m.raw?.provider === "gemini")
115
+ push("model", m.raw.content);
116
+ else
117
+ push("model", [
118
+ ...(m.text ? [{ text: m.text }] : []),
119
+ ...m.toolCalls.map((c) => ({ functionCall: { id: c.id, name: c.name, args: c.input } })),
120
+ ]);
121
+ }
122
+ return out;
123
+ }
@@ -0,0 +1,128 @@
1
+ import OpenAI from "openai";
2
+ import { explicitEffort } from "../../core/provider.js";
3
+ import { fallbackCallId, imageDataUrl, parseToolArguments } from "./shared.js";
4
+ // Chat Completions adapter: works for OpenAI and any OpenAI-compatible endpoint
5
+ // (OpenRouter, Ollama, Groq, DeepSeek, LM Studio, vLLM, ...).
6
+ export class OpenAIProvider {
7
+ client;
8
+ filter;
9
+ /** Models that rejected reasoning_effort; it isn't sent to them again this session. */
10
+ noEffort = new Set();
11
+ constructor(opts = {}) {
12
+ this.client = new OpenAI({ apiKey: opts.apiKey, baseURL: opts.baseURL });
13
+ this.filter = opts.filter;
14
+ }
15
+ async listModels() {
16
+ const models = [];
17
+ // No retries: an offline local server should just drop out of the picker quickly.
18
+ for await (const m of this.client.models.list({ maxRetries: 0, timeout: 10_000 }))
19
+ models.push(m);
20
+ return models
21
+ .sort((a, b) => (b.created ?? 0) - (a.created ?? 0) || a.id.localeCompare(b.id))
22
+ .map((m) => m.id)
23
+ .filter((id) => !this.filter || this.filter(id));
24
+ }
25
+ stream(req, effort) {
26
+ return this.client.chat.completions.create({
27
+ model: req.model,
28
+ ...(effort ? { reasoning_effort: effort } : {}),
29
+ stream: true,
30
+ stream_options: { include_usage: true },
31
+ messages: [{ role: "system", content: req.system }, ...toOpenAI(req.messages)],
32
+ tools: req.tools.map((t) => ({
33
+ type: "function",
34
+ function: { name: t.name, description: t.description, parameters: t.parameters },
35
+ })),
36
+ }, { signal: req.signal });
37
+ }
38
+ async turn(req) {
39
+ const effort = this.noEffort.has(req.model) ? undefined : explicitEffort(req.effort);
40
+ let stream;
41
+ try {
42
+ stream = await this.stream(req, effort);
43
+ }
44
+ catch (e) {
45
+ // Models without reasoning (and some compatible servers) reject the parameter instead of ignoring it.
46
+ if (!effort || !rejectsEffort(e))
47
+ throw e;
48
+ this.noEffort.add(req.model);
49
+ stream = await this.stream(req, undefined);
50
+ }
51
+ let text = "";
52
+ let finish = null;
53
+ let usage;
54
+ const calls = [];
55
+ for await (const chunk of stream) {
56
+ if (chunk.usage)
57
+ usage = { input: chunk.usage.prompt_tokens, output: chunk.usage.completion_tokens };
58
+ const choice = chunk.choices[0];
59
+ if (!choice)
60
+ continue;
61
+ if (choice.delta.content) {
62
+ text += choice.delta.content;
63
+ req.onText(choice.delta.content);
64
+ }
65
+ for (const tc of choice.delta.tool_calls ?? []) {
66
+ const c = (calls[tc.index] ??= { id: "", name: "", args: "" });
67
+ if (tc.id)
68
+ c.id = tc.id;
69
+ if (tc.function?.name)
70
+ c.name += tc.function.name;
71
+ if (tc.function?.arguments)
72
+ c.args += tc.function.arguments;
73
+ }
74
+ if (choice.finish_reason)
75
+ finish = choice.finish_reason;
76
+ }
77
+ const toolCalls = calls.filter(Boolean).map((c, i) => ({
78
+ id: c.id || fallbackCallId(i),
79
+ name: c.name,
80
+ input: parseToolArguments(c.args),
81
+ }));
82
+ return { message: { role: "assistant", text, toolCalls }, stop: mapStop(finish, toolCalls.length > 0), usage };
83
+ }
84
+ }
85
+ const rejectsEffort = (e) => e instanceof OpenAI.BadRequestError && (e.param === "reasoning_effort" || /reasoning_effort/.test(e.message));
86
+ function mapStop(r, hasTools) {
87
+ if (r === "length")
88
+ return "max_tokens";
89
+ if (r === "content_filter")
90
+ return "refusal";
91
+ if (hasTools || r === "tool_calls")
92
+ return "tool_use";
93
+ return r === "stop" ? "end" : "other";
94
+ }
95
+ export function toOpenAI(messages) {
96
+ return messages.flatMap((m) => {
97
+ if (m.role === "user")
98
+ return [{ role: "user", content: m.text }];
99
+ if (m.role === "tool") {
100
+ const results = m.results.map((r) => ({ role: "tool", tool_call_id: r.id, content: r.output }));
101
+ // Chat Completions only accepts images in user messages, after all tool replies.
102
+ for (const r of m.results) {
103
+ if (r.images?.length)
104
+ results.push({ role: "user", content: [
105
+ { type: "text", text: r.output },
106
+ ...r.images.map((image) => ({ type: "image_url", image_url: { url: imageDataUrl(image) } })),
107
+ ] });
108
+ }
109
+ return results;
110
+ }
111
+ // An assistant message needs content or tool calls; leave out empty replies.
112
+ if (!m.text && !m.toolCalls.length)
113
+ return [];
114
+ return [
115
+ {
116
+ role: "assistant",
117
+ content: m.text || null,
118
+ ...(m.toolCalls.length && {
119
+ tool_calls: m.toolCalls.map((c) => ({
120
+ id: c.id,
121
+ type: "function",
122
+ function: { name: c.name, arguments: JSON.stringify(c.input) },
123
+ })),
124
+ }),
125
+ },
126
+ ];
127
+ });
128
+ }
@@ -0,0 +1,68 @@
1
+ import { parseModelSpec } from "../../core/provider.js";
2
+ import { loadSettings } from "../settings.js";
3
+ import { AnthropicProvider } from "./anthropic.js";
4
+ import { PROVIDER_INFO, PROVIDERS, providerInfo, usesEndpoint } from "./catalog.js";
5
+ import { ChatGPTProvider } from "./chatgpt.js";
6
+ import { credentials, isConfigured, needsLogin, providerOf } from "./credentials.js";
7
+ import { GeminiProvider } from "./gemini.js";
8
+ import { OpenAIProvider } from "./openai.js";
9
+ export function createProvider(name, creds = credentials(name)) {
10
+ const info = providerInfo(name);
11
+ if (creds.source === "chatgpt")
12
+ return new ChatGPTProvider();
13
+ switch (info.adapter) {
14
+ case "anthropic":
15
+ return new AnthropicProvider(creds.apiKey);
16
+ case "gemini":
17
+ return new GeminiProvider(creds.apiKey, info.filter);
18
+ case "openai": {
19
+ // Without an endpoint the SDK uses api.openai.com and reads $OPENAI_API_KEY itself.
20
+ if (!usesEndpoint(info))
21
+ return new OpenAIProvider({ apiKey: creds.apiKey, filter: info.filter });
22
+ if (!creds.baseURL)
23
+ throw new Error(`No endpoint configured for ${info.label}. Run /login.`);
24
+ // Local servers don't need a key, but the SDK requires a non-empty one.
25
+ return new OpenAIProvider({ baseURL: creds.baseURL, apiKey: creds.apiKey ?? "none", filter: info.filter });
26
+ }
27
+ }
28
+ }
29
+ const providers = new Map();
30
+ const modelLists = new Map();
31
+ /** Drop cached clients and model lists after credentials change. */
32
+ export function resetProvider(name) {
33
+ providers.delete(name);
34
+ modelLists.delete(name);
35
+ }
36
+ function getProvider(name) {
37
+ let provider = providers.get(name);
38
+ if (!provider)
39
+ providers.set(name, (provider = createProvider(name)));
40
+ return provider;
41
+ }
42
+ /** Live model list for a provider, cached for the session. */
43
+ export function listModels(name) {
44
+ let list = modelLists.get(name);
45
+ if (!list) {
46
+ list = getProvider(name).listModels();
47
+ list.catch(() => modelLists.delete(name)); // retry next time
48
+ modelLists.set(name, list);
49
+ }
50
+ return list;
51
+ }
52
+ /** Resolves "provider:model" (or a bare provider name, meaning its default model). */
53
+ export const resolveModel = (spec) => {
54
+ const { provider, model = providerInfo(provider).defaultModel } = parseModelSpec(spec);
55
+ if (!model)
56
+ throw new Error(`Specify a model, e.g. ${provider}:<model>, or pick one with /model`);
57
+ return { provider: getProvider(provider), model };
58
+ };
59
+ /** $MEGACODE_MODEL, else the last model used, else the first configured provider's default. */
60
+ export function defaultModel() {
61
+ if (process.env.MEGACODE_MODEL)
62
+ return process.env.MEGACODE_MODEL;
63
+ const last = loadSettings().model;
64
+ if (last && PROVIDERS.includes(providerOf(last)) && !needsLogin(last))
65
+ return last;
66
+ const p = PROVIDER_INFO.find((p) => p.defaultModel && isConfigured(p.name)) ?? PROVIDER_INFO[0];
67
+ return `${p.name}:${p.defaultModel}`;
68
+ }
@@ -0,0 +1,12 @@
1
+ /** Streamed tool arguments → object. Invalid JSON is kept so the tool layer can report it to the model. */
2
+ export function parseToolArguments(json) {
3
+ try {
4
+ return json ? JSON.parse(json) : {};
5
+ }
6
+ catch {
7
+ return { _invalid_json: json };
8
+ }
9
+ }
10
+ /** Some providers and compatible servers omit tool call ids; every call needs one to pair with its result. */
11
+ export const fallbackCallId = (index) => `call_${Date.now()}_${index}`;
12
+ export const imageDataUrl = (image) => `data:${image.mediaType};base64,${image.data}`;
@@ -0,0 +1,95 @@
1
+ import { chatGPTUsage } from "../auth/chatgpt.js";
2
+ import { providerInfo } from "./catalog.js";
3
+ import { credentials } from "./credentials.js";
4
+ const number = (v) => typeof v === "number" && Number.isFinite(v);
5
+ const object = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : {};
6
+ /** Only display values actually returned by the provider; missing values aren't zero. */
7
+ export function formatChatGPTUsage(value) {
8
+ const data = object(value);
9
+ const lines = [];
10
+ if (typeof data.plan_type === "string")
11
+ lines.push(`Plan: ${data.plan_type}`);
12
+ for (const [key, label] of [["rate_limit", "Usage"], ["code_review_rate_limit", "Code review"]]) {
13
+ const limit = object(data[key]);
14
+ if (typeof limit.allowed === "boolean")
15
+ lines.push(`${label}: ${limit.allowed ? "available" : "limit reached"}`);
16
+ for (const [key, fallback] of [["primary_window", "Primary window"], ["secondary_window", "Secondary window"]]) {
17
+ const window = object(limit[key]);
18
+ if (number(window.used_percent))
19
+ lines.push(formatWindow(label, fallback, window));
20
+ }
21
+ }
22
+ const credits = object(data.credits);
23
+ if (credits.unlimited === true)
24
+ lines.push("Credits: unlimited");
25
+ else if (typeof credits.balance === "string" || number(credits.balance))
26
+ lines.push(`Credits remaining: ${credits.balance}`);
27
+ return lines;
28
+ }
29
+ function formatWindow(label, fallback, window) {
30
+ const duration = window.limit_window_seconds;
31
+ const name = number(duration) ? `${duration / 3600}-hour window` : fallback;
32
+ const used = window.used_percent;
33
+ const filled = Math.round(Math.min(100, Math.max(0, used)) / 100 * 24);
34
+ const bar = "█".repeat(filled) + "░".repeat(24 - filled);
35
+ let line = `${label} (${name})\n ${bar} ${used}% used · ${Math.max(0, 100 - used)}% remaining`;
36
+ if (number(window.reset_at)) {
37
+ const date = new Date(window.reset_at * 1000);
38
+ if (!Number.isNaN(date.getTime()))
39
+ line += `\n Resets ${date.toLocaleString()}`;
40
+ }
41
+ else if (number(window.reset_after_seconds))
42
+ line += `\n Resets in ${Math.ceil(window.reset_after_seconds / 60)} min`;
43
+ return line;
44
+ }
45
+ async function get(url, apiKey) {
46
+ const res = await fetch(url, {
47
+ headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
48
+ signal: AbortSignal.timeout(15_000),
49
+ });
50
+ if (!res.ok)
51
+ throw new Error(`Usage request failed (HTTP ${res.status}).${[401, 403].includes(res.status) ? " Run /login to update credentials." : ""}`);
52
+ return res.json();
53
+ }
54
+ const endpoint = (creds, path) => `${creds.baseURL.replace(/\/$/, "")}${path}`;
55
+ const OPENROUTER_FIELDS = [
56
+ ["usage", "Used (total)"], ["usage_daily", "Used (today)"], ["usage_weekly", "Used (this week)"],
57
+ ["usage_monthly", "Used (this month)"], ["limit", "Key spending limit"], ["limit_remaining", "Remaining"],
58
+ ];
59
+ /** Per-provider usage lookups. Returning undefined means this credential type has no usage API. */
60
+ const FETCHERS = {
61
+ async openai(creds) {
62
+ return creds.source === "chatgpt" ? formatChatGPTUsage(await chatGPTUsage()) : undefined;
63
+ },
64
+ async openrouter(creds) {
65
+ const data = object(object(await get(endpoint(creds, "/key"), creds.apiKey)).data);
66
+ const lines = OPENROUTER_FIELDS.flatMap(([key, label]) => (number(data[key]) ? [`${label}: $${data[key]}`] : []));
67
+ if (data.limit === null)
68
+ lines.push("Key spending limit: unlimited");
69
+ if (typeof data.limit_reset === "string")
70
+ lines.push(`Limit reset: ${data.limit_reset}`);
71
+ return lines;
72
+ },
73
+ async deepseek(creds) {
74
+ const data = object(await get(endpoint(creds, "/user/balance"), creds.apiKey));
75
+ const lines = [];
76
+ for (const entry of Array.isArray(data.balance_infos) ? data.balance_infos : []) {
77
+ const balance = object(entry);
78
+ if (typeof balance.currency === "string" && (typeof balance.total_balance === "string" || number(balance.total_balance)))
79
+ lines.push(`Balance remaining: ${balance.total_balance} ${balance.currency}`);
80
+ }
81
+ lines.push("Usage totals and limits are not exposed by this provider's balance API.");
82
+ return lines;
83
+ },
84
+ };
85
+ /** Fresh account data, using the same credential precedence as model requests. */
86
+ export async function providerUsage(name, creds = credentials(name)) {
87
+ const info = providerInfo(name);
88
+ const heading = `${info.label} — account usage and limits`;
89
+ if (!creds.source)
90
+ return `${heading}\nNot configured. Run /login.`;
91
+ const lines = await FETCHERS[name]?.(creds);
92
+ if (!lines)
93
+ return `${heading}\n${info.local ? "Local provider: no account quota API." : "Account usage and limits are not available through this provider's configured authentication/API. Check the provider dashboard."}`;
94
+ return `${heading}\n${lines.length ? lines.join("\n") : "The provider returned no usage or limit data."}`;
95
+ }
@@ -0,0 +1,15 @@
1
+ import { DEFAULT_SETTINGS } from "../core/settings.js";
2
+ import { configDir, readJson, writeJson } from "./storage.js";
3
+ const FILE = "settings.json";
4
+ // Only values the user changed are written, so later changes to defaults still apply.
5
+ let cache;
6
+ function savedSettings() {
7
+ if (cache?.dir !== configDir())
8
+ cache = { dir: configDir(), saved: readJson(FILE, {}) };
9
+ return cache.saved;
10
+ }
11
+ export const loadSettings = () => ({ ...DEFAULT_SETTINGS, ...savedSettings() });
12
+ export function updateSettings(patch) {
13
+ cache = { dir: configDir(), saved: { ...savedSettings(), ...patch } };
14
+ writeJson(FILE, cache.saved);
15
+ }