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.
Files changed (175) hide show
  1. shotgun/agents/agent_manager.py +382 -60
  2. shotgun/agents/common.py +15 -9
  3. shotgun/agents/config/README.md +89 -0
  4. shotgun/agents/config/__init__.py +10 -1
  5. shotgun/agents/config/constants.py +0 -6
  6. shotgun/agents/config/manager.py +383 -82
  7. shotgun/agents/config/models.py +122 -18
  8. shotgun/agents/config/provider.py +81 -15
  9. shotgun/agents/config/streaming_test.py +119 -0
  10. shotgun/agents/context_analyzer/__init__.py +28 -0
  11. shotgun/agents/context_analyzer/analyzer.py +475 -0
  12. shotgun/agents/context_analyzer/constants.py +9 -0
  13. shotgun/agents/context_analyzer/formatter.py +115 -0
  14. shotgun/agents/context_analyzer/models.py +212 -0
  15. shotgun/agents/conversation/__init__.py +18 -0
  16. shotgun/agents/conversation/filters.py +164 -0
  17. shotgun/agents/conversation/history/chunking.py +278 -0
  18. shotgun/agents/{history → conversation/history}/compaction.py +36 -5
  19. shotgun/agents/{history → conversation/history}/constants.py +5 -0
  20. shotgun/agents/conversation/history/file_content_deduplication.py +216 -0
  21. shotgun/agents/{history → conversation/history}/history_processors.py +380 -8
  22. shotgun/agents/{history → conversation/history}/token_counting/anthropic.py +25 -1
  23. shotgun/agents/{history → conversation/history}/token_counting/base.py +14 -3
  24. shotgun/agents/{history → conversation/history}/token_counting/openai.py +11 -1
  25. shotgun/agents/{history → conversation/history}/token_counting/sentencepiece_counter.py +8 -0
  26. shotgun/agents/{history → conversation/history}/token_counting/tokenizer_cache.py +3 -1
  27. shotgun/agents/{history → conversation/history}/token_counting/utils.py +0 -3
  28. shotgun/agents/{conversation_manager.py → conversation/manager.py} +36 -20
  29. shotgun/agents/{conversation_history.py → conversation/models.py} +8 -92
  30. shotgun/agents/error/__init__.py +11 -0
  31. shotgun/agents/error/models.py +19 -0
  32. shotgun/agents/export.py +2 -2
  33. shotgun/agents/plan.py +2 -2
  34. shotgun/agents/research.py +3 -3
  35. shotgun/agents/runner.py +230 -0
  36. shotgun/agents/specify.py +2 -2
  37. shotgun/agents/tasks.py +2 -2
  38. shotgun/agents/tools/codebase/codebase_shell.py +6 -0
  39. shotgun/agents/tools/codebase/directory_lister.py +6 -0
  40. shotgun/agents/tools/codebase/file_read.py +11 -2
  41. shotgun/agents/tools/codebase/query_graph.py +6 -0
  42. shotgun/agents/tools/codebase/retrieve_code.py +6 -0
  43. shotgun/agents/tools/file_management.py +27 -7
  44. shotgun/agents/tools/registry.py +217 -0
  45. shotgun/agents/tools/web_search/__init__.py +8 -8
  46. shotgun/agents/tools/web_search/anthropic.py +8 -2
  47. shotgun/agents/tools/web_search/gemini.py +7 -1
  48. shotgun/agents/tools/web_search/openai.py +8 -2
  49. shotgun/agents/tools/web_search/utils.py +2 -2
  50. shotgun/agents/usage_manager.py +16 -11
  51. shotgun/api_endpoints.py +7 -3
  52. shotgun/build_constants.py +2 -2
  53. shotgun/cli/clear.py +53 -0
  54. shotgun/cli/compact.py +188 -0
  55. shotgun/cli/config.py +8 -5
  56. shotgun/cli/context.py +154 -0
  57. shotgun/cli/error_handler.py +24 -0
  58. shotgun/cli/export.py +34 -34
  59. shotgun/cli/feedback.py +4 -2
  60. shotgun/cli/models.py +1 -0
  61. shotgun/cli/plan.py +34 -34
  62. shotgun/cli/research.py +18 -10
  63. shotgun/cli/spec/__init__.py +5 -0
  64. shotgun/cli/spec/backup.py +81 -0
  65. shotgun/cli/spec/commands.py +132 -0
  66. shotgun/cli/spec/models.py +48 -0
  67. shotgun/cli/spec/pull_service.py +219 -0
  68. shotgun/cli/specify.py +20 -19
  69. shotgun/cli/tasks.py +34 -34
  70. shotgun/cli/update.py +16 -2
  71. shotgun/codebase/core/change_detector.py +5 -3
  72. shotgun/codebase/core/code_retrieval.py +4 -2
  73. shotgun/codebase/core/ingestor.py +163 -15
  74. shotgun/codebase/core/manager.py +13 -4
  75. shotgun/codebase/core/nl_query.py +1 -1
  76. shotgun/codebase/models.py +2 -0
  77. shotgun/exceptions.py +357 -0
  78. shotgun/llm_proxy/__init__.py +17 -0
  79. shotgun/llm_proxy/client.py +215 -0
  80. shotgun/llm_proxy/models.py +137 -0
  81. shotgun/logging_config.py +60 -27
  82. shotgun/main.py +77 -11
  83. shotgun/posthog_telemetry.py +38 -29
  84. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +28 -2
  85. shotgun/prompts/agents/partials/interactive_mode.j2 +3 -3
  86. shotgun/prompts/agents/plan.j2 +16 -0
  87. shotgun/prompts/agents/research.j2 +16 -3
  88. shotgun/prompts/agents/specify.j2 +54 -1
  89. shotgun/prompts/agents/state/system_state.j2 +0 -2
  90. shotgun/prompts/agents/tasks.j2 +16 -0
  91. shotgun/prompts/history/chunk_summarization.j2 +34 -0
  92. shotgun/prompts/history/combine_summaries.j2 +53 -0
  93. shotgun/sdk/codebase.py +14 -3
  94. shotgun/sentry_telemetry.py +163 -16
  95. shotgun/settings.py +243 -0
  96. shotgun/shotgun_web/__init__.py +67 -1
  97. shotgun/shotgun_web/client.py +42 -1
  98. shotgun/shotgun_web/constants.py +46 -0
  99. shotgun/shotgun_web/exceptions.py +29 -0
  100. shotgun/shotgun_web/models.py +390 -0
  101. shotgun/shotgun_web/shared_specs/__init__.py +32 -0
  102. shotgun/shotgun_web/shared_specs/file_scanner.py +175 -0
  103. shotgun/shotgun_web/shared_specs/hasher.py +83 -0
  104. shotgun/shotgun_web/shared_specs/models.py +71 -0
  105. shotgun/shotgun_web/shared_specs/upload_pipeline.py +329 -0
  106. shotgun/shotgun_web/shared_specs/utils.py +34 -0
  107. shotgun/shotgun_web/specs_client.py +703 -0
  108. shotgun/shotgun_web/supabase_client.py +31 -0
  109. shotgun/telemetry.py +10 -33
  110. shotgun/tui/app.py +310 -46
  111. shotgun/tui/commands/__init__.py +1 -1
  112. shotgun/tui/components/context_indicator.py +179 -0
  113. shotgun/tui/components/mode_indicator.py +70 -0
  114. shotgun/tui/components/status_bar.py +48 -0
  115. shotgun/tui/containers.py +91 -0
  116. shotgun/tui/dependencies.py +39 -0
  117. shotgun/tui/layout.py +5 -0
  118. shotgun/tui/protocols.py +45 -0
  119. shotgun/tui/screens/chat/__init__.py +5 -0
  120. shotgun/tui/screens/chat/chat.tcss +54 -0
  121. shotgun/tui/screens/chat/chat_screen.py +1531 -0
  122. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +243 -0
  123. shotgun/tui/screens/chat/codebase_index_selection.py +12 -0
  124. shotgun/tui/screens/chat/help_text.py +40 -0
  125. shotgun/tui/screens/chat/prompt_history.py +48 -0
  126. shotgun/tui/screens/chat.tcss +11 -0
  127. shotgun/tui/screens/chat_screen/command_providers.py +91 -4
  128. shotgun/tui/screens/chat_screen/hint_message.py +76 -1
  129. shotgun/tui/screens/chat_screen/history/__init__.py +22 -0
  130. shotgun/tui/screens/chat_screen/history/agent_response.py +66 -0
  131. shotgun/tui/screens/chat_screen/history/chat_history.py +115 -0
  132. shotgun/tui/screens/chat_screen/history/formatters.py +115 -0
  133. shotgun/tui/screens/chat_screen/history/partial_response.py +43 -0
  134. shotgun/tui/screens/chat_screen/history/user_question.py +42 -0
  135. shotgun/tui/screens/confirmation_dialog.py +191 -0
  136. shotgun/tui/screens/directory_setup.py +45 -41
  137. shotgun/tui/screens/feedback.py +14 -7
  138. shotgun/tui/screens/github_issue.py +111 -0
  139. shotgun/tui/screens/model_picker.py +77 -32
  140. shotgun/tui/screens/onboarding.py +580 -0
  141. shotgun/tui/screens/pipx_migration.py +205 -0
  142. shotgun/tui/screens/provider_config.py +116 -35
  143. shotgun/tui/screens/shared_specs/__init__.py +21 -0
  144. shotgun/tui/screens/shared_specs/create_spec_dialog.py +273 -0
  145. shotgun/tui/screens/shared_specs/models.py +56 -0
  146. shotgun/tui/screens/shared_specs/share_specs_dialog.py +390 -0
  147. shotgun/tui/screens/shared_specs/upload_progress_screen.py +452 -0
  148. shotgun/tui/screens/shotgun_auth.py +112 -18
  149. shotgun/tui/screens/spec_pull.py +288 -0
  150. shotgun/tui/screens/welcome.py +137 -11
  151. shotgun/tui/services/__init__.py +5 -0
  152. shotgun/tui/services/conversation_service.py +187 -0
  153. shotgun/tui/state/__init__.py +7 -0
  154. shotgun/tui/state/processing_state.py +185 -0
  155. shotgun/tui/utils/mode_progress.py +14 -7
  156. shotgun/tui/widgets/__init__.py +5 -0
  157. shotgun/tui/widgets/widget_coordinator.py +263 -0
  158. shotgun/utils/file_system_utils.py +22 -2
  159. shotgun/utils/marketing.py +110 -0
  160. shotgun/utils/update_checker.py +69 -14
  161. shotgun_sh-0.3.3.dev1.dist-info/METADATA +472 -0
  162. shotgun_sh-0.3.3.dev1.dist-info/RECORD +229 -0
  163. {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/WHEEL +1 -1
  164. {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/entry_points.txt +1 -0
  165. {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/licenses/LICENSE +1 -1
  166. shotgun/tui/screens/chat.py +0 -996
  167. shotgun/tui/screens/chat_screen/history.py +0 -335
  168. shotgun_sh-0.2.8.dev2.dist-info/METADATA +0 -126
  169. shotgun_sh-0.2.8.dev2.dist-info/RECORD +0 -155
  170. /shotgun/agents/{history → conversation/history}/__init__.py +0 -0
  171. /shotgun/agents/{history → conversation/history}/context_extraction.py +0 -0
  172. /shotgun/agents/{history → conversation/history}/history_building.py +0 -0
  173. /shotgun/agents/{history → conversation/history}/message_utils.py +0 -0
  174. /shotgun/agents/{history → conversation/history}/token_counting/__init__.py +0 -0
  175. /shotgun/agents/{history → conversation/history}/token_estimation.py +0 -0
@@ -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 self.config_path.exists():
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
- data = json.load(f)
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
- # Migration: Rename user_id to shotgun_instance_id (config v2 -> v3)
77
- if "user_id" in data and SHOTGUN_INSTANCE_ID_FIELD not in data:
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
- "Migrated config v2->v3: renamed user_id to shotgun_instance_id"
275
+ f"Config needs migration from v{current_version} to v{CURRENT_CONFIG_VERSION}"
82
276
  )
83
-
84
- # Migration: Set shown_welcome_screen for existing BYOK users
85
- # If shown_welcome_screen doesn't exist AND any BYOK provider has a key,
86
- # set it to False so they see the welcome screen once
87
- if "shown_welcome_screen" not in data:
88
- has_byok_key = False
89
- for section in ["openai", "anthropic", "google"]:
90
- if (
91
- section in data
92
- and isinstance(data[section], dict)
93
- and data[section].get("api_key")
94
- ):
95
- has_byok_key = True
96
- break
97
-
98
- if has_byok_key:
99
- data["shown_welcome_screen"] = False
100
- logger.info(
101
- "Existing BYOK user detected: set shown_welcome_screen=False to show welcome screen"
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
- # Validate selected_model if in BYOK mode (no Shotgun key)
111
- if not self._provider_has_api_key(self._config.shotgun):
112
- should_save = False
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
- if self._config.selected_model in MODEL_SPECS:
119
- spec = MODEL_SPECS[self._config.selected_model]
120
- if not self.has_provider_key(spec.provider):
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 not found in MODEL_SPECS, resetting",
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.GPT_5,
145
- ProviderType.ANTHROPIC: ModelName.CLAUDE_SONNET_4_5,
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 Exception as e:
374
+ except ConfigMigrationError as migration_error:
375
+ # Migration failed - automatically create fresh config with migration info
164
376
  logger.error(
165
- "Failed to load configuration from %s: %s", self.config_path, e
377
+ "Config migration failed, creating fresh config: %s", migration_error
166
378
  )
167
- logger.info("Creating new configuration with generated shotgun_instance_id")
168
- self._config = self.initialize()
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.mkdir(parents=True, exist_ok=True)
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
- with open(self.config_path, "w", encoding="utf-8") as f:
195
- json.dump(data, f, indent=2, ensure_ascii=False)
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(self, provider: ProviderType | str, **kwargs: Any) -> None:
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.GPT_5,
246
- ProviderType.ANTHROPIC: ModelName.CLAUDE_SONNET_4_5,
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
- self.save(config)
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
- def clear_provider_key(self, provider: ProviderType | str) -> None:
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
- self.save(config)
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, api_key: str | None = None, supabase_jwt: str | None = None
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
- self.save(config)
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