@synmux/claude-commit 0.1.0

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/tokens.ts ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Lightweight token estimation.
3
+ *
4
+ * We deliberately avoid a real tokenizer here: chunking only needs a rough,
5
+ * conservative estimate to decide where to split a diff, and pulling in a
6
+ * tokenizer (or a network round-trip to `count_tokens`) would add weight and
7
+ * latency for no real benefit. We slightly over-estimate tokens so that chunks
8
+ * stay safely under the model's context window.
9
+ */
10
+
11
+ /** Estimate the number of tokens in `text` given a chars-per-token ratio. */
12
+ export function estimateTokens(text: string, charsPerToken: number): number {
13
+ if (charsPerToken <= 0) throw new Error("charsPerToken must be positive");
14
+ return Math.ceil(text.length / charsPerToken);
15
+ }
16
+
17
+ /** Convert a token budget into an approximate character budget. */
18
+ export function tokensToChars(tokens: number, charsPerToken: number): number {
19
+ return Math.floor(tokens * charsPerToken);
20
+ }
package/src/types.ts ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Shared types for claude-commit.
3
+ */
4
+
5
+ /** Which models to use for each stage of the pipeline. */
6
+ export interface ModelConfig {
7
+ /** Model used to read diffs and write summaries. Defaults to `sonnet[1m]`. */
8
+ summary: string;
9
+ /** Model used to turn summaries into the final commit message. Defaults to `haiku`. */
10
+ final: string;
11
+ }
12
+
13
+ /** Fully-resolved configuration after merging defaults, file config and CLI flags. */
14
+ export interface Config {
15
+ /** Format the subject line as a Conventional Commit (`type(scope): description`). */
16
+ conventionalCommits: boolean;
17
+ /** Prefix the subject line with a gitmoji. */
18
+ gitmoji: boolean;
19
+ /** Produce a multi-line commit (subject + body) instead of a single subject line. */
20
+ multiline: boolean;
21
+ /**
22
+ * Template for the first line. `{message}` is replaced with the generated
23
+ * subject. Useful for ticket prefixes, e.g. `"[PROJ-123] {message}"`.
24
+ */
25
+ template: string | null;
26
+ /** Extra instructions appended to the standard prompt. */
27
+ customPrompt: string | null;
28
+ /**
29
+ * Default to interactive mode (the `-i` selection TUI) on every run, without
30
+ * needing to pass `-i`. Override for a single run with `--no-interactive`.
31
+ * When there is no interactive terminal (a pipe, CI, etc.) this is ignored and
32
+ * cco falls back to the non-interactive flow rather than failing.
33
+ */
34
+ interactive: boolean;
35
+ /** How many candidate messages to generate in interactive mode. */
36
+ interactiveCount: number;
37
+ /**
38
+ * Sampling temperature for the final model when generating interactive
39
+ * options, to encourage variety between candidates. `null` leaves the model
40
+ * at its default. Only applied in interactive mode.
41
+ */
42
+ interactiveTemperature: number | null;
43
+ /** Models for each pipeline stage. */
44
+ models: ModelConfig;
45
+ /**
46
+ * Approximate maximum number of tokens of diff to send to the summary model
47
+ * in a single request. Diffs larger than this are split across requests.
48
+ */
49
+ maxChunkTokens: number;
50
+ /** Approximate characters-per-token ratio used for chunk-size estimation. */
51
+ charsPerToken: number;
52
+ /**
53
+ * Allow API credentials from the environment (`ANTHROPIC_API_KEY` /
54
+ * `ANTHROPIC_AUTH_TOKEN`) to be used, billing pay-as-you-go instead of the
55
+ * Claude subscription. When false (the default) those variables are
56
+ * stripped from the environment passed to the Claude Agent SDK subprocess,
57
+ * so an exported key can never silently switch billing.
58
+ */
59
+ allowApiKey: boolean;
60
+ }
61
+
62
+ /** Partial config as it may appear in a config file or be produced by flags. */
63
+ export type PartialConfig = {
64
+ [K in keyof Config]?: K extends "models" ? Partial<ModelConfig> : Config[K];
65
+ };
66
+
67
+ /** Result of a single model invocation. */
68
+ export interface ModelResult {
69
+ /** The text the model produced. */
70
+ text: string;
71
+ /** Cost of the call in USD, if reported. */
72
+ costUsd: number;
73
+ /** The model that actually served the request, if reported. */
74
+ model?: string;
75
+ /** Parsed structured output, when a JSON-schema `outputFormat` was requested. */
76
+ structured?: unknown;
77
+ }
78
+
79
+ /** A staged change as seen by `git`. */
80
+ export interface FileChange {
81
+ /** Status code from `git diff --name-status` (e.g. `A`, `M`, `D`, `R100`). */
82
+ status: string;
83
+ /** Path of the file (the destination path for renames). */
84
+ path: string;
85
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Minimal ANSI color helpers for stderr output.
3
+ *
4
+ * Color is emitted only when stderr is a TTY and `NO_COLOR` is unset, so escape
5
+ * codes never leak into redirected logs or CI output.
6
+ */
7
+ export const useColor = Boolean(process.stderr.isTTY) && !process.env.NO_COLOR;
8
+
9
+ /** Wrap `text` in an ANSI SGR sequence when color is enabled, else return it unchanged. */
10
+ export function color(code: string, text: string): string {
11
+ return useColor ? `\x1b[${code}m${text}\x1b[0m` : text;
12
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Launching the user's `$EDITOR` to tweak a commit message, plus a tiny
3
+ * yes/no/edit confirmation prompt for non-interactive runs in a TTY.
4
+ */
5
+ import { spawn } from "node:child_process";
6
+ import { readFile, unlink, writeFile } from "node:fs/promises";
7
+ import { randomUUID } from "node:crypto";
8
+ import { join } from "node:path";
9
+ import { tmpdir } from "node:os";
10
+ import { createInterface } from "node:readline";
11
+ import { ClaudeCommitError } from "../errors";
12
+
13
+ /** Resolve the editor command, mirroring git's lookup order. */
14
+ function resolveEditor(): string {
15
+ return (
16
+ process.env.GIT_EDITOR || process.env.VISUAL || process.env.EDITOR || "vi"
17
+ );
18
+ }
19
+
20
+ /** Open `initial` in the user's editor and return the saved contents. */
21
+ export async function editInEditor(initial: string): Promise<string> {
22
+ const editor = resolveEditor();
23
+ // Unpredictable name + exclusive create (`wx`) + owner-only perms (0o600)
24
+ // so the temp file can't be pre-created as a symlink or read by other users.
25
+ const file = join(tmpdir(), `claude-commit-edit-${randomUUID()}.txt`);
26
+ try {
27
+ await writeFile(file, initial, { mode: 0o600, flag: "wx" });
28
+ } catch (err) {
29
+ throw new ClaudeCommitError(
30
+ `Could not create a temporary file to edit the message: ${(err as Error).message}`,
31
+ );
32
+ }
33
+ try {
34
+ await runEditor(editor, file);
35
+ const edited = await readFile(file, "utf8");
36
+ // Drop trailing whitespace/newline noise editors tend to add.
37
+ return edited.replace(/\s+$/, "");
38
+ } catch (err) {
39
+ if (err instanceof ClaudeCommitError) throw err;
40
+ throw new ClaudeCommitError(
41
+ `Editing the commit message failed (${editor}): ${(err as Error).message}`,
42
+ );
43
+ } finally {
44
+ await unlink(file).catch(() => {});
45
+ }
46
+ }
47
+
48
+ function runEditor(editor: string, file: string): Promise<void> {
49
+ return new Promise((resolvePromise, reject) => {
50
+ // $EDITOR / $GIT_EDITOR are shell command lines that may contain flags,
51
+ // spaces in the program path, or quoted arguments (e.g. "code --wait" or
52
+ // "/path/with spaces/editor"). Run them through the shell exactly as git
53
+ // does rather than naively splitting on whitespace. The file path is our
54
+ // own (tmpdir + UUID), so double-quoting it is safe.
55
+ const child = spawn(`${editor} "${file}"`, {
56
+ stdio: "inherit",
57
+ shell: true,
58
+ });
59
+ child.on("error", reject);
60
+ child.on("exit", (code) => {
61
+ if (code === 0 || code === null) resolvePromise();
62
+ else reject(new Error(`Editor exited with code ${code}`));
63
+ });
64
+ });
65
+ }
66
+
67
+ export type ConfirmChoice = "yes" | "no" | "edit";
68
+
69
+ /** Prompt for [Y]es / [n]o / [e]dit. Requires an interactive stdin. */
70
+ export async function confirmCommit(): Promise<ConfirmChoice> {
71
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
72
+ try {
73
+ for (;;) {
74
+ const answer = (
75
+ await new Promise<string>((res) =>
76
+ rl.question("Commit this message? [Y/n/e] ", res),
77
+ )
78
+ )
79
+ .trim()
80
+ .toLowerCase();
81
+ if (answer === "" || answer === "y" || answer === "yes") return "yes";
82
+ if (answer === "n" || answer === "no") return "no";
83
+ if (answer === "e" || answer === "edit") return "edit";
84
+ process.stderr.write("Please answer y, n, or e.\n");
85
+ }
86
+ } finally {
87
+ rl.close();
88
+ }
89
+ }
@@ -0,0 +1,313 @@
1
+ /**
2
+ * Interactive mode: generate several candidate messages, then let the user pick
3
+ * one (and optionally edit it) before committing.
4
+ *
5
+ * The picker is an OpenTUI selection screen. All key handling is driven through
6
+ * the renderer's global key handler so there is a single source of truth for
7
+ * navigation. If the TUI cannot be initialized for any reason, we transparently
8
+ * fall back to a plain readline prompt.
9
+ */
10
+ import { createInterface } from "node:readline";
11
+ import { commit } from "../git";
12
+ import { generateCommit } from "../generate";
13
+ import { Spinner } from "./spinner";
14
+ import { editInEditor } from "./editor";
15
+ import { color } from "./colors";
16
+ import type { Config } from "../types";
17
+
18
+ export interface InteractiveOptions {
19
+ verbose: boolean;
20
+ abortController: AbortController;
21
+ }
22
+
23
+ type Selection =
24
+ { action: "commit" | "edit"; index: number } | { action: "cancel" };
25
+
26
+ /** Run the full interactive flow. Returns a process exit code. */
27
+ export async function runInteractive(
28
+ diff: string,
29
+ config: Config,
30
+ opts: InteractiveOptions,
31
+ ): Promise<number> {
32
+ const count = Math.max(1, config.interactiveCount);
33
+ const spinner = new Spinner(process.stderr.isTTY);
34
+ spinner.start(`Generating ${count} option${count === 1 ? "" : "s"}`);
35
+ let result;
36
+ try {
37
+ result = await generateCommit(diff, config, {
38
+ count,
39
+ progress: { onPhase: (label) => spinner.update(label) },
40
+ abortController: opts.abortController,
41
+ });
42
+ } catch (err) {
43
+ spinner.stop();
44
+ throw err;
45
+ }
46
+ spinner.stop();
47
+
48
+ const messages = result.messages;
49
+
50
+ let selection: Selection;
51
+ try {
52
+ selection = await selectWithTui(messages);
53
+ } catch {
54
+ // TUI failed to initialize (unusual terminal, etc.) — degrade gracefully.
55
+ selection = await selectWithReadline(messages);
56
+ }
57
+
58
+ if (selection.action === "cancel") {
59
+ process.stderr.write("Aborted. Nothing was committed.\n");
60
+ return 1;
61
+ }
62
+
63
+ let message = messages[selection.index]!;
64
+ if (selection.action === "edit") {
65
+ message = await editInEditor(message);
66
+ if (message.trim() === "") {
67
+ process.stderr.write("Aborted: empty commit message.\n");
68
+ return 1;
69
+ }
70
+ }
71
+
72
+ await commit(message);
73
+ process.stderr.write(
74
+ `${color("32", "✔")} Committed\n${color("90", firstLine(message))}\n`,
75
+ );
76
+ if (opts.verbose) {
77
+ process.stderr.write(
78
+ color("90", `cost $${result.costUsd.toFixed(4)}`) + "\n",
79
+ );
80
+ }
81
+ return 0;
82
+ }
83
+
84
+ /**
85
+ * Rows a single candidate occupies in the picker: the subject line plus a
86
+ * one-line body preview (`showDescription`), with OpenTUI's default
87
+ * `itemSpacing` of 0. Mirrors SelectRenderable's own line accounting.
88
+ */
89
+ const PICKER_LINES_PER_ITEM = 2;
90
+
91
+ /**
92
+ * Rows reserved for the non-picker chrome when capping its height: root padding
93
+ * (2), the header line (1) and the gap below it (1).
94
+ */
95
+ const PICKER_CHROME_ROWS = 4;
96
+
97
+ /**
98
+ * The picker's height, sized to its content (two rows per candidate) but capped
99
+ * to the terminal so a large `interactiveCount` still fits; past the cap the list
100
+ * scrolls internally (see `showScrollIndicator` in {@link buildPickerScene}). An
101
+ * explicit height keeps the picker compact — only as tall as the options need —
102
+ * rather than stretching to fill the screen.
103
+ */
104
+ export function pickerHeight(count: number, terminalRows: number): number {
105
+ const wanted = Math.max(1, count) * PICKER_LINES_PER_ITEM;
106
+ const cap = Math.max(
107
+ PICKER_LINES_PER_ITEM,
108
+ terminalRows - PICKER_CHROME_ROWS,
109
+ );
110
+ return Math.min(wanted, cap);
111
+ }
112
+
113
+ /** The `@opentui/core` module, however it is obtained (dynamic import or test). */
114
+ type TuiModule = typeof import("@opentui/core");
115
+ /** The renderer object returned by `createCliRenderer` (and the headless test renderer). */
116
+ type TuiRenderer = Awaited<ReturnType<TuiModule["createCliRenderer"]>>;
117
+
118
+ /** The renderables the caller wires key handling to after building the scene. */
119
+ export interface PickerScene {
120
+ root: InstanceType<TuiModule["BoxRenderable"]>;
121
+ select: InstanceType<TuiModule["SelectRenderable"]>;
122
+ }
123
+
124
+ /**
125
+ * Build the picker's renderable tree: a header line above the candidate list.
126
+ * Extracted from {@link selectWithTui} so the layout can be rendered under
127
+ * OpenTUI's headless test renderer and asserted on. The caller adds `root` to
128
+ * the renderer and wires key handling to the returned `select`.
129
+ */
130
+ export function buildPickerScene(
131
+ renderer: TuiRenderer,
132
+ tui: TuiModule,
133
+ messages: string[],
134
+ terminalRows: number,
135
+ ): PickerScene {
136
+ const { BoxRenderable, TextRenderable, SelectRenderable } = tui;
137
+
138
+ const root = new BoxRenderable(renderer, {
139
+ flexDirection: "column",
140
+ width: "100%",
141
+ height: "100%",
142
+ padding: 1,
143
+ gap: 1,
144
+ });
145
+
146
+ const header = new TextRenderable(renderer, {
147
+ content:
148
+ "Pick a commit message ↑/↓ select · ⏎ commit · e edit · q cancel",
149
+ });
150
+
151
+ // A compact, content-sized list of the candidate messages. flexShrink:0 keeps
152
+ // it at its full height; showScrollIndicator covers more options than fit.
153
+ const select = new SelectRenderable(renderer, {
154
+ height: pickerHeight(messages.length, terminalRows),
155
+ flexShrink: 0,
156
+ showScrollIndicator: true,
157
+ options: messages.map((message, index) => ({
158
+ name: firstLine(message),
159
+ description: bodyPreview(message),
160
+ value: index,
161
+ })),
162
+ selectedIndex: 0,
163
+ showDescription: true,
164
+ wrapSelection: true,
165
+ // A calm slate highlight (OpenTUI's own default background) with soft
166
+ // near-white text — the previous bright cyan/black fill was too harsh.
167
+ // The default description greys (#888888 / #CCCCCC) read fine on the slate,
168
+ // so they are left untouched.
169
+ selectedBackgroundColor: "#334455",
170
+ selectedTextColor: "#e6edf3",
171
+ });
172
+
173
+ root.add(header);
174
+ root.add(select);
175
+
176
+ return { root, select };
177
+ }
178
+
179
+ /** The OpenTUI selection screen. Resolves with the user's choice. */
180
+ async function selectWithTui(messages: string[]): Promise<Selection> {
181
+ const tui = await import("@opentui/core");
182
+ const renderer = await tui.createCliRenderer({ exitOnCtrlC: false });
183
+
184
+ return await new Promise<Selection>((resolve, reject) => {
185
+ let settled = false;
186
+ let onKey: (key: { name?: string; ctrl?: boolean }) => void = () => {};
187
+
188
+ const cleanup = () => {
189
+ try {
190
+ renderer.keyInput.off("keypress", onKey);
191
+ } catch {
192
+ /* ignore */
193
+ }
194
+ try {
195
+ renderer.destroy();
196
+ } catch {
197
+ /* ignore */
198
+ }
199
+ };
200
+ const finish = (sel: Selection) => {
201
+ if (settled) return;
202
+ settled = true;
203
+ cleanup();
204
+ resolve(sel);
205
+ };
206
+
207
+ try {
208
+ const terminalRows = process.stdout.rows ?? 24;
209
+ const { root, select } = buildPickerScene(
210
+ renderer,
211
+ tui,
212
+ messages,
213
+ terminalRows,
214
+ );
215
+ renderer.root.add(root);
216
+
217
+ onKey = (key) => {
218
+ if (!key) return; // some terminals can emit empty/unknown key events
219
+ try {
220
+ switch (key.name) {
221
+ case "up":
222
+ case "k":
223
+ select.moveUp();
224
+ renderer.requestRender();
225
+ break;
226
+ case "down":
227
+ case "j":
228
+ select.moveDown();
229
+ renderer.requestRender();
230
+ break;
231
+ case "return":
232
+ case "enter":
233
+ finish({ action: "commit", index: select.getSelectedIndex() });
234
+ break;
235
+ case "e":
236
+ finish({ action: "edit", index: select.getSelectedIndex() });
237
+ break;
238
+ case "q":
239
+ case "escape":
240
+ finish({ action: "cancel" });
241
+ break;
242
+ case "c":
243
+ if (key.ctrl) finish({ action: "cancel" });
244
+ break;
245
+ }
246
+ } catch {
247
+ // A renderable method threw unexpectedly. Rather than let the error
248
+ // escape the key handler and leave the terminal stuck in raw mode,
249
+ // cancel cleanly — finish() restores the terminal via cleanup().
250
+ finish({ action: "cancel" });
251
+ }
252
+ };
253
+
254
+ renderer.keyInput.on("keypress", onKey);
255
+ renderer.start();
256
+ renderer.requestRender();
257
+ } catch (err) {
258
+ cleanup();
259
+ reject(err);
260
+ }
261
+ });
262
+ }
263
+
264
+ /** Plain-prompt fallback when the TUI is unavailable. */
265
+ async function selectWithReadline(messages: string[]): Promise<Selection> {
266
+ process.stderr.write("\nCandidate commit messages:\n");
267
+ messages.forEach((m, i) =>
268
+ process.stderr.write(` ${i + 1}. ${firstLine(m)}\n`),
269
+ );
270
+
271
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
272
+ try {
273
+ for (;;) {
274
+ const answer = (
275
+ await new Promise<string>((res) =>
276
+ rl.question(
277
+ `Choose 1-${messages.length}, "e N" to edit, or q to quit: `,
278
+ res,
279
+ ),
280
+ )
281
+ )
282
+ .trim()
283
+ .toLowerCase();
284
+
285
+ if (answer === "q" || answer === "") return { action: "cancel" };
286
+
287
+ const editMatch = answer.match(/^e\s*(\d+)$/);
288
+ if (editMatch) {
289
+ const index = parseInt(editMatch[1]!, 10) - 1;
290
+ if (index >= 0 && index < messages.length)
291
+ return { action: "edit", index };
292
+ }
293
+
294
+ const choice = parseInt(answer, 10);
295
+ if (choice >= 1 && choice <= messages.length) {
296
+ return { action: "commit", index: choice - 1 };
297
+ }
298
+ process.stderr.write("Invalid choice.\n");
299
+ }
300
+ } finally {
301
+ rl.close();
302
+ }
303
+ }
304
+
305
+ function firstLine(text: string): string {
306
+ return text.split("\n", 1)[0] ?? text;
307
+ }
308
+
309
+ /** A short, single-line preview of a message body (everything after the subject). */
310
+ function bodyPreview(text: string): string {
311
+ const rest = text.split("\n").slice(1).join(" ").replace(/\s+/g, " ").trim();
312
+ return rest.length > 120 ? rest.slice(0, 117) + "…" : rest;
313
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * A minimal, dependency-free progress spinner for non-interactive runs.
3
+ *
4
+ * It renders to stderr (so stdout stays clean for piping) and only animates when
5
+ * stderr is a TTY; otherwise `start`/`update` are silent no-ops. This keeps the
6
+ * read-only progress indicator out of the way of `cco --dry-run | git commit -F -`.
7
+ */
8
+ import { color } from "./colors";
9
+
10
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
11
+ const INTERVAL_MS = 80;
12
+
13
+ export class Spinner {
14
+ private timer: ReturnType<typeof setInterval> | null = null;
15
+ private frame = 0;
16
+ private label = "";
17
+ private readonly enabled: boolean;
18
+
19
+ constructor(enabled = process.stderr.isTTY) {
20
+ this.enabled = Boolean(enabled);
21
+ }
22
+
23
+ start(label: string): void {
24
+ this.label = label;
25
+ if (!this.enabled) return;
26
+ this.stopTimer();
27
+ process.stderr.write("\x1b[?25l"); // hide cursor
28
+ this.render();
29
+ this.timer = setInterval(() => {
30
+ this.frame = (this.frame + 1) % FRAMES.length;
31
+ this.render();
32
+ }, INTERVAL_MS);
33
+ }
34
+
35
+ update(label: string): void {
36
+ this.label = label;
37
+ if (this.enabled && this.timer) this.render();
38
+ }
39
+
40
+ /** Stop and clear the spinner line, optionally printing a final status line. */
41
+ stop(finalLine?: string): void {
42
+ this.stopTimer();
43
+ if (this.enabled) {
44
+ process.stderr.write("\r\x1b[2K\x1b[?25h"); // clear line, show cursor
45
+ }
46
+ if (finalLine !== undefined) process.stderr.write(finalLine + "\n");
47
+ }
48
+
49
+ succeed(label: string): void {
50
+ this.stop(`${color("32", "✔")} ${label}`);
51
+ }
52
+
53
+ fail(label: string): void {
54
+ this.stop(`${color("31", "✖")} ${label}`);
55
+ }
56
+
57
+ private render(): void {
58
+ // `\r\x1b[2K` (cursor return + clear line) only runs when enabled (a TTY);
59
+ // the frame color additionally respects NO_COLOR via the helper.
60
+ process.stderr.write(
61
+ `\r\x1b[2K${color("36", FRAMES[this.frame]!)} ${this.label}`,
62
+ );
63
+ }
64
+
65
+ private stopTimer(): void {
66
+ if (this.timer) {
67
+ clearInterval(this.timer);
68
+ this.timer = null;
69
+ }
70
+ }
71
+ }