feat: add multi-model cleanup tool and CLI command

- Bump `@opencode-ai/plugin` dependency from 1.2.27 to 1.3.0.
- Introduce `cleanup` command in CLI with force and remote options.
- Export `cleanupMultiModel` from core index.
- Register `multi-model-cleanup` tool in plugin and update exports.
- Add `CleanupOptions` and `CleanupResult` types to define cleanup behavior and results.
This commit is contained in:
2026-03-23 09:43:43 +05:30
parent 42f153a28f
commit 6e2521b70d
9 changed files with 747 additions and 4 deletions
+2 -2
View File
@@ -6,10 +6,10 @@
"test": "bun test" "test": "bun test"
}, },
"dependencies": { "dependencies": {
"@opencode-ai/plugin": "1.2.27" "@opencode-ai/plugin": "1.3.0"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.10", "@types/bun": "^1.3.10",
"@types/node": "^25.5.0" "@types/node": "^25.5.0"
} }
} }
+29
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import { Command } from "commander"; import { Command } from "commander";
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 } from "./core/utils";
@@ -91,4 +92,32 @@ program
}, },
); );
program
.command("cleanup")
.description("Delete all archive tags from local and remote")
.option("-f, --force", "Skip confirmation prompts", false)
.option(
"-r, --remote <remote>",
"Remote name to push deletions to (default: first remote from git remote -v)",
)
.option("--no-remote", "Only delete local tags, skip remote")
.action(async (options: { force: boolean; remote?: string | boolean }) => {
try {
const result = await cleanupMultiModel({
force: options.force,
remote: options.remote === true ? undefined : options.remote,
});
if (result.success) {
console.log(result.instructions);
} else {
console.error(`Failed: ${result.error}`);
process.exit(1);
}
} catch (error) {
console.error(`Error: ${error}`);
process.exit(1);
}
});
program.parse(); program.parse();
+220
View File
@@ -0,0 +1,220 @@
import * as readline from "node:readline";
import chalk from "chalk";
import type { CleanupOptions, CleanupResult } from "../types";
import { runCommand } from "./utils";
/**
* Prompts the user for confirmation on the terminal.
*/
async function promptUser(question: string): Promise<string> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer);
});
});
}
/**
* Lists all archive tags matching the pattern archive/*.
*/
export async function listArchiveTags(): Promise<string[]> {
const { stdout } = await runCommand(["git", "tag", "-l", "archive/*"]);
return stdout.split("\n").filter(Boolean);
}
/**
* Gets the list of available git remotes.
*/
export async function getAvailableRemotes(): Promise<string[]> {
const { stdout } = await runCommand(["git", "remote"]);
return stdout.split("\n").filter(Boolean);
}
/**
* Deletes a local git tag.
*/
async function deleteLocalTag(tagName: string): Promise<boolean> {
const result = await runCommand(["git", "tag", "-d", tagName]);
return result.ok;
}
/**
* Deletes a tag from a remote repository.
*/
async function deleteRemoteTag(
tagName: string,
remoteName: string,
): Promise<{ ok: boolean; error: string }> {
const result = await runCommand([
"git",
"push",
remoteName,
"--delete",
tagName,
]);
return {
ok: result.ok,
error: result.ok ? "" : result.stderr,
};
}
/**
* Cleans up archive tags from local and optionally remote repositories.
*
* Archive tags have the format: archive/<branch-name>-<timestamp>
* These are created by the close command to preserve branch state before deletion.
*
* @param options Cleanup configuration including force mode and remote settings.
* @returns Result with counts and details of deleted tags.
*/
export async function cleanupMultiModel(
options: CleanupOptions = {},
): Promise<CleanupResult> {
const { force = false, remote } = options;
// Get available remotes and archive tags first
const availableRemotes = await getAvailableRemotes();
const archiveTags = await listArchiveTags();
// Determine whether to push to remote and which remote to use:
// - remote === false: --no-remote flag, local-only mode
// - remote === string: use specified remote (error if doesn't exist)
// - remote === undefined: use first available remote
const shouldPushToRemote = remote !== false;
let remoteToDeleteFrom: string | undefined;
if (shouldPushToRemote) {
if (typeof remote === "string") {
// User specified a remote - validate it exists
if (!availableRemotes.includes(remote)) {
return {
success: false,
error: `Error: Remote '${remote}' not found. Available remotes: ${availableRemotes.join(", ") || "none"}`,
localTagsDeleted: 0,
remoteTagsDeleted: 0,
localTagNames: [],
remoteTagNames: [],
remoteTagErrors: [],
availableRemotes,
};
}
remoteToDeleteFrom = remote;
} else {
// remote === undefined, use first available
remoteToDeleteFrom = availableRemotes[0];
}
}
if (archiveTags.length === 0) {
return {
success: true,
instructions: chalk.dim("No archive tags found."),
localTagsDeleted: 0,
remoteTagsDeleted: 0,
localTagNames: [],
remoteTagNames: [],
remoteTagErrors: [],
availableRemotes,
};
}
// Confirmation prompt (unless --force)
if (!force) {
const tagCount = archiveTags.length;
const tagWord = tagCount === 1 ? "tag" : "tags";
console.log(chalk.yellow(`Found ${tagCount} archive ${tagWord}.`));
if (shouldPushToRemote && remoteToDeleteFrom) {
console.log(
chalk.dim(`Remote deletions will use: ${remoteToDeleteFrom}`),
);
} else if (shouldPushToRemote && !remoteToDeleteFrom) {
console.log(
chalk.dim(
"No remotes configured. Only local cleanup will be performed.",
),
);
}
const answer = await promptUser(
chalk.bold("\nProceed with cleanup? (y/N): "),
);
if (answer.toLowerCase() !== "y" && answer.toLowerCase() !== "yes") {
return {
success: false,
error: "Cleanup cancelled by user",
localTagsDeleted: 0,
remoteTagsDeleted: 0,
localTagNames: [],
remoteTagNames: [],
remoteTagErrors: [],
availableRemotes,
};
}
}
const localTagNames: string[] = [];
const remoteTagNames: string[] = [];
const remoteTagErrors: Array<{ tag: string; error: string }> = [];
// Delete local tags
for (const tag of archiveTags) {
const deleted = await deleteLocalTag(tag);
if (deleted) {
localTagNames.push(tag);
}
}
// Delete from remote (if remote is specified and exists)
if (shouldPushToRemote && remoteToDeleteFrom) {
for (const tag of localTagNames) {
const result = await deleteRemoteTag(tag, remoteToDeleteFrom);
if (result.ok) {
remoteTagNames.push(tag);
} else {
// Continue with local cleanup even if remote fails
remoteTagErrors.push({ tag, error: result.error });
}
}
}
// Build instructions message
const instructionsArr: string[] = [];
if (localTagNames.length > 0) {
const word = localTagNames.length === 1 ? "tag" : "tags";
instructionsArr.push(
chalk.green(`Deleted ${localTagNames.length} local archive ${word}.`),
);
}
if (remoteTagNames.length > 0) {
const word = remoteTagNames.length === 1 ? "tag" : "tags";
instructionsArr.push(
chalk.green(`Deleted ${remoteTagNames.length} remote archive ${word}.`),
);
}
if (remoteTagErrors.length > 0) {
const word = remoteTagErrors.length === 1 ? "tag" : "tags";
instructionsArr.push(
chalk.yellow(
`Failed to delete ${remoteTagErrors.length} remote ${word} (local cleanup continued).`,
),
);
}
return {
success: true,
instructions: instructionsArr.join("\n"),
localTagsDeleted: localTagNames.length,
remoteTagsDeleted: remoteTagNames.length,
localTagNames,
remoteTagNames,
remoteTagErrors,
availableRemotes,
};
}
+1
View File
@@ -1,3 +1,4 @@
export { cleanupMultiModel } from "./cleanup";
export { closeMultiModel } from "./close"; export { closeMultiModel } from "./close";
export { openMultiModel } from "./open"; export { openMultiModel } from "./open";
export * from "./utils"; export * from "./utils";
+5 -2
View File
@@ -1,13 +1,15 @@
import type { Plugin } from "@opencode-ai/plugin"; import type { Plugin } from "@opencode-ai/plugin";
import { cleanupTool } from "./tools/cleanup";
import { closeTool } from "./tools/close"; import { closeTool } from "./tools/close";
import { openTool } from "./tools/open"; import { openTool } from "./tools/open";
/** /**
* OpenCode plugin that provides multi-model tmux session management tools. * OpenCode plugin that provides multi-model tmux session management tools.
* *
* Registers two tools: * Registers three tools:
* - `multi-model-open`: Launch multiple AI models in a tmux session * - `multi-model-open`: Launch multiple AI models in a tmux session
* - `multi-model-close`: Close a multi-model session and cleanup resources * - `multi-model-close`: Close a multi-model session and cleanup resources
* - `multi-model-cleanup`: Delete all archive tags from local and remote repositories
* *
* @example * @example
* ```json * ```json
@@ -22,6 +24,7 @@ const OpenCodeMultiModelPlugin: Plugin = async (_ctx) => {
tool: { tool: {
"multi-model-open": openTool, "multi-model-open": openTool,
"multi-model-close": closeTool, "multi-model-close": closeTool,
"multi-model-cleanup": cleanupTool,
}, },
}; };
}; };
@@ -29,4 +32,4 @@ const OpenCodeMultiModelPlugin: Plugin = async (_ctx) => {
export default OpenCodeMultiModelPlugin; export default OpenCodeMultiModelPlugin;
export * from "./core/index"; export * from "./core/index";
export * from "./types"; export * from "./types";
export { closeTool, openTool }; export { cleanupTool, closeTool, openTool };
+45
View File
@@ -0,0 +1,45 @@
import { tool } from "@opencode-ai/plugin";
import { cleanupMultiModel } from "../core/cleanup";
/**
* OpenCode tool definition for cleaning up archive tags.
*
* Deletes archive tags (format: archive/*) from local and remote repositories.
* Used to clean up recovery tags created by the close command.
*/
export const cleanupTool = tool({
description:
"Delete all archive tags from local and optionally remote repository",
args: {
remote: tool.schema
.union([
tool.schema
.string()
.describe(
"Remote name to push deletions to. Error if doesn't exist.",
),
tool.schema
.literal(false)
.describe("Only delete local tags, don't push to remote."),
])
.optional()
.describe(
"Remote handling: string=specific remote, false=local-only, undefined=first available remote",
),
},
async execute(args, _context) {
// In plugin mode, we skip confirmation (force=true)
const result = await cleanupMultiModel({
force: true,
remote: args.remote,
});
if (!result.success) {
return result.error!;
}
return (
result.instructions ?? `Deleted ${result.localTagsDeleted} local tags.`
);
},
});
+39
View File
@@ -74,6 +74,45 @@ export interface WindowLaunchPlan {
windowName: string; windowName: string;
} }
/**
* Options for cleaning up archive tags.
*/
export interface CleanupOptions {
/** Skip confirmation prompts. */
force?: boolean;
/**
* Remote name to push deletions to.
* - If string: use specified remote (error if doesn't exist)
* - If undefined: use first available remote from git remote -v
* - If false: only local cleanup (--no-remote flag)
*/
remote?: string | false;
}
/**
* Result returned after attempting to cleanup archive tags.
*/
export interface CleanupResult {
/** Whether the cleanup succeeded. */
success: boolean;
/** Error message if the cleanup failed. */
error?: string;
/** Summary message for the user. */
instructions?: string;
/** Number of local tags deleted. */
localTagsDeleted: number;
/** Number of remote tags deleted. */
remoteTagsDeleted: number;
/** Names of tags that were deleted locally. */
localTagNames: string[];
/** Names of tags deleted from remote. */
remoteTagNames: string[];
/** Tags that failed remote deletion. */
remoteTagErrors: Array<{ tag: string; error: string }>;
/** Available remotes discovered. */
availableRemotes: string[];
}
/** /**
* Result of running a shell command. * Result of running a shell command.
*/ */
+406
View File
@@ -0,0 +1,406 @@
import {
afterEach,
beforeEach,
describe,
expect,
mock,
spyOn,
test,
} from "bun:test";
import * as readline from "node:readline";
import { cleanupMultiModel } from "../src/core/cleanup";
const originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
let mockCommandResponses: Record<
string,
{ ok: boolean; stdout: string; stderr: string }
> = {};
let executedCommands: string[] = [];
function setupMockBun$() {
const mockFn = mock((_strings: TemplateStringsArray, ...values: any[]) => {
const parts = values[0] as string[];
const commandSignature = parts.join(" ");
executedCommands.push(commandSignature);
const response = mockCommandResponses[commandSignature] ?? {
ok: true,
stdout: "",
stderr: "",
};
return {
quiet: () => ({
nothrow: async () => ({
exitCode: response.ok ? 0 : 1,
stdout: { toString: () => response.stdout },
stderr: { toString: () => response.stderr },
}),
}),
};
});
(globalThis as any).Bun.$ = mockFn;
return mockFn;
}
function restoreOriginalBun$() {
(globalThis as any).Bun.$ = originalBun$;
}
describe("cleanupMultiModel", () => {
let _bunMock: ReturnType<typeof setupMockBun$>;
beforeEach(() => {
_bunMock = setupMockBun$();
executedCommands = [];
mockCommandResponses = {};
});
afterEach(() => {
restoreOriginalBun$();
});
test("no archive tags found - returns success with message", async () => {
mockCommandResponses["git remote"] = {
ok: true,
stdout: "origin",
stderr: "",
};
mockCommandResponses["git tag -l archive/*"] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await cleanupMultiModel({ force: true });
expect(result.success).toBe(true);
expect(result.localTagsDeleted).toBe(0);
expect(result.remoteTagsDeleted).toBe(0);
expect(result.instructions).toContain("No archive tags found");
});
test("user cancels confirmation - returns success=false", async () => {
mockCommandResponses["git remote"] = {
ok: true,
stdout: "origin",
stderr: "",
};
mockCommandResponses["git tag -l archive/*"] = {
ok: true,
stdout: "archive/test-1\narchive/test-2",
stderr: "",
};
const mockInterface = {
question: (_q: string, cb: (a: string) => void) => cb("n"),
close: () => {},
} as any;
spyOn(readline, "createInterface").mockImplementation(() => mockInterface);
const result = await cleanupMultiModel({ force: false });
expect(result.success).toBe(false);
expect(result.error).toBe("Cleanup cancelled by user");
expect(result.localTagsDeleted).toBe(0);
expect(result.remoteTagsDeleted).toBe(0);
});
test("force mode skips confirmation", async () => {
mockCommandResponses["git remote"] = {
ok: true,
stdout: "origin",
stderr: "",
};
mockCommandResponses["git tag -l archive/*"] = {
ok: true,
stdout: "archive/test-1",
stderr: "",
};
mockCommandResponses["git tag -d archive/test-1"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git push origin --delete archive/test-1"] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await cleanupMultiModel({ force: true });
expect(result.success).toBe(true);
expect(result.localTagsDeleted).toBe(1);
expect(executedCommands).toContain("git tag -d archive/test-1");
});
test("--no-remote flag (remote=false) - only local cleanup, no remote push", async () => {
mockCommandResponses["git remote"] = {
ok: true,
stdout: "origin",
stderr: "",
};
mockCommandResponses["git tag -l archive/*"] = {
ok: true,
stdout: "archive/test-1",
stderr: "",
};
mockCommandResponses["git tag -d archive/test-1"] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await cleanupMultiModel({ force: true, remote: false });
expect(result.success).toBe(true);
expect(result.localTagsDeleted).toBe(1);
expect(result.remoteTagsDeleted).toBe(0);
expect(executedCommands).toContain("git tag -d archive/test-1");
expect(executedCommands.some((c) => c.startsWith("git push"))).toBe(false);
});
test("remote deletion failure continues local cleanup", async () => {
mockCommandResponses["git remote"] = {
ok: true,
stdout: "origin",
stderr: "",
};
mockCommandResponses["git tag -l archive/*"] = {
ok: true,
stdout: "archive/test-1\narchive/test-2",
stderr: "",
};
mockCommandResponses["git tag -d archive/test-1"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git tag -d archive/test-2"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git push origin --delete archive/test-1"] = {
ok: false,
stdout: "",
stderr: "remote error",
};
mockCommandResponses["git push origin --delete archive/test-2"] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await cleanupMultiModel({ force: true });
expect(result.success).toBe(true);
expect(result.localTagsDeleted).toBe(2);
expect(result.remoteTagsDeleted).toBe(1);
expect(result.remoteTagErrors.length).toBe(1);
expect(result.remoteTagErrors[0]?.tag).toBe("archive/test-1");
});
test("dynamic remote detection - remote=undefined uses first available remote", async () => {
mockCommandResponses["git remote"] = {
ok: true,
stdout: "upstream\norigin",
stderr: "",
};
mockCommandResponses["git tag -l archive/*"] = {
ok: true,
stdout: "archive/test-1",
stderr: "",
};
mockCommandResponses["git tag -d archive/test-1"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git push upstream --delete archive/test-1"] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await cleanupMultiModel({ force: true, remote: undefined });
expect(result.success).toBe(true);
expect(executedCommands).toContain(
"git push upstream --delete archive/test-1",
);
});
test("remote name validation - uses specified remote if it exists", async () => {
mockCommandResponses["git remote"] = {
ok: true,
stdout: "upstream\norigin",
stderr: "",
};
mockCommandResponses["git tag -l archive/*"] = {
ok: true,
stdout: "archive/test-1",
stderr: "",
};
mockCommandResponses["git tag -d archive/test-1"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git push upstream --delete archive/test-1"] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await cleanupMultiModel({
force: true,
remote: "upstream",
});
expect(result.success).toBe(true);
expect(result.availableRemotes).toEqual(["upstream", "origin"]);
expect(executedCommands).toContain(
"git push upstream --delete archive/test-1",
);
});
test("remote error - returns error if specified remote doesn't exist", async () => {
mockCommandResponses["git remote"] = {
ok: true,
stdout: "origin",
stderr: "",
};
mockCommandResponses["git tag -l archive/*"] = {
ok: true,
stdout: "archive/test-1",
stderr: "",
};
const result = await cleanupMultiModel({
force: true,
remote: "nonexistent",
});
expect(result.success).toBe(false);
expect(result.error).toContain("Remote 'nonexistent' not found");
expect(result.error).toContain("Available remotes: origin");
});
test("no remotes configured - only local cleanup", async () => {
mockCommandResponses["git remote"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git tag -l archive/*"] = {
ok: true,
stdout: "archive/test-1",
stderr: "",
};
mockCommandResponses["git tag -d archive/test-1"] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await cleanupMultiModel({ force: true });
expect(result.success).toBe(true);
expect(result.availableRemotes).toEqual([]);
expect(result.remoteTagsDeleted).toBe(0);
expect(executedCommands.some((c) => c.startsWith("git push"))).toBe(false);
});
test("user confirms cleanup when not forced - proceeds with deletion", async () => {
mockCommandResponses["git remote"] = {
ok: true,
stdout: "origin",
stderr: "",
};
mockCommandResponses["git tag -l archive/*"] = {
ok: true,
stdout: "archive/test-1",
stderr: "",
};
mockCommandResponses["git tag -d archive/test-1"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git push origin --delete archive/test-1"] = {
ok: true,
stdout: "",
stderr: "",
};
const mockInterface = {
question: (_q: string, cb: (a: string) => void) => cb("y"),
close: () => {},
} as any;
spyOn(readline, "createInterface").mockImplementation(() => mockInterface);
const result = await cleanupMultiModel({ force: false });
expect(result.success).toBe(true);
expect(result.localTagsDeleted).toBe(1);
expect(result.remoteTagsDeleted).toBe(1);
expect(executedCommands).toContain("git tag -d archive/test-1");
expect(executedCommands).toContain(
"git push origin --delete archive/test-1",
);
});
test("multiple archive tags deleted", async () => {
mockCommandResponses["git remote"] = {
ok: true,
stdout: "origin",
stderr: "",
};
mockCommandResponses["git tag -l archive/*"] = {
ok: true,
stdout: "archive/test-1\narchive/test-2\narchive/test-3",
stderr: "",
};
mockCommandResponses["git tag -d archive/test-1"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git tag -d archive/test-2"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git tag -d archive/test-3"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git push origin --delete archive/test-1"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git push origin --delete archive/test-2"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git push origin --delete archive/test-3"] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await cleanupMultiModel({ force: true });
expect(result.success).toBe(true);
expect(result.localTagsDeleted).toBe(3);
expect(result.remoteTagsDeleted).toBe(3);
expect(result.localTagNames).toEqual([
"archive/test-1",
"archive/test-2",
"archive/test-3",
]);
});
});