mirror of
https://github.com/bendtherules/opencode-multi-model.git
synced 2026-08-18 13:42:21 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc1efdbd63 | ||
|
|
77e5470d04 | ||
|
|
ac31d69e3a | ||
|
|
651c2ae9a9 | ||
|
|
0a509d2bc3 | ||
|
|
d8a2e11d29 | ||
|
|
2cc01f5faa | ||
|
|
b3a9ccc848 | ||
|
|
2558d44ea2 | ||
|
|
af5d888f56 | ||
|
|
fabd2f6519 |
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "opencode-multi-model",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.2",
|
||||
"description": "Launch multiple AI models in tmux sessions - OpenCode Plugin, CLI, and Project Tool",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
+30
-3
@@ -1,10 +1,11 @@
|
||||
#!/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();
|
||||
|
||||
@@ -26,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) {
|
||||
|
||||
+35
-27
@@ -1,37 +1,36 @@
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -41,10 +40,12 @@ export async function getAvailableRemotes(): Promise<string[]> {
|
||||
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;
|
||||
}
|
||||
@@ -56,12 +57,14 @@ 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],
|
||||
{
|
||||
mode,
|
||||
spinnerMsg: `Deleting remote tag ${tagName} from ${remoteName}...`,
|
||||
abortSignal,
|
||||
},
|
||||
);
|
||||
return {
|
||||
@@ -82,11 +85,11 @@ async function deleteRemoteTag(
|
||||
export async function cleanupMultiModel(
|
||||
options: CleanupOptions,
|
||||
): Promise<CleanupResult> {
|
||||
const { force = false, remote, mode } = 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
|
||||
@@ -172,7 +175,7 @@ export async function cleanupMultiModel(
|
||||
|
||||
// Delete local tags
|
||||
for (const tag of archiveTags) {
|
||||
const deleted = await deleteLocalTag(tag, mode);
|
||||
const deleted = await deleteLocalTag(tag, mode, abortSignal);
|
||||
if (deleted) {
|
||||
localTagNames.push(tag);
|
||||
}
|
||||
@@ -181,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, mode);
|
||||
const result = await deleteRemoteTag(
|
||||
tag,
|
||||
remoteToDeleteFrom,
|
||||
mode,
|
||||
abortSignal,
|
||||
);
|
||||
if (result.ok) {
|
||||
remoteTagNames.push(tag);
|
||||
} else {
|
||||
|
||||
+22
-30
@@ -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,21 +36,27 @@ export async function closeMultiModel(
|
||||
const instructionsArr: string[] = [];
|
||||
const branchesDeleted: string[] = [];
|
||||
const tagsCreated: string[] = [];
|
||||
const mode = options.mode;
|
||||
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}'`),
|
||||
);
|
||||
@@ -116,6 +102,7 @@ export async function closeMultiModel(
|
||||
await runCommand(["git", "worktree", "remove", "-f", worktree.path], {
|
||||
mode,
|
||||
spinnerMsg: `Removing worktree ${worktree.path}...`,
|
||||
abortSignal,
|
||||
});
|
||||
worktreesRemoved.push(worktree.path);
|
||||
|
||||
@@ -141,6 +128,7 @@ export async function closeMultiModel(
|
||||
await runCommand(["git", "branch", "-D", worktree.branch], {
|
||||
mode,
|
||||
spinnerMsg: `Deleting branch ${worktree.branch}...`,
|
||||
abortSignal,
|
||||
});
|
||||
branchesDeleted.push(worktree.branch);
|
||||
} catch (err) {
|
||||
@@ -153,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
|
||||
}
|
||||
|
||||
+59
-27
@@ -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;
|
||||
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,
|
||||
@@ -102,6 +114,7 @@ export async function openMultiModel(
|
||||
const modelListResult = await runCommand([binaryName, "models"], {
|
||||
mode,
|
||||
spinnerMsg: `Fetching available models using \`${binaryName} models\`...`,
|
||||
abortSignal,
|
||||
});
|
||||
if (!modelListResult.ok) {
|
||||
return {
|
||||
@@ -125,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,
|
||||
@@ -165,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(
|
||||
@@ -189,7 +205,11 @@ export async function openMultiModel(
|
||||
|
||||
const worktreeResult = await runCommand(
|
||||
["git", "worktree", "add", "-b", branchName, worktreePath],
|
||||
{ mode, spinnerMsg: `Creating worktree for ${plan.model}...` },
|
||||
{
|
||||
mode,
|
||||
spinnerMsg: `Creating worktree for ${plan.model}...`,
|
||||
abortSignal,
|
||||
},
|
||||
);
|
||||
if (!worktreeResult.ok) {
|
||||
if (!isFirst) {
|
||||
@@ -219,7 +239,11 @@ export async function openMultiModel(
|
||||
"-c",
|
||||
worktreePath,
|
||||
],
|
||||
{ mode, spinnerMsg: `Starting tmux session '${sessionName}'...` },
|
||||
{
|
||||
mode,
|
||||
spinnerMsg: `Starting tmux session '${sessionName}'...`,
|
||||
abortSignal,
|
||||
},
|
||||
);
|
||||
|
||||
if (!sessionCreateResult.ok) {
|
||||
@@ -243,7 +267,11 @@ export async function openMultiModel(
|
||||
"-c",
|
||||
worktreePath,
|
||||
],
|
||||
{ mode, spinnerMsg: `Creating tmux window '${plan.windowName}'...` },
|
||||
{
|
||||
mode,
|
||||
spinnerMsg: `Creating tmux window '${plan.windowName}'...`,
|
||||
abortSignal,
|
||||
},
|
||||
);
|
||||
|
||||
if (!windowCreateResult.ok) {
|
||||
@@ -313,6 +341,10 @@ export async function openMultiModel(
|
||||
};
|
||||
}
|
||||
|
||||
if (options.saveToSettings) {
|
||||
await saveSettings({ lastModels: models });
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
sessionName,
|
||||
|
||||
+62
-6
@@ -1,13 +1,18 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
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;
|
||||
|
||||
@@ -35,17 +40,22 @@ export async function runCommand(
|
||||
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();
|
||||
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) {
|
||||
if (result.exitCode === 0) {
|
||||
spinner.succeed();
|
||||
} else {
|
||||
spinner.fail();
|
||||
}
|
||||
spinner.stop().clear();
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -483,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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+1
-8
@@ -32,14 +32,7 @@ Arguments:
|
||||
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) {
|
||||
|
||||
+19
-2
@@ -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. */
|
||||
@@ -10,12 +17,16 @@ export interface MultiModelOptions {
|
||||
binaryName?: string;
|
||||
/** Caller context: 'cli' shows shell commands, 'tool' shows tool instructions. */
|
||||
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. */
|
||||
@@ -42,6 +53,8 @@ export interface CloseSessionOptions {
|
||||
force?: boolean;
|
||||
/** Caller context: 'cli' shows shell commands, 'tool' shows tool instructions. */
|
||||
mode: "cli" | "tool";
|
||||
/** Signal to abort the operation. */
|
||||
abortSignal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,6 +104,8 @@ export interface CleanupOptions {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,6 +152,8 @@ export interface RunCommandOptions {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -538,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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user