Repository: geekforbrains/sidekick-cli Branch: develop Commit: 82c6958176b6 Files: 93 Total size: 223.0 KB Directory structure: gitextract_hyq1ej6a/ ├── .flake8 ├── .github/ │ └── workflows/ │ └── release.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── pyproject.toml ├── pytest.ini ├── src/ │ └── sidekick/ │ ├── __init__.py │ ├── agent.py │ ├── commands/ │ │ ├── __init__.py │ │ ├── clear.py │ │ ├── dump.py │ │ ├── help.py │ │ ├── model.py │ │ ├── usage.py │ │ └── yolo.py │ ├── config.py │ ├── constants.py │ ├── deps.py │ ├── main.py │ ├── mcp/ │ │ ├── __init__.py │ │ ├── agent.py │ │ └── servers.py │ ├── messages.py │ ├── prompts/ │ │ └── system.txt │ ├── repl.py │ ├── session.py │ ├── setup.py │ ├── tools/ │ │ ├── __init__.py │ │ ├── common.py │ │ ├── find.py │ │ ├── git.py │ │ ├── list.py │ │ ├── read_file.py │ │ ├── run_command.py │ │ ├── update_file.py │ │ ├── wrapper.py │ │ └── write_file.py │ ├── ui/ │ │ ├── __init__.py │ │ ├── colors.py │ │ ├── core.py │ │ ├── formatting.py │ │ ├── manager.py │ │ ├── special.py │ │ └── spinner.py │ ├── usage.py │ └── utils/ │ ├── __init__.py │ ├── command.py │ ├── error.py │ ├── guide.py │ ├── input.py │ └── logger.py └── tests/ ├── __init__.py ├── agent/ │ ├── __init__.py │ └── test_process_node.py ├── commands/ │ ├── __init__.py │ ├── test_handle_command.py │ ├── test_handle_dump.py │ ├── test_handle_model.py │ └── test_handle_yolo.py ├── config/ │ ├── __init__.py │ ├── test_config_exists.py │ ├── test_deep_merge_dicts.py │ ├── test_ensure_config_structure.py │ ├── test_get_config_path.py │ ├── test_parse_mcp_servers.py │ ├── test_read_config_file.py │ ├── test_set_env_vars.py │ ├── test_update_config_file.py │ └── test_validate_config_structure.py ├── conftest.py ├── main/ │ └── test_error_handling.py ├── mcp/ │ ├── __init__.py │ ├── test_create_mcp_server.py │ ├── test_format_display_name.py │ ├── test_load_mcp_servers.py │ └── test_validate_server_config.py ├── setup/ │ └── test_create_config.py ├── test_data.py ├── tools/ │ ├── __init__.py │ ├── conftest.py │ ├── test_find.py │ ├── test_read_file.py │ └── test_update_file.py ├── ui/ │ └── __init__.py ├── usage/ │ ├── __init__.py │ └── test_usage_tracker.py └── utils/ ├── __init__.py ├── test_command_parser.py ├── test_error_handler.py ├── test_guide.py └── test_input.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .flake8 ================================================ [flake8] max-line-length=100 ignore=E203,E501,W503 ================================================ FILE: .github/workflows/release.yml ================================================ name: Release to PyPI on: push: branches: - main jobs: deploy: runs-on: ubuntu-latest environment: pypi-publish steps: - uses: actions/checkout@v3 with: fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.9' - name: Install dependencies run: | python -m pip install --upgrade pip pip install hatch build twine - name: Build package run: python -m build - name: Publish package uses: pypa/gh-action-pypi-publish@release/v1 with: password: ${{ secrets.PYPI_API_TOKEN }} ================================================ FILE: .gitignore ================================================ # Byte-compiled / optimized files __pycache__/ *.py[cod] *$py.class *.so # Distribution / packaging dist/ build/ *.egg-info/ *.egg # Unit test / coverage reports .pytest_cache/ .coverage htmlcov/ .tox/ coverage.xml *.cover # Virtual environments env/ venv/ ENV/ .env # IDE files .idea/ .vscode/ *.swp *.swo # OS specific files .DS_Store Thumbs.db # Project files SIDEKICK.md .python-version REFACTOR* .claude/ TASKS*.md error*.log *.log ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2025 Gavin Vickery Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ .PHONY: install clean lint format build test install: pip install -e ".[dev]" run: env/bin/sidekick debug: env/bin/sidekick --debug clean: rm -rf build/ rm -rf dist/ rm -rf *.egg-info find . -type d -name __pycache__ -exec rm -rf {} + find . -type f -name "*.pyc" -delete lint: isort src/ tests/ black src/ tests/ flake8 src/ tests/ test: pytest build: python -m build ================================================ FILE: README.md ================================================ # Sidekick (Beta) [![PyPI version](https://badge.fury.io/py/sidekick-cli.svg)](https://badge.fury.io/py/sidekick-cli) [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/) ![Sidekick Demo](screenshot.gif) Your agentic CLI developer. ## Overview Sidekick is an agentic CLI-based AI tool inspired by Claude Code, Copilot, Windsurf and Cursor. It's meant to be an open source alternative to these tools, providing a similar experience but with the flexibility of using different LLM providers (Anthropic, OpenAI, Google Gemini) while keeping the agentic workflow. *Sidekick is currently in beta and under active development. Please [report issues](https://github.com/geekforbrains/sidekick-cli/issues) or share feedback!* ## Features - No vendor lock-in. Use whichever LLM provider you prefer. - MCP support - Easily switch between models in the same session. - JIT-style system prompt injection ensures Sidekick doesn't lose the plot. - Per-project guide. Adjust Sidekick's behavior to suit your needs. - CLI-first design. Ditch the clunky IDE. - Cost and token tracking. - Per command or per session confirmation skipping. ## Roadmap - Tests 😅 - More LLM providers, including Ollama ## Quick Start Install Sidekick. ``` pip install sidekick-cli ``` On first run, you'll be asked to configure your LLM providers. ``` sidekick ``` ## Configuration After initial setup, Sidekick saves a config file to `~/.config/sidekick.json`. You can open and edit this file as needed. Future updates will make editing easier directly from within Sidekick. ### MCP Support Sidekick supports Model Context Protocol (MCP) servers. You can configure MCP servers in your `~/.config/sidekick.json` file: ```json { "mcpServers": { "fetch": { "command": "uvx", "args": ["mcp-server-fetch"] }, "github": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"], "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "" } } } } ``` MCP servers extend the capabilities of your AI assistant, allowing it to interact with additional tools and data sources. Learn more about MCP at [modelcontextprotocol.io](https://modelcontextprotocol.io/). ### Available Commands - `/help` - Show available commands - `/yolo` - Toggle "yolo" mode (skip tool confirmations) - `/clear` - Clear message history - `/model` - List available models - `/model ` - Switch to a specific model (by index) - `/usage` - Show session usage statistics - `exit` - Exit the application ## Customization Sidekick supports the use of a "guide". This is a `SIDEKICK.md` file in the project root that contains instructions for Sidekick. Helpful for specifying tech stack, project structure, development preferences etc. ## Telemetry Sidekick uses [Sentry](https://sentry.io/) for error tracking and usage analytics. You can disable this by starting with the `--no-telemetry` flag. ``` sidekick --no-telemetry ``` ## Requirements - Python 3.10 or higher - Git (for undo functionality) ## Installation ### Using pip ```bash pip install sidekick-cli ``` ### From Source 1. Clone the repository 2. Install dependencies: `pip install .` (or `pip install -e .` for development) ## Development ```bash # Install development dependencies make install # Run linting make lint # Run tests make test ``` ## Release Process When preparing a new release: 1. Update version numbers in: - `pyproject.toml` - `src/sidekick/constants.py` (APP_VERSION) 2. Commit the version changes: ```bash git add pyproject.toml src/sidekick/constants.py git commit -m "chore: bump version to X.Y.Z" ``` 3. Create and push a tag: ```bash git tag vX.Y.Z git push origin vX.Y.Z ``` 4. Create a GitHub release: ```bash gh release create vX.Y.Z --title "vX.Y.Z" --notes "Release notes here" ``` 5. Merge to main branch and push to trigger PyPI release (automated) ### Commit Convention This project follows the [Conventional Commits](https://www.conventionalcommits.org/) specification for commit messages: - `feat:` - New features - `fix:` - Bug fixes - `docs:` - Documentation changes - `style:` - Code style changes (formatting, etc.) - `refactor:` - Code refactoring - `perf:` - Performance improvements - `test:` - Test additions or modifications - `chore:` - Maintenance tasks (version bumps, etc.) - `build:` - Build system changes - `ci:` - CI configuration changes ## Links - [PyPI Package](https://pypi.org/project/sidekick-cli/) - [GitHub Issues](https://github.com/geekforbrains/sidekick-cli/issues) - [GitHub Repository](https://github.com/geekforbrains/sidekick-cli) ## License MIT ================================================ FILE: pyproject.toml ================================================ [build-system] requires = ["setuptools>=61.0.0", "wheel"] build-backend = "setuptools.build_meta" [project] name = "sidekick-cli" version = "0.5.1" description = "Your agentic CLI developer." keywords = ["cli", "agent", "development", "automation"] readme = "README.md" requires-python = ">=3.10" license = {text = "MIT"} authors = [ { name = "Gavin Vickery", email = "gavin@geekforbrains.com" }, ] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: Software Development", "Topic :: Utilities", ] dependencies = [ "pydantic-ai", "rich", "typer", "prompt_toolkit", ] [project.scripts] sidekick = "sidekick:app" [project.optional-dependencies] dev = [ "black", "flake8", "isort", "pytest", "pytest-asyncio", "pytest-mock", ] [project.urls] Homepage = "https://github.com/geekforbrains/sidekick-cli" Repository = "https://github.com/geekforbrains/sidekick-cli" [tool.black] line-length = 100 [tool.isort] profile = "black" line_length = 100 ================================================ FILE: pytest.ini ================================================ [pytest] testpaths = tests python_files = test_*.py python_classes = Test* python_functions = test_* addopts = -v --tb=short asyncio_mode = auto ================================================ FILE: src/sidekick/__init__.py ================================================ from sidekick.main import app __all__ = ["app"] ================================================ FILE: src/sidekick/agent.py ================================================ import asyncio import logging from pathlib import Path from typing import Any, Optional from pydantic_ai import Agent, CallToolsNode from pydantic_ai.messages import ( TextPart, ToolCallPart, ) from sidekick import ui from sidekick.deps import ToolDeps from sidekick.mcp import MCPAgent, load_mcp_servers from sidekick.session import session from sidekick.tools import TOOLS from sidekick.usage import usage_tracker from sidekick.utils.error import ErrorContext log = logging.getLogger(__name__) def _get_prompt(name: str) -> str: try: prompt_path = Path(__file__).parent / "prompts" / f"{name}.txt" return prompt_path.read_text(encoding="utf-8").strip() except FileNotFoundError: return f"Error: Prompt file '{name}.txt' not found" async def _process_node(node, message_history): if isinstance(node, CallToolsNode): for part in node.model_response.parts: if isinstance(part, ToolCallPart): log.debug(f"Calling tool: {part.tool_name}") # I cant' find a definitive way to check if a text part is a "thinking" response # or not, but majority of the time they are accompanied by other tool calls. # Using that as a basis for showing "thinking" messages. if isinstance(part, TextPart) and len(node.model_response.parts) > 1: ui.stop_spinner() ui.thinking_panel(part.content) ui.start_spinner() if hasattr(node, "request"): message_history.add_request(node.request) for part in node.request.parts: if part.part_kind == "retry-prompt": ui.stop_spinner() error_msg = ( part.content if hasattr(part, "content") and isinstance(part.content, str) else "Trying a different approach" ) ui.muted(f"{error_msg}") ui.start_spinner() if hasattr(node, "model_response"): message_history.add_response(node.model_response) def create_agent(): """Create a fresh agent instance with MCP server support.""" base_agent = Agent( model=session.current_model, system_prompt=_get_prompt("system"), tools=TOOLS, mcp_servers=load_mcp_servers(), deps_type=ToolDeps, ) return MCPAgent(base_agent) def _create_confirmation_callback(): async def confirm(title: str, preview: Any, footer: Optional[str] = None) -> bool: tool_name = title.split(":")[0].strip() if ":" in title else title if not session.confirmation_enabled or tool_name in session.disabled_confirmations: return True ui.stop_spinner() ui.tool(preview, title, footer) # Display confirmation options without using a panel, but still # indented by two spaces so they line up with other panel content. options = ( ("y", "Yes, execute this tool"), ("a", "Always allow this tool"), ("n", "No, cancel this execution"), ) for key, description in options: ui.muted(f"{key}: {description}", indent=2) while True: choice = ui.console.input(" Continue? (y): ").lower().strip() if choice == "" or choice in ["y", "yes"]: ui.start_spinner() return True elif choice in ["a", "always"]: session.disabled_confirmations.add(tool_name) ui.start_spinner() return True elif choice in ["n", "no"]: return False return confirm def _create_display_tool_status_callback(): async def display(title: str, *args: Any, **kwargs: Any) -> None: """ Display the current tool status. Args: title: str *args: Any **kwargs: Any Keyword arguments passed to the tool. These will be rendered in the form ``key=value`` in the output. """ ui.stop_spinner() parts = [] if args: parts.extend(str(arg) for arg in args) if kwargs: parts.extend(f"{k}={v}" for k, v in kwargs.items()) arg_str = ", ".join(parts) ui.info(f"{title}({arg_str})") ui.start_spinner() return display async def process_request(message: str, message_history): log.debug(f"Processing request: {message.replace('\n', ' ')[:100]}...") async with create_agent() as mcp_agent: agent = mcp_agent.agent mh = message_history.get_messages_for_agent() log.debug(f"Message history size: {len(mh)}") deps = ToolDeps( confirm_action=_create_confirmation_callback(), display_tool_status=_create_display_tool_status_callback(), ) ctx = ErrorContext("agent", ui) ctx.add_cleanup(lambda e: message_history.patch_on_error(str(e))) try: async with agent.iter(message, deps=deps, message_history=mh) as agent_run: async for node in agent_run: await _process_node(node, message_history) usage = agent_run.usage() if usage: usage_tracker.record_usage(session.current_model, usage) result = agent_run.result.output log.debug(f"Agent response: {result.replace('\n', ' ')[:100]}...") return result except asyncio.CancelledError: raise except Exception as e: if type(e).__name__ == "ClosedResourceError" and e.__class__.__module__ == "anyio": raise asyncio.CancelledError() from e if type(e).__name__ == "McpError" and str(e) == "Connection closed": log.debug("MCP connection closed, cancelling request") raise asyncio.CancelledError() from e return await ctx.handle(e) ================================================ FILE: src/sidekick/commands/__init__.py ================================================ """Command handlers for Sidekick CLI.""" from sidekick import ui from sidekick.commands.clear import handle_clear from sidekick.commands.dump import handle_dump from sidekick.commands.help import handle_help from sidekick.commands.model import handle_model from sidekick.commands.usage import handle_usage from sidekick.commands.yolo import handle_yolo __all__ = [ "handle_clear", "handle_dump", "handle_help", "handle_model", "handle_usage", "handle_yolo", "handle_command", ] async def handle_command(user_input: str, message_history=None) -> bool: """Handle slash commands. Returns True if command was handled.""" if not user_input.startswith("/"): return False parts = user_input.split() command = parts[0] args = parts[1:] if len(parts) > 1 else [] handlers = { "/dump": lambda: handle_dump(message_history), "/yolo": handle_yolo, "/model": lambda: handle_model(args), "/usage": handle_usage, "/clear": lambda: handle_clear(message_history), "/help": handle_help, } handler = handlers.get(command) if handler: await handler() return True ui.line() ui.error(f"Unknown command: {command}") ui.muted("Use /help to see available commands") return True ================================================ FILE: src/sidekick/commands/clear.py ================================================ """Handle /clear command.""" from sidekick import ui async def handle_clear(message_history): """Handle /clear command - clear conversation history and screen.""" if message_history: message_history.clear() ui.banner() ui.success("Conversation history cleared") else: ui.error("Message history not available") ================================================ FILE: src/sidekick/commands/dump.py ================================================ """Handle /dump command.""" from sidekick import ui DUMP_FILE_PATH = "dump.log" def recursive_expand(obj, indent=0): """Recursively expand objects to show their attributes.""" indent_str = " " * indent lines = [] if isinstance(obj, (str, int, float, bool, type(None))): return repr(obj) if hasattr(obj, "isoformat"): return repr(obj) if isinstance(obj, (list, tuple)): if not obj: return "[]" if isinstance(obj, list) else "()" bracket_open = "[" if isinstance(obj, list) else "(" bracket_close = "]" if isinstance(obj, list) else ")" if len(obj) == 1 and isinstance(obj[0], (str, int, float, bool)): return f"{bracket_open}{repr(obj[0])}{bracket_close}" lines.append(bracket_open) for item in obj: expanded = recursive_expand(item, indent + 1) lines.append(f"{indent_str} {expanded},") lines.append(f"{indent_str}{bracket_close}") return "\n".join(lines) if isinstance(obj, dict): if not obj: return "{}" lines.append("{") for key, value in obj.items(): expanded_value = recursive_expand(value, indent + 1) lines.append(f"{indent_str} {repr(key)}: {expanded_value},") lines.append(f"{indent_str}}}") return "\n".join(lines) if hasattr(obj, "__dict__"): class_name = type(obj).__name__ attrs = vars(obj) if not attrs: return f"{class_name}()" lines.append(f"{class_name}(") for key, value in attrs.items(): expanded_value = recursive_expand(value, indent + 1) lines.append(f"{indent_str} {key}={expanded_value},") lines.append(f"{indent_str})") return "\n".join(lines) if hasattr(obj, "__class__"): class_name = type(obj).__name__ attrs = { attr: getattr(obj, attr) for attr in dir(obj) if not attr.startswith("_") and not callable(getattr(obj, attr)) } if not attrs: return repr(obj) lines.append(f"{class_name}(") for key, value in attrs.items(): expanded_value = recursive_expand(value, indent + 1) lines.append(f"{indent_str} {key}={expanded_value},") lines.append(f"{indent_str})") return "\n".join(lines) return repr(obj) async def handle_dump(message_history): """Handle /dump command - write message history to dump.log, overwriting the file each time.""" if not message_history: ui.error("Message history not available") return try: with open(DUMP_FILE_PATH, "w") as f: for i, message in enumerate(message_history): f.write(f"{'=' * 80}\n") f.write(f"Message #{i} - Type: {type(message).__name__}\n") f.write(f"{'=' * 80}\n\n") expanded = recursive_expand(message) f.write(expanded) f.write("\n\n") ui.success(f"Message history dumped to {DUMP_FILE_PATH}") except Exception as e: ui.error(f"Failed to dump message history: {e}") ================================================ FILE: src/sidekick/commands/help.py ================================================ """Handle /help command.""" from sidekick import ui async def handle_help(): """Handle /help command - show available commands.""" ui.line() ui.help() ================================================ FILE: src/sidekick/commands/model.py ================================================ """Handle /model command.""" import logging from rich.table import Table from sidekick import ui from sidekick.config import update_config_file from sidekick.constants import MODELS from sidekick.session import session from sidekick.ui.colors import colors log = logging.getLogger(__name__) async def handle_model(args: list[str]): """Handle /model command - list, switch, or set default model.""" ui.line() if len(args) == 0: table = Table(show_header=False, box=None, padding=(0, 2, 0, 0)) table.add_column("#", justify="right", style=colors.primary) table.add_column("Model", style="white") for i, model_name in enumerate(MODELS.keys(), 1): label = model_name if model_name == session.current_model: label += " [dim](current)[/dim]" table.add_row(str(i), label) ui.info_panel(table, "Available Models") elif len(args) >= 1: try: model_num = int(args[0]) model_list = list(MODELS.keys()) if 1 <= model_num <= len(model_list): selected_model = model_list[model_num - 1] if len(args) >= 2 and args[1] == "default": try: update_config_file({"default_model": selected_model}) ui.success(f"Set {selected_model} as default model") except Exception as e: ui.error(f"Failed to update config: {e}") else: old_model = session.current_model session.current_model = selected_model log.debug(f"Model switched from {old_model} to {selected_model}") ui.info(f"Switched to model: {selected_model}") else: ui.error(f"Invalid model number. Choose between 1 and {len(model_list)}") except ValueError: ui.error("Invalid model number") ================================================ FILE: src/sidekick/commands/usage.py ================================================ """Handle /usage command.""" from rich.text import Text from sidekick import ui from sidekick.usage import usage_tracker async def handle_usage(): """Handle /usage command - show session usage statistics.""" content = Text() if usage_tracker.total_tokens > 0: content.append("Total Statistics\n", style=f"bold {ui.colors.primary}") content.append(f" • Total tokens: {usage_tracker.total_tokens:,}\n", style="white") content.append(f" • Total cost: ${usage_tracker.total_cost:.5f}\n", style="white") content.append(f" • Total requests: {usage_tracker.total_requests:,}\n", style="white") if usage_tracker.last_request: if usage_tracker.total_tokens > 0: content.append("\n") content.append("Last Request\n", style=f"bold {ui.colors.primary}") content.append(f" • Model: {usage_tracker.last_request['model']}\n", style="white") content.append( f" • Input tokens: {usage_tracker.last_request['input_tokens']:,}\n", style="white" ) content.append( f" • Cached tokens: {usage_tracker.last_request['cached_tokens']:,}\n", style="white" ) content.append( f" • Output tokens: {usage_tracker.last_request['output_tokens']:,}\n", style="white" ) content.append( f" • Request cost: ${usage_tracker.last_request['request_cost']:.5f}\n", style="white" ) if not usage_tracker.total_tokens: content.append("No usage data yet in this session", style=ui.colors.muted) if content.plain.endswith("\n"): content = Text(content.plain.rstrip("\n")) panel = ui.create_panel(content, "Session Usage Statistics", ui.colors.muted) ui.display_panel(panel) ================================================ FILE: src/sidekick/commands/yolo.py ================================================ """Handle /yolo command.""" from sidekick import ui from sidekick.session import session async def handle_yolo(): """Handle /yolo command - toggle confirmation mode.""" session.confirmation_enabled = not session.confirmation_enabled if session.confirmation_enabled: session.disabled_confirmations.clear() status = "disabled (YOLO mode)" if not session.confirmation_enabled else "enabled" ui.info(f"Tool confirmations {status}") ================================================ FILE: src/sidekick/config.py ================================================ """Configuration management for Sidekick CLI.""" import json import os from pathlib import Path from typing import Any, Dict from .constants import DEFAULT_USER_CONFIG class ConfigError(Exception): """Base exception for configuration errors.""" pass class ConfigValidationError(ConfigError): """Raised when config structure is invalid.""" pass def get_config_path() -> Path: """Get the path to the config file.""" return Path.home() / ".config" / "sidekick.json" def config_exists() -> bool: """Check if the config file exists.""" return get_config_path().exists() def read_config_file() -> Dict[str, Any]: """Read and parse the config file. Returns: dict: Parsed configuration Raises: ConfigError: If config file doesn't exist or can't be accessed ConfigValidationError: If config file contains invalid JSON """ config_path = get_config_path() if not config_path.exists(): raise ConfigError(f"Config file not found at {config_path}") try: with open(config_path, "r") as f: return json.load(f) except PermissionError as e: raise ConfigError(f"Cannot access config file at {config_path}") from e except json.JSONDecodeError as e: raise ConfigValidationError(f"Invalid JSON in config file at {config_path}") from e def validate_config_structure(config: Dict[str, Any]) -> None: """Validate the configuration structure. Args: config: Configuration dictionary to validate Raises: ConfigValidationError: If required fields are missing or invalid """ if not isinstance(config, dict): raise ConfigValidationError("Config must be a JSON object") if "default_model" not in config: raise ConfigValidationError("Config missing required field 'default_model'") if not isinstance(config["default_model"], str): raise ConfigValidationError("'default_model' must be a string") if "env" not in config: raise ConfigValidationError("Config missing required field 'env'") if not isinstance(config["env"], dict): raise ConfigValidationError("'env' field must be an object") def parse_mcp_servers(config: Dict[str, Any]) -> Dict[str, Any]: """Extract and validate MCP server configuration. Args: config: Full configuration dictionary Returns: dict: MCP servers configuration (may be empty) Raises: ConfigValidationError: If mcpServers field is present but invalid """ if "mcpServers" not in config: return {} mcp_servers = config["mcpServers"] if not isinstance(mcp_servers, dict): raise ConfigValidationError("'mcpServers' field must be an object") for key, server_config in mcp_servers.items(): if not isinstance(server_config, dict): raise ConfigValidationError(f"MCP server '{key}' configuration must be an object") if "command" not in server_config: raise ConfigValidationError(f"MCP server '{key}' missing required field 'command'") if not isinstance(server_config["command"], str): raise ConfigValidationError(f"MCP server '{key}' field 'command' must be a string") if "args" not in server_config: raise ConfigValidationError(f"MCP server '{key}' missing required field 'args'") if not isinstance(server_config["args"], list): raise ConfigValidationError(f"MCP server '{key}' field 'args' must be an array") if len(server_config["args"]) < 1: raise ConfigValidationError( f"MCP server '{key}' field 'args' must contain at least one argument" ) if "env" in server_config and not isinstance(server_config["env"], dict): raise ConfigValidationError(f"MCP server '{key}' field 'env' must be an object") return mcp_servers def set_env_vars(env_dict: Dict[str, str]) -> None: """Set environment variables from config. Args: env_dict: Dictionary of environment variables to set """ for key, value in env_dict.items(): if value and isinstance(value, str): os.environ[key] = value def update_config_file(updates: Dict[str, Any]) -> None: """Update the config file with new values. Args: updates: Dictionary of updates to apply to the config Raises: ConfigError: If config file cannot be read or written """ try: config = read_config_file() except FileNotFoundError: raise ConfigError("Config file not found. Please run initial setup first.") # Merge updates into existing config for key, value in updates.items(): if isinstance(value, dict) and key in config and isinstance(config[key], dict): # For nested dicts, merge instead of replace config[key].update(value) else: config[key] = value # Write updated config back to file config_path = get_config_path() # Ensure the config directory exists config_path.parent.mkdir(parents=True, exist_ok=True) try: with open(config_path, "w") as f: json.dump(config, f, indent=2) except (PermissionError, IOError) as e: raise ConfigError(f"Failed to write config file: {e}") def deep_merge_dicts(base: Dict[str, Any], update: Dict[str, Any]) -> Dict[str, Any]: """Deep merge two dictionaries, preserving existing values in update. Args: base: Base dictionary with default values update: Dictionary with user values to preserve Returns: Merged dictionary with all keys from base and values from update where they exist """ result = base.copy() for key, value in update.items(): if key in result and isinstance(result[key], dict) and isinstance(value, dict): result[key] = deep_merge_dicts(result[key], value) else: result[key] = value return result def ensure_config_structure() -> Dict[str, Any]: """Ensure the config file has all expected keys with defaults for missing ones. This function reads the existing config, merges it with the default structure, and writes back the updated config if any keys were missing. Returns: The updated configuration dictionary Raises: ConfigError: If config file cannot be read or written """ try: config = read_config_file() except ConfigError: raise original_config = json.dumps(config, sort_keys=True) merged_config = deep_merge_dicts(DEFAULT_USER_CONFIG, config) updated_config = json.dumps(merged_config, sort_keys=True) if original_config != updated_config: try: config_path = get_config_path() with open(config_path, "w") as f: json.dump(merged_config, f, indent=2) except (PermissionError, IOError) as e: raise ConfigError(f"Failed to update config file with missing keys: {e}") return merged_config ================================================ FILE: src/sidekick/constants.py ================================================ APP_NAME = "Sidekick" APP_VERSION = "0.5.1" MODELS = { "anthropic:claude-opus-4-0": { "pricing": { "input": 3.00, "cached_input": 1.50, "output": 15.00, }, "context_window": 200_000, }, "anthropic:claude-sonnet-4-0": { "pricing": { "input": 3.00, "cached_input": 1.50, "output": 15.00, }, "context_window": 200_000, }, "anthropic:claude-3-7-sonnet-latest": { "pricing": { "input": 3.00, "cached_input": 1.50, "output": 15.00, }, "context_window": 200_000, }, "google-gla:gemini-2.5-pro": { # Gemini pro has pricing tiers <= 200k / >200k # For now, using the lower pricing as unlikely to exceed 200k tokens # During a session # # TODO: Should make usage tracking dynamic to handle this "pricing": { "input": 1.25, "cached_input": 1.25, "output": 10.00, }, "context_window": 2_000_000, }, "google-gla:gemini-2.5-flash": { "pricing": { "input": 0.30, "cached_input": 0.035, "output": 2.50, }, "context_window": 2_000_000, }, "openai:o4-mini": { "pricing": { "input": 1.10, "cached_input": 0.275, "output": 4.40, }, "context_window": 200_000, }, "openai:o3-pro": { "pricing": { "input": 20.00, "cached_input": 20.00, "output": 80.00, }, "context_window": 200_000, }, "openai:o3": { "pricing": { "input": 10.00, "cached_input": 2.50, "output": 40.00, }, "context_window": 200_000, }, "openai:o3-mini": { "pricing": { "input": 1.10, "cached_input": 0.55, "output": 4.40, }, "context_window": 200_000, }, "openai:gpt-4.1": { "pricing": { "input": 2.00, "cached_input": 0.50, "output": 8.00, }, "context_window": 1_047_576, }, "openai:gpt-4.1-mini": { "pricing": { "input": 0.40, "cached_input": 0.10, "output": 1.60, }, "context_window": 1_047_576, }, "openai:gpt-4.1-nano": { "pricing": { "input": 0.10, "cached_input": 0.025, "output": 0.40, }, "context_window": 1_047_576, }, } # Non-destructive tools that should always be allowed without confirmation ALLOWED_TOOLS = [ "read_file", "find", "list_directory", ] DEFAULT_USER_CONFIG = { "default_model": "", "env": { "ANTHROPIC_API_KEY": "your-anthropic-api-key", "OPENAI_API_KEY": "your-openai-api-key", "GEMINI_API_KEY": "your-gemini-api-key", }, "mcpServers": {}, "settings": { "allowed_tools": [], "allowed_commands": [ "ls", "cat", "rg", "find", "pwd", "echo", "which", "head", "tail", "wc", "sort", "uniq", "diff", "tree", "file", "stat", "du", "df", "ps", "top", "env", "date", "whoami", "hostname", "uname", "id", "groups", "history", ], }, } ================================================ FILE: src/sidekick/deps.py ================================================ from dataclasses import dataclass from typing import Any, Awaitable, Callable, Optional @dataclass class ToolDeps: """Dependencies passed to tools via RunContext.""" confirm_action: Optional[Callable[[str, str, Optional[str]], Awaitable[bool]]] = None display_tool_status: Optional[Callable[[str, Any], Awaitable[None]]] = None ================================================ FILE: src/sidekick/main.py ================================================ import asyncio import logging import sys import typer from rich.console import Console from sidekick import ui from sidekick.config import ( ConfigError, ConfigValidationError, config_exists, ensure_config_structure, set_env_vars, validate_config_structure, ) from sidekick.constants import APP_NAME, APP_VERSION from sidekick.repl import Repl from sidekick.session import session from sidekick.setup import run_setup from sidekick.utils.guide import load_guide from sidekick.utils.logger import setup_logging app = typer.Typer(help=f"{APP_NAME} - Your agentic CLI developer") console = Console() log = logging.getLogger(__name__) def _setup_and_run_event_loop(coro): """ Create, run, and properly clean up the asyncio event loop. This manual setup is used instead of the simpler `asyncio.run()` to gain direct access to the loop object. This is necessary because OS signal handlers (like for SIGINT/Ctrl+C) execute outside of the asyncio loop's context. To gracefully cancel a task from the handler, we must use `loop.call_soon_threadsafe()` to safely schedule the cancellation within the running loop. """ loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: loop.run_until_complete(coro) finally: loop.close() def _initialize_config(): """Checks for, loads, and validates the application configuration.""" ui.banner() if not config_exists(): console.print() config = run_setup() else: try: config = ensure_config_structure() validate_config_structure(config) except ConfigError as e: ui.error("Configuration error", str(e)) sys.exit(1) except ConfigValidationError as e: ui.error("Invalid configuration", str(e)) sys.exit(1) except Exception as e: ui.error("Failed to load configuration", str(e)) sys.exit(1) set_env_vars(config.get("env", {})) return config @app.command() def main( version: bool = typer.Option(False, "--version", "-v", help="Show version and exit."), debug: bool = typer.Option(False, "--debug", help="Enable debug logging to file."), ): """Sidekick CLI main entry point.""" if version: console.print(f"{APP_NAME} version {APP_VERSION}") return if debug: session.debug_enabled = True setup_logging(debug_enabled=debug) config = _initialize_config() session.init(config, config["default_model"]) project_guide = load_guide() if project_guide: ui.info("Loaded SIDEKICK.md guide") log.debug(f"Session initialized with model: {session.current_model}") repl = Repl(project_guide=project_guide) _setup_and_run_event_loop(repl.run()) if __name__ == "__main__": app() ================================================ FILE: src/sidekick/mcp/__init__.py ================================================ """MCP (Model Context Protocol) module for managing servers and agents.""" from .agent import MCPAgent from .servers import SilentMCPServerStdio, load_mcp_servers __all__ = ["MCPAgent", "load_mcp_servers", "SilentMCPServerStdio"] ================================================ FILE: src/sidekick/mcp/agent.py ================================================ """Agent wrapper that manages MCP server lifecycle. This module provides a wrapper around pydantic_ai.Agent that ensures MCP (Model Context Protocol) servers are properly started and stopped when using the agent. """ from pydantic_ai import Agent class MCPAgent: """Manages MCP server lifecycle for an agent. This is a context manager wrapper that ensures MCP servers are running when needed and properly cleaned up afterwards. It wraps a pydantic_ai.Agent instance and manages the lifecycle of its MCP servers without modifying the agent's behavior. The wrapper is reusable - it can be entered and exited multiple times, starting and stopping MCP servers as needed. This is useful for long-running applications where the agent is created once but used for multiple requests (ie. REPL). Key design points: - Does NOT inherit from Agent - uses composition instead of inheritance - Provides transparent access to the wrapped agent via the .agent property - Tracks state to prevent double-starting or stopping of MCP servers - Handles async context manager protocol for proper resource management """ def __init__(self, agent: Agent): """Initialize the MCP agent wrapper. Args: agent: A pydantic_ai.Agent instance that may have MCP servers configured. The agent should already have its model, tools, and MCP servers set up. """ self._agent = agent self._mcp_context = None # Stores the context manager from agent.run_mcp_servers() self._mcp_entered = False # Tracks whether we've entered the MCP context @property def agent(self) -> Agent: """Access the wrapped pydantic_ai.Agent instance. This property allows direct access to the underlying agent for running conversations, accessing tools, or any other agent operations. The wrapper does not intercept or modify any agent methods - it only manages the MCP server lifecycle. Returns: The wrapped pydantic_ai.Agent instance """ return self._agent async def __aenter__(self): """Enter the async context and start MCP servers. This method starts all MCP servers configured on the agent by calling agent.run_mcp_servers(). The method is idempotent - if called multiple times without exiting, it will only start the servers once. The actual server startup is delegated to pydantic_ai's implementation which: 1. Iterates through all MCPServerStdio instances in agent._mcp_servers 2. Starts each server process and establishes stdio communication 3. Returns a context manager that handles shutdown Returns: self: Returns this MCPAgent instance for use in async with statements """ if not self._mcp_entered: # Get the context manager from pydantic_ai that manages MCP servers self._mcp_context = self._agent.run_mcp_servers() # Enter the context to actually start the servers await self._mcp_context.__aenter__() self._mcp_entered = True return self async def __aexit__(self, exc_type, exc_val, exc_tb): """Exit the async context and stop MCP servers. This method ensures all MCP servers are properly shut down by delegating to the pydantic_ai context manager. It resets the internal state so the wrapper can be reused. The cleanup process includes: 1. Sending shutdown signals to all MCP server processes 2. Waiting for processes to terminate gracefully 3. Cleaning up any stdio connections Args: exc_type: Exception type if an exception occurred exc_val: Exception value if an exception occurred exc_tb: Exception traceback if an exception occurred """ if self._mcp_context and self._mcp_entered: # Delegate cleanup to pydantic_ai's context manager await self._mcp_context.__aexit__(exc_type, exc_val, exc_tb) self._mcp_entered = False self._mcp_context = None ================================================ FILE: src/sidekick/mcp/servers.py ================================================ """MCP server utilities and configurations.""" import asyncio import logging import os from contextlib import asynccontextmanager from typing import Any, Dict, List from mcp.client.stdio import StdioServerParameters, stdio_client from pydantic_ai.mcp import MCPServerStdio from pydantic_ai.tools import RunContext from sidekick import ui from sidekick.config import ( ConfigError, parse_mcp_servers, read_config_file, validate_config_structure, ) from sidekick.ui import format_server_name logger = logging.getLogger(__name__) async def mcp_tool_confirmation_callback( ctx: RunContext[Any], original_call_tool, tool_name: str, arguments: Dict[str, Any], ) -> Any: """Process tool callback that shows confirmation for ALL MCP tool calls. This callback is invoked for every MCP tool call and ensures that confirmations are shown regardless of yolo mode or other settings. """ # Check if we have the confirmation callback available if hasattr(ctx.deps, "confirm_action") and ctx.deps.confirm_action: ui.stop_spinner() # Format the arguments for display from rich.pretty import Pretty args_display = Pretty(arguments, expand_all=True) # Always show confirmation for MCP tools confirmed = await ctx.deps.confirm_action(f"MCP({tool_name})", args_display, None) if not confirmed: raise asyncio.CancelledError("MCP tool execution cancelled by user") ui.start_spinner() # Call the original tool return await original_call_tool(tool_name, arguments) class SilentMCPServerStdio(MCPServerStdio): """MCPServerStdio that suppresses stderr output. Extends pydantic_ai's MCPServerStdio to redirect stderr to /dev/null, preventing MCP server error messages from cluttering the CLI output. """ def __init__(self, *args, display_name: str = None, **kwargs): super().__init__(*args, **kwargs) # Add display_name for better server identification in logs/UI self.display_name = display_name or self.command @asynccontextmanager async def client_streams(self): """Override parent's client_streams to suppress stderr. The parent implementation logs errors to stderr by default. This override redirects stderr to /dev/null to keep the CLI clean. """ server = StdioServerParameters( command=self.command, args=list(self.args), env=self.env, cwd=self.cwd ) with open(os.devnull, "w") as null_stream: async with stdio_client(server=server, errlog=null_stream) as ( read_stream, write_stream, ): yield read_stream, write_stream def create_mcp_server(key: str, config: Dict[str, Any]) -> SilentMCPServerStdio: """Create a single MCP server instance. Args: key: Server identifier config: Server configuration dictionary Returns: SilentMCPServerStdio: Configured server instance """ # Use 'name' field if present, otherwise format the key display_name = config.get("name", format_server_name(key)) return SilentMCPServerStdio( command=config["command"], args=config["args"], env=config.get("env", {}), display_name=display_name, process_tool_call=mcp_tool_confirmation_callback, ) def load_mcp_servers() -> List[SilentMCPServerStdio]: """Load MCP servers from configuration. Returns: List of configured MCP server instances Note: - Returns empty list if no servers configured - Shows warnings for invalid server configs but continues with valid ones """ try: config = read_config_file() validate_config_structure(config) mcp_servers_config = parse_mcp_servers(config) except ConfigError as e: logger.error(f"Failed to load config: {e}", exc_info=True) ui.error(f"Failed to load MCP configuration: {e}") return [] except Exception as e: logger.error(f"Unexpected error loading config: {e}", exc_info=True) ui.error(f"Unexpected error loading MCP configuration: {e}") return [] servers = [] failed_servers = [] for key, server_config in mcp_servers_config.items(): try: server = create_mcp_server(key, server_config) servers.append(server) except Exception as e: logger.warning(f"Failed to create server '{key}': {e}", exc_info=True) display_name = server_config.get("name", format_server_name(key)) failed_servers.append((display_name, str(e))) # Show errors for failed servers if failed_servers: ui.warning("Some MCP servers failed to load:") for server_name, error in failed_servers: ui.bullet(f"{server_name}: {error}") # Show summary if all servers failed if mcp_servers_config and not servers: ui.error("No MCP servers could be loaded successfully") elif servers and failed_servers: ui.info(f"Loaded {len(servers)} of {len(mcp_servers_config)} MCP servers") return servers ================================================ FILE: src/sidekick/messages.py ================================================ """Message history management for Sidekick sessions.""" import logging from dataclasses import dataclass, field from typing import List, Optional from pydantic_ai import messages log = logging.getLogger(__name__) @dataclass class MessageHistory: """Manages conversation message history with support for future pruning.""" _messages: List[messages.ModelMessage] = field(default_factory=list) _project_guide: Optional[str] = None def add_request(self, request: messages.ModelRequest) -> None: """Add a request message to the history.""" self._messages.append(request) log.debug("Added request to message history") def add_response(self, response: messages.ModelResponse) -> None: """Add a model response to the history. This is where future pruning logic could be implemented to remove thinking parts and verbose tool outputs after the response is complete. """ self._messages.append(response) log.debug("Added model response to message history") def add_cancellation_note(self) -> None: """Add a user prompt indicating the request was cancelled. This provides clear context to the LLM that the user interrupted the request. """ cancellation_request = messages.ModelRequest( parts=[messages.UserPromptPart(content="Previous request cancelled by user")] ) self.add_request(cancellation_request) log.debug("Added cancellation note to message history") def patch_on_error(self, error_message: str) -> None: """Patch the message history with a ToolReturnPart on error. This is critical for maintaining valid message history when a tool call fails, the user interrupts execution, or other errors occur. LLM models expect to see both a tool call and its corresponding response in the history. Without this patch, the next request would fail because the model would see an unanswered tool call in the history. This method finds the last tool call in the most recent response and creates a synthetic tool return with the error message, ensuring the conversation history remains valid for future interactions. """ if not self._messages: return last_message = self._messages[-1] if not ( hasattr(last_message, "kind") and last_message.kind == "response" and hasattr(last_message, "parts") ): return last_tool_call = None for part in reversed(last_message.parts): if hasattr(part, "part_kind") and part.part_kind == "tool-call": last_tool_call = part break if last_tool_call: tool_return = messages.ToolReturnPart( tool_name=last_tool_call.tool_name, tool_call_id=last_tool_call.tool_call_id, content=error_message, ) self.add_request(messages.ModelRequest(parts=[tool_return])) def clear(self) -> None: """Clear all messages from the history.""" self._messages.clear() log.debug("Cleared message history") def get_messages(self) -> List[messages.ModelMessage]: """Get a copy of all messages for agent use.""" return self._messages.copy() def get_messages_for_agent(self) -> List[messages.ModelMessage]: """Get messages for agent use, with project guide prepended if available.""" messages_copy = self._messages.copy() if self._project_guide: guide_message = messages.ModelRequest( parts=[messages.UserPromptPart(content=self._project_guide)] ) messages_copy.insert(0, guide_message) log.debug("Prepended project guide to message history") return messages_copy def set_project_guide(self, guide: Optional[str]) -> None: """Set the project guide content.""" self._project_guide = guide def __len__(self) -> int: """Return the number of messages in history.""" return len(self._messages) def __iter__(self): """Allow iteration over messages.""" return iter(self._messages) def __getitem__(self, index): """Allow indexed access to messages.""" return self._messages[index] ================================================ FILE: src/sidekick/prompts/system.txt ================================================ You are **Sidekick**, a CLI assistant running in the user's terminal. ### Understanding User Intent - **Action requests** (most common): "create a function", "fix this bug", "add tests" → Take immediate action - **Information requests**: "what does this code do?", "explain the architecture" → Analyze and respond - **Hybrid requests**: "find all TODOs and fix them" → Research then act When unclear, bias toward action—users chose a CLI tool because they want things done. ### Your Environment - **Finding files**: Use list_directory and find tools instead of shell commands. They respect .gitignore and are more efficient. - **Working directory**: Start where the user runs the command unless specified otherwise - **Project context**: Check for README, package.json, requirements.txt, SIDEKICK.md to understand the project - **Available tools**: You have built-in tools AND may have additional MCP tools (check tool list at runtime) ### Built-in Tools (in order of preference) #### Discovery & Navigation - **list_directory**: List directory contents with tree structure, respects .gitignore - **find**: Search for files/directories by name or content (uses ripgrep/fd when available) - Use `pattern` for filename wildcards (e.g., "*.py") - Use `content` to search text within files - Use `dirs=True` to search for directories instead of files #### File Operations - **read_file**: Read any file. Use this before modifying code to understand context - **write_file**: Create new files. Only use when file doesn't exist - **update_file**: Modify existing files. Requires exact string matching—read first! #### Git Operations - **git_add**: Stage files (provides visual preview) - **git_commit**: Create commits (shows changes and message) #### Fallback - **run_command**: For EVERYTHING else (npm install, pytest, ls, mkdir, etc.) ### Important Patterns 1. **Read before writing**: Always read files before modifying them 2. **Chain operations**: Use multiple tools to complete tasks fully 3. **Parallel execution**: Can call multiple read operations simultaneously 4. **Use specialized tools**: Only use run_command when no specific tool exists 5. **Complete the cycle**: If you install packages, run them. If you create tests, execute them. ### Response Style - **Minimal output**: Terminal shows your actions—don't narrate them - **Success**: "Created auth.py with login function" ✓ - **Information**: "Found 3 TODO comments in: main.py (line 45), utils.py (lines 23, 67)" - **Errors**: "Failed to import pandas. Run: pip install pandas" ### Example Workflows **Finding and modifying code:** 1. list_directory (understand project structure) 2. find (search for files by name or content) 3. read_file (examine the code in detail) 4. update_file (make changes) 5. run_command (test the changes) **Creating a feature:** 1. find (find related files) 2. read_file (understand existing code) 3. write_file or update_file (implement feature) 4. run_command (test the feature) **Git workflow:** 1. Make changes using file tools 2. git_add (stage changes) 3. git_commit (commit with descriptive message) **Debugging:** 1. find (with content parameter to find error-related code) 2. read_file (examine problematic code) 3. run_command (run tests to see error) 4. update_file (fix the issue) 5. run_command (verify fix) ### Remember - External confirmation protects users—don't hesitate to use tools - You might have additional MCP tools available—check your tool list - Users want results, not explanations of what you'll do - When in doubt, take action and report outcomes ================================================ FILE: src/sidekick/repl.py ================================================ import asyncio import logging import os import signal import subprocess import sys from sidekick import ui from sidekick.agent import process_request from sidekick.commands import handle_command from sidekick.mcp import load_mcp_servers from sidekick.messages import MessageHistory from sidekick.session import session from sidekick.usage import usage_tracker from sidekick.utils.error import ErrorContext from sidekick.utils.input import create_multiline_prompt_session, get_multiline_input log = logging.getLogger(__name__) def _restore_default_signal_handler(): """Restore the default SIGINT handler.""" signal.signal(signal.SIGINT, signal.default_int_handler) def _should_exit(user_input: str) -> bool: """Check if user wants to exit.""" return user_input.lower() in ["exit", "quit"] async def _display_server_info(): """Display information about configured MCP servers.""" servers = load_mcp_servers() ui.info("Starting MCP servers") if servers: for server in servers: ui.bullet(server.display_name) else: ui.bullet("No servers configured") class Repl: """Manages the application's Read-Eval-Print Loop and interrupt handling.""" def __init__(self, project_guide=None): """Initializes the REPL manager with signal handler.""" self.loop = asyncio.get_event_loop() self.current_task = None self.signal_handler = self._setup_signal_handler() self.message_history = MessageHistory() if project_guide: self.message_history.set_project_guide(project_guide) def _kill_child_processes(self): """Kill all child processes of the current process.""" if sys.platform == "win32": return pid = os.getpid() try: import psutil parent = psutil.Process(pid) for child in parent.children(recursive=True): try: child.kill() except Exception: pass except ImportError: try: subprocess.run(["pkill", "-P", str(pid)], capture_output=True) except Exception: pass def _setup_signal_handler(self): """Set up SIGINT handler for immediate cancellation.""" def signal_handler(signum, frame): if self.current_task and not self.current_task.done(): ui.stop_spinner() self._kill_child_processes() self.loop.call_soon_threadsafe(self.current_task.cancel) else: raise KeyboardInterrupt() signal.signal(signal.SIGINT, signal_handler) return signal_handler async def _handle_user_request(self, user_input: str): """Process a user request with proper exception handling.""" log.debug(f"Handling user request: {user_input.replace('\n', ' ')[:100]}...") ui.start_spinner() request_task = asyncio.create_task(process_request(user_input, self.message_history)) self.current_task = request_task ctx = ErrorContext("request", ui) try: resp = await request_task ui.stop_spinner() if resp: has_footer = bool(usage_tracker.last_request) ui.agent(resp, has_footer=has_footer) if usage_tracker.last_request: ui.usage(usage_tracker.last_request) except asyncio.CancelledError: ui.stop_spinner() ui.warning("Request interrupted") self.message_history.add_cancellation_note() except Exception as e: await ctx.handle(e) finally: self.current_task = None async def run(self): """Runs the main read-eval-print loop.""" ui.info(f"Using model {session.current_model}") await _display_server_info() ui.success("Go kick some ass!") prompt_session = create_multiline_prompt_session() while True: ui.line() try: user_input = await get_multiline_input(prompt_session) except EOFError: break except KeyboardInterrupt: ui.muted("Use Ctrl+D or 'exit' to quit") continue ui.reset_context() if not user_input: continue if _should_exit(user_input): break if await handle_command(user_input, self.message_history): continue await self._handle_user_request(user_input) _restore_default_signal_handler() ui.line() ui.info("Thanks for all the fish.") ================================================ FILE: src/sidekick/session.py ================================================ from dataclasses import dataclass, field from typing import Any, Dict, Optional, Set @dataclass class Session: current_model: Optional[str] = None allowed_commands: Set[str] = field(default_factory=set) disabled_confirmations: Set[str] = field(default_factory=set) confirmation_enabled: bool = True debug_enabled: bool = False def init(self, config: Dict[str, Any], model: str): """Initialize the session state.""" self.current_model = model if "settings" in config: if "allowed_commands" in config["settings"]: self.allowed_commands.update(config["settings"]["allowed_commands"]) # Create global session instance session = Session() ================================================ FILE: src/sidekick/setup.py ================================================ import json from pathlib import Path from typing import Dict, Optional from rich.console import Console from rich.panel import Panel from rich.prompt import Confirm, Prompt from .config import deep_merge_dicts, ensure_config_structure from .constants import DEFAULT_USER_CONFIG console = Console() def validate_json_file(config_path: Path) -> Optional[Dict]: """Validate if a JSON file is valid and return its content.""" try: with open(config_path, "r") as f: return json.load(f) except (json.JSONDecodeError, FileNotFoundError): return None def collect_api_keys() -> Dict[str, str]: """Collect API keys from user input.""" console.print("\n[bold]API Keys Configuration[/bold]\n") console.print("Enter your API keys (press Enter to skip):\n") api_keys = {} providers = [ ("ANTHROPIC_API_KEY", "Anthropic (Claude)"), ("OPENAI_API_KEY", "OpenAI (GPT)"), ("GEMINI_API_KEY", "Google (Gemini)"), ] for key, name in providers: value = Prompt.ask(f"{name} API Key", password=True, default="") if value: api_keys[key] = value return api_keys def select_default_model(api_keys: Dict[str, str]) -> str: """Select default model based on available API keys.""" available_models = [] if "ANTHROPIC_API_KEY" in api_keys: available_models.extend( [ "claude-3-5-sonnet-20241022", "claude-3-5-haiku-20241022", ] ) if "OPENAI_API_KEY" in api_keys: available_models.extend( [ "gpt-4o", "gpt-4o-mini", ] ) if "GEMINI_API_KEY" in api_keys: available_models.extend( [ "gemini-2.0-flash-exp", "gemini-1.5-pro-latest", ] ) if not available_models: console.print("[yellow]No API keys provided. Using default model.[/yellow]") return DEFAULT_USER_CONFIG["default_model"] console.print("\n[bold]Default Model Selection[/bold]\n") for i, model in enumerate(available_models, 1): console.print(f"{i}. {model}") while True: choice = Prompt.ask( "\nSelect default model", choices=[str(i) for i in range(1, len(available_models) + 1)] ) return available_models[int(choice) - 1] def create_config(config_path: Path) -> Dict: """Create a new configuration file.""" console.print( Panel.fit( "[bold cyan]Sidekick CLI Setup[/bold cyan]\n\n" "Welcome! Let's set up your configuration.", border_style="cyan", ) ) api_keys = collect_api_keys() if not api_keys: console.print("\n[red]No API keys provided. At least one API key is required.[/red]") if not Confirm.ask("Continue anyway?", default=False): raise KeyboardInterrupt("Setup cancelled") default_model = select_default_model(api_keys) # Start with user's choices user_config = {"default_model": default_model, "env": api_keys if api_keys else {}} # Merge with defaults to get all fields config = deep_merge_dicts(DEFAULT_USER_CONFIG, user_config) # Remove placeholder API keys if user didn't provide any if not api_keys: config["env"] = {} config_path.parent.mkdir(parents=True, exist_ok=True) with open(config_path, "w") as f: json.dump(config, f, indent=2) console.print(f"\n[green]✓ Configuration saved to {config_path}[/green]") return config def handle_invalid_config(config_path: Path) -> Dict: """Handle invalid configuration file.""" console.print( Panel.fit( "[bold red]Invalid Configuration File[/bold red]\n\n" f"The configuration file at {config_path} is invalid or corrupted.", border_style="red", ) ) console.print("\nOptions:") console.print("1. Reset configuration (create new)") console.print("2. Exit and fix manually") choice = Prompt.ask("\nWhat would you like to do?", choices=["1", "2"]) if choice == "1": config_path.unlink() return create_config(config_path) else: raise SystemExit("Please fix the configuration file manually and try again.") def run_setup() -> Dict: """Run the setup flow and return the configuration.""" config_path = Path.home() / ".config" / "sidekick.json" if config_path.exists(): config = validate_json_file(config_path) if config is None: return handle_invalid_config(config_path) required_fields = ["default_model", "env"] if all(field in config for field in required_fields): # Ensure all default fields are present return ensure_config_structure() else: console.print("[yellow]Configuration file is missing required fields.[/yellow]") return handle_invalid_config(config_path) return create_config(config_path) ================================================ FILE: src/sidekick/tools/__init__.py ================================================ from .wrapper import create_tools TOOLS = create_tools() ================================================ FILE: src/sidekick/tools/common.py ================================================ EXCLUDE_DIRS = { ".git", ".svn", ".hg", ".bzr", "node_modules", "bower_components", "vendor", "packages", "__pycache__", "*.pyc", ".pytest_cache", ".mypy_cache", ".ruff_cache", "venv", ".venv", "env", ".env", "virtualenv", "*.egg-info", ".eggs", ".tox", "pip-wheel-metadata", "build", "dist", "out", "target", "bin", "obj", "_build", "_site", ".build", ".idea", ".vscode", ".vs", ".sublime-project", ".sublime-workspace", "*.swp", "*.swo", "*~", ".DS_Store", "Thumbs.db", "coverage", ".coverage", "htmlcov", ".nyc_output", ".cache", ".parcel-cache", ".next", ".nuxt", ".vuepress", ".docusaurus", ".serverless", ".fusebox", ".dynamodb", "logs", "*.log", "npm-debug.log*", "yarn-debug.log*", "yarn-error.log*", ".npm", ".yarn", ".pnp.*", "debug", "tmp", "temp", ".tmp", ".temp", ".sass-cache", ".gradle", ".m2", ".terraform", "*.tfstate", "*.tfstate.*", ".vagrant", ".kitchen", ".bundle", "__MACOSX", ".pytest_cache", ".hypothesis", } BINARY_EXTENSIONS = { ".pyc", ".pyo", ".so", ".dylib", ".dll", ".exe", ".bin", ".dat", ".db", ".sqlite", ".sqlite3", ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".ico", ".svg", ".webp", ".mp3", ".mp4", ".avi", ".mov", ".wmv", ".flv", ".wav", ".flac", ".zip", ".tar", ".gz", ".bz2", ".7z", ".rar", ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".woff", ".woff2", ".ttf", ".otf", ".eot", } ================================================ FILE: src/sidekick/tools/find.py ================================================ import asyncio import fnmatch import os import re import shutil from pathlib import Path from typing import List, Optional, Set from pydantic_ai import RunContext from sidekick.deps import ToolDeps from sidekick.tools.common import BINARY_EXTENSIONS, EXCLUDE_DIRS async def _run_external_tool(tool_name: str, cmd: List[str]) -> Optional[str]: """Common helper for running external tools with subprocess.""" if not shutil.which(tool_name): return None try: process = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) stdout, stderr = await process.communicate() if process.returncode == 0: output = stdout.decode().strip() return output if output else "No results found." elif process.returncode == 1: # Common "no matches found" exit code return "No results found." return None except Exception: return None def _get_gitignore_patterns() -> Set[str]: gitignore_path = Path(".gitignore") if not gitignore_path.exists(): return set() patterns = set() try: with open(gitignore_path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if line and not line.startswith("#"): patterns.add(line) except Exception: pass return patterns async def _find_files_with_fd(pattern: str, dirs: bool, max_depth: Optional[int]) -> Optional[str]: cmd = ["fd"] if dirs: cmd.extend(["--type", "d"]) else: cmd.extend(["--type", "f"]) if max_depth: cmd.extend(["--max-depth", str(max_depth)]) cmd.append(pattern) return await _run_external_tool("fd", cmd) async def _find_files_with_rg(pattern: str, max_depth: Optional[int]) -> Optional[str]: cmd = ["rg", "--files"] if max_depth: cmd.extend(["--max-depth", str(max_depth)]) result = await _run_external_tool("rg", cmd) if result and result != "No results found.": # rg --files lists all files, so we need to filter by pattern files = result.strip().split("\n") matching = [f for f in files if fnmatch.fnmatch(f, pattern)] return "\n".join(matching) if matching else "No results found." return result async def _find_content_with_rg( content: str, include_pattern: Optional[str] = None, case_sensitive: bool = True, max_results: Optional[int] = None, ) -> Optional[str]: cmd = ["rg", "--line-number"] if not case_sensitive: cmd.append("-i") if include_pattern: cmd.extend(["--glob", include_pattern]) if max_results: cmd.extend(["--max-count", str(max_results)]) cmd.append(content) return await _run_external_tool("rg", cmd) async def _find_content_with_ag( content: str, include_pattern: Optional[str] = None, case_sensitive: bool = True, max_results: Optional[int] = None, ) -> Optional[str]: cmd = ["ag", "--line-numbers"] if not case_sensitive: cmd.append("-i") if include_pattern: cmd.extend(["-G", include_pattern]) if max_results: cmd.extend(["--max-count", str(max_results)]) cmd.append(content) return await _run_external_tool("ag", cmd) def _find_files_python(pattern: str, dirs: bool, max_depth: Optional[int]) -> str: exclude_patterns = EXCLUDE_DIRS.copy() exclude_patterns.update(_get_gitignore_patterns()) results = [] for root, directories, files in os.walk(".", followlinks=False): current_depth = root.count(os.sep) if max_depth and current_depth >= max_depth: directories[:] = [] continue skip_root = False for exclude in exclude_patterns: if exclude in root: skip_root = True break if skip_root: continue directories[:] = [d for d in directories if d not in exclude_patterns] items = directories if dirs else files for item in items: if fnmatch.fnmatch(item, pattern): path = os.path.join(root, item) results.append(path) return "\n".join(sorted(results)) if results else "No results found." def _find_content_python( pattern: str, include_pattern: Optional[str] = None, case_sensitive: bool = True, max_results: Optional[int] = None, ) -> str: try: flags = 0 if case_sensitive else re.IGNORECASE regex = re.compile(pattern, flags) except re.error as e: return f"Invalid regex pattern: {e}" exclude_patterns = EXCLUDE_DIRS.copy() exclude_patterns.update(_get_gitignore_patterns()) results = [] count = 0 for root, dirs, files in os.walk("."): dirs[:] = [d for d in dirs if d not in exclude_patterns and not d.startswith(".")] skip_root = False for exclude in exclude_patterns: if exclude in root: skip_root = True break if skip_root: continue for file in files: if max_results and count >= max_results: results.append(f"... (showing first {max_results} results)") return "\n".join(results) if results else "No results found." if any(file.endswith(ext) for ext in BINARY_EXTENSIONS): continue if include_pattern: if not fnmatch.fnmatch(file, include_pattern): continue filepath = os.path.join(root, file) try: with open(filepath, "r", encoding="utf-8", errors="ignore") as f: for line_num, line in enumerate(f, 1): if regex.search(line): result_line = f"{filepath}:{line_num}:{line.rstrip()}" results.append(result_line) count += 1 if max_results and count >= max_results: break except (OSError, PermissionError): continue return "\n".join(results) if results else "No results found." async def find( ctx: RunContext[ToolDeps], directory: str = ".", pattern: str = "*", *, content: Optional[str] = None, dirs: bool = False, max_depth: Optional[int] = None, case_sensitive: bool = True, max_results: Optional[int] = None, include_pattern: Optional[str] = None, ) -> str: """Find files/directories by name or content. Examples: find(".", "*.py") # Find all Python files find("src", "*test*") # Find files with "test" in name under src/ find(".", "*config*", dirs=True) # Find directories with "config" in name find(".", "*.js", max_depth=2) # Find JS files, max 2 levels deep find(".", content="TODO") # Find all files containing "TODO" find(".", "*.py", content="def main") # Find Python files containing "def main" find(".", content="error", case_sensitive=False) # Case-insensitive content search Args: directory: Directory to search in (default: current directory ".") pattern: Shell-style wildcard pattern for filename (default: "*" matches all) - * matches any characters (e.g., "*.py" matches all .py files) - ? matches single character (e.g., "test?.py" matches test1.py, test2.py) - [seq] matches any character in seq (e.g., "test[123].py") content: Text or regex pattern to search for in file contents dirs: If True, search for directories instead of files (default: False) max_depth: Maximum depth to search (default: None for unlimited) case_sensitive: Whether content search is case-sensitive (default: True) max_results: Maximum number of results to return (default: None for all) include_pattern: When searching content, only search files matching this pattern Returns: For name search: Newline-separated list of matching paths For content search: Newline-separated results in format "filepath:line_number:matching_line" Returns "No results found." if no matches. Note: Automatically excludes common non-project directories (node_modules, .git, etc.) and respects .gitignore when using external tools. """ if ctx.deps and ctx.deps.display_tool_status: status_info = {"pattern": pattern, "dirs": dirs, "depth": max_depth} if content: status_info["content"] = content await ctx.deps.display_tool_status("Find", directory, **status_info) directory = directory or "." orig_dir = os.getcwd() try: os.chdir(os.path.expanduser(directory)) if content: result = await _find_content_with_rg( content, include_pattern, case_sensitive, max_results ) if result is not None: return result result = await _find_content_with_ag( content, include_pattern, case_sensitive, max_results ) if result is not None: return result return _find_content_python(content, include_pattern, case_sensitive, max_results) else: result = await _find_files_with_fd(pattern, dirs, max_depth) if result is not None: return result if not dirs: result = await _find_files_with_rg(pattern, max_depth) if result is not None: return result return _find_files_python(pattern, dirs, max_depth) finally: os.chdir(orig_dir) ================================================ FILE: src/sidekick/tools/git.py ================================================ import asyncio import subprocess from pydantic_ai import ModelRetry, RunContext from sidekick.deps import ToolDeps async def git_add(ctx: RunContext[ToolDeps], files: str) -> str: """Stage files for commit using git add. Args: files: Files to stage (can be paths, patterns, or '.' for all) Returns: Success message with staged files count """ # Ignore for now, we already show panel # # if ctx.deps and ctx.deps.display_tool_status: # await ctx.deps.display_tool_status("Git Add", f"{len(files)} files") try: # First check git status to show what will be staged status_result = subprocess.run( ["git", "status", "--porcelain"], capture_output=True, text=True, check=True ) if not status_result.stdout.strip(): return "No changes to stage" if ctx.deps and ctx.deps.confirm_action: files_to_stage = [] for line in status_result.stdout.splitlines(): if line.strip(): status = line[:2] filename = line[3:] if files.strip() == "." or any( f in filename for f in (files.split() if " " in files else [files]) ): files_to_stage.append(f"{status} {filename}") if files_to_stage: preview = "\n".join(files_to_stage[:20]) if len(files_to_stage) > 20: preview += f"\n... and {len(files_to_stage) - 20} more files" if not await ctx.deps.confirm_action("Git Add", preview): raise asyncio.CancelledError("Tool execution cancelled by user") # Parse files argument - could be '.', specific files, or patterns if files.strip() == ".": # Stage all changes subprocess.run(["git", "add", "."], capture_output=True, text=True, check=True) else: # Stage specific files/patterns file_list = files.split() if " " in files else [files] subprocess.run(["git", "add"] + file_list, capture_output=True, text=True, check=True) # Get updated status to show what was staged new_status = subprocess.run( ["git", "status", "--porcelain"], capture_output=True, text=True, check=True ) # Count staged files staged_count = sum( 1 for line in new_status.stdout.splitlines() if line and line[0] in ["A", "M", "D", "R"] ) return f"Successfully staged {staged_count} file(s)" except subprocess.CalledProcessError as e: error_msg = e.stderr.strip() if e.stderr else str(e) raise ModelRetry(f"Git add failed: {error_msg}") except Exception as e: raise ModelRetry(f"Error running git add: {str(e)}") async def git_commit(ctx: RunContext[ToolDeps], message: str) -> str: """Create a git commit with the given message. Args: message: Commit message Returns: Success message with commit hash """ # Ignore for now, we already show panel # # if ctx.deps and ctx.deps.display_tool_status: # short_message = message[:25].replace("\n", " ") # await ctx.deps.display_tool_status("Git Commit", short_message) try: # Check if there are staged changes status_result = subprocess.run( ["git", "status", "--porcelain"], capture_output=True, text=True, check=True ) # Check for staged files staged_files = [ line for line in status_result.stdout.splitlines() if line and line[0] in ["A", "M", "D", "R"] ] if not staged_files: return "No staged changes to commit" if ctx.deps and ctx.deps.confirm_action: preview = f"Message: {message}\n\nStaged changes:\n\n" preview += "\n".join(staged_files[:20]) if len(staged_files) > 20: preview += f"\n... and {len(staged_files) - 20} more files" if not await ctx.deps.confirm_action("Git Commit", preview): raise asyncio.CancelledError("Tool execution cancelled by user") # Create the commit commit_result = subprocess.run( ["git", "commit", "-m", message], capture_output=True, text=True, check=True ) # Extract commit hash from output output_lines = commit_result.stdout.strip().split("\n") commit_info = output_lines[0] if output_lines else "Commit created" return f"Successfully created commit: {commit_info}" except subprocess.CalledProcessError as e: error_msg = e.stderr.strip() if e.stderr else str(e) raise ModelRetry(f"Git commit failed: {error_msg}") except Exception as e: raise ModelRetry(f"Error running git commit: {str(e)}") ================================================ FILE: src/sidekick/tools/list.py ================================================ import asyncio import os import shutil from pathlib import Path from typing import Dict, List, Tuple from pydantic_ai import RunContext from sidekick.deps import ToolDeps from .common import EXCLUDE_DIRS def _should_exclude(path: str, gitignore_patterns: List[str]) -> bool: """Check if a path should be excluded based on patterns.""" path_obj = Path(path) # Check against EXCLUDE_DIRS for part in path_obj.parts: if part in EXCLUDE_DIRS: return True # Check against gitignore patterns (simplified) for pattern in gitignore_patterns: pattern = pattern.strip() if not pattern or pattern.startswith("#"): continue # Simple pattern matching (not full gitignore spec) if pattern.endswith("/"): # Directory pattern if pattern[:-1] in path_obj.parts: return True else: # File pattern if path_obj.match(pattern): return True return False def _read_gitignore(base_path: str) -> List[str]: """Read .gitignore patterns from the given directory.""" gitignore_path = os.path.join(base_path, ".gitignore") if os.path.exists(gitignore_path): try: with open(gitignore_path, "r") as f: return f.readlines() except Exception: pass return [] def _format_tree(items: List[Tuple[str, bool, int]], prefix: str = "") -> List[str]: """Format directory structure as a tree.""" lines = [] for i, (name, is_dir, file_count) in enumerate(items): is_last = i == len(items) - 1 current_prefix = "└── " if is_last else "├── " if is_dir and file_count > 0: lines.append(f"{prefix}{current_prefix}{name}/ ({file_count} files)") elif is_dir: lines.append(f"{prefix}{current_prefix}{name}/") else: lines.append(f"{prefix}{current_prefix}{name}") return lines async def _run_rg_files(path: str, max_depth: int) -> str: """Use ripgrep to list files efficiently.""" cmd = ["rg", "--files", "--max-depth", str(max_depth), path] try: process = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) stdout, stderr = await process.communicate() if process.returncode == 0: files = stdout.decode().strip().split("\n") # Convert flat file list to tree structure return _build_tree_from_files(files, path) else: # Fall back to Python implementation return None except Exception: return None def _build_tree_from_files(files: List[str], base_path: str) -> str: """Build a tree structure from a flat list of file paths.""" # This is a simplified version - for now, fallback to Python implementation # A full implementation would parse the paths and build a proper tree return None def _walk_directory( path: str, max_depth: int, current_depth: int = 0, gitignore_patterns: List[str] = None ) -> Tuple[List[str], Dict[str, int]]: """Walk directory and return formatted tree lines and stats.""" if gitignore_patterns is None: gitignore_patterns = _read_gitignore(path) lines = [] total_files = 0 total_dirs = 0 if current_depth >= max_depth: return lines, {"files": total_files, "dirs": total_dirs} try: items = [] for item in sorted(os.listdir(path)): if item.startswith(".") and item not in [".gitignore", ".env.example"]: continue item_path = os.path.join(path, item) if _should_exclude(item_path, gitignore_patterns): continue is_dir = os.path.isdir(item_path) file_count = 0 if is_dir: # Count files in subdirectory (for display) try: sub_items = os.listdir(item_path) file_count = sum( 1 for i in sub_items if not os.path.isdir(os.path.join(item_path, i)) and not i.startswith(".") ) except Exception: pass total_dirs += 1 else: total_files += 1 items.append((item, is_dir, file_count)) # Sort: directories first, then files items.sort(key=lambda x: (not x[1], x[0].lower())) # Format current level if current_depth == 0: lines.append(f"{path}") # Add tree lines tree_lines = _format_tree(items) lines.extend(tree_lines) # Recursively process subdirectories for i, (name, is_dir, _) in enumerate(items): if is_dir: item_path = os.path.join(path, name) is_last = i == len(items) - 1 prefix = " " if is_last else "│ " sub_lines, sub_stats = _walk_directory( item_path, max_depth, current_depth + 1, gitignore_patterns ) # Add subdirectory content with proper indentation for line in sub_lines: if line: # Skip empty lines lines.append(prefix + line) total_files += sub_stats["files"] total_dirs += sub_stats["dirs"] except PermissionError: lines.append(f"Permission denied: {path}") except Exception as e: lines.append(f"Error reading directory: {str(e)}") return lines, {"files": total_files, "dirs": total_dirs} async def list_directory(ctx: RunContext[ToolDeps], path: str = ".", max_depth: int = 3) -> str: """ List directory contents in a tree structure, respecting .gitignore and common exclusions. Args: path: Directory path to list (default: current directory) max_depth: Maximum depth to traverse (default: 3) Returns: Formatted directory tree as a string """ if ctx.deps and ctx.deps.display_tool_status: await ctx.deps.display_tool_status("List", path, depth=max_depth) path = os.path.abspath(os.path.expanduser(path)) if not os.path.exists(path): return f"Error: Path does not exist: {path}" if not os.path.isdir(path): return f"Error: Path is not a directory: {path}" # Try using ripgrep first if available if shutil.which("rg"): result = await _run_rg_files(path, max_depth) if result: return result # Fall back to Python implementation lines, stats = _walk_directory(path, max_depth) # Give AI the context of result lines.append("") lines.append(f"Total: {stats['files']} files, {stats['dirs']} directories") return "\n".join(lines) ================================================ FILE: src/sidekick/tools/read_file.py ================================================ import logging from pydantic_ai import RunContext from sidekick.deps import ToolDeps log = logging.getLogger(__name__) async def read_file(ctx: RunContext[ToolDeps], filepath: str) -> str: """Read the contents of a file.""" log.debug(f"read_file called with filepath: {filepath}") if ctx.deps and ctx.deps.display_tool_status: await ctx.deps.display_tool_status("Read", filepath) try: with open(filepath, "r", encoding="utf-8") as file: content = file.read() log.debug(f"Successfully read {len(content)} characters from {filepath}") return content except FileNotFoundError: return f"Error: File not found: {filepath}" except PermissionError: return f"Error: Permission denied: {filepath}" except Exception as e: return f"Error reading file {filepath}: {str(e)}" ================================================ FILE: src/sidekick/tools/run_command.py ================================================ import asyncio import subprocess from pydantic_ai import RunContext from sidekick import ui from sidekick.deps import ToolDeps from sidekick.session import session from sidekick.utils.command import extract_commands, is_command_allowed async def run_command(ctx: RunContext[ToolDeps], command: str) -> str: """Run a shell command and return its output.""" # Ignore for now, we already show panel # # if ctx.deps and ctx.deps.display_tool_status: # await ctx.deps.display_tool_status("Run", command) if ctx.deps and ctx.deps.confirm_action: if not is_command_allowed(command, session.allowed_commands): command_display = ui.create_shell_syntax(command) if not await ctx.deps.confirm_action("Run Command", command_display): raise asyncio.CancelledError("Tool execution cancelled by user") commands = extract_commands(command) session.allowed_commands.update(commands) else: # If command is allowed, show as a status await ctx.deps.display_tool_status("Run", command) result = subprocess.run( command, shell=True, capture_output=True, text=True, timeout=30, ) output = result.stdout + result.stderr return output if output else "(no output)" ================================================ FILE: src/sidekick/tools/update_file.py ================================================ import asyncio from pydantic_ai import ModelRetry, RunContext from sidekick import ui from sidekick.deps import ToolDeps async def update_file( ctx: RunContext[ToolDeps], filepath: str, old_content: str, new_content: str ) -> str: """Update specific content in a file.""" # Ignore for now, we already show panel # # if ctx.deps and ctx.deps.display_tool_status: # await ctx.deps.display_tool_status("Update", filepath) if old_content == new_content: raise ModelRetry( "The old_content and new_content are identical. " "Please provide different content for the replacement." ) try: with open(filepath, "r", encoding="utf-8") as file: content = file.read() except FileNotFoundError: raise ModelRetry(f"File not found: {filepath}. Please check the file path and try again.") except Exception as e: raise ModelRetry(f"Error reading file {filepath}: {str(e)}") if old_content not in content: preview = old_content[:100] + "..." if len(old_content) > 100 else old_content raise ModelRetry( f"Content to replace not found in {filepath}. " f"Searched for: '{preview}'. " "Please re-read the file and ensure the exact content matches, including whitespace." ) if ctx.deps and ctx.deps.confirm_action: updated_content = content.replace(old_content, new_content, 1) diff_preview = ui.create_unified_diff(content, updated_content, filepath) footer = f"File: {filepath}" if not await ctx.deps.confirm_action("Update File", diff_preview, footer): raise asyncio.CancelledError("Tool execution cancelled by user") try: updated_content = content.replace(old_content, new_content, 1) with open(filepath, "w", encoding="utf-8") as file: file.write(updated_content) except Exception as e: raise ModelRetry(f"Error writing to file {filepath}: {str(e)}") return f"Successfully updated {filepath}" ================================================ FILE: src/sidekick/tools/wrapper.py ================================================ from pydantic_ai import Tool from sidekick.tools.find import find from sidekick.tools.git import git_add, git_commit from sidekick.tools.list import list_directory from sidekick.tools.read_file import read_file from sidekick.tools.run_command import run_command from sidekick.tools.update_file import update_file from sidekick.tools.write_file import write_file TOOL_RETRY_LIMIT = 10 def create_tools(): """Create Tool instances for all tools.""" tools = [ read_file, write_file, update_file, run_command, git_add, git_commit, find, list_directory, ] return [Tool(tool, max_retries=TOOL_RETRY_LIMIT) for tool in tools] ================================================ FILE: src/sidekick/tools/write_file.py ================================================ import asyncio import logging from pathlib import Path from pydantic_ai import RunContext from sidekick import ui from sidekick.deps import ToolDeps log = logging.getLogger(__name__) async def write_file(ctx: RunContext[ToolDeps], filepath: str, content: str) -> str: """Write content to a file.""" log.debug(f"write_file called with filepath: {filepath}, content length: {len(content)}") # Write content is in a panel already, showing this here feels redundant # Commenting out for now # # if ctx.deps and ctx.deps.display_tool_status: # await ctx.deps.display_tool_status("Write", filepath) if ctx.deps and ctx.deps.confirm_action: syntax = ui.create_syntax_highlighted(content, filepath) footer = f"File: {filepath}" if not await ctx.deps.confirm_action("Write File", syntax, footer): raise asyncio.CancelledError("Tool execution cancelled by user") Path(filepath).parent.mkdir(parents=True, exist_ok=True) with open(filepath, "w", encoding="utf-8") as file: file.write(content) return f"Successfully wrote to {filepath}" ================================================ FILE: src/sidekick/ui/__init__.py ================================================ """Clean, simplified UI module.""" from sidekick.ui.core import BANNER, SpinnerStyle from sidekick.ui.formatting import ( create_inline_diff, create_shell_syntax, create_syntax_highlighted, create_unified_diff, format_server_name, get_command_display_name, get_file_language, ) from sidekick.ui.manager import MessageType, OutputType, PanelType, UIManager from sidekick.ui.special import update_available as _update_available from sidekick.ui.special import usage as _usage from sidekick.ui.special import version as _version from sidekick.ui.spinner import SpinnerManager _ui = UIManager() _spinner = SpinnerManager(_ui.console) # Core API panel = _ui.panel message = _ui.message line = _ui.line reset_context = _ui.reset_context # Convenience methods agent = _ui.agent tool = _ui.tool info = _ui.info error = _ui.error warning = _ui.warning success = _ui.success bullet = _ui.bullet muted = _ui.muted thinking = _ui.thinking # Special panels thinking_panel = _ui.thinking_panel confirmation_panel = _ui.confirmation_panel info_panel = _ui.info_panel error_panel = _ui.error_panel # Special functions dump = _ui.dump help = _ui.help def version(): """Display version information.""" _version(_ui) def update_available(latest_version: str): """Display update available message.""" _update_available(_ui, latest_version) def usage(usage_data: dict): """Display usage statistics.""" _usage(_ui, usage_data) def banner(): """Display the application banner.""" from rich.padding import Padding from sidekick.constants import APP_VERSION from sidekick.ui.colors import colors _ui.console.clear() banner_padding = Padding(BANNER, (1, 0, 0, 2)) version_padding = Padding(f"v{APP_VERSION}", (0, 0, 1, 2)) _ui.console.print(banner_padding, style=colors.primary) _ui.console.print(version_padding, style=colors.muted) _ui._last_output = None def start_spinner(message: str = "", style: str = SpinnerStyle.DEFAULT): """Start the spinner with a message.""" # Add spacing before spinner if coming after user input if _ui._last_output == OutputType.USER_INPUT: _ui.console.print() _spinner.start(message, style) _ui.set_spinner_active(True) def stop_spinner(): """Stop the spinner.""" _spinner.stop() _ui.set_spinner_active(False) console = _ui.console __all__ = [ # Core API "panel", "message", "line", "reset_context", # Messages "info", "error", "warning", "success", "bullet", "muted", "thinking", # Panels "agent", "tool", "thinking_panel", "confirmation_panel", "info_panel", "error_panel", # Special functions "dump", "help", "version", "update_available", "usage", # Utilities "banner", "start_spinner", "stop_spinner", "console", # Formatting "create_inline_diff", "create_shell_syntax", "create_syntax_highlighted", "create_unified_diff", "format_server_name", "get_command_display_name", "get_file_language", # Types "PanelType", "MessageType", "SpinnerStyle", ] ================================================ FILE: src/sidekick/ui/colors.py ================================================ """Color definitions for the UI module.""" class Colors: primary = "medium_purple1" # Agent responses secondary = "medium_purple3" # Secondary purple success = "green" # Success messages warning = "orange1" # Confirmations/warnings error = "red" # Errors muted = "grey62" # Info/help tool_data = "bright_blue" # Tool output data colors = Colors() ================================================ FILE: src/sidekick/ui/core.py ================================================ """Core UI functions including banner and spinner management.""" from rich.console import Console from rich.padding import Padding from sidekick.constants import APP_VERSION from sidekick.ui.colors import colors from sidekick.ui.spinner import SpinnerManager console = Console() _spinner_manager = SpinnerManager(console) BANNER = """ ███████╗██╗██████╗ ███████╗██╗ ██╗██╗ ██████╗██╗ ██╗ ██╔════╝██║██╔══██╗██╔════╝██║ ██╔╝██║██╔════╝██║ ██╔╝ ███████╗██║██║ ██║█████╗ █████╔╝ ██║██║ █████╔╝ ╚════██║██║██║ ██║██╔══╝ ██╔═██╗ ██║██║ ██╔═██╗ ███████║██║██████╔╝███████╗██║ ██╗██║╚██████╗██║ ██╗ ╚══════╝╚═╝╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═════╝╚═╝ ╚═╝""" class SpinnerStyle: DEFAULT = f"[bold {colors.primary}]{{}}[/bold {colors.primary}]" MUTED = f"[{colors.muted}]{{}}[/{colors.muted}]" WARNING = f"[{colors.warning}]{{}}[/{colors.warning}]" ERROR = f"[{colors.error}]{{}}[/{colors.error}]" def banner(): """Display the application banner.""" console.clear() banner_padding = Padding(BANNER, (0, 0, 0, 2)) version_padding = Padding(f"v{APP_VERSION}", (0, 0, 1, 2)) console.print(banner_padding, style=colors.primary) console.print(version_padding, style=colors.muted) def start_spinner(message: str = "", style: str = SpinnerStyle.DEFAULT): """Start the spinner with a message.""" _spinner_manager.start(message, style) def stop_spinner(): """Stop the spinner.""" _spinner_manager.stop() ================================================ FILE: src/sidekick/ui/formatting.py ================================================ """Formatting functions for syntax highlighting, diffs, and display.""" import difflib from pathlib import Path from rich.syntax import Syntax from rich.text import Text SYNTAX_THEME = "nord" def get_file_language(filepath: str) -> str: """Determine the language for syntax highlighting based on file extension. Args: filepath: Path to the file Returns: Language identifier for rich.syntax.Syntax """ ext_map = { ".py": "python", ".js": "javascript", ".ts": "typescript", ".jsx": "jsx", ".tsx": "tsx", ".json": "json", ".html": "html", ".css": "css", ".scss": "scss", ".sass": "sass", ".less": "less", ".xml": "xml", ".yaml": "yaml", ".yml": "yaml", ".toml": "toml", ".ini": "ini", ".cfg": "ini", ".conf": "ini", ".sh": "bash", ".bash": "bash", ".zsh": "zsh", ".fish": "fish", ".ps1": "powershell", ".bat": "batch", ".cmd": "batch", ".go": "go", ".rs": "rust", ".java": "java", ".kt": "kotlin", ".swift": "swift", ".c": "c", ".h": "c", ".cpp": "cpp", ".cxx": "cpp", ".cc": "cpp", ".hpp": "cpp", ".cs": "csharp", ".php": "php", ".rb": "ruby", ".lua": "lua", ".pl": "perl", ".r": "r", ".R": "r", ".m": "matlab", ".jl": "julia", ".scala": "scala", ".clj": "clojure", ".elm": "elm", ".ex": "elixir", ".exs": "elixir", ".erl": "erlang", ".hrl": "erlang", ".vim": "vim", ".vimrc": "vim", ".sql": "sql", ".dockerfile": "docker", ".Dockerfile": "docker", ".md": "markdown", ".markdown": "markdown", ".rst": "rst", ".tex": "latex", ".vue": "vue", ".svelte": "svelte", } # Get the file extension ext = Path(filepath).suffix.lower() # Check if we have a mapping for this extension if ext in ext_map: return ext_map[ext] # Check for some special filenames filename = Path(filepath).name.lower() if filename == "dockerfile": return "docker" elif filename == "makefile": return "makefile" elif filename == ".gitignore": return "gitignore" elif filename == ".env": return "dotenv" # Default to text if we don't recognize the extension return "text" def create_syntax_highlighted(content: str, filepath: str, theme: str = None) -> Syntax: """Create syntax-highlighted content. Args: content: The content to highlight filepath: Path to determine language theme: Optional theme override Returns: Syntax object for rendering """ if theme is None: theme = SYNTAX_THEME language = get_file_language(filepath) return Syntax( content, language, theme=theme, line_numbers=True, word_wrap=True, ) def create_shell_syntax(command: str, theme: str = None) -> Syntax: """Create syntax-highlighted shell command. Args: command: Shell command to highlight theme: Optional theme override Returns: Syntax object for rendering """ if theme is None: theme = SYNTAX_THEME return Syntax( command, "bash", theme=theme, line_numbers=False, word_wrap=True, ) def create_unified_diff( old_content: str, new_content: str, filepath: str = "file", context_lines: int = 3 ) -> Syntax: """Create a unified diff with syntax highlighting. Args: old_content: Original file content new_content: Modified file content filepath: Path for diff header context_lines: Number of context lines Returns: Syntax object with highlighted diff """ old_lines = old_content.splitlines(keepends=True) new_lines = new_content.splitlines(keepends=True) diff = difflib.unified_diff( old_lines, new_lines, fromfile=f"a/{filepath}", tofile=f"b/{filepath}", n=context_lines, lineterm="", ) diff_text = "".join(diff) return Syntax( diff_text, "diff", theme=SYNTAX_THEME, line_numbers=False, word_wrap=True, ) def create_inline_diff(old_content: str, new_content: str) -> tuple[Text, Text]: """Create inline diffs showing character-level changes. Args: old_content: Original content new_content: New content Returns: Tuple of (old_text, new_text) with highlighting """ old_text = Text() new_text = Text() # Use difflib to find character-level differences matcher = difflib.SequenceMatcher(None, old_content, new_content) for tag, i1, i2, j1, j2 in matcher.get_opcodes(): if tag == "equal": old_text.append(old_content[i1:i2]) new_text.append(new_content[j1:j2]) elif tag == "delete": old_text.append(old_content[i1:i2], style="red strike") elif tag == "insert": new_text.append(new_content[j1:j2], style="green") elif tag == "replace": old_text.append(old_content[i1:i2], style="red strike") new_text.append(new_content[j1:j2], style="green") return old_text, new_text def format_server_name(key: str) -> str: """Convert server key to human-readable name. Args: key: Server key (e.g., 'npmScripts') Returns: Human-readable name (e.g., 'NPM Scripts') """ # Handle camelCase result = "" for i, char in enumerate(key): if i > 0 and char.isupper() and key[i - 1].islower(): result += " " result += char # Handle snake_case and hyphenated names result = result.replace("_", " ").replace("-", " ") # Capitalize words, but preserve certain acronyms words = result.split() formatted_words = [] acronyms = {"npm", "mcp", "api", "cli", "url", "uri", "id", "ui"} for word in words: if word.lower() in acronyms: formatted_words.append(word.upper()) else: formatted_words.append(word.capitalize()) return " ".join(formatted_words) def get_command_display_name(command_string: str) -> str: """Get a display-friendly version of the commands for UI. Args: command_string: The full shell command string Returns: A comma-separated list of command names """ # Import here to avoid circular dependency from sidekick.utils.command import extract_commands commands = extract_commands(command_string) if len(commands) == 1: return f"'{commands[0]}'" else: return ", ".join(f"'{cmd}'" for cmd in commands) ================================================ FILE: src/sidekick/ui/manager.py ================================================ """UI Manager for centralized output control and spacing logic.""" from enum import Enum, auto from typing import Optional, Union from rich.console import Console from rich.markdown import Markdown from rich.padding import Padding from rich.panel import Panel from rich.pretty import Pretty from rich.table import Table from rich.text import Text from sidekick.ui.colors import colors from sidekick.ui.formatting import create_syntax_highlighted class OutputType(Enum): """Types of output that affect spacing decisions.""" STATUS = auto() # Info, error, warning messages PANEL = auto() # Boxed content panels USER_INPUT = auto() # After user input THINKING = auto() # Thinking messages (special status) SPINNER = auto() # While spinner is active class PanelType(Enum): """Types of panels with different styling.""" DEFAULT = auto() AGENT = auto() TOOL = auto() ERROR = auto() WARNING = auto() INFO = auto() CONFIRMATION = auto() THINKING = auto() class MessageType(Enum): """Types of status messages.""" INFO = auto() ERROR = auto() WARNING = auto() SUCCESS = auto() BULLET = auto() MUTED = auto() THINKING = auto() # Style configuration for different panel types PANEL_STYLES = { PanelType.DEFAULT: {"border_style": colors.muted, "title_prefix": ""}, PanelType.AGENT: {"border_style": colors.primary, "title_prefix": "Sidekick"}, PanelType.TOOL: {"border_style": colors.tool_data, "title_prefix": ""}, PanelType.ERROR: {"border_style": colors.error, "title_prefix": "Error"}, PanelType.WARNING: {"border_style": colors.warning, "title_prefix": "Warning"}, PanelType.INFO: {"border_style": colors.muted, "title_prefix": ""}, PanelType.CONFIRMATION: {"border_style": colors.warning, "title_prefix": "Confirm Action"}, PanelType.THINKING: {"border_style": colors.muted, "title_prefix": "Thinking"}, } # Style configuration for different message types MESSAGE_STYLES = { MessageType.INFO: {"prefix": "•", "style": colors.primary}, MessageType.ERROR: {"prefix": "✗", "style": colors.error}, MessageType.WARNING: {"prefix": "⚠", "style": colors.warning}, MessageType.SUCCESS: {"prefix": "✓", "style": colors.success}, MessageType.BULLET: {"prefix": " -", "style": colors.muted}, MessageType.MUTED: {"prefix": "ℹ", "style": colors.muted}, MessageType.THINKING: {"prefix": "›", "style": colors.muted}, } class UIManager: """Manages UI output with automatic spacing and consistent styling.""" PANEL_CONTENT_PADDING = 1 PANEL_WRAPPER_PADDING = (0, 0, 0, 1) def __init__(self): self.console = Console() self._last_output: Optional[OutputType] = None self._spinner_active = False def _prepare_spacing(self, new_type: OutputType): """Add spacing based on output transitions. Rules: - Panel -> Status: add blank line - Any -> Panel: add blank line (except after user input) - Status -> Status: no spacing """ if self._last_output is None: return if new_type == OutputType.STATUS and self._last_output == OutputType.PANEL: self.console.print() elif new_type == OutputType.PANEL and self._last_output != OutputType.USER_INPUT: self.console.print() def _prepare_panel_content(self, content, markdown, syntax): """Prepare content for display in panel. Args: content: Raw content to display markdown: Whether to render as markdown syntax: Language for syntax highlighting Returns: Formatted content ready for panel display """ if markdown and isinstance(content, str): return Markdown(content) elif syntax and isinstance(content, str): return create_syntax_highlighted(content, syntax) return content def _determine_panel_title(self, title, panel_type, config): """Determine final panel title based on type and configuration. Args: title: Optional title override panel_type: Type of panel for special handling config: Panel style configuration Returns: Final title string or None """ if title is None and config["title_prefix"]: return config["title_prefix"] elif title and config["title_prefix"] and panel_type == PanelType.AGENT: return title elif title and config["title_prefix"]: return f"{config['title_prefix']}: {title}" return title def panel( self, content: Union[str, Text], *, title: Optional[str] = None, panel_type: PanelType = PanelType.DEFAULT, footer: Optional[str] = None, markdown: bool = False, syntax: Optional[str] = None, has_footer: bool = False, ): """Display a panel with automatic spacing and styling. Args: content: Content to display in the panel title: Optional title override panel_type: Type of panel for styling footer: Optional footer text (displayed below panel) markdown: Whether to render content as markdown syntax: Language for syntax highlighting (alternative to markdown) has_footer: Whether external footer will be shown (affects padding) """ self._prepare_spacing(OutputType.PANEL) config = PANEL_STYLES[panel_type] display_content = self._prepare_panel_content(content, markdown, syntax) final_title = self._determine_panel_title(title, panel_type, config) panel = Panel( Padding(display_content, self.PANEL_CONTENT_PADDING), title=final_title, title_align="left", border_style=config["border_style"], ) self.console.print(Padding(panel, self.PANEL_WRAPPER_PADDING)) if footer: self.console.print(f" {footer}", style=colors.muted) self._last_output = OutputType.PANEL def message( self, text: str, *, message_type: MessageType = MessageType.INFO, indent: int = 0, detail: Optional[str] = None, ): """Display a status message with automatic spacing. Args: text: Message text message_type: Type of message for styling indent: Additional spaces to indent detail: Optional detail text (for errors) """ self._prepare_spacing(OutputType.STATUS) config = MESSAGE_STYLES[message_type] prefix = config["prefix"] style = config["style"] if message_type == MessageType.THINKING: lines = text.strip().split("\n") if lines: self.console.print(f"{prefix} {lines[0]}", style=style) for line in lines[1:]: self.console.print(f" {line}", style=style) else: indent_str = " " * indent if prefix: msg = f"{indent_str}{prefix} {text}" else: msg = f"{indent_str}{text}" if detail and message_type == MessageType.ERROR: msg = f"{msg}: {detail}" self.console.print(msg, style=style) self._last_output = OutputType.STATUS def line(self): """Print a blank line without affecting output context.""" self.console.print() def reset_context(self): """Reset output context (typically after user input).""" self._last_output = OutputType.USER_INPUT def set_spinner_active(self, active: bool): """Update spinner state and preserve spacing context. Args: active: Whether spinner is active """ self._spinner_active = active if active and self._last_output not in (OutputType.PANEL, OutputType.USER_INPUT): self._last_output = OutputType.SPINNER def agent(self, content: str, has_footer: bool = False): """Display agent response panel.""" self.panel( content, title="Sidekick", panel_type=PanelType.AGENT, markdown=True, has_footer=has_footer, ) def tool(self, content: Union[str, Text], title: str, footer: Optional[str] = None): """Display tool output panel.""" self.panel( content, title=title, panel_type=PanelType.TOOL, footer=footer, ) def error_panel(self, message: str, detail: Optional[str] = None, title: Optional[str] = None): """Display error panel.""" content = f"{message}\n\n{detail}" if detail else message self.panel( content, title=title, panel_type=PanelType.ERROR, ) def info(self, text: str): """Display info message.""" self.message(text, message_type=MessageType.INFO) def error(self, text: str, detail: Optional[str] = None): """Display error message.""" self.message(text, message_type=MessageType.ERROR, detail=detail) def warning(self, text: str): """Display warning message.""" self.message(text, message_type=MessageType.WARNING) def success(self, text: str): """Display success message.""" self.message(text, message_type=MessageType.SUCCESS) def bullet(self, text: str): """Display bullet point.""" self.message(text, message_type=MessageType.BULLET) def muted(self, text: str, indent: int = 0): """Display muted text.""" self.message(text, message_type=MessageType.MUTED, indent=indent) def thinking(self, text: str): """Display thinking message.""" self.message(text, message_type=MessageType.THINKING) def thinking_panel(self, content: str): """Display thinking panel.""" self.panel(content, panel_type=PanelType.THINKING) def confirmation_panel(self, content: str): """Display confirmation panel.""" self.panel(content, panel_type=PanelType.CONFIRMATION) def info_panel(self, content, title: str): """Display info panel.""" self.panel(content, title=title, panel_type=PanelType.INFO) def dump(self, data): """Display data in a pretty format. Args: data: Any data structure to display """ self.console.print(Pretty(data)) def help(self): """Display help information.""" commands = [ ("/help", "Show this help message"), ("/yolo", "Toggle tool confirmation prompts"), ("/clear", "Clear conversation history"), ("/model", "List available models"), ("/model ", "Switch to a specific model"), ("/model default", "Set a model as default"), ("/usage", "Show session usage statistics"), ("exit", "Exit the application"), ] table = Table(show_header=False, box=None, padding=(0, 2, 0, 0)) table.add_column("Command", style=colors.primary, no_wrap=True) table.add_column("Description", style="white") for cmd, desc in commands: table.add_row(cmd, desc) self.panel(table, title="Available Commands", panel_type=PanelType.INFO) ================================================ FILE: src/sidekick/ui/special.py ================================================ """Special UI functions that need external dependencies.""" from rich.padding import Padding from rich.text import Text from sidekick.constants import APP_NAME, APP_VERSION, MODELS from sidekick.session import session from sidekick.ui.colors import colors from sidekick.usage import usage_tracker def version(ui_manager): """Display version information.""" ui_manager.console.print(f"{APP_NAME} v{APP_VERSION}", style=colors.muted) def update_available(ui_manager, latest_version: str): """Display update available message.""" ui_manager.muted(f"Update available: {APP_VERSION} → {latest_version}") def usage(ui_manager, usage_data: dict): """Display usage statistics.""" content = Text() content.append("Input: ", style=colors.muted) content.append(f"{usage_data['input_tokens']:,} tokens") if usage_data["cached_tokens"] > 0: content.append(f" ({usage_data['cached_tokens']:,} cached)", style=colors.muted) content.append(" | ", style=colors.muted) content.append("Output: ", style=colors.muted) content.append(f"{usage_data['output_tokens']:,} tokens") content.append(" | ", style=colors.muted) content.append("Cost: ", style=colors.muted) content.append(f"${usage_data['request_cost']:.5f}") if session.current_model and usage_tracker.total_tokens: model_info = MODELS.get(session.current_model) if model_info and "context_window" in model_info: token_limit = model_info["context_window"] if token_limit > 0: remaining_percentage = ( (token_limit - usage_tracker.total_tokens) / token_limit ) * 100 # Ensure percentage doesn't go below 0 remaining_percentage = max(0, remaining_percentage) content.append(" | ", style=colors.muted) content.append(f"{remaining_percentage:.0f}% ") content.append("Context remaining", style=colors.muted) ui_manager.console.print(Padding(content, (0, 0, 0, 2))) ================================================ FILE: src/sidekick/ui/spinner.py ================================================ """Spinner management for the UI module.""" import asyncio import random from typing import Optional from rich.console import Console class SpinnerManager: """Manages spinner state and rotation task.""" _THINKING_MESSAGES = [ "Cracking knuckles...", "Polishing grappling hook...", "Consulting the manual...", "Adjusting utility belt...", "Calibrating gadgets...", "Dusting off cape...", "Sharpening batarangs...", "Pressing buttons...", "Looking busy...", "Doing stretches...", "Putting on thinking mask...", "Running diagnostics...", "Preparing witty comeback...", "Calculating trajectories...", "Donning thinking cape...", ] def __init__(self, console: Console): self.console = console self.spinner = None self.rotation_task: Optional[asyncio.Task] = None def _get_thinking_message(self) -> str: """Get a random thinking message.""" return random.choice(self._THINKING_MESSAGES) async def _rotate_messages(self, style: str, interval: float = 5.0): """Rotate thinking messages at specified interval.""" while True: try: await asyncio.sleep(interval) if self.spinner: message = self._get_thinking_message() formatted_message = style.format(message) self.spinner.update(formatted_message) except asyncio.CancelledError: break except Exception: break def start(self, message: str = "", style: str = None): """Start the spinner with a message.""" if self.spinner: # Spinner already running, just update the message if message == "": message = self._get_thinking_message() if style: formatted_message = style.format(message) else: formatted_message = message self.spinner.update(formatted_message) return if message == "": message = self._get_thinking_message() if style: formatted_message = style.format(message) else: formatted_message = message self.spinner = self.console.status(formatted_message, spinner="dots") self.spinner.start() if self.rotation_task: self.rotation_task.cancel() if style: self.rotation_task = asyncio.create_task(self._rotate_messages(style)) def stop(self): """Stop the spinner.""" if self.spinner: self.spinner.stop() self.spinner = None if self.rotation_task: self.rotation_task.cancel() self.rotation_task = None ================================================ FILE: src/sidekick/usage.py ================================================ """Usage tracking for model API calls.""" from dataclasses import dataclass, field from typing import Any, Dict, Optional from sidekick.constants import MODELS @dataclass class ModelUsage: """Usage statistics for a specific model.""" requests: int = 0 input_tokens: int = 0 cached_tokens: int = 0 output_tokens: int = 0 total_cost: float = 0.0 def add_usage( self, input_tokens: int, cached_tokens: int, output_tokens: int, cost: float ) -> None: """Add usage data to this model's totals.""" self.requests += 1 self.input_tokens += input_tokens self.cached_tokens += cached_tokens self.output_tokens += output_tokens self.total_cost += cost @dataclass class UsageTracker: """Tracks token usage and costs across multiple models.""" # Usage per model model_usage: Dict[str, ModelUsage] = field(default_factory=dict) # Last request details (for display) last_request: Optional[Dict[str, Any]] = None def record_usage(self, model: str, usage: Any) -> None: """Record usage from a model run. Args: model: Model identifier usage: Usage object from pydantic_ai """ # Extract token counts cached_tokens = 0 if hasattr(usage, "details") and usage.details: for detail in usage.details: if hasattr(detail, "cached_tokens"): cached_tokens += detail.cached_tokens input_tokens = usage.request_tokens non_cached_input = input_tokens - cached_tokens output_tokens = usage.response_tokens # Calculate costs model_ids = list(MODELS.keys()) pricing = MODELS.get(model, MODELS[model_ids[0]])["pricing"] input_cost = non_cached_input / 1_000_000 * pricing["input"] cached_cost = cached_tokens / 1_000_000 * pricing["cached_input"] output_cost = output_tokens / 1_000_000 * pricing["output"] request_cost = input_cost + cached_cost + output_cost # Store usage if model not in self.model_usage: self.model_usage[model] = ModelUsage() self.model_usage[model].add_usage(input_tokens, cached_tokens, output_tokens, request_cost) # Store last request details for display self.last_request = { "model": model, "input_tokens": input_tokens, "cached_tokens": cached_tokens, "output_tokens": output_tokens, "input_cost": input_cost, "cached_cost": cached_cost, "output_cost": output_cost, "request_cost": request_cost, "total_cost": self.total_cost, } @property def total_tokens(self) -> int: """Get total tokens across all models.""" return sum(usage.input_tokens + usage.output_tokens for usage in self.model_usage.values()) @property def total_cost(self) -> float: """Get total cost across all models.""" return sum(usage.total_cost for usage in self.model_usage.values()) @property def total_requests(self) -> int: """Get total requests across all models.""" return sum(usage.requests for usage in self.model_usage.values()) # Global usage tracker instance usage_tracker = UsageTracker() ================================================ FILE: src/sidekick/utils/__init__.py ================================================ ================================================ FILE: src/sidekick/utils/command.py ================================================ """Command parser for extracting individual commands from shell command strings.""" import shlex from typing import List, Set def extract_commands(command_string: str) -> List[str]: """ Extract individual command names from a shell command string. Handles: - Simple commands: "ls -la" -> ["ls"] - Chained commands: "ls && mkdir foo" -> ["ls", "mkdir"] - Piped commands: "ls | grep foo" -> ["ls", "grep"] - Commands with semicolons: "cd /tmp; ls" -> ["cd", "ls"] Args: command_string: The full shell command string Returns: List of command names (without arguments) """ commands = [] # First, we need to handle quoted strings to avoid splitting on separators inside quotes # We'll use a simple state machine to track whether we're inside quotes parts = [] current_part = [] in_single_quote = False in_double_quote = False i = 0 while i < len(command_string): char = command_string[i] # Handle quotes if char == "'" and not in_double_quote: in_single_quote = not in_single_quote current_part.append(char) elif char == '"' and not in_single_quote: in_double_quote = not in_double_quote current_part.append(char) # Handle separators when not in quotes elif not in_single_quote and not in_double_quote: # Check for two-character separators if i + 1 < len(command_string): two_char = command_string[i : i + 2] if two_char in ["&&", "||"]: if current_part: parts.append("".join(current_part)) current_part = [] i += 1 # Skip the second character i += 1 continue # Check for single-character separators if char in [";", "|"]: if current_part: parts.append("".join(current_part)) current_part = [] else: current_part.append(char) else: current_part.append(char) i += 1 # Don't forget the last part if current_part: parts.append("".join(current_part)) # Extract the command name from each part for part in parts: part = part.strip() if not part: continue try: # Use shlex to properly parse the command considering quotes tokens = shlex.split(part) if tokens: # The first token is the command name command = tokens[0] # Remove any path components to get just the command name command = command.split("/")[-1] commands.append(command) except ValueError: # If shlex parsing fails, fall back to simple split tokens = part.split() if tokens: command = tokens[0].split("/")[-1] commands.append(command) return commands def is_command_allowed(command_string: str, allowed_commands: Set[str]) -> bool: """ Check if all commands in a command string are allowed. Args: command_string: The full shell command string allowed_commands: Set of allowed command names Returns: True if all commands are allowed, False otherwise """ commands = extract_commands(command_string) return all(cmd in allowed_commands for cmd in commands) ================================================ FILE: src/sidekick/utils/error.py ================================================ """Simplified error handling for Sidekick CLI.""" import asyncio import re import tempfile import traceback from datetime import datetime from pathlib import Path from typing import Any, Callable, List, Optional from pydantic_ai.exceptions import ModelHTTPError, ModelRetry async def handle_error(error: Exception, display_func) -> None: """Handle any error in the application. Args: error: The exception to handle display_func: Function to display error (typically ui.error) """ message = extract_error_message(error) if should_log_error(error): log_file = save_error_log(error) display_func(message, detail=f"Error log: {log_file}") else: display_func(message) def extract_error_message(error: Exception) -> str: """Extract a clean error message from any exception.""" if isinstance(error, ModelHTTPError): return f"{error.model_name}: {_get_api_message(error)}" error_str = str(error) if "MALFORMED_FUNCTION_CALL" in error_str: return "The AI model had trouble executing a function. Please try again." if "Content field missing" in error_str: return ( "The AI model returned an unexpected response format. This might be a temporary issue." ) error_type = type(error).__name__ module_name = type(error).__module__ if hasattr(type(error), "__module__") else "" if error_type in ["ClientError", "APIStatusError", "BadRequestError", "AuthenticationError"]: clean_msg = _extract_provider_message(error) if clean_msg: provider = _get_provider_name(module_name) return f"{provider}: {clean_msg}" if len(error_str) > 150: message_match = re.search( r'["\']?message["\']?:\s*["\']([^"\'\n]+)["\']', error_str, re.IGNORECASE ) if message_match: error_str = message_match.group(1) else: error_str = error_str[:150] + "..." return f"Unexpected error ({error_type}): {error_str}" def should_log_error(error: Exception) -> bool: """Determine if error should be logged to file.""" known_errors = (asyncio.CancelledError, KeyboardInterrupt, ModelHTTPError) return not isinstance(error, known_errors) def save_error_log(error: Exception) -> Path: """Save error traceback to a temp file and return the path.""" timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") temp_dir = Path(tempfile.gettempdir()) log_file = temp_dir / f"sidekick_error_{timestamp}.log" tb = traceback.format_exc() with open(log_file, "w") as f: f.write("Sidekick Error Log\n") f.write("==================\n\n") f.write(f"Timestamp: {datetime.now().isoformat()}\n") f.write(f"Error Type: {type(error).__name__}\n") module = type(error).__module__ if hasattr(type(error), "__module__") else "Unknown" f.write(f"Error Module: {module}\n\n") f.write(f"Error Message:\n{str(error)}\n\n") f.write(f"Full Traceback:\n{tb}") return log_file def _get_api_message(error: ModelHTTPError) -> str: """Extract API error message from ModelHTTPError.""" error_msg = str(error) if isinstance(error.body, dict): # Try to extract message from common error structures if "error" in error.body and isinstance(error.body["error"], dict): error_msg = error.body["error"].get("message", str(error)) elif "message" in error.body: error_msg = error.body["message"] return error_msg def _extract_provider_message(error: Exception) -> str: """Extract clean message from provider-specific errors.""" if hasattr(error, "message"): return error.message elif hasattr(error, "body") and isinstance(error.body, dict): if "error" in error.body and isinstance(error.body["error"], dict): return error.body["error"].get("message", "") elif "message" in error.body: return error.body["message"] elif hasattr(error, "details") and isinstance(error.details, dict): if "error" in error.details and isinstance(error.details["error"], dict): return error.details["error"].get("message", "") elif "message" in error.details: return error.details["message"] return "" def _get_provider_name(module_name: str) -> str: """Get provider name from module name.""" if "openai" in module_name: return "OpenAI" elif "anthropic" in module_name: return "Anthropic" elif "google" in module_name or "genai" in module_name: return "Google" return "Provider" class ErrorContext: """Context for error handling with cleanup callbacks.""" def __init__(self, operation: str, ui: Any): self.operation = operation self.ui = ui self.cleanup_callbacks: List[Callable] = [] def add_cleanup(self, callback: Callable) -> None: self.cleanup_callbacks.append(callback) async def handle(self, error: Exception) -> Optional[Any]: """Handle error with context-specific cleanup.""" if isinstance(error, ModelRetry): raise error self.ui.stop_spinner() for callback in self.cleanup_callbacks: if asyncio.iscoroutinefunction(callback): await callback() else: callback(error) if isinstance(error, asyncio.CancelledError): return None await handle_error(error, self.ui.error_panel) return None ================================================ FILE: src/sidekick/utils/guide.py ================================================ from pathlib import Path def load_guide(): """Load the project guide from SIDEKICK.md if it exists.""" guide_path = Path.cwd() / "SIDEKICK.md" if guide_path.exists(): return guide_path.read_text(encoding="utf-8").strip() return None ================================================ FILE: src/sidekick/utils/input.py ================================================ """Input utilities for the Sidekick CLI.""" from typing import Optional from prompt_toolkit import PromptSession from prompt_toolkit.formatted_text import HTML, to_formatted_text from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.styles import Style # Note: We don't import colors from ui module because prompt_toolkit # uses a different color system than Rich PROMPT_SYMBOL = "λ " PROMPT_CONTINUATION_INDENT = " " # Same width as prompt symbol PLACEHOLDER_TEXT = "Esc+Enter to submit, /help for commands" PLACEHOLDER_STYLE = "italic fg:#666666" def create_multiline_keybindings() -> KeyBindings: """Create key bindings for multiline input. Returns: KeyBindings configured for ESC+Enter to submit. """ bindings = KeyBindings() @bindings.add("escape", "enter") def _handle_multiline_submit(event): """Accept the buffer on ESC+Enter.""" event.current_buffer.validate_and_handle() return bindings def create_prompt_style() -> Style: """Create consistent style for prompts. Returns: Style dictionary for prompt_toolkit. """ return Style.from_dict( { "placeholder": PLACEHOLDER_STYLE, } ) def prompt_continuation(width: int, line_number: int, is_soft_wrap: bool) -> str: """Provide continuation prompt for multiline input. Args: width: Terminal width (unused) line_number: Current line number (unused) is_soft_wrap: Whether this is a soft wrap (unused) Returns: Continuation prompt string. """ return PROMPT_CONTINUATION_INDENT def create_multiline_prompt_session() -> PromptSession: """Create a configured prompt session for multiline input. Returns: PromptSession configured for multiline input with ESC+Enter submission. """ placeholder_tokens = [("class:placeholder", PLACEHOLDER_TEXT)] placeholder_ft = to_formatted_text(placeholder_tokens) prompt_html = HTML(f"{PROMPT_SYMBOL}") return PromptSession( message=prompt_html, style=create_prompt_style(), placeholder=placeholder_ft, key_bindings=create_multiline_keybindings(), multiline=True, prompt_continuation=prompt_continuation, ) async def get_multiline_input(session: Optional[PromptSession] = None) -> str: """Get multiline input from the user. Args: session: Optional existing PromptSession. If not provided, a new one is created. Returns: The user's input, stripped of leading/trailing whitespace. Raises: EOFError: When Ctrl+D is pressed KeyboardInterrupt: When Ctrl+C is pressed """ if session is None: session = create_multiline_prompt_session() return (await session.prompt_async()).strip() ================================================ FILE: src/sidekick/utils/logger.py ================================================ """Debug logging configuration for Sidekick CLI.""" import logging from sidekick import ui class UILogHandler(logging.Handler): """A logging handler that outputs messages to the UI's muted function.""" def emit(self, record): # Only manipulate spinner if one is active # The spinner manager will handle the state internally from sidekick.ui.core import _spinner_manager spinner_was_active = _spinner_manager.spinner is not None if spinner_was_active: ui.stop_spinner() ui.muted(self.format(record)) if spinner_was_active: ui.start_spinner() def _is_allowed_module(name: str) -> bool: """Check if a logger name is from an allowed module.""" # allowed = ["sidekick", "openai", "google_genai", "anthropic", "pydantic_ai"] allowed = ["sidekick"] return any(name == module or name.startswith(module + ".") for module in allowed) def setup_logging(debug_enabled: bool): """Configure logging for debug mode or disable completely.""" if debug_enabled: logging.root.setLevel(logging.DEBUG) handler = UILogHandler() handler.setFormatter(logging.Formatter("⚙︎ %(levelname)s (%(name)s): %(message)s")) handler.addFilter(lambda record: _is_allowed_module(record.name)) logging.root.addHandler(handler) else: logging.disable(logging.CRITICAL) ================================================ FILE: tests/__init__.py ================================================ # Test package for Sidekick CLI ================================================ FILE: tests/agent/__init__.py ================================================ ================================================ FILE: tests/agent/test_process_node.py ================================================ from unittest.mock import Mock, patch import pytest from pydantic_ai import messages from sidekick.agent import _process_node @pytest.mark.asyncio async def test_process_node_with_request(): """Test processing a node with a request.""" node = Mock() node.request = Mock(spec=messages.ModelRequest) node.request.parts = [] delattr(node, "model_response") mock_message_history = Mock() await _process_node(node, mock_message_history) mock_message_history.add_request.assert_called_once_with(node.request) @pytest.mark.asyncio async def test_process_node_with_model_response_no_tools(): """Test processing a node with model response but no tool calls.""" node = Mock() delattr(node, "request") text_part = Mock(spec=messages.TextPart) text_part.part_kind = "text" node.model_response = Mock(spec=messages.ModelResponse) node.model_response.parts = [text_part] mock_message_history = Mock() await _process_node(node, mock_message_history) mock_message_history.add_response.assert_called_once_with(node.model_response) @pytest.mark.asyncio async def test_process_node_with_tool_call(): """Test processing a node with tool call.""" node = Mock() delattr(node, "request") tool_call = Mock(spec=messages.ToolCallPart) tool_call.part_kind = "tool-call" tool_call.tool_name = "test_tool" tool_call.tool_call_id = "test_123" tool_call.args_as_dict = Mock(return_value={"arg1": "value1"}) node.model_response = Mock(spec=messages.ModelResponse) node.model_response.parts = [tool_call] mock_message_history = Mock() await _process_node(node, mock_message_history) mock_message_history.add_response.assert_called_once_with(node.model_response) @pytest.mark.asyncio async def test_process_node_with_tool_return(): """Test processing a node with tool return.""" node = Mock() delattr(node, "model_response") tool_return = Mock() tool_return.part_kind = "tool-return" tool_return.tool_call_id = "test_123" node.request = Mock(spec=messages.ModelRequest) node.request.parts = [tool_return] mock_message_history = Mock() await _process_node(node, mock_message_history) mock_message_history.add_request.assert_called_once_with(node.request) @pytest.mark.asyncio async def test_process_node_with_retry_prompt(): """Test processing a node with retry prompt.""" node = Mock() delattr(node, "model_response") retry_part = Mock() retry_part.part_kind = "retry-prompt" retry_part.content = "Trying a different approach" node.request = Mock(spec=messages.ModelRequest) node.request.parts = [retry_part] mock_message_history = Mock() with patch("sidekick.agent.ui.muted") as mock_muted: await _process_node(node, mock_message_history) mock_muted.assert_called_once_with("Trying a different approach") ================================================ FILE: tests/commands/__init__.py ================================================ ================================================ FILE: tests/commands/test_handle_command.py ================================================ """Command handler routing tests (DRY parametrised).""" from unittest.mock import AsyncMock import pytest from sidekick.commands import handle_command @pytest.mark.parametrize( "user_input, patch_target, expected_args", [ ("/dump", "handle_dump", [None]), ("/clear", "handle_clear", [None]), ("/yolo", "handle_yolo", []), ("/model 2", "handle_model", [["2"]]), ], ) @pytest.mark.asyncio async def test_handle_command_routes(monkeypatch, user_input, patch_target, expected_args): mock_func = AsyncMock() monkeypatch.setattr(f"sidekick.commands.{patch_target}", mock_func) result = await handle_command(user_input) assert result is True call_args = mock_func.call_args[0] if expected_args: if len(expected_args) == 1 and isinstance(expected_args[0], list): assert call_args[0] == expected_args[0] else: assert list(call_args) == expected_args else: assert call_args == () @pytest.mark.asyncio async def test_handle_command_non_command(): """Input without leading slash should not be treated as command.""" assert await handle_command("not a command") is False @pytest.mark.asyncio async def test_handle_command_unknown(): """Unknown command string should return True and display error.""" assert await handle_command("/unknown") is True ================================================ FILE: tests/commands/test_handle_dump.py ================================================ """Test /dump command handler.""" from unittest.mock import Mock, patch import pytest from sidekick.commands import handle_dump @pytest.mark.asyncio async def test_handle_dump_writes_to_file_and_pretty_prints(mock_ui, tmp_path): """Test /dump command writes message history to dump.log and pretty prints it.""" temp_dump_file = tmp_path / "dump.log" mock_message_history = Mock() mock_message_history.__iter__ = Mock( return_value=iter( [ {"role": "user", "content": "hello"}, {"role": "agent", "content": "hi"}, ] ) ) with ( patch("sidekick.commands.dump.ui", mock_ui), patch("sidekick.commands.dump.DUMP_FILE_PATH", str(temp_dump_file)), ): await handle_dump(mock_message_history) mock_ui.success.assert_called_once_with(f"Message history dumped to {temp_dump_file}") assert temp_dump_file.exists() content = temp_dump_file.read_text() assert "Message #0 - Type: dict" in content assert "Message #1 - Type: dict" in content assert "'role': 'user'" in content assert "'content': 'hello'" in content assert "'role': 'agent'" in content assert "'content': 'hi'" in content assert "=" * 80 in content ================================================ FILE: tests/commands/test_handle_model.py ================================================ """Test /model command handler.""" from unittest.mock import MagicMock, patch import pytest from sidekick.commands import handle_model @pytest.mark.asyncio async def test_handle_model_list(mock_ui, mock_session, mock_models): """Test /model with no args lists available models.""" mock_session.current_model = "model2" with ( patch("sidekick.commands.model.ui", mock_ui), patch("sidekick.commands.model.session", mock_session), patch("sidekick.commands.model.MODELS", mock_models), ): await handle_model([]) mock_ui.info_panel.assert_called_once() @pytest.mark.asyncio async def test_handle_model_switch(mock_ui, mock_session, mock_models): """Test /model switches to selected model.""" with ( patch("sidekick.commands.model.ui", mock_ui), patch("sidekick.commands.model.session", mock_session), patch("sidekick.commands.model.MODELS", mock_models), ): await handle_model(["2"]) assert mock_session.current_model == "model2" mock_ui.info.assert_called_with("Switched to model: model2") @pytest.mark.asyncio async def test_handle_model_invalid_number(mock_ui): """Test /model with invalid number shows error.""" with ( patch("sidekick.commands.model.ui", mock_ui), patch("sidekick.commands.model.MODELS", {"model1": {}, "model2": {}}), ): await handle_model(["5"]) mock_ui.error.assert_called_with("Invalid model number. Choose between 1 and 2") @pytest.mark.asyncio async def test_handle_model_set_default(mock_ui): """Test /model default sets default model in config.""" mock_update = MagicMock() with ( patch("sidekick.commands.model.ui", mock_ui), patch("sidekick.commands.model.MODELS", {"model1": {}, "model2": {}}), patch("sidekick.commands.model.update_config_file", mock_update), ): await handle_model(["2", "default"]) mock_update.assert_called_once_with({"default_model": "model2"}) mock_ui.success.assert_called_with("Set model2 as default model") ================================================ FILE: tests/commands/test_handle_yolo.py ================================================ """Test /yolo command handler.""" from unittest.mock import patch import pytest from sidekick.commands import handle_yolo @pytest.mark.asyncio async def test_handle_yolo_disables_confirmation(mock_ui, mock_session): """Test /yolo toggles confirmation from enabled to disabled.""" mock_session.confirmation_enabled = True with ( patch("sidekick.commands.yolo.ui", mock_ui), patch("sidekick.commands.yolo.session", mock_session), ): await handle_yolo() assert mock_session.confirmation_enabled is False mock_ui.info.assert_called_with("Tool confirmations disabled (YOLO mode)") @pytest.mark.asyncio async def test_handle_yolo_enables_confirmation(mock_ui, mock_session): """Test /yolo toggles confirmation from disabled to enabled.""" mock_session.confirmation_enabled = False with ( patch("sidekick.commands.yolo.ui", mock_ui), patch("sidekick.commands.yolo.session", mock_session), ): await handle_yolo() assert mock_session.confirmation_enabled is True mock_ui.info.assert_called_with("Tool confirmations enabled") ================================================ FILE: tests/config/__init__.py ================================================ # Config tests package ================================================ FILE: tests/config/test_config_exists.py ================================================ """Tests for config_exists function.""" from unittest.mock import patch from sidekick.config import config_exists def test_returns_true_when_exists(): """Test config_exists returns True when file exists.""" with patch("pathlib.Path.exists", return_value=True): assert config_exists() is True def test_returns_false_when_not_exists(): """Test config_exists returns False when file doesn't exist.""" with patch("pathlib.Path.exists", return_value=False): assert config_exists() is False ================================================ FILE: tests/config/test_deep_merge_dicts.py ================================================ from sidekick.config import deep_merge_dicts def test_simple_merge(): """Test merging simple dictionaries.""" base = {"a": 1, "b": 2} update = {"b": 3, "c": 4} result = deep_merge_dicts(base, update) assert result == {"a": 1, "b": 3, "c": 4} def test_nested_dict_merge(): """Test merging nested dictionaries.""" base = {"settings": {"allowed_tools": ["read_file"], "allowed_commands": ["ls", "cat"]}} update = {"settings": {"allowed_commands": ["grep", "pwd"], "custom_field": "value"}} result = deep_merge_dicts(base, update) assert result == { "settings": { "allowed_tools": ["read_file"], "allowed_commands": ["grep", "pwd"], "custom_field": "value", } } def test_deep_nested_merge(): """Test merging deeply nested structures.""" base = {"a": {"b": {"c": 1, "d": 2}, "e": 3}} update = {"a": {"b": {"c": 10, "f": 4}, "g": 5}} result = deep_merge_dicts(base, update) assert result == {"a": {"b": {"c": 10, "d": 2, "f": 4}, "e": 3, "g": 5}} def test_list_override(): """Test that lists are overridden, not merged.""" base = {"items": [1, 2, 3]} update = {"items": [4, 5]} result = deep_merge_dicts(base, update) assert result == {"items": [4, 5]} def test_mixed_types_override(): """Test that mixed types result in override.""" base = {"field": {"nested": "value"}} update = {"field": "string"} result = deep_merge_dicts(base, update) assert result == {"field": "string"} def test_empty_dicts(): """Test merging with empty dictionaries.""" assert deep_merge_dicts({}, {}) == {} assert deep_merge_dicts({"a": 1}, {}) == {"a": 1} assert deep_merge_dicts({}, {"b": 2}) == {"b": 2} def test_none_values(): """Test handling of None values.""" base = {"a": 1, "b": None} update = {"b": 2, "c": None} result = deep_merge_dicts(base, update) assert result == {"a": 1, "b": 2, "c": None} def test_preserves_update_values(): """Test that update values always take precedence.""" base = {"env": {"API_KEY": "default-key", "OTHER_KEY": "default-other"}} update = {"env": {"API_KEY": "user-key"}} result = deep_merge_dicts(base, update) assert result["env"]["API_KEY"] == "user-key" assert result["env"]["OTHER_KEY"] == "default-other" ================================================ FILE: tests/config/test_ensure_config_structure.py ================================================ import json import tempfile import time from pathlib import Path from unittest.mock import patch import pytest from src.sidekick.config import ConfigError, ensure_config_structure from src.sidekick.constants import DEFAULT_USER_CONFIG def test_preserves_user_settings(): """Test that existing user settings are preserved.""" user_config = { "default_model": "gpt-4o", "env": {"OPENAI_API_KEY": "sk-user123", "CUSTOM_KEY": "custom-value"}, "settings": { "allowed_tools": ["bash", "write_file"], "allowed_commands": ["rm", "mv", "cp"], }, "mcpServers": {"myserver": {"command": "node", "args": ["server.js"]}}, } with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump(user_config, f, indent=2) temp_path = Path(f.name) try: with patch("src.sidekick.config.get_config_path", return_value=temp_path): result = ensure_config_structure() # User values should be preserved assert result["default_model"] == "gpt-4o" assert result["env"]["OPENAI_API_KEY"] == "sk-user123" assert result["env"]["CUSTOM_KEY"] == "custom-value" assert result["settings"]["allowed_tools"] == ["bash", "write_file"] assert result["settings"]["allowed_commands"] == ["rm", "mv", "cp"] assert "myserver" in result["mcpServers"] # Default values should still be present for missing keys assert "ANTHROPIC_API_KEY" in result["env"] assert "GEMINI_API_KEY" in result["env"] finally: temp_path.unlink() def test_adds_missing_defaults(): """Test that missing fields are added with defaults.""" minimal_config = { "default_model": "claude-3-5-sonnet", "env": {"ANTHROPIC_API_KEY": "sk-ant123"}, } with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump(minimal_config, f, indent=2) temp_path = Path(f.name) try: with patch("src.sidekick.config.get_config_path", return_value=temp_path): result = ensure_config_structure() # User values preserved assert result["default_model"] == "claude-3-5-sonnet" assert result["env"]["ANTHROPIC_API_KEY"] == "sk-ant123" # Missing fields added assert "mcpServers" in result assert result["mcpServers"] == {} assert "settings" in result assert ( result["settings"]["allowed_tools"] == DEFAULT_USER_CONFIG["settings"]["allowed_tools"] ) assert len(result["settings"]["allowed_commands"]) > 0 assert "ls" in result["settings"]["allowed_commands"] # Verify file was updated with open(temp_path) as f: file_content = json.load(f) assert "settings" in file_content assert "mcpServers" in file_content finally: temp_path.unlink() def test_does_not_add_tool_ignore_field(): """Test that tool_ignore is not added to config when updating.""" config_with_legacy = { "default_model": "gpt-4o", "env": {"OPENAI_API_KEY": "sk-123"}, "settings": {"tool_ignore": ["bash", "write_file"]}, } with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump(config_with_legacy, f, indent=2) temp_path = Path(f.name) try: with patch("src.sidekick.config.get_config_path", return_value=temp_path): result = ensure_config_structure() # Should have allowed_tools from defaults assert "allowed_tools" in result["settings"] # Should still have tool_ignore (preserved, not removed) assert "tool_ignore" in result["settings"] assert result["settings"]["tool_ignore"] == ["bash", "write_file"] # Verify the file still has tool_ignore with open(temp_path) as f: file_content = json.load(f) assert file_content["settings"]["tool_ignore"] == ["bash", "write_file"] finally: temp_path.unlink() def test_empty_config_gets_full_defaults(): """Test that an empty config gets all defaults.""" empty_config = {} with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump(empty_config, f, indent=2) temp_path = Path(f.name) try: with patch("src.sidekick.config.get_config_path", return_value=temp_path): # This should fail validation before ensure_config_structure is called # but let's test the merge behavior anyway with patch("src.sidekick.config.validate_config_structure"): result = ensure_config_structure() # Should have all defaults assert result == DEFAULT_USER_CONFIG finally: temp_path.unlink() def test_no_file_update_when_no_changes(): """Test that file is not rewritten when no changes are needed.""" # Config that already has all expected fields complete_config = { "default_model": "claude-3-5-sonnet-20241022", "env": { "ANTHROPIC_API_KEY": "your-anthropic-api-key", "OPENAI_API_KEY": "your-openai-api-key", "GEMINI_API_KEY": "your-gemini-api-key", }, "mcpServers": {}, "settings": { "allowed_tools": ["read_file"], "allowed_commands": [ "ls", "cat", "rg", "find", "pwd", "echo", "which", "head", "tail", "wc", "sort", "uniq", "diff", "tree", "file", "stat", "du", "df", "ps", "top", "env", "date", "whoami", "hostname", "uname", "id", "groups", "history", ], }, } with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump(complete_config, f, indent=2) temp_path = Path(f.name) # Small delay to ensure different mtime if file is modified time.sleep(0.01) original_mtime = temp_path.stat().st_mtime try: with patch("src.sidekick.config.get_config_path", return_value=temp_path): result = ensure_config_structure() # Should return the same config assert result == complete_config # File should not have been modified assert temp_path.stat().st_mtime == original_mtime finally: temp_path.unlink() def test_raises_config_error_on_missing_file(): """Test that ConfigError is raised when config file doesn't exist.""" non_existent_path = Path("/tmp/does_not_exist_12345.json") with patch("src.sidekick.config.get_config_path", return_value=non_existent_path): with pytest.raises(ConfigError): ensure_config_structure() ================================================ FILE: tests/config/test_get_config_path.py ================================================ """Tests for get_config_path function.""" from pathlib import Path from sidekick.config import get_config_path def test_returns_correct_path(): """Test that get_config_path returns the expected path.""" expected = Path.home() / ".config" / "sidekick.json" assert get_config_path() == expected ================================================ FILE: tests/config/test_parse_mcp_servers.py ================================================ """Tests for parse_mcp_servers function.""" import pytest from sidekick.config import ConfigValidationError, parse_mcp_servers def test_returns_empty_dict_when_no_mcp_servers(): """Test returns empty dict when mcpServers not present.""" config = {"default_model": "test", "env": {}} assert parse_mcp_servers(config) == {} def test_returns_valid_mcp_servers(): """Test returns MCP servers when valid.""" config = {"mcpServers": {"fetch": {"command": "uvx", "args": ["mcp-server-fetch"]}}} result = parse_mcp_servers(config) assert result == config["mcpServers"] def test_accepts_server_with_name_field(): """Test accepts server config with optional name field.""" config = { "mcpServers": { "fetch": {"command": "uvx", "args": ["mcp-server-fetch"], "name": "Fetch Server"} } } result = parse_mcp_servers(config) assert result == config["mcpServers"] def test_accepts_server_with_env(): """Test accepts server config with env vars.""" config = { "mcpServers": { "brave": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-brave-search"], "env": {"BRAVE_API_KEY": "test-key"}, } } } result = parse_mcp_servers(config) assert result == config["mcpServers"] def test_raises_for_non_dict_mcp_servers(): """Test ConfigValidationError when mcpServers not dict.""" with pytest.raises(ConfigValidationError) as exc_info: parse_mcp_servers({"mcpServers": "not a dict"}) assert "'mcpServers' field must be an object" in str(exc_info.value) def test_raises_for_invalid_server_config(): """Test ConfigValidationError for invalid server config.""" with pytest.raises(ConfigValidationError) as exc_info: parse_mcp_servers({"mcpServers": {"fetch": "not a dict"}}) assert "MCP server 'fetch' configuration must be an object" in str(exc_info.value) def test_raises_for_missing_command(): """Test ConfigValidationError when command missing.""" with pytest.raises(ConfigValidationError) as exc_info: parse_mcp_servers({"mcpServers": {"fetch": {"args": []}}}) assert "MCP server 'fetch' missing required field 'command'" in str(exc_info.value) def test_raises_for_missing_args(): """Test ConfigValidationError when args missing.""" with pytest.raises(ConfigValidationError) as exc_info: parse_mcp_servers({"mcpServers": {"fetch": {"command": "uvx"}}}) assert "MCP server 'fetch' missing required field 'args'" in str(exc_info.value) def test_raises_for_non_string_command(): """Test ConfigValidationError when command not string.""" with pytest.raises(ConfigValidationError) as exc_info: parse_mcp_servers({"mcpServers": {"fetch": {"command": 123}}}) assert "MCP server 'fetch' field 'command' must be a string" in str(exc_info.value) def test_raises_for_non_list_args(): """Test ConfigValidationError when args not list.""" with pytest.raises(ConfigValidationError) as exc_info: parse_mcp_servers({"mcpServers": {"fetch": {"command": "uvx", "args": "not a list"}}}) assert "MCP server 'fetch' field 'args' must be an array" in str(exc_info.value) def test_raises_for_empty_args(): """Test ConfigValidationError when args is empty list.""" with pytest.raises(ConfigValidationError) as exc_info: parse_mcp_servers({"mcpServers": {"fetch": {"command": "uvx", "args": []}}}) assert "MCP server 'fetch' field 'args' must contain at least one argument" in str( exc_info.value ) def test_raises_for_non_dict_env(): """Test ConfigValidationError when env not dict.""" with pytest.raises(ConfigValidationError) as exc_info: parse_mcp_servers( { "mcpServers": { "fetch": {"command": "uvx", "args": ["mcp-server-fetch"], "env": "not a dict"} } } ) assert "MCP server 'fetch' field 'env' must be an object" in str(exc_info.value) ================================================ FILE: tests/config/test_read_config_file.py ================================================ """Tests for read_config_file function.""" from unittest.mock import mock_open, patch import pytest from sidekick.config import ConfigError, ConfigValidationError, read_config_file def test_reads_valid_json(): """Test reading valid JSON config.""" mock_json = '{"default_model": "test-model", "env": {}}' with patch("pathlib.Path.exists", return_value=True): with patch("builtins.open", mock_open(read_data=mock_json)): config = read_config_file() assert config == {"default_model": "test-model", "env": {}} def test_raises_file_not_found(): """Test ConfigError when config doesn't exist.""" with patch("pathlib.Path.exists", return_value=False): with pytest.raises(ConfigError) as exc_info: read_config_file() assert "Config file not found" in str(exc_info.value) def test_raises_permission_error(): """Test ConfigError when can't access file.""" with patch("pathlib.Path.exists", return_value=True): with patch("builtins.open", side_effect=PermissionError("Access denied")): with pytest.raises(ConfigError) as exc_info: read_config_file() assert "Cannot access config file" in str(exc_info.value) def test_raises_json_decode_error(): """Test ConfigValidationError for invalid JSON.""" with patch("pathlib.Path.exists", return_value=True): with patch("builtins.open", mock_open(read_data="invalid json")): with pytest.raises(ConfigValidationError): read_config_file() ================================================ FILE: tests/config/test_set_env_vars.py ================================================ """Tests for set_env_vars function.""" import os from unittest.mock import patch from sidekick.config import set_env_vars def test_sets_string_env_vars(): """Test that string environment variables are set.""" env_dict = {"API_KEY": "test-key", "ANOTHER_VAR": "test-value"} with patch.dict(os.environ, {}, clear=True): set_env_vars(env_dict) assert os.environ.get("API_KEY") == "test-key" assert os.environ.get("ANOTHER_VAR") == "test-value" def test_skips_empty_values(): """Test that empty string values are skipped.""" env_dict = {"API_KEY": "test-key", "EMPTY_VAR": ""} with patch.dict(os.environ, {}, clear=True): set_env_vars(env_dict) assert os.environ.get("API_KEY") == "test-key" assert "EMPTY_VAR" not in os.environ def test_skips_non_string_values(): """Test that non-string values are skipped.""" env_dict = {"API_KEY": "test-key", "NUMBER_VAR": 123, "BOOL_VAR": True, "NONE_VAR": None} with patch.dict(os.environ, {}, clear=True): set_env_vars(env_dict) assert os.environ.get("API_KEY") == "test-key" assert "NUMBER_VAR" not in os.environ assert "BOOL_VAR" not in os.environ assert "NONE_VAR" not in os.environ def test_handles_empty_dict(): """Test that empty dict is handled gracefully.""" with patch.dict(os.environ, {"EXISTING": "value"}, clear=True): set_env_vars({}) # Should not change existing env assert os.environ.get("EXISTING") == "value" ================================================ FILE: tests/config/test_update_config_file.py ================================================ """Test update_config_file function.""" import json import tempfile from pathlib import Path from unittest.mock import patch import pytest from sidekick.config import ConfigError, update_config_file def test_update_config_file_success(): """Test successful config update.""" with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: config = {"default_model": "old-model", "env": {"KEY": "value"}} json.dump(config, f) temp_path = Path(f.name) try: with patch("sidekick.config.get_config_path", return_value=temp_path): update_config_file({"default_model": "new-model"}) # Read back the updated config with open(temp_path) as f: updated = json.load(f) assert updated["default_model"] == "new-model" assert updated["env"]["KEY"] == "value" # Other fields preserved finally: temp_path.unlink() def test_update_config_file_no_config(): """Test update when config doesn't exist.""" with patch("sidekick.config.get_config_path", return_value=Path("/nonexistent/path")): with pytest.raises(ConfigError, match="Config file not found"): update_config_file({"default_model": "new-model"}) def test_update_config_file_merge_nested(): """Test that nested dicts are merged, not replaced.""" with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: config = {"env": {"KEY1": "value1", "KEY2": "value2"}} json.dump(config, f) temp_path = Path(f.name) try: with patch("sidekick.config.get_config_path", return_value=temp_path): update_config_file({"env": {"KEY2": "updated", "KEY3": "new"}}) with open(temp_path) as f: updated = json.load(f) assert updated["env"]["KEY1"] == "value1" # Preserved assert updated["env"]["KEY2"] == "updated" # Updated assert updated["env"]["KEY3"] == "new" # Added finally: temp_path.unlink() ================================================ FILE: tests/config/test_validate_config_structure.py ================================================ """Tests for validate_config_structure function.""" import pytest from sidekick.config import ConfigValidationError, validate_config_structure def test_valid_config_passes(): """Test that valid config passes validation.""" config = {"default_model": "test-model", "env": {"API_KEY": "test"}} # Should not raise validate_config_structure(config) def test_valid_config_with_empty_env(): """Test valid config with empty env dict.""" config = {"default_model": "test-model", "env": {}} # Should not raise validate_config_structure(config) def test_raises_for_non_dict(): """Test ConfigValidationError for non-dict config.""" with pytest.raises(ConfigValidationError) as exc_info: validate_config_structure("not a dict") assert "Config must be a JSON object" in str(exc_info.value) def test_raises_for_missing_default_model(): """Test ConfigValidationError when default_model missing.""" with pytest.raises(ConfigValidationError) as exc_info: validate_config_structure({"env": {}}) assert "Config missing required field 'default_model'" in str(exc_info.value) def test_raises_for_non_string_default_model(): """Test ConfigValidationError when default_model not string.""" with pytest.raises(ConfigValidationError) as exc_info: validate_config_structure({"default_model": 123, "env": {}}) assert "'default_model' must be a string" in str(exc_info.value) def test_raises_for_missing_env(): """Test ConfigValidationError when env field missing.""" with pytest.raises(ConfigValidationError) as exc_info: validate_config_structure({"default_model": "test-model"}) assert "Config missing required field 'env'" in str(exc_info.value) def test_raises_for_non_dict_env(): """Test ConfigValidationError when env is not a dict.""" with pytest.raises(ConfigValidationError) as exc_info: validate_config_structure({"default_model": "test", "env": "not a dict"}) assert "'env' field must be an object" in str(exc_info.value) ================================================ FILE: tests/conftest.py ================================================ """Shared fixtures and helpers for tests.""" from unittest.mock import AsyncMock, MagicMock, Mock import pytest from pydantic_ai import messages # --------------------------------------------------------------------------- # Generic mocks # --------------------------------------------------------------------------- @pytest.fixture def mock_ui(): """Return a mocked *ui* module/object.""" return MagicMock() @pytest.fixture def mock_session(): """Return a mocked *session* object.""" return MagicMock() @pytest.fixture def mock_models(): """Return a simple models mapping for tests that need it.""" return {"model1": {}, "model2": {}, "model3": {}} @pytest.fixture def mock_config(): """Return a minimal config dict.""" return { "default_model": "claude-3-5-sonnet", "env": {}, "settings": {}, } @pytest.fixture def mock_usage(): """Return a usage-like object with default numbers (can be tweaked inside tests).""" usage = MagicMock() usage.requests = 1 usage.request_tokens = 1000 usage.response_tokens = 500 usage.total_tokens = 1500 usage.details = [] return usage # --------------------------------------------------------------------------- # Helper / factory fixtures # --------------------------------------------------------------------------- @pytest.fixture def make_mock_process(): """Factory to create an *asyncio* subprocess mock with the given stdout/stderr/returncode.""" def _factory(stdout: str = "", stderr: str = "", returncode: int = 0): proc = AsyncMock() proc.communicate = AsyncMock(return_value=(stdout.encode(), stderr.encode())) proc.returncode = returncode return proc return _factory @pytest.fixture def make_tool_call(): """Factory that produces a *messages.ToolCallPart*-like mock used in agent tests.""" def _factory(name: str = "test_tool", call_id: str = "call_id"): tc = Mock(spec=messages.ToolCallPart) tc.part_kind = "tool-call" tc.tool_name = name tc.tool_call_id = call_id return tc return _factory # --------------------------------------------------------------------------- # Composite fixtures for common patching scenarios # --------------------------------------------------------------------------- @pytest.fixture def patched_commands_env(monkeypatch, mock_ui, mock_session): """Patch *sidekick.commands.ui* and *.session* once for a whole test function.""" import sidekick.commands as _cmd monkeypatch.setattr(_cmd, "ui", mock_ui) monkeypatch.setattr(_cmd, "session", mock_session) yield ================================================ FILE: tests/main/test_error_handling.py ================================================ """Tests for error handling integration in the REPL.""" import asyncio from unittest.mock import MagicMock, patch import pytest from pydantic_ai.exceptions import ModelHTTPError from sidekick.repl import Repl @pytest.fixture def mock_repl(): """Fixture to create a mock Repl instance for testing.""" with patch("signal.signal"): repl = Repl(project_guide=None) return repl @pytest.mark.asyncio async def test_handle_user_request_with_error(mock_repl): """Test that _handle_user_request properly calls the error handler.""" mock_ui = MagicMock() mock_session = MagicMock(sigint_received=False, current_task=None) with patch("sidekick.repl.process_request") as mock_process: mock_process.side_effect = ValueError("Test error") with patch("sidekick.repl.ui", mock_ui), patch("sidekick.repl.session", mock_session): await mock_repl._handle_user_request("test input") mock_ui.stop_spinner.assert_called() mock_ui.error_panel.assert_called_once() call_args = mock_ui.error_panel.call_args assert "ValueError" in call_args[0][0] assert "Test error" in call_args[0][0] assert "detail" in call_args[1] assert "Error log:" in call_args[1]["detail"] @pytest.mark.asyncio async def test_handle_user_request_with_model_http_error(mock_repl): """Test that ModelHTTPError doesn't create a log file.""" mock_ui = MagicMock() mock_session = MagicMock(sigint_received=False, current_task=None) with patch("sidekick.repl.process_request") as mock_process: error = ModelHTTPError( status_code=400, model_name="test-model", body={"error": {"message": "Bad request"}} ) mock_process.side_effect = error with patch("sidekick.repl.ui", mock_ui), patch("sidekick.repl.session", mock_session): await mock_repl._handle_user_request("test input") mock_ui.error_panel.assert_called_once_with("test-model: Bad request") @pytest.mark.asyncio async def test_handle_user_request_success(mock_repl): """Test that a successful request doesn't trigger error handling.""" mock_ui = MagicMock() mock_session = MagicMock(sigint_received=False, current_task=None, last_usage=None) with patch("sidekick.repl.process_request") as mock_process: mock_process.return_value = "Success response" with patch("sidekick.repl.ui", mock_ui), patch("sidekick.repl.session", mock_session): await mock_repl._handle_user_request("test input") mock_ui.error_panel.assert_not_called() mock_ui.agent.assert_called_once_with("Success response", has_footer=False) @pytest.mark.asyncio async def test_handle_user_request_cancellation(mock_repl): """Test that cancellation is handled gracefully without error logging.""" mock_ui = MagicMock() mock_session = MagicMock( sigint_received=False, current_task=None, current_model="test-model", ) with patch("sidekick.repl.process_request") as mock_process: mock_process.side_effect = asyncio.CancelledError() with patch("sidekick.repl.ui", mock_ui), patch("sidekick.repl.session", mock_session): await mock_repl._handle_user_request("test input") mock_ui.error_panel.assert_not_called() ================================================ FILE: tests/mcp/__init__.py ================================================ # MCP tests package ================================================ FILE: tests/mcp/test_create_mcp_server.py ================================================ """Tests for create_mcp_server function.""" from sidekick.mcp.servers import SilentMCPServerStdio, create_mcp_server def test_creates_server_with_minimal_config(): """Test creating server with minimal valid config.""" config = {"command": "uvx", "args": ["mcp-server-fetch"]} server = create_mcp_server("fetch", config) assert isinstance(server, SilentMCPServerStdio) assert server.command == "uvx" assert server.args == ["mcp-server-fetch"] assert server.env == {} assert server.display_name == "Fetch" def test_creates_server_with_custom_name(): """Test creating server with custom name field.""" config = {"command": "uvx", "args": ["mcp-server-fetch"], "name": "Custom Fetch Server"} server = create_mcp_server("fetch", config) assert server.display_name == "Custom Fetch Server" def test_creates_server_with_env_vars(): """Test creating server with environment variables.""" config = { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-brave-search"], "env": {"BRAVE_API_KEY": "test-key"}, } server = create_mcp_server("brave-search", config) assert server.env == {"BRAVE_API_KEY": "test-key"} assert server.display_name == "Brave Search" def test_formats_display_name_from_key(): """Test display name formatting from server key.""" config = {"command": "test", "args": ["arg"]} # Test various key formats server1 = create_mcp_server("simple", config) assert server1.display_name == "Simple" server2 = create_mcp_server("hyphen-name", config) assert server2.display_name == "Hyphen Name" server3 = create_mcp_server("underscore_name", config) assert server3.display_name == "Underscore Name" ================================================ FILE: tests/mcp/test_format_display_name.py ================================================ """Tests for format_server_name function.""" from sidekick.ui import format_server_name def test_converts_simple_lowercase(): """Test conversion of simple lowercase name.""" assert format_server_name("fetch") == "Fetch" def test_converts_hyphenated_names(): """Test conversion of hyphenated names.""" assert format_server_name("brave-search") == "Brave Search" assert format_server_name("mcp-server-fetch") == "MCP Server Fetch" def test_converts_underscored_names(): """Test conversion of underscored names.""" assert format_server_name("brave_search") == "Brave Search" assert format_server_name("mcp_server_fetch") == "MCP Server Fetch" def test_converts_mixed_separators(): """Test conversion with mixed separators.""" assert format_server_name("brave-search_api") == "Brave Search API" assert format_server_name("mcp_server-fetch") == "MCP Server Fetch" def test_handles_already_capitalized(): """Test handling of already capitalized input.""" assert format_server_name("FETCH") == "Fetch" assert format_server_name("Brave-Search") == "Brave Search" def test_handles_empty_string(): """Test handling of empty string.""" assert format_server_name("") == "" ================================================ FILE: tests/mcp/test_load_mcp_servers.py ================================================ """Tests for load_mcp_servers function.""" import logging from unittest.mock import patch from sidekick.config import ConfigError from sidekick.mcp.servers import load_mcp_servers def test_loads_valid_servers(): """Test loading valid MCP servers from config.""" mock_config = { "default_model": "test", "env": {}, "mcpServers": { "fetch": {"command": "uvx", "args": ["mcp-server-fetch"]}, "brave": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-brave-search"], "env": {"BRAVE_API_KEY": "test"}, }, }, } with patch("sidekick.mcp.servers.read_config_file", return_value=mock_config): servers = load_mcp_servers() assert len(servers) == 2 assert servers[0].display_name == "Fetch" assert servers[1].display_name == "Brave" def test_returns_empty_list_when_no_mcp_servers(): """Test returns empty list when no mcpServers in config.""" mock_config = {"default_model": "test", "env": {}} with patch("sidekick.mcp.servers.read_config_file", return_value=mock_config): servers = load_mcp_servers() assert servers == [] def test_returns_empty_list_on_config_error(): """Test returns empty list when config loading fails.""" with patch("sidekick.mcp.servers.read_config_file", side_effect=ConfigError("Test error")): servers = load_mcp_servers() assert servers == [] def test_handles_server_creation_failure(caplog): """Test handles individual server creation failures gracefully.""" mock_config = { "default_model": "test", "env": {}, "mcpServers": { "valid": {"command": "uvx", "args": ["mcp-server-fetch"]}, "failing": {"command": "test", "args": ["test"]}, }, } with patch("sidekick.mcp.servers.read_config_file", return_value=mock_config): # Make create_mcp_server fail for one server original_create = __import__( "sidekick.mcp.servers", fromlist=["create_mcp_server"] ).create_mcp_server def mock_create(key, config): if key == "failing": raise RuntimeError("Simulated server creation failure") return original_create(key, config) with patch("sidekick.mcp.servers.create_mcp_server", side_effect=mock_create): with caplog.at_level(logging.WARNING): servers = load_mcp_servers() assert len(servers) == 1 assert servers[0].display_name == "Valid" assert "Failed to create server 'failing'" in caplog.text def test_warns_when_all_servers_fail(caplog): """Test warns when no servers could be created.""" mock_config = { "default_model": "test", "env": {}, "mcpServers": { "server1": {"command": "test1", "args": ["test"]}, "server2": {"command": "test2", "args": ["test"]}, }, } with patch("sidekick.mcp.servers.read_config_file", return_value=mock_config): # Make all server creations fail with patch("sidekick.mcp.servers.create_mcp_server", side_effect=Exception("Failed")): with patch("sidekick.mcp.servers.ui.error") as mock_error: with patch("sidekick.mcp.servers.ui.warning"): with patch("sidekick.mcp.servers.ui.bullet"): with caplog.at_level(logging.WARNING): servers = load_mcp_servers() assert servers == [] # Check that ui.error was called with the expected message mock_error.assert_called_with("No MCP servers could be loaded successfully") def test_handles_unexpected_errors(caplog): """Test handles unexpected errors gracefully.""" with patch("sidekick.mcp.servers.read_config_file", side_effect=Exception("Unexpected")): with caplog.at_level(logging.ERROR): servers = load_mcp_servers() assert servers == [] assert "Unexpected error loading config" in caplog.text ================================================ FILE: tests/mcp/test_validate_server_config.py ================================================ """Tests for MCP server config validation.""" import pytest from sidekick.config import ConfigValidationError, parse_mcp_servers def test_valid_config_passes(): """Test that valid server config passes validation.""" config = {"mcpServers": {"test-server": {"command": "uvx", "args": ["mcp-server-fetch"]}}} # Should not raise result = parse_mcp_servers(config) assert "test-server" in result def test_valid_config_with_multiple_args(): """Test valid config with multiple args.""" config = { "mcpServers": { "test-server": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-brave-search"], } } } # Should not raise result = parse_mcp_servers(config) assert "test-server" in result def test_raises_for_non_dict(): """Test ConfigValidationError for non-dict config.""" config = {"mcpServers": {"test-server": "not a dict"}} with pytest.raises(ConfigValidationError) as exc_info: parse_mcp_servers(config) assert "MCP server 'test-server' configuration must be an object" in str(exc_info.value) def test_raises_for_missing_command(): """Test ConfigValidationError when command missing.""" config = {"mcpServers": {"test-server": {"args": ["test"]}}} with pytest.raises(ConfigValidationError) as exc_info: parse_mcp_servers(config) assert "MCP server 'test-server' missing required field 'command'" in str(exc_info.value) def test_raises_for_empty_command(): """Test that empty command is allowed (validation doesn't check for empty strings).""" config = {"mcpServers": {"test-server": {"command": "", "args": ["test"]}}} # parse_mcp_servers doesn't validate empty command strings, only type result = parse_mcp_servers(config) assert "test-server" in result def test_raises_for_missing_args(): """Test ConfigValidationError when args missing.""" config = {"mcpServers": {"test-server": {"command": "uvx"}}} with pytest.raises(ConfigValidationError) as exc_info: parse_mcp_servers(config) assert "MCP server 'test-server' missing required field 'args'" in str(exc_info.value) def test_raises_for_non_list_args(): """Test ConfigValidationError when args is not a list.""" config = {"mcpServers": {"test-server": {"command": "uvx", "args": "not-a-list"}}} with pytest.raises(ConfigValidationError) as exc_info: parse_mcp_servers(config) assert "MCP server 'test-server' field 'args' must be an array" in str(exc_info.value) def test_raises_for_empty_args(): """Test ConfigValidationError when args is empty list.""" config = {"mcpServers": {"test-server": {"command": "uvx", "args": []}}} with pytest.raises(ConfigValidationError) as exc_info: parse_mcp_servers(config) assert "MCP server 'test-server' field 'args' must contain at least one argument" in str( exc_info.value ) def test_accepts_optional_fields(): """Test that optional fields are accepted.""" config = { "mcpServers": { "test-server": { "command": "uvx", "args": ["mcp-server-fetch"], "env": {"API_KEY": "test"}, "name": "Custom Name", } } } # Should not raise result = parse_mcp_servers(config) assert "test-server" in result assert result["test-server"]["env"] == {"API_KEY": "test"} def test_raises_for_non_dict_mcpservers(): """Test ConfigValidationError when mcpServers is not a dict.""" config = {"mcpServers": "not a dict"} with pytest.raises(ConfigValidationError) as exc_info: parse_mcp_servers(config) assert "'mcpServers' field must be an object" in str(exc_info.value) def test_returns_empty_dict_when_no_mcpservers(): """Test that empty dict is returned when no mcpServers field.""" config = {} result = parse_mcp_servers(config) assert result == {} def test_raises_for_non_dict_env(): """Test ConfigValidationError when env field is not a dict.""" config = { "mcpServers": { "test-server": {"command": "uvx", "args": ["mcp-server-fetch"], "env": "not a dict"} } } with pytest.raises(ConfigValidationError) as exc_info: parse_mcp_servers(config) assert "MCP server 'test-server' field 'env' must be an object" in str(exc_info.value) ================================================ FILE: tests/setup/test_create_config.py ================================================ import json import tempfile from pathlib import Path from unittest.mock import patch from sidekick.constants import DEFAULT_USER_CONFIG from sidekick.setup import create_config def test_create_config_includes_all_defaults(): """Test that create_config includes all default fields.""" # Mock user inputs with patch("sidekick.setup.collect_api_keys") as mock_collect: with patch("sidekick.setup.select_default_model") as mock_select: mock_collect.return_value = {"OPENAI_API_KEY": "sk-test123"} mock_select.return_value = "gpt-4o" # Create temporary config file with tempfile.TemporaryDirectory() as temp_dir: config_path = Path(temp_dir) / ".config" / "sidekick.json" # Mock console to suppress output with patch("sidekick.setup.console"): result = create_config(config_path) # Verify the returned config has all fields assert "default_model" in result assert result["default_model"] == "gpt-4o" assert "env" in result assert result["env"]["OPENAI_API_KEY"] == "sk-test123" # Should also have default placeholder values assert "ANTHROPIC_API_KEY" in result["env"] assert "GEMINI_API_KEY" in result["env"] assert "mcpServers" in result assert result["mcpServers"] == {} assert "settings" in result assert "allowed_tools" in result["settings"] from sidekick.constants import DEFAULT_USER_CONFIG assert ( result["settings"]["allowed_tools"] == DEFAULT_USER_CONFIG["settings"]["allowed_tools"] ) assert "allowed_commands" in result["settings"] assert len(result["settings"]["allowed_commands"]) > 0 # Verify the file was written correctly with open(config_path) as f: file_content = json.load(f) assert file_content == result def test_create_config_with_no_api_keys(): """Test create_config when user provides no API keys but continues anyway.""" with patch("sidekick.setup.collect_api_keys") as mock_collect: with patch("sidekick.setup.select_default_model") as mock_select: with patch("sidekick.setup.Confirm.ask") as mock_confirm: mock_collect.return_value = {} mock_select.return_value = DEFAULT_USER_CONFIG["default_model"] mock_confirm.return_value = True # Continue anyway with tempfile.TemporaryDirectory() as temp_dir: config_path = Path(temp_dir) / ".config" / "sidekick.json" with patch("sidekick.setup.console"): result = create_config(config_path) # Should have empty env dict, not the placeholder values assert result["env"] == {} # But should still have all other defaults assert "settings" in result assert "mcpServers" in result def test_create_config_filters_empty_api_keys(): """Test that empty string API keys are not included in the config.""" # Mock user inputs - simulate user pressing enter without entering values with patch("sidekick.setup.Prompt.ask") as mock_prompt: with patch("sidekick.setup.select_default_model") as mock_select: # Simulate user pressing enter (empty string) for all API keys mock_prompt.side_effect = ["", "", ""] # Empty strings for all 3 API keys mock_select.return_value = DEFAULT_USER_CONFIG["default_model"] with tempfile.TemporaryDirectory() as temp_dir: config_path = Path(temp_dir) / ".config" / "sidekick.json" with patch("sidekick.setup.console"): with patch("sidekick.setup.Confirm.ask", return_value=True): result = create_config(config_path) # Should have empty env dict when all API keys are empty strings assert result["env"] == {} # But should still have all other defaults assert "default_model" in result assert "settings" in result assert "mcpServers" in result ================================================ FILE: tests/test_data.py ================================================ """Shared test data for sidekick tests.""" # Valid MCP server configurations VALID_MCP_SERVER = {"command": "uvx", "args": ["mcp-server-fetch"]} VALID_MCP_SERVER_WITH_NAME = { "command": "uvx", "args": ["mcp-server-fetch"], "name": "Fetch Server", } VALID_MCP_SERVER_WITH_ENV = { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-brave-search"], "env": {"BRAVE_API_KEY": "test-key"}, } VALID_MCP_CONFIG = {"mcpServers": {"fetch": VALID_MCP_SERVER}} # Invalid MCP server configurations INVALID_MCP_SERVER_NOT_DICT = "not a dict" INVALID_MCP_SERVER_NO_COMMAND = {"args": ["test"]} INVALID_MCP_SERVER_NO_ARGS = {"command": "test"} INVALID_MCP_SERVER_EMPTY_ARGS = {"command": "test", "args": []} INVALID_MCP_SERVER_NON_LIST_ARGS = {"command": "test", "args": "not-a-list"} INVALID_MCP_SERVER_NON_DICT_ENV = {"command": "test", "args": ["arg"], "env": "not-a-dict"} # Mock tool calls MOCK_TOOL_CALL = { "tool_name": "test_tool", "tool_call_id": "tc_123", "tool_args": {"arg1": "value1"}, } ================================================ FILE: tests/tools/__init__.py ================================================ ================================================ FILE: tests/tools/conftest.py ================================================ """Shared fixtures for tool tests.""" import pytest from pydantic_ai import RunContext @pytest.fixture def mock_ctx(): """Create a mock RunContext for testing.""" return RunContext( deps=None, model=None, usage=None, prompt=None, messages=[], tool_call_id=None, tool_name=None, retry=0, run_step=0, ) ================================================ FILE: tests/tools/test_find.py ================================================ """Tests for the consolidated find tool.""" from unittest.mock import MagicMock, patch import pytest from pydantic_ai import RunContext from sidekick.deps import ToolDeps from sidekick.tools.find import find @pytest.fixture def mock_context(): """Create a mock RunContext with ToolDeps.""" mock_deps = MagicMock(spec=ToolDeps) mock_deps.display_tool_status = None mock_ctx = MagicMock(spec=RunContext) mock_ctx.deps = mock_deps return mock_ctx @pytest.mark.asyncio @patch("shutil.which") @patch("asyncio.create_subprocess_exec") async def test_find_files_with_fd( mock_subprocess_exec, mock_which, make_mock_process, mock_context ): mock_which.side_effect = lambda cmd: "/usr/bin/fd" if cmd == "fd" else None mock_subprocess_exec.return_value = make_mock_process("./src/main.py\n./src/agent.py") result = await find(mock_context, ".", "*.py") args = mock_subprocess_exec.call_args[0] assert args[0] == "fd" assert "--type" in args and "f" in args assert "*.py" in args assert result == "./src/main.py\n./src/agent.py" @pytest.mark.asyncio @patch("shutil.which") @patch("asyncio.create_subprocess_exec") async def test_find_dirs_with_fd(mock_subprocess_exec, mock_which, make_mock_process, mock_context): mock_which.side_effect = lambda cmd: "/usr/bin/fd" if cmd == "fd" else None mock_subprocess_exec.return_value = make_mock_process("./src/utils\n./tests/tools") result = await find(mock_context, ".", "*tools*", dirs=True) args = mock_subprocess_exec.call_args[0] assert args[0] == "fd" assert "--type" in args and "d" in args assert "*tools*" in args assert result == "./src/utils\n./tests/tools" @pytest.mark.asyncio @patch("shutil.which", return_value="/usr/bin/rg") @patch("asyncio.create_subprocess_exec") async def test_find_content_with_rg( mock_subprocess_exec, mock_which, make_mock_process, mock_context ): mock_subprocess_exec.return_value = make_mock_process("main.py:10:def main():") result = await find(mock_context, ".", content="def main") mock_which.assert_called_once_with("rg") args = mock_subprocess_exec.call_args[0] assert args[0] == "rg" assert "--line-number" in args assert args[-1] == "def main" assert result == "main.py:10:def main():" @pytest.mark.asyncio @patch("shutil.which") @patch("asyncio.create_subprocess_exec") async def test_find_content_case_insensitive( mock_subprocess_exec, mock_which, make_mock_process, mock_context ): mock_which.return_value = "/usr/bin/rg" mock_subprocess_exec.return_value = make_mock_process("main.py:10:def MAIN():") result = await find(mock_context, ".", content="main", case_sensitive=False) args = mock_subprocess_exec.call_args[0] assert args[0] == "rg" assert "-i" in args assert result == "main.py:10:def MAIN():" @pytest.mark.asyncio @patch("shutil.which") @patch("asyncio.create_subprocess_exec") async def test_find_content_with_include_pattern( mock_subprocess_exec, mock_which, make_mock_process, mock_context ): mock_which.return_value = "/usr/bin/rg" mock_subprocess_exec.return_value = make_mock_process("test.py:5:import pytest") result = await find(mock_context, ".", content="import", include_pattern="*.py") args = mock_subprocess_exec.call_args[0] assert args[0] == "rg" assert "--glob" in args glob_idx = args.index("--glob") assert args[glob_idx + 1] == "*.py" assert result == "test.py:5:import pytest" @pytest.mark.asyncio @patch("shutil.which") @patch("asyncio.create_subprocess_exec") async def test_find_content_with_ag_fallback( mock_subprocess_exec, mock_which, make_mock_process, mock_context ): mock_which.side_effect = lambda cmd: "/usr/bin/ag" if cmd == "ag" else None mock_subprocess_exec.return_value = make_mock_process("main.py:10:def main():") result = await find(mock_context, ".", content="def main") args = mock_subprocess_exec.call_args[0] assert args[0] == "ag" assert "--line-numbers" in args assert args[-1] == "def main" assert result == "main.py:10:def main():" @pytest.mark.asyncio @patch("shutil.which") @patch("os.walk") @patch("builtins.open") async def test_find_content_python_fallback(mock_open, mock_walk, mock_which, mock_context): mock_which.return_value = None mock_walk.return_value = [ (".", ["src"], ["README.md"]), ("./src", [], ["main.py"]), ] mock_file = MagicMock() mock_file.__enter__.return_value = ["def main():\n", " print('hello')\n"] mock_open.return_value = mock_file result = await find(mock_context, ".", content="def main") assert "main.py:1:def main():" in result @pytest.mark.asyncio @patch("shutil.which") @patch("asyncio.create_subprocess_exec") async def test_find_no_results(mock_subprocess_exec, mock_which, make_mock_process, mock_context): mock_which.side_effect = lambda cmd: "/usr/bin/fd" if cmd == "fd" else None mock_subprocess_exec.return_value = make_mock_process("") result = await find(mock_context, ".", "*.nonexistent") assert result == "No results found." @pytest.mark.asyncio @patch("shutil.which") @patch("os.walk") async def test_find_files_python_fallback(mock_walk, mock_which, mock_context): mock_which.return_value = None mock_walk.return_value = [ (".", ["src"], ["README.md"]), ("./src", [], ["main.py", "utils.py"]), ] result = await find(mock_context, ".", "*.py") assert "./src/main.py" in result assert "./src/utils.py" in result assert "README.md" not in result ================================================ FILE: tests/tools/test_read_file.py ================================================ """Tests for read_file tool.""" from unittest.mock import MagicMock, mock_open, patch import pytest from pydantic_ai import RunContext from sidekick.deps import ToolDeps from sidekick.tools.read_file import read_file @pytest.fixture def mock_context(): """Create a mock RunContext with ToolDeps.""" mock_deps = MagicMock(spec=ToolDeps) mock_deps.display_tool_status = None mock_ctx = MagicMock(spec=RunContext) mock_ctx.deps = mock_deps return mock_ctx @pytest.mark.asyncio async def test_read_file_success(mock_context): content = "Hello, World!\nThis is a test file." with patch("builtins.open", mock_open(read_data=content)) as m: assert await read_file(mock_context, "/test/file.txt") == content m.assert_called_once_with("/test/file.txt", "r", encoding="utf-8") @pytest.mark.parametrize( "side_effect, expected", [ (FileNotFoundError(), "Error: File not found: /x"), (PermissionError("Access denied"), "Error: Permission denied: /x"), (IOError("Disk error"), "Error reading file /x: Disk error"), ], ) @pytest.mark.asyncio async def test_read_file_errors(side_effect, expected, mock_context): with patch("builtins.open", side_effect=side_effect): assert await read_file(mock_context, "/x") == expected @pytest.mark.asyncio async def test_read_file_real_file(tmp_path, mock_context): tmp_file = tmp_path / "sample.txt" tmp_file.write_text("Line1\nLine2") assert await read_file(mock_context, str(tmp_file)) == "Line1\nLine2" @pytest.mark.asyncio async def test_read_file_empty_file(mock_context): with patch("builtins.open", mock_open(read_data="")): assert await read_file(mock_context, "/empty.txt") == "" @pytest.mark.asyncio async def test_read_file_large_content(mock_context): large_content = "x" * 10000 + "\n" + "y" * 10000 with patch("builtins.open", mock_open(read_data=large_content)): result = await read_file(mock_context, "/big.txt") assert result == large_content and len(result) == 20001 ================================================ FILE: tests/tools/test_update_file.py ================================================ """Tests for update_file tool.""" import tempfile from pathlib import Path from unittest.mock import mock_open, patch import pytest from pydantic_ai import ModelRetry from sidekick.tools.update_file import update_file @pytest.mark.asyncio async def test_update_file_success(mock_ctx): """Test successful file update.""" original_content = "Hello, World!\nThis is a test file.\nGoodbye!" old_content = "This is a test file." new_content = "This is an updated file." expected_content = "Hello, World!\nThis is an updated file.\nGoodbye!" with patch("builtins.open", mock_open(read_data=original_content)) as mock_file: result = await update_file(mock_ctx, "/test/file.txt", old_content, new_content) # Verify the file was opened for reading and writing assert mock_file.call_count == 2 mock_file.assert_any_call("/test/file.txt", "r", encoding="utf-8") mock_file.assert_any_call("/test/file.txt", "w", encoding="utf-8") # Verify the updated content was written handle = mock_file() handle.write.assert_called_once_with(expected_content) assert result == "Successfully updated /test/file.txt" @pytest.mark.asyncio async def test_update_file_content_not_found(mock_ctx): """Test ModelRetry when content to replace is not found.""" original_content = "Hello, World!\nThis is a test file.\nGoodbye!" old_content = "This content does not exist" new_content = "This is an updated file." with patch("builtins.open", mock_open(read_data=original_content)): with pytest.raises(ModelRetry) as exc_info: await update_file(mock_ctx, "/test/file.txt", old_content, new_content) error_msg = str(exc_info.value) assert "Content to replace not found in /test/file.txt" in error_msg assert "Searched for: 'This content does not exist'" in error_msg assert "re-read the file" in error_msg @pytest.mark.asyncio async def test_update_file_content_not_found_long_content(mock_ctx): """Test ModelRetry with truncated preview for long content.""" original_content = "Short content" old_content = "a" * 150 # Long content that doesn't exist new_content = "New content" with patch("builtins.open", mock_open(read_data=original_content)): with pytest.raises(ModelRetry) as exc_info: await update_file(mock_ctx, "/test/file.txt", old_content, new_content) error_msg = str(exc_info.value) assert "Content to replace not found" in error_msg # Check that long content is truncated assert f"Searched for: '{'a' * 100}...'" in error_msg @pytest.mark.asyncio async def test_update_file_not_found(mock_ctx): """Test ModelRetry when file doesn't exist.""" with patch("builtins.open", side_effect=FileNotFoundError()): with pytest.raises(ModelRetry) as exc_info: await update_file(mock_ctx, "/nonexistent/file.txt", "old", "new") error_msg = str(exc_info.value) assert "File not found: /nonexistent/file.txt" in error_msg assert "check the file path" in error_msg @pytest.mark.asyncio async def test_update_file_read_error(mock_ctx): """Test ModelRetry on generic read error.""" with patch("builtins.open", side_effect=PermissionError("Access denied")): with pytest.raises(ModelRetry) as exc_info: await update_file(mock_ctx, "/test/file.txt", "old", "new") error_msg = str(exc_info.value) assert "Error reading file /test/file.txt" in error_msg assert "Access denied" in error_msg @pytest.mark.asyncio async def test_update_file_write_error(mock_ctx): """Test ModelRetry on write error.""" original_content = "Hello, World!\nThis is a test file.\nGoodbye!" old_content = "This is a test file." new_content = "This is an updated file." # Mock successful read but failed write read_mock = mock_open(read_data=original_content) def open_side_effect(filepath, mode, encoding="utf-8"): if mode == "r": return read_mock(filepath, mode, encoding) else: # mode == "w" raise PermissionError("Cannot write to file") with patch("builtins.open", side_effect=open_side_effect): with pytest.raises(ModelRetry) as exc_info: await update_file(mock_ctx, "/test/file.txt", old_content, new_content) error_msg = str(exc_info.value) assert "Error writing to file /test/file.txt" in error_msg assert "Cannot write to file" in error_msg @pytest.mark.asyncio async def test_update_file_with_real_file(mock_ctx): """Integration test with actual file operations.""" with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as tmp: tmp.write("Line 1\nLine 2\nLine 3\n") tmp_path = tmp.name try: # Test successful update result = await update_file(mock_ctx, tmp_path, "Line 2", "Updated Line 2") assert result == f"Successfully updated {tmp_path}" # Verify content was updated with open(tmp_path, "r") as f: content = f.read() assert content == "Line 1\nUpdated Line 2\nLine 3\n" # Test content not found with pytest.raises(ModelRetry) as exc_info: await update_file(mock_ctx, tmp_path, "Non-existent line", "New line") assert "Content to replace not found" in str(exc_info.value) finally: # Clean up Path(tmp_path).unlink() @pytest.mark.asyncio async def test_update_file_only_first_occurrence(mock_ctx): """Test that only the first occurrence is replaced.""" original_content = "foo\nbar\nfoo\nbaz" old_content = "foo" new_content = "replaced" expected_content = "replaced\nbar\nfoo\nbaz" with patch("builtins.open", mock_open(read_data=original_content)) as mock_file: result = await update_file(mock_ctx, "/test/file.txt", old_content, new_content) # Verify only first occurrence was replaced handle = mock_file() handle.write.assert_called_once_with(expected_content) assert result == "Successfully updated /test/file.txt" @pytest.mark.asyncio async def test_update_file_preserves_encoding(mock_ctx): """Test that UTF-8 encoding is properly handled.""" original_content = "Hello 世界!\nThis is a test file with émojis 🎉\nGoodbye!" old_content = "This is a test file with émojis 🎉" new_content = "This is an updated file with émojis 🎊" expected_content = "Hello 世界!\nThis is an updated file with émojis 🎊\nGoodbye!" with patch("builtins.open", mock_open(read_data=original_content)) as mock_file: result = await update_file(mock_ctx, "/test/file.txt", old_content, new_content) # Verify encoding was specified mock_file.assert_any_call("/test/file.txt", "r", encoding="utf-8") mock_file.assert_any_call("/test/file.txt", "w", encoding="utf-8") # Verify the content was correctly updated handle = mock_file() handle.write.assert_called_once_with(expected_content) assert result == "Successfully updated /test/file.txt" @pytest.mark.asyncio async def test_update_file_identical_content(mock_ctx): """Test ModelRetry when old_content and new_content are identical.""" with pytest.raises(ModelRetry) as exc_info: await update_file(mock_ctx, "/test/file.txt", "same content", "same content") error_msg = str(exc_info.value) assert "old_content and new_content are identical" in error_msg assert "provide different content" in error_msg ================================================ FILE: tests/ui/__init__.py ================================================ ================================================ FILE: tests/usage/__init__.py ================================================ """Tests for usage tracking functionality.""" ================================================ FILE: tests/usage/test_usage_tracker.py ================================================ """Tests for the UsageTracker class.""" from unittest.mock import Mock import pytest from sidekick.usage import ModelUsage, UsageTracker # Tests for ModelUsage dataclass def test_model_usage_initial_state(): """Test initial state of ModelUsage.""" usage = ModelUsage() assert usage.requests == 0 assert usage.input_tokens == 0 assert usage.cached_tokens == 0 assert usage.output_tokens == 0 assert usage.total_cost == 0.0 def test_model_usage_add_usage(): """Test adding usage data.""" usage = ModelUsage() usage.add_usage(1000, 200, 500, 0.01) assert usage.requests == 1 assert usage.input_tokens == 1000 assert usage.cached_tokens == 200 assert usage.output_tokens == 500 assert usage.total_cost == 0.01 # Add more usage usage.add_usage(500, 100, 250, 0.005) assert usage.requests == 2 assert usage.input_tokens == 1500 assert usage.cached_tokens == 300 assert usage.output_tokens == 750 assert usage.total_cost == 0.015 # Tests for UsageTracker class def test_usage_tracker_initial_state(): """Test initial state of UsageTracker.""" tracker = UsageTracker() assert tracker.model_usage == {} assert tracker.last_request is None assert tracker.total_tokens == 0 assert tracker.total_cost == 0.0 assert tracker.total_requests == 0 def test_record_usage_basic(): """Test basic usage recording without cached tokens.""" tracker = UsageTracker() # Mock usage object usage = Mock() usage.request_tokens = 1000 usage.response_tokens = 500 usage.details = [] tracker.record_usage("openai:o4-mini", usage) # Check model usage was recorded assert "openai:o4-mini" in tracker.model_usage model_usage = tracker.model_usage["openai:o4-mini"] assert model_usage.requests == 1 assert model_usage.input_tokens == 1000 assert model_usage.output_tokens == 500 assert model_usage.cached_tokens == 0 # Check last request assert tracker.last_request is not None assert tracker.last_request["model"] == "openai:o4-mini" assert tracker.last_request["input_tokens"] == 1000 assert tracker.last_request["output_tokens"] == 500 # Check totals assert tracker.total_tokens == 1500 assert tracker.total_requests == 1 def test_record_usage_with_cached_tokens(): """Test usage recording with cached tokens.""" tracker = UsageTracker() # Mock usage object with cached tokens usage = Mock() usage.request_tokens = 1000 usage.response_tokens = 500 detail = Mock() detail.cached_tokens = 300 usage.details = [detail] tracker.record_usage("anthropic:claude-3-7-sonnet-latest", usage) # Check cached tokens were recorded model_usage = tracker.model_usage["anthropic:claude-3-7-sonnet-latest"] assert model_usage.cached_tokens == 300 # Check last request has cached tokens assert tracker.last_request["cached_tokens"] == 300 def test_record_usage_multiple_models(): """Test recording usage for multiple models.""" tracker = UsageTracker() # First model usage1 = Mock() usage1.request_tokens = 1000 usage1.response_tokens = 500 usage1.details = [] tracker.record_usage("openai:o4-mini", usage1) # Second model usage2 = Mock() usage2.request_tokens = 2000 usage2.response_tokens = 1000 usage2.details = [] tracker.record_usage("anthropic:claude-3-7-sonnet-latest", usage2) # Check both models are tracked assert len(tracker.model_usage) == 2 assert "openai:o4-mini" in tracker.model_usage assert "anthropic:claude-3-7-sonnet-latest" in tracker.model_usage # Check totals include both models assert tracker.total_tokens == 4500 # 1500 + 3000 assert tracker.total_requests == 2 def test_record_usage_same_model_multiple_times(): """Test recording multiple usages for the same model.""" tracker = UsageTracker() # First usage usage1 = Mock() usage1.request_tokens = 1000 usage1.response_tokens = 500 usage1.details = [] tracker.record_usage("openai:o4-mini", usage1) # Second usage for same model usage2 = Mock() usage2.request_tokens = 500 usage2.response_tokens = 250 usage2.details = [] tracker.record_usage("openai:o4-mini", usage2) # Check cumulative stats model_usage = tracker.model_usage["openai:o4-mini"] assert model_usage.requests == 2 assert model_usage.input_tokens == 1500 assert model_usage.output_tokens == 750 assert tracker.total_tokens == 2250 assert tracker.total_requests == 2 def test_cost_calculation(): """Test that costs are calculated correctly.""" tracker = UsageTracker() usage = Mock() usage.request_tokens = 1000 usage.response_tokens = 500 usage.details = [] tracker.record_usage("openai:o4-mini", usage) # Check cost calculation (o4-mini pricing: $1.10/$0.275/$4.40 per 1M) expected_input_cost = 1000 / 1_000_000 * 1.10 expected_output_cost = 500 / 1_000_000 * 4.40 expected_total = expected_input_cost + expected_output_cost assert tracker.last_request["input_cost"] == pytest.approx(expected_input_cost) assert tracker.last_request["output_cost"] == pytest.approx(expected_output_cost) assert tracker.last_request["request_cost"] == pytest.approx(expected_total) assert tracker.total_cost == pytest.approx(expected_total) def test_unknown_model_fallback(): """Test that unknown models fall back to first model pricing.""" tracker = UsageTracker() usage = Mock() usage.request_tokens = 1000 usage.response_tokens = 500 usage.details = [] tracker.record_usage("unknown:model", usage) # Should use first model's pricing (anthropic:claude-opus-4-0) expected_input_cost = 1000 / 1_000_000 * 3.00 expected_output_cost = 500 / 1_000_000 * 15.00 assert tracker.last_request["input_cost"] == pytest.approx(expected_input_cost) assert tracker.last_request["output_cost"] == pytest.approx(expected_output_cost) ================================================ FILE: tests/utils/__init__.py ================================================ """Tests for utilities.""" ================================================ FILE: tests/utils/test_command_parser.py ================================================ """Tests for command parser.""" from sidekick.ui import get_command_display_name from sidekick.utils.command import extract_commands, is_command_allowed def test_simple_command(): assert extract_commands("ls -la") == ["ls"] assert extract_commands("mkdir -p /some/path") == ["mkdir"] assert extract_commands("cd /tmp") == ["cd"] def test_commands_with_paths(): assert extract_commands("/usr/bin/ls -la") == ["ls"] assert extract_commands("./scripts/deploy.sh") == ["deploy.sh"] assert extract_commands("/bin/bash script.sh") == ["bash"] def test_chained_commands_with_and(): assert extract_commands("ls && mkdir foo") == ["ls", "mkdir"] assert extract_commands("cd /tmp && ls -la && pwd") == ["cd", "ls", "pwd"] def test_chained_commands_with_or(): assert extract_commands("ls || echo 'failed'") == ["ls", "echo"] def test_piped_commands(): assert extract_commands("ls | grep foo") == ["ls", "grep"] assert extract_commands("cat file.txt | grep pattern | wc -l") == ["cat", "grep", "wc"] def test_semicolon_separated(): assert extract_commands("cd /tmp; ls") == ["cd", "ls"] assert extract_commands("echo 'hello'; echo 'world'") == ["echo", "echo"] def test_mixed_separators(): assert extract_commands("ls && cd /tmp; pwd | grep tmp") == ["ls", "cd", "pwd", "grep"] def test_quoted_arguments(): assert extract_commands('echo "hello && world"') == ["echo"] assert extract_commands("echo 'ls | grep'") == ["echo"] assert extract_commands('mkdir "my folder" && ls') == ["mkdir", "ls"] def test_empty_and_whitespace(): assert extract_commands("") == [] assert extract_commands(" ") == [] assert extract_commands("ls && && pwd") == ["ls", "pwd"] def test_single_command_allowed(): allowed = {"ls", "mkdir", "cd"} assert is_command_allowed("ls -la", allowed) is True assert is_command_allowed("rm -rf /", allowed) is False def test_all_commands_must_be_allowed(): allowed = {"ls", "cd"} assert is_command_allowed("ls && cd /tmp", allowed) is True assert is_command_allowed("ls && rm file", allowed) is False def test_empty_allowed_set(): allowed = set() assert is_command_allowed("ls", allowed) is False def test_command_with_path(): allowed = {"ls"} assert is_command_allowed("/usr/bin/ls", allowed) is True def test_single_command_display(): assert get_command_display_name("ls -la") == "'ls'" def test_multiple_commands_display(): assert get_command_display_name("ls && cd") == "'ls', 'cd'" assert get_command_display_name("cat | grep | wc") == "'cat', 'grep', 'wc'" ================================================ FILE: tests/utils/test_error_handler.py ================================================ """Tests for error_handler utility.""" from unittest.mock import MagicMock import pytest from pydantic_ai.exceptions import ModelHTTPError from sidekick.utils.error import ( extract_error_message, handle_error, save_error_log, should_log_error, ) def test_extract_error_message_model_http_error(): """Test extracting message from ModelHTTPError.""" error = ModelHTTPError( status_code=400, model_name="TestModel", body={"error": {"message": "Invalid API key"}}, ) result = extract_error_message(error) assert result == "TestModel: Invalid API key" def test_extract_error_message_model_http_error_direct_message(): """Test extracting message from ModelHTTPError with message in body.""" error = ModelHTTPError( status_code=429, model_name="TestModel", body={"message": "Rate limit exceeded"}, ) result = extract_error_message(error) assert result == "TestModel: Rate limit exceeded" def test_extract_error_message_malformed_function_call(): """Test extracting message for MALFORMED_FUNCTION_CALL error.""" error = Exception("Content field missing, MALFORMED_FUNCTION_CALL") result = extract_error_message(error) assert result == "The AI model had trouble executing a function. Please try again." def test_extract_error_message_content_field_missing(): """Test extracting message for Content field missing error.""" error = Exception("Content field missing from response") result = extract_error_message(error) expected = ( "The AI model returned an unexpected response format. This might be a temporary issue." ) assert result == expected def test_extract_error_message_long_error(): """Test that long error messages are truncated.""" long_message = "x" * 200 error = Exception(long_message) result = extract_error_message(error) assert len(result) < 200 assert result.endswith("...") assert "Exception" in result def test_extract_error_message_provider_error_openai(): """Test extracting message from OpenAI provider error.""" class MockOpenAIError(Exception): def __init__(self): self.body = {"error": {"message": "Rate limit exceeded"}} super().__init__() error = MockOpenAIError() error.__class__.__name__ = "APIStatusError" error.__class__.__module__ = "openai" result = extract_error_message(error) assert result == "OpenAI: Rate limit exceeded" def test_extract_error_message_provider_error_anthropic(): """Test extracting message from Anthropic provider error.""" class MockAnthropicError(Exception): def __init__(self): self.message = "Invalid API key" super().__init__() error = MockAnthropicError() error.__class__.__name__ = "AuthenticationError" error.__class__.__module__ = "anthropic" result = extract_error_message(error) assert result == "Anthropic: Invalid API key" def test_extract_error_message_provider_error_google(): """Test extracting message from Google provider error.""" class MockGoogleError(Exception): def __init__(self): self.details = {"error": {"message": "API key not valid"}} super().__init__() error = MockGoogleError() error.__class__.__name__ = "ClientError" error.__class__.__module__ = "google.genai" result = extract_error_message(error) assert result == "Google: API key not valid" def test_should_log_error_known_errors(): """Test that known errors should not be logged.""" from asyncio import CancelledError # Known errors that shouldn't be logged assert not should_log_error(CancelledError()) assert not should_log_error(KeyboardInterrupt()) assert not should_log_error( ModelHTTPError( status_code=400, model_name="test", body={}, ) ) def test_should_log_error_unknown_errors(): """Test that unknown errors should be logged.""" assert should_log_error(ValueError("test")) assert should_log_error(Exception("test")) assert should_log_error(RuntimeError("test")) def test_save_error_log(): """Test saving error log to file.""" error = ValueError("Test error message") log_file = save_error_log(error) # Check file exists assert log_file.exists() assert log_file.name.startswith("sidekick_error_") assert log_file.suffix == ".log" # Check content content = log_file.read_text() assert "Sidekick Error Log" in content assert "Test error message" in content assert "ValueError" in content assert "Traceback" in content # Clean up log_file.unlink() @pytest.mark.asyncio async def test_handle_error_with_logging(): """Test handle_error function with error that should be logged.""" mock_display = MagicMock() error = ValueError("Unexpected error") await handle_error(error, mock_display) # Check display function was called mock_display.assert_called_once() call_args = mock_display.call_args # Check message assert "ValueError" in call_args[0][0] assert "Unexpected error" in call_args[0][0] # Check detail contains log file path assert "detail" in call_args[1] assert "Error log:" in call_args[1]["detail"] assert "sidekick_error_" in call_args[1]["detail"] @pytest.mark.asyncio async def test_handle_error_without_logging(): """Test handle_error function with error that shouldn't be logged.""" mock_display = MagicMock() error = ModelHTTPError( status_code=400, model_name="TestModel", body={"error": {"message": "Bad request"}}, ) await handle_error(error, mock_display) # Check display function was called without detail mock_display.assert_called_once_with("TestModel: Bad request") def test_extract_error_message_with_regex_extraction(): """Test that regex extraction works for embedded messages.""" # Test with a complex error message containing embedded JSON-like structure error_msg = ( 'APIError: {"error": {"message": "Your credit balance is too low", ' '"type": "insufficient_funds", "code": 1234}}' ) error = Exception(error_msg) result = extract_error_message(error) assert "Your credit balance is too low" in result ================================================ FILE: tests/utils/test_guide.py ================================================ """Tests for guide utility functions.""" from sidekick.utils.guide import load_guide def test_load_guide_with_file(tmp_path, monkeypatch): """`load_guide` should return the file contents.""" guide_content = "# Project Guide\nUse Python 3.11" guide_path = tmp_path / "SIDEKICK.md" guide_path.write_text(guide_content) monkeypatch.chdir(tmp_path) result = load_guide() assert result == guide_content def test_load_guide_without_file(tmp_path, monkeypatch): """`load_guide` should return ``None`` when no guide file is present.""" monkeypatch.chdir(tmp_path) result = load_guide() assert result is None ================================================ FILE: tests/utils/test_input.py ================================================ """Tests for input utilities.""" from prompt_toolkit import PromptSession from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.styles import Style from sidekick.utils.input import ( PLACEHOLDER_STYLE, PLACEHOLDER_TEXT, PROMPT_CONTINUATION_INDENT, PROMPT_SYMBOL, create_multiline_keybindings, create_multiline_prompt_session, create_prompt_style, prompt_continuation, ) def test_create_multiline_keybindings(): """Test that keybindings are created correctly.""" bindings = create_multiline_keybindings() assert isinstance(bindings, KeyBindings) assert len(bindings.bindings) > 0 def test_create_prompt_style(): """Test that prompt style is created correctly.""" style = create_prompt_style() assert isinstance(style, Style) style_dict = dict(style.style_rules) assert "placeholder" in style_dict assert style_dict["placeholder"] == PLACEHOLDER_STYLE def test_prompt_continuation(): """Test prompt continuation returns correct indentation.""" # Should always return the same indentation regardless of parameters assert prompt_continuation(80, 0, False) == PROMPT_CONTINUATION_INDENT assert prompt_continuation(120, 5, True) == PROMPT_CONTINUATION_INDENT assert prompt_continuation(60, 10, False) == PROMPT_CONTINUATION_INDENT def test_create_multiline_prompt_session(): """Test that prompt session is created with correct configuration.""" session = create_multiline_prompt_session() assert isinstance(session, PromptSession) assert session.multiline is True assert session.prompt_continuation == prompt_continuation def test_constants(): """Test that constants have expected values.""" assert PROMPT_SYMBOL == "λ " assert PROMPT_CONTINUATION_INDENT == " " assert PLACEHOLDER_TEXT == "Esc+Enter to submit, /help for commands"