@synmux/claude-commit 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/README.md ADDED
@@ -0,0 +1,177 @@
1
+ # `claude-commit`
2
+
3
+ Generate high-quality git commit messages with Claude — using your **Claude Code
4
+ subscription**, not an API key.
5
+
6
+ `claude-commit` reads your staged diff, has a strong model summarize it, and a fast
7
+ model turn that summary into a well-formed commit message. It handles diffs of any
8
+ size (including ones too large to fit in a single context window), and supports
9
+ Conventional Commits, gitmoji, first-line templates, custom instructions, and an
10
+ interactive mode for choosing between several options.
11
+
12
+ ```console
13
+ $ cco -c
14
+ ✔ Committed
15
+ feat(auth): add error handling and refresh token rotation to login
16
+ ```
17
+
18
+ ## How it works
19
+
20
+ Want more details? See [WALKTHROUGH.md](WALKTHROUGH.md).
21
+
22
+ ```plaintext
23
+ staged diff ──split──▶ [chunk, …] ──sonnet[1m]──▶ summaries ──haiku──▶ commit message
24
+ ```
25
+
26
+ 1. **Summarize** — the diff is split into chunks that fit the context window and
27
+ each chunk is summarized by a strong model (`sonnet[1m]`, Sonnet with a 1M-token
28
+ context). Diffs larger than 1M tokens simply produce more chunks.
29
+ 2. **Write** — the summaries are handed to a fast model (`haiku`) that writes the
30
+ final commit message according to your formatting rules.
31
+
32
+ Both stages run through the [Claude Agent SDK](https://code.claude.com/docs/en/agent-sdk/overview).
33
+
34
+ ## Install
35
+
36
+ Requires [Bun](https://bun.sh).
37
+
38
+ ```sh
39
+ bun install
40
+ bun link # makes `cco` and `claude-commit` available on your PATH
41
+ ```
42
+
43
+ Or run it directly without linking:
44
+
45
+ ```sh
46
+ bun run bin/cco.ts --help
47
+ ```
48
+
49
+ ## Authentication
50
+
51
+ `claude-commit` uses the Claude Agent SDK and, by default, always authenticates
52
+ with your Claude Code subscription session (run `claude login` once). Usage is
53
+ bundled with your Claude Code usage — no separate API bill.
54
+
55
+ To protect you from surprise pay-as-you-go charges, API credentials in your
56
+ environment (`ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN`) are **ignored by
57
+ default**: they are stripped from the environment passed to the model
58
+ subprocess, and a one-line notice is printed to stderr. To bill an API key
59
+ instead (pay-as-you-go), opt in explicitly in your configuration:
60
+
61
+ ```json
62
+ { "allowApiKey": true }
63
+ ```
64
+
65
+ ## Usage
66
+
67
+ ```sh
68
+ cco [options]
69
+ ```
70
+
71
+ By default `cco` summarizes your **staged** changes, generates a message, shows it,
72
+ and asks for confirmation before committing. Pass `-y` to skip the prompt, or
73
+ `--dry-run` to print the message without committing.
74
+
75
+ ### Options
76
+
77
+ | Flag | Description |
78
+ | ---------------------------------------- | --------------------------------------------------------------------------------------- |
79
+ | `-i, --interactive` / `--no-interactive` | Choose between several options in an interactive TUI, or skip it when enabled in config |
80
+ | `-n, --count <n>` | Number of options to generate in interactive mode (default 3) |
81
+ | `-a, --all` | Stage all changes (`git add -A`) before committing |
82
+ | `-c, --conventional` | Format as a [Conventional Commit](https://www.conventionalcommits.org) |
83
+ | `-g, --gitmoji` | Prefix the subject with a [gitmoji](https://gitmoji.dev) |
84
+ | `-m, --multiline` / `--no-multiline` | Write a multi-line commit (subject + body), or force a single line |
85
+ | `-t, --template <tpl>` | Template for the first line, e.g. `"[PROJ-1] {message}"` |
86
+ | `-p, --prompt <text>` | Extra instructions appended to the prompt |
87
+ | `--model-summary <model>` | Model used to summarize the diff (default `sonnet[1m]`) |
88
+ | `--model-final <model>` | Model used to write the message (default `haiku`) |
89
+ | `-d, --dry-run` | Print the message to stdout without committing |
90
+ | `-y, --yes` | Commit without asking for confirmation |
91
+ | `--no-spinner` | Disable the progress spinner |
92
+ | `--config <path>` | Path to a config file |
93
+ | `-v, --verbose` | Print summaries, cost and debug output |
94
+
95
+ ### Examples
96
+
97
+ ```sh
98
+ cco # generate, confirm, and commit staged changes
99
+ cco -a -c # stage everything and write a Conventional Commit
100
+ cco -c -g -m # conventional + gitmoji + a body
101
+ cco -i -n 5 # pick from 5 options interactively
102
+ cco --dry-run | cat # print a message without committing (TUI-free, pipe-safe)
103
+ git commit -F <(cco -d) # use the message with your own git invocation
104
+ ```
105
+
106
+ In a pipe (no TTY) there is no spinner and no confirmation prompt — `cco` just
107
+ generates and commits (or prints, with `--dry-run`).
108
+
109
+ ## Interactive mode
110
+
111
+ `cco -i` opens a TUI listing several candidate messages to choose from. The
112
+ options are generated with a higher temperature (`interactiveTemperature`) for
113
+ more variety. Use the arrow keys to move between options, `Enter` to commit the
114
+ highlighted option, `e` to edit it in your `$EDITOR` first, and `q`/`Esc` to
115
+ cancel.
116
+
117
+ To make interactive mode the default without typing `-i` every time, set
118
+ `"interactive": true` in your config (see below); opt out of a single run with
119
+ `--no-interactive`. When there is no interactive terminal — in a pipe, a CI job,
120
+ or with `--dry-run` — `cco` ignores the setting and falls back to the
121
+ non-interactive flow rather than failing.
122
+
123
+ ## Configuration
124
+
125
+ Configuration is layered, from lowest to highest precedence:
126
+
127
+ 1. Built-in defaults.
128
+ 2. **A global user config** at `~/.config/claude-commit/config.json` (or
129
+ `$XDG_CONFIG_HOME/claude-commit/config.json`) — your personal defaults across
130
+ every project. The `.claude-commit.json` / `.claude-commitrc.json` /
131
+ `.claude-commitrc` names are also accepted in that directory.
132
+ 3. A `claude-commit` key in the repo's `package.json`.
133
+ 4. The nearest `.claude-commit.json` / `.claude-commitrc.json` / `.claude-commitrc`,
134
+ searched from the current directory up to the repo root.
135
+ 5. CLI flags.
136
+
137
+ So a global config sets your personal defaults and any project further down the
138
+ tree can override them. Note that the `config.json` name is recognised **only** in
139
+ the global directory; inside a project, use one of the dotted filenames. The same
140
+ keys are valid at every level:
141
+
142
+ ```json
143
+ {
144
+ "conventionalCommits": true,
145
+ "gitmoji": true,
146
+ "multiline": true,
147
+ "template": null,
148
+ "customPrompt": "Reference the ticket id from the branch name when present.",
149
+ "interactive": true,
150
+ "interactiveCount": 3,
151
+ "interactiveTemperature": 1,
152
+ "models": {
153
+ "summary": "sonnet[1m]",
154
+ "final": "haiku"
155
+ },
156
+ "maxChunkTokens": 600000,
157
+ "charsPerToken": 3.5,
158
+ "allowApiKey": false
159
+ }
160
+ ```
161
+
162
+ ## Development
163
+
164
+ ```sh
165
+ bun test # run the test suite
166
+ bun run typecheck # tsc --noEmit
167
+ ```
168
+
169
+ ## Did you vibe this?
170
+
171
+ I distinguish vibe coding and AI-assisted development by
172
+ _where you live as the developer_. If you live in the code, it's AI-assisted dev.
173
+ If you just shout at a chat and hope for the best, that's vibe coding.
174
+
175
+ This was AI-assisted development.
176
+
177
+ Thanks for coming to my TED talk.
package/bin/cco.ts ADDED
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Executable entry point for `cco` / `claude-commit`.
4
+ */
5
+ import { run } from "../src/cli";
6
+ import { color } from "../src/ui/colors";
7
+
8
+ run(process.argv.slice(2))
9
+ .then((code) => {
10
+ process.exitCode = code;
11
+ })
12
+ .catch((err) => {
13
+ // Unexpected (non-ClaudeCommitError) failures: print a stack for debugging.
14
+ process.stderr.write(
15
+ `${color("31", "unexpected error:")} ${err?.stack ?? err}\n`,
16
+ );
17
+ process.exitCode = 1;
18
+ });
package/index.ts ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Library entry point for `claude-commit`.
3
+ *
4
+ * Re-exports the building blocks so the commit-message pipeline can be used
5
+ * programmatically. The CLI lives in `bin/cco.ts` (`src/cli.ts`).
6
+ */
7
+ export { generateCommit } from "./src/generate";
8
+ export type {
9
+ GenerateOptions,
10
+ GenerateProgress,
11
+ GenerateResult,
12
+ } from "./src/generate";
13
+ export { runPrompt } from "./src/agent";
14
+ export type { RunPromptOptions } from "./src/agent";
15
+ export { splitDiff } from "./src/diff";
16
+ export {
17
+ DEFAULT_CONFIG,
18
+ loadFileConfig,
19
+ resolveConfig,
20
+ mergeConfig,
21
+ mergePartial,
22
+ sanitizePartial,
23
+ } from "./src/config";
24
+ export {
25
+ buildSummarySystem,
26
+ buildSummaryUser,
27
+ buildFinalSystem,
28
+ buildFinalUser,
29
+ parseOptions,
30
+ cleanMessage,
31
+ } from "./src/prompts";
32
+ export * as git from "./src/git";
33
+ export { ClaudeCommitError } from "./src/errors";
34
+ export type {
35
+ Config,
36
+ ModelConfig,
37
+ PartialConfig,
38
+ ModelResult,
39
+ FileChange,
40
+ } from "./src/types";
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@synmux/claude-commit",
3
+ "version": "0.1.0",
4
+ "description": "Generate git commit messages with Claude, using your Claude Code subscription.",
5
+ "main": "index.ts",
6
+ "module": "index.ts",
7
+ "type": "module",
8
+ "private": false,
9
+ "bin": {
10
+ "cco": "bin/cco.ts",
11
+ "claude-commit": "bin/cco.ts"
12
+ },
13
+ "files": [
14
+ "bin",
15
+ "src",
16
+ "index.ts"
17
+ ],
18
+ "claude-commit": {
19
+ "conventionalCommits": true,
20
+ "gitmoji": true,
21
+ "multiline": true,
22
+ "template": null,
23
+ "customPrompt": null,
24
+ "interactive": false,
25
+ "interactiveCount": 3,
26
+ "interactiveTemperature": 1,
27
+ "models": {
28
+ "summary": "sonnet[1m]",
29
+ "final": "haiku"
30
+ },
31
+ "maxChunkTokens": 600000,
32
+ "charsPerToken": 3.5,
33
+ "allowApiKey": false
34
+ },
35
+ "devDependencies": {
36
+ "@anthropic-ai/claude-code": "^2.1.198",
37
+ "@trunkio/launcher": "^1.3.4",
38
+ "@types/bun": "^1.3.14",
39
+ "prettier": "3.9.4",
40
+ "skilld": "^2.0.0"
41
+ },
42
+ "peerDependencies": {
43
+ "typescript": "^6.0.3"
44
+ },
45
+ "dependencies": {
46
+ "@anthropic-ai/claude-agent-sdk": "^0.3.198",
47
+ "@opentui/core": "^0.4.2",
48
+ "commander": "^15.0.0"
49
+ },
50
+ "scripts": {
51
+ "start": "bun run bin/cco.ts",
52
+ "test": "bun test",
53
+ "format": "bun run prettier --write . && bun run trunk fmt -a",
54
+ "lint": "bun run trunk check -a",
55
+ "lint:fix": "bun run trunk check -a --fix",
56
+ "typecheck": "bun run tsc --noEmit",
57
+ "prepare": "bun run skilld prepare || true"
58
+ }
59
+ }
package/src/agent.ts ADDED
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Thin wrapper around the Claude Agent SDK that turns a single prompt into a
3
+ * single text completion.
4
+ *
5
+ * The Agent SDK spawns a bundled `claude` binary, so authentication follows
6
+ * Claude Code's own resolution order over the environment we hand it. By
7
+ * default the API credential variables (`ANTHROPIC_API_KEY` /
8
+ * `ANTHROPIC_AUTH_TOKEN`) are stripped from that environment, forcing the
9
+ * `claude login` subscription session so the cost is bundled with Claude Code
10
+ * usage; the `allowApiKey` config option passes them through for explicit
11
+ * pay-as-you-go billing. We deliberately disable every tool (`tools: []`) and
12
+ * load no settings (`settingSources: []`) so the run is a clean, isolated,
13
+ * prompt-in/text-out request.
14
+ */
15
+ import {
16
+ query,
17
+ type Options,
18
+ type SDKMessage,
19
+ } from "@anthropic-ai/claude-agent-sdk";
20
+ import { ClaudeCommitError } from "./errors";
21
+ import type { ModelResult } from "./types";
22
+
23
+ export interface RunPromptOptions {
24
+ /** Model string (alias like `haiku`, `sonnet[1m]`, or a full model id). */
25
+ model: string;
26
+ /** Full custom system prompt. */
27
+ system: string;
28
+ /** Receives assistant text as it streams in (enables partial messages). */
29
+ onText?: (delta: string) => void;
30
+ /** Abort the in-flight request. */
31
+ abortController?: AbortController;
32
+ /** Receives the underlying CLI's stderr (for `--verbose`). */
33
+ onStderr?: (data: string) => void;
34
+ /**
35
+ * Sampling temperature. Passed to the model via `CLAUDE_CODE_EXTRA_BODY`.
36
+ * Used to add variety when generating several interactive options. Models
37
+ * that don't accept a temperature override will reject the request, so the
38
+ * caller should be prepared to retry without it.
39
+ */
40
+ temperature?: number;
41
+ /**
42
+ * Request a structured JSON response matching this schema. The parsed object
43
+ * is returned on {@link ModelResult.structured}. Models that don't support
44
+ * structured outputs will reject the request, so the caller should be
45
+ * prepared to retry without it.
46
+ */
47
+ outputFormat?: { type: "json_schema"; schema: Record<string, unknown> };
48
+ /**
49
+ * Allow API credentials from the environment to reach the SDK subprocess.
50
+ * Defaults to false: `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` are
51
+ * stripped so the run is billed to the Claude subscription.
52
+ */
53
+ allowApiKey?: boolean;
54
+ }
55
+
56
+ /**
57
+ * Environment variables that carry Claude API credentials. Their presence
58
+ * switches the spawned `claude` binary from subscription auth to
59
+ * pay-as-you-go API billing, so they are stripped from the subprocess
60
+ * environment unless the user opts in via the `allowApiKey` config option.
61
+ */
62
+ export const GATED_CREDENTIAL_VARS = [
63
+ "ANTHROPIC_API_KEY",
64
+ "ANTHROPIC_AUTH_TOKEN",
65
+ ] as const;
66
+
67
+ /**
68
+ * Names of the gated credential variables present in `env`. An empty string
69
+ * counts as present, since presence alone perturbs credential resolution.
70
+ */
71
+ export function presentCredentialVars(
72
+ env: Record<string, string | undefined>,
73
+ ): string[] {
74
+ return GATED_CREDENTIAL_VARS.filter((name) => env[name] !== undefined);
75
+ }
76
+
77
+ export interface SubprocessEnvOptions {
78
+ /** Environment to derive the subprocess environment from (usually `process.env`). */
79
+ baseEnv: Record<string, string | undefined>;
80
+ /**
81
+ * Pass API credential variables through instead of stripping them.
82
+ * Defaults to false so the gate fails safe when a caller omits it.
83
+ */
84
+ allowApiKey?: boolean;
85
+ /** Sampling temperature to inject via `CLAUDE_CODE_EXTRA_BODY`, preserving any existing extra body. */
86
+ temperature?: number;
87
+ }
88
+
89
+ /**
90
+ * Build the environment for the Claude Agent SDK subprocess, or return
91
+ * `undefined` when the parent environment can be inherited unchanged.
92
+ *
93
+ * Unless `allowApiKey` is set, API credential variables are removed so the
94
+ * spawned `claude` binary always authenticates with the user's subscription
95
+ * session — an exported `ANTHROPIC_API_KEY` must never silently switch
96
+ * billing to pay-as-you-go.
97
+ */
98
+ export function buildSubprocessEnv(
99
+ opts: SubprocessEnvOptions,
100
+ ): Record<string, string | undefined> | undefined {
101
+ const { baseEnv, allowApiKey = false, temperature } = opts;
102
+ const stripped = allowApiKey ? [] : presentCredentialVars(baseEnv);
103
+ if (stripped.length === 0 && temperature == null) return undefined;
104
+
105
+ const env = { ...baseEnv };
106
+ for (const name of stripped) delete env[name];
107
+
108
+ if (temperature != null) {
109
+ let extra: Record<string, unknown> = {};
110
+ const existing = baseEnv.CLAUDE_CODE_EXTRA_BODY;
111
+ if (existing) {
112
+ try {
113
+ const parsed: unknown = JSON.parse(existing);
114
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
115
+ extra = parsed as Record<string, unknown>;
116
+ }
117
+ } catch {
118
+ /* ignore a malformed existing value */
119
+ }
120
+ }
121
+ env.CLAUDE_CODE_EXTRA_BODY = JSON.stringify({ ...extra, temperature });
122
+ }
123
+
124
+ return env;
125
+ }
126
+
127
+ /** Map a known SDK assistant error code to a friendlier, actionable message. */
128
+ function describeAssistantError(code: string): string {
129
+ switch (code) {
130
+ case "authentication_failed":
131
+ case "oauth_org_not_allowed":
132
+ return (
133
+ "Authentication failed. Run `claude login` to sign in with your Claude " +
134
+ "subscription, or set ANTHROPIC_API_KEY and enable `allowApiKey` in " +
135
+ "your claude-commit config."
136
+ );
137
+ case "billing_error":
138
+ return "Billing error from the Claude API. Check your plan or API credits.";
139
+ case "rate_limit":
140
+ return "Rate limited by the Claude API. Try again shortly.";
141
+ case "overloaded":
142
+ return "The Claude API is overloaded. Try again shortly.";
143
+ case "model_not_found":
144
+ return "The requested model was not found. Check the configured model name.";
145
+ case "max_output_tokens":
146
+ return "The model hit its output limit before finishing.";
147
+ default:
148
+ return `Model request failed (${code}).`;
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Run a single prompt and return the model's text response.
154
+ *
155
+ * Throws {@link ClaudeCommitError} on any model/authentication/quota failure.
156
+ */
157
+ export async function runPrompt(
158
+ prompt: string,
159
+ opts: RunPromptOptions,
160
+ ): Promise<ModelResult> {
161
+ const subprocessEnv = buildSubprocessEnv({
162
+ baseEnv: process.env,
163
+ allowApiKey: opts.allowApiKey ?? false,
164
+ ...(opts.temperature != null ? { temperature: opts.temperature } : {}),
165
+ });
166
+ const options: Options = {
167
+ model: opts.model,
168
+ systemPrompt: opts.system,
169
+ tools: [], // pure text completion: no Bash/Read/Edit/etc.
170
+ maxTurns: 1,
171
+ settingSources: [], // ignore user/project/local settings, CLAUDE.md, MCP, plugins
172
+ includePartialMessages: Boolean(opts.onText),
173
+ ...(opts.abortController ? { abortController: opts.abortController } : {}),
174
+ ...(opts.onStderr ? { stderr: opts.onStderr } : {}),
175
+ ...(subprocessEnv ? { env: subprocessEnv } : {}),
176
+ ...(opts.outputFormat ? { outputFormat: opts.outputFormat } : {}),
177
+ };
178
+
179
+ let resultText: string | null = null;
180
+ let costUsd = 0;
181
+ let model: string | undefined;
182
+ let structured: unknown;
183
+ let assistantError: string | undefined;
184
+
185
+ let response;
186
+ try {
187
+ response = query({ prompt, options });
188
+ for await (const message of response as AsyncIterable<SDKMessage>) {
189
+ switch (message.type) {
190
+ case "stream_event": {
191
+ if (opts.onText) {
192
+ const event = message.event as {
193
+ type?: string;
194
+ delta?: { type?: string; text?: string };
195
+ };
196
+ if (
197
+ event.type === "content_block_delta" &&
198
+ event.delta?.type === "text_delta"
199
+ ) {
200
+ opts.onText(event.delta.text ?? "");
201
+ }
202
+ }
203
+ break;
204
+ }
205
+ case "assistant": {
206
+ if (message.error) assistantError = message.error;
207
+ break;
208
+ }
209
+ case "result": {
210
+ costUsd = message.total_cost_usd ?? 0;
211
+ // The served model is the (only) key of modelUsage, when present.
212
+ const usedModels = Object.keys(message.modelUsage ?? {});
213
+ if (usedModels.length > 0) model = usedModels[0];
214
+ if (message.subtype === "success") {
215
+ resultText = message.result;
216
+ structured = message.structured_output;
217
+ } else {
218
+ const detail =
219
+ "errors" in message && message.errors.length
220
+ ? message.errors.join("; ")
221
+ : message.subtype;
222
+ throw new ClaudeCommitError(`Model run failed: ${detail}`);
223
+ }
224
+ break;
225
+ }
226
+ default:
227
+ break;
228
+ }
229
+ }
230
+ } catch (err) {
231
+ if (err instanceof ClaudeCommitError) throw err;
232
+ if (opts.abortController?.signal.aborted) {
233
+ throw new ClaudeCommitError("Generation was cancelled.");
234
+ }
235
+ throw new ClaudeCommitError(
236
+ `Failed to call the Claude Agent SDK: ${(err as Error).message}`,
237
+ );
238
+ }
239
+
240
+ if (assistantError) {
241
+ throw new ClaudeCommitError(describeAssistantError(assistantError));
242
+ }
243
+ if (resultText === null) {
244
+ throw new ClaudeCommitError("The model returned no result.");
245
+ }
246
+
247
+ return {
248
+ text: resultText.trim(),
249
+ costUsd,
250
+ ...(model ? { model } : {}),
251
+ ...(structured !== undefined ? { structured } : {}),
252
+ };
253
+ }