mirror of
https://github.com/bendtherules/opencode-multi-model.git
synced 2026-08-18 13:42:21 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc1efdbd63 | ||
|
|
77e5470d04 | ||
|
|
ac31d69e3a | ||
|
|
651c2ae9a9 | ||
|
|
0a509d2bc3 | ||
|
|
d8a2e11d29 | ||
|
|
2cc01f5faa | ||
|
|
b3a9ccc848 | ||
|
|
2558d44ea2 | ||
|
|
af5d888f56 | ||
|
|
fabd2f6519 | ||
|
|
932e023fcf | ||
|
|
8f069c5582 | ||
|
|
1a58a318fd | ||
|
|
e16f432c94 | ||
|
|
e4d3e0e263 | ||
|
|
6b07a7ac2a | ||
|
|
415b0624f1 | ||
|
|
4824375970 | ||
|
|
1294fcede1 | ||
|
|
8b85bc4a39 | ||
|
|
78784fabb6 | ||
|
|
c6698e7c22 | ||
|
|
cb9c973868 | ||
|
|
f58cd1e897 | ||
|
|
4e20a03359 | ||
|
|
114837cfae | ||
|
|
426def40f1 | ||
|
|
3ac3153aa9 | ||
|
|
233e211eb8 | ||
|
|
4f15a8f8d2 | ||
|
|
46d81b4162 | ||
|
|
c6d6c889d8 | ||
|
|
6367d99e7b |
@@ -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
|
||||
@@ -1,7 +1,8 @@
|
||||
// Re-export open and close tools for project-level use via relative paths.
|
||||
// For published package usage, change imports to "@username/opencode-multi-model".
|
||||
|
||||
import { cleanupTool } from "../../src/tools/cleanup";
|
||||
import { closeTool } from "../../src/tools/close";
|
||||
import { openTool } from "../../src/tools/open";
|
||||
|
||||
export { closeTool as close, openTool as open };
|
||||
export { cleanupTool as cleanup, closeTool as close, openTool as open };
|
||||
|
||||
@@ -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
|
||||
@@ -62,6 +62,11 @@ sessionName: my-session, models: ["openai/gpt-5.2", "anthropic/claude-3-5-sonnet
|
||||
call tool multi-model-close with sessionName: my-session
|
||||
```
|
||||
|
||||
**Cleanup archive tags (using tool):**
|
||||
```
|
||||
call tool multi-model-cleanup
|
||||
```
|
||||
|
||||
**Close a session (using manual command):**
|
||||
```bash
|
||||
tmux kill-session -t my-session && rm -rf ~/.local/share/opencode/multi-model/my-session && git worktree prune && git branch -D $(git branch --format='%(refname:short)' --list 'opencode/my-session/*')
|
||||
@@ -88,6 +93,21 @@ opencode-multi-model open my-session -m openai/gpt-5.2
|
||||
opencode-multi-model close my-session
|
||||
```
|
||||
|
||||
**Cleanup archive tags (CLI command):**
|
||||
```bash
|
||||
# Delete all archive tags from local and remote repositories
|
||||
opencode-multi-model cleanup
|
||||
|
||||
# Delete all archive tags without confirmation prompts
|
||||
opencode-multi-model cleanup --force
|
||||
|
||||
# Delete only local archive tags, skip remote deletion
|
||||
opencode-multi-model cleanup --no-remote
|
||||
|
||||
# Delete archive tags and push deletions to a specific remote
|
||||
opencode-multi-model cleanup --remote origin
|
||||
```
|
||||
|
||||
**Close a session (manual command):**
|
||||
```bash
|
||||
tmux kill-session -t my-session && rm -rf ~/.local/share/opencode/multi-model/my-session && git worktree prune && git branch -D $(git branch --format='%(refname:short)' --list 'opencode/my-session/*')
|
||||
@@ -98,6 +118,7 @@ tmux kill-session -t my-session && rm -rf ~/.local/share/opencode/multi-model/my
|
||||
opencode-multi-model --help
|
||||
opencode-multi-model open --help
|
||||
opencode-multi-model close --help
|
||||
opencode-multi-model cleanup --help
|
||||
```
|
||||
|
||||
## Configuration
|
||||
@@ -151,6 +172,21 @@ opencode-multi-model close <session-name> [options]
|
||||
| `--no-tags` | Do not create archive tags before deleting branches |
|
||||
| `-f, --force` | Skip confirmation prompts |
|
||||
|
||||
### `cleanup` - Delete all archive tags from local and remote repositories
|
||||
|
||||
Archive tags have the format: `archive/<branch-name>-<timestamp>`. These are created by the close command to preserve branch state before deletion.
|
||||
|
||||
```bash
|
||||
opencode-multi-model cleanup [options]
|
||||
```
|
||||
|
||||
**Options:**
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-f, --force` | Skip confirmation prompts |
|
||||
| `-r, --remote <remote>` | Remote name to push deletions to (default: first remote from git remote -v) |
|
||||
| `--no-remote` | Only delete local tags, skip remote |
|
||||
|
||||
### Global Options
|
||||
|
||||
| Option | Description |
|
||||
@@ -191,6 +227,20 @@ The `close` command performs the following steps:
|
||||
|
||||
4. **Directory cleanup**: Removes the base session directory
|
||||
|
||||
### `cleanup` command
|
||||
|
||||
The `cleanup` command performs the following steps:
|
||||
|
||||
1. **Archive tag discovery**: Finds all git tags matching the pattern `archive/*`
|
||||
|
||||
2. **Local tag deletion**: Deletes all archive tags from the local repository
|
||||
|
||||
3. **Remote tag deletion** (unless `--no-remote`):
|
||||
- Determines which remote to use (user-specified, first available, or skip)
|
||||
- Pushes tag deletions to the remote repository
|
||||
|
||||
4. **Summary**: Reports the number of tags deleted locally and remotely
|
||||
|
||||
## Requirements
|
||||
|
||||
- tmux
|
||||
@@ -230,11 +280,13 @@ bun dist/cli.js close test-session
|
||||
│ ├── core/
|
||||
│ │ ├── index.ts # Core exports
|
||||
│ │ ├── 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
|
||||
│ └── tools/
|
||||
│ ├── open.ts # Open tool definition
|
||||
│ └── close.ts # Close tool definition
|
||||
│ ├── close.ts # Close tool definition
|
||||
│ └── cleanup.ts # Cleanup tool definition
|
||||
├── tests/
|
||||
│ └── multi-model.test.ts # Tests
|
||||
├── package.json
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"@opencode-ai/plugin": "^1.2.27",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"ora": "^9.3.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.8",
|
||||
@@ -44,16 +45,48 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
|
||||
|
||||
"zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "opencode-multi-model",
|
||||
"version": "0.0.10",
|
||||
"version": "0.2.2",
|
||||
"description": "Launch multiple AI models in tmux sessions - OpenCode Plugin, CLI, and Project Tool",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
@@ -29,9 +29,10 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "^5.3.0",
|
||||
"@opencode-ai/plugin": "^1.2.27",
|
||||
"commander": "^12.0.0"
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"ora": "^9.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.8",
|
||||
|
||||
+34
-4
@@ -1,16 +1,18 @@
|
||||
#!/usr/bin/env bun
|
||||
import chalk from "chalk";
|
||||
import { Command } from "commander";
|
||||
import pkg from "../package.json";
|
||||
import { cleanupMultiModel } from "./core/cleanup";
|
||||
import { closeMultiModel } from "./core/close";
|
||||
import { openMultiModel } from "./core/open";
|
||||
import { getBinaryName } from "./core/utils";
|
||||
import { getBinaryName, loadSettings, promptUser } from "./core/utils";
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name("opencode-multi-model")
|
||||
.description("Launch multiple OpenCode models in tmux sessions")
|
||||
.version("1.0.0");
|
||||
.version(pkg.version);
|
||||
|
||||
program
|
||||
.command("open")
|
||||
@@ -25,19 +27,45 @@ program
|
||||
"-b, --binary <binary>",
|
||||
"Binary to use (opencode or kilo). Defaults to env var OPENCODE_MULTI_MODEL_BINARY or 'opencode'",
|
||||
)
|
||||
.option("-f, --force", "Skip confirmation prompts", false)
|
||||
.action(
|
||||
async (
|
||||
sessionName: string,
|
||||
options: { models: string[]; binary?: string },
|
||||
options: { models: string[]; binary?: string; force: boolean },
|
||||
) => {
|
||||
try {
|
||||
let models = options.models;
|
||||
|
||||
if (models.length === 0) {
|
||||
const settings = await loadSettings();
|
||||
if (settings.lastModels.length > 0) {
|
||||
if (!options.force) {
|
||||
const modelList = settings.lastModels
|
||||
.map((m) => ` - ${chalk.cyan(m)}`)
|
||||
.join("\n");
|
||||
const answer = await promptUser(
|
||||
`Continue with last used models?\n${modelList}\n(Y/n): `,
|
||||
);
|
||||
if (
|
||||
answer.toLowerCase() === "y" ||
|
||||
answer.toLowerCase() === "yes"
|
||||
) {
|
||||
models = settings.lastModels;
|
||||
}
|
||||
} else {
|
||||
models = settings.lastModels;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const binaryName = options.binary || getBinaryName();
|
||||
|
||||
const result = await openMultiModel({
|
||||
sessionName,
|
||||
models: options.models,
|
||||
models,
|
||||
binaryName,
|
||||
mode: "cli",
|
||||
saveToSettings: true,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
@@ -77,6 +105,7 @@ program
|
||||
cleanupWorktrees: !options.keepWorktrees,
|
||||
force: options.force,
|
||||
createArchiveTags: options.tags,
|
||||
mode: "cli",
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
@@ -106,6 +135,7 @@ program
|
||||
const result = await cleanupMultiModel({
|
||||
force: options.force,
|
||||
remote: options.remote === true ? undefined : options.remote,
|
||||
mode: "cli",
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
|
||||
+52
-37
@@ -1,45 +1,52 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
import { promptUser, runCommand } from "./utils";
|
||||
|
||||
/**
|
||||
* Lists all archive tags matching the pattern archive/*.
|
||||
*/
|
||||
export async function listArchiveTags(): Promise<string[]> {
|
||||
const { stdout } = await runCommand(["git", "tag", "-l", "archive/*"]);
|
||||
export async function listArchiveTags(
|
||||
mode?: "cli" | "tool",
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<string[]> {
|
||||
const { stdout } = await runCommand(["git", "tag", "-l", "archive/*"], {
|
||||
mode,
|
||||
abortSignal,
|
||||
});
|
||||
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"]);
|
||||
/**
|
||||
* Retrieves the list of git remotes.
|
||||
* Accepts optional mode and abort signal for cancellation support.
|
||||
*/
|
||||
export async function getAvailableRemotes(
|
||||
mode?: "cli" | "tool",
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<string[]> {
|
||||
const { stdout } = await runCommand(["git", "remote"], {
|
||||
mode,
|
||||
abortSignal,
|
||||
});
|
||||
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]);
|
||||
async function deleteLocalTag(
|
||||
tagName: string,
|
||||
mode?: "cli" | "tool",
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<boolean> {
|
||||
const result = await runCommand(["git", "tag", "-d", tagName], {
|
||||
mode,
|
||||
spinnerMsg: `Deleting local tag ${tagName}...`,
|
||||
abortSignal,
|
||||
});
|
||||
return result.ok;
|
||||
}
|
||||
|
||||
@@ -49,14 +56,17 @@ async function deleteLocalTag(tagName: string): Promise<boolean> {
|
||||
async function deleteRemoteTag(
|
||||
tagName: string,
|
||||
remoteName: string,
|
||||
mode?: "cli" | "tool",
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<{ ok: boolean; error: string }> {
|
||||
const result = await runCommand([
|
||||
"git",
|
||||
"push",
|
||||
remoteName,
|
||||
"--delete",
|
||||
tagName,
|
||||
]);
|
||||
const result = await runCommand(
|
||||
["git", "push", remoteName, "--delete", tagName],
|
||||
{
|
||||
mode,
|
||||
spinnerMsg: `Deleting remote tag ${tagName} from ${remoteName}...`,
|
||||
abortSignal,
|
||||
},
|
||||
);
|
||||
return {
|
||||
ok: result.ok,
|
||||
error: result.ok ? "" : result.stderr,
|
||||
@@ -73,13 +83,13 @@ async function deleteRemoteTag(
|
||||
* @returns Result with counts and details of deleted tags.
|
||||
*/
|
||||
export async function cleanupMultiModel(
|
||||
options: CleanupOptions = {},
|
||||
options: CleanupOptions,
|
||||
): Promise<CleanupResult> {
|
||||
const { force = false, remote } = options;
|
||||
const { force = false, remote, mode, abortSignal } = options;
|
||||
|
||||
// Get available remotes and archive tags first
|
||||
const availableRemotes = await getAvailableRemotes();
|
||||
const archiveTags = await listArchiveTags();
|
||||
const availableRemotes = await getAvailableRemotes(mode, abortSignal);
|
||||
const archiveTags = await listArchiveTags(mode, abortSignal);
|
||||
|
||||
// Determine whether to push to remote and which remote to use:
|
||||
// - remote === false: --no-remote flag, local-only mode
|
||||
@@ -165,7 +175,7 @@ export async function cleanupMultiModel(
|
||||
|
||||
// Delete local tags
|
||||
for (const tag of archiveTags) {
|
||||
const deleted = await deleteLocalTag(tag);
|
||||
const deleted = await deleteLocalTag(tag, mode, abortSignal);
|
||||
if (deleted) {
|
||||
localTagNames.push(tag);
|
||||
}
|
||||
@@ -174,7 +184,12 @@ export async function cleanupMultiModel(
|
||||
// Delete from remote (if remote is specified and exists)
|
||||
if (shouldPushToRemote && remoteToDeleteFrom) {
|
||||
for (const tag of localTagNames) {
|
||||
const result = await deleteRemoteTag(tag, remoteToDeleteFrom);
|
||||
const result = await deleteRemoteTag(
|
||||
tag,
|
||||
remoteToDeleteFrom,
|
||||
mode,
|
||||
abortSignal,
|
||||
);
|
||||
if (result.ok) {
|
||||
remoteTagNames.push(tag);
|
||||
} else {
|
||||
|
||||
+34
-37
@@ -1,33 +1,13 @@
|
||||
import * as readline from "node:readline";
|
||||
import chalk from "chalk"; // removed console usage
|
||||
import type { CloseSessionOptions, CloseSessionResult } from "../types";
|
||||
import {
|
||||
getSessionPath,
|
||||
getWorktreesForSession,
|
||||
promptUser,
|
||||
runCommand,
|
||||
sanitizeName,
|
||||
} from "./utils";
|
||||
|
||||
/**
|
||||
* Prompts the user for input on the terminal.
|
||||
*
|
||||
* @param question The question to display.
|
||||
* @returns The user's answer.
|
||||
*/
|
||||
async function promptUser(question: string): Promise<string> {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer) => {
|
||||
rl.close();
|
||||
resolve(answer);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes a multi-model tmux session and optionally cleans up worktrees and branches.
|
||||
*
|
||||
@@ -56,20 +36,27 @@ export async function closeMultiModel(
|
||||
const instructionsArr: string[] = [];
|
||||
const branchesDeleted: string[] = [];
|
||||
const tagsCreated: string[] = [];
|
||||
const { mode, abortSignal } = options;
|
||||
|
||||
const safeSessionName = sanitizeName(options.sessionName);
|
||||
try {
|
||||
// Check if session exists
|
||||
const { ok: sessionExists } = await runCommand([
|
||||
"tmux",
|
||||
"has-session",
|
||||
"-t",
|
||||
options.sessionName,
|
||||
]);
|
||||
const { ok: sessionExists } = await runCommand(
|
||||
["tmux", "has-session", "-t", options.sessionName],
|
||||
{
|
||||
mode,
|
||||
spinnerMsg: `Checking if session '${options.sessionName}' exists...`,
|
||||
abortSignal,
|
||||
},
|
||||
);
|
||||
|
||||
// Kill tmux session if it exists
|
||||
if (sessionExists) {
|
||||
await runCommand(["tmux", "kill-session", "-t", options.sessionName]);
|
||||
await runCommand(["tmux", "kill-session", "-t", options.sessionName], {
|
||||
mode,
|
||||
spinnerMsg: `Killing session '${options.sessionName}'...`,
|
||||
abortSignal,
|
||||
});
|
||||
instructionsArr.push(
|
||||
chalk.green(`Closed session '${options.sessionName}'`),
|
||||
);
|
||||
@@ -112,19 +99,21 @@ export async function closeMultiModel(
|
||||
for (const worktree of worktrees) {
|
||||
try {
|
||||
// Remove worktree
|
||||
await runCommand(["git", "worktree", "remove", "-f", worktree.path]);
|
||||
await runCommand(["git", "worktree", "remove", "-f", worktree.path], {
|
||||
mode,
|
||||
spinnerMsg: `Removing worktree ${worktree.path}...`,
|
||||
abortSignal,
|
||||
});
|
||||
worktreesRemoved.push(worktree.path);
|
||||
|
||||
// Optionally create an archive tag before deleting the branch for safe recovery
|
||||
if (options.createArchiveTags !== false) {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const archiveTag = `archive/${worktree.branch}-${timestamp}`;
|
||||
const tagResult = await runCommand([
|
||||
"git",
|
||||
"tag",
|
||||
archiveTag,
|
||||
worktree.branch,
|
||||
]);
|
||||
const tagResult = await runCommand(
|
||||
["git", "tag", archiveTag, worktree.branch],
|
||||
{ mode, spinnerMsg: `Creating archive tag ${archiveTag}...` },
|
||||
);
|
||||
|
||||
if (tagResult.ok) {
|
||||
tagsCreated.push(archiveTag);
|
||||
@@ -136,7 +125,11 @@ export async function closeMultiModel(
|
||||
}
|
||||
}
|
||||
|
||||
await runCommand(["git", "branch", "-D", worktree.branch]);
|
||||
await runCommand(["git", "branch", "-D", worktree.branch], {
|
||||
mode,
|
||||
spinnerMsg: `Deleting branch ${worktree.branch}...`,
|
||||
abortSignal,
|
||||
});
|
||||
branchesDeleted.push(worktree.branch);
|
||||
} catch (err) {
|
||||
instructionsArr.push(
|
||||
@@ -148,7 +141,11 @@ export async function closeMultiModel(
|
||||
// Also remove the base directory
|
||||
const worktreeBase = getSessionPath(safeSessionName);
|
||||
try {
|
||||
await runCommand(["rm", "-rf", worktreeBase]);
|
||||
await runCommand(["rm", "-rf", worktreeBase], {
|
||||
mode,
|
||||
spinnerMsg: `Removing worktree base directory: ${worktreeBase}...`,
|
||||
abortSignal,
|
||||
});
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
+93
-55
@@ -1,6 +1,6 @@
|
||||
import * as fs from "node:fs";
|
||||
import chalk from "chalk";
|
||||
import type { MultiModelOptions, MultiModelResult } from "../types";
|
||||
import type { OpenSessionOptions, OpenSessionResult } from "../types";
|
||||
import {
|
||||
createWindowPlans,
|
||||
findDuplicates,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
normalizeModels,
|
||||
runCommand,
|
||||
sanitizeName,
|
||||
saveSettings,
|
||||
undoWorktree,
|
||||
} from "./utils";
|
||||
|
||||
@@ -35,13 +36,13 @@ import {
|
||||
* ```
|
||||
*/
|
||||
export async function openMultiModel(
|
||||
options: MultiModelOptions,
|
||||
): Promise<MultiModelResult> {
|
||||
options: OpenSessionOptions,
|
||||
): Promise<OpenSessionResult> {
|
||||
const sessionName = options.sessionName.trim();
|
||||
const safeSessionName = sanitizeName(sessionName);
|
||||
const models = normalizeModels(options.models);
|
||||
const binaryName = options.binaryName || "opencode";
|
||||
const mode = options.mode || "cli";
|
||||
const { mode, abortSignal } = options;
|
||||
|
||||
if (!sessionName) {
|
||||
return {
|
||||
@@ -68,11 +69,14 @@ export async function openMultiModel(
|
||||
};
|
||||
}
|
||||
|
||||
const gitRepoCheck = await runCommand([
|
||||
"git",
|
||||
"rev-parse",
|
||||
"--is-inside-work-tree",
|
||||
]);
|
||||
const gitRepoCheck = await runCommand(
|
||||
["git", "rev-parse", "--is-inside-work-tree"],
|
||||
{
|
||||
mode,
|
||||
spinnerMsg: "Checking if inside git repository...",
|
||||
abortSignal,
|
||||
},
|
||||
);
|
||||
if (!gitRepoCheck.ok) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -81,7 +85,11 @@ export async function openMultiModel(
|
||||
};
|
||||
}
|
||||
|
||||
const tmuxExists = await runCommand(["command", "-v", "tmux"]);
|
||||
const tmuxExists = await runCommand(["command", "-v", "tmux"], {
|
||||
mode,
|
||||
spinnerMsg: "Checking if tmux is installed...",
|
||||
abortSignal,
|
||||
});
|
||||
if (!tmuxExists.ok) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -90,7 +98,11 @@ export async function openMultiModel(
|
||||
};
|
||||
}
|
||||
|
||||
const binaryExists = await runCommand(["command", "-v", binaryName]);
|
||||
const binaryExists = await runCommand(["command", "-v", binaryName], {
|
||||
mode,
|
||||
spinnerMsg: `Checking if ${binaryName} is installed...`,
|
||||
abortSignal,
|
||||
});
|
||||
if (!binaryExists.ok) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -99,7 +111,11 @@ export async function openMultiModel(
|
||||
};
|
||||
}
|
||||
|
||||
const modelListResult = await runCommand([binaryName, "models"]);
|
||||
const modelListResult = await runCommand([binaryName, "models"], {
|
||||
mode,
|
||||
spinnerMsg: `Fetching available models using \`${binaryName} models\`...`,
|
||||
abortSignal,
|
||||
});
|
||||
if (!modelListResult.ok) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -122,12 +138,14 @@ export async function openMultiModel(
|
||||
};
|
||||
}
|
||||
|
||||
const sessionExists = await runCommand([
|
||||
"tmux",
|
||||
"has-session",
|
||||
"-t",
|
||||
sessionName,
|
||||
]);
|
||||
const sessionExists = await runCommand(
|
||||
["tmux", "has-session", "-t", sessionName],
|
||||
{
|
||||
mode,
|
||||
spinnerMsg: `Checking if tmux session '${sessionName}' already exists...`,
|
||||
abortSignal,
|
||||
},
|
||||
);
|
||||
if (sessionExists.ok) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -162,13 +180,14 @@ export async function openMultiModel(
|
||||
}
|
||||
}
|
||||
|
||||
const branchExists = await runCommand([
|
||||
"git",
|
||||
"show-ref",
|
||||
"--verify",
|
||||
"--quiet",
|
||||
`refs/heads/${branchName}`,
|
||||
]);
|
||||
const branchExists = await runCommand(
|
||||
["git", "show-ref", "--verify", "--quiet", `refs/heads/${branchName}`],
|
||||
{
|
||||
mode,
|
||||
spinnerMsg: `Checking if branch '${branchName}' already exists...`,
|
||||
abortSignal,
|
||||
},
|
||||
);
|
||||
if (branchExists.ok) {
|
||||
if (!isFirst) {
|
||||
failedModels.push(
|
||||
@@ -184,14 +203,14 @@ export async function openMultiModel(
|
||||
}
|
||||
}
|
||||
|
||||
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}...`,
|
||||
abortSignal,
|
||||
},
|
||||
);
|
||||
if (!worktreeResult.ok) {
|
||||
if (!isFirst) {
|
||||
failedModels.push(
|
||||
@@ -208,17 +227,24 @@ export async function openMultiModel(
|
||||
}
|
||||
|
||||
if (isFirst) {
|
||||
const sessionCreateResult = await runCommand([
|
||||
"tmux",
|
||||
"new-session",
|
||||
"-d",
|
||||
"-s",
|
||||
sessionName,
|
||||
"-n",
|
||||
plan.windowName,
|
||||
"-c",
|
||||
worktreePath,
|
||||
]);
|
||||
const sessionCreateResult = await runCommand(
|
||||
[
|
||||
"tmux",
|
||||
"new-session",
|
||||
"-d",
|
||||
"-s",
|
||||
sessionName,
|
||||
"-n",
|
||||
plan.windowName,
|
||||
"-c",
|
||||
worktreePath,
|
||||
],
|
||||
{
|
||||
mode,
|
||||
spinnerMsg: `Starting tmux session '${sessionName}'...`,
|
||||
abortSignal,
|
||||
},
|
||||
);
|
||||
|
||||
if (!sessionCreateResult.ok) {
|
||||
const undoErrors = await undoWorktree(worktreePath, branchName);
|
||||
@@ -229,17 +255,24 @@ export async function openMultiModel(
|
||||
return { success: false, sessionName, error: errorMsg };
|
||||
}
|
||||
} else {
|
||||
const windowCreateResult = await runCommand([
|
||||
"tmux",
|
||||
"new-window",
|
||||
"-d",
|
||||
"-t",
|
||||
sessionName,
|
||||
"-n",
|
||||
plan.windowName,
|
||||
"-c",
|
||||
worktreePath,
|
||||
]);
|
||||
const windowCreateResult = await runCommand(
|
||||
[
|
||||
"tmux",
|
||||
"new-window",
|
||||
"-d",
|
||||
"-t",
|
||||
sessionName,
|
||||
"-n",
|
||||
plan.windowName,
|
||||
"-c",
|
||||
worktreePath,
|
||||
],
|
||||
{
|
||||
mode,
|
||||
spinnerMsg: `Creating tmux window '${plan.windowName}'...`,
|
||||
abortSignal,
|
||||
},
|
||||
);
|
||||
|
||||
if (!windowCreateResult.ok) {
|
||||
failedModels.push(
|
||||
@@ -259,6 +292,7 @@ export async function openMultiModel(
|
||||
sessionName,
|
||||
plan,
|
||||
binaryName,
|
||||
mode,
|
||||
);
|
||||
if (launchResult.ok) {
|
||||
succeededModels.push(plan.model);
|
||||
@@ -307,6 +341,10 @@ export async function openMultiModel(
|
||||
};
|
||||
}
|
||||
|
||||
if (options.saveToSettings) {
|
||||
await saveSettings({ lastModels: models });
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
sessionName,
|
||||
|
||||
+96
-10
@@ -1,6 +1,17 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import type { CommandResult, WindowLaunchPlan, WorktreeInfo } from "../types";
|
||||
import * as readline from "node:readline";
|
||||
import ora, { type Ora } from "ora";
|
||||
import type {
|
||||
CommandResult,
|
||||
MultiModelSettings,
|
||||
RunCommandOptions,
|
||||
WindowLaunchPlan,
|
||||
WorktreeInfo,
|
||||
} from "../types";
|
||||
|
||||
let sharedSpinner: Ora | undefined;
|
||||
|
||||
const MAX_SUGGESTIONS = 3;
|
||||
const WINDOW_NAME_LIMIT = 24;
|
||||
@@ -9,6 +20,7 @@ const WINDOW_NAME_LIMIT = 24;
|
||||
* Runs a command and captures its output without throwing on non-zero exit codes.
|
||||
*
|
||||
* @param parts Command segments to pass to the shell.
|
||||
* @param options Additional options for running the command (e.g., mode, spinnerMsg).
|
||||
* @returns The exit status plus captured stdout and stderr.
|
||||
*
|
||||
* @example
|
||||
@@ -19,9 +31,33 @@ 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]}...`;
|
||||
if (!sharedSpinner) {
|
||||
sharedSpinner = ora(msg).start();
|
||||
} else {
|
||||
sharedSpinner.text = msg;
|
||||
if (!sharedSpinner.isSpinning) {
|
||||
sharedSpinner.start();
|
||||
}
|
||||
}
|
||||
spinner = sharedSpinner;
|
||||
}
|
||||
|
||||
options?.abortSignal?.throwIfAborted();
|
||||
const result = await Bun.$`${parts}`.quiet().nothrow();
|
||||
|
||||
if (spinner) {
|
||||
spinner.stop().clear();
|
||||
}
|
||||
|
||||
return {
|
||||
ok: result.exitCode === 0,
|
||||
stdout: result.stdout.toString().trim(),
|
||||
@@ -274,17 +310,21 @@ export async function launchModelInWindow(
|
||||
sessionName: string,
|
||||
plan: WindowLaunchPlan,
|
||||
binaryName: string = "opencode",
|
||||
mode?: "cli" | "tool",
|
||||
): Promise<CommandResult> {
|
||||
const launchCommand = `${binaryName} --model ${shellQuote(plan.model)}`;
|
||||
|
||||
return runCommand([
|
||||
"tmux",
|
||||
"send-keys",
|
||||
"-t",
|
||||
`${sessionName}:${plan.windowName}`,
|
||||
launchCommand,
|
||||
"C-m",
|
||||
]);
|
||||
return runCommand(
|
||||
[
|
||||
"tmux",
|
||||
"send-keys",
|
||||
"-t",
|
||||
`${sessionName}:${plan.windowName}`,
|
||||
launchCommand,
|
||||
"C-m",
|
||||
],
|
||||
{ mode, spinnerMsg: `Launching model ${plan.model}...` },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -453,3 +493,49 @@ export async function undoWorktree(
|
||||
export function getBinaryName(): string {
|
||||
return process.env.OPENCODE_MULTI_MODEL_BINARY || "opencode";
|
||||
}
|
||||
|
||||
export function getSettingsPath(): string {
|
||||
const homedir = os.homedir();
|
||||
return path.join(homedir, ".config", "opencode-multi-model", "settings.json");
|
||||
}
|
||||
|
||||
export async function loadSettings(): Promise<MultiModelSettings> {
|
||||
const settingsPath = getSettingsPath();
|
||||
try {
|
||||
const content = await fs.promises.readFile(settingsPath, "utf-8");
|
||||
const parsed = JSON.parse(content) as MultiModelSettings;
|
||||
if (!Array.isArray(parsed.lastModels)) {
|
||||
return { lastModels: [] };
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return { lastModels: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveSettings(
|
||||
settings: MultiModelSettings,
|
||||
): Promise<void> {
|
||||
const settingsPath = getSettingsPath();
|
||||
const dir = path.dirname(settingsPath);
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
settingsPath,
|
||||
JSON.stringify(settings, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
|
||||
export 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -30,6 +30,3 @@ const OpenCodeMultiModelPlugin: Plugin = async (_ctx) => {
|
||||
};
|
||||
|
||||
export default OpenCodeMultiModelPlugin;
|
||||
export * from "./core/index";
|
||||
export * from "./types";
|
||||
export { cleanupTool, closeTool, openTool };
|
||||
|
||||
+14
-14
@@ -8,30 +8,30 @@ import { cleanupMultiModel } from "../core/cleanup";
|
||||
* 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",
|
||||
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
|
||||
.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."),
|
||||
])
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Remote handling: string=specific remote, false=local-only, undefined=first available remote",
|
||||
"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.remote,
|
||||
remote: args.noRemote ? false : args.remote,
|
||||
mode: "tool",
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
|
||||
+7
-2
@@ -8,8 +8,12 @@ import { closeMultiModel } from "../core/close";
|
||||
* creates archive tags, and deletes associated branches.
|
||||
*/
|
||||
export const closeTool = tool({
|
||||
description:
|
||||
"Close a multi-model tmux session and optionally cleanup worktrees and branches",
|
||||
description: `Close a multi-model tmux session and optionally clean up worktrees and branches.
|
||||
|
||||
Arguments:
|
||||
- \`sessionName\` (string, required): tmux session name to close.
|
||||
- \`cleanupWorktrees\` (boolean, optional, default: true): Whether to remove worktrees and delete branches.
|
||||
- \`createArchiveTags\` (boolean, optional, default: true): Whether to create archive tags before deleting branches.`,
|
||||
args: {
|
||||
sessionName: tool.schema
|
||||
.string()
|
||||
@@ -35,6 +39,7 @@ export const closeTool = tool({
|
||||
cleanupWorktrees: args.cleanupWorktrees,
|
||||
createArchiveTags: args.createArchiveTags,
|
||||
force: true,
|
||||
mode: "tool",
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
|
||||
+6
-9
@@ -9,7 +9,11 @@ import { getBinaryName } from "../core/utils";
|
||||
* Returns instructions for attaching to and cleaning up the session.
|
||||
*/
|
||||
export const openTool = tool({
|
||||
description: "Launch multiple OpenCode models in tmux",
|
||||
description: `Launch multiple OpenCode models in tmux.
|
||||
|
||||
Arguments:
|
||||
- \`sessionName\` (string, required): tmux session name to create.
|
||||
- \`models\` (array of strings, required): one or more OpenCode model IDs to launch.`,
|
||||
args: {
|
||||
sessionName: tool.schema
|
||||
.string()
|
||||
@@ -28,14 +32,7 @@ export const openTool = tool({
|
||||
models: args.models,
|
||||
binaryName,
|
||||
mode: "tool",
|
||||
});
|
||||
|
||||
context.metadata({
|
||||
title: `multi-model ${args.sessionName}`,
|
||||
metadata: {
|
||||
safeSessionName: result.sessionName,
|
||||
modelCount: args.models.length,
|
||||
},
|
||||
abortSignal: context.abort,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
|
||||
+34
-3
@@ -1,7 +1,14 @@
|
||||
/**
|
||||
* Settings stored in ~/.config/opencode-multi-model/settings.json
|
||||
*/
|
||||
export interface MultiModelSettings {
|
||||
lastModels: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for launching a multi-model tmux session.
|
||||
*/
|
||||
export interface MultiModelOptions {
|
||||
export interface OpenSessionOptions {
|
||||
/** Name for the tmux session. */
|
||||
sessionName: string;
|
||||
/** List of model ids to launch. */
|
||||
@@ -9,13 +16,17 @@ export interface MultiModelOptions {
|
||||
/** Binary name to use ('opencode' or 'kilo'). Defaults to 'opencode'. */
|
||||
binaryName?: string;
|
||||
/** Caller context: 'cli' shows shell commands, 'tool' shows tool instructions. */
|
||||
mode?: "cli" | "tool";
|
||||
mode: "cli" | "tool";
|
||||
/** Save models to settings file on successful launch (CLI only). */
|
||||
saveToSettings?: boolean;
|
||||
/** Signal to abort the operation. */
|
||||
abortSignal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result returned after attempting to launch a multi-model session.
|
||||
*/
|
||||
export interface MultiModelResult {
|
||||
export interface OpenSessionResult {
|
||||
/** Whether the launch succeeded. */
|
||||
success: boolean;
|
||||
/** The session name used. */
|
||||
@@ -40,6 +51,10 @@ export interface CloseSessionOptions {
|
||||
cleanupWorktrees?: boolean;
|
||||
/** Skip confirmation prompts. */
|
||||
force?: boolean;
|
||||
/** Caller context: 'cli' shows shell commands, 'tool' shows tool instructions. */
|
||||
mode: "cli" | "tool";
|
||||
/** Signal to abort the operation. */
|
||||
abortSignal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,6 +102,10 @@ export interface CleanupOptions {
|
||||
* - If false: only local cleanup (--no-remote flag)
|
||||
*/
|
||||
remote?: string | false;
|
||||
/** Caller context: 'cli' shows shell commands, 'tool' shows tool instructions. */
|
||||
mode: "cli" | "tool";
|
||||
/** Optional abort signal to cancel the operation. */
|
||||
abortSignal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,6 +144,18 @@ export interface CommandResult {
|
||||
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;
|
||||
/** Signal to abort the operation. Check it before running the command. */
|
||||
abortSignal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Information about a git worktree.
|
||||
*/
|
||||
|
||||
+19
-9
@@ -70,7 +70,7 @@ describe("cleanupMultiModel", () => {
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
const result = await cleanupMultiModel({ force: true });
|
||||
const result = await cleanupMultiModel({ mode: "cli", force: true });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.localTagsDeleted).toBe(0);
|
||||
@@ -96,7 +96,7 @@ describe("cleanupMultiModel", () => {
|
||||
} as any;
|
||||
spyOn(readline, "createInterface").mockImplementation(() => mockInterface);
|
||||
|
||||
const result = await cleanupMultiModel({ force: false });
|
||||
const result = await cleanupMultiModel({ mode: "cli", force: false });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("Cleanup cancelled by user");
|
||||
@@ -126,7 +126,7 @@ describe("cleanupMultiModel", () => {
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
const result = await cleanupMultiModel({ force: true });
|
||||
const result = await cleanupMultiModel({ mode: "cli", force: true });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.localTagsDeleted).toBe(1);
|
||||
@@ -150,7 +150,11 @@ describe("cleanupMultiModel", () => {
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
const result = await cleanupMultiModel({ force: true, remote: false });
|
||||
const result = await cleanupMultiModel({
|
||||
mode: "cli",
|
||||
force: true,
|
||||
remote: false,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.localTagsDeleted).toBe(1);
|
||||
@@ -191,7 +195,7 @@ describe("cleanupMultiModel", () => {
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
const result = await cleanupMultiModel({ force: true });
|
||||
const result = await cleanupMultiModel({ mode: "cli", force: true });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.localTagsDeleted).toBe(2);
|
||||
@@ -222,7 +226,11 @@ describe("cleanupMultiModel", () => {
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
const result = await cleanupMultiModel({ force: true, remote: undefined });
|
||||
const result = await cleanupMultiModel({
|
||||
mode: "cli",
|
||||
force: true,
|
||||
remote: undefined,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(executedCommands).toContain(
|
||||
@@ -253,6 +261,7 @@ describe("cleanupMultiModel", () => {
|
||||
};
|
||||
|
||||
const result = await cleanupMultiModel({
|
||||
mode: "cli",
|
||||
force: true,
|
||||
remote: "upstream",
|
||||
});
|
||||
@@ -277,6 +286,7 @@ describe("cleanupMultiModel", () => {
|
||||
};
|
||||
|
||||
const result = await cleanupMultiModel({
|
||||
mode: "cli",
|
||||
force: true,
|
||||
remote: "nonexistent",
|
||||
});
|
||||
@@ -303,7 +313,7 @@ describe("cleanupMultiModel", () => {
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
const result = await cleanupMultiModel({ force: true });
|
||||
const result = await cleanupMultiModel({ mode: "cli", force: true });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.availableRemotes).toEqual([]);
|
||||
@@ -339,7 +349,7 @@ describe("cleanupMultiModel", () => {
|
||||
} as any;
|
||||
spyOn(readline, "createInterface").mockImplementation(() => mockInterface);
|
||||
|
||||
const result = await cleanupMultiModel({ force: false });
|
||||
const result = await cleanupMultiModel({ mode: "cli", force: false });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.localTagsDeleted).toBe(1);
|
||||
@@ -392,7 +402,7 @@ describe("cleanupMultiModel", () => {
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
const result = await cleanupMultiModel({ force: true });
|
||||
const result = await cleanupMultiModel({ mode: "cli", force: true });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.localTagsDeleted).toBe(3);
|
||||
|
||||
@@ -79,6 +79,7 @@ describe("closeMultiModel", () => {
|
||||
};
|
||||
|
||||
const result = await closeMultiModel({
|
||||
mode: "cli",
|
||||
sessionName: "test-session",
|
||||
cleanupWorktrees: false,
|
||||
force: false,
|
||||
@@ -103,6 +104,7 @@ describe("closeMultiModel", () => {
|
||||
};
|
||||
|
||||
const result = await closeMultiModel({
|
||||
mode: "cli",
|
||||
sessionName: "test-session",
|
||||
cleanupWorktrees: false,
|
||||
force: false,
|
||||
@@ -161,6 +163,7 @@ describe("closeMultiModel", () => {
|
||||
} as any;
|
||||
|
||||
const result = await closeMultiModel({
|
||||
mode: "cli",
|
||||
sessionName: "test-session",
|
||||
cleanupWorktrees: true,
|
||||
force: true,
|
||||
@@ -206,6 +209,7 @@ describe("closeMultiModel", () => {
|
||||
spyOn(readline, "createInterface").mockImplementation(() => mockInterface);
|
||||
|
||||
const result = await closeMultiModel({
|
||||
mode: "cli",
|
||||
sessionName: "test-session",
|
||||
cleanupWorktrees: true,
|
||||
force: false,
|
||||
@@ -251,6 +255,7 @@ describe("closeMultiModel", () => {
|
||||
} as any;
|
||||
|
||||
const result = await closeMultiModel({
|
||||
mode: "cli",
|
||||
sessionName: "test-session",
|
||||
cleanupWorktrees: true,
|
||||
force: false,
|
||||
@@ -322,6 +327,7 @@ describe("closeMultiModel", () => {
|
||||
};
|
||||
|
||||
const result = await closeMultiModel({
|
||||
mode: "cli",
|
||||
sessionName: "test-session",
|
||||
cleanupWorktrees: true,
|
||||
force: true,
|
||||
@@ -380,6 +386,7 @@ describe("closeMultiModel", () => {
|
||||
} as any;
|
||||
|
||||
const result = await closeMultiModel({
|
||||
mode: "cli",
|
||||
sessionName: unsanitized,
|
||||
cleanupWorktrees: true,
|
||||
force: true,
|
||||
@@ -422,6 +429,7 @@ describe("closeMultiModel", () => {
|
||||
};
|
||||
|
||||
const result = await closeMultiModel({
|
||||
mode: "cli",
|
||||
sessionName: unsanitized,
|
||||
cleanupWorktrees: false,
|
||||
force: false,
|
||||
@@ -465,6 +473,7 @@ describe("closeMultiModel", () => {
|
||||
] = { ok: true, stdout: "", stderr: "" };
|
||||
|
||||
const result = await closeMultiModel({
|
||||
mode: "cli",
|
||||
sessionName: "test-session",
|
||||
cleanupWorktrees: true,
|
||||
force: true,
|
||||
|
||||
@@ -62,6 +62,7 @@ describe("closeMultiModel unexpected error handling", () => {
|
||||
|
||||
test("catches thrown error and returns failure", async () => {
|
||||
const result = await closeMultiModel({
|
||||
mode: "cli",
|
||||
sessionName: "any-session",
|
||||
cleanupWorktrees: false,
|
||||
force: false,
|
||||
|
||||
@@ -94,6 +94,7 @@ describe("multi-model launch", () => {
|
||||
|
||||
test("fails if session name is empty", async () => {
|
||||
const result = await openMultiModel({
|
||||
mode: "cli",
|
||||
sessionName: "",
|
||||
models: ["openai/gpt-5.2"],
|
||||
});
|
||||
@@ -105,6 +106,7 @@ describe("multi-model launch", () => {
|
||||
|
||||
test("fails if no models are provided", async () => {
|
||||
const result = await openMultiModel({
|
||||
mode: "cli",
|
||||
sessionName: "test-session",
|
||||
models: [],
|
||||
});
|
||||
@@ -116,6 +118,7 @@ describe("multi-model launch", () => {
|
||||
|
||||
test("fails if duplicate models are provided", async () => {
|
||||
const result = await openMultiModel({
|
||||
mode: "cli",
|
||||
sessionName: "test-session",
|
||||
models: ["openai/gpt-5.2", "openai/gpt-5.2"],
|
||||
});
|
||||
@@ -133,6 +136,7 @@ describe("multi-model launch", () => {
|
||||
};
|
||||
|
||||
const result = await openMultiModel({
|
||||
mode: "cli",
|
||||
sessionName: "test-session",
|
||||
models: ["openai/gpt-5.2"],
|
||||
});
|
||||
@@ -152,6 +156,7 @@ describe("multi-model launch", () => {
|
||||
};
|
||||
|
||||
const result = await openMultiModel({
|
||||
mode: "cli",
|
||||
sessionName: "test-session",
|
||||
models: ["openai/gpt-5.2"],
|
||||
});
|
||||
@@ -174,6 +179,7 @@ describe("multi-model launch", () => {
|
||||
};
|
||||
|
||||
const result = await openMultiModel({
|
||||
mode: "cli",
|
||||
sessionName: "test-session",
|
||||
models: ["openai/gpt-5.2"],
|
||||
});
|
||||
@@ -191,6 +197,7 @@ describe("multi-model launch", () => {
|
||||
|
||||
test("fails if model name is not in allowlist", async () => {
|
||||
const result = await openMultiModel({
|
||||
mode: "cli",
|
||||
sessionName: "test-session",
|
||||
models: ["invalid/model"],
|
||||
});
|
||||
@@ -215,6 +222,7 @@ describe("multi-model launch", () => {
|
||||
};
|
||||
|
||||
const result = await openMultiModel({
|
||||
mode: "cli",
|
||||
sessionName: "test-session",
|
||||
models: ["openai/gpt-5.2"],
|
||||
});
|
||||
@@ -232,6 +240,7 @@ describe("multi-model launch", () => {
|
||||
|
||||
test("successfully plans and executes tmux launch with single model", async () => {
|
||||
const result = await openMultiModel({
|
||||
mode: "cli",
|
||||
sessionName: "test-session",
|
||||
models: ["openai/gpt-5.2"],
|
||||
});
|
||||
@@ -258,6 +267,7 @@ describe("multi-model launch", () => {
|
||||
] = { ok: false, stdout: "", stderr: "" };
|
||||
|
||||
const result = await openMultiModel({
|
||||
mode: "cli",
|
||||
sessionName: "test-session",
|
||||
models: ["openai/gpt-5.2", "anthropic/claude-3-5-sonnet"],
|
||||
});
|
||||
@@ -289,6 +299,7 @@ describe("multi-model launch", () => {
|
||||
] = { ok: false, stdout: "", stderr: "" };
|
||||
|
||||
const result = await openMultiModel({
|
||||
mode: "cli",
|
||||
sessionName: "test session!",
|
||||
models: ["openai/gpt-5.2"],
|
||||
});
|
||||
@@ -326,6 +337,7 @@ describe("multi-model launch", () => {
|
||||
] = { ok: true, stdout: "", stderr: "" };
|
||||
|
||||
const result = await openMultiModel({
|
||||
mode: "cli",
|
||||
sessionName: unsanitized,
|
||||
models: ["openai/gpt-5.2"],
|
||||
});
|
||||
@@ -373,6 +385,7 @@ describe("multi-model launch", () => {
|
||||
};
|
||||
|
||||
const result = await openMultiModel({
|
||||
mode: "cli",
|
||||
sessionName: "test-session",
|
||||
models: ["openai/gpt-5.2"],
|
||||
});
|
||||
@@ -399,6 +412,7 @@ describe("multi-model launch", () => {
|
||||
});
|
||||
|
||||
const result = await openMultiModel({
|
||||
mode: "cli",
|
||||
sessionName: "test-session",
|
||||
models: ["openai/gpt-5.2"],
|
||||
});
|
||||
@@ -420,6 +434,7 @@ describe("multi-model launch", () => {
|
||||
] = { ok: true, stdout: "", stderr: "" };
|
||||
|
||||
const result = await openMultiModel({
|
||||
mode: "cli",
|
||||
sessionName: "test-session",
|
||||
models: ["openai/gpt-5.2"],
|
||||
});
|
||||
@@ -444,6 +459,7 @@ describe("multi-model launch", () => {
|
||||
] = { ok: false, stdout: "", stderr: "git error" };
|
||||
|
||||
const result = await openMultiModel({
|
||||
mode: "cli",
|
||||
sessionName: "test-session",
|
||||
models: ["openai/gpt-5.2"],
|
||||
});
|
||||
@@ -475,6 +491,7 @@ describe("multi-model launch", () => {
|
||||
] = { ok: false, stdout: "", stderr: "" };
|
||||
|
||||
const result = await openMultiModel({
|
||||
mode: "cli",
|
||||
sessionName: "test-session",
|
||||
models: ["openai/gpt-5.2", "anthropic/gpt-5.2"],
|
||||
});
|
||||
@@ -507,6 +524,7 @@ describe("multi-model launch", () => {
|
||||
] = { ok: false, stdout: "", stderr: "" };
|
||||
|
||||
const result = await openMultiModel({
|
||||
mode: "cli",
|
||||
sessionName: "test-session",
|
||||
models: [longModel],
|
||||
});
|
||||
@@ -520,6 +538,34 @@ describe("multi-model launch", () => {
|
||||
?.match(/-n (\S+)/)?.[1];
|
||||
expect(windowName?.length).toBeLessThanOrEqual(24);
|
||||
});
|
||||
|
||||
test("saves settings on successful launch when saveToSettings is true", async () => {
|
||||
spyOn(fs.promises, "mkdir").mockResolvedValue(undefined);
|
||||
spyOn(fs.promises, "writeFile").mockResolvedValue(undefined);
|
||||
|
||||
const result = await openMultiModel({
|
||||
mode: "cli",
|
||||
sessionName: "test-session",
|
||||
models: ["openai/gpt-5.2"],
|
||||
saveToSettings: true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("does not save settings when saveToSettings is false", async () => {
|
||||
spyOn(fs.promises, "mkdir").mockResolvedValue(undefined);
|
||||
spyOn(fs.promises, "writeFile").mockResolvedValue(undefined);
|
||||
|
||||
const result = await openMultiModel({
|
||||
mode: "cli",
|
||||
sessionName: "test-session",
|
||||
models: ["openai/gpt-5.2"],
|
||||
saveToSettings: false,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ----------
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
spyOn,
|
||||
test,
|
||||
} from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import {
|
||||
createWindowBaseName,
|
||||
@@ -15,13 +16,16 @@ import {
|
||||
formatInvalidModelError,
|
||||
getBinaryName,
|
||||
getSessionPath,
|
||||
getSettingsPath,
|
||||
getWorktreePath,
|
||||
getWorktreesForSession,
|
||||
launchModelInWindow,
|
||||
levenshtein,
|
||||
loadSettings,
|
||||
normalizeModels,
|
||||
runCommand,
|
||||
sanitizeName,
|
||||
saveSettings,
|
||||
shellQuote,
|
||||
suggestModels,
|
||||
undoWorktree,
|
||||
@@ -484,4 +488,86 @@ describe("core utils", () => {
|
||||
delete process.env.OPENCODE_MULTI_MODEL_BINARY;
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSettingsPath", () => {
|
||||
test("returns correct settings path", () => {
|
||||
spyOn(os, "homedir").mockReturnValue("/home/user");
|
||||
expect(getSettingsPath()).toBe(
|
||||
"/home/user/.config/opencode-multi-model/settings.json",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadSettings", () => {
|
||||
let readFileMock: ReturnType<typeof spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
spyOn(os, "homedir").mockReturnValue("/home/user");
|
||||
readFileMock = spyOn(fs.promises, "readFile");
|
||||
});
|
||||
|
||||
test("returns default settings when file does not exist", async () => {
|
||||
readFileMock.mockRejectedValue(new Error("ENOENT"));
|
||||
const result = await loadSettings();
|
||||
expect(result).toEqual({ lastModels: [] });
|
||||
});
|
||||
|
||||
test("returns default settings when file has invalid JSON", async () => {
|
||||
readFileMock.mockResolvedValue("not valid json");
|
||||
const result = await loadSettings();
|
||||
expect(result).toEqual({ lastModels: [] });
|
||||
});
|
||||
|
||||
test("returns default settings when lastModels is not an array", async () => {
|
||||
readFileMock.mockResolvedValue(
|
||||
JSON.stringify({ lastModels: "not array" }),
|
||||
);
|
||||
const result = await loadSettings();
|
||||
expect(result).toEqual({ lastModels: [] });
|
||||
});
|
||||
|
||||
test("parses valid settings file", async () => {
|
||||
readFileMock.mockResolvedValue(
|
||||
JSON.stringify({ lastModels: ["model1", "model2"] }),
|
||||
);
|
||||
const result = await loadSettings();
|
||||
expect(result).toEqual({ lastModels: ["model1", "model2"] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveSettings", () => {
|
||||
let mkdirMock: ReturnType<typeof spyOn>;
|
||||
let writeFileMock: ReturnType<typeof spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
spyOn(os, "homedir").mockReturnValue("/home/user");
|
||||
mkdirMock = spyOn(fs.promises, "mkdir");
|
||||
writeFileMock = spyOn(fs.promises, "writeFile");
|
||||
});
|
||||
|
||||
test("creates directory if it does not exist", async () => {
|
||||
mkdirMock.mockResolvedValue(undefined);
|
||||
writeFileMock.mockResolvedValue(undefined);
|
||||
|
||||
await saveSettings({ lastModels: ["model1"] });
|
||||
|
||||
expect(mkdirMock).toHaveBeenCalledWith(
|
||||
"/home/user/.config/opencode-multi-model",
|
||||
{ recursive: true },
|
||||
);
|
||||
});
|
||||
|
||||
test("writes settings with correct content", async () => {
|
||||
mkdirMock.mockResolvedValue(undefined);
|
||||
writeFileMock.mockResolvedValue(undefined);
|
||||
|
||||
await saveSettings({ lastModels: ["model1", "model2"] });
|
||||
|
||||
expect(writeFileMock).toHaveBeenCalledWith(
|
||||
"/home/user/.config/opencode-multi-model/settings.json",
|
||||
JSON.stringify({ lastModels: ["model1", "model2"] }, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+3
-1
@@ -12,7 +12,9 @@
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"types": ["bun-types"]
|
||||
"types": ["bun-types"],
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src/**/*", "tests/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
|
||||
Reference in New Issue
Block a user