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 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
71pub 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
83pub 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
113pub 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 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 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); }
138 }
139
140 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 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
185pub 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 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 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 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 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 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#[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 std::env::set_var("HOME", dir.path());
385 let result = install_opencode_plugin("sqz");
386 assert!(result.is_ok());
387 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}