@hifullmoon/aicommit 1.5.1 → 2.0.1

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.js CHANGED
@@ -25,10 +25,13 @@ import { isExtensionProviderType, validateExtensionsConfig } from './extensions.
25
25
  // global config. Project config may tune generation/display behaviour, but all
26
26
  // connection and provider selection fields remain user-owned.
27
27
  export const PROJECT_CONNECTION_KEYS = new Set([
28
+ 'schemaVersion',
28
29
  'apiUrl',
29
30
  'apiKey',
30
31
  'apiKeyEnv',
31
32
  'modelId',
33
+ 'models',
34
+ 'defaultModel',
32
35
  'providerType',
33
36
  'providers',
34
37
  'defaultProvider',
@@ -193,6 +196,59 @@ export const DEFAULT_CONFIG = {
193
196
  prompt: '',
194
197
  };
195
198
 
199
+ export const CONFIG_SCHEMA_VERSION = 1;
200
+
201
+ const CONFIG_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
202
+ const TOP_LEVEL_CONNECTION_KEYS = new Set([
203
+ 'apiUrl',
204
+ 'apiKey',
205
+ 'apiKeyEnv',
206
+ 'modelId',
207
+ 'providerType',
208
+ 'defaultModel',
209
+ 'models',
210
+ 'extraBody',
211
+ ]);
212
+ const USER_CONFIG_KEYS = new Set([
213
+ 'schemaVersion',
214
+ 'defaultProvider',
215
+ 'providers',
216
+ ...Object.keys(DEFAULT_CONFIG).filter((key) => !TOP_LEVEL_CONNECTION_KEYS.has(key)),
217
+ ]);
218
+ const PROVIDER_CONFIG_KEYS = new Set([
219
+ 'providerType',
220
+ 'apiUrl',
221
+ 'apiKey',
222
+ 'apiKeyEnv',
223
+ 'credentialHelper',
224
+ 'retry',
225
+ 'defaultModel',
226
+ 'models',
227
+ ]);
228
+ const MODEL_CONFIG_KEYS = new Set([
229
+ 'label',
230
+ 'modelId',
231
+ 'temperature',
232
+ 'maxTokens',
233
+ 'timeoutMs',
234
+ 'reasoning',
235
+ 'extraBody',
236
+ ]);
237
+
238
+ const DEFAULT_PROVIDER_CATALOG = Object.freeze({
239
+ defaultProvider: 'openai',
240
+ providers: {
241
+ openai: {
242
+ providerType: 'openai',
243
+ apiUrl: DEFAULT_CONFIG.apiUrl,
244
+ defaultModel: 'default',
245
+ models: {
246
+ default: { modelId: DEFAULT_CONFIG.modelId },
247
+ },
248
+ },
249
+ },
250
+ });
251
+
196
252
  function mergeConfig(base, override) {
197
253
  const merged = deepMerge(base, override);
198
254
  if (Object.hasOwn(override, 'commitPolicy')) {
@@ -207,39 +263,139 @@ function mergeConfig(base, override) {
207
263
  return merged;
208
264
  }
209
265
 
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;
266
+ function object(value) {
267
+ return value && typeof value === 'object' && !Array.isArray(value);
268
+ }
217
269
 
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.',
270
+ function assertKnownKeys(value, allowed, path) {
271
+ const unknown = Object.keys(value).filter((key) => !allowed.has(key));
272
+ if (unknown.length) {
273
+ throw new Error(`Invalid config "${path}": unknown properties: ${unknown.join(', ')}.`);
274
+ }
275
+ }
276
+
277
+ function assertConfigId(value, path) {
278
+ if (typeof value !== 'string' || !CONFIG_ID_RE.test(value)) {
279
+ throw new Error(
280
+ `Invalid config "${path}": expected 1-64 letters, digits, dots, dashes, or underscores.`,
281
+ );
282
+ }
283
+ }
284
+
285
+ function providerRuntimeConfig(provider) {
286
+ const { defaultModel: _defaultModel, models: _models, ...runtime } = provider;
287
+ return runtime;
288
+ }
289
+
290
+ function modelRuntimeConfig(model) {
291
+ const { label: _label, ...runtime } = model;
292
+ return runtime;
293
+ }
294
+
295
+ export function validateUserConfig(value) {
296
+ if (!object(value)) throw new Error('Invalid user config: expected a JSON object.');
297
+ if (value.schemaVersion !== CONFIG_SCHEMA_VERSION) {
298
+ throw new Error(
299
+ `Invalid config "schemaVersion": expected ${CONFIG_SCHEMA_VERSION}. ` +
300
+ 'Run "aicommit setup" to create the current Provider/Model format.',
301
+ );
302
+ }
303
+ assertKnownKeys(value, USER_CONFIG_KEYS, 'root');
304
+ assertConfigId(value.defaultProvider, 'defaultProvider');
305
+ if (!object(value.providers) || Object.keys(value.providers).length === 0) {
306
+ throw new Error('Invalid config "providers": expected a non-empty object.');
307
+ }
308
+ if (!Object.hasOwn(value.providers, value.defaultProvider)) {
309
+ throw new Error(
310
+ `Invalid config "defaultProvider": unknown provider "${value.defaultProvider}".`,
223
311
  );
224
312
  }
225
313
 
226
- if (!providers || typeof providers !== 'object' || Object.keys(providers).length === 0) {
227
- return { config, providerName: null };
314
+ const globalConfig = { ...value };
315
+ delete globalConfig.schemaVersion;
316
+ delete globalConfig.defaultProvider;
317
+ delete globalConfig.providers;
318
+ const baseConfig = mergeConfig(DEFAULT_CONFIG, globalConfig);
319
+
320
+ for (const [providerName, provider] of Object.entries(value.providers)) {
321
+ assertConfigId(providerName, `providers.${providerName}`);
322
+ if (!object(provider)) {
323
+ throw new Error(`Invalid config "providers.${providerName}": expected an object.`);
324
+ }
325
+ assertKnownKeys(provider, PROVIDER_CONFIG_KEYS, `providers.${providerName}`);
326
+ for (const required of ['providerType', 'apiUrl', 'defaultModel', 'models']) {
327
+ if (!Object.hasOwn(provider, required)) {
328
+ throw new Error(`Invalid config "providers.${providerName}": missing ${required}.`);
329
+ }
330
+ }
331
+ assertConfigId(provider.defaultModel, `providers.${providerName}.defaultModel`);
332
+ if (!object(provider.models) || Object.keys(provider.models).length === 0) {
333
+ throw new Error(`Invalid config "providers.${providerName}.models": expected models.`);
334
+ }
335
+ if (!Object.hasOwn(provider.models, provider.defaultModel)) {
336
+ throw new Error(
337
+ `Invalid config "providers.${providerName}.defaultModel": unknown model ` +
338
+ `"${provider.defaultModel}".`,
339
+ );
340
+ }
341
+
342
+ const providerConfig = mergeConfig(baseConfig, providerRuntimeConfig(provider));
343
+ for (const [modelName, model] of Object.entries(provider.models)) {
344
+ assertConfigId(modelName, `providers.${providerName}.models.${modelName}`);
345
+ if (!object(model)) {
346
+ throw new Error(
347
+ `Invalid config "providers.${providerName}.models.${modelName}": expected an object.`,
348
+ );
349
+ }
350
+ assertKnownKeys(model, MODEL_CONFIG_KEYS, `providers.${providerName}.models.${modelName}`);
351
+ if (!Object.hasOwn(model, 'modelId')) {
352
+ throw new Error(
353
+ `Invalid config "providers.${providerName}.models.${modelName}": missing modelId.`,
354
+ );
355
+ }
356
+ if (
357
+ Object.hasOwn(model, 'label') &&
358
+ (typeof model.label !== 'string' || !model.label.trim() || model.label.length > 80)
359
+ ) {
360
+ throw new Error(
361
+ `Invalid config "providers.${providerName}.models.${modelName}.label": ` +
362
+ 'expected a non-empty string of at most 80 characters.',
363
+ );
364
+ }
365
+ validateConfig(mergeConfig(providerConfig, modelRuntimeConfig(model)));
366
+ }
228
367
  }
229
368
 
230
- const name = cliProvider || config.defaultProvider || Object.keys(providers)[0];
369
+ return value;
370
+ }
371
+
372
+ // Select a Provider and one of its named Model profiles, then flatten both
373
+ // layers so request, split, and extension code keep consuming one runtime
374
+ // config with a concrete modelId.
375
+ function resolveSelection(config, catalog, cliProvider, cliModel) {
376
+ const providers = catalog.providers;
377
+ const providerName = cliProvider || catalog.defaultProvider;
378
+ const provider = providers[providerName];
231
379
 
232
- if (!providers[name]) {
380
+ if (!provider) {
381
+ throw fail(
382
+ ERROR_CATEGORIES.CONFIG,
383
+ `Unknown provider: "${providerName}". Available providers: ${Object.keys(providers).join(', ')}`,
384
+ );
385
+ }
386
+ const modelName = cliModel || provider.defaultModel;
387
+ const model = provider.models[modelName];
388
+ if (!model) {
233
389
  throw fail(
234
390
  ERROR_CATEGORIES.CONFIG,
235
- `Unknown provider: "${name}". Available providers: ${Object.keys(providers).join(', ')}`,
391
+ `Unknown model: "${modelName}" for provider "${providerName}". ` +
392
+ `Available models: ${Object.keys(provider.models).join(', ')}`,
236
393
  );
237
394
  }
238
395
 
239
- const resolved = mergeConfig(config, providers[name]);
240
- delete resolved.providers;
241
- delete resolved.defaultProvider;
242
- return { config: resolved, providerName: name };
396
+ const providerConfig = mergeConfig(config, providerRuntimeConfig(provider));
397
+ const resolvedConfig = mergeConfig(providerConfig, modelRuntimeConfig(model));
398
+ return { config: resolvedConfig, providerName, modelName };
243
399
  }
244
400
 
245
401
  function assertString(config, key) {
@@ -440,9 +596,13 @@ export function getProjectRoot() {
440
596
  }
441
597
  }
442
598
 
443
- export async function loadConfig(cliProvider = null, { resolveCredentials = true } = {}) {
599
+ export async function loadConfig(
600
+ cliProvider = null,
601
+ { model: cliModel = null, resolveCredentials = true } = {},
602
+ ) {
444
603
  const projectRoot = getProjectRoot();
445
604
  let config = { ...DEFAULT_CONFIG };
605
+ let catalog = DEFAULT_PROVIDER_CATALOG;
446
606
  const loaded = [];
447
607
 
448
608
  const userPath = join(homedir(), '.aicommit.config.json');
@@ -456,13 +616,25 @@ export async function loadConfig(cliProvider = null, { resolveCredentials = true
456
616
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
457
617
  throw new Error(`Failed to parse user config ${userPath}: expected a JSON object.`);
458
618
  }
459
- config = mergeConfig(config, parsed);
619
+ validateUserConfig(parsed);
620
+ catalog = parsed;
621
+ const globalConfig = { ...parsed };
622
+ delete globalConfig.schemaVersion;
623
+ delete globalConfig.defaultProvider;
624
+ delete globalConfig.providers;
625
+ config = mergeConfig(config, globalConfig);
460
626
  loaded.push('user');
461
627
  // Tighten loose permissions on config files that actually hold a key
462
628
  // (a hand-created or older 0644 file would otherwise expose it).
463
629
  if (configHasApiKey(parsed)) await chmod(userPath, 0o600).catch(() => {});
464
630
  }
465
631
 
632
+ let {
633
+ config: resolvedConfig,
634
+ providerName,
635
+ modelName,
636
+ } = resolveSelection(config, catalog, cliProvider, cliModel);
637
+
466
638
  const projectPath = join(projectRoot, '.aicommit.config.json');
467
639
  if (projectPath !== userPath && (await fileExists(projectPath))) {
468
640
  let parsed;
@@ -471,8 +643,8 @@ export async function loadConfig(cliProvider = null, { resolveCredentials = true
471
643
  } catch (err) {
472
644
  throw new Error(`Failed to parse project config ${projectPath}: ${err.message}`);
473
645
  }
474
- const { safe, ignored } = filterProjectConfig(parsed, config);
475
- config = mergeConfig(config, safe);
646
+ const { safe, ignored } = filterProjectConfig(parsed, resolvedConfig);
647
+ resolvedConfig = mergeConfig(resolvedConfig, safe);
476
648
  loaded.push('project');
477
649
  if (ignored.length) {
478
650
  console.error(
@@ -487,7 +659,6 @@ export async function loadConfig(cliProvider = null, { resolveCredentials = true
487
659
  }
488
660
 
489
661
  const teamPolicy = await readTeamPolicy(projectRoot);
490
- let { config: resolvedConfig, providerName } = resolveProvider(config, cliProvider);
491
662
 
492
663
  // A repository-owned team policy is a strict, credential-free document.
493
664
  // Apply it after selecting the personal provider so provider-scoped user
@@ -516,6 +687,7 @@ export async function loadConfig(cliProvider = null, { resolveCredentials = true
516
687
  projectRoot,
517
688
  loaded,
518
689
  providerName,
690
+ modelName,
519
691
  credentialSource: credential.source,
520
692
  credentialSourceLabel: credential.sourceLabel,
521
693
  credentialWarning: credential.warning || null,
package/src/doctor.js CHANGED
@@ -33,7 +33,7 @@ function gitVersion() {
33
33
  }).trim();
34
34
  }
35
35
 
36
- export async function runDoctor(cliProvider = null) {
36
+ export async function runDoctor(cliProvider = null, cliModel = null) {
37
37
  const checks = [];
38
38
  const warnings = [];
39
39
  console.log('\n ' + chalk.cyan.bold('Doctor diagnostics'));
@@ -60,7 +60,7 @@ export async function runDoctor(cliProvider = null) {
60
60
 
61
61
  let loadedConfig;
62
62
  try {
63
- loadedConfig = await loadConfig(cliProvider);
63
+ loadedConfig = await loadConfig(cliProvider, { model: cliModel });
64
64
  } catch (err) {
65
65
  const classified = classifyError(err);
66
66
  addCheck(checks, 'Config', 'fail', classified.message);
@@ -68,11 +68,13 @@ export async function runDoctor(cliProvider = null) {
68
68
  throw classified;
69
69
  }
70
70
 
71
- const { config, loaded, providerName, credentialSourceLabel, credentialWarning } = loadedConfig;
71
+ const { config, loaded, providerName, modelName, credentialSourceLabel, credentialWarning } =
72
+ loadedConfig;
72
73
  const extensionHost = await configureExtensionHost(config);
73
74
  const adapter = await resolveProviderAdapter(config, getProviderAdapter);
74
75
  const provider = providerName || adapter.id;
75
76
  addCheck(checks, 'Config', 'pass', loaded.length ? loaded.join(' + ') : 'built-in defaults');
77
+ addCheck(checks, 'Model', 'pass', `${providerName}/${modelName} (${config.modelId})`);
76
78
  addCheck(
77
79
  checks,
78
80
  'Endpoint security',
package/src/main.js CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  statusIcon,
29
29
  confirmAction,
30
30
  editMessage,
31
+ isPromptQuitError,
31
32
  vimSelect,
32
33
  vimCheckbox,
33
34
  } from './ui.js';
@@ -82,13 +83,14 @@ async function pickPathsToStage(unstaged, untracked, message) {
82
83
  return picked.flat();
83
84
  }
84
85
 
85
- export async function main() {
86
+ async function runMain() {
86
87
  // ── CLI arguments ───────────────────────────────────────────────────
87
88
 
88
89
  const {
89
90
  targetPath,
90
91
  cliLang,
91
92
  cliProvider,
93
+ cliModel,
92
94
  cliReasoning,
93
95
  output,
94
96
  debug,
@@ -147,6 +149,7 @@ export async function main() {
147
149
  if (configAction) {
148
150
  return runConfigCommand(configAction, {
149
151
  provider: cliProvider,
152
+ model: cliModel,
150
153
  machineOutput,
151
154
  });
152
155
  }
@@ -182,12 +185,14 @@ export async function main() {
182
185
  return applySplitPlan(projectRoot, splitPlanFile, { yes, machineOutput });
183
186
  }
184
187
 
185
- if (doctor) return runDoctor(cliProvider);
188
+ if (doctor) return runDoctor(cliProvider, cliModel);
186
189
 
187
190
  // ── 1. Config ───────────────────────────────────────────────────────
188
191
 
189
- const { config, projectRoot, loaded, providerName, teamPolicyPath } =
190
- await loadConfig(cliProvider);
192
+ const { config, projectRoot, loaded, providerName, modelName, teamPolicyPath } = await loadConfig(
193
+ cliProvider,
194
+ { model: cliModel },
195
+ );
191
196
  const extensionHost = await configureExtensionHost(config);
192
197
  const selectedProvider = providerName || detectProviderType(config.apiUrl, config.providerType);
193
198
  const warnings = [];
@@ -234,8 +239,8 @@ export async function main() {
234
239
  }
235
240
  const reasoningEnabled = config.reasoning.mode === 'on';
236
241
 
237
- const viaCli = cliProvider ? chalk.dim(' (via CLI)') : '';
238
- const modelLabel = providerName ? `${providerName} (${config.modelId})` : config.modelId;
242
+ const viaCli = cliProvider || cliModel ? chalk.dim(' (via CLI)') : '';
243
+ const modelLabel = `${providerName}/${modelName} (${config.modelId})`;
239
244
  console.log(
240
245
  ' ' + chalk.green('✓') + chalk.dim(` Model: ${sanitizeTerminalText(modelLabel)}${viaCli}`),
241
246
  );
@@ -277,12 +282,14 @@ export async function main() {
277
282
  console.log(chalk.dim(` config files: ${loaded.join(', ') || '(none — defaults only)'}`));
278
283
  console.log(chalk.dim(` cliLang: ${cliLang || '(not set)'}`));
279
284
  console.log(chalk.dim(` cliProvider: ${cliProvider || '(not set)'}`));
285
+ console.log(chalk.dim(` cliModel: ${cliModel || '(not set)'}`));
280
286
  console.log(
281
287
  chalk.dim(
282
288
  ` reasoning: ${config.reasoning.mode === 'on' ? config.reasoning.effort : config.reasoning.mode}${cliReasoning ? ' (via CLI)' : ''}`,
283
289
  ),
284
290
  );
285
291
  console.log(chalk.dim(` providerName: ${providerName || '(not set)'}`));
292
+ console.log(chalk.dim(` modelName: ${modelName || '(not set)'}`));
286
293
  console.log(chalk.dim(` split: ${split}`));
287
294
  console.log(chalk.dim(` dryRun: ${dryRun}`));
288
295
  console.log(chalk.dim(` yes: ${yes}`));
@@ -766,3 +773,19 @@ export async function main() {
766
773
  });
767
774
  }
768
775
  }
776
+
777
+ export async function main() {
778
+ try {
779
+ return await runMain();
780
+ } catch (error) {
781
+ if (!isPromptQuitError(error)) throw error;
782
+ console.log(chalk.dim('\n Quit — no commit was created.\n'));
783
+ return {
784
+ warnings: [],
785
+ exitReason: 'cancelled',
786
+ committed: false,
787
+ edited: false,
788
+ rewrites: 0,
789
+ };
790
+ }
791
+ }
@@ -11,7 +11,7 @@ const require = createRequire(import.meta.url);
11
11
  const { version: CORE_VERSION } = require('../package.json');
12
12
 
13
13
  export const PROVIDER_PRESET_KIND = 'aicommit-provider-presets';
14
- export const PROVIDER_PRESET_SCHEMA_VERSION = 1;
14
+ export const PROVIDER_PRESET_SCHEMA_VERSION = 2;
15
15
  export const PROVIDER_ADAPTER_CONTRACT_VERSION = 1;
16
16
  export const PROVIDER_PRESET_FILENAME = 'provider-presets.json';
17
17
  export const PROVIDER_PRESET_BACKUP_FILENAME = 'provider-presets.previous.json';
@@ -179,12 +179,8 @@ export function validateProviderPresetManifest(value) {
179
179
  const ids = new Set();
180
180
  for (const [index, provider] of value.providers.entries()) {
181
181
  const path = `Provider preset providers[${index}]`;
182
- const required = ['id', 'label', 'adapter', 'apiUrl', 'modelId'];
183
- assertExactKeys(
184
- provider,
185
- Object.hasOwn(provider || {}, 'extraBody') ? [...required, 'extraBody'] : required,
186
- path,
187
- );
182
+ const required = ['id', 'label', 'adapter', 'apiUrl', 'defaultModel', 'models'];
183
+ assertExactKeys(provider, required, path);
188
184
  if (
189
185
  !ID_RE.test(provider.id) ||
190
186
  provider.id.toLowerCase() === 'custom' ||
@@ -215,17 +211,49 @@ export function validateProviderPresetManifest(value) {
215
211
  `${path}.apiUrl must be credential-free HTTPS, or HTTP only for localhost/loopback, without query or fragment.`,
216
212
  );
217
213
  }
214
+ if (!ID_RE.test(provider.defaultModel)) {
215
+ throw new Error(`${path}.defaultModel must be a valid model name.`);
216
+ }
218
217
  if (
219
- typeof provider.modelId !== 'string' ||
220
- !provider.modelId.trim() ||
221
- provider.modelId.length > 256 ||
222
- /[\0-\x1f\x7f]/.test(provider.modelId)
218
+ !object(provider.models) ||
219
+ !Object.keys(provider.models).length ||
220
+ Object.keys(provider.models).length > 100
223
221
  ) {
224
- throw new Error(`${path}.modelId must be a non-empty string of at most 256 characters.`);
222
+ throw new Error(`${path}.models must contain 1-100 named models.`);
223
+ }
224
+ if (!Object.hasOwn(provider.models, provider.defaultModel)) {
225
+ throw new Error(`${path}.defaultModel must reference a model in ${path}.models.`);
225
226
  }
226
- if (Object.hasOwn(provider, 'extraBody')) {
227
- if (!object(provider.extraBody)) throw new Error(`${path}.extraBody must be an object.`);
228
- assertExtraBody(provider.extraBody, `${path}.extraBody`);
227
+ for (const [modelName, model] of Object.entries(provider.models)) {
228
+ const modelPath = `${path}.models.${modelName}`;
229
+ if (!ID_RE.test(modelName)) throw new Error(`${modelPath} has an invalid model name.`);
230
+ const modelKeys = ['modelId'];
231
+ if (Object.hasOwn(model || {}, 'label')) modelKeys.push('label');
232
+ if (Object.hasOwn(model || {}, 'extraBody')) modelKeys.push('extraBody');
233
+ assertExactKeys(model, modelKeys, modelPath);
234
+ if (
235
+ typeof model.modelId !== 'string' ||
236
+ !model.modelId.trim() ||
237
+ model.modelId.length > 256 ||
238
+ /[\0-\x1f\x7f]/.test(model.modelId)
239
+ ) {
240
+ throw new Error(
241
+ `${modelPath}.modelId must be a non-empty string of at most 256 characters.`,
242
+ );
243
+ }
244
+ if (
245
+ Object.hasOwn(model, 'label') &&
246
+ (typeof model.label !== 'string' ||
247
+ !model.label.trim() ||
248
+ model.label.length > 80 ||
249
+ /[\0-\x1f\x7f]/.test(model.label))
250
+ ) {
251
+ throw new Error(`${modelPath}.label must be a non-empty string of at most 80 characters.`);
252
+ }
253
+ if (Object.hasOwn(model, 'extraBody')) {
254
+ if (!object(model.extraBody)) throw new Error(`${modelPath}.extraBody must be an object.`);
255
+ assertExtraBody(model.extraBody, `${modelPath}.extraBody`);
256
+ }
229
257
  }
230
258
  }
231
259
  return value;