@synmux/claude-commit 1.0.2 → 1.0.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/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
+ }
package/src/cli.ts CHANGED
@@ -14,12 +14,17 @@ import {
14
14
  } from "./git";
15
15
  import { presentCredentialVars } from "./agent";
16
16
  import { loadFileConfig, resolveConfig } from "./config";
17
- import { generateCommit } from "./generate";
17
+ import {
18
+ generateCommit,
19
+ type IgnoreStats,
20
+ type LowPriorityStats,
21
+ type OllamaContextWindow,
22
+ } from "./generate";
18
23
  import { Spinner } from "./ui/spinner";
19
24
  import { confirmCommit, editInEditor } from "./ui/editor";
20
25
  import { color } from "./ui/colors";
21
26
  import { ClaudeCommitError } from "./errors";
22
- import type { ModelConfig, PartialConfig } from "./types";
27
+ import type { ModelConfig, OllamaConfig, PartialConfig } from "./types";
23
28
 
24
29
  export const VERSION = getVersion();
25
30
 
@@ -40,9 +45,17 @@ interface CliOptions {
40
45
  config?: string;
41
46
  verbose?: boolean;
42
47
  skipArmored?: boolean;
48
+ filenamesOnly?: boolean;
49
+ /** `false` when `--no-low-priority-paths` was passed (Commander's negated-flag shape). */
50
+ lowPriorityPaths?: boolean;
51
+ /** `false` when `--no-ignore` was passed (Commander's negated-flag shape). */
52
+ ignore?: boolean;
53
+ ollamaHost?: string;
54
+ ollamaContext?: number | "auto";
43
55
  }
44
56
 
45
- function buildProgram(): Command {
57
+ /** Build the Commander program. Exported for tests. */
58
+ export function buildProgram(): Command {
46
59
  const program = new Command();
47
60
  program
48
61
  .name("cco")
@@ -71,6 +84,10 @@ function buildProgram(): Command {
71
84
  'template for the first line, e.g. "[PROJ-1] {message}"',
72
85
  )
73
86
  .option("-p, --prompt <text>", "extra instructions appended to the prompt")
87
+ .option(
88
+ "-f, --filenames-only",
89
+ "skip summarisation and use only filenames (faster, less useful messages)",
90
+ )
74
91
  .option("--model-summary <model>", "model used to summarize the diff")
75
92
  .option("--model-final <model>", "model used to write the final message")
76
93
  .option(
@@ -78,6 +95,26 @@ function buildProgram(): Command {
78
95
  "omit armored/encoded lines (age/gpg armor, base64 blobs) from the " +
79
96
  "summarized diff; recommended for chezmoi-style encrypted repos",
80
97
  )
98
+ .option(
99
+ "--no-low-priority-paths",
100
+ 'ignore the "lowPriorityPaths" config for this run, so every change ' +
101
+ "weighs the same",
102
+ )
103
+ .option(
104
+ "--no-ignore",
105
+ 'disregard the "ignore" config for this run, so every staged change is ' +
106
+ "read",
107
+ )
108
+ .option(
109
+ "--ollama-host <url>",
110
+ "base URL of the Ollama server for ollama: models",
111
+ )
112
+ .option(
113
+ "--ollama-context <tokens|auto>",
114
+ "context window for Ollama models: a token count, or auto to use " +
115
+ "the server's own choice for this machine",
116
+ parseContextFlag,
117
+ )
81
118
  .option("-d, --dry-run", "print the message to stdout without committing")
82
119
  .option("-y, --yes", "commit without asking for confirmation")
83
120
  .option("--no-spinner", "disable the progress spinner")
@@ -88,22 +125,39 @@ function buildProgram(): Command {
88
125
  [
89
126
  "",
90
127
  "Authentication:",
91
- " Uses the Claude Agent SDK with your Claude Code subscription (run",
92
- " `claude login`). ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN are ignored",
93
- ' unless the config sets "allowApiKey": true (pay-as-you-go billing).',
128
+ " Claude models use the Claude Agent SDK with your Claude Code",
129
+ " subscription (run `claude login`). ANTHROPIC_API_KEY /",
130
+ " ANTHROPIC_AUTH_TOKEN are ignored unless the config sets",
131
+ ' "allowApiKey": true (pay-as-you-go billing).',
132
+ "",
133
+ "Ollama models:",
134
+ " Prefix a model with `ollama:` to run it on a local Ollama server,",
135
+ " e.g. --model-summary ollama:ornith-1.5:35b. Everything after the",
136
+ " prefix is the Ollama model name, tag included. The server needs no",
137
+ " credential; point cco at it with --ollama-host or $OLLAMA_HOST.",
94
138
  "",
95
139
  "Examples:",
96
140
  " cco generate and commit a message for staged changes",
97
141
  " cco -a -c stage everything and write a Conventional Commit",
98
142
  " cco -i pick from several options interactively",
99
143
  " cco --dry-run | cat print a message without committing",
144
+ " cco --model-summary ollama:ornith-1.5:35b",
145
+ " read the diff locally, write the message with Claude",
100
146
  ].join("\n"),
101
147
  );
102
148
  return program;
103
149
  }
104
150
 
105
- /** Map parsed CLI flags onto a partial config (only set keys the user provided). */
106
- function flagsToConfig(opts: CliOptions): PartialConfig {
151
+ /** `--ollama-context` accepts a token count or the literal `auto`. */
152
+ function parseContextFlag(value: string): number | "auto" {
153
+ return value.trim().toLowerCase() === "auto" ? "auto" : parseInt(value, 10);
154
+ }
155
+
156
+ /**
157
+ * Map parsed CLI flags onto a partial config (only set keys the user
158
+ * provided). Exported for tests.
159
+ */
160
+ export function flagsToConfig(opts: CliOptions): PartialConfig {
107
161
  const cfg: PartialConfig = {};
108
162
  if (opts.conventional !== undefined)
109
163
  cfg.conventionalCommits = opts.conventional;
@@ -113,6 +167,22 @@ function flagsToConfig(opts: CliOptions): PartialConfig {
113
167
  if (opts.template !== undefined) cfg.template = opts.template;
114
168
  if (opts.prompt !== undefined) cfg.customPrompt = opts.prompt;
115
169
  if (opts.skipArmored !== undefined) cfg.skipArmored = opts.skipArmored;
170
+ if (opts.filenamesOnly !== undefined) cfg.filenamesOnly = opts.filenamesOnly;
171
+ // A negated flag arrives as `false`; an empty list overrides any
172
+ // configured patterns because lists replace rather than merge.
173
+ if (opts.lowPriorityPaths === false) cfg.lowPriorityPaths = [];
174
+ if (opts.ignore === false) cfg.ignore = [];
175
+ const ollama: Partial<OllamaConfig> = {};
176
+ if (opts.ollamaHost) ollama.host = opts.ollamaHost;
177
+ if (opts.ollamaContext === "auto") {
178
+ ollama.context = "auto";
179
+ } else if (
180
+ opts.ollamaContext !== undefined &&
181
+ Number.isFinite(opts.ollamaContext)
182
+ ) {
183
+ ollama.context = Math.max(1, opts.ollamaContext);
184
+ }
185
+ if (Object.keys(ollama).length) cfg.ollama = ollama;
116
186
  if (opts.count !== undefined && Number.isFinite(opts.count)) {
117
187
  cfg.interactiveCount = Math.max(1, opts.count);
118
188
  }
@@ -258,7 +328,7 @@ async function runNonInteractive(
258
328
  const useSpinner = opts.spinner !== false && process.stderr.isTTY;
259
329
  const spinner = new Spinner(useSpinner, config.spinner);
260
330
 
261
- spinner.start("Reading diff");
331
+ spinner.start(config.filenamesOnly ? "Reading filenames" : "Reading diff");
262
332
  let result;
263
333
  try {
264
334
  result = await generateCommit(diff, config, {
@@ -275,14 +345,29 @@ async function runNonInteractive(
275
345
  process.stderr.write(
276
346
  color(
277
347
  "90",
278
- `${result.chunkCount} chunk(s), cost $${result.costUsd.toFixed(4)}`,
348
+ `${config.filenamesOnly ? "filenames only (summariser skipped)" : `${result.chunkCount} chunk(s)`}, cost $${result.costUsd.toFixed(4)}`,
279
349
  ) + "\n",
280
350
  );
281
- for (const [i, summary] of result.summaries.entries()) {
351
+ for (const window of result.ollamaContexts) {
352
+ process.stderr.write(color("90", describeOllamaContext(window)) + "\n");
353
+ }
354
+ if (config.ignore.length > 0) {
282
355
  process.stderr.write(
283
- color("90", `--- summary ${i + 1} ---\n${summary}`) + "\n",
356
+ color("90", describeIgnoreStats(result.ignored)) + "\n",
284
357
  );
285
358
  }
359
+ if (config.lowPriorityPaths.length > 0) {
360
+ process.stderr.write(
361
+ color("90", describeLowPriorityStats(result.lowPriority)) + "\n",
362
+ );
363
+ }
364
+ for (const [index, summary] of result.summaries.entries()) {
365
+ const label =
366
+ summary.priority === "low"
367
+ ? `--- summary ${index + 1} (low priority) ---`
368
+ : `--- summary ${index + 1} ---`;
369
+ process.stderr.write(color("90", `${label}\n${summary.text}`) + "\n");
370
+ }
286
371
  }
287
372
 
288
373
  let message = result.messages[0]!;
@@ -318,6 +403,46 @@ async function runNonInteractive(
318
403
  return 0;
319
404
  }
320
405
 
406
+ /**
407
+ * One verbose line saying how the low-priority patterns applied. Without it
408
+ * a pattern that matched nothing and one that matched everything (and was
409
+ * promoted) are indistinguishable from the message alone.
410
+ */
411
+ export function describeLowPriorityStats(stats: LowPriorityStats): string {
412
+ const files = `${stats.totalFiles} file${stats.totalFiles === 1 ? "" : "s"}`;
413
+ if (stats.matchedFiles === 0) {
414
+ return `low-priority paths: matched none of ${files}`;
415
+ }
416
+ if (stats.promoted) {
417
+ return `low-priority paths: matched all ${files} - nothing else changed, so treated as primary`;
418
+ }
419
+ return `low-priority paths: matched ${stats.matchedFiles} of ${files}`;
420
+ }
421
+
422
+ /**
423
+ * One verbose line per Ollama model naming the context window it ran with
424
+ * and where the number came from. With `"auto"` this is the only place the
425
+ * server's choice is visible, and it is the first thing to check when a
426
+ * summary reads as if it saw half the diff.
427
+ */
428
+ export function describeOllamaContext(window: OllamaContextWindow): string {
429
+ const source =
430
+ window.source === "auto" ? "chosen by the server" : "from config";
431
+ return `ollama: ${window.model} context ${window.tokens} tokens (${source})`;
432
+ }
433
+
434
+ /**
435
+ * One verbose line saying how the ignore patterns applied. The counts are
436
+ * the only way to tell a pattern that quietly matched nothing from one that
437
+ * quietly removed half the commit.
438
+ */
439
+ export function describeIgnoreStats(stats: IgnoreStats): string {
440
+ const files = `${stats.totalFiles} file${stats.totalFiles === 1 ? "" : "s"}`;
441
+ return stats.ignoredFiles === 0
442
+ ? `ignore: matched none of ${files}`
443
+ : `ignore: dropped ${stats.ignoredFiles} of ${files} before reading`;
444
+ }
445
+
321
446
  function firstLine(text: string): string {
322
447
  return text.split("\n", 1)[0] ?? text;
323
448
  }
package/src/config.ts CHANGED
@@ -11,7 +11,8 @@ import { dirname, isAbsolute, join, resolve } from "node:path";
11
11
  import { homedir } from "node:os";
12
12
  import { ClaudeCommitError } from "./errors";
13
13
  import { DEFAULT_SPINNER, isSpinnerName } from "./ui/spinner";
14
- import type { Config, ModelConfig, PartialConfig } from "./types";
14
+ import { DEFAULT_OLLAMA_CONTEXT, DEFAULT_OLLAMA_HOST } from "./models";
15
+ import type { Config, ModelConfig, OllamaConfig, PartialConfig } from "./types";
15
16
 
16
17
  export const DEFAULT_CONFIG: Config = {
17
18
  conventionalCommits: false,
@@ -29,7 +30,15 @@ export const DEFAULT_CONFIG: Config = {
29
30
  },
30
31
  maxChunkTokens: 600_000,
31
32
  charsPerToken: 3.5,
33
+ filenamesOnly: false,
32
34
  skipArmored: false,
35
+ lowPriorityPaths: [],
36
+ ignore: [],
37
+ ollama: {
38
+ host: DEFAULT_OLLAMA_HOST,
39
+ context: DEFAULT_OLLAMA_CONTEXT,
40
+ keepAlive: null,
41
+ },
33
42
  allowApiKey: false,
34
43
  };
35
44
 
@@ -72,10 +81,28 @@ async function findGlobalConfigFile(
72
81
  return undefined;
73
82
  }
74
83
 
75
- /** Deep-ish merge of a partial config over a base config (only `models` is nested). */
84
+ /**
85
+ * Deep-ish merge of a partial config over a base config: `models` and
86
+ * `ollama` are merged key by key; the path lists (`lowPriorityPaths`,
87
+ * `ignore`) are replaced whole - a higher layer's list wins outright, so a
88
+ * project can drop a global pattern - and copied so the result never
89
+ * aliases the base's array.
90
+ */
76
91
  export function mergeConfig(base: Config, override: PartialConfig): Config {
77
92
  const models: ModelConfig = { ...base.models, ...(override.models ?? {}) };
78
- const merged: Config = { ...base, ...override, models };
93
+ const ollama: OllamaConfig = { ...base.ollama, ...(override.ollama ?? {}) };
94
+ const lowPriorityPaths = [
95
+ ...(override.lowPriorityPaths ?? base.lowPriorityPaths),
96
+ ];
97
+ const ignore = [...(override.ignore ?? base.ignore)];
98
+ const merged: Config = {
99
+ ...base,
100
+ ...override,
101
+ models,
102
+ ollama,
103
+ lowPriorityPaths,
104
+ ignore,
105
+ };
79
106
  return merged;
80
107
  }
81
108
 
@@ -94,6 +121,7 @@ export function sanitizePartial(raw: unknown): PartialConfig {
94
121
  bool("multiline");
95
122
  bool("interactive");
96
123
  bool("skipArmored");
124
+ bool("filenamesOnly");
97
125
  bool("allowApiKey");
98
126
 
99
127
  if (typeof obj.template === "string") out.template = obj.template;
@@ -127,18 +155,64 @@ export function sanitizePartial(raw: unknown): PartialConfig {
127
155
  if (typeof obj.charsPerToken === "number" && obj.charsPerToken > 0) {
128
156
  out.charsPerToken = obj.charsPerToken;
129
157
  }
158
+ // An explicit empty list is meaningful for either path option: it clears
159
+ // patterns inherited from a lower layer, so it is kept rather than
160
+ // treated as "unset".
161
+ if (Array.isArray(obj.lowPriorityPaths)) {
162
+ out.lowPriorityPaths = cleanPatternList(obj.lowPriorityPaths);
163
+ }
164
+ if (Array.isArray(obj.ignore)) {
165
+ out.ignore = cleanPatternList(obj.ignore);
166
+ }
130
167
 
131
168
  if (obj.models && typeof obj.models === "object") {
132
169
  const m = obj.models as Record<string, unknown>;
133
170
  const models: Partial<ModelConfig> = {};
134
- if (typeof m.summary === "string") models.summary = m.summary;
135
- if (typeof m.final === "string") models.final = m.final;
171
+ // A blank model name is not an override, it is a mistake: leaving the
172
+ // key unset keeps the layer below, which is a working model.
173
+ if (typeof m.summary === "string" && m.summary.trim() !== "") {
174
+ models.summary = m.summary.trim();
175
+ }
176
+ if (typeof m.final === "string" && m.final.trim() !== "") {
177
+ models.final = m.final.trim();
178
+ }
136
179
  if (Object.keys(models).length) out.models = models;
137
180
  }
138
181
 
182
+ if (obj.ollama && typeof obj.ollama === "object") {
183
+ const o = obj.ollama as Record<string, unknown>;
184
+ const ollama: Partial<OllamaConfig> = {};
185
+ if (typeof o.host === "string" && o.host.trim() !== "") {
186
+ ollama.host = o.host.trim();
187
+ }
188
+ if (typeof o.context === "number" && o.context > 0) {
189
+ ollama.context = Math.floor(o.context);
190
+ } else if (o.context === "auto") {
191
+ ollama.context = "auto";
192
+ }
193
+ if (typeof o.keepAlive === "string" || typeof o.keepAlive === "number") {
194
+ ollama.keepAlive = o.keepAlive;
195
+ } else if (o.keepAlive === null) {
196
+ ollama.keepAlive = null;
197
+ }
198
+ if (Object.keys(ollama).length) out.ollama = ollama;
199
+ }
200
+
139
201
  return out;
140
202
  }
141
203
 
204
+ /**
205
+ * Clean one raw path-pattern list: drop non-strings and blanks, trim the
206
+ * rest. Shared by `lowPriorityPaths` and `ignore`, which take the same
207
+ * pattern language (see `src/paths.ts`).
208
+ */
209
+ function cleanPatternList(raw: unknown[]): string[] {
210
+ return raw
211
+ .filter((entry): entry is string => typeof entry === "string")
212
+ .map((entry) => entry.trim())
213
+ .filter((entry) => entry !== "");
214
+ }
215
+
142
216
  async function readJsonIfExists(path: string): Promise<unknown | undefined> {
143
217
  const file = Bun.file(path);
144
218
  if (!(await file.exists())) return undefined;
@@ -233,7 +307,11 @@ export async function loadFileConfig(
233
307
  return result;
234
308
  }
235
309
 
236
- /** Merge two partial configs (only `models` is nested). */
310
+ /**
311
+ * Merge two partial configs: `models` and `ollama` are merged key by key;
312
+ * every other key, including both path lists, is taken whole from the
313
+ * override when present.
314
+ */
237
315
  export function mergePartial(
238
316
  base: PartialConfig,
239
317
  override: PartialConfig,
@@ -242,6 +320,13 @@ export function mergePartial(
242
320
  if (base.models || override.models) {
243
321
  out.models = { ...base.models, ...override.models };
244
322
  }
323
+ if (base.ollama || override.ollama) {
324
+ out.ollama = { ...base.ollama, ...override.ollama };
325
+ }
326
+ const lowPriorityPaths = override.lowPriorityPaths ?? base.lowPriorityPaths;
327
+ if (lowPriorityPaths) out.lowPriorityPaths = [...lowPriorityPaths];
328
+ const ignore = override.ignore ?? base.ignore;
329
+ if (ignore) out.ignore = [...ignore];
245
330
  return out;
246
331
  }
247
332