Showing preview only (245K chars total). Download the full file or copy to clipboard to get everything.
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)
[](https://badge.fury.io/py/sidekick-cli)
[](https://www.python.org/downloads/)

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": "<YOUR_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 <num>` - 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 <num>", "Switch to a specific model"),
("/model <num> 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"<ansicyan>{PROMPT_SYMBOL}</ansicyan>")
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 <num> 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 <num> 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 jso
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
SYMBOL INDEX (355 symbols across 68 files)
FILE: src/sidekick/agent.py
function _get_prompt (line 23) | def _get_prompt(name: str) -> str:
function _process_node (line 31) | async def _process_node(node, message_history):
function create_agent (line 63) | def create_agent():
function _create_confirmation_callback (line 75) | def _create_confirmation_callback():
function _create_display_tool_status_callback (line 112) | def _create_display_tool_status_callback():
function process_request (line 139) | async def process_request(message: str, message_history):
FILE: src/sidekick/commands/__init__.py
function handle_command (line 22) | async def handle_command(user_input: str, message_history=None) -> bool:
FILE: src/sidekick/commands/clear.py
function handle_clear (line 6) | async def handle_clear(message_history):
FILE: src/sidekick/commands/dump.py
function recursive_expand (line 8) | def recursive_expand(obj, indent=0):
function handle_dump (line 82) | async def handle_dump(message_history):
FILE: src/sidekick/commands/help.py
function handle_help (line 6) | async def handle_help():
FILE: src/sidekick/commands/model.py
function handle_model (line 16) | async def handle_model(args: list[str]):
FILE: src/sidekick/commands/usage.py
function handle_usage (line 9) | async def handle_usage():
FILE: src/sidekick/commands/yolo.py
function handle_yolo (line 7) | async def handle_yolo():
FILE: src/sidekick/config.py
class ConfigError (line 11) | class ConfigError(Exception):
class ConfigValidationError (line 17) | class ConfigValidationError(ConfigError):
function get_config_path (line 23) | def get_config_path() -> Path:
function config_exists (line 28) | def config_exists() -> bool:
function read_config_file (line 33) | def read_config_file() -> Dict[str, Any]:
function validate_config_structure (line 57) | def validate_config_structure(config: Dict[str, Any]) -> None:
function parse_mcp_servers (line 82) | def parse_mcp_servers(config: Dict[str, Any]) -> Dict[str, Any]:
function set_env_vars (line 129) | def set_env_vars(env_dict: Dict[str, str]) -> None:
function update_config_file (line 140) | def update_config_file(updates: Dict[str, Any]) -> None:
function deep_merge_dicts (line 175) | def deep_merge_dicts(base: Dict[str, Any], update: Dict[str, Any]) -> Di...
function ensure_config_structure (line 196) | def ensure_config_structure() -> Dict[str, Any]:
FILE: src/sidekick/deps.py
class ToolDeps (line 6) | class ToolDeps:
FILE: src/sidekick/main.py
function _setup_and_run_event_loop (line 29) | def _setup_and_run_event_loop(coro):
function _initialize_config (line 48) | def _initialize_config():
function main (line 74) | def main(
FILE: src/sidekick/mcp/agent.py
class MCPAgent (line 10) | class MCPAgent:
method __init__ (line 28) | def __init__(self, agent: Agent):
method agent (line 40) | def agent(self) -> Agent:
method __aenter__ (line 52) | async def __aenter__(self):
method __aexit__ (line 75) | async def __aexit__(self, exc_type, exc_val, exc_tb):
FILE: src/sidekick/mcp/servers.py
function mcp_tool_confirmation_callback (line 25) | async def mcp_tool_confirmation_callback(
class SilentMCPServerStdio (line 57) | class SilentMCPServerStdio(MCPServerStdio):
method __init__ (line 64) | def __init__(self, *args, display_name: str = None, **kwargs):
method client_streams (line 70) | async def client_streams(self):
function create_mcp_server (line 87) | def create_mcp_server(key: str, config: Dict[str, Any]) -> SilentMCPServ...
function load_mcp_servers (line 109) | def load_mcp_servers() -> List[SilentMCPServerStdio]:
FILE: src/sidekick/messages.py
class MessageHistory (line 13) | class MessageHistory:
method add_request (line 19) | def add_request(self, request: messages.ModelRequest) -> None:
method add_response (line 24) | def add_response(self, response: messages.ModelResponse) -> None:
method add_cancellation_note (line 33) | def add_cancellation_note(self) -> None:
method patch_on_error (line 44) | def patch_on_error(self, error_message: str) -> None:
method clear (line 83) | def clear(self) -> None:
method get_messages (line 88) | def get_messages(self) -> List[messages.ModelMessage]:
method get_messages_for_agent (line 92) | def get_messages_for_agent(self) -> List[messages.ModelMessage]:
method set_project_guide (line 105) | def set_project_guide(self, guide: Optional[str]) -> None:
method __len__ (line 109) | def __len__(self) -> int:
method __iter__ (line 113) | def __iter__(self):
method __getitem__ (line 117) | def __getitem__(self, index):
FILE: src/sidekick/repl.py
function _restore_default_signal_handler (line 21) | def _restore_default_signal_handler():
function _should_exit (line 26) | def _should_exit(user_input: str) -> bool:
function _display_server_info (line 31) | async def _display_server_info():
class Repl (line 42) | class Repl:
method __init__ (line 45) | def __init__(self, project_guide=None):
method _kill_child_processes (line 54) | def _kill_child_processes(self):
method _setup_signal_handler (line 75) | def _setup_signal_handler(self):
method _handle_user_request (line 89) | async def _handle_user_request(self, user_input: str):
method run (line 116) | async def run(self):
FILE: src/sidekick/session.py
class Session (line 6) | class Session:
method init (line 13) | def init(self, config: Dict[str, Any], model: str):
FILE: src/sidekick/setup.py
function validate_json_file (line 15) | def validate_json_file(config_path: Path) -> Optional[Dict]:
function collect_api_keys (line 24) | def collect_api_keys() -> Dict[str, str]:
function select_default_model (line 44) | def select_default_model(api_keys: Dict[str, str]) -> str:
function create_config (line 87) | def create_config(config_path: Path) -> Dict:
function handle_invalid_config (line 126) | def handle_invalid_config(config_path: Path) -> Dict:
function run_setup (line 149) | def run_setup() -> Dict:
FILE: src/sidekick/tools/find.py
function _run_external_tool (line 15) | async def _run_external_tool(tool_name: str, cmd: List[str]) -> Optional...
function _get_gitignore_patterns (line 36) | def _get_gitignore_patterns() -> Set[str]:
function _find_files_with_fd (line 54) | async def _find_files_with_fd(pattern: str, dirs: bool, max_depth: Optio...
function _find_files_with_rg (line 69) | async def _find_files_with_rg(pattern: str, max_depth: Optional[int]) ->...
function _find_content_with_rg (line 83) | async def _find_content_with_rg(
function _find_content_with_ag (line 105) | async def _find_content_with_ag(
function _find_files_python (line 127) | def _find_files_python(pattern: str, dirs: bool, max_depth: Optional[int...
function _find_content_python (line 157) | def _find_content_python(
function find (line 216) | async def find(
FILE: src/sidekick/tools/git.py
function git_add (line 9) | async def git_add(ctx: RunContext[ToolDeps], files: str) -> str:
function git_commit (line 79) | async def git_commit(ctx: RunContext[ToolDeps], message: str) -> str:
FILE: src/sidekick/tools/list.py
function _should_exclude (line 14) | def _should_exclude(path: str, gitignore_patterns: List[str]) -> bool:
function _read_gitignore (line 42) | def _read_gitignore(base_path: str) -> List[str]:
function _format_tree (line 54) | def _format_tree(items: List[Tuple[str, bool, int]], prefix: str = "") -...
function _run_rg_files (line 71) | async def _run_rg_files(path: str, max_depth: int) -> str:
function _build_tree_from_files (line 92) | def _build_tree_from_files(files: List[str], base_path: str) -> str:
function _walk_directory (line 99) | def _walk_directory(
function list_directory (line 181) | async def list_directory(ctx: RunContext[ToolDeps], path: str = ".", max...
FILE: src/sidekick/tools/read_file.py
function read_file (line 10) | async def read_file(ctx: RunContext[ToolDeps], filepath: str) -> str:
FILE: src/sidekick/tools/run_command.py
function run_command (line 12) | async def run_command(ctx: RunContext[ToolDeps], command: str) -> str:
FILE: src/sidekick/tools/update_file.py
function update_file (line 9) | async def update_file(
FILE: src/sidekick/tools/wrapper.py
function create_tools (line 14) | def create_tools():
FILE: src/sidekick/tools/write_file.py
function write_file (line 13) | async def write_file(ctx: RunContext[ToolDeps], filepath: str, content: ...
FILE: src/sidekick/ui/__init__.py
function version (line 50) | def version():
function update_available (line 55) | def update_available(latest_version: str):
function usage (line 60) | def usage(usage_data: dict):
function banner (line 65) | def banner():
function start_spinner (line 80) | def start_spinner(message: str = "", style: str = SpinnerStyle.DEFAULT):
function stop_spinner (line 90) | def stop_spinner():
FILE: src/sidekick/ui/colors.py
class Colors (line 4) | class Colors:
FILE: src/sidekick/ui/core.py
class SpinnerStyle (line 22) | class SpinnerStyle:
function banner (line 29) | def banner():
function start_spinner (line 38) | def start_spinner(message: str = "", style: str = SpinnerStyle.DEFAULT):
function stop_spinner (line 43) | def stop_spinner():
FILE: src/sidekick/ui/formatting.py
function get_file_language (line 12) | def get_file_language(filepath: str) -> str:
function create_syntax_highlighted (line 109) | def create_syntax_highlighted(content: str, filepath: str, theme: str = ...
function create_shell_syntax (line 133) | def create_shell_syntax(command: str, theme: str = None) -> Syntax:
function create_unified_diff (line 155) | def create_unified_diff(
function create_inline_diff (line 192) | def create_inline_diff(old_content: str, new_content: str) -> tuple[Text...
function format_server_name (line 223) | def format_server_name(key: str) -> str:
function get_command_display_name (line 256) | def get_command_display_name(command_string: str) -> str:
FILE: src/sidekick/ui/manager.py
class OutputType (line 18) | class OutputType(Enum):
class PanelType (line 28) | class PanelType(Enum):
class MessageType (line 41) | class MessageType(Enum):
class UIManager (line 77) | class UIManager:
method __init__ (line 83) | def __init__(self):
method _prepare_spacing (line 88) | def _prepare_spacing(self, new_type: OutputType):
method _prepare_panel_content (line 104) | def _prepare_panel_content(self, content, markdown, syntax):
method _determine_panel_title (line 121) | def _determine_panel_title(self, title, panel_type, config):
method panel (line 140) | def panel(
method message (line 182) | def message(
method line (line 224) | def line(self):
method reset_context (line 228) | def reset_context(self):
method set_spinner_active (line 232) | def set_spinner_active(self, active: bool):
method agent (line 242) | def agent(self, content: str, has_footer: bool = False):
method tool (line 252) | def tool(self, content: Union[str, Text], title: str, footer: Optional...
method error_panel (line 261) | def error_panel(self, message: str, detail: Optional[str] = None, titl...
method info (line 270) | def info(self, text: str):
method error (line 274) | def error(self, text: str, detail: Optional[str] = None):
method warning (line 278) | def warning(self, text: str):
method success (line 282) | def success(self, text: str):
method bullet (line 286) | def bullet(self, text: str):
method muted (line 290) | def muted(self, text: str, indent: int = 0):
method thinking (line 294) | def thinking(self, text: str):
method thinking_panel (line 298) | def thinking_panel(self, content: str):
method confirmation_panel (line 302) | def confirmation_panel(self, content: str):
method info_panel (line 306) | def info_panel(self, content, title: str):
method dump (line 310) | def dump(self, data):
method help (line 318) | def help(self):
FILE: src/sidekick/ui/special.py
function version (line 12) | def version(ui_manager):
function update_available (line 17) | def update_available(ui_manager, latest_version: str):
function usage (line 22) | def usage(ui_manager, usage_data: dict):
FILE: src/sidekick/ui/spinner.py
class SpinnerManager (line 10) | class SpinnerManager:
method __init__ (line 31) | def __init__(self, console: Console):
method _get_thinking_message (line 36) | def _get_thinking_message(self) -> str:
method _rotate_messages (line 40) | async def _rotate_messages(self, style: str, interval: float = 5.0):
method start (line 54) | def start(self, message: str = "", style: str = None):
method stop (line 82) | def stop(self):
FILE: src/sidekick/usage.py
class ModelUsage (line 10) | class ModelUsage:
method add_usage (line 19) | def add_usage(
class UsageTracker (line 31) | class UsageTracker:
method record_usage (line 40) | def record_usage(self, model: str, usage: Any) -> None:
method total_tokens (line 87) | def total_tokens(self) -> int:
method total_cost (line 92) | def total_cost(self) -> float:
method total_requests (line 97) | def total_requests(self) -> int:
FILE: src/sidekick/utils/command.py
function extract_commands (line 7) | def extract_commands(command_string: str) -> List[str]:
function is_command_allowed (line 97) | def is_command_allowed(command_string: str, allowed_commands: Set[str]) ...
FILE: src/sidekick/utils/error.py
function handle_error (line 14) | async def handle_error(error: Exception, display_func) -> None:
function extract_error_message (line 30) | def extract_error_message(error: Exception) -> str:
function should_log_error (line 66) | def should_log_error(error: Exception) -> bool:
function save_error_log (line 72) | def save_error_log(error: Exception) -> Path:
function _get_api_message (line 93) | def _get_api_message(error: ModelHTTPError) -> str:
function _extract_provider_message (line 105) | def _extract_provider_message(error: Exception) -> str:
function _get_provider_name (line 122) | def _get_provider_name(module_name: str) -> str:
class ErrorContext (line 133) | class ErrorContext:
method __init__ (line 136) | def __init__(self, operation: str, ui: Any):
method add_cleanup (line 141) | def add_cleanup(self, callback: Callable) -> None:
method handle (line 144) | async def handle(self, error: Exception) -> Optional[Any]:
FILE: src/sidekick/utils/guide.py
function load_guide (line 4) | def load_guide():
FILE: src/sidekick/utils/input.py
function create_multiline_keybindings (line 19) | def create_multiline_keybindings() -> KeyBindings:
function create_prompt_style (line 35) | def create_prompt_style() -> Style:
function prompt_continuation (line 48) | def prompt_continuation(width: int, line_number: int, is_soft_wrap: bool...
function create_multiline_prompt_session (line 62) | def create_multiline_prompt_session() -> PromptSession:
function get_multiline_input (line 83) | async def get_multiline_input(session: Optional[PromptSession] = None) -...
FILE: src/sidekick/utils/logger.py
class UILogHandler (line 8) | class UILogHandler(logging.Handler):
method emit (line 11) | def emit(self, record):
function _is_allowed_module (line 26) | def _is_allowed_module(name: str) -> bool:
function setup_logging (line 33) | def setup_logging(debug_enabled: bool):
FILE: tests/agent/test_process_node.py
function test_process_node_with_request (line 10) | async def test_process_node_with_request():
function test_process_node_with_model_response_no_tools (line 26) | async def test_process_node_with_model_response_no_tools():
function test_process_node_with_tool_call (line 45) | async def test_process_node_with_tool_call():
function test_process_node_with_tool_return (line 67) | async def test_process_node_with_tool_return():
function test_process_node_with_retry_prompt (line 87) | async def test_process_node_with_retry_prompt():
FILE: tests/commands/test_handle_command.py
function test_handle_command_routes (line 20) | async def test_handle_command_routes(monkeypatch, user_input, patch_targ...
function test_handle_command_non_command (line 39) | async def test_handle_command_non_command():
function test_handle_command_unknown (line 45) | async def test_handle_command_unknown():
FILE: tests/commands/test_handle_dump.py
function test_handle_dump_writes_to_file_and_pretty_prints (line 11) | async def test_handle_dump_writes_to_file_and_pretty_prints(mock_ui, tmp...
FILE: tests/commands/test_handle_model.py
function test_handle_model_list (line 11) | async def test_handle_model_list(mock_ui, mock_session, mock_models):
function test_handle_model_switch (line 26) | async def test_handle_model_switch(mock_ui, mock_session, mock_models):
function test_handle_model_invalid_number (line 41) | async def test_handle_model_invalid_number(mock_ui):
function test_handle_model_set_default (line 52) | async def test_handle_model_set_default(mock_ui):
FILE: tests/commands/test_handle_yolo.py
function test_handle_yolo_disables_confirmation (line 11) | async def test_handle_yolo_disables_confirmation(mock_ui, mock_session):
function test_handle_yolo_enables_confirmation (line 25) | async def test_handle_yolo_enables_confirmation(mock_ui, mock_session):
FILE: tests/config/test_config_exists.py
function test_returns_true_when_exists (line 8) | def test_returns_true_when_exists():
function test_returns_false_when_not_exists (line 14) | def test_returns_false_when_not_exists():
FILE: tests/config/test_deep_merge_dicts.py
function test_simple_merge (line 4) | def test_simple_merge():
function test_nested_dict_merge (line 14) | def test_nested_dict_merge():
function test_deep_nested_merge (line 30) | def test_deep_nested_merge():
function test_list_override (line 40) | def test_list_override():
function test_mixed_types_override (line 50) | def test_mixed_types_override():
function test_empty_dicts (line 60) | def test_empty_dicts():
function test_none_values (line 67) | def test_none_values():
function test_preserves_update_values (line 77) | def test_preserves_update_values():
FILE: tests/config/test_ensure_config_structure.py
function test_preserves_user_settings (line 13) | def test_preserves_user_settings():
function test_adds_missing_defaults (line 48) | def test_adds_missing_defaults():
function test_does_not_add_tool_ignore_field (line 87) | def test_does_not_add_tool_ignore_field():
function test_empty_config_gets_full_defaults (line 118) | def test_empty_config_gets_full_defaults():
function test_no_file_update_when_no_changes (line 139) | def test_no_file_update_when_no_changes():
function test_raises_config_error_on_missing_file (line 206) | def test_raises_config_error_on_missing_file():
FILE: tests/config/test_get_config_path.py
function test_returns_correct_path (line 8) | def test_returns_correct_path():
FILE: tests/config/test_parse_mcp_servers.py
function test_returns_empty_dict_when_no_mcp_servers (line 8) | def test_returns_empty_dict_when_no_mcp_servers():
function test_returns_valid_mcp_servers (line 14) | def test_returns_valid_mcp_servers():
function test_accepts_server_with_name_field (line 21) | def test_accepts_server_with_name_field():
function test_accepts_server_with_env (line 32) | def test_accepts_server_with_env():
function test_raises_for_non_dict_mcp_servers (line 47) | def test_raises_for_non_dict_mcp_servers():
function test_raises_for_invalid_server_config (line 54) | def test_raises_for_invalid_server_config():
function test_raises_for_missing_command (line 61) | def test_raises_for_missing_command():
function test_raises_for_missing_args (line 68) | def test_raises_for_missing_args():
function test_raises_for_non_string_command (line 75) | def test_raises_for_non_string_command():
function test_raises_for_non_list_args (line 82) | def test_raises_for_non_list_args():
function test_raises_for_empty_args (line 89) | def test_raises_for_empty_args():
function test_raises_for_non_dict_env (line 98) | def test_raises_for_non_dict_env():
FILE: tests/config/test_read_config_file.py
function test_reads_valid_json (line 10) | def test_reads_valid_json():
function test_raises_file_not_found (line 19) | def test_raises_file_not_found():
function test_raises_permission_error (line 27) | def test_raises_permission_error():
function test_raises_json_decode_error (line 36) | def test_raises_json_decode_error():
FILE: tests/config/test_set_env_vars.py
function test_sets_string_env_vars (line 9) | def test_sets_string_env_vars():
function test_skips_empty_values (line 19) | def test_skips_empty_values():
function test_skips_non_string_values (line 29) | def test_skips_non_string_values():
function test_handles_empty_dict (line 41) | def test_handles_empty_dict():
FILE: tests/config/test_update_config_file.py
function test_update_config_file_success (line 13) | def test_update_config_file_success():
function test_update_config_file_no_config (line 34) | def test_update_config_file_no_config():
function test_update_config_file_merge_nested (line 41) | def test_update_config_file_merge_nested():
FILE: tests/config/test_validate_config_structure.py
function test_valid_config_passes (line 8) | def test_valid_config_passes():
function test_valid_config_with_empty_env (line 15) | def test_valid_config_with_empty_env():
function test_raises_for_non_dict (line 22) | def test_raises_for_non_dict():
function test_raises_for_missing_default_model (line 29) | def test_raises_for_missing_default_model():
function test_raises_for_non_string_default_model (line 36) | def test_raises_for_non_string_default_model():
function test_raises_for_missing_env (line 43) | def test_raises_for_missing_env():
function test_raises_for_non_dict_env (line 50) | def test_raises_for_non_dict_env():
FILE: tests/conftest.py
function mock_ui (line 14) | def mock_ui():
function mock_session (line 20) | def mock_session():
function mock_models (line 26) | def mock_models():
function mock_config (line 32) | def mock_config():
function mock_usage (line 42) | def mock_usage():
function make_mock_process (line 59) | def make_mock_process():
function make_tool_call (line 72) | def make_tool_call():
function patched_commands_env (line 91) | def patched_commands_env(monkeypatch, mock_ui, mock_session):
FILE: tests/main/test_error_handling.py
function mock_repl (line 13) | def mock_repl():
function test_handle_user_request_with_error (line 21) | async def test_handle_user_request_with_error(mock_repl):
function test_handle_user_request_with_model_http_error (line 42) | async def test_handle_user_request_with_model_http_error(mock_repl):
function test_handle_user_request_success (line 60) | async def test_handle_user_request_success(mock_repl):
function test_handle_user_request_cancellation (line 76) | async def test_handle_user_request_cancellation(mock_repl):
FILE: tests/mcp/test_create_mcp_server.py
function test_creates_server_with_minimal_config (line 6) | def test_creates_server_with_minimal_config():
function test_creates_server_with_custom_name (line 19) | def test_creates_server_with_custom_name():
function test_creates_server_with_env_vars (line 28) | def test_creates_server_with_env_vars():
function test_formats_display_name_from_key (line 42) | def test_formats_display_name_from_key():
FILE: tests/mcp/test_format_display_name.py
function test_converts_simple_lowercase (line 6) | def test_converts_simple_lowercase():
function test_converts_hyphenated_names (line 11) | def test_converts_hyphenated_names():
function test_converts_underscored_names (line 17) | def test_converts_underscored_names():
function test_converts_mixed_separators (line 23) | def test_converts_mixed_separators():
function test_handles_already_capitalized (line 29) | def test_handles_already_capitalized():
function test_handles_empty_string (line 35) | def test_handles_empty_string():
FILE: tests/mcp/test_load_mcp_servers.py
function test_loads_valid_servers (line 10) | def test_loads_valid_servers():
function test_returns_empty_list_when_no_mcp_servers (line 33) | def test_returns_empty_list_when_no_mcp_servers():
function test_returns_empty_list_on_config_error (line 43) | def test_returns_empty_list_on_config_error():
function test_handles_server_creation_failure (line 51) | def test_handles_server_creation_failure(caplog):
function test_warns_when_all_servers_fail (line 82) | def test_warns_when_all_servers_fail(caplog):
function test_handles_unexpected_errors (line 107) | def test_handles_unexpected_errors(caplog):
FILE: tests/mcp/test_validate_server_config.py
function test_valid_config_passes (line 8) | def test_valid_config_passes():
function test_valid_config_with_multiple_args (line 16) | def test_valid_config_with_multiple_args():
function test_raises_for_non_dict (line 31) | def test_raises_for_non_dict():
function test_raises_for_missing_command (line 39) | def test_raises_for_missing_command():
function test_raises_for_empty_command (line 47) | def test_raises_for_empty_command():
function test_raises_for_missing_args (line 55) | def test_raises_for_missing_args():
function test_raises_for_non_list_args (line 63) | def test_raises_for_non_list_args():
function test_raises_for_empty_args (line 71) | def test_raises_for_empty_args():
function test_accepts_optional_fields (line 81) | def test_accepts_optional_fields():
function test_raises_for_non_dict_mcpservers (line 99) | def test_raises_for_non_dict_mcpservers():
function test_returns_empty_dict_when_no_mcpservers (line 107) | def test_returns_empty_dict_when_no_mcpservers():
function test_raises_for_non_dict_env (line 114) | def test_raises_for_non_dict_env():
FILE: tests/setup/test_create_config.py
function test_create_config_includes_all_defaults (line 10) | def test_create_config_includes_all_defaults():
function test_create_config_with_no_api_keys (line 57) | def test_create_config_with_no_api_keys():
function test_create_config_filters_empty_api_keys (line 80) | def test_create_config_filters_empty_api_keys():
FILE: tests/tools/conftest.py
function mock_ctx (line 8) | def mock_ctx():
FILE: tests/tools/test_find.py
function mock_context (line 13) | def mock_context():
function test_find_files_with_fd (line 25) | async def test_find_files_with_fd(
function test_find_dirs_with_fd (line 43) | async def test_find_dirs_with_fd(mock_subprocess_exec, mock_which, make_...
function test_find_content_with_rg (line 59) | async def test_find_content_with_rg(
function test_find_content_case_insensitive (line 77) | async def test_find_content_case_insensitive(
function test_find_content_with_include_pattern (line 94) | async def test_find_content_with_include_pattern(
function test_find_content_with_ag_fallback (line 113) | async def test_find_content_with_ag_fallback(
function test_find_content_python_fallback (line 132) | async def test_find_content_python_fallback(mock_open, mock_walk, mock_w...
function test_find_no_results (line 151) | async def test_find_no_results(mock_subprocess_exec, mock_which, make_mo...
function test_find_files_python_fallback (line 163) | async def test_find_files_python_fallback(mock_walk, mock_which, mock_co...
FILE: tests/tools/test_read_file.py
function mock_context (line 13) | def mock_context():
function test_read_file_success (line 23) | async def test_read_file_success(mock_context):
function test_read_file_errors (line 39) | async def test_read_file_errors(side_effect, expected, mock_context):
function test_read_file_real_file (line 45) | async def test_read_file_real_file(tmp_path, mock_context):
function test_read_file_empty_file (line 52) | async def test_read_file_empty_file(mock_context):
function test_read_file_large_content (line 58) | async def test_read_file_large_content(mock_context):
FILE: tests/tools/test_update_file.py
function test_update_file_success (line 14) | async def test_update_file_success(mock_ctx):
function test_update_file_content_not_found (line 37) | async def test_update_file_content_not_found(mock_ctx):
function test_update_file_content_not_found_long_content (line 54) | async def test_update_file_content_not_found_long_content(mock_ctx):
function test_update_file_not_found (line 71) | async def test_update_file_not_found(mock_ctx):
function test_update_file_read_error (line 83) | async def test_update_file_read_error(mock_ctx):
function test_update_file_write_error (line 95) | async def test_update_file_write_error(mock_ctx):
function test_update_file_with_real_file (line 120) | async def test_update_file_with_real_file(mock_ctx):
function test_update_file_only_first_occurrence (line 147) | async def test_update_file_only_first_occurrence(mock_ctx):
function test_update_file_preserves_encoding (line 165) | async def test_update_file_preserves_encoding(mock_ctx):
function test_update_file_identical_content (line 187) | async def test_update_file_identical_content(mock_ctx):
FILE: tests/usage/test_usage_tracker.py
function test_model_usage_initial_state (line 12) | def test_model_usage_initial_state():
function test_model_usage_add_usage (line 22) | def test_model_usage_add_usage():
function test_usage_tracker_initial_state (line 46) | def test_usage_tracker_initial_state():
function test_record_usage_basic (line 56) | def test_record_usage_basic():
function test_record_usage_with_cached_tokens (line 87) | def test_record_usage_with_cached_tokens():
function test_record_usage_multiple_models (line 110) | def test_record_usage_multiple_models():
function test_record_usage_same_model_multiple_times (line 140) | def test_record_usage_same_model_multiple_times():
function test_cost_calculation (line 170) | def test_cost_calculation():
function test_unknown_model_fallback (line 192) | def test_unknown_model_fallback():
FILE: tests/utils/test_command_parser.py
function test_simple_command (line 7) | def test_simple_command():
function test_commands_with_paths (line 13) | def test_commands_with_paths():
function test_chained_commands_with_and (line 19) | def test_chained_commands_with_and():
function test_chained_commands_with_or (line 24) | def test_chained_commands_with_or():
function test_piped_commands (line 28) | def test_piped_commands():
function test_semicolon_separated (line 33) | def test_semicolon_separated():
function test_mixed_separators (line 38) | def test_mixed_separators():
function test_quoted_arguments (line 42) | def test_quoted_arguments():
function test_empty_and_whitespace (line 48) | def test_empty_and_whitespace():
function test_single_command_allowed (line 54) | def test_single_command_allowed():
function test_all_commands_must_be_allowed (line 60) | def test_all_commands_must_be_allowed():
function test_empty_allowed_set (line 66) | def test_empty_allowed_set():
function test_command_with_path (line 71) | def test_command_with_path():
function test_single_command_display (line 76) | def test_single_command_display():
function test_multiple_commands_display (line 80) | def test_multiple_commands_display():
FILE: tests/utils/test_error_handler.py
function test_extract_error_message_model_http_error (line 16) | def test_extract_error_message_model_http_error():
function test_extract_error_message_model_http_error_direct_message (line 28) | def test_extract_error_message_model_http_error_direct_message():
function test_extract_error_message_malformed_function_call (line 40) | def test_extract_error_message_malformed_function_call():
function test_extract_error_message_content_field_missing (line 48) | def test_extract_error_message_content_field_missing():
function test_extract_error_message_long_error (line 59) | def test_extract_error_message_long_error():
function test_extract_error_message_provider_error_openai (line 70) | def test_extract_error_message_provider_error_openai():
function test_extract_error_message_provider_error_anthropic (line 86) | def test_extract_error_message_provider_error_anthropic():
function test_extract_error_message_provider_error_google (line 102) | def test_extract_error_message_provider_error_google():
function test_should_log_error_known_errors (line 118) | def test_should_log_error_known_errors():
function test_should_log_error_unknown_errors (line 134) | def test_should_log_error_unknown_errors():
function test_save_error_log (line 141) | def test_save_error_log():
function test_handle_error_with_logging (line 164) | async def test_handle_error_with_logging():
function test_handle_error_without_logging (line 186) | async def test_handle_error_without_logging():
function test_extract_error_message_with_regex_extraction (line 201) | def test_extract_error_message_with_regex_extraction():
FILE: tests/utils/test_guide.py
function test_load_guide_with_file (line 6) | def test_load_guide_with_file(tmp_path, monkeypatch):
function test_load_guide_without_file (line 19) | def test_load_guide_without_file(tmp_path, monkeypatch):
FILE: tests/utils/test_input.py
function test_create_multiline_keybindings (line 19) | def test_create_multiline_keybindings():
function test_create_prompt_style (line 26) | def test_create_prompt_style():
function test_prompt_continuation (line 35) | def test_prompt_continuation():
function test_create_multiline_prompt_session (line 43) | def test_create_multiline_prompt_session():
function test_constants (line 51) | def test_constants():
Condensed preview — 93 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (249K chars).
[
{
"path": ".flake8",
"chars": 51,
"preview": "[flake8]\nmax-line-length=100\nignore=E203,E501,W503\n"
},
{
"path": ".github/workflows/release.yml",
"chars": 658,
"preview": "name: Release to PyPI\n\non:\n push:\n branches:\n - main\n\njobs:\n deploy:\n runs-on: ubuntu-latest\n environmen"
},
{
"path": ".gitignore",
"chars": 443,
"preview": "# Byte-compiled / optimized files\n__pycache__/\n*.py[cod]\n*$py.class\n*.so\n\n# Distribution / packaging\ndist/\nbuild/\n*.egg-"
},
{
"path": "LICENSE",
"chars": 1070,
"preview": "MIT License\n\nCopyright (c) 2025 Gavin Vickery\n\nPermission is hereby granted, free of charge, to any person obtaining a c"
},
{
"path": "Makefile",
"chars": 389,
"preview": ".PHONY: install clean lint format build test\n\ninstall:\n\tpip install -e \".[dev]\"\n\nrun:\n\tenv/bin/sidekick\n\ndebug:\n\tenv/bin"
},
{
"path": "README.md",
"chars": 4724,
"preview": "# Sidekick (Beta)\n\n[](https://badge.fury.io/py/sidekick-cli)\n["
},
{
"path": "pyproject.toml",
"chars": 1275,
"preview": "[build-system]\nrequires = [\"setuptools>=61.0.0\", \"wheel\"]\nbuild-backend = \"setuptools.build_meta\"\n\n[project]\nname = \"sid"
},
{
"path": "pytest.ini",
"chars": 144,
"preview": "[pytest]\ntestpaths = tests\npython_files = test_*.py\npython_classes = Test*\npython_functions = test_*\naddopts = -v --tb=s"
},
{
"path": "src/sidekick/__init__.py",
"chars": 49,
"preview": "from sidekick.main import app\n\n__all__ = [\"app\"]\n"
},
{
"path": "src/sidekick/agent.py",
"chars": 5995,
"preview": "import asyncio\nimport logging\nfrom pathlib import Path\nfrom typing import Any, Optional\n\nfrom pydantic_ai import Agent, "
},
{
"path": "src/sidekick/commands/__init__.py",
"chars": 1314,
"preview": "\"\"\"Command handlers for Sidekick CLI.\"\"\"\n\nfrom sidekick import ui\nfrom sidekick.commands.clear import handle_clear\nfrom "
},
{
"path": "src/sidekick/commands/clear.py",
"chars": 357,
"preview": "\"\"\"Handle /clear command.\"\"\"\n\nfrom sidekick import ui\n\n\nasync def handle_clear(message_history):\n \"\"\"Handle /clear co"
},
{
"path": "src/sidekick/commands/dump.py",
"chars": 3203,
"preview": "\"\"\"Handle /dump command.\"\"\"\n\nfrom sidekick import ui\n\nDUMP_FILE_PATH = \"dump.log\"\n\n\ndef recursive_expand(obj, indent=0):"
},
{
"path": "src/sidekick/commands/help.py",
"chars": 166,
"preview": "\"\"\"Handle /help command.\"\"\"\n\nfrom sidekick import ui\n\n\nasync def handle_help():\n \"\"\"Handle /help command - show avail"
},
{
"path": "src/sidekick/commands/model.py",
"chars": 1968,
"preview": "\"\"\"Handle /model command.\"\"\"\n\nimport logging\n\nfrom rich.table import Table\n\nfrom sidekick import ui\nfrom sidekick.config"
},
{
"path": "src/sidekick/commands/usage.py",
"chars": 1774,
"preview": "\"\"\"Handle /usage command.\"\"\"\n\nfrom rich.text import Text\n\nfrom sidekick import ui\nfrom sidekick.usage import usage_track"
},
{
"path": "src/sidekick/commands/yolo.py",
"chars": 461,
"preview": "\"\"\"Handle /yolo command.\"\"\"\n\nfrom sidekick import ui\nfrom sidekick.session import session\n\n\nasync def handle_yolo():\n "
},
{
"path": "src/sidekick/config.py",
"chars": 7059,
"preview": "\"\"\"Configuration management for Sidekick CLI.\"\"\"\n\nimport json\nimport os\nfrom pathlib import Path\nfrom typing import Any,"
},
{
"path": "src/sidekick/constants.py",
"chars": 3675,
"preview": "APP_NAME = \"Sidekick\"\nAPP_VERSION = \"0.5.1\"\n\nMODELS = {\n \"anthropic:claude-opus-4-0\": {\n \"pricing\": {\n "
},
{
"path": "src/sidekick/deps.py",
"chars": 343,
"preview": "from dataclasses import dataclass\nfrom typing import Any, Awaitable, Callable, Optional\n\n\n@dataclass\nclass ToolDeps:\n "
},
{
"path": "src/sidekick/main.py",
"chars": 2864,
"preview": "import asyncio\nimport logging\nimport sys\n\nimport typer\nfrom rich.console import Console\n\nfrom sidekick import ui\nfrom si"
},
{
"path": "src/sidekick/mcp/__init__.py",
"chars": 232,
"preview": "\"\"\"MCP (Model Context Protocol) module for managing servers and agents.\"\"\"\n\nfrom .agent import MCPAgent\nfrom .servers im"
},
{
"path": "src/sidekick/mcp/agent.py",
"chars": 4185,
"preview": "\"\"\"Agent wrapper that manages MCP server lifecycle.\n\nThis module provides a wrapper around pydantic_ai.Agent that ensure"
},
{
"path": "src/sidekick/mcp/servers.py",
"chars": 5186,
"preview": "\"\"\"MCP server utilities and configurations.\"\"\"\n\nimport asyncio\nimport logging\nimport os\nfrom contextlib import asynccont"
},
{
"path": "src/sidekick/messages.py",
"chars": 4404,
"preview": "\"\"\"Message history management for Sidekick sessions.\"\"\"\n\nimport logging\nfrom dataclasses import dataclass, field\nfrom ty"
},
{
"path": "src/sidekick/prompts/system.txt",
"chars": 3596,
"preview": "You are **Sidekick**, a CLI assistant running in the user's terminal.\n\n### Understanding User Intent\n- **Action requests"
},
{
"path": "src/sidekick/repl.py",
"chars": 4736,
"preview": "import asyncio\nimport logging\nimport os\nimport signal\nimport subprocess\nimport sys\n\nfrom sidekick import ui\nfrom sidekic"
},
{
"path": "src/sidekick/session.py",
"chars": 716,
"preview": "from dataclasses import dataclass, field\nfrom typing import Any, Dict, Optional, Set\n\n\n@dataclass\nclass Session:\n cur"
},
{
"path": "src/sidekick/setup.py",
"chars": 5046,
"preview": "import json\nfrom pathlib import Path\nfrom typing import Dict, Optional\n\nfrom rich.console import Console\nfrom rich.panel"
},
{
"path": "src/sidekick/tools/__init__.py",
"chars": 58,
"preview": "from .wrapper import create_tools\n\nTOOLS = create_tools()\n"
},
{
"path": "src/sidekick/tools/common.py",
"chars": 1828,
"preview": "EXCLUDE_DIRS = {\n \".git\",\n \".svn\",\n \".hg\",\n \".bzr\",\n \"node_modules\",\n \"bower_components\",\n \"vendor\""
},
{
"path": "src/sidekick/tools/find.py",
"chars": 9872,
"preview": "import asyncio\nimport fnmatch\nimport os\nimport re\nimport shutil\nfrom pathlib import Path\nfrom typing import List, Option"
},
{
"path": "src/sidekick/tools/git.py",
"chars": 4903,
"preview": "import asyncio\nimport subprocess\n\nfrom pydantic_ai import ModelRetry, RunContext\n\nfrom sidekick.deps import ToolDeps\n\n\na"
},
{
"path": "src/sidekick/tools/list.py",
"chars": 6910,
"preview": "import asyncio\nimport os\nimport shutil\nfrom pathlib import Path\nfrom typing import Dict, List, Tuple\n\nfrom pydantic_ai i"
},
{
"path": "src/sidekick/tools/read_file.py",
"chars": 873,
"preview": "import logging\n\nfrom pydantic_ai import RunContext\n\nfrom sidekick.deps import ToolDeps\n\nlog = logging.getLogger(__name__"
},
{
"path": "src/sidekick/tools/run_command.py",
"chars": 1335,
"preview": "import asyncio\nimport subprocess\n\nfrom pydantic_ai import RunContext\n\nfrom sidekick import ui\nfrom sidekick.deps import "
},
{
"path": "src/sidekick/tools/update_file.py",
"chars": 2066,
"preview": "import asyncio\n\nfrom pydantic_ai import ModelRetry, RunContext\n\nfrom sidekick import ui\nfrom sidekick.deps import ToolDe"
},
{
"path": "src/sidekick/tools/wrapper.py",
"chars": 704,
"preview": "from pydantic_ai import Tool\n\nfrom sidekick.tools.find import find\nfrom sidekick.tools.git import git_add, git_commit\nfr"
},
{
"path": "src/sidekick/tools/write_file.py",
"chars": 1127,
"preview": "import asyncio\nimport logging\nfrom pathlib import Path\n\nfrom pydantic_ai import RunContext\n\nfrom sidekick import ui\nfrom"
},
{
"path": "src/sidekick/ui/__init__.py",
"chars": 3188,
"preview": "\"\"\"Clean, simplified UI module.\"\"\"\n\nfrom sidekick.ui.core import BANNER, SpinnerStyle\nfrom sidekick.ui.formatting import"
},
{
"path": "src/sidekick/ui/colors.py",
"chars": 386,
"preview": "\"\"\"Color definitions for the UI module.\"\"\"\n\n\nclass Colors:\n primary = \"medium_purple1\" # Agent responses\n seconda"
},
{
"path": "src/sidekick/ui/core.py",
"chars": 1469,
"preview": "\"\"\"Core UI functions including banner and spinner management.\"\"\"\n\nfrom rich.console import Console\nfrom rich.padding imp"
},
{
"path": "src/sidekick/ui/formatting.py",
"chars": 6958,
"preview": "\"\"\"Formatting functions for syntax highlighting, diffs, and display.\"\"\"\n\nimport difflib\nfrom pathlib import Path\n\nfrom r"
},
{
"path": "src/sidekick/ui/manager.py",
"chars": 11468,
"preview": "\"\"\"UI Manager for centralized output control and spacing logic.\"\"\"\n\nfrom enum import Enum, auto\nfrom typing import Optio"
},
{
"path": "src/sidekick/ui/special.py",
"chars": 2057,
"preview": "\"\"\"Special UI functions that need external dependencies.\"\"\"\n\nfrom rich.padding import Padding\nfrom rich.text import Text"
},
{
"path": "src/sidekick/ui/spinner.py",
"chars": 2847,
"preview": "\"\"\"Spinner management for the UI module.\"\"\"\n\nimport asyncio\nimport random\nfrom typing import Optional\n\nfrom rich.console"
},
{
"path": "src/sidekick/usage.py",
"chars": 3337,
"preview": "\"\"\"Usage tracking for model API calls.\"\"\"\n\nfrom dataclasses import dataclass, field\nfrom typing import Any, Dict, Option"
},
{
"path": "src/sidekick/utils/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "src/sidekick/utils/command.py",
"chars": 3541,
"preview": "\"\"\"Command parser for extracting individual commands from shell command strings.\"\"\"\n\nimport shlex\nfrom typing import Lis"
},
{
"path": "src/sidekick/utils/error.py",
"chars": 5568,
"preview": "\"\"\"Simplified error handling for Sidekick CLI.\"\"\"\n\nimport asyncio\nimport re\nimport tempfile\nimport traceback\nfrom dateti"
},
{
"path": "src/sidekick/utils/guide.py",
"chars": 259,
"preview": "from pathlib import Path\n\n\ndef load_guide():\n \"\"\"Load the project guide from SIDEKICK.md if it exists.\"\"\"\n guide_p"
},
{
"path": "src/sidekick/utils/input.py",
"chars": 2839,
"preview": "\"\"\"Input utilities for the Sidekick CLI.\"\"\"\n\nfrom typing import Optional\n\nfrom prompt_toolkit import PromptSession\nfrom "
},
{
"path": "src/sidekick/utils/logger.py",
"chars": 1407,
"preview": "\"\"\"Debug logging configuration for Sidekick CLI.\"\"\"\n\nimport logging\n\nfrom sidekick import ui\n\n\nclass UILogHandler(loggin"
},
{
"path": "tests/__init__.py",
"chars": 32,
"preview": "# Test package for Sidekick CLI\n"
},
{
"path": "tests/agent/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "tests/agent/test_process_node.py",
"chars": 2933,
"preview": "from unittest.mock import Mock, patch\n\nimport pytest\nfrom pydantic_ai import messages\n\nfrom sidekick.agent import _proce"
},
{
"path": "tests/commands/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "tests/commands/test_handle_command.py",
"chars": 1383,
"preview": "\"\"\"Command handler routing tests (DRY parametrised).\"\"\"\n\nfrom unittest.mock import AsyncMock\n\nimport pytest\n\nfrom sideki"
},
{
"path": "tests/commands/test_handle_dump.py",
"chars": 1313,
"preview": "\"\"\"Test /dump command handler.\"\"\"\n\nfrom unittest.mock import Mock, patch\n\nimport pytest\n\nfrom sidekick.commands import h"
},
{
"path": "tests/commands/test_handle_model.py",
"chars": 2112,
"preview": "\"\"\"Test /model command handler.\"\"\"\n\nfrom unittest.mock import MagicMock, patch\n\nimport pytest\n\nfrom sidekick.commands im"
},
{
"path": "tests/commands/test_handle_yolo.py",
"chars": 1137,
"preview": "\"\"\"Test /yolo command handler.\"\"\"\n\nfrom unittest.mock import patch\n\nimport pytest\n\nfrom sidekick.commands import handle_"
},
{
"path": "tests/config/__init__.py",
"chars": 23,
"preview": "# Config tests package\n"
},
{
"path": "tests/config/test_config_exists.py",
"chars": 523,
"preview": "\"\"\"Tests for config_exists function.\"\"\"\n\nfrom unittest.mock import patch\n\nfrom sidekick.config import config_exists\n\n\nde"
},
{
"path": "tests/config/test_deep_merge_dicts.py",
"chars": 2369,
"preview": "from sidekick.config import deep_merge_dicts\n\n\ndef test_simple_merge():\n \"\"\"Test merging simple dictionaries.\"\"\"\n "
},
{
"path": "tests/config/test_ensure_config_structure.py",
"chars": 7315,
"preview": "import json\nimport tempfile\nimport time\nfrom pathlib import Path\nfrom unittest.mock import patch\n\nimport pytest\n\nfrom sr"
},
{
"path": "tests/config/test_get_config_path.py",
"chars": 309,
"preview": "\"\"\"Tests for get_config_path function.\"\"\"\n\nfrom pathlib import Path\n\nfrom sidekick.config import get_config_path\n\n\ndef t"
},
{
"path": "tests/config/test_parse_mcp_servers.py",
"chars": 4077,
"preview": "\"\"\"Tests for parse_mcp_servers function.\"\"\"\n\nimport pytest\n\nfrom sidekick.config import ConfigValidationError, parse_mcp"
},
{
"path": "tests/config/test_read_config_file.py",
"chars": 1560,
"preview": "\"\"\"Tests for read_config_file function.\"\"\"\n\nfrom unittest.mock import mock_open, patch\n\nimport pytest\n\nfrom sidekick.con"
},
{
"path": "tests/config/test_set_env_vars.py",
"chars": 1532,
"preview": "\"\"\"Tests for set_env_vars function.\"\"\"\n\nimport os\nfrom unittest.mock import patch\n\nfrom sidekick.config import set_env_v"
},
{
"path": "tests/config/test_update_config_file.py",
"chars": 2063,
"preview": "\"\"\"Test update_config_file function.\"\"\"\n\nimport json\nimport tempfile\nfrom pathlib import Path\nfrom unittest.mock import "
},
{
"path": "tests/config/test_validate_config_structure.py",
"chars": 2054,
"preview": "\"\"\"Tests for validate_config_structure function.\"\"\"\n\nimport pytest\n\nfrom sidekick.config import ConfigValidationError, v"
},
{
"path": "tests/conftest.py",
"chars": 2672,
"preview": "\"\"\"Shared fixtures and helpers for tests.\"\"\"\n\nfrom unittest.mock import AsyncMock, MagicMock, Mock\n\nimport pytest\nfrom p"
},
{
"path": "tests/main/test_error_handling.py",
"chars": 3382,
"preview": "\"\"\"Tests for error handling integration in the REPL.\"\"\"\n\nimport asyncio\nfrom unittest.mock import MagicMock, patch\n\nimpo"
},
{
"path": "tests/mcp/__init__.py",
"chars": 20,
"preview": "# MCP tests package\n"
},
{
"path": "tests/mcp/test_create_mcp_server.py",
"chars": 1760,
"preview": "\"\"\"Tests for create_mcp_server function.\"\"\"\n\nfrom sidekick.mcp.servers import SilentMCPServerStdio, create_mcp_server\n\n\n"
},
{
"path": "tests/mcp/test_format_display_name.py",
"chars": 1238,
"preview": "\"\"\"Tests for format_server_name function.\"\"\"\n\nfrom sidekick.ui import format_server_name\n\n\ndef test_converts_simple_lowe"
},
{
"path": "tests/mcp/test_load_mcp_servers.py",
"chars": 4054,
"preview": "\"\"\"Tests for load_mcp_servers function.\"\"\"\n\nimport logging\nfrom unittest.mock import patch\n\nfrom sidekick.config import "
},
{
"path": "tests/mcp/test_validate_server_config.py",
"chars": 4440,
"preview": "\"\"\"Tests for MCP server config validation.\"\"\"\n\nimport pytest\n\nfrom sidekick.config import ConfigValidationError, parse_m"
},
{
"path": "tests/setup/test_create_config.py",
"chars": 4463,
"preview": "import json\nimport tempfile\nfrom pathlib import Path\nfrom unittest.mock import patch\n\nfrom sidekick.constants import DEF"
},
{
"path": "tests/test_data.py",
"chars": 1040,
"preview": "\"\"\"Shared test data for sidekick tests.\"\"\"\n\n# Valid MCP server configurations\nVALID_MCP_SERVER = {\"command\": \"uvx\", \"arg"
},
{
"path": "tests/tools/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "tests/tools/conftest.py",
"chars": 388,
"preview": "\"\"\"Shared fixtures for tool tests.\"\"\"\n\nimport pytest\nfrom pydantic_ai import RunContext\n\n\n@pytest.fixture\ndef mock_ctx()"
},
{
"path": "tests/tools/test_find.py",
"chars": 5628,
"preview": "\"\"\"Tests for the consolidated find tool.\"\"\"\n\nfrom unittest.mock import MagicMock, patch\n\nimport pytest\nfrom pydantic_ai "
},
{
"path": "tests/tools/test_read_file.py",
"chars": 2067,
"preview": "\"\"\"Tests for read_file tool.\"\"\"\n\nfrom unittest.mock import MagicMock, mock_open, patch\n\nimport pytest\nfrom pydantic_ai i"
},
{
"path": "tests/tools/test_update_file.py",
"chars": 7619,
"preview": "\"\"\"Tests for update_file tool.\"\"\"\n\nimport tempfile\nfrom pathlib import Path\nfrom unittest.mock import mock_open, patch\n\n"
},
{
"path": "tests/ui/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "tests/usage/__init__.py",
"chars": 46,
"preview": "\"\"\"Tests for usage tracking functionality.\"\"\"\n"
},
{
"path": "tests/usage/test_usage_tracker.py",
"chars": 6095,
"preview": "\"\"\"Tests for the UsageTracker class.\"\"\"\n\nfrom unittest.mock import Mock\n\nimport pytest\n\nfrom sidekick.usage import Model"
},
{
"path": "tests/utils/__init__.py",
"chars": 27,
"preview": "\"\"\"Tests for utilities.\"\"\"\n"
},
{
"path": "tests/utils/test_command_parser.py",
"chars": 2631,
"preview": "\"\"\"Tests for command parser.\"\"\"\n\nfrom sidekick.ui import get_command_display_name\nfrom sidekick.utils.command import ext"
},
{
"path": "tests/utils/test_error_handler.py",
"chars": 6396,
"preview": "\"\"\"Tests for error_handler utility.\"\"\"\n\nfrom unittest.mock import MagicMock\n\nimport pytest\nfrom pydantic_ai.exceptions i"
},
{
"path": "tests/utils/test_guide.py",
"chars": 654,
"preview": "\"\"\"Tests for guide utility functions.\"\"\"\n\nfrom sidekick.utils.guide import load_guide\n\n\ndef test_load_guide_with_file(tm"
},
{
"path": "tests/utils/test_input.py",
"chars": 1883,
"preview": "\"\"\"Tests for input utilities.\"\"\"\n\nfrom prompt_toolkit import PromptSession\nfrom prompt_toolkit.key_binding import KeyBin"
}
]
About this extraction
This page contains the full source code of the geekforbrains/sidekick-cli GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 93 files (223.0 KB), approximately 54.0k tokens, and a symbol index with 355 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.