179 lines
6.1 KiB
JavaScript
179 lines
6.1 KiB
JavaScript
import { execFile } from "node:child_process";
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { promisify } from "node:util";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const HOME = process.env.HOME || "/home/ai";
|
|
const CC_SWITCH_DATABASE = path.join(HOME, ".cc-switch", "cc-switch.db");
|
|
const ANSI = /\u001b\[[0-?]*[ -/]*[@-~]/g;
|
|
const SAFE_ID = /^[a-zA-Z0-9._-]{1,128}$/;
|
|
|
|
export const CC_SWITCH_APPS = Object.freeze([
|
|
{ id: "claude", toolId: "claude", name: "Claude Code" },
|
|
{ id: "codex", toolId: "codex", name: "Codex" },
|
|
{ id: "open-code", toolId: "opencode", name: "OpenCode" },
|
|
]);
|
|
|
|
export const MULTICA_TOOL_IDS = Object.freeze(["claude", "codex", "codebuddy", "kimi", "opencode"]);
|
|
|
|
function clean(value) {
|
|
return String(value || "").replace(ANSI, "").trim();
|
|
}
|
|
|
|
function publicEndpoint(value) {
|
|
const candidate = clean(value);
|
|
if (!candidate || candidate === "N/A") return null;
|
|
try {
|
|
const url = new URL(candidate);
|
|
return url.origin;
|
|
} catch {
|
|
return "configured";
|
|
}
|
|
}
|
|
|
|
export function parseCcProviderList(output) {
|
|
const providers = [];
|
|
let currentProviderId = null;
|
|
for (const rawLine of clean(output).split(/\r?\n/)) {
|
|
const line = rawLine.trim();
|
|
const current = line.match(/^→\s*Current:\s*(\S+)/);
|
|
if (current) currentProviderId = current[1];
|
|
if (!line.startsWith("│") || !line.includes("┆")) continue;
|
|
const columns = line.slice(1, line.endsWith("│") ? -1 : undefined).split("┆").map(clean);
|
|
if (columns.length < 4 || columns[1] === "ID") continue;
|
|
const [marker, id, name, endpoint] = columns;
|
|
if (!SAFE_ID.test(id)) continue;
|
|
providers.push({
|
|
id,
|
|
name: name || id,
|
|
endpoint: publicEndpoint(endpoint),
|
|
active: marker.includes("✓"),
|
|
});
|
|
}
|
|
if (!currentProviderId) currentProviderId = providers.find((provider) => provider.active)?.id || null;
|
|
return { currentProviderId, providers };
|
|
}
|
|
|
|
export function parseMulticaConfig(output) {
|
|
const values = {};
|
|
for (const line of clean(output).split(/\r?\n/)) {
|
|
const match = line.match(/^([a-z_]+):\s*(.*)$/);
|
|
if (match) values[match[1]] = match[2] === "(not set)" ? null : match[2];
|
|
}
|
|
return {
|
|
serverUrl: values.server_url || null,
|
|
appUrl: values.app_url || null,
|
|
workspaceId: values.workspace_id || null,
|
|
configured: Boolean(values.server_url && values.workspace_id),
|
|
};
|
|
}
|
|
|
|
async function run(command, args, options = {}) {
|
|
const { stdout, stderr } = await execFileAsync(command, args, {
|
|
timeout: options.timeout || 10000,
|
|
maxBuffer: options.maxBuffer || 1024 * 1024,
|
|
env: { ...process.env, NO_COLOR: "1", TERM: "dumb" },
|
|
});
|
|
return clean(stdout || stderr);
|
|
}
|
|
|
|
async function versionOf(command, args = ["--version"]) {
|
|
try {
|
|
const output = await run(command, args, { timeout: 8000 });
|
|
return { installed: true, version: output.split(/\r?\n/)[0] || "installed" };
|
|
} catch (error) {
|
|
if (error.code === "ENOENT") return { installed: false, version: null };
|
|
return { installed: true, version: "unknown", error: error.message };
|
|
}
|
|
}
|
|
|
|
async function ccSwitchInitialized() {
|
|
try {
|
|
await fs.access(CC_SWITCH_DATABASE);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function ccSwitchSnapshot() {
|
|
const availability = await versionOf("cc-switch");
|
|
const initialized = availability.installed && await ccSwitchInitialized();
|
|
const apps = await Promise.all(CC_SWITCH_APPS.map(async (app) => {
|
|
if (!initialized) return { ...app, currentProviderId: null, providers: [] };
|
|
try {
|
|
return { ...app, ...parseCcProviderList(await run("cc-switch", ["--app", app.id, "provider", "list"])) };
|
|
} catch (error) {
|
|
return { ...app, currentProviderId: null, providers: [], error: error.message };
|
|
}
|
|
}));
|
|
return {
|
|
...availability,
|
|
initialized,
|
|
apps,
|
|
unsupportedToolIds: ["codebuddy", "kimi", "qwen", "dsh"],
|
|
};
|
|
}
|
|
|
|
async function multicaSnapshot() {
|
|
const availability = await versionOf("multica", ["version"]);
|
|
if (!availability.installed) {
|
|
return { ...availability, daemon: { status: "unavailable" }, config: { configured: false }, supportedToolIds: MULTICA_TOOL_IDS };
|
|
}
|
|
let daemon = { status: "unknown" };
|
|
let config = { configured: false, serverUrl: null, appUrl: null, workspaceId: null };
|
|
try {
|
|
daemon = JSON.parse(await run("multica", ["daemon", "status", "--output", "json"]));
|
|
} catch (error) {
|
|
daemon = { status: "unknown", error: error.message };
|
|
}
|
|
try {
|
|
config = parseMulticaConfig(await run("multica", ["config", "show"]));
|
|
} catch (error) {
|
|
config = { ...config, error: error.message };
|
|
}
|
|
return { ...availability, daemon, config, supportedToolIds: MULTICA_TOOL_IDS };
|
|
}
|
|
|
|
let cachedSnapshot = null;
|
|
let cachedAt = 0;
|
|
|
|
export async function integrationSnapshot({ force = false } = {}) {
|
|
if (!force && cachedSnapshot && Date.now() - cachedAt < 5000) return cachedSnapshot;
|
|
const [ccSwitch, multica] = await Promise.all([ccSwitchSnapshot(), multicaSnapshot()]);
|
|
cachedSnapshot = { ccSwitch, multica };
|
|
cachedAt = Date.now();
|
|
return cachedSnapshot;
|
|
}
|
|
|
|
function validateCcSwitchApp(appId) {
|
|
if (!CC_SWITCH_APPS.some((app) => app.id === appId)) throw new Error("Unsupported CC Switch application");
|
|
}
|
|
|
|
let mutationQueue = Promise.resolve();
|
|
function serializeMutation(operation) {
|
|
const result = mutationQueue.then(operation, operation);
|
|
mutationQueue = result.catch(() => {});
|
|
return result;
|
|
}
|
|
|
|
export async function switchCcProvider(appId, providerId) {
|
|
validateCcSwitchApp(appId);
|
|
if (!SAFE_ID.test(providerId || "")) throw new Error("Invalid provider ID");
|
|
return serializeMutation(async () => {
|
|
const output = await run("cc-switch", ["--app", appId, "provider", "switch", providerId], { timeout: 30000 });
|
|
cachedSnapshot = null;
|
|
return { ok: true, output: output.split(/\r?\n/).slice(0, 4).join("\n") };
|
|
});
|
|
}
|
|
|
|
export async function importCcLiveConfig(appId) {
|
|
validateCcSwitchApp(appId);
|
|
return serializeMutation(async () => {
|
|
const output = await run("cc-switch", ["--app", appId, "provider", "import-live"], { timeout: 30000 });
|
|
cachedSnapshot = null;
|
|
return { ok: true, output: output.split(/\r?\n/).slice(0, 6).join("\n") };
|
|
});
|
|
}
|