shotgun-sh 0.2.1.dev4__py3-none-any.whl → 0.2.1.dev5__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.

@@ -85,6 +85,7 @@ class ProviderConfigScreen(Screen[None]):
85
85
 
86
86
  BINDINGS = [
87
87
  ("escape", "done", "Back"),
88
+ ("ctrl+c", "app.quit", "Quit"),
88
89
  ]
89
90
 
90
91
  selected_provider: reactive[str] = reactive("openai")
@@ -108,15 +109,20 @@ class ProviderConfigScreen(Screen[None]):
108
109
  )
109
110
  with Horizontal(id="provider-actions"):
110
111
  yield Button("Save key \\[ENTER]", variant="primary", id="save")
112
+ yield Button("Authenticate", variant="success", id="authenticate")
111
113
  yield Button("Clear key", id="clear", variant="warning")
112
114
  yield Button("Done \\[ESC]", id="done")
113
115
 
114
116
  def on_mount(self) -> None:
115
117
  self.refresh_provider_status()
118
+ self._update_done_button_visibility()
116
119
  list_view = self.query_one(ListView)
117
120
  if list_view.children:
118
121
  list_view.index = 0
119
122
  self.selected_provider = "openai"
123
+
124
+ # Hide authenticate button by default (shown only for shotgun)
125
+ self.query_one("#authenticate", Button).display = False
120
126
  self.set_focus(self.query_one("#api-key", Input))
121
127
 
122
128
  def on_screenresume(self) -> None:
@@ -125,6 +131,7 @@ class ProviderConfigScreen(Screen[None]):
125
131
  This ensures the UI reflects any provider changes made elsewhere.
126
132
  """
127
133
  self.refresh_provider_status()
134
+ self._update_done_button_visibility()
128
135
 
129
136
  def action_done(self) -> None:
130
137
  self.dismiss()
@@ -146,6 +153,10 @@ class ProviderConfigScreen(Screen[None]):
146
153
  def _on_save_pressed(self) -> None:
147
154
  self._save_api_key()
148
155
 
156
+ @on(Button.Pressed, "#authenticate")
157
+ def _on_authenticate_pressed(self) -> None:
158
+ self.run_worker(self._start_shotgun_auth(), exclusive=True)
159
+
149
160
  @on(Button.Pressed, "#clear")
150
161
  def _on_clear_pressed(self) -> None:
151
162
  self._clear_api_key()
@@ -162,9 +173,31 @@ class ProviderConfigScreen(Screen[None]):
162
173
  def watch_selected_provider(self, provider: ProviderType) -> None:
163
174
  if not self.is_mounted:
164
175
  return
176
+
177
+ # Show/hide UI elements based on provider type
178
+ is_shotgun = provider == "shotgun"
179
+
165
180
  input_widget = self.query_one("#api-key", Input)
166
- input_widget.placeholder = self._input_placeholder(provider)
167
- input_widget.value = ""
181
+ save_button = self.query_one("#save", Button)
182
+ auth_button = self.query_one("#authenticate", Button)
183
+
184
+ if is_shotgun:
185
+ # Hide API key input and save button
186
+ input_widget.display = False
187
+ save_button.display = False
188
+
189
+ # Only show Authenticate button if shotgun is NOT already configured
190
+ if self._has_provider_key("shotgun"):
191
+ auth_button.display = False
192
+ else:
193
+ auth_button.display = True
194
+ else:
195
+ # Show API key input and save button, hide authenticate button
196
+ input_widget.display = True
197
+ save_button.display = True
198
+ auth_button.display = False
199
+ input_widget.placeholder = self._input_placeholder(provider)
200
+ input_widget.value = ""
168
201
 
169
202
  @property
170
203
  def config_manager(self) -> ConfigManager:
@@ -177,6 +210,12 @@ class ProviderConfigScreen(Screen[None]):
177
210
  label = self.query_one(f"#label-{provider_id}", Label)
178
211
  label.update(self._provider_label(provider_id))
179
212
 
213
+ def _update_done_button_visibility(self) -> None:
214
+ """Show/hide Done button based on whether any provider keys are configured."""
215
+ done_button = self.query_one("#done", Button)
216
+ has_keys = self.config_manager.has_any_provider_key()
217
+ done_button.display = has_keys
218
+
180
219
  def _build_provider_items(self) -> list[ListItem]:
181
220
  items: list[ListItem] = []
182
221
  for provider_id in get_configurable_providers():
@@ -242,6 +281,7 @@ class ProviderConfigScreen(Screen[None]):
242
281
 
243
282
  input_widget.value = ""
244
283
  self.refresh_provider_status()
284
+ self._update_done_button_visibility()
245
285
  self.notify(
246
286
  f"Saved API key for {self._provider_display_name(self.selected_provider)}."
247
287
  )
@@ -254,7 +294,26 @@ class ProviderConfigScreen(Screen[None]):
254
294
  return
255
295
 
256
296
  self.refresh_provider_status()
297
+ self._update_done_button_visibility()
257
298
  self.query_one("#api-key", Input).value = ""
299
+
300
+ # If we just cleared shotgun, show the Authenticate button
301
+ if self.selected_provider == "shotgun":
302
+ auth_button = self.query_one("#authenticate", Button)
303
+ auth_button.display = True
304
+
258
305
  self.notify(
259
306
  f"Cleared API key for {self._provider_display_name(self.selected_provider)}."
260
307
  )
308
+
309
+ async def _start_shotgun_auth(self) -> None:
310
+ """Launch Shotgun Account authentication flow."""
311
+ from .shotgun_auth import ShotgunAuthScreen
312
+
313
+ # Push the auth screen and wait for result
314
+ result = await self.app.push_screen_wait(ShotgunAuthScreen())
315
+
316
+ # Refresh provider status after auth completes
317
+ if result:
318
+ self.refresh_provider_status()
319
+ # 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 = 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
+ 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)
@@ -0,0 +1,176 @@
1
+ """Welcome screen for choosing between Shotgun Account and BYOK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, cast
6
+
7
+ from textual import on
8
+ from textual.app import ComposeResult
9
+ from textual.containers import Container, Horizontal, Vertical
10
+ from textual.screen import Screen
11
+ from textual.widgets import Button, Markdown, Static
12
+
13
+ if TYPE_CHECKING:
14
+ from ..app import ShotgunApp
15
+
16
+
17
+ class WelcomeScreen(Screen[None]):
18
+ """Welcome screen for first-time setup."""
19
+
20
+ CSS = """
21
+ WelcomeScreen {
22
+ layout: vertical;
23
+ align: center middle;
24
+ }
25
+
26
+ #titlebox {
27
+ width: 100%;
28
+ height: auto;
29
+ margin: 2 0;
30
+ padding: 1;
31
+ border: hkey $border;
32
+ content-align: center middle;
33
+
34
+ & > * {
35
+ text-align: center;
36
+ }
37
+ }
38
+
39
+ #welcome-title {
40
+ padding: 1 0;
41
+ text-style: bold;
42
+ color: $text-accent;
43
+ }
44
+
45
+ #welcome-subtitle {
46
+ padding: 0 1;
47
+ }
48
+
49
+ #options-container {
50
+ width: 100%;
51
+ height: auto;
52
+ padding: 2;
53
+ align: center middle;
54
+ }
55
+
56
+ #options {
57
+ width: auto;
58
+ height: auto;
59
+ }
60
+
61
+ .option-box {
62
+ width: 45;
63
+ height: auto;
64
+ border: solid $primary;
65
+ padding: 2;
66
+ margin: 0 1;
67
+ background: $surface;
68
+ }
69
+
70
+ .option-box:focus-within {
71
+ border: solid $accent;
72
+ }
73
+
74
+ .option-title {
75
+ text-style: bold;
76
+ color: $text-accent;
77
+ padding: 0 0 1 0;
78
+ }
79
+
80
+ .option-benefits {
81
+ padding: 1 0;
82
+ }
83
+
84
+ .option-button {
85
+ margin: 1 0 0 0;
86
+ width: 100%;
87
+ }
88
+ """
89
+
90
+ BINDINGS = [
91
+ ("ctrl+c", "app.quit", "Quit"),
92
+ ]
93
+
94
+ def compose(self) -> ComposeResult:
95
+ with Vertical(id="titlebox"):
96
+ yield Static("Welcome to Shotgun", id="welcome-title")
97
+ yield Static(
98
+ "Choose how you'd like to get started",
99
+ id="welcome-subtitle",
100
+ )
101
+
102
+ with Container(id="options-container"):
103
+ with Horizontal(id="options"):
104
+ # Left box - Shotgun Account
105
+ with Vertical(classes="option-box", id="shotgun-box"):
106
+ yield Static("Use a Shotgun Account", classes="option-title")
107
+ yield Markdown(
108
+ "**Benefits:**\n"
109
+ "• Use of all models in the Model Garden\n"
110
+ "• We'll pick the optimal models to give you the best "
111
+ "experience for things like web search, codebase indexing",
112
+ classes="option-benefits",
113
+ )
114
+ yield Button(
115
+ "Sign Up for/Use your Shotgun Account",
116
+ variant="primary",
117
+ id="shotgun-button",
118
+ classes="option-button",
119
+ )
120
+
121
+ # Right box - BYOK
122
+ with Vertical(classes="option-box", id="byok-box"):
123
+ yield Static("Bring Your Own Key (BYOK)", classes="option-title")
124
+ yield Markdown(
125
+ "**Benefits:**\n"
126
+ "• 100% Supported by the application\n"
127
+ "• Use your existing API keys from OpenAI, Anthropic, or Google",
128
+ classes="option-benefits",
129
+ )
130
+ yield Button(
131
+ "Configure API Keys",
132
+ variant="success",
133
+ id="byok-button",
134
+ classes="option-button",
135
+ )
136
+
137
+ def on_mount(self) -> None:
138
+ """Focus the first button on mount."""
139
+ self.query_one("#shotgun-button", Button).focus()
140
+
141
+ @on(Button.Pressed, "#shotgun-button")
142
+ def _on_shotgun_pressed(self) -> None:
143
+ """Handle Shotgun Account button press."""
144
+ self.run_worker(self._start_shotgun_auth(), exclusive=True)
145
+
146
+ @on(Button.Pressed, "#byok-button")
147
+ def _on_byok_pressed(self) -> None:
148
+ """Handle BYOK button press."""
149
+ self._mark_welcome_shown()
150
+ # Push provider config screen before dismissing
151
+ from .provider_config import ProviderConfigScreen
152
+
153
+ self.app.push_screen(
154
+ ProviderConfigScreen(),
155
+ callback=lambda _arg: self.dismiss(),
156
+ )
157
+
158
+ async def _start_shotgun_auth(self) -> None:
159
+ """Launch Shotgun Account authentication flow."""
160
+ from .shotgun_auth import ShotgunAuthScreen
161
+
162
+ # Mark welcome screen as shown before auth
163
+ self._mark_welcome_shown()
164
+
165
+ # Push the auth screen and wait for result
166
+ await self.app.push_screen_wait(ShotgunAuthScreen())
167
+
168
+ # Dismiss welcome screen after auth
169
+ self.dismiss()
170
+
171
+ def _mark_welcome_shown(self) -> None:
172
+ """Mark the welcome screen as shown in config."""
173
+ app = cast("ShotgunApp", self.app)
174
+ config = app.config_manager.load()
175
+ config.shown_welcome_screen = True
176
+ app.config_manager.save(config)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shotgun-sh
3
- Version: 0.2.1.dev4
3
+ Version: 0.2.1.dev5
4
4
  Summary: AI-powered research, planning, and task management CLI tool
5
5
  Project-URL: Homepage, https://shotgun.sh/
6
6
  Project-URL: Repository, https://github.com/shotgun-sh/shotgun