@thankarezos/git-ai-commit 0.1.2 → 0.1.4

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # git-aic
2
2
 
3
- Generate a git commit message from your staged diff using any AI CLI you already have on your machine (Claude Code, Gemini CLI, Codex, `llm`, Ollama, or a custom command). The message opens in your editor — save to commit, quit without saving to abort.
3
+ Generate a git commit message from your staged diff using any AI CLI you already have on your machine (Claude Code, Antigravity CLI, Codex, `llm`, Ollama, or a custom command). The message opens in your editor — save to commit, quit without saving to abort.
4
4
 
5
5
  ```
6
6
  $ git aic -ap "mention the migration"
@@ -44,13 +44,14 @@ First run launches an **interactive wizard** that asks you to pick a provider, e
44
44
  | `-a`, `--add` | Run `git add -A` before generating |
45
45
  | `-p "..."`, `--prompt "..."` | Append extra instructions to the AI prompt |
46
46
  | `-ap "..."`, `-pa "..."` | Combine `-a` and `-p` |
47
- | `--provider <name>`, `-P <name>` | One-off provider override. `<name>` matches a preset command (`claude`, `gemini`, `codex`, `opencode`, `llm`, `ollama`). |
47
+ | `--provider <name>`, `-P <name>` | One-off provider override. `<name>` matches a preset command (`claude`, `agy`, `codex`, `opencode`, `llm`, `ollama`). |
48
48
  | `--no-commit` | Generate and edit, but skip the `git commit` |
49
49
  | `--commit` | Force commit (overrides `commit: false` in config) |
50
50
  | `--require-edit` | Refuse to commit if the saved message is byte-identical to what the AI produced |
51
51
  | `--no-require-edit` | Allow committing the AI message unchanged |
52
52
  | `--init` | Run the interactive setup wizard |
53
53
  | `--config` | Print the active config path and contents |
54
+ | `-v`, `--version` | Print the package version |
54
55
  | `-h` | Show usage. (`--help` is intercepted by git, so use `-h`.) |
55
56
 
56
57
  ## How it decides to commit
@@ -99,7 +100,7 @@ Each field in your user config **replaces** the default if present. If you delet
99
100
  The wizard ships with presets for:
100
101
 
101
102
  - **Claude Code** — `claude -p "{prompt}"` (reads diff from stdin)
102
- - **Gemini CLI** — `gemini -p "{prompt}"`
103
+ - **Antigravity CLI** — `agy -p "{prompt}"`
103
104
  - **Codex CLI** — `codex exec --skip-git-repo-check "{prompt}"`
104
105
  - **OpenCode** — `opencode run "{prompt}"`
105
106
  - **llm** (Simon Willison) — `llm "{prompt}"`
@@ -108,6 +109,20 @@ The wizard ships with presets for:
108
109
 
109
110
  You can edit `provider` in the config file at any time, or rerun `git aic --init`.
110
111
 
112
+ ### Windows note
113
+
114
+ Most CLIs work the same on Windows. The exception is a CLI that renders its output to the console instead of stdout (like `agy`): to capture it, `git-aic` must spawn it with no console, which briefly flashes a console window. A provider can opt into this with a `windows` field carrying extra spawn options:
115
+
116
+ ```json
117
+ "provider": {
118
+ "command": "agy",
119
+ "args": ["-p", "{prompt}"],
120
+ "windows": { "detached": true }
121
+ }
122
+ ```
123
+
124
+ CLIs that write to stdout (e.g. `claude`) don't need this and run without any window.
125
+
111
126
  ## Customizing the prompt
112
127
 
113
128
  The base prompt template, the `<commit_message>` tag, and the cleanup patterns live in `bin/prompts.json`. Edit that file to:
package/bin/git-aic.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { spawnSync } from "node:child_process";
3
+ import { execFileSync, spawnSync } from "node:child_process";
4
4
  import {
5
5
  existsSync,
6
6
  mkdtempSync,
@@ -16,6 +16,7 @@ import { runInitWizard } from "./wizard.js";
16
16
  import { cleanCommitMessage } from "./clean.js";
17
17
  import { extractCommitMessage } from "./extract.js";
18
18
  import { runProvider } from "./run-provider.js";
19
+ import { spawnCross } from "./spawn.js";
19
20
  import { applyUserOverrides, getConfiguration } from "./prompts.js";
20
21
  import { getConfigPath, loadConfig } from "./get-config-path.js";
21
22
  import { PROVIDER_PRESETS } from "./presets.js";
@@ -28,6 +29,9 @@ function fail(message, code = 1) {
28
29
  function run(command, args, options = {}) {
29
30
  return spawnSync(command, args, {
30
31
  encoding: "utf8",
32
+ // Suppress the transient console window Windows flashes for piped child
33
+ // processes (no effect on other platforms).
34
+ windowsHide: true,
31
35
  ...options,
32
36
  });
33
37
  }
@@ -37,9 +41,11 @@ function parseArgs(argv) {
37
41
  let shouldCommit = null;
38
42
  let requireEdit = null;
39
43
  let printConfig = false;
44
+ let printVersion = false;
40
45
  let runInit = false;
41
46
  let shouldAdd = false;
42
47
  let providerName = null;
48
+ let verbose = false;
43
49
 
44
50
  for (let i = 0; i < argv.length; i++) {
45
51
  const arg = argv[i];
@@ -99,6 +105,16 @@ function parseArgs(argv) {
99
105
  continue;
100
106
  }
101
107
 
108
+ if (arg === "-v" || arg === "--version") {
109
+ printVersion = true;
110
+ continue;
111
+ }
112
+
113
+ if (arg === "-V" || arg === "--verbose") {
114
+ verbose = true;
115
+ continue;
116
+ }
117
+
102
118
  if (arg === "--init") {
103
119
  runInit = true;
104
120
  continue;
@@ -111,10 +127,12 @@ Usage:
111
127
  git aic -a # stage all changes first (git add -A)
112
128
  git aic -p "extra prompt"
113
129
  git aic -ap "extra prompt" # combine -a and -p
114
- git aic --provider gemini # one-off provider override (also -P)
130
+ git aic --provider agy # one-off provider override (also -P)
115
131
  git aic --provider # list available providers
116
132
  git aic --no-commit
117
133
  git aic --config
134
+ git aic -v, --version
135
+ git aic -V, --verbose # print the prompt and raw provider output
118
136
  git aic --init
119
137
 
120
138
  Examples:
@@ -143,9 +161,11 @@ Environment:
143
161
  shouldCommit,
144
162
  requireEdit,
145
163
  printConfig,
164
+ printVersion,
146
165
  runInit,
147
166
  shouldAdd,
148
167
  providerName,
168
+ verbose,
149
169
  };
150
170
  }
151
171
 
@@ -159,8 +179,37 @@ function buildPrompt(extraPrompt) {
159
179
  return cfg.prompt.join("\n").replaceAll(token, extraPrompt || extraPromptFallback);
160
180
  }
161
181
 
182
+ function getVersion() {
183
+ const packageJsonPath = new URL("../package.json", import.meta.url);
184
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
185
+ return packageJson.version;
186
+ }
187
+
188
+ function readStagedDiff() {
189
+ try {
190
+ return execFileSync(
191
+ "git",
192
+ ["diff", "--cached", "--no-ext-diff"],
193
+ {
194
+ encoding: "utf8",
195
+ maxBuffer: 50 * 1024 * 1024,
196
+ windowsHide: true,
197
+ }
198
+ );
199
+ } catch (error) {
200
+ const detail = error?.message ? `\n${error.message}` : "";
201
+ throw new Error(`Failed to read staged diff.${detail}`);
202
+ }
203
+ }
204
+
162
205
 
163
206
  const args = parseArgs(process.argv.slice(2));
207
+
208
+ if (args.printVersion) {
209
+ console.log(getVersion());
210
+ process.exit(0);
211
+ }
212
+
164
213
  const configPath = getConfigPath();
165
214
 
166
215
  if (args.runInit) {
@@ -192,7 +241,11 @@ if (args.providerName) {
192
241
  const names = PROVIDER_PRESETS.map((p) => p.command).join(", ");
193
242
  fail(`Unknown provider: ${args.providerName}. Available: ${names}`);
194
243
  }
195
- config.provider = { command: preset.command, args: preset.args };
244
+ config.provider = {
245
+ command: preset.command,
246
+ args: preset.args,
247
+ ...(preset.windows ? { windows: preset.windows } : {}),
248
+ };
196
249
  }
197
250
 
198
251
  if (args.printConfig) {
@@ -223,23 +276,36 @@ if (hasStagedChanges.status === 0) {
223
276
  fail("No staged changes found. Use: git add <files>");
224
277
  }
225
278
 
226
- const diff = run("git", ["diff", "--cached"]);
227
-
228
- if (diff.status !== 0) {
229
- fail(diff.stderr || "Failed to read staged diff.");
230
- }
279
+ const diff = readStagedDiff();
280
+ const MAX_DIFF_CHARS = 120_000;
281
+ const safeDiff =
282
+ diff.length > MAX_DIFF_CHARS
283
+ ? `${diff.slice(0, MAX_DIFF_CHARS)}\n\n[Diff truncated because it was too large.]`
284
+ : diff;
231
285
 
232
286
  const prompt = buildPrompt(args.extraPrompt);
233
287
 
234
288
  console.log(`Using provider: ${config.provider.command}`);
235
289
 
290
+ if (args.verbose) {
291
+ console.error("[verbose] ----- prompt -----");
292
+ console.error(prompt);
293
+ console.error("[verbose] ----- end prompt -----");
294
+ }
295
+
236
296
  let raw;
237
297
  try {
238
- raw = runProvider(config.provider, prompt, diff.stdout);
298
+ raw = runProvider(config.provider, prompt, safeDiff, { verbose: args.verbose });
239
299
  } catch (error) {
240
300
  fail(error.message);
241
301
  }
242
302
 
303
+ if (args.verbose) {
304
+ console.error("[verbose] ----- raw provider output -----");
305
+ console.error(raw);
306
+ console.error("[verbose] ----- end raw provider output -----");
307
+ }
308
+
243
309
  const message = extractCommitMessage(raw);
244
310
 
245
311
  if (!message) {
@@ -254,8 +320,12 @@ writeFileSync(file, `${message.trim()}\n`);
254
320
  const before = sha256(readFileSync(file, "utf8"));
255
321
  const mtimeBefore = statSync(file).mtimeMs;
256
322
 
257
- const editor = process.env.EDITOR || config.editor || "nano";
258
- const editorResult = spawnSync(editor, [file], {
323
+ const defaultEditor = process.platform === "win32" ? "notepad" : "nano";
324
+ const editor = process.env.EDITOR || config.editor || defaultEditor;
325
+ // Editor configs can carry flags (e.g. "code --wait"), so split the command
326
+ // from its arguments before spawning.
327
+ const [editorCommand, ...editorArgs] = editor.trim().split(/\s+/);
328
+ const editorResult = spawnCross(editorCommand, [...editorArgs, file], {
259
329
  stdio: "inherit",
260
330
  });
261
331
 
package/bin/presets.js CHANGED
@@ -5,9 +5,13 @@ export const PROVIDER_PRESETS = [
5
5
  args: ["-p", "{prompt}"]
6
6
  },
7
7
  {
8
- name: "Gemini CLI",
9
- command: "gemini",
10
- args: ["-p", "{prompt}"]
8
+ name: "Antigravity CLI",
9
+ command: "agy",
10
+ args: ["-p", "{prompt}"],
11
+ // agy renders to the Windows console device instead of stdout, so it can
12
+ // only be captured when spawned with no console (detached). This pops a
13
+ // brief console window on Windows; CLIs that write to stdout don't need it.
14
+ windows: { detached: true }
11
15
  },
12
16
  {
13
17
  name: "Codex CLI",
@@ -31,4 +35,4 @@ export const PROVIDER_PRESETS = [
31
35
  }
32
36
  ];
33
37
 
34
- export const EDITOR_PRESETS = ["nano", "vim", "nvim", "code --wait", "micro", "hx"];
38
+ export const EDITOR_PRESETS = ["nano", "vim", "nvim", "code --wait", "micro", "hx", "notepad"];
@@ -1,30 +1,58 @@
1
- import { spawnSync } from "node:child_process";
1
+ import { spawnCross, isWindowsShim } from "./spawn.js";
2
2
 
3
- export function runProvider(provider, prompt, diff) {
3
+ // Keep verbose output readable: show short args verbatim, summarise long ones
4
+ // (like the full prompt) by byte size instead of dumping them.
5
+ function previewArg(arg) {
6
+ return arg.length > 60 ? `<${Buffer.byteLength(arg)} bytes>` : arg;
7
+ }
8
+
9
+ export function runProvider(provider, prompt, diff, { verbose = false } = {}) {
4
10
  if (!provider?.command) {
5
11
  throw new Error("Config error: provider.command is required.");
6
12
  }
7
13
 
8
14
  const args = provider.args ?? [];
9
-
10
- const finalArgs = args.map((arg) =>
11
- arg
12
- .replaceAll("{prompt}", prompt)
13
- .replaceAll("{diff}", diff)
14
- );
15
-
16
15
  const usesPromptPlaceholder = args.some((arg) => arg.includes("{prompt}"));
17
16
  const usesDiffPlaceholder = args.some((arg) => arg.includes("{diff}"));
18
17
 
19
- const input = usesDiffPlaceholder ? undefined : diff;
18
+ let finalArgs;
19
+ let input;
20
+
21
+ if (isWindowsShim(provider.command) && !usesDiffPlaceholder) {
22
+ // Batch shims (e.g. claude.cmd) run through cmd.exe, which truncates a
23
+ // command-line argument at the first newline — so the multi-line prompt
24
+ // can't be passed inline. Send the prompt and diff via stdin and drop the
25
+ // inline {prompt}; CLIs like claude read the prompt from stdin when no
26
+ // inline prompt is given.
27
+ finalArgs = args.filter((arg) => arg.trim() !== "{prompt}");
28
+ input = `${prompt}\n\n${diff}`;
29
+ } else {
30
+ finalArgs = args.map((arg) =>
31
+ arg.replaceAll("{prompt}", prompt).replaceAll("{diff}", diff)
32
+ );
33
+ input = usesDiffPlaceholder ? undefined : diff;
34
+ if (!usesPromptPlaceholder) {
35
+ finalArgs.push(prompt);
36
+ }
37
+ }
20
38
 
21
- if (!usesPromptPlaceholder) {
22
- finalArgs.push(prompt);
39
+ if (verbose) {
40
+ const preview = finalArgs.map(previewArg).join(" ");
41
+ console.error(`[verbose] command: ${provider.command} ${preview}`);
42
+ console.error(
43
+ `[verbose] stdin: ${input ? `${Buffer.byteLength(input)} bytes` : "(none)"}`
44
+ );
23
45
  }
24
46
 
25
- const result = spawnSync(provider.command, finalArgs, {
47
+ // Per-provider Windows spawn options (e.g. { detached: true } for agy).
48
+ // Applied only on Windows; ignored elsewhere.
49
+ const windowsOptions =
50
+ process.platform === "win32" ? provider.windows ?? {} : {};
51
+
52
+ const result = spawnCross(provider.command, finalArgs, {
26
53
  input,
27
54
  encoding: "utf8",
55
+ ...windowsOptions,
28
56
  });
29
57
 
30
58
  if (result.status !== 0) {
package/bin/spawn.js ADDED
@@ -0,0 +1,97 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { delimiter, extname, isAbsolute, join } from "node:path";
4
+
5
+ const isWindows = process.platform === "win32";
6
+
7
+ // On Windows, a CLI on PATH may be a native executable (`agy.exe`) or a batch
8
+ // shim that npm generates for global installs (`claude.cmd`). Native exes can
9
+ // be spawned directly and accept multi-line arguments; batch shims must be run
10
+ // through cmd.exe, which mangles multi-line arguments. We need to tell them
11
+ // apart, so resolve the command to a real file and inspect its extension.
12
+ function resolveWindowsCommand(command) {
13
+ if (isAbsolute(command) || command.includes("\\") || command.includes("/")) {
14
+ return { file: command, ext: extname(command).toLowerCase() };
15
+ }
16
+
17
+ const pathext = (process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD")
18
+ .split(";")
19
+ .filter(Boolean);
20
+ const hasExt = extname(command) !== "";
21
+ const candidates = hasExt ? [command] : pathext.map((ext) => command + ext);
22
+ const dirs = (process.env.PATH || "").split(delimiter).filter(Boolean);
23
+
24
+ for (const dir of dirs) {
25
+ for (const candidate of candidates) {
26
+ const full = join(dir, candidate);
27
+ if (existsSync(full)) {
28
+ return { file: full, ext: extname(full).toLowerCase() };
29
+ }
30
+ }
31
+ }
32
+
33
+ // Not found on PATH — hand it back unchanged and let spawn surface the error.
34
+ return { file: command, ext: extname(command).toLowerCase() };
35
+ }
36
+
37
+ // Whether a command resolves to a batch/PowerShell shim on Windows (e.g. the
38
+ // `claude.cmd` npm installs). Such shims run through cmd.exe, which truncates
39
+ // arguments at the first newline — so callers must avoid passing multi-line
40
+ // arguments to them. Always false off Windows.
41
+ export function isWindowsShim(command) {
42
+ if (!isWindows) return false;
43
+ const { ext } = resolveWindowsCommand(command);
44
+ return ext === ".cmd" || ext === ".bat" || ext === ".ps1";
45
+ }
46
+
47
+ // Quoting for the cmd.exe shell path. With `shell: true` Node concatenates argv
48
+ // verbatim instead of quoting it, so we wrap anything containing whitespace or
49
+ // shell metacharacters ourselves.
50
+ function quoteWinArg(arg) {
51
+ if (arg === "") return '""';
52
+ if (!/[\s"&|<>^()]/.test(arg)) return arg;
53
+ const escaped = arg
54
+ .replace(/(\\*)"/g, '$1$1\\"') // double backslashes before a quote, then escape the quote
55
+ .replace(/(\\*)$/, "$1$1"); // double trailing backslashes so they don't escape the closing quote
56
+ return `"${escaped}"`;
57
+ }
58
+
59
+ // Cross-platform spawnSync. Non-Windows spawns the command directly. On Windows
60
+ // native executables are also spawned directly (preserving multi-line args),
61
+ // while batch/PowerShell shims are routed through the shell so PATHEXT resolves
62
+ // them.
63
+ export function spawnCross(command, args = [], options = {}) {
64
+ if (!isWindows) {
65
+ return spawnSync(command, args, options);
66
+ }
67
+
68
+ const { file, ext } = resolveWindowsCommand(command);
69
+ const needsShell = ext === ".cmd" || ext === ".bat" || ext === ".ps1";
70
+ // Interactive spawns (the editor) inherit stdio and need a real, visible
71
+ // console; only capturing spawns get the window-suppression flags below.
72
+ // detached and windowsHide map to mutually exclusive console-creation flags
73
+ // (DETACHED_PROCESS vs CREATE_NO_WINDOW), so each path picks exactly one.
74
+ const capturing = options.stdio !== "inherit";
75
+
76
+ if (!needsShell) {
77
+ // Native exe. A caller can pass `detached: true` for a CLI that renders to
78
+ // the console device instead of stdout (e.g. agy): detached gives it no
79
+ // console so it falls back to stdout — at the cost of a brief console
80
+ // window. Everything else just gets a hidden console (no window).
81
+ const wantsDetached = capturing && options.detached === true;
82
+ const extra = capturing && !wantsDetached ? { windowsHide: true } : {};
83
+ return spawnSync(file, args, { ...extra, ...options });
84
+ }
85
+
86
+ // Batch/PowerShell shim — route through cmd.exe so PATHEXT resolves it.
87
+ // windowsHide (CREATE_NO_WINDOW) gives cmd.exe a hidden console: no popup,
88
+ // and shims like claude write to stdout normally so capture still works.
89
+ // detached would pop a cmd.exe window and shims never need it, so drop it.
90
+ const { detached, ...shimOptions } = options;
91
+ const extra = capturing ? { windowsHide: true } : {};
92
+ return spawnSync(file, args.map(quoteWinArg), {
93
+ ...extra,
94
+ ...shimOptions,
95
+ shell: true,
96
+ });
97
+ }
package/bin/wizard.js CHANGED
@@ -75,7 +75,11 @@ async function pickProvider(rl) {
75
75
 
76
76
  if (index >= 0 && index < PROVIDER_PRESETS.length) {
77
77
  const preset = PROVIDER_PRESETS[index];
78
- return { command: preset.command, args: preset.args };
78
+ return {
79
+ command: preset.command,
80
+ args: preset.args,
81
+ ...(preset.windows ? { windows: preset.windows } : {}),
82
+ };
79
83
  }
80
84
 
81
85
  if (index === PROVIDER_PRESETS.length) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thankarezos/git-ai-commit",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Generate editable git commit messages using a configurable AI CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,7 +32,7 @@
32
32
  "commit",
33
33
  "cli",
34
34
  "claude",
35
- "gemini",
35
+ "antigravity",
36
36
  "conventional-commits"
37
37
  ],
38
38
  "license": "MIT"