shotgun-sh 0.2.11.dev7__py3-none-any.whl → 0.2.19__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 (33) hide show
  1. shotgun/agents/agent_manager.py +22 -11
  2. shotgun/agents/config/README.md +89 -0
  3. shotgun/agents/config/__init__.py +10 -1
  4. shotgun/agents/config/manager.py +287 -32
  5. shotgun/agents/config/models.py +17 -1
  6. shotgun/agents/config/provider.py +27 -0
  7. shotgun/agents/config/streaming_test.py +119 -0
  8. shotgun/build_constants.py +3 -3
  9. shotgun/logging_config.py +42 -0
  10. shotgun/main.py +2 -0
  11. shotgun/posthog_telemetry.py +18 -25
  12. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +3 -2
  13. shotgun/sentry_telemetry.py +140 -2
  14. shotgun/settings.py +5 -0
  15. shotgun/tui/app.py +7 -1
  16. shotgun/tui/screens/chat/chat_screen.py +66 -35
  17. shotgun/tui/screens/chat_screen/command_providers.py +3 -2
  18. shotgun/tui/screens/chat_screen/history/chat_history.py +1 -2
  19. shotgun/tui/screens/directory_setup.py +14 -5
  20. shotgun/tui/screens/feedback.py +10 -3
  21. shotgun/tui/screens/github_issue.py +11 -2
  22. shotgun/tui/screens/model_picker.py +8 -1
  23. shotgun/tui/screens/pipx_migration.py +12 -6
  24. shotgun/tui/screens/provider_config.py +25 -8
  25. shotgun/tui/screens/shotgun_auth.py +0 -10
  26. shotgun/tui/screens/welcome.py +32 -0
  27. shotgun/tui/widgets/widget_coordinator.py +3 -2
  28. shotgun_sh-0.2.19.dist-info/METADATA +465 -0
  29. {shotgun_sh-0.2.11.dev7.dist-info → shotgun_sh-0.2.19.dist-info}/RECORD +32 -30
  30. shotgun_sh-0.2.11.dev7.dist-info/METADATA +0 -130
  31. {shotgun_sh-0.2.11.dev7.dist-info → shotgun_sh-0.2.19.dist-info}/WHEEL +0 -0
  32. {shotgun_sh-0.2.11.dev7.dist-info → shotgun_sh-0.2.19.dist-info}/entry_points.txt +0 -0
  33. {shotgun_sh-0.2.11.dev7.dist-info → shotgun_sh-0.2.19.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,119 @@
1
+ """Utility for testing streaming capability of OpenAI models."""
2
+
3
+ import logging
4
+
5
+ import httpx
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ # Maximum number of attempts to test streaming capability
10
+ MAX_STREAMING_TEST_ATTEMPTS = 3
11
+
12
+ # Timeout for each streaming test attempt (in seconds)
13
+ STREAMING_TEST_TIMEOUT = 10.0
14
+
15
+
16
+ async def check_streaming_capability(
17
+ api_key: str, model_name: str, max_attempts: int = MAX_STREAMING_TEST_ATTEMPTS
18
+ ) -> bool:
19
+ """Check if the given OpenAI model supports streaming with this API key.
20
+
21
+ Retries multiple times to handle transient network issues. Only returns False
22
+ if streaming definitively fails after all retry attempts.
23
+
24
+ Args:
25
+ api_key: The OpenAI API key to test
26
+ model_name: The model name (e.g., "gpt-5", "gpt-5-mini")
27
+ max_attempts: Maximum number of attempts (default: 3)
28
+
29
+ Returns:
30
+ True if streaming is supported, False if it definitively fails
31
+ """
32
+ url = "https://api.openai.com/v1/chat/completions"
33
+ headers = {
34
+ "Authorization": f"Bearer {api_key}",
35
+ "Content-Type": "application/json",
36
+ }
37
+ # GPT-5 family uses max_completion_tokens instead of max_tokens
38
+ payload = {
39
+ "model": model_name,
40
+ "messages": [{"role": "user", "content": "test"}],
41
+ "stream": True,
42
+ "max_completion_tokens": 10,
43
+ }
44
+
45
+ last_error = None
46
+
47
+ for attempt in range(1, max_attempts + 1):
48
+ logger.debug(
49
+ f"Streaming test attempt {attempt}/{max_attempts} for {model_name}"
50
+ )
51
+
52
+ try:
53
+ async with httpx.AsyncClient(timeout=STREAMING_TEST_TIMEOUT) as client:
54
+ async with client.stream(
55
+ "POST", url, json=payload, headers=headers
56
+ ) as response:
57
+ # Check if we get a successful response
58
+ if response.status_code != 200:
59
+ last_error = f"HTTP {response.status_code}"
60
+ logger.warning(
61
+ f"Streaming test attempt {attempt} failed for {model_name}: {last_error}"
62
+ )
63
+
64
+ # For definitive errors (403 Forbidden, 404 Not Found), don't retry
65
+ if response.status_code in (403, 404):
66
+ logger.info(
67
+ f"Streaming definitively unsupported for {model_name} (HTTP {response.status_code})"
68
+ )
69
+ return False
70
+
71
+ # For other errors, retry
72
+ continue
73
+
74
+ # Try to read at least one chunk from the stream
75
+ try:
76
+ async for _ in response.aiter_bytes():
77
+ # Successfully received streaming data
78
+ logger.info(
79
+ f"Streaming test passed for {model_name} (attempt {attempt})"
80
+ )
81
+ return True
82
+ except Exception as e:
83
+ last_error = str(e)
84
+ logger.warning(
85
+ f"Streaming test attempt {attempt} failed for {model_name} while reading stream: {e}"
86
+ )
87
+ continue
88
+
89
+ except httpx.TimeoutException:
90
+ last_error = "timeout"
91
+ logger.warning(
92
+ f"Streaming test attempt {attempt} timed out for {model_name}"
93
+ )
94
+ continue
95
+ except httpx.HTTPStatusError as e:
96
+ last_error = str(e)
97
+ logger.warning(
98
+ f"Streaming test attempt {attempt} failed for {model_name}: {e}"
99
+ )
100
+ continue
101
+ except Exception as e:
102
+ last_error = str(e)
103
+ logger.warning(
104
+ f"Streaming test attempt {attempt} failed for {model_name} with unexpected error: {e}"
105
+ )
106
+ continue
107
+
108
+ # If we got here without reading any chunks, streaming didn't work
109
+ last_error = "no data received"
110
+ logger.warning(
111
+ f"Streaming test attempt {attempt} failed for {model_name}: no data received"
112
+ )
113
+
114
+ # All attempts exhausted
115
+ logger.error(
116
+ f"Streaming test failed for {model_name} after {max_attempts} attempts. "
117
+ f"Last error: {last_error}. Assuming streaming is NOT supported."
118
+ )
119
+ return False
@@ -8,12 +8,12 @@ DO NOT EDIT MANUALLY.
8
8
  SENTRY_DSN = 'https://2818a6d165c64eccc94cfd51ce05d6aa@o4506813296738304.ingest.us.sentry.io/4510045952409600'
9
9
 
10
10
  # PostHog configuration embedded at build time (empty strings if not provided)
11
- POSTHOG_API_KEY = ''
11
+ POSTHOG_API_KEY = 'phc_KKnChzZUKeNqZDOTJ6soCBWNQSx3vjiULdwTR9H5Mcr'
12
12
  POSTHOG_PROJECT_ID = '191396'
13
13
 
14
14
  # Logfire configuration embedded at build time (only for dev builds)
15
- LOGFIRE_ENABLED = 'true'
16
- LOGFIRE_TOKEN = 'pylf_v1_us_RwZMlJm1tX6j0PL5RWWbmZpzK2hLBNtFWStNKlySfjh8'
15
+ LOGFIRE_ENABLED = ''
16
+ LOGFIRE_TOKEN = ''
17
17
 
18
18
  # Build metadata
19
19
  BUILD_TIME_ENV = "production" if SENTRY_DSN else "development"
shotgun/logging_config.py CHANGED
@@ -27,6 +27,44 @@ def get_log_directory() -> Path:
27
27
  return log_dir
28
28
 
29
29
 
30
+ def cleanup_old_log_files(log_dir: Path, max_files: int) -> None:
31
+ """Remove old log files, keeping only the most recent ones.
32
+
33
+ Also removes the legacy shotgun.log file if it exists.
34
+
35
+ Args:
36
+ log_dir: Directory containing log files
37
+ max_files: Maximum number of log files to keep
38
+ """
39
+ try:
40
+ # Remove legacy non-timestamped log file if it exists
41
+ legacy_log = log_dir / "shotgun.log"
42
+ if legacy_log.exists():
43
+ try:
44
+ legacy_log.unlink()
45
+ except OSError:
46
+ pass # noqa: S110
47
+
48
+ # Find all shotgun log files
49
+ log_files = sorted(
50
+ log_dir.glob("shotgun-*.log"),
51
+ key=lambda p: p.stat().st_mtime,
52
+ reverse=True, # Newest first
53
+ )
54
+
55
+ # Remove files beyond the limit
56
+ files_to_delete = log_files[max_files:]
57
+ for log_file in files_to_delete:
58
+ try:
59
+ log_file.unlink()
60
+ except OSError:
61
+ # Ignore errors when deleting individual files
62
+ pass # noqa: S110
63
+ except Exception: # noqa: S110
64
+ # Silently fail - log cleanup shouldn't break the application
65
+ pass
66
+
67
+
30
68
  class ColoredFormatter(logging.Formatter):
31
69
  """Custom formatter with colors for different log levels."""
32
70
 
@@ -123,6 +161,10 @@ def setup_logger(
123
161
  try:
124
162
  # Create file handler with ISO8601 timestamp for each run
125
163
  log_dir = get_log_directory()
164
+
165
+ # Clean up old log files before creating a new one
166
+ cleanup_old_log_files(log_dir, settings.logging.max_log_files)
167
+
126
168
  log_file = log_dir / f"shotgun-{_RUN_TIMESTAMP}.log"
127
169
 
128
170
  # Use regular FileHandler - each run gets its own isolated log file
shotgun/main.py CHANGED
@@ -55,6 +55,8 @@ logger = get_logger(__name__)
55
55
  logger.debug("Logfire observability enabled: %s", _logfire_enabled)
56
56
 
57
57
  # Initialize configuration
58
+ # Note: If config migration fails, ConfigManager will auto-create fresh config
59
+ # and set migration_failed flag for user notification
58
60
  try:
59
61
  import asyncio
60
62
 
@@ -18,6 +18,9 @@ logger = get_early_logger(__name__)
18
18
  # Global PostHog client instance
19
19
  _posthog_client = None
20
20
 
21
+ # Cache the shotgun instance ID to avoid async calls during event tracking
22
+ _shotgun_instance_id: str | None = None
23
+
21
24
 
22
25
  def setup_posthog_observability() -> bool:
23
26
  """Set up PostHog analytics for usage tracking.
@@ -25,7 +28,7 @@ def setup_posthog_observability() -> bool:
25
28
  Returns:
26
29
  True if PostHog was successfully set up, False otherwise
27
30
  """
28
- global _posthog_client
31
+ global _posthog_client, _shotgun_instance_id
29
32
 
30
33
  try:
31
34
  # Check if PostHog is already initialized
@@ -57,31 +60,20 @@ def setup_posthog_observability() -> bool:
57
60
  # Store the client for later use
58
61
  _posthog_client = posthog
59
62
 
60
- # Set user context with anonymous shotgun instance ID from config
63
+ # Cache the shotgun instance ID for later use (avoids async issues)
61
64
  try:
62
65
  import asyncio
63
66
 
64
67
  config_manager = get_config_manager()
65
- shotgun_instance_id = asyncio.run(config_manager.get_shotgun_instance_id())
66
-
67
- # Identify the user in PostHog
68
- posthog.identify( # type: ignore[attr-defined]
69
- distinct_id=shotgun_instance_id,
70
- properties={
71
- "version": __version__,
72
- "environment": environment,
73
- },
74
- )
75
-
76
- # Set default properties for all events
77
- posthog.disabled = False
78
- posthog.personal_api_key = None # Not needed for event tracking
68
+ _shotgun_instance_id = asyncio.run(config_manager.get_shotgun_instance_id())
79
69
 
80
70
  logger.debug(
81
- "PostHog user identified with anonymous ID: %s", shotgun_instance_id
71
+ "PostHog initialized with shotgun instance ID: %s",
72
+ _shotgun_instance_id,
82
73
  )
83
74
  except Exception as e:
84
- logger.warning("Failed to set user context: %s", e)
75
+ logger.warning("Failed to load shotgun instance ID: %s", e)
76
+ # Continue anyway - we'll try to get it during event tracking
85
77
 
86
78
  logger.debug(
87
79
  "PostHog analytics configured successfully (environment: %s, version: %s)",
@@ -102,18 +94,19 @@ def track_event(event_name: str, properties: dict[str, Any] | None = None) -> No
102
94
  event_name: Name of the event to track
103
95
  properties: Optional properties to include with the event
104
96
  """
105
- global _posthog_client
97
+ global _posthog_client, _shotgun_instance_id
106
98
 
107
99
  if _posthog_client is None:
108
100
  logger.debug("PostHog not initialized, skipping event: %s", event_name)
109
101
  return
110
102
 
111
103
  try:
112
- import asyncio
113
-
114
- # Get shotgun instance ID for tracking
115
- config_manager = get_config_manager()
116
- shotgun_instance_id = asyncio.run(config_manager.get_shotgun_instance_id())
104
+ # Use cached instance ID (loaded during setup)
105
+ if _shotgun_instance_id is None:
106
+ logger.warning(
107
+ "Shotgun instance ID not available, skipping event: %s", event_name
108
+ )
109
+ return
117
110
 
118
111
  # Add version and environment to properties
119
112
  if properties is None:
@@ -128,7 +121,7 @@ def track_event(event_name: str, properties: dict[str, Any] | None = None) -> No
128
121
 
129
122
  # Track the event using PostHog's capture method
130
123
  _posthog_client.capture(
131
- distinct_id=shotgun_instance_id, event=event_name, properties=properties
124
+ distinct_id=_shotgun_instance_id, event=event_name, properties=properties
132
125
  )
133
126
  logger.debug("Tracked PostHog event: %s", event_name)
134
127
  except Exception as e:
@@ -7,10 +7,11 @@ Your extensive expertise spans, among other things:
7
7
  ## KEY RULES
8
8
 
9
9
  {% if interactive_mode %}
10
- 0. Always ask CLARIFYING QUESTIONS using structured output if the user's request is ambiguous or lacks sufficient detail.
10
+ 0. Always ask CLARIFYING QUESTIONS using structured output before doing work.
11
11
  - Return your response with the clarifying_questions field populated
12
- - Do not make assumptions about what the user wants
12
+ - Do not make assumptions about what the user wants, get a clear understanding first.
13
13
  - Questions should be clear, specific, and answerable
14
+ - Do not ask too many questions that might overwhelm the user; prioritize the most important ones.
14
15
  {% endif %}
15
16
  1. Above all, prefer using tools to do the work and NEVER respond with text.
16
17
  2. IMPORTANT: Always ask for review and go ahead to move forward after using write_file().
@@ -1,5 +1,6 @@
1
1
  """Sentry observability setup for Shotgun."""
2
2
 
3
+ from pathlib import Path
3
4
  from typing import Any
4
5
 
5
6
  from shotgun import __version__
@@ -10,6 +11,122 @@ from shotgun.settings import settings
10
11
  logger = get_early_logger(__name__)
11
12
 
12
13
 
14
+ def _scrub_path(path: str) -> str:
15
+ """Scrub sensitive information from file paths.
16
+
17
+ Removes home directory and current working directory prefixes to prevent
18
+ leaking usernames that might be part of the path.
19
+
20
+ Args:
21
+ path: The file path to scrub
22
+
23
+ Returns:
24
+ The scrubbed path with sensitive prefixes removed
25
+ """
26
+ if not path:
27
+ return path
28
+
29
+ try:
30
+ # Get home and cwd as Path objects for comparison
31
+ home = Path.home()
32
+ cwd = Path.cwd()
33
+
34
+ # Convert path to Path object
35
+ path_obj = Path(path)
36
+
37
+ # Try to make path relative to cwd first (most common case)
38
+ try:
39
+ relative_to_cwd = path_obj.relative_to(cwd)
40
+ return str(relative_to_cwd)
41
+ except ValueError:
42
+ pass
43
+
44
+ # Try to replace home directory with ~
45
+ try:
46
+ relative_to_home = path_obj.relative_to(home)
47
+ return f"~/{relative_to_home}"
48
+ except ValueError:
49
+ pass
50
+
51
+ # If path is absolute but not under cwd or home, just return filename
52
+ if path_obj.is_absolute():
53
+ return path_obj.name
54
+
55
+ # Return as-is if already relative
56
+ return path
57
+
58
+ except Exception:
59
+ # If anything goes wrong, return the original path
60
+ # Better to leak a path than break error reporting
61
+ return path
62
+
63
+
64
+ def _scrub_sensitive_paths(event: dict[str, Any]) -> None:
65
+ """Scrub sensitive paths from Sentry event data.
66
+
67
+ Modifies the event in-place to remove:
68
+ - Home directory paths (might contain usernames)
69
+ - Current working directory paths (might contain usernames)
70
+ - Server name/hostname
71
+ - Paths in sys.argv
72
+
73
+ Args:
74
+ event: The Sentry event dictionary to scrub
75
+ """
76
+ extra = event.get("extra", {})
77
+ if "sys.argv" in extra:
78
+ argv = extra["sys.argv"]
79
+ if isinstance(argv, list):
80
+ extra["sys.argv"] = [
81
+ _scrub_path(arg) if isinstance(arg, str) else arg for arg in argv
82
+ ]
83
+
84
+ # Scrub server name if present
85
+ if "server_name" in event:
86
+ event["server_name"] = ""
87
+
88
+ # Scrub contexts that might contain paths
89
+ if "contexts" in event:
90
+ contexts = event["contexts"]
91
+ # Remove runtime context if it has CWD
92
+ if "runtime" in contexts:
93
+ if "cwd" in contexts["runtime"]:
94
+ del contexts["runtime"]["cwd"]
95
+ # Scrub sys.argv to remove paths
96
+ if "sys.argv" in contexts["runtime"]:
97
+ argv = contexts["runtime"]["sys.argv"]
98
+ if isinstance(argv, list):
99
+ contexts["runtime"]["sys.argv"] = [
100
+ _scrub_path(arg) if isinstance(arg, str) else arg
101
+ for arg in argv
102
+ ]
103
+
104
+ # Scrub exception stack traces
105
+ if "exception" in event and "values" in event["exception"]:
106
+ for exception in event["exception"]["values"]:
107
+ if "stacktrace" in exception and "frames" in exception["stacktrace"]:
108
+ for frame in exception["stacktrace"]["frames"]:
109
+ # Scrub file paths
110
+ if "abs_path" in frame:
111
+ frame["abs_path"] = _scrub_path(frame["abs_path"])
112
+ if "filename" in frame:
113
+ frame["filename"] = _scrub_path(frame["filename"])
114
+
115
+ # Scrub local variables that might contain paths
116
+ if "vars" in frame:
117
+ for var_name, var_value in frame["vars"].items():
118
+ if isinstance(var_value, str):
119
+ frame["vars"][var_name] = _scrub_path(var_value)
120
+
121
+ # Scrub breadcrumbs that might contain paths
122
+ if "breadcrumbs" in event and "values" in event["breadcrumbs"]:
123
+ for breadcrumb in event["breadcrumbs"]["values"]:
124
+ if "data" in breadcrumb:
125
+ for key, value in breadcrumb["data"].items():
126
+ if isinstance(value, str):
127
+ breadcrumb["data"][key] = _scrub_path(value)
128
+
129
+
13
130
  def setup_sentry_observability() -> bool:
14
131
  """Set up Sentry observability for error tracking.
15
132
 
@@ -41,18 +158,38 @@ def setup_sentry_observability() -> bool:
41
158
  environment = "production"
42
159
 
43
160
  def before_send(event: Any, hint: dict[str, Any]) -> Any:
44
- """Filter out user-actionable errors from Sentry.
161
+ """Filter out user-actionable errors and scrub sensitive paths.
45
162
 
46
163
  User-actionable errors (like context size limits) are expected conditions
47
164
  that users need to resolve, not bugs that need tracking.
165
+
166
+ Also scrubs sensitive information like usernames from file paths and
167
+ working directories to protect user privacy.
48
168
  """
169
+
170
+ log_record = hint.get("log_record")
171
+ if log_record:
172
+ # Scrub pathname using the helper function
173
+ log_record.pathname = _scrub_path(log_record.pathname)
174
+
175
+ # Scrub traceback text if it exists
176
+ if hasattr(log_record, "exc_text") and isinstance(
177
+ log_record.exc_text, str
178
+ ):
179
+ # Replace home directory in traceback text
180
+ home = Path.home()
181
+ log_record.exc_text = log_record.exc_text.replace(str(home), "~")
182
+
49
183
  if "exc_info" in hint:
50
- exc_type, exc_value, tb = hint["exc_info"]
184
+ _, exc_value, _ = hint["exc_info"]
51
185
  from shotgun.exceptions import ErrorNotPickedUpBySentry
52
186
 
53
187
  if isinstance(exc_value, ErrorNotPickedUpBySentry):
54
188
  # Don't send to Sentry - this is user-actionable, not a bug
55
189
  return None
190
+
191
+ # Scrub sensitive paths from the event
192
+ _scrub_sensitive_paths(event)
56
193
  return event
57
194
 
58
195
  # Initialize Sentry
@@ -61,6 +198,7 @@ def setup_sentry_observability() -> bool:
61
198
  release=f"shotgun-sh@{__version__}",
62
199
  environment=environment,
63
200
  send_default_pii=False, # Privacy-first: never send PII
201
+ server_name="", # Privacy: don't send hostname (may contain username)
64
202
  traces_sample_rate=0.1 if environment == "production" else 1.0,
65
203
  profiles_sample_rate=0.1 if environment == "production" else 1.0,
66
204
  before_send=before_send,
shotgun/settings.py CHANGED
@@ -108,6 +108,11 @@ class LoggingSettings(BaseSettings):
108
108
  default=True,
109
109
  description="Enable file logging output",
110
110
  )
111
+ max_log_files: int = Field(
112
+ default=10,
113
+ description="Maximum number of log files to keep (older files are deleted)",
114
+ ge=1,
115
+ )
111
116
 
112
117
  model_config = SettingsConfigDict(
113
118
  env_prefix="SHOTGUN_",
shotgun/tui/app.py CHANGED
@@ -6,7 +6,10 @@ from textual.binding import Binding
6
6
  from textual.screen import Screen
7
7
 
8
8
  from shotgun.agents.agent_manager import AgentManager
9
- from shotgun.agents.config import ConfigManager, get_config_manager
9
+ from shotgun.agents.config import (
10
+ ConfigManager,
11
+ get_config_manager,
12
+ )
10
13
  from shotgun.agents.models import AgentType
11
14
  from shotgun.logging_config import get_logger
12
15
  from shotgun.tui.containers import TUIContainer
@@ -99,7 +102,10 @@ class ShotgunApp(App[None]):
99
102
  # Run async config loading in worker
100
103
  async def _check_config() -> None:
101
104
  # Show welcome screen if no providers are configured OR if user hasn't seen it yet
105
+ # Note: If config migration fails, ConfigManager will auto-create fresh config
106
+ # and set migration_failed flag, which WelcomeScreen will display
102
107
  config = await self.config_manager.load()
108
+
103
109
  has_any_key = await self.config_manager.has_any_provider_key()
104
110
  if not has_any_key or not config.shown_welcome_screen:
105
111
  if isinstance(self.screen, WelcomeScreen):