@hifullmoon/aicommit 1.4.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.
Files changed (57) hide show
  1. package/.aicommit.config.example.json +118 -0
  2. package/CHANGELOG.md +131 -0
  3. package/LICENSE +21 -0
  4. package/README.md +406 -0
  5. package/README.zh-CN.md +408 -0
  6. package/SECURITY.md +33 -0
  7. package/bin/aicommit.js +47 -0
  8. package/docs/distribution.md +104 -0
  9. package/docs/examples/aicommit-policy.yml +24 -0
  10. package/docs/examples/commit-msg +4 -0
  11. package/docs/examples/extension/aicommit-extension.json +9 -0
  12. package/docs/examples/extension/index.mjs +32 -0
  13. package/docs/extensions.md +93 -0
  14. package/docs/privacy.md +58 -0
  15. package/docs/provider-compatibility.md +37 -0
  16. package/docs/provider-presets.md +100 -0
  17. package/docs/team-policy.md +86 -0
  18. package/docs/troubleshooting.md +37 -0
  19. package/package.json +100 -0
  20. package/presets/provider-presets.json +60 -0
  21. package/schemas/aicommit-extension.schema.json +27 -0
  22. package/schemas/aicommit-output.schema.json +86 -0
  23. package/schemas/aicommit-provider-presets.schema.json +56 -0
  24. package/schemas/aicommit-split-checkpoint.schema.json +92 -0
  25. package/schemas/aicommit-split-plan.schema.json +108 -0
  26. package/schemas/aicommit-team-policy.schema.json +59 -0
  27. package/src/api.js +740 -0
  28. package/src/cli.js +558 -0
  29. package/src/completion.js +136 -0
  30. package/src/config-command.js +59 -0
  31. package/src/config.js +524 -0
  32. package/src/context.js +537 -0
  33. package/src/credentials.js +123 -0
  34. package/src/doctor.js +131 -0
  35. package/src/errors.js +96 -0
  36. package/src/extension-runner.mjs +36 -0
  37. package/src/extensions.js +426 -0
  38. package/src/generation-ui.js +53 -0
  39. package/src/git.js +458 -0
  40. package/src/main.js +768 -0
  41. package/src/metrics.js +375 -0
  42. package/src/output.js +87 -0
  43. package/src/policy-command.js +172 -0
  44. package/src/policy.js +421 -0
  45. package/src/preset-command.js +91 -0
  46. package/src/provider-presets.js +361 -0
  47. package/src/providers.js +306 -0
  48. package/src/setup.js +268 -0
  49. package/src/split-checkpoint.js +252 -0
  50. package/src/split-hunks.js +263 -0
  51. package/src/split-plan.js +339 -0
  52. package/src/split.js +2285 -0
  53. package/src/team-policy.js +95 -0
  54. package/src/trust.js +31 -0
  55. package/src/ui.js +488 -0
  56. package/src/utils.js +165 -0
  57. package/templates/.aicommit.policy.json +22 -0
@@ -0,0 +1,59 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+
4
+ import { getProjectRoot, loadConfig } from './config.js';
5
+ import { fileExists, stringifyConfigRedacted } from './utils.js';
6
+ import { TEAM_POLICY_FILENAME } from './team-policy.js';
7
+
8
+ function redactedObject(value) {
9
+ return JSON.parse(stringifyConfigRedacted(value));
10
+ }
11
+
12
+ export async function inspectConfigPaths(projectRoot = getProjectRoot()) {
13
+ const user = join(homedir(), '.aicommit.config.json');
14
+ const project = join(projectRoot, '.aicommit.config.json');
15
+ const teamPolicy = join(projectRoot, TEAM_POLICY_FILENAME);
16
+ return {
17
+ user: { path: user, exists: await fileExists(user) },
18
+ project: { path: project, exists: project !== user && (await fileExists(project)) },
19
+ teamPolicy: { path: teamPolicy, exists: await fileExists(teamPolicy) },
20
+ };
21
+ }
22
+
23
+ function printPaths(paths) {
24
+ console.log(`User config: ${paths.user.path}${paths.user.exists ? '' : ' (not found)'}`);
25
+ console.log(`Project config: ${paths.project.path}${paths.project.exists ? '' : ' (not found)'}`);
26
+ console.log(
27
+ `Team policy: ${paths.teamPolicy.path}${paths.teamPolicy.exists ? '' : ' (not found)'}`,
28
+ );
29
+ }
30
+
31
+ export async function runConfigCommand(action, { provider = null, machineOutput = false } = {}) {
32
+ const projectRoot = getProjectRoot();
33
+ const paths = await inspectConfigPaths(projectRoot);
34
+ if (action === 'path') {
35
+ if (!machineOutput) printPaths(paths);
36
+ return { exitReason: 'config_path', data: { projectRoot, paths } };
37
+ }
38
+
39
+ const loaded = await loadConfig(provider, { resolveCredentials: false });
40
+ const data = {
41
+ projectRoot: loaded.projectRoot,
42
+ sources: loaded.loaded,
43
+ provider: loaded.providerName,
44
+ paths,
45
+ };
46
+ if (action === 'validate') {
47
+ if (!machineOutput) {
48
+ console.log(
49
+ `Configuration valid${loaded.providerName ? ` for provider "${loaded.providerName}"` : ''}.`,
50
+ );
51
+ printPaths(paths);
52
+ }
53
+ return { exitReason: 'config_valid', data };
54
+ }
55
+
56
+ data.config = redactedObject(loaded.config);
57
+ if (!machineOutput) console.log(JSON.stringify(data, null, 2));
58
+ return { exitReason: 'config_show', data };
59
+ }
package/src/config.js ADDED
@@ -0,0 +1,524 @@
1
+ import { readFile, chmod } from 'node:fs/promises';
2
+ import { execSync } from 'node:child_process';
3
+ import { homedir } from 'node:os';
4
+ import { isAbsolute, join } from 'node:path';
5
+
6
+ import chalk from 'chalk';
7
+
8
+ import { fileExists, deepMerge } from './utils.js';
9
+ import { ERROR_CATEGORIES, fail } from './errors.js';
10
+ import { resolveCredential } from './credentials.js';
11
+ import { DEFAULT_METRICS } from './metrics.js';
12
+ import { DEFAULT_COMMIT_POLICY, mergeCommitPolicy, validateCommitPolicyConfig } from './policy.js';
13
+ import {
14
+ DEFAULT_REPOSITORY_CONTEXT,
15
+ filterProjectRepositoryContext,
16
+ mergeRepositoryContext,
17
+ validateRepositoryContextConfig,
18
+ } from './context.js';
19
+ import { readTeamPolicy } from './team-policy.js';
20
+ import { isProviderType, PROVIDER_TYPES } from './providers.js';
21
+ import { isExtensionProviderType, validateExtensionsConfig } from './extensions.js';
22
+
23
+ // Repository-owned config is untrusted input: a cloned repository must never
24
+ // be able to redirect requests while inheriting the API key from the user's
25
+ // global config. Project config may tune generation/display behaviour, but all
26
+ // connection and provider selection fields remain user-owned.
27
+ export const PROJECT_CONNECTION_KEYS = new Set([
28
+ 'apiUrl',
29
+ 'apiKey',
30
+ 'apiKeyEnv',
31
+ 'modelId',
32
+ 'providerType',
33
+ 'providers',
34
+ 'defaultProvider',
35
+ 'extraBody',
36
+ 'retry',
37
+ 'credentialHelper',
38
+ 'metrics',
39
+ 'allowProjectPrompt',
40
+ 'extensions',
41
+ ]);
42
+
43
+ const PROJECT_SAFE_KEYS = new Set([
44
+ 'language',
45
+ 'commitPolicy',
46
+ 'repositoryContext',
47
+ 'prompt',
48
+ 'stripFiles',
49
+ 'temperature',
50
+ 'maxTokens',
51
+ 'timeoutMs',
52
+ 'maxDiffChars',
53
+ 'maxFileDiffChars',
54
+ 'splitMaxDiffChars',
55
+ 'splitMaxPlanFiles',
56
+ 'diffContextLines',
57
+ ]);
58
+ const PROJECT_CEILING_KEYS = new Set([
59
+ 'maxTokens',
60
+ 'timeoutMs',
61
+ 'maxDiffChars',
62
+ 'maxFileDiffChars',
63
+ 'splitMaxDiffChars',
64
+ 'splitMaxPlanFiles',
65
+ 'diffContextLines',
66
+ ]);
67
+ const MAX_PROJECT_PROMPT_CHARS = 20_000;
68
+
69
+ export function filterProjectConfig(projectConfig, baseConfig = DEFAULT_CONFIG) {
70
+ if (!projectConfig || typeof projectConfig !== 'object' || Array.isArray(projectConfig)) {
71
+ throw new Error('expected a JSON object');
72
+ }
73
+
74
+ const safe = {};
75
+ const ignored = [];
76
+ for (const [key, value] of Object.entries(projectConfig)) {
77
+ if (PROJECT_CONNECTION_KEYS.has(key) || !PROJECT_SAFE_KEYS.has(key)) {
78
+ ignored.push(key);
79
+ continue;
80
+ }
81
+ if (
82
+ key === 'prompt' &&
83
+ (!baseConfig.allowProjectPrompt ||
84
+ typeof value !== 'string' ||
85
+ value.length > MAX_PROJECT_PROMPT_CHARS)
86
+ ) {
87
+ ignored.push(key);
88
+ continue;
89
+ }
90
+ if (key === 'repositoryContext') {
91
+ const filtered = filterProjectRepositoryContext(value, baseConfig.repositoryContext);
92
+ if (filtered.safe && Object.keys(filtered.safe).length) safe[key] = filtered.safe;
93
+ ignored.push(...filtered.ignored);
94
+ continue;
95
+ }
96
+ if (
97
+ PROJECT_CEILING_KEYS.has(key) &&
98
+ typeof value === 'number' &&
99
+ typeof baseConfig[key] === 'number' &&
100
+ value > baseConfig[key]
101
+ ) {
102
+ ignored.push(key);
103
+ continue;
104
+ }
105
+ safe[key] = value;
106
+ }
107
+ return { safe, ignored };
108
+ }
109
+
110
+ export const DEFAULT_CONFIG = {
111
+ apiUrl: 'https://api.openai.com/v1/chat/completions',
112
+ apiKey: '',
113
+ apiKeyEnv: '',
114
+ modelId: 'gpt-4o',
115
+ // Optional explicit adapter selection. An empty value uses endpoint-based
116
+ // detection; provider presets can set this for custom domains.
117
+ providerType: '',
118
+ temperature: 0.3,
119
+ language: 'zh', // 'zh' = Chinese, 'en' = English
120
+ // Versioned, structured rules replace the old hard-coded prompt contract.
121
+ // A user prompt may add guidance, but cannot replace these constraints.
122
+ commitPolicy: DEFAULT_COMMIT_POLICY,
123
+ repositoryContext: DEFAULT_REPOSITORY_CONTEXT,
124
+ // Repository-owned prompt text is executable model guidance, so it is
125
+ // ignored unless the user explicitly opts in from their home config.
126
+ allowProjectPrompt: false,
127
+ maxTokens: 1024,
128
+ // Per-request timeout in milliseconds — a hung endpoint aborts with a clear
129
+ // error instead of leaving the spinner running forever.
130
+ timeoutMs: 120000,
131
+ // Retry only transient transport failures, rate limits, and recoverable
132
+ // server errors. Authentication, parameter, and safety errors fail once.
133
+ retry: {
134
+ maxAttempts: 3,
135
+ baseDelayMs: 500,
136
+ maxDelayMs: 5000,
137
+ },
138
+ // Opt in to the user's configured Git credential helper, which commonly
139
+ // delegates to macOS Keychain, Windows Credential Manager, or libsecret.
140
+ credentialHelper: {
141
+ enabled: false,
142
+ username: 'aicommit',
143
+ },
144
+ // Minimal local-only run metrics. There is deliberately no upload target.
145
+ metrics: { ...DEFAULT_METRICS },
146
+ // Executable extensions are selected only from the user-owned config.
147
+ // Their v1 manifests must explicitly deny credential access and run in a
148
+ // permissioned child process instead of sharing this process.
149
+ extensions: {
150
+ manifests: [],
151
+ timeoutMs: 3000,
152
+ maxContextChars: 2000,
153
+ },
154
+ // Cap on diff characters sent to the model per call. Oversized diffs are
155
+ // condensed to a `git diff --stat` summary plus truncated hunks, so a huge
156
+ // change set doesn't burn tokens on lines the model doesn't need.
157
+ maxDiffChars: 30000,
158
+ // Cap on a single file's diff section. One huge file (e.g. a new generated
159
+ // asset) is truncated to its header and leading hunks instead of eating
160
+ // the whole maxDiffChars budget and pushing every other file out.
161
+ maxFileDiffChars: 3000,
162
+ // Split-mode planning has its own tighter prompt budget: it needs enough
163
+ // context to group files, not the full detail needed to write the final
164
+ // message for one commit.
165
+ splitMaxDiffChars: 16000,
166
+ splitMaxPlanFiles: 100,
167
+ // Context lines around each diff hunk (git diff --unified=<n>). Fewer lines
168
+ // means fewer tokens; 1 is enough for a commit message — git's default of
169
+ // 3 mostly pays for context the model doesn't need.
170
+ diffContextLines: 1,
171
+ // Extra files to stub out of the diff like lock files, matched by basename
172
+ // with "*" wildcards (e.g. ["*.min.js", "*.map", "*.snap"]). Generated
173
+ // artifacts carry no commit intent but can be enormous.
174
+ stripFiles: [],
175
+ // false (default): regenerate rewords the previous message without
176
+ // re-sending the diff — far cheaper. true: re-send the full diff on every
177
+ // regenerate, for more varied rewrites at a much higher token cost.
178
+ regenerateWithDiff: false,
179
+ // Provider-specific request fields are opt-in. Keeping this empty by
180
+ // default preserves compatibility with strict OpenAI-compatible servers.
181
+ extraBody: {},
182
+ // Cross-provider reasoning controls. Reasoning defaults to "on" and is
183
+ // streamed automatically; "auto" preserves the provider/model default,
184
+ // while --no-reasoning switches it to "off". There is no separate display
185
+ // switch.
186
+ reasoning: {
187
+ mode: 'on',
188
+ effort: 'medium',
189
+ maxTokens: 4096,
190
+ maxDisplayChars: 12000,
191
+ },
192
+ // Optional user-approved guidance appended to the structured policy.
193
+ prompt: '',
194
+ };
195
+
196
+ function mergeConfig(base, override) {
197
+ const merged = deepMerge(base, override);
198
+ if (Object.hasOwn(override, 'commitPolicy')) {
199
+ merged.commitPolicy = mergeCommitPolicy(base.commitPolicy, override.commitPolicy);
200
+ }
201
+ if (Object.hasOwn(override, 'repositoryContext')) {
202
+ merged.repositoryContext = mergeRepositoryContext(
203
+ base.repositoryContext,
204
+ override.repositoryContext,
205
+ );
206
+ }
207
+ return merged;
208
+ }
209
+
210
+ // Resolve the final flat config from a merged config that may contain a
211
+ // "providers" map: pick `cliProvider` → config.defaultProvider → first
212
+ // provider key, deep-merge that entry over the top-level values, then drop
213
+ // the providers/defaultProvider keys so downstream code sees a plain flat
214
+ // config.
215
+ function resolveProvider(config, cliProvider) {
216
+ const providers = config.providers;
217
+
218
+ if (cliProvider && !providers) {
219
+ throw fail(
220
+ ERROR_CATEGORIES.CONFIG,
221
+ `-p/--provider given ("${cliProvider}") but no "providers" defined in config. ` +
222
+ 'Define a "providers" map in ~/.aicommit.config.json.',
223
+ );
224
+ }
225
+
226
+ if (!providers || typeof providers !== 'object' || Object.keys(providers).length === 0) {
227
+ return { config, providerName: null };
228
+ }
229
+
230
+ const name = cliProvider || config.defaultProvider || Object.keys(providers)[0];
231
+
232
+ if (!providers[name]) {
233
+ throw fail(
234
+ ERROR_CATEGORIES.CONFIG,
235
+ `Unknown provider: "${name}". Available providers: ${Object.keys(providers).join(', ')}`,
236
+ );
237
+ }
238
+
239
+ const resolved = mergeConfig(config, providers[name]);
240
+ delete resolved.providers;
241
+ delete resolved.defaultProvider;
242
+ return { config: resolved, providerName: name };
243
+ }
244
+
245
+ function assertString(config, key) {
246
+ if (typeof config[key] !== 'string' || !config[key].trim()) {
247
+ throw new Error(`Invalid config "${key}": expected a non-empty string.`);
248
+ }
249
+ }
250
+
251
+ export function isSecureApiUrl(value) {
252
+ try {
253
+ const u = new URL(value);
254
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
255
+ const loopback =
256
+ u.hostname === 'localhost' ||
257
+ u.hostname === '127.0.0.1' ||
258
+ u.hostname.startsWith('127.') ||
259
+ u.hostname === '[::1]';
260
+ return u.protocol === 'https:' || loopback;
261
+ } catch {
262
+ return false;
263
+ }
264
+ }
265
+
266
+ function assertUrl(config, key) {
267
+ assertString(config, key);
268
+ if (!isSecureApiUrl(config[key])) {
269
+ throw new Error(
270
+ `Invalid config "${key}": expected an HTTPS URL, or HTTP only for localhost/loopback.`,
271
+ );
272
+ }
273
+ }
274
+
275
+ function assertNumber(config, key, { integer = false, min = -Infinity, max = Infinity } = {}) {
276
+ const value = config[key];
277
+ const ok =
278
+ typeof value === 'number' &&
279
+ Number.isFinite(value) &&
280
+ (!integer || Number.isInteger(value)) &&
281
+ value >= min &&
282
+ value <= max;
283
+ if (!ok) {
284
+ const kind = integer ? 'integer' : 'number';
285
+ const range = Number.isFinite(max) ? ` between ${min} and ${max}` : ` >= ${min}`;
286
+ throw new Error(`Invalid config "${key}": expected a ${kind}${range}.`);
287
+ }
288
+ }
289
+
290
+ export function validateConfig(config) {
291
+ assertUrl(config, 'apiUrl');
292
+ assertString(config, 'modelId');
293
+ if (typeof config.prompt !== 'string' || config.prompt.length > 100_000) {
294
+ throw new Error('Invalid config "prompt": expected a string of at most 100000 characters.');
295
+ }
296
+ if (typeof config.allowProjectPrompt !== 'boolean') {
297
+ throw new Error('Invalid config "allowProjectPrompt": expected a boolean.');
298
+ }
299
+ validateCommitPolicyConfig(config.commitPolicy);
300
+ validateRepositoryContextConfig(config.repositoryContext);
301
+
302
+ if (typeof config.apiKey !== 'string') {
303
+ throw new Error(
304
+ 'Invalid config "apiKey": expected a string. Use "" for keyless local endpoints.',
305
+ );
306
+ }
307
+ if (
308
+ typeof config.providerType !== 'string' ||
309
+ (config.providerType !== '' &&
310
+ !isProviderType(config.providerType) &&
311
+ !isExtensionProviderType(config.providerType))
312
+ ) {
313
+ throw new Error(
314
+ `Invalid config "providerType": expected ${PROVIDER_TYPES.join(', ')}, extension:<id>, or "".`,
315
+ );
316
+ }
317
+ validateExtensionsConfig(config.extensions);
318
+ if (!config.metrics || typeof config.metrics !== 'object' || Array.isArray(config.metrics)) {
319
+ throw new Error('Invalid config "metrics": expected an object.');
320
+ }
321
+ if (typeof config.metrics.enabled !== 'boolean') {
322
+ throw new Error('Invalid config "metrics.enabled": expected a boolean.');
323
+ }
324
+ if (
325
+ typeof config.metrics.path !== 'string' ||
326
+ (config.metrics.path && !isAbsolute(config.metrics.path))
327
+ ) {
328
+ throw new Error('Invalid config "metrics.path": expected an absolute path or "".');
329
+ }
330
+ if (
331
+ typeof config.apiKeyEnv !== 'string' ||
332
+ (config.apiKeyEnv && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(config.apiKeyEnv))
333
+ ) {
334
+ throw new Error('Invalid config "apiKeyEnv": expected an environment variable name or "".');
335
+ }
336
+ if (config.language !== 'zh' && config.language !== 'en') {
337
+ throw new Error(`Invalid config "language": expected "zh" or "en", got "${config.language}".`);
338
+ }
339
+ if (typeof config.regenerateWithDiff !== 'boolean') {
340
+ throw new Error('Invalid config "regenerateWithDiff": expected a boolean.');
341
+ }
342
+ if (!Array.isArray(config.stripFiles) || config.stripFiles.some((p) => typeof p !== 'string')) {
343
+ throw new Error('Invalid config "stripFiles": expected an array of strings.');
344
+ }
345
+ if (
346
+ !config.extraBody ||
347
+ typeof config.extraBody !== 'object' ||
348
+ Array.isArray(config.extraBody)
349
+ ) {
350
+ throw new Error('Invalid config "extraBody": expected an object.');
351
+ }
352
+ if ('model' in config.extraBody || 'messages' in config.extraBody) {
353
+ throw new Error('Invalid config "extraBody": "model" and "messages" are managed by aicommit.');
354
+ }
355
+ if (
356
+ !config.reasoning ||
357
+ typeof config.reasoning !== 'object' ||
358
+ Array.isArray(config.reasoning)
359
+ ) {
360
+ throw new Error('Invalid config "reasoning": expected an object.');
361
+ }
362
+ if (!config.retry || typeof config.retry !== 'object' || Array.isArray(config.retry)) {
363
+ throw new Error('Invalid config "retry": expected an object.');
364
+ }
365
+ if (
366
+ !config.credentialHelper ||
367
+ typeof config.credentialHelper !== 'object' ||
368
+ Array.isArray(config.credentialHelper)
369
+ ) {
370
+ throw new Error('Invalid config "credentialHelper": expected an object.');
371
+ }
372
+ if (typeof config.credentialHelper.enabled !== 'boolean') {
373
+ throw new Error('Invalid config "credentialHelper.enabled": expected a boolean.');
374
+ }
375
+ if (
376
+ typeof config.credentialHelper.username !== 'string' ||
377
+ !config.credentialHelper.username.trim() ||
378
+ /[\r\n\0]/.test(config.credentialHelper.username)
379
+ ) {
380
+ throw new Error(
381
+ 'Invalid config "credentialHelper.username": expected a non-empty string without control characters.',
382
+ );
383
+ }
384
+ if (!['auto', 'on', 'off'].includes(config.reasoning.mode)) {
385
+ throw new Error('Invalid config "reasoning.mode": expected "auto", "on", or "off".');
386
+ }
387
+ if (!['low', 'medium', 'high', 'xhigh', 'max'].includes(config.reasoning.effort)) {
388
+ throw new Error(
389
+ 'Invalid config "reasoning.effort": expected low, medium, high, xhigh, or max.',
390
+ );
391
+ }
392
+ for (const key of ['enabledBody', 'disabledBody']) {
393
+ const value = config.reasoning[key];
394
+ if (value !== undefined && (!value || typeof value !== 'object' || Array.isArray(value))) {
395
+ throw new Error(`Invalid config "reasoning.${key}": expected an object.`);
396
+ }
397
+ }
398
+
399
+ assertNumber(config, 'temperature', { min: 0, max: 2 });
400
+ assertNumber(config, 'maxTokens', { integer: true, min: 1 });
401
+ assertNumber(config, 'timeoutMs', { integer: true, min: 1 });
402
+ assertNumber(config.retry, 'maxAttempts', { integer: true, min: 1, max: 10 });
403
+ assertNumber(config.retry, 'baseDelayMs', { integer: true, min: 0, max: 60000 });
404
+ assertNumber(config.retry, 'maxDelayMs', { integer: true, min: 0, max: 300000 });
405
+ assertNumber(config.metrics, 'maxEntries', { integer: true, min: 1, max: 10000 });
406
+ if (config.retry.maxDelayMs < config.retry.baseDelayMs) {
407
+ throw new Error('Invalid config "retry.maxDelayMs": must be >= retry.baseDelayMs.');
408
+ }
409
+ assertNumber(config, 'maxDiffChars', { integer: true, min: 1 });
410
+ assertNumber(config, 'maxFileDiffChars', { integer: true, min: 1 });
411
+ assertNumber(config, 'splitMaxDiffChars', { integer: true, min: 1 });
412
+ assertNumber(config, 'splitMaxPlanFiles', { integer: true, min: 1 });
413
+ assertNumber(config, 'diffContextLines', { integer: true, min: 0 });
414
+ assertNumber(config.reasoning, 'maxTokens', { integer: true, min: 1 });
415
+ assertNumber(config.reasoning, 'maxDisplayChars', { integer: true, min: 1 });
416
+
417
+ return config;
418
+ }
419
+
420
+ // Whether a raw config (or any of its providers) carries a plaintext API key.
421
+ function configHasApiKey(cfg) {
422
+ if (cfg && typeof cfg.apiKey === 'string' && cfg.apiKey) return true;
423
+ const providers = cfg?.providers;
424
+ if (providers && typeof providers === 'object') {
425
+ return Object.values(providers).some((p) => p && typeof p.apiKey === 'string' && p.apiKey);
426
+ }
427
+ return false;
428
+ }
429
+
430
+ // The git repo root, or cwd when not inside a repo (config files are still
431
+ // looked up relative to cwd in that case). Shared by loadConfig and setup.
432
+ export function getProjectRoot() {
433
+ try {
434
+ return execSync('git rev-parse --show-toplevel', {
435
+ encoding: 'utf-8',
436
+ stdio: ['pipe', 'pipe', 'ignore'],
437
+ }).trim();
438
+ } catch {
439
+ return process.cwd();
440
+ }
441
+ }
442
+
443
+ export async function loadConfig(cliProvider = null, { resolveCredentials = true } = {}) {
444
+ const projectRoot = getProjectRoot();
445
+ let config = { ...DEFAULT_CONFIG };
446
+ const loaded = [];
447
+
448
+ const userPath = join(homedir(), '.aicommit.config.json');
449
+ if (await fileExists(userPath)) {
450
+ let parsed;
451
+ try {
452
+ parsed = JSON.parse(await readFile(userPath, 'utf-8'));
453
+ } catch (err) {
454
+ throw new Error(`Failed to parse user config ${userPath}: ${err.message}`);
455
+ }
456
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
457
+ throw new Error(`Failed to parse user config ${userPath}: expected a JSON object.`);
458
+ }
459
+ config = mergeConfig(config, parsed);
460
+ loaded.push('user');
461
+ // Tighten loose permissions on config files that actually hold a key
462
+ // (a hand-created or older 0644 file would otherwise expose it).
463
+ if (configHasApiKey(parsed)) await chmod(userPath, 0o600).catch(() => {});
464
+ }
465
+
466
+ const projectPath = join(projectRoot, '.aicommit.config.json');
467
+ if (projectPath !== userPath && (await fileExists(projectPath))) {
468
+ let parsed;
469
+ try {
470
+ parsed = JSON.parse(await readFile(projectPath, 'utf-8'));
471
+ } catch (err) {
472
+ throw new Error(`Failed to parse project config ${projectPath}: ${err.message}`);
473
+ }
474
+ const { safe, ignored } = filterProjectConfig(parsed, config);
475
+ config = mergeConfig(config, safe);
476
+ loaded.push('project');
477
+ if (ignored.length) {
478
+ console.error(
479
+ chalk.yellow(
480
+ ` ⚠ Ignored unsafe settings from untrusted project config: ${ignored.join(', ')}`,
481
+ ),
482
+ );
483
+ console.error(
484
+ chalk.dim(' Put provider credentials and endpoints in ~/.aicommit.config.json.'),
485
+ );
486
+ }
487
+ }
488
+
489
+ const teamPolicy = await readTeamPolicy(projectRoot);
490
+ let { config: resolvedConfig, providerName } = resolveProvider(config, cliProvider);
491
+
492
+ // A repository-owned team policy is a strict, credential-free document.
493
+ // Apply it after selecting the personal provider so provider-scoped user
494
+ // preferences cannot change the committed policy on developer machines.
495
+ if (teamPolicy) {
496
+ resolvedConfig = mergeConfig(resolvedConfig, {
497
+ language: teamPolicy.language,
498
+ commitPolicy: teamPolicy.commitPolicy,
499
+ });
500
+ loaded.push('team policy');
501
+ }
502
+
503
+ validateConfig(resolvedConfig);
504
+ const credential = resolveCredentials
505
+ ? resolveCredential(resolvedConfig)
506
+ : {
507
+ apiKey: resolvedConfig.apiKey,
508
+ source: 'not_resolved',
509
+ sourceLabel: 'not resolved',
510
+ warning: null,
511
+ };
512
+ if (resolveCredentials) resolvedConfig.apiKey = credential.apiKey;
513
+
514
+ return {
515
+ config: resolvedConfig,
516
+ projectRoot,
517
+ loaded,
518
+ providerName,
519
+ credentialSource: credential.source,
520
+ credentialSourceLabel: credential.sourceLabel,
521
+ credentialWarning: credential.warning || null,
522
+ teamPolicyPath: teamPolicy?.path || null,
523
+ };
524
+ }