shotgun-sh 0.1.0__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 (130) hide show
  1. shotgun/__init__.py +5 -0
  2. shotgun/agents/__init__.py +1 -0
  3. shotgun/agents/agent_manager.py +651 -0
  4. shotgun/agents/common.py +549 -0
  5. shotgun/agents/config/__init__.py +13 -0
  6. shotgun/agents/config/constants.py +17 -0
  7. shotgun/agents/config/manager.py +294 -0
  8. shotgun/agents/config/models.py +185 -0
  9. shotgun/agents/config/provider.py +206 -0
  10. shotgun/agents/conversation_history.py +106 -0
  11. shotgun/agents/conversation_manager.py +105 -0
  12. shotgun/agents/export.py +96 -0
  13. shotgun/agents/history/__init__.py +5 -0
  14. shotgun/agents/history/compaction.py +85 -0
  15. shotgun/agents/history/constants.py +19 -0
  16. shotgun/agents/history/context_extraction.py +108 -0
  17. shotgun/agents/history/history_building.py +104 -0
  18. shotgun/agents/history/history_processors.py +426 -0
  19. shotgun/agents/history/message_utils.py +84 -0
  20. shotgun/agents/history/token_counting.py +429 -0
  21. shotgun/agents/history/token_estimation.py +138 -0
  22. shotgun/agents/messages.py +35 -0
  23. shotgun/agents/models.py +275 -0
  24. shotgun/agents/plan.py +98 -0
  25. shotgun/agents/research.py +108 -0
  26. shotgun/agents/specify.py +98 -0
  27. shotgun/agents/tasks.py +96 -0
  28. shotgun/agents/tools/__init__.py +34 -0
  29. shotgun/agents/tools/codebase/__init__.py +28 -0
  30. shotgun/agents/tools/codebase/codebase_shell.py +256 -0
  31. shotgun/agents/tools/codebase/directory_lister.py +141 -0
  32. shotgun/agents/tools/codebase/file_read.py +144 -0
  33. shotgun/agents/tools/codebase/models.py +252 -0
  34. shotgun/agents/tools/codebase/query_graph.py +67 -0
  35. shotgun/agents/tools/codebase/retrieve_code.py +81 -0
  36. shotgun/agents/tools/file_management.py +218 -0
  37. shotgun/agents/tools/user_interaction.py +37 -0
  38. shotgun/agents/tools/web_search/__init__.py +60 -0
  39. shotgun/agents/tools/web_search/anthropic.py +144 -0
  40. shotgun/agents/tools/web_search/gemini.py +85 -0
  41. shotgun/agents/tools/web_search/openai.py +98 -0
  42. shotgun/agents/tools/web_search/utils.py +20 -0
  43. shotgun/build_constants.py +20 -0
  44. shotgun/cli/__init__.py +1 -0
  45. shotgun/cli/codebase/__init__.py +5 -0
  46. shotgun/cli/codebase/commands.py +202 -0
  47. shotgun/cli/codebase/models.py +21 -0
  48. shotgun/cli/config.py +275 -0
  49. shotgun/cli/export.py +81 -0
  50. shotgun/cli/models.py +10 -0
  51. shotgun/cli/plan.py +73 -0
  52. shotgun/cli/research.py +85 -0
  53. shotgun/cli/specify.py +69 -0
  54. shotgun/cli/tasks.py +78 -0
  55. shotgun/cli/update.py +152 -0
  56. shotgun/cli/utils.py +25 -0
  57. shotgun/codebase/__init__.py +12 -0
  58. shotgun/codebase/core/__init__.py +46 -0
  59. shotgun/codebase/core/change_detector.py +358 -0
  60. shotgun/codebase/core/code_retrieval.py +243 -0
  61. shotgun/codebase/core/ingestor.py +1497 -0
  62. shotgun/codebase/core/language_config.py +297 -0
  63. shotgun/codebase/core/manager.py +1662 -0
  64. shotgun/codebase/core/nl_query.py +331 -0
  65. shotgun/codebase/core/parser_loader.py +128 -0
  66. shotgun/codebase/models.py +111 -0
  67. shotgun/codebase/service.py +206 -0
  68. shotgun/logging_config.py +227 -0
  69. shotgun/main.py +167 -0
  70. shotgun/posthog_telemetry.py +158 -0
  71. shotgun/prompts/__init__.py +5 -0
  72. shotgun/prompts/agents/__init__.py +1 -0
  73. shotgun/prompts/agents/export.j2 +350 -0
  74. shotgun/prompts/agents/partials/codebase_understanding.j2 +87 -0
  75. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +37 -0
  76. shotgun/prompts/agents/partials/content_formatting.j2 +65 -0
  77. shotgun/prompts/agents/partials/interactive_mode.j2 +26 -0
  78. shotgun/prompts/agents/plan.j2 +144 -0
  79. shotgun/prompts/agents/research.j2 +69 -0
  80. shotgun/prompts/agents/specify.j2 +51 -0
  81. shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +19 -0
  82. shotgun/prompts/agents/state/system_state.j2 +31 -0
  83. shotgun/prompts/agents/tasks.j2 +143 -0
  84. shotgun/prompts/codebase/__init__.py +1 -0
  85. shotgun/prompts/codebase/cypher_query_patterns.j2 +223 -0
  86. shotgun/prompts/codebase/cypher_system.j2 +28 -0
  87. shotgun/prompts/codebase/enhanced_query_context.j2 +10 -0
  88. shotgun/prompts/codebase/partials/cypher_rules.j2 +24 -0
  89. shotgun/prompts/codebase/partials/graph_schema.j2 +30 -0
  90. shotgun/prompts/codebase/partials/temporal_context.j2 +21 -0
  91. shotgun/prompts/history/__init__.py +1 -0
  92. shotgun/prompts/history/incremental_summarization.j2 +53 -0
  93. shotgun/prompts/history/summarization.j2 +46 -0
  94. shotgun/prompts/loader.py +140 -0
  95. shotgun/py.typed +0 -0
  96. shotgun/sdk/__init__.py +13 -0
  97. shotgun/sdk/codebase.py +219 -0
  98. shotgun/sdk/exceptions.py +17 -0
  99. shotgun/sdk/models.py +189 -0
  100. shotgun/sdk/services.py +23 -0
  101. shotgun/sentry_telemetry.py +87 -0
  102. shotgun/telemetry.py +93 -0
  103. shotgun/tui/__init__.py +0 -0
  104. shotgun/tui/app.py +116 -0
  105. shotgun/tui/commands/__init__.py +76 -0
  106. shotgun/tui/components/prompt_input.py +69 -0
  107. shotgun/tui/components/spinner.py +86 -0
  108. shotgun/tui/components/splash.py +25 -0
  109. shotgun/tui/components/vertical_tail.py +13 -0
  110. shotgun/tui/screens/chat.py +782 -0
  111. shotgun/tui/screens/chat.tcss +43 -0
  112. shotgun/tui/screens/chat_screen/__init__.py +0 -0
  113. shotgun/tui/screens/chat_screen/command_providers.py +219 -0
  114. shotgun/tui/screens/chat_screen/hint_message.py +40 -0
  115. shotgun/tui/screens/chat_screen/history.py +221 -0
  116. shotgun/tui/screens/directory_setup.py +113 -0
  117. shotgun/tui/screens/provider_config.py +221 -0
  118. shotgun/tui/screens/splash.py +31 -0
  119. shotgun/tui/styles.tcss +10 -0
  120. shotgun/tui/utils/__init__.py +5 -0
  121. shotgun/tui/utils/mode_progress.py +257 -0
  122. shotgun/utils/__init__.py +5 -0
  123. shotgun/utils/env_utils.py +35 -0
  124. shotgun/utils/file_system_utils.py +36 -0
  125. shotgun/utils/update_checker.py +375 -0
  126. shotgun_sh-0.1.0.dist-info/METADATA +466 -0
  127. shotgun_sh-0.1.0.dist-info/RECORD +130 -0
  128. shotgun_sh-0.1.0.dist-info/WHEEL +4 -0
  129. shotgun_sh-0.1.0.dist-info/entry_points.txt +2 -0
  130. shotgun_sh-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,206 @@
1
+ """High-level service for managing codebase graphs and executing queries."""
2
+
3
+ import time
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from shotgun.codebase.core.manager import CodebaseGraphManager
8
+ from shotgun.codebase.core.nl_query import generate_cypher
9
+ from shotgun.codebase.models import CodebaseGraph, QueryResult, QueryType
10
+ from shotgun.logging_config import get_logger
11
+
12
+ logger = get_logger(__name__)
13
+
14
+
15
+ class CodebaseService:
16
+ """High-level service for codebase graph management and querying."""
17
+
18
+ def __init__(self, storage_dir: Path | str):
19
+ """Initialize the service.
20
+
21
+ Args:
22
+ storage_dir: Directory to store graph databases
23
+ """
24
+ if isinstance(storage_dir, str):
25
+ storage_dir = Path(storage_dir)
26
+
27
+ self.storage_dir = storage_dir
28
+ self.storage_dir.mkdir(parents=True, exist_ok=True)
29
+ self.manager = CodebaseGraphManager(storage_dir)
30
+
31
+ async def list_graphs(self) -> list[CodebaseGraph]:
32
+ """List all existing graphs.
33
+
34
+ Returns:
35
+ List of CodebaseGraph objects
36
+ """
37
+ return await self.manager.list_graphs()
38
+
39
+ async def list_graphs_for_directory(
40
+ self, directory: Path | str | None = None
41
+ ) -> list[CodebaseGraph]:
42
+ """List graphs that match a specific directory.
43
+
44
+ Args:
45
+ directory: Directory to filter by. If None, uses current working directory.
46
+
47
+ Returns:
48
+ List of CodebaseGraph objects accessible from the specified directory
49
+ """
50
+ from pathlib import Path
51
+
52
+ if directory is None:
53
+ directory = Path.cwd()
54
+ elif isinstance(directory, str):
55
+ directory = Path(directory)
56
+
57
+ # Resolve to absolute path for comparison
58
+ target_path = str(directory.resolve())
59
+
60
+ # Get all graphs and filter by those accessible from this directory
61
+ all_graphs = await self.manager.list_graphs()
62
+ filtered_graphs = []
63
+
64
+ for graph in all_graphs:
65
+ # If indexed_from_cwds is empty, it's globally accessible (backward compatibility)
66
+ if not graph.indexed_from_cwds:
67
+ filtered_graphs.append(graph)
68
+ # Otherwise, check if current directory is in the allowed list
69
+ elif target_path in graph.indexed_from_cwds:
70
+ filtered_graphs.append(graph)
71
+
72
+ return filtered_graphs
73
+
74
+ async def create_graph(
75
+ self, repo_path: str | Path, name: str, indexed_from_cwd: str | None = None
76
+ ) -> CodebaseGraph:
77
+ """Create and index a new graph from a repository.
78
+
79
+ Args:
80
+ repo_path: Path to the repository to index
81
+ name: Human-readable name for the graph
82
+ indexed_from_cwd: Working directory from which indexing was initiated
83
+
84
+ Returns:
85
+ The created CodebaseGraph
86
+ """
87
+ return await self.manager.build_graph(
88
+ str(repo_path), name, indexed_from_cwd=indexed_from_cwd
89
+ )
90
+
91
+ async def get_graph(self, graph_id: str) -> CodebaseGraph | None:
92
+ """Get graph metadata by ID.
93
+
94
+ Args:
95
+ graph_id: Graph ID to retrieve
96
+
97
+ Returns:
98
+ CodebaseGraph object or None if not found
99
+ """
100
+ return await self.manager.get_graph(graph_id)
101
+
102
+ async def add_cwd_access(self, graph_id: str, cwd: str | None = None) -> None:
103
+ """Add a working directory to a graph's access list.
104
+
105
+ Args:
106
+ graph_id: Graph ID to update
107
+ cwd: Working directory to add. If None, uses current working directory.
108
+ """
109
+ await self.manager.add_cwd_access(graph_id, cwd)
110
+
111
+ async def remove_cwd_access(self, graph_id: str, cwd: str) -> None:
112
+ """Remove a working directory from a graph's access list.
113
+
114
+ Args:
115
+ graph_id: Graph ID to update
116
+ cwd: Working directory to remove
117
+ """
118
+ await self.manager.remove_cwd_access(graph_id, cwd)
119
+
120
+ async def delete_graph(self, graph_id: str) -> None:
121
+ """Delete a graph and its data.
122
+
123
+ Args:
124
+ graph_id: Graph ID to delete
125
+ """
126
+ await self.manager.delete_graph(graph_id)
127
+
128
+ async def reindex_graph(self, graph_id: str) -> dict[str, Any]:
129
+ """Rebuild an existing graph (full reindex).
130
+
131
+ Args:
132
+ graph_id: Graph ID to reindex
133
+
134
+ Returns:
135
+ Statistics from the reindex operation
136
+ """
137
+ return await self.manager.update_graph_incremental(graph_id)
138
+
139
+ async def execute_query(
140
+ self,
141
+ graph_id: str,
142
+ query: str,
143
+ query_type: QueryType,
144
+ parameters: dict[str, Any] | None = None,
145
+ ) -> QueryResult:
146
+ """Execute a query against a graph.
147
+
148
+ Args:
149
+ graph_id: Graph ID to query
150
+ query: The query (natural language or Cypher)
151
+ query_type: Type of query being executed
152
+ parameters: Optional parameters for Cypher queries
153
+
154
+ Returns:
155
+ QueryResult with results and metadata
156
+ """
157
+ start_time = time.time()
158
+ cypher_query = None
159
+
160
+ try:
161
+ # Handle query type conversion
162
+ if query_type == QueryType.NATURAL_LANGUAGE:
163
+ logger.info(f"Converting natural language query to Cypher: {query}")
164
+ cypher_query = await generate_cypher(query)
165
+ logger.info(f"Generated Cypher: {cypher_query}")
166
+ execute_query = cypher_query
167
+ else:
168
+ execute_query = query
169
+
170
+ # Execute the query
171
+ results = await self.manager.execute_query(
172
+ graph_id=graph_id, query=execute_query, parameters=parameters
173
+ )
174
+
175
+ # Extract column names from first result
176
+ column_names = list(results[0].keys()) if results else []
177
+
178
+ execution_time = (time.time() - start_time) * 1000
179
+
180
+ return QueryResult(
181
+ query=query,
182
+ cypher_query=cypher_query
183
+ if query_type == QueryType.NATURAL_LANGUAGE
184
+ else None,
185
+ results=results,
186
+ column_names=column_names,
187
+ row_count=len(results),
188
+ execution_time_ms=execution_time,
189
+ success=True,
190
+ error=None,
191
+ )
192
+
193
+ except Exception as e:
194
+ execution_time = (time.time() - start_time) * 1000
195
+ logger.error(f"Query execution failed: {e}")
196
+
197
+ return QueryResult(
198
+ query=query,
199
+ cypher_query=cypher_query,
200
+ results=[],
201
+ column_names=[],
202
+ row_count=0,
203
+ execution_time_ms=execution_time,
204
+ success=False,
205
+ error=str(e),
206
+ )
@@ -0,0 +1,227 @@
1
+ """Centralized logging configuration for Shotgun CLI."""
2
+
3
+ import logging
4
+ import logging.handlers
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from shotgun.utils.env_utils import is_truthy
10
+
11
+
12
+ def get_log_directory() -> Path:
13
+ """Get the log directory path, creating it if necessary.
14
+
15
+ Returns:
16
+ Path to log directory (~/.shotgun-sh/logs/)
17
+ """
18
+ # Lazy import to avoid circular dependency
19
+ from shotgun.utils.file_system_utils import get_shotgun_home
20
+
21
+ log_dir = get_shotgun_home() / "logs"
22
+ log_dir.mkdir(parents=True, exist_ok=True)
23
+ return log_dir
24
+
25
+
26
+ class ColoredFormatter(logging.Formatter):
27
+ """Custom formatter with colors for different log levels."""
28
+
29
+ # ANSI color codes
30
+ COLORS = {
31
+ "DEBUG": "\033[36m", # Cyan
32
+ "INFO": "\033[32m", # Green
33
+ "WARNING": "\033[33m", # Yellow
34
+ "ERROR": "\033[31m", # Red
35
+ "CRITICAL": "\033[35m", # Magenta
36
+ }
37
+ RESET = "\033[0m"
38
+
39
+ def format(self, record: logging.LogRecord) -> str:
40
+ # Create a copy of the record to avoid modifying the original
41
+ record = logging.makeLogRecord(record.__dict__)
42
+
43
+ # Add color to levelname
44
+ if record.levelname in self.COLORS:
45
+ colored_levelname = (
46
+ f"{self.COLORS[record.levelname]}{record.levelname}{self.RESET}"
47
+ )
48
+ record.levelname = colored_levelname
49
+
50
+ return super().format(record)
51
+
52
+
53
+ def setup_logger(
54
+ name: str,
55
+ format_string: str | None = None,
56
+ ) -> logging.Logger:
57
+ """Set up a logger with consistent configuration.
58
+
59
+ Args:
60
+ name: Logger name (typically __name__)
61
+ format_string: Custom format string, uses default if None
62
+
63
+ Returns:
64
+ Configured logger instance
65
+ """
66
+ logger = logging.getLogger(name)
67
+
68
+ # 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
+
74
+ # If we already have a file handler, just return the logger
75
+ if has_file_handler:
76
+ return logger
77
+
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"
82
+
83
+ logger.setLevel(getattr(logging, env_level))
84
+
85
+ # Default format string
86
+ if format_string is None:
87
+ format_string = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
88
+
89
+ # Check if this is a dev build with Logfire enabled
90
+ is_logfire_dev_build = False
91
+ try:
92
+ from shotgun.build_constants import IS_DEV_BUILD, LOGFIRE_ENABLED
93
+
94
+ if IS_DEV_BUILD and is_truthy(LOGFIRE_ENABLED):
95
+ is_logfire_dev_build = True
96
+ # This debug message will only appear in file logs
97
+ logger.debug("Console logging disabled for Logfire dev build")
98
+ except ImportError:
99
+ # No build constants available (local development)
100
+ pass
101
+
102
+ # Check if console logging is enabled (default: off)
103
+ # Force console logging OFF if Logfire is enabled in dev build
104
+ console_logging_enabled = (
105
+ is_truthy(os.getenv("LOGGING_TO_CONSOLE", "false")) and not is_logfire_dev_build
106
+ )
107
+
108
+ if console_logging_enabled:
109
+ # Create console handler
110
+ console_handler = logging.StreamHandler(sys.stdout)
111
+ console_handler.setLevel(getattr(logging, env_level))
112
+
113
+ # Use colored formatter for console
114
+ console_formatter = ColoredFormatter(format_string, datefmt="%H:%M:%S")
115
+ console_handler.setFormatter(console_formatter)
116
+
117
+ # Add handler to logger
118
+ logger.addHandler(console_handler)
119
+
120
+ # Check if file logging is enabled (default: on)
121
+ file_logging_enabled = is_truthy(os.getenv("LOGGING_TO_FILE", "true"))
122
+
123
+ if file_logging_enabled:
124
+ try:
125
+ # Create file handler with rotation
126
+ log_dir = get_log_directory()
127
+ log_file = log_dir / "shotgun.log"
128
+
129
+ # Use TimedRotatingFileHandler - rotates daily and keeps 7 days of logs
130
+ file_handler = logging.handlers.TimedRotatingFileHandler(
131
+ filename=log_file,
132
+ when="midnight", # Rotate at midnight
133
+ interval=1, # Every 1 day
134
+ backupCount=7, # Keep 7 days of logs
135
+ encoding="utf-8",
136
+ )
137
+
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))
141
+
142
+ # Use standard formatter for file (no colors)
143
+ file_formatter = logging.Formatter(
144
+ format_string, datefmt="%Y-%m-%d %H:%M:%S"
145
+ )
146
+ file_handler.setFormatter(file_formatter)
147
+
148
+ # Add handler to logger
149
+ logger.addHandler(file_handler)
150
+ except Exception as e:
151
+ # If file logging fails, log to stderr but don't crash
152
+ print(f"Warning: Could not set up file logging: {e}", file=sys.stderr)
153
+
154
+ # Prevent propagation to avoid duplicate messages from parent loggers
155
+ if name != "shotgun": # Keep propagation for root logger
156
+ logger.propagate = False
157
+
158
+ return logger
159
+
160
+
161
+ def get_early_logger(name: str) -> logging.Logger:
162
+ """Get a logger with NullHandler for early initialization.
163
+
164
+ Use this for loggers created at module import time, before
165
+ configure_root_logger() is called. The NullHandler prevents
166
+ Python from automatically adding a StreamHandler when WARNING
167
+ or ERROR messages are logged.
168
+
169
+ Args:
170
+ name: Logger name (typically __name__)
171
+
172
+ Returns:
173
+ Logger with NullHandler attached
174
+ """
175
+ logger = logging.getLogger(name)
176
+ # Only add NullHandler if no handlers exist
177
+ if not logger.handlers:
178
+ logger.addHandler(logging.NullHandler())
179
+ return logger
180
+
181
+
182
+ def get_logger(name: str) -> logging.Logger:
183
+ """Get a logger instance with default configuration.
184
+
185
+ Args:
186
+ name: Logger name (typically __name__)
187
+
188
+ Returns:
189
+ Logger instance with handlers configured
190
+ """
191
+ logger = logging.getLogger(name)
192
+
193
+ # 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
+ )
198
+
199
+ # If no file handler, set up the logger (will add file handler)
200
+ if not has_file_handler:
201
+ return setup_logger(name)
202
+
203
+ return logger
204
+
205
+
206
+ def set_global_log_level(level: str) -> None:
207
+ """Set log level for all shotgun loggers.
208
+
209
+ Args:
210
+ level: Log level ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
211
+ """
212
+ # Set level for all existing shotgun loggers
213
+ for name, logger in logging.getLogger().manager.loggerDict.items():
214
+ if isinstance(logger, logging.Logger) and name.startswith("shotgun"):
215
+ logger.setLevel(getattr(logging, level.upper()))
216
+ # Only set handler levels if handlers exist
217
+ for handler in logger.handlers:
218
+ handler.setLevel(getattr(logging, level.upper()))
219
+
220
+
221
+ def configure_root_logger() -> None:
222
+ """Configure the root shotgun logger."""
223
+ # Always set up the root logger to ensure file handler is added
224
+ setup_logger("shotgun")
225
+
226
+ # Also ensure main module gets configured
227
+ setup_logger("__main__")
shotgun/main.py ADDED
@@ -0,0 +1,167 @@
1
+ """Main CLI application for shotgun."""
2
+
3
+ import logging
4
+
5
+ # CRITICAL: Add NullHandler to root logger before ANY other imports.
6
+ # This prevents Python from automatically adding a StreamHandler when
7
+ # WARNING/ERROR messages are logged by modules during import.
8
+ # DO NOT MOVE THIS BELOW OTHER IMPORTS.
9
+ logging.getLogger().addHandler(logging.NullHandler())
10
+
11
+ # ruff: noqa: E402 (module import not at top - intentionally after NullHandler setup)
12
+ from typing import Annotated
13
+
14
+ import typer
15
+ from dotenv import load_dotenv
16
+
17
+ from shotgun import __version__
18
+ from shotgun.agents.config import get_config_manager
19
+ from shotgun.cli import codebase, config, export, plan, research, specify, tasks, update
20
+ from shotgun.logging_config import configure_root_logger, get_logger
21
+ from shotgun.posthog_telemetry import setup_posthog_observability
22
+ from shotgun.sentry_telemetry import setup_sentry_observability
23
+ from shotgun.telemetry import setup_logfire_observability
24
+ from shotgun.tui import app as tui_app
25
+ from shotgun.utils.update_checker import check_for_updates_async
26
+
27
+ # Load environment variables from .env file
28
+ load_dotenv()
29
+
30
+ # Initialize telemetry FIRST (before logging setup to prevent handler conflicts)
31
+ _logfire_enabled = setup_logfire_observability()
32
+
33
+ # Initialize logging AFTER telemetry
34
+ configure_root_logger()
35
+ logger = get_logger(__name__)
36
+ logger.debug("Logfire observability enabled: %s", _logfire_enabled)
37
+
38
+ # Initialize configuration
39
+ try:
40
+ config_manager = get_config_manager()
41
+ config_manager.load() # Ensure config is loaded at startup
42
+ except Exception as e:
43
+ logger.debug("Configuration initialization warning: %s", e)
44
+
45
+ # Initialize Sentry telemetry
46
+ _sentry_enabled = setup_sentry_observability()
47
+ logger.debug("Sentry observability enabled: %s", _sentry_enabled)
48
+
49
+ # Initialize PostHog analytics
50
+ _posthog_enabled = setup_posthog_observability()
51
+ logger.debug("PostHog analytics enabled: %s", _posthog_enabled)
52
+
53
+ # Global variable to store update notification
54
+ _update_notification: str | None = None
55
+
56
+
57
+ def _update_callback(notification: str) -> None:
58
+ """Callback to store update notification."""
59
+ global _update_notification
60
+ _update_notification = notification
61
+
62
+
63
+ app = typer.Typer(
64
+ name="shotgun",
65
+ help="Shotgun - AI-powered CLI tool for research, planning, and task management",
66
+ rich_markup_mode="rich",
67
+ )
68
+
69
+ # Add commands
70
+ app.add_typer(config.app, name="config", help="Manage Shotgun configuration")
71
+ app.add_typer(
72
+ codebase.app, name="codebase", help="Manage and query code knowledge graphs"
73
+ )
74
+ app.add_typer(research.app, name="research", help="Perform research with agentic loops")
75
+ app.add_typer(plan.app, name="plan", help="Generate structured plans")
76
+ app.add_typer(specify.app, name="specify", help="Generate comprehensive specifications")
77
+ app.add_typer(tasks.app, name="tasks", help="Generate task lists with agentic approach")
78
+ app.add_typer(export.app, name="export", help="Export artifacts to various formats")
79
+ app.add_typer(update.app, name="update", help="Check for and install updates")
80
+
81
+
82
+ def version_callback(value: bool) -> None:
83
+ """Show version and exit."""
84
+ if value:
85
+ from rich.console import Console
86
+
87
+ console = Console()
88
+ console.print(f"shotgun {__version__}")
89
+ raise typer.Exit()
90
+
91
+
92
+ @app.callback(invoke_without_command=True)
93
+ def main(
94
+ ctx: typer.Context,
95
+ version: Annotated[
96
+ bool,
97
+ typer.Option(
98
+ "--version",
99
+ "-v",
100
+ callback=version_callback,
101
+ is_eager=True,
102
+ help="Show version and exit",
103
+ ),
104
+ ] = False,
105
+ no_update_check: Annotated[
106
+ bool,
107
+ typer.Option(
108
+ "--no-update-check",
109
+ help="Disable automatic update checks",
110
+ ),
111
+ ] = False,
112
+ continue_session: Annotated[
113
+ bool,
114
+ typer.Option(
115
+ "--continue",
116
+ "-c",
117
+ help="Continue previous TUI conversation",
118
+ ),
119
+ ] = False,
120
+ ) -> None:
121
+ """Shotgun - AI-powered CLI tool."""
122
+ logger.debug("Starting shotgun CLI application")
123
+
124
+ # Start async update check (non-blocking)
125
+ if not ctx.resilient_parsing:
126
+ check_for_updates_async(
127
+ callback=_update_callback, no_update_check=no_update_check
128
+ )
129
+
130
+ if ctx.invoked_subcommand is None and not ctx.resilient_parsing:
131
+ logger.debug("Launching shotgun TUI application")
132
+ tui_app.run(no_update_check=no_update_check, continue_session=continue_session)
133
+
134
+ # Show update notification after TUI exits
135
+ if _update_notification:
136
+ from rich.console import Console
137
+
138
+ console = Console()
139
+ console.print(f"\n[cyan]{_update_notification}[/cyan]", style="bold")
140
+
141
+ raise typer.Exit()
142
+
143
+ # For CLI commands, we'll show notification at the end
144
+ # This is handled by registering an atexit handler
145
+ if not ctx.resilient_parsing and ctx.invoked_subcommand is not None:
146
+ import atexit
147
+
148
+ def show_update_notification() -> None:
149
+ if _update_notification:
150
+ from rich.console import Console
151
+
152
+ console = Console()
153
+ console.print(f"\n[cyan]{_update_notification}[/cyan]", style="bold")
154
+
155
+ atexit.register(show_update_notification)
156
+
157
+ # Register PostHog shutdown handler
158
+ def shutdown_posthog() -> None:
159
+ from shotgun.posthog_telemetry import shutdown
160
+
161
+ shutdown()
162
+
163
+ atexit.register(shutdown_posthog)
164
+
165
+
166
+ if __name__ == "__main__":
167
+ app()