shotgun-sh 0.2.3.dev2__py3-none-any.whl → 0.2.11.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.

Potentially problematic release.


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

Files changed (107) hide show
  1. shotgun/agents/agent_manager.py +524 -58
  2. shotgun/agents/common.py +62 -62
  3. shotgun/agents/config/constants.py +0 -6
  4. shotgun/agents/config/manager.py +14 -3
  5. shotgun/agents/config/models.py +16 -0
  6. shotgun/agents/config/provider.py +68 -13
  7. shotgun/agents/context_analyzer/__init__.py +28 -0
  8. shotgun/agents/context_analyzer/analyzer.py +493 -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 +24 -2
  14. shotgun/agents/export.py +4 -5
  15. shotgun/agents/history/compaction.py +9 -4
  16. shotgun/agents/history/context_extraction.py +93 -6
  17. shotgun/agents/history/history_processors.py +14 -2
  18. shotgun/agents/history/token_counting/anthropic.py +32 -10
  19. shotgun/agents/models.py +50 -2
  20. shotgun/agents/plan.py +4 -5
  21. shotgun/agents/research.py +4 -5
  22. shotgun/agents/specify.py +4 -5
  23. shotgun/agents/tasks.py +4 -5
  24. shotgun/agents/tools/__init__.py +0 -2
  25. shotgun/agents/tools/codebase/codebase_shell.py +6 -0
  26. shotgun/agents/tools/codebase/directory_lister.py +6 -0
  27. shotgun/agents/tools/codebase/file_read.py +6 -0
  28. shotgun/agents/tools/codebase/query_graph.py +6 -0
  29. shotgun/agents/tools/codebase/retrieve_code.py +6 -0
  30. shotgun/agents/tools/file_management.py +71 -9
  31. shotgun/agents/tools/registry.py +217 -0
  32. shotgun/agents/tools/web_search/__init__.py +24 -12
  33. shotgun/agents/tools/web_search/anthropic.py +24 -3
  34. shotgun/agents/tools/web_search/gemini.py +22 -10
  35. shotgun/agents/tools/web_search/openai.py +21 -12
  36. shotgun/api_endpoints.py +7 -3
  37. shotgun/build_constants.py +1 -1
  38. shotgun/cli/clear.py +52 -0
  39. shotgun/cli/compact.py +186 -0
  40. shotgun/cli/context.py +111 -0
  41. shotgun/cli/models.py +1 -0
  42. shotgun/cli/update.py +16 -2
  43. shotgun/codebase/core/manager.py +10 -1
  44. shotgun/llm_proxy/__init__.py +5 -2
  45. shotgun/llm_proxy/clients.py +12 -7
  46. shotgun/logging_config.py +8 -10
  47. shotgun/main.py +70 -10
  48. shotgun/posthog_telemetry.py +9 -3
  49. shotgun/prompts/agents/export.j2 +18 -1
  50. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +5 -1
  51. shotgun/prompts/agents/partials/interactive_mode.j2 +24 -7
  52. shotgun/prompts/agents/plan.j2 +1 -1
  53. shotgun/prompts/agents/research.j2 +1 -1
  54. shotgun/prompts/agents/specify.j2 +270 -3
  55. shotgun/prompts/agents/state/system_state.j2 +4 -0
  56. shotgun/prompts/agents/tasks.j2 +1 -1
  57. shotgun/prompts/loader.py +2 -2
  58. shotgun/prompts/tools/web_search.j2 +14 -0
  59. shotgun/sentry_telemetry.py +4 -15
  60. shotgun/settings.py +238 -0
  61. shotgun/telemetry.py +15 -32
  62. shotgun/tui/app.py +203 -9
  63. shotgun/tui/commands/__init__.py +1 -1
  64. shotgun/tui/components/context_indicator.py +136 -0
  65. shotgun/tui/components/mode_indicator.py +70 -0
  66. shotgun/tui/components/status_bar.py +48 -0
  67. shotgun/tui/containers.py +93 -0
  68. shotgun/tui/dependencies.py +39 -0
  69. shotgun/tui/protocols.py +45 -0
  70. shotgun/tui/screens/chat/__init__.py +5 -0
  71. shotgun/tui/screens/chat/chat.tcss +54 -0
  72. shotgun/tui/screens/chat/chat_screen.py +1110 -0
  73. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +64 -0
  74. shotgun/tui/screens/chat/codebase_index_selection.py +12 -0
  75. shotgun/tui/screens/chat/help_text.py +39 -0
  76. shotgun/tui/screens/chat/prompt_history.py +48 -0
  77. shotgun/tui/screens/chat.tcss +11 -0
  78. shotgun/tui/screens/chat_screen/command_providers.py +68 -2
  79. shotgun/tui/screens/chat_screen/history/__init__.py +22 -0
  80. shotgun/tui/screens/chat_screen/history/agent_response.py +66 -0
  81. shotgun/tui/screens/chat_screen/history/chat_history.py +116 -0
  82. shotgun/tui/screens/chat_screen/history/formatters.py +115 -0
  83. shotgun/tui/screens/chat_screen/history/partial_response.py +43 -0
  84. shotgun/tui/screens/chat_screen/history/user_question.py +42 -0
  85. shotgun/tui/screens/confirmation_dialog.py +151 -0
  86. shotgun/tui/screens/model_picker.py +30 -6
  87. shotgun/tui/screens/pipx_migration.py +153 -0
  88. shotgun/tui/screens/welcome.py +24 -5
  89. shotgun/tui/services/__init__.py +5 -0
  90. shotgun/tui/services/conversation_service.py +182 -0
  91. shotgun/tui/state/__init__.py +7 -0
  92. shotgun/tui/state/processing_state.py +185 -0
  93. shotgun/tui/widgets/__init__.py +5 -0
  94. shotgun/tui/widgets/widget_coordinator.py +247 -0
  95. shotgun/utils/datetime_utils.py +77 -0
  96. shotgun/utils/file_system_utils.py +3 -2
  97. shotgun/utils/update_checker.py +69 -14
  98. shotgun_sh-0.2.11.dev1.dist-info/METADATA +129 -0
  99. shotgun_sh-0.2.11.dev1.dist-info/RECORD +190 -0
  100. {shotgun_sh-0.2.3.dev2.dist-info → shotgun_sh-0.2.11.dev1.dist-info}/entry_points.txt +1 -0
  101. {shotgun_sh-0.2.3.dev2.dist-info → shotgun_sh-0.2.11.dev1.dist-info}/licenses/LICENSE +1 -1
  102. shotgun/agents/tools/user_interaction.py +0 -37
  103. shotgun/tui/screens/chat.py +0 -804
  104. shotgun/tui/screens/chat_screen/history.py +0 -352
  105. shotgun_sh-0.2.3.dev2.dist-info/METADATA +0 -467
  106. shotgun_sh-0.2.3.dev2.dist-info/RECORD +0 -154
  107. {shotgun_sh-0.2.3.dev2.dist-info → shotgun_sh-0.2.11.dev1.dist-info}/WHEEL +0 -0
@@ -0,0 +1,185 @@
1
+ """Processing state management for TUI operations.
2
+
3
+ This module provides centralized management of processing state including:
4
+ - Tracking whether operations are in progress
5
+ - Managing worker references for cancellation
6
+ - Coordinating spinner widget updates
7
+ - Providing clean cancellation API
8
+ """
9
+
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from shotgun.logging_config import get_logger
13
+ from shotgun.posthog_telemetry import track_event
14
+
15
+ if TYPE_CHECKING:
16
+ from textual.screen import Screen
17
+ from textual.worker import Worker
18
+
19
+ from shotgun.tui.components.spinner import Spinner
20
+
21
+ logger = get_logger(__name__)
22
+
23
+
24
+ class ProcessingStateManager:
25
+ """Manages processing state and spinner coordination for async operations.
26
+
27
+ This class centralizes the logic for tracking whether the TUI is processing
28
+ an operation, managing the current worker for cancellation, and updating
29
+ spinner text.
30
+
31
+ Example:
32
+ ```python
33
+ # In ChatScreen
34
+ self.processing_state = ProcessingStateManager(self)
35
+
36
+ # Start processing
37
+ @work
38
+ async def some_operation(self) -> None:
39
+ self.processing_state.start_processing("Doing work...")
40
+ self.processing_state.bind_worker(get_current_worker())
41
+ try:
42
+ # ... do work ...
43
+ finally:
44
+ self.processing_state.stop_processing()
45
+ ```
46
+ """
47
+
48
+ def __init__(
49
+ self, screen: "Screen[Any]", telemetry_context: dict[str, Any] | None = None
50
+ ) -> None:
51
+ """Initialize the processing state manager.
52
+
53
+ Args:
54
+ screen: The Textual screen this manager is attached to
55
+ telemetry_context: Optional context to include in telemetry events
56
+ (e.g., {"agent_mode": "research"})
57
+ """
58
+ self.screen = screen
59
+ self._working = False
60
+ self._current_worker: Worker[Any] | None = None
61
+ self._spinner_widget: Spinner | None = None
62
+ self._default_spinner_text = "Processing..."
63
+ self._telemetry_context = telemetry_context or {}
64
+
65
+ @property
66
+ def is_working(self) -> bool:
67
+ """Check if an operation is currently in progress.
68
+
69
+ Returns:
70
+ True if processing, False if idle
71
+ """
72
+ return self._working
73
+
74
+ def bind_spinner(self, spinner: "Spinner") -> None:
75
+ """Bind a spinner widget for state coordination.
76
+
77
+ Should be called during screen mount after the spinner widget is available.
78
+
79
+ Args:
80
+ spinner: The Spinner widget to coordinate with
81
+ """
82
+ self._spinner_widget = spinner
83
+ logger.debug(f"Spinner widget bound: {spinner}")
84
+
85
+ def start_processing(self, spinner_text: str | None = None) -> None:
86
+ """Start processing state with optional custom spinner text.
87
+
88
+ Args:
89
+ spinner_text: Custom text to display in spinner. If None, uses default.
90
+ """
91
+ if self._working:
92
+ logger.warning("Attempted to start processing while already processing")
93
+ return
94
+
95
+ self._working = True
96
+ text = spinner_text or self._default_spinner_text
97
+
98
+ # Update screen's reactive working state
99
+ if hasattr(self.screen, "working"):
100
+ self.screen.working = True
101
+
102
+ if self._spinner_widget:
103
+ self._spinner_widget.text = text
104
+ logger.debug(f"Processing started with spinner text: {text}")
105
+ else:
106
+ logger.warning("Processing started but no spinner widget bound")
107
+
108
+ def stop_processing(self) -> None:
109
+ """Stop processing state and reset to default."""
110
+ if not self._working:
111
+ logger.debug("stop_processing called when not working (no-op)")
112
+ return
113
+
114
+ self._working = False
115
+ self._current_worker = None
116
+
117
+ # Update screen's reactive working state
118
+ if hasattr(self.screen, "working"):
119
+ self.screen.working = False
120
+
121
+ # Reset spinner to default text
122
+ if self._spinner_widget:
123
+ self._spinner_widget.text = self._default_spinner_text
124
+ logger.debug("Processing stopped, spinner reset to default")
125
+
126
+ def bind_worker(self, worker: "Worker[Any]") -> None:
127
+ """Bind a worker for cancellation tracking.
128
+
129
+ Should be called immediately after starting a @work decorated method
130
+ using get_current_worker().
131
+
132
+ Args:
133
+ worker: The Worker instance to track for cancellation
134
+ """
135
+ self._current_worker = worker
136
+ logger.debug(f"Worker bound: {worker}")
137
+
138
+ def cancel_current_operation(self, cancel_key: str | None = None) -> bool:
139
+ """Attempt to cancel the current operation if one is running.
140
+
141
+ Automatically tracks cancellation telemetry with context from initialization.
142
+
143
+ Args:
144
+ cancel_key: Optional key that triggered cancellation (e.g., "Escape")
145
+
146
+ Returns:
147
+ True if an operation was cancelled, False if no operation was running
148
+ """
149
+ if not self._working or not self._current_worker:
150
+ logger.debug("No operation to cancel")
151
+ return False
152
+
153
+ try:
154
+ self._current_worker.cancel()
155
+ logger.info("Operation cancelled successfully")
156
+
157
+ # Track cancellation event with context
158
+ event_data = {**self._telemetry_context}
159
+ if cancel_key:
160
+ event_data["cancel_key"] = cancel_key
161
+
162
+ track_event("agent_cancelled", event_data)
163
+
164
+ return True
165
+ except Exception as e:
166
+ logger.error(f"Failed to cancel operation: {e}", exc_info=True)
167
+ return False
168
+
169
+ def update_spinner_text(self, text: str) -> None:
170
+ """Update spinner text during processing.
171
+
172
+ Args:
173
+ text: New text to display in spinner
174
+ """
175
+ if not self._working:
176
+ logger.warning(
177
+ f"Attempted to update spinner text while not working: {text}"
178
+ )
179
+ return
180
+
181
+ if self._spinner_widget:
182
+ self._spinner_widget.text = text
183
+ logger.debug(f"Spinner text updated to: {text}")
184
+ else:
185
+ logger.warning(f"Cannot update spinner text, widget not bound: {text}")
@@ -0,0 +1,5 @@
1
+ """Widget utilities and coordinators for TUI."""
2
+
3
+ from shotgun.tui.widgets.widget_coordinator import WidgetCoordinator
4
+
5
+ __all__ = ["WidgetCoordinator"]
@@ -0,0 +1,247 @@
1
+ # mypy: disable-error-code="import-not-found"
2
+ """Widget coordinator to centralize widget queries and updates.
3
+
4
+ This module eliminates scattered `query_one()` calls throughout ChatScreen
5
+ by providing a single place for all widget updates. This improves:
6
+ - Testability (can test update logic in isolation)
7
+ - Maintainability (clear update contracts)
8
+ - Performance (can batch updates if needed)
9
+ """
10
+
11
+ import logging
12
+ from typing import TYPE_CHECKING
13
+
14
+ from pydantic_ai.messages import ModelMessage
15
+
16
+ from shotgun.agents.config.models import ModelName
17
+ from shotgun.agents.models import AgentType
18
+ from shotgun.tui.components.context_indicator import ContextIndicator
19
+ from shotgun.tui.components.mode_indicator import ModeIndicator
20
+ from shotgun.tui.components.prompt_input import PromptInput
21
+ from shotgun.tui.components.spinner import Spinner
22
+ from shotgun.tui.components.status_bar import StatusBar
23
+ from shotgun.tui.screens.chat_screen.history.chat_history import ChatHistory
24
+
25
+ if TYPE_CHECKING:
26
+ from shotgun.agents.context_analyzer.models import ContextAnalysis
27
+ from shotgun.agents.conversation_history import HintMessage
28
+ from shotgun.tui.screens.chat import ChatScreen
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ class WidgetCoordinator:
34
+ """Coordinates updates to all widgets in ChatScreen.
35
+
36
+ This class centralizes all `query_one()` calls and widget manipulations,
37
+ providing clear update methods instead of scattered direct queries.
38
+
39
+ Benefits:
40
+ - Single place for all widget updates
41
+ - Testable without full TUI
42
+ - Clear update contracts
43
+ - Can add batching/debouncing easily
44
+ """
45
+
46
+ def __init__(self, screen: "ChatScreen"):
47
+ """Initialize the coordinator with a reference to the screen.
48
+
49
+ Args:
50
+ screen: The ChatScreen instance containing the widgets.
51
+ """
52
+ self.screen = screen
53
+
54
+ def update_for_mode_change(
55
+ self, new_mode: AgentType, placeholder: str | None = None
56
+ ) -> None:
57
+ """Update all widgets when agent mode changes.
58
+
59
+ Args:
60
+ new_mode: The new agent mode.
61
+ placeholder: Optional placeholder text for input. If not provided,
62
+ will use the screen's _placeholder_for_mode method.
63
+ """
64
+ if not self.screen.is_mounted:
65
+ return
66
+
67
+ # Update mode indicator
68
+ try:
69
+ mode_indicator = self.screen.query_one(ModeIndicator)
70
+ mode_indicator.mode = new_mode
71
+ mode_indicator.refresh()
72
+ except Exception as e:
73
+ logger.exception(f"Failed to update mode indicator: {e}")
74
+
75
+ # Update prompt input placeholder
76
+ try:
77
+ prompt_input = self.screen.query_one(PromptInput)
78
+ if placeholder is None:
79
+ placeholder = self.screen._placeholder_for_mode(
80
+ new_mode, force_new=True
81
+ )
82
+ prompt_input.placeholder = placeholder
83
+ prompt_input.refresh()
84
+ except Exception as e:
85
+ logger.exception(f"Failed to update prompt input: {e}")
86
+
87
+ def update_for_processing_state(
88
+ self, is_processing: bool, spinner_text: str | None = None
89
+ ) -> None:
90
+ """Update widgets when processing state changes.
91
+
92
+ Args:
93
+ is_processing: Whether processing is active.
94
+ spinner_text: Optional text to display in spinner.
95
+ """
96
+ if not self.screen.is_mounted:
97
+ return
98
+
99
+ # Update spinner visibility
100
+ try:
101
+ spinner = self.screen.query_one("#spinner", Spinner)
102
+ spinner.set_classes("" if is_processing else "hidden")
103
+ spinner.display = is_processing
104
+ if spinner_text and is_processing:
105
+ spinner.text = spinner_text
106
+ except Exception as e:
107
+ logger.exception(f"Failed to update spinner: {e}")
108
+
109
+ # Update status bar
110
+ try:
111
+ status_bar = self.screen.query_one(StatusBar)
112
+ status_bar.working = is_processing
113
+ status_bar.refresh()
114
+ except Exception as e:
115
+ logger.exception(f"Failed to update status bar: {e}")
116
+
117
+ def update_for_qa_mode(self, qa_mode_active: bool) -> None:
118
+ """Update widgets when Q&A mode changes.
119
+
120
+ Args:
121
+ qa_mode_active: Whether Q&A mode is active.
122
+ """
123
+ if not self.screen.is_mounted:
124
+ return
125
+
126
+ # Update status bar
127
+ try:
128
+ status_bar = self.screen.query_one(StatusBar)
129
+ status_bar.refresh()
130
+ except Exception as e:
131
+ logger.exception(f"Failed to update status bar for Q&A: {e}")
132
+
133
+ # Update mode indicator
134
+ try:
135
+ mode_indicator = self.screen.query_one(ModeIndicator)
136
+ mode_indicator.refresh()
137
+ except Exception as e:
138
+ logger.exception(f"Failed to update mode indicator for Q&A: {e}")
139
+
140
+ def update_messages(self, messages: list[ModelMessage | "HintMessage"]) -> None:
141
+ """Update chat history with new messages.
142
+
143
+ Args:
144
+ messages: The messages to display.
145
+ """
146
+ if not self.screen.is_mounted:
147
+ return
148
+
149
+ try:
150
+ chat_history = self.screen.query_one(ChatHistory)
151
+ chat_history.update_messages(messages)
152
+ except Exception as e:
153
+ logger.exception(f"Failed to update messages: {e}")
154
+
155
+ def set_partial_response(
156
+ self, message: ModelMessage | None, messages: list[ModelMessage | "HintMessage"]
157
+ ) -> None:
158
+ """Update chat history with partial streaming response.
159
+
160
+ Args:
161
+ message: The partial message being streamed.
162
+ messages: The full message history.
163
+ """
164
+ if not self.screen.is_mounted:
165
+ return
166
+
167
+ try:
168
+ chat_history = self.screen.query_one(ChatHistory)
169
+ if message:
170
+ chat_history.partial_response = message
171
+ chat_history.update_messages(messages)
172
+ except Exception as e:
173
+ logger.exception(f"Failed to set partial response: {e}")
174
+
175
+ def update_context_indicator(
176
+ self, analysis: "ContextAnalysis | None", model_name: str
177
+ ) -> None:
178
+ """Update context indicator with new analysis.
179
+
180
+ Args:
181
+ analysis: The context analysis results.
182
+ model_name: The current model name.
183
+ """
184
+ if not self.screen.is_mounted:
185
+ return
186
+
187
+ try:
188
+ context_indicator = self.screen.query_one(ContextIndicator)
189
+ # Cast the string model name to ModelName type
190
+ model = ModelName(model_name) if model_name else None
191
+ context_indicator.update_context(analysis, model)
192
+ except Exception as e:
193
+ logger.exception(f"Failed to update context indicator: {e}")
194
+
195
+ def update_prompt_input(
196
+ self,
197
+ placeholder: str | None = None,
198
+ clear: bool = False,
199
+ focus: bool = False,
200
+ ) -> None:
201
+ """Update prompt input widget.
202
+
203
+ Args:
204
+ placeholder: New placeholder text.
205
+ clear: Whether to clear the input.
206
+ focus: Whether to focus the input.
207
+ """
208
+ if not self.screen.is_mounted:
209
+ return
210
+
211
+ try:
212
+ prompt_input = self.screen.query_one(PromptInput)
213
+ if placeholder is not None:
214
+ prompt_input.placeholder = placeholder
215
+ if clear:
216
+ prompt_input.clear()
217
+ if focus:
218
+ prompt_input.focus()
219
+ except Exception as e:
220
+ logger.exception(f"Failed to update prompt input: {e}")
221
+
222
+ def refresh_mode_indicator(self) -> None:
223
+ """Refresh mode indicator without changing mode."""
224
+ if not self.screen.is_mounted:
225
+ return
226
+
227
+ try:
228
+ mode_indicator = self.screen.query_one(ModeIndicator)
229
+ mode_indicator.refresh()
230
+ except Exception as e:
231
+ logger.exception(f"Failed to refresh mode indicator: {e}")
232
+
233
+ def update_spinner_text(self, text: str) -> None:
234
+ """Update spinner text without changing visibility.
235
+
236
+ Args:
237
+ text: The new spinner text.
238
+ """
239
+ if not self.screen.is_mounted:
240
+ return
241
+
242
+ try:
243
+ spinner = self.screen.query_one("#spinner", Spinner)
244
+ if spinner.display: # Only update if visible
245
+ spinner.text = text
246
+ except Exception as e:
247
+ logger.exception(f"Failed to update spinner text: {e}")
@@ -0,0 +1,77 @@
1
+ """Datetime utilities for consistent datetime formatting across the application."""
2
+
3
+ from datetime import datetime
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class DateTimeContext(BaseModel):
9
+ """Structured datetime context with timezone information.
10
+
11
+ This model provides consistently formatted datetime information
12
+ for use in prompts, templates, and UI display.
13
+
14
+ Attributes:
15
+ datetime_formatted: Human-readable datetime string
16
+ timezone_name: Short timezone name (e.g., "PST", "UTC")
17
+ utc_offset: UTC offset formatted with colon (e.g., "UTC-08:00")
18
+
19
+ Example:
20
+ >>> dt_context = get_datetime_context()
21
+ >>> print(dt_context.datetime_formatted)
22
+ 'Monday, January 13, 2025 at 3:45:30 PM'
23
+ >>> print(dt_context.timezone_name)
24
+ 'PST'
25
+ >>> print(dt_context.utc_offset)
26
+ 'UTC-08:00'
27
+ """
28
+
29
+ datetime_formatted: str = Field(
30
+ description="Human-readable datetime string in format: 'Day, Month DD, YYYY at HH:MM:SS AM/PM'"
31
+ )
32
+ timezone_name: str = Field(description="Short timezone name (e.g., PST, EST, UTC)")
33
+ utc_offset: str = Field(
34
+ description="UTC offset formatted with colon (e.g., UTC-08:00, UTC+05:30)"
35
+ )
36
+
37
+
38
+ def get_datetime_context() -> DateTimeContext:
39
+ """Get formatted datetime context with timezone information.
40
+
41
+ Returns a Pydantic model containing consistently formatted datetime
42
+ information suitable for use in prompts and templates.
43
+
44
+ Returns:
45
+ DateTimeContext: Structured datetime context with formatted strings
46
+
47
+ Example:
48
+ >>> dt_context = get_datetime_context()
49
+ >>> dt_context.datetime_formatted
50
+ 'Monday, January 13, 2025 at 3:45:30 PM'
51
+ >>> dt_context.timezone_name
52
+ 'PST'
53
+ >>> dt_context.utc_offset
54
+ 'UTC-08:00'
55
+ """
56
+ # Get current datetime with timezone information
57
+ now = datetime.now().astimezone()
58
+
59
+ # Format datetime in plain English
60
+ # Example: "Monday, January 13, 2025 at 3:45:30 PM"
61
+ datetime_formatted = now.strftime("%A, %B %d, %Y at %I:%M:%S %p")
62
+
63
+ # Get timezone name and UTC offset
64
+ # Example: "PST" and "UTC-08:00"
65
+ timezone_name = now.strftime("%Z")
66
+ utc_offset = now.strftime("%z") # Format: +0800 or -0500
67
+
68
+ # Reformat UTC offset to include colon: +08:00 or -05:00
69
+ utc_offset_formatted = (
70
+ f"UTC{utc_offset[:3]}:{utc_offset[3:]}" if utc_offset else "UTC"
71
+ )
72
+
73
+ return DateTimeContext(
74
+ datetime_formatted=datetime_formatted,
75
+ timezone_name=timezone_name,
76
+ utc_offset=utc_offset_formatted,
77
+ )
@@ -1,8 +1,9 @@
1
1
  """File system utility functions."""
2
2
 
3
- import os
4
3
  from pathlib import Path
5
4
 
5
+ from shotgun.settings import settings
6
+
6
7
 
7
8
  def get_shotgun_base_path() -> Path:
8
9
  """Get the absolute path to the .shotgun directory."""
@@ -18,7 +19,7 @@ def get_shotgun_home() -> Path:
18
19
  Path to shotgun home directory (default: ~/.shotgun-sh/)
19
20
  """
20
21
  # Allow override via environment variable (useful for testing)
21
- if custom_home := os.environ.get("SHOTGUN_HOME"):
22
+ if custom_home := settings.dev.home:
22
23
  return Path(custom_home)
23
24
 
24
25
  return Path.home() / ".shotgun-sh"
@@ -10,6 +10,7 @@ from packaging import version
10
10
 
11
11
  from shotgun import __version__
12
12
  from shotgun.logging_config import get_logger
13
+ from shotgun.settings import settings
13
14
 
14
15
  logger = get_logger(__name__)
15
16
 
@@ -18,8 +19,34 @@ def detect_installation_method() -> str:
18
19
  """Detect how shotgun-sh was installed.
19
20
 
20
21
  Returns:
21
- Installation method: 'pipx', 'pip', 'venv', or 'unknown'.
22
+ Installation method: 'uvx', 'uv-tool', 'pipx', 'pip', 'venv', or 'unknown'.
22
23
  """
24
+ # Check for simulation environment variable (for testing)
25
+ if settings.dev.pipx_simulate:
26
+ logger.debug("SHOTGUN_PIPX_SIMULATE enabled, simulating pipx installation")
27
+ return "pipx"
28
+
29
+ # Check for uvx (ephemeral execution) by looking at executable path
30
+ # uvx runs from a temporary cache directory
31
+ executable = Path(sys.executable)
32
+ if ".cache/uv" in str(executable) or "uv/cache" in str(executable):
33
+ logger.debug("Detected uvx (ephemeral) execution")
34
+ return "uvx"
35
+
36
+ # Check for uv tool installation
37
+ try:
38
+ result = subprocess.run(
39
+ ["uv", "tool", "list"], # noqa: S607, S603
40
+ capture_output=True,
41
+ text=True,
42
+ timeout=5,
43
+ )
44
+ if result.returncode == 0 and "shotgun-sh" in result.stdout:
45
+ logger.debug("Detected uv tool installation")
46
+ return "uv-tool"
47
+ except (subprocess.SubprocessError, FileNotFoundError):
48
+ pass
49
+
23
50
  # Check for pipx installation
24
51
  try:
25
52
  result = subprocess.run(
@@ -59,7 +86,7 @@ def detect_installation_method() -> str:
59
86
 
60
87
 
61
88
  def perform_auto_update(no_update_check: bool = False) -> None:
62
- """Perform automatic update if installed via pipx.
89
+ """Perform automatic update if installed via pipx or uv tool.
63
90
 
64
91
  Args:
65
92
  no_update_check: If True, skip the update.
@@ -68,23 +95,40 @@ def perform_auto_update(no_update_check: bool = False) -> None:
68
95
  return
69
96
 
70
97
  try:
71
- # Only auto-update for pipx installations
72
- if detect_installation_method() != "pipx":
73
- logger.debug("Not a pipx installation, skipping auto-update")
98
+ method = detect_installation_method()
99
+
100
+ # Skip auto-update for ephemeral uvx executions
101
+ if method == "uvx":
102
+ logger.debug("uvx (ephemeral) execution, skipping auto-update")
74
103
  return
75
104
 
76
- # Run pipx upgrade quietly
77
- logger.debug("Running pipx upgrade shotgun-sh --quiet")
78
- result = subprocess.run(
79
- ["pipx", "upgrade", "shotgun-sh", "--quiet"], # noqa: S607, S603
105
+ # Only auto-update for pipx and uv-tool installations
106
+ if method not in ["pipx", "uv-tool"]:
107
+ logger.debug(f"Installation method '{method}', skipping auto-update")
108
+ return
109
+
110
+ # Determine the appropriate upgrade command
111
+ if method == "pipx":
112
+ command = ["pipx", "upgrade", "shotgun-sh", "--quiet"]
113
+ logger.debug("Running pipx upgrade shotgun-sh --quiet")
114
+ elif method == "uv-tool":
115
+ command = ["uv", "tool", "upgrade", "shotgun-sh"]
116
+ logger.debug("Running uv tool upgrade shotgun-sh")
117
+ else:
118
+ return
119
+
120
+ # Run upgrade command
121
+ result = subprocess.run( # noqa: S603, S607
122
+ command,
80
123
  capture_output=True,
81
124
  text=True,
82
125
  timeout=30,
83
126
  )
84
127
 
85
128
  if result.returncode == 0:
86
- # Check if there was an actual update (pipx shows output even with --quiet for actual updates)
87
- if result.stdout and "upgraded" in result.stdout.lower():
129
+ # Check if there was an actual update
130
+ output = result.stdout.lower()
131
+ if "upgraded" in output or "updated" in output:
88
132
  logger.info("Shotgun-sh has been updated to the latest version")
89
133
  else:
90
134
  # Only log errors at debug level to not annoy users
@@ -166,16 +210,18 @@ def compare_versions(current: str, latest: str) -> bool:
166
210
  return False
167
211
 
168
212
 
169
- def get_update_command(method: str) -> list[str]:
213
+ def get_update_command(method: str) -> list[str] | None:
170
214
  """Get the appropriate update command based on installation method.
171
215
 
172
216
  Args:
173
- method: Installation method ('pipx', 'pip', 'venv', or 'unknown').
217
+ method: Installation method ('uvx', 'uv-tool', 'pipx', 'pip', 'venv', or 'unknown').
174
218
 
175
219
  Returns:
176
- Command list to execute for updating.
220
+ Command list to execute for updating, or None for uvx (ephemeral).
177
221
  """
178
222
  commands = {
223
+ "uvx": None, # uvx is ephemeral, no update command
224
+ "uv-tool": ["uv", "tool", "upgrade", "shotgun-sh"],
179
225
  "pipx": ["pipx", "upgrade", "shotgun-sh"],
180
226
  "pip": [sys.executable, "-m", "pip", "install", "--upgrade", "shotgun-sh"],
181
227
  "venv": [sys.executable, "-m", "pip", "install", "--upgrade", "shotgun-sh"],
@@ -210,6 +256,15 @@ def perform_update(force: bool = False) -> tuple[bool, str]:
210
256
  method = detect_installation_method()
211
257
  command = get_update_command(method)
212
258
 
259
+ # Handle uvx (ephemeral) installations
260
+ if method == "uvx" or command is None:
261
+ return (
262
+ False,
263
+ "You're running shotgun-sh via uvx (ephemeral mode). "
264
+ "To get the latest version, simply run 'uvx shotgun-sh' again, "
265
+ "or install permanently with 'uv tool install shotgun-sh'.",
266
+ )
267
+
213
268
  # Perform update
214
269
  try:
215
270
  logger.info(f"Updating shotgun-sh using {method}...")