@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/.aicommit.config.example.json +30 -10
- package/CHANGELOG.md +17 -1
- package/README.md +43 -41
- package/README.zh-CN.md +68 -66
- package/SECURITY.md +1 -1
- package/docs/distribution.md +3 -38
- package/docs/privacy.md +8 -8
- package/docs/provider-presets.md +22 -9
- package/docs/troubleshooting.md +0 -1
- package/package.json +2 -3
- package/presets/provider-presets.json +48 -15
- package/schemas/aicommit-provider-presets.schema.json +24 -4
- package/src/cli.js +38 -7
- package/src/completion.js +4 -1
- package/src/config-command.js +7 -5
- package/src/config.js +198 -26
- package/src/doctor.js +5 -3
- package/src/main.js +29 -6
- package/src/provider-presets.js +43 -15
- package/src/setup.js +66 -37
- package/src/ui.js +66 -13
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
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
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
|
-
|
|
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:
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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(
|
|
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
|
|
356
|
-
|
|
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
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
//
|
|
383
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|