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