@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 +208 -5
- package/index.ts +31 -2
- package/package.json +28 -16
- package/src/agent.ts +36 -43
- package/src/cli.ts +129 -10
- package/src/config.ts +89 -6
- package/src/diff.ts +339 -14
- package/src/generate.ts +270 -47
- package/src/git.ts +38 -4
- package/src/models.ts +95 -0
- package/src/ollama.ts +502 -0
- package/src/paths.ts +139 -0
- package/src/prompts.ts +148 -27
- package/src/tokens.ts +47 -9
- package/src/types.ts +141 -3
- package/src/ui/spinner.ts +1 -1
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 {
|
|
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
|
-
|
|
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
|
-
"
|
|
92
|
-
" `claude login`). ANTHROPIC_API_KEY /
|
|
93
|
-
|
|
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
|
-
/**
|
|
106
|
-
function
|
|
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
|
|
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",
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
/**
|
|
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
|
|