openpoly 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 polycode contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,156 @@
1
+ # polycode
2
+
3
+ An open-source, model-agnostic terminal coding agent — like Claude Code, but with **free, open-source models**. Use DeepSeek, GLM, Qwen, Llama and more via [OpenRouter](https://openrouter.ai)'s free tier, run them locally with [Ollama](https://ollama.com), or bring your own GPT/Claude/Gemini keys — all from one terminal UI.
4
+
5
+ ```
6
+ › add input validation to the signup form
7
+
8
+ ● read src/auth/signup.ts
9
+ ● edit src/auth/signup.ts
10
+ ● run npm test
11
+ I added validation and the tests pass.
12
+ ```
13
+
14
+ ## Features
15
+
16
+ - **Free, open-source models.** Use DeepSeek, GLM, Qwen, Llama, gpt-oss and more for free via [OpenRouter](https://openrouter.ai)'s free tier — one free API key, **no credit card**.
17
+ - **Fully local & keyless option.** Run open models on your own machine with [Ollama](https://ollama.com) — no key, no limits.
18
+ - **Any model.** Built on the [Vercel AI SDK](https://sdk.vercel.ai) — also works directly with OpenAI, Anthropic, Google, and any OpenAI-compatible endpoint using your own keys.
19
+ - **In-terminal model picker.** Run `/model` for a Claude-Code-style menu pre-loaded with the live list of all tool-capable models (250+) — free ones marked `·free` and listed first; type to filter, arrow keys to select.
20
+ - **Real coding tools.** Read, write, and edit files; run shell commands; search the codebase.
21
+ - **Permission prompts.** Approve each file write and command (`y` / `a` always / `n` deny), or run with `--yolo` to auto-approve.
22
+ - **Rich terminal UI** built with [Ink](https://github.com/vadimdemedes/ink): streaming output, colored diffs, live tool status.
23
+ - **Switch models on the fly** with `/model <name>` — your conversation is kept.
24
+ - **Adjustable thinking speed.** Reasoning models can be slow; `/think off|low|medium|high` dials reasoning effort (default `low` for speed). Press **Esc** to cancel a slow turn.
25
+
26
+ ## Install
27
+
28
+ ### One command (anyone, anywhere)
29
+
30
+ ```bash
31
+ npm install -g openpoly
32
+ ```
33
+
34
+ That's it — no cloning, no folders. Now open **any** terminal, `cd` into a project, and run `polycode` (or `openpoly`). It works on whatever directory you're in. Update with `npm i -g openpoly@latest`; remove with `npm rm -g openpoly`.
35
+
36
+ ### Install from source (for development)
37
+
38
+ ```bash
39
+ # from anywhere — point npm at the project folder; it builds and installs globally
40
+ npm install -g /absolute/path/to/polycode
41
+ ```
42
+
43
+ ```powershell
44
+ # Windows PowerShell
45
+ npm install -g "C:\path\to\polycode"
46
+ ```
47
+
48
+ Now open **any** terminal, `cd` into a project you want help with, and run:
49
+
50
+ ```bash
51
+ polycode
52
+ ```
53
+
54
+ To update later, re-run the install command. To remove it: `npm rm -g polycode`.
55
+
56
+ ### Run from source (no global install)
57
+
58
+ ```bash
59
+ cd polycode
60
+ npm install
61
+ npm run dev
62
+ ```
63
+
64
+ ## Configure
65
+
66
+ ### Recommended: free models via OpenRouter
67
+
68
+ 1. Create a **free** OpenRouter API key (no credit card) at [openrouter.ai/keys](https://openrouter.ai/keys).
69
+ 2. Run `polycode`. On first run it **prompts you to paste the key right in the terminal** (masked) and saves it for next time — no env-var setup needed. Type `/key` anytime to enter or change it.
70
+ 3. The default model is `gpt-oss` (OpenAI gpt-oss-120b, free). Run **`/model`** (or `/models`) to open a picker populated with **all 250+ tool-capable models** — free ones marked `·free` and shown first; type to filter, arrow keys to move, enter to select. The free set rotates; DeepSeek/GLM/Kimi show up whenever they're offered free.
71
+
72
+ > Prefer env vars? Set `OPENROUTER_API_KEY` (PowerShell: `$env:OPENROUTER_API_KEY="sk-or-..."`) and the prompt is skipped. The terminal-entered key is stored at `~/.config/polycode/auth.json`.
73
+
74
+ > Free models are rate-limited (≈20 requests/min, ≈200/day) and shared, so they can be slower or briefly unavailable.
75
+
76
+ ### Fully local & free with Ollama (no key)
77
+
78
+ Install [Ollama](https://ollama.com), pull a model, then pick a `*-local` model:
79
+
80
+ ```bash
81
+ ollama pull qwen3 # or: ollama pull deepseek-r1 / glm4
82
+ polycode --model=qwen-local
83
+ ```
84
+
85
+ No API key, no rate limits — it runs on your machine.
86
+
87
+ ### Config file
88
+
89
+ On first run, polycode seeds `~/.config/polycode/config.json` (older configs are auto-migrated; the previous file is kept as `config.json.bak`):
90
+
91
+ ```json
92
+ {
93
+ "version": 2,
94
+ "defaultModel": "qwen-coder",
95
+ "models": [
96
+ { "name": "qwen-coder", "provider": "openrouter", "model": "qwen/qwen3-coder:free" },
97
+ { "name": "llama", "provider": "openrouter", "model": "meta-llama/llama-3.3-70b-instruct:free" },
98
+ { "name": "gpt-oss", "provider": "openrouter", "model": "openai/gpt-oss-120b:free" },
99
+ { "name": "deepseek-local", "provider": "openai-compatible", "model": "deepseek-r1", "baseURL": "http://localhost:11434/v1" },
100
+ { "name": "claude-direct", "provider": "anthropic", "model": "claude-3-5-sonnet-latest" }
101
+ ]
102
+ }
103
+ ```
104
+
105
+ With `OPENROUTER_API_KEY` set you can also `/model <any-openrouter-id>` directly without editing config (e.g. `/model deepseek/deepseek-chat-v3.1:free`).
106
+
107
+ ### API keys (env vars, never written to disk)
108
+
109
+ | Provider | Env var |
110
+ | ------------------- | -------------------------------- |
111
+ | `openrouter` | `OPENROUTER_API_KEY` (free at openrouter.ai/keys) |
112
+ | `openai-compatible` | none for local Ollama/LM Studio |
113
+ | `openai` | `OPENAI_API_KEY` |
114
+ | `anthropic` | `ANTHROPIC_API_KEY` |
115
+ | `google` | `GOOGLE_GENERATIVE_AI_API_KEY` |
116
+
117
+ You can also drop a project-level `.polycode.json` in any repo to override or add models for that project.
118
+
119
+ ## Usage
120
+
121
+ ```bash
122
+ polycode # start with the default model
123
+ polycode --model=llama # start with a specific model
124
+ polycode --yolo # auto-approve writes and commands
125
+ ```
126
+
127
+ In-session commands:
128
+
129
+ | Command | Description |
130
+ | --------------- | ------------------------------------------------ |
131
+ | `/think <lvl>` | reasoning effort: `off`/`low`/`medium`/`high` (lower = faster) |
132
+ | `/key` | enter/update the API key for the current model |
133
+ | `/model` | open the picker — free models, type to filter |
134
+ | `/models` | same as `/model` |
135
+ | `/model <name>` | switch directly (any OpenRouter model id works) |
136
+ | `/clear` | clear the conversation |
137
+ | `/help` | show help |
138
+ | `/exit` | quit |
139
+
140
+ ## Architecture
141
+
142
+ ```
143
+ src/
144
+ cli.tsx entry point — args, wiring, render
145
+ config/ load/merge config (versioned, auto-migrating), resolve keys
146
+ providers/ map a config entry → a Vercel AI SDK model; list free models
147
+ tools/ read / write / edit / run_command / search_code + permission broker
148
+ agent/ the agentic loop (streamText + tools, multi-step)
149
+ tui/ Ink components — transcript, permission dialog, status bar
150
+ ```
151
+
152
+ The agent loop leans on the Vercel AI SDK's multi-step tool calling so behavior is uniform across providers. Tools request approval through a `PermissionBroker` that bridges tool execution and the UI.
153
+
154
+ ## License
155
+
156
+ MIT
@@ -0,0 +1,233 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { streamText, generateText, } from "ai";
3
+ import { buildSystemPrompt } from "./systemPrompt.js";
4
+ /**
5
+ * Owns the conversation state and drives one agentic turn per `send()`.
6
+ * Relies on the Vercel AI SDK's multi-step loop (maxSteps) to handle the
7
+ * model<->tool round-trips uniformly across providers. Emits streaming events
8
+ * the TUI subscribes to.
9
+ */
10
+ export class AgentSession extends EventEmitter {
11
+ model;
12
+ tools;
13
+ messages = [];
14
+ system;
15
+ busy = false;
16
+ controller = null;
17
+ // Default to "low" so reasoning models stay snappy; raise with /think.
18
+ reasoning = "low";
19
+ constructor(model, tools, cwd) {
20
+ super();
21
+ this.model = model;
22
+ this.tools = tools;
23
+ this.system = buildSystemPrompt(cwd);
24
+ }
25
+ /** Swap the underlying model mid-session (used by /model). History is kept. */
26
+ setModel(model) {
27
+ this.model = model;
28
+ }
29
+ hasModel() {
30
+ return this.model !== null;
31
+ }
32
+ /** Drop conversation history (used by /clear). */
33
+ clearHistory() {
34
+ this.messages = [];
35
+ }
36
+ isBusy() {
37
+ return this.busy;
38
+ }
39
+ setReasoning(level) {
40
+ this.reasoning = level;
41
+ }
42
+ getReasoning() {
43
+ return this.reasoning;
44
+ }
45
+ /**
46
+ * OpenRouter reasoning control, passed through the openai-compatible provider
47
+ * (it spreads providerOptions["openrouter"] into the request body). Lower
48
+ * effort = less "thinking" = faster. Ignored by non-OpenRouter providers.
49
+ */
50
+ reasoningOptions() {
51
+ const reasoning = this.reasoning === "off"
52
+ ? { enabled: false }
53
+ : { effort: this.reasoning };
54
+ return { openrouter: { reasoning } };
55
+ }
56
+ /** Cancel the in-flight turn, if any (Esc). Triggers the stream to abort. */
57
+ abort() {
58
+ this.controller?.abort();
59
+ }
60
+ /** Emit an error, but report a user-initiated abort gently instead. */
61
+ emitError(err) {
62
+ if (this.controller?.signal.aborted)
63
+ this.emit("error", "⊘ Cancelled.");
64
+ else
65
+ this.emit("error", describeError(err));
66
+ }
67
+ async send(userText) {
68
+ if (this.busy)
69
+ return;
70
+ if (!this.model) {
71
+ this.emit("error", "No model configured. Use /model <name> to pick one, or set the provider's API key and restart.");
72
+ this.emit("done");
73
+ return;
74
+ }
75
+ this.busy = true;
76
+ this.controller = new AbortController();
77
+ this.messages.push({ role: "user", content: userText });
78
+ try {
79
+ const result = streamText({
80
+ model: this.model,
81
+ system: this.system,
82
+ messages: this.messages,
83
+ tools: this.tools,
84
+ maxSteps: 30,
85
+ // Free model endpoints are often briefly rate-limited (HTTP 429 with a
86
+ // short retry-after); a few extra backoff retries ride most of them out.
87
+ maxRetries: 4,
88
+ abortSignal: this.controller.signal,
89
+ providerOptions: this.reasoningOptions(),
90
+ // Weaker free models sometimes emit malformed tool calls (e.g. a
91
+ // write_file with no "path"). Re-ask the model to redo the call
92
+ // correctly instead of crashing the turn.
93
+ experimental_repairToolCall: async ({ toolCall, tools, error, messages, system }) => {
94
+ if (!this.model)
95
+ return null;
96
+ try {
97
+ const repair = await generateText({
98
+ model: this.model,
99
+ system,
100
+ messages: [
101
+ ...messages,
102
+ {
103
+ role: "user",
104
+ content: `Your previous ${toolCall.toolName} tool call was rejected: ${error.message}. Call ${toolCall.toolName} again with ALL required arguments. (For write_file you must include both "path" — a filename — and "content".)`,
105
+ },
106
+ ],
107
+ tools,
108
+ toolChoice: "required",
109
+ maxRetries: 1,
110
+ abortSignal: this.controller?.signal,
111
+ providerOptions: this.reasoningOptions(),
112
+ });
113
+ const fixed = repair.toolCalls.find((tc) => tc.toolName === toolCall.toolName) ??
114
+ repair.toolCalls[0];
115
+ if (!fixed)
116
+ return null;
117
+ return {
118
+ toolCallType: "function",
119
+ toolCallId: toolCall.toolCallId,
120
+ toolName: fixed.toolName,
121
+ args: JSON.stringify(fixed.args),
122
+ };
123
+ }
124
+ catch {
125
+ return null;
126
+ }
127
+ },
128
+ });
129
+ let streamErrored = false;
130
+ for await (const part of result.fullStream) {
131
+ switch (part.type) {
132
+ case "text-delta":
133
+ this.emit("text-delta", part.textDelta);
134
+ break;
135
+ case "tool-call":
136
+ this.emit("tool-call", {
137
+ toolName: part.toolName,
138
+ args: part.args,
139
+ });
140
+ break;
141
+ case "tool-result":
142
+ this.emit("tool-result", {
143
+ toolName: part.toolName,
144
+ result: part.result,
145
+ });
146
+ break;
147
+ case "error":
148
+ // The stream surfaced an error (e.g. a rate limit). The loop ends
149
+ // here, but result.response would never resolve — awaiting it would
150
+ // hang forever (busy stuck on "thinking…"). So bail out instead.
151
+ streamErrored = true;
152
+ this.emitError(part.error);
153
+ break;
154
+ }
155
+ }
156
+ if (!streamErrored) {
157
+ const response = await result.response;
158
+ this.messages.push(...response.messages);
159
+ }
160
+ }
161
+ catch (err) {
162
+ this.emitError(err);
163
+ }
164
+ finally {
165
+ this.controller = null;
166
+ // Reset busy BEFORE emitting done so the UI never sees done while busy.
167
+ this.busy = false;
168
+ this.emit("done");
169
+ }
170
+ }
171
+ }
172
+ /**
173
+ * Build a detailed, human-readable error string. The Vercel AI SDK wraps the
174
+ * real failure in RetryError -> APICallError, where the actual provider reason
175
+ * (HTTP status + response body) lives. We unwrap that chain so errors like
176
+ * OpenRouter's "Provider returned error" show what really happened.
177
+ */
178
+ function describeError(err) {
179
+ if (typeof err === "string")
180
+ return err;
181
+ // Tool-argument validation failures echo the entire (often huge) argument
182
+ // blob. Collapse them to a concise, actionable line.
183
+ const msg = err instanceof Error ? err.message : "";
184
+ if (/Invalid arguments for tool|Type validation failed|No such tool/i.test(msg)) {
185
+ const tool = /tool[: ]+["']?(\w+)/i.exec(msg)?.[1];
186
+ return `The model sent an invalid tool call${tool ? ` for ${tool}` : ""} and couldn't be repaired. Try again, or switch models with /model — some free models struggle with large file writes.`;
187
+ }
188
+ const parts = [];
189
+ const seen = new Set();
190
+ let e = err;
191
+ while (e && typeof e === "object" && !seen.has(e)) {
192
+ seen.add(e);
193
+ if (typeof e.statusCode === "number")
194
+ parts.push(`HTTP ${e.statusCode}`);
195
+ if (typeof e.message === "string" && e.message)
196
+ parts.push(e.message);
197
+ // The provider's raw error body usually holds the real reason.
198
+ const body = e.responseBody ?? e.data;
199
+ if (body) {
200
+ const text = typeof body === "string" ? body : safeJson(body);
201
+ if (text)
202
+ parts.push(text);
203
+ }
204
+ // Descend into wrapped errors (RetryError.lastError, Error.cause).
205
+ e = e.lastError ?? e.cause ?? undefined;
206
+ }
207
+ // De-duplicate (the wrapper message often repeats the inner one) and trim.
208
+ const seenText = new Set();
209
+ const unique = parts.filter((p) => {
210
+ const key = p.trim();
211
+ if (!key || seenText.has(key))
212
+ return false;
213
+ seenText.add(key);
214
+ return true;
215
+ });
216
+ let out = unique.join("\n");
217
+ if (out.length > 1500)
218
+ out = out.slice(0, 1500) + "…";
219
+ // Rate limits are the common free-tier failure — guide the user to options.
220
+ if (/\b429\b|rate.?limit/i.test(out)) {
221
+ out +=
222
+ "\n\nThis free model is busy. Retry in a moment, run /model to pick another free model, or add your own provider key with /key.";
223
+ }
224
+ return out || (err instanceof Error ? err.message : String(err));
225
+ }
226
+ function safeJson(value) {
227
+ try {
228
+ return JSON.stringify(value);
229
+ }
230
+ catch {
231
+ return String(value);
232
+ }
233
+ }
@@ -0,0 +1,18 @@
1
+ export function buildSystemPrompt(cwd) {
2
+ return `You are polycode, an open-source AI coding agent running in a terminal.
3
+ You help the user with software engineering tasks in their project.
4
+
5
+ Working directory: ${cwd}
6
+ Operating system: ${process.platform}
7
+
8
+ Guidelines:
9
+ - Use the provided tools to read, search, write, and edit files, and to run shell commands. Do not guess file contents — read them.
10
+ - Before editing a file, read it so your edits match the existing code exactly.
11
+ - Prefer edit_file for small changes and write_file for new files.
12
+ - write_file ALWAYS needs both "path" (a filename) and "content". For large files, write them in one complete call; don't omit the path.
13
+ - When running commands, choose the smallest command that accomplishes the goal.
14
+ - Explain what you are about to do briefly, then act. Keep prose concise.
15
+ - After making changes, verify them (e.g. run tests or the build) when reasonable.
16
+ - The user must approve file writes and shell commands; if a tool returns "Denied by user", stop and ask how to proceed.
17
+ - Never invent file paths. Use search_code to locate things you are unsure about.`;
18
+ }
@@ -0,0 +1,35 @@
1
+ import { homedir } from "node:os";
2
+ import { dirname, join } from "node:path";
3
+ import { readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
4
+ const KEYS_PATH = join(homedir(), ".config", "polycode", "auth.json");
5
+ function read() {
6
+ try {
7
+ return JSON.parse(readFileSync(KEYS_PATH, "utf8"));
8
+ }
9
+ catch {
10
+ return {};
11
+ }
12
+ }
13
+ /** Persist an API key locally and export it to the current process env. */
14
+ export function saveKey(envVar, value) {
15
+ const store = read();
16
+ store[envVar] = value;
17
+ mkdirSync(dirname(KEYS_PATH), { recursive: true });
18
+ writeFileSync(KEYS_PATH, JSON.stringify(store, null, 2), "utf8");
19
+ process.env[envVar] = value;
20
+ }
21
+ export function clearKeys() {
22
+ try {
23
+ rmSync(KEYS_PATH);
24
+ }
25
+ catch {
26
+ /* nothing stored */
27
+ }
28
+ }
29
+ /** Load stored keys into the environment without overriding real env vars. */
30
+ export function applyStoredKeys() {
31
+ for (const [envVar, value] of Object.entries(read())) {
32
+ if (value && !process.env[envVar])
33
+ process.env[envVar] = value;
34
+ }
35
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { render } from "ink";
4
+ import { App } from "./tui/App.js";
5
+ import { loadConfig, findModel, globalConfigPath, openRouterModelConfig, } from "./config/config.js";
6
+ import { buildModel, listModels, keyEnvFor, needsKey, ProviderError, } from "./providers/index.js";
7
+ import { createTools } from "./tools/index.js";
8
+ import { PermissionBroker } from "./tools/permissions.js";
9
+ import { AgentSession } from "./agent/agent.js";
10
+ import { applyStoredKeys, saveKey } from "./auth/keys.js";
11
+ const VERSION = "0.1.0";
12
+ function parseArgs(argv) {
13
+ const args = { yolo: false, model: "", help: false, version: false };
14
+ for (const a of argv) {
15
+ if (a === "--yolo" || a === "-y")
16
+ args.yolo = true;
17
+ else if (a === "--help" || a === "-h")
18
+ args.help = true;
19
+ else if (a === "--version" || a === "-v")
20
+ args.version = true;
21
+ else if (a.startsWith("--model="))
22
+ args.model = a.slice("--model=".length);
23
+ }
24
+ return args;
25
+ }
26
+ function msg(err) {
27
+ return err instanceof Error ? err.message : String(err);
28
+ }
29
+ function printHelp() {
30
+ process.stdout.write(`polycode ${VERSION} — model-agnostic terminal coding agent
31
+
32
+ Usage:
33
+ polycode [options]
34
+
35
+ Options:
36
+ --model=<name> Start with a specific configured model
37
+ --yolo, -y Auto-approve all file writes and commands (use with care)
38
+ --version, -v Print version and exit
39
+ --help, -h Show this help
40
+
41
+ Config: ${globalConfigPath()}
42
+
43
+ Free open-source models (DeepSeek, GLM, Qwen, Llama, …):
44
+ • OpenRouter — get a FREE key (no credit card) at https://openrouter.ai/keys.
45
+ polycode will ask you to paste it on first run (or type /key anytime);
46
+ it's saved for next time. Run /model to browse all free models.
47
+ • Ollama — fully local & keyless: install https://ollama.com, then e.g.
48
+ \`ollama pull qwen3\` and pick a *-local model.
49
+
50
+ You can also set keys as env vars: OPENROUTER_API_KEY, OPENAI_API_KEY, etc.
51
+
52
+ Bring your own keys too: OPENAI_API_KEY, ANTHROPIC_API_KEY, GOOGLE_GENERATIVE_AI_API_KEY.
53
+ `);
54
+ }
55
+ function main() {
56
+ const args = parseArgs(process.argv.slice(2));
57
+ if (args.help)
58
+ return printHelp();
59
+ if (args.version)
60
+ return void process.stdout.write(`${VERSION}\n`);
61
+ const cwd = process.cwd();
62
+ applyStoredKeys(); // load any keys entered previously in the terminal
63
+ const { config, seeded } = loadConfig(cwd);
64
+ const broker = new PermissionBroker();
65
+ const tools = createTools(cwd, broker);
66
+ if (args.yolo) {
67
+ broker.allowAlways("write_file");
68
+ broker.allowAlways("edit_file");
69
+ broker.allowAlways("run_command");
70
+ }
71
+ const startName = args.model || config.defaultModel;
72
+ const startCfg = findModel(config, startName);
73
+ // Dynamic startup notices only — the title/help line lives in the banner.
74
+ const introLines = [];
75
+ if (seeded) {
76
+ introLines.push(`Seeded a default config at ${globalConfigPath()}.`);
77
+ }
78
+ // Does the starting model need a key we don't have yet? If so, we'll prompt
79
+ // for it right in the terminal instead of erroring out.
80
+ const startNeedsKey = !!startCfg && needsKey(startCfg) && !process.env[keyEnvFor(startCfg)];
81
+ let activeName = startName;
82
+ let initialModel = null;
83
+ let currentName = startName;
84
+ try {
85
+ if (!startCfg)
86
+ throw new ProviderError(`No model named "${startName}" in config.`);
87
+ initialModel = buildModel(startCfg);
88
+ }
89
+ catch (err) {
90
+ activeName = startCfg ? `${startName} (needs key)` : "none";
91
+ if (!startNeedsKey) {
92
+ introLines.push(msg(err));
93
+ introLines.push("Use /model to choose another, or /models to browse free models.");
94
+ }
95
+ }
96
+ const session = new AgentSession(initialModel, tools, cwd);
97
+ const switchModel = (name) => {
98
+ // Use a configured model if it exists; otherwise treat the name as a raw
99
+ // OpenRouter model id so any OpenRouter model works without editing config.
100
+ const cfg = findModel(config, name) ??
101
+ (process.env.OPENROUTER_API_KEY ? openRouterModelConfig(name) : undefined);
102
+ if (!cfg)
103
+ return {
104
+ ok: false,
105
+ message: `No model named "${name}". Set OPENROUTER_API_KEY to use any OpenRouter model, or see /models.`,
106
+ };
107
+ try {
108
+ session.setModel(buildModel(cfg));
109
+ currentName = name;
110
+ return { ok: true, message: `Switched to ${name}.`, name };
111
+ }
112
+ catch (err) {
113
+ return { ok: false, message: msg(err) };
114
+ }
115
+ };
116
+ // Save a key typed in the terminal to the env var of the current model's
117
+ // provider, then rebuild that model so it's ready immediately.
118
+ const onSaveKey = (key) => {
119
+ const cfg = findModel(config, currentName) ?? openRouterModelConfig(currentName);
120
+ saveKey(keyEnvFor(cfg), key);
121
+ return switchModel(currentName);
122
+ };
123
+ render(_jsx(App, { session: session, broker: broker, config: config, initialModel: activeName, switchModel: switchModel, listModels: listModels, onSaveKey: onSaveKey, promptKeyOnStart: startNeedsKey, version: VERSION, intro: introLines.length ? introLines.join("\n") : undefined }));
124
+ }
125
+ main();