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

@@ -362,6 +362,17 @@ class AgentManager(Widget):
362
362
  **kwargs,
363
363
  )
364
364
  finally:
365
+ # If the stream ended unexpectedly without a final result, clear accumulated state.
366
+ # state = self._stream_state
367
+ # if state is not None:
368
+ # pending_response = state.current_response
369
+ # if pending_response is not None:
370
+ # already_recorded = (
371
+ # bool(state.messages) and state.messages[-1] is pending_response
372
+ # )
373
+ # if not already_recorded:
374
+ # self._post_partial_message(pending_response, True)
375
+ # state.messages.append(pending_response)
365
376
  self._stream_state = None
366
377
 
367
378
  self.ui_message_history = original_messages + cast(
@@ -4,13 +4,15 @@ import time
4
4
  from datetime import datetime
5
5
  from typing import TYPE_CHECKING
6
6
 
7
- from pydantic_ai import Agent
7
+ from pydantic_ai.messages import (
8
+ ModelRequest,
9
+ SystemPromptPart,
10
+ TextPart,
11
+ UserPromptPart,
12
+ )
8
13
 
9
14
  from shotgun.agents.config import get_provider_model
10
- from shotgun.codebase.core.cypher_models import (
11
- CypherGenerationNotPossibleError,
12
- CypherGenerationResponse,
13
- )
15
+ from shotgun.agents.config.models import shotgun_model_request
14
16
  from shotgun.logging_config import get_logger
15
17
  from shotgun.prompts import PromptLoader
16
18
 
@@ -23,52 +25,42 @@ logger = get_logger(__name__)
23
25
  prompt_loader = PromptLoader()
24
26
 
25
27
 
26
- async def llm_cypher_prompt(
27
- system_prompt: str, user_prompt: str
28
- ) -> CypherGenerationResponse:
29
- """Generate a Cypher query from a natural language prompt using structured output.
28
+ async def llm_cypher_prompt(system_prompt: str, user_prompt: str) -> str:
29
+ """Generate a Cypher query from a natural language prompt using the configured LLM provider.
30
30
 
31
31
  Args:
32
32
  system_prompt: The system prompt defining the behavior and context for the LLM
33
33
  user_prompt: The user's natural language query
34
34
  Returns:
35
- CypherGenerationResponse with cypher_query, can_generate flag, and reason if not
35
+ The generated Cypher query as a string
36
36
  """
37
37
  model_config = get_provider_model()
38
-
39
- # Create an agent with structured output for Cypher generation
40
- cypher_agent = Agent(
41
- model=model_config.model_instance,
42
- output_type=CypherGenerationResponse,
43
- retries=2,
38
+ # Use shotgun wrapper to maximize response quality for codebase queries
39
+ # Limit max_tokens to 2000 for Cypher queries (they're typically 50-200 tokens)
40
+ # This prevents Anthropic SDK from requiring streaming for longer token limits
41
+ query_cypher_response = await shotgun_model_request(
42
+ model_config=model_config,
43
+ messages=[
44
+ ModelRequest(
45
+ parts=[
46
+ SystemPromptPart(content=system_prompt),
47
+ UserPromptPart(content=user_prompt),
48
+ ]
49
+ ),
50
+ ],
51
+ max_tokens=2000, # Cypher queries are short, 2000 tokens is plenty
44
52
  )
45
53
 
46
- # Combine system and user prompts
47
- combined_prompt = f"{system_prompt}\n\nUser Query: {user_prompt}"
48
-
49
- try:
50
- # Run the agent to get structured response
51
- result = await cypher_agent.run(combined_prompt)
52
- response = result.output
53
-
54
- # Log the structured response for debugging
55
- logger.debug(
56
- "Cypher generation response - can_generate: %s, query: %s, reason: %s",
57
- response.can_generate_valid_cypher,
58
- response.cypher_query[:50] if response.cypher_query else None,
59
- response.reason_cannot_generate,
60
- )
61
-
62
- return response
54
+ if not query_cypher_response.parts or not query_cypher_response.parts[0]:
55
+ raise ValueError("Empty response from LLM")
63
56
 
64
- except Exception as e:
65
- logger.error("Failed to generate Cypher query with structured output: %s", e)
66
- # Return a failure response
67
- return CypherGenerationResponse(
68
- cypher_query=None,
69
- can_generate_valid_cypher=False,
70
- reason_cannot_generate=f"LLM error: {str(e)}",
71
- )
57
+ message_part = query_cypher_response.parts[0]
58
+ if not isinstance(message_part, TextPart):
59
+ raise ValueError("Unexpected response part type from LLM")
60
+ cypher_query = str(message_part.content)
61
+ if not cypher_query:
62
+ raise ValueError("Empty content in LLM response")
63
+ return cypher_query
72
64
 
73
65
 
74
66
  async def generate_cypher(natural_language_query: str) -> str:
@@ -79,10 +71,6 @@ async def generate_cypher(natural_language_query: str) -> str:
79
71
 
80
72
  Returns:
81
73
  Generated Cypher query
82
-
83
- Raises:
84
- CypherGenerationNotPossibleError: If the query cannot be converted to Cypher
85
- RuntimeError: If there's an error during generation
86
74
  """
87
75
  # Get current time for context
88
76
  current_timestamp = int(time.time())
@@ -100,30 +88,8 @@ async def generate_cypher(natural_language_query: str) -> str:
100
88
  )
101
89
 
102
90
  try:
103
- response = await llm_cypher_prompt(system_prompt, enhanced_query)
104
-
105
- # Check if the LLM could generate a valid Cypher query
106
- if not response.can_generate_valid_cypher:
107
- logger.info(
108
- "Cannot generate Cypher for query '%s': %s",
109
- natural_language_query,
110
- response.reason_cannot_generate,
111
- )
112
- raise CypherGenerationNotPossibleError(
113
- response.reason_cannot_generate or "Query cannot be converted to Cypher"
114
- )
115
-
116
- if not response.cypher_query:
117
- raise ValueError("LLM indicated success but provided no query")
118
-
119
- cleaned_query = clean_cypher_response(response.cypher_query)
120
-
121
- # Validate Cypher keywords
122
- is_valid, validation_error = validate_cypher_keywords(cleaned_query)
123
- if not is_valid:
124
- logger.warning(f"Generated query has invalid syntax: {validation_error}")
125
- logger.warning(f"Problematic query: {cleaned_query}")
126
- raise ValueError(f"Generated query validation failed: {validation_error}")
91
+ cypher_query = await llm_cypher_prompt(system_prompt, enhanced_query)
92
+ cleaned_query = clean_cypher_response(cypher_query)
127
93
 
128
94
  # Validate UNION ALL queries
129
95
  is_valid, validation_error = validate_union_query(cleaned_query)
@@ -134,8 +100,6 @@ async def generate_cypher(natural_language_query: str) -> str:
134
100
 
135
101
  return cleaned_query
136
102
 
137
- except CypherGenerationNotPossibleError:
138
- raise # Re-raise as-is
139
103
  except Exception as e:
140
104
  raise RuntimeError(f"Failed to generate Cypher query: {e}") from e
141
105
 
@@ -206,31 +170,8 @@ MATCH (f:Function) RETURN f.name, f.qualified_name // WRONG: missing third colu
206
170
  base_system_prompt=prompt_loader.render("codebase/cypher_system.j2"),
207
171
  )
208
172
 
209
- response = await llm_cypher_prompt(enhanced_system_prompt, enhanced_query)
210
-
211
- # Check if the LLM could generate a valid Cypher query
212
- if not response.can_generate_valid_cypher:
213
- logger.info(
214
- "Cannot generate Cypher for retry query '%s': %s",
215
- natural_language_query,
216
- response.reason_cannot_generate,
217
- )
218
- raise CypherGenerationNotPossibleError(
219
- response.reason_cannot_generate
220
- or "Query cannot be converted to Cypher even with error context"
221
- )
222
-
223
- if not response.cypher_query:
224
- raise ValueError("LLM indicated success but provided no query on retry")
225
-
226
- cleaned_query = clean_cypher_response(response.cypher_query)
227
-
228
- # Validate Cypher keywords
229
- is_valid, validation_error = validate_cypher_keywords(cleaned_query)
230
- if not is_valid:
231
- logger.warning(f"Generated query has invalid syntax: {validation_error}")
232
- logger.warning(f"Problematic query: {cleaned_query}")
233
- raise ValueError(f"Generated query validation failed: {validation_error}")
173
+ cypher_query = await llm_cypher_prompt(enhanced_system_prompt, enhanced_query)
174
+ cleaned_query = clean_cypher_response(cypher_query)
234
175
 
235
176
  # Validate UNION ALL queries
236
177
  is_valid, validation_error = validate_union_query(cleaned_query)
@@ -241,8 +182,6 @@ MATCH (f:Function) RETURN f.name, f.qualified_name // WRONG: missing third colu
241
182
 
242
183
  return cleaned_query
243
184
 
244
- except CypherGenerationNotPossibleError:
245
- raise # Re-raise as-is
246
185
  except Exception as e:
247
186
  raise RuntimeError(
248
187
  f"Failed to generate Cypher query with error context: {e}"
@@ -263,10 +202,6 @@ async def generate_cypher_openai_async(
263
202
 
264
203
  Returns:
265
204
  Generated Cypher query
266
-
267
- Raises:
268
- CypherGenerationNotPossibleError: If the query cannot be converted to Cypher
269
- RuntimeError: If there's an error during generation
270
205
  """
271
206
  # Get current time for context
272
207
  current_timestamp = int(time.time())
@@ -284,26 +219,9 @@ async def generate_cypher_openai_async(
284
219
  )
285
220
 
286
221
  try:
287
- response = await llm_cypher_prompt(system_prompt, enhanced_query)
288
-
289
- # Check if the LLM could generate a valid Cypher query
290
- if not response.can_generate_valid_cypher:
291
- logger.info(
292
- "Cannot generate Cypher for query '%s': %s",
293
- natural_language_query,
294
- response.reason_cannot_generate,
295
- )
296
- raise CypherGenerationNotPossibleError(
297
- response.reason_cannot_generate or "Query cannot be converted to Cypher"
298
- )
222
+ cypher_query = await llm_cypher_prompt(system_prompt, enhanced_query)
223
+ return clean_cypher_response(cypher_query)
299
224
 
300
- if not response.cypher_query:
301
- raise ValueError("LLM indicated success but provided no query")
302
-
303
- return clean_cypher_response(response.cypher_query)
304
-
305
- except CypherGenerationNotPossibleError:
306
- raise # Re-raise as-is
307
225
  except Exception as e:
308
226
  logger.error(f"OpenAI API error: {e}")
309
227
  raise RuntimeError(f"Failed to generate Cypher query: {e}") from e
@@ -370,65 +288,6 @@ def validate_union_query(cypher_query: str) -> tuple[bool, str]:
370
288
  return True, ""
371
289
 
372
290
 
373
- def validate_cypher_keywords(query: str) -> tuple[bool, str]:
374
- """Validate that a query starts with valid Kuzu Cypher keywords.
375
-
376
- Args:
377
- query: The Cypher query to validate
378
-
379
- Returns:
380
- Tuple of (is_valid, error_message)
381
- """
382
- # Valid Kuzu Cypher starting keywords based on parser expectations
383
- valid_cypher_keywords = {
384
- "ALTER",
385
- "ATTACH",
386
- "BEGIN",
387
- "CALL",
388
- "CHECKPOINT",
389
- "COMMENT",
390
- "COMMIT",
391
- "COPY",
392
- "CREATE",
393
- "DELETE",
394
- "DETACH",
395
- "DROP",
396
- "EXPLAIN",
397
- "EXPORT",
398
- "FORCE",
399
- "IMPORT",
400
- "INSTALL",
401
- "LOAD",
402
- "MATCH",
403
- "MERGE",
404
- "OPTIONAL",
405
- "PROFILE",
406
- "RETURN",
407
- "ROLLBACK",
408
- "SET",
409
- "UNWIND",
410
- "UNINSTALL",
411
- "UPDATE",
412
- "USE",
413
- "WITH",
414
- }
415
-
416
- query = query.strip()
417
- if not query:
418
- return False, "Empty query"
419
-
420
- # Get the first word
421
- first_word = query.upper().split()[0] if query else ""
422
-
423
- if first_word not in valid_cypher_keywords:
424
- return (
425
- False,
426
- f"Query doesn't start with valid Cypher keyword. Found: '{first_word}'",
427
- )
428
-
429
- return True, ""
430
-
431
-
432
291
  def clean_cypher_response(response_text: str) -> str:
433
292
  """Clean up common LLM formatting artifacts from a Cypher query.
434
293
 
@@ -4,7 +4,6 @@ import time
4
4
  from pathlib import Path
5
5
  from typing import Any
6
6
 
7
- from shotgun.codebase.core.cypher_models import CypherGenerationNotPossibleError
8
7
  from shotgun.codebase.core.manager import CodebaseGraphManager
9
8
  from shotgun.codebase.core.nl_query import generate_cypher
10
9
  from shotgun.codebase.models import CodebaseGraph, QueryResult, QueryType
@@ -191,22 +190,6 @@ class CodebaseService:
191
190
  error=None,
192
191
  )
193
192
 
194
- except CypherGenerationNotPossibleError as e:
195
- # Handle queries that cannot be converted to Cypher
196
- execution_time = (time.time() - start_time) * 1000
197
- logger.info(f"Query cannot be converted to Cypher: {e.reason}")
198
-
199
- return QueryResult(
200
- query=query,
201
- cypher_query=None,
202
- results=[],
203
- column_names=[],
204
- row_count=0,
205
- execution_time_ms=execution_time,
206
- success=False,
207
- error=f"This query cannot be converted to Cypher: {e.reason}",
208
- )
209
-
210
193
  except Exception as e:
211
194
  execution_time = (time.time() - start_time) * 1000
212
195
  logger.error(f"Query execution failed: {e}")
shotgun/main.py CHANGED
@@ -22,7 +22,7 @@ from shotgun.posthog_telemetry import setup_posthog_observability
22
22
  from shotgun.sentry_telemetry import setup_sentry_observability
23
23
  from shotgun.telemetry import setup_logfire_observability
24
24
  from shotgun.tui import app as tui_app
25
- from shotgun.utils.update_checker import check_for_updates_async
25
+ from shotgun.utils.update_checker import check_and_install_updates_async
26
26
 
27
27
  # Load environment variables from .env file
28
28
  load_dotenv()
@@ -52,6 +52,7 @@ logger.debug("PostHog analytics enabled: %s", _posthog_enabled)
52
52
 
53
53
  # Global variable to store update notification
54
54
  _update_notification: str | None = None
55
+ _update_progress: str | None = None
55
56
 
56
57
 
57
58
  def _update_callback(notification: str) -> None:
@@ -60,6 +61,13 @@ def _update_callback(notification: str) -> None:
60
61
  _update_notification = notification
61
62
 
62
63
 
64
+ def _update_progress_callback(progress: str) -> None:
65
+ """Callback to store update progress."""
66
+ global _update_progress
67
+ _update_progress = progress
68
+ logger.debug(f"Update progress: {progress}")
69
+
70
+
63
71
  app = typer.Typer(
64
72
  name="shotgun",
65
73
  help="Shotgun - AI-powered CLI tool for research, planning, and task management",
@@ -121,10 +129,12 @@ def main(
121
129
  """Shotgun - AI-powered CLI tool."""
122
130
  logger.debug("Starting shotgun CLI application")
123
131
 
124
- # Start async update check (non-blocking)
132
+ # Start async update check and install (non-blocking)
125
133
  if not ctx.resilient_parsing:
126
- check_for_updates_async(
127
- callback=_update_callback, no_update_check=no_update_check
134
+ check_and_install_updates_async(
135
+ callback=_update_callback,
136
+ no_update_check=no_update_check,
137
+ progress_callback=_update_progress_callback,
128
138
  )
129
139
 
130
140
  if ctx.invoked_subcommand is None and not ctx.resilient_parsing:
@@ -25,18 +25,4 @@ Your goal is to return appropriate properties for each node type. Common propert
25
25
  {% include 'codebase/partials/temporal_context.j2' %}
26
26
 
27
27
  **6. Output Format**
28
- You must return a structured JSON response with the following fields:
29
- - `cypher_query`: The generated Cypher query string (or null if not possible)
30
- - `can_generate_valid_cypher`: Boolean indicating if a valid Cypher query can be generated
31
- - `reason_cannot_generate`: String explaining why generation isn't possible (or null if successful)
32
-
33
- **IMPORTANT:** Some queries cannot be expressed in Cypher:
34
- - Conceptual questions requiring interpretation (e.g., "What is the main purpose of this codebase?")
35
- - Questions about code quality or best practices
36
- - Questions requiring semantic understanding beyond structure
37
-
38
- For these, set `can_generate_valid_cypher` to false and provide a clear explanation in `reason_cannot_generate`.
39
-
40
- Examples:
41
- - Query: "Show all classes" → can_generate_valid_cypher: true, cypher_query: "MATCH (c:Class) RETURN c.name, c.qualified_name;"
42
- - Query: "What is the main purpose of this codebase?" → can_generate_valid_cypher: false, reason_cannot_generate: "This is a conceptual question requiring interpretation and analysis of the code's overall design and intent, rather than a structural query about specific code elements."
28
+ Provide only the Cypher query.
shotgun/tui/app.py CHANGED
@@ -9,7 +9,7 @@ from shotgun.agents.config import ConfigManager, get_config_manager
9
9
  from shotgun.logging_config import get_logger
10
10
  from shotgun.tui.screens.splash import SplashScreen
11
11
  from shotgun.utils.file_system_utils import get_shotgun_base_path
12
- from shotgun.utils.update_checker import check_for_updates_async
12
+ from shotgun.utils.update_checker import check_and_install_updates_async
13
13
 
14
14
  from .screens.chat import ChatScreen
15
15
  from .screens.directory_setup import DirectorySetupScreen
@@ -37,16 +37,26 @@ class ShotgunApp(App[None]):
37
37
  self.no_update_check = no_update_check
38
38
  self.continue_session = continue_session
39
39
  self.update_notification: str | None = None
40
+ self.update_progress: str | None = None
40
41
 
41
- # Start async update check
42
+ # Start async update check and install
42
43
  if not no_update_check:
43
- check_for_updates_async(callback=self._update_callback)
44
+ check_and_install_updates_async(
45
+ callback=self._update_callback,
46
+ no_update_check=no_update_check,
47
+ progress_callback=self._update_progress_callback,
48
+ )
44
49
 
45
50
  def _update_callback(self, notification: str) -> None:
46
51
  """Store update notification to show later."""
47
52
  self.update_notification = notification
48
53
  logger.debug(f"Update notification received: {notification}")
49
54
 
55
+ def _update_progress_callback(self, progress: str) -> None:
56
+ """Store update progress."""
57
+ self.update_progress = progress
58
+ logger.debug(f"Update progress: {progress}")
59
+
50
60
  def on_mount(self) -> None:
51
61
  self.theme = "gruvbox"
52
62
  # Track TUI startup
@@ -411,7 +411,7 @@ class ChatScreen(Screen[None]):
411
411
  await self.codebase_sdk.list_codebases_for_directory()
412
412
  ).graphs
413
413
  if accessible_graphs:
414
- self.mount_hint(help_text_with_codebase(already_indexed=True))
414
+ self.mount_hint(help_text_with_codebase())
415
415
  return
416
416
 
417
417
  should_index = await self.app.push_screen_wait(CodebaseIndexPromptScreen())
@@ -419,8 +419,6 @@ class ChatScreen(Screen[None]):
419
419
  self.mount_hint(help_text_empty_dir())
420
420
  return
421
421
 
422
- self.mount_hint(help_text_with_codebase(already_indexed=False))
423
-
424
422
  self.index_codebase_command()
425
423
 
426
424
  def watch_mode(self, new_mode: AgentType) -> None:
@@ -694,6 +692,7 @@ class ChatScreen(Screen[None]):
694
692
  timeout=8,
695
693
  )
696
694
 
695
+ self.mount_hint(codebase_indexed_hint(selection.name))
697
696
  except CodebaseAlreadyIndexedError as exc:
698
697
  logger.warning(f"Codebase already indexed: {exc}")
699
698
  self.notify(str(exc), severity="warning")
@@ -797,10 +796,17 @@ class ChatScreen(Screen[None]):
797
796
  self.mode = AgentType(conversation.last_agent_model)
798
797
 
799
798
 
800
- def help_text_with_codebase(already_indexed: bool = False) -> str:
799
+ def codebase_indexed_hint(codebase_name: str) -> str:
800
+ return (
801
+ f"Codebase **{codebase_name}** indexed successfully. You can now use it in your chat.\n\n"
802
+ + help_text_with_codebase()
803
+ )
804
+
805
+
806
+ def help_text_with_codebase() -> str:
801
807
  return (
802
808
  "Howdy! Welcome to Shotgun - the context tool for software engineering. \n\nYou can research, build specs, plan, create tasks, and export context to your favorite code-gen agents.\n\n"
803
- f"{'' if already_indexed else 'Once your codebase is indexed, '}I can help with:\n\n"
809
+ "I can help with:\n\n"
804
810
  "- Speccing out a new feature\n"
805
811
  "- Onboarding you onto this project\n"
806
812
  "- Helping with a refactor spec\n"
@@ -136,8 +136,7 @@ class UserQuestionWidget(Widget):
136
136
  if part.tool_name == "ask_user" and isinstance(part.content, dict):
137
137
  acc += f"**>** {part.content['answer']}\n\n"
138
138
  else:
139
- # acc += " ∟ finished\n\n" # let's not show anything yet
140
- pass
139
+ acc += " ∟ finished\n\n" # let's not show anything yet
141
140
  elif isinstance(part, UserPromptPart):
142
141
  acc += f"**>** {part.content}\n\n"
143
142
  return acc
@@ -153,7 +152,7 @@ class AgentResponseWidget(Widget):
153
152
  if self.item is None:
154
153
  yield Markdown(markdown="")
155
154
  else:
156
- yield Markdown(markdown=self.compute_output())
155
+ yield Markdown(markdown=f"**⏺** {self.compute_output()}")
157
156
 
158
157
  def compute_output(self) -> str:
159
158
  acc = ""
@@ -161,10 +160,10 @@ class AgentResponseWidget(Widget):
161
160
  return ""
162
161
  for idx, part in enumerate(self.item.parts):
163
162
  if isinstance(part, TextPart):
164
- acc += f"**⏺** {part.content}\n\n"
163
+ acc += f"{part.content}\n\n"
165
164
  elif isinstance(part, ToolCallPart):
166
165
  parts_str = self._format_tool_call_part(part)
167
- acc += parts_str + "\n\n"
166
+ acc += f"{part.tool_name}: " + parts_str + "\n\n"
168
167
  elif isinstance(part, BuiltinToolCallPart):
169
168
  acc += f"{part.tool_name}({part.args})\n\n"
170
169
  elif isinstance(part, BuiltinToolReturnPart):
@@ -294,6 +294,31 @@ def format_update_notification(current: str, latest: str) -> str:
294
294
  return f"Update available: {current} → {latest}. Run 'shotgun update' to upgrade."
295
295
 
296
296
 
297
+ def format_update_status(
298
+ status: str, current: str | None = None, latest: str | None = None
299
+ ) -> str:
300
+ """Format update status messages.
301
+
302
+ Args:
303
+ status: Status type ('installing', 'success', 'failed', 'checking').
304
+ current: Current version (optional).
305
+ latest: Latest version (optional).
306
+
307
+ Returns:
308
+ Formatted status message.
309
+ """
310
+ if status == "checking":
311
+ return "Checking for updates..."
312
+ elif status == "installing" and current and latest:
313
+ return f"Installing update: {current} → {latest}..."
314
+ elif status == "success" and latest:
315
+ return f"✓ Successfully updated to version {latest}. Restart your terminal to use the new version."
316
+ elif status == "failed":
317
+ return "Update failed. Run 'shotgun update' to try manually."
318
+ else:
319
+ return ""
320
+
321
+
297
322
  def check_for_updates_sync(no_update_check: bool = False) -> str | None:
298
323
  """Synchronously check for updates and return notification if available.
299
324
 
@@ -336,6 +361,69 @@ def check_for_updates_sync(no_update_check: bool = False) -> str | None:
336
361
  return None
337
362
 
338
363
 
364
+ def check_and_install_updates_sync(no_update_check: bool = False) -> tuple[str, bool]:
365
+ """Synchronously check for updates and install if available.
366
+
367
+ Args:
368
+ no_update_check: If True, skip update checks and installation.
369
+
370
+ Returns:
371
+ Tuple of (status message, success boolean).
372
+ """
373
+ if no_update_check:
374
+ return "", False
375
+
376
+ if not should_check_for_updates(no_update_check):
377
+ return "", False
378
+
379
+ # Skip auto-install for development versions
380
+ if is_dev_version():
381
+ logger.debug("Skipping auto-install for development version")
382
+ return "", False
383
+
384
+ latest_version = get_latest_version()
385
+ if not latest_version:
386
+ return "", False
387
+ latest = latest_version # Type narrowing
388
+
389
+ # Check if update is needed
390
+ if not compare_versions(__version__, latest):
391
+ # Already up to date, update cache
392
+ now = datetime.now(timezone.utc)
393
+ cache_data = UpdateCache(
394
+ last_check=now,
395
+ latest_version=latest,
396
+ current_version=__version__,
397
+ update_available=False,
398
+ )
399
+ save_cache(cache_data)
400
+ return "", False
401
+
402
+ # Perform the update
403
+ logger.info(f"Auto-installing update: {__version__} → {latest}")
404
+ success, message = perform_update(force=False)
405
+
406
+ if success:
407
+ # Clear cache on successful update
408
+ cache_file = get_cache_file()
409
+ if cache_file.exists():
410
+ cache_file.unlink()
411
+ return format_update_status("success", latest=latest), True
412
+ else:
413
+ # Update cache to mark that we tried and failed
414
+ # This prevents repeated attempts within the check interval
415
+ now = datetime.now(timezone.utc)
416
+ cache_data = UpdateCache(
417
+ last_check=now,
418
+ latest_version=latest,
419
+ current_version=__version__,
420
+ update_available=True, # Still available, but we failed to install
421
+ )
422
+ save_cache(cache_data)
423
+ logger.warning(f"Auto-update failed: {message}")
424
+ return format_update_status("failed"), False
425
+
426
+
339
427
  def check_for_updates_async(
340
428
  callback: Callable[[str], None] | None = None, no_update_check: bool = False
341
429
  ) -> threading.Thread:
@@ -362,6 +450,117 @@ def check_for_updates_async(
362
450
  return thread
363
451
 
364
452
 
453
+ def check_and_install_updates_async(
454
+ callback: Callable[[str], None] | None = None,
455
+ no_update_check: bool = False,
456
+ progress_callback: Callable[[str], None] | None = None,
457
+ ) -> threading.Thread:
458
+ """Asynchronously check for updates and install in a background thread.
459
+
460
+ Args:
461
+ callback: Optional callback function to call with final status message.
462
+ no_update_check: If True, skip update checks and installation.
463
+ progress_callback: Optional callback for progress updates.
464
+
465
+ Returns:
466
+ The thread object that was started.
467
+ """
468
+
469
+ def _check_and_install() -> None:
470
+ try:
471
+ # Send checking status if progress callback provided
472
+ if progress_callback:
473
+ progress_callback(format_update_status("checking"))
474
+
475
+ # Skip if disabled
476
+ if no_update_check:
477
+ return
478
+
479
+ # Skip for dev versions
480
+ if is_dev_version():
481
+ logger.debug("Skipping auto-install for development version")
482
+ return
483
+
484
+ # Check if we should check for updates
485
+ if not should_check_for_updates(no_update_check):
486
+ # Check cache to see if update is still pending
487
+ cache = load_cache()
488
+ if cache and cache.update_available:
489
+ # We have a pending update from a previous check
490
+ # Don't retry installation automatically to avoid repeated failures
491
+ if callback:
492
+ callback(
493
+ format_update_notification(
494
+ cache.current_version, cache.latest_version
495
+ )
496
+ )
497
+ return
498
+
499
+ # Get latest version
500
+ latest_version = get_latest_version()
501
+ if not latest_version:
502
+ return
503
+ latest = latest_version # Type narrowing
504
+
505
+ # Check if update is needed
506
+ if not compare_versions(__version__, latest):
507
+ # Already up to date, update cache
508
+ now = datetime.now(timezone.utc)
509
+ cache_data = UpdateCache(
510
+ last_check=now,
511
+ latest_version=latest,
512
+ current_version=__version__,
513
+ update_available=False,
514
+ )
515
+ save_cache(cache_data)
516
+ logger.debug(f"Already at latest version ({__version__})")
517
+ return
518
+
519
+ # Send installing status
520
+ if progress_callback:
521
+ progress_callback(
522
+ format_update_status(
523
+ "installing", current=__version__, latest=latest
524
+ )
525
+ )
526
+
527
+ # Perform the update
528
+ logger.info(f"Auto-installing update: {__version__} → {latest}")
529
+ success, message = perform_update(force=False)
530
+
531
+ if success:
532
+ # Clear cache on successful update
533
+ cache_file = get_cache_file()
534
+ if cache_file.exists():
535
+ cache_file.unlink()
536
+
537
+ if callback:
538
+ callback(format_update_status("success", latest=latest))
539
+ else:
540
+ # Update cache to mark that we tried and failed
541
+ now = datetime.now(timezone.utc)
542
+ cache_data = UpdateCache(
543
+ last_check=now,
544
+ latest_version=latest,
545
+ current_version=__version__,
546
+ update_available=True,
547
+ )
548
+ save_cache(cache_data)
549
+ logger.warning(f"Auto-update failed: {message}")
550
+
551
+ if callback:
552
+ callback(format_update_status("failed"))
553
+
554
+ except Exception as e:
555
+ logger.debug(f"Error in async update check and install: {e}")
556
+ if callback:
557
+ callback(format_update_status("failed"))
558
+
559
+ thread = threading.Thread(target=_check_and_install, daemon=True)
560
+ thread.start()
561
+ return thread
562
+
563
+
365
564
  __all__ = [
366
565
  "UpdateCache",
367
566
  "is_dev_version",
@@ -371,5 +570,8 @@ __all__ = [
371
570
  "perform_update",
372
571
  "check_for_updates_async",
373
572
  "check_for_updates_sync",
573
+ "check_and_install_updates_async",
574
+ "check_and_install_updates_sync",
374
575
  "format_update_notification",
576
+ "format_update_status",
375
577
  ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shotgun-sh
3
- Version: 0.1.1
3
+ Version: 0.1.2
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
@@ -1,13 +1,13 @@
1
1
  shotgun/__init__.py,sha256=P40K0fnIsb7SKcQrFnXZ4aREjpWchVDhvM1HxI4cyIQ,104
2
2
  shotgun/build_constants.py,sha256=hDFr6eO0lwN0iCqHQ1A5s0D68txR8sYrTJLGa7tSi0o,654
3
3
  shotgun/logging_config.py,sha256=UKenihvgH8OA3W0b8ZFcItYaFJVe9MlsMYlcevyW1HY,7440
4
- shotgun/main.py,sha256=5WEtPs5kwD1tdeWCnM-jIAwarcwQNc4dhaqdPKCyxug,5510
4
+ shotgun/main.py,sha256=lFx8IsLIfMvOw6lMJGnzg8o9HKcuj59VxhjNgetMZP0,5854
5
5
  shotgun/posthog_telemetry.py,sha256=usfaJ8VyqckLIbLgoj2yhuNyDh0VWA5EJPRr7a0dyVs,5054
6
6
  shotgun/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
7
  shotgun/sentry_telemetry.py,sha256=0W0o810ewFpIcdPsi_q4uKLiaP6zDYRRE5MHpIbQIPo,2954
8
8
  shotgun/telemetry.py,sha256=Ves6Ih3hshpKVNVAUUmwRdtW8NkTjFPg8hEqvFKZ0t0,3208
9
9
  shotgun/agents/__init__.py,sha256=8Jzv1YsDuLyNPFJyckSr_qI4ehTVeDyIMDW4omsfPGc,25
10
- shotgun/agents/agent_manager.py,sha256=xw9xNEwVU-P4NGqF8W6mzVw4HNqtSfegAN9atog7aEo,23813
10
+ shotgun/agents/agent_manager.py,sha256=7BjTRQ7s77OqxYHBdbM1IECOERTfv5GFAETDGz9YLy4,24444
11
11
  shotgun/agents/common.py,sha256=vt7ECq1rT6GR5Rt63t0whH0R0cydrk7Mty2KyPL8mEg,19045
12
12
  shotgun/agents/conversation_history.py,sha256=5J8_1yxdZiiWTq22aDio88DkBDZ4_Lh_p5Iy5_ENszc,3898
13
13
  shotgun/agents/conversation_manager.py,sha256=fxAvXbEl3Cl2ugJ4N9aWXaqZtkrnfj3QzwjWC4LFXwI,3514
@@ -62,15 +62,14 @@ shotgun/cli/codebase/commands.py,sha256=zvcM9gjHHO6styhXojb_1bnpq-Cozh2c77ZOIjw4
62
62
  shotgun/cli/codebase/models.py,sha256=B9vs-d-Bq0aS6FZKebhHT-9tw90Y5f6k_t71VlZpL8k,374
63
63
  shotgun/codebase/__init__.py,sha256=QBgFE2Abd5Vl7_NdYOglF9S6d-vIjkb3C0cpIYoHZEU,309
64
64
  shotgun/codebase/models.py,sha256=hxjbfDUka8loTApXq9KTvkXKt272fzdjr5u2ImYrNtk,4367
65
- shotgun/codebase/service.py,sha256=CZR5f1vZyUS3gVXuDiZj0cIuuxiR7fbkHK65PTPAovI,7504
65
+ shotgun/codebase/service.py,sha256=IK7h6IW84cblpHZVx5z9ulLDqJImGg6L9aZJimijBu8,6804
66
66
  shotgun/codebase/core/__init__.py,sha256=GWWhJEqChiDXAF4omYCgzgoZmJjwsAf6P1aZ5Bl8OE0,1170
67
67
  shotgun/codebase/core/change_detector.py,sha256=kWCYLWzRzb3IGGOj71KBn7UOCOKMpINJbOBDf98aMxE,12409
68
68
  shotgun/codebase/core/code_retrieval.py,sha256=_JVyyQKHDFm3dxOOua1mw9eIIOHIVz3-I8aZtEsEj1E,7927
69
- shotgun/codebase/core/cypher_models.py,sha256=Yfysfa9lLguILftkmtuJCN3kLBFIo7WW7NigM-Zr-W4,1735
70
69
  shotgun/codebase/core/ingestor.py,sha256=H_kVCqdOKmnQpjcXvUdPFpep8OC2AbOhhE-9HKr_XZM,59836
71
70
  shotgun/codebase/core/language_config.py,sha256=vsqHyuFnumRPRBV1lMOxWKNOIiClO6FyfKQR0fGrtl4,8934
72
71
  shotgun/codebase/core/manager.py,sha256=6gyjfACbC5n1Hdy-JQIEDH2aNAlesUS9plQP_FHoJ94,59277
73
- shotgun/codebase/core/nl_query.py,sha256=kPoSJXBlm5rLhzOofZhqPVMJ_Lj3rV2H6sld6BwtMdg,16115
72
+ shotgun/codebase/core/nl_query.py,sha256=vOc8S_5U5wJEzIznFPhV5KDB438M4Rr0dnpoGraEthM,11653
74
73
  shotgun/codebase/core/parser_loader.py,sha256=LZRrDS8Sp518jIu3tQW-BxdwJ86lnsTteI478ER9Td8,4278
75
74
  shotgun/prompts/__init__.py,sha256=RswUm0HMdfm2m2YKUwUsEdRIwoczdbI7zlucoEvHYRo,132
76
75
  shotgun/prompts/loader.py,sha256=jy24-E02pCSmz2651aCT2NgHfRrHAGMYvKrD6gs0Er8,4424
@@ -88,7 +87,7 @@ shotgun/prompts/agents/state/system_state.j2,sha256=TQPnCLtmiNwQCbMxnCE7nLhXMJpK
88
87
  shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2,sha256=U-hy-H9bPwV0sYIHTZ5TESxc5EOCtntI8GUZOmJipJw,601
89
88
  shotgun/prompts/codebase/__init__.py,sha256=NYuPMtmYM2ptuwf3YxVuotNlJOUq0hnjmwlzKcJkGK4,42
90
89
  shotgun/prompts/codebase/cypher_query_patterns.j2,sha256=ufTx_xT3VoS76KcVUbIgGQx-bJoJHx3bBE3dagAXv18,8913
91
- shotgun/prompts/codebase/cypher_system.j2,sha256=jo8d_AIoyAd0zKCvPXSmYGBxvtulMsCfeaOTdOfeC5g,2620
90
+ shotgun/prompts/codebase/cypher_system.j2,sha256=kV-OJ8gM3vsBo8hW4mLSEHpJW-wV_2tNaPck3HUM52c,1497
92
91
  shotgun/prompts/codebase/enhanced_query_context.j2,sha256=WzGnFaBLZO-mOdkZ_u_PewSu9niKy87DKNL4uzQq1Jg,724
93
92
  shotgun/prompts/codebase/partials/cypher_rules.j2,sha256=vtc5OqTp-z5Rq_ti-_RG31bVOIA_iNe80_x3CdxO6bs,2397
94
93
  shotgun/prompts/codebase/partials/graph_schema.j2,sha256=fUsD1ZgU1pIWUzrs97jHq3TatKeGSvZgG8XP5gCQUJc,1939
@@ -102,14 +101,14 @@ shotgun/sdk/exceptions.py,sha256=qBcQv0v7ZTwP7CMcxZST4GqCsfOWtOUjSzGBo0-heqo,412
102
101
  shotgun/sdk/models.py,sha256=X9nOTUHH0cdkQW1NfnMEDu-QgK9oUsEISh1Jtwr5Am4,5496
103
102
  shotgun/sdk/services.py,sha256=J4PJFSxCQ6--u7rb3Ta-9eYtlYcxcbnzrMP6ThyCnw4,705
104
103
  shotgun/tui/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
105
- shotgun/tui/app.py,sha256=t0IAQbGr0lKKEoBVnp85DcmZ-V92bi79SjyEE2uKpuw,3990
104
+ shotgun/tui/app.py,sha256=AAoFQYw6A9_kVxB0c0p7g-BNdplexfZIQbrGDQ1m4KI,4407
106
105
  shotgun/tui/styles.tcss,sha256=ETyyw1bpMBOqTi5RLcAJUScdPWTvAWEqE9YcT0kVs_E,121
107
106
  shotgun/tui/commands/__init__.py,sha256=8D5lvtpqMW5-fF7Bg3oJtUzU75cKOv6aUaHYYszydU8,2518
108
107
  shotgun/tui/components/prompt_input.py,sha256=Ss-htqraHZAPaehGE4x86ij0veMjc4UgadMXpbdXr40,2229
109
108
  shotgun/tui/components/spinner.py,sha256=ovTDeaJ6FD6chZx_Aepia6R3UkPOVJ77EKHfRmn39MY,2427
110
109
  shotgun/tui/components/splash.py,sha256=vppy9vEIEvywuUKRXn2y11HwXSRkQZHLYoVjhDVdJeU,1267
111
110
  shotgun/tui/components/vertical_tail.py,sha256=GavHXNMq1X8hc0juDLKDWTW9seRLk3VlhBBMl60uPG0,439
112
- shotgun/tui/screens/chat.py,sha256=U3FfJj0c3HIXxPW8W7wRRWP5WruxUBFcHdsShQ_sMoQ,29820
111
+ shotgun/tui/screens/chat.py,sha256=Sa9MGwdcHcLxzy0c6MC--fePEA5y3-e13lZmRtioHc8,29912
113
112
  shotgun/tui/screens/chat.tcss,sha256=2Yq3E23jxsySYsgZf4G1AYrYVcpX0UDW6kNNI0tDmtM,437
114
113
  shotgun/tui/screens/directory_setup.py,sha256=lIZ1J4A6g5Q2ZBX8epW7BhR96Dmdcg22CyiM5S-I5WU,3237
115
114
  shotgun/tui/screens/provider_config.py,sha256=A_tvDHF5KLP5PV60LjMJ_aoOdT3TjI6_g04UIUqGPqM,7126
@@ -117,15 +116,15 @@ shotgun/tui/screens/splash.py,sha256=E2MsJihi3c9NY1L28o_MstDxGwrCnnV7zdq00MrGAsw
117
116
  shotgun/tui/screens/chat_screen/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
118
117
  shotgun/tui/screens/chat_screen/command_providers.py,sha256=55JIH9T8QnyHRsMoXhOi87FiVM-d6o7OKpCe82uDP9I,7840
119
118
  shotgun/tui/screens/chat_screen/hint_message.py,sha256=WOpbk8q7qt7eOHTyyHvh_IQIaublVDeJGaLpsxEk9FA,933
120
- shotgun/tui/screens/chat_screen/history.py,sha256=JjQOKjCZpLBcw9CMorkBjOt2U5Ikr81hEQRtQmhw_KM,7459
119
+ shotgun/tui/screens/chat_screen/history.py,sha256=ZozEVG1ffdmruQ-XCr25qePWReFAJZFJACdwq9g8wIk,7461
121
120
  shotgun/tui/utils/__init__.py,sha256=cFjDfoXTRBq29wgP7TGRWUu1eFfiIG-LLOzjIGfadgI,150
122
121
  shotgun/tui/utils/mode_progress.py,sha256=lseRRo7kMWLkBzI3cU5vqJmS2ZcCjyRYf9Zwtvc-v58,10931
123
122
  shotgun/utils/__init__.py,sha256=WinIEp9oL2iMrWaDkXz2QX4nYVPAm8C9aBSKTeEwLtE,198
124
123
  shotgun/utils/env_utils.py,sha256=8QK5aw_f_V2AVTleQQlcL0RnD4sPJWXlDG46fsHu0d8,1057
125
124
  shotgun/utils/file_system_utils.py,sha256=l-0p1bEHF34OU19MahnRFdClHufThfGAjQ431teAIp0,1004
126
- shotgun/utils/update_checker.py,sha256=Xf-7w3Pos3etzCoT771gJe2HLkA8_V2GrqWy7ni9UqA,11373
127
- shotgun_sh-0.1.1.dist-info/METADATA,sha256=D6rAxtG6uoiaP8xQqDm6mhO-MAqPTE595suqA7GkWNY,11191
128
- shotgun_sh-0.1.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
129
- shotgun_sh-0.1.1.dist-info/entry_points.txt,sha256=asZxLU4QILneq0MWW10saVCZc4VWhZfb0wFZvERnzfA,45
130
- shotgun_sh-0.1.1.dist-info/licenses/LICENSE,sha256=YebsZl590zCHrF_acCU5pmNt0pnAfD2DmAnevJPB1tY,1065
131
- shotgun_sh-0.1.1.dist-info/RECORD,,
125
+ shotgun/utils/update_checker.py,sha256=Q3va_lsDTWtIS7B_8RQh7bjrWlLt7pzDR_AR-4aAKnc,18522
126
+ shotgun_sh-0.1.2.dist-info/METADATA,sha256=awGmV6yuJ20hZYwH36bnd58OUZC2-LXTkK4D3A6vjxE,11191
127
+ shotgun_sh-0.1.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
128
+ shotgun_sh-0.1.2.dist-info/entry_points.txt,sha256=asZxLU4QILneq0MWW10saVCZc4VWhZfb0wFZvERnzfA,45
129
+ shotgun_sh-0.1.2.dist-info/licenses/LICENSE,sha256=YebsZl590zCHrF_acCU5pmNt0pnAfD2DmAnevJPB1tY,1065
130
+ shotgun_sh-0.1.2.dist-info/RECORD,,
@@ -1,46 +0,0 @@
1
- """Pydantic models and exceptions for Cypher query generation."""
2
-
3
- from typing import Any
4
-
5
- from pydantic import BaseModel, Field
6
-
7
-
8
- class CypherGenerationResponse(BaseModel):
9
- """Structured response from LLM for Cypher query generation.
10
-
11
- This model ensures the LLM explicitly indicates whether it can generate
12
- a valid Cypher query and provides a reason if it cannot.
13
- """
14
-
15
- cypher_query: str | None = Field(
16
- default=None,
17
- description="The generated Cypher query, or None if generation not possible",
18
- )
19
- can_generate_valid_cypher: bool = Field(
20
- description="Whether a valid Cypher query can be generated for this request"
21
- )
22
- reason_cannot_generate: str | None = Field(
23
- default=None,
24
- description="Explanation why query cannot be generated (if applicable)",
25
- )
26
-
27
- def model_post_init(self, __context: Any) -> None:
28
- """Validate that reason is provided when query cannot be generated."""
29
- if not self.can_generate_valid_cypher and not self.reason_cannot_generate:
30
- self.reason_cannot_generate = "No reason provided"
31
- if self.can_generate_valid_cypher and not self.cypher_query:
32
- raise ValueError(
33
- "cypher_query must be provided when can_generate_valid_cypher is True"
34
- )
35
-
36
-
37
- class CypherGenerationNotPossibleError(Exception):
38
- """Raised when LLM cannot generate valid Cypher for the query.
39
-
40
- This typically happens when the query is conceptual rather than structural,
41
- or when it requires interpretation beyond what can be expressed in Cypher.
42
- """
43
-
44
- def __init__(self, reason: str):
45
- self.reason = reason
46
- super().__init__(f"Cannot generate Cypher query: {reason}")