commitcraft-cli 0.1.0__py3-none-any.whl

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 @@
1
+ __version__ = "0.1.0"
File without changes
@@ -0,0 +1,57 @@
1
+ from dataclasses import dataclass, field
2
+ from pathlib import Path
3
+
4
+ from commitcraft.analysis.filters import FilteredDiff
5
+
6
+
7
+ @dataclass
8
+ class ClassificationResult:
9
+ score: int
10
+ is_simple: bool
11
+ signals: dict[str, int | bool] = field(default_factory=dict)
12
+
13
+
14
+ def _count_directories(files: list) -> int:
15
+ dirs = {str(Path(f.path).parent) for f in files}
16
+ return len(dirs)
17
+
18
+
19
+ def _has_test_alongside_source(files: list) -> bool:
20
+ paths = [f.path for f in files]
21
+ has_test = any("test" in p.lower() for p in paths)
22
+ has_source = any("test" not in p.lower() for p in paths)
23
+ return has_test and has_source
24
+
25
+
26
+ def classify(filtered: FilteredDiff, threshold: int = 30) -> ClassificationResult:
27
+ if not filtered.files:
28
+ return ClassificationResult(score=0, is_simple=True, signals={
29
+ "file_count": 0, "directory_spread": 0,
30
+ "function_changes": 0, "test_alongside_source": False, "line_count": 0,
31
+ })
32
+
33
+ file_count = len(filtered.files)
34
+ dir_spread = _count_directories(filtered.files)
35
+ func_changes = sum(1 for f in filtered.files if f.has_function_changes or f.has_class_changes)
36
+ test_with_source = _has_test_alongside_source(filtered.files)
37
+ total_lines = filtered.total_added + filtered.total_removed
38
+
39
+ # Weighted scoring (caps at 100)
40
+ score = 0
41
+ score += min(file_count * 5, 25) # up to 25 pts for file count
42
+ score += min(dir_spread * 5, 20) # up to 20 pts for directory spread
43
+ score += min(func_changes * 10, 30) # up to 30 pts for function/class changes
44
+ score += 10 if test_with_source else 0 # 10 pts for test+source together
45
+ score += min(total_lines // 20, 15) # up to 15 pts for line count
46
+
47
+ score = min(score, 100)
48
+
49
+ signals = {
50
+ "file_count": file_count,
51
+ "directory_spread": dir_spread,
52
+ "function_changes": func_changes,
53
+ "test_alongside_source": test_with_source,
54
+ "line_count": total_lines,
55
+ }
56
+
57
+ return ClassificationResult(score=score, is_simple=score < threshold, signals=signals)
@@ -0,0 +1,67 @@
1
+ import fnmatch
2
+ from dataclasses import dataclass, field
3
+
4
+ from commitcraft.git.diff_parser import DiffFile, ParsedDiff
5
+
6
+ DEFAULT_IGNORE_PATTERNS: list[str] = [
7
+ "package-lock.json",
8
+ "yarn.lock",
9
+ "poetry.lock",
10
+ "Pipfile.lock",
11
+ "composer.lock",
12
+ "Gemfile.lock",
13
+ "pnpm-lock.yaml",
14
+ "dist/*",
15
+ "build/*",
16
+ ".next/*",
17
+ "node_modules/*",
18
+ "*.min.js",
19
+ "*.min.css",
20
+ "*.map",
21
+ "__pycache__/*",
22
+ "*.pyc",
23
+ ".mypy_cache/*",
24
+ ".ruff_cache/*",
25
+ "coverage/*",
26
+ ".coverage",
27
+ "*.egg-info/*",
28
+ "site-packages/*",
29
+ ]
30
+
31
+
32
+ @dataclass
33
+ class FilteredDiff:
34
+ files: list[DiffFile] = field(default_factory=list)
35
+ filtered_out: list[str] = field(default_factory=list)
36
+ total_added: int = 0
37
+ total_removed: int = 0
38
+
39
+
40
+ def _is_ignored(path: str, patterns: list[str]) -> bool:
41
+ for pattern in patterns:
42
+ if fnmatch.fnmatch(path, pattern):
43
+ return True
44
+ # also match if path starts with a prefix pattern
45
+ # e.g. "dist/*" should match "dist/foo/bar.js"
46
+ prefix = pattern.rstrip("/*")
47
+ if path.startswith(prefix + "/") or path.startswith(prefix + "\\"):
48
+ return True
49
+ return False
50
+
51
+
52
+ def filter_diff(
53
+ parsed: ParsedDiff,
54
+ extra_patterns: list[str] | None = None,
55
+ ) -> FilteredDiff:
56
+ patterns = DEFAULT_IGNORE_PATTERNS + (extra_patterns or [])
57
+ result = FilteredDiff()
58
+
59
+ for f in parsed.files:
60
+ if _is_ignored(f.path, patterns):
61
+ result.filtered_out.append(f.path)
62
+ else:
63
+ result.files.append(f)
64
+ result.total_added += f.added_lines
65
+ result.total_removed += f.removed_lines
66
+
67
+ return result
@@ -0,0 +1,101 @@
1
+ from dataclasses import dataclass
2
+ from pathlib import Path
3
+
4
+ from commitcraft.analysis.filters import FilteredDiff
5
+
6
+ LOCKFILE_NAMES = {
7
+ "package-lock.json", "yarn.lock", "poetry.lock",
8
+ "Pipfile.lock", "composer.lock", "Gemfile.lock", "pnpm-lock.yaml",
9
+ }
10
+
11
+ DEPENDENCY_MANIFESTS = {
12
+ "requirements.txt", "requirements-dev.txt", "setup.cfg",
13
+ "package.json", "Pipfile", "pyproject.toml",
14
+ }
15
+
16
+ CONFIG_PATTERNS = {".yml", ".yaml", ".json", ".toml", ".ini", ".cfg", ".env"}
17
+ CONFIG_DIRS = {".github", ".circleci", ".gitlab", "config", ".husky"}
18
+
19
+
20
+ @dataclass
21
+ class RuleResult:
22
+ matched: bool
23
+ message: str | None
24
+ rule_name: str | None
25
+
26
+
27
+ def _all_paths(filtered: FilteredDiff) -> list[str]:
28
+ return [f.path for f in filtered.files]
29
+
30
+
31
+ def _readme_only(paths: list[str]) -> RuleResult | None:
32
+ if len(paths) == 1 and Path(paths[0]).name.upper().startswith("README"):
33
+ return RuleResult(matched=True, message="docs: update README", rule_name="readme_only")
34
+ return None
35
+
36
+
37
+ def _lockfile_only(paths: list[str]) -> RuleResult | None:
38
+ if all(Path(p).name in LOCKFILE_NAMES for p in paths):
39
+ return RuleResult(matched=True, message="chore: update lockfile", rule_name="lockfile_only")
40
+ return None
41
+
42
+
43
+ def _dependency_bump(paths: list[str]) -> RuleResult | None:
44
+ if all(Path(p).name in DEPENDENCY_MANIFESTS for p in paths):
45
+ return RuleResult(
46
+ matched=True, message="chore: bump dependencies", rule_name="dependency_bump"
47
+ )
48
+ return None
49
+
50
+
51
+ def _docs_only(paths: list[str]) -> RuleResult | None:
52
+ doc_exts = {".md", ".rst", ".txt"}
53
+ doc_dirs = {"docs", "doc", "documentation"}
54
+ if all(
55
+ Path(p).suffix.lower() in doc_exts or Path(p).parts[0].lower() in doc_dirs
56
+ for p in paths
57
+ ):
58
+ return RuleResult(matched=True, message="docs: update documentation", rule_name="docs_only")
59
+ return None
60
+
61
+
62
+ def _single_test_file(paths: list[str]) -> RuleResult | None:
63
+ if len(paths) == 1 and "test" in paths[0].lower():
64
+ name = Path(paths[0]).stem
65
+ return RuleResult(
66
+ matched=True, message=f"test: add tests for {name}", rule_name="single_test_file"
67
+ )
68
+ return None
69
+
70
+
71
+ def _config_only(paths: list[str]) -> RuleResult | None:
72
+ def is_config(p: str) -> bool:
73
+ parts = Path(p).parts
74
+ return (
75
+ Path(p).suffix.lower() in CONFIG_PATTERNS
76
+ and (
77
+ parts[0] in CONFIG_DIRS
78
+ or not any("src" in part or "lib" in part for part in parts)
79
+ )
80
+ )
81
+ if paths and all(is_config(p) for p in paths):
82
+ return RuleResult(matched=True, message="chore: update config", rule_name="config_only")
83
+ return None
84
+
85
+
86
+ _RULES = [
87
+ _readme_only, _lockfile_only, _dependency_bump, _docs_only, _single_test_file, _config_only
88
+ ]
89
+
90
+
91
+ def apply_rules(filtered: FilteredDiff) -> RuleResult:
92
+ if not filtered.files:
93
+ return RuleResult(matched=False, message=None, rule_name=None)
94
+
95
+ paths = _all_paths(filtered)
96
+ for rule in _RULES:
97
+ result = rule(paths)
98
+ if result is not None:
99
+ return result
100
+
101
+ return RuleResult(matched=False, message=None, rule_name=None)
commitcraft/cli.py ADDED
@@ -0,0 +1,172 @@
1
+ from pathlib import Path
2
+
3
+ import typer
4
+ from rich.console import Console
5
+
6
+ app = typer.Typer(
7
+ name="commitcraft",
8
+ help="Smart conventional commit messages — rule engine first, LLM only when needed.",
9
+ no_args_is_help=True,
10
+ )
11
+ console = Console()
12
+
13
+
14
+ @app.command()
15
+ def init():
16
+ """First-time setup: choose provider, store config."""
17
+ from commitcraft.config.wizard import run_wizard
18
+ run_wizard()
19
+
20
+
21
+ @app.command()
22
+ def commit(no_stage: bool = typer.Option(False, "--no-stage", help="Skip git add .")):
23
+ """Analyze staged diff and generate a conventional commit message."""
24
+ import subprocess
25
+
26
+ from rich.panel import Panel
27
+ from rich.prompt import Prompt
28
+
29
+ from commitcraft.config.store import load_config
30
+ from commitcraft.generators.commit import generate_commit_message
31
+ from commitcraft.git.diff_parser import get_staged_diff
32
+ from commitcraft.utils.token_estimator import estimate_tokens
33
+
34
+ cfg = load_config()
35
+
36
+ if not no_stage:
37
+ console.print("[dim]Running git add .[/dim]")
38
+ subprocess.run(["git", "add", "."], check=True)
39
+
40
+ console.print("[dim]Analyzing staged changes...[/dim]")
41
+ staged_diff = get_staged_diff()
42
+
43
+ if not staged_diff.strip():
44
+ console.print("[yellow]No staged changes found.[/yellow]")
45
+ raise typer.Exit(1)
46
+
47
+ message, source = generate_commit_message(staged_diff, cfg)
48
+
49
+ if not message:
50
+ console.print("[red]Could not generate a commit message.[/red]")
51
+ raise typer.Exit(1)
52
+
53
+ source_label = f"[dim](source: {source})[/dim]"
54
+ if cfg.show_cost and source not in ("rule_engine", "none"):
55
+ from commitcraft.analysis.filters import filter_diff
56
+ from commitcraft.context.builder import build_commit_context
57
+ from commitcraft.git.diff_parser import parse_diff
58
+ parsed = parse_diff(staged_diff)
59
+ filtered = filter_diff(parsed, extra_patterns=cfg.ignore_patterns)
60
+ ctx = build_commit_context(filtered, [])
61
+ tokens = estimate_tokens(ctx)
62
+ source_label += f" [dim](~{tokens} tokens)[/dim]"
63
+
64
+ console.print(Panel(f"[bold green]{message}[/bold green]", title="Suggested commit message"))
65
+ console.print(source_label)
66
+
67
+ choice = Prompt.ask("Accept?", choices=["y", "n", "e"], default="y")
68
+
69
+ if choice == "n":
70
+ console.print("[yellow]Cancelled.[/yellow]")
71
+ raise typer.Exit(0)
72
+
73
+ if choice == "e":
74
+ import os
75
+ import tempfile
76
+ editor = os.environ.get("EDITOR", "vi")
77
+ with tempfile.NamedTemporaryFile(suffix=".txt", mode="w", delete=False) as tmp:
78
+ tmp.write(message)
79
+ tmp_path = tmp.name
80
+ subprocess.run([editor, tmp_path])
81
+ message = Path(tmp_path).read_text().strip()
82
+ os.unlink(tmp_path)
83
+
84
+ subprocess.run(["git", "commit", "-m", message], check=True)
85
+ console.print(f"[green]Committed: [bold]{message}[/bold][/green]")
86
+
87
+
88
+ @app.command()
89
+ def pr(base: str = typer.Option("main", "--base", "-b", help="Base branch to diff against")):
90
+ """Generate a PR description from the current branch diff."""
91
+ from rich.panel import Panel
92
+
93
+ from commitcraft.config.store import load_config
94
+ from commitcraft.generators.pr import generate_pr_description
95
+
96
+ cfg = load_config()
97
+ console.print("[dim]Generating PR description...[/dim]")
98
+ description = generate_pr_description(cfg, base=base)
99
+ console.print(Panel(description, title="PR Description"))
100
+ console.print("\n[dim]Copy the above into your PR description.[/dim]")
101
+
102
+
103
+ @app.command("release-notes")
104
+ def release_notes(tag_range: str = typer.Argument(..., help="e.g. v1.0.0..v1.1.0")):
105
+ """Categorize and summarize changes between two tags."""
106
+ from rich.panel import Panel
107
+
108
+ from commitcraft.config.store import load_config
109
+ from commitcraft.generators.release_notes import generate_release_notes
110
+
111
+ cfg = load_config()
112
+ console.print(f"[dim]Generating release notes for {tag_range}...[/dim]")
113
+ notes = generate_release_notes(tag_range, cfg)
114
+ console.print(Panel(notes, title=f"Release Notes: {tag_range}"))
115
+
116
+
117
+ @app.command()
118
+ def history():
119
+ """Analyze commit history for patterns and generic messages."""
120
+ from rich.table import Table
121
+
122
+ from commitcraft.git.history import (
123
+ analyze_commit_patterns,
124
+ detect_generic_commits,
125
+ get_all_commits,
126
+ )
127
+
128
+ commits = get_all_commits(n=100)
129
+ if not commits:
130
+ console.print("[yellow]No commits found.[/yellow]")
131
+ raise typer.Exit(0)
132
+
133
+ generic = detect_generic_commits(commits)
134
+ patterns = analyze_commit_patterns(commits)
135
+
136
+ console.print(f"\n[bold]Analyzed {len(commits)} commits[/bold]\n")
137
+
138
+ table = Table(title="Commit Type Distribution")
139
+ table.add_column("Type", style="cyan")
140
+ table.add_column("Count", justify="right")
141
+ for ctype, count in sorted(patterns.items(), key=lambda x: -x[1]):
142
+ table.add_row(ctype, str(count))
143
+ console.print(table)
144
+
145
+ if generic:
146
+ console.print(f"\n[yellow]Found {len(generic)} generic commit message(s):[/yellow]")
147
+ for g in generic:
148
+ console.print(f" [red]•[/red] {g}")
149
+ console.print("\n[dim]Consider using commitcraft to write better commit messages.[/dim]")
150
+ else:
151
+ console.print("\n[green]✓[/green] No generic commit messages found. Great commit hygiene!")
152
+
153
+
154
+ @app.command()
155
+ def config():
156
+ """Show or change current settings."""
157
+ from rich.table import Table
158
+
159
+ from commitcraft.config.store import config_path, load_config
160
+ cfg = load_config()
161
+ table = Table(title="commitcraft config", show_header=True)
162
+ table.add_column("Setting", style="cyan")
163
+ table.add_column("Value")
164
+ for key, value in cfg.model_dump().items():
165
+ display = "***" if "api_key" in key and value else str(value)
166
+ table.add_row(key, display)
167
+ console.print(table)
168
+ console.print(f"\n[dim]Config file: {config_path()}[/dim]")
169
+
170
+
171
+ if __name__ == "__main__":
172
+ app()
File without changes
@@ -0,0 +1,25 @@
1
+ from enum import Enum
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class ProviderName(str, Enum):
7
+ OLLAMA = "ollama"
8
+ OPENAI = "openai"
9
+ ANTHROPIC = "anthropic"
10
+ GEMINI = "gemini"
11
+
12
+
13
+ class CommitcraftConfig(BaseModel):
14
+ provider: ProviderName = ProviderName.OLLAMA
15
+ ollama_model: str = "llama3.2"
16
+ ollama_base_url: str = "http://localhost:11434"
17
+ openai_api_key: str | None = None
18
+ openai_model: str = "gpt-4o-mini"
19
+ anthropic_api_key: str | None = None
20
+ anthropic_model: str = "claude-haiku-4-5-20251001"
21
+ gemini_api_key: str | None = None
22
+ gemini_model: str = "gemini-1.5-flash"
23
+ complexity_threshold: int = Field(default=30, ge=0, le=100)
24
+ ignore_patterns: list[str] = Field(default_factory=list)
25
+ show_cost: bool = False
@@ -0,0 +1,48 @@
1
+ from pathlib import Path
2
+
3
+ import yaml
4
+
5
+ from commitcraft.config.models import CommitcraftConfig, ProviderName
6
+ from commitcraft.providers.base import Provider
7
+
8
+ CONFIG_DIR = Path.home() / ".commitcraft"
9
+ _CONFIG_FILE = "config.yaml"
10
+
11
+
12
+ def config_path() -> Path:
13
+ return CONFIG_DIR / _CONFIG_FILE
14
+
15
+
16
+ def load_config() -> CommitcraftConfig:
17
+ path = config_path()
18
+ if not path.exists():
19
+ return CommitcraftConfig()
20
+ with path.open() as f:
21
+ data = yaml.safe_load(f) or {}
22
+ return CommitcraftConfig(**data)
23
+
24
+
25
+ def save_config(config: CommitcraftConfig) -> None:
26
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
27
+ path = config_path()
28
+ with path.open("w") as f:
29
+ yaml.safe_dump(config.model_dump(mode="json"), f, default_flow_style=False)
30
+
31
+
32
+ def get_provider(config: CommitcraftConfig) -> Provider:
33
+ from commitcraft.providers.anthropic_provider import AnthropicProvider
34
+ from commitcraft.providers.gemini_provider import GeminiProvider
35
+ from commitcraft.providers.ollama_provider import OllamaProvider
36
+ from commitcraft.providers.openai_provider import OpenAIProvider
37
+
38
+ match config.provider:
39
+ case ProviderName.OLLAMA:
40
+ return OllamaProvider(config)
41
+ case ProviderName.ANTHROPIC:
42
+ return AnthropicProvider(config)
43
+ case ProviderName.OPENAI:
44
+ return OpenAIProvider(config)
45
+ case ProviderName.GEMINI:
46
+ return GeminiProvider(config)
47
+ case _:
48
+ raise ValueError(f"Unknown provider: {config.provider}")
@@ -0,0 +1,44 @@
1
+ from rich.console import Console
2
+ from rich.prompt import Prompt
3
+
4
+ from commitcraft.config.models import CommitcraftConfig, ProviderName
5
+ from commitcraft.config.store import save_config
6
+
7
+ console = Console()
8
+
9
+ _PROVIDER_CHOICES = {
10
+ "1": (ProviderName.OLLAMA, "Ollama (free, runs locally — requires Ollama installed)"),
11
+ "2": (ProviderName.OPENAI, "OpenAI (requires your own API key)"),
12
+ "3": (ProviderName.GEMINI, "Google Gemini (requires your own API key)"),
13
+ "4": (ProviderName.ANTHROPIC, "Anthropic Claude (requires your own API key)"),
14
+ }
15
+
16
+
17
+ def run_wizard() -> CommitcraftConfig:
18
+ console.print("\n[bold cyan]Welcome to commitcraft![/bold cyan]\n")
19
+ console.print("Which provider would you like to use?\n")
20
+
21
+ for key, (_, label) in _PROVIDER_CHOICES.items():
22
+ console.print(f" [bold]{key})[/bold] {label}")
23
+
24
+ choice = Prompt.ask("\nEnter choice", choices=list(_PROVIDER_CHOICES.keys()), default="1")
25
+ provider, _ = _PROVIDER_CHOICES[choice]
26
+
27
+ cfg = CommitcraftConfig(provider=provider)
28
+
29
+ if provider == ProviderName.OPENAI:
30
+ cfg.openai_api_key = Prompt.ask("Enter your OpenAI API key", password=True)
31
+ cfg.openai_model = Prompt.ask("Model", default="gpt-4o-mini")
32
+ elif provider == ProviderName.ANTHROPIC:
33
+ cfg.anthropic_api_key = Prompt.ask("Enter your Anthropic API key", password=True)
34
+ cfg.anthropic_model = Prompt.ask("Model", default="claude-haiku-4-5-20251001")
35
+ elif provider == ProviderName.GEMINI:
36
+ cfg.gemini_api_key = Prompt.ask("Enter your Gemini API key", password=True)
37
+ cfg.gemini_model = Prompt.ask("Model", default="gemini-1.5-flash")
38
+ elif provider == ProviderName.OLLAMA:
39
+ cfg.ollama_base_url = Prompt.ask("Ollama base URL", default="http://localhost:11434")
40
+ cfg.ollama_model = Prompt.ask("Model", default="llama3.2")
41
+
42
+ save_config(cfg)
43
+ console.print(f"\n[green]✓[/green] Config saved. Provider: [bold]{provider.value}[/bold]")
44
+ return cfg
File without changes
@@ -0,0 +1,59 @@
1
+ from commitcraft.analysis.filters import FilteredDiff
2
+
3
+
4
+ def build_commit_context(filtered: FilteredDiff, recent_commits: list[str]) -> str:
5
+ lines: list[str] = []
6
+
7
+ lines.append("=== Changed Files ===")
8
+ for f in filtered.files:
9
+ markers = []
10
+ if f.is_new:
11
+ markers.append("NEW")
12
+ if f.is_deleted:
13
+ markers.append("DELETED")
14
+ if f.has_function_changes:
15
+ markers.append("has function/def changes")
16
+ if f.has_class_changes:
17
+ markers.append("has class changes")
18
+ marker_str = f" [{', '.join(markers)}]" if markers else ""
19
+ lines.append(f" {f.path} (+{f.added_lines}/-{f.removed_lines}){marker_str}")
20
+
21
+ lines.append(
22
+ f"\nTotal: +{filtered.total_added}/-{filtered.total_removed}"
23
+ f" lines across {len(filtered.files)} file(s)"
24
+ )
25
+
26
+ if recent_commits:
27
+ lines.append("\n=== Recent Commit Style (follow this format) ===")
28
+ for c in recent_commits[:5]:
29
+ lines.append(f" {c}")
30
+
31
+ lines.append("\n=== Task ===")
32
+ lines.append("Write a single conventional commit message for the changes above.")
33
+ lines.append("Output ONLY the commit message. No explanation.")
34
+
35
+ return "\n".join(lines)
36
+
37
+
38
+ def build_pr_context(branch_diff: str, branch_name: str, recent_commits: list[str]) -> str:
39
+ lines: list[str] = []
40
+ lines.append(f"Branch: {branch_name}")
41
+ lines.append("\nRecent commits on this branch:")
42
+ for c in recent_commits[:10]:
43
+ lines.append(f" {c}")
44
+ lines.append("\n=== Task ===")
45
+ lines.append(
46
+ "Write a GitHub PR description for this branch."
47
+ " Use markdown with ## Summary and ## Changes sections."
48
+ )
49
+ return "\n".join(lines)
50
+
51
+
52
+ def build_release_context(tag_diff: str, tag_range: str) -> str:
53
+ lines: list[str] = [
54
+ f"Release range: {tag_range}",
55
+ "\n=== Task ===",
56
+ "Write structured release notes grouped by: Features, Bug Fixes, Documentation, Other."
57
+ " Use markdown.",
58
+ ]
59
+ return "\n".join(lines)
File without changes
@@ -0,0 +1,33 @@
1
+ from commitcraft.analysis.classifier import classify
2
+ from commitcraft.analysis.filters import filter_diff
3
+ from commitcraft.analysis.rule_engine import apply_rules
4
+ from commitcraft.config.models import CommitcraftConfig
5
+ from commitcraft.config.store import get_provider
6
+ from commitcraft.context.builder import build_commit_context
7
+ from commitcraft.git.diff_parser import parse_diff
8
+ from commitcraft.git.history import get_recent_commits
9
+
10
+
11
+ def generate_commit_message(staged_diff: str, config: CommitcraftConfig) -> tuple[str, str]:
12
+ if not staged_diff.strip():
13
+ return "", "none"
14
+
15
+ parsed = parse_diff(staged_diff)
16
+ filtered = filter_diff(parsed, extra_patterns=config.ignore_patterns)
17
+
18
+ if not filtered.files:
19
+ return "chore: update generated/dependency files", "rule_engine"
20
+
21
+ rule_result = apply_rules(filtered)
22
+ if rule_result.matched:
23
+ return rule_result.message, "rule_engine"
24
+
25
+ classification = classify(filtered, threshold=config.complexity_threshold)
26
+ if classification.is_simple:
27
+ return "chore: minor changes", "rule_engine"
28
+
29
+ recent = get_recent_commits(n=5)
30
+ context = build_commit_context(filtered, recent)
31
+ provider = get_provider(config)
32
+ message = provider.generate_commit_message(context)
33
+ return message, provider.name
@@ -0,0 +1,12 @@
1
+ from commitcraft.config.models import CommitcraftConfig
2
+ from commitcraft.config.store import get_provider
3
+ from commitcraft.context.builder import build_pr_context
4
+ from commitcraft.git.history import get_branch_commits, get_current_branch
5
+
6
+
7
+ def generate_pr_description(config: CommitcraftConfig, base: str = "main") -> str:
8
+ branch = get_current_branch()
9
+ commits = get_branch_commits(base=base)
10
+ context = build_pr_context("", branch, commits)
11
+ provider = get_provider(config)
12
+ return provider.generate_pr_description(context)
@@ -0,0 +1,26 @@
1
+ import subprocess
2
+
3
+ from commitcraft.config.models import CommitcraftConfig
4
+ from commitcraft.config.store import get_provider
5
+ from commitcraft.context.builder import build_release_context
6
+ from commitcraft.git.diff_parser import get_tag_diff
7
+
8
+
9
+ def _get_commits_in_range(tag_range: str) -> list[str]:
10
+ try:
11
+ result = subprocess.run(
12
+ ["git", "log", tag_range, "--pretty=format:%s"],
13
+ capture_output=True, text=True, check=True,
14
+ )
15
+ return [line.strip() for line in result.stdout.splitlines() if line.strip()]
16
+ except subprocess.CalledProcessError:
17
+ return []
18
+
19
+
20
+ def generate_release_notes(tag_range: str, config: CommitcraftConfig) -> str:
21
+ commits = _get_commits_in_range(tag_range)
22
+ tag_diff = get_tag_diff(tag_range)
23
+ context = build_release_context(tag_diff, tag_range)
24
+ context += "\n\n=== Commits in range ===\n" + "\n".join(f" {c}" for c in commits)
25
+ provider = get_provider(config)
26
+ return provider.generate_release_notes(context)
File without changes
@@ -0,0 +1,132 @@
1
+ import re
2
+ import subprocess
3
+ from dataclasses import dataclass, field
4
+
5
+ FUNCTION_PATTERNS = [
6
+ re.compile(r"^\+\s*(def |async def )\w+"), # Python
7
+ re.compile(r"^\+\s*(function |const \w+ = |let \w+ = |var \w+ = )\w*\s*[\(\{]"), # JS/TS
8
+ re.compile(r"^\+\s*(public|private|protected|static)?\s+\w+\s+\w+\s*\("), # Java/C#
9
+ re.compile(r"^\+\s*func \w+"), # Go
10
+ ]
11
+
12
+ CLASS_PATTERNS = [
13
+ re.compile(r"^\+\s*class \w+"), # Python/JS/TS
14
+ re.compile(r"^\+\s*(public|private)?\s*class \w+"), # Java/C#
15
+ re.compile(r"^\+\s*type \w+ struct"), # Go
16
+ ]
17
+
18
+ EXTENSION_TO_TYPE: dict[str, str] = {
19
+ ".py": "python", ".js": "javascript", ".ts": "typescript",
20
+ ".tsx": "typescript", ".jsx": "javascript", ".go": "go",
21
+ ".rs": "rust", ".java": "java", ".cs": "csharp", ".rb": "ruby",
22
+ ".php": "php", ".cpp": "cpp", ".c": "c", ".h": "c",
23
+ ".md": "markdown", ".json": "json", ".yaml": "yaml", ".yml": "yaml",
24
+ ".toml": "toml", ".sh": "shell", ".bash": "shell",
25
+ ".html": "html", ".css": "css", ".scss": "css",
26
+ ".txt": "text", ".lock": "lock",
27
+ }
28
+
29
+
30
+ @dataclass
31
+ class DiffFile:
32
+ path: str
33
+ file_type: str
34
+ is_new: bool
35
+ is_deleted: bool
36
+ added_lines: int
37
+ removed_lines: int
38
+ raw_hunks: str
39
+ has_function_changes: bool
40
+ has_class_changes: bool
41
+
42
+
43
+ @dataclass
44
+ class ParsedDiff:
45
+ files: list[DiffFile] = field(default_factory=list)
46
+ total_added: int = 0
47
+ total_removed: int = 0
48
+
49
+
50
+ def _file_type(path: str) -> str:
51
+ from pathlib import Path
52
+ suffix = Path(path).suffix.lower()
53
+ return EXTENSION_TO_TYPE.get(suffix, "unknown")
54
+
55
+
56
+ def _detect_changes(hunk_text: str) -> tuple[bool, bool]:
57
+ has_func = any(p.search(line) for line in hunk_text.splitlines() for p in FUNCTION_PATTERNS)
58
+ has_class = any(p.search(line) for line in hunk_text.splitlines() for p in CLASS_PATTERNS)
59
+ return has_func, has_class
60
+
61
+
62
+ def parse_diff(raw: str) -> ParsedDiff:
63
+ if not raw.strip():
64
+ return ParsedDiff()
65
+
66
+ result = ParsedDiff()
67
+ file_blocks = re.split(r"(?=^diff --git )", raw, flags=re.MULTILINE)
68
+
69
+ for block in file_blocks:
70
+ if not block.startswith("diff --git "):
71
+ continue
72
+
73
+ header_match = re.match(r"diff --git a/(.+?) b/(.+)", block)
74
+ if not header_match:
75
+ continue
76
+
77
+ path = header_match.group(2)
78
+ is_new = bool(
79
+ re.search(r"^new file mode", block, re.MULTILINE)
80
+ or re.search(r"^--- /dev/null", block, re.MULTILINE)
81
+ )
82
+ is_deleted = bool(re.search(r"^deleted file mode", block, re.MULTILINE))
83
+
84
+ added = len(re.findall(r"^\+(?!\+\+)", block, re.MULTILINE))
85
+ removed = len(re.findall(r"^-(?!--)", block, re.MULTILINE))
86
+
87
+ hunk_text = "\n".join(
88
+ line for line in block.splitlines()
89
+ if line.startswith("+") or line.startswith("-")
90
+ )
91
+ has_func, has_class = _detect_changes(hunk_text)
92
+
93
+ diff_file = DiffFile(
94
+ path=path,
95
+ file_type=_file_type(path),
96
+ is_new=is_new,
97
+ is_deleted=is_deleted,
98
+ added_lines=added,
99
+ removed_lines=removed,
100
+ raw_hunks=block,
101
+ has_function_changes=has_func,
102
+ has_class_changes=has_class,
103
+ )
104
+ result.files.append(diff_file)
105
+ result.total_added += added
106
+ result.total_removed += removed
107
+
108
+ return result
109
+
110
+
111
+ def get_staged_diff() -> str:
112
+ result = subprocess.run(
113
+ ["git", "diff", "--cached"],
114
+ capture_output=True, text=True, check=True,
115
+ )
116
+ return result.stdout
117
+
118
+
119
+ def get_branch_diff(base: str = "main") -> str:
120
+ result = subprocess.run(
121
+ ["git", "diff", f"{base}...HEAD"],
122
+ capture_output=True, text=True, check=True,
123
+ )
124
+ return result.stdout
125
+
126
+
127
+ def get_tag_diff(tag_range: str) -> str:
128
+ result = subprocess.run(
129
+ ["git", "diff", tag_range],
130
+ capture_output=True, text=True, check=True,
131
+ )
132
+ return result.stdout
@@ -0,0 +1,73 @@
1
+ import re
2
+ import subprocess
3
+ from collections import Counter
4
+
5
+
6
+ def get_recent_commits(n: int = 10) -> list[str]:
7
+ try:
8
+ result = subprocess.run(
9
+ ["git", "log", f"--max-count={n}", "--pretty=format:%s"],
10
+ capture_output=True, text=True, check=True,
11
+ )
12
+ return [line.strip() for line in result.stdout.splitlines() if line.strip()]
13
+ except subprocess.CalledProcessError:
14
+ return []
15
+
16
+
17
+ def get_branch_commits(base: str = "main", n: int = 20) -> list[str]:
18
+ try:
19
+ result = subprocess.run(
20
+ ["git", "log", f"{base}...HEAD", f"--max-count={n}", "--pretty=format:%s"],
21
+ capture_output=True, text=True, check=True,
22
+ )
23
+ return [line.strip() for line in result.stdout.splitlines() if line.strip()]
24
+ except subprocess.CalledProcessError:
25
+ return []
26
+
27
+
28
+ def get_current_branch() -> str:
29
+ try:
30
+ result = subprocess.run(
31
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
32
+ capture_output=True, text=True, check=True,
33
+ )
34
+ return result.stdout.strip()
35
+ except subprocess.CalledProcessError:
36
+ return "HEAD"
37
+
38
+
39
+ def analyze_commit_patterns(commits: list[str]) -> dict[str, int]:
40
+ types = []
41
+ for msg in commits:
42
+ m = re.match(r"^(\w+)[\(:]", msg)
43
+ if m:
44
+ types.append(m.group(1))
45
+ return dict(Counter(types))
46
+
47
+
48
+ _GENERIC_PATTERNS = [
49
+ re.compile(r"^initial commit$", re.IGNORECASE),
50
+ re.compile(r"^wip$", re.IGNORECASE),
51
+ re.compile(r"^fix$", re.IGNORECASE),
52
+ re.compile(r"^update$", re.IGNORECASE),
53
+ re.compile(r"^changes?$", re.IGNORECASE),
54
+ re.compile(r"^misc$", re.IGNORECASE),
55
+ re.compile(r"^temp$", re.IGNORECASE),
56
+ re.compile(r"^\.+$"),
57
+ re.compile(r"^commit \d+$", re.IGNORECASE),
58
+ ]
59
+
60
+
61
+ def detect_generic_commits(commits: list[str]) -> list[str]:
62
+ return [c for c in commits if any(p.match(c.strip()) for p in _GENERIC_PATTERNS)]
63
+
64
+
65
+ def get_all_commits(n: int = 100) -> list[str]:
66
+ try:
67
+ result = subprocess.run(
68
+ ["git", "log", f"--max-count={n}", "--pretty=format:%s"],
69
+ capture_output=True, text=True, check=True,
70
+ )
71
+ return [line.strip() for line in result.stdout.splitlines() if line.strip()]
72
+ except subprocess.CalledProcessError:
73
+ return []
File without changes
@@ -0,0 +1,44 @@
1
+ import anthropic
2
+
3
+ from commitcraft.config.models import CommitcraftConfig
4
+ from commitcraft.providers.base import Provider
5
+ from commitcraft.providers.ollama_provider import _COMMIT_SYSTEM, _PR_SYSTEM, _RELEASE_SYSTEM
6
+
7
+
8
+ class AnthropicProvider(Provider):
9
+ def __init__(self, config: CommitcraftConfig) -> None:
10
+ self._config = config
11
+ self._client = anthropic.Anthropic(api_key=config.anthropic_api_key)
12
+ self._model = config.anthropic_model
13
+
14
+ @property
15
+ def name(self) -> str:
16
+ return "anthropic"
17
+
18
+ def _generate(self, system: str, prompt: str) -> str:
19
+ message = self._client.messages.create(
20
+ model=self._model,
21
+ max_tokens=512,
22
+ system=system,
23
+ messages=[{"role": "user", "content": prompt}],
24
+ )
25
+ return message.content[0].text.strip()
26
+
27
+ def generate_commit_message(self, context: str) -> str:
28
+ return self._generate(_COMMIT_SYSTEM, context)
29
+
30
+ def generate_pr_description(self, context: str) -> str:
31
+ return self._generate(_PR_SYSTEM, context)
32
+
33
+ def generate_release_notes(self, context: str) -> str:
34
+ return self._generate(_RELEASE_SYSTEM, context)
35
+
36
+ def health_check(self) -> bool:
37
+ try:
38
+ self._client.messages.create(
39
+ model=self._model, max_tokens=1,
40
+ messages=[{"role": "user", "content": "ping"}],
41
+ )
42
+ return True
43
+ except Exception:
44
+ return False
@@ -0,0 +1,19 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+
4
+ class Provider(ABC):
5
+ @property
6
+ @abstractmethod
7
+ def name(self) -> str: ...
8
+
9
+ @abstractmethod
10
+ def generate_commit_message(self, context: str) -> str: ...
11
+
12
+ @abstractmethod
13
+ def generate_pr_description(self, context: str) -> str: ...
14
+
15
+ @abstractmethod
16
+ def generate_release_notes(self, context: str) -> str: ...
17
+
18
+ @abstractmethod
19
+ def health_check(self) -> bool: ...
@@ -0,0 +1,40 @@
1
+ import google.generativeai as genai
2
+
3
+ from commitcraft.config.models import CommitcraftConfig
4
+ from commitcraft.providers.base import Provider
5
+ from commitcraft.providers.ollama_provider import _COMMIT_SYSTEM, _PR_SYSTEM, _RELEASE_SYSTEM
6
+
7
+
8
+ class GeminiProvider(Provider):
9
+ def __init__(self, config: CommitcraftConfig) -> None:
10
+ self._config = config
11
+ genai.configure(api_key=config.gemini_api_key)
12
+ self._model_name = config.gemini_model
13
+
14
+ @property
15
+ def name(self) -> str:
16
+ return "gemini"
17
+
18
+ def _generate(self, system: str, prompt: str) -> str:
19
+ model = genai.GenerativeModel(
20
+ model_name=self._model_name,
21
+ system_instruction=system,
22
+ )
23
+ response = model.generate_content(prompt)
24
+ return response.text.strip()
25
+
26
+ def generate_commit_message(self, context: str) -> str:
27
+ return self._generate(_COMMIT_SYSTEM, context)
28
+
29
+ def generate_pr_description(self, context: str) -> str:
30
+ return self._generate(_PR_SYSTEM, context)
31
+
32
+ def generate_release_notes(self, context: str) -> str:
33
+ return self._generate(_RELEASE_SYSTEM, context)
34
+
35
+ def health_check(self) -> bool:
36
+ try:
37
+ genai.list_models()
38
+ return True
39
+ except Exception:
40
+ return False
@@ -0,0 +1,61 @@
1
+ import httpx
2
+
3
+ from commitcraft.config.models import CommitcraftConfig
4
+ from commitcraft.providers.base import Provider
5
+
6
+ _COMMIT_SYSTEM = (
7
+ "You are an expert at writing conventional commit messages. "
8
+ "Given a summary of code changes, write a single conventional commit message. "
9
+ "Format: <type>(<optional scope>): <description>. "
10
+ "Types: feat, fix, docs, style, refactor, test, chore, perf, ci, build. "
11
+ "Output ONLY the commit message — no explanation, no markdown, no quotes."
12
+ )
13
+
14
+ _PR_SYSTEM = (
15
+ "You are an expert at writing GitHub pull request descriptions. "
16
+ "Given a summary of code changes, write a clear PR description in markdown with: "
17
+ "a '## Summary' section (3-5 bullet points) and a '## Changes' section. "
18
+ "Be concise and factual."
19
+ )
20
+
21
+ _RELEASE_SYSTEM = (
22
+ "You are an expert at writing software release notes. "
23
+ "Given a list of commits or change summaries, produce structured release notes "
24
+ "grouped by: Features, Bug Fixes, Documentation, Other. Use markdown."
25
+ )
26
+
27
+
28
+ class OllamaProvider(Provider):
29
+ def __init__(self, config: CommitcraftConfig) -> None:
30
+ self._config = config
31
+ self._base_url = config.ollama_base_url
32
+ self._model = config.ollama_model
33
+
34
+ @property
35
+ def name(self) -> str:
36
+ return "ollama"
37
+
38
+ def _generate(self, system: str, prompt: str) -> str:
39
+ response = httpx.post(
40
+ f"{self._base_url}/api/generate",
41
+ json={"model": self._model, "system": system, "prompt": prompt, "stream": False},
42
+ timeout=60.0,
43
+ )
44
+ response.raise_for_status()
45
+ return response.json()["response"].strip()
46
+
47
+ def generate_commit_message(self, context: str) -> str:
48
+ return self._generate(_COMMIT_SYSTEM, context)
49
+
50
+ def generate_pr_description(self, context: str) -> str:
51
+ return self._generate(_PR_SYSTEM, context)
52
+
53
+ def generate_release_notes(self, context: str) -> str:
54
+ return self._generate(_RELEASE_SYSTEM, context)
55
+
56
+ def health_check(self) -> bool:
57
+ try:
58
+ httpx.get(f"{self._base_url}/api/tags", timeout=5.0).raise_for_status()
59
+ return True
60
+ except Exception:
61
+ return False
@@ -0,0 +1,43 @@
1
+ from openai import OpenAI
2
+
3
+ from commitcraft.config.models import CommitcraftConfig
4
+ from commitcraft.providers.base import Provider
5
+ from commitcraft.providers.ollama_provider import _COMMIT_SYSTEM, _PR_SYSTEM, _RELEASE_SYSTEM
6
+
7
+
8
+ class OpenAIProvider(Provider):
9
+ def __init__(self, config: CommitcraftConfig) -> None:
10
+ self._config = config
11
+ self._client = OpenAI(api_key=config.openai_api_key)
12
+ self._model = config.openai_model
13
+
14
+ @property
15
+ def name(self) -> str:
16
+ return "openai"
17
+
18
+ def _generate(self, system: str, prompt: str) -> str:
19
+ response = self._client.chat.completions.create(
20
+ model=self._model,
21
+ messages=[
22
+ {"role": "system", "content": system},
23
+ {"role": "user", "content": prompt},
24
+ ],
25
+ max_tokens=512,
26
+ )
27
+ return (response.choices[0].message.content or "").strip()
28
+
29
+ def generate_commit_message(self, context: str) -> str:
30
+ return self._generate(_COMMIT_SYSTEM, context)
31
+
32
+ def generate_pr_description(self, context: str) -> str:
33
+ return self._generate(_PR_SYSTEM, context)
34
+
35
+ def generate_release_notes(self, context: str) -> str:
36
+ return self._generate(_RELEASE_SYSTEM, context)
37
+
38
+ def health_check(self) -> bool:
39
+ try:
40
+ self._client.models.list()
41
+ return True
42
+ except Exception:
43
+ return False
File without changes
@@ -0,0 +1,3 @@
1
+ def estimate_tokens(text: str) -> int:
2
+ # Rough approximation: 1 token ≈ 4 characters (widely used heuristic)
3
+ return max(1, len(text) // 4)
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.4
2
+ Name: commitcraft-cli
3
+ Version: 0.1.0
4
+ Summary: Smart conventional commit messages with a rule engine + minimal-context LLM
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: anthropic>=0.30.0
9
+ Requires-Dist: google-generativeai>=0.7.0
10
+ Requires-Dist: httpx>=0.27.0
11
+ Requires-Dist: openai>=1.0.0
12
+ Requires-Dist: pydantic>=2.0.0
13
+ Requires-Dist: pyyaml>=6.0.0
14
+ Requires-Dist: rich>=13.0.0
15
+ Requires-Dist: typer>=0.12.0
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest-cov>=5.0.0; extra == 'dev'
18
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
19
+ Requires-Dist: ruff>=0.4.0; extra == 'dev'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # commitcraft
23
+
24
+ > Smart conventional commit messages — a rule engine decides when AI is even needed.
25
+
26
+ [![CI](https://github.com/mavsensei/commitcraft/actions/workflows/ci.yml/badge.svg)](https://github.com/mavesensei/commitcraft/actions)
27
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
28
+
29
+ ## What makes it different
30
+
31
+ Most "AI commit message" tools blindly send your entire diff to an LLM. commitcraft doesn't.
32
+
33
+ It first runs a **local rule engine** on your diff. README-only change? Lockfile bump? Single test file? Those get a conventional commit message generated instantly, locally, with zero AI cost. Only when changes are genuinely complex does it reach out to an LLM — and even then it sends a condensed summary, not the full diff.
34
+
35
+ **The story isn't "I used AI." It's "I built a system that decides when AI should be used."**
36
+
37
+ ## Pipeline
38
+
39
+ ```
40
+ git diff (staged)
41
+
42
+
43
+ Parser → extract changed files, line counts, file types
44
+
45
+
46
+ Filter → strip lockfiles, dist/, build/, *.min.js, etc.
47
+
48
+
49
+ Rule Engine → simple change? generate commit locally (zero cost)
50
+ │ e.g. README-only → "docs: update README"
51
+ │ lockfile-only → "chore: update lockfile"
52
+
53
+ ▼ (complex change only)
54
+ Classifier → complexity score 0-100 from:
55
+ • file count and directory spread
56
+ • function/class definition changes (regex)
57
+ • test files alongside source files
58
+ • total line count
59
+
60
+
61
+ Context Builder → condensed summary (not the full diff)
62
+ │ ~100-500 tokens instead of thousands
63
+
64
+ Your chosen Provider (Ollama / OpenAI / Gemini / Anthropic)
65
+
66
+
67
+ Conventional commit message
68
+ ```
69
+
70
+ ## Installation
71
+
72
+ ```bash
73
+ pip install commitcraft
74
+ commitcraft init # one-time setup: choose your provider
75
+ ```
76
+
77
+ ## Quickstart
78
+
79
+ ```bash
80
+ # Make some changes, then:
81
+ commitcraft commit
82
+
83
+ # Generate a PR description for the current branch:
84
+ commitcraft pr
85
+
86
+ # Generate release notes between two tags:
87
+ commitcraft release-notes v1.0.0..v1.1.0
88
+
89
+ # Analyze your commit history:
90
+ commitcraft history
91
+ ```
92
+
93
+ ## Provider setup
94
+
95
+ On first run (`commitcraft init`), you choose your provider:
96
+
97
+ | Provider | Cost | Requires |
98
+ |---|---|---|
99
+ | **Ollama** | Free (local) | Ollama installed + a model pulled |
100
+ | **OpenAI** | Your API key | `openai_api_key` in config |
101
+ | **Anthropic Claude** | Your API key | `anthropic_api_key` in config |
102
+ | **Google Gemini** | Your API key | `gemini_api_key` in config |
103
+
104
+ Your API key is stored locally at `~/.commitcraft/config.yaml`. It never leaves your machine except to call your chosen provider directly.
105
+
106
+ ## Contributing
107
+
108
+ See [CONTRIBUTING.md](CONTRIBUTING.md) and [docs/adding_a_provider.md](docs/adding_a_provider.md).
109
+
110
+ ## License
111
+
112
+ MIT © mavesensei
113
+
@@ -0,0 +1,32 @@
1
+ commitcraft/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ commitcraft/cli.py,sha256=XksgFlnwMfARFuPrDyocxmXwg8hIt44oowGCy6qR1N0,5959
3
+ commitcraft/analysis/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ commitcraft/analysis/classifier.py,sha256=OBTsHbiYsa0_sKY2DtU2ZszvCmqnaD2Ypc_M1ozs9WQ,2062
5
+ commitcraft/analysis/filters.py,sha256=xBOnRlLqEehGpus36EAlegJXQiF7CBQWCAdtVAtgg9Y,1676
6
+ commitcraft/analysis/rule_engine.py,sha256=vW8N26iUjhK1ZcQSF2AxGuPaK23ZXUBWbPc-Wr_LrwY,3184
7
+ commitcraft/config/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ commitcraft/config/models.py,sha256=e9pIh7auLf4cbskmhcAEgGzKEXrDkXTdzlVlj4W5Iig,768
9
+ commitcraft/config/store.py,sha256=VeJPu6QHmmWxmdy2NGwMO9hJFleD1ABL388aJf4N-58,1533
10
+ commitcraft/config/wizard.py,sha256=misy4ajxQ5nHLisPdmXQi0SoXmOMkxS608RVUlKDco8,1981
11
+ commitcraft/context/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ commitcraft/context/builder.py,sha256=gJN_d5hwZ3GvP5rCQfU40qBVz6O41kG1R4DlprrwUFE,2043
13
+ commitcraft/generators/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ commitcraft/generators/commit.py,sha256=2i4vrCNRArvooOdXbw45MII3chLB7H_tmMt6QT7yJxw,1304
15
+ commitcraft/generators/pr.py,sha256=vz3opiaT9NaaZ-Iigfvo9SBI9Ad9LZ6Ab5Hr1tgut9U,542
16
+ commitcraft/generators/release_notes.py,sha256=-nPhJUeJveNz-wCl_JCjzNq7QZcf4yWrG5RdhZ9JX-M,1010
17
+ commitcraft/git/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
+ commitcraft/git/diff_parser.py,sha256=cw1gejJty7J2MsMGJPRLuGMvgoKa_M3XS0GrFeTCLAQ,4018
19
+ commitcraft/git/history.py,sha256=PPNv7lASAoiYwjAD3wIVCy_3cPZnYF2XS0TOW06itD0,2313
20
+ commitcraft/providers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
21
+ commitcraft/providers/anthropic_provider.py,sha256=rKg1e3TC2oqUuYpaw27nwmnFhXjKKKIt65tYJRSSLPE,1481
22
+ commitcraft/providers/base.py,sha256=Axnq_fd1SgvBvX2z0FAQyeKWfncfvd_mDWO4P4kQC9U,439
23
+ commitcraft/providers/gemini_provider.py,sha256=uELZK81MQzkNAsFdev5kxqCwWcrb66vAoNcCRAk2YMs,1302
24
+ commitcraft/providers/ollama_provider.py,sha256=hNzuqzXsUhXn3CCKzZWlX0goXMPPLP6dF1WxkjAT1aw,2216
25
+ commitcraft/providers/openai_provider.py,sha256=yLt4w3lhRwsmhiGu7ci19V6ZNU8aMzEisWMVY5hkAzU,1423
26
+ commitcraft/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
27
+ commitcraft/utils/token_estimator.py,sha256=VCL6Vl2sAnZFTlEQz5KeBXWAM9HTaOT3w93aFgU8KCU,149
28
+ commitcraft_cli-0.1.0.dist-info/METADATA,sha256=XuqbytEOM-oXYOfrmyU7t3F0CXwxPBv8HKyzcCB5_PE,3505
29
+ commitcraft_cli-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
30
+ commitcraft_cli-0.1.0.dist-info/entry_points.txt,sha256=b-w1XlpnrfOwWJ8P5YHtyWQjTeFZsFt8pIh23iKz8nY,52
31
+ commitcraft_cli-0.1.0.dist-info/licenses/LICENSE,sha256=HeWpEYKYnDAM0XJEdR6q2HU2XjfKW4VFkunJLLEv7yY,1067
32
+ commitcraft_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ commitcraft = commitcraft.cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mavesensei
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.