mirror of
https://github.com/bendtherules/opencode-multi-model.git
synced 2026-08-18 13:42:21 +00:00
541 lines
14 KiB
TypeScript
541 lines
14 KiB
TypeScript
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<CommandResult> {
|
|
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<string>();
|
|
const duplicates = new Set<string>();
|
|
|
|
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<string, number>();
|
|
|
|
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<CommandResult> {
|
|
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<WorktreeInfo[]> {
|
|
const { stdout } = await runCommand([
|
|
"git",
|
|
"worktree",
|
|
"list",
|
|
"--porcelain",
|
|
]);
|
|
const worktrees: WorktreeInfo[] = [];
|
|
|
|
const sessionPath = getSessionPath(sanitizeName(sessionName));
|
|
|
|
let currentWorktree: Partial<WorktreeInfo> = {};
|
|
|
|
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<string[]> {
|
|
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<MultiModelSettings> {
|
|
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<void> {
|
|
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<string> {
|
|
const rl = readline.createInterface({
|
|
input: process.stdin,
|
|
output: process.stdout,
|
|
});
|
|
|
|
return new Promise((resolve) => {
|
|
rl.question(question, (answer) => {
|
|
rl.close();
|
|
resolve(answer);
|
|
});
|
|
});
|
|
}
|