import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import * as readline from "node:readline"; import ora, { type Ora } from "ora"; import type { CommandResult, MultiModelSettings, RunCommandOptions, WindowLaunchPlan, WorktreeInfo, } from "../types"; let sharedSpinner: Ora | undefined; const MAX_SUGGESTIONS = 3; const WINDOW_NAME_LIMIT = 24; /** * Runs a command and captures its output without throwing on non-zero exit codes. * * @param parts Command segments to pass to the shell. * @param options Additional options for running the command (e.g., mode, spinnerMsg). * @returns The exit status plus captured stdout and stderr. * * @example * ```ts * const result = await runCommand(["command", "-v", "tmux"]); * if (!result.ok) { * return "Error: `tmux` is not installed."; * } * ``` */ export async function runCommand( parts: string[], options?: RunCommandOptions, ): Promise { let spinner: Ora | undefined; const isTest = process.env.NODE_ENV === "test" || process.env.BUN_ENV === "test"; if (options?.mode === "cli" && !isTest) { const msg = options.spinnerMsg || `Running ${parts[0]}...`; if (!sharedSpinner) { sharedSpinner = ora(msg).start(); } else { sharedSpinner.text = msg; if (!sharedSpinner.isSpinning) { sharedSpinner.start(); } } spinner = sharedSpinner; } const result = await Bun.$`${parts}`.quiet().nothrow(); if (spinner) { spinner.stop().clear(); } return { ok: result.exitCode === 0, stdout: result.stdout.toString().trim(), stderr: result.stderr.toString().trim(), }; } /** * Escapes a value for safe use inside a shell command string. * * @param value Raw user-provided value. * @returns A POSIX-safe single-quoted string. * * @example * ```ts * const command = `opencode --model ${shellQuote("openai/gpt-5.4")}`; * ``` */ export function shellQuote(value: string): string { return `'${value.replace(/'/g, `'"'"'`)}'`; } /** * Normalizes requested model ids by trimming whitespace and dropping empty items. * * @param models Raw tool input. * @returns Clean model ids in the original order. * * @example * ```ts * const normalized = normalizeModels([" openai/gpt-5.4 ", ""]); * // ["openai/gpt-5.4"] * ``` */ export function normalizeModels(models: string[] | undefined): string[] { return (models ?? []).map((model) => model.trim()).filter(Boolean); } /** * Finds duplicate values while preserving their first repeated occurrence order. * * @param values Values to inspect. * @returns Duplicate entries exactly once each. * * @example * ```ts * const duplicates = findDuplicates(["a", "b", "a", "b"]); * // ["a", "b"] * ``` */ export function findDuplicates(values: string[]): string[] { const seen = new Set(); const duplicates = new Set(); for (const value of values) { if (seen.has(value)) { duplicates.add(value); continue; } seen.add(value); } return [...duplicates]; } /** * Builds a short tmux-safe window label from a model id. * * @param model Full model id. * @returns A concise window label. * * @example * ```ts * const label = createWindowBaseName("openai/gpt-5.4"); * // "gpt-5-4" * ``` */ export function createWindowBaseName(model: string): string { const preferredPart = model.split("/").at(-1) ?? model; const sanitized = preferredPart .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, WINDOW_NAME_LIMIT); return sanitized || "model"; } /** * Makes window names unique when sanitized model labels collide. * * @param models Validated model ids. * @returns Window plans containing the full model id and unique tmux window name. * * @example * ```ts * const plans = createWindowPlans(["provider/a", "other/a"]); * // [{ model: "provider/a", windowName: "a" }, { model: "other/a", windowName: "a-2" }] * ``` */ export function createWindowPlans(models: string[]): WindowLaunchPlan[] { const counts = new Map(); return models.map((model) => { const baseName = createWindowBaseName(model); const nextCount = (counts.get(baseName) ?? 0) + 1; counts.set(baseName, nextCount); if (nextCount === 1) { return { model, windowName: baseName }; } const suffix = `-${nextCount}`; const trimmedBase = baseName.slice( 0, Math.max(1, WINDOW_NAME_LIMIT - suffix.length), ); return { model, windowName: `${trimmedBase}${suffix}`, }; }); } /** * Computes Levenshtein distance for fuzzy model suggestions. * * @param left First string. * @param right Second string. * @returns Edit distance between the two strings. * * @example * ```ts * const distance = levenshtein("gpt5.4", "gpt-5.4"); * // 1 * ``` */ export function levenshtein(left: string, right: string): number { const row = Array.from({ length: right.length + 1 }, (_, index) => index); for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) { let previous = row[0]; row[0] = leftIndex; for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) { const current = row[rightIndex]; const cost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1; row[rightIndex] = Math.min( row[rightIndex]! + 1, row[rightIndex - 1]! + 1, previous! + cost, ); previous = current; } } return row[right.length]!; } /** * Suggests close model ids for invalid input. * * @param requested Invalid requested model id. * @param allowlist Known valid model ids. * @returns Up to three likely matches ordered by relevance. * * @example * ```ts * const suggestions = suggestModels("openai/gpt5.4", ["openai/gpt-5.4", "openai/gpt-5.4-pro"]); * // ["openai/gpt-5.4", "openai/gpt-5.4-pro"] * ``` */ export function suggestModels( requested: string, allowlist: string[], ): string[] { const normalizedRequested = requested.toLowerCase(); return allowlist .map((candidate) => { const normalizedCandidate = candidate.toLowerCase(); const distance = levenshtein(normalizedRequested, normalizedCandidate); const containsBoost = normalizedCandidate.includes(normalizedRequested) || normalizedRequested.includes(normalizedCandidate) ? -2 : 0; return { candidate, score: distance + containsBoost, }; }) .sort( (left, right) => left.score - right.score || left.candidate.localeCompare(right.candidate), ) .slice(0, MAX_SUGGESTIONS) .map(({ candidate }) => candidate); } /** * Formats invalid model errors with repair hints. * * @param invalidModels Invalid requested model ids. * @param allowlist Known valid model ids. * @returns A user-facing error string. * * @example * ```ts * const message = formatInvalidModelError(["openai/gpt5.4"], ["openai/gpt-5.4"]); * ``` */ export function formatInvalidModelError( invalidModels: string[], allowlist: string[], ): string { const firstInvalidModel = invalidModels[0]!; const suggestions = suggestModels(firstInvalidModel, allowlist); const suggestionText = suggestions.length > 0 ? ` Did you mean ${suggestions.map((item) => `'${item}'`).join(" or ")}?` : " Run `opencode models` and try again."; if (invalidModels.length === 1) { return `Error: Model name '${firstInvalidModel}' not found.${suggestionText}`; } return `Error: Model names not found: ${invalidModels.map((item) => `'${item}'`).join(", ")}.${suggestionText}`; } /** * Launches an OpenCode command in a tmux window. * * @param sessionName Existing tmux session name. * @param plan Window launch plan. * @param binaryName Binary to launch ('opencode' or 'kilo'). * @returns Result describing whether the command was sent successfully. * * @example * ```ts * await launchModelInWindow("demo", { model: "openai/gpt-5.4", windowName: "gpt-5-4" }); * ``` */ export async function launchModelInWindow( sessionName: string, plan: WindowLaunchPlan, binaryName: string = "opencode", mode?: "cli" | "tool", ): Promise { const launchCommand = `${binaryName} --model ${shellQuote(plan.model)}`; return runCommand( [ "tmux", "send-keys", "-t", `${sessionName}:${plan.windowName}`, launchCommand, "C-m", ], { mode, spinnerMsg: `Launching model ${plan.model}...` }, ); } /** * Sanitizes a session name to be safe for paths and branch names. * * @param name Raw session name. * @returns Sanitized name safe for use in paths and git branches. * * @example * ```ts * sanitizeName("test session!"); * // "test-session" * ``` */ export function sanitizeName(name: string): string { return name .replace(/[^a-zA-Z0-9_-]/g, "-") .replace(/-+/g, "-") .replace(/^-|-$/g, ""); } /** * Generates the full absolute path for a session's base directory. * * @param sessionName Sanitized session name. * @returns Absolute path to the session directory. * * @example * ```ts * getSessionPath("my-session"); * // "/home/user/.local/share/opencode/multi-model/my-session" * ``` */ export function getSessionPath(sessionName: string): string { const homedir = os.homedir(); return path.join( homedir, ".local", "share", "opencode", "multi-model", sessionName, ); } /** * Generates the full absolute path for a worktree. * * @param safeSession Sanitized session name. * @param windowName Tmux window name. * @returns Absolute path to the worktree directory. * * @example * ```ts * getWorktreePath("my-session", "gpt-5-2"); * // "/home/user/.local/share/opencode/multi-model/my-session/gpt-5-2" * ``` */ export function getWorktreePath( safeSession: string, windowName: string, ): string { return path.join(getSessionPath(safeSession), windowName); } /** * Retrieves all git worktrees associated with a session. * * @param sessionName Sanitized session name. * @returns Array of worktree info objects filtered to the session. * * @example * ```ts * const worktrees = await getWorktreesForSession("my-session"); * ``` */ export async function getWorktreesForSession( sessionName: string, ): Promise { const { stdout } = await runCommand([ "git", "worktree", "list", "--porcelain", ]); const worktrees: WorktreeInfo[] = []; const sessionPath = getSessionPath(sanitizeName(sessionName)); let currentWorktree: Partial = {}; for (const line of stdout.split("\n")) { if (line.startsWith("worktree ")) { if (currentWorktree.path && currentWorktree.branch) { worktrees.push(currentWorktree as WorktreeInfo); } currentWorktree = { path: line.slice(9), }; } else if (line.startsWith("branch ")) { currentWorktree.branch = line.slice(7).replace(/^refs\/heads\//, ""); } } // Add last worktree if (currentWorktree.path && currentWorktree.branch) { worktrees.push(currentWorktree as WorktreeInfo); } // Filter for session-specific worktrees return worktrees.filter((wt) => wt.path.startsWith(sessionPath)); } /** * Removes a git worktree and deletes its branch on failure. * * @param worktreePath Absolute path to the worktree. * @param branchName Branch name to delete. * @returns Array of error messages (empty if successful). * * @example * ```ts * const errors = await undoWorktree("/path/to/worktree", "opencode/session/window"); * if (errors.length > 0) console.error(errors); * ``` */ export async function undoWorktree( worktreePath: string, branchName: string, ): Promise { const errors: string[] = []; const removeRes = await runCommand([ "git", "worktree", "remove", "-f", worktreePath, ]); if (!removeRes.ok) errors.push( `Failed to remove worktree ${worktreePath}: ${removeRes.stderr}`, ); const branchRes = await runCommand(["git", "branch", "-D", branchName]); if (!branchRes.ok) errors.push(`Failed to delete branch ${branchName}: ${branchRes.stderr}`); return errors; } /** * Determines the binary name to use based on environment variable or default. * * Priority: env var OPENCODE_MULTI_MODEL_BINARY > default ("opencode"). * * @returns Binary name string. * * @example * ```ts * // With OPENCODE_MULTI_MODEL_BINARY=kilo * getBinaryName(); // "kilo" * * // Without env var * getBinaryName(); // "opencode" * ``` */ export function getBinaryName(): string { return process.env.OPENCODE_MULTI_MODEL_BINARY || "opencode"; } export function getSettingsPath(): string { const homedir = os.homedir(); return path.join(homedir, ".config", "opencode-multi-model", "settings.json"); } export async function loadSettings(): Promise { const settingsPath = getSettingsPath(); try { const content = await fs.promises.readFile(settingsPath, "utf-8"); const parsed = JSON.parse(content) as MultiModelSettings; if (!Array.isArray(parsed.lastModels)) { return { lastModels: [] }; } return parsed; } catch { return { lastModels: [] }; } } export async function saveSettings( settings: MultiModelSettings, ): Promise { const settingsPath = getSettingsPath(); const dir = path.dirname(settingsPath); await fs.promises.mkdir(dir, { recursive: true }); await fs.promises.writeFile( settingsPath, JSON.stringify(settings, null, 2), "utf-8", ); } export async function promptUser(question: string): Promise { const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); return new Promise((resolve) => { rl.question(question, (answer) => { rl.close(); resolve(answer); }); }); }