Skip to content

Commit db56bd3

Browse files
authored
Merge pull request #60 from ghostincli/mistral-integration
Integrated mistral vibe cli
2 parents 54d654e + 935f637 commit db56bd3

File tree

11 files changed

+964
-0
lines changed

11 files changed

+964
-0
lines changed

src/infra/engines/core/registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import cursorEngine from '../providers/cursor/index.js';
1212
import ccrEngine from '../providers/ccr/index.js';
1313
import opencodeEngine from '../providers/opencode/index.js';
1414
import auggieEngine from '../providers/auggie/index.js';
15+
import mistralEngine from '../providers/mistral/index.js';
1516

1617
/**
1718
* Engine Registry - Singleton that manages all available engines
@@ -38,6 +39,7 @@ class EngineRegistry {
3839
ccrEngine,
3940
opencodeEngine,
4041
auggieEngine,
42+
mistralEngine,
4143
// Add new engines here
4244
];
4345

src/infra/engines/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ export * as claude from './providers/claude/index.js';
88
export * as ccr from './providers/ccr/index.js';
99
export * as opencode from './providers/opencode/index.js';
1010
export * as auggie from './providers/auggie/index.js';
11+
export * as mistral from './providers/mistral/index.js';
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
import { stat, rm, writeFile, mkdir } from 'node:fs/promises';
2+
import * as path from 'node:path';
3+
import { homedir } from 'node:os';
4+
import { createInterface } from 'node:readline/promises';
5+
import { stdin as input, stdout as output } from 'node:process';
6+
7+
import { expandHomeDir } from '../../../../shared/utils/index.js';
8+
import { metadata } from './metadata.js';
9+
10+
/**
11+
* Check if CLI is installed
12+
*/
13+
async function isCliInstalled(command: string): Promise<boolean> {
14+
try {
15+
// Resolve command using Bun.which() to handle Windows .cmd files
16+
const resolvedCommand = Bun.which(command);
17+
18+
// If command is not found in PATH, it's not installed
19+
if (!resolvedCommand) {
20+
return false;
21+
}
22+
23+
const proc = Bun.spawn([resolvedCommand, '--help'], {
24+
stdout: 'pipe',
25+
stderr: 'pipe',
26+
stdin: 'ignore',
27+
});
28+
29+
// Set a timeout
30+
const timeout = new Promise<never>((_, reject) =>
31+
setTimeout(() => reject(new Error('Timeout')), 3000)
32+
);
33+
34+
const exitCode = await Promise.race([proc.exited, timeout]);
35+
const stdout = await new Response(proc.stdout).text();
36+
const stderr = await new Response(proc.stderr).text();
37+
const out = `${stdout}\n${stderr}`;
38+
39+
// Check for error messages indicating command not found
40+
if (/not recognized as an internal or external command/i.test(out)) return false;
41+
if (/command not found/i.test(out)) return false;
42+
if (/No such file or directory/i.test(out)) return false;
43+
44+
// If exit code is 0 or we get help output, CLI is installed
45+
if (typeof exitCode === 'number' && exitCode === 0) return true;
46+
// Even if exit code is non-zero, if we got help output, CLI exists
47+
if (/usage:|vibe \[-h\]/i.test(out)) return true;
48+
49+
return false;
50+
} catch {
51+
return false;
52+
}
53+
}
54+
55+
export interface MistralAuthOptions {
56+
mistralConfigDir?: string;
57+
}
58+
59+
export function resolveMistralConfigDir(options?: MistralAuthOptions): string {
60+
// Keep for backward compatibility; prefer resolveVibeHome below
61+
if (options?.mistralConfigDir) {
62+
return expandHomeDir(options.mistralConfigDir);
63+
}
64+
65+
if (process.env.MISTRAL_CONFIG_DIR) {
66+
return expandHomeDir(process.env.MISTRAL_CONFIG_DIR);
67+
}
68+
69+
return path.join(homedir(), '.codemachine', 'mistral');
70+
}
71+
72+
function resolveVibeHome(options?: MistralAuthOptions): string {
73+
if (options?.mistralConfigDir) {
74+
return expandHomeDir(options.mistralConfigDir);
75+
}
76+
if (process.env.VIBE_HOME) {
77+
return expandHomeDir(process.env.VIBE_HOME);
78+
}
79+
// default under codemachine
80+
return path.join(homedir(), '.codemachine', 'vibe');
81+
}
82+
83+
/**
84+
* Gets the path to the credentials file
85+
* Mistral Vibe stores it at ~/.vibe/.env
86+
*/
87+
export function getCredentialsPath(configDir: string): string {
88+
// Use VIBE_HOME override or fallback to ~/.codemachine/vibe/.env
89+
const vibeDir = resolveVibeHome({ mistralConfigDir: configDir });
90+
return path.join(vibeDir, '.env');
91+
}
92+
93+
async function promptForApiKey(): Promise<string | null> {
94+
try {
95+
const rl = createInterface({ input, output });
96+
const answer = await rl.question('Enter MISTRAL_API_KEY: ');
97+
rl.close();
98+
const key = answer.trim();
99+
return key ? key : null;
100+
} catch {
101+
return null;
102+
}
103+
}
104+
105+
/**
106+
* Gets paths to all Mistral-related files that need to be cleaned up
107+
* CodeMachine should not manage Vibe's credentials - it only checks if they exist.
108+
*/
109+
export function getMistralAuthPaths(configDir: string): string[] {
110+
// Only return CodeMachine-specific paths, not Vibe's actual credentials
111+
// Mistral Vibe manages its own credentials at ~/.vibe/.env
112+
return [
113+
// Add any CodeMachine-specific auth files here if needed in the future
114+
// For now, we don't manage any CodeMachine-specific Mistral auth files
115+
];
116+
}
117+
118+
/**
119+
* Checks if Mistral is authenticated
120+
*/
121+
export async function isAuthenticated(options?: MistralAuthOptions): Promise<boolean> {
122+
// Check if token is set via environment variable
123+
if (process.env.MISTRAL_API_KEY) {
124+
return true;
125+
}
126+
127+
const credPath = getCredentialsPath(resolveVibeHome(options));
128+
129+
try {
130+
await stat(credPath);
131+
return true;
132+
} catch (_error) {
133+
return false;
134+
}
135+
}
136+
137+
/**
138+
* Ensures Mistral is authenticated, running setup-token if needed
139+
*/
140+
export async function ensureAuth(options?: MistralAuthOptions): Promise<boolean> {
141+
// Check if token is already set via environment variable
142+
if (process.env.MISTRAL_API_KEY) {
143+
return true;
144+
}
145+
146+
const configDir = resolveMistralConfigDir(options);
147+
const vibeHome = resolveVibeHome(options);
148+
const credPath = getCredentialsPath(vibeHome);
149+
150+
// If already authenticated, nothing to do
151+
try {
152+
await stat(credPath);
153+
return true;
154+
} catch {
155+
// Credentials file doesn't exist
156+
}
157+
158+
if (process.env.CODEMACHINE_SKIP_AUTH === '1') {
159+
// Create a placeholder for testing/dry-run mode
160+
await mkdir(vibeHome, { recursive: true });
161+
await writeFile(credPath, 'MISTRAL_API_KEY=placeholder', { encoding: 'utf8' });
162+
return true;
163+
}
164+
165+
// Check if CLI is installed
166+
const cliInstalled = await isCliInstalled(metadata.cliBinary);
167+
if (!cliInstalled) {
168+
console.error(`\n────────────────────────────────────────────────────────────`);
169+
console.error(` ⚠️ ${metadata.name} CLI Not Installed`);
170+
console.error(`────────────────────────────────────────────────────────────`);
171+
console.error(`\nThe '${metadata.cliBinary}' command is not available.`);
172+
console.error(`Please install ${metadata.name} CLI first:\n`);
173+
console.error(` ${metadata.installCommand}\n`);
174+
console.error(`────────────────────────────────────────────────────────────\n`);
175+
throw new Error(`${metadata.name} CLI is not installed.`);
176+
}
177+
178+
// CLI is present but no API key - run setup or prompt and persist to VIBE_HOME/.env
179+
console.log(`\n────────────────────────────────────────────────────────────`);
180+
console.log(` 🔐 ${metadata.name} Authentication`);
181+
console.log(`────────────────────────────────────────────────────────────`);
182+
console.log(`\n${metadata.name} CLI requires the MISTRAL_API_KEY.`);
183+
console.log(`VIBE_HOME will be used to store credentials: ${vibeHome}`);
184+
console.log(`(override with VIBE_HOME env)\n`);
185+
186+
// Try interactive setup via vibe-acp --setup with VIBE_HOME set
187+
try {
188+
const resolvedSetup = Bun.which('vibe-acp') ?? 'vibe-acp';
189+
const proc = Bun.spawn([resolvedSetup, '--setup'], {
190+
env: { ...process.env, VIBE_HOME: vibeHome },
191+
stdio: ['inherit', 'inherit', 'inherit'],
192+
});
193+
await proc.exited;
194+
// After setup, check again
195+
try {
196+
await stat(credPath);
197+
return true;
198+
} catch {
199+
// fall through to manual prompt
200+
}
201+
} catch {
202+
// ignore and fall back to manual prompt
203+
}
204+
205+
console.log(`You can paste the API key here and we'll save it to ${path.join(vibeHome, '.env')} for you.\n`);
206+
207+
const apiKey = await promptForApiKey();
208+
if (apiKey) {
209+
await mkdir(vibeHome, { recursive: true });
210+
const envPath = path.join(vibeHome, '.env');
211+
await writeFile(envPath, `MISTRAL_API_KEY=${apiKey}\n`, { encoding: 'utf8' });
212+
process.env.MISTRAL_API_KEY = apiKey; // make available for this process
213+
console.log(`\nSaved API key to ${envPath}\n`);
214+
return true;
215+
}
216+
217+
console.log(`\nNo API key provided. You can also set it manually:\n`);
218+
console.log(` export MISTRAL_API_KEY=<your-api-key>\n`);
219+
console.log(`or create ~/.vibe/.env with:\n`);
220+
console.log(` MISTRAL_API_KEY=<your-api-key>\n`);
221+
console.log(`────────────────────────────────────────────────────────────\n`);
222+
223+
throw new Error('Authentication incomplete. Please set MISTRAL_API_KEY.');
224+
}
225+
226+
/**
227+
* Clears all Mistral authentication data
228+
* CodeMachine does not manage Vibe's credentials - it only checks if they exist.
229+
* To clear Vibe's credentials, users should do so directly via the Vibe CLI or manually.
230+
*/
231+
export async function clearAuth(options?: MistralAuthOptions): Promise<void> {
232+
const configDir = resolveMistralConfigDir(options);
233+
const vibeHome = resolveVibeHome(options);
234+
const authPaths = getMistralAuthPaths(configDir);
235+
236+
// Remove CodeMachine-specific auth files (if any)
237+
await Promise.all(
238+
authPaths.map(async (authPath) => {
239+
try {
240+
await rm(authPath, { force: true });
241+
} catch (_error) {
242+
// Ignore removal errors; treat as cleared
243+
}
244+
}),
245+
);
246+
247+
// Also remove the Vibe credentials file to fully sign out
248+
const vibeEnv = path.join(vibeHome, '.env');
249+
try {
250+
await rm(vibeEnv, { force: true });
251+
} catch (_error) {
252+
// Ignore removal errors
253+
}
254+
}
255+
256+
/**
257+
* Returns the next auth menu action based on current auth state
258+
*/
259+
export async function nextAuthMenuAction(options?: MistralAuthOptions): Promise<'login' | 'logout'> {
260+
return (await isAuthenticated(options)) ? 'logout' : 'login';
261+
}
262+
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/**
2+
* Mistral engine configuration and model mapping
3+
*/
4+
5+
export interface MistralConfig {
6+
/**
7+
* Model to use for Mistral execution
8+
* Can be a Mistral model name (devstral-2, mistral-large, mistral-medium, etc.)
9+
* or a generic model name that will be mapped (gpt-5-codex, gpt-4, etc.)
10+
*/
11+
model?: string;
12+
13+
/**
14+
* Working directory for execution
15+
*/
16+
workingDir: string;
17+
18+
/**
19+
* Optional custom Mistral config directory
20+
* Defaults to ~/.codemachine/mistral
21+
*/
22+
mistralConfigDir?: string;
23+
}
24+
25+
/**
26+
* Available Mistral models
27+
*/
28+
export const MISTRAL_MODELS = {
29+
DEVSTRAL_2: 'devstral-2',
30+
MISTRAL_LARGE: 'mistral-large',
31+
MISTRAL_MEDIUM: 'mistral-medium',
32+
MISTRAL_SMALL: 'mistral-small',
33+
} as const;
34+
35+
/**
36+
* Model mapping from generic model names to Mistral models
37+
* This allows using config with 'gpt-5-codex' or 'gpt-4' to map to Mistral models
38+
*/
39+
export const MODEL_MAPPING: Record<string, string> = {
40+
// Map common model names to Mistral equivalents
41+
'gpt-5-codex': MISTRAL_MODELS.DEVSTRAL_2,
42+
'gpt-4': MISTRAL_MODELS.MISTRAL_LARGE,
43+
'gpt-4-turbo': MISTRAL_MODELS.MISTRAL_LARGE,
44+
'gpt-3.5-turbo': MISTRAL_MODELS.MISTRAL_SMALL,
45+
'o1-preview': MISTRAL_MODELS.DEVSTRAL_2,
46+
'o1-mini': MISTRAL_MODELS.MISTRAL_LARGE,
47+
};
48+
49+
/**
50+
* Resolves a model name to a Mistral model
51+
* Returns undefined if the model should use Mistral's default
52+
*/
53+
export function resolveModel(model?: string): string | undefined {
54+
if (!model) {
55+
return undefined;
56+
}
57+
58+
// Check if it's in our mapping
59+
if (model in MODEL_MAPPING) {
60+
return MODEL_MAPPING[model];
61+
}
62+
63+
// If it's already a Mistral model name, return it
64+
if (model.startsWith('mistral-') || model.startsWith('devstral-')) {
65+
return model;
66+
}
67+
68+
// Otherwise, return undefined to use Mistral's default
69+
return undefined;
70+
}
71+
72+
/**
73+
* Default timeout for Mistral operations (30 minutes)
74+
*/
75+
export const DEFAULT_TIMEOUT = 1800000;
76+
77+
/**
78+
* Environment variable names
79+
*/
80+
export const ENV = {
81+
MISTRAL_CONFIG_DIR: 'MISTRAL_CONFIG_DIR',
82+
SKIP_MISTRAL: 'CODEMACHINE_SKIP_MISTRAL',
83+
SKIP_AUTH: 'CODEMACHINE_SKIP_AUTH',
84+
PLAIN_LOGS: 'CODEMACHINE_PLAIN_LOGS',
85+
} as const;
86+

0 commit comments

Comments
 (0)