vtx-coding-agent 0.1.1__py3-none-any.whl
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.
- vtx/__init__.py +63 -0
- vtx/async_utils.py +40 -0
- vtx/builtin_skills/github/SKILL.md +139 -0
- vtx/builtin_skills/init/SKILL.md +74 -0
- vtx/builtin_skills/review/SKILL.md +73 -0
- vtx/builtin_skills/skill-builder/SKILL.md +133 -0
- vtx/cli.py +90 -0
- vtx/config.py +741 -0
- vtx/context/__init__.py +15 -0
- vtx/context/_xml.py +8 -0
- vtx/context/agent_mds.py +128 -0
- vtx/context/git.py +64 -0
- vtx/context/loader.py +41 -0
- vtx/context/skills.py +423 -0
- vtx/core/__init__.py +47 -0
- vtx/core/compaction.py +89 -0
- vtx/core/errors.py +17 -0
- vtx/core/handoff.py +51 -0
- vtx/core/scratchpad.py +54 -0
- vtx/core/types.py +197 -0
- vtx/defaults/__init__.py +0 -0
- vtx/defaults/config.yml +53 -0
- vtx/diff_display.py +12 -0
- vtx/events.py +224 -0
- vtx/gh_cli.py +82 -0
- vtx/git_branch.py +90 -0
- vtx/headless.py +127 -0
- vtx/llm/__init__.py +93 -0
- vtx/llm/base.py +217 -0
- vtx/llm/context_length.py +150 -0
- vtx/llm/dynamic_models.py +735 -0
- vtx/llm/model_fetcher.py +279 -0
- vtx/llm/models.py +78 -0
- vtx/llm/oauth/__init__.py +59 -0
- vtx/llm/oauth/copilot.py +358 -0
- vtx/llm/oauth/dynamic.py +236 -0
- vtx/llm/oauth/openai.py +400 -0
- vtx/llm/phase_parser.py +270 -0
- vtx/llm/provider.yaml +280 -0
- vtx/llm/provider_catalog.py +230 -0
- vtx/llm/providers/__init__.py +45 -0
- vtx/llm/providers/anthropic_sdk.py +256 -0
- vtx/llm/providers/mock.py +249 -0
- vtx/llm/providers/openai_sdk.py +246 -0
- vtx/llm/providers/sanitize.py +14 -0
- vtx/llm/sdk/__init__.py +13 -0
- vtx/llm/sdk/anthropic.py +382 -0
- vtx/llm/sdk/base.py +82 -0
- vtx/llm/sdk/openai.py +344 -0
- vtx/llm/tool_parser.py +161 -0
- vtx/loop.py +272 -0
- vtx/notify.py +109 -0
- vtx/permissions.py +114 -0
- vtx/prompts/__init__.py +45 -0
- vtx/prompts/builder.py +86 -0
- vtx/prompts/env.py +58 -0
- vtx/prompts/identity.py +166 -0
- vtx/prompts/tooling.py +36 -0
- vtx/py.typed +0 -0
- vtx/runtime.py +580 -0
- vtx/session.py +868 -0
- vtx/sounds/completion.wav +0 -0
- vtx/sounds/error.wav +0 -0
- vtx/sounds/permission.wav +0 -0
- vtx/themes.py +1104 -0
- vtx/tools/__init__.py +68 -0
- vtx/tools/_read_image.py +106 -0
- vtx/tools/_tool_utils.py +90 -0
- vtx/tools/base.py +36 -0
- vtx/tools/bash.py +371 -0
- vtx/tools/edit.py +261 -0
- vtx/tools/find.py +132 -0
- vtx/tools/read.py +238 -0
- vtx/tools/skill.py +278 -0
- vtx/tools/web.py +238 -0
- vtx/tools/write.py +88 -0
- vtx/tools_manager.py +216 -0
- vtx/turn.py +789 -0
- vtx/ui/__init__.py +0 -0
- vtx/ui/agent_runner.py +417 -0
- vtx/ui/app.py +665 -0
- vtx/ui/app_protocol.py +29 -0
- vtx/ui/autocomplete.py +440 -0
- vtx/ui/blocks.py +735 -0
- vtx/ui/chat.py +613 -0
- vtx/ui/clipboard.py +59 -0
- vtx/ui/commands/__init__.py +100 -0
- vtx/ui/commands/auth.py +306 -0
- vtx/ui/commands/base.py +122 -0
- vtx/ui/commands/models.py +144 -0
- vtx/ui/commands/sessions.py +388 -0
- vtx/ui/commands/settings.py +286 -0
- vtx/ui/completion_ui.py +313 -0
- vtx/ui/export.py +703 -0
- vtx/ui/floating_list.py +370 -0
- vtx/ui/formatting.py +287 -0
- vtx/ui/input.py +760 -0
- vtx/ui/latex.py +349 -0
- vtx/ui/launch.py +108 -0
- vtx/ui/path_complete.py +228 -0
- vtx/ui/prompt_history.py +102 -0
- vtx/ui/queue_ui.py +141 -0
- vtx/ui/selection_mode.py +18 -0
- vtx/ui/session_ui.py +235 -0
- vtx/ui/startup.py +124 -0
- vtx/ui/styles.py +327 -0
- vtx/ui/tool_output.py +34 -0
- vtx/ui/tree.py +437 -0
- vtx/ui/welcome.py +51 -0
- vtx/ui/widgets.py +558 -0
- vtx/update_check.py +49 -0
- vtx/version.py +22 -0
- vtx_coding_agent-0.1.1.dist-info/METADATA +259 -0
- vtx_coding_agent-0.1.1.dist-info/RECORD +117 -0
- vtx_coding_agent-0.1.1.dist-info/WHEEL +4 -0
- vtx_coding_agent-0.1.1.dist-info/entry_points.txt +2 -0
- vtx_coding_agent-0.1.1.dist-info/licenses/LICENSE +201 -0
vtx/config.py
ADDED
|
@@ -0,0 +1,741 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
import sys
|
|
5
|
+
import tempfile
|
|
6
|
+
from contextvars import ContextVar
|
|
7
|
+
from copy import deepcopy
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from importlib import resources
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Literal, get_args
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, Field, ValidationError, field_validator
|
|
14
|
+
|
|
15
|
+
from .themes import ColorsConfig, get_theme, get_theme_ids
|
|
16
|
+
|
|
17
|
+
CONFIG_DIR_NAME: str = "vtx"
|
|
18
|
+
|
|
19
|
+
OnOverflowMode = Literal["continue", "pause"]
|
|
20
|
+
AuthMode = Literal["auto", "required", "none"]
|
|
21
|
+
PermissionMode = Literal["prompt", "auto"]
|
|
22
|
+
NotificationMode = Literal["on", "off"]
|
|
23
|
+
PERMISSION_MODES: tuple[PermissionMode, ...] = get_args(PermissionMode)
|
|
24
|
+
NOTIFICATION_MODES: tuple[NotificationMode, ...] = get_args(NotificationMode)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# =================================================================================================
|
|
28
|
+
# Persisted Config Schema and Defaults
|
|
29
|
+
# =================================================================================================
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _load_default_config_yaml() -> dict[str, Any]:
|
|
33
|
+
import yaml
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
yaml.safe_load(
|
|
37
|
+
resources.files("vtx.defaults").joinpath("config.yml").read_text(encoding="utf-8")
|
|
38
|
+
)
|
|
39
|
+
or {}
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_DEFAULT_CONFIG_DATA = _load_default_config_yaml()
|
|
44
|
+
CURRENT_CONFIG_VERSION = int(_DEFAULT_CONFIG_DATA.get("meta", {}).get("config_version", 1))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _resolve_default_system_prompt() -> str:
|
|
48
|
+
"""Return the default base identity string.
|
|
49
|
+
|
|
50
|
+
Pulled from :mod:`vtx.prompts.identity` so the prompt is owned by
|
|
51
|
+
Python code rather than the shipped YAML. The YAML keeps an empty
|
|
52
|
+
placeholder for schema stability; this function fills it in.
|
|
53
|
+
"""
|
|
54
|
+
from .prompts.identity import DEFAULT_VTX_BASE
|
|
55
|
+
|
|
56
|
+
return DEFAULT_VTX_BASE
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_config_var: ContextVar["Config | None"] = ContextVar("vtx_config", default=None)
|
|
60
|
+
_config_warnings: list[str] = []
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class MetaConfig(BaseModel):
|
|
64
|
+
config_version: int = CURRENT_CONFIG_VERSION
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
ThinkingLinesOption = Literal["1", "2", "3", "4", "5", "none"]
|
|
68
|
+
THINKING_LINES_OPTIONS: tuple[ThinkingLinesOption, ...] = get_args(ThinkingLinesOption)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class UIConfig(BaseModel):
|
|
72
|
+
theme: str = "gruvbox-dark"
|
|
73
|
+
# When true, finalized thinking blocks are collapsed to a single line summary.
|
|
74
|
+
# Set to false to always show the full thinking content.
|
|
75
|
+
collapse_thinking: bool = True
|
|
76
|
+
# Number of lines to show when thinking is collapsed. "none" means no truncation.
|
|
77
|
+
thinking_lines: ThinkingLinesOption = "1"
|
|
78
|
+
# When true, tool icon and name use badge label color on success.
|
|
79
|
+
colored_tool_badge: bool = True
|
|
80
|
+
# Show the list of keyboard shortcuts in the welcome section on launch.
|
|
81
|
+
# Set to false to hide the shortcuts panel.
|
|
82
|
+
show_welcome_shortcuts: bool = True
|
|
83
|
+
# Models hidden from the /model picker. Use a provider name ("github-copilot")
|
|
84
|
+
# to hide all its models, or "provider:model" to hide a specific model.
|
|
85
|
+
# Hidden models remain usable via config defaults or session resume.
|
|
86
|
+
hidden_models: list[str] = []
|
|
87
|
+
|
|
88
|
+
@field_validator("theme")
|
|
89
|
+
@classmethod
|
|
90
|
+
def _validate_theme(cls, value: str) -> str:
|
|
91
|
+
if value not in get_theme_ids():
|
|
92
|
+
raise ValueError(f"Unknown theme: {value}")
|
|
93
|
+
return value
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def colors(self) -> ColorsConfig:
|
|
97
|
+
return get_theme(self.theme).colors
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class SystemPromptConfig(BaseModel):
|
|
101
|
+
content: str
|
|
102
|
+
git_context: bool = False
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class AuthConfig(BaseModel):
|
|
106
|
+
openai_compat: AuthMode = "auto"
|
|
107
|
+
anthropic_compat: AuthMode = "auto"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class TLSConfig(BaseModel):
|
|
111
|
+
insecure_skip_verify: bool = False
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class LLMConfig(BaseModel):
|
|
115
|
+
default_provider: str
|
|
116
|
+
default_model: str
|
|
117
|
+
default_base_url: str = ""
|
|
118
|
+
default_thinking_level: str
|
|
119
|
+
system_prompt: SystemPromptConfig
|
|
120
|
+
tool_call_idle_timeout_seconds: float = 180
|
|
121
|
+
request_timeout_seconds: float = 600
|
|
122
|
+
auth: AuthConfig = AuthConfig()
|
|
123
|
+
tls: TLSConfig = TLSConfig()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class CompactionConfig(BaseModel):
|
|
127
|
+
on_overflow: OnOverflowMode = "continue"
|
|
128
|
+
buffer_tokens: int = 20000
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class AgentConfig(BaseModel):
|
|
132
|
+
max_turns: int = 500
|
|
133
|
+
default_context_window: int = 200000
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class PermissionsConfig(BaseModel):
|
|
137
|
+
mode: PermissionMode = "prompt"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class NotificationsConfig(BaseModel):
|
|
141
|
+
enabled: bool = False
|
|
142
|
+
volume: float = Field(default=0.5, ge=0.0, le=1.0)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class LastSelectedConfig(BaseModel):
|
|
146
|
+
model_id: str | None = None
|
|
147
|
+
provider: str | None = None
|
|
148
|
+
thinking_level: str | None = None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class ConfigSchema(BaseModel):
|
|
152
|
+
meta: MetaConfig
|
|
153
|
+
llm: LLMConfig
|
|
154
|
+
ui: UIConfig
|
|
155
|
+
compaction: CompactionConfig
|
|
156
|
+
agent: AgentConfig
|
|
157
|
+
permissions: PermissionsConfig
|
|
158
|
+
notifications: NotificationsConfig = NotificationsConfig()
|
|
159
|
+
last_selected: LastSelectedConfig = LastSelectedConfig()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# =================================================================================================
|
|
163
|
+
# Runtime Config Accessors
|
|
164
|
+
# =================================================================================================
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class _BinariesConfig:
|
|
168
|
+
def __init__(self, binaries: set[str]) -> None:
|
|
169
|
+
self._binaries = binaries
|
|
170
|
+
|
|
171
|
+
def has(self, binary: str) -> bool:
|
|
172
|
+
return binary in self._binaries
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def rg(self) -> bool:
|
|
176
|
+
return "rg" in self._binaries
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def fd(self) -> bool:
|
|
180
|
+
return "fd" in self._binaries
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def gh(self) -> bool:
|
|
184
|
+
return "gh" in self._binaries
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class Config:
|
|
188
|
+
def __init__(self, data: dict[str, Any]) -> None:
|
|
189
|
+
merged = self.merge_with_defaults(data)
|
|
190
|
+
self._parsed = ConfigSchema.model_validate(merged)
|
|
191
|
+
|
|
192
|
+
@staticmethod
|
|
193
|
+
def deep_merge(base: dict[str, Any], overrides: dict[str, Any]) -> dict[str, Any]:
|
|
194
|
+
merged = deepcopy(base)
|
|
195
|
+
for key, value in overrides.items():
|
|
196
|
+
current_value = merged.get(key)
|
|
197
|
+
if isinstance(current_value, dict) and isinstance(value, dict):
|
|
198
|
+
merged[key] = Config.deep_merge(current_value, value)
|
|
199
|
+
else:
|
|
200
|
+
merged[key] = deepcopy(value)
|
|
201
|
+
return merged
|
|
202
|
+
|
|
203
|
+
@staticmethod
|
|
204
|
+
def _apply_legacy_key_shims(data: dict[str, Any]) -> dict[str, Any]:
|
|
205
|
+
normalized_data = deepcopy(data)
|
|
206
|
+
|
|
207
|
+
llm = normalized_data.get("llm")
|
|
208
|
+
if isinstance(llm, dict):
|
|
209
|
+
legacy_prompt = llm.get("system_prompt")
|
|
210
|
+
if isinstance(legacy_prompt, str):
|
|
211
|
+
llm["system_prompt"] = {"content": legacy_prompt}
|
|
212
|
+
|
|
213
|
+
legacy_git_context = llm.pop("system_prompt_git_context", None)
|
|
214
|
+
if isinstance(legacy_git_context, bool):
|
|
215
|
+
system_prompt = llm.get("system_prompt")
|
|
216
|
+
if not isinstance(system_prompt, dict):
|
|
217
|
+
system_prompt = {}
|
|
218
|
+
llm["system_prompt"] = system_prompt
|
|
219
|
+
system_prompt.setdefault("git_context", legacy_git_context)
|
|
220
|
+
|
|
221
|
+
# Fill the default base identity from Python when the YAML left
|
|
222
|
+
# the placeholder empty (or did not include it at all).
|
|
223
|
+
system_prompt = llm.get("system_prompt")
|
|
224
|
+
if isinstance(system_prompt, dict) and not system_prompt.get("content"):
|
|
225
|
+
system_prompt["content"] = _resolve_default_system_prompt()
|
|
226
|
+
|
|
227
|
+
return normalized_data
|
|
228
|
+
|
|
229
|
+
@staticmethod
|
|
230
|
+
def merge_with_defaults(data: dict[str, Any]) -> dict[str, Any]:
|
|
231
|
+
normalized_data = Config._apply_legacy_key_shims(data)
|
|
232
|
+
return Config.deep_merge(_DEFAULT_CONFIG_DATA, normalized_data)
|
|
233
|
+
|
|
234
|
+
@property
|
|
235
|
+
def llm(self) -> LLMConfig:
|
|
236
|
+
return self._parsed.llm
|
|
237
|
+
|
|
238
|
+
@property
|
|
239
|
+
def ui(self) -> UIConfig:
|
|
240
|
+
return self._parsed.ui
|
|
241
|
+
|
|
242
|
+
@property
|
|
243
|
+
def compaction(self) -> CompactionConfig:
|
|
244
|
+
return self._parsed.compaction
|
|
245
|
+
|
|
246
|
+
@property
|
|
247
|
+
def agent(self) -> AgentConfig:
|
|
248
|
+
return self._parsed.agent
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def permissions(self) -> PermissionsConfig:
|
|
252
|
+
return self._parsed.permissions
|
|
253
|
+
|
|
254
|
+
@property
|
|
255
|
+
def notifications(self) -> NotificationsConfig:
|
|
256
|
+
return self._parsed.notifications
|
|
257
|
+
|
|
258
|
+
@property
|
|
259
|
+
def binaries(self) -> _BinariesConfig:
|
|
260
|
+
return _BinariesConfig(AVAILABLE_BINARIES)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# =================================================================================================
|
|
264
|
+
# Persisted Config IO, Migration, and Serialization
|
|
265
|
+
# =================================================================================================
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def get_config_dir() -> Path:
|
|
269
|
+
return Path.home() / f".{CONFIG_DIR_NAME}"
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def get_agents_dir() -> Path:
|
|
273
|
+
return Path.home() / ".agents"
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _ensure_config_file() -> Path:
|
|
277
|
+
config_dir = get_config_dir()
|
|
278
|
+
config_file = config_dir / "config.yml"
|
|
279
|
+
|
|
280
|
+
if not config_file.exists():
|
|
281
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
282
|
+
import yaml
|
|
283
|
+
|
|
284
|
+
defaults = Config._apply_legacy_key_shims(_load_default_config_yaml())
|
|
285
|
+
config_file.write_text(yaml.dump(defaults, default_flow_style=False), encoding="utf-8")
|
|
286
|
+
|
|
287
|
+
return config_file
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _record_config_warning(message: str) -> None:
|
|
291
|
+
_config_warnings.append(message)
|
|
292
|
+
print(message, file=sys.stderr)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def consume_config_warnings() -> list[str]:
|
|
296
|
+
warnings = _config_warnings.copy()
|
|
297
|
+
_config_warnings.clear()
|
|
298
|
+
return warnings
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _detect_available_binaries() -> set[str]:
|
|
302
|
+
binaries = {"rg", "fd", "gh"}
|
|
303
|
+
available = set()
|
|
304
|
+
bin_dir = get_config_dir() / "bin"
|
|
305
|
+
|
|
306
|
+
for binary in binaries:
|
|
307
|
+
if shutil.which(binary) or (bin_dir / binary).exists():
|
|
308
|
+
available.add(binary)
|
|
309
|
+
|
|
310
|
+
return available
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _get_config_version(data: dict[str, Any]) -> int:
|
|
314
|
+
meta = data.get("meta")
|
|
315
|
+
if not isinstance(meta, dict):
|
|
316
|
+
return 0
|
|
317
|
+
version = meta.get("config_version")
|
|
318
|
+
if isinstance(version, int) and version >= 0:
|
|
319
|
+
return version
|
|
320
|
+
return 0
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _migrate_v0_to_v1(data: dict[str, Any]) -> dict[str, Any]:
|
|
324
|
+
migrated = Config._apply_legacy_key_shims(data)
|
|
325
|
+
meta = migrated.get("meta")
|
|
326
|
+
if not isinstance(meta, dict):
|
|
327
|
+
migrated["meta"] = {"config_version": 1}
|
|
328
|
+
else:
|
|
329
|
+
meta["config_version"] = 1
|
|
330
|
+
return migrated
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _migrate_v1_to_v2(data: dict[str, Any]) -> dict[str, Any]:
|
|
334
|
+
migrated = Config._apply_legacy_key_shims(data)
|
|
335
|
+
meta = migrated.get("meta")
|
|
336
|
+
if not isinstance(meta, dict):
|
|
337
|
+
migrated["meta"] = {"config_version": 2}
|
|
338
|
+
else:
|
|
339
|
+
meta["config_version"] = 2
|
|
340
|
+
return migrated
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _migrate_v2_to_v3(data: dict[str, Any]) -> dict[str, Any]:
|
|
344
|
+
migrated = Config._apply_legacy_key_shims(data)
|
|
345
|
+
ui = migrated.get("ui")
|
|
346
|
+
if not isinstance(ui, dict):
|
|
347
|
+
ui = {}
|
|
348
|
+
migrated["ui"] = ui
|
|
349
|
+
|
|
350
|
+
ui["theme"] = "gruvbox-dark"
|
|
351
|
+
ui.pop("colors", None)
|
|
352
|
+
|
|
353
|
+
meta = migrated.get("meta")
|
|
354
|
+
if not isinstance(meta, dict):
|
|
355
|
+
migrated["meta"] = {"config_version": 3}
|
|
356
|
+
else:
|
|
357
|
+
meta["config_version"] = 3
|
|
358
|
+
return migrated
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _migrate_v3_to_v4(data: dict[str, Any]) -> dict[str, Any]:
|
|
362
|
+
migrated = Config._apply_legacy_key_shims(data)
|
|
363
|
+
llm = migrated.get("llm")
|
|
364
|
+
if not isinstance(llm, dict):
|
|
365
|
+
llm = {}
|
|
366
|
+
migrated["llm"] = llm
|
|
367
|
+
|
|
368
|
+
auth = llm.get("auth")
|
|
369
|
+
if not isinstance(auth, dict):
|
|
370
|
+
auth = {}
|
|
371
|
+
llm["auth"] = auth
|
|
372
|
+
|
|
373
|
+
auth.setdefault("openai_compat", "auto")
|
|
374
|
+
auth.setdefault("anthropic_compat", "auto")
|
|
375
|
+
|
|
376
|
+
meta = migrated.get("meta")
|
|
377
|
+
if not isinstance(meta, dict):
|
|
378
|
+
migrated["meta"] = {"config_version": 4}
|
|
379
|
+
else:
|
|
380
|
+
meta["config_version"] = 4
|
|
381
|
+
return migrated
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _migrate_v4_to_v5(data: dict[str, Any]) -> dict[str, Any]:
|
|
385
|
+
migrated = Config._apply_legacy_key_shims(data)
|
|
386
|
+
notifications = migrated.get("notifications")
|
|
387
|
+
if not isinstance(notifications, dict):
|
|
388
|
+
notifications = {}
|
|
389
|
+
migrated["notifications"] = notifications
|
|
390
|
+
|
|
391
|
+
notifications.setdefault("volume", 0.5)
|
|
392
|
+
|
|
393
|
+
meta = migrated.get("meta")
|
|
394
|
+
if not isinstance(meta, dict):
|
|
395
|
+
migrated["meta"] = {"config_version": 5}
|
|
396
|
+
else:
|
|
397
|
+
meta["config_version"] = 5
|
|
398
|
+
return migrated
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _migrate_v5_to_v6(data: dict[str, Any]) -> dict[str, Any]:
|
|
402
|
+
migrated = Config._apply_legacy_key_shims(data)
|
|
403
|
+
llm = migrated.get("llm")
|
|
404
|
+
if not isinstance(llm, dict):
|
|
405
|
+
llm = {}
|
|
406
|
+
migrated["llm"] = llm
|
|
407
|
+
|
|
408
|
+
system_prompt = llm.get("system_prompt")
|
|
409
|
+
if not isinstance(system_prompt, dict):
|
|
410
|
+
system_prompt = {}
|
|
411
|
+
llm["system_prompt"] = system_prompt
|
|
412
|
+
|
|
413
|
+
# Pull the default identity from Python so the YAML placeholder
|
|
414
|
+
# remains a single source of truth.
|
|
415
|
+
system_prompt["content"] = _resolve_default_system_prompt()
|
|
416
|
+
system_prompt["git_context"] = _DEFAULT_CONFIG_DATA["llm"]["system_prompt"]["git_context"]
|
|
417
|
+
|
|
418
|
+
meta = migrated.get("meta")
|
|
419
|
+
if not isinstance(meta, dict):
|
|
420
|
+
migrated["meta"] = {"config_version": 6}
|
|
421
|
+
else:
|
|
422
|
+
meta["config_version"] = 6
|
|
423
|
+
return migrated
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _migrate_config_data(data: dict[str, Any]) -> tuple[dict[str, Any], int, int, bool]:
|
|
427
|
+
original = deepcopy(data)
|
|
428
|
+
current_version = _get_config_version(original)
|
|
429
|
+
migrated = deepcopy(original)
|
|
430
|
+
|
|
431
|
+
while current_version < CURRENT_CONFIG_VERSION:
|
|
432
|
+
if current_version == 0:
|
|
433
|
+
migrated = _migrate_v0_to_v1(migrated)
|
|
434
|
+
current_version = 1
|
|
435
|
+
continue
|
|
436
|
+
if current_version == 1:
|
|
437
|
+
migrated = _migrate_v1_to_v2(migrated)
|
|
438
|
+
current_version = 2
|
|
439
|
+
continue
|
|
440
|
+
if current_version == 2:
|
|
441
|
+
migrated = _migrate_v2_to_v3(migrated)
|
|
442
|
+
current_version = 3
|
|
443
|
+
continue
|
|
444
|
+
if current_version == 3:
|
|
445
|
+
migrated = _migrate_v3_to_v4(migrated)
|
|
446
|
+
current_version = 4
|
|
447
|
+
continue
|
|
448
|
+
if current_version == 4:
|
|
449
|
+
migrated = _migrate_v4_to_v5(migrated)
|
|
450
|
+
current_version = 5
|
|
451
|
+
continue
|
|
452
|
+
if current_version == 5:
|
|
453
|
+
migrated = _migrate_v5_to_v6(migrated)
|
|
454
|
+
current_version = 6
|
|
455
|
+
continue
|
|
456
|
+
break
|
|
457
|
+
|
|
458
|
+
migrated_version = _get_config_version(migrated)
|
|
459
|
+
did_migrate = migrated != original
|
|
460
|
+
return migrated, _get_config_version(original), migrated_version, did_migrate
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _serialize_config_yaml(data: dict[str, Any]) -> str:
|
|
464
|
+
import yaml
|
|
465
|
+
|
|
466
|
+
return yaml.dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False) + "\n"
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _atomic_write_text(path: Path, content: str) -> None:
|
|
470
|
+
fd, tmp_name = tempfile.mkstemp(prefix=f"{path.name}.", suffix=".tmp", dir=path.parent)
|
|
471
|
+
tmp_path = Path(tmp_name)
|
|
472
|
+
try:
|
|
473
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
474
|
+
f.write(content)
|
|
475
|
+
os.replace(tmp_path, path)
|
|
476
|
+
except Exception:
|
|
477
|
+
with contextlib.suppress(FileNotFoundError):
|
|
478
|
+
tmp_path.unlink()
|
|
479
|
+
raise
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _backup_and_write_migrated_config(config_file: Path, data: dict[str, Any]) -> Path:
|
|
483
|
+
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
|
484
|
+
backup_path = config_file.with_name(f"{config_file.name}.bak.{timestamp}")
|
|
485
|
+
shutil.copy2(config_file, backup_path)
|
|
486
|
+
_atomic_write_text(config_file, _serialize_config_yaml(data))
|
|
487
|
+
return backup_path
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
# =================================================================================================
|
|
491
|
+
# Runtime Environment Capabilities
|
|
492
|
+
# TODO: Consider moving runtime capability detection and caching to a dedicated runtime.py module.
|
|
493
|
+
# =================================================================================================
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
AVAILABLE_BINARIES = _detect_available_binaries()
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def update_available_binaries() -> None:
|
|
500
|
+
AVAILABLE_BINARIES.clear()
|
|
501
|
+
AVAILABLE_BINARIES.update(_detect_available_binaries())
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
# =================================================================================================
|
|
505
|
+
# Persisted Config Loading and Runtime Cache
|
|
506
|
+
# =================================================================================================
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _read_config_data(config_file: Path) -> dict[str, Any]:
|
|
510
|
+
try:
|
|
511
|
+
import yaml
|
|
512
|
+
|
|
513
|
+
with open(config_file, encoding="utf-8") as f:
|
|
514
|
+
return yaml.safe_load(f) or {}
|
|
515
|
+
except (OSError, yaml.YAMLError) as exc:
|
|
516
|
+
_record_config_warning(
|
|
517
|
+
f"Invalid config at {config_file}: {exc}. Falling back to built-in defaults."
|
|
518
|
+
)
|
|
519
|
+
return {}
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _load_config() -> Config:
|
|
523
|
+
config_file = _ensure_config_file()
|
|
524
|
+
data = _read_config_data(config_file)
|
|
525
|
+
|
|
526
|
+
try:
|
|
527
|
+
migrated_data, from_version, to_version, did_migrate = _migrate_config_data(data)
|
|
528
|
+
if did_migrate and data:
|
|
529
|
+
try:
|
|
530
|
+
backup = _backup_and_write_migrated_config(config_file, migrated_data)
|
|
531
|
+
_record_config_warning(
|
|
532
|
+
f"Migrated config at {config_file} from v{from_version} to v{to_version}. "
|
|
533
|
+
f"Backup saved to {backup}."
|
|
534
|
+
)
|
|
535
|
+
except Exception as exc:
|
|
536
|
+
_record_config_warning(
|
|
537
|
+
f"Failed to persist migrated config at {config_file}: {exc}. "
|
|
538
|
+
"Continuing with in-memory migrated config."
|
|
539
|
+
)
|
|
540
|
+
return Config(migrated_data)
|
|
541
|
+
except ValidationError as exc:
|
|
542
|
+
_record_config_warning(
|
|
543
|
+
f"Invalid config values at {config_file}: {exc}. Falling back to built-in defaults."
|
|
544
|
+
)
|
|
545
|
+
return Config({})
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def get_config() -> Config:
|
|
549
|
+
"""
|
|
550
|
+
Get the current config instance.
|
|
551
|
+
|
|
552
|
+
Returns the config from context variable if set, otherwise loads from file.
|
|
553
|
+
The loaded config is cached in the context variable.
|
|
554
|
+
"""
|
|
555
|
+
cfg = _config_var.get()
|
|
556
|
+
if cfg is None:
|
|
557
|
+
cfg = _load_config()
|
|
558
|
+
_config_var.set(cfg)
|
|
559
|
+
return cfg
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def set_config(config: Config) -> None:
|
|
563
|
+
"""Set the config instance (useful for testing)."""
|
|
564
|
+
_config_var.set(config)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def reload_config() -> Config:
|
|
568
|
+
"""Reload config from file and update the context variable."""
|
|
569
|
+
cfg = _load_config()
|
|
570
|
+
_config_var.set(cfg)
|
|
571
|
+
return cfg
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _set_config_version(data: dict[str, Any]) -> None:
|
|
575
|
+
meta = data.get("meta")
|
|
576
|
+
if not isinstance(meta, dict):
|
|
577
|
+
data["meta"] = {"config_version": CURRENT_CONFIG_VERSION}
|
|
578
|
+
else:
|
|
579
|
+
meta["config_version"] = CURRENT_CONFIG_VERSION
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def set_theme(theme: str) -> Config:
|
|
583
|
+
get_theme(theme)
|
|
584
|
+
|
|
585
|
+
config_file = _ensure_config_file()
|
|
586
|
+
data = _read_config_data(config_file)
|
|
587
|
+
|
|
588
|
+
ui = data.get("ui")
|
|
589
|
+
if not isinstance(ui, dict):
|
|
590
|
+
ui = {}
|
|
591
|
+
data["ui"] = ui
|
|
592
|
+
|
|
593
|
+
ui["theme"] = theme
|
|
594
|
+
ui.pop("colors", None)
|
|
595
|
+
_set_config_version(data)
|
|
596
|
+
|
|
597
|
+
_atomic_write_text(config_file, _serialize_config_yaml(data))
|
|
598
|
+
return reload_config()
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def set_show_welcome_shortcuts(enabled: bool) -> Config:
|
|
602
|
+
config_file = _ensure_config_file()
|
|
603
|
+
data = _read_config_data(config_file)
|
|
604
|
+
|
|
605
|
+
ui = data.get("ui")
|
|
606
|
+
if not isinstance(ui, dict):
|
|
607
|
+
ui = {}
|
|
608
|
+
data["ui"] = ui
|
|
609
|
+
|
|
610
|
+
ui["show_welcome_shortcuts"] = enabled
|
|
611
|
+
_set_config_version(data)
|
|
612
|
+
|
|
613
|
+
_atomic_write_text(config_file, _serialize_config_yaml(data))
|
|
614
|
+
return reload_config()
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def set_permissions_mode(mode: PermissionMode) -> Config:
|
|
618
|
+
config_file = _ensure_config_file()
|
|
619
|
+
data = _read_config_data(config_file)
|
|
620
|
+
|
|
621
|
+
perms = data.get("permissions")
|
|
622
|
+
if not isinstance(perms, dict):
|
|
623
|
+
perms = {}
|
|
624
|
+
data["permissions"] = perms
|
|
625
|
+
|
|
626
|
+
perms["mode"] = mode
|
|
627
|
+
_set_config_version(data)
|
|
628
|
+
|
|
629
|
+
_atomic_write_text(config_file, _serialize_config_yaml(data))
|
|
630
|
+
return reload_config()
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def set_thinking_lines(lines: ThinkingLinesOption) -> Config:
|
|
634
|
+
config_file = _ensure_config_file()
|
|
635
|
+
data = _read_config_data(config_file)
|
|
636
|
+
|
|
637
|
+
ui = data.get("ui")
|
|
638
|
+
if not isinstance(ui, dict):
|
|
639
|
+
ui = {}
|
|
640
|
+
data["ui"] = ui
|
|
641
|
+
|
|
642
|
+
ui["thinking_lines"] = lines
|
|
643
|
+
_set_config_version(data)
|
|
644
|
+
|
|
645
|
+
_atomic_write_text(config_file, _serialize_config_yaml(data))
|
|
646
|
+
return reload_config()
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def set_git_context(enabled: bool) -> Config:
|
|
650
|
+
config_file = _ensure_config_file()
|
|
651
|
+
data = _read_config_data(config_file)
|
|
652
|
+
|
|
653
|
+
llm = data.get("llm")
|
|
654
|
+
if not isinstance(llm, dict):
|
|
655
|
+
llm = {}
|
|
656
|
+
data["llm"] = llm
|
|
657
|
+
|
|
658
|
+
system_prompt = llm.get("system_prompt")
|
|
659
|
+
if not isinstance(system_prompt, dict):
|
|
660
|
+
system_prompt = {}
|
|
661
|
+
llm["system_prompt"] = system_prompt
|
|
662
|
+
|
|
663
|
+
system_prompt["git_context"] = enabled
|
|
664
|
+
_set_config_version(data)
|
|
665
|
+
|
|
666
|
+
_atomic_write_text(config_file, _serialize_config_yaml(data))
|
|
667
|
+
return reload_config()
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def set_colored_tool_badge(enabled: bool) -> Config:
|
|
671
|
+
config_file = _ensure_config_file()
|
|
672
|
+
data = _read_config_data(config_file)
|
|
673
|
+
|
|
674
|
+
ui = data.get("ui")
|
|
675
|
+
if not isinstance(ui, dict):
|
|
676
|
+
ui = {}
|
|
677
|
+
data["ui"] = ui
|
|
678
|
+
|
|
679
|
+
ui["colored_tool_badge"] = enabled
|
|
680
|
+
_set_config_version(data)
|
|
681
|
+
|
|
682
|
+
_atomic_write_text(config_file, _serialize_config_yaml(data))
|
|
683
|
+
return reload_config()
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def set_notifications_enabled(enabled: bool) -> Config:
|
|
687
|
+
config_file = _ensure_config_file()
|
|
688
|
+
data = _read_config_data(config_file)
|
|
689
|
+
|
|
690
|
+
notifications = data.get("notifications")
|
|
691
|
+
if not isinstance(notifications, dict):
|
|
692
|
+
notifications = {}
|
|
693
|
+
data["notifications"] = notifications
|
|
694
|
+
|
|
695
|
+
notifications["enabled"] = enabled
|
|
696
|
+
_set_config_version(data)
|
|
697
|
+
|
|
698
|
+
_atomic_write_text(config_file, _serialize_config_yaml(data))
|
|
699
|
+
return reload_config()
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def reset_config() -> None:
|
|
703
|
+
"""Reset config to uninitialized state (next get_config() will reload from file)."""
|
|
704
|
+
_config_var.set(None)
|
|
705
|
+
_config_warnings.clear()
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def set_last_selected(
|
|
709
|
+
model_id: str | None, provider: str | None, thinking_level: str | None
|
|
710
|
+
) -> None:
|
|
711
|
+
"""Save the last selected model, provider, and thinking level."""
|
|
712
|
+
config_file = _ensure_config_file()
|
|
713
|
+
data = _read_config_data(config_file)
|
|
714
|
+
|
|
715
|
+
last_selected = data.get("last_selected", {})
|
|
716
|
+
if not isinstance(last_selected, dict):
|
|
717
|
+
last_selected = {}
|
|
718
|
+
|
|
719
|
+
last_selected["model_id"] = model_id
|
|
720
|
+
last_selected["provider"] = provider
|
|
721
|
+
last_selected["thinking_level"] = thinking_level
|
|
722
|
+
|
|
723
|
+
data["last_selected"] = last_selected
|
|
724
|
+
_set_config_version(data)
|
|
725
|
+
_atomic_write_text(config_file, _serialize_config_yaml(data))
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def get_last_selected() -> LastSelectedConfig:
|
|
729
|
+
"""Get the last selected model, provider, and thinking level."""
|
|
730
|
+
config_file = _ensure_config_file()
|
|
731
|
+
data = _read_config_data(config_file)
|
|
732
|
+
|
|
733
|
+
last_selected = data.get("last_selected", {})
|
|
734
|
+
if not isinstance(last_selected, dict):
|
|
735
|
+
last_selected = {}
|
|
736
|
+
|
|
737
|
+
return LastSelectedConfig(
|
|
738
|
+
model_id=last_selected.get("model_id"),
|
|
739
|
+
provider=last_selected.get("provider"),
|
|
740
|
+
thinking_level=last_selected.get("thinking_level"),
|
|
741
|
+
)
|