Last active
January 3, 2026 19:54
-
-
Save elithrar/5e3a4c741c0588d007178b17f5c5079c to your computer and use it in GitHub Desktop.
2025/12/24 - draft/hack version of a cross-repo tool for OpenCode.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Cross-Repo Tool for OpenCode | |
| * | |
| * Enables operations on GitHub repositories other than the current working repository. | |
| * Useful for coordinated changes across multiple repos, updating related repos, | |
| * or opening PRs in different repositories. | |
| * | |
| * ## Security Model | |
| * | |
| * Authentication is context-aware: | |
| * - **GitHub Actions**: OIDC token exchange (preferred) -> GITHUB_TOKEN fallback | |
| * - **Interactive/CI**: gh CLI -> GH_TOKEN/GITHUB_TOKEN fallback | |
| * | |
| * In GitHub Actions with OIDC, tokens are scoped to only the target repository | |
| * with minimal permissions (contents:write, pull_requests:write, issues:write). | |
| * | |
| * ## Agent Usage | |
| * | |
| * Use this tool when you need to: | |
| * - Clone and modify a different repository | |
| * - Create coordinated changes across multiple repos | |
| * - Open PRs in related repositories | |
| * - Apply changes from current repo context to another repo | |
| */ | |
| import { tool, type ToolContext } from "@opencode-ai/plugin" | |
| import { Shescape } from "shescape" | |
| import { tmpdir } from "os" | |
| const shescape = new Shescape({ shell: "bash" }) | |
| function shellEscape(str: string): string { | |
| return shescape.quote(str) | |
| } | |
| interface RepoState { | |
| path: string | |
| token: string | |
| defaultBranch: string | |
| } | |
| // Session-scoped tracking of cloned repos | |
| // Key format: "{sessionID}/{owner}/{repo}" | |
| const clonedRepos = new Map<string, RepoState>() | |
| // Cache gh CLI availability | |
| let ghCliAvailable: boolean | null = null | |
| function log(sessionID: string, action: string, detail: string): void { | |
| console.log(`[cross-repo] session=${sessionID} action=${action} ${detail}`) | |
| } | |
| function logError(sessionID: string, action: string, error: string): void { | |
| console.error(`[cross-repo] session=${sessionID} action=${action} error=${error}`) | |
| } | |
| function getClonePath(sessionID: string, owner: string, repo: string): string { | |
| return `${tmpdir()}/${sessionID}/${owner}-${repo}` | |
| } | |
| function getRepoKey(sessionID: string, owner: string, repo: string): string { | |
| return `${sessionID}/${owner}/${repo}` | |
| } | |
| /** | |
| * Validate owner/repo names to prevent injection | |
| */ | |
| function validateRepoName(name: string, field: string): string | null { | |
| // GitHub owner/repo names: alphanumeric, hyphens, underscores, dots | |
| // Cannot start with hyphen or dot | |
| if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name)) { | |
| return `Invalid ${field}: must be alphanumeric with hyphens/underscores/dots, cannot start with - or .` | |
| } | |
| if (name.length > 100) { | |
| return `Invalid ${field}: exceeds maximum length` | |
| } | |
| return null | |
| } | |
| /** | |
| * Validate branch name | |
| */ | |
| function validateBranchName(branch: string): string | null { | |
| // Reject shell metacharacters and git-unsafe patterns | |
| if (/[\s;&|`$(){}[\]<>\\'"!*?~^]/.test(branch) || branch.startsWith("-")) { | |
| return `Invalid branch name: contains unsafe characters` | |
| } | |
| if (branch.length > 250) { | |
| return `Invalid branch name: exceeds maximum length` | |
| } | |
| return null | |
| } | |
| type ExecutionContextType = "github-actions" | "interactive" | "non-interactive" | |
| interface ExecutionContext { | |
| type: ExecutionContextType | |
| hasOIDC: boolean | |
| hasGhCli: boolean | null | |
| hasGitHubToken: boolean | |
| } | |
| function isGitHubActions(): boolean { | |
| return process.env.GITHUB_ACTIONS === "true" | |
| } | |
| function hasOIDCPermissions(): boolean { | |
| return !!(process.env.ACTIONS_ID_TOKEN_REQUEST_URL && process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN) | |
| } | |
| function isInteractive(): boolean { | |
| if (process.env.CI === "true") { | |
| return false | |
| } | |
| return !!(process.stdin?.isTTY && process.stdout?.isTTY) | |
| } | |
| /** | |
| * Safe shell execution with timeout and non-interactive mode | |
| * Uses Bun.spawn with array args to avoid shell interpolation vulnerabilities | |
| */ | |
| async function run( | |
| command: string[], | |
| timeoutMs: number = 60_000, | |
| cwd?: string | |
| ): Promise<{ success: boolean; stdout: string; stderr: string }> { | |
| try { | |
| const proc = Bun.spawn(command, { | |
| cwd, | |
| stdout: "pipe", | |
| stderr: "pipe", | |
| stdin: "ignore", | |
| env: { | |
| ...process.env, | |
| GIT_TERMINAL_PROMPT: "0", | |
| GIT_SSH_COMMAND: "ssh -oBatchMode=yes -oStrictHostKeyChecking=accept-new", | |
| GIT_PAGER: "cat", | |
| PAGER: "cat", | |
| DEBIAN_FRONTEND: "noninteractive", | |
| NO_COLOR: "1", | |
| TERM: "dumb", | |
| }, | |
| }) | |
| // Set up timeout | |
| const timeoutPromise = new Promise<never>((_, reject) => { | |
| setTimeout(() => { | |
| proc.kill() | |
| reject(new Error(`Command timed out after ${timeoutMs}ms`)) | |
| }, timeoutMs) | |
| }) | |
| const resultPromise = (async () => { | |
| const [stdout, stderr] = await Promise.all([ | |
| new Response(proc.stdout).text(), | |
| new Response(proc.stderr).text(), | |
| ]) | |
| const exitCode = await proc.exited | |
| return { success: exitCode === 0, stdout, stderr } | |
| })() | |
| return await Promise.race([resultPromise, timeoutPromise]) | |
| } catch (error) { | |
| return { | |
| success: false, | |
| stdout: "", | |
| stderr: error instanceof Error ? error.message : String(error), | |
| } | |
| } | |
| } | |
| /** | |
| * Run a shell command string (for complex piping) | |
| * Only used internally for specific cases like base64 encoding | |
| */ | |
| async function runShell( | |
| command: string, | |
| timeoutMs: number = 60_000 | |
| ): Promise<{ success: boolean; stdout: string; stderr: string }> { | |
| try { | |
| const proc = Bun.spawn(["bash", "-c", command], { | |
| stdout: "pipe", | |
| stderr: "pipe", | |
| stdin: "ignore", | |
| env: { | |
| ...process.env, | |
| GIT_TERMINAL_PROMPT: "0", | |
| GIT_SSH_COMMAND: "ssh -oBatchMode=yes -oStrictHostKeyChecking=accept-new", | |
| GIT_PAGER: "cat", | |
| PAGER: "cat", | |
| DEBIAN_FRONTEND: "noninteractive", | |
| NO_COLOR: "1", | |
| TERM: "dumb", | |
| }, | |
| }) | |
| const timeoutPromise = new Promise<never>((_, reject) => { | |
| setTimeout(() => { | |
| proc.kill() | |
| reject(new Error(`Command timed out after ${timeoutMs}ms`)) | |
| }, timeoutMs) | |
| }) | |
| const resultPromise = (async () => { | |
| const [stdout, stderr] = await Promise.all([ | |
| new Response(proc.stdout).text(), | |
| new Response(proc.stderr).text(), | |
| ]) | |
| const exitCode = await proc.exited | |
| return { success: exitCode === 0, stdout, stderr } | |
| })() | |
| return await Promise.race([resultPromise, timeoutPromise]) | |
| } catch (error) { | |
| return { | |
| success: false, | |
| stdout: "", | |
| stderr: error instanceof Error ? error.message : String(error), | |
| } | |
| } | |
| } | |
| async function checkGhCliAvailable(): Promise<boolean> { | |
| if (ghCliAvailable !== null) { | |
| return ghCliAvailable | |
| } | |
| const result = await run(["gh", "auth", "status"], 5_000) | |
| ghCliAvailable = result.success | |
| return ghCliAvailable | |
| } | |
| async function detectExecutionContext(): Promise<ExecutionContext> { | |
| const env = process.env | |
| if (isGitHubActions()) { | |
| return { | |
| type: "github-actions", | |
| hasOIDC: hasOIDCPermissions(), | |
| hasGhCli: false, | |
| hasGitHubToken: !!(env.GH_TOKEN || env.GITHUB_TOKEN), | |
| } | |
| } | |
| if (isInteractive()) { | |
| return { | |
| type: "interactive", | |
| hasOIDC: false, | |
| hasGhCli: await checkGhCliAvailable(), | |
| hasGitHubToken: !!(env.GH_TOKEN || env.GITHUB_TOKEN), | |
| } | |
| } | |
| return { | |
| type: "non-interactive", | |
| hasOIDC: false, | |
| hasGhCli: await checkGhCliAvailable(), | |
| hasGitHubToken: !!(env.GH_TOKEN || env.GITHUB_TOKEN), | |
| } | |
| } | |
| async function getGhCliToken(): Promise<string | null> { | |
| const result = await run(["gh", "auth", "token"], 5_000) | |
| return result.success ? result.stdout.trim() : null | |
| } | |
| async function getTokenViaOIDC(owner: string, repo: string): Promise<{ token: string } | { error: string }> { | |
| try { | |
| const tokenUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL | |
| const tokenRequestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN | |
| const oidcUrl = `${tokenUrl}&audience=opencode-github-action` | |
| const oidcResponse = await fetch(oidcUrl, { | |
| headers: { Authorization: `Bearer ${tokenRequestToken}` }, | |
| }) | |
| if (!oidcResponse.ok) { | |
| return { error: `Failed to get OIDC token: ${oidcResponse.statusText}` } | |
| } | |
| const { value: oidcToken } = (await oidcResponse.json()) as { value: string } | |
| const oidcBaseUrl = process.env.OIDC_BASE_URL | |
| if (!oidcBaseUrl) { | |
| return { | |
| error: | |
| "OIDC_BASE_URL environment variable not set. Ensure the workflow passes oidc_base_url to the OpenCode action.", | |
| } | |
| } | |
| const exchangeResponse = await fetch(`${oidcBaseUrl}/exchange_github_app_token_for_repo`, { | |
| method: "POST", | |
| headers: { | |
| Authorization: `Bearer ${oidcToken}`, | |
| "Content-Type": "application/json", | |
| }, | |
| body: JSON.stringify({ owner, repo }), | |
| }) | |
| if (!exchangeResponse.ok) { | |
| const errorBody = await exchangeResponse.text() | |
| if (exchangeResponse.status === 401) { | |
| return { | |
| error: `Authentication failed for ${owner}/${repo}. Ensure the Bonk GitHub App is installed on the target repository.`, | |
| } | |
| } | |
| return { error: `Failed to get installation token: ${errorBody}` } | |
| } | |
| const { token } = (await exchangeResponse.json()) as { token: string } | |
| return { token } | |
| } catch (error) { | |
| const message = error instanceof Error ? error.message : String(error) | |
| return { error: `OIDC token exchange failed: ${message}` } | |
| } | |
| } | |
| async function getTargetRepoToken(owner: string, repo: string): Promise<{ token: string } | { error: string }> { | |
| const context = await detectExecutionContext() | |
| if (context.type === "github-actions") { | |
| if (context.hasOIDC) { | |
| return await getTokenViaOIDC(owner, repo) | |
| } | |
| if (context.hasGitHubToken) { | |
| const envToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN | |
| return { token: envToken! } | |
| } | |
| return { | |
| error: | |
| "In GitHub Actions but no authentication available. Add 'id-token: write' permission for OIDC, or set GITHUB_TOKEN.", | |
| } | |
| } | |
| if (context.hasGhCli) { | |
| const ghToken = await getGhCliToken() | |
| if (ghToken) { | |
| return { token: ghToken } | |
| } | |
| } | |
| if (context.hasGitHubToken) { | |
| const envToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN | |
| return { token: envToken! } | |
| } | |
| const contextHints: Record<ExecutionContextType, string> = { | |
| "github-actions": "Add 'id-token: write' permission or set GITHUB_TOKEN.", | |
| interactive: "Run 'gh auth login' to authenticate, or set GH_TOKEN/GITHUB_TOKEN.", | |
| "non-interactive": "Set GH_TOKEN/GITHUB_TOKEN, or ensure 'gh auth login' was run.", | |
| } | |
| return { | |
| error: `No authentication available (context: ${context.type}). ${contextHints[context.type]}`, | |
| } | |
| } | |
| // Operation implementations | |
| async function cloneRepo( | |
| sessionID: string, | |
| owner: string, | |
| repo: string, | |
| branch?: string | |
| ): Promise<{ success: boolean; path?: string; defaultBranch?: string; error?: string }> { | |
| const repoKey = getRepoKey(sessionID, owner, repo) | |
| if (clonedRepos.has(repoKey)) { | |
| const state = clonedRepos.get(repoKey)! | |
| log(sessionID, "clone", `repo=${owner}/${repo} already_cloned=true path=${state.path}`) | |
| return { | |
| success: true, | |
| path: state.path, | |
| defaultBranch: state.defaultBranch, | |
| } | |
| } | |
| const tokenResult = await getTargetRepoToken(owner, repo) | |
| if ("error" in tokenResult) { | |
| logError(sessionID, "clone", tokenResult.error) | |
| return { success: false, error: tokenResult.error } | |
| } | |
| const clonePath = getClonePath(sessionID, owner, repo) | |
| await run(["mkdir", "-p", `${tmpdir()}/${sessionID}`]) | |
| const cloneUrl = `https://x-access-token:${tokenResult.token}@github.com/${owner}/${repo}.git` | |
| await run(["rm", "-rf", clonePath]) | |
| const cloneArgs = ["git", "clone", "--depth", "1"] | |
| if (branch) { | |
| cloneArgs.push("--branch", branch) | |
| } | |
| cloneArgs.push(cloneUrl, clonePath) | |
| const cloneResult = await run(cloneArgs) | |
| if (!cloneResult.success) { | |
| logError(sessionID, "clone", cloneResult.stderr) | |
| return { success: false, error: `Clone failed: ${cloneResult.stderr}` } | |
| } | |
| const defaultBranchResult = await run(["git", "rev-parse", "--abbrev-ref", "HEAD"], 10_000, clonePath) | |
| const defaultBranch = defaultBranchResult.stdout.trim() || "main" | |
| await run(["git", "config", "user.email", "bonk[bot]@users.noreply.github.com"], 10_000, clonePath) | |
| await run(["git", "config", "user.name", "bonk[bot]"], 10_000, clonePath) | |
| clonedRepos.set(repoKey, { | |
| path: clonePath, | |
| token: tokenResult.token, | |
| defaultBranch, | |
| }) | |
| log(sessionID, "clone", `repo=${owner}/${repo} path=${clonePath} defaultBranch=${defaultBranch}`) | |
| return { success: true, path: clonePath, defaultBranch } | |
| } | |
| async function createBranch( | |
| sessionID: string, | |
| repoPath: string, | |
| branchName: string | |
| ): Promise<{ success: boolean; branch?: string; error?: string }> { | |
| const result = await run(["git", "checkout", "-b", branchName], 30_000, repoPath) | |
| if (!result.success) { | |
| const checkoutResult = await run(["git", "checkout", branchName], 30_000, repoPath) | |
| if (!checkoutResult.success) { | |
| logError(sessionID, "branch", result.stderr) | |
| return { success: false, error: `Failed to create/checkout branch: ${result.stderr}` } | |
| } | |
| } | |
| log(sessionID, "branch", `branch=${branchName}`) | |
| return { success: true, branch: branchName } | |
| } | |
| async function commitChanges( | |
| sessionID: string, | |
| repoPath: string, | |
| message: string | |
| ): Promise<{ success: boolean; commit?: string; error?: string }> { | |
| const addResult = await run(["git", "add", "-A"], 30_000, repoPath) | |
| if (!addResult.success) { | |
| logError(sessionID, "commit", addResult.stderr) | |
| return { success: false, error: `Failed to stage changes: ${addResult.stderr}` } | |
| } | |
| const statusResult = await run(["git", "status", "--porcelain"], 10_000, repoPath) | |
| if (!statusResult.stdout.trim()) { | |
| return { success: false, error: "No changes to commit" } | |
| } | |
| const commitResult = await run(["git", "commit", "-m", message], 30_000, repoPath) | |
| if (!commitResult.success) { | |
| logError(sessionID, "commit", commitResult.stderr) | |
| return { success: false, error: `Failed to commit: ${commitResult.stderr}` } | |
| } | |
| const shaResult = await run(["git", "rev-parse", "HEAD"], 10_000, repoPath) | |
| const commit = shaResult.stdout.trim() | |
| log(sessionID, "commit", `commit=${commit}`) | |
| return { success: true, commit } | |
| } | |
| async function pushBranch( | |
| sessionID: string, | |
| repoPath: string, | |
| token: string | |
| ): Promise<{ success: boolean; error?: string }> { | |
| const branchResult = await run(["git", "rev-parse", "--abbrev-ref", "HEAD"], 10_000, repoPath) | |
| const branch = branchResult.stdout.trim() | |
| const remoteResult = await run(["git", "remote", "get-url", "origin"], 10_000, repoPath) | |
| let remoteUrl = remoteResult.stdout.trim() | |
| if (!remoteUrl.includes("x-access-token")) { | |
| remoteUrl = remoteUrl.replace("https://", `https://x-access-token:${token}@`) | |
| await run(["git", "remote", "set-url", "origin", remoteUrl], 10_000, repoPath) | |
| } | |
| const pushResult = await run(["git", "push", "-u", "origin", branch], 120_000, repoPath) | |
| if (!pushResult.success) { | |
| logError(sessionID, "push", pushResult.stderr) | |
| return { success: false, error: `Push failed: ${pushResult.stderr}` } | |
| } | |
| log(sessionID, "push", `branch=${branch}`) | |
| return { success: true } | |
| } | |
| async function createPR( | |
| sessionID: string, | |
| repoPath: string, | |
| token: string, | |
| title: string, | |
| body?: string, | |
| base?: string | |
| ): Promise<{ success: boolean; prUrl?: string; prNumber?: number; error?: string }> { | |
| const branchResult = await run(["git", "rev-parse", "--abbrev-ref", "HEAD"], 10_000, repoPath) | |
| const headBranch = branchResult.stdout.trim() | |
| const prArgs = ["gh", "pr", "create", "--title", title, "--body", body || "", "--head", headBranch] | |
| if (base) { | |
| prArgs.push("--base", base) | |
| } | |
| const prResult = await run(prArgs, 60_000, repoPath) | |
| if (!prResult.success) { | |
| logError(sessionID, "pr", prResult.stderr) | |
| return { success: false, error: `PR creation failed: ${prResult.stderr}` } | |
| } | |
| const prUrl = prResult.stdout.trim() | |
| const prNumberMatch = prUrl.match(/\/pull\/(\d+)/) | |
| const prNumber = prNumberMatch ? parseInt(prNumberMatch[1], 10) : undefined | |
| log(sessionID, "pr", `url=${prUrl}`) | |
| return { success: true, prUrl, prNumber } | |
| } | |
| async function readFile( | |
| sessionID: string, | |
| repoPath: string, | |
| filePath: string | |
| ): Promise<{ success: boolean; content?: string; error?: string }> { | |
| const fullPath = `${repoPath}/${filePath}`.replace(/\/+/g, "/") | |
| if (!fullPath.startsWith(repoPath)) { | |
| return { success: false, error: "Invalid path: path traversal detected" } | |
| } | |
| const result = await run(["cat", fullPath]) | |
| if (!result.success) { | |
| logError(sessionID, "read", result.stderr) | |
| return { success: false, error: `Failed to read file: ${result.stderr}` } | |
| } | |
| log(sessionID, "read", `path=${filePath}`) | |
| return { success: true, content: result.stdout } | |
| } | |
| async function writeFile( | |
| sessionID: string, | |
| repoPath: string, | |
| filePath: string, | |
| content: string | |
| ): Promise<{ success: boolean; error?: string }> { | |
| const fullPath = `${repoPath}/${filePath}`.replace(/\/+/g, "/") | |
| if (!fullPath.startsWith(repoPath)) { | |
| return { success: false, error: "Invalid path: path traversal detected" } | |
| } | |
| const dirPath = fullPath.substring(0, fullPath.lastIndexOf("/")) | |
| await run(["mkdir", "-p", dirPath]) | |
| // Use base64 encoding to safely pass arbitrary content | |
| const base64Content = Buffer.from(content).toString("base64") | |
| const result = await runShell(`echo ${shellEscape(base64Content)} | base64 -d > ${shellEscape(fullPath)}`) | |
| if (!result.success) { | |
| logError(sessionID, "write", result.stderr) | |
| return { success: false, error: `Failed to write file: ${result.stderr}` } | |
| } | |
| log(sessionID, "write", `path=${filePath}`) | |
| return { success: true } | |
| } | |
| async function listFiles( | |
| sessionID: string, | |
| repoPath: string, | |
| subPath?: string | |
| ): Promise<{ success: boolean; files?: string[]; error?: string }> { | |
| const targetPath = subPath ? `${repoPath}/${subPath}`.replace(/\/+/g, "/") : repoPath | |
| if (!targetPath.startsWith(repoPath)) { | |
| return { success: false, error: "Invalid path: path traversal detected" } | |
| } | |
| const result = await runShell(`find ${shellEscape(targetPath)} -type f ! -path '*/.git/*' | sed 's|^${repoPath}/||'`) | |
| if (!result.success) { | |
| logError(sessionID, "list", result.stderr) | |
| return { success: false, error: `Failed to list files: ${result.stderr}` } | |
| } | |
| const files = result.stdout.trim().split("\n").filter(Boolean) | |
| log(sessionID, "list", `path=${subPath || "/"} count=${files.length}`) | |
| return { success: true, files } | |
| } | |
| async function execCommand( | |
| sessionID: string, | |
| repoPath: string, | |
| command: string | |
| ): Promise<{ success: boolean; stdout?: string; stderr?: string; error?: string }> { | |
| const result = await runShell(`cd ${shellEscape(repoPath)} && ${command}`) | |
| log(sessionID, "exec", `command=${command.substring(0, 50)}...`) | |
| return { | |
| success: result.success, | |
| stdout: result.stdout, | |
| stderr: result.stderr, | |
| error: result.success ? undefined : result.stderr, | |
| } | |
| } | |
| export default tool({ | |
| description: `Operate on GitHub repositories other than the current working repository. | |
| Use this tool when you need to: | |
| - Clone and make changes to a different repository (e.g. "also update the docs repo") | |
| - Create coordinated changes across multiple repos (e.g. "update the SDK and the examples repo") | |
| - Open PRs in related repositories based on changes in the current repo | |
| - Summarize changes from the current repo and apply related changes to another repo | |
| The tool handles authentication automatically based on execution context: | |
| **GitHub Actions**: Uses OIDC token exchange (requires id-token: write permission), falls back to GITHUB_TOKEN env var. | |
| **Interactive** (terminal): Uses gh CLI (supports OAuth flow), falls back to GH_TOKEN/GITHUB_TOKEN env var. | |
| **Non-interactive** (CI, sandbox, scripts): Uses gh CLI if authenticated, falls back to GH_TOKEN/GITHUB_TOKEN env var. | |
| ## Supported Operations | |
| - **clone**: Shallow clone a repo to {tmpdir}/{sessionID}/{owner}-{repo}. Returns the local path. Session-scoped paths prevent concurrent agents from clobbering each other. | |
| - **read**: Read a file from the cloned repo (path relative to repo root). | |
| - **write**: Write content to a file in the cloned repo (path relative to repo root). | |
| - **list**: List files in the cloned repo (optionally under a subpath). | |
| - **branch**: Create and checkout a new branch from the default branch. | |
| - **commit**: Stage all changes and commit with a message. | |
| - **push**: Push the current branch to remote. | |
| - **pr**: Create a pull request using gh CLI. IMPORTANT: Always include a meaningful PR body/description via the 'message' parameter. | |
| - **exec**: Run arbitrary shell commands in the cloned repo directory. | |
| ## Typical Workflow | |
| 1. clone the target repo | |
| 2. Use read/write/list operations to view and modify files | |
| 3. branch to create a feature branch | |
| 4. commit your changes | |
| 5. push the branch | |
| 6. pr to create a pull request with a descriptive body (use message parameter with markdown formatting) | |
| ## Prerequisites (GitHub Actions mode) | |
| - The Bonk GitHub App must be installed on the target repository | |
| - The workflow must have 'id-token: write' permission | |
| - The target repo must be in the same org as the source repo | |
| - The actor must have write access to the target repository | |
| ## Prerequisites (local/CI/other environments) | |
| - Authenticated via 'gh auth login' with appropriate permissions, or | |
| - GH_TOKEN/GITHUB_TOKEN env var set with appropriate permissions | |
| ## Security | |
| In GitHub Actions, the token is scoped to only the target repository with minimal permissions (contents:write, pull_requests:write, issues:write).`, | |
| args: { | |
| owner: tool.schema.string().describe("Repository owner (org or user)"), | |
| repo: tool.schema.string().describe("Repository name"), | |
| operation: tool.schema | |
| .enum(["clone", "branch", "commit", "push", "pr", "exec", "read", "write", "list"]) | |
| .describe("Operation to perform on the target repository"), | |
| branch: tool.schema | |
| .string() | |
| .optional() | |
| .describe("Branch name for 'branch' operation, or specific branch to clone for 'clone'"), | |
| message: tool.schema | |
| .string() | |
| .optional() | |
| .describe( | |
| "Commit message for 'commit' operation. For 'pr' operation, this is the PR body/description - include a meaningful summary of changes (use markdown with ## Summary header)." | |
| ), | |
| title: tool.schema.string().optional().describe("PR title for 'pr' operation"), | |
| base: tool.schema.string().optional().describe("Base branch for PR (defaults to repo's default branch)"), | |
| command: tool.schema.string().optional().describe("Shell command to execute for 'exec' operation"), | |
| path: tool.schema.string().optional().describe("File path for 'read', 'write', or 'list' operations (relative to repo root)"), | |
| content: tool.schema.string().optional().describe("File content for 'write' operation"), | |
| }, | |
| async execute(args, ctx: ToolContext) { | |
| const { sessionID } = ctx | |
| const repoKey = getRepoKey(sessionID, args.owner, args.repo) | |
| const stringify = (result: object) => JSON.stringify(result) | |
| try { | |
| // Validate owner/repo names | |
| const ownerError = validateRepoName(args.owner, "owner") | |
| if (ownerError) { | |
| return stringify({ success: false, error: ownerError }) | |
| } | |
| const repoError = validateRepoName(args.repo, "repo") | |
| if (repoError) { | |
| return stringify({ success: false, error: repoError }) | |
| } | |
| // Validate branch name if provided | |
| if (args.branch) { | |
| const branchError = validateBranchName(args.branch) | |
| if (branchError) { | |
| return stringify({ success: false, error: branchError }) | |
| } | |
| } | |
| switch (args.operation) { | |
| case "clone": | |
| return stringify(await cloneRepo(sessionID, args.owner, args.repo, args.branch)) | |
| case "branch": { | |
| const state = clonedRepos.get(repoKey) | |
| if (!state) { | |
| return stringify({ | |
| success: false, | |
| error: `Repository ${args.owner}/${args.repo} not cloned. Run clone operation first.`, | |
| }) | |
| } | |
| if (!args.branch) { | |
| return stringify({ success: false, error: "Branch name required for 'branch' operation" }) | |
| } | |
| return stringify(await createBranch(sessionID, state.path, args.branch)) | |
| } | |
| case "commit": { | |
| const state = clonedRepos.get(repoKey) | |
| if (!state) { | |
| return stringify({ | |
| success: false, | |
| error: `Repository ${args.owner}/${args.repo} not cloned. Run clone operation first.`, | |
| }) | |
| } | |
| if (!args.message) { | |
| return stringify({ success: false, error: "Commit message required for 'commit' operation" }) | |
| } | |
| return stringify(await commitChanges(sessionID, state.path, args.message)) | |
| } | |
| case "push": { | |
| const state = clonedRepos.get(repoKey) | |
| if (!state) { | |
| return stringify({ | |
| success: false, | |
| error: `Repository ${args.owner}/${args.repo} not cloned. Run clone operation first.`, | |
| }) | |
| } | |
| return stringify(await pushBranch(sessionID, state.path, state.token)) | |
| } | |
| case "pr": { | |
| const state = clonedRepos.get(repoKey) | |
| if (!state) { | |
| return stringify({ | |
| success: false, | |
| error: `Repository ${args.owner}/${args.repo} not cloned. Run clone operation first.`, | |
| }) | |
| } | |
| if (!args.title) { | |
| return stringify({ success: false, error: "PR title required for 'pr' operation" }) | |
| } | |
| return stringify(await createPR(sessionID, state.path, state.token, args.title, args.message, args.base || state.defaultBranch)) | |
| } | |
| case "exec": { | |
| const state = clonedRepos.get(repoKey) | |
| if (!state) { | |
| return stringify({ | |
| success: false, | |
| error: `Repository ${args.owner}/${args.repo} not cloned. Run clone operation first.`, | |
| }) | |
| } | |
| if (!args.command) { | |
| return stringify({ success: false, error: "Command required for 'exec' operation" }) | |
| } | |
| return stringify(await execCommand(sessionID, state.path, args.command)) | |
| } | |
| case "read": { | |
| const state = clonedRepos.get(repoKey) | |
| if (!state) { | |
| return stringify({ | |
| success: false, | |
| error: `Repository ${args.owner}/${args.repo} not cloned. Run clone operation first.`, | |
| }) | |
| } | |
| if (!args.path) { | |
| return stringify({ success: false, error: "Path required for 'read' operation" }) | |
| } | |
| return stringify(await readFile(sessionID, state.path, args.path)) | |
| } | |
| case "write": { | |
| const state = clonedRepos.get(repoKey) | |
| if (!state) { | |
| return stringify({ | |
| success: false, | |
| error: `Repository ${args.owner}/${args.repo} not cloned. Run clone operation first.`, | |
| }) | |
| } | |
| if (!args.path) { | |
| return stringify({ success: false, error: "Path required for 'write' operation" }) | |
| } | |
| if (args.content === undefined) { | |
| return stringify({ success: false, error: "Content required for 'write' operation" }) | |
| } | |
| return stringify(await writeFile(sessionID, state.path, args.path, args.content)) | |
| } | |
| case "list": { | |
| const state = clonedRepos.get(repoKey) | |
| if (!state) { | |
| return stringify({ | |
| success: false, | |
| error: `Repository ${args.owner}/${args.repo} not cloned. Run clone operation first.`, | |
| }) | |
| } | |
| return stringify(await listFiles(sessionID, state.path, args.path)) | |
| } | |
| default: | |
| return stringify({ success: false, error: `Unknown operation: ${args.operation}` }) | |
| } | |
| } catch (error) { | |
| // Catch-all for any unhandled errors - never crash OpenCode | |
| const message = error instanceof Error ? error.message : String(error) | |
| logError(sessionID, args.operation, message) | |
| return stringify({ success: false, error: `Unexpected error: ${message}` }) | |
| } | |
| }, | |
| }) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment