@hifullmoon/aicommit 1.5.1 → 2.0.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/setup.js CHANGED
@@ -9,28 +9,17 @@ import password from '@inquirer/password';
9
9
  import confirm from '@inquirer/confirm';
10
10
 
11
11
  import { checkConnection } from './api.js';
12
- import { isSecureApiUrl } from './config.js';
12
+ import { CONFIG_SCHEMA_VERSION, isSecureApiUrl, validateUserConfig } from './config.js';
13
13
  import { vimSelect } from './ui.js';
14
14
  import { fileExists, formatMs, indentError, maskApiKey } from './utils.js';
15
15
  import { loadProviderPresetManifest } from './provider-presets.js';
16
16
 
17
- // Top-level connection keys from a legacy flat config. When the wizard writes
18
- // a providers map these are dropped from the top level, so the old flat
19
- // values can't silently shadow or conflict with the provider entries.
20
- const FLAT_CONNECTION_KEYS = ['apiUrl', 'apiKey', 'apiKeyEnv', 'modelId'];
21
-
22
- // Merge the wizard's answers into an existing config object (or an empty
23
- // one). Existing providers and unrelated settings are preserved; the new
24
- // provider becomes the default. Pure — exported for tests.
17
+ // Merge the wizard's answers into the one supported Provider/Model schema.
18
+ // Existing providers and unrelated global settings are preserved; the
19
+ // configured provider becomes the default. Pure — exported for tests.
25
20
  export function mergeSetupConfig(existing, { providerName, entry, language }) {
26
21
  const result = { ...existing };
27
-
28
- // Legacy flat config: remove the top-level connection keys now that the
29
- // connection lives under providers[providerName].
30
- if (!result.providers) {
31
- for (const key of FLAT_CONNECTION_KEYS) delete result[key];
32
- }
33
-
22
+ result.schemaVersion = CONFIG_SCHEMA_VERSION;
34
23
  result.providers = { ...existing.providers, [providerName]: entry };
35
24
  result.defaultProvider = providerName;
36
25
  result.language = language;
@@ -41,10 +30,7 @@ async function readExistingConfig(path) {
41
30
  if (!(await fileExists(path))) return {};
42
31
  try {
43
32
  const parsed = JSON.parse(await readFile(path, 'utf-8'));
44
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
45
- throw new Error('expected a JSON object');
46
- }
47
- return parsed;
33
+ return validateUserConfig(parsed);
48
34
  } catch (err) {
49
35
  const backup = `${path}.invalid-${Date.now()}.bak`;
50
36
  await copyFile(path, backup);
@@ -105,13 +91,15 @@ export async function runSetup(dependencies = {}) {
105
91
  ...providerPresets.map((p) => ({
106
92
  name: p.label,
107
93
  value: p.id,
108
- description: `${p.apiUrl} — default model: ${p.modelId}`,
94
+ description:
95
+ `${p.apiUrl} — default model: ` +
96
+ `${p.models[p.defaultModel].label || p.models[p.defaultModel].modelId}`,
109
97
  })),
110
98
  { name: 'custom', value: 'custom', description: 'Any OpenAI-compatible endpoint' },
111
99
  ],
112
100
  });
113
101
 
114
- let providerName, apiUrl, defaultModel, presetAdapter, presetExtraBody;
102
+ let providerName, apiUrl, presetAdapter, presetModels, presetDefaultModel;
115
103
  if (presetName === 'custom') {
116
104
  providerName = await inputPrompt({
117
105
  message: 'Provider name (used with aicommit -p <name>)',
@@ -123,15 +111,16 @@ export async function runSetup(dependencies = {}) {
123
111
  validate: (v) => isSecureApiUrl(v.trim()) || 'Use HTTPS, or HTTP only for localhost/loopback',
124
112
  });
125
113
  apiUrl = apiUrl.trim();
126
- defaultModel = '';
114
+ presetModels = {};
115
+ presetDefaultModel = 'default';
127
116
  } else {
128
117
  const preset = providerPresets.find((p) => p.id === presetName);
129
118
  if (!preset) throw new Error(`Provider preset not found: ${presetName}`);
130
119
  providerName = preset.id;
131
120
  apiUrl = preset.apiUrl;
132
- defaultModel = preset.modelId;
133
121
  presetAdapter = preset.adapter;
134
- presetExtraBody = preset.extraBody;
122
+ presetModels = preset.models;
123
+ presetDefaultModel = preset.defaultModel;
135
124
  }
136
125
 
137
126
  const existingProvider = existing.providers?.[providerName] || {};
@@ -172,12 +161,45 @@ export async function runSetup(dependencies = {}) {
172
161
 
173
162
  // ── 4. Model ────────────────────────────────────────────────────────
174
163
 
175
- const modelInput = await inputPrompt({
176
- message: 'Model ID',
177
- default: existingProvider.modelId || defaultModel || undefined,
178
- validate: (v) => (v.trim() ? true : 'Model ID is required'),
179
- });
180
- const modelId = modelInput.trim();
164
+ const models = { ...presetModels, ...existingProvider.models };
165
+ let suggestedModel = existingProvider.defaultModel || presetDefaultModel;
166
+ while (true) {
167
+ const modelNameInput = await inputPrompt({
168
+ message: 'Model name (used with aicommit -m <name>)',
169
+ default: suggestedModel,
170
+ validate: (v) =>
171
+ /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(v.trim()) ||
172
+ 'Use 1-64 letters, digits, dots, dashes, or underscores',
173
+ });
174
+ const modelName = modelNameInput.trim();
175
+ const currentModel = models[modelName] || {};
176
+ const modelIdInput = await inputPrompt({
177
+ message: `Model ID for ${modelName}`,
178
+ default: currentModel.modelId || undefined,
179
+ validate: (v) => (v.trim() ? true : 'Model ID is required'),
180
+ });
181
+ models[modelName] = { ...currentModel, modelId: modelIdInput.trim() };
182
+ const addAnother = await confirmPrompt({
183
+ message: 'Add another model?',
184
+ default: false,
185
+ });
186
+ if (!addAnother) break;
187
+ suggestedModel = undefined;
188
+ }
189
+
190
+ const modelNames = Object.keys(models);
191
+ const defaultModel =
192
+ modelNames.length === 1
193
+ ? modelNames[0]
194
+ : await selectPrompt({
195
+ message: 'Choose the default model',
196
+ default: existingProvider.defaultModel || presetDefaultModel,
197
+ choices: modelNames.map((name) => ({
198
+ name: `${name} (${models[name].modelId})`,
199
+ value: name,
200
+ })),
201
+ });
202
+ const selectedModel = models[defaultModel];
181
203
 
182
204
  // ── 5. Commit message language ──────────────────────────────────────
183
205
 
@@ -191,13 +213,16 @@ export async function runSetup(dependencies = {}) {
191
213
  });
192
214
 
193
215
  const entry = {
194
- ...existingProvider,
195
216
  apiUrl,
196
217
  apiKey,
197
218
  apiKeyEnv,
198
- modelId,
199
- ...(presetAdapter ? { providerType: presetAdapter } : {}),
200
- ...(presetExtraBody && !existingProvider.extraBody ? { extraBody: presetExtraBody } : {}),
219
+ providerType: presetAdapter || existingProvider.providerType || 'custom',
220
+ defaultModel,
221
+ models,
222
+ ...(existingProvider.retry ? { retry: existingProvider.retry } : {}),
223
+ ...(existingProvider.credentialHelper
224
+ ? { credentialHelper: existingProvider.credentialHelper }
225
+ : {}),
201
226
  };
202
227
 
203
228
  // ── 6. Connection test ──────────────────────────────────────────────
@@ -210,13 +235,14 @@ export async function runSetup(dependencies = {}) {
210
235
 
211
236
  if (runTest) {
212
237
  const spinner = spinnerFactory({
213
- text: chalk.dim(`Checking ${chalk.bold(modelId)} ...`),
238
+ text: chalk.dim(`Checking ${chalk.bold(selectedModel.modelId)} ...`),
214
239
  color: 'cyan',
215
240
  }).start();
216
241
 
217
242
  try {
218
243
  const report = await connectionCheck({
219
244
  ...entry,
245
+ ...selectedModel,
220
246
  apiKey: apiKeyEnv ? process.env[apiKeyEnv] : apiKey,
221
247
  maxTokens: 64,
222
248
  timeoutMs: 120000,
@@ -240,6 +266,7 @@ export async function runSetup(dependencies = {}) {
240
266
  // ── 7. Write config ─────────────────────────────────────────────────
241
267
 
242
268
  const merged = mergeSetupConfig(existing, { providerName, entry, language });
269
+ validateUserConfig(merged);
243
270
  // Write through a same-directory temporary file so interruption cannot
244
271
  // leave a partially written config. Permissions are tightened before the
245
272
  // atomic replacement as well as after it (best effort on Windows).
@@ -248,7 +275,9 @@ export async function runSetup(dependencies = {}) {
248
275
  console.log('');
249
276
  console.log(' ' + chalk.green.bold('✓ Config saved'));
250
277
  console.log(' ' + chalk.dim(` Path: ${targetPath}`));
251
- console.log(' ' + chalk.dim(` Provider: ${providerName} (${modelId})`));
278
+ console.log(
279
+ ' ' + chalk.dim(` Provider: ${providerName}/${defaultModel} (${selectedModel.modelId})`),
280
+ );
252
281
  console.log(
253
282
  ' ' + chalk.dim(` API key: ${apiKeyEnv ? `env:${apiKeyEnv}` : maskApiKey(apiKey)}`),
254
283
  );
package/src/ui.js CHANGED
@@ -128,6 +128,21 @@ export function formatReasoningPanel(reasoning, options = {}) {
128
128
 
129
129
  const CTRL_O_RELEASE_MS = 180;
130
130
 
131
+ export class PromptQuitError extends Error {
132
+ constructor() {
133
+ super('User quit the interactive prompt.');
134
+ this.name = 'PromptQuitError';
135
+ }
136
+ }
137
+
138
+ export function isPromptQuitError(error) {
139
+ return error instanceof PromptQuitError;
140
+ }
141
+
142
+ function isQuitKey(key) {
143
+ return key?.name === 'q' && !key.ctrl && !key.meta && !key.shift;
144
+ }
145
+
131
146
  function useCtrlOToggleGate() {
132
147
  const gate = useRef({ locked: false, timer: null });
133
148
  useEffect(() => () => clearTimeout(gate.current.timer), []);
@@ -291,6 +306,7 @@ const reasoningSelect = createPrompt((config, done) => {
291
306
  const choices = normalizeSelectChoices(config.choices);
292
307
  const first = choices.findIndex((choice) => !choice.disabled);
293
308
  if (first === -1) throw new Error('No selectable choices.');
309
+ const quitChoice = choices.find((choice) => !choice.disabled && choice.value === 'cancel');
294
310
 
295
311
  const [active, setActive] = useState(first);
296
312
  const [expanded, setExpanded] = useState(false);
@@ -308,6 +324,12 @@ const reasoningSelect = createPrompt((config, done) => {
308
324
  const maxOffset = Math.max(0, completeView.totalLines - pageSize);
309
325
 
310
326
  useKeypress((key, rl) => {
327
+ if (isQuitKey(key)) {
328
+ if (!quitChoice) throw new PromptQuitError();
329
+ setStatus('quit');
330
+ done(quitChoice.value);
331
+ return;
332
+ }
311
333
  if (key.ctrl && key.name === 'o') {
312
334
  rl.clearLine(0);
313
335
  if (acceptCtrlOToggle(ctrlOGate)) {
@@ -352,8 +374,9 @@ const reasoningSelect = createPrompt((config, done) => {
352
374
  }
353
375
  });
354
376
 
355
- if (status === 'done') {
356
- return `${chalk.green('?')} ${config.message} ${chalk.cyan(selected.short)}`;
377
+ if (status !== 'idle') {
378
+ const answer = status === 'quit' ? quitChoice : selected;
379
+ return `${chalk.green('?')} ${config.message} ${chalk.cyan(answer.short)}`;
357
380
  }
358
381
 
359
382
  const panel = formatReasoningPanel(config.reasoning.text, {
@@ -371,25 +394,29 @@ const reasoningSelect = createPrompt((config, done) => {
371
394
  .join('\n');
372
395
  const description = selected.description ? `\n ${chalk.cyan(selected.description)}` : '';
373
396
  const pageHelp = expanded && maxOffset > 0 ? ' · PgUp/PgDn reasoning' : '';
374
- const help = chalk.dim(` ↑↓/j k navigate · Enter select · Ctrl+O reasoning${pageHelp}`);
397
+ const help = chalk.dim(` ↑↓/j k navigate · Enter select · q quit · Ctrl+O reasoning${pageHelp}`);
375
398
 
376
399
  return `${panel}\n\n${chalk.green('?')} ${config.message}\n${rows}${description}\n${help}\x1B[?25l`;
377
400
  });
378
401
 
379
- // Vim-friendly prompts: wraps @inquirer prompts with j/k → arrow key
380
- // remapping. We intercept keypress events BEFORE @inquirer sees them and
381
- // translate j→down, k→up by emitting synthetic arrow-key events.
382
- // @inquirer ignores the original j/k characters, so only the arrow keys
383
- // take effect.
384
- async function withVimKeys(promptFn, options) {
402
+ // Vim-friendly prompts: wraps @inquirer prompts with j/k navigation and q to
403
+ // quit. The q shortcut resolves to an existing cancel choice when possible so
404
+ // callers can run their normal cleanup; prompts without one unwind through a
405
+ // dedicated error that main turns into a successful cancellation.
406
+ async function withVimKeys(promptFn, options, { quitValue, hasQuitValue = false } = {}) {
385
407
  // Ensure keypress events are enabled on stdin (idempotent)
386
408
  readline.emitKeypressEvents(process.stdin);
387
409
 
388
410
  let inTranslate = false; // guard against re-entering our own synthetic emits
411
+ let quitRequested = false;
412
+ let activePrompt;
389
413
 
390
414
  const interceptor = (_str, key) => {
391
415
  if (inTranslate || !key) return;
392
- if (key.name === 'j') {
416
+ if (isQuitKey(key)) {
417
+ quitRequested = true;
418
+ activePrompt?.cancel();
419
+ } else if (key.name === 'j') {
393
420
  inTranslate = true;
394
421
  process.stdin.emit('keypress', undefined, {
395
422
  name: 'down',
@@ -416,7 +443,12 @@ async function withVimKeys(promptFn, options) {
416
443
  process.stdin.prependListener('keypress', interceptor);
417
444
 
418
445
  try {
419
- return await promptFn(options);
446
+ activePrompt = promptFn(options);
447
+ return await activePrompt;
448
+ } catch (error) {
449
+ if (!quitRequested) throw error;
450
+ if (hasQuitValue) return quitValue;
451
+ throw new PromptQuitError();
420
452
  } finally {
421
453
  process.stdin.removeListener('keypress', interceptor);
422
454
  }
@@ -426,11 +458,32 @@ export async function vimSelect(options, reasoning) {
426
458
  if (reasoning) {
427
459
  return reasoningSelect({ ...options, reasoning });
428
460
  }
429
- return withVimKeys(select, options);
461
+ const quitChoice = options.choices.find(
462
+ (choice) => typeof choice === 'object' && choice?.value === 'cancel' && !choice.disabled,
463
+ );
464
+ const promptOptions = options.instructions
465
+ ? options
466
+ : {
467
+ ...options,
468
+ instructions: {
469
+ navigation: '↑↓/j k navigate · Enter select · q quit',
470
+ pager: '↑↓/j k navigate · Enter select · q quit',
471
+ },
472
+ };
473
+ return withVimKeys(select, promptOptions, {
474
+ quitValue: quitChoice?.value,
475
+ hasQuitValue: Boolean(quitChoice),
476
+ });
430
477
  }
431
478
 
432
479
  export async function vimCheckbox(options) {
433
- return withVimKeys(checkbox, options);
480
+ const promptOptions = options.instructions
481
+ ? options
482
+ : {
483
+ ...options,
484
+ instructions: '↑↓/j k navigate · Space select · Enter confirm · q quit',
485
+ };
486
+ return withVimKeys(checkbox, promptOptions);
434
487
  }
435
488
 
436
489
  export async function confirmAction(message, reasoning) {