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.

Files changed (150) hide show
  1. shotgun/agents/agent_manager.py +761 -52
  2. shotgun/agents/common.py +80 -75
  3. shotgun/agents/config/constants.py +21 -10
  4. shotgun/agents/config/manager.py +322 -97
  5. shotgun/agents/config/models.py +114 -84
  6. shotgun/agents/config/provider.py +232 -88
  7. shotgun/agents/context_analyzer/__init__.py +28 -0
  8. shotgun/agents/context_analyzer/analyzer.py +471 -0
  9. shotgun/agents/context_analyzer/constants.py +9 -0
  10. shotgun/agents/context_analyzer/formatter.py +115 -0
  11. shotgun/agents/context_analyzer/models.py +212 -0
  12. shotgun/agents/conversation_history.py +125 -2
  13. shotgun/agents/conversation_manager.py +57 -19
  14. shotgun/agents/export.py +6 -7
  15. shotgun/agents/history/compaction.py +23 -3
  16. shotgun/agents/history/context_extraction.py +93 -6
  17. shotgun/agents/history/history_processors.py +179 -11
  18. shotgun/agents/history/token_counting/__init__.py +31 -0
  19. shotgun/agents/history/token_counting/anthropic.py +127 -0
  20. shotgun/agents/history/token_counting/base.py +78 -0
  21. shotgun/agents/history/token_counting/openai.py +90 -0
  22. shotgun/agents/history/token_counting/sentencepiece_counter.py +127 -0
  23. shotgun/agents/history/token_counting/tokenizer_cache.py +92 -0
  24. shotgun/agents/history/token_counting/utils.py +144 -0
  25. shotgun/agents/history/token_estimation.py +12 -12
  26. shotgun/agents/llm.py +62 -0
  27. shotgun/agents/models.py +59 -4
  28. shotgun/agents/plan.py +6 -7
  29. shotgun/agents/research.py +7 -8
  30. shotgun/agents/specify.py +6 -7
  31. shotgun/agents/tasks.py +6 -7
  32. shotgun/agents/tools/__init__.py +0 -2
  33. shotgun/agents/tools/codebase/codebase_shell.py +6 -0
  34. shotgun/agents/tools/codebase/directory_lister.py +6 -0
  35. shotgun/agents/tools/codebase/file_read.py +11 -2
  36. shotgun/agents/tools/codebase/query_graph.py +6 -0
  37. shotgun/agents/tools/codebase/retrieve_code.py +6 -0
  38. shotgun/agents/tools/file_management.py +82 -16
  39. shotgun/agents/tools/registry.py +217 -0
  40. shotgun/agents/tools/web_search/__init__.py +55 -16
  41. shotgun/agents/tools/web_search/anthropic.py +76 -51
  42. shotgun/agents/tools/web_search/gemini.py +50 -27
  43. shotgun/agents/tools/web_search/openai.py +26 -17
  44. shotgun/agents/tools/web_search/utils.py +2 -2
  45. shotgun/agents/usage_manager.py +164 -0
  46. shotgun/api_endpoints.py +15 -0
  47. shotgun/cli/clear.py +53 -0
  48. shotgun/cli/codebase/commands.py +71 -2
  49. shotgun/cli/compact.py +186 -0
  50. shotgun/cli/config.py +41 -67
  51. shotgun/cli/context.py +111 -0
  52. shotgun/cli/export.py +1 -1
  53. shotgun/cli/feedback.py +50 -0
  54. shotgun/cli/models.py +3 -2
  55. shotgun/cli/plan.py +1 -1
  56. shotgun/cli/research.py +1 -1
  57. shotgun/cli/specify.py +1 -1
  58. shotgun/cli/tasks.py +1 -1
  59. shotgun/cli/update.py +18 -5
  60. shotgun/codebase/core/change_detector.py +5 -3
  61. shotgun/codebase/core/code_retrieval.py +4 -2
  62. shotgun/codebase/core/ingestor.py +169 -19
  63. shotgun/codebase/core/manager.py +177 -13
  64. shotgun/codebase/core/nl_query.py +1 -1
  65. shotgun/codebase/models.py +28 -3
  66. shotgun/codebase/service.py +14 -2
  67. shotgun/exceptions.py +32 -0
  68. shotgun/llm_proxy/__init__.py +19 -0
  69. shotgun/llm_proxy/clients.py +44 -0
  70. shotgun/llm_proxy/constants.py +15 -0
  71. shotgun/logging_config.py +18 -27
  72. shotgun/main.py +91 -4
  73. shotgun/posthog_telemetry.py +87 -40
  74. shotgun/prompts/agents/export.j2 +18 -1
  75. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +5 -1
  76. shotgun/prompts/agents/partials/interactive_mode.j2 +24 -7
  77. shotgun/prompts/agents/plan.j2 +1 -1
  78. shotgun/prompts/agents/research.j2 +1 -1
  79. shotgun/prompts/agents/specify.j2 +270 -3
  80. shotgun/prompts/agents/state/system_state.j2 +4 -0
  81. shotgun/prompts/agents/tasks.j2 +1 -1
  82. shotgun/prompts/codebase/partials/cypher_rules.j2 +13 -0
  83. shotgun/prompts/loader.py +2 -2
  84. shotgun/prompts/tools/web_search.j2 +14 -0
  85. shotgun/sdk/codebase.py +60 -2
  86. shotgun/sentry_telemetry.py +28 -21
  87. shotgun/settings.py +238 -0
  88. shotgun/shotgun_web/__init__.py +19 -0
  89. shotgun/shotgun_web/client.py +138 -0
  90. shotgun/shotgun_web/constants.py +21 -0
  91. shotgun/shotgun_web/models.py +47 -0
  92. shotgun/telemetry.py +24 -36
  93. shotgun/tui/app.py +275 -23
  94. shotgun/tui/commands/__init__.py +1 -1
  95. shotgun/tui/components/context_indicator.py +179 -0
  96. shotgun/tui/components/mode_indicator.py +70 -0
  97. shotgun/tui/components/status_bar.py +48 -0
  98. shotgun/tui/components/vertical_tail.py +6 -0
  99. shotgun/tui/containers.py +91 -0
  100. shotgun/tui/dependencies.py +39 -0
  101. shotgun/tui/filtered_codebase_service.py +46 -0
  102. shotgun/tui/protocols.py +45 -0
  103. shotgun/tui/screens/chat/__init__.py +5 -0
  104. shotgun/tui/screens/chat/chat.tcss +54 -0
  105. shotgun/tui/screens/chat/chat_screen.py +1234 -0
  106. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +64 -0
  107. shotgun/tui/screens/chat/codebase_index_selection.py +12 -0
  108. shotgun/tui/screens/chat/help_text.py +40 -0
  109. shotgun/tui/screens/chat/prompt_history.py +48 -0
  110. shotgun/tui/screens/chat.tcss +11 -0
  111. shotgun/tui/screens/chat_screen/command_providers.py +226 -11
  112. shotgun/tui/screens/chat_screen/history/__init__.py +22 -0
  113. shotgun/tui/screens/chat_screen/history/agent_response.py +66 -0
  114. shotgun/tui/screens/chat_screen/history/chat_history.py +116 -0
  115. shotgun/tui/screens/chat_screen/history/formatters.py +115 -0
  116. shotgun/tui/screens/chat_screen/history/partial_response.py +43 -0
  117. shotgun/tui/screens/chat_screen/history/user_question.py +42 -0
  118. shotgun/tui/screens/confirmation_dialog.py +151 -0
  119. shotgun/tui/screens/feedback.py +193 -0
  120. shotgun/tui/screens/github_issue.py +102 -0
  121. shotgun/tui/screens/model_picker.py +352 -0
  122. shotgun/tui/screens/onboarding.py +431 -0
  123. shotgun/tui/screens/pipx_migration.py +153 -0
  124. shotgun/tui/screens/provider_config.py +156 -39
  125. shotgun/tui/screens/shotgun_auth.py +295 -0
  126. shotgun/tui/screens/welcome.py +198 -0
  127. shotgun/tui/services/__init__.py +5 -0
  128. shotgun/tui/services/conversation_service.py +184 -0
  129. shotgun/tui/state/__init__.py +7 -0
  130. shotgun/tui/state/processing_state.py +185 -0
  131. shotgun/tui/utils/mode_progress.py +14 -7
  132. shotgun/tui/widgets/__init__.py +5 -0
  133. shotgun/tui/widgets/widget_coordinator.py +262 -0
  134. shotgun/utils/datetime_utils.py +77 -0
  135. shotgun/utils/env_utils.py +13 -0
  136. shotgun/utils/file_system_utils.py +22 -2
  137. shotgun/utils/marketing.py +110 -0
  138. shotgun/utils/source_detection.py +16 -0
  139. shotgun/utils/update_checker.py +73 -21
  140. shotgun_sh-0.2.11.dist-info/METADATA +130 -0
  141. shotgun_sh-0.2.11.dist-info/RECORD +194 -0
  142. {shotgun_sh-0.1.9.dist-info → shotgun_sh-0.2.11.dist-info}/entry_points.txt +1 -0
  143. {shotgun_sh-0.1.9.dist-info → shotgun_sh-0.2.11.dist-info}/licenses/LICENSE +1 -1
  144. shotgun/agents/history/token_counting.py +0 -429
  145. shotgun/agents/tools/user_interaction.py +0 -37
  146. shotgun/tui/screens/chat.py +0 -818
  147. shotgun/tui/screens/chat_screen/history.py +0 -222
  148. shotgun_sh-0.1.9.dist-info/METADATA +0 -466
  149. shotgun_sh-0.1.9.dist-info/RECORD +0 -131
  150. {shotgun_sh-0.1.9.dist-info → shotgun_sh-0.2.11.dist-info}/WHEEL +0 -0
@@ -1,12 +1,13 @@
1
1
  """Data models for codebase service."""
2
2
 
3
- from enum import Enum
3
+ from collections.abc import Callable
4
+ from enum import StrEnum
4
5
  from typing import Any
5
6
 
6
7
  from pydantic import BaseModel, Field
7
8
 
8
9
 
9
- class GraphStatus(str, Enum):
10
+ class GraphStatus(StrEnum):
10
11
  """Status of a code knowledge graph."""
11
12
 
12
13
  READY = "READY" # Graph is ready for queries
@@ -15,13 +16,37 @@ class GraphStatus(str, Enum):
15
16
  ERROR = "ERROR" # Last operation failed
16
17
 
17
18
 
18
- class QueryType(str, Enum):
19
+ class QueryType(StrEnum):
19
20
  """Type of query being executed."""
20
21
 
21
22
  NATURAL_LANGUAGE = "natural_language"
22
23
  CYPHER = "cypher"
23
24
 
24
25
 
26
+ class ProgressPhase(StrEnum):
27
+ """Phase of codebase indexing progress."""
28
+
29
+ STRUCTURE = "structure" # Identifying packages and folders
30
+ DEFINITIONS = "definitions" # Processing files and extracting definitions
31
+ RELATIONSHIPS = "relationships" # Processing relationships (calls, imports)
32
+
33
+
34
+ class IndexProgress(BaseModel):
35
+ """Progress information for codebase indexing."""
36
+
37
+ phase: ProgressPhase = Field(..., description="Current indexing phase")
38
+ phase_name: str = Field(..., description="Human-readable phase name")
39
+ current: int = Field(..., description="Current item count")
40
+ total: int | None = Field(None, description="Total items (None if unknown)")
41
+ phase_complete: bool = Field(
42
+ default=False, description="Whether this phase is complete"
43
+ )
44
+
45
+
46
+ # Type alias for progress callback function
47
+ ProgressCallback = Callable[[IndexProgress], None]
48
+
49
+
25
50
  class OperationStats(BaseModel):
26
51
  """Statistics for a graph operation (build/update)."""
27
52
 
@@ -69,11 +69,19 @@ class CodebaseService:
69
69
  # Otherwise, check if current directory is in the allowed list
70
70
  elif target_path in graph.indexed_from_cwds:
71
71
  filtered_graphs.append(graph)
72
+ # Also allow access if current directory IS the repository itself
73
+ # Use Path.resolve() for robust comparison (handles symlinks, etc.)
74
+ elif Path(target_path).resolve() == Path(graph.repo_path).resolve():
75
+ filtered_graphs.append(graph)
72
76
 
73
77
  return filtered_graphs
74
78
 
75
79
  async def create_graph(
76
- self, repo_path: str | Path, name: str, indexed_from_cwd: str | None = None
80
+ self,
81
+ repo_path: str | Path,
82
+ name: str,
83
+ indexed_from_cwd: str | None = None,
84
+ progress_callback: Any | None = None,
77
85
  ) -> CodebaseGraph:
78
86
  """Create and index a new graph from a repository.
79
87
 
@@ -81,12 +89,16 @@ class CodebaseService:
81
89
  repo_path: Path to the repository to index
82
90
  name: Human-readable name for the graph
83
91
  indexed_from_cwd: Working directory from which indexing was initiated
92
+ progress_callback: Optional callback for progress reporting
84
93
 
85
94
  Returns:
86
95
  The created CodebaseGraph
87
96
  """
88
97
  return await self.manager.build_graph(
89
- str(repo_path), name, indexed_from_cwd=indexed_from_cwd
98
+ str(repo_path),
99
+ name,
100
+ indexed_from_cwd=indexed_from_cwd,
101
+ progress_callback=progress_callback,
90
102
  )
91
103
 
92
104
  async def get_graph(self, graph_id: str) -> CodebaseGraph | None:
shotgun/exceptions.py ADDED
@@ -0,0 +1,32 @@
1
+ """General exceptions for Shotgun application."""
2
+
3
+
4
+ class ErrorNotPickedUpBySentry(Exception): # noqa: N818
5
+ """Base for user-actionable errors that shouldn't be sent to Sentry.
6
+
7
+ These errors represent expected user conditions requiring action
8
+ rather than bugs that need tracking.
9
+ """
10
+
11
+
12
+ class ContextSizeLimitExceeded(ErrorNotPickedUpBySentry):
13
+ """Raised when conversation context exceeds the model's limits.
14
+
15
+ This is a user-actionable error - they need to either:
16
+ 1. Switch to a larger context model
17
+ 2. Switch to a larger model, compact their conversation, then switch back
18
+ 3. Clear the conversation and start fresh
19
+ """
20
+
21
+ def __init__(self, model_name: str, max_tokens: int):
22
+ """Initialize the exception.
23
+
24
+ Args:
25
+ model_name: Name of the model whose limit was exceeded
26
+ max_tokens: Maximum tokens allowed by the model
27
+ """
28
+ self.model_name = model_name
29
+ self.max_tokens = max_tokens
30
+ super().__init__(
31
+ f"Context too large for {model_name} (limit: {max_tokens:,} tokens)"
32
+ )
@@ -0,0 +1,19 @@
1
+ """LiteLLM proxy client utilities and configuration."""
2
+
3
+ from .clients import (
4
+ create_anthropic_proxy_provider,
5
+ create_litellm_provider,
6
+ )
7
+ from .constants import (
8
+ LITELLM_PROXY_ANTHROPIC_BASE,
9
+ LITELLM_PROXY_BASE_URL,
10
+ LITELLM_PROXY_OPENAI_BASE,
11
+ )
12
+
13
+ __all__ = [
14
+ "LITELLM_PROXY_BASE_URL",
15
+ "LITELLM_PROXY_ANTHROPIC_BASE",
16
+ "LITELLM_PROXY_OPENAI_BASE",
17
+ "create_litellm_provider",
18
+ "create_anthropic_proxy_provider",
19
+ ]
@@ -0,0 +1,44 @@
1
+ """Client creation utilities for LiteLLM proxy."""
2
+
3
+ from pydantic_ai.providers.anthropic import AnthropicProvider
4
+ from pydantic_ai.providers.litellm import LiteLLMProvider
5
+
6
+ from .constants import LITELLM_PROXY_ANTHROPIC_BASE, LITELLM_PROXY_BASE_URL
7
+
8
+
9
+ def create_litellm_provider(api_key: str) -> LiteLLMProvider:
10
+ """Create LiteLLM provider for Shotgun Account.
11
+
12
+ Args:
13
+ api_key: Shotgun API key
14
+
15
+ Returns:
16
+ Configured LiteLLM provider pointing to Shotgun's proxy
17
+ """
18
+ return LiteLLMProvider(
19
+ api_base=LITELLM_PROXY_BASE_URL,
20
+ api_key=api_key,
21
+ )
22
+
23
+
24
+ def create_anthropic_proxy_provider(api_key: str) -> AnthropicProvider:
25
+ """Create Anthropic provider configured for LiteLLM proxy.
26
+
27
+ This provider uses native Anthropic API format while routing through
28
+ the LiteLLM proxy. This preserves Anthropic-specific features like
29
+ tool_choice and web search.
30
+
31
+ The provider's .client attribute provides access to the async Anthropic
32
+ client (AsyncAnthropic), which should be used for all API operations
33
+ including token counting.
34
+
35
+ Args:
36
+ api_key: Shotgun API key
37
+
38
+ Returns:
39
+ AnthropicProvider configured to use LiteLLM proxy /anthropic endpoint
40
+ """
41
+ return AnthropicProvider(
42
+ api_key=api_key,
43
+ base_url=LITELLM_PROXY_ANTHROPIC_BASE,
44
+ )
@@ -0,0 +1,15 @@
1
+ """LiteLLM proxy constants and configuration."""
2
+
3
+ # Import from centralized API endpoints module
4
+ from shotgun.api_endpoints import (
5
+ LITELLM_PROXY_ANTHROPIC_BASE,
6
+ LITELLM_PROXY_BASE_URL,
7
+ LITELLM_PROXY_OPENAI_BASE,
8
+ )
9
+
10
+ # Re-export for backward compatibility
11
+ __all__ = [
12
+ "LITELLM_PROXY_BASE_URL",
13
+ "LITELLM_PROXY_ANTHROPIC_BASE",
14
+ "LITELLM_PROXY_OPENAI_BASE",
15
+ ]
shotgun/logging_config.py CHANGED
@@ -2,12 +2,16 @@
2
2
 
3
3
  import logging
4
4
  import logging.handlers
5
- import os
6
5
  import sys
6
+ from datetime import datetime, timezone
7
7
  from pathlib import Path
8
8
 
9
+ from shotgun.settings import settings
9
10
  from shotgun.utils.env_utils import is_truthy
10
11
 
12
+ # Generate a single timestamp for this run to be used across all loggers
13
+ _RUN_TIMESTAMP = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
14
+
11
15
 
12
16
  def get_log_directory() -> Path:
13
17
  """Get the log directory path, creating it if necessary.
@@ -66,21 +70,16 @@ def setup_logger(
66
70
  logger = logging.getLogger(name)
67
71
 
68
72
  # Check if we already have a file handler
69
- has_file_handler = any(
70
- isinstance(h, logging.handlers.TimedRotatingFileHandler)
71
- for h in logger.handlers
72
- )
73
+ has_file_handler = any(isinstance(h, logging.FileHandler) for h in logger.handlers)
73
74
 
74
75
  # If we already have a file handler, just return the logger
75
76
  if has_file_handler:
76
77
  return logger
77
78
 
78
- # Get log level from environment variable, default to INFO
79
- env_level = os.getenv("SHOTGUN_LOG_LEVEL", "INFO").upper()
80
- if env_level not in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]:
81
- env_level = "INFO"
79
+ # Get log level from settings (already validated and uppercased)
80
+ log_level = settings.logging.log_level
82
81
 
83
- logger.setLevel(getattr(logging, env_level))
82
+ logger.setLevel(getattr(logging, log_level))
84
83
 
85
84
  # Default format string
86
85
  if format_string is None:
@@ -102,13 +101,13 @@ def setup_logger(
102
101
  # Check if console logging is enabled (default: off)
103
102
  # Force console logging OFF if Logfire is enabled in dev build
104
103
  console_logging_enabled = (
105
- is_truthy(os.getenv("LOGGING_TO_CONSOLE", "false")) and not is_logfire_dev_build
104
+ settings.logging.logging_to_console and not is_logfire_dev_build
106
105
  )
107
106
 
108
107
  if console_logging_enabled:
109
108
  # Create console handler
110
109
  console_handler = logging.StreamHandler(sys.stdout)
111
- console_handler.setLevel(getattr(logging, env_level))
110
+ console_handler.setLevel(getattr(logging, log_level))
112
111
 
113
112
  # Use colored formatter for console
114
113
  console_formatter = ColoredFormatter(format_string, datefmt="%H:%M:%S")
@@ -118,26 +117,21 @@ def setup_logger(
118
117
  logger.addHandler(console_handler)
119
118
 
120
119
  # Check if file logging is enabled (default: on)
121
- file_logging_enabled = is_truthy(os.getenv("LOGGING_TO_FILE", "true"))
120
+ file_logging_enabled = settings.logging.logging_to_file
122
121
 
123
122
  if file_logging_enabled:
124
123
  try:
125
- # Create file handler with rotation
124
+ # Create file handler with ISO8601 timestamp for each run
126
125
  log_dir = get_log_directory()
127
- log_file = log_dir / "shotgun.log"
126
+ log_file = log_dir / f"shotgun-{_RUN_TIMESTAMP}.log"
128
127
 
129
- # Use TimedRotatingFileHandler - rotates daily and keeps 7 days of logs
130
- file_handler = logging.handlers.TimedRotatingFileHandler(
128
+ # Use regular FileHandler - each run gets its own isolated log file
129
+ file_handler = logging.FileHandler(
131
130
  filename=log_file,
132
- when="midnight", # Rotate at midnight
133
- interval=1, # Every 1 day
134
- backupCount=7, # Keep 7 days of logs
135
131
  encoding="utf-8",
136
132
  )
137
133
 
138
- # Also set max file size (10MB) using RotatingFileHandler as fallback
139
- # Note: We'll use TimedRotatingFileHandler which handles both time and size
140
- file_handler.setLevel(getattr(logging, env_level))
134
+ file_handler.setLevel(getattr(logging, log_level))
141
135
 
142
136
  # Use standard formatter for file (no colors)
143
137
  file_formatter = logging.Formatter(
@@ -191,10 +185,7 @@ def get_logger(name: str) -> logging.Logger:
191
185
  logger = logging.getLogger(name)
192
186
 
193
187
  # Check if we have a file handler already
194
- has_file_handler = any(
195
- isinstance(h, logging.handlers.TimedRotatingFileHandler)
196
- for h in logger.handlers
197
- )
188
+ has_file_handler = any(isinstance(h, logging.FileHandler) for h in logger.handlers)
198
189
 
199
190
  # If no file handler, set up the logger (will add file handler)
200
191
  if not has_file_handler:
shotgun/main.py CHANGED
@@ -1,5 +1,11 @@
1
1
  """Main CLI application for shotgun."""
2
2
 
3
+ # NOTE: These are before we import any Google library to stop the noisy gRPC logs.
4
+ import os # noqa: I001
5
+
6
+ os.environ["GRPC_VERBOSITY"] = "ERROR"
7
+ os.environ["GLOG_minloglevel"] = "2"
8
+
3
9
  import logging
4
10
 
5
11
  # CRITICAL: Add NullHandler to root logger before ANY other imports.
@@ -16,7 +22,20 @@ from dotenv import load_dotenv
16
22
 
17
23
  from shotgun import __version__
18
24
  from shotgun.agents.config import get_config_manager
19
- from shotgun.cli import codebase, config, export, plan, research, specify, tasks, update
25
+ from shotgun.cli import (
26
+ clear,
27
+ codebase,
28
+ compact,
29
+ config,
30
+ context,
31
+ export,
32
+ feedback,
33
+ plan,
34
+ research,
35
+ specify,
36
+ tasks,
37
+ update,
38
+ )
20
39
  from shotgun.logging_config import configure_root_logger, get_logger
21
40
  from shotgun.posthog_telemetry import setup_posthog_observability
22
41
  from shotgun.sentry_telemetry import setup_sentry_observability
@@ -37,8 +56,10 @@ logger.debug("Logfire observability enabled: %s", _logfire_enabled)
37
56
 
38
57
  # Initialize configuration
39
58
  try:
59
+ import asyncio
60
+
40
61
  config_manager = get_config_manager()
41
- config_manager.load() # Ensure config is loaded at startup
62
+ asyncio.run(config_manager.load()) # Ensure config is loaded at startup
42
63
  except Exception as e:
43
64
  logger.debug("Configuration initialization warning: %s", e)
44
65
 
@@ -62,12 +83,16 @@ app.add_typer(config.app, name="config", help="Manage Shotgun configuration")
62
83
  app.add_typer(
63
84
  codebase.app, name="codebase", help="Manage and query code knowledge graphs"
64
85
  )
86
+ app.add_typer(context.app, name="context", help="Analyze conversation context usage")
87
+ app.add_typer(compact.app, name="compact", help="Compact conversation history")
88
+ app.add_typer(clear.app, name="clear", help="Clear conversation history")
65
89
  app.add_typer(research.app, name="research", help="Perform research with agentic loops")
66
90
  app.add_typer(plan.app, name="plan", help="Generate structured plans")
67
91
  app.add_typer(specify.app, name="specify", help="Generate comprehensive specifications")
68
92
  app.add_typer(tasks.app, name="tasks", help="Generate task lists with agentic approach")
69
93
  app.add_typer(export.app, name="export", help="Export artifacts to various formats")
70
94
  app.add_typer(update.app, name="update", help="Check for and install updates")
95
+ app.add_typer(feedback.app, name="feedback", help="Send us feedback")
71
96
 
72
97
 
73
98
  def version_callback(value: bool) -> None:
@@ -108,6 +133,41 @@ def main(
108
133
  help="Continue previous TUI conversation",
109
134
  ),
110
135
  ] = False,
136
+ web: Annotated[
137
+ bool,
138
+ typer.Option(
139
+ "--web",
140
+ help="Serve TUI as web application",
141
+ ),
142
+ ] = False,
143
+ port: Annotated[
144
+ int,
145
+ typer.Option(
146
+ "--port",
147
+ help="Port for web server (only used with --web)",
148
+ ),
149
+ ] = 8000,
150
+ host: Annotated[
151
+ str,
152
+ typer.Option(
153
+ "--host",
154
+ help="Host address for web server (only used with --web)",
155
+ ),
156
+ ] = "localhost",
157
+ public_url: Annotated[
158
+ str | None,
159
+ typer.Option(
160
+ "--public-url",
161
+ help="Public URL if behind proxy (only used with --web)",
162
+ ),
163
+ ] = None,
164
+ force_reindex: Annotated[
165
+ bool,
166
+ typer.Option(
167
+ "--force-reindex",
168
+ help="Force re-indexing of codebase (ignores existing index)",
169
+ ),
170
+ ] = False,
111
171
  ) -> None:
112
172
  """Shotgun - AI-powered CLI tool."""
113
173
  logger.debug("Starting shotgun CLI application")
@@ -117,8 +177,35 @@ def main(
117
177
  perform_auto_update_async(no_update_check=no_update_check)
118
178
 
119
179
  if ctx.invoked_subcommand is None and not ctx.resilient_parsing:
120
- logger.debug("Launching shotgun TUI application")
121
- tui_app.run(no_update_check=no_update_check, continue_session=continue_session)
180
+ if web:
181
+ logger.debug("Launching shotgun TUI as web application")
182
+ try:
183
+ tui_app.serve(
184
+ host=host,
185
+ port=port,
186
+ public_url=public_url,
187
+ no_update_check=no_update_check,
188
+ continue_session=continue_session,
189
+ force_reindex=force_reindex,
190
+ )
191
+ finally:
192
+ # Ensure PostHog is shut down cleanly even if server exits unexpectedly
193
+ from shotgun.posthog_telemetry import shutdown
194
+
195
+ shutdown()
196
+ else:
197
+ logger.debug("Launching shotgun TUI application")
198
+ try:
199
+ tui_app.run(
200
+ no_update_check=no_update_check,
201
+ continue_session=continue_session,
202
+ force_reindex=force_reindex,
203
+ )
204
+ finally:
205
+ # Ensure PostHog is shut down cleanly even if TUI exits unexpectedly
206
+ from shotgun.posthog_telemetry import shutdown
207
+
208
+ shutdown()
122
209
  raise typer.Exit()
123
210
 
124
211
  # For CLI commands, register PostHog shutdown handler
@@ -1,9 +1,16 @@
1
1
  """PostHog analytics setup for Shotgun."""
2
2
 
3
- import os
3
+ from enum import StrEnum
4
4
  from typing import Any
5
5
 
6
+ import posthog
7
+ from pydantic import BaseModel
8
+
9
+ from shotgun import __version__
10
+ from shotgun.agents.config import get_config_manager
11
+ from shotgun.agents.conversation_manager import ConversationManager
6
12
  from shotgun.logging_config import get_early_logger
13
+ from shotgun.settings import settings
7
14
 
8
15
  # Use early logger to prevent automatic StreamHandler creation
9
16
  logger = get_early_logger(__name__)
@@ -21,41 +28,20 @@ def setup_posthog_observability() -> bool:
21
28
  global _posthog_client
22
29
 
23
30
  try:
24
- import posthog
25
-
26
31
  # Check if PostHog is already initialized
27
32
  if _posthog_client is not None:
28
33
  logger.debug("PostHog is already initialized, skipping")
29
34
  return True
30
35
 
31
- # Try to get API key from build constants first (production builds)
32
- api_key = None
33
-
34
- try:
35
- from shotgun import build_constants
36
-
37
- api_key = build_constants.POSTHOG_API_KEY
38
- if api_key:
39
- logger.debug("Using PostHog configuration from build constants")
40
- except (ImportError, AttributeError):
41
- pass
42
-
43
- # Fallback to environment variables if build constants are empty or missing
44
- if not api_key:
45
- api_key = os.getenv("POSTHOG_API_KEY", "")
46
- if api_key:
47
- logger.debug("Using PostHog configuration from environment variables")
36
+ # Get API key from settings (handles build constants + env vars automatically)
37
+ api_key = settings.telemetry.posthog_api_key
48
38
 
39
+ # If no API key is available, skip PostHog initialization
49
40
  if not api_key:
50
- logger.debug(
51
- "No PostHog API key configured, skipping PostHog initialization"
52
- )
41
+ logger.debug("No PostHog API key available, skipping initialization")
53
42
  return False
54
43
 
55
- logger.debug("Found PostHog configuration, proceeding with setup")
56
-
57
- # Get version for context
58
- from shotgun import __version__
44
+ logger.debug("Using PostHog API key from settings")
59
45
 
60
46
  # Determine environment based on version
61
47
  # Dev versions contain "dev", "rc", "alpha", or "beta"
@@ -71,22 +57,31 @@ def setup_posthog_observability() -> bool:
71
57
  # Store the client for later use
72
58
  _posthog_client = posthog
73
59
 
74
- # Set user context with anonymous user ID from config
60
+ # Set user context with anonymous shotgun instance ID from config
75
61
  try:
76
- from shotgun.agents.config import get_config_manager
62
+ import asyncio
77
63
 
78
64
  config_manager = get_config_manager()
79
- user_id = config_manager.get_user_id()
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
+ )
80
75
 
81
76
  # Set default properties for all events
82
77
  posthog.disabled = False
83
78
  posthog.personal_api_key = None # Not needed for event tracking
84
79
 
85
80
  logger.debug(
86
- "PostHog user context will be set with anonymous ID: %s", user_id
81
+ "PostHog user identified with anonymous ID: %s", shotgun_instance_id
87
82
  )
88
83
  except Exception as e:
89
- logger.warning("Failed to get user context: %s", e)
84
+ logger.warning("Failed to set user context: %s", e)
90
85
 
91
86
  logger.debug(
92
87
  "PostHog analytics configured successfully (environment: %s, version: %s)",
@@ -95,9 +90,6 @@ def setup_posthog_observability() -> bool:
95
90
  )
96
91
  return True
97
92
 
98
- except ImportError as e:
99
- logger.error("PostHog SDK not available: %s", e)
100
- return False
101
93
  except Exception as e:
102
94
  logger.warning("Failed to setup PostHog analytics: %s", e)
103
95
  return False
@@ -117,12 +109,11 @@ def track_event(event_name: str, properties: dict[str, Any] | None = None) -> No
117
109
  return
118
110
 
119
111
  try:
120
- from shotgun import __version__
121
- from shotgun.agents.config import get_config_manager
112
+ import asyncio
122
113
 
123
- # Get user ID for tracking
114
+ # Get shotgun instance ID for tracking
124
115
  config_manager = get_config_manager()
125
- user_id = config_manager.get_user_id()
116
+ shotgun_instance_id = asyncio.run(config_manager.get_shotgun_instance_id())
126
117
 
127
118
  # Add version and environment to properties
128
119
  if properties is None:
@@ -137,7 +128,7 @@ def track_event(event_name: str, properties: dict[str, Any] | None = None) -> No
137
128
 
138
129
  # Track the event using PostHog's capture method
139
130
  _posthog_client.capture(
140
- distinct_id=user_id, event=event_name, properties=properties
131
+ distinct_id=shotgun_instance_id, event=event_name, properties=properties
141
132
  )
142
133
  logger.debug("Tracked PostHog event: %s", event_name)
143
134
  except Exception as e:
@@ -156,3 +147,59 @@ def shutdown() -> None:
156
147
  logger.warning("Error shutting down PostHog: %s", e)
157
148
  finally:
158
149
  _posthog_client = None
150
+
151
+
152
+ class FeedbackKind(StrEnum):
153
+ BUG = "bug"
154
+ FEATURE = "feature"
155
+ OTHER = "other"
156
+
157
+
158
+ class Feedback(BaseModel):
159
+ kind: FeedbackKind
160
+ description: str
161
+ shotgun_instance_id: str
162
+
163
+
164
+ SURVEY_ID = "01999f81-9486-0000-4fa6-9632959f92f3"
165
+ Q_KIND_ID = "aaa5fcc3-88ba-4c24-bcf5-1481fd5efc2b"
166
+ Q_DESCRIPTION_ID = "a0ed6283-5d4b-452c-9160-6768d879db8a"
167
+
168
+
169
+ def submit_feedback_survey(feedback: Feedback) -> None:
170
+ global _posthog_client
171
+ if _posthog_client is None:
172
+ logger.debug("PostHog not initialized, skipping feedback survey")
173
+ return
174
+
175
+ import asyncio
176
+
177
+ config_manager = get_config_manager()
178
+ config = asyncio.run(config_manager.load())
179
+ conversation_manager = ConversationManager()
180
+ conversation = None
181
+ try:
182
+ conversation = asyncio.run(conversation_manager.load())
183
+ except Exception as e:
184
+ logger.debug(f"Failed to load conversation history: {e}")
185
+ last_10_messages = []
186
+ if conversation is not None:
187
+ last_10_messages = conversation.get_agent_messages()[:10]
188
+
189
+ track_event(
190
+ "survey sent",
191
+ properties={
192
+ "$survey_id": SURVEY_ID,
193
+ "$survey_questions": [
194
+ {"id": Q_KIND_ID, "question": "Feedback type"},
195
+ {"id": Q_DESCRIPTION_ID, "question": "Feedback description"},
196
+ ],
197
+ f"$survey_response_{Q_KIND_ID}": feedback.kind,
198
+ f"$survey_response_{Q_DESCRIPTION_ID}": feedback.description,
199
+ "selected_model": config.selected_model.value
200
+ if config.selected_model
201
+ else None,
202
+ "config_version": config.config_version,
203
+ "last_10_messages": last_10_messages, # last 10 messages
204
+ },
205
+ )