shotgun-sh 0.3.3.dev1__py3-none-any.whl → 0.6.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. shotgun/agents/agent_manager.py +497 -30
  2. shotgun/agents/cancellation.py +103 -0
  3. shotgun/agents/common.py +90 -77
  4. shotgun/agents/config/README.md +0 -1
  5. shotgun/agents/config/manager.py +52 -8
  6. shotgun/agents/config/models.py +21 -27
  7. shotgun/agents/config/provider.py +44 -27
  8. shotgun/agents/conversation/history/file_content_deduplication.py +66 -43
  9. shotgun/agents/conversation/history/token_counting/base.py +51 -9
  10. shotgun/agents/export.py +12 -13
  11. shotgun/agents/file_read.py +176 -0
  12. shotgun/agents/messages.py +15 -3
  13. shotgun/agents/models.py +90 -2
  14. shotgun/agents/plan.py +12 -13
  15. shotgun/agents/research.py +13 -10
  16. shotgun/agents/router/__init__.py +47 -0
  17. shotgun/agents/router/models.py +384 -0
  18. shotgun/agents/router/router.py +185 -0
  19. shotgun/agents/router/tools/__init__.py +18 -0
  20. shotgun/agents/router/tools/delegation_tools.py +557 -0
  21. shotgun/agents/router/tools/plan_tools.py +403 -0
  22. shotgun/agents/runner.py +17 -2
  23. shotgun/agents/specify.py +12 -13
  24. shotgun/agents/tasks.py +12 -13
  25. shotgun/agents/tools/__init__.py +8 -0
  26. shotgun/agents/tools/codebase/directory_lister.py +27 -39
  27. shotgun/agents/tools/codebase/file_read.py +26 -35
  28. shotgun/agents/tools/codebase/query_graph.py +9 -0
  29. shotgun/agents/tools/codebase/retrieve_code.py +9 -0
  30. shotgun/agents/tools/file_management.py +81 -3
  31. shotgun/agents/tools/file_read_tools/__init__.py +7 -0
  32. shotgun/agents/tools/file_read_tools/multimodal_file_read.py +167 -0
  33. shotgun/agents/tools/markdown_tools/__init__.py +62 -0
  34. shotgun/agents/tools/markdown_tools/insert_section.py +148 -0
  35. shotgun/agents/tools/markdown_tools/models.py +86 -0
  36. shotgun/agents/tools/markdown_tools/remove_section.py +114 -0
  37. shotgun/agents/tools/markdown_tools/replace_section.py +119 -0
  38. shotgun/agents/tools/markdown_tools/utils.py +453 -0
  39. shotgun/agents/tools/registry.py +46 -6
  40. shotgun/agents/tools/web_search/__init__.py +1 -2
  41. shotgun/agents/tools/web_search/gemini.py +1 -3
  42. shotgun/agents/tools/web_search/openai.py +42 -23
  43. shotgun/attachments/__init__.py +41 -0
  44. shotgun/attachments/errors.py +60 -0
  45. shotgun/attachments/models.py +107 -0
  46. shotgun/attachments/parser.py +257 -0
  47. shotgun/attachments/processor.py +193 -0
  48. shotgun/build_constants.py +4 -7
  49. shotgun/cli/clear.py +2 -2
  50. shotgun/cli/codebase/commands.py +181 -65
  51. shotgun/cli/compact.py +2 -2
  52. shotgun/cli/context.py +2 -2
  53. shotgun/cli/error_handler.py +2 -2
  54. shotgun/cli/run.py +90 -0
  55. shotgun/cli/spec/backup.py +2 -1
  56. shotgun/codebase/__init__.py +2 -0
  57. shotgun/codebase/benchmarks/__init__.py +35 -0
  58. shotgun/codebase/benchmarks/benchmark_runner.py +309 -0
  59. shotgun/codebase/benchmarks/exporters.py +119 -0
  60. shotgun/codebase/benchmarks/formatters/__init__.py +49 -0
  61. shotgun/codebase/benchmarks/formatters/base.py +34 -0
  62. shotgun/codebase/benchmarks/formatters/json_formatter.py +106 -0
  63. shotgun/codebase/benchmarks/formatters/markdown.py +136 -0
  64. shotgun/codebase/benchmarks/models.py +129 -0
  65. shotgun/codebase/core/__init__.py +4 -0
  66. shotgun/codebase/core/call_resolution.py +91 -0
  67. shotgun/codebase/core/change_detector.py +11 -6
  68. shotgun/codebase/core/errors.py +159 -0
  69. shotgun/codebase/core/extractors/__init__.py +23 -0
  70. shotgun/codebase/core/extractors/base.py +138 -0
  71. shotgun/codebase/core/extractors/factory.py +63 -0
  72. shotgun/codebase/core/extractors/go/__init__.py +7 -0
  73. shotgun/codebase/core/extractors/go/extractor.py +122 -0
  74. shotgun/codebase/core/extractors/javascript/__init__.py +7 -0
  75. shotgun/codebase/core/extractors/javascript/extractor.py +132 -0
  76. shotgun/codebase/core/extractors/protocol.py +109 -0
  77. shotgun/codebase/core/extractors/python/__init__.py +7 -0
  78. shotgun/codebase/core/extractors/python/extractor.py +141 -0
  79. shotgun/codebase/core/extractors/rust/__init__.py +7 -0
  80. shotgun/codebase/core/extractors/rust/extractor.py +139 -0
  81. shotgun/codebase/core/extractors/types.py +15 -0
  82. shotgun/codebase/core/extractors/typescript/__init__.py +7 -0
  83. shotgun/codebase/core/extractors/typescript/extractor.py +92 -0
  84. shotgun/codebase/core/gitignore.py +252 -0
  85. shotgun/codebase/core/ingestor.py +644 -354
  86. shotgun/codebase/core/kuzu_compat.py +119 -0
  87. shotgun/codebase/core/language_config.py +239 -0
  88. shotgun/codebase/core/manager.py +256 -46
  89. shotgun/codebase/core/metrics_collector.py +310 -0
  90. shotgun/codebase/core/metrics_types.py +347 -0
  91. shotgun/codebase/core/parallel_executor.py +424 -0
  92. shotgun/codebase/core/work_distributor.py +254 -0
  93. shotgun/codebase/core/worker.py +768 -0
  94. shotgun/codebase/indexing_state.py +86 -0
  95. shotgun/codebase/models.py +94 -0
  96. shotgun/codebase/service.py +13 -0
  97. shotgun/exceptions.py +9 -9
  98. shotgun/main.py +3 -16
  99. shotgun/posthog_telemetry.py +165 -24
  100. shotgun/prompts/agents/export.j2 +2 -0
  101. shotgun/prompts/agents/file_read.j2 +48 -0
  102. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +19 -52
  103. shotgun/prompts/agents/partials/content_formatting.j2 +12 -33
  104. shotgun/prompts/agents/partials/interactive_mode.j2 +9 -32
  105. shotgun/prompts/agents/partials/router_delegation_mode.j2 +35 -0
  106. shotgun/prompts/agents/plan.j2 +38 -12
  107. shotgun/prompts/agents/research.j2 +70 -31
  108. shotgun/prompts/agents/router.j2 +713 -0
  109. shotgun/prompts/agents/specify.j2 +53 -16
  110. shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +14 -1
  111. shotgun/prompts/agents/state/system_state.j2 +24 -13
  112. shotgun/prompts/agents/tasks.j2 +72 -34
  113. shotgun/settings.py +49 -10
  114. shotgun/tui/app.py +154 -24
  115. shotgun/tui/commands/__init__.py +9 -1
  116. shotgun/tui/components/attachment_bar.py +87 -0
  117. shotgun/tui/components/mode_indicator.py +120 -25
  118. shotgun/tui/components/prompt_input.py +25 -28
  119. shotgun/tui/components/status_bar.py +14 -7
  120. shotgun/tui/dependencies.py +58 -8
  121. shotgun/tui/protocols.py +55 -0
  122. shotgun/tui/screens/chat/chat.tcss +24 -1
  123. shotgun/tui/screens/chat/chat_screen.py +1376 -213
  124. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +8 -4
  125. shotgun/tui/screens/chat_screen/attachment_hint.py +40 -0
  126. shotgun/tui/screens/chat_screen/command_providers.py +0 -97
  127. shotgun/tui/screens/chat_screen/history/agent_response.py +7 -3
  128. shotgun/tui/screens/chat_screen/history/chat_history.py +58 -6
  129. shotgun/tui/screens/chat_screen/history/formatters.py +75 -15
  130. shotgun/tui/screens/chat_screen/history/partial_response.py +11 -1
  131. shotgun/tui/screens/chat_screen/history/user_question.py +25 -3
  132. shotgun/tui/screens/chat_screen/messages.py +219 -0
  133. shotgun/tui/screens/database_locked_dialog.py +219 -0
  134. shotgun/tui/screens/database_timeout_dialog.py +158 -0
  135. shotgun/tui/screens/kuzu_error_dialog.py +135 -0
  136. shotgun/tui/screens/model_picker.py +1 -3
  137. shotgun/tui/screens/models.py +11 -0
  138. shotgun/tui/state/processing_state.py +19 -0
  139. shotgun/tui/utils/mode_progress.py +20 -86
  140. shotgun/tui/widgets/__init__.py +2 -1
  141. shotgun/tui/widgets/approval_widget.py +152 -0
  142. shotgun/tui/widgets/cascade_confirmation_widget.py +203 -0
  143. shotgun/tui/widgets/plan_panel.py +129 -0
  144. shotgun/tui/widgets/step_checkpoint_widget.py +180 -0
  145. shotgun/tui/widgets/widget_coordinator.py +18 -0
  146. shotgun/utils/file_system_utils.py +4 -1
  147. {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/METADATA +88 -35
  148. shotgun_sh-0.6.2.dist-info/RECORD +291 -0
  149. shotgun/cli/export.py +0 -81
  150. shotgun/cli/plan.py +0 -73
  151. shotgun/cli/research.py +0 -93
  152. shotgun/cli/specify.py +0 -70
  153. shotgun/cli/tasks.py +0 -78
  154. shotgun/sentry_telemetry.py +0 -232
  155. shotgun/tui/screens/onboarding.py +0 -580
  156. shotgun_sh-0.3.3.dev1.dist-info/RECORD +0 -229
  157. {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/WHEEL +0 -0
  158. {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/entry_points.txt +0 -0
  159. {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,193 @@
1
+ """Attachment processor for file validation and encoding.
2
+
3
+ Provides functions to validate file sizes against provider limits
4
+ and encode file contents to base64 for API submission.
5
+ """
6
+
7
+ import base64
8
+ import logging
9
+ from pathlib import Path
10
+
11
+ import aiofiles
12
+
13
+ from shotgun.agents.config.models import ProviderType
14
+ from shotgun.attachments.errors import (
15
+ cannot_read_file,
16
+ file_not_found,
17
+ file_too_large,
18
+ )
19
+ from shotgun.attachments.models import AttachmentType, FileAttachment
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # Provider file size limits in bytes
24
+ PROVIDER_SIZE_LIMITS: dict[ProviderType, int] = {
25
+ ProviderType.OPENAI: 20 * 1024 * 1024, # 20MB
26
+ ProviderType.ANTHROPIC: 32 * 1024 * 1024, # 32MB
27
+ ProviderType.GOOGLE: 4 * 1024 * 1024, # 4MB
28
+ }
29
+
30
+ # Default limit for unknown providers (most restrictive)
31
+ DEFAULT_SIZE_LIMIT: int = 4 * 1024 * 1024 # 4MB
32
+
33
+
34
+ def get_provider_size_limit(provider: ProviderType) -> int:
35
+ """Get the maximum file size limit for a provider.
36
+
37
+ Args:
38
+ provider: The LLM provider type.
39
+
40
+ Returns:
41
+ Maximum file size in bytes.
42
+ """
43
+ return PROVIDER_SIZE_LIMITS.get(provider, DEFAULT_SIZE_LIMIT)
44
+
45
+
46
+ def format_file_size(size_bytes: int) -> str:
47
+ """Format file size in human-readable format.
48
+
49
+ Args:
50
+ size_bytes: File size in bytes.
51
+
52
+ Returns:
53
+ Human-readable string (e.g., "2.5 MB", "512 KB", "128 B").
54
+ """
55
+ if size_bytes < 1024:
56
+ return f"{size_bytes} B"
57
+ elif size_bytes < 1024 * 1024:
58
+ return f"{size_bytes / 1024:.1f} KB"
59
+ else:
60
+ return f"{size_bytes / (1024 * 1024):.1f} MB"
61
+
62
+
63
+ def validate_file_size(
64
+ attachment: FileAttachment,
65
+ provider: ProviderType,
66
+ ) -> tuple[bool, str | None]:
67
+ """Validate file size against provider limit.
68
+
69
+ Args:
70
+ attachment: The file attachment to validate.
71
+ provider: The target LLM provider.
72
+
73
+ Returns:
74
+ Tuple of (is_valid, error_message).
75
+ If valid: (True, None)
76
+ If invalid: (False, "File size {size} exceeds {provider} limit of {limit}")
77
+ """
78
+ limit = get_provider_size_limit(provider)
79
+
80
+ if attachment.file_size_bytes > limit:
81
+ size_str = format_file_size(attachment.file_size_bytes)
82
+ limit_str = format_file_size(limit)
83
+ provider_name = provider.value.capitalize()
84
+ return (
85
+ False,
86
+ file_too_large(size_str, limit_str, provider_name),
87
+ )
88
+
89
+ return (True, None)
90
+
91
+
92
+ async def encode_file_to_base64(file_path: Path) -> str:
93
+ """Asynchronously read and encode file contents to base64.
94
+
95
+ Args:
96
+ file_path: Path to the file to encode.
97
+
98
+ Returns:
99
+ Base64-encoded string of file contents.
100
+
101
+ Raises:
102
+ FileNotFoundError: If file does not exist.
103
+ PermissionError: If file cannot be read.
104
+ OSError: If file read fails.
105
+ """
106
+ async with aiofiles.open(file_path, "rb") as f:
107
+ content = await f.read()
108
+
109
+ if not content:
110
+ return ""
111
+
112
+ return base64.b64encode(content).decode("utf-8")
113
+
114
+
115
+ async def process_attachment(
116
+ attachment: FileAttachment,
117
+ provider: ProviderType,
118
+ ) -> tuple[FileAttachment, str | None]:
119
+ """Validate and process an attachment for submission.
120
+
121
+ Validates file size against provider limits and encodes content to base64.
122
+ All supported attachment types (PDF, PNG, JPG, JPEG, GIF, WEBP) work with
123
+ all providers (OpenAI, Anthropic, Google) via BinaryContent.
124
+
125
+ Args:
126
+ attachment: The file attachment to process.
127
+ provider: The target LLM provider.
128
+
129
+ Returns:
130
+ Tuple of (processed_attachment, error_message).
131
+ If successful: (attachment_with_base64_content, None)
132
+ If failed: (original_attachment, error_message)
133
+ """
134
+ # Validate file size
135
+ is_valid, error = validate_file_size(attachment, provider)
136
+ if not is_valid:
137
+ return (attachment, error)
138
+
139
+ # Encode file to base64
140
+ try:
141
+ content_base64 = await encode_file_to_base64(attachment.file_path)
142
+ except FileNotFoundError:
143
+ return (attachment, file_not_found(attachment.file_path))
144
+ except PermissionError:
145
+ return (attachment, cannot_read_file(attachment.file_path, "permission denied"))
146
+ except OSError as e:
147
+ logger.warning(f"Failed to read file '{attachment.file_path}': {e}")
148
+ return (attachment, cannot_read_file(attachment.file_path))
149
+
150
+ # Create new attachment with base64 content
151
+ processed = FileAttachment(
152
+ file_path=attachment.file_path,
153
+ file_name=attachment.file_name,
154
+ file_type=attachment.file_type,
155
+ file_size_bytes=attachment.file_size_bytes,
156
+ content_base64=content_base64,
157
+ mime_type=attachment.mime_type,
158
+ )
159
+
160
+ logger.debug(
161
+ f"Processed attachment: {processed.file_name} "
162
+ f"({len(content_base64)} base64 chars)"
163
+ )
164
+
165
+ return (processed, None)
166
+
167
+
168
+ def create_attachment_hint_display(attachment: FileAttachment) -> str:
169
+ """Create display string for attachment in chat history.
170
+
171
+ Args:
172
+ attachment: The file attachment.
173
+
174
+ Returns:
175
+ Formatted display string (e.g., "document.pdf (2.5 MB)").
176
+ """
177
+ size_str = format_file_size(attachment.file_size_bytes)
178
+ return f"{attachment.file_name} ({size_str})"
179
+
180
+
181
+ def get_attachment_icon(attachment_type: AttachmentType) -> str:
182
+ """Get the appropriate icon for an attachment type.
183
+
184
+ Args:
185
+ attachment_type: The type of attachment.
186
+
187
+ Returns:
188
+ Icon string for display.
189
+ """
190
+ if attachment_type == AttachmentType.PDF:
191
+ return "\U0001f4c4" # document emoji
192
+ else:
193
+ return "\U0001f5bc\ufe0f" # framed picture emoji
@@ -4,17 +4,14 @@ This file is auto-generated during the build process.
4
4
  DO NOT EDIT MANUALLY.
5
5
  """
6
6
 
7
- # Sentry DSN embedded at build time (empty string if not provided)
8
- SENTRY_DSN = 'https://2818a6d165c64eccc94cfd51ce05d6aa@o4506813296738304.ingest.us.sentry.io/4510045952409600'
9
-
10
7
  # PostHog configuration embedded at build time (empty strings if not provided)
11
8
  POSTHOG_API_KEY = 'phc_KKnChzZUKeNqZDOTJ6soCBWNQSx3vjiULdwTR9H5Mcr'
12
9
  POSTHOG_PROJECT_ID = '191396'
13
10
 
14
11
  # Logfire configuration embedded at build time (only for dev builds)
15
- LOGFIRE_ENABLED = 'true'
16
- LOGFIRE_TOKEN = 'pylf_v1_us_RwZMlJm1tX6j0PL5RWWbmZpzK2hLBNtFWStNKlySfjh8'
12
+ LOGFIRE_ENABLED = ''
13
+ LOGFIRE_TOKEN = ''
17
14
 
18
15
  # Build metadata
19
- BUILD_TIME_ENV = "production" if SENTRY_DSN else "development"
20
- IS_DEV_BUILD = True
16
+ BUILD_TIME_ENV = "production" if POSTHOG_API_KEY else "development"
17
+ IS_DEV_BUILD = False
shotgun/cli/clear.py CHANGED
@@ -1,13 +1,13 @@
1
1
  """Clear command for shotgun CLI."""
2
2
 
3
3
  import asyncio
4
- from pathlib import Path
5
4
 
6
5
  import typer
7
6
  from rich.console import Console
8
7
 
9
8
  from shotgun.agents.conversation import ConversationManager
10
9
  from shotgun.logging_config import get_logger
10
+ from shotgun.utils import get_shotgun_home
11
11
 
12
12
  app = typer.Typer(
13
13
  name="clear", help="Clear the conversation history", no_args_is_help=False
@@ -26,7 +26,7 @@ def clear() -> None:
26
26
  """
27
27
  try:
28
28
  # Get conversation file path
29
- conversation_file = Path.home() / ".shotgun-sh" / "conversation.json"
29
+ conversation_file = get_shotgun_home() / "conversation.json"
30
30
 
31
31
  # Check if file exists
32
32
  if not conversation_file.exists():
@@ -16,6 +16,8 @@ from rich.progress import (
16
16
  TimeElapsedColumn,
17
17
  )
18
18
 
19
+ from shotgun.codebase.benchmarks import BenchmarkRunner, MetricsExporter, get_formatter
20
+ from shotgun.codebase.benchmarks.formatters import MetricsDisplayOptions
19
21
  from shotgun.codebase.models import (
20
22
  CodebaseGraph,
21
23
  IndexProgress,
@@ -69,81 +71,195 @@ def index(
69
71
  format_type: Annotated[
70
72
  OutputFormat, typer.Option("--format", "-f", help="Output format")
71
73
  ] = OutputFormat.TEXT,
74
+ # Benchmark flags
75
+ benchmark: Annotated[
76
+ bool,
77
+ typer.Option("--benchmark", help="Enable benchmark mode with detailed metrics"),
78
+ ] = False,
79
+ iterations: Annotated[
80
+ int,
81
+ typer.Option(
82
+ "--benchmark-iterations",
83
+ help="Number of benchmark runs (requires --benchmark)",
84
+ ),
85
+ ] = 1,
86
+ warmup: Annotated[
87
+ int,
88
+ typer.Option(
89
+ "--benchmark-warmup", help="Number of warmup runs (requires --benchmark)"
90
+ ),
91
+ ] = 0,
92
+ benchmark_output: Annotated[
93
+ str,
94
+ typer.Option(
95
+ "--benchmark-output",
96
+ help="Benchmark output format: json|markdown",
97
+ ),
98
+ ] = "json",
99
+ benchmark_export: Annotated[
100
+ str | None,
101
+ typer.Option("--benchmark-export", help="Export metrics to file"),
102
+ ] = None,
103
+ show_files: Annotated[
104
+ bool,
105
+ typer.Option("--show-files", help="Show per-file metrics in benchmark output"),
106
+ ] = False,
107
+ show_workers: Annotated[
108
+ bool,
109
+ typer.Option(
110
+ "--show-workers", help="Show per-worker metrics in benchmark output"
111
+ ),
112
+ ] = False,
113
+ top_n: Annotated[
114
+ int | None,
115
+ typer.Option("--top-n", help="Show N slowest files (requires --benchmark)"),
116
+ ] = None,
117
+ sequential: Annotated[
118
+ bool,
119
+ typer.Option(
120
+ "--sequential", help="Force sequential mode (disable parallelization)"
121
+ ),
122
+ ] = False,
72
123
  ) -> None:
73
- """Index a new codebase."""
74
- sdk = CodebaseSDK()
124
+ """Index a new codebase.
125
+
126
+ By default, runs with a TUI progress display. Use --benchmark for detailed
127
+ metrics reporting.
128
+ """
75
129
  console = Console()
76
130
 
77
- # Create progress display
78
- progress = Progress(
79
- SpinnerColumn(),
80
- TextColumn("[bold blue]{task.description}"),
81
- BarColumn(),
82
- TaskProgressColumn(),
83
- TimeElapsedColumn(),
84
- console=console,
85
- )
86
-
87
- # Track tasks by phase
88
- tasks = {}
89
-
90
- def progress_callback(progress_info: IndexProgress) -> None:
91
- """Update progress display based on indexing phase."""
92
- phase = progress_info.phase
93
-
94
- # Create task if it doesn't exist
95
- if phase not in tasks:
96
- if progress_info.total is not None:
97
- tasks[phase] = progress.add_task(
98
- progress_info.phase_name, total=progress_info.total
99
- )
100
- else:
101
- # Indeterminate progress (spinner only)
102
- tasks[phase] = progress.add_task(progress_info.phase_name, total=None)
103
-
104
- task_id = tasks[phase]
105
-
106
- # Update task
107
- if progress_info.total is not None:
108
- progress.update(
109
- task_id,
110
- completed=progress_info.current,
111
- total=progress_info.total,
112
- description=f"[bold blue]{progress_info.phase_name}",
131
+ # Validate path
132
+ repo_path = Path(path).resolve()
133
+ if not repo_path.exists():
134
+ error_result = ErrorResult(error_message=f"Path does not exist: {repo_path}")
135
+ output_result(error_result, format_type)
136
+ raise typer.Exit(1)
137
+
138
+ # Benchmark mode
139
+ if benchmark:
140
+ try:
141
+ # Create and run benchmark
142
+ runner = BenchmarkRunner(
143
+ codebase_path=repo_path,
144
+ codebase_name=name,
145
+ iterations=iterations,
146
+ warmup_iterations=warmup,
147
+ parallel=not sequential,
148
+ collect_file_metrics=show_files or top_n is not None,
149
+ collect_worker_metrics=show_workers,
150
+ )
151
+
152
+ console.print(f"\n[bold blue]Starting benchmark: {name}[/bold blue]")
153
+ console.print(f"Path: {repo_path}")
154
+ console.print(f"Mode: {'Sequential' if sequential else 'Parallel'}")
155
+ console.print(f"Iterations: {iterations} ({warmup} warmup)")
156
+ console.print()
157
+
158
+ results = asyncio.run(runner.run())
159
+
160
+ # Format output
161
+ formatter = get_formatter(benchmark_output)
162
+ options = MetricsDisplayOptions(
163
+ show_phase_metrics=True,
164
+ show_worker_metrics=show_workers,
165
+ show_file_metrics=show_files or top_n is not None,
166
+ top_n_files=top_n,
113
167
  )
114
- else:
115
- # Just update description for indeterminate tasks
116
- progress.update(
117
- task_id,
118
- description=f"[bold blue]{progress_info.phase_name} ({progress_info.current} items)",
168
+ output = formatter.format_results(results, options)
169
+ console.print(output)
170
+
171
+ # Export if requested
172
+ if benchmark_export:
173
+ exporter = MetricsExporter()
174
+ exporter.export(results, Path(benchmark_export), options=options)
175
+ console.print(
176
+ f"\n[green]Metrics exported to: {benchmark_export}[/green]"
177
+ )
178
+
179
+ except Exception as e:
180
+ error_result = ErrorResult(
181
+ error_message=f"Benchmark error: {e}",
182
+ details=f"Full traceback:\n{traceback.format_exc()}",
119
183
  )
184
+ output_result(error_result, format_type)
185
+ raise typer.Exit(1) from e
186
+
187
+ else:
188
+ # Normal mode with TUI progress display
189
+ sdk = CodebaseSDK()
190
+
191
+ # Create progress display
192
+ progress = Progress(
193
+ SpinnerColumn(),
194
+ TextColumn("[bold blue]{task.description}"),
195
+ BarColumn(),
196
+ TaskProgressColumn(),
197
+ TimeElapsedColumn(),
198
+ console=console,
199
+ )
200
+
201
+ # Track tasks by phase
202
+ tasks = {}
120
203
 
121
- # Mark as complete if phase is done
122
- if progress_info.phase_complete:
204
+ def progress_callback(progress_info: IndexProgress) -> None:
205
+ """Update progress display based on indexing phase."""
206
+ phase = progress_info.phase
207
+
208
+ # Create task if it doesn't exist
209
+ if phase not in tasks:
210
+ if progress_info.total is not None:
211
+ tasks[phase] = progress.add_task(
212
+ progress_info.phase_name, total=progress_info.total
213
+ )
214
+ else:
215
+ # Indeterminate progress (spinner only)
216
+ tasks[phase] = progress.add_task(
217
+ progress_info.phase_name, total=None
218
+ )
219
+
220
+ task_id = tasks[phase]
221
+
222
+ # Update task
123
223
  if progress_info.total is not None:
124
- progress.update(task_id, completed=progress_info.total)
224
+ progress.update(
225
+ task_id,
226
+ completed=progress_info.current,
227
+ total=progress_info.total,
228
+ description=f"[bold blue]{progress_info.phase_name}",
229
+ )
230
+ else:
231
+ # Just update description for indeterminate tasks
232
+ progress.update(
233
+ task_id,
234
+ description=f"[bold blue]{progress_info.phase_name} ({progress_info.current} items)",
235
+ )
125
236
 
126
- try:
127
- repo_path = Path(path)
237
+ # Mark as complete if phase is done
238
+ if progress_info.phase_complete:
239
+ if progress_info.total is not None:
240
+ progress.update(task_id, completed=progress_info.total)
241
+
242
+ try:
243
+ # Run indexing with progress display
244
+ with progress:
245
+ result = asyncio.run(
246
+ sdk.index_codebase(
247
+ repo_path, name, progress_callback=progress_callback
248
+ )
249
+ )
128
250
 
129
- # Run indexing with progress display
130
- with progress:
131
- result = asyncio.run(
132
- sdk.index_codebase(repo_path, name, progress_callback=progress_callback)
251
+ output_result(result, format_type)
252
+ except InvalidPathError as e:
253
+ error_result = ErrorResult(error_message=str(e))
254
+ output_result(error_result, format_type)
255
+ raise typer.Exit(1) from e
256
+ except Exception as e:
257
+ error_result = ErrorResult(
258
+ error_message=f"Error indexing codebase: {e}",
259
+ details=f"Full traceback:\n{traceback.format_exc()}",
133
260
  )
134
-
135
- output_result(result, format_type)
136
- except InvalidPathError as e:
137
- error_result = ErrorResult(error_message=str(e))
138
- output_result(error_result, format_type)
139
- raise typer.Exit(1) from e
140
- except Exception as e:
141
- error_result = ErrorResult(
142
- error_message=f"Error indexing codebase: {e}",
143
- details=f"Full traceback:\n{traceback.format_exc()}",
144
- )
145
- output_result(error_result, format_type)
146
- raise typer.Exit(1) from e
261
+ output_result(error_result, format_type)
262
+ raise typer.Exit(1) from e
147
263
 
148
264
 
149
265
  @app.command()
shotgun/cli/compact.py CHANGED
@@ -2,7 +2,6 @@
2
2
 
3
3
  import asyncio
4
4
  import json
5
- from pathlib import Path
6
5
  from typing import Annotated, Any
7
6
 
8
7
  import typer
@@ -17,6 +16,7 @@ from shotgun.agents.conversation.history.token_estimation import (
17
16
  )
18
17
  from shotgun.cli.models import OutputFormat
19
18
  from shotgun.logging_config import get_logger
19
+ from shotgun.utils import get_shotgun_home
20
20
 
21
21
  app = typer.Typer(
22
22
  name="compact", help="Compact the conversation history", no_args_is_help=False
@@ -74,7 +74,7 @@ async def compact_conversation() -> dict[str, Any]:
74
74
  Dictionary with compaction statistics including before/after metrics
75
75
  """
76
76
  # Get conversation file path
77
- conversation_file = Path.home() / ".shotgun-sh" / "conversation.json"
77
+ conversation_file = get_shotgun_home() / "conversation.json"
78
78
 
79
79
  if not conversation_file.exists():
80
80
  raise FileNotFoundError(f"Conversation file not found at {conversation_file}")
shotgun/cli/context.py CHANGED
@@ -2,7 +2,6 @@
2
2
 
3
3
  import asyncio
4
4
  import json
5
- from pathlib import Path
6
5
  from typing import Annotated
7
6
 
8
7
  import httpx
@@ -19,6 +18,7 @@ from shotgun.agents.conversation import ConversationManager
19
18
  from shotgun.cli.models import OutputFormat
20
19
  from shotgun.llm_proxy import BudgetInfo, LiteLLMProxyClient
21
20
  from shotgun.logging_config import get_logger
21
+ from shotgun.utils import get_shotgun_home
22
22
 
23
23
  app = typer.Typer(
24
24
  name="context", help="Analyze conversation context usage", no_args_is_help=False
@@ -74,7 +74,7 @@ async def analyze_context() -> ContextAnalysisOutput:
74
74
  ContextAnalysisOutput with both markdown and JSON representations of the analysis
75
75
  """
76
76
  # Get conversation file path
77
- conversation_file = Path.home() / ".shotgun-sh" / "conversation.json"
77
+ conversation_file = get_shotgun_home() / "conversation.json"
78
78
 
79
79
  if not conversation_file.exists():
80
80
  raise FileNotFoundError(f"Conversation file not found at {conversation_file}")
@@ -6,12 +6,12 @@ by printing formatted messages to the console.
6
6
 
7
7
  from rich.console import Console
8
8
 
9
- from shotgun.exceptions import ErrorNotPickedUpBySentry
9
+ from shotgun.exceptions import UserActionableError
10
10
 
11
11
  console = Console(stderr=True)
12
12
 
13
13
 
14
- def print_agent_error(exception: ErrorNotPickedUpBySentry) -> None:
14
+ def print_agent_error(exception: UserActionableError) -> None:
15
15
  """Print an agent error to the console in yellow.
16
16
 
17
17
  Args:
shotgun/cli/run.py ADDED
@@ -0,0 +1,90 @@
1
+ """Run command for shotgun CLI - executes prompts using the Router agent."""
2
+
3
+ import asyncio
4
+ import traceback
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from shotgun.agents.config import ProviderType
10
+ from shotgun.agents.models import AgentRuntimeOptions
11
+ from shotgun.agents.router import (
12
+ RouterMode,
13
+ create_router_agent,
14
+ run_router_agent,
15
+ )
16
+ from shotgun.cli.error_handler import print_agent_error
17
+ from shotgun.exceptions import UserActionableError
18
+ from shotgun.logging_config import get_logger
19
+ from shotgun.posthog_telemetry import track_event
20
+
21
+ app = typer.Typer(
22
+ name="run", help="Run a prompt using the Router agent", no_args_is_help=True
23
+ )
24
+ logger = get_logger(__name__)
25
+
26
+
27
+ @app.callback(invoke_without_command=True)
28
+ def run(
29
+ prompt: Annotated[str, typer.Argument(help="The prompt to execute")],
30
+ non_interactive: Annotated[
31
+ bool,
32
+ typer.Option(
33
+ "--non-interactive", "-n", help="Disable user interaction (for CI/CD)"
34
+ ),
35
+ ] = False,
36
+ provider: Annotated[
37
+ ProviderType | None,
38
+ typer.Option("--provider", "-p", help="AI provider to use (overrides default)"),
39
+ ] = None,
40
+ ) -> None:
41
+ """Execute a prompt using the Router agent in drafting mode.
42
+
43
+ The Router agent orchestrates sub-agents (Research, Specify, Plan, Tasks, Export)
44
+ based on your prompt. In drafting mode, it auto-executes without confirmation.
45
+ """
46
+ logger.info("Running prompt: %s", prompt[:100])
47
+
48
+ try:
49
+ asyncio.run(async_run(prompt, non_interactive, provider))
50
+ except Exception as e:
51
+ logger.error("Error during execution: %s", str(e))
52
+ logger.debug("Full traceback:\n%s", traceback.format_exc())
53
+
54
+
55
+ async def async_run(
56
+ prompt: str,
57
+ non_interactive: bool,
58
+ provider: ProviderType | None = None,
59
+ ) -> None:
60
+ """Async implementation of the run command."""
61
+ track_event(
62
+ "run_command",
63
+ {
64
+ "non_interactive": non_interactive,
65
+ "provider": provider.value if provider else "default",
66
+ },
67
+ )
68
+
69
+ # Create agent runtime options
70
+ agent_runtime_options = AgentRuntimeOptions(
71
+ interactive_mode=not non_interactive,
72
+ )
73
+
74
+ # Create the router agent
75
+ agent, deps = await create_router_agent(agent_runtime_options, provider)
76
+
77
+ # Set drafting mode for CLI (auto-execute without confirmation)
78
+ deps.router_mode = RouterMode.DRAFTING
79
+
80
+ logger.info("Starting Router agent in drafting mode...")
81
+ try:
82
+ result = await run_router_agent(agent, prompt, deps)
83
+ print("Complete!")
84
+ print("Response:")
85
+ print(result.output)
86
+ except UserActionableError as e:
87
+ print_agent_error(e)
88
+ except Exception as e:
89
+ logger.exception("Unexpected error in run command")
90
+ print(f"An unexpected error occurred: {str(e)}")
@@ -6,11 +6,12 @@ from datetime import datetime, timezone
6
6
  from pathlib import Path
7
7
 
8
8
  from shotgun.logging_config import get_logger
9
+ from shotgun.utils import get_shotgun_home
9
10
 
10
11
  logger = get_logger(__name__)
11
12
 
12
13
  # Backup directory location
13
- BACKUP_DIR = Path.home() / ".shotgun-sh" / "backups"
14
+ BACKUP_DIR = get_shotgun_home() / "backups"
14
15
 
15
16
 
16
17
  async def create_backup(shotgun_dir: Path) -> str | None: