diff --git a/.github/actions/cora-review-simple/action.yml b/.github/actions/cora-review-simple/action.yml new file mode 100644 index 0000000..ba7808a --- /dev/null +++ b/.github/actions/cora-review-simple/action.yml @@ -0,0 +1,195 @@ +name: 'Cora AI Code Review' +description: 'Run cora AI code review on a PR diff β€” posts a PR comment. Uses GitHub Secrets directly (no Infisical required). SARIF upload is optional.' + +inputs: + base-branch: + description: 'Base branch to compare against (default: origin/develop)' + required: false + default: 'origin/develop' + severity: + description: 'Minimum severity to report (info, minor, major, critical)' + required: false + default: 'major' + cora-version: + description: 'cora-cli version tag (default: latest release)' + required: false + default: 'latest' + upload-sarif: + description: 'Upload SARIF to GitHub Code Scanning (default: false)' + required: false + default: 'false' + cora-api-key: + description: 'LLM API key (from secrets.CORA_API_KEY)' + required: true + cora-base-url: + description: 'LLM API base URL (from secrets.CORA_BASE_URL)' + required: true + cora-model: + description: 'LLM model ID (from secrets.CORA_MODEL)' + required: true + github-token: + description: 'GitHub token for PR comments and SARIF upload' + required: true + +runs: + using: 'composite' + steps: + - name: Resolve cora-cli version + id: resolve + shell: bash + run: | + if [ "${{ inputs.cora-version }}" = "latest" ]; then + VERSION=$(curl -sL https://api.github.com/repos/ajianaz/cora-cli/releases/latest | python3 -c "import sys,json; print(json.load(sys.stdin)['tag_name'])") + else + VERSION="${{ inputs.cora-version }}" + fi + echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" + echo "Resolved cora-cli version: $VERSION" + + - name: Install cora-cli + shell: bash + run: | + ARCH=$(uname -m) + if [ "$ARCH" = "x86_64" ]; then + ASSET="cora-x86_64-unknown-linux-gnu" + elif [ "$ARCH" = "aarch64" ]; then + ASSET="cora-aarch64-unknown-linux-gnu" + else + echo "Unsupported architecture: $ARCH" + exit 1 + fi + curl -sL "https://github.com/ajianaz/cora-cli/releases/download/${{ steps.resolve.outputs.VERSION }}/${ASSET}-${{ steps.resolve.outputs.VERSION }}.tar.gz" | tar xz -C /usr/local/bin cora + chmod +x /usr/local/bin/cora + cora --version + + - name: Run cora review + id: review + shell: bash + env: + CORA_API_KEY: ${{ inputs.cora-api-key }} + CORA_BASE_URL: ${{ inputs.cora-base-url }} + CORA_MODEL: ${{ inputs.cora-model }} + run: | + cora review \ + --base ${{ inputs.base-branch }} \ + --format sarif \ + --severity ${{ inputs.severity }} \ + --quiet \ + > cora-results.sarif 2>/dev/null + echo "Cora review complete ($(wc -c < cora-results.sarif) bytes)" + + - name: Upload SARIF to GitHub Code Scanning + if: inputs.upload-sarif == 'true' + continue-on-error: true + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: cora-results.sarif + category: cora-review + + - name: Post PR comment + if: always() && github.event_name == 'pull_request' + uses: actions/github-script@v7 + env: + GH_TOKEN: ${{ inputs.github-token }} + with: + script: | + const fs = require('fs'); + + let sarifContent; + try { + sarifContent = JSON.parse(fs.readFileSync('cora-results.sarif', 'utf8')); + } catch (e) { + sarifContent = null; + } + + let body; + if (!sarifContent || !sarifContent.runs || sarifContent.runs.length === 0) { + body = `## πŸ” Cora AI Code Review\n\nβœ… **No issues found.** Code looks good!`; + } else { + const results = sarifContent.runs[0].results || []; + if (results.length === 0) { + body = `## πŸ” Cora AI Code Review\n\nβœ… **No issues found.** Code looks good!`; + } else { + const grouped = {}; + for (const r of results) { + const sev = (r.level || 'note').charAt(0).toUpperCase() + (r.level || 'note').slice(1); + if (!grouped[sev]) grouped[sev] = []; + grouped[sev].push(r); + } + + const severityOrder = ['Error', 'Warning', 'Note']; + let table = ''; + + for (const sev of severityOrder) { + if (!grouped[sev]) continue; + const icon = sev === 'Error' ? 'πŸ”΄' : sev === 'Warning' ? '🟑' : 'πŸ”΅'; + table += `\n### ${icon} ${sev} (${grouped[sev].length})\n\n`; + for (const r of grouped[sev]) { + const loc = r.locations?.[0]?.physicalLocation; + const file = loc?.artifactLocation?.uri || 'unknown'; + const line = loc?.region?.startLine || '?'; + const msg = r.message?.text || r.message?.markdown || 'No message'; + table += `- \`${file}:${line}\` β€” ${msg}\n`; + } + } + + const hasErrors = (grouped['Error'] || []).length > 0; + const hasWarnings = (grouped['Warning'] || []).length > 0; + const status = hasErrors ? '❌ **Blocked** β€” critical issues found.' : + hasWarnings ? '⚠️ **Review recommended** β€” warnings found.' : + 'βœ… **Passed** β€” only informational notes.'; + + body = `## πŸ” Cora AI Code Review\n\n${status}\n${table}\n---\n_Review powered by [cora-cli](https://github.com/ajianaz/cora-cli) Β· BYOK Β· MIT_`; + } + } + + // Find existing cora comment and update it, or create new + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const existing = comments.find(c => + c.user.login === 'github-actions[bot]' && + c.body.startsWith('## πŸ” Cora AI Code Review') + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } + + - name: Check for blocking issues + if: always() + shell: bash + run: | + if [ -f cora-results.sarif ]; then + ERRORS=$(python3 -c " + import json, sys + try: + data = json.load(open('cora-results.sarif')) + results = data.get('runs', [{}])[0].get('results', []) + errors = [r for r in results if r.get('level') == 'error'] + print(len(errors)) + except: + print(0) + " 2>/dev/null || echo "0") + + if [ "$ERRORS" -gt 0 ]; then + echo "::error::Cora found $ERRORS blocking issue(s)." + exit 1 + fi + fi + echo "No blocking issues." diff --git a/.github/actions/cora-review/action.yml b/.github/actions/cora-review/action.yml new file mode 100644 index 0000000..a4198a7 --- /dev/null +++ b/.github/actions/cora-review/action.yml @@ -0,0 +1,210 @@ +name: 'Cora AI Code Review' +description: 'Run cora AI code review on a PR diff β€” posts a PR comment. Uses Infisical OIDC for LLM secrets (zero keys in GitHub). SARIF upload is optional.' + +inputs: + base-branch: + description: 'Base branch to compare against (default: origin/develop)' + required: false + default: 'origin/develop' + severity: + description: 'Minimum severity to report (info, minor, major, critical)' + required: false + default: 'major' + cora-version: + description: 'cora-cli version tag (default: latest release)' + required: false + default: 'latest' + upload-sarif: + description: 'Upload SARIF to GitHub Code Scanning (default: false)' + required: false + default: 'false' + infisical-identity-id: + description: 'Infisical OIDC identity ID (from secret INFISICAL_IDENTITY_ID)' + required: true + infisical-project: + description: 'Infisical project slug' + required: false + default: 'github-actions' + infisical-env: + description: 'Infisical environment slug' + required: false + default: 'prod' + infisical-domain: + description: 'Infisical domain URL' + required: false + default: 'https://infisical.ajianaz.dev' + github-token: + description: 'GitHub token for PR comments and SARIF upload' + required: true + +runs: + using: 'composite' + steps: + - name: Fetch LLM secrets from Infisical + uses: Infisical/secrets-action@v1.0.9 + with: + method: 'oidc' + identity-id: ${{ inputs.infisical-identity-id }} + project-slug: ${{ inputs.infisical-project }} + env-slug: ${{ inputs.infisical-env }} + domain: ${{ inputs.infisical-domain }} + + - name: Resolve cora-cli version + id: resolve + shell: bash + run: | + if [ "${{ inputs.cora-version }}" = "latest" ]; then + VERSION=$(curl -sL https://api.github.com/repos/ajianaz/cora-cli/releases/latest | python3 -c "import sys,json; print(json.load(sys.stdin)['tag_name'])") + else + VERSION="${{ inputs.cora-version }}" + fi + echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" + echo "Resolved cora-cli version: $VERSION" + + - name: Install cora-cli + shell: bash + run: | + ARCH=$(uname -m) + if [ "$ARCH" = "x86_64" ]; then + ASSET="cora-x86_64-unknown-linux-gnu" + elif [ "$ARCH" = "aarch64" ]; then + ASSET="cora-aarch64-unknown-linux-gnu" + else + echo "Unsupported architecture: $ARCH" + exit 1 + fi + curl -sL "https://github.com/ajianaz/cora-cli/releases/download/${{ steps.resolve.outputs.VERSION }}/${ASSET}-${{ steps.resolve.outputs.VERSION }}.tar.gz" | tar xz -C /usr/local/bin cora + chmod +x /usr/local/bin/cora + cora --version + + - name: Run cora review + id: review + shell: bash + env: + CORA_API_KEY: ${{ env.CORA_API_KEY }} + CORA_BASE_URL: ${{ env.CORA_BASE_URL }} + CORA_MODEL: ${{ env.CORA_MODEL }} + run: | + cora review \ + --base ${{ inputs.base-branch }} \ + --format sarif \ + --severity ${{ inputs.severity }} \ + --quiet \ + > cora-results.sarif 2>/dev/null + echo "Cora review complete ($(wc -c < cora-results.sarif) bytes)" + + - name: Upload SARIF to GitHub Code Scanning + if: inputs.upload-sarif == 'true' + continue-on-error: true + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: cora-results.sarif + category: cora-review + + - name: Post PR comment + if: always() && github.event_name == 'pull_request' + uses: actions/github-script@v7 + env: + GH_TOKEN: ${{ inputs.github-token }} + with: + script: | + const fs = require('fs'); + + let sarifContent; + try { + sarifContent = JSON.parse(fs.readFileSync('cora-results.sarif', 'utf8')); + } catch (e) { + sarifContent = null; + } + + let body; + if (!sarifContent || !sarifContent.runs || sarifContent.runs.length === 0) { + body = `## πŸ” Cora AI Code Review\n\nβœ… **No issues found.** Code looks good!`; + } else { + const results = sarifContent.runs[0].results || []; + if (results.length === 0) { + body = `## πŸ” Cora AI Code Review\n\nβœ… **No issues found.** Code looks good!`; + } else { + const grouped = {}; + for (const r of results) { + const sev = (r.level || 'note').charAt(0).toUpperCase() + (r.level || 'note').slice(1); + if (!grouped[sev]) grouped[sev] = []; + grouped[sev].push(r); + } + + const severityOrder = ['Error', 'Warning', 'Note']; + let table = ''; + + for (const sev of severityOrder) { + if (!grouped[sev]) continue; + const icon = sev === 'Error' ? 'πŸ”΄' : sev === 'Warning' ? '🟑' : 'πŸ”΅'; + table += `\n### ${icon} ${sev} (${grouped[sev].length})\n\n`; + for (const r of grouped[sev]) { + const loc = r.locations?.[0]?.physicalLocation; + const file = loc?.artifactLocation?.uri || 'unknown'; + const line = loc?.region?.startLine || '?'; + const msg = r.message?.text || r.message?.markdown || 'No message'; + table += `- \`${file}:${line}\` β€” ${msg}\n`; + } + } + + const hasErrors = (grouped['Error'] || []).length > 0; + const hasWarnings = (grouped['Warning'] || []).length > 0; + const status = hasErrors ? '❌ **Blocked** β€” critical issues found.' : + hasWarnings ? '⚠️ **Review recommended** β€” warnings found.' : + 'βœ… **Passed** β€” only informational notes.'; + + body = `## πŸ” Cora AI Code Review\n\n${status}\n${table}\n---\n_Review powered by [cora-cli](https://github.com/ajianaz/cora-cli) Β· BYOK Β· MIT_`; + } + } + + // Find existing cora comment and update it, or create new + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const existing = comments.find(c => + c.user.login === 'github-actions[bot]' && + c.body.startsWith('## πŸ” Cora AI Code Review') + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } + + - name: Check for blocking issues + if: always() + shell: bash + run: | + if [ -f cora-results.sarif ]; then + ERRORS=$(python3 -c " + import json, sys + try: + data = json.load(open('cora-results.sarif')) + results = data.get('runs', [{}])[0].get('results', []) + errors = [r for r in results if r.get('level') == 'error'] + print(len(errors)) + except: + print(0) + " 2>/dev/null || echo "0") + + if [ "$ERRORS" -gt 0 ]; then + echo "::error::Cora found $ERRORS blocking issue(s)." + exit 1 + fi + fi + echo "No blocking issues." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dad790f..e7595d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,12 @@ env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 +permissions: + contents: read + security-events: write + pull-requests: write + id-token: write + jobs: check: name: Check @@ -58,3 +64,26 @@ jobs: - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - run: cargo build --release + + cora-review: + name: Cora Review + runs-on: ubuntu-latest + needs: [check, fmt, clippy, test] + if: github.event_name == 'pull_request' + permissions: + contents: read + security-events: write + pull-requests: write + id-token: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + + - uses: ./.github/actions/cora-review + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + infisical-identity-id: ${{ secrets.INFISICAL_IDENTITY_ID }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 99705c0..764a6ed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,9 +64,9 @@ jobs: if: runner.os != 'Windows' run: | mkdir -p release - cp target/${{ matrix.target }}/release/cora release/${{ matrix.artifact }} + cp target/${{ matrix.target }}/release/cora release/cora cd release - tar czf ${{ matrix.artifact }}-${{ steps.version.outputs.VERSION }}.${{ matrix.ext }} ${{ matrix.artifact }} + tar czf ${{ matrix.artifact }}-${{ steps.version.outputs.VERSION }}.${{ matrix.ext }} cora - name: Package (Windows) if: runner.os == 'Windows' @@ -104,7 +104,7 @@ jobs: domain: "https://infisical.ajianaz.dev" - name: Publish to crates.io - run: cargo publish --token ${{ env.CARGO_REGISTRY_TOKEN }} + run: cargo publish --token ${{ env.CARGO_REGISTRY_TOKEN }} --allow-dirty release: name: Create GitHub Release @@ -151,10 +151,10 @@ jobs: | Platform | File | |----------|------| - | Linux (x86_64) | `cora-VERSION-x86_64-unknown-linux-gnu.tar.gz` | - | Linux (ARM64) | `cora-VERSION-aarch64-unknown-linux-gnu.tar.gz` | - | macOS (Apple Silicon) | `cora-VERSION-aarch64-apple-darwin.tar.gz` | - | Windows (x86_64) | `cora-VERSION-x86_64-pc-windows-msvc.zip` | + | Linux (x86_64) | `cora-x86_64-unknown-linux-gnu-VERSION.tar.gz` | + | Linux (ARM64) | `cora-aarch64-unknown-linux-gnu-VERSION.tar.gz` | + | macOS (Apple Silicon) | `cora-aarch64-apple-darwin-VERSION.tar.gz` | + | Windows (x86_64) | `cora-x86_64-pc-windows-msvc-VERSION.zip` | ### πŸš€ Quick Start @@ -162,8 +162,10 @@ jobs: # Install via cargo cargo install cora - # Or download binary - curl -fsSL https://github.com/ajianaz/cora-cli/releases/latest/download/cora-x86_64-unknown-linux-gnu-v0.1.1.tar.gz | tar xz + # Or download binary (extracts as 'cora') + curl -fsSL https://github.com/ajianaz/cora-cli/releases/latest/download/cora-x86_64-unknown-linux-gnu-VERSION.tar.gz | tar xz + chmod +x cora + sudo mv cora /usr/local/bin/ # Init project cora init diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e1aabf2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,92 @@ +# Changelog + +All notable changes to cora-cli are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- `cora config set --global` β€” write config to `~/.cora/config.yaml` instead of project `.cora.yaml` +- `cora config set base_url` β€” set base URL via CLI (previously only in YAML) +- Global config support (`~/.cora/config.yaml`) with priority chain: CLI flags β†’ env vars β†’ project β†’ global β†’ defaults +- Auto-migration from old `~/.cora/config.toml` to new YAML + `auth.toml` split + +### Changed + +- `cora config set` now writes YAML instead of TOML (compatible with config loader) +- API key storage moved from `~/.cora/config.toml` to `~/.cora/auth.toml` (0600 permissions) +- YAML serialization uses `skip_serializing_if` β€” no more `null` values in output + +### Fixed + +- **Severity comparison inverted** β€” `Critical` issues no longer silently pass `should_block` check (Ord ordering bug) +- Hook `mode: block` no longer exits with code 2 when "No issues found" (severity filter mismatch) +- Consistent severity logic across review, scan, and block mode paths + +## [0.1.2] - 2025-05-29 + +### Added + +- `cora init` β€” create `.cora.yaml` config file with provider/model selection +- `cora hook install|uninstall` β€” pre-commit hook management +- `cora config show|set` β€” configuration management +- CI composite action (`cora-review-simple`) for easy GitHub Actions integration +- Shell completions for bash, zsh, fish, and powershell +- `cora scan --incremental` with SHA256 content hash cache for fast incremental scanning +- `cora review --upload` for direct SARIF upload to GitHub Code Scanning +- `cora review --stream` for real-time review output +- `cora review --unpushed` for reviewing unpushed commits +- `cora review --base ` for branch comparison +- `cora review --diff-file ` for reviewing external diff files +- `cora providers` command to list available LLM providers +- `cora auth login` for interactive API key storage + +### Fixed + +- SARIF schema compliance for GitHub Code Scanning upload +- Clippy `format_in_format_args` warnings +- Replaced deprecated `serde_yaml` with `serde_yaml_ng` +- Normalized release binary naming (`cora-{arch}-{target}-v{version}.tar.gz`) + +### Changed + +- Replaced deprecated dependencies +- Removed unused dependencies +- Bumped minimum Rust version to 1.85 + +## [0.1.1] - 2025-05-27 + +### Changed + +- Replaced ASCII art banner with eye icon in README +- Updated README branding to cora-cli + +### Fixed + +- CI `cargo publish` with `--allow-dirty` for Cargo.lock mismatch on tag checkout + +## [0.1.0] - 2025-05-25 + +### Added + +- **AI Code Review** β€” review staged changes, commit ranges, branch diffs, and full project scans +- **BYOK** β€” bring your own API key (OpenAI, Anthropic, Groq, Ollama, Google) +- **5 LLM Providers** β€” with auto-detection from installed API keys +- **Pre-commit Hooks** β€” `cora hook install` for automatic review on every commit +- **SARIF Output** β€” `--format sarif` for GitHub Code Scanning integration +- **4 Output Formats** β€” pretty (colored), compact, JSON, SARIF +- **Project Config** β€” `.cora.yaml` per-project configuration with provider, focus, rules, ignore, and hook settings +- **Environment Variables** β€” `CORA_API_KEY`, `CORA_MODEL`, `CORA_PROVIDER`, `CORA_BASE_URL`, `CORA_CONFIG`, `CORA_FORMAT` +- **Severity Levels** β€” `info`, `minor`, `major`, `critical` with configurable thresholds +- **Focus Areas** β€” `security`, `performance`, `bugs`, `best_practice`, `maintainability` +- **Ignore Rules** β€” file patterns and rule-level exclusions +- **Cross-platform** β€” Linux (x86_64, ARM64), macOS (Apple Silicon), Windows (x86_64) +- **MIT License** β€” fully open source + +[Unreleased]: https://github.com/ajianaz/cora-cli/compare/v0.1.2...develop +[0.1.2]: https://github.com/ajianaz/cora-cli/compare/v0.1.1...v0.1.2 +[0.1.1]: https://github.com/ajianaz/cora-cli/compare/v0.1.0...v0.1.1 +[0.1.0]: https://github.com/ajianaz/cora-cli/releases/tag/v0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bd6274c..fe48c04 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,7 +21,7 @@ Be respectful, constructive, and inclusive. We follow the [Rust Code of Conduct] ### Prerequisites -- **Rust** 1.75+ (stable toolchain recommended) +- **Rust** 1.85+ (stable toolchain recommended) - **Git** for version control - An **LLM API key** (OpenAI, Anthropic, etc.) for testing review features @@ -47,14 +47,30 @@ cargo clippy -- -D warnings ``` cora-cli/ β”œβ”€β”€ src/ -β”‚ β”œβ”€β”€ main.rs # CLI entry point -β”‚ β”œβ”€β”€ cli.rs # Argument parsing (clap) -β”‚ β”œβ”€β”€ config.rs # Configuration management -β”‚ β”œβ”€β”€ scanner.rs # File scanning and diff generation -β”‚ β”œβ”€β”€ reviewer.rs # LLM integration for code review -β”‚ β”œβ”€β”€ formatter.rs # Output formatting -β”‚ └── ci.rs # CI/CD integration helpers -β”œβ”€β”€ tests/ # Integration tests +β”‚ β”œβ”€β”€ main.rs # CLI entry point + clap args +β”‚ β”œβ”€β”€ cli.rs # Argument parsing (clap) +β”‚ β”œβ”€β”€ commands/ # CLI subcommands +β”‚ β”‚ β”œβ”€β”€ review.rs # cora review +β”‚ β”‚ β”œβ”€β”€ scan.rs # cora scan +β”‚ β”‚ β”œβ”€β”€ config_cmd.rs # cora config show/set +β”‚ β”‚ β”œβ”€β”€ auth.rs # cora auth login/logout +β”‚ β”‚ └── init.rs # cora init +β”‚ β”œβ”€β”€ config/ +β”‚ β”‚ β”œβ”€β”€ loader.rs # Config resolution chain (global β†’ project β†’ env β†’ CLI) +β”‚ β”‚ β”œβ”€β”€ schema.rs # YAML config structs + merge logic +β”‚ β”‚ └── providers.rs # Provider presets (OpenAI, Anthropic, etc.) +β”‚ β”œβ”€β”€ engine/ +β”‚ β”‚ β”œβ”€β”€ review.rs # LLM review + SARIF generation +β”‚ β”‚ β”œβ”€β”€ scanner.rs # File scanning and diff generation +β”‚ β”‚ β”œβ”€β”€ types.rs # Issue, Severity, ScanResponse types +β”‚ β”‚ └── llm.rs # LLM API abstraction +β”‚ └── formatter/ +β”‚ └── mod.rs # Output formatting (pretty, compact, json, sarif) +β”œβ”€β”€ .github/ +β”‚ β”œβ”€β”€ workflows/ # CI, release, deploy workflows +β”‚ └── actions/ +β”‚ └── cora-review/ # Composite action for GitHub Actions +β”œβ”€β”€ tests/ # Integration tests β”œβ”€β”€ Cargo.toml └── README.md ``` @@ -68,7 +84,7 @@ cora-cli/ ``` 3. **Make your changes** and commit with meaningful messages: ```bash - git commit -s -m "feat: add support for SARIF output format" + git commit -m "feat: add support for SARIF output format" ``` 4. **Push** to your fork and open a **Pull Request** @@ -111,9 +127,10 @@ mod tests { use super::*; #[test] - fn test_scan_single_file() { - let result = scan_files(vec!["src/main.rs".into()]); - assert!(!result.is_empty()); + fn test_severity_from_str() { + use crate::engine::types::Severity; + assert_eq!(Severity::from_str_lossy("critical"), Severity::Critical); + assert_eq!(Severity::from_str_lossy("info"), Severity::Info); } } ``` diff --git a/Cargo.lock b/Cargo.lock index 74984e2..91956ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,15 +11,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - [[package]] name = "anstream" version = "1.0.0" @@ -162,19 +153,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" -[[package]] -name = "chrono" -version = "0.4.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "wasm-bindgen", - "windows-link", -] - [[package]] name = "clap" version = "4.6.1" @@ -253,11 +231,10 @@ dependencies = [ [[package]] name = "cora-cli" -version = "0.1.0" +version = "0.1.2" dependencies = [ "anyhow", "assert_cmd", - "chrono", "clap", "clap_complete", "colored", @@ -266,15 +243,13 @@ dependencies = [ "git2", "glob", "indicatif", - "owo-colors", "predicates", "regex", "reqwest", "serde", "serde_json", - "serde_yaml", + "serde_yaml_ng", "tempfile", - "thiserror", "tokio", "tokio-test", "toml", @@ -283,12 +258,6 @@ dependencies = [ "walkdir", ] -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - [[package]] name = "difflib" version = "0.4.0" @@ -623,30 +592,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - [[package]] name = "icu_collections" version = "2.1.1" @@ -883,15 +828,6 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - [[package]] name = "log" version = "0.4.30" @@ -972,35 +908,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" -[[package]] -name = "owo-colors" -version = "4.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -1197,15 +1104,6 @@ dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - [[package]] name = "redox_users" version = "0.5.2" @@ -1376,12 +1274,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - [[package]] name = "semver" version = "1.0.28" @@ -1453,10 +1345,10 @@ dependencies = [ ] [[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" +name = "serde_yaml_ng" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" dependencies = [ "indexmap", "itoa", @@ -1480,16 +1372,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - [[package]] name = "slab" version = "0.4.12" @@ -1643,9 +1525,7 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", - "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -2124,65 +2004,12 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - [[package]] name = "windows-sys" version = "0.52.0" diff --git a/Cargo.toml b/Cargo.toml index 33b2c00..c83f0a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cora-cli" -version = "0.1.1" +version = "0.1.2" edition = "2024" description = "CLI-first AI code review β€” BYOK, diff/scan/branch, pre-commit hooks" license = "MIT" @@ -15,8 +15,8 @@ rust-version = "1.85" clap = { version = "4", features = ["derive", "env"] } clap_complete = "4" -# Async runtime -tokio = { version = "1", features = ["full"] } +# Async runtime (only what we need) +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } # HTTP client (OpenAI-compatible API calls) reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls"], default-features = false } @@ -24,14 +24,13 @@ reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls"], defau # Serialization serde = { version = "1", features = ["derive"] } serde_json = "1" -serde_yaml = "0.9" +serde_yaml_ng = "0.10" # Git operations git2 = { version = "0.20", default-features = false, features = ["vendored-libgit2"] } # Terminal output colored = "3" -owo-colors = "4" indicatif = "0.18" # File system @@ -41,7 +40,6 @@ walkdir = "2" # Error handling anyhow = "1" -thiserror = "2" # Config toml = "0.8" @@ -51,7 +49,6 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } # Misc -chrono = "0.4" regex = "1" futures-util = "0.3" diff --git a/README.md b/README.md index a24ddcf..454eaad 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ ## ✨ Features - πŸ” **Git-Aware Scanning** β€” Automatically detects staged, committed, or changed files -- πŸ€– **Multi-LLM Support** β€” Works with OpenAI, Anthropic, Google, and any OpenAI-compatible API +- πŸ€– **Multi-LLM Support** β€” Works with OpenAI, Anthropic, Google, Ollama, and any OpenAI-compatible API - 🎨 **Beautiful Output** β€” Colorized, structured review output with severity levels - πŸ—οΈ **CI/CD Ready** β€” Designed for GitHub Actions, GitLab CI, and any pipeline - ⚑ **Fast & Lightweight** β€” Native Rust binary, no runtime dependencies @@ -35,33 +35,34 @@ cargo install cora-cli ``` -### Homebrew - -```bash -brew tap ajianaz/tap -brew install cora-cli -``` - ### Binary Download Download the latest release from [GitHub Releases](https://github.com/ajianaz/cora-cli/releases): ```bash -# Linux x86_64 -curl -L https://github.com/ajianaz/cora-cli/releases/latest/download/cora-linux-x86_64.tar.gz | tar xz +# Determine your platform tag from the releases page, e.g.: +# cora-aarch64-unknown-linux-gnu-v0.1.2.tar.gz +# cora-x86_64-unknown-linux-gnu-v0.1.2.tar.gz +# cora-aarch64-apple-darwin-v0.1.2.tar.gz +# cora-x86_64-apple-darwin-v0.1.2.tar.gz +# cora-x86_64-pc-windows-msvc-v0.1.2.zip + +# Example: Linux aarch64 +VERSION=$(curl -s https://api.github.com/repos/ajianaz/cora-cli/releases/latest | grep tag_name | cut -d'"' -f4) +curl -L "https://github.com/ajianaz/cora-cli/releases/download/${VERSION}/cora-aarch64-unknown-linux-gnu-${VERSION}.tar.gz" | tar xz +sudo mv cora /usr/local/bin/ +``` -# macOS ARM (Apple Silicon) -curl -L https://github.com/ajianaz/cora-cli/releases/latest/download/cora-macos-aarch64.tar.gz | tar xz +> **Tip:** Visit the [Releases page](https://github.com/ajianaz/cora-cli/releases) to find the correct asset name for your platform. -# macOS x86_64 (Intel) -curl -L https://github.com/ajianaz/cora-cli/releases/latest/download/cora-macos-x86_64.tar.gz | tar xz +### Homebrew -# Windows x86_64 -curl -L https://github.com/ajianaz/cora-cli/releases/latest/download/cora-windows-x86_64.tar.gz | tar xz -``` +> 🚧 Homebrew tap is planned β€” check back soon! ### Build from Source +Requires **Rust 1.85+**. + ```bash git clone https://github.com/ajianaz/cora-cli.git cd cora-cli @@ -78,16 +79,16 @@ export OPENAI_API_KEY="sk-..." export ANTHROPIC_API_KEY="sk-ant-..." ``` -### 2. Review Staged Changes +### 2. Initialize Config (Optional) ```bash -cora review +cora init ``` -### 3. Review a Specific File +### 3. Review Staged Changes ```bash -cora review src/main.rs +cora review ``` ### 4. Review the Last Commit @@ -99,7 +100,7 @@ cora review --commit HEAD ### 5. Scan the Entire Project ```bash -cora scan . +cora scan ``` ## πŸ“– Commands @@ -109,29 +110,35 @@ cora scan . Review code changes using an LLM. ```bash -# Review staged files +# Review staged files (default) cora review -# Review specific files -cora review src/main.rs src/lib.rs +# Review unpushed changes +cora review --unpushed # Review a range of commits cora review --commit HEAD~3..HEAD -# Review a pull request diff +# Review changes vs a base branch +cora review --base origin/main + +# Review a pull request diff from a file cora review --diff-file pr.diff # Use a specific model cora review --model gpt-4o -# Output as SARIF (for CI) -cora review --output sarif --output-file results.sarif +# Output as SARIF +cora review --format sarif # Output as JSON -cora review --output json +cora review --format json + +# Upload SARIF to GitHub Code Scanning (implies --format sarif) +cora review --upload # Set severity threshold -cora review --severity warning +cora review --severity major # Quiet mode (machine-readable) cora review --quiet @@ -143,32 +150,49 @@ Scan files for code quality issues without requiring git context. ```bash # Scan current directory -cora scan . +cora scan -# Scan specific files -cora scan src/**/*.rs +# Scan a specific directory +cora scan --path src/ -# Scan with custom rules focus -cora scan . --focus security,performance +# Scan with focus areas +cora scan --focus security,performance # Exclude patterns -cora scan . --exclude "tests/**" --exclude "examples/**" +cora scan --exclude "tests/**" --exclude "examples/**" + +# Only scan changed files (incremental) +cora scan --incremental ``` ### `cora config` -Manage configuration. +Manage configuration. Supports both project-level (`.cora.yaml`) and global (`~/.cora/config.yaml`) config. ```bash -# Show current configuration +# Show current resolved configuration cora config show -# Initialize config file -cora config init - -# Set a configuration value +# Set a project-level value (writes to .cora.yaml) cora config set model claude-sonnet-4-20250514 -cora config set severity error +cora config set base_url https://api.openai.com/v1 +cora config set severity major + +# Set a global value (writes to ~/.cora/config.yaml) +cora config set --global model gpt-4o-mini +cora config set --global provider anthropic + +# Supported keys: model, provider, base_url, format, severity +``` + +**Priority**: CLI flags β†’ env vars β†’ `.cora.yaml` (project) β†’ `~/.cora/config.yaml` (global) β†’ defaults + +### `cora init` + +Create a `.cora.yaml` config file in the current directory. + +```bash +cora init ``` ### `cora completion` @@ -181,47 +205,64 @@ cora completion zsh > ~/.cora-completion.zsh cora completion fish > ~/.cora-completion.fish ``` +### `cora hook` + +Manage pre-commit git hooks. + +```bash +cora hook install +cora hook uninstall +``` + ## βš™οΈ Configuration -Create a `.cora.yaml` in your project root or `~/.config/cora/config.yaml` globally: +Cora reads configuration from multiple sources in priority order: + +``` +CLI flags β†’ CORA_* env vars β†’ .cora.yaml (project) β†’ ~/.cora/config.yaml (global) β†’ defaults +``` + +Create a `.cora.yaml` in your project root, or use `~/.cora/config.yaml` for global settings. Project config always overrides global. ```yaml # .cora.yaml -model: gpt-4o -temperature: 0.1 # Provider configuration provider: - name: openai # openai | anthropic | google | custom - base_url: null # Override API endpoint (for custom/self-hosted) - api_key_env: OPENAI_API_KEY # Environment variable for API key - -# Review settings -review: - severity: warning # error | warning | info | note - focus: # Focus areas (empty = all) - - security - - performance - - maintainability - ignore: - patterns: - - "tests/**" - - "vendor/**" - - "*.generated.*" - rules: - - "line-too-long" - max_tokens: 4096 - language: en # Response language + provider: openai # openai | anthropic | google | ollama | custom + model: gpt-4o-mini + base_url: https://api.openai.com/v1 # Override for custom/self-hosted endpoints + +# Focus areas for review (empty = all) +focus: + - security + - performance + - bugs + - best_practice + +# Custom rules +rules: + - "no unwrap" + +# Ignore configuration +ignore: + files: + - "tests/**" + - "vendor/**" + - "*.generated.*" + rules: + - "skip-rule-1" + +# Hook configuration +hook: + mode: warn # warn | block + min_severity: major # info | minor | major | critical + max_diff_size: 51200 # Max diff size in bytes (50 KB) # Output settings output: - format: colored # colored | plain | json | sarif - file: null # Output to file instead of stdout - -# Git settings -git: - auto_stage: false # Auto-stage files after review - base_branch: main # Base branch for PR reviews + format: pretty # pretty | json | compact | sarif + color: true ``` ### Environment Variables @@ -231,47 +272,61 @@ git: | `OPENAI_API_KEY` | OpenAI API key | β€” | | `ANTHROPIC_API_KEY` | Anthropic API key | β€” | | `GOOGLE_API_KEY` | Google AI API key | β€” | -| `CORa_MODEL` | Override model | β€” | -| `CORa_PROVIDER` | Override provider | β€” | -| `CORa_CONFIG` | Path to config file | `.cora.yaml` | -| `CORa_LOG_LEVEL` | Log verbosity | `info` | +| `CORA_API_KEY` | API key (overrides provider-specific keys) | β€” | +| `CORA_MODEL` | Override model | β€” | +| `CORA_PROVIDER` | Override provider | β€” | +| `CORA_BASE_URL` | Override API base URL | β€” | +| `CORA_CONFIG` | Path to config file | `.cora.yaml` | +| `CORA_FORMAT` | Output format (`pretty`, `json`, `compact`, `sarif`) | `pretty` | +| `CORA_NO_COLOR` | Disable colored output | β€” | + +### Authentication + +API keys can be provided via environment variable (`CORA_API_KEY`), provider-specific env vars (`OPENAI_API_KEY`, etc.), or stored in `~/.cora/auth.toml` (auto-created by `cora auth login`, permission `0600`). + +```bash +# Interactive login (stores key in ~/.cora/auth.toml) +cora auth login + +# Or set via environment variable +export CORA_API_KEY=sk-... +``` ## πŸ”— CI/CD Integration ### GitHub Actions -```yaml -# .github/workflows/review.yml -name: Code Review +Using the official [cora-review composite action](.github/actions/cora-review): +```yaml +name: CI on: pull_request: - types: [opened, synchronize, reopened] + branches: [develop] jobs: - review: + cora-review: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 + - uses: ./.github/actions/cora-review + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + infisical-identity-id: ${{ secrets.INFISICAL_IDENTITY_ID }} + severity: major + upload-sarif: 'true' +``` - - name: Install cora-cli - run: cargo install cora-cli - - - name: Run code review - env: - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - run: | - cora review --commit origin/main..HEAD \ - --output sarif \ - --output-file results.sarif +Or install manually: - - name: Upload SARIF to GitHub Code Scanning - if: always() - uses: github/codeql-action/upload-sarif@v3 - with: - sarif_file: results.sarif +```yaml +# Manual install in CI +- name: Install cora-cli + run: | + curl -fsSL https://github.com/ajianaz/cora-cli/releases/latest/download/cora-x86_64-unknown-linux-gnu.tar.gz | tar xz + sudo mv cora /usr/local/bin/ ``` ### GitLab CI @@ -284,7 +339,7 @@ code-review: before_script: - cargo install cora-cli script: - - cora review --commit origin/main..HEAD --severity error + - cora review --base origin/main --severity major variables: OPENAI_API_KEY: $CI_OPENAI_API_KEY rules: @@ -311,7 +366,7 @@ Or add it manually to `.git/hooks/pre-commit`: ```bash #!/bin/sh # cora-cli pre-commit hook -cora review --quiet --severity error +cora review --quiet --severity major if [ $? -ne 0 ]; then echo "❌ Code review found critical issues. Commit blocked." echo " Run 'cora review' to see details, or use 'git commit --no-verify' to skip." @@ -321,15 +376,7 @@ fi ### With [pre-commit](https://pre-commit.com) framework -```yaml -# .pre-commit-config.yaml -repos: - - repo: https://github.com/ajianaz/cora-cli - rev: v0.1.0 - hooks: - - id: cora-review - args: ['--severity', 'warning'] -``` +> 🚧 Planned β€” the pre-commit hook repo will be available soon. For now, use `cora hook install` directly. ## πŸ†š Positioning: How Cora Compares @@ -354,6 +401,8 @@ repos: ## πŸ› οΈ Development +Requires **Rust 1.85+**. + ```bash # Build cargo build diff --git a/docs/CONFIG-REDESIGN.md b/docs/CONFIG-REDESIGN.md new file mode 100644 index 0000000..0300e14 --- /dev/null +++ b/docs/CONFIG-REDESIGN.md @@ -0,0 +1,166 @@ +# Config System Redesign: Global + Project Config + +## Status: Draft β€” Pending Approval + +## Problem + +Current `cora config set` writes to `~/.cora/config.toml` but the config loader only reads `.cora.yaml`. Values set via `config set` are silently ignored. Additionally, there's no way to set global defaults across all projects. + +## Goal + +Support both global and per-project configuration with clear priority: + +``` +CLI flags β†’ CORA_* env vars β†’ .cora.yaml (project) β†’ ~/.cora/config.yaml (global) β†’ Defaults +``` + +## Design + +### Config Sources + +| Source | Path | Format | Scope | Priority | +|--------|------|--------|-------|----------| +| **Global** | `~/.cora/config.yaml` | YAML | All projects | 4 (lowest, after env) | +| **Project** | `.cora.yaml` (walk parent dirs) | YAML | Current project | 3 | +| **Env vars** | `CORA_PROVIDER`, `CORA_MODEL`, `CORA_BASE_URL`, `CORA_API_KEY`, etc. | β€” | Current shell | 2 | +| **CLI flags** | `--provider`, `--model`, `--base-url`, etc. | β€” | Current command | 1 (highest) | + +### Merge Strategy + +Same as current `.cora.yaml` merge β€” global config is loaded first (into defaults), then project config overwrites any fields present. Each field is independently resolved. + +Example: + +**Global** (`~/.cora/config.yaml`): +```yaml +provider: + provider: openai + model: gpt-4o-mini +output: + format: compact +``` + +**Project** (`.cora.yaml`): +```yaml +provider: + model: glm-5.1 + base_url: http://litellm:4000 +hook: + mode: block +``` + +**Resolved:** +```yaml +provider: + provider: openai # from global + model: glm-5.1 # project overrides + base_url: http://litellm:4000 # project +output: + format: compact # from global +hook: + mode: block # from project + min_severity: major # default +``` + +### `cora config set` Redesign + +```bash +# Default: write to project .cora.yaml (must be inside a git project) +cora config set model glm-5.1 +cora config set base_url http://litellm:4000 + +# Explicit global +cora config set --global model gpt-4o-mini +cora config set --global provider openai + +# All supported keys: +# model, provider, base_url, format, severity (maps to hook.min_severity) +``` + +Behavior: +- **No `--global`**: writes to `.cora.yaml` in current dir (creates if missing) +- **`--global`**: writes to `~/.cora/config.yaml` (creates if missing) +- **`--global --show`**: prints global config path + contents +- Works with the same TOMLβ†’YAML key mapping as before + +### Auth File (Separate Concern) + +API key storage stays separate at `~/.cora/auth.toml` (rename from `config.toml`): + +- `cora auth login` β†’ `~/.cora/auth.toml` (contains only `api_key`) +- `cora auth status` β†’ reads from `~/.cora/auth.toml` +- `cora auth remove` β†’ deletes `~/.cora/auth.toml` + +This separation means: +- Config = `~/.cora/config.yaml` (global settings, no secrets) +- Auth = `~/.cora/auth.toml` (API key only, permissions 0o600) + +### `cora config show` Enhancement + +```bash +$ cora config show + +╔══════════════════════════════════════════╗ +β•‘ Current Configuration β•‘ +β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• + + Config sources (priority ↓): + CLI flags β€” + Env vars β€” + Project config .cora.yaml βœ… + Global config ~/.cora/config.yaml βœ… + +provider: + provider: openai (global) + model: glm-5.1 (project) + base_url: http://litellm:4000 (project) + +focus: + security, performance, bugs (default) + +hook: + mode: block (project) + min_severity: major (default) + max_diff_size: 51200 (default) + +output: + format: compact (global) + color: true (default) +``` + +Each value shows its source β€” makes debugging config priority transparent. + +## Files to Change + +| File | Change | +|------|--------| +| `src/config/loader.rs` | Add `load_global_config()` β†’ reads `~/.cora/config.yaml`. Update `load_config()` to merge: global β†’ project β†’ CLI. Rename `AUTH_FILENAME` to `auth.toml`. | +| `src/commands/config_cmd.rs` | Rewrite `execute_config_set()`: write YAML instead of TOML. Add `--global` flag support. Add `base_url` key. Keep `cora config show` but add source annotations. | +| `src/main.rs` | Add `--global` flag to `config set` subcommand. Add `config set base_url `. | +| `src/config/schema.rs` | No changes needed β€” `CoraFile` schema already supports all fields. Global uses same `CoraFile` struct. | + +## Migration + +- **No breaking change** β€” existing `.cora.yaml` files work as-is +- `~/.cora/config.toml` (if exists) β†’ auto-migrate to `~/.cora/config.yaml` on first run (read TOML, write YAML, delete TOML). For auth keys: rename `config.toml` β†’ `auth.toml` +- `cora auth login` switches from writing `config.toml` to `auth.toml` + +## Effort Estimate + +| Item | LOC (est.) | Effort | +|------|-----------|--------| +| Global config loader | ~40 | 30 min | +| `config set` rewrite (YAML + `--global` + `base_url`) | ~80 | 1h | +| `config show` source annotations | ~40 | 30 min | +| Auth file rename (`config.toml` β†’ `auth.toml`) | ~20 | 15 min | +| Migration logic (TOML β†’ YAML) | ~30 | 30 min | +| Tests | ~60 | 45 min | +| **Total** | ~270 | **~3.5h** | + +## Risks + +| Risk | Mitigation | +|------|-----------| +| YAML serialization changes formatting | Use `serde_yaml_ng` with consistent output | +| Migration edge case (partial TOML) | Log warning, skip gracefully, user can manually edit | +| Two config sources confuse users | `config show` annotates source for each value | diff --git a/docs/FEATURE-DESIGN.md b/docs/FEATURE-DESIGN.md new file mode 100644 index 0000000..3bab445 --- /dev/null +++ b/docs/FEATURE-DESIGN.md @@ -0,0 +1,167 @@ +# Feature Design: `cora init --interactive` & `cora doctor` + +## Status: Draft β€” Pending Review + +## Overview + +Two new features to improve developer experience: + +1. **`cora init --interactive`** (or `cora init -i`) β€” guided, step-by-step config creation with prompts +2. **`cora doctor`** β€” health check command that validates API connectivity, config, and environment + +--- + +## 1. `cora init --interactive` + +### Current Behavior + +`cora init` generates a static `.cora.yaml` template with hardcoded defaults (openai, gpt-4o-mini). User must manually edit the file to customize. + +CLI flags (`--provider`, `--model`, `--base-url`, `--api-key`) allow one-shot config but are non-interactive. + +### Proposed Behavior + +With `--interactive` (or `-i`), `cora init` becomes a guided wizard: + +``` +$ cora init -i + +πŸš€ Welcome to Cora! Let's set up your project. + +? Select a provider: + > OpenAI + Anthropic + Groq + Ollama (local) + Google + Custom (OpenAI-compatible) + +? Model name (default: gpt-4o-mini): glm-5.1 + +? Base URL (default: https://api.openai.com/v1): https://litellm:4000 + +? API key: **** (or leave blank to use CORA_API_KEY env var) + +? Review focus areas (space to toggle, enter to confirm): + [βœ“] security + [βœ“] performance + [ ] bugs + [ ] best_practice + [ ] style + [ ] maintainability + +? Pre-commit hook mode: + > warn (print findings but allow commit) + block (fail commit on findings above threshold) + +βœ… Created .cora.yaml with your settings. +βœ… Pre-commit hook installed. + +Next steps: + cora review # Review staged changes + cora scan # Scan the full project +``` + +### Implementation Approach + +- **No new dependencies** β€” use `dialoguer` (already in Cargo.toml? check) or `inquire`. If neither exists, use minimal stdin/stdout with `rustyline` or basic `io::stdin()`. +- **Fallback** β€” if `--interactive` is not passed, current behavior is preserved (static template). +- **Flags take precedence** β€” `cora init -i --provider openai --model gpt-4o` skips the provider/model prompts. + +### Config Schema Changes + +None β€” output is the same `.cora.yaml` format, just populated interactively. + +### Open Questions + +1. **Which prompt library?** β€” `dialoguer` (lightweight), `inquire` (feature-rich), or raw stdin? +2. **Should it test API connectivity** after setup? (e.g., send a minimal request to validate key) +3. **Should it detect existing API keys** from env vars and pre-select the provider? + +--- + +## 2. `cora doctor` + +### Purpose + +Diagnostic command that checks the entire cora setup β€” config, API connectivity, git state, environment. + +### Proposed Output + +``` +$ cora doctor + +πŸ” Cora Doctor β€” System Health Check + +Config + βœ… .cora.yaml found at /project/.cora.yaml + βœ… Config schema valid + +API Connectivity + βœ… Provider: openai (gpt-4o-mini) + βœ… API key detected (CORA_API_KEY) + βœ… Connection OK β€” 142ms latency + βœ… Model accessible + +Git + βœ… Inside a git repository + βœ… Upstream detected: origin/main + ⚠️ 3 uncommitted changes detected + +Environment + βœ… Rust version: 1.85.0 + βœ… cora version: 0.1.2 + βœ… Shell completions available: bash, zsh, fish, powershell + +Pre-commit Hook + βœ… Installed at .git/hooks/pre-commit + +Result: 7 passed, 1 warning β€” your setup is ready! πŸš€ +``` + +### Checks (ordered) + +| # | Check | Severity | What it does | +|---|-------|----------|-------------| +| 1 | Config file found | error | Looks for `.cora.yaml` or `~/.config/cora/config.yaml` | +| 2 | Config schema valid | error | Parses YAML, validates against schema | +| 3 | API key available | error | Checks `CORA_API_KEY` or provider-specific env vars | +| 4 | Provider configured | error | Has a valid provider name | +| 5 | API connectivity | error | Sends a minimal request (e.g., list models) to validate key + endpoint | +| 6 | API latency | info | Measures round-trip time to API endpoint | +| 7 | Model accessible | warn | Validates the configured model exists at the endpoint | +| 8 | Git repository | warn | Checks if inside a git repo (not required for `cora scan --path`) | +| 9 | Pre-commit hook | info | Checks if `.git/hooks/pre-commit` has cora | +| 10 | Cora version | info | Shows current version, checks for updates | + +### Implementation Approach + +- **New command** β€” `src/commands/doctor.rs` +- **New subcommand** in `main.rs` CLI definition +- **Check result type** β€” enum `{ Ok, Warning, Error }` with message +- **Optional `--json` flag** β€” machine-readable output for CI +- **Exit code** β€” 0 if all pass, 1 if any error, 2 if connection failed + +### Open Questions + +1. **Check for updates** β€” should `cora doctor` hit GitHub API to check latest version? (adds network dependency) +2. **Model validation** β€” how to check if a model exists without expensive API calls? (Some providers support `GET /models`) +3. **Should it fix things?** β€” e.g., `cora doctor --fix` to auto-install hook, fix config schema? + +--- + +## Effort Estimate + +| Feature | LOC (est.) | New Files | Effort | Risk | +|---------|-----------|-----------|--------|------| +| `cora init -i` | ~150-250 | 1 (modify `init.rs`) | 2-3h | Low | +| `cora doctor` | ~200-300 | 2 (`doctor.rs`, types) | 3-4h | Low | + +## Dependencies + +- `dialoguer` or `inquire` crate (for interactive prompts) β€” whichever is lighter +- No new deps for `cora doctor` β€” uses existing HTTP client + +## Timeline + +Both features can go into **v0.2.0** (next minor release) or as individual patches. diff --git a/src/commands/auth.rs b/src/commands/auth.rs index 8fda6e0..3654822 100644 --- a/src/commands/auth.rs +++ b/src/commands/auth.rs @@ -38,10 +38,7 @@ pub fn execute_auth_login() -> Result<()> { } loader::save_api_key(&key)?; - println!( - "{} API key saved to ~/.cora/config.toml", - "βœ…".green().bold() - ); + println!("{} API key saved to ~/.cora/auth.toml", "βœ…".green().bold()); println!( "{}", " This file is local to your machine and not committed to git.".dimmed() diff --git a/src/commands/config_cmd.rs b/src/commands/config_cmd.rs new file mode 100644 index 0000000..b16a0b6 --- /dev/null +++ b/src/commands/config_cmd.rs @@ -0,0 +1,193 @@ +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use colored::Colorize; + +use crate::config::loader; +use crate::config::schema::{CoraFile, HookSection, OutputSection, ProviderSection}; + +/// Execute `cora config show` β€” print the current resolved configuration. +pub fn execute_config_show() -> Result<()> { + let config = loader::load_config(None, None, None, None, None, None, false)?; + + println!( + "{}", + "╔══════════════════════════════════════════╗".cyan().bold() + ); + println!( + "{}", + "β•‘ Current Configuration β•‘" + .cyan() + .bold() + ); + println!( + "{}", + "β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•".cyan().bold() + ); + println!(); + + println!( + "{} {}", + "provider:".bold(), + config.provider.provider.green() + ); + println!("{} {}", " model:".dimmed(), config.provider.model.green()); + println!( + "{} {}", + " base_url:".dimmed(), + config.provider.base_url.green() + ); + + println!( + "{} {}", + "focus:".bold(), + config + .focus + .iter() + .map(|f| format!("{}", f.green())) + .collect::>() + .join(", ") + ); + + if !config.rules.is_empty() { + println!( + "{} {}", + "rules:".bold(), + config + .rules + .iter() + .map(|r| format!("{}", r.green())) + .collect::>() + .join(", ") + ); + } + + println!( + "{} mode={} min_severity={} max_diff_size={}", + "hook:".bold(), + config.hook.mode.yellow(), + config.hook.min_severity.yellow(), + config.hook.max_diff_size + ); + + println!( + "{} format={} color={}", + "output:".bold(), + config.output.format.green(), + config.output.color + ); + + println!( + "{} {}", + "ignore files:".bold(), + if config.ignore.files.is_empty() { + "(none)".to_string().dimmed().to_string() + } else { + config + .ignore + .files + .iter() + .map(|f| format!("{}", f.dimmed())) + .collect::>() + .join(", ") + } + ); + + Ok(()) +} + +/// Execute `cora config set [--global] ` β€” write a key-value pair +/// to a YAML config file. +/// +/// Supported keys: model, provider, base_url, format, severity +pub fn execute_config_set(key: &str, value: &str, global: bool) -> Result<()> { + // Validate the key + match key { + "model" | "provider" | "base_url" | "format" | "severity" => {} + _ => { + anyhow::bail!( + "unsupported key: {key}\nSupported keys: model, provider, base_url, format, severity" + ); + } + } + + // Determine target path + let path = if global { + let dir = loader::cora_dir()?; + std::fs::create_dir_all(&dir) + .with_context(|| format!("failed to create {}", dir.display()))?; + dir.join("config.yaml") + } else { + // Warn if not inside a git project (could create orphan file) + if std::process::Command::new("git") + .args(["rev-parse", "--git-dir"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map_or(false, |s| s.success()) + { + PathBuf::from(".cora.yaml") + } else { + anyhow::bail!( + "Not in a git project. Use --global to set config in ~/.cora/config.yaml, or cd into a git repo first." + ); + } + }; + + // Load existing file or start fresh + let mut cora = if path.is_file() { + let content = std::fs::read_to_string(&path) + .with_context(|| format!("failed to read {}", path.display()))?; + CoraFile::from_str(&content).unwrap_or_default() + } else { + CoraFile::default() + }; + + // Apply the key update + match key { + "model" => { + let provider = cora.provider.get_or_insert_with(ProviderSection::default); + provider.model = Some(value.to_string()); + } + "provider" => { + let provider = cora.provider.get_or_insert_with(ProviderSection::default); + provider.provider = Some(value.to_string()); + } + "base_url" => { + let provider = cora.provider.get_or_insert_with(ProviderSection::default); + provider.base_url = Some(value.to_string()); + } + "format" => { + let output = cora.output.get_or_insert_with(OutputSection::default); + output.format = Some(value.to_string()); + } + "severity" => { + let hook = cora.hook.get_or_insert_with(HookSection::default); + hook.min_severity = Some(value.to_string()); + } + _ => unreachable!(), + } + + // Write back as YAML + let yaml = serde_yaml_ng::to_string(&cora).context("failed to serialize config to YAML")?; + std::fs::write(&path, &yaml).with_context(|| format!("failed to write {}", path.display()))?; + + // Restrict permissions for global config (defense-in-depth) + #[cfg(unix)] + if global { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); + } + + let scope = if global { "global" } else { "project" }; + println!( + "{} Set {} = {} in {} ({})", + "βœ“".green().bold(), + key.bold(), + value.green(), + path.display(), + scope.dimmed() + ); + + Ok(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index a76dd9e..7b0c797 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,5 +1,6 @@ pub mod auth; pub mod completion; +pub mod config_cmd; pub mod hook_cmd; pub mod init; pub mod providers; diff --git a/src/commands/review.rs b/src/commands/review.rs index f5b28c8..0484b85 100644 --- a/src/commands/review.rs +++ b/src/commands/review.rs @@ -1,8 +1,9 @@ -use anyhow::Result; +use anyhow::{Context, Result}; use colored::Colorize; use tracing::debug; use crate::config::schema::Config; +use crate::engine::Severity; use crate::formatters::{OutputFormat, formatter_for}; use crate::git; @@ -19,12 +20,20 @@ pub struct ReviewOptions { pub unpushed: bool, /// Review diff against a base branch. pub base: Option, + /// Review diff from a git commit reference (e.g. HEAD, HEAD~3..HEAD, abc123). + pub commit: Option, + /// Read diff from a file instead of git. + pub diff_file: Option, /// Review unstaged changes. pub unstaged: bool, /// Maximum diff size before refusing (0 = use config default). pub max_diff_size: Option, /// Stream LLM response tokens to stdout in real-time. pub stream: bool, + /// Suppress all output except the formatted review result. + pub quiet: bool, + /// Minimum severity level for filtering issues. + pub severity: Option, } /// Result of a review command execution. @@ -49,6 +58,12 @@ pub async fn execute_review( let diff = get_diff(opts, config)?; if diff.trim().is_empty() { + if opts.quiet { + return Ok(ReviewResult { + exit_code: EXIT_OK, + output: String::new(), + }); + } let output = format!("{}\n", "No changes to review.".yellow()); return Ok(ReviewResult { exit_code: EXIT_OK, @@ -77,11 +92,26 @@ pub async fn execute_review( crate::engine::review::review_diff_with_stream(config, llm_config, &diff, opts.stream) .await?; - // 4. Format output + // 4. Filter by severity if specified + let min_severity = if let Some(ref sev) = opts.severity { + Severity::from_str_lossy(sev) + } else { + config.hook.min_severity_level() + }; + + let mut filtered_response = response.clone(); + // Keep issues at or above min_severity (Critical=worst, Info=lowest in Ord but + // Ord order is Critical(0) < Major(1) < Minor(2) < Info(3), so we invert: keep + // where severity Ord value <= min_severity Ord value). + filtered_response + .issues + .retain(|i| i.severity <= min_severity); + + // 5. Format output let formatter = formatter_for(format); - let output = formatter.format_review(&response)?; + let output = formatter.format_review(&filtered_response)?; - // 5. Return exit code + // 6. Return exit code let exit_code = if response.should_block && config.hook.mode == "block" { EXIT_BLOCKED } else { @@ -93,6 +123,17 @@ pub async fn execute_review( /// Get the diff based on the provided options. fn get_diff(opts: &ReviewOptions, _config: &Config) -> Result { + if let Some(ref diff_file) = opts.diff_file { + let path = std::path::Path::new(diff_file); + if !path.exists() { + anyhow::bail!("diff file not found: {}", diff_file); + } + return std::fs::read_to_string(path) + .with_context(|| format!("failed to read diff file: {}", diff_file)); + } + if let Some(ref commit_ref) = opts.commit { + return git::get_commit_diff(commit_ref); + } if opts.staged { git::get_staged_diff() } else if opts.unpushed { diff --git a/src/commands/scan.rs b/src/commands/scan.rs index 398f1a4..5aa4f74 100644 --- a/src/commands/scan.rs +++ b/src/commands/scan.rs @@ -20,6 +20,8 @@ pub struct ScanOptions { pub extensions: Vec, /// Only scan files changed since last scan. pub incremental: bool, + /// Focus areas for review (overrides config). + pub focus: Vec, } /// Execute the scan command. @@ -46,6 +48,13 @@ pub async fn execute_scan( let mut exclude = config.ignore.files.clone(); exclude.extend(opts.exclude.clone()); + // Merge focus areas: CLI --focus overrides config + let effective_focus = if opts.focus.is_empty() { + config.focus.clone() + } else { + opts.focus.clone() + }; + debug!(root = %root.display(), "starting scan"); // 1. Walk and collect files @@ -107,7 +116,7 @@ pub async fn execute_scan( let (issues, _summary, tokens) = crate::engine::llm::scan_files( llm_config, &files_content, - &config.focus, + &effective_focus, &config.rules, ) .await?; @@ -121,7 +130,9 @@ pub async fn execute_scan( // 5. Build response and format let issue_count = all_issues.len(); let min_severity = config.hook.min_severity_level(); - let should_block = all_issues.iter().any(|i| i.severity >= min_severity); + // Ord order is Critical(0) < Major(1) < Minor(2) < Info(3), so "at or above + // min_severity" means Ord value <= min_severity. + let should_block = all_issues.iter().any(|i| i.severity <= min_severity); let response = crate::engine::ScanResponse { issues: all_issues, diff --git a/src/config/loader.rs b/src/config/loader.rs index 007dc6b..52eb83c 100644 --- a/src/config/loader.rs +++ b/src/config/loader.rs @@ -7,11 +7,20 @@ use crate::config::providers::{PRESETS, detected_presets}; use crate::config::schema::{Config, CoraFile}; use crate::engine::LLMConfig; -/// The name of the config file we search for. +/// The name of the config file we search for in projects. const CONFIG_FILENAME: &str = ".cora.yaml"; +/// Name of the global config file. +const GLOBAL_CONFIG_FILENAME: &str = "config.yaml"; + /// Name of the secret config file for API keys (never committed). -const AUTH_FILENAME: &str = "config.toml"; +const AUTH_FILENAME: &str = "auth.toml"; + +/// Name of the old auth file (for migration). +const OLD_AUTH_FILENAME: &str = "config.toml"; + +/// Marker file created after successful migration. +const MIGRATION_MARKER: &str = ".migrated"; /// Locate the `.cora.yaml` config by walking parent directories from `start`. /// Returns the path and parsed content, or `None` if not found. @@ -39,7 +48,169 @@ pub fn find_cora_file(start: &Path) -> Result> { } } -/// Load the full resolved config: defaults ← .cora.yaml ← CLI overrides. +/// Load the global config from `~/.cora/config.yaml`. +/// Returns `None` if the file doesn't exist or can't be parsed. +fn load_global_config() -> Result> { + let dir = cora_dir()?; + let path = dir.join(GLOBAL_CONFIG_FILENAME); + if !path.is_file() { + return Ok(None); + } + let content = std::fs::read_to_string(&path) + .with_context(|| format!("failed to read {}", path.display()))?; + let cora = CoraFile::from_str(&content)?; + debug!(path = %path.display(), "loaded global config"); + Ok(Some(cora)) +} + +/// Migrate old `~/.cora/config.toml` to the new format if it exists. +/// - Non-secret keys β†’ `~/.cora/config.yaml` +/// - api_key β†’ `~/.cora/auth.toml` +/// - Delete the old file after successful migration. +/// - Creates `.migrated` marker to prevent re-running. +fn migrate_old_config() { + let dir = match cora_dir() { + Ok(d) => d, + Err(_) => return, + }; + let old_path = dir.join(OLD_AUTH_FILENAME); + if !old_path.is_file() { + return; + } + + // Check if migration already completed + if dir.join(MIGRATION_MARKER).is_file() { + return; + } + + let content = match std::fs::read_to_string(&old_path) { + Ok(c) => c, + Err(e) => { + debug!("failed to read old config for migration: {}", e); + return; + } + }; + + let table: toml::Table = match content.parse::() { + Ok(t) => t, + Err(e) => { + debug!( + "old config.toml is not valid TOML, skipping migration: {}", + e + ); + return; + } + }; + + // Check if there's an api_key + let api_key = table + .get("auth") + .and_then(|a| a.get("api_key")) + .and_then(|k| k.as_str()) + .map(|s| s.to_string()) + .or_else(|| { + table + .get("api_key") + .and_then(|k| k.as_str()) + .map(|s| s.to_string()) + }); + + // Extract non-secret config fields into a CoraFile + let mut cora = CoraFile::default(); + + if let Some(provider) = table.get("provider").and_then(|v| v.as_table()) { + let mut ps = crate::config::schema::ProviderSection::default(); + if let Some(v) = provider.get("provider").and_then(|v| v.as_str()) { + ps.provider = Some(v.to_string()); + } + if let Some(v) = provider.get("model").and_then(|v| v.as_str()) { + ps.model = Some(v.to_string()); + } + if let Some(v) = provider.get("base_url").and_then(|v| v.as_str()) { + ps.base_url = Some(v.to_string()); + } + // Only set if we found something + if ps.provider.is_some() || ps.model.is_some() || ps.base_url.is_some() { + cora.provider = Some(ps); + } + } + + if let Some(output) = table.get("output").and_then(|v| v.as_table()) { + let mut os = crate::config::schema::OutputSection::default(); + if let Some(v) = output.get("format").and_then(|v| v.as_str()) { + os.format = Some(v.to_string()); + } + if let Some(v) = output.get("color").and_then(|v| v.as_bool()) { + os.color = Some(v); + } + if os.format.is_some() || os.color.is_some() { + cora.output = Some(os); + } + } + + if let Some(hook) = table.get("hook").and_then(|v| v.as_table()) { + let mut hs = crate::config::schema::HookSection::default(); + if let Some(v) = hook.get("mode").and_then(|v| v.as_str()) { + hs.mode = Some(v.to_string()); + } + if let Some(v) = hook.get("min_severity").and_then(|v| v.as_str()) { + hs.min_severity = Some(v.to_string()); + } + if let Some(v) = hook.get("max_diff_size").and_then(|v| v.as_integer()) { + hs.max_diff_size = Some(v as usize); + } + if hs.mode.is_some() || hs.min_severity.is_some() || hs.max_diff_size.is_some() { + cora.hook = Some(hs); + } + } + + // Write api_key to auth.toml if present (use TOML library to avoid injection) + if let Some(key) = api_key { + let auth_path = dir.join(AUTH_FILENAME); + let mut table = toml::Table::new(); + table.insert("api_key".to_string(), toml::Value::String(key)); + let content = table.to_string(); + if let Err(e) = std::fs::write(&auth_path, content) { + debug!("failed to write migrated auth.toml: {}", e); + return; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o600); + let _ = std::fs::set_permissions(&auth_path, perms); + } + } + + // Write config to config.yaml if there are non-secret fields + let has_config = cora.provider.is_some() + || cora.output.is_some() + || cora.hook.is_some() + || cora.focus.is_some() + || cora.rules.is_some() + || cora.ignore.is_some(); + + if has_config { + let config_path = dir.join(GLOBAL_CONFIG_FILENAME); + if let Ok(yaml) = serde_yaml_ng::to_string(&cora) { + if let Err(e) = std::fs::write(&config_path, yaml) { + debug!("failed to write migrated config.yaml: {}", e); + return; + } + } + } + + // Delete old file and create migration marker + if let Err(e) = std::fs::remove_file(&old_path) { + debug!("failed to remove old config.toml after migration: {}", e); + } else { + // Create marker to prevent re-running migration + let _ = std::fs::write(dir.join(MIGRATION_MARKER), ""); + debug!("migrated ~/.cora/config.toml to new format"); + } +} + +/// Load the full resolved config: defaults ← global config ← .cora.yaml ← CLI overrides. /// /// `cli_provider`, `cli_model`, `cli_api_key`, and `cli_format` are `None` /// when the user did not pass the corresponding flag. @@ -54,7 +225,15 @@ pub fn load_config( ) -> Result { let mut config = Config::default(); - // 1. Load .cora.yaml + // Run migration silently on first access + migrate_old_config(); + + // 1. Load global config (~/.cora/config.yaml) + if let Some(cora) = load_global_config()? { + cora.merge_into(&mut config); + } + + // 2. Load project config (.cora.yaml) if let Some(path) = cli_config_path { let path = Path::new(path); let content = std::fs::read_to_string(path) @@ -69,7 +248,7 @@ pub fn load_config( debug!("no .cora.yaml found, using defaults"); } - // 2. CLI overrides + // 3. CLI overrides if let Some(p) = cli_provider { config.provider.provider = p.to_string(); } @@ -90,7 +269,7 @@ pub fn load_config( } /// Build an `LLMConfig` from the resolved `Config`, fetching the API key -/// from: CLI flag β†’ env CORA_API_KEY β†’ ~/.config/cora/config.toml. +/// from: CLI flag β†’ env CORA_API_KEY β†’ ~/.cora/auth.toml. /// /// If none of those are set, auto-detect from known provider env vars (OPENAI_API_KEY, etc.) /// and configure provider/model/base_url from the matching preset. @@ -166,12 +345,12 @@ pub fn build_llm_config(config: &Config, cli_api_key: Option<&str>) -> Result Result { +pub fn cora_dir() -> Result { let home = dirs::home_dir().context("cannot determine home directory")?; Ok(home.join(".cora")) } -/// Read the stored API key from ~/.cora/config.toml. +/// Read the stored API key from ~/.cora/auth.toml. pub fn load_api_key_from_auth_file() -> Result> { let dir = cora_dir()?; let path = dir.join(AUTH_FILENAME); @@ -202,7 +381,7 @@ pub fn load_api_key_from_auth_file() -> Result> { Ok(key) } -/// Save an API key to ~/.cora/config.toml. +/// Save an API key to ~/.cora/auth.toml. pub fn save_api_key(key: &str) -> Result<()> { let dir = cora_dir()?; std::fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?; @@ -226,7 +405,7 @@ pub fn save_api_key(key: &str) -> Result<()> { Ok(()) } -/// Remove the stored API key from ~/.cora/config.toml. +/// Remove the stored API key from ~/.cora/auth.toml. pub fn remove_api_key() -> Result<()> { let dir = cora_dir()?; let path = dir.join(AUTH_FILENAME); diff --git a/src/config/schema.rs b/src/config/schema.rs index 1f207ce..a3e2db6 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -97,43 +97,59 @@ impl HookConfig { /// Serde-compatible schema for the `.cora.yaml` configuration file. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct CoraFile { + #[serde(skip_serializing_if = "Option::is_none")] pub provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub focus: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub rules: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub ignore: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub hook: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub output: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ProviderSection { + #[serde(skip_serializing_if = "Option::is_none")] pub provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub base_url: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct IgnoreSection { + #[serde(skip_serializing_if = "Option::is_none")] pub files: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub rules: Option>, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct HookSection { + #[serde(skip_serializing_if = "Option::is_none")] pub mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub min_severity: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub max_diff_size: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct OutputSection { + #[serde(skip_serializing_if = "Option::is_none")] pub format: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub color: Option, } impl CoraFile { pub fn from_str(content: &str) -> Result { - serde_yaml::from_str(content).context("failed to parse .cora.yaml") + serde_yaml_ng::from_str(content).context("failed to parse .cora.yaml") } /// Merge this file config into a `Config`, overwriting only fields that are present. @@ -433,8 +449,8 @@ output: focus: Some(vec!["security".to_string()]), ..Default::default() }; - let yaml = serde_yaml::to_string(&cora).unwrap(); - let back: CoraFile = serde_yaml::from_str(&yaml).unwrap(); + let yaml = serde_yaml_ng::to_string(&cora).unwrap(); + let back: CoraFile = serde_yaml_ng::from_str(&yaml).unwrap(); assert_eq!( back.provider.as_ref().unwrap().provider.as_deref(), Some("ollama") diff --git a/src/engine/review.rs b/src/engine/review.rs index 97b6b98..9b6368e 100644 --- a/src/engine/review.rs +++ b/src/engine/review.rs @@ -64,10 +64,12 @@ async fn review_diff_inner( // Calculate should_block based on min_severity let min_severity = config.hook.min_severity_level(); + // Ord order is Critical(0) < Major(1) < Minor(2) < Info(3), so "at or above + // min_severity" means Ord value <= min_severity. response.should_block = response .issues .iter() - .any(|issue| issue.severity >= min_severity); + .any(|issue| issue.severity <= min_severity); debug!( issues = response.issues.len(), @@ -115,7 +117,8 @@ pub async fn scan_project( // Calculate should_block let min_severity = config.hook.min_severity_level(); - let should_block = issues.iter().any(|issue| issue.severity >= min_severity); + // Ord order: Critical(0) < Major(1) < Minor(2) < Info(3) + let should_block = issues.iter().any(|issue| issue.severity <= min_severity); let default_summary = format!( "Scanned {} files, found {} issues.", diff --git a/src/formatters/sarif.rs b/src/formatters/sarif.rs index de5a7e6..5e08451 100644 --- a/src/formatters/sarif.rs +++ b/src/formatters/sarif.rs @@ -1,5 +1,4 @@ use anyhow::Result; -use chrono::Utc; use serde_json::{Value, json}; use crate::engine::{ReviewIssue, ReviewResponse, ScanResponse, Severity}; @@ -27,24 +26,33 @@ impl Formatter for SarifFormatter { /// Build a SARIF v2.1.0 document from a list of issues. fn build_sarif(issues: &[ReviewIssue]) -> Value { - let rules: Vec = issues - .iter() - .map(|issue| { - json!({ - "id": issue.issue_type.as_ref().map(|t| t.to_string()).unwrap_or_else(|| "unknown".to_string()), - "shortDescription": { - "text": issue.title.clone() - }, - "fullDescription": { - "text": issue.body.clone() - }, - "helpUri": null, - "defaultConfiguration": { - "level": severity_to_sarif_level(&issue.severity) - } - }) - }) - .collect(); + // Deduplicate rules by id (multiple issues can share same rule) + let mut rule_map = serde_json::Map::new(); + for issue in issues { + let rule_id = issue + .issue_type + .as_ref() + .map(|t| t.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + if !rule_map.contains_key(&rule_id) { + rule_map.insert( + rule_id.clone(), + json!({ + "id": rule_id, + "shortDescription": { + "text": issue.title.clone() + }, + "fullDescription": { + "text": issue.body.clone() + }, + "defaultConfiguration": { + "level": severity_to_sarif_level(&issue.severity) + } + }), + ); + } + } + let rules: Vec = rule_map.into_values().collect(); let results: Vec = issues .iter() @@ -73,17 +81,23 @@ fn build_sarif(issues: &[ReviewIssue]) -> Value { // Add fix suggestion if available if let Some(ref fix) = issue.suggested_fix { - let mut replacements = Vec::new(); - replacements.push(json!({ - "description": { - "text": "Suggested fix" - } - // We don't have a full replacement spec, but we can include the text - })); result["fixes"] = json!([{ "description": { "text": fix.clone() - } + }, + "artifactChanges": [{ + "artifactLocation": { + "uri": issue.file.clone() + }, + "replacements": [{ + "deletedRegion": { + "startLine": issue.line.unwrap_or(1) + }, + "insertedContent": { + "text": fix.clone() + } + }] + }] }]); } @@ -103,12 +117,7 @@ fn build_sarif(issues: &[ReviewIssue]) -> Value { "rules": rules } }, - "results": results, - "invocation": { - "executionSuccessful": true, - "startTimeUtc": Utc::now().to_rfc3339(), - "endTimeUtc": Utc::now().to_rfc3339() - } + "results": results }] }) } diff --git a/src/git/diff.rs b/src/git/diff.rs index 3178e83..00eb8c2 100644 --- a/src/git/diff.rs +++ b/src/git/diff.rs @@ -45,6 +45,19 @@ pub fn get_unpushed_diff() -> Result { git_cmd(&["log", "-p", "@{u}..HEAD"]) } +/// Get the diff for a commit reference. +/// +/// - If the ref contains `..` (e.g. `HEAD~3..HEAD`), uses `git diff `. +/// - Otherwise (e.g. `HEAD`, `abc123`), uses `git show --format=""` to +/// get just the diff without commit metadata. +pub fn get_commit_diff(ref_str: &str) -> Result { + if ref_str.contains("..") { + git_cmd(&["diff", ref_str]) + } else { + git_cmd(&["show", ref_str, "--format="]) + } +} + /// Get the current branch name. pub fn get_current_branch() -> Result { let output = Command::new("git") diff --git a/src/main.rs b/src/main.rs index d6bf9f5..c21b699 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,7 +15,7 @@ mod formatters; mod git; mod hook; -use commands::{auth, completion, hook_cmd, init, providers, review, scan, upload}; +use commands::{auth, completion, config_cmd, hook_cmd, init, providers, review, scan, upload}; use config::loader; use formatters::OutputFormat; @@ -91,6 +91,14 @@ enum Command { #[clap(long)] base: Option, + /// Review changes from a git commit ref (e.g. HEAD, HEAD~3..HEAD, abc123) + #[clap(long)] + commit: Option, + + /// Read diff from a file instead of git + #[clap(long)] + diff_file: Option, + /// Review unstaged changes (working tree) #[clap(long)] unstaged: bool, @@ -99,6 +107,14 @@ enum Command { #[clap(long)] stream: bool, + /// Suppress all output except the formatted review result + #[clap(long, short)] + quiet: bool, + + /// Filter results by minimum severity (info, minor, major, critical) + #[clap(long, value_parser = ["info", "minor", "major", "critical"])] + severity: Option, + /// Upload SARIF output to GitHub Code Scanning after review /// (implies --format sarif) #[clap(long)] @@ -138,6 +154,10 @@ enum Command { /// Only scan files changed since last scan (uses ~/.cora/scan-cache.json) #[clap(long)] incremental: bool, + + /// Focus areas for review (overrides config) + #[clap(long)] + focus: Vec, }, /// Upload a SARIF file to GitHub Code Scanning @@ -177,6 +197,12 @@ enum Command { action: AuthAction, }, + /// View or set configuration + Config { + #[command(subcommand)] + action: ConfigAction, + }, + /// List detected/available LLM providers Providers, @@ -205,6 +231,22 @@ enum AuthAction { Remove, } +#[derive(Subcommand, Debug)] +enum ConfigAction { + /// Show the current resolved configuration + Show, + /// Set a configuration value (keys: model, provider, base_url, format, severity) + Set { + /// Configuration key to set + key: String, + /// Value to assign + value: String, + /// Write to global config (~/.cora/config.yaml) instead of project .cora.yaml + #[clap(long)] + global: bool, + }, +} + #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); @@ -238,8 +280,12 @@ async fn main() -> Result<()> { staged, unpushed, base, + commit, + diff_file, unstaged, stream, + quiet, + severity, upload, repo, ref_name, @@ -251,8 +297,12 @@ async fn main() -> Result<()> { staged, unpushed, base, + commit, + diff_file, unstaged, stream, + quiet, + severity, upload, repo, ref_name, @@ -267,6 +317,7 @@ async fn main() -> Result<()> { exclude, extensions, incremental, + focus, } => { cmd_scan( &cli.global, @@ -276,6 +327,7 @@ async fn main() -> Result<()> { exclude, extensions, incremental, + focus, }, ) .await? @@ -320,6 +372,16 @@ async fn main() -> Result<()> { } 0 } + Command::Config { action } => match action { + ConfigAction::Show => { + config_cmd::execute_config_show()?; + 0 + } + ConfigAction::Set { key, value, global } => { + config_cmd::execute_config_set(&key, &value, global)?; + 0 + } + }, Command::Providers => { providers::execute_providers()?; 0 @@ -338,8 +400,12 @@ struct ReviewOpts { staged: bool, unpushed: bool, base: Option, + commit: Option, + diff_file: Option, unstaged: bool, stream: bool, + quiet: bool, + severity: Option, upload: bool, repo: Option, ref_name: Option, @@ -353,6 +419,7 @@ struct ScanOpts { exclude: Vec, extensions: Vec, incremental: bool, + focus: Vec, } /// Handle the `review` subcommand. @@ -380,13 +447,17 @@ async fn cmd_review(globals: &GlobalOptions, opts: ReviewOpts) -> Result { staged: opts.staged, unpushed: opts.unpushed, base: opts.base.clone(), + commit: opts.commit.clone(), + diff_file: opts.diff_file.clone(), unstaged: opts.unstaged, max_diff_size: None, stream: opts.stream, + quiet: opts.quiet, + severity: opts.severity.clone(), }; - // When streaming, show a simpler message (no spinner, since we print tokens live) - if opts.stream { + // When streaming and not quiet, show a simpler message + if opts.stream && !opts.quiet { eprintln!( "{}", format!( @@ -473,6 +544,7 @@ async fn cmd_scan(globals: &GlobalOptions, opts: ScanOpts) -> Result { exclude: opts.exclude, extensions: opts.extensions, incremental: opts.incremental, + focus: opts.focus, }; scan::execute_scan(&config, &llm_config, &scan_opts, format).await diff --git a/tests/cli_basic.rs b/tests/cli_basic.rs index 0519664..2717669 100644 --- a/tests/cli_basic.rs +++ b/tests/cli_basic.rs @@ -18,7 +18,7 @@ fn cli_version_returns_version_string() { .arg("--version") .assert() .success() - .stdout(predicate::str::contains("0.1.0")); + .stdout(predicate::str::contains(env!("CARGO_PKG_VERSION"))); } #[test] diff --git a/tests/config_loading.rs b/tests/config_loading.rs index 4d048d3..e22f6e9 100644 --- a/tests/config_loading.rs +++ b/tests/config_loading.rs @@ -33,8 +33,8 @@ fn init_creates_valid_yaml() { // Verify the created file is valid YAML let content = std::fs::read_to_string(&config_path).unwrap(); - let parsed: serde_yaml::Value = - serde_yaml::from_str(&content).expect("init should create valid YAML"); + let parsed: serde_yaml_ng::Value = + serde_yaml_ng::from_str(&content).expect("init should create valid YAML"); // Verify it has expected top-level keys assert!( @@ -59,9 +59,9 @@ output: "#; let file = write_temp_yaml(yaml); - // Parse and verify using serde_yaml directly + // Parse and verify using serde_yaml_ng directly let content = std::fs::read_to_string(file.path()).unwrap(); - let parsed: serde_yaml::Value = serde_yaml::from_str(&content).unwrap(); + let parsed: serde_yaml_ng::Value = serde_yaml_ng::from_str(&content).unwrap(); assert_eq!(parsed["provider"]["provider"].as_str(), Some("anthropic")); assert_eq!(parsed["provider"]["model"].as_str(), Some("claude-3-haiku")); @@ -88,11 +88,11 @@ ignore: let content = std::fs::read_to_string(file.path()).unwrap(); // Parse to Value, serialize back - let val: serde_yaml::Value = serde_yaml::from_str(&content).unwrap(); - let reser = serde_yaml::to_string(&val).unwrap(); + let val: serde_yaml_ng::Value = serde_yaml_ng::from_str(&content).unwrap(); + let reser = serde_yaml_ng::to_string(&val).unwrap(); // Re-parse the re-serialized version to ensure round-trip - let val2: serde_yaml::Value = serde_yaml::from_str(&reser).unwrap(); + let val2: serde_yaml_ng::Value = serde_yaml_ng::from_str(&reser).unwrap(); assert_eq!(val2["provider"]["provider"], val["provider"]["provider"]); assert_eq!(val2["hook"]["mode"], val["hook"]["mode"]); assert_eq!( @@ -106,7 +106,7 @@ fn config_file_empty_yaml_is_valid() { let yaml = ""; let file = write_temp_yaml(yaml); let content = std::fs::read_to_string(file.path()).unwrap(); - let parsed: serde_yaml::Value = serde_yaml::from_str(&content).unwrap(); + let parsed: serde_yaml_ng::Value = serde_yaml_ng::from_str(&content).unwrap(); assert!(parsed.is_null()); } @@ -118,7 +118,7 @@ focus: "#; let file = write_temp_yaml(yaml); let content = std::fs::read_to_string(file.path()).unwrap(); - let parsed: serde_yaml::Value = serde_yaml::from_str(&content).unwrap(); + let parsed: serde_yaml_ng::Value = serde_yaml_ng::from_str(&content).unwrap(); assert_eq!(parsed["focus"].as_sequence().unwrap().len(), 1); assert!(parsed.get("provider").is_none()); } diff --git a/website/src/app.html b/website/src/app.html index 9332d24..2333f58 100644 --- a/website/src/app.html +++ b/website/src/app.html @@ -14,8 +14,8 @@ - - + + diff --git a/website/src/routes/docs/cli-reference/+page.svelte b/website/src/routes/docs/cli-reference/+page.svelte index 77ef7e8..d5d4814 100644 --- a/website/src/routes/docs/cli-reference/+page.svelte +++ b/website/src/routes/docs/cli-reference/+page.svelte @@ -47,8 +47,28 @@ Override config file path (default: .cora.yaml) - --json - Output results as JSON + --format <fmt> + Output format: pretty, json, compact, sarif + + + --no-color + Disable colored output + + + --provider <name> + Override provider + + + --model <name> + Override model + + + --base-url <url> + Override API base URL + + + --api-key <key> + Override API key --verbose @@ -78,25 +98,53 @@ cora init Create .cora.yaml config file + + cora review + Review code changes (default: staged files) + cora review --staged - Review staged git changes (default) + Review staged git changes explicitly + + + cora review --unstaged + Review unstaged working changes + + + cora review --unpushed + Review unpushed commits - cora review --branch <name> + cora review --base <branch> Compare current branch against target - cora review --diff <base>..<head> - Review specific diff range + cora review --commit <ref> + Review specific commit or range + + + cora review --diff-file <path> + Review from a diff file + + + cora review --upload + Review and upload SARIF to GitHub Code Scanning + + + cora scan <path> + Scan files for issues - cora review --file <path> - Review single file + cora scan . [--incremental] + Scan only changed files - cora scan [--incremental] - Full project scan + cora config show + Show resolved configuration + + + cora config set <key> <value> + Set a config value cora hook install @@ -115,17 +163,21 @@ Check current auth status - cora providers - List supported AI providers + cora auth remove + Remove stored API key - cora completion <shell> - Generate shell completions (bash/zsh/fish/powershell) + cora providers + List detected AI providers cora upload-sarif <file> Upload SARIF to GitHub Code Scanning + + cora completion <shell> + Generate shell completions (bash/zsh/fish) + @@ -158,7 +210,7 @@
# Compare your feature branch against main
- $ cora review --branch main + $ cora review --base main
diff --git a/website/src/routes/docs/configuration/+page.svelte b/website/src/routes/docs/configuration/+page.svelte index 5e9d3e4..981b53e 100644 --- a/website/src/routes/docs/configuration/+page.svelte +++ b/website/src/routes/docs/configuration/+page.svelte @@ -70,7 +70,7 @@
# cora project config
 review:
-  severity: warning          # minimum severity: info, warning, error
+  severity: warning          # minimum severity: info, minor, major, critical
   max_issues: 20             # max issues to report
   focus: security,performance  # focus areas
 
@@ -119,6 +119,30 @@
 					CORA_BASE_URL
 					Custom API base URL
 				
+				
+					CORA_CONFIG
+					Path to config file
+				
+				
+					CORA_FORMAT
+					Output format (pretty, json, compact, sarif)
+				
+				
+					CORA_NO_COLOR
+					Disable colored output
+				
+				
+					GITHUB_TOKEN
+					GitHub token for SARIF upload
+				
+				
+					GITHUB_REPOSITORY
+					GitHub repo for SARIF upload
+				
+				
+					GITHUB_REF
+					GitHub ref for SARIF upload
+				
 			
 		
 	
@@ -150,7 +174,9 @@ GROQ_API_KEY=gsk_... # Ollama (local, no key needed) -OLLAMA_BASE_URL=http://localhost:11434 +OLLAMA_HOST=http://localhost:11434 +# Optional: OLLAMA_API_KEY if your Ollama instance requires auth +OLLAMA_API_KEY=... # Z.AI ZAI_API_KEY=... diff --git a/website/src/routes/docs/examples/+page.svelte b/website/src/routes/docs/examples/+page.svelte index 5e966eb..bc550dd 100644 --- a/website/src/routes/docs/examples/+page.svelte +++ b/website/src/routes/docs/examples/+page.svelte @@ -41,7 +41,10 @@
- $ CORA_API_KEY=sk-xxx cora review --staged +# Review staged changes (default)
+ $ cora review

+ # Or review with explicit flags
+ $ cora review --staged
@@ -60,7 +63,7 @@
- $ cora review --branch main + $ cora review --base main
@@ -149,12 +152,12 @@ steps: - uses: actions/checkout@v4 - name: Install cora - run: cargo install cora + run: cargo install cora-cli - name: Run AI code review env: {@html 'CORA_API_KEY: ${{ secrets.CORA_API_KEY }}'} CORA_PROVIDER: openai - run: cora review --branch main --fail-on error + run: cora review --base main --format sarif @@ -197,10 +200,10 @@
- # Generate SARIF report and upload
- $ cora review --staged --output sarif > results.sarif && \
-   cora upload-sarif results.sarif

- Uploaded 3 findings to GitHub Code Scanning + # Generate SARIF report and upload
+ $ cora review --base main --format sarif > results.sarif && \\
+   cora upload-sarif results.sarif

+ Uploaded 3 findings to GitHub Code Scanning
diff --git a/website/src/routes/docs/getting-started/+page.svelte b/website/src/routes/docs/getting-started/+page.svelte index a152047..775aaf7 100644 --- a/website/src/routes/docs/getting-started/+page.svelte +++ b/website/src/routes/docs/getting-started/+page.svelte @@ -34,7 +34,7 @@
1
Install cora β€” Single binary via Cargo: - cargo install cora + cargo install cora-cli
@@ -57,7 +57,7 @@
4
Review β€” Analyze your staged changes: - cora review --staged + cora review
@@ -120,24 +120,25 @@
# .cora.yaml
-
provider: openai
-
model: gpt-4o
+
review:
+
severity: warning
+
focus: security,performance
-
custom_prompt: |
-
Focus on security vulnerabilities and
-
performance issues. Ignore style linting.
+
providers:
+
openai:
+
model: gpt-4o
- provider β€” Which LLM provider to use (openai, anthropic, groq, ollama, zai) + review.severity β€” Minimum severity level (info, minor, major, critical)
- model β€” Specific model name (e.g., gpt-4o, claude-sonnet-4-20250514) + review.focus β€” Focus areas for review (e.g., security, performance)
- custom_prompt β€” Override the default review prompt to focus on specific concerns + providers β€” Provider-specific model overrides (openai, anthropic, groq, ollama, zai)
diff --git a/website/src/routes/docs/installation/+page.svelte b/website/src/routes/docs/installation/+page.svelte index 2c62fbe..d57f4a5 100644 --- a/website/src/routes/docs/installation/+page.svelte +++ b/website/src/routes/docs/installation/+page.svelte @@ -31,7 +31,7 @@

cora is a Rust binary. You have two installation paths:

- Via Cargo (recommended) β€” Requires Rust 1.70 or later with Cargo installed + Via Cargo (recommended) β€” Requires Rust 1.85 or later with Cargo installed
Binary download β€” No Rust required; just download and place in your PATH @@ -51,7 +51,7 @@
-
$ cargo install cora
+
$ cargo install cora-cli
@@ -157,7 +157,7 @@
- Via Cargo: cargo install cora --force + Via Cargo: cargo install cora-cli --force
Via Binary: Download the latest release from GitHub and replace the existing binary diff --git a/website/src/routes/docs/providers/+page.svelte b/website/src/routes/docs/providers/+page.svelte index 058f734..7ae143b 100644 --- a/website/src/routes/docs/providers/+page.svelte +++ b/website/src/routes/docs/providers/+page.svelte @@ -46,8 +46,8 @@ OpenAI - gpt-4o - OPENAI_API_KEY + gpt-4o-mini + OPENAI_API_KEY OPENAI_BASE_URL @@ -58,7 +58,7 @@ Groq - llama-3.3-70b-versatile + llama-3.1-8b-instant GROQ_API_KEY GROQ_BASE_URL @@ -66,7 +66,7 @@ Ollama llama3.1 — (local) - OLLAMA_BASE_URL (default: http://localhost:11434) + OLLAMA_HOST (default: http://localhost:11434) Z.AI @@ -94,7 +94,7 @@ 'ANTHROPIC_API_KEY \u2192 uses Anthropic', 'GROQ_API_KEY \u2192 uses Groq', 'ZAI_API_KEY \u2192 uses Z.AI', - 'OLLAMA_BASE_URL \u2192 uses Ollama (localhost)' + 'OLLAMA_HOST \u2192 uses Ollama (localhost)' ] as item, i}
{i + 1}
diff --git a/website/src/routes/docs/usage/+page.svelte b/website/src/routes/docs/usage/+page.svelte index e43cccb..4f62231 100644 --- a/website/src/routes/docs/usage/+page.svelte +++ b/website/src/routes/docs/usage/+page.svelte @@ -28,7 +28,7 @@

Review Modes

-

cora supports four review modes, each suited to a different workflow:

+

cora supports multiple review modes, each suited to a different workflow:

@@ -41,6 +41,12 @@ + + + + + + @@ -48,22 +54,34 @@ - - + + + + + + + + + + + + + + - - - - + + + + - - - - + + + +
Default(no flag)Tries staged first, then unpushedQuick feedback
Staged --stagedPre-commit review
Branch--branchUnstaged--unstagedUnstaged working changesReview work in progress
Unpushed--unpushedCommits not yet pushedReview before push
Base Branch--base <branch> Diff against base branch PR review workflow
Full--fullEntire repositoryComprehensive auditCommit--commit <ref>Specific commit or rangeReview specific changes
Incremental--incrementalOnly new/changed filesLarge codebasesDiff File--diff-file <path>External diff fileReview patch files
@@ -76,17 +94,20 @@
-
# Review staged changes
-
$ cora review --staged
+
# Review staged changes (default)
+
$ cora review
+
+
# Review against a branch
+
$ cora review --base main
-
# Review against main branch
-
$ cora review --branch main
+
# Review a specific commit
+
$ cora review --commit HEAD
-
# Full project scan
-
$ cora review --full
+
# Review from a diff file
+
$ cora review --diff-file pr.diff
-
# Incremental (only changed files)
-
$ cora review --incremental
+
# Full project scan (use cora scan)
+
$ cora scan .
@@ -141,7 +162,7 @@

Configuration File

-

The .cora.yaml file provides persistent configuration. Place it in your project root or use ~/.cora/config.yaml for global settings.

+

The .cora.yaml file provides persistent configuration. Place it in your project root. API keys are stored at ~/.cora/config.toml.

@@ -150,30 +171,19 @@
-
# .cora.yaml β€” full example
-
-
# LLM provider: openai, anthropic, groq, ollama, zai
-
provider: openai
-
-
# Model name
-
model: gpt-4o
-
-
# Custom base URL (for proxies or self-hosted endpoints)
-
# base_url: https://api.example.com/v1
-
-
# Custom review prompt
-
# custom_prompt: |
-
# Focus on security and performance only.
-
-
# File patterns to include
-
# include:
-
# - "src/**"
-
# - "lib/**"
-
-
# File patterns to exclude
-
# exclude:
-
# - "**/*.test.ts"
-
# - "**/node_modules/**"
+
# .cora.yaml β€” example
+
+
review:
+
severity: warning
+
focus: security,performance
+
+
ignore:
+
- "vendor/**"
+
- "*.min.js"
+
+
providers:
+
openai:
+
model: gpt-4o
@@ -288,19 +298,19 @@
- Use --staged as a pre-commit hook for the fastest feedback loop + Use cora review with no flags for the fastest pre-commit feedback
- Combine --format json with --branch main in CI pipelines + Combine --format json with --base main in CI pipelines
- Use --incremental for large codebases β€” only changed files are analyzed + Use cora scan . --incremental for large codebases β€” only changed files are analyzed
- Set custom_prompt to match your team's coding standards + Use --quiet for minimal output and --severity to filter by severity level
diff --git a/website/static/og.png b/website/static/og.png new file mode 100644 index 0000000..dee27b5 Binary files /dev/null and b/website/static/og.png differ