Skip to main content

sqz_engine/
opencode_plugin.rs

1/// OpenCode plugin support for sqz.
2///
3/// OpenCode uses TypeScript plugins loaded from `~/.config/opencode/plugins/`.
4/// The plugin hooks into `tool.execute.before` to rewrite bash commands,
5/// piping output through `sqz compress` for token savings.
6///
7/// Unlike Claude Code / Cursor / Gemini (which use JSON hook configs),
8/// OpenCode requires a TypeScript file that exports a factory function.
9///
10/// Plugin path: `~/.config/opencode/plugins/sqz.ts`
11/// Config path: `opencode.json` (project root) — adds `"plugin": ["sqz"]`
12
13use std::path::{Path, PathBuf};
14
15use crate::error::Result;
16
17/// Generate the OpenCode TypeScript plugin content.
18///
19/// The plugin intercepts shell tool calls and rewrites them to pipe
20/// output through `sqz hook opencode`, which compresses the output.
21pub fn generate_opencode_plugin(sqz_path: &str) -> String {
22    format!(
23        r#"/**
24 * sqz — OpenCode plugin for transparent context compression.
25 *
26 * Intercepts shell commands and pipes output through sqz for token savings.
27 * Install: copy to ~/.config/opencode/plugins/sqz.ts
28 * Config:  add "plugin": ["sqz"] to opencode.json
29 */
30
31export const SqzPlugin = async (ctx: any) => {{
32  const SQZ_PATH = "{sqz_path}";
33
34  // Commands that should not be intercepted.
35  const INTERACTIVE = new Set([
36    "vim", "vi", "nano", "emacs", "less", "more", "top", "htop",
37    "ssh", "python", "python3", "node", "irb", "ghci",
38    "psql", "mysql", "sqlite3", "mongo", "redis-cli",
39  ]);
40
41  function isInteractive(cmd: string): boolean {{
42    const base = cmd.split(/\s+/)[0]?.split("/").pop() ?? "";
43    if (INTERACTIVE.has(base)) return true;
44    if (cmd.includes("--watch") || cmd.includes("run dev") ||
45        cmd.includes("run start") || cmd.includes("run serve")) return true;
46    return false;
47  }}
48
49  function shouldIntercept(tool: string): boolean {{
50    return ["bash", "shell", "terminal", "run_shell_command"].includes(tool.toLowerCase());
51  }}
52
53  return {{
54    "tool.execute.before": async (input: any, output: any) => {{
55      const tool = input.tool ?? "";
56      if (!shouldIntercept(tool)) return;
57
58      const cmd = output.args?.command ?? "";
59      if (!cmd || cmd.includes("sqz") || isInteractive(cmd)) return;
60
61      // Rewrite: pipe through sqz compress
62      const base = cmd.split(/\s+/)[0]?.split("/").pop() ?? "unknown";
63      output.args.command = `SQZ_CMD=${{base}} ${{cmd}} 2>&1 | ${{SQZ_PATH}} compress`;
64    }},
65  }};
66}};
67"#
68    )
69}
70
71/// Default path for the OpenCode plugin file.
72pub fn opencode_plugin_path() -> PathBuf {
73    let home = std::env::var("HOME")
74        .or_else(|_| std::env::var("USERPROFILE"))
75        .map(PathBuf::from)
76        .unwrap_or_else(|_| PathBuf::from("."));
77    home.join(".config")
78        .join("opencode")
79        .join("plugins")
80        .join("sqz.ts")
81}
82
83/// Install the OpenCode plugin to `~/.config/opencode/plugins/sqz.ts`.
84///
85/// Returns `true` if the plugin was installed, `false` if it already exists.
86pub fn install_opencode_plugin(sqz_path: &str) -> Result<bool> {
87    let plugin_path = opencode_plugin_path();
88
89    if plugin_path.exists() {
90        return Ok(false);
91    }
92
93    if let Some(parent) = plugin_path.parent() {
94        std::fs::create_dir_all(parent).map_err(|e| {
95            crate::error::SqzError::Other(format!(
96                "failed to create OpenCode plugins dir {}: {e}",
97                parent.display()
98            ))
99        })?;
100    }
101
102    let content = generate_opencode_plugin(sqz_path);
103    std::fs::write(&plugin_path, &content).map_err(|e| {
104        crate::error::SqzError::Other(format!(
105            "failed to write OpenCode plugin to {}: {e}",
106            plugin_path.display()
107        ))
108    })?;
109
110    Ok(true)
111}
112
113/// Update the project's `opencode.json` to reference the sqz plugin.
114///
115/// If `opencode.json` exists, adds `"sqz"` to the `"plugin"` array.
116/// If it doesn't exist, creates a minimal config with the plugin reference.
117///
118/// Returns `true` if the config was created/updated, `false` if sqz was
119/// already listed.
120pub fn update_opencode_config(project_dir: &Path) -> Result<bool> {
121    let config_path = project_dir.join("opencode.json");
122
123    if config_path.exists() {
124        let content = std::fs::read_to_string(&config_path).map_err(|e| {
125            crate::error::SqzError::Other(format!("failed to read opencode.json: {e}"))
126        })?;
127
128        // Parse existing config
129        let mut config: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
130            crate::error::SqzError::Other(format!("failed to parse opencode.json: {e}"))
131        })?;
132
133        // Check if sqz is already in the plugin array
134        if let Some(plugins) = config.get("plugin").and_then(|v| v.as_array()) {
135            if plugins.iter().any(|v| v.as_str() == Some("sqz")) {
136                return Ok(false); // Already configured
137            }
138        }
139
140        // Add sqz to the plugin array
141        let plugins = config
142            .as_object_mut()
143            .ok_or_else(|| crate::error::SqzError::Other("opencode.json is not an object".into()))?
144            .entry("plugin")
145            .or_insert_with(|| serde_json::json!([]));
146
147        if let Some(arr) = plugins.as_array_mut() {
148            arr.push(serde_json::json!("sqz"));
149        }
150
151        let updated = serde_json::to_string_pretty(&config).map_err(|e| {
152            crate::error::SqzError::Other(format!("failed to serialize opencode.json: {e}"))
153        })?;
154
155        std::fs::write(&config_path, format!("{updated}\n")).map_err(|e| {
156            crate::error::SqzError::Other(format!("failed to write opencode.json: {e}"))
157        })?;
158
159        Ok(true)
160    } else {
161        // Create a minimal opencode.json with sqz plugin + MCP
162        let config = serde_json::json!({
163            "$schema": "https://opencode.ai/config.json",
164            "mcp": {
165                "sqz": {
166                    "type": "local",
167                    "command": ["sqz-mcp", "--transport", "stdio"]
168                }
169            },
170            "plugin": ["sqz"]
171        });
172
173        let content = serde_json::to_string_pretty(&config).map_err(|e| {
174            crate::error::SqzError::Other(format!("failed to serialize opencode.json: {e}"))
175        })?;
176
177        std::fs::write(&config_path, format!("{content}\n")).map_err(|e| {
178            crate::error::SqzError::Other(format!("failed to write opencode.json: {e}"))
179        })?;
180
181        Ok(true)
182    }
183}
184
185/// Process an OpenCode `tool.execute.before` hook invocation.
186///
187/// OpenCode's hook format differs from Claude Code / Cursor:
188/// - Input: `{ "tool": "bash", "sessionID": "...", "callID": "..." }`
189/// - Args:  `{ "command": "git status" }`
190///
191/// The hook receives both `input` and `output` (args) as separate objects,
192/// but when invoked via CLI (`sqz hook opencode`), we receive a combined
193/// JSON with both fields.
194pub fn process_opencode_hook(input: &str) -> Result<String> {
195    let parsed: serde_json::Value = serde_json::from_str(input)
196        .map_err(|e| crate::error::SqzError::Other(format!("opencode hook: invalid JSON: {e}")))?;
197
198    let tool = parsed
199        .get("tool")
200        .or_else(|| parsed.get("toolName"))
201        .or_else(|| parsed.get("tool_name"))
202        .and_then(|v| v.as_str())
203        .unwrap_or("");
204
205    // Only intercept shell tool calls
206    if !matches!(
207        tool.to_lowercase().as_str(),
208        "bash" | "shell" | "terminal" | "run_shell_command"
209    ) {
210        return Ok(input.to_string());
211    }
212
213    // OpenCode puts args in a separate "args" field or in "toolCall"
214    let command = parsed
215        .get("args")
216        .or_else(|| parsed.get("toolCall"))
217        .or_else(|| parsed.get("tool_input"))
218        .and_then(|v| v.get("command"))
219        .and_then(|v| v.as_str())
220        .unwrap_or("");
221
222    if command.is_empty() || command.contains("sqz") {
223        return Ok(input.to_string());
224    }
225
226    // Check for interactive commands
227    let base = command
228        .split_whitespace()
229        .next()
230        .unwrap_or("")
231        .rsplit('/')
232        .next()
233        .unwrap_or("");
234
235    if matches!(
236        base,
237        "vim" | "vi" | "nano" | "emacs" | "less" | "more" | "top" | "htop"
238            | "ssh" | "python" | "python3" | "node" | "irb" | "ghci"
239            | "psql" | "mysql" | "sqlite3" | "mongo" | "redis-cli"
240    ) || command.contains("--watch")
241        || command.contains("run dev")
242        || command.contains("run start")
243        || command.contains("run serve")
244    {
245        return Ok(input.to_string());
246    }
247
248    // Rewrite the command
249    let base_cmd = command
250        .split_whitespace()
251        .next()
252        .unwrap_or("unknown")
253        .rsplit('/')
254        .next()
255        .unwrap_or("unknown");
256
257    let escaped_base = if base_cmd
258        .chars()
259        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
260    {
261        base_cmd.to_string()
262    } else {
263        format!("'{}'", base_cmd.replace('\'', "'\\''"))
264    };
265
266    let rewritten = format!(
267        "SQZ_CMD={} {} 2>&1 | sqz compress",
268        escaped_base, command
269    );
270
271    // Output in the format OpenCode expects (same as Claude Code for CLI path)
272    let output = serde_json::json!({
273        "decision": "approve",
274        "reason": "sqz: command output will be compressed for token savings",
275        "updatedInput": {
276            "command": rewritten
277        },
278        "args": {
279            "command": rewritten
280        }
281    });
282
283    serde_json::to_string(&output)
284        .map_err(|e| crate::error::SqzError::Other(format!("opencode hook: serialize error: {e}")))
285}
286
287// ── Tests ─────────────────────────────────────────────────────────────────
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    #[test]
294    fn test_generate_opencode_plugin_contains_sqz_path() {
295        let content = generate_opencode_plugin("/usr/local/bin/sqz");
296        assert!(content.contains("/usr/local/bin/sqz"));
297        assert!(content.contains("SqzPlugin"));
298        assert!(content.contains("tool.execute.before"));
299    }
300
301    #[test]
302    fn test_generate_opencode_plugin_has_interactive_check() {
303        let content = generate_opencode_plugin("sqz");
304        assert!(content.contains("isInteractive"));
305        assert!(content.contains("vim"));
306        assert!(content.contains("--watch"));
307    }
308
309    #[test]
310    fn test_generate_opencode_plugin_has_sqz_guard() {
311        let content = generate_opencode_plugin("sqz");
312        assert!(
313            content.contains(r#"cmd.includes("sqz")"#),
314            "should skip commands already containing sqz"
315        );
316    }
317
318    #[test]
319    fn test_process_opencode_hook_rewrites_bash() {
320        let input = r#"{"tool":"bash","args":{"command":"git status"}}"#;
321        let result = process_opencode_hook(input).unwrap();
322        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
323        assert_eq!(parsed["decision"].as_str().unwrap(), "approve");
324        let cmd = parsed["args"]["command"].as_str().unwrap();
325        assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
326        assert!(cmd.contains("git status"), "should preserve original: {cmd}");
327        assert!(cmd.contains("SQZ_CMD=git"), "should set SQZ_CMD: {cmd}");
328    }
329
330    #[test]
331    fn test_process_opencode_hook_passes_non_shell() {
332        let input = r#"{"tool":"read_file","args":{"path":"file.txt"}}"#;
333        let result = process_opencode_hook(input).unwrap();
334        assert_eq!(result, input, "non-shell tools should pass through");
335    }
336
337    #[test]
338    fn test_process_opencode_hook_skips_sqz_commands() {
339        let input = r#"{"tool":"bash","args":{"command":"sqz stats"}}"#;
340        let result = process_opencode_hook(input).unwrap();
341        assert_eq!(result, input, "sqz commands should not be double-wrapped");
342    }
343
344    #[test]
345    fn test_process_opencode_hook_skips_interactive() {
346        let input = r#"{"tool":"bash","args":{"command":"vim file.txt"}}"#;
347        let result = process_opencode_hook(input).unwrap();
348        assert_eq!(result, input, "interactive commands should pass through");
349    }
350
351    #[test]
352    fn test_process_opencode_hook_skips_watch() {
353        let input = r#"{"tool":"bash","args":{"command":"npm run dev --watch"}}"#;
354        let result = process_opencode_hook(input).unwrap();
355        assert_eq!(result, input, "watch mode should pass through");
356    }
357
358    #[test]
359    fn test_process_opencode_hook_invalid_json() {
360        let result = process_opencode_hook("not json");
361        assert!(result.is_err());
362    }
363
364    #[test]
365    fn test_process_opencode_hook_empty_command() {
366        let input = r#"{"tool":"bash","args":{"command":""}}"#;
367        let result = process_opencode_hook(input).unwrap();
368        assert_eq!(result, input);
369    }
370
371    #[test]
372    fn test_process_opencode_hook_run_shell_command() {
373        let input = r#"{"tool":"run_shell_command","args":{"command":"ls -la"}}"#;
374        let result = process_opencode_hook(input).unwrap();
375        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
376        let cmd = parsed["args"]["command"].as_str().unwrap();
377        assert!(cmd.contains("sqz compress"));
378    }
379
380    #[test]
381    fn test_install_opencode_plugin_creates_file() {
382        let dir = tempfile::tempdir().unwrap();
383        // Override HOME to use temp dir
384        std::env::set_var("HOME", dir.path());
385        let result = install_opencode_plugin("sqz");
386        assert!(result.is_ok());
387        // Plugin should be created at ~/.config/opencode/plugins/sqz.ts
388        let plugin_path = dir
389            .path()
390            .join(".config/opencode/plugins/sqz.ts");
391        assert!(plugin_path.exists(), "plugin file should exist");
392        let content = std::fs::read_to_string(&plugin_path).unwrap();
393        assert!(content.contains("SqzPlugin"));
394    }
395
396    #[test]
397    fn test_update_opencode_config_creates_new() {
398        let dir = tempfile::tempdir().unwrap();
399        let result = update_opencode_config(dir.path()).unwrap();
400        assert!(result, "should create new config");
401        let config_path = dir.path().join("opencode.json");
402        assert!(config_path.exists());
403        let content = std::fs::read_to_string(&config_path).unwrap();
404        assert!(content.contains("\"sqz\""));
405        assert!(content.contains("sqz-mcp"));
406    }
407
408    #[test]
409    fn test_update_opencode_config_adds_to_existing() {
410        let dir = tempfile::tempdir().unwrap();
411        let config_path = dir.path().join("opencode.json");
412        std::fs::write(
413            &config_path,
414            r#"{"$schema":"https://opencode.ai/config.json","plugin":["other"]}"#,
415        )
416        .unwrap();
417
418        let result = update_opencode_config(dir.path()).unwrap();
419        assert!(result, "should update existing config");
420        let content = std::fs::read_to_string(&config_path).unwrap();
421        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
422        let plugins = parsed["plugin"].as_array().unwrap();
423        assert!(plugins.iter().any(|v| v.as_str() == Some("sqz")));
424        assert!(plugins.iter().any(|v| v.as_str() == Some("other")));
425    }
426
427    #[test]
428    fn test_update_opencode_config_skips_if_present() {
429        let dir = tempfile::tempdir().unwrap();
430        let config_path = dir.path().join("opencode.json");
431        std::fs::write(
432            &config_path,
433            r#"{"plugin":["sqz"]}"#,
434        )
435        .unwrap();
436
437        let result = update_opencode_config(dir.path()).unwrap();
438        assert!(!result, "should skip if sqz already present");
439    }
440}