diff --git a/plugins/catenary-opencode/catenary.mjs b/plugins/catenary-opencode/catenary.mjs new file mode 100755 index 0000000..78974e8 --- /dev/null +++ b/plugins/catenary-opencode/catenary.mjs @@ -0,0 +1,558 @@ +/** + * Catenary LSP Integration for OpenCode + * + * Provides LSP-powered code intelligence: + * - Watches file edits and runs LSP diagnostics after each write + * - Exposes `catenary-grep` / `catenary-glob` for semantic symbol search + * - Tracks editing session and cleans up on session end + * + * Platform notes: + * - On Unix/Linux/macOS with the daemon running: full LSP diagnostics, grep, glob + * - On Windows: daemon is not yet supported; grep/glob/diagnostics fall back to + * `catenary doctor` health-check only. The `catenary-doctor` tool always works. + * - Set `CATENARY_BIN` env var to override the binary path. + * - Set `CATENARY_WSL=true` to run Catenary from WSL (recommended on Windows + * for full LSP features — requires the patched binary in WSL at + * ~/.cargo/bin/catenary). + * + * Setup: + * 1. Install Catenary: + * Unix: cargo install catenary-mcp + * Windows: cargo install catenary-mcp (or use WSL for full features) + * WSL path: ~/.cargo/bin/catenary (build from catenary-dev with OpenCode patches) + * 2. Configure language servers: ~/.config/catenary/config.toml + * 3. Place this file in: ~/.config/opencode/plugin/catenary.mjs + * 4. Restart OpenCode + * 5. Verify: opencode → /doctor or run `catenary-doctor` + */ + +import { spawn } from "node:child_process"; +import { resolve } from "node:path"; +import { homedir } from "node:os"; + +// ── Catenary binary resolution ───────────────────────────────────────────────── + +/** Resolved Catenary binary path. Respects CATENARY_BIN env var. */ +function resolveCatenaryBin() { + if (process.env.CATENARY_BIN) return process.env.CATENARY_BIN; + + const home = homedir(); + + // WSL mode: run the patched Linux binary from WSL + // Set CATENARY_WSL=true in your environment to enable this. + if (process.env.CATENARY_WSL === "true" || process.env.CATENARY_WSL === "1") { + return resolve(home, ".cargo", "bin", "catenary"); + } + + if (process.platform === "win32") { + return resolve(home, ".cargo", "bin", "catenary.exe"); + } + return resolve(home, ".cargo", "bin", "catenary"); +} + +const CATENARY_BIN = resolveCatenaryBin(); + +/** Whether we are running via WSL. */ +const IS_WSL = process.env.CATENARY_WSL === "true" || process.env.CATENARY_WSL === "1"; + +/** Whether the current platform supports Catenary daemon (Unix only). */ +const SUPPORTS_DAEMON = process.platform !== "win32" || IS_WSL; + +// ── State ───────────────────────────────────────────────────────────────────── +const editedFiles = new Set(); +let sessionRegistered = false; +let daemonAvailable = null; // null = unknown, true = up, false = down + +// ── Subprocess runner ───────────────────────────────────────────────────────── + +/** + * Run a Catenary CLI command and return stdout. + * On WSL: prepends "wsl" to run the command in the Linux environment. + * Silently returns null on any error — plugins must never break OpenCode's flow. + */ +async function runCatenary(args, options = {}) { + const useWsl = IS_WSL; + const cmd = useWsl ? "wsl" : CATENARY_BIN; + const cmdArgs = useWsl ? [CATENARY_BIN, ...args] : args; + + return new Promise((resolve) => { + const proc = spawn(cmd, cmdArgs, { timeout: 30_000, ...options }); + let stdout = ""; + let stderr = ""; + + proc.stdout?.on("data", (d) => (stdout += String(d))); + proc.stderr?.on("data", (d) => (stderr += String(d))); + + proc.on("close", (code) => { + if (code === 0) { + resolve(stdout.trim() || null); + } else { + if (stderr) { + // Trim noisy output — only surface the first line + const firstLine = stderr.split("\n")[0]; + console.error(`[catenary] ${args[0]} exited ${code}: ${firstLine.slice(0, 160)}`); + } + resolve(null); + } + }); + + proc.on("error", (err) => { + console.error(`[catenary] failed to spawn${useWsl ? " WSL " + CATENARY_BIN : " " + CATENARY_BIN}: ${err.message}`); + resolve(null); + }); + }); +} + +/** + * Send a hook request to the Catenary daemon via IPC. + * Uses `catenary hook --format=claude` (stdin/stdout). + * Returns null on failure (plugin must not break host flow). + */ +async function catenaryHookIpc(method, extraFields = {}) { + const cwd = process.cwd(); + const payload = { + method, + format: "claude", + cwd, + session_id: null, + ...extraFields, + host_payload: { + tool_name: extraFields.tool_name || null, + cwd, + }, + }; + + const hookSubcommand = methodToHookSubcommand(method); + if (!hookSubcommand) return null; + + const args = ["hook", hookSubcommand, "--format=claude"]; + const cmd = IS_WSL ? "wsl" : CATENARY_BIN; + const cmdArgs = IS_WSL ? [CATENARY_BIN, ...args] : args; + + return new Promise((resolve) => { + const proc = spawn(cmd, cmdArgs, { timeout: 30_000 }); + let stdout = ""; + let stderr = ""; + + proc.stdout?.on("data", (d) => (stdout += String(d))); + proc.stderr?.on("data", (d) => (stderr += String(d))); + + proc.on("close", (code) => { + if (code === 0 && stdout.trim()) { + try { + resolve(JSON.parse(stdout.trim())); + } catch { + resolve(null); + } + } else { + if (stderr) { + const firstLine = stderr.split("\n")[0]; + console.error(`[catenary] hook ${hookSubcommand} error: ${firstLine.slice(0, 160)}`); + } + resolve(null); + } + }); + + proc.on("error", (err) => { + console.error(`[catenary] hook IPC error: ${err.message}`); + resolve(null); + }); + + proc.stdin?.write(JSON.stringify(payload) + "\n"); + proc.stdin?.end(); + }); +} + +function methodToHookSubcommand(method) { + const map = { + "session-start/clear-editing": "session-start", + "session-end/cleanup": "session-end", + "pre-agent/turn-start": "pre-agent", + "pre-tool/editing-state": "pre-tool", + "post-agent/require-release": "post-agent", + "pre-tool/editing-start": "pre-tool", + "pre-tool/editing-stop": "pre-tool", + }; + return map[method] || null; +} + +// ── Editing lifecycle ────────────────────────────────────────────────────────── + +async function trackEdit(filePath) { + const absPath = resolve(filePath); + editedFiles.add(absPath); + + if (!sessionRegistered && SUPPORTS_DAEMON) { + sessionRegistered = true; + await catenaryHookIpc("session-start/clear-editing"); + } +} + +async function endSession() { + if (sessionRegistered) { + await catenaryHookIpc("session-end/cleanup"); + sessionRegistered = false; + } + editedFiles.clear(); +} + +// ── Tool name detection ─────────────────────────────────────────────────────── + +const FILE_WRITE_TOOLS = new Set([ + "write", "write_file", "edit", "edit_file", "patch_file", + "replace_in_file", "insert_content_at", "multi_replace_file", + "Write", "Edit", "PatchFile", "ReplaceInFile", +]); + +function isFileWriteTool(toolName) { + return FILE_WRITE_TOOLS.has(toolName?.toLowerCase()); +} + +function extractFilePath(toolName, args) { + if (!args || typeof args !== "object") return null; + + const keys = ["file_path", "filePath", "path", "file", "target", "target_file", "targetPath", "filePath"]; + for (const key of keys) { + const val = args[key]; + if (typeof val === "string" && val.length > 0 && val.length < 4096) return val; + } + + if (Array.isArray(args)) { + for (const item of args) { + if (typeof item === "object" && item !== null) { + for (const key of keys) { + const val = item[key]; + if (typeof val === "string" && val.length > 0) return val; + } + } + } + } + return null; +} + +// ── Plugin export ───────────────────────────────────────────────────────────── + +export default async function CatenaryPlugin(ctx) { + const platformNote = IS_WSL + ? "Running via WSL (full LSP features enabled)" + : SUPPORTS_DAEMON + ? "Unix platform (full daemon support)" + : "Windows platform (daemon not yet supported — LSP grep/glob/diagnostics require WSL or Unix)"; + + console.error(`[catenary] Catenary LSP plugin loaded | ${platformNote}`); + console.error(`[catenary] binary: ${CATENARY_BIN}`); + + return { + hooks: { + "session.created": async () => { + console.error("[catenary] session started"); + daemonAvailable = null; + sessionRegistered = false; + editedFiles.clear(); + }, + + "session.deleted": async () => { + await endSession(); + console.error("[catenary] session ended"); + }, + + "session.compacted": async () => { + console.error("[catenary] session compacted (LSP session alive, editedFiles preserved)"); + }, + + "tool.execute.before": async (ctx, input) => { + const toolName = ctx?.tool; + if (!toolName) return; + + if (isFileWriteTool(toolName)) { + const filePath = extractFilePath(toolName, ctx?.input || input); + if (filePath) { + await trackEdit(filePath); + } + } + }, + + "tool.execute.after": async (ctx, result) => { + const toolName = ctx?.tool; + if (!toolName) return; + + if (result?.error || result?.content?.[0]?.is_error) return; + + if (isFileWriteTool(toolName) && editedFiles.size > 0 && SUPPORTS_DAEMON) { + if (daemonAvailable === null) { + const health = await runCatenary(["doctor", "--json"]).catch(() => null); + daemonAvailable = health !== null; + } + + if (daemonAvailable) { + const diagResult = await runCatenary(["hook", "post-tool", "--format=claude"]).catch(() => null); + if (diagResult && diagResult.systemMessage) { + console.error(`[catenary] ${diagResult.systemMessage.split("\n").slice(0, 2).join(" | ")}`); + } + } + } + }, + + "message.created": async (ctx) => { + const text = String(ctx?.message || "").toLowerCase(); + if (text.includes("catenary") || text.includes("lsp") || text.includes("diagnostic")) { + console.error( + `[catenary] LSP plugin active. ` + + `Files tracked: ${editedFiles.size}. ` + + `Platform: ${platformNote}. ` + + `Run 'catenary-doctor' to check LSP server health.` + ); + } + }, + }, + + tool: { + /** + * LSP-powered semantic search using language-server symbol context. + * Requires Catenary daemon on Unix, or WSL mode on Windows. + * Falls back to plain-text doctor output on Windows without WSL. + */ + "catenary-grep": { + description: + "LSP-powered semantic search. Searches with symbol context from LSP servers " + + "(type signatures, definitions, cross-file references). " + + "Requires Catenary daemon (Unix) or CATENARY_WSL=true (Windows with WSL binary).", + args: { + pattern: { + type: "string", + description: "Regex pattern to search for (Rust/PCRE syntax). Use | for alternation.", + required: true, + }, + scope: { + type: "string", + description: "File or directory path to search within. Defaults to current working directory.", + required: false, + }, + }, + async execute({ pattern, scope }, context) { + const args = ["grep", pattern]; + if (scope) args.push(scope); + + const result = await runCatenary(args, { + cwd: context?.directory || process.cwd(), + }); + + if (result === null) { + if (!SUPPORTS_DAEMON && !IS_WSL) { + return { + content: [{ + type: "text", + text: + "[catenary-grep] Daemon not available on Windows without WSL.\n" + + "Set CATENARY_WSL=true in your environment and ensure the patched\n" + + "Catenary binary is at ~/.cargo/bin/catenary in WSL.\n" + + "Alternatively, run 'catenary-doctor' to at least check LSP server health.", + }], + }; + } + return { + content: [{ + type: "text", + text: + "[catenary-grep] No results or Catenary daemon unavailable.\n" + + "Run 'catenary-doctor' to check LSP server health.", + }], + }; + } + + return { content: [{ type: "text", text: result }] }; + }, + }, + + /** + * Browse files with LSP symbol outlines. + * Requires Catenary daemon on Unix, or WSL mode on Windows. + */ + "catenary-glob": { + description: + "Browse files with LSP document-symbol outlines (functions, classes, exports per file). " + + "Much more informative than ls. Requires Catenary daemon (Unix) or CATENARY_WSL=true (Windows).", + args: { + paths: { + type: "string", + description: "File or directory path(s). Supports glob patterns.", + required: true, + }, + }, + async execute({ paths }, context) { + const result = await runCatenary(["glob", paths], { + cwd: context?.directory || process.cwd(), + }); + + if (result === null) { + if (!SUPPORTS_DAEMON && !IS_WSL) { + return { + content: [{ + type: "text", + text: + "[catenary-glob] Daemon not available on Windows without WSL.\n" + + "Set CATENARY_WSL=true to enable full LSP features via WSL.", + }], + }; + } + return { + content: [{ + type: "text", + text: "[catenary-glob] No results or daemon unavailable. Run 'catenary-doctor' to diagnose.", + }], + }; + } + return { content: [{ type: "text", text: result }] }; + }, + }, + + /** + * LSP-aware find-and-replace across files. + * Preview mode shows per-file match counts. Use --in-place to apply. + */ + "catenary-sed": { + description: + "LSP-aware find-and-replace across files. Preview mode shows per-file match counts.\n" + + "Use --in-place to apply. Requires daemon (Unix) or CATENARY_WSL=true (Windows).", + args: { + pattern: { type: "string", description: "Regex pattern to match.", required: true }, + replacement: { type: "string", description: "Replacement text. Use $1, $2... for capture groups.", required: true }, + paths: { type: "string", description: "File or directory path(s).", required: true }, + in_place: { type: "boolean", description: "If true, apply edits. If false (default), show preview only.", required: false }, + }, + async execute({ pattern, replacement, paths, in_place }, context) { + const args = ["sed", pattern, replacement, paths]; + if (in_place) args.push("--in-place"); + + const result = await runCatenary(args, { + cwd: context?.directory || process.cwd(), + }); + + if (result === null) { + if (!SUPPORTS_DAEMON && !IS_WSL) { + return { + content: [{ + type: "text", + text: + "[catenary-sed] Daemon not available on Windows without WSL.\n" + + "Set CATENARY_WSL=true to enable full LSP features via WSL.", + }], + }; + } + return { + content: [{ + type: "text", + text: "[catenary-sed] Failed. Run 'catenary-doctor' to diagnose.", + }], + }; + } + return { content: [{ type: "text", text: result }] }; + }, + }, + + /** + * Print LSP diagnostics for all files edited in this session. + * Call after making several edits to see errors and warnings across all touched files. + */ + "catenary-diagnostics": { + description: + "Print LSP errors and warnings for all files edited in this session.\n" + + "Automatically tracks file-write tools. Requires daemon (Unix) or CATENARY_WSL=true (Windows).", + args: {}, + async execute({}, context) { + if (editedFiles.size === 0) { + return { + content: [{ + type: "text", + text: "[catenary-diagnostics] No files tracked yet in this session.\nEdit some files first, then run this tool.", + }], + }; + } + + const result = await runCatenary(["diagnostics"]); + + if (result === null) { + if (!SUPPORTS_DAEMON && !IS_WSL) { + return { + content: [{ + type: "text", + text: + "[catenary-diagnostics] Daemon not available on Windows without WSL.\n" + + `Files tracked: ${Array.from(editedFiles).join(", ")}\n` + + "Set CATENARY_WSL=true to enable full LSP features via WSL.", + }], + }; + } + return { + content: [{ + type: "text", + text: + "[catenary-diagnostics] Catenary daemon unavailable.\n" + + `Files tracked: ${Array.from(editedFiles).join(", ")}`, + }], + }; + } + return { content: [{ type: "text", text: result }] }; + }, + }, + + /** + * Health-check for Catenary LSP integration. + * Always works — verifies LSP servers are installed and responding. + * This is the most reliable tool on Windows (no daemon required). + */ + "catenary-doctor": { + description: + "Run Catenary health check. Verifies all configured LSP servers are installed\n" + + "and responding. Always works on all platforms. Use when LSP features seem broken.", + args: {}, + async execute({}, context) { + const result = await runCatenary( + ["doctor", "--json"], + { cwd: context?.directory || process.cwd() } + ); + + if (result === null) { + const textResult = await runCatenary(["doctor"]); + if (textResult === null) { + return { + content: [{ + type: "text", + text: + `[catenary-doctor] Catenary not found or not working.\n` + + `Expected binary at: ${CATENARY_BIN}\n` + + `Install: cargo install catenary-mcp\n` + + `WSL mode: set CATENARY_WSL=true and ensure ~/.cargo/bin/catenary exists in WSL\n` + + `Docs: https://twowells.github.io/Catenary/`, + }], + }; + } + return { content: [{ type: "text", text: textResult }] }; + } + + try { + const health = JSON.parse(result); + const lines = []; + for (const [lang, info] of Object.entries(health)) { + if (info && typeof info === "object") { + const status = info.ok !== false ? "✓" : "✗"; + const name = info.name || lang; + lines.push(` ${status} ${lang}: ${name}`); + } + } + return { + content: [{ + type: "text", + text: + `[catenary-doctor] LSP server health:\n` + + (lines.length > 0 ? lines.join("\n") : ` (raw: ${result})`), + }], + }; + } catch { + return { content: [{ type: "text", text: result }] }; + } + }, + }, + }, + }; +} diff --git a/src/cli/hooks.rs b/src/cli/hooks.rs index 812ae73..7818473 100644 --- a/src/cli/hooks.rs +++ b/src/cli/hooks.rs @@ -125,6 +125,11 @@ fn format_deny(reason: &str, format: HostFormat) -> String { "reason": reason }) .to_string(), + HostFormat::Opencode => serde_json::json!({ + "decision": "deny", + "reason": reason + }) + .to_string(), } } @@ -146,6 +151,11 @@ fn format_stop_block(reason: &str, format: HostFormat) -> String { "reason": reason }) .to_string(), + HostFormat::Opencode => serde_json::json!({ + "decision": "block", + "reason": reason + }) + .to_string(), } } @@ -156,6 +166,7 @@ fn extract_session_id(hook_json: &serde_json::Value, format: HostFormat) -> Opti match format { HostFormat::Claude | HostFormat::Gemini => hook_json.get("session_id"), HostFormat::Antigravity => hook_json.get("conversationId"), + HostFormat::Opencode => hook_json.get("session_id"), } .and_then(|v| v.as_str()) } @@ -171,6 +182,7 @@ fn extract_cwd_str(hook_json: &serde_json::Value, format: HostFormat) -> Option< .and_then(|v| v.as_array()) .and_then(|a| a.first()) .and_then(|v| v.as_str()), + HostFormat::Opencode => hook_json.get("cwd").and_then(|v| v.as_str()), } } @@ -186,6 +198,7 @@ fn extract_tool_name(hook_json: &serde_json::Value, format: HostFormat) -> &str .get("toolCall") .and_then(|tc| tc.get("name")) .and_then(|v| v.as_str()), + HostFormat::Opencode => hook_json.get("tool_name").and_then(|v| v.as_str()), } .unwrap_or("") } @@ -212,6 +225,10 @@ fn extract_file_path(hook_json: &serde_json::Value, format: HostFormat) -> Optio .and_then(|tc| tc.get("args")) .and_then(|a| a.get("TargetFile")) .and_then(|fp| fp.as_str()), + HostFormat::Opencode => hook_json + .get("tool_input") + .and_then(|ti| ti.get("file_path").or_else(|| ti.get("filePath")).or_else(|| ti.get("path"))) + .and_then(|fp| fp.as_str()), }?; // Resolve to absolute path @@ -402,7 +419,7 @@ fn emit_system_message(builder: crate::hook::response::SystemMessageBuilder, for /// Format a `systemMessage` for hook responses. fn format_system_message(msg: &str, format: HostFormat) -> String { match format { - HostFormat::Claude | HostFormat::Gemini | HostFormat::Antigravity => { + HostFormat::Claude | HostFormat::Gemini | HostFormat::Antigravity | HostFormat::Opencode => { serde_json::json!({ "systemMessage": msg }).to_string() } } @@ -806,6 +823,7 @@ fn extract_shell_command( HostFormat::Claude => tool_name == "Bash", HostFormat::Gemini => tool_name == "run_shell_command", HostFormat::Antigravity => tool_name == "run_command", + HostFormat::Opencode => tool_name == "bash", }; if !is_shell_tool { return None; @@ -1605,4 +1623,114 @@ mod tests { }); assert!(extract_shell_command(&json, "write_to_file", HostFormat::Antigravity).is_none(),); } + + // ── OpenCode format tests ──────────────────────────────────────── + + #[test] + fn extract_session_id_opencode() { + let json = serde_json::json!({ + "session_id": "opencode-session-123", + "cwd": "/project" + }); + assert_eq!( + extract_session_id(&json, HostFormat::Opencode), + Some("opencode-session-123"), + ); + } + + #[test] + fn extract_cwd_str_opencode() { + let json = serde_json::json!({ + "cwd": "/workspace/myproject" + }); + assert_eq!( + extract_cwd_str(&json, HostFormat::Opencode), + Some("/workspace/myproject"), + ); + } + + #[test] + fn extract_tool_name_opencode() { + let json = serde_json::json!({ + "tool_name": "Edit" + }); + assert_eq!(extract_tool_name(&json, HostFormat::Opencode), "Edit",); + } + + #[test] + fn extract_file_path_opencode() { + let json = serde_json::json!({ + "tool_name": "Edit", + "tool_input": { "file_path": "/project/src/main.rs" } + }); + assert_eq!( + extract_file_path(&json, HostFormat::Opencode), + Some("/project/src/main.rs".to_string()), + ); + } + + #[test] + fn extract_file_path_opencode_path_field() { + let json = serde_json::json!({ + "tool_name": "Write", + "tool_input": { "path": "/project/newfile.ts" } + }); + assert_eq!( + extract_file_path(&json, HostFormat::Opencode), + Some("/project/newfile.ts".to_string()), + ); + } + + #[test] + fn extract_shell_command_opencode_bash() { + let json = serde_json::json!({ + "tool_name": "bash", + "tool_input": { "command": "cargo build" } + }); + assert_eq!( + extract_shell_command(&json, "bash", HostFormat::Opencode), + Some("cargo build".to_string()), + ); + } + + #[test] + fn extract_shell_command_opencode_non_shell_returns_none() { + let json = serde_json::json!({ + "tool_name": "Edit", + "tool_input": { "file_path": "/src/main.rs" } + }); + assert!(extract_shell_command(&json, "Edit", HostFormat::Opencode).is_none(),); + } + + #[test] + fn format_deny_opencode_structure() { + let output = format_deny("call editing start first", HostFormat::Opencode); + let parsed: serde_json::Value = + serde_json::from_str(&output).expect("should produce valid JSON"); + assert_eq!(parsed["decision"], "deny"); + assert_eq!(parsed["reason"], "call editing start first"); + } + + #[test] + fn format_stop_block_opencode_structure() { + let output = format_stop_block("files still in editing state", HostFormat::Opencode); + let parsed: serde_json::Value = + serde_json::from_str(&output).expect("should produce valid JSON"); + assert_eq!(parsed["decision"], "block"); + assert_eq!(parsed["reason"], "files still in editing state"); + } + + #[test] + fn format_system_message_opencode() { + let output = format_system_message( + "─── background ───\n[warn] ra offline", + HostFormat::Opencode, + ); + let parsed: serde_json::Value = + serde_json::from_str(&output).expect("should produce valid JSON"); + assert_eq!( + parsed["systemMessage"].as_str(), + Some("─── background ───\n[warn] ra offline"), + ); + } } diff --git a/src/cli/install.rs b/src/cli/install.rs index 674d0f4..2d1043e 100644 --- a/src/cli/install.rs +++ b/src/cli/install.rs @@ -703,6 +703,126 @@ fn gemini_current_install() -> Option<(String, Option)> { Some((install_type, source)) } +// ── OpenCode plugin install ──────────────────────────────────────── + +/// Embedded OpenCode plugin file. +const OC_PLUGIN: &str = include_str!("../../plugins/catenary-opencode/catenary.mjs"); + +/// OpenCode plugin file set for installation. +const OC_FILES: &[(&str, &str)] = &[("catenary.mjs", OC_PLUGIN)]; + +/// Run `catenary install opencode`. +/// +/// Installs the Catenary OpenCode plugin to `~/.config/opencode/plugin/catenary.mjs`. +pub fn run_install_opencode( + out: &mut Output, + source: Option<&str>, + dry_run: bool, +) -> Result<()> { + let _ = out.writeln(format_args!("OpenCode plugin:")); + + let parsed_source = source.map(parse_source); + + let home = dirs::home_dir().context("cannot determine home directory")?; + let target_dir = home.join(".config/opencode/plugin"); + + match parsed_source { + Some(InstallSource::Local(path)) => { + install_opencode_local(out, &path, &target_dir, dry_run)?; + } + Some(InstallSource::Remote(_)) | None => { + install_opencode_bundled(out, &target_dir, dry_run)?; + } + } + + Ok(()) +} + +fn install_opencode_local( + out: &mut Output, + source: &Path, + target_dir: &Path, + dry_run: bool, +) -> Result<()> { + let plugin_file = source.join("plugins/catenary-opencode/catenary.mjs"); + let source_file = if plugin_file.is_file() { + plugin_file + } else if source.join("catenary.mjs").is_file() { + source.join("catenary.mjs") + } else { + source.to_path_buf() + }; + + if dry_run { + let _ = out.writeln(format_args!( + " {} copy {} → {}", + out.colors.dim("(dry-run)"), + source_file.display(), + target_dir.display(), + )); + return Ok(()); + } + + if let Some(parent) = target_dir.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("create parent directory {}", parent.display()))?; + } + std::fs::create_dir_all(target_dir) + .with_context(|| format!("create plugin directory {}", target_dir.display()))?; + + let target_file = target_dir.join("catenary.mjs"); + std::fs::copy(&source_file, &target_file) + .with_context(|| format!("copy {} → {}", source_file.display(), target_file.display()))?; + + let _ = out.writeln(format_args!( + " {} installed → {}", + out.colors.green("✓"), + target_file.display(), + )); + + Ok(()) +} + +fn install_opencode_bundled(out: &mut Output, target_dir: &Path, dry_run: bool) -> Result<()> { + let target_file = target_dir.join("catenary.mjs"); + + // Check staleness + let up_to_date = OC_FILES.iter().all(|(rel_path, expected)| { + let file_path = target_dir.join(rel_path); + std::fs::read_to_string(&file_path).map_or(false, |current| current == *expected) + }); + + if up_to_date && target_file.is_file() { + let _ = out.writeln(format_args!( + " {} up to date → {}", + out.colors.green("✓"), + target_file.display(), + )); + return Ok(()); + } + + if dry_run { + let _ = out.writeln(format_args!( + " {} write {}", + out.colors.dim("(dry-run)"), + target_file.display(), + )); + return Ok(()); + } + + std::fs::create_dir_all(target_dir) + .with_context(|| format!("create plugin directory {}", target_dir.display()))?; + + for (rel_path, content) in OC_FILES { + let file_path = target_dir.join(rel_path); + std::fs::write(&file_path, *content) + .with_context(|| format!("write {}", file_path.display()))?; + let _ = out.writeln(format_args!(" {} wrote → {}", out.colors.green("✓"), file_path.display())); + } + + Ok(()) +} + // ── Antigravity CLI install ──────────────────────────────────────── /// Embedded Antigravity plugin files. diff --git a/src/cli/mod.rs b/src/cli/mod.rs index db6e219..d871e1b 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -27,6 +27,8 @@ pub enum HostFormat { Gemini, /// Antigravity CLI hooks (`PreToolUse` / `Stop`). Antigravity, + /// OpenCode plugin hooks (`tool.execute.before` / `tool.execute.after`). + Opencode, } impl HostFormat { @@ -40,6 +42,7 @@ impl HostFormat { Self::Claude => "claude", Self::Gemini => "gemini", Self::Antigravity => "antigravity", + Self::Opencode => "opencode", } } @@ -49,6 +52,7 @@ impl HostFormat { match self { Self::Claude => "Read", Self::Gemini | Self::Antigravity => "read_file", + Self::Opencode => "Read", } } @@ -59,6 +63,7 @@ impl HostFormat { Self::Claude => "Edit", Self::Gemini => "write_file", Self::Antigravity => "write_to_file", + Self::Opencode => "Edit", } } } @@ -393,6 +398,7 @@ mod tests { assert_eq!(HostFormat::Claude.edit_tool(), "Edit"); assert_eq!(HostFormat::Gemini.edit_tool(), "write_file"); assert_eq!(HostFormat::Antigravity.edit_tool(), "write_to_file"); + assert_eq!(HostFormat::Opencode.edit_tool(), "Edit"); } #[test] @@ -400,6 +406,7 @@ mod tests { assert_eq!(HostFormat::Claude.read_tool(), "Read"); assert_eq!(HostFormat::Gemini.read_tool(), "read_file"); assert_eq!(HostFormat::Antigravity.read_tool(), "read_file"); + assert_eq!(HostFormat::Opencode.read_tool(), "Read"); } #[test] diff --git a/src/main.rs b/src/main.rs index fd4bdfd..69b07c3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -409,6 +409,11 @@ enum InstallHost { /// Source: local path (dev install) or repo identifier (release install). source: Option, }, + /// Install the Catenary plugin for OpenCode. + Opencode { + /// Source: local path (dev install) or repo identifier (release install). + source: Option, + }, } /// Entry point for the Catenary binary. @@ -641,6 +646,9 @@ fn main() -> Result<()> { Some(InstallHost::Antigravity { source }) => { cli::install::run_install_antigravity(&mut out, source.as_deref(), dry_run) } + Some(InstallHost::Opencode { source }) => { + cli::install::run_install_opencode(&mut out, source.as_deref(), dry_run) + } } } Some(Command::Update { check, force }) => {