Compare commits

...
Author SHA1 Message Date
bendtherules 932e023fcf chore(release): 0.2.0 2026-03-23 15:49:36 +05:30
bendtherules 8f069c5582 chore(release): 0.1.6 2026-03-23 15:48:26 +05:30
bendtherules 1a58a318fd refactor(index): remove unused re-exports from src/index.ts 2026-03-23 15:48:13 +05:30
bendtherules e16f432c94 chore(release): 0.1.5 2026-03-23 14:44:34 +05:30
bendtherules e4d3e0e263 feat: add ora spinner (v9.3.0) while running shell 2026-03-23 14:27:23 +05:30
bendtherules 6b07a7ac2a feat: add required mode flag to cleanup and close
- Made `mode` a mandatory property in `MultiModelOptions`, `CloseSessionOptions`, and `CleanupOptions`.
- Updated function signatures to accept `mode` and removed default empty option objects where appropriate.
- Passed `mode: "cli"` from CLI commands and `mode: "tool"` from tool commands to cleanup, close, and open operations.
- Adjusted internal handling to use the provided `mode` (removed fallback defaults).
- Updated type definitions and added documentation for the new `mode` field.
2026-03-23 13:59:05 +05:30
bendtherules 415b0624f1 Merge branch 'opencode/plan-remember-last-model/mimo-v2-pro-free' 2026-03-23 13:46:26 +05:30
bendtherules 4824375970 plan: Remember Last Used Models 2026-03-23 13:46:15 +05:30
bendtherules 1294fcede1 chore(release): 0.1.4 2026-03-23 13:18:00 +05:30
bendtherules 8b85bc4a39 refactor(tools): change noRemote option from literal true to boolean with default false 2026-03-23 13:17:53 +05:30
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
31 changed files with 3326 additions and 575 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
+2 -2
View File
@@ -6,10 +6,10 @@
"test": "bun test" "test": "bun test"
}, },
"dependencies": { "dependencies": {
"@opencode-ai/plugin": "1.2.27" "@opencode-ai/plugin": "1.3.0"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.10", "@types/bun": "^1.3.10",
"@types/node": "^25.5.0" "@types/node": "^25.5.0"
} }
} }
@@ -0,0 +1,73 @@
---
model: mimo-v2-pro-free
---
# Remember Last Used Models
## Goal
When CLI `open` is called without `-m` models, read last used models from `~/.config/opencode-multi-model/settings.json` and confirm with user (Y/n). Use `--force` to skip confirmation. After successful open from CLI, persist models to settings. Tool path is unchanged — always requires explicit values.
## Files to Modify
| File | Change |
|---|---|
| `src/core/utils.ts` | Add `loadSettings()`, `saveSettings()`, and move `promptUser` here |
| `src/types.ts` | Add `MultiModelSettings` interface and `saveToSettings` to `MultiModelOptions` |
| `src/core/open.ts` | Conditional save after success when `saveToSettings` is true |
| `src/core/close.ts` | Remove local `promptUser`, import from utils |
| `src/core/cleanup.ts` | Remove local `promptUser`, import from utils |
| `src/cli.ts` | Load settings + confirmation (unless `--force`) |
| `tests/utils.test.ts` | Add tests for loadSettings/saveSettings |
| `tests/open.test.ts` | Add tests for settings save on success |
## Implementation Details
### 1. `src/types.ts` — Add settings interface and option flag
```ts
export interface MultiModelSettings {
lastModels: string[];
}
```
Add `saveToSettings?: boolean` to `MultiModelOptions`.
### 2. `src/core/utils.ts` — Settings read/write + shared promptUser
Add three new exported functions:
- `getSettingsPath(): string` — returns `~/.config/opencode-multi-model/settings.json`
- `loadSettings(): Promise<MultiModelSettings>` — reads and parses the JSON file. Returns `{ lastModels: [] }` if file doesn't exist or is invalid.
- `saveSettings(settings: MultiModelSettings): Promise<void>` — creates directory if needed, writes JSON with 2-space indent.
Also move the `promptUser` function (currently duplicated in `close.ts:17-29` and `cleanup.ts:9-21`) into `utils.ts` as a shared export. Remove the local copies from both files and update imports.
### 3. `src/core/open.ts` — Conditional save after full success
Before the successful return at line 310, if `options.saveToSettings` is true, call `saveSettings({ lastModels: models })`. Only save on full success, not partial. CLI passes `saveToSettings: true`; tool passes nothing (defaults false).
### 4. `src/cli.ts` — Settings load + confirmation + --force
Add `-f, --force` option to the `open` command: `.option("-f, --force", "Skip confirmation prompts", false)` (matches close/cleanup pattern).
In the `open` command action, before calling `openMultiModel`:
1. If `options.models` is empty, call `loadSettings()`.
2. If `settings.lastModels` is non-empty, use those. If empty, proceed with empty array (existing error from core function).
3. If models were loaded from settings and `--force` is not set, confirm: `Use models [model1, model2]? (Y/n): ` using `promptUser` from utils.
4. If user rejects (n, no), print error and exit.
5. Pass `saveToSettings: true` to `openMultiModel` so successful runs persist the models.
### 5. Tests
- `utils.test.ts`: Test `loadSettings` returns defaults for missing file, parses valid JSON, handles corrupt JSON gracefully. Test `saveSettings` writes correct content.
- `open.test.ts`: Test that `openMultiModel` calls `saveSettings` on success (mock `fs` / the save function).
## Verification
1. Run `bun run typecheck` — no type errors
2. Run `bun run lint` — no lint errors
3. Run `bun test` — all tests pass
4. Manual CLI test:
- `opencode-multi-model open test1 -m model1 model2` → saves settings
- `opencode-multi-model open test2` → prompts with saved models, confirm with Enter → uses saved models
- `opencode-multi-model open test3` → prompt, type `n` → cancels
@@ -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. // Re-export open and close tools for project-level use via relative paths.
// For published package usage, change imports to "@username/opencode-multi-model". // 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, "noUncheckedIndexedAccess": true,
"noImplicitOverride": true, "noImplicitOverride": true,
"types": [ "types": ["bun-types"]
"bun-types"
]
}, },
"include": ["**/*.ts", "**/*.tsx"] "include": ["**/*.ts", "**/*.tsx"]
} }
+2 -2
View File
@@ -12,5 +12,5 @@
"bun.test.filePattern": "**/*{.test.,.spec.,_test_,_spec_}{js,ts,tsx,jsx,mts,cts,cjs,mjs}", "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` // The custom script to call for testing instead of `bun test`
"bun.test.customScript": "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
+57 -2
View File
@@ -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 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):** **Close a session (using manual command):**
```bash ```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/*') 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 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):** **Close a session (manual command):**
```bash ```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/*') 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 --help
opencode-multi-model open --help opencode-multi-model open --help
opencode-multi-model close --help opencode-multi-model close --help
opencode-multi-model cleanup --help
``` ```
## Configuration ## Configuration
@@ -151,6 +172,21 @@ opencode-multi-model close <session-name> [options]
| `--no-tags` | Do not create archive tags before deleting branches | | `--no-tags` | Do not create archive tags before deleting branches |
| `-f, --force` | Skip confirmation prompts | | `-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 ### Global Options
| Option | Description | | Option | Description |
@@ -191,6 +227,20 @@ The `close` command performs the following steps:
4. **Directory cleanup**: Removes the base session directory 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 ## Requirements
- tmux - tmux
@@ -206,6 +256,9 @@ For local development and testing without publishing:
# Install dependencies # Install dependencies
bun install bun install
# Setup git hooks
bun run setup
# Build # Build
bun run build bun run build
@@ -227,11 +280,13 @@ bun dist/cli.js close test-session
│ ├── core/ │ ├── core/
│ │ ├── index.ts # Core exports │ │ ├── index.ts # Core exports
│ │ ├── open.ts # Launch session logic │ │ ├── open.ts # Launch session logic
│ │ ├── close.ts # Close session logic │ │ ├── close.ts # Close session logic
│ │ ├── cleanup.ts # Cleanup archive tags logic
│ │ └── utils.ts # Helper functions │ │ └── utils.ts # Helper functions
│ └── tools/ │ └── tools/
│ ├── open.ts # Open tool definition │ ├── open.ts # Open tool definition
── close.ts # Close tool definition ── close.ts # Close tool definition
│ └── cleanup.ts # Cleanup tool definition
├── tests/ ├── tests/
│ └── multi-model.test.ts # Tests │ └── multi-model.test.ts # Tests
├── package.json ├── package.json
+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"
}
}
}
}
+55
View File
@@ -8,14 +8,35 @@
"@opencode-ai/plugin": "^1.2.27", "@opencode-ai/plugin": "^1.2.27",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.0.0", "commander": "^12.0.0",
"ora": "^9.3.0",
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.8",
"@types/bun": "^1.3.10", "@types/bun": "^1.3.10",
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
"typescript": "^5.9.3",
}, },
}, },
}, },
"packages": { "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/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=="], "@opencode-ai/sdk": ["@opencode-ai/sdk@1.2.27", "", {}, "sha512-Wk0o/I+Fo+wE3zgvlJDs8Fb67KlKqX0PrV8dK5adSDkANq6r4Z25zXJg2iOir+a8ntg3rAcpel1OY4FV/TwRUA=="],
@@ -24,14 +45,48 @@
"@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], "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=="], "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
"cli-spinners": ["cli-spinners@3.4.0", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="],
"commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
"get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="],
"is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="],
"is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
"log-symbols": ["log-symbols@7.0.1", "", { "dependencies": { "is-unicode-supported": "^2.0.0", "yoctocolors": "^2.1.1" } }, "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg=="],
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
"onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
"ora": ["ora@9.3.0", "", { "dependencies": { "chalk": "^5.6.2", "cli-cursor": "^5.0.0", "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", "stdin-discarder": "^0.3.1", "string-width": "^8.1.0" } }, "sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw=="],
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"stdin-discarder": ["stdin-discarder@0.3.1", "", {}, "sha512-reExS1kSGoElkextOcPkel4NE99S0BWxjUHQeDFnR8S993JxpPX7KU4MNmO19NXhlJp+8dmdCbKQVNgLJh2teA=="],
"string-width": ["string-width@8.2.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="],
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"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=="], "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
"zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
} }
} }
+13 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "opencode-multi-model", "name": "opencode-multi-model",
"version": "0.0.7", "version": "0.2.0",
"description": "Launch multiple AI models in tmux sessions - OpenCode Plugin, CLI, and Project Tool", "description": "Launch multiple AI models in tmux sessions - OpenCode Plugin, CLI, and Project Tool",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
@@ -21,16 +21,24 @@
"test": "bun test", "test": "bun test",
"coverage": "bun test --coverage", "coverage": "bun test --coverage",
"prepublishOnly": "bun run build", "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": { "dependencies": {
"chalk": "^5.3.0",
"@opencode-ai/plugin": "^1.2.27", "@opencode-ai/plugin": "^1.2.27",
"commander": "^12.0.0" "chalk": "^5.3.0",
"commander": "^12.0.0",
"ora": "^9.3.0"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.8",
"@types/bun": "^1.3.10", "@types/bun": "^1.3.10",
"@types/node": "^25.5.0" "@types/node": "^25.5.0",
"typescript": "^5.9.3"
}, },
"keywords": [ "keywords": [
"opencode", "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!"
+95 -40
View File
@@ -1,7 +1,9 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import { Command } from "commander"; import { Command } from "commander";
import { openMultiModel } from "./core/open"; import pkg from "../package.json";
import { cleanupMultiModel } from "./core/cleanup";
import { closeMultiModel } from "./core/close"; import { closeMultiModel } from "./core/close";
import { openMultiModel } from "./core/open";
import { getBinaryName } from "./core/utils"; import { getBinaryName } from "./core/utils";
const program = new Command(); const program = new Command();
@@ -9,27 +11,108 @@ const program = new Command();
program program
.name("opencode-multi-model") .name("opencode-multi-model")
.description("Launch multiple OpenCode models in tmux sessions") .description("Launch multiple OpenCode models in tmux sessions")
.version("1.0.0"); .version(pkg.version);
program program
.command("open") .command("open")
.description("Create a new multi-model tmux session") .description("Create a new multi-model tmux session")
.argument("<session-name>", "Name for the tmux session") .argument("<session-name>", "Name for the tmux session")
.option("-m, --models <models...>", "Model IDs to launch (space-separated)", []) .option(
.option("-b, --binary <binary>", "Binary to use (opencode or kilo). Defaults to env var OPENCODE_MULTI_MODEL_BINARY or 'opencode'") "-m, --models <models...>",
.action(async (sessionName: string, options: { models: string[]; binary?: string }) => { "Model IDs to launch (space-separated)",
try { [],
const binaryName = options.binary || getBinaryName(); )
.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 openMultiModel({ const result = await openMultiModel({
sessionName, sessionName,
models: options.models, models: options.models,
binaryName, binaryName,
mode: "cli",
});
if (result.success) {
console.log(
result.instructions || `Created session: ${result.sessionName}`,
);
} else {
console.error(`Failed: ${result.error}`);
process.exit(1);
}
} catch (error) {
console.error(`Error: ${error}`);
process.exit(1);
}
},
);
program
.command("close")
.description("Close a multi-model tmux session")
.argument("<session-name>", "Name of the tmux session to close")
.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: { keepWorktrees: boolean; force: boolean; tags: boolean },
) => {
try {
const result = await closeMultiModel({
sessionName,
cleanupWorktrees: !options.keepWorktrees,
force: options.force,
createArchiveTags: options.tags,
mode: "cli",
});
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 cleanupMultiModel({
force: options.force,
remote: options.remote === true ? undefined : options.remote,
mode: "cli", mode: "cli",
}); });
if (result.success) { if (result.success) {
console.log(result.instructions || `Created session: ${result.sessionName}`); console.log(result.instructions);
} else { } else {
console.error(`Failed: ${result.error}`); console.error(`Failed: ${result.error}`);
process.exit(1); process.exit(1);
@@ -40,32 +123,4 @@ program
} }
}); });
program
.command("close")
.description("Close a multi-model tmux session")
.argument("<session-name>", "Name of the tmux session to close")
.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: { 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.parse(); program.parse();
+227
View File
@@ -0,0 +1,227 @@
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,
mode?: "cli" | "tool",
): Promise<boolean> {
const result = await runCommand(["git", "tag", "-d", tagName], {
mode,
spinnerMsg: `Deleting local tag ${tagName}...`,
});
return result.ok;
}
/**
* Deletes a tag from a remote repository.
*/
async function deleteRemoteTag(
tagName: string,
remoteName: string,
mode?: "cli" | "tool",
): Promise<{ ok: boolean; error: string }> {
const result = await runCommand(
["git", "push", remoteName, "--delete", tagName],
{
mode,
spinnerMsg: `Deleting remote tag ${tagName} from ${remoteName}...`,
},
);
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, mode } = 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, mode);
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, mode);
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,
};
}
+83 -44
View File
@@ -1,7 +1,12 @@
import * as readline from "node:readline"; import * as readline from "node:readline";
import chalk from 'chalk'; // removed console usage import chalk from "chalk"; // removed console usage
import type { CloseSessionOptions, CloseSessionResult } from "../types"; import type { CloseSessionOptions, CloseSessionResult } from "../types";
import { runCommand, getSessionPath, getWorktreesForSession, sanitizeName } from "./utils"; import {
getSessionPath,
getWorktreesForSession,
runCommand,
sanitizeName,
} from "./utils";
/** /**
* Prompts the user for input on the terminal. * Prompts the user for input on the terminal.
@@ -44,24 +49,35 @@ 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 worktreesRemoved: string[] = [];
const instructionsArr: string[] = []; const instructionsArr: string[] = [];
const branchesDeleted: string[] = []; const branchesDeleted: string[] = [];
const tagsCreated: string[] = []; const tagsCreated: string[] = [];
const mode = options.mode;
const safeSessionName = sanitizeName(options.sessionName); const safeSessionName = sanitizeName(options.sessionName);
try { try {
// Check if session exists // 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 // Kill tmux session if it exists
if (sessionExists) { if (sessionExists) {
await runCommand(["tmux", "kill-session", "-t", options.sessionName]); await runCommand(["tmux", "kill-session", "-t", options.sessionName]);
instructionsArr.push(chalk.green(`Closed session '${options.sessionName}'`)); instructionsArr.push(
chalk.green(`Closed session '${options.sessionName}'`),
);
} else { } else {
instructionsArr.push(chalk.yellow(`Session '${options.sessionName}' does not exist`)); instructionsArr.push(
chalk.yellow(`Session '${options.sessionName}' does not exist`),
);
} }
// Get list of worktrees for this session before killing tmux // Get list of worktrees for this session before killing tmux
@@ -69,13 +85,17 @@ export async function closeMultiModel(options: CloseSessionOptions): Promise<Clo
// If cleanup requested and not forced, ask for confirmation // If cleanup requested and not forced, ask for confirmation
if (options.cleanupWorktrees && !options.force && worktrees.length > 0) { if (options.cleanupWorktrees && !options.force && worktrees.length > 0) {
console.log(chalk.red('The following worktrees and branches will be removed:')); console.log(
chalk.red("The following worktrees and branches will be removed:"),
);
worktrees.forEach((wt) => { worktrees.forEach((wt) => {
const msg = ` - ${wt.path} (branch: ${wt.branch})`; const msg = ` - ${wt.path} (branch: ${wt.branch})`;
console.log(msg.trim()); console.log(msg.trim());
}); });
const answer = await promptUser(chalk.bold("\nDo you want to proceed? (y/N): ")); const answer = await promptUser(
chalk.bold("\nDo you want to proceed? (y/N): "),
);
if (answer.toLowerCase() !== "y" && answer.toLowerCase() !== "yes") { if (answer.toLowerCase() !== "y" && answer.toLowerCase() !== "yes") {
return { return {
success: false, success: false,
@@ -93,27 +113,40 @@ export async function closeMultiModel(options: CloseSessionOptions): Promise<Clo
for (const worktree of worktrees) { for (const worktree of worktrees) {
try { try {
// Remove worktree // Remove worktree
await runCommand(["git", "worktree", "remove", "-f", worktree.path]); await runCommand(["git", "worktree", "remove", "-f", worktree.path], {
mode,
spinnerMsg: `Removing worktree ${worktree.path}...`,
});
worktreesRemoved.push(worktree.path); worktreesRemoved.push(worktree.path);
// Optionally create an archive tag before deleting the branch for safe recovery // Optionally create an archive tag before deleting the branch for safe recovery
if (options.createArchiveTags !== false) { if (options.createArchiveTags !== false) {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const archiveTag = `archive/${worktree.branch}-${timestamp}`; const archiveTag = `archive/${worktree.branch}-${timestamp}`;
const tagResult = await runCommand(["git", "tag", archiveTag, worktree.branch]); const tagResult = await runCommand(
["git", "tag", archiveTag, worktree.branch],
{ mode, spinnerMsg: `Creating archive tag ${archiveTag}...` },
);
if (tagResult.ok) { if (tagResult.ok) {
tagsCreated.push(archiveTag); tagsCreated.push(archiveTag);
} else { } else {
const warningMsg = chalk.red(`Warning: Failed to create tag ${archiveTag} for branch ${worktree.branch}.`); const warningMsg = chalk.red(
instructionsArr.push(warningMsg); `Warning: Failed to create tag ${archiveTag} for branch ${worktree.branch}.`,
} );
} instructionsArr.push(warningMsg);
}
}
await runCommand(["git", "branch", "-D", worktree.branch]); await runCommand(["git", "branch", "-D", worktree.branch], {
mode,
spinnerMsg: `Deleting branch ${worktree.branch}...`,
});
branchesDeleted.push(worktree.branch); branchesDeleted.push(worktree.branch);
} catch (err) { } catch (err) {
instructionsArr.push(chalk.red(`Failed to cleanup worktree ${worktree.path}: ${err}`)); instructionsArr.push(
chalk.red(`Failed to cleanup worktree ${worktree.path}: ${err}`),
);
} }
} }
@@ -127,31 +160,37 @@ export async function closeMultiModel(options: CloseSessionOptions): Promise<Clo
cleanupPerformed = worktreesRemoved.length > 0; cleanupPerformed = worktreesRemoved.length > 0;
if (cleanupPerformed) { if (cleanupPerformed) {
instructionsArr.push(chalk.dim(`Removed ${worktreesRemoved.length} worktrees, alongwith their branches.`)); instructionsArr.push(
chalk.dim(
`Removed ${worktreesRemoved.length} worktrees, alongwith their branches.`,
),
);
if (tagsCreated.length > 0) { if (tagsCreated.length > 0) {
instructionsArr.push(chalk.dim(`Created archive tags: ${tagsCreated.join(", ")}.`)); instructionsArr.push(
chalk.dim(`Created archive tags: ${tagsCreated.join(", ")}.`),
);
} }
} }
} }
return { return {
success: true, success: true,
sessionName: safeSessionName, sessionName: safeSessionName,
cleanupPerformed, cleanupPerformed,
worktreesRemoved, worktreesRemoved,
branchesDeleted, branchesDeleted,
tagsCreated, tagsCreated,
instructions: instructionsArr.join("\n"), instructions: instructionsArr.join("\n"),
}; };
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
sessionName: safeSessionName, sessionName: safeSessionName,
error: String(error), error: String(error),
worktreesRemoved, worktreesRemoved,
branchesDeleted, branchesDeleted,
tagsCreated, tagsCreated,
instructions: instructionsArr.join("\n"), instructions: instructionsArr.join("\n"),
}; };
} }
} }
+2 -1
View File
@@ -1,3 +1,4 @@
export { openMultiModel } from "./open"; export { cleanupMultiModel } from "./cleanup";
export { closeMultiModel } from "./close"; export { closeMultiModel } from "./close";
export { openMultiModel } from "./open";
export * from "./utils"; export * from "./utils";
+150 -58
View File
@@ -1,17 +1,16 @@
import * as fs from "node:fs"; import * as fs from "node:fs";
import chalk from 'chalk'; import chalk from "chalk";
import type { MultiModelOptions, MultiModelResult } from "../types"; import type { MultiModelOptions, MultiModelResult } from "../types";
import { import {
runCommand,
normalizeModels,
findDuplicates,
createWindowPlans, createWindowPlans,
findDuplicates,
formatInvalidModelError, formatInvalidModelError,
sanitizeName,
getWorktreePath, getWorktreePath,
getSessionPath,
undoWorktree,
launchModelInWindow, launchModelInWindow,
normalizeModels,
runCommand,
sanitizeName,
undoWorktree,
} from "./utils"; } from "./utils";
/** /**
@@ -35,19 +34,29 @@ import {
* } * }
* ``` * ```
*/ */
export async function openMultiModel(options: MultiModelOptions): Promise<MultiModelResult> { export async function openMultiModel(
options: MultiModelOptions,
): Promise<MultiModelResult> {
const sessionName = options.sessionName.trim(); const sessionName = options.sessionName.trim();
const safeSessionName = sanitizeName(sessionName); const safeSessionName = sanitizeName(sessionName);
const models = normalizeModels(options.models); const models = normalizeModels(options.models);
const binaryName = options.binaryName || "opencode"; const binaryName = options.binaryName || "opencode";
const mode = options.mode || "cli"; const mode = options.mode;
if (!sessionName) { 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) { 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); const duplicateModels = findDuplicates(models);
@@ -59,22 +68,41 @@ export async function openMultiModel(options: MultiModelOptions): Promise<MultiM
}; };
} }
const gitRepoCheck = await runCommand(["git", "rev-parse", "--is-inside-work-tree"]); const gitRepoCheck = await runCommand([
"git",
"rev-parse",
"--is-inside-work-tree",
]);
if (!gitRepoCheck.ok) { 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"]); const tmuxExists = await runCommand(["command", "-v", "tmux"]);
if (!tmuxExists.ok) { 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]); const binaryExists = await runCommand(["command", "-v", binaryName]);
if (!binaryExists.ok) { 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"]); const modelListResult = await runCommand([binaryName, "models"], {
mode,
spinnerMsg: `Fetching available models using \`${binaryName} models\`...`,
});
if (!modelListResult.ok) { if (!modelListResult.ok) {
return { return {
success: false, success: false,
@@ -90,12 +118,25 @@ export async function openMultiModel(options: MultiModelOptions): Promise<MultiM
const invalidModels = models.filter((model) => !allowlist.includes(model)); const invalidModels = models.filter((model) => !allowlist.includes(model));
if (invalidModels.length > 0) { 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) { 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); const windowPlans = createWindowPlans(models);
@@ -111,45 +152,75 @@ export async function openMultiModel(options: MultiModelOptions): Promise<MultiM
if (fs.existsSync(worktreePath)) { if (fs.existsSync(worktreePath)) {
if (!isFirst) { if (!isFirst) {
failedModels.push(`${plan.model} (Worktree path already exists at ${worktreePath})`); failedModels.push(
`${plan.model} (Worktree path already exists at ${worktreePath})`,
);
continue; continue;
} else { } 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 (branchExists.ok) {
if (!isFirst) { if (!isFirst) {
failedModels.push(`${plan.model} (Branch '${branchName}' already exists)`); failedModels.push(
`${plan.model} (Branch '${branchName}' already exists)`,
);
continue; continue;
} else { } 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],
{ mode, spinnerMsg: `Creating worktree for ${plan.model}...` },
);
if (!worktreeResult.ok) { if (!worktreeResult.ok) {
if (!isFirst) { if (!isFirst) {
failedModels.push(`${plan.model} (Failed to create worktree: ${worktreeResult.stderr})`); failedModels.push(
`${plan.model} (Failed to create worktree: ${worktreeResult.stderr})`,
);
continue; continue;
} else { } 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}`,
};
} }
} }
if (isFirst) { if (isFirst) {
const sessionCreateResult = await runCommand([ const sessionCreateResult = await runCommand(
"tmux", [
"new-session", "tmux",
"-d", "new-session",
"-s", "-d",
sessionName, "-s",
"-n", sessionName,
plan.windowName, "-n",
"-c", plan.windowName,
worktreePath, "-c",
]); worktreePath,
],
{ mode, spinnerMsg: `Starting tmux session '${sessionName}'...` },
);
if (!sessionCreateResult.ok) { if (!sessionCreateResult.ok) {
const undoErrors = await undoWorktree(worktreePath, branchName); const undoErrors = await undoWorktree(worktreePath, branchName);
@@ -160,35 +231,52 @@ export async function openMultiModel(options: MultiModelOptions): Promise<MultiM
return { success: false, sessionName, error: errorMsg }; return { success: false, sessionName, error: errorMsg };
} }
} else { } else {
const windowCreateResult = await runCommand([ const windowCreateResult = await runCommand(
"tmux", [
"new-window", "tmux",
"-d", "new-window",
"-t", "-d",
sessionName, "-t",
"-n", sessionName,
plan.windowName, "-n",
"-c", plan.windowName,
worktreePath, "-c",
]); worktreePath,
],
{ mode, spinnerMsg: `Creating tmux window '${plan.windowName}'...` },
);
if (!windowCreateResult.ok) { 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); const undoErrors = await undoWorktree(worktreePath, branchName);
if (undoErrors.length > 0) { if (undoErrors.length > 0) {
failedModels.push(`cleanup failed for ${plan.model}: ${undoErrors.join(", ")}`); failedModels.push(
`cleanup failed for ${plan.model}: ${undoErrors.join(", ")}`,
);
} }
continue; continue;
} }
} }
const launchResult = await launchModelInWindow(sessionName, plan, binaryName); const launchResult = await launchModelInWindow(
sessionName,
plan,
binaryName,
mode,
);
if (launchResult.ok) { if (launchResult.ok) {
succeededModels.push(plan.model); succeededModels.push(plan.model);
} else { } 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); const undoErrors = await undoWorktree(worktreePath, branchName);
if (undoErrors.length > 0) failedModels.push(`cleanup failed for ${plan.model}: ${undoErrors.join(", ")}`); if (undoErrors.length > 0)
failedModels.push(
`cleanup failed for ${plan.model}: ${undoErrors.join(", ")}`,
);
} }
} }
@@ -206,9 +294,13 @@ export async function openMultiModel(options: MultiModelOptions): Promise<MultiM
sessionName, sessionName,
windows: windowNames, windows: windowNames,
instructions: [ instructions: [
chalk.red(`Error: Created tmux session '${sessionName}', but some model launches failed.`), chalk.red(
`Error: Created tmux session '${sessionName}', but some model launches failed.`,
),
chalk.red(`Failed: ${failedModels.join(", ")}.`), chalk.red(`Failed: ${failedModels.join(", ")}.`),
chalk.dim(`Succeeded: ${succeededModels.length > 0 ? succeededModels.join(", ") : "none"}.`), chalk.dim(
`Succeeded: ${succeededModels.length > 0 ? succeededModels.join(", ") : "none"}.`,
),
"", "",
chalk.bold(`Attach with \`${attachCommand}\` to inspect the session.`), chalk.bold(`Attach with \`${attachCommand}\` to inspect the session.`),
"", "",
@@ -216,7 +308,7 @@ export async function openMultiModel(options: MultiModelOptions): Promise<MultiM
primaryCleanup, primaryCleanup,
"", "",
chalk.dim(`Alternate manual cleanup - `), chalk.dim(`Alternate manual cleanup - `),
chalk.dim(cleanupCommand) chalk.dim(cleanupCommand),
].join("\n"), ].join("\n"),
}; };
} }
@@ -232,7 +324,7 @@ export async function openMultiModel(options: MultiModelOptions): Promise<MultiM
primaryCleanup, primaryCleanup,
"", "",
chalk.dim(`Alternate manual cleanup - `), chalk.dim(`Alternate manual cleanup - `),
chalk.dim(cleanupCommand) chalk.dim(cleanupCommand),
].join("\n"), ].join("\n"),
}; };
} }
+99 -16
View File
@@ -1,6 +1,12 @@
import * as os from "node:os"; import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
import type { CommandResult, WindowLaunchPlan, WorktreeInfo } from "../types"; import ora, { type Ora } from "ora";
import type {
CommandResult,
RunCommandOptions,
WindowLaunchPlan,
WorktreeInfo,
} from "../types";
const MAX_SUGGESTIONS = 3; const MAX_SUGGESTIONS = 3;
const WINDOW_NAME_LIMIT = 24; const WINDOW_NAME_LIMIT = 24;
@@ -9,6 +15,7 @@ const WINDOW_NAME_LIMIT = 24;
* Runs a command and captures its output without throwing on non-zero exit codes. * Runs a command and captures its output without throwing on non-zero exit codes.
* *
* @param parts Command segments to pass to the shell. * @param parts Command segments to pass to the shell.
* @param options Additional options for running the command (e.g., mode, spinnerMsg).
* @returns The exit status plus captured stdout and stderr. * @returns The exit status plus captured stdout and stderr.
* *
* @example * @example
@@ -19,9 +26,28 @@ const WINDOW_NAME_LIMIT = 24;
* } * }
* ``` * ```
*/ */
export async function runCommand(parts: string[]): Promise<CommandResult> { export async function runCommand(
parts: string[],
options?: RunCommandOptions,
): Promise<CommandResult> {
let spinner: Ora | undefined;
const isTest =
process.env.NODE_ENV === "test" || process.env.BUN_ENV === "test";
if (options?.mode === "cli" && !isTest) {
const msg = options.spinnerMsg || `Running ${parts[0]}...`;
spinner = ora(msg).start();
}
const result = await Bun.$`${parts}`.quiet().nothrow(); const result = await Bun.$`${parts}`.quiet().nothrow();
if (spinner) {
if (result.exitCode === 0) {
spinner.succeed();
} else {
spinner.fail();
}
}
return { return {
ok: result.exitCode === 0, ok: result.exitCode === 0,
stdout: result.stdout.toString().trim(), stdout: result.stdout.toString().trim(),
@@ -136,7 +162,10 @@ export function createWindowPlans(models: string[]): WindowLaunchPlan[] {
} }
const suffix = `-${nextCount}`; 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 { return {
model, model,
@@ -194,7 +223,10 @@ export function levenshtein(left: string, right: string): number {
* // ["openai/gpt-5.4", "openai/gpt-5.4-pro"] * // ["openai/gpt-5.4", "openai/gpt-5.4-pro"]
* ``` * ```
*/ */
export function suggestModels(requested: string, allowlist: string[]): string[] { export function suggestModels(
requested: string,
allowlist: string[],
): string[] {
const normalizedRequested = requested.toLowerCase(); const normalizedRequested = requested.toLowerCase();
return allowlist return allowlist
@@ -212,7 +244,11 @@ export function suggestModels(requested: string, allowlist: string[]): string[]
score: distance + containsBoost, 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) .slice(0, MAX_SUGGESTIONS)
.map(({ candidate }) => candidate); .map(({ candidate }) => candidate);
} }
@@ -229,7 +265,10 @@ export function suggestModels(requested: string, allowlist: string[]): string[]
* const message = formatInvalidModelError(["openai/gpt5.4"], ["openai/gpt-5.4"]); * 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 firstInvalidModel = invalidModels[0]!;
const suggestions = suggestModels(firstInvalidModel, allowlist); const suggestions = suggestModels(firstInvalidModel, allowlist);
const suggestionText = const suggestionText =
@@ -261,10 +300,21 @@ export async function launchModelInWindow(
sessionName: string, sessionName: string,
plan: WindowLaunchPlan, plan: WindowLaunchPlan,
binaryName: string = "opencode", binaryName: string = "opencode",
mode?: "cli" | "tool",
): Promise<CommandResult> { ): Promise<CommandResult> {
const launchCommand = `${binaryName} --model ${shellQuote(plan.model)}`; 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",
],
{ mode, spinnerMsg: `Launching model ${plan.model}...` },
);
} }
/** /**
@@ -280,7 +330,10 @@ export async function launchModelInWindow(
* ``` * ```
*/ */
export function sanitizeName(name: string): string { 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 +350,14 @@ export function sanitizeName(name: string): string {
*/ */
export function getSessionPath(sessionName: string): string { export function getSessionPath(sessionName: string): string {
const homedir = os.homedir(); 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 +373,10 @@ export function getSessionPath(sessionName: string): string {
* // "/home/user/.local/share/opencode/multi-model/my-session/gpt-5-2" * // "/home/user/.local/share/opencode/multi-model/my-session/gpt-5-2"
* ``` * ```
*/ */
export function getWorktreePath(safeSession: string, windowName: string): string { export function getWorktreePath(
safeSession: string,
windowName: string,
): string {
return path.join(getSessionPath(safeSession), windowName); return path.join(getSessionPath(safeSession), windowName);
} }
@@ -328,8 +391,15 @@ export function getWorktreePath(safeSession: string, windowName: string): string
* const worktrees = await getWorktreesForSession("my-session"); * const worktrees = await getWorktreesForSession("my-session");
* ``` * ```
*/ */
export async function getWorktreesForSession(sessionName: string): Promise<WorktreeInfo[]> { export async function getWorktreesForSession(
const { stdout } = await runCommand(["git", "worktree", "list", "--porcelain"]); sessionName: string,
): Promise<WorktreeInfo[]> {
const { stdout } = await runCommand([
"git",
"worktree",
"list",
"--porcelain",
]);
const worktrees: WorktreeInfo[] = []; const worktrees: WorktreeInfo[] = [];
const sessionPath = getSessionPath(sanitizeName(sessionName)); const sessionPath = getSessionPath(sanitizeName(sessionName));
@@ -371,13 +441,26 @@ export async function getWorktreesForSession(sessionName: string): Promise<Workt
* if (errors.length > 0) console.error(errors); * 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 errors: string[] = [];
const removeRes = await runCommand(["git", "worktree", "remove", "-f", worktreePath]); const removeRes = await runCommand([
if (!removeRes.ok) errors.push(`Failed to remove worktree ${worktreePath}: ${removeRes.stderr}`); "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]); 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; return errors;
} }
+5 -5
View File
@@ -1,13 +1,15 @@
import type { Plugin } from "@opencode-ai/plugin"; import type { Plugin } from "@opencode-ai/plugin";
import { openTool } from "./tools/open"; import { cleanupTool } from "./tools/cleanup";
import { closeTool } from "./tools/close"; import { closeTool } from "./tools/close";
import { openTool } from "./tools/open";
/** /**
* OpenCode plugin that provides multi-model tmux session management tools. * 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-open`: Launch multiple AI models in a tmux session
* - `multi-model-close`: Close a multi-model session and cleanup resources * - `multi-model-close`: Close a multi-model session and cleanup resources
* - `multi-model-cleanup`: Delete all archive tags from local and remote repositories
* *
* @example * @example
* ```json * ```json
@@ -22,11 +24,9 @@ const OpenCodeMultiModelPlugin: Plugin = async (_ctx) => {
tool: { tool: {
"multi-model-open": openTool, "multi-model-open": openTool,
"multi-model-close": closeTool, "multi-model-close": closeTool,
"multi-model-cleanup": cleanupTool,
}, },
}; };
}; };
export default OpenCodeMultiModelPlugin; export default OpenCodeMultiModelPlugin;
export { openTool, closeTool };
export * from "./core/index";
export * from "./types";
+45
View File
@@ -0,0 +1,45 @@
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
.boolean()
.default(false)
.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,
mode: "tool",
});
if (!result.success) {
return result.error!;
}
return (
result.instructions ?? `Deleted ${result.localTagsDeleted} local tags.`
);
},
});
+24 -5
View File
@@ -8,19 +8,38 @@ import { closeMultiModel } from "../core/close";
* creates archive tags, and deletes associated branches. * creates archive tags, and deletes associated branches.
*/ */
export const closeTool = tool({ 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: { args: {
sessionName: tool.schema.string().min(1).describe("tmux session name to close"), sessionName: tool.schema
cleanupWorktrees: tool.schema.boolean().default(true).describe("whether to remove worktrees and delete branches (default: true)"), .string()
createArchiveTags: tool.schema.boolean().default(true).describe("whether to create archive tags before deleting branches (default: true)"), .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 // In plugin mode, we skip confirmation (force=true) since there's no interactive terminal
const result = await closeMultiModel({ const result = await closeMultiModel({
sessionName: args.sessionName, sessionName: args.sessionName,
cleanupWorktrees: args.cleanupWorktrees, cleanupWorktrees: args.cleanupWorktrees,
createArchiveTags: args.createArchiveTags, createArchiveTags: args.createArchiveTags,
force: true, force: true,
mode: "tool",
}); });
if (!result.success) { if (!result.success) {
+13 -3
View File
@@ -9,9 +9,16 @@ import { getBinaryName } from "../core/utils";
* Returns instructions for attaching to and cleaning up the session. * Returns instructions for attaching to and cleaning up the session.
*/ */
export const openTool = tool({ 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: { 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 models: tool.schema
.array(tool.schema.string().min(1)) .array(tool.schema.string().min(1))
.min(1) .min(1)
@@ -39,6 +46,9 @@ export const openTool = tool({
return result.error!; 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`
);
}, },
}); });
+54 -2
View File
@@ -9,7 +9,7 @@ export interface MultiModelOptions {
/** Binary name to use ('opencode' or 'kilo'). Defaults to 'opencode'. */ /** Binary name to use ('opencode' or 'kilo'). Defaults to 'opencode'. */
binaryName?: string; binaryName?: string;
/** Caller context: 'cli' shows shell commands, 'tool' shows tool instructions. */ /** Caller context: 'cli' shows shell commands, 'tool' shows tool instructions. */
mode?: "cli" | "tool"; mode: "cli" | "tool";
} }
/** /**
@@ -40,6 +40,8 @@ export interface CloseSessionOptions {
cleanupWorktrees?: boolean; cleanupWorktrees?: boolean;
/** Skip confirmation prompts. */ /** Skip confirmation prompts. */
force?: boolean; force?: boolean;
/** Caller context: 'cli' shows shell commands, 'tool' shows tool instructions. */
mode: "cli" | "tool";
} }
/** /**
@@ -62,7 +64,6 @@ export interface CloseSessionResult {
branchesDeleted?: string[]; branchesDeleted?: string[];
/** Archive tags that were created before branch deletion. */ /** Archive tags that were created before branch deletion. */
tagsCreated?: string[]; tagsCreated?: string[];
} }
/** /**
@@ -75,6 +76,47 @@ export interface WindowLaunchPlan {
windowName: string; 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;
/** Caller context: 'cli' shows shell commands, 'tool' shows tool instructions. */
mode: "cli" | "tool";
}
/**
* 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. * Result of running a shell command.
*/ */
@@ -87,6 +129,16 @@ export interface CommandResult {
stderr: string; stderr: string;
} }
/**
* Options for running a shell command.
*/
export interface RunCommandOptions {
/** Caller context: 'cli' shows spinner, 'tool' does not. */
mode?: "cli" | "tool";
/** Message to display in the spinner while the command runs. */
spinnerMsg?: string;
}
/** /**
* Information about a git worktree. * Information about a git worktree.
*/ */
+416
View File
@@ -0,0 +1,416 @@
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({ mode: "cli", 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({ mode: "cli", 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({ mode: "cli", 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({
mode: "cli",
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({ mode: "cli", 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({
mode: "cli",
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({
mode: "cli",
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({
mode: "cli",
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({ mode: "cli", 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({ mode: "cli", 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({ mode: "cli", 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",
]);
});
});
+312 -80
View File
@@ -1,18 +1,33 @@
import { expect, test, mock, spyOn, beforeEach, afterEach, describe } from "bun:test"; import {
afterEach,
beforeEach,
describe,
expect,
mock,
spyOn,
test,
} from "bun:test";
import * as os from "node:os"; import * as os from "node:os";
import * as readline from "node:readline"; import * as readline from "node:readline";
import { closeMultiModel } from "../src/core/close"; import { closeMultiModel } from "../src/core/close";
let originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$; const originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
let mockCommandResponses: Record<string, { ok: boolean; stdout: string; stderr: string }> = {}; let mockCommandResponses: Record<
string,
{ ok: boolean; stdout: string; stderr: string }
> = {};
let executedCommands: string[] = []; let executedCommands: string[] = [];
function setupMockBun$() { function setupMockBun$() {
const mockFn = mock((strings: TemplateStringsArray, ...values: any[]) => { const mockFn = mock((_strings: TemplateStringsArray, ...values: any[]) => {
const parts = values[0] as string[]; const parts = values[0] as string[];
const commandSignature = parts.join(" "); const commandSignature = parts.join(" ");
executedCommands.push(commandSignature); executedCommands.push(commandSignature);
const response = mockCommandResponses[commandSignature] ?? { ok: true, stdout: "", stderr: "" }; const response = mockCommandResponses[commandSignature] ?? {
ok: true,
stdout: "",
stderr: "",
};
return { return {
quiet: () => ({ quiet: () => ({
nothrow: async () => ({ nothrow: async () => ({
@@ -32,11 +47,11 @@ function restoreOriginalBun$() {
} }
describe("closeMultiModel", () => { describe("closeMultiModel", () => {
let bunMock: ReturnType<typeof setupMockBun$>; let _bunMock: ReturnType<typeof setupMockBun$>;
beforeEach(() => { beforeEach(() => {
spyOn(os, "homedir").mockReturnValue("/home/user"); spyOn(os, "homedir").mockReturnValue("/home/user");
bunMock = setupMockBun$(); _bunMock = setupMockBun$();
executedCommands = []; executedCommands = [];
mockCommandResponses = {}; mockCommandResponses = {};
}); });
@@ -46,12 +61,29 @@ describe("closeMultiModel", () => {
}); });
test("kills existing session without cleanup", async () => { test("kills existing session without cleanup", async () => {
mockCommandResponses["tmux has-session -t test-session"] = { ok: true, stdout: "", stderr: "" }; mockCommandResponses["tmux has-session -t test-session"] = {
mockCommandResponses["tmux kill-session -t test-session"] = { ok: true, stdout: "", stderr: "" }; ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["tmux kill-session -t test-session"] = {
ok: true,
stdout: "",
stderr: "",
};
// No worktrees // No worktrees
mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: "", stderr: "" }; mockCommandResponses["git worktree list --porcelain"] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await closeMultiModel({ sessionName: "test-session", cleanupWorktrees: false, force: false }); const result = await closeMultiModel({
mode: "cli",
sessionName: "test-session",
cleanupWorktrees: false,
force: false,
});
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.cleanupPerformed).toBe(false); expect(result.cleanupPerformed).toBe(false);
expect(result.worktreesRemoved).toEqual([]); expect(result.worktreesRemoved).toEqual([]);
@@ -60,37 +92,83 @@ describe("closeMultiModel", () => {
}); });
test("no session exists, no cleanup", async () => { test("no session exists, no cleanup", async () => {
mockCommandResponses["tmux has-session -t test-session"] = { ok: false, stdout: "", stderr: "" }; mockCommandResponses["tmux has-session -t test-session"] = {
mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: "", stderr: "" }; ok: false,
stdout: "",
stderr: "",
};
mockCommandResponses["git worktree list --porcelain"] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await closeMultiModel({ sessionName: "test-session", cleanupWorktrees: false, force: false }); const result = await closeMultiModel({
mode: "cli",
sessionName: "test-session",
cleanupWorktrees: false,
force: false,
});
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.cleanupPerformed).toBe(false); expect(result.cleanupPerformed).toBe(false);
expect(result.worktreesRemoved).toEqual([]); expect(result.worktreesRemoved).toEqual([]);
expect(executedCommands).toContain("tmux has-session -t test-session"); expect(executedCommands).toContain("tmux has-session -t test-session");
// No kill-session should be issued // No kill-session should be issued
expect(executedCommands.some(c => c.startsWith("tmux kill-session"))).toBe(false); expect(
executedCommands.some((c) => c.startsWith("tmux kill-session")),
).toBe(false);
}); });
test("cleanup works with worktrees and force, all succeeds", async () => { test("cleanup works with worktrees and force, all succeeds", async () => {
mockCommandResponses["tmux has-session -t test-session"] = { ok: false, stdout: "", stderr: "" }; mockCommandResponses["tmux has-session -t test-session"] = {
const worktreePath = "/home/user/.local/share/opencode/multi-model/test-session/model"; 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`; const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/test-session/model`;
mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: worktreeListOutput, stderr: "" }; mockCommandResponses["git worktree list --porcelain"] = {
mockCommandResponses[`git worktree remove -f ${worktreePath}`] = { ok: true, stdout: "", stderr: "" }; ok: true,
mockCommandResponses["git branch -D opencode/test-session/model"] = { ok: true, stdout: "", stderr: "" }; 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 // 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: "" }; mockCommandResponses[
"rm -rf /home/user/.local/share/opencode/multi-model/test-session"
] = { ok: true, stdout: "", stderr: "" };
// Freeze timestamp for deterministic tag name // Freeze timestamp for deterministic tag name
const fixedDate = new Date("2023-01-01T00:00:00.000Z"); const fixedDate = new Date("2023-01-01T00:00:00.000Z");
const OriginalDate = Date; const OriginalDate = Date;
// @ts-ignore global.Date = class extends OriginalDate {
global.Date = class extends OriginalDate { constructor() { super(); return fixedDate; } toISOString() { return "2023-01-01T00:00:00.000Z"; } } as any; 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 }); const result = await closeMultiModel({
mode: "cli",
sessionName: "test-session",
cleanupWorktrees: true,
force: true,
});
// Restore Date // Restore Date
// @ts-ignore
global.Date = OriginalDate; global.Date = OriginalDate;
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -98,39 +176,66 @@ describe("closeMultiModel", () => {
expect(result.worktreesRemoved).toEqual([worktreePath]); expect(result.worktreesRemoved).toEqual([worktreePath]);
expect(result.branchesDeleted).toEqual(["opencode/test-session/model"]); expect(result.branchesDeleted).toEqual(["opencode/test-session/model"]);
expect(result.tagsCreated?.length).toBe(1); expect(result.tagsCreated?.length).toBe(1);
// Ensure removal commands were executed // Ensure removal commands were executed
expect(executedCommands).toContain(`git worktree remove -f ${worktreePath}`); expect(executedCommands).toContain(
expect(executedCommands).toContain(`git branch -D opencode/test-session/model`); `git worktree remove -f ${worktreePath}`,
);
expect(executedCommands).toContain(
`git branch -D opencode/test-session/model`,
);
}); });
test("user cancels cleanup when not forced", async () => { test("user cancels cleanup when not forced", async () => {
mockCommandResponses["tmux has-session -t test-session"] = { ok: false, stdout: "", stderr: "" }; mockCommandResponses["tmux has-session -t test-session"] = {
const worktreePath = "/home/user/.local/share/opencode/multi-model/test-session/model"; 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`; const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/test-session/model`;
mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: worktreeListOutput, stderr: "" }; mockCommandResponses["git worktree list --porcelain"] = {
ok: true,
stdout: worktreeListOutput,
stderr: "",
};
// Stub readline to return "n" // Stub readline to return "n"
const mockInterface = { const mockInterface = {
question: (q: string, cb: (a: string) => void) => cb("n"), question: (_q: string, cb: (a: string) => void) => cb("n"),
close: () => {}, close: () => {},
} as any; } as any;
spyOn(readline, "createInterface").mockImplementation(() => mockInterface); spyOn(readline, "createInterface").mockImplementation(() => mockInterface);
const result = await closeMultiModel({ sessionName: "test-session", cleanupWorktrees: true, force: false }); const result = await closeMultiModel({
mode: "cli",
sessionName: "test-session",
cleanupWorktrees: true,
force: false,
});
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toBe("Cleanup cancelled by user"); expect(result.error).toBe("Cleanup cancelled by user");
}); });
test("user confirms cleanup when not forced", async () => { test("user confirms cleanup when not forced", async () => {
mockCommandResponses["tmux has-session -t test-session"] = { ok: false, stdout: "", stderr: "" }; mockCommandResponses["tmux has-session -t test-session"] = {
const worktreePath = "/home/user/.local/share/opencode/multi-model/test-session/model"; 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`; const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/test-session/model`;
mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: worktreeListOutput, stderr: "" }; mockCommandResponses["git worktree list --porcelain"] = {
ok: true,
stdout: worktreeListOutput,
stderr: "",
};
// Stub readline to simulate user confirming cleanup (answers "y") // Stub readline to simulate user confirming cleanup (answers "y")
const mockInterface = { const mockInterface = {
question: (q: string, cb: (a: string) => void) => cb("y"), question: (_q: string, cb: (a: string) => void) => cb("y"),
close: () => {}, close: () => {},
} as any; } as any;
spyOn(readline, "createInterface").mockImplementation(() => mockInterface); spyOn(readline, "createInterface").mockImplementation(() => mockInterface);
@@ -138,13 +243,25 @@ describe("closeMultiModel", () => {
// Freeze timestamp for deterministic tag name // Freeze timestamp for deterministic tag name
const fixedDate = new Date("2023-01-01T00:00:00.000Z"); const fixedDate = new Date("2023-01-01T00:00:00.000Z");
const OriginalDate = Date; const OriginalDate = Date;
// @ts-ignore global.Date = class extends OriginalDate {
global.Date = class extends OriginalDate { constructor() { super(); return fixedDate; } toISOString() { return "2023-01-01T00:00:00.000Z"; } } as any; 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 }); const result = await closeMultiModel({
mode: "cli",
sessionName: "test-session",
cleanupWorktrees: true,
force: false,
});
// Restore Date after invocation // Restore Date after invocation
// @ts-ignore
global.Date = OriginalDate; global.Date = OriginalDate;
// Verify result indicates successful cleanup // Verify result indicates successful cleanup
@@ -153,38 +270,74 @@ describe("closeMultiModel", () => {
expect(result.worktreesRemoved).toEqual([worktreePath]); expect(result.worktreesRemoved).toEqual([worktreePath]);
expect(result.branchesDeleted).toEqual(["opencode/test-session/model"]); expect(result.branchesDeleted).toEqual(["opencode/test-session/model"]);
expect(result.tagsCreated?.length).toBe(1); expect(result.tagsCreated?.length).toBe(1);
// Verify the expected git commands were executed // Verify the expected git commands were executed
expect(executedCommands).toContain(`git worktree remove -f ${worktreePath}`); expect(executedCommands).toContain(
expect(executedCommands).toContain(`git branch -D opencode/test-session/model`); `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`; const expectedTagCmd = `git tag archive/opencode/test-session/model-2023-01-01T00-00-00-000Z opencode/test-session/model`;
expect(executedCommands).toContain(expectedTagCmd); expect(executedCommands).toContain(expectedTagCmd);
}); });
test("warning when tag creation fails", async () => { test("warning when tag creation fails", async () => {
mockCommandResponses["tmux has-session -t test-session"] = { ok: false, stdout: "", stderr: "" }; mockCommandResponses["tmux has-session -t test-session"] = {
const worktreePath = "/home/user/.local/share/opencode/multi-model/test-session/model"; 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`; const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/test-session/model`;
mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: worktreeListOutput, stderr: "" }; mockCommandResponses["git worktree list --porcelain"] = {
mockCommandResponses[`git worktree remove -f ${worktreePath}`] = { ok: true, stdout: "", stderr: "" }; ok: true,
mockCommandResponses["git branch -D opencode/test-session/model"] = { ok: true, stdout: "", stderr: "" }; 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 // Tag command will fail
const fixedDate = new Date("2023-01-01T00:00:00.000Z"); const fixedDate = new Date("2023-01-01T00:00:00.000Z");
const OriginalDate = Date; const OriginalDate = Date;
// @ts-ignore global.Date = class extends OriginalDate {
global.Date = class extends OriginalDate { constructor() { super(); return fixedDate; } toISOString() { return "2023-01-01T00:00:00.000Z"; } } as any; 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`; 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" }; mockCommandResponses[tagCommand] = {
ok: false,
stdout: "",
stderr: "tag error",
};
const result = await closeMultiModel({ sessionName: "test-session", cleanupWorktrees: true, force: true }); const result = await closeMultiModel({
mode: "cli",
sessionName: "test-session",
cleanupWorktrees: true,
force: true,
});
// Restore Date // Restore Date
// @ts-ignore
global.Date = OriginalDate; global.Date = OriginalDate;
expect(result.success).toBe(true); expect(result.success).toBe(true);
// Expect the warning message to be captured in instructions // Expect the warning message to be captured in instructions
expect(result.instructions.includes('Failed to create tag')).toBe(true); expect(result.instructions.includes("Failed to create tag")).toBe(true);
expect(result.tagsCreated).toEqual([]); expect(result.tagsCreated).toEqual([]);
}); });
@@ -192,23 +345,53 @@ describe("closeMultiModel", () => {
const unsanitized = "test session!"; const unsanitized = "test session!";
const sanitized = "test-session"; // result of sanitizeName const sanitized = "test-session"; // result of sanitizeName
// tmux lookup uses unsanitized name // tmux lookup uses unsanitized name
mockCommandResponses[`tmux has-session -t ${unsanitized}`] = { ok: false, stdout: "", stderr: "" }; mockCommandResponses[`tmux has-session -t ${unsanitized}`] = {
ok: false,
stdout: "",
stderr: "",
};
const worktreePath = `/home/user/.local/share/opencode/multi-model/${sanitized}/model`; const worktreePath = `/home/user/.local/share/opencode/multi-model/${sanitized}/model`;
const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/${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 list --porcelain"] = {
mockCommandResponses[`git worktree remove -f ${worktreePath}`] = { ok: true, stdout: "", stderr: "" }; ok: true,
mockCommandResponses[`git branch -D opencode/${sanitized}/model`] = { ok: true, stdout: "", stderr: "" }; stdout: worktreeListOutput,
mockCommandResponses[`rm -rf /home/user/.local/share/opencode/multi-model/${sanitized}`] = { ok: true, stdout: "", stderr: "" }; 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 // Freeze timestamp for deterministic tag name
const fixedDate = new Date("2023-01-01T00:00:00.000Z"); const fixedDate = new Date("2023-01-01T00:00:00.000Z");
const OriginalDate = Date; const OriginalDate = Date;
// @ts-ignore global.Date = class extends OriginalDate {
global.Date = class extends OriginalDate { constructor() { super(); return fixedDate; } toISOString() { return "2023-01-01T00:00:00.000Z"; } } as any; 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 }); const result = await closeMultiModel({
mode: "cli",
sessionName: unsanitized,
cleanupWorktrees: true,
force: true,
});
// Restore Date // Restore Date
// @ts-ignore
global.Date = OriginalDate; global.Date = OriginalDate;
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -217,37 +400,85 @@ describe("closeMultiModel", () => {
expect(result.branchesDeleted).toEqual([`opencode/${sanitized}/model`]); expect(result.branchesDeleted).toEqual([`opencode/${sanitized}/model`]);
expect(result.tagsCreated?.length).toBe(1); expect(result.tagsCreated?.length).toBe(1);
// Verify that the commands used the sanitized paths for worktree removal and branch deletion // 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(
expect(executedCommands).toContain(`git branch -D opencode/${sanitized}/model`); `git worktree remove -f ${worktreePath}`,
);
expect(executedCommands).toContain(
`git branch -D opencode/${sanitized}/model`,
);
}); });
test("tmux commands use sessionName, not safeSessionName", async () => { test("tmux commands use sessionName, not safeSessionName", async () => {
const unsanitized = "my session!"; const unsanitized = "my session!";
const sanitized = "my-session"; const sanitized = "my-session";
mockCommandResponses[`tmux has-session -t ${unsanitized}`] = { ok: true, stdout: "", stderr: "" }; mockCommandResponses[`tmux has-session -t ${unsanitized}`] = {
mockCommandResponses[`tmux kill-session -t ${unsanitized}`] = { ok: true, stdout: "", stderr: "" }; ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses[`tmux kill-session -t ${unsanitized}`] = {
ok: true,
stdout: "",
stderr: "",
};
// No worktrees // No worktrees
mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: "", stderr: "" }; mockCommandResponses["git worktree list --porcelain"] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await closeMultiModel({ sessionName: unsanitized, cleanupWorktrees: false, force: false }); const result = await closeMultiModel({
mode: "cli",
sessionName: unsanitized,
cleanupWorktrees: false,
force: false,
});
expect(result.success).toBe(true); expect(result.success).toBe(true);
// Verify tmux commands used unsanitized name // Verify tmux commands used unsanitized name
expect(executedCommands).toContain(`tmux has-session -t ${unsanitized}`); expect(executedCommands).toContain(`tmux has-session -t ${unsanitized}`);
expect(executedCommands).toContain(`tmux kill-session -t ${unsanitized}`); expect(executedCommands).toContain(`tmux kill-session -t ${unsanitized}`);
// Ensure sanitized name not used in tmux commands // Ensure sanitized name not used in tmux commands
expect(executedCommands.some(c => c.includes(`-t ${sanitized}`))).toBe(false); expect(executedCommands.some((c) => c.includes(`-t ${sanitized}`))).toBe(
false,
);
}); });
test("skip archive tags when flag disabled", async () => { test("skip archive tags when flag disabled", async () => {
mockCommandResponses["tmux has-session -t test-session"] = { ok: false, stdout: "", stderr: "" }; mockCommandResponses["tmux has-session -t test-session"] = {
const worktreePath = "/home/user/.local/share/opencode/multi-model/test-session/model"; 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`; const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/test-session/model`;
mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: worktreeListOutput, stderr: "" }; mockCommandResponses["git worktree list --porcelain"] = {
mockCommandResponses[`git worktree remove -f ${worktreePath}`] = { ok: true, stdout: "", stderr: "" }; ok: true,
mockCommandResponses["git branch -D opencode/test-session/model"] = { ok: true, stdout: "", stderr: "" }; stdout: worktreeListOutput,
mockCommandResponses["rm -rf /home/user/.local/share/opencode/multi-model/test-session"] = { ok: true, stdout: "", stderr: "" }; 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 }); const result = await closeMultiModel({
mode: "cli",
sessionName: "test-session",
cleanupWorktrees: true,
force: true,
createArchiveTags: false,
});
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.cleanupPerformed).toBe(true); expect(result.cleanupPerformed).toBe(true);
@@ -255,7 +486,8 @@ describe("closeMultiModel", () => {
expect(result.branchesDeleted).toEqual(["opencode/test-session/model"]); expect(result.branchesDeleted).toEqual(["opencode/test-session/model"]);
expect(result.tagsCreated?.length ?? 0).toBe(0); expect(result.tagsCreated?.length ?? 0).toBe(0);
// Ensure no tag command was executed // Ensure no tag command was executed
expect(executedCommands.some(cmd => cmd.startsWith("git tag"))).toBe(false); expect(executedCommands.some((cmd) => cmd.startsWith("git tag"))).toBe(
false,
);
}); });
}); });
+26 -6
View File
@@ -1,13 +1,24 @@
import { expect, test, mock, beforeEach, afterEach, describe, spyOn } from "bun:test"; import {
afterEach,
beforeEach,
describe,
expect,
mock,
spyOn,
test,
} from "bun:test";
import * as os from "node:os"; import * as os from "node:os";
import { closeMultiModel } from "../src/core/close"; import { closeMultiModel } from "../src/core/close";
let originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$; const originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
let mockCommandResponses: Record<string, { ok: boolean; stdout: string; stderr: string }> = {}; let mockCommandResponses: Record<
string,
{ ok: boolean; stdout: string; stderr: string }
> = {};
let executedCommands: string[] = []; let executedCommands: string[] = [];
function setupMockBun$() { function setupMockBun$() {
const mockFn = mock((strings: TemplateStringsArray, ...values: any[]) => { const mockFn = mock((_strings: TemplateStringsArray, ...values: any[]) => {
const parts = values[0] as string[]; const parts = values[0] as string[];
const cmd = parts.join(" "); const cmd = parts.join(" ");
executedCommands.push(cmd); executedCommands.push(cmd);
@@ -15,7 +26,11 @@ function setupMockBun$() {
if (cmd === "git worktree list --porcelain") { if (cmd === "git worktree list --porcelain") {
throw new Error("unexpected error"); throw new Error("unexpected error");
} }
const response = mockCommandResponses[cmd] ?? { ok: true, stdout: "", stderr: "" }; const response = mockCommandResponses[cmd] ?? {
ok: true,
stdout: "",
stderr: "",
};
return { return {
quiet: () => ({ quiet: () => ({
nothrow: async () => ({ nothrow: async () => ({
@@ -46,7 +61,12 @@ describe("closeMultiModel unexpected error handling", () => {
}); });
test("catches thrown error and returns failure", async () => { test("catches thrown error and returns failure", async () => {
const result = await closeMultiModel({ sessionName: "any-session", cleanupWorktrees: false, force: false }); const result = await closeMultiModel({
mode: "cli",
sessionName: "any-session",
cleanupWorktrees: false,
force: false,
});
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toContain("unexpected error"); expect(result.error).toContain("unexpected error");
}); });
+237 -75
View File
@@ -1,32 +1,25 @@
import { expect, test, mock, spyOn, beforeEach, afterEach, describe } from "bun:test"; import {
afterEach,
beforeEach,
describe,
expect,
mock,
spyOn,
test,
} from "bun:test";
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as os from "node:os"; import * as os from "node:os";
import { openMultiModel } from "../src/core/open"; import { openMultiModel } from "../src/core/open";
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?.$; const originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
let mockCommandResponses: Record<string, { ok: boolean; stdout: string; stderr: string }> = {}; let mockCommandResponses: Record<
string,
{ ok: boolean; stdout: string; stderr: string }
> = {};
let executedCommands: string[] = []; let executedCommands: string[] = [];
function setupMockBun$() { function setupMockBun$() {
const mockFn = mock((strings: TemplateStringsArray, ...values: any[]) => { const mockFn = mock((_strings: TemplateStringsArray, ...values: any[]) => {
const parts = values[0] as string[]; const parts = values[0] as string[];
const commandSignature = parts.join(" "); const commandSignature = parts.join(" ");
@@ -35,7 +28,11 @@ function setupMockBun$() {
return { return {
quiet: () => ({ quiet: () => ({
nothrow: async () => { nothrow: async () => {
const response = mockCommandResponses[commandSignature] ?? { ok: true, stdout: "", stderr: "" }; const response = mockCommandResponses[commandSignature] ?? {
ok: true,
stdout: "",
stderr: "",
};
return { return {
exitCode: response.ok ? 0 : 1, exitCode: response.ok ? 0 : 1,
stdout: { toString: () => response.stdout }, stdout: { toString: () => response.stdout },
@@ -56,21 +53,38 @@ function restoreOriginalBun$() {
describe("multi-model launch", () => { describe("multi-model launch", () => {
let existsSyncMock: ReturnType<typeof spyOn>; let existsSyncMock: ReturnType<typeof spyOn>;
let bunMock: ReturnType<typeof setupMockBun$>; let _bunMock: ReturnType<typeof setupMockBun$>;
beforeEach(() => { beforeEach(() => {
spyOn(os, "homedir").mockReturnValue("/mock/home"); spyOn(os, "homedir").mockReturnValue("/mock/home");
existsSyncMock = spyOn(fs, "existsSync").mockReturnValue(false); existsSyncMock = spyOn(fs, "existsSync").mockReturnValue(false);
bunMock = setupMockBun$(); _bunMock = setupMockBun$();
executedCommands = []; executedCommands = [];
mockCommandResponses = { mockCommandResponses = {
"git rev-parse --is-inside-work-tree": { ok: true, stdout: "true", stderr: "" }, "git rev-parse --is-inside-work-tree": {
ok: true,
stdout: "true",
stderr: "",
},
"command -v tmux": { ok: true, stdout: "/usr/bin/tmux", stderr: "" }, "command -v tmux": { ok: true, stdout: "/usr/bin/tmux", stderr: "" },
"command -v opencode": { ok: true, stdout: "/usr/bin/opencode", stderr: "" }, "command -v opencode": {
"opencode models": { ok: true, stdout: "openai/gpt-5.2\nanthropic/claude-3-5-sonnet", stderr: "" }, ok: true,
"tmux has-session -t test-session": { ok: false, stdout: "", stderr: "session not found" }, stdout: "/usr/bin/opencode",
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2": { ok: false, stdout: "", stderr: "" }, 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: "" },
}; };
}); });
@@ -80,6 +94,7 @@ describe("multi-model launch", () => {
test("fails if session name is empty", async () => { test("fails if session name is empty", async () => {
const result = await openMultiModel({ const result = await openMultiModel({
mode: "cli",
sessionName: "", sessionName: "",
models: ["openai/gpt-5.2"], models: ["openai/gpt-5.2"],
}); });
@@ -91,6 +106,7 @@ describe("multi-model launch", () => {
test("fails if no models are provided", async () => { test("fails if no models are provided", async () => {
const result = await openMultiModel({ const result = await openMultiModel({
mode: "cli",
sessionName: "test-session", sessionName: "test-session",
models: [], models: [],
}); });
@@ -102,6 +118,7 @@ describe("multi-model launch", () => {
test("fails if duplicate models are provided", async () => { test("fails if duplicate models are provided", async () => {
const result = await openMultiModel({ const result = await openMultiModel({
mode: "cli",
sessionName: "test-session", sessionName: "test-session",
models: ["openai/gpt-5.2", "openai/gpt-5.2"], models: ["openai/gpt-5.2", "openai/gpt-5.2"],
}); });
@@ -112,28 +129,42 @@ describe("multi-model launch", () => {
}); });
test("fails if not inside a git repository", async () => { 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" }; mockCommandResponses["git rev-parse --is-inside-work-tree"] = {
ok: false,
stdout: "",
stderr: "not a git repo",
};
const result = await openMultiModel({ const result = await openMultiModel({
mode: "cli",
sessionName: "test-session", sessionName: "test-session",
models: ["openai/gpt-5.2"], models: ["openai/gpt-5.2"],
}); });
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toContain("Error: `multi-model` requires a git repository."); expect(result.error).toContain(
"Error: `multi-model` requires a git repository.",
);
expect(executedCommands).toEqual(["git rev-parse --is-inside-work-tree"]); expect(executedCommands).toEqual(["git rev-parse --is-inside-work-tree"]);
}); });
test("fails if tmux is not installed", async () => { test("fails if tmux is not installed", async () => {
mockCommandResponses["command -v tmux"] = { ok: false, stdout: "", stderr: "" }; mockCommandResponses["command -v tmux"] = {
ok: false,
stdout: "",
stderr: "",
};
const result = await openMultiModel({ const result = await openMultiModel({
mode: "cli",
sessionName: "test-session", sessionName: "test-session",
models: ["openai/gpt-5.2"], models: ["openai/gpt-5.2"],
}); });
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toContain("Error: `tmux` is not installed or not on `PATH`."); expect(result.error).toContain(
"Error: `tmux` is not installed or not on `PATH`.",
);
expect(executedCommands).toEqual([ expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree", "git rev-parse --is-inside-work-tree",
"command -v tmux", "command -v tmux",
@@ -141,15 +172,22 @@ describe("multi-model launch", () => {
}); });
test("fails if opencode is not installed", async () => { test("fails if opencode is not installed", async () => {
mockCommandResponses["command -v opencode"] = { ok: false, stdout: "", stderr: "" }; mockCommandResponses["command -v opencode"] = {
ok: false,
stdout: "",
stderr: "",
};
const result = await openMultiModel({ const result = await openMultiModel({
mode: "cli",
sessionName: "test-session", sessionName: "test-session",
models: ["openai/gpt-5.2"], models: ["openai/gpt-5.2"],
}); });
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toContain("Error: `opencode` is not installed or not on `PATH`."); expect(result.error).toContain(
"Error: `opencode` is not installed or not on `PATH`.",
);
expect(executedCommands).toEqual([ expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree", "git rev-parse --is-inside-work-tree",
"command -v tmux", "command -v tmux",
@@ -159,12 +197,15 @@ describe("multi-model launch", () => {
test("fails if model name is not in allowlist", async () => { test("fails if model name is not in allowlist", async () => {
const result = await openMultiModel({ const result = await openMultiModel({
mode: "cli",
sessionName: "test-session", sessionName: "test-session",
models: ["invalid/model"], models: ["invalid/model"],
}); });
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toContain("Error: Model name 'invalid/model' not found"); expect(result.error).toContain(
"Error: Model name 'invalid/model' not found",
);
expect(executedCommands).toEqual([ expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree", "git rev-parse --is-inside-work-tree",
"command -v tmux", "command -v tmux",
@@ -174,9 +215,14 @@ describe("multi-model launch", () => {
}); });
test("fails if session already exists", async () => { test("fails if session already exists", async () => {
mockCommandResponses["tmux has-session -t test-session"] = { ok: true, stdout: "", stderr: "" }; mockCommandResponses["tmux has-session -t test-session"] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await openMultiModel({ const result = await openMultiModel({
mode: "cli",
sessionName: "test-session", sessionName: "test-session",
models: ["openai/gpt-5.2"], models: ["openai/gpt-5.2"],
}); });
@@ -194,75 +240,152 @@ describe("multi-model launch", () => {
test("successfully plans and executes tmux launch with single model", async () => { test("successfully plans and executes tmux launch with single model", async () => {
const result = await openMultiModel({ const result = await openMultiModel({
mode: "cli",
sessionName: "test-session", sessionName: "test-session",
models: ["openai/gpt-5.2"], models: ["openai/gpt-5.2"],
}); });
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session"); expect(result.instructions).toContain(
expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"))).toBe(true); "Use `tmux attach -t test-session` to join session",
expect(executedCommands.some(c => c.startsWith("tmux send-keys -t test-session:gpt-5-2"))).toBe(true); );
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 () => { 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: "" }; mockCommandResponses[
"git show-ref --verify --quiet refs/heads/opencode/test-session/claude-3-5-sonnet"
] = { ok: false, stdout: "", stderr: "" };
const result = await openMultiModel({ const result = await openMultiModel({
mode: "cli",
sessionName: "test-session", sessionName: "test-session",
models: ["openai/gpt-5.2", "anthropic/claude-3-5-sonnet"], models: ["openai/gpt-5.2", "anthropic/claude-3-5-sonnet"],
}); });
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session"); expect(result.instructions).toContain(
expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"))).toBe(true); "Use `tmux attach -t test-session` to join session",
expect(executedCommands.some(c => c.startsWith("tmux new-window -d -t test-session -n claude-3-5-sonnet"))).toBe(true); );
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 () => { test("sanitizes session name", async () => {
mockCommandResponses["tmux has-session -t test session!"] = { ok: false, stdout: "", stderr: "" }; mockCommandResponses["tmux has-session -t test session!"] = {
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "" }; 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({ const result = await openMultiModel({
mode: "cli",
sessionName: "test session!", sessionName: "test session!",
models: ["openai/gpt-5.2"], models: ["openai/gpt-5.2"],
}); });
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.instructions).toContain("Use `tmux attach -t test session!` to join session"); expect(result.instructions).toContain(
expect(executedCommands.some(c => c.startsWith("git worktree add -b opencode/test-session/gpt-5-2"))).toBe(true); "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 () => { test("uses sessionName for tmux commands, not safeSessionName", async () => {
const unsanitized = "my awesome session!!"; const unsanitized = "my awesome session!!";
const sanitized = "my-awesome-session"; const sanitized = "my-awesome-session";
mockCommandResponses[`tmux has-session -t ${unsanitized}`] = { ok: false, stdout: "", stderr: "" }; mockCommandResponses[`tmux has-session -t ${unsanitized}`] = {
mockCommandResponses[`git show-ref --verify --quiet refs/heads/opencode/${sanitized}/gpt-5-2`] = { ok: false, stdout: "", stderr: "" }; ok: false,
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: "" }; stdout: "",
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: "" }; stderr: "",
mockCommandResponses[`tmux send-keys -t ${unsanitized}:gpt-5-2 opencode --model 'openai/gpt-5.2' C-m`] = { ok: true, 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({ const result = await openMultiModel({
mode: "cli",
sessionName: unsanitized, sessionName: unsanitized,
models: ["openai/gpt-5.2"], models: ["openai/gpt-5.2"],
}); });
expect(result.success).toBe(true); expect(result.success).toBe(true);
// tmux commands should use unsanitized name // tmux commands should use unsanitized name
expect(executedCommands.some(c => c.startsWith(`tmux has-session -t ${unsanitized}`))).toBe(true); expect(
expect(executedCommands.some(c => c.startsWith(`tmux new-session -d -s ${unsanitized}`))).toBe(true); executedCommands.some((c) =>
expect(executedCommands.some(c => c.startsWith(`tmux send-keys -t ${unsanitized}:`))).toBe(true); 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 // sanitized name should NOT appear in tmux commands
expect(executedCommands.some(c => c.includes(`-t ${sanitized}`))).toBe(false); expect(executedCommands.some((c) => c.includes(`-t ${sanitized}`))).toBe(
false,
);
// git branches and paths should use sanitized name // 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); 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 () => { 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[
mockCommandResponses["git worktree remove -f /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"] = { ok: true, stdout: "", stderr: "" }; "tmux new-session -d -s test-session -n gpt-5-2 -c /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"
mockCommandResponses["git branch -D opencode/test-session/gpt-5-2"] = { ok: true, stdout: "", stderr: "" }; ] = { 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({ const result = await openMultiModel({
mode: "cli",
sessionName: "test-session", sessionName: "test-session",
models: ["openai/gpt-5.2"], models: ["openai/gpt-5.2"],
}); });
@@ -289,6 +412,7 @@ describe("multi-model launch", () => {
}); });
const result = await openMultiModel({ const result = await openMultiModel({
mode: "cli",
sessionName: "test-session", sessionName: "test-session",
models: ["openai/gpt-5.2"], models: ["openai/gpt-5.2"],
}); });
@@ -305,15 +429,20 @@ describe("multi-model launch", () => {
}); });
test("fails if git branch exists for first model", async () => { 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: "" }; mockCommandResponses[
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"
] = { ok: true, stdout: "", stderr: "" };
const result = await openMultiModel({ const result = await openMultiModel({
mode: "cli",
sessionName: "test-session", sessionName: "test-session",
models: ["openai/gpt-5.2"], models: ["openai/gpt-5.2"],
}); });
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toContain("Branch 'opencode/test-session/gpt-5-2' already exists"); expect(result.error).toContain(
"Branch 'opencode/test-session/gpt-5-2' already exists",
);
expect(executedCommands).toEqual([ expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree", "git rev-parse --is-inside-work-tree",
"command -v tmux", "command -v tmux",
@@ -325,9 +454,12 @@ describe("multi-model launch", () => {
}); });
test("fails if git worktree add fails for first model", async () => { 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" }; 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({ const result = await openMultiModel({
mode: "cli",
sessionName: "test-session", sessionName: "test-session",
models: ["openai/gpt-5.2"], models: ["openai/gpt-5.2"],
}); });
@@ -346,34 +478,64 @@ describe("multi-model launch", () => {
}); });
test("model name collision creates unique window names", async () => { 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["opencode models"] = {
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "" }; ok: true,
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2-2"] = { ok: false, stdout: "", stderr: "" }; 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({ const result = await openMultiModel({
mode: "cli",
sessionName: "test-session", sessionName: "test-session",
models: ["openai/gpt-5.2", "anthropic/gpt-5.2"], models: ["openai/gpt-5.2", "anthropic/gpt-5.2"],
}); });
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session"); expect(result.instructions).toContain(
expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"))).toBe(true); "Use `tmux attach -t test-session` to join session",
expect(executedCommands.some(c => c.startsWith("tmux new-window -d -t test-session -n gpt-5-2-2"))).toBe(true); );
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 () => { test("long model name is truncated in window name", async () => {
const longModel = "verylongmodelfrontexampleprovider"; const longModel = "verylongmodelfrontexampleprovider";
mockCommandResponses["opencode models"] = { ok: true, stdout: longModel, stderr: "" }; mockCommandResponses["opencode models"] = {
mockCommandResponses[`git show-ref --verify --quiet refs/heads/opencode/test-session/${longModel.slice(0, 24)}`] = { ok: false, stdout: "", stderr: "" }; 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({ const result = await openMultiModel({
mode: "cli",
sessionName: "test-session", sessionName: "test-session",
models: [longModel], models: [longModel],
}); });
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session"); expect(result.instructions).toContain(
const windowName = executedCommands.find(c => c.includes("tmux new-session"))?.match(/-n (\S+)/)?.[1]; "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); expect(windowName?.length).toBeLessThanOrEqual(24);
}); });
}); });
+471 -168
View File
@@ -1,18 +1,33 @@
import { expect, test, mock, beforeEach, afterEach, describe, spyOn } from "bun:test"; import {
import * as os from "node:os"; afterEach,
beforeEach,
describe,
expect,
mock,
spyOn,
test,
} from "bun:test";
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as os from "node:os";
import { openMultiModel } from "../src/core/open"; import { openMultiModel } from "../src/core/open";
let originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$; const originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
let mockCommandResponses: Record<string, { ok: boolean; stdout: string; stderr: string }> = {}; let mockCommandResponses: Record<
string,
{ ok: boolean; stdout: string; stderr: string }
> = {};
let executedCommands: string[] = []; let executedCommands: string[] = [];
function setupMockBun$() { function setupMockBun$() {
const mockFn = mock((strings: TemplateStringsArray, ...values: any[]) => { const mockFn = mock((_strings: TemplateStringsArray, ...values: any[]) => {
const parts = values[0] as string[]; const parts = values[0] as string[];
const cmd = parts.join(" "); const cmd = parts.join(" ");
executedCommands.push(cmd); executedCommands.push(cmd);
const response = mockCommandResponses[cmd] ?? { ok: true, stdout: "", stderr: "" }; const response = mockCommandResponses[cmd] ?? {
ok: true,
stdout: "",
stderr: "",
};
return { return {
quiet: () => ({ quiet: () => ({
nothrow: async () => ({ nothrow: async () => ({
@@ -44,193 +59,481 @@ describe("openMultiModel error paths", () => {
test("fails when model list command fails", async () => { test("fails when model list command fails", async () => {
const session = "sess"; const session = "sess";
mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: true, stdout: "true", stderr: "" }; mockCommandResponses["git rev-parse --is-inside-work-tree"] = {
mockCommandResponses["command -v tmux"] = { ok: true, stdout: "tmux", stderr: "" }; ok: true,
mockCommandResponses["command -v opencode"] = { ok: true, stdout: "opencode", stderr: "" }; stdout: "true",
mockCommandResponses["opencode models"] = { ok: false, stdout: "", stderr: "model error" }; 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" }); const result = await openMultiModel({
sessionName: session,
models: ["openai/gpt-5.2"],
binaryName: "opencode",
mode: "cli",
});
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toContain("Failed to load valid models"); expect(result.error).toContain("Failed to load valid models");
}); });
test("fails when duplicate models provided", async () => { test("fails when duplicate models provided", async () => {
const session = "dup"; const session = "dup";
mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: true, stdout: "true", stderr: "" }; mockCommandResponses["git rev-parse --is-inside-work-tree"] = {
mockCommandResponses["command -v tmux"] = { ok: true, stdout: "tmux", stderr: "" }; ok: true,
mockCommandResponses["command -v opencode"] = { ok: true, stdout: "opencode", stderr: "" }; stdout: "true",
mockCommandResponses["opencode models"] = { ok: true, stdout: "openai/gpt-5.2", stderr: "" }; 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" }); 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.success).toBe(false);
expect(result.error).toContain("Duplicate model names are not allowed"); expect(result.error).toContain("Duplicate model names are not allowed");
}); });
test("fails when session already exists", async () => { test("fails when session already exists", async () => {
const session = "existing"; const session = "existing";
mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: true, stdout: "true", stderr: "" }; mockCommandResponses["git rev-parse --is-inside-work-tree"] = {
mockCommandResponses["command -v tmux"] = { ok: true, stdout: "tmux", stderr: "" }; ok: true,
mockCommandResponses["command -v opencode"] = { ok: true, stdout: "opencode", stderr: "" }; stdout: "true",
mockCommandResponses["opencode models"] = { ok: true, stdout: "openai/gpt-5.2", stderr: "" }; stderr: "",
mockCommandResponses[`tmux has-session -t ${session}`] = { ok: true, stdout: "", 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" }); const result = await openMultiModel({
sessionName: session,
models: ["openai/gpt-5.2"],
binaryName: "opencode",
mode: "cli",
});
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toContain("Session already exists"); expect(result.error).toContain("Session already exists");
}); });
test("second model fails because branch already exists", async () => { test("second model fails because branch already exists", async () => {
const session = "sess-add"; const session = "sess-add";
const binary = "opencode"; const binary = "opencode";
const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"]; const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"];
// common mocks // common mocks
mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: true, stdout: "true", stderr: "" }; mockCommandResponses["git rev-parse --is-inside-work-tree"] = {
mockCommandResponses["command -v tmux"] = { ok: true, stdout: "tmux", stderr: "" }; ok: true,
mockCommandResponses["command -v opencode"] = { ok: true, stdout: "opencode", stderr: "" }; stdout: "true",
mockCommandResponses["opencode models"] = { ok: true, stdout: models.join("\n"), stderr: "" }; stderr: "",
mockCommandResponses[`tmux has-session -t ${session}`] = { ok: false, stdout: "", stderr: "" }; };
// first model success mockCommandResponses["command -v tmux"] = {
const firstBranch = `opencode/${session}/gpt-5-2`; ok: true,
const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`; stdout: "tmux",
mockCommandResponses[`git show-ref --verify --quiet refs/heads/${firstBranch}`] = { ok: false, stdout: "", stderr: "" }; 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["command -v opencode"] = {
mockCommandResponses[`tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m`] = { ok: true, stdout: "", stderr: "" }; ok: true,
// second model branch exists stdout: "opencode",
const secondBranch = `opencode/${session}/gpt-5-2-2`; stderr: "",
mockCommandResponses[`git show-ref --verify --quiet refs/heads/${secondBranch}`] = { ok: true, stdout: "", stderr: "" }; };
const result = await openMultiModel({ sessionName: session, models, binaryName: binary, mode: "cli" }); mockCommandResponses["opencode models"] = {
expect(result.success).toBe(true); ok: true,
expect(result.instructions).toContain("Failed: anthropic/gpt-5-2 (Branch"); stdout: models.join("\n"),
expect(result.windows).toContain("gpt-5-2"); stderr: "",
expect(result.windows).toContain("gpt-5-2-2"); };
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",
}); });
test("second model worktree creation fails", async () => { expect(result.success).toBe(true);
const session = "sess-wt"; expect(result.instructions).toContain("Error: Created tmux session");
const binary = "opencode"; expect(result.instructions).toContain("Failed: anthropic/gpt-5-2");
const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"]; expect(result.windows).toContain("gpt-5-2");
mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: true, stdout: "true", stderr: "" }; expect(result.windows).toContain("gpt-5-2-2");
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" });
test("second model window creation fails", async () => {
expect(result.success).toBe(true); const session = "sess-win";
expect(result.instructions).toContain("Error: Created tmux session"); expect(result.instructions).toContain("Failed: anthropic/gpt-5-2"); const binary = "opencode";
expect(result.windows).toContain("gpt-5-2"); const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"];
expect(result.windows).toContain("gpt-5-2-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",
}); });
test("second model window creation fails", async () => { expect(result.success).toBe(true);
const session = "sess-win"; expect(result.instructions).toContain("Error: Created tmux session");
const binary = "opencode"; expect(result.instructions).toContain("Failed: anthropic/gpt-5-2");
const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"]; expect(result.windows).toContain("gpt-5-2");
mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: true, stdout: "true", stderr: "" }; expect(result.windows).toContain("gpt-5-2-2");
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" });
test("second model launch command fails", async () => {
expect(result.success).toBe(true); const session = "sess-launch";
expect(result.instructions).toContain("Error: Created tmux session"); expect(result.instructions).toContain("Failed: anthropic/gpt-5-2"); const binary = "opencode";
expect(result.windows).toContain("gpt-5-2"); const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"];
expect(result.windows).toContain("gpt-5-2-2"); mockCommandResponses["git rev-parse --is-inside-work-tree"] = {
}); ok: true,
stdout: "true",
test("second model launch command fails", async () => { stderr: "",
const session = "sess-launch"; };
const binary = "opencode"; mockCommandResponses["command -v tmux"] = {
const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"]; ok: true,
mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: true, stdout: "true", stderr: "" }; stdout: "tmux",
mockCommandResponses["command -v tmux"] = { ok: true, stdout: "tmux", stderr: "" }; stderr: "",
mockCommandResponses["command -v opencode"] = { ok: true, stdout: "opencode", stderr: "" }; };
mockCommandResponses["opencode models"] = { ok: true, stdout: models.join("\n"), stderr: "" }; mockCommandResponses["command -v opencode"] = {
mockCommandResponses[`tmux has-session -t ${session}`] = { ok: false, stdout: "", stderr: "" }; ok: true,
// first model success stdout: "opencode",
const firstBranch = `opencode/${session}/gpt-5-2`; stderr: "",
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["opencode models"] = {
mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = { ok: true, stdout: "", stderr: "" }; ok: true,
mockCommandResponses[`tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}`] = { ok: true, stdout: "", stderr: "" }; stdout: models.join("\n"),
mockCommandResponses[`tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m`] = { ok: true, stdout: "", stderr: "" }; stderr: "",
// second model launch fails };
const secondBranch = `opencode/${session}/gpt-5-2-2`; mockCommandResponses[`tmux has-session -t ${session}`] = {
const secondPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2-2`; ok: false,
mockCommandResponses[`git show-ref --verify --quiet refs/heads/${secondBranch}`] = { ok: false, stdout: "", stderr: "" }; stdout: "",
mockCommandResponses[`git worktree add -b ${secondBranch} ${secondPath}`] = { ok: true, stdout: "", stderr: "" }; 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" }; // first model success
mockCommandResponses[`git worktree remove -f ${secondPath}`] = { ok: true, stdout: "", stderr: "" }; const firstBranch = `opencode/${session}/gpt-5-2`;
mockCommandResponses[`git branch -D ${secondBranch}`] = { ok: true, stdout: "", stderr: "" }; const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`;
const result = await openMultiModel({ sessionName: session, models, binaryName: binary, mode: "cli" }); mockCommandResponses[
expect(result.success).toBe(true); `git show-ref --verify --quiet refs/heads/${firstBranch}`
expect(result.instructions).toContain("Failed: anthropic/gpt-5-2 (launch err"); ] = { ok: false, stdout: "", stderr: "" };
expect(result.windows).toContain("gpt-5-2"); mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = {
expect(result.windows).toContain("gpt-5-2-2"); ok: true,
}); stdout: "",
stderr: "",
test("second model worktree path already exists", async () => { };
const session = "sess-wtpath"; mockCommandResponses[
const binary = "opencode"; `tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}`
const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"]; ] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: true, stdout: "true", stderr: "" }; mockCommandResponses[
mockCommandResponses["command -v tmux"] = { ok: true, stdout: "tmux", stderr: "" }; `tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m`
mockCommandResponses["command -v opencode"] = { ok: true, stdout: "opencode", stderr: "" }; ] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses["opencode models"] = { ok: true, stdout: models.join("\n"), stderr: "" }; // second model launch fails
mockCommandResponses[`tmux has-session -t ${session}`] = { ok: false, stdout: "", stderr: "" }; const secondBranch = `opencode/${session}/gpt-5-2-2`;
// first model success const secondPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2-2`;
const firstBranch = `opencode/${session}/gpt-5-2`; mockCommandResponses[
const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`; `git show-ref --verify --quiet refs/heads/${secondBranch}`
mockCommandResponses[`git show-ref --verify --quiet refs/heads/${firstBranch}`] = { ok: false, stdout: "", stderr: "" }; ] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = { ok: true, stdout: "", stderr: "" }; mockCommandResponses[`git worktree add -b ${secondBranch} ${secondPath}`] =
mockCommandResponses[`tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}`] = { ok: true, stdout: "", stderr: "" }; { 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: "" }; mockCommandResponses[
// second model path exists `tmux new-window -d -t ${session} -n gpt-5-2-2 -c ${secondPath}`
const secondBranch = `opencode/${session}/gpt-5-2-2`; ] = { ok: true, stdout: "", stderr: "" };
const secondPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2-2`; mockCommandResponses[
mockCommandResponses[`git show-ref --verify --quiet refs/heads/${secondBranch}`] = { ok: false, stdout: "", stderr: "" }; `tmux send-keys -t ${session}:gpt-5-2-2 ${binary} --model 'anthropic/gpt-5-2' C-m`
// mock fs existsSync to return true for secondPath ] = { ok: false, stdout: "", stderr: "launch err" };
spyOn(fs, "existsSync").mockImplementation((p) => p === secondPath); mockCommandResponses[`git worktree remove -f ${secondPath}`] = {
const result = await openMultiModel({ sessionName: session, models, binaryName: binary, mode: "cli" }); ok: true,
expect(result.success).toBe(true); stdout: "",
expect(result.instructions).toContain("Error: Created tmux session"); stderr: "",
expect(result.instructions).toContain("Failed: anthropic/gpt-5-2 (Worktree path already exists at"); };
expect(result.windows).toContain("gpt-5-2"); mockCommandResponses[`git branch -D ${secondBranch}`] = {
expect(result.windows).toContain("gpt-5-2-2"); 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");
});
});
+163 -53
View File
@@ -1,38 +1,52 @@
import { expect, test, mock, spyOn, beforeEach, afterEach, describe } from "bun:test"; import {
import * as fs from "node:fs"; afterEach,
beforeEach,
describe,
expect,
mock,
spyOn,
test,
} from "bun:test";
import * as os from "node:os"; import * as os from "node:os";
import { import {
shellQuote,
normalizeModels,
findDuplicates,
createWindowBaseName, createWindowBaseName,
createWindowPlans, createWindowPlans,
levenshtein, findDuplicates,
suggestModels,
formatInvalidModelError, formatInvalidModelError,
sanitizeName,
runCommand,
getWorktreePath,
getSessionPath,
undoWorktree,
launchModelInWindow,
getWorktreesForSession,
getBinaryName, getBinaryName,
getSessionPath,
getWorktreePath,
getWorktreesForSession,
launchModelInWindow,
levenshtein,
normalizeModels,
runCommand,
sanitizeName,
shellQuote,
suggestModels,
undoWorktree,
} from "../src/core/utils"; } from "../src/core/utils";
let originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$; const originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
let mockCommandResponses: Record<string, { ok: boolean; stdout: string; stderr: string }> = {}; let mockCommandResponses: Record<
string,
{ ok: boolean; stdout: string; stderr: string }
> = {};
let executedCommands: string[] = []; let executedCommands: string[] = [];
function setupMockBun$() { function setupMockBun$() {
const mockFn = mock((strings: TemplateStringsArray, ...values: any[]) => { const mockFn = mock((_strings: TemplateStringsArray, ...values: any[]) => {
const parts = values[0] as string[]; const parts = values[0] as string[];
const commandSignature = parts.join(" "); const commandSignature = parts.join(" ");
executedCommands.push(commandSignature); executedCommands.push(commandSignature);
return { return {
quiet: () => ({ quiet: () => ({
nothrow: async () => { nothrow: async () => {
const response = mockCommandResponses[commandSignature] ?? { ok: true, stdout: "", stderr: "" }; const response = mockCommandResponses[commandSignature] ?? {
ok: true,
stdout: "",
stderr: "",
};
return { return {
exitCode: response.ok ? 0 : 1, exitCode: response.ok ? 0 : 1,
stdout: { toString: () => response.stdout }, stdout: { toString: () => response.stdout },
@@ -51,11 +65,11 @@ function restoreOriginalBun$() {
} }
describe("core utils", () => { describe("core utils", () => {
let bunMock: ReturnType<typeof setupMockBun$>; let _bunMock: ReturnType<typeof setupMockBun$>;
beforeEach(() => { beforeEach(() => {
spyOn(os, "homedir").mockReturnValue("/mock/home"); spyOn(os, "homedir").mockReturnValue("/mock/home");
bunMock = setupMockBun$(); _bunMock = setupMockBun$();
executedCommands = []; executedCommands = [];
mockCommandResponses = {}; mockCommandResponses = {};
}); });
@@ -75,7 +89,9 @@ describe("core utils", () => {
expect(shellQuote("test'value")).toBe("'test'\"'\"'value'"); expect(shellQuote("test'value")).toBe("'test'\"'\"'value'");
}); });
test("string with multiple single quotes", () => { test("string with multiple single quotes", () => {
expect(shellQuote("te's't'v'alue")).toBe("'te'\"'\"'s'\"'\"'t'\"'\"'v'\"'\"'alue'"); expect(shellQuote("te's't'v'alue")).toBe(
"'te'\"'\"'s'\"'\"'t'\"'\"'v'\"'\"'alue'",
);
}); });
test("empty string", () => { test("empty string", () => {
expect(shellQuote("")).toBe("''"); expect(shellQuote("")).toBe("''");
@@ -93,7 +109,9 @@ describe("core utils", () => {
expect(normalizeModels([" ", " "])).toEqual([]); expect(normalizeModels([" ", " "])).toEqual([]);
}); });
test("array with mixed valid models, padded models, and empty strings", () => { 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"]); expect(
normalizeModels([" openai/gpt-5.2 ", "", " anthropic/claude "]),
).toEqual(["openai/gpt-5.2", "anthropic/claude"]);
}); });
}); });
@@ -111,7 +129,11 @@ describe("core utils", () => {
expect(findDuplicates(["a", "a", "a"])).toEqual(["a"]); expect(findDuplicates(["a", "a", "a"])).toEqual(["a"]);
}); });
test("verifying first-repeated-occurrence order", () => { test("verifying first-repeated-occurrence order", () => {
expect(findDuplicates(["x", "a", "b", "a", "x", "b"])).toEqual(["a", "x", "b"]); expect(findDuplicates(["x", "a", "b", "a", "x", "b"])).toEqual([
"a",
"x",
"b",
]);
}); });
}); });
@@ -136,10 +158,14 @@ describe("core utils", () => {
describe("createWindowPlans", () => { describe("createWindowPlans", () => {
test("single model", () => { test("single model", () => {
expect(createWindowPlans(["openai/gpt-5-2"])).toEqual([{ model: "openai/gpt-5-2", windowName: "gpt-5-2" }]); expect(createWindowPlans(["openai/gpt-5-2"])).toEqual([
{ model: "openai/gpt-5-2", windowName: "gpt-5-2" },
]);
}); });
test("two models with the same base name", () => { test("two models with the same base name", () => {
expect(createWindowPlans(["openai/gpt-5-2", "anthropic/gpt-5-2"])).toEqual([ expect(
createWindowPlans(["openai/gpt-5-2", "anthropic/gpt-5-2"]),
).toEqual([
{ model: "openai/gpt-5-2", windowName: "gpt-5-2" }, { model: "openai/gpt-5-2", windowName: "gpt-5-2" },
{ model: "anthropic/gpt-5-2", windowName: "gpt-5-2-2" }, { model: "anthropic/gpt-5-2", windowName: "gpt-5-2-2" },
]); ]);
@@ -194,7 +220,12 @@ describe("core utils", () => {
expect(suggestions[0]).toBe("gpt-5.2"); expect(suggestions[0]).toBe("gpt-5.2");
}); });
test("maximum of 3 suggestions", () => { test("maximum of 3 suggestions", () => {
const allowlist = ["gpt-5.2", "gpt-5.2-mini", "gpt-5.2-pro", "gpt-5.2-ultra"]; const allowlist = [
"gpt-5.2",
"gpt-5.2-mini",
"gpt-5.2-pro",
"gpt-5.2-ultra",
];
expect(suggestModels("gpt4o", allowlist).length).toBe(3); expect(suggestModels("gpt4o", allowlist).length).toBe(3);
}); });
test("sorting logic", () => { test("sorting logic", () => {
@@ -239,14 +270,22 @@ describe("core utils", () => {
describe("runCommand", () => { describe("runCommand", () => {
test("returns ok true for successful command", async () => { test("returns ok true for successful command", async () => {
mockCommandResponses["echo hello"] = { ok: true, stdout: "hello", stderr: "" }; mockCommandResponses["echo hello"] = {
ok: true,
stdout: "hello",
stderr: "",
};
const result = await runCommand(["echo", "hello"]); const result = await runCommand(["echo", "hello"]);
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
expect(result.stdout).toBe("hello"); expect(result.stdout).toBe("hello");
expect(result.stderr).toBe(""); expect(result.stderr).toBe("");
}); });
test("returns ok false for failed command", async () => { test("returns ok false for failed command", async () => {
mockCommandResponses["false"] = { ok: false, stdout: "", stderr: "error" }; mockCommandResponses.false = {
ok: false,
stdout: "",
stderr: "error",
};
const result = await runCommand(["false"]); const result = await runCommand(["false"]);
expect(result.ok).toBe(false); expect(result.ok).toBe(false);
expect(result.stderr).toBe("error"); expect(result.stderr).toBe("error");
@@ -257,12 +296,16 @@ describe("core utils", () => {
test("returns correct worktree path", () => { test("returns correct worktree path", () => {
spyOn(os, "homedir").mockReturnValue("/home/user"); spyOn(os, "homedir").mockReturnValue("/home/user");
const result = getWorktreePath("my-session", "gpt-5.2"); const result = getWorktreePath("my-session", "gpt-5.2");
expect(result).toBe("/home/user/.local/share/opencode/multi-model/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", () => { test("handles nested session names", () => {
spyOn(os, "homedir").mockReturnValue("/home/user"); spyOn(os, "homedir").mockReturnValue("/home/user");
const result = getWorktreePath("parent/child", "window"); const result = getWorktreePath("parent/child", "window");
expect(result).toBe("/home/user/.local/share/opencode/multi-model/parent/child/window"); expect(result).toBe(
"/home/user/.local/share/opencode/multi-model/parent/child/window",
);
}); });
}); });
@@ -270,57 +313,118 @@ describe("core utils", () => {
test("returns correct session path", () => { test("returns correct session path", () => {
spyOn(os, "homedir").mockReturnValue("/home/user"); spyOn(os, "homedir").mockReturnValue("/home/user");
const result = getSessionPath("my-session"); const result = getSessionPath("my-session");
expect(result).toBe("/home/user/.local/share/opencode/multi-model/my-session"); expect(result).toBe(
"/home/user/.local/share/opencode/multi-model/my-session",
);
}); });
}); });
describe("undoWorktree", () => { describe("undoWorktree", () => {
test("returns empty array on success", async () => { test("returns empty array on success", async () => {
mockCommandResponses["git worktree remove -f /path/to/worktree"] = { ok: true, stdout: "", stderr: "" }; mockCommandResponses["git worktree remove -f /path/to/worktree"] = {
mockCommandResponses["git branch -D opencode/session/window"] = { ok: true, stdout: "", stderr: "" }; 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"); const errors = await undoWorktree(
"/path/to/worktree",
"opencode/session/window",
);
expect(errors).toEqual([]); expect(errors).toEqual([]);
}); });
test("returns error when worktree remove fails", async () => { test("returns error when worktree remove fails", async () => {
mockCommandResponses["git worktree remove -f /path/to/worktree"] = { ok: false, stdout: "", stderr: "remove failed" }; mockCommandResponses["git worktree remove -f /path/to/worktree"] = {
mockCommandResponses["git branch -D opencode/session/window"] = { ok: true, stdout: "", stderr: "" }; 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"); const errors = await undoWorktree(
"/path/to/worktree",
"opencode/session/window",
);
expect(errors.length).toBe(1); expect(errors.length).toBe(1);
expect(errors[0]).toContain("Failed to remove worktree"); expect(errors[0]).toContain("Failed to remove worktree");
}); });
test("returns error when branch delete fails", async () => { test("returns error when branch delete fails", async () => {
mockCommandResponses["git worktree remove -f /path/to/worktree"] = { ok: true, stdout: "", stderr: "" }; mockCommandResponses["git worktree remove -f /path/to/worktree"] = {
mockCommandResponses["git branch -D opencode/session/window"] = { ok: false, stdout: "", stderr: "branch delete failed" }; 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"); const errors = await undoWorktree(
"/path/to/worktree",
"opencode/session/window",
);
expect(errors.length).toBe(1); expect(errors.length).toBe(1);
expect(errors[0]).toContain("Failed to delete branch"); expect(errors[0]).toContain("Failed to delete branch");
}); });
test("returns both errors when both operations fail", async () => { 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 worktree remove -f /path/to/worktree"] = {
mockCommandResponses["git branch -D opencode/session/window"] = { ok: false, stdout: "", stderr: "branch delete failed" }; 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"); const errors = await undoWorktree(
"/path/to/worktree",
"opencode/session/window",
);
expect(errors.length).toBe(2); expect(errors.length).toBe(2);
}); });
}); });
describe("launchModelInWindow", () => { describe("launchModelInWindow", () => {
test("sends correct tmux command", async () => { 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: "" }; 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" }); const result = await launchModelInWindow("session", {
model: "openai/gpt-5.2",
windowName: "window",
});
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
expect(executedCommands).toContain("tmux send-keys -t session:window opencode --model 'openai/gpt-5.2' C-m"); expect(executedCommands).toContain(
"tmux send-keys -t session:window opencode --model 'openai/gpt-5.2' C-m",
);
}); });
test("uses custom binary name", async () => { 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: "" }; 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"); const result = await launchModelInWindow(
"session",
{ model: "openai/gpt-5.2", windowName: "window" },
"kilo",
);
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
expect(executedCommands).toContain("tmux send-keys -t session:window kilo --model 'openai/gpt-5.2' C-m"); expect(executedCommands).toContain(
"tmux send-keys -t session:window kilo --model 'openai/gpt-5.2' C-m",
);
}); });
}); });
@@ -329,26 +433,32 @@ describe("core utils", () => {
spyOn(os, "homedir").mockReturnValue("/home/user"); spyOn(os, "homedir").mockReturnValue("/home/user");
mockCommandResponses["git worktree list --porcelain"] = { mockCommandResponses["git worktree list --porcelain"] = {
ok: true, 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", 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: "", stderr: "",
}; };
const worktrees = await getWorktreesForSession("my-session"); const worktrees = await getWorktreesForSession("my-session");
expect(worktrees.length).toBe(1); 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]?.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"); expect(worktrees[0]?.branch).toBe("opencode/my-session/gpt-5.2");
}); });
test("sanitizes session name when filtering worktrees", async () => { test("sanitizes session name when filtering worktrees", async () => {
spyOn(os, "homedir").mockReturnValue("/home/user"); spyOn(os, "homedir").mockReturnValue("/home/user");
mockCommandResponses["git worktree list --porcelain"] = { mockCommandResponses["git worktree list --porcelain"] = {
ok: true, 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", stdout:
"worktree /home/user/.local/share/opencode/multi-model/my-session/gpt-5.2\nbranch refs/heads/opencode/my-session/gpt-5.2",
stderr: "", stderr: "",
}; };
const worktrees = await getWorktreesForSession("my session!"); const worktrees = await getWorktreesForSession("my session!");
expect(worktrees.length).toBe(1); 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]?.path).toBe(
"/home/user/.local/share/opencode/multi-model/my-session/gpt-5.2",
);
}); });
test("returns empty array when no matching worktrees", async () => { test("returns empty array when no matching worktrees", async () => {
spyOn(os, "homedir").mockReturnValue("/home/user"); spyOn(os, "homedir").mockReturnValue("/home/user");
+3 -1
View File
@@ -12,7 +12,9 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
"noImplicitOverride": true, "noImplicitOverride": true,
"types": ["bun-types"] "types": ["bun-types"],
"resolveJsonModule": true,
"esModuleInterop": true
}, },
"include": ["src/**/*", "tests/**/*"], "include": ["src/**/*", "tests/**/*"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]