From ca753cd1ca1daa9a073429182defdd252014d0f9 Mon Sep 17 00:00:00 2001 From: bendtherules Date: Thu, 19 Mar 2026 19:12:50 +0530 Subject: [PATCH] Impl: tool-npm-package --- .../1773656570427-mighty-tiger.md | 0 .opencode/tools/multi-model.ts | 504 +--------------- LICENSE | 21 + README.md | 168 ++++++ bun.lock | 37 ++ package.json | 49 ++ src/cli.ts | 80 +++ src/core/close.ts | 140 +++++ src/core/index.ts | 3 + src/core/launch.ts | 218 +++++++ src/core/utils.ts | 402 +++++++++++++ src/index.ts | 32 + src/tools/close.ts | 41 ++ src/tools/open.ts | 43 ++ src/types.ts | 93 +++ tests/multi-model.test.ts | 559 ++++++++++++++++++ tsconfig.json | 23 + 17 files changed, 1914 insertions(+), 499 deletions(-) rename .opencode/plans/{ => archive}/1773656570427-mighty-tiger.md (100%) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 bun.lock create mode 100644 package.json create mode 100644 src/cli.ts create mode 100644 src/core/close.ts create mode 100644 src/core/index.ts create mode 100644 src/core/launch.ts create mode 100644 src/core/utils.ts create mode 100644 src/index.ts create mode 100644 src/tools/close.ts create mode 100644 src/tools/open.ts create mode 100644 src/types.ts create mode 100644 tests/multi-model.test.ts create mode 100644 tsconfig.json diff --git a/.opencode/plans/1773656570427-mighty-tiger.md b/.opencode/plans/archive/1773656570427-mighty-tiger.md similarity index 100% rename from .opencode/plans/1773656570427-mighty-tiger.md rename to .opencode/plans/archive/1773656570427-mighty-tiger.md diff --git a/.opencode/tools/multi-model.ts b/.opencode/tools/multi-model.ts index 2a4e015..db28ab7 100644 --- a/.opencode/tools/multi-model.ts +++ b/.opencode/tools/multi-model.ts @@ -1,500 +1,6 @@ -import { tool } from "@opencode-ai/plugin"; -import * as os from "node:os"; -import * as path from "node:path"; -import * as fs from "node:fs"; +// Re-export open and close tools for project-level use via relative paths. +// For published package usage, change imports to "@username/opencode-multi-model". +import { openTool } from "../../src/tools/open"; +import { closeTool } from "../../src/tools/close"; -type WindowLaunchPlan = { - model: string; - windowName: string; -}; - -type CommandResult = { - ok: boolean; - stdout: string; - stderr: string; -}; - -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."; - * } - * ``` - */ -async function runCommand(parts: string[]): Promise { - 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")}`; - * ``` - */ -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"] - * ``` - */ -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"] - * ``` - */ -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" - * ``` - */ -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" }] - * ``` - */ -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 - * ``` - */ -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"] - * ``` - */ -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"]); - * ``` - */ -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. - * @returns Result describing whether the command was sent successfully. - * - * @example - * ```ts - * await launchModelInWindow("demo", { model: "openai/gpt-5.4", windowName: "gpt-5-4" }); - * ``` - */ -async function launchModelInWindow(sessionName: string, plan: WindowLaunchPlan): Promise { - const launchCommand = `opencode --model ${shellQuote(plan.model)}`; - - // Send the exact command text into the pane so tmux keeps the user's normal shell setup. - return runCommand(["tmux", "send-keys", "-t", `${sessionName}:${plan.windowName}`, launchCommand, "C-m"]); -} - -/** - * Sanitizes a session name to be safe for paths and branch names. - */ -function sanitizeName(name: string): string { - return name.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""); -} - -/** - * Generates the full absolute path for a worktree. - */ -function getWorktreePath(safeSession: string, windowName: string): string { - return path.join(os.homedir(), ".local/share/opencode/multi-model", safeSession, windowName); -} - -/** - * Removes a git worktree and deletes its branch on failure. - */ -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; -} - -export default tool({ - description: "Launch multiple OpenCode models in tmux", - args: { - sessionName: tool.schema.string().min(1).describe("tmux session name to create"), - models: tool.schema - .array(tool.schema.string().min(1)) - .min(1) - .describe("one or more OpenCode model ids to launch"), - }, - async execute(args, context) { - const sessionName = args.sessionName.trim(); - const safeSessionName = sanitizeName(sessionName); - const models = normalizeModels(args.models); - - if (!sessionName) { - return "Error: `sessionName` must not be empty."; - } - - if (models.length === 0) { - return "Error: Model names must not be empty."; - } - - const duplicateModels = findDuplicates(models); - if (duplicateModels.length > 0) { - return `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 "Error: `multi-model` requires a git repository."; - } - - const tmuxExists = await runCommand(["command", "-v", "tmux"]); - if (!tmuxExists.ok) { - return "Error: `tmux` is not installed or not on `PATH`."; - } - - const opencodeExists = await runCommand(["command", "-v", "opencode"]); - if (!opencodeExists.ok) { - return "Error: `opencode` is not installed or not on `PATH`."; - } - - const modelListResult = await runCommand(["opencode", "models"]); - if (!modelListResult.ok) { - return `Error: Failed to load valid models from \`opencode 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 formatInvalidModelError(invalidModels, allowlist); - } - - const sessionExists = await runCommand(["tmux", "has-session", "-t", sessionName]); - if (sessionExists.ok) { - return "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 `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 `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 `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 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); - 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 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}/*')`; - - context.metadata({ - title: `multi-model ${sessionName}`, - metadata: { - safeSessionName, - modelCount: models.length, - }, - }); - - if (failedModels.length > 0) { - return [ - `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 [ - `Use \`${attachCommand}\` to join session.`, - `When finished, clean up worktrees with:`, - `\`${cleanupCommand}\`` - ].join("\n"); - }, -}); - -export const __testing_helpers = { - shellQuote, - normalizeModels, - findDuplicates, - createWindowBaseName, - createWindowPlans, - levenshtein, - suggestModels, - formatInvalidModelError, - sanitizeName, - runCommand, - getWorktreePath, - undoWorktree, - launchModelInWindow, -}; +export { openTool, closeTool }; diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..14fac91 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..40a5dd6 --- /dev/null +++ b/README.md @@ -0,0 +1,168 @@ +# opencode-multi-model + +Launch multiple AI models in tmux sessions. Available as: +- OpenCode Plugin +- Standalone CLI +- Project-level tool + +## Installation + +### As OpenCode Plugin + +Add to your OpenCode config (`opencode.json`): + +```json +{ + "plugin": ["@username/opencode-multi-model"] +} +``` + +### As Standalone CLI + +```bash +# Using npx (no install) +npx @username/opencode-multi-model open my-session -m openai/gpt-4o anthropic/claude-3-5-sonnet + +# Or install globally +npm install -g @username/opencode-multi-model +opencode-multi-model open my-session -m openai/gpt-4o +``` + +### As Project Tool + +If you installed it via NPM and want to use it as a project tool, import it from the package: + +```typescript +// .opencode/tools/multi-model.ts +import { openTool, closeTool } from "@username/opencode-multi-model" +export { openTool, closeTool } +``` + +If you are developing this package locally and want to test it in a project without publishing, import via relative paths: + +```typescript +// .opencode/tools/multi-model.ts +import { openTool } from "../../src/tools/open" +import { closeTool } from "../../src/tools/close" +export { openTool, closeTool } +``` + +## Usage + +### Plugin Usage + +**Open a session:** +``` +Use multi-model-open tool: +sessionName: my-session, models: ["openai/gpt-4o", "anthropic/claude-3-5-sonnet"] +``` + +**Close a session:** +``` +Use multi-model-close tool: +sessionName: my-session, cleanupWorktrees: true +``` + +### CLI Usage + +**Open a session:** +```bash +# Basic usage +opencode-multi-model open my-session -m openai/gpt-4o anthropic/claude-3-5-sonnet + +# With custom binary (via flag) +opencode-multi-model open my-session -m openai/gpt-4o -b kilo + +# With custom binary (via env) +export OPENCODE_MULTI_MODEL_BINARY=kilo +opencode-multi-model open my-session -m openai/gpt-4o +``` + +**Close a session:** +```bash +# Just close the tmux session +opencode-multi-model close my-session + +# Close and cleanup worktrees +opencode-multi-model close my-session --cleanup-worktrees + +# Force cleanup without confirmation +opencode-multi-model close my-session --cleanup-worktrees --force +``` + +**Help:** +```bash +opencode-multi-model --help +opencode-multi-model open --help +opencode-multi-model close --help +``` + +## Configuration + +### Custom Binary + +By default uses `opencode`. To use `kilo` instead: + +**Via environment variable:** +```bash +export OPENCODE_MULTI_MODEL_BINARY=kilo +``` + +**Via CLI flag:** +```bash +opencode-multi-model open my-session -m openai/gpt-4o -b kilo +``` + +Priority: CLI flag > Environment variable > Default ("opencode") + +## Requirements + +- tmux +- git +- opencode or kilo binary +- Bun runtime + +## Development + +For local development and testing without publishing: + +```bash +# Install dependencies +bun install + +# Build +bun run build + +# Run tests +bun test + +# Test CLI locally +bun dist/cli.js open test-session -m openai/gpt-4o +bun dist/cli.js close test-session +``` + +## Architecture + +``` +├── src/ +│ ├── index.ts # Plugin entry point +│ ├── cli.ts # CLI entry point +│ ├── types.ts # Shared TypeScript types +│ ├── core/ +│ │ ├── index.ts # Core exports +│ │ ├── launch.ts # Launch session logic +│ │ ├── close.ts # Close session logic +│ │ └── utils.ts # Helper functions +│ └── tools/ +│ ├── open.ts # Open tool definition +│ └── close.ts # Close tool definition +├── tests/ +│ └── multi-model.test.ts # Tests +├── package.json +├── tsconfig.json +└── README.md +``` + +## License + +MIT diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..3dc2b7a --- /dev/null +++ b/bun.lock @@ -0,0 +1,37 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@username/opencode-multi-model", + "dependencies": { + "@opencode-ai/plugin": "^1.2.27", + "commander": "^12.0.0", + }, + "devDependencies": { + "@types/bun": "^1.3.10", + "@types/node": "^25.5.0", + "typescript": "^5.0.0", + }, + }, + }, + "packages": { + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.2.27", "", { "dependencies": { "@opencode-ai/sdk": "1.2.27", "zod": "4.1.8" } }, "sha512-h+8Bw9v9nghMg7T+SUCTzxlIhOrsTqXW7U0HVLGQST5DjbN7uyCUM51roZWZ8LRjGxzbzFhvPnY1bj8i+ioZyw=="], + + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.2.27", "", {}, "sha512-Wk0o/I+Fo+wE3zgvlJDs8Fb67KlKqX0PrV8dK5adSDkANq6r4Z25zXJg2iOir+a8ntg3rAcpel1OY4FV/TwRUA=="], + + "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + + "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + + "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..55dc81c --- /dev/null +++ b/package.json @@ -0,0 +1,49 @@ +{ + "name": "@username/opencode-multi-model", + "version": "1.0.0", + "description": "Launch multiple AI models in tmux sessions - OpenCode Plugin, CLI, and Project Tool", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "bin": { + "opencode-multi-model": "./dist/cli.js" + }, + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./core": { + "import": "./dist/core/index.js", + "types": "./dist/core/index.d.ts" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "bun build src/index.ts src/cli.ts --outdir dist --target bun --format esm && tsc --emitDeclarationOnly --outDir dist", + "test": "bun test", + "typecheck": "tsc --noEmit", + "prepublishOnly": "bun run build" + }, + "dependencies": { + "@opencode-ai/plugin": "^1.2.27", + "commander": "^12.0.0" + }, + "devDependencies": { + "@types/bun": "^1.3.10", + "@types/node": "^25.5.0", + "typescript": "^5.0.0" + }, + "keywords": [ + "opencode", + "multi-model", + "tmux", + "ai", + "plugin" + ], + "license": "MIT" +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..6416435 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,80 @@ +#!/usr/bin/env bun +import { Command } from "commander"; +import { launchMultiModel } from "./core/launch"; +import { closeMultiModel } from "./core/close"; +import { getBinaryName } from "./core/utils"; + +const program = new Command(); + +program + .name("opencode-multi-model") + .description("Launch multiple OpenCode models in tmux sessions") + .version("1.0.0"); + +program + .command("open") + .description("Create a new multi-model tmux session") + .argument("", "Name for the tmux session") + .option("-m, --models ", "Model IDs to launch (space-separated)", []) + .option("-b, --binary ", "Binary to use (opencode or kilo). Defaults to env var OPENCODE_MULTI_MODEL_BINARY or 'opencode'") + .action(async (sessionName: string, options: { models: string[]; binary?: string }) => { + try { + const binaryName = options.binary || getBinaryName(); + + const result = await launchMultiModel({ + sessionName, + models: options.models, + binaryName, + }); + + if (result.success) { + console.log(result.instructions || `Created session: ${result.sessionName}`); + } else { + console.error(`Failed: ${result.error}`); + process.exit(1); + } + } catch (error) { + console.error(`Error: ${error}`); + process.exit(1); + } + }); + +program + .command("close") + .description("Close a multi-model tmux session") + .argument("", "Name of the tmux session to close") + .option("-c, --cleanup-worktrees", "Remove worktrees and delete branches", true) + .option("-f, --force", "Skip confirmation prompts", false) + .action(async (sessionName: string, options: { cleanupWorktrees: boolean; force: boolean }) => { + try { + const result = await closeMultiModel({ + sessionName, + cleanupWorktrees: options.cleanupWorktrees, + force: options.force, + }); + + if (result.success) { + console.log(`Closed session: ${result.sessionName}`); + if (result.cleanupPerformed) { + console.log(` Removed ${result.worktreesRemoved?.length || 0} worktrees`); + if (result.tagsCreated && result.tagsCreated.length > 0) { + console.log(` Created backup tags: ${result.tagsCreated.join(", ")}`); + } + if (result.warnings && result.warnings.length > 0) { + console.log(` Warnings: ${result.warnings.join("; ")}`); + } + if (result.branchesDeleted && result.branchesDeleted.length > 0) { + console.log(` Deleted ${result.branchesDeleted.length} branches`); + } + } + } else { + console.error(`Failed: ${result.error}`); + process.exit(1); + } + } catch (error) { + console.error(`Error: ${error}`); + process.exit(1); + } + }); + +program.parse(); diff --git a/src/core/close.ts b/src/core/close.ts new file mode 100644 index 0000000..7ca5912 --- /dev/null +++ b/src/core/close.ts @@ -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 { + 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 { + 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, + }; + } +} diff --git a/src/core/index.ts b/src/core/index.ts new file mode 100644 index 0000000..c4a2235 --- /dev/null +++ b/src/core/index.ts @@ -0,0 +1,3 @@ +export { launchMultiModel } from "./launch"; +export { closeMultiModel } from "./close"; +export * from "./utils"; diff --git a/src/core/launch.ts b/src/core/launch.ts new file mode 100644 index 0000000..173303b --- /dev/null +++ b/src/core/launch.ts @@ -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 { + 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"), + }; +} diff --git a/src/core/utils.ts b/src/core/utils.ts new file mode 100644 index 0000000..dd26c60 --- /dev/null +++ b/src/core/utils.ts @@ -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 { + 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(); + 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", +): Promise { + 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 { + const { stdout } = await runCommand(["git", "worktree", "list", "--porcelain"]); + const worktrees: WorktreeInfo[] = []; + + const sessionPath = getSessionPath(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); + } + } + + // 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"; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..7d55d37 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,32 @@ +import type { Plugin } from "@opencode-ai/plugin"; +import { openTool } from "./tools/open"; +import { closeTool } from "./tools/close"; + +/** + * OpenCode plugin that provides multi-model tmux session management tools. + * + * Registers two tools: + * - `multi-model-open`: Launch multiple AI models in a tmux session + * - `multi-model-close`: Close a multi-model session and cleanup resources + * + * @example + * ```json + * // opencode.json + * { + * "plugin": ["@username/opencode-multi-model"] + * } + * ``` + */ +const OpenCodeMultiModelPlugin: Plugin = async (_ctx) => { + return { + tool: { + "multi-model-open": openTool, + "multi-model-close": closeTool, + }, + }; +}; + +export default OpenCodeMultiModelPlugin; +export { openTool, closeTool }; +export * from "./core/index"; +export * from "./types"; diff --git a/src/tools/close.ts b/src/tools/close.ts new file mode 100644 index 0000000..27ef355 --- /dev/null +++ b/src/tools/close.ts @@ -0,0 +1,41 @@ +import { tool } from "@opencode-ai/plugin"; +import { closeMultiModel } from "../core/close"; + +/** + * OpenCode tool definition for closing multi-model tmux sessions. + * + * Kills the tmux session and optionally removes git worktrees, + * creates archive tags, and deletes associated branches. + */ +export const closeTool = tool({ + description: "Close a multi-model tmux session and optionally cleanup worktrees and branches", + args: { + sessionName: tool.schema.string().min(1).describe("tmux session name to close"), + cleanupWorktrees: tool.schema.boolean().default(true).describe("whether to remove worktrees and delete branches (default: true)"), + }, + async execute(args, context) { + // In plugin mode, we skip confirmation (force=true) since there's no interactive terminal + const result = await closeMultiModel({ + sessionName: args.sessionName, + cleanupWorktrees: args.cleanupWorktrees, + force: true, + }); + + if (!result.success) { + return result.error!; + } + + let details = ""; + if (result.cleanupPerformed) { + details += ` Removed ${result.worktreesRemoved?.length || 0} worktrees.`; + if (result.tagsCreated && result.tagsCreated.length > 0) { + details += ` Created backup tags: ${result.tagsCreated.join(", ")}.`; + } + if (result.warnings && result.warnings.length > 0) { + details += ` Warnings: ${result.warnings.join("; ")}.`; + } + } + + return `Session "${result.sessionName}" has been closed.${details}`; + }, +}); diff --git a/src/tools/open.ts b/src/tools/open.ts new file mode 100644 index 0000000..1464592 --- /dev/null +++ b/src/tools/open.ts @@ -0,0 +1,43 @@ +import { tool } from "@opencode-ai/plugin"; +import { launchMultiModel } from "../core/launch"; +import { getBinaryName } from "../core/utils"; + +/** + * OpenCode tool definition for launching multiple AI models in tmux sessions. + * + * Creates a tmux session with one window per model, each in its own git worktree. + * Returns instructions for attaching to and cleaning up the session. + */ +export const openTool = tool({ + description: "Launch multiple OpenCode models in tmux", + args: { + sessionName: tool.schema.string().min(1).describe("tmux session name to create"), + models: tool.schema + .array(tool.schema.string().min(1)) + .min(1) + .describe("one or more OpenCode model ids to launch"), + }, + async execute(args, context) { + const binaryName = getBinaryName(); + + const result = await launchMultiModel({ + sessionName: args.sessionName, + models: args.models, + binaryName, + }); + + context.metadata({ + title: `multi-model ${args.sessionName}`, + metadata: { + safeSessionName: result.sessionName, + modelCount: args.models.length, + }, + }); + + if (!result.success) { + return result.error!; + } + + return result.instructions || `Created session "${result.sessionName}" with ${result.windows?.length || 0} windows`; + }, +}); diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..8ab8442 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,93 @@ +/** + * Options for launching a multi-model tmux session. + */ +export interface MultiModelOptions { + /** Name for the tmux session. */ + sessionName: string; + /** List of model ids to launch. */ + models: string[]; + /** Binary name to use ('opencode' or 'kilo'). Defaults to 'opencode'. */ + binaryName?: string; +} + +/** + * Result returned after attempting to launch a multi-model session. + */ +export interface MultiModelResult { + /** Whether the launch succeeded. */ + success: boolean; + /** The session name used. */ + sessionName: string; + /** Window names created in the tmux session. */ + windows?: string[]; + /** Error message if the launch failed. */ + error?: string; + /** Instructions for attaching and cleaning up the session. */ + instructions?: string; +} + +/** + * Options for closing a multi-model tmux session. + */ +export interface CloseSessionOptions { + /** Name of the tmux session to close. */ + sessionName: string; + /** Whether to remove worktrees and delete branches. */ + cleanupWorktrees?: boolean; + /** Skip confirmation prompts. */ + force?: boolean; +} + +/** + * Result returned after attempting to close a multi-model session. + */ +export interface CloseSessionResult { + /** Whether the close succeeded. */ + success: boolean; + /** The session name that was closed. */ + sessionName: string; + /** Error message if the close failed. */ + error?: string; + /** Whether cleanup was performed. */ + cleanupPerformed?: boolean; + /** Worktree paths that were removed. */ + worktreesRemoved?: string[]; + /** Branch names that were deleted. */ + branchesDeleted?: string[]; + /** Archive tags that were created before branch deletion. */ + tagsCreated?: string[]; + /** Warning messages. */ + warnings?: string[]; +} + +/** + * Internal type for a window launch plan. + */ +export interface WindowLaunchPlan { + /** Full model id. */ + model: string; + /** Unique tmux window name. */ + windowName: string; +} + +/** + * Result of running a shell command. + */ +export interface CommandResult { + /** Whether the command exited with code 0. */ + ok: boolean; + /** Captured stdout. */ + stdout: string; + /** Captured stderr. */ + stderr: string; +} + +/** + * Information about a git worktree. + */ +export interface WorktreeInfo { + /** Absolute path to the worktree. */ + path: string; + /** Branch name associated with the worktree. */ + branch: string; +} diff --git a/tests/multi-model.test.ts b/tests/multi-model.test.ts new file mode 100644 index 0000000..75c062b --- /dev/null +++ b/tests/multi-model.test.ts @@ -0,0 +1,559 @@ +import { expect, test, mock, spyOn, beforeEach, afterEach, describe } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import { launchMultiModel } from "../src/core/launch"; +import { + shellQuote, + normalizeModels, + findDuplicates, + createWindowBaseName, + createWindowPlans, + levenshtein, + suggestModels, + formatInvalidModelError, + sanitizeName, + runCommand, + getWorktreePath, + undoWorktree, + launchModelInWindow, +} from "../src/core/utils"; + +let originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$; +let mockCommandResponses: Record = {}; +let executedCommands: string[] = []; + +function setupMockBun$() { + const mockFn = mock((strings: TemplateStringsArray, ...values: any[]) => { + const parts = values[0] as string[]; + const commandSignature = parts.join(" "); + + executedCommands.push(commandSignature); + + return { + quiet: () => ({ + nothrow: async () => { + const response = mockCommandResponses[commandSignature] ?? { ok: true, stdout: "", stderr: "" }; + return { + exitCode: response.ok ? 0 : 1, + stdout: { toString: () => response.stdout }, + stderr: { toString: () => response.stderr }, + }; + }, + }), + }; + }); + + (globalThis as any).Bun.$ = mockFn; + return mockFn; +} + +function restoreOriginalBun$() { + (globalThis as any).Bun.$ = originalBun$; +} + +describe("multi-model launch", () => { + let existsSyncMock: ReturnType; + let bunMock: ReturnType; + + beforeEach(() => { + spyOn(os, "homedir").mockReturnValue("/mock/home"); + existsSyncMock = spyOn(fs, "existsSync").mockReturnValue(false); + bunMock = setupMockBun$(); + executedCommands = []; + + mockCommandResponses = { + "git rev-parse --is-inside-work-tree": { ok: true, stdout: "true", stderr: "" }, + "command -v tmux": { ok: true, stdout: "/usr/bin/tmux", stderr: "" }, + "command -v opencode": { ok: true, stdout: "/usr/bin/opencode", stderr: "" }, + "opencode models": { ok: true, stdout: "openai/gpt-4o\nanthropic/claude-3-5-sonnet", stderr: "" }, + "tmux has-session -t test-session": { ok: false, stdout: "", stderr: "session not found" }, + "git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-4o": { ok: false, stdout: "", stderr: "" }, + }; + }); + + afterEach(() => { + restoreOriginalBun$(); + }); + + test("fails if session name is empty", async () => { + const result = await launchMultiModel({ + sessionName: "", + models: ["openai/gpt-4o"], + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Error: `sessionName` must not be empty."); + expect(executedCommands).toEqual([]); + }); + + test("fails if no models are provided", async () => { + const result = await launchMultiModel({ + sessionName: "test-session", + models: [], + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Error: Model names must not be empty."); + expect(executedCommands).toEqual([]); + }); + + test("fails if duplicate models are provided", async () => { + const result = await launchMultiModel({ + sessionName: "test-session", + models: ["openai/gpt-4o", "openai/gpt-4o"], + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Duplicate model names are not allowed"); + expect(executedCommands).toEqual([]); + }); + + test("fails if not inside a git repository", async () => { + mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: false, stdout: "", stderr: "not a git repo" }; + + const result = await launchMultiModel({ + sessionName: "test-session", + models: ["openai/gpt-4o"], + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Error: `multi-model` requires a git repository."); + expect(executedCommands).toEqual(["git rev-parse --is-inside-work-tree"]); + }); + + test("fails if tmux is not installed", async () => { + mockCommandResponses["command -v tmux"] = { ok: false, stdout: "", stderr: "" }; + + const result = await launchMultiModel({ + sessionName: "test-session", + models: ["openai/gpt-4o"], + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Error: `tmux` is not installed or not on `PATH`."); + expect(executedCommands).toEqual([ + "git rev-parse --is-inside-work-tree", + "command -v tmux" + ]); + }); + + test("fails if opencode is not installed", async () => { + mockCommandResponses["command -v opencode"] = { ok: false, stdout: "", stderr: "" }; + + const result = await launchMultiModel({ + sessionName: "test-session", + models: ["openai/gpt-4o"], + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Error: `opencode` is not installed or not on `PATH`."); + expect(executedCommands).toEqual([ + "git rev-parse --is-inside-work-tree", + "command -v tmux", + "command -v opencode" + ]); + }); + + test("fails if model name is not in allowlist", async () => { + const result = await launchMultiModel({ + sessionName: "test-session", + models: ["invalid/model"], + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Error: Model name 'invalid/model' not found"); + expect(executedCommands).toEqual([ + "git rev-parse --is-inside-work-tree", + "command -v tmux", + "command -v opencode", + "opencode models" + ]); + }); + + test("fails if session already exists", async () => { + mockCommandResponses["tmux has-session -t test-session"] = { ok: true, stdout: "", stderr: "" }; + + const result = await launchMultiModel({ + sessionName: "test-session", + models: ["openai/gpt-4o"], + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Error: Session already exists"); + expect(executedCommands).toEqual([ + "git rev-parse --is-inside-work-tree", + "command -v tmux", + "command -v opencode", + "opencode models", + "tmux has-session -t test-session" + ]); + }); + + test("successfully plans and executes tmux launch with single model", async () => { + const result = await launchMultiModel({ + sessionName: "test-session", + models: ["openai/gpt-4o"], + }); + + expect(result.success).toBe(true); + expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session"); + expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-4o"))).toBe(true); + expect(executedCommands.some(c => c.startsWith("tmux send-keys -t test-session:gpt-4o"))).toBe(true); + }); + + test("successfully launches multiple models", async () => { + mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/claude-3-5-sonnet"] = { ok: false, stdout: "", stderr: "" }; + + const result = await launchMultiModel({ + sessionName: "test-session", + models: ["openai/gpt-4o", "anthropic/claude-3-5-sonnet"], + }); + + expect(result.success).toBe(true); + expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session"); + expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-4o"))).toBe(true); + expect(executedCommands.some(c => c.startsWith("tmux new-window -d -t test-session -n claude-3-5-sonnet"))).toBe(true); + }); + + test("sanitizes session name", async () => { + mockCommandResponses["tmux has-session -t test session!"] = { ok: false, stdout: "", stderr: "" }; + mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-4o"] = { ok: false, stdout: "", stderr: "" }; + + const result = await launchMultiModel({ + sessionName: "test session!", + models: ["openai/gpt-4o"], + }); + + expect(result.success).toBe(true); + expect(result.instructions).toContain("Use `tmux attach -t test session!` to join session"); + expect(executedCommands.some(c => c.startsWith("git worktree add -b opencode/test-session/gpt-4o"))).toBe(true); + }); + + test("fails if tmux new-session fails for the first model and triggers undo", async () => { + mockCommandResponses["tmux new-session -d -s test-session -n gpt-4o -c /mock/home/.local/share/opencode/multi-model/test-session/gpt-4o"] = { ok: false, stdout: "", stderr: "tmux error" }; + mockCommandResponses["git worktree remove -f /mock/home/.local/share/opencode/multi-model/test-session/gpt-4o"] = { ok: true, stdout: "", stderr: "" }; + mockCommandResponses["git branch -D opencode/test-session/gpt-4o"] = { ok: true, stdout: "", stderr: "" }; + + const result = await launchMultiModel({ + sessionName: "test-session", + models: ["openai/gpt-4o"], + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Failed to create tmux session"); + expect(executedCommands).toEqual([ + "git rev-parse --is-inside-work-tree", + "command -v tmux", + "command -v opencode", + "opencode models", + "tmux has-session -t test-session", + "git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-4o", + "git worktree add -b opencode/test-session/gpt-4o /mock/home/.local/share/opencode/multi-model/test-session/gpt-4o", + "tmux new-session -d -s test-session -n gpt-4o -c /mock/home/.local/share/opencode/multi-model/test-session/gpt-4o", + "git worktree remove -f /mock/home/.local/share/opencode/multi-model/test-session/gpt-4o", + "git branch -D opencode/test-session/gpt-4o" + ]); + }); + + test("fails if worktree path exists for first model", async () => { + existsSyncMock.mockImplementation((path: string) => { + return path.includes("test-session/gpt-4o"); + }); + + const result = await launchMultiModel({ + sessionName: "test-session", + models: ["openai/gpt-4o"], + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Worktree path already exists"); + expect(executedCommands).toEqual([ + "git rev-parse --is-inside-work-tree", + "command -v tmux", + "command -v opencode", + "opencode models", + "tmux has-session -t test-session" + ]); + }); + + test("fails if git branch exists for first model", async () => { + mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-4o"] = { ok: true, stdout: "", stderr: "" }; + + const result = await launchMultiModel({ + sessionName: "test-session", + models: ["openai/gpt-4o"], + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Branch 'opencode/test-session/gpt-4o' already exists"); + expect(executedCommands).toEqual([ + "git rev-parse --is-inside-work-tree", + "command -v tmux", + "command -v opencode", + "opencode models", + "tmux has-session -t test-session", + "git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-4o" + ]); + }); + + test("fails if git worktree add fails for first model", async () => { + mockCommandResponses["git worktree add -b opencode/test-session/gpt-4o /mock/home/.local/share/opencode/multi-model/test-session/gpt-4o"] = { ok: false, stdout: "", stderr: "git error" }; + + const result = await launchMultiModel({ + sessionName: "test-session", + models: ["openai/gpt-4o"], + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Failed to create worktree"); + expect(executedCommands).toEqual([ + "git rev-parse --is-inside-work-tree", + "command -v tmux", + "command -v opencode", + "opencode models", + "tmux has-session -t test-session", + "git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-4o", + "git worktree add -b opencode/test-session/gpt-4o /mock/home/.local/share/opencode/multi-model/test-session/gpt-4o" + ]); + }); + + test("model name collision creates unique window names", async () => { + mockCommandResponses["opencode models"] = { ok: true, stdout: "openai/gpt-4o\nanthropic/gpt-4o", stderr: "" }; + mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-4o"] = { ok: false, stdout: "", stderr: "" }; + mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-4o-2"] = { ok: false, stdout: "", stderr: "" }; + + const result = await launchMultiModel({ + sessionName: "test-session", + models: ["openai/gpt-4o", "anthropic/gpt-4o"], + }); + + expect(result.success).toBe(true); + expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session"); + expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-4o"))).toBe(true); + expect(executedCommands.some(c => c.startsWith("tmux new-window -d -t test-session -n gpt-4o-2"))).toBe(true); + }); + + test("long model name is truncated in window name", async () => { + const longModel = "verylongmodelfrontexampleprovider"; + mockCommandResponses["opencode models"] = { ok: true, stdout: longModel, stderr: "" }; + mockCommandResponses[`git show-ref --verify --quiet refs/heads/opencode/test-session/${longModel.slice(0, 24)}`] = { ok: false, stdout: "", stderr: "" }; + + const result = await launchMultiModel({ + sessionName: "test-session", + models: [longModel], + }); + + expect(result.success).toBe(true); + expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session"); + const windowName = executedCommands.find(c => c.includes("tmux new-session"))?.match(/-n (\S+)/)?.[1]; + expect(windowName?.length).toBeLessThanOrEqual(24); + }); +}); + +describe("core utils", () => { + describe("shellQuote", () => { + test("normal string without special characters", () => { + expect(shellQuote("test")).toBe("'test'"); + }); + + test("string with spaces", () => { + expect(shellQuote("test value")).toBe("'test value'"); + }); + + test("string with single quotes", () => { + expect(shellQuote("test'value")).toBe("'test'\"'\"'value'"); + }); + + test("string with multiple single quotes", () => { + expect(shellQuote("te's't'v'alue")).toBe("'te'\"'\"'s'\"'\"'t'\"'\"'v'\"'\"'alue'"); + }); + + test("empty string", () => { + expect(shellQuote("")).toBe("''"); + }); + }); + + describe("normalizeModels", () => { + test("empty array", () => { + expect(normalizeModels([])).toEqual([]); + }); + + test("undefined input", () => { + expect(normalizeModels(undefined)).toEqual([]); + }); + + test("array with whitespace-only strings", () => { + expect(normalizeModels([" ", " "])).toEqual([]); + }); + + test("array with mixed valid models, padded models, and empty strings", () => { + expect(normalizeModels([" openai/gpt-4o ", "", " anthropic/claude "])).toEqual(["openai/gpt-4o", "anthropic/claude"]); + }); + }); + + describe("findDuplicates", () => { + test("array with no duplicates", () => { + expect(findDuplicates(["a", "b", "c"])).toEqual([]); + }); + + test("array with one duplicate pair", () => { + expect(findDuplicates(["a", "b", "a"])).toEqual(["a"]); + }); + + test("array with multiple different duplicates", () => { + expect(findDuplicates(["a", "b", "a", "c", "b"])).toEqual(["a", "b"]); + }); + + test("array with a single value repeated more than twice", () => { + expect(findDuplicates(["a", "a", "a"])).toEqual(["a"]); + }); + + test("verifying first-repeated-occurrence order", () => { + expect(findDuplicates(["x", "a", "b", "a", "x", "b"])).toEqual(["a", "x", "b"]); + }); + }); + + describe("createWindowBaseName", () => { + test("normal model name without slashes", () => { + expect(createWindowBaseName("gpt-4o")).toBe("gpt-4o"); + }); + + test("model with slashes", () => { + expect(createWindowBaseName("vendor/namespace/model")).toBe("model"); + }); + + test("model exceeding WINDOW_NAME_LIMIT", () => { + const longName = "a".repeat(30); + expect(createWindowBaseName(longName)).toBe("aaaaaaaaaaaaaaaaaaaaaaaa"); + }); + + test("model with special characters", () => { + expect(createWindowBaseName("OpenAI/GPT_4.0!")).toBe("gpt-4-0"); + }); + + test("model made entirely of special characters", () => { + expect(createWindowBaseName("!@#$%^&*()")).toBe("model"); + }); + }); + + describe("createWindowPlans", () => { + test("single model", () => { + expect(createWindowPlans(["openai/gpt-4o"])).toEqual([{ model: "openai/gpt-4o", windowName: "gpt-4o" }]); + }); + + test("two models with the same base name", () => { + expect(createWindowPlans(["openai/gpt-4o", "anthropic/gpt-4o"])).toEqual([ + { model: "openai/gpt-4o", windowName: "gpt-4o" }, + { model: "anthropic/gpt-4o", windowName: "gpt-4o-2" } + ]); + }); + + test("multiple collisions", () => { + const result = createWindowPlans(["a", "b", "c", "a", "b", "a"]); + expect(result[0]?.windowName).toBe("a"); + expect(result[3]?.windowName).toBe("a-2"); + expect(result[5]?.windowName).toBe("a-3"); + }); + + test("truncation to accommodate suffix", () => { + const longBase = "a".repeat(23); + const result = createWindowPlans([longBase, "b"]); + expect(result[1]?.windowName.length).toBeLessThanOrEqual(24); + }); + }); + + describe("levenshtein", () => { + test("identical strings", () => { + expect(levenshtein("test", "test")).toBe(0); + }); + + test("one substitution", () => { + expect(levenshtein("test", "tent")).toBe(1); + }); + + test("one insertion", () => { + expect(levenshtein("test", "tests")).toBe(1); + }); + + test("one deletion", () => { + expect(levenshtein("tests", "test")).toBe(1); + }); + + test("completely different strings", () => { + expect(levenshtein("abc", "xyz")).toBe(3); + }); + + test("one empty string", () => { + expect(levenshtein("", "test")).toBe(4); + }); + + test("both empty strings", () => { + expect(levenshtein("", "")).toBe(0); + }); + }); + + describe("suggestModels", () => { + test("exact match", () => { + const result = suggestModels("gpt-4o", ["gpt-4o", "gpt-4o-mini"]); + expect(result[0]).toBe("gpt-4o"); + }); + + test("fuzzy match/typo", () => { + expect(suggestModels("gpt5.4", ["gpt-5.4"])).toEqual(["gpt-5.4"]); + }); + + test("fuzzy match with contains boost", () => { + const suggestions = suggestModels("gpt4o", ["gpt-4o", "gpt-4o-mini"]); + expect(suggestions[0]).toBe("gpt-4o"); + }); + + test("maximum of 3 suggestions", () => { + const allowlist = ["gpt-4o", "gpt-4o-mini", "gpt-4o-pro", "gpt-4o-ultra"]; + expect(suggestModels("gpt4o", allowlist).length).toBe(3); + }); + + test("sorting logic", () => { + const suggestions = suggestModels("abc", ["abc", "abd", "abe"]); + expect(suggestions).toEqual(["abc", "abd", "abe"]); + }); + }); + + describe("formatInvalidModelError", () => { + test("single invalid model with available suggestions", () => { + const error = formatInvalidModelError(["gpt5.4"], ["gpt-5.4", "gpt-4o"]); + expect(error).toContain("Model name 'gpt5.4' not found"); + expect(error).toContain("Did you mean"); + }); + + test("single invalid model without close matches", () => { + const error = formatInvalidModelError(["xyz"], ["abc", "def"]); + expect(error).toContain("Model name 'xyz' not found"); + expect(error).toContain("Did you mean"); + }); + + test("multiple invalid models", () => { + const error = formatInvalidModelError(["xyz", "abc"], ["def"]); + expect(error).toContain("Model names not found"); + expect(error).toContain("'xyz'"); + expect(error).toContain("'abc'"); + }); + }); + + describe("sanitizeName", () => { + test("normal alphanumeric name", () => { + expect(sanitizeName("test-session")).toBe("test-session"); + }); + + test("name with spaces and special chars", () => { + expect(sanitizeName("test session!")).toBe("test-session"); + }); + + test("name with consecutive hyphens", () => { + expect(sanitizeName("test--session")).toBe("test-session"); + }); + + test("name with leading/trailing hyphens", () => { + expect(sanitizeName("-test-session-")).toBe("test-session"); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2eb3bf8 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "moduleResolution": "bundler", + "allowImportingTsExtensions": false, + "verbatimModuleSyntax": true, + "emitDeclarationOnly": true, + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "declaration": true, + "outDir": "./dist", + "rootDir": "./src", + "types": ["bun-types"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests"] +}