commit-writer 1.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/index.js +171 -0
- package/package.json +40 -0
- package/setup.js +256 -0
package/index.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { execSync } from "child_process";
|
|
4
|
+
import { createInterface } from "readline";
|
|
5
|
+
import { readFileSync, existsSync } from "fs";
|
|
6
|
+
import { join } from "path";
|
|
7
|
+
import { homedir } from "os";
|
|
8
|
+
import { generateText } from "ai";
|
|
9
|
+
import { openai } from "@ai-sdk/openai";
|
|
10
|
+
import { anthropic } from "@ai-sdk/anthropic";
|
|
11
|
+
import { google } from "@ai-sdk/google";
|
|
12
|
+
import { runSetup } from "./setup.js";
|
|
13
|
+
|
|
14
|
+
const CONFIG_PATH = join(homedir(), ".config", "commit-writer", "config.json");
|
|
15
|
+
|
|
16
|
+
const ENV_VAR_MAP = {
|
|
17
|
+
openai: "OPENAI_API_KEY",
|
|
18
|
+
anthropic: "ANTHROPIC_API_KEY",
|
|
19
|
+
google: "GOOGLE_GENERATIVE_AI_API_KEY",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function loadConfig() {
|
|
23
|
+
try {
|
|
24
|
+
const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
|
25
|
+
// Migrate legacy config (pre-1.1.0)
|
|
26
|
+
if (raw.openaiApiKey && !raw.provider) {
|
|
27
|
+
return {
|
|
28
|
+
provider: "openai",
|
|
29
|
+
model: "gpt-5-mini",
|
|
30
|
+
apiKey: raw.openaiApiKey,
|
|
31
|
+
diffMode: raw.diffMode || "staged",
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
return raw;
|
|
35
|
+
} catch {
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let config = loadConfig();
|
|
41
|
+
|
|
42
|
+
const SYSTEM_PROMPT = `You are a commit message generator. Given a git diff, write a concise conventional commit message.
|
|
43
|
+
|
|
44
|
+
Rules:
|
|
45
|
+
- Use conventional commit format: type(scope): description
|
|
46
|
+
- Types: feat, fix, refactor, docs, style, test, chore, perf, ci, build
|
|
47
|
+
- Keep the subject line under 72 characters
|
|
48
|
+
- Optionally add a body with bullet points for complex changes
|
|
49
|
+
- Do not include anything other than the commit message itself`;
|
|
50
|
+
|
|
51
|
+
function getModel(config) {
|
|
52
|
+
const providers = { openai, anthropic, google };
|
|
53
|
+
const createModel = providers[config.provider];
|
|
54
|
+
if (!createModel) {
|
|
55
|
+
console.error(
|
|
56
|
+
`Error: Unknown provider "${config.provider}". Run \`commit-writer-setup\` to reconfigure.`
|
|
57
|
+
);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
return createModel(config.model);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function getDiff() {
|
|
64
|
+
if (!process.stdin.isTTY) {
|
|
65
|
+
const chunks = [];
|
|
66
|
+
for await (const chunk of process.stdin) {
|
|
67
|
+
chunks.push(chunk);
|
|
68
|
+
}
|
|
69
|
+
return Buffer.concat(chunks).toString("utf-8").trim();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const diffCommands = {
|
|
73
|
+
staged: "git diff --staged",
|
|
74
|
+
unstaged: "git diff",
|
|
75
|
+
all: "git diff HEAD",
|
|
76
|
+
};
|
|
77
|
+
const diffMode = config.diffMode || "staged";
|
|
78
|
+
const command = diffCommands[diffMode] || diffCommands.staged;
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const diff = execSync(command, { encoding: "utf-8" }).trim();
|
|
82
|
+
return diff;
|
|
83
|
+
} catch {
|
|
84
|
+
console.error("Error: Not a git repository or git is not installed.");
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function generateCommitMessage(diff) {
|
|
90
|
+
const { text } = await generateText({
|
|
91
|
+
model: getModel(config),
|
|
92
|
+
system: SYSTEM_PROMPT,
|
|
93
|
+
prompt: `Generate a commit message for this diff:\n\n${diff}`,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return text.trim();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function ask(question) {
|
|
100
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
101
|
+
return new Promise((resolve) => {
|
|
102
|
+
rl.question(question, (answer) => {
|
|
103
|
+
rl.close();
|
|
104
|
+
resolve(answer.trim().toLowerCase());
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function main() {
|
|
110
|
+
// First run: no config or no provider set — run setup automatically
|
|
111
|
+
if (!config.provider && !existsSync(CONFIG_PATH)) {
|
|
112
|
+
await runSetup();
|
|
113
|
+
config = loadConfig();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!config.provider) {
|
|
117
|
+
console.error(
|
|
118
|
+
"Error: No provider configured. Run `commit-writer-setup` to set up."
|
|
119
|
+
);
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Set the env var the AI SDK reads from config
|
|
124
|
+
const envVar = ENV_VAR_MAP[config.provider];
|
|
125
|
+
if (!process.env[envVar] && config.apiKey) {
|
|
126
|
+
process.env[envVar] = config.apiKey;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!process.env[envVar]) {
|
|
130
|
+
console.error(
|
|
131
|
+
`Error: ${envVar} not set. Run \`commit-writer-setup\` or set the env var.`
|
|
132
|
+
);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const diff = await getDiff();
|
|
137
|
+
|
|
138
|
+
if (!diff) {
|
|
139
|
+
console.error("No diff found. Stage your changes with `git add` first.");
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
console.log("Generating commit message...\n");
|
|
144
|
+
|
|
145
|
+
const message = await generateCommitMessage(diff);
|
|
146
|
+
|
|
147
|
+
console.log("--- Suggested commit message ---");
|
|
148
|
+
console.log(message);
|
|
149
|
+
console.log("--------------------------------\n");
|
|
150
|
+
|
|
151
|
+
if (process.stdin.isTTY) {
|
|
152
|
+
const answer = await ask("Commit with this message? (y/n): ");
|
|
153
|
+
if (answer === "y" || answer === "yes") {
|
|
154
|
+
try {
|
|
155
|
+
execSync(`git commit -m ${JSON.stringify(message)}`, {
|
|
156
|
+
stdio: "inherit",
|
|
157
|
+
});
|
|
158
|
+
} catch {
|
|
159
|
+
console.error("Commit failed.");
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
} else {
|
|
163
|
+
console.log("Commit cancelled.");
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
main().catch((err) => {
|
|
169
|
+
console.error("Error:", err.message);
|
|
170
|
+
process.exit(1);
|
|
171
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "commit-writer",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Generate commit messages from git diffs using AI (OpenAI, Anthropic, Gemini)",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"commit-writer": "./index.js",
|
|
9
|
+
"commit-writer-setup": "./setup.js"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"start": "node index.js",
|
|
13
|
+
"setup": "node setup.js"
|
|
14
|
+
},
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"index.js",
|
|
20
|
+
"setup.js"
|
|
21
|
+
],
|
|
22
|
+
"keywords": [
|
|
23
|
+
"git",
|
|
24
|
+
"commit",
|
|
25
|
+
"ai",
|
|
26
|
+
"openai",
|
|
27
|
+
"anthropic",
|
|
28
|
+
"claude",
|
|
29
|
+
"gemini",
|
|
30
|
+
"conventional-commits"
|
|
31
|
+
],
|
|
32
|
+
"author": "",
|
|
33
|
+
"license": "ISC",
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@ai-sdk/anthropic": "^1.0.0",
|
|
36
|
+
"@ai-sdk/google": "^1.0.0",
|
|
37
|
+
"@ai-sdk/openai": "^1.0.0",
|
|
38
|
+
"ai": "^4.0.0"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/setup.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createInterface } from "readline";
|
|
4
|
+
import { createReadStream } from "fs";
|
|
5
|
+
import { mkdirSync, writeFileSync, readFileSync } from "fs";
|
|
6
|
+
import { join } from "path";
|
|
7
|
+
import { homedir } from "os";
|
|
8
|
+
|
|
9
|
+
const CONFIG_DIR = join(homedir(), ".config", "commit-writer");
|
|
10
|
+
const CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
11
|
+
|
|
12
|
+
const PROVIDERS = {
|
|
13
|
+
openai: { name: "OpenAI", envVar: "OPENAI_API_KEY" },
|
|
14
|
+
anthropic: { name: "Anthropic (Claude)", envVar: "ANTHROPIC_API_KEY" },
|
|
15
|
+
google: { name: "Google Gemini", envVar: "GOOGLE_GENERATIVE_AI_API_KEY" },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function loadExistingConfig() {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
|
21
|
+
} catch {
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getTTYInput() {
|
|
27
|
+
if (process.stdin.isTTY) {
|
|
28
|
+
return process.stdin;
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
return createReadStream("/dev/tty", { encoding: "utf-8" });
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function askQuestion(rl, question) {
|
|
38
|
+
return new Promise((resolve) => {
|
|
39
|
+
rl.question(question, (answer) => resolve(answer.trim()));
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function askMasked(rl, input, question) {
|
|
44
|
+
return new Promise((resolve) => {
|
|
45
|
+
const output = rl.output;
|
|
46
|
+
output.write(question);
|
|
47
|
+
|
|
48
|
+
const originalWrite = output.write.bind(output);
|
|
49
|
+
let answer = "";
|
|
50
|
+
|
|
51
|
+
output.write = () => true;
|
|
52
|
+
|
|
53
|
+
const onData = (char) => {
|
|
54
|
+
const str = char.toString();
|
|
55
|
+
if (str === "\n" || str === "\r") {
|
|
56
|
+
output.write = originalWrite;
|
|
57
|
+
input.removeListener("data", onData);
|
|
58
|
+
if (input.setRawMode) input.setRawMode(false);
|
|
59
|
+
output.write("\n");
|
|
60
|
+
resolve(answer);
|
|
61
|
+
} else if (str === "\u007F" || str === "\b") {
|
|
62
|
+
if (answer.length > 0) {
|
|
63
|
+
answer = answer.slice(0, -1);
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
answer += str;
|
|
67
|
+
output.write = originalWrite;
|
|
68
|
+
output.write("*");
|
|
69
|
+
output.write = () => true;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
if (input.setRawMode) input.setRawMode(true);
|
|
74
|
+
input.resume();
|
|
75
|
+
input.on("data", onData);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function fetchModels(provider, apiKey) {
|
|
80
|
+
let url, headers;
|
|
81
|
+
|
|
82
|
+
if (provider === "openai") {
|
|
83
|
+
url = "https://api.openai.com/v1/models";
|
|
84
|
+
headers = { Authorization: `Bearer ${apiKey}` };
|
|
85
|
+
} else if (provider === "anthropic") {
|
|
86
|
+
url = "https://api.anthropic.com/v1/models";
|
|
87
|
+
headers = { "x-api-key": apiKey, "anthropic-version": "2023-06-01" };
|
|
88
|
+
} else if (provider === "google") {
|
|
89
|
+
url = `https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`;
|
|
90
|
+
headers = {};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const res = await fetch(url, { headers });
|
|
94
|
+
if (!res.ok) {
|
|
95
|
+
const body = await res.text();
|
|
96
|
+
throw new Error(`API returned ${res.status}: ${body}`);
|
|
97
|
+
}
|
|
98
|
+
const json = await res.json();
|
|
99
|
+
|
|
100
|
+
if (provider === "openai") {
|
|
101
|
+
return json.data
|
|
102
|
+
.map((m) => m.id)
|
|
103
|
+
.filter((id) => /^(gpt|o[0-9]|chatgpt)/.test(id))
|
|
104
|
+
.sort();
|
|
105
|
+
} else if (provider === "anthropic") {
|
|
106
|
+
return json.data.map((m) => m.id).sort();
|
|
107
|
+
} else if (provider === "google") {
|
|
108
|
+
return json.models
|
|
109
|
+
.filter((m) =>
|
|
110
|
+
m.supportedGenerationMethods?.includes("generateContent")
|
|
111
|
+
)
|
|
112
|
+
.map((m) => m.name.replace("models/", ""))
|
|
113
|
+
.sort();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function runSetup() {
|
|
118
|
+
const input = getTTYInput();
|
|
119
|
+
|
|
120
|
+
if (!input) {
|
|
121
|
+
console.log("\nNo interactive terminal detected.");
|
|
122
|
+
console.log(
|
|
123
|
+
"Run `commit-writer-setup` to configure your API key and preferences.\n"
|
|
124
|
+
);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const existing = loadExistingConfig();
|
|
129
|
+
|
|
130
|
+
console.log("\n🔧 commit-writer setup\n");
|
|
131
|
+
|
|
132
|
+
const rl = createInterface({ input, output: process.stdout });
|
|
133
|
+
|
|
134
|
+
// 1. Provider selection
|
|
135
|
+
console.log("Provider:");
|
|
136
|
+
console.log(" 1) OpenAI");
|
|
137
|
+
console.log(" 2) Anthropic (Claude)");
|
|
138
|
+
console.log(" 3) Google Gemini\n");
|
|
139
|
+
|
|
140
|
+
const currentProvider = existing.provider || "openai";
|
|
141
|
+
const providerAnswer = await askQuestion(
|
|
142
|
+
rl,
|
|
143
|
+
`Choose provider [1/2/3] (current: ${currentProvider}): `
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const providerMap = { "1": "openai", "2": "anthropic", "3": "google" };
|
|
147
|
+
const provider = providerMap[providerAnswer] || existing.provider || "openai";
|
|
148
|
+
const providerInfo = PROVIDERS[provider];
|
|
149
|
+
|
|
150
|
+
// 2. API key
|
|
151
|
+
const sameProvider = provider === existing.provider;
|
|
152
|
+
const keyHint =
|
|
153
|
+
sameProvider && existing.apiKey ? ` (press Enter to keep existing)` : "";
|
|
154
|
+
const apiKey = await askMasked(
|
|
155
|
+
rl,
|
|
156
|
+
input,
|
|
157
|
+
`\n${providerInfo.name} API key${keyHint}: `
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const finalKey = apiKey || (sameProvider ? existing.apiKey : "") || "";
|
|
161
|
+
if (!finalKey) {
|
|
162
|
+
console.log(
|
|
163
|
+
`\n⚠️ No API key provided. You can set ${providerInfo.envVar} env var instead.`
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 3. Model selection (live-fetched)
|
|
168
|
+
let model = existing.model && sameProvider ? existing.model : "";
|
|
169
|
+
|
|
170
|
+
if (finalKey) {
|
|
171
|
+
console.log("\nFetching available models...");
|
|
172
|
+
try {
|
|
173
|
+
const models = await fetchModels(provider, finalKey);
|
|
174
|
+
|
|
175
|
+
if (models.length === 0) {
|
|
176
|
+
console.log("No models found.");
|
|
177
|
+
} else {
|
|
178
|
+
console.log(`\nAvailable models (${models.length}):`);
|
|
179
|
+
models.forEach((m, i) => {
|
|
180
|
+
console.log(` ${i + 1}) ${m}`);
|
|
181
|
+
});
|
|
182
|
+
console.log();
|
|
183
|
+
|
|
184
|
+
const currentHint = model ? ` (current: ${model})` : "";
|
|
185
|
+
const modelAnswer = await askQuestion(
|
|
186
|
+
rl,
|
|
187
|
+
`Choose model [number]${currentHint}: `
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const idx = parseInt(modelAnswer, 10) - 1;
|
|
191
|
+
if (idx >= 0 && idx < models.length) {
|
|
192
|
+
model = models[idx];
|
|
193
|
+
} else if (!modelAnswer && model) {
|
|
194
|
+
// keep existing
|
|
195
|
+
} else if (modelAnswer) {
|
|
196
|
+
// treat as manual model ID input
|
|
197
|
+
model = modelAnswer;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
} catch (err) {
|
|
201
|
+
console.log(`\n⚠️ Could not fetch models: ${err.message}`);
|
|
202
|
+
const manualModel = await askQuestion(rl, "Enter model ID manually: ");
|
|
203
|
+
if (manualModel) model = manualModel;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!model) {
|
|
208
|
+
const manualModel = await askQuestion(rl, "\nEnter model ID: ");
|
|
209
|
+
model = manualModel || "";
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 4. Diff mode
|
|
213
|
+
console.log("\nDiff mode options:");
|
|
214
|
+
console.log(" 1) staged — git diff --staged (default)");
|
|
215
|
+
console.log(" 2) unstaged — git diff");
|
|
216
|
+
console.log(" 3) all — git diff HEAD\n");
|
|
217
|
+
|
|
218
|
+
const currentMode = existing.diffMode || "staged";
|
|
219
|
+
const modeAnswer = await askQuestion(
|
|
220
|
+
rl,
|
|
221
|
+
`Choose diff mode [1/2/3] (current: ${currentMode}): `
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const modeMap = { "1": "staged", "2": "unstaged", "3": "all" };
|
|
225
|
+
const diffMode = modeMap[modeAnswer] || existing.diffMode || "staged";
|
|
226
|
+
|
|
227
|
+
rl.close();
|
|
228
|
+
if (input !== process.stdin) input.destroy();
|
|
229
|
+
|
|
230
|
+
// Write config
|
|
231
|
+
const config = { provider, model, apiKey: finalKey, diffMode };
|
|
232
|
+
|
|
233
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
234
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n");
|
|
235
|
+
|
|
236
|
+
console.log(`\n✅ Config saved to ${CONFIG_PATH}`);
|
|
237
|
+
console.log(` Provider: ${providerInfo.name}`);
|
|
238
|
+
console.log(` Model: ${model || "(not set)"}`);
|
|
239
|
+
console.log(` Diff mode: ${diffMode}`);
|
|
240
|
+
console.log(
|
|
241
|
+
` API key: ${finalKey ? "***" + finalKey.slice(-4) : "(not set)"}\n`
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Run directly if called as a script (commit-writer-setup command)
|
|
246
|
+
const isDirectRun =
|
|
247
|
+
process.argv[1] &&
|
|
248
|
+
(process.argv[1].endsWith("setup.js") ||
|
|
249
|
+
process.argv[1].endsWith("commit-writer-setup"));
|
|
250
|
+
|
|
251
|
+
if (isDirectRun) {
|
|
252
|
+
runSetup().catch((err) => {
|
|
253
|
+
console.error("commit-writer setup error:", err.message);
|
|
254
|
+
process.exit(0);
|
|
255
|
+
});
|
|
256
|
+
}
|