shotgun-sh 0.2.17__py3-none-any.whl → 0.4.0.dev1__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 (150) hide show
  1. shotgun/agents/agent_manager.py +219 -37
  2. shotgun/agents/common.py +79 -78
  3. shotgun/agents/config/README.md +89 -0
  4. shotgun/agents/config/__init__.py +10 -1
  5. shotgun/agents/config/manager.py +364 -53
  6. shotgun/agents/config/models.py +101 -21
  7. shotgun/agents/config/provider.py +51 -13
  8. shotgun/agents/config/streaming_test.py +119 -0
  9. shotgun/agents/context_analyzer/analyzer.py +6 -2
  10. shotgun/agents/conversation/__init__.py +18 -0
  11. shotgun/agents/conversation/filters.py +164 -0
  12. shotgun/agents/conversation/history/chunking.py +278 -0
  13. shotgun/agents/{history → conversation/history}/compaction.py +27 -1
  14. shotgun/agents/{history → conversation/history}/constants.py +5 -0
  15. shotgun/agents/conversation/history/file_content_deduplication.py +239 -0
  16. shotgun/agents/{history → conversation/history}/history_processors.py +267 -3
  17. shotgun/agents/{history → conversation/history}/token_counting/anthropic.py +8 -0
  18. shotgun/agents/{conversation_manager.py → conversation/manager.py} +1 -1
  19. shotgun/agents/{conversation_history.py → conversation/models.py} +8 -94
  20. shotgun/agents/error/__init__.py +11 -0
  21. shotgun/agents/error/models.py +19 -0
  22. shotgun/agents/export.py +12 -13
  23. shotgun/agents/models.py +66 -1
  24. shotgun/agents/plan.py +12 -13
  25. shotgun/agents/research.py +13 -10
  26. shotgun/agents/router/__init__.py +47 -0
  27. shotgun/agents/router/models.py +376 -0
  28. shotgun/agents/router/router.py +185 -0
  29. shotgun/agents/router/tools/__init__.py +18 -0
  30. shotgun/agents/router/tools/delegation_tools.py +503 -0
  31. shotgun/agents/router/tools/plan_tools.py +322 -0
  32. shotgun/agents/runner.py +230 -0
  33. shotgun/agents/specify.py +12 -13
  34. shotgun/agents/tasks.py +12 -13
  35. shotgun/agents/tools/file_management.py +49 -1
  36. shotgun/agents/tools/registry.py +2 -0
  37. shotgun/agents/tools/web_search/__init__.py +1 -2
  38. shotgun/agents/tools/web_search/gemini.py +1 -3
  39. shotgun/agents/tools/web_search/openai.py +1 -1
  40. shotgun/build_constants.py +2 -2
  41. shotgun/cli/clear.py +1 -1
  42. shotgun/cli/compact.py +5 -3
  43. shotgun/cli/context.py +44 -1
  44. shotgun/cli/error_handler.py +24 -0
  45. shotgun/cli/export.py +34 -34
  46. shotgun/cli/plan.py +34 -34
  47. shotgun/cli/research.py +17 -9
  48. shotgun/cli/spec/__init__.py +5 -0
  49. shotgun/cli/spec/backup.py +81 -0
  50. shotgun/cli/spec/commands.py +132 -0
  51. shotgun/cli/spec/models.py +48 -0
  52. shotgun/cli/spec/pull_service.py +219 -0
  53. shotgun/cli/specify.py +20 -19
  54. shotgun/cli/tasks.py +34 -34
  55. shotgun/codebase/core/change_detector.py +1 -1
  56. shotgun/codebase/core/ingestor.py +154 -8
  57. shotgun/codebase/core/manager.py +1 -1
  58. shotgun/codebase/models.py +2 -0
  59. shotgun/exceptions.py +325 -0
  60. shotgun/llm_proxy/__init__.py +17 -0
  61. shotgun/llm_proxy/client.py +215 -0
  62. shotgun/llm_proxy/models.py +137 -0
  63. shotgun/logging_config.py +42 -0
  64. shotgun/main.py +4 -0
  65. shotgun/posthog_telemetry.py +1 -1
  66. shotgun/prompts/agents/export.j2 +2 -0
  67. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +23 -3
  68. shotgun/prompts/agents/partials/interactive_mode.j2 +3 -3
  69. shotgun/prompts/agents/partials/router_delegation_mode.j2 +36 -0
  70. shotgun/prompts/agents/plan.j2 +29 -1
  71. shotgun/prompts/agents/research.j2 +75 -23
  72. shotgun/prompts/agents/router.j2 +440 -0
  73. shotgun/prompts/agents/specify.j2 +80 -4
  74. shotgun/prompts/agents/state/system_state.j2 +15 -8
  75. shotgun/prompts/agents/tasks.j2 +63 -23
  76. shotgun/prompts/history/chunk_summarization.j2 +34 -0
  77. shotgun/prompts/history/combine_summaries.j2 +53 -0
  78. shotgun/sdk/codebase.py +14 -3
  79. shotgun/settings.py +5 -0
  80. shotgun/shotgun_web/__init__.py +67 -1
  81. shotgun/shotgun_web/client.py +42 -1
  82. shotgun/shotgun_web/constants.py +46 -0
  83. shotgun/shotgun_web/exceptions.py +29 -0
  84. shotgun/shotgun_web/models.py +390 -0
  85. shotgun/shotgun_web/shared_specs/__init__.py +32 -0
  86. shotgun/shotgun_web/shared_specs/file_scanner.py +175 -0
  87. shotgun/shotgun_web/shared_specs/hasher.py +83 -0
  88. shotgun/shotgun_web/shared_specs/models.py +71 -0
  89. shotgun/shotgun_web/shared_specs/upload_pipeline.py +329 -0
  90. shotgun/shotgun_web/shared_specs/utils.py +34 -0
  91. shotgun/shotgun_web/specs_client.py +703 -0
  92. shotgun/shotgun_web/supabase_client.py +31 -0
  93. shotgun/tui/app.py +78 -15
  94. shotgun/tui/components/mode_indicator.py +120 -25
  95. shotgun/tui/components/status_bar.py +2 -2
  96. shotgun/tui/containers.py +1 -1
  97. shotgun/tui/dependencies.py +64 -9
  98. shotgun/tui/layout.py +5 -0
  99. shotgun/tui/protocols.py +37 -0
  100. shotgun/tui/screens/chat/chat.tcss +9 -1
  101. shotgun/tui/screens/chat/chat_screen.py +1015 -106
  102. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +196 -17
  103. shotgun/tui/screens/chat_screen/command_providers.py +13 -89
  104. shotgun/tui/screens/chat_screen/hint_message.py +76 -1
  105. shotgun/tui/screens/chat_screen/history/agent_response.py +7 -3
  106. shotgun/tui/screens/chat_screen/history/chat_history.py +12 -0
  107. shotgun/tui/screens/chat_screen/history/formatters.py +53 -15
  108. shotgun/tui/screens/chat_screen/history/partial_response.py +11 -1
  109. shotgun/tui/screens/chat_screen/messages.py +219 -0
  110. shotgun/tui/screens/confirmation_dialog.py +40 -0
  111. shotgun/tui/screens/directory_setup.py +45 -41
  112. shotgun/tui/screens/feedback.py +10 -3
  113. shotgun/tui/screens/github_issue.py +11 -2
  114. shotgun/tui/screens/model_picker.py +28 -8
  115. shotgun/tui/screens/onboarding.py +179 -26
  116. shotgun/tui/screens/pipx_migration.py +58 -6
  117. shotgun/tui/screens/provider_config.py +66 -8
  118. shotgun/tui/screens/shared_specs/__init__.py +21 -0
  119. shotgun/tui/screens/shared_specs/create_spec_dialog.py +273 -0
  120. shotgun/tui/screens/shared_specs/models.py +56 -0
  121. shotgun/tui/screens/shared_specs/share_specs_dialog.py +390 -0
  122. shotgun/tui/screens/shared_specs/upload_progress_screen.py +452 -0
  123. shotgun/tui/screens/shotgun_auth.py +110 -16
  124. shotgun/tui/screens/spec_pull.py +288 -0
  125. shotgun/tui/screens/welcome.py +123 -0
  126. shotgun/tui/services/conversation_service.py +5 -2
  127. shotgun/tui/utils/mode_progress.py +20 -86
  128. shotgun/tui/widgets/__init__.py +2 -1
  129. shotgun/tui/widgets/approval_widget.py +152 -0
  130. shotgun/tui/widgets/cascade_confirmation_widget.py +203 -0
  131. shotgun/tui/widgets/plan_panel.py +129 -0
  132. shotgun/tui/widgets/step_checkpoint_widget.py +180 -0
  133. shotgun/tui/widgets/widget_coordinator.py +1 -1
  134. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/METADATA +11 -4
  135. shotgun_sh-0.4.0.dev1.dist-info/RECORD +242 -0
  136. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/WHEEL +1 -1
  137. shotgun_sh-0.2.17.dist-info/RECORD +0 -194
  138. /shotgun/agents/{history → conversation/history}/__init__.py +0 -0
  139. /shotgun/agents/{history → conversation/history}/context_extraction.py +0 -0
  140. /shotgun/agents/{history → conversation/history}/history_building.py +0 -0
  141. /shotgun/agents/{history → conversation/history}/message_utils.py +0 -0
  142. /shotgun/agents/{history → conversation/history}/token_counting/__init__.py +0 -0
  143. /shotgun/agents/{history → conversation/history}/token_counting/base.py +0 -0
  144. /shotgun/agents/{history → conversation/history}/token_counting/openai.py +0 -0
  145. /shotgun/agents/{history → conversation/history}/token_counting/sentencepiece_counter.py +0 -0
  146. /shotgun/agents/{history → conversation/history}/token_counting/tokenizer_cache.py +0 -0
  147. /shotgun/agents/{history → conversation/history}/token_counting/utils.py +0 -0
  148. /shotgun/agents/{history → conversation/history}/token_estimation.py +0 -0
  149. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/entry_points.txt +0 -0
  150. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/licenses/LICENSE +0 -0
@@ -23,7 +23,10 @@ logger = get_logger(__name__)
23
23
  # - A list of Paths: multiple allowed files/directories (e.g., [Path("specification.md"), Path("contracts")])
24
24
  # - "*": any file except protected files (for export agent)
25
25
  AGENT_DIRECTORIES: dict[AgentType, str | Path | list[Path]] = {
26
- AgentType.RESEARCH: Path("research.md"),
26
+ AgentType.RESEARCH: [
27
+ Path("research.md"),
28
+ Path("research"),
29
+ ], # Research can write main file and research folder
27
30
  AgentType.SPECIFY: [
28
31
  Path("specification.md"),
29
32
  Path("contracts"),
@@ -282,3 +285,48 @@ async def append_file(ctx: RunContext[AgentDeps], filename: str, content: str) -
282
285
  Success message or error message
283
286
  """
284
287
  return await write_file(ctx, filename, content, mode="a")
288
+
289
+
290
+ @register_tool(
291
+ category=ToolCategory.ARTIFACT_MANAGEMENT,
292
+ display_text="Deleting file",
293
+ key_arg="filename",
294
+ )
295
+ async def delete_file(ctx: RunContext[AgentDeps], filename: str) -> str:
296
+ """Delete a file from the .shotgun directory.
297
+
298
+ Uses the same permission model as write_file - agents can only delete
299
+ files they have permission to write to.
300
+
301
+ Args:
302
+ filename: Relative path to file within .shotgun directory
303
+
304
+ Returns:
305
+ Success message or error message
306
+
307
+ Raises:
308
+ ValueError: If path is outside .shotgun directory or agent lacks permission
309
+ FileNotFoundError: If file does not exist
310
+ """
311
+ logger.debug("🔧 Deleting file: %s", filename)
312
+
313
+ try:
314
+ # Use agent-scoped validation (same as write_file)
315
+ file_path = _validate_agent_scoped_path(filename, ctx.deps.agent_mode)
316
+
317
+ if not await aiofiles.os.path.exists(file_path):
318
+ raise FileNotFoundError(f"File not found: {filename}")
319
+
320
+ # Delete the file
321
+ await aiofiles.os.remove(file_path)
322
+ logger.debug("🗑️ Deleted file: %s", filename)
323
+
324
+ # Track the file operation
325
+ ctx.deps.file_tracker.add_operation(file_path, FileOperationType.DELETED)
326
+
327
+ return f"Successfully deleted {filename}"
328
+
329
+ except Exception as e:
330
+ error_msg = f"Error deleting file '{filename}': {str(e)}"
331
+ logger.error("❌ File delete failed: %s", error_msg)
332
+ return error_msg
@@ -30,6 +30,8 @@ class ToolCategory(StrEnum):
30
30
  ARTIFACT_MANAGEMENT = "artifact_management"
31
31
  WEB_RESEARCH = "web_research"
32
32
  AGENT_RESPONSE = "agent_response"
33
+ PLANNING = "planning"
34
+ DELEGATION = "delegation"
33
35
  UNKNOWN = "unknown"
34
36
 
35
37
 
@@ -44,9 +44,8 @@ async def get_available_web_search_tools() -> list[WebSearchTool]:
44
44
  # Check if using Shotgun Account
45
45
  config_manager = get_config_manager()
46
46
  config = await config_manager.load()
47
- has_shotgun_key = config.shotgun.api_key is not None
48
47
 
49
- if has_shotgun_key:
48
+ if config.shotgun.has_valid_account:
50
49
  logger.debug("🔑 Shotgun Account - only Gemini web search available")
51
50
 
52
51
  # Gemini: Only search tool available for Shotgun Account
@@ -1,7 +1,7 @@
1
1
  """Gemini web search tool implementation."""
2
2
 
3
3
  from opentelemetry import trace
4
- from pydantic_ai.messages import ModelMessage, ModelRequest
4
+ from pydantic_ai.messages import ModelMessage, ModelRequest, TextPart
5
5
  from pydantic_ai.settings import ModelSettings
6
6
 
7
7
  from shotgun.agents.config import get_provider_model
@@ -82,8 +82,6 @@ async def gemini_web_search_tool(query: str) -> str:
82
82
  )
83
83
 
84
84
  # Extract text from response
85
- from pydantic_ai.messages import TextPart
86
-
87
85
  result_text = "No content returned from search"
88
86
  if response.parts:
89
87
  for part in response.parts:
@@ -64,7 +64,7 @@ async def openai_web_search_tool(query: str) -> str:
64
64
  )
65
65
 
66
66
  client = AsyncOpenAI(api_key=api_key)
67
- response = await client.responses.create( # type: ignore[call-overload]
67
+ response = await client.responses.create(
68
68
  model="gpt-5-mini",
69
69
  input=[
70
70
  {"role": "user", "content": [{"type": "input_text", "text": prompt}]}
@@ -12,8 +12,8 @@ POSTHOG_API_KEY = 'phc_KKnChzZUKeNqZDOTJ6soCBWNQSx3vjiULdwTR9H5Mcr'
12
12
  POSTHOG_PROJECT_ID = '191396'
13
13
 
14
14
  # Logfire configuration embedded at build time (only for dev builds)
15
- LOGFIRE_ENABLED = ''
16
- LOGFIRE_TOKEN = ''
15
+ LOGFIRE_ENABLED = 'true'
16
+ LOGFIRE_TOKEN = 'pylf_v1_us_RwZMlJm1tX6j0PL5RWWbmZpzK2hLBNtFWStNKlySfjh8'
17
17
 
18
18
  # Build metadata
19
19
  BUILD_TIME_ENV = "production" if SENTRY_DSN else "development"
shotgun/cli/clear.py CHANGED
@@ -6,7 +6,7 @@ from pathlib import Path
6
6
  import typer
7
7
  from rich.console import Console
8
8
 
9
- from shotgun.agents.conversation_manager import ConversationManager
9
+ from shotgun.agents.conversation import ConversationManager
10
10
  from shotgun.logging_config import get_logger
11
11
 
12
12
  app = typer.Typer(
shotgun/cli/compact.py CHANGED
@@ -10,9 +10,11 @@ from pydantic_ai.usage import RequestUsage
10
10
  from rich.console import Console
11
11
 
12
12
  from shotgun.agents.config import get_provider_model
13
- from shotgun.agents.conversation_manager import ConversationManager
14
- from shotgun.agents.history.history_processors import token_limit_compactor
15
- from shotgun.agents.history.token_estimation import estimate_tokens_from_messages
13
+ from shotgun.agents.conversation import ConversationManager
14
+ from shotgun.agents.conversation.history.history_processors import token_limit_compactor
15
+ from shotgun.agents.conversation.history.token_estimation import (
16
+ estimate_tokens_from_messages,
17
+ )
16
18
  from shotgun.cli.models import OutputFormat
17
19
  from shotgun.logging_config import get_logger
18
20
 
shotgun/cli/context.py CHANGED
@@ -5,6 +5,7 @@ import json
5
5
  from pathlib import Path
6
6
  from typing import Annotated
7
7
 
8
+ import httpx
8
9
  import typer
9
10
  from rich.console import Console
10
11
 
@@ -14,8 +15,9 @@ from shotgun.agents.context_analyzer import (
14
15
  ContextAnalyzer,
15
16
  ContextFormatter,
16
17
  )
17
- from shotgun.agents.conversation_manager import ConversationManager
18
+ from shotgun.agents.conversation import ConversationManager
18
19
  from shotgun.cli.models import OutputFormat
20
+ from shotgun.llm_proxy import BudgetInfo, LiteLLMProxyClient
19
21
  from shotgun.logging_config import get_logger
20
22
 
21
23
  app = typer.Typer(
@@ -108,4 +110,45 @@ async def analyze_context() -> ContextAnalysisOutput:
108
110
  markdown = ContextFormatter.format_markdown(analysis)
109
111
  json_data = ContextFormatter.format_json(analysis)
110
112
 
113
+ # Add budget info for Shotgun Account users
114
+ if model_config.is_shotgun_account:
115
+ try:
116
+ logger.debug("Fetching budget info for Shotgun Account")
117
+ client = LiteLLMProxyClient(model_config.api_key)
118
+ budget_info = await client.get_budget_info()
119
+
120
+ # Format budget section for markdown
121
+ budget_markdown = _format_budget_markdown(budget_info)
122
+ markdown = f"{markdown}\n\n{budget_markdown}"
123
+
124
+ # Add budget info to JSON using Pydantic model
125
+ json_data["budget"] = budget_info.model_dump()
126
+ logger.debug("Successfully added budget info to context output")
127
+
128
+ except httpx.HTTPError as e:
129
+ logger.warning(f"Failed to fetch budget info: {e}")
130
+ # Don't fail the entire command if budget fetch fails
131
+ except Exception as e:
132
+ logger.warning(f"Unexpected error fetching budget info: {e}")
133
+ # Don't fail the entire command if budget fetch fails
134
+
111
135
  return ContextAnalysisOutput(markdown=markdown, json_data=json_data)
136
+
137
+
138
+ def _format_budget_markdown(budget_info: BudgetInfo) -> str:
139
+ """Format budget information as markdown.
140
+
141
+ Args:
142
+ budget_info: BudgetInfo instance
143
+
144
+ Returns:
145
+ Formatted markdown string
146
+ """
147
+ source_label = "Key" if budget_info.source == "key" else "Team"
148
+
149
+ return f"""## Shotgun Account Budget
150
+
151
+ * Max Budget: ${budget_info.max_budget:.2f}
152
+ * Current Spend: ${budget_info.spend:.2f}
153
+ * Remaining: ${budget_info.remaining:.2f} ({100 - budget_info.percentage_used:.1f}%)
154
+ * Budget Source: {source_label}-level"""
@@ -0,0 +1,24 @@
1
+ """CLI-specific error handling utilities.
2
+
3
+ This module provides utilities for displaying agent errors in the CLI
4
+ by printing formatted messages to the console.
5
+ """
6
+
7
+ from rich.console import Console
8
+
9
+ from shotgun.exceptions import ErrorNotPickedUpBySentry
10
+
11
+ console = Console(stderr=True)
12
+
13
+
14
+ def print_agent_error(exception: ErrorNotPickedUpBySentry) -> None:
15
+ """Print an agent error to the console in yellow.
16
+
17
+ Args:
18
+ exception: The error exception with formatting methods
19
+ """
20
+ # Get plain text version for CLI
21
+ message = exception.to_plain_text()
22
+
23
+ # Print with yellow styling
24
+ console.print(message, style="yellow")
shotgun/cli/export.py CHANGED
@@ -11,7 +11,10 @@ from shotgun.agents.export import (
11
11
  run_export_agent,
12
12
  )
13
13
  from shotgun.agents.models import AgentRuntimeOptions
14
+ from shotgun.cli.error_handler import print_agent_error
15
+ from shotgun.exceptions import ErrorNotPickedUpBySentry
14
16
  from shotgun.logging_config import get_logger
17
+ from shotgun.posthog_telemetry import track_event
15
18
 
16
19
  app = typer.Typer(
17
20
  name="export", help="Export artifacts to various formats with agentic approach"
@@ -45,37 +48,34 @@ def export(
45
48
 
46
49
  logger.info("📤 Export Instruction: %s", instruction)
47
50
 
48
- try:
49
- # Track export command usage
50
- from shotgun.posthog_telemetry import track_event
51
-
52
- track_event(
53
- "export_command",
54
- {
55
- "non_interactive": non_interactive,
56
- "provider": provider.value if provider else "default",
57
- },
58
- )
59
-
60
- # Create agent dependencies
61
- agent_runtime_options = AgentRuntimeOptions(
62
- interactive_mode=not non_interactive
63
- )
64
-
65
- # Create the export agent with deps and provider
66
- agent, deps = asyncio.run(create_export_agent(agent_runtime_options, provider))
67
-
68
- # Start export process
69
- logger.info("🎯 Starting export...")
70
- result = asyncio.run(run_export_agent(agent, instruction, deps))
71
-
72
- # Display results
73
- logger.info("✅ Export Complete!")
74
- logger.info("📤 Results:")
75
- logger.info("%s", result.output)
76
-
77
- except Exception as e:
78
- logger.error("❌ Error during export: %s", str(e))
79
- import traceback
80
-
81
- logger.debug("Full traceback:\n%s", traceback.format_exc())
51
+ # Track export command usage
52
+ track_event(
53
+ "export_command",
54
+ {
55
+ "non_interactive": non_interactive,
56
+ "provider": provider.value if provider else "default",
57
+ },
58
+ )
59
+
60
+ # Create agent dependencies
61
+ agent_runtime_options = AgentRuntimeOptions(interactive_mode=not non_interactive)
62
+
63
+ # Create the export agent with deps and provider
64
+ agent, deps = asyncio.run(create_export_agent(agent_runtime_options, provider))
65
+
66
+ # Start export process with error handling
67
+ logger.info("🎯 Starting export...")
68
+
69
+ async def async_export() -> None:
70
+ try:
71
+ result = await run_export_agent(agent, instruction, deps)
72
+ logger.info(" Export Complete!")
73
+ logger.info("📤 Results:")
74
+ logger.info("%s", result.output)
75
+ except ErrorNotPickedUpBySentry as e:
76
+ print_agent_error(e)
77
+ except Exception as e:
78
+ logger.exception("Unexpected error in export command")
79
+ print(f"⚠️ An unexpected error occurred: {str(e)}")
80
+
81
+ asyncio.run(async_export())
shotgun/cli/plan.py CHANGED
@@ -8,7 +8,10 @@ import typer
8
8
  from shotgun.agents.config import ProviderType
9
9
  from shotgun.agents.models import AgentRuntimeOptions
10
10
  from shotgun.agents.plan import create_plan_agent, run_plan_agent
11
+ from shotgun.cli.error_handler import print_agent_error
12
+ from shotgun.exceptions import ErrorNotPickedUpBySentry
11
13
  from shotgun.logging_config import get_logger
14
+ from shotgun.posthog_telemetry import track_event
12
15
 
13
16
  app = typer.Typer(name="plan", help="Generate structured plans", no_args_is_help=True)
14
17
  logger = get_logger(__name__)
@@ -37,37 +40,34 @@ def plan(
37
40
 
38
41
  logger.info("📋 Planning Goal: %s", goal)
39
42
 
40
- try:
41
- # Track plan command usage
42
- from shotgun.posthog_telemetry import track_event
43
-
44
- track_event(
45
- "plan_command",
46
- {
47
- "non_interactive": non_interactive,
48
- "provider": provider.value if provider else "default",
49
- },
50
- )
51
-
52
- # Create agent dependencies
53
- agent_runtime_options = AgentRuntimeOptions(
54
- interactive_mode=not non_interactive
55
- )
56
-
57
- # Create the plan agent with deps and provider
58
- agent, deps = asyncio.run(create_plan_agent(agent_runtime_options, provider))
59
-
60
- # Start planning process
61
- logger.info("🎯 Starting planning...")
62
- result = asyncio.run(run_plan_agent(agent, goal, deps))
63
-
64
- # Display results
65
- logger.info("✅ Planning Complete!")
66
- logger.info("📋 Results:")
67
- logger.info("%s", result.output)
68
-
69
- except Exception as e:
70
- logger.error("❌ Error during planning: %s", str(e))
71
- import traceback
72
-
73
- logger.debug("Full traceback:\n%s", traceback.format_exc())
43
+ # Track plan command usage
44
+ track_event(
45
+ "plan_command",
46
+ {
47
+ "non_interactive": non_interactive,
48
+ "provider": provider.value if provider else "default",
49
+ },
50
+ )
51
+
52
+ # Create agent dependencies
53
+ agent_runtime_options = AgentRuntimeOptions(interactive_mode=not non_interactive)
54
+
55
+ # Create the plan agent with deps and provider
56
+ agent, deps = asyncio.run(create_plan_agent(agent_runtime_options, provider))
57
+
58
+ # Start planning process with error handling
59
+ logger.info("🎯 Starting planning...")
60
+
61
+ async def async_plan() -> None:
62
+ try:
63
+ result = await run_plan_agent(agent, goal, deps)
64
+ logger.info(" Planning Complete!")
65
+ logger.info("📋 Results:")
66
+ logger.info("%s", result.output)
67
+ except ErrorNotPickedUpBySentry as e:
68
+ print_agent_error(e)
69
+ except Exception as e:
70
+ logger.exception("Unexpected error in plan command")
71
+ print(f"⚠️ An unexpected error occurred: {str(e)}")
72
+
73
+ asyncio.run(async_plan())
shotgun/cli/research.py CHANGED
@@ -11,7 +11,10 @@ from shotgun.agents.research import (
11
11
  create_research_agent,
12
12
  run_research_agent,
13
13
  )
14
+ from shotgun.cli.error_handler import print_agent_error
15
+ from shotgun.exceptions import ErrorNotPickedUpBySentry
14
16
  from shotgun.logging_config import get_logger
17
+ from shotgun.posthog_telemetry import track_event
15
18
 
16
19
  app = typer.Typer(
17
20
  name="research", help="Perform research with agentic loops", no_args_is_help=True
@@ -59,8 +62,6 @@ async def async_research(
59
62
  ) -> None:
60
63
  """Async wrapper for research process."""
61
64
  # Track research command usage
62
- from shotgun.posthog_telemetry import track_event
63
-
64
65
  track_event(
65
66
  "research_command",
66
67
  {
@@ -75,11 +76,18 @@ async def async_research(
75
76
  # Create the research agent with deps and provider
76
77
  agent, deps = await create_research_agent(agent_runtime_options, provider)
77
78
 
78
- # Start research process
79
+ # Start research process with error handling
79
80
  logger.info("🔬 Starting research...")
80
- result = await run_research_agent(agent, query, deps)
81
-
82
- # Display results
83
- print("✅ Research Complete!")
84
- print("📋 Findings:")
85
- print(result.output)
81
+ try:
82
+ result = await run_research_agent(agent, query, deps)
83
+ # Display results
84
+ print("✅ Research Complete!")
85
+ print("📋 Findings:")
86
+ print(result.output)
87
+ except ErrorNotPickedUpBySentry as e:
88
+ # All user-actionable errors - display with plain text
89
+ print_agent_error(e)
90
+ except Exception as e:
91
+ # Unexpected errors that weren't wrapped (shouldn't happen)
92
+ logger.exception("Unexpected error in research command")
93
+ print(f"⚠️ An unexpected error occurred: {str(e)}")
@@ -0,0 +1,5 @@
1
+ """Spec CLI module."""
2
+
3
+ from .commands import app
4
+
5
+ __all__ = ["app"]
@@ -0,0 +1,81 @@
1
+ """Backup utility for .shotgun/ directory before pulling specs."""
2
+
3
+ import shutil
4
+ import zipfile
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+
8
+ from shotgun.logging_config import get_logger
9
+
10
+ logger = get_logger(__name__)
11
+
12
+ # Backup directory location
13
+ BACKUP_DIR = Path.home() / ".shotgun-sh" / "backups"
14
+
15
+
16
+ async def create_backup(shotgun_dir: Path) -> str | None:
17
+ """Create a zip backup of the .shotgun/ directory.
18
+
19
+ Creates a timestamped backup at ~/.shotgun-sh/backups/{YYYYMMDD_HHMMSS}.zip.
20
+ Only creates backup if the directory exists and has content.
21
+
22
+ Args:
23
+ shotgun_dir: Path to the .shotgun/ directory to backup
24
+
25
+ Returns:
26
+ Path to the backup file as string, or None if no backup was created
27
+ (e.g., directory doesn't exist or is empty)
28
+
29
+ Raises:
30
+ Exception: If backup creation fails (caller should handle)
31
+ """
32
+ # Check if directory exists and has content
33
+ if not shotgun_dir.exists():
34
+ logger.debug("No .shotgun/ directory to backup")
35
+ return None
36
+
37
+ files_to_backup = list(shotgun_dir.rglob("*"))
38
+ if not any(f.is_file() for f in files_to_backup):
39
+ logger.debug(".shotgun/ directory is empty, skipping backup")
40
+ return None
41
+
42
+ # Create backup directory if needed
43
+ BACKUP_DIR.mkdir(parents=True, exist_ok=True)
44
+
45
+ # Generate timestamp-based filename
46
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
47
+ backup_path = BACKUP_DIR / f"{timestamp}.zip"
48
+
49
+ logger.info("Creating backup of .shotgun/ at %s", backup_path)
50
+
51
+ # Create zip file
52
+ with zipfile.ZipFile(backup_path, "w", zipfile.ZIP_DEFLATED) as zipf:
53
+ for file_path in files_to_backup:
54
+ if file_path.is_file():
55
+ # Store with path relative to shotgun_dir
56
+ arcname = file_path.relative_to(shotgun_dir)
57
+ zipf.write(file_path, arcname)
58
+ logger.debug("Added to backup: %s", arcname)
59
+
60
+ logger.info("Backup created successfully: %s", backup_path)
61
+ return str(backup_path)
62
+
63
+
64
+ def clear_shotgun_dir(shotgun_dir: Path) -> None:
65
+ """Clear all contents of the .shotgun/ directory.
66
+
67
+ Removes all files and subdirectories but keeps the .shotgun/ directory itself.
68
+
69
+ Args:
70
+ shotgun_dir: Path to the .shotgun/ directory to clear
71
+ """
72
+ if not shotgun_dir.exists():
73
+ return
74
+
75
+ for item in shotgun_dir.iterdir():
76
+ if item.is_dir():
77
+ shutil.rmtree(item)
78
+ else:
79
+ item.unlink()
80
+
81
+ logger.debug("Cleared contents of %s", shotgun_dir)
@@ -0,0 +1,132 @@
1
+ """Spec management commands for shotgun CLI."""
2
+
3
+ import asyncio
4
+ from typing import Annotated
5
+
6
+ import typer
7
+ from rich.console import Console
8
+ from rich.progress import BarColumn, Progress, SpinnerColumn, TaskID, TextColumn
9
+
10
+ from shotgun.logging_config import get_logger
11
+ from shotgun.shotgun_web.exceptions import (
12
+ ForbiddenError,
13
+ NotFoundError,
14
+ UnauthorizedError,
15
+ )
16
+ from shotgun.tui import app as tui_app
17
+ from shotgun.utils.file_system_utils import get_shotgun_base_path
18
+
19
+ from .models import PullSource
20
+ from .pull_service import CancelledError, PullProgress, SpecPullService
21
+
22
+ app = typer.Typer(
23
+ name="spec",
24
+ help="Manage shared specifications",
25
+ no_args_is_help=True,
26
+ )
27
+ logger = get_logger(__name__)
28
+ console = Console()
29
+
30
+
31
+ @app.command()
32
+ def pull(
33
+ version_id: Annotated[str, typer.Argument(help="Version ID to pull")],
34
+ no_tui: Annotated[
35
+ bool,
36
+ typer.Option("--no-tui", help="Run in CLI-only mode (requires existing auth)"),
37
+ ] = False,
38
+ ) -> None:
39
+ """Pull a spec version from the cloud to local .shotgun/ directory.
40
+
41
+ Downloads all files for the specified version and writes them to the
42
+ local .shotgun/ directory. If the directory already has content, it
43
+ will be backed up to ~/.shotgun-sh/backups/ before being replaced.
44
+
45
+ By default, launches the TUI which handles authentication and shows
46
+ the pull progress. Use --no-tui for scripted/headless use (requires
47
+ existing authentication).
48
+
49
+ Example:
50
+ shotgun spec pull 2532e1c7-7068-4d23-9379-58ea439c592f
51
+ """
52
+ if no_tui:
53
+ # CLI-only mode: do pull directly (requires existing auth)
54
+ success = asyncio.run(_async_pull(version_id))
55
+ if not success:
56
+ raise typer.Exit(1)
57
+ else:
58
+ # TUI mode: launch TUI which handles auth and pull
59
+ tui_app.run(pull_version_id=version_id)
60
+
61
+
62
+ async def _async_pull(version_id: str) -> bool:
63
+ """Async implementation of spec pull command.
64
+
65
+ Returns:
66
+ True if pull was successful, False otherwise.
67
+ """
68
+ shotgun_dir = get_shotgun_base_path()
69
+ service = SpecPullService()
70
+
71
+ # Track current progress state for rich display
72
+ current_task_id: TaskID | None = None
73
+ progress_ctx: Progress | None = None
74
+
75
+ def on_progress(p: PullProgress) -> None:
76
+ nonlocal current_task_id, progress_ctx
77
+ # For CLI, we just update the description - progress bar handled by result
78
+ if progress_ctx and current_task_id is not None:
79
+ progress_ctx.update(current_task_id, description=p.phase)
80
+ if p.total_files and p.file_index is not None:
81
+ pct = ((p.file_index + 1) / p.total_files) * 100
82
+ progress_ctx.update(current_task_id, completed=pct)
83
+
84
+ try:
85
+ with Progress(
86
+ SpinnerColumn(),
87
+ TextColumn("[progress.description]{task.description}"),
88
+ BarColumn(),
89
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
90
+ ) as progress:
91
+ progress_ctx = progress
92
+ current_task_id = progress.add_task("Starting...", total=100)
93
+
94
+ result = await service.pull_version(
95
+ version_id=version_id,
96
+ shotgun_dir=shotgun_dir,
97
+ on_progress=on_progress,
98
+ source=PullSource.CLI,
99
+ )
100
+
101
+ if result.success:
102
+ console.print()
103
+ console.print(f"[green]Successfully pulled '{result.spec_name}'[/green]")
104
+ console.print(f" [dim]Files downloaded:[/dim] {result.file_count}")
105
+ if result.backup_path:
106
+ console.print(f" [dim]Previous backup:[/dim] {result.backup_path}")
107
+ if result.web_url:
108
+ console.print(f" [blue]View in browser:[/blue] {result.web_url}")
109
+ return True
110
+ else:
111
+ console.print(f"[red]Error: {result.error}[/red]")
112
+ return False
113
+
114
+ except UnauthorizedError:
115
+ console.print(
116
+ "[red]Not authenticated. Please re-run the command to login.[/red]"
117
+ )
118
+ raise typer.Exit(1) from None
119
+ except NotFoundError:
120
+ console.print(f"[red]Version not found: {version_id}[/red]")
121
+ console.print("[dim]Check the version ID and try again.[/dim]")
122
+ raise typer.Exit(1) from None
123
+ except ForbiddenError:
124
+ console.print("[red]You don't have access to this spec.[/red]")
125
+ raise typer.Exit(1) from None
126
+ except CancelledError:
127
+ console.print("[yellow]Pull cancelled.[/yellow]")
128
+ raise typer.Exit(1) from None
129
+ except Exception as e:
130
+ logger.exception("Unexpected error in spec pull")
131
+ console.print(f"[red]Unexpected error: {e}[/red]")
132
+ raise typer.Exit(1) from None