@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/cli.ts ADDED
@@ -0,0 +1,315 @@
1
+ /**
2
+ * Command-line interface: argument parsing (Commander) and orchestration of the
3
+ * non-interactive and interactive flows.
4
+ */
5
+ import { Command } from "commander";
6
+ import {
7
+ commit,
8
+ getStagedDiff,
9
+ getStagedStat,
10
+ isGitRepo,
11
+ getRepoRoot,
12
+ stageAll,
13
+ } from "./git";
14
+ import { presentCredentialVars } from "./agent";
15
+ import { loadFileConfig, resolveConfig } from "./config";
16
+ import { generateCommit } from "./generate";
17
+ import { Spinner } from "./ui/spinner";
18
+ import { confirmCommit, editInEditor } from "./ui/editor";
19
+ import { color } from "./ui/colors";
20
+ import { ClaudeCommitError } from "./errors";
21
+ import type { ModelConfig, PartialConfig } from "./types";
22
+
23
+ export const VERSION = "0.1.0";
24
+
25
+ interface CliOptions {
26
+ interactive?: boolean;
27
+ count?: number;
28
+ multiline?: boolean;
29
+ conventional?: boolean;
30
+ gitmoji?: boolean;
31
+ template?: string;
32
+ prompt?: string;
33
+ all?: boolean;
34
+ modelSummary?: string;
35
+ modelFinal?: string;
36
+ dryRun?: boolean;
37
+ yes?: boolean;
38
+ spinner?: boolean;
39
+ config?: string;
40
+ verbose?: boolean;
41
+ }
42
+
43
+ function buildProgram(): Command {
44
+ const program = new Command();
45
+ program
46
+ .name("cco")
47
+ .description("Generate a git commit message with Claude.")
48
+ .version(VERSION, "-V, --version", "output the version number")
49
+ .option(
50
+ "-i, --interactive",
51
+ "choose between several options in an interactive TUI",
52
+ )
53
+ .option(
54
+ "--no-interactive",
55
+ 'skip the interactive TUI even when "interactive" is set in config',
56
+ )
57
+ .option(
58
+ "-n, --count <n>",
59
+ "number of options to generate in interactive mode",
60
+ (v) => parseInt(v, 10),
61
+ )
62
+ .option("-a, --all", "stage all changes (git add -A) before committing")
63
+ .option("-c, --conventional", "format as a Conventional Commit")
64
+ .option("-g, --gitmoji", "prefix the subject with a gitmoji")
65
+ .option("-m, --multiline", "write a multi-line commit (subject + body)")
66
+ .option("--no-multiline", "write only a single-line subject")
67
+ .option(
68
+ "-t, --template <tpl>",
69
+ 'template for the first line, e.g. "[PROJ-1] {message}"',
70
+ )
71
+ .option("-p, --prompt <text>", "extra instructions appended to the prompt")
72
+ .option("--model-summary <model>", "model used to summarize the diff")
73
+ .option("--model-final <model>", "model used to write the final message")
74
+ .option("-d, --dry-run", "print the message to stdout without committing")
75
+ .option("-y, --yes", "commit without asking for confirmation")
76
+ .option("--no-spinner", "disable the progress spinner")
77
+ .option("--config <path>", "path to a config file")
78
+ .option("-v, --verbose", "print summaries, cost and debug output")
79
+ .addHelpText(
80
+ "after",
81
+ [
82
+ "",
83
+ "Authentication:",
84
+ " Uses the Claude Agent SDK with your Claude Code subscription (run",
85
+ " `claude login`). ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN are ignored",
86
+ ' unless the config sets "allowApiKey": true (pay-as-you-go billing).',
87
+ "",
88
+ "Examples:",
89
+ " cco generate and commit a message for staged changes",
90
+ " cco -a -c stage everything and write a Conventional Commit",
91
+ " cco -i pick from several options interactively",
92
+ " cco --dry-run | cat print a message without committing",
93
+ ].join("\n"),
94
+ );
95
+ return program;
96
+ }
97
+
98
+ /** Map parsed CLI flags onto a partial config (only set keys the user provided). */
99
+ function flagsToConfig(opts: CliOptions): PartialConfig {
100
+ const cfg: PartialConfig = {};
101
+ if (opts.conventional !== undefined)
102
+ cfg.conventionalCommits = opts.conventional;
103
+ if (opts.gitmoji !== undefined) cfg.gitmoji = opts.gitmoji;
104
+ if (opts.multiline !== undefined) cfg.multiline = opts.multiline;
105
+ if (opts.interactive !== undefined) cfg.interactive = opts.interactive;
106
+ if (opts.template !== undefined) cfg.template = opts.template;
107
+ if (opts.prompt !== undefined) cfg.customPrompt = opts.prompt;
108
+ if (opts.count !== undefined && Number.isFinite(opts.count)) {
109
+ cfg.interactiveCount = Math.max(1, opts.count);
110
+ }
111
+ const models: Partial<ModelConfig> = {};
112
+ if (opts.modelSummary) models.summary = opts.modelSummary;
113
+ if (opts.modelFinal) models.final = opts.modelFinal;
114
+ if (Object.keys(models).length) cfg.models = models;
115
+ return cfg;
116
+ }
117
+
118
+ /**
119
+ * Decide which flow to run from the resolved config, the raw `--interactive`
120
+ * flag state, and whether we have an interactive terminal.
121
+ *
122
+ * - `--dry-run` always wins: it prints and never commits, so the TUI is skipped.
123
+ * - Interactive requires a TTY. An explicit `-i` without one is a hard error
124
+ * (the user asked for something we cannot provide), whereas interactive coming
125
+ * only from config silently falls back to the non-interactive flow so pipes
126
+ * and CI keep working.
127
+ */
128
+ export function resolveInteractiveMode(args: {
129
+ configInteractive: boolean;
130
+ interactiveFlag: boolean | undefined;
131
+ dryRun: boolean;
132
+ hasTty: boolean;
133
+ }): "interactive" | "non-interactive" | "no-tty-error" {
134
+ if (args.dryRun) return "non-interactive";
135
+ if (!args.configInteractive) return "non-interactive";
136
+ if (args.hasTty) return "interactive";
137
+ return args.interactiveFlag === true ? "no-tty-error" : "non-interactive";
138
+ }
139
+
140
+ function printMessage(message: string): void {
141
+ const bar = color("90", "─".repeat(48));
142
+ process.stderr.write(`\n${bar}\n${message}\n${bar}\n`);
143
+ }
144
+
145
+ /** Entry point. Returns a process exit code. */
146
+ export async function run(argv: string[]): Promise<number> {
147
+ const program = buildProgram();
148
+ program.parse(argv, { from: "user" });
149
+ const opts = program.opts<CliOptions>();
150
+ const verbose = Boolean(opts.verbose);
151
+
152
+ const abortController = new AbortController();
153
+ // Two-stage Ctrl-C: the first interrupt asks the in-flight generation to
154
+ // cancel gracefully (aborting the SDK subprocess); a second, impatient
155
+ // interrupt force-quits in case that abort is slow to take effect.
156
+ let interrupting = false;
157
+ const onSigint = () => {
158
+ if (interrupting) {
159
+ // Second Ctrl-C: force-quit. Restore the cursor in case a spinner hid it,
160
+ // since this path bypasses the spinner's own cleanup.
161
+ if (process.stderr.isTTY) process.stderr.write("\x1b[?25h");
162
+ process.exit(130);
163
+ }
164
+ interrupting = true;
165
+ abortController.abort();
166
+ };
167
+ process.on("SIGINT", onSigint);
168
+
169
+ try {
170
+ if (!(await isGitRepo())) {
171
+ throw new ClaudeCommitError(
172
+ "Not a git repository (or any parent). Run `cco` inside a repo.",
173
+ );
174
+ }
175
+ const repoRoot = await getRepoRoot();
176
+ const fileConfig = await loadFileConfig(
177
+ process.cwd(),
178
+ repoRoot,
179
+ opts.config,
180
+ );
181
+ const config = resolveConfig(fileConfig, flagsToConfig(opts));
182
+
183
+ // Surface the credential gate: the actual stripping happens in the agent
184
+ // layer, but silently ignoring an exported key would be confusing.
185
+ if (!config.allowApiKey) {
186
+ const ignored = presentCredentialVars(process.env);
187
+ if (ignored.length > 0) {
188
+ process.stderr.write(
189
+ color(
190
+ "90",
191
+ `Ignoring ${ignored.join(" and ")}: using subscription auth. Set ` +
192
+ `"allowApiKey": true in your claude-commit config to use API credentials.`,
193
+ ) + "\n",
194
+ );
195
+ }
196
+ }
197
+
198
+ if (opts.all) await stageAll();
199
+
200
+ const diff = await getStagedDiff();
201
+ if (diff.trim() === "") {
202
+ throw new ClaudeCommitError(
203
+ opts.all
204
+ ? "No changes to commit: the working tree is clean."
205
+ : "No staged changes. Stage files with `git add`, or pass -a/--all to stage everything.",
206
+ );
207
+ }
208
+
209
+ const interactiveMode = resolveInteractiveMode({
210
+ configInteractive: config.interactive,
211
+ interactiveFlag: opts.interactive,
212
+ dryRun: Boolean(opts.dryRun),
213
+ hasTty: Boolean(process.stdin.isTTY && process.stdout.isTTY),
214
+ });
215
+ if (interactiveMode === "no-tty-error") {
216
+ throw new ClaudeCommitError(
217
+ "Interactive mode (-i) requires an interactive terminal.",
218
+ );
219
+ }
220
+ if (interactiveMode === "interactive") {
221
+ const { runInteractive } = await import("./ui/interactive");
222
+ return await runInteractive(diff, config, { verbose, abortController });
223
+ }
224
+
225
+ return await runNonInteractive(
226
+ diff,
227
+ config,
228
+ opts,
229
+ verbose,
230
+ abortController,
231
+ );
232
+ } catch (err) {
233
+ if (err instanceof ClaudeCommitError) {
234
+ process.stderr.write(`${color("31", "error:")} ${err.message}\n`);
235
+ return 1;
236
+ }
237
+ throw err;
238
+ } finally {
239
+ process.off("SIGINT", onSigint);
240
+ }
241
+ }
242
+
243
+ async function runNonInteractive(
244
+ diff: string,
245
+ config: ReturnType<typeof resolveConfig>,
246
+ opts: CliOptions,
247
+ verbose: boolean,
248
+ abortController: AbortController,
249
+ ): Promise<number> {
250
+ const useSpinner = opts.spinner !== false && process.stderr.isTTY;
251
+ const spinner = new Spinner(useSpinner);
252
+
253
+ spinner.start("Reading diff");
254
+ let result;
255
+ try {
256
+ result = await generateCommit(diff, config, {
257
+ progress: { onPhase: (label) => spinner.update(label) },
258
+ abortController,
259
+ });
260
+ } catch (err) {
261
+ spinner.stop(); // always restore the cursor / clear the line on failure
262
+ throw err;
263
+ }
264
+ spinner.stop();
265
+
266
+ if (verbose) {
267
+ process.stderr.write(
268
+ color(
269
+ "90",
270
+ `${result.chunkCount} chunk(s), cost $${result.costUsd.toFixed(4)}`,
271
+ ) + "\n",
272
+ );
273
+ for (const [i, summary] of result.summaries.entries()) {
274
+ process.stderr.write(
275
+ color("90", `--- summary ${i + 1} ---\n${summary}`) + "\n",
276
+ );
277
+ }
278
+ }
279
+
280
+ let message = result.messages[0]!;
281
+
282
+ // Dry run: emit to stdout so it can be piped, and never commit.
283
+ if (opts.dryRun) {
284
+ process.stdout.write(message + "\n");
285
+ return 0;
286
+ }
287
+
288
+ const canPrompt = process.stdin.isTTY && process.stdout.isTTY;
289
+ if (!opts.yes && canPrompt) {
290
+ printMessage(message);
291
+ const choice = await confirmCommit();
292
+ if (choice === "no") {
293
+ process.stderr.write("Aborted. Nothing was committed.\n");
294
+ return 1;
295
+ }
296
+ if (choice === "edit") {
297
+ message = await editInEditor(message);
298
+ if (message.trim() === "") {
299
+ process.stderr.write("Aborted: empty commit message.\n");
300
+ return 1;
301
+ }
302
+ }
303
+ }
304
+
305
+ const stat = verbose ? await getStagedStat().catch(() => "") : "";
306
+ await commit(message);
307
+ spinner.succeed("Committed");
308
+ process.stderr.write(color("90", firstLine(message)) + "\n");
309
+ if (stat) process.stderr.write(color("90", stat) + "\n");
310
+ return 0;
311
+ }
312
+
313
+ function firstLine(text: string): string {
314
+ return text.split("\n", 1)[0] ?? text;
315
+ }
package/src/config.ts ADDED
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Configuration loading and merging.
3
+ *
4
+ * Precedence (low to high): built-in defaults < a global user config in
5
+ * `$XDG_CONFIG_HOME/claude-commit` (default `~/.config/claude-commit`) <
6
+ * `package.json` (`claude-commit` key at the repo root) < the nearest project
7
+ * `.claude-commit.json` / `.claude-commitrc(.json)` file (searched cwd → repo
8
+ * root) < CLI flags.
9
+ */
10
+ import { dirname, isAbsolute, join, resolve } from "node:path";
11
+ import { homedir } from "node:os";
12
+ import { ClaudeCommitError } from "./errors";
13
+ import type { Config, ModelConfig, PartialConfig } from "./types";
14
+
15
+ export const DEFAULT_CONFIG: Config = {
16
+ conventionalCommits: false,
17
+ gitmoji: false,
18
+ multiline: false,
19
+ template: null,
20
+ customPrompt: null,
21
+ interactive: false,
22
+ interactiveCount: 3,
23
+ interactiveTemperature: 1,
24
+ models: {
25
+ summary: "sonnet[1m]",
26
+ final: "haiku",
27
+ },
28
+ maxChunkTokens: 600_000,
29
+ charsPerToken: 3.5,
30
+ allowApiKey: false,
31
+ };
32
+
33
+ const CONFIG_FILENAMES = [
34
+ ".claude-commit.json",
35
+ ".claude-commitrc.json",
36
+ ".claude-commitrc",
37
+ ];
38
+
39
+ /**
40
+ * Filenames accepted inside the global config directory, most-preferred first.
41
+ * `config.json` is the canonical name (the directory already says which tool it
42
+ * is for); the project-style names are also honoured so a config can be copied
43
+ * or symlinked there.
44
+ */
45
+ const GLOBAL_CONFIG_FILENAMES = ["config.json", ...CONFIG_FILENAMES];
46
+
47
+ /**
48
+ * The user-level config directory, `$XDG_CONFIG_HOME/claude-commit` (falling back
49
+ * to `~/.config/claude-commit`). Per the XDG Base Directory spec, `XDG_CONFIG_HOME`
50
+ * is honoured only when it is set to an absolute path.
51
+ */
52
+ export function globalConfigDir(
53
+ env: Record<string, string | undefined> = process.env,
54
+ ): string {
55
+ const xdg = env.XDG_CONFIG_HOME;
56
+ const base = xdg && isAbsolute(xdg) ? xdg : join(homedir(), ".config");
57
+ return join(base, "claude-commit");
58
+ }
59
+
60
+ /** The first existing global config file in {@link globalConfigDir}, if any. */
61
+ async function findGlobalConfigFile(
62
+ env: Record<string, string | undefined> = process.env,
63
+ ): Promise<string | undefined> {
64
+ const dir = globalConfigDir(env);
65
+ for (const name of GLOBAL_CONFIG_FILENAMES) {
66
+ const candidate = join(dir, name);
67
+ if (await Bun.file(candidate).exists()) return candidate;
68
+ }
69
+ return undefined;
70
+ }
71
+
72
+ /** Deep-ish merge of a partial config over a base config (only `models` is nested). */
73
+ export function mergeConfig(base: Config, override: PartialConfig): Config {
74
+ const models: ModelConfig = { ...base.models, ...(override.models ?? {}) };
75
+ const merged: Config = { ...base, ...override, models };
76
+ return merged;
77
+ }
78
+
79
+ /** Validate and normalize a parsed partial config, ignoring unknown keys. */
80
+ export function sanitizePartial(raw: unknown): PartialConfig {
81
+ if (raw === null || typeof raw !== "object") return {};
82
+ const obj = raw as Record<string, unknown>;
83
+ const out: PartialConfig = {};
84
+
85
+ const bool = (k: keyof Config) => {
86
+ if (typeof obj[k] === "boolean")
87
+ (out as Record<string, unknown>)[k] = obj[k];
88
+ };
89
+ bool("conventionalCommits");
90
+ bool("gitmoji");
91
+ bool("multiline");
92
+ bool("interactive");
93
+ bool("allowApiKey");
94
+
95
+ if (typeof obj.template === "string") out.template = obj.template;
96
+ else if (obj.template === null) out.template = null;
97
+ if (typeof obj.customPrompt === "string") out.customPrompt = obj.customPrompt;
98
+ else if (obj.customPrompt === null) out.customPrompt = null;
99
+
100
+ if (
101
+ typeof obj.interactiveCount === "number" &&
102
+ Number.isFinite(obj.interactiveCount)
103
+ ) {
104
+ out.interactiveCount = Math.max(1, Math.floor(obj.interactiveCount));
105
+ }
106
+ if (obj.interactiveTemperature === null) {
107
+ out.interactiveTemperature = null;
108
+ } else if (
109
+ typeof obj.interactiveTemperature === "number" &&
110
+ Number.isFinite(obj.interactiveTemperature)
111
+ ) {
112
+ out.interactiveTemperature = Math.min(
113
+ 2,
114
+ Math.max(0, obj.interactiveTemperature),
115
+ );
116
+ }
117
+ if (typeof obj.maxChunkTokens === "number" && obj.maxChunkTokens > 0) {
118
+ out.maxChunkTokens = Math.floor(obj.maxChunkTokens);
119
+ }
120
+ if (typeof obj.charsPerToken === "number" && obj.charsPerToken > 0) {
121
+ out.charsPerToken = obj.charsPerToken;
122
+ }
123
+
124
+ if (obj.models && typeof obj.models === "object") {
125
+ const m = obj.models as Record<string, unknown>;
126
+ const models: Partial<ModelConfig> = {};
127
+ if (typeof m.summary === "string") models.summary = m.summary;
128
+ if (typeof m.final === "string") models.final = m.final;
129
+ if (Object.keys(models).length) out.models = models;
130
+ }
131
+
132
+ return out;
133
+ }
134
+
135
+ async function readJsonIfExists(path: string): Promise<unknown | undefined> {
136
+ const file = Bun.file(path);
137
+ if (!(await file.exists())) return undefined;
138
+ try {
139
+ return await file.json();
140
+ } catch (err) {
141
+ throw new ClaudeCommitError(
142
+ `Failed to parse config file ${path}: ${(err as Error).message}`,
143
+ );
144
+ }
145
+ }
146
+
147
+ /** Walk from `startDir` up to and including `rootDir`, returning the first config file found. */
148
+ async function findConfigFile(
149
+ startDir: string,
150
+ rootDir: string,
151
+ ): Promise<string | undefined> {
152
+ let dir = resolve(startDir);
153
+ const stop = resolve(rootDir);
154
+ // Always terminates: we stop at `rootDir`, and `dirname` of the filesystem
155
+ // root returns itself (`parent === dir`), so even when `startDir` is not under
156
+ // `rootDir` the walk halts at the root regardless of directory depth.
157
+ for (;;) {
158
+ for (const name of CONFIG_FILENAMES) {
159
+ const candidate = join(dir, name);
160
+ if (await Bun.file(candidate).exists()) return candidate;
161
+ }
162
+ if (dir === stop) break;
163
+ const parent = dirname(dir);
164
+ if (parent === dir) break;
165
+ dir = parent;
166
+ }
167
+ return undefined;
168
+ }
169
+
170
+ /**
171
+ * Load and merge the file-based configuration layers that sit below CLI flags,
172
+ * lowest first: the global user config, then `package.json`'s `claude-commit` key
173
+ * at the repo root, then the nearest project config file (searched from `cwd` up
174
+ * to `repoRoot`). An explicit `configPath` short-circuits the project-file
175
+ * discovery; the global and `package.json` layers still apply beneath it. `env`
176
+ * supplies `XDG_CONFIG_HOME` for locating the global config (defaults to
177
+ * `process.env`).
178
+ */
179
+ export async function loadFileConfig(
180
+ cwd: string,
181
+ repoRoot: string,
182
+ configPath?: string,
183
+ env: Record<string, string | undefined> = process.env,
184
+ ): Promise<PartialConfig> {
185
+ let result: PartialConfig = {};
186
+
187
+ // Global user config (lowest precedence): $XDG_CONFIG_HOME/claude-commit. Like
188
+ // a project config file, a malformed one throws (it is a file the user wrote
189
+ // deliberately), which readJsonIfExists handles.
190
+ const globalPath = await findGlobalConfigFile(env);
191
+ if (globalPath) {
192
+ result = mergePartial(
193
+ result,
194
+ sanitizePartial(await readJsonIfExists(globalPath)),
195
+ );
196
+ }
197
+
198
+ // package.json#claude-commit at the repo root (above the global config, below
199
+ // project config files). A malformed package.json is not cco's concern to
200
+ // enforce — skip it rather than blocking the commit (the user may even be
201
+ // committing its fix).
202
+ let pkg: unknown;
203
+ try {
204
+ pkg = await readJsonIfExists(join(repoRoot, "package.json"));
205
+ } catch {
206
+ pkg = undefined;
207
+ }
208
+ if (pkg && typeof pkg === "object" && "claude-commit" in (pkg as object)) {
209
+ result = mergePartial(
210
+ result,
211
+ sanitizePartial((pkg as Record<string, unknown>)["claude-commit"]),
212
+ );
213
+ }
214
+
215
+ const filePath = configPath
216
+ ? resolve(cwd, configPath)
217
+ : await findConfigFile(cwd, repoRoot);
218
+ if (filePath) {
219
+ const raw = await readJsonIfExists(filePath);
220
+ if (raw === undefined && configPath) {
221
+ throw new ClaudeCommitError(`Config file not found: ${filePath}`);
222
+ }
223
+ result = mergePartial(result, sanitizePartial(raw));
224
+ }
225
+
226
+ return result;
227
+ }
228
+
229
+ /** Merge two partial configs (only `models` is nested). */
230
+ export function mergePartial(
231
+ base: PartialConfig,
232
+ override: PartialConfig,
233
+ ): PartialConfig {
234
+ const out: PartialConfig = { ...base, ...override };
235
+ if (base.models || override.models) {
236
+ out.models = { ...base.models, ...override.models };
237
+ }
238
+ return out;
239
+ }
240
+
241
+ /** Produce a fully-resolved config from file config and CLI-flag overrides. */
242
+ export function resolveConfig(
243
+ fileConfig: PartialConfig,
244
+ flagConfig: PartialConfig,
245
+ ): Config {
246
+ return mergeConfig(DEFAULT_CONFIG, mergePartial(fileConfig, flagConfig));
247
+ }