shotgun-sh 0.2.8.dev2__py3-none-any.whl → 0.3.3.dev1__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.
- shotgun/agents/agent_manager.py +382 -60
- shotgun/agents/common.py +15 -9
- shotgun/agents/config/README.md +89 -0
- shotgun/agents/config/__init__.py +10 -1
- shotgun/agents/config/constants.py +0 -6
- shotgun/agents/config/manager.py +383 -82
- shotgun/agents/config/models.py +122 -18
- shotgun/agents/config/provider.py +81 -15
- shotgun/agents/config/streaming_test.py +119 -0
- shotgun/agents/context_analyzer/__init__.py +28 -0
- shotgun/agents/context_analyzer/analyzer.py +475 -0
- shotgun/agents/context_analyzer/constants.py +9 -0
- shotgun/agents/context_analyzer/formatter.py +115 -0
- shotgun/agents/context_analyzer/models.py +212 -0
- shotgun/agents/conversation/__init__.py +18 -0
- shotgun/agents/conversation/filters.py +164 -0
- shotgun/agents/conversation/history/chunking.py +278 -0
- shotgun/agents/{history → conversation/history}/compaction.py +36 -5
- shotgun/agents/{history → conversation/history}/constants.py +5 -0
- shotgun/agents/conversation/history/file_content_deduplication.py +216 -0
- shotgun/agents/{history → conversation/history}/history_processors.py +380 -8
- shotgun/agents/{history → conversation/history}/token_counting/anthropic.py +25 -1
- shotgun/agents/{history → conversation/history}/token_counting/base.py +14 -3
- shotgun/agents/{history → conversation/history}/token_counting/openai.py +11 -1
- shotgun/agents/{history → conversation/history}/token_counting/sentencepiece_counter.py +8 -0
- shotgun/agents/{history → conversation/history}/token_counting/tokenizer_cache.py +3 -1
- shotgun/agents/{history → conversation/history}/token_counting/utils.py +0 -3
- shotgun/agents/{conversation_manager.py → conversation/manager.py} +36 -20
- shotgun/agents/{conversation_history.py → conversation/models.py} +8 -92
- shotgun/agents/error/__init__.py +11 -0
- shotgun/agents/error/models.py +19 -0
- shotgun/agents/export.py +2 -2
- shotgun/agents/plan.py +2 -2
- shotgun/agents/research.py +3 -3
- shotgun/agents/runner.py +230 -0
- shotgun/agents/specify.py +2 -2
- shotgun/agents/tasks.py +2 -2
- shotgun/agents/tools/codebase/codebase_shell.py +6 -0
- shotgun/agents/tools/codebase/directory_lister.py +6 -0
- shotgun/agents/tools/codebase/file_read.py +11 -2
- shotgun/agents/tools/codebase/query_graph.py +6 -0
- shotgun/agents/tools/codebase/retrieve_code.py +6 -0
- shotgun/agents/tools/file_management.py +27 -7
- shotgun/agents/tools/registry.py +217 -0
- shotgun/agents/tools/web_search/__init__.py +8 -8
- shotgun/agents/tools/web_search/anthropic.py +8 -2
- shotgun/agents/tools/web_search/gemini.py +7 -1
- shotgun/agents/tools/web_search/openai.py +8 -2
- shotgun/agents/tools/web_search/utils.py +2 -2
- shotgun/agents/usage_manager.py +16 -11
- shotgun/api_endpoints.py +7 -3
- shotgun/build_constants.py +2 -2
- shotgun/cli/clear.py +53 -0
- shotgun/cli/compact.py +188 -0
- shotgun/cli/config.py +8 -5
- shotgun/cli/context.py +154 -0
- shotgun/cli/error_handler.py +24 -0
- shotgun/cli/export.py +34 -34
- shotgun/cli/feedback.py +4 -2
- shotgun/cli/models.py +1 -0
- shotgun/cli/plan.py +34 -34
- shotgun/cli/research.py +18 -10
- shotgun/cli/spec/__init__.py +5 -0
- shotgun/cli/spec/backup.py +81 -0
- shotgun/cli/spec/commands.py +132 -0
- shotgun/cli/spec/models.py +48 -0
- shotgun/cli/spec/pull_service.py +219 -0
- shotgun/cli/specify.py +20 -19
- shotgun/cli/tasks.py +34 -34
- shotgun/cli/update.py +16 -2
- shotgun/codebase/core/change_detector.py +5 -3
- shotgun/codebase/core/code_retrieval.py +4 -2
- shotgun/codebase/core/ingestor.py +163 -15
- shotgun/codebase/core/manager.py +13 -4
- shotgun/codebase/core/nl_query.py +1 -1
- shotgun/codebase/models.py +2 -0
- shotgun/exceptions.py +357 -0
- shotgun/llm_proxy/__init__.py +17 -0
- shotgun/llm_proxy/client.py +215 -0
- shotgun/llm_proxy/models.py +137 -0
- shotgun/logging_config.py +60 -27
- shotgun/main.py +77 -11
- shotgun/posthog_telemetry.py +38 -29
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +28 -2
- shotgun/prompts/agents/partials/interactive_mode.j2 +3 -3
- shotgun/prompts/agents/plan.j2 +16 -0
- shotgun/prompts/agents/research.j2 +16 -3
- shotgun/prompts/agents/specify.j2 +54 -1
- shotgun/prompts/agents/state/system_state.j2 +0 -2
- shotgun/prompts/agents/tasks.j2 +16 -0
- shotgun/prompts/history/chunk_summarization.j2 +34 -0
- shotgun/prompts/history/combine_summaries.j2 +53 -0
- shotgun/sdk/codebase.py +14 -3
- shotgun/sentry_telemetry.py +163 -16
- shotgun/settings.py +243 -0
- shotgun/shotgun_web/__init__.py +67 -1
- shotgun/shotgun_web/client.py +42 -1
- shotgun/shotgun_web/constants.py +46 -0
- shotgun/shotgun_web/exceptions.py +29 -0
- shotgun/shotgun_web/models.py +390 -0
- shotgun/shotgun_web/shared_specs/__init__.py +32 -0
- shotgun/shotgun_web/shared_specs/file_scanner.py +175 -0
- shotgun/shotgun_web/shared_specs/hasher.py +83 -0
- shotgun/shotgun_web/shared_specs/models.py +71 -0
- shotgun/shotgun_web/shared_specs/upload_pipeline.py +329 -0
- shotgun/shotgun_web/shared_specs/utils.py +34 -0
- shotgun/shotgun_web/specs_client.py +703 -0
- shotgun/shotgun_web/supabase_client.py +31 -0
- shotgun/telemetry.py +10 -33
- shotgun/tui/app.py +310 -46
- shotgun/tui/commands/__init__.py +1 -1
- shotgun/tui/components/context_indicator.py +179 -0
- shotgun/tui/components/mode_indicator.py +70 -0
- shotgun/tui/components/status_bar.py +48 -0
- shotgun/tui/containers.py +91 -0
- shotgun/tui/dependencies.py +39 -0
- shotgun/tui/layout.py +5 -0
- shotgun/tui/protocols.py +45 -0
- shotgun/tui/screens/chat/__init__.py +5 -0
- shotgun/tui/screens/chat/chat.tcss +54 -0
- shotgun/tui/screens/chat/chat_screen.py +1531 -0
- shotgun/tui/screens/chat/codebase_index_prompt_screen.py +243 -0
- shotgun/tui/screens/chat/codebase_index_selection.py +12 -0
- shotgun/tui/screens/chat/help_text.py +40 -0
- shotgun/tui/screens/chat/prompt_history.py +48 -0
- shotgun/tui/screens/chat.tcss +11 -0
- shotgun/tui/screens/chat_screen/command_providers.py +91 -4
- shotgun/tui/screens/chat_screen/hint_message.py +76 -1
- shotgun/tui/screens/chat_screen/history/__init__.py +22 -0
- shotgun/tui/screens/chat_screen/history/agent_response.py +66 -0
- shotgun/tui/screens/chat_screen/history/chat_history.py +115 -0
- shotgun/tui/screens/chat_screen/history/formatters.py +115 -0
- shotgun/tui/screens/chat_screen/history/partial_response.py +43 -0
- shotgun/tui/screens/chat_screen/history/user_question.py +42 -0
- shotgun/tui/screens/confirmation_dialog.py +191 -0
- shotgun/tui/screens/directory_setup.py +45 -41
- shotgun/tui/screens/feedback.py +14 -7
- shotgun/tui/screens/github_issue.py +111 -0
- shotgun/tui/screens/model_picker.py +77 -32
- shotgun/tui/screens/onboarding.py +580 -0
- shotgun/tui/screens/pipx_migration.py +205 -0
- shotgun/tui/screens/provider_config.py +116 -35
- shotgun/tui/screens/shared_specs/__init__.py +21 -0
- shotgun/tui/screens/shared_specs/create_spec_dialog.py +273 -0
- shotgun/tui/screens/shared_specs/models.py +56 -0
- shotgun/tui/screens/shared_specs/share_specs_dialog.py +390 -0
- shotgun/tui/screens/shared_specs/upload_progress_screen.py +452 -0
- shotgun/tui/screens/shotgun_auth.py +112 -18
- shotgun/tui/screens/spec_pull.py +288 -0
- shotgun/tui/screens/welcome.py +137 -11
- shotgun/tui/services/__init__.py +5 -0
- shotgun/tui/services/conversation_service.py +187 -0
- shotgun/tui/state/__init__.py +7 -0
- shotgun/tui/state/processing_state.py +185 -0
- shotgun/tui/utils/mode_progress.py +14 -7
- shotgun/tui/widgets/__init__.py +5 -0
- shotgun/tui/widgets/widget_coordinator.py +263 -0
- shotgun/utils/file_system_utils.py +22 -2
- shotgun/utils/marketing.py +110 -0
- shotgun/utils/update_checker.py +69 -14
- shotgun_sh-0.3.3.dev1.dist-info/METADATA +472 -0
- shotgun_sh-0.3.3.dev1.dist-info/RECORD +229 -0
- {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/WHEEL +1 -1
- {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/entry_points.txt +1 -0
- {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/licenses/LICENSE +1 -1
- shotgun/tui/screens/chat.py +0 -996
- shotgun/tui/screens/chat_screen/history.py +0 -335
- shotgun_sh-0.2.8.dev2.dist-info/METADATA +0 -126
- shotgun_sh-0.2.8.dev2.dist-info/RECORD +0 -155
- /shotgun/agents/{history → conversation/history}/__init__.py +0 -0
- /shotgun/agents/{history → conversation/history}/context_extraction.py +0 -0
- /shotgun/agents/{history → conversation/history}/history_building.py +0 -0
- /shotgun/agents/{history → conversation/history}/message_utils.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/__init__.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_estimation.py +0 -0
shotgun/agents/config/manager.py
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
"""Configuration manager for Shotgun CLI."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import shutil
|
|
4
5
|
import uuid
|
|
6
|
+
from datetime import datetime
|
|
5
7
|
from pathlib import Path
|
|
6
8
|
from typing import Any
|
|
7
9
|
|
|
10
|
+
import aiofiles
|
|
11
|
+
import aiofiles.os
|
|
8
12
|
from pydantic import SecretStr
|
|
9
13
|
|
|
10
14
|
from shotgun.logging_config import get_logger
|
|
@@ -28,9 +32,196 @@ from .models import (
|
|
|
28
32
|
|
|
29
33
|
logger = get_logger(__name__)
|
|
30
34
|
|
|
35
|
+
|
|
36
|
+
class ConfigMigrationError(Exception):
|
|
37
|
+
"""Exception raised when config migration fails."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, message: str, backup_path: Path | None = None):
|
|
40
|
+
"""Initialize with error message and optional backup path.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
message: Error message describing what went wrong
|
|
44
|
+
backup_path: Path to backup file if one was created
|
|
45
|
+
"""
|
|
46
|
+
self.backup_path = backup_path
|
|
47
|
+
super().__init__(message)
|
|
48
|
+
|
|
49
|
+
|
|
31
50
|
# Type alias for provider configuration objects
|
|
32
51
|
ProviderConfig = OpenAIConfig | AnthropicConfig | GoogleConfig | ShotgunAccountConfig
|
|
33
52
|
|
|
53
|
+
# Current config version
|
|
54
|
+
CURRENT_CONFIG_VERSION = 5
|
|
55
|
+
|
|
56
|
+
# Backup directory name
|
|
57
|
+
BACKUP_DIR_NAME = "backup"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_backup_dir(config_path: Path) -> Path:
|
|
61
|
+
"""Get the backup directory path for a given config file.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
config_path: Path to the config file
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Path to the backup directory (e.g., ~/.shotgun-sh/backup/)
|
|
68
|
+
"""
|
|
69
|
+
return config_path.parent / BACKUP_DIR_NAME
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _create_backup(config_path: Path) -> Path:
|
|
73
|
+
"""Create a timestamped backup of the config file before migration.
|
|
74
|
+
|
|
75
|
+
Backups are saved to ~/.shotgun-sh/backup/ directory.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
config_path: Path to the config file to backup
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Path to the backup file in the backup directory
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
OSError: If backup creation fails
|
|
85
|
+
"""
|
|
86
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
87
|
+
backup_dir = get_backup_dir(config_path)
|
|
88
|
+
backup_path = backup_dir / f"config.backup.{timestamp}.json"
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
# Create backup directory if it doesn't exist
|
|
92
|
+
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
shutil.copy2(config_path, backup_path)
|
|
94
|
+
logger.info(f"Created config backup at {backup_path}")
|
|
95
|
+
return backup_path
|
|
96
|
+
except Exception as e:
|
|
97
|
+
logger.error(f"Failed to create config backup: {e}")
|
|
98
|
+
raise OSError(f"Failed to create config backup: {e}") from e
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _migrate_v2_to_v3(data: dict[str, Any]) -> dict[str, Any]:
|
|
102
|
+
"""Migrate config from version 2 to version 3.
|
|
103
|
+
|
|
104
|
+
Changes:
|
|
105
|
+
- Rename 'user_id' field to 'shotgun_instance_id'
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
data: Config data dict at version 2
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Modified config data dict at version 3
|
|
112
|
+
"""
|
|
113
|
+
if "user_id" in data and SHOTGUN_INSTANCE_ID_FIELD not in data:
|
|
114
|
+
data[SHOTGUN_INSTANCE_ID_FIELD] = data.pop("user_id")
|
|
115
|
+
data["config_version"] = 3
|
|
116
|
+
logger.info("Migrated config v2->v3: renamed user_id to shotgun_instance_id")
|
|
117
|
+
|
|
118
|
+
return data
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _migrate_v3_to_v4(data: dict[str, Any]) -> dict[str, Any]:
|
|
122
|
+
"""Migrate config from version 3 to version 4.
|
|
123
|
+
|
|
124
|
+
Changes:
|
|
125
|
+
- Add 'marketing' field with empty messages dict
|
|
126
|
+
- Set 'shown_welcome_screen' to False for existing BYOK users
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
data: Config data dict at version 3
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Modified config data dict at version 4
|
|
133
|
+
"""
|
|
134
|
+
# Add marketing config
|
|
135
|
+
if "marketing" not in data:
|
|
136
|
+
data["marketing"] = {"messages": {}}
|
|
137
|
+
logger.info("Migrated config v3->v4: added marketing configuration")
|
|
138
|
+
|
|
139
|
+
# Set shown_welcome_screen for existing BYOK users
|
|
140
|
+
# If shown_welcome_screen doesn't exist AND any BYOK provider has a key,
|
|
141
|
+
# set it to False so they see the welcome screen once
|
|
142
|
+
if "shown_welcome_screen" not in data:
|
|
143
|
+
has_byok_key = False
|
|
144
|
+
for section in ["openai", "anthropic", "google"]:
|
|
145
|
+
if (
|
|
146
|
+
section in data
|
|
147
|
+
and isinstance(data[section], dict)
|
|
148
|
+
and data[section].get("api_key")
|
|
149
|
+
):
|
|
150
|
+
has_byok_key = True
|
|
151
|
+
break
|
|
152
|
+
|
|
153
|
+
if has_byok_key:
|
|
154
|
+
data["shown_welcome_screen"] = False
|
|
155
|
+
logger.info(
|
|
156
|
+
"Existing BYOK user detected: set shown_welcome_screen=False to show welcome screen"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
data["config_version"] = 4
|
|
160
|
+
return data
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _migrate_v4_to_v5(data: dict[str, Any]) -> dict[str, Any]:
|
|
164
|
+
"""Migrate config from version 4 to version 5.
|
|
165
|
+
|
|
166
|
+
Changes:
|
|
167
|
+
- Add 'supports_streaming' field to OpenAI config (initially None for auto-detection)
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
data: Config data dict at version 4
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
Modified config data dict at version 5
|
|
174
|
+
"""
|
|
175
|
+
if "openai" in data and isinstance(data["openai"], dict):
|
|
176
|
+
if "supports_streaming" not in data["openai"]:
|
|
177
|
+
data["openai"]["supports_streaming"] = None
|
|
178
|
+
logger.info(
|
|
179
|
+
"Migrated config v4->v5: added streaming capability detection for OpenAI"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
data["config_version"] = 5
|
|
183
|
+
return data
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _apply_migrations(data: dict[str, Any]) -> dict[str, Any]:
|
|
187
|
+
"""Apply all necessary migrations to bring config to current version.
|
|
188
|
+
|
|
189
|
+
Migrations are applied sequentially from the config's current version
|
|
190
|
+
to CURRENT_CONFIG_VERSION.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
data: Config data dict at any version
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
Config data dict at CURRENT_CONFIG_VERSION
|
|
197
|
+
"""
|
|
198
|
+
# Get current version (default to 2 for very old configs)
|
|
199
|
+
current_version = data.get("config_version", 2)
|
|
200
|
+
|
|
201
|
+
# Define migrations in order
|
|
202
|
+
migrations = {
|
|
203
|
+
2: _migrate_v2_to_v3,
|
|
204
|
+
3: _migrate_v3_to_v4,
|
|
205
|
+
4: _migrate_v4_to_v5,
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
# Apply migrations sequentially
|
|
209
|
+
while current_version < CURRENT_CONFIG_VERSION:
|
|
210
|
+
if current_version in migrations:
|
|
211
|
+
logger.info(
|
|
212
|
+
f"Applying migration from v{current_version} to v{current_version + 1}"
|
|
213
|
+
)
|
|
214
|
+
data = migrations[current_version](data)
|
|
215
|
+
current_version = data.get("config_version", current_version + 1)
|
|
216
|
+
else:
|
|
217
|
+
logger.warning(
|
|
218
|
+
f"No migration defined for v{current_version}, skipping to v{current_version + 1}"
|
|
219
|
+
)
|
|
220
|
+
current_version += 1
|
|
221
|
+
data["config_version"] = current_version
|
|
222
|
+
|
|
223
|
+
return data
|
|
224
|
+
|
|
34
225
|
|
|
35
226
|
class ConfigManager:
|
|
36
227
|
"""Manager for Shotgun configuration."""
|
|
@@ -48,7 +239,7 @@ class ConfigManager:
|
|
|
48
239
|
|
|
49
240
|
self._config: ShotgunConfig | None = None
|
|
50
241
|
|
|
51
|
-
def load(self, force_reload: bool = True) -> ShotgunConfig:
|
|
242
|
+
async def load(self, force_reload: bool = True) -> ShotgunConfig:
|
|
52
243
|
"""Load configuration from file.
|
|
53
244
|
|
|
54
245
|
Args:
|
|
@@ -60,73 +251,97 @@ class ConfigManager:
|
|
|
60
251
|
if self._config is not None and not force_reload:
|
|
61
252
|
return self._config
|
|
62
253
|
|
|
63
|
-
if not
|
|
254
|
+
if not await aiofiles.os.path.exists(self.config_path):
|
|
64
255
|
logger.info(
|
|
65
256
|
"Configuration file not found, creating new config at: %s",
|
|
66
257
|
self.config_path,
|
|
67
258
|
)
|
|
68
259
|
# Create new config with generated shotgun_instance_id
|
|
69
|
-
self._config = self.initialize()
|
|
260
|
+
self._config = await self.initialize()
|
|
70
261
|
return self._config
|
|
71
262
|
|
|
263
|
+
backup_path: Path | None = None
|
|
72
264
|
try:
|
|
73
|
-
with open(self.config_path, encoding="utf-8") as f:
|
|
74
|
-
|
|
265
|
+
async with aiofiles.open(self.config_path, encoding="utf-8") as f:
|
|
266
|
+
content = await f.read()
|
|
267
|
+
data = json.loads(content)
|
|
268
|
+
|
|
269
|
+
# Get current version to determine if migration is needed
|
|
270
|
+
current_version = data.get("config_version", 2)
|
|
75
271
|
|
|
76
|
-
#
|
|
77
|
-
if
|
|
78
|
-
data[SHOTGUN_INSTANCE_ID_FIELD] = data.pop("user_id")
|
|
79
|
-
data["config_version"] = 3
|
|
272
|
+
# Create backup before migration if config needs upgrading
|
|
273
|
+
if current_version < CURRENT_CONFIG_VERSION:
|
|
80
274
|
logger.info(
|
|
81
|
-
"
|
|
275
|
+
f"Config needs migration from v{current_version} to v{CURRENT_CONFIG_VERSION}"
|
|
82
276
|
)
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
277
|
+
try:
|
|
278
|
+
backup_path = _create_backup(self.config_path)
|
|
279
|
+
except OSError as backup_error:
|
|
280
|
+
logger.warning(
|
|
281
|
+
f"Could not create backup before migration: {backup_error}"
|
|
282
|
+
)
|
|
283
|
+
# Continue without backup - better than failing completely
|
|
284
|
+
|
|
285
|
+
# Apply all necessary migrations to bring config to current version
|
|
286
|
+
try:
|
|
287
|
+
data = _apply_migrations(data)
|
|
288
|
+
except Exception as migration_error:
|
|
289
|
+
error_msg = (
|
|
290
|
+
f"Failed to migrate configuration from v{current_version} to v{CURRENT_CONFIG_VERSION}. "
|
|
291
|
+
f"Error: {migration_error}"
|
|
292
|
+
)
|
|
293
|
+
if backup_path:
|
|
294
|
+
error_msg += f"\n\nYour original config has been backed up to:\n{backup_path}"
|
|
295
|
+
error_msg += (
|
|
296
|
+
"\n\nTo start fresh, delete or rename your config file:\n"
|
|
297
|
+
f" rm {self.config_path}\n"
|
|
298
|
+
f" shotgun config init\n\n"
|
|
299
|
+
"To restore your backup:\n"
|
|
300
|
+
f" cp {backup_path} {self.config_path}"
|
|
102
301
|
)
|
|
302
|
+
else:
|
|
303
|
+
error_msg += "\n\nTo start fresh, run: shotgun config init"
|
|
304
|
+
|
|
305
|
+
raise ConfigMigrationError(error_msg, backup_path) from migration_error
|
|
103
306
|
|
|
104
307
|
# Convert plain text secrets to SecretStr objects
|
|
105
308
|
self._convert_secrets_to_secretstr(data)
|
|
106
309
|
|
|
310
|
+
# Clean up invalid selected_model before Pydantic validation
|
|
311
|
+
if "selected_model" in data and data["selected_model"] is not None:
|
|
312
|
+
from .models import MODEL_SPECS, ModelName
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
# Try to convert to ModelName enum
|
|
316
|
+
model_name = ModelName(data["selected_model"])
|
|
317
|
+
# Check if it exists in MODEL_SPECS
|
|
318
|
+
if model_name not in MODEL_SPECS:
|
|
319
|
+
data["selected_model"] = None
|
|
320
|
+
except (ValueError, KeyError):
|
|
321
|
+
# Invalid model name - reset to None
|
|
322
|
+
data["selected_model"] = None
|
|
323
|
+
|
|
107
324
|
self._config = ShotgunConfig.model_validate(data)
|
|
108
325
|
logger.debug("Configuration loaded successfully from %s", self.config_path)
|
|
109
326
|
|
|
110
|
-
#
|
|
111
|
-
|
|
112
|
-
|
|
327
|
+
# Clear migration_failed flag if config loaded successfully
|
|
328
|
+
should_save = False
|
|
329
|
+
if self._config.migration_failed:
|
|
330
|
+
self._config.migration_failed = False
|
|
331
|
+
self._config.migration_backup_path = None
|
|
332
|
+
should_save = True
|
|
113
333
|
|
|
334
|
+
# Validate selected_model for BYOK mode - verify provider has a key
|
|
335
|
+
if not self._provider_has_api_key(self._config.shotgun):
|
|
114
336
|
# If selected_model is set, verify its provider has a key
|
|
115
337
|
if self._config.selected_model:
|
|
116
338
|
from .models import MODEL_SPECS
|
|
117
339
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
logger.info(
|
|
122
|
-
"Selected model %s provider has no API key, finding available model",
|
|
123
|
-
self._config.selected_model.value,
|
|
124
|
-
)
|
|
125
|
-
self._config.selected_model = None
|
|
126
|
-
should_save = True
|
|
127
|
-
else:
|
|
340
|
+
spec = MODEL_SPECS[self._config.selected_model]
|
|
341
|
+
if not await self.has_provider_key(spec.provider):
|
|
342
|
+
# Provider has no key - reset to None
|
|
128
343
|
logger.info(
|
|
129
|
-
"Selected model %s
|
|
344
|
+
"Selected model %s provider has no API key, finding available model",
|
|
130
345
|
self._config.selected_model.value,
|
|
131
346
|
)
|
|
132
347
|
self._config.selected_model = None
|
|
@@ -135,40 +350,80 @@ class ConfigManager:
|
|
|
135
350
|
# If no selected_model or it was invalid, find first available model
|
|
136
351
|
if not self._config.selected_model:
|
|
137
352
|
for provider in ProviderType:
|
|
138
|
-
if self.has_provider_key(provider):
|
|
353
|
+
if await self.has_provider_key(provider):
|
|
139
354
|
# Set to that provider's default model
|
|
140
355
|
from .models import MODEL_SPECS, ModelName
|
|
141
356
|
|
|
142
357
|
# Find default model for this provider
|
|
143
358
|
provider_models = {
|
|
144
|
-
ProviderType.OPENAI: ModelName.
|
|
145
|
-
ProviderType.ANTHROPIC: ModelName.
|
|
359
|
+
ProviderType.OPENAI: ModelName.GPT_5_1,
|
|
360
|
+
ProviderType.ANTHROPIC: ModelName.CLAUDE_HAIKU_4_5,
|
|
146
361
|
ProviderType.GOOGLE: ModelName.GEMINI_2_5_PRO,
|
|
147
362
|
}
|
|
148
363
|
|
|
149
364
|
if provider in provider_models:
|
|
150
365
|
self._config.selected_model = provider_models[provider]
|
|
151
|
-
logger.info(
|
|
152
|
-
"Set selected_model to %s (first available provider)",
|
|
153
|
-
self._config.selected_model.value,
|
|
154
|
-
)
|
|
155
366
|
should_save = True
|
|
156
367
|
break
|
|
157
368
|
|
|
158
369
|
if should_save:
|
|
159
|
-
self.save(self._config)
|
|
370
|
+
await self.save(self._config)
|
|
160
371
|
|
|
161
372
|
return self._config
|
|
162
373
|
|
|
163
|
-
except
|
|
374
|
+
except ConfigMigrationError as migration_error:
|
|
375
|
+
# Migration failed - automatically create fresh config with migration info
|
|
164
376
|
logger.error(
|
|
165
|
-
"
|
|
377
|
+
"Config migration failed, creating fresh config: %s", migration_error
|
|
166
378
|
)
|
|
167
|
-
|
|
168
|
-
|
|
379
|
+
backup_path = migration_error.backup_path
|
|
380
|
+
|
|
381
|
+
# Create fresh config with migration failure info
|
|
382
|
+
self._config = await self.initialize()
|
|
383
|
+
self._config.migration_failed = True
|
|
384
|
+
if backup_path:
|
|
385
|
+
self._config.migration_backup_path = str(backup_path)
|
|
386
|
+
|
|
387
|
+
# Save the fresh config
|
|
388
|
+
await self.save(self._config)
|
|
389
|
+
logger.info("Created fresh config after migration failure")
|
|
390
|
+
|
|
391
|
+
return self._config
|
|
392
|
+
|
|
393
|
+
except json.JSONDecodeError as json_error:
|
|
394
|
+
# Invalid JSON - create backup and fresh config
|
|
395
|
+
logger.error("Config file has invalid JSON: %s", json_error)
|
|
396
|
+
|
|
397
|
+
try:
|
|
398
|
+
backup_path = _create_backup(self.config_path)
|
|
399
|
+
except OSError:
|
|
400
|
+
backup_path = None
|
|
401
|
+
|
|
402
|
+
self._config = await self.initialize()
|
|
403
|
+
self._config.migration_failed = True
|
|
404
|
+
if backup_path:
|
|
405
|
+
self._config.migration_backup_path = str(backup_path)
|
|
406
|
+
|
|
407
|
+
await self.save(self._config)
|
|
408
|
+
logger.info("Created fresh config after JSON parse error")
|
|
409
|
+
|
|
410
|
+
return self._config
|
|
411
|
+
|
|
412
|
+
except Exception as e:
|
|
413
|
+
# Generic error - create fresh config
|
|
414
|
+
logger.error("Failed to load config: %s", e)
|
|
415
|
+
|
|
416
|
+
self._config = await self.initialize()
|
|
417
|
+
self._config.migration_failed = True
|
|
418
|
+
if backup_path:
|
|
419
|
+
self._config.migration_backup_path = str(backup_path)
|
|
420
|
+
|
|
421
|
+
await self.save(self._config)
|
|
422
|
+
logger.info("Created fresh config after load error")
|
|
423
|
+
|
|
169
424
|
return self._config
|
|
170
425
|
|
|
171
|
-
def save(self, config: ShotgunConfig | None = None) -> None:
|
|
426
|
+
async def save(self, config: ShotgunConfig | None = None) -> None:
|
|
172
427
|
"""Save configuration to file.
|
|
173
428
|
|
|
174
429
|
Args:
|
|
@@ -184,15 +439,17 @@ class ConfigManager:
|
|
|
184
439
|
)
|
|
185
440
|
|
|
186
441
|
# Ensure directory exists
|
|
187
|
-
self.config_path.parent
|
|
442
|
+
await aiofiles.os.makedirs(self.config_path.parent, exist_ok=True)
|
|
188
443
|
|
|
189
444
|
try:
|
|
190
445
|
# Convert SecretStr to plain text for JSON serialization
|
|
191
446
|
data = config.model_dump()
|
|
192
447
|
self._convert_secretstr_to_plain(data)
|
|
448
|
+
self._convert_datetime_to_isoformat(data)
|
|
193
449
|
|
|
194
|
-
|
|
195
|
-
|
|
450
|
+
json_content = json.dumps(data, indent=2, ensure_ascii=False)
|
|
451
|
+
async with aiofiles.open(self.config_path, "w", encoding="utf-8") as f:
|
|
452
|
+
await f.write(json_content)
|
|
196
453
|
|
|
197
454
|
logger.debug("Configuration saved to %s", self.config_path)
|
|
198
455
|
self._config = config
|
|
@@ -201,14 +458,16 @@ class ConfigManager:
|
|
|
201
458
|
logger.error("Failed to save configuration to %s: %s", self.config_path, e)
|
|
202
459
|
raise
|
|
203
460
|
|
|
204
|
-
def update_provider(
|
|
461
|
+
async def update_provider(
|
|
462
|
+
self, provider: ProviderType | str, **kwargs: Any
|
|
463
|
+
) -> None:
|
|
205
464
|
"""Update provider configuration.
|
|
206
465
|
|
|
207
466
|
Args:
|
|
208
467
|
provider: Provider to update
|
|
209
468
|
**kwargs: Configuration fields to update (only api_key supported)
|
|
210
469
|
"""
|
|
211
|
-
config = self.load()
|
|
470
|
+
config = await self.load()
|
|
212
471
|
|
|
213
472
|
# Get provider config and check if it's shotgun
|
|
214
473
|
provider_config, is_shotgun = self._get_provider_config_and_type(
|
|
@@ -224,6 +483,11 @@ class ConfigManager:
|
|
|
224
483
|
SecretStr(api_key_value) if api_key_value is not None else None
|
|
225
484
|
)
|
|
226
485
|
|
|
486
|
+
# Reset streaming capabilities when OpenAI API key is changed
|
|
487
|
+
if not is_shotgun and provider_enum == ProviderType.OPENAI:
|
|
488
|
+
if isinstance(provider_config, OpenAIConfig):
|
|
489
|
+
provider_config.supports_streaming = None
|
|
490
|
+
|
|
227
491
|
# Reject other fields
|
|
228
492
|
unsupported_fields = set(kwargs.keys()) - {API_KEY_FIELD}
|
|
229
493
|
if unsupported_fields:
|
|
@@ -242,8 +506,8 @@ class ConfigManager:
|
|
|
242
506
|
from .models import ModelName
|
|
243
507
|
|
|
244
508
|
provider_models = {
|
|
245
|
-
ProviderType.OPENAI: ModelName.
|
|
246
|
-
ProviderType.ANTHROPIC: ModelName.
|
|
509
|
+
ProviderType.OPENAI: ModelName.GPT_5_1,
|
|
510
|
+
ProviderType.ANTHROPIC: ModelName.CLAUDE_HAIKU_4_5,
|
|
247
511
|
ProviderType.GOOGLE: ModelName.GEMINI_2_5_PRO,
|
|
248
512
|
}
|
|
249
513
|
if provider_enum in provider_models:
|
|
@@ -253,11 +517,16 @@ class ConfigManager:
|
|
|
253
517
|
# This prevents the welcome screen from showing again after user has made their choice
|
|
254
518
|
config.shown_welcome_screen = True
|
|
255
519
|
|
|
256
|
-
|
|
520
|
+
# Clear migration failure flag when user successfully configures a provider
|
|
521
|
+
if API_KEY_FIELD in kwargs and api_key_value is not None:
|
|
522
|
+
config.migration_failed = False
|
|
523
|
+
config.migration_backup_path = None
|
|
257
524
|
|
|
258
|
-
|
|
525
|
+
await self.save(config)
|
|
526
|
+
|
|
527
|
+
async def clear_provider_key(self, provider: ProviderType | str) -> None:
|
|
259
528
|
"""Remove the API key for the given provider (LLM provider or shotgun)."""
|
|
260
|
-
config = self.load()
|
|
529
|
+
config = await self.load()
|
|
261
530
|
|
|
262
531
|
# Get provider config (shotgun or LLM provider)
|
|
263
532
|
provider_config, is_shotgun = self._get_provider_config_and_type(
|
|
@@ -270,34 +539,41 @@ class ConfigManager:
|
|
|
270
539
|
if is_shotgun and isinstance(provider_config, ShotgunAccountConfig):
|
|
271
540
|
provider_config.supabase_jwt = None
|
|
272
541
|
|
|
273
|
-
|
|
542
|
+
# Reset streaming capabilities when OpenAI API key is cleared
|
|
543
|
+
if not is_shotgun:
|
|
544
|
+
provider_enum = self._ensure_provider_enum(provider)
|
|
545
|
+
if provider_enum == ProviderType.OPENAI:
|
|
546
|
+
if isinstance(provider_config, OpenAIConfig):
|
|
547
|
+
provider_config.supports_streaming = None
|
|
548
|
+
|
|
549
|
+
await self.save(config)
|
|
274
550
|
|
|
275
|
-
def update_selected_model(self, model_name: "ModelName") -> None:
|
|
551
|
+
async def update_selected_model(self, model_name: "ModelName") -> None:
|
|
276
552
|
"""Update the selected model.
|
|
277
553
|
|
|
278
554
|
Args:
|
|
279
555
|
model_name: Model to select
|
|
280
556
|
"""
|
|
281
|
-
config = self.load()
|
|
557
|
+
config = await self.load()
|
|
282
558
|
config.selected_model = model_name
|
|
283
|
-
self.save(config)
|
|
559
|
+
await self.save(config)
|
|
284
560
|
|
|
285
|
-
def has_provider_key(self, provider: ProviderType | str) -> bool:
|
|
561
|
+
async def has_provider_key(self, provider: ProviderType | str) -> bool:
|
|
286
562
|
"""Check if the given provider has a non-empty API key configured.
|
|
287
563
|
|
|
288
564
|
This checks only the configuration file.
|
|
289
565
|
"""
|
|
290
566
|
# Use force_reload=False to avoid infinite loop when called from load()
|
|
291
|
-
config = self.load(force_reload=False)
|
|
567
|
+
config = await self.load(force_reload=False)
|
|
292
568
|
provider_enum = self._ensure_provider_enum(provider)
|
|
293
569
|
provider_config = self._get_provider_config(config, provider_enum)
|
|
294
570
|
|
|
295
571
|
return self._provider_has_api_key(provider_config)
|
|
296
572
|
|
|
297
|
-
def has_any_provider_key(self) -> bool:
|
|
573
|
+
async def has_any_provider_key(self) -> bool:
|
|
298
574
|
"""Determine whether any provider has a configured API key."""
|
|
299
575
|
# Use force_reload=False to avoid infinite loop when called from load()
|
|
300
|
-
config = self.load(force_reload=False)
|
|
576
|
+
config = await self.load(force_reload=False)
|
|
301
577
|
# Check LLM provider keys (BYOK)
|
|
302
578
|
has_llm_key = any(
|
|
303
579
|
self._provider_has_api_key(self._get_provider_config(config, provider))
|
|
@@ -311,7 +587,7 @@ class ConfigManager:
|
|
|
311
587
|
has_shotgun_key = self._provider_has_api_key(config.shotgun)
|
|
312
588
|
return has_llm_key or has_shotgun_key
|
|
313
589
|
|
|
314
|
-
def initialize(self) -> ShotgunConfig:
|
|
590
|
+
async def initialize(self) -> ShotgunConfig:
|
|
315
591
|
"""Initialize configuration with defaults and save to file.
|
|
316
592
|
|
|
317
593
|
Returns:
|
|
@@ -321,7 +597,7 @@ class ConfigManager:
|
|
|
321
597
|
config = ShotgunConfig(
|
|
322
598
|
shotgun_instance_id=str(uuid.uuid4()),
|
|
323
599
|
)
|
|
324
|
-
self.save(config)
|
|
600
|
+
await self.save(config)
|
|
325
601
|
logger.info(
|
|
326
602
|
"Configuration initialized at %s with shotgun_instance_id: %s",
|
|
327
603
|
self.config_path,
|
|
@@ -377,6 +653,24 @@ class ConfigManager:
|
|
|
377
653
|
SUPABASE_JWT_FIELD
|
|
378
654
|
].get_secret_value()
|
|
379
655
|
|
|
656
|
+
def _convert_datetime_to_isoformat(self, data: dict[str, Any]) -> None:
|
|
657
|
+
"""Convert datetime objects in data to ISO8601 format strings for JSON serialization."""
|
|
658
|
+
from datetime import datetime
|
|
659
|
+
|
|
660
|
+
def convert_dict(d: dict[str, Any]) -> None:
|
|
661
|
+
"""Recursively convert datetime objects in a dict."""
|
|
662
|
+
for key, value in d.items():
|
|
663
|
+
if isinstance(value, datetime):
|
|
664
|
+
d[key] = value.isoformat()
|
|
665
|
+
elif isinstance(value, dict):
|
|
666
|
+
convert_dict(value)
|
|
667
|
+
elif isinstance(value, list):
|
|
668
|
+
for item in value:
|
|
669
|
+
if isinstance(item, dict):
|
|
670
|
+
convert_dict(item)
|
|
671
|
+
|
|
672
|
+
convert_dict(data)
|
|
673
|
+
|
|
380
674
|
def _ensure_provider_enum(self, provider: ProviderType | str) -> ProviderType:
|
|
381
675
|
"""Normalize provider values to ProviderType enum."""
|
|
382
676
|
return (
|
|
@@ -440,25 +734,29 @@ class ConfigManager:
|
|
|
440
734
|
provider_enum = self._ensure_provider_enum(provider)
|
|
441
735
|
return (self._get_provider_config(config, provider_enum), False)
|
|
442
736
|
|
|
443
|
-
def get_shotgun_instance_id(self) -> str:
|
|
737
|
+
async def get_shotgun_instance_id(self) -> str:
|
|
444
738
|
"""Get the shotgun instance ID from configuration.
|
|
445
739
|
|
|
446
740
|
Returns:
|
|
447
741
|
The unique shotgun instance ID string
|
|
448
742
|
"""
|
|
449
|
-
config = self.load()
|
|
743
|
+
config = await self.load()
|
|
450
744
|
return config.shotgun_instance_id
|
|
451
745
|
|
|
452
|
-
def update_shotgun_account(
|
|
453
|
-
self,
|
|
746
|
+
async def update_shotgun_account(
|
|
747
|
+
self,
|
|
748
|
+
api_key: str | None = None,
|
|
749
|
+
supabase_jwt: str | None = None,
|
|
750
|
+
workspace_id: str | None = None,
|
|
454
751
|
) -> None:
|
|
455
752
|
"""Update Shotgun Account configuration.
|
|
456
753
|
|
|
457
754
|
Args:
|
|
458
755
|
api_key: LiteLLM proxy API key (optional)
|
|
459
756
|
supabase_jwt: Supabase authentication JWT (optional)
|
|
757
|
+
workspace_id: Default workspace ID for shared specs (optional)
|
|
460
758
|
"""
|
|
461
|
-
config = self.load()
|
|
759
|
+
config = await self.load()
|
|
462
760
|
|
|
463
761
|
if api_key is not None:
|
|
464
762
|
config.shotgun.api_key = SecretStr(api_key) if api_key else None
|
|
@@ -468,7 +766,10 @@ class ConfigManager:
|
|
|
468
766
|
SecretStr(supabase_jwt) if supabase_jwt else None
|
|
469
767
|
)
|
|
470
768
|
|
|
471
|
-
|
|
769
|
+
if workspace_id is not None:
|
|
770
|
+
config.shotgun.workspace_id = workspace_id
|
|
771
|
+
|
|
772
|
+
await self.save(config)
|
|
472
773
|
logger.info("Updated Shotgun Account configuration")
|
|
473
774
|
|
|
474
775
|
|