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.
- shotgun/__init__.py +5 -0
- shotgun/agents/__init__.py +1 -0
- shotgun/agents/agent_manager.py +651 -0
- shotgun/agents/common.py +549 -0
- shotgun/agents/config/__init__.py +13 -0
- shotgun/agents/config/constants.py +17 -0
- shotgun/agents/config/manager.py +294 -0
- shotgun/agents/config/models.py +185 -0
- shotgun/agents/config/provider.py +206 -0
- shotgun/agents/conversation_history.py +106 -0
- shotgun/agents/conversation_manager.py +105 -0
- shotgun/agents/export.py +96 -0
- shotgun/agents/history/__init__.py +5 -0
- shotgun/agents/history/compaction.py +85 -0
- shotgun/agents/history/constants.py +19 -0
- shotgun/agents/history/context_extraction.py +108 -0
- shotgun/agents/history/history_building.py +104 -0
- shotgun/agents/history/history_processors.py +426 -0
- shotgun/agents/history/message_utils.py +84 -0
- shotgun/agents/history/token_counting.py +429 -0
- shotgun/agents/history/token_estimation.py +138 -0
- shotgun/agents/messages.py +35 -0
- shotgun/agents/models.py +275 -0
- shotgun/agents/plan.py +98 -0
- shotgun/agents/research.py +108 -0
- shotgun/agents/specify.py +98 -0
- shotgun/agents/tasks.py +96 -0
- shotgun/agents/tools/__init__.py +34 -0
- shotgun/agents/tools/codebase/__init__.py +28 -0
- shotgun/agents/tools/codebase/codebase_shell.py +256 -0
- shotgun/agents/tools/codebase/directory_lister.py +141 -0
- shotgun/agents/tools/codebase/file_read.py +144 -0
- shotgun/agents/tools/codebase/models.py +252 -0
- shotgun/agents/tools/codebase/query_graph.py +67 -0
- shotgun/agents/tools/codebase/retrieve_code.py +81 -0
- shotgun/agents/tools/file_management.py +218 -0
- shotgun/agents/tools/user_interaction.py +37 -0
- shotgun/agents/tools/web_search/__init__.py +60 -0
- shotgun/agents/tools/web_search/anthropic.py +144 -0
- shotgun/agents/tools/web_search/gemini.py +85 -0
- shotgun/agents/tools/web_search/openai.py +98 -0
- shotgun/agents/tools/web_search/utils.py +20 -0
- shotgun/build_constants.py +20 -0
- shotgun/cli/__init__.py +1 -0
- shotgun/cli/codebase/__init__.py +5 -0
- shotgun/cli/codebase/commands.py +202 -0
- shotgun/cli/codebase/models.py +21 -0
- shotgun/cli/config.py +275 -0
- shotgun/cli/export.py +81 -0
- shotgun/cli/models.py +10 -0
- shotgun/cli/plan.py +73 -0
- shotgun/cli/research.py +85 -0
- shotgun/cli/specify.py +69 -0
- shotgun/cli/tasks.py +78 -0
- shotgun/cli/update.py +152 -0
- shotgun/cli/utils.py +25 -0
- shotgun/codebase/__init__.py +12 -0
- shotgun/codebase/core/__init__.py +46 -0
- shotgun/codebase/core/change_detector.py +358 -0
- shotgun/codebase/core/code_retrieval.py +243 -0
- shotgun/codebase/core/ingestor.py +1497 -0
- shotgun/codebase/core/language_config.py +297 -0
- shotgun/codebase/core/manager.py +1662 -0
- shotgun/codebase/core/nl_query.py +331 -0
- shotgun/codebase/core/parser_loader.py +128 -0
- shotgun/codebase/models.py +111 -0
- shotgun/codebase/service.py +206 -0
- shotgun/logging_config.py +227 -0
- shotgun/main.py +167 -0
- shotgun/posthog_telemetry.py +158 -0
- shotgun/prompts/__init__.py +5 -0
- shotgun/prompts/agents/__init__.py +1 -0
- shotgun/prompts/agents/export.j2 +350 -0
- shotgun/prompts/agents/partials/codebase_understanding.j2 +87 -0
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +37 -0
- shotgun/prompts/agents/partials/content_formatting.j2 +65 -0
- shotgun/prompts/agents/partials/interactive_mode.j2 +26 -0
- shotgun/prompts/agents/plan.j2 +144 -0
- shotgun/prompts/agents/research.j2 +69 -0
- shotgun/prompts/agents/specify.j2 +51 -0
- shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +19 -0
- shotgun/prompts/agents/state/system_state.j2 +31 -0
- shotgun/prompts/agents/tasks.j2 +143 -0
- shotgun/prompts/codebase/__init__.py +1 -0
- shotgun/prompts/codebase/cypher_query_patterns.j2 +223 -0
- shotgun/prompts/codebase/cypher_system.j2 +28 -0
- shotgun/prompts/codebase/enhanced_query_context.j2 +10 -0
- shotgun/prompts/codebase/partials/cypher_rules.j2 +24 -0
- shotgun/prompts/codebase/partials/graph_schema.j2 +30 -0
- shotgun/prompts/codebase/partials/temporal_context.j2 +21 -0
- shotgun/prompts/history/__init__.py +1 -0
- shotgun/prompts/history/incremental_summarization.j2 +53 -0
- shotgun/prompts/history/summarization.j2 +46 -0
- shotgun/prompts/loader.py +140 -0
- shotgun/py.typed +0 -0
- shotgun/sdk/__init__.py +13 -0
- shotgun/sdk/codebase.py +219 -0
- shotgun/sdk/exceptions.py +17 -0
- shotgun/sdk/models.py +189 -0
- shotgun/sdk/services.py +23 -0
- shotgun/sentry_telemetry.py +87 -0
- shotgun/telemetry.py +93 -0
- shotgun/tui/__init__.py +0 -0
- shotgun/tui/app.py +116 -0
- shotgun/tui/commands/__init__.py +76 -0
- shotgun/tui/components/prompt_input.py +69 -0
- shotgun/tui/components/spinner.py +86 -0
- shotgun/tui/components/splash.py +25 -0
- shotgun/tui/components/vertical_tail.py +13 -0
- shotgun/tui/screens/chat.py +782 -0
- shotgun/tui/screens/chat.tcss +43 -0
- shotgun/tui/screens/chat_screen/__init__.py +0 -0
- shotgun/tui/screens/chat_screen/command_providers.py +219 -0
- shotgun/tui/screens/chat_screen/hint_message.py +40 -0
- shotgun/tui/screens/chat_screen/history.py +221 -0
- shotgun/tui/screens/directory_setup.py +113 -0
- shotgun/tui/screens/provider_config.py +221 -0
- shotgun/tui/screens/splash.py +31 -0
- shotgun/tui/styles.tcss +10 -0
- shotgun/tui/utils/__init__.py +5 -0
- shotgun/tui/utils/mode_progress.py +257 -0
- shotgun/utils/__init__.py +5 -0
- shotgun/utils/env_utils.py +35 -0
- shotgun/utils/file_system_utils.py +36 -0
- shotgun/utils/update_checker.py +375 -0
- shotgun_sh-0.1.0.dist-info/METADATA +466 -0
- shotgun_sh-0.1.0.dist-info/RECORD +130 -0
- shotgun_sh-0.1.0.dist-info/WHEEL +4 -0
- shotgun_sh-0.1.0.dist-info/entry_points.txt +2 -0
- 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()
|