diff --git a/.flake8 b/.flake8 index 4bf6242..12548e8 100644 --- a/.flake8 +++ b/.flake8 @@ -1,29 +1,3 @@ [flake8] max-line-length = 100 -extend-ignore = - # whitespace before ':' (conflicts with black) - E203, - # line break before binary operator (conflicts with black) - W503, - # assert statements in tests - S101, - # subprocess calls with shell=False are safe (we explicitly use list args) - S603, - # start process with partial path (uv, git calls) - S607, - # hardcoded SQL string detection (handled by SENTINEL rules, not flake8) - S608 - -per-file-ignores = - tests/*: S101, S106, S105 - -exclude = - .git, - __pycache__, - .venv, - venv, - build, - dist, - *.egg-info, - .mypy_cache, - .ruff_cache +extend-ignore = E501 diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..4b391cb --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,112 @@ +name: SENTINEL CD + +on: + workflow_dispatch: + push: + branches: + - main + +jobs: + # ── 1. Build ───────────────────────────────────────────────────────────────── + build: + name: Build wheel & sdist + runs-on: ubuntu-latest + outputs: + version: ${{ steps.ver.outputs.version }} + version_changed: ${{ steps.ver.outputs.changed }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check if VERSION changed + id: ver + run: | + NULL_SHA="0000000000000000000000000000000000000000" + if [ "${{ github.event_name }}" = "push" ] && [ "${{ github.event.before }}" != "${NULL_SHA}" ]; then + RANGE="${{ github.event.before }}..${{ github.sha }}" + else + RANGE="HEAD~1..HEAD" + fi + if git diff --quiet "${RANGE}" -- VERSION; then + echo "changed=false" >> $GITHUB_OUTPUT + echo "version=$(cat VERSION | tr -d '[:space:]')" >> $GITHUB_OUTPUT + else + echo "changed=true" >> $GITHUB_OUTPUT + echo "version=$(cat VERSION | tr -d '[:space:]')" >> $GITHUB_OUTPUT + fi + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Ensure static manifests are version-synced + shell: bash + run: | + python scripts/sync_version.py + if [ -n "$(git status --porcelain)" ]; then + echo "::error::Static manifests are out of sync with VERSION. Run: python scripts/sync_version.py and commit the updates." + exit 1 + fi + + - name: Build package + run: uv build + + - name: Upload dist artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + retention-days: 7 + + # ── 2. Tag & GitHub Release ────────────────────────────────────────────────── + release: + name: Tag & GitHub Release + needs: build + if: needs.build.outputs.version_changed == 'true' + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Download dist artifacts + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Create and push tag + id: tag + run: | + TAG="v${{ needs.build.outputs.version }}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + if git ls-remote --tags origin | grep -q "refs/tags/${TAG}$"; then + echo "Tag ${TAG} already exists — skipping." + echo "created=false" >> $GITHUB_OUTPUT + else + git tag -a "${TAG}" -m "Release ${TAG}" + git push origin "${TAG}" + echo "created=true" >> $GITHUB_OUTPUT + fi + echo "tag=${TAG}" >> $GITHUB_OUTPUT + + - name: Create GitHub Release + if: steps.tag.outputs.created == 'true' + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.tag.outputs.tag }} + name: SENTINEL ${{ steps.tag.outputs.tag }} + generate_release_notes: true + files: dist/* diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..51f2011 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: SENTINEL CI + +on: + workflow_dispatch: + pull_request: + branches: + - main + +jobs: + ci: + name: Lint, Type-check & Test + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Ensure VERSION was bumped + if: github.event_name == 'pull_request' + shell: bash + run: | + SEMVER_REGEX='^[0-9]+\.[0-9]+\.[0-9]+([+-].+)?$' + BASE_VERSION="$(git show "${{ github.event.pull_request.base.sha }}:VERSION" | tr -d '[:space:]')" + HEAD_VERSION="$(git show "${{ github.event.pull_request.head.sha }}:VERSION" | tr -d '[:space:]')" + if ! [[ "$BASE_VERSION" =~ $SEMVER_REGEX ]]; then + echo "::error::Base VERSION is not valid semver: $BASE_VERSION" + exit 1 + fi + if ! [[ "$HEAD_VERSION" =~ $SEMVER_REGEX ]]; then + echo "::error::Head VERSION is not valid semver: $HEAD_VERSION" + exit 1 + fi + if [ "$BASE_VERSION" = "$HEAD_VERSION" ]; then + echo "::error::VERSION not bumped. Bump VERSION before merging." + exit 1 + fi + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Install dependencies + run: uv sync --group dev + + - name: Lint (ruff) + run: uv run ruff check sentinel + + - name: Format check (black) + run: uv run black --check sentinel + + - name: Type-check (mypy) + run: uv run mypy sentinel + + - name: Test + run: uv run pytest --cov=sentinel tests/ diff --git a/README.md b/README.md index 24f0cdb..4cca0e3 100644 --- a/README.md +++ b/README.md @@ -1,97 +1,275 @@ # SENTINEL -AI-powered contextual security auditing platform. Detects realistic vulnerabilities, exploit chains, and insecure architectural patterns through deep offensive-minded analysis. +**AI-powered contextual security auditing platform. Thinks like a red team operator, not a linter.** -## Architecture +[![CI](https://github.com/Wembie/Sentinel/actions/workflows/ci.yml/badge.svg)](https://github.com/Wembie/Sentinel/actions/workflows/ci.yml) +[![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/downloads/) +[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) +[![MCP](https://img.shields.io/badge/MCP-compatible-purple.svg)](https://modelcontextprotocol.io) + +--- + +SENTINEL is a security auditing engine that combines AST analysis, taint flow tracking, call graph traversal, and LLM-powered contextual reasoning to surface realistic vulnerabilities and exploit chains — not just grep hits. + +**Basic SAST finds `eval(user_input)`. SENTINEL finds the full path: HTTP param → deserialization → `eval` → RCE, explains why it's exploitable, and shows the attack chain.** + +--- + +## Install + +```bash +# macOS / Linux / WSL / Git Bash +curl -fsSL https://raw.githubusercontent.com/Wembie/Sentinel/main/install.sh | bash +# Windows (PowerShell) +irm https://raw.githubusercontent.com/Wembie/Sentinel/main/install.ps1 | iex ``` -sentinel/ -├── core/ -│ ├── engine.py # Top-level orchestrator; wires everything, drives pipeline -│ ├── pipeline.py # Composable async stage runner (Callable-based, not class hierarchy) -│ ├── context.py # AuditContext — mutable accumulation bus flowing through all stages -│ └── registry.py # Generic plugin/component registry with @register decorator -├── models/ -│ ├── finding.py # Finding, Severity, Confidence, Location, ExploitChainStep -│ ├── audit.py # AuditRequest, AuditResult, AuditSummary, AuditStatus -│ └── graph.py # GraphNode, GraphEdge, NodeType, EdgeType, TrustLevel -├── analyzers/ -│ └── base.py # Analyzer Protocol — structural typing, no inheritance required -├── graph/ -│ ├── backend.py # GraphBackend Protocol + NetworkXBackend (Neo4j-ready) -│ ├── builder.py # Builds context graph from parsed ASTs -│ └── queries.py # Trust boundary crossings, taint paths, privilege reachability -├── llm/ -│ ├── base.py # LLMProvider Protocol (provider-agnostic) -│ ├── claude.py # Anthropic Claude — prompt caching enabled -│ ├── openai.py # OpenAI backend -│ └── router.py # Provider instantiation from config -├── parsers/ -│ ├── base.py # Parser Protocol + language extension map -│ ├── project.py # File ingestion stage (async, respects limits) -│ └── treesitter.py # Tree-sitter AST extraction (graceful fallback) -├── rules/ -│ ├── base.py # Rule Protocol + RuleMetadata dataclass -│ ├── loader.py # Builtin + directory-based rule discovery -│ └── builtin/ -│ ├── injection.py # INJ-001 SQL injection, INJ-002 command injection -│ └── auth.py # AUTH-001 hardcoded secrets, AUTH-002 TLS disabled -├── reporting/ -│ ├── markdown_reporter.py # Human-readable findings report -│ ├── json_reporter.py # Machine-readable full result -│ └── sarif_reporter.py # SARIF 2.1.0 — GitHub Code Scanning compatible -├── api/ -│ ├── main.py # FastAPI app with lifespan engine init -│ └── routes/audit.py # POST /audit, GET /audit/{id}, GET /audit/{id}/report -├── tracing/tracer.py # AuditTracer with async span context manager -├── plugins/loader.py # External plugin discovery from directories -├── logging.py # structlog configuration (JSON in CI, console in TTY) -├── config.py # pydantic-settings — all config via SENTINEL_* env vars -└── cli.py # Typer CLI: sentinel audit, sentinel serve, sentinel rules -``` - -## Key Design Decisions - -**Protocol-based interfaces** — every subsystem boundary (Analyzer, Rule, Parser, LLMProvider, GraphBackend, ReportFormatter) is a `Protocol`. Plugins and third-party analyzers implement the interface structurally — no imports from sentinel base classes required. - -**AuditContext as accumulation bus** — a single mutable object flows through every pipeline stage. Stages are independent callables that read from and write to it. No shared state via class fields; no threading issues since the pipeline is sequential. - -**Pipeline as composed callables** — stages are `async def stage(ctx: AuditContext) -> None`. Adding a stage is `pipeline.add_stage("name", fn)`. No subclassing, no lifecycle hooks, no framework magic. - -**LLM as oracle, not driver** — structural analysis (AST + regex rules) pre-filters candidates first. LLM is invoked only at the enrichment stage for contextual reasoning over the top findings. Keeps costs predictable and analysis fast for LLM-disabled runs. - -**Graph as first-class citizen** — every function, class, endpoint, and data flow is a node. Trust levels annotate nodes. Edges carry taint markers. GraphQueries exposes traversal primitives for trust boundary analysis and user-input-to-sink path detection. - -**Flat config via pydantic-settings** — `SENTINEL_LLM_PROVIDER=claude`, `SENTINEL_LLM_API_KEY=...`. No nested delimiter complexity. Works cleanly in Docker, k8s, and `.env` files. - -## Quickstart + +Detects 30+ agents and registers for each automatically. One-line install, no manual config. + +**Install flags:** ```bash -# Install with uv -uv sync +bash install.sh --all # install + hooks + init +bash install.sh --with-hooks # add Claude Code SessionStart hook +bash install.sh --with-init # write agent rules to current project +bash install.sh --minimal # plugin/skills only, no hooks +bash install.sh --dry-run # preview without writing +bash install.sh --list # show detected agents +``` -# Run audit on a local codebase (no LLM) -uv run sentinel audit ./my-project --no-llm +**What gets installed:** +- SENTINEL as MCP server registered with your agents +- Agent rule files (`.cursor/rules/`, `.windsurf/rules/`, `.clinerules/`) +- Skills CLI entries for 30+ additional agents +- Claude Code hooks (with `--with-hooks`) for auto-activation at session start -# Run with Claude LLM enrichment -SENTINEL_LLM_API_KEY=your-key uv run sentinel audit ./my-project +**No API key required.** All structural analysis (AST, rules, graph) works offline. LLM enrichment is opt-in. -# Output as SARIF (GitHub Code Scanning) -uv run sentinel audit ./my-project -f sarif -o results.sarif +--- -# Start API server -uv run sentinel serve +## Quick Start -# List available rules -uv run sentinel rules +```bash +# Audit a codebase — no configuration needed +sentinel audit ./my-project --no-llm -# Run tests -uv run pytest +# With LLM enrichment (Claude) +SENTINEL_LLM_API_KEY=sk-ant-... sentinel audit ./my-project + +# Output SARIF for GitHub Code Scanning +sentinel audit ./my-project -f sarif -o results.sarif + +# Audit only the security diff on a PR +sentinel diff ./my-project --base main + +# List all detection rules +sentinel rules +``` + +First run works with zero setup. No `.env` to copy, no config to write. + +--- + +## MCP Integration + +SENTINEL runs as an MCP server — a local process your agent connects to and invokes as tools. Once registered, your agent calls `sentinel_audit`, `sentinel_trace`, and the rest exactly like any built-in tool. + +```bash +# Start the MCP server manually (usually handled by your agent) +sentinel-mcp + +# Or via uv (development) +uv run python -m sentinel.mcp +``` + +**Automatic registration** — the installer writes the MCP server config for each detected agent. For Claude Code: + +```json +{ + "mcpServers": { + "sentinel": { + "command": "uv", + "args": ["run", "--project", "~/.sentinel", "python", "-m", "sentinel.mcp"] + } + } +} +``` + +**Supported agents and runtimes:** + +| Category | Agents | +|----------|--------| +| Claude family | Claude Code, Claude Desktop | +| IDE agents | Cursor, Windsurf, Cline, Continue, Roo | +| Terminal | Codex, Aider, Aider-Desk | +| Web | Copilot, Devin, OpenHands, v0 | +| Skills CLI | 30+ additional agents via `npx -y skills add` | + +Configuration is portable — one install works across all agents without per-agent manual setup. + +--- + +## MCP Tools + +Thirteen tools covering the full offensive analysis lifecycle: + +| Tool | Purpose | +|------|---------| +| `sentinel_audit` | Full deep audit — AST, call graph, all rules, LLM enrichment | +| `sentinel_surface` | Fast attack surface map: endpoints, auth entry points, exposed data | +| `sentinel_trace` | Taint flow: user input → dangerous sinks (SQLi, RCE, SSRF) | +| `sentinel_attack_graph` | Trust boundary and privilege escalation graph (Mermaid output) | +| `sentinel_logic` | IDOR, BAC, unvalidated redirects, business logic flaws | +| `sentinel_review` | Deep single-file security review | +| `sentinel_verify` | Confirm or dismiss a specific finding | +| `sentinel_diff` | Security impact of a git diff — PR and commit auditing | +| `sentinel_harden` | Hardening checklist generated from live codebase scan | +| `sentinel_exploit_chain` | Full exploitation chain narrative for a specific finding | +| `sentinel_hunt` | Tag-focused scan: `injection`, `auth`, `secrets` | +| `sentinel_rules` | List all registered detection rules with metadata | +| `sentinel_report` | Retrieve a stored audit as markdown / json / sarif | + +**Typical workflows:** + +``` +# Full audit → taint trace → exploit chain → SARIF export +sentinel_audit(target="./") +sentinel_trace(audit_id="") +sentinel_exploit_chain(audit_id="", finding_id="") +sentinel_report(audit_id="", format="sarif") + +# PR security review +sentinel_diff(repo_path="./", base="main") +sentinel_verify(audit_id="", finding_id="") + +# Targeted injection hunt +sentinel_hunt(target="./", tags="injection,sqli") +sentinel_logic(audit_id="") +sentinel_attack_graph(audit_id="") +``` + +--- + +## Skills + +SENTINEL ships nine skills — structured prompts that give agents deep context on how to use each tool effectively. + +| Skill | Trigger | +|-------|---------| +| `sentinel-audit` | Full codebase security audit workflow | +| `sentinel-surface` | Attack surface enumeration | +| `sentinel-trace` | Taint flow and injection path analysis | +| `sentinel-attack-graph` | Trust boundary and privilege escalation | +| `sentinel-logic` | IDOR, BAC, business logic analysis | +| `sentinel-review` | Single-file deep review | +| `sentinel-diff` | PR / git diff security review | +| `sentinel-exploit-chain` | Exploitation chain narrative | +| `sentinel-harden` | Hardening recommendations | + +Skills are discovered automatically by the Skills CLI and compatible agents. Install via: + +```bash +npx -y skills add https://github.com/Wembie/Sentinel +``` + +In agents that support slash commands, invoke as `/sentinel-audit`, `/sentinel-diff`, etc. + +--- + +## Claude Code Hooks + +SENTINEL ships a `SessionStart` hook that auto-injects tool availability context at the start of every session — no slash command needed. + +```bash +# Install once +bash ~/.sentinel/hooks/install.sh + +# Windows +& "$HOME\.sentinel\hooks\install.ps1" +``` + +The hook checks if SENTINEL is registered as an MCP server and injects a tool reminder into the system prompt. A `🛡 SENTINEL` badge appears in the statusline when active. + +```bash +# Uninstall +bash ~/.sentinel/hooks/uninstall.sh +``` + +--- + +## Per-Project Setup + +Write SENTINEL agent rules into any project root: + +```bash +sentinel init +# or with uv: +uv run sentinel init +``` + +Writes: +- `.cursor/rules/sentinel.mdc` +- `.windsurf/rules/sentinel.md` +- `.clinerules/sentinel.md` +- Appends to `AGENTS.md` +- Appends to `.github/copilot-instructions.md` + +Safe to re-run (idempotent). Use `--force` to overwrite existing files. + +--- + +## Configuration + +All config via environment variables — no config file required. Sensible defaults work out of the box. + +| Variable | Default | Description | +|----------|---------|-------------| +| `SENTINEL_LLM_PROVIDER` | `none` | `claude` \| `openai` \| `none` | +| `SENTINEL_LLM_MODEL` | `claude-sonnet-4-6` | Model identifier | +| `SENTINEL_LLM_API_KEY` | — | API key (optional, LLM enrichment only) | +| `SENTINEL_LOG_LEVEL` | `INFO` | `DEBUG` \| `INFO` \| `WARNING` | +| `SENTINEL_MAX_FILE_SIZE_KB` | `512` | Per-file size cap | +| `SENTINEL_MAX_FILES_PER_AUDIT` | `1000` | File count cap per audit | +| `SENTINEL_RULES_DIRS` | — | Extra rule directories (colon-separated) | +| `SENTINEL_PLUGIN_DIRS` | — | Extra plugin directories | + +**Config file** (optional, higher priority than env vars): +`~/.config/sentinel/config.json` or `~/.sentinel/config.json` + +```json +{ + "llm_provider": "claude", + "llm_api_key": "sk-ant-...", + "log_level": "INFO" +} +``` + +--- + +## REST API + +```bash +# Start server +sentinel serve --port 8000 + +# Submit audit +curl -X POST http://localhost:8000/audit/ \ + -H 'Content-Type: application/json' \ + -d '{"target": "/path/to/project", "llm_enabled": false}' + +# Get result +curl http://localhost:8000/audit/{id} + +# Get report +curl "http://localhost:8000/audit/{id}/report?fmt=sarif" ``` +--- + ## Adding Rules -Create a Python file in `sentinel/rules/builtin/` or any directory in `SENTINEL_RULES_DIRS`: +Drop a Python file in `sentinel/rules/builtin/` or any directory in `SENTINEL_RULES_DIRS`: ```python from sentinel.rules.base import BaseRule, RuleMetadata @@ -100,66 +278,105 @@ from sentinel.models.finding import Finding, Severity, Confidence class MyRule(BaseRule): metadata = RuleMetadata( id="CUSTOM-001", - title="My Custom Rule", - severity="high", - confidence="medium", - cwe_ids=["CWE-XXX"], + title="Unsafe deserialization", + severity="critical", + confidence="high", + cwe_ids=["CWE-502"], languages=["python"], ) async def match(self, ctx): findings = [] - # inspect ctx.file_contents, ctx.parsed_files, ctx.graph_nodes + for path, content in ctx.file_contents.items(): + if "pickle.loads" in content: + findings.append(Finding(...)) return findings ``` -## Adding Analyzers +Rules implement a `Protocol` — no imports from sentinel base classes required at runtime. -Implement the `Analyzer` protocol and register via the engine's registry: +--- -```python -from sentinel.analyzers.base import BaseAnalyzer +## Architecture + +``` +sentinel/ +├── core/ +│ ├── engine.py # Orchestrator — wires subsystems, drives pipeline +│ ├── pipeline.py # Composable async stage runner +│ ├── context.py # AuditContext — accumulation bus across all stages +│ └── registry.py # Generic plugin registry with @register decorator +├── models/ # Finding, AuditRequest/Result, GraphNode/Edge (Pydantic) +├── graph/ # NetworkX call graph — trust boundaries, taint paths +├── llm/ # LLMProvider Protocol + Claude and OpenAI backends +├── parsers/ # File ingestion + tree-sitter AST extraction +├── rules/ # Rule Protocol + builtin rules (injection, auth) +├── reporting/ # Markdown, JSON, SARIF 2.1.0 reporters +├── api/ # FastAPI REST API with lifespan engine init +├── tracing/ # AuditTracer with async span context manager +├── plugins/ # External plugin discovery from directories +└── mcp.py # MCP server — all 13 tools exposed as MCP endpoints +``` + +**Design principles:** + +- **Protocol-based interfaces** — every boundary (Rule, Parser, Analyzer, LLMProvider, ReportFormatter) is a structural `Protocol`. Plugins implement the interface without importing from sentinel. +- **AuditContext as accumulation bus** — single mutable object flows through every stage. No shared class state, no threading issues. +- **LLM as oracle, not driver** — AST + rule analysis pre-filters candidates first. LLM enrichment is opt-in and cost-predictable. +- **Graph as first-class citizen** — functions, classes, endpoints, and data flows are nodes. Trust levels annotate nodes. Edges carry taint markers. + +--- + +## Audit Pipeline -class MyAnalyzer(BaseAnalyzer): - name = "my-analyzer" - description = "Does X" - supported_languages = ["python"] +| Stage | What happens | +|-------|-------------| +| `ingest` | Reads source files into `ctx.file_contents` | +| `parse_ast` | Runs tree-sitter, stores ASTs in `ctx.parsed_files` | +| `build_graph` | Builds call graph of files/functions/classes | +| `run_rules` | Runs all rules concurrently, collects findings | +| `llm_enrich` | (opt-in) LLM contextual reasoning over top findings | - async def analyze(self, ctx): - # write findings to ctx.add_finding(...) - pass +--- + +## Development + +```bash +# Clone and install with dev dependencies +git clone https://github.com/Wembie/Sentinel +cd Sentinel +uv sync --group dev + +# Run tests +uv run pytest + +# Run tests with coverage +uv run pytest --cov=sentinel + +# Lint +uv run ruff check sentinel/ +uv run black --check sentinel/ +uv run mypy sentinel/ + +# List builtin rules +uv run sentinel rules ``` -## Audit Pipeline Stages +Python 3.11+ required. All tooling managed via [uv](https://docs.astral.sh/uv/). -| Stage | Description | -|---|---| -| `ingest` | Reads all source files into `ctx.file_contents` | -| `parse_ast` | Runs tree-sitter on supported files, stores ASTs in `ctx.parsed_files` | -| `build_graph` | Builds context graph of files/functions/classes in NetworkX | -| `run_rules` | Runs all registered rules concurrently, collects findings | -| `llm_enrich` | (optional) LLM contextual reasoning over top findings | +--- -## SENTINEL Commands (API / CLI) +## Contributing -| Command | Description | -|---|---| -| `sentinel audit` | Full audit pipeline | -| `sentinel serve` | REST API server | -| `sentinel rules` | List registered rules | -| `POST /audit/` | Submit audit request | -| `GET /audit/{id}` | Get audit result | -| `GET /audit/{id}/report?fmt=sarif` | Formatted report | +1. Fork → branch → PR to `main` +2. New rules go in `sentinel/rules/builtin/` or as a documented pattern in the PR +3. New MCP tools: add to `sentinel/mcp.py` + add a corresponding `skills//SKILL.md` +4. CI runs ruff, mypy, black, and pytest on every PR -## Environment Variables +Issues and PRs welcome. Keep diffs small, findings clear, and test coverage honest. -| Variable | Default | Description | -|---|---|---| -| `SENTINEL_LLM_PROVIDER` | `claude` | `claude` / `openai` / `none` | -| `SENTINEL_LLM_MODEL` | `claude-sonnet-4-6` | Model name | -| `SENTINEL_LLM_API_KEY` | — | Provider API key | -| `SENTINEL_GRAPH_BACKEND` | `networkx` | `networkx` / `neo4j` | -| `SENTINEL_MAX_FILE_SIZE_KB` | `512` | Skip files larger than this | -| `SENTINEL_MAX_FILES_PER_AUDIT` | `1000` | Audit file cap | -| `SENTINEL_LOG_LEVEL` | `INFO` | Log level | -| `SENTINEL_SERVER_PORT` | `8000` | API server port | +--- + +## License + +MIT — audit freely, ship securely. diff --git a/VERSION b/VERSION index 7693c96..afaf360 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.3 \ No newline at end of file +1.0.0 \ No newline at end of file diff --git a/install.ps1 b/install.ps1 index b7114c7..94c2ff0 100644 --- a/install.ps1 +++ b/install.ps1 @@ -2,585 +2,527 @@ .SYNOPSIS SENTINEL Installer — Windows (PowerShell 5.1+) .DESCRIPTION - Installs SENTINEL, registers MCP servers, and optionally installs Claude Code hooks. -.PARAMETER Dev - Clone repository and install in editable mode (requires git). -.PARAMETER Upgrade - Pull latest changes and re-sync dependencies. -.PARAMETER Uninstall - Remove SENTINEL installation. -.PARAMETER DryRun - Print all actions without executing anything. -.PARAMETER List - List detected agents and exit. -.PARAMETER NoMcp - Skip MCP auto-registration. -.PARAMETER WithHooks - Install Claude Code session hooks (auto-activates SENTINEL at session start). -.PARAMETER WithInit - Run `sentinel init` in the current directory after install. -.PARAMETER All - Enable WithHooks and MCP registration for all detected agents. -.PARAMETER Minimal - Install package only; skip MCP registration, hooks, and init. -.PARAMETER Only - Register with a specific agent only (e.g. -Only claude). + Detects AI coding agents on your machine and registers SENTINEL as an MCP + server for each one. Safe to re-run — idempotent per agent. .EXAMPLE irm https://raw.githubusercontent.com/Wembie/Sentinel/main/install.ps1 | iex .\install.ps1 -DryRun -List .\install.ps1 -All - .\install.ps1 -WithHooks + .\install.ps1 -Only claude #> param( [switch]$Dev, [switch]$Upgrade, [switch]$Uninstall, [switch]$DryRun, - [switch]$List, - [switch]$NoMcp, + [switch]$Force, + [switch]$SkipSkills, [switch]$WithHooks, [switch]$WithInit, [switch]$All, [switch]$Minimal, - [string]$Only = "" + [switch]$List, + [switch]$NoColor, + [string[]]$Only = @() ) $ErrorActionPreference = "Stop" -# --all implies WithHooks -if ($All) { $WithHooks = $true } -# --minimal implies NoMcp -if ($Minimal) { $NoMcp = $true } +# ── Constants ────────────────────────────────────────────────────────────── +$Repo = "Wembie/Sentinel" +$RepoUrl = "https://github.com/$Repo" +$RepoArchive = "https://github.com/$Repo/archive/refs/heads/main.zip" +$InstallDir = if ($env:SENTINEL_HOME) { $env:SENTINEL_HOME } else { Join-Path $HOME ".sentinel" } +$BinDir = if ($env:SENTINEL_BIN) { $env:SENTINEL_BIN } else { Join-Path $HOME ".local\bin" } +$MinPyMajor = 3 +$MinPyMinor = 11 + +# ── Flag resolution ──────────────────────────────────────────────────────── +if ($All -and $Minimal) { Write-Error "error: -All and -Minimal are mutually exclusive"; exit 2 } +if ($All) { $WithHooks = $true; $WithInit = $true } +if ($Minimal) { $WithHooks = $false; $WithInit = $false; $SkipSkills = $true } +# Default: WithHooks ON (matches Caveman pattern) +if (-not $Minimal -and -not $WithHooks) { $WithHooks = $true } + +# ── Result trackers ──────────────────────────────────────────────────────── +$InstalledIds = [System.Collections.Generic.List[string]]::new() +$SkippedIds = [System.Collections.Generic.List[string]]::new() +$SkippedWhy = [System.Collections.Generic.List[string]]::new() +$FailedIds = [System.Collections.Generic.List[string]]::new() +$FailedWhy = [System.Collections.Generic.List[string]]::new() +$DetectedCount = 0 + +# ── Color helpers ────────────────────────────────────────────────────────── +function Write-Say { param([string]$M) Write-Host $M -ForegroundColor Blue } +function Write-Note { param([string]$M) Write-Host $M -ForegroundColor DarkGray } +function Write-Warn { param([string]$M) Write-Host $M -ForegroundColor Yellow } +function Write-Err { param([string]$M) Write-Host $M -ForegroundColor Red } +function Write-Ok { param([string]$M) Write-Host $M -ForegroundColor Green } -# ─── configuration ──────────────────────────────────────────────────────────── +function Invoke-Cmd { + param([string]$Display, [scriptblock]$Action) + if ($DryRun) { Write-Note " would run: $Display" } + else { Write-Host " $ $Display"; & $Action } +} -$RepoUrl = "https://github.com/Wembie/Sentinel" -$RepoArchive = "https://github.com/Wembie/Sentinel/archive/refs/heads/main.zip" -$InstallDir = if ($env:SENTINEL_HOME) { $env:SENTINEL_HOME } else { Join-Path $HOME ".sentinel" } -$BinDir = if ($env:SENTINEL_BIN) { $env:SENTINEL_BIN } else { Join-Path $HOME ".local\bin" } -$MinPyMajor = 3 -$MinPyMinor = 11 +# ── Helpers ──────────────────────────────────────────────────────────────── +function Test-Want { + param([string]$Id) + if ($Only.Count -eq 0) { return $true } + return $Only -contains $Id +} -# ─── helpers ────────────────────────────────────────────────────────────────── +function Test-Cmd { + param([string]$Cmd) + return ($null -ne (Get-Command $Cmd -ErrorAction SilentlyContinue)) +} -function Write-Info { param([string]$Msg) Write-Host "[SENTINEL] $Msg" -ForegroundColor Green } -function Write-Warn { param([string]$Msg) Write-Host "[SENTINEL] $Msg" -ForegroundColor Yellow } -function Write-Err { param([string]$Msg) Write-Host "[SENTINEL ERROR] $Msg" -ForegroundColor Red } -function Write-Cyan { param([string]$Msg) Write-Host $Msg -ForegroundColor Cyan } -function Fail { param([string]$Msg) Write-Err $Msg; exit 1 } +function Test-NodeNpx { + if ((Test-Cmd "node") -and (Test-Cmd "npx")) { return $true } + Write-Warn " node + npx required — install Node.js (https://nodejs.org) and re-run." + return $false +} -function Invoke-Cmd { - param([string]$Cmd, [string]$Desc = "") - if ($DryRun) { - Write-Host " [dry-run] $Cmd" -ForegroundColor Yellow - } else { - Invoke-Expression $Cmd +function Add-Installed { param([string]$Id) $InstalledIds.Add($Id) } +function Add-Skipped { param([string]$Id, [string]$Why) $SkippedIds.Add($Id); $SkippedWhy.Add($Why) } +function Add-Failed { param([string]$Id, [string]$Why) $FailedIds.Add($Id); $FailedWhy.Add($Why) } + +# ── Detection helpers ────────────────────────────────────────────────────── +function Test-VSCodeExt { + param([string]$Needle) + $roots = @( + (Join-Path $HOME ".vscode\extensions"), + (Join-Path $HOME ".cursor\extensions"), + (Join-Path $HOME ".windsurf\extensions") + ) + foreach ($r in $roots) { + if ((Test-Path $r) -and (Get-ChildItem $r -EA SilentlyContinue | Where-Object { $_.Name -ilike "*$Needle*" })) { + return $true + } } + return $false } -function Test-Command { - param([string]$Cmd) - $null = Get-Command $Cmd -ErrorAction SilentlyContinue - return $? +function Test-JetbrainsPlugin { + param([string]$Needle) + $roots = @( + (Join-Path $env:APPDATA "JetBrains"), + (Join-Path $HOME ".config\JetBrains") + ) + foreach ($r in $roots) { + if ((Test-Path $r) -and (Get-ChildItem $r -Recurse -Depth 4 -Directory -EA SilentlyContinue | Where-Object { $_.Name -ilike "*$Needle*" })) { + return $true + } + } + return $false +} + +# Parse a detection spec "command:foo||dir:~/.x" and return true if any clause matches. +function Test-DetectMatch { + param([string]$Spec) + $clauses = $Spec -split '\|\|' + foreach ($clause in $clauses) { + $clause = $clause.Trim() + if ($clause -match '^command:(.+)$') { + if (Test-Cmd $Matches[1]) { return $true } + } elseif ($clause -match '^dir:(.+)$') { + $expanded = $Matches[1] -replace '^\$HOME', $HOME + if (Test-Path $expanded -PathType Container) { return $true } + } elseif ($clause -match '^file:(.+)$') { + $expanded = $Matches[1] -replace '^\$HOME', $HOME + if (Test-Path $expanded -PathType Leaf) { return $true } + } elseif ($clause -match '^vscode-ext:(.+)$') { + if (Test-VSCodeExt $Matches[1]) { return $true } + } elseif ($clause -match '^jetbrains-plugin:(.+)$') { + if (Test-JetbrainsPlugin $Matches[1]) { return $true } + } + } + return $false } +# ── Provider matrix ──────────────────────────────────────────────────────── +$Providers = @( + # id, label, skills-profile, detect-spec + [pscustomobject]@{ Id="claude"; Label="Claude Code"; Profile=""; Detect="command:claude||dir:$HOME\.claude" } + [pscustomobject]@{ Id="gemini"; Label="Gemini CLI"; Profile=""; Detect="command:gemini||dir:$HOME\.gemini" } + [pscustomobject]@{ Id="codex"; Label="Codex CLI"; Profile="codex"; Detect="command:codex||dir:$HOME\.codex" } + [pscustomobject]@{ Id="cursor"; Label="Cursor"; Profile="cursor"; Detect="command:cursor||dir:$HOME\.cursor" } + [pscustomobject]@{ Id="windsurf"; Label="Windsurf"; Profile="windsurf"; Detect="command:windsurf||dir:$HOME\.codeium\windsurf||dir:$HOME\.windsurf" } + [pscustomobject]@{ Id="cline"; Label="Cline"; Profile="cline"; Detect="vscode-ext:cline" } + [pscustomobject]@{ Id="copilot"; Label="GitHub Copilot"; Profile="github-copilot"; Detect="command:gh" } + [pscustomobject]@{ Id="continue"; Label="Continue"; Profile="continue"; Detect="vscode-ext:continue.continue||vscode-ext:continue" } + [pscustomobject]@{ Id="kilo"; Label="Kilo Code"; Profile="kilo"; Detect="vscode-ext:kilocode||dir:$HOME\.kilocode" } + [pscustomobject]@{ Id="roo"; Label="Roo Code"; Profile="roo"; Detect="vscode-ext:roo||vscode-ext:rooveterinaryinc.roo-cline" } + [pscustomobject]@{ Id="augment"; Label="Augment Code"; Profile="augment"; Detect="vscode-ext:augment||jetbrains-plugin:augment" } + [pscustomobject]@{ Id="aider-desk"; Label="Aider Desk"; Profile="aider-desk"; Detect="command:aider||dir:$HOME\.aider-desk" } + [pscustomobject]@{ Id="amp"; Label="Sourcegraph Amp"; Profile="amp"; Detect="command:amp" } + [pscustomobject]@{ Id="bob"; Label="IBM Bob"; Profile="bob"; Detect="command:bob||dir:$HOME\.bob" } + [pscustomobject]@{ Id="crush"; Label="Crush"; Profile="crush"; Detect="command:crush||dir:$HOME\.config\crush" } + [pscustomobject]@{ Id="devin"; Label="Devin"; Profile="devin"; Detect="command:devin||dir:$HOME\.config\devin" } + [pscustomobject]@{ Id="droid"; Label="Droid (Factory)"; Profile="droid"; Detect="command:droid||dir:$HOME\.factory" } + [pscustomobject]@{ Id="forgecode"; Label="ForgeCode"; Profile="forgecode"; Detect="command:forge||dir:$HOME\.forge" } + [pscustomobject]@{ Id="goose"; Label="Block Goose"; Profile="goose"; Detect="command:goose||dir:$HOME\.config\goose" } + [pscustomobject]@{ Id="iflow"; Label="iFlow CLI"; Profile="iflow-cli"; Detect="command:iflow||dir:$HOME\.iflow" } + [pscustomobject]@{ Id="junie"; Label="JetBrains Junie"; Profile="junie"; Detect="dir:$HOME\.junie||jetbrains-plugin:junie" } + [pscustomobject]@{ Id="kiro"; Label="Kiro CLI"; Profile="kiro-cli"; Detect="command:kiro||dir:$HOME\.kiro" } + [pscustomobject]@{ Id="mistral"; Label="Mistral Vibe"; Profile="mistral-vibe"; Detect="command:mistral||dir:$HOME\.vibe" } + [pscustomobject]@{ Id="openhands"; Label="OpenHands"; Profile="openhands"; Detect="command:openhands||dir:$HOME\.openhands" } + [pscustomobject]@{ Id="opencode"; Label="opencode"; Profile="opencode"; Detect="command:opencode||file:$HOME\.config\opencode\AGENTS.md" } + [pscustomobject]@{ Id="qwen"; Label="Qwen Code"; Profile="qwen-code"; Detect="command:qwen||dir:$HOME\.qwen" } + [pscustomobject]@{ Id="qoder"; Label="Qoder"; Profile="qoder"; Detect="dir:$HOME\.qoder" } + [pscustomobject]@{ Id="rovodev"; Label="Atlassian Rovo Dev"; Profile="rovodev"; Detect="command:rovodev||dir:$HOME\.rovodev" } + [pscustomobject]@{ Id="tabnine"; Label="Tabnine CLI"; Profile="tabnine-cli"; Detect="command:tabnine||dir:$HOME\.tabnine" } + [pscustomobject]@{ Id="trae"; Label="Trae"; Profile="trae"; Detect="command:trae||dir:$HOME\.trae" } + [pscustomobject]@{ Id="warp"; Label="Warp"; Profile="warp"; Detect="command:warp||dir:$HOME\.warp" } + [pscustomobject]@{ Id="replit"; Label="Replit Agent"; Profile="replit"; Detect="command:replit||dir:$HOME\.replit" } + [pscustomobject]@{ Id="antigravity"; Label="Google Antigravity"; Profile="antigravity"; Detect="dir:$HOME\.gemini\antigravity" } +) + +# ── --list mode ──────────────────────────────────────────────────────────── +if ($List) { + Write-Say "SENTINEL agent matrix" + Write-Host "" + Write-Host (" {0,-14} {1,-22} {2}" -f "ID", "AGENT", "INSTALL MECHANISM") + Write-Host (" {0,-14} {1,-22} {2}" -f "----", "-----", "-----------------") + foreach ($p in $Providers) { + $mech = if ($p.Profile) { "npx skills add ($($p.Profile))" } else { "native" } + if ($p.Id -eq "claude") { $mech = "claude mcp add" } + if ($p.Id -eq "gemini") { $mech = "gemini extensions install" } + Write-Host (" {0,-14} {1,-22} {2}" -f $p.Id, $p.Label, $mech) + } + Write-Host "" + Write-Note " Defaults: -WithHooks ON. -All turns on -WithInit. -Minimal turns both off." + Write-Host "" + exit 0 +} + +# ── Core install helpers ─────────────────────────────────────────────────── function Get-PythonCmd { - $candidates = @("python", "python3", "py") - foreach ($candidate in $candidates) { - if (Test-Command $candidate) { + foreach ($candidate in @("python", "python3", "py")) { + if (Test-Cmd $candidate) { $ver = & $candidate -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>$null if ($ver -match "^(\d+)\.(\d+)$") { - $major = [int]$Matches[1] - $minor = [int]$Matches[2] - if ($major -gt $MinPyMajor -or ($major -eq $MinPyMajor -and $minor -ge $MinPyMinor)) { + if ([int]$Matches[1] -gt $MinPyMajor -or ([int]$Matches[1] -eq $MinPyMajor -and [int]$Matches[2] -ge $MinPyMinor)) { return $candidate } } } } - Fail "Python $MinPyMajor.$MinPyMinor+ required. Download from https://python.org" + Write-Err "Python $MinPyMajor.$MinPyMinor+ required — https://python.org" + exit 1 } function Install-Uv { - if (Test-Command "uv") { - Write-Info "uv already installed: $(uv --version)" - return - } - Write-Info "Installing uv (Python package manager)..." - if ($DryRun) { - Write-Host " [dry-run] Invoke-WebRequest https://astral.sh/uv/install.ps1 | iex" -ForegroundColor Yellow - return - } + if (Test-Cmd "uv") { Write-Note " uv $(uv --version) already installed"; return } + Write-Say " -> installing uv..." + if ($DryRun) { Write-Note " would run: Invoke-WebRequest https://astral.sh/uv/install.ps1 | iex"; return } try { - $uvInstall = (Invoke-WebRequest -Uri "https://astral.sh/uv/install.ps1" -UseBasicParsing).Content - Invoke-Expression $uvInstall + $uvScript = (Invoke-WebRequest -Uri "https://astral.sh/uv/install.ps1" -UseBasicParsing).Content + Invoke-Expression $uvScript } catch { - Fail "Failed to install uv: $_`nInstall manually from https://docs.astral.sh/uv/getting-started/installation/" + Write-Err "Failed to install uv: $_"; exit 1 } $env:PATH = [System.Environment]::GetEnvironmentVariable("PATH", "User") + ";" + $env:PATH - if (-not (Test-Command "uv")) { - Write-Warn "uv installed but not on PATH yet. Restart terminal or add ~/.cargo/bin to PATH." - } else { - Write-Info "uv installed: $(uv --version)" - } + if (-not (Test-Cmd "uv")) { Write-Warn " uv installed but not on PATH yet — restart terminal" } } function Ensure-BinDir { - if (-not (Test-Path $BinDir)) { - New-Item -ItemType Directory -Path $BinDir -Force | Out-Null - } + if (-not (Test-Path $BinDir)) { New-Item -ItemType Directory -Path $BinDir -Force | Out-Null } $userPath = [System.Environment]::GetEnvironmentVariable("PATH", "User") if ($userPath -notlike "*$BinDir*") { - Write-Warn "$BinDir is not on PATH. Adding permanently..." [System.Environment]::SetEnvironmentVariable("PATH", "$userPath;$BinDir", "User") $env:PATH = "$env:PATH;$BinDir" - Write-Info "PATH updated. Changes take effect in new terminal sessions." + Write-Ok " PATH updated — effective in new terminal sessions" } } function Write-Wrapper { - if ($DryRun) { - Write-Host " [dry-run] write sentinel-mcp.cmd and sentinel-mcp.ps1 to $BinDir" -ForegroundColor Yellow - return - } - $wrapperPath = Join-Path $BinDir "sentinel-mcp.cmd" - $content = "@echo off`r`nuv run --project `"$InstallDir`" python -m sentinel.mcp %*" - Set-Content -Path $wrapperPath -Value $content -Encoding ASCII - Write-Info "Wrapper written: $wrapperPath" - + if ($DryRun) { Write-Note " would write sentinel-mcp.cmd + sentinel-mcp.ps1 to $BinDir"; return } + $cmdPath = Join-Path $BinDir "sentinel-mcp.cmd" + Set-Content -Path $cmdPath -Value "@echo off`r`nuv run --project `"$InstallDir`" python -m sentinel.mcp %*" -Encoding ASCII $ps1Path = Join-Path $BinDir "sentinel-mcp.ps1" Set-Content -Path $ps1Path -Value "uv run --project `"$InstallDir`" python -m sentinel.mcp @args" -Encoding UTF8 - Write-Info "PowerShell shim: $ps1Path" + Write-Ok " wrapper: $cmdPath" } -function Test-Install { - Write-Info "Validating installation..." - if ($DryRun) { - Write-Host " [dry-run] uv run --project $InstallDir python -c 'import sentinel'" -ForegroundColor Yellow - return - } - $result = uv run --project $InstallDir python -c "import sentinel; print('sentinel OK')" 2>&1 - if ($LASTEXITCODE -ne 0) { - Fail "Import validation failed: $result" - } - Write-Info "Import check passed." +function Test-SentinelInstall { + if ($DryRun) { Write-Note " would validate: import sentinel"; return } + $result = uv run --project $InstallDir python -c "import sentinel; print('OK')" 2>&1 + if ($LASTEXITCODE -ne 0) { Write-Err "import validation failed: $result"; exit 1 } + Write-Ok " import OK" } -# ─── agent detection ────────────────────────────────────────────────────────── - -$script:DetectedAgents = [System.Collections.Generic.List[string]]::new() - -function Detect-Agents { - Write-Info "Scanning for AI coding agents..." - $script:DetectedAgents.Clear() +function Write-Config { + $configDir = Join-Path $HOME ".config\sentinel" + $configFile = Join-Path $configDir "config.json" - $extDirs = @( - (Join-Path $HOME ".vscode\extensions"), - (Join-Path $HOME ".cursor\extensions"), - (Join-Path $HOME ".windsurf\extensions") - ) + if (Test-Path $configFile) { Write-Note " config exists at $configFile — skipping"; return } - # ── Native CLI agents ────────────────────────────────────────────────────── + $provider = ""; $apiKey = "" + if ($env:ANTHROPIC_API_KEY) { $provider = "claude"; $apiKey = $env:ANTHROPIC_API_KEY } + elseif ($env:OPENAI_API_KEY) { $provider = "openai"; $apiKey = $env:OPENAI_API_KEY } + else { return } - # Claude Code - if ((Test-Command "claude") -or (Test-Path (Join-Path $HOME ".claude"))) { - $script:DetectedAgents.Add("claude:Claude Code") - } + Write-Say " -> detected $provider API key — writing config" + if ($DryRun) { Write-Note " would write $configFile (llm_provider=$provider)"; return } - # Gemini CLI - if ((Test-Command "gemini") -or (Test-Path (Join-Path $HOME ".gemini"))) { - $script:DetectedAgents.Add("gemini:Gemini CLI") - } - - # OpenAI Codex CLI - if ((Test-Command "codex") -or (Test-Path (Join-Path $HOME ".codex"))) { - $script:DetectedAgents.Add("codex:Codex CLI") - } - - # GitHub Copilot CLI - if ((Test-Command "gh") -and (& gh extension list 2>$null | Select-String "gh-copilot")) { - $script:DetectedAgents.Add("copilot-cli:GitHub Copilot CLI") - } - - # Aider - if (Test-Command "aider") { - $script:DetectedAgents.Add("aider:Aider") - } - - # v0 (Vercel) - if (Test-Command "v0") { - $script:DetectedAgents.Add("v0:v0") - } - - # ── IDE editors ───────────────────────────────────────────────────────────── - - # Cursor - if ((Test-Command "cursor") -or (Test-Path (Join-Path $HOME ".cursor"))) { - $script:DetectedAgents.Add("cursor:Cursor") - } - - # Windsurf - $windsurfPaths = @( - (Join-Path $HOME ".codeium\windsurf"), - (Join-Path $HOME ".windsurf") - ) - $windsurfFound = $false - foreach ($p in $windsurfPaths) { - if (Test-Path $p) { $windsurfFound = $true; break } - } - if ($windsurfFound -or (Test-Command "windsurf")) { - $script:DetectedAgents.Add("windsurf:Windsurf") - } - - # VS Code - if (Test-Command "code") { - $script:DetectedAgents.Add("vscode:VS Code") - } + if (-not (Test-Path $configDir)) { New-Item -ItemType Directory -Path $configDir -Force | Out-Null } + [ordered]@{ llm_provider = $provider; llm_api_key = $apiKey } | ConvertTo-Json | Set-Content -Path $configFile -Encoding UTF8 + Write-Ok " config: $configFile" +} - # JetBrains - $jbPaths = @( - (Join-Path $env:APPDATA "JetBrains"), - (Join-Path $HOME ".config\JetBrains") - ) - foreach ($jbPath in $jbPaths) { - if (Test-Path $jbPath) { - $script:DetectedAgents.Add("jetbrains:JetBrains") - break - } - } +# ── Core installation ────────────────────────────────────────────────────── +function Invoke-CoreInstall { + param([bool]$DevMode = $false) - # Sourcegraph Amp - if ((Test-Command "amp") -or (Test-Path (Join-Path $HOME ".amp"))) { - $script:DetectedAgents.Add("amp:Sourcegraph Amp") - } + Write-Say "SENTINEL installer" + Write-Note " $RepoUrl" + if ($DryRun) { Write-Note " (dry run — nothing will be written)" } + Write-Host "" - # ── VS Code / Cursor extensions ────────────────────────────────────────────── + $pyCmd = Get-PythonCmd + Write-Note " python: $(& $pyCmd --version)" - # Cline - $clineAdded = $false - foreach ($extDir in $extDirs) { - if (-not $clineAdded -and (Test-Path $extDir) -and (Get-ChildItem $extDir -EA SilentlyContinue | Where-Object { $_.Name -like "saoudrizwan.claude-dev*" })) { - $script:DetectedAgents.Add("cline:Cline"); $clineAdded = $true - } - } + Install-Uv - # Continue - $continueAdded = $false - foreach ($extDir in $extDirs) { - if (-not $continueAdded -and (Test-Path $extDir) -and (Get-ChildItem $extDir -EA SilentlyContinue | Where-Object { $_.Name -like "continue.continue*" })) { - $script:DetectedAgents.Add("continue:Continue"); $continueAdded = $true + if ($DevMode) { + if (-not (Test-Cmd "git")) { Write-Err "-Dev requires git — https://git-scm.com"; exit 1 } + if (Test-Path (Join-Path $InstallDir ".git")) { + Write-Warn " $InstallDir already exists — use -Upgrade" + } else { + Write-Say " -> cloning to $InstallDir..." + if ($DryRun) { Write-Note " would run: git clone $RepoUrl $InstallDir" } + else { git clone $RepoUrl $InstallDir } } - } - - # Roo - $rooAdded = $false - foreach ($extDir in $extDirs) { - if (-not $rooAdded -and (Test-Path $extDir) -and (Get-ChildItem $extDir -EA SilentlyContinue | Where-Object { $_.Name -like "rooveterinaryinc.roo-cline*" })) { - $script:DetectedAgents.Add("roo:Roo"); $rooAdded = $true + } else { + if (Test-Path $InstallDir) { + Write-Warn " $InstallDir already exists — use -Upgrade or -Uninstall first" + } else { + Write-Say " -> downloading to $InstallDir..." + if ($DryRun) { + Write-Note " would download $RepoArchive -> $InstallDir" + } else { + New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null + $zipPath = Join-Path $env:TEMP "sentinel-main.zip" + try { + Invoke-WebRequest -Uri $RepoArchive -OutFile $zipPath -UseBasicParsing + Expand-Archive -Path $zipPath -DestinationPath $env:TEMP -Force + $extracted = Join-Path $env:TEMP "Sentinel-main" + Copy-Item -Recurse -Force "$extracted\*" $InstallDir + Remove-Item $zipPath, $extracted -Recurse -Force -EA SilentlyContinue + } catch { Write-Err "Download failed: $_"; exit 1 } + } } } - # ── AI coding platforms ────────────────────────────────────────────────────── - - # OpenHands - if ((Test-Command "openhands") -or (Test-Path (Join-Path $HOME ".openhands"))) { - $script:DetectedAgents.Add("openhands:OpenHands") - } + Write-Say " -> syncing dependencies..." + if ($DryRun) { Write-Note " would run: uv sync --project $InstallDir" } + else { uv sync --project $InstallDir } - # Devin - if (Test-Path (Join-Path $HOME ".devin")) { - $script:DetectedAgents.Add("devin:Devin") - } - - # Kode - if ((Test-Command "kode") -or (Test-Path (Join-Path $HOME ".kode"))) { - $script:DetectedAgents.Add("kode:Kode") - } - - # Aide - if ((Test-Command "aide") -or (Test-Path (Join-Path $HOME ".aide"))) { - $script:DetectedAgents.Add("aide:Aide") - } - - if ($script:DetectedAgents.Count -eq 0) { - Write-Warn "No AI coding agents detected." - Write-Warn "Install MCP manually using mcp.example.json, or run: sentinel init" - return - } - - Write-Info "Detected agents:" - foreach ($entry in $script:DetectedAgents) { - $label = $entry.Split(":")[1] - Write-Host " OK $label" -ForegroundColor Green - } + Ensure-BinDir + Write-Wrapper + Test-SentinelInstall + Write-Config + Write-Host "" } -# ─── list mode ──────────────────────────────────────────────────────────────── +# ── Per-agent install functions ──────────────────────────────────────────── +function Install-Claude { + $script:DetectedCount++ + Write-Say "-> Claude Code detected" -function Invoke-List { - Write-Host "SENTINEL — Agent Detection" -ForegroundColor Cyan - Write-Host "" - Detect-Agents - Write-Host "" - Write-Host "Supported: Claude Code, Gemini CLI, Codex CLI, Copilot CLI, Aider, v0," -ForegroundColor Cyan - Write-Host " Cursor, Windsurf, VS Code, JetBrains, Amp, Cline, Continue," -ForegroundColor Cyan - Write-Host " Roo, OpenHands, Devin, Kode, Aide — plus any skills-CLI-compatible agent." -ForegroundColor Cyan -} + if (-not $Force -and (Test-Cmd "claude") -and (& claude mcp list 2>$null | Select-String -Quiet "^sentinel")) { + Write-Note " sentinel MCP already registered (-Force to re-register)" + Add-Skipped "claude" "already registered" + Write-Host ""; return + } -# ─── MCP registration ───────────────────────────────────────────────────────── - -function Register-Mcp { - param([string]$InstallPath) - if ($NoMcp) { return } - if ($script:DetectedAgents.Count -eq 0) { Detect-Agents } - - $registered = [System.Collections.Generic.List[string]]::new() - $manualAgents = [System.Collections.Generic.List[string]]::new() - - foreach ($entry in $script:DetectedAgents) { - $agentId = $entry.Split(":")[0] - $agentLabel = $entry.Split(":")[1] - - if ($Only -and $agentId -ne $Only) { continue } - - switch ($agentId) { - "claude" { - if (Test-Command "claude") { - if ($DryRun) { - Write-Host " [dry-run] claude mcp add sentinel -- uv run --project `"$InstallPath`" python -m sentinel.mcp" -ForegroundColor Yellow - $registered.Add("$agentLabel (dry-run)") - } else { - try { - & claude mcp add sentinel -- uv run --project "$InstallPath" python -m sentinel.mcp 2>$null - Write-Info "Claude Code: MCP server registered." - $registered.Add($agentLabel) - } catch { - Write-Warn "Claude Code: auto-registration failed." - $manualAgents.Add($agentLabel) - } - } - } else { $manualAgents.Add($agentLabel) } - } - "gemini" { - if (Test-Command "gemini") { - if ($DryRun) { - Write-Host " [dry-run] gemini extensions install https://github.com/Wembie/Sentinel" -ForegroundColor Yellow - $registered.Add("$agentLabel (dry-run)") - } else { - try { - & gemini extensions install "https://github.com/Wembie/Sentinel" 2>$null - Write-Info "Gemini CLI: extension installed." - $registered.Add($agentLabel) - } catch { - Write-Warn "Gemini CLI: auto-install failed." - $manualAgents.Add($agentLabel) - } - } - } else { $manualAgents.Add($agentLabel) } - } - default { $manualAgents.Add($agentLabel) } + if (Test-Cmd "claude") { + try { + if ($DryRun) { Write-Note " would run: claude mcp add sentinel -- uv run --project `"$InstallDir`" python -m sentinel.mcp" } + else { & claude mcp add sentinel -- uv run --project "$InstallDir" python -m sentinel.mcp } + Write-Ok " MCP server registered" + Add-Installed "claude" + } catch { + Write-Warn " claude mcp add failed: $_" + Add-Failed "claude" "claude mcp add failed" } + } else { + Write-Warn " claude CLI not found — add MCP server manually:" + Write-Note " command: uv" + Write-Note " args: [`"run`", `"--project`", `"$InstallDir`", `"python`", `"-m`", `"sentinel.mcp`"]" + Add-Failed "claude" "claude CLI not on PATH" } - # Skills CLI fallback - if (-not $Minimal -and -not $Only -and (Test-Command "npx")) { - Write-Info "Running skills CLI registration (covers all skills-compatible agents)..." - if ($DryRun) { - Write-Host " [dry-run] npx -y skills add https://github.com/Wembie/Sentinel" -ForegroundColor Yellow - } else { - try { - npx -y skills add "https://github.com/Wembie/Sentinel" 2>$null - Write-Info "Skills CLI: SENTINEL registered." - } catch { - Write-Warn "Skills CLI registration failed (non-fatal)." + if ($WithHooks) { + Write-Say " -> installing Claude Code hooks..." + $hooksInstaller = Join-Path $InstallDir "hooks\install.ps1" + if (Test-Path $hooksInstaller) { + if ($DryRun) { Write-Note " would run: & `"$hooksInstaller`"" } + else { + try { & $hooksInstaller; Add-Installed "claude-hooks" } + catch { Write-Warn " hooks installer failed (non-fatal): $_"; Add-Failed "claude-hooks" "hooks/install.ps1 failed" } } + } else { + Write-Note " hooks installer not found — run: & `"$InstallDir\hooks\install.ps1`"" + Add-Skipped "claude-hooks" "installer not found" } } + Write-Host "" +} - if ($registered.Count -gt 0) { - Write-Host "" - Write-Info "Auto-registered: $($registered -join ', ')" - } +function Install-Gemini { + $script:DetectedCount++ + Write-Say "-> Gemini CLI detected" - if ($manualAgents.Count -gt 0) { - Write-Host "" - Write-Cyan "Manual MCP config needed for: $($manualAgents -join ', ')" - Write-Host "" - Write-Host " Add to your editor's MCP server settings:" -ForegroundColor Cyan - Write-Host @" - { - "command": "uv", - "args": ["run", "--project", "$($InstallPath.Replace('\','\\'))", "python", "-m", "sentinel.mcp"] + if (-not $Force -and (& gemini extensions list 2>$null | Select-String -Quiet "sentinel")) { + Write-Note " sentinel extension already installed (-Force to reinstall)" + Add-Skipped "gemini" "already installed" + Write-Host ""; return } -"@ - Write-Host "" - Write-Host " See $InstallPath\mcp.example.json for editor-specific examples." -ForegroundColor Cyan - Write-Host " Or run: sentinel init (in any project root) to drop rule files." -ForegroundColor Cyan + + try { + if ($DryRun) { Write-Note " would run: gemini extensions install $RepoUrl" } + else { & gemini extensions install $RepoUrl } + Add-Installed "gemini" + } catch { + Add-Failed "gemini" "gemini extensions install failed" } + Write-Host "" } -# ─── hooks installation ─────────────────────────────────────────────────────── +function Install-ViaSkills { + param([string]$Id, [string]$Label, [string]$Profile) + $script:DetectedCount++ + Write-Say "-> $Label detected" -function Install-Hooks { - param([string]$InstallPath) - $hooksInstaller = Join-Path $InstallPath "hooks\install.ps1" - if (-not (Test-Path $hooksInstaller)) { - Write-Warn "Hooks installer not found at $hooksInstaller — skipping." - return - } - Write-Info "Installing Claude Code hooks..." - if ($DryRun) { - Write-Host " [dry-run] & `"$hooksInstaller`" -DryRun" -ForegroundColor Yellow - } else { - try { - & $hooksInstaller - } catch { - Write-Warn "Hooks installation failed (non-fatal): $_" - } + if (-not (Test-NodeNpx)) { Add-Failed $Id "node/npx missing"; Write-Host ""; return } + + try { + if ($DryRun) { Write-Note " would run: npx -y skills add $RepoUrl -a $Profile" } + else { npx -y skills add $RepoUrl -a $Profile } + Add-Installed $Id + } catch { + Add-Failed $Id "npx skills add (profile: $Profile) failed" } + Write-Host "" } -# ─── uninstall ──────────────────────────────────────────────────────────────── - +# ── Uninstall ────────────────────────────────────────────────────────────── function Invoke-Uninstall { - Write-Host "Uninstalling SENTINEL..." -ForegroundColor Cyan + Write-Say "SENTINEL uninstall" if (Test-Path $InstallDir) { - if ($DryRun) { - Write-Host " [dry-run] Remove-Item -Recurse -Force $InstallDir" -ForegroundColor Yellow - } else { - Remove-Item -Recurse -Force $InstallDir - Write-Info "Removed $InstallDir" - } - } else { - Write-Warn "Installation directory not found: $InstallDir" - } + if ($DryRun) { Write-Note " would remove $InstallDir" } + else { Remove-Item -Recurse -Force $InstallDir; Write-Ok " removed $InstallDir" } + } else { Write-Warn " $InstallDir not found" } foreach ($f in @("sentinel-mcp.cmd", "sentinel-mcp.ps1")) { $p = Join-Path $BinDir $f if (Test-Path $p) { - if ($DryRun) { - Write-Host " [dry-run] Remove-Item $p" -ForegroundColor Yellow - } else { - Remove-Item $p - Write-Info "Removed $p" - } + if ($DryRun) { Write-Note " would remove $p" } + else { Remove-Item $p; Write-Ok " removed $p" } } } - Write-Info "SENTINEL uninstalled." + Write-Ok " done." } -# ─── upgrade ────────────────────────────────────────────────────────────────── - +# ── Upgrade ──────────────────────────────────────────────────────────────── function Invoke-Upgrade { if (-not (Test-Path (Join-Path $InstallDir ".git"))) { - Fail "Upgrade requires a git-cloned install (-Dev). Use -Uninstall then re-run." - } - Write-Host "Upgrading SENTINEL..." -ForegroundColor Cyan - if ($DryRun) { - Write-Host " [dry-run] git -C $InstallDir pull --ff-only" -ForegroundColor Yellow - Write-Host " [dry-run] uv sync --project $InstallDir" -ForegroundColor Yellow - } else { - git -C $InstallDir pull --ff-only - uv sync --project $InstallDir + Write-Err "upgrade requires a -Dev install (git repo at $InstallDir)"; exit 1 } - Test-Install - Write-Info "SENTINEL upgraded." + Write-Say "SENTINEL upgrade" + if ($DryRun) { Write-Note " would run: git pull + uv sync" } + else { git -C $InstallDir pull --ff-only; uv sync --project $InstallDir } + Test-SentinelInstall + Write-Ok " upgraded." } -# ─── install ────────────────────────────────────────────────────────────────── +# ── Dispatch ─────────────────────────────────────────────────────────────── +if ($Uninstall) { Invoke-Uninstall; exit 0 } +elseif ($Upgrade) { Invoke-Upgrade; exit 0 } +elseif ($Dev) { Invoke-CoreInstall -DevMode $true } +else { Invoke-CoreInstall -DevMode $false } -function Invoke-Install { - param([bool]$DevMode = $false) +# ── Agent registration ───────────────────────────────────────────────────── +Write-Say "SENTINEL registering with detected agents..." +Write-Host "" - Write-Host "Installing SENTINEL..." -ForegroundColor Cyan - if ($DryRun) { Write-Warn "Dry-run mode — no changes will be made." } - Write-Host "" +foreach ($p in $Providers) { + if (-not (Test-Want $p.Id)) { continue } + if (-not (Test-DetectMatch $p.Detect)) { continue } - $pyCmd = Get-PythonCmd - Write-Info "Python: $(& $pyCmd --version)" - - Install-Uv + switch ($p.Id) { + "claude" { Install-Claude; break } + "gemini" { Install-Gemini; break } + default { Install-ViaSkills $p.Id $p.Label $p.Profile; break } + } +} - if ($DevMode) { - if (-not (Test-Command "git")) { - Fail "-Dev mode requires git. Install from https://git-scm.com" - } - if (Test-Path (Join-Path $InstallDir ".git")) { - Write-Warn "Git repo already exists at $InstallDir. Use -Upgrade to update." - } else { - Write-Info "Cloning repository to $InstallDir..." - if ($DryRun) { - Write-Host " [dry-run] git clone $RepoUrl $InstallDir" -ForegroundColor Yellow - } else { - git clone $RepoUrl $InstallDir - } - } - } else { - if (Test-Path $InstallDir) { - Write-Warn "$InstallDir already exists. Use -Upgrade or -Uninstall first." - } else { - Write-Info "Downloading SENTINEL to $InstallDir..." - if ($DryRun) { - Write-Host " [dry-run] download $RepoArchive -> $InstallDir" -ForegroundColor Yellow - } else { - New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null - $zipPath = Join-Path $env:TEMP "sentinel-main.zip" - try { - Invoke-WebRequest -Uri $RepoArchive -OutFile $zipPath -UseBasicParsing - Expand-Archive -Path $zipPath -DestinationPath $env:TEMP -Force - $extracted = Join-Path $env:TEMP "Sentinel-main" - Copy-Item -Recurse -Force "$extracted\*" $InstallDir - Remove-Item $zipPath, $extracted -Recurse -Force -ErrorAction SilentlyContinue - } catch { - Fail "Download failed: $_" - } - } - } +# ── Generic npx skills fallback ──────────────────────────────────────────── +if (-not $SkipSkills -and $Only.Count -eq 0 -and $script:DetectedCount -eq 0) { + Write-Say "-> no agents detected — running npx skills auto-detect fallback" + if (Test-NodeNpx) { + try { + if ($DryRun) { Write-Note " would run: npx -y skills add $RepoUrl" } + else { npx -y skills add $RepoUrl } + Add-Installed "skills-auto" + } catch { Add-Failed "skills-auto" "npx skills add (auto) failed" } } + Write-Host "" +} - Write-Info "Syncing dependencies..." - if ($DryRun) { - Write-Host " [dry-run] uv sync --project $InstallDir" -ForegroundColor Yellow - } else { - uv sync --project $InstallDir +# ── --with-init ──────────────────────────────────────────────────────────── +if ($WithInit) { + Write-Say "-> writing per-project rule files into $PWD (-WithInit)" + if ($DryRun) { Write-Note " would run: uv run --project $InstallDir sentinel init ." } + else { + try { + $initArgs = @(".") + if ($Force) { $initArgs += "--force" } + uv run --project $InstallDir sentinel init @initArgs + Add-Installed "sentinel-init ($PWD)" + } catch { Add-Failed "sentinel-init" "sentinel init failed" } } + Write-Host "" +} elseif ($InstalledIds.Count -gt 0 -or $SkippedIds.Count -gt 0) { + Write-Note " tip: re-run with -All (or -WithInit) to also write per-project IDE rule files." +} - Ensure-BinDir - Write-Wrapper - Test-Install +# ── Summary ──────────────────────────────────────────────────────────────── +Write-Host "" +Write-Say "SENTINEL done" +Write-Host "" - Detect-Agents - Register-Mcp -InstallPath $InstallDir +if ($InstalledIds.Count -gt 0) { + Write-Ok " installed:" + $InstalledIds | ForEach-Object { Write-Host " * $_" } +} - # Optional: install Claude Code hooks - if ($WithHooks) { - Install-Hooks -InstallPath $InstallDir +if ($SkippedIds.Count -gt 0) { + Write-Host " skipped:" + for ($i = 0; $i -lt $SkippedIds.Count; $i++) { + Write-Host " * $($SkippedIds[$i]) -- $($SkippedWhy[$i])" } +} - # Optional: run sentinel init in current directory - if ($WithInit) { - Write-Info "Running sentinel init in current directory..." - if ($DryRun) { - Write-Host " [dry-run] uv run --project $InstallDir sentinel init ." -ForegroundColor Yellow - } else { - uv run --project $InstallDir sentinel init . - } +if ($FailedIds.Count -gt 0) { + Write-Warn " failed:" + for ($i = 0; $i -lt $FailedIds.Count; $i++) { + Write-Host " * $($FailedIds[$i]) -- $($FailedWhy[$i])" -ForegroundColor Red } +} - Write-Host "" - Write-Host "SENTINEL installed successfully!" -ForegroundColor Green - Write-Host "" - Write-Host " Start MCP server: sentinel-mcp" - Write-Host " Run audit: uv run --project `"$InstallDir`" sentinel audit .\" - Write-Host " List rules: uv run --project `"$InstallDir`" sentinel rules" - Write-Host " Per-project setup: uv run --project `"$InstallDir`" sentinel init" - Write-Host " Install hooks: & `"$InstallDir\hooks\install.ps1`"" - Write-Host "" +if ($InstalledIds.Count -eq 0 -and $SkippedIds.Count -eq 0 -and $FailedIds.Count -eq 0) { + Write-Note " nothing detected -- run 'install.ps1 -List' for all supported agents" + Write-Note " or pass -Only to force a specific target." } -# ─── dispatch ───────────────────────────────────────────────────────────────── +Write-Host "" +Write-Note " start an audit: uv run --project `"$InstallDir`" sentinel audit ." +Write-Note " per-project setup: sentinel init" -if ($List) { Invoke-List } -elseif ($Uninstall) { Invoke-Uninstall } -elseif ($Upgrade) { Invoke-Upgrade } -elseif ($Dev) { Invoke-Install -DevMode $true } -else { Invoke-Install -DevMode $false } +# Exit non-zero only when every detected agent failed. +if ($script:DetectedCount -gt 0 -and $InstalledIds.Count -eq 0 -and $SkippedIds.Count -eq 0) { + exit 1 +} +exit 0 diff --git a/install.sh b/install.sh index ba225d3..994e69a 100644 --- a/install.sh +++ b/install.sh @@ -1,554 +1,737 @@ #!/usr/bin/env bash # SENTINEL Installer — macOS / Linux / WSL / Git Bash -# Usage: +# +# One line: # curl -fsSL https://raw.githubusercontent.com/Wembie/Sentinel/main/install.sh | bash -# bash install.sh [OPTIONS] # -# Options: -# --dev Clone repository and install in editable mode -# --upgrade Pull latest changes and re-sync dependencies -# --uninstall Remove SENTINEL installation -# --dry-run Print all actions without executing anything -# --list List detected agents and exit -# --no-mcp Skip MCP auto-registration -# --with-hooks Install Claude Code session hooks (auto-activates SENTINEL at session start) -# --with-init Run `sentinel init` in the current directory after install -# --all Enable --with-hooks + --with-mcp (hooks + MCP registration for all detected agents) -# --minimal Install package only; skip MCP registration, hooks, and init -# --only Register with a specific agent only (e.g. --only claude) +# Detects which AI coding agents are on your machine and registers SENTINEL +# as an MCP server for each one. Skips agents that aren't installed. +# Safe to re-run — idempotent per agent. # -# Environment overrides: -# SENTINEL_HOME Install directory (default: ~/.sentinel) -# SENTINEL_BIN Bin directory for sentinel-mcp wrapper (default: ~/.local/bin) +# Run `install.sh --help` for the full flag reference and agent matrix. set -euo pipefail -# ─── configuration ──────────────────────────────────────────────────────────── - -REPO_URL="https://github.com/Wembie/Sentinel" -REPO_ARCHIVE="https://github.com/Wembie/Sentinel/archive/refs/heads/main.tar.gz" +# ── Constants ────────────────────────────────────────────────────────────── +REPO="Wembie/Sentinel" +REPO_URL="https://github.com/$REPO" +REPO_ARCHIVE="https://github.com/$REPO/archive/refs/heads/main.tar.gz" INSTALL_DIR="${SENTINEL_HOME:-$HOME/.sentinel}" BIN_DIR="${SENTINEL_BIN:-$HOME/.local/bin}" MIN_PYTHON_MAJOR=3 MIN_PYTHON_MINOR=11 -BOLD="\033[1m" -GREEN="\033[32m" -YELLOW="\033[33m" -RED="\033[31m" -CYAN="\033[36m" -RESET="\033[0m" - -# ─── flags ──────────────────────────────────────────────────────────────────── - -MODE="install" -DRY_RUN=false -NO_MCP=false -WITH_HOOKS=false -WITH_INIT=false -MINIMAL=false -ONLY_AGENT="" - -for arg in "$@"; do - case "$arg" in + +# ── Flags ────────────────────────────────────────────────────────────────── +DRY=0 +FORCE=0 +MODE="install" # install | dev | upgrade | uninstall | list +WITH_HOOKS=auto # auto resolves to 1 unless --minimal is set +WITH_INIT=0 +ALL=0 +MINIMAL=0 +SKIP_SKILLS=0 +NO_COLOR=0 +ONLY=() + +# Result trackers (parallel indexed arrays — bash 3.2 safe) +INSTALLED_IDS=() +SKIPPED_IDS=() +SKIPPED_WHY=() +FAILED_IDS=() +FAILED_WHY=() +DETECTED_COUNT=0 + +# ── Color setup (auto-disable on non-TTY) ────────────────────────────────── +if [ ! -t 1 ]; then NO_COLOR=1; fi + +# ── Argument parsing ─────────────────────────────────────────────────────── +print_help() { + cat <<'EOF' +SENTINEL installer — detects your agents and registers the MCP server for each. + +USAGE + install.sh [flags] + + curl -fsSL https://raw.githubusercontent.com/Wembie/Sentinel/main/install.sh | bash + curl -fsSL https://raw.githubusercontent.com/Wembie/Sentinel/main/install.sh | bash -s -- --with-hooks + +FLAGS + --dev Clone repository and install in editable mode (requires git). + --upgrade Pull latest and re-sync dependencies (requires --dev install). + --uninstall Remove SENTINEL installation. + --dry-run Print all actions without executing anything. + --force Re-register even if already registered. + --only Register with a specific agent only. Repeatable. + --skip-skills Skip the npx-skills auto-detect fallback. + --all Turn on --with-hooks and --with-init. + --minimal Package only; skip hooks, per-project init, and skills. + --with-hooks Install Claude Code SessionStart hooks. On by default. + --with-init Run `sentinel init` in the current directory. + --list Print the full agent matrix and exit. + --no-color Disable ANSI color codes (auto-disabled on non-TTY). + -h, --help Show this help and exit. + +AGENTS DETECTED + Native: + claude Claude Code claude mcp add + gemini Gemini CLI gemini extensions install + codex Codex CLI npx skills add (codex) + IDE / VS Code-family: + cursor Cursor IDE npx skills add (cursor) + windsurf Windsurf IDE npx skills add (windsurf) + cline Cline npx skills add (cline) + copilot GitHub Copilot npx skills add (github-copilot) + continue Continue npx skills add (continue) + kilo Kilo Code npx skills add (kilo) + roo Roo Code npx skills add (roo) + augment Augment Code npx skills add (augment) + CLI agents (30+ via skills): + aider-desk Aider Desk npx skills add (aider-desk) + amp Sourcegraph Amp npx skills add (amp) + bob IBM Bob npx skills add (bob) + crush Crush npx skills add (crush) + devin Devin npx skills add (devin) + droid Droid (Factory) npx skills add (droid) + forgecode ForgeCode npx skills add (forgecode) + goose Block Goose npx skills add (goose) + iflow iFlow CLI npx skills add (iflow-cli) + junie JetBrains Junie npx skills add (junie) + kiro Kiro CLI npx skills add (kiro-cli) + mistral Mistral Vibe npx skills add (mistral-vibe) + openhands OpenHands npx skills add (openhands) + opencode opencode npx skills add (opencode) + qwen Qwen Code npx skills add (qwen-code) + qoder Qoder npx skills add (qoder) + rovodev Atlassian Rovo Dev npx skills add (rovodev) + tabnine Tabnine CLI npx skills add (tabnine-cli) + trae Trae npx skills add (trae) + warp Warp npx skills add (warp) + replit Replit Agent npx skills add (replit) + antigravity Google Antigravity npx skills add (antigravity) + +ENVIRONMENT + SENTINEL_HOME Install directory (default: ~/.sentinel) + SENTINEL_BIN Bin dir for sentinel-mcp wrapper (default: ~/.local/bin) + +EXAMPLES + install.sh # default: install + hooks + install.sh --all # install + hooks + per-project init + install.sh --minimal # install package only + install.sh --dry-run --all + install.sh --only claude + install.sh --only cursor --only windsurf + install.sh --upgrade + install.sh --list +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in --dev) MODE="dev" ;; --upgrade) MODE="upgrade" ;; --uninstall) MODE="uninstall" ;; - --dry-run) DRY_RUN=true ;; + --dry-run) DRY=1 ;; + --force) FORCE=1 ;; + --skip-skills) SKIP_SKILLS=1 ;; + --with-hooks) WITH_HOOKS=1 ;; + --with-init) WITH_INIT=1 ;; + --all) ALL=1 ;; + --minimal) MINIMAL=1 ;; --list) MODE="list" ;; - --no-mcp) NO_MCP=true ;; - --with-hooks) WITH_HOOKS=true ;; - --with-init) WITH_INIT=true ;; - --all) WITH_HOOKS=true; WITH_INIT=false ;; - --minimal) NO_MCP=true; MINIMAL=true ;; - --only) shift; ONLY_AGENT="${1:-}" ;; - --help|-h) - echo "Usage: install.sh [--dev] [--upgrade] [--uninstall] [--dry-run] [--list]" - echo " [--no-mcp] [--with-hooks] [--with-init] [--all] [--minimal]" - echo " [--only ]" - exit 0 - ;; + --no-color) NO_COLOR=1 ;; + --only) + shift + [ $# -eq 0 ] && { echo "error: --only requires an argument" >&2; exit 2; } + ONLY+=("$1") ;; + -h|--help) print_help; exit 0 ;; + *) echo "error: unknown flag: $1" >&2; echo "run 'install.sh --help' for usage" >&2; exit 2 ;; esac + shift done -# ─── helpers ────────────────────────────────────────────────────────────────── +# Resolve --all / --minimal / "auto" into concrete values. +if [ "$ALL" = 1 ] && [ "$MINIMAL" = 1 ]; then + echo "error: --all and --minimal are mutually exclusive" >&2; exit 2 +fi +if [ "$ALL" = 1 ]; then WITH_HOOKS=1; WITH_INIT=1; fi +if [ "$MINIMAL" = 1 ]; then WITH_HOOKS=0; WITH_INIT=0; SKIP_SKILLS=1; fi +[ "$WITH_HOOKS" = "auto" ] && WITH_HOOKS=1 + +# ── Color helpers ────────────────────────────────────────────────────────── +if [ "$NO_COLOR" = 1 ]; then + c_blue=""; c_dim=""; c_red=""; c_green=""; c_yellow=""; c_reset="" +else + c_blue=$'\033[34m' + c_dim=$'\033[2m' + c_red=$'\033[31m' + c_green=$'\033[32m' + c_yellow=$'\033[33m' + c_reset=$'\033[0m' +fi + +say() { printf '%s%s%s\n' "$c_blue" "$1" "$c_reset"; } +note() { printf '%s%s%s\n' "$c_dim" "$1" "$c_reset"; } +warn() { printf '%s%s%s\n' "$c_yellow" "$1" "$c_reset" >&2; } +err() { printf '%s%s%s\n' "$c_red" "$1" "$c_reset" >&2; } +ok() { printf '%s%s%s\n' "$c_green" "$1" "$c_reset"; } + +# ── Helpers ──────────────────────────────────────────────────────────────── +want() { + [ ${#ONLY[@]} -eq 0 ] && return 0 + local a; for a in "${ONLY[@]}"; do [ "$a" = "$1" ] && return 0; done + return 1 +} -info() { echo -e "${GREEN}[SENTINEL]${RESET} $*"; } -warn() { echo -e "${YELLOW}[SENTINEL]${RESET} $*"; } -error() { echo -e "${RED}[SENTINEL ERROR]${RESET} $*" >&2; } -die() { error "$*"; exit 1; } -bold() { echo -e "${BOLD}$*${RESET}"; } -cyan() { echo -e "${CYAN}$*${RESET}"; } +run() { + if [ "$DRY" = 1 ]; then note " would run: $*"; return 0; fi + echo " $ $*"; "$@" +} -run_cmd() { - if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}[dry-run]${RESET} $*" - else - eval "$@" - fi +try() { + if [ "$DRY" = 1 ]; then note " would run: $*"; return 0; fi + echo " $ $*"; "$@" } -need_cmd() { - command -v "$1" &>/dev/null +has() { command -v "$1" >/dev/null 2>&1; } + +ensure_node() { + has node && has npx && return 0 + warn " node + npx required — install Node.js (https://nodejs.org) and re-run." + return 1 } -detect_os() { - case "$(uname -s)" in - Linux*) echo "linux" ;; - Darwin*) echo "macos" ;; - MINGW*|CYGWIN*|MSYS*) echo "windows" ;; - *) echo "unknown" ;; - esac +record_installed() { INSTALLED_IDS+=("$1"); } +record_skipped() { SKIPPED_IDS+=("$1"); SKIPPED_WHY+=("$2"); } +record_failed() { FAILED_IDS+=("$1"); FAILED_WHY+=("$2"); } + +# ── Detection helpers ────────────────────────────────────────────────────── +vscode_ext_present() { + local needle="$1" + local roots=("$HOME/.vscode/extensions" "$HOME/.vscode-server/extensions" "$HOME/.cursor/extensions" "$HOME/.windsurf/extensions") + local r + for r in "${roots[@]}"; do + [ -d "$r" ] && ls "$r" 2>/dev/null | grep -qi "$needle" && return 0 + done + return 1 +} + +cursor_ext_present() { + local needle="$1" + [ -d "$HOME/.cursor/extensions" ] && ls "$HOME/.cursor/extensions" 2>/dev/null | grep -qi "$needle" } +jetbrains_plugin_present() { + local needle="$1" + local roots=("$HOME/Library/Application Support/JetBrains" "$HOME/.config/JetBrains") + local r + for r in "${roots[@]}"; do + [ -d "$r" ] && find "$r" -maxdepth 4 -type d -iname "*${needle}*" 2>/dev/null | grep -q . && return 0 + done + return 1 +} + +# Parse a PROVIDER_DETECT spec ("command:foo||dir:~/.x") and return 0 if any clause matches. +# Splits on '||' via parameter expansion — avoids BSD awk regex bugs. +detect_match() { + local spec="$1" rest="$spec" clause + while [ -n "$rest" ]; do + if [ "${rest#*||}" != "$rest" ]; then + clause="${rest%%||*}"; rest="${rest#*||}" + else + clause="$rest"; rest="" + fi + [ -z "$clause" ] && continue + case "$clause" in + command:*) has "${clause#command:}" && return 0 ;; + dir:*) [ -d "${clause#dir:}" ] && return 0 ;; + file:*) [ -f "${clause#file:}" ] && return 0 ;; + vscode-ext:*) vscode_ext_present "${clause#vscode-ext:}" && return 0 ;; + cursor-ext:*) cursor_ext_present "${clause#cursor-ext:}" && return 0 ;; + jetbrains-plugin:*) jetbrains_plugin_present "${clause#jetbrains-plugin:}" && return 0 ;; + esac + done + return 1 +} + +# ── Provider matrix (bash 3.2-safe parallel arrays) ─────────────────────── +# id | label | skills profile | detection spec +# claude and gemini are handled by dedicated functions — included here for --list only. +PROVIDER_IDS=( + "claude" "gemini" "codex" + "cursor" "windsurf" "cline" "copilot" "continue" "kilo" "roo" "augment" + "aider-desk" "amp" "bob" "crush" "devin" "droid" "forgecode" "goose" + "iflow" "junie" "kiro" "mistral" "openhands" "opencode" "qwen" "qoder" + "rovodev" "tabnine" "trae" "warp" "replit" "antigravity" +) +PROVIDER_LABELS=( + "Claude Code" "Gemini CLI" "Codex CLI" + "Cursor" "Windsurf" "Cline" "GitHub Copilot" "Continue" "Kilo Code" "Roo Code" "Augment Code" + "Aider Desk" "Sourcegraph Amp" "IBM Bob" "Crush" "Devin" "Droid (Factory)" "ForgeCode" "Block Goose" + "iFlow CLI" "JetBrains Junie" "Kiro CLI" "Mistral Vibe" "OpenHands" "opencode" "Qwen Code" "Qoder" + "Atlassian Rovo Dev" "Tabnine CLI" "Trae" "Warp" "Replit Agent" "Google Antigravity" +) +PROVIDER_MECHS=( + "claude mcp add" "gemini extensions install" "npx skills add (codex)" + "npx skills add (cursor)" "npx skills add (windsurf)" "npx skills add (cline)" + "npx skills add (github-copilot)" "npx skills add (continue)" "npx skills add (kilo)" + "npx skills add (roo)" "npx skills add (augment)" + "npx skills add (aider-desk)" "npx skills add (amp)" "npx skills add (bob)" + "npx skills add (crush)" "npx skills add (devin)" "npx skills add (droid)" + "npx skills add (forgecode)" "npx skills add (goose)" "npx skills add (iflow-cli)" + "npx skills add (junie)" "npx skills add (kiro-cli)" "npx skills add (mistral-vibe)" + "npx skills add (openhands)" "npx skills add (opencode)" "npx skills add (qwen-code)" + "npx skills add (qoder)" "npx skills add (rovodev)" "npx skills add (tabnine-cli)" + "npx skills add (trae)" "npx skills add (warp)" "npx skills add (replit)" + "npx skills add (antigravity)" +) +PROVIDER_DETECT=( + "command:claude||dir:$HOME/.claude" + "command:gemini||dir:$HOME/.gemini" + "command:codex||dir:$HOME/.codex" + "command:cursor||dir:$HOME/.cursor" + "command:windsurf||dir:$HOME/.codeium/windsurf||dir:$HOME/.windsurf" + "vscode-ext:cline" + "command:gh" + "vscode-ext:continue.continue||vscode-ext:continue" + "vscode-ext:kilocode||dir:$HOME/.kilocode" + "vscode-ext:roo||vscode-ext:rooveterinaryinc.roo-cline||cursor-ext:roo" + "vscode-ext:augment||jetbrains-plugin:augment" + "command:aider||dir:$HOME/.aider-desk" + "command:amp" + "command:bob||dir:$HOME/.bob" + "command:crush||dir:$HOME/.config/crush" + "command:devin||dir:$HOME/.config/devin" + "command:droid||dir:$HOME/.factory" + "command:forge||dir:$HOME/.forge" + "command:goose||dir:$HOME/.config/goose" + "command:iflow||dir:$HOME/.iflow" + "dir:$HOME/.junie||jetbrains-plugin:junie" + "command:kiro||dir:$HOME/.kiro" + "command:mistral||dir:$HOME/.vibe" + "command:openhands||dir:$HOME/.openhands" + "command:opencode||file:$HOME/.config/opencode/AGENTS.md" + "command:qwen||dir:$HOME/.qwen" + "dir:$HOME/.qoder" + "command:rovodev||dir:$HOME/.rovodev" + "command:tabnine||dir:$HOME/.tabnine" + "command:trae||dir:$HOME/.trae" + "command:warp||dir:$HOME/.warp" + "command:replit||dir:$HOME/.replit" + "dir:$HOME/.gemini/antigravity" +) + +# ── --list mode ──────────────────────────────────────────────────────────── +if [ "$MODE" = "list" ]; then + say "🛡 SENTINEL agent matrix" + printf '\n %-14s %-22s %s\n' "ID" "AGENT" "INSTALL MECHANISM" + printf ' %-14s %-22s %s\n' "----" "-----" "-----------------" + i=0; total=${#PROVIDER_IDS[@]} + while [ $i -lt "$total" ]; do + printf ' %-14s %-22s %s\n' "${PROVIDER_IDS[$i]}" "${PROVIDER_LABELS[$i]}" "${PROVIDER_MECHS[$i]}" + i=$((i + 1)) + done + echo + note " Defaults: --with-hooks ON. --all turns on --with-init. --minimal turns both off." + echo + exit 0 +fi + +# ── Core install helpers ─────────────────────────────────────────────────── check_python() { local py_cmd="" for candidate in python3 python python3.11 python3.12 python3.13; do - if need_cmd "$candidate"; then - local ver + if has "$candidate"; then + local ver major minor ver=$("$candidate" -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>/dev/null || echo "0.0") - local major="${ver%%.*}" - local minor="${ver##*.}" + major="${ver%%.*}"; minor="${ver##*.}" if [[ "$major" -gt "$MIN_PYTHON_MAJOR" ]] || \ [[ "$major" -eq "$MIN_PYTHON_MAJOR" && "$minor" -ge "$MIN_PYTHON_MINOR" ]]; then - py_cmd="$candidate" - break + py_cmd="$candidate"; break fi fi done - if [[ -z "$py_cmd" ]]; then - die "Python ${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR}+ required. Install from https://python.org" - fi + [ -z "$py_cmd" ] && { err "Python ${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR}+ required — https://python.org"; exit 1; } echo "$py_cmd" } install_uv() { - if need_cmd uv; then - info "uv already installed: $(uv --version)" - return - fi - info "Installing uv (Python package manager)..." - if [[ "$DRY_RUN" == "true" ]]; then - echo " [dry-run] curl -LsSf https://astral.sh/uv/install.sh | sh" - return - fi - if need_cmd curl; then + has uv && { note " uv $(uv --version) already installed"; return; } + say " → installing uv..." + if [ "$DRY" = 1 ]; then note " would run: curl -LsSf https://astral.sh/uv/install.sh | sh"; return; fi + if has curl; then curl -LsSf https://astral.sh/uv/install.sh | sh - elif need_cmd wget; then + elif has wget; then wget -qO- https://astral.sh/uv/install.sh | sh else - die "curl or wget required to install uv. Install one and retry." + err "curl or wget required to install uv"; exit 1 fi export PATH="$HOME/.cargo/bin:$HOME/.local/bin:$PATH" - if ! need_cmd uv; then - die "uv installed but not found on PATH. Add ~/.cargo/bin or ~/.local/bin to PATH." - fi - info "uv installed: $(uv --version)" + has uv || { err "uv installed but not on PATH — add ~/.local/bin to PATH"; exit 1; } } ensure_bin_dir() { mkdir -p "$BIN_DIR" if [[ ":$PATH:" != *":$BIN_DIR:"* ]]; then - warn "$BIN_DIR is not on PATH. Add this to your shell profile:" - echo " export PATH=\"\$PATH:$BIN_DIR\"" + warn " $BIN_DIR not on PATH — add: export PATH=\"\$PATH:$BIN_DIR\"" fi } write_wrapper() { local wrapper="$BIN_DIR/sentinel-mcp" - if [[ "$DRY_RUN" == "true" ]]; then - echo " [dry-run] write $wrapper" - return - fi + [ "$DRY" = 1 ] && { note " would write $wrapper"; return; } cat > "$wrapper" </dev/null; then - info "Import check passed." - else - die "Import validation failed. Check: uv run --project $INSTALL_DIR python -c \"import sentinel\"" - fi + [ "$DRY" = 1 ] && { note " would validate: import sentinel"; return; } + uv run --project "$INSTALL_DIR" python -c "import sentinel" &>/dev/null \ + || { err "import validation failed — check: uv run --project $INSTALL_DIR python -c 'import sentinel'"; exit 1; } + ok " import OK" } -# ─── agent detection ────────────────────────────────────────────────────────── - -DETECTED_AGENTS=() +write_config() { + local config_dir="$HOME/.config/sentinel" + local config_file="$config_dir/config.json" -detect_agents() { - info "Scanning for AI coding agents..." - DETECTED_AGENTS=() - - local ext_dirs=("$HOME/.vscode/extensions" "$HOME/.cursor/extensions" "$HOME/.windsurf/extensions") - - # ── Native CLI agents ────────────────────────────────────────────────────── - - # Claude Code - if need_cmd claude || [[ -d "$HOME/.claude" ]]; then - DETECTED_AGENTS+=("claude:Claude Code") - fi - - # Gemini CLI - if need_cmd gemini || [[ -f "$HOME/.gemini/config.json" ]] || [[ -d "$HOME/.gemini" ]]; then - DETECTED_AGENTS+=("gemini:Gemini CLI") - fi - - # OpenAI Codex CLI - if need_cmd codex || [[ -d "$HOME/.codex" ]]; then - DETECTED_AGENTS+=("codex:Codex CLI") - fi - - # GitHub Copilot CLI (gh extension) - if need_cmd gh && gh extension list 2>/dev/null | grep -q "gh-copilot"; then - DETECTED_AGENTS+=("copilot-cli:GitHub Copilot CLI") - fi - - # Aider - if need_cmd aider; then - DETECTED_AGENTS+=("aider:Aider") - fi - - # v0 (Vercel) - if need_cmd v0; then - DETECTED_AGENTS+=("v0:v0") + if [ -f "$config_file" ]; then + note " config exists at $config_file — skipping" + return fi - # ── IDE editors ───────────────────────────────────────────────────────────── - - # Cursor - if need_cmd cursor || [[ -d "$HOME/.cursor" ]]; then - DETECTED_AGENTS+=("cursor:Cursor") + local provider="" api_key="" + if [[ -n "${ANTHROPIC_API_KEY:-}" ]]; then + provider="claude"; api_key="$ANTHROPIC_API_KEY" + elif [[ -n "${OPENAI_API_KEY:-}" ]]; then + provider="openai"; api_key="$OPENAI_API_KEY" + else + return fi - # Windsurf - if [[ -d "$HOME/.codeium/windsurf" ]] || [[ -d "$HOME/.windsurf" ]] || need_cmd windsurf; then - DETECTED_AGENTS+=("windsurf:Windsurf") - fi + say " → detected $provider API key — writing config" + if [ "$DRY" = 1 ]; then note " would write $config_file (llm_provider=$provider)"; return; fi - # VS Code - if need_cmd code; then - DETECTED_AGENTS+=("vscode:VS Code") - fi + mkdir -p "$config_dir" + printf '{\n "llm_provider": "%s",\n "llm_api_key": "%s"\n}\n' "$provider" "$api_key" > "$config_file" + ok " config: $config_file" +} - # JetBrains (any IDE) - local jb_roots=( - "$HOME/.config/JetBrains" - "$HOME/Library/Application Support/JetBrains" - "${APPDATA:-}/JetBrains" - ) - for jb_root in "${jb_roots[@]}"; do - if [[ -d "$jb_root" ]]; then - DETECTED_AGENTS+=("jetbrains:JetBrains") - break - fi - done +# ── Core installation (download + deps + wrapper) ────────────────────────── +do_core_install() { + local dev_mode="${1:-false}" - # Sourcegraph Amp - if need_cmd amp || [[ -d "$HOME/.amp" ]]; then - DETECTED_AGENTS+=("amp:Sourcegraph Amp") - fi + say "🛡 SENTINEL installer" + note " $REPO_URL" + [ "$DRY" = 1 ] && note " (dry run — nothing will be written)" + echo - # ── VS Code / Cursor extensions ────────────────────────────────────────────── + local py_cmd; py_cmd=$(check_python) + note " python: $("$py_cmd" --version)" - # Cline (VS Code extension) - local cline_added=false - for ext_dir in "${ext_dirs[@]}"; do - if [[ -d "$ext_dir" ]] && ls "$ext_dir" 2>/dev/null | grep -q "saoudrizwan.claude-dev"; then - DETECTED_AGENTS+=("cline:Cline"); cline_added=true; break - fi - done + install_uv - # Continue (VS Code extension) - for ext_dir in "${ext_dirs[@]}"; do - if [[ -d "$ext_dir" ]] && ls "$ext_dir" 2>/dev/null | grep -q "continue.continue"; then - DETECTED_AGENTS+=("continue:Continue"); break + if [ "$dev_mode" = "true" ]; then + has git || { err "--dev requires git — https://git-scm.com"; exit 1; } + if [ -d "$INSTALL_DIR/.git" ]; then + warn " $INSTALL_DIR already exists — use --upgrade" + else + say " → cloning to $INSTALL_DIR..." + run git clone "$REPO_URL" "$INSTALL_DIR" fi - done - - # Roo / Roo Cline - for ext_dir in "${ext_dirs[@]}"; do - if [[ -d "$ext_dir" ]] && ls "$ext_dir" 2>/dev/null | grep -q "rooveterinaryinc.roo-cline"; then - DETECTED_AGENTS+=("roo:Roo"); break + else + if [ -d "$INSTALL_DIR" ]; then + warn " $INSTALL_DIR already exists — use --upgrade or --uninstall first" + else + say " → downloading to $INSTALL_DIR..." + if [ "$DRY" = 1 ]; then + note " would download $REPO_ARCHIVE → $INSTALL_DIR" + else + mkdir -p "$INSTALL_DIR" + if has curl; then + curl -fsSL "$REPO_ARCHIVE" | tar -xz --strip-components=1 -C "$INSTALL_DIR" + elif has wget; then + wget -qO- "$REPO_ARCHIVE" | tar -xz --strip-components=1 -C "$INSTALL_DIR" + else + err "curl or wget required"; exit 1 + fi + fi fi - done - - # ── AI coding platforms ────────────────────────────────────────────────────── - - # OpenHands / All-Hands - if need_cmd openhands || [[ -d "$HOME/.openhands" ]]; then - DETECTED_AGENTS+=("openhands:OpenHands") fi - # Devin - if [[ -d "$HOME/.devin" ]]; then - DETECTED_AGENTS+=("devin:Devin") - fi + say " → syncing dependencies..." + run uv sync --project "$INSTALL_DIR" - # Kode - if need_cmd kode || [[ -d "$HOME/.kode" ]]; then - DETECTED_AGENTS+=("kode:Kode") - fi + ensure_bin_dir + write_wrapper + validate_install + write_config + echo +} - # Aide - if need_cmd aide || [[ -d "$HOME/.aide" ]]; then - DETECTED_AGENTS+=("aide:Aide") - fi +# ── Per-agent install functions ──────────────────────────────────────────── +install_claude() { + DETECTED_COUNT=$((DETECTED_COUNT + 1)) + say "→ Claude Code detected" - if [[ ${#DETECTED_AGENTS[@]} -eq 0 ]]; then - warn "No AI coding agents detected." - warn "Install MCP manually using mcp.example.json, or run: sentinel init" - return + # Idempotency check + if [ "$FORCE" = 0 ] && has claude && claude mcp list 2>/dev/null | grep -qi "^sentinel"; then + note " sentinel MCP already registered (--force to re-register)" + record_skipped "claude" "already registered" + echo; return 0 fi - info "Detected agents:" - for entry in "${DETECTED_AGENTS[@]}"; do - echo " ✓ ${entry##*:}" - done -} - -# ─── list mode ──────────────────────────────────────────────────────────────── - -do_list() { - bold "SENTINEL — Agent Detection" - echo "" - detect_agents - echo "" - echo "Supported: Claude Code, Gemini CLI, Codex CLI, Copilot CLI, Aider, v0," - echo " Cursor, Windsurf, VS Code, JetBrains, Amp, Cline, Continue," - echo " Roo, OpenHands, Devin, Kode, Aide — plus any skills-CLI-compatible agent." - echo "" - echo "Manual MCP config: $([ -d "$INSTALL_DIR" ] && echo "$INSTALL_DIR/mcp.example.json" || echo "mcp.example.json (run installer first)")" -} + if has claude; then + if try claude mcp add sentinel -- uv run --project "$INSTALL_DIR" python -m sentinel.mcp; then + ok " MCP server registered" + record_installed "claude" + else + warn " claude mcp add failed" + record_failed "claude" "claude mcp add failed" + fi + else + # Write config file manually + local claude_cfg="$HOME/.claude/claude.json" + if [ "$DRY" = 1 ]; then + note " would patch $claude_cfg" + record_installed "claude (dry-run)" + else + warn " claude CLI not found — add MCP server manually:" + note " command: uv" + note " args: [\"run\", \"--project\", \"$INSTALL_DIR\", \"python\", \"-m\", \"sentinel.mcp\"]" + record_failed "claude" "claude CLI not on PATH" + fi + fi -# ─── MCP registration ───────────────────────────────────────────────────────── - -register_mcp() { - local install_dir="$1" - [[ "$NO_MCP" == "true" ]] && return - [[ ${#DETECTED_AGENTS[@]} -eq 0 ]] && { detect_agents; } - - local registered=() - local manual_agents=() - - for entry in "${DETECTED_AGENTS[@]}"; do - local agent_id="${entry%%:*}" - local agent_label="${entry##*:}" - - [[ -n "$ONLY_AGENT" && "$agent_id" != "$ONLY_AGENT" ]] && continue - - case "$agent_id" in - claude) - if need_cmd claude; then - if [[ "$DRY_RUN" == "true" ]]; then - echo " [dry-run] claude mcp add sentinel -- uv run --project \"$install_dir\" python -m sentinel.mcp" - registered+=("$agent_label (dry-run)") - elif claude mcp add sentinel -- uv run --project "$install_dir" python -m sentinel.mcp 2>/dev/null; then - info "Claude Code: MCP server registered." - registered+=("$agent_label") - else - warn "Claude Code: auto-registration failed." - manual_agents+=("$agent_label") - fi - else - manual_agents+=("$agent_label") - fi - ;; - gemini) - if need_cmd gemini; then - if [[ "$DRY_RUN" == "true" ]]; then - echo " [dry-run] gemini extensions install https://github.com/Wembie/Sentinel" - registered+=("$agent_label (dry-run)") - elif gemini extensions install "https://github.com/Wembie/Sentinel" 2>/dev/null; then - info "Gemini CLI: extension installed." - registered+=("$agent_label") - else - warn "Gemini CLI: auto-install failed." - manual_agents+=("$agent_label") - fi + # --with-hooks + if [ "$WITH_HOOKS" = 1 ]; then + say " → installing Claude Code hooks..." + local hooks_installer="$INSTALL_DIR/hooks/install.sh" + if [ -f "$hooks_installer" ]; then + local hooks_args=""; [ "$FORCE" = 1 ] && hooks_args="--force" + if [ "$DRY" = 1 ]; then + note " would run: bash $hooks_installer $hooks_args" + else + # shellcheck disable=SC2086 + if bash "$hooks_installer" $hooks_args; then + record_installed "claude-hooks" else - manual_agents+=("$agent_label") + warn " hooks installer failed (non-fatal)" + record_failed "claude-hooks" "hooks/install.sh failed" fi - ;; - *) - manual_agents+=("$agent_label") - ;; - esac - done - - # Skills CLI fallback: covers Cursor, Windsurf, Cline, Continue, Roo, and 30+ more - # Runs unless --minimal or --only is specified - if need_cmd npx && [[ "$MINIMAL" != "true" ]] && [[ -z "$ONLY_AGENT" ]]; then - info "Running skills CLI registration (covers all skills-compatible agents)..." - if [[ "$DRY_RUN" == "true" ]]; then - echo " [dry-run] npx -y skills add https://github.com/Wembie/Sentinel" - elif npx -y skills add "https://github.com/Wembie/Sentinel" 2>/dev/null; then - info "Skills CLI: SENTINEL registered." + fi else - warn "Skills CLI registration failed (npx error — non-fatal)." + note " hooks installer not found at $hooks_installer — run: bash $INSTALL_DIR/hooks/install.sh" + record_skipped "claude-hooks" "installer not found (run hooks/install.sh manually)" fi fi - if [[ ${#registered[@]} -gt 0 ]]; then - echo "" - info "Auto-registered: ${registered[*]}" + echo +} + +install_gemini() { + DETECTED_COUNT=$((DETECTED_COUNT + 1)) + say "→ Gemini CLI detected" + + if [ "$FORCE" = 0 ] && gemini extensions list 2>/dev/null | grep -qi "sentinel"; then + note " sentinel extension already installed (--force to reinstall)" + record_skipped "gemini" "already installed" + echo; return 0 fi - if [[ ${#manual_agents[@]} -gt 0 ]]; then - echo "" - cyan "Manual MCP config needed for: ${manual_agents[*]}" - echo "" - echo " Add to your editor's MCP server settings:" - echo " {" - echo " \"command\": \"uv\"," - echo " \"args\": [\"run\", \"--project\", \"$install_dir\", \"python\", \"-m\", \"sentinel.mcp\"]" - echo " }" - echo "" - echo " See $install_dir/mcp.example.json for editor-specific examples." - echo " Or run: sentinel init (in any project root) to drop rule files." + if try gemini extensions install "$REPO_URL"; then + record_installed "gemini" + else + record_failed "gemini" "gemini extensions install failed" fi + echo } -# ─── hooks installation ─────────────────────────────────────────────────────── - -install_hooks() { - local install_dir="$1" - local hooks_installer="$install_dir/hooks/install.sh" +install_via_skills() { + local id="$1" label="$2" profile="$3" + DETECTED_COUNT=$((DETECTED_COUNT + 1)) + say "→ $label detected" - if [[ ! -f "$hooks_installer" ]]; then - warn "Hooks installer not found at $hooks_installer — skipping." - return + if ! ensure_node; then + record_failed "$id" "node/npx missing" + echo; return 0 fi - info "Installing Claude Code hooks..." - if [[ "$DRY_RUN" == "true" ]]; then - echo " [dry-run] bash $hooks_installer --dry-run" + if try npx -y skills add "$REPO_URL" -a "$profile"; then + record_installed "$id" else - bash "$hooks_installer" || warn "Hooks installation failed (non-fatal)." + record_failed "$id" "npx skills add (profile: $profile) failed" fi + echo } -# ─── uninstall ──────────────────────────────────────────────────────────────── - +# ── Uninstall ────────────────────────────────────────────────────────────── do_uninstall() { - bold "Uninstalling SENTINEL..." - if [[ -d "$INSTALL_DIR" ]]; then - run_cmd rm -rf "$INSTALL_DIR" - info "Removed $INSTALL_DIR" + say "🛡 uninstalling SENTINEL..." + if [ -d "$INSTALL_DIR" ]; then + run rm -rf "$INSTALL_DIR"; ok " removed $INSTALL_DIR" else - warn "Installation directory not found: $INSTALL_DIR" + warn " $INSTALL_DIR not found" fi local wrapper="$BIN_DIR/sentinel-mcp" - if [[ -f "$wrapper" ]]; then - run_cmd rm -f "$wrapper" - info "Removed $wrapper" + if [ -f "$wrapper" ]; then + run rm -f "$wrapper"; ok " removed $wrapper" fi - info "SENTINEL uninstalled." + ok " done." } -# ─── upgrade ────────────────────────────────────────────────────────────────── - +# ── Upgrade ──────────────────────────────────────────────────────────────── do_upgrade() { - if [[ ! -d "$INSTALL_DIR/.git" ]]; then - die "Upgrade requires a git-cloned install (--dev). Use --uninstall then re-run installer." - fi - bold "Upgrading SENTINEL..." - run_cmd git -C "$INSTALL_DIR" pull --ff-only - run_cmd uv sync --project "$INSTALL_DIR" + [ -d "$INSTALL_DIR/.git" ] || { err "upgrade requires a --dev install (git repo at $INSTALL_DIR)"; exit 1; } + say "🛡 upgrading SENTINEL..." + run git -C "$INSTALL_DIR" pull --ff-only + run uv sync --project "$INSTALL_DIR" validate_install - info "SENTINEL upgraded." + ok " upgraded." } -# ─── install ────────────────────────────────────────────────────────────────── - -do_install() { - local dev_mode="${1:-false}" - bold "Installing SENTINEL..." - [[ "$DRY_RUN" == "true" ]] && warn "Dry-run mode — no changes will be made." - echo "" - - local py_cmd - py_cmd=$(check_python) - info "Python: $("$py_cmd" --version)" +# ── Dispatch non-registration modes ─────────────────────────────────────── +case "$MODE" in + uninstall) do_uninstall; exit 0 ;; + upgrade) do_upgrade; exit 0 ;; + install) do_core_install "false" ;; + dev) do_core_install "true" ;; +esac - install_uv +# ── Agent registration ───────────────────────────────────────────────────── +say "🛡 registering with detected agents..." +echo + +# Claude (native MCP + hooks) +if want claude && detect_match "${PROVIDER_DETECT[0]}"; then + install_claude +fi + +# Gemini (native extension) +if want gemini && detect_match "${PROVIDER_DETECT[1]}"; then + install_gemini +fi + +# All skills-based agents +SKILLS_AGENTS=( + "codex|Codex CLI|codex|${PROVIDER_DETECT[2]}" + "cursor|Cursor|cursor|${PROVIDER_DETECT[3]}" + "windsurf|Windsurf|windsurf|${PROVIDER_DETECT[4]}" + "cline|Cline|cline|${PROVIDER_DETECT[5]}" + "copilot|GitHub Copilot|github-copilot|${PROVIDER_DETECT[6]}" + "continue|Continue|continue|${PROVIDER_DETECT[7]}" + "kilo|Kilo Code|kilo|${PROVIDER_DETECT[8]}" + "roo|Roo Code|roo|${PROVIDER_DETECT[9]}" + "augment|Augment Code|augment|${PROVIDER_DETECT[10]}" + "aider-desk|Aider Desk|aider-desk|${PROVIDER_DETECT[11]}" + "amp|Sourcegraph Amp|amp|${PROVIDER_DETECT[12]}" + "bob|IBM Bob|bob|${PROVIDER_DETECT[13]}" + "crush|Crush|crush|${PROVIDER_DETECT[14]}" + "devin|Devin|devin|${PROVIDER_DETECT[15]}" + "droid|Droid (Factory)|droid|${PROVIDER_DETECT[16]}" + "forgecode|ForgeCode|forgecode|${PROVIDER_DETECT[17]}" + "goose|Block Goose|goose|${PROVIDER_DETECT[18]}" + "iflow|iFlow CLI|iflow-cli|${PROVIDER_DETECT[19]}" + "junie|JetBrains Junie|junie|${PROVIDER_DETECT[20]}" + "kiro|Kiro CLI|kiro-cli|${PROVIDER_DETECT[21]}" + "mistral|Mistral Vibe|mistral-vibe|${PROVIDER_DETECT[22]}" + "openhands|OpenHands|openhands|${PROVIDER_DETECT[23]}" + "opencode|opencode|opencode|${PROVIDER_DETECT[24]}" + "qwen|Qwen Code|qwen-code|${PROVIDER_DETECT[25]}" + "qoder|Qoder|qoder|${PROVIDER_DETECT[26]}" + "rovodev|Atlassian Rovo Dev|rovodev|${PROVIDER_DETECT[27]}" + "tabnine|Tabnine CLI|tabnine-cli|${PROVIDER_DETECT[28]}" + "trae|Trae|trae|${PROVIDER_DETECT[29]}" + "warp|Warp|warp|${PROVIDER_DETECT[30]}" + "replit|Replit Agent|replit|${PROVIDER_DETECT[31]}" + "antigravity|Google Antigravity|antigravity|${PROVIDER_DETECT[32]}" +) + +for spec in "${SKILLS_AGENTS[@]}"; do + IFS='|' read -r id label profile detect_spec <&2 + i=$((i + 1)) + done +fi + +if [ ${#INSTALLED_IDS[@]} -eq 0 ] && [ ${#SKIPPED_IDS[@]} -eq 0 ] && [ ${#FAILED_IDS[@]} -eq 0 ]; then + note " nothing detected — run 'install.sh --list' for all supported agents" + note " or pass --only to force a specific target." +fi + +echo +note " start an audit: uv run --project \"$INSTALL_DIR\" sentinel audit ./" +note " per-project setup: sentinel init" + +# Exit non-zero only when every detected agent failed (and at least one was detected). +if [ "$DETECTED_COUNT" -gt 0 ] && [ ${#INSTALLED_IDS[@]} -eq 0 ] && [ ${#SKIPPED_IDS[@]} -eq 0 ]; then + exit 1 +fi +exit 0 diff --git a/pyproject.toml b/pyproject.toml index 1e35975..83beab0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,16 +33,6 @@ dependencies = [ "mcp[cli]>=1.0.0", ] -[project.optional-dependencies] -dev = [ - "pytest>=8.3.0", - "pytest-asyncio>=0.23.0", - "ruff>=0.5.0", - "mypy>=1.10.0", - "black>=24.0.0", - "flake8>=7.0.0", -] - [project.scripts] sentinel = "sentinel.cli:app" sentinel-mcp = "sentinel.mcp:run" @@ -56,10 +46,11 @@ pattern = "^(?P[^\n]+)" requires = ["hatchling"] build-backend = "hatchling.build" -[tool.uv] -dev-dependencies = [ +[dependency-groups] +dev = [ "pytest>=8.3.0", "pytest-asyncio>=0.23.0", + "pytest-cov>=5.0.0", "ruff>=0.5.0", "mypy>=1.10.0", "black>=24.0.0", @@ -76,7 +67,7 @@ line-length = 100 [tool.ruff.lint] select = ["E", "F", "I", "UP", "B", "S"] -ignore = ["S101", "S603", "S607", "S608"] +ignore = ["S101", "S603", "S607", "S608", "B008", "S104", "S105", "E501"] [tool.mypy] python_version = "3.11" diff --git a/sentinel/cli.py b/sentinel/cli.py index 997ac69..de9c529 100644 --- a/sentinel/cli.py +++ b/sentinel/cli.py @@ -2,7 +2,7 @@ import asyncio from pathlib import Path -from typing import Optional +from typing import TYPE_CHECKING import typer from rich.console import Console @@ -12,7 +12,11 @@ from sentinel import __version__ from sentinel.config import get_settings from sentinel.logging import configure_logging -from sentinel.models.audit import AuditRequest, AuditScope +from sentinel.models.audit import AuditRequest, AuditResult, AuditScope + +if TYPE_CHECKING: + from sentinel.config import Settings + from sentinel.core.engine import AuditEngine app = typer.Typer( name="sentinel", @@ -31,7 +35,7 @@ def _version_callback(value: bool) -> None: @app.callback() def _main( - version: Optional[bool] = typer.Option( + version: bool | None = typer.Option( None, "--version", "-V", @@ -46,10 +50,12 @@ def _main( @app.command("audit") def audit( target: Path = typer.Argument(..., help="Target directory to audit"), - fmt: str = typer.Option("markdown", "--format", "-f", help="Output format: json|markdown|sarif"), - output: Optional[Path] = typer.Option(None, "--output", "-o", help="Write report to file"), + fmt: str = typer.Option( + "markdown", "--format", "-f", help="Output format: json|markdown|sarif" + ), + output: Path | None = typer.Option(None, "--output", "-o", help="Write report to file"), no_llm: bool = typer.Option(False, "--no-llm", help="Disable LLM enrichment stage"), - languages: Optional[str] = typer.Option( + languages: str | None = typer.Option( None, "--languages", "-l", help="Comma-separated language filter (e.g. python,go)" ), ) -> None: @@ -101,7 +107,9 @@ def serve( @app.command("init") def init( - target: Path = typer.Argument(Path("."), help="Target project directory (default: current dir)"), + target: Path = typer.Argument( + Path("."), help="Target project directory (default: current dir)" + ), dry_run: bool = typer.Option(False, "--dry-run", help="Print actions without writing files"), force: bool = typer.Option(False, "--force", help="Overwrite existing rule files"), ) -> None: @@ -212,11 +220,11 @@ def init( """ files: list[tuple[Path, str, bool]] = [ - (target / ".cursor" / "rules" / "sentinel.mdc", _CURSOR_RULE, False), - (target / ".windsurf" / "rules" / "sentinel.md", _WINDSURF_RULE, False), - (target / ".clinerules" / "sentinel.md", _CLINE_RULE, False), - (target / "AGENTS.md", _AGENTS_SNIPPET, True), - (target / ".github" / "copilot-instructions.md", _COPILOT_SNIPPET, True), + (target / ".cursor" / "rules" / "sentinel.mdc", _CURSOR_RULE, False), + (target / ".windsurf" / "rules" / "sentinel.md", _WINDSURF_RULE, False), + (target / ".clinerules" / "sentinel.md", _CLINE_RULE, False), + (target / "AGENTS.md", _AGENTS_SNIPPET, True), + (target / ".github" / "copilot-instructions.md", _COPILOT_SNIPPET, True), ] written = [] @@ -301,7 +309,7 @@ def list_rules() -> None: console.print(table) -def _print_summary(result: AuditResult) -> None: # type: ignore[name-defined] +def _print_summary(result: AuditResult) -> None: from sentinel.models.finding import Severity sev_styles = { @@ -326,7 +334,7 @@ def _print_summary(result: AuditResult) -> None: # type: ignore[name-defined] console.print(table) -async def _do_audit(settings, request): # type: ignore[no-untyped-def] +async def _do_audit(settings: Settings, request: AuditRequest) -> tuple[AuditResult, AuditEngine]: from sentinel.core.engine import AuditEngine engine = AuditEngine(settings) diff --git a/sentinel/config.py b/sentinel/config.py index d791e93..dde099a 100644 --- a/sentinel/config.py +++ b/sentinel/config.py @@ -69,7 +69,11 @@ def settings_customise_sources( config_file = _find_config_file() if config_file: - return (init_settings, env_settings, JsonConfigSettingsSource(settings_cls, config_file)) + return ( + init_settings, + env_settings, + JsonConfigSettingsSource(settings_cls, config_file), + ) return (init_settings, env_settings) diff --git a/sentinel/core/engine.py b/sentinel/core/engine.py index a2dc95e..c4cf4e1 100644 --- a/sentinel/core/engine.py +++ b/sentinel/core/engine.py @@ -2,7 +2,7 @@ import asyncio import time -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path import structlog @@ -10,7 +10,6 @@ from sentinel.config import Settings from sentinel.core.context import AuditContext from sentinel.core.pipeline import AuditPipeline -from sentinel.core.registry import Registry from sentinel.graph.builder import GraphBuilder from sentinel.llm.base import LLMProvider from sentinel.llm.router import build_provider @@ -71,7 +70,7 @@ async def run(self, request: AuditRequest) -> AuditResult: result = AuditResult( request_id=request.id, status=AuditStatus.RUNNING, - started_at=datetime.now(timezone.utc), + started_at=datetime.now(UTC), ) ctx = AuditContext(request) @@ -88,7 +87,7 @@ async def run(self, request: AuditRequest) -> AuditResult: ctx.add_error(f"Audit failed: {exc}") finally: elapsed = time.monotonic() - t0 - result.completed_at = datetime.now(timezone.utc) + result.completed_at = datetime.now(UTC) result.findings = sorted(ctx.findings, key=lambda f: f.risk_score, reverse=True) result.errors = ctx.errors result.summary = AuditSummary.from_findings( @@ -146,7 +145,7 @@ async def _stage_run_rules(self, ctx: AuditContext) -> None: *[rule.match(ctx) for rule in self._rule_loader.rules], return_exceptions=True, ) - for rule, result in zip(self._rule_loader.rules, results): + for rule, result in zip(self._rule_loader.rules, results, strict=True): if isinstance(result, BaseException): ctx.add_error(f"Rule {rule.metadata.id} raised: {result}") else: diff --git a/sentinel/core/pipeline.py b/sentinel/core/pipeline.py index d999f01..1642b5b 100644 --- a/sentinel/core/pipeline.py +++ b/sentinel/core/pipeline.py @@ -23,7 +23,7 @@ class AuditPipeline: def __init__(self) -> None: self._stages: list[tuple[str, PipelineStage]] = [] - def add_stage(self, name: str, fn: PipelineStage) -> "AuditPipeline": + def add_stage(self, name: str, fn: PipelineStage) -> AuditPipeline: self._stages.append((name, fn)) return self diff --git a/sentinel/core/registry.py b/sentinel/core/registry.py index b175f43..b791884 100644 --- a/sentinel/core/registry.py +++ b/sentinel/core/registry.py @@ -2,7 +2,8 @@ import importlib import inspect -from typing import Any, Generic, TypeVar +from collections.abc import Callable +from typing import Any, Generic, TypeVar, cast T = TypeVar("T") @@ -14,7 +15,7 @@ def __init__(self, name: str) -> None: self.name = name self._entries: dict[str, type[Any]] = {} - def register(self, name: str | None = None): + def register(self, name: str | None = None) -> Callable[[type[Any]], type[Any]]: def decorator(cls: type[Any]) -> type[Any]: key = name or cls.__name__.lower() self._entries[key] = cls @@ -35,10 +36,10 @@ def instantiate(self, name: str, *args: Any, **kwargs: Any) -> T | None: cls = self.get(name) if cls is None: return None - return cls(*args, **kwargs) # type: ignore[return-value] + return cast(T, cls(*args, **kwargs)) def instantiate_all(self, *args: Any, **kwargs: Any) -> list[T]: - return [cls(*args, **kwargs) for cls in self._entries.values()] # type: ignore[return-value] + return [cast(T, cls(*args, **kwargs)) for cls in self._entries.values()] def load_from_module(self, module_path: str, base_class: type[Any]) -> None: module = importlib.import_module(module_path) diff --git a/sentinel/graph/backend.py b/sentinel/graph/backend.py index 97db455..bb6313b 100644 --- a/sentinel/graph/backend.py +++ b/sentinel/graph/backend.py @@ -52,13 +52,13 @@ def neighbors(self, node_id: str) -> list[GraphNode]: return [self._node_data[n] for n in self._graph.successors(node_id) if n in self._node_data] def predecessors(self, node_id: str) -> list[GraphNode]: - return [self._node_data[n] for n in self._graph.predecessors(node_id) if n in self._node_data] + return [ + self._node_data[n] for n in self._graph.predecessors(node_id) if n in self._node_data + ] def find_paths(self, source: str, target: str, max_length: int = 10) -> list[list[str]]: try: - return list( - self._nx.all_simple_paths(self._graph, source, target, cutoff=max_length) - ) + return list(self._nx.all_simple_paths(self._graph, source, target, cutoff=max_length)) except (self._nx.NodeNotFound, self._nx.NetworkXError): return [] diff --git a/sentinel/graph/builder.py b/sentinel/graph/builder.py index 5726358..b218ca1 100644 --- a/sentinel/graph/builder.py +++ b/sentinel/graph/builder.py @@ -85,7 +85,9 @@ def _walk_ast( line=start_line + 1, ) ) - self._backend.add_edge(GraphEdge(source=parent_id, target=fn_id, type=EdgeType.CALLS)) + self._backend.add_edge( + GraphEdge(source=parent_id, target=fn_id, type=EdgeType.CALLS) + ) parent_id = fn_id elif node_type == "class_definition": @@ -113,5 +115,6 @@ def _walk_ast( def _first_child_text(self, node: dict[str, Any], child_type: str) -> str | None: for child in node.get("children", []): if child.get("type") == child_type: - return child.get("text") + text = child.get("text") + return str(text) if text is not None else None return None diff --git a/sentinel/llm/claude.py b/sentinel/llm/claude.py index 3cf68a8..26aef2c 100644 --- a/sentinel/llm/claude.py +++ b/sentinel/llm/claude.py @@ -64,9 +64,7 @@ async def complete_structured( if hasattr(response_model, "model_json_schema") else {} ) - structured_system = ( - f"{system or ''}\n\nRespond ONLY with valid JSON matching:\n{json.dumps(schema, indent=2)}" - ) + structured_system = f"{system or ''}\n\nRespond ONLY with valid JSON matching:\n{json.dumps(schema, indent=2)}" response = await self.complete( messages=messages, system=structured_system, diff --git a/sentinel/llm/openai.py b/sentinel/llm/openai.py index 3287dda..90cd897 100644 --- a/sentinel/llm/openai.py +++ b/sentinel/llm/openai.py @@ -58,9 +58,7 @@ async def complete_structured( if hasattr(response_model, "model_json_schema") else {} ) - structured_system = ( - f"{system or ''}\n\nRespond ONLY with valid JSON matching:\n{json.dumps(schema, indent=2)}" - ) + structured_system = f"{system or ''}\n\nRespond ONLY with valid JSON matching:\n{json.dumps(schema, indent=2)}" response = await self.complete( messages=messages, system=structured_system, diff --git a/sentinel/logging.py b/sentinel/logging.py index 0f734cb..590ddd3 100644 --- a/sentinel/logging.py +++ b/sentinel/logging.py @@ -2,6 +2,7 @@ import logging import sys +from typing import Any import structlog @@ -16,13 +17,15 @@ def configure_logging(log_level: str = "INFO") -> None: structlog.processors.TimeStamper(fmt="iso"), ] + renderer: Any if sys.stderr.isatty(): renderer = structlog.dev.ConsoleRenderer() else: renderer = structlog.processors.JSONRenderer() + processors: list[Any] = [*shared_processors, renderer] structlog.configure( - processors=[*shared_processors, renderer], + processors=processors, wrapper_class=structlog.make_filtering_bound_logger(level), logger_factory=structlog.PrintLoggerFactory(), cache_logger_on_first_use=True, diff --git a/sentinel/mcp.py b/sentinel/mcp.py index 8975a1d..f57fa0c 100644 --- a/sentinel/mcp.py +++ b/sentinel/mcp.py @@ -90,22 +90,43 @@ def _get_stored(audit_id: str) -> tuple[Any, Any] | None: # --------------------------------------------------------------------------- _EXT_TO_LANG: dict[str, str] = { - ".py": "python", ".js": "javascript", ".mjs": "javascript", - ".ts": "typescript", ".tsx": "typescript", - ".go": "go", ".rb": "ruby", ".java": "java", - ".rs": "rust", ".php": "php", ".cs": "csharp", - ".cpp": "cpp", ".cc": "cpp", ".c": "c", - ".sh": "shell", ".bash": "shell", - ".yaml": "yaml", ".yml": "yaml", - ".json": "json", ".toml": "toml", ".tf": "terraform", - ".kt": "kotlin", ".scala": "scala", ".swift": "swift", + ".py": "python", + ".js": "javascript", + ".mjs": "javascript", + ".ts": "typescript", + ".tsx": "typescript", + ".go": "go", + ".rb": "ruby", + ".java": "java", + ".rs": "rust", + ".php": "php", + ".cs": "csharp", + ".cpp": "cpp", + ".cc": "cpp", + ".c": "c", + ".sh": "shell", + ".bash": "shell", + ".yaml": "yaml", + ".yml": "yaml", + ".json": "json", + ".toml": "toml", + ".tf": "terraform", + ".kt": "kotlin", + ".scala": "scala", + ".swift": "swift", } _LANG_TO_EXTS: dict[str, list[str]] = { - "python": [".py"], "javascript": [".js", ".mjs"], - "typescript": [".ts", ".tsx"], "go": [".go"], - "ruby": [".rb"], "java": [".java"], "rust": [".rs"], - "php": [".php"], "csharp": [".cs"], "cpp": [".cpp", ".cc"], + "python": [".py"], + "javascript": [".js", ".mjs"], + "typescript": [".ts", ".tsx"], + "go": [".go"], + "ruby": [".rb"], + "java": [".java"], + "rust": [".rs"], + "php": [".php"], + "csharp": [".cs"], + "cpp": [".cpp", ".cc"], } @@ -126,9 +147,7 @@ async def _build_context( settings = get_settings() request = AuditRequest( target=Path(target).resolve(), - scope=AuditScope( - languages=[lang.strip() for lang in languages.split(",") if lang.strip()] - ), + scope=AuditScope(languages=[lang.strip() for lang in languages.split(",") if lang.strip()]), llm_enabled=False, ) ctx = AuditContext(request) @@ -152,8 +171,8 @@ async def _build_context( async def _parse_one(ts: Any, path: str, content: str, ctx: Any) -> None: try: ctx.parsed_files[path] = await ts.parse(Path(path), content) - except Exception: - pass + except Exception as exc: # noqa: BLE001 + log.debug("parse_skip", path=path, reason=str(exc)) async def _run_rules_on_ctx(ctx: Any, tag_filter: str = "") -> list[Any]: @@ -166,9 +185,7 @@ async def _run_rules_on_ctx(ctx: Any, tag_filter: str = "") -> list[Any]: if tag_filter: rules = [r for r in rules if tag_filter.lower() in r.metadata.tags] or rules - results = await asyncio.gather( - *[rule.match(ctx) for rule in rules], return_exceptions=True - ) + results = await asyncio.gather(*[rule.match(ctx) for rule in rules], return_exceptions=True) findings: list[Any] = [] for result in results: if not isinstance(result, BaseException): @@ -205,9 +222,7 @@ async def sentinel_audit( settings = get_settings() request = AuditRequest( target=Path(target).resolve(), - scope=AuditScope( - languages=[lang.strip() for lang in languages.split(",") if lang.strip()] - ), + scope=AuditScope(languages=[lang.strip() for lang in languages.split(",") if lang.strip()]), llm_enabled=not no_llm, ) @@ -254,18 +269,58 @@ async def sentinel_surface(target: str, languages: str = "") -> str: lang_counts[lang] = lang_counts.get(lang, 0) + 1 _SECURITY_KEYWORDS = [ - "auth", "login", "password", "token", "secret", "key", "credential", - "admin", "user", "payment", "upload", "execute", "query", "eval", - "config", "settings", "database", "db", "sql", "shell", "subprocess", - "webhook", "api", "route", "endpoint", "middleware", "deserializ", - "serialize", "pickle", "yaml", "jwt", "session", "cookie", "csrf", - "permission", "role", "acl", "rbac", "oauth", "saml", "signature", - "encrypt", "decrypt", "hash", "sign", "verify", "certificate", "tls", + "auth", + "login", + "password", + "token", + "secret", + "key", + "credential", + "admin", + "user", + "payment", + "upload", + "execute", + "query", + "eval", + "config", + "settings", + "database", + "db", + "sql", + "shell", + "subprocess", + "webhook", + "api", + "route", + "endpoint", + "middleware", + "deserializ", + "serialize", + "pickle", + "yaml", + "jwt", + "session", + "cookie", + "csrf", + "permission", + "role", + "acl", + "rbac", + "oauth", + "saml", + "signature", + "encrypt", + "decrypt", + "hash", + "sign", + "verify", + "certificate", + "tls", ] notable = sorted( - p for p in ctx.file_contents - if any(kw in p.lower() for kw in _SECURITY_KEYWORDS) + p for p in ctx.file_contents if any(kw in p.lower() for kw in _SECURITY_KEYWORDS) ) lines = [ @@ -283,9 +338,7 @@ async def sentinel_surface(target: str, languages: str = "") -> str: lines += ["", "## Security-Relevant Files", ""] for path in notable[:50]: meta = ctx.file_metadata.get(path, {}) - lines.append( - f"- `{path}` ({meta.get('language', '?')}, {meta.get('lines', 0)} lines)" - ) + lines.append(f"- `{path}` ({meta.get('language', '?')}, {meta.get('lines', 0)} lines)") if not notable: lines.append("_No notably named files detected._") @@ -333,7 +386,9 @@ async def sentinel_trace( "secret": [NodeType.SECRET], "all": [NodeType.DATABASE, NodeType.OUTPUT, NodeType.SECRET], } - sink_types = _SINK_MAP.get(sink_type.lower(), [NodeType.DATABASE, NodeType.OUTPUT, NodeType.SECRET]) + sink_types = _SINK_MAP.get( + sink_type.lower(), [NodeType.DATABASE, NodeType.OUTPUT, NodeType.SECRET] + ) header = [ f"# Taint Flow Analysis — `{target}`", @@ -473,9 +528,7 @@ def _emit_node(node: Any) -> None: for src, dst, _ in crossings[:30]: _emit_node(src) _emit_node(dst) - mermaid.append( - f" {_safe_id(src.id)} -->|trust crossing| {_safe_id(dst.id)}" - ) + mermaid.append(f" {_safe_id(src.id)} -->|trust crossing| {_safe_id(dst.id)}") for ep_id, priv_id in priv_pairs[:20]: ep = backend.get_node(ep_id) @@ -485,9 +538,7 @@ def _emit_node(node: Any) -> None: if priv: _emit_node(priv) if ep and priv: - mermaid.append( - f" {_safe_id(ep_id)} -.->|reachable| {_safe_id(priv_id)}" - ) + mermaid.append(f" {_safe_id(ep_id)} -.->|reachable| {_safe_id(priv_id)}") if len(mermaid) <= 2: mermaid.append(' A["No inter-trust edges detected"]') @@ -594,9 +645,7 @@ async def sentinel_logic( settings = get_settings() request = AuditRequest( target=Path(target).resolve(), - scope=AuditScope( - languages=[lang.strip() for lang in languages.split(",") if lang.strip()] - ), + scope=AuditScope(languages=[lang.strip() for lang in languages.split(",") if lang.strip()]), llm_enabled=not no_llm, ) ctx = AuditContext(request) @@ -613,35 +662,37 @@ async def sentinel_logic( for line_num, line in enumerate(file_lines, 1): for description, pattern, mitigation in _LOGIC_PATTERNS: if pattern.search(line): - ctx.add_finding(Finding( - title=f"Logic Flaw Pattern: {description}", - severity=Severity.MEDIUM, - confidence=Confidence.LOW, - affected_components=[rel_path], - locations=[Location(file=rel_path, line_start=line_num)], - attack_surface="Application logic / authorization layer", - exploitation_requirements="Understanding of application auth flow", - technical_explanation=description, - root_cause="Potential insufficient or bypassable authorization check", - attack_scenario=( - "Attacker manipulates request parameters or exploits " - "logic flaws to access unauthorized resources or escalate privileges." - ), - potential_impact="Unauthorized data access or privilege escalation", - blast_radius="Depends on exposed resource sensitivity", - detection_difficulty="medium", - business_risk="Unauthorized access to sensitive data or admin functions", - mitigation_strategy=mitigation, - secure_refactor_recommendations=[ - "Always verify resource ownership server-side", - "Never trust client-supplied role or privilege values", - "Use framework-level authorization decorators consistently", - "Apply OWASP ASVS Level 2 access control requirements", - ], - analyzer="sentinel_logic", - rule_id="LOGIC-PATTERN", - tags=["logic", "auth", "idor", "access-control"], - )) + ctx.add_finding( + Finding( + title=f"Logic Flaw Pattern: {description}", + severity=Severity.MEDIUM, + confidence=Confidence.LOW, + affected_components=[rel_path], + locations=[Location(file=rel_path, line_start=line_num)], + attack_surface="Application logic / authorization layer", + exploitation_requirements="Understanding of application auth flow", + technical_explanation=description, + root_cause="Potential insufficient or bypassable authorization check", + attack_scenario=( + "Attacker manipulates request parameters or exploits " + "logic flaws to access unauthorized resources or escalate privileges." + ), + potential_impact="Unauthorized data access or privilege escalation", + blast_radius="Depends on exposed resource sensitivity", + detection_difficulty="medium", + business_risk="Unauthorized access to sensitive data or admin functions", + mitigation_strategy=mitigation, + secure_refactor_recommendations=[ + "Always verify resource ownership server-side", + "Never trust client-supplied role or privilege values", + "Use framework-level authorization decorators consistently", + "Apply OWASP ASVS Level 2 access control requirements", + ], + analyzer="sentinel_logic", + rule_id="LOGIC-PATTERN", + tags=["logic", "auth", "idor", "access-control"], + ) + ) findings = sorted(ctx.findings, key=lambda f: f.risk_score, reverse=True) dummy = AuditResult( @@ -721,9 +772,7 @@ async def sentinel_review( request_id=request.id, status=AuditStatus.COMPLETED, findings=sorted_findings, - summary=AuditSummary.from_findings( - sorted_findings, 0.0, 1, content.count("\n") + 1 - ), + summary=AuditSummary.from_findings(sorted_findings, 0.0, 1, content.count("\n") + 1), ) _store_result(dummy, request) @@ -840,11 +889,21 @@ async def sentinel_verify( target_line = file_lines[line_idx] if line_idx < len(file_lines) else "" _VULN_TOKENS = [ - 'execute(f"', "execute(f'", "execute(%", ".execute(", - 'shell=True', 'os.system(', 'subprocess.', - 'password = "', "password = '", 'secret = "', "secret = '", - 'verify=False', 'ssl_verify=False', - 'eval(', 'exec(', + 'execute(f"', + "execute(f'", + "execute(%", + ".execute(", + "shell=True", + "os.system(", + "subprocess.", + 'password = "', + "password = '", + 'secret = "', + "secret = '", + "verify=False", + "ssl_verify=False", + "eval(", + "exec(", ] matched = [tok for tok in _VULN_TOKENS if tok in target_line] @@ -1031,7 +1090,7 @@ async def sentinel_diff( ( "TLS / Transport", "Non-HTTPS URL hardcoded for external service", - re.compile(r'http://(?!localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])', re.I), + re.compile(r"http://(?!localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])", re.I), "Use HTTPS for all external service URLs", ), ( @@ -1085,7 +1144,7 @@ async def sentinel_diff( ( "Dependency / Supply Chain", "Unpinned dependency reference", - re.compile(r'pip install\s+\w+(?![>== str: f"Available tags: {', '.join(f'`{t}`' for t in available)}" ) - results = await asyncio.gather( - *[rule.match(ctx) for rule in matching], return_exceptions=True - ) + results = await asyncio.gather(*[rule.match(ctx) for rule in matching], return_exceptions=True) findings: list[Any] = [] for result in results: if not isinstance(result, BaseException): @@ -1358,9 +1415,7 @@ async def sentinel_hunt(target: str, category: str) -> str: request_id=request.id, status=AuditStatus.COMPLETED, findings=findings, - summary=AuditSummary.from_findings( - findings, 0.0, ctx.files_analyzed, ctx.lines_analyzed - ), + summary=AuditSummary.from_findings(findings, 0.0, ctx.files_analyzed, ctx.lines_analyzed), ) _store_result(dummy, request) diff --git a/sentinel/models/audit.py b/sentinel/models/audit.py index d3229ac..aecdf82 100644 --- a/sentinel/models/audit.py +++ b/sentinel/models/audit.py @@ -1,17 +1,17 @@ from __future__ import annotations -from datetime import datetime, timezone -from enum import Enum +from datetime import datetime +from enum import StrEnum from pathlib import Path from typing import Any from uuid import UUID, uuid4 from pydantic import BaseModel, Field -from sentinel.models.finding import Finding, Severity +from sentinel.models.finding import Finding -class AuditMode(str, Enum): +class AuditMode(StrEnum): FULL = "full" QUICK = "quick" TARGETED = "targeted" @@ -36,7 +36,7 @@ class AuditRequest(BaseModel): metadata: dict[str, Any] = Field(default_factory=dict) -class AuditStatus(str, Enum): +class AuditStatus(StrEnum): PENDING = "pending" RUNNING = "running" COMPLETED = "completed" @@ -59,7 +59,7 @@ def from_findings( duration: float, files: int, lines: int, - ) -> "AuditSummary": + ) -> AuditSummary: by_sev: dict[str, int] = {} by_analyzer: dict[str, int] = {} for f in findings: diff --git a/sentinel/models/finding.py b/sentinel/models/finding.py index 6580f41..7ded48b 100644 --- a/sentinel/models/finding.py +++ b/sentinel/models/finding.py @@ -1,14 +1,14 @@ from __future__ import annotations -from datetime import datetime, timezone -from enum import Enum +from datetime import UTC, datetime +from enum import StrEnum from typing import Any from uuid import UUID, uuid4 from pydantic import BaseModel, Field -class Severity(str, Enum): +class Severity(StrEnum): CRITICAL = "critical" HIGH = "high" MEDIUM = "medium" @@ -16,7 +16,7 @@ class Severity(str, Enum): INFO = "info" -class Confidence(str, Enum): +class Confidence(StrEnum): CONFIRMED = "confirmed" HIGH = "high" MEDIUM = "medium" @@ -75,7 +75,7 @@ class Finding(BaseModel): analyzer: str rule_id: str | None = None - discovered_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + discovered_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) metadata: dict[str, Any] = Field(default_factory=dict) @property diff --git a/sentinel/models/graph.py b/sentinel/models/graph.py index fdc34fc..87dfcf6 100644 --- a/sentinel/models/graph.py +++ b/sentinel/models/graph.py @@ -1,12 +1,12 @@ from __future__ import annotations -from enum import Enum +from enum import StrEnum from typing import Any from pydantic import BaseModel, Field -class NodeType(str, Enum): +class NodeType(StrEnum): FILE = "file" FUNCTION = "function" CLASS = "class" @@ -21,7 +21,7 @@ class NodeType(str, Enum): OUTPUT = "output" -class EdgeType(str, Enum): +class EdgeType(StrEnum): CALLS = "calls" IMPORTS = "imports" INHERITS = "inherits" @@ -33,7 +33,7 @@ class EdgeType(str, Enum): WRITES_OUTPUT = "writes_output" -class TrustLevel(str, Enum): +class TrustLevel(StrEnum): UNTRUSTED = "untrusted" SEMI_TRUSTED = "semi_trusted" TRUSTED = "trusted" diff --git a/sentinel/parsers/treesitter.py b/sentinel/parsers/treesitter.py index 3cd628f..c6baf5c 100644 --- a/sentinel/parsers/treesitter.py +++ b/sentinel/parsers/treesitter.py @@ -12,7 +12,8 @@ try: import tree_sitter_python as _tspython - from tree_sitter import Language, Parser as _TSParser + from tree_sitter import Language + from tree_sitter import Parser as _TSParser _PYTHON_LANGUAGE = Language(_tspython.language()) _HAS_TREE_SITTER = True diff --git a/sentinel/reporting/markdown_reporter.py b/sentinel/reporting/markdown_reporter.py index 779c494..0e45819 100644 --- a/sentinel/reporting/markdown_reporter.py +++ b/sentinel/reporting/markdown_reporter.py @@ -67,8 +67,8 @@ def _render_finding(self, index: int, f: Finding) -> list[str]: "", f"### {index}. {badge} — {f.title}", "", - f"| | |", - f"|---|---|", + "| | |", + "|---|---|", f"| **Confidence** | {f.confidence.value} |", f"| **Rule** | `{f.rule_id or 'N/A'}` |", f"| **CWE** | {', '.join(f.cwe_ids) or 'N/A'} |", diff --git a/sentinel/reporting/sarif_reporter.py b/sentinel/reporting/sarif_reporter.py index 93e06c0..4ea39d3 100644 --- a/sentinel/reporting/sarif_reporter.py +++ b/sentinel/reporting/sarif_reporter.py @@ -83,5 +83,6 @@ def _finding_to_result(self, f: Finding) -> dict[str, Any]: "ruleId": f.rule_id or str(f.id), "level": _SEVERITY_LEVEL.get(f.severity.value, "warning"), "message": {"text": f.technical_explanation}, - "locations": locations or [{"physicalLocation": {"artifactLocation": {"uri": "unknown"}}}], + "locations": locations + or [{"physicalLocation": {"artifactLocation": {"uri": "unknown"}}}], } diff --git a/sentinel/rules/builtin/auth.py b/sentinel/rules/builtin/auth.py index a3e437f..4f50bb6 100644 --- a/sentinel/rules/builtin/auth.py +++ b/sentinel/rules/builtin/auth.py @@ -10,17 +10,20 @@ # Hardcoded credential patterns _HARDCODED_SECRET_PATTERNS = [ - re.compile(r'(password|passwd|secret|api_key|apikey|token|auth_token)\s*=\s*["\'][^"\']{4,}["\']', re.IGNORECASE), + re.compile( + r'(password|passwd|secret|api_key|apikey|token|auth_token)\s*=\s*["\'][^"\']{4,}["\']', + re.IGNORECASE, + ), re.compile(r'(AWS_SECRET|AWS_ACCESS_KEY|PRIVATE_KEY)\s*=\s*["\'][^"\']+["\']', re.IGNORECASE), ] # Disabled security controls _DISABLED_AUTH_PATTERNS = [ - re.compile(r'verify\s*=\s*False', re.IGNORECASE), - re.compile(r'ssl_verify\s*=\s*False', re.IGNORECASE), - re.compile(r'check_hostname\s*=\s*False', re.IGNORECASE), - re.compile(r'ALLOW_ALL_ORIGINS\s*=\s*True', re.IGNORECASE), - re.compile(r'DEBUG\s*=\s*True', re.IGNORECASE), + re.compile(r"verify\s*=\s*False", re.IGNORECASE), + re.compile(r"ssl_verify\s*=\s*False", re.IGNORECASE), + re.compile(r"check_hostname\s*=\s*False", re.IGNORECASE), + re.compile(r"ALLOW_ALL_ORIGINS\s*=\s*True", re.IGNORECASE), + re.compile(r"DEBUG\s*=\s*True", re.IGNORECASE), ] @@ -70,9 +73,19 @@ async def match(self, ctx: AuditContext) -> list[Finding]: "extracts the credential from source or git history, and authenticates as the service." ), exploit_chain=[ - ExploitChainStep(step=1, component="VCS", action="Clone or access repository"), - ExploitChainStep(step=2, component=rel_path, action=f"Extract credential at line {line_no}"), - ExploitChainStep(step=3, component="Target Service", action="Authenticate using extracted secret"), + ExploitChainStep( + step=1, component="VCS", action="Clone or access repository" + ), + ExploitChainStep( + step=2, + component=rel_path, + action=f"Extract credential at line {line_no}", + ), + ExploitChainStep( + step=3, + component="Target Service", + action="Authenticate using extracted secret", + ), ], potential_impact="Service impersonation, data access, lateral movement to connected systems", blast_radius="All resources the credential grants access to", @@ -144,10 +157,24 @@ async def match(self, ctx: AuditContext) -> list[Finding]: "Client accepts it without verification, attacker reads/modifies all traffic." ), exploit_chain=[ - ExploitChainStep(step=1, component="Network", action="ARP spoofing or rogue AP"), - ExploitChainStep(step=2, component="TLS Handshake", action="Attacker presents forged cert"), - ExploitChainStep(step=3, component=rel_path, action="Client accepts cert (verify=False)"), - ExploitChainStep(step=4, component="Traffic", action="Attacker reads plaintext credentials/data"), + ExploitChainStep( + step=1, component="Network", action="ARP spoofing or rogue AP" + ), + ExploitChainStep( + step=2, + component="TLS Handshake", + action="Attacker presents forged cert", + ), + ExploitChainStep( + step=3, + component=rel_path, + action="Client accepts cert (verify=False)", + ), + ExploitChainStep( + step=4, + component="Traffic", + action="Attacker reads plaintext credentials/data", + ), ], potential_impact="Credential theft, session hijacking, data interception", blast_radius="All data transmitted over affected connections", diff --git a/sentinel/rules/builtin/injection.py b/sentinel/rules/builtin/injection.py index a085f64..346f70d 100644 --- a/sentinel/rules/builtin/injection.py +++ b/sentinel/rules/builtin/injection.py @@ -22,9 +22,9 @@ _CMD_PATTERNS = [ re.compile(r'os\.system\s*\(\s*f["\']', re.IGNORECASE), re.compile(r'os\.system\s*\(\s*["\'].*%[sd]', re.IGNORECASE), - re.compile(r'os\.system\s*\(\s*[^\)]*\+', re.IGNORECASE), + re.compile(r"os\.system\s*\(\s*[^\)]*\+", re.IGNORECASE), re.compile(r'subprocess\.\w+\s*\(\s*f["\']', re.IGNORECASE), - re.compile(r'shell\s*=\s*True', re.IGNORECASE), + re.compile(r"shell\s*=\s*True", re.IGNORECASE), re.compile(r'Popen\s*\(\s*f["\']', re.IGNORECASE), ] diff --git a/uv.lock b/uv.lock index 918531d..968760d 100644 --- a/uv.lock +++ b/uv.lock @@ -240,6 +240,110 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] +[[package]] +name = "coverage" +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381 }, + { url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880 }, + { url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303 }, + { url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218 }, + { url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326 }, + { url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267 }, + { url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430 }, + { url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017 }, + { url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080 }, + { url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843 }, + { url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802 }, + { url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707 }, + { url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880 }, + { url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816 }, + { url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483 }, + { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554 }, + { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908 }, + { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419 }, + { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159 }, + { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270 }, + { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538 }, + { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821 }, + { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191 }, + { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337 }, + { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404 }, + { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903 }, + { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780 }, + { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093 }, + { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900 }, + { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515 }, + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576 }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942 }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935 }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541 }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780 }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912 }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165 }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908 }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873 }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030 }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694 }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469 }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112 }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923 }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540 }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262 }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617 }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912 }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987 }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416 }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558 }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163 }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981 }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604 }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321 }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502 }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688 }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788 }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851 }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104 }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621 }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953 }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992 }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503 }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852 }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161 }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021 }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858 }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823 }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099 }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638 }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295 }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360 }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174 }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739 }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351 }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612 }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985 }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107 }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513 }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650 }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089 }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982 }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579 }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316 }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427 }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745 }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146 }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254 }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276 }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346 }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + [[package]] name = "cryptography" version = "48.0.0" @@ -1040,6 +1144,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075 }, ] +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876 }, +] + [[package]] name = "python-dotenv" version = "1.2.2" @@ -1347,16 +1465,6 @@ dependencies = [ { name = "uvicorn", extra = ["standard"] }, ] -[package.optional-dependencies] -dev = [ - { name = "black" }, - { name = "flake8" }, - { name = "mypy" }, - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "ruff" }, -] - [package.dev-dependencies] dev = [ { name = "black" }, @@ -1364,34 +1472,28 @@ dev = [ { name = "mypy" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-cov" }, { name = "ruff" }, ] [package.metadata] requires-dist = [ { name = "anthropic", specifier = ">=0.40.0" }, - { name = "black", marker = "extra == 'dev'", specifier = ">=24.0.0" }, { name = "fastapi", specifier = ">=0.115.0" }, - { name = "flake8", marker = "extra == 'dev'", specifier = ">=7.0.0" }, { name = "gitpython", specifier = ">=3.1.43" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "mcp", extras = ["cli"], specifier = ">=1.0.0" }, - { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10.0" }, { name = "networkx", specifier = ">=3.3" }, { name = "openai", specifier = ">=1.40.0" }, { name = "pydantic", specifier = ">=2.7.0" }, { name = "pydantic-settings", specifier = ">=2.3.0" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0" }, - { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" }, { name = "rich", specifier = ">=13.7.0" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.5.0" }, { name = "structlog", specifier = ">=24.4.0" }, { name = "tree-sitter", specifier = ">=0.23.0" }, { name = "tree-sitter-python", specifier = ">=0.23.0" }, { name = "typer", specifier = ">=0.12.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" }, ] -provides-extras = ["dev"] [package.metadata.requires-dev] dev = [ @@ -1400,6 +1502,7 @@ dev = [ { name = "mypy", specifier = ">=1.10.0" }, { name = "pytest", specifier = ">=8.3.0" }, { name = "pytest-asyncio", specifier = ">=0.23.0" }, + { name = "pytest-cov", specifier = ">=5.0.0" }, { name = "ruff", specifier = ">=0.5.0" }, ] @@ -1465,6 +1568,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510 }, ] +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704 }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454 }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561 }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824 }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227 }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859 }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204 }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084 }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285 }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924 }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018 }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948 }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341 }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159 }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290 }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141 }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847 }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088 }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866 }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887 }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704 }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628 }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180 }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674 }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976 }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755 }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265 }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726 }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859 }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713 }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084 }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973 }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223 }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973 }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082 }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490 }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263 }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736 }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717 }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461 }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855 }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144 }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683 }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196 }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393 }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583 }, +] + [[package]] name = "tqdm" version = "4.67.3"