Compare commits

..
57 Commits
Author SHA1 Message Date
bendtherules 78784fabb6 chore(release): 0.1.3 2026-03-23 13:12:12 +05:30
bendtherules c6698e7c22 fix: load CLI version from package.json 2026-03-23 13:11:25 +05:30
bendtherules cb9c973868 chore(release): 0.1.2 2026-03-23 12:56:10 +05:30
bendtherules f58cd1e897 fix: remote argument schema
Removed unnecessary union wrapper for the `remote` argument, now using a direct string schema with optional flag and updated description.
2026-03-23 12:55:50 +05:30
bendtherules 4e20a03359 docs(AGENTS): rename JSDoc Comments heading to Documentation and add note to update README when implementation changes 2026-03-23 12:31:52 +05:30
bendtherules 114837cfae chore(release): 0.1.1 2026-03-23 12:26:17 +05:30
bendtherules 426def40f1 docs: enhance AGENTS.md 2026-03-23 12:17:29 +05:30
bendtherules 3ac3153aa9 docs: add AGENTS.md with project overview, build commands, and coding guidelines 2026-03-23 12:01:37 +05:30
bendtherules 233e211eb8 feat: add cleanup command to readme
Introduce a new `cleanup` command that discovers and deletes archive tags, with options for force, remote handling, and local-only operations. Updated README with usage examples, added help entry, and expanded documentation to describe the command and its steps. Added corresponding implementation files (`cleanup.ts`) in both core and tools directories.
2026-03-23 11:32:42 +05:30
bendtherules 4f15a8f8d2 chore(release): 0.1.0 2026-03-23 11:13:42 +05:30
bendtherules 46d81b4162 feat(tools): enhance CLI tool descriptions and add remote handling option
- Updated `cleanupTool` description to include detailed usage and arguments.
- Added optional `noRemote` flag to `cleanupTool` for local‑only tag deletion.
- Adjusted remote handling logic to respect the new `noRemote` flag.
- Expanded `closeTool` description with clear argument documentation.
- Expanded `openTool` description with detailed argument documentation.
2026-03-23 10:24:49 +05:30
bendtherules c6d6c889d8 feat(tools): add cleanupTool import and export for project-level use. 2026-03-23 10:24:32 +05:30
bendtherules 6367d99e7b chore(release): 0.0.11 2026-03-23 10:00:00 +05:30
bendtherules 21703fa2f8 chore(release): 0.0.10 2026-03-23 09:58:48 +05:30
bendtherules 4f75a920fc Merge branch 'opencode/build-cleanup/kimi-k2-5' 2026-03-23 09:56:41 +05:30
bendtherules 92708cf68b chore(deps): bump @opencode-ai/plugin from 1.2.27 to 1.3.0 2026-03-23 09:56:38 +05:30
bendtherules 6e2521b70d feat: add multi-model cleanup tool and CLI command
- Bump `@opencode-ai/plugin` dependency from 1.2.27 to 1.3.0.
- Introduce `cleanup` command in CLI with force and remote options.
- Export `cleanupMultiModel` from core index.
- Register `multi-model-cleanup` tool in plugin and update exports.
- Add `CleanupOptions` and `CleanupResult` types to define cleanup behavior and results.
2026-03-23 09:43:43 +05:30
bendtherules 42f153a28f plan: cleanup - streamline remote option handling 2026-03-23 09:33:39 +05:30
bendtherules ff065300ad feat: add TypeScript typecheck\ 2026-03-23 09:25:33 +05:30
bendtherules 36c6700437 fix: Fix biome correctness errors 2026-03-23 08:32:30 +05:30
bendtherules 7a164c705c build: add biome formatter and linter 2026-03-23 08:17:27 +05:30
bendtherules 068db04355 fix: Simplify remote handling in cleanup command 2026-03-23 07:54:09 +05:30
bendtherules dc60dc674e plan: cleanup command 2026-03-23 07:22:44 +05:30
bendtherules 8210080b43 feat: Add setup script for configuring git hooks 2026-03-21 23:28:51 +05:30
bendtherules 545f06b293 build: Add post-checkout hook to run bun install 2026-03-21 23:25:30 +05:30
bendtherules 3bcdc0a90f chore(release): 0.0.9 2026-03-21 18:00:37 +05:30
bendtherules 132a15dfc4 chore(release): 0.0.8 2026-03-21 18:00:29 +05:30
bendtherules 8bbd741bfa chore(release): 0.0.7 2026-03-21 17:55:26 +05:30
bendtherules 0503c474a8 fix: use sessionName instead of safeSessionName in tmux commands 2026-03-21 17:42:38 +05:30
bendtherules d92c9623a8 fix: remove package-lock.json to streamline dependencies 2026-03-21 17:33:37 +05:30
bendtherules 4364910fca Merge branch 'opencode/build-readme-improve/glm-5' 2026-03-21 17:31:34 +05:30
bendtherules f956406e9d docs: add CLI reference and How it works to Readme 2026-03-21 17:31:07 +05:30
bendtherules b92fdc692b fix: remove default value for --no-tags option 2026-03-21 17:29:09 +05:30
bendtherules 81b25bd348 fix: close cli options - improve negative options 2026-03-21 16:42:24 +05:30
bendtherules b2621a10de fix: console logs branch names directly for close confirmation 2026-03-21 16:41:54 +05:30
bendtherules fc7feb2095 chore(release): 0.0.6 2026-03-21 01:28:15 +05:30
bendtherules 7180569b87 feat: add option to disable archive tags 2026-03-21 01:27:43 +05:30
bendtherules f0cc07f513 chore(release): 0.0.5 2026-03-21 01:11:36 +05:30
bendtherules 6dd4ee4b4f fix: correct path for cli.js in package.json 2026-03-21 01:11:30 +05:30
bendtherules dd7ac4dc2d chore(release): 0.0.4 2026-03-21 01:10:27 +05:30
bendtherules e8dbcff73e fix test 2026-03-21 01:10:21 +05:30
bendtherules 787fa75df4 build: color-code open and close outputs 2026-03-21 01:05:17 +05:30
bendtherules 17bb1130c8 build: color-code open output 2026-03-21 00:22:31 +05:30
bendtherules c642148cac test: add user confirmation handling for cleanup in closeMultiModel 2026-03-20 14:56:29 +05:30
bendtherules 7abfd6bf64 test: fix tests 2026-03-20 14:46:00 +05:30
bendtherules 93478294aa test: add error handling tests for closeMultiModel and openMultiModel 2026-03-20 14:20:34 +05:30
bendtherules 5d50038845 docs: update test coverage command in README 2026-03-20 14:08:00 +05:30
bendtherules 0b88ea0763 docs: add local code coverage instructions 2026-03-20 14:02:41 +05:30
bendtherules ffdc5451ab refactor: tests and add close.test.ts 2026-03-20 13:51:28 +05:30
bendtherules 8a087275b3 refactor: rename launchMultiModel to openMultiModel 2026-03-20 13:38:46 +05:30
bendtherules 962c9a474f refactor: rename src/core/launch.ts to open.ts 2026-03-20 13:33:30 +05:30
bendtherules 71523f2144 feat: add launch configuration for multi-model sessions in VSCode 2026-03-20 13:29:36 +05:30
bendtherules 1c2f203a34 feat: add initial VSCode settings for Bun configuration 2026-03-20 13:22:00 +05:30
bendtherules 536e30c5d0 test: add session name sanitization when fetching worktrees 2026-03-20 13:21:51 +05:30
bendtherules da0ed90192 fix: sanitize session name in getWorktreesForSession function 2026-03-20 13:17:10 +05:30
bendtherules 49da7c4219 chore(release): 0.0.3 2026-03-20 10:42:09 +05:30
bendtherules 41757e712f fix: update package name to remove username prefix 2026-03-20 10:41:58 +05:30
33 changed files with 4166 additions and 931 deletions
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Post-checkout hook to run bun install when switching branches or checking out files
# This ensures dependencies are always up to date
# Only run if package.json or bun.lock exist (indicating a Node/Bun project)
if [ -f "package.json" ] || [ -f "bun.lock" ]; then
echo "Running bun install..."
bun install
fi
-52
View File
@@ -1,52 +0,0 @@
{
"name": "opencode-multi-model-tools",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "opencode-multi-model-tools",
"dependencies": {
"@opencode-ai/plugin": "1.2.24"
},
"devDependencies": {
"@types/node": "^25.5.0"
}
},
"node_modules/@opencode-ai/plugin": {
"version": "1.2.24",
"license": "MIT",
"dependencies": {
"@opencode-ai/sdk": "1.2.24",
"zod": "4.1.8"
}
},
"node_modules/@opencode-ai/sdk": {
"version": "1.2.24",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.5.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
},
"node_modules/zod": {
"version": "4.1.8",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
"test": "bun test"
},
"dependencies": {
"@opencode-ai/plugin": "1.2.27"
"@opencode-ai/plugin": "1.3.0"
},
"devDependencies": {
"@types/bun": "^1.3.10",
@@ -0,0 +1,475 @@
---
model: opencode-go/glm-5
---
# Plan: Add `opencode-multi-model cleanup` Command
## Overview
Add a new`cleanup` command that deletes all archive tags (format: `archive/*`) from localand remote git repositories. Archive tags are created by the `close` command to preserve branch state before deletion.
## User Requirements
1. **Scope**: Only archive tags (not orphaned worktrees/branches)
2. **Confirmation**: Prompt by default, `--force` to skip
3. **Remote handling**: Delete from both local and remote by default
4. **Preview**: Show count of tags, not full list
5. **Remote discovery**: Check `git remote -v` for available remotes
6. **Error handling**: Continue with local cleanup if remote deletion fails
## Files to Modify/Create
| File | Action | Description |
|------|--------|-------------|
| `src/types.ts` | MODIFY | Add `CleanupOptions` and `CleanupResult` interfaces |
| `src/core/cleanup.ts` | CREATE | Core cleanup implementation |
| `src/tools/cleanup.ts` | CREATE | Tool definition for plugin mode |
| `src/cli.ts` | MODIFY | Add cleanup command registration |
| `src/index.ts` | MODIFY | Export cleanup tool |
| `tests/cleanup.test.ts` | CREATE | Unit tests |
---
## Implementation Details
### 1. TypeScript Interfaces(`src/types.ts`)
Add after `WorktreeInfo` interface (around line 98):
```typescript
/**
* Options for cleaning up archive tags.
*/
export interface CleanupOptions {
/** Skip confirmation prompts. */
force?: boolean;
/**
* Remote name to push deletions to.
* - If string: use specified remote (error if doesn't exist)
* - If undefined: use first available remote from git remote -v
* - If false: only local cleanup (--no-remote flag)
*/
remote?: string | false;
}
/**
* Result returned after attempting to cleanup archive tags.
*/
export interface CleanupResult {
/** Whether the cleanup succeeded. */
success: boolean;
/** Error message if the cleanup failed. */
error?: string;
/** Summary message for the user. */
instructions?: string;
/** Number of local tags deleted. */
localTagsDeleted: number;
/** Number of remote tags deleted. */
remoteTagsDeleted: number;
/** Names of tags that were deleted locally. */
localTagNames: string[];
/** Names of tags deleted from remote. */
remoteTagNames: string[];
/** Tags that failed remote deletion. */
remoteTagErrors: Array<{ tag: string; error: string }>;
/** Available remotes discovered. */
availableRemotes: string[];
}
```
---
### 2. Core Implementation (`src/core/cleanup.ts`)
Create new file with the following functions:
#### Helper Functions
```typescript
import * as readline from "node:readline";
import chalk from 'chalk';
import type { CleanupOptions, CleanupResult } from "../types";
import { runCommand } from "./utils";
/**
* Prompts the user for confirmation on the terminal.
*/
async function promptUser(question: string): Promise<string> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer);
});
});
}
/**
* Lists all archive tags matching the pattern archive/*.
*/
export async function listArchiveTags(): Promise<string[]> {
const { stdout } = await runCommand(["git", "tag", "-l", "archive/*"]);
return stdout.split("\n").filter(Boolean);
}
/**
* Gets the list of available git remotes.
*/
export async function getAvailableRemotes(): Promise<string[]> {
const { stdout } = await runCommand(["git", "remote"]);
return stdout.split("\n").filter(Boolean);
}
/**
* Deletes a local git tag.
*/
async function deleteLocalTag(tagName: string): Promise<boolean> {
const result = await runCommand(["git", "tag", "-d", tagName]);
return result.ok;
}
/**
* Deletes a tag from a remote repository.
*/
async function deleteRemoteTag(tagName: string, remoteName: string): Promise<{ ok: boolean; error: string }> {
const result = await runCommand(["git", "push", remoteName, "--delete", tagName]);
return {
ok: result.ok,
error: result.ok ? "" : result.stderr,
};
}
```
#### Main Function
```typescript
/**
* Cleans up archive tags from local and optionally remote repositories.
*
* Archive tags have the format: archive/<branch-name>-<timestamp>
* These are created by the close command to preserve branch state before deletion.
*
* @param options Cleanup configuration including force mode and remote settings.
* @returns Result with counts and details of deleted tags.
*/
export async function cleanupArchiveTags(options: CleanupOptions = {}): Promise<CleanupResult> {
const { force = false, remote } = options;
// Get available remotes and archive tags first
const availableRemotes = await getAvailableRemotes();
const archiveTags = await listArchiveTags();
// Determine whether to push to remote and which remote to use:
// - remote === false: --no-remote flag, local-only mode
// - remote === string: use specified remote (error if doesn't exist)
// - remote === undefined: use first available remote
const shouldPushToRemote = remote !== false;
let remoteToDeleteFrom: string | undefined;
if (shouldPushToRemote) {
if (typeof remote === "string") {
// User specified a remote - validate it exists
if (!availableRemotes.includes(remote)) {
return {
success: false,
error: `Error: Remote '${remote}' not found. Available remotes: ${availableRemotes.join(", ") || "none"}`,
localTagsDeleted: 0,
remoteTagsDeleted: 0,
localTagNames: [],
remoteTagNames: [],
remoteTagErrors: [],
availableRemotes,
};
}
remoteToDeleteFrom = remote;
} else {
// remote === undefined, use first available
remoteToDeleteFrom = availableRemotes[0];
}
}
if (archiveTags.length === 0) {
return {
success: true,
instructions: chalk.dim("No archive tags found."),
localTagsDeleted: 0,
remoteTagsDeleted: 0,
localTagNames: [],
remoteTagNames: [],
remoteTagErrors: [],
availableRemotes,
};
}
// Confirmation prompt (unless --force)
if (!force) {
const tagCount = archiveTags.length;
const tagWord = tagCount === 1 ? "tag" : "tags";
console.log(chalk.yellow(`Found ${tagCount} archive ${tagWord}.`));
if (shouldPushToRemote && remoteToDeleteFrom) {
console.log(chalk.dim(`Remote deletions will use: ${remoteToDeleteFrom}`));
} else if (shouldPushToRemote && !remoteToDeleteFrom) {
console.log(chalk.dim("No remotes configured. Only local cleanup will be performed."));
}
const answer = await promptUser(chalk.bold("\nProceed with cleanup? (y/N): "));
if (answer.toLowerCase() !== "y" && answer.toLowerCase() !== "yes") {
return {
success: false,
error: "Cleanup cancelled by user",
localTagsDeleted: 0,
remoteTagsDeleted: 0,
localTagNames: [],
remoteTagNames: [],
remoteTagErrors: [],
availableRemotes,
};
}
}
const localTagNames: string[] = [];
const remoteTagNames: string[] = [];
const remoteTagErrors: Array<{ tag: string; error: string }> = [];
// Delete local tags
for (const tag of archiveTags) {
const deleted = await deleteLocalTag(tag);
if (deleted) {
localTagNames.push(tag);
}
}
// Delete from remote (if remote is specified and exists)
if (shouldPushToRemote && remoteToDeleteFrom) {
for (const tag of localTagNames) {
const result = await deleteRemoteTag(tag, remoteToDeleteFrom);
if (result.ok) {
remoteTagNames.push(tag);
} else {
// Continue with local cleanup even if remote fails
remoteTagErrors.push({ tag, error: result.error });
}
}
}
// Build instructions message
const instructionsArr: string[] = [];
if (localTagNames.length > 0) {
const word = localTagNames.length === 1 ? "tag" : "tags";
instructionsArr.push(chalk.green(`Deleted ${localTagNames.length} local archive ${word}.`));
}
if (remoteTagNames.length > 0) {
const word = remoteTagNames.length === 1 ? "tag" : "tags";
instructionsArr.push(chalk.green(`Deleted ${remoteTagNames.length} remote archive ${word}.`));
}
if (remoteTagErrors.length > 0) {
const word = remoteTagErrors.length === 1 ? "tag" : "tags";
instructionsArr.push(chalk.yellow(`Failed to delete ${remoteTagErrors.length} remote ${word} (local cleanup continued).`));
}
return {
success: true,
instructions: instructionsArr.join("\n"),
localTagsDeleted: localTagNames.length,
remoteTagsDeleted: remoteTagNames.length,
localTagNames,
remoteTagNames,
remoteTagErrors,
availableRemotes,
};
}
```
---
### 3. Tool Definition (`src/tools/cleanup.ts`)
Create new file:
```typescript
import { tool } from "@opencode-ai/plugin";
import { cleanupArchiveTags } from "../core/cleanup";
/**
* OpenCode tool definition for cleaning up archive tags.
*
* Deletes archive tags (format: archive/*) from local and remote repositories.
* Used to clean up recovery tags created by the close command.
*/
export const cleanupTool = tool({
description: "Delete all archive tags from local and optionally remote repository",
args: {
remote: tool.schema.union([
tool.schema.string().describe("Remote name to push deletions to. Error if doesn't exist."),
tool.schema.literal(false).describe("Only delete local tags, don't push to remote."),
]).optional().describe("Remote handling: string=specific remote, false=local-only, undefined=first available remote"),
},
async execute(args, context) {
// In plugin mode, we skip confirmation (force=true)
const result = await cleanupArchiveTags({
force: true,
remote: args.remote,
});
if (!result.success) {
return result.error!;
}
return result.instructions ?? `Deleted ${result.localTagsDeleted} local tags.`;
},
});
```
---
### 4. CLI Command (`src/cli.ts`)
Add import after line 5:
```typescript
import { cleanupArchiveTags } from "./core/cleanup";
```
Add command after line 69 (after close command):
```typescript
program
.command("cleanup")
.description("Delete all archive tags from local and remote")
.option("-f, --force", "Skip confirmation prompts", false)
.option("-r, --remote <remote>", "Remote name to push deletions to (default: first remote from git remote -v)")
.option("--no-remote", "Only delete local tags, skip remote")
.action(async (options: { force: boolean; remote?: string|boolean }) => {
try {
const result = await cleanupArchiveTags({
force: options.force,
remote: options.remote,
});
if (result.success) {
console.log(result.instructions);
} else {
console.error(`Failed: ${result.error}`);
process.exit(1);
}
} catch (error) {
console.error(`Error: ${error}`);
process.exit(1);
}
});
```
---
### 5. Index Export (`src/index.ts`)
Add import after line 3:
```typescript
import { cleanupTool } from "./tools/cleanup";
```
Update return object (line 22-25):
```typescript
tool: {
"multi-model-open": openTool,
"multi-model-close": closeTool,
"multi-model-cleanup": cleanupTool,
},
```
Update exports (line30):
```typescript
export { openTool, closeTool, cleanupTool };
```
---
## Usage Examples
### CLI Usage
```bash
# Interactive (shows confirmation prompt, uses first remote from git remote -v)
opencode-multi-model cleanup
# Skip confirmation
opencode-multi-model cleanup --force
# Use specific remote
opencode-multi-model cleanup --remote upstream
# Only local cleanup (don't push to remote)
opencode-multi-model cleanup --no-remote
# Combined options
opencode-multi-model cleanup -f --remote upstream
```
### Tool Usage (OpenCode Plugin)
```typescript
// Local-only cleanup (don't push to remote)
await multi-model-cleanup({ remote: false });
// Use first available remote (default behavior)
await multi-model-cleanup({ remote: undefined });
// Specify a remote
await multi-model-cleanup({ remote: "upstream" });
```
---
## Git Commands Used
| Command | Purpose |
|---------|---------|
| `git tag -l "archive/*"` | List all archive tags |
| `git remote` | Get available remotes |
| `git tag -d <name>` | Delete local tag |
| `git push <remote> --delete <tag>` | Delete remote tag |
---
## Verification
After implementation, verify with:
1. **Build**: `bun run build` or `bun run typecheck`
2. **Unit tests**: `bun test tests/cleanup.test.ts`
3. **Manual test**:
```bash
# Create some archive tags for testing
git tag archive/test-branch-1-2024-01-01T00-00-00-000Z
git tag archive/test-branch-2-2024-01-02T00-00-00-000Z
# Run cleanup
bun run src/cli.ts cleanup
# Verify tags are deleted
git tag -l "archive/*"
```
---
## Test Cases
Create `tests/cleanup.test.ts` with:
1. No archive tags found - returns success with message
2. User cancels confirmation - returns success=false
3. Force mode skips confirmation
4. **--no-remote flag (remote=false) - only local cleanup, no remote push**
5. Remote deletion failure continues local cleanup
6. **Dynamic remote detection - remote=undefined uses first available remote**
7. **Remote name validation - uses specified remote if it exists**
8. **Remote error - returns error if specified remote doesn't exist**
9. No remotes configured - only local cleanup
+5 -3
View File
@@ -1,6 +1,8 @@
// 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";
export { openTool as open, closeTool as close };
import { cleanupTool } from "../../src/tools/cleanup";
import { closeTool } from "../../src/tools/close";
import { openTool } from "../../src/tools/open";
export { cleanupTool as cleanup, closeTool as close, openTool as open };
+2 -4
View File
@@ -18,9 +18,7 @@
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"types": [
"bun-types"
]
"types": ["bun-types"]
},
"include": ["**/*.ts", "**/*.tsx"]
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Open Multi-Model Session",
"runtimeExecutable": "bun",
"program": "${workspaceFolder}/src/cli.ts",
"args": ["open", "test/session1", "--models", "openai/gpt-5.2"],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal"
},
{
"type": "node",
"request": "launch",
"name": "Close Multi-Model Session",
"runtimeExecutable": "bun",
"program": "${workspaceFolder}/src/cli.ts",
"args": ["close", "test/session1"],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal"
}
]
}
+16
View File
@@ -0,0 +1,16 @@
{
// The path to the `bun` executable.
"bun.runtime": "/path/to/bun",
// If support for Bun should be added to the default "JavaScript Debug Terminal".
"bun.debugTerminal.enabled": true,
// If the debugger should stop on the first line of the program.
"bun.debugTerminal.stopOnEntry": false,
// Glob pattern to find test files. Defaults to the value shown below.
"bun.test.filePattern": "**/*{.test.,.spec.,_test_,_spec_}{js,ts,tsx,jsx,mts,cts,cjs,mjs}",
// The custom script to call for testing instead of `bun test`
"bun.test.customScript": "bun test"
}
+151
View File
@@ -0,0 +1,151 @@
# AGENTS.md
## Project Overview
opencode-multi-model is a Bun/TypeScript project that provides multi-model tmux session management for AI coding assistants. It allows running multiple AI models simultaneously in isolated tmux windows with separate git worktrees.
Available as three integration modes:
- **OpenCode Plugin**: Loaded via `opencode.json` plugin array
- **Standalone CLI**: Install globally and run `opencode-multi-model open <session> -m <models...>`
- **Project Tool**: Import core functions from `"opencode-multi-model/core"` for project-level commands (used during development).
Requires: tmux, git, opencode or kilo binary, Bun runtime.
## Build, Lint, and Test Commands
```bash
# Install dependencies
bun install
# Build for production
bun run build
# Run tests
bun test
# Run tests with coverage (HTML report in ./coverage)
bun run coverage
# Run a specific test by name
bun test --test-name-pattern "fails if session name is empty"
# TypeScript type checking
bun run typecheck
# Lint with Biome
bun run lint
# Auto-fix lint issues
bun run lint:fix
# Format code with Biome
bun run format
# Full quality check
bun run typecheck && bun run lint
```
## Code Style Guidelines
### General
- Use **TypeScript** with strict mode
- Use **Bun** as the runtime and package manager
- Use ESM modules (`"type": "module"` in package.json)
- Use `node:` prefix for Node.js built-ins
### Formatting (Biome)
- **Indent**: 2 spaces (not tabs)
- **Quote style**: Double quotes for JS strings
- **Semicolons**: Required
- Run `bun run format` to format files
### TypeScript Conventions
- Use `interface` for object shapes; avoid `type` aliases unless needed
- Use explicit return types on exported functions
- Avoid `any` when possible
- Use `noUncheckedIndexedAccess: true` and `noImplicitOverride: true` in tsconfig
- All types/interfaces go in `src/types.ts`
### Naming Conventions
- **Files**: kebab-case (`multi-model-open.ts`)
- **Functions**: camelCase (`openMultiModel`)
- **Types/Interfaces**: PascalCase (`MultiModelOptions`)
- **Constants**: UPPER_SNAKE_CASE (e.g., `MAX_SUGGESTIONS`)
### Imports
- Use `import type` for type-only imports
- Group imports: external packages, internal modules, types
- Biome automatically organizes imports
### Documentation
- Use JSDoc for all exported functions
- Include `@example`, `@param`, and `@returns` descriptions
- Add inline comments for hard-to-understand code
- Modify Readme.md, if implementation details have changed.
### Error Handling
- Return result objects (e.g., `MultiModelResult`, `CloseSessionResult`, `CleanupResult`) with `success: boolean`
- Include descriptive error messages prefixed with `Error:`
- Provide actionable error messages (e.g., suggest valid models on invalid model name)
- Use `chalk` for colored terminal output (red for errors, dim for secondary info, bold for primary info)
### Shell Commands
- Use `Bun.$` template tag for shell commands
- Always use `quiet().nothrow()` to capture output without throwing
- Wrap command execution in `runCommand()` utility from `core/utils.ts`
- Use `shellQuote()` for user-provided values in shell commands
### Testing
- Use `bun:test` framework
- Mock `Bun.$` using the `mock()` function from `bun:test`
- Use `spyOn()` for fs/os module mocking
- Group tests with `describe()` blocks
- Test file naming: `*.test.ts` in `tests/` directory
## Project Structure
```
src/
├── index.ts # Plugin entry point (default export)
├── cli.ts # CLI entry point (commander setup)
├── types.ts # All TypeScript interfaces/types
├── core/
│ ├── index.ts # Core exports
│ ├── open.ts # Session launch logic
│ ├── close.ts # Session close logic
│ ├── cleanup.ts # Archive tag cleanup logic
│ └── utils.ts # Shared utilities (runCommand, shellQuote, etc.)
└── tools/
├── open.ts # OpenCode tool definition
├── close.ts # CloseCode tool definition
└── cleanup.ts # Cleanup tool definition
tests/
├── open.test.ts # Open command tests
├── close.test.ts # Close command tests
├── cleanup.test.ts # Cleanup command tests
├── open_error.test.ts # Open error handling tests
├── close_error.test.ts # Close error handling tests
└── utils.test.ts # Utility function tests
```
### Git Branch and Tag Naming
- Session branches: `opencode/<session-name>/<window-name>`
- Archive tags: `archive/<branch-name>-<timestamp>` (created by close command)
- Worktrees: `~/.local/share/opencode/multi-model/<session>/<window>/`
### Common Pitfalls
- Tmux session names use raw input; git branches/paths use sanitized names
- Window names must be unique; collisions get numeric suffixes (e.g., `gpt-5-2`, `gpt-5-2-2`)
- Window names are truncated to 24 characters to fit tmux limits
- Worktree paths are checked for existence before creation to avoid conflicts
- Archive tags preserve branch state before deletion for recovery
+148 -7
View File
@@ -13,7 +13,7 @@ Add to your OpenCode config (`opencode.json`):
```json
{
"plugin": ["@bendtherules/opencode-multi-model"]
"plugin": ["opencode-multi-model"]
}
```
@@ -21,10 +21,10 @@ Add to your OpenCode config (`opencode.json`):
```bash
# Using bunx (no install)
bunx @bendtherules/opencode-multi-model open my-session -m openai/gpt-5.2 anthropic/claude-3-5-sonnet
bunx opencode-multi-model open my-session -m openai/gpt-5.2 anthropic/claude-3-5-sonnet
# Or install globally
bun install -g @bendtherules/opencode-multi-model
bun install -g opencode-multi-model
opencode-multi-model open my-session -m openai/gpt-5.2
```
@@ -34,7 +34,7 @@ If you installed it via bun and want to use it as a project tool, import it from
```typescript
// .opencode/tools/multi-model.ts
import { openTool as open, closeTool as close } from "@bendtherules/opencode-multi-model"
import { openTool as open, closeTool as close } from "opencode-multi-model"
export { open, close }
```
@@ -62,6 +62,11 @@ sessionName: my-session, models: ["openai/gpt-5.2", "anthropic/claude-3-5-sonnet
call tool multi-model-close with sessionName: my-session
```
**Cleanup archive tags (using tool):**
```
call tool multi-model-cleanup
```
**Close a session (using manual command):**
```bash
tmux kill-session -t my-session && rm -rf ~/.local/share/opencode/multi-model/my-session && git worktree prune && git branch -D $(git branch --format='%(refname:short)' --list 'opencode/my-session/*')
@@ -88,6 +93,21 @@ opencode-multi-model open my-session -m openai/gpt-5.2
opencode-multi-model close my-session
```
**Cleanup archive tags (CLI command):**
```bash
# Delete all archive tags from local and remote repositories
opencode-multi-model cleanup
# Delete all archive tags without confirmation prompts
opencode-multi-model cleanup --force
# Delete only local archive tags, skip remote deletion
opencode-multi-model cleanup --no-remote
# Delete archive tags and push deletions to a specific remote
opencode-multi-model cleanup --remote origin
```
**Close a session (manual command):**
```bash
tmux kill-session -t my-session && rm -rf ~/.local/share/opencode/multi-model/my-session && git worktree prune && git branch -D $(git branch --format='%(refname:short)' --list 'opencode/my-session/*')
@@ -98,6 +118,7 @@ tmux kill-session -t my-session && rm -rf ~/.local/share/opencode/multi-model/my
opencode-multi-model --help
opencode-multi-model open --help
opencode-multi-model close --help
opencode-multi-model cleanup --help
```
## Configuration
@@ -118,6 +139,108 @@ opencode-multi-model open my-session -m openai/gpt-5.2 -b kilo
Priority: CLI flag > Environment variable > Default ("opencode")
## CLI Reference
### `open` - Create a new multi-model tmux session
```bash
opencode-multi-model open <session-name> [options]
```
**Arguments:**
- `<session-name>` - Name for the tmux session (required)
**Options:**
| Option | Description |
|--------|-------------|
| `-m, --models <models...>` | Model IDs to launch (space-separated) |
| `-b, --binary <binary>` | Binary to use (opencode or kilo). Defaults to env var `OPENCODE_MULTI_MODEL_BINARY` or 'opencode' |
### `close` - Close a multi-model tmux session
```bash
opencode-multi-model close <session-name> [options]
```
**Arguments:**
- `<session-name>` - Name of the tmux session to close (required)
**Options:**
| Option | Description |
|--------|-------------|
| `--keep-worktrees` | Do not remove worktrees and delete branches |
| `--no-tags` | Do not create archive tags before deleting branches |
| `-f, --force` | Skip confirmation prompts |
### `cleanup` - Delete all archive tags from local and remote repositories
Archive tags have the format: `archive/<branch-name>-<timestamp>`. These are created by the close command to preserve branch state before deletion.
```bash
opencode-multi-model cleanup [options]
```
**Options:**
| Option | Description |
|--------|-------------|
| `-f, --force` | Skip confirmation prompts |
| `-r, --remote <remote>` | Remote name to push deletions to (default: first remote from git remote -v) |
| `--no-remote` | Only delete local tags, skip remote |
### Global Options
| Option | Description |
|--------|-------------|
| `-h, --help` | Display help information |
| `-V, --version` | Display version number |
## How it works
### `open` command
The `open` command performs the following steps:
1. **Validation**: Checks that session name andmodels are provided, no duplicate models exist, and verifies git repo, tmux, and binary are available
2. **Model verification**: Validates models against `opencode models` output to ensure only valid model IDs are used
3. **Git worktree creation**: For each model, creates:
- A git worktree at `~/.local/share/opencode/multi-model/<session>/<model>/`
- A branch named `opencode/<session>/<model>`
4. **Tmux session**: Creates a tmux session with one window per model, each window's working directory set to its corresponding worktree
5. **Launch**: Sends the launch command to each window to start the binary with the specified model
### `close` command
The `close` command performs the following steps:
1. **Kill tmux session**: Terminates the tmux session if it exists
2. **Worktree discovery**: Finds all worktrees under `~/.local/share/opencode/multi-model/<session>/`
3. **Worktree cleanup** (unless `--keep-worktrees`):
- Removes each gitworktree
- Creates an archive tag (unless `--no-tags`): `archive/<branch>-<timestamp>` for recovery
- Deletes the associated branches
4. **Directory cleanup**: Removes the base session directory
### `cleanup` command
The `cleanup` command performs the following steps:
1. **Archive tag discovery**: Finds all git tags matching the pattern `archive/*`
2. **Local tag deletion**: Deletes all archive tags from the local repository
3. **Remote tag deletion** (unless `--no-remote`):
- Determines which remote to use (user-specified, first available, or skip)
- Pushes tag deletions to the remote repository
4. **Summary**: Reports the number of tags deleted locally and remotely
## Requirements
- tmux
@@ -133,6 +256,9 @@ For local development and testing without publishing:
# Install dependencies
bun install
# Setup git hooks
bun run setup
# Build
bun run build
@@ -153,12 +279,14 @@ bun dist/cli.js close test-session
│ ├── types.ts # Shared TypeScript types
│ ├── core/
│ │ ├── index.ts # Core exports
│ │ ├── launch.ts # Launch session logic
│ │ ├── close.ts # Close session logic
│ │ ├── open.ts # Launch session logic
│ │ ├── close.ts # Close session logic
│ │ ├── cleanup.ts # Cleanup archive tags logic
│ │ └── utils.ts # Helper functions
│ └── tools/
│ ├── open.ts # Open tool definition
── close.ts # Close tool definition
── close.ts # Close tool definition
│ └── cleanup.ts # Cleanup tool definition
├── tests/
│ └── multi-model.test.ts # Tests
├── package.json
@@ -166,6 +294,19 @@ bun dist/cli.js close test-session
└── README.md
```
## Local code coverage
The test suite can generate a coverage report locally.
```bash
# Run tests with coverage (HTML report generated in ./coverage)
npm run coverage # or: bun run coverage
# Open the HTML report
open coverage/index.html # macOS
```
The `coverage` directory is ignored by Git but can be inspected to see which lines are exercised.
## License
MIT
+50
View File
@@ -0,0 +1,50 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.8/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"includes": ["**", "!!**/dist"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"style": {
"noNonNullAssertion": "off"
},
"suspicious": {
"noExplicitAny": "off"
},
"correctness": {
"noUnusedImports": "warn",
"noUnusedFunctionParameters": "warn",
"noUnusedVariables": "warn",
"noConstructorReturn": "warn"
},
"complexity": {
"useLiteralKeys": "warn"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}
+25
View File
@@ -6,15 +6,36 @@
"name": "@username/opencode-multi-model",
"dependencies": {
"@opencode-ai/plugin": "^1.2.27",
"chalk": "^5.3.0",
"commander": "^12.0.0",
},
"devDependencies": {
"@biomejs/biome": "^2.4.8",
"@types/bun": "^1.3.10",
"@types/node": "^25.5.0",
"typescript": "^5.9.3",
},
},
},
"packages": {
"@biomejs/biome": ["@biomejs/biome@2.4.8", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.8", "@biomejs/cli-darwin-x64": "2.4.8", "@biomejs/cli-linux-arm64": "2.4.8", "@biomejs/cli-linux-arm64-musl": "2.4.8", "@biomejs/cli-linux-x64": "2.4.8", "@biomejs/cli-linux-x64-musl": "2.4.8", "@biomejs/cli-win32-arm64": "2.4.8", "@biomejs/cli-win32-x64": "2.4.8" }, "bin": { "biome": "bin/biome" } }, "sha512-ponn0oKOky1oRXBV+rlSaUlixUxf1aZvWC19Z41zBfUOUesthrQqL3OtiAlSB1EjFjyWpn98Q64DHelhA6jNlA=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ARx0tECE8I7S2C2yjnWYLNbBdDoPdq3oyNLhMglmuctThwUsuzFWRKrHmIGwIRWKz0Mat9DuzLEDp52hGnrxGQ=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jg9/PsB9vDCJlANE8uhG7qDhb5w0Ix69D7XIIc8IfZPUoiPrbLm33k2Ig3NOJ/7nb3UbesFz3D1aDKm9DvzjhQ=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-5CdrsJct76XG2hpKFwXnEtlT1p+4g4yV+XvvwBpzKsTNLO9c6iLlAxwcae2BJ7ekPGWjNGw9j09T5KGPKKxQig=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-Zo9OhBQDJ3IBGPlqHiTISloo5H0+FBIpemqIJdW/0edJ+gEcLR+MZeZozcUyz3o1nXkVA7++DdRKQT0599j9jA=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.8", "", { "os": "linux", "cpu": "x64" }, "sha512-PdKXspVEaMCQLjtZCn6vfSck/li4KX9KGwSDbZdgIqlrizJ2MnMcE3TvHa2tVfXNmbjMikzcfJpuPWH695yJrw=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.8", "", { "os": "linux", "cpu": "x64" }, "sha512-Gi8quv8MEuDdKaPFtS2XjEnMqODPsRg6POT6KhoP+VrkNb+T2ywunVB+TvOU0LX1jAZzfBr+3V1mIbBhzAMKvw=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-LoFatS0tnHv6KkCVpIy3qZCih+MxUMvdYiPWLHRri7mhi2vyOOs8OrbZBcLTUEWCS+ktO72nZMy4F96oMhkOHQ=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.8", "", { "os": "win32", "cpu": "x64" }, "sha512-vAn7iXDoUbqFXqVocuq1sMYAd33p8+mmurqJkWl6CtIhobd/O6moe4rY5AJvzbunn/qZCdiDVcveqtkFh1e7Hg=="],
"@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=="],
@@ -25,8 +46,12 @@
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"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=="],
+14 -5
View File
@@ -1,11 +1,11 @@
{
"name": "@bendtherules/opencode-multi-model",
"version": "0.0.2",
"name": "opencode-multi-model",
"version": "0.1.3",
"description": "Launch multiple AI models in tmux sessions - OpenCode Plugin, CLI, and Project Tool",
"type": "module",
"main": "./dist/index.js",
"bin": {
"opencode-multi-model": "./dist/cli.js"
"opencode-multi-model": "dist/cli.js"
},
"exports": {
".": "./dist/index.js",
@@ -19,16 +19,25 @@
"scripts": {
"build": "bun build src/index.ts src/cli.ts --outdir dist --target bun --format esm",
"test": "bun test",
"coverage": "bun test --coverage",
"prepublishOnly": "bun run build",
"release": "bash ./scripts/release.sh"
"release": "bash ./scripts/release.sh",
"setup": "bash ./scripts/setup.sh",
"lint": "biome check .",
"lint:fix": "biome check . --fix",
"format": "biome format . --write",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"chalk": "^5.3.0",
"@opencode-ai/plugin": "^1.2.27",
"commander": "^12.0.0"
},
"devDependencies": {
"@biomejs/biome": "^2.4.8",
"@types/bun": "^1.3.10",
"@types/node": "^25.5.0"
"@types/node": "^25.5.0",
"typescript": "^5.9.3"
},
"keywords": [
"opencode",
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
# Setup script for development environment
echo "Setting up git hooks..."
git config core.hooksPath .githooks
echo "Setup complete!"
+83 -40
View File
@@ -1,7 +1,9 @@
#!/usr/bin/env bun
import { Command } from "commander";
import { launchMultiModel } from "./core/launch";
import pkg from "../package.json";
import { cleanupMultiModel } from "./core/cleanup";
import { closeMultiModel } from "./core/close";
import { openMultiModel } from "./core/open";
import { getBinaryName } from "./core/utils";
const program = new Command();
@@ -9,65 +11,106 @@ const program = new Command();
program
.name("opencode-multi-model")
.description("Launch multiple OpenCode models in tmux sessions")
.version("1.0.0");
.version(pkg.version);
program
.command("open")
.description("Create a new multi-model tmux session")
.argument("<session-name>", "Name for the tmux session")
.option("-m, --models <models...>", "Model IDs to launch (space-separated)", [])
.option("-b, --binary <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();
.option(
"-m, --models <models...>",
"Model IDs to launch (space-separated)",
[],
)
.option(
"-b, --binary <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,
mode: "cli",
});
const result = await openMultiModel({
sessionName,
models: options.models,
binaryName,
mode: "cli",
});
if (result.success) {
console.log(result.instructions || `Created session: ${result.sessionName}`);
} else {
console.error(`Failed: ${result.error}`);
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);
}
} catch (error) {
console.error(`Error: ${error}`);
process.exit(1);
}
});
},
);
program
.command("close")
.description("Close a multi-model tmux session")
.argument("<session-name>", "Name of the tmux session to close")
.option("-c, --cleanup-worktrees", "Remove worktrees and delete branches", true)
.option(
"--keep-worktrees",
"Do not remove worktrees and delete branches",
false,
)
.option("--no-tags", "Create archive tags before deleting branches")
.option("-f, --force", "Skip confirmation prompts", false)
.action(async (sessionName: string, options: { cleanupWorktrees: boolean; force: boolean }) => {
.action(
async (
sessionName: string,
options: { keepWorktrees: boolean; force: boolean; tags: boolean },
) => {
try {
const result = await closeMultiModel({
sessionName,
cleanupWorktrees: !options.keepWorktrees,
force: options.force,
createArchiveTags: options.tags,
});
if (result.success) {
console.log(result.instructions);
} else {
console.error(`Failed: ${result.error}`);
process.exit(1);
}
} catch (error) {
console.error(`Error: ${error}`);
process.exit(1);
}
},
);
program
.command("cleanup")
.description("Delete all archive tags from local and remote")
.option("-f, --force", "Skip confirmation prompts", false)
.option(
"-r, --remote <remote>",
"Remote name to push deletions to (default: first remote from git remote -v)",
)
.option("--no-remote", "Only delete local tags, skip remote")
.action(async (options: { force: boolean; remote?: string | boolean }) => {
try {
const result = await closeMultiModel({
sessionName,
cleanupWorktrees: options.cleanupWorktrees,
const result = await cleanupMultiModel({
force: options.force,
remote: options.remote === true ? undefined : options.remote,
});
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`);
}
}
console.log(result.instructions);
} else {
console.error(`Failed: ${result.error}`);
process.exit(1);
+220
View File
@@ -0,0 +1,220 @@
import * as readline from "node:readline";
import chalk from "chalk";
import type { CleanupOptions, CleanupResult } from "../types";
import { runCommand } from "./utils";
/**
* Prompts the user for confirmation on the terminal.
*/
async function promptUser(question: string): Promise<string> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer);
});
});
}
/**
* Lists all archive tags matching the pattern archive/*.
*/
export async function listArchiveTags(): Promise<string[]> {
const { stdout } = await runCommand(["git", "tag", "-l", "archive/*"]);
return stdout.split("\n").filter(Boolean);
}
/**
* Gets the list of available git remotes.
*/
export async function getAvailableRemotes(): Promise<string[]> {
const { stdout } = await runCommand(["git", "remote"]);
return stdout.split("\n").filter(Boolean);
}
/**
* Deletes a local git tag.
*/
async function deleteLocalTag(tagName: string): Promise<boolean> {
const result = await runCommand(["git", "tag", "-d", tagName]);
return result.ok;
}
/**
* Deletes a tag from a remote repository.
*/
async function deleteRemoteTag(
tagName: string,
remoteName: string,
): Promise<{ ok: boolean; error: string }> {
const result = await runCommand([
"git",
"push",
remoteName,
"--delete",
tagName,
]);
return {
ok: result.ok,
error: result.ok ? "" : result.stderr,
};
}
/**
* Cleans up archive tags from local and optionally remote repositories.
*
* Archive tags have the format: archive/<branch-name>-<timestamp>
* These are created by the close command to preserve branch state before deletion.
*
* @param options Cleanup configuration including force mode and remote settings.
* @returns Result with counts and details of deleted tags.
*/
export async function cleanupMultiModel(
options: CleanupOptions = {},
): Promise<CleanupResult> {
const { force = false, remote } = options;
// Get available remotes and archive tags first
const availableRemotes = await getAvailableRemotes();
const archiveTags = await listArchiveTags();
// Determine whether to push to remote and which remote to use:
// - remote === false: --no-remote flag, local-only mode
// - remote === string: use specified remote (error if doesn't exist)
// - remote === undefined: use first available remote
const shouldPushToRemote = remote !== false;
let remoteToDeleteFrom: string | undefined;
if (shouldPushToRemote) {
if (typeof remote === "string") {
// User specified a remote - validate it exists
if (!availableRemotes.includes(remote)) {
return {
success: false,
error: `Error: Remote '${remote}' not found. Available remotes: ${availableRemotes.join(", ") || "none"}`,
localTagsDeleted: 0,
remoteTagsDeleted: 0,
localTagNames: [],
remoteTagNames: [],
remoteTagErrors: [],
availableRemotes,
};
}
remoteToDeleteFrom = remote;
} else {
// remote === undefined, use first available
remoteToDeleteFrom = availableRemotes[0];
}
}
if (archiveTags.length === 0) {
return {
success: true,
instructions: chalk.dim("No archive tags found."),
localTagsDeleted: 0,
remoteTagsDeleted: 0,
localTagNames: [],
remoteTagNames: [],
remoteTagErrors: [],
availableRemotes,
};
}
// Confirmation prompt (unless --force)
if (!force) {
const tagCount = archiveTags.length;
const tagWord = tagCount === 1 ? "tag" : "tags";
console.log(chalk.yellow(`Found ${tagCount} archive ${tagWord}.`));
if (shouldPushToRemote && remoteToDeleteFrom) {
console.log(
chalk.dim(`Remote deletions will use: ${remoteToDeleteFrom}`),
);
} else if (shouldPushToRemote && !remoteToDeleteFrom) {
console.log(
chalk.dim(
"No remotes configured. Only local cleanup will be performed.",
),
);
}
const answer = await promptUser(
chalk.bold("\nProceed with cleanup? (y/N): "),
);
if (answer.toLowerCase() !== "y" && answer.toLowerCase() !== "yes") {
return {
success: false,
error: "Cleanup cancelled by user",
localTagsDeleted: 0,
remoteTagsDeleted: 0,
localTagNames: [],
remoteTagNames: [],
remoteTagErrors: [],
availableRemotes,
};
}
}
const localTagNames: string[] = [];
const remoteTagNames: string[] = [];
const remoteTagErrors: Array<{ tag: string; error: string }> = [];
// Delete local tags
for (const tag of archiveTags) {
const deleted = await deleteLocalTag(tag);
if (deleted) {
localTagNames.push(tag);
}
}
// Delete from remote (if remote is specified and exists)
if (shouldPushToRemote && remoteToDeleteFrom) {
for (const tag of localTagNames) {
const result = await deleteRemoteTag(tag, remoteToDeleteFrom);
if (result.ok) {
remoteTagNames.push(tag);
} else {
// Continue with local cleanup even if remote fails
remoteTagErrors.push({ tag, error: result.error });
}
}
}
// Build instructions message
const instructionsArr: string[] = [];
if (localTagNames.length > 0) {
const word = localTagNames.length === 1 ? "tag" : "tags";
instructionsArr.push(
chalk.green(`Deleted ${localTagNames.length} local archive ${word}.`),
);
}
if (remoteTagNames.length > 0) {
const word = remoteTagNames.length === 1 ? "tag" : "tags";
instructionsArr.push(
chalk.green(`Deleted ${remoteTagNames.length} remote archive ${word}.`),
);
}
if (remoteTagErrors.length > 0) {
const word = remoteTagErrors.length === 1 ? "tag" : "tags";
instructionsArr.push(
chalk.yellow(
`Failed to delete ${remoteTagErrors.length} remote ${word} (local cleanup continued).`,
),
);
}
return {
success: true,
instructions: instructionsArr.join("\n"),
localTagsDeleted: localTagNames.length,
remoteTagsDeleted: remoteTagNames.length,
localTagNames,
remoteTagNames,
remoteTagErrors,
availableRemotes,
};
}
+76 -25
View File
@@ -1,6 +1,12 @@
import * as readline from "node:readline";
import chalk from "chalk"; // removed console usage
import type { CloseSessionOptions, CloseSessionResult } from "../types";
import { runCommand, getSessionPath, getWorktreesForSession } from "./utils";
import {
getSessionPath,
getWorktreesForSession,
runCommand,
sanitizeName,
} from "./utils";
/**
* Prompts the user for input on the terminal.
@@ -43,35 +49,58 @@ 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 warnings: 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}'`),
);
} else {
instructionsArr.push(
chalk.yellow(`Session '${options.sessionName}' does not exist`),
);
}
// Get list of worktrees for this session before killing tmux
const worktrees = await getWorktreesForSession(options.sessionName);
const worktrees = await getWorktreesForSession(safeSessionName);
// 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})`));
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("\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,
sessionName: options.sessionName,
sessionName: safeSessionName,
error: "Cleanup cancelled by user",
instructions: instructionsArr.join("\n"),
};
}
}
@@ -86,28 +115,38 @@ export async function closeMultiModel(options: CloseSessionOptions): Promise<Clo
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]);
// 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 = `Failed to create tag ${archiveTag} for branch ${worktree.branch}.`;
warnings.push(warningMsg);
console.warn(`Warning: ${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) {
console.warn(`Warning: Failed to cleanup worktree ${worktree.path}: ${err}`);
instructionsArr.push(
chalk.red(`Failed to cleanup worktree ${worktree.path}: ${err}`),
);
}
}
// Also remove the base directory
const worktreeBase = getSessionPath(options.sessionName);
const worktreeBase = getSessionPath(safeSessionName);
try {
await runCommand(["rm", "-rf", worktreeBase]);
} catch {
@@ -115,26 +154,38 @@ 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.`,
),
);
if (tagsCreated.length > 0) {
instructionsArr.push(
chalk.dim(`Created archive tags: ${tagsCreated.join(", ")}.`),
);
}
}
}
return {
success: true,
sessionName: options.sessionName,
sessionName: safeSessionName,
cleanupPerformed,
worktreesRemoved,
branchesDeleted,
tagsCreated,
warnings,
instructions: instructionsArr.join("\n"),
};
} catch (error) {
return {
success: false,
sessionName: options.sessionName,
sessionName: safeSessionName,
error: String(error),
worktreesRemoved,
branchesDeleted,
tagsCreated,
warnings,
instructions: instructionsArr.join("\n"),
};
}
}
+2 -1
View File
@@ -1,3 +1,4 @@
export { launchMultiModel } from "./launch";
export { cleanupMultiModel } from "./cleanup";
export { closeMultiModel } from "./close";
export { openMultiModel } from "./open";
export * from "./utils";
+140 -42
View File
@@ -1,16 +1,16 @@
import * as fs from "node:fs";
import chalk from "chalk";
import type { MultiModelOptions, MultiModelResult } from "../types";
import {
runCommand,
normalizeModels,
findDuplicates,
createWindowPlans,
findDuplicates,
formatInvalidModelError,
sanitizeName,
getWorktreePath,
getSessionPath,
undoWorktree,
launchModelInWindow,
normalizeModels,
runCommand,
sanitizeName,
undoWorktree,
} from "./utils";
/**
@@ -24,7 +24,7 @@ import {
*
* @example
* ```ts
* const result = await launchMultiModel({
* const result = await openMultiModel({
* sessionName: "compare",
* models: ["openai/gpt-5.2", "anthropic/claude-3-5-sonnet"],
* binaryName: "opencode",
@@ -34,7 +34,9 @@ import {
* }
* ```
*/
export async function launchMultiModel(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);
@@ -42,11 +44,19 @@ export async function launchMultiModel(options: MultiModelOptions): Promise<Mult
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);
@@ -58,19 +68,35 @@ export async function launchMultiModel(options: MultiModelOptions): Promise<Mult
};
}
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"]);
@@ -89,12 +115,25 @@ export async function launchMultiModel(options: MultiModelOptions): Promise<Mult
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);
@@ -110,30 +149,61 @@ export async function launchMultiModel(options: MultiModelOptions): Promise<Mult
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}`,
};
}
}
@@ -153,7 +223,9 @@ export async function launchMultiModel(options: MultiModelOptions): Promise<Mult
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(", ")}`;
if (undoErrors.length > 0) {
errorMsg += ` Cleanup errors: ${undoErrors.join(", ")}`;
}
return { success: false, sessionName, error: errorMsg };
}
} else {
@@ -170,20 +242,35 @@ export async function launchMultiModel(options: MultiModelOptions): Promise<Mult
]);
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(", ")}`);
if (undoErrors.length > 0) {
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(", ")}`,
);
}
}
@@ -201,13 +288,21 @@ export async function launchMultiModel(options: MultiModelOptions): Promise<Mult
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 when done using either:`,
`1. ${primaryCleanup}`,
`2. Or run manually: \`${cleanupCommand}\``,
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.bold(`Attach with \`${attachCommand}\` to inspect the session.`),
"",
chalk.dim(`When finished, clean up using:`),
primaryCleanup,
"",
chalk.dim(`Alternate manual cleanup - `),
chalk.dim(cleanupCommand),
].join("\n"),
};
}
@@ -217,10 +312,13 @@ export async function launchMultiModel(options: MultiModelOptions): Promise<Mult
sessionName,
windows: windowNames,
instructions: [
`Use \`${attachCommand}\` to join session.`,
`When finished, clean up using either:`,
`1. ${primaryCleanup}`,
`2. Or run manually: \`${cleanupCommand}\``,
chalk.bold(`Use \`${attachCommand}\` to join session.`),
"",
chalk.dim(`When finished, clean up using:`),
primaryCleanup,
"",
chalk.dim(`Alternate manual cleanup - `),
chalk.dim(cleanupCommand),
].join("\n"),
};
}
+68 -15
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,11 +361,18 @@ 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(sessionName);
const sessionPath = getSessionPath(sanitizeName(sessionName));
let currentWorktree: Partial<WorktreeInfo> = {};
@@ -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;
}
+6 -3
View File
@@ -1,13 +1,15 @@
import type { Plugin } from "@opencode-ai/plugin";
import { openTool } from "./tools/open";
import { cleanupTool } from "./tools/cleanup";
import { closeTool } from "./tools/close";
import { openTool } from "./tools/open";
/**
* OpenCode plugin that provides multi-model tmux session management tools.
*
* Registers two tools:
* Registers three tools:
* - `multi-model-open`: Launch multiple AI models in a tmux session
* - `multi-model-close`: Close a multi-model session and cleanup resources
* - `multi-model-cleanup`: Delete all archive tags from local and remote repositories
*
* @example
* ```json
@@ -22,11 +24,12 @@ const OpenCodeMultiModelPlugin: Plugin = async (_ctx) => {
tool: {
"multi-model-open": openTool,
"multi-model-close": closeTool,
"multi-model-cleanup": cleanupTool,
},
};
};
export default OpenCodeMultiModelPlugin;
export { openTool, closeTool };
export * from "./core/index";
export * from "./types";
export { cleanupTool, closeTool, openTool };
+43
View File
@@ -0,0 +1,43 @@
import { tool } from "@opencode-ai/plugin";
import { cleanupMultiModel } from "../core/cleanup";
/**
* OpenCode tool definition for cleaning up archive tags.
*
* Deletes archive tags (format: archive/*) from local and remote repositories.
* Used to clean up recovery tags created by the close command.
*/
export const cleanupTool = tool({
description: `Delete all archive tags from the local repository and, optionally, from a remote.
Arguments:
-\`remote\` (string, optional): Specify a remote name to which deletions should be pushed. If omitted, the first available remote is used.
- \`noRemote\` (boolean, optional): When true, only delete local tags and do not push deletions to any remote.`,
args: {
remote: tool.schema
.string()
.optional()
.describe(
"Remote handling: string=specific remote, undefined=first available remote",
),
noRemote: tool.schema
.literal(true)
.describe("Only delete local tags, don't push to remote.")
.optional(),
},
async execute(args, _context) {
// In plugin mode, we skip confirmation (force=true)
const result = await cleanupMultiModel({
force: true,
remote: args.noRemote ? false : args.remote,
});
if (!result.success) {
return result.error!;
}
return (
result.instructions ?? `Deleted ${result.localTagsDeleted} local tags.`
);
},
});
+25 -16
View File
@@ -8,16 +8,36 @@ import { closeMultiModel } from "../core/close";
* creates archive tags, and deletes associated branches.
*/
export const closeTool = tool({
description: "Close a multi-model tmux session and optionally cleanup worktrees and branches",
description: `Close a multi-model tmux session and optionally clean up worktrees and branches.
Arguments:
- \`sessionName\` (string, required): tmux session name to close.
- \`cleanupWorktrees\` (boolean, optional, default: true): Whether to remove worktrees and delete branches.
- \`createArchiveTags\` (boolean, optional, default: true): Whether to create archive tags before deleting 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)"),
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)",
),
createArchiveTags: tool.schema
.boolean()
.default(true)
.describe(
"whether to create archive tags before deleting branches (default: true)",
),
},
async execute(args, context) {
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,
createArchiveTags: args.createArchiveTags,
force: true,
});
@@ -25,17 +45,6 @@ export const closeTool = tool({
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}`;
return result.instructions;
},
});
+15 -5
View File
@@ -1,5 +1,5 @@
import { tool } from "@opencode-ai/plugin";
import { launchMultiModel } from "../core/launch";
import { openMultiModel } from "../core/open";
import { getBinaryName } from "../core/utils";
/**
@@ -9,9 +9,16 @@ import { getBinaryName } from "../core/utils";
* Returns instructions for attaching to and cleaning up the session.
*/
export const openTool = tool({
description: "Launch multiple OpenCode models in tmux",
description: `Launch multiple OpenCode models in tmux.
Arguments:
- \`sessionName\` (string, required): tmux session name to create.
- \`models\` (array of strings, required): one or more OpenCode model IDs to launch.`,
args: {
sessionName: tool.schema.string().min(1).describe("tmux session name to create"),
sessionName: tool.schema
.string()
.min(1)
.describe("tmux session name to create"),
models: tool.schema
.array(tool.schema.string().min(1))
.min(1)
@@ -20,7 +27,7 @@ export const openTool = tool({
async execute(args, context) {
const binaryName = getBinaryName();
const result = await launchMultiModel({
const result = await openMultiModel({
sessionName: args.sessionName,
models: args.models,
binaryName,
@@ -39,6 +46,9 @@ export const openTool = tool({
return result.error!;
}
return result.instructions || `Created session "${result.sessionName}" with ${result.windows?.length || 0} windows`;
return (
result.instructions ||
`Created session "${result.sessionName}" with ${result.windows?.length || 0} windows`
);
},
});
+43 -2
View File
@@ -32,6 +32,8 @@ export interface MultiModelResult {
* Options for closing a multi-model tmux session.
*/
export interface CloseSessionOptions {
/** Whether to create archive tags before deleting branches. */
createArchiveTags?: boolean;
/** Name of the tmux session to close. */
sessionName: string;
/** Whether to remove worktrees and delete branches. */
@@ -44,6 +46,8 @@ export interface CloseSessionOptions {
* Result returned after attempting to close a multi-model session.
*/
export interface CloseSessionResult {
/** Console messages and instructions. */
instructions: string;
/** Whether the close succeeded. */
success: boolean;
/** The session name that was closed. */
@@ -58,8 +62,6 @@ export interface CloseSessionResult {
branchesDeleted?: string[];
/** Archive tags that were created before branch deletion. */
tagsCreated?: string[];
/** Warning messages. */
warnings?: string[];
}
/**
@@ -72,6 +74,45 @@ export interface WindowLaunchPlan {
windowName: string;
}
/**
* Options for cleaning up archive tags.
*/
export interface CleanupOptions {
/** Skip confirmation prompts. */
force?: boolean;
/**
* Remote name to push deletions to.
* - If string: use specified remote (error if doesn't exist)
* - If undefined: use first available remote from git remote -v
* - If false: only local cleanup (--no-remote flag)
*/
remote?: string | false;
}
/**
* Result returned after attempting to cleanup archive tags.
*/
export interface CleanupResult {
/** Whether the cleanup succeeded. */
success: boolean;
/** Error message if the cleanup failed. */
error?: string;
/** Summary message for the user. */
instructions?: string;
/** Number of local tags deleted. */
localTagsDeleted: number;
/** Number of remote tags deleted. */
remoteTagsDeleted: number;
/** Names of tags that were deleted locally. */
localTagNames: string[];
/** Names of tags deleted from remote. */
remoteTagNames: string[];
/** Tags that failed remote deletion. */
remoteTagErrors: Array<{ tag: string; error: string }>;
/** Available remotes discovered. */
availableRemotes: string[];
}
/**
* Result of running a shell command.
*/
+406
View File
@@ -0,0 +1,406 @@
import {
afterEach,
beforeEach,
describe,
expect,
mock,
spyOn,
test,
} from "bun:test";
import * as readline from "node:readline";
import { cleanupMultiModel } from "../src/core/cleanup";
const originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
let mockCommandResponses: Record<
string,
{ ok: boolean; stdout: string; stderr: string }
> = {};
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);
const response = mockCommandResponses[commandSignature] ?? {
ok: true,
stdout: "",
stderr: "",
};
return {
quiet: () => ({
nothrow: async () => ({
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("cleanupMultiModel", () => {
let _bunMock: ReturnType<typeof setupMockBun$>;
beforeEach(() => {
_bunMock = setupMockBun$();
executedCommands = [];
mockCommandResponses = {};
});
afterEach(() => {
restoreOriginalBun$();
});
test("no archive tags found - returns success with message", async () => {
mockCommandResponses["git remote"] = {
ok: true,
stdout: "origin",
stderr: "",
};
mockCommandResponses["git tag -l archive/*"] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await cleanupMultiModel({ force: true });
expect(result.success).toBe(true);
expect(result.localTagsDeleted).toBe(0);
expect(result.remoteTagsDeleted).toBe(0);
expect(result.instructions).toContain("No archive tags found");
});
test("user cancels confirmation - returns success=false", async () => {
mockCommandResponses["git remote"] = {
ok: true,
stdout: "origin",
stderr: "",
};
mockCommandResponses["git tag -l archive/*"] = {
ok: true,
stdout: "archive/test-1\narchive/test-2",
stderr: "",
};
const mockInterface = {
question: (_q: string, cb: (a: string) => void) => cb("n"),
close: () => {},
} as any;
spyOn(readline, "createInterface").mockImplementation(() => mockInterface);
const result = await cleanupMultiModel({ force: false });
expect(result.success).toBe(false);
expect(result.error).toBe("Cleanup cancelled by user");
expect(result.localTagsDeleted).toBe(0);
expect(result.remoteTagsDeleted).toBe(0);
});
test("force mode skips confirmation", async () => {
mockCommandResponses["git remote"] = {
ok: true,
stdout: "origin",
stderr: "",
};
mockCommandResponses["git tag -l archive/*"] = {
ok: true,
stdout: "archive/test-1",
stderr: "",
};
mockCommandResponses["git tag -d archive/test-1"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git push origin --delete archive/test-1"] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await cleanupMultiModel({ force: true });
expect(result.success).toBe(true);
expect(result.localTagsDeleted).toBe(1);
expect(executedCommands).toContain("git tag -d archive/test-1");
});
test("--no-remote flag (remote=false) - only local cleanup, no remote push", async () => {
mockCommandResponses["git remote"] = {
ok: true,
stdout: "origin",
stderr: "",
};
mockCommandResponses["git tag -l archive/*"] = {
ok: true,
stdout: "archive/test-1",
stderr: "",
};
mockCommandResponses["git tag -d archive/test-1"] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await cleanupMultiModel({ force: true, remote: false });
expect(result.success).toBe(true);
expect(result.localTagsDeleted).toBe(1);
expect(result.remoteTagsDeleted).toBe(0);
expect(executedCommands).toContain("git tag -d archive/test-1");
expect(executedCommands.some((c) => c.startsWith("git push"))).toBe(false);
});
test("remote deletion failure continues local cleanup", async () => {
mockCommandResponses["git remote"] = {
ok: true,
stdout: "origin",
stderr: "",
};
mockCommandResponses["git tag -l archive/*"] = {
ok: true,
stdout: "archive/test-1\narchive/test-2",
stderr: "",
};
mockCommandResponses["git tag -d archive/test-1"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git tag -d archive/test-2"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git push origin --delete archive/test-1"] = {
ok: false,
stdout: "",
stderr: "remote error",
};
mockCommandResponses["git push origin --delete archive/test-2"] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await cleanupMultiModel({ force: true });
expect(result.success).toBe(true);
expect(result.localTagsDeleted).toBe(2);
expect(result.remoteTagsDeleted).toBe(1);
expect(result.remoteTagErrors.length).toBe(1);
expect(result.remoteTagErrors[0]?.tag).toBe("archive/test-1");
});
test("dynamic remote detection - remote=undefined uses first available remote", async () => {
mockCommandResponses["git remote"] = {
ok: true,
stdout: "upstream\norigin",
stderr: "",
};
mockCommandResponses["git tag -l archive/*"] = {
ok: true,
stdout: "archive/test-1",
stderr: "",
};
mockCommandResponses["git tag -d archive/test-1"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git push upstream --delete archive/test-1"] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await cleanupMultiModel({ force: true, remote: undefined });
expect(result.success).toBe(true);
expect(executedCommands).toContain(
"git push upstream --delete archive/test-1",
);
});
test("remote name validation - uses specified remote if it exists", async () => {
mockCommandResponses["git remote"] = {
ok: true,
stdout: "upstream\norigin",
stderr: "",
};
mockCommandResponses["git tag -l archive/*"] = {
ok: true,
stdout: "archive/test-1",
stderr: "",
};
mockCommandResponses["git tag -d archive/test-1"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git push upstream --delete archive/test-1"] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await cleanupMultiModel({
force: true,
remote: "upstream",
});
expect(result.success).toBe(true);
expect(result.availableRemotes).toEqual(["upstream", "origin"]);
expect(executedCommands).toContain(
"git push upstream --delete archive/test-1",
);
});
test("remote error - returns error if specified remote doesn't exist", async () => {
mockCommandResponses["git remote"] = {
ok: true,
stdout: "origin",
stderr: "",
};
mockCommandResponses["git tag -l archive/*"] = {
ok: true,
stdout: "archive/test-1",
stderr: "",
};
const result = await cleanupMultiModel({
force: true,
remote: "nonexistent",
});
expect(result.success).toBe(false);
expect(result.error).toContain("Remote 'nonexistent' not found");
expect(result.error).toContain("Available remotes: origin");
});
test("no remotes configured - only local cleanup", async () => {
mockCommandResponses["git remote"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git tag -l archive/*"] = {
ok: true,
stdout: "archive/test-1",
stderr: "",
};
mockCommandResponses["git tag -d archive/test-1"] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await cleanupMultiModel({ force: true });
expect(result.success).toBe(true);
expect(result.availableRemotes).toEqual([]);
expect(result.remoteTagsDeleted).toBe(0);
expect(executedCommands.some((c) => c.startsWith("git push"))).toBe(false);
});
test("user confirms cleanup when not forced - proceeds with deletion", async () => {
mockCommandResponses["git remote"] = {
ok: true,
stdout: "origin",
stderr: "",
};
mockCommandResponses["git tag -l archive/*"] = {
ok: true,
stdout: "archive/test-1",
stderr: "",
};
mockCommandResponses["git tag -d archive/test-1"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git push origin --delete archive/test-1"] = {
ok: true,
stdout: "",
stderr: "",
};
const mockInterface = {
question: (_q: string, cb: (a: string) => void) => cb("y"),
close: () => {},
} as any;
spyOn(readline, "createInterface").mockImplementation(() => mockInterface);
const result = await cleanupMultiModel({ force: false });
expect(result.success).toBe(true);
expect(result.localTagsDeleted).toBe(1);
expect(result.remoteTagsDeleted).toBe(1);
expect(executedCommands).toContain("git tag -d archive/test-1");
expect(executedCommands).toContain(
"git push origin --delete archive/test-1",
);
});
test("multiple archive tags deleted", async () => {
mockCommandResponses["git remote"] = {
ok: true,
stdout: "origin",
stderr: "",
};
mockCommandResponses["git tag -l archive/*"] = {
ok: true,
stdout: "archive/test-1\narchive/test-2\narchive/test-3",
stderr: "",
};
mockCommandResponses["git tag -d archive/test-1"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git tag -d archive/test-2"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git tag -d archive/test-3"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git push origin --delete archive/test-1"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git push origin --delete archive/test-2"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git push origin --delete archive/test-3"] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await cleanupMultiModel({ force: true });
expect(result.success).toBe(true);
expect(result.localTagsDeleted).toBe(3);
expect(result.remoteTagsDeleted).toBe(3);
expect(result.localTagNames).toEqual([
"archive/test-1",
"archive/test-2",
"archive/test-3",
]);
});
});
+484
View File
@@ -0,0 +1,484 @@
import {
afterEach,
beforeEach,
describe,
expect,
mock,
spyOn,
test,
} from "bun:test";
import * as os from "node:os";
import * as readline from "node:readline";
import { closeMultiModel } from "../src/core/close";
const originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
let mockCommandResponses: Record<
string,
{ ok: boolean; stdout: string; stderr: string }
> = {};
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);
const response = mockCommandResponses[commandSignature] ?? {
ok: true,
stdout: "",
stderr: "",
};
return {
quiet: () => ({
nothrow: async () => ({
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("closeMultiModel", () => {
let _bunMock: ReturnType<typeof setupMockBun$>;
beforeEach(() => {
spyOn(os, "homedir").mockReturnValue("/home/user");
_bunMock = setupMockBun$();
executedCommands = [];
mockCommandResponses = {};
});
afterEach(() => {
restoreOriginalBun$();
});
test("kills existing session without cleanup", async () => {
mockCommandResponses["tmux has-session -t test-session"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["tmux kill-session -t test-session"] = {
ok: true,
stdout: "",
stderr: "",
};
// No worktrees
mockCommandResponses["git worktree list --porcelain"] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await closeMultiModel({
sessionName: "test-session",
cleanupWorktrees: false,
force: false,
});
expect(result.success).toBe(true);
expect(result.cleanupPerformed).toBe(false);
expect(result.worktreesRemoved).toEqual([]);
expect(executedCommands).toContain("tmux has-session -t test-session");
expect(executedCommands).toContain("tmux kill-session -t test-session");
});
test("no session exists, no cleanup", async () => {
mockCommandResponses["tmux has-session -t test-session"] = {
ok: false,
stdout: "",
stderr: "",
};
mockCommandResponses["git worktree list --porcelain"] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await closeMultiModel({
sessionName: "test-session",
cleanupWorktrees: false,
force: false,
});
expect(result.success).toBe(true);
expect(result.cleanupPerformed).toBe(false);
expect(result.worktreesRemoved).toEqual([]);
expect(executedCommands).toContain("tmux has-session -t test-session");
// No kill-session should be issued
expect(
executedCommands.some((c) => c.startsWith("tmux kill-session")),
).toBe(false);
});
test("cleanup works with worktrees and force, all succeeds", async () => {
mockCommandResponses["tmux has-session -t test-session"] = {
ok: false,
stdout: "",
stderr: "",
};
const worktreePath =
"/home/user/.local/share/opencode/multi-model/test-session/model";
const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/test-session/model`;
mockCommandResponses["git worktree list --porcelain"] = {
ok: true,
stdout: worktreeListOutput,
stderr: "",
};
mockCommandResponses[`git worktree remove -f ${worktreePath}`] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git branch -D opencode/test-session/model"] = {
ok: true,
stdout: "",
stderr: "",
};
// Tag command will default to ok:true via fallback
mockCommandResponses[
"rm -rf /home/user/.local/share/opencode/multi-model/test-session"
] = { ok: true, stdout: "", stderr: "" };
// Freeze timestamp for deterministic tag name
const fixedDate = new Date("2023-01-01T00:00:00.000Z");
const OriginalDate = Date;
global.Date = class extends OriginalDate {
constructor() {
super();
// biome-ignore lint/correctness/noConstructorReturn: time-mocking required
return fixedDate;
}
override toISOString() {
return "2023-01-01T00:00:00.000Z";
}
} as any;
const result = await closeMultiModel({
sessionName: "test-session",
cleanupWorktrees: true,
force: true,
});
// Restore Date
global.Date = OriginalDate;
expect(result.success).toBe(true);
expect(result.cleanupPerformed).toBe(true);
expect(result.worktreesRemoved).toEqual([worktreePath]);
expect(result.branchesDeleted).toEqual(["opencode/test-session/model"]);
expect(result.tagsCreated?.length).toBe(1);
// Ensure removal commands were executed
expect(executedCommands).toContain(
`git worktree remove -f ${worktreePath}`,
);
expect(executedCommands).toContain(
`git branch -D opencode/test-session/model`,
);
});
test("user cancels cleanup when not forced", async () => {
mockCommandResponses["tmux has-session -t test-session"] = {
ok: false,
stdout: "",
stderr: "",
};
const worktreePath =
"/home/user/.local/share/opencode/multi-model/test-session/model";
const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/test-session/model`;
mockCommandResponses["git worktree list --porcelain"] = {
ok: true,
stdout: worktreeListOutput,
stderr: "",
};
// Stub readline to return "n"
const mockInterface = {
question: (_q: string, cb: (a: string) => void) => cb("n"),
close: () => {},
} as any;
spyOn(readline, "createInterface").mockImplementation(() => mockInterface);
const result = await closeMultiModel({
sessionName: "test-session",
cleanupWorktrees: true,
force: false,
});
expect(result.success).toBe(false);
expect(result.error).toBe("Cleanup cancelled by user");
});
test("user confirms cleanup when not forced", async () => {
mockCommandResponses["tmux has-session -t test-session"] = {
ok: false,
stdout: "",
stderr: "",
};
const worktreePath =
"/home/user/.local/share/opencode/multi-model/test-session/model";
const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/test-session/model`;
mockCommandResponses["git worktree list --porcelain"] = {
ok: true,
stdout: worktreeListOutput,
stderr: "",
};
// Stub readline to simulate user confirming cleanup (answers "y")
const mockInterface = {
question: (_q: string, cb: (a: string) => void) => cb("y"),
close: () => {},
} as any;
spyOn(readline, "createInterface").mockImplementation(() => mockInterface);
// Freeze timestamp for deterministic tag name
const fixedDate = new Date("2023-01-01T00:00:00.000Z");
const OriginalDate = Date;
global.Date = class extends OriginalDate {
constructor() {
super();
// biome-ignore lint/correctness/noConstructorReturn: time-mocking required
return fixedDate;
}
override toISOString() {
return "2023-01-01T00:00:00.000Z";
}
} as any;
const result = await closeMultiModel({
sessionName: "test-session",
cleanupWorktrees: true,
force: false,
});
// Restore Date after invocation
global.Date = OriginalDate;
// Verify result indicates successful cleanup
expect(result.success).toBe(true);
expect(result.cleanupPerformed).toBe(true);
expect(result.worktreesRemoved).toEqual([worktreePath]);
expect(result.branchesDeleted).toEqual(["opencode/test-session/model"]);
expect(result.tagsCreated?.length).toBe(1);
// Verify the expected git commands were executed
expect(executedCommands).toContain(
`git worktree remove -f ${worktreePath}`,
);
expect(executedCommands).toContain(
`git branch -D opencode/test-session/model`,
);
const expectedTagCmd = `git tag archive/opencode/test-session/model-2023-01-01T00-00-00-000Z opencode/test-session/model`;
expect(executedCommands).toContain(expectedTagCmd);
});
test("warning when tag creation fails", async () => {
mockCommandResponses["tmux has-session -t test-session"] = {
ok: false,
stdout: "",
stderr: "",
};
const worktreePath =
"/home/user/.local/share/opencode/multi-model/test-session/model";
const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/test-session/model`;
mockCommandResponses["git worktree list --porcelain"] = {
ok: true,
stdout: worktreeListOutput,
stderr: "",
};
mockCommandResponses[`git worktree remove -f ${worktreePath}`] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git branch -D opencode/test-session/model"] = {
ok: true,
stdout: "",
stderr: "",
};
// Tag command will fail
const fixedDate = new Date("2023-01-01T00:00:00.000Z");
const OriginalDate = Date;
global.Date = class extends OriginalDate {
constructor() {
super();
// biome-ignore lint/correctness/noConstructorReturn: time-mocking required
return fixedDate;
}
override toISOString() {
return "2023-01-01T00:00:00.000Z";
}
} as any;
const tagCommand = `git tag archive/opencode/test-session/model-2023-01-01T00-00-00-000Z opencode/test-session/model`;
mockCommandResponses[tagCommand] = {
ok: false,
stdout: "",
stderr: "tag error",
};
const result = await closeMultiModel({
sessionName: "test-session",
cleanupWorktrees: true,
force: true,
});
// Restore Date
global.Date = OriginalDate;
expect(result.success).toBe(true);
// Expect the warning message to be captured in instructions
expect(result.instructions.includes("Failed to create tag")).toBe(true);
expect(result.tagsCreated).toEqual([]);
});
test("cleanup works with unsanitized session name", async () => {
const unsanitized = "test session!";
const sanitized = "test-session"; // result of sanitizeName
// tmux lookup uses unsanitized name
mockCommandResponses[`tmux has-session -t ${unsanitized}`] = {
ok: false,
stdout: "",
stderr: "",
};
const worktreePath = `/home/user/.local/share/opencode/multi-model/${sanitized}/model`;
const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/${sanitized}/model`;
mockCommandResponses["git worktree list --porcelain"] = {
ok: true,
stdout: worktreeListOutput,
stderr: "",
};
mockCommandResponses[`git worktree remove -f ${worktreePath}`] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses[`git branch -D opencode/${sanitized}/model`] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses[
`rm -rf /home/user/.local/share/opencode/multi-model/${sanitized}`
] = { ok: true, stdout: "", stderr: "" };
// Freeze timestamp for deterministic tag name
const fixedDate = new Date("2023-01-01T00:00:00.000Z");
const OriginalDate = Date;
global.Date = class extends OriginalDate {
constructor() {
super();
// biome-ignore lint/correctness/noConstructorReturn: time-mocking required
return fixedDate;
}
override toISOString() {
return "2023-01-01T00:00:00.000Z";
}
} as any;
const result = await closeMultiModel({
sessionName: unsanitized,
cleanupWorktrees: true,
force: true,
});
// Restore Date
global.Date = OriginalDate;
expect(result.success).toBe(true);
expect(result.cleanupPerformed).toBe(true);
expect(result.worktreesRemoved).toEqual([worktreePath]);
expect(result.branchesDeleted).toEqual([`opencode/${sanitized}/model`]);
expect(result.tagsCreated?.length).toBe(1);
// Verify that the commands used the sanitized paths for worktree removal and branch deletion
expect(executedCommands).toContain(
`git worktree remove -f ${worktreePath}`,
);
expect(executedCommands).toContain(
`git branch -D opencode/${sanitized}/model`,
);
});
test("tmux commands use sessionName, not safeSessionName", async () => {
const unsanitized = "my session!";
const sanitized = "my-session";
mockCommandResponses[`tmux has-session -t ${unsanitized}`] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses[`tmux kill-session -t ${unsanitized}`] = {
ok: true,
stdout: "",
stderr: "",
};
// No worktrees
mockCommandResponses["git worktree list --porcelain"] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await closeMultiModel({
sessionName: unsanitized,
cleanupWorktrees: false,
force: false,
});
expect(result.success).toBe(true);
// Verify tmux commands used unsanitized name
expect(executedCommands).toContain(`tmux has-session -t ${unsanitized}`);
expect(executedCommands).toContain(`tmux kill-session -t ${unsanitized}`);
// Ensure sanitized name not used in tmux commands
expect(executedCommands.some((c) => c.includes(`-t ${sanitized}`))).toBe(
false,
);
});
test("skip archive tags when flag disabled", async () => {
mockCommandResponses["tmux has-session -t test-session"] = {
ok: false,
stdout: "",
stderr: "",
};
const worktreePath =
"/home/user/.local/share/opencode/multi-model/test-session/model";
const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/test-session/model`;
mockCommandResponses["git worktree list --porcelain"] = {
ok: true,
stdout: worktreeListOutput,
stderr: "",
};
mockCommandResponses[`git worktree remove -f ${worktreePath}`] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git branch -D opencode/test-session/model"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses[
"rm -rf /home/user/.local/share/opencode/multi-model/test-session"
] = { ok: true, stdout: "", stderr: "" };
const result = await closeMultiModel({
sessionName: "test-session",
cleanupWorktrees: true,
force: true,
createArchiveTags: false,
});
expect(result.success).toBe(true);
expect(result.cleanupPerformed).toBe(true);
expect(result.worktreesRemoved).toEqual([worktreePath]);
expect(result.branchesDeleted).toEqual(["opencode/test-session/model"]);
expect(result.tagsCreated?.length ?? 0).toBe(0);
// Ensure no tag command was executed
expect(executedCommands.some((cmd) => cmd.startsWith("git tag"))).toBe(
false,
);
});
});
+72
View File
@@ -0,0 +1,72 @@
import {
afterEach,
beforeEach,
describe,
expect,
mock,
spyOn,
test,
} from "bun:test";
import * as os from "node:os";
import { closeMultiModel } from "../src/core/close";
const originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
let mockCommandResponses: Record<
string,
{ ok: boolean; stdout: string; stderr: string }
> = {};
let executedCommands: string[] = [];
function setupMockBun$() {
const mockFn = mock((_strings: TemplateStringsArray, ...values: any[]) => {
const parts = values[0] as string[];
const cmd = parts.join(" ");
executedCommands.push(cmd);
// Simulate throwing error for a specific command
if (cmd === "git worktree list --porcelain") {
throw new Error("unexpected error");
}
const response = mockCommandResponses[cmd] ?? {
ok: true,
stdout: "",
stderr: "",
};
return {
quiet: () => ({
nothrow: async () => ({
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("closeMultiModel unexpected error handling", () => {
beforeEach(() => {
spyOn(os, "homedir").mockReturnValue("/home/user");
mockCommandResponses = {};
executedCommands = [];
setupMockBun$();
});
afterEach(() => {
restoreOriginalBun$();
});
test("catches thrown error and returns failure", async () => {
const result = await closeMultiModel({
sessionName: "any-session",
cleanupWorktrees: false,
force: false,
});
expect(result.success).toBe(false);
expect(result.error).toContain("unexpected error");
});
});
-709
View File
@@ -1,709 +0,0 @@
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,
getSessionPath,
undoWorktree,
launchModelInWindow,
getWorktreesForSession,
getBinaryName,
} from "../src/core/utils";
let originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
let mockCommandResponses: Record<string, { ok: boolean; stdout: string; stderr: string }> = {};
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<typeof spyOn>;
let bunMock: ReturnType<typeof setupMockBun$>;
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-5.2\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-5-2": { ok: false, stdout: "", stderr: "" },
};
});
afterEach(() => {
restoreOriginalBun$();
});
test("fails if session name is empty", async () => {
const result = await launchMultiModel({
sessionName: "",
models: ["openai/gpt-5.2"],
});
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-5.2", "openai/gpt-5.2"],
});
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-5.2"],
});
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-5.2"],
});
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-5.2"],
});
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-5.2"],
});
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-5.2"],
});
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-5-2"))).toBe(true);
expect(executedCommands.some(c => c.startsWith("tmux send-keys -t test-session:gpt-5-2"))).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-5.2", "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-5-2"))).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-5-2"] = { ok: false, stdout: "", stderr: "" };
const result = await launchMultiModel({
sessionName: "test session!",
models: ["openai/gpt-5.2"],
});
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-5-2"))).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-5-2 -c /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "tmux error" };
mockCommandResponses["git worktree remove -f /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses["git branch -D opencode/test-session/gpt-5-2"] = { ok: true, stdout: "", stderr: "" };
const result = await launchMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
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-5-2",
"git worktree add -b opencode/test-session/gpt-5-2 /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2",
"tmux new-session -d -s test-session -n gpt-5-2 -c /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2",
"git worktree remove -f /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2",
"git branch -D opencode/test-session/gpt-5-2"
]);
});
test("fails if worktree path exists for first model", async () => {
existsSyncMock.mockImplementation((path: string) => {
return path.includes("test-session/gpt-5-2");
});
const result = await launchMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
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-5-2"] = { ok: true, stdout: "", stderr: "" };
const result = await launchMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Branch 'opencode/test-session/gpt-5-2' 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-5-2"
]);
});
test("fails if git worktree add fails for first model", async () => {
mockCommandResponses["git worktree add -b opencode/test-session/gpt-5-2 /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "git error" };
const result = await launchMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
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-5-2",
"git worktree add -b opencode/test-session/gpt-5-2 /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"
]);
});
test("model name collision creates unique window names", async () => {
mockCommandResponses["opencode models"] = { ok: true, stdout: "openai/gpt-5.2\nanthropic/gpt-5.2", stderr: "" };
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2-2"] = { ok: false, stdout: "", stderr: "" };
const result = await launchMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2", "anthropic/gpt-5.2"],
});
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-5-2"))).toBe(true);
expect(executedCommands.some(c => c.startsWith("tmux new-window -d -t test-session -n gpt-5-2-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", () => {
let bunMock: ReturnType<typeof setupMockBun$>;
beforeEach(() => {
spyOn(os, "homedir").mockReturnValue("/mock/home");
bunMock = setupMockBun$();
executedCommands = [];
mockCommandResponses = {};
});
afterEach(() => {
restoreOriginalBun$();
});
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-5.2 ", "", " anthropic/claude "])).toEqual(["openai/gpt-5.2", "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-5-2")).toBe("gpt-5-2");
});
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-5-2"])).toEqual([{ model: "openai/gpt-5-2", windowName: "gpt-5-2" }]);
});
test("two models with the same base name", () => {
expect(createWindowPlans(["openai/gpt-5-2", "anthropic/gpt-5-2"])).toEqual([
{ model: "openai/gpt-5-2", windowName: "gpt-5-2" },
{ model: "anthropic/gpt-5-2", windowName: "gpt-5-2-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-5.2", ["gpt-5.2", "gpt-5.2-mini"]);
expect(result[0]).toBe("gpt-5.2");
});
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-5.2", "gpt-5.2-mini"]);
expect(suggestions[0]).toBe("gpt-5.2");
});
test("maximum of 3 suggestions", () => {
const allowlist = ["gpt-5.2", "gpt-5.2-mini", "gpt-5.2-pro", "gpt-5.2-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-5.2"]);
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");
});
});
describe("runCommand", () => {
test("returns ok true for successful command", async () => {
mockCommandResponses["echo hello"] = { ok: true, stdout: "hello", stderr: "" };
const result = await runCommand(["echo", "hello"]);
expect(result.ok).toBe(true);
expect(result.stdout).toBe("hello");
expect(result.stderr).toBe("");
});
test("returns ok false for failed command", async () => {
mockCommandResponses["false"] = { ok: false, stdout: "", stderr: "error" };
const result = await runCommand(["false"]);
expect(result.ok).toBe(false);
expect(result.stderr).toBe("error");
});
});
describe("getWorktreePath", () => {
test("returns correct worktree path", () => {
spyOn(os, "homedir").mockReturnValue("/home/user");
const result = getWorktreePath("my-session", "gpt-5.2");
expect(result).toBe("/home/user/.local/share/opencode/multi-model/my-session/gpt-5.2");
});
test("handles nested session names", () => {
spyOn(os, "homedir").mockReturnValue("/home/user");
const result = getWorktreePath("parent/child", "window");
expect(result).toBe("/home/user/.local/share/opencode/multi-model/parent/child/window");
});
});
describe("getSessionPath", () => {
test("returns correct session path", () => {
spyOn(os, "homedir").mockReturnValue("/home/user");
const result = getSessionPath("my-session");
expect(result).toBe("/home/user/.local/share/opencode/multi-model/my-session");
});
});
describe("undoWorktree", () => {
test("returns empty array on success", async () => {
mockCommandResponses["git worktree remove -f /path/to/worktree"] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses["git branch -D opencode/session/window"] = { ok: true, stdout: "", stderr: "" };
const errors = await undoWorktree("/path/to/worktree", "opencode/session/window");
expect(errors).toEqual([]);
});
test("returns error when worktree remove fails", async () => {
mockCommandResponses["git worktree remove -f /path/to/worktree"] = { ok: false, stdout: "", stderr: "remove failed" };
mockCommandResponses["git branch -D opencode/session/window"] = { ok: true, stdout: "", stderr: "" };
const errors = await undoWorktree("/path/to/worktree", "opencode/session/window");
expect(errors.length).toBe(1);
expect(errors[0]).toContain("Failed to remove worktree");
});
test("returns error when branch delete fails", async () => {
mockCommandResponses["git worktree remove -f /path/to/worktree"] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses["git branch -D opencode/session/window"] = { ok: false, stdout: "", stderr: "branch delete failed" };
const errors = await undoWorktree("/path/to/worktree", "opencode/session/window");
expect(errors.length).toBe(1);
expect(errors[0]).toContain("Failed to delete branch");
});
test("returns both errors when both operations fail", async () => {
mockCommandResponses["git worktree remove -f /path/to/worktree"] = { ok: false, stdout: "", stderr: "remove failed" };
mockCommandResponses["git branch -D opencode/session/window"] = { ok: false, stdout: "", stderr: "branch delete failed" };
const errors = await undoWorktree("/path/to/worktree", "opencode/session/window");
expect(errors.length).toBe(2);
});
});
describe("launchModelInWindow", () => {
test("sends correct tmux command", async () => {
mockCommandResponses["tmux send-keys -t session:window opencode --model 'openai/gpt-5.2' C-m"] = { ok: true, stdout: "", stderr: "" };
const result = await launchModelInWindow("session", { model: "openai/gpt-5.2", windowName: "window" });
expect(result.ok).toBe(true);
expect(executedCommands).toContain("tmux send-keys -t session:window opencode --model 'openai/gpt-5.2' C-m");
});
test("uses custom binary name", async () => {
mockCommandResponses["tmux send-keys -t session:window kilo --model 'openai/gpt-5.2' C-m"] = { ok: true, stdout: "", stderr: "" };
const result = await launchModelInWindow("session", { model: "openai/gpt-5.2", windowName: "window" }, "kilo");
expect(result.ok).toBe(true);
expect(executedCommands).toContain("tmux send-keys -t session:window kilo --model 'openai/gpt-5.2' C-m");
});
});
describe("getWorktreesForSession", () => {
test("returns session-specific worktrees", async () => {
spyOn(os, "homedir").mockReturnValue("/home/user");
mockCommandResponses["git worktree list --porcelain"] = {
ok: true,
stdout: "worktree /home/user/.local/share/opencode/multi-model/my-session/gpt-5.2\nbranch refs/heads/opencode/my-session/gpt-5.2\n\nworktree /home/user/other-repo\nbranch refs/heads/main",
stderr: "",
};
const worktrees = await getWorktreesForSession("my-session");
expect(worktrees.length).toBe(1);
expect(worktrees[0]?.path).toBe("/home/user/.local/share/opencode/multi-model/my-session/gpt-5.2");
expect(worktrees[0]?.branch).toBe("opencode/my-session/gpt-5.2");
});
test("returns empty array when no matching worktrees", async () => {
spyOn(os, "homedir").mockReturnValue("/home/user");
mockCommandResponses["git worktree list --porcelain"] = {
ok: true,
stdout: "worktree /home/user/other-repo\nbranch refs/heads/main",
stderr: "",
};
const worktrees = await getWorktreesForSession("my-session");
expect(worktrees).toEqual([]);
});
});
describe("getBinaryName", () => {
test("returns default 'opencode' when env var not set", () => {
delete process.env.OPENCODE_MULTI_MODEL_BINARY;
expect(getBinaryName()).toBe("opencode");
});
test("returns env var value when set", () => {
process.env.OPENCODE_MULTI_MODEL_BINARY = "kilo";
expect(getBinaryName()).toBe("kilo");
delete process.env.OPENCODE_MULTI_MODEL_BINARY;
});
});
});
+526
View File
@@ -0,0 +1,526 @@
import {
afterEach,
beforeEach,
describe,
expect,
mock,
spyOn,
test,
} from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import { openMultiModel } from "../src/core/open";
const originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
let mockCommandResponses: Record<
string,
{ ok: boolean; stdout: string; stderr: string }
> = {};
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<typeof spyOn>;
let _bunMock: ReturnType<typeof setupMockBun$>;
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-5.2\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-5-2":
{ ok: false, stdout: "", stderr: "" },
};
});
afterEach(() => {
restoreOriginalBun$();
});
test("fails if session name is empty", async () => {
const result = await openMultiModel({
sessionName: "",
models: ["openai/gpt-5.2"],
});
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 openMultiModel({
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 openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2", "openai/gpt-5.2"],
});
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 openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
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 openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
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 openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
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 openMultiModel({
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 openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
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 openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
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-5-2"),
),
).toBe(true);
expect(
executedCommands.some((c) =>
c.startsWith("tmux send-keys -t test-session:gpt-5-2"),
),
).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 openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2", "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-5-2"),
),
).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-5-2"
] = { ok: false, stdout: "", stderr: "" };
const result = await openMultiModel({
sessionName: "test session!",
models: ["openai/gpt-5.2"],
});
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-5-2"),
),
).toBe(true);
});
test("uses sessionName for tmux commands, not safeSessionName", async () => {
const unsanitized = "my awesome session!!";
const sanitized = "my-awesome-session";
mockCommandResponses[`tmux has-session -t ${unsanitized}`] = {
ok: false,
stdout: "",
stderr: "",
};
mockCommandResponses[
`git show-ref --verify --quiet refs/heads/opencode/${sanitized}/gpt-5-2`
] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses[
`git worktree add -b opencode/${sanitized}/gpt-5-2 /mock/home/.local/share/opencode/multi-model/${sanitized}/gpt-5-2`
] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[
`tmux new-session -d -s ${unsanitized} -n gpt-5-2 -c /mock/home/.local/share/opencode/multi-model/${sanitized}/gpt-5-2`
] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[
`tmux send-keys -t ${unsanitized}:gpt-5-2 opencode --model 'openai/gpt-5.2' C-m`
] = { ok: true, stdout: "", stderr: "" };
const result = await openMultiModel({
sessionName: unsanitized,
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(true);
// tmux commands should use unsanitized name
expect(
executedCommands.some((c) =>
c.startsWith(`tmux has-session -t ${unsanitized}`),
),
).toBe(true);
expect(
executedCommands.some((c) =>
c.startsWith(`tmux new-session -d -s ${unsanitized}`),
),
).toBe(true);
expect(
executedCommands.some((c) =>
c.startsWith(`tmux send-keys -t ${unsanitized}:`),
),
).toBe(true);
// sanitized name should NOT appear in tmux commands
expect(executedCommands.some((c) => c.includes(`-t ${sanitized}`))).toBe(
false,
);
// git branches and paths should use sanitized name
expect(
executedCommands.some((c) =>
c.startsWith(`git worktree add -b opencode/${sanitized}/gpt-5-2`),
),
).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-5-2 -c /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"
] = { ok: false, stdout: "", stderr: "tmux error" };
mockCommandResponses[
"git worktree remove -f /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"
] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses["git branch -D opencode/test-session/gpt-5-2"] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
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-5-2",
"git worktree add -b opencode/test-session/gpt-5-2 /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2",
"tmux new-session -d -s test-session -n gpt-5-2 -c /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2",
"git worktree remove -f /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2",
"git branch -D opencode/test-session/gpt-5-2",
]);
});
test("fails if worktree path exists for first model", async () => {
existsSyncMock.mockImplementation((path: string) => {
return path.includes("test-session/gpt-5-2");
});
const result = await openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
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-5-2"
] = { ok: true, stdout: "", stderr: "" };
const result = await openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain(
"Branch 'opencode/test-session/gpt-5-2' 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-5-2",
]);
});
test("fails if git worktree add fails for first model", async () => {
mockCommandResponses[
"git worktree add -b opencode/test-session/gpt-5-2 /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"
] = { ok: false, stdout: "", stderr: "git error" };
const result = await openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
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-5-2",
"git worktree add -b opencode/test-session/gpt-5-2 /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2",
]);
});
test("model name collision creates unique window names", async () => {
mockCommandResponses["opencode models"] = {
ok: true,
stdout: "openai/gpt-5.2\nanthropic/gpt-5.2",
stderr: "",
};
mockCommandResponses[
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"
] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses[
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2-2"
] = { ok: false, stdout: "", stderr: "" };
const result = await openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2", "anthropic/gpt-5.2"],
});
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-5-2"),
),
).toBe(true);
expect(
executedCommands.some((c) =>
c.startsWith("tmux new-window -d -t test-session -n gpt-5-2-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 openMultiModel({
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);
});
});
// ----------
// Core utils tests remain in a separate file.
+539
View File
@@ -0,0 +1,539 @@
import {
afterEach,
beforeEach,
describe,
expect,
mock,
spyOn,
test,
} from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import { openMultiModel } from "../src/core/open";
const originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
let mockCommandResponses: Record<
string,
{ ok: boolean; stdout: string; stderr: string }
> = {};
let executedCommands: string[] = [];
function setupMockBun$() {
const mockFn = mock((_strings: TemplateStringsArray, ...values: any[]) => {
const parts = values[0] as string[];
const cmd = parts.join(" ");
executedCommands.push(cmd);
const response = mockCommandResponses[cmd] ?? {
ok: true,
stdout: "",
stderr: "",
};
return {
quiet: () => ({
nothrow: async () => ({
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("openMultiModel error paths", () => {
beforeEach(() => {
spyOn(os, "homedir").mockReturnValue("/mock/home");
mockCommandResponses = {};
executedCommands = [];
setupMockBun$();
});
afterEach(() => {
restoreOriginalBun$();
});
test("fails when model list command fails", async () => {
const session = "sess";
mockCommandResponses["git rev-parse --is-inside-work-tree"] = {
ok: true,
stdout: "true",
stderr: "",
};
mockCommandResponses["command -v tmux"] = {
ok: true,
stdout: "tmux",
stderr: "",
};
mockCommandResponses["command -v opencode"] = {
ok: true,
stdout: "opencode",
stderr: "",
};
mockCommandResponses["opencode models"] = {
ok: false,
stdout: "",
stderr: "model error",
};
const result = await openMultiModel({
sessionName: session,
models: ["openai/gpt-5.2"],
binaryName: "opencode",
mode: "cli",
});
expect(result.success).toBe(false);
expect(result.error).toContain("Failed to load valid models");
});
test("fails when duplicate models provided", async () => {
const session = "dup";
mockCommandResponses["git rev-parse --is-inside-work-tree"] = {
ok: true,
stdout: "true",
stderr: "",
};
mockCommandResponses["command -v tmux"] = {
ok: true,
stdout: "tmux",
stderr: "",
};
mockCommandResponses["command -v opencode"] = {
ok: true,
stdout: "opencode",
stderr: "",
};
mockCommandResponses["opencode models"] = {
ok: true,
stdout: "openai/gpt-5.2",
stderr: "",
};
const result = await openMultiModel({
sessionName: session,
models: ["openai/gpt-5.2", "openai/gpt-5.2"],
binaryName: "opencode",
mode: "cli",
});
expect(result.success).toBe(false);
expect(result.error).toContain("Duplicate model names are not allowed");
});
test("fails when session already exists", async () => {
const session = "existing";
mockCommandResponses["git rev-parse --is-inside-work-tree"] = {
ok: true,
stdout: "true",
stderr: "",
};
mockCommandResponses["command -v tmux"] = {
ok: true,
stdout: "tmux",
stderr: "",
};
mockCommandResponses["command -v opencode"] = {
ok: true,
stdout: "opencode",
stderr: "",
};
mockCommandResponses["opencode models"] = {
ok: true,
stdout: "openai/gpt-5.2",
stderr: "",
};
mockCommandResponses[`tmux has-session -t ${session}`] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await openMultiModel({
sessionName: session,
models: ["openai/gpt-5.2"],
binaryName: "opencode",
mode: "cli",
});
expect(result.success).toBe(false);
expect(result.error).toContain("Session already exists");
});
test("second model fails because branch already exists", async () => {
const session = "sess-add";
const binary = "opencode";
const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"];
// common mocks
mockCommandResponses["git rev-parse --is-inside-work-tree"] = {
ok: true,
stdout: "true",
stderr: "",
};
mockCommandResponses["command -v tmux"] = {
ok: true,
stdout: "tmux",
stderr: "",
};
mockCommandResponses["command -v opencode"] = {
ok: true,
stdout: "opencode",
stderr: "",
};
mockCommandResponses["opencode models"] = {
ok: true,
stdout: models.join("\n"),
stderr: "",
};
mockCommandResponses[`tmux has-session -t ${session}`] = {
ok: false,
stdout: "",
stderr: "",
};
// first model success
const firstBranch = `opencode/${session}/gpt-5-2`;
const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`;
mockCommandResponses[
`git show-ref --verify --quiet refs/heads/${firstBranch}`
] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses[
`tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}`
] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[
`tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m`
] = { ok: true, stdout: "", stderr: "" };
// second model branch exists
const secondBranch = `opencode/${session}/gpt-5-2-2`;
mockCommandResponses[
`git show-ref --verify --quiet refs/heads/${secondBranch}`
] = { ok: true, stdout: "", stderr: "" };
const result = await openMultiModel({
sessionName: session,
models,
binaryName: binary,
mode: "cli",
});
expect(result.success).toBe(true);
expect(result.instructions).toContain("Failed: anthropic/gpt-5-2 (Branch");
expect(result.windows).toContain("gpt-5-2");
expect(result.windows).toContain("gpt-5-2-2");
});
test("second model worktree creation fails", async () => {
const session = "sess-wt";
const binary = "opencode";
const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"];
mockCommandResponses["git rev-parse --is-inside-work-tree"] = {
ok: true,
stdout: "true",
stderr: "",
};
mockCommandResponses["command -v tmux"] = {
ok: true,
stdout: "tmux",
stderr: "",
};
mockCommandResponses["command -v opencode"] = {
ok: true,
stdout: "opencode",
stderr: "",
};
mockCommandResponses["opencode models"] = {
ok: true,
stdout: models.join("\n"),
stderr: "",
};
mockCommandResponses[`tmux has-session -t ${session}`] = {
ok: false,
stdout: "",
stderr: "",
};
// first model success
const firstBranch = `opencode/${session}/gpt-5-2`;
const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`;
mockCommandResponses[
`git show-ref --verify --quiet refs/heads/${firstBranch}`
] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses[
`tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}`
] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[
`tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m`
] = { ok: true, stdout: "", stderr: "" };
// second model fails worktree
const secondBranch = `opencode/${session}/gpt-5-2-2`;
const secondPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2-2`;
mockCommandResponses[
`git show-ref --verify --quiet refs/heads/${secondBranch}`
] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses[`git worktree add -b ${secondBranch} ${secondPath}`] =
{ ok: false, stdout: "", stderr: "wt error" };
mockCommandResponses[`git worktree remove -f ${secondPath}`] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses[`git branch -D ${secondBranch}`] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await openMultiModel({
sessionName: session,
models,
binaryName: binary,
mode: "cli",
});
expect(result.success).toBe(true);
expect(result.instructions).toContain("Error: Created tmux session");
expect(result.instructions).toContain("Failed: anthropic/gpt-5-2");
expect(result.windows).toContain("gpt-5-2");
expect(result.windows).toContain("gpt-5-2-2");
});
test("second model window creation fails", async () => {
const session = "sess-win";
const binary = "opencode";
const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"];
mockCommandResponses["git rev-parse --is-inside-work-tree"] = {
ok: true,
stdout: "true",
stderr: "",
};
mockCommandResponses["command -v tmux"] = {
ok: true,
stdout: "tmux",
stderr: "",
};
mockCommandResponses["command -v opencode"] = {
ok: true,
stdout: "opencode",
stderr: "",
};
mockCommandResponses["opencode models"] = {
ok: true,
stdout: models.join("\n"),
stderr: "",
};
mockCommandResponses[`tmux has-session -t ${session}`] = {
ok: false,
stdout: "",
stderr: "",
};
// first model success
const firstBranch = `opencode/${session}/gpt-5-2`;
const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`;
mockCommandResponses[
`git show-ref --verify --quiet refs/heads/${firstBranch}`
] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses[
`tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}`
] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[
`tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m`
] = { ok: true, stdout: "", stderr: "" };
// second model window failure
const secondBranch = `opencode/${session}/gpt-5-2-2`;
const secondPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2-2`;
mockCommandResponses[
`git show-ref --verify --quiet refs/heads/${secondBranch}`
] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses[`git worktree add -b ${secondBranch} ${secondPath}`] =
{ ok: true, stdout: "", stderr: "" };
mockCommandResponses[
`tmux new-window -d -t ${session} -n gpt-5-2-2 -c ${secondPath}`
] = { ok: false, stdout: "", stderr: "win err" };
mockCommandResponses[`git worktree remove -f ${secondPath}`] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses[`git branch -D ${secondBranch}`] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await openMultiModel({
sessionName: session,
models,
binaryName: binary,
mode: "cli",
});
expect(result.success).toBe(true);
expect(result.instructions).toContain("Error: Created tmux session");
expect(result.instructions).toContain("Failed: anthropic/gpt-5-2");
expect(result.windows).toContain("gpt-5-2");
expect(result.windows).toContain("gpt-5-2-2");
});
test("second model launch command fails", async () => {
const session = "sess-launch";
const binary = "opencode";
const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"];
mockCommandResponses["git rev-parse --is-inside-work-tree"] = {
ok: true,
stdout: "true",
stderr: "",
};
mockCommandResponses["command -v tmux"] = {
ok: true,
stdout: "tmux",
stderr: "",
};
mockCommandResponses["command -v opencode"] = {
ok: true,
stdout: "opencode",
stderr: "",
};
mockCommandResponses["opencode models"] = {
ok: true,
stdout: models.join("\n"),
stderr: "",
};
mockCommandResponses[`tmux has-session -t ${session}`] = {
ok: false,
stdout: "",
stderr: "",
};
// first model success
const firstBranch = `opencode/${session}/gpt-5-2`;
const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`;
mockCommandResponses[
`git show-ref --verify --quiet refs/heads/${firstBranch}`
] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses[
`tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}`
] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[
`tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m`
] = { ok: true, stdout: "", stderr: "" };
// second model launch fails
const secondBranch = `opencode/${session}/gpt-5-2-2`;
const secondPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2-2`;
mockCommandResponses[
`git show-ref --verify --quiet refs/heads/${secondBranch}`
] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses[`git worktree add -b ${secondBranch} ${secondPath}`] =
{ ok: true, stdout: "", stderr: "" };
mockCommandResponses[
`tmux new-window -d -t ${session} -n gpt-5-2-2 -c ${secondPath}`
] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[
`tmux send-keys -t ${session}:gpt-5-2-2 ${binary} --model 'anthropic/gpt-5-2' C-m`
] = { ok: false, stdout: "", stderr: "launch err" };
mockCommandResponses[`git worktree remove -f ${secondPath}`] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses[`git branch -D ${secondBranch}`] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await openMultiModel({
sessionName: session,
models,
binaryName: binary,
mode: "cli",
});
expect(result.success).toBe(true);
expect(result.instructions).toContain(
"Failed: anthropic/gpt-5-2 (launch err",
);
expect(result.windows).toContain("gpt-5-2");
expect(result.windows).toContain("gpt-5-2-2");
});
test("second model worktree path already exists", async () => {
const session = "sess-wtpath";
const binary = "opencode";
const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"];
mockCommandResponses["git rev-parse --is-inside-work-tree"] = {
ok: true,
stdout: "true",
stderr: "",
};
mockCommandResponses["command -v tmux"] = {
ok: true,
stdout: "tmux",
stderr: "",
};
mockCommandResponses["command -v opencode"] = {
ok: true,
stdout: "opencode",
stderr: "",
};
mockCommandResponses["opencode models"] = {
ok: true,
stdout: models.join("\n"),
stderr: "",
};
mockCommandResponses[`tmux has-session -t ${session}`] = {
ok: false,
stdout: "",
stderr: "",
};
// first model success
const firstBranch = `opencode/${session}/gpt-5-2`;
const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`;
mockCommandResponses[
`git show-ref --verify --quiet refs/heads/${firstBranch}`
] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses[
`tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}`
] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[
`tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m`
] = { ok: true, stdout: "", stderr: "" };
// second model path exists
const secondBranch = `opencode/${session}/gpt-5-2-2`;
const secondPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2-2`;
mockCommandResponses[
`git show-ref --verify --quiet refs/heads/${secondBranch}`
] = { ok: false, stdout: "", stderr: "" };
// mock fs existsSync to return true for secondPath
spyOn(fs, "existsSync").mockImplementation((p) => p === secondPath);
const result = await openMultiModel({
sessionName: session,
models,
binaryName: binary,
mode: "cli",
});
expect(result.success).toBe(true);
expect(result.instructions).toContain("Error: Created tmux session");
expect(result.instructions).toContain(
"Failed: anthropic/gpt-5-2 (Worktree path already exists at",
);
expect(result.windows).toContain("gpt-5-2");
expect(result.windows).toContain("gpt-5-2-2");
});
});
+487
View File
@@ -0,0 +1,487 @@
import {
afterEach,
beforeEach,
describe,
expect,
mock,
spyOn,
test,
} from "bun:test";
import * as os from "node:os";
import {
createWindowBaseName,
createWindowPlans,
findDuplicates,
formatInvalidModelError,
getBinaryName,
getSessionPath,
getWorktreePath,
getWorktreesForSession,
launchModelInWindow,
levenshtein,
normalizeModels,
runCommand,
sanitizeName,
shellQuote,
suggestModels,
undoWorktree,
} from "../src/core/utils";
const originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
let mockCommandResponses: Record<
string,
{ ok: boolean; stdout: string; stderr: string }
> = {};
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("core utils", () => {
let _bunMock: ReturnType<typeof setupMockBun$>;
beforeEach(() => {
spyOn(os, "homedir").mockReturnValue("/mock/home");
_bunMock = setupMockBun$();
executedCommands = [];
mockCommandResponses = {};
});
afterEach(() => {
restoreOriginalBun$();
});
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-5.2 ", "", " anthropic/claude "]),
).toEqual(["openai/gpt-5.2", "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-5-2")).toBe("gpt-5-2");
});
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-5-2"])).toEqual([
{ model: "openai/gpt-5-2", windowName: "gpt-5-2" },
]);
});
test("two models with the same base name", () => {
expect(
createWindowPlans(["openai/gpt-5-2", "anthropic/gpt-5-2"]),
).toEqual([
{ model: "openai/gpt-5-2", windowName: "gpt-5-2" },
{ model: "anthropic/gpt-5-2", windowName: "gpt-5-2-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-5.2", ["gpt-5.2", "gpt-5.2-mini"]);
expect(result[0]).toBe("gpt-5.2");
});
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-5.2", "gpt-5.2-mini"]);
expect(suggestions[0]).toBe("gpt-5.2");
});
test("maximum of 3 suggestions", () => {
const allowlist = [
"gpt-5.2",
"gpt-5.2-mini",
"gpt-5.2-pro",
"gpt-5.2-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-5.2"]);
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");
});
});
describe("runCommand", () => {
test("returns ok true for successful command", async () => {
mockCommandResponses["echo hello"] = {
ok: true,
stdout: "hello",
stderr: "",
};
const result = await runCommand(["echo", "hello"]);
expect(result.ok).toBe(true);
expect(result.stdout).toBe("hello");
expect(result.stderr).toBe("");
});
test("returns ok false for failed command", async () => {
mockCommandResponses.false = {
ok: false,
stdout: "",
stderr: "error",
};
const result = await runCommand(["false"]);
expect(result.ok).toBe(false);
expect(result.stderr).toBe("error");
});
});
describe("getWorktreePath", () => {
test("returns correct worktree path", () => {
spyOn(os, "homedir").mockReturnValue("/home/user");
const result = getWorktreePath("my-session", "gpt-5.2");
expect(result).toBe(
"/home/user/.local/share/opencode/multi-model/my-session/gpt-5.2",
);
});
test("handles nested session names", () => {
spyOn(os, "homedir").mockReturnValue("/home/user");
const result = getWorktreePath("parent/child", "window");
expect(result).toBe(
"/home/user/.local/share/opencode/multi-model/parent/child/window",
);
});
});
describe("getSessionPath", () => {
test("returns correct session path", () => {
spyOn(os, "homedir").mockReturnValue("/home/user");
const result = getSessionPath("my-session");
expect(result).toBe(
"/home/user/.local/share/opencode/multi-model/my-session",
);
});
});
describe("undoWorktree", () => {
test("returns empty array on success", async () => {
mockCommandResponses["git worktree remove -f /path/to/worktree"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git branch -D opencode/session/window"] = {
ok: true,
stdout: "",
stderr: "",
};
const errors = await undoWorktree(
"/path/to/worktree",
"opencode/session/window",
);
expect(errors).toEqual([]);
});
test("returns error when worktree remove fails", async () => {
mockCommandResponses["git worktree remove -f /path/to/worktree"] = {
ok: false,
stdout: "",
stderr: "remove failed",
};
mockCommandResponses["git branch -D opencode/session/window"] = {
ok: true,
stdout: "",
stderr: "",
};
const errors = await undoWorktree(
"/path/to/worktree",
"opencode/session/window",
);
expect(errors.length).toBe(1);
expect(errors[0]).toContain("Failed to remove worktree");
});
test("returns error when branch delete fails", async () => {
mockCommandResponses["git worktree remove -f /path/to/worktree"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git branch -D opencode/session/window"] = {
ok: false,
stdout: "",
stderr: "branch delete failed",
};
const errors = await undoWorktree(
"/path/to/worktree",
"opencode/session/window",
);
expect(errors.length).toBe(1);
expect(errors[0]).toContain("Failed to delete branch");
});
test("returns both errors when both operations fail", async () => {
mockCommandResponses["git worktree remove -f /path/to/worktree"] = {
ok: false,
stdout: "",
stderr: "remove failed",
};
mockCommandResponses["git branch -D opencode/session/window"] = {
ok: false,
stdout: "",
stderr: "branch delete failed",
};
const errors = await undoWorktree(
"/path/to/worktree",
"opencode/session/window",
);
expect(errors.length).toBe(2);
});
});
describe("launchModelInWindow", () => {
test("sends correct tmux command", async () => {
mockCommandResponses[
"tmux send-keys -t session:window opencode --model 'openai/gpt-5.2' C-m"
] = { ok: true, stdout: "", stderr: "" };
const result = await launchModelInWindow("session", {
model: "openai/gpt-5.2",
windowName: "window",
});
expect(result.ok).toBe(true);
expect(executedCommands).toContain(
"tmux send-keys -t session:window opencode --model 'openai/gpt-5.2' C-m",
);
});
test("uses custom binary name", async () => {
mockCommandResponses[
"tmux send-keys -t session:window kilo --model 'openai/gpt-5.2' C-m"
] = { ok: true, stdout: "", stderr: "" };
const result = await launchModelInWindow(
"session",
{ model: "openai/gpt-5.2", windowName: "window" },
"kilo",
);
expect(result.ok).toBe(true);
expect(executedCommands).toContain(
"tmux send-keys -t session:window kilo --model 'openai/gpt-5.2' C-m",
);
});
});
describe("getWorktreesForSession", () => {
test("returns session-specific worktrees", async () => {
spyOn(os, "homedir").mockReturnValue("/home/user");
mockCommandResponses["git worktree list --porcelain"] = {
ok: true,
stdout:
"worktree /home/user/.local/share/opencode/multi-model/my-session/gpt-5.2\nbranch refs/heads/opencode/my-session/gpt-5.2\n\nworktree /home/user/other-repo\nbranch refs/heads/main",
stderr: "",
};
const worktrees = await getWorktreesForSession("my-session");
expect(worktrees.length).toBe(1);
expect(worktrees[0]?.path).toBe(
"/home/user/.local/share/opencode/multi-model/my-session/gpt-5.2",
);
expect(worktrees[0]?.branch).toBe("opencode/my-session/gpt-5.2");
});
test("sanitizes session name when filtering worktrees", async () => {
spyOn(os, "homedir").mockReturnValue("/home/user");
mockCommandResponses["git worktree list --porcelain"] = {
ok: true,
stdout:
"worktree /home/user/.local/share/opencode/multi-model/my-session/gpt-5.2\nbranch refs/heads/opencode/my-session/gpt-5.2",
stderr: "",
};
const worktrees = await getWorktreesForSession("my session!");
expect(worktrees.length).toBe(1);
expect(worktrees[0]?.path).toBe(
"/home/user/.local/share/opencode/multi-model/my-session/gpt-5.2",
);
});
test("returns empty array when no matching worktrees", async () => {
spyOn(os, "homedir").mockReturnValue("/home/user");
mockCommandResponses["git worktree list --porcelain"] = {
ok: true,
stdout: "worktree /home/user/other-repo\nbranch refs/heads/main",
stderr: "",
};
const worktrees = await getWorktreesForSession("my-session");
expect(worktrees).toEqual([]);
});
});
describe("getBinaryName", () => {
test("returns default 'opencode' when env var not set", () => {
delete process.env.OPENCODE_MULTI_MODEL_BINARY;
expect(getBinaryName()).toBe("opencode");
});
test("returns env var value when set", () => {
process.env.OPENCODE_MULTI_MODEL_BINARY = "kilo";
expect(getBinaryName()).toBe("kilo");
delete process.env.OPENCODE_MULTI_MODEL_BINARY;
});
});
});
+3 -1
View File
@@ -12,7 +12,9 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"types": ["bun-types"]
"types": ["bun-types"],
"resolveJsonModule": true,
"esModuleInterop": true
},
"include": ["src/**/*", "tests/**/*"],
"exclude": ["node_modules", "dist"]