Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/infra/engines/core/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import cursorEngine from '../providers/cursor/index.js';
import ccrEngine from '../providers/ccr/index.js';
import opencodeEngine from '../providers/opencode/index.js';
import auggieEngine from '../providers/auggie/index.js';
import mistralEngine from '../providers/mistral/index.js';

/**
* Engine Registry - Singleton that manages all available engines
Expand All @@ -38,6 +39,7 @@ class EngineRegistry {
ccrEngine,
opencodeEngine,
auggieEngine,
mistralEngine,
// Add new engines here
];

Expand Down
1 change: 1 addition & 0 deletions src/infra/engines/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export * as claude from './providers/claude/index.js';
export * as ccr from './providers/ccr/index.js';
export * as opencode from './providers/opencode/index.js';
export * as auggie from './providers/auggie/index.js';
export * as mistral from './providers/mistral/index.js';
262 changes: 262 additions & 0 deletions src/infra/engines/providers/mistral/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
import { stat, rm, writeFile, mkdir } from 'node:fs/promises';
import * as path from 'node:path';
import { homedir } from 'node:os';
import { createInterface } from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';

import { expandHomeDir } from '../../../../shared/utils/index.js';
import { metadata } from './metadata.js';

/**
* Check if CLI is installed
*/
async function isCliInstalled(command: string): Promise<boolean> {
try {
// Resolve command using Bun.which() to handle Windows .cmd files
const resolvedCommand = Bun.which(command);

// If command is not found in PATH, it's not installed
if (!resolvedCommand) {
return false;
}

const proc = Bun.spawn([resolvedCommand, '--help'], {
stdout: 'pipe',
stderr: 'pipe',
stdin: 'ignore',
});

// Set a timeout
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 3000)
);

const exitCode = await Promise.race([proc.exited, timeout]);
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
const out = `${stdout}\n${stderr}`;

// Check for error messages indicating command not found
if (/not recognized as an internal or external command/i.test(out)) return false;
if (/command not found/i.test(out)) return false;
if (/No such file or directory/i.test(out)) return false;

// If exit code is 0 or we get help output, CLI is installed
if (typeof exitCode === 'number' && exitCode === 0) return true;
// Even if exit code is non-zero, if we got help output, CLI exists
if (/usage:|vibe \[-h\]/i.test(out)) return true;

return false;
} catch {
return false;
}
}

export interface MistralAuthOptions {
mistralConfigDir?: string;
}

export function resolveMistralConfigDir(options?: MistralAuthOptions): string {
// Keep for backward compatibility; prefer resolveVibeHome below
if (options?.mistralConfigDir) {
return expandHomeDir(options.mistralConfigDir);
}

if (process.env.MISTRAL_CONFIG_DIR) {
return expandHomeDir(process.env.MISTRAL_CONFIG_DIR);
}

return path.join(homedir(), '.codemachine', 'mistral');
}

function resolveVibeHome(options?: MistralAuthOptions): string {
if (options?.mistralConfigDir) {
return expandHomeDir(options.mistralConfigDir);
}
if (process.env.VIBE_HOME) {
return expandHomeDir(process.env.VIBE_HOME);
}
// default under codemachine
return path.join(homedir(), '.codemachine', 'vibe');
}

/**
* Gets the path to the credentials file
* Mistral Vibe stores it at ~/.vibe/.env
*/
export function getCredentialsPath(configDir: string): string {
// Use VIBE_HOME override or fallback to ~/.codemachine/vibe/.env
const vibeDir = resolveVibeHome({ mistralConfigDir: configDir });
return path.join(vibeDir, '.env');
}

async function promptForApiKey(): Promise<string | null> {
try {
const rl = createInterface({ input, output });
const answer = await rl.question('Enter MISTRAL_API_KEY: ');
rl.close();
const key = answer.trim();
return key ? key : null;
} catch {
return null;
}
}

/**
* Gets paths to all Mistral-related files that need to be cleaned up
* CodeMachine should not manage Vibe's credentials - it only checks if they exist.
*/
export function getMistralAuthPaths(configDir: string): string[] {
// Only return CodeMachine-specific paths, not Vibe's actual credentials
// Mistral Vibe manages its own credentials at ~/.vibe/.env
return [
// Add any CodeMachine-specific auth files here if needed in the future
// For now, we don't manage any CodeMachine-specific Mistral auth files
];
}

/**
* Checks if Mistral is authenticated
*/
export async function isAuthenticated(options?: MistralAuthOptions): Promise<boolean> {
// Check if token is set via environment variable
if (process.env.MISTRAL_API_KEY) {
return true;
}

const credPath = getCredentialsPath(resolveVibeHome(options));

try {
await stat(credPath);
return true;
} catch (_error) {
return false;
}
}

/**
* Ensures Mistral is authenticated, running setup-token if needed
*/
export async function ensureAuth(options?: MistralAuthOptions): Promise<boolean> {
// Check if token is already set via environment variable
if (process.env.MISTRAL_API_KEY) {
return true;
}

const configDir = resolveMistralConfigDir(options);
const vibeHome = resolveVibeHome(options);
const credPath = getCredentialsPath(vibeHome);

// If already authenticated, nothing to do
try {
await stat(credPath);
return true;
} catch {
// Credentials file doesn't exist
}

if (process.env.CODEMACHINE_SKIP_AUTH === '1') {
// Create a placeholder for testing/dry-run mode
await mkdir(vibeHome, { recursive: true });
await writeFile(credPath, 'MISTRAL_API_KEY=placeholder', { encoding: 'utf8' });
return true;
}

// Check if CLI is installed
const cliInstalled = await isCliInstalled(metadata.cliBinary);
if (!cliInstalled) {
console.error(`\n────────────────────────────────────────────────────────────`);
console.error(` ⚠️ ${metadata.name} CLI Not Installed`);
console.error(`────────────────────────────────────────────────────────────`);
console.error(`\nThe '${metadata.cliBinary}' command is not available.`);
console.error(`Please install ${metadata.name} CLI first:\n`);
console.error(` ${metadata.installCommand}\n`);
console.error(`────────────────────────────────────────────────────────────\n`);
throw new Error(`${metadata.name} CLI is not installed.`);
}

// CLI is present but no API key - run setup or prompt and persist to VIBE_HOME/.env
console.log(`\n────────────────────────────────────────────────────────────`);
console.log(` 🔐 ${metadata.name} Authentication`);
console.log(`────────────────────────────────────────────────────────────`);
console.log(`\n${metadata.name} CLI requires the MISTRAL_API_KEY.`);
console.log(`VIBE_HOME will be used to store credentials: ${vibeHome}`);
console.log(`(override with VIBE_HOME env)\n`);

// Try interactive setup via vibe-acp --setup with VIBE_HOME set
try {
const resolvedSetup = Bun.which('vibe-acp') ?? 'vibe-acp';
const proc = Bun.spawn([resolvedSetup, '--setup'], {
env: { ...process.env, VIBE_HOME: vibeHome },
stdio: ['inherit', 'inherit', 'inherit'],
});
await proc.exited;
// After setup, check again
try {
await stat(credPath);
return true;
} catch {
// fall through to manual prompt
}
} catch {
// ignore and fall back to manual prompt
}

console.log(`You can paste the API key here and we'll save it to ${path.join(vibeHome, '.env')} for you.\n`);

const apiKey = await promptForApiKey();
if (apiKey) {
await mkdir(vibeHome, { recursive: true });
const envPath = path.join(vibeHome, '.env');
await writeFile(envPath, `MISTRAL_API_KEY=${apiKey}\n`, { encoding: 'utf8' });
process.env.MISTRAL_API_KEY = apiKey; // make available for this process
console.log(`\nSaved API key to ${envPath}\n`);
return true;
}

console.log(`\nNo API key provided. You can also set it manually:\n`);
console.log(` export MISTRAL_API_KEY=<your-api-key>\n`);
console.log(`or create ~/.vibe/.env with:\n`);
console.log(` MISTRAL_API_KEY=<your-api-key>\n`);
console.log(`────────────────────────────────────────────────────────────\n`);

throw new Error('Authentication incomplete. Please set MISTRAL_API_KEY.');
}

/**
* Clears all Mistral authentication data
* CodeMachine does not manage Vibe's credentials - it only checks if they exist.
* To clear Vibe's credentials, users should do so directly via the Vibe CLI or manually.
*/
export async function clearAuth(options?: MistralAuthOptions): Promise<void> {
const configDir = resolveMistralConfigDir(options);
const vibeHome = resolveVibeHome(options);
const authPaths = getMistralAuthPaths(configDir);

// Remove CodeMachine-specific auth files (if any)
await Promise.all(
authPaths.map(async (authPath) => {
try {
await rm(authPath, { force: true });
} catch (_error) {
// Ignore removal errors; treat as cleared
}
}),
);

// Also remove the Vibe credentials file to fully sign out
const vibeEnv = path.join(vibeHome, '.env');
try {
await rm(vibeEnv, { force: true });
} catch (_error) {
// Ignore removal errors
}
}

/**
* Returns the next auth menu action based on current auth state
*/
export async function nextAuthMenuAction(options?: MistralAuthOptions): Promise<'login' | 'logout'> {
return (await isAuthenticated(options)) ? 'logout' : 'login';
}

86 changes: 86 additions & 0 deletions src/infra/engines/providers/mistral/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* Mistral engine configuration and model mapping
*/

export interface MistralConfig {
/**
* Model to use for Mistral execution
* Can be a Mistral model name (devstral-2, mistral-large, mistral-medium, etc.)
* or a generic model name that will be mapped (gpt-5-codex, gpt-4, etc.)
*/
model?: string;

/**
* Working directory for execution
*/
workingDir: string;

/**
* Optional custom Mistral config directory
* Defaults to ~/.codemachine/mistral
*/
mistralConfigDir?: string;
}

/**
* Available Mistral models
*/
export const MISTRAL_MODELS = {
DEVSTRAL_2: 'devstral-2',
MISTRAL_LARGE: 'mistral-large',
MISTRAL_MEDIUM: 'mistral-medium',
MISTRAL_SMALL: 'mistral-small',
} as const;

/**
* Model mapping from generic model names to Mistral models
* This allows using config with 'gpt-5-codex' or 'gpt-4' to map to Mistral models
*/
export const MODEL_MAPPING: Record<string, string> = {
// Map common model names to Mistral equivalents
'gpt-5-codex': MISTRAL_MODELS.DEVSTRAL_2,
'gpt-4': MISTRAL_MODELS.MISTRAL_LARGE,
'gpt-4-turbo': MISTRAL_MODELS.MISTRAL_LARGE,
'gpt-3.5-turbo': MISTRAL_MODELS.MISTRAL_SMALL,
'o1-preview': MISTRAL_MODELS.DEVSTRAL_2,
'o1-mini': MISTRAL_MODELS.MISTRAL_LARGE,
};

/**
* Resolves a model name to a Mistral model
* Returns undefined if the model should use Mistral's default
*/
export function resolveModel(model?: string): string | undefined {
if (!model) {
return undefined;
}

// Check if it's in our mapping
if (model in MODEL_MAPPING) {
return MODEL_MAPPING[model];
}

// If it's already a Mistral model name, return it
if (model.startsWith('mistral-') || model.startsWith('devstral-')) {
return model;
}

// Otherwise, return undefined to use Mistral's default
return undefined;
}

/**
* Default timeout for Mistral operations (30 minutes)
*/
export const DEFAULT_TIMEOUT = 1800000;

/**
* Environment variable names
*/
export const ENV = {
MISTRAL_CONFIG_DIR: 'MISTRAL_CONFIG_DIR',
SKIP_MISTRAL: 'CODEMACHINE_SKIP_MISTRAL',
SKIP_AUTH: 'CODEMACHINE_SKIP_AUTH',
PLAIN_LOGS: 'CODEMACHINE_PLAIN_LOGS',
} as const;

Loading