shotgun-sh 0.2.11__py3-none-any.whl → 0.2.11.dev2__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 (73) hide show
  1. shotgun/agents/agent_manager.py +28 -194
  2. shotgun/agents/common.py +8 -14
  3. shotgun/agents/config/manager.py +33 -64
  4. shotgun/agents/config/models.py +1 -25
  5. shotgun/agents/config/provider.py +2 -2
  6. shotgun/agents/context_analyzer/analyzer.py +24 -2
  7. shotgun/agents/conversation_manager.py +19 -35
  8. shotgun/agents/export.py +2 -2
  9. shotgun/agents/history/history_processors.py +3 -99
  10. shotgun/agents/history/token_counting/anthropic.py +1 -17
  11. shotgun/agents/history/token_counting/base.py +3 -14
  12. shotgun/agents/history/token_counting/openai.py +1 -11
  13. shotgun/agents/history/token_counting/sentencepiece_counter.py +0 -8
  14. shotgun/agents/history/token_counting/tokenizer_cache.py +1 -3
  15. shotgun/agents/history/token_counting/utils.py +3 -0
  16. shotgun/agents/plan.py +2 -2
  17. shotgun/agents/research.py +3 -3
  18. shotgun/agents/specify.py +2 -2
  19. shotgun/agents/tasks.py +2 -2
  20. shotgun/agents/tools/codebase/file_read.py +2 -5
  21. shotgun/agents/tools/file_management.py +7 -11
  22. shotgun/agents/tools/web_search/__init__.py +8 -8
  23. shotgun/agents/tools/web_search/anthropic.py +2 -2
  24. shotgun/agents/tools/web_search/gemini.py +1 -1
  25. shotgun/agents/tools/web_search/openai.py +1 -1
  26. shotgun/agents/tools/web_search/utils.py +2 -2
  27. shotgun/agents/usage_manager.py +11 -16
  28. shotgun/build_constants.py +2 -2
  29. shotgun/cli/clear.py +1 -2
  30. shotgun/cli/compact.py +3 -3
  31. shotgun/cli/config.py +5 -8
  32. shotgun/cli/context.py +2 -2
  33. shotgun/cli/export.py +1 -1
  34. shotgun/cli/feedback.py +2 -4
  35. shotgun/cli/plan.py +1 -1
  36. shotgun/cli/research.py +1 -1
  37. shotgun/cli/specify.py +1 -1
  38. shotgun/cli/tasks.py +1 -1
  39. shotgun/codebase/core/change_detector.py +3 -5
  40. shotgun/codebase/core/code_retrieval.py +2 -4
  41. shotgun/codebase/core/ingestor.py +8 -10
  42. shotgun/codebase/core/manager.py +3 -3
  43. shotgun/codebase/core/nl_query.py +1 -1
  44. shotgun/logging_config.py +17 -10
  45. shotgun/main.py +1 -3
  46. shotgun/posthog_telemetry.py +4 -14
  47. shotgun/sentry_telemetry.py +2 -22
  48. shotgun/telemetry.py +1 -3
  49. shotgun/tui/app.py +65 -71
  50. shotgun/tui/components/context_indicator.py +0 -43
  51. shotgun/tui/containers.py +17 -15
  52. shotgun/tui/dependencies.py +2 -2
  53. shotgun/tui/screens/chat/chat_screen.py +40 -164
  54. shotgun/tui/screens/chat/help_text.py +15 -16
  55. shotgun/tui/screens/chat_screen/command_providers.py +0 -10
  56. shotgun/tui/screens/feedback.py +4 -4
  57. shotgun/tui/screens/model_picker.py +20 -21
  58. shotgun/tui/screens/provider_config.py +27 -50
  59. shotgun/tui/screens/shotgun_auth.py +2 -2
  60. shotgun/tui/screens/welcome.py +11 -14
  61. shotgun/tui/services/conversation_service.py +14 -16
  62. shotgun/tui/utils/mode_progress.py +7 -14
  63. shotgun/tui/widgets/widget_coordinator.py +0 -15
  64. shotgun/utils/file_system_utils.py +0 -19
  65. {shotgun_sh-0.2.11.dist-info → shotgun_sh-0.2.11.dev2.dist-info}/METADATA +1 -2
  66. {shotgun_sh-0.2.11.dist-info → shotgun_sh-0.2.11.dev2.dist-info}/RECORD +69 -73
  67. shotgun/exceptions.py +0 -32
  68. shotgun/tui/screens/github_issue.py +0 -102
  69. shotgun/tui/screens/onboarding.py +0 -431
  70. shotgun/utils/marketing.py +0 -110
  71. {shotgun_sh-0.2.11.dist-info → shotgun_sh-0.2.11.dev2.dist-info}/WHEEL +0 -0
  72. {shotgun_sh-0.2.11.dist-info → shotgun_sh-0.2.11.dev2.dist-info}/entry_points.txt +0 -0
  73. {shotgun_sh-0.2.11.dist-info → shotgun_sh-0.2.11.dev2.dist-info}/licenses/LICENSE +0 -0
@@ -58,12 +58,7 @@ from shotgun.agents.context_analyzer import (
58
58
  ContextCompositionTelemetry,
59
59
  ContextFormatter,
60
60
  )
61
- from shotgun.agents.models import (
62
- AgentResponse,
63
- AgentType,
64
- FileOperation,
65
- FileOperationTracker,
66
- )
61
+ from shotgun.agents.models import AgentResponse, AgentType, FileOperation
67
62
  from shotgun.posthog_telemetry import track_event
68
63
  from shotgun.tui.screens.chat_screen.hint_message import HintMessage
69
64
  from shotgun.utils.source_detection import detect_source
@@ -174,14 +169,6 @@ class CompactionCompletedMessage(Message):
174
169
  """Event posted when conversation compaction completes."""
175
170
 
176
171
 
177
- class AgentStreamingStarted(Message):
178
- """Event posted when agent starts streaming responses."""
179
-
180
-
181
- class AgentStreamingCompleted(Message):
182
- """Event posted when agent finishes streaming responses."""
183
-
184
-
185
172
  @dataclass(frozen=True)
186
173
  class ModelConfigUpdated:
187
174
  """Data returned when AI model configuration changes.
@@ -235,7 +222,7 @@ class AgentManager(Widget):
235
222
  self.deps = deps
236
223
 
237
224
  # Create AgentRuntimeOptions from deps for agent creation
238
- self._agent_runtime_options = AgentRuntimeOptions(
225
+ agent_runtime_options = AgentRuntimeOptions(
239
226
  interactive_mode=self.deps.interactive_mode,
240
227
  working_directory=self.deps.working_directory,
241
228
  is_tui_context=self.deps.is_tui_context,
@@ -244,18 +231,22 @@ class AgentManager(Widget):
244
231
  tasks=self.deps.tasks,
245
232
  )
246
233
 
247
- # Lazy initialization - agents created on first access
248
- self._research_agent: Agent[AgentDeps, AgentResponse] | None = None
249
- self._research_deps: AgentDeps | None = None
250
- self._plan_agent: Agent[AgentDeps, AgentResponse] | None = None
251
- self._plan_deps: AgentDeps | None = None
252
- self._tasks_agent: Agent[AgentDeps, AgentResponse] | None = None
253
- self._tasks_deps: AgentDeps | None = None
254
- self._specify_agent: Agent[AgentDeps, AgentResponse] | None = None
255
- self._specify_deps: AgentDeps | None = None
256
- self._export_agent: Agent[AgentDeps, AgentResponse] | None = None
257
- self._export_deps: AgentDeps | None = None
258
- self._agents_initialized = False
234
+ # Initialize all agents and store their specific deps
235
+ self.research_agent, self.research_deps = create_research_agent(
236
+ agent_runtime_options=agent_runtime_options
237
+ )
238
+ self.plan_agent, self.plan_deps = create_plan_agent(
239
+ agent_runtime_options=agent_runtime_options
240
+ )
241
+ self.tasks_agent, self.tasks_deps = create_tasks_agent(
242
+ agent_runtime_options=agent_runtime_options
243
+ )
244
+ self.specify_agent, self.specify_deps = create_specify_agent(
245
+ agent_runtime_options=agent_runtime_options
246
+ )
247
+ self.export_agent, self.export_deps = create_export_agent(
248
+ agent_runtime_options=agent_runtime_options
249
+ )
259
250
 
260
251
  # Track current active agent
261
252
  self._current_agent_type: AgentType = initial_type
@@ -270,119 +261,6 @@ class AgentManager(Widget):
270
261
  self._qa_questions: list[str] | None = None
271
262
  self._qa_mode_active: bool = False
272
263
 
273
- async def _ensure_agents_initialized(self) -> None:
274
- """Ensure all agents are initialized (lazy initialization)."""
275
- if self._agents_initialized:
276
- return
277
-
278
- # Initialize all agents asynchronously
279
- self._research_agent, self._research_deps = await create_research_agent(
280
- agent_runtime_options=self._agent_runtime_options
281
- )
282
- self._plan_agent, self._plan_deps = await create_plan_agent(
283
- agent_runtime_options=self._agent_runtime_options
284
- )
285
- self._tasks_agent, self._tasks_deps = await create_tasks_agent(
286
- agent_runtime_options=self._agent_runtime_options
287
- )
288
- self._specify_agent, self._specify_deps = await create_specify_agent(
289
- agent_runtime_options=self._agent_runtime_options
290
- )
291
- self._export_agent, self._export_deps = await create_export_agent(
292
- agent_runtime_options=self._agent_runtime_options
293
- )
294
- self._agents_initialized = True
295
-
296
- @property
297
- def research_agent(self) -> Agent[AgentDeps, AgentResponse]:
298
- """Get research agent (must call _ensure_agents_initialized first)."""
299
- if self._research_agent is None:
300
- raise RuntimeError(
301
- "Agents not initialized. Call _ensure_agents_initialized() first."
302
- )
303
- return self._research_agent
304
-
305
- @property
306
- def research_deps(self) -> AgentDeps:
307
- """Get research deps (must call _ensure_agents_initialized first)."""
308
- if self._research_deps is None:
309
- raise RuntimeError(
310
- "Agents not initialized. Call _ensure_agents_initialized() first."
311
- )
312
- return self._research_deps
313
-
314
- @property
315
- def plan_agent(self) -> Agent[AgentDeps, AgentResponse]:
316
- """Get plan agent (must call _ensure_agents_initialized first)."""
317
- if self._plan_agent is None:
318
- raise RuntimeError(
319
- "Agents not initialized. Call _ensure_agents_initialized() first."
320
- )
321
- return self._plan_agent
322
-
323
- @property
324
- def plan_deps(self) -> AgentDeps:
325
- """Get plan deps (must call _ensure_agents_initialized first)."""
326
- if self._plan_deps is None:
327
- raise RuntimeError(
328
- "Agents not initialized. Call _ensure_agents_initialized() first."
329
- )
330
- return self._plan_deps
331
-
332
- @property
333
- def tasks_agent(self) -> Agent[AgentDeps, AgentResponse]:
334
- """Get tasks agent (must call _ensure_agents_initialized first)."""
335
- if self._tasks_agent is None:
336
- raise RuntimeError(
337
- "Agents not initialized. Call _ensure_agents_initialized() first."
338
- )
339
- return self._tasks_agent
340
-
341
- @property
342
- def tasks_deps(self) -> AgentDeps:
343
- """Get tasks deps (must call _ensure_agents_initialized first)."""
344
- if self._tasks_deps is None:
345
- raise RuntimeError(
346
- "Agents not initialized. Call _ensure_agents_initialized() first."
347
- )
348
- return self._tasks_deps
349
-
350
- @property
351
- def specify_agent(self) -> Agent[AgentDeps, AgentResponse]:
352
- """Get specify agent (must call _ensure_agents_initialized first)."""
353
- if self._specify_agent is None:
354
- raise RuntimeError(
355
- "Agents not initialized. Call _ensure_agents_initialized() first."
356
- )
357
- return self._specify_agent
358
-
359
- @property
360
- def specify_deps(self) -> AgentDeps:
361
- """Get specify deps (must call _ensure_agents_initialized first)."""
362
- if self._specify_deps is None:
363
- raise RuntimeError(
364
- "Agents not initialized. Call _ensure_agents_initialized() first."
365
- )
366
- return self._specify_deps
367
-
368
- @property
369
- def export_agent(self) -> Agent[AgentDeps, AgentResponse]:
370
- """Get export agent (must call _ensure_agents_initialized first)."""
371
- if self._export_agent is None:
372
- raise RuntimeError(
373
- "Agents not initialized. Call _ensure_agents_initialized() first."
374
- )
375
- return self._export_agent
376
-
377
- @property
378
- def export_deps(self) -> AgentDeps:
379
- """Get export deps (must call _ensure_agents_initialized first)."""
380
- if self._export_deps is None:
381
- raise RuntimeError(
382
- "Agents not initialized. Call _ensure_agents_initialized() first."
383
- )
384
- return self._export_deps
385
-
386
264
  @property
387
265
  def current_agent(self) -> Agent[AgentDeps, AgentResponse]:
388
266
  """Get the currently active agent.
@@ -534,9 +412,6 @@ class AgentManager(Widget):
534
412
  Returns:
535
413
  The agent run result.
536
414
  """
537
- # Ensure agents are initialized before running
538
- await self._ensure_agents_initialized()
539
-
540
415
  logger.info(f"Running agent {self._current_agent_type.value}")
541
416
  # Use merged deps (shared state + agent-specific system prompt) if not provided
542
417
  if deps is None:
@@ -774,12 +649,6 @@ class AgentManager(Widget):
774
649
  HintMessage(message=agent_response.response)
775
650
  )
776
651
 
777
- # Add file operation hints before questions (so they appear first in UI)
778
- if file_operations:
779
- file_hint = self._create_file_operation_hint(file_operations)
780
- if file_hint:
781
- self.ui_message_history.append(HintMessage(message=file_hint))
782
-
783
652
  if len(agent_response.clarifying_questions) == 1:
784
653
  # Single question - treat as non-blocking suggestion, DON'T enter Q&A mode
785
654
  self.ui_message_history.append(
@@ -815,9 +684,11 @@ class AgentManager(Widget):
815
684
  )
816
685
  )
817
686
 
818
- # Post UI update with hint messages (file operations will be posted after compaction)
819
- logger.debug("Posting UI update for Q&A mode with hint messages")
820
- self._post_messages_updated([])
687
+ # Post UI update with hint messages and file operations
688
+ logger.debug(
689
+ "Posting UI update for Q&A mode with hint messages and file operations"
690
+ )
691
+ self._post_messages_updated(file_operations)
821
692
  else:
822
693
  # No clarifying questions - show the response or a default success message
823
694
  if agent_response.response and agent_response.response.strip():
@@ -852,9 +723,10 @@ class AgentManager(Widget):
852
723
  )
853
724
 
854
725
  # Post UI update immediately so user sees the response without delay
855
- # (file operations will be posted after compaction to avoid duplicates)
856
- logger.debug("Posting immediate UI update with hint message")
857
- self._post_messages_updated([])
726
+ logger.debug(
727
+ "Posting immediate UI update with hint message and file operations"
728
+ )
729
+ self._post_messages_updated(file_operations)
858
730
 
859
731
  # Apply compaction to persistent message history to prevent cascading growth
860
732
  all_messages = result.all_messages()
@@ -908,7 +780,7 @@ class AgentManager(Widget):
908
780
 
909
781
  usage = result.usage()
910
782
  if hasattr(deps, "llm_model") and deps.llm_model is not None:
911
- await deps.usage_manager.add_usage(
783
+ deps.usage_manager.add_usage(
912
784
  usage, model_name=deps.llm_model.name, provider=deps.llm_model.provider
913
785
  )
914
786
  else:
@@ -934,9 +806,6 @@ class AgentManager(Widget):
934
806
  ) -> None:
935
807
  """Process streamed events and forward partial updates to the UI."""
936
808
 
937
- # Notify UI that streaming has started
938
- self.post_message(AgentStreamingStarted())
939
-
940
809
  state = self._stream_state
941
810
  if state is None:
942
811
  state = self._stream_state = _PartialStreamState()
@@ -1115,9 +984,6 @@ class AgentManager(Widget):
1115
984
  self._post_partial_message(True)
1116
985
  state.current_response = None
1117
986
 
1118
- # Notify UI that streaming has completed
1119
- self.post_message(AgentStreamingCompleted())
1120
-
1121
987
  def _build_partial_response(
1122
988
  self, parts: list[ModelResponsePart | ToolCallPartDelta]
1123
989
  ) -> ModelResponse | None:
@@ -1145,38 +1011,6 @@ class AgentManager(Widget):
1145
1011
  )
1146
1012
  )
1147
1013
 
1148
- def _create_file_operation_hint(
1149
- self, file_operations: list[FileOperation]
1150
- ) -> str | None:
1151
- """Create a hint message for file operations.
1152
-
1153
- Args:
1154
- file_operations: List of file operations to create a hint for
1155
-
1156
- Returns:
1157
- Hint message string or None if no operations
1158
- """
1159
- if not file_operations:
1160
- return None
1161
-
1162
- tracker = FileOperationTracker(operations=file_operations)
1163
- display_path = tracker.get_display_path()
1164
-
1165
- if not display_path:
1166
- return None
1167
-
1168
- path_obj = Path(display_path)
1169
-
1170
- if len(file_operations) == 1:
1171
- return f"📝 Modified: `{display_path}`"
1172
- else:
1173
- num_files = len({op.file_path for op in file_operations})
1174
- if path_obj.is_dir():
1175
- return f"📁 Modified {num_files} files in: `{display_path}`"
1176
- else:
1177
- # Common path is a file, show parent directory
1178
- return f"📁 Modified {num_files} files in: `{path_obj.parent}`"
1179
-
1180
1014
  def _post_messages_updated(
1181
1015
  self, file_operations: list[FileOperation] | None = None
1182
1016
  ) -> None:
shotgun/agents/common.py CHANGED
@@ -4,7 +4,6 @@ from collections.abc import Callable
4
4
  from pathlib import Path
5
5
  from typing import Any
6
6
 
7
- import aiofiles
8
7
  from pydantic_ai import (
9
8
  Agent,
10
9
  RunContext,
@@ -69,7 +68,7 @@ async def add_system_status_message(
69
68
  existing_files = get_agent_existing_files(deps.agent_mode)
70
69
 
71
70
  # Extract table of contents from the agent's markdown file
72
- markdown_toc = await extract_markdown_toc(deps.agent_mode)
71
+ markdown_toc = extract_markdown_toc(deps.agent_mode)
73
72
 
74
73
  # Get current datetime with timezone information
75
74
  dt_context = get_datetime_context()
@@ -95,7 +94,7 @@ async def add_system_status_message(
95
94
  return message_history
96
95
 
97
96
 
98
- async def create_base_agent(
97
+ def create_base_agent(
99
98
  system_prompt_fn: Callable[[RunContext[AgentDeps]], str],
100
99
  agent_runtime_options: AgentRuntimeOptions,
101
100
  load_codebase_understanding_tools: bool = True,
@@ -120,7 +119,7 @@ async def create_base_agent(
120
119
 
121
120
  # Get configured model or fall back to first available provider
122
121
  try:
123
- model_config = await get_provider_model(provider)
122
+ model_config = get_provider_model(provider)
124
123
  provider_name = model_config.provider
125
124
  logger.debug(
126
125
  "🤖 Creating agent with configured %s model: %s",
@@ -195,7 +194,7 @@ async def create_base_agent(
195
194
  return agent, deps
196
195
 
197
196
 
198
- async def _extract_file_toc_content(
197
+ def _extract_file_toc_content(
199
198
  file_path: Path, max_depth: int | None = None, max_chars: int = 500
200
199
  ) -> str | None:
201
200
  """Extract TOC from a single file with depth and character limits.
@@ -212,8 +211,7 @@ async def _extract_file_toc_content(
212
211
  return None
213
212
 
214
213
  try:
215
- async with aiofiles.open(file_path, encoding="utf-8") as f:
216
- content = await f.read()
214
+ content = file_path.read_text(encoding="utf-8")
217
215
  lines = content.split("\n")
218
216
 
219
217
  # Extract headings
@@ -259,7 +257,7 @@ async def _extract_file_toc_content(
259
257
  return None
260
258
 
261
259
 
262
- async def extract_markdown_toc(agent_mode: AgentType | None) -> str | None:
260
+ def extract_markdown_toc(agent_mode: AgentType | None) -> str | None:
263
261
  """Extract TOCs from current and prior agents' files in the pipeline.
264
262
 
265
263
  Shows full TOC of agent's own file and high-level summaries of prior agents'
@@ -311,9 +309,7 @@ async def extract_markdown_toc(agent_mode: AgentType | None) -> str | None:
311
309
  for prior_file in config.prior_files:
312
310
  file_path = base_path / prior_file
313
311
  # Only show # and ## headings from prior files, max 500 chars each
314
- prior_toc = await _extract_file_toc_content(
315
- file_path, max_depth=2, max_chars=500
316
- )
312
+ prior_toc = _extract_file_toc_content(file_path, max_depth=2, max_chars=500)
317
313
  if prior_toc:
318
314
  # Add section with XML tags
319
315
  toc_sections.append(
@@ -325,9 +321,7 @@ async def extract_markdown_toc(agent_mode: AgentType | None) -> str | None:
325
321
  # Extract TOC from own file (full detail)
326
322
  if config.own_file:
327
323
  own_path = base_path / config.own_file
328
- own_toc = await _extract_file_toc_content(
329
- own_path, max_depth=None, max_chars=2000
330
- )
324
+ own_toc = _extract_file_toc_content(own_path, max_depth=None, max_chars=2000)
331
325
  if own_toc:
332
326
  # Put own file TOC at the beginning with XML tags
333
327
  toc_sections.insert(
@@ -5,8 +5,6 @@ import uuid
5
5
  from pathlib import Path
6
6
  from typing import Any
7
7
 
8
- import aiofiles
9
- import aiofiles.os
10
8
  from pydantic import SecretStr
11
9
 
12
10
  from shotgun.logging_config import get_logger
@@ -50,7 +48,7 @@ class ConfigManager:
50
48
 
51
49
  self._config: ShotgunConfig | None = None
52
50
 
53
- async def load(self, force_reload: bool = True) -> ShotgunConfig:
51
+ def load(self, force_reload: bool = True) -> ShotgunConfig:
54
52
  """Load configuration from file.
55
53
 
56
54
  Args:
@@ -62,19 +60,18 @@ class ConfigManager:
62
60
  if self._config is not None and not force_reload:
63
61
  return self._config
64
62
 
65
- if not await aiofiles.os.path.exists(self.config_path):
63
+ if not self.config_path.exists():
66
64
  logger.info(
67
65
  "Configuration file not found, creating new config at: %s",
68
66
  self.config_path,
69
67
  )
70
68
  # Create new config with generated shotgun_instance_id
71
- self._config = await self.initialize()
69
+ self._config = self.initialize()
72
70
  return self._config
73
71
 
74
72
  try:
75
- async with aiofiles.open(self.config_path, encoding="utf-8") as f:
76
- content = await f.read()
77
- data = json.loads(content)
73
+ with open(self.config_path, encoding="utf-8") as f:
74
+ data = json.load(f)
78
75
 
79
76
  # Migration: Rename user_id to shotgun_instance_id (config v2 -> v3)
80
77
  if "user_id" in data and SHOTGUN_INSTANCE_ID_FIELD not in data:
@@ -104,12 +101,6 @@ class ConfigManager:
104
101
  "Existing BYOK user detected: set shown_welcome_screen=False to show welcome screen"
105
102
  )
106
103
 
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")
112
-
113
104
  # Convert plain text secrets to SecretStr objects
114
105
  self._convert_secrets_to_secretstr(data)
115
106
 
@@ -126,7 +117,7 @@ class ConfigManager:
126
117
 
127
118
  if self._config.selected_model in MODEL_SPECS:
128
119
  spec = MODEL_SPECS[self._config.selected_model]
129
- if not await self.has_provider_key(spec.provider):
120
+ if not self.has_provider_key(spec.provider):
130
121
  logger.info(
131
122
  "Selected model %s provider has no API key, finding available model",
132
123
  self._config.selected_model.value,
@@ -144,7 +135,7 @@ class ConfigManager:
144
135
  # If no selected_model or it was invalid, find first available model
145
136
  if not self._config.selected_model:
146
137
  for provider in ProviderType:
147
- if await self.has_provider_key(provider):
138
+ if self.has_provider_key(provider):
148
139
  # Set to that provider's default model
149
140
  from .models import MODEL_SPECS, ModelName
150
141
 
@@ -165,7 +156,7 @@ class ConfigManager:
165
156
  break
166
157
 
167
158
  if should_save:
168
- await self.save(self._config)
159
+ self.save(self._config)
169
160
 
170
161
  return self._config
171
162
 
@@ -174,10 +165,10 @@ class ConfigManager:
174
165
  "Failed to load configuration from %s: %s", self.config_path, e
175
166
  )
176
167
  logger.info("Creating new configuration with generated shotgun_instance_id")
177
- self._config = await self.initialize()
168
+ self._config = self.initialize()
178
169
  return self._config
179
170
 
180
- async def save(self, config: ShotgunConfig | None = None) -> None:
171
+ def save(self, config: ShotgunConfig | None = None) -> None:
181
172
  """Save configuration to file.
182
173
 
183
174
  Args:
@@ -193,17 +184,15 @@ class ConfigManager:
193
184
  )
194
185
 
195
186
  # Ensure directory exists
196
- await aiofiles.os.makedirs(self.config_path.parent, exist_ok=True)
187
+ self.config_path.parent.mkdir(parents=True, exist_ok=True)
197
188
 
198
189
  try:
199
190
  # Convert SecretStr to plain text for JSON serialization
200
191
  data = config.model_dump()
201
192
  self._convert_secretstr_to_plain(data)
202
- self._convert_datetime_to_isoformat(data)
203
193
 
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)
194
+ with open(self.config_path, "w", encoding="utf-8") as f:
195
+ json.dump(data, f, indent=2, ensure_ascii=False)
207
196
 
208
197
  logger.debug("Configuration saved to %s", self.config_path)
209
198
  self._config = config
@@ -212,16 +201,14 @@ class ConfigManager:
212
201
  logger.error("Failed to save configuration to %s: %s", self.config_path, e)
213
202
  raise
214
203
 
215
- async def update_provider(
216
- self, provider: ProviderType | str, **kwargs: Any
217
- ) -> None:
204
+ def update_provider(self, provider: ProviderType | str, **kwargs: Any) -> None:
218
205
  """Update provider configuration.
219
206
 
220
207
  Args:
221
208
  provider: Provider to update
222
209
  **kwargs: Configuration fields to update (only api_key supported)
223
210
  """
224
- config = await self.load()
211
+ config = self.load()
225
212
 
226
213
  # Get provider config and check if it's shotgun
227
214
  provider_config, is_shotgun = self._get_provider_config_and_type(
@@ -266,11 +253,11 @@ class ConfigManager:
266
253
  # This prevents the welcome screen from showing again after user has made their choice
267
254
  config.shown_welcome_screen = True
268
255
 
269
- await self.save(config)
256
+ self.save(config)
270
257
 
271
- async def clear_provider_key(self, provider: ProviderType | str) -> None:
258
+ def clear_provider_key(self, provider: ProviderType | str) -> None:
272
259
  """Remove the API key for the given provider (LLM provider or shotgun)."""
273
- config = await self.load()
260
+ config = self.load()
274
261
 
275
262
  # Get provider config (shotgun or LLM provider)
276
263
  provider_config, is_shotgun = self._get_provider_config_and_type(
@@ -283,34 +270,34 @@ class ConfigManager:
283
270
  if is_shotgun and isinstance(provider_config, ShotgunAccountConfig):
284
271
  provider_config.supabase_jwt = None
285
272
 
286
- await self.save(config)
273
+ self.save(config)
287
274
 
288
- async def update_selected_model(self, model_name: "ModelName") -> None:
275
+ def update_selected_model(self, model_name: "ModelName") -> None:
289
276
  """Update the selected model.
290
277
 
291
278
  Args:
292
279
  model_name: Model to select
293
280
  """
294
- config = await self.load()
281
+ config = self.load()
295
282
  config.selected_model = model_name
296
- await self.save(config)
283
+ self.save(config)
297
284
 
298
- async def has_provider_key(self, provider: ProviderType | str) -> bool:
285
+ def has_provider_key(self, provider: ProviderType | str) -> bool:
299
286
  """Check if the given provider has a non-empty API key configured.
300
287
 
301
288
  This checks only the configuration file.
302
289
  """
303
290
  # Use force_reload=False to avoid infinite loop when called from load()
304
- config = await self.load(force_reload=False)
291
+ config = self.load(force_reload=False)
305
292
  provider_enum = self._ensure_provider_enum(provider)
306
293
  provider_config = self._get_provider_config(config, provider_enum)
307
294
 
308
295
  return self._provider_has_api_key(provider_config)
309
296
 
310
- async def has_any_provider_key(self) -> bool:
297
+ def has_any_provider_key(self) -> bool:
311
298
  """Determine whether any provider has a configured API key."""
312
299
  # Use force_reload=False to avoid infinite loop when called from load()
313
- config = await self.load(force_reload=False)
300
+ config = self.load(force_reload=False)
314
301
  # Check LLM provider keys (BYOK)
315
302
  has_llm_key = any(
316
303
  self._provider_has_api_key(self._get_provider_config(config, provider))
@@ -324,7 +311,7 @@ class ConfigManager:
324
311
  has_shotgun_key = self._provider_has_api_key(config.shotgun)
325
312
  return has_llm_key or has_shotgun_key
326
313
 
327
- async def initialize(self) -> ShotgunConfig:
314
+ def initialize(self) -> ShotgunConfig:
328
315
  """Initialize configuration with defaults and save to file.
329
316
 
330
317
  Returns:
@@ -334,7 +321,7 @@ class ConfigManager:
334
321
  config = ShotgunConfig(
335
322
  shotgun_instance_id=str(uuid.uuid4()),
336
323
  )
337
- await self.save(config)
324
+ self.save(config)
338
325
  logger.info(
339
326
  "Configuration initialized at %s with shotgun_instance_id: %s",
340
327
  self.config_path,
@@ -390,24 +377,6 @@ class ConfigManager:
390
377
  SUPABASE_JWT_FIELD
391
378
  ].get_secret_value()
392
379
 
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)
410
-
411
380
  def _ensure_provider_enum(self, provider: ProviderType | str) -> ProviderType:
412
381
  """Normalize provider values to ProviderType enum."""
413
382
  return (
@@ -471,16 +440,16 @@ class ConfigManager:
471
440
  provider_enum = self._ensure_provider_enum(provider)
472
441
  return (self._get_provider_config(config, provider_enum), False)
473
442
 
474
- async def get_shotgun_instance_id(self) -> str:
443
+ def get_shotgun_instance_id(self) -> str:
475
444
  """Get the shotgun instance ID from configuration.
476
445
 
477
446
  Returns:
478
447
  The unique shotgun instance ID string
479
448
  """
480
- config = await self.load()
449
+ config = self.load()
481
450
  return config.shotgun_instance_id
482
451
 
483
- async def update_shotgun_account(
452
+ def update_shotgun_account(
484
453
  self, api_key: str | None = None, supabase_jwt: str | None = None
485
454
  ) -> None:
486
455
  """Update Shotgun Account configuration.
@@ -489,7 +458,7 @@ class ConfigManager:
489
458
  api_key: LiteLLM proxy API key (optional)
490
459
  supabase_jwt: Supabase authentication JWT (optional)
491
460
  """
492
- config = await self.load()
461
+ config = self.load()
493
462
 
494
463
  if api_key is not None:
495
464
  config.shotgun.api_key = SecretStr(api_key) if api_key else None
@@ -499,7 +468,7 @@ class ConfigManager:
499
468
  SecretStr(supabase_jwt) if supabase_jwt else None
500
469
  )
501
470
 
502
- await self.save(config)
471
+ self.save(config)
503
472
  logger.info("Updated Shotgun Account configuration")
504
473
 
505
474