shotgun-sh 0.2.17__py3-none-any.whl → 0.4.0.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 +219 -37
- shotgun/agents/common.py +79 -78
- shotgun/agents/config/README.md +89 -0
- shotgun/agents/config/__init__.py +10 -1
- shotgun/agents/config/manager.py +364 -53
- shotgun/agents/config/models.py +101 -21
- shotgun/agents/config/provider.py +51 -13
- shotgun/agents/config/streaming_test.py +119 -0
- shotgun/agents/context_analyzer/analyzer.py +6 -2
- 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 +27 -1
- shotgun/agents/{history → conversation/history}/constants.py +5 -0
- shotgun/agents/conversation/history/file_content_deduplication.py +239 -0
- shotgun/agents/{history → conversation/history}/history_processors.py +267 -3
- shotgun/agents/{history → conversation/history}/token_counting/anthropic.py +8 -0
- shotgun/agents/{conversation_manager.py → conversation/manager.py} +1 -1
- shotgun/agents/{conversation_history.py → conversation/models.py} +8 -94
- shotgun/agents/error/__init__.py +11 -0
- shotgun/agents/error/models.py +19 -0
- shotgun/agents/export.py +12 -13
- shotgun/agents/models.py +66 -1
- shotgun/agents/plan.py +12 -13
- shotgun/agents/research.py +13 -10
- shotgun/agents/router/__init__.py +47 -0
- shotgun/agents/router/models.py +376 -0
- shotgun/agents/router/router.py +185 -0
- shotgun/agents/router/tools/__init__.py +18 -0
- shotgun/agents/router/tools/delegation_tools.py +503 -0
- shotgun/agents/router/tools/plan_tools.py +322 -0
- shotgun/agents/runner.py +230 -0
- shotgun/agents/specify.py +12 -13
- shotgun/agents/tasks.py +12 -13
- shotgun/agents/tools/file_management.py +49 -1
- shotgun/agents/tools/registry.py +2 -0
- shotgun/agents/tools/web_search/__init__.py +1 -2
- shotgun/agents/tools/web_search/gemini.py +1 -3
- shotgun/agents/tools/web_search/openai.py +1 -1
- shotgun/build_constants.py +2 -2
- shotgun/cli/clear.py +1 -1
- shotgun/cli/compact.py +5 -3
- shotgun/cli/context.py +44 -1
- shotgun/cli/error_handler.py +24 -0
- shotgun/cli/export.py +34 -34
- shotgun/cli/plan.py +34 -34
- shotgun/cli/research.py +17 -9
- 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/codebase/core/change_detector.py +1 -1
- shotgun/codebase/core/ingestor.py +154 -8
- shotgun/codebase/core/manager.py +1 -1
- shotgun/codebase/models.py +2 -0
- shotgun/exceptions.py +325 -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 +42 -0
- shotgun/main.py +4 -0
- shotgun/posthog_telemetry.py +1 -1
- shotgun/prompts/agents/export.j2 +2 -0
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +23 -3
- shotgun/prompts/agents/partials/interactive_mode.j2 +3 -3
- shotgun/prompts/agents/partials/router_delegation_mode.j2 +36 -0
- shotgun/prompts/agents/plan.j2 +29 -1
- shotgun/prompts/agents/research.j2 +75 -23
- shotgun/prompts/agents/router.j2 +440 -0
- shotgun/prompts/agents/specify.j2 +80 -4
- shotgun/prompts/agents/state/system_state.j2 +15 -8
- shotgun/prompts/agents/tasks.j2 +63 -23
- shotgun/prompts/history/chunk_summarization.j2 +34 -0
- shotgun/prompts/history/combine_summaries.j2 +53 -0
- shotgun/sdk/codebase.py +14 -3
- shotgun/settings.py +5 -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/tui/app.py +78 -15
- shotgun/tui/components/mode_indicator.py +120 -25
- shotgun/tui/components/status_bar.py +2 -2
- shotgun/tui/containers.py +1 -1
- shotgun/tui/dependencies.py +64 -9
- shotgun/tui/layout.py +5 -0
- shotgun/tui/protocols.py +37 -0
- shotgun/tui/screens/chat/chat.tcss +9 -1
- shotgun/tui/screens/chat/chat_screen.py +1015 -106
- shotgun/tui/screens/chat/codebase_index_prompt_screen.py +196 -17
- shotgun/tui/screens/chat_screen/command_providers.py +13 -89
- shotgun/tui/screens/chat_screen/hint_message.py +76 -1
- shotgun/tui/screens/chat_screen/history/agent_response.py +7 -3
- shotgun/tui/screens/chat_screen/history/chat_history.py +12 -0
- shotgun/tui/screens/chat_screen/history/formatters.py +53 -15
- shotgun/tui/screens/chat_screen/history/partial_response.py +11 -1
- shotgun/tui/screens/chat_screen/messages.py +219 -0
- shotgun/tui/screens/confirmation_dialog.py +40 -0
- shotgun/tui/screens/directory_setup.py +45 -41
- shotgun/tui/screens/feedback.py +10 -3
- shotgun/tui/screens/github_issue.py +11 -2
- shotgun/tui/screens/model_picker.py +28 -8
- shotgun/tui/screens/onboarding.py +179 -26
- shotgun/tui/screens/pipx_migration.py +58 -6
- shotgun/tui/screens/provider_config.py +66 -8
- 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 +110 -16
- shotgun/tui/screens/spec_pull.py +288 -0
- shotgun/tui/screens/welcome.py +123 -0
- shotgun/tui/services/conversation_service.py +5 -2
- shotgun/tui/utils/mode_progress.py +20 -86
- shotgun/tui/widgets/__init__.py +2 -1
- shotgun/tui/widgets/approval_widget.py +152 -0
- shotgun/tui/widgets/cascade_confirmation_widget.py +203 -0
- shotgun/tui/widgets/plan_panel.py +129 -0
- shotgun/tui/widgets/step_checkpoint_widget.py +180 -0
- shotgun/tui/widgets/widget_coordinator.py +1 -1
- {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/METADATA +11 -4
- shotgun_sh-0.4.0.dev1.dist-info/RECORD +242 -0
- {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/WHEEL +1 -1
- shotgun_sh-0.2.17.dist-info/RECORD +0 -194
- /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_counting/base.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/openai.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/sentencepiece_counter.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/tokenizer_cache.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/utils.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_estimation.py +0 -0
- {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/entry_points.txt +0 -0
- {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/licenses/LICENSE +0 -0
shotgun/agents/config/manager.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
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
|
|
|
@@ -30,9 +32,217 @@ from .models import (
|
|
|
30
32
|
|
|
31
33
|
logger = get_logger(__name__)
|
|
32
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
|
+
|
|
33
50
|
# Type alias for provider configuration objects
|
|
34
51
|
ProviderConfig = OpenAIConfig | AnthropicConfig | GoogleConfig | ShotgunAccountConfig
|
|
35
52
|
|
|
53
|
+
# Current config version
|
|
54
|
+
CURRENT_CONFIG_VERSION = 6
|
|
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 _migrate_v5_to_v6(data: dict[str, Any]) -> dict[str, Any]:
|
|
187
|
+
"""Migrate config from version 5 to version 6.
|
|
188
|
+
|
|
189
|
+
Changes:
|
|
190
|
+
- Add 'router_mode' field with default 'planning'
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
data: Config data dict at version 5
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
Modified config data dict at version 6
|
|
197
|
+
"""
|
|
198
|
+
if "router_mode" not in data:
|
|
199
|
+
data["router_mode"] = "planning"
|
|
200
|
+
logger.info("Migrated config v5->v6: added router_mode field")
|
|
201
|
+
|
|
202
|
+
data["config_version"] = 6
|
|
203
|
+
return data
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _apply_migrations(data: dict[str, Any]) -> dict[str, Any]:
|
|
207
|
+
"""Apply all necessary migrations to bring config to current version.
|
|
208
|
+
|
|
209
|
+
Migrations are applied sequentially from the config's current version
|
|
210
|
+
to CURRENT_CONFIG_VERSION.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
data: Config data dict at any version
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
Config data dict at CURRENT_CONFIG_VERSION
|
|
217
|
+
"""
|
|
218
|
+
# Get current version (default to 2 for very old configs)
|
|
219
|
+
current_version = data.get("config_version", 2)
|
|
220
|
+
|
|
221
|
+
# Define migrations in order
|
|
222
|
+
migrations = {
|
|
223
|
+
2: _migrate_v2_to_v3,
|
|
224
|
+
3: _migrate_v3_to_v4,
|
|
225
|
+
4: _migrate_v4_to_v5,
|
|
226
|
+
5: _migrate_v5_to_v6,
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
# Apply migrations sequentially
|
|
230
|
+
while current_version < CURRENT_CONFIG_VERSION:
|
|
231
|
+
if current_version in migrations:
|
|
232
|
+
logger.info(
|
|
233
|
+
f"Applying migration from v{current_version} to v{current_version + 1}"
|
|
234
|
+
)
|
|
235
|
+
data = migrations[current_version](data)
|
|
236
|
+
current_version = data.get("config_version", current_version + 1)
|
|
237
|
+
else:
|
|
238
|
+
logger.warning(
|
|
239
|
+
f"No migration defined for v{current_version}, skipping to v{current_version + 1}"
|
|
240
|
+
)
|
|
241
|
+
current_version += 1
|
|
242
|
+
data["config_version"] = current_version
|
|
243
|
+
|
|
244
|
+
return data
|
|
245
|
+
|
|
36
246
|
|
|
37
247
|
class ConfigManager:
|
|
38
248
|
"""Manager for Shotgun configuration."""
|
|
@@ -71,71 +281,88 @@ class ConfigManager:
|
|
|
71
281
|
self._config = await self.initialize()
|
|
72
282
|
return self._config
|
|
73
283
|
|
|
284
|
+
backup_path: Path | None = None
|
|
74
285
|
try:
|
|
75
286
|
async with aiofiles.open(self.config_path, encoding="utf-8") as f:
|
|
76
287
|
content = await f.read()
|
|
77
288
|
data = json.loads(content)
|
|
78
289
|
|
|
79
|
-
#
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
290
|
+
# Get current version to determine if migration is needed
|
|
291
|
+
current_version = data.get("config_version", 2)
|
|
292
|
+
|
|
293
|
+
# Create backup before migration if config needs upgrading
|
|
294
|
+
if current_version < CURRENT_CONFIG_VERSION:
|
|
83
295
|
logger.info(
|
|
84
|
-
"
|
|
296
|
+
f"Config needs migration from v{current_version} to v{CURRENT_CONFIG_VERSION}"
|
|
85
297
|
)
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
has_byok_key = False
|
|
92
|
-
for section in ["openai", "anthropic", "google"]:
|
|
93
|
-
if (
|
|
94
|
-
section in data
|
|
95
|
-
and isinstance(data[section], dict)
|
|
96
|
-
and data[section].get("api_key")
|
|
97
|
-
):
|
|
98
|
-
has_byok_key = True
|
|
99
|
-
break
|
|
100
|
-
|
|
101
|
-
if has_byok_key:
|
|
102
|
-
data["shown_welcome_screen"] = False
|
|
103
|
-
logger.info(
|
|
104
|
-
"Existing BYOK user detected: set shown_welcome_screen=False to show welcome screen"
|
|
298
|
+
try:
|
|
299
|
+
backup_path = _create_backup(self.config_path)
|
|
300
|
+
except OSError as backup_error:
|
|
301
|
+
logger.warning(
|
|
302
|
+
f"Could not create backup before migration: {backup_error}"
|
|
105
303
|
)
|
|
304
|
+
# Continue without backup - better than failing completely
|
|
305
|
+
|
|
306
|
+
# Apply all necessary migrations to bring config to current version
|
|
307
|
+
try:
|
|
308
|
+
data = _apply_migrations(data)
|
|
309
|
+
except Exception as migration_error:
|
|
310
|
+
error_msg = (
|
|
311
|
+
f"Failed to migrate configuration from v{current_version} to v{CURRENT_CONFIG_VERSION}. "
|
|
312
|
+
f"Error: {migration_error}"
|
|
313
|
+
)
|
|
314
|
+
if backup_path:
|
|
315
|
+
error_msg += f"\n\nYour original config has been backed up to:\n{backup_path}"
|
|
316
|
+
error_msg += (
|
|
317
|
+
"\n\nTo start fresh, delete or rename your config file:\n"
|
|
318
|
+
f" rm {self.config_path}\n"
|
|
319
|
+
f" shotgun config init\n\n"
|
|
320
|
+
"To restore your backup:\n"
|
|
321
|
+
f" cp {backup_path} {self.config_path}"
|
|
322
|
+
)
|
|
323
|
+
else:
|
|
324
|
+
error_msg += "\n\nTo start fresh, run: shotgun config init"
|
|
106
325
|
|
|
107
|
-
|
|
108
|
-
if "marketing" not in data:
|
|
109
|
-
data["marketing"] = {"messages": {}}
|
|
110
|
-
data["config_version"] = 4
|
|
111
|
-
logger.info("Migrated config v3->v4: added marketing configuration")
|
|
326
|
+
raise ConfigMigrationError(error_msg, backup_path) from migration_error
|
|
112
327
|
|
|
113
328
|
# Convert plain text secrets to SecretStr objects
|
|
114
329
|
self._convert_secrets_to_secretstr(data)
|
|
115
330
|
|
|
331
|
+
# Clean up invalid selected_model before Pydantic validation
|
|
332
|
+
if "selected_model" in data and data["selected_model"] is not None:
|
|
333
|
+
from .models import MODEL_SPECS, ModelName
|
|
334
|
+
|
|
335
|
+
try:
|
|
336
|
+
# Try to convert to ModelName enum
|
|
337
|
+
model_name = ModelName(data["selected_model"])
|
|
338
|
+
# Check if it exists in MODEL_SPECS
|
|
339
|
+
if model_name not in MODEL_SPECS:
|
|
340
|
+
data["selected_model"] = None
|
|
341
|
+
except (ValueError, KeyError):
|
|
342
|
+
# Invalid model name - reset to None
|
|
343
|
+
data["selected_model"] = None
|
|
344
|
+
|
|
116
345
|
self._config = ShotgunConfig.model_validate(data)
|
|
117
346
|
logger.debug("Configuration loaded successfully from %s", self.config_path)
|
|
118
347
|
|
|
119
|
-
#
|
|
120
|
-
|
|
121
|
-
|
|
348
|
+
# Clear migration_failed flag if config loaded successfully
|
|
349
|
+
should_save = False
|
|
350
|
+
if self._config.migration_failed:
|
|
351
|
+
self._config.migration_failed = False
|
|
352
|
+
self._config.migration_backup_path = None
|
|
353
|
+
should_save = True
|
|
122
354
|
|
|
355
|
+
# Validate selected_model for BYOK mode - verify provider has a key
|
|
356
|
+
if not self._provider_has_api_key(self._config.shotgun):
|
|
123
357
|
# If selected_model is set, verify its provider has a key
|
|
124
358
|
if self._config.selected_model:
|
|
125
359
|
from .models import MODEL_SPECS
|
|
126
360
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
logger.info(
|
|
131
|
-
"Selected model %s provider has no API key, finding available model",
|
|
132
|
-
self._config.selected_model.value,
|
|
133
|
-
)
|
|
134
|
-
self._config.selected_model = None
|
|
135
|
-
should_save = True
|
|
136
|
-
else:
|
|
361
|
+
spec = MODEL_SPECS[self._config.selected_model]
|
|
362
|
+
if not await self.has_provider_key(spec.provider):
|
|
363
|
+
# Provider has no key - reset to None
|
|
137
364
|
logger.info(
|
|
138
|
-
"Selected model %s
|
|
365
|
+
"Selected model %s provider has no API key, finding available model",
|
|
139
366
|
self._config.selected_model.value,
|
|
140
367
|
)
|
|
141
368
|
self._config.selected_model = None
|
|
@@ -150,17 +377,13 @@ class ConfigManager:
|
|
|
150
377
|
|
|
151
378
|
# Find default model for this provider
|
|
152
379
|
provider_models = {
|
|
153
|
-
ProviderType.OPENAI: ModelName.
|
|
380
|
+
ProviderType.OPENAI: ModelName.GPT_5_1,
|
|
154
381
|
ProviderType.ANTHROPIC: ModelName.CLAUDE_HAIKU_4_5,
|
|
155
382
|
ProviderType.GOOGLE: ModelName.GEMINI_2_5_PRO,
|
|
156
383
|
}
|
|
157
384
|
|
|
158
385
|
if provider in provider_models:
|
|
159
386
|
self._config.selected_model = provider_models[provider]
|
|
160
|
-
logger.info(
|
|
161
|
-
"Set selected_model to %s (first available provider)",
|
|
162
|
-
self._config.selected_model.value,
|
|
163
|
-
)
|
|
164
387
|
should_save = True
|
|
165
388
|
break
|
|
166
389
|
|
|
@@ -169,12 +392,56 @@ class ConfigManager:
|
|
|
169
392
|
|
|
170
393
|
return self._config
|
|
171
394
|
|
|
172
|
-
except
|
|
395
|
+
except ConfigMigrationError as migration_error:
|
|
396
|
+
# Migration failed - automatically create fresh config with migration info
|
|
173
397
|
logger.error(
|
|
174
|
-
"
|
|
398
|
+
"Config migration failed, creating fresh config: %s", migration_error
|
|
175
399
|
)
|
|
176
|
-
|
|
400
|
+
backup_path = migration_error.backup_path
|
|
401
|
+
|
|
402
|
+
# Create fresh config with migration failure info
|
|
177
403
|
self._config = await self.initialize()
|
|
404
|
+
self._config.migration_failed = True
|
|
405
|
+
if backup_path:
|
|
406
|
+
self._config.migration_backup_path = str(backup_path)
|
|
407
|
+
|
|
408
|
+
# Save the fresh config
|
|
409
|
+
await self.save(self._config)
|
|
410
|
+
logger.info("Created fresh config after migration failure")
|
|
411
|
+
|
|
412
|
+
return self._config
|
|
413
|
+
|
|
414
|
+
except json.JSONDecodeError as json_error:
|
|
415
|
+
# Invalid JSON - create backup and fresh config
|
|
416
|
+
logger.error("Config file has invalid JSON: %s", json_error)
|
|
417
|
+
|
|
418
|
+
try:
|
|
419
|
+
backup_path = _create_backup(self.config_path)
|
|
420
|
+
except OSError:
|
|
421
|
+
backup_path = None
|
|
422
|
+
|
|
423
|
+
self._config = await self.initialize()
|
|
424
|
+
self._config.migration_failed = True
|
|
425
|
+
if backup_path:
|
|
426
|
+
self._config.migration_backup_path = str(backup_path)
|
|
427
|
+
|
|
428
|
+
await self.save(self._config)
|
|
429
|
+
logger.info("Created fresh config after JSON parse error")
|
|
430
|
+
|
|
431
|
+
return self._config
|
|
432
|
+
|
|
433
|
+
except Exception as e:
|
|
434
|
+
# Generic error - create fresh config
|
|
435
|
+
logger.error("Failed to load config: %s", e)
|
|
436
|
+
|
|
437
|
+
self._config = await self.initialize()
|
|
438
|
+
self._config.migration_failed = True
|
|
439
|
+
if backup_path:
|
|
440
|
+
self._config.migration_backup_path = str(backup_path)
|
|
441
|
+
|
|
442
|
+
await self.save(self._config)
|
|
443
|
+
logger.info("Created fresh config after load error")
|
|
444
|
+
|
|
178
445
|
return self._config
|
|
179
446
|
|
|
180
447
|
async def save(self, config: ShotgunConfig | None = None) -> None:
|
|
@@ -237,6 +504,11 @@ class ConfigManager:
|
|
|
237
504
|
SecretStr(api_key_value) if api_key_value is not None else None
|
|
238
505
|
)
|
|
239
506
|
|
|
507
|
+
# Reset streaming capabilities when OpenAI API key is changed
|
|
508
|
+
if not is_shotgun and provider_enum == ProviderType.OPENAI:
|
|
509
|
+
if isinstance(provider_config, OpenAIConfig):
|
|
510
|
+
provider_config.supports_streaming = None
|
|
511
|
+
|
|
240
512
|
# Reject other fields
|
|
241
513
|
unsupported_fields = set(kwargs.keys()) - {API_KEY_FIELD}
|
|
242
514
|
if unsupported_fields:
|
|
@@ -255,7 +527,7 @@ class ConfigManager:
|
|
|
255
527
|
from .models import ModelName
|
|
256
528
|
|
|
257
529
|
provider_models = {
|
|
258
|
-
ProviderType.OPENAI: ModelName.
|
|
530
|
+
ProviderType.OPENAI: ModelName.GPT_5_1,
|
|
259
531
|
ProviderType.ANTHROPIC: ModelName.CLAUDE_HAIKU_4_5,
|
|
260
532
|
ProviderType.GOOGLE: ModelName.GEMINI_2_5_PRO,
|
|
261
533
|
}
|
|
@@ -266,6 +538,11 @@ class ConfigManager:
|
|
|
266
538
|
# This prevents the welcome screen from showing again after user has made their choice
|
|
267
539
|
config.shown_welcome_screen = True
|
|
268
540
|
|
|
541
|
+
# Clear migration failure flag when user successfully configures a provider
|
|
542
|
+
if API_KEY_FIELD in kwargs and api_key_value is not None:
|
|
543
|
+
config.migration_failed = False
|
|
544
|
+
config.migration_backup_path = None
|
|
545
|
+
|
|
269
546
|
await self.save(config)
|
|
270
547
|
|
|
271
548
|
async def clear_provider_key(self, provider: ProviderType | str) -> None:
|
|
@@ -283,6 +560,13 @@ class ConfigManager:
|
|
|
283
560
|
if is_shotgun and isinstance(provider_config, ShotgunAccountConfig):
|
|
284
561
|
provider_config.supabase_jwt = None
|
|
285
562
|
|
|
563
|
+
# Reset streaming capabilities when OpenAI API key is cleared
|
|
564
|
+
if not is_shotgun:
|
|
565
|
+
provider_enum = self._ensure_provider_enum(provider)
|
|
566
|
+
if provider_enum == ProviderType.OPENAI:
|
|
567
|
+
if isinstance(provider_config, OpenAIConfig):
|
|
568
|
+
provider_config.supports_streaming = None
|
|
569
|
+
|
|
286
570
|
await self.save(config)
|
|
287
571
|
|
|
288
572
|
async def update_selected_model(self, model_name: "ModelName") -> None:
|
|
@@ -481,13 +765,17 @@ class ConfigManager:
|
|
|
481
765
|
return config.shotgun_instance_id
|
|
482
766
|
|
|
483
767
|
async def update_shotgun_account(
|
|
484
|
-
self,
|
|
768
|
+
self,
|
|
769
|
+
api_key: str | None = None,
|
|
770
|
+
supabase_jwt: str | None = None,
|
|
771
|
+
workspace_id: str | None = None,
|
|
485
772
|
) -> None:
|
|
486
773
|
"""Update Shotgun Account configuration.
|
|
487
774
|
|
|
488
775
|
Args:
|
|
489
776
|
api_key: LiteLLM proxy API key (optional)
|
|
490
777
|
supabase_jwt: Supabase authentication JWT (optional)
|
|
778
|
+
workspace_id: Default workspace ID for shared specs (optional)
|
|
491
779
|
"""
|
|
492
780
|
config = await self.load()
|
|
493
781
|
|
|
@@ -499,9 +787,32 @@ class ConfigManager:
|
|
|
499
787
|
SecretStr(supabase_jwt) if supabase_jwt else None
|
|
500
788
|
)
|
|
501
789
|
|
|
790
|
+
if workspace_id is not None:
|
|
791
|
+
config.shotgun.workspace_id = workspace_id
|
|
792
|
+
|
|
502
793
|
await self.save(config)
|
|
503
794
|
logger.info("Updated Shotgun Account configuration")
|
|
504
795
|
|
|
796
|
+
async def get_router_mode(self) -> str:
|
|
797
|
+
"""Get the saved router mode.
|
|
798
|
+
|
|
799
|
+
Returns:
|
|
800
|
+
The router mode string ('planning' or 'drafting')
|
|
801
|
+
"""
|
|
802
|
+
config = await self.load()
|
|
803
|
+
return config.router_mode
|
|
804
|
+
|
|
805
|
+
async def set_router_mode(self, mode: str) -> None:
|
|
806
|
+
"""Save the router mode.
|
|
807
|
+
|
|
808
|
+
Args:
|
|
809
|
+
mode: Router mode to save ('planning' or 'drafting')
|
|
810
|
+
"""
|
|
811
|
+
config = await self.load()
|
|
812
|
+
config.router_mode = mode
|
|
813
|
+
await self.save(config)
|
|
814
|
+
logger.debug("Router mode saved: %s", mode)
|
|
815
|
+
|
|
505
816
|
|
|
506
817
|
# Global singleton instance
|
|
507
818
|
_config_manager_instance: ConfigManager | None = None
|