@synmux/claude-commit 1.0.3 → 1.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/config.ts DELETED
@@ -1,337 +0,0 @@
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 { DEFAULT_SPINNER, isSpinnerName } from "./ui/spinner";
14
- import { DEFAULT_OLLAMA_CONTEXT, DEFAULT_OLLAMA_HOST } from "./models";
15
- import type { Config, ModelConfig, OllamaConfig, PartialConfig } from "./types";
16
-
17
- export const DEFAULT_CONFIG: Config = {
18
- conventionalCommits: false,
19
- gitmoji: false,
20
- multiline: false,
21
- template: null,
22
- customPrompt: null,
23
- interactive: false,
24
- interactiveCount: 3,
25
- interactiveTemperature: 1,
26
- spinner: DEFAULT_SPINNER,
27
- models: {
28
- summary: "sonnet",
29
- final: "sonnet",
30
- },
31
- maxChunkTokens: 600_000,
32
- charsPerToken: 3.5,
33
- skipArmored: false,
34
- lowPriorityPaths: [],
35
- ignore: [],
36
- ollama: {
37
- host: DEFAULT_OLLAMA_HOST,
38
- context: DEFAULT_OLLAMA_CONTEXT,
39
- keepAlive: null,
40
- },
41
- allowApiKey: false,
42
- };
43
-
44
- const CONFIG_FILENAMES = [
45
- ".claude-commit.json",
46
- ".claude-commitrc.json",
47
- ".claude-commitrc",
48
- ];
49
-
50
- /**
51
- * Filenames accepted inside the global config directory, most-preferred first.
52
- * `config.json` is the canonical name (the directory already says which tool it
53
- * is for); the project-style names are also honoured so a config can be copied
54
- * or symlinked there.
55
- */
56
- const GLOBAL_CONFIG_FILENAMES = ["config.json", ...CONFIG_FILENAMES];
57
-
58
- /**
59
- * The user-level config directory, `$XDG_CONFIG_HOME/claude-commit` (falling back
60
- * to `~/.config/claude-commit`). Per the XDG Base Directory spec, `XDG_CONFIG_HOME`
61
- * is honoured only when it is set to an absolute path.
62
- */
63
- export function globalConfigDir(
64
- env: Record<string, string | undefined> = process.env,
65
- ): string {
66
- const xdg = env.XDG_CONFIG_HOME;
67
- const base = xdg && isAbsolute(xdg) ? xdg : join(homedir(), ".config");
68
- return join(base, "claude-commit");
69
- }
70
-
71
- /** The first existing global config file in {@link globalConfigDir}, if any. */
72
- async function findGlobalConfigFile(
73
- env: Record<string, string | undefined> = process.env,
74
- ): Promise<string | undefined> {
75
- const dir = globalConfigDir(env);
76
- for (const name of GLOBAL_CONFIG_FILENAMES) {
77
- const candidate = join(dir, name);
78
- if (await Bun.file(candidate).exists()) return candidate;
79
- }
80
- return undefined;
81
- }
82
-
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
- */
90
- export function mergeConfig(base: Config, override: PartialConfig): Config {
91
- const models: ModelConfig = { ...base.models, ...(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
- };
105
- return merged;
106
- }
107
-
108
- /** Validate and normalize a parsed partial config, ignoring unknown keys. */
109
- export function sanitizePartial(raw: unknown): PartialConfig {
110
- if (raw === null || typeof raw !== "object") return {};
111
- const obj = raw as Record<string, unknown>;
112
- const out: PartialConfig = {};
113
-
114
- const bool = (k: keyof Config) => {
115
- if (typeof obj[k] === "boolean")
116
- (out as Record<string, unknown>)[k] = obj[k];
117
- };
118
- bool("conventionalCommits");
119
- bool("gitmoji");
120
- bool("multiline");
121
- bool("interactive");
122
- bool("skipArmored");
123
- bool("allowApiKey");
124
-
125
- if (typeof obj.template === "string") out.template = obj.template;
126
- else if (obj.template === null) out.template = null;
127
- if (typeof obj.customPrompt === "string") out.customPrompt = obj.customPrompt;
128
- else if (obj.customPrompt === null) out.customPrompt = null;
129
-
130
- if (
131
- typeof obj.interactiveCount === "number" &&
132
- Number.isFinite(obj.interactiveCount)
133
- ) {
134
- out.interactiveCount = Math.max(1, Math.floor(obj.interactiveCount));
135
- }
136
- if (obj.interactiveTemperature === null) {
137
- out.interactiveTemperature = null;
138
- } else if (
139
- typeof obj.interactiveTemperature === "number" &&
140
- Number.isFinite(obj.interactiveTemperature)
141
- ) {
142
- out.interactiveTemperature = Math.min(
143
- 2,
144
- Math.max(0, obj.interactiveTemperature),
145
- );
146
- }
147
- if (typeof obj.spinner === "string" && isSpinnerName(obj.spinner)) {
148
- out.spinner = obj.spinner;
149
- }
150
- if (typeof obj.maxChunkTokens === "number" && obj.maxChunkTokens > 0) {
151
- out.maxChunkTokens = Math.floor(obj.maxChunkTokens);
152
- }
153
- if (typeof obj.charsPerToken === "number" && obj.charsPerToken > 0) {
154
- out.charsPerToken = obj.charsPerToken;
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
- }
165
-
166
- if (obj.models && typeof obj.models === "object") {
167
- const m = obj.models as Record<string, unknown>;
168
- const models: Partial<ModelConfig> = {};
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
- }
177
- if (Object.keys(models).length) out.models = models;
178
- }
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
-
199
- return out;
200
- }
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
-
214
- async function readJsonIfExists(path: string): Promise<unknown | undefined> {
215
- const file = Bun.file(path);
216
- if (!(await file.exists())) return undefined;
217
- try {
218
- return await file.json();
219
- } catch (err) {
220
- throw new ClaudeCommitError(
221
- `Failed to parse config file ${path}: ${(err as Error).message}`,
222
- );
223
- }
224
- }
225
-
226
- /** Walk from `startDir` up to and including `rootDir`, returning the first config file found. */
227
- async function findConfigFile(
228
- startDir: string,
229
- rootDir: string,
230
- ): Promise<string | undefined> {
231
- let dir = resolve(startDir);
232
- const stop = resolve(rootDir);
233
- // Always terminates: we stop at `rootDir`, and `dirname` of the filesystem
234
- // root returns itself (`parent === dir`), so even when `startDir` is not under
235
- // `rootDir` the walk halts at the root regardless of directory depth.
236
- for (;;) {
237
- for (const name of CONFIG_FILENAMES) {
238
- const candidate = join(dir, name);
239
- if (await Bun.file(candidate).exists()) return candidate;
240
- }
241
- if (dir === stop) break;
242
- const parent = dirname(dir);
243
- if (parent === dir) break;
244
- dir = parent;
245
- }
246
- return undefined;
247
- }
248
-
249
- /**
250
- * Load and merge the file-based configuration layers that sit below CLI flags,
251
- * lowest first: the global user config, then `package.json`'s `claude-commit` key
252
- * at the repo root, then the nearest project config file (searched from `cwd` up
253
- * to `repoRoot`). An explicit `configPath` short-circuits the project-file
254
- * discovery; the global and `package.json` layers still apply beneath it. `env`
255
- * supplies `XDG_CONFIG_HOME` for locating the global config (defaults to
256
- * `process.env`).
257
- */
258
- export async function loadFileConfig(
259
- cwd: string,
260
- repoRoot: string,
261
- configPath?: string,
262
- env: Record<string, string | undefined> = process.env,
263
- ): Promise<PartialConfig> {
264
- let result: PartialConfig = {};
265
-
266
- // Global user config (lowest precedence): $XDG_CONFIG_HOME/claude-commit. Like
267
- // a project config file, a malformed one throws (it is a file the user wrote
268
- // deliberately), which readJsonIfExists handles.
269
- const globalPath = await findGlobalConfigFile(env);
270
- if (globalPath) {
271
- result = mergePartial(
272
- result,
273
- sanitizePartial(await readJsonIfExists(globalPath)),
274
- );
275
- }
276
-
277
- // package.json#claude-commit at the repo root (above the global config, below
278
- // project config files). A malformed package.json is not cco's concern to
279
- // enforce - skip it rather than blocking the commit (the user may even be
280
- // committing its fix).
281
- let pkg: unknown;
282
- try {
283
- pkg = await readJsonIfExists(join(repoRoot, "package.json"));
284
- } catch {
285
- pkg = undefined;
286
- }
287
- if (pkg && typeof pkg === "object" && "claude-commit" in (pkg as object)) {
288
- result = mergePartial(
289
- result,
290
- sanitizePartial((pkg as Record<string, unknown>)["claude-commit"]),
291
- );
292
- }
293
-
294
- const filePath = configPath
295
- ? resolve(cwd, configPath)
296
- : await findConfigFile(cwd, repoRoot);
297
- if (filePath) {
298
- const raw = await readJsonIfExists(filePath);
299
- if (raw === undefined && configPath) {
300
- throw new ClaudeCommitError(`Config file not found: ${filePath}`);
301
- }
302
- result = mergePartial(result, sanitizePartial(raw));
303
- }
304
-
305
- return result;
306
- }
307
-
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
- */
313
- export function mergePartial(
314
- base: PartialConfig,
315
- override: PartialConfig,
316
- ): PartialConfig {
317
- const out: PartialConfig = { ...base, ...override };
318
- if (base.models || override.models) {
319
- out.models = { ...base.models, ...override.models };
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];
328
- return out;
329
- }
330
-
331
- /** Produce a fully-resolved config from file config and CLI-flag overrides. */
332
- export function resolveConfig(
333
- fileConfig: PartialConfig,
334
- flagConfig: PartialConfig,
335
- ): Config {
336
- return mergeConfig(DEFAULT_CONFIG, mergePartial(fileConfig, flagConfig));
337
- }