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.
Files changed (150) hide show
  1. shotgun/agents/agent_manager.py +219 -37
  2. shotgun/agents/common.py +79 -78
  3. shotgun/agents/config/README.md +89 -0
  4. shotgun/agents/config/__init__.py +10 -1
  5. shotgun/agents/config/manager.py +364 -53
  6. shotgun/agents/config/models.py +101 -21
  7. shotgun/agents/config/provider.py +51 -13
  8. shotgun/agents/config/streaming_test.py +119 -0
  9. shotgun/agents/context_analyzer/analyzer.py +6 -2
  10. shotgun/agents/conversation/__init__.py +18 -0
  11. shotgun/agents/conversation/filters.py +164 -0
  12. shotgun/agents/conversation/history/chunking.py +278 -0
  13. shotgun/agents/{history → conversation/history}/compaction.py +27 -1
  14. shotgun/agents/{history → conversation/history}/constants.py +5 -0
  15. shotgun/agents/conversation/history/file_content_deduplication.py +239 -0
  16. shotgun/agents/{history → conversation/history}/history_processors.py +267 -3
  17. shotgun/agents/{history → conversation/history}/token_counting/anthropic.py +8 -0
  18. shotgun/agents/{conversation_manager.py → conversation/manager.py} +1 -1
  19. shotgun/agents/{conversation_history.py → conversation/models.py} +8 -94
  20. shotgun/agents/error/__init__.py +11 -0
  21. shotgun/agents/error/models.py +19 -0
  22. shotgun/agents/export.py +12 -13
  23. shotgun/agents/models.py +66 -1
  24. shotgun/agents/plan.py +12 -13
  25. shotgun/agents/research.py +13 -10
  26. shotgun/agents/router/__init__.py +47 -0
  27. shotgun/agents/router/models.py +376 -0
  28. shotgun/agents/router/router.py +185 -0
  29. shotgun/agents/router/tools/__init__.py +18 -0
  30. shotgun/agents/router/tools/delegation_tools.py +503 -0
  31. shotgun/agents/router/tools/plan_tools.py +322 -0
  32. shotgun/agents/runner.py +230 -0
  33. shotgun/agents/specify.py +12 -13
  34. shotgun/agents/tasks.py +12 -13
  35. shotgun/agents/tools/file_management.py +49 -1
  36. shotgun/agents/tools/registry.py +2 -0
  37. shotgun/agents/tools/web_search/__init__.py +1 -2
  38. shotgun/agents/tools/web_search/gemini.py +1 -3
  39. shotgun/agents/tools/web_search/openai.py +1 -1
  40. shotgun/build_constants.py +2 -2
  41. shotgun/cli/clear.py +1 -1
  42. shotgun/cli/compact.py +5 -3
  43. shotgun/cli/context.py +44 -1
  44. shotgun/cli/error_handler.py +24 -0
  45. shotgun/cli/export.py +34 -34
  46. shotgun/cli/plan.py +34 -34
  47. shotgun/cli/research.py +17 -9
  48. shotgun/cli/spec/__init__.py +5 -0
  49. shotgun/cli/spec/backup.py +81 -0
  50. shotgun/cli/spec/commands.py +132 -0
  51. shotgun/cli/spec/models.py +48 -0
  52. shotgun/cli/spec/pull_service.py +219 -0
  53. shotgun/cli/specify.py +20 -19
  54. shotgun/cli/tasks.py +34 -34
  55. shotgun/codebase/core/change_detector.py +1 -1
  56. shotgun/codebase/core/ingestor.py +154 -8
  57. shotgun/codebase/core/manager.py +1 -1
  58. shotgun/codebase/models.py +2 -0
  59. shotgun/exceptions.py +325 -0
  60. shotgun/llm_proxy/__init__.py +17 -0
  61. shotgun/llm_proxy/client.py +215 -0
  62. shotgun/llm_proxy/models.py +137 -0
  63. shotgun/logging_config.py +42 -0
  64. shotgun/main.py +4 -0
  65. shotgun/posthog_telemetry.py +1 -1
  66. shotgun/prompts/agents/export.j2 +2 -0
  67. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +23 -3
  68. shotgun/prompts/agents/partials/interactive_mode.j2 +3 -3
  69. shotgun/prompts/agents/partials/router_delegation_mode.j2 +36 -0
  70. shotgun/prompts/agents/plan.j2 +29 -1
  71. shotgun/prompts/agents/research.j2 +75 -23
  72. shotgun/prompts/agents/router.j2 +440 -0
  73. shotgun/prompts/agents/specify.j2 +80 -4
  74. shotgun/prompts/agents/state/system_state.j2 +15 -8
  75. shotgun/prompts/agents/tasks.j2 +63 -23
  76. shotgun/prompts/history/chunk_summarization.j2 +34 -0
  77. shotgun/prompts/history/combine_summaries.j2 +53 -0
  78. shotgun/sdk/codebase.py +14 -3
  79. shotgun/settings.py +5 -0
  80. shotgun/shotgun_web/__init__.py +67 -1
  81. shotgun/shotgun_web/client.py +42 -1
  82. shotgun/shotgun_web/constants.py +46 -0
  83. shotgun/shotgun_web/exceptions.py +29 -0
  84. shotgun/shotgun_web/models.py +390 -0
  85. shotgun/shotgun_web/shared_specs/__init__.py +32 -0
  86. shotgun/shotgun_web/shared_specs/file_scanner.py +175 -0
  87. shotgun/shotgun_web/shared_specs/hasher.py +83 -0
  88. shotgun/shotgun_web/shared_specs/models.py +71 -0
  89. shotgun/shotgun_web/shared_specs/upload_pipeline.py +329 -0
  90. shotgun/shotgun_web/shared_specs/utils.py +34 -0
  91. shotgun/shotgun_web/specs_client.py +703 -0
  92. shotgun/shotgun_web/supabase_client.py +31 -0
  93. shotgun/tui/app.py +78 -15
  94. shotgun/tui/components/mode_indicator.py +120 -25
  95. shotgun/tui/components/status_bar.py +2 -2
  96. shotgun/tui/containers.py +1 -1
  97. shotgun/tui/dependencies.py +64 -9
  98. shotgun/tui/layout.py +5 -0
  99. shotgun/tui/protocols.py +37 -0
  100. shotgun/tui/screens/chat/chat.tcss +9 -1
  101. shotgun/tui/screens/chat/chat_screen.py +1015 -106
  102. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +196 -17
  103. shotgun/tui/screens/chat_screen/command_providers.py +13 -89
  104. shotgun/tui/screens/chat_screen/hint_message.py +76 -1
  105. shotgun/tui/screens/chat_screen/history/agent_response.py +7 -3
  106. shotgun/tui/screens/chat_screen/history/chat_history.py +12 -0
  107. shotgun/tui/screens/chat_screen/history/formatters.py +53 -15
  108. shotgun/tui/screens/chat_screen/history/partial_response.py +11 -1
  109. shotgun/tui/screens/chat_screen/messages.py +219 -0
  110. shotgun/tui/screens/confirmation_dialog.py +40 -0
  111. shotgun/tui/screens/directory_setup.py +45 -41
  112. shotgun/tui/screens/feedback.py +10 -3
  113. shotgun/tui/screens/github_issue.py +11 -2
  114. shotgun/tui/screens/model_picker.py +28 -8
  115. shotgun/tui/screens/onboarding.py +179 -26
  116. shotgun/tui/screens/pipx_migration.py +58 -6
  117. shotgun/tui/screens/provider_config.py +66 -8
  118. shotgun/tui/screens/shared_specs/__init__.py +21 -0
  119. shotgun/tui/screens/shared_specs/create_spec_dialog.py +273 -0
  120. shotgun/tui/screens/shared_specs/models.py +56 -0
  121. shotgun/tui/screens/shared_specs/share_specs_dialog.py +390 -0
  122. shotgun/tui/screens/shared_specs/upload_progress_screen.py +452 -0
  123. shotgun/tui/screens/shotgun_auth.py +110 -16
  124. shotgun/tui/screens/spec_pull.py +288 -0
  125. shotgun/tui/screens/welcome.py +123 -0
  126. shotgun/tui/services/conversation_service.py +5 -2
  127. shotgun/tui/utils/mode_progress.py +20 -86
  128. shotgun/tui/widgets/__init__.py +2 -1
  129. shotgun/tui/widgets/approval_widget.py +152 -0
  130. shotgun/tui/widgets/cascade_confirmation_widget.py +203 -0
  131. shotgun/tui/widgets/plan_panel.py +129 -0
  132. shotgun/tui/widgets/step_checkpoint_widget.py +180 -0
  133. shotgun/tui/widgets/widget_coordinator.py +1 -1
  134. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/METADATA +11 -4
  135. shotgun_sh-0.4.0.dev1.dist-info/RECORD +242 -0
  136. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/WHEEL +1 -1
  137. shotgun_sh-0.2.17.dist-info/RECORD +0 -194
  138. /shotgun/agents/{history → conversation/history}/__init__.py +0 -0
  139. /shotgun/agents/{history → conversation/history}/context_extraction.py +0 -0
  140. /shotgun/agents/{history → conversation/history}/history_building.py +0 -0
  141. /shotgun/agents/{history → conversation/history}/message_utils.py +0 -0
  142. /shotgun/agents/{history → conversation/history}/token_counting/__init__.py +0 -0
  143. /shotgun/agents/{history → conversation/history}/token_counting/base.py +0 -0
  144. /shotgun/agents/{history → conversation/history}/token_counting/openai.py +0 -0
  145. /shotgun/agents/{history → conversation/history}/token_counting/sentencepiece_counter.py +0 -0
  146. /shotgun/agents/{history → conversation/history}/token_counting/tokenizer_cache.py +0 -0
  147. /shotgun/agents/{history → conversation/history}/token_counting/utils.py +0 -0
  148. /shotgun/agents/{history → conversation/history}/token_estimation.py +0 -0
  149. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/entry_points.txt +0 -0
  150. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/licenses/LICENSE +0 -0
@@ -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
- # Migration: Rename user_id to shotgun_instance_id (config v2 -> v3)
80
- if "user_id" in data and SHOTGUN_INSTANCE_ID_FIELD not in data:
81
- data[SHOTGUN_INSTANCE_ID_FIELD] = data.pop("user_id")
82
- data["config_version"] = 3
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
- "Migrated config v2->v3: renamed user_id to shotgun_instance_id"
296
+ f"Config needs migration from v{current_version} to v{CURRENT_CONFIG_VERSION}"
85
297
  )
86
-
87
- # Migration: Set shown_welcome_screen for existing BYOK users
88
- # If shown_welcome_screen doesn't exist AND any BYOK provider has a key,
89
- # set it to False so they see the welcome screen once
90
- if "shown_welcome_screen" not in data:
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
- # Migration: Add marketing config for v3 -> v4
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
- # Validate selected_model if in BYOK mode (no Shotgun key)
120
- if not self._provider_has_api_key(self._config.shotgun):
121
- should_save = False
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
- if self._config.selected_model in MODEL_SPECS:
128
- spec = MODEL_SPECS[self._config.selected_model]
129
- if not await self.has_provider_key(spec.provider):
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 not found in MODEL_SPECS, resetting",
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.GPT_5,
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 Exception as e:
395
+ except ConfigMigrationError as migration_error:
396
+ # Migration failed - automatically create fresh config with migration info
173
397
  logger.error(
174
- "Failed to load configuration from %s: %s", self.config_path, e
398
+ "Config migration failed, creating fresh config: %s", migration_error
175
399
  )
176
- logger.info("Creating new configuration with generated shotgun_instance_id")
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.GPT_5,
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, api_key: str | None = None, supabase_jwt: str | None = None
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