├── src
└── shotgun
│ ├── py.typed
│ ├── tui
│ ├── __init__.py
│ ├── screens
│ │ ├── chat_screen
│ │ │ ├── __init__.py
│ │ │ └── history
│ │ │ │ ├── __init__.py
│ │ │ │ ├── user_question.py
│ │ │ │ ├── partial_response.py
│ │ │ │ └── agent_response.py
│ │ ├── chat
│ │ │ ├── __init__.py
│ │ │ ├── codebase_index_selection.py
│ │ │ ├── chat.tcss
│ │ │ ├── prompt_history.py
│ │ │ └── help_text.py
│ │ ├── shared_specs
│ │ │ ├── __init__.py
│ │ │ └── models.py
│ │ ├── chat.tcss
│ │ └── splash.py
│ ├── styles.tcss
│ ├── state
│ │ └── __init__.py
│ ├── utils
│ │ └── __init__.py
│ ├── services
│ │ └── __init__.py
│ ├── layout.py
│ ├── widgets
│ │ └── __init__.py
│ ├── components
│ │ ├── vertical_tail.py
│ │ ├── splash.py
│ │ ├── status_bar.py
│ │ ├── prompt_input.py
│ │ └── spinner.py
│ ├── filtered_codebase_service.py
│ ├── protocols.py
│ └── commands
│ │ └── __init__.py
│ ├── agents
│ ├── __init__.py
│ ├── conversation
│ │ ├── history
│ │ │ ├── __init__.py
│ │ │ ├── constants.py
│ │ │ ├── token_counting
│ │ │ │ ├── __init__.py
│ │ │ │ ├── base.py
│ │ │ │ └── openai.py
│ │ │ └── message_utils.py
│ │ └── __init__.py
│ ├── error
│ │ ├── __init__.py
│ │ └── models.py
│ ├── context_analyzer
│ │ ├── constants.py
│ │ └── __init__.py
│ ├── router
│ │ ├── tools
│ │ │ └── __init__.py
│ │ └── __init__.py
│ ├── config
│ │ ├── __init__.py
│ │ └── constants.py
│ ├── tools
│ │ ├── web_search
│ │ │ └── utils.py
│ │ ├── codebase
│ │ │ ├── __init__.py
│ │ │ └── query_graph.py
│ │ └── __init__.py
│ ├── messages.py
│ └── llm.py
│ ├── cli
│ ├── __init__.py
│ ├── spec
│ │ ├── __init__.py
│ │ ├── models.py
│ │ └── backup.py
│ ├── codebase
│ │ ├── __init__.py
│ │ └── models.py
│ ├── models.py
│ ├── error_handler.py
│ ├── utils.py
│ ├── feedback.py
│ ├── clear.py
│ ├── specify.py
│ ├── plan.py
│ ├── tasks.py
│ └── export.py
│ ├── prompts
│ ├── agents
│ │ ├── __init__.py
│ │ ├── state
│ │ │ ├── codebase
│ │ │ │ └── codebase_graphs_available.j2
│ │ │ └── system_state.j2
│ │ └── partials
│ │ │ ├── interactive_mode.j2
│ │ │ ├── router_delegation_mode.j2
│ │ │ ├── content_formatting.j2
│ │ │ └── common_agent_system_prompt.j2
│ ├── codebase
│ │ ├── __init__.py
│ │ ├── enhanced_query_context.j2
│ │ ├── partials
│ │ │ ├── temporal_context.j2
│ │ │ └── graph_schema.j2
│ │ └── cypher_system.j2
│ ├── history
│ │ ├── __init__.py
│ │ ├── chunk_summarization.j2
│ │ ├── combine_summaries.j2
│ │ ├── summarization.j2
│ │ └── incremental_summarization.j2
│ ├── __init__.py
│ └── tools
│ │ └── web_search.j2
│ ├── __init__.py
│ ├── utils
│ ├── __init__.py
│ ├── source_detection.py
│ ├── env_utils.py
│ ├── file_system_utils.py
│ └── datetime_utils.py
│ ├── codebase
│ ├── __init__.py
│ └── core
│ │ ├── __init__.py
│ │ └── cypher_models.py
│ ├── sdk
│ ├── __init__.py
│ ├── exceptions.py
│ └── services.py
│ ├── llm_proxy
│ ├── constants.py
│ ├── __init__.py
│ └── clients.py
│ ├── api_endpoints.py
│ ├── shotgun_web
│ ├── exceptions.py
│ ├── shared_specs
│ │ ├── utils.py
│ │ ├── __init__.py
│ │ ├── models.py
│ │ └── hasher.py
│ ├── supabase_client.py
│ ├── __init__.py
│ └── constants.py
│ └── telemetry.py
├── test
├── unit
│ ├── codebase
│ │ ├── tools
│ │ │ ├── __init__.py
│ │ │ ├── test_query_graph.py
│ │ │ ├── test_directory_lister.py
│ │ │ ├── conftest.py
│ │ │ ├── test_file_read.py
│ │ │ └── test_retrieve_code.py
│ │ ├── __init__.py
│ │ ├── conftest.py
│ │ └── test_parser_loader.py
│ ├── cli
│ │ ├── __init__.py
│ │ └── spec
│ │ │ ├── __init__.py
│ │ │ └── test_models.py
│ ├── __init__.py
│ ├── agents
│ │ ├── history
│ │ │ └── __init__.py
│ │ ├── router
│ │ │ └── __init__.py
│ │ ├── config
│ │ │ ├── __init__.py
│ │ │ └── test_models.py
│ │ └── test_research_system_prompt.py
│ ├── shotgun_web
│ │ ├── __init__.py
│ │ ├── shared_specs
│ │ │ ├── __init__.py
│ │ │ └── conftest.py
│ │ └── test_supabase_client.py
│ ├── tui
│ │ ├── components
│ │ │ └── __init__.py
│ │ ├── state
│ │ │ └── __init__.py
│ │ ├── widgets
│ │ │ └── __init__.py
│ │ ├── screens
│ │ │ └── shared_specs
│ │ │ │ └── __init__.py
│ │ ├── test_provider_config.py
│ │ └── test_chat_screen_byok_hints.py
│ ├── exceptions
│ │ ├── __init__.py
│ │ └── test_context_size_exception.py
│ └── llm_proxy
│ │ └── __init__.py
└── integration
│ ├── __init__.py
│ ├── sdk
│ ├── __init__.py
│ └── test_sdk_services.py
│ ├── codebase
│ ├── __init__.py
│ └── tools
│ │ ├── __init__.py
│ │ └── test_query_graph.py
│ ├── tools
│ └── web_search
│ │ └── __init__.py
│ └── pytest.ini
├── .github
├── trufflehog-include-lockfile.txt
├── ISSUE_TEMPLATE
│ ├── config.yml
│ ├── feature_request.md
│ ├── bug_report.md
│ └── documentation_request.md
├── CODEOWNERS
└── dependabot.yml
├── docs
├── shotgun_logo.png
├── index_codebase_privacy.png
└── README_DOCKER.md
├── SECURITY
├── evals
├── reporters
│ └── __init__.py
├── suites
│ └── __init__.py
├── datasets
│ ├── __init__.py
│ └── router_agent
│ │ ├── planning_cases.py
│ │ ├── __init__.py
│ │ └── clarifying_questions_cases.py
├── judges
│ └── __init__.py
├── aggregators
│ └── __init__.py
└── evaluators
│ ├── deterministic
│ └── __init__.py
│ └── __init__.py
├── .vscode
└── settings.json
├── lefthook.yml
├── LICENSE
├── .dockerignore
├── .env.example
└── Dockerfile
/src/shotgun/py.typed:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/shotgun/tui/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/unit/codebase/tools/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/shotgun/tui/screens/chat_screen/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/unit/cli/__init__.py:
--------------------------------------------------------------------------------
1 | """CLI unit tests."""
2 |
--------------------------------------------------------------------------------
/.github/trufflehog-include-lockfile.txt:
--------------------------------------------------------------------------------
1 | ^uv\.lock$
2 |
--------------------------------------------------------------------------------
/src/shotgun/agents/__init__.py:
--------------------------------------------------------------------------------
1 | """Shotgun AI Agents."""
2 |
--------------------------------------------------------------------------------
/test/unit/__init__.py:
--------------------------------------------------------------------------------
1 | """Unit tests for Shotgun components."""
2 |
--------------------------------------------------------------------------------
/src/shotgun/cli/__init__.py:
--------------------------------------------------------------------------------
1 | """Commands package for shotgun CLI."""
2 |
--------------------------------------------------------------------------------
/test/unit/agents/history/__init__.py:
--------------------------------------------------------------------------------
1 | """Tests for history module."""
2 |
--------------------------------------------------------------------------------
/test/unit/agents/router/__init__.py:
--------------------------------------------------------------------------------
1 | """Router agent unit tests."""
2 |
--------------------------------------------------------------------------------
/test/unit/cli/spec/__init__.py:
--------------------------------------------------------------------------------
1 | """Tests for CLI spec commands."""
2 |
--------------------------------------------------------------------------------
/test/unit/shotgun_web/__init__.py:
--------------------------------------------------------------------------------
1 | """Tests for shotgun_web module."""
2 |
--------------------------------------------------------------------------------
/test/unit/tui/components/__init__.py:
--------------------------------------------------------------------------------
1 | """Tests for TUI components."""
2 |
--------------------------------------------------------------------------------
/test/unit/tui/state/__init__.py:
--------------------------------------------------------------------------------
1 | """Tests for TUI state management."""
2 |
--------------------------------------------------------------------------------
/test/unit/tui/widgets/__init__.py:
--------------------------------------------------------------------------------
1 | """Unit tests for TUI widgets."""
2 |
--------------------------------------------------------------------------------
/test/unit/agents/config/__init__.py:
--------------------------------------------------------------------------------
1 | """Tests for agents config module."""
2 |
--------------------------------------------------------------------------------
/test/unit/exceptions/__init__.py:
--------------------------------------------------------------------------------
1 | """Unit tests for exceptions module."""
2 |
--------------------------------------------------------------------------------
/test/unit/llm_proxy/__init__.py:
--------------------------------------------------------------------------------
1 | """Unit tests for LiteLLM proxy client."""
2 |
--------------------------------------------------------------------------------
/src/shotgun/prompts/agents/__init__.py:
--------------------------------------------------------------------------------
1 | """Agent-specific prompt templates."""
2 |
--------------------------------------------------------------------------------
/test/integration/__init__.py:
--------------------------------------------------------------------------------
1 | """Integration tests for Shotgun components."""
2 |
--------------------------------------------------------------------------------
/src/shotgun/prompts/codebase/__init__.py:
--------------------------------------------------------------------------------
1 | """Codebase analysis prompt templates."""
2 |
--------------------------------------------------------------------------------
/src/shotgun/prompts/history/__init__.py:
--------------------------------------------------------------------------------
1 | """History processing prompt templates."""
2 |
--------------------------------------------------------------------------------
/test/integration/sdk/__init__.py:
--------------------------------------------------------------------------------
1 | """Integration tests for shotgun SDK module."""
2 |
--------------------------------------------------------------------------------
/test/unit/codebase/__init__.py:
--------------------------------------------------------------------------------
1 | """Unit tests for codebase understanding functionality."""
2 |
--------------------------------------------------------------------------------
/test/unit/shotgun_web/shared_specs/__init__.py:
--------------------------------------------------------------------------------
1 | """Unit tests for shared_specs module."""
2 |
--------------------------------------------------------------------------------
/test/unit/tui/screens/shared_specs/__init__.py:
--------------------------------------------------------------------------------
1 | """Tests for shared specs TUI screens."""
2 |
--------------------------------------------------------------------------------
/docs/shotgun_logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shotgun-sh/shotgun/HEAD/docs/shotgun_logo.png
--------------------------------------------------------------------------------
/test/integration/codebase/__init__.py:
--------------------------------------------------------------------------------
1 | """Integration tests for shotgun codebase module."""
2 |
--------------------------------------------------------------------------------
/test/integration/tools/web_search/__init__.py:
--------------------------------------------------------------------------------
1 | """Integration tests for web search tools."""
2 |
--------------------------------------------------------------------------------
/test/integration/codebase/tools/__init__.py:
--------------------------------------------------------------------------------
1 | """Integration tests for codebase understanding tools."""
2 |
--------------------------------------------------------------------------------
/docs/index_codebase_privacy.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shotgun-sh/shotgun/HEAD/docs/index_codebase_privacy.png
--------------------------------------------------------------------------------
/src/shotgun/cli/spec/__init__.py:
--------------------------------------------------------------------------------
1 | """Spec CLI module."""
2 |
3 | from .commands import app
4 |
5 | __all__ = ["app"]
6 |
--------------------------------------------------------------------------------
/src/shotgun/cli/codebase/__init__.py:
--------------------------------------------------------------------------------
1 | """Codebase CLI module."""
2 |
3 | from .commands import app
4 |
5 | __all__ = ["app"]
6 |
--------------------------------------------------------------------------------
/src/shotgun/__init__.py:
--------------------------------------------------------------------------------
1 | """Shotgun CLI package."""
2 |
3 | from importlib.metadata import version
4 |
5 | __version__ = version("shotgun-sh")
6 |
--------------------------------------------------------------------------------
/src/shotgun/tui/screens/chat/__init__.py:
--------------------------------------------------------------------------------
1 | """Chat screen module."""
2 |
3 | from shotgun.tui.screens.chat.chat_screen import ChatScreen
4 |
5 | __all__ = ["ChatScreen"]
6 |
--------------------------------------------------------------------------------
/src/shotgun/prompts/__init__.py:
--------------------------------------------------------------------------------
1 | """Jinja2 template-based prompt management for Shotgun LLM agents."""
2 |
3 | from .loader import PromptLoader
4 |
5 | __all__ = ["PromptLoader"]
6 |
--------------------------------------------------------------------------------
/src/shotgun/tui/styles.tcss:
--------------------------------------------------------------------------------
1 | Screen {
2 | background: $surface;
3 | }
4 |
5 | Input {
6 | border: round $border-blurred;
7 | &:focus {
8 | border: round $border;
9 | }
10 | }
--------------------------------------------------------------------------------
/src/shotgun/tui/state/__init__.py:
--------------------------------------------------------------------------------
1 | """State management utilities for TUI."""
2 |
3 | from .processing_state import ProcessingStateManager
4 |
5 | __all__ = [
6 | "ProcessingStateManager",
7 | ]
8 |
--------------------------------------------------------------------------------
/src/shotgun/tui/utils/__init__.py:
--------------------------------------------------------------------------------
1 | """TUI utilities package."""
2 |
3 | from .mode_progress import ModeProgressChecker, PlaceholderHints
4 |
5 | __all__ = ["ModeProgressChecker", "PlaceholderHints"]
6 |
--------------------------------------------------------------------------------
/src/shotgun/tui/services/__init__.py:
--------------------------------------------------------------------------------
1 | """Services for TUI business logic."""
2 |
3 | from shotgun.tui.services.conversation_service import ConversationService
4 |
5 | __all__ = ["ConversationService"]
6 |
--------------------------------------------------------------------------------
/src/shotgun/agents/conversation/history/__init__.py:
--------------------------------------------------------------------------------
1 | """History management utilities for Shotgun agents."""
2 |
3 | from .history_processors import token_limit_compactor
4 |
5 | __all__ = ["token_limit_compactor"]
6 |
--------------------------------------------------------------------------------
/src/shotgun/utils/__init__.py:
--------------------------------------------------------------------------------
1 | """Utility functions for the Shotgun package."""
2 |
3 | from .file_system_utils import ensure_shotgun_directory_exists, get_shotgun_home
4 |
5 | __all__ = ["ensure_shotgun_directory_exists", "get_shotgun_home"]
6 |
--------------------------------------------------------------------------------
/src/shotgun/cli/models.py:
--------------------------------------------------------------------------------
1 | """Common models for CLI commands."""
2 |
3 | from enum import StrEnum
4 |
5 |
6 | class OutputFormat(StrEnum):
7 | """Output format options for CLI commands."""
8 |
9 | TEXT = "text"
10 | JSON = "json"
11 | MARKDOWN = "markdown"
12 |
--------------------------------------------------------------------------------
/src/shotgun/tui/layout.py:
--------------------------------------------------------------------------------
1 | """Layout utilities for responsive terminal UI."""
2 |
3 | # Height thresholds for responsive layouts
4 | TINY_HEIGHT_THRESHOLD = 25 # Below this: minimal UI, hide most content
5 | COMPACT_HEIGHT_THRESHOLD = 35 # Below this: reduced padding, hide verbose text
6 |
--------------------------------------------------------------------------------
/src/shotgun/tui/widgets/__init__.py:
--------------------------------------------------------------------------------
1 | """Widget utilities and coordinators for TUI."""
2 |
3 | from shotgun.tui.widgets.plan_panel import PlanPanelWidget
4 | from shotgun.tui.widgets.widget_coordinator import WidgetCoordinator
5 |
6 | __all__ = ["PlanPanelWidget", "WidgetCoordinator"]
7 |
--------------------------------------------------------------------------------
/SECURITY:
--------------------------------------------------------------------------------
1 | # Security Policy
2 |
3 | ## Reporting a Vulnerability
4 |
5 | **Do not open public issues for security vulnerabilities.**
6 |
7 | Email contact@shotgun.sh with:
8 | - Description of the vulnerability
9 | - Steps to reproduce
10 | - Potential impact
11 |
12 | We'll respond within 48 hours.
13 |
--------------------------------------------------------------------------------
/src/shotgun/agents/error/__init__.py:
--------------------------------------------------------------------------------
1 | """Agent error handling module.
2 |
3 | This module provides the AgentErrorContext model used by AgentRunner
4 | for error classification.
5 | """
6 |
7 | from shotgun.agents.error.models import AgentErrorContext
8 |
9 | __all__ = [
10 | "AgentErrorContext",
11 | ]
12 |
--------------------------------------------------------------------------------
/src/shotgun/tui/screens/chat/codebase_index_selection.py:
--------------------------------------------------------------------------------
1 | """Codebase indexing selection models."""
2 |
3 | from pathlib import Path
4 |
5 | from pydantic import BaseModel
6 |
7 |
8 | class CodebaseIndexSelection(BaseModel):
9 | """User-selected repository path and name for indexing."""
10 |
11 | repo_path: Path
12 | name: str
13 |
--------------------------------------------------------------------------------
/evals/reporters/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Report formatters for evaluation results.
3 |
4 | Provides console and JSON output formats for evaluation reports.
5 | """
6 |
7 | from evals.reporters.console import ConsoleReporter
8 | from evals.reporters.json_reporter import JSONReporter
9 |
10 | __all__ = [
11 | "ConsoleReporter",
12 | "JSONReporter",
13 | ]
14 |
--------------------------------------------------------------------------------
/evals/suites/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Evaluation suites for Shotgun agents.
3 |
4 | Organized by agent type:
5 | - router_suites: Router delegation and workflow test suites
6 | """
7 |
8 | from evals.suites.router_suites import ROUTER_SUITES, router_core, router_smoke
9 |
10 | __all__ = [
11 | "ROUTER_SUITES",
12 | "router_smoke",
13 | "router_core",
14 | ]
15 |
--------------------------------------------------------------------------------
/src/shotgun/codebase/__init__.py:
--------------------------------------------------------------------------------
1 | """Shotgun codebase analysis and graph management."""
2 |
3 | from shotgun.codebase.models import CodebaseGraph, GraphStatus, QueryResult, QueryType
4 | from shotgun.codebase.service import CodebaseService
5 |
6 | __all__ = [
7 | "CodebaseService",
8 | "CodebaseGraph",
9 | "GraphStatus",
10 | "QueryResult",
11 | "QueryType",
12 | ]
13 |
--------------------------------------------------------------------------------
/src/shotgun/agents/context_analyzer/constants.py:
--------------------------------------------------------------------------------
1 | """Tool category registry for context analysis.
2 |
3 | This module re-exports the tool registry functionality for backward compatibility.
4 | The actual implementation is in shotgun.agents.tools.registry.
5 | """
6 |
7 | from shotgun.agents.tools.registry import ToolCategory, get_tool_category
8 |
9 | __all__ = ["ToolCategory", "get_tool_category"]
10 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: false
2 | contact_links:
3 | - name: Questions & Support
4 | url: https://github.com/shotgun-sh/shotgun/discussions
5 | about: Ask questions and discuss Shotgun with the community
6 | - name: General Discussion
7 | url: https://github.com/shotgun-sh/shotgun/discussions/categories/general
8 | about: General discussion about Shotgun and its features
9 |
--------------------------------------------------------------------------------
/src/shotgun/sdk/__init__.py:
--------------------------------------------------------------------------------
1 | """Shotgun SDK - Framework-agnostic business logic for CLI and TUI."""
2 |
3 | from .codebase import CodebaseSDK
4 | from .exceptions import CodebaseNotFoundError, CodebaseOperationError, ShotgunSDKError
5 | from .services import get_codebase_service
6 |
7 | __all__ = [
8 | "CodebaseSDK",
9 | "ShotgunSDKError",
10 | "CodebaseNotFoundError",
11 | "CodebaseOperationError",
12 | "get_codebase_service",
13 | ]
14 |
--------------------------------------------------------------------------------
/src/shotgun/llm_proxy/constants.py:
--------------------------------------------------------------------------------
1 | """LiteLLM proxy constants and configuration."""
2 |
3 | # Import from centralized API endpoints module
4 | from shotgun.api_endpoints import (
5 | LITELLM_PROXY_ANTHROPIC_BASE,
6 | LITELLM_PROXY_BASE_URL,
7 | LITELLM_PROXY_OPENAI_BASE,
8 | )
9 |
10 | # Re-export for backward compatibility
11 | __all__ = [
12 | "LITELLM_PROXY_BASE_URL",
13 | "LITELLM_PROXY_ANTHROPIC_BASE",
14 | "LITELLM_PROXY_OPENAI_BASE",
15 | ]
16 |
--------------------------------------------------------------------------------
/src/shotgun/agents/router/tools/__init__.py:
--------------------------------------------------------------------------------
1 | """Router tools package."""
2 |
3 | from shotgun.agents.router.tools.plan_tools import (
4 | add_step,
5 | create_plan,
6 | mark_step_done,
7 | remove_step,
8 | )
9 |
10 | # Note: Delegation tools are imported directly in router.py to use
11 | # the prepare_delegation_tool function for conditional visibility.
12 |
13 | __all__ = [
14 | "add_step",
15 | "create_plan",
16 | "mark_step_done",
17 | "remove_step",
18 | ]
19 |
--------------------------------------------------------------------------------
/src/shotgun/cli/codebase/models.py:
--------------------------------------------------------------------------------
1 | """Re-export SDK models for backward compatibility."""
2 |
3 | from shotgun.sdk.models import (
4 | DeleteResult,
5 | ErrorResult,
6 | IndexResult,
7 | InfoResult,
8 | ListResult,
9 | QueryCommandResult,
10 | ReindexResult,
11 | )
12 |
13 | __all__ = [
14 | "ListResult",
15 | "IndexResult",
16 | "DeleteResult",
17 | "InfoResult",
18 | "QueryCommandResult",
19 | "ReindexResult",
20 | "ErrorResult",
21 | ]
22 |
--------------------------------------------------------------------------------
/src/shotgun/sdk/exceptions.py:
--------------------------------------------------------------------------------
1 | """SDK-specific exceptions."""
2 |
3 |
4 | class ShotgunSDKError(Exception):
5 | """Base exception for all SDK operations."""
6 |
7 |
8 | class CodebaseNotFoundError(ShotgunSDKError):
9 | """Raised when a codebase or graph is not found."""
10 |
11 |
12 | class CodebaseOperationError(ShotgunSDKError):
13 | """Raised when a codebase operation fails."""
14 |
15 |
16 | class InvalidPathError(ShotgunSDKError):
17 | """Raised when a provided path is invalid."""
18 |
--------------------------------------------------------------------------------
/test/unit/codebase/conftest.py:
--------------------------------------------------------------------------------
1 | """Configuration and fixtures for codebase unit tests.
2 |
3 | This file now only contains codebase-specific fixtures that are not
4 | shared across different test types. The shared Kuzu cleanup fixtures
5 | are now provided by the root test/conftest.py file.
6 | """
7 |
8 | # All Kuzu helper fixtures (cleanup_before_tests, cleanup_kuzu_state,
9 | # unique_graph_id, temp_storage_path) are now provided by the shared
10 | # test/conftest.py file and will be automatically available to these tests.
11 |
--------------------------------------------------------------------------------
/evals/datasets/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Test case datasets for Shotgun agent evaluation.
3 |
4 | Datasets are organized by agent type:
5 | datasets/
6 | ├── router_agent/ # Router clarifying questions tests
7 | ├── research_agent/ # Research capability tests (future)
8 | ├── specify_agent/ # Specification generation tests (future)
9 | └── ...
10 | """
11 |
12 | from evals.datasets.router_agent import ALL_ROUTER_CASES, CLARIFYING_QUESTIONS_CASES
13 |
14 | __all__ = ["CLARIFYING_QUESTIONS_CASES", "ALL_ROUTER_CASES"]
15 |
--------------------------------------------------------------------------------
/src/shotgun/utils/source_detection.py:
--------------------------------------------------------------------------------
1 | """Utility for detecting the source of function calls (CLI vs TUI)."""
2 |
3 | import inspect
4 |
5 |
6 | def detect_source() -> str:
7 | """Detect if the call originated from CLI or TUI by inspecting the call stack.
8 |
9 | Returns:
10 | "tui" if any frame in the call stack contains "tui" in the filename,
11 | "cli" otherwise.
12 | """
13 | for frame_info in inspect.stack():
14 | if "tui" in frame_info.filename.lower():
15 | return "tui"
16 | return "cli"
17 |
--------------------------------------------------------------------------------
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | # CODEOWNERS
2 | # This file defines code ownership for the Shotgun repository.
3 | #
4 | # Each line is a file pattern followed by one or more owners.
5 | # When a pull request is opened, the specified owners will be
6 | # automatically requested for review.
7 | #
8 | # For more information about CODEOWNERS, see:
9 | # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
10 |
11 | # Global owner - all files require review from @scottfrasso
12 | * @scottfrasso
13 |
--------------------------------------------------------------------------------
/evals/judges/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | LLM judge implementations for Shotgun agent evaluation.
3 |
4 | Judges use LLM-as-a-judge to evaluate agent outputs against rubrics.
5 | """
6 |
7 | from evals.judges.router_quality_judge import RouterQualityJudge
8 | from evals.models import (
9 | DimensionScoreOutput,
10 | RouterDimension,
11 | RouterDimensionRubric,
12 | RouterJudgeResult,
13 | )
14 |
15 | __all__ = [
16 | "RouterQualityJudge",
17 | "RouterJudgeResult",
18 | "RouterDimensionRubric",
19 | "RouterDimension",
20 | "DimensionScoreOutput",
21 | ]
22 |
--------------------------------------------------------------------------------
/evals/aggregators/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Aggregators for combining evaluation results from multiple sources.
3 |
4 | Aggregators combine:
5 | - Deterministic evaluator results
6 | - LLM judge results
7 | - Per-dimension scores
8 |
9 | Into a final score and pass/fail determination.
10 | """
11 |
12 | from evals.aggregators.router_aggregator import RouterAggregator
13 | from evals.models import AggregatedResult, DimensionAggregate, DimensionSource
14 |
15 | __all__ = [
16 | "RouterAggregator",
17 | "AggregatedResult",
18 | "DimensionAggregate",
19 | "DimensionSource",
20 | ]
21 |
--------------------------------------------------------------------------------
/src/shotgun/agents/config/__init__.py:
--------------------------------------------------------------------------------
1 | """Configuration module for Shotgun CLI."""
2 |
3 | from .manager import (
4 | BACKUP_DIR_NAME,
5 | ConfigManager,
6 | ConfigMigrationError,
7 | get_backup_dir,
8 | get_config_manager,
9 | )
10 | from .models import ProviderType, ShotgunConfig
11 | from .provider import get_provider_model
12 |
13 | __all__ = [
14 | "BACKUP_DIR_NAME",
15 | "ConfigManager",
16 | "ConfigMigrationError",
17 | "get_backup_dir",
18 | "get_config_manager",
19 | "ProviderType",
20 | "ShotgunConfig",
21 | "get_provider_model",
22 | ]
23 |
--------------------------------------------------------------------------------
/src/shotgun/agents/conversation/__init__.py:
--------------------------------------------------------------------------------
1 | """Conversation module for managing conversation history and persistence."""
2 |
3 | from .filters import (
4 | filter_incomplete_messages,
5 | filter_orphaned_tool_responses,
6 | is_tool_call_complete,
7 | )
8 | from .manager import ConversationManager
9 | from .models import ConversationHistory, ConversationState
10 |
11 | __all__ = [
12 | "ConversationHistory",
13 | "ConversationManager",
14 | "ConversationState",
15 | "filter_incomplete_messages",
16 | "filter_orphaned_tool_responses",
17 | "is_tool_call_complete",
18 | ]
19 |
--------------------------------------------------------------------------------
/src/shotgun/agents/error/models.py:
--------------------------------------------------------------------------------
1 | """Pydantic models for agent error handling."""
2 |
3 | from typing import Any
4 |
5 | from pydantic import BaseModel, ConfigDict, Field
6 |
7 |
8 | class AgentErrorContext(BaseModel):
9 | """Context information needed to classify and handle agent errors.
10 |
11 | Attributes:
12 | exception: The exception that was raised
13 | is_shotgun_account: Whether the user is using a Shotgun Account
14 | """
15 |
16 | model_config = ConfigDict(arbitrary_types_allowed=True)
17 |
18 | exception: Any = Field(...)
19 | is_shotgun_account: bool
20 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "python.defaultInterpreter": "${workspaceFolder}/.venv/bin/python",
3 | "python.pythonPath": "${workspaceFolder}/.venv/bin/python",
4 | "python.analysis.extraPaths": [
5 | "${workspaceFolder}/src"
6 | ],
7 | "python.envFile": "${workspaceFolder}/.env",
8 | "python.testing.pytestEnabled": false,
9 | "python.testing.unittestEnabled": false,
10 | "python.linting.enabled": true,
11 | "python.linting.ruffEnabled": true,
12 | "python.formatting.provider": "ruff",
13 | "python.analysis.autoImportCompletions": true,
14 | "python.analysis.typeCheckingMode": "basic"
15 | }
--------------------------------------------------------------------------------
/src/shotgun/prompts/tools/web_search.j2:
--------------------------------------------------------------------------------
1 | Your training data may be old. The current date and time is: {{ current_datetime }} ({{ timezone_name }}, {{ utc_offset }})
2 |
3 | Please provide current and accurate information about the following query:
4 |
5 | Query: {{ query }}
6 |
7 | Instructions:
8 | - Provide comprehensive, factual information
9 | - Include relevant details and context
10 | - Focus on current and recent information
11 | - Be specific and accurate in your response
12 | - You can't ask the user for details, so assume the most relevant details for the query
13 |
14 | ALWAYS PROVIDE THE SOURCES (urls) TO BACK UP THE INFORMATION YOU PROVIDE.
15 |
--------------------------------------------------------------------------------
/src/shotgun/agents/tools/web_search/utils.py:
--------------------------------------------------------------------------------
1 | """Utility functions for web search tools."""
2 |
3 | from shotgun.agents.config import get_provider_model
4 | from shotgun.agents.config.models import ProviderType
5 |
6 |
7 | async def is_provider_available(provider: ProviderType) -> bool:
8 | """Check if a provider has API key configured.
9 |
10 | Args:
11 | provider: The provider to check
12 |
13 | Returns:
14 | True if the provider has valid credentials configured (from config or env)
15 | """
16 | try:
17 | await get_provider_model(provider)
18 | return True
19 | except ValueError:
20 | return False
21 |
--------------------------------------------------------------------------------
/src/shotgun/api_endpoints.py:
--------------------------------------------------------------------------------
1 | """Shotgun backend service API endpoints and URLs."""
2 |
3 | from shotgun.settings import settings
4 |
5 | # Shotgun Web API base URL (for authentication/subscription)
6 | # Can be overridden with SHOTGUN_WEB_BASE_URL environment variable
7 | SHOTGUN_WEB_BASE_URL = settings.api.web_base_url
8 |
9 | # Shotgun's LiteLLM proxy base URL (for AI model requests)
10 | # Can be overridden with SHOTGUN_ACCOUNT_LLM_BASE_URL environment variable
11 | LITELLM_PROXY_BASE_URL = settings.api.account_llm_base_url
12 |
13 | # Provider-specific LiteLLM proxy endpoints
14 | LITELLM_PROXY_ANTHROPIC_BASE = f"{LITELLM_PROXY_BASE_URL}/anthropic"
15 | LITELLM_PROXY_OPENAI_BASE = LITELLM_PROXY_BASE_URL
16 |
--------------------------------------------------------------------------------
/src/shotgun/tui/screens/chat_screen/history/__init__.py:
--------------------------------------------------------------------------------
1 | """Chat history package - displays conversation messages in the TUI.
2 |
3 | This package provides widgets for displaying chat history including:
4 | - User questions
5 | - Agent responses
6 | - Tool calls
7 | - Streaming/partial responses
8 | """
9 |
10 | from .agent_response import AgentResponseWidget
11 | from .chat_history import ChatHistory
12 | from .formatters import ToolFormatter
13 | from .partial_response import PartialResponseWidget
14 | from .user_question import UserQuestionWidget
15 |
16 | __all__ = [
17 | "ChatHistory",
18 | "PartialResponseWidget",
19 | "AgentResponseWidget",
20 | "UserQuestionWidget",
21 | "ToolFormatter",
22 | ]
23 |
--------------------------------------------------------------------------------
/src/shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2:
--------------------------------------------------------------------------------
1 | ## Codebase Graphs Available
2 |
3 | {% if codebase_understanding_graphs -%}
4 |
5 | You have access to the following codebase graphs:
6 |
7 | {% for graph in codebase_understanding_graphs -%}
8 | - {{ graph.name }} ID: {{ graph.graph_id }} Path: {{ graph.repo_path }}
9 | {% endfor -%}
10 |
11 | {% else -%}
12 |
13 | {% if is_tui_context -%}
14 | No codebase has been indexed yet. To enable code analysis, please tell the user to restart the TUI and follow the prompt to 'Index this codebase?' when it appears.
15 | {% else -%}
16 | No codebase has been indexed yet. If the user needs code analysis, ask them to index a codebase first.
17 | {% endif -%}
18 |
19 | {% endif %}
--------------------------------------------------------------------------------
/src/shotgun/tui/components/vertical_tail.py:
--------------------------------------------------------------------------------
1 | from textual.containers import VerticalScroll
2 | from textual.geometry import Size
3 | from textual.reactive import reactive
4 |
5 |
6 | class VerticalTail(VerticalScroll):
7 | """A vertical scroll container that automatically scrolls to the bottom when content is added."""
8 |
9 | auto_scroll = reactive(True, layout=False)
10 |
11 | def watch_auto_scroll(self, value: bool) -> None:
12 | """Handle auto_scroll property changes."""
13 | if value:
14 | self.scroll_end(animate=False)
15 |
16 | def watch_virtual_size(self, value: Size) -> None:
17 | """Handle virtual_size property changes."""
18 |
19 | self.call_later(self.scroll_end, animate=False)
20 |
--------------------------------------------------------------------------------
/src/shotgun/tui/screens/shared_specs/__init__.py:
--------------------------------------------------------------------------------
1 | """Shared specs TUI screens and dialogs."""
2 |
3 | from shotgun.tui.screens.shared_specs.create_spec_dialog import CreateSpecDialog
4 | from shotgun.tui.screens.shared_specs.models import (
5 | CreateSpecResult,
6 | ShareSpecsAction,
7 | ShareSpecsResult,
8 | UploadScreenResult,
9 | )
10 | from shotgun.tui.screens.shared_specs.share_specs_dialog import ShareSpecsDialog
11 | from shotgun.tui.screens.shared_specs.upload_progress_screen import UploadProgressScreen
12 |
13 | __all__ = [
14 | "CreateSpecDialog",
15 | "CreateSpecResult",
16 | "ShareSpecsAction",
17 | "ShareSpecsDialog",
18 | "ShareSpecsResult",
19 | "UploadProgressScreen",
20 | "UploadScreenResult",
21 | ]
22 |
--------------------------------------------------------------------------------
/src/shotgun/cli/error_handler.py:
--------------------------------------------------------------------------------
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")
25 |
--------------------------------------------------------------------------------
/src/shotgun/prompts/codebase/enhanced_query_context.j2:
--------------------------------------------------------------------------------
1 | Current datetime: {{ current_datetime }} (Unix timestamp: {{ current_timestamp }})
2 |
3 | User query: {{ natural_language_query }}
4 |
5 | IMPORTANT: All timestamps in the database are stored as Unix timestamps (INT64). When generating time-based queries:
6 | - For "2 minutes ago": use {{ current_timestamp - 120 }}
7 | - For "1 hour ago": use {{ current_timestamp - 3600 }}
8 | - For "today": use timestamps >= {{ current_timestamp - (current_timestamp % 86400) }}
9 | - For "yesterday": use timestamps between {{ current_timestamp - 86400 - (current_timestamp % 86400) }} and {{ current_timestamp - (current_timestamp % 86400) }}
10 | - NEVER use placeholder values like 1704067200, always calculate based on the current timestamp: {{ current_timestamp }}
--------------------------------------------------------------------------------
/src/shotgun/tui/components/splash.py:
--------------------------------------------------------------------------------
1 | from textual.app import RenderResult
2 | from textual.widgets import Static
3 |
4 | ART = """
5 |
6 | ███████╗██╗ ██╗ ██████╗ ████████╗ ██████╗ ██╗ ██╗███╗ ██╗
7 | ██╔════╝██║ ██║██╔═══██╗╚══██╔══╝██╔════╝ ██║ ██║████╗ ██║
8 | ███████╗███████║██║ ██║ ██║ ██║ ███╗██║ ██║██╔██╗ ██║
9 | ╚════██║██╔══██║██║ ██║ ██║ ██║ ██║██║ ██║██║╚██╗██║
10 | ███████║██║ ██║╚██████╔╝ ██║ ╚██████╔╝╚██████╔╝██║ ╚████║
11 | ╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═══╝
12 |
13 | """
14 |
15 |
16 | class SplashWidget(Static):
17 | DEFAULT_CSS = """
18 | SplashWidget {
19 | text-align: center;
20 | width: 64;
21 | }
22 | """
23 |
24 | def render(self) -> RenderResult:
25 | return ART
26 |
--------------------------------------------------------------------------------
/src/shotgun/agents/tools/codebase/__init__.py:
--------------------------------------------------------------------------------
1 | """Codebase understanding tools for Pydantic AI agents."""
2 |
3 | from .codebase_shell import codebase_shell
4 | from .directory_lister import directory_lister
5 | from .file_read import file_read
6 | from .models import (
7 | CodeSnippetResult,
8 | DirectoryListResult,
9 | FileReadResult,
10 | QueryGraphResult,
11 | ShellCommandResult,
12 | )
13 | from .query_graph import query_graph
14 | from .retrieve_code import retrieve_code
15 |
16 | __all__ = [
17 | "query_graph",
18 | "retrieve_code",
19 | "file_read",
20 | "directory_lister",
21 | "codebase_shell",
22 | # Result models
23 | "QueryGraphResult",
24 | "CodeSnippetResult",
25 | "FileReadResult",
26 | "DirectoryListResult",
27 | "ShellCommandResult",
28 | ]
29 |
--------------------------------------------------------------------------------
/src/shotgun/sdk/services.py:
--------------------------------------------------------------------------------
1 | """Service factory functions for SDK."""
2 |
3 | from pathlib import Path
4 |
5 | from shotgun.codebase.service import CodebaseService
6 | from shotgun.utils import get_shotgun_home
7 |
8 |
9 | def get_codebase_service(storage_dir: Path | str | None = None) -> CodebaseService:
10 | """Get CodebaseService instance with configurable storage.
11 |
12 | Args:
13 | storage_dir: Optional custom storage directory.
14 | Defaults to ~/.shotgun-sh/codebases/
15 |
16 | Returns:
17 | Configured CodebaseService instance
18 | """
19 | if storage_dir is None:
20 | storage_dir = get_shotgun_home() / "codebases"
21 | elif isinstance(storage_dir, str):
22 | storage_dir = Path(storage_dir)
23 | return CodebaseService(storage_dir)
24 |
--------------------------------------------------------------------------------
/src/shotgun/tui/screens/chat.tcss:
--------------------------------------------------------------------------------
1 | ChatHistory {
2 | height: auto;
3 | }
4 |
5 | PromptInput {
6 | min-height: 3;
7 | max-height: 7;
8 | height: auto;
9 | }
10 |
11 | StatusBar {
12 | height: auto;
13 | }
14 |
15 | ModeIndicator {
16 | height: auto;
17 | }
18 |
19 | #footer {
20 | dock: bottom;
21 | height: auto;
22 | padding: 1 1 1 2;
23 | max-height: 14;
24 | }
25 |
26 | #window {
27 | align: left bottom;
28 | }
29 |
30 | .hidden {
31 | display: none;
32 | }
33 |
34 | #footer > Grid {
35 | height: auto;
36 | grid-columns: 1fr auto;
37 | grid-size: 2;
38 | }
39 |
40 |
41 | #right-footer-indicators {
42 | width: auto;
43 | height: auto;
44 | layout: vertical;
45 | }
46 |
47 | #context-indicator {
48 | text-align: end;
49 | height: 1;
50 | }
51 |
52 | #indexing-job-display {
53 | text-align: end;
54 | }
--------------------------------------------------------------------------------
/src/shotgun/agents/conversation/history/constants.py:
--------------------------------------------------------------------------------
1 | """Constants for history processing and compaction."""
2 |
3 | from enum import Enum
4 |
5 | # Summary marker for compacted history
6 | SUMMARY_MARKER = "📌 COMPACTED_HISTORY:"
7 |
8 | # Token calculation constants
9 | INPUT_BUFFER_TOKENS = 500
10 | MIN_SUMMARY_TOKENS = 100
11 | TOKEN_LIMIT_RATIO = 0.8
12 |
13 | # Chunked compaction constants
14 | CHUNK_TARGET_RATIO = 0.60 # Target chunk size as % of max_input_tokens
15 | CHUNK_SAFE_RATIO = 0.70 # Max safe ratio before triggering chunked compaction
16 | RETENTION_WINDOW_MESSAGES = 5 # Keep last N message groups outside compaction
17 |
18 |
19 | class SummaryType(Enum):
20 | """Types of summarization requests for logging."""
21 |
22 | INCREMENTAL = "INCREMENTAL"
23 | FULL = "FULL"
24 | CONTEXT_EXTRACTION = "CONTEXT_EXTRACTION"
25 |
--------------------------------------------------------------------------------
/src/shotgun/cli/utils.py:
--------------------------------------------------------------------------------
1 | """Common utilities for CLI commands."""
2 |
3 | import json
4 | from typing import Any
5 |
6 | from pydantic import BaseModel
7 |
8 | from .models import OutputFormat
9 |
10 |
11 | def format_result_json(result: Any) -> str:
12 | """Format result object as JSON using Pydantic serialization."""
13 | if isinstance(result, BaseModel):
14 | return result.model_dump_json(indent=2)
15 | else:
16 | # Fallback for non-Pydantic objects
17 | return json.dumps({"result": str(result)}, indent=2)
18 |
19 |
20 | def output_result(result: Any, format_type: OutputFormat = OutputFormat.TEXT) -> None:
21 | """Output result in specified format."""
22 | if format_type == OutputFormat.JSON:
23 | print(format_result_json(result))
24 | else: # Default to text
25 | print(str(result))
26 |
--------------------------------------------------------------------------------
/src/shotgun/shotgun_web/exceptions.py:
--------------------------------------------------------------------------------
1 | """Typed exceptions for Shotgun Web API operations."""
2 |
3 |
4 | class ShotgunWebError(Exception):
5 | """Base exception for Shotgun Web API operations."""
6 |
7 |
8 | class UnauthorizedError(ShotgunWebError):
9 | """401 - Missing or invalid authentication token."""
10 |
11 |
12 | class ForbiddenError(ShotgunWebError):
13 | """403 - Insufficient permissions for the requested operation."""
14 |
15 |
16 | class NotFoundError(ShotgunWebError):
17 | """404 - Resource not found."""
18 |
19 |
20 | class ConflictError(ShotgunWebError):
21 | """409 - Name conflict or duplicate path."""
22 |
23 |
24 | class PayloadTooLargeError(ShotgunWebError):
25 | """413 - File exceeds maximum size limit."""
26 |
27 |
28 | class RateLimitExceededError(ShotgunWebError):
29 | """429 - Rate limit exceeded."""
30 |
--------------------------------------------------------------------------------
/src/shotgun/tui/screens/splash.py:
--------------------------------------------------------------------------------
1 | from textual.app import ComposeResult
2 | from textual.containers import Container
3 | from textual.screen import Screen
4 |
5 | from ..components.splash import SplashWidget
6 |
7 |
8 | class SplashScreen(Screen[None]):
9 | CSS = """
10 | #splash-container {
11 | align: center middle;
12 | width: 100%;
13 | height: 100%;
14 |
15 | }
16 |
17 | SplashWidget {
18 | color: $text-accent;
19 | }
20 | """
21 | """Splash screen for the app."""
22 |
23 | def on_mount(self) -> None:
24 | self.set_timer(2, self.on_timer_tick)
25 |
26 | def on_timer_tick(self) -> None:
27 | self.dismiss()
28 |
29 | def compose(self) -> ComposeResult:
30 | with Container(id="splash-container"):
31 | yield SplashWidget()
32 |
--------------------------------------------------------------------------------
/src/shotgun/agents/tools/__init__.py:
--------------------------------------------------------------------------------
1 | """Tools package for Pydantic AI agents."""
2 |
3 | from .codebase import (
4 | codebase_shell,
5 | directory_lister,
6 | file_read,
7 | query_graph,
8 | retrieve_code,
9 | )
10 | from .file_management import append_file, read_file, write_file
11 | from .web_search import (
12 | anthropic_web_search_tool,
13 | gemini_web_search_tool,
14 | get_available_web_search_tools,
15 | openai_web_search_tool,
16 | )
17 |
18 | __all__ = [
19 | "openai_web_search_tool",
20 | "anthropic_web_search_tool",
21 | "gemini_web_search_tool",
22 | "get_available_web_search_tools",
23 | "read_file",
24 | "write_file",
25 | "append_file",
26 | # Codebase understanding tools
27 | "query_graph",
28 | "retrieve_code",
29 | "file_read",
30 | "directory_lister",
31 | "codebase_shell",
32 | ]
33 |
--------------------------------------------------------------------------------
/test/unit/tui/test_provider_config.py:
--------------------------------------------------------------------------------
1 | """Unit tests for provider_config screen module."""
2 |
3 | import pytest
4 |
5 | from shotgun.tui.screens.provider_config import get_configurable_providers
6 |
7 |
8 | @pytest.mark.smoke
9 | def test_get_configurable_providers_includes_shotgun():
10 | """Test that get_configurable_providers always includes shotgun."""
11 | providers = get_configurable_providers()
12 |
13 | assert "shotgun" in providers
14 | assert "openai" in providers
15 | assert "anthropic" in providers
16 | assert "google" in providers
17 |
18 |
19 | def test_get_configurable_providers_returns_all_providers():
20 | """Test that get_configurable_providers returns all four providers."""
21 | providers = get_configurable_providers()
22 |
23 | assert len(providers) == 4
24 | assert providers == ["openai", "anthropic", "google", "shotgun"]
25 |
--------------------------------------------------------------------------------
/src/shotgun/agents/config/constants.py:
--------------------------------------------------------------------------------
1 | """Configuration constants for Shotgun agents."""
2 |
3 | from enum import StrEnum, auto
4 |
5 | # Field names
6 | API_KEY_FIELD = "api_key"
7 | SUPABASE_JWT_FIELD = "supabase_jwt"
8 | SHOTGUN_INSTANCE_ID_FIELD = "shotgun_instance_id"
9 | CONFIG_VERSION_FIELD = "config_version"
10 |
11 |
12 | class ConfigSection(StrEnum):
13 | """Configuration file section names (JSON keys)."""
14 |
15 | OPENAI = auto()
16 | ANTHROPIC = auto()
17 | GOOGLE = auto()
18 | SHOTGUN = auto()
19 |
20 |
21 | # Backwards compatibility - deprecated
22 | OPENAI_PROVIDER = ConfigSection.OPENAI.value
23 | ANTHROPIC_PROVIDER = ConfigSection.ANTHROPIC.value
24 | GOOGLE_PROVIDER = ConfigSection.GOOGLE.value
25 | SHOTGUN_PROVIDER = ConfigSection.SHOTGUN.value
26 |
27 | # Token limits
28 | MEDIUM_TEXT_8K_TOKENS = 8192 # Default max_tokens for web search requests
29 |
--------------------------------------------------------------------------------
/src/shotgun/agents/context_analyzer/__init__.py:
--------------------------------------------------------------------------------
1 | """Context analysis module for conversation composition statistics.
2 |
3 | This module provides tools for analyzing conversation context usage, breaking down
4 | token consumption by message type and tool category.
5 | """
6 |
7 | from .analyzer import ContextAnalyzer
8 | from .constants import ToolCategory, get_tool_category
9 | from .formatter import ContextFormatter
10 | from .models import (
11 | ContextAnalysis,
12 | ContextAnalysisOutput,
13 | ContextCompositionTelemetry,
14 | MessageTypeStats,
15 | TokenAllocation,
16 | )
17 |
18 | __all__ = [
19 | "ContextAnalyzer",
20 | "ContextAnalysis",
21 | "ContextAnalysisOutput",
22 | "ContextCompositionTelemetry",
23 | "ContextFormatter",
24 | "MessageTypeStats",
25 | "TokenAllocation",
26 | "ToolCategory",
27 | "get_tool_category",
28 | ]
29 |
--------------------------------------------------------------------------------
/lefthook.yml:
--------------------------------------------------------------------------------
1 | pre-commit:
2 | parallel: true
3 | commands:
4 | ruff:
5 | run: uv run ruff check --fix {staged_files}
6 | glob: "*.py"
7 | stage_fixed: true
8 | ruff-format:
9 | run: uv run ruff format {staged_files}
10 | glob: "*.py"
11 | stage_fixed: true
12 | mypy:
13 | run: uv run mypy {staged_files}
14 | glob: "*.py"
15 | exclude: "(^test/|hatch_build\\.py)"
16 | trufflehog-main:
17 | run: trufflehog git file://. --since-commit HEAD --fail --no-update --exclude-globs=uv.lock
18 | trufflehog-lockfile:
19 | run: trufflehog git file://. --since-commit HEAD --fail --no-update --include-paths=.github/trufflehog-include-lockfile.txt --exclude-detectors=SentryToken
20 | actionlint:
21 | run: actionlint {staged_files}
22 | glob: ".github/workflows/*.yml"
23 | skip: true
24 |
25 | commit-msg:
26 | commands:
27 | commitizen:
28 | run: uv run cz check --commit-msg-file {1}
29 |
--------------------------------------------------------------------------------
/src/shotgun/shotgun_web/shared_specs/utils.py:
--------------------------------------------------------------------------------
1 | """Utility functions for shared specs module."""
2 |
3 | from enum import StrEnum
4 |
5 |
6 | class UploadPhase(StrEnum):
7 | """Upload pipeline phases."""
8 |
9 | CREATING = "creating" # Creating spec/version via API
10 | SCANNING = "scanning"
11 | HASHING = "hashing"
12 | UPLOADING = "uploading"
13 | CLOSING = "closing"
14 | COMPLETE = "complete"
15 | ERROR = "error"
16 |
17 |
18 | def format_bytes(size: int) -> str:
19 | """Format bytes as human-readable string.
20 |
21 | Args:
22 | size: Size in bytes
23 |
24 | Returns:
25 | Human-readable string like "1.5 KB" or "2.3 MB"
26 | """
27 | if size < 1024:
28 | return f"{size} B"
29 | elif size < 1024 * 1024:
30 | return f"{size / 1024:.1f} KB"
31 | elif size < 1024 * 1024 * 1024:
32 | return f"{size / (1024 * 1024):.1f} MB"
33 | else:
34 | return f"{size / (1024 * 1024 * 1024):.1f} GB"
35 |
--------------------------------------------------------------------------------
/src/shotgun/llm_proxy/__init__.py:
--------------------------------------------------------------------------------
1 | """LiteLLM proxy client utilities and configuration."""
2 |
3 | from .client import LiteLLMProxyClient, get_budget_info
4 | from .clients import (
5 | create_anthropic_proxy_provider,
6 | create_litellm_provider,
7 | )
8 | from .constants import (
9 | LITELLM_PROXY_ANTHROPIC_BASE,
10 | LITELLM_PROXY_BASE_URL,
11 | LITELLM_PROXY_OPENAI_BASE,
12 | )
13 | from .models import (
14 | BudgetInfo,
15 | BudgetSource,
16 | KeyInfoData,
17 | KeyInfoResponse,
18 | TeamInfoData,
19 | TeamInfoResponse,
20 | )
21 |
22 | __all__ = [
23 | "LITELLM_PROXY_BASE_URL",
24 | "LITELLM_PROXY_ANTHROPIC_BASE",
25 | "LITELLM_PROXY_OPENAI_BASE",
26 | "create_litellm_provider",
27 | "create_anthropic_proxy_provider",
28 | "LiteLLMProxyClient",
29 | "get_budget_info",
30 | "BudgetInfo",
31 | "BudgetSource",
32 | "KeyInfoData",
33 | "KeyInfoResponse",
34 | "TeamInfoData",
35 | "TeamInfoResponse",
36 | ]
37 |
--------------------------------------------------------------------------------
/src/shotgun/tui/screens/chat/chat.tcss:
--------------------------------------------------------------------------------
1 | ChatHistory {
2 | height: auto;
3 | }
4 |
5 | PromptInput {
6 | min-height: 3;
7 | max-height: 7;
8 | height: auto;
9 | }
10 |
11 | StatusBar {
12 | height: auto;
13 | }
14 |
15 | ModeIndicator {
16 | height: auto;
17 | }
18 |
19 | ModeIndicator.mode-planning {
20 | /* Blue/cyan accent for planning mode */
21 | }
22 |
23 | ModeIndicator.mode-drafting {
24 | /* Green accent for drafting mode */
25 | }
26 |
27 | #footer {
28 | dock: bottom;
29 | height: auto;
30 | padding: 1 1 1 2;
31 | max-height: 24;
32 | }
33 |
34 | #window {
35 | align: left bottom;
36 | }
37 |
38 | .hidden {
39 | display: none;
40 | }
41 |
42 | #footer > Grid {
43 | height: auto;
44 | grid-columns: 1fr auto;
45 | grid-size: 2;
46 | }
47 |
48 |
49 | #right-footer-indicators {
50 | width: auto;
51 | height: auto;
52 | layout: vertical;
53 | }
54 |
55 | #context-indicator {
56 | text-align: end;
57 | height: 1;
58 | }
59 |
60 | #indexing-job-display {
61 | text-align: end;
62 | }
--------------------------------------------------------------------------------
/evals/evaluators/deterministic/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Deterministic evaluators for Router agent evaluation.
3 |
4 | These evaluators apply rule-based checks with deterministic outcomes.
5 | """
6 |
7 | from evals.evaluators.deterministic.router_evaluators import (
8 | ClarifyingQuestionsEvaluator,
9 | ContentAssertionEvaluator,
10 | DelegationCorrectnessEvaluator,
11 | DisallowedToolUsageEvaluator,
12 | ExecutionFailureEvaluator,
13 | ExpectedToolPresenceEvaluator,
14 | RouterDeterministicEvaluator,
15 | run_all_deterministic_evaluators,
16 | )
17 | from evals.models import EvaluatorResult, EvaluatorSeverity
18 |
19 | __all__ = [
20 | "EvaluatorSeverity",
21 | "EvaluatorResult",
22 | "RouterDeterministicEvaluator",
23 | "DisallowedToolUsageEvaluator",
24 | "ExecutionFailureEvaluator",
25 | "ExpectedToolPresenceEvaluator",
26 | "ContentAssertionEvaluator",
27 | "DelegationCorrectnessEvaluator",
28 | "ClarifyingQuestionsEvaluator",
29 | "run_all_deterministic_evaluators",
30 | ]
31 |
--------------------------------------------------------------------------------
/src/shotgun/shotgun_web/supabase_client.py:
--------------------------------------------------------------------------------
1 | """Supabase Storage download utilities."""
2 |
3 | import httpx
4 |
5 | from shotgun.logging_config import get_logger
6 |
7 | logger = get_logger(__name__)
8 |
9 |
10 | async def download_file_from_url(download_url: str) -> bytes:
11 | """Download a file from a presigned Supabase Storage URL.
12 |
13 | The API returns presigned URLs with embedded tokens that don't require
14 | any authentication headers.
15 |
16 | Args:
17 | download_url: Presigned Supabase Storage URL
18 | (e.g., "https://...supabase.co/storage/v1/object/sign/...?token=...")
19 |
20 | Returns:
21 | File contents as bytes
22 |
23 | Raises:
24 | httpx.HTTPStatusError: If download fails
25 | """
26 | logger.debug("Downloading file from: %s", download_url)
27 |
28 | async with httpx.AsyncClient(timeout=60.0) as client:
29 | response = await client.get(download_url)
30 | response.raise_for_status()
31 | return response.content
32 |
--------------------------------------------------------------------------------
/src/shotgun/shotgun_web/shared_specs/__init__.py:
--------------------------------------------------------------------------------
1 | """Shared specs file utilities.
2 |
3 | This module provides utilities for scanning and hashing files in the
4 | .shotgun/ directory for upload to the shared specs API.
5 | """
6 |
7 | from shotgun.shotgun_web.shared_specs.file_scanner import (
8 | get_shotgun_directory,
9 | scan_shotgun_directory,
10 | )
11 | from shotgun.shotgun_web.shared_specs.hasher import (
12 | calculate_sha256,
13 | calculate_sha256_with_size,
14 | )
15 | from shotgun.shotgun_web.shared_specs.models import (
16 | UploadProgress,
17 | UploadResult,
18 | )
19 | from shotgun.shotgun_web.shared_specs.upload_pipeline import run_upload_pipeline
20 | from shotgun.shotgun_web.shared_specs.utils import UploadPhase, format_bytes
21 |
22 | __all__ = [
23 | "UploadPhase",
24 | "UploadProgress",
25 | "UploadResult",
26 | "calculate_sha256",
27 | "calculate_sha256_with_size",
28 | "format_bytes",
29 | "get_shotgun_directory",
30 | "run_upload_pipeline",
31 | "scan_shotgun_directory",
32 | ]
33 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature Request
3 | about: Suggest a new feature or enhancement for Shotgun
4 | title: '[FEATURE] '
5 | labels: enhancement
6 | assignees: ''
7 | ---
8 |
9 | ## Problem Description
10 |
11 |
14 |
15 |
16 |
17 | ## Proposed Solution
18 |
19 |
20 |
21 |
22 |
23 | ## Alternative Solutions Considered
24 |
25 |
27 |
28 |
29 |
30 | ## Additional Context
31 |
32 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # Dependabot configuration
2 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
3 |
4 | version: 2
5 | updates:
6 | # Python dependencies (using uv)
7 | - package-ecosystem: "uv"
8 | directory: "/"
9 | schedule:
10 | interval: "daily"
11 | time: "04:00"
12 | timezone: "America/New_York"
13 | open-pull-requests-limit: 10
14 | assignees:
15 | - "scottfrasso"
16 | labels:
17 | - "dependencies"
18 | - "python"
19 | commit-message:
20 | prefix: "build"
21 | prefix-development: "build"
22 | include: "scope"
23 |
24 | # GitHub Actions
25 | - package-ecosystem: "github-actions"
26 | directory: "/"
27 | schedule:
28 | interval: "daily"
29 | time: "04:00"
30 | timezone: "America/New_York"
31 | open-pull-requests-limit: 10
32 | assignees:
33 | - "scottfrasso"
34 | labels:
35 | - "dependencies"
36 | - "github-actions"
37 | commit-message:
38 | prefix: "ci"
39 | include: "scope"
--------------------------------------------------------------------------------
/src/shotgun/agents/conversation/history/token_counting/__init__.py:
--------------------------------------------------------------------------------
1 | """Real token counting for all supported providers.
2 |
3 | This module provides accurate token counting using each provider's official
4 | APIs and libraries, eliminating the need for rough character-based estimation.
5 | """
6 |
7 | from .anthropic import AnthropicTokenCounter
8 | from .base import TokenCounter, extract_text_from_messages
9 | from .openai import OpenAITokenCounter
10 | from .sentencepiece_counter import SentencePieceTokenCounter
11 | from .utils import (
12 | count_post_summary_tokens,
13 | count_tokens_from_message_parts,
14 | count_tokens_from_messages,
15 | get_token_counter,
16 | )
17 |
18 | __all__ = [
19 | # Base classes
20 | "TokenCounter",
21 | # Counter implementations
22 | "OpenAITokenCounter",
23 | "AnthropicTokenCounter",
24 | "SentencePieceTokenCounter",
25 | # Utility functions
26 | "get_token_counter",
27 | "count_tokens_from_messages",
28 | "count_post_summary_tokens",
29 | "count_tokens_from_message_parts",
30 | "extract_text_from_messages",
31 | ]
32 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Mimir AI, Inc.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/test/integration/pytest.ini:
--------------------------------------------------------------------------------
1 | [tool:pytest]
2 | # Configuration for integration tests
3 | testpaths = .
4 | python_files = test_*.py
5 | python_classes = Test*
6 | python_functions = test_*
7 |
8 | # Markers
9 | markers =
10 | integration: Integration tests that require LLM configuration
11 | slow: Tests that take a long time to run
12 | llm: Tests that make actual LLM calls
13 |
14 | # Async support
15 | asyncio_mode = auto
16 |
17 | # Logging
18 | log_cli = true
19 | log_cli_level = INFO
20 | log_cli_format = %(asctime)s [%(levelname)8s] %(name)s: %(message)s
21 | log_cli_date_format = %Y-%m-%d %H:%M:%S
22 |
23 | # Test discovery
24 | addopts =
25 | --strict-markers
26 | --tb=short
27 | -v
28 |
29 | # Timeout for tests (in seconds)
30 | timeout = 300
31 |
32 | # Minimum coverage
33 | [coverage:run]
34 | source = ../../../src/shotgun/codebase
35 | omit =
36 | */test*
37 | */__pycache__/*
38 | */conftest.py
39 |
40 | [coverage:report]
41 | exclude_lines =
42 | pragma: no cover
43 | def __repr__
44 | raise AssertionError
45 | raise NotImplementedError
46 | if __name__ == .__main__.:
47 | if TYPE_CHECKING:
--------------------------------------------------------------------------------
/evals/evaluators/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Evaluator implementations for Shotgun agent evaluation.
3 |
4 | Organized by evaluator type:
5 | - deterministic/: Rule-based evaluators with deterministic outcomes
6 | - llm_judges/: LLM-as-a-judge evaluators (in evals/judges/)
7 | """
8 |
9 | from evals.evaluators.deterministic.router_evaluators import (
10 | ClarifyingQuestionsEvaluator,
11 | ContentAssertionEvaluator,
12 | DelegationCorrectnessEvaluator,
13 | DisallowedToolUsageEvaluator,
14 | ExecutionFailureEvaluator,
15 | ExpectedToolPresenceEvaluator,
16 | RouterDeterministicEvaluator,
17 | run_all_deterministic_evaluators,
18 | )
19 | from evals.models import EvaluatorResult, EvaluatorSeverity
20 |
21 | __all__ = [
22 | # Base types
23 | "EvaluatorSeverity",
24 | "EvaluatorResult",
25 | "RouterDeterministicEvaluator",
26 | # Evaluators
27 | "DisallowedToolUsageEvaluator",
28 | "ExecutionFailureEvaluator",
29 | "ExpectedToolPresenceEvaluator",
30 | "ContentAssertionEvaluator",
31 | "DelegationCorrectnessEvaluator",
32 | "ClarifyingQuestionsEvaluator",
33 | # Runner
34 | "run_all_deterministic_evaluators",
35 | ]
36 |
--------------------------------------------------------------------------------
/src/shotgun/agents/messages.py:
--------------------------------------------------------------------------------
1 | """Custom message types for Shotgun agents.
2 |
3 | This module defines specialized SystemPromptPart subclasses to distinguish
4 | between different types of system prompts in the agent pipeline.
5 | """
6 |
7 | from dataclasses import dataclass, field
8 |
9 | from pydantic_ai.messages import SystemPromptPart
10 |
11 | from shotgun.agents.models import AgentType
12 |
13 |
14 | @dataclass
15 | class AgentSystemPrompt(SystemPromptPart):
16 | """System prompt containing the main agent instructions.
17 |
18 | This is the primary system prompt that defines the agent's role,
19 | capabilities, and behavior. It should be preserved during compaction.
20 | """
21 |
22 | prompt_type: str = "agent"
23 | agent_mode: AgentType | None = field(default=None)
24 |
25 |
26 | @dataclass
27 | class SystemStatusPrompt(SystemPromptPart):
28 | """System prompt containing current system status information.
29 |
30 | This includes table of contents, available files, and other contextual
31 | information about the current state. Only the most recent status should
32 | be preserved during compaction.
33 | """
34 |
35 | prompt_type: str = "status"
36 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | # Development and test directories
2 | test/
3 | playground/
4 | tmp/
5 |
6 | # Build and coverage artifacts
7 | htmlcov/
8 | dist/
9 | build/
10 | *.egg-info/
11 |
12 | # Virtual environments
13 | .venv/
14 | venv/
15 | env/
16 | ENV/
17 |
18 | # Project-specific directories
19 | .shotgun/
20 |
21 | # IDE and editor files
22 | .vscode/
23 | .idea/
24 | *.swp
25 | *.swo
26 | *~
27 |
28 | # Git
29 | .git/
30 | .gitignore
31 | .gitattributes
32 |
33 | # GitHub workflows and configs
34 | .github/
35 |
36 | # Claude Code
37 | .claude/
38 |
39 | # Python cache and compiled files
40 | __pycache__/
41 | *.py[cod]
42 | *$py.class
43 | *.so
44 |
45 | # Linting and type checking caches
46 | .ruff_cache/
47 | .pytest_cache/
48 | .mypy_cache/
49 | .pytype/
50 |
51 | # Testing and coverage
52 | .coverage
53 | .coverage.*
54 | .cache
55 | nosetests.xml
56 | coverage.xml
57 | *.cover
58 | *.py,cover
59 | .hypothesis/
60 | cover/
61 |
62 | # Documentation
63 | docs/
64 | !docs/README_DOCKER.md
65 | *.md
66 | !README.md
67 |
68 | # OS files
69 | .DS_Store
70 | Thumbs.db
71 |
72 | # Environment files
73 | .env
74 | .env.*
75 |
76 | # Logs
77 | *.log
78 |
79 | # Other development files
80 | lefthook.yml
81 |
--------------------------------------------------------------------------------
/src/shotgun/agents/router/__init__.py:
--------------------------------------------------------------------------------
1 | """Router Agent - The intelligent orchestrator for shotgun agents."""
2 |
3 | from shotgun.agents.router.models import (
4 | CascadeScope,
5 | CreatePlanInput,
6 | DelegationInput,
7 | DelegationResult,
8 | ExecutionPlan,
9 | ExecutionStep,
10 | ExecutionStepInput,
11 | MarkStepDoneInput,
12 | PlanApprovalStatus,
13 | RemoveStepInput,
14 | RouterDeps,
15 | RouterMode,
16 | StepCheckpointAction,
17 | SubAgentResult,
18 | SubAgentResultStatus,
19 | ToolResult,
20 | )
21 | from shotgun.agents.router.router import create_router_agent, run_router_agent
22 |
23 | __all__ = [
24 | # Agent factory
25 | "create_router_agent",
26 | "run_router_agent",
27 | # Enums
28 | "RouterMode",
29 | "PlanApprovalStatus",
30 | "StepCheckpointAction",
31 | "CascadeScope",
32 | "SubAgentResultStatus",
33 | # Plan models
34 | "ExecutionStep",
35 | "ExecutionPlan",
36 | # Tool I/O models
37 | "ExecutionStepInput",
38 | "CreatePlanInput",
39 | "MarkStepDoneInput",
40 | "RemoveStepInput",
41 | "DelegationInput",
42 | "ToolResult",
43 | "DelegationResult",
44 | "SubAgentResult",
45 | # Deps
46 | "RouterDeps",
47 | ]
48 |
--------------------------------------------------------------------------------
/src/shotgun/prompts/agents/partials/interactive_mode.j2:
--------------------------------------------------------------------------------
1 | {% if interactive_mode -%}
2 |
3 | BEFORE GETTING TO WORK: For complex or multi-step tasks where the request is ambiguous or lacks sufficient detail, use clarifying_questions to ask what they want.
4 | DURING WORK: After using write_file(), you can suggest that the user review it and ask any clarifying questions with clarifying_questions.
5 | For simple, straightforward requests, make reasonable assumptions and proceed.
6 | Only ask critical questions that significantly impact the outcome.
7 | If you don't need to ask questions, set clarifying_questions to null or omit it.
8 | Keep response field concise with a paragraph at most for user communication and follow EXPECTED_FORMAT.
9 | Don't ask multiple questions in one string - use separate array items using EXPECTED_FORMAT.
10 |
11 | {% else -%}
12 |
13 | IMPORTANT: USER INTERACTION IS DISABLED (non-interactive mode).
14 | - You cannot ask clarifying questions (clarifying_questions will be ignored)
15 | - Make reasonable assumptions based on best practices
16 | - Use sensible defaults when information is missing
17 | - When in doubt, make reasonable assumptions and proceed with best practices
18 | {% endif %}
19 |
20 |
21 |
--------------------------------------------------------------------------------
/src/shotgun/codebase/core/__init__.py:
--------------------------------------------------------------------------------
1 | """Core components for codebase understanding."""
2 |
3 | from shotgun.codebase.core.code_retrieval import (
4 | CodeSnippet,
5 | retrieve_code_by_cypher,
6 | retrieve_code_by_qualified_name,
7 | )
8 | from shotgun.codebase.core.ingestor import (
9 | CodebaseIngestor,
10 | Ingestor,
11 | SimpleGraphBuilder,
12 | )
13 | from shotgun.codebase.core.language_config import (
14 | LANGUAGE_CONFIGS,
15 | LanguageConfig,
16 | get_language_config,
17 | )
18 | from shotgun.codebase.core.manager import CodebaseGraphManager
19 | from shotgun.codebase.core.nl_query import (
20 | clean_cypher_response,
21 | generate_cypher,
22 | generate_cypher_openai_async,
23 | )
24 | from shotgun.codebase.core.parser_loader import load_parsers
25 |
26 | __all__ = [
27 | # Ingestor classes
28 | "CodebaseIngestor",
29 | "Ingestor",
30 | "SimpleGraphBuilder",
31 | "CodebaseGraphManager",
32 | # Language configuration
33 | "LanguageConfig",
34 | "LANGUAGE_CONFIGS",
35 | "get_language_config",
36 | # Parser loading
37 | "load_parsers",
38 | # Natural language query
39 | "generate_cypher",
40 | "generate_cypher_openai_async",
41 | "clean_cypher_response",
42 | # Code retrieval
43 | "CodeSnippet",
44 | "retrieve_code_by_qualified_name",
45 | "retrieve_code_by_cypher",
46 | ]
47 |
--------------------------------------------------------------------------------
/src/shotgun/tui/screens/chat/prompt_history.py:
--------------------------------------------------------------------------------
1 | """Prompt history management for chat screen."""
2 |
3 | from pydantic import BaseModel, Field
4 |
5 |
6 | class PromptHistory(BaseModel):
7 | """Manages prompt history for navigation in chat input."""
8 |
9 | prompts: list[str] = Field(default_factory=lambda: ["Hello there!"])
10 | curr: int | None = None
11 |
12 | def next(self) -> str:
13 | """Navigate to next prompt in history.
14 |
15 | Returns:
16 | The next prompt in history.
17 | """
18 | if self.curr is None:
19 | self.curr = -1
20 | else:
21 | self.curr = -1
22 | return self.prompts[self.curr]
23 |
24 | def prev(self) -> str:
25 | """Navigate to previous prompt in history.
26 |
27 | Returns:
28 | The previous prompt in history.
29 |
30 | Raises:
31 | Exception: If current entry is None.
32 | """
33 | if self.curr is None:
34 | raise Exception("current entry is none")
35 | if self.curr == -1:
36 | self.curr = None
37 | return ""
38 | self.curr += 1
39 | return ""
40 |
41 | def append(self, text: str) -> None:
42 | """Add a new prompt to history.
43 |
44 | Args:
45 | text: The prompt text to add.
46 | """
47 | self.prompts.append(text)
48 | self.curr = None
49 |
--------------------------------------------------------------------------------
/src/shotgun/prompts/history/chunk_summarization.j2:
--------------------------------------------------------------------------------
1 | You are summarizing chunk {{ chunk_index }} of {{ total_chunks }} from a conversation that is too large to summarize at once.
2 |
3 | Focus on preserving:
4 | 1. ALL file paths, function names, variable names, URLs, and identifiers VERBATIM
5 | 2. Key decisions and their rationale
6 | 3. Tool call results (summarize large outputs, keep critical data)
7 | 4. Important errors or issues encountered
8 | 5. User requests and how they were addressed
9 |
10 |
11 | {{ chunk_content }}
12 |
13 |
14 |
15 | ## Topics Covered
16 | Brief bullet points of main topics discussed in this chunk.
17 |
18 | ## Entities & References
19 | - Files: [list all file paths mentioned, verbatim]
20 | - Functions/Classes: [list all function/method/class names, verbatim]
21 | - Variables/Keys: [configuration keys, env vars, etc., verbatim]
22 | - Links/IDs: [any URLs, ticket IDs, etc., verbatim]
23 |
24 | ## Actions & Results
25 | Summarized tool calls and their outcomes. Preserve important data verbatim.
26 |
27 | ## Important Decisions
28 | Key decisions made and their rationale.
29 |
30 | ## Status at Chunk End
31 | Current active task or objective at the end of this chunk (if any), or "N/A" if this chunk completes without active work.
32 |
33 |
34 | Be concise but preserve all critical information. This summary will be combined with others to create a complete conversation summary.
35 |
--------------------------------------------------------------------------------
/src/shotgun/prompts/codebase/partials/temporal_context.j2:
--------------------------------------------------------------------------------
1 | **4. Handling Time-based Queries**
2 | When users ask about "today", "yesterday", "last hour", "last week", etc., convert these to Unix timestamp comparisons:
3 | - "today" → Use a timestamp representing start of current day (e.g., WHERE f.created_at > [today's start timestamp])
4 | - "yesterday" → Use timestamps for yesterday's range
5 | - "last hour" → Current time minus 3600 seconds
6 | - "last 24 hours" → Current time minus 86400 seconds
7 | - "last week" → Current time minus 604800 seconds
8 |
9 | Since you cannot calculate the current time, use reasonable example timestamps that would work for the query.
10 | The actual timestamp calculation will be handled by the calling application.
11 |
12 | **5. Handling Queries About Deleted/Removed Entities**
13 | When users ask about "removed", "deleted", or "no longer exist" entities:
14 | - Query the DeletionLog table which tracks all deletions
15 | - Filter by entity_type (Function, Method, Class, Module) based on what they're asking about
16 | - Use timestamp comparisons for time-based deletion queries
17 | - The deletion_reason field indicates why it was deleted (e.g., "removed_from_file", "file_deleted")
18 |
19 | Examples:
20 | - "What functions were removed today?" → Query DeletionLog WHERE entity_type = 'Function' AND deleted_at > [today's timestamp]
21 | - "Show deleted classes from auth module" → Query DeletionLog WHERE entity_type = 'Class' AND entity_qualified_name CONTAINS 'auth'
--------------------------------------------------------------------------------
/src/shotgun/prompts/agents/partials/router_delegation_mode.j2:
--------------------------------------------------------------------------------
1 | {% if sub_agent_context and sub_agent_context.is_router_delegated %}
2 |
3 |
4 | You are being orchestrated by the Router agent.
5 | {% if sub_agent_context.plan_goal %}
6 |
7 | {{ sub_agent_context.plan_goal }}
8 |
9 | {% endif %}
10 | {% if sub_agent_context.current_step_title %}
11 |
12 | {{ sub_agent_context.current_step_title }}
13 |
14 | {% endif %}
15 |
16 |
17 |
18 | Do the work first to accomplish the CURRENT_TASK_CONTEXT via tool usage like read_file, write_file, query_graph, web_search, etc.
19 | You must complete the work for CURRENT_TASK_CONTEXT before calling final_result.
20 | You cannot answer with "please wait" or tell the user to "be patient" or "this will take a few minutes" you must complete the work via tool usage now.
21 | Skip greetings, pleasantries, or preamble and start the work immediately.
22 | Be concise with your response in final_result.
23 |
24 |
25 | 1. Call read_file to check existing research
26 | 2. Call query_graph or web_search to gather information
27 | 3. Call write_file to save research findings
28 | 4. Call final_result with "Research complete. Updated research.md with findings on X and Y."
29 |
30 |
31 | 1. Call final_result with "I'll research this, please be patient..." and exit without doing any work.
32 |
33 |
34 |
35 | {% endif %}
36 |
--------------------------------------------------------------------------------
/src/shotgun/tui/screens/chat_screen/history/user_question.py:
--------------------------------------------------------------------------------
1 | """User question widget for chat history."""
2 |
3 | from collections.abc import Sequence
4 |
5 | from pydantic_ai.messages import (
6 | ModelRequest,
7 | ModelRequestPart,
8 | ToolReturnPart,
9 | UserPromptPart,
10 | )
11 | from textual.app import ComposeResult
12 | from textual.widget import Widget
13 | from textual.widgets import Markdown
14 |
15 |
16 | class UserQuestionWidget(Widget):
17 | """Widget that displays user prompts in the chat history."""
18 |
19 | def __init__(self, item: ModelRequest | None) -> None:
20 | super().__init__()
21 | self.item = item
22 |
23 | def compose(self) -> ComposeResult:
24 | self.display = self.item is not None
25 | if self.item is None:
26 | yield Markdown(markdown="")
27 | else:
28 | prompt = self.format_prompt_parts(self.item.parts)
29 | yield Markdown(markdown=prompt)
30 |
31 | def format_prompt_parts(self, parts: Sequence[ModelRequestPart]) -> str:
32 | """Format user prompt parts into markdown."""
33 | acc = ""
34 | for part in parts:
35 | if isinstance(part, UserPromptPart):
36 | acc += (
37 | f"**>** {part.content if isinstance(part.content, str) else ''}\n\n"
38 | )
39 | elif isinstance(part, ToolReturnPart):
40 | # Don't show tool return parts in the UI
41 | pass
42 | return acc
43 |
--------------------------------------------------------------------------------
/src/shotgun/llm_proxy/clients.py:
--------------------------------------------------------------------------------
1 | """Client creation utilities for LiteLLM proxy."""
2 |
3 | from pydantic_ai.providers.anthropic import AnthropicProvider
4 | from pydantic_ai.providers.litellm import LiteLLMProvider
5 |
6 | from .constants import LITELLM_PROXY_ANTHROPIC_BASE, LITELLM_PROXY_BASE_URL
7 |
8 |
9 | def create_litellm_provider(api_key: str) -> LiteLLMProvider:
10 | """Create LiteLLM provider for Shotgun Account.
11 |
12 | Args:
13 | api_key: Shotgun API key
14 |
15 | Returns:
16 | Configured LiteLLM provider pointing to Shotgun's proxy
17 | """
18 | return LiteLLMProvider(
19 | api_base=LITELLM_PROXY_BASE_URL,
20 | api_key=api_key,
21 | )
22 |
23 |
24 | def create_anthropic_proxy_provider(api_key: str) -> AnthropicProvider:
25 | """Create Anthropic provider configured for LiteLLM proxy.
26 |
27 | This provider uses native Anthropic API format while routing through
28 | the LiteLLM proxy. This preserves Anthropic-specific features like
29 | tool_choice and web search.
30 |
31 | The provider's .client attribute provides access to the async Anthropic
32 | client (AsyncAnthropic), which should be used for all API operations
33 | including token counting.
34 |
35 | Args:
36 | api_key: Shotgun API key
37 |
38 | Returns:
39 | AnthropicProvider configured to use LiteLLM proxy /anthropic endpoint
40 | """
41 | return AnthropicProvider(
42 | api_key=api_key,
43 | base_url=LITELLM_PROXY_ANTHROPIC_BASE,
44 | )
45 |
--------------------------------------------------------------------------------
/src/shotgun/cli/feedback.py:
--------------------------------------------------------------------------------
1 | """Configuration management CLI commands."""
2 |
3 | from typing import Annotated
4 |
5 | import typer
6 | from rich.console import Console
7 |
8 | from shotgun.agents.config import get_config_manager
9 | from shotgun.logging_config import get_logger
10 | from shotgun.posthog_telemetry import Feedback, FeedbackKind, submit_feedback_survey
11 |
12 | logger = get_logger(__name__)
13 | console = Console()
14 |
15 | app = typer.Typer(
16 | name="feedback",
17 | help="Send us feedback",
18 | no_args_is_help=True,
19 | )
20 |
21 |
22 | @app.callback(invoke_without_command=True)
23 | def send_feedback(
24 | description: Annotated[str, typer.Argument(help="Description of the feedback")],
25 | kind: Annotated[
26 | FeedbackKind,
27 | typer.Option("--type", "-t", help="Feedback type"),
28 | ],
29 | ) -> None:
30 | """Initialize Shotgun configuration."""
31 | import asyncio
32 |
33 | config_manager = get_config_manager()
34 | asyncio.run(config_manager.load())
35 | shotgun_instance_id = asyncio.run(config_manager.get_shotgun_instance_id())
36 |
37 | if not description:
38 | console.print(
39 | '❌ Please add your feedback (shotgun feedback "").',
40 | style="red",
41 | )
42 | raise typer.Exit(1)
43 |
44 | feedback = Feedback(
45 | kind=kind, description=description, shotgun_instance_id=shotgun_instance_id
46 | )
47 |
48 | submit_feedback_survey(feedback)
49 |
50 | console.print("Feedback sent. Thank you!")
51 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug Report
3 | about: Report a bug or unexpected behavior in Shotgun
4 | title: '[BUG] '
5 | labels: bug
6 | assignees: ''
7 | ---
8 |
9 | ## What happened?
10 |
11 |
12 |
13 |
14 |
15 | ## What did you expect to happen?
16 |
17 |
18 |
19 |
20 |
21 | ## How can I reproduce this issue?
22 |
23 |
24 |
25 | 1.
26 | 2.
27 | 3.
28 | 4.
29 |
30 |
31 |
32 | ## Screenshots
33 |
34 |
35 |
36 |
37 |
38 | ## Environment Details
39 |
40 | **Operating System:**
41 |
42 |
43 |
44 | **Installation Method:**
45 |
46 |
47 |
48 | **Shotgun Version:**
49 |
50 | ```
51 | shotgun --version
52 | ```
53 |
54 | **Python Version:**
55 |
56 | ```
57 | python --version
58 | ```
59 |
60 |
61 | ## Additional Context
62 |
63 |
70 |
71 |
72 |
--------------------------------------------------------------------------------
/src/shotgun/utils/env_utils.py:
--------------------------------------------------------------------------------
1 | """Utilities for working with environment variables."""
2 |
3 |
4 | def is_shotgun_account_enabled() -> bool:
5 | """Check if Shotgun Account feature is enabled via environment variable.
6 |
7 | Returns:
8 | True always (Shotgun Account is now live for all users)
9 |
10 | Note:
11 | This function is deprecated and always returns True.
12 | Shotgun Account is now available to all users by default.
13 | """
14 | return True
15 |
16 |
17 | def is_truthy(value: str | None) -> bool:
18 | """Check if a string value represents true.
19 |
20 | Args:
21 | value: String value to check (e.g., from environment variable)
22 |
23 | Returns:
24 | True if value is "true", "1", or "yes" (case-insensitive)
25 | False otherwise (including None, empty string, or any other value)
26 | """
27 | if not value:
28 | return False
29 | return value.lower() in ("true", "1", "yes")
30 |
31 |
32 | def is_falsy(value: str | None) -> bool:
33 | """Check if a string value explicitly represents false.
34 |
35 | Args:
36 | value: String value to check (e.g., from environment variable)
37 |
38 | Returns:
39 | True if value is "false", "0", or "no" (case-insensitive)
40 | False otherwise (including None, empty string, or any other value)
41 |
42 | Note:
43 | This is NOT the opposite of is_truthy(). A value can be neither
44 | truthy nor falsy (e.g., None, "", "maybe", etc.)
45 | """
46 | if not value:
47 | return False
48 | return value.lower() in ("false", "0", "no")
49 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/documentation_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Documentation Request
3 | about: Report missing or unclear documentation
4 | title: '[DOCS] '
5 | labels: documentation
6 | assignees: ''
7 | ---
8 |
9 | ## What documentation is missing or unclear?
10 |
11 |
16 |
17 |
18 |
19 | ## Where did you look?
20 |
21 |
26 |
27 |
28 |
29 | ## What would you like to see documented or clarified?
30 |
31 |
36 |
37 |
38 |
39 | ## Suggested Improvements
40 |
41 |
47 |
48 |
49 |
50 | ## Additional Context
51 |
52 |
57 |
58 |
59 |
--------------------------------------------------------------------------------
/src/shotgun/cli/spec/models.py:
--------------------------------------------------------------------------------
1 | """Pydantic models for spec CLI commands."""
2 |
3 | from datetime import datetime
4 | from enum import StrEnum
5 |
6 | from pydantic import BaseModel, Field
7 |
8 |
9 | class PullSource(StrEnum):
10 | """Source of spec pull operation for analytics."""
11 |
12 | CLI = "cli"
13 | TUI = "tui"
14 |
15 |
16 | class PullPhase(StrEnum):
17 | """Phases during spec pull operation for analytics."""
18 |
19 | STARTING = "starting"
20 | FETCHING = "fetching"
21 | BACKUP = "backup"
22 | DOWNLOADING = "downloading"
23 | FINALIZING = "finalizing"
24 |
25 |
26 | class SpecMeta(BaseModel):
27 | """Metadata stored in .shotgun/meta.json after pulling a spec.
28 |
29 | This file tracks the source of the local spec files and is used
30 | by the TUI to display version information and enable future sync operations.
31 | """
32 |
33 | version_id: str = Field(description="Pulled version UUID")
34 | spec_id: str = Field(description="Spec UUID")
35 | spec_name: str = Field(description="Spec name at time of pull")
36 | workspace_id: str = Field(description="Workspace UUID")
37 | is_latest: bool = Field(
38 | description="Whether this was the latest version when pulled"
39 | )
40 | pulled_at: datetime = Field(description="Timestamp when spec was pulled (UTC)")
41 | backup_path: str | None = Field(
42 | default=None,
43 | description="Path where previous .shotgun/ files were backed up",
44 | )
45 | web_url: str | None = Field(
46 | default=None,
47 | description="URL to view this version in the web UI",
48 | )
49 |
--------------------------------------------------------------------------------
/src/shotgun/prompts/agents/partials/content_formatting.j2:
--------------------------------------------------------------------------------
1 |
2 | Always use professionaly formatted markdown for the section content with proper headings and subheadings so it's easy to read and understand.
3 | AVOID using --- line dividers in the section content.
4 | When formatting code use full Markdown code blocks (```) and format them with proper language identifier for code parts longer than a line.
5 | For short code parts like that that go into a sentence, use Markdown `class Foo` syntax instead of code blocks.
6 | Make text easier to read by bolding important parts of text and use links for external references.
7 | Use Emojis sparingly.
8 |
9 |
10 |
11 |
12 | To visualize in your artifacts, you can use all of the following mermaid features:
13 | * Flowchart
14 | * Sequence Diagram
15 | * Class Diagram
16 | * State Diagram
17 | * Entity Relationship Diagram
18 | * User Journey
19 | * Gantt
20 | * Pie Chart
21 | * Quadrant Chart
22 | * Requirement Diagram
23 | * GitGraph (Git) Diagram
24 | * C4 Diagram (but avoid using "all" as a context)
25 | * Mindmaps
26 | * Timeline
27 | * ZenUML
28 | * Sankey
29 | * XY Chart
30 | * Block Diagram
31 | * Packet
32 | * Kanban
33 | * Architecture
34 | * Radar
35 | * Treemap
36 |
37 |
38 | Avoid 'as' in diagrams
39 | AVOID using "FOO as BAR" in the diagrams.
40 | AVOID using <>, <> and <> in the diagrams and similar.
41 | AVOID using custom stereotype syntax in the diagrams, like <<(L,#6fa8dc)>>.
42 | AVOID using ";" in the diagrams.
43 |
44 |
45 |
--------------------------------------------------------------------------------
/src/shotgun/prompts/agents/state/system_state.j2:
--------------------------------------------------------------------------------
1 |
2 | Your training data may be old. The current date and time is: {{ current_datetime }} in {{ timezone_name }} (UTC{{ utc_offset }})
3 |
4 |
5 | {% include 'agents/state/codebase/codebase_graphs_available.j2' %}
6 |
7 | {% if execution_plan %}
8 |
9 | {{ execution_plan }}
10 |
11 |
12 | {% if pending_approval %}
13 |
14 | The current plan is pending approval for the user.
15 | The plan above requires user approval before execution can begin.
16 | You MUST call `final_result` now to present this plan to the user.
17 | Do NOT attempt to delegate to any sub-agents until the user approves.
18 |
19 | {% endif %}
20 |
21 | {% endif %}
22 |
23 |
24 | {% if existing_files %}
25 | The following files already exist.
26 | Before doing a web search check the information in these files before continuing.
27 | Your working files are:
28 | {% for file in existing_files %}
29 | - `{{ file }}`
30 | {% endfor %}
31 | {% else %}
32 | No research or planning documents exist yet. Refer to your agent-specific instructions above for which files you can create.
33 | {% endif %}
34 |
35 | {% if markdown_toc %}
36 |
37 | {{ markdown_toc }}
38 |
39 |
40 | It is imporant that TABLE_OF_CONTENTS shows ONLY the Table of Contents from prior stages in the pipeline. You must review this context before asking questions or creating new content.
41 | {% else %}
42 | Review the existing documents above before adding new content to avoid duplication.
43 | {% endif %}
44 |
--------------------------------------------------------------------------------
/test/unit/codebase/tools/test_query_graph.py:
--------------------------------------------------------------------------------
1 | """Tests for query_graph tool."""
2 |
3 | import pytest
4 |
5 | from shotgun.agents.tools.codebase import QueryGraphResult, query_graph
6 | from shotgun.codebase.models import QueryResult
7 |
8 |
9 | @pytest.mark.asyncio
10 | async def test_query_graph_success(mock_run_context, mock_codebase_service):
11 | """Test successful graph query."""
12 | # Setup mock response
13 | query_result = QueryResult(
14 | query="test query",
15 | cypher_query="MATCH (n) RETURN n",
16 | results=[{"name": "TestClass", "type": "class"}],
17 | column_names=["name", "type"],
18 | row_count=1,
19 | execution_time_ms=50.0,
20 | success=True,
21 | error=None,
22 | )
23 | mock_codebase_service.execute_query.return_value = query_result
24 |
25 | # Execute
26 | result = await query_graph(mock_run_context, "graph-id", "test query")
27 |
28 | # Verify
29 | assert isinstance(result, QueryGraphResult)
30 | assert result.success is True
31 | assert result.query == "test query"
32 | assert result.cypher_query == "MATCH (n) RETURN n"
33 | assert result.row_count == 1
34 | assert "TestClass" in str(result)
35 |
36 |
37 | @pytest.mark.asyncio
38 | async def test_query_graph_no_service(mock_run_context):
39 | """Test query_graph with no codebase service."""
40 | mock_run_context.deps.codebase_service = None
41 |
42 | result = await query_graph(mock_run_context, "graph-id", "test query")
43 |
44 | assert isinstance(result, QueryGraphResult)
45 | assert result.success is False
46 | assert result.error and "No codebase indexed" in result.error
47 |
--------------------------------------------------------------------------------
/src/shotgun/tui/screens/shared_specs/models.py:
--------------------------------------------------------------------------------
1 | """Pydantic models for shared specs TUI screens."""
2 |
3 | from enum import StrEnum
4 |
5 | from pydantic import BaseModel
6 |
7 |
8 | class ShareSpecsAction(StrEnum):
9 | """Actions from share specs dialog."""
10 |
11 | CREATE = "create"
12 | ADD_VERSION = "add_version"
13 |
14 |
15 | class UploadScreenResult(BaseModel):
16 | """Result from UploadProgressScreen.
17 |
18 | Attributes:
19 | success: Whether the upload completed successfully
20 | web_url: URL to view the spec version (on success)
21 | cancelled: Whether the upload was cancelled
22 | """
23 |
24 | success: bool
25 | web_url: str | None = None
26 | cancelled: bool = False
27 |
28 |
29 | class ShareSpecsResult(BaseModel):
30 | """Result from ShareSpecsDialog.
31 |
32 | Attributes:
33 | action: CREATE to create new spec, ADD_VERSION to add to existing, None if cancelled
34 | workspace_id: Workspace ID (fetched by dialog)
35 | spec_id: Spec ID if adding version to existing spec
36 | spec_name: Spec name if adding version to existing spec
37 | """
38 |
39 | action: ShareSpecsAction | None = None
40 | workspace_id: str | None = None
41 | spec_id: str | None = None
42 | spec_name: str | None = None
43 |
44 |
45 | class CreateSpecResult(BaseModel):
46 | """Result from CreateSpecDialog.
47 |
48 | Attributes:
49 | name: Spec name (required)
50 | description: Optional description
51 | is_public: Whether spec should be public (default: False)
52 | """
53 |
54 | name: str
55 | description: str | None = None
56 | is_public: bool = False
57 |
--------------------------------------------------------------------------------
/test/unit/codebase/tools/test_directory_lister.py:
--------------------------------------------------------------------------------
1 | """Tests for directory_lister tool."""
2 |
3 | import pytest
4 |
5 | from shotgun.agents.tools.codebase import DirectoryListResult, directory_lister
6 |
7 |
8 | @pytest.mark.asyncio
9 | async def test_directory_lister_success(
10 | mock_run_context, mock_codebase_service, mock_graph, tmp_path
11 | ):
12 | """Test successful directory listing."""
13 | # Create test directory structure
14 | (tmp_path / "subdir").mkdir()
15 | (tmp_path / "file.py").write_text("test")
16 | (tmp_path / "README.md").write_text("readme")
17 |
18 | mock_graph.repo_path = str(tmp_path)
19 | mock_codebase_service.list_graphs.return_value = [mock_graph]
20 |
21 | # Execute
22 | result = await directory_lister(mock_run_context, "test-graph-id", ".")
23 |
24 | # Verify
25 | assert isinstance(result, DirectoryListResult)
26 | assert result.success is True
27 | assert result.directory == "."
28 | assert "subdir" in result.directories
29 | assert any(fname == "file.py" for fname, _ in result.files)
30 | assert "📁 subdir/" in str(result)
31 | assert "📄 file.py" in str(result)
32 |
33 |
34 | @pytest.mark.asyncio
35 | async def test_directory_lister_security_violation(
36 | mock_run_context, mock_codebase_service, mock_graph, tmp_path
37 | ):
38 | """Test directory_lister prevents path traversal."""
39 | mock_graph.repo_path = str(tmp_path)
40 | mock_codebase_service.list_graphs.return_value = [mock_graph]
41 |
42 | result = await directory_lister(mock_run_context, "test-graph-id", "../../../etc")
43 |
44 | assert isinstance(result, DirectoryListResult)
45 | assert result.success is False
46 | assert result.error and "Access denied" in result.error
47 |
--------------------------------------------------------------------------------
/test/integration/codebase/tools/test_query_graph.py:
--------------------------------------------------------------------------------
1 | """Integration tests for query_graph codebase tool."""
2 |
3 | import pytest
4 | from pydantic_ai import RunContext
5 |
6 | from shotgun.agents.models import AgentDeps
7 | from shotgun.agents.tools.codebase import query_graph
8 | from shotgun.codebase.models import CodebaseGraph
9 |
10 |
11 | @pytest.mark.integration
12 | @pytest.mark.asyncio
13 | async def test_query_graph_invalid_graph_id(
14 | run_context: RunContext[AgentDeps],
15 | ):
16 | """Test querying with invalid graph ID."""
17 | result = await query_graph(
18 | ctx=run_context,
19 | graph_id="nonexistent-graph-id",
20 | query="What classes are in this codebase?",
21 | )
22 |
23 | # Should fail gracefully
24 | assert result.success is False
25 | assert result.error is not None
26 | assert "not found" in result.error.lower() or "nonexistent" in result.error.lower()
27 |
28 |
29 | @pytest.mark.integration
30 | @pytest.mark.asyncio
31 | async def test_query_graph_no_codebase_service(
32 | indexed_graph: CodebaseGraph,
33 | ):
34 | """Test querying when no codebase service is available."""
35 |
36 | # Create run context without codebase service
37 | class MockRunContextNoService:
38 | def __init__(self):
39 | self.deps = type("MockDeps", (), {"codebase_service": None})()
40 |
41 | context = MockRunContextNoService()
42 |
43 | result = await query_graph(
44 | ctx=context,
45 | graph_id=indexed_graph.graph_id,
46 | query="What classes are in this codebase?",
47 | )
48 |
49 | # Should fail gracefully
50 | assert result.success is False
51 | assert result.error is not None
52 | assert "codebase service" in result.error.lower()
53 |
--------------------------------------------------------------------------------
/src/shotgun/utils/file_system_utils.py:
--------------------------------------------------------------------------------
1 | """File system utility functions."""
2 |
3 | from pathlib import Path
4 |
5 | import aiofiles
6 |
7 | from shotgun.settings import settings
8 |
9 |
10 | def get_shotgun_base_path() -> Path:
11 | """Get the absolute path to the .shotgun directory."""
12 | return Path.cwd() / ".shotgun"
13 |
14 |
15 | def get_shotgun_home() -> Path:
16 | """Get the Shotgun home directory path.
17 |
18 | Can be overridden with SHOTGUN_HOME environment variable for testing.
19 |
20 | Returns:
21 | Path to shotgun home directory (default: ~/.shotgun-sh/)
22 | """
23 | # Allow override via environment variable (useful for testing)
24 | if custom_home := settings.dev.home:
25 | return Path(custom_home)
26 |
27 | return Path.home() / ".shotgun-sh"
28 |
29 |
30 | def ensure_shotgun_directory_exists() -> Path:
31 | """Ensure the .shotgun directory exists and return its path.
32 |
33 | Returns:
34 | Path: The path to the .shotgun directory.
35 | """
36 | shotgun_dir = get_shotgun_base_path()
37 | shotgun_dir.mkdir(exist_ok=True)
38 | # Note: Removed logger to avoid circular dependency with logging_config
39 | return shotgun_dir
40 |
41 |
42 | async def async_copy_file(src: Path, dst: Path) -> None:
43 | """Asynchronously copy a file from src to dst.
44 |
45 | Args:
46 | src: Source file path
47 | dst: Destination file path
48 |
49 | Raises:
50 | FileNotFoundError: If source file doesn't exist
51 | OSError: If copy operation fails
52 | """
53 | async with aiofiles.open(src, "rb") as src_file:
54 | content = await src_file.read()
55 | async with aiofiles.open(dst, "wb") as dst_file:
56 | await dst_file.write(content)
57 |
--------------------------------------------------------------------------------
/src/shotgun/tui/screens/chat/help_text.py:
--------------------------------------------------------------------------------
1 | """Helper functions for chat screen help text."""
2 |
3 |
4 | def help_text_with_codebase(already_indexed: bool = False) -> str:
5 | """Generate help text for when a codebase is available.
6 |
7 | Args:
8 | already_indexed: Whether the codebase is already indexed.
9 |
10 | Returns:
11 | Formatted help text string.
12 | """
13 | return (
14 | "Howdy! Welcome to Shotgun - Spec Driven Development for Developers and AI Agents.\n\n"
15 | "Shotgun writes codebase-aware specs for your AI coding agents so they don't derail.\n\n"
16 | f"{'It' if already_indexed else 'Once your codebase is indexed, it'} can help you:\n"
17 | "- Research your codebase and spec out new features\n"
18 | "- Create implementation plans that fit your architecture\n"
19 | "- Generate AGENTS.md files for AI coding agents\n"
20 | "- Onboard to existing projects or plan refactors\n\n"
21 | "Ready to build something? Let's go.\n"
22 | )
23 |
24 |
25 | def help_text_empty_dir() -> str:
26 | """Generate help text for empty directory.
27 |
28 | Returns:
29 | Formatted help text string.
30 | """
31 | return (
32 | "Howdy! Welcome to Shotgun - Spec Driven Development for Developers and AI Agents.\n\n"
33 | "Shotgun writes codebase-aware specs for your AI coding agents so they don't derail.\n\n"
34 | "It can help you:\n"
35 | "- Research your codebase and spec out new features\n"
36 | "- Create implementation plans that fit your architecture\n"
37 | "- Generate AGENTS.md files for AI coding agents\n"
38 | "- Onboard to existing projects or plan refactors\n\n"
39 | "Ready to build something? Let's go.\n"
40 | )
41 |
--------------------------------------------------------------------------------
/src/shotgun/cli/clear.py:
--------------------------------------------------------------------------------
1 | """Clear command for shotgun CLI."""
2 |
3 | import asyncio
4 | from pathlib import Path
5 |
6 | import typer
7 | from rich.console import Console
8 |
9 | from shotgun.agents.conversation import ConversationManager
10 | from shotgun.logging_config import get_logger
11 |
12 | app = typer.Typer(
13 | name="clear", help="Clear the conversation history", no_args_is_help=False
14 | )
15 | logger = get_logger(__name__)
16 | console = Console()
17 |
18 |
19 | @app.callback(invoke_without_command=True)
20 | def clear() -> None:
21 | """Clear the current conversation history.
22 |
23 | This command deletes the conversation file at ~/.shotgun-sh/conversation.json,
24 | removing all conversation history. Other files in ~/.shotgun-sh/ (config, usage,
25 | codebases, logs) are preserved.
26 | """
27 | try:
28 | # Get conversation file path
29 | conversation_file = Path.home() / ".shotgun-sh" / "conversation.json"
30 |
31 | # Check if file exists
32 | if not conversation_file.exists():
33 | console.print(
34 | "[yellow]No conversation file found.[/yellow] Nothing to clear.",
35 | style="bold",
36 | )
37 | return
38 |
39 | # Clear the conversation
40 | manager = ConversationManager(conversation_file)
41 | asyncio.run(manager.clear())
42 |
43 | console.print(
44 | "[green]✓[/green] Conversation cleared successfully", style="bold"
45 | )
46 | logger.info("Conversation cleared successfully")
47 |
48 | except Exception as e:
49 | console.print(
50 | f"[red]Error:[/red] Failed to clear conversation: {e}", style="bold"
51 | )
52 | logger.debug("Full traceback:", exc_info=True)
53 | raise typer.Exit(code=1) from e
54 |
--------------------------------------------------------------------------------
/src/shotgun/tui/components/status_bar.py:
--------------------------------------------------------------------------------
1 | """Widget to display the status bar with contextual help text."""
2 |
3 | from textual.widget import Widget
4 |
5 | from shotgun.tui.protocols import QAStateProvider
6 |
7 |
8 | class StatusBar(Widget):
9 | """Widget to display the status bar with contextual help text."""
10 |
11 | DEFAULT_CSS = """
12 | StatusBar {
13 | text-wrap: wrap;
14 | padding-left: 1;
15 | }
16 | """
17 |
18 | def __init__(self, working: bool = False) -> None:
19 | """Initialize the status bar.
20 |
21 | Args:
22 | working: Whether an agent is currently working.
23 | """
24 | super().__init__()
25 | self.working = working
26 |
27 | def render(self) -> str:
28 | """Render the status bar with contextual help text."""
29 | # Check if in Q&A mode first (highest priority)
30 | if isinstance(self.screen, QAStateProvider) and self.screen.qa_mode:
31 | return (
32 | "[$foreground-muted][bold $text]esc[/] to exit Q&A mode • "
33 | "[bold $text]enter[/] to send answer • [bold $text]ctrl+j[/] for newline[/]"
34 | )
35 |
36 | if self.working:
37 | return (
38 | "[$foreground-muted][bold $text]esc[/] to stop • "
39 | "[bold $text]enter[/] to send • [bold $text]ctrl+j[/] for newline • "
40 | "[bold $text]ctrl+p[/] command palette • [bold $text]shift+tab[/] toggle mode • "
41 | "/help for commands[/]"
42 | )
43 | else:
44 | return (
45 | "[$foreground-muted][bold $text]enter[/] to send • "
46 | "[bold $text]ctrl+j[/] for newline • [bold $text]ctrl+p[/] command palette • "
47 | "[bold $text]shift+tab[/] toggle mode • /help for commands[/]"
48 | )
49 |
--------------------------------------------------------------------------------
/src/shotgun/tui/filtered_codebase_service.py:
--------------------------------------------------------------------------------
1 | """Filtered codebase service that restricts access to current directory's codebase only."""
2 |
3 | from pathlib import Path
4 |
5 | from shotgun.codebase.models import CodebaseGraph
6 | from shotgun.codebase.service import CodebaseService
7 |
8 |
9 | class FilteredCodebaseService(CodebaseService):
10 | """CodebaseService subclass that filters graphs to only those accessible from CWD.
11 |
12 | This ensures TUI agents can only see and access the codebase indexed from the
13 | current working directory, providing isolation between different project directories.
14 | """
15 |
16 | def __init__(self, storage_dir: Path | str):
17 | """Initialize the filtered service.
18 |
19 | Args:
20 | storage_dir: Directory to store graph databases
21 | """
22 | super().__init__(storage_dir)
23 | self._cwd = str(Path.cwd().resolve())
24 |
25 | async def list_graphs(self) -> list[CodebaseGraph]:
26 | """List only graphs accessible from the current working directory.
27 |
28 | Returns:
29 | Filtered list of CodebaseGraph objects accessible from CWD
30 | """
31 | # Use the existing filtering logic from list_graphs_for_directory
32 | return await super().list_graphs_for_directory(self._cwd)
33 |
34 | async def list_graphs_for_directory(
35 | self, directory: Path | str | None = None
36 | ) -> list[CodebaseGraph]:
37 | """List graphs for directory - always filters to CWD for TUI context.
38 |
39 | Args:
40 | directory: Ignored in TUI context, always uses CWD
41 |
42 | Returns:
43 | Filtered list of CodebaseGraph objects accessible from CWD
44 | """
45 | # Always use CWD regardless of what directory is passed
46 | return await super().list_graphs_for_directory(self._cwd)
47 |
--------------------------------------------------------------------------------
/test/integration/sdk/test_sdk_services.py:
--------------------------------------------------------------------------------
1 | """Integration tests for SDK service factory functions."""
2 |
3 | import pytest
4 |
5 | from shotgun.sdk.services import get_codebase_service
6 |
7 |
8 | @pytest.mark.integration
9 | def test_get_codebase_service_default_storage(isolated_shotgun_home):
10 | """Test get_codebase_service with default storage directory."""
11 | service = get_codebase_service()
12 |
13 | assert service is not None
14 | # Should use the test home directory
15 | assert service.storage_dir.parent == isolated_shotgun_home
16 | assert service.storage_dir.name == "codebases"
17 | assert service.storage_dir.exists() # Directory should be created
18 |
19 |
20 | @pytest.mark.integration
21 | def test_get_codebase_service_custom_storage(temp_storage_dir):
22 | """Test get_codebase_service with custom storage directory."""
23 | custom_dir = temp_storage_dir / "custom_storage"
24 | service = get_codebase_service(custom_dir)
25 |
26 | assert service is not None
27 | assert service.storage_dir == custom_dir
28 | assert custom_dir.exists()
29 |
30 |
31 | @pytest.mark.integration
32 | def test_get_codebase_service_string_path(temp_storage_dir):
33 | """Test get_codebase_service with string path."""
34 | custom_dir = temp_storage_dir / "string_storage"
35 | service = get_codebase_service(str(custom_dir))
36 |
37 | assert service is not None
38 | assert service.storage_dir == custom_dir
39 | assert custom_dir.exists()
40 |
41 |
42 | @pytest.mark.integration
43 | @pytest.mark.asyncio
44 | async def test_service_functionality(temp_storage_dir):
45 | """Test that returned service is functional."""
46 | custom_dir = temp_storage_dir / "functional_test"
47 | service = get_codebase_service(custom_dir)
48 |
49 | # Should be able to list graphs (empty initially)
50 | graphs = await service.list_graphs()
51 | assert graphs == []
52 |
--------------------------------------------------------------------------------
/test/unit/exceptions/test_context_size_exception.py:
--------------------------------------------------------------------------------
1 | """Unit tests for context size exception classes."""
2 |
3 | from shotgun.exceptions import ContextSizeLimitExceeded, ErrorNotPickedUpBySentry
4 |
5 |
6 | def test_error_not_picked_up_by_sentry_is_exception():
7 | """ErrorNotPickedUpBySentry should be an Exception."""
8 | error = ErrorNotPickedUpBySentry("test message")
9 | assert isinstance(error, Exception)
10 | assert str(error) == "test message"
11 |
12 |
13 | def test_context_size_limit_exceeded_inheritance():
14 | """ContextSizeLimitExceeded should inherit from ErrorNotPickedUpBySentry."""
15 | error = ContextSizeLimitExceeded(model_name="test-model", max_tokens=1000)
16 | assert isinstance(error, ErrorNotPickedUpBySentry)
17 | assert isinstance(error, Exception)
18 |
19 |
20 | def test_context_size_limit_exceeded_attributes():
21 | """ContextSizeLimitExceeded should store model_name and max_tokens."""
22 | error = ContextSizeLimitExceeded(model_name="claude-sonnet-4.5", max_tokens=200000)
23 |
24 | assert error.model_name == "claude-sonnet-4.5"
25 | assert error.max_tokens == 200000
26 |
27 |
28 | def test_context_size_limit_exceeded_message_formatting():
29 | """ContextSizeLimitExceeded should format message with commas."""
30 | error = ContextSizeLimitExceeded(model_name="gpt-5", max_tokens=400000)
31 |
32 | message = str(error)
33 | assert "gpt-5" in message
34 | assert "400,000" in message # Should have comma formatting
35 | assert "limit" in message.lower()
36 |
37 |
38 | def test_context_size_limit_exceeded_message_small_number():
39 | """ContextSizeLimitExceeded should format message correctly for small numbers."""
40 | error = ContextSizeLimitExceeded(model_name="test-model", max_tokens=999)
41 |
42 | message = str(error)
43 | assert "test-model" in message
44 | assert "999" in message # No comma for numbers < 1000
45 |
--------------------------------------------------------------------------------
/src/shotgun/codebase/core/cypher_models.py:
--------------------------------------------------------------------------------
1 | """Pydantic models and exceptions for Cypher query generation."""
2 |
3 | from typing import Any
4 |
5 | from pydantic import BaseModel, Field
6 |
7 |
8 | class CypherGenerationResponse(BaseModel):
9 | """Structured response from LLM for Cypher query generation.
10 |
11 | This model ensures the LLM explicitly indicates whether it can generate
12 | a valid Cypher query and provides a reason if it cannot.
13 | """
14 |
15 | cypher_query: str | None = Field(
16 | default=None,
17 | description="The generated Cypher query, or None if generation not possible",
18 | )
19 | can_generate_valid_cypher: bool = Field(
20 | description="Whether a valid Cypher query can be generated for this request"
21 | )
22 | reason_cannot_generate: str | None = Field(
23 | default=None,
24 | description="Explanation why query cannot be generated (if applicable)",
25 | )
26 |
27 | def model_post_init(self, __context: Any) -> None:
28 | """Validate that reason is provided when query cannot be generated."""
29 | if not self.can_generate_valid_cypher and not self.reason_cannot_generate:
30 | self.reason_cannot_generate = "No reason provided"
31 | if self.can_generate_valid_cypher and not self.cypher_query:
32 | raise ValueError(
33 | "cypher_query must be provided when can_generate_valid_cypher is True"
34 | )
35 |
36 |
37 | class CypherGenerationNotPossibleError(Exception):
38 | """Raised when LLM cannot generate valid Cypher for the query.
39 |
40 | This typically happens when the query is conceptual rather than structural,
41 | or when it requires interpretation beyond what can be expressed in Cypher.
42 | """
43 |
44 | def __init__(self, reason: str):
45 | self.reason = reason
46 | super().__init__(f"Cannot generate Cypher query: {reason}")
47 |
--------------------------------------------------------------------------------
/src/shotgun/tui/screens/chat_screen/history/partial_response.py:
--------------------------------------------------------------------------------
1 | """Partial response widget for streaming chat messages."""
2 |
3 | from pydantic_ai.messages import ModelMessage
4 | from textual.app import ComposeResult
5 | from textual.reactive import reactive
6 | from textual.widget import Widget
7 |
8 | from shotgun.tui.protocols import ActiveSubAgentProvider
9 |
10 | from .agent_response import AgentResponseWidget
11 | from .user_question import UserQuestionWidget
12 |
13 |
14 | class PartialResponseWidget(Widget): # TODO: doesn't work lol
15 | """Widget that displays a streaming/partial response in the chat history."""
16 |
17 | DEFAULT_CSS = """
18 | PartialResponseWidget {
19 | height: auto;
20 | }
21 | Markdown, AgentResponseWidget, UserQuestionWidget {
22 | height: auto;
23 | }
24 | """
25 |
26 | item: reactive[ModelMessage | None] = reactive(None, recompose=True)
27 |
28 | def __init__(self, item: ModelMessage | None) -> None:
29 | super().__init__()
30 | self.item = item
31 |
32 | def _is_sub_agent_active(self) -> bool:
33 | """Check if a sub-agent is currently active."""
34 | if isinstance(self.screen, ActiveSubAgentProvider):
35 | return self.screen.active_sub_agent is not None
36 | return False
37 |
38 | def compose(self) -> ComposeResult:
39 | if self.item is None:
40 | pass
41 | elif self.item.kind == "response":
42 | yield AgentResponseWidget(
43 | self.item, is_sub_agent=self._is_sub_agent_active()
44 | )
45 | elif self.item.kind == "request":
46 | yield UserQuestionWidget(self.item)
47 |
48 | def watch_item(self, item: ModelMessage | None) -> None:
49 | """React to changes in the item."""
50 | if item is None:
51 | self.display = False
52 | else:
53 | self.display = True
54 |
--------------------------------------------------------------------------------
/test/unit/shotgun_web/test_supabase_client.py:
--------------------------------------------------------------------------------
1 | """Tests for shotgun_web.supabase_client module."""
2 |
3 | from unittest.mock import AsyncMock, MagicMock, patch
4 |
5 | import httpx
6 | import pytest
7 |
8 | from shotgun.shotgun_web.supabase_client import download_file_from_url
9 |
10 |
11 | @pytest.mark.asyncio
12 | async def test_download_file_from_url_success():
13 | """Test download_file_from_url returns file content."""
14 | mock_response = MagicMock()
15 | mock_response.content = b"Test file content"
16 | mock_response.raise_for_status = MagicMock()
17 |
18 | mock_client = AsyncMock()
19 | mock_client.get = AsyncMock(return_value=mock_response)
20 |
21 | with patch(
22 | "httpx.AsyncClient",
23 | return_value=AsyncMock(__aenter__=AsyncMock(return_value=mock_client)),
24 | ):
25 | result = await download_file_from_url(
26 | "https://storage.example.com/file.md?token=abc123"
27 | )
28 |
29 | assert result == b"Test file content"
30 | mock_client.get.assert_called_once_with(
31 | "https://storage.example.com/file.md?token=abc123"
32 | )
33 |
34 |
35 | @pytest.mark.asyncio
36 | async def test_download_file_from_url_http_error():
37 | """Test download_file_from_url propagates HTTP errors."""
38 | mock_response = MagicMock()
39 | mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
40 | "Not Found",
41 | request=MagicMock(),
42 | response=MagicMock(status_code=404),
43 | )
44 |
45 | mock_client = AsyncMock()
46 | mock_client.get = AsyncMock(return_value=mock_response)
47 |
48 | with patch(
49 | "httpx.AsyncClient",
50 | return_value=AsyncMock(__aenter__=AsyncMock(return_value=mock_client)),
51 | ):
52 | with pytest.raises(httpx.HTTPStatusError):
53 | await download_file_from_url(
54 | "https://storage.example.com/nonexistent.md?token=abc123"
55 | )
56 |
--------------------------------------------------------------------------------
/test/unit/cli/spec/test_models.py:
--------------------------------------------------------------------------------
1 | """Tests for CLI spec models."""
2 |
3 | from datetime import datetime, timezone
4 |
5 | from shotgun.cli.spec.models import SpecMeta
6 |
7 |
8 | def test_spec_meta_creation() -> None:
9 | """Test SpecMeta model creation."""
10 | now = datetime.now(timezone.utc)
11 | meta = SpecMeta(
12 | version_id="version-123",
13 | spec_id="spec-456",
14 | spec_name="My Test Spec",
15 | workspace_id="workspace-789",
16 | is_latest=True,
17 | pulled_at=now,
18 | )
19 |
20 | assert meta.version_id == "version-123"
21 | assert meta.spec_id == "spec-456"
22 | assert meta.spec_name == "My Test Spec"
23 | assert meta.workspace_id == "workspace-789"
24 | assert meta.is_latest is True
25 | assert meta.pulled_at == now
26 |
27 |
28 | def test_spec_meta_serialization() -> None:
29 | """Test SpecMeta model serialization to JSON."""
30 | now = datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc)
31 | meta = SpecMeta(
32 | version_id="version-123",
33 | spec_id="spec-456",
34 | spec_name="My Test Spec",
35 | workspace_id="workspace-789",
36 | is_latest=False,
37 | pulled_at=now,
38 | )
39 |
40 | json_str = meta.model_dump_json(indent=2)
41 | assert '"version_id": "version-123"' in json_str
42 | assert '"spec_name": "My Test Spec"' in json_str
43 | assert '"is_latest": false' in json_str
44 |
45 |
46 | def test_spec_meta_from_json() -> None:
47 | """Test SpecMeta model deserialization from JSON."""
48 | json_str = """{
49 | "version_id": "v-id",
50 | "spec_id": "s-id",
51 | "spec_name": "Test",
52 | "workspace_id": "w-id",
53 | "is_latest": true,
54 | "pulled_at": "2024-01-15T10:30:00Z"
55 | }"""
56 |
57 | meta = SpecMeta.model_validate_json(json_str)
58 | assert meta.version_id == "v-id"
59 | assert meta.spec_name == "Test"
60 | assert meta.is_latest is True
61 |
--------------------------------------------------------------------------------
/evals/datasets/router_agent/planning_cases.py:
--------------------------------------------------------------------------------
1 | """
2 | Router agent test cases for planning behavior.
3 |
4 | Tests that the Router asks clarifying questions or creates plans
5 | based on request specificity.
6 | """
7 |
8 | from evals.models import (
9 | AgentType,
10 | ExpectedAgentOutput,
11 | ShotgunTestCase,
12 | TestCaseContext,
13 | TestCaseInput,
14 | )
15 |
16 | FEATURE_REQUEST_ASKS_QUESTIONS = ShotgunTestCase(
17 | name="feature_request_asks_questions",
18 | inputs=TestCaseInput(
19 | prompt="I want to write a spec for adding support for local models to this project",
20 | agent_type=AgentType.ROUTER,
21 | context=TestCaseContext(has_codebase_indexed=True, codebase_name="shotgun"),
22 | ),
23 | expected=ExpectedAgentOutput(min_clarifying_questions=1),
24 | )
25 |
26 | COMPLEX_FEATURE_ASKS_QUESTIONS = ShotgunTestCase(
27 | name="complex_feature_asks_questions",
28 | inputs=TestCaseInput(
29 | prompt="Add authentication to this project",
30 | agent_type=AgentType.ROUTER,
31 | context=TestCaseContext(has_codebase_indexed=True, codebase_name="shotgun"),
32 | ),
33 | expected=ExpectedAgentOutput(min_clarifying_questions=1),
34 | )
35 |
36 | SPECIFIC_FEATURE_CREATES_PLAN = ShotgunTestCase(
37 | name="specific_feature_creates_plan",
38 | inputs=TestCaseInput(
39 | prompt="I want to add Ollama support for local model inference. Research what's needed and write a spec for it.",
40 | agent_type=AgentType.ROUTER,
41 | context=TestCaseContext(has_codebase_indexed=True, codebase_name="shotgun"),
42 | ),
43 | expected=ExpectedAgentOutput(
44 | expected_tools=["create_plan"],
45 | expected_response="Plan should have research as the first step and writing a specification as the second step",
46 | ),
47 | )
48 |
49 | PLANNING_CASES: list[ShotgunTestCase] = [
50 | FEATURE_REQUEST_ASKS_QUESTIONS,
51 | COMPLEX_FEATURE_ASKS_QUESTIONS,
52 | SPECIFIC_FEATURE_CREATES_PLAN,
53 | ]
54 |
--------------------------------------------------------------------------------
/src/shotgun/prompts/codebase/partials/graph_schema.j2:
--------------------------------------------------------------------------------
1 |
2 | # Codebase Graph Schema Definition
3 | The database contains information about a codebase, structured with the following nodes and relationships.
4 |
5 | THE ONLY PROPERTIES THAT ARE AVAILABLE ARE THE ONES LISTED BELOW. DO NOT MAKE UP ANY OTHER PROPERTIES.
6 | PAY ATTENTION TO THE PROPERTY TYPES.
7 | - Project: {name: string}
8 | - Package: {qualified_name: string, name: string, path: string}
9 | - Folder: {path: string, name: string}
10 | - File: {path: string, name: string, extension: string} // Note: extension includes the dot (e.g., ".ts", ".py", ".js")
11 | - FileMetadata: {filepath: string, mtime: int64, hash: string, last_updated: int64}
12 | - Module: {qualified_name: string, name: string, path: string, created_at: int64, updated_at: int64}
13 | - Class: {qualified_name: string, name: string, decorators: list[string], line_start: int, line_end: int, created_at: int64, updated_at: int64}
14 | - Function: {qualified_name: string, name: string, decorators: list[string], line_start: int, line_end: int, created_at: int64, updated_at: int64}
15 | - Method: {qualified_name: string, name: string, decorators: list[string], line_start: int, line_end: int, created_at: int64, updated_at: int64}
16 | - ExternalPackage: {name: string, version_spec: string}
17 | - DeletionLog: {id: string, entity_type: string, entity_qualified_name: string, deleted_from_file: string, deleted_at: int64, deletion_reason: string}
18 |
19 | Relationships (source)-[REL_TYPE]->(target):
20 | - (Project|Package|Folder) -[:CONTAINS_PACKAGE|CONTAINS_FOLDER|CONTAINS_FILE|CONTAINS_MODULE]-> (various)
21 | - Module -[:DEFINES]-> (Class|Function)
22 | - Class -[:DEFINES_METHOD]-> Method
23 | - (Child Class) -[:INHERITS]-> (Parent Class)
24 | - Method -[:OVERRIDES]-> Method
25 | - Project -[:DEPENDS_ON_EXTERNAL]-> ExternalPackage
26 | - (Function|Method) -[:CALLS]-> (Function|Method)
27 | - FileMetadata -[:TRACKS_Module]-> Module
28 | - FileMetadata -[:TRACKS_Class]-> Class
29 | - FileMetadata -[:TRACKS_Function]-> Function
30 | - FileMetadata -[:TRACKS_Method]-> Method
--------------------------------------------------------------------------------
/src/shotgun/prompts/agents/partials/common_agent_system_prompt.j2:
--------------------------------------------------------------------------------
1 | You are an experienced Software Architect with experience in Business Analysis, Product Management, Software Architecture, and Software Development
2 |
3 | ## YOUR ROLE IN THE PIPELINE
4 |
5 |
6 | It is critical that you understand you are a DOCUMENTATION and PLANNING agent, NOT a coding/implementation agent.
7 | You produce DOCUMENTS (research, specifications, plans, tasks) that AI coding agents will consume
8 | You do NOT try to write production code, implement features, or make code changes.
9 | NEVER offer to "move forward with implementation" or "start coding" - that's not your job
10 | NEVER ask "would you like me to implement this?" - implementation is done by separate AI coding tools
11 | Your deliverable is always a document file (.md), not code execution
12 | When your work is complete, the user will take your documents to a coding agent (Claude Code, Cursor, etc.)
13 |
14 |
15 |
16 | {% if interactive_mode %}
17 | Ask CLARIFYING QUESTIONS using structured output for complex or multi-step tasks when the request lacks sufficient detail.
18 | - Return your response with the clarifying_questions field populated
19 | - For simple, straightforward requests, make reasonable assumptions and proceed.
20 | - Only ask the most critical questions to avoid overwhelming the user.
21 | - Questions should be clear, specific, and answerable
22 | {% endif %}
23 | Always prioritize user needs and provide actionable assistance.
24 | Avoid redundant work by check existing files in the .shotgun/ and conversation history.
25 | If the user seems not to know something, always be creative and come up with ideas that fit their thinking.
26 | DO NOT repeat yourself.
27 | If a user has agreed to a plan, you DO NOT NEED TO FOLLOW UP with them after every step to ask "is this search query ok?" just execute the plan.
28 |
29 |
30 | {% include 'agents/partials/codebase_understanding.j2' %}
31 |
32 | {% include 'agents/partials/content_formatting.j2' %}
33 |
34 | {% include 'agents/partials/interactive_mode.j2' %}
--------------------------------------------------------------------------------
/src/shotgun/shotgun_web/shared_specs/models.py:
--------------------------------------------------------------------------------
1 | """Pydantic models for the shared specs upload pipeline."""
2 |
3 | from pydantic import BaseModel
4 |
5 | from shotgun.shotgun_web.models import FileMetadata
6 | from shotgun.shotgun_web.shared_specs.utils import UploadPhase
7 |
8 |
9 | class UploadProgress(BaseModel):
10 | """Progress information for the upload pipeline.
11 |
12 | Attributes:
13 | phase: Current phase of the pipeline
14 | current: Current item number in the phase
15 | total: Total items in the phase
16 | current_file: Name of the file currently being processed
17 | bytes_uploaded: Total bytes uploaded so far
18 | total_bytes: Total bytes to upload
19 | message: Human-readable status message
20 | """
21 |
22 | phase: UploadPhase
23 | current: int = 0
24 | total: int = 0
25 | current_file: str | None = None
26 | bytes_uploaded: int = 0
27 | total_bytes: int = 0
28 | message: str = ""
29 |
30 |
31 | class UploadResult(BaseModel):
32 | """Result of the upload pipeline.
33 |
34 | Attributes:
35 | success: Whether the upload completed successfully
36 | web_url: URL to view the spec version (on success)
37 | error: Error message (on failure)
38 | files_uploaded: Number of files uploaded
39 | total_bytes: Total bytes uploaded
40 | """
41 |
42 | success: bool
43 | web_url: str | None = None
44 | error: str | None = None
45 | files_uploaded: int = 0
46 | total_bytes: int = 0
47 |
48 |
49 | class FileWithHash(BaseModel):
50 | """File metadata with computed hash."""
51 |
52 | metadata: FileMetadata
53 | content_hash: str = ""
54 |
55 |
56 | class UploadState(BaseModel):
57 | """Internal state for upload progress tracking."""
58 |
59 | files_uploaded: int = 0
60 | bytes_uploaded: int = 0
61 | total_bytes: int = 0
62 | current_file: str | None = None
63 | hashes_completed: int = 0
64 | total_files: int = 0
65 |
66 |
67 | class ScanResult(BaseModel):
68 | """Result of scanning .shotgun/ directory."""
69 |
70 | files: list[FileMetadata]
71 | total_files_before_filter: int
72 |
--------------------------------------------------------------------------------
/test/unit/agents/config/test_models.py:
--------------------------------------------------------------------------------
1 | """Tests for agents.config.models module."""
2 |
3 | from shotgun.agents.config.models import MODEL_SPECS, ModelName
4 |
5 |
6 | def test_model_spec_short_name_claude_sonnet():
7 | """Test ModelSpec short_name for Claude Sonnet 4.5."""
8 | spec = MODEL_SPECS[ModelName.CLAUDE_SONNET_4_5]
9 | assert spec.short_name == "Sonnet 4.5"
10 |
11 |
12 | def test_model_spec_short_name_claude_opus():
13 | """Test ModelSpec short_name for Claude Opus 4.5."""
14 | spec = MODEL_SPECS[ModelName.CLAUDE_OPUS_4_5]
15 | assert spec.short_name == "Opus 4.5"
16 |
17 |
18 | def test_model_spec_short_name_claude_haiku():
19 | """Test ModelSpec short_name for Claude Haiku 4.5."""
20 | spec = MODEL_SPECS[ModelName.CLAUDE_HAIKU_4_5]
21 | assert spec.short_name == "Haiku 4.5"
22 |
23 |
24 | def test_model_spec_short_name_gpt_5_1():
25 | """Test ModelSpec short_name for GPT-5.1."""
26 | spec = MODEL_SPECS[ModelName.GPT_5_1]
27 | assert spec.short_name == "GPT-5.1"
28 |
29 |
30 | def test_model_spec_short_name_gpt_5_2():
31 | """Test ModelSpec short_name for GPT-5.2."""
32 | spec = MODEL_SPECS[ModelName.GPT_5_2]
33 | assert spec.short_name == "GPT-5.2"
34 |
35 |
36 | def test_model_spec_short_name_gemini_pro():
37 | """Test ModelSpec short_name for Gemini 2.5 Pro."""
38 | spec = MODEL_SPECS[ModelName.GEMINI_2_5_PRO]
39 | assert spec.short_name == "Gemini 2.5 Pro"
40 |
41 |
42 | def test_model_spec_short_name_gemini_flash():
43 | """Test ModelSpec short_name for Gemini 2.5 Flash."""
44 | spec = MODEL_SPECS[ModelName.GEMINI_2_5_FLASH]
45 | assert spec.short_name == "Gemini 2.5 Flash"
46 |
47 |
48 | def test_all_models_have_short_name():
49 | """Test that all ModelName enum values have a short_name in MODEL_SPECS."""
50 | for model_name in ModelName:
51 | spec = MODEL_SPECS[model_name]
52 | # Should have a short_name field
53 | assert hasattr(spec, "short_name")
54 | # Should not return the enum string representation
55 | assert spec.short_name != str(model_name)
56 | # Should return a non-empty string
57 | assert len(spec.short_name) > 0
58 |
--------------------------------------------------------------------------------
/test/unit/codebase/tools/conftest.py:
--------------------------------------------------------------------------------
1 | """Shared fixtures for codebase tools tests."""
2 |
3 | from unittest.mock import AsyncMock, MagicMock
4 |
5 | import pytest
6 | from pydantic_ai import RunContext
7 |
8 | from shotgun.agents.config.models import (
9 | KeyProvider,
10 | ModelConfig,
11 | ModelName,
12 | ProviderType,
13 | )
14 | from shotgun.agents.models import AgentDeps, AgentRuntimeOptions
15 | from shotgun.codebase.models import CodebaseGraph, GraphStatus
16 |
17 |
18 | @pytest.fixture
19 | def mock_codebase_service():
20 | """Create a mock codebase service."""
21 | service = MagicMock()
22 | service.list_graphs = AsyncMock()
23 | service.execute_query = AsyncMock()
24 | service.manager = MagicMock()
25 | service.manager._execute_query = AsyncMock()
26 | return service
27 |
28 |
29 | @pytest.fixture
30 | def mock_graph():
31 | """Create a mock codebase graph."""
32 | return CodebaseGraph(
33 | graph_id="test-graph-id",
34 | repo_path="/tmp/test-repo", # noqa: S108
35 | graph_path="/tmp/test-graph.db", # noqa: S108
36 | name="Test Graph",
37 | created_at=1234567890.0,
38 | updated_at=1234567890.0,
39 | status=GraphStatus.READY,
40 | )
41 |
42 |
43 | @pytest.fixture
44 | def mock_agent_deps(mock_codebase_service):
45 | """Create mock agent dependencies."""
46 | runtime_options = AgentRuntimeOptions()
47 |
48 | # Create a real ModelConfig instead of a mock
49 | model_config = ModelConfig(
50 | name=ModelName.GPT_5_1,
51 | provider=ProviderType.OPENAI,
52 | key_provider=KeyProvider.BYOK,
53 | max_input_tokens=4096,
54 | max_output_tokens=2048,
55 | api_key="test-api-key",
56 | )
57 |
58 | # Use model_construct to bypass validation entirely for the mock
59 | deps = AgentDeps.model_construct(
60 | **runtime_options.model_dump(),
61 | llm_model=model_config,
62 | codebase_service=mock_codebase_service,
63 | )
64 | return deps
65 |
66 |
67 | @pytest.fixture
68 | def mock_run_context(mock_agent_deps):
69 | """Create mock run context."""
70 | return MagicMock(spec=RunContext, deps=mock_agent_deps)
71 |
--------------------------------------------------------------------------------
/test/unit/codebase/tools/test_file_read.py:
--------------------------------------------------------------------------------
1 | """Tests for file_read tool."""
2 |
3 | import pytest
4 |
5 | from shotgun.agents.tools.codebase import FileReadResult, file_read
6 |
7 |
8 | @pytest.mark.asyncio
9 | async def test_file_read_success(
10 | mock_run_context, mock_codebase_service, mock_graph, tmp_path
11 | ):
12 | """Test successful file reading."""
13 | # Setup test file
14 | test_file = tmp_path / "test.py"
15 | test_content = "# Test file\nprint('hello')"
16 | test_file.write_text(test_content)
17 |
18 | # Mock graph with tmp_path as repo
19 | mock_graph.repo_path = str(tmp_path)
20 | mock_codebase_service.list_graphs.return_value = [mock_graph]
21 |
22 | # Execute
23 | result = await file_read(mock_run_context, "test-graph-id", "test.py")
24 |
25 | # Verify
26 | assert isinstance(result, FileReadResult)
27 | assert result.success is True
28 | assert result.file_path == "test.py"
29 | assert result.content == test_content
30 | assert test_content in str(result)
31 |
32 |
33 | @pytest.mark.asyncio
34 | async def test_file_read_security_violation(
35 | mock_run_context, mock_codebase_service, mock_graph, tmp_path
36 | ):
37 | """Test file_read prevents path traversal."""
38 | mock_graph.repo_path = str(tmp_path)
39 | mock_codebase_service.list_graphs.return_value = [mock_graph]
40 |
41 | # Try to read outside repo
42 | result = await file_read(mock_run_context, "test-graph-id", "../../../etc/passwd")
43 |
44 | assert isinstance(result, FileReadResult)
45 | assert result.success is False
46 | assert result.error and "Access denied" in result.error
47 |
48 |
49 | @pytest.mark.asyncio
50 | async def test_file_read_file_not_found(
51 | mock_run_context, mock_codebase_service, mock_graph, tmp_path
52 | ):
53 | """Test file_read with non-existent file."""
54 | mock_graph.repo_path = str(tmp_path)
55 | mock_codebase_service.list_graphs.return_value = [mock_graph]
56 |
57 | result = await file_read(mock_run_context, "test-graph-id", "nonexistent.py")
58 |
59 | assert isinstance(result, FileReadResult)
60 | assert result.success is False
61 | assert result.error and "not found" in result.error
62 |
--------------------------------------------------------------------------------
/test/unit/tui/test_chat_screen_byok_hints.py:
--------------------------------------------------------------------------------
1 | """Tests for BYOK signup hints in ChatScreen error handling."""
2 |
3 | from shotgun.agents.config.models import KeyProvider
4 | from shotgun.exceptions import SHOTGUN_SIGNUP_URL
5 |
6 |
7 | def test_signup_url_constant_defined():
8 | """Verify that the SHOTGUN_SIGNUP_URL constant is defined."""
9 | assert SHOTGUN_SIGNUP_URL == "https://shotgun.sh"
10 |
11 |
12 | def test_byok_account_detection(mock_agent_deps):
13 | """Test that BYOK accounts can be detected via key_provider."""
14 | # Set as BYOK
15 | mock_agent_deps.llm_model.key_provider = KeyProvider.BYOK
16 | assert not mock_agent_deps.llm_model.is_shotgun_account
17 |
18 | # Set as Shotgun
19 | mock_agent_deps.llm_model.key_provider = KeyProvider.SHOTGUN
20 | assert mock_agent_deps.llm_model.is_shotgun_account
21 |
22 |
23 | def test_error_message_detection_rate_limit():
24 | """Test that rate limit errors can be detected from error messages."""
25 | error_message = "Rate limit exceeded for requests"
26 | assert "rate" in error_message.lower()
27 |
28 |
29 | def test_error_message_detection_quota():
30 | """Test that quota errors can be detected from error messages."""
31 | error_message = "Insufficient quota remaining"
32 | assert "quota" in error_message.lower()
33 |
34 |
35 | def test_error_message_detection_auth():
36 | """Test that auth errors can be detected from error messages."""
37 | error_message = "Invalid API key provided"
38 | assert "invalid" in error_message.lower() and "key" in error_message.lower()
39 |
40 | error_message2 = "Authentication failed"
41 | assert "authentication" in error_message2.lower()
42 |
43 |
44 | def test_error_message_detection_overload():
45 | """Test that overload errors can be detected from error messages."""
46 | error_message = "The server is overloaded"
47 | assert "overload" in error_message.lower()
48 |
49 |
50 | def test_error_name_detection():
51 | """Test that APIStatusError can be detected from exception type name."""
52 |
53 | # Simulate what happens in the error handler
54 | class APIStatusError(Exception):
55 | pass
56 |
57 | error = APIStatusError("test")
58 | error_name = type(error).__name__
59 | assert "APIStatusError" in error_name
60 |
--------------------------------------------------------------------------------
/src/shotgun/prompts/history/combine_summaries.j2:
--------------------------------------------------------------------------------
1 | You are combining {{ num_summaries }} conversation chunk summaries into a unified summary.
2 |
3 |
4 | {% for summary in chunk_summaries %}
5 | --- Chunk {{ loop.index }} of {{ num_summaries }} ---
6 | {{ summary }}
7 |
8 | {% endfor %}
9 |
10 |
11 | Instructions:
12 | 1. Merge overlapping information - don't repeat the same facts
13 | 2. Preserve ALL entity references (files, functions, variables) from ALL chunks
14 | 3. Maintain chronological timeline of actions
15 | 4. Identify patterns and evolution across chunks
16 | 5. The current status should reflect the LAST chunk's end state
17 |
18 |
19 | # Context
20 |
21 | Short summary of the discussion, grouped by topics or tasks if any.
22 | Present it as clear bullet points. Be brief but do not lose information that might be important for the current state, task or objectives.
23 |
24 | # Key elements, learnings and entities
25 |
26 | Present the important elements of context, learnings and entities from the discussion. Be very detailed.
27 |
28 | Present it as clear bullet points. Be brief but do not lose information that might be important for the current state, task or objectives.
29 |
30 | If the agent obtained information via tools, preserve it verbatim if it was short, summarize it if it was long focusing on preserving all of the most important facts and information.
31 |
32 | If there are any links mentioned, IDs, filenames, etc. - preserve them verbatim.
33 |
34 | # Timeline
35 |
36 | For important tasks and content, provide
37 | - User: asked to handle a complex task X:
38 | - Assistant: we handle it by doing A, B, C...
39 | - Tools used and output summary
40 |
41 | ...
42 | - User: asked to handle a complex task Y:
43 | - Assistant: we handle it by doing A, B, C...
44 | - Tools used and output summary
45 |
46 | Do not hesitate to merge original messages, remove noise. We want to be as concise and brief as possible.
47 |
48 | # Current status, task and objectives
49 |
50 | Based on the conversation, include here the current status, task and objectives.
51 |
52 | CRITICAL: This is not about summarizing or compacting. We are talking about the status, task and objectives currently active in the conversation to keep as much context as possible regarding the last messages.
53 |
54 |
--------------------------------------------------------------------------------
/src/shotgun/shotgun_web/__init__.py:
--------------------------------------------------------------------------------
1 | """Shotgun Web API client for subscription, authentication, and shared specs."""
2 |
3 | from .client import ShotgunWebClient, check_token_status, create_unification_token
4 | from .exceptions import (
5 | ConflictError,
6 | ForbiddenError,
7 | NotFoundError,
8 | PayloadTooLargeError,
9 | RateLimitExceededError,
10 | ShotgunWebError,
11 | UnauthorizedError,
12 | )
13 | from .models import (
14 | # Specs models
15 | ErrorDetail,
16 | ErrorResponse,
17 | FileListResponse,
18 | FileMetadata,
19 | FileUploadRequest,
20 | FileUploadResponse,
21 | PermissionCheckResponse,
22 | PublicSpecResponse,
23 | SpecCreateRequest,
24 | SpecCreateResponse,
25 | SpecFileResponse,
26 | SpecListResponse,
27 | SpecResponse,
28 | SpecUpdateRequest,
29 | SpecVersionCreateRequest,
30 | SpecVersionResponse,
31 | SpecVersionState,
32 | # Token models
33 | TokenCreateRequest,
34 | TokenCreateResponse,
35 | TokenStatus,
36 | TokenStatusResponse,
37 | VersionCloseResponse,
38 | VersionCreateResponse,
39 | VersionListResponse,
40 | WorkspaceRole,
41 | )
42 | from .specs_client import SpecsClient
43 |
44 | __all__ = [
45 | # Existing exports
46 | "ShotgunWebClient",
47 | "create_unification_token",
48 | "check_token_status",
49 | "TokenCreateRequest",
50 | "TokenCreateResponse",
51 | "TokenStatus",
52 | "TokenStatusResponse",
53 | # Specs client
54 | "SpecsClient",
55 | # Exceptions
56 | "ShotgunWebError",
57 | "UnauthorizedError",
58 | "ForbiddenError",
59 | "NotFoundError",
60 | "ConflictError",
61 | "PayloadTooLargeError",
62 | "RateLimitExceededError",
63 | # Specs models
64 | "ErrorDetail",
65 | "ErrorResponse",
66 | "FileListResponse",
67 | "FileMetadata",
68 | "FileUploadRequest",
69 | "FileUploadResponse",
70 | "PermissionCheckResponse",
71 | "PublicSpecResponse",
72 | "SpecCreateRequest",
73 | "SpecCreateResponse",
74 | "SpecFileResponse",
75 | "SpecListResponse",
76 | "SpecResponse",
77 | "SpecUpdateRequest",
78 | "SpecVersionCreateRequest",
79 | "SpecVersionResponse",
80 | "SpecVersionState",
81 | "VersionCloseResponse",
82 | "VersionCreateResponse",
83 | "VersionListResponse",
84 | "WorkspaceRole",
85 | ]
86 |
--------------------------------------------------------------------------------
/src/shotgun/prompts/history/summarization.j2:
--------------------------------------------------------------------------------
1 | Summarize the following conversation. Focus on key information and maintain context.
2 | Provide the output as specified in .
3 | Remove noisy information or irrelevant messages.
4 |
5 |
6 | # Context
7 |
8 | Short summary of the discussion, grouped by topics or tasks if any.
9 | Present it as clear bullet points. Be brief but do not lose information that might be important for the current state, task or objectives.
10 |
11 | # Key elements, learnings and entities
12 |
13 | Present the important elements of context, learnings and entities from the discussion. Be very detailed.
14 |
15 | Present it as clear bullet points. Be brief but do not lose information that might be important for the current state, task or objectives.
16 |
17 | If the agent obtained information via tools, preserve it verbatim if it was short, summarize it if it was long focusing on preserving all of the most important facts and information.
18 |
19 | If the agent has acknowledged the user's request, make sure to include the summary of the work done in the output so it doesn't acknowledge the user again.
20 |
21 | If agent listed files, code or retrieved parts of the code, preserve it verbatim unless it's very long, then summarize it. ALWAYS IN THE CONTEXT OF WHAT THE AGENT NEEDS.
22 |
23 | Keep user's messages verbatim if they are short to medium length, summarize them if they are long focusing on preserving all of the most important facts and information.
24 |
25 | If there are any links mentioned, IDs, filenames, etc. - preserve them verbatim.
26 |
27 | # Timeline
28 |
29 | For important tasks and content, provide
30 | - User: asked to handle a complex task X:
31 | - Assistant: we handle it by doing A, B, C...
32 | - Tools used and output summary
33 |
34 | ...
35 | - User: asked to handle a complex task Y:
36 | - Assistant: we handle it by doing A, B, C...
37 | - Tools used and output summary
38 |
39 | Do not hesitate to merge original messages, remove noise. We want to be as concise and brief as possible.
40 |
41 | # Current status, task and objectives
42 |
43 | Based on the conversation, include here the current status, task and objectives.
44 |
45 | CRITICAL: This is not about summarizing or compacting. We are talking about the status, task and objectives currently active in the convertion to keep as much context as possible regarding the last messages.
46 |
--------------------------------------------------------------------------------
/src/shotgun/agents/llm.py:
--------------------------------------------------------------------------------
1 | """LLM request utilities for Shotgun agents."""
2 |
3 | from typing import Any
4 |
5 | from pydantic_ai.direct import model_request
6 | from pydantic_ai.messages import ModelMessage, ModelResponse
7 | from pydantic_ai.settings import ModelSettings
8 |
9 | from shotgun.agents.config.models import ModelConfig
10 | from shotgun.logging_config import get_logger
11 |
12 | logger = get_logger(__name__)
13 |
14 |
15 | async def shotgun_model_request(
16 | model_config: ModelConfig,
17 | messages: list[ModelMessage],
18 | model_settings: ModelSettings | None = None,
19 | **kwargs: Any,
20 | ) -> ModelResponse:
21 | """Model request wrapper that uses full token capacity by default.
22 |
23 | This wrapper ensures all LLM calls in Shotgun use the maximum available
24 | token capacity of each model, improving response quality and completeness.
25 | The most common issue this fixes is truncated summaries that were cut off
26 | at default token limits (e.g., 4096 for Claude models).
27 |
28 | Args:
29 | model_config: ModelConfig instance with model settings and API key
30 | messages: Messages to send to the model
31 | model_settings: Optional ModelSettings. If None, creates default with max tokens
32 | **kwargs: Additional arguments passed to model_request
33 |
34 | Returns:
35 | ModelResponse from the model
36 |
37 | Example:
38 | # Uses full token capacity (e.g., 4096 for Claude, 128k for GPT-5)
39 | response = await shotgun_model_request(model_config, messages)
40 |
41 | # With custom settings
42 | response = await shotgun_model_request(model_config, messages, model_settings=ModelSettings(max_tokens=1000, temperature=0.7))
43 | """
44 | if kwargs.get("max_tokens") is not None:
45 | logger.warning(
46 | "⚠️ 'max_tokens' argument is ignored in shotgun_model_request. "
47 | "Set 'model_settings.max_tokens' instead."
48 | )
49 |
50 | if not model_settings:
51 | model_settings = ModelSettings()
52 |
53 | if model_settings.get("max_tokens") is None:
54 | model_settings["max_tokens"] = model_config.max_output_tokens
55 |
56 | # Make the model request with full token utilization
57 | return await model_request(
58 | model=model_config.model_instance,
59 | messages=messages,
60 | model_settings=model_settings,
61 | **kwargs,
62 | )
63 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # =============================================================================
2 | # Build Configuration
3 | # =============================================================================
4 | # Build validation is SKIPPED by default (for local dev and CI test jobs).
5 | # To require validation (fail if secrets are missing), set:
6 | # SHOTGUN_BUILD_REQUIRE_VALIDATION=true
7 | #
8 | # This is used in production builds (deploy.yml) to ensure secrets are configured.
9 |
10 | # =============================================================================
11 | # Telemetry Configuration (SHOTGUN_ prefixed for runtime override)
12 | # =============================================================================
13 | # These keys are typically embedded at build time via hatch build hooks.
14 | # The SHOTGUN_ prefixed environment variables can be used to override at runtime.
15 |
16 | # Sentry Error Tracking
17 | # Runtime override: export SHOTGUN_SENTRY_DSN="your_sentry_dsn"
18 | SHOTGUN_SENTRY_DSN=
19 |
20 | # PostHog Analytics
21 | # Runtime override: export SHOTGUN_POSTHOG_API_KEY="your_posthog_api_key"
22 | # Note: Hardcoded key "phc_KKnChzZUKeNqZDOTJ6soCBWNQSx3vjiULdwTR9H5Mcr" was removed
23 | # from posthog_telemetry.py on 2025-10-29 and replaced with build constants.
24 | SHOTGUN_POSTHOG_API_KEY=
25 | SHOTGUN_POSTHOG_PROJECT_ID=
26 |
27 | # Logfire (Pydantic) Observability
28 | # Runtime override: export SHOTGUN_LOGFIRE_ENABLED="true"
29 | # Note: Logfire is only embedded in dev builds (versions containing dev, rc, alpha, beta)
30 | SHOTGUN_LOGFIRE_ENABLED=false
31 | SHOTGUN_LOGFIRE_TOKEN=
32 |
33 | # =============================================================================
34 | # Optional Configuration (SHOTGUN_ prefixed)
35 | # =============================================================================
36 |
37 | # Logging Configuration
38 | SHOTGUN_LOG_LEVEL=INFO
39 | SHOTGUN_LOGGING_TO_CONSOLE=false
40 | SHOTGUN_LOGGING_TO_FILE=true
41 |
42 | # Testing / Development Configuration
43 | SHOTGUN_HOME=~/.shotgun-sh
44 | SHOTGUN_PIPX_SIMULATE=false
45 | SHOTGUN_WEB_BASE_URL=https://api-219702594231.us-east4.run.app
46 | SHOTGUN_ACCOUNT_LLM_BASE_URL=https://litellm-219702594231.us-east4.run.app
47 |
48 | # =============================================================================
49 | # PyPI Publishing Configuration
50 | # =============================================================================
51 | PYPI_TOKEN=your_pypi_api_token_here
52 | TEST_PYPI_TOKEN=your_test_pypi_api_token_here
53 |
--------------------------------------------------------------------------------
/src/shotgun/tui/components/prompt_input.py:
--------------------------------------------------------------------------------
1 | from textual import events
2 | from textual.message import Message
3 | from textual.widgets import TextArea
4 |
5 |
6 | class PromptInput(TextArea):
7 | """A TextArea with a submit binding."""
8 |
9 | DEFAULT_CSS = """
10 | PromptInput {
11 | outline: round $primary;
12 | background: transparent;
13 | }
14 | """
15 |
16 | def check_action(self, action: str, parameters: tuple[object, ...]) -> bool:
17 | if action != "copy":
18 | return True
19 | # run copy action if there is selected text
20 | # otherwise, do nothing, so global ctrl+c still works.
21 | return bool(self.selected_text)
22 |
23 | class Submitted(Message):
24 | """A message to indicate that the text has been submitted."""
25 |
26 | def __init__(self, text: str) -> None:
27 | super().__init__()
28 | self.text = text
29 |
30 | def action_submit(self) -> None:
31 | """An action to submit the text."""
32 | self.post_message(self.Submitted(self.text))
33 |
34 | async def _on_key(self, event: events.Key) -> None:
35 | """Handle key presses which correspond to document inserts."""
36 |
37 | # Don't handle Enter key here - let the binding handle it
38 | if event.key == "enter":
39 | self.action_submit()
40 |
41 | self._restart_blink()
42 |
43 | if self.read_only:
44 | return
45 |
46 | key = event.key
47 | insert_values = {
48 | "ctrl+j": "\n",
49 | }
50 | if self.tab_behavior == "indent":
51 | if key == "escape":
52 | event.stop()
53 | event.prevent_default()
54 | self.screen.focus_next()
55 | return
56 | if self.indent_type == "tabs":
57 | insert_values["tab"] = "\t"
58 | else:
59 | insert_values["tab"] = " " * self._find_columns_to_next_tab_stop()
60 |
61 | if event.is_printable or key in insert_values:
62 | event.stop()
63 | event.prevent_default()
64 | insert = insert_values.get(key, event.character)
65 | # `insert` is not None because event.character cannot be
66 | # None because we've checked that it's printable.
67 | assert insert is not None # noqa: S101
68 | start, end = self.selection
69 | self._replace_via_keyboard(insert, start, end)
70 |
--------------------------------------------------------------------------------
/src/shotgun/shotgun_web/constants.py:
--------------------------------------------------------------------------------
1 | """Constants for Shotgun Web API."""
2 |
3 | # Import from centralized API endpoints module
4 | from shotgun.api_endpoints import SHOTGUN_WEB_BASE_URL
5 |
6 | # API endpoints
7 | UNIFICATION_TOKEN_CREATE_PATH = "/api/unification/token/create" # noqa: S105
8 | UNIFICATION_TOKEN_STATUS_PATH = "/api/unification/token/{token}/status" # noqa: S105
9 | ME_PATH = "/api/me"
10 |
11 | # Polling configuration
12 | DEFAULT_POLL_INTERVAL_SECONDS = 3
13 | DEFAULT_TOKEN_TIMEOUT_SECONDS = 1800 # 30 minutes
14 |
15 | # Workspaces API endpoint
16 | WORKSPACES_PATH = "/api/workspaces"
17 |
18 | # Specs API endpoints
19 | PERMISSIONS_PATH = "/api/workspaces/{workspace_id}/specs/permissions"
20 | SPECS_BASE_PATH = "/api/workspaces/{workspace_id}/specs"
21 | SPECS_DETAIL_PATH = "/api/workspaces/{workspace_id}/specs/{spec_id}"
22 | VERSIONS_PATH = "/api/workspaces/{workspace_id}/specs/{spec_id}/versions"
23 | VERSION_DETAIL_PATH = (
24 | "/api/workspaces/{workspace_id}/specs/{spec_id}/versions/{version_id}"
25 | )
26 | VERSION_CLOSE_PATH = (
27 | "/api/workspaces/{workspace_id}/specs/{spec_id}/versions/{version_id}/close"
28 | )
29 | VERSION_SET_LATEST_PATH = (
30 | "/api/workspaces/{workspace_id}/specs/{spec_id}/versions/{version_id}/set-latest"
31 | )
32 | FILES_PATH = (
33 | "/api/workspaces/{workspace_id}/specs/{spec_id}/versions/{version_id}/files"
34 | )
35 | FILE_DETAIL_PATH = "/api/workspaces/{workspace_id}/specs/{spec_id}/versions/{version_id}/files/{file_id}"
36 | PUBLIC_SPEC_PATH = "/api/public/specs/{spec_id}"
37 | PUBLIC_SPEC_FILES_PATH = "/api/public/specs/{spec_id}/files"
38 | PUBLIC_FILE_PATH = "/api/public/specs/{spec_id}/files/{file_id}"
39 |
40 | # CLI convenience endpoint (version lookup by ID only)
41 | VERSION_BY_ID_PATH = "/api/versions/{version_id}"
42 |
43 | # Re-export for backward compatibility
44 | __all__ = [
45 | "SHOTGUN_WEB_BASE_URL",
46 | "UNIFICATION_TOKEN_CREATE_PATH",
47 | "UNIFICATION_TOKEN_STATUS_PATH",
48 | "ME_PATH",
49 | "DEFAULT_POLL_INTERVAL_SECONDS",
50 | "DEFAULT_TOKEN_TIMEOUT_SECONDS",
51 | # Workspaces endpoint
52 | "WORKSPACES_PATH",
53 | # Specs endpoints
54 | "PERMISSIONS_PATH",
55 | "SPECS_BASE_PATH",
56 | "SPECS_DETAIL_PATH",
57 | "VERSIONS_PATH",
58 | "VERSION_DETAIL_PATH",
59 | "VERSION_CLOSE_PATH",
60 | "VERSION_SET_LATEST_PATH",
61 | "FILES_PATH",
62 | "FILE_DETAIL_PATH",
63 | "PUBLIC_SPEC_PATH",
64 | "PUBLIC_SPEC_FILES_PATH",
65 | "PUBLIC_FILE_PATH",
66 | "VERSION_BY_ID_PATH",
67 | ]
68 |
--------------------------------------------------------------------------------
/test/unit/shotgun_web/shared_specs/conftest.py:
--------------------------------------------------------------------------------
1 | """Fixtures for shared_specs tests."""
2 |
3 | from pathlib import Path
4 |
5 | import pytest
6 |
7 |
8 | @pytest.fixture
9 | def temp_shotgun_dir(tmp_path: Path) -> Path:
10 | """Create a temporary .shotgun/ directory with test files."""
11 | shotgun_dir = tmp_path / ".shotgun"
12 | shotgun_dir.mkdir()
13 |
14 | # Create some test files
15 | (shotgun_dir / "research.md").write_text("# Research\n\nSome research content.")
16 | (shotgun_dir / "specification.md").write_text("# Specification\n\nSpec content.")
17 |
18 | # Create a subdirectory with files
19 | contracts_dir = shotgun_dir / "contracts"
20 | contracts_dir.mkdir()
21 | (contracts_dir / "api.yaml").write_text("openapi: 3.0.0\ninfo:\n title: Test API")
22 | (contracts_dir / "models.py").write_text("class TestModel:\n pass")
23 |
24 | return tmp_path
25 |
26 |
27 | @pytest.fixture
28 | def temp_shotgun_dir_with_ignored_files(temp_shotgun_dir: Path) -> Path:
29 | """Create a temporary .shotgun/ directory with files that should be ignored."""
30 | shotgun_dir = temp_shotgun_dir / ".shotgun"
31 |
32 | # Create files that should be ignored
33 | (shotgun_dir / ".DS_Store").write_bytes(b"\x00\x00\x00\x01Bud1")
34 | (shotgun_dir / "file.pyc").write_bytes(b"compiled python")
35 | (shotgun_dir / "file.swp").write_text("vim swap file")
36 | (shotgun_dir / "backup.bak").write_text("backup file")
37 | (shotgun_dir / "temp~").write_text("temp file")
38 |
39 | # Create __pycache__ directory with files
40 | pycache_dir = shotgun_dir / "__pycache__"
41 | pycache_dir.mkdir()
42 | (pycache_dir / "module.cpython-311.pyc").write_bytes(b"cached")
43 |
44 | # Create .vscode directory with files
45 | vscode_dir = shotgun_dir / ".vscode"
46 | vscode_dir.mkdir()
47 | (vscode_dir / "settings.json").write_text('{"key": "value"}')
48 |
49 | return temp_shotgun_dir
50 |
51 |
52 | @pytest.fixture
53 | def temp_file_for_hash(tmp_path: Path) -> Path:
54 | """Create a temporary file with known content for hash testing."""
55 | test_file = tmp_path / "test_file.txt"
56 | test_file.write_text("Hello, World!")
57 | return test_file
58 |
59 |
60 | @pytest.fixture
61 | def large_temp_file(tmp_path: Path) -> Path:
62 | """Create a larger temporary file (>10MB) for testing chunk size."""
63 | test_file = tmp_path / "large_file.bin"
64 | # Create a 15MB file
65 | with open(test_file, "wb") as f:
66 | f.write(b"x" * (15 * 1024 * 1024))
67 | return test_file
68 |
--------------------------------------------------------------------------------
/src/shotgun/shotgun_web/shared_specs/hasher.py:
--------------------------------------------------------------------------------
1 | """Async SHA-256 file hashing utilities."""
2 |
3 | import hashlib
4 | from pathlib import Path
5 |
6 | import aiofiles
7 |
8 | from shotgun.logging_config import get_logger
9 |
10 | logger = get_logger(__name__)
11 |
12 | # Chunk sizes for reading files
13 | SMALL_FILE_CHUNK_SIZE = 65536 # 64KB for files < 10MB
14 | LARGE_FILE_CHUNK_SIZE = 1048576 # 1MB for files >= 10MB
15 | LARGE_FILE_THRESHOLD = 10 * 1024 * 1024 # 10MB
16 |
17 |
18 | def _get_chunk_size(file_size: int) -> int:
19 | """Determine optimal chunk size based on file size.
20 |
21 | Args:
22 | file_size: Size of file in bytes
23 |
24 | Returns:
25 | Chunk size in bytes (64KB for small files, 1MB for large files)
26 | """
27 | if file_size >= LARGE_FILE_THRESHOLD:
28 | return LARGE_FILE_CHUNK_SIZE
29 | return SMALL_FILE_CHUNK_SIZE
30 |
31 |
32 | async def calculate_sha256(file_path: Path) -> str:
33 | """Calculate SHA-256 hash of a file using streaming.
34 |
35 | Reads file in chunks to avoid loading entire file into memory.
36 | Uses adaptive chunk sizing: 64KB for files <10MB, 1MB for larger files.
37 |
38 | Args:
39 | file_path: Path to file to hash
40 |
41 | Returns:
42 | Hex-encoded SHA-256 hash string (64 characters)
43 |
44 | Raises:
45 | FileNotFoundError: If file does not exist
46 | PermissionError: If file is not readable
47 | """
48 | file_size = file_path.stat().st_size
49 | chunk_size = _get_chunk_size(file_size)
50 |
51 | logger.debug(
52 | "Calculating SHA-256 for %s (size=%d, chunk_size=%d)",
53 | file_path,
54 | file_size,
55 | chunk_size,
56 | )
57 |
58 | sha256_hash = hashlib.sha256()
59 |
60 | async with aiofiles.open(file_path, "rb") as f:
61 | while chunk := await f.read(chunk_size):
62 | sha256_hash.update(chunk)
63 |
64 | hex_digest = sha256_hash.hexdigest()
65 | logger.debug("SHA-256 for %s: %s", file_path, hex_digest)
66 |
67 | return hex_digest
68 |
69 |
70 | async def calculate_sha256_with_size(file_path: Path) -> tuple[str, int]:
71 | """Calculate SHA-256 hash and get file size.
72 |
73 | Convenience function that returns both hash and size in one call.
74 |
75 | Args:
76 | file_path: Path to file to hash
77 |
78 | Returns:
79 | Tuple of (hex-encoded SHA-256 hash, file size in bytes)
80 | """
81 | file_size = file_path.stat().st_size
82 | content_hash = await calculate_sha256(file_path)
83 | return content_hash, file_size
84 |
--------------------------------------------------------------------------------
/src/shotgun/agents/tools/codebase/query_graph.py:
--------------------------------------------------------------------------------
1 | """Query codebase knowledge graph using natural language."""
2 |
3 | from pydantic_ai import RunContext
4 |
5 | from shotgun.agents.models import AgentDeps
6 | from shotgun.agents.tools.registry import ToolCategory, register_tool
7 | from shotgun.codebase.models import QueryType
8 | from shotgun.logging_config import get_logger
9 |
10 | from .models import QueryGraphResult
11 |
12 | logger = get_logger(__name__)
13 |
14 |
15 | @register_tool(
16 | category=ToolCategory.CODEBASE_UNDERSTANDING,
17 | display_text="Querying code",
18 | key_arg="query",
19 | )
20 | async def query_graph(
21 | ctx: RunContext[AgentDeps], graph_id: str, query: str
22 | ) -> QueryGraphResult:
23 | """Query codebase knowledge graph using natural language.
24 |
25 | Args:
26 | ctx: RunContext containing AgentDeps with codebase service
27 | graph_id: Graph ID to query (use the ID, not the name)
28 | query: Natural language question about the codebase
29 |
30 | Returns:
31 | QueryGraphResult with formatted output via __str__
32 | """
33 | logger.debug("🔧 Querying graph %s with query: %s", graph_id, query)
34 |
35 | try:
36 | if not ctx.deps.codebase_service:
37 | return QueryGraphResult(
38 | success=False,
39 | query=query,
40 | error="No codebase indexed",
41 | )
42 |
43 | # Execute natural language query
44 | result = await ctx.deps.codebase_service.execute_query(
45 | graph_id=graph_id,
46 | query=query,
47 | query_type=QueryType.NATURAL_LANGUAGE,
48 | )
49 |
50 | # Create QueryGraphResult from service result
51 | graph_result = QueryGraphResult(
52 | success=result.success,
53 | query=query,
54 | cypher_query=result.cypher_query,
55 | column_names=result.column_names,
56 | results=result.results,
57 | row_count=result.row_count,
58 | execution_time_ms=result.execution_time_ms,
59 | error=result.error,
60 | )
61 |
62 | logger.debug(
63 | "📄 Query completed: %s with %d results",
64 | "success" if graph_result.success else "failed",
65 | graph_result.row_count,
66 | )
67 |
68 | return graph_result
69 |
70 | except Exception as e:
71 | error_msg = f"Error querying graph: {str(e)}"
72 | logger.error("❌ Query graph failed: %s", str(e))
73 | return QueryGraphResult(success=False, query=query, error=error_msg)
74 |
--------------------------------------------------------------------------------
/src/shotgun/tui/protocols.py:
--------------------------------------------------------------------------------
1 | """Protocol definitions for TUI components.
2 |
3 | These protocols define interfaces that components can depend on without
4 | creating circular imports. Screens like ChatScreen can satisfy these
5 | protocols without explicitly implementing them.
6 | """
7 |
8 | from typing import Protocol, runtime_checkable
9 |
10 |
11 | @runtime_checkable
12 | class QAStateProvider(Protocol):
13 | """Protocol for screens that provide Q&A mode state.
14 |
15 | This protocol allows components to check if they're on a screen with
16 | Q&A mode without importing the concrete ChatScreen class, eliminating
17 | circular dependencies.
18 | """
19 |
20 | @property
21 | def qa_mode(self) -> bool:
22 | """Whether Q&A mode is currently active.
23 |
24 | Returns:
25 | True if Q&A mode is active, False otherwise.
26 | """
27 | ...
28 |
29 |
30 | @runtime_checkable
31 | class ProcessingStateProvider(Protocol):
32 | """Protocol for screens that provide processing state.
33 |
34 | This protocol allows components to check if they're on a screen with
35 | an active agent processing without importing the concrete ChatScreen class.
36 | """
37 |
38 | @property
39 | def working(self) -> bool:
40 | """Whether an agent is currently working.
41 |
42 | Returns:
43 | True if an agent is processing, False otherwise.
44 | """
45 | ...
46 |
47 |
48 | @runtime_checkable
49 | class RouterModeProvider(Protocol):
50 | """Protocol for screens that provide router mode state.
51 |
52 | This protocol allows components to check the current router mode
53 | (Planning or Drafting) without importing the concrete ChatScreen class.
54 | """
55 |
56 | @property
57 | def router_mode(self) -> str | None:
58 | """The current router mode.
59 |
60 | Returns:
61 | 'planning' or 'drafting' if in router mode, None otherwise.
62 | """
63 | ...
64 |
65 |
66 | @runtime_checkable
67 | class ActiveSubAgentProvider(Protocol):
68 | """Protocol for screens that provide active sub-agent state.
69 |
70 | This protocol allows components to check which sub-agent is currently
71 | executing during router delegation without importing ChatScreen.
72 | """
73 |
74 | @property
75 | def active_sub_agent(self) -> str | None:
76 | """The currently executing sub-agent type.
77 |
78 | Returns:
79 | The sub-agent type string (e.g., 'research', 'specify') if
80 | a sub-agent is executing, None if idle.
81 | """
82 | ...
83 |
--------------------------------------------------------------------------------
/src/shotgun/cli/specify.py:
--------------------------------------------------------------------------------
1 | """Specify command for shotgun CLI."""
2 |
3 | import asyncio
4 | from typing import Annotated
5 |
6 | import typer
7 |
8 | from shotgun.agents.config import ProviderType
9 | from shotgun.agents.models import AgentRuntimeOptions
10 | from shotgun.agents.specify import (
11 | create_specify_agent,
12 | run_specify_agent,
13 | )
14 | from shotgun.cli.error_handler import print_agent_error
15 | from shotgun.exceptions import ErrorNotPickedUpBySentry
16 | from shotgun.logging_config import get_logger
17 |
18 | app = typer.Typer(
19 | name="specify", help="Generate comprehensive specifications", no_args_is_help=True
20 | )
21 | logger = get_logger(__name__)
22 |
23 |
24 | @app.callback(invoke_without_command=True)
25 | def specify(
26 | requirement: Annotated[
27 | str, typer.Argument(help="Requirement or feature to specify")
28 | ],
29 | non_interactive: Annotated[
30 | bool,
31 | typer.Option(
32 | "--non-interactive", "-n", help="Disable user interaction (for CI/CD)"
33 | ),
34 | ] = False,
35 | provider: Annotated[
36 | ProviderType | None,
37 | typer.Option("--provider", "-p", help="AI provider to use (overrides default)"),
38 | ] = None,
39 | ) -> None:
40 | """Generate comprehensive specifications for software features and systems.
41 |
42 | This command creates detailed technical specifications including requirements,
43 | architecture, implementation details, and acceptance criteria based on your
44 | provided requirement or feature description.
45 | """
46 |
47 | logger.info("📝 Specification Requirement: %s", requirement)
48 |
49 | # Create agent dependencies
50 | agent_runtime_options = AgentRuntimeOptions(interactive_mode=not non_interactive)
51 |
52 | # Create the specify agent with deps and provider
53 | agent, deps = asyncio.run(create_specify_agent(agent_runtime_options, provider))
54 |
55 | # Start specification process with error handling
56 | logger.info("📋 Starting specification generation...")
57 |
58 | async def async_specify() -> None:
59 | try:
60 | result = await run_specify_agent(agent, requirement, deps)
61 | logger.info("✅ Specification Complete!")
62 | logger.info("📋 Results:")
63 | logger.info("%s", result.output)
64 | except ErrorNotPickedUpBySentry as e:
65 | print_agent_error(e)
66 | except Exception as e:
67 | logger.exception("Unexpected error in specify command")
68 | print(f"⚠️ An unexpected error occurred: {str(e)}")
69 |
70 | asyncio.run(async_specify())
71 |
--------------------------------------------------------------------------------
/test/unit/codebase/tools/test_retrieve_code.py:
--------------------------------------------------------------------------------
1 | """Tests for retrieve_code tool."""
2 |
3 | import importlib
4 | from unittest.mock import AsyncMock, patch
5 |
6 | import pytest
7 |
8 | from shotgun.agents.tools.codebase import CodeSnippetResult
9 | from shotgun.codebase.core.code_retrieval import CodeSnippet
10 |
11 | # Import the actual module, not the function
12 | retrieve_code_module = importlib.import_module(
13 | "shotgun.agents.tools.codebase.retrieve_code"
14 | )
15 |
16 |
17 | @pytest.mark.asyncio
18 | async def test_retrieve_code_success(mock_run_context, mock_codebase_service):
19 | """Test successful code retrieval."""
20 | # Mock the code retrieval function
21 | with patch.object(
22 | retrieve_code_module, "retrieve_code_by_qualified_name", new_callable=AsyncMock
23 | ) as mock_retrieve:
24 | code_snippet = CodeSnippet(
25 | qualified_name="test.module.Class",
26 | source_code="class TestClass:\n pass",
27 | file_path="test/module.py",
28 | line_start=1,
29 | line_end=2,
30 | found=True,
31 | docstring="Test class",
32 | )
33 | mock_retrieve.return_value = code_snippet
34 |
35 | # Execute
36 | result = await retrieve_code_module.retrieve_code(
37 | mock_run_context, "graph-id", "test.module.Class"
38 | )
39 |
40 | # Verify
41 | assert isinstance(result, CodeSnippetResult)
42 | assert result.found is True
43 | assert result.qualified_name == "test.module.Class"
44 | assert result.source_code == "class TestClass:\n pass"
45 | assert "```python" in str(result)
46 |
47 |
48 | @pytest.mark.asyncio
49 | async def test_retrieve_code_not_found(mock_run_context, mock_codebase_service):
50 | """Test code retrieval when entity not found."""
51 | with patch.object(
52 | retrieve_code_module, "retrieve_code_by_qualified_name", new_callable=AsyncMock
53 | ) as mock_retrieve:
54 | code_snippet = CodeSnippet(
55 | qualified_name="test.module.Missing",
56 | source_code="",
57 | file_path="",
58 | line_start=0,
59 | line_end=0,
60 | found=False,
61 | error_message="Entity not found",
62 | )
63 | mock_retrieve.return_value = code_snippet
64 |
65 | result = await retrieve_code_module.retrieve_code(
66 | mock_run_context, "graph-id", "test.module.Missing"
67 | )
68 |
69 | assert isinstance(result, CodeSnippetResult)
70 | assert result.found is False
71 | assert "Not Found" in str(result)
72 |
--------------------------------------------------------------------------------
/src/shotgun/prompts/history/incremental_summarization.j2:
--------------------------------------------------------------------------------
1 | You are extending an existing conversation summary with new messages.
2 |
3 | Your task is to merge the new information into the existing summary while maintaining the same structured format and preserving all important information.
4 |
5 |
6 | {{ existing_summary }}
7 |
8 |
9 |
10 | {{ new_messages }}
11 |
12 |
13 | Instructions:
14 | 1. Update the existing summary sections with new information from the NEW_MESSAGES
15 | 2. Maintain the same structure and format as the existing summary
16 | 3. Preserve all important information from both existing and new content
17 | 4. Do not repeat information already covered in the existing summary unless it's been updated or expanded
18 | 5. Focus on integrating new developments, decisions, or progress into the appropriate sections
19 |
20 |
21 | Provide the complete updated summary following the exact same format as the existing summary:
22 |
23 | # Context
24 |
25 | Updated summary of the discussion, incorporating new topics or tasks. Present it as clear bullet points. Be brief but do not lose information that might be important for the current state, task or objectives.
26 |
27 | # Key elements, learnings and entities
28 |
29 | Present the updated important elements of context, learnings and entities from the entire discussion. Be very detailed.
30 |
31 | Present it as clear bullet points. Be brief but do not lose information that might be important for the current state, task or objectives.
32 |
33 | If new tool results were obtained, preserve them verbatim if short, summarize if long focusing on preserving all important facts.
34 |
35 | If new user messages exist, preserve them verbatim if short to medium length, summarize if long focusing on preserving all important facts.
36 |
37 | If there are any new links, IDs, filenames, etc. - preserve them verbatim.
38 |
39 | # Timeline
40 |
41 | Update the timeline with new important tasks and content:
42 | - User: asked to handle a complex task X:
43 | - Assistant: we handle it by doing A, B, C...
44 | - Tools used and output summary
45 |
46 | [Include any new timeline entries from the new messages]
47 |
48 | # Current status, task and objectives
49 |
50 | Based on the complete conversation including new messages, update the current status, task and objectives.
51 |
52 | CRITICAL: This section should reflect the most current state based on all messages including the new ones. This is not about summarizing but about the status, task and objectives currently active in the conversation to keep as much context as possible.
53 |
--------------------------------------------------------------------------------
/src/shotgun/agents/conversation/history/token_counting/base.py:
--------------------------------------------------------------------------------
1 | """Base classes and shared utilities for token counting."""
2 |
3 | from abc import ABC, abstractmethod
4 |
5 | from pydantic_ai.messages import ModelMessage
6 |
7 |
8 | class TokenCounter(ABC):
9 | """Abstract base class for provider-specific token counting.
10 |
11 | All methods are async to support non-blocking operations like
12 | downloading tokenizer models or making API calls.
13 | """
14 |
15 | @abstractmethod
16 | async def count_tokens(self, text: str) -> int:
17 | """Count tokens in text using provider-specific method (async).
18 |
19 | Args:
20 | text: Text to count tokens for
21 |
22 | Returns:
23 | Exact token count as determined by the provider
24 |
25 | Raises:
26 | RuntimeError: If token counting fails
27 | """
28 |
29 | @abstractmethod
30 | async def count_message_tokens(self, messages: list[ModelMessage]) -> int:
31 | """Count tokens in PydanticAI message structures (async).
32 |
33 | Args:
34 | messages: List of messages to count tokens for
35 |
36 | Returns:
37 | Total token count across all messages
38 |
39 | Raises:
40 | RuntimeError: If token counting fails
41 | """
42 |
43 |
44 | def extract_text_from_messages(messages: list[ModelMessage]) -> str:
45 | """Extract all text content from messages for token counting.
46 |
47 | Args:
48 | messages: List of PydanticAI messages
49 |
50 | Returns:
51 | Combined text content from all messages
52 | """
53 | text_parts = []
54 |
55 | for message in messages:
56 | if hasattr(message, "parts"):
57 | for part in message.parts:
58 | if hasattr(part, "content") and isinstance(part.content, str):
59 | # Only add non-empty content
60 | if part.content.strip():
61 | text_parts.append(part.content)
62 | else:
63 | # Handle non-text parts (tool calls, etc.)
64 | part_str = str(part)
65 | if part_str.strip():
66 | text_parts.append(part_str)
67 | else:
68 | # Handle messages without parts
69 | msg_str = str(message)
70 | if msg_str.strip():
71 | text_parts.append(msg_str)
72 |
73 | # If no valid text parts found, return a minimal placeholder
74 | # This ensures we never send completely empty content to APIs
75 | if not text_parts:
76 | return "."
77 |
78 | return "\n".join(text_parts)
79 |
--------------------------------------------------------------------------------
/src/shotgun/prompts/codebase/cypher_system.j2:
--------------------------------------------------------------------------------
1 | You are an expert translator that converts natural language questions about code structure into precise Neo4j Cypher queries.
2 |
3 | {% include 'codebase/partials/graph_schema.j2' %}
4 |
5 | {% include 'codebase/partials/cypher_rules.j2' %}
6 |
7 | **3. Query Patterns & Examples**
8 | Your goal is to return appropriate properties for each node type. Common properties:
9 | - All nodes have: `name`
10 | - Nodes with paths: Module, Package, File, Folder (have `path` property)
11 | - Code entities: Class, Function, Method (have `qualified_name` but NO `path` - get path via Module relationship)
12 | - Always include a type indicator (either as a string literal or via CASE statement)
13 | - Do NOT include comments (// or /*) in your queries.
14 |
15 | **IMPORTANT: Handling Entity Names**
16 | - `name` property: Contains only the simple/short name (e.g., 'WebSocketServer', 'start')
17 | - `qualified_name` property: Contains the full qualified path (e.g., 'shotgun2.server.src.shotgun.api.websocket.server.WebSocketServer')
18 | - When users mention a specific class/function/method by name:
19 | - If it looks like a short name, use: `WHERE c.name = 'WebSocketServer'`
20 | - If it contains dots or looks like a full path, use: `WHERE c.qualified_name = 'full.path.to.Class'`
21 | - For partial paths, use: `WHERE c.qualified_name CONTAINS 'partial.path'` or `WHERE c.qualified_name ENDS WITH '.WebSocketServer'`
22 |
23 | {% include 'codebase/cypher_query_patterns.j2' %}
24 |
25 | {% include 'codebase/partials/temporal_context.j2' %}
26 |
27 | **6. Output Format**
28 | You must return a structured JSON response with the following fields:
29 | - `cypher_query`: The generated Cypher query string (or null if not possible)
30 | - `can_generate_valid_cypher`: Boolean indicating if a valid Cypher query can be generated
31 | - `reason_cannot_generate`: String explaining why generation isn't possible (or null if successful)
32 |
33 | **IMPORTANT:** Some queries cannot be expressed in Cypher:
34 | - Conceptual questions requiring interpretation (e.g., "What is the main purpose of this codebase?")
35 | - Questions about code quality or best practices
36 | - Questions requiring semantic understanding beyond structure
37 |
38 | For these, set `can_generate_valid_cypher` to false and provide a clear explanation in `reason_cannot_generate`.
39 |
40 | Examples:
41 | - Query: "Show all classes" → can_generate_valid_cypher: true, cypher_query: "MATCH (c:Class) RETURN c.name, c.qualified_name;"
42 | - Query: "What is the main purpose of this codebase?" → can_generate_valid_cypher: false, reason_cannot_generate: "This is a conceptual question requiring interpretation and analysis of the code's overall design and intent, rather than a structural query about specific code elements."
--------------------------------------------------------------------------------
/src/shotgun/cli/plan.py:
--------------------------------------------------------------------------------
1 | """Plan command for shotgun CLI."""
2 |
3 | import asyncio
4 | from typing import Annotated
5 |
6 | import typer
7 |
8 | from shotgun.agents.config import ProviderType
9 | from shotgun.agents.models import AgentRuntimeOptions
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
13 | from shotgun.logging_config import get_logger
14 | from shotgun.posthog_telemetry import track_event
15 |
16 | app = typer.Typer(name="plan", help="Generate structured plans", no_args_is_help=True)
17 | logger = get_logger(__name__)
18 |
19 |
20 | @app.callback(invoke_without_command=True)
21 | def plan(
22 | goal: Annotated[str, typer.Argument(help="Goal or objective to plan for")],
23 | non_interactive: Annotated[
24 | bool,
25 | typer.Option(
26 | "--non-interactive", "-n", help="Disable user interaction (for CI/CD)"
27 | ),
28 | ] = False,
29 | provider: Annotated[
30 | ProviderType | None,
31 | typer.Option("--provider", "-p", help="AI provider to use (overrides default)"),
32 | ] = None,
33 | ) -> None:
34 | """Generate a structured plan for achieving the given goal.
35 |
36 | This command will create detailed, actionable plans broken down into steps
37 | and milestones to help achieve your specified objective. It can also update
38 | existing plans based on new requirements or refinements.
39 | """
40 |
41 | logger.info("📋 Planning Goal: %s", goal)
42 |
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())
74 |
--------------------------------------------------------------------------------
/src/shotgun/tui/components/spinner.py:
--------------------------------------------------------------------------------
1 | """Spinner component for showing loading/working state."""
2 |
3 | from textual.app import ComposeResult
4 | from textual.containers import Container
5 | from textual.css.query import NoMatches
6 | from textual.reactive import reactive
7 | from textual.timer import Timer
8 | from textual.widget import Widget
9 | from textual.widgets import Static
10 |
11 |
12 | class Spinner(Widget):
13 | """A spinner widget that shows a rotating animation when working."""
14 |
15 | DEFAULT_CSS = """
16 | Spinner {
17 | width: auto;
18 | height: 1;
19 | }
20 |
21 | Spinner > Container {
22 | width: auto;
23 | height: 1;
24 | layout: horizontal;
25 | }
26 |
27 | Spinner .spinner-icon {
28 | width: 1;
29 | margin-right: 1;
30 | }
31 |
32 | Spinner .spinner-text {
33 | width: auto;
34 | }
35 | """
36 |
37 | # Animation frames for the spinner
38 | FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
39 |
40 | text = reactive("Working...")
41 | _frame_index = reactive(0)
42 |
43 | def __init__(
44 | self,
45 | text: str = "Working...",
46 | *,
47 | name: str | None = None,
48 | id: str | None = None,
49 | classes: str | None = None,
50 | ) -> None:
51 | super().__init__(name=name, id=id, classes=classes)
52 | self.text = text
53 | self._timer: Timer | None = None
54 |
55 | def compose(self) -> ComposeResult:
56 | """Compose the spinner widget."""
57 | with Container():
58 | yield Static("", classes="spinner-icon")
59 | yield Static(self.text, classes="spinner-text")
60 |
61 | def on_mount(self) -> None:
62 | """Set up the animation timer when mounted."""
63 | self._timer = self.set_interval(0.1, self._advance_frame)
64 |
65 | def _advance_frame(self) -> None:
66 | """Advance to the next animation frame."""
67 | self._frame_index = (self._frame_index + 1) % len(self.FRAMES)
68 | self._update_display()
69 |
70 | def _update_display(self) -> None:
71 | """Update the spinner display."""
72 | try:
73 | icon_widget = self.query_one(".spinner-icon", Static)
74 | icon_widget.update(self.FRAMES[self._frame_index])
75 | except NoMatches:
76 | # Widget not mounted yet, ignore
77 | pass
78 |
79 | def watch_text(self, text: str) -> None:
80 | """React to changes in the text."""
81 | try:
82 | text_widget = self.query_one(".spinner-text", Static)
83 | text_widget.update(text)
84 | except NoMatches:
85 | # Widget not mounted yet, ignore
86 | return
87 |
--------------------------------------------------------------------------------
/src/shotgun/tui/commands/__init__.py:
--------------------------------------------------------------------------------
1 | """Command handling for the TUI chat interface."""
2 |
3 | from collections.abc import Callable
4 |
5 |
6 | class CommandHandler:
7 | """Handles slash commands in the TUI chat interface."""
8 |
9 | def __init__(self) -> None:
10 | """Initialize the command handler with available commands."""
11 | self.commands: dict[str, Callable[[], str]] = {
12 | "help": self.get_help_text,
13 | }
14 |
15 | def is_command(self, text: str) -> bool:
16 | """Check if the text is a command (starts with /)."""
17 | return text.strip().startswith("/")
18 |
19 | def parse_command(self, text: str) -> str:
20 | """Extract the command name from the text."""
21 | text = text.strip()
22 | if not text.startswith("/"):
23 | return ""
24 |
25 | # Split on whitespace and get the command part
26 | parts = text[1:].split()
27 | return parts[0] if parts else ""
28 |
29 | def handle_command(self, text: str) -> tuple[bool, str]:
30 | """
31 | Handle a command and return success status and response text.
32 |
33 | Args:
34 | text: The full command text including the leading /
35 |
36 | Returns:
37 | Tuple of (success, response_text)
38 | """
39 | if not self.is_command(text):
40 | return False, ""
41 |
42 | command = self.parse_command(text)
43 |
44 | if command in self.commands:
45 | response = self.commands[command]()
46 | return True, response
47 | else:
48 | return False, self.get_error_message(command)
49 |
50 | def get_help_text(self) -> str:
51 | """Return the help text for the /help command."""
52 | return """📚 **Shotgun Help**
53 |
54 | **Commands:**
55 | • `/help` - Show this help message
56 |
57 | **Keyboard Shortcuts:**
58 |
59 | * `Enter` - Send message
60 | * `Ctrl+P` - Open command palette (for usage, context, and other commands)
61 | * `Shift+Tab` - Cycle agent modes
62 | * `Ctrl+C` - Quit application
63 |
64 | **Agent Modes:**
65 | * **Research** - Research topics with web search and synthesize findings
66 | * **Specify** - Create detailed specifications and requirements documents
67 | * **Planning** - Create comprehensive, actionable plans with milestones
68 | * **Tasks** - Generate specific, actionable tasks from research and plans
69 | * **Export** - Export artifacts and findings to various formats
70 |
71 | **Usage:**
72 | Type your message and press Enter to chat with the AI. The AI will respond based on the current mode."""
73 |
74 | def get_error_message(self, command: str) -> str:
75 | """Return a polite error message for unknown commands."""
76 | return f"⚠️ Sorry, `/{command}` is not a recognized command. Type `/help` to see available commands."
77 |
--------------------------------------------------------------------------------
/src/shotgun/tui/screens/chat_screen/history/agent_response.py:
--------------------------------------------------------------------------------
1 | """Agent response widget for chat history."""
2 |
3 | from pydantic_ai.messages import (
4 | BuiltinToolCallPart,
5 | BuiltinToolReturnPart,
6 | ModelResponse,
7 | TextPart,
8 | ThinkingPart,
9 | ToolCallPart,
10 | )
11 | from textual.app import ComposeResult
12 | from textual.widget import Widget
13 | from textual.widgets import Markdown
14 |
15 | from .formatters import ToolFormatter
16 |
17 |
18 | class AgentResponseWidget(Widget):
19 | """Widget that displays agent responses in the chat history."""
20 |
21 | def __init__(self, item: ModelResponse | None, is_sub_agent: bool = False) -> None:
22 | super().__init__()
23 | self.item = item
24 | self.is_sub_agent = is_sub_agent
25 |
26 | def compose(self) -> ComposeResult:
27 | self.display = self.item is not None
28 | if self.item is None:
29 | yield Markdown(markdown="")
30 | else:
31 | yield Markdown(markdown=self.compute_output())
32 |
33 | def compute_output(self) -> str:
34 | """Compute the markdown output for the agent response."""
35 | acc = ""
36 | if self.item is None:
37 | return ""
38 |
39 | # Use different prefix for sub-agent responses
40 | prefix = "**⏺** " if not self.is_sub_agent else " **↳** "
41 |
42 | for idx, part in enumerate(self.item.parts):
43 | if isinstance(part, TextPart):
44 | # Only show the prefix if there's actual content
45 | if part.content and part.content.strip():
46 | acc += f"{prefix}{part.content}\n\n"
47 | elif isinstance(part, ToolCallPart):
48 | parts_str = ToolFormatter.format_tool_call_part(part)
49 | if parts_str: # Only add if there's actual content
50 | acc += parts_str + "\n\n"
51 | elif isinstance(part, BuiltinToolCallPart):
52 | # Format builtin tool calls using registry
53 | formatted = ToolFormatter.format_builtin_tool_call(part)
54 | if formatted: # Only add if not hidden
55 | acc += formatted + "\n\n"
56 | elif isinstance(part, BuiltinToolReturnPart):
57 | # Don't show tool return parts in the UI
58 | pass
59 | elif isinstance(part, ThinkingPart):
60 | if (
61 | idx == len(self.item.parts) - 1
62 | ): # show the thinking part only if it's the last part
63 | acc += (
64 | f"thinking: {part.content}\n\n"
65 | if part.content
66 | else "Thinking..."
67 | )
68 | else:
69 | continue
70 | return acc.strip()
71 |
--------------------------------------------------------------------------------
/src/shotgun/cli/tasks.py:
--------------------------------------------------------------------------------
1 | """Tasks command for shotgun CLI."""
2 |
3 | import asyncio
4 | from typing import Annotated
5 |
6 | import typer
7 |
8 | from shotgun.agents.config import ProviderType
9 | from shotgun.agents.models import AgentRuntimeOptions
10 | from shotgun.agents.tasks import (
11 | create_tasks_agent,
12 | run_tasks_agent,
13 | )
14 | from shotgun.cli.error_handler import print_agent_error
15 | from shotgun.exceptions import ErrorNotPickedUpBySentry
16 | from shotgun.logging_config import get_logger
17 | from shotgun.posthog_telemetry import track_event
18 |
19 | app = typer.Typer(name="tasks", help="Generate task lists with agentic approach")
20 | logger = get_logger(__name__)
21 |
22 |
23 | @app.callback(invoke_without_command=True)
24 | def tasks(
25 | instruction: Annotated[
26 | str, typer.Argument(help="Task creation instruction or project description")
27 | ],
28 | non_interactive: Annotated[
29 | bool,
30 | typer.Option(
31 | "--non-interactive", "-n", help="Disable user interaction (for CI/CD)"
32 | ),
33 | ] = False,
34 | provider: Annotated[
35 | ProviderType | None,
36 | typer.Option("--provider", "-p", help="AI provider to use (overrides default)"),
37 | ] = None,
38 | ) -> None:
39 | """Generate actionable task lists based on existing research and plans.
40 |
41 | This command creates detailed task breakdowns using AI agents that analyze
42 | your research and plans to generate prioritized, actionable tasks with
43 | acceptance criteria and effort estimates.
44 | """
45 |
46 | logger.info("📋 Task Creation Instruction: %s", instruction)
47 |
48 | # Track tasks command usage
49 | track_event(
50 | "tasks_command",
51 | {
52 | "non_interactive": non_interactive,
53 | "provider": provider.value if provider else "default",
54 | },
55 | )
56 |
57 | # Create agent dependencies
58 | agent_runtime_options = AgentRuntimeOptions(interactive_mode=not non_interactive)
59 |
60 | # Create the tasks agent with deps and provider
61 | agent, deps = asyncio.run(create_tasks_agent(agent_runtime_options, provider))
62 |
63 | # Start task creation process with error handling
64 | logger.info("🎯 Starting task creation...")
65 |
66 | async def async_tasks() -> None:
67 | try:
68 | result = await run_tasks_agent(agent, instruction, deps)
69 | logger.info("✅ Task Creation Complete!")
70 | logger.info("📋 Results:")
71 | logger.info("%s", result.output)
72 | except ErrorNotPickedUpBySentry as e:
73 | print_agent_error(e)
74 | except Exception as e:
75 | logger.exception("Unexpected error in tasks command")
76 | print(f"⚠️ An unexpected error occurred: {str(e)}")
77 |
78 | asyncio.run(async_tasks())
79 |
--------------------------------------------------------------------------------
/src/shotgun/cli/spec/backup.py:
--------------------------------------------------------------------------------
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)
82 |
--------------------------------------------------------------------------------
/test/unit/codebase/test_parser_loader.py:
--------------------------------------------------------------------------------
1 | """Unit tests for parser_loader module."""
2 |
3 | from unittest.mock import patch
4 |
5 | from shotgun.codebase.core.parser_loader import load_parsers
6 |
7 |
8 | def test_load_parsers_returns_tuple():
9 | """Test that load_parsers returns a tuple structure."""
10 | result = load_parsers()
11 |
12 | assert isinstance(result, tuple)
13 | assert len(result) == 2
14 |
15 | parsers, queries = result
16 | assert isinstance(parsers, dict)
17 | assert isinstance(queries, dict)
18 |
19 |
20 | def test_load_parsers_empty_config_exits():
21 | """Test that empty language config causes exit."""
22 | with (
23 | patch("shotgun.codebase.core.parser_loader.LANGUAGE_CONFIGS", {}),
24 | patch("sys.exit") as mock_exit,
25 | ):
26 | load_parsers()
27 | mock_exit.assert_called_once_with(1)
28 |
29 |
30 | def test_load_parsers_handles_missing_tree_sitter_modules():
31 | """Test graceful handling when tree-sitter modules are missing."""
32 | # Mock missing all tree-sitter modules
33 | with (
34 | patch.dict(
35 | "sys.modules",
36 | {
37 | "tree_sitter_python": None,
38 | "tree_sitter_javascript": None,
39 | "tree_sitter_typescript": None,
40 | "tree_sitter_go": None,
41 | "tree_sitter_rust": None,
42 | "tree_sitter_languages": None,
43 | },
44 | clear=False,
45 | ),
46 | patch("sys.exit") as mock_exit,
47 | ):
48 | load_parsers()
49 | # Should exit when no parsers can be loaded
50 | mock_exit.assert_called_once_with(1)
51 |
52 |
53 | def test_load_parsers_with_real_config():
54 | """Test load_parsers with actual language configuration."""
55 | # This test uses the real implementation
56 | parsers, queries = load_parsers()
57 |
58 | # Should have loaded some languages
59 | assert isinstance(parsers, dict)
60 | assert isinstance(queries, dict)
61 |
62 | # If any parsers were loaded, they should be valid
63 | for lang, parser in parsers.items():
64 | assert lang is not None
65 | assert parser is not None
66 |
67 | # If any queries were loaded, they should be dictionaries
68 | for lang, lang_queries in queries.items():
69 | assert lang is not None
70 | assert isinstance(lang_queries, dict)
71 |
72 |
73 | def test_load_parsers_consistent_languages():
74 | """Test that parsers and queries have consistent language keys."""
75 | parsers, queries = load_parsers()
76 |
77 | # All languages in parsers should have corresponding queries
78 | for lang in parsers.keys():
79 | assert lang in queries
80 |
81 | # All languages in queries should have corresponding parsers
82 | for lang in queries.keys():
83 | assert lang in parsers
84 |
--------------------------------------------------------------------------------
/src/shotgun/utils/datetime_utils.py:
--------------------------------------------------------------------------------
1 | """Datetime utilities for consistent datetime formatting across the application."""
2 |
3 | from datetime import datetime
4 |
5 | from pydantic import BaseModel, Field
6 |
7 |
8 | class DateTimeContext(BaseModel):
9 | """Structured datetime context with timezone information.
10 |
11 | This model provides consistently formatted datetime information
12 | for use in prompts, templates, and UI display.
13 |
14 | Attributes:
15 | datetime_formatted: Human-readable datetime string
16 | timezone_name: Short timezone name (e.g., "PST", "UTC")
17 | utc_offset: UTC offset formatted with colon (e.g., "UTC-08:00")
18 |
19 | Example:
20 | >>> dt_context = get_datetime_context()
21 | >>> print(dt_context.datetime_formatted)
22 | 'Monday, January 13, 2025 at 3:45:30 PM'
23 | >>> print(dt_context.timezone_name)
24 | 'PST'
25 | >>> print(dt_context.utc_offset)
26 | 'UTC-08:00'
27 | """
28 |
29 | datetime_formatted: str = Field(
30 | description="Human-readable datetime string in format: 'Day, Month DD, YYYY at HH:MM:SS AM/PM'"
31 | )
32 | timezone_name: str = Field(description="Short timezone name (e.g., PST, EST, UTC)")
33 | utc_offset: str = Field(
34 | description="UTC offset formatted with colon (e.g., UTC-08:00, UTC+05:30)"
35 | )
36 |
37 |
38 | def get_datetime_context() -> DateTimeContext:
39 | """Get formatted datetime context with timezone information.
40 |
41 | Returns a Pydantic model containing consistently formatted datetime
42 | information suitable for use in prompts and templates.
43 |
44 | Returns:
45 | DateTimeContext: Structured datetime context with formatted strings
46 |
47 | Example:
48 | >>> dt_context = get_datetime_context()
49 | >>> dt_context.datetime_formatted
50 | 'Monday, January 13, 2025 at 3:45:30 PM'
51 | >>> dt_context.timezone_name
52 | 'PST'
53 | >>> dt_context.utc_offset
54 | 'UTC-08:00'
55 | """
56 | # Get current datetime with timezone information
57 | now = datetime.now().astimezone()
58 |
59 | # Format datetime in plain English
60 | # Example: "Monday, January 13, 2025 at 3:45:30 PM"
61 | datetime_formatted = now.strftime("%A, %B %d, %Y at %I:%M:%S %p")
62 |
63 | # Get timezone name and UTC offset
64 | # Example: "PST" and "UTC-08:00"
65 | timezone_name = now.strftime("%Z")
66 | utc_offset = now.strftime("%z") # Format: +0800 or -0500
67 |
68 | # Reformat UTC offset to include colon: +08:00 or -05:00
69 | utc_offset_formatted = (
70 | f"UTC{utc_offset[:3]}:{utc_offset[3:]}" if utc_offset else "UTC"
71 | )
72 |
73 | return DateTimeContext(
74 | datetime_formatted=datetime_formatted,
75 | timezone_name=timezone_name,
76 | utc_offset=utc_offset_formatted,
77 | )
78 |
--------------------------------------------------------------------------------
/evals/datasets/router_agent/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Router agent test case datasets.
3 |
4 | Contains test cases for evaluating Router agent behavior.
5 |
6 | Test case categories:
7 | - Clarifying questions: Tests Router asks questions for vague/ambiguous prompts
8 | - Planning: Tests Router creates appropriate plans for feature requests
9 | - Research planning: Tests Router plans research-first when adding features to codebase
10 | - Delegation routing: Tests Router correctly delegates to each agent for their files
11 |
12 | Exports:
13 | - CLARIFYING_QUESTIONS_CASES: List of clarifying questions test cases
14 | - PLANNING_CASES: List of planning behavior test cases
15 | - RESEARCH_PLANNING_CASES: List of research-first planning test cases
16 | - DELEGATION_ROUTING_CASES: List of delegation routing test cases
17 | - ALL_ROUTER_CASES: Dict mapping test case names to test case objects
18 | """
19 |
20 | from evals.datasets.router_agent.clarifying_questions_cases import (
21 | CACHE_REQUEST_ASKS_QUESTIONS,
22 | CLARIFYING_QUESTIONS_CASES,
23 | OPEN_SOURCE_MODELS_ASKS_QUESTIONS,
24 | PERFORMANCE_REQUEST_ASKS_QUESTIONS,
25 | VAGUE_PROMPT_CLARIFYING_QUESTIONS,
26 | )
27 | from evals.datasets.router_agent.delegation_routing_cases import (
28 | DELEGATION_ROUTING_CASES,
29 | MULTI_FILE_UPDATE_DELEGATES_SEPARATELY,
30 | )
31 | from evals.datasets.router_agent.planning_cases import (
32 | COMPLEX_FEATURE_ASKS_QUESTIONS,
33 | FEATURE_REQUEST_ASKS_QUESTIONS,
34 | PLANNING_CASES,
35 | SPECIFIC_FEATURE_CREATES_PLAN,
36 | )
37 | from evals.datasets.router_agent.research_planning_cases import (
38 | AUTH_FEATURE_PLANS_RESEARCH_FIRST,
39 | CACHE_FEATURE_PLANS_RESEARCH_FIRST,
40 | OLLAMA_FEATURE_PLANS_RESEARCH_FIRST,
41 | RESEARCH_PLANNING_CASES,
42 | )
43 |
44 | # Index of all Router test cases by name for discovery
45 | ALL_ROUTER_CASES = {
46 | case.name: case
47 | for case in (
48 | CLARIFYING_QUESTIONS_CASES
49 | + PLANNING_CASES
50 | + RESEARCH_PLANNING_CASES
51 | + DELEGATION_ROUTING_CASES
52 | )
53 | }
54 |
55 | __all__ = [
56 | # Collections
57 | "CLARIFYING_QUESTIONS_CASES",
58 | "PLANNING_CASES",
59 | "RESEARCH_PLANNING_CASES",
60 | "DELEGATION_ROUTING_CASES",
61 | "ALL_ROUTER_CASES",
62 | # Clarifying questions test cases
63 | "VAGUE_PROMPT_CLARIFYING_QUESTIONS",
64 | "PERFORMANCE_REQUEST_ASKS_QUESTIONS",
65 | "CACHE_REQUEST_ASKS_QUESTIONS",
66 | "OPEN_SOURCE_MODELS_ASKS_QUESTIONS",
67 | # Planning test cases
68 | "FEATURE_REQUEST_ASKS_QUESTIONS",
69 | "COMPLEX_FEATURE_ASKS_QUESTIONS",
70 | "SPECIFIC_FEATURE_CREATES_PLAN",
71 | # Research planning test cases
72 | "OLLAMA_FEATURE_PLANS_RESEARCH_FIRST",
73 | "AUTH_FEATURE_PLANS_RESEARCH_FIRST",
74 | "CACHE_FEATURE_PLANS_RESEARCH_FIRST",
75 | # Delegation routing test cases
76 | "MULTI_FILE_UPDATE_DELEGATES_SEPARATELY",
77 | ]
78 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Use Python 3.13 slim as base image
2 | FROM python:3.13-slim
3 |
4 | # Build arguments for telemetry configuration
5 | # These are used during the build process to embed analytics keys
6 | ARG SHOTGUN_SENTRY_DSN=""
7 | ARG SHOTGUN_POSTHOG_API_KEY=""
8 | ARG SHOTGUN_POSTHOG_PROJECT_ID=""
9 | ARG SHOTGUN_LOGFIRE_ENABLED=""
10 | ARG SHOTGUN_LOGFIRE_TOKEN=""
11 | ARG SHOTGUN_BUILD_REQUIRE_VALIDATION=""
12 |
13 | # OCI annotations for package metadata
14 | LABEL org.opencontainers.image.title="Shotgun" \
15 | org.opencontainers.image.description="AI-powered CLI tool for research, planning, and task management. Always use :latest for production - see README for details." \
16 | org.opencontainers.image.source="https://github.com/shotgun-sh/shotgun" \
17 | org.opencontainers.image.licenses="MIT" \
18 | org.opencontainers.image.vendor="Shotgun" \
19 | org.opencontainers.image.url="https://shotgun.sh"
20 |
21 | # Set environment variables
22 | ENV PYTHONUNBUFFERED=1 \
23 | PYTHONDONTWRITEBYTECODE=1 \
24 | UV_SYSTEM_PYTHON=1
25 |
26 | # Install uv for dependency management
27 | RUN pip install --no-cache-dir uv
28 |
29 | # Create non-root user
30 | RUN useradd -m -u 1000 shotgun
31 |
32 | # Set working directory for build
33 | WORKDIR /app
34 |
35 | # Copy project files
36 | COPY --chown=shotgun:shotgun pyproject.toml uv.lock hatch_build.py ./
37 | COPY --chown=shotgun:shotgun src/ ./src/
38 | COPY --chown=shotgun:shotgun README.md LICENSE ./
39 | COPY --chown=shotgun:shotgun docs/README_DOCKER.md ./docs/
40 |
41 | # Install dependencies
42 | # Pass build args as environment variables for the build hook
43 | RUN SHOTGUN_SENTRY_DSN="${SHOTGUN_SENTRY_DSN}" \
44 | SHOTGUN_POSTHOG_API_KEY="${SHOTGUN_POSTHOG_API_KEY}" \
45 | SHOTGUN_POSTHOG_PROJECT_ID="${SHOTGUN_POSTHOG_PROJECT_ID}" \
46 | SHOTGUN_LOGFIRE_ENABLED="${SHOTGUN_LOGFIRE_ENABLED}" \
47 | SHOTGUN_LOGFIRE_TOKEN="${SHOTGUN_LOGFIRE_TOKEN}" \
48 | SHOTGUN_BUILD_REQUIRE_VALIDATION="${SHOTGUN_BUILD_REQUIRE_VALIDATION}" \
49 | uv sync --frozen --no-dev
50 |
51 | # Create directories for workspace and config
52 | RUN mkdir -p /workspace /home/shotgun/.shotgun-sh && \
53 | chown -R shotgun:shotgun /workspace /home/shotgun/.shotgun-sh
54 |
55 | # Switch to non-root user
56 | USER shotgun
57 |
58 | # Set working directory to workspace
59 | WORKDIR /workspace
60 |
61 | # Expose default web server port
62 | EXPOSE 8000
63 |
64 | # Health check for web server
65 | HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
66 | CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000').read()" || exit 1
67 |
68 | # Add .venv/bin to PATH so shotgun command is available
69 | ENV PATH="/app/.venv/bin:$PATH"
70 |
71 | # Entry point - run shotgun in web mode with force-reindex for Docker
72 | ENTRYPOINT ["shotgun", "--web", "--host", "0.0.0.0", "--force-reindex"]
73 |
74 | # Default port (can be overridden)
75 | CMD ["--port", "8000"]
76 |
--------------------------------------------------------------------------------
/src/shotgun/cli/export.py:
--------------------------------------------------------------------------------
1 | """Export command for shotgun CLI."""
2 |
3 | import asyncio
4 | from typing import Annotated
5 |
6 | import typer
7 |
8 | from shotgun.agents.config import ProviderType
9 | from shotgun.agents.export import (
10 | create_export_agent,
11 | run_export_agent,
12 | )
13 | from shotgun.agents.models import AgentRuntimeOptions
14 | from shotgun.cli.error_handler import print_agent_error
15 | from shotgun.exceptions import ErrorNotPickedUpBySentry
16 | from shotgun.logging_config import get_logger
17 | from shotgun.posthog_telemetry import track_event
18 |
19 | app = typer.Typer(
20 | name="export", help="Export artifacts to various formats with agentic approach"
21 | )
22 | logger = get_logger(__name__)
23 |
24 |
25 | @app.callback(invoke_without_command=True)
26 | def export(
27 | instruction: Annotated[
28 | str, typer.Argument(help="Export instruction or format specification")
29 | ],
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 | """Export artifacts and findings to various formats.
42 |
43 | This command exports research, plans, tasks, and other project artifacts
44 | to different formats like Markdown, HTML, JSON, CSV, or project management
45 | tool formats. The AI agent will analyze available content and transform
46 | it according to your export requirements.
47 | """
48 |
49 | logger.info("📤 Export Instruction: %s", instruction)
50 |
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())
82 |
--------------------------------------------------------------------------------
/src/shotgun/agents/conversation/history/token_counting/openai.py:
--------------------------------------------------------------------------------
1 | """OpenAI token counting using tiktoken."""
2 |
3 | from pydantic_ai.messages import ModelMessage
4 |
5 | from shotgun.logging_config import get_logger
6 |
7 | from .base import TokenCounter, extract_text_from_messages
8 |
9 | logger = get_logger(__name__)
10 |
11 |
12 | class OpenAITokenCounter(TokenCounter):
13 | """Token counter for OpenAI models using tiktoken."""
14 |
15 | # Official encoding mappings for OpenAI models
16 | ENCODING_MAP = {
17 | "gpt-5": "o200k_base",
18 | "gpt-4o": "o200k_base",
19 | "gpt-4": "cl100k_base",
20 | "gpt-3.5-turbo": "cl100k_base",
21 | }
22 |
23 | def __init__(self, model_name: str):
24 | """Initialize OpenAI token counter.
25 |
26 | Args:
27 | model_name: OpenAI model name to get correct encoding for
28 |
29 | Raises:
30 | RuntimeError: If encoding initialization fails
31 | """
32 | self.model_name = model_name
33 |
34 | import tiktoken
35 |
36 | try:
37 | # Get the appropriate encoding for this model
38 | encoding_name = self.ENCODING_MAP.get(model_name, "o200k_base")
39 | self.encoding = tiktoken.get_encoding(encoding_name)
40 | logger.debug(
41 | f"Initialized OpenAI token counter with {encoding_name} encoding"
42 | )
43 | except Exception as e:
44 | raise RuntimeError(
45 | f"Failed to initialize tiktoken encoding for {model_name}"
46 | ) from e
47 |
48 | async def count_tokens(self, text: str) -> int:
49 | """Count tokens using tiktoken (async).
50 |
51 | Args:
52 | text: Text to count tokens for
53 |
54 | Returns:
55 | Exact token count using tiktoken
56 |
57 | Raises:
58 | RuntimeError: If token counting fails
59 | """
60 | # Handle empty text to avoid unnecessary encoding
61 | if not text or not text.strip():
62 | return 0
63 |
64 | try:
65 | return len(self.encoding.encode(text))
66 | except BaseException as e:
67 | # Must catch BaseException to handle PanicException from tiktoken's Rust layer
68 | # which can occur with extremely long texts. Regular Exception won't catch it.
69 | raise RuntimeError(
70 | f"Failed to count tokens for OpenAI model {self.model_name}"
71 | ) from e
72 |
73 | async def count_message_tokens(self, messages: list[ModelMessage]) -> int:
74 | """Count tokens across all messages using tiktoken (async).
75 |
76 | Args:
77 | messages: List of PydanticAI messages
78 |
79 | Returns:
80 | Total token count for all messages
81 |
82 | Raises:
83 | RuntimeError: If token counting fails
84 | """
85 | # Handle empty message list early
86 | if not messages:
87 | return 0
88 |
89 | total_text = extract_text_from_messages(messages)
90 | return await self.count_tokens(total_text)
91 |
--------------------------------------------------------------------------------
/docs/README_DOCKER.md:
--------------------------------------------------------------------------------
1 | # Shotgun Docker Image
2 |
3 | AI-powered CLI tool for research, planning, and task management.
4 |
5 | ## ⚠️ Important: Use the Stable Release
6 |
7 | **Always use the `latest` tag for production use:**
8 |
9 | ```bash
10 | docker pull ghcr.io/shotgun-sh/shotgun:latest
11 | ```
12 |
13 | ### Why NOT to use `:dev`
14 |
15 | **DO NOT use the `:dev` tag unless you are a developer testing new features.**
16 |
17 | Development versions:
18 | - ❌ Have telemetry and logging built-in that you probably don't want
19 | - ❌ Never auto-update (you'll be stuck on that version)
20 | - ❌ Are for internal testing only
21 |
22 | Production versions (`:latest` and version tags like `:v0.1.0`) are secure and only log anonymous event metadata - no user data is collected.
23 |
24 | ## Quick Start
25 |
26 | **Important:** Always run the container from your code directory so Shotgun can analyze your codebase.
27 |
28 | ```bash
29 | cd /path/to/your/project
30 |
31 | docker run -p 8000:8000 \
32 | -v $(pwd):/workspace \
33 | -v ~/.shotgun-sh:/home/shotgun/.shotgun-sh \
34 | ghcr.io/shotgun-sh/shotgun:latest
35 | ```
36 |
37 | Access the web interface at **http://localhost:8000**
38 |
39 | ### What this does:
40 |
41 | - Maps port 8000 from the container to your host
42 | - Mounts your current directory as `/workspace` inside the container
43 | - Mounts your Shotgun config directory to persist API keys and settings
44 | - Automatically prompts you to index the codebase on startup
45 |
46 | ## Available Tags
47 |
48 | - **`latest`** - ✅ **Recommended** - Latest stable release (secure, minimal telemetry)
49 | - **`v0.1.0`** - Specific version tags (for pinning to a particular version)
50 | - **`dev`** - ❌ **Not recommended** - Development version with full telemetry (for developers only)
51 |
52 | ## Custom Port
53 |
54 | To use a different port:
55 |
56 | ```bash
57 | docker run -p 3000:3000 \
58 | -v $(pwd):/workspace \
59 | -v ~/.shotgun-sh:/home/shotgun/.shotgun-sh \
60 | ghcr.io/shotgun-sh/shotgun:latest --port 3000
61 | ```
62 |
63 | ## Background Mode
64 |
65 | Run in the background with auto-restart:
66 |
67 | ```bash
68 | docker run -d --restart unless-stopped \
69 | --name shotgun-web \
70 | -p 8000:8000 \
71 | -v $(pwd):/workspace \
72 | -v ~/.shotgun-sh:/home/shotgun/.shotgun-sh \
73 | ghcr.io/shotgun-sh/shotgun:latest
74 | ```
75 |
76 | Stop the container with:
77 | ```bash
78 | docker stop shotgun-web
79 | docker rm shotgun-web
80 | ```
81 |
82 | ## Configuration
83 |
84 | On first run, configure your API keys through the web UI at http://localhost:8000. The configuration will persist in the mounted `~/.shotgun-sh` directory.
85 |
86 | ## Full Documentation
87 |
88 | For complete Docker documentation including troubleshooting, advanced usage, and deployment guides, see [docs/DOCKER.md](docs/DOCKER.md).
89 |
90 | For general Shotgun documentation, installation options, and development setup, see the [main README](https://github.com/shotgun-sh/shotgun#readme).
91 |
92 | ## Support
93 |
94 | Join our [Discord community](https://discord.gg/5RmY6J2N7s) for help and discussion.
95 |
96 | ---
97 |
98 | **License:** MIT | **Python:** 3.11+ | **Homepage:** [shotgun.sh](https://shotgun.sh/)
99 |
--------------------------------------------------------------------------------
/test/unit/agents/test_research_system_prompt.py:
--------------------------------------------------------------------------------
1 | """Test research agent system prompt rendering."""
2 |
3 | from unittest.mock import MagicMock
4 |
5 | import pytest
6 |
7 | from shotgun.agents.common import build_agent_system_prompt
8 | from shotgun.agents.models import AgentDeps
9 |
10 |
11 | @pytest.fixture
12 | def mock_context():
13 | """Create mock RunContext for testing."""
14 | context = MagicMock()
15 | context.deps = MagicMock(spec=AgentDeps)
16 | context.deps.interactive_mode = True
17 | context.deps.sub_agent_context = None
18 | return context
19 |
20 |
21 | def test_research_system_prompt_renders_correctly(mock_context):
22 | """Test that the research agent system prompt renders without errors."""
23 | result = build_agent_system_prompt("research", mock_context)
24 |
25 | # Verify the result is a non-empty string
26 | assert isinstance(result, str)
27 | assert len(result) > 0
28 |
29 | # Verify key content is present
30 | assert "Research Assistant" in result
31 | # The prompt should contain research-specific instructions
32 | assert "research" in result.lower()
33 | assert "artifact" in result.lower()
34 |
35 |
36 | def test_research_system_prompt_with_interactive_mode_variations(mock_context):
37 | """Test system prompt rendering with different interactive_mode values."""
38 | # Test with interactive_mode=True
39 | mock_context.deps.interactive_mode = True
40 | result_interactive = build_agent_system_prompt("research", mock_context)
41 | assert isinstance(result_interactive, str)
42 | assert len(result_interactive) > 0
43 |
44 | # Test with interactive_mode=False
45 | mock_context.deps.interactive_mode = False
46 | result_non_interactive = build_agent_system_prompt("research", mock_context)
47 | assert isinstance(result_non_interactive, str)
48 | assert len(result_non_interactive) > 0
49 |
50 | # Both should render successfully
51 | assert (
52 | result_interactive != result_non_interactive
53 | ) # Should be different due to interactive_mode
54 |
55 |
56 | def test_research_system_prompt_template_loading():
57 | """Test that the system prompt function loads the correct template."""
58 | from unittest.mock import patch
59 |
60 | mock_context = MagicMock()
61 | # Use spec=AgentDeps to prevent auto-creation of router_mode attribute
62 | mock_context.deps = MagicMock(spec=AgentDeps)
63 | mock_context.deps.interactive_mode = True
64 | mock_context.deps.sub_agent_context = None
65 |
66 | with patch("shotgun.agents.common.PromptLoader") as mock_loader_class:
67 | mock_loader = MagicMock()
68 | mock_loader_class.return_value = mock_loader
69 | mock_loader.render.return_value = "Test system prompt"
70 |
71 | result = build_agent_system_prompt("research", mock_context)
72 |
73 | # Verify render was called with correct parameters
74 | mock_loader.render.assert_called_once_with(
75 | "agents/research.j2",
76 | interactive_mode=True,
77 | mode="research",
78 | sub_agent_context=None,
79 | router_mode=None,
80 | )
81 | assert result == "Test system prompt"
82 |
--------------------------------------------------------------------------------
/src/shotgun/telemetry.py:
--------------------------------------------------------------------------------
1 | """Observability setup for Logfire."""
2 |
3 | from shotgun.logging_config import get_early_logger
4 | from shotgun.settings import settings
5 |
6 | # Use early logger to prevent automatic StreamHandler creation
7 | logger = get_early_logger(__name__)
8 |
9 |
10 | def setup_logfire_observability() -> bool:
11 | """Set up Logfire observability if enabled.
12 |
13 | Returns:
14 | True if Logfire was successfully set up, False otherwise
15 | """
16 | # Get Logfire configuration from settings (handles build constants + env vars)
17 | logfire_enabled = settings.telemetry.logfire_enabled
18 | logfire_token = settings.telemetry.logfire_token
19 |
20 | # Check if Logfire observability is enabled
21 | if not logfire_enabled:
22 | logger.debug("Logfire observability disabled")
23 | return False
24 |
25 | try:
26 | import logfire
27 |
28 | # Check for Logfire token
29 | if not logfire_token:
30 | logger.warning("Logfire token not set, Logfire observability disabled")
31 | return False
32 |
33 | # Configure Logfire
34 | # Always disable console output - we only want telemetry sent to the web service
35 | logfire.configure(
36 | token=logfire_token,
37 | console=False, # Never output to console, only send to Logfire service
38 | )
39 |
40 | # Instrument Pydantic AI for better observability
41 | logfire.instrument_pydantic_ai()
42 |
43 | # Add LogfireLoggingHandler to root logger so logfire logs also go to file
44 | import logging
45 |
46 | root_logger = logging.getLogger()
47 | logfire_handler = logfire.LogfireLoggingHandler()
48 | root_logger.addHandler(logfire_handler)
49 | logger.debug("Added LogfireLoggingHandler to root logger for file integration")
50 |
51 | # Set user context using baggage for all logs and spans
52 | try:
53 | import asyncio
54 |
55 | from opentelemetry import baggage, context
56 |
57 | from shotgun.agents.config import get_config_manager
58 |
59 | config_manager = get_config_manager()
60 | shotgun_instance_id = asyncio.run(config_manager.get_shotgun_instance_id())
61 |
62 | # Set shotgun_instance_id as baggage in global context - this will be included in all logs/spans
63 | ctx = baggage.set_baggage("shotgun_instance_id", shotgun_instance_id)
64 | context.attach(ctx)
65 | logger.debug(
66 | "Logfire user context set with shotgun_instance_id: %s",
67 | shotgun_instance_id,
68 | )
69 | except Exception as e:
70 | logger.warning("Failed to set Logfire user context: %s", e)
71 |
72 | logger.debug("Logfire observability configured successfully")
73 | logger.debug("Token configured: %s", "Yes" if logfire_token else "No")
74 | return True
75 |
76 | except ImportError as e:
77 | logger.warning("Logfire not available: %s", e)
78 | return False
79 | except Exception as e:
80 | logger.warning("Failed to setup Logfire observability: %s", e)
81 | return False
82 |
--------------------------------------------------------------------------------
/evals/datasets/router_agent/clarifying_questions_cases.py:
--------------------------------------------------------------------------------
1 | """
2 | Router agent test cases for clarifying questions behavior.
3 |
4 | Tests that the Router asks clarifying questions when given vague/ambiguous prompts
5 | before taking action or delegating.
6 | """
7 |
8 | from evals.models import (
9 | AgentType,
10 | ExpectedAgentOutput,
11 | ShotgunTestCase,
12 | TestCaseContext,
13 | TestCaseInput,
14 | )
15 |
16 | # Plan tools that should NOT be called when asking clarifying questions
17 | PLAN_TOOLS = ["create_plan", "edit_plan", "update_plan", "append_plan"]
18 |
19 | VAGUE_PROMPT_CLARIFYING_QUESTIONS = ShotgunTestCase(
20 | name="vague_prompt_clarifying_questions",
21 | inputs=TestCaseInput(
22 | prompt="I want to add a new feature to this project",
23 | agent_type=AgentType.ROUTER,
24 | context=TestCaseContext(has_codebase_indexed=False),
25 | ),
26 | expected=ExpectedAgentOutput(
27 | min_clarifying_questions=1,
28 | disallowed_tools=PLAN_TOOLS,
29 | expected_response="Questions should be high-level and help understand what feature the user wants to build",
30 | ),
31 | )
32 |
33 | PERFORMANCE_REQUEST_ASKS_QUESTIONS = ShotgunTestCase(
34 | name="performance_request_asks_questions",
35 | inputs=TestCaseInput(
36 | prompt="This app is slow, make it faster",
37 | agent_type=AgentType.ROUTER,
38 | context=TestCaseContext(has_codebase_indexed=True, codebase_name="shotgun"),
39 | ),
40 | expected=ExpectedAgentOutput(
41 | min_clarifying_questions=1,
42 | disallowed_tools=PLAN_TOOLS,
43 | expected_response="Questions should ask about where the slowness is observed, what operations are slow, and what performance targets are expected",
44 | ),
45 | )
46 |
47 | CACHE_REQUEST_ASKS_QUESTIONS = ShotgunTestCase(
48 | name="cache_request_asks_questions",
49 | inputs=TestCaseInput(
50 | prompt="Add a cache",
51 | agent_type=AgentType.ROUTER,
52 | context=TestCaseContext(has_codebase_indexed=True, codebase_name="shotgun"),
53 | ),
54 | expected=ExpectedAgentOutput(
55 | min_clarifying_questions=1,
56 | disallowed_tools=PLAN_TOOLS,
57 | expected_response="Questions should ask about what to cache, cache backend preferences, TTL requirements, and invalidation strategy",
58 | ),
59 | )
60 |
61 | OPEN_SOURCE_MODELS_ASKS_QUESTIONS = ShotgunTestCase(
62 | name="open_source_models_asks_questions",
63 | inputs=TestCaseInput(
64 | prompt="I want to write a spec to add support for open source models for this project",
65 | agent_type=AgentType.ROUTER,
66 | context=TestCaseContext(has_codebase_indexed=True, codebase_name="shotgun"),
67 | ),
68 | expected=ExpectedAgentOutput(
69 | min_clarifying_questions=1,
70 | disallowed_tools=PLAN_TOOLS,
71 | expected_response="Questions should ask about which open source models, what inference backend (Ollama, vLLM, etc), and integration requirements",
72 | ),
73 | )
74 |
75 | CLARIFYING_QUESTIONS_CASES: list[ShotgunTestCase] = [
76 | VAGUE_PROMPT_CLARIFYING_QUESTIONS,
77 | PERFORMANCE_REQUEST_ASKS_QUESTIONS,
78 | CACHE_REQUEST_ASKS_QUESTIONS,
79 | OPEN_SOURCE_MODELS_ASKS_QUESTIONS,
80 | ]
81 |
--------------------------------------------------------------------------------
/src/shotgun/agents/conversation/history/message_utils.py:
--------------------------------------------------------------------------------
1 | """Utility functions for working with PydanticAI messages."""
2 |
3 | from pydantic_ai.messages import (
4 | ModelMessage,
5 | ModelRequest,
6 | SystemPromptPart,
7 | UserPromptPart,
8 | )
9 |
10 | from shotgun.agents.messages import AgentSystemPrompt, SystemStatusPrompt
11 |
12 |
13 | def get_first_user_request(messages: list[ModelMessage]) -> str | None:
14 | """Extract first user request content from messages."""
15 | for msg in messages:
16 | if isinstance(msg, ModelRequest):
17 | for part in msg.parts:
18 | if isinstance(part, UserPromptPart) and isinstance(part.content, str):
19 | return part.content
20 | return None
21 |
22 |
23 | def get_last_user_request(messages: list[ModelMessage]) -> ModelRequest | None:
24 | """Extract the last user request from messages."""
25 | for msg in reversed(messages):
26 | if isinstance(msg, ModelRequest):
27 | for part in msg.parts:
28 | if isinstance(part, UserPromptPart):
29 | return msg
30 | return None
31 |
32 |
33 | def get_user_content_from_request(request: ModelRequest) -> str | None:
34 | """Extract user prompt content from a ModelRequest."""
35 | for part in request.parts:
36 | if isinstance(part, UserPromptPart) and isinstance(part.content, str):
37 | return part.content
38 | return None
39 |
40 |
41 | def get_system_prompt(messages: list[ModelMessage]) -> str | None:
42 | """Extract system prompt from messages (any SystemPromptPart)."""
43 | for msg in messages:
44 | if isinstance(msg, ModelRequest):
45 | for part in msg.parts:
46 | if isinstance(part, SystemPromptPart):
47 | return part.content
48 | return None
49 |
50 |
51 | def get_agent_system_prompt(messages: list[ModelMessage]) -> str | None:
52 | """Extract the main agent system prompt from messages.
53 |
54 | Prioritizes AgentSystemPrompt but falls back to generic SystemPromptPart
55 | if no AgentSystemPrompt is found.
56 | """
57 | # First try to find AgentSystemPrompt
58 | for msg in messages:
59 | if isinstance(msg, ModelRequest):
60 | for part in msg.parts:
61 | if isinstance(part, AgentSystemPrompt):
62 | return part.content
63 |
64 | # Fall back to any SystemPromptPart (excluding SystemStatusPrompt)
65 | for msg in messages:
66 | if isinstance(msg, ModelRequest):
67 | for part in msg.parts:
68 | if isinstance(part, SystemPromptPart) and not isinstance(
69 | part, SystemStatusPrompt
70 | ):
71 | return part.content
72 |
73 | return None
74 |
75 |
76 | def get_latest_system_status(messages: list[ModelMessage]) -> str | None:
77 | """Extract the most recent system status prompt from messages."""
78 | # Iterate in reverse to find the most recent status
79 | for msg in reversed(messages):
80 | if isinstance(msg, ModelRequest):
81 | for part in msg.parts:
82 | if isinstance(part, SystemStatusPrompt):
83 | return part.content
84 | return None
85 |
--------------------------------------------------------------------------------