messygit 0.1.3__tar.gz → 0.2.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.
- messygit-0.2.0/.claude/worktrees/frosty-montalcini-03d71b/.claude/settings.local.json +7 -0
- messygit-0.2.0/.claude/worktrees/frosty-montalcini-03d71b/.git +1 -0
- messygit-0.2.0/.claude/worktrees/frosty-montalcini-03d71b/messygit/git.py +240 -0
- {messygit-0.1.3 → messygit-0.2.0/.claude/worktrees/frosty-montalcini-03d71b}/messygit/prompts.py +11 -2
- {messygit-0.1.3 → messygit-0.2.0/.claude/worktrees/frosty-montalcini-03d71b}/pyproject.toml +1 -1
- messygit-0.2.0/.gitignore +5 -0
- messygit-0.2.0/ARCHITECTURE.md +48 -0
- {messygit-0.1.3 → messygit-0.2.0}/PKG-INFO +2 -2
- messygit-0.2.0/README.md +105 -0
- messygit-0.2.0/messygit/__init__.py +0 -0
- messygit-0.2.0/messygit/agent/agent.py +79 -0
- messygit-0.2.0/messygit/agent/tool.py +28 -0
- messygit-0.2.0/messygit/agent/tools.py +82 -0
- messygit-0.2.0/messygit/cli.py +283 -0
- messygit-0.2.0/messygit/config.py +104 -0
- messygit-0.2.0/messygit/git.py +256 -0
- messygit-0.2.0/messygit/llm.py +112 -0
- messygit-0.2.0/messygit/prompts.py +108 -0
- messygit-0.2.0/pyproject.toml +18 -0
- messygit-0.1.3/messygit/git.py +0 -109
- {messygit-0.1.3 → messygit-0.2.0/.claude/worktrees/frosty-montalcini-03d71b}/.gitignore +0 -0
- {messygit-0.1.3 → messygit-0.2.0/.claude/worktrees/frosty-montalcini-03d71b}/README.md +0 -0
- {messygit-0.1.3 → messygit-0.2.0/.claude/worktrees/frosty-montalcini-03d71b}/messygit/__init__.py +0 -0
- {messygit-0.1.3 → messygit-0.2.0/.claude/worktrees/frosty-montalcini-03d71b}/messygit/cli.py +0 -0
- {messygit-0.1.3 → messygit-0.2.0/.claude/worktrees/frosty-montalcini-03d71b}/messygit/config.py +0 -0
- {messygit-0.1.3 → messygit-0.2.0/.claude/worktrees/frosty-montalcini-03d71b}/messygit/llm.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
gitdir: /Users/jaydentan/Desktop/projects/messygit/.git/worktrees/frosty-montalcini-03d71b
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from subprocess import CompletedProcess
|
|
7
|
+
|
|
8
|
+
TOKEN_CHAR_ESTIMATE = 4
|
|
9
|
+
MAX_CONTEXT_TOKENS = 60_000
|
|
10
|
+
MAX_CONTEXT_CHARS = MAX_CONTEXT_TOKENS * TOKEN_CHAR_ESTIMATE
|
|
11
|
+
|
|
12
|
+
NOISE_PATTERNS: tuple[str, ...] = (
|
|
13
|
+
"package-lock.json",
|
|
14
|
+
"yarn.lock",
|
|
15
|
+
"pnpm-lock.yaml",
|
|
16
|
+
"Pipfile.lock",
|
|
17
|
+
"poetry.lock",
|
|
18
|
+
"Cargo.lock",
|
|
19
|
+
"composer.lock",
|
|
20
|
+
"Gemfile.lock",
|
|
21
|
+
"go.sum",
|
|
22
|
+
".DS_Store",
|
|
23
|
+
"Thumbs.db",
|
|
24
|
+
"*.min.js",
|
|
25
|
+
"*.min.css",
|
|
26
|
+
"*.map",
|
|
27
|
+
"*.bundle.js",
|
|
28
|
+
"*.chunk.js",
|
|
29
|
+
"*.pb.go",
|
|
30
|
+
"*.generated.*",
|
|
31
|
+
"*.snap",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
_DIFF_FILE_HEADER = re.compile(r"^diff --git a/.+ b/(.+)$")
|
|
35
|
+
_HUNK_HEADER = re.compile(r"^@@\s")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _is_noise_file(path: str) -> bool:
|
|
39
|
+
"""Return True if path matches a common build/generated pattern we always skip."""
|
|
40
|
+
from fnmatch import fnmatch
|
|
41
|
+
|
|
42
|
+
name = path.rsplit("/", 1)[-1]
|
|
43
|
+
for pattern in NOISE_PATTERNS:
|
|
44
|
+
if fnmatch(name, pattern) or fnmatch(path, pattern):
|
|
45
|
+
return True
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _parse_compact_diff(raw_diff: str) -> str:
|
|
50
|
+
"""Parse a -U0 unified diff into a compact per-file changed-lines format.
|
|
51
|
+
|
|
52
|
+
Output looks like:
|
|
53
|
+
|
|
54
|
+
=== path/to/file.py ===
|
|
55
|
+
+ added line
|
|
56
|
+
- removed line
|
|
57
|
+
=== another/file.ts ===
|
|
58
|
+
+ another addition
|
|
59
|
+
"""
|
|
60
|
+
lines = raw_diff.splitlines()
|
|
61
|
+
out: list[str] = []
|
|
62
|
+
current_file: str | None = None
|
|
63
|
+
skip_file = False
|
|
64
|
+
|
|
65
|
+
for line in lines:
|
|
66
|
+
header_match = _DIFF_FILE_HEADER.match(line)
|
|
67
|
+
if header_match:
|
|
68
|
+
current_file = header_match.group(1)
|
|
69
|
+
skip_file = _is_noise_file(current_file)
|
|
70
|
+
if not skip_file:
|
|
71
|
+
out.append(f"\n=== {current_file} ===")
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
if skip_file:
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
if _HUNK_HEADER.match(line):
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
if line.startswith("+") and not line.startswith("+++"):
|
|
81
|
+
out.append(line)
|
|
82
|
+
elif line.startswith("-") and not line.startswith("---"):
|
|
83
|
+
out.append(line)
|
|
84
|
+
|
|
85
|
+
return "\n".join(out).strip()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class FileStat:
|
|
90
|
+
path: str
|
|
91
|
+
added: int
|
|
92
|
+
removed: int
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def total_changed(self) -> int:
|
|
96
|
+
return self.added + self.removed
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
_STAT_LINE_RE = re.compile(
|
|
100
|
+
r"^\s*(\d+)\s+(\d+)\s+(.+)$"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _get_raw_staged_diff() -> str:
|
|
105
|
+
result = subprocess.run(
|
|
106
|
+
["git", "diff", "--cached", "-U0"],
|
|
107
|
+
capture_output=True,
|
|
108
|
+
text=True,
|
|
109
|
+
)
|
|
110
|
+
return result.stdout
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _get_staged_numstat() -> list[FileStat]:
|
|
114
|
+
"""Run git diff --cached --numstat and parse per-file added/removed counts."""
|
|
115
|
+
result = subprocess.run(
|
|
116
|
+
["git", "diff", "--cached", "--numstat"],
|
|
117
|
+
capture_output=True,
|
|
118
|
+
text=True,
|
|
119
|
+
)
|
|
120
|
+
stats: list[FileStat] = []
|
|
121
|
+
for line in result.stdout.strip().splitlines():
|
|
122
|
+
match = _STAT_LINE_RE.match(line)
|
|
123
|
+
if not match:
|
|
124
|
+
continue
|
|
125
|
+
added_str, removed_str, path = match.groups()
|
|
126
|
+
if added_str == "-" or removed_str == "-":
|
|
127
|
+
continue
|
|
128
|
+
path = path.strip()
|
|
129
|
+
if _is_noise_file(path):
|
|
130
|
+
continue
|
|
131
|
+
stats.append(FileStat(path=path, added=int(added_str), removed=int(removed_str)))
|
|
132
|
+
return stats
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _get_stat_summary() -> str:
|
|
136
|
+
"""Run git diff --cached --stat and return the summary string."""
|
|
137
|
+
result = subprocess.run(
|
|
138
|
+
["git", "diff", "--cached", "--stat"],
|
|
139
|
+
capture_output=True,
|
|
140
|
+
text=True,
|
|
141
|
+
)
|
|
142
|
+
return result.stdout.strip()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _compact_diff_for_files(paths: set[str], raw_diff: str) -> str:
|
|
146
|
+
"""Extract compact changed lines only for the given file paths."""
|
|
147
|
+
raw_lines = raw_diff.splitlines()
|
|
148
|
+
collected: list[str] = []
|
|
149
|
+
active_path: str | None = None
|
|
150
|
+
include = False
|
|
151
|
+
|
|
152
|
+
for raw_line in raw_lines:
|
|
153
|
+
file_match = _DIFF_FILE_HEADER.match(raw_line)
|
|
154
|
+
if file_match:
|
|
155
|
+
active_path = file_match.group(1)
|
|
156
|
+
include = active_path in paths
|
|
157
|
+
if include:
|
|
158
|
+
collected.append(f"\n=== {active_path} ===")
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
if not include:
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
if _HUNK_HEADER.match(raw_line):
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
if raw_line.startswith("+") and not raw_line.startswith("+++"):
|
|
168
|
+
collected.append(raw_line)
|
|
169
|
+
elif raw_line.startswith("-") and not raw_line.startswith("---"):
|
|
170
|
+
collected.append(raw_line)
|
|
171
|
+
|
|
172
|
+
return "\n".join(collected).strip()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def build_staged_context() -> str:
|
|
176
|
+
"""Build the context string sent to the LLM.
|
|
177
|
+
|
|
178
|
+
If the full compact diff fits within the token budget, return it as-is.
|
|
179
|
+
Otherwise, fall back to:
|
|
180
|
+
- The full --stat summary (file list with bar chart)
|
|
181
|
+
- Full compact diff of only the most-changed files that fit the budget
|
|
182
|
+
"""
|
|
183
|
+
raw_diff = _get_raw_staged_diff()
|
|
184
|
+
full_compact = _parse_compact_diff(raw_diff)
|
|
185
|
+
|
|
186
|
+
if len(full_compact) <= MAX_CONTEXT_CHARS:
|
|
187
|
+
return full_compact
|
|
188
|
+
|
|
189
|
+
stat_summary = _get_stat_summary()
|
|
190
|
+
file_stats = _get_staged_numstat()
|
|
191
|
+
file_stats.sort(key=lambda fs: fs.total_changed, reverse=True)
|
|
192
|
+
|
|
193
|
+
header = (
|
|
194
|
+
"This diff was too large to include in full. "
|
|
195
|
+
"Below is the complete --stat summary followed by the full changed lines "
|
|
196
|
+
"of the most-changed files.\n\n"
|
|
197
|
+
f"--- stat summary ---\n{stat_summary}\n\n"
|
|
198
|
+
"--- most-changed files (full changed lines) ---\n"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
budget = MAX_CONTEXT_CHARS - len(header)
|
|
202
|
+
selected_paths: set[str] = set()
|
|
203
|
+
for fs in file_stats:
|
|
204
|
+
file_diff = _compact_diff_for_files({fs.path}, raw_diff)
|
|
205
|
+
if len(file_diff) > budget:
|
|
206
|
+
continue
|
|
207
|
+
selected_paths.add(fs.path)
|
|
208
|
+
budget -= len(file_diff)
|
|
209
|
+
|
|
210
|
+
if not selected_paths:
|
|
211
|
+
return header.strip()
|
|
212
|
+
|
|
213
|
+
top_files_diff = _compact_diff_for_files(selected_paths, raw_diff)
|
|
214
|
+
return f"{header}{top_files_diff}"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def get_staged_diff() -> str:
|
|
218
|
+
"""Return a compact, changed-lines-only representation of staged changes."""
|
|
219
|
+
return build_staged_context()
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def get_staged_files() -> list[str]:
|
|
223
|
+
"""Return list of staged file paths, excluding noise files."""
|
|
224
|
+
result = subprocess.run(
|
|
225
|
+
["git", "diff", "--cached", "--name-only"],
|
|
226
|
+
capture_output=True,
|
|
227
|
+
text=True,
|
|
228
|
+
)
|
|
229
|
+
files = result.stdout.strip()
|
|
230
|
+
if not files:
|
|
231
|
+
return []
|
|
232
|
+
return [f for f in files.split("\n") if not _is_noise_file(f)]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def git_commit(message: str) -> CompletedProcess[str]:
|
|
236
|
+
return subprocess.run(
|
|
237
|
+
["git", "commit", "-m", message],
|
|
238
|
+
capture_output=True,
|
|
239
|
+
text=True,
|
|
240
|
+
)
|
{messygit-0.1.3 → messygit-0.2.0/.claude/worktrees/frosty-montalcini-03d71b}/messygit/prompts.py
RENAMED
|
@@ -3,9 +3,9 @@ You are a git commit message generator. Your sole purpose is to produce \
|
|
|
3
3
|
a single Conventional Commits subject line from staged changes.
|
|
4
4
|
|
|
5
5
|
# Input format
|
|
6
|
-
You will receive
|
|
7
|
-
The format is:
|
|
6
|
+
You will receive staged changes in one of two formats:
|
|
8
7
|
|
|
8
|
+
## Format A — full compact diff (small changes)
|
|
9
9
|
=== path/to/file.py ===
|
|
10
10
|
+ added line
|
|
11
11
|
- removed line
|
|
@@ -16,6 +16,15 @@ Each "=== filename ===" header marks the file that the following +/- lines \
|
|
|
16
16
|
belong to. Lines starting with "+" were added; lines starting with "-" were \
|
|
17
17
|
removed. Context lines and diff metadata are already stripped.
|
|
18
18
|
|
|
19
|
+
## Format B — truncated large diff
|
|
20
|
+
When the diff exceeds the token budget, you receive:
|
|
21
|
+
1. A note explaining the diff was too large.
|
|
22
|
+
2. The complete `git diff --stat` summary (file list with insertions/deletions bar chart).
|
|
23
|
+
3. Full changed lines for the most-changed files only.
|
|
24
|
+
|
|
25
|
+
Use the stat summary to understand the overall scope, then use the detailed \
|
|
26
|
+
changed lines to infer what the commit actually does.
|
|
27
|
+
|
|
19
28
|
# Output rules (absolute, no exceptions)
|
|
20
29
|
- Output EXACTLY one line: type(scope): description
|
|
21
30
|
- No markdown, no quotes, no code fences, no bullet points, no explanation.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "messygit"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.4"
|
|
8
8
|
description = "CLI that drafts Conventional Commits from staged git diffs with Claude, then commit, cancel, or edit."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
This document describes the purpose of each file in the `messygit` project.
|
|
4
|
+
|
|
5
|
+
## Root
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
| File | Purpose |
|
|
9
|
+
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
|
10
|
+
| `pyproject.toml` | Package metadata, dependencies (`anthropic`, `click`), build system (hatchling), and the `messygit` console script entrypoint. |
|
|
11
|
+
| `README.md` | User-facing documentation: install, usage, commands, and development instructions. |
|
|
12
|
+
| `.gitignore` | Keeps `.venv/`, `__pycache__/`, `dist/`, and `*.egg-info/` out of version control. |
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
## `messygit/` (Python package)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
| File | Purpose |
|
|
19
|
+
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
20
|
+
| `__init__.py` | Marks the directory as a Python package (empty). |
|
|
21
|
+
| `cli.py` | Click CLI entrypoint. Defines the command group (`main`), the default commit flow (generate → prompt Y/n/e → commit), and subcommands (`config`, `show`). Orchestrates all other modules. |
|
|
22
|
+
| `git.py` | All subprocess calls to `git`. Reads staged diffs (`git diff --cached -U0`), parses them into a compact changed-lines format, filters noise files, handles the large-diff fallback (stat summary + top-N most-changed files), and runs `git commit -m`. |
|
|
23
|
+
| `llm.py` | Anthropic SDK integration. Creates the client with the resolved API key, calls `messages.create`, extracts the text response, and maps SDK exceptions (`AuthenticationError`, `PermissionDeniedError`, `BadRequestError`, billing 402) into user-friendly error classes. |
|
|
24
|
+
| `config.py` | API key storage and resolution. Reads/writes `~/.messygit/config.json`, checks the `ANTHROPIC_API_KEY` env var, validates keys are non-empty, masks keys for display, and defines all user-facing error messages and exception classes. |
|
|
25
|
+
| `prompts.py` | System prompt and user prompt builder. Contains the full Conventional Commits instructions, input format descriptions (full and truncated), security rules, and the function that wraps staged changes into the user message sent to Claude. |
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
## Data flow
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
User runs `messygit`
|
|
32
|
+
│
|
|
33
|
+
▼
|
|
34
|
+
cli.py ──► git.py (read staged diff, apply token budget)
|
|
35
|
+
│
|
|
36
|
+
▼
|
|
37
|
+
cli.py ──► llm.py (send context to Claude)
|
|
38
|
+
│ │
|
|
39
|
+
│ ├── config.py (resolve API key)
|
|
40
|
+
│ └── prompts.py (system + user prompt)
|
|
41
|
+
│
|
|
42
|
+
▼
|
|
43
|
+
cli.py (display message, prompt Y/n/e)
|
|
44
|
+
│
|
|
45
|
+
▼
|
|
46
|
+
cli.py ──► git.py (git commit -m "...")
|
|
47
|
+
```
|
|
48
|
+
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: messygit
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: CLI that
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Interactive CLI that turns messy git workflows into clean Conventional Commits — stage, commit, push, as well as agentic functionality, all from one interface.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Requires-Python: >=3.10
|
|
7
7
|
Requires-Dist: anthropic>=0.39.0
|
messygit-0.2.0/README.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# messygit
|
|
2
|
+
|
|
3
|
+
**messygit** is a command-line tool that reads your **staged** Git changes, asks **Claude** (via the [Anthropic API](https://www.anthropic.com/api)) to suggest a **Conventional Commits** subject line, and then lets you **commit**, **cancel**, or **edit** the message before running `git commit`.
|
|
4
|
+
|
|
5
|
+
## Why use it
|
|
6
|
+
|
|
7
|
+
- Keeps commit subjects consistent (`feat(scope): describe the change`) without thinking up wording from scratch.
|
|
8
|
+
- Only the **staged** diff is sent to the model—what you `git add` is what gets summarized.
|
|
9
|
+
- The API key is never printed in full; `show` uses a masked preview.
|
|
10
|
+
- Clear errors for missing keys, rejected keys, and billing or zero-balance situations.
|
|
11
|
+
|
|
12
|
+
## Requirements
|
|
13
|
+
|
|
14
|
+
- **Python** 3.10 or newer
|
|
15
|
+
- **Git** (run inside a repository)
|
|
16
|
+
- An **Anthropic API key** with access to the Messages API
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install messygit
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
This installs the `messygit` command (see `[project.scripts]` in `pyproject.toml`).
|
|
25
|
+
|
|
26
|
+
### Install from source
|
|
27
|
+
|
|
28
|
+
From a checkout of this project:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
cd messygit
|
|
32
|
+
python -m venv .venv
|
|
33
|
+
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
|
34
|
+
pip install -e .
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## API key
|
|
38
|
+
|
|
39
|
+
messygit resolves the key in this order:
|
|
40
|
+
|
|
41
|
+
1. Environment variable **`ANTHROPIC_API_KEY`**
|
|
42
|
+
2. Config file **`~/.messygit/config.json`** (written by `messygit config`)
|
|
43
|
+
|
|
44
|
+
If neither is set, the default command exits with a short message explaining how to fix it.
|
|
45
|
+
|
|
46
|
+
**Save a key to the config file:**
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
messygit config --key YOUR_ANTHROPIC_API_KEY
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**Show a masked key** (which source is active, without revealing the secret):
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
messygit show
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Usage
|
|
59
|
+
|
|
60
|
+
Typical flow:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
git add .
|
|
64
|
+
messygit
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
1. If there is nothing staged, messygit tells you to run `git add` first.
|
|
68
|
+
2. Otherwise it sends the staged diff to Claude and prints a suggested one-line message.
|
|
69
|
+
3. You are prompted: **commit** (default), **no** (cancel), or **edit** (open `$EDITOR` to change the message).
|
|
70
|
+
4. On confirmation, it runs `git commit -m "..."` with your chosen text.
|
|
71
|
+
|
|
72
|
+
### Commands
|
|
73
|
+
|
|
74
|
+
| Command | Description |
|
|
75
|
+
|--------|-------------|
|
|
76
|
+
| `messygit` | Generate a message from `git diff --staged`, then prompt to commit / cancel / edit. |
|
|
77
|
+
| `messygit config --key KEY` | Store the Anthropic API key under `~/.messygit/config.json`. |
|
|
78
|
+
| `messygit show` | Print a masked API key and whether it comes from the environment or config file. |
|
|
79
|
+
|
|
80
|
+
### Commit message style
|
|
81
|
+
|
|
82
|
+
The model is instructed to follow **Conventional Commits**, for example:
|
|
83
|
+
|
|
84
|
+
`feat(auth): validate refresh tokens`
|
|
85
|
+
|
|
86
|
+
Allowed types include: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`. Subjects are one line, imperative, lowercase, no trailing period, and kept within a reasonable length (see your prompts in the package if you customize behavior).
|
|
87
|
+
|
|
88
|
+
## Development
|
|
89
|
+
|
|
90
|
+
Without installing the package, from the **repository root** (the directory that contains the `messygit` package folder):
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
.venv/bin/python -m messygit.cli
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Subcommands:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
python -m messygit.cli config --key YOUR_KEY
|
|
100
|
+
python -m messygit.cli show
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## License
|
|
104
|
+
|
|
105
|
+
MIT (see `pyproject.toml`).
|
|
File without changes
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from .tool import Tool
|
|
2
|
+
from anthropic import (
|
|
3
|
+
Anthropic,
|
|
4
|
+
APIStatusError,
|
|
5
|
+
AuthenticationError,
|
|
6
|
+
BadRequestError,
|
|
7
|
+
PermissionDeniedError,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
from ..config import (
|
|
11
|
+
FORBIDDEN_API_KEY_MESSAGE,
|
|
12
|
+
INVALID_API_KEY_MESSAGE,
|
|
13
|
+
AnthropicInsufficientBalanceError,
|
|
14
|
+
InvalidAnthropicCredentialsError,
|
|
15
|
+
resolve_api_key,
|
|
16
|
+
)
|
|
17
|
+
from ..llm import _is_insufficient_balance_or_billing, _insufficient_balance_user_message, _text_from_message
|
|
18
|
+
|
|
19
|
+
DEFAULT_MODEL = "claude-haiku-4-5-20251001"
|
|
20
|
+
DEFAULT_MAX_TOKENS = 4096
|
|
21
|
+
|
|
22
|
+
class Agent:
|
|
23
|
+
def __init__(self, name: str, system_prompt: str, max_iterations: int, tools: list[Tool]):
|
|
24
|
+
self.name = name
|
|
25
|
+
self.system_prompt = system_prompt
|
|
26
|
+
self.max_iterations = max_iterations
|
|
27
|
+
self.tools = tools
|
|
28
|
+
|
|
29
|
+
def run(self, user_input: str) -> str:
|
|
30
|
+
"""Run the agent."""
|
|
31
|
+
client = Anthropic(api_key=resolve_api_key())
|
|
32
|
+
messages = []
|
|
33
|
+
try:
|
|
34
|
+
messages.append({"role": "user", "content": user_input})
|
|
35
|
+
response = None
|
|
36
|
+
for i in range(self.max_iterations):
|
|
37
|
+
response = client.messages.create(
|
|
38
|
+
model=DEFAULT_MODEL,
|
|
39
|
+
max_tokens=DEFAULT_MAX_TOKENS,
|
|
40
|
+
tools=[t.to_schema() for t in self.tools],
|
|
41
|
+
tool_choice={"type": "auto"},
|
|
42
|
+
system=self.system_prompt,
|
|
43
|
+
messages=messages,
|
|
44
|
+
)
|
|
45
|
+
messages.append({"role": "assistant", "content": response.content})
|
|
46
|
+
|
|
47
|
+
tool_use_blocks = [b for b in response.content if b.type == "tool_use"]
|
|
48
|
+
if not tool_use_blocks:
|
|
49
|
+
break
|
|
50
|
+
|
|
51
|
+
tool_results = []
|
|
52
|
+
for block in tool_use_blocks:
|
|
53
|
+
tool = next(t for t in self.tools if t.name == block.name)
|
|
54
|
+
result = tool.run(**block.input)
|
|
55
|
+
tool_results.append({
|
|
56
|
+
"type": "tool_result",
|
|
57
|
+
"tool_use_id": block.id,
|
|
58
|
+
"content": str(result),
|
|
59
|
+
})
|
|
60
|
+
messages.append({"role": "user", "content": tool_results})
|
|
61
|
+
except AuthenticationError as e:
|
|
62
|
+
raise InvalidAnthropicCredentialsError(INVALID_API_KEY_MESSAGE) from e
|
|
63
|
+
except PermissionDeniedError as e:
|
|
64
|
+
raise InvalidAnthropicCredentialsError(FORBIDDEN_API_KEY_MESSAGE) from e
|
|
65
|
+
except BadRequestError as e:
|
|
66
|
+
if _is_insufficient_balance_or_billing(e):
|
|
67
|
+
raise AnthropicInsufficientBalanceError(
|
|
68
|
+
_insufficient_balance_user_message(e)
|
|
69
|
+
) from e
|
|
70
|
+
raise
|
|
71
|
+
except APIStatusError as e:
|
|
72
|
+
if _is_insufficient_balance_or_billing(e):
|
|
73
|
+
raise AnthropicInsufficientBalanceError(
|
|
74
|
+
_insufficient_balance_user_message(e)
|
|
75
|
+
) from e
|
|
76
|
+
raise
|
|
77
|
+
if not response:
|
|
78
|
+
return "No response from the agent."
|
|
79
|
+
return _text_from_message(response)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class Tool:
|
|
9
|
+
"""A tool that an Agent can invoke, wrapping a plain Python function."""
|
|
10
|
+
|
|
11
|
+
name: str
|
|
12
|
+
description: str
|
|
13
|
+
function: Callable[..., Any]
|
|
14
|
+
parameters: dict[str, Any] = field(default_factory=dict)
|
|
15
|
+
|
|
16
|
+
def run(self, **kwargs: Any) -> Any:
|
|
17
|
+
return self.function(**kwargs)
|
|
18
|
+
|
|
19
|
+
def to_schema(self) -> dict[str, Any]:
|
|
20
|
+
"""Return an Anthropic-compatible tool schema for API calls."""
|
|
21
|
+
return {
|
|
22
|
+
"name": self.name,
|
|
23
|
+
"description": self.description,
|
|
24
|
+
"input_schema": {
|
|
25
|
+
"type": "object",
|
|
26
|
+
"properties": self.parameters,
|
|
27
|
+
},
|
|
28
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from .tool import Tool
|
|
3
|
+
from ..git import get_staged_diff, get_staged_files
|
|
4
|
+
import subprocess
|
|
5
|
+
|
|
6
|
+
ALLOWED_GIT_COMMANDS = ["log", "diff", "status", "show", "status", "shortlog", "blame"]
|
|
7
|
+
|
|
8
|
+
def run_git(args: list[str]) -> str:
|
|
9
|
+
if not args or args[0] not in ALLOWED_GIT_COMMANDS:
|
|
10
|
+
return "Invalid git command."
|
|
11
|
+
result = subprocess.run(["git", *args], capture_output=True, text=True)
|
|
12
|
+
return result.stdout or result.stderr
|
|
13
|
+
|
|
14
|
+
run_git_tool = Tool(
|
|
15
|
+
name="run_git",
|
|
16
|
+
description="Run a git command",
|
|
17
|
+
function=run_git,
|
|
18
|
+
parameters={
|
|
19
|
+
"args": {
|
|
20
|
+
"type": "array",
|
|
21
|
+
"items": {"type": "string"},
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
def read_file(path: str) -> str:
|
|
27
|
+
try:
|
|
28
|
+
with open(path, "r") as file:
|
|
29
|
+
return file.read()
|
|
30
|
+
except FileNotFoundError:
|
|
31
|
+
return "File not found."
|
|
32
|
+
except PermissionError:
|
|
33
|
+
return "Permission denied."
|
|
34
|
+
except Exception as e:
|
|
35
|
+
return f"Error reading file: {e}"
|
|
36
|
+
|
|
37
|
+
read_file_tool = Tool(
|
|
38
|
+
name="read_file",
|
|
39
|
+
description="Read a file",
|
|
40
|
+
function=read_file,
|
|
41
|
+
parameters={
|
|
42
|
+
"path": {
|
|
43
|
+
"type": "string",
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def list_directory(path: str) -> list[str]:
|
|
49
|
+
try:
|
|
50
|
+
return os.listdir(path)
|
|
51
|
+
except FileNotFoundError:
|
|
52
|
+
return "Directory not found."
|
|
53
|
+
except PermissionError:
|
|
54
|
+
return "Permission denied."
|
|
55
|
+
except Exception as e:
|
|
56
|
+
return f"Error listing directory: {e}"
|
|
57
|
+
|
|
58
|
+
list_directory_tool = Tool(
|
|
59
|
+
name="list_directory",
|
|
60
|
+
description="List a directory",
|
|
61
|
+
function=list_directory,
|
|
62
|
+
parameters={
|
|
63
|
+
"path": {
|
|
64
|
+
"type": "string",
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def search_code(query: str) -> str:
|
|
70
|
+
result = subprocess.run(["git", "grep", "-n", query], capture_output=True, text=True)
|
|
71
|
+
return result.stdout or result.stderr
|
|
72
|
+
|
|
73
|
+
search_code_tool = Tool(
|
|
74
|
+
name="search_code",
|
|
75
|
+
description="Search the codebase for a query",
|
|
76
|
+
function=search_code,
|
|
77
|
+
parameters={
|
|
78
|
+
"query": {
|
|
79
|
+
"type": "string",
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
)
|