404 lines
10 KiB
JavaScript
404 lines
10 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const SAVED_PROFILES = new Set([
|
|
'openai',
|
|
'ollama',
|
|
'codex',
|
|
'gemini',
|
|
'atomic-chat',
|
|
]);
|
|
|
|
const CODEX_ALIAS_MODELS = new Set([
|
|
'codexplan',
|
|
'codexspark',
|
|
'gpt-5.4',
|
|
'gpt-5.4-mini',
|
|
'gpt-5.3-codex',
|
|
'gpt-5.3-codex-spark',
|
|
'gpt-5.2',
|
|
'gpt-5.2-codex',
|
|
'gpt-5.1-codex-max',
|
|
'gpt-5.1-codex-mini',
|
|
]);
|
|
|
|
function asNonEmptyString(value) {
|
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
}
|
|
|
|
function isEnvTruthy(value) {
|
|
const normalized = asNonEmptyString(value);
|
|
if (!normalized) {
|
|
return false;
|
|
}
|
|
|
|
const lowered = normalized.toLowerCase();
|
|
return lowered !== '0' && lowered !== 'false' && lowered !== 'no';
|
|
}
|
|
|
|
function chooseLaunchWorkspace({ activeWorkspacePath, workspacePaths }) {
|
|
const activePath = asNonEmptyString(activeWorkspacePath);
|
|
if (activePath) {
|
|
return { workspacePath: activePath, source: 'active-workspace' };
|
|
}
|
|
|
|
const firstWorkspacePath = Array.isArray(workspacePaths)
|
|
? asNonEmptyString(workspacePaths[0])
|
|
: null;
|
|
|
|
if (firstWorkspacePath) {
|
|
return { workspacePath: firstWorkspacePath, source: 'first-workspace' };
|
|
}
|
|
|
|
return { workspacePath: null, source: 'none' };
|
|
}
|
|
|
|
function sanitizeProfileEnv(env) {
|
|
if (!env || typeof env !== 'object' || Array.isArray(env)) {
|
|
return {};
|
|
}
|
|
|
|
return Object.fromEntries(
|
|
Object.entries(env).filter(([, value]) => typeof value === 'string' && value.trim()),
|
|
);
|
|
}
|
|
|
|
function parseProfileFile(raw) {
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
return null;
|
|
}
|
|
|
|
const profile = asNonEmptyString(parsed.profile);
|
|
if (!profile || !SAVED_PROFILES.has(profile)) {
|
|
return null;
|
|
}
|
|
|
|
if (!parsed.env || typeof parsed.env !== 'object' || Array.isArray(parsed.env)) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
profile,
|
|
env: sanitizeProfileEnv(parsed.env),
|
|
createdAt: asNonEmptyString(parsed.createdAt),
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isLocalBaseUrl(baseUrl) {
|
|
const normalized = asNonEmptyString(baseUrl);
|
|
if (!normalized) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const hostname = new URL(normalized).hostname.toLowerCase();
|
|
return (
|
|
hostname === 'localhost' ||
|
|
hostname === '127.0.0.1' ||
|
|
hostname === '0.0.0.0' ||
|
|
hostname === '::1' ||
|
|
hostname.endsWith('.local')
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function getHostname(baseUrl) {
|
|
const normalized = asNonEmptyString(baseUrl);
|
|
if (!normalized) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return new URL(normalized).hostname.toLowerCase();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function resolveCommandCheckPath(command, workspacePath) {
|
|
const normalized = asNonEmptyString(command);
|
|
if (!normalized) {
|
|
return null;
|
|
}
|
|
|
|
if (!normalized.includes(path.sep) && !normalized.includes('/')) {
|
|
return null;
|
|
}
|
|
|
|
if (path.isAbsolute(normalized)) {
|
|
return normalized;
|
|
}
|
|
|
|
return workspacePath
|
|
? path.resolve(workspacePath, normalized)
|
|
: path.resolve(normalized);
|
|
}
|
|
|
|
function getEnvValue(env, key) {
|
|
if (!env || typeof env !== 'object') {
|
|
return '';
|
|
}
|
|
|
|
const matchedKey = Object.keys(env).find(candidate => candidate.toUpperCase() === key);
|
|
return matchedKey ? env[matchedKey] : '';
|
|
}
|
|
|
|
function canAccessExecutable(filePath, platform) {
|
|
try {
|
|
fs.accessSync(filePath, platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function findCommandPath(command, options = {}) {
|
|
const normalized = asNonEmptyString(command);
|
|
if (!normalized) {
|
|
return null;
|
|
}
|
|
|
|
const cwd = asNonEmptyString(options.cwd);
|
|
const env = options.env || process.env;
|
|
const platform = options.platform || process.platform;
|
|
const hasPathSeparators = normalized.includes(path.sep) || normalized.includes('/');
|
|
|
|
if (hasPathSeparators) {
|
|
if (!path.isAbsolute(normalized) && !cwd) {
|
|
return null;
|
|
}
|
|
|
|
const directPath = resolveCommandCheckPath(normalized, cwd);
|
|
return directPath && canAccessExecutable(directPath, platform) ? directPath : null;
|
|
}
|
|
|
|
const pathValue = getEnvValue(env, 'PATH');
|
|
if (!pathValue) {
|
|
return null;
|
|
}
|
|
|
|
const pathExtValue = getEnvValue(env, 'PATHEXT');
|
|
const hasExplicitExtension = Boolean(path.extname(normalized));
|
|
const extensions = platform === 'win32'
|
|
? (hasExplicitExtension
|
|
? ['']
|
|
: (pathExtValue || '.COM;.EXE;.BAT;.CMD')
|
|
.split(';')
|
|
.map(extension => extension.trim())
|
|
.filter(Boolean))
|
|
: [''];
|
|
|
|
for (const directory of pathValue.split(path.delimiter)) {
|
|
const baseDirectory = asNonEmptyString(directory);
|
|
if (!baseDirectory) {
|
|
continue;
|
|
}
|
|
|
|
for (const extension of extensions) {
|
|
const candidatePath = path.join(baseDirectory, `${normalized}${extension}`);
|
|
if (canAccessExecutable(candidatePath, platform)) {
|
|
return candidatePath;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function isPathInsideWorkspace(filePath, workspacePath) {
|
|
const normalizedFilePath = asNonEmptyString(filePath);
|
|
const normalizedWorkspacePath = asNonEmptyString(workspacePath);
|
|
if (!normalizedFilePath || !normalizedWorkspacePath) {
|
|
return false;
|
|
}
|
|
|
|
const resolvedFilePath = path.resolve(normalizedFilePath);
|
|
const resolvedWorkspacePath = path.resolve(normalizedWorkspacePath);
|
|
const comparableFilePath = process.platform === 'win32'
|
|
? resolvedFilePath.toLowerCase()
|
|
: resolvedFilePath;
|
|
const comparableWorkspacePath = process.platform === 'win32'
|
|
? resolvedWorkspacePath.toLowerCase()
|
|
: resolvedWorkspacePath;
|
|
const relativePath = path.relative(comparableWorkspacePath, comparableFilePath);
|
|
|
|
return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath));
|
|
}
|
|
|
|
function hasCodexBaseUrl(baseUrl) {
|
|
const normalized = asNonEmptyString(baseUrl);
|
|
if (!normalized) {
|
|
return false;
|
|
}
|
|
|
|
return /chatgpt\.com\/backend-api\/codex/i.test(normalized);
|
|
}
|
|
|
|
function hasCodexAlias(model) {
|
|
const normalized = asNonEmptyString(model);
|
|
if (!normalized) {
|
|
return false;
|
|
}
|
|
|
|
const baseModel = normalized.toLowerCase().split('?', 1)[0] || normalized.toLowerCase();
|
|
return CODEX_ALIAS_MODELS.has(baseModel);
|
|
}
|
|
|
|
function getOpenAICompatibleLabel(baseUrl, model) {
|
|
const normalizedBaseUrl = (asNonEmptyString(baseUrl) || '').toLowerCase();
|
|
const normalizedModel = (asNonEmptyString(model) || '').toLowerCase();
|
|
const hostname = getHostname(baseUrl);
|
|
|
|
if (hasCodexBaseUrl(baseUrl) || (!baseUrl && hasCodexAlias(model))) {
|
|
return 'Codex';
|
|
}
|
|
|
|
if (/localhost:11434|127\.0\.0\.1:11434|0\.0\.0\.0:11434/i.test(normalizedBaseUrl)) {
|
|
return 'Ollama';
|
|
}
|
|
|
|
if (/localhost:1234|127\.0\.0\.1:1234|0\.0\.0\.0:1234/i.test(normalizedBaseUrl)) {
|
|
return 'LM Studio';
|
|
}
|
|
|
|
if (normalizedBaseUrl.includes('deepseek') || normalizedModel.includes('deepseek')) {
|
|
return 'DeepSeek';
|
|
}
|
|
|
|
if (normalizedBaseUrl.includes('openrouter')) {
|
|
return 'OpenRouter';
|
|
}
|
|
|
|
if (normalizedBaseUrl.includes('together')) {
|
|
return 'Together AI';
|
|
}
|
|
|
|
if (normalizedBaseUrl.includes('groq')) {
|
|
return 'Groq';
|
|
}
|
|
|
|
if (normalizedBaseUrl.includes('mistral') || normalizedModel.includes('mistral')) {
|
|
return 'Mistral';
|
|
}
|
|
|
|
if (normalizedBaseUrl.includes('azure')) {
|
|
return 'Azure OpenAI';
|
|
}
|
|
|
|
if (hostname === 'api.openai.com' || !normalizedBaseUrl) {
|
|
return 'OpenAI';
|
|
}
|
|
|
|
if (isLocalBaseUrl(normalizedBaseUrl)) {
|
|
return 'Local OpenAI-compatible';
|
|
}
|
|
|
|
return 'OpenAI-compatible';
|
|
}
|
|
|
|
function buildProviderState(label, detail, source) {
|
|
return {
|
|
label,
|
|
detail,
|
|
source,
|
|
};
|
|
}
|
|
|
|
function getDetail(env, fallback) {
|
|
return (
|
|
asNonEmptyString(env.OPENAI_MODEL) ||
|
|
asNonEmptyString(env.GEMINI_MODEL) ||
|
|
asNonEmptyString(env.OPENAI_BASE_URL) ||
|
|
asNonEmptyString(env.GEMINI_BASE_URL) ||
|
|
fallback
|
|
);
|
|
}
|
|
|
|
function describeOpenAICompatible(env, source) {
|
|
const baseUrl = asNonEmptyString(env.OPENAI_BASE_URL) || asNonEmptyString(env.OPENAI_API_BASE);
|
|
const model = asNonEmptyString(env.OPENAI_MODEL);
|
|
const label = getOpenAICompatibleLabel(baseUrl, model);
|
|
|
|
if (label === 'Codex') {
|
|
return buildProviderState('Codex', model || 'ChatGPT Codex', source);
|
|
}
|
|
|
|
return buildProviderState(label, model || baseUrl || 'OpenAI-compatible runtime', source);
|
|
}
|
|
|
|
function describeSavedProfile(profile) {
|
|
switch (profile.profile) {
|
|
case 'ollama':
|
|
return buildProviderState('Ollama', getDetail(profile.env, 'saved profile'), 'profile');
|
|
case 'gemini':
|
|
return buildProviderState('Gemini', getDetail(profile.env, 'saved profile'), 'profile');
|
|
case 'codex':
|
|
return buildProviderState('Codex', getDetail(profile.env, 'saved profile'), 'profile');
|
|
case 'atomic-chat':
|
|
return buildProviderState('Atomic Chat', getDetail(profile.env, 'saved profile'), 'profile');
|
|
case 'openai':
|
|
default:
|
|
return describeOpenAICompatible(profile.env, 'profile');
|
|
}
|
|
}
|
|
|
|
function describeProviderState({ shimEnabled, env, profile }) {
|
|
if (profile) {
|
|
return describeSavedProfile(profile);
|
|
}
|
|
|
|
if (isEnvTruthy(env.CLAUDE_CODE_USE_GEMINI)) {
|
|
return buildProviderState('Gemini', getDetail(env, 'from environment'), 'env');
|
|
}
|
|
|
|
if (isEnvTruthy(env.CLAUDE_CODE_USE_GITHUB)) {
|
|
return buildProviderState('GitHub Models', getDetail(env, 'from environment'), 'env');
|
|
}
|
|
|
|
if (isEnvTruthy(env.CLAUDE_CODE_USE_BEDROCK)) {
|
|
return buildProviderState('Bedrock', 'from environment', 'env');
|
|
}
|
|
|
|
if (isEnvTruthy(env.CLAUDE_CODE_USE_VERTEX)) {
|
|
return buildProviderState('Vertex AI', 'from environment', 'env');
|
|
}
|
|
|
|
if (isEnvTruthy(env.CLAUDE_CODE_USE_FOUNDRY)) {
|
|
return buildProviderState('Foundry', 'from environment', 'env');
|
|
}
|
|
|
|
if (isEnvTruthy(env.CLAUDE_CODE_USE_OPENAI)) {
|
|
return describeOpenAICompatible(env, 'env');
|
|
}
|
|
|
|
if (shimEnabled) {
|
|
return buildProviderState(
|
|
'OpenAI-compatible (provider unknown)',
|
|
'launch shim enabled',
|
|
'shim',
|
|
);
|
|
}
|
|
|
|
return buildProviderState(
|
|
'Unknown',
|
|
'no saved profile or provider env detected',
|
|
'unknown',
|
|
);
|
|
}
|
|
|
|
module.exports = {
|
|
chooseLaunchWorkspace,
|
|
describeProviderState,
|
|
findCommandPath,
|
|
isPathInsideWorkspace,
|
|
parseProfileFile,
|
|
resolveCommandCheckPath,
|
|
};
|