euron-coding-agent 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Euron Agent 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.
@@ -0,0 +1,70 @@
1
+ Metadata-Version: 2.4
2
+ Name: euron-coding-agent
3
+ Version: 0.1.0
4
+ Summary: Lightweight provider-agnostic agentic coding backend (CLI + server) for the Euron VS Code extension.
5
+ Author: Euron Agent contributors
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/euronone/Euron-coding-agent
8
+ Project-URL: Issues, https://github.com/euronone/Euron-coding-agent/issues
9
+ Keywords: ai,agent,coding,llm,cli,vscode
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Environment :: Console
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: fastapi>=0.110
18
+ Requires-Dist: uvicorn[standard]>=0.29
19
+ Requires-Dist: pydantic>=2.6
20
+ Requires-Dist: python-dotenv>=1.0
21
+ Requires-Dist: pyyaml>=6.0
22
+ Requires-Dist: openai>=1.30
23
+ Requires-Dist: httpx>=0.27
24
+ Requires-Dist: rich>=13.7
25
+ Provides-Extra: anthropic
26
+ Requires-Dist: anthropic>=0.34; extra == "anthropic"
27
+ Dynamic: license-file
28
+
29
+ # euron-agent (backend)
30
+
31
+ The Python agent backend behind the **Euron Coding Agent** VS Code extension —
32
+ also a standalone CLI. Provider-agnostic (Euron/Euri, OpenAI, OpenRouter,
33
+ Anthropic, Ollama, or any OpenAI-compatible / self-hosted endpoint).
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install euron-coding-agent # add: pip install "euron-coding-agent[anthropic]" for Claude
39
+ ```
40
+
41
+ ## CLI
42
+
43
+ ```bash
44
+ euron-agent init # scaffold config.yaml + .env (optional)
45
+ euron-agent providers # list providers
46
+ euron-agent run "add a /health route to app.py"
47
+ euron-agent chat # interactive REPL (keeps context)
48
+ euron-agent serve --port 0 # run the API/WebSocket server (0 = auto-port)
49
+ ```
50
+
51
+ Set a key via environment (`OPENAI_API_KEY`, `EURI_API_KEY`, …) or `config.yaml`.
52
+ Built-in provider profiles mean no config file is required — just pick a
53
+ provider and supply a key.
54
+
55
+ ## Server
56
+
57
+ - `GET /health` — liveness.
58
+ - `GET /providers` — configured providers.
59
+ - `POST /agent/run` — one-shot, non-interactive (set `"auto_approve": true`).
60
+ - `WS /ws` — streaming agent with per-action approval (used by the extension).
61
+
62
+ The WebSocket `init` message accepts `provider`, `model`, `api_key`, and
63
+ `base_url` so secrets never need to live on disk.
64
+
65
+ See the [project README](https://github.com/euronone/Euron-coding-agent) for the
66
+ full architecture and the VS Code extension.
67
+
68
+ ## License
69
+
70
+ MIT
@@ -0,0 +1,42 @@
1
+ # euron-agent (backend)
2
+
3
+ The Python agent backend behind the **Euron Coding Agent** VS Code extension —
4
+ also a standalone CLI. Provider-agnostic (Euron/Euri, OpenAI, OpenRouter,
5
+ Anthropic, Ollama, or any OpenAI-compatible / self-hosted endpoint).
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install euron-coding-agent # add: pip install "euron-coding-agent[anthropic]" for Claude
11
+ ```
12
+
13
+ ## CLI
14
+
15
+ ```bash
16
+ euron-agent init # scaffold config.yaml + .env (optional)
17
+ euron-agent providers # list providers
18
+ euron-agent run "add a /health route to app.py"
19
+ euron-agent chat # interactive REPL (keeps context)
20
+ euron-agent serve --port 0 # run the API/WebSocket server (0 = auto-port)
21
+ ```
22
+
23
+ Set a key via environment (`OPENAI_API_KEY`, `EURI_API_KEY`, …) or `config.yaml`.
24
+ Built-in provider profiles mean no config file is required — just pick a
25
+ provider and supply a key.
26
+
27
+ ## Server
28
+
29
+ - `GET /health` — liveness.
30
+ - `GET /providers` — configured providers.
31
+ - `POST /agent/run` — one-shot, non-interactive (set `"auto_approve": true`).
32
+ - `WS /ws` — streaming agent with per-action approval (used by the extension).
33
+
34
+ The WebSocket `init` message accepts `provider`, `model`, `api_key`, and
35
+ `base_url` so secrets never need to live on disk.
36
+
37
+ See the [project README](https://github.com/euronone/Euron-coding-agent) for the
38
+ full architecture and the VS Code extension.
39
+
40
+ ## License
41
+
42
+ MIT
@@ -0,0 +1,9 @@
1
+ """Euron Agent — a lightweight, provider-agnostic agentic coding backend.
2
+
3
+ Public surface:
4
+ from euron_agent.config import load_config
5
+ from euron_agent.llm import build_client
6
+ from euron_agent.loop import AgentSession
7
+ """
8
+
9
+ __version__ = "0.1.0"
@@ -0,0 +1,7 @@
1
+ """Enable `python -m euron_agent ...` (used by the VS Code auto-start)."""
2
+ import sys
3
+
4
+ from .cli import main
5
+
6
+ if __name__ == "__main__":
7
+ sys.exit(main())
@@ -0,0 +1,286 @@
1
+ """Command-line interface.
2
+
3
+ euron-agent run "add a /health route to app.py" # one-shot in cwd
4
+ euron-agent chat # interactive REPL
5
+ euron-agent serve # start the API server
6
+ euron-agent providers # list configured providers
7
+ euron-agent init # scaffold config.yaml/.env
8
+
9
+ The CLI uses the very same AgentSession/loop as the VS Code backend, so the
10
+ terminal experience and the editor experience are identical.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import asyncio
16
+ import os
17
+ import shutil
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ from rich.console import Console
22
+ from rich.panel import Panel
23
+ from rich.prompt import Prompt
24
+
25
+ from .config import load_config
26
+ from .events import AgentIO, ApprovalDecision
27
+ from .loop import AgentSession
28
+
29
+
30
+ def _force_utf8() -> None:
31
+ """Avoid UnicodeEncodeError for box-drawing/emoji on Windows code pages."""
32
+ for stream in (sys.stdout, sys.stderr):
33
+ try:
34
+ stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
35
+ except Exception:
36
+ pass
37
+
38
+
39
+ _force_utf8()
40
+ # legacy_windows=False -> use ANSI (Windows 10+ supports it) instead of the
41
+ # win32 console API, which encodes with the active code page and chokes on '●'.
42
+ console = Console(legacy_windows=False)
43
+
44
+
45
+ # --------------------------------------------------------------------------- #
46
+ # Terminal IO
47
+ # --------------------------------------------------------------------------- #
48
+ class TerminalIO(AgentIO):
49
+ def __init__(self, auto_approve: bool):
50
+ self.auto_approve = auto_approve
51
+ self._dirty = False # unfinished streamed line on screen
52
+
53
+ def _newline_if_dirty(self) -> None:
54
+ if self._dirty:
55
+ sys.stdout.write("\n")
56
+ sys.stdout.flush()
57
+ self._dirty = False
58
+
59
+ # streamed tokens (may arrive from a worker thread)
60
+ def on_token(self, text: str) -> None:
61
+ sys.stdout.write(text)
62
+ sys.stdout.flush()
63
+ self._dirty = True
64
+
65
+ async def emit(self, event: dict) -> None:
66
+ t = event["type"]
67
+ if t == "status":
68
+ return # keep the terminal quiet; spinners would fight streaming
69
+ if t == "assistant_message":
70
+ # text already streamed via tokens; just close the line
71
+ self._newline_if_dirty()
72
+ return
73
+ self._newline_if_dirty()
74
+ if t == "tool_start":
75
+ args = event["args"]
76
+ detail = args.get("path") or args.get("command") or args.get("query") or ""
77
+ console.print(f"[cyan]⚙ {event['name']}[/cyan] [dim]{detail}[/dim]")
78
+ elif t == "diff":
79
+ self._print_diff(event["patch"])
80
+ elif t == "tool_result":
81
+ mark = "[green]✓[/green]" if event["ok"] else "[red]✗[/red]"
82
+ out = (event["output"] or "").strip()
83
+ if out:
84
+ snippet = out if len(out) < 1200 else out[:1200] + " …"
85
+ console.print(f"{mark} [dim]{snippet}[/dim]")
86
+ elif t == "error":
87
+ console.print(f"[red]error:[/red] {event['message']}")
88
+ elif t == "done":
89
+ console.print("[dim]— done —[/dim]")
90
+
91
+ def _print_diff(self, patch: str) -> None:
92
+ for line in patch.splitlines():
93
+ if line.startswith("+") and not line.startswith("+++"):
94
+ console.print(f"[green]{line}[/green]")
95
+ elif line.startswith("-") and not line.startswith("---"):
96
+ console.print(f"[red]{line}[/red]")
97
+ elif line.startswith("@@"):
98
+ console.print(f"[magenta]{line}[/magenta]")
99
+ else:
100
+ console.print(f"[dim]{line}[/dim]")
101
+
102
+ async def request_approval(self, request: dict) -> ApprovalDecision:
103
+ self._newline_if_dirty()
104
+ preview = request.get("preview") or ""
105
+ title = f"Approve {request['name']}?"
106
+ if preview:
107
+ self._print_diff(preview) if "\n" in preview and (
108
+ "+++" in preview or "@@" in preview
109
+ ) else console.print(Panel(preview, title=title, border_style="yellow"))
110
+ if self.auto_approve:
111
+ console.print("[green]auto-approved[/green]")
112
+ return ApprovalDecision(approved=True)
113
+
114
+ answer = await asyncio.to_thread(
115
+ Prompt.ask,
116
+ f"[yellow]{title}[/yellow] (y/n, or type feedback to reject)",
117
+ default="y",
118
+ )
119
+ a = answer.strip().lower()
120
+ if a in ("y", "yes", ""):
121
+ return ApprovalDecision(approved=True)
122
+ if a in ("n", "no"):
123
+ return ApprovalDecision(approved=False, feedback="rejected by user")
124
+ return ApprovalDecision(approved=False, feedback=answer.strip())
125
+
126
+
127
+ # --------------------------------------------------------------------------- #
128
+ # Commands
129
+ # --------------------------------------------------------------------------- #
130
+ async def _run_task(task: str, args) -> None:
131
+ cfg = load_config(args.config, provider=args.provider, model=args.model)
132
+ if args.yes:
133
+ cfg.agent.auto_approve_writes = True
134
+ cfg.agent.auto_approve_commands = True
135
+ workspace = str(Path(args.workspace).resolve())
136
+ console.print(
137
+ f"[dim]workspace={workspace} · provider={cfg.provider.name} · "
138
+ f"model={cfg.provider.model}[/dim]"
139
+ )
140
+ io = TerminalIO(auto_approve=args.yes)
141
+ session = AgentSession(workspace, cfg, io)
142
+ await session.run(task)
143
+
144
+
145
+ def cmd_run(args) -> None:
146
+ asyncio.run(_run_task(args.task, args))
147
+
148
+
149
+ async def _chat(args) -> None:
150
+ cfg = load_config(args.config, provider=args.provider, model=args.model)
151
+ if args.yes:
152
+ cfg.agent.auto_approve_writes = True
153
+ cfg.agent.auto_approve_commands = True
154
+ workspace = str(Path(args.workspace).resolve())
155
+ io = TerminalIO(auto_approve=args.yes)
156
+ session = AgentSession(workspace, cfg, io) # one session => memory across turns
157
+ console.print(
158
+ Panel(
159
+ f"Euron Agent · [bold]{cfg.provider.name}[/bold] / {cfg.provider.model}\n"
160
+ f"workspace: {workspace}\n"
161
+ "Type a task. Commands: /exit, /reset, /yes (toggle auto-approve).",
162
+ border_style="cyan",
163
+ )
164
+ )
165
+ while True:
166
+ try:
167
+ msg = await asyncio.to_thread(Prompt.ask, "[bold cyan]you[/bold cyan]")
168
+ except (EOFError, KeyboardInterrupt):
169
+ break
170
+ msg = msg.strip()
171
+ if not msg:
172
+ continue
173
+ if msg in ("/exit", "/quit"):
174
+ break
175
+ if msg == "/reset":
176
+ session.messages.clear()
177
+ console.print("[dim]context cleared[/dim]")
178
+ continue
179
+ if msg == "/yes":
180
+ io.auto_approve = not io.auto_approve
181
+ console.print(f"[dim]auto-approve = {io.auto_approve}[/dim]")
182
+ continue
183
+ await session.run(msg)
184
+ console.print("[dim]bye[/dim]")
185
+
186
+
187
+ def cmd_chat(args) -> None:
188
+ asyncio.run(_chat(args))
189
+
190
+
191
+ def cmd_serve(args) -> None:
192
+ from .server import serve
193
+
194
+ console.print(f"[cyan]Euron Agent server[/cyan] on http://{args.host}:{args.port}")
195
+ serve(host=args.host, port=args.port, reload=args.reload)
196
+
197
+
198
+ def cmd_providers(args) -> None:
199
+ from rich.table import Table
200
+
201
+ cfg = load_config(args.config)
202
+ table = Table(title="Configured providers")
203
+ table.add_column("name")
204
+ table.add_column("active")
205
+ table.add_column("type")
206
+ table.add_column("model")
207
+ table.add_column("base_url")
208
+ for name, p in cfg.all_providers.items():
209
+ table.add_row(
210
+ name,
211
+ "●" if name == cfg.provider.name else "",
212
+ p.type,
213
+ p.model,
214
+ p.base_url or "(default)",
215
+ )
216
+ console.print(table)
217
+
218
+
219
+ def cmd_init(args) -> None:
220
+ backend = Path(__file__).resolve().parent.parent
221
+ pairs = [("config.example.yaml", "config.yaml"), (".env.example", ".env")]
222
+ for src, dst in pairs:
223
+ dst_path = Path.cwd() / dst
224
+ src_path = backend / src
225
+ if dst_path.exists():
226
+ console.print(f"[yellow]skip[/yellow] {dst} already exists")
227
+ elif src_path.exists():
228
+ shutil.copy(src_path, dst_path)
229
+ console.print(f"[green]created[/green] {dst}")
230
+ else:
231
+ console.print(f"[red]missing template[/red] {src}")
232
+ console.print("Edit config.yaml (pick a provider) and .env (add your key).")
233
+
234
+
235
+ # --------------------------------------------------------------------------- #
236
+ # Parser
237
+ # --------------------------------------------------------------------------- #
238
+ def build_parser() -> argparse.ArgumentParser:
239
+ p = argparse.ArgumentParser(prog="euron-agent", description="Euron coding agent.")
240
+ p.add_argument("--config", help="Path to config.yaml")
241
+ p.add_argument("--provider", help="Override active provider profile")
242
+ p.add_argument("--model", help="Override model id")
243
+ p.add_argument(
244
+ "--workspace", default=os.getcwd(), help="Workspace root (default: cwd)"
245
+ )
246
+ sub = p.add_subparsers(dest="command", required=True)
247
+
248
+ r = sub.add_parser("run", help="Run a single task and exit")
249
+ r.add_argument("task")
250
+ r.add_argument("--yes", "-y", action="store_true", help="Auto-approve all actions")
251
+ r.set_defaults(func=cmd_run)
252
+
253
+ c = sub.add_parser("chat", help="Interactive REPL")
254
+ c.add_argument("--yes", "-y", action="store_true", help="Auto-approve all actions")
255
+ c.set_defaults(func=cmd_chat)
256
+
257
+ s = sub.add_parser("serve", help="Start the FastAPI server")
258
+ s.add_argument("--host", default="127.0.0.1")
259
+ s.add_argument("--port", type=int, default=8000)
260
+ s.add_argument("--reload", action="store_true")
261
+ s.set_defaults(func=cmd_serve)
262
+
263
+ sub.add_parser("providers", help="List configured providers").set_defaults(
264
+ func=cmd_providers
265
+ )
266
+ sub.add_parser("init", help="Scaffold config.yaml and .env").set_defaults(
267
+ func=cmd_init
268
+ )
269
+ return p
270
+
271
+
272
+ def main(argv=None) -> int:
273
+ args = build_parser().parse_args(argv)
274
+ try:
275
+ args.func(args)
276
+ except KeyboardInterrupt:
277
+ console.print("\n[dim]interrupted[/dim]")
278
+ return 130
279
+ except Exception as e: # noqa: BLE001
280
+ console.print(f"[red]fatal:[/red] {type(e).__name__}: {e}")
281
+ return 1
282
+ return 0
283
+
284
+
285
+ if __name__ == "__main__":
286
+ sys.exit(main())
@@ -0,0 +1,203 @@
1
+ """Configuration loading and resolution.
2
+
3
+ Config is layered, in order of precedence (later wins):
4
+ 1. built-in defaults
5
+ 2. config.yaml (next to the package, the cwd, or $EURON_AGENT_CONFIG)
6
+ 3. environment variables (.env is loaded automatically)
7
+ 4. explicit overrides passed on the CLI / API call
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from dataclasses import dataclass, field, replace
13
+ from pathlib import Path
14
+ from typing import Any, Optional
15
+
16
+ import yaml
17
+ from dotenv import load_dotenv
18
+
19
+ load_dotenv() # pull .env into os.environ if present
20
+
21
+
22
+ # --------------------------------------------------------------------------- #
23
+ # Dataclasses
24
+ # --------------------------------------------------------------------------- #
25
+ @dataclass
26
+ class ProviderConfig:
27
+ name: str
28
+ type: str = "openai" # "openai" (OpenAI-compatible) or "anthropic"
29
+ base_url: Optional[str] = None
30
+ api_key_env: Optional[str] = None
31
+ api_key: Optional[str] = None # resolved from api_key_env at load time
32
+ model: str = "gpt-4o-mini"
33
+ temperature: float = 0.2
34
+ max_tokens: int = 4096
35
+ extra_headers: dict = field(default_factory=dict)
36
+
37
+
38
+ @dataclass
39
+ class AgentConfig:
40
+ max_steps: int = 30
41
+ stream: bool = True
42
+ auto_approve_reads: bool = True
43
+ auto_approve_writes: bool = False
44
+ auto_approve_commands: bool = False
45
+ max_file_bytes: int = 120_000
46
+ max_command_seconds: int = 60
47
+
48
+
49
+ @dataclass
50
+ class Config:
51
+ provider: ProviderConfig
52
+ agent: AgentConfig
53
+ ignore: list[str] = field(default_factory=list)
54
+ all_providers: dict[str, ProviderConfig] = field(default_factory=dict)
55
+
56
+
57
+ DEFAULT_IGNORE = [
58
+ ".git/**", "node_modules/**", "__pycache__/**", ".venv/**", "venv/**",
59
+ "dist/**", "build/**", "*.lock", ".env", ".env.*",
60
+ ]
61
+
62
+ # Built-in provider profiles so the extension (and a fresh install with no
63
+ # config.yaml) works out of the box: the user just picks a provider and supplies
64
+ # a key. Any of these can be overridden by a user-defined provider of the same
65
+ # name in config.yaml.
66
+ BUILTIN_PROVIDERS: dict[str, dict] = {
67
+ "euri": {
68
+ "type": "openai",
69
+ "base_url": "https://api.euron.one/api/v1",
70
+ "api_key_env": "EURI_API_KEY",
71
+ "model": "gpt-4.1-mini",
72
+ },
73
+ "openai": {
74
+ "type": "openai",
75
+ "base_url": "https://api.openai.com/v1",
76
+ "api_key_env": "OPENAI_API_KEY",
77
+ "model": "gpt-4o-mini",
78
+ },
79
+ "openrouter": {
80
+ "type": "openai",
81
+ "base_url": "https://openrouter.ai/api/v1",
82
+ "api_key_env": "OPENROUTER_API_KEY",
83
+ "model": "openai/gpt-4o-mini",
84
+ },
85
+ "ollama": {
86
+ "type": "openai",
87
+ "base_url": "http://localhost:11434/v1",
88
+ "api_key_env": None,
89
+ "model": "qwen2.5-coder:7b",
90
+ },
91
+ "anthropic": {
92
+ "type": "anthropic",
93
+ "base_url": None,
94
+ "api_key_env": "ANTHROPIC_API_KEY",
95
+ "model": "claude-sonnet-4-6",
96
+ },
97
+ # Generic OpenAI-compatible endpoint; base_url/model supplied at runtime.
98
+ "custom": {
99
+ "type": "openai",
100
+ "base_url": "http://localhost:8001/v1",
101
+ "api_key_env": None,
102
+ "model": "local-model",
103
+ },
104
+ }
105
+
106
+
107
+ # --------------------------------------------------------------------------- #
108
+ # Loading
109
+ # --------------------------------------------------------------------------- #
110
+ def _candidate_paths(explicit: Optional[str]) -> list[Path]:
111
+ paths = []
112
+ if explicit:
113
+ paths.append(Path(explicit))
114
+ env = os.getenv("EURON_AGENT_CONFIG")
115
+ if env:
116
+ paths.append(Path(env))
117
+ paths.append(Path.cwd() / "config.yaml")
118
+ paths.append(Path(__file__).resolve().parent.parent / "config.yaml")
119
+ return paths
120
+
121
+
122
+ def _find_config_file(explicit: Optional[str]) -> Optional[Path]:
123
+ for p in _candidate_paths(explicit):
124
+ if p and p.is_file():
125
+ return p
126
+ return None
127
+
128
+
129
+ def _provider_from_dict(name: str, d: dict) -> ProviderConfig:
130
+ api_key_env = d.get("api_key_env")
131
+ api_key = os.getenv(api_key_env) if api_key_env else None
132
+ return ProviderConfig(
133
+ name=name,
134
+ type=d.get("type", "openai"),
135
+ base_url=d.get("base_url"),
136
+ api_key_env=api_key_env,
137
+ api_key=api_key,
138
+ model=d.get("model", "gpt-4o-mini"),
139
+ temperature=float(d.get("temperature", 0.2)),
140
+ max_tokens=int(d.get("max_tokens", 4096)),
141
+ extra_headers=d.get("extra_headers", {}) or {},
142
+ )
143
+
144
+
145
+ def load_config(
146
+ config_path: Optional[str] = None,
147
+ *,
148
+ provider: Optional[str] = None,
149
+ model: Optional[str] = None,
150
+ api_key: Optional[str] = None,
151
+ base_url: Optional[str] = None,
152
+ ) -> Config:
153
+ """Load configuration, applying optional overrides.
154
+
155
+ `api_key` / `base_url` let a caller (e.g. the VS Code extension) inject a
156
+ secret and endpoint at runtime so nothing has to live in a file.
157
+ """
158
+ raw: dict[str, Any] = {}
159
+ cfg_file = _find_config_file(config_path)
160
+ if cfg_file:
161
+ raw = yaml.safe_load(cfg_file.read_text(encoding="utf-8")) or {}
162
+
163
+ # Start from the built-in profiles, then let config.yaml override/extend them.
164
+ merged: dict[str, dict] = {k: dict(v) for k, v in BUILTIN_PROVIDERS.items()}
165
+ for name, d in (raw.get("providers", {}) or {}).items():
166
+ merged[name] = {**merged.get(name, {}), **d}
167
+ all_providers = {name: _provider_from_dict(name, d) for name, d in merged.items()}
168
+
169
+ active = provider or raw.get("active") or "openai"
170
+ if active not in all_providers:
171
+ raise ValueError(
172
+ f"Provider '{active}' not found. Available: {', '.join(all_providers)}"
173
+ )
174
+ selected = all_providers[active]
175
+ overrides: dict[str, Any] = {}
176
+ if model:
177
+ overrides["model"] = model
178
+ if api_key: # non-empty only
179
+ overrides["api_key"] = api_key
180
+ if base_url:
181
+ overrides["base_url"] = base_url
182
+ if overrides:
183
+ selected = replace(selected, **overrides)
184
+
185
+ agent_raw = raw.get("agent", {}) or {}
186
+ agent = AgentConfig(
187
+ max_steps=int(agent_raw.get("max_steps", 30)),
188
+ stream=bool(agent_raw.get("stream", True)),
189
+ auto_approve_reads=bool(agent_raw.get("auto_approve_reads", True)),
190
+ auto_approve_writes=bool(agent_raw.get("auto_approve_writes", False)),
191
+ auto_approve_commands=bool(agent_raw.get("auto_approve_commands", False)),
192
+ max_file_bytes=int(agent_raw.get("max_file_bytes", 120_000)),
193
+ max_command_seconds=int(agent_raw.get("max_command_seconds", 60)),
194
+ )
195
+
196
+ ignore = raw.get("ignore") or DEFAULT_IGNORE
197
+
198
+ return Config(
199
+ provider=selected,
200
+ agent=agent,
201
+ ignore=list(ignore),
202
+ all_providers=all_providers,
203
+ )