cmt-cli 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,13 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ # IDE files
13
+ .idea/
@@ -0,0 +1 @@
1
+ 3.12
cmt_cli-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bijay Das
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.
cmt_cli-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,21 @@
1
+ Metadata-Version: 2.5
2
+ Name: cmt-cli
3
+ Version: 0.1.0
4
+ Summary: AI-powered Git commit message generator
5
+ Author-email: Bijay Das <me@bijaydas.com>
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.12
9
+ Requires-Dist: langchain-openai>=1.6.0
10
+ Requires-Dist: langchain>=1.3.18
11
+ Requires-Dist: pydantic>=2.13.4
12
+ Requires-Dist: typer>=0.27.1
13
+ Description-Content-Type: text/markdown
14
+
15
+ # cmt
16
+
17
+ `cmt` is a CLI tool that checks the currently staged Git changes and suggests a professional commit message.
18
+
19
+ ## License
20
+
21
+ MIT
@@ -0,0 +1,7 @@
1
+ # cmt
2
+
3
+ `cmt` is a CLI tool that checks the currently staged Git changes and suggests a professional commit message.
4
+
5
+ ## License
6
+
7
+ MIT
@@ -0,0 +1,50 @@
1
+ [project]
2
+ name = "cmt-cli"
3
+ version = "0.1.0"
4
+ description = "AI-powered Git commit message generator"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = { text = "MIT" }
8
+ authors = [
9
+ { name = "Bijay Das", email = "me@bijaydas.com" },
10
+ ]
11
+ dependencies = [
12
+ "langchain>=1.3.18",
13
+ "langchain-openai>=1.6.0",
14
+ "pydantic>=2.13.4",
15
+ "typer>=0.27.1",
16
+ ]
17
+
18
+ [project.scripts]
19
+ cmt = "cmt.cli:app"
20
+
21
+ [build-system]
22
+ requires = ["hatchling"]
23
+ build-backend = "hatchling.build"
24
+ keywords = ["ai", "git", "commit", "message", "generator"]
25
+
26
+ [dependency-groups]
27
+ dev = [
28
+ "ruff>=0.16.5",
29
+ ]
30
+ [tool.ruff]
31
+ line-length = 100
32
+ target-version = "py312"
33
+ src = ["src"]
34
+
35
+ [tool.ruff.lint]
36
+ select = [
37
+ "E", # pycodestyle errors
38
+ "F", # pyflakes
39
+ "I", # isort (import sorting)
40
+ "UP", # pyupgrade (modern syntax)
41
+ "B", # flake8-bugbear (common bugs)
42
+ "SIM", # flake8-simplify
43
+ ]
44
+ ignore = []
45
+
46
+ [tool.ruff.format]
47
+ quote-style = "double"
48
+
49
+ [tool.hatch.build.targets.wheel]
50
+ include = ["src/cmt"]
File without changes
File without changes
@@ -0,0 +1,28 @@
1
+ import hashlib
2
+ import json
3
+ from pathlib import Path
4
+
5
+ from cmt.config.settings import Settings
6
+ from cmt.models.suggestion import CommitSuggestion
7
+
8
+
9
+ class CommitMessageCache:
10
+ def __init__(self):
11
+ self.cache_dir = Path(Settings().CACHE_DIR)
12
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
13
+
14
+ @staticmethod
15
+ def _key(diff: str, model: str) -> str:
16
+ return hashlib.sha256(f"{diff}-{model}".encode()).hexdigest()
17
+
18
+ def set(self, diff: str, model: str, commit: CommitSuggestion):
19
+ key = CommitMessageCache._key(diff, model)
20
+ cache_file = self.cache_dir / key
21
+ cache_file.write_text(commit.model_dump_json())
22
+
23
+ def get(self, diff: str, model: str) -> CommitSuggestion | None:
24
+ key = CommitMessageCache._key(diff, model)
25
+ cache_file = self.cache_dir / key
26
+ if cache_file.exists():
27
+ return CommitSuggestion(**json.loads(cache_file.read_text()))
28
+ return None
@@ -0,0 +1,86 @@
1
+ from langchain.agents import create_agent
2
+ from langchain_openai import ChatOpenAI
3
+ from pydantic import SecretStr
4
+
5
+ from cmt.ai.cache import CommitMessageCache
6
+ from cmt.ai.prompt import COMMIT_PROMPT, COMMIT_SYSTEM_PROMPT
7
+ from cmt.ai.provider import AIProvider
8
+ from cmt.config.settings import Settings
9
+ from cmt.models.changes import AnalysisResult, StagedChangeSet, StagedFile
10
+ from cmt.models.suggestion import CommitSuggestion
11
+
12
+
13
+ class OpenAIProvider(AIProvider):
14
+ def __init__(self) -> None:
15
+ settings = Settings()
16
+ self.config = settings.get()
17
+
18
+ def _build_prompt(
19
+ self,
20
+ changes: StagedChangeSet,
21
+ analysis: AnalysisResult
22
+ ) -> str:
23
+ staged_files = self._process_files(changes.files)
24
+ staged_diffs = changes.diff
25
+
26
+ return COMMIT_PROMPT.format_messages(
27
+ total_files=len(changes.files),
28
+ added_files=analysis.added_files,
29
+ modified_files=analysis.modified_files,
30
+ deleted_files=analysis.deleted_files,
31
+ renamed_files=analysis.renamed_files,
32
+ staged_files=staged_files,
33
+ staged_diffs=staged_diffs
34
+ )
35
+
36
+ @staticmethod
37
+ def _process_files(files: list[StagedFile]) -> str:
38
+ output = ""
39
+ for file in files:
40
+ output += f"{file.status} {file.path}\n"
41
+
42
+ return output.strip()
43
+
44
+ def _invoke(self, prompt: str) -> CommitSuggestion:
45
+ model = ChatOpenAI(
46
+ model=self.config.model,
47
+ api_key=SecretStr(self.config.api_key),
48
+ timeout=30,
49
+ max_tokens=1200,
50
+ )
51
+
52
+ agent = create_agent(
53
+ model=model,
54
+ system_prompt=COMMIT_SYSTEM_PROMPT,
55
+ response_format=CommitSuggestion,
56
+ )
57
+
58
+ result = agent.invoke({"messages": prompt})
59
+
60
+ return result["structured_response"]
61
+
62
+ def generate_commit_message(
63
+ self,
64
+ change_set: StagedChangeSet,
65
+ analysis: AnalysisResult
66
+ ) -> CommitSuggestion:
67
+ prompt = self._build_prompt(change_set, analysis)
68
+
69
+ cache = CommitMessageCache()
70
+ cached_message = cache.get(change_set.diff, self.config.model)
71
+
72
+ if cached_message:
73
+ return CommitSuggestion(
74
+ message=cached_message.message,
75
+ description=cached_message.description
76
+ )
77
+
78
+ suggestion = self._invoke(prompt)
79
+
80
+ cache.set(change_set.diff, self.config.model, suggestion)
81
+
82
+ return suggestion
83
+
84
+ @staticmethod
85
+ def commit_command(commit_suggestion: CommitSuggestion) -> str:
86
+ return f"{commit_suggestion.message} \n\n{commit_suggestion.description}"
@@ -0,0 +1,66 @@
1
+ from langchain_core.prompts import ChatPromptTemplate
2
+
3
+ COMMIT_SYSTEM_PROMPT = """You are an expert software engineer responsible for generating Git commit
4
+ messages.
5
+
6
+ Your task is to analyze the staged Git changes provided below and generate the most appropriate
7
+ commit message.
8
+
9
+ ## Rules
10
+
11
+ 1. Base the commit message ONLY on the provided staged changes.
12
+ 2. Do not invent functionality, behavior, or intent.
13
+ 3. Identify the primary purpose of the changes.
14
+ 4. If multiple files are changed, determine whether they represent one coherent change.
15
+ 5. If the changes are unrelated or miscellaneous, use an appropriate general commit type
16
+ such as `chore`.
17
+ 6. Follow the Conventional Commits specification.
18
+ 7. Use one of these commit types when appropriate:
19
+ - feat
20
+ - fix
21
+ - refactor
22
+ - docs
23
+ - test
24
+ - chore
25
+ - perf
26
+ - build
27
+ - ci
28
+ - style
29
+ 8. Add a scope only when the affected area is clear.
30
+ 9. Keep the commit message concise.
31
+ 10. Use imperative mood.
32
+ 11. Do not mention individual files unless necessary.
33
+ 12. Do not generate multiple commit messages.
34
+ 13. Do not include a commit body.
35
+ 14. Return ONLY the commit message.
36
+ 15. Do not include markdown, quotes, explanations, or additional text.
37
+
38
+ ## Commit message format
39
+
40
+ <type>[optional scope]: <short description>
41
+ """
42
+
43
+ COMMIT_DATA_PROMPT = """## Change statistics
44
+
45
+ Total files: {total_files}
46
+ Added: {added_files}
47
+ Modified: {modified_files}
48
+ Deleted: {deleted_files}
49
+ Renamed: {renamed_files}
50
+
51
+ ## Staged files
52
+
53
+ {staged_files}
54
+
55
+ ## Staged Git diff
56
+
57
+ {staged_diffs}
58
+
59
+ Generate the commit message now.
60
+ """
61
+
62
+ COMMIT_PROMPT = ChatPromptTemplate.from_messages(
63
+ [
64
+ ("human", COMMIT_DATA_PROMPT),
65
+ ]
66
+ )
@@ -0,0 +1,14 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+ from cmt.models.changes import AnalysisResult, StagedChangeSet
4
+ from cmt.models.suggestion import CommitSuggestion
5
+
6
+
7
+ class AIProvider(ABC):
8
+ @abstractmethod
9
+ def generate_commit_message(
10
+ self,
11
+ change_set: StagedChangeSet,
12
+ analysis: AnalysisResult
13
+ ) -> CommitSuggestion:
14
+ pass
File without changes
@@ -0,0 +1,29 @@
1
+ from cmt.models.changes import AnalysisResult, StagedChangeSet
2
+
3
+
4
+ class Analyzer:
5
+ def analyze(self, change_set: StagedChangeSet) -> AnalysisResult:
6
+ added = 0
7
+ modified = 0
8
+ deleted = 0
9
+ renamed = 0
10
+ total = 0
11
+
12
+ for file in change_set.files:
13
+ total += 1
14
+ if file.status == "A":
15
+ added += 1
16
+ elif file.status == "M":
17
+ modified += 1
18
+ elif file.status == "D":
19
+ deleted += 1
20
+ elif file.status == "R":
21
+ renamed += 1
22
+
23
+ return AnalysisResult(
24
+ total_files=total,
25
+ added_files=added,
26
+ modified_files=modified,
27
+ deleted_files=deleted,
28
+ renamed_files=renamed,
29
+ )
@@ -0,0 +1,85 @@
1
+ import typer
2
+
3
+ from cmt.ai.openai import OpenAIProvider
4
+ from cmt.analysis.analyzer import Analyzer
5
+ from cmt.config.settings import OpenAIConfig, Settings
6
+ from cmt.exceptions import CmtError
7
+ from cmt.git.repository import Repository
8
+ from cmt.utils import edit_with_vim
9
+
10
+ app = typer.Typer()
11
+
12
+
13
+ @app.command()
14
+ def suggest() -> None:
15
+ try:
16
+ repository = Repository()
17
+ analyzer = Analyzer()
18
+
19
+ if not repository.is_git_repository():
20
+ raise CmtError("Not a git repository.")
21
+
22
+ staged_files = repository.get_staged_changes()
23
+
24
+ if not staged_files.files:
25
+ typer.echo(
26
+ "No staged files found. Please stage your changes before running this command."
27
+ )
28
+ raise typer.Exit(code=0)
29
+
30
+ analysis_result = analyzer.analyze(staged_files)
31
+
32
+ open_ai = OpenAIProvider()
33
+
34
+ commit = open_ai.generate_commit_message(staged_files, analysis_result)
35
+ commit_command = OpenAIProvider.commit_command(commit)
36
+
37
+ typer.echo(f"Suggested commit:\n\n{commit_command}")
38
+
39
+ while True:
40
+ action = (
41
+ typer.prompt("\nUse this message? [y]es / [e]dit / [n]o", default="y")
42
+ .strip()
43
+ .lower()[0]
44
+ )
45
+
46
+ if action == "n":
47
+ typer.echo("Aborted.")
48
+ break
49
+
50
+ if action == "e":
51
+ commit_command = edit_with_vim(commit_command)
52
+ typer.echo(f"Edited commit:\n\n{commit_command}")
53
+
54
+ if action == "y":
55
+ result = repository.commit(commit_command)
56
+ typer.echo(f"Commit result:\n\n{result.stdout}")
57
+ break
58
+
59
+ except CmtError as e:
60
+ typer.echo(f"Error: {e}", err=True)
61
+ raise typer.Exit(code=1) from None
62
+ except typer.Exit:
63
+ raise
64
+ except KeyboardInterrupt:
65
+ typer.echo("\nOperation cancelled by user.", err=True)
66
+ raise typer.Exit(code=1) from None
67
+ except Exception as e:
68
+ typer.echo(f"Unexpected error: {e}", err=True)
69
+ raise typer.Exit(code=1) from None
70
+
71
+
72
+ @app.command()
73
+ def config(task: str) -> None:
74
+ if task == "set":
75
+ settings = Settings()
76
+
77
+ open_ai_key = typer.prompt("Enter your OpenAI API key")
78
+ open_ai_model = typer.prompt(
79
+ "Enter your OpenAI model", default=settings.OPEN_AI_DEFAULT_MODEL
80
+ )
81
+ settings.set(OpenAIConfig(api_key=open_ai_key, model=open_ai_model))
82
+
83
+
84
+ if __name__ == "__main__":
85
+ app()
File without changes
@@ -0,0 +1,58 @@
1
+ from configparser import ConfigParser
2
+ from pathlib import Path
3
+
4
+ from pydantic import BaseModel
5
+
6
+ from cmt.exceptions import ConfigurationError
7
+
8
+
9
+ class OpenAIConfig(BaseModel):
10
+ api_key: str
11
+ model: str
12
+
13
+
14
+ class Settings:
15
+ CONFIG_DIR: Path = Path.home() / ".config" / "cmt"
16
+ CACHE_DIR: Path = CONFIG_DIR / "cache"
17
+
18
+ CONFIG_FILE: Path = CONFIG_DIR / "config.ini"
19
+ OPEN_AI_DEFAULT_MODEL: str = "gpt-4o-mini"
20
+
21
+ def set(self, openai_config: OpenAIConfig):
22
+ self.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
23
+
24
+ config = ConfigParser()
25
+ config["openai"] = {
26
+ "api_key": openai_config.api_key,
27
+ "model": openai_config.model
28
+ }
29
+
30
+ with open(self.CONFIG_FILE, "w") as f:
31
+ config.write(f)
32
+
33
+ def get(self) -> OpenAIConfig:
34
+ if not self.CONFIG_FILE.exists():
35
+ raise FileNotFoundError(
36
+ "Configuration file not found. Run `cmt config set` to set it up."
37
+ )
38
+
39
+ config = ConfigParser()
40
+ config.read(self.CONFIG_FILE)
41
+
42
+ api_key = config["openai"].get("api_key")
43
+ model = config["openai"].get("model")
44
+
45
+ if not api_key:
46
+ raise ConfigurationError(
47
+ "API key not found in the configuration. Run `cmt config set` to set it up."
48
+ )
49
+
50
+ if not model:
51
+ raise ConfigurationError(
52
+ "Model not found in the configuration. Run `cmt config set` to set it up."
53
+ )
54
+
55
+ return OpenAIConfig(
56
+ api_key=api_key,
57
+ model=model
58
+ )
@@ -0,0 +1,14 @@
1
+ class CmtError(Exception):
2
+ """Base exception for expected cmt errors."""
3
+
4
+
5
+ class ConfigurationError(CmtError):
6
+ """Raised when cmt configuration is missing or invalid."""
7
+
8
+
9
+ class GitError(CmtError):
10
+ """Raised when a Git operation fails."""
11
+
12
+
13
+ class AIError(CmtError):
14
+ """Raised when AI processing fails."""
File without changes
@@ -0,0 +1,50 @@
1
+ import subprocess
2
+ from pathlib import Path
3
+
4
+ from cmt.models.changes import StagedChangeSet, StagedFile
5
+
6
+
7
+ class Repository:
8
+ def __init__(self, root: Path | None = None):
9
+ self.root = Path(root) if root is not None else Path.cwd()
10
+
11
+ def _execute(self, command: list[str]) -> subprocess.CompletedProcess:
12
+ return subprocess.run(
13
+ command,
14
+ cwd=self.root,
15
+ capture_output=True,
16
+ check=True,
17
+ text=True
18
+ )
19
+
20
+ def is_git_repository(self):
21
+ try:
22
+ result = self._execute(["git", "rev-parse", "--is-inside-work-tree"])
23
+ return result.stdout.strip() == "true"
24
+ except subprocess.CalledProcessError:
25
+ return False
26
+
27
+ def _get_staged_files(self) -> list[StagedFile]:
28
+ result = self._execute(["git", "diff", "--name-status", "--cached"])
29
+
30
+ files: list[StagedFile] = []
31
+
32
+ for line in result.stdout.splitlines():
33
+ _line = line.split("\t", 1)
34
+ status, path = _line
35
+ files.append(StagedFile(status=status, path=path))
36
+
37
+ return files
38
+
39
+ def _get_staged_diff(self) -> str:
40
+ result = self._execute(["git", "diff", "--cached"])
41
+ return result.stdout
42
+
43
+ def get_staged_changes(self) -> StagedChangeSet:
44
+ return StagedChangeSet(
45
+ files=self._get_staged_files(),
46
+ diff=self._get_staged_diff()
47
+ )
48
+
49
+ def commit(self, message: str) -> subprocess.CompletedProcess:
50
+ return self._execute(["git", "commit", "-m", message])
File without changes
@@ -0,0 +1,17 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class StagedFile(BaseModel):
5
+ status: str
6
+ path: str
7
+
8
+ class StagedChangeSet(BaseModel):
9
+ files: list[StagedFile]
10
+ diff: str
11
+
12
+ class AnalysisResult(BaseModel):
13
+ total_files: int
14
+ added_files: int
15
+ modified_files: int
16
+ deleted_files: int
17
+ renamed_files: int
@@ -0,0 +1,6 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class CommitSuggestion(BaseModel):
5
+ message: str
6
+ description: str
@@ -0,0 +1,14 @@
1
+ import subprocess
2
+ import tempfile
3
+
4
+
5
+ def edit_with_vim(message: str) -> str:
6
+ with tempfile.NamedTemporaryFile(
7
+ mode="w+",
8
+ delete=False
9
+ ) as tmp_file:
10
+ tmp_file.write(message)
11
+ tmp_file.flush()
12
+ subprocess.run(["vim", tmp_file.name], check=True)
13
+ tmp_file.seek(0)
14
+ return tmp_file.read().strip()
File without changes