@jakejarvis/acai 0.2.1 → 0.3.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/README.md +12 -8
  2. package/dist/cli.mjs +159 -93
  3. package/package.json +10 -5
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # acai
2
2
 
3
- AI-generated commit messages that match your repo's existing style. Powered by [Claude Code](https://docs.anthropic.com/en/docs/claude-code) or [Codex](https://github.com/openai/codex).
3
+ AI-generated commit messages that match your repo's existing style. Powered by [Claude Code](https://docs.anthropic.com/en/docs/claude-code) or [Codex](https://github.com/openai/codex), with real-time streaming as the message is generated.
4
4
 
5
5
  `acai` reads your repo's recent commit history and adapts to whatever conventions your team already uses — conventional commits, gitmoji, ticket prefixes, pig latin, etc.
6
6
 
@@ -29,8 +29,11 @@ AI-generated commit messages that match your repo's existing style. Powered by [
29
29
  | Flag | Env var | Default | Description |
30
30
  |------|---------|---------|-------------|
31
31
  | `-p, --provider` | `ACAI_PROVIDER` | `claude` | AI provider (`claude`, `codex`) |
32
- | `-m, --model` | `ACAI_MODEL` | provider default | Model override (`sonnet`, `gpt-5.3-codex`, etc.) |
32
+ | `--claude, --codex` | `ACAI_PROVIDER` | `claude` | Shorthand for `--provider <name>` |
33
+ | `-m, --model` | `ACAI_MODEL` | `sonnet` or `gpt-5.4-mini` | Model override |
33
34
  | `-y, --yolo` | — | `false` | Stage all changes and commit without confirmation |
35
+ | `-V, --verbose` | — | `false` | Print prompts sent to the provider and raw responses |
36
+ | `-v, --version` | — | — | Show version number |
34
37
 
35
38
  ## Usage
36
39
 
@@ -45,11 +48,12 @@ npm install -g @jakejarvis/acai
45
48
  acai
46
49
 
47
50
  # Use a different provider
51
+ acai --codex
48
52
  acai -p codex
49
53
 
50
54
  # Override the model
51
55
  acai -m haiku
52
- acai -p codex -m o4-mini
56
+ acai --codex -m gpt-5.4-mini
53
57
 
54
58
  # Stage everything, generate, and commit — no prompts
55
59
  acai --yolo
@@ -62,14 +66,14 @@ acai --yolo
62
66
 
63
67
  ◇ 3 files staged
64
68
 
65
- Generating commit message…
69
+ Here's what Claude (sonnet) came up with:
66
70
 
67
71
  │ feat(auth): add session expiry validation
68
72
 
69
- ◆ What do you want to do?
73
+ ◆ What's next?
70
74
  │ ✓ Commit — accept and commit
71
75
  │ ✎ Edit — open in $EDITOR before committing
72
- │ ↻ Revise — give Claude feedback and regenerate
76
+ │ ↻ Revise — give feedback and regenerate
73
77
  │ ⎘ Copy — copy to clipboard, don't commit
74
78
  │ ✕ Cancel
75
79
 
@@ -78,13 +82,13 @@ acai --yolo
78
82
 
79
83
  ### Revision loop
80
84
 
81
- Choose **Revise** and tell Claude what to change:
85
+ Choose **Revise** and tell the provider what to change:
82
86
 
83
87
  ```
84
88
  ◆ What should Claude change?
85
89
  │ make it shorter, drop the scope
86
90
 
87
- Generating commit message…
91
+ Here's what Claude (sonnet) came up with:
88
92
 
89
93
  │ feat: add session expiry validation
90
94
  ```
package/dist/cli.mjs CHANGED
@@ -7,6 +7,8 @@ import c from "readline";
7
7
  import { ReadStream } from "tty";
8
8
  import { mkdtempSync, rmSync, writeFileSync } from "fs";
9
9
  import { delimiter, dirname, join, normalize, resolve } from "path";
10
+ import { query } from "@anthropic-ai/claude-agent-sdk";
11
+ import { Codex } from "@openai/codex-sdk";
10
12
  import { spawn } from "child_process";
11
13
  import { PassThrough } from "stream";
12
14
  import { tmpdir } from "os";
@@ -1912,61 +1914,86 @@ const providers = {
1912
1914
  bin: "claude",
1913
1915
  versionArgs: ["--version"],
1914
1916
  defaultModel: "sonnet",
1915
- buildArgs({ userPrompt, systemPrompt, model }) {
1916
- return [
1917
- "-p",
1918
- userPrompt,
1919
- "--output-format",
1920
- "json",
1921
- "--model",
1922
- model,
1923
- "--tools",
1924
- "",
1925
- "--strict-mcp-config",
1926
- "--no-session-persistence",
1927
- "--system-prompt",
1928
- systemPrompt
1929
- ];
1930
- },
1931
- parseOutput(stdout) {
1932
- let parsed;
1917
+ async *generate(opts) {
1918
+ const systemPrompt = buildSystemPrompt(opts.commitLog, opts.instructions);
1919
+ const userPrompt = buildUserPrompt(opts.diff, opts.stat, opts.files);
1920
+ opts.log?.(`System prompt:\n${systemPrompt}`);
1921
+ opts.log?.(`User prompt:\n${userPrompt}`);
1922
+ const abortController = new AbortController();
1923
+ const timeout = setTimeout(() => abortController.abort(), 12e4);
1933
1924
  try {
1934
- parsed = JSON.parse(stdout);
1935
- } catch {
1936
- throw new Error(`Failed to parse Claude response as JSON. Raw output:\n${stdout.slice(0, 500)}`);
1925
+ let fullText = "";
1926
+ for await (const message of query({
1927
+ prompt: userPrompt,
1928
+ options: {
1929
+ systemPrompt,
1930
+ model: opts.model,
1931
+ tools: [],
1932
+ permissionMode: "bypassPermissions",
1933
+ allowDangerouslySkipPermissions: true,
1934
+ persistSession: false,
1935
+ includePartialMessages: true,
1936
+ maxTurns: 1,
1937
+ abortController
1938
+ }
1939
+ })) {
1940
+ if (message.type === "stream_event") {
1941
+ const event = message.event;
1942
+ if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
1943
+ fullText += event.delta.text;
1944
+ yield event.delta.text;
1945
+ }
1946
+ }
1947
+ if (message.type === "assistant" && !message.error) {
1948
+ const content = message.message?.content;
1949
+ if (Array.isArray(content)) {
1950
+ for (const block of content) if ("text" in block && block.text && !fullText) {
1951
+ fullText = block.text;
1952
+ yield block.text;
1953
+ }
1954
+ }
1955
+ }
1956
+ if (message.type === "result" && message.is_error) throw new Error(`Claude error: ${message.subtype === "success" ? message.result : message.errors?.join(", ")}`);
1957
+ if (message.type === "assistant" && message.error) throw new Error(`Claude ${message.error} error`);
1958
+ }
1959
+ if (!fullText.trim()) throw new Error("Claude returned an empty commit message.");
1960
+ } finally {
1961
+ clearTimeout(timeout);
1937
1962
  }
1938
- if (parsed.is_error) throw new Error(`Claude error: ${parsed.result}`);
1939
- const result = (parsed.result || "").trim();
1940
- if (!result) throw new Error("Claude returned an empty commit message.");
1941
- return result;
1942
1963
  }
1943
1964
  },
1944
1965
  codex: {
1945
1966
  name: "Codex",
1946
1967
  bin: "codex",
1947
1968
  versionArgs: ["--version"],
1948
- defaultModel: "gpt-5.1-codex-mini",
1949
- buildArgs({ userPrompt, systemPrompt, model }) {
1950
- return [
1951
- "exec",
1952
- userPrompt,
1953
- "--model",
1954
- model,
1955
- "-c",
1956
- `developer_instructions=${systemPrompt}`,
1957
- "-c",
1958
- "model_reasoning_effort=medium",
1959
- "-c",
1960
- "check_for_update_on_startup=false",
1961
- "--ephemeral",
1962
- "--sandbox",
1963
- "read-only"
1964
- ];
1965
- },
1966
- parseOutput(stdout) {
1967
- const result = stdout.trim();
1968
- if (!result) throw new Error("Codex returned an empty commit message.");
1969
- return result;
1969
+ defaultModel: "gpt-5.4-mini",
1970
+ async *generate(opts) {
1971
+ const systemPrompt = buildSystemPrompt(opts.commitLog, opts.instructions);
1972
+ const userPrompt = buildUserPrompt(opts.diff, opts.stat, opts.files);
1973
+ opts.log?.(`System prompt:\n${systemPrompt}`);
1974
+ opts.log?.(`User prompt:\n${userPrompt}`);
1975
+ const { events } = await new Codex({ config: {
1976
+ developer_instructions: systemPrompt,
1977
+ model_reasoning_effort: "medium",
1978
+ check_for_update_on_startup: false
1979
+ } }).startThread({
1980
+ model: opts.model,
1981
+ sandboxMode: "read-only",
1982
+ skipGitRepoCheck: true
1983
+ }).runStreamed(userPrompt);
1984
+ let lastText = "";
1985
+ for await (const event of events) {
1986
+ opts.log?.(`Event: ${JSON.stringify(event).slice(0, 300)}`);
1987
+ if (event.type === "turn.failed") throw new Error(`Codex error: ${event.error.message}`);
1988
+ if ((event.type === "item.updated" || event.type === "item.completed") && event.item.type === "agent_message") {
1989
+ const newText = event.item.text;
1990
+ if (newText.length > lastText.length) {
1991
+ yield newText.slice(lastText.length);
1992
+ lastText = newText;
1993
+ }
1994
+ }
1995
+ }
1996
+ if (!lastText.trim()) throw new Error("Codex returned an empty commit message.");
1970
1997
  }
1971
1998
  }
1972
1999
  };
@@ -1981,32 +2008,6 @@ async function ensureProvider(provider) {
1981
2008
  ] } });
1982
2009
  if (exitCode !== 0) throw new Error(`${provider.name} CLI ("${provider.bin}") not found. Make sure it is installed and on your PATH.`);
1983
2010
  }
1984
- /**
1985
- * Generate a commit message using the given provider's CLI.
1986
- */
1987
- async function generateCommitMessage(provider, opts) {
1988
- const { diff, stat, files, commitLog, model, instructions, log } = opts;
1989
- const systemPrompt = buildSystemPrompt(commitLog, instructions);
1990
- const userPrompt = buildUserPrompt(diff, stat, files);
1991
- log?.(`System prompt:\n${systemPrompt}`);
1992
- log?.(`User prompt:\n${userPrompt}`);
1993
- const args = provider.buildArgs({
1994
- userPrompt,
1995
- systemPrompt,
1996
- model
1997
- });
1998
- const { stdout, stderr, exitCode } = await z(provider.bin, args, {
1999
- timeout: 12e4,
2000
- nodeOptions: { stdio: [
2001
- "ignore",
2002
- "pipe",
2003
- "pipe"
2004
- ] }
2005
- });
2006
- log?.(`Raw response:\n${stdout}${stderr ? `\nStderr:\n${stderr}` : ""}`);
2007
- if (exitCode !== 0) throw new Error(`${provider.name} exited with code ${exitCode}\n${stderr || stdout}`);
2008
- return provider.parseOutput(stdout);
2009
- }
2010
2011
  //#endregion
2011
2012
  //#region src/args.ts
2012
2013
  const DEFAULT_PROVIDER = "claude";
@@ -2024,13 +2025,19 @@ const options = {
2024
2025
  short: "y"
2025
2026
  },
2026
2027
  verbose: {
2028
+ type: "boolean",
2029
+ short: "V"
2030
+ },
2031
+ version: {
2027
2032
  type: "boolean",
2028
2033
  short: "v"
2029
2034
  },
2030
2035
  help: {
2031
2036
  type: "boolean",
2032
2037
  short: "h"
2033
- }
2038
+ },
2039
+ claude: { type: "boolean" },
2040
+ codex: { type: "boolean" }
2034
2041
  };
2035
2042
  function parseConfig() {
2036
2043
  const { values } = parseArgs({
@@ -2038,11 +2045,13 @@ function parseConfig() {
2038
2045
  options,
2039
2046
  strict: false
2040
2047
  });
2048
+ const providerAlias = values.codex ? "codex" : values.claude ? "claude" : void 0;
2041
2049
  return {
2042
- provider: values.provider ?? process.env.ACAI_PROVIDER ?? DEFAULT_PROVIDER,
2050
+ provider: values.provider ?? providerAlias ?? process.env.ACAI_PROVIDER ?? DEFAULT_PROVIDER,
2043
2051
  model: values.model ?? process.env.ACAI_MODEL ?? "",
2044
2052
  yolo: values.yolo ?? false,
2045
2053
  verbose: values.verbose ?? false,
2054
+ version: values.version ?? false,
2046
2055
  help: values.help ?? false
2047
2056
  };
2048
2057
  }
@@ -2053,11 +2062,13 @@ Usage: acai [options]
2053
2062
 
2054
2063
  Options:
2055
2064
  -p, --provider <name> AI provider to use (${providerNames}) (default: ${DEFAULT_PROVIDER})
2065
+ --claude, --codex Shorthand for --provider <name>
2056
2066
  Can also set ACAI_PROVIDER env var
2057
2067
  -m, --model <model> Model to use (default: provider-specific)
2058
2068
  Can also set ACAI_MODEL env var
2059
2069
  -y, --yolo Stage all changes and commit without confirmation
2060
- -v, --verbose Print prompts sent to the provider and raw responses
2070
+ -V, --verbose Print prompts sent to the provider and raw responses
2071
+ -v, --version Show version number
2061
2072
  -h, --help Show this help message
2062
2073
 
2063
2074
  Examples:
@@ -2070,19 +2081,51 @@ Examples:
2070
2081
  }
2071
2082
  //#endregion
2072
2083
  //#region src/git.ts
2084
+ /**
2085
+ * Files excluded from diffs sent to the AI provider.
2086
+ * These are still listed as changed files — just their content is omitted
2087
+ * to avoid wasting tokens on generated/binary/noisy content.
2088
+ */
2089
+ const DIFF_EXCLUDE_PATTERNS = [
2090
+ "package-lock.json",
2091
+ "yarn.lock",
2092
+ "pnpm-lock.yaml",
2093
+ "bun.lock",
2094
+ "bun.lockb",
2095
+ "deno.lock",
2096
+ "Cargo.lock",
2097
+ "Gemfile.lock",
2098
+ "composer.lock",
2099
+ "poetry.lock",
2100
+ "uv.lock",
2101
+ "go.sum",
2102
+ "flake.lock",
2103
+ "*.pbxproj",
2104
+ "*.xcworkspacedata",
2105
+ "*.map"
2106
+ ];
2073
2107
  async function ensureGitRepo() {
2074
2108
  const { stdout, exitCode } = await z("git", ["rev-parse", "--is-inside-work-tree"]);
2075
2109
  if (exitCode !== 0 || stdout.trim() !== "true") throw new Error("Not inside a git repository.");
2076
2110
  }
2077
2111
  async function getStagedDiff() {
2078
- const { stdout, exitCode } = await z("git", ["diff", "--cached"]);
2112
+ const { stdout, exitCode } = await z("git", [
2113
+ "diff",
2114
+ "--cached",
2115
+ "--",
2116
+ ".",
2117
+ ...DIFF_EXCLUDE_PATTERNS.map((p) => `:(exclude)${p}`)
2118
+ ]);
2079
2119
  return exitCode === 0 ? stdout.trim() : null;
2080
2120
  }
2081
2121
  async function getStagedStat() {
2082
2122
  const { stdout, exitCode } = await z("git", [
2083
2123
  "diff",
2084
2124
  "--cached",
2085
- "--stat"
2125
+ "--stat",
2126
+ "--",
2127
+ ".",
2128
+ ...DIFF_EXCLUDE_PATTERNS.map((p) => `:(exclude)${p}`)
2086
2129
  ]);
2087
2130
  return exitCode === 0 ? stdout.trim() : null;
2088
2131
  }
@@ -2177,6 +2220,12 @@ async function commit(message) {
2177
2220
  //#region bin/cli.ts
2178
2221
  async function main() {
2179
2222
  const config = parseConfig();
2223
+ if (config.version) {
2224
+ const { createRequire } = await import("module");
2225
+ const pkg = createRequire(import.meta.url)("../package.json");
2226
+ console.log(pkg.version);
2227
+ process.exit(0);
2228
+ }
2180
2229
  if (config.help) {
2181
2230
  printUsage();
2182
2231
  process.exit(0);
@@ -2189,7 +2238,6 @@ async function main() {
2189
2238
  }
2190
2239
  const model = config.model || provider.defaultModel;
2191
2240
  Wt("acai");
2192
- const s = be();
2193
2241
  try {
2194
2242
  await ensureGitRepo();
2195
2243
  } catch {
@@ -2257,10 +2305,15 @@ async function main() {
2257
2305
  const commitLog = await getRecentCommitLog(10) || "";
2258
2306
  let instructions;
2259
2307
  while (true) {
2260
- s.start(`Waiting for ${provider.name}`);
2261
2308
  let message;
2309
+ let firstToken = true;
2310
+ const BAR = import_picocolors.default.gray("│");
2311
+ const s = be();
2312
+ s.start(`Waiting for ${provider.name}`);
2262
2313
  try {
2263
- message = await generateCommitMessage(provider, {
2314
+ let fullText = "";
2315
+ let inBody = false;
2316
+ const generator = provider.generate({
2264
2317
  diff,
2265
2318
  stat,
2266
2319
  files,
@@ -2269,19 +2322,40 @@ async function main() {
2269
2322
  instructions,
2270
2323
  log: config.verbose ? (msg) => R$1.message(import_picocolors.default.dim(msg)) : void 0
2271
2324
  });
2325
+ for await (const token of generator) {
2326
+ if (firstToken) {
2327
+ s.stop(`Here's what ${provider.name} ${import_picocolors.default.dim(`(${model})`)} came up with:\n`);
2328
+ process.stderr.write(`${BAR} `);
2329
+ firstToken = false;
2330
+ }
2331
+ fullText += token;
2332
+ const parts = token.split("\n");
2333
+ for (let i = 0; i < parts.length; i++) {
2334
+ if (i > 0) {
2335
+ inBody = true;
2336
+ process.stderr.write(`\n${BAR} `);
2337
+ }
2338
+ if (parts[i]) process.stderr.write(inBody ? import_picocolors.default.dim(parts[i]) : import_picocolors.default.bold(import_picocolors.default.green(parts[i])));
2339
+ }
2340
+ }
2341
+ process.stderr.write(`\n${BAR}\n`);
2342
+ if (firstToken) s.stop("No response");
2343
+ message = fullText.trim();
2272
2344
  } catch (e) {
2273
- s.stop("Failed");
2345
+ if (firstToken) s.stop("Failed");
2274
2346
  Nt(`Generation failed: ${e.message}`);
2275
2347
  process.exit(1);
2276
2348
  }
2277
- s.stop(`Here's what ${provider.name} ${import_picocolors.default.dim(`(${model})`)} came up with:`);
2278
- R$1.message(formatMessageForDisplay(message));
2349
+ if (!message) {
2350
+ Nt("Provider returned an empty message.");
2351
+ process.exit(1);
2352
+ }
2279
2353
  if (config.yolo) {
2280
2354
  await doCommit(message);
2281
2355
  break;
2282
2356
  }
2283
2357
  const action = await Jt({
2284
- message: "What should we do?",
2358
+ message: "What's next?",
2285
2359
  options: [
2286
2360
  {
2287
2361
  value: "commit",
@@ -2379,14 +2453,6 @@ async function doCommit(message) {
2379
2453
  process.exit(1);
2380
2454
  }
2381
2455
  }
2382
- function formatMessageForDisplay(message) {
2383
- const lines = message.split("\n");
2384
- const subject = lines[0];
2385
- const body = lines.slice(1).join("\n").trim();
2386
- let display = import_picocolors.default.bold(import_picocolors.default.green(subject));
2387
- if (body) display += `\n${import_picocolors.default.dim(body)}`;
2388
- return display;
2389
- }
2390
2456
  async function editInEditor(message) {
2391
2457
  const { spawn } = await import("child_process");
2392
2458
  const { writeFileSync, readFileSync, mkdtempSync, rmSync } = await import("fs");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jakejarvis/acai",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "description": "Yet another AI commit message generator",
6
6
  "repository": {
@@ -20,7 +20,7 @@
20
20
  },
21
21
  "type": "module",
22
22
  "bin": {
23
- "acai": "./dist/cli.mjs"
23
+ "acai": "dist/cli.mjs"
24
24
  },
25
25
  "files": [
26
26
  "dist"
@@ -30,15 +30,20 @@
30
30
  "lint": "biome check",
31
31
  "format": "biome format --write",
32
32
  "check-types": "tsc --noEmit",
33
- "prepublishOnly": "npm run build"
33
+ "prepack": "npm run build"
34
+ },
35
+ "dependencies": {
36
+ "@anthropic-ai/claude-agent-sdk": "^0.2.81",
37
+ "@openai/codex-sdk": "^0.116.0",
38
+ "zod": "^4.3.6"
34
39
  },
35
40
  "devDependencies": {
36
- "@biomejs/biome": "2.4.7",
41
+ "@biomejs/biome": "2.4.8",
37
42
  "@clack/prompts": "1.1.0",
38
43
  "@types/node": "^25.5.0",
39
44
  "picocolors": "1.1.1",
40
45
  "tinyexec": "1.0.4",
41
- "tsdown": "^0.21.2",
46
+ "tsdown": "0.21.4",
42
47
  "typescript": "^5.9.3"
43
48
  },
44
49
  "engines": {