agent-gitv1 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.
- PKG-INFO +174 -0
- README.md +159 -0
- agent.py +716 -0
- agent_gitv1-0.1.0.dist-info/METADATA +174 -0
- agent_gitv1-0.1.0.dist-info/RECORD +8 -0
- agent_gitv1-0.1.0.dist-info/WHEEL +4 -0
- agent_gitv1-0.1.0.dist-info/entry_points.txt +2 -0
- pyproject.toml +36 -0
PKG-INFO
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agent-gitv1
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AI-powered Git CLI using MCP + Gemini to auto-generate commit messages
|
|
5
|
+
Author-email: Vijay <you@example.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: ai,automation,cli,gemini,git,mcp
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: click>=8.1
|
|
10
|
+
Requires-Dist: google-genai>=0.8.0
|
|
11
|
+
Requires-Dist: mcp[cli]>=1.0.0
|
|
12
|
+
Requires-Dist: openai>=1.14.0
|
|
13
|
+
Requires-Dist: requests>=2.31.0
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# agent-gitv1 π€
|
|
17
|
+
|
|
18
|
+
> **AI-powered Git CLI** β Uses [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) + Google Gemini to automatically generate professional commit messages from your diff.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## How It Works
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
Your Code Changes
|
|
26
|
+
β
|
|
27
|
+
βΌ
|
|
28
|
+
[MCP Client] ββstdioβββΊ [mcp-server-git]
|
|
29
|
+
β β
|
|
30
|
+
ββββ git diff ββββββββββββ
|
|
31
|
+
β
|
|
32
|
+
βΌ
|
|
33
|
+
[Gemini AI] βββΊ Generate commit message
|
|
34
|
+
β
|
|
35
|
+
βΌ
|
|
36
|
+
[MCP Client] βββΊ git add . βββΊ git commit
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Prerequisites
|
|
42
|
+
|
|
43
|
+
| Tool | Install |
|
|
44
|
+
|------|---------|
|
|
45
|
+
| Python β₯ 3.10 | [python.org](https://python.org) |
|
|
46
|
+
| `uvx` (uv tool runner) | `pip install uv` |
|
|
47
|
+
| `mcp-server-git` | Auto-fetched by `uvx` |
|
|
48
|
+
| Gemini API Key | [aistudio.google.com](https://aistudio.google.com) |
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Installation
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# 1. Clone / navigate to the project
|
|
56
|
+
cd "d:\Vijay Projects\Agent_bhai"
|
|
57
|
+
|
|
58
|
+
# 2. Install in editable mode (creates the `agent` command globally)
|
|
59
|
+
pip install -e .
|
|
60
|
+
|
|
61
|
+
# 3. Set your Gemini API key
|
|
62
|
+
set GEMINI_API_KEY=your_gemini_api_key_here # Windows CMD
|
|
63
|
+
$env:GEMINI_API_KEY="your_key" # Windows PowerShell
|
|
64
|
+
export GEMINI_API_KEY=your_gemini_api_key_here # Linux / Mac
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Usage
|
|
70
|
+
|
|
71
|
+
### `agent config` β Configure LLM Provider (Start Here!)
|
|
72
|
+
|
|
73
|
+
Run this to configure OpenAI, Google Gemini, or Ollama.
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
agent config
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
What you can configure:
|
|
80
|
+
- **Google Gemini**: Uses your API key and a model like `gemini-2.0-flash`.
|
|
81
|
+
- **OpenAI (ChatGPT)**: Uses your API key and a model like `gpt-4o-mini`.
|
|
82
|
+
- **Ollama (Local/Network)**: Provide the base URL (e.g. `http://localhost:11434` or `http://192.168.1.50:11434`). The CLI will automatically fetch your downloaded models and let you choose one!
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
### `agent commit` β Stage + AI commit message + commit
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
# In any git repo:
|
|
90
|
+
agent commit
|
|
91
|
+
|
|
92
|
+
# Specify a repo path:
|
|
93
|
+
agent commit --repo /path/to/repo
|
|
94
|
+
|
|
95
|
+
# Generate multiple suggestions (pick one or type your own):
|
|
96
|
+
agent commit --suggestions 3
|
|
97
|
+
|
|
98
|
+
# Verbose mode (shows diff preview + available MCP tools):
|
|
99
|
+
agent commit --verbose
|
|
100
|
+
agent commit -v
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`agent commit` is now history-aware:
|
|
104
|
+
- It uses recent commit messages from your repo to align tone/style.
|
|
105
|
+
- It includes changed file names in the LLM prompt for better scoped messages.
|
|
106
|
+
|
|
107
|
+
### `agent push` β Push to remote
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
agent push
|
|
111
|
+
agent push --remote origin --branch main
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Help
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
agent --help
|
|
118
|
+
agent config --help
|
|
119
|
+
agent commit --help
|
|
120
|
+
agent push --help
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## Example Session
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
π Repository: D:\my-project
|
|
129
|
+
|
|
130
|
+
π Connecting to mcp-server-git...
|
|
131
|
+
β
MCP session initialized.
|
|
132
|
+
|
|
133
|
+
π Fetching git diff (unstaged changes)...
|
|
134
|
+
Diff captured (1240 chars).
|
|
135
|
+
|
|
136
|
+
π€ Generating commit message with Gemini...
|
|
137
|
+
|
|
138
|
+
π¬ Commit Message: feat(auth): add JWT token refresh endpoint
|
|
139
|
+
|
|
140
|
+
Proceed with git add + commit? [Y/n]: y
|
|
141
|
+
|
|
142
|
+
π¦ Staging all changes (git add .)...
|
|
143
|
+
Files staged.
|
|
144
|
+
|
|
145
|
+
βοΈ Committing...
|
|
146
|
+
[main a3f12bc] feat(auth): add JWT token refresh endpoint
|
|
147
|
+
|
|
148
|
+
π Done! Changes committed successfully.
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## Environment Variables
|
|
154
|
+
|
|
155
|
+
| Variable | Required | Description |
|
|
156
|
+
|----------|----------|-------------|
|
|
157
|
+
| `GEMINI_API_KEY` | β
Yes | Your Google Gemini API key |
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## Project Structure
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
Agent_bhai/
|
|
165
|
+
βββ agent.py # Main CLI + MCP client logic
|
|
166
|
+
βββ pyproject.toml # Packaging + entry point config
|
|
167
|
+
βββ README.md # This file
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## License
|
|
173
|
+
|
|
174
|
+
MIT
|
README.md
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# agent-gitv1 π€
|
|
2
|
+
|
|
3
|
+
> **AI-powered Git CLI** β Uses [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) + Google Gemini to automatically generate professional commit messages from your diff.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## How It Works
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
Your Code Changes
|
|
11
|
+
β
|
|
12
|
+
βΌ
|
|
13
|
+
[MCP Client] ββstdioβββΊ [mcp-server-git]
|
|
14
|
+
β β
|
|
15
|
+
ββββ git diff ββββββββββββ
|
|
16
|
+
β
|
|
17
|
+
βΌ
|
|
18
|
+
[Gemini AI] βββΊ Generate commit message
|
|
19
|
+
β
|
|
20
|
+
βΌ
|
|
21
|
+
[MCP Client] βββΊ git add . βββΊ git commit
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Prerequisites
|
|
27
|
+
|
|
28
|
+
| Tool | Install |
|
|
29
|
+
|------|---------|
|
|
30
|
+
| Python β₯ 3.10 | [python.org](https://python.org) |
|
|
31
|
+
| `uvx` (uv tool runner) | `pip install uv` |
|
|
32
|
+
| `mcp-server-git` | Auto-fetched by `uvx` |
|
|
33
|
+
| Gemini API Key | [aistudio.google.com](https://aistudio.google.com) |
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# 1. Clone / navigate to the project
|
|
41
|
+
cd "d:\Vijay Projects\Agent_bhai"
|
|
42
|
+
|
|
43
|
+
# 2. Install in editable mode (creates the `agent` command globally)
|
|
44
|
+
pip install -e .
|
|
45
|
+
|
|
46
|
+
# 3. Set your Gemini API key
|
|
47
|
+
set GEMINI_API_KEY=your_gemini_api_key_here # Windows CMD
|
|
48
|
+
$env:GEMINI_API_KEY="your_key" # Windows PowerShell
|
|
49
|
+
export GEMINI_API_KEY=your_gemini_api_key_here # Linux / Mac
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Usage
|
|
55
|
+
|
|
56
|
+
### `agent config` β Configure LLM Provider (Start Here!)
|
|
57
|
+
|
|
58
|
+
Run this to configure OpenAI, Google Gemini, or Ollama.
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
agent config
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
What you can configure:
|
|
65
|
+
- **Google Gemini**: Uses your API key and a model like `gemini-2.0-flash`.
|
|
66
|
+
- **OpenAI (ChatGPT)**: Uses your API key and a model like `gpt-4o-mini`.
|
|
67
|
+
- **Ollama (Local/Network)**: Provide the base URL (e.g. `http://localhost:11434` or `http://192.168.1.50:11434`). The CLI will automatically fetch your downloaded models and let you choose one!
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
### `agent commit` β Stage + AI commit message + commit
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
# In any git repo:
|
|
75
|
+
agent commit
|
|
76
|
+
|
|
77
|
+
# Specify a repo path:
|
|
78
|
+
agent commit --repo /path/to/repo
|
|
79
|
+
|
|
80
|
+
# Generate multiple suggestions (pick one or type your own):
|
|
81
|
+
agent commit --suggestions 3
|
|
82
|
+
|
|
83
|
+
# Verbose mode (shows diff preview + available MCP tools):
|
|
84
|
+
agent commit --verbose
|
|
85
|
+
agent commit -v
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`agent commit` is now history-aware:
|
|
89
|
+
- It uses recent commit messages from your repo to align tone/style.
|
|
90
|
+
- It includes changed file names in the LLM prompt for better scoped messages.
|
|
91
|
+
|
|
92
|
+
### `agent push` β Push to remote
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
agent push
|
|
96
|
+
agent push --remote origin --branch main
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Help
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
agent --help
|
|
103
|
+
agent config --help
|
|
104
|
+
agent commit --help
|
|
105
|
+
agent push --help
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Example Session
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
π Repository: D:\my-project
|
|
114
|
+
|
|
115
|
+
π Connecting to mcp-server-git...
|
|
116
|
+
β
MCP session initialized.
|
|
117
|
+
|
|
118
|
+
π Fetching git diff (unstaged changes)...
|
|
119
|
+
Diff captured (1240 chars).
|
|
120
|
+
|
|
121
|
+
π€ Generating commit message with Gemini...
|
|
122
|
+
|
|
123
|
+
π¬ Commit Message: feat(auth): add JWT token refresh endpoint
|
|
124
|
+
|
|
125
|
+
Proceed with git add + commit? [Y/n]: y
|
|
126
|
+
|
|
127
|
+
π¦ Staging all changes (git add .)...
|
|
128
|
+
Files staged.
|
|
129
|
+
|
|
130
|
+
βοΈ Committing...
|
|
131
|
+
[main a3f12bc] feat(auth): add JWT token refresh endpoint
|
|
132
|
+
|
|
133
|
+
π Done! Changes committed successfully.
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## Environment Variables
|
|
139
|
+
|
|
140
|
+
| Variable | Required | Description |
|
|
141
|
+
|----------|----------|-------------|
|
|
142
|
+
| `GEMINI_API_KEY` | β
Yes | Your Google Gemini API key |
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## Project Structure
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
Agent_bhai/
|
|
150
|
+
βββ agent.py # Main CLI + MCP client logic
|
|
151
|
+
βββ pyproject.toml # Packaging + entry point config
|
|
152
|
+
βββ README.md # This file
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## License
|
|
158
|
+
|
|
159
|
+
MIT
|
agent.py
ADDED
|
@@ -0,0 +1,716 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agent-gitv1: A CLI tool that uses MCP + Local/Cloud LLMs to automate git workflows.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import sys
|
|
7
|
+
import os
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import click
|
|
13
|
+
from mcp import ClientSession, StdioServerParameters
|
|
14
|
+
from mcp.client.stdio import stdio_client
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
|
18
|
+
# Configuration
|
|
19
|
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
|
20
|
+
|
|
21
|
+
CONFIG_FILE = Path.home() / ".agent_git_config.json"
|
|
22
|
+
DEFAULT_DIFF_TARGET = "HEAD"
|
|
23
|
+
MAX_DIFF_CHARS_FOR_LLM = 28000
|
|
24
|
+
MAX_FILES_FOR_LLM_DIFF = 40
|
|
25
|
+
MAX_CHARS_PER_FILE_SECTION = 1400
|
|
26
|
+
MAX_HISTORY_COMMITS = 12
|
|
27
|
+
MAX_HISTORY_CHARS = 2400
|
|
28
|
+
|
|
29
|
+
COMMIT_SYSTEM_PROMPT = """
|
|
30
|
+
You are an expert software engineer. Your ONLY job is to write a concise, professional Git commit message.
|
|
31
|
+
|
|
32
|
+
Rules:
|
|
33
|
+
- Use the Conventional Commits format: <type>(<optional scope>): <short summary>
|
|
34
|
+
- Types: feat, fix, docs, style, refactor, test, chore, ci, perf
|
|
35
|
+
- Summary must be in imperative mood (e.g., "add feature" not "added feature")
|
|
36
|
+
- Keep the summary under 72 characters
|
|
37
|
+
- Do NOT include explanations, markdown, backticks, or any extra text
|
|
38
|
+
- Output ONLY the commit message, nothing else
|
|
39
|
+
|
|
40
|
+
Examples:
|
|
41
|
+
feat(auth): add JWT token refresh endpoint
|
|
42
|
+
fix(ui): resolve button alignment on mobile devices
|
|
43
|
+
docs: update README with installation steps
|
|
44
|
+
refactor(api): simplify error handling middleware
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
COMMIT_TYPE_PATTERN = r"(?:feat|fix|docs|style|refactor|test|chore|ci|perf)"
|
|
48
|
+
COMMIT_MESSAGE_PATTERN = re.compile(
|
|
49
|
+
rf"^{COMMIT_TYPE_PATTERN}(?:\([^)]+\))?: .+"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def load_config() -> dict:
|
|
53
|
+
if CONFIG_FILE.exists():
|
|
54
|
+
try:
|
|
55
|
+
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
|
56
|
+
return json.load(f)
|
|
57
|
+
except Exception:
|
|
58
|
+
return {}
|
|
59
|
+
return {}
|
|
60
|
+
|
|
61
|
+
def save_config(config: dict):
|
|
62
|
+
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
|
63
|
+
json.dump(config, f, indent=2)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
|
67
|
+
# AI Providers
|
|
68
|
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
|
69
|
+
|
|
70
|
+
def get_ollama_models(base_url: str):
|
|
71
|
+
import requests
|
|
72
|
+
try:
|
|
73
|
+
url = base_url.rstrip("/") + "/api/tags"
|
|
74
|
+
resp = requests.get(url, timeout=3)
|
|
75
|
+
resp.raise_for_status()
|
|
76
|
+
return [m["name"] for m in resp.json().get("models", [])]
|
|
77
|
+
except Exception:
|
|
78
|
+
return []
|
|
79
|
+
|
|
80
|
+
def generate_with_gemini(diff: str, config: dict) -> str:
|
|
81
|
+
from google import genai
|
|
82
|
+
from google.genai import types
|
|
83
|
+
api_key = config.get("api_key") or os.environ.get("GEMINI_API_KEY")
|
|
84
|
+
if not api_key:
|
|
85
|
+
raise click.ClickException("Gemini API key is not configured. Run 'agent config' or set GEMINI_API_KEY.")
|
|
86
|
+
|
|
87
|
+
client = genai.Client(api_key=api_key)
|
|
88
|
+
user_prompt = diff
|
|
89
|
+
response = client.models.generate_content(
|
|
90
|
+
model=config.get("model", "gemini-2.0-flash"),
|
|
91
|
+
contents=user_prompt,
|
|
92
|
+
config=types.GenerateContentConfig(
|
|
93
|
+
system_instruction=COMMIT_SYSTEM_PROMPT,
|
|
94
|
+
temperature=0.2,
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
return response.text.strip()
|
|
98
|
+
|
|
99
|
+
def generate_with_openai(diff: str, config: dict) -> str:
|
|
100
|
+
from openai import OpenAI
|
|
101
|
+
api_key = config.get("api_key") or os.environ.get("OPENAI_API_KEY")
|
|
102
|
+
if not api_key:
|
|
103
|
+
raise click.ClickException("OpenAI API key is not configured. Run 'agent config' or set OPENAI_API_KEY.")
|
|
104
|
+
|
|
105
|
+
client = OpenAI(api_key=api_key)
|
|
106
|
+
response = client.chat.completions.create(
|
|
107
|
+
model=config.get("model", "gpt-4o-mini"),
|
|
108
|
+
messages=[
|
|
109
|
+
{"role": "system", "content": COMMIT_SYSTEM_PROMPT},
|
|
110
|
+
{"role": "user", "content": diff}
|
|
111
|
+
],
|
|
112
|
+
temperature=0.2,
|
|
113
|
+
)
|
|
114
|
+
return response.choices[0].message.content.strip()
|
|
115
|
+
|
|
116
|
+
def generate_with_ollama(diff: str, config: dict) -> str:
|
|
117
|
+
from openai import OpenAI
|
|
118
|
+
base_url = config.get("base_url", "http://localhost:11434")
|
|
119
|
+
# Ollama uses the OpenAI client but needs the /v1 suffix if not provided
|
|
120
|
+
openai_base_url = base_url.rstrip("/") + "/v1"
|
|
121
|
+
|
|
122
|
+
# Fake API key for Ollama compatibility
|
|
123
|
+
client = OpenAI(base_url=openai_base_url, api_key="ollama-local")
|
|
124
|
+
try:
|
|
125
|
+
response = client.chat.completions.create(
|
|
126
|
+
model=config.get("model", "llama3:latest"),
|
|
127
|
+
messages=[
|
|
128
|
+
{"role": "system", "content": COMMIT_SYSTEM_PROMPT},
|
|
129
|
+
{"role": "user", "content": diff}
|
|
130
|
+
],
|
|
131
|
+
temperature=0.2,
|
|
132
|
+
)
|
|
133
|
+
return response.choices[0].message.content.strip()
|
|
134
|
+
except Exception as e:
|
|
135
|
+
raise click.ClickException(f"Ollama generation failed: {e}\nIs Ollama running at {base_url}?")
|
|
136
|
+
|
|
137
|
+
def generate_commit_message(diff: str) -> str:
|
|
138
|
+
"""Send the git diff to the configured AI and get back a commit message."""
|
|
139
|
+
config = load_config()
|
|
140
|
+
provider = config.get("provider", "gemini") # Default fallback
|
|
141
|
+
|
|
142
|
+
if provider == "gemini":
|
|
143
|
+
return generate_with_gemini(diff, config)
|
|
144
|
+
elif provider == "openai":
|
|
145
|
+
return generate_with_openai(diff, config)
|
|
146
|
+
elif provider == "ollama":
|
|
147
|
+
return generate_with_ollama(diff, config)
|
|
148
|
+
else:
|
|
149
|
+
raise click.ClickException(f"Unknown provider '{provider}'. Run 'agent config'.")
|
|
150
|
+
|
|
151
|
+
def build_commit_system_prompt(suggestions: int) -> str:
|
|
152
|
+
if suggestions <= 1:
|
|
153
|
+
return COMMIT_SYSTEM_PROMPT
|
|
154
|
+
|
|
155
|
+
return f"""
|
|
156
|
+
You are an expert software engineer. Your ONLY job is to write concise, professional Git commit messages.
|
|
157
|
+
|
|
158
|
+
Rules:
|
|
159
|
+
- Use the Conventional Commits format: <type>(<optional scope>): <short summary>
|
|
160
|
+
- Types: feat, fix, docs, style, refactor, test, chore, ci, perf
|
|
161
|
+
- Summary must be in imperative mood (e.g., "add feature" not "added feature")
|
|
162
|
+
- Keep each summary under 72 characters
|
|
163
|
+
- Return exactly {suggestions} options
|
|
164
|
+
- Each option must be on a new line with numbering: "1. ...", "2. ..."
|
|
165
|
+
- Do NOT include explanations, markdown, backticks, or any extra text
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
def extract_changed_files(diff_text: str, max_files: int = 20) -> list[str]:
|
|
169
|
+
files = []
|
|
170
|
+
for line in diff_text.splitlines():
|
|
171
|
+
if not line.startswith("diff --git "):
|
|
172
|
+
continue
|
|
173
|
+
parts = line.split()
|
|
174
|
+
if len(parts) >= 4:
|
|
175
|
+
path = parts[3]
|
|
176
|
+
if path.startswith("b/"):
|
|
177
|
+
path = path[2:]
|
|
178
|
+
if path not in files:
|
|
179
|
+
files.append(path)
|
|
180
|
+
if len(files) >= max_files:
|
|
181
|
+
break
|
|
182
|
+
return files
|
|
183
|
+
|
|
184
|
+
def get_recent_commit_history(repo_path: str, max_commits: int = MAX_HISTORY_COMMITS) -> str:
|
|
185
|
+
try:
|
|
186
|
+
cmd = [
|
|
187
|
+
"git",
|
|
188
|
+
"log",
|
|
189
|
+
f"-n{max_commits}",
|
|
190
|
+
"--pretty=format:%h %s",
|
|
191
|
+
]
|
|
192
|
+
output = subprocess.check_output(
|
|
193
|
+
cmd,
|
|
194
|
+
cwd=repo_path,
|
|
195
|
+
text=True,
|
|
196
|
+
stderr=subprocess.DEVNULL,
|
|
197
|
+
).strip()
|
|
198
|
+
except Exception:
|
|
199
|
+
return ""
|
|
200
|
+
|
|
201
|
+
if not output:
|
|
202
|
+
return ""
|
|
203
|
+
|
|
204
|
+
if len(output) > MAX_HISTORY_CHARS:
|
|
205
|
+
return output[:MAX_HISTORY_CHARS] + "\n... [history truncated]"
|
|
206
|
+
return output
|
|
207
|
+
|
|
208
|
+
def build_commit_user_prompt(
|
|
209
|
+
diff_text: str,
|
|
210
|
+
history_text: str,
|
|
211
|
+
changed_files: list[str],
|
|
212
|
+
) -> str:
|
|
213
|
+
files_block = "\n".join(f"- {name}" for name in changed_files) if changed_files else "- (not detected)"
|
|
214
|
+
history_block = history_text if history_text else "(No recent commits available)"
|
|
215
|
+
return (
|
|
216
|
+
"Write commit message suggestion(s) for the following change.\n\n"
|
|
217
|
+
"Recent commit messages from this repository (for style/context):\n"
|
|
218
|
+
f"{history_block}\n\n"
|
|
219
|
+
"Changed files:\n"
|
|
220
|
+
f"{files_block}\n\n"
|
|
221
|
+
"Git diff:\n"
|
|
222
|
+
f"{diff_text}"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def sanitize_commit_candidate(text: str) -> str:
|
|
226
|
+
cleaned = text.strip().strip("`").strip("\"").strip("'")
|
|
227
|
+
cleaned = re.sub(r"^\d+\s*[\.\)\-:]\s*", "", cleaned).strip()
|
|
228
|
+
return cleaned
|
|
229
|
+
|
|
230
|
+
def parse_commit_suggestions(raw_text: str, expected: int) -> list[str]:
|
|
231
|
+
suggestions = []
|
|
232
|
+
for line in raw_text.splitlines():
|
|
233
|
+
candidate = sanitize_commit_candidate(line)
|
|
234
|
+
if COMMIT_MESSAGE_PATTERN.match(candidate):
|
|
235
|
+
if candidate not in suggestions:
|
|
236
|
+
suggestions.append(candidate)
|
|
237
|
+
if len(suggestions) >= expected:
|
|
238
|
+
return suggestions
|
|
239
|
+
|
|
240
|
+
matches = re.findall(
|
|
241
|
+
rf"(?:^|\n)\s*(?:\d+\s*[\.\)\-:]\s*)?({COMMIT_TYPE_PATTERN}(?:\([^)]+\))?: [^\n]+)",
|
|
242
|
+
raw_text,
|
|
243
|
+
flags=re.IGNORECASE,
|
|
244
|
+
)
|
|
245
|
+
for match in matches:
|
|
246
|
+
candidate = sanitize_commit_candidate(match)
|
|
247
|
+
if COMMIT_MESSAGE_PATTERN.match(candidate) and candidate not in suggestions:
|
|
248
|
+
suggestions.append(candidate)
|
|
249
|
+
if len(suggestions) >= expected:
|
|
250
|
+
return suggestions
|
|
251
|
+
|
|
252
|
+
# Fallback: take first non-empty line even if model ignored format rules.
|
|
253
|
+
if not suggestions:
|
|
254
|
+
for line in raw_text.splitlines():
|
|
255
|
+
candidate = sanitize_commit_candidate(line)
|
|
256
|
+
if candidate:
|
|
257
|
+
suggestions.append(candidate)
|
|
258
|
+
break
|
|
259
|
+
return suggestions
|
|
260
|
+
|
|
261
|
+
def generate_commit_suggestions(
|
|
262
|
+
diff_text: str,
|
|
263
|
+
repo_path: str,
|
|
264
|
+
suggestions: int,
|
|
265
|
+
) -> list[str]:
|
|
266
|
+
config = load_config()
|
|
267
|
+
provider = config.get("provider", "gemini") # Default fallback
|
|
268
|
+
|
|
269
|
+
history_text = get_recent_commit_history(repo_path)
|
|
270
|
+
changed_files = extract_changed_files(diff_text)
|
|
271
|
+
user_prompt = build_commit_user_prompt(diff_text, history_text, changed_files)
|
|
272
|
+
system_prompt = build_commit_system_prompt(suggestions)
|
|
273
|
+
|
|
274
|
+
if provider == "gemini":
|
|
275
|
+
from google import genai
|
|
276
|
+
from google.genai import types
|
|
277
|
+
api_key = config.get("api_key") or os.environ.get("GEMINI_API_KEY")
|
|
278
|
+
if not api_key:
|
|
279
|
+
raise click.ClickException("Gemini API key is not configured. Run 'agent config' or set GEMINI_API_KEY.")
|
|
280
|
+
client = genai.Client(api_key=api_key)
|
|
281
|
+
response = client.models.generate_content(
|
|
282
|
+
model=config.get("model", "gemini-2.0-flash"),
|
|
283
|
+
contents=user_prompt,
|
|
284
|
+
config=types.GenerateContentConfig(
|
|
285
|
+
system_instruction=system_prompt,
|
|
286
|
+
temperature=0.2,
|
|
287
|
+
),
|
|
288
|
+
)
|
|
289
|
+
raw_text = (response.text or "").strip()
|
|
290
|
+
elif provider in ("openai", "ollama"):
|
|
291
|
+
from openai import OpenAI
|
|
292
|
+
if provider == "openai":
|
|
293
|
+
api_key = config.get("api_key") or os.environ.get("OPENAI_API_KEY")
|
|
294
|
+
if not api_key:
|
|
295
|
+
raise click.ClickException("OpenAI API key is not configured. Run 'agent config' or set OPENAI_API_KEY.")
|
|
296
|
+
client = OpenAI(api_key=api_key)
|
|
297
|
+
model = config.get("model", "gpt-4o-mini")
|
|
298
|
+
else:
|
|
299
|
+
base_url = config.get("base_url", "http://localhost:11434").rstrip("/") + "/v1"
|
|
300
|
+
client = OpenAI(base_url=base_url, api_key="ollama-local")
|
|
301
|
+
model = config.get("model", "llama3:latest")
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
response = client.chat.completions.create(
|
|
305
|
+
model=model,
|
|
306
|
+
messages=[
|
|
307
|
+
{"role": "system", "content": system_prompt},
|
|
308
|
+
{"role": "user", "content": user_prompt},
|
|
309
|
+
],
|
|
310
|
+
temperature=0.2,
|
|
311
|
+
)
|
|
312
|
+
except Exception as e:
|
|
313
|
+
if provider == "ollama":
|
|
314
|
+
raise click.ClickException(f"Ollama generation failed: {e}\nIs Ollama running at {config.get('base_url', 'http://localhost:11434')}?")
|
|
315
|
+
raise
|
|
316
|
+
raw_text = (response.choices[0].message.content or "").strip()
|
|
317
|
+
else:
|
|
318
|
+
raise click.ClickException(f"Unknown provider '{provider}'. Run 'agent config'.")
|
|
319
|
+
|
|
320
|
+
parsed = parse_commit_suggestions(raw_text, suggestions)
|
|
321
|
+
if not parsed:
|
|
322
|
+
raise click.ClickException("Model returned no usable commit message suggestions.")
|
|
323
|
+
return parsed
|
|
324
|
+
|
|
325
|
+
def extract_tool_text(result) -> str:
|
|
326
|
+
"""Collect text blocks from an MCP tool response."""
|
|
327
|
+
output = ""
|
|
328
|
+
for block in result.content:
|
|
329
|
+
if hasattr(block, "text"):
|
|
330
|
+
output += block.text
|
|
331
|
+
return output.strip()
|
|
332
|
+
|
|
333
|
+
def split_diff_sections(diff_text: str):
|
|
334
|
+
"""
|
|
335
|
+
Split unified diff into per-file sections starting at 'diff --git'.
|
|
336
|
+
If no markers exist, return the original text as one section.
|
|
337
|
+
"""
|
|
338
|
+
lines = diff_text.splitlines(keepends=True)
|
|
339
|
+
sections = []
|
|
340
|
+
current = []
|
|
341
|
+
|
|
342
|
+
for line in lines:
|
|
343
|
+
if line.startswith("diff --git "):
|
|
344
|
+
if current:
|
|
345
|
+
sections.append("".join(current))
|
|
346
|
+
current = [line]
|
|
347
|
+
else:
|
|
348
|
+
current.append(line)
|
|
349
|
+
|
|
350
|
+
if current:
|
|
351
|
+
sections.append("".join(current))
|
|
352
|
+
|
|
353
|
+
if not sections:
|
|
354
|
+
return [diff_text]
|
|
355
|
+
return sections
|
|
356
|
+
|
|
357
|
+
def build_llm_diff_payload(diff_text: str):
|
|
358
|
+
"""
|
|
359
|
+
Keep prompt size bounded for better LLM latency/quality on very large diffs.
|
|
360
|
+
Returns (payload_text, was_truncated).
|
|
361
|
+
"""
|
|
362
|
+
if len(diff_text) <= MAX_DIFF_CHARS_FOR_LLM:
|
|
363
|
+
return diff_text, False
|
|
364
|
+
|
|
365
|
+
sections = split_diff_sections(diff_text)
|
|
366
|
+
compact_sections = []
|
|
367
|
+
total_files = len(sections)
|
|
368
|
+
|
|
369
|
+
for idx, section in enumerate(sections):
|
|
370
|
+
if idx >= MAX_FILES_FOR_LLM_DIFF:
|
|
371
|
+
break
|
|
372
|
+
if len(section) > MAX_CHARS_PER_FILE_SECTION:
|
|
373
|
+
compact_sections.append(
|
|
374
|
+
section[:MAX_CHARS_PER_FILE_SECTION]
|
|
375
|
+
+ f"\n... [section truncated at {MAX_CHARS_PER_FILE_SECTION} chars]\n"
|
|
376
|
+
)
|
|
377
|
+
else:
|
|
378
|
+
compact_sections.append(section)
|
|
379
|
+
|
|
380
|
+
remaining_files = max(0, total_files - len(compact_sections))
|
|
381
|
+
header = (
|
|
382
|
+
f"[Truncated diff for AI input: original={len(diff_text)} chars, "
|
|
383
|
+
f"included_files={len(compact_sections)}, omitted_files={remaining_files}]\n\n"
|
|
384
|
+
)
|
|
385
|
+
payload = header + "".join(compact_sections)
|
|
386
|
+
|
|
387
|
+
if len(payload) > MAX_DIFF_CHARS_FOR_LLM:
|
|
388
|
+
payload = payload[:MAX_DIFF_CHARS_FOR_LLM] + "\n... [overall diff payload truncated]\n"
|
|
389
|
+
|
|
390
|
+
return payload, True
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
|
394
|
+
# MCP Core Logic
|
|
395
|
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
|
396
|
+
|
|
397
|
+
async def run_agent_commit(repo_path: str, verbose: bool, suggestion_count: int):
|
|
398
|
+
"""
|
|
399
|
+
Full MCP lifecycle:
|
|
400
|
+
1. Initialize MCP session with mcp-server-git
|
|
401
|
+
2. Call git_diff to get uncommitted changes
|
|
402
|
+
3. Generate commit message via selected AI Provider
|
|
403
|
+
4. Call git_add then git_commit via MCP
|
|
404
|
+
"""
|
|
405
|
+
server_params = StdioServerParameters(
|
|
406
|
+
command="uvx",
|
|
407
|
+
args=["mcp-server-git", "--repository", repo_path],
|
|
408
|
+
env=None,
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
click.echo(click.style("π Connecting to mcp-server-git...", fg="cyan"))
|
|
412
|
+
|
|
413
|
+
async with stdio_client(server_params) as (read, write):
|
|
414
|
+
async with ClientSession(read, write) as session:
|
|
415
|
+
|
|
416
|
+
# ββ Step 1: Initialize ββββββββββββββββββββββββββ
|
|
417
|
+
await session.initialize()
|
|
418
|
+
click.echo(click.style("β
MCP session initialized.", fg="green"))
|
|
419
|
+
|
|
420
|
+
if verbose:
|
|
421
|
+
tools = await session.list_tools()
|
|
422
|
+
click.echo(click.style(
|
|
423
|
+
f" Available tools: {[t.name for t in tools.tools]}",
|
|
424
|
+
fg="bright_black"
|
|
425
|
+
))
|
|
426
|
+
|
|
427
|
+
# ββ Step 2: Get git diff ββββββββββββββββββββββββ
|
|
428
|
+
click.echo(click.style("\nπ Fetching git diff (changes against HEAD)...", fg="cyan"))
|
|
429
|
+
|
|
430
|
+
diff_result = await session.call_tool(
|
|
431
|
+
"git_diff",
|
|
432
|
+
arguments={
|
|
433
|
+
"repo_path": repo_path,
|
|
434
|
+
"target": DEFAULT_DIFF_TARGET,
|
|
435
|
+
},
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
diff_text = extract_tool_text(diff_result)
|
|
439
|
+
|
|
440
|
+
if getattr(diff_result, "isError", False):
|
|
441
|
+
raise click.ClickException(
|
|
442
|
+
f"git_diff failed: {diff_text or 'Unknown MCP tool error.'}"
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
if not diff_text.strip():
|
|
446
|
+
click.echo(click.style(
|
|
447
|
+
"β οΈ No changes detected. Nothing to commit.",
|
|
448
|
+
fg="yellow"
|
|
449
|
+
))
|
|
450
|
+
return
|
|
451
|
+
|
|
452
|
+
if verbose:
|
|
453
|
+
click.echo(click.style("\nββ Diff Preview ββ", fg="bright_black"))
|
|
454
|
+
preview = diff_text[:1500] + ("..." if len(diff_text) > 1500 else "")
|
|
455
|
+
click.echo(click.style(preview, fg="bright_black"))
|
|
456
|
+
|
|
457
|
+
click.echo(click.style(
|
|
458
|
+
f" Diff captured ({len(diff_text)} chars).", fg="green"
|
|
459
|
+
))
|
|
460
|
+
|
|
461
|
+
# ββ Step 3: Generate commit message via LLM βββββ
|
|
462
|
+
config = load_config()
|
|
463
|
+
provider = config.get("provider", "gemini")
|
|
464
|
+
click.echo(click.style(f"\nπ€ Generating commit message suggestions with {provider.upper()}...", fg="cyan"))
|
|
465
|
+
|
|
466
|
+
llm_diff_payload, was_truncated = build_llm_diff_payload(diff_text)
|
|
467
|
+
if was_truncated:
|
|
468
|
+
click.echo(click.style(
|
|
469
|
+
f" Large diff detected. Sending compacted payload ({len(llm_diff_payload)} chars) to AI.",
|
|
470
|
+
fg="yellow"
|
|
471
|
+
))
|
|
472
|
+
|
|
473
|
+
suggestions = generate_commit_suggestions(
|
|
474
|
+
llm_diff_payload,
|
|
475
|
+
repo_path,
|
|
476
|
+
suggestion_count,
|
|
477
|
+
)
|
|
478
|
+
if len(suggestions) < suggestion_count and verbose:
|
|
479
|
+
click.echo(click.style(
|
|
480
|
+
f" Provider returned {len(suggestions)} valid suggestion(s).",
|
|
481
|
+
fg="yellow",
|
|
482
|
+
))
|
|
483
|
+
|
|
484
|
+
click.echo(click.style("\n㪠Suggested Commit Messages:", fg="green"))
|
|
485
|
+
for idx, suggestion in enumerate(suggestions, start=1):
|
|
486
|
+
click.echo(click.style(f" {idx}. {suggestion}", fg="bright_white"))
|
|
487
|
+
|
|
488
|
+
selection = click.prompt(
|
|
489
|
+
click.style("\nSelect message number or type a custom message", fg="yellow"),
|
|
490
|
+
default="1",
|
|
491
|
+
type=str,
|
|
492
|
+
).strip()
|
|
493
|
+
if selection.isdigit() and 1 <= int(selection) <= len(suggestions):
|
|
494
|
+
commit_message = suggestions[int(selection) - 1]
|
|
495
|
+
elif selection:
|
|
496
|
+
commit_message = selection
|
|
497
|
+
else:
|
|
498
|
+
commit_message = suggestions[0]
|
|
499
|
+
|
|
500
|
+
click.echo(click.style(
|
|
501
|
+
f"\nβ
Selected Commit Message: {click.style(commit_message, bold=True, fg='bright_white')}",
|
|
502
|
+
fg="green"
|
|
503
|
+
))
|
|
504
|
+
|
|
505
|
+
# ββ Confirmation Prompt βββββββββββββββββββββββββ
|
|
506
|
+
if not click.confirm(
|
|
507
|
+
click.style("\nProceed with git add + commit?", fg="yellow"),
|
|
508
|
+
default=True,
|
|
509
|
+
):
|
|
510
|
+
click.echo(click.style("β Aborted by user.", fg="red"))
|
|
511
|
+
return
|
|
512
|
+
|
|
513
|
+
# ββ Step 4: git add . βββββββββββββββββββββββββββ
|
|
514
|
+
click.echo(click.style("\nπ¦ Staging all changes (git add .)...", fg="cyan"))
|
|
515
|
+
|
|
516
|
+
add_result = await session.call_tool(
|
|
517
|
+
"git_add",
|
|
518
|
+
arguments={
|
|
519
|
+
"repo_path": repo_path,
|
|
520
|
+
"files": ["."],
|
|
521
|
+
},
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
add_output = ""
|
|
525
|
+
for block in add_result.content:
|
|
526
|
+
if hasattr(block, "text"):
|
|
527
|
+
add_output += block.text
|
|
528
|
+
click.echo(click.style(f" {add_output.strip() or 'Files staged.'}", fg="green"))
|
|
529
|
+
|
|
530
|
+
# ββ Step 5: git commit ββββββββββββββββββββββββββ
|
|
531
|
+
click.echo(click.style("\nβοΈ Committing...", fg="cyan"))
|
|
532
|
+
|
|
533
|
+
commit_result = await session.call_tool(
|
|
534
|
+
"git_commit",
|
|
535
|
+
arguments={
|
|
536
|
+
"repo_path": repo_path,
|
|
537
|
+
"message": commit_message,
|
|
538
|
+
},
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
commit_output = ""
|
|
542
|
+
for block in commit_result.content:
|
|
543
|
+
if hasattr(block, "text"):
|
|
544
|
+
commit_output += block.text
|
|
545
|
+
click.echo(click.style(f" {commit_output.strip()}", fg="green"))
|
|
546
|
+
|
|
547
|
+
click.echo(click.style(
|
|
548
|
+
"\nπ Done! Changes committed successfully.",
|
|
549
|
+
fg="bright_green", bold=True
|
|
550
|
+
))
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
|
554
|
+
# CLI Entry Points
|
|
555
|
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
|
556
|
+
|
|
557
|
+
@click.group()
|
|
558
|
+
@click.version_option(version="0.1.0", prog_name="agent-gitv1")
|
|
559
|
+
def cli():
|
|
560
|
+
"""
|
|
561
|
+
\b
|
|
562
|
+
ββββββββββββββββββββββββββββββββββββββββββββββ
|
|
563
|
+
β agent-gitv1 π€ MCP + Multi-LLM Commits β
|
|
564
|
+
ββββββββββββββββββββββββββββββββββββββββββββββ
|
|
565
|
+
|
|
566
|
+
Automate your Git workflow with AI-generated commit messages.
|
|
567
|
+
Supports OpenAI, Gemini, and Local Ollama models!
|
|
568
|
+
"""
|
|
569
|
+
pass
|
|
570
|
+
|
|
571
|
+
@cli.command("config")
|
|
572
|
+
def config_command():
|
|
573
|
+
"""Configure your preferred AI Provider (OpenAI, Gemini, or Ollama)."""
|
|
574
|
+
click.echo(click.style("π Agent Git Setup\n", bold=True, fg="cyan"))
|
|
575
|
+
|
|
576
|
+
provider = click.prompt(
|
|
577
|
+
"Select AI Provider",
|
|
578
|
+
type=click.Choice(["gemini", "openai", "ollama"]),
|
|
579
|
+
default="ollama"
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
config = {"provider": provider}
|
|
583
|
+
|
|
584
|
+
if provider == "gemini":
|
|
585
|
+
config["api_key"] = click.prompt("Gemini API Key (Leave empty to use GEMINI_API_KEY env var)", default="", hide_input=True)
|
|
586
|
+
config["model"] = click.prompt("Model", default="gemini-2.0-flash")
|
|
587
|
+
|
|
588
|
+
elif provider == "openai":
|
|
589
|
+
config["api_key"] = click.prompt("OpenAI API Key (Leave empty to use OPENAI_API_KEY env var)", default="", hide_input=True)
|
|
590
|
+
config["model"] = click.prompt("Model", default="gpt-4o-mini")
|
|
591
|
+
|
|
592
|
+
elif provider == "ollama":
|
|
593
|
+
base_url = click.prompt("Ollama Base URL", default="http://localhost:11434")
|
|
594
|
+
config["base_url"] = base_url
|
|
595
|
+
|
|
596
|
+
click.echo("Fetching available models from Ollama...")
|
|
597
|
+
models = get_ollama_models(base_url)
|
|
598
|
+
|
|
599
|
+
if models:
|
|
600
|
+
click.echo(click.style("\nAvailable Ollama Models:", fg="green"))
|
|
601
|
+
for i, m in enumerate(models):
|
|
602
|
+
click.echo(f" {i+1}. {m}")
|
|
603
|
+
|
|
604
|
+
choice = click.prompt("\nSelect a model by number or type model name manually", type=str)
|
|
605
|
+
if choice.isdigit() and 1 <= int(choice) <= len(models):
|
|
606
|
+
config["model"] = models[int(choice)-1]
|
|
607
|
+
else:
|
|
608
|
+
config["model"] = choice
|
|
609
|
+
else:
|
|
610
|
+
click.echo(click.style("Could not auto-fetch models.", fg="yellow"))
|
|
611
|
+
config["model"] = click.prompt("Model Name (e.g. llama3:8b, mistral, deepseek-r1)", default="llama3:latest")
|
|
612
|
+
|
|
613
|
+
save_config(config)
|
|
614
|
+
click.echo(click.style(f"\nβ
Configuration saved successfully to {CONFIG_FILE}!", fg="bright_green", bold=True))
|
|
615
|
+
click.echo(click.style(f"Current setup -> Provider: {provider}, Model: {config['model']}", fg="green"))
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
@cli.command("commit")
|
|
619
|
+
@click.option(
|
|
620
|
+
"--repo",
|
|
621
|
+
"-r",
|
|
622
|
+
default=".",
|
|
623
|
+
show_default=True,
|
|
624
|
+
help="Path to the git repository.",
|
|
625
|
+
)
|
|
626
|
+
@click.option(
|
|
627
|
+
"--verbose",
|
|
628
|
+
"-v",
|
|
629
|
+
is_flag=True,
|
|
630
|
+
default=False,
|
|
631
|
+
help="Show extra debug information.",
|
|
632
|
+
)
|
|
633
|
+
@click.option(
|
|
634
|
+
"--suggestions",
|
|
635
|
+
"-s",
|
|
636
|
+
type=click.IntRange(1, 5),
|
|
637
|
+
default=3,
|
|
638
|
+
show_default=True,
|
|
639
|
+
help="How many AI commit message suggestions to generate.",
|
|
640
|
+
)
|
|
641
|
+
def commit_command(repo: str, verbose: bool, suggestions: int):
|
|
642
|
+
"""Stage, generate a commit message, and commit all changes."""
|
|
643
|
+
repo_path = os.path.abspath(repo)
|
|
644
|
+
|
|
645
|
+
if not os.path.isdir(os.path.join(repo_path, ".git")):
|
|
646
|
+
raise click.ClickException(
|
|
647
|
+
f"'{repo_path}' is not a valid git repository (no .git folder found)."
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
click.echo(click.style(
|
|
651
|
+
f"\nπ Repository: {repo_path}", fg="bright_cyan", bold=True
|
|
652
|
+
))
|
|
653
|
+
|
|
654
|
+
try:
|
|
655
|
+
asyncio.run(run_agent_commit(repo_path, verbose, suggestions))
|
|
656
|
+
except KeyboardInterrupt:
|
|
657
|
+
click.echo(click.style("\n\nβ‘ Interrupted by user.", fg="yellow"))
|
|
658
|
+
sys.exit(0)
|
|
659
|
+
except Exception as e:
|
|
660
|
+
raise click.ClickException(str(e))
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
@cli.command("push")
|
|
664
|
+
@click.option("--remote", default="origin", show_default=True, help="Remote name.")
|
|
665
|
+
@click.option("--branch", default=None, help="Branch to push (defaults to current).")
|
|
666
|
+
@click.option("--repo", "-r", default=".", show_default=True, help="Repo path.")
|
|
667
|
+
def push_command(remote: str, branch: str, repo: str):
|
|
668
|
+
"""Push committed changes to the remote repository."""
|
|
669
|
+
import subprocess
|
|
670
|
+
repo_path = os.path.abspath(repo)
|
|
671
|
+
|
|
672
|
+
if not os.path.isdir(os.path.join(repo_path, ".git")):
|
|
673
|
+
raise click.ClickException(f"'{repo_path}' is not a valid git repository.")
|
|
674
|
+
|
|
675
|
+
# Check if a remote exists
|
|
676
|
+
try:
|
|
677
|
+
remotes = subprocess.check_output(["git", "remote", "-v"], cwd=repo_path, text=True)
|
|
678
|
+
if not remotes.strip():
|
|
679
|
+
raise click.ClickException(
|
|
680
|
+
"No git remotes configured! You need to add a remote first.\n"
|
|
681
|
+
"Example: git remote add origin https://github.com/user/repo.git"
|
|
682
|
+
)
|
|
683
|
+
except subprocess.CalledProcessError:
|
|
684
|
+
raise click.ClickException("Failed to check git remotes.")
|
|
685
|
+
|
|
686
|
+
click.echo(click.style(
|
|
687
|
+
f"π Pushing to {remote} {branch or '(current branch)'}...", fg="cyan"
|
|
688
|
+
))
|
|
689
|
+
|
|
690
|
+
cmd = ["git", "push", remote]
|
|
691
|
+
if branch:
|
|
692
|
+
cmd.append(branch)
|
|
693
|
+
|
|
694
|
+
try:
|
|
695
|
+
# We run it directly so SSH / password prompts work nicely in the terminal
|
|
696
|
+
result = subprocess.run(cmd, cwd=repo_path)
|
|
697
|
+
if result.returncode == 0:
|
|
698
|
+
click.echo(click.style("\nβ
Push complete!", fg="bright_green", bold=True))
|
|
699
|
+
else:
|
|
700
|
+
sys.exit(result.returncode)
|
|
701
|
+
except KeyboardInterrupt:
|
|
702
|
+
click.echo(click.style("\nβ‘ Interrupted.", fg="yellow"))
|
|
703
|
+
except Exception as e:
|
|
704
|
+
raise click.ClickException(f"Failed to execute git push: {e}")
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
|
708
|
+
# Entrypoint
|
|
709
|
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
|
710
|
+
|
|
711
|
+
def main():
|
|
712
|
+
cli()
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
if __name__ == "__main__":
|
|
716
|
+
main()
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agent-gitv1
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AI-powered Git CLI using MCP + Gemini to auto-generate commit messages
|
|
5
|
+
Author-email: Vijay <you@example.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: ai,automation,cli,gemini,git,mcp
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: click>=8.1
|
|
10
|
+
Requires-Dist: google-genai>=0.8.0
|
|
11
|
+
Requires-Dist: mcp[cli]>=1.0.0
|
|
12
|
+
Requires-Dist: openai>=1.14.0
|
|
13
|
+
Requires-Dist: requests>=2.31.0
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# agent-gitv1 π€
|
|
17
|
+
|
|
18
|
+
> **AI-powered Git CLI** β Uses [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) + Google Gemini to automatically generate professional commit messages from your diff.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## How It Works
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
Your Code Changes
|
|
26
|
+
β
|
|
27
|
+
βΌ
|
|
28
|
+
[MCP Client] ββstdioβββΊ [mcp-server-git]
|
|
29
|
+
β β
|
|
30
|
+
ββββ git diff ββββββββββββ
|
|
31
|
+
β
|
|
32
|
+
βΌ
|
|
33
|
+
[Gemini AI] βββΊ Generate commit message
|
|
34
|
+
β
|
|
35
|
+
βΌ
|
|
36
|
+
[MCP Client] βββΊ git add . βββΊ git commit
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Prerequisites
|
|
42
|
+
|
|
43
|
+
| Tool | Install |
|
|
44
|
+
|------|---------|
|
|
45
|
+
| Python β₯ 3.10 | [python.org](https://python.org) |
|
|
46
|
+
| `uvx` (uv tool runner) | `pip install uv` |
|
|
47
|
+
| `mcp-server-git` | Auto-fetched by `uvx` |
|
|
48
|
+
| Gemini API Key | [aistudio.google.com](https://aistudio.google.com) |
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Installation
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# 1. Clone / navigate to the project
|
|
56
|
+
cd "d:\Vijay Projects\Agent_bhai"
|
|
57
|
+
|
|
58
|
+
# 2. Install in editable mode (creates the `agent` command globally)
|
|
59
|
+
pip install -e .
|
|
60
|
+
|
|
61
|
+
# 3. Set your Gemini API key
|
|
62
|
+
set GEMINI_API_KEY=your_gemini_api_key_here # Windows CMD
|
|
63
|
+
$env:GEMINI_API_KEY="your_key" # Windows PowerShell
|
|
64
|
+
export GEMINI_API_KEY=your_gemini_api_key_here # Linux / Mac
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Usage
|
|
70
|
+
|
|
71
|
+
### `agent config` β Configure LLM Provider (Start Here!)
|
|
72
|
+
|
|
73
|
+
Run this to configure OpenAI, Google Gemini, or Ollama.
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
agent config
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
What you can configure:
|
|
80
|
+
- **Google Gemini**: Uses your API key and a model like `gemini-2.0-flash`.
|
|
81
|
+
- **OpenAI (ChatGPT)**: Uses your API key and a model like `gpt-4o-mini`.
|
|
82
|
+
- **Ollama (Local/Network)**: Provide the base URL (e.g. `http://localhost:11434` or `http://192.168.1.50:11434`). The CLI will automatically fetch your downloaded models and let you choose one!
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
### `agent commit` β Stage + AI commit message + commit
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
# In any git repo:
|
|
90
|
+
agent commit
|
|
91
|
+
|
|
92
|
+
# Specify a repo path:
|
|
93
|
+
agent commit --repo /path/to/repo
|
|
94
|
+
|
|
95
|
+
# Generate multiple suggestions (pick one or type your own):
|
|
96
|
+
agent commit --suggestions 3
|
|
97
|
+
|
|
98
|
+
# Verbose mode (shows diff preview + available MCP tools):
|
|
99
|
+
agent commit --verbose
|
|
100
|
+
agent commit -v
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`agent commit` is now history-aware:
|
|
104
|
+
- It uses recent commit messages from your repo to align tone/style.
|
|
105
|
+
- It includes changed file names in the LLM prompt for better scoped messages.
|
|
106
|
+
|
|
107
|
+
### `agent push` β Push to remote
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
agent push
|
|
111
|
+
agent push --remote origin --branch main
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Help
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
agent --help
|
|
118
|
+
agent config --help
|
|
119
|
+
agent commit --help
|
|
120
|
+
agent push --help
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## Example Session
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
π Repository: D:\my-project
|
|
129
|
+
|
|
130
|
+
π Connecting to mcp-server-git...
|
|
131
|
+
β
MCP session initialized.
|
|
132
|
+
|
|
133
|
+
π Fetching git diff (unstaged changes)...
|
|
134
|
+
Diff captured (1240 chars).
|
|
135
|
+
|
|
136
|
+
π€ Generating commit message with Gemini...
|
|
137
|
+
|
|
138
|
+
π¬ Commit Message: feat(auth): add JWT token refresh endpoint
|
|
139
|
+
|
|
140
|
+
Proceed with git add + commit? [Y/n]: y
|
|
141
|
+
|
|
142
|
+
π¦ Staging all changes (git add .)...
|
|
143
|
+
Files staged.
|
|
144
|
+
|
|
145
|
+
βοΈ Committing...
|
|
146
|
+
[main a3f12bc] feat(auth): add JWT token refresh endpoint
|
|
147
|
+
|
|
148
|
+
π Done! Changes committed successfully.
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## Environment Variables
|
|
154
|
+
|
|
155
|
+
| Variable | Required | Description |
|
|
156
|
+
|----------|----------|-------------|
|
|
157
|
+
| `GEMINI_API_KEY` | β
Yes | Your Google Gemini API key |
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## Project Structure
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
Agent_bhai/
|
|
165
|
+
βββ agent.py # Main CLI + MCP client logic
|
|
166
|
+
βββ pyproject.toml # Packaging + entry point config
|
|
167
|
+
βββ README.md # This file
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## License
|
|
173
|
+
|
|
174
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
./PKG-INFO,sha256=3ALyKD-dRyRpnDjxZfSJMMQPOqDEhEkQzjoc43-efL0,3927
|
|
2
|
+
./README.md,sha256=-CrDrXOXqj7_2dkk_VXRZTUx9E4ZEXu3G5Ti3naLY7w,3479
|
|
3
|
+
./agent.py,sha256=xr89SLvDhSQcGbGvz624kcCPXGzKJbzuTy_mQ6xbPlE,27165
|
|
4
|
+
./pyproject.toml,sha256=4hDiJymLhMU0SL6ETqSOcbusbwQNGU6zSQhe9A9crJc,769
|
|
5
|
+
agent_gitv1-0.1.0.dist-info/METADATA,sha256=3ALyKD-dRyRpnDjxZfSJMMQPOqDEhEkQzjoc43-efL0,3927
|
|
6
|
+
agent_gitv1-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
7
|
+
agent_gitv1-0.1.0.dist-info/entry_points.txt,sha256=cbPAl9C0KyMpkeNPgYr9JclssqVmdDzFPmq0DQ4PmAs,37
|
|
8
|
+
agent_gitv1-0.1.0.dist-info/RECORD,,
|
pyproject.toml
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "agent-gitv1"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "AI-powered Git CLI using MCP + Gemini to auto-generate commit messages"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Vijay", email = "you@example.com" }
|
|
14
|
+
]
|
|
15
|
+
keywords = ["git", "mcp", "gemini", "ai", "cli", "automation"]
|
|
16
|
+
|
|
17
|
+
dependencies = [
|
|
18
|
+
"click>=8.1",
|
|
19
|
+
"mcp[cli]>=1.0.0",
|
|
20
|
+
"google-genai>=0.8.0",
|
|
21
|
+
"openai>=1.14.0",
|
|
22
|
+
"requests>=2.31.0",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.scripts]
|
|
26
|
+
# This is the magic line:
|
|
27
|
+
# Typing `agent` in terminal calls the `main()` function in agent.py
|
|
28
|
+
agent = "agent:main"
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["."]
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build]
|
|
34
|
+
include = [
|
|
35
|
+
"agent.py",
|
|
36
|
+
]
|