@synmux/claude-commit
v1.1.0
Published
Generate git commit messages with Claude, using your Claude Code subscription and/or Ollama.
Readme
claude-commit
Generate high-quality git commit messages with Claude - using your Claude Code subscription, not an API key.
claude-commit reads your staged diff, has a strong model summarize it, and a fast
model turn that summary into a well-formed commit message. It handles diffs of any
size (including ones too large to fit in a single context window), and supports
Conventional Commits, gitmoji, first-line templates, custom instructions, and an
interactive mode for choosing between several options.
$ cco -c
✔ Committed
feat(auth): add error handling and refresh token rotation to loginHow it works
Want more details? See WALKTHROUGH.md.
staged diff ──ignore──▶ ──split──▶ [chunk, …] ──sonnet──▶ summaries ──sonnet──▶ commit message- Summarize - the diff is split into chunks that fit the context window and
each chunk is summarized by a strong model (
sonnet, which carries a native 1M-token context). Diffs larger than 1M tokens simply produce more chunks. Paths listed inignoreare dropped first and never read at all; changes under configured low-priority paths are summarized separately, so churn cannot crowd out the code. - Write - the summaries are handed to the same model (
sonnet) to write the final commit message according to your formatting rules. The message is the whole point of the tool, and its input is tiny, so a strong model here costs almost nothing extra.
Either stage can run on a local Ollama model instead; by default both go through the Claude Agent SDK.
For less model work at the cost of less useful messages, enable
filenamesOnly to skip summarisation entirely.
Install
Requires Node.js 22.18 or later (24 LTS recommended).
npm install -g @synmux/claude-commit # `cco` and `claude-commit` on your PATHFrom a checkout, pnpm is the package manager (the
packageManager field pins the version, so corepack enable is enough):
pnpm install
pnpm link --global # makes `cco` and `claude-commit` available on your PATHOr run it directly without linking:
node bin/cco.js --helpAuthentication
claude-commit uses the Claude Agent SDK and, by default, always authenticates
with your Claude Code subscription session (run claude login once). Usage is
bundled with your Claude Code usage - no separate API bill.
To protect you from surprise pay-as-you-go charges, API credentials in your
environment (ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN) are ignored by
default: they are stripped from the environment passed to the model
subprocess, and a one-line notice is printed to stderr. To bill an API key
instead (pay-as-you-go), opt in explicitly in your configuration:
{ "allowApiKey": true }Ollama models sit outside all of this: they run on a server you control, over plain HTTP, with no credential at all. See Ollama models.
Usage
cco [options]By default cco summarizes your staged changes, generates a message, shows it,
and asks for confirmation before committing. Pass -y to skip the prompt, or
--dry-run to print the message without committing.
Options
| Flag | Description |
| ---------------------------------------- | ------------------------------------------------------------------------------------------ |
| -i, --interactive / --no-interactive | Choose between several options in an interactive picker, or skip it when enabled in config |
| -n, --count <n> | Number of options to generate in interactive mode (default 3) |
| -a, --all | Stage all changes (git add -A) before committing |
| -c, --conventional | Format as a Conventional Commit |
| -g, --gitmoji | Prefix the subject with a gitmoji |
| -m, --multiline / --no-multiline | Write a multi-line commit (subject + body), or force a single line |
| -t, --template <tpl> | Template for the first line, e.g. "[PROJ-1] {message}" |
| -p, --prompt <text> | Extra instructions appended to the prompt |
| -f, --filenames-only | Skip summarisation and send only filenames to the final model |
| --model-summary <model> | Model used to summarize the diff (default sonnet) |
| --model-final <model> | Model used to write the message (default sonnet) |
| --skip-armored | Omit armored/encoded lines (age/gpg armor, base64 blobs) from the summarized diff |
| --no-low-priority-paths | Ignore lowPriorityPaths for this run, so every change weighs the same |
| --no-ignore | Disregard ignore for this run, so every staged change is read |
| --ollama-host <url> | Base URL of the Ollama server for ollama: models |
| --ollama-context <tokens> | Context window requested from Ollama models |
| -d, --dry-run | Print the message to stdout without committing |
| -y, --yes | Commit without asking for confirmation |
| --no-spinner | Disable the progress spinner |
| --config <path> | Path to a config file |
| -v, --verbose | Print summaries, cost and debug output |
Examples
cco # generate, confirm, and commit staged changes
cco -a -c # stage everything and write a Conventional Commit
cco -c -g -m # conventional + gitmoji + a body
cco -i -n 5 # pick from 5 options interactively
cco -f --dry-run # generate from filenames only, without committing
cco --dry-run | cat # print a message without committing (no picker, pipe-safe)
git commit -F <(cco -d) # use the message with your own git invocationIn a pipe (no TTY) there is no spinner and no confirmation prompt - cco just
generates and commits (or prints, with --dry-run).
Filenames-only mode
Set "filenamesOnly": true in any config layer, or pass -f /
--filenames-only, to send only the list of filenames touched by staged
changes to models.final. The default is false.
{ "filenamesOnly": true }The summariser is skipped entirely: no diff chunks, summary calls, or summary-model preload. The final model receives no file contents or diff hunks, so expect broader, less useful messages. It is instructed to describe the affected areas without inventing specific edits or reasons for them. Normal formatting, custom instructions and interactive options still apply.
ignore still removes matching file sections, and lowPriorityPaths still
groups and weights the remaining filenames. Renames and copies include both
paths; additions, deletions, binary files and mode changes are included.
If every staged file is ignored, generation still stops with an error.
models.summary, maxChunkTokens, charsPerToken and skipArmored have no
effect in this mode. --verbose reports that the summariser was skipped;
library results have an empty summaries array and chunkCount: 0.
Interactive mode
cco -i opens a picker (built on Clack)
listing several candidate messages, each with its subject and a one-line body
preview. The options are generated with a higher temperature
(interactiveTemperature) for more variety. Use the arrow keys (or j/k) to
move between options, Enter to commit the highlighted option, e to edit it
in your $EDITOR first, and q/Esc/Ctrl-C to cancel. The picker draws on
stderr, so stdout stays clean.
To make interactive mode the default without typing -i every time, set
"interactive": true in your config (see below); opt out of a single run with
--no-interactive. When there is no interactive terminal - in a pipe, a CI job,
or with --dry-run - cco ignores the setting and falls back to the
non-interactive flow rather than failing.
Configuration
Configuration is layered, from lowest to highest precedence:
- Built-in defaults.
- A global user config at
~/.config/claude-commit/config.json(or$XDG_CONFIG_HOME/claude-commit/config.json) - your personal defaults across every project. The.claude-commit.json/.claude-commitrc.json/.claude-commitrcnames are also accepted in that directory. - A
claude-commitkey in the repo'spackage.json. - The nearest
.claude-commit.json/.claude-commitrc.json/.claude-commitrc, searched from the current directory up to the repo root. - CLI flags.
So a global config sets your personal defaults and any project further down the
tree can override them. Note that the config.json name is recognised only in
the global directory; inside a project, use one of the dotted filenames. The same
keys are valid at every level:
{
"conventionalCommits": true,
"gitmoji": true,
"multiline": true,
"template": null,
"customPrompt": "Reference the ticket id from the branch name when present.",
"interactive": true,
"interactiveCount": 3,
"interactiveTemperature": 1,
"spinner": "material",
"models": {
"summary": "sonnet",
"final": "sonnet"
},
"maxChunkTokens": 600000,
"charsPerToken": 3.5,
"filenamesOnly": false,
"skipArmored": false,
"lowPriorityPaths": [],
"ignore": [],
"ollama": {
"host": "http://localhost:11434",
"context": "auto",
"keepAlive": null
},
"allowApiKey": false
}spinner chooses the progress animation: any name from the
cli-spinners set bundled with
ora ("dots", "moon", "pong",
"material", ...). Unknown names are ignored and the default
material is used. --no-spinner disables the animated spinner, but final
status lines still print.
The material spinner is chosen because it's fucking cool. Fight me.
maxChunkTokens is a cap, not a promise: at run time it is clamped to the
summary model's context window minus a fixed reserve (1M-window models such as
current Sonnet/Opus keep the full budget; Haiku, older pinned model ids, and
unrecognised models are floored at 200k), so a single chunk can never overflow
the model.
Chunk sizes come from a content-classified token estimate, not a flat
charsPerToken ratio: armored or encoded lines (age/gpg armor, base64 blobs,
git binary patches) tokenize at roughly one token per character on current
Claude models, so they are budgeted at that rate while ordinary text keeps the
configured ratio. If the backend still rejects a chunk as too long, cco
re-splits just that chunk with a halved budget and retries - the rejection is
free, so the API is the final arbiter.
skipArmored (or the --skip-armored flag) goes further and replaces each
run of armored lines with a one-line [cco: N armored/encoded lines omitted]
marker before summarizing. Ciphertext is unreadable to the model anyway, so
this is the recommended setting for encrypted-file repos - for example a
chezmoi source directory with age encryption, where
every chezmoi re-add re-encrypts nondeterministically and produces megabytes
of churned armor. Drop a .claude-commit.json with { "skipArmored": true }
in the repo root to enable it per-repo.
Low-priority paths
Some paths change a lot without meaning much - generated docs, lockfiles,
vendored snapshots, build output. Left alone, a commit that touches twenty
lines of code and regenerates two thousand lines of tooling gets a subject
line about the tooling. lowPriorityPaths lists gitignore-style patterns for
those paths:
{
"lowPriorityPaths": [
".agents/skills/*-skilld",
"pnpm-lock.yaml",
"generated/**",
"!generated/schema.ts"
]
}Changes under matching paths are summarised separately and briefly, and the
model is told that the subject line - and the commit type, scope and gitmoji
where you use them - comes from the other changes, however small they are.
The low-priority changes are mentioned in the subject only if they fit, and in
the body (with multiline) only after the primary changes. When every
changed file is low priority there is nothing for it to yield to, so the
changes are described normally, exactly as if no patterns were configured.
Pattern rules follow .gitignore conventions, so trunk or gitignore lines can
usually be copied in:
- A pattern with a
/in it is anchored at the repository root and matches a path or any directory above it -.agents/skills/*-skilldcovers every file inside each matching directory. - A pattern without a
/matches any path segment at any depth -pnpm-lock.yamlmatchespackages/app/pnpm-lock.yaml;*-skilldmatches everything inside any*-skillddirectory. *matches dotfiles and does not cross/;**does;{a,b}expands. A leading/or./anchors, a trailing/is ignored. The anchoring decision looks at the whole pattern, so a/inside a brace group anchors all of its alternatives - prefer one pattern per intent.- A leading
!negates, and the last matching pattern wins:["docs/**", "!docs/adr/**"]deprioritises docs except the ADRs. - Patterns are always matched against repository-root-relative paths with
/separators, whichever directory you runccofrom. A backslash in a pattern is an escape (\[,\{,\!for the literal characters), so Windows-styledist\**matches nothing. - Patterns are not validated: a typo such as an unbalanced
{is parsed rather than rejected and may match something unexpected, so check the--verbosematch counts when you add one.
A rename into or out of a low-priority path counts as primary (both sides
must match). The nearest config layer that sets the key wins outright - lists
are never merged - so "lowPriorityPaths": [] in a project opts out of a
global list, and a project that wants the global patterns plus its own must
repeat them. --no-low-priority-paths switches the feature off for one run,
which is handy when the churn is the story, or for comparing messages while
tuning patterns.
This changes how changes are weighted in the message, not how much of the
diff is read: low-priority content is still summarised in full, at the same
cost. To skip content outright, see skipArmored. Under --verbose, cco
reports how many files matched (low-priority paths: matched 3 of 41 files),
which is the only way to tell a pattern that matched nothing from one that
matched everything and was promoted.
Ignoring paths entirely
lowPriorityPaths still reads everything it deprioritises, and pays for it.
Some content is worth neither the tokens nor the time: a vendored dependency
tree, a generated API client, a data fixture that changes wholesale.
ignore takes the same gitignore-style patterns and removes those file
sections from the diff before anything else looks at it - before the
low-priority partition, before chunking, before any model call:
{ "ignore": ["vendor/**", "**/__snapshots__", "*.generated.ts"] }The stages compose in the order their names suggest:
diff ─ ignore ─▶ ─ skipArmored ─▶ ─ lowPriorityPaths ─▶ chunks ─▶ summariesTwo things worth being clear about:
- The files are still committed.
ignoregoverns what the model reads, never what git stages.ccois writing a message, not choosing a changeset. - When it matches everything,
ccostops with an error naming the directive, rather than inventing a message about changes you told it not to read. This is deliberately unlikelowPriorityPaths, which promotes its partition in the same situation - "this matters less" can degrade gracefully, "do not look at this" has nothing to degrade to. Pass--no-ignorefor that one commit.
As with lowPriorityPaths, a section is dropped only when it names at least
one path and all of them match, so a rename out of an ignored directory
survives. --verbose reports the count
(ignore: dropped 3 of 41 files before reading).
Ollama models
Any model can be run on a local (or self-hosted) Ollama
server instead of Claude, by prefixing its name with ollama:. Everything
after the prefix is the Ollama model name verbatim, tag included:
{
"models": {
"summary": "ollama:ornith-1.5:35b",
"final": "sonnet"
}
}The two stages resolve independently, so that mixed setup is the interesting
one: reading the diff is the bulk of the work and the most sensitive thing
cco touches, so it runs locally and free, while the final message - one
short, quality-sensitive call on a summary - still goes to Claude. The prefix
works anywhere a model name does, including the flags:
cco --model-summary ollama:ornith-1.5:35b --dry-run -vThe server needs no credential. cco talks to Ollama's native /api/chat
endpoint, not either of its OpenAI/Anthropic compatibility layers, because
only the native API can set a context length.
Context length is the setting that matters
Ollama picks a context window for each model from available VRAM (4k / 32k /
256k tiers, capped at the model's trained maximum), and a prompt that
exceeds it is truncated silently - HTTP 200, oldest content dropped,
nothing on the response to say so. A summary written from half a diff is
worse than no summary, so cco never lets that number stay implicit: it
sends an explicit window on every request, sizes its diff chunks against the
same number, and checks the token counts afterwards to catch a truncation
that happened anyway (in which case it re-splits the chunk and retries,
exactly as it does for a Claude context overflow).
Where the number comes from is ollama.context:
{
"ollama": {
"host": "http://localhost:11434",
"context": "auto",
"keepAlive": "10m"
}
}context-"auto"(the default) asks the server rather than guessing: the model is preloaded with no window set, so Ollama applies its own VRAM-based choice, andccoreads that choice back from/api/psbefore sizing anything. That is the largest window Ollama believes this machine can actually run - 131072 for a Gemma model on a large Mac, 4096 for the same model on a small laptop - resolved once per model per run, and shown under--verbose. A number pins the window instead: lower it when memory is tight (usage scales with it, multiplied byOLLAMA_NUM_PARALLEL), or raise it past the tier if you know your hardware better than the server does. A smaller window is never a correctness problem -ccojust splits the diff into more chunks.host- defaults to$OLLAMA_HOST, thenhttp://localhost:11434. A barebox.local:11434gains anhttp://, matching Ollama's own convention.keepAlive- how long the server keeps the model loaded after a request: a duration string ("10m"), seconds as a number,0to unload immediately, or negative to pin it.nullleaves the server's own default. Pinning is worth it if you commit often; a 35b model takes a while to load.
What differs from a Claude model
- Cost is reported as zero, because local inference is not billed. In a
mixed run,
--verbose's total is exactly the Claude half. - Structured output is requested through Ollama's
formatfield. A model or server that cannot honour it (Ollama Cloud does not support it at all) falls back to plain-text parsing automatically. - A missing model is an error, not a download.
ccotells you to runollama pull <model>rather than pulling tens of gigabytes on your behalf. - Reasoning is never requested, and any the model volunteers is discarded - models disagree about whether thinking can even be switched off, and asking is a good way to earn a 400.
Development
pnpm test # run the test suite (vitest)
pnpm run typecheck # tsc --noEmit
pnpm run build # bundle bin/ and index.ts into dist/ (only needed to publish)The sources run directly on Node's native type stripping, so there is no build
step during development: node bin/cco.js picks up dist/ when it exists and
falls back to the TypeScript entry otherwise.
What changed between versions is in CHANGELOG.md, and at greater length on the releases page.
Did you vibe this?
I distinguish vibe coding and AI-assisted development by where you live as the developer. If you live in the code, it's AI-assisted dev. If you just shout at a chat and hope for the best, that's vibe coding.
This was AI-assisted development.
Thanks for coming to my TED talk.
