Impl: tool-npm-package

This commit is contained in:
2026-03-19 19:12:50 +05:30
parent f1b09bf7de
commit ca753cd1ca
17 changed files with 1914 additions and 499 deletions
+140
View File
@@ -0,0 +1,140 @@
import * as readline from "node:readline";
import type { CloseSessionOptions, CloseSessionResult } from "../types";
import { runCommand, getSessionPath, getWorktreesForSession } from "./utils";
/**
* Prompts the user for input on the terminal.
*
* @param question The question to display.
* @returns The user's answer.
*/
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);
});
});
}
/**
* Closes a multi-model tmux session and optionally cleans up worktrees and branches.
*
* When `cleanupWorktrees` is true, removes git worktrees, creates archive tags
* for recovery, and deletes the associated branches.
*
* @param options Close configuration including session name and cleanup flags.
* @returns Result with details about what was cleaned up.
*
* @example
* ```ts
* const result = await closeMultiModel({
* sessionName: "compare",
* cleanupWorktrees: true,
* force: true,
* });
* if (result.success) {
* console.log(`Closed ${result.sessionName}, removed ${result.worktreesRemoved?.length} worktrees`);
* }
* ```
*/
export async function closeMultiModel(options: CloseSessionOptions): Promise<CloseSessionResult> {
const worktreesRemoved: string[] = [];
const branchesDeleted: string[] = [];
const tagsCreated: string[] = [];
const warnings: string[] = [];
try {
// Check if session exists
const { ok: sessionExists } = await runCommand(["tmux", "has-session", "-t", options.sessionName]);
// Get list of worktrees for this session before killing tmux
const worktrees = await getWorktreesForSession(options.sessionName);
// If cleanup requested and not forced, ask for confirmation
if (options.cleanupWorktrees && !options.force && worktrees.length > 0) {
console.log(`\nThe following worktrees and branches will be removed:`);
worktrees.forEach((wt) => console.log(` - ${wt.path} (branch: ${wt.branch})`));
const answer = await promptUser("\nDo you want to proceed? (y/N): ");
if (answer.toLowerCase() !== "y" && answer.toLowerCase() !== "yes") {
return {
success: false,
sessionName: options.sessionName,
error: "Cleanup cancelled by user",
};
}
}
// Kill tmux session if it exists
if (sessionExists) {
await runCommand(["tmux", "kill-session", "-t", options.sessionName]);
}
let cleanupPerformed = false;
// Cleanup worktrees and optionally branches
if (options.cleanupWorktrees) {
for (const worktree of worktrees) {
try {
// Remove worktree
await runCommand(["git", "worktree", "remove", "-f", worktree.path]);
worktreesRemoved.push(worktree.path);
// Create an archive tag before deleting the branch for safe recovery
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const archiveTag = `archive/${worktree.branch}-${timestamp}`;
const tagResult = await runCommand(["git", "tag", archiveTag, worktree.branch]);
if (tagResult.ok) {
tagsCreated.push(archiveTag);
} else {
const warningMsg = `Failed to create tag ${archiveTag} for branch ${worktree.branch}.`;
warnings.push(warningMsg);
console.warn(`Warning: ${warningMsg}`);
}
await runCommand(["git", "branch", "-D", worktree.branch]);
branchesDeleted.push(worktree.branch);
} catch (err) {
console.warn(`Warning: Failed to cleanup worktree ${worktree.path}: ${err}`);
}
}
// Also remove the base directory if empty
const worktreeBase = getSessionPath(options.sessionName);
try {
await runCommand(["rmdir", worktreeBase]);
} catch {
// Ignore errors if directory not empty
}
cleanupPerformed = worktreesRemoved.length > 0;
}
return {
success: true,
sessionName: options.sessionName,
cleanupPerformed,
worktreesRemoved,
branchesDeleted,
tagsCreated,
warnings,
};
} catch (error) {
return {
success: false,
sessionName: options.sessionName,
error: String(error),
worktreesRemoved,
branchesDeleted,
tagsCreated,
warnings,
};
}
}
+3
View File
@@ -0,0 +1,3 @@
export { launchMultiModel } from "./launch";
export { closeMultiModel } from "./close";
export * from "./utils";
+218
View File
@@ -0,0 +1,218 @@
import * as fs from "node:fs";
import type { MultiModelOptions, MultiModelResult } from "../types";
import {
runCommand,
normalizeModels,
findDuplicates,
createWindowPlans,
formatInvalidModelError,
sanitizeName,
getWorktreePath,
getSessionPath,
undoWorktree,
launchModelInWindow,
} from "./utils";
/**
* Launches multiple AI models in a tmux session with git worktrees.
*
* Creates a tmux session with one window per model, each in its own git worktree.
* Validates models against `opencode models` output before launching.
*
* @param options Launch configuration including session name and model list.
* @returns Result with success status, window names, and usage instructions.
*
* @example
* ```ts
* const result = await launchMultiModel({
* sessionName: "compare",
* models: ["openai/gpt-4o", "anthropic/claude-3-5-sonnet"],
* binaryName: "opencode",
* });
* if (result.success) {
* console.log(result.instructions);
* }
* ```
*/
export async function launchMultiModel(options: MultiModelOptions): Promise<MultiModelResult> {
const sessionName = options.sessionName.trim();
const safeSessionName = sanitizeName(sessionName);
const models = normalizeModels(options.models);
const binaryName = options.binaryName || "opencode";
if (!sessionName) {
return { success: false, sessionName: "", error: "Error: `sessionName` must not be empty." };
}
if (models.length === 0) {
return { success: false, sessionName, error: "Error: Model names must not be empty." };
}
const duplicateModels = findDuplicates(models);
if (duplicateModels.length > 0) {
return {
success: false,
sessionName,
error: `Error: Duplicate model names are not allowed: ${duplicateModels.map((item) => `'${item}'`).join(", ")}.`,
};
}
const gitRepoCheck = await runCommand(["git", "rev-parse", "--is-inside-work-tree"]);
if (!gitRepoCheck.ok) {
return { success: false, sessionName, error: "Error: `multi-model` requires a git repository." };
}
const tmuxExists = await runCommand(["command", "-v", "tmux"]);
if (!tmuxExists.ok) {
return { success: false, sessionName, error: "Error: `tmux` is not installed or not on `PATH`." };
}
const binaryExists = await runCommand(["command", "-v", binaryName]);
if (!binaryExists.ok) {
return { success: false, sessionName, error: `Error: \`${binaryName}\` is not installed or not on \`PATH\`.` };
}
const modelListResult = await runCommand([binaryName, "models"]);
if (!modelListResult.ok) {
return {
success: false,
sessionName,
error: `Error: Failed to load valid models from \`${binaryName} models\`${modelListResult.stderr ? `: ${modelListResult.stderr}` : "."}`,
};
}
const allowlist = modelListResult.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const invalidModels = models.filter((model) => !allowlist.includes(model));
if (invalidModels.length > 0) {
return { success: false, sessionName, error: formatInvalidModelError(invalidModels, allowlist) };
}
const sessionExists = await runCommand(["tmux", "has-session", "-t", sessionName]);
if (sessionExists.ok) {
return { success: false, sessionName, error: "Error: Session already exists. Use a different `sessionName`." };
}
const windowPlans = createWindowPlans(models);
const succeededModels: string[] = [];
const failedModels: string[] = [];
for (let i = 0; i < windowPlans.length; i++) {
const plan = windowPlans[i]!;
const isFirst = i === 0;
const worktreePath = getWorktreePath(safeSessionName, plan.windowName);
const branchName = `opencode/${safeSessionName}/${plan.windowName}`;
if (fs.existsSync(worktreePath)) {
if (!isFirst) {
failedModels.push(`${plan.model} (Worktree path already exists at ${worktreePath})`);
continue;
} else {
return { success: false, sessionName, error: `Error: Worktree path already exists at ${worktreePath}. Clean it up first.` };
}
}
const branchExists = await runCommand(["git", "show-ref", "--verify", "--quiet", `refs/heads/${branchName}`]);
if (branchExists.ok) {
if (!isFirst) {
failedModels.push(`${plan.model} (Branch '${branchName}' already exists)`);
continue;
} else {
return { success: false, sessionName, error: `Error: Branch '${branchName}' already exists.` };
}
}
const worktreeResult = await runCommand(["git", "worktree", "add", "-b", branchName, worktreePath]);
if (!worktreeResult.ok) {
if (!isFirst) {
failedModels.push(`${plan.model} (Failed to create worktree: ${worktreeResult.stderr})`);
continue;
} else {
return { success: false, sessionName, error: `Error: Failed to create worktree for ${plan.model}: ${worktreeResult.stderr}` };
}
}
if (isFirst) {
const sessionCreateResult = await runCommand([
"tmux",
"new-session",
"-d",
"-s",
sessionName,
"-n",
plan.windowName,
"-c",
worktreePath,
]);
if (!sessionCreateResult.ok) {
const undoErrors = await undoWorktree(worktreePath, branchName);
let errorMsg = `Error: Failed to create tmux session '${sessionName}'.${sessionCreateResult.stderr ? ` ${sessionCreateResult.stderr}` : ""}`;
if (undoErrors.length > 0) errorMsg += ` Cleanup errors: ${undoErrors.join(", ")}`;
return { success: false, sessionName, error: errorMsg };
}
} else {
const windowCreateResult = await runCommand([
"tmux",
"new-window",
"-d",
"-t",
sessionName,
"-n",
plan.windowName,
"-c",
worktreePath,
]);
if (!windowCreateResult.ok) {
failedModels.push(`${plan.model} (${windowCreateResult.stderr || "failed to create window"})`);
const undoErrors = await undoWorktree(worktreePath, branchName);
if (undoErrors.length > 0) failedModels.push(`cleanup failed for ${plan.model}: ${undoErrors.join(", ")}`);
continue;
}
}
const launchResult = await launchModelInWindow(sessionName, plan, binaryName);
if (launchResult.ok) {
succeededModels.push(plan.model);
} else {
failedModels.push(`${plan.model} (${launchResult.stderr || "failed to send launch command"})`);
const undoErrors = await undoWorktree(worktreePath, branchName);
if (undoErrors.length > 0) failedModels.push(`cleanup failed for ${plan.model}: ${undoErrors.join(", ")}`);
}
}
const windowNames = windowPlans.map((p) => p.windowName);
const attachCommand = `tmux attach -t ${sessionName}`;
const cleanupCommand = `tmux kill-session -t ${sessionName} && rm -rf ~/.local/share/opencode/multi-model/${safeSessionName} && git worktree prune && git branch -D $(git branch --format='%(refname:short)' --list 'opencode/${safeSessionName}/*')`;
if (failedModels.length > 0) {
return {
success: true,
sessionName,
windows: windowNames,
instructions: [
`Error: Created tmux session '${sessionName}', but some model launches failed.`,
`Succeeded: ${succeededModels.length > 0 ? succeededModels.join(", ") : "none"}.`,
`Failed: ${failedModels.join(", ")}.`,
`Attach with \`${attachCommand}\` to inspect the session.`,
`Cleanup with \n\`${cleanupCommand}\`\nwhen done.`,
].join("\n"),
};
}
return {
success: true,
sessionName,
windows: windowNames,
instructions: [
`Use \`${attachCommand}\` to join session.`,
`When finished, clean up worktrees with:`,
`\`${cleanupCommand}\``,
].join("\n"),
};
}
+402
View File
@@ -0,0 +1,402 @@
import * as os from "node:os";
import * as path from "node:path";
import type { CommandResult, WindowLaunchPlan, WorktreeInfo } from "../types";
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.
* @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[]): Promise<CommandResult> {
const result = await Bun.$`${parts}`.quiet().nothrow();
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",
): Promise<CommandResult> {
const launchCommand = `${binaryName} --model ${shellQuote(plan.model)}`;
return runCommand(["tmux", "send-keys", "-t", `${sessionName}:${plan.windowName}`, launchCommand, "C-m"]);
}
/**
* 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-4o");
* // "/home/user/.local/share/opencode/multi-model/my-session/gpt-4o"
* ```
*/
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(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);
}
}
// 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";
}