1use std::path::{Path, PathBuf};
18
19use crate::error::Result;
20
21pub fn generate_opencode_plugin(sqz_path: &str) -> String {
55 let sqz_path = crate::tool_hooks::json_escape_string_value(sqz_path);
59 format!(
60 r#"/**
61 * sqz — OpenCode plugin for transparent context compression.
62 *
63 * Intercepts shell commands and pipes output through sqz for token savings.
64 * Install: copy to ~/.config/opencode/plugins/sqz.ts
65 * Discovery is automatic — no opencode.json entry needed (and in fact
66 * including one causes the plugin to load twice, per issue #10).
67 */
68
69const SqzPluginFactory = async (ctx: any) => {{
70 const SQZ_PATH = "{sqz_path}";
71
72 // Commands that should not be intercepted.
73 const INTERACTIVE = new Set([
74 "vim", "vi", "nano", "emacs", "less", "more", "top", "htop",
75 "ssh", "python", "python3", "node", "irb", "ghci",
76 "psql", "mysql", "sqlite3", "mongo", "redis-cli",
77 ]);
78
79 function isInteractive(cmd: string): boolean {{
80 const base = cmd.split(/\s+/)[0]?.split("/").pop() ?? "";
81 if (INTERACTIVE.has(base)) return true;
82 if (cmd.includes("--watch") || cmd.includes("run dev") ||
83 cmd.includes("run start") || cmd.includes("run serve")) return true;
84 return false;
85 }}
86
87 function shouldIntercept(tool: string): boolean {{
88 return ["bash", "shell", "terminal", "run_shell_command"].includes(tool.toLowerCase());
89 }}
90
91 // Detect that a command has already been wrapped by sqz. Before this
92 // guard was in place OpenCode could call the hook twice on the same
93 // command (for retried tool calls, or when a previous rewrite was
94 // echoed back to the agent and the agent re-submitted it) and each
95 // pass would prepend another `SQZ_CMD=$base` prefix, producing monsters
96 // like `SQZ_CMD=SQZ_CMD=ddev SQZ_CMD=ddev ddev exec ...` (reported as
97 // a follow-up to issue #5). We skip if any of these markers appear:
98 // * the case-insensitive substring "sqz_cmd=" or "sqz compress"
99 // (covers the tail of prior wraps regardless of case; SQZ_CMD= is
100 // legacy pre-issue-#10 but still valid in POSIX shell hooks)
101 // * a leading `VAR=` assignment that starts with SQZ_
102 // (defensive catch-all for exotic wrap variants)
103 // * the base command itself is sqz or sqz-mcp (running sqz directly
104 // — compressing sqz's own output is pointless and causes loops)
105 function isAlreadyWrapped(cmd: string): boolean {{
106 const lowered = cmd.toLowerCase();
107 if (lowered.includes("sqz_cmd=")) return true;
108 if (lowered.includes("sqz compress")) return true;
109 if (lowered.includes("| sqz ") || lowered.includes("| sqz\t")) return true;
110 if (/^\s*SQZ_[A-Z0-9_]+=/.test(cmd)) return true;
111 const base = extractBaseCmd(cmd);
112 if (base === "sqz" || base === "sqz-mcp" || base === "sqz.exe") return true;
113 return false;
114 }}
115
116 // Extract the base command name defensively. If the command has
117 // leading env-var assignments (VAR=val VAR2=val2 actual_cmd arg1),
118 // skip past them so the base is `actual_cmd` — not `VAR=val`.
119 function extractBaseCmd(cmd: string): string {{
120 const tokens = cmd.split(/\s+/).filter(t => t.length > 0);
121 for (const tok of tokens) {{
122 // A token is an env assignment if it matches NAME=VALUE where NAME
123 // is a valid env var identifier. Skip it and keep looking.
124 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tok)) continue;
125 return tok.split("/").pop() ?? "unknown";
126 }}
127 return "unknown";
128 }}
129
130 // Shell-escape a command-name label so it's safe to inline into the
131 // rewritten shell command. Agents occasionally invoke commands via
132 // paths with spaces (`"/my tools/foo" --arg`) and in the LLM
133 // roundtrip that can survive to `extractBaseCmd`'s output. Quote the
134 // label unless it's pure ASCII alphanumeric.
135 function shellEscapeLabel(s: string): string {{
136 if (/^[A-Za-z0-9_.-]+$/.test(s)) return s;
137 return "'" + s.replace(/'/g, "'\\''") + "'";
138 }}
139
140 return {{
141 "tool.execute.before": async (input: any, output: any) => {{
142 const tool = input.tool ?? "";
143 if (!shouldIntercept(tool)) return;
144
145 const cmd = output.args?.command ?? "";
146 if (!cmd || isAlreadyWrapped(cmd) || isInteractive(cmd)) return;
147
148 // Rewrite: pipe through `sqz compress --cmd <base>`.
149 //
150 // Issue #10: the previous form was `SQZ_CMD=<base> <cmd> 2>&1 |
151 // <sqz> compress`, which uses sh-specific inline env-var syntax.
152 // On Windows, OpenCode Desktop routes bash-tool commands through
153 // PowerShell (or cmd.exe when $SHELL is unset), and both parse
154 // `SQZ_CMD=cmd` as a command name — raising CommandNotFoundException
155 // and producing zero compression. `--cmd NAME` is a normal CLI
156 // argument, shell-neutral, works in POSIX sh, zsh, fish, PowerShell,
157 // and cmd.exe.
158 const base = extractBaseCmd(cmd);
159 const label = shellEscapeLabel(base);
160 output.args.command = `${{cmd}} 2>&1 | ${{SQZ_PATH}} compress --cmd ${{label}}`;
161 }},
162 }};
163}};
164
165// V1 default export — modern OpenCode (post-V1 loader) reads `id` here
166// and displays "sqz" in the plugin list. Without this, OpenCode falls
167// back to the raw `file:///...` spec as the plugin name (@itguy327 on
168// issue #10). `readV1Plugin` in OpenCode's plugin/shared.ts requires
169// file-source plugins to declare an id — otherwise `resolvePluginId`
170// throws.
171export default {{
172 id: "sqz",
173 server: SqzPluginFactory,
174}};
175
176// Legacy named export — pre-V1 OpenCode versions walk Object.values(mod)
177// looking for factory functions. Assigning the same reference as the
178// default export's `.server` means the legacy `seen` Set dedups via
179// identity, so the factory fires exactly once either way. Kept for
180// backward compatibility with OpenCode versions that predate the V1
181// loader (roughly anything before mid-2025).
182export const SqzPlugin = SqzPluginFactory;
183"#
184 )
185}
186
187pub fn opencode_plugin_path() -> PathBuf {
189 let home = std::env::var("HOME")
190 .or_else(|_| std::env::var("USERPROFILE"))
191 .map(PathBuf::from)
192 .unwrap_or_else(|_| PathBuf::from("."));
193 home.join(".config")
194 .join("opencode")
195 .join("plugins")
196 .join("sqz.ts")
197}
198
199pub fn install_opencode_plugin(sqz_path: &str) -> Result<bool> {
211 let plugin_path = opencode_plugin_path();
212 let new_content = generate_opencode_plugin(sqz_path);
213
214 if plugin_path.exists() {
216 if let Ok(existing) = std::fs::read_to_string(&plugin_path) {
217 if existing == new_content {
218 return Ok(false);
219 }
220 }
221 }
222
223 if let Some(parent) = plugin_path.parent() {
224 std::fs::create_dir_all(parent).map_err(|e| {
225 crate::error::SqzError::Other(format!(
226 "failed to create OpenCode plugins dir {}: {e}",
227 parent.display()
228 ))
229 })?;
230 }
231
232 let content = generate_opencode_plugin(sqz_path);
233 std::fs::write(&plugin_path, &content).map_err(|e| {
234 crate::error::SqzError::Other(format!(
235 "failed to write OpenCode plugin to {}: {e}",
236 plugin_path.display()
237 ))
238 })?;
239
240 Ok(true)
241}
242
243pub fn find_opencode_config(project_dir: &Path) -> Option<PathBuf> {
250 let jsonc = project_dir.join("opencode.jsonc");
251 if jsonc.exists() {
252 return Some(jsonc);
253 }
254 let json = project_dir.join("opencode.json");
255 if json.exists() {
256 return Some(json);
257 }
258 None
259}
260
261pub fn opencode_config_has_comments(project_dir: &Path) -> bool {
266 let path = match find_opencode_config(project_dir) {
267 Some(p) => p,
268 None => return false,
269 };
270 if path.extension().map(|e| e != "jsonc").unwrap_or(true) {
271 return false;
272 }
273 let content = match std::fs::read_to_string(&path) {
274 Ok(s) => s,
275 Err(_) => return false,
276 };
277 strip_jsonc_comments(&content) != content
278}
279
280pub fn strip_jsonc_comments(src: &str) -> String {
290 let mut out = String::with_capacity(src.len());
291 let bytes = src.as_bytes();
292 let mut i = 0;
293 let len = bytes.len();
294
295 while i < len {
296 let b = bytes[i];
297
298 if b == b'"' {
301 out.push('"');
302 i += 1;
303 while i < len {
304 let c = bytes[i];
305 out.push(c as char);
306 if c == b'\\' && i + 1 < len {
307 out.push(bytes[i + 1] as char);
309 i += 2;
310 continue;
311 }
312 i += 1;
313 if c == b'"' {
314 break;
315 }
316 }
317 continue;
318 }
319
320 if b == b'/' && i + 1 < len && bytes[i + 1] == b'/' {
323 i += 2;
324 while i < len && bytes[i] != b'\n' {
325 i += 1;
326 }
327 continue;
328 }
329
330 if b == b'/' && i + 1 < len && bytes[i + 1] == b'*' {
332 i += 2;
333 while i + 1 < len && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
334 if bytes[i] == b'\n' {
336 out.push('\n');
337 }
338 i += 1;
339 }
340 if i + 1 < len {
343 i += 2;
344 }
345 continue;
346 }
347
348 out.push(b as char);
349 i += 1;
350 }
351
352 out
353}
354
355pub fn update_opencode_config(project_dir: &Path) -> Result<bool> {
371 let (updated, _) = update_opencode_config_detailed(project_dir)?;
372 Ok(updated)
373}
374
375pub fn update_opencode_config_detailed(project_dir: &Path) -> Result<(bool, bool)> {
379 fn sqz_mcp_value() -> serde_json::Value {
381 serde_json::json!({
382 "type": "local",
383 "command": ["sqz-mcp", "--transport", "stdio"],
384 "enabled": true
385 })
386 }
387
388 if let Some(existing_path) = find_opencode_config(project_dir) {
389 let is_jsonc = existing_path
390 .extension()
391 .map(|e| e == "jsonc")
392 .unwrap_or(false);
393 let content = std::fs::read_to_string(&existing_path).map_err(|e| {
394 crate::error::SqzError::Other(format!(
395 "failed to read {}: {e}",
396 existing_path.display()
397 ))
398 })?;
399
400 let parseable = if is_jsonc {
401 strip_jsonc_comments(&content)
402 } else {
403 content.clone()
404 };
405
406 let had_comments = is_jsonc && parseable != content;
409
410 let mut config: serde_json::Value = serde_json::from_str(&parseable).map_err(|e| {
412 crate::error::SqzError::Other(format!(
413 "failed to parse {}: {e}",
414 existing_path.display()
415 ))
416 })?;
417
418 let obj = config.as_object_mut().ok_or_else(|| {
419 crate::error::SqzError::Other(format!(
420 "{} root is not a JSON object",
421 existing_path.display()
422 ))
423 })?;
424
425 let mut changed = false;
426
427 if let Some(arr) = obj.get_mut("plugin").and_then(|v| v.as_array_mut()) {
442 let before = arr.len();
443 arr.retain(|v| v.as_str() != Some("sqz"));
444 if arr.len() != before {
445 changed = true;
446 }
447 if arr.is_empty() {
453 obj.remove("plugin");
454 changed = true;
455 }
456 }
457
458 let mcp_entry = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
460 if let Some(mcp_obj) = mcp_entry.as_object_mut() {
461 if !mcp_obj.contains_key("sqz") {
462 mcp_obj.insert("sqz".to_string(), sqz_mcp_value());
463 changed = true;
464 } else if let Some(sqz_entry) = mcp_obj.get_mut("sqz").and_then(|v| v.as_object_mut()) {
465 if !sqz_entry.contains_key("enabled") {
469 sqz_entry.insert("enabled".to_string(), serde_json::json!(true));
470 changed = true;
471 }
472 }
473 } else {
474 return Err(crate::error::SqzError::Other(format!(
475 "{} has an `mcp` field that is not an object; \
476 refusing to modify it automatically",
477 existing_path.display()
478 )));
479 }
480
481 if !changed {
482 return Ok((false, false));
483 }
484
485 let updated = serde_json::to_string_pretty(&config).map_err(|e| {
488 crate::error::SqzError::Other(format!("failed to serialize config: {e}"))
489 })?;
490 std::fs::write(&existing_path, format!("{updated}\n")).map_err(|e| {
491 crate::error::SqzError::Other(format!(
492 "failed to write {}: {e}",
493 existing_path.display()
494 ))
495 })?;
496
497 Ok((true, had_comments))
498 } else {
499 let config = serde_json::json!({
509 "$schema": "https://opencode.ai/config.json",
510 "mcp": {
511 "sqz": sqz_mcp_value()
512 }
513 });
514 let content = serde_json::to_string_pretty(&config).map_err(|e| {
515 crate::error::SqzError::Other(format!("failed to serialize opencode.json: {e}"))
516 })?;
517 let path = project_dir.join("opencode.json");
518 std::fs::write(&path, format!("{content}\n")).map_err(|e| {
519 crate::error::SqzError::Other(format!("failed to write opencode.json: {e}"))
520 })?;
521 Ok((true, false))
522 }
523}
524
525pub fn remove_sqz_from_opencode_config(project_dir: &Path) -> Result<Option<(PathBuf, bool)>> {
538 let path = match find_opencode_config(project_dir) {
539 Some(p) => p,
540 None => return Ok(None),
541 };
542 let is_jsonc = path.extension().map(|e| e == "jsonc").unwrap_or(false);
543 let raw = std::fs::read_to_string(&path).map_err(|e| {
544 crate::error::SqzError::Other(format!("failed to read {}: {e}", path.display()))
545 })?;
546 let parseable = if is_jsonc {
547 strip_jsonc_comments(&raw)
548 } else {
549 raw.clone()
550 };
551 let mut config: serde_json::Value = match serde_json::from_str(&parseable) {
552 Ok(v) => v,
553 Err(_) => {
554 return Ok(Some((path, false)));
556 }
557 };
558
559 let mut changed = false;
560
561 if let Some(obj) = config.as_object_mut() {
562 if let Some(plugin) = obj.get_mut("plugin").and_then(|v| v.as_array_mut()) {
564 let before = plugin.len();
565 plugin.retain(|v| v.as_str() != Some("sqz"));
566 if plugin.len() != before {
567 changed = true;
568 }
569 if plugin.is_empty() {
571 obj.remove("plugin");
572 }
573 }
574
575 if let Some(mcp) = obj.get_mut("mcp").and_then(|v| v.as_object_mut()) {
577 if mcp.remove("sqz").is_some() {
578 changed = true;
579 }
580 if mcp.is_empty() {
581 obj.remove("mcp");
582 }
583 }
584 }
585
586 if !changed {
587 return Ok(Some((path, false)));
588 }
589
590 let essentially_empty = match config.as_object() {
595 Some(obj) => {
596 obj.is_empty()
597 || (obj.len() == 1
598 && obj.get("$schema").and_then(|v| v.as_str())
599 == Some("https://opencode.ai/config.json"))
600 }
601 None => false,
602 };
603
604 if essentially_empty {
605 std::fs::remove_file(&path).map_err(|e| {
606 crate::error::SqzError::Other(format!(
607 "failed to remove {}: {e}",
608 path.display()
609 ))
610 })?;
611 return Ok(Some((path, true)));
612 }
613
614 let updated = serde_json::to_string_pretty(&config).map_err(|e| {
617 crate::error::SqzError::Other(format!("failed to serialize config: {e}"))
618 })?;
619 std::fs::write(&path, format!("{updated}\n")).map_err(|e| {
620 crate::error::SqzError::Other(format!(
621 "failed to write {}: {e}",
622 path.display()
623 ))
624 })?;
625 Ok(Some((path, true)))
626}
627
628fn is_already_wrapped(command: &str) -> bool {
640 let lowered = command.to_ascii_lowercase();
641 if lowered.contains("sqz_cmd=") {
642 return true;
643 }
644 if lowered.contains("sqz compress") {
645 return true;
646 }
647 if lowered.contains("| sqz ") || lowered.contains("| sqz\t") {
648 return true;
649 }
650 let trimmed = command.trim_start();
652 if let Some(eq_idx) = trimmed.find('=') {
653 let name = &trimmed[..eq_idx];
654 if name.starts_with("SQZ_")
655 && !name.is_empty()
656 && name
657 .chars()
658 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
659 {
660 return true;
661 }
662 }
663 let base = extract_base_cmd(command);
665 if base == "sqz" || base == "sqz-mcp" || base == "sqz.exe" {
666 return true;
667 }
668 false
669}
670
671fn extract_base_cmd(command: &str) -> &str {
678 for tok in command.split_whitespace() {
679 if is_env_assignment(tok) {
680 continue;
681 }
682 return tok.rsplit('/').next().unwrap_or("unknown");
683 }
684 "unknown"
685}
686
687fn is_env_assignment(token: &str) -> bool {
691 let eq = match token.find('=') {
692 Some(i) => i,
693 None => return false,
694 };
695 if eq == 0 {
696 return false;
697 }
698 let name = &token[..eq];
699 let mut chars = name.chars();
700 match chars.next() {
701 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
702 _ => return false,
703 }
704 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
705}
706
707pub fn process_opencode_hook(input: &str) -> Result<String> {
717 let parsed: serde_json::Value = serde_json::from_str(input)
718 .map_err(|e| crate::error::SqzError::Other(format!("opencode hook: invalid JSON: {e}")))?;
719
720 let tool = parsed
721 .get("tool")
722 .or_else(|| parsed.get("toolName"))
723 .or_else(|| parsed.get("tool_name"))
724 .and_then(|v| v.as_str())
725 .unwrap_or("");
726
727 if !matches!(
729 tool.to_lowercase().as_str(),
730 "bash" | "shell" | "terminal" | "run_shell_command"
731 ) {
732 return Ok(input.to_string());
733 }
734
735 let command = parsed
737 .get("args")
738 .or_else(|| parsed.get("toolCall"))
739 .or_else(|| parsed.get("tool_input"))
740 .and_then(|v| v.get("command"))
741 .and_then(|v| v.as_str())
742 .unwrap_or("");
743
744 if command.is_empty() || is_already_wrapped(command) {
745 return Ok(input.to_string());
746 }
747
748 let base = extract_base_cmd(command);
752
753 if matches!(
754 base,
755 "vim" | "vi" | "nano" | "emacs" | "less" | "more" | "top" | "htop"
756 | "ssh" | "python" | "python3" | "node" | "irb" | "ghci"
757 | "psql" | "mysql" | "sqlite3" | "mongo" | "redis-cli"
758 ) || command.contains("--watch")
759 || command.contains("run dev")
760 || command.contains("run start")
761 || command.contains("run serve")
762 {
763 return Ok(input.to_string());
764 }
765
766 let base_cmd = base;
768
769 let escaped_base = if base_cmd
770 .chars()
771 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
772 {
773 base_cmd.to_string()
774 } else {
775 format!("'{}'", base_cmd.replace('\'', "'\\''"))
776 };
777
778 let rewritten = format!(
783 "{} 2>&1 | sqz compress --cmd {}",
784 command, escaped_base,
785 );
786
787 let output = serde_json::json!({
789 "decision": "approve",
790 "reason": "sqz: command output will be compressed for token savings",
791 "updatedInput": {
792 "command": rewritten
793 },
794 "args": {
795 "command": rewritten
796 }
797 });
798
799 serde_json::to_string(&output)
800 .map_err(|e| crate::error::SqzError::Other(format!("opencode hook: serialize error: {e}")))
801}
802
803#[cfg(test)]
806mod tests {
807 use super::*;
808
809 #[test]
810 fn test_generate_opencode_plugin_contains_sqz_path() {
811 let content = generate_opencode_plugin("/usr/local/bin/sqz");
812 assert!(content.contains("/usr/local/bin/sqz"));
813 assert!(content.contains("SqzPlugin"));
814 assert!(content.contains("tool.execute.before"));
815 }
816
817 #[test]
818 fn test_generate_opencode_plugin_windows_path_escaped() {
819 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
824 let content = generate_opencode_plugin(windows_path);
825 assert!(
829 content.contains(r#"const SQZ_PATH = "C:\\Users\\SqzUser\\.cargo\\bin\\sqz.exe""#),
830 "expected JS-escaped path in plugin — got:\n{content}"
831 );
832 assert!(
835 !content.contains(r#"const SQZ_PATH = "C:\U"#),
836 "plugin must not contain unescaped backslashes in the string literal"
837 );
838 }
839
840 #[test]
841 fn test_generate_opencode_plugin_has_interactive_check() {
842 let content = generate_opencode_plugin("sqz");
843 assert!(content.contains("isInteractive"));
844 assert!(content.contains("vim"));
845 assert!(content.contains("--watch"));
846 }
847
848 #[test]
864 fn test_generate_opencode_plugin_declares_v1_id() {
865 let content = generate_opencode_plugin("sqz");
866 assert!(
867 content.contains("id: \"sqz\""),
868 "plugin must default-export `id: \"sqz\"` so OpenCode's \
869 V1 loader (shared.ts readV1Plugin/resolvePluginId) \
870 displays \"sqz\" in the UI instead of the file path; \
871 got:\n{content}"
872 );
873 assert!(
874 content.contains("server: SqzPluginFactory"),
875 "plugin must default-export `server: <factory>` for V1 \
876 loader compliance; got:\n{content}"
877 );
878 assert!(
879 content.contains("export default {"),
880 "plugin must have a default export per OpenCode V1 shape; \
881 got:\n{content}"
882 );
883 }
884
885 #[test]
896 fn test_generate_opencode_plugin_legacy_named_export_preserved() {
897 let content = generate_opencode_plugin("sqz");
898 assert!(
899 content.contains("export const SqzPlugin = SqzPluginFactory"),
900 "legacy named export must alias the same factory reference \
901 as the V1 default export — otherwise old OpenCode versions \
902 would see two distinct factories in `Object.values(mod)` \
903 and fire the hook twice; got:\n{content}"
904 );
905 }
906
907 #[test]
914 fn test_process_opencode_hook_rewrites_bash() {
915 let input = r#"{"tool":"bash","args":{"command":"git status"}}"#;
916 let result = process_opencode_hook(input).unwrap();
917 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
918 assert_eq!(parsed["decision"].as_str().unwrap(), "approve");
919 let cmd = parsed["args"]["command"].as_str().unwrap();
920 assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
921 assert!(cmd.contains("git status"), "should preserve original: {cmd}");
922 assert!(cmd.contains("--cmd git"), "should pass base command via --cmd: {cmd}");
926 assert!(
927 !cmd.contains("SQZ_CMD="),
928 "must not emit legacy sh-style env prefix: {cmd}"
929 );
930 }
931
932 #[test]
933 fn test_process_opencode_hook_passes_non_shell() {
934 let input = r#"{"tool":"read_file","args":{"path":"file.txt"}}"#;
935 let result = process_opencode_hook(input).unwrap();
936 assert_eq!(result, input, "non-shell tools should pass through");
937 }
938
939 #[test]
940 fn test_process_opencode_hook_skips_sqz_commands() {
941 let input = r#"{"tool":"bash","args":{"command":"sqz stats"}}"#;
942 let result = process_opencode_hook(input).unwrap();
943 assert_eq!(result, input, "sqz commands should not be double-wrapped");
944 }
945
946 #[test]
947 fn test_process_opencode_hook_skips_interactive() {
948 let input = r#"{"tool":"bash","args":{"command":"vim file.txt"}}"#;
949 let result = process_opencode_hook(input).unwrap();
950 assert_eq!(result, input, "interactive commands should pass through");
951 }
952
953 #[test]
954 fn test_process_opencode_hook_skips_watch() {
955 let input = r#"{"tool":"bash","args":{"command":"npm run dev --watch"}}"#;
956 let result = process_opencode_hook(input).unwrap();
957 assert_eq!(result, input, "watch mode should pass through");
958 }
959
960 #[test]
961 fn test_process_opencode_hook_invalid_json() {
962 let result = process_opencode_hook("not json");
963 assert!(result.is_err());
964 }
965
966 #[test]
967 fn test_process_opencode_hook_empty_command() {
968 let input = r#"{"tool":"bash","args":{"command":""}}"#;
969 let result = process_opencode_hook(input).unwrap();
970 assert_eq!(result, input);
971 }
972
973 #[test]
974 fn test_process_opencode_hook_run_shell_command() {
975 let input = r#"{"tool":"run_shell_command","args":{"command":"ls -la"}}"#;
976 let result = process_opencode_hook(input).unwrap();
977 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
978 let cmd = parsed["args"]["command"].as_str().unwrap();
979 assert!(cmd.contains("sqz compress"));
980 }
981
982 #[test]
983 fn test_install_opencode_plugin_creates_file() {
984 let dir = tempfile::tempdir().unwrap();
985 std::env::set_var("HOME", dir.path());
987 let result = install_opencode_plugin("sqz");
988 assert!(result.is_ok());
989 let plugin_path = dir
991 .path()
992 .join(".config/opencode/plugins/sqz.ts");
993 assert!(plugin_path.exists(), "plugin file should exist");
994 let content = std::fs::read_to_string(&plugin_path).unwrap();
995 assert!(content.contains("SqzPlugin"));
996 }
997
998 #[test]
999 fn test_update_opencode_config_creates_new() {
1000 let dir = tempfile::tempdir().unwrap();
1001 let result = update_opencode_config(dir.path()).unwrap();
1002 assert!(result, "should create new config");
1003 let config_path = dir.path().join("opencode.json");
1004 assert!(config_path.exists());
1005 let content = std::fs::read_to_string(&config_path).unwrap();
1006 assert!(content.contains("\"sqz\""));
1007 assert!(content.contains("sqz-mcp"));
1008
1009 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1016 assert!(
1017 parsed.get("plugin").is_none(),
1018 "fresh-install opencode.json must not include `plugin`; got: {content}"
1019 );
1020 assert_eq!(
1021 parsed["mcp"]["sqz"]["type"].as_str(),
1022 Some("local"),
1023 "mcp.sqz must be present"
1024 );
1025 }
1026
1027 #[test]
1028 fn test_update_opencode_config_adds_to_existing() {
1029 let dir = tempfile::tempdir().unwrap();
1030 let config_path = dir.path().join("opencode.json");
1031 std::fs::write(
1032 &config_path,
1033 r#"{"$schema":"https://opencode.ai/config.json","plugin":["other"]}"#,
1034 )
1035 .unwrap();
1036
1037 let result = update_opencode_config(dir.path()).unwrap();
1038 assert!(result, "should update existing config");
1039 let content = std::fs::read_to_string(&config_path).unwrap();
1040 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1041 let plugins = parsed["plugin"].as_array().unwrap();
1046 assert!(
1047 !plugins.iter().any(|v| v.as_str() == Some("sqz")),
1048 "issue #10: sqz must NOT be registered as a config-level plugin \
1049 (the local plugin file at ~/.config/opencode/plugins/sqz.ts \
1050 already loads it; double-registering causes double hook firing)"
1051 );
1052 assert!(
1053 plugins.iter().any(|v| v.as_str() == Some("other")),
1054 "pre-existing plugin entries from OTHER plugins must be preserved"
1055 );
1056 assert_eq!(
1059 parsed["mcp"]["sqz"]["type"].as_str(),
1060 Some("local"),
1061 "mcp.sqz must be added"
1062 );
1063 }
1064
1065 #[test]
1070 fn test_update_opencode_config_removes_legacy_sqz_plugin_entry() {
1071 let dir = tempfile::tempdir().unwrap();
1072 let config_path = dir.path().join("opencode.json");
1073 std::fs::write(
1074 &config_path,
1075 r#"{"plugin":["other","sqz"]}"#,
1076 )
1077 .unwrap();
1078
1079 let changed = update_opencode_config(dir.path()).unwrap();
1080 assert!(changed, "must report that the legacy plugin entry was stripped");
1081
1082 let after = std::fs::read_to_string(&config_path).unwrap();
1083 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1084 let plugins = parsed["plugin"].as_array().unwrap();
1085 assert!(
1086 !plugins.iter().any(|v| v.as_str() == Some("sqz")),
1087 "legacy sqz plugin entry must be stripped on re-init"
1088 );
1089 assert!(
1090 plugins.iter().any(|v| v.as_str() == Some("other")),
1091 "other plugin entries must survive the cleanup"
1092 );
1093 }
1094
1095 #[test]
1099 fn test_update_opencode_config_drops_empty_plugin_array_after_cleanup() {
1100 let dir = tempfile::tempdir().unwrap();
1101 let config_path = dir.path().join("opencode.json");
1102 std::fs::write(&config_path, r#"{"plugin":["sqz"]}"#).unwrap();
1103
1104 update_opencode_config(dir.path()).unwrap();
1105
1106 let after = std::fs::read_to_string(&config_path).unwrap();
1107 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1108 assert!(
1109 parsed.get("plugin").is_none(),
1110 "empty plugin array should be dropped entirely, got: {after}"
1111 );
1112 }
1113
1114 #[test]
1115 fn test_update_opencode_config_skips_if_present() {
1116 let dir = tempfile::tempdir().unwrap();
1117 let config_path = dir.path().join("opencode.json");
1118 std::fs::write(
1119 &config_path,
1120 r#"{
1121 "mcp": {
1122 "sqz": {
1123 "type": "local",
1124 "command": ["sqz-mcp", "--transport", "stdio"],
1125 "enabled": true
1126 }
1127 }
1128}"#,
1129 )
1130 .unwrap();
1131
1132 let result = update_opencode_config(dir.path()).unwrap();
1133 assert!(
1134 !result,
1135 "a config with mcp.sqz including enabled:true must be idempotent"
1136 );
1137 }
1138
1139 #[test]
1144 fn test_update_opencode_config_adds_missing_mcp_entry() {
1145 let dir = tempfile::tempdir().unwrap();
1146 let config_path = dir.path().join("opencode.json");
1147 std::fs::write(&config_path, r#"{"plugin":["sqz"]}"#).unwrap();
1148
1149 let changed = update_opencode_config(dir.path()).unwrap();
1150 assert!(changed, "must report that mcp.sqz was added");
1151
1152 let after = std::fs::read_to_string(&config_path).unwrap();
1153 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1154 assert_eq!(
1155 parsed["mcp"]["sqz"]["type"].as_str(),
1156 Some("local"),
1157 "mcp.sqz must be populated with the default server entry"
1158 );
1159 }
1160
1161 #[test]
1172 fn test_process_opencode_hook_skips_already_wrapped_sqz_cmd_prefix() {
1173 let input = r#"{"tool":"bash","args":{"command":"SQZ_CMD=ddev ddev exec --dir=/var/www/html php -v 2>&1 | /home/user/.cargo/bin/sqz compress"}}"#;
1174 let result = process_opencode_hook(input).unwrap();
1175 assert_eq!(
1176 result, input,
1177 "already-wrapped command must pass through unchanged; \
1178 otherwise each pass accumulates another SQZ_CMD= prefix"
1179 );
1180 }
1181
1182 #[test]
1185 fn test_process_opencode_hook_guard_is_case_insensitive() {
1186 let input = r#"{"tool":"bash","args":{"command":"SQZ_CMD=git git status"}}"#;
1187 let result = process_opencode_hook(input).unwrap();
1188 assert_eq!(
1189 result, input,
1190 "uppercase SQZ_CMD= prefix must short-circuit the wrap"
1191 );
1192 }
1193
1194 #[test]
1200 fn test_process_opencode_hook_skips_leading_env_assignments_for_base() {
1201 let input = r#"{"tool":"bash","args":{"command":"FOO=bar BAZ=qux make test"}}"#;
1202 let result = process_opencode_hook(input).unwrap();
1203 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1204 let cmd = parsed["args"]["command"].as_str().unwrap();
1205 assert!(
1206 cmd.contains("--cmd make"),
1207 "base command must be `make`, not `FOO=bar`; got: {cmd}"
1208 );
1209 assert!(
1210 cmd.contains("FOO=bar BAZ=qux make test"),
1211 "original command must be preserved: {cmd}"
1212 );
1213 }
1214
1215 #[test]
1217 fn test_process_opencode_hook_skips_bare_sqz_invocation() {
1218 for cmd in ["sqz stats", "sqz gain", "/usr/local/bin/sqz compress"] {
1219 let input = format!(
1220 r#"{{"tool":"bash","args":{{"command":"{cmd}"}}}}"#
1221 );
1222 let result = process_opencode_hook(&input).unwrap();
1223 assert_eq!(
1224 result, input,
1225 "sqz-invoking command `{cmd}` must not be rewrapped"
1226 );
1227 }
1228 }
1229
1230 #[test]
1234 fn test_generate_opencode_plugin_has_double_wrap_guard() {
1235 let content = generate_opencode_plugin("sqz");
1236 assert!(
1237 content.contains("function isAlreadyWrapped(cmd: string): boolean"),
1238 "generated plugin must define isAlreadyWrapped helper"
1239 );
1240 assert!(
1241 content.contains(r#"lowered.includes("sqz_cmd=")"#),
1242 "plugin must check for the SQZ_CMD= prior-wrap prefix"
1243 );
1244 assert!(
1245 content.contains(r#"lowered.includes("sqz compress")"#),
1246 "plugin must check for the `sqz compress` prior-wrap tail"
1247 );
1248 assert!(
1249 content.contains("isAlreadyWrapped(cmd)"),
1250 "plugin hook body must call isAlreadyWrapped on the command"
1251 );
1252 assert!(
1253 content.contains("function extractBaseCmd(cmd: string): string"),
1254 "plugin must define extractBaseCmd that skips env assignments"
1255 );
1256 assert!(
1257 content.contains("extractBaseCmd(cmd)"),
1258 "plugin hook body must use extractBaseCmd, not raw split"
1259 );
1260 }
1261
1262 #[test]
1265 fn test_is_already_wrapped_detects_all_marker_shapes() {
1266 assert!(is_already_wrapped("SQZ_CMD=git git status"));
1267 assert!(is_already_wrapped("sqz_cmd=git git status"));
1268 assert!(is_already_wrapped("git status | sqz compress"));
1269 assert!(is_already_wrapped("git status 2>&1 | /path/sqz compress"));
1270 assert!(is_already_wrapped("ls -la | sqz compress-stream"));
1271 assert!(is_already_wrapped("sqz stats"));
1272 assert!(is_already_wrapped("/usr/local/bin/sqz gain"));
1273 assert!(is_already_wrapped("SQZ_FOO=bar cmd"));
1274 assert!(!is_already_wrapped("git status"));
1275 assert!(!is_already_wrapped("grep sqz logfile.txt"));
1276 assert!(!is_already_wrapped("cargo test --package my-sqz-crate"));
1277 }
1278
1279 #[test]
1280 fn test_extract_base_cmd_skips_env_assignments() {
1281 assert_eq!(extract_base_cmd("make test"), "make");
1282 assert_eq!(extract_base_cmd("FOO=bar make test"), "make");
1283 assert_eq!(extract_base_cmd("FOO=bar BAZ=qux make test"), "make");
1284 assert_eq!(extract_base_cmd("/usr/bin/git status"), "git");
1285 assert_eq!(extract_base_cmd(""), "unknown");
1286 assert_eq!(extract_base_cmd("FOO=bar"), "unknown");
1287 }
1288
1289 #[test]
1290 fn test_is_env_assignment() {
1291 assert!(is_env_assignment("FOO=bar"));
1292 assert!(is_env_assignment("FOO="));
1293 assert!(is_env_assignment("_underscore=1"));
1294 assert!(is_env_assignment("MixedCase_1=x"));
1295 assert!(!is_env_assignment("=bar"));
1296 assert!(!is_env_assignment("FOO"));
1297 assert!(!is_env_assignment("--flag=value"));
1298 assert!(!is_env_assignment("123=value"));
1299 assert!(!is_env_assignment("FOO BAR=baz"));
1300 }
1301
1302 #[test]
1311 fn test_update_merges_into_existing_jsonc() {
1312 let dir = tempfile::tempdir().unwrap();
1313 let jsonc = dir.path().join("opencode.jsonc");
1314 std::fs::write(
1315 &jsonc,
1316 r#"{
1317 // user's own config with a comment
1318 "$schema": "https://opencode.ai/config.json",
1319 "model": "anthropic/claude-sonnet-4-5",
1320 /* another comment */
1321 "plugin": ["other-plugin"]
1322}
1323"#,
1324 )
1325 .unwrap();
1326
1327 let changed = update_opencode_config(dir.path()).unwrap();
1328 assert!(changed, "must merge sqz entries into the existing .jsonc");
1329
1330 assert!(jsonc.exists(), "original .jsonc must still exist");
1332 assert!(
1333 !dir.path().join("opencode.json").exists(),
1334 "must not create a parallel opencode.json alongside .jsonc \
1335 (that's the issue #6 bug)"
1336 );
1337
1338 let after = std::fs::read_to_string(&jsonc).unwrap();
1339 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1340 let plugins = parsed["plugin"].as_array().unwrap();
1341 assert!(
1345 !plugins.iter().any(|v| v.as_str() == Some("sqz")),
1346 "issue #10: sqz must NOT be added to plugin[]"
1347 );
1348 assert!(
1349 plugins.iter().any(|v| v.as_str() == Some("other-plugin")),
1350 "pre-existing plugin entries must be preserved"
1351 );
1352 assert_eq!(
1353 parsed["model"].as_str(),
1354 Some("anthropic/claude-sonnet-4-5"),
1355 "unrelated user keys must survive the merge"
1356 );
1357 assert_eq!(
1358 parsed["mcp"]["sqz"]["type"].as_str(),
1359 Some("local"),
1360 "mcp.sqz must be registered"
1361 );
1362 }
1363
1364 #[test]
1368 fn test_update_opencode_config_detailed_reports_comments_lost() {
1369 let dir = tempfile::tempdir().unwrap();
1370 let jsonc = dir.path().join("opencode.jsonc");
1371 std::fs::write(
1372 &jsonc,
1373 r#"{
1374 // comment to be dropped
1375 "plugin": ["other"]
1376}
1377"#,
1378 )
1379 .unwrap();
1380
1381 let (changed, comments_lost) =
1382 update_opencode_config_detailed(dir.path()).unwrap();
1383 assert!(changed);
1384 assert!(
1385 comments_lost,
1386 "merger must report that comments were dropped from .jsonc"
1387 );
1388 }
1389
1390 #[test]
1394 fn test_update_creates_plain_json_when_nothing_exists() {
1395 let dir = tempfile::tempdir().unwrap();
1396 update_opencode_config(dir.path()).unwrap();
1397 assert!(dir.path().join("opencode.json").exists());
1398 assert!(!dir.path().join("opencode.jsonc").exists());
1399 }
1400
1401 #[test]
1403 fn test_find_opencode_config_prefers_jsonc() {
1404 let dir = tempfile::tempdir().unwrap();
1405 std::fs::write(dir.path().join("opencode.json"), "{}").unwrap();
1406 std::fs::write(dir.path().join("opencode.jsonc"), "{}").unwrap();
1407 let found = find_opencode_config(dir.path()).unwrap();
1408 assert_eq!(
1409 found.file_name().unwrap(),
1410 "opencode.jsonc",
1411 "must prefer the .jsonc variant when both exist — the user \
1412 is maintaining .jsonc for its comment support"
1413 );
1414 }
1415
1416 #[test]
1417 fn test_find_opencode_config_returns_none_when_missing() {
1418 let dir = tempfile::tempdir().unwrap();
1419 assert!(find_opencode_config(dir.path()).is_none());
1420 }
1421
1422 #[test]
1423 fn test_opencode_config_has_comments_detects_jsonc_comments() {
1424 let dir = tempfile::tempdir().unwrap();
1425 std::fs::write(
1426 dir.path().join("opencode.jsonc"),
1427 "// a line comment\n{\"plugin\":[]}\n",
1428 )
1429 .unwrap();
1430 assert!(opencode_config_has_comments(dir.path()));
1431 }
1432
1433 #[test]
1434 fn test_opencode_config_has_comments_ignores_plain_json() {
1435 let dir = tempfile::tempdir().unwrap();
1436 std::fs::write(
1438 dir.path().join("opencode.json"),
1439 r#"{"url":"http://example.com"}"#,
1440 )
1441 .unwrap();
1442 assert!(!opencode_config_has_comments(dir.path()));
1443 }
1444
1445 #[test]
1448 fn test_strip_jsonc_comments_removes_line_comments() {
1449 let src = "{\n // leading comment\n \"a\": 1 // trailing\n}";
1450 let stripped = strip_jsonc_comments(src);
1451 assert!(!stripped.contains("leading comment"));
1452 assert!(!stripped.contains("trailing"));
1453 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1454 assert_eq!(parsed["a"], 1);
1455 }
1456
1457 #[test]
1458 fn test_strip_jsonc_comments_removes_block_comments() {
1459 let src = "{\n /* block\n comment */\n \"a\": 1\n}";
1460 let stripped = strip_jsonc_comments(src);
1461 assert!(!stripped.contains("block"));
1462 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1463 assert_eq!(parsed["a"], 1);
1464 }
1465
1466 #[test]
1467 fn test_strip_jsonc_comments_preserves_strings() {
1468 let src = r#"{"url": "http://example.com", "re": "/* not a comment */"}"#;
1473 let stripped = strip_jsonc_comments(src);
1474 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1475 assert_eq!(parsed["url"], "http://example.com");
1476 assert_eq!(parsed["re"], "/* not a comment */");
1477 }
1478
1479 #[test]
1480 fn test_strip_jsonc_comments_preserves_escaped_quote_in_string() {
1481 let src = r#"{"s": "a\"//b"}"#;
1482 let stripped = strip_jsonc_comments(src);
1483 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1484 assert_eq!(parsed["s"], r#"a"//b"#);
1485 }
1486
1487 #[test]
1488 fn test_strip_jsonc_comments_tolerates_unterminated_block() {
1489 let src = "{\"a\":1 /* never ends";
1491 let _ = strip_jsonc_comments(src); }
1493
1494 #[test]
1502 fn test_remove_sqz_preserves_other_user_config() {
1503 let dir = tempfile::tempdir().unwrap();
1504 let config = dir.path().join("opencode.json");
1505 std::fs::write(
1506 &config,
1507 r#"{
1508 "$schema": "https://opencode.ai/config.json",
1509 "model": "anthropic/claude-sonnet-4-5",
1510 "plugin": ["other-plugin", "sqz"],
1511 "mcp": {
1512 "sqz": { "type": "local", "command": ["sqz-mcp"] },
1513 "jira": { "type": "remote", "url": "https://jira.example.com/mcp" }
1514 }
1515}
1516"#,
1517 )
1518 .unwrap();
1519
1520 let (path, changed) =
1521 remove_sqz_from_opencode_config(dir.path()).unwrap().unwrap();
1522 assert_eq!(path, config);
1523 assert!(changed, "must report that sqz entries were removed");
1524 assert!(
1525 config.exists(),
1526 "file must NOT be deleted — only sqz's entries removed"
1527 );
1528
1529 let after = std::fs::read_to_string(&config).unwrap();
1530 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1531 let plugins = parsed["plugin"].as_array().unwrap();
1532 assert!(!plugins.iter().any(|v| v.as_str() == Some("sqz")));
1533 assert!(plugins.iter().any(|v| v.as_str() == Some("other-plugin")));
1534 let mcp = parsed["mcp"].as_object().unwrap();
1535 assert!(!mcp.contains_key("sqz"), "mcp.sqz must be gone");
1536 assert!(mcp.contains_key("jira"), "mcp.jira must survive");
1537 assert_eq!(
1538 parsed["model"].as_str(),
1539 Some("anthropic/claude-sonnet-4-5"),
1540 "unrelated keys must survive"
1541 );
1542 }
1543
1544 #[test]
1548 fn test_remove_sqz_deletes_file_when_nothing_else_remains() {
1549 let dir = tempfile::tempdir().unwrap();
1550 let config = dir.path().join("opencode.json");
1551 std::fs::write(
1553 &config,
1554 r#"{
1555 "$schema": "https://opencode.ai/config.json",
1556 "mcp": {
1557 "sqz": { "type": "local", "command": ["sqz-mcp", "--transport", "stdio"] }
1558 },
1559 "plugin": ["sqz"]
1560}
1561"#,
1562 )
1563 .unwrap();
1564
1565 let (_, changed) =
1566 remove_sqz_from_opencode_config(dir.path()).unwrap().unwrap();
1567 assert!(changed);
1568 assert!(
1569 !config.exists(),
1570 "file with only $schema + sqz entries must be removed"
1571 );
1572 }
1573
1574 #[test]
1577 fn test_remove_sqz_returns_none_when_config_missing() {
1578 let dir = tempfile::tempdir().unwrap();
1579 let result = remove_sqz_from_opencode_config(dir.path()).unwrap();
1580 assert!(result.is_none());
1581 }
1582
1583 #[test]
1586 fn test_remove_sqz_from_jsonc_drops_comments() {
1587 let dir = tempfile::tempdir().unwrap();
1588 let jsonc = dir.path().join("opencode.jsonc");
1589 std::fs::write(
1590 &jsonc,
1591 r#"{
1592 // user's comment
1593 "model": "x",
1594 "plugin": ["sqz", "other"]
1595}
1596"#,
1597 )
1598 .unwrap();
1599
1600 let (path, changed) =
1601 remove_sqz_from_opencode_config(dir.path()).unwrap().unwrap();
1602 assert_eq!(path, jsonc);
1603 assert!(changed);
1604 assert!(path.exists(), "jsonc file kept because `model` and `other` remain");
1605
1606 let after = std::fs::read_to_string(&jsonc).unwrap();
1607 assert!(
1608 !after.contains("// user's comment"),
1609 "comments are dropped by the serde_json round-trip; \
1610 documented in update_opencode_config_detailed"
1611 );
1612 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1613 let plugins = parsed["plugin"].as_array().unwrap();
1614 assert_eq!(plugins.len(), 1);
1615 assert_eq!(plugins[0], "other");
1616 }
1617
1618 #[test]
1628 fn issue_10_opencode_rewrite_works_in_powershell_syntax() {
1629 let input = r#"{"tool":"bash","args":{"command":"dotnet build NewNeonCheckers3.sln"}}"#;
1630 let result = process_opencode_hook(input).unwrap();
1631 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1632 let cmd = parsed["args"]["command"].as_str().unwrap();
1633
1634 assert!(
1638 !cmd.contains("SQZ_CMD="),
1639 "issue #10: rewrite must not emit `SQZ_CMD=` (breaks on \
1640 PowerShell/cmd.exe); got: {cmd}"
1641 );
1642 assert!(
1644 cmd.contains("--cmd dotnet"),
1645 "rewrite must pass label via --cmd; got: {cmd}"
1646 );
1647 let first_token = cmd.split_whitespace().next().unwrap_or("");
1652 assert_eq!(
1653 first_token, "dotnet",
1654 "first token of the rewritten command must be the user's \
1655 command itself, not an env-var assignment; got: {cmd}"
1656 );
1657 }
1658
1659 #[test]
1663 fn issue_10_ts_plugin_emits_cmd_flag_not_env_prefix() {
1664 let content = generate_opencode_plugin("sqz");
1665 assert!(
1669 content.contains("compress --cmd"),
1670 "TS plugin must build rewrite with `compress --cmd ${{base}}`"
1671 );
1672 assert!(
1678 !content.contains("SQZ_CMD=${base}"),
1679 "TS plugin must not emit the legacy `SQZ_CMD=${{base}}` prefix"
1680 );
1681 }
1682
1683 #[test]
1698 fn issue_10_fresh_opencode_config_has_no_plugin_entry() {
1699 let dir = tempfile::tempdir().unwrap();
1700 update_opencode_config(dir.path()).unwrap();
1701 let content = std::fs::read_to_string(dir.path().join("opencode.json")).unwrap();
1702 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1703
1704 assert!(
1706 parsed.get("plugin").is_none(),
1707 "issue #10: fresh opencode.json must not include `plugin` key; got: {content}"
1708 );
1709
1710 assert_eq!(
1713 parsed["mcp"]["sqz"]["type"].as_str(),
1714 Some("local"),
1715 "mcp.sqz is the one sqz-authored entry that belongs in \
1716 opencode.json; must still be registered"
1717 );
1718 }
1719
1720 #[test]
1725 fn issue_10_reinit_strips_legacy_plugin_entry() {
1726 let dir = tempfile::tempdir().unwrap();
1727 let config = dir.path().join("opencode.json");
1728 std::fs::write(
1729 &config,
1730 r#"{"$schema":"https://opencode.ai/config.json","mcp":{"sqz":{"type":"local","command":["sqz-mcp","--transport","stdio"]}},"plugin":["sqz"]}"#,
1732 )
1733 .unwrap();
1734
1735 let changed = update_opencode_config(dir.path()).unwrap();
1736 assert!(changed, "re-init must report a change (the legacy entry was stripped)");
1737
1738 let after = std::fs::read_to_string(&config).unwrap();
1739 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1740 assert!(
1741 parsed.get("plugin").is_none(),
1742 "legacy `plugin: [\"sqz\"]` must be stripped on re-init; got: {after}"
1743 );
1744 assert_eq!(
1746 parsed["mcp"]["sqz"]["type"].as_str(),
1747 Some("local"),
1748 "mcp.sqz must survive cleanup of the plugin entry"
1749 );
1750 }
1751}