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.
Files changed (3) hide show
  1. package/index.js +171 -0
  2. package/package.json +40 -0
  3. 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
+ }