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