shotgun-sh 0.1.9__py3-none-any.whl → 0.2.11__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.

Potentially problematic release.


This version of shotgun-sh might be problematic. Click here for more details.

Files changed (150) hide show
  1. shotgun/agents/agent_manager.py +761 -52
  2. shotgun/agents/common.py +80 -75
  3. shotgun/agents/config/constants.py +21 -10
  4. shotgun/agents/config/manager.py +322 -97
  5. shotgun/agents/config/models.py +114 -84
  6. shotgun/agents/config/provider.py +232 -88
  7. shotgun/agents/context_analyzer/__init__.py +28 -0
  8. shotgun/agents/context_analyzer/analyzer.py +471 -0
  9. shotgun/agents/context_analyzer/constants.py +9 -0
  10. shotgun/agents/context_analyzer/formatter.py +115 -0
  11. shotgun/agents/context_analyzer/models.py +212 -0
  12. shotgun/agents/conversation_history.py +125 -2
  13. shotgun/agents/conversation_manager.py +57 -19
  14. shotgun/agents/export.py +6 -7
  15. shotgun/agents/history/compaction.py +23 -3
  16. shotgun/agents/history/context_extraction.py +93 -6
  17. shotgun/agents/history/history_processors.py +179 -11
  18. shotgun/agents/history/token_counting/__init__.py +31 -0
  19. shotgun/agents/history/token_counting/anthropic.py +127 -0
  20. shotgun/agents/history/token_counting/base.py +78 -0
  21. shotgun/agents/history/token_counting/openai.py +90 -0
  22. shotgun/agents/history/token_counting/sentencepiece_counter.py +127 -0
  23. shotgun/agents/history/token_counting/tokenizer_cache.py +92 -0
  24. shotgun/agents/history/token_counting/utils.py +144 -0
  25. shotgun/agents/history/token_estimation.py +12 -12
  26. shotgun/agents/llm.py +62 -0
  27. shotgun/agents/models.py +59 -4
  28. shotgun/agents/plan.py +6 -7
  29. shotgun/agents/research.py +7 -8
  30. shotgun/agents/specify.py +6 -7
  31. shotgun/agents/tasks.py +6 -7
  32. shotgun/agents/tools/__init__.py +0 -2
  33. shotgun/agents/tools/codebase/codebase_shell.py +6 -0
  34. shotgun/agents/tools/codebase/directory_lister.py +6 -0
  35. shotgun/agents/tools/codebase/file_read.py +11 -2
  36. shotgun/agents/tools/codebase/query_graph.py +6 -0
  37. shotgun/agents/tools/codebase/retrieve_code.py +6 -0
  38. shotgun/agents/tools/file_management.py +82 -16
  39. shotgun/agents/tools/registry.py +217 -0
  40. shotgun/agents/tools/web_search/__init__.py +55 -16
  41. shotgun/agents/tools/web_search/anthropic.py +76 -51
  42. shotgun/agents/tools/web_search/gemini.py +50 -27
  43. shotgun/agents/tools/web_search/openai.py +26 -17
  44. shotgun/agents/tools/web_search/utils.py +2 -2
  45. shotgun/agents/usage_manager.py +164 -0
  46. shotgun/api_endpoints.py +15 -0
  47. shotgun/cli/clear.py +53 -0
  48. shotgun/cli/codebase/commands.py +71 -2
  49. shotgun/cli/compact.py +186 -0
  50. shotgun/cli/config.py +41 -67
  51. shotgun/cli/context.py +111 -0
  52. shotgun/cli/export.py +1 -1
  53. shotgun/cli/feedback.py +50 -0
  54. shotgun/cli/models.py +3 -2
  55. shotgun/cli/plan.py +1 -1
  56. shotgun/cli/research.py +1 -1
  57. shotgun/cli/specify.py +1 -1
  58. shotgun/cli/tasks.py +1 -1
  59. shotgun/cli/update.py +18 -5
  60. shotgun/codebase/core/change_detector.py +5 -3
  61. shotgun/codebase/core/code_retrieval.py +4 -2
  62. shotgun/codebase/core/ingestor.py +169 -19
  63. shotgun/codebase/core/manager.py +177 -13
  64. shotgun/codebase/core/nl_query.py +1 -1
  65. shotgun/codebase/models.py +28 -3
  66. shotgun/codebase/service.py +14 -2
  67. shotgun/exceptions.py +32 -0
  68. shotgun/llm_proxy/__init__.py +19 -0
  69. shotgun/llm_proxy/clients.py +44 -0
  70. shotgun/llm_proxy/constants.py +15 -0
  71. shotgun/logging_config.py +18 -27
  72. shotgun/main.py +91 -4
  73. shotgun/posthog_telemetry.py +87 -40
  74. shotgun/prompts/agents/export.j2 +18 -1
  75. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +5 -1
  76. shotgun/prompts/agents/partials/interactive_mode.j2 +24 -7
  77. shotgun/prompts/agents/plan.j2 +1 -1
  78. shotgun/prompts/agents/research.j2 +1 -1
  79. shotgun/prompts/agents/specify.j2 +270 -3
  80. shotgun/prompts/agents/state/system_state.j2 +4 -0
  81. shotgun/prompts/agents/tasks.j2 +1 -1
  82. shotgun/prompts/codebase/partials/cypher_rules.j2 +13 -0
  83. shotgun/prompts/loader.py +2 -2
  84. shotgun/prompts/tools/web_search.j2 +14 -0
  85. shotgun/sdk/codebase.py +60 -2
  86. shotgun/sentry_telemetry.py +28 -21
  87. shotgun/settings.py +238 -0
  88. shotgun/shotgun_web/__init__.py +19 -0
  89. shotgun/shotgun_web/client.py +138 -0
  90. shotgun/shotgun_web/constants.py +21 -0
  91. shotgun/shotgun_web/models.py +47 -0
  92. shotgun/telemetry.py +24 -36
  93. shotgun/tui/app.py +275 -23
  94. shotgun/tui/commands/__init__.py +1 -1
  95. shotgun/tui/components/context_indicator.py +179 -0
  96. shotgun/tui/components/mode_indicator.py +70 -0
  97. shotgun/tui/components/status_bar.py +48 -0
  98. shotgun/tui/components/vertical_tail.py +6 -0
  99. shotgun/tui/containers.py +91 -0
  100. shotgun/tui/dependencies.py +39 -0
  101. shotgun/tui/filtered_codebase_service.py +46 -0
  102. shotgun/tui/protocols.py +45 -0
  103. shotgun/tui/screens/chat/__init__.py +5 -0
  104. shotgun/tui/screens/chat/chat.tcss +54 -0
  105. shotgun/tui/screens/chat/chat_screen.py +1234 -0
  106. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +64 -0
  107. shotgun/tui/screens/chat/codebase_index_selection.py +12 -0
  108. shotgun/tui/screens/chat/help_text.py +40 -0
  109. shotgun/tui/screens/chat/prompt_history.py +48 -0
  110. shotgun/tui/screens/chat.tcss +11 -0
  111. shotgun/tui/screens/chat_screen/command_providers.py +226 -11
  112. shotgun/tui/screens/chat_screen/history/__init__.py +22 -0
  113. shotgun/tui/screens/chat_screen/history/agent_response.py +66 -0
  114. shotgun/tui/screens/chat_screen/history/chat_history.py +116 -0
  115. shotgun/tui/screens/chat_screen/history/formatters.py +115 -0
  116. shotgun/tui/screens/chat_screen/history/partial_response.py +43 -0
  117. shotgun/tui/screens/chat_screen/history/user_question.py +42 -0
  118. shotgun/tui/screens/confirmation_dialog.py +151 -0
  119. shotgun/tui/screens/feedback.py +193 -0
  120. shotgun/tui/screens/github_issue.py +102 -0
  121. shotgun/tui/screens/model_picker.py +352 -0
  122. shotgun/tui/screens/onboarding.py +431 -0
  123. shotgun/tui/screens/pipx_migration.py +153 -0
  124. shotgun/tui/screens/provider_config.py +156 -39
  125. shotgun/tui/screens/shotgun_auth.py +295 -0
  126. shotgun/tui/screens/welcome.py +198 -0
  127. shotgun/tui/services/__init__.py +5 -0
  128. shotgun/tui/services/conversation_service.py +184 -0
  129. shotgun/tui/state/__init__.py +7 -0
  130. shotgun/tui/state/processing_state.py +185 -0
  131. shotgun/tui/utils/mode_progress.py +14 -7
  132. shotgun/tui/widgets/__init__.py +5 -0
  133. shotgun/tui/widgets/widget_coordinator.py +262 -0
  134. shotgun/utils/datetime_utils.py +77 -0
  135. shotgun/utils/env_utils.py +13 -0
  136. shotgun/utils/file_system_utils.py +22 -2
  137. shotgun/utils/marketing.py +110 -0
  138. shotgun/utils/source_detection.py +16 -0
  139. shotgun/utils/update_checker.py +73 -21
  140. shotgun_sh-0.2.11.dist-info/METADATA +130 -0
  141. shotgun_sh-0.2.11.dist-info/RECORD +194 -0
  142. {shotgun_sh-0.1.9.dist-info → shotgun_sh-0.2.11.dist-info}/entry_points.txt +1 -0
  143. {shotgun_sh-0.1.9.dist-info → shotgun_sh-0.2.11.dist-info}/licenses/LICENSE +1 -1
  144. shotgun/agents/history/token_counting.py +0 -429
  145. shotgun/agents/tools/user_interaction.py +0 -37
  146. shotgun/tui/screens/chat.py +0 -818
  147. shotgun/tui/screens/chat_screen/history.py +0 -222
  148. shotgun_sh-0.1.9.dist-info/METADATA +0 -466
  149. shotgun_sh-0.1.9.dist-info/RECORD +0 -131
  150. {shotgun_sh-0.1.9.dist-info → shotgun_sh-0.2.11.dist-info}/WHEEL +0 -0
@@ -1,29 +1,38 @@
1
1
  """Configuration manager for Shotgun CLI."""
2
2
 
3
3
  import json
4
- import os
5
4
  import uuid
6
5
  from pathlib import Path
7
6
  from typing import Any
8
7
 
8
+ import aiofiles
9
+ import aiofiles.os
9
10
  from pydantic import SecretStr
10
11
 
11
12
  from shotgun.logging_config import get_logger
12
13
  from shotgun.utils import get_shotgun_home
13
14
 
14
15
  from .constants import (
15
- ANTHROPIC_API_KEY_ENV,
16
- ANTHROPIC_PROVIDER,
17
16
  API_KEY_FIELD,
18
- GEMINI_API_KEY_ENV,
19
- GOOGLE_PROVIDER,
20
- OPENAI_API_KEY_ENV,
21
- OPENAI_PROVIDER,
17
+ SHOTGUN_INSTANCE_ID_FIELD,
18
+ SUPABASE_JWT_FIELD,
19
+ ConfigSection,
20
+ )
21
+ from .models import (
22
+ AnthropicConfig,
23
+ GoogleConfig,
24
+ ModelName,
25
+ OpenAIConfig,
26
+ ProviderType,
27
+ ShotgunAccountConfig,
28
+ ShotgunConfig,
22
29
  )
23
- from .models import ProviderType, ShotgunConfig
24
30
 
25
31
  logger = get_logger(__name__)
26
32
 
33
+ # Type alias for provider configuration objects
34
+ ProviderConfig = OpenAIConfig | AnthropicConfig | GoogleConfig | ShotgunAccountConfig
35
+
27
36
 
28
37
  class ConfigManager:
29
38
  """Manager for Shotgun configuration."""
@@ -41,27 +50,65 @@ class ConfigManager:
41
50
 
42
51
  self._config: ShotgunConfig | None = None
43
52
 
44
- def load(self) -> ShotgunConfig:
53
+ async def load(self, force_reload: bool = True) -> ShotgunConfig:
45
54
  """Load configuration from file.
46
55
 
56
+ Args:
57
+ force_reload: If True, reload from disk even if cached (default: True)
58
+
47
59
  Returns:
48
60
  ShotgunConfig: Loaded configuration or default config if file doesn't exist
49
61
  """
50
- if self._config is not None:
62
+ if self._config is not None and not force_reload:
51
63
  return self._config
52
64
 
53
- if not self.config_path.exists():
65
+ if not await aiofiles.os.path.exists(self.config_path):
54
66
  logger.info(
55
- "Configuration file not found, creating new config with user_id: %s",
67
+ "Configuration file not found, creating new config at: %s",
56
68
  self.config_path,
57
69
  )
58
- # Create new config with generated user_id
59
- self._config = self.initialize()
70
+ # Create new config with generated shotgun_instance_id
71
+ self._config = await self.initialize()
60
72
  return self._config
61
73
 
62
74
  try:
63
- with open(self.config_path, encoding="utf-8") as f:
64
- data = json.load(f)
75
+ async with aiofiles.open(self.config_path, encoding="utf-8") as f:
76
+ content = await f.read()
77
+ data = json.loads(content)
78
+
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
83
+ logger.info(
84
+ "Migrated config v2->v3: renamed user_id to shotgun_instance_id"
85
+ )
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"
105
+ )
106
+
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")
65
112
 
66
113
  # Convert plain text secrets to SecretStr objects
67
114
  self._convert_secrets_to_secretstr(data)
@@ -69,20 +116,56 @@ class ConfigManager:
69
116
  self._config = ShotgunConfig.model_validate(data)
70
117
  logger.debug("Configuration loaded successfully from %s", self.config_path)
71
118
 
72
- # Check if the default provider has a key, if not find one that does
73
- if not self.has_provider_key(self._config.default_provider):
74
- original_default = self._config.default_provider
75
- # Find first provider with a configured key
76
- for provider in ProviderType:
77
- if self.has_provider_key(provider):
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
122
+
123
+ # If selected_model is set, verify its provider has a key
124
+ if self._config.selected_model:
125
+ from .models import MODEL_SPECS
126
+
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:
78
137
  logger.info(
79
- "Default provider %s has no API key, updating to %s",
80
- original_default.value,
81
- provider.value,
138
+ "Selected model %s not found in MODEL_SPECS, resetting",
139
+ self._config.selected_model.value,
82
140
  )
83
- self._config.default_provider = provider
84
- self.save(self._config)
85
- break
141
+ self._config.selected_model = None
142
+ should_save = True
143
+
144
+ # If no selected_model or it was invalid, find first available model
145
+ if not self._config.selected_model:
146
+ for provider in ProviderType:
147
+ if await self.has_provider_key(provider):
148
+ # Set to that provider's default model
149
+ from .models import MODEL_SPECS, ModelName
150
+
151
+ # Find default model for this provider
152
+ provider_models = {
153
+ ProviderType.OPENAI: ModelName.GPT_5,
154
+ ProviderType.ANTHROPIC: ModelName.CLAUDE_HAIKU_4_5,
155
+ ProviderType.GOOGLE: ModelName.GEMINI_2_5_PRO,
156
+ }
157
+
158
+ if provider in provider_models:
159
+ 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
+ should_save = True
165
+ break
166
+
167
+ if should_save:
168
+ await self.save(self._config)
86
169
 
87
170
  return self._config
88
171
 
@@ -90,11 +173,11 @@ class ConfigManager:
90
173
  logger.error(
91
174
  "Failed to load configuration from %s: %s", self.config_path, e
92
175
  )
93
- logger.info("Creating new configuration with generated user_id")
94
- self._config = self.initialize()
176
+ logger.info("Creating new configuration with generated shotgun_instance_id")
177
+ self._config = await self.initialize()
95
178
  return self._config
96
179
 
97
- def save(self, config: ShotgunConfig | None = None) -> None:
180
+ async def save(self, config: ShotgunConfig | None = None) -> None:
98
181
  """Save configuration to file.
99
182
 
100
183
  Args:
@@ -104,22 +187,23 @@ class ConfigManager:
104
187
  if self._config:
105
188
  config = self._config
106
189
  else:
107
- # Create a new config with generated user_id
190
+ # Create a new config with generated shotgun_instance_id
108
191
  config = ShotgunConfig(
109
- user_id=str(uuid.uuid4()),
110
- config_version=1,
192
+ shotgun_instance_id=str(uuid.uuid4()),
111
193
  )
112
194
 
113
195
  # Ensure directory exists
114
- self.config_path.parent.mkdir(parents=True, exist_ok=True)
196
+ await aiofiles.os.makedirs(self.config_path.parent, exist_ok=True)
115
197
 
116
198
  try:
117
199
  # Convert SecretStr to plain text for JSON serialization
118
200
  data = config.model_dump()
119
201
  self._convert_secretstr_to_plain(data)
202
+ self._convert_datetime_to_isoformat(data)
120
203
 
121
- with open(self.config_path, "w", encoding="utf-8") as f:
122
- json.dump(data, f, indent=2, ensure_ascii=False)
204
+ json_content = json.dumps(data, indent=2, ensure_ascii=False)
205
+ async with aiofiles.open(self.config_path, "w", encoding="utf-8") as f:
206
+ await f.write(json_content)
123
207
 
124
208
  logger.debug("Configuration saved to %s", self.config_path)
125
209
  self._config = config
@@ -128,16 +212,23 @@ class ConfigManager:
128
212
  logger.error("Failed to save configuration to %s: %s", self.config_path, e)
129
213
  raise
130
214
 
131
- def update_provider(self, provider: ProviderType | str, **kwargs: Any) -> None:
215
+ async def update_provider(
216
+ self, provider: ProviderType | str, **kwargs: Any
217
+ ) -> None:
132
218
  """Update provider configuration.
133
219
 
134
220
  Args:
135
221
  provider: Provider to update
136
222
  **kwargs: Configuration fields to update (only api_key supported)
137
223
  """
138
- config = self.load()
139
- provider_enum = self._ensure_provider_enum(provider)
140
- provider_config = self._get_provider_config(config, provider_enum)
224
+ config = await self.load()
225
+
226
+ # Get provider config and check if it's shotgun
227
+ provider_config, is_shotgun = self._get_provider_config_and_type(
228
+ config, provider
229
+ )
230
+ # For non-shotgun providers, we need the enum for default provider logic
231
+ provider_enum = None if is_shotgun else self._ensure_provider_enum(provider)
141
232
 
142
233
  # Only support api_key updates
143
234
  if API_KEY_FIELD in kwargs:
@@ -152,50 +243,76 @@ class ConfigManager:
152
243
  raise ValueError(f"Unsupported configuration fields: {unsupported_fields}")
153
244
 
154
245
  # If no other providers have keys configured and we just added one,
155
- # set this provider as the default
156
- if API_KEY_FIELD in kwargs and api_key_value is not None:
246
+ # set selected_model to that provider's default model (only for LLM providers, not shotgun)
247
+ if not is_shotgun and API_KEY_FIELD in kwargs and api_key_value is not None:
248
+ # provider_enum is guaranteed to be non-None here since is_shotgun is False
249
+ if provider_enum is None:
250
+ raise RuntimeError("Provider enum should not be None for LLM providers")
157
251
  other_providers = [p for p in ProviderType if p != provider_enum]
158
252
  has_other_keys = any(self.has_provider_key(p) for p in other_providers)
159
253
  if not has_other_keys:
160
- config.default_provider = provider_enum
161
-
162
- self.save(config)
254
+ # Set selected_model to this provider's default model
255
+ from .models import ModelName
256
+
257
+ provider_models = {
258
+ ProviderType.OPENAI: ModelName.GPT_5,
259
+ ProviderType.ANTHROPIC: ModelName.CLAUDE_HAIKU_4_5,
260
+ ProviderType.GOOGLE: ModelName.GEMINI_2_5_PRO,
261
+ }
262
+ if provider_enum in provider_models:
263
+ config.selected_model = provider_models[provider_enum]
264
+
265
+ # Mark welcome screen as shown when BYOK provider is configured
266
+ # This prevents the welcome screen from showing again after user has made their choice
267
+ config.shown_welcome_screen = True
268
+
269
+ await self.save(config)
270
+
271
+ async def clear_provider_key(self, provider: ProviderType | str) -> None:
272
+ """Remove the API key for the given provider (LLM provider or shotgun)."""
273
+ config = await self.load()
274
+
275
+ # Get provider config (shotgun or LLM provider)
276
+ provider_config, is_shotgun = self._get_provider_config_and_type(
277
+ config, provider
278
+ )
163
279
 
164
- def clear_provider_key(self, provider: ProviderType | str) -> None:
165
- """Remove the API key for the given provider."""
166
- config = self.load()
167
- provider_enum = self._ensure_provider_enum(provider)
168
- provider_config = self._get_provider_config(config, provider_enum)
169
280
  provider_config.api_key = None
170
- self.save(config)
171
281
 
172
- def has_provider_key(self, provider: ProviderType | str) -> bool:
282
+ # For Shotgun Account, also clear the JWT
283
+ if is_shotgun and isinstance(provider_config, ShotgunAccountConfig):
284
+ provider_config.supabase_jwt = None
285
+
286
+ await self.save(config)
287
+
288
+ async def update_selected_model(self, model_name: "ModelName") -> None:
289
+ """Update the selected model.
290
+
291
+ Args:
292
+ model_name: Model to select
293
+ """
294
+ config = await self.load()
295
+ config.selected_model = model_name
296
+ await self.save(config)
297
+
298
+ async def has_provider_key(self, provider: ProviderType | str) -> bool:
173
299
  """Check if the given provider has a non-empty API key configured.
174
300
 
175
- This checks both the configuration file and environment variables.
301
+ This checks only the configuration file.
176
302
  """
177
- config = self.load()
303
+ # Use force_reload=False to avoid infinite loop when called from load()
304
+ config = await self.load(force_reload=False)
178
305
  provider_enum = self._ensure_provider_enum(provider)
179
306
  provider_config = self._get_provider_config(config, provider_enum)
180
307
 
181
- # Check config first
182
- if self._provider_has_api_key(provider_config):
183
- return True
184
-
185
- # Check environment variable
186
- if provider_enum == ProviderType.OPENAI:
187
- return bool(os.getenv(OPENAI_API_KEY_ENV))
188
- elif provider_enum == ProviderType.ANTHROPIC:
189
- return bool(os.getenv(ANTHROPIC_API_KEY_ENV))
190
- elif provider_enum == ProviderType.GOOGLE:
191
- return bool(os.getenv(GEMINI_API_KEY_ENV))
308
+ return self._provider_has_api_key(provider_config)
192
309
 
193
- return False
194
-
195
- def has_any_provider_key(self) -> bool:
310
+ async def has_any_provider_key(self) -> bool:
196
311
  """Determine whether any provider has a configured API key."""
197
- config = self.load()
198
- return any(
312
+ # Use force_reload=False to avoid infinite loop when called from load()
313
+ config = await self.load(force_reload=False)
314
+ # Check LLM provider keys (BYOK)
315
+ has_llm_key = any(
199
316
  self._provider_has_api_key(self._get_provider_config(config, provider))
200
317
  for provider in (
201
318
  ProviderType.OPENAI,
@@ -203,50 +320,93 @@ class ConfigManager:
203
320
  ProviderType.GOOGLE,
204
321
  )
205
322
  )
323
+ # Also check Shotgun Account key
324
+ has_shotgun_key = self._provider_has_api_key(config.shotgun)
325
+ return has_llm_key or has_shotgun_key
206
326
 
207
- def initialize(self) -> ShotgunConfig:
327
+ async def initialize(self) -> ShotgunConfig:
208
328
  """Initialize configuration with defaults and save to file.
209
329
 
210
330
  Returns:
211
331
  Default ShotgunConfig
212
332
  """
213
- # Generate unique user ID for new config
333
+ # Generate unique shotgun instance ID for new config
214
334
  config = ShotgunConfig(
215
- user_id=str(uuid.uuid4()),
216
- config_version=1,
335
+ shotgun_instance_id=str(uuid.uuid4()),
217
336
  )
218
- self.save(config)
337
+ await self.save(config)
219
338
  logger.info(
220
- "Configuration initialized at %s with user_id: %s",
339
+ "Configuration initialized at %s with shotgun_instance_id: %s",
221
340
  self.config_path,
222
- config.user_id,
341
+ config.shotgun_instance_id,
223
342
  )
224
343
  return config
225
344
 
226
345
  def _convert_secrets_to_secretstr(self, data: dict[str, Any]) -> None:
227
346
  """Convert plain text secrets in data to SecretStr objects."""
228
- for provider in [OPENAI_PROVIDER, ANTHROPIC_PROVIDER, GOOGLE_PROVIDER]:
229
- if provider in data and isinstance(data[provider], dict):
347
+ for section in ConfigSection:
348
+ if section.value in data and isinstance(data[section.value], dict):
349
+ # Convert API key
230
350
  if (
231
- API_KEY_FIELD in data[provider]
232
- and data[provider][API_KEY_FIELD] is not None
351
+ API_KEY_FIELD in data[section.value]
352
+ and data[section.value][API_KEY_FIELD] is not None
233
353
  ):
234
- data[provider][API_KEY_FIELD] = SecretStr(
235
- data[provider][API_KEY_FIELD]
354
+ data[section.value][API_KEY_FIELD] = SecretStr(
355
+ data[section.value][API_KEY_FIELD]
356
+ )
357
+ # Convert supabase JWT (shotgun section only)
358
+ if (
359
+ section == ConfigSection.SHOTGUN
360
+ and SUPABASE_JWT_FIELD in data[section.value]
361
+ and data[section.value][SUPABASE_JWT_FIELD] is not None
362
+ ):
363
+ data[section.value][SUPABASE_JWT_FIELD] = SecretStr(
364
+ data[section.value][SUPABASE_JWT_FIELD]
236
365
  )
237
366
 
238
367
  def _convert_secretstr_to_plain(self, data: dict[str, Any]) -> None:
239
368
  """Convert SecretStr objects in data to plain text for JSON serialization."""
240
- for provider in [OPENAI_PROVIDER, ANTHROPIC_PROVIDER, GOOGLE_PROVIDER]:
241
- if provider in data and isinstance(data[provider], dict):
369
+ for section in ConfigSection:
370
+ if section.value in data and isinstance(data[section.value], dict):
371
+ # Convert API key
242
372
  if (
243
- API_KEY_FIELD in data[provider]
244
- and data[provider][API_KEY_FIELD] is not None
373
+ API_KEY_FIELD in data[section.value]
374
+ and data[section.value][API_KEY_FIELD] is not None
245
375
  ):
246
- if hasattr(data[provider][API_KEY_FIELD], "get_secret_value"):
247
- data[provider][API_KEY_FIELD] = data[provider][
376
+ if hasattr(data[section.value][API_KEY_FIELD], "get_secret_value"):
377
+ data[section.value][API_KEY_FIELD] = data[section.value][
248
378
  API_KEY_FIELD
249
379
  ].get_secret_value()
380
+ # Convert supabase JWT (shotgun section only)
381
+ if (
382
+ section == ConfigSection.SHOTGUN
383
+ and SUPABASE_JWT_FIELD in data[section.value]
384
+ and data[section.value][SUPABASE_JWT_FIELD] is not None
385
+ ):
386
+ if hasattr(
387
+ data[section.value][SUPABASE_JWT_FIELD], "get_secret_value"
388
+ ):
389
+ data[section.value][SUPABASE_JWT_FIELD] = data[section.value][
390
+ SUPABASE_JWT_FIELD
391
+ ].get_secret_value()
392
+
393
+ def _convert_datetime_to_isoformat(self, data: dict[str, Any]) -> None:
394
+ """Convert datetime objects in data to ISO8601 format strings for JSON serialization."""
395
+ from datetime import datetime
396
+
397
+ def convert_dict(d: dict[str, Any]) -> None:
398
+ """Recursively convert datetime objects in a dict."""
399
+ for key, value in d.items():
400
+ if isinstance(value, datetime):
401
+ d[key] = value.isoformat()
402
+ elif isinstance(value, dict):
403
+ convert_dict(value)
404
+ elif isinstance(value, list):
405
+ for item in value:
406
+ if isinstance(item, dict):
407
+ convert_dict(item)
408
+
409
+ convert_dict(data)
250
410
 
251
411
  def _ensure_provider_enum(self, provider: ProviderType | str) -> ProviderType:
252
412
  """Normalize provider values to ProviderType enum."""
@@ -279,16 +439,81 @@ class ConfigManager:
279
439
 
280
440
  return bool(value.strip())
281
441
 
282
- def get_user_id(self) -> str:
283
- """Get the user ID from configuration.
442
+ def _is_shotgun_provider(self, provider: ProviderType | str) -> bool:
443
+ """Check if provider string represents Shotgun Account.
444
+
445
+ Args:
446
+ provider: Provider type or string
447
+
448
+ Returns:
449
+ True if provider is shotgun account
450
+ """
451
+ return (
452
+ isinstance(provider, str)
453
+ and provider.lower() == ConfigSection.SHOTGUN.value
454
+ )
455
+
456
+ def _get_provider_config_and_type(
457
+ self, config: ShotgunConfig, provider: ProviderType | str
458
+ ) -> tuple[ProviderConfig, bool]:
459
+ """Get provider config, handling shotgun as special case.
460
+
461
+ Args:
462
+ config: Shotgun configuration
463
+ provider: Provider type or string
284
464
 
285
465
  Returns:
286
- The unique user ID string
466
+ Tuple of (provider_config, is_shotgun)
287
467
  """
288
- config = self.load()
289
- return config.user_id
468
+ if self._is_shotgun_provider(provider):
469
+ return (config.shotgun, True)
470
+
471
+ provider_enum = self._ensure_provider_enum(provider)
472
+ return (self._get_provider_config(config, provider_enum), False)
473
+
474
+ async def get_shotgun_instance_id(self) -> str:
475
+ """Get the shotgun instance ID from configuration.
476
+
477
+ Returns:
478
+ The unique shotgun instance ID string
479
+ """
480
+ config = await self.load()
481
+ return config.shotgun_instance_id
482
+
483
+ async def update_shotgun_account(
484
+ self, api_key: str | None = None, supabase_jwt: str | None = None
485
+ ) -> None:
486
+ """Update Shotgun Account configuration.
487
+
488
+ Args:
489
+ api_key: LiteLLM proxy API key (optional)
490
+ supabase_jwt: Supabase authentication JWT (optional)
491
+ """
492
+ config = await self.load()
493
+
494
+ if api_key is not None:
495
+ config.shotgun.api_key = SecretStr(api_key) if api_key else None
496
+
497
+ if supabase_jwt is not None:
498
+ config.shotgun.supabase_jwt = (
499
+ SecretStr(supabase_jwt) if supabase_jwt else None
500
+ )
501
+
502
+ await self.save(config)
503
+ logger.info("Updated Shotgun Account configuration")
504
+
505
+
506
+ # Global singleton instance
507
+ _config_manager_instance: ConfigManager | None = None
290
508
 
291
509
 
292
510
  def get_config_manager() -> ConfigManager:
293
- """Get the global ConfigManager instance."""
294
- return ConfigManager()
511
+ """Get the global singleton ConfigManager instance.
512
+
513
+ Returns:
514
+ The singleton ConfigManager instance
515
+ """
516
+ global _config_manager_instance
517
+ if _config_manager_instance is None:
518
+ _config_manager_instance = ConfigManager()
519
+ return _config_manager_instance