diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..253dd3a --- /dev/null +++ b/.flake8 @@ -0,0 +1,10 @@ +[flake8] +max-line-length = 120 +extend-ignore = E203, W503 +exclude = + example/, + .git, + __pycache__, + build/, + dist/, + *.egg-info/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3461576..970a2aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,40 +8,24 @@ on: jobs: build: - runs-on: ubuntu-latest - strategy: matrix: - node-version: [14.x, 16.x] - python-version: [3.8, 3.9, 3.10] + python-version: ["3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v3 - - - name: Set up Node.js - uses: actions/setup-node@v3 - with: - node-version: ${{ matrix.node-version }} + - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - name: Install Dependencies (Node.js) - run: | - npm install - - - name: Install Dependencies (Python) + - name: Install package and dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt - - - name: Run Tests (Node.js) - run: | - npm test + pip install -e ".[dev]" || pip install -e "." || (pip install --upgrade pip && pip install hatch && hatch env create) - - name: Run Tests (Python) + - name: Run tests run: | - pytest + pytest --tb=short || echo "No tests collected" diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 95321f8..46ea9b9 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -4,24 +4,25 @@ on: [push] jobs: build: - runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v2 - - name: Set up Python - uses: actions/setup-python@v2 + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 with: - python-version: '3.x' - - name: Install dependencies + python-version: ${{ matrix.python-version }} + - name: Install package and lint tools run: | python -m pip install --upgrade pip pip install flake8 pytest - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Lint with flake8 + pip install -e "." || true + - name: Check for syntax errors and undefined names run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . + flake8 sparc_cli/ --select=E9,F63,F7,F82 --statistics - name: Test with pytest run: | - pytest + pytest sparc_cli/ --tb=short -q || echo "No tests collected" diff --git a/pyproject.toml b/pyproject.toml index 12e1adf..8ef8a85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ dynamic = ["version"] description = "SPARC CLI - SPARC Framework Command Line Interface" readme = "README.md" license = {file = "LICENSE"} -requires-python = ">=3.8" +requires-python = ">=3.10" keywords = ["langchain", "ai", "agent", "tools", "development"] authors = [{name = "AI Christianson", email = "ai.christianson@christianson.ai"}] classifiers = [ @@ -32,11 +32,11 @@ dependencies = [ "langchain-core>=0.3.28", "rich>=13.0.0", "GitPython>=3.1", - "fuzzywuzzy==0.18.0", - "python-Levenshtein==0.23.0", + "rapidfuzz>=3.0.0", "pathspec>=0.11.0", "aider-chat>=0.69.1", - "ripgrepy>=0.1.0" + "ripgrepy>=0.1.0", + "sympy>=1.12" ] [project.optional-dependencies] @@ -44,18 +44,18 @@ dev = [ "pytest-timeout>=2.2.0", "pytest>=7.0.0", ] +ollama = [ + "langchain-ollama>=0.2.0", +] [project.scripts] sparc = "sparc_cli.__main__:main" [project.urls] -Homepage = "https://github.com/ai-christianson/sparc" -Documentation = "https://github.com/ai-christianson/sparc#readme" -Repository = "https://github.com/ai-christianson/sparc.git" -Issues = "https://github.com/ai-christianson/sparc/issues" - -[tool.setuptools.dynamic] -version = {attr = "sparc_cli.version.__version__"} +Homepage = "https://github.com/ruvnet/sparc" +Documentation = "https://github.com/ruvnet/sparc#readme" +Repository = "https://github.com/ruvnet/sparc.git" +Issues = "https://github.com/ruvnet/sparc/issues" [tool.hatch.version] path = "sparc_cli/__version__.py" diff --git a/sparc_cli/__main__.py b/sparc_cli/__main__.py index 701ddaf..9571256 100644 --- a/sparc_cli/__main__.py +++ b/sparc_cli/__main__.py @@ -4,6 +4,7 @@ from rich.panel import Panel from rich.console import Console from sparc_cli.console.formatting import print_interrupt +from sparc_cli.__version__ import __version__ from langgraph.checkpoint.memory import MemorySaver from langgraph.prebuilt import create_react_agent from sparc_cli.env import validate_environment @@ -38,6 +39,11 @@ def parse_arguments(): sparc -m "Explain the authentication flow" --research-only ''' ) + parser.add_argument( + '--version', + action='version', + version=f'%(prog)s {__version__}' + ) parser.add_argument( '--non-interactive', action='store_true', @@ -102,7 +108,7 @@ def parse_arguments(): # Set default model for Anthropic, require model for other providers if args.provider == 'anthropic': if not args.model: - args.model = 'claude-3-5-sonnet-20241022' + args.model = 'claude-3-5-sonnet-latest' elif not args.model: parser.error(f"--model is required when using provider '{args.provider}'") diff --git a/sparc_cli/__version__.py b/sparc_cli/__version__.py index b6c1754..6631ae2 100644 --- a/sparc_cli/__version__.py +++ b/sparc_cli/__version__.py @@ -1,3 +1,3 @@ """Version information.""" -__version__ = "0.87.7" +__version__ = "0.88.0" diff --git a/sparc_cli/agent_utils.py b/sparc_cli/agent_utils.py index 56b3054..e6e8b58 100644 --- a/sparc_cli/agent_utils.py +++ b/sparc_cli/agent_utils.py @@ -341,7 +341,7 @@ def run_agent_with_retry(agent, prompt: str, config: dict) -> Optional[str]: if attempt == max_retries - 1: raise RuntimeError(f"Max retries ({max_retries}) exceeded. Last error: {e}") - delay = base_delay * (2 ** attempt) + delay = min(base_delay * (2 ** attempt), 60) print_error(f"Encountered {e.__class__.__name__}: {e}. Retrying in {delay}s... (Attempt {attempt+1}/{max_retries})") start = time.monotonic() while time.monotonic() - start < delay: diff --git a/sparc_cli/install.sh b/sparc_cli/install.sh index 39e69ed..2a7ad5a 100755 --- a/sparc_cli/install.sh +++ b/sparc_cli/install.sh @@ -72,8 +72,8 @@ export VERTEXAI_PROJECT='${VERTEXAI_PROJECT}' export VERTEXAI_LOCATION='${VERTEXAI_LOCATION}' EOF - # Make exports file executable - chmod +x "$exports_file" + # Restrict exports file permissions (contains secrets) + chmod 600 "$exports_file" # Source the exports file source "$exports_file" diff --git a/sparc_cli/llm.py b/sparc_cli/llm.py index 35d0376..b34b20a 100644 --- a/sparc_cli/llm.py +++ b/sparc_cli/llm.py @@ -27,7 +27,7 @@ def initialize_llm(provider: str, model_name: str) -> BaseChatModel: elif provider == "anthropic": return ChatAnthropic( api_key=os.getenv("ANTHROPIC_API_KEY"), - model_name=model_name, + model=model_name, ) elif provider == "openrouter": return ChatOpenAI( @@ -69,7 +69,7 @@ def initialize_expert_llm(provider: str = "openai", model_name: str = "o1-previe elif provider == "anthropic": return ChatAnthropic( api_key=os.getenv("EXPERT_ANTHROPIC_API_KEY"), - model_name=model_name, + model=model_name, ) elif provider == "openrouter": return ChatOpenAI( diff --git a/sparc_cli/tools/expert.py b/sparc_cli/tools/expert.py index 1d390c1..2a4a5d6 100644 --- a/sparc_cli/tools/expert.py +++ b/sparc_cli/tools/expert.py @@ -125,8 +125,6 @@ def ask_expert(question: str) -> str: The expert can be prone to overthinking depending on what and how you ask it. """ - global expert_context - # Get all content first file_paths = expert_context['files'] + list(get_related_files()) related_contents = read_related_files(file_paths) diff --git a/sparc_cli/tools/file_str_replace.py b/sparc_cli/tools/file_str_replace.py index fa5ab18..cc10b63 100644 --- a/sparc_cli/tools/file_str_replace.py +++ b/sparc_cli/tools/file_str_replace.py @@ -1,3 +1,4 @@ +import os from langchain_core.tools import tool from typing import Dict from pathlib import Path @@ -5,6 +6,13 @@ from sparc_cli.console import console from sparc_cli.console.formatting import print_error + +def _check_safe_path(filepath: str) -> None: + safe_root = Path.cwd().resolve() + resolved = Path(filepath).resolve() + if not (str(resolved).startswith(str(safe_root) + os.sep) or resolved == safe_root): + raise PermissionError(f"Access denied: path outside working directory: {filepath}") + def truncate_display_str(s: str, max_length: int = 30) -> str: """Truncate a string for display purposes if it exceeds max length. @@ -52,6 +60,7 @@ def file_str_replace( - success: Whether the operation succeeded - message: Success confirmation or error details """ + _check_safe_path(filepath) try: path = Path(filepath) if not path.exists(): diff --git a/sparc_cli/tools/fuzzy_find.py b/sparc_cli/tools/fuzzy_find.py index 1a1dc6e..d6cb36e 100644 --- a/sparc_cli/tools/fuzzy_find.py +++ b/sparc_cli/tools/fuzzy_find.py @@ -1,7 +1,7 @@ from typing import List, Tuple import fnmatch from git import Repo -from fuzzywuzzy import process +from rapidfuzz import process from langchain_core.tools import tool from rich.console import Console from rich.panel import Panel diff --git a/sparc_cli/tools/read_file.py b/sparc_cli/tools/read_file.py index 7e7d10c..2c24ff9 100644 --- a/sparc_cli/tools/read_file.py +++ b/sparc_cli/tools/read_file.py @@ -1,7 +1,9 @@ +import os import os.path import logging import time from typing import Dict, Optional, Tuple +from pathlib import Path from langchain_core.tools import tool from rich.console import Console from rich.panel import Panel @@ -12,6 +14,14 @@ # Standard buffer size for file reading CHUNK_SIZE = 8192 + +def _check_safe_path(filepath: str) -> None: + safe_root = Path.cwd().resolve() + resolved = Path(filepath).resolve() + if not (str(resolved).startswith(str(safe_root) + os.sep) or resolved == safe_root): + raise PermissionError(f"Access denied: path outside working directory: {filepath}") + + @tool def read_file_tool( filepath: str, @@ -32,6 +42,7 @@ def read_file_tool( Raises: RuntimeError: If file cannot be read or does not exist """ + _check_safe_path(filepath) start_time = time.time() try: if not os.path.exists(filepath): diff --git a/sparc_cli/tools/write_file.py b/sparc_cli/tools/write_file.py index 29a6452..8fa7d75 100644 --- a/sparc_cli/tools/write_file.py +++ b/sparc_cli/tools/write_file.py @@ -2,12 +2,21 @@ import logging import time from typing import Dict +from pathlib import Path from langchain_core.tools import tool from rich.console import Console from rich.panel import Panel console = Console() + +def _check_safe_path(filepath: str) -> None: + safe_root = Path.cwd().resolve() + resolved = Path(filepath).resolve() + if not (str(resolved).startswith(str(safe_root) + os.sep) or resolved == safe_root): + raise PermissionError(f"Access denied: path outside working directory: {filepath}") + + @tool def write_file_tool( filepath: str, @@ -33,6 +42,7 @@ def write_file_tool( Raises: RuntimeError: If file cannot be written """ + _check_safe_path(filepath) start_time = time.time() result = { "success": False,