minion-code 0.1.0__py3-none-any.whl → 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.
- examples/cli_entrypoint.py +60 -0
- examples/{agent_with_todos.py → components/agent_with_todos.py} +58 -47
- examples/{message_response_children_demo.py → components/message_response_children_demo.py} +61 -55
- examples/components/messages_component.py +199 -0
- examples/file_freshness_example.py +22 -22
- examples/file_watching_example.py +32 -26
- examples/interruptible_tui.py +921 -3
- examples/repl_tui.py +129 -0
- examples/skills/example_usage.py +57 -0
- examples/start.py +173 -0
- minion_code/__init__.py +1 -1
- minion_code/acp_server/__init__.py +34 -0
- minion_code/acp_server/agent.py +539 -0
- minion_code/acp_server/hooks.py +354 -0
- minion_code/acp_server/main.py +194 -0
- minion_code/acp_server/permissions.py +142 -0
- minion_code/acp_server/test_client.py +104 -0
- minion_code/adapters/__init__.py +22 -0
- minion_code/adapters/output_adapter.py +207 -0
- minion_code/adapters/rich_adapter.py +169 -0
- minion_code/adapters/textual_adapter.py +254 -0
- minion_code/agents/__init__.py +2 -2
- minion_code/agents/code_agent.py +517 -104
- minion_code/agents/hooks.py +378 -0
- minion_code/cli.py +538 -429
- minion_code/cli_simple.py +665 -0
- minion_code/commands/__init__.py +136 -29
- minion_code/commands/clear_command.py +19 -46
- minion_code/commands/help_command.py +33 -49
- minion_code/commands/history_command.py +37 -55
- minion_code/commands/model_command.py +194 -0
- minion_code/commands/quit_command.py +9 -12
- minion_code/commands/resume_command.py +181 -0
- minion_code/commands/skill_command.py +89 -0
- minion_code/commands/status_command.py +48 -73
- minion_code/commands/tools_command.py +54 -52
- minion_code/commands/version_command.py +34 -69
- minion_code/components/ConfirmDialog.py +430 -0
- minion_code/components/Message.py +318 -97
- minion_code/components/MessageResponse.py +30 -29
- minion_code/components/Messages.py +351 -0
- minion_code/components/PromptInput.py +499 -245
- minion_code/components/__init__.py +24 -17
- minion_code/const.py +7 -0
- minion_code/screens/REPL.py +1453 -469
- minion_code/screens/__init__.py +1 -1
- minion_code/services/__init__.py +20 -20
- minion_code/services/event_system.py +19 -14
- minion_code/services/file_freshness_service.py +223 -170
- minion_code/skills/__init__.py +25 -0
- minion_code/skills/skill.py +128 -0
- minion_code/skills/skill_loader.py +198 -0
- minion_code/skills/skill_registry.py +177 -0
- minion_code/subagents/__init__.py +31 -0
- minion_code/subagents/builtin/__init__.py +30 -0
- minion_code/subagents/builtin/claude_code_guide.py +32 -0
- minion_code/subagents/builtin/explore.py +36 -0
- minion_code/subagents/builtin/general_purpose.py +19 -0
- minion_code/subagents/builtin/plan.py +61 -0
- minion_code/subagents/subagent.py +116 -0
- minion_code/subagents/subagent_loader.py +147 -0
- minion_code/subagents/subagent_registry.py +151 -0
- minion_code/tools/__init__.py +8 -2
- minion_code/tools/bash_tool.py +16 -3
- minion_code/tools/file_edit_tool.py +201 -104
- minion_code/tools/file_read_tool.py +183 -26
- minion_code/tools/file_write_tool.py +17 -3
- minion_code/tools/glob_tool.py +23 -2
- minion_code/tools/grep_tool.py +229 -21
- minion_code/tools/ls_tool.py +28 -3
- minion_code/tools/multi_edit_tool.py +89 -84
- minion_code/tools/python_interpreter_tool.py +9 -1
- minion_code/tools/skill_tool.py +210 -0
- minion_code/tools/task_tool.py +287 -0
- minion_code/tools/todo_read_tool.py +28 -24
- minion_code/tools/todo_write_tool.py +82 -65
- minion_code/{types.py → type_defs.py} +15 -2
- minion_code/utils/__init__.py +45 -17
- minion_code/utils/config.py +610 -0
- minion_code/utils/history.py +114 -0
- minion_code/utils/logs.py +53 -0
- minion_code/utils/mcp_loader.py +153 -55
- minion_code/utils/output_truncator.py +233 -0
- minion_code/utils/session_storage.py +369 -0
- minion_code/utils/todo_file_utils.py +26 -22
- minion_code/utils/todo_storage.py +43 -33
- minion_code/web/__init__.py +9 -0
- minion_code/web/adapters/__init__.py +5 -0
- minion_code/web/adapters/web_adapter.py +524 -0
- minion_code/web/api/__init__.py +7 -0
- minion_code/web/api/chat.py +277 -0
- minion_code/web/api/interactions.py +136 -0
- minion_code/web/api/sessions.py +135 -0
- minion_code/web/server.py +149 -0
- minion_code/web/services/__init__.py +5 -0
- minion_code/web/services/session_manager.py +420 -0
- minion_code-0.1.1.dist-info/METADATA +475 -0
- minion_code-0.1.1.dist-info/RECORD +111 -0
- {minion_code-0.1.0.dist-info → minion_code-0.1.1.dist-info}/WHEEL +1 -1
- minion_code-0.1.1.dist-info/entry_points.txt +6 -0
- tests/test_adapter.py +67 -0
- tests/test_adapter_simple.py +79 -0
- tests/test_file_read_tool.py +144 -0
- tests/test_readonly_tools.py +0 -2
- tests/test_skills.py +441 -0
- examples/advance_tui.py +0 -508
- examples/rich_example.py +0 -4
- examples/simple_file_watching.py +0 -57
- examples/simple_tui.py +0 -267
- examples/simple_usage.py +0 -69
- minion_code-0.1.0.dist-info/METADATA +0 -350
- minion_code-0.1.0.dist-info/RECORD +0 -59
- minion_code-0.1.0.dist-info/entry_points.txt +0 -4
- {minion_code-0.1.0.dist-info → minion_code-0.1.1.dist-info}/licenses/LICENSE +0 -0
- {minion_code-0.1.0.dist-info → minion_code-0.1.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
"""Configuration management utilities for minion-code.
|
|
2
|
+
|
|
3
|
+
This module provides configuration management functionality similar to the TypeScript
|
|
4
|
+
config.ts file, adapted for Python and the minion-code project structure.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import secrets
|
|
10
|
+
from copy import deepcopy
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, List, Literal, Optional, TypedDict, Union
|
|
13
|
+
from dataclasses import dataclass, field, asdict
|
|
14
|
+
import logging
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
# Type definitions
|
|
19
|
+
McpStdioServerConfig = TypedDict(
|
|
20
|
+
"McpStdioServerConfig",
|
|
21
|
+
{
|
|
22
|
+
"type": Optional[str], # Optional for backwards compatibility
|
|
23
|
+
"command": str,
|
|
24
|
+
"args": List[str],
|
|
25
|
+
"env": Optional[Dict[str, str]],
|
|
26
|
+
},
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
McpSSEServerConfig = TypedDict(
|
|
30
|
+
"McpSSEServerConfig", {"type": Literal["sse"], "url": str}
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
McpServerConfig = Union[McpStdioServerConfig, McpSSEServerConfig]
|
|
34
|
+
|
|
35
|
+
AutoUpdaterStatus = Literal["disabled", "enabled", "no_permissions", "not_configured"]
|
|
36
|
+
NotificationChannel = Literal[
|
|
37
|
+
"iterm2", "terminal_bell", "iterm2_with_bell", "notifications_disabled"
|
|
38
|
+
]
|
|
39
|
+
ProviderType = Literal[
|
|
40
|
+
"anthropic",
|
|
41
|
+
"openai",
|
|
42
|
+
"mistral",
|
|
43
|
+
"deepseek",
|
|
44
|
+
"kimi",
|
|
45
|
+
"qwen",
|
|
46
|
+
"glm",
|
|
47
|
+
"minimax",
|
|
48
|
+
"baidu-qianfan",
|
|
49
|
+
"siliconflow",
|
|
50
|
+
"bigdream",
|
|
51
|
+
"opendev",
|
|
52
|
+
"xai",
|
|
53
|
+
"groq",
|
|
54
|
+
"gemini",
|
|
55
|
+
"ollama",
|
|
56
|
+
"azure",
|
|
57
|
+
"custom",
|
|
58
|
+
"custom-openai",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
ReasoningEffort = Literal["low", "medium", "high", "minimal"]
|
|
62
|
+
ModelPointerType = Literal["main", "task", "reasoning", "quick"]
|
|
63
|
+
ValidationStatus = Literal["valid", "needs_repair", "auto_repaired"]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class ModelProfile:
|
|
68
|
+
"""Model profile configuration."""
|
|
69
|
+
|
|
70
|
+
name: str # User-friendly name
|
|
71
|
+
provider: ProviderType # Provider type
|
|
72
|
+
model_name: str # Primary key - actual model identifier
|
|
73
|
+
api_key: str
|
|
74
|
+
max_tokens: int # Output token limit
|
|
75
|
+
context_length: int # Context window size
|
|
76
|
+
is_active: bool = True # Whether profile is enabled
|
|
77
|
+
created_at: int = field(default_factory=lambda: int(os.time.time() * 1000))
|
|
78
|
+
base_url: Optional[str] = None # Custom endpoint
|
|
79
|
+
reasoning_effort: Optional[ReasoningEffort] = None
|
|
80
|
+
last_used: Optional[int] = None # Last usage timestamp
|
|
81
|
+
is_gpt5: Optional[bool] = None # Auto-detected GPT-5 model flag
|
|
82
|
+
validation_status: Optional[ValidationStatus] = None # Configuration status
|
|
83
|
+
last_validation: Optional[int] = None # Last validation timestamp
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class ModelPointers:
|
|
88
|
+
"""Model pointer system."""
|
|
89
|
+
|
|
90
|
+
main: str = "" # Main dialog model ID
|
|
91
|
+
task: str = "" # Task tool model ID
|
|
92
|
+
reasoning: str = "" # Reasoning model ID
|
|
93
|
+
quick: str = "" # Quick model ID
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class AccountInfo:
|
|
98
|
+
"""Account information."""
|
|
99
|
+
|
|
100
|
+
account_uuid: str
|
|
101
|
+
email_address: str
|
|
102
|
+
organization_uuid: Optional[str] = None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class ProjectConfig:
|
|
107
|
+
"""Project-specific configuration."""
|
|
108
|
+
|
|
109
|
+
allowed_tools: List[str] = field(default_factory=list)
|
|
110
|
+
context: Dict[str, str] = field(default_factory=dict)
|
|
111
|
+
context_files: Optional[List[str]] = None
|
|
112
|
+
history: List[str] = field(default_factory=list)
|
|
113
|
+
dont_crawl_directory: bool = False
|
|
114
|
+
enable_architect_tool: bool = False
|
|
115
|
+
mcp_context_uris: List[str] = field(default_factory=list)
|
|
116
|
+
mcp_servers: Optional[Dict[str, McpServerConfig]] = field(default_factory=dict)
|
|
117
|
+
approved_mcprc_servers: Optional[List[str]] = field(default_factory=list)
|
|
118
|
+
rejected_mcprc_servers: Optional[List[str]] = field(default_factory=list)
|
|
119
|
+
last_api_duration: Optional[float] = None
|
|
120
|
+
last_cost: Optional[float] = None
|
|
121
|
+
last_duration: Optional[float] = None
|
|
122
|
+
last_session_id: Optional[str] = None
|
|
123
|
+
example_files: Optional[List[str]] = None
|
|
124
|
+
example_files_generated_at: Optional[int] = None
|
|
125
|
+
has_trust_dialog_accepted: bool = False
|
|
126
|
+
has_completed_project_onboarding: bool = False
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class GlobalConfig:
|
|
131
|
+
"""Global configuration."""
|
|
132
|
+
|
|
133
|
+
num_startups: int = 0
|
|
134
|
+
auto_updater_status: AutoUpdaterStatus = "not_configured"
|
|
135
|
+
user_id: Optional[str] = None
|
|
136
|
+
theme: str = "dark"
|
|
137
|
+
has_completed_onboarding: Optional[bool] = None
|
|
138
|
+
last_onboarding_version: Optional[str] = None
|
|
139
|
+
last_release_notes_seen: Optional[str] = None
|
|
140
|
+
mcp_servers: Optional[Dict[str, McpServerConfig]] = field(default_factory=dict)
|
|
141
|
+
preferred_notif_channel: NotificationChannel = "iterm2"
|
|
142
|
+
verbose: bool = False
|
|
143
|
+
custom_api_key_responses: Optional[Dict[str, List[str]]] = field(
|
|
144
|
+
default_factory=lambda: {"approved": [], "rejected": []}
|
|
145
|
+
)
|
|
146
|
+
primary_provider: ProviderType = "anthropic"
|
|
147
|
+
max_tokens: Optional[int] = None
|
|
148
|
+
has_acknowledged_cost_threshold: Optional[bool] = None
|
|
149
|
+
oauth_account: Optional[AccountInfo] = None
|
|
150
|
+
iterm2_key_binding_installed: Optional[bool] = None # Legacy
|
|
151
|
+
shift_enter_key_binding_installed: Optional[bool] = None
|
|
152
|
+
proxy: Optional[str] = None
|
|
153
|
+
stream: bool = True
|
|
154
|
+
projects: Optional[Dict[str, ProjectConfig]] = field(default_factory=dict)
|
|
155
|
+
|
|
156
|
+
# New model system
|
|
157
|
+
model_profiles: Optional[List[ModelProfile]] = field(default_factory=list)
|
|
158
|
+
model_pointers: Optional[ModelPointers] = field(default_factory=ModelPointers)
|
|
159
|
+
default_model_name: Optional[str] = None
|
|
160
|
+
last_dismissed_update_version: Optional[str] = None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# Configuration keys that can be modified
|
|
164
|
+
GLOBAL_CONFIG_KEYS = [
|
|
165
|
+
"auto_updater_status",
|
|
166
|
+
"theme",
|
|
167
|
+
"has_completed_onboarding",
|
|
168
|
+
"last_onboarding_version",
|
|
169
|
+
"last_release_notes_seen",
|
|
170
|
+
"verbose",
|
|
171
|
+
"custom_api_key_responses",
|
|
172
|
+
"primary_provider",
|
|
173
|
+
"preferred_notif_channel",
|
|
174
|
+
"shift_enter_key_binding_installed",
|
|
175
|
+
"max_tokens",
|
|
176
|
+
]
|
|
177
|
+
|
|
178
|
+
PROJECT_CONFIG_KEYS = [
|
|
179
|
+
"dont_crawl_directory",
|
|
180
|
+
"enable_architect_tool",
|
|
181
|
+
"has_trust_dialog_accepted",
|
|
182
|
+
"has_completed_project_onboarding",
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class ConfigParseError(Exception):
|
|
187
|
+
"""Configuration parsing error."""
|
|
188
|
+
|
|
189
|
+
def __init__(self, message: str, file_path: str, default_config: Any):
|
|
190
|
+
super().__init__(message)
|
|
191
|
+
self.file_path = file_path
|
|
192
|
+
self.default_config = default_config
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class ConfigManager:
|
|
196
|
+
"""Configuration manager for minion-code."""
|
|
197
|
+
|
|
198
|
+
def __init__(self, config_dir: Optional[Path] = None):
|
|
199
|
+
"""Initialize configuration manager.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
config_dir: Directory for configuration files. Defaults to ~/.minion-code
|
|
203
|
+
"""
|
|
204
|
+
if config_dir is None:
|
|
205
|
+
config_dir = Path.home() / ".minion-code"
|
|
206
|
+
|
|
207
|
+
self.config_dir = Path(config_dir)
|
|
208
|
+
self.config_dir.mkdir(exist_ok=True)
|
|
209
|
+
self.global_config_file = self.config_dir / "config.json"
|
|
210
|
+
|
|
211
|
+
# Test configurations for testing environment
|
|
212
|
+
self._test_global_config: Optional[GlobalConfig] = None
|
|
213
|
+
self._test_project_config: Optional[ProjectConfig] = None
|
|
214
|
+
|
|
215
|
+
def _is_test_env(self) -> bool:
|
|
216
|
+
"""Check if running in test environment."""
|
|
217
|
+
return (
|
|
218
|
+
os.getenv("PYTHON_ENV") == "test"
|
|
219
|
+
or os.getenv("PYTEST_CURRENT_TEST") is not None
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
def _get_current_project_path(self) -> str:
|
|
223
|
+
"""Get current project path."""
|
|
224
|
+
return os.getcwd()
|
|
225
|
+
|
|
226
|
+
def _default_project_config(self, project_path: str) -> ProjectConfig:
|
|
227
|
+
"""Get default configuration for a project."""
|
|
228
|
+
config = ProjectConfig()
|
|
229
|
+
if project_path == str(Path.home()):
|
|
230
|
+
config.dont_crawl_directory = True
|
|
231
|
+
return config
|
|
232
|
+
|
|
233
|
+
def _safe_parse_json(self, content: str) -> Any:
|
|
234
|
+
"""Safely parse JSON content."""
|
|
235
|
+
try:
|
|
236
|
+
return json.loads(content)
|
|
237
|
+
except json.JSONDecodeError as e:
|
|
238
|
+
logger.error(f"JSON parse error: {e}")
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
def _save_config(self, file_path: Path, config: Any, default_config: Any) -> None:
|
|
242
|
+
"""Save configuration to file."""
|
|
243
|
+
# Convert dataclass to dict if needed
|
|
244
|
+
if hasattr(config, "__dataclass_fields__"):
|
|
245
|
+
config_dict = asdict(config)
|
|
246
|
+
else:
|
|
247
|
+
config_dict = config
|
|
248
|
+
|
|
249
|
+
if hasattr(default_config, "__dataclass_fields__"):
|
|
250
|
+
default_dict = asdict(default_config)
|
|
251
|
+
else:
|
|
252
|
+
default_dict = default_config
|
|
253
|
+
|
|
254
|
+
# Filter out values that match defaults
|
|
255
|
+
filtered_config = {}
|
|
256
|
+
for key, value in config_dict.items():
|
|
257
|
+
if key in default_dict:
|
|
258
|
+
if json.dumps(value, sort_keys=True) != json.dumps(
|
|
259
|
+
default_dict[key], sort_keys=True
|
|
260
|
+
):
|
|
261
|
+
filtered_config[key] = value
|
|
262
|
+
else:
|
|
263
|
+
filtered_config[key] = value
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
267
|
+
json.dump(filtered_config, f, indent=2, ensure_ascii=False)
|
|
268
|
+
except (PermissionError, OSError) as e:
|
|
269
|
+
logger.warning(f"Could not save config to {file_path}: {e}")
|
|
270
|
+
|
|
271
|
+
def _load_config(
|
|
272
|
+
self, file_path: Path, default_config: Any, throw_on_invalid: bool = False
|
|
273
|
+
) -> Any:
|
|
274
|
+
"""Load configuration from file."""
|
|
275
|
+
logger.debug(f"Loading config from {file_path}")
|
|
276
|
+
|
|
277
|
+
if not file_path.exists():
|
|
278
|
+
logger.debug(f"Config file {file_path} does not exist, using defaults")
|
|
279
|
+
return deepcopy(default_config)
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
283
|
+
content = f.read()
|
|
284
|
+
|
|
285
|
+
parsed_config = self._safe_parse_json(content)
|
|
286
|
+
if parsed_config is None:
|
|
287
|
+
if throw_on_invalid:
|
|
288
|
+
raise ConfigParseError(
|
|
289
|
+
f"Invalid JSON in {file_path}", str(file_path), default_config
|
|
290
|
+
)
|
|
291
|
+
logger.warning(f"Invalid JSON in {file_path}, using defaults")
|
|
292
|
+
return deepcopy(default_config)
|
|
293
|
+
|
|
294
|
+
# Merge with defaults
|
|
295
|
+
if hasattr(default_config, "__dataclass_fields__"):
|
|
296
|
+
# Handle dataclass
|
|
297
|
+
default_dict = asdict(default_config)
|
|
298
|
+
merged_dict = {**default_dict, **parsed_config}
|
|
299
|
+
# Convert back to dataclass
|
|
300
|
+
return type(default_config)(**merged_dict)
|
|
301
|
+
else:
|
|
302
|
+
# Handle regular dict
|
|
303
|
+
return {**deepcopy(default_config), **parsed_config}
|
|
304
|
+
|
|
305
|
+
except Exception as e:
|
|
306
|
+
if throw_on_invalid:
|
|
307
|
+
raise ConfigParseError(str(e), str(file_path), default_config)
|
|
308
|
+
logger.warning(
|
|
309
|
+
f"Error loading config from {file_path}: {e}, using defaults"
|
|
310
|
+
)
|
|
311
|
+
return deepcopy(default_config)
|
|
312
|
+
|
|
313
|
+
def get_global_config(self) -> GlobalConfig:
|
|
314
|
+
"""Get global configuration."""
|
|
315
|
+
if self._is_test_env() and self._test_global_config is not None:
|
|
316
|
+
return self._test_global_config
|
|
317
|
+
|
|
318
|
+
config = self._load_config(self.global_config_file, GlobalConfig())
|
|
319
|
+
return self._migrate_model_profiles_remove_id(config)
|
|
320
|
+
|
|
321
|
+
def save_global_config(self, config: GlobalConfig) -> None:
|
|
322
|
+
"""Save global configuration."""
|
|
323
|
+
if self._is_test_env():
|
|
324
|
+
self._test_global_config = config
|
|
325
|
+
return
|
|
326
|
+
|
|
327
|
+
# Preserve projects when saving global config
|
|
328
|
+
current_config = self._load_config(self.global_config_file, GlobalConfig())
|
|
329
|
+
config.projects = current_config.projects
|
|
330
|
+
|
|
331
|
+
self._save_config(self.global_config_file, config, GlobalConfig())
|
|
332
|
+
|
|
333
|
+
def get_current_project_config(self) -> ProjectConfig:
|
|
334
|
+
"""Get current project configuration."""
|
|
335
|
+
if self._is_test_env() and self._test_project_config is not None:
|
|
336
|
+
return self._test_project_config
|
|
337
|
+
|
|
338
|
+
project_path = self._get_current_project_path()
|
|
339
|
+
global_config = self.get_global_config()
|
|
340
|
+
|
|
341
|
+
if not global_config.projects:
|
|
342
|
+
return self._default_project_config(project_path)
|
|
343
|
+
|
|
344
|
+
project_config_data = global_config.projects.get(project_path)
|
|
345
|
+
if project_config_data is None:
|
|
346
|
+
return self._default_project_config(project_path)
|
|
347
|
+
|
|
348
|
+
# Convert dict to ProjectConfig instance if needed
|
|
349
|
+
if isinstance(project_config_data, dict):
|
|
350
|
+
# Handle legacy string format for allowed_tools
|
|
351
|
+
if isinstance(project_config_data.get("allowed_tools"), str):
|
|
352
|
+
try:
|
|
353
|
+
project_config_data["allowed_tools"] = json.loads(
|
|
354
|
+
project_config_data["allowed_tools"]
|
|
355
|
+
)
|
|
356
|
+
except json.JSONDecodeError:
|
|
357
|
+
project_config_data["allowed_tools"] = []
|
|
358
|
+
|
|
359
|
+
# Create ProjectConfig instance from dict
|
|
360
|
+
project_config = ProjectConfig(**project_config_data)
|
|
361
|
+
else:
|
|
362
|
+
# Already a ProjectConfig instance
|
|
363
|
+
project_config = project_config_data
|
|
364
|
+
# Handle legacy string format for allowed_tools
|
|
365
|
+
if isinstance(project_config.allowed_tools, str):
|
|
366
|
+
try:
|
|
367
|
+
project_config.allowed_tools = json.loads(
|
|
368
|
+
project_config.allowed_tools
|
|
369
|
+
)
|
|
370
|
+
except json.JSONDecodeError:
|
|
371
|
+
project_config.allowed_tools = []
|
|
372
|
+
|
|
373
|
+
return project_config
|
|
374
|
+
|
|
375
|
+
def save_current_project_config(self, project_config: ProjectConfig) -> None:
|
|
376
|
+
"""Save current project configuration."""
|
|
377
|
+
if self._is_test_env():
|
|
378
|
+
self._test_project_config = project_config
|
|
379
|
+
return
|
|
380
|
+
|
|
381
|
+
project_path = self._get_current_project_path()
|
|
382
|
+
global_config = self.get_global_config()
|
|
383
|
+
|
|
384
|
+
if global_config.projects is None:
|
|
385
|
+
global_config.projects = {}
|
|
386
|
+
|
|
387
|
+
global_config.projects[project_path] = project_config
|
|
388
|
+
self._save_config(self.global_config_file, global_config, GlobalConfig())
|
|
389
|
+
|
|
390
|
+
def get_anthropic_api_key(self) -> Optional[str]:
|
|
391
|
+
"""Get Anthropic API key from environment."""
|
|
392
|
+
return os.getenv("ANTHROPIC_API_KEY")
|
|
393
|
+
|
|
394
|
+
def get_openai_api_key(self) -> Optional[str]:
|
|
395
|
+
"""Get OpenAI API key from environment."""
|
|
396
|
+
return os.getenv("OPENAI_API_KEY")
|
|
397
|
+
|
|
398
|
+
def normalize_api_key_for_config(self, api_key: str) -> str:
|
|
399
|
+
"""Normalize API key for configuration storage."""
|
|
400
|
+
return api_key[-20:] if api_key else ""
|
|
401
|
+
|
|
402
|
+
def get_custom_api_key_status(
|
|
403
|
+
self, truncated_api_key: str
|
|
404
|
+
) -> Literal["approved", "rejected", "new"]:
|
|
405
|
+
"""Get custom API key status."""
|
|
406
|
+
config = self.get_global_config()
|
|
407
|
+
responses = config.custom_api_key_responses or {"approved": [], "rejected": []}
|
|
408
|
+
|
|
409
|
+
if truncated_api_key in responses.get("approved", []):
|
|
410
|
+
return "approved"
|
|
411
|
+
if truncated_api_key in responses.get("rejected", []):
|
|
412
|
+
return "rejected"
|
|
413
|
+
return "new"
|
|
414
|
+
|
|
415
|
+
def get_or_create_user_id(self) -> str:
|
|
416
|
+
"""Get or create user ID."""
|
|
417
|
+
config = self.get_global_config()
|
|
418
|
+
if config.user_id:
|
|
419
|
+
return config.user_id
|
|
420
|
+
|
|
421
|
+
user_id = secrets.token_hex(32)
|
|
422
|
+
config.user_id = user_id
|
|
423
|
+
self.save_global_config(config)
|
|
424
|
+
return user_id
|
|
425
|
+
|
|
426
|
+
def check_has_trust_dialog_accepted(self) -> bool:
|
|
427
|
+
"""Check if trust dialog has been accepted for current or parent directories."""
|
|
428
|
+
current_path = Path(self._get_current_project_path())
|
|
429
|
+
config = self.get_global_config()
|
|
430
|
+
|
|
431
|
+
# Check current and parent directories
|
|
432
|
+
for path in [current_path] + list(current_path.parents):
|
|
433
|
+
path_str = str(path)
|
|
434
|
+
project_config = config.projects.get(path_str) if config.projects else None
|
|
435
|
+
if project_config and project_config.has_trust_dialog_accepted:
|
|
436
|
+
return True
|
|
437
|
+
|
|
438
|
+
return False
|
|
439
|
+
|
|
440
|
+
def _migrate_model_profiles_remove_id(self, config: GlobalConfig) -> GlobalConfig:
|
|
441
|
+
"""Migrate model profiles to remove ID field and update pointers."""
|
|
442
|
+
if not config.model_profiles:
|
|
443
|
+
return config
|
|
444
|
+
|
|
445
|
+
# Build ID to model_name mapping and remove id field
|
|
446
|
+
id_to_model_name = {}
|
|
447
|
+
migrated_profiles = []
|
|
448
|
+
|
|
449
|
+
for profile in config.model_profiles:
|
|
450
|
+
profile_dict = (
|
|
451
|
+
asdict(profile) if hasattr(profile, "__dataclass_fields__") else profile
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
# Build mapping before removing id field
|
|
455
|
+
if "id" in profile_dict and "model_name" in profile_dict:
|
|
456
|
+
id_to_model_name[profile_dict["id"]] = profile_dict["model_name"]
|
|
457
|
+
|
|
458
|
+
# Remove id field
|
|
459
|
+
profile_dict.pop("id", None)
|
|
460
|
+
migrated_profiles.append(ModelProfile(**profile_dict))
|
|
461
|
+
|
|
462
|
+
# Migrate model pointers
|
|
463
|
+
migrated_pointers = ModelPointers()
|
|
464
|
+
if config.model_pointers:
|
|
465
|
+
pointers_dict = (
|
|
466
|
+
asdict(config.model_pointers)
|
|
467
|
+
if hasattr(config.model_pointers, "__dataclass_fields__")
|
|
468
|
+
else config.model_pointers
|
|
469
|
+
)
|
|
470
|
+
for pointer, value in pointers_dict.items():
|
|
471
|
+
if value:
|
|
472
|
+
model_name = id_to_model_name.get(value, value)
|
|
473
|
+
setattr(migrated_pointers, pointer, model_name)
|
|
474
|
+
|
|
475
|
+
# Migrate legacy fields
|
|
476
|
+
default_model_name = config.default_model_name
|
|
477
|
+
if hasattr(config, "default_model_id"):
|
|
478
|
+
default_model_name = id_to_model_name.get(
|
|
479
|
+
getattr(config, "default_model_id"), getattr(config, "default_model_id")
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
config.model_profiles = migrated_profiles
|
|
483
|
+
config.model_pointers = migrated_pointers
|
|
484
|
+
config.default_model_name = default_model_name
|
|
485
|
+
|
|
486
|
+
return config
|
|
487
|
+
|
|
488
|
+
# GPT-5 specific functions
|
|
489
|
+
def is_gpt5_model_name(self, model_name: str) -> bool:
|
|
490
|
+
"""Check if a model name represents a GPT-5 model."""
|
|
491
|
+
if not model_name or not isinstance(model_name, str):
|
|
492
|
+
return False
|
|
493
|
+
return "gpt-5" in model_name.lower()
|
|
494
|
+
|
|
495
|
+
def validate_and_repair_gpt5_profile(self, profile: ModelProfile) -> ModelProfile:
|
|
496
|
+
"""Validate and auto-repair GPT-5 model configuration."""
|
|
497
|
+
is_gpt5 = self.is_gpt5_model_name(profile.model_name)
|
|
498
|
+
now = int(os.time.time() * 1000)
|
|
499
|
+
|
|
500
|
+
# Create working copy
|
|
501
|
+
repaired_profile = deepcopy(profile)
|
|
502
|
+
was_repaired = False
|
|
503
|
+
|
|
504
|
+
# Set GPT-5 detection flag
|
|
505
|
+
if is_gpt5 != profile.is_gpt5:
|
|
506
|
+
repaired_profile.is_gpt5 = is_gpt5
|
|
507
|
+
was_repaired = True
|
|
508
|
+
|
|
509
|
+
if is_gpt5:
|
|
510
|
+
# GPT-5 parameter validation and repair
|
|
511
|
+
valid_reasoning_efforts = ["minimal", "low", "medium", "high"]
|
|
512
|
+
if (
|
|
513
|
+
not profile.reasoning_effort
|
|
514
|
+
or profile.reasoning_effort not in valid_reasoning_efforts
|
|
515
|
+
):
|
|
516
|
+
repaired_profile.reasoning_effort = "medium"
|
|
517
|
+
was_repaired = True
|
|
518
|
+
logger.info(
|
|
519
|
+
f"🔧 GPT-5 Config: Set reasoning effort to 'medium' for {profile.model_name}"
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
# Context length validation
|
|
523
|
+
if profile.context_length < 128000:
|
|
524
|
+
repaired_profile.context_length = 128000
|
|
525
|
+
was_repaired = True
|
|
526
|
+
logger.info(
|
|
527
|
+
f"🔧 GPT-5 Config: Updated context length to 128k for {profile.model_name}"
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
# Output tokens validation
|
|
531
|
+
if profile.max_tokens < 4000:
|
|
532
|
+
repaired_profile.max_tokens = 8192
|
|
533
|
+
was_repaired = True
|
|
534
|
+
logger.info(
|
|
535
|
+
f"🔧 GPT-5 Config: Updated max tokens to 8192 for {profile.model_name}"
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
# Base URL validation
|
|
539
|
+
if "gpt-5" in profile.model_name and not profile.base_url:
|
|
540
|
+
repaired_profile.base_url = "https://api.openai.com/v1"
|
|
541
|
+
was_repaired = True
|
|
542
|
+
logger.info(
|
|
543
|
+
f"🔧 GPT-5 Config: Set default base URL for {profile.model_name}"
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
# Update validation metadata
|
|
547
|
+
repaired_profile.validation_status = (
|
|
548
|
+
"auto_repaired" if was_repaired else "valid"
|
|
549
|
+
)
|
|
550
|
+
repaired_profile.last_validation = now
|
|
551
|
+
|
|
552
|
+
if was_repaired:
|
|
553
|
+
logger.info(
|
|
554
|
+
f"✅ GPT-5 Config: Auto-repaired configuration for {profile.model_name}"
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
return repaired_profile
|
|
558
|
+
|
|
559
|
+
def set_model_pointer(self, pointer: ModelPointerType, model_name: str) -> None:
|
|
560
|
+
"""Set a model pointer to a specific model."""
|
|
561
|
+
config = self.get_global_config()
|
|
562
|
+
if config.model_pointers is None:
|
|
563
|
+
config.model_pointers = ModelPointers()
|
|
564
|
+
|
|
565
|
+
setattr(config.model_pointers, pointer, model_name)
|
|
566
|
+
self.save_global_config(config)
|
|
567
|
+
|
|
568
|
+
def set_all_pointers_to_model(self, model_name: str) -> None:
|
|
569
|
+
"""Set all model pointers to the same model."""
|
|
570
|
+
config = self.get_global_config()
|
|
571
|
+
config.model_pointers = ModelPointers(
|
|
572
|
+
main=model_name, task=model_name, reasoning=model_name, quick=model_name
|
|
573
|
+
)
|
|
574
|
+
config.default_model_name = model_name
|
|
575
|
+
self.save_global_config(config)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
# Global instance
|
|
579
|
+
config_manager = ConfigManager()
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
# Convenience functions
|
|
583
|
+
def get_global_config() -> GlobalConfig:
|
|
584
|
+
"""Get global configuration."""
|
|
585
|
+
return config_manager.get_global_config()
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def save_global_config(config: GlobalConfig) -> None:
|
|
589
|
+
"""Save global configuration."""
|
|
590
|
+
config_manager.save_global_config(config)
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def get_current_project_config() -> ProjectConfig:
|
|
594
|
+
"""Get current project configuration."""
|
|
595
|
+
return config_manager.get_current_project_config()
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def save_current_project_config(config: ProjectConfig) -> None:
|
|
599
|
+
"""Save current project configuration."""
|
|
600
|
+
config_manager.save_current_project_config(config)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def get_anthropic_api_key() -> Optional[str]:
|
|
604
|
+
"""Get Anthropic API key."""
|
|
605
|
+
return config_manager.get_anthropic_api_key()
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def get_openai_api_key() -> Optional[str]:
|
|
609
|
+
"""Get OpenAI API key."""
|
|
610
|
+
return config_manager.get_openai_api_key()
|