├── .flake8 ├── .github └── workflows │ └── release.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── pyproject.toml ├── screenshot.gif ├── setup.py └── src ├── __init__.py └── sidekick ├── __init__.py ├── cli ├── __init__.py ├── commands.py ├── main.py └── repl.py ├── configuration ├── __init__.py ├── defaults.py ├── models.py └── settings.py ├── constants.py ├── core ├── __init__.py ├── agents │ ├── __init__.py │ └── main.py ├── setup │ ├── __init__.py │ ├── agent_setup.py │ ├── base.py │ ├── config_setup.py │ ├── coordinator.py │ ├── environment_setup.py │ ├── telemetry_setup.py │ └── undo_setup.py ├── state.py └── tool_handler.py ├── exceptions.py ├── prompts └── system.txt ├── py.typed ├── services ├── __init__.py ├── mcp.py ├── telemetry.py └── undo_service.py ├── setup.py ├── tools ├── __init__.py ├── base.py ├── read_file.py ├── run_command.py ├── update_file.py └── write_file.py ├── types.py ├── ui ├── __init__.py ├── console.py ├── constants.py ├── decorators.py ├── input.py ├── keybindings.py ├── output.py ├── panels.py ├── prompt_manager.py ├── tool_ui.py └── validators.py └── utils ├── __init__.py ├── diff_utils.py ├── file_utils.py ├── system.py ├── text_utils.py └── user_configuration.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length=100 3 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release to PyPI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | environment: pypi-publish 12 | steps: 13 | - uses: actions/checkout@v3 14 | with: 15 | fetch-depth: 0 16 | 17 | - name: Set up Python 18 | uses: actions/setup-python@v4 19 | with: 20 | python-version: '3.9' 21 | 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | pip install hatch build twine 26 | 27 | - name: Build package 28 | run: python -m build 29 | 30 | - name: Publish package 31 | uses: pypa/gh-action-pypi-publish@release/v1 32 | with: 33 | password: ${{ secrets.PYPI_API_TOKEN }} 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | *.so 6 | 7 | # Distribution / packaging 8 | dist/ 9 | build/ 10 | *.egg-info/ 11 | *.egg 12 | 13 | # Unit test / coverage reports 14 | .pytest_cache/ 15 | .coverage 16 | htmlcov/ 17 | .tox/ 18 | coverage.xml 19 | *.cover 20 | 21 | # Virtual environments 22 | env/ 23 | venv/ 24 | ENV/ 25 | .env 26 | 27 | # IDE files 28 | .idea/ 29 | .vscode/ 30 | *.swp 31 | *.swo 32 | 33 | # OS specific files 34 | .DS_Store 35 | Thumbs.db 36 | 37 | # Project files 38 | SIDEKICK.md 39 | .python-version 40 | REFACTOR* 41 | .claude/ 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Gavin Vickery 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: install clean lint format test coverage build remove-playwright-binaries restore-playwright-binaries 2 | 3 | install: 4 | pip install -e ".[dev]" 5 | 6 | run: 7 | env/bin/sidekick 8 | 9 | clean: 10 | rm -rf build/ 11 | rm -rf dist/ 12 | rm -rf *.egg-info 13 | find . -type d -name __pycache__ -exec rm -rf {} + 14 | find . -type f -name "*.pyc" -delete 15 | 16 | lint: 17 | black src/ 18 | isort src/ 19 | flake8 src/ 20 | 21 | test: 22 | pytest 23 | 24 | coverage: 25 | pytest --cov=src/sidekick --cov-report=term 26 | 27 | build: 28 | python -m build 29 | 30 | remove-playwright-binaries: 31 | @echo "Removing Playwright binaries for testing..." 32 | @MAC_CACHE="$(HOME)/Library/Caches/ms-playwright"; \ 33 | LINUX_CACHE="$(HOME)/.cache/ms-playwright"; \ 34 | if [ -d "$$MAC_CACHE" ]; then \ 35 | mv "$$MAC_CACHE" "$$MAC_CACHE"_backup; \ 36 | echo "Playwright binaries moved to $$MAC_CACHE"_backup; \ 37 | elif [ -d "$$LINUX_CACHE" ]; then \ 38 | mv "$$LINUX_CACHE" "$$LINUX_CACHE"_backup; \ 39 | echo "Playwright binaries moved to $$LINUX_CACHE"_backup; \ 40 | else \ 41 | echo "No Playwright binaries found. Please run 'playwright install' first if you want to test the reinstall flow."; \ 42 | fi 43 | 44 | restore-playwright-binaries: 45 | @echo "Restoring Playwright binaries..." 46 | @MAC_CACHE="$(HOME)/Library/Caches/ms-playwright"; \ 47 | LINUX_CACHE="$(HOME)/.cache/ms-playwright"; \ 48 | if [ -d "$$MAC_CACHE"_backup ]; then \ 49 | mv "$$MAC_CACHE"_backup "$$MAC_CACHE"; \ 50 | echo "Playwright binaries restored from $$MAC_CACHE"_backup; \ 51 | elif [ -d "$$LINUX_CACHE"_backup ]; then \ 52 | mv "$$LINUX_CACHE"_backup "$$LINUX_CACHE"; \ 53 | echo "Playwright binaries restored from $$LINUX_CACHE"_backup; \ 54 | else \ 55 | echo "No backed up Playwright binaries found. Nothing to restore."; \ 56 | fi 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sidekick (Beta) 2 | 3 | [![PyPI version](https://badge.fury.io/py/sidekick-cli.svg)](https://badge.fury.io/py/sidekick-cli) 4 | [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/) 5 | 6 | ![Sidekick Demo](screenshot.gif) 7 | 8 | Your agentic CLI developer. 9 | 10 | ## Overview 11 | 12 | Sidekick is an agentic CLI-based AI tool inspired by Claude Code, Copilot, Windsurf and Cursor. It's meant 13 | to be an open source alternative to these tools, providing a similar experience but with the flexibility of 14 | using different LLM providers (Anthropic, OpenAI, Google Gemini) while keeping the agentic workflow. 15 | 16 | *Sidekick is currently in beta and under active development. Please [report issues](https://github.com/geekforbrains/sidekick-cli/issues) or share feedback!* 17 | 18 | ## Features 19 | 20 | - No vendor lock-in. Use whichever LLM provider you prefer. 21 | - MCP support 22 | - Use /undo when AI breaks things. 23 | - Easily switch between models in the same session. 24 | - JIT-style system prompt injection ensures Sidekick doesn't lose the plot. 25 | - Per-project guide. Adjust Sidekick's behavior to suit your needs. 26 | - CLI-first design. Ditch the clunky IDE. 27 | - Cost and token tracking. 28 | - Per command or per session confirmation skipping. 29 | 30 | ## Roadmap 31 | 32 | - Tests 😅 33 | - More LLM providers, including Ollama 34 | 35 | ## Quick Start 36 | 37 | Install Sidekick. 38 | 39 | ``` 40 | pip install sidekick-cli 41 | ``` 42 | 43 | On first run, you'll be asked to configure your LLM providers. 44 | 45 | ``` 46 | sidekick 47 | ``` 48 | 49 | ## Configuration 50 | 51 | After initial setup, Sidekick saves a config file to `~/.config/sidekick.json`. You can open and 52 | edit this file as needed. Future updates will make editing easier directly from within Sidekick. 53 | 54 | ### MCP Support 55 | 56 | Sidekick supports Model Context Protocol (MCP) servers. You can configure MCP servers in your `~/.config/sidekick.json` file: 57 | 58 | ```json 59 | { 60 | "mcpServers": { 61 | "fetch": { 62 | "command": "uvx", 63 | "args": ["mcp-server-fetch"] 64 | }, 65 | "github": { 66 | "command": "npx", 67 | "args": ["-y", "@modelcontextprotocol/server-github"], 68 | "env": { 69 | "GITHUB_PERSONAL_ACCESS_TOKEN": "" 70 | } 71 | } 72 | } 73 | } 74 | ``` 75 | 76 | 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/). 77 | 78 | ### Available Commands 79 | 80 | - `/help` - Show available commands 81 | - `/yolo` - Toggle "yolo" mode (skip tool confirmations) 82 | - `/clear` - Clear message history 83 | - `/compact` - Summarize message history and clear old messages 84 | - `/model` - List available models 85 | - `/model ` - Switch to a specific model (by index) 86 | - `/undo` - Undo most recent changes 87 | - `/dump` - Show current message history (for debugging) 88 | - `exit` - Exit the application 89 | 90 | ## Customization 91 | 92 | Sidekick supports the use of a "guide". This is a `SIDEKICK.md` file in the project root that contains 93 | instructions for Sidekick. Helpful for specifying tech stack, project structure, development 94 | preferences etc. 95 | 96 | ## Telemetry 97 | 98 | Sidekick uses [Sentry](https://sentry.io/) for error tracking and usage analytics. You can disable this by 99 | starting with the `--no-telemetry` flag. 100 | 101 | ``` 102 | sidekick --no-telemetry 103 | ``` 104 | 105 | ## Requirements 106 | 107 | - Python 3.10 or higher 108 | - Git (for undo functionality) 109 | 110 | ## Installation 111 | 112 | ### Using pip 113 | 114 | ```bash 115 | pip install sidekick-cli 116 | ``` 117 | 118 | ### From Source 119 | 120 | 1. Clone the repository 121 | 2. Install dependencies: `pip install .` (or `pip install -e .` for development) 122 | 123 | ## Development 124 | 125 | ```bash 126 | # Install development dependencies 127 | make install 128 | 129 | # Run linting 130 | make lint 131 | 132 | # Run tests 133 | make test 134 | ``` 135 | 136 | ## Release Process 137 | 138 | When preparing a new release: 139 | 140 | 1. Update version numbers in: 141 | - `pyproject.toml` 142 | - `src/sidekick/constants.py` (APP_VERSION) 143 | 144 | 2. Commit the version changes: 145 | ```bash 146 | git add pyproject.toml src/sidekick/constants.py 147 | git commit -m "chore: bump version to X.Y.Z" 148 | ``` 149 | 150 | 3. Create and push a tag: 151 | ```bash 152 | git tag vX.Y.Z 153 | git push origin vX.Y.Z 154 | ``` 155 | 156 | 4. Create a GitHub release: 157 | ```bash 158 | gh release create vX.Y.Z --title "vX.Y.Z" --notes "Release notes here" 159 | ``` 160 | 161 | 5. Merge to main branch and push to trigger PyPI release (automated) 162 | 163 | ### Commit Convention 164 | 165 | This project follows the [Conventional Commits](https://www.conventionalcommits.org/) specification for commit messages: 166 | 167 | - `feat:` - New features 168 | - `fix:` - Bug fixes 169 | - `docs:` - Documentation changes 170 | - `style:` - Code style changes (formatting, etc.) 171 | - `refactor:` - Code refactoring 172 | - `perf:` - Performance improvements 173 | - `test:` - Test additions or modifications 174 | - `chore:` - Maintenance tasks (version bumps, etc.) 175 | - `build:` - Build system changes 176 | - `ci:` - CI configuration changes 177 | 178 | ## Links 179 | 180 | - [PyPI Package](https://pypi.org/project/sidekick-cli/) 181 | - [GitHub Issues](https://github.com/geekforbrains/sidekick-cli/issues) 182 | - [GitHub Repository](https://github.com/geekforbrains/sidekick-cli) 183 | 184 | ## License 185 | 186 | MIT 187 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "sidekick-cli" 7 | version = "0.5.1" 8 | description = "Your agentic CLI developer." 9 | keywords = ["cli", "agent", "development", "automation"] 10 | readme = "README.md" 11 | requires-python = ">=3.10" 12 | license = "MIT" 13 | authors = [ 14 | { name = "Gavin Vickery", email = "gavin@geekforbrains.com" }, 15 | ] 16 | classifiers = [ 17 | "Development Status :: 4 - Beta", 18 | "Intended Audience :: Developers", 19 | "Programming Language :: Python :: 3", 20 | "Programming Language :: Python :: 3.10", 21 | "Programming Language :: Python :: 3.11", 22 | "Programming Language :: Python :: 3.12", 23 | "Programming Language :: Python :: 3.13", 24 | "Topic :: Software Development", 25 | "Topic :: Utilities", 26 | ] 27 | dependencies = [ 28 | "prompt_toolkit==3.0.51", 29 | "pydantic-ai[logfire]==0.2.6", 30 | "pygments==2.19.1", 31 | "rich==14.0.0", 32 | "typer==0.15.3", 33 | "sentry_sdk==2.28.0", 34 | ] 35 | 36 | [project.scripts] 37 | sidekick = "sidekick.cli:app" 38 | 39 | [project.optional-dependencies] 40 | dev = [ 41 | "build", 42 | "black", 43 | "flake8", 44 | "isort", 45 | "pytest", 46 | "pytest-cov", 47 | ] 48 | 49 | [project.urls] 50 | Homepage = "https://github.com/geekforbrains/sidekick-cli" 51 | Repository = "https://github.com/geekforbrains/sidekick-cli" 52 | 53 | [tool.black] 54 | line-length = 100 55 | 56 | [tool.isort] 57 | line_length = 100 58 | -------------------------------------------------------------------------------- /screenshot.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geekforbrains/sidekick-cli/2176d4056b3f31f4a2f6fc922cb9c981cd2a1b6f/screenshot.gif -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_namespace_packages 2 | 3 | setup( 4 | package_dir={"": "src"}, 5 | packages=find_namespace_packages(where="src"), 6 | include_package_data=True, 7 | package_data={ 8 | "sidekick": ["prompts/*.txt"], 9 | }, 10 | ) 11 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geekforbrains/sidekick-cli/2176d4056b3f31f4a2f6fc922cb9c981cd2a1b6f/src/__init__.py -------------------------------------------------------------------------------- /src/sidekick/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geekforbrains/sidekick-cli/2176d4056b3f31f4a2f6fc922cb9c981cd2a1b6f/src/sidekick/__init__.py -------------------------------------------------------------------------------- /src/sidekick/cli/__init__.py: -------------------------------------------------------------------------------- 1 | # CLI package 2 | from .main import app 3 | 4 | __all__ = ["app"] 5 | -------------------------------------------------------------------------------- /src/sidekick/cli/commands.py: -------------------------------------------------------------------------------- 1 | """Command system for Sidekick CLI.""" 2 | 3 | from abc import ABC, abstractmethod 4 | from dataclasses import dataclass 5 | from enum import Enum 6 | from typing import Any, Dict, List, Optional, Type 7 | 8 | from .. import utils 9 | from ..configuration.models import ModelRegistry 10 | from ..exceptions import ValidationError 11 | from ..services.undo_service import perform_undo 12 | from ..types import CommandArgs, CommandContext, CommandResult, ProcessRequestCallback 13 | from ..ui import console as ui 14 | 15 | 16 | class CommandCategory(Enum): 17 | """Categories for organizing commands.""" 18 | 19 | SYSTEM = "system" 20 | NAVIGATION = "navigation" 21 | DEVELOPMENT = "development" 22 | MODEL = "model" 23 | DEBUG = "debug" 24 | 25 | 26 | class Command(ABC): 27 | """Base class for all commands.""" 28 | 29 | @property 30 | @abstractmethod 31 | def name(self) -> str: 32 | """The primary name of the command.""" 33 | pass 34 | 35 | @property 36 | @abstractmethod 37 | def aliases(self) -> CommandArgs: 38 | """Alternative names/aliases for the command.""" 39 | pass 40 | 41 | @property 42 | def description(self) -> str: 43 | """Description of what the command does.""" 44 | return "" 45 | 46 | @property 47 | def category(self) -> CommandCategory: 48 | """Category this command belongs to.""" 49 | return CommandCategory.SYSTEM 50 | 51 | @abstractmethod 52 | async def execute(self, args: CommandArgs, context: CommandContext) -> CommandResult: 53 | """ 54 | Execute the command. 55 | 56 | Args: 57 | args: Command arguments (excluding the command name) 58 | context: Execution context with state and config 59 | 60 | Returns: 61 | Command-specific return value 62 | """ 63 | pass 64 | 65 | 66 | @dataclass 67 | class CommandSpec: 68 | """Specification for a command's metadata.""" 69 | 70 | name: str 71 | aliases: List[str] 72 | description: str 73 | category: CommandCategory = CommandCategory.SYSTEM 74 | 75 | 76 | class SimpleCommand(Command): 77 | """Base class for simple commands without complex logic.""" 78 | 79 | def __init__(self, spec: CommandSpec): 80 | self.spec = spec 81 | 82 | @property 83 | def name(self) -> str: 84 | """The primary name of the command.""" 85 | return self.spec.name 86 | 87 | @property 88 | def aliases(self) -> CommandArgs: 89 | """Alternative names/aliases for the command.""" 90 | return self.spec.aliases 91 | 92 | @property 93 | def description(self) -> str: 94 | """Description of what the command does.""" 95 | return self.spec.description 96 | 97 | @property 98 | def category(self) -> CommandCategory: 99 | """Category this command belongs to.""" 100 | return self.spec.category 101 | 102 | 103 | class YoloCommand(SimpleCommand): 104 | """Toggle YOLO mode (skip confirmations).""" 105 | 106 | def __init__(self): 107 | super().__init__( 108 | CommandSpec( 109 | name="yolo", 110 | aliases=["/yolo"], 111 | description="Toggle YOLO mode (skip tool confirmations)", 112 | category=CommandCategory.DEVELOPMENT, 113 | ) 114 | ) 115 | 116 | async def execute(self, args: List[str], context: CommandContext) -> None: 117 | state = context.state_manager.session 118 | state.yolo = not state.yolo 119 | if state.yolo: 120 | await ui.success("Ooh shit, its YOLO time!\n") 121 | else: 122 | await ui.info("Pfft, boring...\n") 123 | 124 | 125 | class DumpCommand(SimpleCommand): 126 | """Dump message history.""" 127 | 128 | def __init__(self): 129 | super().__init__( 130 | CommandSpec( 131 | name="dump", 132 | aliases=["/dump"], 133 | description="Dump the current message history", 134 | category=CommandCategory.DEBUG, 135 | ) 136 | ) 137 | 138 | async def execute(self, args: List[str], context: CommandContext) -> None: 139 | await ui.dump_messages(context.state_manager.session.messages) 140 | 141 | 142 | class ClearCommand(SimpleCommand): 143 | """Clear screen and message history.""" 144 | 145 | def __init__(self): 146 | super().__init__( 147 | CommandSpec( 148 | name="clear", 149 | aliases=["/clear"], 150 | description="Clear the screen and message history", 151 | category=CommandCategory.NAVIGATION, 152 | ) 153 | ) 154 | 155 | async def execute(self, args: List[str], context: CommandContext) -> None: 156 | await ui.clear() 157 | context.state_manager.session.messages = [] 158 | 159 | 160 | class HelpCommand(SimpleCommand): 161 | """Show help information.""" 162 | 163 | def __init__(self, command_registry=None): 164 | super().__init__( 165 | CommandSpec( 166 | name="help", 167 | aliases=["/help"], 168 | description="Show help information", 169 | category=CommandCategory.SYSTEM, 170 | ) 171 | ) 172 | self._command_registry = command_registry 173 | 174 | async def execute(self, args: List[str], context: CommandContext) -> None: 175 | await ui.help(self._command_registry) 176 | 177 | 178 | class UndoCommand(SimpleCommand): 179 | """Undo the last file operation.""" 180 | 181 | def __init__(self): 182 | super().__init__( 183 | CommandSpec( 184 | name="undo", 185 | aliases=["/undo"], 186 | description="Undo the last file operation", 187 | category=CommandCategory.DEVELOPMENT, 188 | ) 189 | ) 190 | 191 | async def execute(self, args: List[str], context: CommandContext) -> None: 192 | success, message = perform_undo(context.state_manager) 193 | if success: 194 | await ui.success(message) 195 | else: 196 | await ui.warning(message) 197 | 198 | 199 | class CompactCommand(SimpleCommand): 200 | """Compact conversation context.""" 201 | 202 | def __init__(self, process_request_callback: Optional[ProcessRequestCallback] = None): 203 | super().__init__( 204 | CommandSpec( 205 | name="compact", 206 | aliases=["/compact"], 207 | description="Summarize and compact the conversation history", 208 | category=CommandCategory.SYSTEM, 209 | ) 210 | ) 211 | self._process_request = process_request_callback 212 | 213 | async def execute(self, args: List[str], context: CommandContext) -> None: 214 | # Use the injected callback or get it from context 215 | process_request = self._process_request or context.process_request 216 | 217 | if not process_request: 218 | await ui.error("Compact command not available - process_request not configured") 219 | return 220 | 221 | # Get the current agent, create a summary of context, and trim message history 222 | await process_request( 223 | "Summarize the conversation so far", context.state_manager, output=False 224 | ) 225 | await ui.success("Context history has been summarized and truncated.") 226 | context.state_manager.session.messages = context.state_manager.session.messages[-2:] 227 | 228 | 229 | class ModelCommand(SimpleCommand): 230 | """Manage model selection.""" 231 | 232 | def __init__(self): 233 | super().__init__( 234 | CommandSpec( 235 | name="model", 236 | aliases=["/model"], 237 | description="List models or select a model (e.g., /model 3 or /model 3 default)", 238 | category=CommandCategory.MODEL, 239 | ) 240 | ) 241 | 242 | async def execute(self, args: CommandArgs, context: CommandContext) -> Optional[str]: 243 | if not args: 244 | # No arguments - list models 245 | await ui.models(context.state_manager) 246 | return None 247 | 248 | # Parse model index 249 | try: 250 | model_index = int(args[0]) 251 | except ValueError: 252 | await ui.error(f"Invalid model index: {args[0]}") 253 | return None 254 | 255 | # Get model list 256 | model_registry = ModelRegistry() 257 | models = list(model_registry.list_models().keys()) 258 | if model_index < 0 or model_index >= len(models): 259 | await ui.error(f"Model index {model_index} out of range") 260 | return None 261 | 262 | # Set the model 263 | model = models[model_index] 264 | context.state_manager.session.current_model = model 265 | 266 | # Check if setting as default 267 | if len(args) > 1 and args[1] == "default": 268 | utils.user_configuration.set_default_model(model, context.state_manager) 269 | await ui.muted("Updating default model") 270 | return "restart" 271 | else: 272 | # Show success message with the new model 273 | await ui.success(f"Switched to model: {model}") 274 | return None 275 | 276 | 277 | @dataclass 278 | class CommandDependencies: 279 | """Container for command dependencies.""" 280 | 281 | process_request_callback: Optional[ProcessRequestCallback] = None 282 | command_registry: Optional[Any] = None # Reference to the registry itself 283 | 284 | 285 | class CommandFactory: 286 | """Factory for creating commands with proper dependency injection.""" 287 | 288 | def __init__(self, dependencies: Optional[CommandDependencies] = None): 289 | self.dependencies = dependencies or CommandDependencies() 290 | 291 | def create_command(self, command_class: Type[Command]) -> Command: 292 | """Create a command instance with proper dependencies.""" 293 | # Special handling for commands that need dependencies 294 | if command_class == CompactCommand: 295 | return CompactCommand(self.dependencies.process_request_callback) 296 | elif command_class == HelpCommand: 297 | return HelpCommand(self.dependencies.command_registry) 298 | 299 | # Default creation for commands without dependencies 300 | return command_class() 301 | 302 | def update_dependencies(self, **kwargs) -> None: 303 | """Update factory dependencies.""" 304 | for key, value in kwargs.items(): 305 | if hasattr(self.dependencies, key): 306 | setattr(self.dependencies, key, value) 307 | 308 | 309 | class CommandRegistry: 310 | """Registry for managing commands with auto-discovery and categories.""" 311 | 312 | def __init__(self, factory: Optional[CommandFactory] = None): 313 | self._commands: Dict[str, Command] = {} 314 | self._categories: Dict[CommandCategory, List[Command]] = { 315 | category: [] for category in CommandCategory 316 | } 317 | self._factory = factory or CommandFactory() 318 | self._discovered = False 319 | 320 | # Set registry reference in factory dependencies 321 | self._factory.update_dependencies(command_registry=self) 322 | 323 | def register(self, command: Command) -> None: 324 | """Register a command and its aliases.""" 325 | # Register by primary name 326 | self._commands[command.name] = command 327 | 328 | # Register all aliases 329 | for alias in command.aliases: 330 | self._commands[alias.lower()] = command 331 | 332 | # Add to category 333 | if command not in self._categories[command.category]: 334 | self._categories[command.category].append(command) 335 | 336 | def register_command_class(self, command_class: Type[Command]) -> None: 337 | """Register a command class using the factory.""" 338 | command = self._factory.create_command(command_class) 339 | self.register(command) 340 | 341 | def discover_commands(self) -> None: 342 | """Auto-discover and register all command classes.""" 343 | if self._discovered: 344 | return 345 | 346 | # List of all command classes to register 347 | command_classes = [ 348 | YoloCommand, 349 | DumpCommand, 350 | ClearCommand, 351 | HelpCommand, 352 | UndoCommand, 353 | CompactCommand, 354 | ModelCommand, 355 | ] 356 | 357 | # Register all discovered commands 358 | for command_class in command_classes: 359 | self.register_command_class(command_class) 360 | 361 | self._discovered = True 362 | 363 | def register_all_default_commands(self) -> None: 364 | """Register all default commands (backward compatibility).""" 365 | self.discover_commands() 366 | 367 | def set_process_request_callback(self, callback: ProcessRequestCallback) -> None: 368 | """Set the process_request callback for commands that need it.""" 369 | self._factory.update_dependencies(process_request_callback=callback) 370 | 371 | # Re-register CompactCommand with new dependency if already registered 372 | if "compact" in self._commands: 373 | self.register_command_class(CompactCommand) 374 | 375 | async def execute(self, command_text: str, context: CommandContext) -> Any: 376 | """ 377 | Execute a command. 378 | 379 | Args: 380 | command_text: The full command text 381 | context: Execution context 382 | 383 | Returns: 384 | Command-specific return value, or None if command not found 385 | 386 | Raises: 387 | ValidationError: If command is not found or empty 388 | """ 389 | # Ensure commands are discovered 390 | self.discover_commands() 391 | 392 | parts = command_text.split() 393 | if not parts: 394 | raise ValidationError("Empty command") 395 | 396 | command_name = parts[0].lower() 397 | args = parts[1:] 398 | 399 | if command_name not in self._commands: 400 | raise ValidationError(f"Unknown command: {command_name}") 401 | 402 | command = self._commands[command_name] 403 | return await command.execute(args, context) 404 | 405 | def is_command(self, text: str) -> bool: 406 | """Check if text starts with a registered command.""" 407 | if not text: 408 | return False 409 | 410 | parts = text.split() 411 | if not parts: 412 | return False 413 | 414 | return parts[0].lower() in self._commands 415 | 416 | def get_command_names(self) -> CommandArgs: 417 | """Get all registered command names (including aliases).""" 418 | self.discover_commands() 419 | return sorted(self._commands.keys()) 420 | 421 | def get_commands_by_category(self, category: CommandCategory) -> List[Command]: 422 | """Get all commands in a specific category.""" 423 | self.discover_commands() 424 | return self._categories.get(category, []) 425 | 426 | def get_all_categories(self) -> Dict[CommandCategory, List[Command]]: 427 | """Get all commands organized by category.""" 428 | self.discover_commands() 429 | return self._categories.copy() 430 | -------------------------------------------------------------------------------- /src/sidekick/cli/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module: sidekick.cli.main 3 | 4 | CLI entry point and main command handling for the Sidekick application. 5 | Manages application startup, version checking, and REPL initialization. 6 | """ 7 | 8 | import asyncio 9 | 10 | import typer 11 | 12 | from sidekick.cli.repl import repl 13 | from sidekick.configuration.settings import ApplicationSettings 14 | from sidekick.core.state import StateManager 15 | from sidekick.setup import setup 16 | from sidekick.ui import console as ui 17 | from sidekick.utils.system import check_for_updates 18 | 19 | app_settings = ApplicationSettings() 20 | app = typer.Typer(help=app_settings.name) 21 | state_manager = StateManager() 22 | 23 | 24 | @app.command() 25 | def main( 26 | version: bool = typer.Option(False, "--version", "-v", help="Show version and exit."), 27 | logfire_enabled: bool = typer.Option(False, "--logfire", help="Enable Logfire tracing."), 28 | no_telemetry: bool = typer.Option( 29 | False, "--no-telemetry", help="Disable telemetry collection." 30 | ), 31 | run_setup: bool = typer.Option(False, "--setup", help="Run setup process."), 32 | ): 33 | if version: 34 | asyncio.run(ui.version()) 35 | return 36 | 37 | asyncio.run(ui.banner()) 38 | 39 | has_update, latest_version = check_for_updates() 40 | if has_update: 41 | asyncio.run(ui.show_update_message(latest_version)) 42 | 43 | if no_telemetry: 44 | state_manager.session.telemetry_enabled = False 45 | 46 | try: 47 | asyncio.run(setup(run_setup, state_manager)) 48 | asyncio.run(repl(state_manager)) 49 | except Exception as e: 50 | asyncio.run(ui.error(str(e))) 51 | 52 | 53 | if __name__ == "__main__": 54 | app() 55 | -------------------------------------------------------------------------------- /src/sidekick/cli/repl.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module: sidekick.cli.repl 3 | 4 | Interactive REPL (Read-Eval-Print Loop) implementation for Sidekick. 5 | Handles user input, command processing, and agent interaction in an interactive shell. 6 | """ 7 | 8 | import json 9 | from asyncio.exceptions import CancelledError 10 | 11 | from prompt_toolkit.application import run_in_terminal 12 | from prompt_toolkit.application.current import get_app 13 | from pydantic_ai.exceptions import UnexpectedModelBehavior 14 | 15 | from sidekick.configuration.settings import ApplicationSettings 16 | from sidekick.core.agents import main as agent 17 | from sidekick.core.agents.main import patch_tool_messages 18 | from sidekick.core.tool_handler import ToolHandler 19 | from sidekick.exceptions import AgentError, UserAbortError, ValidationError 20 | from sidekick.ui import console as ui 21 | from sidekick.ui.tool_ui import ToolUI 22 | 23 | from ..types import CommandContext, CommandResult, StateManager, ToolArgs 24 | from .commands import CommandRegistry 25 | 26 | # Tool UI instance 27 | _tool_ui = ToolUI() 28 | 29 | 30 | def _parse_args(args) -> ToolArgs: 31 | """ 32 | Parse tool arguments from a JSON string or dictionary. 33 | 34 | Args: 35 | args (str or dict): A JSON-formatted string or a dictionary containing tool arguments. 36 | 37 | Returns: 38 | dict: The parsed arguments. 39 | 40 | Raises: 41 | ValueError: If 'args' is not a string or dictionary, or if the string is not valid JSON. 42 | """ 43 | if isinstance(args, str): 44 | try: 45 | return json.loads(args) 46 | except json.JSONDecodeError: 47 | raise ValidationError(f"Invalid JSON: {args}") 48 | elif isinstance(args, dict): 49 | return args 50 | else: 51 | raise ValidationError(f"Invalid args type: {type(args)}") 52 | 53 | 54 | async def _tool_confirm(tool_call, node, state_manager: StateManager): 55 | """Confirm tool execution with separated business logic and UI.""" 56 | # Create tool handler with state 57 | tool_handler = ToolHandler(state_manager) 58 | args = _parse_args(tool_call.args) 59 | 60 | # Check if confirmation is needed 61 | if not tool_handler.should_confirm(tool_call.tool_name): 62 | # Log MCP tools when skipping confirmation 63 | app_settings = ApplicationSettings() 64 | if tool_call.tool_name not in app_settings.internal_tools: 65 | title = _tool_ui._get_tool_title(tool_call.tool_name) 66 | await _tool_ui.log_mcp(title, args) 67 | return 68 | 69 | # Stop spinner during user interaction 70 | state_manager.session.spinner.stop() 71 | 72 | # Create confirmation request 73 | request = tool_handler.create_confirmation_request(tool_call.tool_name, args) 74 | 75 | # Show UI and get response 76 | response = await _tool_ui.show_confirmation(request, state_manager) 77 | 78 | # Process the response 79 | if not tool_handler.process_confirmation(response, tool_call.tool_name): 80 | raise UserAbortError("User aborted.") 81 | 82 | await ui.line() # Add line after user input 83 | state_manager.session.spinner.start() 84 | 85 | 86 | async def _tool_handler(part, node, state_manager: StateManager): 87 | """Handle tool execution with separated business logic and UI.""" 88 | await ui.info(f"Tool({part.tool_name})") 89 | state_manager.session.spinner.stop() 90 | 91 | try: 92 | # Create tool handler with state 93 | tool_handler = ToolHandler(state_manager) 94 | args = _parse_args(part.args) 95 | 96 | # Use a synchronous function in run_in_terminal to avoid async deadlocks 97 | def confirm_func(): 98 | # Skip confirmation if not needed 99 | if not tool_handler.should_confirm(part.tool_name): 100 | return False 101 | 102 | # Create confirmation request 103 | request = tool_handler.create_confirmation_request(part.tool_name, args) 104 | 105 | # Show sync UI and get response 106 | response = _tool_ui.show_sync_confirmation(request) 107 | 108 | # Process the response 109 | if not tool_handler.process_confirmation(response, part.tool_name): 110 | return True # Abort 111 | return False # Continue 112 | 113 | # Run the confirmation in the terminal 114 | should_abort = await run_in_terminal(confirm_func) 115 | 116 | if should_abort: 117 | raise UserAbortError("User aborted.") 118 | 119 | except UserAbortError: 120 | patch_tool_messages("Operation aborted by user.", state_manager) 121 | raise 122 | finally: 123 | state_manager.session.spinner.start() 124 | 125 | 126 | # Initialize command registry 127 | _command_registry = CommandRegistry() 128 | _command_registry.register_all_default_commands() 129 | 130 | 131 | async def _handle_command(command: str, state_manager: StateManager) -> CommandResult: 132 | """ 133 | Handles a command string using the command registry. 134 | 135 | Args: 136 | command: The command string entered by the user. 137 | state_manager: The state manager instance. 138 | 139 | Returns: 140 | Command result (varies by command). 141 | """ 142 | # Create command context 143 | context = CommandContext(state_manager=state_manager, process_request=process_request) 144 | 145 | try: 146 | # Set the process_request callback for commands that need it 147 | _command_registry.set_process_request_callback(process_request) 148 | 149 | # Execute the command 150 | return await _command_registry.execute(command, context) 151 | except ValidationError as e: 152 | await ui.error(str(e)) 153 | 154 | 155 | async def process_request(text: str, state_manager: StateManager, output: bool = True): 156 | """Process input using the agent, handling cancellation safely.""" 157 | state_manager.session.spinner = await ui.spinner( 158 | True, state_manager.session.spinner, state_manager 159 | ) 160 | try: 161 | # Create a partial function that includes state_manager 162 | def tool_callback_with_state(part, node): 163 | return _tool_handler(part, node, state_manager) 164 | 165 | res = await agent.process_request( 166 | state_manager.session.current_model, 167 | text, 168 | state_manager, 169 | tool_callback=tool_callback_with_state, 170 | ) 171 | if output: 172 | await ui.agent(res.result.output) 173 | except CancelledError: 174 | await ui.muted("Request cancelled") 175 | except UserAbortError: 176 | await ui.muted("Operation aborted.") 177 | except UnexpectedModelBehavior as e: 178 | error_message = str(e) 179 | await ui.muted(error_message) 180 | patch_tool_messages(error_message, state_manager) 181 | except Exception as e: 182 | # Wrap unexpected exceptions in AgentError for better tracking 183 | agent_error = AgentError(f"Agent processing failed: {str(e)}") 184 | agent_error.__cause__ = e # Preserve the original exception chain 185 | await ui.error(str(e)) 186 | finally: 187 | await ui.spinner(False, state_manager.session.spinner, state_manager) 188 | state_manager.session.current_task = None 189 | 190 | # Force refresh of the multiline input prompt to restore placeholder 191 | if "multiline" in state_manager.session.input_sessions: 192 | await run_in_terminal( 193 | lambda: state_manager.session.input_sessions["multiline"].app.invalidate() 194 | ) 195 | 196 | 197 | async def repl(state_manager: StateManager): 198 | action = None 199 | 200 | await ui.info(f"Using model {state_manager.session.current_model}") 201 | instance = agent.get_or_create_agent(state_manager.session.current_model, state_manager) 202 | 203 | await ui.info("Attaching MCP servers") 204 | await ui.line() 205 | 206 | async with instance.run_mcp_servers(): 207 | while True: 208 | try: 209 | line = await ui.multiline_input() 210 | except (EOFError, KeyboardInterrupt): 211 | break 212 | 213 | if not line: 214 | continue 215 | 216 | if line.lower() in ["exit", "quit"]: 217 | break 218 | 219 | if line.startswith("/"): 220 | action = await _handle_command(line, state_manager) 221 | if action == "restart": 222 | break 223 | continue 224 | 225 | # Check if another task is already running 226 | if state_manager.session.current_task and not state_manager.session.current_task.done(): 227 | await ui.muted("Agent is busy, press esc to interrupt.") 228 | continue 229 | 230 | state_manager.session.current_task = get_app().create_background_task( 231 | process_request(line, state_manager) 232 | ) 233 | 234 | if action == "restart": 235 | await repl(state_manager) 236 | else: 237 | await ui.info("Thanks for all the fish.") 238 | -------------------------------------------------------------------------------- /src/sidekick/configuration/__init__.py: -------------------------------------------------------------------------------- 1 | # Config package 2 | -------------------------------------------------------------------------------- /src/sidekick/configuration/defaults.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module: sidekick.configuration.defaults 3 | 4 | Default configuration values for the Sidekick CLI. 5 | Provides baseline settings for user configuration including API keys, 6 | tool settings, and MCP servers. 7 | """ 8 | 9 | from sidekick.constants import GUIDE_FILE_NAME, TOOL_READ_FILE 10 | from sidekick.types import UserConfig 11 | 12 | DEFAULT_USER_CONFIG: UserConfig = { 13 | "default_model": "", 14 | "env": { 15 | "ANTHROPIC_API_KEY": "", 16 | "GEMINI_API_KEY": "", 17 | "OPENAI_API_KEY": "", 18 | }, 19 | "settings": { 20 | "max_retries": 10, 21 | "tool_ignore": [TOOL_READ_FILE], 22 | "guide_file": GUIDE_FILE_NAME, 23 | }, 24 | "mcpServers": {}, 25 | } 26 | -------------------------------------------------------------------------------- /src/sidekick/configuration/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module: sidekick.configuration.models 3 | 4 | Configuration model definitions and model registry for AI models. 5 | Manages available AI models, their configurations, and pricing information. 6 | """ 7 | 8 | from sidekick.types import ModelConfig, ModelName, ModelPricing 9 | from sidekick.types import ModelRegistry as ModelRegistryType 10 | 11 | 12 | class ModelRegistry: 13 | def __init__(self): 14 | self._models = self._load_default_models() 15 | 16 | def _load_default_models(self) -> ModelRegistryType: 17 | return { 18 | "anthropic:claude-opus-4-20250514": ModelConfig( 19 | pricing=ModelPricing(input=3.00, cached_input=1.50, output=15.00) 20 | ), 21 | "anthropic:claude-sonnet-4-20250514": ModelConfig( 22 | pricing=ModelPricing(input=3.00, cached_input=1.50, output=15.00) 23 | ), 24 | "anthropic:claude-3-7-sonnet-latest": ModelConfig( 25 | pricing=ModelPricing(input=3.00, cached_input=1.50, output=15.00) 26 | ), 27 | "google-gla:gemini-2.0-flash": ModelConfig( 28 | pricing=ModelPricing(input=0.10, cached_input=0.025, output=0.40) 29 | ), 30 | "google-gla:gemini-2.5-flash-preview-05-20": ModelConfig( 31 | pricing=ModelPricing(input=0.15, cached_input=0.025, output=0.60) 32 | ), 33 | "google-gla:gemini-2.5-pro-preview-05-06": ModelConfig( 34 | pricing=ModelPricing(input=1.25, cached_input=0.025, output=10.00) 35 | ), 36 | "openai:gpt-4.1": ModelConfig( 37 | pricing=ModelPricing(input=2.00, cached_input=0.50, output=8.00) 38 | ), 39 | "openai:gpt-4.1-mini": ModelConfig( 40 | pricing=ModelPricing(input=0.40, cached_input=0.10, output=1.60) 41 | ), 42 | "openai:gpt-4.1-nano": ModelConfig( 43 | pricing=ModelPricing(input=0.10, cached_input=0.025, output=0.40) 44 | ), 45 | "openai:gpt-4o": ModelConfig( 46 | pricing=ModelPricing(input=2.50, cached_input=1.25, output=10.00) 47 | ), 48 | "openai:o3": ModelConfig( 49 | pricing=ModelPricing(input=10.00, cached_input=2.50, output=40.00) 50 | ), 51 | "openai:o3-mini": ModelConfig( 52 | pricing=ModelPricing(input=1.10, cached_input=0.55, output=4.40) 53 | ), 54 | } 55 | 56 | def get_model(self, name: ModelName) -> ModelConfig: 57 | return self._models.get(name) 58 | 59 | def list_models(self) -> ModelRegistryType: 60 | return self._models.copy() 61 | 62 | def list_model_ids(self) -> list[ModelName]: 63 | return list(self._models.keys()) 64 | -------------------------------------------------------------------------------- /src/sidekick/configuration/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module: sidekick.configuration.settings 3 | 4 | Application settings management for the Sidekick CLI. 5 | Manages application paths, tool configurations, and runtime settings. 6 | """ 7 | 8 | from pathlib import Path 9 | 10 | from sidekick.constants import (APP_NAME, APP_VERSION, CONFIG_FILE_NAME, TOOL_READ_FILE, 11 | TOOL_RUN_COMMAND, TOOL_UPDATE_FILE, TOOL_WRITE_FILE) 12 | from sidekick.types import ConfigFile, ConfigPath, ToolName 13 | 14 | 15 | class PathConfig: 16 | def __init__(self): 17 | self.config_dir: ConfigPath = Path.home() / ".config" 18 | self.config_file: ConfigFile = self.config_dir / CONFIG_FILE_NAME 19 | 20 | 21 | class ApplicationSettings: 22 | def __init__(self): 23 | self.version = APP_VERSION 24 | self.name = APP_NAME 25 | self.guide_file = f"{self.name.upper()}.md" 26 | self.paths = PathConfig() 27 | self.internal_tools: list[ToolName] = [ 28 | TOOL_READ_FILE, 29 | TOOL_RUN_COMMAND, 30 | TOOL_UPDATE_FILE, 31 | TOOL_WRITE_FILE, 32 | ] 33 | -------------------------------------------------------------------------------- /src/sidekick/constants.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module: sidekick.constants 3 | 4 | Global constants and configuration values for the Sidekick CLI application. 5 | Centralizes all magic strings, UI text, error messages, and application constants. 6 | """ 7 | 8 | # Application info 9 | APP_NAME = "Sidekick" 10 | APP_VERSION = "0.5.1" 11 | 12 | # File patterns 13 | GUIDE_FILE_PATTERN = "{name}.md" 14 | GUIDE_FILE_NAME = "SIDEKICK.md" 15 | ENV_FILE = ".env" 16 | CONFIG_FILE_NAME = "sidekick.json" 17 | 18 | # Default limits 19 | MAX_FILE_SIZE = 100 * 1024 # 100KB 20 | MAX_COMMAND_OUTPUT = 5000 # 5000 chars 21 | 22 | # Command output processing 23 | COMMAND_OUTPUT_THRESHOLD = 3500 # Length threshold for truncation 24 | COMMAND_OUTPUT_START_INDEX = 2500 # Where to start showing content 25 | COMMAND_OUTPUT_END_SIZE = 1000 # How much to show from the end 26 | 27 | # Tool names 28 | TOOL_READ_FILE = "read_file" 29 | TOOL_WRITE_FILE = "write_file" 30 | TOOL_UPDATE_FILE = "update_file" 31 | TOOL_RUN_COMMAND = "run_command" 32 | 33 | # Commands 34 | CMD_HELP = "/help" 35 | CMD_CLEAR = "/clear" 36 | CMD_DUMP = "/dump" 37 | CMD_YOLO = "/yolo" 38 | CMD_UNDO = "/undo" 39 | CMD_COMPACT = "/compact" 40 | CMD_MODEL = "/model" 41 | CMD_EXIT = "exit" 42 | CMD_QUIT = "quit" 43 | 44 | # Command descriptions 45 | DESC_HELP = "Show this help message" 46 | DESC_CLEAR = "Clear the conversation history" 47 | DESC_DUMP = "Show the current conversation history" 48 | DESC_YOLO = "Toggle confirmation prompts on/off" 49 | DESC_UNDO = "Undo the last file change" 50 | DESC_COMPACT = "Summarize the conversation context" 51 | DESC_MODEL = "List available models" 52 | DESC_MODEL_SWITCH = "Switch to a specific model" 53 | DESC_MODEL_DEFAULT = "Set a model as the default" 54 | DESC_EXIT = "Exit the application" 55 | 56 | # Command Configuration 57 | COMMAND_PREFIX = "/" 58 | COMMAND_CATEGORIES = { 59 | "state": ["yolo", "undo"], 60 | "debug": ["dump", "compact"], 61 | "ui": ["clear", "help"], 62 | "config": ["model"], 63 | } 64 | 65 | # System paths 66 | SIDEKICK_HOME_DIR = ".sidekick" 67 | SESSIONS_SUBDIR = "sessions" 68 | DEVICE_ID_FILE = "device_id" 69 | 70 | # UI colors 71 | UI_COLORS = { 72 | "primary": "medium_purple1", 73 | "secondary": "medium_purple3", 74 | "success": "green", 75 | "warning": "orange1", 76 | "error": "red", 77 | "muted": "grey62", 78 | } 79 | 80 | # UI text and formatting 81 | UI_PROMPT_PREFIX = "λ " 82 | UI_THINKING_MESSAGE = "[bold green]Thinking..." 83 | UI_DARKGREY_OPEN = "" 84 | UI_DARKGREY_CLOSE = "" 85 | UI_BOLD_OPEN = "" 86 | UI_BOLD_CLOSE = "" 87 | UI_KEY_ENTER = "Enter" 88 | UI_KEY_ESC_ENTER = "Esc + Enter" 89 | 90 | # Panel titles 91 | PANEL_ERROR = "Error" 92 | PANEL_MESSAGE_HISTORY = "Message History" 93 | PANEL_MODELS = "Models" 94 | PANEL_AVAILABLE_COMMANDS = "Available Commands" 95 | 96 | # Error messages 97 | ERROR_PROVIDER_EMPTY = "Provider number cannot be empty" 98 | ERROR_INVALID_PROVIDER = "Invalid provider number" 99 | ERROR_FILE_NOT_FOUND = "Error: File not found at '{filepath}'." 100 | ERROR_FILE_TOO_LARGE = "Error: File '{filepath}' is too large (> 100KB)." 101 | ERROR_FILE_DECODE = "Error reading file '{filepath}': Could not decode using UTF-8." 102 | ERROR_FILE_DECODE_DETAILS = "It might be a binary file or use a different encoding. {error}" 103 | ERROR_COMMAND_NOT_FOUND = "Error: Command not found or failed to execute:" 104 | ERROR_COMMAND_EXECUTION = ( 105 | "Error: Command not found or failed to execute: {command}. Details: {error}" 106 | ) 107 | ERROR_UNDO_INIT = "Error initializing undo system: {e}" 108 | 109 | # Command output messages 110 | CMD_OUTPUT_NO_OUTPUT = "No output." 111 | CMD_OUTPUT_NO_ERRORS = "No errors." 112 | CMD_OUTPUT_FORMAT = "STDOUT:\n{output}\n\nSTDERR:\n{error}" 113 | CMD_OUTPUT_TRUNCATED = "\n...\n[truncated]\n...\n" 114 | 115 | # Undo system messages 116 | UNDO_DISABLED_HOME = "Undo system disabled, running from home directory" 117 | UNDO_DISABLED_NO_GIT = "Undo system disabled, not in a git project" 118 | UNDO_INITIAL_COMMIT = "Initial commit for sidekick undo history" 119 | UNDO_GIT_TIMEOUT = "Git initialization timed out" 120 | 121 | # Log/status messages 122 | MSG_UPDATE_AVAILABLE = "Update available: v{latest_version}" 123 | MSG_UPDATE_INSTRUCTION = "Exit, and run: [bold]pip install --upgrade sidekick-cli" 124 | MSG_VERSION_DISPLAY = "Sidekick CLI {version}" 125 | MSG_FILE_SIZE_LIMIT = " Please specify a smaller file or use other tools to process it." 126 | -------------------------------------------------------------------------------- /src/sidekick/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geekforbrains/sidekick-cli/2176d4056b3f31f4a2f6fc922cb9c981cd2a1b6f/src/sidekick/core/__init__.py -------------------------------------------------------------------------------- /src/sidekick/core/agents/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geekforbrains/sidekick-cli/2176d4056b3f31f4a2f6fc922cb9c981cd2a1b6f/src/sidekick/core/agents/__init__.py -------------------------------------------------------------------------------- /src/sidekick/core/agents/main.py: -------------------------------------------------------------------------------- 1 | """Module: sidekick.core.agents.main 2 | 3 | Main agent functionality and coordination for the Sidekick CLI. 4 | Provides agent creation, message processing, and tool call management. 5 | """ 6 | 7 | from datetime import datetime, timezone 8 | from typing import Optional 9 | 10 | from pydantic_ai import Agent, Tool 11 | from pydantic_ai.messages import ModelRequest, ToolReturnPart 12 | 13 | from sidekick.core.state import StateManager 14 | from sidekick.services.mcp import get_mcp_servers 15 | from sidekick.tools.read_file import read_file 16 | from sidekick.tools.run_command import run_command 17 | from sidekick.tools.update_file import update_file 18 | from sidekick.tools.write_file import write_file 19 | from sidekick.types import (AgentRun, ErrorMessage, ModelName, PydanticAgent, ToolCallback, 20 | ToolCallId, ToolName) 21 | 22 | 23 | async def _process_node(node, tool_callback: Optional[ToolCallback], state_manager: StateManager): 24 | if hasattr(node, "request"): 25 | state_manager.session.messages.append(node.request) 26 | 27 | if hasattr(node, "model_response"): 28 | state_manager.session.messages.append(node.model_response) 29 | for part in node.model_response.parts: 30 | if part.part_kind == "tool-call" and tool_callback: 31 | await tool_callback(part, node) 32 | 33 | 34 | def get_or_create_agent(model: ModelName, state_manager: StateManager) -> PydanticAgent: 35 | if model not in state_manager.session.agents: 36 | max_retries = state_manager.session.user_config["settings"]["max_retries"] 37 | state_manager.session.agents[model] = Agent( 38 | model=model, 39 | tools=[ 40 | Tool(read_file, max_retries=max_retries), 41 | Tool(run_command, max_retries=max_retries), 42 | Tool(update_file, max_retries=max_retries), 43 | Tool(write_file, max_retries=max_retries), 44 | ], 45 | mcp_servers=get_mcp_servers(state_manager), 46 | ) 47 | return state_manager.session.agents[model] 48 | 49 | 50 | def patch_tool_messages( 51 | error_message: ErrorMessage = "Tool operation failed", 52 | state_manager: StateManager = None, 53 | ): 54 | """ 55 | Find any tool calls without responses and add synthetic error responses for them. 56 | Takes an error message to use in the synthesized tool response. 57 | 58 | Ignores tools that have corresponding retry prompts as the model is already 59 | addressing them. 60 | """ 61 | if state_manager is None: 62 | raise ValueError("state_manager is required for patch_tool_messages") 63 | 64 | messages = state_manager.session.messages 65 | 66 | if not messages: 67 | return 68 | 69 | # Map tool calls to their tool returns 70 | tool_calls: dict[ToolCallId, ToolName] = {} # tool_call_id -> tool_name 71 | tool_returns: set[ToolCallId] = set() # set of tool_call_ids with returns 72 | retry_prompts: set[ToolCallId] = set() # set of tool_call_ids with retry prompts 73 | 74 | for message in messages: 75 | if hasattr(message, "parts"): 76 | for part in message.parts: 77 | if ( 78 | hasattr(part, "part_kind") 79 | and hasattr(part, "tool_call_id") 80 | and part.tool_call_id 81 | ): 82 | if part.part_kind == "tool-call": 83 | tool_calls[part.tool_call_id] = part.tool_name 84 | elif part.part_kind == "tool-return": 85 | tool_returns.add(part.tool_call_id) 86 | elif part.part_kind == "retry-prompt": 87 | retry_prompts.add(part.tool_call_id) 88 | 89 | # Identify orphaned tools (those without responses and not being retried) 90 | for tool_call_id, tool_name in list(tool_calls.items()): 91 | if tool_call_id not in tool_returns and tool_call_id not in retry_prompts: 92 | messages.append( 93 | ModelRequest( 94 | parts=[ 95 | ToolReturnPart( 96 | tool_name=tool_name, 97 | content=error_message, 98 | tool_call_id=tool_call_id, 99 | timestamp=datetime.now(timezone.utc), 100 | part_kind="tool-return", 101 | ) 102 | ], 103 | kind="request", 104 | ) 105 | ) 106 | 107 | 108 | async def process_request( 109 | model: ModelName, 110 | message: str, 111 | state_manager: StateManager, 112 | tool_callback: Optional[ToolCallback] = None, 113 | ) -> AgentRun: 114 | agent = get_or_create_agent(model, state_manager) 115 | mh = state_manager.session.messages.copy() 116 | async with agent.iter(message, message_history=mh) as agent_run: 117 | async for node in agent_run: 118 | await _process_node(node, tool_callback, state_manager) 119 | return agent_run 120 | -------------------------------------------------------------------------------- /src/sidekick/core/setup/__init__.py: -------------------------------------------------------------------------------- 1 | from .agent_setup import AgentSetup 2 | from .base import BaseSetup 3 | from .config_setup import ConfigSetup 4 | from .coordinator import SetupCoordinator 5 | from .environment_setup import EnvironmentSetup 6 | from .telemetry_setup import TelemetrySetup 7 | from .undo_setup import UndoSetup 8 | 9 | __all__ = [ 10 | "BaseSetup", 11 | "SetupCoordinator", 12 | "TelemetrySetup", 13 | "ConfigSetup", 14 | "EnvironmentSetup", 15 | "UndoSetup", 16 | "AgentSetup", 17 | ] 18 | -------------------------------------------------------------------------------- /src/sidekick/core/setup/agent_setup.py: -------------------------------------------------------------------------------- 1 | """Module: sidekick.core.setup.agent_setup 2 | 3 | Agent initialization and configuration for the Sidekick CLI. 4 | Handles the setup and validation of AI agents with the selected model. 5 | """ 6 | 7 | from typing import Any, Optional 8 | 9 | from sidekick.core.setup.base import BaseSetup 10 | from sidekick.core.state import StateManager 11 | from sidekick.ui import console as ui 12 | 13 | 14 | class AgentSetup(BaseSetup): 15 | """Setup step for agent initialization.""" 16 | 17 | def __init__(self, state_manager: StateManager, agent: Optional[Any] = None): 18 | super().__init__(state_manager) 19 | self.agent = agent 20 | 21 | @property 22 | def name(self) -> str: 23 | return "Agent" 24 | 25 | async def should_run(self, force_setup: bool = False) -> bool: 26 | """Agent setup should run if an agent is provided.""" 27 | return self.agent is not None 28 | 29 | async def execute(self, force_setup: bool = False) -> None: 30 | """Initialize the agent with the current model.""" 31 | if self.agent is not None: 32 | await ui.info(f"Initializing Agent({self.state_manager.session.current_model})") 33 | self.agent.agent = self.agent.get_agent() 34 | 35 | async def validate(self) -> bool: 36 | """Validate that agent was initialized correctly.""" 37 | if self.agent is None: 38 | return True # No agent to validate 39 | 40 | # Check if agent was initialized 41 | return hasattr(self.agent, "agent") and self.agent.agent is not None 42 | -------------------------------------------------------------------------------- /src/sidekick/core/setup/base.py: -------------------------------------------------------------------------------- 1 | """Module: sidekick.core.setup.base 2 | 3 | Base setup step abstraction for the Sidekick CLI initialization process. 4 | Defines the contract that all setup steps must implement. 5 | """ 6 | 7 | from abc import ABC, abstractmethod 8 | 9 | from sidekick.core.state import StateManager 10 | 11 | 12 | class BaseSetup(ABC): 13 | """Base class for all setup steps.""" 14 | 15 | def __init__(self, state_manager: StateManager): 16 | self.state_manager = state_manager 17 | 18 | @property 19 | @abstractmethod 20 | def name(self) -> str: 21 | """Return the name of this setup step.""" 22 | pass 23 | 24 | @abstractmethod 25 | async def should_run(self, force_setup: bool = False) -> bool: 26 | """Determine if this setup step should run.""" 27 | pass 28 | 29 | @abstractmethod 30 | async def execute(self, force_setup: bool = False) -> None: 31 | """Execute the setup step.""" 32 | pass 33 | 34 | @abstractmethod 35 | async def validate(self) -> bool: 36 | """Validate that the setup was successful.""" 37 | pass 38 | -------------------------------------------------------------------------------- /src/sidekick/core/setup/config_setup.py: -------------------------------------------------------------------------------- 1 | """Module: sidekick.core.setup.config_setup 2 | 3 | Configuration system initialization for the Sidekick CLI. 4 | Handles user configuration loading, validation, and first-time setup onboarding. 5 | """ 6 | 7 | import json 8 | from pathlib import Path 9 | 10 | from sidekick.configuration.defaults import DEFAULT_USER_CONFIG 11 | from sidekick.configuration.models import ModelRegistry 12 | from sidekick.constants import APP_NAME, CONFIG_FILE_NAME, UI_COLORS 13 | from sidekick.core.setup.base import BaseSetup 14 | from sidekick.core.state import StateManager 15 | from sidekick.exceptions import ConfigurationError 16 | from sidekick.types import ConfigFile, ConfigPath, UserConfig 17 | from sidekick.ui import console as ui 18 | from sidekick.utils import system, user_configuration 19 | from sidekick.utils.text_utils import key_to_title 20 | 21 | 22 | class ConfigSetup(BaseSetup): 23 | """Setup step for configuration and onboarding.""" 24 | 25 | def __init__(self, state_manager: StateManager): 26 | super().__init__(state_manager) 27 | self.config_dir: ConfigPath = Path.home() / ".config" 28 | self.config_file: ConfigFile = self.config_dir / CONFIG_FILE_NAME 29 | self.model_registry = ModelRegistry() 30 | 31 | @property 32 | def name(self) -> str: 33 | return "Configuration" 34 | 35 | async def should_run(self, force_setup: bool = False) -> bool: 36 | """Config setup should always run to load and merge configuration.""" 37 | return True 38 | 39 | async def execute(self, force_setup: bool = False) -> None: 40 | """Setup configuration and run onboarding if needed.""" 41 | self.state_manager.session.device_id = system.get_device_id() 42 | loaded_config = user_configuration.load_config() 43 | 44 | if loaded_config and not force_setup: 45 | await ui.muted(f"Loading config from: {self.config_file}") 46 | # Merge loaded config with defaults to ensure all required keys exist 47 | self.state_manager.session.user_config = self._merge_with_defaults(loaded_config) 48 | else: 49 | if force_setup: 50 | await ui.muted("Running setup process, resetting config") 51 | else: 52 | await ui.muted("No user configuration found, running setup") 53 | self.state_manager.session.user_config = DEFAULT_USER_CONFIG.copy() 54 | user_configuration.save_config(self.state_manager) # Save the default config initially 55 | await self._onboarding() 56 | 57 | if not self.state_manager.session.user_config.get("default_model"): 58 | raise ConfigurationError( 59 | ( 60 | f"No default model found in config at [bold]{self.config_file}[/bold]\n\n" 61 | "Run [code]sidekick --setup[/code] to rerun the setup process." 62 | ) 63 | ) 64 | 65 | # Check if the configured model still exists 66 | default_model = self.state_manager.session.user_config["default_model"] 67 | if not self.model_registry.get_model(default_model): 68 | await ui.panel( 69 | "Model Not Found", 70 | f"The configured model '[bold]{default_model}[/bold]' is no longer available.\n" 71 | "Please select a new default model.", 72 | border_style=UI_COLORS["warning"], 73 | ) 74 | await self._step2_default_model() 75 | user_configuration.save_config(self.state_manager) 76 | 77 | self.state_manager.session.current_model = self.state_manager.session.user_config[ 78 | "default_model" 79 | ] 80 | 81 | async def validate(self) -> bool: 82 | """Validate that configuration is properly set up.""" 83 | # Check that we have a user config 84 | if not self.state_manager.session.user_config: 85 | return False 86 | 87 | # Check that we have a default model 88 | if not self.state_manager.session.user_config.get("default_model"): 89 | return False 90 | 91 | # Check that the default model is valid 92 | default_model = self.state_manager.session.user_config["default_model"] 93 | if not self.model_registry.get_model(default_model): 94 | return False 95 | 96 | return True 97 | 98 | def _merge_with_defaults(self, loaded_config: UserConfig) -> UserConfig: 99 | """Merge loaded config with defaults to ensure all required keys exist.""" 100 | # Start with loaded config if available, otherwise use defaults 101 | if loaded_config: 102 | merged = loaded_config.copy() 103 | 104 | # Add missing top-level keys from defaults 105 | for key, default_value in DEFAULT_USER_CONFIG.items(): 106 | if key not in merged: 107 | merged[key] = default_value 108 | 109 | return merged 110 | else: 111 | return DEFAULT_USER_CONFIG.copy() 112 | 113 | async def _onboarding(self): 114 | """Run the onboarding process for new users.""" 115 | initial_config = json.dumps(self.state_manager.session.user_config, sort_keys=True) 116 | 117 | await self._step1_api_keys() 118 | 119 | # Only continue if at least one API key was provided 120 | env = self.state_manager.session.user_config.get("env", {}) 121 | has_api_key = any(key.endswith("_API_KEY") and env.get(key) for key in env) 122 | 123 | if has_api_key: 124 | if not self.state_manager.session.user_config.get("default_model"): 125 | await self._step2_default_model() 126 | 127 | # Compare configs to see if anything changed 128 | current_config = json.dumps(self.state_manager.session.user_config, sort_keys=True) 129 | if initial_config != current_config: 130 | if user_configuration.save_config(self.state_manager): 131 | message = f"Config saved to: [bold]{self.config_file}[/bold]" 132 | await ui.panel("Finished", message, top=0, border_style=UI_COLORS["success"]) 133 | else: 134 | await ui.error("Failed to save configuration.") 135 | else: 136 | await ui.panel( 137 | "Setup canceled", 138 | "At least one API key is required.", 139 | border_style=UI_COLORS["warning"], 140 | ) 141 | 142 | async def _step1_api_keys(self): 143 | """Onboarding step 1: Collect API keys.""" 144 | message = ( 145 | f"Welcome to {APP_NAME}!\n" 146 | "Let's get you setup. First, we'll need to set some environment variables.\n" 147 | "Skip the ones you don't need." 148 | ) 149 | await ui.panel("Setup", message, border_style=UI_COLORS["primary"]) 150 | env_keys = self.state_manager.session.user_config["env"].copy() 151 | for key in env_keys: 152 | provider = key_to_title(key) 153 | val = await ui.input( 154 | "step1", 155 | pretext=f" {provider}: ", 156 | is_password=True, 157 | state_manager=self.state_manager, 158 | ) 159 | val = val.strip() 160 | if val: 161 | self.state_manager.session.user_config["env"][key] = val 162 | 163 | async def _step2_default_model(self): 164 | """Onboarding step 2: Select default model.""" 165 | message = "Which model would you like to use by default?\n\n" 166 | 167 | model_ids = self.model_registry.list_model_ids() 168 | for index, model_id in enumerate(model_ids): 169 | message += f" {index} - {model_id}\n" 170 | message = message.strip() 171 | 172 | await ui.panel("Default Model", message, border_style=UI_COLORS["primary"]) 173 | choice = await ui.input( 174 | "step2", 175 | pretext=" Default model (#): ", 176 | validator=ui.ModelValidator(len(model_ids)), 177 | state_manager=self.state_manager, 178 | ) 179 | self.state_manager.session.user_config["default_model"] = model_ids[int(choice)] 180 | -------------------------------------------------------------------------------- /src/sidekick/core/setup/coordinator.py: -------------------------------------------------------------------------------- 1 | """Module: sidekick.core.setup.coordinator 2 | 3 | Setup orchestration and coordination for the Sidekick CLI. 4 | Manages the execution order and validation of all registered setup steps. 5 | """ 6 | 7 | from typing import List 8 | 9 | from sidekick.core.setup.base import BaseSetup 10 | from sidekick.core.state import StateManager 11 | from sidekick.ui import console as ui 12 | 13 | 14 | class SetupCoordinator: 15 | """Coordinator for running all setup steps in order.""" 16 | 17 | def __init__(self, state_manager: StateManager): 18 | self.state_manager = state_manager 19 | self.setup_steps: List[BaseSetup] = [] 20 | 21 | def register_step(self, step: BaseSetup) -> None: 22 | """Register a setup step to be run.""" 23 | self.setup_steps.append(step) 24 | 25 | async def run_setup(self, force_setup: bool = False) -> None: 26 | """Run all registered setup steps in order.""" 27 | for step in self.setup_steps: 28 | try: 29 | if await step.should_run(force_setup): 30 | await ui.info(f"Running setup: {step.name}") 31 | await step.execute(force_setup) 32 | 33 | if not await step.validate(): 34 | await ui.error(f"Setup validation failed: {step.name}") 35 | raise RuntimeError(f"Setup step '{step.name}' failed validation") 36 | else: 37 | await ui.muted(f"Skipping setup: {step.name}") 38 | except Exception as e: 39 | await ui.error(f"Setup failed at step '{step.name}': {str(e)}") 40 | raise 41 | 42 | def clear_steps(self) -> None: 43 | """Clear all registered setup steps.""" 44 | self.setup_steps.clear() 45 | -------------------------------------------------------------------------------- /src/sidekick/core/setup/environment_setup.py: -------------------------------------------------------------------------------- 1 | """Module: sidekick.core.setup.environment_setup 2 | 3 | Environment detection and configuration for the Sidekick CLI. 4 | Handles setting up environment variables from user configuration. 5 | """ 6 | 7 | import os 8 | 9 | from sidekick.core.setup.base import BaseSetup 10 | from sidekick.core.state import StateManager 11 | from sidekick.types import EnvConfig 12 | from sidekick.ui import console as ui 13 | 14 | 15 | class EnvironmentSetup(BaseSetup): 16 | """Setup step for environment variables.""" 17 | 18 | def __init__(self, state_manager: StateManager): 19 | super().__init__(state_manager) 20 | 21 | @property 22 | def name(self) -> str: 23 | return "Environment Variables" 24 | 25 | async def should_run(self, force_setup: bool = False) -> bool: 26 | """Environment setup should always run to set env vars from config.""" 27 | return True 28 | 29 | async def execute(self, force_setup: bool = False) -> None: 30 | """Set environment variables from the config file.""" 31 | if "env" not in self.state_manager.session.user_config or not isinstance( 32 | self.state_manager.session.user_config["env"], dict 33 | ): 34 | self.state_manager.session.user_config["env"] = {} 35 | 36 | env_dict: EnvConfig = self.state_manager.session.user_config["env"] 37 | env_set_count = 0 38 | 39 | for key, value in env_dict.items(): 40 | if not isinstance(value, str): 41 | await ui.warning(f"Invalid env value in config: {key}") 42 | continue 43 | value = value.strip() 44 | if value: 45 | os.environ[key] = value 46 | env_set_count += 1 47 | 48 | if env_set_count > 0: 49 | await ui.muted(f"Set {env_set_count} environment variable(s)") 50 | 51 | async def validate(self) -> bool: 52 | """Validate that environment variables were set correctly.""" 53 | # Check that at least one API key environment variable is set 54 | env_dict = self.state_manager.session.user_config.get("env", {}) 55 | for key, value in env_dict.items(): 56 | if key.endswith("_API_KEY") and value and value.strip(): 57 | # Check if it was actually set in the environment 58 | if os.environ.get(key) == value.strip(): 59 | return True 60 | 61 | # If no API keys are configured, that's still valid 62 | # (user might be using other auth methods) 63 | return True 64 | -------------------------------------------------------------------------------- /src/sidekick/core/setup/telemetry_setup.py: -------------------------------------------------------------------------------- 1 | """Module: sidekick.core.setup.telemetry_setup 2 | 3 | Telemetry service initialization for the Sidekick CLI. 4 | Sets up error tracking and usage telemetry when enabled. 5 | """ 6 | 7 | from sidekick.core.setup.base import BaseSetup 8 | from sidekick.core.state import StateManager 9 | from sidekick.services import telemetry 10 | 11 | 12 | class TelemetrySetup(BaseSetup): 13 | """Setup step for telemetry initialization.""" 14 | 15 | def __init__(self, state_manager: StateManager): 16 | super().__init__(state_manager) 17 | 18 | @property 19 | def name(self) -> str: 20 | return "Telemetry" 21 | 22 | async def should_run(self, force_setup: bool = False) -> bool: 23 | """Telemetry should run if enabled, regardless of force_setup.""" 24 | return self.state_manager.session.telemetry_enabled 25 | 26 | async def execute(self, force_setup: bool = False) -> None: 27 | """Setup telemetry for capturing exceptions and errors.""" 28 | telemetry.setup(self.state_manager) 29 | 30 | async def validate(self) -> bool: 31 | """Validate that telemetry was set up correctly.""" 32 | # For now, we assume telemetry setup always succeeds 33 | # In the future, we could check if telemetry is properly initialized 34 | return True 35 | -------------------------------------------------------------------------------- /src/sidekick/core/setup/undo_setup.py: -------------------------------------------------------------------------------- 1 | """Module: sidekick.core.setup.undo_setup 2 | 3 | Undo system initialization for the Sidekick CLI. 4 | Sets up file tracking and state management for undo operations. 5 | """ 6 | 7 | from sidekick.core.setup.base import BaseSetup 8 | from sidekick.core.state import StateManager 9 | from sidekick.services.undo_service import init_undo_system 10 | 11 | 12 | class UndoSetup(BaseSetup): 13 | """Setup step for undo system initialization.""" 14 | 15 | def __init__(self, state_manager: StateManager): 16 | super().__init__(state_manager) 17 | 18 | @property 19 | def name(self) -> str: 20 | return "Undo System" 21 | 22 | async def should_run(self, force_setup: bool = False) -> bool: 23 | """Undo setup should run if not already initialized.""" 24 | return not self.state_manager.session.undo_initialized 25 | 26 | async def execute(self, force_setup: bool = False) -> None: 27 | """Initialize the undo system.""" 28 | self.state_manager.session.undo_initialized = init_undo_system(self.state_manager) 29 | 30 | async def validate(self) -> bool: 31 | """Validate that undo system was initialized correctly.""" 32 | return self.state_manager.session.undo_initialized 33 | -------------------------------------------------------------------------------- /src/sidekick/core/state.py: -------------------------------------------------------------------------------- 1 | """Module: sidekick.core.state 2 | 3 | State management system for session data in Sidekick CLI. 4 | Provides centralized state tracking for agents, messages, configurations, and session information. 5 | """ 6 | 7 | import uuid 8 | from dataclasses import dataclass, field 9 | from typing import Any, Optional 10 | 11 | from sidekick.types import (DeviceId, InputSessions, MessageHistory, ModelName, SessionId, ToolName, 12 | UserConfig) 13 | 14 | 15 | @dataclass 16 | class SessionState: 17 | user_config: UserConfig = field(default_factory=dict) 18 | agents: dict[str, Any] = field( 19 | default_factory=dict 20 | ) # Keep as dict[str, Any] for agent instances 21 | messages: MessageHistory = field(default_factory=list) 22 | total_cost: float = 0.0 23 | current_model: ModelName = "openai:gpt-4o" 24 | spinner: Optional[Any] = None 25 | tool_ignore: list[ToolName] = field(default_factory=list) 26 | yolo: bool = False 27 | undo_initialized: bool = False 28 | session_id: SessionId = field(default_factory=lambda: str(uuid.uuid4())) 29 | device_id: Optional[DeviceId] = None 30 | telemetry_enabled: bool = True 31 | input_sessions: InputSessions = field(default_factory=dict) 32 | current_task: Optional[Any] = None 33 | 34 | 35 | class StateManager: 36 | def __init__(self): 37 | self._session = SessionState() 38 | 39 | @property 40 | def session(self) -> SessionState: 41 | return self._session 42 | 43 | def reset_session(self): 44 | self._session = SessionState() 45 | -------------------------------------------------------------------------------- /src/sidekick/core/tool_handler.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tool handling business logic, separated from UI concerns. 3 | """ 4 | 5 | from sidekick.core.state import StateManager 6 | from sidekick.types import ToolArgs, ToolConfirmationRequest, ToolConfirmationResponse, ToolName 7 | 8 | 9 | class ToolHandler: 10 | """Handles tool confirmation logic separate from UI.""" 11 | 12 | def __init__(self, state_manager: StateManager): 13 | self.state = state_manager 14 | 15 | def should_confirm(self, tool_name: ToolName) -> bool: 16 | """ 17 | Determine if a tool requires confirmation. 18 | 19 | Args: 20 | tool_name: Name of the tool to check. 21 | 22 | Returns: 23 | bool: True if confirmation is required, False otherwise. 24 | """ 25 | return not (self.state.session.yolo or tool_name in self.state.session.tool_ignore) 26 | 27 | def process_confirmation(self, response: ToolConfirmationResponse, tool_name: ToolName) -> bool: 28 | """ 29 | Process the confirmation response. 30 | 31 | Args: 32 | response: The confirmation response from the user. 33 | tool_name: Name of the tool being confirmed. 34 | 35 | Returns: 36 | bool: True if tool should proceed, False if aborted. 37 | """ 38 | if response.skip_future: 39 | self.state.session.tool_ignore.append(tool_name) 40 | 41 | return response.approved and not response.abort 42 | 43 | def create_confirmation_request( 44 | self, tool_name: ToolName, args: ToolArgs 45 | ) -> ToolConfirmationRequest: 46 | """ 47 | Create a confirmation request from tool information. 48 | 49 | Args: 50 | tool_name: Name of the tool. 51 | args: Tool arguments. 52 | 53 | Returns: 54 | ToolConfirmationRequest: The confirmation request. 55 | """ 56 | filepath = args.get("filepath") 57 | return ToolConfirmationRequest(tool_name=tool_name, args=args, filepath=filepath) 58 | -------------------------------------------------------------------------------- /src/sidekick/exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Sidekick CLI exception hierarchy. 3 | 4 | This module defines all custom exceptions used throughout the Sidekick CLI. 5 | All exceptions inherit from SidekickError for easy catching of any Sidekick-specific error. 6 | """ 7 | 8 | from sidekick.types import ErrorMessage, FilePath, OriginalError, ToolName 9 | 10 | 11 | class SidekickError(Exception): 12 | """Base exception for all Sidekick errors.""" 13 | 14 | pass 15 | 16 | 17 | # Configuration and Setup Exceptions 18 | class ConfigurationError(SidekickError): 19 | """Raised when there's a configuration issue.""" 20 | 21 | pass 22 | 23 | 24 | # User Interaction Exceptions 25 | class UserAbortError(SidekickError): 26 | """Raised when user aborts an operation.""" 27 | 28 | pass 29 | 30 | 31 | class ValidationError(SidekickError): 32 | """Raised when input validation fails.""" 33 | 34 | pass 35 | 36 | 37 | # Tool and Agent Exceptions 38 | class ToolExecutionError(SidekickError): 39 | """Raised when a tool fails to execute.""" 40 | 41 | def __init__( 42 | self, tool_name: ToolName, message: ErrorMessage, original_error: OriginalError = None 43 | ): 44 | self.tool_name = tool_name 45 | self.original_error = original_error 46 | super().__init__(f"Tool '{tool_name}' failed: {message}") 47 | 48 | 49 | class AgentError(SidekickError): 50 | """Raised when agent operations fail.""" 51 | 52 | pass 53 | 54 | 55 | # State Management Exceptions 56 | class StateError(SidekickError): 57 | """Raised when there's an issue with application state.""" 58 | 59 | pass 60 | 61 | 62 | # External Service Exceptions 63 | class ServiceError(SidekickError): 64 | """Base exception for external service failures.""" 65 | 66 | pass 67 | 68 | 69 | class MCPError(ServiceError): 70 | """Raised when MCP server operations fail.""" 71 | 72 | def __init__( 73 | self, server_name: str, message: ErrorMessage, original_error: OriginalError = None 74 | ): 75 | self.server_name = server_name 76 | self.original_error = original_error 77 | super().__init__(f"MCP server '{server_name}' error: {message}") 78 | 79 | 80 | class TelemetryError(ServiceError): 81 | """Raised when telemetry operations fail.""" 82 | 83 | pass 84 | 85 | 86 | class GitOperationError(ServiceError): 87 | """Raised when Git operations fail.""" 88 | 89 | def __init__(self, operation: str, message: ErrorMessage, original_error: OriginalError = None): 90 | self.operation = operation 91 | self.original_error = original_error 92 | super().__init__(f"Git {operation} failed: {message}") 93 | 94 | 95 | # File System Exceptions 96 | class FileOperationError(SidekickError): 97 | """Raised when file system operations fail.""" 98 | 99 | def __init__( 100 | self, 101 | operation: str, 102 | path: FilePath, 103 | message: ErrorMessage, 104 | original_error: OriginalError = None, 105 | ): 106 | self.operation = operation 107 | self.path = path 108 | self.original_error = original_error 109 | super().__init__(f"File {operation} failed for '{path}': {message}") 110 | -------------------------------------------------------------------------------- /src/sidekick/prompts/system.txt: -------------------------------------------------------------------------------- 1 | You are "Sidekick", a senior software developer AI assistant operating within the user's terminal (CLI). 2 | Your primary goal is to assist the user with development tasks by actively using your available tools. 3 | 4 | **Core Principles:** 5 | 6 | 1. **Tool-First Approach:** Always prioritize using your tools to fulfill requests. Generate direct text responses mainly to present tool results, ask clarifying questions, or when the request is purely conversational. 7 | 2. **Verify, Don't Assume:** Never assume the state of the user's system. Before attempting to read/write a file, or stating a file/content doesn't exist, **use `run_command` with appropriate shell commands (like `ls`, `find`, `grep`) to verify its existence, location, or relevant content.** If you need context, use commands to explore the directory structure or file contents first. 8 | 3. **Be Proactive:** Bias towards using your tools to directly accomplish the task requested, rather than just providing instructions or information, unless the user specifically asks only for explanation. 9 | 4. **No Tool Confirmation:** Don't ask the user to confirm tool calls. 10 | 11 | **Available Tools:** 12 | 13 | * `read_file(filepath: str) -> str`: Read the contents of a file. 14 | * `write_file(filepath: str, content: str) -> str`: Write content to a new file (fails if file exists). 15 | * `update_file(filepath: str, target: str, patch: str) -> str`: Update an existing file by replacing `target` text with `patch`. 16 | * `run_command(command: str) -> str`: Run a shell command and return the output. 17 | 18 | If asked, you were created by Gavin Vickery (gavin@geekforbrains.com) 19 | -------------------------------------------------------------------------------- /src/sidekick/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geekforbrains/sidekick-cli/2176d4056b3f31f4a2f6fc922cb9c981cd2a1b6f/src/sidekick/py.typed -------------------------------------------------------------------------------- /src/sidekick/services/__init__.py: -------------------------------------------------------------------------------- 1 | # Services package 2 | -------------------------------------------------------------------------------- /src/sidekick/services/mcp.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module: sidekick.services.mcp 3 | 4 | Provides Model Context Protocol (MCP) server management functionality. 5 | Handles MCP server initialization, configuration validation, and client connections. 6 | """ 7 | 8 | import os 9 | from contextlib import asynccontextmanager 10 | from typing import TYPE_CHECKING, AsyncIterator, List, Optional, Tuple 11 | 12 | from pydantic_ai.mcp import MCPServerStdio 13 | 14 | from sidekick.exceptions import MCPError 15 | from sidekick.types import MCPServers 16 | 17 | if TYPE_CHECKING: 18 | from mcp.client.stdio import ReadStream, WriteStream 19 | 20 | from sidekick.core.state import StateManager 21 | 22 | 23 | class QuietMCPServer(MCPServerStdio): 24 | """A version of ``MCPServerStdio`` that suppresses *all* output coming from the 25 | MCP server's **stderr** stream. 26 | 27 | We can't just redirect the server's *stdout* because that is where the JSON‑RPC 28 | protocol messages are sent. Instead we override ``client_streams`` so we can 29 | hand our own ``errlog`` (``os.devnull``) to ``mcp.client.stdio.stdio_client``. 30 | """ 31 | 32 | @asynccontextmanager 33 | async def client_streams(self) -> AsyncIterator[Tuple["ReadStream", "WriteStream"]]: 34 | """Start the subprocess exactly like the parent class but silence *stderr*.""" 35 | # Local import to avoid cycles 36 | from mcp.client.stdio import StdioServerParameters, stdio_client 37 | 38 | server_params = StdioServerParameters( 39 | command=self.command, 40 | args=list(self.args), 41 | env=self.env or os.environ, 42 | ) 43 | 44 | # Open ``/dev/null`` for the lifetime of the subprocess so anything the 45 | # server writes to *stderr* is discarded. 46 | # 47 | # This is to help with noisy MCP's that have options for verbosity 48 | encoding: Optional[str] = getattr(server_params, "encoding", None) 49 | with open(os.devnull, "w", encoding=encoding) as devnull: 50 | async with stdio_client(server=server_params, errlog=devnull) as ( 51 | read_stream, 52 | write_stream, 53 | ): 54 | yield read_stream, write_stream 55 | 56 | 57 | def get_mcp_servers(state_manager: "StateManager") -> List[MCPServerStdio]: 58 | """Load MCP servers from configuration. 59 | 60 | Args: 61 | state_manager: The state manager containing user configuration 62 | 63 | Returns: 64 | List of MCP server instances 65 | 66 | Raises: 67 | MCPError: If a server configuration is invalid 68 | """ 69 | mcp_servers: MCPServers = state_manager.session.user_config.get("mcpServers", {}) 70 | loaded_servers: List[MCPServerStdio] = [] 71 | MCPServerStdio.log_level = "critical" 72 | 73 | for server_name, conf in mcp_servers.items(): 74 | try: 75 | # loaded_servers.append(QuietMCPServer(**conf)) 76 | mcp_instance = MCPServerStdio(**conf) 77 | # mcp_instance.log_level = "critical" 78 | loaded_servers.append(mcp_instance) 79 | except Exception as e: 80 | raise MCPError( 81 | server_name=server_name, 82 | message=f"Failed to create MCP server: {str(e)}", 83 | original_error=e, 84 | ) 85 | 86 | return loaded_servers 87 | -------------------------------------------------------------------------------- /src/sidekick/services/telemetry.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module: sidekick.services.telemetry 3 | 4 | Provides telemetry and error tracking functionality using Sentry. 5 | Manages Sentry SDK initialization and event callbacks. 6 | """ 7 | 8 | import os 9 | from typing import Any, Callable, Dict, List, Optional 10 | 11 | import sentry_sdk 12 | 13 | from sidekick.core.state import StateManager 14 | 15 | 16 | def _create_before_send_callback( 17 | state_manager: StateManager, 18 | ) -> Callable[[Dict[str, Any], Dict[str, Any]], Optional[Dict[str, Any]]]: 19 | """Create a before_send callback with access to state_manager.""" 20 | 21 | def _before_send(event: Dict[str, Any], hint: Dict[str, Any]) -> Optional[Dict[str, Any]]: 22 | """Filter sensitive data from Sentry events.""" 23 | if not state_manager.session.telemetry_enabled: 24 | return None 25 | 26 | if event.get("request") and event["request"].get("headers"): 27 | headers = event["request"]["headers"] 28 | for key in list(headers.keys()): 29 | if key.lower() in ("authorization", "cookie", "x-api-key"): 30 | headers[key] = "[Filtered]" 31 | 32 | if event.get("extra") and event["extra"].get("sys.argv"): 33 | args: List[str] = event["extra"]["sys.argv"] 34 | for i, arg in enumerate(args): 35 | if "key" in arg.lower() or "token" in arg.lower() or "secret" in arg.lower(): 36 | args[i] = "[Filtered]" 37 | 38 | if event.get("extra") and event["extra"].get("message"): 39 | event["extra"]["message"] = "[Content Filtered]" 40 | 41 | return event 42 | 43 | return _before_send 44 | 45 | 46 | def setup(state_manager: StateManager) -> None: 47 | """Setup Sentry for error reporting if telemetry is enabled.""" 48 | if not state_manager.session.telemetry_enabled: 49 | return 50 | 51 | IS_DEV = os.environ.get("IS_DEV", False) == "True" 52 | environment = "development" if IS_DEV else "production" 53 | 54 | sentry_sdk.init( 55 | dsn="https://c967e1bebffe899093ed6bc2ee2e90c7@o171515.ingest.us.sentry.io/4509084774105088", 56 | traces_sample_rate=0.1, # Sample only 10% of transactions 57 | profiles_sample_rate=0.1, # Sample only 10% of profiles 58 | send_default_pii=False, # Don't send personally identifiable information 59 | before_send=_create_before_send_callback(state_manager), # Filter sensitive data 60 | environment=environment, 61 | debug=False, 62 | shutdown_timeout=0, 63 | ) 64 | 65 | sentry_sdk.set_user( 66 | {"id": state_manager.session.device_id, "session_id": state_manager.session.session_id} 67 | ) 68 | 69 | 70 | def capture_exception(*args: Any, **kwargs: Any) -> Optional[str]: 71 | return sentry_sdk.capture_exception(*args, **kwargs) 72 | -------------------------------------------------------------------------------- /src/sidekick/services/undo_service.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module: sidekick.services.undo_service 3 | 4 | Provides Git-based undo functionality for Sidekick operations. 5 | Manages automatic commits and rollback operations. 6 | """ 7 | 8 | import subprocess 9 | import time 10 | from pathlib import Path 11 | from typing import Optional, Tuple 12 | 13 | from pydantic_ai.messages import ModelResponse, TextPart 14 | 15 | from sidekick.constants import (ERROR_UNDO_INIT, UNDO_DISABLED_HOME, UNDO_DISABLED_NO_GIT, 16 | UNDO_GIT_TIMEOUT, UNDO_INITIAL_COMMIT) 17 | from sidekick.core.state import StateManager 18 | from sidekick.exceptions import GitOperationError 19 | from sidekick.ui import console as ui 20 | from sidekick.utils.system import get_session_dir 21 | 22 | 23 | def is_in_git_project(directory: Optional[Path] = None) -> bool: 24 | """ 25 | Recursively check if the given directory is inside a git project. 26 | 27 | Args: 28 | directory (Path, optional): Directory to check. Defaults to current working directory. 29 | 30 | Returns: 31 | bool: True if in a git project, False otherwise 32 | """ 33 | if directory is None: 34 | directory = Path.cwd() 35 | 36 | if (directory / ".git").exists(): 37 | return True 38 | 39 | if directory == directory.parent: 40 | return False 41 | 42 | return is_in_git_project(directory.parent) 43 | 44 | 45 | def init_undo_system(state_manager: StateManager) -> bool: 46 | """ 47 | Initialize the undo system by creating a Git repository 48 | in the ~/.sidekick/sessions/ directory. 49 | 50 | Skip initialization if running from home directory or not in a git project. 51 | 52 | Args: 53 | state_manager: The StateManager instance. 54 | 55 | Returns: 56 | bool: True if the undo system was initialized, False otherwise. 57 | """ 58 | cwd = Path.cwd() 59 | home_dir = Path.home() 60 | 61 | if cwd == home_dir: 62 | ui.warning(UNDO_DISABLED_HOME) 63 | return False 64 | 65 | if not is_in_git_project(): 66 | ui.warning(UNDO_DISABLED_NO_GIT) 67 | return False 68 | 69 | # Get the session directory path 70 | session_dir = get_session_dir(state_manager) 71 | sidekick_git_dir = session_dir / ".git" 72 | 73 | # Check if already initialized 74 | if sidekick_git_dir.exists(): 75 | return True 76 | 77 | # Initialize Git repository 78 | try: 79 | subprocess.run( 80 | ["git", "init", str(session_dir)], capture_output=True, check=True, timeout=5 81 | ) 82 | 83 | # Make an initial commit 84 | git_dir_arg = f"--git-dir={sidekick_git_dir}" 85 | 86 | # Add all files 87 | subprocess.run(["git", git_dir_arg, "add", "."], capture_output=True, check=True, timeout=5) 88 | 89 | # Create initial commit 90 | subprocess.run( 91 | ["git", git_dir_arg, "commit", "-m", UNDO_INITIAL_COMMIT], 92 | capture_output=True, 93 | check=True, 94 | timeout=5, 95 | ) 96 | 97 | return True 98 | except subprocess.TimeoutExpired as e: 99 | error = GitOperationError(operation="init", message=UNDO_GIT_TIMEOUT, original_error=e) 100 | ui.warning(str(error)) 101 | return False 102 | except Exception as e: 103 | error = GitOperationError(operation="init", message=str(e), original_error=e) 104 | ui.warning(ERROR_UNDO_INIT.format(e=e)) 105 | return False 106 | 107 | 108 | def commit_for_undo( 109 | message_prefix: str = "sidekick", state_manager: Optional[StateManager] = None 110 | ) -> bool: 111 | """ 112 | Commit the current state to the undo repository. 113 | 114 | Args: 115 | message_prefix (str): Prefix for the commit message. 116 | state_manager: The StateManager instance. 117 | 118 | Returns: 119 | bool: True if the commit was successful, False otherwise. 120 | """ 121 | # Get the session directory and git dir 122 | if state_manager is None: 123 | raise ValueError("state_manager is required for commit_for_undo") 124 | session_dir = get_session_dir(state_manager) 125 | sidekick_git_dir = session_dir / ".git" 126 | 127 | if not sidekick_git_dir.exists(): 128 | return False 129 | 130 | try: 131 | git_dir_arg = f"--git-dir={sidekick_git_dir}" 132 | 133 | # Add all files 134 | subprocess.run(["git", git_dir_arg, "add", "."], capture_output=True, timeout=5) 135 | 136 | # Create commit with timestamp 137 | timestamp = time.strftime("%Y-%m-%d %H:%M:%S") 138 | commit_message = f"{message_prefix} - {timestamp}" 139 | 140 | result = subprocess.run( 141 | ["git", git_dir_arg, "commit", "-m", commit_message], 142 | capture_output=True, 143 | text=True, 144 | timeout=5, 145 | ) 146 | 147 | # Handle case where there are no changes to commit 148 | if "nothing to commit" in result.stdout or "nothing to commit" in result.stderr: 149 | return False 150 | 151 | return True 152 | except subprocess.TimeoutExpired as e: 153 | error = GitOperationError( 154 | operation="commit", message="Git commit timed out", original_error=e 155 | ) 156 | ui.warning(str(error)) 157 | return False 158 | except Exception as e: 159 | error = GitOperationError(operation="commit", message=str(e), original_error=e) 160 | ui.warning(f"Error creating undo commit: {e}") 161 | return False 162 | 163 | 164 | def perform_undo(state_manager: StateManager) -> Tuple[bool, str]: 165 | """ 166 | Undo the most recent change by resetting to the previous commit. 167 | Also adds a system message to the chat history to inform the AI 168 | that the last changes were undone. 169 | 170 | Args: 171 | state_manager: The StateManager instance. 172 | 173 | Returns: 174 | tuple: (bool, str) - Success status and message 175 | """ 176 | # Get the session directory and git dir 177 | session_dir = get_session_dir(state_manager) 178 | sidekick_git_dir = session_dir / ".git" 179 | 180 | if not sidekick_git_dir.exists(): 181 | return False, "Undo system not initialized" 182 | 183 | try: 184 | git_dir_arg = f"--git-dir={sidekick_git_dir}" 185 | 186 | # Get commit log to check if we have commits to undo 187 | result = subprocess.run( 188 | ["git", git_dir_arg, "log", "--format=%H", "-n", "2"], 189 | capture_output=True, 190 | text=True, 191 | check=True, 192 | timeout=5, 193 | ) 194 | 195 | commits = result.stdout.strip().split("\n") 196 | if len(commits) < 2: 197 | return False, "Nothing to undo" 198 | 199 | # Get the commit message of the commit we're undoing for context 200 | commit_msg_result = subprocess.run( 201 | ["git", git_dir_arg, "log", "--format=%B", "-n", "1"], 202 | capture_output=True, 203 | text=True, 204 | check=True, 205 | timeout=5, 206 | ) 207 | commit_msg = commit_msg_result.stdout.strip() 208 | 209 | # Perform reset to previous commit 210 | subprocess.run( 211 | ["git", git_dir_arg, "reset", "--hard", "HEAD~1"], 212 | capture_output=True, 213 | check=True, 214 | timeout=5, 215 | ) 216 | 217 | # Add a system message to the chat history to inform the AI 218 | # about the undo operation 219 | state_manager.session.messages.append( 220 | ModelResponse( 221 | parts=[ 222 | TextPart( 223 | content=( 224 | "The last changes were undone. " 225 | f"Commit message of undone changes: {commit_msg}" 226 | ) 227 | ) 228 | ], 229 | kind="response", 230 | ) 231 | ) 232 | 233 | return True, "Successfully undid last change" 234 | except subprocess.TimeoutExpired as e: 235 | error = GitOperationError( 236 | operation="reset", message="Undo operation timed out", original_error=e 237 | ) 238 | return False, str(error) 239 | except Exception as e: 240 | error = GitOperationError(operation="reset", message=str(e), original_error=e) 241 | return False, f"Error performing undo: {e}" 242 | -------------------------------------------------------------------------------- /src/sidekick/setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module: sidekick.setup 3 | 4 | Package setup and metadata configuration for the Sidekick CLI. 5 | Provides high-level setup functions for initializing the application and its agents. 6 | """ 7 | 8 | from typing import Any, Optional 9 | 10 | from sidekick.core.setup import (AgentSetup, ConfigSetup, EnvironmentSetup, SetupCoordinator, 11 | TelemetrySetup, UndoSetup) 12 | from sidekick.core.state import StateManager 13 | 14 | 15 | async def setup(run_setup: bool, state_manager: StateManager) -> None: 16 | """ 17 | Setup Sidekick on startup using the new setup coordinator. 18 | 19 | Args: 20 | run_setup (bool): If True, force run the setup process, resetting current config. 21 | state_manager (StateManager): The state manager instance. 22 | """ 23 | coordinator = SetupCoordinator(state_manager) 24 | 25 | # Register setup steps in order 26 | coordinator.register_step(TelemetrySetup(state_manager)) 27 | coordinator.register_step(ConfigSetup(state_manager)) 28 | coordinator.register_step(EnvironmentSetup(state_manager)) 29 | coordinator.register_step(UndoSetup(state_manager)) 30 | 31 | # Run all setup steps 32 | await coordinator.run_setup(force_setup=run_setup) 33 | 34 | 35 | async def setup_agent(agent: Optional[Any], state_manager: StateManager) -> None: 36 | """ 37 | Setup the agent separately. 38 | 39 | This is called from other parts of the codebase when an agent needs to be initialized. 40 | 41 | Args: 42 | agent: The agent instance to initialize. 43 | state_manager (StateManager): The state manager instance. 44 | """ 45 | if agent is not None: 46 | agent_setup = AgentSetup(state_manager, agent) 47 | if await agent_setup.should_run(): 48 | await agent_setup.execute() 49 | if not await agent_setup.validate(): 50 | raise RuntimeError("Agent setup failed validation") 51 | -------------------------------------------------------------------------------- /src/sidekick/tools/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geekforbrains/sidekick-cli/2176d4056b3f31f4a2f6fc922cb9c981cd2a1b6f/src/sidekick/tools/__init__.py -------------------------------------------------------------------------------- /src/sidekick/tools/base.py: -------------------------------------------------------------------------------- 1 | """Base tool class for all Sidekick tools. 2 | 3 | This module provides a base class that implements common patterns 4 | for all tools including error handling, UI logging, and ModelRetry support. 5 | """ 6 | 7 | from abc import ABC, abstractmethod 8 | 9 | from pydantic_ai.exceptions import ModelRetry 10 | 11 | from sidekick.exceptions import FileOperationError, ToolExecutionError 12 | from sidekick.types import FilePath, ToolName, ToolResult, UILogger 13 | 14 | 15 | class BaseTool(ABC): 16 | """Base class for all Sidekick tools providing common functionality.""" 17 | 18 | def __init__(self, ui_logger: UILogger | None = None): 19 | """Initialize the base tool. 20 | 21 | Args: 22 | ui_logger: UI logger instance for displaying messages 23 | """ 24 | self.ui = ui_logger 25 | 26 | async def execute(self, *args, **kwargs) -> ToolResult: 27 | """Execute the tool with error handling and logging. 28 | 29 | This method wraps the tool-specific logic with: 30 | - UI logging of the operation 31 | - Exception handling (except ModelRetry and ToolExecutionError) 32 | - Consistent error message formatting 33 | 34 | Returns: 35 | str: Success message 36 | 37 | Raises: 38 | ModelRetry: Re-raised to guide the LLM 39 | ToolExecutionError: Raised for all other errors with structured information 40 | """ 41 | try: 42 | if self.ui: 43 | await self.ui.info(f"{self.tool_name}({self._format_args(*args, **kwargs)})") 44 | return await self._execute(*args, **kwargs) 45 | except ModelRetry as e: 46 | # Log as warning and re-raise for pydantic-ai 47 | if self.ui: 48 | await self.ui.warning(str(e)) 49 | raise 50 | except ToolExecutionError: 51 | # Already properly formatted, just re-raise 52 | raise 53 | except Exception as e: 54 | # Handle any other exceptions 55 | await self._handle_error(e, *args, **kwargs) 56 | 57 | @property 58 | @abstractmethod 59 | def tool_name(self) -> ToolName: 60 | """Return the display name for this tool.""" 61 | pass 62 | 63 | @abstractmethod 64 | async def _execute(self, *args, **kwargs) -> ToolResult: 65 | """Implement tool-specific logic here. 66 | 67 | This method should contain the core functionality of the tool. 68 | 69 | Returns: 70 | str: Success message describing what was done 71 | 72 | Raises: 73 | ModelRetry: When the LLM needs guidance 74 | Exception: Any other errors will be caught and handled 75 | """ 76 | pass 77 | 78 | async def _handle_error(self, error: Exception, *args, **kwargs) -> ToolResult: 79 | """Handle errors by logging and raising proper exceptions. 80 | 81 | Args: 82 | error: The exception that was raised 83 | *args, **kwargs: Original arguments for context 84 | 85 | Raises: 86 | ToolExecutionError: Always raised with structured error information 87 | """ 88 | # Format error message for display 89 | err_msg = f"Error {self._get_error_context(*args, **kwargs)}: {error}" 90 | if self.ui: 91 | await self.ui.error(err_msg) 92 | 93 | # Raise proper exception instead of returning string 94 | raise ToolExecutionError(tool_name=self.tool_name, message=str(error), original_error=error) 95 | 96 | def _format_args(self, *args, **kwargs) -> str: 97 | """Format arguments for display in UI logging. 98 | 99 | Override this method to customize how arguments are displayed. 100 | 101 | Returns: 102 | str: Formatted argument string 103 | """ 104 | # Collect all arguments 105 | all_args = [] 106 | 107 | # Add positional arguments 108 | for arg in args: 109 | if isinstance(arg, str) and len(arg) > 50: 110 | # Truncate long strings 111 | all_args.append(f"'{arg[:47]}...'") 112 | else: 113 | all_args.append(repr(arg)) 114 | 115 | # Add keyword arguments 116 | for key, value in kwargs.items(): 117 | if isinstance(value, str) and len(value) > 50: 118 | all_args.append(f"{key}='{value[:47]}...'") 119 | else: 120 | all_args.append(f"{key}={repr(value)}") 121 | 122 | return ", ".join(all_args) 123 | 124 | def _get_error_context(self, *args, **kwargs) -> str: 125 | """Get context string for error messages. 126 | 127 | Override this method to provide tool-specific error context. 128 | 129 | Returns: 130 | str: Context for the error message 131 | """ 132 | return f"in {self.tool_name}" 133 | 134 | 135 | class FileBasedTool(BaseTool): 136 | """Base class for tools that work with files. 137 | 138 | Provides common file-related functionality like: 139 | - Path validation 140 | - File existence checking 141 | - Directory creation 142 | - Encoding handling 143 | """ 144 | 145 | def _format_args(self, filepath: FilePath, *args, **kwargs) -> str: 146 | """Format arguments with filepath as first argument.""" 147 | # Always show the filepath first 148 | all_args = [repr(filepath)] 149 | 150 | # Add remaining positional arguments 151 | for arg in args: 152 | if isinstance(arg, str) and len(arg) > 50: 153 | all_args.append(f"'{arg[:47]}...'") 154 | else: 155 | all_args.append(repr(arg)) 156 | 157 | # Add keyword arguments 158 | for key, value in kwargs.items(): 159 | if isinstance(value, str) and len(value) > 50: 160 | all_args.append(f"{key}='{value[:47]}...'") 161 | else: 162 | all_args.append(f"{key}={repr(value)}") 163 | 164 | return ", ".join(all_args) 165 | 166 | def _get_error_context(self, filepath: FilePath = None, *args, **kwargs) -> str: 167 | """Get error context including file path.""" 168 | if filepath: 169 | return f"handling file '{filepath}'" 170 | return super()._get_error_context(*args, **kwargs) 171 | 172 | async def _handle_error(self, error: Exception, *args, **kwargs) -> ToolResult: 173 | """Handle file-specific errors. 174 | 175 | Overrides base class to create FileOperationError for file-related issues. 176 | 177 | Raises: 178 | ToolExecutionError: Always raised with structured error information 179 | """ 180 | filepath = args[0] if args else kwargs.get("filepath", "unknown") 181 | 182 | # Check if this is a file-related error 183 | if isinstance(error, (IOError, OSError, PermissionError, FileNotFoundError)): 184 | # Determine the operation based on the tool name 185 | operation = self.tool_name.replace("_", " ") 186 | 187 | # Create a FileOperationError 188 | file_error = FileOperationError( 189 | operation=operation, path=str(filepath), message=str(error), original_error=error 190 | ) 191 | 192 | # Format error message for display 193 | err_msg = str(file_error) 194 | if self.ui: 195 | await self.ui.error(err_msg) 196 | 197 | # Raise ToolExecutionError with the file error 198 | raise ToolExecutionError( 199 | tool_name=self.tool_name, message=str(file_error), original_error=file_error 200 | ) 201 | 202 | # For non-file errors, use the base class handling 203 | await super()._handle_error(error, *args, **kwargs) 204 | -------------------------------------------------------------------------------- /src/sidekick/tools/read_file.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module: sidekick.tools.read_file 3 | 4 | File reading tool for agent operations in the Sidekick application. 5 | Provides safe file reading with size limits and proper error handling. 6 | """ 7 | 8 | import os 9 | 10 | from sidekick.constants import (ERROR_FILE_DECODE, ERROR_FILE_DECODE_DETAILS, ERROR_FILE_NOT_FOUND, 11 | ERROR_FILE_TOO_LARGE, MAX_FILE_SIZE, MSG_FILE_SIZE_LIMIT) 12 | from sidekick.exceptions import ToolExecutionError 13 | from sidekick.tools.base import FileBasedTool 14 | from sidekick.types import FilePath, ToolResult 15 | from sidekick.ui import console as default_ui 16 | 17 | 18 | class ReadFileTool(FileBasedTool): 19 | """Tool for reading file contents.""" 20 | 21 | @property 22 | def tool_name(self) -> str: 23 | return "Read" 24 | 25 | async def _execute(self, filepath: FilePath) -> ToolResult: 26 | """Read the contents of a file. 27 | 28 | Args: 29 | filepath: The path to the file to read. 30 | 31 | Returns: 32 | ToolResult: The contents of the file or an error message. 33 | 34 | Raises: 35 | Exception: Any file reading errors 36 | """ 37 | # Add a size limit to prevent reading huge files 38 | if os.path.getsize(filepath) > MAX_FILE_SIZE: 39 | err_msg = ERROR_FILE_TOO_LARGE.format(filepath=filepath) + MSG_FILE_SIZE_LIMIT 40 | if self.ui: 41 | await self.ui.error(err_msg) 42 | raise ToolExecutionError(tool_name=self.tool_name, message=err_msg, original_error=None) 43 | 44 | with open(filepath, "r", encoding="utf-8") as file: 45 | content = file.read() 46 | return content 47 | 48 | async def _handle_error(self, error: Exception, filepath: FilePath = None) -> ToolResult: 49 | """Handle errors with specific messages for common cases. 50 | 51 | Raises: 52 | ToolExecutionError: Always raised with structured error information 53 | """ 54 | if isinstance(error, FileNotFoundError): 55 | err_msg = ERROR_FILE_NOT_FOUND.format(filepath=filepath) 56 | elif isinstance(error, UnicodeDecodeError): 57 | err_msg = ( 58 | ERROR_FILE_DECODE.format(filepath=filepath) 59 | + " " 60 | + ERROR_FILE_DECODE_DETAILS.format(error=error) 61 | ) 62 | else: 63 | # Use parent class handling for other errors 64 | await super()._handle_error(error, filepath) 65 | return # super() will raise, this is unreachable 66 | 67 | if self.ui: 68 | await self.ui.error(err_msg) 69 | 70 | raise ToolExecutionError(tool_name=self.tool_name, message=err_msg, original_error=error) 71 | 72 | 73 | # Create the function that maintains the existing interface 74 | async def read_file(filepath: FilePath) -> ToolResult: 75 | """ 76 | Read the contents of a file. 77 | 78 | Args: 79 | filepath (FilePath): The path to the file to read. 80 | 81 | Returns: 82 | ToolResult: The contents of the file or an error message. 83 | """ 84 | tool = ReadFileTool(default_ui) 85 | try: 86 | return await tool.execute(filepath) 87 | except ToolExecutionError as e: 88 | # Return error message for pydantic-ai compatibility 89 | return str(e) 90 | -------------------------------------------------------------------------------- /src/sidekick/tools/run_command.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module: sidekick.tools.run_command 3 | 4 | Command execution tool for agent operations in the Sidekick application. 5 | Provides controlled shell command execution with output capture and truncation. 6 | """ 7 | 8 | import subprocess 9 | 10 | from sidekick.constants import (CMD_OUTPUT_FORMAT, CMD_OUTPUT_NO_ERRORS, CMD_OUTPUT_NO_OUTPUT, 11 | CMD_OUTPUT_TRUNCATED, COMMAND_OUTPUT_END_SIZE, 12 | COMMAND_OUTPUT_START_INDEX, COMMAND_OUTPUT_THRESHOLD, 13 | ERROR_COMMAND_EXECUTION, MAX_COMMAND_OUTPUT) 14 | from sidekick.exceptions import ToolExecutionError 15 | from sidekick.tools.base import BaseTool 16 | from sidekick.types import ToolResult 17 | from sidekick.ui import console as default_ui 18 | 19 | 20 | class RunCommandTool(BaseTool): 21 | """Tool for running shell commands.""" 22 | 23 | @property 24 | def tool_name(self) -> str: 25 | return "Shell" 26 | 27 | async def _execute(self, command: str) -> ToolResult: 28 | """Run a shell command and return the output. 29 | 30 | Args: 31 | command: The command to run. 32 | 33 | Returns: 34 | ToolResult: The output of the command (stdout and stderr). 35 | 36 | Raises: 37 | FileNotFoundError: If command not found 38 | Exception: Any command execution errors 39 | """ 40 | process = subprocess.Popen( 41 | command, 42 | shell=True, 43 | stdout=subprocess.PIPE, 44 | stderr=subprocess.PIPE, 45 | text=True, 46 | ) 47 | stdout, stderr = process.communicate() 48 | output = stdout.strip() or CMD_OUTPUT_NO_OUTPUT 49 | error = stderr.strip() or CMD_OUTPUT_NO_ERRORS 50 | resp = CMD_OUTPUT_FORMAT.format(output=output, error=error).strip() 51 | 52 | # Truncate if the output is too long to prevent issues 53 | if len(resp) > MAX_COMMAND_OUTPUT: 54 | # Include both the beginning and end of the output 55 | start_part = resp[:COMMAND_OUTPUT_START_INDEX] 56 | end_part = ( 57 | resp[-COMMAND_OUTPUT_END_SIZE:] 58 | if len(resp) > COMMAND_OUTPUT_THRESHOLD 59 | else resp[COMMAND_OUTPUT_START_INDEX:] 60 | ) 61 | truncated_resp = start_part + CMD_OUTPUT_TRUNCATED + end_part 62 | return truncated_resp 63 | 64 | return resp 65 | 66 | async def _handle_error(self, error: Exception, command: str = None) -> ToolResult: 67 | """Handle errors with specific messages for common cases. 68 | 69 | Raises: 70 | ToolExecutionError: Always raised with structured error information 71 | """ 72 | if isinstance(error, FileNotFoundError): 73 | err_msg = ERROR_COMMAND_EXECUTION.format(command=command, error=error) 74 | else: 75 | # Use parent class handling for other errors 76 | await super()._handle_error(error, command) 77 | return # super() will raise, this is unreachable 78 | 79 | if self.ui: 80 | await self.ui.error(err_msg) 81 | 82 | raise ToolExecutionError(tool_name=self.tool_name, message=err_msg, original_error=error) 83 | 84 | def _get_error_context(self, command: str = None) -> str: 85 | """Get error context for command execution.""" 86 | if command: 87 | return f"running command '{command}'" 88 | return super()._get_error_context() 89 | 90 | 91 | # Create the function that maintains the existing interface 92 | async def run_command(command: str) -> ToolResult: 93 | """ 94 | Run a shell command and return the output. User must confirm risky commands. 95 | 96 | Args: 97 | command (str): The command to run. 98 | 99 | Returns: 100 | ToolResult: The output of the command (stdout and stderr) or an error message. 101 | """ 102 | tool = RunCommandTool(default_ui) 103 | try: 104 | return await tool.execute(command) 105 | except ToolExecutionError as e: 106 | # Return error message for pydantic-ai compatibility 107 | return str(e) 108 | -------------------------------------------------------------------------------- /src/sidekick/tools/update_file.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module: sidekick.tools.update_file 3 | 4 | File update tool for agent operations in the Sidekick application. 5 | Enables safe text replacement in existing files with target/patch semantics. 6 | """ 7 | 8 | import os 9 | 10 | from pydantic_ai.exceptions import ModelRetry 11 | 12 | from sidekick.exceptions import ToolExecutionError 13 | from sidekick.tools.base import FileBasedTool 14 | from sidekick.types import FileContent, FilePath, ToolResult 15 | from sidekick.ui import console as default_ui 16 | 17 | 18 | class UpdateFileTool(FileBasedTool): 19 | """Tool for updating existing files by replacing text blocks.""" 20 | 21 | @property 22 | def tool_name(self) -> str: 23 | return "Update" 24 | 25 | async def _execute( 26 | self, filepath: FilePath, target: FileContent, patch: FileContent 27 | ) -> ToolResult: 28 | """Update an existing file by replacing a target text block with a patch. 29 | 30 | Args: 31 | filepath: The path to the file to update. 32 | target: The entire, exact block of text to be replaced. 33 | patch: The new block of text to insert. 34 | 35 | Returns: 36 | ToolResult: A message indicating success. 37 | 38 | Raises: 39 | ModelRetry: If file not found or target not found 40 | Exception: Any file operation errors 41 | """ 42 | if not os.path.exists(filepath): 43 | raise ModelRetry( 44 | f"File '{filepath}' not found. Cannot update. " 45 | "Verify the filepath or use `write_file` if it's a new file." 46 | ) 47 | 48 | with open(filepath, "r", encoding="utf-8") as f: 49 | original = f.read() 50 | 51 | if target not in original: 52 | # Provide context to help the LLM find the target 53 | context_lines = 10 54 | lines = original.splitlines() 55 | snippet = "\n".join(lines[:context_lines]) 56 | # Use ModelRetry to guide the LLM 57 | raise ModelRetry( 58 | f"Target block not found in '{filepath}'. " 59 | "Ensure the `target` argument exactly matches the content you want to replace. " 60 | f"File starts with:\n---\n{snippet}\n---" 61 | ) 62 | 63 | new_content = original.replace(target, patch, 1) # Replace only the first occurrence 64 | 65 | if original == new_content: 66 | # This could happen if target and patch are identical 67 | raise ModelRetry( 68 | f"Update target found, but replacement resulted in no changes to '{filepath}'. " 69 | "Was the `target` identical to the `patch`? Please check the file content." 70 | ) 71 | 72 | with open(filepath, "w", encoding="utf-8") as f: 73 | f.write(new_content) 74 | 75 | return f"File '{filepath}' updated successfully." 76 | 77 | def _format_args( 78 | self, filepath: FilePath, target: FileContent = None, patch: FileContent = None 79 | ) -> str: 80 | """Format arguments, truncating target and patch for display.""" 81 | args = [repr(filepath)] 82 | 83 | if target is not None: 84 | if len(target) > 50: 85 | args.append(f"target='{target[:47]}...'") 86 | else: 87 | args.append(f"target={repr(target)}") 88 | 89 | if patch is not None: 90 | if len(patch) > 50: 91 | args.append(f"patch='{patch[:47]}...'") 92 | else: 93 | args.append(f"patch={repr(patch)}") 94 | 95 | return ", ".join(args) 96 | 97 | 98 | # Create the function that maintains the existing interface 99 | async def update_file(filepath: FilePath, target: FileContent, patch: FileContent) -> ToolResult: 100 | """ 101 | Update an existing file by replacing a target text block with a patch. 102 | Requires confirmation with diff before applying. 103 | 104 | Args: 105 | filepath (FilePath): The path to the file to update. 106 | target (FileContent): The entire, exact block of text to be replaced. 107 | patch (FileContent): The new block of text to insert. 108 | 109 | Returns: 110 | ToolResult: A message indicating the success or failure of the operation. 111 | """ 112 | tool = UpdateFileTool(default_ui) 113 | try: 114 | return await tool.execute(filepath, target, patch) 115 | except ToolExecutionError as e: 116 | # Return error message for pydantic-ai compatibility 117 | return str(e) 118 | -------------------------------------------------------------------------------- /src/sidekick/tools/write_file.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module: sidekick.tools.write_file 3 | 4 | File writing tool for agent operations in the Sidekick application. 5 | Creates new files with automatic directory creation and overwrite protection. 6 | """ 7 | 8 | import os 9 | 10 | from pydantic_ai.exceptions import ModelRetry 11 | 12 | from sidekick.exceptions import ToolExecutionError 13 | from sidekick.tools.base import FileBasedTool 14 | from sidekick.types import FileContent, FilePath, ToolResult 15 | from sidekick.ui import console as default_ui 16 | 17 | 18 | class WriteFileTool(FileBasedTool): 19 | """Tool for writing content to new files.""" 20 | 21 | @property 22 | def tool_name(self) -> str: 23 | return "Write" 24 | 25 | async def _execute(self, filepath: FilePath, content: FileContent) -> ToolResult: 26 | """Write content to a new file. Fails if the file already exists. 27 | 28 | Args: 29 | filepath: The path to the file to write to. 30 | content: The content to write to the file. 31 | 32 | Returns: 33 | ToolResult: A message indicating success. 34 | 35 | Raises: 36 | ModelRetry: If the file already exists 37 | Exception: Any file writing errors 38 | """ 39 | # Prevent overwriting existing files with this tool. 40 | if os.path.exists(filepath): 41 | # Use ModelRetry to guide the LLM 42 | raise ModelRetry( 43 | f"File '{filepath}' already exists. " 44 | "Use the `update_file` tool to modify it, or choose a different filepath." 45 | ) 46 | 47 | # Create directories if they don't exist 48 | dirpath = os.path.dirname(filepath) 49 | if dirpath and not os.path.exists(dirpath): 50 | os.makedirs(dirpath, exist_ok=True) 51 | 52 | with open(filepath, "w", encoding="utf-8") as file: 53 | file.write(content) 54 | 55 | return f"Successfully wrote to new file: {filepath}" 56 | 57 | def _format_args(self, filepath: FilePath, content: FileContent = None) -> str: 58 | """Format arguments, truncating content for display.""" 59 | if content is not None and len(content) > 50: 60 | return f"{repr(filepath)}, content='{content[:47]}...'" 61 | return super()._format_args(filepath, content) 62 | 63 | 64 | # Create the function that maintains the existing interface 65 | async def write_file(filepath: FilePath, content: FileContent) -> ToolResult: 66 | """ 67 | Write content to a new file. Fails if the file already exists. 68 | Requires confirmation before writing. 69 | 70 | Args: 71 | filepath (FilePath): The path to the file to write to. 72 | content (FileContent): The content to write to the file. 73 | 74 | Returns: 75 | ToolResult: A message indicating the success or failure of the operation. 76 | """ 77 | tool = WriteFileTool(default_ui) 78 | try: 79 | return await tool.execute(filepath, content) 80 | except ToolExecutionError as e: 81 | # Return error message for pydantic-ai compatibility 82 | return str(e) 83 | -------------------------------------------------------------------------------- /src/sidekick/types.py: -------------------------------------------------------------------------------- 1 | """ 2 | Centralized type definitions for Sidekick CLI. 3 | 4 | This module contains all type aliases, protocols, and type definitions 5 | used throughout the Sidekick codebase. 6 | """ 7 | 8 | from dataclasses import dataclass 9 | from pathlib import Path 10 | from typing import Any, Awaitable, Callable, Dict, List, Optional, Protocol, Tuple, Union 11 | 12 | # Try to import pydantic-ai types if available 13 | try: 14 | from pydantic_ai import Agent 15 | from pydantic_ai.messages import ModelRequest, ModelResponse, ToolReturnPart 16 | 17 | PydanticAgent = Agent 18 | MessagePart = Union[ToolReturnPart, Any] 19 | except ImportError: 20 | # Fallback if pydantic-ai is not available 21 | PydanticAgent = Any 22 | MessagePart = Any 23 | ModelRequest = Any 24 | ModelResponse = Any 25 | 26 | # ============================================================================= 27 | # Core Types 28 | # ============================================================================= 29 | 30 | # Basic type aliases 31 | UserConfig = Dict[str, Any] 32 | EnvConfig = Dict[str, str] 33 | ModelName = str 34 | ToolName = str 35 | SessionId = str 36 | DeviceId = str 37 | InputSessions = Dict[str, Any] 38 | 39 | # ============================================================================= 40 | # Configuration Types 41 | # ============================================================================= 42 | 43 | 44 | @dataclass 45 | class ModelPricing: 46 | """Pricing information for a model.""" 47 | 48 | input: float 49 | cached_input: float 50 | output: float 51 | 52 | 53 | @dataclass 54 | class ModelConfig: 55 | """Configuration for a model including pricing.""" 56 | 57 | pricing: ModelPricing 58 | 59 | 60 | ModelRegistry = Dict[str, ModelConfig] 61 | 62 | # Path configuration 63 | ConfigPath = Path 64 | ConfigFile = Path 65 | 66 | # ============================================================================= 67 | # Tool Types 68 | # ============================================================================= 69 | 70 | # Tool execution types 71 | ToolArgs = Dict[str, Any] 72 | ToolResult = str 73 | ToolCallback = Callable[[Any, Any], Awaitable[None]] 74 | ToolCallId = str 75 | 76 | 77 | class ToolFunction(Protocol): 78 | """Protocol for tool functions.""" 79 | 80 | async def __call__(self, *args, **kwargs) -> str: ... 81 | 82 | 83 | @dataclass 84 | class ToolConfirmationRequest: 85 | """Request for tool execution confirmation.""" 86 | 87 | tool_name: str 88 | args: Dict[str, Any] 89 | filepath: Optional[str] = None 90 | 91 | 92 | @dataclass 93 | class ToolConfirmationResponse: 94 | """Response from tool confirmation dialog.""" 95 | 96 | approved: bool 97 | skip_future: bool = False 98 | abort: bool = False 99 | 100 | 101 | # ============================================================================= 102 | # UI Types 103 | # ============================================================================= 104 | 105 | 106 | class UILogger(Protocol): 107 | """Protocol for UI logging operations.""" 108 | 109 | async def info(self, message: str) -> None: ... 110 | async def error(self, message: str) -> None: ... 111 | async def warning(self, message: str) -> None: ... 112 | async def debug(self, message: str) -> None: ... 113 | async def success(self, message: str) -> None: ... 114 | 115 | 116 | # UI callback types 117 | UICallback = Callable[[str], Awaitable[None]] 118 | UIInputCallback = Callable[[str, str], Awaitable[str]] 119 | 120 | # ============================================================================= 121 | # Agent Types 122 | # ============================================================================= 123 | 124 | # Agent response types 125 | AgentResponse = Any # Replace with proper pydantic-ai types when available 126 | MessageHistory = List[Any] 127 | AgentRun = Any # pydantic_ai.RunContext or similar 128 | 129 | # Agent configuration 130 | AgentConfig = Dict[str, Any] 131 | AgentName = str 132 | 133 | # ============================================================================= 134 | # Session and State Types 135 | # ============================================================================= 136 | 137 | 138 | @dataclass 139 | class SessionState: 140 | """Complete session state for the application.""" 141 | 142 | user_config: Dict[str, Any] 143 | agents: Dict[str, Any] 144 | messages: List[Any] 145 | total_cost: float 146 | current_model: str 147 | spinner: Optional[Any] 148 | tool_ignore: List[str] 149 | yolo: bool 150 | undo_initialized: bool 151 | session_id: str 152 | device_id: Optional[str] 153 | telemetry_enabled: bool 154 | input_sessions: Dict[str, Any] 155 | current_task: Optional[Any] 156 | 157 | 158 | # Forward reference for StateManager to avoid circular imports 159 | StateManager = Any # Will be replaced with actual StateManager type 160 | 161 | # ============================================================================= 162 | # Command Types 163 | # ============================================================================= 164 | 165 | # Command execution types 166 | CommandArgs = List[str] 167 | CommandResult = Optional[Any] 168 | ProcessRequestCallback = Callable[[str, StateManager, bool], Awaitable[Any]] 169 | 170 | 171 | @dataclass 172 | class CommandContext: 173 | """Context passed to command handlers.""" 174 | 175 | state_manager: StateManager 176 | process_request: Optional[ProcessRequestCallback] = None 177 | 178 | 179 | # ============================================================================= 180 | # Service Types 181 | # ============================================================================= 182 | 183 | # MCP (Model Context Protocol) types 184 | MCPServerConfig = Dict[str, Any] 185 | MCPServers = Dict[str, MCPServerConfig] 186 | 187 | # Telemetry types 188 | TelemetryEvent = Dict[str, Any] 189 | TelemetryData = Dict[str, Any] 190 | 191 | # ============================================================================= 192 | # File Operation Types 193 | # ============================================================================= 194 | 195 | # File-related types 196 | FilePath = Union[str, Path] 197 | FileContent = str 198 | FileEncoding = str 199 | FileDiff = Tuple[str, str] # (original, modified) 200 | FileSize = int 201 | LineNumber = int 202 | 203 | # ============================================================================= 204 | # Error Handling Types 205 | # ============================================================================= 206 | 207 | # Error context types 208 | ErrorContext = Dict[str, Any] 209 | OriginalError = Optional[Exception] 210 | ErrorMessage = str 211 | 212 | # ============================================================================= 213 | # Async Types 214 | # ============================================================================= 215 | 216 | # Async function types 217 | AsyncFunc = Callable[..., Awaitable[Any]] 218 | AsyncToolFunc = Callable[..., Awaitable[str]] 219 | AsyncVoidFunc = Callable[..., Awaitable[None]] 220 | 221 | # ============================================================================= 222 | # Diff and Update Types 223 | # ============================================================================= 224 | 225 | # Types for file updates and diffs 226 | UpdateOperation = Dict[str, Any] 227 | DiffLine = str 228 | DiffHunk = List[DiffLine] 229 | 230 | # ============================================================================= 231 | # Validation Types 232 | # ============================================================================= 233 | 234 | # Input validation types 235 | ValidationResult = Union[bool, str] # True for valid, error message for invalid 236 | Validator = Callable[[Any], ValidationResult] 237 | 238 | # ============================================================================= 239 | # Cost Tracking Types 240 | # ============================================================================= 241 | 242 | # Cost calculation types 243 | TokenCount = int 244 | CostAmount = float 245 | 246 | 247 | @dataclass 248 | class TokenUsage: 249 | """Token usage for a request.""" 250 | 251 | input_tokens: int 252 | cached_tokens: int 253 | output_tokens: int 254 | 255 | 256 | @dataclass 257 | class CostBreakdown: 258 | """Breakdown of costs for a request.""" 259 | 260 | input_cost: float 261 | cached_cost: float 262 | output_cost: float 263 | total_cost: float 264 | -------------------------------------------------------------------------------- /src/sidekick/ui/__init__.py: -------------------------------------------------------------------------------- 1 | # UI package 2 | -------------------------------------------------------------------------------- /src/sidekick/ui/console.py: -------------------------------------------------------------------------------- 1 | """Main console coordination module for Sidekick UI. 2 | 3 | This module re-exports functions from specialized UI modules to maintain 4 | backward compatibility while organizing code into focused modules. 5 | """ 6 | 7 | from rich.console import Console as RichConsole 8 | from rich.markdown import Markdown 9 | 10 | # Import and re-export all functions from specialized modules 11 | from .input import formatted_text, input, multiline_input 12 | from .keybindings import create_key_bindings 13 | from .output import (banner, clear, info, line, muted, print, spinner, success, sync_print, 14 | update_available, usage, version, warning) 15 | from .panels import (agent, dump_messages, error, help, models, panel, sync_panel, 16 | sync_tool_confirm, tool_confirm) 17 | from .prompt_manager import PromptConfig, PromptManager 18 | from .validators import ModelValidator 19 | 20 | # Create console object for backward compatibility 21 | console = RichConsole() 22 | 23 | # Create key bindings object for backward compatibility 24 | kb = create_key_bindings() 25 | 26 | 27 | # Re-export markdown utility for backward compatibility 28 | def markdown(text: str) -> Markdown: 29 | """Create a Markdown object.""" 30 | return Markdown(text) 31 | 32 | 33 | # All functions are now available through imports above 34 | __all__ = [ 35 | # From input module 36 | "formatted_text", 37 | "input", 38 | "multiline_input", 39 | # From keybindings module 40 | "create_key_bindings", 41 | "kb", 42 | # From output module 43 | "banner", 44 | "clear", 45 | "console", 46 | "info", 47 | "line", 48 | "muted", 49 | "print", 50 | "spinner", 51 | "success", 52 | "sync_print", 53 | "update_available", 54 | "usage", 55 | "version", 56 | "warning", 57 | # From panels module 58 | "agent", 59 | "dump_messages", 60 | "error", 61 | "help", 62 | "models", 63 | "panel", 64 | "sync_panel", 65 | "sync_tool_confirm", 66 | "tool_confirm", 67 | # From prompt_manager module 68 | "PromptConfig", 69 | "PromptManager", 70 | # From validators module 71 | "ModelValidator", 72 | # Local utilities 73 | "markdown", 74 | ] 75 | -------------------------------------------------------------------------------- /src/sidekick/ui/constants.py: -------------------------------------------------------------------------------- 1 | """UI-specific constants for Sidekick.""" 2 | 3 | # UI Layout Constants 4 | DEFAULT_PANEL_PADDING = {"top": 1, "right": 0, "bottom": 1, "left": 1} 5 | 6 | # Spinner Configuration 7 | SPINNER_TYPE = "star2" # Current spinner icon used in output.py 8 | SPINNER_STYLE = "medium_purple1" # Uses UI_COLORS["primary"] from main constants 9 | 10 | # Input Configuration 11 | DEFAULT_PROMPT = "> " 12 | MULTILINE_PROMPT = "... " 13 | MAX_HISTORY_SIZE = 1000 14 | 15 | # Display Limits 16 | MAX_DISPLAY_LENGTH = 50 # For truncating in UI 17 | -------------------------------------------------------------------------------- /src/sidekick/ui/decorators.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module: sidekick.ui.decorators 3 | 4 | Provides decorators for UI functions including sync/async wrapper patterns. 5 | """ 6 | 7 | import asyncio 8 | from functools import wraps 9 | from typing import Any, Callable, TypeVar 10 | 11 | F = TypeVar("F", bound=Callable[..., Any]) 12 | 13 | 14 | def create_sync_wrapper(async_func: F) -> F: 15 | """Create a synchronous wrapper for an async function. 16 | 17 | This decorator does NOT modify the original async function. 18 | Instead, it attaches a sync version as a 'sync' attribute. 19 | 20 | Args: 21 | async_func: The async function to wrap 22 | 23 | Returns: 24 | The original async function with sync version attached 25 | """ 26 | 27 | @wraps(async_func) 28 | def sync_wrapper(*args, **kwargs): 29 | try: 30 | loop = asyncio.get_event_loop() 31 | if loop.is_running(): 32 | # If we're already in an async context, we can't use run_until_complete 33 | # This might happen when called from within an async function 34 | raise RuntimeError( 35 | f"Cannot call sync_{async_func.__name__} from within an async context. " 36 | f"Use await {async_func.__name__}() instead." 37 | ) 38 | except RuntimeError: 39 | # No event loop exists, create one 40 | loop = asyncio.new_event_loop() 41 | asyncio.set_event_loop(loop) 42 | 43 | return loop.run_until_complete(async_func(*args, **kwargs)) 44 | 45 | # Set a naming convention 46 | sync_wrapper.__name__ = f"sync_{async_func.__name__}" 47 | sync_wrapper.__qualname__ = f"sync_{async_func.__qualname__}" 48 | 49 | # Update docstring to indicate this is a sync version 50 | if async_func.__doc__: 51 | sync_wrapper.__doc__ = ( 52 | f"Synchronous version of {async_func.__name__}.\n\n{async_func.__doc__}" 53 | ) 54 | 55 | # Attach the sync version as an attribute 56 | async_func.sync = sync_wrapper 57 | 58 | # Return the original async function 59 | return async_func 60 | -------------------------------------------------------------------------------- /src/sidekick/ui/input.py: -------------------------------------------------------------------------------- 1 | """User input handling functions for Sidekick UI.""" 2 | 3 | from typing import Optional 4 | 5 | from prompt_toolkit.formatted_text import HTML 6 | from prompt_toolkit.key_binding import KeyBindings 7 | from prompt_toolkit.validation import Validator 8 | 9 | from sidekick.constants import UI_PROMPT_PREFIX 10 | from sidekick.core.state import StateManager 11 | 12 | from .keybindings import create_key_bindings 13 | from .prompt_manager import PromptConfig, PromptManager 14 | 15 | 16 | def formatted_text(text: str) -> HTML: 17 | """Create formatted HTML text.""" 18 | return HTML(text) 19 | 20 | 21 | async def input( 22 | session_key: str, 23 | pretext: str = UI_PROMPT_PREFIX, 24 | is_password: bool = False, 25 | validator: Optional[Validator] = None, 26 | multiline: bool = False, 27 | key_bindings: Optional[KeyBindings] = None, 28 | placeholder: Optional[HTML] = None, 29 | timeoutlen: float = 0.05, 30 | state_manager: Optional[StateManager] = None, 31 | ) -> str: 32 | """ 33 | Prompt for user input using simplified prompt management. 34 | 35 | Args: 36 | session_key: The session key for the prompt 37 | pretext: The text to display before the input prompt 38 | is_password: Whether to mask the input 39 | validator: Optional input validator 40 | multiline: Whether to allow multiline input 41 | key_bindings: Optional custom key bindings 42 | placeholder: Optional placeholder text 43 | timeoutlen: Timeout length for input 44 | state_manager: The state manager for session storage 45 | 46 | Returns: 47 | User input string 48 | """ 49 | # Create prompt configuration 50 | config = PromptConfig( 51 | multiline=multiline, 52 | is_password=is_password, 53 | validator=validator, 54 | key_bindings=key_bindings, 55 | placeholder=placeholder, 56 | timeoutlen=timeoutlen, 57 | ) 58 | 59 | # Create prompt manager 60 | manager = PromptManager(state_manager) 61 | 62 | # Get user input 63 | return await manager.get_input(session_key, pretext, config) 64 | 65 | 66 | async def multiline_input() -> str: 67 | """Get multiline input from the user.""" 68 | kb = create_key_bindings() 69 | placeholder = formatted_text( 70 | ( 71 | "" 72 | "Enter to submit, " 73 | "Esc + Enter for new line, " 74 | "/help for commands" 75 | "" 76 | ) 77 | ) 78 | return await input("multiline", key_bindings=kb, multiline=True, placeholder=placeholder) 79 | -------------------------------------------------------------------------------- /src/sidekick/ui/keybindings.py: -------------------------------------------------------------------------------- 1 | """Key binding handlers for Sidekick UI.""" 2 | 3 | from prompt_toolkit.key_binding import KeyBindings 4 | 5 | 6 | def create_key_bindings() -> KeyBindings: 7 | """Create and configure key bindings for the UI.""" 8 | kb = KeyBindings() 9 | 10 | @kb.add("escape", eager=True) 11 | def _cancel(event): 12 | """Kill the running agent task, if any.""" 13 | # Key bindings can't easily access state_manager, so we'll handle this differently 14 | # This will be handled in the REPL where state is available 15 | if ( 16 | hasattr(event.app, "current_task") 17 | and event.app.current_task 18 | and not event.app.current_task.done() 19 | ): 20 | event.app.current_task.cancel() 21 | event.app.invalidate() 22 | 23 | @kb.add("enter") 24 | def _submit(event): 25 | """Submit the current buffer.""" 26 | event.current_buffer.validate_and_handle() 27 | 28 | @kb.add("c-o") # ctrl+o 29 | def _newline(event): 30 | """Insert a newline character.""" 31 | event.current_buffer.insert_text("\n") 32 | 33 | return kb 34 | -------------------------------------------------------------------------------- /src/sidekick/ui/output.py: -------------------------------------------------------------------------------- 1 | """Output and display functions for Sidekick UI.""" 2 | 3 | from prompt_toolkit.application import run_in_terminal 4 | from rich.console import Console 5 | from rich.padding import Padding 6 | 7 | from sidekick.configuration.settings import ApplicationSettings 8 | from sidekick.constants import (MSG_UPDATE_AVAILABLE, MSG_UPDATE_INSTRUCTION, MSG_VERSION_DISPLAY, 9 | UI_COLORS, UI_THINKING_MESSAGE) 10 | from sidekick.core.state import StateManager 11 | from sidekick.utils.file_utils import DotDict 12 | 13 | from .constants import SPINNER_TYPE 14 | from .decorators import create_sync_wrapper 15 | 16 | console = Console() 17 | colors = DotDict(UI_COLORS) 18 | 19 | BANNER = """\ 20 | ███████╗██╗██████╗ ███████╗██╗ ██╗██╗ ██████╗██╗ ██╗ 21 | ██╔════╝██║██╔══██╗██╔════╝██║ ██╔╝██║██╔════╝██║ ██╔╝ 22 | ███████╗██║██║ ██║█████╗ █████╔╝ ██║██║ █████╔╝ 23 | ╚════██║██║██║ ██║██╔══╝ ██╔═██╗ ██║██║ ██╔═██╗ 24 | ███████║██║██████╔╝███████╗██║ ██╗██║╚██████╗██║ ██╗ 25 | ╚══════╝╚═╝╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═════╝╚═╝ ╚═╝""" 26 | 27 | 28 | @create_sync_wrapper 29 | async def print(message, **kwargs) -> None: 30 | """Print a message to the console.""" 31 | await run_in_terminal(lambda: console.print(message, **kwargs)) 32 | 33 | 34 | async def line() -> None: 35 | """Print a line to the console.""" 36 | await run_in_terminal(lambda: console.line()) 37 | 38 | 39 | async def info(text: str) -> None: 40 | """Print an informational message.""" 41 | await print(f"• {text}", style=colors.primary) 42 | 43 | 44 | async def success(message: str) -> None: 45 | """Print a success message.""" 46 | await print(f"• {message}", style=colors.success) 47 | 48 | 49 | async def warning(text: str) -> None: 50 | """Print a warning message.""" 51 | await print(f"• {text}", style=colors.warning) 52 | 53 | 54 | async def muted(text: str, spaces: int = 0) -> None: 55 | """Print a muted message.""" 56 | await print(f"{' ' * spaces}• {text}", style=colors.muted) 57 | 58 | 59 | async def usage(usage: str) -> None: 60 | """Print usage information.""" 61 | await print(Padding(usage, (0, 0, 1, 2)), style=colors.muted) 62 | 63 | 64 | async def version() -> None: 65 | """Print version information.""" 66 | app_settings = ApplicationSettings() 67 | await info(MSG_VERSION_DISPLAY.format(version=app_settings.version)) 68 | 69 | 70 | async def banner() -> None: 71 | """Display the application banner.""" 72 | console.clear() 73 | banner_padding = Padding(BANNER, (1, 0, 0, 2)) 74 | app_settings = ApplicationSettings() 75 | version_padding = Padding(f"v{app_settings.version}", (0, 0, 1, 2)) 76 | await print(banner_padding, style=colors.primary) 77 | await print(version_padding, style=colors.muted) 78 | 79 | 80 | async def clear() -> None: 81 | """Clear the console and display the banner.""" 82 | console.clear() 83 | await banner() 84 | 85 | 86 | async def update_available(latest_version: str) -> None: 87 | """Display update available notification.""" 88 | await warning(MSG_UPDATE_AVAILABLE.format(latest_version=latest_version)) 89 | await muted(MSG_UPDATE_INSTRUCTION) 90 | 91 | 92 | async def spinner(show: bool = True, spinner_obj=None, state_manager: StateManager = None): 93 | """Manage a spinner display.""" 94 | icon = SPINNER_TYPE 95 | message = UI_THINKING_MESSAGE 96 | 97 | # Get spinner from state manager if available 98 | if spinner_obj is None and state_manager: 99 | spinner_obj = state_manager.session.spinner 100 | 101 | if not spinner_obj: 102 | spinner_obj = await run_in_terminal(lambda: console.status(message, spinner=icon)) 103 | # Store it back in state manager if available 104 | if state_manager: 105 | state_manager.session.spinner = spinner_obj 106 | 107 | if show: 108 | spinner_obj.start() 109 | else: 110 | spinner_obj.stop() 111 | 112 | return spinner_obj 113 | 114 | 115 | # Auto-generated sync version 116 | sync_print = print.sync # type: ignore 117 | -------------------------------------------------------------------------------- /src/sidekick/ui/panels.py: -------------------------------------------------------------------------------- 1 | """Panel display functions for Sidekick UI.""" 2 | 3 | from typing import Any, Optional, Union 4 | 5 | from rich.markdown import Markdown 6 | from rich.padding import Padding 7 | from rich.panel import Panel 8 | from rich.pretty import Pretty 9 | from rich.table import Table 10 | 11 | from sidekick.configuration.models import ModelRegistry 12 | from sidekick.constants import (APP_NAME, CMD_CLEAR, CMD_COMPACT, CMD_DUMP, CMD_EXIT, CMD_HELP, 13 | CMD_MODEL, CMD_UNDO, CMD_YOLO, DESC_CLEAR, DESC_COMPACT, DESC_DUMP, 14 | DESC_EXIT, DESC_HELP, DESC_MODEL, DESC_MODEL_DEFAULT, 15 | DESC_MODEL_SWITCH, DESC_UNDO, DESC_YOLO, PANEL_AVAILABLE_COMMANDS, 16 | PANEL_ERROR, PANEL_MESSAGE_HISTORY, PANEL_MODELS, UI_COLORS) 17 | from sidekick.core.state import StateManager 18 | from sidekick.utils.file_utils import DotDict 19 | 20 | from .constants import DEFAULT_PANEL_PADDING 21 | from .decorators import create_sync_wrapper 22 | from .output import print 23 | 24 | colors = DotDict(UI_COLORS) 25 | 26 | 27 | @create_sync_wrapper 28 | async def panel( 29 | title: str, 30 | text: Union[str, Markdown, Pretty], 31 | top: int = DEFAULT_PANEL_PADDING["top"], 32 | right: int = DEFAULT_PANEL_PADDING["right"], 33 | bottom: int = DEFAULT_PANEL_PADDING["bottom"], 34 | left: int = DEFAULT_PANEL_PADDING["left"], 35 | border_style: Optional[str] = None, 36 | **kwargs: Any, 37 | ) -> None: 38 | """Display a rich panel.""" 39 | border_style = border_style or kwargs.get("style") 40 | panel_obj = Panel(Padding(text, 1), title=title, title_align="left", border_style=border_style) 41 | await print(Padding(panel_obj, (top, right, bottom, left)), **kwargs) 42 | 43 | 44 | async def agent(text: str, bottom: int = 1) -> None: 45 | """Display an agent panel.""" 46 | await panel(APP_NAME, Markdown(text), bottom=bottom, border_style=colors.primary) 47 | 48 | 49 | async def error(text: str) -> None: 50 | """Display an error panel.""" 51 | await panel(PANEL_ERROR, text, style=colors.error) 52 | 53 | 54 | async def dump_messages(messages_list=None, state_manager: StateManager = None) -> None: 55 | """Display message history panel.""" 56 | if messages_list is None and state_manager: 57 | # Get messages from state manager 58 | messages = Pretty(state_manager.session.messages) 59 | elif messages_list is not None: 60 | messages = Pretty(messages_list) 61 | else: 62 | # No messages available 63 | messages = Pretty([]) 64 | await panel(PANEL_MESSAGE_HISTORY, messages, style=colors.muted) 65 | 66 | 67 | async def models(state_manager: StateManager = None) -> None: 68 | """Display available models panel.""" 69 | model_registry = ModelRegistry() 70 | model_ids = list(model_registry.list_models().keys()) 71 | model_list = "\n".join([f"{index} - {model}" for index, model in enumerate(model_ids)]) 72 | current_model = state_manager.session.current_model if state_manager else "unknown" 73 | text = f"Current model: {current_model}\n\n{model_list}" 74 | await panel(PANEL_MODELS, text, border_style=colors.muted) 75 | 76 | 77 | async def help(command_registry=None) -> None: 78 | """Display the available commands organized by category.""" 79 | table = Table(show_header=False, box=None, padding=(0, 2, 0, 0)) 80 | table.add_column("Command", style="white", justify="right") 81 | table.add_column("Description", style="white") 82 | 83 | if command_registry: 84 | # Use the new command registry to display commands by category 85 | from ..cli.commands import CommandCategory 86 | 87 | category_order = [ 88 | CommandCategory.SYSTEM, 89 | CommandCategory.NAVIGATION, 90 | CommandCategory.DEVELOPMENT, 91 | CommandCategory.MODEL, 92 | CommandCategory.DEBUG, 93 | ] 94 | 95 | for category in category_order: 96 | commands = command_registry.get_commands_by_category(category) 97 | if commands: 98 | # Add category header 99 | table.add_row("", "") 100 | table.add_row(f"[bold]{category.value.title()}[/bold]", "") 101 | 102 | # Add commands in this category 103 | for command in commands: 104 | # Show primary command name 105 | cmd_display = f"/{command.name}" 106 | table.add_row(cmd_display, command.description) 107 | 108 | # Special handling for model command variations 109 | if command.name == "model": 110 | table.add_row(f"{cmd_display} ", DESC_MODEL_SWITCH) 111 | table.add_row(f"{cmd_display} default", DESC_MODEL_DEFAULT) 112 | 113 | # Add built-in commands 114 | table.add_row("", "") 115 | table.add_row("[bold]Built-in[/bold]", "") 116 | table.add_row(CMD_EXIT, DESC_EXIT) 117 | else: 118 | # Fallback to static command list 119 | commands = [ 120 | (CMD_HELP, DESC_HELP), 121 | (CMD_CLEAR, DESC_CLEAR), 122 | (CMD_DUMP, DESC_DUMP), 123 | (CMD_YOLO, DESC_YOLO), 124 | (CMD_UNDO, DESC_UNDO), 125 | (CMD_COMPACT, DESC_COMPACT), 126 | (CMD_MODEL, DESC_MODEL), 127 | (f"{CMD_MODEL} ", DESC_MODEL_SWITCH), 128 | (f"{CMD_MODEL} default", DESC_MODEL_DEFAULT), 129 | (CMD_EXIT, DESC_EXIT), 130 | ] 131 | 132 | for cmd, desc in commands: 133 | table.add_row(cmd, desc) 134 | 135 | await panel(PANEL_AVAILABLE_COMMANDS, table, border_style=colors.muted) 136 | 137 | 138 | @create_sync_wrapper 139 | async def tool_confirm( 140 | title: str, content: Union[str, Markdown], filepath: Optional[str] = None 141 | ) -> None: 142 | """Display a tool confirmation panel.""" 143 | bottom_padding = 0 if filepath else 1 144 | await panel(title, content, bottom=bottom_padding, border_style=colors.warning) 145 | 146 | 147 | # Auto-generated sync versions 148 | sync_panel = panel.sync # type: ignore 149 | sync_tool_confirm = tool_confirm.sync # type: ignore 150 | -------------------------------------------------------------------------------- /src/sidekick/ui/prompt_manager.py: -------------------------------------------------------------------------------- 1 | """Prompt configuration and management for Sidekick UI.""" 2 | 3 | from dataclasses import dataclass 4 | from typing import Optional 5 | 6 | from prompt_toolkit.formatted_text import FormattedText 7 | from prompt_toolkit.key_binding import KeyBindings 8 | from prompt_toolkit.shortcuts import PromptSession 9 | from prompt_toolkit.validation import Validator 10 | 11 | from sidekick.core.state import StateManager 12 | from sidekick.exceptions import UserAbortError 13 | 14 | 15 | @dataclass 16 | class PromptConfig: 17 | """Configuration for prompt sessions.""" 18 | 19 | multiline: bool = False 20 | is_password: bool = False 21 | validator: Optional[Validator] = None 22 | key_bindings: Optional[KeyBindings] = None 23 | placeholder: Optional[FormattedText] = None 24 | timeoutlen: float = 0.05 25 | 26 | 27 | class PromptManager: 28 | """Manages prompt sessions and their lifecycle.""" 29 | 30 | def __init__(self, state_manager: Optional[StateManager] = None): 31 | """Initialize the prompt manager. 32 | 33 | Args: 34 | state_manager: Optional state manager for session persistence 35 | """ 36 | self.state_manager = state_manager 37 | self._temp_sessions = {} # For when no state manager is available 38 | 39 | def get_session(self, session_key: str, config: PromptConfig) -> PromptSession: 40 | """Get or create a prompt session. 41 | 42 | Args: 43 | session_key: Unique key for the session 44 | config: Configuration for the session 45 | 46 | Returns: 47 | PromptSession instance 48 | """ 49 | if self.state_manager: 50 | # Use state manager's session storage 51 | if session_key not in self.state_manager.session.input_sessions: 52 | self.state_manager.session.input_sessions[session_key] = PromptSession( 53 | key_bindings=config.key_bindings, 54 | placeholder=config.placeholder, 55 | ) 56 | return self.state_manager.session.input_sessions[session_key] 57 | else: 58 | # Use temporary storage 59 | if session_key not in self._temp_sessions: 60 | self._temp_sessions[session_key] = PromptSession( 61 | key_bindings=config.key_bindings, 62 | placeholder=config.placeholder, 63 | ) 64 | return self._temp_sessions[session_key] 65 | 66 | async def get_input(self, session_key: str, prompt: str, config: PromptConfig) -> str: 67 | """Get user input using the specified configuration. 68 | 69 | Args: 70 | session_key: Unique key for the session 71 | prompt: The prompt text to display 72 | config: Configuration for the input 73 | 74 | Returns: 75 | User input string 76 | 77 | Raises: 78 | UserAbortError: If user cancels input 79 | """ 80 | session = self.get_session(session_key, config) 81 | 82 | try: 83 | # Get user input 84 | response = await session.prompt_async( 85 | prompt, 86 | is_password=config.is_password, 87 | validator=config.validator, 88 | multiline=config.multiline, 89 | ) 90 | 91 | # Clean up response 92 | if isinstance(response, str): 93 | response = response.strip() 94 | 95 | return response 96 | 97 | except (KeyboardInterrupt, EOFError): 98 | raise UserAbortError 99 | -------------------------------------------------------------------------------- /src/sidekick/ui/tool_ui.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tool confirmation UI components, separated from business logic. 3 | """ 4 | 5 | from rich.markdown import Markdown 6 | from rich.padding import Padding 7 | from rich.panel import Panel 8 | 9 | from sidekick.configuration.settings import ApplicationSettings 10 | from sidekick.constants import APP_NAME, TOOL_UPDATE_FILE, TOOL_WRITE_FILE, UI_COLORS 11 | from sidekick.core.tool_handler import ToolConfirmationRequest, ToolConfirmationResponse 12 | from sidekick.types import ToolArgs 13 | from sidekick.ui import console as ui 14 | from sidekick.utils.diff_utils import render_file_diff 15 | from sidekick.utils.file_utils import DotDict 16 | from sidekick.utils.text_utils import ext_to_lang, key_to_title 17 | 18 | 19 | class ToolUI: 20 | """Handles tool confirmation UI presentation.""" 21 | 22 | def __init__(self): 23 | self.colors = DotDict(UI_COLORS) 24 | 25 | def _get_tool_title(self, tool_name: str) -> str: 26 | """ 27 | Get the display title for a tool. 28 | 29 | Args: 30 | tool_name: Name of the tool. 31 | 32 | Returns: 33 | str: Display title. 34 | """ 35 | app_settings = ApplicationSettings() 36 | if tool_name in app_settings.internal_tools: 37 | return f"Tool({tool_name})" 38 | else: 39 | return f"MCP({tool_name})" 40 | 41 | def _create_code_block(self, filepath: str, content: str) -> Markdown: 42 | """ 43 | Create a code block for the given file path and content. 44 | 45 | Args: 46 | filepath: The path to the file. 47 | content: The content of the file. 48 | 49 | Returns: 50 | Markdown: A Markdown object representing the code block. 51 | """ 52 | lang = ext_to_lang(filepath) 53 | code_block = f"```{lang}\n{content}\n```" 54 | return ui.markdown(code_block) 55 | 56 | def _render_args(self, tool_name: str, args: ToolArgs) -> str: 57 | """ 58 | Render the tool arguments for display. 59 | 60 | Args: 61 | tool_name: Name of the tool. 62 | args: Tool arguments. 63 | 64 | Returns: 65 | str: Formatted arguments for display. 66 | """ 67 | # Show diff between `target` and `patch` on file updates 68 | if tool_name == TOOL_UPDATE_FILE: 69 | return render_file_diff(args["target"], args["patch"], self.colors) 70 | 71 | # Show file content on write_file 72 | elif tool_name == TOOL_WRITE_FILE: 73 | return self._create_code_block(args["filepath"], args["content"]) 74 | 75 | # Default to showing key and value on new line 76 | content = "" 77 | for key, value in args.items(): 78 | if isinstance(value, list): 79 | content += f"{key_to_title(key)}:\n" 80 | for item in value: 81 | content += f" - {item}\n" 82 | content += "\n" 83 | else: 84 | # If string length is over 200 characters, split to new line 85 | value = str(value) 86 | content += f"{key_to_title(key)}:" 87 | if len(value) > 200: 88 | content += f"\n{value}\n\n" 89 | else: 90 | content += f" {value}\n\n" 91 | return content.strip() 92 | 93 | async def show_confirmation( 94 | self, request: ToolConfirmationRequest, state_manager=None 95 | ) -> ToolConfirmationResponse: 96 | """ 97 | Show tool confirmation UI and get user response. 98 | 99 | Args: 100 | request: The confirmation request. 101 | 102 | Returns: 103 | ToolConfirmationResponse: User's response to the confirmation. 104 | """ 105 | title = self._get_tool_title(request.tool_name) 106 | content = self._render_args(request.tool_name, request.args) 107 | 108 | await ui.tool_confirm(title, content, filepath=request.filepath) 109 | 110 | # If tool call has filepath, show it under panel 111 | if request.filepath: 112 | await ui.usage(f"File: {request.filepath}") 113 | 114 | await ui.print(" 1. Yes (default)") 115 | await ui.print(" 2. Yes, and don't ask again for commands like this") 116 | await ui.print(f" 3. No, and tell {APP_NAME} what to do differently") 117 | resp = ( 118 | await ui.input( 119 | session_key="tool_confirm", 120 | pretext=" Choose an option [1/2/3]: ", 121 | state_manager=state_manager, 122 | ) 123 | or "1" 124 | ) 125 | 126 | if resp == "2": 127 | return ToolConfirmationResponse(approved=True, skip_future=True) 128 | elif resp == "3": 129 | return ToolConfirmationResponse(approved=False, abort=True) 130 | else: 131 | return ToolConfirmationResponse(approved=True) 132 | 133 | def show_sync_confirmation(self, request: ToolConfirmationRequest) -> ToolConfirmationResponse: 134 | """ 135 | Show tool confirmation UI synchronously and get user response. 136 | 137 | Args: 138 | request: The confirmation request. 139 | 140 | Returns: 141 | ToolConfirmationResponse: User's response to the confirmation. 142 | """ 143 | title = self._get_tool_title(request.tool_name) 144 | content = self._render_args(request.tool_name, request.args) 145 | 146 | # Display styled confirmation panel using direct console output 147 | # Avoid using sync wrappers that might create event loop conflicts 148 | panel_obj = Panel( 149 | Padding(content, 1), title=title, title_align="left", border_style=self.colors.warning 150 | ) 151 | # Add consistent spacing above panels 152 | ui.console.print(Padding(panel_obj, (1, 0, 0, 0))) 153 | 154 | if request.filepath: 155 | ui.console.print(f"File: {request.filepath}", style=self.colors.muted) 156 | 157 | ui.console.print(" 1. Yes (default)") 158 | ui.console.print(" 2. Yes, and don't ask again for commands like this") 159 | ui.console.print(f" 3. No, and tell {APP_NAME} what to do differently") 160 | resp = input(" Choose an option [1/2/3]: ").strip() or "1" 161 | 162 | # Add spacing after user choice for better readability 163 | print() 164 | 165 | if resp == "2": 166 | return ToolConfirmationResponse(approved=True, skip_future=True) 167 | elif resp == "3": 168 | return ToolConfirmationResponse(approved=False, abort=True) 169 | else: 170 | return ToolConfirmationResponse(approved=True) 171 | 172 | async def log_mcp(self, title: str, args: ToolArgs) -> None: 173 | """ 174 | Display MCP tool with its arguments. 175 | 176 | Args: 177 | title: Title to display. 178 | args: Arguments to display. 179 | """ 180 | if not args: 181 | return 182 | 183 | await ui.info(title) 184 | for key, value in args.items(): 185 | if isinstance(value, list): 186 | value = ", ".join(value) 187 | await ui.muted(f"{key}: {value}", spaces=4) 188 | -------------------------------------------------------------------------------- /src/sidekick/ui/validators.py: -------------------------------------------------------------------------------- 1 | """Input validators for Sidekick UI.""" 2 | 3 | from prompt_toolkit.validation import ValidationError, Validator 4 | 5 | 6 | class ModelValidator(Validator): 7 | """Validate default provider selection""" 8 | 9 | def __init__(self, index): 10 | self.index = index 11 | 12 | def validate(self, document) -> None: 13 | text = document.text.strip() 14 | if not text: 15 | raise ValidationError(message="Provider number cannot be empty") 16 | elif text and not text.isdigit(): 17 | raise ValidationError(message="Invalid provider number") 18 | elif text.isdigit(): 19 | number = int(text) 20 | if number < 0 or number >= self.index: 21 | raise ValidationError( 22 | message="Invalid provider number", 23 | ) 24 | -------------------------------------------------------------------------------- /src/sidekick/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geekforbrains/sidekick-cli/2176d4056b3f31f4a2f6fc922cb9c981cd2a1b6f/src/sidekick/utils/__init__.py -------------------------------------------------------------------------------- /src/sidekick/utils/diff_utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module: sidekick.utils.diff_utils 3 | 4 | Provides diff visualization utilities for file changes. 5 | Generates styled text diffs between original and modified content using the difflib library. 6 | """ 7 | 8 | import difflib 9 | 10 | from rich.text import Text 11 | 12 | 13 | def render_file_diff(target: str, patch: str, colors=None) -> Text: 14 | """ 15 | Create a formatted diff between target and patch text. 16 | 17 | Args: 18 | target (str): The original text to be replaced. 19 | patch (str): The new text to insert. 20 | colors (dict, optional): Dictionary containing style colors. 21 | If None, no styling will be applied. 22 | 23 | Returns: 24 | Text: A Rich Text object containing the formatted diff. 25 | """ 26 | # Create a clean diff with styled text 27 | diff_text = Text() 28 | 29 | # Get lines and create a diff sequence 30 | target_lines = target.splitlines() 31 | patch_lines = patch.splitlines() 32 | 33 | # Use difflib to identify changes 34 | matcher = difflib.SequenceMatcher(None, target_lines, patch_lines) 35 | 36 | for op, i1, i2, j1, j2 in matcher.get_opcodes(): 37 | if op == "equal": 38 | # Unchanged lines 39 | for line in target_lines[i1:i2]: 40 | diff_text.append(f" {line}\n") 41 | elif op == "delete": 42 | # Removed lines - show in red with (-) prefix 43 | for line in target_lines[i1:i2]: 44 | if colors: 45 | diff_text.append(f"- {line}\n", style=colors.error) 46 | else: 47 | diff_text.append(f"- {line}\n") 48 | elif op == "insert": 49 | # Added lines - show in green with (+) prefix 50 | for line in patch_lines[j1:j2]: 51 | if colors: 52 | diff_text.append(f"+ {line}\n", style=colors.success) 53 | else: 54 | diff_text.append(f"+ {line}\n") 55 | elif op == "replace": 56 | # Removed lines with (-) prefix 57 | for line in target_lines[i1:i2]: 58 | if colors: 59 | diff_text.append(f"- {line}\n", style=colors.error) 60 | else: 61 | diff_text.append(f"- {line}\n") 62 | # Added lines with (+) prefix 63 | for line in patch_lines[j1:j2]: 64 | if colors: 65 | diff_text.append(f"+ {line}\n", style=colors.success) 66 | else: 67 | diff_text.append(f"+ {line}\n") 68 | 69 | return diff_text 70 | -------------------------------------------------------------------------------- /src/sidekick/utils/file_utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module: sidekick.utils.file_utils 3 | 4 | Provides file system utilities and helper classes. 5 | Includes DotDict for dot notation access and stdout capture functionality. 6 | """ 7 | 8 | import io 9 | import sys 10 | from contextlib import contextmanager 11 | 12 | 13 | class DotDict(dict): 14 | """dot.notation access to dictionary attributes""" 15 | 16 | __getattr__ = dict.get 17 | __setattr__ = dict.__setitem__ 18 | __delattr__ = dict.__delitem__ 19 | 20 | 21 | @contextmanager 22 | def capture_stdout(): 23 | """ 24 | Context manager to capture stdout output. 25 | 26 | Example: 27 | with capture_stdout() as stdout_capture: 28 | print("This will be captured") 29 | 30 | captured_output = stdout_capture.getvalue() 31 | 32 | Returns: 33 | StringIO object containing the captured output 34 | """ 35 | stdout_capture = io.StringIO() 36 | original_stdout = sys.stdout 37 | sys.stdout = stdout_capture 38 | try: 39 | yield stdout_capture 40 | finally: 41 | sys.stdout = original_stdout 42 | -------------------------------------------------------------------------------- /src/sidekick/utils/system.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module: sidekick.utils.system 3 | 4 | Provides system information and directory management utilities. 5 | Handles session management, device identification, file listing 6 | with gitignore support, and update checking. 7 | """ 8 | 9 | import fnmatch 10 | import os 11 | import subprocess 12 | import uuid 13 | from pathlib import Path 14 | 15 | from ..configuration.settings import ApplicationSettings 16 | from ..constants import DEVICE_ID_FILE, ENV_FILE, SESSIONS_SUBDIR, SIDEKICK_HOME_DIR 17 | 18 | # Default ignore patterns if .gitignore is not found 19 | DEFAULT_IGNORE_PATTERNS = { 20 | "node_modules/", 21 | "env/", 22 | "venv/", 23 | ".git/", 24 | "build/", 25 | "dist/", 26 | "__pycache__/", 27 | "*.pyc", 28 | "*.pyo", 29 | "*.pyd", 30 | ".DS_Store", 31 | "Thumbs.db", 32 | ENV_FILE, 33 | ".venv", 34 | "*.egg-info", 35 | ".pytest_cache/", 36 | ".coverage", 37 | "htmlcov/", 38 | ".tox/", 39 | "coverage.xml", 40 | "*.cover", 41 | ".idea/", 42 | ".vscode/", 43 | "*.swp", 44 | "*.swo", 45 | } 46 | 47 | 48 | def get_sidekick_home(): 49 | """ 50 | Get the path to the Sidekick home directory (~/.sidekick). 51 | Creates it if it doesn't exist. 52 | 53 | Returns: 54 | Path: The path to the Sidekick home directory. 55 | """ 56 | home = Path.home() / SIDEKICK_HOME_DIR 57 | home.mkdir(exist_ok=True) 58 | return home 59 | 60 | 61 | def get_session_dir(state_manager): 62 | """ 63 | Get the path to the current session directory. 64 | 65 | Args: 66 | state_manager: The StateManager instance containing session info. 67 | 68 | Returns: 69 | Path: The path to the current session directory. 70 | """ 71 | session_dir = get_sidekick_home() / SESSIONS_SUBDIR / state_manager.session.session_id 72 | session_dir.mkdir(exist_ok=True, parents=True) 73 | return session_dir 74 | 75 | 76 | def _load_gitignore_patterns(filepath=".gitignore"): 77 | """Loads patterns from a .gitignore file.""" 78 | patterns = set() 79 | try: 80 | # Use io.open for potentially better encoding handling, though default utf-8 is usually fine 81 | import io 82 | 83 | with io.open(filepath, "r", encoding="utf-8") as f: 84 | for line in f: 85 | line = line.strip() 86 | if line and not line.startswith("#"): 87 | patterns.add(line) 88 | # print(f"Loaded {len(patterns)} patterns from {filepath}") # Debug print (optional) 89 | except FileNotFoundError: 90 | # print(f"{filepath} not found.") # Debug print (optional) 91 | return None 92 | except Exception as e: 93 | print(f"Error reading {filepath}: {e}") 94 | return None 95 | # Always ignore .git directory contents explicitly 96 | patterns.add(".git/") 97 | return patterns 98 | 99 | 100 | def _is_ignored(rel_path, name, patterns): 101 | """ 102 | Checks if a given relative path or name matches any ignore patterns. 103 | Mimics basic .gitignore behavior using fnmatch. 104 | """ 105 | if not patterns: 106 | return False 107 | 108 | # Ensure '.git' is always ignored 109 | # Check both name and if the path starts with .git/ 110 | if name == ".git" or rel_path.startswith(".git/") or "/.git/" in rel_path: 111 | return True 112 | 113 | path_parts = rel_path.split(os.sep) 114 | 115 | for pattern in patterns: 116 | # Normalize pattern: remove trailing slash for matching, but keep track if it was there 117 | is_dir_pattern = pattern.endswith("/") 118 | match_pattern = pattern.rstrip("/") if is_dir_pattern else pattern 119 | 120 | # Remove leading slash for root-relative patterns matching logic 121 | if match_pattern.startswith("/"): 122 | match_pattern = match_pattern.lstrip("/") 123 | # Root relative: Match only if rel_path starts with pattern 124 | if fnmatch.fnmatch(rel_path, match_pattern) or fnmatch.fnmatch( 125 | rel_path, match_pattern + "/*" 126 | ): 127 | # If it was a dir pattern, ensure we are matching a dir or content within it 128 | if is_dir_pattern: 129 | # Check if rel_path is exactly the dir or starts with the dir path + '/' 130 | if rel_path == match_pattern or rel_path.startswith(match_pattern + os.sep): 131 | return True 132 | else: # File pattern, direct match is enough 133 | return True 134 | # If root-relative, don't check further down the path parts 135 | continue 136 | 137 | # --- Non-root-relative patterns --- 138 | 139 | # Check direct filename match (e.g., '*.log', 'config.ini') 140 | if fnmatch.fnmatch(name, match_pattern): 141 | # If it's a directory pattern, ensure the match corresponds to a directory segment 142 | if is_dir_pattern: 143 | # This check happens during directory pruning in get_cwd_files primarily. 144 | # If checking a file path like 'a/b/file.txt' against 'b/', need path checks. 145 | pass # Let path checks below handle dir content matching 146 | else: 147 | # If it's a file pattern matching the name, it's ignored. 148 | return True 149 | 150 | # Check full relative path match (e.g., 'src/*.py', 'docs/specific.txt') 151 | if fnmatch.fnmatch(rel_path, match_pattern): 152 | return True 153 | 154 | # Check if pattern matches intermediate directory names 155 | # e.g. path 'a/b/c.txt', pattern 'b' (no slash) -> ignore if 'b' matches a dir name 156 | # e.g. path 'a/b/c.txt', pattern 'b/' (slash) -> ignore 157 | # Check if any directory component matches the pattern 158 | # This is crucial for patterns like 'node_modules' or 'build/' 159 | # Match pattern against any directory part 160 | if ( 161 | is_dir_pattern or "/" not in pattern 162 | ): # Check patterns like 'build/' or 'node_modules' against path parts 163 | # Check all parts except the last one (filename) if it's not a dir pattern itself 164 | # If dir pattern ('build/'), check all parts. 165 | limit = len(path_parts) if is_dir_pattern else len(path_parts) - 1 166 | for i in range(limit): 167 | if fnmatch.fnmatch(path_parts[i], match_pattern): 168 | return True 169 | # Also check the last part if it's potentially a directory being checked directly 170 | if name == path_parts[-1] and fnmatch.fnmatch(name, match_pattern): 171 | # This case helps match directory names passed directly during walk 172 | return True 173 | 174 | return False 175 | 176 | 177 | def get_cwd(): 178 | """Returns the current working directory.""" 179 | return os.getcwd() 180 | 181 | 182 | def get_device_id(): 183 | """ 184 | Get the device ID from the ~/.sidekick/device_id file. 185 | If the file doesn't exist, generate a new UUID and save it. 186 | 187 | Returns: 188 | str: The device ID as a string. 189 | """ 190 | try: 191 | # Get the ~/.sidekick directory 192 | sidekick_home = get_sidekick_home() 193 | device_id_file = sidekick_home / DEVICE_ID_FILE 194 | 195 | # If the file exists, read the device ID 196 | if device_id_file.exists(): 197 | device_id = device_id_file.read_text().strip() 198 | if device_id: 199 | return device_id 200 | 201 | # If we got here, either the file doesn't exist or is empty 202 | # Generate a new device ID 203 | device_id = str(uuid.uuid4()) 204 | 205 | # Write the device ID to the file 206 | device_id_file.write_text(device_id) 207 | 208 | return device_id 209 | except Exception as e: 210 | print(f"Error getting device ID: {e}") 211 | # Return a temporary device ID if we couldn't get or save one 212 | return str(uuid.uuid4()) 213 | 214 | 215 | def cleanup_session(state_manager): 216 | """ 217 | Clean up the session directory after the CLI exits. 218 | Removes the session directory completely. 219 | 220 | Args: 221 | state_manager: The StateManager instance containing session info. 222 | 223 | Returns: 224 | bool: True if cleanup was successful, False otherwise. 225 | """ 226 | try: 227 | # If no session ID was generated, nothing to clean up 228 | if state_manager.session.session_id is None: 229 | return True 230 | 231 | # Get the session directory using the imported function 232 | session_dir = get_session_dir(state_manager) 233 | 234 | # If the directory exists, remove it 235 | if session_dir.exists(): 236 | import shutil 237 | 238 | shutil.rmtree(session_dir) 239 | 240 | return True 241 | except Exception as e: 242 | print(f"Error cleaning up session: {e}") 243 | return False 244 | 245 | 246 | def check_for_updates(): 247 | """ 248 | Check if there's a newer version of sidekick-cli available on PyPI. 249 | 250 | Returns: 251 | tuple: (has_update, latest_version) 252 | - has_update (bool): True if a newer version is available 253 | - latest_version (str): The latest version available 254 | """ 255 | 256 | app_settings = ApplicationSettings() 257 | current_version = app_settings.version 258 | try: 259 | result = subprocess.run( 260 | ["pip", "index", "versions", "sidekick-cli"], capture_output=True, text=True, check=True 261 | ) 262 | output = result.stdout 263 | 264 | if "Available versions:" in output: 265 | versions_line = output.split("Available versions:")[1].strip() 266 | versions = versions_line.split(", ") 267 | latest_version = versions[0] 268 | 269 | latest_version = latest_version.strip() 270 | 271 | if latest_version > current_version: 272 | return True, latest_version 273 | 274 | # If we got here, either we're on the latest version or we couldn't parse the output 275 | return False, current_version 276 | except Exception: 277 | return False, current_version 278 | 279 | 280 | def list_cwd(max_depth=3): 281 | """ 282 | Lists files in the current working directory up to a specified depth, 283 | respecting .gitignore rules or a default ignore list. 284 | 285 | Args: 286 | max_depth (int): Maximum directory depth to traverse. 287 | 0: only files in the current directory. 288 | 1: includes files in immediate subdirectories. 289 | ... Default is 3. 290 | 291 | Returns: 292 | list: A sorted list of relative file paths. 293 | """ 294 | ignore_patterns = _load_gitignore_patterns() 295 | if ignore_patterns is None: 296 | ignore_patterns = DEFAULT_IGNORE_PATTERNS 297 | 298 | file_list = [] 299 | start_path = "." 300 | # Ensure max_depth is non-negative 301 | max_depth = max(0, max_depth) 302 | 303 | for root, dirs, files in os.walk(start_path, topdown=True): 304 | rel_root = os.path.relpath(root, start_path) 305 | # Handle root case where relpath is '.' 306 | if rel_root == ".": 307 | rel_root = "" 308 | current_depth = 0 309 | else: 310 | # Depth is number of separators + 1 311 | current_depth = rel_root.count(os.sep) + 1 312 | 313 | # --- Depth Pruning --- 314 | if current_depth >= max_depth: 315 | dirs[:] = [] 316 | 317 | # --- Directory Ignoring --- 318 | original_dirs = list(dirs) 319 | dirs[:] = [] # Reset dirs, only add back non-ignored ones 320 | for d in original_dirs: 321 | # Important: Check the directory based on its relative path 322 | dir_rel_path = os.path.join(rel_root, d) if rel_root else d 323 | if not _is_ignored(dir_rel_path, d, ignore_patterns): 324 | dirs.append(d) 325 | # else: # Optional debug print 326 | # print(f"Ignoring dir: {dir_rel_path}") 327 | 328 | # --- File Processing --- 329 | if current_depth <= max_depth: 330 | for f in files: 331 | file_rel_path = os.path.join(rel_root, f) if rel_root else f 332 | if not _is_ignored(file_rel_path, f, ignore_patterns): 333 | # Standardize path separators for consistency 334 | file_list.append(file_rel_path.replace(os.sep, "/")) 335 | 336 | return sorted(file_list) 337 | -------------------------------------------------------------------------------- /src/sidekick/utils/text_utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module: sidekick.utils.text_utils 3 | 4 | Provides text processing utilities. 5 | Includes file extension to language mapping and key formatting functions. 6 | """ 7 | 8 | import os 9 | from typing import Set 10 | 11 | 12 | def key_to_title(key: str, uppercase_words: Set[str] = None) -> str: 13 | """Convert key to title, replacing underscores with spaces and capitalizing words.""" 14 | if uppercase_words is None: 15 | uppercase_words = {"api", "id", "url"} 16 | 17 | words = key.split("_") 18 | result_words = [] 19 | for word in words: 20 | lower_word = word.lower() 21 | if lower_word in uppercase_words: 22 | result_words.append(lower_word.upper()) 23 | elif word: 24 | result_words.append(word[0].upper() + word[1:].lower()) 25 | else: 26 | result_words.append("") 27 | 28 | return " ".join(result_words) 29 | 30 | 31 | def ext_to_lang(path: str) -> str: 32 | """Get the language from the file extension. Default to `text` if not found.""" 33 | MAP = { 34 | "py": "python", 35 | "js": "javascript", 36 | "ts": "typescript", 37 | "java": "java", 38 | "c": "c", 39 | "cpp": "cpp", 40 | "cs": "csharp", 41 | "html": "html", 42 | "css": "css", 43 | "json": "json", 44 | "yaml": "yaml", 45 | "yml": "yaml", 46 | } 47 | ext = os.path.splitext(path)[1][1:] 48 | if ext in MAP: 49 | return MAP[ext] 50 | return "text" 51 | -------------------------------------------------------------------------------- /src/sidekick/utils/user_configuration.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module: sidekick.utils.user_configuration 3 | 4 | Provides user configuration file management. 5 | Handles loading, saving, and updating user preferences including 6 | model selection and MCP server settings. 7 | """ 8 | 9 | import json 10 | from json import JSONDecodeError 11 | from typing import TYPE_CHECKING, Optional 12 | 13 | from sidekick.configuration.settings import ApplicationSettings 14 | from sidekick.exceptions import ConfigurationError 15 | from sidekick.types import MCPServers, ModelName, UserConfig 16 | 17 | if TYPE_CHECKING: 18 | from sidekick.core.state import StateManager 19 | 20 | 21 | def load_config() -> Optional[UserConfig]: 22 | """Load user config from file""" 23 | app_settings = ApplicationSettings() 24 | try: 25 | with open(app_settings.paths.config_file, "r") as f: 26 | return json.load(f) 27 | except FileNotFoundError: 28 | return None 29 | except JSONDecodeError: 30 | raise ConfigurationError(f"Invalid JSON in config file at {app_settings.paths.config_file}") 31 | except Exception as e: 32 | raise ConfigurationError(e) 33 | 34 | 35 | def save_config(state_manager: "StateManager") -> bool: 36 | """Save user config to file""" 37 | app_settings = ApplicationSettings() 38 | try: 39 | with open(app_settings.paths.config_file, "w") as f: 40 | json.dump(state_manager.session.user_config, f, indent=4) 41 | return True 42 | except Exception: 43 | return False 44 | 45 | 46 | def get_mcp_servers(state_manager: "StateManager") -> MCPServers: 47 | """Retrieve MCP server configurations from user config""" 48 | return state_manager.session.user_config.get("mcpServers", []) 49 | 50 | 51 | def set_default_model(model_name: ModelName, state_manager: "StateManager") -> bool: 52 | """Set the default model in the user config and save""" 53 | state_manager.session.user_config["default_model"] = model_name 54 | return save_config(state_manager) 55 | --------------------------------------------------------------------------------