build: add biome formatter and linter

This commit is contained in:
2026-03-23 08:17:27 +05:30
parent 068db04355
commit 7a164c705c
21 changed files with 1620 additions and 510 deletions
+76 -42
View File
@@ -1,7 +1,12 @@
import * as readline from "node:readline";
import chalk from 'chalk'; // removed console usage
import chalk from "chalk"; // removed console usage
import type { CloseSessionOptions, CloseSessionResult } from "../types";
import { runCommand, getSessionPath, getWorktreesForSession, sanitizeName } from "./utils";
import {
getSessionPath,
getWorktreesForSession,
runCommand,
sanitizeName,
} from "./utils";
/**
* Prompts the user for input on the terminal.
@@ -44,24 +49,34 @@ async function promptUser(question: string): Promise<string> {
* }
* ```
*/
export async function closeMultiModel(options: CloseSessionOptions): Promise<CloseSessionResult> {
export async function closeMultiModel(
options: CloseSessionOptions,
): Promise<CloseSessionResult> {
const worktreesRemoved: string[] = [];
const instructionsArr: string[] = [];
const branchesDeleted: string[] = [];
const tagsCreated: string[] = [];
const safeSessionName = sanitizeName(options.sessionName);
try {
// Check if session exists
const { ok: sessionExists } = await runCommand(["tmux", "has-session", "-t", options.sessionName]);
const { ok: sessionExists } = await runCommand([
"tmux",
"has-session",
"-t",
options.sessionName,
]);
// Kill tmux session if it exists
if (sessionExists) {
await runCommand(["tmux", "kill-session", "-t", options.sessionName]);
instructionsArr.push(chalk.green(`Closed session '${options.sessionName}'`));
instructionsArr.push(
chalk.green(`Closed session '${options.sessionName}'`),
);
} else {
instructionsArr.push(chalk.yellow(`Session '${options.sessionName}' does not exist`));
instructionsArr.push(
chalk.yellow(`Session '${options.sessionName}' does not exist`),
);
}
// Get list of worktrees for this session before killing tmux
@@ -69,13 +84,17 @@ export async function closeMultiModel(options: CloseSessionOptions): Promise<Clo
// If cleanup requested and not forced, ask for confirmation
if (options.cleanupWorktrees && !options.force && worktrees.length > 0) {
console.log(chalk.red('The following worktrees and branches will be removed:'));
console.log(
chalk.red("The following worktrees and branches will be removed:"),
);
worktrees.forEach((wt) => {
const msg = ` - ${wt.path} (branch: ${wt.branch})`;
console.log(msg.trim());
});
const answer = await promptUser(chalk.bold("\nDo you want to proceed? (y/N): "));
const answer = await promptUser(
chalk.bold("\nDo you want to proceed? (y/N): "),
);
if (answer.toLowerCase() !== "y" && answer.toLowerCase() !== "yes") {
return {
success: false,
@@ -96,24 +115,33 @@ export async function closeMultiModel(options: CloseSessionOptions): Promise<Clo
await runCommand(["git", "worktree", "remove", "-f", worktree.path]);
worktreesRemoved.push(worktree.path);
// Optionally create an archive tag before deleting the branch for safe recovery
if (options.createArchiveTags !== false) {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const archiveTag = `archive/${worktree.branch}-${timestamp}`;
const tagResult = await runCommand(["git", "tag", archiveTag, worktree.branch]);
// Optionally create an archive tag before deleting the branch for safe recovery
if (options.createArchiveTags !== false) {
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 = chalk.red(`Warning: Failed to create tag ${archiveTag} for branch ${worktree.branch}.`);
instructionsArr.push(warningMsg);
}
}
if (tagResult.ok) {
tagsCreated.push(archiveTag);
} else {
const warningMsg = chalk.red(
`Warning: Failed to create tag ${archiveTag} for branch ${worktree.branch}.`,
);
instructionsArr.push(warningMsg);
}
}
await runCommand(["git", "branch", "-D", worktree.branch]);
branchesDeleted.push(worktree.branch);
} catch (err) {
instructionsArr.push(chalk.red(`Failed to cleanup worktree ${worktree.path}: ${err}`));
instructionsArr.push(
chalk.red(`Failed to cleanup worktree ${worktree.path}: ${err}`),
);
}
}
@@ -127,31 +155,37 @@ export async function closeMultiModel(options: CloseSessionOptions): Promise<Clo
cleanupPerformed = worktreesRemoved.length > 0;
if (cleanupPerformed) {
instructionsArr.push(chalk.dim(`Removed ${worktreesRemoved.length} worktrees, alongwith their branches.`));
instructionsArr.push(
chalk.dim(
`Removed ${worktreesRemoved.length} worktrees, alongwith their branches.`,
),
);
if (tagsCreated.length > 0) {
instructionsArr.push(chalk.dim(`Created archive tags: ${tagsCreated.join(", ")}.`));
instructionsArr.push(
chalk.dim(`Created archive tags: ${tagsCreated.join(", ")}.`),
);
}
}
}
return {
success: true,
sessionName: safeSessionName,
cleanupPerformed,
worktreesRemoved,
branchesDeleted,
tagsCreated,
instructions: instructionsArr.join("\n"),
};
return {
success: true,
sessionName: safeSessionName,
cleanupPerformed,
worktreesRemoved,
branchesDeleted,
tagsCreated,
instructions: instructionsArr.join("\n"),
};
} catch (error) {
return {
success: false,
sessionName: safeSessionName,
error: String(error),
worktreesRemoved,
branchesDeleted,
tagsCreated,
instructions: instructionsArr.join("\n"),
};
return {
success: false,
sessionName: safeSessionName,
error: String(error),
worktreesRemoved,
branchesDeleted,
tagsCreated,
instructions: instructionsArr.join("\n"),
};
}
}
+1 -1
View File
@@ -1,3 +1,3 @@
export { openMultiModel } from "./open";
export { closeMultiModel } from "./close";
export { openMultiModel } from "./open";
export * from "./utils";
+121 -34
View File
@@ -1,17 +1,17 @@
import * as fs from "node:fs";
import chalk from 'chalk';
import chalk from "chalk";
import type { MultiModelOptions, MultiModelResult } from "../types";
import {
runCommand,
normalizeModels,
findDuplicates,
createWindowPlans,
findDuplicates,
formatInvalidModelError,
sanitizeName,
getWorktreePath,
getSessionPath,
undoWorktree,
getWorktreePath,
launchModelInWindow,
normalizeModels,
runCommand,
sanitizeName,
undoWorktree,
} from "./utils";
/**
@@ -35,7 +35,9 @@ import {
* }
* ```
*/
export async function openMultiModel(options: MultiModelOptions): Promise<MultiModelResult> {
export async function openMultiModel(
options: MultiModelOptions,
): Promise<MultiModelResult> {
const sessionName = options.sessionName.trim();
const safeSessionName = sanitizeName(sessionName);
const models = normalizeModels(options.models);
@@ -43,11 +45,19 @@ export async function openMultiModel(options: MultiModelOptions): Promise<MultiM
const mode = options.mode || "cli";
if (!sessionName) {
return { success: false, sessionName: "", error: "Error: `sessionName` must not be empty." };
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." };
return {
success: false,
sessionName,
error: "Error: Model names must not be empty.",
};
}
const duplicateModels = findDuplicates(models);
@@ -59,19 +69,35 @@ export async function openMultiModel(options: MultiModelOptions): Promise<MultiM
};
}
const gitRepoCheck = await runCommand(["git", "rev-parse", "--is-inside-work-tree"]);
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." };
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`." };
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\`.` };
return {
success: false,
sessionName,
error: `Error: \`${binaryName}\` is not installed or not on \`PATH\`.`,
};
}
const modelListResult = await runCommand([binaryName, "models"]);
@@ -90,12 +116,25 @@ export async function openMultiModel(options: MultiModelOptions): Promise<MultiM
const invalidModels = models.filter((model) => !allowlist.includes(model));
if (invalidModels.length > 0) {
return { success: false, sessionName, error: formatInvalidModelError(invalidModels, allowlist) };
return {
success: false,
sessionName,
error: formatInvalidModelError(invalidModels, allowlist),
};
}
const sessionExists = await runCommand(["tmux", "has-session", "-t", sessionName]);
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`." };
return {
success: false,
sessionName,
error: "Error: Session already exists. Use a different `sessionName`.",
};
}
const windowPlans = createWindowPlans(models);
@@ -111,30 +150,61 @@ export async function openMultiModel(options: MultiModelOptions): Promise<MultiM
if (fs.existsSync(worktreePath)) {
if (!isFirst) {
failedModels.push(`${plan.model} (Worktree path already exists at ${worktreePath})`);
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.` };
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}`]);
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)`);
failedModels.push(
`${plan.model} (Branch '${branchName}' already exists)`,
);
continue;
} else {
return { success: false, sessionName, error: `Error: Branch '${branchName}' already exists.` };
return {
success: false,
sessionName,
error: `Error: Branch '${branchName}' already exists.`,
};
}
}
const worktreeResult = await runCommand(["git", "worktree", "add", "-b", branchName, worktreePath]);
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})`);
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}` };
return {
success: false,
sessionName,
error: `Error: Failed to create worktree for ${plan.model}: ${worktreeResult.stderr}`,
};
}
}
@@ -173,22 +243,35 @@ export async function openMultiModel(options: MultiModelOptions): Promise<MultiM
]);
if (!windowCreateResult.ok) {
failedModels.push(`${plan.model} (${windowCreateResult.stderr || "failed to create window"})`);
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(", ")}`);
failedModels.push(
`cleanup failed for ${plan.model}: ${undoErrors.join(", ")}`,
);
}
continue;
}
}
const launchResult = await launchModelInWindow(sessionName, plan, binaryName);
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"})`);
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(", ")}`);
if (undoErrors.length > 0)
failedModels.push(
`cleanup failed for ${plan.model}: ${undoErrors.join(", ")}`,
);
}
}
@@ -206,9 +289,13 @@ export async function openMultiModel(options: MultiModelOptions): Promise<MultiM
sessionName,
windows: windowNames,
instructions: [
chalk.red(`Error: Created tmux session '${sessionName}', but some model launches failed.`),
chalk.red(
`Error: Created tmux session '${sessionName}', but some model launches failed.`,
),
chalk.red(`Failed: ${failedModels.join(", ")}.`),
chalk.dim(`Succeeded: ${succeededModels.length > 0 ? succeededModels.join(", ") : "none"}.`),
chalk.dim(
`Succeeded: ${succeededModels.length > 0 ? succeededModels.join(", ") : "none"}.`,
),
"",
chalk.bold(`Attach with \`${attachCommand}\` to inspect the session.`),
"",
@@ -216,7 +303,7 @@ export async function openMultiModel(options: MultiModelOptions): Promise<MultiM
primaryCleanup,
"",
chalk.dim(`Alternate manual cleanup - `),
chalk.dim(cleanupCommand)
chalk.dim(cleanupCommand),
].join("\n"),
};
}
@@ -232,7 +319,7 @@ export async function openMultiModel(options: MultiModelOptions): Promise<MultiM
primaryCleanup,
"",
chalk.dim(`Alternate manual cleanup - `),
chalk.dim(cleanupCommand)
chalk.dim(cleanupCommand),
].join("\n"),
};
}
+67 -14
View File
@@ -136,7 +136,10 @@ export function createWindowPlans(models: string[]): WindowLaunchPlan[] {
}
const suffix = `-${nextCount}`;
const trimmedBase = baseName.slice(0, Math.max(1, WINDOW_NAME_LIMIT - suffix.length));
const trimmedBase = baseName.slice(
0,
Math.max(1, WINDOW_NAME_LIMIT - suffix.length),
);
return {
model,
@@ -194,7 +197,10 @@ export function levenshtein(left: string, right: string): number {
* // ["openai/gpt-5.4", "openai/gpt-5.4-pro"]
* ```
*/
export function suggestModels(requested: string, allowlist: string[]): string[] {
export function suggestModels(
requested: string,
allowlist: string[],
): string[] {
const normalizedRequested = requested.toLowerCase();
return allowlist
@@ -212,7 +218,11 @@ export function suggestModels(requested: string, allowlist: string[]): string[]
score: distance + containsBoost,
};
})
.sort((left, right) => left.score - right.score || left.candidate.localeCompare(right.candidate))
.sort(
(left, right) =>
left.score - right.score ||
left.candidate.localeCompare(right.candidate),
)
.slice(0, MAX_SUGGESTIONS)
.map(({ candidate }) => candidate);
}
@@ -229,7 +239,10 @@ export function suggestModels(requested: string, allowlist: string[]): string[]
* const message = formatInvalidModelError(["openai/gpt5.4"], ["openai/gpt-5.4"]);
* ```
*/
export function formatInvalidModelError(invalidModels: string[], allowlist: string[]): string {
export function formatInvalidModelError(
invalidModels: string[],
allowlist: string[],
): string {
const firstInvalidModel = invalidModels[0]!;
const suggestions = suggestModels(firstInvalidModel, allowlist);
const suggestionText =
@@ -264,7 +277,14 @@ export async function launchModelInWindow(
): Promise<CommandResult> {
const launchCommand = `${binaryName} --model ${shellQuote(plan.model)}`;
return runCommand(["tmux", "send-keys", "-t", `${sessionName}:${plan.windowName}`, launchCommand, "C-m"]);
return runCommand([
"tmux",
"send-keys",
"-t",
`${sessionName}:${plan.windowName}`,
launchCommand,
"C-m",
]);
}
/**
@@ -280,7 +300,10 @@ export async function launchModelInWindow(
* ```
*/
export function sanitizeName(name: string): string {
return name.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
return name
.replace(/[^a-zA-Z0-9_-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
/**
@@ -297,7 +320,14 @@ export function sanitizeName(name: string): string {
*/
export function getSessionPath(sessionName: string): string {
const homedir = os.homedir();
return path.join(homedir, ".local", "share", "opencode", "multi-model", sessionName);
return path.join(
homedir,
".local",
"share",
"opencode",
"multi-model",
sessionName,
);
}
/**
@@ -313,7 +343,10 @@ export function getSessionPath(sessionName: string): string {
* // "/home/user/.local/share/opencode/multi-model/my-session/gpt-5-2"
* ```
*/
export function getWorktreePath(safeSession: string, windowName: string): string {
export function getWorktreePath(
safeSession: string,
windowName: string,
): string {
return path.join(getSessionPath(safeSession), windowName);
}
@@ -328,8 +361,15 @@ export function getWorktreePath(safeSession: string, windowName: string): string
* const worktrees = await getWorktreesForSession("my-session");
* ```
*/
export async function getWorktreesForSession(sessionName: string): Promise<WorktreeInfo[]> {
const { stdout } = await runCommand(["git", "worktree", "list", "--porcelain"]);
export async function getWorktreesForSession(
sessionName: string,
): Promise<WorktreeInfo[]> {
const { stdout } = await runCommand([
"git",
"worktree",
"list",
"--porcelain",
]);
const worktrees: WorktreeInfo[] = [];
const sessionPath = getSessionPath(sanitizeName(sessionName));
@@ -371,13 +411,26 @@ export async function getWorktreesForSession(sessionName: string): Promise<Workt
* if (errors.length > 0) console.error(errors);
* ```
*/
export async function undoWorktree(worktreePath: string, branchName: string): Promise<string[]> {
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 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}`);
if (!branchRes.ok)
errors.push(`Failed to delete branch ${branchName}: ${branchRes.stderr}`);
return errors;
}