mirror of
https://github.com/bendtherules/opencode-multi-model.git
synced 2026-08-18 21:52:11 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af5d888f56 | ||
|
|
fabd2f6519 | ||
|
|
932e023fcf |
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "opencode-multi-model",
|
"name": "opencode-multi-model",
|
||||||
"version": "0.1.6",
|
"version": "0.2.0",
|
||||||
"description": "Launch multiple AI models in tmux sessions - OpenCode Plugin, CLI, and Project Tool",
|
"description": "Launch multiple AI models in tmux sessions - OpenCode Plugin, CLI, and Project Tool",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
|||||||
+24
-3
@@ -4,7 +4,7 @@ import pkg from "../package.json";
|
|||||||
import { cleanupMultiModel } from "./core/cleanup";
|
import { cleanupMultiModel } from "./core/cleanup";
|
||||||
import { closeMultiModel } from "./core/close";
|
import { closeMultiModel } from "./core/close";
|
||||||
import { openMultiModel } from "./core/open";
|
import { openMultiModel } from "./core/open";
|
||||||
import { getBinaryName } from "./core/utils";
|
import { getBinaryName, loadSettings, promptUser } from "./core/utils";
|
||||||
|
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
|
|
||||||
@@ -26,19 +26,40 @@ program
|
|||||||
"-b, --binary <binary>",
|
"-b, --binary <binary>",
|
||||||
"Binary to use (opencode or kilo). Defaults to env var OPENCODE_MULTI_MODEL_BINARY or 'opencode'",
|
"Binary to use (opencode or kilo). Defaults to env var OPENCODE_MULTI_MODEL_BINARY or 'opencode'",
|
||||||
)
|
)
|
||||||
|
.option("-f, --force", "Skip confirmation prompts", false)
|
||||||
.action(
|
.action(
|
||||||
async (
|
async (
|
||||||
sessionName: string,
|
sessionName: string,
|
||||||
options: { models: string[]; binary?: string },
|
options: { models: string[]; binary?: string; force: boolean },
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
|
let models = options.models;
|
||||||
|
|
||||||
|
if (models.length === 0) {
|
||||||
|
const settings = await loadSettings();
|
||||||
|
if (settings.lastModels.length > 0) {
|
||||||
|
if (!options.force) {
|
||||||
|
const answer = await promptUser(
|
||||||
|
`Use models [${models.join(", ")}]? (Y/n): `,
|
||||||
|
);
|
||||||
|
if (answer.toLowerCase() === "y" || answer.toLowerCase() === "yes") {
|
||||||
|
models = settings.lastModels;
|
||||||
|
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
models = settings.lastModels;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const binaryName = options.binary || getBinaryName();
|
const binaryName = options.binary || getBinaryName();
|
||||||
|
|
||||||
const result = await openMultiModel({
|
const result = await openMultiModel({
|
||||||
sessionName,
|
sessionName,
|
||||||
models: options.models,
|
models,
|
||||||
binaryName,
|
binaryName,
|
||||||
mode: "cli",
|
mode: "cli",
|
||||||
|
saveToSettings: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
|||||||
+1
-18
@@ -1,23 +1,6 @@
|
|||||||
import * as readline from "node:readline";
|
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import type { CleanupOptions, CleanupResult } from "../types";
|
import type { CleanupOptions, CleanupResult } from "../types";
|
||||||
import { runCommand } from "./utils";
|
import { promptUser, runCommand } from "./utils";
|
||||||
|
|
||||||
/**
|
|
||||||
* Prompts the user for confirmation on the terminal.
|
|
||||||
*/
|
|
||||||
async function promptUser(question: string): Promise<string> {
|
|
||||||
const rl = readline.createInterface({
|
|
||||||
input: process.stdin,
|
|
||||||
output: process.stdout,
|
|
||||||
});
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
rl.question(question, (answer) => {
|
|
||||||
rl.close();
|
|
||||||
resolve(answer);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lists all archive tags matching the pattern archive/*.
|
* Lists all archive tags matching the pattern archive/*.
|
||||||
|
|||||||
+1
-21
@@ -1,33 +1,13 @@
|
|||||||
import * as readline from "node:readline";
|
|
||||||
import chalk from "chalk"; // removed console usage
|
import chalk from "chalk"; // removed console usage
|
||||||
import type { CloseSessionOptions, CloseSessionResult } from "../types";
|
import type { CloseSessionOptions, CloseSessionResult } from "../types";
|
||||||
import {
|
import {
|
||||||
getSessionPath,
|
getSessionPath,
|
||||||
getWorktreesForSession,
|
getWorktreesForSession,
|
||||||
|
promptUser,
|
||||||
runCommand,
|
runCommand,
|
||||||
sanitizeName,
|
sanitizeName,
|
||||||
} from "./utils";
|
} 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.
|
* Closes a multi-model tmux session and optionally cleans up worktrees and branches.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
normalizeModels,
|
normalizeModels,
|
||||||
runCommand,
|
runCommand,
|
||||||
sanitizeName,
|
sanitizeName,
|
||||||
|
saveSettings,
|
||||||
undoWorktree,
|
undoWorktree,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
|
|
||||||
@@ -313,6 +314,10 @@ export async function openMultiModel(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.saveToSettings) {
|
||||||
|
await saveSettings({ lastModels: models });
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
sessionName,
|
sessionName,
|
||||||
|
|||||||
+61
-6
@@ -1,13 +1,18 @@
|
|||||||
|
import * as fs from "node:fs";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
|
import * as readline from "node:readline";
|
||||||
import ora, { type Ora } from "ora";
|
import ora, { type Ora } from "ora";
|
||||||
import type {
|
import type {
|
||||||
CommandResult,
|
CommandResult,
|
||||||
|
MultiModelSettings,
|
||||||
RunCommandOptions,
|
RunCommandOptions,
|
||||||
WindowLaunchPlan,
|
WindowLaunchPlan,
|
||||||
WorktreeInfo,
|
WorktreeInfo,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
||||||
|
let sharedSpinner: Ora | undefined;
|
||||||
|
|
||||||
const MAX_SUGGESTIONS = 3;
|
const MAX_SUGGESTIONS = 3;
|
||||||
const WINDOW_NAME_LIMIT = 24;
|
const WINDOW_NAME_LIMIT = 24;
|
||||||
|
|
||||||
@@ -35,17 +40,21 @@ export async function runCommand(
|
|||||||
process.env.NODE_ENV === "test" || process.env.BUN_ENV === "test";
|
process.env.NODE_ENV === "test" || process.env.BUN_ENV === "test";
|
||||||
if (options?.mode === "cli" && !isTest) {
|
if (options?.mode === "cli" && !isTest) {
|
||||||
const msg = options.spinnerMsg || `Running ${parts[0]}...`;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await Bun.$`${parts}`.quiet().nothrow();
|
const result = await Bun.$`${parts}`.quiet().nothrow();
|
||||||
|
|
||||||
if (spinner) {
|
if (spinner) {
|
||||||
if (result.exitCode === 0) {
|
spinner.stop().clear();
|
||||||
spinner.succeed();
|
|
||||||
} else {
|
|
||||||
spinner.fail();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -483,3 +492,49 @@ export async function undoWorktree(
|
|||||||
export function getBinaryName(): string {
|
export function getBinaryName(): string {
|
||||||
return process.env.OPENCODE_MULTI_MODEL_BINARY || "opencode";
|
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,3 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Settings stored in ~/.config/opencode-multi-model/settings.json
|
||||||
|
*/
|
||||||
|
export interface MultiModelSettings {
|
||||||
|
lastModels: string[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options for launching a multi-model tmux session.
|
* Options for launching a multi-model tmux session.
|
||||||
*/
|
*/
|
||||||
@@ -10,6 +17,8 @@ export interface MultiModelOptions {
|
|||||||
binaryName?: string;
|
binaryName?: string;
|
||||||
/** Caller context: 'cli' shows shell commands, 'tool' shows tool instructions. */
|
/** Caller context: 'cli' shows shell commands, 'tool' shows tool instructions. */
|
||||||
mode: "cli" | "tool";
|
mode: "cli" | "tool";
|
||||||
|
/** Save models to settings file on successful launch (CLI only). */
|
||||||
|
saveToSettings?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -538,6 +538,34 @@ describe("multi-model launch", () => {
|
|||||||
?.match(/-n (\S+)/)?.[1];
|
?.match(/-n (\S+)/)?.[1];
|
||||||
expect(windowName?.length).toBeLessThanOrEqual(24);
|
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,
|
spyOn,
|
||||||
test,
|
test,
|
||||||
} from "bun:test";
|
} from "bun:test";
|
||||||
|
import * as fs from "node:fs";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import {
|
import {
|
||||||
createWindowBaseName,
|
createWindowBaseName,
|
||||||
@@ -15,13 +16,16 @@ import {
|
|||||||
formatInvalidModelError,
|
formatInvalidModelError,
|
||||||
getBinaryName,
|
getBinaryName,
|
||||||
getSessionPath,
|
getSessionPath,
|
||||||
|
getSettingsPath,
|
||||||
getWorktreePath,
|
getWorktreePath,
|
||||||
getWorktreesForSession,
|
getWorktreesForSession,
|
||||||
launchModelInWindow,
|
launchModelInWindow,
|
||||||
levenshtein,
|
levenshtein,
|
||||||
|
loadSettings,
|
||||||
normalizeModels,
|
normalizeModels,
|
||||||
runCommand,
|
runCommand,
|
||||||
sanitizeName,
|
sanitizeName,
|
||||||
|
saveSettings,
|
||||||
shellQuote,
|
shellQuote,
|
||||||
suggestModels,
|
suggestModels,
|
||||||
undoWorktree,
|
undoWorktree,
|
||||||
@@ -484,4 +488,86 @@ describe("core utils", () => {
|
|||||||
delete process.env.OPENCODE_MULTI_MODEL_BINARY;
|
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