diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9ed377b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,72 @@ +name: CI + +on: + push: + branches: [main, "feat/**", "fix/**"] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install uv + uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + cache-dependency-glob: pyproject.toml + + - name: Build wheel (verify it builds) + run: uv build --wheel + + - name: Verify wheel contents + run: | + uv run python -c " + import zipfile, sys + from pathlib import Path + wheels = list(Path('dist').glob('*.whl')) + if not wheels: + print('ERROR: no wheel found', file=sys.stderr) + sys.exit(1) + whl = wheels[0] + with zipfile.ZipFile(whl) as z: + names = z.namelist() + for req in ['fastcontext_mcp/__init__.py', 'fastcontext_mcp/server.py', 'fastcontext_mcp/_shared.py', 'fastcontext_mcp/configwriter.py', 'fastcontext_mcp/release.py']: + assert req in names, f'Missing in wheel: {req}' + print(f'Wheel OK: {whl.name}') + " + + - name: Install test dependencies + run: | + # Install only the light deps needed by configwriter/release modules + # (tomli_w) plus pytest. The heavy mlx-lm / fastcontext git deps + # require Apple Silicon and are not needed for the test suite. + uv pip install --system pytest tomli-w + + - name: Run tests + env: + PYTHONPATH: ${{ github.workspace }}/src + run: pytest tests/ -v + + shellcheck: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Run shellcheck + uses: ludeeus/action-shellcheck@master + with: + severity: warning + ignore_paths: benchmark diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..49cfdd7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,83 @@ +name: Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + version: + description: "Version tag (e.g. v0.1.0) — leave empty to use pyproject.toml" + required: false + +permissions: + contents: write + +jobs: + build-and-release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install uv + uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + cache-dependency-glob: pyproject.toml + + - name: Build wheel + run: | + uv build --wheel + echo "Built wheel:" + ls -la dist/*.whl + + - name: Verify wheel metadata + run: | + uv run python -c " + import zipfile, sys + from pathlib import Path + wheels = list(Path('dist').glob('*.whl')) + if not wheels: + print('ERROR: no wheel found', file=sys.stderr) + sys.exit(1) + whl = wheels[0] + print(f'Wheel: {whl.name} ({whl.stat().st_size} bytes)') + with zipfile.ZipFile(whl) as z: + names = z.namelist() + required = [ + 'fastcontext_mcp/__init__.py', + 'fastcontext_mcp/server.py', + 'fastcontext_mcp/_shared.py', + 'fastcontext_mcp/configwriter.py', + 'fastcontext_mcp/release.py', + ] + for req in required: + assert req in names, f'Missing in wheel: {req}' + print('All required modules present in wheel') + " + + - name: Determine version + id: version + run: | + if [ -n "${{ inputs.version }}" ]; then + TAG="${{ inputs.version }}" + else + TAG="${GITHUB_REF#refs/tags/}" + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "Version: $TAG" + + - name: Create or update release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.version.outputs.tag }} + name: ${{ steps.version.outputs.tag }} + generate_release_notes: true + files: dist/*.whl + fail_on_unmatched_files: true diff --git a/.gitignore b/.gitignore index d422621..830522f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,15 @@ __pycache__/ *.pyc +*.pyo +*.pyd .DS_Store .fastcontext/ +dist/ +build/ +*.egg-info/ +.pytest_cache/ +.ruff_cache/ + +# venvs +.venv/ +venv/ diff --git a/README.md b/README.md index a7bf576..d4d9050 100644 --- a/README.md +++ b/README.md @@ -2,20 +2,117 @@ MCP server wrapping [Microsoft FastContext](https://github.com/microsoft/fastcontext) with in-process MLX inference on Apple Silicon. -Loads the fine-tuned `mattrobenolt/FastContext-1.0-4B-SFT-mlx-bf16` model directly via `mlx_lm` — no external server, no env vars, no subprocess management. +Loads the fine-tuned `rubybear/FastContext-1.0-4B-SFT-mlx-4bit-g32` model directly via `mlx_lm` — no external server, no env vars, no subprocess management. + +## Architecture + +FastContext is a two-part system: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ AI agent (Claude Code / OpenCode / Codex CLI) │ +│ │ │ +│ ├── reads SKILL.md ──▶ knows when/how to call the tool │ +│ │ │ +│ └── calls fastcontext_explore ──┐ │ +│ │ │ +└────────────────────────────────────│────────────────────────┘ + │ stdio (MCP protocol) + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ fastcontext-mcp (long-running Python process) │ +│ │ │ +│ ├── loads FastContext-1.0-4B-mlx-4bit model into MLX │ +│ │ │ +│ └── agent loop: Read / Glob / Grep in parallel │ +│ returns file:line citations │ +└─────────────────────────────────────────────────────────────┘ +``` + +| Part | What it is | What it does | +|------|-----------|--------------| +| **MCP server** (`fastcontext-mcp`) | Python program running the 4B model via MLX | Provides the `fastcontext_explore` tool — the actual engine | +| **Skill** (`SKILL.md`) | Markdown instructions the agent loads | Tells the agent *when* and *how* to use `fastcontext_explore` | + +Both are needed: the MCP server provides the tool, the skill tells the agent to use it. ## Install +### One-command install (recommended) + +Install the setup skill, then ask your agent to install the MCP server: + ```bash -uv pip install "fastcontext-mcp @ git+https://github.com/rubybear-lgtm/fastcontext.git" +npx add skill fastcontext-setup ``` +Then in your AI coding tool, say: + +> Install FastContext + +The agent will follow the skill instructions, run the install script, register the MCP server with your detected tool(s), and tell you to restart. + +### Direct install + +If you prefer to run the install script yourself: + +```bash +curl -fsSL https://raw.githubusercontent.com/rubybear-lgtm/fastcontext/main/scripts/install.sh | bash +``` + +Target a specific tool with `--target claude|opencode|codex|both` (default: auto-detect). + +### What the install script does + +1. Ensures `uv` is available (installs if missing) +2. Creates a venv at `~/.cache/fastcontext/venv` and installs `fastcontext-mcp` + - **Default**: downloads a prebuilt wheel from the latest GitHub release (fast — no git clone) + - **Fallback**: if no release exists, builds from the git repo + - **`--from-source`**: skip the prebuilt check and always build from git +3. Auto-detects installed AI coding tools (or uses explicit `--target`) +4. Registers the MCP server in the appropriate config file(s): + - Claude Code → `~/.mcp.json` + - OpenCode → `~/.config/opencode/opencode.json` + - Codex CLI → `~/.codex/config.toml` +5. Verifies the model loads correctly + +With `--install-skills`, also downloads and installs the FastContext skill files (`fastcontext`, `fastcontext-setup`) to `~/.agents/skills/` and symlinks them into detected tool skill directories. + +After installation, **restart your AI coding tool**. The `fastcontext_explore` MCP tool becomes available automatically. + +### Prebuilt wheels + +Releases include a prebuilt `fastcontext_mcp--py3-none-any.whl` wheel. The install script downloads it automatically by default. To install it manually: + +```bash +# Download from the latest release (replace accordingly) +uv pip install --python ~/.cache/fastcontext/venv/bin/python \ + https://github.com/rubybear-lgtm/fastcontext/releases/latest/download/fastcontext_mcp-0.1.0-py3-none-any.whl +``` + +The wheel is platform-independent (pure Python) — only the `mlx-lm` and `fastcontext` dependencies require Apple Silicon at runtime. + +### Manual installation + +```bash +uv venv ~/.cache/fastcontext/venv --python ">=3.12" +uv pip install --python ~/.cache/fastcontext/venv/bin/python "fastcontext-mcp @ git+https://github.com/rubybear-lgtm/fastcontext.git" +``` + +Then register the MCP server manually — see [`skills/fastcontext-setup/SKILL.md`](skills/fastcontext-setup/SKILL.md) for the per-tool config snippets. + ## Run ```bash fastcontext-mcp ``` +## Requirements + +- macOS with Apple Silicon (M1/M2/M3/M4) — required for MLX +- ~5 GB free RAM for the 4B model +- Python ≥ 3.12 + ## License MIT diff --git a/pyproject.toml b/pyproject.toml index 90b9098..32a79f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,9 @@ dependencies = [ "tomli-w>=1.2.0", ] +[project.optional-dependencies] +test = ["pytest>=8.0.0"] + [project.scripts] fastcontext-mcp = "fastcontext_mcp.server:main" @@ -23,3 +26,15 @@ allow-direct-references = true [tool.hatch.build.targets.wheel] packages = ["src/fastcontext_mcp"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra" + +[tool.ruff] +line-length = 100 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F", "W", "I"] +ignore = ["E501"] diff --git a/scripts/install.sh b/scripts/install.sh index 1ceb744..8bb913f 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,18 +1,56 @@ #!/bin/bash # Install fastcontext-mcp: create venv, install package, register MCP server, verify. +# Optionally install skill files (--install-skills). +# +# By default, tries to download a prebuilt wheel from the latest GitHub +# release (faster — no git clone). Falls back to building from git source +# if no release is available. Use --from-source to skip the prebuilt check. set -euo pipefail VENV_DIR="${HOME}/.cache/fastcontext/venv" REPO_URL="git+https://github.com/rubybear-lgtm/fastcontext.git" +SKILLS_RAW_BASE="https://raw.githubusercontent.com/rubybear-lgtm/fastcontext/main/skills" -# Parse --target flag: claude | opencode | both -# Default: auto-detect (both if tools found, claude as fallback) +# Parse flags TARGET="auto" +INSTALL_SKILLS=false +FROM_SOURCE=false while [[ $# -gt 0 ]]; do case "$1" in --target) TARGET="$2"; shift 2 ;; --target=*) TARGET="${1#*=}"; shift ;; - *) echo "Unknown flag: $1"; exit 1 ;; + --install-skills) INSTALL_SKILLS=true; shift ;; + --from-source) FROM_SOURCE=true; shift ;; + --help|-h) + cat <<'EOF' +fastcontext-mcp installer + +Usage: + install.sh [options] + +Options: + --target Which AI coding tool(s) to register with: + claude | opencode | codex | both | auto (default: auto) + --install-skills Also download and install the FastContext skill files + (fastcontext, fastcontext-setup) to ~/.agents/skills/ + and symlink them into detected tool skill directories. + --from-source Build from git source instead of using a prebuilt wheel. + Slower, but useful for development or if no release exists. + --help, -h Show this help message + +By default, the installer downloads a prebuilt wheel from the latest +GitHub release (fast — no git clone). If no release is available, or if +--from-source is passed, it falls back to building from the git repo. + +Examples: + install.sh # auto-detect tools, prebuilt wheel + install.sh --target claude # Claude Code only + install.sh --target both --install-skills # all tools + skill files + install.sh --from-source # build from git + install.sh --install-skills # auto-detect + skill files +EOF + exit 0 ;; + *) echo "Unknown flag: $1 (use --help for usage)"; exit 1 ;; esac done @@ -36,9 +74,81 @@ if [ ! -d "$VENV_DIR" ]; then uv venv "$VENV_DIR" --python ">=3.12" --quiet fi PYTHON="$VENV_DIR/bin/python" -uv pip install --quiet --python "$PYTHON" "fastcontext-mcp @ $REPO_URL" + +INSTALL_SOURCE="" +if [ "$FROM_SOURCE" = true ]; then + INSTALL_SOURCE="$REPO_URL" + echo " --from-source: building from git" +else + # Try prebuilt wheel first; fall back to git source on failure. + # NOTE: This runs BEFORE the package is installed, so we use only + # stdlib (no import from fastcontext_mcp) — the release module is + # available after install for programmatic use, but the bootstrap + # must be self-contained. + WHEEL_URL=$("$PYTHON" -c " +import json, re, urllib.request, sys +API = 'https://api.github.com/repos/rubybear-lgtm/fastcontext/releases' +WHEEL_RE = re.compile(r'fastcontext_mcp-[\w.]+-py3-none-any\.whl$') +def get(url): + req = urllib.request.Request(url, headers={ + 'Accept': 'application/vnd.github+json', + 'User-Agent': 'fastcontext-mcp-installer', + }) + with urllib.request.urlopen(req, timeout=15) as r: + return json.loads(r.read().decode('utf-8')) +try: + release = get(API + '/latest') +except Exception: + try: + releases = get(API) + release = releases[0] if releases else {} + except Exception: + release = {} +for asset in release.get('assets', []): + if WHEEL_RE.search(asset.get('name', '')): + url = asset.get('browser_download_url') + if url: + print(url) + sys.exit(0) +print('NONE') +" 2>/dev/null || echo "NONE") + + if [ "$WHEEL_URL" != "NONE" ] && [ -n "$WHEEL_URL" ]; then + echo " Found prebuilt wheel, downloading..." + # mktemp -d + filename is portable (macOS BSD mktemp doesn't + # support --suffix) + WHEEL_DIR=$(mktemp -d) + WHEEL_PATH="$WHEEL_DIR/fastcontext_mcp.whl" + if "$PYTHON" -c " +import sys, urllib.request +try: + urllib.request.urlretrieve('$WHEEL_URL', '$WHEEL_PATH') + print('OK') +except Exception as e: + print(f'FAIL: {e}', file=sys.stderr) + sys.exit(1) +" 2>/dev/null; then + INSTALL_SOURCE="$WHEEL_PATH" + echo " Downloaded prebuilt wheel" + else + echo " Wheel download failed, falling back to git source" + rm -rf "$WHEEL_DIR" + INSTALL_SOURCE="$REPO_URL" + fi + else + echo " No prebuilt wheel available, building from git" + INSTALL_SOURCE="$REPO_URL" + fi +fi + +uv pip install --quiet --python "$PYTHON" "fastcontext-mcp @ $INSTALL_SOURCE" echo " Installed into $VENV_DIR" +# Clean up downloaded wheel (uv pip install copies it into the venv) +if [[ "$INSTALL_SOURCE" == *.whl ]] && [ -f "$INSTALL_SOURCE" ]; then + rm -rf "$(dirname "$INSTALL_SOURCE")" +fi + # ------------------------------------------------------------------- # 3. Determine which tools to register with # ------------------------------------------------------------------- @@ -72,96 +182,57 @@ esac echo " Tools to register: ${REGISTER_TOOLS[*]:-(none)}" # ------------------------------------------------------------------- -# 4. Register MCP servers +# 4. Register MCP servers (and optionally install skill files) # ------------------------------------------------------------------- echo "[4/5] Registering MCP server(s)..." +# Verify the package installed successfully before using its modules +if ! "$PYTHON" -c "import fastcontext_mcp.configwriter" 2>/dev/null; then + echo " ERROR: fastcontext-mcp package not installed correctly." + echo " The uv pip install in step 2 may have failed. Re-run the installer." + exit 1 +fi + export VENV_DIR +export REGISTER_TOOLS_STR="${REGISTER_TOOLS[*]}" +export INSTALL_SKILLS +export SKILLS_RAW_BASE "$PYTHON" -c " -import json, os, sys, tomllib +import json, os, sys from pathlib import Path -try: - import tomli_w -except ImportError: - print(' Installing tomli-w for TOML support...') - import subprocess - subprocess.check_call([sys.executable, '-m', 'pip', 'install', '--quiet', 'tomli-w>=1.2.0']) - import tomli_w +from fastcontext_mcp.configwriter import ( + TOOL_CONFIGS, SKILL_NAMES, write_mcp_config, install_skill_file, +) venv = os.environ['VENV_DIR'] command_path = f'{venv}/bin/fastcontext-mcp' -tools_str = '${REGISTER_TOOLS[*]}'.strip() -tools = tools_str.split() if tools_str else [] - -TOOLS = { - 'claude': { - 'path': Path.home() / '.mcp.json', - 'format': 'json', - 'container_key': 'mcpServers', - 'entry_name': 'fastcontext', - 'entry': {'command': command_path, 'args': []}, - }, - 'opencode': { - 'path': Path.home() / '.config' / 'opencode' / 'opencode.json', - 'format': 'json', - 'container_key': 'mcp', - 'entry_name': 'fastcontext', - 'entry': {'type': 'local', 'command': [command_path], 'enabled': True}, - }, - 'codex': { - 'path': Path.home() / '.codex' / 'config.toml', - 'format': 'toml', - 'container_key': 'mcp_servers', - 'entry_name': 'fastcontext', - 'entry': {'command': command_path, 'args': []}, - }, -} - -def _ensure_json(path, container_key, entry_name, entry): - path.parent.mkdir(parents=True, exist_ok=True) - if path.exists(): - content = path.read_text().strip() - config = json.loads(content) if content else {} - else: - config = {} - config.setdefault(container_key, {}) - if entry_name in config[container_key]: - print(f' {path}: already registered.') - return False - config[container_key][entry_name] = entry - path.write_text(json.dumps(config, indent=2) + '\n') - print(f' Added to {path}') - return True - -def _ensure_toml(path, container_key, entry_name, entry): - if path.exists(): - config = tomllib.loads(path.read_text()) - else: - config = {} - config.setdefault(container_key, {}) - if entry_name in config[container_key]: - print(f' {path}: already registered.') - return False - config[container_key][entry_name] = entry - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(tomli_w.dumps(config)) - print(f' Added to {path}') - return True - -if not tools: - print(' No tools to register.') -else: - for tool_name in tools: - if tool_name not in TOOLS: - print(f' Unknown tool: {tool_name}, skipping.') +tools = os.environ['REGISTER_TOOLS_STR'].strip().split() +install_skills = os.environ['INSTALL_SKILLS'] == 'true' +skills_base = os.environ['SKILLS_RAW_BASE'] + +for tool_name in tools: + if tool_name not in TOOL_CONFIGS: + print(f' Unknown tool: {tool_name}, skipping.') + continue + written = write_mcp_config(tool_name, command_path) + cfg = TOOL_CONFIGS[tool_name] + action = 'Added to' if written else 'already registered:' + print(f' {action} ~/{cfg[\"path\"]}') + +if install_skills: + print(' Installing skill files...') + import urllib.request + for skill_name in SKILL_NAMES: + url = f'{skills_base}/{skill_name}/SKILL.md' + try: + content = urllib.request.urlopen(url, timeout=30).read().decode('utf-8') + except Exception as e: + print(f' Failed to fetch {skill_name}: {e}') continue - cfg = TOOLS[tool_name] - if cfg['format'] == 'json': - _ensure_json(cfg['path'], cfg['container_key'], cfg['entry_name'], cfg['entry']) - elif cfg['format'] == 'toml': - _ensure_toml(cfg['path'], cfg['container_key'], cfg['entry_name'], cfg['entry']) + path = install_skill_file(skill_name, content) + print(f' Skill {skill_name} -> {path}') " # ------------------------------------------------------------------- @@ -184,5 +255,8 @@ for tool in "${REGISTER_TOOLS[@]}"; do codex) echo " MCP: Registered in ~/.codex/config.toml (Codex CLI)" ;; esac done +if [ "$INSTALL_SKILLS" = true ]; then + echo " Skills: Installed to ~/.agents/skills/ (fastcontext, fastcontext-setup)" +fi echo "" echo "Restart your AI coding tool to activate the MCP server." diff --git a/skills/fastcontext-setup/SKILL.md b/skills/fastcontext-setup/SKILL.md index 8474d67..505d074 100644 --- a/skills/fastcontext-setup/SKILL.md +++ b/skills/fastcontext-setup/SKILL.md @@ -5,61 +5,170 @@ description: This skill should be used when the user asks to "install FastContex # FastContext Setup -Install and configure fastcontext-mcp — an MCP server that runs Microsoft FastContext for parallel codebase exploration. Uses a local MLX model on Apple Silicon for in-process inference (no external API calls). +Install and configure `fastcontext-mcp` — an MCP server that runs Microsoft FastContext for parallel codebase exploration. Uses a local MLX model on Apple Silicon for in-process inference (no external API calls). + +## Architecture (why two parts) + +FastContext has two components that must both be present: + +| Component | What it is | What it does | +|-----------|-----------|--------------| +| **MCP server** | A Python program (`fastcontext-mcp`) running the `FastContext-1.0-4B` model via MLX | Provides the `fastcontext_explore` tool — the actual engine | +| **Skill** (this file) | Instructions the AI agent loads | Tells the agent *when* and *how* to use `fastcontext_explore` | + +The skill is easy to install (`npx add skill fastcontext-setup`) — it's just a Markdown file. The MCP server is harder: it requires a Python venv, a model download, and registration in your agent tool's config file. **This skill exists to bridge that gap** — it lets the agent install the MCP server for the user instead of requiring the user to do it manually. + +``` +┌─────────────────────────────────────────────────────────────┐ +│ AI agent (Claude Code / OpenCode / Codex CLI) │ +│ │ │ +│ ├── reads SKILL.md ──▶ knows when/how to call the tool │ +│ │ │ +│ └── calls fastcontext_explore ──┐ │ +│ │ │ +└────────────────────────────────────│────────────────────────┘ + │ stdio (MCP protocol) + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ fastcontext-mcp (long-running Python process) │ +│ │ │ +│ ├── loads FastContext-1.0-4B-mlx-4bit model into MLX │ +│ │ │ +│ └── agent loop: Read / Glob / Grep in parallel │ +│ returns file:line citations │ +└─────────────────────────────────────────────────────────────┘ +``` ## Prerequisites - macOS with Apple Silicon (M1/M2/M3/M4) — required for MLX +- ~5 GB free RAM for the 4B model - `uv` (installed automatically if missing) -## Installation +## Installation procedure (agent-executed) + +**You (the AI agent) should perform these steps yourself using bash.** Do not just print these commands for the user to paste — execute them and report the results. Pause only to ask permission before the first command that modifies the system (the install script). + +### Step 1: Guard check + +Verify the user is on macOS Apple Silicon. Run: + +```bash +uname -s && uname -m +``` + +- `Darwin` + `arm64` → proceed +- Anything else → stop and tell the user: "FastContext requires macOS with Apple Silicon (M-series) for MLX inference. Your platform is ." Do not continue. + +### Step 2: Check if already installed + +```bash +test -x "$HOME/.cache/fastcontext/venv/bin/fastcontext-mcp" && echo INSTALLED || echo NOT_INSTALLED +``` + +- `INSTALLED` → skip to **Step 5: Verify** (the MCP is already there; only config registration might be missing, which the install script will detect idempotently). +- `NOT_INSTALLED` → continue. + +### Step 3: Ask permission + +Tell the user: -### Quick install +> "I'll install fastcontext-mcp now. This will: +> - Create a Python venv at `~/.cache/fastcontext/venv` +> - Download the `FastContext-1.0-4B-mlx-4bit` model (~2 GB on first use) +> - Register the MCP server with your detected AI coding tool(s) +> +> Shall I proceed?" -Run the install script directly from the repo: +Wait for an affirmative response before running the next command. + +### Step 4: Run the install script + +The repo's `scripts/install.sh` handles everything: uv bootstrap, venv creation, package install, agent-tool auto-detection, MCP config registration, and verification. By default it downloads a **prebuilt wheel** from the latest GitHub release (fast — no git clone) and falls back to building from source if no release exists. Run it directly from GitHub: ```bash curl -fsSL https://raw.githubusercontent.com/rubybear-lgtm/fastcontext/main/scripts/install.sh | bash ``` -The script auto-detects which AI coding tools you have installed and registers the MCP server accordingly. To target specific tools: +To target a specific tool, pass `--target`: ```bash -# Claude Code only curl -fsSL https://raw.githubusercontent.com/rubybear-lgtm/fastcontext/main/scripts/install.sh | bash -s -- --target claude +# alternatives: --target opencode | --target codex | --target both +``` -# OpenCode only -curl -fsSL https://raw.githubusercontent.com/rubybear-lgtm/fastcontext/main/scripts/install.sh | bash -s -- --target opencode +To also install the FastContext skill files (`fastcontext` and `fastcontext-setup`) to `~/.agents/skills/` and symlink them into your tool's skill directory, add `--install-skills`: -# Codex CLI only -curl -fsSL https://raw.githubusercontent.com/rubybear-lgtm/fastcontext/main/scripts/install.sh | bash -s -- --target codex +```bash +curl -fsSL https://raw.githubusercontent.com/rubybear-lgtm/fastcontext/main/scripts/install.sh | bash -s -- --install-skills +``` + +To force building from git source instead of the prebuilt wheel, add `--from-source`: -# All detected tools (default) -curl -fsSL https://raw.githubusercontent.com/rubybear-lgtm/fastcontext/main/scripts/install.sh | bash -s -- --target both +```bash +curl -fsSL https://raw.githubusercontent.com/rubybear-lgtm/fastcontext/main/scripts/install.sh | bash -s -- --from-source ``` -### What the script does +The script is idempotent and safe to re-run. It prints a `=== Done ===` block at the end listing which config files were written. + +If the script fails, capture the output and report the failing step to the user. Common failures: +- `uv` install failed → network issue; retry or install uv manually from https://astral.sh/uv +- `uv pip install` failed → check network / disk space; the model is ~2 GB +- Config write failed → likely a permissions issue on the config file; report the path + +### Step 5: Verify -1. Ensures `uv` is available -2. Creates a venv at `~/.cache/fastcontext/venv` and installs `fastcontext-mcp` -3. Auto-detects installed AI coding tools (or uses explicit `--target`) -4. Registers the MCP server in the appropriate config file(s) -5. Verifies the model loads correctly +Run the install script's own verification step: -After installation, restart your AI coding tool. The `fastcontext_explore` MCP tool becomes available automatically. +```bash +"$HOME/.cache/fastcontext/venv/bin/python" -c "from fastcontext_mcp.server import mcp; print('MCP server loads OK')" +``` -### Manual installation +- Success → continue to Step 6 +- Failure → report the error to the user and suggest re-running the install script -If the automated script is not suitable: +Also check that the MCP config entry exists for the tool(s) the user uses: ```bash +# Claude Code +grep -q '"fastcontext"' "$HOME/.mcp.json" 2>/dev/null && echo "claude: registered" || echo "claude: not registered" + +# OpenCode +grep -q '"fastcontext"' "$HOME/.config/opencode/opencode.json" 2>/dev/null && echo "opencode: registered" || echo "opencode: not registered" + +# Codex CLI +grep -q 'fastcontext' "$HOME/.codex/config.toml" 2>/dev/null && echo "codex: registered" || echo "codex: not registered" +``` + +If a tool the user uses shows "not registered", re-run the install script with `--target `. + +### Step 6: Report and instruct restart + +Report to the user: +- Which venv was created (path) +- Which agent tool config(s) were written (paths) +- That verification passed + +Then tell them the final manual step: + +> **Restart your AI coding tool now** for the `fastcontext_explore` MCP tool to become available. After restart, you can ask me to explore the codebase and I'll use FastContext automatically. + +## Manual installation (fallback) + +If the automated procedure above is not suitable (e.g. sandboxed environment, no network access for `curl`, or the user prefers manual control), share these steps with the user: + +```bash +# 1. Ensure uv (https://astral.sh/uv) +curl -LsSf https://astral.sh/uv/install.sh | sh + +# 2. Create venv and install uv venv ~/.cache/fastcontext/venv --python ">=3.12" uv pip install --python ~/.cache/fastcontext/venv/bin/python "fastcontext-mcp @ git+https://github.com/rubybear-lgtm/fastcontext.git" ``` -Then register the MCP server manually: +Then register the MCP server manually in the relevant config file: -**Claude Code** — add to `~/.mcp.json`: +**Claude Code** — `~/.mcp.json`: ```json { "mcpServers": { @@ -70,7 +179,7 @@ Then register the MCP server manually: } ``` -**OpenCode** — add to `~/.config/opencode/opencode.json` under the `mcp` key: +**OpenCode** — `~/.config/opencode/opencode.json` under the `mcp` key: ```json { "mcp": { @@ -83,30 +192,28 @@ Then register the MCP server manually: } ``` -**Codex CLI** — add to `~/.codex/config.toml`: +**Codex CLI** — `~/.codex/config.toml`: ```toml [mcp_servers.fastcontext] command = "~/.cache/fastcontext/venv/bin/fastcontext-mcp" args = [] ``` -## Verification +## Verification commands for the user -- **Claude Code**: Run `/mcp` to confirm the `fastcontext` server appears in the list. -- **OpenCode**: Check that `fastcontext` appears in the MCP servers list (use `/mcp` or the equivalent status command). -- **Codex CLI**: Run `codex mcp list` to verify `fastcontext` is connected. +After restarting their tool, the user can confirm with: -You can also verify directly: -```bash -~/.cache/fastcontext/venv/bin/python -c "from fastcontext_mcp.server import mcp; print('OK')" -``` +- **Claude Code**: `/mcp` → `fastcontext` should be in the list +- **OpenCode**: `/mcp` or the equivalent status command → `fastcontext` listed +- **Codex CLI**: `codex mcp list` → `fastcontext` connected ## Troubleshooting - **Model load errors**: The 4B model requires ~5 GB RAM. Close memory-intensive apps. -- **Import errors**: Run the verification command above to check Python dependencies. -- **MCP not showing**: Verify the config was written correctly and restart your tool. - - Claude Code: check `mcpServers.fastcontext` in `~/.mcp.json` - - OpenCode: check `mcp.fastcontext` in `~/.config/opencode/opencode.json` - - Codex CLI: check `[mcp_servers.fastcontext]` in `~/.codex/config.toml` -- **Wrong tool registered**: Re-run with `--target` to specify which tool(s) to configure. +- **Import errors**: Run `~/.cache/fastcontext/venv/bin/python -c "from fastcontext_mcp.server import mcp; print('OK')"` to see the underlying error. +- **MCP not showing after restart**: Verify the config was written correctly: + - Claude Code: `mcpServers.fastcontext` in `~/.mcp.json` + - OpenCode: `mcp.fastcontext` in `~/.config/opencode/opencode.json` + - Codex CLI: `[mcp_servers.fastcontext]` in `~/.codex/config.toml` +- **Wrong tool registered**: Re-run the install script with `--target `. +- **First call to `fastcontext_explore` is slow**: This is normal — the model is loading into memory on first use. Subsequent calls reuse the loaded model. diff --git a/skills/fastcontext/SKILL.md b/skills/fastcontext/SKILL.md index 444cbe3..88320c2 100644 --- a/skills/fastcontext/SKILL.md +++ b/skills/fastcontext/SKILL.md @@ -7,6 +7,36 @@ description: This skill should be used when the user asks to "explore the codeba FastContext is a specialized exploration agent that runs Read, Glob, and Grep operations in parallel using a local 4B model via MLX, returning compact file-path:line-range citations. The model runs in-process — no external server, no env vars. +## How it works + +FastContext is a two-part system. You are currently reading the **skill** (instructions); the actual work is done by the **MCP server** (a Python process running the model). + +``` +User: "find all auth middleware" + │ + ▼ +Agent reads this SKILL.md ──▶ decides: use fastcontext_explore + │ + ▼ +Agent calls fastcontext_explore(query="...", max_turns=4) + │ (MCP call over stdio) + ▼ +fastcontext-mcp process + ├─ loads FastContext-1.0-4B-mlx-4bit model + ├─ agent loop: Read / Glob / Grep in parallel + └─ returns file:line citations + │ + ▼ +Agent receives citations, can Read the specific lines +``` + +| Part | File | Role | +|------|------|------| +| Skill (this file) | `SKILL.md` | Tells the agent *when* and *how* to call the tool | +| MCP server | `fastcontext-mcp` binary | Runs the model, exposes `fastcontext_explore` as an MCP tool | + +If `fastcontext_explore` is not in your tool list, the MCP server isn't installed. See the `fastcontext-setup` skill to install it. + ## Prerequisite The `fastcontext` MCP server must be registered. If the `fastcontext_explore` tool is not available, run the setup skill first — `fastcontext-setup`. diff --git a/src/fastcontext_mcp/configwriter.py b/src/fastcontext_mcp/configwriter.py new file mode 100644 index 0000000..a8e5731 --- /dev/null +++ b/src/fastcontext_mcp/configwriter.py @@ -0,0 +1,177 @@ +"""MCP config and skill file installation utilities. + +Used by ``scripts/install.sh``. Extracted into a module so the logic is +testable without running the full install script (which requires network +access and a real venv). + +All functions accept an optional ``home`` parameter (defaulting to +``Path.home()``) so tests can point at a temporary directory. +""" + +from __future__ import annotations + +import json +import os +import tomllib +from pathlib import Path +from typing import Literal, Optional + +try: + import tomli_w +except ImportError: + tomli_w = None + +ToolName = Literal["claude", "opencode", "codex"] + +TOOL_CONFIGS: dict[str, dict] = { + "claude": { + "path": ".mcp.json", + "format": "json", + "container_key": "mcpServers", + "entry_name": "fastcontext", + }, + "opencode": { + "path": ".config/opencode/opencode.json", + "format": "json", + "container_key": "mcp", + "entry_name": "fastcontext", + }, + "codex": { + "path": ".codex/config.toml", + "format": "toml", + "container_key": "mcp_servers", + "entry_name": "fastcontext", + }, +} + +SKILL_NAMES = ("fastcontext", "fastcontext-setup") + +_SKILL_LINK_DIRS: dict[str, str] = { + "claude": ".claude/skills", + "opencode": ".config/opencode/skills", + # Note: Codex CLI does not have a skill directory system — it loads + # skills from the MCP server's instructions, so no symlink is needed. +} + + +def _config_path(tool: str, home: Path) -> Path: + cfg = TOOL_CONFIGS[tool] + return home / cfg["path"] + + +def _mcp_entry(tool: str, command_path: str) -> dict: + if tool == "opencode": + return {"type": "local", "command": [command_path], "enabled": True} + return {"command": command_path, "args": []} + + +def write_mcp_config( + tool: ToolName, + command_path: str, + *, + home: Optional[Path] = None, +) -> bool: + """Write the MCP server entry into ``tool``'s config file. + + Returns True if a new entry was written, False if already present. + Raises ValueError if ``tool`` is unknown or tomli_w is missing for TOML. + """ + if tool not in TOOL_CONFIGS: + raise ValueError(f"Unknown tool: {tool!r}") + + home = home or Path.home() + cfg = TOOL_CONFIGS[tool] + path = _config_path(tool, home) + entry_name = cfg["entry_name"] + container_key = cfg["container_key"] + entry = _mcp_entry(tool, command_path) + + if cfg["format"] == "json": + path.parent.mkdir(parents=True, exist_ok=True) + if path.exists(): + content = path.read_text().strip() + config = json.loads(content) if content else {} + else: + config = {} + config.setdefault(container_key, {}) + if entry_name in config[container_key]: + return False + config[container_key][entry_name] = entry + path.write_text(json.dumps(config, indent=2) + "\n") + return True + + if cfg["format"] == "toml": + if tomli_w is None: + raise ValueError("tomli_w is required to write TOML config for codex") + if path.exists(): + config = tomllib.loads(path.read_text()) + else: + config = {} + config.setdefault(container_key, {}) + if entry_name in config[container_key]: + return False + config[container_key][entry_name] = entry + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(tomli_w.dumps(config)) + return True + + raise ValueError(f"Unknown format: {cfg['format']!r}") + + +def detect_installed_tools(*, home: Optional[Path] = None) -> list[str]: + """Return the list of agent tools that appear to be installed. + + Detection is based on the presence of their config directories/files + under ``home``. This mirrors the ``has_*`` shell functions in + ``install.sh``. + """ + home = home or Path.home() + found: list[str] = [] + if (home / ".claude").is_dir(): + found.append("claude") + if (home / ".config" / "opencode").is_dir(): + found.append("opencode") + if (home / ".codex").is_dir(): + found.append("codex") + return found + + +def install_skill_file( + skill_name: str, + content: str, + *, + home: Optional[Path] = None, + symlink: bool = True, +) -> Path: + """Write a skill's SKILL.md to the shared skills directory. + + Places the file at ``~/.agents/skills//SKILL.md`` and, when + ``symlink`` is True, creates relative symlinks from each detected agent + tool's skills directory (Claude, OpenCode) to the shared location. + + Returns the path to the written file. + Raises ValueError if ``skill_name`` is not a known FastContext skill. + """ + if skill_name not in SKILL_NAMES: + raise ValueError( + f"Unknown skill: {skill_name!r} (expected one of {SKILL_NAMES})" + ) + + home = home or Path.home() + target_dir = home / ".agents" / "skills" / skill_name + target_dir.mkdir(parents=True, exist_ok=True) + target_file = target_dir / "SKILL.md" + target_file.write_text(content) + + if symlink: + for tool, rel_path in _SKILL_LINK_DIRS.items(): + link_dir = home / rel_path + if not link_dir.exists(): + continue + link = link_dir / skill_name + rel = os.path.relpath(target_dir, link_dir) + if link.is_symlink() or link.exists(): + link.unlink() + link.symlink_to(rel) + + return target_file diff --git a/src/fastcontext_mcp/release.py b/src/fastcontext_mcp/release.py new file mode 100644 index 0000000..dc4aaf3 --- /dev/null +++ b/src/fastcontext_mcp/release.py @@ -0,0 +1,107 @@ +"""Resolve and download prebuilt wheel releases from GitHub. + +Used by ``scripts/install.sh`` to install a prebuilt wheel instead of +building from a git clone. Falls back to the git URL if no release is +available. + +The GitHub release is expected to have a wheel asset matching the pattern +``fastcontext_mcp--py3-none-any.whl`` attached to it. +""" + +from __future__ import annotations + +import json +import re +import urllib.request +from typing import Optional + +GITHUB_OWNER = "rubybear-lgtm" +GITHUB_REPO = "fastcontext" +RELEASES_API = f"https://api.github.com/repos/{GITHUB_OWNER}/{GITHUB_REPO}/releases" + +_WHEEL_RE = re.compile(r"fastcontext_mcp-[\w.]+-py3-none-any\.whl$") + + +class ReleaseError(Exception): + """Raised when a release or wheel cannot be found or downloaded.""" + + +def _api_get(url: str, *, timeout: float = 30) -> dict: + """Fetch JSON from the GitHub API.""" + req = urllib.request.Request(url, headers={ + "Accept": "application/vnd.github+json", + "User-Agent": "fastcontext-mcp-installer", + }) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def latest_release(*, timeout: float = 30) -> dict: + """Return the latest published release dict from GitHub. + + Includes draft/prereleases only if no stable release exists. + Raises ReleaseError if the API call fails or no release exists. + """ + try: + return _api_get(f"{RELEASES_API}/latest", timeout=timeout) + except Exception as e: + # No published releases yet — fall back to listing all releases + try: + releases = _api_get(RELEASES_API, timeout=timeout) + except Exception as list_err: + raise ReleaseError( + f"Could not fetch releases: latest={e}, list={list_err}" + ) from list_err + if not releases: + raise ReleaseError("No GitHub releases found") + return releases[0] + + +def find_wheel_asset(release: dict) -> Optional[dict]: + """Find the wheel asset in a release dict, or None if not present.""" + for asset in release.get("assets", []): + name = asset.get("name", "") + if _WHEEL_RE.search(name): + return asset + return None + + +def resolve_wheel_url(*, timeout: float = 30) -> Optional[tuple[str, str]]: + """Resolve the latest prebuilt wheel URL. + + Returns a tuple of (download_url, version) or None if no prebuilt + wheel is available (caller should fall back to git install). + """ + try: + release = latest_release(timeout=timeout) + except ReleaseError: + return None + + asset = find_wheel_asset(release) + if asset is None: + return None + + url = asset.get("browser_download_url") + if not url: + return None + + version = release.get("tag_name", "unknown") + return (url, version) + + +def download_wheel(url: str, dest_path: str, *, timeout: float = 120) -> str: + """Download a wheel from ``url`` to ``dest_path``. + + Returns the destination path. Raises ReleaseError on failure. + """ + try: + req = urllib.request.Request(url, headers={ + "User-Agent": "fastcontext-mcp-installer", + }) + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = resp.read() + with open(dest_path, "wb") as f: + f.write(data) + except Exception as e: + raise ReleaseError(f"Failed to download wheel from {url}: {e}") from e + return dest_path diff --git a/src/fastcontext_mcp/server.py b/src/fastcontext_mcp/server.py index b22bba4..6bac405 100644 --- a/src/fastcontext_mcp/server.py +++ b/src/fastcontext_mcp/server.py @@ -12,23 +12,23 @@ from pathlib import Path import mlx.core as mx -from mcp.server.fastmcp import FastMCP, Context as McpContext +from fastcontext.agent.agent import Agent +from fastcontext.agent.llm import Message, RequestyAPIError +from fastcontext.agent.tool.tool import ToolSet +from fastcontext.agent.utils import get_final_answer, load_system_prompt +from mcp.server.fastmcp import Context as McpContext +from mcp.server.fastmcp import FastMCP from mlx_lm import load from mlx_lm.generate import stream_generate from mlx_lm.models.cache import make_prompt_cache -from fastcontext.agent.agent import Agent -from fastcontext.agent.context import Context -from fastcontext.agent.llm import Message, RequestyAPIError -from fastcontext.agent.tool.tool import ToolSet -from fastcontext.agent.utils import load_system_prompt, get_final_answer from fastcontext_mcp._shared import parse_response MODEL_NAME = "rubybear/FastContext-1.0-4B-SFT-mlx-4bit-g32" print(f"[fastcontext-mcp] Loading model: {MODEL_NAME}") _mlx_model, _tokenizer = load(MODEL_NAME) -print(f"[fastcontext-mcp] Model loaded.") +print("[fastcontext-mcp] Model loaded.") mcp = FastMCP( "fastcontext", diff --git a/tests/test_configwriter.py b/tests/test_configwriter.py new file mode 100644 index 0000000..bb35cd0 --- /dev/null +++ b/tests/test_configwriter.py @@ -0,0 +1,214 @@ +"""Tests for fastcontext_mcp.configwriter — MCP config and skill file installation. + +These tests run against a temporary HOME directory so they don't touch the +user's real config files. +""" + +from __future__ import annotations + +import json +import tomllib +from pathlib import Path + +import pytest + +from fastcontext_mcp.configwriter import ( + SKILL_NAMES, + TOOL_CONFIGS, + detect_installed_tools, + install_skill_file, + write_mcp_config, +) + + +@pytest.fixture +def fake_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Point Path.home() and HOME env at a temporary directory.""" + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + return tmp_path + + +# --- write_mcp_config ---------------------------------------------------- + +class TestWriteMcpConfigClaude: + def test_creates_new_config(self, fake_home: Path): + result = write_mcp_config("claude", "/fake/venv/bin/fastcontext-mcp") + assert result is True + + config_path = fake_home / ".mcp.json" + assert config_path.exists() + config = json.loads(config_path.read_text()) + assert "mcpServers" in config + assert "fastcontext" in config["mcpServers"] + entry = config["mcpServers"]["fastcontext"] + assert entry["command"] == "/fake/venv/bin/fastcontext-mcp" + assert entry["args"] == [] + + def test_idempotent(self, fake_home: Path): + cmd = "/fake/venv/bin/fastcontext-mcp" + assert write_mcp_config("claude", cmd) is True + assert write_mcp_config("claude", cmd) is False + + config_path = fake_home / ".mcp.json" + config = json.loads(config_path.read_text()) + assert len(config["mcpServers"]) == 1 + + def test_preserves_existing_entries(self, fake_home: Path): + config_path = fake_home / ".mcp.json" + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(json.dumps({ + "mcpServers": { + "other-tool": {"command": "/bin/other", "args": []} + } + })) + + write_mcp_config("claude", "/fake/venv/bin/fastcontext-mcp") + + config = json.loads(config_path.read_text()) + assert "other-tool" in config["mcpServers"] + assert "fastcontext" in config["mcpServers"] + + +class TestWriteMcpConfigOpenCode: + def test_creates_new_config(self, fake_home: Path): + result = write_mcp_config("opencode", "/fake/venv/bin/fastcontext-mcp") + assert result is True + + config_path = fake_home / ".config" / "opencode" / "opencode.json" + assert config_path.exists() + config = json.loads(config_path.read_text()) + assert "mcp" in config + assert "fastcontext" in config["mcp"] + entry = config["mcp"]["fastcontext"] + assert entry["type"] == "local" + assert entry["command"] == ["/fake/venv/bin/fastcontext-mcp"] + assert entry["enabled"] is True + + def test_idempotent(self, fake_home: Path): + cmd = "/fake/venv/bin/fastcontext-mcp" + assert write_mcp_config("opencode", cmd) is True + assert write_mcp_config("opencode", cmd) is False + + +class TestWriteMcpConfigCodex: + def test_creates_new_config(self, fake_home: Path): + result = write_mcp_config("codex", "/fake/venv/bin/fastcontext-mcp") + assert result is True + + config_path = fake_home / ".codex" / "config.toml" + assert config_path.exists() + config = tomllib.loads(config_path.read_text()) + assert "mcp_servers" in config + assert "fastcontext" in config["mcp_servers"] + entry = config["mcp_servers"]["fastcontext"] + assert entry["command"] == "/fake/venv/bin/fastcontext-mcp" + assert entry["args"] == [] + + def test_idempotent(self, fake_home: Path): + cmd = "/fake/venv/bin/fastcontext-mcp" + assert write_mcp_config("codex", cmd) is True + assert write_mcp_config("codex", cmd) is False + + def test_preserves_existing_entries(self, fake_home: Path): + config_path = fake_home / ".codex" / "config.toml" + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text('[mcp_servers.other]\ncommand = "/bin/other"\nargs = []\n') + + write_mcp_config("codex", "/fake/venv/bin/fastcontext-mcp") + + config = tomllib.loads(config_path.read_text()) + assert "other" in config["mcp_servers"] + assert "fastcontext" in config["mcp_servers"] + + +class TestWriteMcpConfigErrors: + def test_unknown_tool_raises(self, fake_home: Path): + with pytest.raises(ValueError, match="Unknown tool"): + write_mcp_config("unknown", "/fake/path") + + def test_all_tools_in_configs(self): + assert set(TOOL_CONFIGS.keys()) == {"claude", "opencode", "codex"} + + +# --- detect_installed_tools ---------------------------------------------- + +class TestDetectInstalledTools: + def test_empty_home(self, fake_home: Path): + assert detect_installed_tools() == [] + + def test_detects_claude(self, fake_home: Path): + (fake_home / ".claude").mkdir() + assert detect_installed_tools() == ["claude"] + + def test_detects_opencode(self, fake_home: Path): + (fake_home / ".config" / "opencode").mkdir(parents=True) + assert detect_installed_tools() == ["opencode"] + + def test_detects_codex(self, fake_home: Path): + (fake_home / ".codex").mkdir() + assert detect_installed_tools() == ["codex"] + + def test_detects_all(self, fake_home: Path): + (fake_home / ".claude").mkdir() + (fake_home / ".config" / "opencode").mkdir(parents=True) + (fake_home / ".codex").mkdir() + assert detect_installed_tools() == ["claude", "opencode", "codex"] + + def test_order_is_consistent(self, fake_home: Path): + (fake_home / ".codex").mkdir() + (fake_home / ".claude").mkdir() + (fake_home / ".config" / "opencode").mkdir(parents=True) + assert detect_installed_tools() == ["claude", "opencode", "codex"] + + +# --- install_skill_file -------------------------------------------------- + +class TestInstallSkillFile: + def test_writes_skill_file(self, fake_home: Path): + content = "---\nname: test\n---\n# Test\n" + path = install_skill_file("fastcontext", content, symlink=False) + assert path == fake_home / ".agents" / "skills" / "fastcontext" / "SKILL.md" + assert path.read_text() == content + + def test_creates_symlinks_for_claude(self, fake_home: Path): + (fake_home / ".claude" / "skills").mkdir(parents=True) + content = "# Test\n" + install_skill_file("fastcontext", content) + + link = fake_home / ".claude" / "skills" / "fastcontext" + assert link.is_symlink() + assert link.resolve() == (fake_home / ".agents" / "skills" / "fastcontext").resolve() + + def test_creates_symlinks_for_opencode(self, fake_home: Path): + (fake_home / ".config" / "opencode" / "skills").mkdir(parents=True) + content = "# Test\n" + install_skill_file("fastcontext-setup", content) + + link = fake_home / ".config" / "opencode" / "skills" / "fastcontext-setup" + assert link.is_symlink() + assert link.resolve() == (fake_home / ".agents" / "skills" / "fastcontext-setup").resolve() + + def test_no_symlink_when_tool_absent(self, fake_home: Path): + content = "# Test\n" + install_skill_file("fastcontext", content) + + assert not (fake_home / ".claude" / "skills").exists() + assert not (fake_home / ".config" / "opencode" / "skills").exists() + + def test_overwrites_existing_symlink(self, fake_home: Path): + (fake_home / ".claude" / "skills").mkdir(parents=True) + link = fake_home / ".claude" / "skills" / "fastcontext" + link.symlink_to("/some/old/target") + + install_skill_file("fastcontext", "# New\n") + + assert link.is_symlink() + assert link.resolve() == (fake_home / ".agents" / "skills" / "fastcontext").resolve() + + def test_unknown_skill_raises(self, fake_home: Path): + with pytest.raises(ValueError, match="Unknown skill"): + install_skill_file("unknown-skill", "# content", symlink=False) + + def test_skill_names_are_canonical(self): + assert SKILL_NAMES == ("fastcontext", "fastcontext-setup") diff --git a/tests/test_install_script.py b/tests/test_install_script.py new file mode 100644 index 0000000..fbe28e7 --- /dev/null +++ b/tests/test_install_script.py @@ -0,0 +1,249 @@ +"""Tests for scripts/install.sh — syntax, flags, and help output. + +These are non-destructive tests that don't run the full install (which +requires network access and a real venv). They verify the script's +structure, argument parsing, and help text. +""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).parent.parent +INSTALL_SCRIPT = REPO_ROOT / "scripts" / "install.sh" + + +@pytest.fixture +def script_path() -> Path: + assert INSTALL_SCRIPT.exists(), "install.sh not found" + return INSTALL_SCRIPT + + +# --- Shell syntax -------------------------------------------------------- + +class TestShellSyntax: + def test_script_exists(self, script_path: Path): + assert script_path.is_file() + + def test_script_is_executable(self, script_path: Path): + assert os.access(script_path, os.X_OK), "install.sh should be executable" + + def test_bash_syntax_valid(self, script_path: Path): + result = subprocess.run( + ["bash", "-n", str(script_path)], + capture_output=True, text=True, + ) + assert result.returncode == 0, f"Syntax error:\n{result.stderr}" + + def test_has_shebang(self, script_path: Path): + first_line = script_path.read_text().splitlines()[0] + assert first_line.startswith("#!/bin/bash") or first_line.startswith("#!/usr/bin/env bash") + + def test_has_set_strict_mode(self, script_path: Path): + content = script_path.read_text() + assert "set -euo pipefail" in content, "Missing strict mode (set -euo pipefail)" + + +# --- Help flag ----------------------------------------------------------- + +class TestHelpFlag: + def test_help_exits_zero(self, script_path: Path): + result = subprocess.run( + ["bash", str(script_path), "--help"], + capture_output=True, text=True, + ) + assert result.returncode == 0 + assert "Usage:" in result.stdout + + def test_help_short_flag(self, script_path: Path): + result = subprocess.run( + ["bash", str(script_path), "-h"], + capture_output=True, text=True, + ) + assert result.returncode == 0 + assert "Usage:" in result.stdout + + def test_help_lists_options(self, script_path: Path): + result = subprocess.run( + ["bash", str(script_path), "--help"], + capture_output=True, text=True, + ) + for option in ["--target", "--install-skills", "--from-source", "--help"]: + assert option in result.stdout, f"Missing option in help: {option}" + + def test_help_lists_tools(self, script_path: Path): + result = subprocess.run( + ["bash", str(script_path), "--help"], + capture_output=True, text=True, + ) + for tool in ["claude", "opencode", "codex", "both", "auto"]: + assert tool in result.stdout, f"Missing tool in help: {tool}" + + +# --- Argument parsing ---------------------------------------------------- + +class TestArgumentParsing: + def test_unknown_flag_exits_nonzero(self, script_path: Path): + result = subprocess.run( + ["bash", str(script_path), "--bogus"], + capture_output=True, text=True, + ) + assert result.returncode != 0 + assert "Unknown flag" in result.stderr or "Unknown flag" in result.stdout + + def test_target_flag_accepted(self, script_path: Path): + """--target should be parsed before any installation begins. + + We can't run the full script, but we can verify the flag is accepted + by checking that the script doesn't reject it with 'Unknown flag'. + We use a timeout since the script will try to install things. + """ + try: + result = subprocess.run( + ["bash", str(script_path), "--target", "claude"], + capture_output=True, text=True, timeout=2, + ) + # It will either start installing (timeout) or proceed + assert "Unknown flag" not in result.stderr + except subprocess.TimeoutExpired: + # Script started running — flag was accepted + pass + + def test_install_skills_flag_accepted(self, script_path: Path): + try: + result = subprocess.run( + ["bash", str(script_path), "--install-skills"], + capture_output=True, text=True, timeout=2, + ) + assert "Unknown flag" not in result.stderr + except subprocess.TimeoutExpired: + pass + + def test_from_source_flag_accepted(self, script_path: Path): + try: + result = subprocess.run( + ["bash", str(script_path), "--from-source"], + capture_output=True, text=True, timeout=2, + ) + assert "Unknown flag" not in result.stderr + except subprocess.TimeoutExpired: + pass + + def test_target_equals_form(self, script_path: Path): + try: + result = subprocess.run( + ["bash", str(script_path), "--target=opencode"], + capture_output=True, text=True, timeout=2, + ) + assert "Unknown flag" not in result.stderr + except subprocess.TimeoutExpired: + pass + + +# --- Script content checks ----------------------------------------------- + +class TestScriptContent: + def test_uses_configwriter_module(self, script_path: Path): + content = script_path.read_text() + assert "fastcontext_mcp.configwriter" in content + + def test_has_install_skills_logic(self, script_path: Path): + content = script_path.read_text() + assert "INSTALL_SKILLS" in content + assert "--install-skills" in content + + def test_has_from_source_flag(self, script_path: Path): + content = script_path.read_text() + assert "--from-source" in content + assert "FROM_SOURCE" in content + + def test_uses_release_module_for_wheel(self, script_path: Path): + content = script_path.read_text() + # The bootstrap wheel resolution is inlined using stdlib only + # (no import from fastcontext_mcp) so it works on first install. + # The release module is used for programmatic use after install. + assert "api.github.com/repos/rubybear-lgtm/fastcontext/releases" in content + assert "fastcontext_mcp" in content # wheel name pattern + assert "WHEEL_RE" in content or "py3-none-any" in content + + def test_has_prebuilt_fallback_to_git(self, script_path: Path): + content = script_path.read_text() + assert "REPO_URL" in content + assert "falling back to git" in content or "building from git" in content + + def test_wheel_resolution_is_stdlib_only(self, script_path: Path): + """The bootstrap must not import from fastcontext_mcp (not installed yet).""" + content = script_path.read_text() + # Find the wheel resolution Python block + assert "import json, re, urllib.request, sys" in content + # Must NOT import fastcontext_mcp.release in the bootstrap phase + # (it's fine to use it after install in step 4) + bootstrap_section = content.split("uv pip install")[0] + assert "from fastcontext_mcp.release" not in bootstrap_section + + def test_uses_portable_mktemp(self, script_path: Path): + """mktemp must not use --suffix (not supported on macOS BSD mktemp).""" + content = script_path.read_text() + assert "mktemp --suffix" not in content + assert "mktemp -d" in content + + def test_has_all_five_steps(self, script_path: Path): + content = script_path.read_text() + for step in ["[1/5]", "[2/5]", "[3/5]", "[4/5]", "[5/5]"]: + assert step in content, f"Missing step label: {step}" + + def test_detects_all_three_tools(self, script_path: Path): + content = script_path.read_text() + for func in ["has_claude", "has_opencode", "has_codex"]: + assert func in content, f"Missing detection function: {func}" + + def test_has_verification_step(self, script_path: Path): + content = script_path.read_text() + assert "fastcontext_mcp.server" in content + assert "MCP server loads OK" in content + + def test_has_done_summary(self, script_path: Path): + content = script_path.read_text() + assert "=== Done ===" in content + assert "Restart your AI coding tool" in content + + +# --- README consistency -------------------------------------------------- + +class TestReadmeConsistency: + @pytest.fixture + def readme_content(self) -> str: + return (REPO_ROOT / "README.md").read_text() + + def test_has_architecture_section(self, readme_content: str): + assert "## Architecture" in readme_content or "# Architecture" in readme_content + + def test_has_install_section(self, readme_content: str): + assert "## Install" in readme_content or "# Install" in readme_content + + def test_mentions_npx_add_skill(self, readme_content: str): + assert "npx add skill" in readme_content + + def test_mentions_curl_install(self, readme_content: str): + assert "curl" in readme_content + assert "install.sh" in readme_content + + def test_mentions_all_three_tools(self, readme_content: str): + for tool in ["Claude", "OpenCode", "Codex"]: + assert tool in readme_content, f"Missing tool: {tool}" + + def test_has_config_file_paths(self, readme_content: str): + assert ".mcp.json" in readme_content + assert "opencode.json" in readme_content + assert "config.toml" in readme_content + + def test_has_requirements_section(self, readme_content: str): + assert "## Requirements" in readme_content or "Requirements" in readme_content + assert "Apple Silicon" in readme_content + + def test_has_two_part_explanation(self, readme_content: str): + assert "two-part" in readme_content.lower() or "MCP server" in readme_content diff --git a/tests/test_release.py b/tests/test_release.py new file mode 100644 index 0000000..997d12f --- /dev/null +++ b/tests/test_release.py @@ -0,0 +1,182 @@ +"""Tests for fastcontext_mcp.release — prebuilt wheel resolution. + +These tests don't hit the real GitHub API. They mock urllib to return +canned responses so the logic is tested deterministically. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from fastcontext_mcp.release import ( + GITHUB_OWNER, + GITHUB_REPO, + ReleaseError, + download_wheel, + find_wheel_asset, + latest_release, + resolve_wheel_url, +) + +# --- Constants ----------------------------------------------------------- + +class TestConstants: + def test_owner_is_set(self): + assert GITHUB_OWNER == "rubybear-lgtm" + + def test_repo_is_set(self): + assert GITHUB_REPO == "fastcontext" + + +# --- find_wheel_asset ---------------------------------------------------- + +class TestFindWheelAsset: + def test_finds_wheel(self): + release = { + "assets": [ + {"name": "source.tar.gz", "browser_download_url": "https://x/src.tar.gz"}, + {"name": "fastcontext_mcp-0.1.0-py3-none-any.whl", + "browser_download_url": "https://x/fastcontext_mcp-0.1.0-py3-none-any.whl"}, + ] + } + asset = find_wheel_asset(release) + assert asset is not None + assert asset["name"] == "fastcontext_mcp-0.1.0-py3-none-any.whl" + assert asset["browser_download_url"] == "https://x/fastcontext_mcp-0.1.0-py3-none-any.whl" + + def test_no_wheel_returns_none(self): + release = {"assets": [{"name": "source.tar.gz"}]} + assert find_wheel_asset(release) is None + + def test_empty_assets(self): + assert find_wheel_asset({"assets": []}) is None + + def test_missing_assets_key(self): + assert find_wheel_asset({}) is None + + def test_matches_versioned_wheel(self): + release = {"assets": [{"name": "fastcontext_mcp-1.2.3-py3-none-any.whl"}]} + asset = find_wheel_asset(release) + assert asset is not None + assert "1.2.3" in asset["name"] + + def test_does_not_match_non_py3_wheel(self): + release = {"assets": [{"name": "fastcontext_mcp-0.1.0-cp312-cp312-macosx_11_0_arm64.whl"}]} + # Only py3-none-any wheels are platform-independent and safe to distribute + assert find_wheel_asset(release) is None + + +# --- latest_release ------------------------------------------------------ + +class TestLatestRelease: + @patch("fastcontext_mcp.release._api_get") + def test_fetches_latest(self, mock_get: MagicMock): + mock_get.return_value = {"tag_name": "v0.1.0", "assets": []} + result = latest_release() + assert result["tag_name"] == "v0.1.0" + # Should call the /latest endpoint + assert mock_get.call_args[0][0].endswith("/releases/latest") + + @patch("fastcontext_mcp.release._api_get") + def test_falls_back_to_list(self, mock_get: MagicMock): + # First call (latest) raises, second (list) returns releases + mock_get.side_effect = [Exception("404 no latest"), [{"tag_name": "v0.0.9"}]] + result = latest_release() + assert result["tag_name"] == "v0.0.9" + + @patch("fastcontext_mcp.release._api_get") + def test_no_releases_raises(self, mock_get: MagicMock): + mock_get.side_effect = [Exception("404"), []] + with pytest.raises(ReleaseError, match="No GitHub releases"): + latest_release() + + @patch("fastcontext_mcp.release._api_get") + def test_both_calls_fail_raises(self, mock_get: MagicMock): + mock_get.side_effect = [Exception("net"), Exception("net")] + with pytest.raises(ReleaseError, match="Could not fetch releases"): + latest_release() + + +# --- resolve_wheel_url --------------------------------------------------- + +class TestResolveWheelUrl: + @patch("fastcontext_mcp.release.latest_release") + def test_returns_url_and_version(self, mock_latest: MagicMock): + mock_latest.return_value = { + "tag_name": "v0.1.0", + "assets": [{ + "name": "fastcontext_mcp-0.1.0-py3-none-any.whl", + "browser_download_url": "https://example.com/whl", + }], + } + url, version = resolve_wheel_url() + assert url == "https://example.com/whl" + assert version == "v0.1.0" + + @patch("fastcontext_mcp.release.latest_release") + def test_no_wheel_returns_none(self, mock_latest: MagicMock): + mock_latest.return_value = {"tag_name": "v0.1.0", "assets": []} + assert resolve_wheel_url() is None + + @patch("fastcontext_mcp.release.latest_release") + def test_missing_download_url_returns_none(self, mock_latest: MagicMock): + mock_latest.return_value = { + "tag_name": "v0.1.0", + "assets": [{"name": "fastcontext_mcp-0.1.0-py3-none-any.whl"}], + } + assert resolve_wheel_url() is None + + @patch("fastcontext_mcp.release.latest_release", side_effect=ReleaseError("nope")) + def test_release_error_returns_none(self, mock_latest: MagicMock): + assert resolve_wheel_url() is None + + +# --- download_wheel ------------------------------------------------------ + +class TestDownloadWheel: + @patch("fastcontext_mcp.release.urllib.request.urlopen") + def test_downloads_to_dest(self, mock_urlopen: MagicMock, tmp_path): + mock_resp = MagicMock() + mock_resp.read.return_value = b"wheel-bytes" + mock_urlopen.return_value.__enter__.return_value = mock_resp + + dest = str(tmp_path / "out.whl") + result = download_wheel("https://x/whl", dest) + assert result == dest + mock_urlopen.assert_called_once() + with open(dest, "rb") as f: + assert f.read() == b"wheel-bytes" + + @patch( + "fastcontext_mcp.release.urllib.request.urlopen", + side_effect=Exception("network error"), + ) + def test_failure_raises_release_error(self, mock_urlopen: MagicMock, tmp_path): + with pytest.raises(ReleaseError, match="Failed to download wheel"): + download_wheel("https://x/whl", str(tmp_path / "out.whl")) + + +# --- Integration: full resolve flow -------------------------------------- + +class TestResolveFlow: + @patch("fastcontext_mcp.release._api_get") + def test_full_flow_finds_wheel(self, mock_get: MagicMock): + """End-to-end: API returns a release with a wheel, we resolve it.""" + mock_get.return_value = { + "tag_name": "v0.2.0", + "assets": [{ + "name": "fastcontext_mcp-0.2.0-py3-none-any.whl", + "browser_download_url": "https://github.com/releases/v0.2.0/whl", + }], + } + url, version = resolve_wheel_url() + assert "github.com" in url + assert version == "v0.2.0" + + @patch("fastcontext_mcp.release._api_get") + def test_full_flow_no_wheel_falls_to_none(self, mock_get: MagicMock): + """End-to-end: API returns a release without a wheel.""" + mock_get.return_value = {"tag_name": "v0.1.0", "assets": []} + assert resolve_wheel_url() is None diff --git a/tests/test_skills.py b/tests/test_skills.py new file mode 100644 index 0000000..db7f273 --- /dev/null +++ b/tests/test_skills.py @@ -0,0 +1,192 @@ +"""Tests for SKILL.md content — frontmatter, required sections, and link consistency. + +Validates that the skill files are well-formed and contain the sections +needed for agent-driven onboarding. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +SKILLS_DIR = Path(__file__).parent.parent / "skills" +SETUP_SKILL = SKILLS_DIR / "fastcontext-setup" / "SKILL.md" +USAGE_SKILL = SKILLS_DIR / "fastcontext" / "SKILL.md" + +FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n", re.DOTALL) + + +def parse_frontmatter(text: str) -> dict[str, str]: + """Parse YAML frontmatter (simple key: value parsing).""" + match = FRONTMATTER_RE.match(text) + if not match: + return {} + fm = {} + for line in match.group(1).splitlines(): + if ":" in line: + key, _, value = line.partition(":") + fm[key.strip()] = value.strip() + return fm + + +@pytest.fixture +def setup_content() -> str: + return SETUP_SKILL.read_text() + + +@pytest.fixture +def usage_content() -> str: + return USAGE_SKILL.read_text() + + +# --- Frontmatter --------------------------------------------------------- + +class TestFrontmatter: + def test_setup_has_frontmatter(self, setup_content: str): + fm = parse_frontmatter(setup_content) + assert fm.get("name") == "fastcontext-setup" + assert "description" in fm + assert len(fm["description"]) > 20 + + def test_usage_has_frontmatter(self, usage_content: str): + fm = parse_frontmatter(usage_content) + assert fm.get("name") == "fastcontext" + assert "description" in fm + assert len(fm["description"]) > 20 + + def test_setup_description_mentions_install_triggers(self, setup_content: str): + fm = parse_frontmatter(setup_content) + desc = fm["description"].lower() + assert "install" in desc or "setup" in desc or "configure" in desc + + def test_usage_description_mentions_explore_triggers(self, usage_content: str): + fm = parse_frontmatter(usage_content) + desc = fm["description"].lower() + assert "explore" in desc or "codebase" in desc or "search" in desc + + +# --- Setup skill: agent-driven procedure --------------------------------- + +class TestSetupSkillAgentDriven: + def test_has_architecture_section(self, setup_content: str): + assert "Architecture" in setup_content + + def test_has_prerequisites(self, setup_content: str): + assert "Prerequisites" in setup_content + assert "Apple Silicon" in setup_content + + def test_has_installation_procedure(self, setup_content: str): + assert "Installation procedure" in setup_content + assert "agent-executed" in setup_content.lower() + + def test_instructs_agent_to_execute(self, setup_content: str): + """The skill must tell the AGENT to run commands, not the user.""" + assert "You (the AI agent)" in setup_content or "agent-executed" in setup_content + + def test_has_guard_check_step(self, setup_content: str): + assert "Guard check" in setup_content + assert "uname" in setup_content + + def test_has_already_installed_check(self, setup_content: str): + assert "already installed" in setup_content.lower() or "NOT_INSTALLED" in setup_content + + def test_has_permission_step(self, setup_content: str): + assert "permission" in setup_content.lower() or "Shall I proceed" in setup_content + + def test_has_install_script_command(self, setup_content: str): + assert "install.sh" in setup_content + assert "curl" in setup_content + + def test_has_verify_step(self, setup_content: str): + assert "Verify" in setup_content + assert "fastcontext_mcp.server" in setup_content + + def test_has_restart_instruction(self, setup_content: str): + assert "restart" in setup_content.lower() + assert "AI coding tool" in setup_content + + def test_has_manual_fallback(self, setup_content: str): + assert "Manual installation" in setup_content + assert "uv venv" in setup_content + + def test_has_troubleshooting(self, setup_content: str): + assert "Troubleshooting" in setup_content + + def test_mentions_all_three_tools(self, setup_content: str): + for tool in ["Claude Code", "OpenCode", "Codex CLI"]: + assert tool in setup_content, f"Missing tool: {tool}" + + def test_has_target_flag_options(self, setup_content: str): + for target in ["--target claude", "--target opencode", "--target codex"]: + assert target in setup_content, f"Missing target option: {target}" + + +# --- Usage skill: architecture docs -------------------------------------- + +class TestUsageSkillDocs: + def test_has_how_it_works_section(self, usage_content: str): + assert "How it works" in usage_content + + def test_explains_two_part_system(self, usage_content: str): + assert "two-part" in usage_content or "two-part system" in usage_content + + def test_mentions_mcp_server_role(self, usage_content: str): + assert "MCP server" in usage_content + + def test_mentions_skill_role(self, usage_content: str): + assert "skill" in usage_content.lower() + + def test_has_prerequisite_section(self, usage_content: str): + assert "Prerequisite" in usage_content + + def test_has_usage_section(self, usage_content: str): + assert "Usage" in usage_content or "fastcontext_explore" in usage_content + + def test_has_query_examples(self, usage_content: str): + assert "Query Examples" in usage_content or "query" in usage_content.lower() + + def test_has_max_turns_guidance(self, usage_content: str): + assert "max_turns" in usage_content + + def test_has_when_to_use(self, usage_content: str): + assert "When to use" in usage_content + + def test_has_when_not_to_use(self, usage_content: str): + assert "When NOT to use" in usage_content + + +# --- Link consistency ---------------------------------------------------- + +class TestLinkConsistency: + def test_install_url_matches_repo(self, setup_content: str): + assert "rubybear-lgtm/fastcontext" in setup_content + assert "raw.githubusercontent.com" in setup_content + + def test_venv_path_is_consistent(self, setup_content: str): + assert "~/.cache/fastcontext/venv" in setup_content + assert "fastcontext-mcp" in setup_content + + def test_config_paths_are_correct(self, setup_content: str): + assert "~/.mcp.json" in setup_content + assert "~/.config/opencode/opencode.json" in setup_content + assert "~/.codex/config.toml" in setup_content + + +# --- File structure ------------------------------------------------------ + +class TestSkillFileStructure: + def test_setup_skill_file_exists(self): + assert SETUP_SKILL.exists() + + def test_usage_skill_file_exists(self): + assert USAGE_SKILL.exists() + + def test_setup_skill_dir_name_matches_frontmatter(self, setup_content: str): + fm = parse_frontmatter(setup_content) + assert SETUP_SKILL.parent.name == fm["name"] + + def test_usage_skill_dir_name_matches_frontmatter(self, usage_content: str): + fm = parse_frontmatter(usage_content) + assert USAGE_SKILL.parent.name == fm["name"]