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.
- shotgun/agents/agent_manager.py +761 -52
- shotgun/agents/common.py +80 -75
- shotgun/agents/config/constants.py +21 -10
- shotgun/agents/config/manager.py +322 -97
- shotgun/agents/config/models.py +114 -84
- shotgun/agents/config/provider.py +232 -88
- shotgun/agents/context_analyzer/__init__.py +28 -0
- shotgun/agents/context_analyzer/analyzer.py +471 -0
- shotgun/agents/context_analyzer/constants.py +9 -0
- shotgun/agents/context_analyzer/formatter.py +115 -0
- shotgun/agents/context_analyzer/models.py +212 -0
- shotgun/agents/conversation_history.py +125 -2
- shotgun/agents/conversation_manager.py +57 -19
- shotgun/agents/export.py +6 -7
- shotgun/agents/history/compaction.py +23 -3
- shotgun/agents/history/context_extraction.py +93 -6
- shotgun/agents/history/history_processors.py +179 -11
- shotgun/agents/history/token_counting/__init__.py +31 -0
- shotgun/agents/history/token_counting/anthropic.py +127 -0
- shotgun/agents/history/token_counting/base.py +78 -0
- shotgun/agents/history/token_counting/openai.py +90 -0
- shotgun/agents/history/token_counting/sentencepiece_counter.py +127 -0
- shotgun/agents/history/token_counting/tokenizer_cache.py +92 -0
- shotgun/agents/history/token_counting/utils.py +144 -0
- shotgun/agents/history/token_estimation.py +12 -12
- shotgun/agents/llm.py +62 -0
- shotgun/agents/models.py +59 -4
- shotgun/agents/plan.py +6 -7
- shotgun/agents/research.py +7 -8
- shotgun/agents/specify.py +6 -7
- shotgun/agents/tasks.py +6 -7
- shotgun/agents/tools/__init__.py +0 -2
- shotgun/agents/tools/codebase/codebase_shell.py +6 -0
- shotgun/agents/tools/codebase/directory_lister.py +6 -0
- shotgun/agents/tools/codebase/file_read.py +11 -2
- shotgun/agents/tools/codebase/query_graph.py +6 -0
- shotgun/agents/tools/codebase/retrieve_code.py +6 -0
- shotgun/agents/tools/file_management.py +82 -16
- shotgun/agents/tools/registry.py +217 -0
- shotgun/agents/tools/web_search/__init__.py +55 -16
- shotgun/agents/tools/web_search/anthropic.py +76 -51
- shotgun/agents/tools/web_search/gemini.py +50 -27
- shotgun/agents/tools/web_search/openai.py +26 -17
- shotgun/agents/tools/web_search/utils.py +2 -2
- shotgun/agents/usage_manager.py +164 -0
- shotgun/api_endpoints.py +15 -0
- shotgun/cli/clear.py +53 -0
- shotgun/cli/codebase/commands.py +71 -2
- shotgun/cli/compact.py +186 -0
- shotgun/cli/config.py +41 -67
- shotgun/cli/context.py +111 -0
- shotgun/cli/export.py +1 -1
- shotgun/cli/feedback.py +50 -0
- shotgun/cli/models.py +3 -2
- shotgun/cli/plan.py +1 -1
- shotgun/cli/research.py +1 -1
- shotgun/cli/specify.py +1 -1
- shotgun/cli/tasks.py +1 -1
- shotgun/cli/update.py +18 -5
- shotgun/codebase/core/change_detector.py +5 -3
- shotgun/codebase/core/code_retrieval.py +4 -2
- shotgun/codebase/core/ingestor.py +169 -19
- shotgun/codebase/core/manager.py +177 -13
- shotgun/codebase/core/nl_query.py +1 -1
- shotgun/codebase/models.py +28 -3
- shotgun/codebase/service.py +14 -2
- shotgun/exceptions.py +32 -0
- shotgun/llm_proxy/__init__.py +19 -0
- shotgun/llm_proxy/clients.py +44 -0
- shotgun/llm_proxy/constants.py +15 -0
- shotgun/logging_config.py +18 -27
- shotgun/main.py +91 -4
- shotgun/posthog_telemetry.py +87 -40
- shotgun/prompts/agents/export.j2 +18 -1
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +5 -1
- shotgun/prompts/agents/partials/interactive_mode.j2 +24 -7
- shotgun/prompts/agents/plan.j2 +1 -1
- shotgun/prompts/agents/research.j2 +1 -1
- shotgun/prompts/agents/specify.j2 +270 -3
- shotgun/prompts/agents/state/system_state.j2 +4 -0
- shotgun/prompts/agents/tasks.j2 +1 -1
- shotgun/prompts/codebase/partials/cypher_rules.j2 +13 -0
- shotgun/prompts/loader.py +2 -2
- shotgun/prompts/tools/web_search.j2 +14 -0
- shotgun/sdk/codebase.py +60 -2
- shotgun/sentry_telemetry.py +28 -21
- shotgun/settings.py +238 -0
- shotgun/shotgun_web/__init__.py +19 -0
- shotgun/shotgun_web/client.py +138 -0
- shotgun/shotgun_web/constants.py +21 -0
- shotgun/shotgun_web/models.py +47 -0
- shotgun/telemetry.py +24 -36
- shotgun/tui/app.py +275 -23
- shotgun/tui/commands/__init__.py +1 -1
- shotgun/tui/components/context_indicator.py +179 -0
- shotgun/tui/components/mode_indicator.py +70 -0
- shotgun/tui/components/status_bar.py +48 -0
- shotgun/tui/components/vertical_tail.py +6 -0
- shotgun/tui/containers.py +91 -0
- shotgun/tui/dependencies.py +39 -0
- shotgun/tui/filtered_codebase_service.py +46 -0
- shotgun/tui/protocols.py +45 -0
- shotgun/tui/screens/chat/__init__.py +5 -0
- shotgun/tui/screens/chat/chat.tcss +54 -0
- shotgun/tui/screens/chat/chat_screen.py +1234 -0
- shotgun/tui/screens/chat/codebase_index_prompt_screen.py +64 -0
- shotgun/tui/screens/chat/codebase_index_selection.py +12 -0
- shotgun/tui/screens/chat/help_text.py +40 -0
- shotgun/tui/screens/chat/prompt_history.py +48 -0
- shotgun/tui/screens/chat.tcss +11 -0
- shotgun/tui/screens/chat_screen/command_providers.py +226 -11
- shotgun/tui/screens/chat_screen/history/__init__.py +22 -0
- shotgun/tui/screens/chat_screen/history/agent_response.py +66 -0
- shotgun/tui/screens/chat_screen/history/chat_history.py +116 -0
- shotgun/tui/screens/chat_screen/history/formatters.py +115 -0
- shotgun/tui/screens/chat_screen/history/partial_response.py +43 -0
- shotgun/tui/screens/chat_screen/history/user_question.py +42 -0
- shotgun/tui/screens/confirmation_dialog.py +151 -0
- shotgun/tui/screens/feedback.py +193 -0
- shotgun/tui/screens/github_issue.py +102 -0
- shotgun/tui/screens/model_picker.py +352 -0
- shotgun/tui/screens/onboarding.py +431 -0
- shotgun/tui/screens/pipx_migration.py +153 -0
- shotgun/tui/screens/provider_config.py +156 -39
- shotgun/tui/screens/shotgun_auth.py +295 -0
- shotgun/tui/screens/welcome.py +198 -0
- shotgun/tui/services/__init__.py +5 -0
- shotgun/tui/services/conversation_service.py +184 -0
- shotgun/tui/state/__init__.py +7 -0
- shotgun/tui/state/processing_state.py +185 -0
- shotgun/tui/utils/mode_progress.py +14 -7
- shotgun/tui/widgets/__init__.py +5 -0
- shotgun/tui/widgets/widget_coordinator.py +262 -0
- shotgun/utils/datetime_utils.py +77 -0
- shotgun/utils/env_utils.py +13 -0
- shotgun/utils/file_system_utils.py +22 -2
- shotgun/utils/marketing.py +110 -0
- shotgun/utils/source_detection.py +16 -0
- shotgun/utils/update_checker.py +73 -21
- shotgun_sh-0.2.11.dist-info/METADATA +130 -0
- shotgun_sh-0.2.11.dist-info/RECORD +194 -0
- {shotgun_sh-0.1.9.dist-info → shotgun_sh-0.2.11.dist-info}/entry_points.txt +1 -0
- {shotgun_sh-0.1.9.dist-info → shotgun_sh-0.2.11.dist-info}/licenses/LICENSE +1 -1
- shotgun/agents/history/token_counting.py +0 -429
- shotgun/agents/tools/user_interaction.py +0 -37
- shotgun/tui/screens/chat.py +0 -818
- shotgun/tui/screens/chat_screen/history.py +0 -222
- shotgun_sh-0.1.9.dist-info/METADATA +0 -466
- shotgun_sh-0.1.9.dist-info/RECORD +0 -131
- {shotgun_sh-0.1.9.dist-info → shotgun_sh-0.2.11.dist-info}/WHEEL +0 -0
|
@@ -9,7 +9,7 @@ from textual.app import ComposeResult
|
|
|
9
9
|
from textual.containers import Horizontal, Vertical
|
|
10
10
|
from textual.reactive import reactive
|
|
11
11
|
from textual.screen import Screen
|
|
12
|
-
from textual.widgets import Button, Input, Label, ListItem, ListView, Static
|
|
12
|
+
from textual.widgets import Button, Input, Label, ListItem, ListView, Markdown, Static
|
|
13
13
|
|
|
14
14
|
from shotgun.agents.config import ConfigManager, ProviderType
|
|
15
15
|
|
|
@@ -17,6 +17,16 @@ if TYPE_CHECKING:
|
|
|
17
17
|
from ..app import ShotgunApp
|
|
18
18
|
|
|
19
19
|
|
|
20
|
+
def get_configurable_providers() -> list[str]:
|
|
21
|
+
"""Get list of configurable providers.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
List of provider identifiers that can be configured.
|
|
25
|
+
Includes all providers: openai, anthropic, google, and shotgun.
|
|
26
|
+
"""
|
|
27
|
+
return ["openai", "anthropic", "google", "shotgun"]
|
|
28
|
+
|
|
29
|
+
|
|
20
30
|
class ProviderConfigScreen(Screen[None]):
|
|
21
31
|
"""Collect API keys for available providers."""
|
|
22
32
|
|
|
@@ -47,6 +57,10 @@ class ProviderConfigScreen(Screen[None]):
|
|
|
47
57
|
color: $text-accent;
|
|
48
58
|
}
|
|
49
59
|
|
|
60
|
+
#provider-links {
|
|
61
|
+
padding: 1 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
50
64
|
#provider-list {
|
|
51
65
|
margin: 2 0;
|
|
52
66
|
height: auto;
|
|
@@ -67,9 +81,10 @@ class ProviderConfigScreen(Screen[None]):
|
|
|
67
81
|
|
|
68
82
|
BINDINGS = [
|
|
69
83
|
("escape", "done", "Back"),
|
|
84
|
+
("ctrl+c", "app.quit", "Quit"),
|
|
70
85
|
]
|
|
71
86
|
|
|
72
|
-
selected_provider: reactive[
|
|
87
|
+
selected_provider: reactive[str] = reactive("openai")
|
|
73
88
|
|
|
74
89
|
def compose(self) -> ComposeResult:
|
|
75
90
|
with Vertical(id="titlebox"):
|
|
@@ -78,7 +93,11 @@ class ProviderConfigScreen(Screen[None]):
|
|
|
78
93
|
"Select a provider and enter the API key needed to activate it.",
|
|
79
94
|
id="provider-config-summary",
|
|
80
95
|
)
|
|
81
|
-
|
|
96
|
+
yield Markdown(
|
|
97
|
+
"Don't have an API Key? Use these links to get one: [OpenAI](https://platform.openai.com/api-keys) | [Anthropic](https://console.anthropic.com) | [Google Gemini](https://aistudio.google.com)",
|
|
98
|
+
id="provider-links",
|
|
99
|
+
)
|
|
100
|
+
yield ListView(*self._build_provider_items_sync(), id="provider-list")
|
|
82
101
|
yield Input(
|
|
83
102
|
placeholder=self._input_placeholder(self.selected_provider),
|
|
84
103
|
password=True,
|
|
@@ -86,17 +105,35 @@ class ProviderConfigScreen(Screen[None]):
|
|
|
86
105
|
)
|
|
87
106
|
with Horizontal(id="provider-actions"):
|
|
88
107
|
yield Button("Save key \\[ENTER]", variant="primary", id="save")
|
|
108
|
+
yield Button("Authenticate", variant="success", id="authenticate")
|
|
89
109
|
yield Button("Clear key", id="clear", variant="warning")
|
|
90
110
|
yield Button("Done \\[ESC]", id="done")
|
|
91
111
|
|
|
92
112
|
def on_mount(self) -> None:
|
|
93
|
-
self.refresh_provider_status()
|
|
94
113
|
list_view = self.query_one(ListView)
|
|
95
114
|
if list_view.children:
|
|
96
115
|
list_view.index = 0
|
|
97
|
-
self.selected_provider =
|
|
116
|
+
self.selected_provider = "openai"
|
|
117
|
+
|
|
118
|
+
# Hide authenticate button by default (shown only for shotgun)
|
|
119
|
+
self.query_one("#authenticate", Button).display = False
|
|
98
120
|
self.set_focus(self.query_one("#api-key", Input))
|
|
99
121
|
|
|
122
|
+
# Refresh UI asynchronously
|
|
123
|
+
self.run_worker(self._refresh_ui(), exclusive=False)
|
|
124
|
+
|
|
125
|
+
def on_screenresume(self) -> None:
|
|
126
|
+
"""Refresh provider status when screen is resumed.
|
|
127
|
+
|
|
128
|
+
This ensures the UI reflects any provider changes made elsewhere.
|
|
129
|
+
"""
|
|
130
|
+
self.run_worker(self._refresh_ui(), exclusive=False)
|
|
131
|
+
|
|
132
|
+
async def _refresh_ui(self) -> None:
|
|
133
|
+
"""Refresh provider status and button visibility."""
|
|
134
|
+
await self.refresh_provider_status()
|
|
135
|
+
await self._update_done_button_visibility()
|
|
136
|
+
|
|
100
137
|
def action_done(self) -> None:
|
|
101
138
|
self.dismiss()
|
|
102
139
|
|
|
@@ -117,6 +154,10 @@ class ProviderConfigScreen(Screen[None]):
|
|
|
117
154
|
def _on_save_pressed(self) -> None:
|
|
118
155
|
self._save_api_key()
|
|
119
156
|
|
|
157
|
+
@on(Button.Pressed, "#authenticate")
|
|
158
|
+
def _on_authenticate_pressed(self) -> None:
|
|
159
|
+
self.run_worker(self._start_shotgun_auth(), exclusive=True)
|
|
160
|
+
|
|
120
161
|
@on(Button.Pressed, "#clear")
|
|
121
162
|
def _on_clear_pressed(self) -> None:
|
|
122
163
|
self._clear_api_key()
|
|
@@ -133,58 +174,110 @@ class ProviderConfigScreen(Screen[None]):
|
|
|
133
174
|
def watch_selected_provider(self, provider: ProviderType) -> None:
|
|
134
175
|
if not self.is_mounted:
|
|
135
176
|
return
|
|
177
|
+
|
|
178
|
+
# Show/hide UI elements based on provider type asynchronously
|
|
179
|
+
self.run_worker(self._update_provider_ui(provider), exclusive=False)
|
|
180
|
+
|
|
181
|
+
async def _update_provider_ui(self, provider: ProviderType) -> None:
|
|
182
|
+
"""Update UI elements based on selected provider."""
|
|
183
|
+
is_shotgun = provider == "shotgun"
|
|
184
|
+
|
|
136
185
|
input_widget = self.query_one("#api-key", Input)
|
|
137
|
-
|
|
138
|
-
|
|
186
|
+
save_button = self.query_one("#save", Button)
|
|
187
|
+
auth_button = self.query_one("#authenticate", Button)
|
|
188
|
+
|
|
189
|
+
if is_shotgun:
|
|
190
|
+
# Hide API key input and save button
|
|
191
|
+
input_widget.display = False
|
|
192
|
+
save_button.display = False
|
|
193
|
+
|
|
194
|
+
# Only show Authenticate button if shotgun is NOT already configured
|
|
195
|
+
if await self._has_provider_key("shotgun"):
|
|
196
|
+
auth_button.display = False
|
|
197
|
+
else:
|
|
198
|
+
auth_button.display = True
|
|
199
|
+
else:
|
|
200
|
+
# Show API key input and save button, hide authenticate button
|
|
201
|
+
input_widget.display = True
|
|
202
|
+
save_button.display = True
|
|
203
|
+
auth_button.display = False
|
|
204
|
+
input_widget.placeholder = self._input_placeholder(provider)
|
|
205
|
+
input_widget.value = ""
|
|
139
206
|
|
|
140
207
|
@property
|
|
141
208
|
def config_manager(self) -> ConfigManager:
|
|
142
209
|
app = cast("ShotgunApp", self.app)
|
|
143
210
|
return app.config_manager
|
|
144
211
|
|
|
145
|
-
def refresh_provider_status(self) -> None:
|
|
212
|
+
async def refresh_provider_status(self) -> None:
|
|
146
213
|
"""Update the list view entries to reflect configured providers."""
|
|
147
|
-
for
|
|
148
|
-
label = self.query_one(f"#label-{
|
|
149
|
-
label.update(self._provider_label(
|
|
214
|
+
for provider_id in get_configurable_providers():
|
|
215
|
+
label = self.query_one(f"#label-{provider_id}", Label)
|
|
216
|
+
label.update(await self._provider_label(provider_id))
|
|
217
|
+
|
|
218
|
+
async def _update_done_button_visibility(self) -> None:
|
|
219
|
+
"""Show/hide Done button based on whether any provider keys are configured."""
|
|
220
|
+
done_button = self.query_one("#done", Button)
|
|
221
|
+
has_keys = await self.config_manager.has_any_provider_key()
|
|
222
|
+
done_button.display = has_keys
|
|
223
|
+
|
|
224
|
+
def _build_provider_items_sync(self) -> list[ListItem]:
|
|
225
|
+
"""Build provider items synchronously for compose().
|
|
150
226
|
|
|
151
|
-
|
|
227
|
+
Labels will be populated with status asynchronously in on_mount().
|
|
228
|
+
"""
|
|
152
229
|
items: list[ListItem] = []
|
|
153
|
-
for
|
|
154
|
-
|
|
155
|
-
|
|
230
|
+
for provider_id in get_configurable_providers():
|
|
231
|
+
# Create labels with placeholder text - will be updated in on_mount()
|
|
232
|
+
label = Label(
|
|
233
|
+
self._provider_display_name(provider_id), id=f"label-{provider_id}"
|
|
234
|
+
)
|
|
235
|
+
items.append(ListItem(label, id=f"provider-{provider_id}"))
|
|
156
236
|
return items
|
|
157
237
|
|
|
158
|
-
def _provider_from_item(self, item: ListItem | None) ->
|
|
238
|
+
def _provider_from_item(self, item: ListItem | None) -> str | None:
|
|
159
239
|
if item is None or item.id is None:
|
|
160
240
|
return None
|
|
161
241
|
provider_id = item.id.removeprefix("provider-")
|
|
162
|
-
|
|
163
|
-
return ProviderType(provider_id)
|
|
164
|
-
except ValueError:
|
|
165
|
-
return None
|
|
242
|
+
return provider_id if provider_id in get_configurable_providers() else None
|
|
166
243
|
|
|
167
|
-
def _provider_label(self,
|
|
168
|
-
display = self._provider_display_name(
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
if self.config_manager.has_provider_key(provider)
|
|
172
|
-
else "Not configured"
|
|
173
|
-
)
|
|
244
|
+
async def _provider_label(self, provider_id: str) -> str:
|
|
245
|
+
display = self._provider_display_name(provider_id)
|
|
246
|
+
has_key = await self._has_provider_key(provider_id)
|
|
247
|
+
status = "Configured" if has_key else "Not configured"
|
|
174
248
|
return f"{display} · {status}"
|
|
175
249
|
|
|
176
|
-
def _provider_display_name(self,
|
|
250
|
+
def _provider_display_name(self, provider_id: str) -> str:
|
|
177
251
|
names = {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
252
|
+
"openai": "OpenAI",
|
|
253
|
+
"anthropic": "Anthropic",
|
|
254
|
+
"google": "Google Gemini",
|
|
255
|
+
"shotgun": "Shotgun Account",
|
|
181
256
|
}
|
|
182
|
-
return names.get(
|
|
183
|
-
|
|
184
|
-
def _input_placeholder(self,
|
|
185
|
-
return f"{self._provider_display_name(
|
|
257
|
+
return names.get(provider_id, provider_id.title())
|
|
258
|
+
|
|
259
|
+
def _input_placeholder(self, provider_id: str) -> str:
|
|
260
|
+
return f"{self._provider_display_name(provider_id)} API key"
|
|
261
|
+
|
|
262
|
+
async def _has_provider_key(self, provider_id: str) -> bool:
|
|
263
|
+
"""Check if provider has a configured API key."""
|
|
264
|
+
if provider_id == "shotgun":
|
|
265
|
+
# Check shotgun key directly
|
|
266
|
+
config = await self.config_manager.load()
|
|
267
|
+
return self.config_manager._provider_has_api_key(config.shotgun)
|
|
268
|
+
else:
|
|
269
|
+
# Check LLM provider key
|
|
270
|
+
try:
|
|
271
|
+
provider = ProviderType(provider_id)
|
|
272
|
+
return await self.config_manager.has_provider_key(provider)
|
|
273
|
+
except ValueError:
|
|
274
|
+
return False
|
|
186
275
|
|
|
187
276
|
def _save_api_key(self) -> None:
|
|
277
|
+
self.run_worker(self._do_save_api_key(), exclusive=True)
|
|
278
|
+
|
|
279
|
+
async def _do_save_api_key(self) -> None:
|
|
280
|
+
"""Async implementation of API key saving."""
|
|
188
281
|
input_widget = self.query_one("#api-key", Input)
|
|
189
282
|
api_key = input_widget.value.strip()
|
|
190
283
|
|
|
@@ -193,7 +286,7 @@ class ProviderConfigScreen(Screen[None]):
|
|
|
193
286
|
return
|
|
194
287
|
|
|
195
288
|
try:
|
|
196
|
-
self.config_manager.update_provider(
|
|
289
|
+
await self.config_manager.update_provider(
|
|
197
290
|
self.selected_provider,
|
|
198
291
|
api_key=api_key,
|
|
199
292
|
)
|
|
@@ -202,20 +295,44 @@ class ProviderConfigScreen(Screen[None]):
|
|
|
202
295
|
return
|
|
203
296
|
|
|
204
297
|
input_widget.value = ""
|
|
205
|
-
self.refresh_provider_status()
|
|
298
|
+
await self.refresh_provider_status()
|
|
299
|
+
await self._update_done_button_visibility()
|
|
206
300
|
self.notify(
|
|
207
301
|
f"Saved API key for {self._provider_display_name(self.selected_provider)}."
|
|
208
302
|
)
|
|
209
303
|
|
|
210
304
|
def _clear_api_key(self) -> None:
|
|
305
|
+
self.run_worker(self._do_clear_api_key(), exclusive=True)
|
|
306
|
+
|
|
307
|
+
async def _do_clear_api_key(self) -> None:
|
|
308
|
+
"""Async implementation of API key clearing."""
|
|
211
309
|
try:
|
|
212
|
-
self.config_manager.clear_provider_key(self.selected_provider)
|
|
310
|
+
await self.config_manager.clear_provider_key(self.selected_provider)
|
|
213
311
|
except Exception as exc: # pragma: no cover - defensive; textual path
|
|
214
312
|
self.notify(f"Failed to clear key: {exc}", severity="error")
|
|
215
313
|
return
|
|
216
314
|
|
|
217
|
-
self.refresh_provider_status()
|
|
315
|
+
await self.refresh_provider_status()
|
|
316
|
+
await self._update_done_button_visibility()
|
|
218
317
|
self.query_one("#api-key", Input).value = ""
|
|
318
|
+
|
|
319
|
+
# If we just cleared shotgun, show the Authenticate button
|
|
320
|
+
if self.selected_provider == "shotgun":
|
|
321
|
+
auth_button = self.query_one("#authenticate", Button)
|
|
322
|
+
auth_button.display = True
|
|
323
|
+
|
|
219
324
|
self.notify(
|
|
220
325
|
f"Cleared API key for {self._provider_display_name(self.selected_provider)}."
|
|
221
326
|
)
|
|
327
|
+
|
|
328
|
+
async def _start_shotgun_auth(self) -> None:
|
|
329
|
+
"""Launch Shotgun Account authentication flow."""
|
|
330
|
+
from .shotgun_auth import ShotgunAuthScreen
|
|
331
|
+
|
|
332
|
+
# Push the auth screen and wait for result
|
|
333
|
+
result = await self.app.push_screen_wait(ShotgunAuthScreen())
|
|
334
|
+
|
|
335
|
+
# Refresh provider status after auth completes
|
|
336
|
+
if result:
|
|
337
|
+
await self.refresh_provider_status()
|
|
338
|
+
# Notify handled by auth screen
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Shotgun Account authentication screen."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import webbrowser
|
|
5
|
+
from typing import TYPE_CHECKING, cast
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from textual import on
|
|
9
|
+
from textual.app import ComposeResult
|
|
10
|
+
from textual.containers import Vertical
|
|
11
|
+
from textual.screen import Screen
|
|
12
|
+
from textual.widgets import Button, Label, Markdown, Static
|
|
13
|
+
from textual.worker import Worker, WorkerState
|
|
14
|
+
|
|
15
|
+
from shotgun.agents.config import ConfigManager
|
|
16
|
+
from shotgun.logging_config import get_logger
|
|
17
|
+
from shotgun.shotgun_web import (
|
|
18
|
+
ShotgunWebClient,
|
|
19
|
+
TokenStatus,
|
|
20
|
+
)
|
|
21
|
+
from shotgun.shotgun_web.constants import DEFAULT_POLL_INTERVAL_SECONDS
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from ..app import ShotgunApp
|
|
25
|
+
|
|
26
|
+
logger = get_logger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ShotgunAuthScreen(Screen[bool]):
|
|
30
|
+
"""Screen for Shotgun Account authentication flow.
|
|
31
|
+
|
|
32
|
+
Returns True if authentication was successful, False otherwise.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
CSS = """
|
|
36
|
+
ShotgunAuth {
|
|
37
|
+
layout: vertical;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
#titlebox {
|
|
41
|
+
height: auto;
|
|
42
|
+
margin: 2 0;
|
|
43
|
+
padding: 1;
|
|
44
|
+
border: hkey $border;
|
|
45
|
+
content-align: center middle;
|
|
46
|
+
|
|
47
|
+
& > * {
|
|
48
|
+
text-align: center;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#auth-title {
|
|
53
|
+
padding: 1 0;
|
|
54
|
+
text-style: bold;
|
|
55
|
+
color: $text-accent;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#content {
|
|
59
|
+
padding: 2;
|
|
60
|
+
height: auto;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
#status {
|
|
64
|
+
padding: 1 0;
|
|
65
|
+
text-align: center;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#auth-url {
|
|
69
|
+
padding: 1;
|
|
70
|
+
border: solid $primary;
|
|
71
|
+
background: $surface;
|
|
72
|
+
text-align: center;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
#actions {
|
|
76
|
+
padding: 1;
|
|
77
|
+
align: center middle;
|
|
78
|
+
}
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
BINDINGS = [
|
|
82
|
+
("escape", "cancel", "Cancel"),
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
def __init__(self) -> None:
|
|
86
|
+
super().__init__()
|
|
87
|
+
self.token: str | None = None
|
|
88
|
+
self.auth_url: str | None = None
|
|
89
|
+
self.poll_worker: Worker[None] | None = None
|
|
90
|
+
|
|
91
|
+
def compose(self) -> ComposeResult:
|
|
92
|
+
with Vertical(id="titlebox"):
|
|
93
|
+
yield Static("Shotgun Account Setup", id="auth-title")
|
|
94
|
+
yield Static(
|
|
95
|
+
"Authenticate with your Shotgun Account to get started",
|
|
96
|
+
id="auth-subtitle",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
with Vertical(id="content"):
|
|
100
|
+
yield Label("Initializing...", id="status")
|
|
101
|
+
yield Markdown("", id="auth-url")
|
|
102
|
+
yield Markdown(
|
|
103
|
+
"**Instructions:**\n"
|
|
104
|
+
"1. A browser window will open automatically\n"
|
|
105
|
+
"2. Sign in or create a Shotgun Account\n"
|
|
106
|
+
"3. Complete payment if required\n"
|
|
107
|
+
"4. This window will automatically detect completion",
|
|
108
|
+
id="instructions",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
with Vertical(id="actions"):
|
|
112
|
+
yield Button("Cancel", variant="default", id="cancel")
|
|
113
|
+
|
|
114
|
+
def on_mount(self) -> None:
|
|
115
|
+
"""Start authentication flow when screen is mounted."""
|
|
116
|
+
self.run_worker(self._start_auth_flow(), exclusive=True)
|
|
117
|
+
|
|
118
|
+
def action_cancel(self) -> None:
|
|
119
|
+
"""Cancel authentication and close screen."""
|
|
120
|
+
if self.poll_worker and self.poll_worker.state == WorkerState.RUNNING:
|
|
121
|
+
self.poll_worker.cancel()
|
|
122
|
+
self.dismiss(False)
|
|
123
|
+
|
|
124
|
+
@on(Button.Pressed, "#cancel")
|
|
125
|
+
def _on_cancel_pressed(self) -> None:
|
|
126
|
+
"""Handle cancel button press."""
|
|
127
|
+
self.action_cancel()
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def config_manager(self) -> ConfigManager:
|
|
131
|
+
app = cast("ShotgunApp", self.app)
|
|
132
|
+
return app.config_manager
|
|
133
|
+
|
|
134
|
+
async def _start_auth_flow(self) -> None:
|
|
135
|
+
"""Start the authentication flow."""
|
|
136
|
+
try:
|
|
137
|
+
# Get shotgun instance ID from config
|
|
138
|
+
shotgun_instance_id = await self.config_manager.get_shotgun_instance_id()
|
|
139
|
+
logger.info("Starting auth flow with instance ID: %s", shotgun_instance_id)
|
|
140
|
+
|
|
141
|
+
# Update status
|
|
142
|
+
self.query_one("#status", Label).update(
|
|
143
|
+
"🔄 Creating authentication token..."
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Create unification token
|
|
147
|
+
client = ShotgunWebClient()
|
|
148
|
+
response = client.create_unification_token(shotgun_instance_id)
|
|
149
|
+
|
|
150
|
+
self.token = response.token
|
|
151
|
+
self.auth_url = response.auth_url
|
|
152
|
+
|
|
153
|
+
logger.info("Auth URL: %s", self.auth_url)
|
|
154
|
+
|
|
155
|
+
# Update UI with auth URL
|
|
156
|
+
self.query_one("#status", Label).update("✅ Authentication URL ready")
|
|
157
|
+
self.query_one("#auth-url", Markdown).update(
|
|
158
|
+
f"**Authentication URL:**\n\n[{self.auth_url}]({self.auth_url})"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# Try to open browser
|
|
162
|
+
try:
|
|
163
|
+
self.query_one("#status", Label).update("🌐 Opening browser...")
|
|
164
|
+
webbrowser.open(self.auth_url)
|
|
165
|
+
await asyncio.sleep(1)
|
|
166
|
+
self.query_one("#status", Label).update(
|
|
167
|
+
"⏳ Waiting for authentication... (opened in browser)"
|
|
168
|
+
)
|
|
169
|
+
except Exception as e:
|
|
170
|
+
logger.warning("Failed to open browser: %s", e)
|
|
171
|
+
self.query_one("#status", Label).update(
|
|
172
|
+
"⚠️ Please click the link above to authenticate"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# Start polling for status
|
|
176
|
+
self.poll_worker = self.run_worker(
|
|
177
|
+
self._poll_token_status(), exclusive=False
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
except httpx.HTTPError as e:
|
|
181
|
+
logger.error("Failed to create auth token: %s", e)
|
|
182
|
+
self.query_one("#status", Label).update(
|
|
183
|
+
f"❌ Error: Failed to create authentication token\n{e}"
|
|
184
|
+
)
|
|
185
|
+
self.notify("Failed to start authentication", severity="error")
|
|
186
|
+
|
|
187
|
+
except Exception as e:
|
|
188
|
+
logger.error("Unexpected error during auth flow: %s", e)
|
|
189
|
+
self.query_one("#status", Label).update(f"❌ Unexpected error: {e}")
|
|
190
|
+
self.notify("Authentication failed", severity="error")
|
|
191
|
+
|
|
192
|
+
async def _poll_token_status(self) -> None:
|
|
193
|
+
"""Poll token status until completed or expired."""
|
|
194
|
+
if not self.token:
|
|
195
|
+
logger.error("No token available for polling")
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
client = ShotgunWebClient()
|
|
199
|
+
poll_count = 0
|
|
200
|
+
max_polls = 600 # 30 minutes with 3 second intervals
|
|
201
|
+
|
|
202
|
+
while poll_count < max_polls:
|
|
203
|
+
try:
|
|
204
|
+
await asyncio.sleep(DEFAULT_POLL_INTERVAL_SECONDS)
|
|
205
|
+
poll_count += 1
|
|
206
|
+
|
|
207
|
+
logger.debug(
|
|
208
|
+
"Polling token status (attempt %d/%d)", poll_count, max_polls
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
status_response = client.check_token_status(self.token)
|
|
212
|
+
|
|
213
|
+
if status_response.status == TokenStatus.COMPLETED:
|
|
214
|
+
# Success! Save keys and dismiss
|
|
215
|
+
logger.info("Authentication completed successfully")
|
|
216
|
+
|
|
217
|
+
if status_response.litellm_key and status_response.supabase_key:
|
|
218
|
+
await self.config_manager.update_shotgun_account(
|
|
219
|
+
api_key=status_response.litellm_key,
|
|
220
|
+
supabase_jwt=status_response.supabase_key,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
self.query_one("#status", Label).update(
|
|
224
|
+
"✅ Authentication successful! Saving credentials..."
|
|
225
|
+
)
|
|
226
|
+
await asyncio.sleep(1)
|
|
227
|
+
self.notify(
|
|
228
|
+
"Shotgun Account configured successfully!",
|
|
229
|
+
severity="information",
|
|
230
|
+
)
|
|
231
|
+
self.dismiss(True)
|
|
232
|
+
else:
|
|
233
|
+
logger.error("Completed but missing keys")
|
|
234
|
+
self.query_one("#status", Label).update(
|
|
235
|
+
"❌ Error: Authentication completed but keys are missing"
|
|
236
|
+
)
|
|
237
|
+
self.notify("Authentication failed", severity="error")
|
|
238
|
+
await asyncio.sleep(3)
|
|
239
|
+
self.dismiss(False)
|
|
240
|
+
return
|
|
241
|
+
|
|
242
|
+
elif status_response.status == TokenStatus.AWAITING_PAYMENT:
|
|
243
|
+
self.query_one("#status", Label).update(
|
|
244
|
+
"💳 Waiting for payment completion..."
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
elif status_response.status == TokenStatus.EXPIRED:
|
|
248
|
+
logger.error("Token expired")
|
|
249
|
+
self.query_one("#status", Label).update(
|
|
250
|
+
"❌ Authentication token expired (30 minutes)\n"
|
|
251
|
+
"Please try again."
|
|
252
|
+
)
|
|
253
|
+
self.notify("Authentication token expired", severity="error")
|
|
254
|
+
await asyncio.sleep(3)
|
|
255
|
+
self.dismiss(False)
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
elif status_response.status == TokenStatus.PENDING:
|
|
259
|
+
# Still waiting, update status message
|
|
260
|
+
elapsed_minutes = (poll_count * DEFAULT_POLL_INTERVAL_SECONDS) // 60
|
|
261
|
+
self.query_one("#status", Label).update(
|
|
262
|
+
f"⏳ Waiting for authentication... ({elapsed_minutes}m elapsed)"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
except httpx.HTTPStatusError as e:
|
|
266
|
+
if e.response.status_code == 410:
|
|
267
|
+
# Token expired
|
|
268
|
+
logger.error("Token expired (410)")
|
|
269
|
+
self.query_one("#status", Label).update(
|
|
270
|
+
"❌ Authentication token expired"
|
|
271
|
+
)
|
|
272
|
+
self.notify("Authentication token expired", severity="error")
|
|
273
|
+
await asyncio.sleep(3)
|
|
274
|
+
self.dismiss(False)
|
|
275
|
+
return
|
|
276
|
+
else:
|
|
277
|
+
logger.error("HTTP error polling status: %s", e)
|
|
278
|
+
self.query_one("#status", Label).update(
|
|
279
|
+
f"❌ Error checking status: {e}"
|
|
280
|
+
)
|
|
281
|
+
await asyncio.sleep(5) # Wait a bit longer on error
|
|
282
|
+
|
|
283
|
+
except Exception as e:
|
|
284
|
+
logger.error("Error polling token status: %s", e)
|
|
285
|
+
self.query_one("#status", Label).update(f"⚠️ Error checking status: {e}")
|
|
286
|
+
await asyncio.sleep(5) # Wait a bit longer on error
|
|
287
|
+
|
|
288
|
+
# Timeout reached
|
|
289
|
+
logger.error("Polling timeout reached")
|
|
290
|
+
self.query_one("#status", Label).update(
|
|
291
|
+
"❌ Authentication timeout (30 minutes)\nPlease try again."
|
|
292
|
+
)
|
|
293
|
+
self.notify("Authentication timeout", severity="error")
|
|
294
|
+
await asyncio.sleep(3)
|
|
295
|
+
self.dismiss(False)
|