flagrant 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .env
5
+ demo/
flagrant-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 parrwiz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,121 @@
1
+ Metadata-Version: 2.4
2
+ Name: flagrant
3
+ Version: 0.1.0
4
+ Summary: Local AI code reviewer for pre-commit checks.
5
+ Project-URL: Homepage, https://github.com/flagrant/flagrant
6
+ Project-URL: Repository, https://github.com/flagrant/flagrant
7
+ Author: Flagrant
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: ai,cli,code-review,developer-tools,linting
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Software Development :: Quality Assurance
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: anthropic>=0.40.0
21
+ Requires-Dist: gitpython>=3.1.0
22
+ Requires-Dist: google-genai>=1.0.0
23
+ Requires-Dist: openai>=1.0.0
24
+ Requires-Dist: python-dotenv>=1.0.0
25
+ Requires-Dist: rich>=13.0.0
26
+ Requires-Dist: typer[all]>=0.9.0
27
+ Description-Content-Type: text/markdown
28
+
29
+ # flagrant
30
+
31
+ CLI tool that reviews your code before you commit. Uses LLMs to catch bugs, security issues, and bad patterns the same way a senior dev would in a PR review.
32
+
33
+ ```
34
+ $ flagrant --staged
35
+
36
+ ───────────────────────────────────
37
+ flagrant | 3 issues flagged (1 high | 1 medium | 1 low)
38
+ ───────────────────────────────────
39
+
40
+ ● HIGH auth.py line 34
41
+ SQL query built with string concatenation
42
+ Fix: use parameterized queries
43
+
44
+ ● MEDIUM utils.py line 12
45
+ Function has no error handling
46
+ Fix: wrap in try/except, handle edge cases
47
+
48
+ ● LOW main.py line 5
49
+ Unused import os
50
+ Fix: remove it
51
+ ───────────────────────────────────
52
+ ```
53
+
54
+ Exits with code 1 on high-severity issues. Works as a pre-commit hook.
55
+
56
+ ## install
57
+
58
+ ```bash
59
+ pip install flagrant
60
+ ```
61
+
62
+ ## usage
63
+
64
+ ```bash
65
+ flagrant . # review entire repo
66
+ flagrant --staged # only staged changes (fast, cheap)
67
+ flagrant --file app.py # single file
68
+ flagrant --strict # security-focused pass
69
+ flagrant --explain # explain why each issue matters
70
+ ```
71
+
72
+ First run prompts for your API key. Supports **Claude**, **OpenAI**, **Gemini**, and **DeepSeek**.
73
+
74
+ ```bash
75
+ flagrant config # switch provider or update key
76
+ ```
77
+
78
+ ## git hook
79
+
80
+ ```bash
81
+ flagrant install-hook # auto-review on every commit
82
+ flagrant remove-hook # undo
83
+ ```
84
+
85
+ Blocks commits with high-severity issues. Skip with `git commit --no-verify`.
86
+
87
+ ## project config
88
+
89
+ Drop a `.flagrant` file in your repo root:
90
+
91
+ ```json
92
+ {
93
+ "ignore": ["migrations/", "tests/", "vendor/"],
94
+ "strict": true,
95
+ "explain": false,
96
+ "language": "python"
97
+ }
98
+ ```
99
+
100
+ ## how it works
101
+
102
+ 1. Reads your files or git diff
103
+ 2. Chunks large files to fit context windows
104
+ 3. Sends to your configured LLM with a review-focused system prompt
105
+ 4. Parses structured JSON issues from the response
106
+ 5. Prints results, returns exit code 1 if anything is high severity
107
+
108
+ No telemetry. No accounts. Your code goes straight to whichever LLM provider you pick and nowhere else.
109
+
110
+ ## providers
111
+
112
+ | Provider | Default model | Env var |
113
+ |----------|--------------|---------|
114
+ | Claude | claude-sonnet-4-20250514 | `ANTHROPIC_API_KEY` |
115
+ | OpenAI | gpt-4o | `OPENAI_API_KEY` |
116
+ | Gemini | gemini-2.5-flash | `GEMINI_API_KEY` |
117
+ | DeepSeek | deepseek-chat | `DEEPSEEK_API_KEY` |
118
+
119
+ ## license
120
+
121
+ MIT
@@ -0,0 +1,93 @@
1
+ # flagrant
2
+
3
+ CLI tool that reviews your code before you commit. Uses LLMs to catch bugs, security issues, and bad patterns the same way a senior dev would in a PR review.
4
+
5
+ ```
6
+ $ flagrant --staged
7
+
8
+ ───────────────────────────────────
9
+ flagrant | 3 issues flagged (1 high | 1 medium | 1 low)
10
+ ───────────────────────────────────
11
+
12
+ ● HIGH auth.py line 34
13
+ SQL query built with string concatenation
14
+ Fix: use parameterized queries
15
+
16
+ ● MEDIUM utils.py line 12
17
+ Function has no error handling
18
+ Fix: wrap in try/except, handle edge cases
19
+
20
+ ● LOW main.py line 5
21
+ Unused import os
22
+ Fix: remove it
23
+ ───────────────────────────────────
24
+ ```
25
+
26
+ Exits with code 1 on high-severity issues. Works as a pre-commit hook.
27
+
28
+ ## install
29
+
30
+ ```bash
31
+ pip install flagrant
32
+ ```
33
+
34
+ ## usage
35
+
36
+ ```bash
37
+ flagrant . # review entire repo
38
+ flagrant --staged # only staged changes (fast, cheap)
39
+ flagrant --file app.py # single file
40
+ flagrant --strict # security-focused pass
41
+ flagrant --explain # explain why each issue matters
42
+ ```
43
+
44
+ First run prompts for your API key. Supports **Claude**, **OpenAI**, **Gemini**, and **DeepSeek**.
45
+
46
+ ```bash
47
+ flagrant config # switch provider or update key
48
+ ```
49
+
50
+ ## git hook
51
+
52
+ ```bash
53
+ flagrant install-hook # auto-review on every commit
54
+ flagrant remove-hook # undo
55
+ ```
56
+
57
+ Blocks commits with high-severity issues. Skip with `git commit --no-verify`.
58
+
59
+ ## project config
60
+
61
+ Drop a `.flagrant` file in your repo root:
62
+
63
+ ```json
64
+ {
65
+ "ignore": ["migrations/", "tests/", "vendor/"],
66
+ "strict": true,
67
+ "explain": false,
68
+ "language": "python"
69
+ }
70
+ ```
71
+
72
+ ## how it works
73
+
74
+ 1. Reads your files or git diff
75
+ 2. Chunks large files to fit context windows
76
+ 3. Sends to your configured LLM with a review-focused system prompt
77
+ 4. Parses structured JSON issues from the response
78
+ 5. Prints results, returns exit code 1 if anything is high severity
79
+
80
+ No telemetry. No accounts. Your code goes straight to whichever LLM provider you pick and nowhere else.
81
+
82
+ ## providers
83
+
84
+ | Provider | Default model | Env var |
85
+ |----------|--------------|---------|
86
+ | Claude | claude-sonnet-4-20250514 | `ANTHROPIC_API_KEY` |
87
+ | OpenAI | gpt-4o | `OPENAI_API_KEY` |
88
+ | Gemini | gemini-2.5-flash | `GEMINI_API_KEY` |
89
+ | DeepSeek | deepseek-chat | `DEEPSEEK_API_KEY` |
90
+
91
+ ## license
92
+
93
+ MIT
@@ -0,0 +1,3 @@
1
+ """Flagrant: Flagrant is a local AI code reviewer that saves you from embarassing mistakes."""
2
+
3
+ __version__ = "1.0.1"
@@ -0,0 +1,174 @@
1
+ """Project config and API key management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ from dotenv import load_dotenv
12
+ from rich.console import Console
13
+ from rich.prompt import Prompt, Confirm
14
+
15
+ console = Console()
16
+
17
+ CONFIG_DIR = Path.home() / ".config" / "flagrant"
18
+ CONFIG_FILE = CONFIG_DIR / "config.json"
19
+
20
+ PROVIDERS = {
21
+ "claude": {
22
+ "name": "Claude (Anthropic)",
23
+ "env_var": "ANTHROPIC_API_KEY",
24
+ "models": ["claude-sonnet-4-20250514", "claude-3-5-sonnet-20241022"],
25
+ },
26
+ "openai": {
27
+ "name": "OpenAI",
28
+ "env_var": "OPENAI_API_KEY",
29
+ "models": ["gpt-4o", "gpt-4o-mini"],
30
+ },
31
+ "gemini": {
32
+ "name": "Gemini (Google)",
33
+ "env_var": "GEMINI_API_KEY",
34
+ "models": ["gemini-2.5-pro", "gemini-2.5-pro"],
35
+ },
36
+ "deepseek": {
37
+ "name": "DeepSeek",
38
+ "env_var": "DEEPSEEK_API_KEY",
39
+ "models": ["deepseek-chat", "deepseek-chat"],
40
+ },
41
+ }
42
+
43
+
44
+ @dataclass
45
+ class ProjectConfig:
46
+ """Per-project .flagrant config."""
47
+ ignore: list[str] = field(default_factory=list)
48
+ strict: bool = False
49
+ explain: bool = False
50
+ language: str = "auto"
51
+
52
+
53
+ @dataclass
54
+ class AppConfig:
55
+ """Global app config (API key + provider)."""
56
+ provider: str = "claude"
57
+ api_key: str = ""
58
+ model: Optional[str] = None
59
+
60
+ @property
61
+ def effective_model(self) -> str:
62
+ if self.model:
63
+ return self.model
64
+ return PROVIDERS[self.provider]["models"][0]
65
+
66
+
67
+ def load_project_config(repo_root: Path) -> ProjectConfig:
68
+ """Load .flagrant config from repo root, or return defaults."""
69
+ config_path = repo_root / ".flagrant"
70
+ if not config_path.exists():
71
+ return ProjectConfig()
72
+
73
+ try:
74
+ with open(config_path) as f:
75
+ data = json.load(f)
76
+ return ProjectConfig(
77
+ ignore=data.get("ignore", []),
78
+ strict=data.get("strict", False),
79
+ explain=data.get("explain", False),
80
+ language=data.get("language", "auto"),
81
+ )
82
+ except (json.JSONDecodeError, OSError) as e:
83
+ console.print(f"[yellow]warning: could not parse .flagrant config: {e}[/yellow]")
84
+ return ProjectConfig()
85
+
86
+
87
+ def _save_app_config(config: AppConfig) -> None:
88
+ """Write config to disk."""
89
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
90
+ data = {
91
+ "provider": config.provider,
92
+ "api_key": config.api_key,
93
+ "model": config.model,
94
+ }
95
+ with open(CONFIG_FILE, "w") as f:
96
+ json.dump(data, f, indent=2)
97
+
98
+
99
+ def _load_saved_config() -> Optional[AppConfig]:
100
+ """Load config from disk, if it exists."""
101
+ if not CONFIG_FILE.exists():
102
+ return None
103
+ try:
104
+ with open(CONFIG_FILE) as f:
105
+ data = json.load(f)
106
+ return AppConfig(
107
+ provider=data.get("provider", "claude"),
108
+ api_key=data.get("api_key", ""),
109
+ model=data.get("model"),
110
+ )
111
+ except (json.JSONDecodeError, OSError):
112
+ return None
113
+
114
+
115
+ def _check_env_key() -> Optional[AppConfig]:
116
+ """Check environment variables for API keys."""
117
+ load_dotenv()
118
+ for provider_id, info in PROVIDERS.items():
119
+ key = os.getenv(info["env_var"])
120
+ if key:
121
+ return AppConfig(provider=provider_id, api_key=key)
122
+ return None
123
+
124
+
125
+ def setup_api_key_interactive() -> AppConfig:
126
+ """Run the interactive API key setup prompt."""
127
+ console.print()
128
+ console.print("[bold cyan]flagrant setup[/bold cyan]")
129
+ console.print()
130
+ console.print("Choose your AI provider:")
131
+ console.print(" [bold]1[/bold] Claude (Anthropic)")
132
+ console.print(" [bold]2[/bold] OpenAI")
133
+ console.print(" [bold]3[/bold] Gemini (Google)")
134
+ console.print(" [bold]4[/bold] DeepSeek")
135
+ console.print()
136
+
137
+ choice = Prompt.ask("Provider", choices=["1", "2", "3", "4"], default="1")
138
+ provider_map = {"1": "claude", "2": "openai", "3": "gemini", "4": "deepseek"}
139
+ provider = provider_map[choice]
140
+
141
+ info = PROVIDERS[provider]
142
+ console.print()
143
+ api_key = Prompt.ask(f"Paste your {info['name']} API key")
144
+
145
+ if not api_key.strip():
146
+ console.print("[red]error: no API key provided[/red]")
147
+ raise SystemExit(1)
148
+
149
+ config = AppConfig(provider=provider, api_key=api_key.strip())
150
+ _save_app_config(config)
151
+
152
+ console.print(f"[green]saved. using {info['name']}[/green]")
153
+ console.print(f"[dim]Config stored at {CONFIG_FILE}[/dim]")
154
+ console.print()
155
+ return config
156
+
157
+
158
+ def get_app_config(require_key: bool = True) -> AppConfig:
159
+ """Resolve API config. Checks env, then saved config, then prompts."""
160
+ # 1. Environment variable
161
+ env_config = _check_env_key()
162
+ if env_config and env_config.api_key:
163
+ return env_config
164
+
165
+ # 2. Saved config
166
+ saved = _load_saved_config()
167
+ if saved and saved.api_key:
168
+ return saved
169
+
170
+ # 3. Interactive setup
171
+ if not require_key:
172
+ return AppConfig()
173
+
174
+ return setup_api_key_interactive()
@@ -0,0 +1,91 @@
1
+ """Terminal output formatting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.console import Console
6
+ from rich.panel import Panel
7
+ from rich.text import Text
8
+
9
+ from flagrant.reviewer import Issue
10
+
11
+ console = Console()
12
+
13
+ SEVERITY_STYLES = {
14
+ "high": ("bold red", "●", "HIGH "),
15
+ "medium": ("bold yellow", "●", "MEDIUM"),
16
+ "low": ("bold blue", "●", "LOW "),
17
+ }
18
+
19
+
20
+ def display_issues(issues: list[Issue], explain: bool = False) -> None:
21
+ """Print review issues to the terminal."""
22
+ if not issues:
23
+ console.print()
24
+ console.print("[bold green]no issues found[/bold green]")
25
+ console.print()
26
+ return
27
+
28
+ # Sort: high first, then medium, then low
29
+ severity_order = {"high": 0, "medium": 1, "low": 2}
30
+ issues.sort(key=lambda i: severity_order.get(i.severity, 3))
31
+
32
+ # Count by severity
33
+ counts = {}
34
+ for issue in issues:
35
+ counts[issue.severity] = counts.get(issue.severity, 0) + 1
36
+
37
+ # Header
38
+ total = len(issues)
39
+ header_parts = []
40
+ if counts.get("high"):
41
+ header_parts.append(f"[bold red]{counts['high']} high[/bold red]")
42
+ if counts.get("medium"):
43
+ header_parts.append(f"[bold yellow]{counts['medium']} medium[/bold yellow]")
44
+ if counts.get("low"):
45
+ header_parts.append(f"[bold blue]{counts['low']} low[/bold blue]")
46
+
47
+ summary = " | ".join(header_parts)
48
+
49
+ console.print()
50
+ console.rule(style="dim")
51
+ console.print(
52
+ f" [bold]flagrant[/bold] | {total} issue{'s' if total != 1 else ''} flagged ({summary})",
53
+ )
54
+ console.rule(style="dim")
55
+
56
+ # Issues
57
+ for issue in issues:
58
+ style, bullet, label = SEVERITY_STYLES.get(
59
+ issue.severity, ("dim", "○", "??? ")
60
+ )
61
+
62
+ line_str = f" line {issue.line}" if issue.line else ""
63
+ location = f"{issue.file}{line_str}"
64
+
65
+ console.print()
66
+ console.print(f" [{style}]{bullet} {label}[/{style}] [bold]{location}[/bold]")
67
+ console.print(f" {issue.issue}")
68
+ if issue.fix:
69
+ console.print(f" [dim]Fix: {issue.fix}[/dim]")
70
+
71
+ if explain and issue.explanation:
72
+ console.print(f" [italic cyan]{issue.explanation}[/italic cyan]")
73
+
74
+ console.print()
75
+ console.rule(style="dim")
76
+ console.print()
77
+
78
+
79
+ def display_error(message: str) -> None:
80
+ """Print an error."""
81
+ console.print(f"\n[bold red]error: {message}[/bold red]\n")
82
+
83
+
84
+ def display_info(message: str) -> None:
85
+ """Print an info message."""
86
+ console.print(f"\n[dim]{message}[/dim]\n")
87
+
88
+
89
+ def display_success(message: str) -> None:
90
+ """Print a success message."""
91
+ console.print(f"\n[green]{message}[/green]\n")
@@ -0,0 +1,151 @@
1
+ """Git helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from git import Repo, InvalidGitRepositoryError
10
+ from rich.console import Console
11
+
12
+ console = Console()
13
+
14
+
15
+ BINARY_EXTENSIONS = {
16
+ ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".svg", ".webp",
17
+ ".mp3", ".mp4", ".avi", ".mov", ".wav", ".flac",
18
+ ".zip", ".tar", ".gz", ".bz2", ".7z", ".rar",
19
+ ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
20
+ ".exe", ".dll", ".so", ".dylib", ".o", ".a",
21
+ ".pyc", ".pyo", ".class", ".wasm",
22
+ ".ttf", ".otf", ".woff", ".woff2", ".eot",
23
+ ".sqlite", ".db",
24
+ }
25
+
26
+ def get_git_root(path: str = ".") -> Optional[Path]:
27
+ """Find the git repo root from the given path."""
28
+ try:
29
+ repo = Repo(path, search_parent_directories=True)
30
+ return Path(repo.working_dir)
31
+ except InvalidGitRepositoryError:
32
+ return None
33
+
34
+
35
+ def _is_binary_path(filepath: str) -> bool:
36
+ """Check if a file is likely binary based on extension."""
37
+ return Path(filepath).suffix.lower() in BINARY_EXTENSIONS
38
+
39
+
40
+ def _is_binary_content(filepath: Path) -> bool:
41
+ """Check for null bytes."""
42
+ try:
43
+ with open(filepath, "rb") as f:
44
+ chunk = f.read(8192)
45
+ return b"\x00" in chunk
46
+ except OSError:
47
+ return True
48
+
49
+
50
+ def get_staged_diff(repo_path: str = ".") -> Optional[str]:
51
+ """Get the unified diff of all staged changes."""
52
+ try:
53
+ repo = Repo(repo_path, search_parent_directories=True)
54
+ diff = repo.git.diff("--cached", "--unified=3")
55
+ return diff if diff.strip() else None
56
+ except Exception:
57
+ return None
58
+
59
+
60
+ def get_staged_files(repo_path: str = ".") -> list[dict]:
61
+ """Get staged file contents. Skips binaries."""
62
+ files = []
63
+ try:
64
+ repo = Repo(repo_path, search_parent_directories=True)
65
+ root = Path(repo.working_dir)
66
+
67
+ # Get list of staged file paths
68
+ staged = repo.git.diff("--cached", "--name-only").strip()
69
+ if not staged:
70
+ return files
71
+
72
+ for rel_path in staged.splitlines():
73
+ rel_path = rel_path.strip()
74
+ if not rel_path or _is_binary_path(rel_path):
75
+ continue
76
+
77
+ full_path = root / rel_path
78
+ if not full_path.exists() or _is_binary_content(full_path):
79
+ continue
80
+
81
+ try:
82
+ content = full_path.read_text(encoding="utf-8", errors="replace")
83
+ files.append({"path": rel_path, "content": content})
84
+ except OSError:
85
+ continue
86
+
87
+ except Exception:
88
+ pass
89
+
90
+ return files
91
+
92
+
93
+ def get_repo_files(
94
+ path: str = ".",
95
+ ignore_patterns: list[str] | None = None,
96
+ ) -> list[dict]:
97
+ """Get all tracked non-binary files in the repo."""
98
+ files = []
99
+ ignore_patterns = ignore_patterns or []
100
+
101
+ try:
102
+ repo = Repo(path, search_parent_directories=True)
103
+ root = Path(repo.working_dir)
104
+
105
+ tracked = repo.git.ls_files().strip()
106
+ if not tracked:
107
+ return files
108
+
109
+ for rel_path in tracked.splitlines():
110
+ rel_path = rel_path.strip()
111
+ if not rel_path or _is_binary_path(rel_path):
112
+ continue
113
+
114
+ # Check ignore patterns
115
+ if any(_matches_ignore(rel_path, pat) for pat in ignore_patterns):
116
+ continue
117
+
118
+ full_path = root / rel_path
119
+ if not full_path.exists() or _is_binary_content(full_path):
120
+ continue
121
+
122
+ try:
123
+ content = full_path.read_text(encoding="utf-8", errors="replace")
124
+ files.append({"path": rel_path, "content": content})
125
+ except OSError:
126
+ continue
127
+
128
+ except Exception:
129
+ pass
130
+
131
+ return files
132
+
133
+
134
+ def read_single_file(filepath: str) -> Optional[dict]:
135
+ """Read a single file, returning {path, content} or None."""
136
+ p = Path(filepath)
137
+ if not p.exists():
138
+ return None
139
+ if _is_binary_path(filepath) or _is_binary_content(p):
140
+ return None
141
+ try:
142
+ content = p.read_text(encoding="utf-8", errors="replace")
143
+ return {"path": str(p), "content": content}
144
+ except OSError:
145
+ return None
146
+
147
+
148
+ def _matches_ignore(filepath: str, pattern: str) -> bool:
149
+ """Check if filepath matches an ignore pattern."""
150
+ pattern = pattern.rstrip("/")
151
+ return filepath.startswith(pattern) or f"/{pattern}" in filepath
@@ -0,0 +1,306 @@
1
+ """CLI entry points."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import stat
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ import click
12
+ import typer
13
+ from rich.console import Console
14
+ from rich.spinner import Spinner
15
+ from rich.live import Live
16
+
17
+ from flagrant.config import (
18
+ get_app_config,
19
+ load_project_config,
20
+ setup_api_key_interactive,
21
+ AppConfig,
22
+ PROVIDERS,
23
+ )
24
+ from flagrant.git_utils import (
25
+ get_git_root,
26
+ get_staged_diff,
27
+ get_staged_files,
28
+ get_repo_files,
29
+ read_single_file,
30
+ )
31
+ from flagrant.reviewer import review_code, review_diff, Issue
32
+ from flagrant.formatter import (
33
+ display_issues,
34
+ display_error,
35
+ display_info,
36
+ display_success,
37
+ )
38
+
39
+ console = Console()
40
+
41
+ _SUBCOMMANDS = {"config", "install-hook", "remove-hook"}
42
+
43
+
44
+ def _run_review(
45
+ files: list[dict],
46
+ app_config: AppConfig,
47
+ strict: bool,
48
+ explain: bool,
49
+ diff_mode: bool,
50
+ language: str,
51
+ diff_text: Optional[str] = None,
52
+ ) -> list[Issue]:
53
+ """Run the review, show a spinner while waiting."""
54
+ provider_name = PROVIDERS[app_config.provider]["name"]
55
+ model = app_config.effective_model
56
+
57
+ with Live(
58
+ Spinner("dots", text=f" [dim]Reviewing with {provider_name} ({model})...[/dim]"),
59
+ console=console,
60
+ transient=True,
61
+ ):
62
+ if diff_mode and diff_text:
63
+ issues = review_diff(
64
+ diff_text=diff_text,
65
+ app_config=app_config,
66
+ strict=strict,
67
+ explain=explain,
68
+ language=language,
69
+ )
70
+ else:
71
+ issues = review_code(
72
+ files=files,
73
+ app_config=app_config,
74
+ strict=strict,
75
+ explain=explain,
76
+ diff_mode=diff_mode,
77
+ language=language,
78
+ )
79
+
80
+ return issues
81
+
82
+
83
+ def do_review(
84
+ path: Optional[str] = None,
85
+ staged: bool = False,
86
+ file: Optional[str] = None,
87
+ strict: bool = False,
88
+ explain: bool = False,
89
+ ) -> None:
90
+ """Run a review based on CLI args."""
91
+ # Default to current dir
92
+ if path is None and not staged and file is None:
93
+ path = "."
94
+
95
+ # Load configs
96
+ try:
97
+ app_config = get_app_config()
98
+ except SystemExit:
99
+ raise typer.Exit(1)
100
+
101
+ repo_root = get_git_root(path or ".")
102
+ project_config = load_project_config(repo_root) if repo_root else None
103
+
104
+ # Merge project config with CLI flags
105
+ if project_config:
106
+ strict = strict or project_config.strict
107
+ explain = explain or project_config.explain
108
+ language = project_config.language
109
+ ignore_patterns = project_config.ignore
110
+ else:
111
+ language = "auto"
112
+ ignore_patterns = []
113
+
114
+ # Determine what to review
115
+ files: list[dict] = []
116
+ diff_text: Optional[str] = None
117
+ diff_mode = False
118
+
119
+ if file:
120
+ # Single file mode
121
+ result = read_single_file(file)
122
+ if not result:
123
+ display_error(f"Cannot read file: {file}")
124
+ raise typer.Exit(1)
125
+ files = [result]
126
+ elif staged:
127
+ # staged changes -- review the diff
128
+ diff_text = get_staged_diff(path or ".")
129
+ if not diff_text:
130
+ display_info("nothing staged")
131
+ raise typer.Exit(0)
132
+ diff_mode = True
133
+ else:
134
+ # Full repo/directory scan
135
+ target = path or "."
136
+ target_path = Path(target)
137
+
138
+ if target_path.is_file():
139
+ result = read_single_file(str(target_path))
140
+ if not result:
141
+ display_error(f"Cannot read file: {target}")
142
+ raise typer.Exit(1)
143
+ files = [result]
144
+ else:
145
+ files = get_repo_files(target, ignore_patterns=ignore_patterns)
146
+ if not files:
147
+ display_info("no reviewable files found")
148
+ raise typer.Exit(0)
149
+
150
+ # limit how much we send to the api
151
+ if len(files) > 50:
152
+ console.print(
153
+ f"[yellow]{len(files)} files found, reviewing first 50. "
154
+ f"use --file or --staged to narrow scope.[/yellow]"
155
+ )
156
+ files = files[:50]
157
+
158
+ # Run review
159
+ try:
160
+ issues = _run_review(
161
+ files=files,
162
+ app_config=app_config,
163
+ strict=strict,
164
+ explain=explain,
165
+ diff_mode=diff_mode,
166
+ language=language,
167
+ diff_text=diff_text,
168
+ )
169
+ except RuntimeError:
170
+ display_error("review failed. check your API key and account balance.")
171
+ raise typer.Exit(1)
172
+
173
+ # Display results
174
+ display_issues(issues, explain=explain)
175
+
176
+ # Exit code 1 if high severity issues found
177
+ if any(i.severity == "high" for i in issues):
178
+ raise typer.Exit(1)
179
+
180
+
181
+
182
+
183
+ class FlagrantCLI(click.Group):
184
+ """Routes bare args to the review subcommand."""
185
+
186
+ def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
187
+ if args:
188
+
189
+ first_non_opt = None
190
+ for a in args:
191
+ if not a.startswith("-"):
192
+ first_non_opt = a
193
+ break
194
+
195
+ if first_non_opt is None or first_non_opt not in self.commands:
196
+ args = ["review"] + args
197
+
198
+ if not args:
199
+ args = ["review"]
200
+
201
+ return super().parse_args(ctx, args)
202
+
203
+
204
+ @click.group(cls=FlagrantCLI)
205
+ def app():
206
+ """flagrant - local AI code reviewer."""
207
+ pass
208
+
209
+
210
+ @app.command("review")
211
+ @click.argument("path", required=False, default=None)
212
+ @click.option("--staged", "-s", is_flag=True, help="Only review staged git changes.")
213
+ @click.option("--file", "-f", "file_path", default=None, help="Review a single file.")
214
+ @click.option("--strict", is_flag=True, help="Security-focused review pass.")
215
+ @click.option("--explain", "-e", is_flag=True, help="Include teaching explanations.")
216
+ def review_cmd(path, staged, file_path, strict, explain):
217
+ """Review code for issues like a senior developer would."""
218
+ do_review(path=path, staged=staged, file=file_path, strict=strict, explain=explain)
219
+
220
+
221
+ @app.command("config")
222
+ def configure():
223
+ """Reconfigure API key and provider settings."""
224
+ setup_api_key_interactive()
225
+
226
+
227
+ PRE_COMMIT_HOOK = """#!/bin/sh
228
+ # flagrant pre-commit hook
229
+ # Installed by: flagrant install-hook
230
+
231
+ flagrant --staged
232
+ exit_code=$?
233
+
234
+ if [ $exit_code -ne 0 ]; then
235
+ echo ""
236
+ echo "flagrant: commit blocked (high severity issues found)"
237
+ echo " Fix the issues above, or commit with --no-verify to skip."
238
+ echo ""
239
+ fi
240
+
241
+ exit $exit_code
242
+ """
243
+
244
+
245
+ @app.command("install-hook")
246
+ def install_hook():
247
+ """Install a pre-commit git hook that runs flagrant --staged."""
248
+ repo_root = get_git_root(".")
249
+ if not repo_root:
250
+ display_error("Not inside a git repository.")
251
+ raise SystemExit(1)
252
+
253
+ hooks_dir = repo_root / ".git" / "hooks"
254
+ hook_path = hooks_dir / "pre-commit"
255
+
256
+ if hook_path.exists():
257
+ content = hook_path.read_text()
258
+ if "flagrant" in content.lower():
259
+ display_info("Flagrant pre-commit hook is already installed.")
260
+ return
261
+ else:
262
+ display_error(
263
+ f"A pre-commit hook already exists at {hook_path}.\n"
264
+ " Back it up or remove it, then try again."
265
+ )
266
+ raise SystemExit(1)
267
+
268
+ hooks_dir.mkdir(parents=True, exist_ok=True)
269
+ hook_path.write_text(PRE_COMMIT_HOOK)
270
+ hook_path.chmod(hook_path.stat().st_mode | stat.S_IEXEC)
271
+
272
+ display_success(f"Pre-commit hook installed at {hook_path}")
273
+ console.print(
274
+ "[dim] It will run [bold]flagrant --staged[/bold] before every commit.\n"
275
+ " Use [bold]git commit --no-verify[/bold] to skip.[/dim]\n"
276
+ )
277
+
278
+
279
+ @app.command("remove-hook")
280
+ def remove_hook():
281
+ """Remove the flagrant pre-commit hook."""
282
+ repo_root = get_git_root(".")
283
+ if not repo_root:
284
+ display_error("Not inside a git repository.")
285
+ raise SystemExit(1)
286
+
287
+ hook_path = repo_root / ".git" / "hooks" / "pre-commit"
288
+
289
+ if not hook_path.exists():
290
+ display_info("no pre-commit hook found.")
291
+ return
292
+
293
+ content = hook_path.read_text()
294
+ if "flagrant" not in content.lower():
295
+ display_error(
296
+ "The existing pre-commit hook was not installed by Flagrant.\n"
297
+ " Remove it manually if needed."
298
+ )
299
+ raise SystemExit(1)
300
+
301
+ hook_path.unlink()
302
+ display_success("Pre-commit hook removed.")
303
+
304
+
305
+ if __name__ == "__main__":
306
+ app()
@@ -0,0 +1,83 @@
1
+ """Prompt construction."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ BASE_PROMPT = """You are a senior software engineer doing a code review. Be direct and blunt.
7
+ You care about real, practical issues, not nitpicking style and formatting.
8
+
9
+ For each issue found return a JSON array in this exact format:
10
+ [
11
+ {
12
+ "severity": "high|medium|low",
13
+ "file": "filename",
14
+ "line": <line_number_or_null>,
15
+ "issue": "one sentence describing the problem",
16
+ "fix": "one sentence describing the solution"
17
+ }
18
+ ]
19
+
20
+ Return ONLY the JSON array. No preamble. No markdown. No commentary.
21
+ If you find no issues, return an empty array: []
22
+
23
+ Severity rules:
24
+ - high: security vulnerabilities, data loss risk, crashes, broken logic
25
+ - medium: bad patterns, missing error handling, performance issues, race conditions
26
+ - low: unused code, minor improvements, documentation gaps
27
+
28
+ Be selective. Only flag things that actually matter. Do NOT flag:
29
+ - Style preferences or formatting
30
+ - Missing type hints (unless causing bugs)
31
+ - Things that are obviously intentional
32
+ - Test files unless they have actual bugs"""
33
+
34
+
35
+ STRICT_ADDON = """
36
+
37
+ STRICT MODE - security-focused review. Prioritize:
38
+ - SQL injection, XSS, command injection, path traversal
39
+ - Hardcoded secrets, credentials, API keys in code
40
+ - Insecure crypto, weak hashing, cleartext passwords
41
+ - SSRF, open redirects, insecure deserialization
42
+ - Missing input validation on user-facing endpoints
43
+ - Overly permissive file/network access
44
+ Flag ALL security concerns as HIGH severity."""
45
+
46
+
47
+ EXPLAIN_ADDON = """
48
+
49
+ For EVERY issue, include an additional field:
50
+ "explanation": "2-3 sentences teaching why this matters and what could go wrong"
51
+
52
+ This is for educational purposes - explain like you're mentoring a junior developer."""
53
+
54
+
55
+ DIFF_MODE_ADDON = """
56
+
57
+ You are reviewing a git diff. Focus ONLY on the changed lines (lines starting with +).
58
+ The file paths and line numbers are shown in the diff headers.
59
+ Do not flag issues in unchanged code (context lines) unless a change introduced a bug that interacts with existing code."""
60
+
61
+
62
+ def build_system_prompt(
63
+ strict: bool = False,
64
+ explain: bool = False,
65
+ diff_mode: bool = False,
66
+ language: str = "auto",
67
+ ) -> str:
68
+ """Build the full system prompt with optional addons."""
69
+ prompt = BASE_PROMPT
70
+
71
+ if language != "auto":
72
+ prompt += f"\n\nThe codebase is primarily written in {language}."
73
+
74
+ if diff_mode:
75
+ prompt += DIFF_MODE_ADDON
76
+
77
+ if strict:
78
+ prompt += STRICT_ADDON
79
+
80
+ if explain:
81
+ prompt += EXPLAIN_ADDON
82
+
83
+ return prompt
@@ -0,0 +1,299 @@
1
+ """Review logic."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import time
8
+ from dataclasses import dataclass
9
+ from typing import Optional
10
+
11
+ from rich.console import Console
12
+
13
+ from flagrant.config import AppConfig, PROVIDERS
14
+ from flagrant.prompt import build_system_prompt
15
+
16
+ console = Console()
17
+
18
+ MAX_LINES_PER_CHUNK = 3000
19
+ MAX_RETRIES = 1
20
+
21
+
22
+ @dataclass
23
+ class Issue:
24
+ """A single code review issue."""
25
+ severity: str # high, medium, low
26
+ file: str
27
+ line: Optional[int]
28
+ issue: str
29
+ fix: str
30
+ explanation: Optional[str] = None
31
+
32
+
33
+ def chunk_files(files: list[dict], max_lines: int = MAX_LINES_PER_CHUNK) -> list[str]:
34
+ """Split files into chunks that fit context limits."""
35
+ chunks = []
36
+ current_chunk_lines = 0
37
+ current_chunk_parts = []
38
+
39
+ for f in files:
40
+ content = f["content"]
41
+ lines = content.splitlines()
42
+ file_header = f"--- FILE: {f['path']} ---"
43
+
44
+ # If single file exceeds max, split it
45
+ if len(lines) > max_lines:
46
+ # Flush current chunk first
47
+ if current_chunk_parts:
48
+ chunks.append("\n".join(current_chunk_parts))
49
+ current_chunk_parts = []
50
+ current_chunk_lines = 0
51
+
52
+ for i in range(0, len(lines), max_lines):
53
+ slice_lines = lines[i:i + max_lines]
54
+ part_header = f"{file_header} (lines {i + 1}-{i + len(slice_lines)})"
55
+ chunks.append(part_header + "\n" + "\n".join(slice_lines))
56
+ else:
57
+ # Would adding this file exceed the limit?
58
+ if current_chunk_lines + len(lines) > max_lines and current_chunk_parts:
59
+ chunks.append("\n".join(current_chunk_parts))
60
+ current_chunk_parts = []
61
+ current_chunk_lines = 0
62
+
63
+ current_chunk_parts.append(file_header + "\n" + content)
64
+ current_chunk_lines += len(lines)
65
+
66
+ # Flush remaining
67
+ if current_chunk_parts:
68
+ chunks.append("\n".join(current_chunk_parts))
69
+
70
+ return chunks
71
+
72
+
73
+ def _parse_issues(raw: str) -> list[Issue]:
74
+ """Parse JSON issues from LLM response. Handles messy output."""
75
+ raw = raw.strip()
76
+
77
+ # Try direct parse first
78
+ try:
79
+ data = json.loads(raw)
80
+ if isinstance(data, list):
81
+ return [_dict_to_issue(d) for d in data if isinstance(d, dict)]
82
+ except json.JSONDecodeError:
83
+ pass
84
+
85
+ # Try to extract JSON array from surrounding text
86
+ match = re.search(r"\[.*\]", raw, re.DOTALL)
87
+ if match:
88
+ try:
89
+ data = json.loads(match.group())
90
+ if isinstance(data, list):
91
+ return [_dict_to_issue(d) for d in data if isinstance(d, dict)]
92
+ except json.JSONDecodeError:
93
+ pass
94
+
95
+ # If we got markdown-wrapped JSON, strip it
96
+ cleaned = re.sub(r"```(?:json)?\s*", "", raw)
97
+ cleaned = cleaned.strip().rstrip("`")
98
+ try:
99
+ data = json.loads(cleaned)
100
+ if isinstance(data, list):
101
+ return [_dict_to_issue(d) for d in data if isinstance(d, dict)]
102
+ except json.JSONDecodeError:
103
+ pass
104
+
105
+ return []
106
+
107
+
108
+ def _dict_to_issue(d: dict) -> Issue:
109
+ return Issue(
110
+ severity=d.get("severity", "low").lower().strip(),
111
+ file=d.get("file", "unknown"),
112
+ line=d.get("line"),
113
+ issue=d.get("issue", ""),
114
+ fix=d.get("fix", ""),
115
+ explanation=d.get("explanation"),
116
+ )
117
+
118
+
119
+ def _call_claude(
120
+ app_config: AppConfig,
121
+ system_prompt: str,
122
+ user_content: str,
123
+ ) -> str:
124
+ """Call Anthropic Claude API."""
125
+ from anthropic import Anthropic
126
+
127
+ client = Anthropic(api_key=app_config.api_key)
128
+ response = client.messages.create(
129
+ model=app_config.effective_model,
130
+ max_tokens=4096,
131
+ system=system_prompt,
132
+ messages=[{"role": "user", "content": user_content}],
133
+ )
134
+ return response.content[0].text
135
+
136
+
137
+ def _call_openai(
138
+ app_config: AppConfig,
139
+ system_prompt: str,
140
+ user_content: str,
141
+ ) -> str:
142
+ """Call OpenAI API."""
143
+ from openai import OpenAI
144
+
145
+ client = OpenAI(api_key=app_config.api_key)
146
+ response = client.chat.completions.create(
147
+ model=app_config.effective_model,
148
+ max_tokens=4096,
149
+ messages=[
150
+ {"role": "system", "content": system_prompt},
151
+ {"role": "user", "content": user_content},
152
+ ],
153
+ )
154
+ return response.choices[0].message.content
155
+
156
+
157
+ def _call_gemini(
158
+ app_config: AppConfig,
159
+ system_prompt: str,
160
+ user_content: str,
161
+ ) -> str:
162
+ """Call Google Gemini API."""
163
+ from google import genai
164
+
165
+ client = genai.Client(api_key=app_config.api_key)
166
+ response = client.models.generate_content(
167
+ model=app_config.effective_model,
168
+ contents=user_content,
169
+ config=genai.types.GenerateContentConfig(
170
+ system_instruction=system_prompt,
171
+ max_output_tokens=4096,
172
+ ),
173
+ )
174
+ return response.text
175
+
176
+
177
+ def _call_deepseek(
178
+ app_config: AppConfig,
179
+ system_prompt: str,
180
+ user_content: str,
181
+ ) -> str:
182
+ """Call DeepSeek API (OpenAI-compatible)."""
183
+ from openai import OpenAI
184
+
185
+ client = OpenAI(
186
+ api_key=app_config.api_key,
187
+ base_url="https://api.deepseek.com",
188
+ )
189
+ response = client.chat.completions.create(
190
+ model=app_config.effective_model,
191
+ max_tokens=4096,
192
+ messages=[
193
+ {"role": "system", "content": system_prompt},
194
+ {"role": "user", "content": user_content},
195
+ ],
196
+ )
197
+ return response.choices[0].message.content
198
+
199
+
200
+ PROVIDER_CALLERS = {
201
+ "claude": _call_claude,
202
+ "openai": _call_openai,
203
+ "gemini": _call_gemini,
204
+ "deepseek": _call_deepseek,
205
+ }
206
+
207
+
208
+ def _call_llm(
209
+ app_config: AppConfig,
210
+ system_prompt: str,
211
+ user_content: str,
212
+ ) -> str:
213
+ """Call the configured provider. Retries once on failure."""
214
+ caller = PROVIDER_CALLERS.get(app_config.provider)
215
+ if not caller:
216
+ raise ValueError(f"Unknown provider: {app_config.provider}")
217
+
218
+ last_error = None
219
+ for attempt in range(MAX_RETRIES + 1):
220
+ try:
221
+ return caller(app_config, system_prompt, user_content)
222
+ except Exception as e:
223
+ last_error = e
224
+ if attempt < MAX_RETRIES:
225
+ console.print(f"[yellow]warning: API call failed, retrying... ({e})[/yellow]")
226
+ time.sleep(2)
227
+
228
+ raise RuntimeError(
229
+ f"API call failed after {MAX_RETRIES + 1} attempts: {last_error}"
230
+ )
231
+
232
+
233
+ def review_code(
234
+ files: list[dict],
235
+ app_config: AppConfig,
236
+ strict: bool = False,
237
+ explain: bool = False,
238
+ diff_mode: bool = False,
239
+ language: str = "auto",
240
+ ) -> list[Issue]:
241
+ """Review files. Chunks large inputs, calls the LLM, parses results."""
242
+ if not files:
243
+ return []
244
+
245
+ system_prompt = build_system_prompt(
246
+ strict=strict,
247
+ explain=explain,
248
+ diff_mode=diff_mode,
249
+ language=language,
250
+ )
251
+
252
+ chunks = chunk_files(files)
253
+ all_issues: list[Issue] = []
254
+ has_error = False
255
+
256
+ for i, chunk in enumerate(chunks):
257
+ if len(chunks) > 1:
258
+ console.print(
259
+ f"[dim] scanning chunk {i + 1}/{len(chunks)}...[/dim]"
260
+ )
261
+
262
+ user_msg = "Review the following code:\n\n" + chunk
263
+ try:
264
+ raw = _call_llm(app_config, system_prompt, user_msg)
265
+ issues = _parse_issues(raw)
266
+ all_issues.extend(issues)
267
+ except RuntimeError as e:
268
+ console.print(f"[red]error: {e}[/red]")
269
+ has_error = True
270
+
271
+ if has_error and not all_issues:
272
+ raise RuntimeError("Review failed, could not reach the API.")
273
+
274
+ return all_issues
275
+
276
+
277
+ def review_diff(
278
+ diff_text: str,
279
+ app_config: AppConfig,
280
+ strict: bool = False,
281
+ explain: bool = False,
282
+ language: str = "auto",
283
+ ) -> list[Issue]:
284
+ """Review a git diff string."""
285
+ system_prompt = build_system_prompt(
286
+ strict=strict,
287
+ explain=explain,
288
+ diff_mode=True,
289
+ language=language,
290
+ )
291
+
292
+ user_msg = "Review the following git diff:\n\n" + diff_text
293
+
294
+ try:
295
+ raw = _call_llm(app_config, system_prompt, user_msg)
296
+ return _parse_issues(raw)
297
+ except RuntimeError as e:
298
+ console.print(f"[red]error: {e}[/red]")
299
+ raise
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "flagrant"
7
+ version = "0.1.0"
8
+ description = "Local AI code reviewer for pre-commit checks."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ { name = "Flagrant" },
14
+ ]
15
+ keywords = ["code-review", "ai", "cli", "linting", "developer-tools"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Topic :: Software Development :: Quality Assurance",
25
+ ]
26
+ dependencies = [
27
+ "typer[all]>=0.9.0",
28
+ "rich>=13.0.0",
29
+ "gitpython>=3.1.0",
30
+ "anthropic>=0.40.0",
31
+ "openai>=1.0.0",
32
+ "google-genai>=1.0.0",
33
+ "python-dotenv>=1.0.0",
34
+ ]
35
+
36
+ [project.scripts]
37
+ flagrant = "flagrant.main:app"
38
+
39
+ [project.urls]
40
+ Homepage = "https://github.com/flagrant/flagrant"
41
+ Repository = "https://github.com/flagrant/flagrant"
@@ -0,0 +1 @@
1
+ # tests