Files
opencode-multi-model/.opencode/plans/archive/1774116737450-clever-canyon.md
T
bendtherules 6e2521b70d feat: add multi-model cleanup tool and CLI command
- Bump `@opencode-ai/plugin` dependency from 1.2.27 to 1.3.0.
- Introduce `cleanup` command in CLI with force and remote options.
- Export `cleanupMultiModel` from core index.
- Register `multi-model-cleanup` tool in plugin and update exports.
- Add `CleanupOptions` and `CleanupResult` types to define cleanup behavior and results.
2026-03-23 09:43:43 +05:30

13 KiB

model
model
opencode-go/glm-5

Plan: Add opencode-multi-model cleanup Command

Overview

Add a newcleanup command that deletes all archive tags (format: archive/*) from localand remote git repositories. Archive tags are created by the close command to preserve branch state before deletion.

User Requirements

  1. Scope: Only archive tags (not orphaned worktrees/branches)
  2. Confirmation: Prompt by default, --force to skip
  3. Remote handling: Delete from both local and remote by default
  4. Preview: Show count of tags, not full list
  5. Remote discovery: Check git remote -v for available remotes
  6. Error handling: Continue with local cleanup if remote deletion fails

Files to Modify/Create

File Action Description
src/types.ts MODIFY Add CleanupOptions and CleanupResult interfaces
src/core/cleanup.ts CREATE Core cleanup implementation
src/tools/cleanup.ts CREATE Tool definition for plugin mode
src/cli.ts MODIFY Add cleanup command registration
src/index.ts MODIFY Export cleanup tool
tests/cleanup.test.ts CREATE Unit tests

Implementation Details

1. TypeScript Interfaces(src/types.ts)

Add after WorktreeInfo interface (around line 98):

/**
 * Options for cleaning up archive tags.
 */
export interface CleanupOptions {
  /** Skip confirmation prompts. */
  force?: boolean;
  /** 
   * Remote name to push deletions to.
   * - If string: use specified remote (error if doesn't exist)
   * - If undefined: use first available remote from git remote -v
   * - If false: only local cleanup (--no-remote flag)
   */
  remote?: string | false;
}

/**
 * Result returned after attempting to cleanup archive tags.
 */
export interface CleanupResult {
  /** Whether the cleanup succeeded. */
  success: boolean;
  /** Error message if the cleanup failed. */
  error?: string;
  /** Summary message for the user. */
  instructions?: string;
  /** Number of local tags deleted. */
  localTagsDeleted: number;
  /** Number of remote tags deleted. */
  remoteTagsDeleted: number;
  /** Names of tags that were deleted locally. */
  localTagNames: string[];
  /** Names of tags deleted from remote. */
  remoteTagNames: string[];
  /** Tags that failed remote deletion. */
  remoteTagErrors: Array<{ tag: string; error: string }>;
  /** Available remotes discovered. */
  availableRemotes: string[];
}

2. Core Implementation (src/core/cleanup.ts)

Create new file with the following functions:

Helper Functions

import * as readline from "node:readline";
import chalk from 'chalk';
import type { CleanupOptions, CleanupResult } from "../types";
import { runCommand } from "./utils";

/**
 * Prompts the user for confirmation on the terminal.
 */
async function promptUser(question: string): Promise<string> {
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
  });
  return new Promise((resolve) => {
    rl.question(question, (answer) => {
      rl.close();
      resolve(answer);
    });
  });
}

/**
 * Lists all archive tags matching the pattern archive/*.
 */
export async function listArchiveTags(): Promise<string[]> {
  const { stdout } = await runCommand(["git", "tag", "-l", "archive/*"]);
  return stdout.split("\n").filter(Boolean);
}

/**
 * Gets the list of available git remotes.
 */
export async function getAvailableRemotes(): Promise<string[]> {
  const { stdout } = await runCommand(["git", "remote"]);
  return stdout.split("\n").filter(Boolean);
}

/**
 * Deletes a local git tag.
 */
async function deleteLocalTag(tagName: string): Promise<boolean> {
  const result = await runCommand(["git", "tag", "-d", tagName]);
  return result.ok;
}

/**
 * Deletes a tag from a remote repository.
 */
async function deleteRemoteTag(tagName: string, remoteName: string): Promise<{ ok: boolean; error: string }> {
  const result = await runCommand(["git", "push", remoteName, "--delete", tagName]);
  return {
    ok: result.ok,
    error: result.ok ? "" : result.stderr,
  };
}

Main Function

/**
 * Cleans up archive tags from local and optionally remote repositories.
 *
 * Archive tags have the format: archive/<branch-name>-<timestamp>
 * These are created by the close command to preserve branch state before deletion.
 *
 * @param options Cleanup configuration including force mode and remote settings.
 * @returns Result with counts and details of deleted tags.
 */
export async function cleanupArchiveTags(options: CleanupOptions = {}): Promise<CleanupResult> {
  const { force = false, remote } = options;

  // Get available remotes and archive tags first
  const availableRemotes = await getAvailableRemotes();
  const archiveTags = await listArchiveTags();

  // Determine whether to push to remote and which remote to use:
  // - remote === false: --no-remote flag, local-only mode
  // - remote === string: use specified remote (error if doesn't exist)
  // - remote === undefined: use first available remote
  const shouldPushToRemote = remote !== false;

  let remoteToDeleteFrom: string | undefined;

  if (shouldPushToRemote) {
    if (typeof remote === "string") {
      // User specified a remote - validate it exists
      if (!availableRemotes.includes(remote)) {
        return {
          success: false,
          error: `Error: Remote '${remote}' not found. Available remotes: ${availableRemotes.join(", ") || "none"}`,
          localTagsDeleted: 0,
          remoteTagsDeleted: 0,
          localTagNames: [],
          remoteTagNames: [],
          remoteTagErrors: [],
          availableRemotes,
        };
      }
      remoteToDeleteFrom = remote;
    } else {
      // remote === undefined, use first available
      remoteToDeleteFrom = availableRemotes[0];
    }
  }

  if (archiveTags.length === 0) {
    return {
      success: true,
      instructions: chalk.dim("No archive tags found."),
      localTagsDeleted: 0,
      remoteTagsDeleted: 0,
      localTagNames: [],
      remoteTagNames: [],
      remoteTagErrors: [],
      availableRemotes,
    };
  }

  // Confirmation prompt (unless --force)
  if (!force) {
    const tagCount = archiveTags.length;
    const tagWord = tagCount === 1 ? "tag" : "tags";
    console.log(chalk.yellow(`Found ${tagCount} archive ${tagWord}.`));

    if (shouldPushToRemote && remoteToDeleteFrom) {
      console.log(chalk.dim(`Remote deletions will use: ${remoteToDeleteFrom}`));
    } else if (shouldPushToRemote && !remoteToDeleteFrom) {
      console.log(chalk.dim("No remotes configured. Only local cleanup will be performed."));
    }

    const answer = await promptUser(chalk.bold("\nProceed with cleanup? (y/N): "));
    if (answer.toLowerCase() !== "y" && answer.toLowerCase() !== "yes") {
      return {
        success: false,
        error: "Cleanup cancelled by user",
        localTagsDeleted: 0,
        remoteTagsDeleted: 0,
        localTagNames: [],
        remoteTagNames: [],
        remoteTagErrors: [],
        availableRemotes,
      };
    }
  }

  const localTagNames: string[] = [];
  const remoteTagNames: string[] = [];
  const remoteTagErrors: Array<{ tag: string; error: string }> = [];

  // Delete local tags
  for (const tag of archiveTags) {
    const deleted = await deleteLocalTag(tag);
    if (deleted) {
      localTagNames.push(tag);
    }
  }

  // Delete from remote (if remote is specified and exists)
  if (shouldPushToRemote && remoteToDeleteFrom) {
    for (const tag of localTagNames) {
      const result = await deleteRemoteTag(tag, remoteToDeleteFrom);
      if (result.ok) {
        remoteTagNames.push(tag);
      } else {
        // Continue with local cleanup even if remote fails
        remoteTagErrors.push({ tag, error: result.error });
      }
    }
  }

  // Build instructions message
  const instructionsArr: string[] = [];
  if (localTagNames.length > 0) {
    const word = localTagNames.length === 1 ? "tag" : "tags";
    instructionsArr.push(chalk.green(`Deleted ${localTagNames.length} local archive ${word}.`));
  }
  if (remoteTagNames.length > 0) {
    const word = remoteTagNames.length === 1 ? "tag" : "tags";
    instructionsArr.push(chalk.green(`Deleted ${remoteTagNames.length} remote archive ${word}.`));
  }
  if (remoteTagErrors.length > 0) {
    const word = remoteTagErrors.length === 1 ? "tag" : "tags";
    instructionsArr.push(chalk.yellow(`Failed to delete ${remoteTagErrors.length} remote ${word} (local cleanup continued).`));
  }

  return {
    success: true,
    instructions: instructionsArr.join("\n"),
    localTagsDeleted: localTagNames.length,
    remoteTagsDeleted: remoteTagNames.length,
    localTagNames,
    remoteTagNames,
    remoteTagErrors,
    availableRemotes,
  };
}

3. Tool Definition (src/tools/cleanup.ts)

Create new file:

import { tool } from "@opencode-ai/plugin";
import { cleanupArchiveTags } from "../core/cleanup";

/**
 * OpenCode tool definition for cleaning up archive tags.
 *
 * Deletes archive tags (format: archive/*) from local and remote repositories.
 * Used to clean up recovery tags created by the close command.
 */
export const cleanupTool = tool({
  description: "Delete all archive tags from local and optionally remote repository",
  args: {
    remote: tool.schema.union([
      tool.schema.string().describe("Remote name to push deletions to. Error if doesn't exist."),
      tool.schema.literal(false).describe("Only delete local tags, don't push to remote."),
    ]).optional().describe("Remote handling: string=specific remote, false=local-only, undefined=first available remote"),
  },
  async execute(args, context) {
    // In plugin mode, we skip confirmation (force=true)
    const result = await cleanupArchiveTags({
      force: true,
      remote: args.remote,
    });

    if (!result.success) {
      return result.error!;
    }

    return result.instructions ?? `Deleted ${result.localTagsDeleted} local tags.`;
  },
});

4. CLI Command (src/cli.ts)

Add import after line 5:

import { cleanupArchiveTags } from "./core/cleanup";

Add command after line 69 (after close command):

program
  .command("cleanup")
  .description("Delete all archive tags from local and remote")
  .option("-f, --force", "Skip confirmation prompts", false)
  .option("-r, --remote <remote>", "Remote name to push deletions to (default: first remote from git remote -v)")
  .option("--no-remote", "Only delete local tags, skip remote")
  .action(async (options: { force: boolean; remote?: string|boolean }) => {
    try {
      const result = await cleanupArchiveTags({
        force: options.force,
        remote: options.remote,
      });

      if (result.success) {
        console.log(result.instructions);
      } else {
        console.error(`Failed: ${result.error}`);
        process.exit(1);
      }
    } catch (error) {
      console.error(`Error: ${error}`);
      process.exit(1);
    }
  });

5. Index Export (src/index.ts)

Add import after line 3:

import { cleanupTool } from "./tools/cleanup";

Update return object (line 22-25):

    tool: {
      "multi-model-open": openTool,
      "multi-model-close": closeTool,
      "multi-model-cleanup": cleanupTool,
    },

Update exports (line30):

export { openTool, closeTool, cleanupTool };

Usage Examples

CLI Usage

# Interactive (shows confirmation prompt, uses first remote from git remote -v)
opencode-multi-model cleanup

# Skip confirmation
opencode-multi-model cleanup --force

# Use specific remote
opencode-multi-model cleanup --remote upstream

# Only local cleanup (don't push to remote)
opencode-multi-model cleanup --no-remote

# Combined options
opencode-multi-model cleanup -f --remote upstream

Tool Usage (OpenCode Plugin)

// Local-only cleanup (don't push to remote)
await multi-model-cleanup({ remote: false });

// Use first available remote (default behavior)
await multi-model-cleanup({ remote: undefined });

// Specify a remote
await multi-model-cleanup({ remote: "upstream" });

Git Commands Used

Command Purpose
git tag -l "archive/*" List all archive tags
git remote Get available remotes
git tag -d <name> Delete local tag
git push <remote> --delete <tag> Delete remote tag

Verification

After implementation, verify with:

  1. Build: bun run build or bun run typecheck
  2. Unit tests: bun test tests/cleanup.test.ts
  3. Manual test:
    # Create some archive tags for testing
    git tag archive/test-branch-1-2024-01-01T00-00-00-000Z
    git tag archive/test-branch-2-2024-01-02T00-00-00-000Z
    
    # Run cleanup
    bun run src/cli.ts cleanup
    
    # Verify tags are deleted
    git tag -l "archive/*"
    

Test Cases

Create tests/cleanup.test.ts with:

  1. No archive tags found - returns success with message
  2. User cancels confirmation - returns success=false
  3. Force mode skips confirmation
  4. --no-remote flag (remote=false) - only local cleanup, no remote push
  5. Remote deletion failure continues local cleanup
  6. Dynamic remote detection - remote=undefined uses first available remote
  7. Remote name validation - uses specified remote if it exists
  8. Remote error - returns error if specified remote doesn't exist
  9. No remotes configured - only local cleanup