commit-agent-cli 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +101 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +575 -0
- package/package.json +55 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# commit-agent-cli
|
|
2
|
+
|
|
3
|
+
AI-powered git commit CLI that analyzes your changes and generates conventional commit messages using Claude 4.5.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **🤖 AI-Powered**: Uses Claude 4.5 (via Anthropic) with LangGraph for intelligent commit message generation.
|
|
8
|
+
- **🕵️ Agentic Exploration**: The AI agent can autonomously explore your codebase and git history:
|
|
9
|
+
- Read files to understand context
|
|
10
|
+
- List directories to understand project structure
|
|
11
|
+
- Check commit history to learn your project's conventions
|
|
12
|
+
- View staged, unstaged, and untracked files
|
|
13
|
+
- Examine detailed diffs for specific files
|
|
14
|
+
- **👁️ Transparent**: See exactly what the agent is doing with detailed console logs showing every file read, directory listed, and git command executed.
|
|
15
|
+
- **⚙️ Customizable**: First-time setup asks for your preferences:
|
|
16
|
+
- Use conventional commit prefixes (feat:, fix:, chore:, etc.) or natural language
|
|
17
|
+
- Choose between concise or descriptive commit messages
|
|
18
|
+
- **✨ Interactive**: Beautiful CLI interface built with `@clack/prompts`.
|
|
19
|
+
- **🔒 Secure**: API keys and preferences are stored locally in `~/.commit-cli.json`.
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install -g commit-agent-cli
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
1. Stage your changes:
|
|
30
|
+
```bash
|
|
31
|
+
git add .
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
2. Run the tool:
|
|
35
|
+
```bash
|
|
36
|
+
commit
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
3. **First Time Setup**:
|
|
40
|
+
- You'll be prompted to enter your Anthropic API Key (`sk-...`)
|
|
41
|
+
- Choose whether to use conventional commit prefixes
|
|
42
|
+
- Select your preferred commit message style (concise vs descriptive)
|
|
43
|
+
- These preferences are saved and can be changed by editing `~/.commit-cli.json`
|
|
44
|
+
|
|
45
|
+
4. **Watch the Agent Work**:
|
|
46
|
+
The CLI will show you exactly what the agent is doing:
|
|
47
|
+
```
|
|
48
|
+
📜 Agent is checking last 10 commits
|
|
49
|
+
📋 Agent is listing staged files
|
|
50
|
+
🔍 Agent is reading: src/index.ts
|
|
51
|
+
📂 Agent is listing directory: src
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
5. **Review and Commit**:
|
|
55
|
+
- Review the generated commit message
|
|
56
|
+
- Choose to commit, regenerate, or cancel
|
|
57
|
+
- Optionally push changes immediately
|
|
58
|
+
|
|
59
|
+
## Agent Capabilities
|
|
60
|
+
|
|
61
|
+
The AI agent has access to these tools to understand your codebase:
|
|
62
|
+
|
|
63
|
+
- **read_file**: Read any file to understand code context
|
|
64
|
+
- **list_dir**: Explore directory structure
|
|
65
|
+
- **git_commit_history**: Learn from your previous commits
|
|
66
|
+
- **git_staged_files**: See what's being committed
|
|
67
|
+
- **git_unstaged_files**: View unstaged changes
|
|
68
|
+
- **git_untracked_files**: List untracked files
|
|
69
|
+
- **git_show_file_diff**: Examine detailed diffs
|
|
70
|
+
|
|
71
|
+
The agent will intelligently use these tools to generate contextually appropriate commit messages.
|
|
72
|
+
|
|
73
|
+
## Configuration
|
|
74
|
+
|
|
75
|
+
Your configuration is stored in `~/.commit-cli.json`:
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{
|
|
79
|
+
"ANTHROPIC_API_KEY": "sk-ant-...",
|
|
80
|
+
"preferences": {
|
|
81
|
+
"useConventionalCommits": true,
|
|
82
|
+
"commitMessageStyle": "concise"
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
You can manually edit this file to change your preferences.
|
|
88
|
+
|
|
89
|
+
## Development
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
git clone https://github.com/sunggyeol/commit-agent-cli.git
|
|
93
|
+
cd commit-agent-cli
|
|
94
|
+
npm install
|
|
95
|
+
npm run build
|
|
96
|
+
npm start
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## License
|
|
100
|
+
|
|
101
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __commonJS = (cb, mod) => function __require() {
|
|
9
|
+
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
|
|
10
|
+
};
|
|
11
|
+
var __copyProps = (to, from, except, desc) => {
|
|
12
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
13
|
+
for (let key of __getOwnPropNames(from))
|
|
14
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
15
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
20
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
21
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
22
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
23
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
24
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
25
|
+
mod
|
|
26
|
+
));
|
|
27
|
+
|
|
28
|
+
// node_modules/picocolors/picocolors.js
|
|
29
|
+
var require_picocolors = __commonJS({
|
|
30
|
+
"node_modules/picocolors/picocolors.js"(exports, module) {
|
|
31
|
+
"use strict";
|
|
32
|
+
var p = process || {};
|
|
33
|
+
var argv = p.argv || [];
|
|
34
|
+
var env = p.env || {};
|
|
35
|
+
var isColorSupported = !(!!env.NO_COLOR || argv.includes("--no-color")) && (!!env.FORCE_COLOR || argv.includes("--color") || p.platform === "win32" || (p.stdout || {}).isTTY && env.TERM !== "dumb" || !!env.CI);
|
|
36
|
+
var formatter = (open, close, replace = open) => (input) => {
|
|
37
|
+
let string = "" + input, index = string.indexOf(close, open.length);
|
|
38
|
+
return ~index ? open + replaceClose(string, close, replace, index) + close : open + string + close;
|
|
39
|
+
};
|
|
40
|
+
var replaceClose = (string, close, replace, index) => {
|
|
41
|
+
let result = "", cursor = 0;
|
|
42
|
+
do {
|
|
43
|
+
result += string.substring(cursor, index) + replace;
|
|
44
|
+
cursor = index + close.length;
|
|
45
|
+
index = string.indexOf(close, cursor);
|
|
46
|
+
} while (~index);
|
|
47
|
+
return result + string.substring(cursor);
|
|
48
|
+
};
|
|
49
|
+
var createColors = (enabled = isColorSupported) => {
|
|
50
|
+
let f = enabled ? formatter : () => String;
|
|
51
|
+
return {
|
|
52
|
+
isColorSupported: enabled,
|
|
53
|
+
reset: f("\x1B[0m", "\x1B[0m"),
|
|
54
|
+
bold: f("\x1B[1m", "\x1B[22m", "\x1B[22m\x1B[1m"),
|
|
55
|
+
dim: f("\x1B[2m", "\x1B[22m", "\x1B[22m\x1B[2m"),
|
|
56
|
+
italic: f("\x1B[3m", "\x1B[23m"),
|
|
57
|
+
underline: f("\x1B[4m", "\x1B[24m"),
|
|
58
|
+
inverse: f("\x1B[7m", "\x1B[27m"),
|
|
59
|
+
hidden: f("\x1B[8m", "\x1B[28m"),
|
|
60
|
+
strikethrough: f("\x1B[9m", "\x1B[29m"),
|
|
61
|
+
black: f("\x1B[30m", "\x1B[39m"),
|
|
62
|
+
red: f("\x1B[31m", "\x1B[39m"),
|
|
63
|
+
green: f("\x1B[32m", "\x1B[39m"),
|
|
64
|
+
yellow: f("\x1B[33m", "\x1B[39m"),
|
|
65
|
+
blue: f("\x1B[34m", "\x1B[39m"),
|
|
66
|
+
magenta: f("\x1B[35m", "\x1B[39m"),
|
|
67
|
+
cyan: f("\x1B[36m", "\x1B[39m"),
|
|
68
|
+
white: f("\x1B[37m", "\x1B[39m"),
|
|
69
|
+
gray: f("\x1B[90m", "\x1B[39m"),
|
|
70
|
+
bgBlack: f("\x1B[40m", "\x1B[49m"),
|
|
71
|
+
bgRed: f("\x1B[41m", "\x1B[49m"),
|
|
72
|
+
bgGreen: f("\x1B[42m", "\x1B[49m"),
|
|
73
|
+
bgYellow: f("\x1B[43m", "\x1B[49m"),
|
|
74
|
+
bgBlue: f("\x1B[44m", "\x1B[49m"),
|
|
75
|
+
bgMagenta: f("\x1B[45m", "\x1B[49m"),
|
|
76
|
+
bgCyan: f("\x1B[46m", "\x1B[49m"),
|
|
77
|
+
bgWhite: f("\x1B[47m", "\x1B[49m"),
|
|
78
|
+
blackBright: f("\x1B[90m", "\x1B[39m"),
|
|
79
|
+
redBright: f("\x1B[91m", "\x1B[39m"),
|
|
80
|
+
greenBright: f("\x1B[92m", "\x1B[39m"),
|
|
81
|
+
yellowBright: f("\x1B[93m", "\x1B[39m"),
|
|
82
|
+
blueBright: f("\x1B[94m", "\x1B[39m"),
|
|
83
|
+
magentaBright: f("\x1B[95m", "\x1B[39m"),
|
|
84
|
+
cyanBright: f("\x1B[96m", "\x1B[39m"),
|
|
85
|
+
whiteBright: f("\x1B[97m", "\x1B[39m"),
|
|
86
|
+
bgBlackBright: f("\x1B[100m", "\x1B[49m"),
|
|
87
|
+
bgRedBright: f("\x1B[101m", "\x1B[49m"),
|
|
88
|
+
bgGreenBright: f("\x1B[102m", "\x1B[49m"),
|
|
89
|
+
bgYellowBright: f("\x1B[103m", "\x1B[49m"),
|
|
90
|
+
bgBlueBright: f("\x1B[104m", "\x1B[49m"),
|
|
91
|
+
bgMagentaBright: f("\x1B[105m", "\x1B[49m"),
|
|
92
|
+
bgCyanBright: f("\x1B[106m", "\x1B[49m"),
|
|
93
|
+
bgWhiteBright: f("\x1B[107m", "\x1B[49m")
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
module.exports = createColors();
|
|
97
|
+
module.exports.createColors = createColors;
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// src/index.ts
|
|
102
|
+
import "dotenv/config";
|
|
103
|
+
import { intro, outro, text, spinner, confirm, isCancel, cancel, note } from "@clack/prompts";
|
|
104
|
+
|
|
105
|
+
// src/git.ts
|
|
106
|
+
import { execa } from "execa";
|
|
107
|
+
async function getStagedDiff() {
|
|
108
|
+
const { stdout } = await execa("git", ["diff", "--cached"], {
|
|
109
|
+
reject: false
|
|
110
|
+
});
|
|
111
|
+
return stdout;
|
|
112
|
+
}
|
|
113
|
+
async function commit(message) {
|
|
114
|
+
await execa("git", ["commit", "-m", message]);
|
|
115
|
+
}
|
|
116
|
+
async function push() {
|
|
117
|
+
await execa("git", ["push"]);
|
|
118
|
+
}
|
|
119
|
+
async function isGitRepository() {
|
|
120
|
+
try {
|
|
121
|
+
await execa("git", ["rev-parse", "--is-inside-work-tree"]);
|
|
122
|
+
return true;
|
|
123
|
+
} catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// src/agent.ts
|
|
129
|
+
import { ChatAnthropic } from "@langchain/anthropic";
|
|
130
|
+
import { HumanMessage, SystemMessage } from "@langchain/core/messages";
|
|
131
|
+
import { StateGraph, MessagesAnnotation } from "@langchain/langgraph";
|
|
132
|
+
import { ToolNode } from "@langchain/langgraph/prebuilt";
|
|
133
|
+
|
|
134
|
+
// src/tools.ts
|
|
135
|
+
import { z } from "zod";
|
|
136
|
+
import { tool } from "@langchain/core/tools";
|
|
137
|
+
import { readFile, readdir, stat } from "fs/promises";
|
|
138
|
+
import { join } from "path";
|
|
139
|
+
import { execa as execa2 } from "execa";
|
|
140
|
+
var validatePath = (p) => {
|
|
141
|
+
if (p.includes("..")) throw new Error("Cannot access parent directories");
|
|
142
|
+
return p;
|
|
143
|
+
};
|
|
144
|
+
var readFileTool = tool(
|
|
145
|
+
async ({ filePath }) => {
|
|
146
|
+
try {
|
|
147
|
+
console.log(` \u{1F50D} Agent is reading: ${filePath}`);
|
|
148
|
+
const stats = await stat(validatePath(filePath));
|
|
149
|
+
const MAX_SIZE = 5e4;
|
|
150
|
+
if (stats.size > MAX_SIZE) {
|
|
151
|
+
return `File ${filePath} is too large (${Math.round(stats.size / 1024)}KB). Please be more specific about what you need from this file, or ask about a smaller file.`;
|
|
152
|
+
}
|
|
153
|
+
const content = await readFile(validatePath(filePath), "utf-8");
|
|
154
|
+
const MAX_CHARS = 1e4;
|
|
155
|
+
if (content.length > MAX_CHARS) {
|
|
156
|
+
return content.substring(0, MAX_CHARS) + `
|
|
157
|
+
|
|
158
|
+
... [File truncated - ${content.length - MAX_CHARS} more characters]`;
|
|
159
|
+
}
|
|
160
|
+
return content;
|
|
161
|
+
} catch (error) {
|
|
162
|
+
return `Error reading file ${filePath}: ${error.message}`;
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
name: "read_file",
|
|
167
|
+
description: "Read the contents of a file to understand code context. Limited to files under 50KB. Use sparingly.",
|
|
168
|
+
schema: z.object({
|
|
169
|
+
filePath: z.string().describe("The path to the file to read (relative to project root)")
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
);
|
|
173
|
+
var listDirTool = tool(
|
|
174
|
+
async ({ dirPath }) => {
|
|
175
|
+
try {
|
|
176
|
+
console.log(` \u{1F4C2} Agent is listing directory: ${dirPath}`);
|
|
177
|
+
const files = await readdir(validatePath(dirPath));
|
|
178
|
+
const result = await Promise.all(files.map(async (f) => {
|
|
179
|
+
try {
|
|
180
|
+
const s = await stat(join(dirPath, f));
|
|
181
|
+
return `${f} ${s.isDirectory() ? "(DIR)" : "(FILE)"}`;
|
|
182
|
+
} catch {
|
|
183
|
+
return f;
|
|
184
|
+
}
|
|
185
|
+
}));
|
|
186
|
+
return result.join("\n");
|
|
187
|
+
} catch (error) {
|
|
188
|
+
return `Error listing directory ${dirPath}: ${error.message}`;
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
name: "list_dir",
|
|
193
|
+
description: "List files and directories in a given path.",
|
|
194
|
+
schema: z.object({
|
|
195
|
+
dirPath: z.string().describe('The directory path to list (relative to project root). defaults to "."')
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
);
|
|
199
|
+
var gitCommitHistoryTool = tool(
|
|
200
|
+
async ({ count }) => {
|
|
201
|
+
try {
|
|
202
|
+
const limitedCount = Math.min(count, 5);
|
|
203
|
+
console.log(` \u{1F4DC} Agent is checking last ${limitedCount} commits`);
|
|
204
|
+
const { stdout } = await execa2("git", ["log", `-n${limitedCount}`, "--pretty=format:%s"], { reject: false });
|
|
205
|
+
return stdout || "No commit history found";
|
|
206
|
+
} catch (error) {
|
|
207
|
+
return `Error getting commit history: ${error.message}`;
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
name: "git_commit_history",
|
|
212
|
+
description: "Get recent commit messages to understand project conventions. Returns just the commit messages (no hashes/authors). Limited to 5 commits max.",
|
|
213
|
+
schema: z.object({
|
|
214
|
+
count: z.number().default(5).describe("Number of recent commits to retrieve (max: 5, default: 5)")
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
);
|
|
218
|
+
var gitStagedFilesTool = tool(
|
|
219
|
+
async () => {
|
|
220
|
+
try {
|
|
221
|
+
console.log(` \u{1F4CB} Agent is listing staged files`);
|
|
222
|
+
const { stdout } = await execa2("git", ["diff", "--cached", "--name-status"], { reject: false });
|
|
223
|
+
return stdout || "No staged files";
|
|
224
|
+
} catch (error) {
|
|
225
|
+
return `Error getting staged files: ${error.message}`;
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
name: "git_staged_files",
|
|
230
|
+
description: "List all staged files with their status (Added, Modified, Deleted).",
|
|
231
|
+
schema: z.object({})
|
|
232
|
+
}
|
|
233
|
+
);
|
|
234
|
+
var gitUnstagedFilesTool = tool(
|
|
235
|
+
async () => {
|
|
236
|
+
try {
|
|
237
|
+
console.log(` \u{1F4DD} Agent is listing unstaged files`);
|
|
238
|
+
const { stdout } = await execa2("git", ["diff", "--name-status"], { reject: false });
|
|
239
|
+
return stdout || "No unstaged changes";
|
|
240
|
+
} catch (error) {
|
|
241
|
+
return `Error getting unstaged files: ${error.message}`;
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
name: "git_unstaged_files",
|
|
246
|
+
description: "List all unstaged modified files.",
|
|
247
|
+
schema: z.object({})
|
|
248
|
+
}
|
|
249
|
+
);
|
|
250
|
+
var gitUntrackedFilesTool = tool(
|
|
251
|
+
async () => {
|
|
252
|
+
try {
|
|
253
|
+
console.log(` \u2753 Agent is listing untracked files`);
|
|
254
|
+
const { stdout } = await execa2("git", ["ls-files", "--others", "--exclude-standard"], { reject: false });
|
|
255
|
+
return stdout || "No untracked files";
|
|
256
|
+
} catch (error) {
|
|
257
|
+
return `Error getting untracked files: ${error.message}`;
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
name: "git_untracked_files",
|
|
262
|
+
description: "List all untracked files (files not yet added to git).",
|
|
263
|
+
schema: z.object({})
|
|
264
|
+
}
|
|
265
|
+
);
|
|
266
|
+
var gitShowFileDiffTool = tool(
|
|
267
|
+
async ({ filePath }) => {
|
|
268
|
+
try {
|
|
269
|
+
console.log(` \u{1F50E} Agent is viewing diff for: ${filePath}`);
|
|
270
|
+
const { stdout } = await execa2("git", ["diff", "--cached", filePath], { reject: false });
|
|
271
|
+
return stdout || "No changes in this file";
|
|
272
|
+
} catch (error) {
|
|
273
|
+
return `Error getting file diff: ${error.message}`;
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
name: "git_show_file_diff",
|
|
278
|
+
description: "Show the detailed diff for a specific staged file.",
|
|
279
|
+
schema: z.object({
|
|
280
|
+
filePath: z.string().describe("Path to the file to show diff for")
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
);
|
|
284
|
+
var tools = [
|
|
285
|
+
readFileTool,
|
|
286
|
+
listDirTool,
|
|
287
|
+
gitCommitHistoryTool,
|
|
288
|
+
gitStagedFilesTool,
|
|
289
|
+
gitUnstagedFilesTool,
|
|
290
|
+
gitUntrackedFilesTool,
|
|
291
|
+
gitShowFileDiffTool
|
|
292
|
+
];
|
|
293
|
+
|
|
294
|
+
// src/agent.ts
|
|
295
|
+
var app = null;
|
|
296
|
+
function getApp() {
|
|
297
|
+
if (app) return app;
|
|
298
|
+
const model = new ChatAnthropic({
|
|
299
|
+
model: "claude-sonnet-4-5-20250929",
|
|
300
|
+
temperature: 0,
|
|
301
|
+
// It will automatically look for ANTHROPIC_API_KEY in process.env if not provided,
|
|
302
|
+
// but explicit assignment ensures it picks up dynamic changes if any.
|
|
303
|
+
apiKey: process.env.ANTHROPIC_API_KEY
|
|
304
|
+
}).bindTools(tools);
|
|
305
|
+
const toolNode = new ToolNode(tools);
|
|
306
|
+
async function agent(state) {
|
|
307
|
+
const { messages } = state;
|
|
308
|
+
const result = await model.invoke(messages);
|
|
309
|
+
return { messages: [result] };
|
|
310
|
+
}
|
|
311
|
+
function shouldContinue(state) {
|
|
312
|
+
const lastMessage = state.messages[state.messages.length - 1];
|
|
313
|
+
if (lastMessage.additional_kwargs.tool_calls || lastMessage.tool_calls?.length > 0) {
|
|
314
|
+
return "tools";
|
|
315
|
+
}
|
|
316
|
+
return "__end__";
|
|
317
|
+
}
|
|
318
|
+
const workflow = new StateGraph(MessagesAnnotation).addNode("agent", agent).addNode("tools", toolNode).addEdge("__start__", "agent").addConditionalEdges("agent", shouldContinue).addEdge("tools", "agent");
|
|
319
|
+
app = workflow.compile();
|
|
320
|
+
return app;
|
|
321
|
+
}
|
|
322
|
+
async function generateCommitMessage(diff, preferences) {
|
|
323
|
+
const conventionalGuide = preferences.useConventionalCommits ? "Use conventional commit format with prefixes like feat:, fix:, chore:, docs:, style:, refactor:, test:, etc." : "Do NOT use conventional commit prefixes. Write natural commit messages.";
|
|
324
|
+
const styleGuide = preferences.commitMessageStyle === "descriptive" ? 'Be descriptive and detailed. Explain the "why" behind changes when relevant. Multi-line messages are encouraged.' : "Be concise and to the point. Keep it short, ideally one line.";
|
|
325
|
+
const systemPrompt = `You are an expert developer. Your task is to generate a commit message for the provided git diff.
|
|
326
|
+
|
|
327
|
+
Available Tools (use SPARINGLY - only when absolutely necessary):
|
|
328
|
+
- git_commit_history: Check last 3-5 commits to understand conventions (use ONLY if diff is ambiguous)
|
|
329
|
+
- git_staged_files: See list of staged files (use ONLY if you need to understand scope)
|
|
330
|
+
- read_file: Read a file (ONLY if diff references unclear code - read MAX 1-2 files, prefer small files)
|
|
331
|
+
- list_dir: List directory contents (RARELY needed)
|
|
332
|
+
|
|
333
|
+
EFFICIENCY RULES - CRITICAL:
|
|
334
|
+
1. The diff contains ALL the information you need in 90% of cases. START by analyzing it thoroughly.
|
|
335
|
+
2. DO NOT automatically call tools. Only use them if the diff is genuinely unclear.
|
|
336
|
+
3. If using read_file, read ONLY the specific function/class mentioned, not entire files.
|
|
337
|
+
4. NEVER read more than 2 files total.
|
|
338
|
+
5. If checking commit history, limit to 3-5 recent commits maximum.
|
|
339
|
+
6. DO NOT use list_dir unless absolutely critical to understand project structure.
|
|
340
|
+
|
|
341
|
+
Commit Message Rules:
|
|
342
|
+
1. ${conventionalGuide}
|
|
343
|
+
2. ${styleGuide}
|
|
344
|
+
3. Focus on WHAT changed and WHY (if clear from diff).
|
|
345
|
+
4. OUTPUT FORMAT: Your response must be ONLY the commit message. No explanations, no "Here is...", no analysis.
|
|
346
|
+
5. Do NOT use markdown code blocks or formatting.
|
|
347
|
+
6. If multi-line, use proper git commit format (subject line, blank line, body).
|
|
348
|
+
|
|
349
|
+
CRITICAL: Your ENTIRE response should be the commit message itself, nothing else. No preamble, no explanation.
|
|
350
|
+
|
|
351
|
+
REMEMBER: The diff is your primary source. Tools are for edge cases only.
|
|
352
|
+
`;
|
|
353
|
+
const messages = [
|
|
354
|
+
new SystemMessage(systemPrompt),
|
|
355
|
+
new HumanMessage(`Generate a commit message for this diff:
|
|
356
|
+
|
|
357
|
+
${diff}`)
|
|
358
|
+
];
|
|
359
|
+
const graph = getApp();
|
|
360
|
+
const result = await graph.invoke({ messages });
|
|
361
|
+
const lastMsg = result.messages[result.messages.length - 1];
|
|
362
|
+
let content = lastMsg.content;
|
|
363
|
+
content = content.trim();
|
|
364
|
+
const patterns = [
|
|
365
|
+
/^(?:Here is|Here's|Based on|Looking at|This is|The commit message is|Commit message).*?:\s*/i,
|
|
366
|
+
/^```[\w]*\n?/,
|
|
367
|
+
// Remove opening markdown code blocks
|
|
368
|
+
/\n?```$/
|
|
369
|
+
// Remove closing markdown code blocks
|
|
370
|
+
];
|
|
371
|
+
for (const pattern of patterns) {
|
|
372
|
+
content = content.replace(pattern, "");
|
|
373
|
+
}
|
|
374
|
+
const lines = content.split("\n");
|
|
375
|
+
if (lines.length > 3 && preferences.useConventionalCommits) {
|
|
376
|
+
const commitPrefixes = ["feat:", "fix:", "chore:", "docs:", "style:", "refactor:", "test:", "perf:", "ci:", "build:", "revert:"];
|
|
377
|
+
const commitLineIndex = lines.findIndex(
|
|
378
|
+
(line) => commitPrefixes.some((prefix) => line.trim().toLowerCase().startsWith(prefix))
|
|
379
|
+
);
|
|
380
|
+
if (commitLineIndex > 0) {
|
|
381
|
+
content = lines.slice(commitLineIndex).join("\n").trim();
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return content.trim();
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// src/index.ts
|
|
388
|
+
var import_picocolors = __toESM(require_picocolors(), 1);
|
|
389
|
+
import { homedir } from "os";
|
|
390
|
+
import { join as join2 } from "path";
|
|
391
|
+
import { readFile as readFile2, writeFile } from "fs/promises";
|
|
392
|
+
var CONFIG_PATH = join2(homedir(), ".commit-cli.json");
|
|
393
|
+
async function getStoredKey() {
|
|
394
|
+
try {
|
|
395
|
+
const data = await readFile2(CONFIG_PATH, "utf-8");
|
|
396
|
+
return JSON.parse(data).ANTHROPIC_API_KEY;
|
|
397
|
+
} catch {
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
async function storeKey(key) {
|
|
402
|
+
try {
|
|
403
|
+
await writeFile(CONFIG_PATH, JSON.stringify({ ANTHROPIC_API_KEY: key }), { mode: 384 });
|
|
404
|
+
} catch (err) {
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
async function getStoredPreferences() {
|
|
408
|
+
try {
|
|
409
|
+
const data = await readFile2(CONFIG_PATH, "utf-8");
|
|
410
|
+
const config = JSON.parse(data);
|
|
411
|
+
return config.preferences || null;
|
|
412
|
+
} catch {
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
async function storePreferences(prefs) {
|
|
417
|
+
try {
|
|
418
|
+
let config = {};
|
|
419
|
+
try {
|
|
420
|
+
const data = await readFile2(CONFIG_PATH, "utf-8");
|
|
421
|
+
config = JSON.parse(data);
|
|
422
|
+
} catch {
|
|
423
|
+
}
|
|
424
|
+
config.preferences = prefs;
|
|
425
|
+
await writeFile(CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 384 });
|
|
426
|
+
} catch (err) {
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
async function main() {
|
|
430
|
+
intro(import_picocolors.default.bgBlue(import_picocolors.default.white(" commit-cli ")));
|
|
431
|
+
let apiKey = process.env.ANTHROPIC_API_KEY;
|
|
432
|
+
if (!apiKey) {
|
|
433
|
+
apiKey = await getStoredKey() || void 0;
|
|
434
|
+
}
|
|
435
|
+
if (!apiKey) {
|
|
436
|
+
const key = await text({
|
|
437
|
+
message: "Enter your Anthropic API Key (sk-...):",
|
|
438
|
+
placeholder: "sk-ant-api...",
|
|
439
|
+
validate: (value) => {
|
|
440
|
+
if (!value) return "API Key is required";
|
|
441
|
+
if (!value.startsWith("sk-")) return "Invalid API Key format (should start with sk-)";
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
if (isCancel(key)) {
|
|
445
|
+
cancel("Operation cancelled.");
|
|
446
|
+
process.exit(0);
|
|
447
|
+
}
|
|
448
|
+
apiKey = key;
|
|
449
|
+
await storeKey(apiKey);
|
|
450
|
+
}
|
|
451
|
+
process.env.ANTHROPIC_API_KEY = apiKey;
|
|
452
|
+
const isRepo = await isGitRepository();
|
|
453
|
+
if (!isRepo) {
|
|
454
|
+
cancel("Current directory is not a git repository.");
|
|
455
|
+
process.exit(1);
|
|
456
|
+
}
|
|
457
|
+
let preferences = await getStoredPreferences();
|
|
458
|
+
if (!preferences) {
|
|
459
|
+
note("Let's set up your commit message preferences (one-time setup)", "First Time Setup");
|
|
460
|
+
const useConventional = await confirm({
|
|
461
|
+
message: "Use conventional commit prefixes (feat:, fix:, chore:, etc.)?",
|
|
462
|
+
initialValue: true
|
|
463
|
+
});
|
|
464
|
+
if (isCancel(useConventional)) {
|
|
465
|
+
cancel("Operation cancelled.");
|
|
466
|
+
process.exit(0);
|
|
467
|
+
}
|
|
468
|
+
const styleChoice = await confirm({
|
|
469
|
+
message: "Prefer descriptive commit messages?",
|
|
470
|
+
active: "Descriptive (detailed explanations)",
|
|
471
|
+
inactive: "Concise (short and to the point)",
|
|
472
|
+
initialValue: false
|
|
473
|
+
});
|
|
474
|
+
if (isCancel(styleChoice)) {
|
|
475
|
+
cancel("Operation cancelled.");
|
|
476
|
+
process.exit(0);
|
|
477
|
+
}
|
|
478
|
+
preferences = {
|
|
479
|
+
useConventionalCommits: useConventional,
|
|
480
|
+
commitMessageStyle: styleChoice ? "descriptive" : "concise"
|
|
481
|
+
};
|
|
482
|
+
await storePreferences(preferences);
|
|
483
|
+
note(`Preferences saved! You can change these by editing ${CONFIG_PATH}`, "Setup Complete");
|
|
484
|
+
}
|
|
485
|
+
const s = spinner();
|
|
486
|
+
s.start("Analyzing staged changes...");
|
|
487
|
+
const diff = await getStagedDiff();
|
|
488
|
+
if (!diff) {
|
|
489
|
+
s.stop("No staged changes found.");
|
|
490
|
+
cancel('Please stage your changes using "git add" first.');
|
|
491
|
+
process.exit(0);
|
|
492
|
+
}
|
|
493
|
+
s.stop("Changes detected.");
|
|
494
|
+
let commitMessage = "";
|
|
495
|
+
let confirmed = false;
|
|
496
|
+
while (!confirmed) {
|
|
497
|
+
s.start("Generating commit message (Agent is exploring)...");
|
|
498
|
+
try {
|
|
499
|
+
commitMessage = await generateCommitMessage(diff, preferences);
|
|
500
|
+
} catch (error) {
|
|
501
|
+
s.stop("Generation failed.");
|
|
502
|
+
cancel(`Error: ${error.message}`);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
s.stop("Message generated.");
|
|
506
|
+
const maxWidth = 80;
|
|
507
|
+
const lines = commitMessage.split("\n");
|
|
508
|
+
const wrappedLines = [];
|
|
509
|
+
for (const line of lines) {
|
|
510
|
+
if (line.length <= maxWidth) {
|
|
511
|
+
wrappedLines.push(line);
|
|
512
|
+
} else {
|
|
513
|
+
const words = line.split(" ");
|
|
514
|
+
let currentLine = "";
|
|
515
|
+
for (const word of words) {
|
|
516
|
+
if ((currentLine + " " + word).trim().length <= maxWidth) {
|
|
517
|
+
currentLine = currentLine ? currentLine + " " + word : word;
|
|
518
|
+
} else {
|
|
519
|
+
if (currentLine) wrappedLines.push(currentLine);
|
|
520
|
+
currentLine = word;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if (currentLine) wrappedLines.push(currentLine);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
const formattedMessage = wrappedLines.map((line) => import_picocolors.default.cyan(line)).join("\n");
|
|
527
|
+
note(formattedMessage, import_picocolors.default.bold("Proposed Commit Message"));
|
|
528
|
+
const action = await confirm({
|
|
529
|
+
message: "Do you want to use this message?",
|
|
530
|
+
active: "Yes, commit",
|
|
531
|
+
inactive: "No, regenerate or cancel"
|
|
532
|
+
});
|
|
533
|
+
if (isCancel(action)) {
|
|
534
|
+
cancel("Operation cancelled.");
|
|
535
|
+
process.exit(0);
|
|
536
|
+
}
|
|
537
|
+
if (action) {
|
|
538
|
+
confirmed = true;
|
|
539
|
+
} else {
|
|
540
|
+
const nextStep = await confirm({
|
|
541
|
+
message: "Try again?",
|
|
542
|
+
active: "Regenerate",
|
|
543
|
+
inactive: "Cancel"
|
|
544
|
+
});
|
|
545
|
+
if (!nextStep || isCancel(nextStep)) {
|
|
546
|
+
cancel("Operation cancelled.");
|
|
547
|
+
process.exit(0);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
s.start("Committing...");
|
|
552
|
+
await commit(commitMessage);
|
|
553
|
+
s.stop("Committed!");
|
|
554
|
+
const shouldPush = await confirm({
|
|
555
|
+
message: "Do you want to push changes now?"
|
|
556
|
+
});
|
|
557
|
+
if (isCancel(shouldPush)) {
|
|
558
|
+
outro(`Changes committed but NOT pushed.`);
|
|
559
|
+
process.exit(0);
|
|
560
|
+
}
|
|
561
|
+
if (shouldPush) {
|
|
562
|
+
s.start("Pushing...");
|
|
563
|
+
try {
|
|
564
|
+
await push();
|
|
565
|
+
s.stop("Pushed!");
|
|
566
|
+
outro("Successfully committed and pushed! \u{1F680}");
|
|
567
|
+
} catch (error) {
|
|
568
|
+
s.stop("Push failed.");
|
|
569
|
+
cancel(`Error pushing: ${error.message}`);
|
|
570
|
+
}
|
|
571
|
+
} else {
|
|
572
|
+
outro("Changes committed locally. \u{1F44D}");
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
main().catch(console.error);
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "commit-agent-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "AI-powered git commit CLI using LangGraph and Claude 4.5",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
11
|
+
"bin": {
|
|
12
|
+
"commit": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
16
|
+
"start": "node dist/index.js",
|
|
17
|
+
"dev": "tsup src/index.ts --watch --onSuccess \"node dist/index.js\"",
|
|
18
|
+
"lint": "eslint src/**",
|
|
19
|
+
"format": "prettier --write .",
|
|
20
|
+
"prepublishOnly": "npm run build"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"git",
|
|
24
|
+
"commit",
|
|
25
|
+
"ai",
|
|
26
|
+
"cli",
|
|
27
|
+
"claude",
|
|
28
|
+
"langgraph"
|
|
29
|
+
],
|
|
30
|
+
"author": "Commit CLI Contributor",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/sunggyeol/commit-agent-cli.git"
|
|
34
|
+
},
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@ai-sdk/anthropic": "^0.0.39",
|
|
38
|
+
"@clack/prompts": "^0.7.0",
|
|
39
|
+
"@langchain/anthropic": "^0.3.0",
|
|
40
|
+
"@langchain/core": "^0.3.0",
|
|
41
|
+
"@langchain/langgraph": "^0.2.0",
|
|
42
|
+
"ai": "^3.3.33",
|
|
43
|
+
"dotenv": "^16.4.5",
|
|
44
|
+
"execa": "^9.3.1",
|
|
45
|
+
"zod": "^3.23.8"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/node": "^22.5.4",
|
|
49
|
+
"tsup": "^8.2.4",
|
|
50
|
+
"typescript": "^5.5.4"
|
|
51
|
+
},
|
|
52
|
+
"engines": {
|
|
53
|
+
"node": ">=18"
|
|
54
|
+
}
|
|
55
|
+
}
|