Files
opencommit/src/commands/setup.ts
di-sukharev cf27085ac9 fix(cli): tighten proxy and setup behavior
Support explicit proxy disabling and ambient proxy fallback without leaking env state into config.

Improve first-run detection, endpoint-specific error messaging, diff exclusions, and runtime helper boundaries covered by unit tests.
2026-04-10 15:16:11 +03:00

491 lines
13 KiB
TypeScript

import { intro, outro, select, text, isCancel, spinner } from '@clack/prompts';
import chalk from 'chalk';
import { command } from 'cleye';
import { COMMANDS } from './ENUMS';
import {
CONFIG_KEYS,
MODEL_LIST,
OCO_AI_PROVIDER_ENUM,
getConfig,
setGlobalConfig,
getGlobalConfig,
getIsGlobalConfigFileExist,
DEFAULT_CONFIG,
PROVIDER_API_KEY_URLS,
RECOMMENDED_MODELS
} from './config';
import {
fetchModelsForProvider,
fetchOllamaModels,
getCacheInfo
} from '../utils/modelCache';
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
[OCO_AI_PROVIDER_ENUM.OPENAI]: 'OpenAI (GPT-4o, GPT-4)',
[OCO_AI_PROVIDER_ENUM.ANTHROPIC]: 'Anthropic (Claude Sonnet, Opus)',
[OCO_AI_PROVIDER_ENUM.OLLAMA]: 'Ollama (Free, runs locally)',
[OCO_AI_PROVIDER_ENUM.GEMINI]: 'Google Gemini',
[OCO_AI_PROVIDER_ENUM.GROQ]: 'Groq (Fast inference, free tier)',
[OCO_AI_PROVIDER_ENUM.MISTRAL]: 'Mistral AI',
[OCO_AI_PROVIDER_ENUM.DEEPSEEK]: 'DeepSeek',
[OCO_AI_PROVIDER_ENUM.OPENROUTER]: 'OpenRouter (Multiple providers)',
[OCO_AI_PROVIDER_ENUM.AIMLAPI]: 'AI/ML API',
[OCO_AI_PROVIDER_ENUM.AZURE]: 'Azure OpenAI',
[OCO_AI_PROVIDER_ENUM.MLX]: 'MLX (Apple Silicon, local)'
};
const PRIMARY_PROVIDERS = [
OCO_AI_PROVIDER_ENUM.OPENAI,
OCO_AI_PROVIDER_ENUM.ANTHROPIC,
OCO_AI_PROVIDER_ENUM.OLLAMA
];
const OTHER_PROVIDERS = [
OCO_AI_PROVIDER_ENUM.GEMINI,
OCO_AI_PROVIDER_ENUM.GROQ,
OCO_AI_PROVIDER_ENUM.MISTRAL,
OCO_AI_PROVIDER_ENUM.DEEPSEEK,
OCO_AI_PROVIDER_ENUM.OPENROUTER,
OCO_AI_PROVIDER_ENUM.AIMLAPI,
OCO_AI_PROVIDER_ENUM.AZURE,
OCO_AI_PROVIDER_ENUM.MLX
];
const NO_API_KEY_PROVIDERS = [
OCO_AI_PROVIDER_ENUM.OLLAMA,
OCO_AI_PROVIDER_ENUM.MLX,
OCO_AI_PROVIDER_ENUM.TEST
];
const MODEL_REQUIRED_PROVIDERS = [
OCO_AI_PROVIDER_ENUM.OLLAMA,
OCO_AI_PROVIDER_ENUM.MLX
];
async function selectProvider(): Promise<string | symbol> {
const primaryOptions = PRIMARY_PROVIDERS.map((provider) => ({
value: provider,
label: PROVIDER_DISPLAY_NAMES[provider] || provider
}));
primaryOptions.push({
value: 'other',
label: 'Other providers...'
});
const selection = await select({
message: 'Select your AI provider:',
options: primaryOptions
});
if (isCancel(selection)) return selection;
if (selection === 'other') {
const otherOptions = OTHER_PROVIDERS.map((provider) => ({
value: provider,
label: PROVIDER_DISPLAY_NAMES[provider] || provider
}));
return await select({
message: 'Select provider:',
options: otherOptions
});
}
return selection;
}
async function getApiKey(provider: string): Promise<string | symbol> {
const url =
PROVIDER_API_KEY_URLS[provider as keyof typeof PROVIDER_API_KEY_URLS];
let message = `Enter your ${provider} API key:`;
if (url) {
message = `Enter your API key:\n${chalk.dim(` Get your key at: ${url}`)}`;
}
return await text({
message,
placeholder: 'sk-...',
validate: (value) => {
if (!value || value.trim().length === 0) {
return 'API key is required';
}
return undefined;
}
});
}
function formatCacheAge(timestamp: number | null): string {
if (!timestamp) return '';
const ageMs = Date.now() - timestamp;
const days = Math.floor(ageMs / (1000 * 60 * 60 * 24));
const hours = Math.floor(ageMs / (1000 * 60 * 60));
if (days > 0) {
return `${days} day${days === 1 ? '' : 's'} ago`;
} else if (hours > 0) {
return `${hours} hour${hours === 1 ? '' : 's'} ago`;
}
return 'just now';
}
async function selectModel(
provider: string,
apiKey?: string
): Promise<string | symbol> {
const providerDisplayName =
PROVIDER_DISPLAY_NAMES[provider]?.split(' (')[0] || provider;
const loadingSpinner = spinner();
loadingSpinner.start(`Fetching models from ${providerDisplayName}...`);
let models: string[] = [];
let usedFallback = false;
try {
models = await fetchModelsForProvider(provider, apiKey);
} catch {
// Fall back to hardcoded list
usedFallback = true;
const providerKey = provider.toLowerCase() as keyof typeof MODEL_LIST;
models = MODEL_LIST[providerKey] || [];
}
// Check cache info for display
const cacheInfo = getCacheInfo();
const cacheAge = formatCacheAge(cacheInfo.timestamp);
if (usedFallback) {
loadingSpinner.stop(
chalk.yellow('Could not fetch models from API. Using default list.')
);
} else if (cacheAge) {
loadingSpinner.stop(`Models loaded ${chalk.dim(`(cached ${cacheAge})`)}`);
} else {
loadingSpinner.stop('Models loaded');
}
if (models.length === 0) {
// For Ollama/MLX, prompt for manual entry
if (NO_API_KEY_PROVIDERS.includes(provider as OCO_AI_PROVIDER_ENUM)) {
return await text({
message: 'Enter model name (e.g., llama3:8b, mistral):',
placeholder: 'llama3:8b',
validate: (value) => {
if (!value || value.trim().length === 0) {
return 'Model name is required';
}
return undefined;
}
});
}
// Use default from config
const providerKey = provider.toLowerCase() as keyof typeof MODEL_LIST;
return MODEL_LIST[providerKey]?.[0] || 'gpt-4o-mini';
}
// Get recommended model for this provider
const recommended =
RECOMMENDED_MODELS[provider as keyof typeof RECOMMENDED_MODELS];
// Build options with recommended first
const options: Array<{ value: string; label: string }> = [];
if (recommended && models.includes(recommended)) {
options.push({
value: recommended,
label: `${recommended} (Recommended)`
});
}
// Add other models (first 10, excluding recommended)
const otherModels = models.filter((m) => m !== recommended).slice(0, 10);
otherModels.forEach((model) => {
options.push({ value: model, label: model });
});
// Add option to see all or enter custom
if (models.length > 11) {
options.push({ value: '__show_all__', label: 'Show all models...' });
}
options.push({ value: '__custom__', label: 'Enter custom model...' });
const selection = await select({
message: 'Select a model:',
options
});
if (isCancel(selection)) return selection;
if (selection === '__show_all__') {
const allOptions = models.map((model) => ({
value: model,
label: model === recommended ? `${model} (Recommended)` : model
}));
return await select({
message: 'Select a model:',
options: allOptions
});
}
if (selection === '__custom__') {
return await text({
message: 'Enter model name:',
validate: (value) => {
if (!value || value.trim().length === 0) {
return 'Model name is required';
}
return undefined;
}
});
}
return selection;
}
async function setupOllama(): Promise<{
provider: string;
model: string;
apiUrl: string;
} | null> {
console.log(chalk.cyan('\n Ollama - Free Local AI\n'));
console.log(chalk.dim(' Setup steps:'));
console.log(chalk.dim(' 1. Install: https://ollama.ai/download'));
console.log(chalk.dim(' 2. Pull a model: ollama pull llama3:8b'));
console.log(chalk.dim(' 3. Start server: ollama serve\n'));
// Try to fetch available models
const loadingSpinner = spinner();
loadingSpinner.start('Checking for local Ollama installation...');
const defaultUrl = 'http://localhost:11434';
let ollamaModels: string[] = [];
try {
ollamaModels = await fetchOllamaModels(defaultUrl);
if (ollamaModels.length > 0) {
loadingSpinner.stop(
`${chalk.green('✔')} Found ${ollamaModels.length} local model(s)`
);
} else {
loadingSpinner.stop(
chalk.yellow(
'Ollama is running but no models found. Pull a model first: ollama pull llama3:8b'
)
);
}
} catch {
loadingSpinner.stop(
chalk.yellow(
'Could not connect to Ollama. Make sure it is running: ollama serve'
)
);
}
// Model selection
let model: string | symbol;
if (ollamaModels.length > 0) {
model = await select({
message: 'Select a model:',
options: [
...ollamaModels.map((m) => ({ value: m, label: m })),
{ value: '__custom__', label: 'Enter custom model name...' }
]
});
if (isCancel(model)) return null;
if (model === '__custom__') {
model = await text({
message: 'Enter model name (e.g., llama3:8b, mistral):',
placeholder: 'llama3:8b'
});
}
} else {
model = await text({
message: 'Enter model name (e.g., llama3:8b, mistral):',
placeholder: 'llama3:8b',
validate: (value) => {
if (!value || value.trim().length === 0) {
return 'Model name is required';
}
return undefined;
}
});
}
if (isCancel(model)) return null;
// API URL (optional)
const apiUrl = await text({
message: 'Ollama URL (press Enter for default):',
placeholder: defaultUrl,
defaultValue: defaultUrl
});
if (isCancel(apiUrl)) return null;
return {
provider: OCO_AI_PROVIDER_ENUM.OLLAMA,
model: model as string,
apiUrl: (apiUrl as string) || defaultUrl
};
}
export async function runSetup(): Promise<boolean> {
intro(chalk.bgCyan(' Welcome to OpenCommit! '));
// Select provider
const provider = await selectProvider();
if (isCancel(provider)) {
outro('Setup cancelled');
return false;
}
let config: Partial<Record<string, any>> = {};
// Handle Ollama specially
if (provider === OCO_AI_PROVIDER_ENUM.OLLAMA) {
const ollamaConfig = await setupOllama();
if (!ollamaConfig) {
outro('Setup cancelled');
return false;
}
config = {
OCO_AI_PROVIDER: ollamaConfig.provider,
OCO_MODEL: ollamaConfig.model,
OCO_API_URL: ollamaConfig.apiUrl,
OCO_API_KEY: 'ollama' // Placeholder
};
} else if (provider === OCO_AI_PROVIDER_ENUM.MLX) {
// MLX setup
console.log(chalk.cyan('\n MLX - Apple Silicon Local AI\n'));
console.log(chalk.dim(' MLX runs locally on Apple Silicon Macs.'));
console.log(chalk.dim(' No API key required.\n'));
const model = await text({
message: 'Enter model name:',
placeholder: 'mlx-community/Llama-3-8B-Instruct-4bit'
});
if (isCancel(model)) {
outro('Setup cancelled');
return false;
}
config = {
OCO_AI_PROVIDER: OCO_AI_PROVIDER_ENUM.MLX,
OCO_MODEL: model,
OCO_API_KEY: 'mlx' // Placeholder
};
} else {
// Standard provider flow: API key then model
const apiKey = await getApiKey(provider as string);
if (isCancel(apiKey)) {
outro('Setup cancelled');
return false;
}
const model = await selectModel(provider as string, apiKey as string);
if (isCancel(model)) {
outro('Setup cancelled');
return false;
}
config = {
OCO_AI_PROVIDER: provider,
OCO_API_KEY: apiKey,
OCO_MODEL: model
};
}
// Save configuration
const existingConfig = getIsGlobalConfigFileExist()
? getGlobalConfig()
: DEFAULT_CONFIG;
const newConfig = {
...existingConfig,
...config
};
setGlobalConfig(newConfig as any);
outro(
`${chalk.green(
'✔'
)} Configuration saved to ~/.opencommit\n\n Run ${chalk.cyan(
'oco'
)} to generate commit messages!`
);
return true;
}
export function isFirstRun(): boolean {
const hasGlobalConfig = getIsGlobalConfigFileExist();
const config = getConfig();
const provider = config.OCO_AI_PROVIDER || OCO_AI_PROVIDER_ENUM.OPENAI;
if (provider === OCO_AI_PROVIDER_ENUM.TEST) {
return false;
}
const hasRequiredConfig = MODEL_REQUIRED_PROVIDERS.includes(
provider as OCO_AI_PROVIDER_ENUM
)
? Boolean(config.OCO_MODEL)
: Boolean(config.OCO_API_KEY);
// Trigger the full setup wizard only when nothing usable was configured yet.
return !hasGlobalConfig && !hasRequiredConfig;
}
export async function promptForMissingApiKey(): Promise<boolean> {
const config = getConfig();
const provider = config.OCO_AI_PROVIDER || OCO_AI_PROVIDER_ENUM.OPENAI;
if (NO_API_KEY_PROVIDERS.includes(provider as OCO_AI_PROVIDER_ENUM)) {
return true; // No API key needed
}
if (config.OCO_API_KEY) {
return true; // Already has key
}
console.log(
chalk.yellow(`\nAPI key missing for ${provider}. Let's set it up.\n`)
);
const apiKey = await getApiKey(provider);
if (isCancel(apiKey)) {
return false;
}
const existingConfig = getGlobalConfig();
setGlobalConfig({
...existingConfig,
OCO_API_KEY: apiKey as string
} as any);
console.log(chalk.green('✔') + ' API key saved\n');
return true;
}
export const setupCommand = command(
{
name: COMMANDS.setup,
help: {
description: 'Interactive setup wizard for OpenCommit'
}
},
async () => {
await runSetup();
}
);