@synmux/claude-commit 1.0.1 → 1.0.3

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
@@ -20,18 +20,24 @@ feat(auth): add error handling and refresh token rotation to login
20
20
  Want more details? See [WALKTHROUGH.md](WALKTHROUGH.md).
21
21
 
22
22
  ```plaintext
23
- staged diff ──split──▶ [chunk, …] ──sonnet──▶ summaries ──sonnet──▶ commit message
23
+ staged diff ──ignore──▶ ──split──▶ [chunk, …] ──sonnet──▶ summaries ──sonnet──▶ commit message
24
24
  ```
25
25
 
26
26
  1. **Summarize** - the diff is split into chunks that fit the context window and
27
27
  each chunk is summarized by a strong model (`sonnet`, which carries a native
28
28
  1M-token context). Diffs larger than 1M tokens simply produce more chunks.
29
+ Paths listed in [`ignore`](#ignoring-paths-entirely) are dropped first and
30
+ never read at all; changes under configured
31
+ [low-priority paths](#low-priority-paths) are summarized separately, so churn
32
+ cannot crowd out the code.
29
33
  2. **Write** - the summaries are handed to the same model (`sonnet`) to write the
30
34
  final commit message according to your formatting rules. The message is the
31
35
  whole point of the tool, and its input is tiny, so a strong model here costs
32
36
  almost nothing extra.
33
37
 
34
- Both stages run through the [Claude Agent SDK](https://code.claude.com/docs/en/agent-sdk/overview).
38
+ Either stage can run on a local [Ollama](#ollama-models) model instead; by
39
+ default both go through the
40
+ [Claude Agent SDK](https://code.claude.com/docs/en/agent-sdk/overview).
35
41
 
36
42
  ## Install
37
43
 
@@ -64,6 +70,9 @@ instead (pay-as-you-go), opt in explicitly in your configuration:
64
70
  { "allowApiKey": true }
65
71
  ```
66
72
 
73
+ Ollama models sit outside all of this: they run on a server you control, over
74
+ plain HTTP, with no credential at all. See [Ollama models](#ollama-models).
75
+
67
76
  ## Usage
68
77
 
69
78
  ```sh
@@ -88,6 +97,11 @@ and asks for confirmation before committing. Pass `-y` to skip the prompt, or
88
97
  | `-p, --prompt <text>` | Extra instructions appended to the prompt |
89
98
  | `--model-summary <model>` | Model used to summarize the diff (default `sonnet`) |
90
99
  | `--model-final <model>` | Model used to write the message (default `sonnet`) |
100
+ | `--skip-armored` | Omit armored/encoded lines (age/gpg armor, base64 blobs) from the summarized diff |
101
+ | `--no-low-priority-paths` | Ignore `lowPriorityPaths` for this run, so every change weighs the same |
102
+ | `--no-ignore` | Disregard `ignore` for this run, so every staged change is read |
103
+ | `--ollama-host <url>` | Base URL of the Ollama server for `ollama:` models |
104
+ | `--ollama-context <tokens>` | Context window requested from Ollama models |
91
105
  | `-d, --dry-run` | Print the message to stdout without committing |
92
106
  | `-y, --yes` | Commit without asking for confirmation |
93
107
  | `--no-spinner` | Disable the progress spinner |
@@ -151,7 +165,7 @@ keys are valid at every level:
151
165
  "interactive": true,
152
166
  "interactiveCount": 3,
153
167
  "interactiveTemperature": 1,
154
- "spinner": "bouncingBall",
168
+ "spinner": "material",
155
169
  "models": {
156
170
  "summary": "sonnet",
157
171
  "final": "sonnet"
@@ -159,6 +173,13 @@ keys are valid at every level:
159
173
  "maxChunkTokens": 600000,
160
174
  "charsPerToken": 3.5,
161
175
  "skipArmored": false,
176
+ "lowPriorityPaths": [],
177
+ "ignore": [],
178
+ "ollama": {
179
+ "host": "http://localhost:11434",
180
+ "context": "auto",
181
+ "keepAlive": null
182
+ },
162
183
  "allowApiKey": false
163
184
  }
164
185
  ```
@@ -166,10 +187,12 @@ keys are valid at every level:
166
187
  `spinner` chooses the progress animation: any name from the
167
188
  [cli-spinners](https://github.com/sindresorhus/cli-spinners) set bundled with
168
189
  [ora](https://github.com/sindresorhus/ora) (`"dots"`, `"moon"`, `"pong"`,
169
- `"bouncingBall"`, ...). Unknown names are ignored and the default
170
- `bouncingBall` is used. `--no-spinner` disables the animated spinner, but final
190
+ `"material"`, ...). Unknown names are ignored and the default
191
+ `material` is used. `--no-spinner` disables the animated spinner, but final
171
192
  status lines still print.
172
193
 
194
+ The `material` spinner is chosen because it's fucking cool. Fight me.
195
+
173
196
  `maxChunkTokens` is a cap, not a promise: at run time it is clamped to the
174
197
  summary model's context window minus a fixed reserve (1M-window models such as
175
198
  current Sonnet/Opus keep the full budget; Haiku, older pinned model ids, and
@@ -193,6 +216,186 @@ every `chezmoi re-add` re-encrypts nondeterministically and produces megabytes
193
216
  of churned armor. Drop a `.claude-commit.json` with `{ "skipArmored": true }`
194
217
  in the repo root to enable it per-repo.
195
218
 
219
+ ### Low-priority paths
220
+
221
+ Some paths change a lot without meaning much - generated docs, lockfiles,
222
+ vendored snapshots, build output. Left alone, a commit that touches twenty
223
+ lines of code and regenerates two thousand lines of tooling gets a subject
224
+ line about the tooling. `lowPriorityPaths` lists gitignore-style patterns for
225
+ those paths:
226
+
227
+ ```json
228
+ {
229
+ "lowPriorityPaths": [".agents/skills/*-skilld", "bun.lock", "!bun.lock.keep"]
230
+ }
231
+ ```
232
+
233
+ Changes under matching paths are summarised separately and briefly, and the
234
+ model is told that the subject line - and the commit type, scope and gitmoji
235
+ where you use them - comes from the _other_ changes, however small they are.
236
+ The low-priority changes are mentioned in the subject only if they fit, and in
237
+ the body (with `multiline`) only after the primary changes. When _every_
238
+ changed file is low priority there is nothing for it to yield to, so the
239
+ changes are described normally, exactly as if no patterns were configured.
240
+
241
+ Pattern rules follow `.gitignore` conventions, so trunk or gitignore lines can
242
+ usually be copied in:
243
+
244
+ - A pattern with a `/` in it is anchored at the repository root and matches a
245
+ path or any directory above it - `.agents/skills/*-skilld` covers every file
246
+ inside each matching directory.
247
+ - A pattern without a `/` matches any path segment at any depth - `bun.lock`
248
+ matches `packages/app/bun.lock`; `*-skilld` matches everything inside any
249
+ `*-skilld` directory.
250
+ - `*` matches dotfiles and does not cross `/`; `**` does; `{a,b}` expands. A
251
+ leading `/` or `./` anchors, a trailing `/` is ignored. The anchoring
252
+ decision looks at the whole pattern, so a `/` inside a brace group anchors
253
+ all of its alternatives - prefer one pattern per intent.
254
+ - A leading `!` negates, and the last matching pattern wins:
255
+ `["docs/**", "!docs/adr/**"]` deprioritises docs except the ADRs.
256
+ - Patterns are always matched against repository-root-relative paths with
257
+ `/` separators, whichever directory you run `cco` from. A backslash in a
258
+ pattern is an escape (`\[`, `\{`, `\!` for the literal characters), so
259
+ Windows-style `dist\**` matches nothing.
260
+ - Patterns are not validated: a typo such as an unbalanced `{` is parsed
261
+ rather than rejected and may match something unexpected, so check the
262
+ `--verbose` match counts when you add one.
263
+
264
+ A rename into or out of a low-priority path counts as primary (both sides
265
+ must match). The nearest config layer that sets the key wins outright - lists
266
+ are never merged - so `"lowPriorityPaths": []` in a project opts out of a
267
+ global list, and a project that wants the global patterns plus its own must
268
+ repeat them. `--no-low-priority-paths` switches the feature off for one run,
269
+ which is handy when the churn _is_ the story, or for comparing messages while
270
+ tuning patterns.
271
+
272
+ This changes how changes are _weighted_ in the message, not how much of the
273
+ diff is read: low-priority content is still summarised in full, at the same
274
+ cost. To skip content outright, see `skipArmored`. Under `--verbose`, `cco`
275
+ reports how many files matched (`low-priority paths: matched 3 of 41 files`),
276
+ which is the only way to tell a pattern that matched nothing from one that
277
+ matched everything and was promoted.
278
+
279
+ ### Ignoring paths entirely
280
+
281
+ `lowPriorityPaths` still reads everything it deprioritises, and pays for it.
282
+ Some content is worth neither the tokens nor the time: a vendored dependency
283
+ tree, a generated API client, a data fixture that changes wholesale.
284
+
285
+ `ignore` takes the same gitignore-style patterns and removes those file
286
+ sections from the diff **before anything else looks at it** - before the
287
+ low-priority partition, before chunking, before any model call:
288
+
289
+ ```json
290
+ { "ignore": ["vendor/**", "**/__snapshots__", "*.generated.ts"] }
291
+ ```
292
+
293
+ The stages compose in the order their names suggest:
294
+
295
+ ```text
296
+ diff ─ ignore ─▶ ─ skipArmored ─▶ ─ lowPriorityPaths ─▶ chunks ─▶ summaries
297
+ ```
298
+
299
+ Two things worth being clear about:
300
+
301
+ - **The files are still committed.** `ignore` governs what the model reads,
302
+ never what git stages. `cco` is writing a message, not choosing a changeset.
303
+ - **When it matches _everything_, `cco` stops** with an error naming the
304
+ directive, rather than inventing a message about changes you told it not to
305
+ read. This is deliberately unlike `lowPriorityPaths`, which promotes its
306
+ partition in the same situation - "this matters less" can degrade
307
+ gracefully, "do not look at this" has nothing to degrade to. Pass
308
+ `--no-ignore` for that one commit.
309
+
310
+ As with `lowPriorityPaths`, a section is dropped only when it names at least
311
+ one path and _all_ of them match, so a rename out of an ignored directory
312
+ survives. `--verbose` reports the count
313
+ (`ignore: dropped 3 of 41 files before reading`).
314
+
315
+ ## Ollama models
316
+
317
+ Any model can be run on a local (or self-hosted) [Ollama](https://ollama.com)
318
+ server instead of Claude, by prefixing its name with `ollama:`. Everything
319
+ after the prefix is the Ollama model name **verbatim**, tag included:
320
+
321
+ ```json
322
+ {
323
+ "models": {
324
+ "summary": "ollama:ornith-1.5:35b",
325
+ "final": "sonnet"
326
+ }
327
+ }
328
+ ```
329
+
330
+ The two stages resolve independently, so that mixed setup is the interesting
331
+ one: reading the diff is the bulk of the work and the most sensitive thing
332
+ `cco` touches, so it runs locally and free, while the final message - one
333
+ short, quality-sensitive call on a summary - still goes to Claude. The prefix
334
+ works anywhere a model name does, including the flags:
335
+
336
+ ```sh
337
+ cco --model-summary ollama:ornith-1.5:35b --dry-run -v
338
+ ```
339
+
340
+ The server needs no credential. `cco` talks to Ollama's native `/api/chat`
341
+ endpoint, not either of its OpenAI/Anthropic compatibility layers, because
342
+ only the native API can set a context length.
343
+
344
+ ### Context length is the setting that matters
345
+
346
+ Ollama picks a context window for each model from available VRAM (4k / 32k /
347
+ 256k tiers, capped at the model's trained maximum), and a prompt that
348
+ exceeds it is truncated **silently** - HTTP 200, oldest content dropped,
349
+ nothing on the response to say so. A summary written from half a diff is
350
+ worse than no summary, so `cco` never lets that number stay implicit: it
351
+ sends an explicit window on every request, sizes its diff chunks against the
352
+ same number, and checks the token counts afterwards to catch a truncation
353
+ that happened anyway (in which case it re-splits the chunk and retries,
354
+ exactly as it does for a Claude context overflow).
355
+
356
+ Where the number comes from is `ollama.context`:
357
+
358
+ ```json
359
+ {
360
+ "ollama": {
361
+ "host": "http://localhost:11434",
362
+ "context": "auto",
363
+ "keepAlive": "10m"
364
+ }
365
+ }
366
+ ```
367
+
368
+ - `context` - `"auto"` (the default) **asks the server** rather than
369
+ guessing: the model is preloaded with no window set, so Ollama applies
370
+ its own VRAM-based choice, and `cco` reads that choice back from
371
+ `/api/ps` before sizing anything. That is the largest window Ollama
372
+ believes this machine can actually run - 131072 for a Gemma model on a
373
+ large Mac, 4096 for the same model on a small laptop - resolved once per
374
+ model per run, and shown under `--verbose`. A number pins the window
375
+ instead: lower it when memory is tight (usage scales with it, multiplied
376
+ by `OLLAMA_NUM_PARALLEL`), or raise it past the tier if you know your
377
+ hardware better than the server does. A smaller window is never a
378
+ correctness problem - `cco` just splits the diff into more chunks.
379
+ - `host` - defaults to `$OLLAMA_HOST`, then `http://localhost:11434`. A bare
380
+ `box.local:11434` gains an `http://`, matching Ollama's own convention.
381
+ - `keepAlive` - how long the server keeps the model loaded after a request: a
382
+ duration string (`"10m"`), seconds as a number, `0` to unload immediately,
383
+ or negative to pin it. `null` leaves the server's own default. Pinning is
384
+ worth it if you commit often; a 35b model takes a while to load.
385
+
386
+ ### What differs from a Claude model
387
+
388
+ - **Cost is reported as zero**, because local inference is not billed. In a
389
+ mixed run, `--verbose`'s total is exactly the Claude half.
390
+ - **Structured output** is requested through Ollama's `format` field. A model
391
+ or server that cannot honour it (Ollama Cloud does not support it at all)
392
+ falls back to plain-text parsing automatically.
393
+ - **A missing model is an error, not a download.** `cco` tells you to run
394
+ `ollama pull <model>` rather than pulling tens of gigabytes on your behalf.
395
+ - **Reasoning is never requested**, and any the model volunteers is
396
+ discarded - models disagree about whether thinking can even be switched
397
+ off, and asking is a good way to earn a 400.
398
+
196
399
  ## Development
197
400
 
198
401
  ```sh
package/index.ts CHANGED
@@ -9,10 +9,36 @@ export type {
9
9
  GenerateOptions,
10
10
  GenerateProgress,
11
11
  GenerateResult,
12
+ IgnoreStats,
13
+ LowPriorityStats,
14
+ OllamaContextWindow,
12
15
  } from "./src/generate";
13
- export { runPrompt } from "./src/agent";
16
+ export { runClaudePrompt, runPrompt } from "./src/agent";
14
17
  export type { RunPromptOptions } from "./src/agent";
15
- export { splitDiff } from "./src/diff";
18
+ export {
19
+ probeOllamaContext,
20
+ resolveOllamaContext,
21
+ resolveOllamaHost,
22
+ runOllamaPrompt,
23
+ } from "./src/ollama";
24
+ export {
25
+ DEFAULT_OLLAMA_CONTEXT,
26
+ DEFAULT_OLLAMA_CONTEXT_TOKENS,
27
+ DEFAULT_OLLAMA_HOST,
28
+ isOllamaModel,
29
+ OLLAMA_PREFIX,
30
+ parseModelRef,
31
+ } from "./src/models";
32
+ export type { ModelProvider, ModelRef } from "./src/models";
33
+ export {
34
+ applyIgnorePatterns,
35
+ partitionDiff,
36
+ sectionPaths,
37
+ splitDiff,
38
+ } from "./src/diff";
39
+ export type { DiffPartition, IgnoreResult } from "./src/diff";
40
+ export { createPathMatcher, matchesPathPatterns } from "./src/paths";
41
+ export type { PathMatcher } from "./src/paths";
16
42
  export {
17
43
  DEFAULT_CONFIG,
18
44
  loadFileConfig,
@@ -32,8 +58,11 @@ export {
32
58
  export * as git from "./src/git";
33
59
  export { ClaudeCommitError } from "./src/errors";
34
60
  export type {
61
+ ChangePriority,
35
62
  Config,
63
+ DiffSummary,
36
64
  ModelConfig,
65
+ OllamaConfig,
37
66
  PartialConfig,
38
67
  ModelResult,
39
68
  FileChange,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synmux/claude-commit",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Generate git commit messages with Claude, using your Claude Code subscription.",
5
5
  "main": "index.ts",
6
6
  "module": "index.ts",
@@ -16,36 +16,48 @@
16
16
  "index.ts"
17
17
  ],
18
18
  "claude-commit": {
19
+ "allowApiKey": false,
20
+ "charsPerToken": 3.5,
19
21
  "conventionalCommits": true,
20
- "gitmoji": true,
21
- "multiline": true,
22
- "template": null,
23
22
  "customPrompt": null,
23
+ "gitmoji": true,
24
24
  "interactive": false,
25
- "interactiveCount": 3,
25
+ "interactiveCount": 10,
26
26
  "interactiveTemperature": 1,
27
- "spinner": "bouncingBall",
27
+ "lowPriorityPaths": [
28
+ ".agents/**",
29
+ ".claude/**",
30
+ "bun.lock",
31
+ ".serena"
32
+ ],
33
+ "maxChunkTokens": 1000000,
28
34
  "models": {
29
- "summary": "sonnet",
30
- "final": "sonnet"
35
+ "summary": "fable",
36
+ "final": "fable"
31
37
  },
32
- "maxChunkTokens": 600000,
33
- "charsPerToken": 3.5,
34
- "allowApiKey": false
38
+ "multiline": true,
39
+ "ollama": {
40
+ "host": "http://localhost:11434",
41
+ "context": "auto",
42
+ "keepAlive": "10m"
43
+ },
44
+ "skipArmored": true,
45
+ "spinner": "pong",
46
+ "template": null
35
47
  },
36
48
  "devDependencies": {
37
- "@anthropic-ai/claude-code": "^2.1.218",
49
+ "@anthropic-ai/claude-code": "^2.1.241",
38
50
  "@trunkio/launcher": "^1.3.4",
39
- "@types/bun": "^1.3.14",
51
+ "@types/bun": "^1.4.0",
40
52
  "prettier": "3.9.4",
41
- "skilld": "^2.0.0"
53
+ "skilld": "^2.3.0"
42
54
  },
43
55
  "peerDependencies": {
44
56
  "typescript": "^6.0.3"
45
57
  },
46
58
  "dependencies": {
47
- "@anthropic-ai/claude-agent-sdk": "^0.3.218",
48
- "@opentui/core": "^0.4.5",
59
+ "@anthropic-ai/claude-agent-sdk": "^0.3.241",
60
+ "@opentui/core": "^0.5.7",
49
61
  "cli-spinners": "^3.4.0",
50
62
  "commander": "^15.0.0",
51
63
  "ora": "^9.4.1"
package/src/agent.ts CHANGED
@@ -1,6 +1,13 @@
1
1
  /**
2
- * Thin wrapper around the Claude Agent SDK that turns a single prompt into a
3
- * single text completion.
2
+ * The model-call layer: {@link runPrompt} turns one prompt into one text
3
+ * completion, routing to whichever backend the model name asks for.
4
+ *
5
+ * A bare model name goes to Claude, through the Agent SDK, below. An
6
+ * `ollama:`-prefixed one goes to `src/ollama.ts` instead. Both return the
7
+ * same {@link ModelResult}, so the pipeline in `src/generate.ts` - and the
8
+ * injectable runner its tests use - never learns which provider ran.
9
+ *
10
+ * The Claude path, in detail:
4
11
  *
5
12
  * The Agent SDK spawns a bundled `claude` binary, so authentication follows
6
13
  * Claude Code's own resolution order over the environment we hand it. By
@@ -19,47 +26,12 @@ import {
19
26
  type SDKMessage,
20
27
  } from "@anthropic-ai/claude-agent-sdk";
21
28
  import { ClaudeCommitError } from "./errors";
22
- import type { ModelResult } from "./types";
29
+ import { parseModelRef } from "./models";
30
+ import { runOllamaPrompt } from "./ollama";
31
+ import type { ModelResult, RunPromptOptions } from "./types";
23
32
 
24
- export interface RunPromptOptions {
25
- /** Model string (alias like `sonnet`, `haiku`, or a full model id). */
26
- model: string;
27
- /** Full custom system prompt. */
28
- system: string;
29
- /** Receives assistant text as it streams in (enables partial messages). */
30
- onText?: (delta: string) => void;
31
- /** Abort the in-flight request. */
32
- abortController?: AbortController;
33
- /** Receives the underlying CLI's stderr (for `--verbose`). */
34
- onStderr?: (data: string) => void;
35
- /**
36
- * Sampling temperature. Passed to the model via `CLAUDE_CODE_EXTRA_BODY`.
37
- * Used to add variety when generating several interactive options. Models
38
- * that don't accept a temperature override will reject the request, so the
39
- * caller should be prepared to retry without it.
40
- */
41
- temperature?: number;
42
- /**
43
- * Request a structured JSON response matching this schema. The parsed object
44
- * is returned on {@link ModelResult.structured}. Models that don't support
45
- * structured outputs will reject the request, so the caller should be
46
- * prepared to retry without it.
47
- */
48
- outputFormat?: { type: "json_schema"; schema: Record<string, unknown> };
49
- /**
50
- * Allow API credentials from the environment to reach the SDK subprocess.
51
- * Defaults to false: `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` are
52
- * stripped so the run is billed to the Claude subscription.
53
- */
54
- allowApiKey?: boolean;
55
- }
33
+ export type { RunPromptOptions } from "./types";
56
34
 
57
- /**
58
- * Environment variables that carry Claude API credentials. Their presence
59
- * switches the spawned `claude` binary from subscription auth to
60
- * pay-as-you-go API billing, so they are stripped from the subprocess
61
- * environment unless the user opts in via the `allowApiKey` config option.
62
- */
63
35
  export const GATED_CREDENTIAL_VARS = [
64
36
  "ANTHROPIC_API_KEY",
65
37
  "ANTHROPIC_AUTH_TOKEN",
@@ -195,11 +167,11 @@ export function buildQueryOptions(
195
167
  }
196
168
 
197
169
  /**
198
- * Run a single prompt and return the model's text response.
170
+ * Run a single prompt against a Claude model via the Agent SDK.
199
171
  *
200
172
  * Throws {@link ClaudeCommitError} on any model/authentication/quota failure.
201
173
  */
202
- export async function runPrompt(
174
+ export async function runClaudePrompt(
203
175
  prompt: string,
204
176
  opts: RunPromptOptions,
205
177
  ): Promise<ModelResult> {
@@ -285,3 +257,24 @@ export async function runPrompt(
285
257
  ...(structured !== undefined ? { structured } : {}),
286
258
  };
287
259
  }
260
+
261
+ /**
262
+ * Run a single prompt against whichever provider `opts.model` names, and
263
+ * return its text response.
264
+ *
265
+ * This is the single seam every caller uses; `generate.ts` accepts a
266
+ * replacement of exactly this shape so the pipeline can be tested without a
267
+ * model of either kind.
268
+ *
269
+ * Throws {@link ClaudeCommitError} on any model, authentication, transport
270
+ * or quota failure.
271
+ */
272
+ export async function runPrompt(
273
+ prompt: string,
274
+ opts: RunPromptOptions,
275
+ ): Promise<ModelResult> {
276
+ const { provider } = parseModelRef(opts.model);
277
+ return provider === "ollama"
278
+ ? runOllamaPrompt(prompt, opts)
279
+ : runClaudePrompt(prompt, opts);
280
+ }