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 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
@@ -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
+ }