sqz_engine/
opencode_plugin.rs1use std::path::{Path, PathBuf};
14
15use crate::error::Result;
16
17pub fn generate_opencode_plugin(sqz_path: &str) -> String {
22 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
75pub 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
87pub 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
117pub 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 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 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); }
142 }
143
144 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 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
189pub 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 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 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 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 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 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#[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 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
312 let content = generate_opencode_plugin(windows_path);
313 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 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 std::env::set_var("HOME", dir.path());
412 let result = install_opencode_plugin("sqz");
413 assert!(result.is_ok());
414 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}