devorch 0.1.2__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.
- cli/commands/ask.py +0 -0
- cli/commands/edit.py +0 -0
- cli/commands/run.py +0 -0
- cli/main.py +1527 -0
- config/permissions.py +285 -0
- config/profiles.py +0 -0
- config/settings.py +185 -0
- core/agent.py +433 -0
- core/context.py +0 -0
- core/executor.py +213 -0
- core/modes.py +141 -0
- core/planner.py +16 -0
- core/sessions.py +271 -0
- core/tasks.py +142 -0
- devorch-0.1.2.dist-info/METADATA +528 -0
- devorch-0.1.2.dist-info/RECORD +50 -0
- devorch-0.1.2.dist-info/WHEEL +4 -0
- devorch-0.1.2.dist-info/entry_points.txt +2 -0
- providers/__init__.py +130 -0
- providers/anthropic.py +127 -0
- providers/base.py +51 -0
- providers/custom.py +186 -0
- providers/deepseek.py +170 -0
- providers/gemini.py +192 -0
- providers/github_copilot.py +196 -0
- providers/groq.py +158 -0
- providers/kimi.py +181 -0
- providers/lmstudio.py +147 -0
- providers/local.py +214 -0
- providers/mistral.py +161 -0
- providers/openai.py +114 -0
- providers/openrouter.py +195 -0
- providers/together.py +159 -0
- schemas/message.py +32 -0
- schemas/task.py +115 -0
- schemas/tool.py +0 -0
- tools/__init__.py +30 -0
- tools/base.py +40 -0
- tools/edit.py +236 -0
- tools/filesystem.py +224 -0
- tools/git.py +0 -0
- tools/grep.py +280 -0
- tools/search.py +150 -0
- tools/shell.py +55 -0
- tools/task.py +91 -0
- tools/terminal.py +123 -0
- tools/terminal_session.py +298 -0
- tools/tests.py +0 -0
- tools/websearch.py +166 -0
- utils/logger.py +52 -0
config/permissions.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Permission management for tool execution safety.
|
|
3
|
+
|
|
4
|
+
Supports:
|
|
5
|
+
- Tool-level permissions (allow/deny/ask)
|
|
6
|
+
- Command pattern matching (e.g., "git *" always allowed)
|
|
7
|
+
- Session-only vs persistent permissions
|
|
8
|
+
- Safe command categories
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import fnmatch
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
import yaml
|
|
19
|
+
|
|
20
|
+
YAML_AVAILABLE = True
|
|
21
|
+
except ImportError:
|
|
22
|
+
YAML_AVAILABLE = False
|
|
23
|
+
|
|
24
|
+
CONFIG_DIR = Path.home() / ".devorch"
|
|
25
|
+
PERMISSIONS_FILE = CONFIG_DIR / "permissions.yaml"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PermissionLevel(str, Enum):
|
|
29
|
+
ALLOW = "allow" # Always allow without asking
|
|
30
|
+
DENY = "deny" # Always deny
|
|
31
|
+
ASK = "ask" # Ask every time (default)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PermissionChoice(str, Enum):
|
|
35
|
+
ALLOW_ONCE = "allow_once"
|
|
36
|
+
ALLOW_SESSION = "allow_session"
|
|
37
|
+
ALLOW_ALWAYS = "allow_always"
|
|
38
|
+
DENY = "deny"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Commands that are generally safe (read-only or low risk)
|
|
42
|
+
SAFE_COMMANDS = [
|
|
43
|
+
# Version/info commands
|
|
44
|
+
"git --version",
|
|
45
|
+
"git status",
|
|
46
|
+
"git log*",
|
|
47
|
+
"git diff*",
|
|
48
|
+
"git branch*",
|
|
49
|
+
"git remote -v",
|
|
50
|
+
"git show*",
|
|
51
|
+
"python --version",
|
|
52
|
+
"python3 --version",
|
|
53
|
+
"pip --version",
|
|
54
|
+
"pip list",
|
|
55
|
+
"node --version",
|
|
56
|
+
"npm --version",
|
|
57
|
+
"npm list*",
|
|
58
|
+
"cargo --version",
|
|
59
|
+
"rustc --version",
|
|
60
|
+
"go version",
|
|
61
|
+
# List/read commands
|
|
62
|
+
"ls*",
|
|
63
|
+
"dir*",
|
|
64
|
+
"pwd",
|
|
65
|
+
"cd*",
|
|
66
|
+
"echo*",
|
|
67
|
+
"cat*",
|
|
68
|
+
"head*",
|
|
69
|
+
"tail*",
|
|
70
|
+
"which*",
|
|
71
|
+
"where*",
|
|
72
|
+
"type*",
|
|
73
|
+
"file*",
|
|
74
|
+
"wc*",
|
|
75
|
+
"grep*",
|
|
76
|
+
"find*",
|
|
77
|
+
"tree*",
|
|
78
|
+
# Environment
|
|
79
|
+
"env",
|
|
80
|
+
"printenv*",
|
|
81
|
+
"hostname",
|
|
82
|
+
"whoami",
|
|
83
|
+
"date",
|
|
84
|
+
"uptime",
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
# Commands that are potentially dangerous
|
|
88
|
+
DANGEROUS_PATTERNS = [
|
|
89
|
+
"rm -rf *",
|
|
90
|
+
"rm -r *",
|
|
91
|
+
"rmdir*",
|
|
92
|
+
"del /s*",
|
|
93
|
+
"rd /s*",
|
|
94
|
+
"format*",
|
|
95
|
+
"mkfs*",
|
|
96
|
+
"dd if=*",
|
|
97
|
+
"> /dev/*",
|
|
98
|
+
"chmod 777*",
|
|
99
|
+
"sudo*",
|
|
100
|
+
"curl*|*sh",
|
|
101
|
+
"wget*|*sh", # Pipe to shell
|
|
102
|
+
"*; rm*",
|
|
103
|
+
"*&& rm*", # Chained destructive
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class ToolPermission:
|
|
109
|
+
"""Permission settings for a specific tool."""
|
|
110
|
+
|
|
111
|
+
level: PermissionLevel = PermissionLevel.ASK
|
|
112
|
+
allowed_patterns: list[str] = field(default_factory=list)
|
|
113
|
+
denied_patterns: list[str] = field(default_factory=list)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class Permissions:
|
|
118
|
+
"""Global permission settings."""
|
|
119
|
+
|
|
120
|
+
tools: dict[str, ToolPermission] = field(default_factory=dict)
|
|
121
|
+
|
|
122
|
+
# Session-only permissions (not persisted)
|
|
123
|
+
session_allowed: list[str] = field(default_factory=list)
|
|
124
|
+
session_denied: list[str] = field(default_factory=list)
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def load(cls) -> "Permissions":
|
|
128
|
+
"""Load permissions from config file."""
|
|
129
|
+
permissions = cls()
|
|
130
|
+
|
|
131
|
+
# Set defaults
|
|
132
|
+
permissions.tools = {
|
|
133
|
+
"shell": ToolPermission(
|
|
134
|
+
level=PermissionLevel.ASK,
|
|
135
|
+
allowed_patterns=SAFE_COMMANDS.copy(),
|
|
136
|
+
denied_patterns=DANGEROUS_PATTERNS.copy(),
|
|
137
|
+
),
|
|
138
|
+
"filesystem": ToolPermission(level=PermissionLevel.ASK),
|
|
139
|
+
"search": ToolPermission(level=PermissionLevel.ALLOW), # Search is safe
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
# Load from file if exists
|
|
143
|
+
if YAML_AVAILABLE and PERMISSIONS_FILE.exists():
|
|
144
|
+
try:
|
|
145
|
+
with open(PERMISSIONS_FILE) as f:
|
|
146
|
+
data = yaml.safe_load(f) or {}
|
|
147
|
+
|
|
148
|
+
for tool_name, tool_data in data.get("tools", {}).items():
|
|
149
|
+
if tool_name not in permissions.tools:
|
|
150
|
+
permissions.tools[tool_name] = ToolPermission()
|
|
151
|
+
|
|
152
|
+
perm = permissions.tools[tool_name]
|
|
153
|
+
perm.level = PermissionLevel(tool_data.get("level", "ask"))
|
|
154
|
+
perm.allowed_patterns = tool_data.get("allowed_patterns", perm.allowed_patterns)
|
|
155
|
+
perm.denied_patterns = tool_data.get("denied_patterns", perm.denied_patterns)
|
|
156
|
+
|
|
157
|
+
except Exception:
|
|
158
|
+
pass # Use defaults on error
|
|
159
|
+
|
|
160
|
+
return permissions
|
|
161
|
+
|
|
162
|
+
def save(self):
|
|
163
|
+
"""Save permissions to config file."""
|
|
164
|
+
if not YAML_AVAILABLE:
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
168
|
+
|
|
169
|
+
data = {"tools": {}}
|
|
170
|
+
|
|
171
|
+
for tool_name, perm in self.tools.items():
|
|
172
|
+
data["tools"][tool_name] = {
|
|
173
|
+
"level": perm.level.value,
|
|
174
|
+
"allowed_patterns": perm.allowed_patterns,
|
|
175
|
+
"denied_patterns": perm.denied_patterns,
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
with open(PERMISSIONS_FILE, "w") as f:
|
|
179
|
+
yaml.safe_dump(data, f, default_flow_style=False)
|
|
180
|
+
|
|
181
|
+
def check_permission(
|
|
182
|
+
self, tool_name: str, command: str | None = None
|
|
183
|
+
) -> tuple[PermissionLevel, str | None]:
|
|
184
|
+
"""
|
|
185
|
+
Check if a tool/command is allowed.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
(PermissionLevel, reason) - The permission level and optional reason
|
|
189
|
+
"""
|
|
190
|
+
tool_perm = self.tools.get(tool_name, ToolPermission())
|
|
191
|
+
|
|
192
|
+
# Check session permissions first (for commands)
|
|
193
|
+
if command:
|
|
194
|
+
# Session denied takes priority
|
|
195
|
+
for pattern in self.session_denied:
|
|
196
|
+
if self._match_command(command, pattern):
|
|
197
|
+
return PermissionLevel.DENY, f"Denied this session: {pattern}"
|
|
198
|
+
|
|
199
|
+
# Session allowed
|
|
200
|
+
for pattern in self.session_allowed:
|
|
201
|
+
if self._match_command(command, pattern):
|
|
202
|
+
return PermissionLevel.ALLOW, f"Allowed this session: {pattern}"
|
|
203
|
+
|
|
204
|
+
# Check denied patterns (dangerous commands)
|
|
205
|
+
for pattern in tool_perm.denied_patterns:
|
|
206
|
+
if self._match_command(command, pattern):
|
|
207
|
+
return PermissionLevel.DENY, f"Matches dangerous pattern: {pattern}"
|
|
208
|
+
|
|
209
|
+
# Check allowed patterns (safe commands)
|
|
210
|
+
for pattern in tool_perm.allowed_patterns:
|
|
211
|
+
if self._match_command(command, pattern):
|
|
212
|
+
return PermissionLevel.ALLOW, f"Matches safe pattern: {pattern}"
|
|
213
|
+
|
|
214
|
+
# Fall back to tool-level permission
|
|
215
|
+
return tool_perm.level, None
|
|
216
|
+
|
|
217
|
+
def _match_command(self, command: str, pattern: str) -> bool:
|
|
218
|
+
"""Check if a command matches a pattern."""
|
|
219
|
+
command = command.strip().lower()
|
|
220
|
+
pattern = pattern.strip().lower()
|
|
221
|
+
|
|
222
|
+
# Handle wildcard patterns
|
|
223
|
+
if "*" in pattern:
|
|
224
|
+
# Convert glob to regex
|
|
225
|
+
regex = fnmatch.translate(pattern)
|
|
226
|
+
return bool(re.match(regex, command))
|
|
227
|
+
|
|
228
|
+
# Exact match or prefix match
|
|
229
|
+
return command == pattern or command.startswith(pattern + " ")
|
|
230
|
+
|
|
231
|
+
def add_allowed_pattern(self, tool_name: str, pattern: str, session_only: bool = False):
|
|
232
|
+
"""Add a pattern to the allowed list."""
|
|
233
|
+
if session_only:
|
|
234
|
+
if pattern not in self.session_allowed:
|
|
235
|
+
self.session_allowed.append(pattern)
|
|
236
|
+
else:
|
|
237
|
+
if tool_name not in self.tools:
|
|
238
|
+
self.tools[tool_name] = ToolPermission()
|
|
239
|
+
if pattern not in self.tools[tool_name].allowed_patterns:
|
|
240
|
+
self.tools[tool_name].allowed_patterns.append(pattern)
|
|
241
|
+
self.save()
|
|
242
|
+
|
|
243
|
+
def add_denied_pattern(self, tool_name: str, pattern: str, session_only: bool = False):
|
|
244
|
+
"""Add a pattern to the denied list."""
|
|
245
|
+
if session_only:
|
|
246
|
+
if pattern not in self.session_denied:
|
|
247
|
+
self.session_denied.append(pattern)
|
|
248
|
+
else:
|
|
249
|
+
if tool_name not in self.tools:
|
|
250
|
+
self.tools[tool_name] = ToolPermission()
|
|
251
|
+
if pattern not in self.tools[tool_name].denied_patterns:
|
|
252
|
+
self.tools[tool_name].denied_patterns.append(pattern)
|
|
253
|
+
self.save()
|
|
254
|
+
|
|
255
|
+
def set_tool_permission(self, tool_name: str, level: PermissionLevel):
|
|
256
|
+
"""Set the default permission level for a tool."""
|
|
257
|
+
if tool_name not in self.tools:
|
|
258
|
+
self.tools[tool_name] = ToolPermission()
|
|
259
|
+
self.tools[tool_name].level = level
|
|
260
|
+
self.save()
|
|
261
|
+
|
|
262
|
+
def clear_session_permissions(self):
|
|
263
|
+
"""Clear session-only permissions."""
|
|
264
|
+
self.session_allowed.clear()
|
|
265
|
+
self.session_denied.clear()
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# Global permissions instance
|
|
269
|
+
_permissions: Permissions | None = None
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def get_permissions() -> Permissions:
|
|
273
|
+
"""Get the global permissions instance."""
|
|
274
|
+
global _permissions
|
|
275
|
+
if _permissions is None:
|
|
276
|
+
_permissions = Permissions.load()
|
|
277
|
+
return _permissions
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def reset_permissions():
|
|
281
|
+
"""Reset permissions to defaults."""
|
|
282
|
+
global _permissions
|
|
283
|
+
_permissions = None
|
|
284
|
+
if PERMISSIONS_FILE.exists():
|
|
285
|
+
PERMISSIONS_FILE.unlink()
|
config/profiles.py
ADDED
|
File without changes
|
config/settings.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
YAML_AVAILABLE = True
|
|
9
|
+
except ImportError:
|
|
10
|
+
YAML_AVAILABLE = False
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
import keyring
|
|
14
|
+
|
|
15
|
+
KEYRING_AVAILABLE = True
|
|
16
|
+
except ImportError:
|
|
17
|
+
KEYRING_AVAILABLE = False
|
|
18
|
+
|
|
19
|
+
CONFIG_DIR = Path.home() / ".devorch"
|
|
20
|
+
CONFIG_FILE = CONFIG_DIR / "config.yaml"
|
|
21
|
+
KEYRING_SERVICE = "devorch"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class ProviderConfig:
|
|
26
|
+
api_key: str | None = None
|
|
27
|
+
default_model: str = ""
|
|
28
|
+
base_url: str | None = None
|
|
29
|
+
key_encrypted: bool = False # True if key is stored in keyring
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Settings:
|
|
34
|
+
default_provider: str = "openai"
|
|
35
|
+
providers: dict[str, ProviderConfig] = field(default_factory=dict)
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def load(cls) -> "Settings":
|
|
39
|
+
"""Load settings from config file, keyring, and env vars."""
|
|
40
|
+
settings = cls()
|
|
41
|
+
|
|
42
|
+
# Load from YAML if exists and yaml is available
|
|
43
|
+
if YAML_AVAILABLE and CONFIG_FILE.exists():
|
|
44
|
+
try:
|
|
45
|
+
with open(CONFIG_FILE) as f:
|
|
46
|
+
data = yaml.safe_load(f) or {}
|
|
47
|
+
settings.default_provider = data.get("default_provider", "openai")
|
|
48
|
+
for name, config in data.get("providers", {}).items():
|
|
49
|
+
settings.providers[name] = ProviderConfig(**config)
|
|
50
|
+
except Exception:
|
|
51
|
+
pass # Fall back to defaults if config is invalid
|
|
52
|
+
|
|
53
|
+
# Environment variable mappings: (env_var_name, default_model)
|
|
54
|
+
env_mappings = {
|
|
55
|
+
"openai": ("OPENAI_API_KEY", "gpt-4o"),
|
|
56
|
+
"anthropic": ("ANTHROPIC_API_KEY", "claude-sonnet-4-20250514"),
|
|
57
|
+
"gemini": ("GOOGLE_API_KEY", "gemini-1.5-pro"),
|
|
58
|
+
"local": (None, "llama3"),
|
|
59
|
+
"groq": ("GROQ_API_KEY", "llama-3.1-70b-versatile"),
|
|
60
|
+
"openrouter": ("OPENROUTER_API_KEY", "openai/gpt-4o"),
|
|
61
|
+
"mistral": ("MISTRAL_API_KEY", "mistral-large-latest"),
|
|
62
|
+
"together": ("TOGETHER_API_KEY", "meta-llama/Llama-3-70b-chat-hf"),
|
|
63
|
+
"lmstudio": (None, ""),
|
|
64
|
+
"github_copilot": ("GITHUB_TOKEN", "gpt-4o"),
|
|
65
|
+
"deepseek": ("DEEPSEEK_API_KEY", "deepseek-chat"),
|
|
66
|
+
"kimi": ("MOONSHOT_API_KEY", "moonshot-v1-32k"),
|
|
67
|
+
"custom": ("CUSTOM_API_KEY", ""),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for provider, (env_var, default_model) in env_mappings.items():
|
|
71
|
+
if provider not in settings.providers:
|
|
72
|
+
settings.providers[provider] = ProviderConfig(default_model=default_model)
|
|
73
|
+
|
|
74
|
+
# Set default model if not configured
|
|
75
|
+
if not settings.providers[provider].default_model:
|
|
76
|
+
settings.providers[provider].default_model = default_model
|
|
77
|
+
|
|
78
|
+
# Priority: 1. Keyring, 2. Env var, 3. Config file
|
|
79
|
+
# Try keyring first (encrypted storage)
|
|
80
|
+
if KEYRING_AVAILABLE and not settings.providers[provider].api_key:
|
|
81
|
+
try:
|
|
82
|
+
key = keyring.get_password(KEYRING_SERVICE, provider)
|
|
83
|
+
if key:
|
|
84
|
+
settings.providers[provider].api_key = key
|
|
85
|
+
settings.providers[provider].key_encrypted = True
|
|
86
|
+
except Exception:
|
|
87
|
+
pass # Keyring not available or failed
|
|
88
|
+
|
|
89
|
+
# Override with environment variable if present
|
|
90
|
+
if env_var and os.environ.get(env_var):
|
|
91
|
+
settings.providers[provider].api_key = os.environ[env_var]
|
|
92
|
+
settings.providers[provider].key_encrypted = False
|
|
93
|
+
|
|
94
|
+
# Set default base_url for local provider
|
|
95
|
+
if "local" in settings.providers and not settings.providers["local"].base_url:
|
|
96
|
+
settings.providers["local"].base_url = "http://localhost:11434/v1"
|
|
97
|
+
|
|
98
|
+
return settings
|
|
99
|
+
|
|
100
|
+
def get_api_key(self, provider: str) -> str | None:
|
|
101
|
+
"""Get API key for a provider."""
|
|
102
|
+
config = self.providers.get(provider)
|
|
103
|
+
return config.api_key if config else None
|
|
104
|
+
|
|
105
|
+
def get_default_model(self, provider: str) -> str:
|
|
106
|
+
"""Get default model for a provider."""
|
|
107
|
+
config = self.providers.get(provider)
|
|
108
|
+
return config.default_model if config else ""
|
|
109
|
+
|
|
110
|
+
def get_base_url(self, provider: str) -> str | None:
|
|
111
|
+
"""Get base URL for a provider (used for local/custom endpoints)."""
|
|
112
|
+
config = self.providers.get(provider)
|
|
113
|
+
return config.base_url if config else None
|
|
114
|
+
|
|
115
|
+
def is_key_encrypted(self, provider: str) -> bool:
|
|
116
|
+
"""Check if the API key is stored encrypted."""
|
|
117
|
+
config = self.providers.get(provider)
|
|
118
|
+
return config.key_encrypted if config else False
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def ensure_config_dir():
|
|
122
|
+
"""Create config directory if it doesn't exist."""
|
|
123
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def set_api_key(provider: str, api_key: str) -> bool:
|
|
127
|
+
"""
|
|
128
|
+
Store an API key securely using keyring.
|
|
129
|
+
Returns True if stored encrypted, False if keyring unavailable.
|
|
130
|
+
"""
|
|
131
|
+
if not KEYRING_AVAILABLE:
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
keyring.set_password(KEYRING_SERVICE, provider, api_key)
|
|
136
|
+
return True
|
|
137
|
+
except Exception:
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def delete_api_key(provider: str) -> bool:
|
|
142
|
+
"""Delete an API key from keyring."""
|
|
143
|
+
if not KEYRING_AVAILABLE:
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
keyring.delete_password(KEYRING_SERVICE, provider)
|
|
148
|
+
return True
|
|
149
|
+
except Exception:
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def save_config(settings: Settings):
|
|
154
|
+
"""Save settings to config file (excluding API keys - those go in keyring)."""
|
|
155
|
+
if not YAML_AVAILABLE:
|
|
156
|
+
raise RuntimeError("PyYAML is required to save config. Install with: pip install pyyaml")
|
|
157
|
+
|
|
158
|
+
ensure_config_dir()
|
|
159
|
+
|
|
160
|
+
data = {"default_provider": settings.default_provider, "providers": {}}
|
|
161
|
+
|
|
162
|
+
for name, config in settings.providers.items():
|
|
163
|
+
provider_data = {}
|
|
164
|
+
# Don't save API keys to file - they should be in keyring or env vars
|
|
165
|
+
if config.default_model:
|
|
166
|
+
provider_data["default_model"] = config.default_model
|
|
167
|
+
if config.base_url:
|
|
168
|
+
provider_data["base_url"] = config.base_url
|
|
169
|
+
if provider_data:
|
|
170
|
+
data["providers"][name] = provider_data
|
|
171
|
+
|
|
172
|
+
with open(CONFIG_FILE, "w") as f:
|
|
173
|
+
yaml.safe_dump(data, f, default_flow_style=False)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def keyring_available() -> bool:
|
|
177
|
+
"""Check if keyring is available and working."""
|
|
178
|
+
if not KEYRING_AVAILABLE:
|
|
179
|
+
return False
|
|
180
|
+
try:
|
|
181
|
+
# Try a test operation
|
|
182
|
+
keyring.get_password(KEYRING_SERVICE, "__test__")
|
|
183
|
+
return True
|
|
184
|
+
except Exception:
|
|
185
|
+
return False
|