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> {
203 let plugin_path = opencode_plugin_path();
204
205 if plugin_path.exists() {
206 return Ok(false);
207 }
208
209 if let Some(parent) = plugin_path.parent() {
210 std::fs::create_dir_all(parent).map_err(|e| {
211 crate::error::SqzError::Other(format!(
212 "failed to create OpenCode plugins dir {}: {e}",
213 parent.display()
214 ))
215 })?;
216 }
217
218 let content = generate_opencode_plugin(sqz_path);
219 std::fs::write(&plugin_path, &content).map_err(|e| {
220 crate::error::SqzError::Other(format!(
221 "failed to write OpenCode plugin to {}: {e}",
222 plugin_path.display()
223 ))
224 })?;
225
226 Ok(true)
227}
228
229pub fn find_opencode_config(project_dir: &Path) -> Option<PathBuf> {
236 let jsonc = project_dir.join("opencode.jsonc");
237 if jsonc.exists() {
238 return Some(jsonc);
239 }
240 let json = project_dir.join("opencode.json");
241 if json.exists() {
242 return Some(json);
243 }
244 None
245}
246
247pub fn opencode_config_has_comments(project_dir: &Path) -> bool {
252 let path = match find_opencode_config(project_dir) {
253 Some(p) => p,
254 None => return false,
255 };
256 if path.extension().map(|e| e != "jsonc").unwrap_or(true) {
257 return false;
258 }
259 let content = match std::fs::read_to_string(&path) {
260 Ok(s) => s,
261 Err(_) => return false,
262 };
263 strip_jsonc_comments(&content) != content
264}
265
266pub fn strip_jsonc_comments(src: &str) -> String {
276 let mut out = String::with_capacity(src.len());
277 let bytes = src.as_bytes();
278 let mut i = 0;
279 let len = bytes.len();
280
281 while i < len {
282 let b = bytes[i];
283
284 if b == b'"' {
287 out.push('"');
288 i += 1;
289 while i < len {
290 let c = bytes[i];
291 out.push(c as char);
292 if c == b'\\' && i + 1 < len {
293 out.push(bytes[i + 1] as char);
295 i += 2;
296 continue;
297 }
298 i += 1;
299 if c == b'"' {
300 break;
301 }
302 }
303 continue;
304 }
305
306 if b == b'/' && i + 1 < len && bytes[i + 1] == b'/' {
309 i += 2;
310 while i < len && bytes[i] != b'\n' {
311 i += 1;
312 }
313 continue;
314 }
315
316 if b == b'/' && i + 1 < len && bytes[i + 1] == b'*' {
318 i += 2;
319 while i + 1 < len && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
320 if bytes[i] == b'\n' {
322 out.push('\n');
323 }
324 i += 1;
325 }
326 if i + 1 < len {
329 i += 2;
330 }
331 continue;
332 }
333
334 out.push(b as char);
335 i += 1;
336 }
337
338 out
339}
340
341pub fn update_opencode_config(project_dir: &Path) -> Result<bool> {
357 let (updated, _) = update_opencode_config_detailed(project_dir)?;
358 Ok(updated)
359}
360
361pub fn update_opencode_config_detailed(project_dir: &Path) -> Result<(bool, bool)> {
365 fn sqz_mcp_value() -> serde_json::Value {
367 serde_json::json!({
368 "type": "local",
369 "command": ["sqz-mcp", "--transport", "stdio"]
370 })
371 }
372
373 if let Some(existing_path) = find_opencode_config(project_dir) {
374 let is_jsonc = existing_path
375 .extension()
376 .map(|e| e == "jsonc")
377 .unwrap_or(false);
378 let content = std::fs::read_to_string(&existing_path).map_err(|e| {
379 crate::error::SqzError::Other(format!(
380 "failed to read {}: {e}",
381 existing_path.display()
382 ))
383 })?;
384
385 let parseable = if is_jsonc {
386 strip_jsonc_comments(&content)
387 } else {
388 content.clone()
389 };
390
391 let had_comments = is_jsonc && parseable != content;
394
395 let mut config: serde_json::Value = serde_json::from_str(&parseable).map_err(|e| {
397 crate::error::SqzError::Other(format!(
398 "failed to parse {}: {e}",
399 existing_path.display()
400 ))
401 })?;
402
403 let obj = config.as_object_mut().ok_or_else(|| {
404 crate::error::SqzError::Other(format!(
405 "{} root is not a JSON object",
406 existing_path.display()
407 ))
408 })?;
409
410 let mut changed = false;
411
412 if let Some(arr) = obj.get_mut("plugin").and_then(|v| v.as_array_mut()) {
427 let before = arr.len();
428 arr.retain(|v| v.as_str() != Some("sqz"));
429 if arr.len() != before {
430 changed = true;
431 }
432 if arr.is_empty() {
438 obj.remove("plugin");
439 changed = true;
440 }
441 }
442
443 let mcp_entry = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
445 if let Some(mcp_obj) = mcp_entry.as_object_mut() {
446 if !mcp_obj.contains_key("sqz") {
447 mcp_obj.insert("sqz".to_string(), sqz_mcp_value());
448 changed = true;
449 }
450 } else {
453 return Err(crate::error::SqzError::Other(format!(
454 "{} has an `mcp` field that is not an object; \
455 refusing to modify it automatically",
456 existing_path.display()
457 )));
458 }
459
460 if !changed {
461 return Ok((false, false));
462 }
463
464 let updated = serde_json::to_string_pretty(&config).map_err(|e| {
467 crate::error::SqzError::Other(format!("failed to serialize config: {e}"))
468 })?;
469 std::fs::write(&existing_path, format!("{updated}\n")).map_err(|e| {
470 crate::error::SqzError::Other(format!(
471 "failed to write {}: {e}",
472 existing_path.display()
473 ))
474 })?;
475
476 Ok((true, had_comments))
477 } else {
478 let config = serde_json::json!({
488 "$schema": "https://opencode.ai/config.json",
489 "mcp": {
490 "sqz": sqz_mcp_value()
491 }
492 });
493 let content = serde_json::to_string_pretty(&config).map_err(|e| {
494 crate::error::SqzError::Other(format!("failed to serialize opencode.json: {e}"))
495 })?;
496 let path = project_dir.join("opencode.json");
497 std::fs::write(&path, format!("{content}\n")).map_err(|e| {
498 crate::error::SqzError::Other(format!("failed to write opencode.json: {e}"))
499 })?;
500 Ok((true, false))
501 }
502}
503
504pub fn remove_sqz_from_opencode_config(project_dir: &Path) -> Result<Option<(PathBuf, bool)>> {
517 let path = match find_opencode_config(project_dir) {
518 Some(p) => p,
519 None => return Ok(None),
520 };
521 let is_jsonc = path.extension().map(|e| e == "jsonc").unwrap_or(false);
522 let raw = std::fs::read_to_string(&path).map_err(|e| {
523 crate::error::SqzError::Other(format!("failed to read {}: {e}", path.display()))
524 })?;
525 let parseable = if is_jsonc {
526 strip_jsonc_comments(&raw)
527 } else {
528 raw.clone()
529 };
530 let mut config: serde_json::Value = match serde_json::from_str(&parseable) {
531 Ok(v) => v,
532 Err(_) => {
533 return Ok(Some((path, false)));
535 }
536 };
537
538 let mut changed = false;
539
540 if let Some(obj) = config.as_object_mut() {
541 if let Some(plugin) = obj.get_mut("plugin").and_then(|v| v.as_array_mut()) {
543 let before = plugin.len();
544 plugin.retain(|v| v.as_str() != Some("sqz"));
545 if plugin.len() != before {
546 changed = true;
547 }
548 if plugin.is_empty() {
550 obj.remove("plugin");
551 }
552 }
553
554 if let Some(mcp) = obj.get_mut("mcp").and_then(|v| v.as_object_mut()) {
556 if mcp.remove("sqz").is_some() {
557 changed = true;
558 }
559 if mcp.is_empty() {
560 obj.remove("mcp");
561 }
562 }
563 }
564
565 if !changed {
566 return Ok(Some((path, false)));
567 }
568
569 let essentially_empty = match config.as_object() {
574 Some(obj) => {
575 obj.is_empty()
576 || (obj.len() == 1
577 && obj.get("$schema").and_then(|v| v.as_str())
578 == Some("https://opencode.ai/config.json"))
579 }
580 None => false,
581 };
582
583 if essentially_empty {
584 std::fs::remove_file(&path).map_err(|e| {
585 crate::error::SqzError::Other(format!(
586 "failed to remove {}: {e}",
587 path.display()
588 ))
589 })?;
590 return Ok(Some((path, true)));
591 }
592
593 let updated = serde_json::to_string_pretty(&config).map_err(|e| {
596 crate::error::SqzError::Other(format!("failed to serialize config: {e}"))
597 })?;
598 std::fs::write(&path, format!("{updated}\n")).map_err(|e| {
599 crate::error::SqzError::Other(format!(
600 "failed to write {}: {e}",
601 path.display()
602 ))
603 })?;
604 Ok(Some((path, true)))
605}
606
607fn is_already_wrapped(command: &str) -> bool {
619 let lowered = command.to_ascii_lowercase();
620 if lowered.contains("sqz_cmd=") {
621 return true;
622 }
623 if lowered.contains("sqz compress") {
624 return true;
625 }
626 if lowered.contains("| sqz ") || lowered.contains("| sqz\t") {
627 return true;
628 }
629 let trimmed = command.trim_start();
631 if let Some(eq_idx) = trimmed.find('=') {
632 let name = &trimmed[..eq_idx];
633 if name.starts_with("SQZ_")
634 && !name.is_empty()
635 && name
636 .chars()
637 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
638 {
639 return true;
640 }
641 }
642 let base = extract_base_cmd(command);
644 if base == "sqz" || base == "sqz-mcp" || base == "sqz.exe" {
645 return true;
646 }
647 false
648}
649
650fn extract_base_cmd(command: &str) -> &str {
657 for tok in command.split_whitespace() {
658 if is_env_assignment(tok) {
659 continue;
660 }
661 return tok.rsplit('/').next().unwrap_or("unknown");
662 }
663 "unknown"
664}
665
666fn is_env_assignment(token: &str) -> bool {
670 let eq = match token.find('=') {
671 Some(i) => i,
672 None => return false,
673 };
674 if eq == 0 {
675 return false;
676 }
677 let name = &token[..eq];
678 let mut chars = name.chars();
679 match chars.next() {
680 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
681 _ => return false,
682 }
683 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
684}
685
686pub fn process_opencode_hook(input: &str) -> Result<String> {
696 let parsed: serde_json::Value = serde_json::from_str(input)
697 .map_err(|e| crate::error::SqzError::Other(format!("opencode hook: invalid JSON: {e}")))?;
698
699 let tool = parsed
700 .get("tool")
701 .or_else(|| parsed.get("toolName"))
702 .or_else(|| parsed.get("tool_name"))
703 .and_then(|v| v.as_str())
704 .unwrap_or("");
705
706 if !matches!(
708 tool.to_lowercase().as_str(),
709 "bash" | "shell" | "terminal" | "run_shell_command"
710 ) {
711 return Ok(input.to_string());
712 }
713
714 let command = parsed
716 .get("args")
717 .or_else(|| parsed.get("toolCall"))
718 .or_else(|| parsed.get("tool_input"))
719 .and_then(|v| v.get("command"))
720 .and_then(|v| v.as_str())
721 .unwrap_or("");
722
723 if command.is_empty() || is_already_wrapped(command) {
724 return Ok(input.to_string());
725 }
726
727 let base = extract_base_cmd(command);
731
732 if matches!(
733 base,
734 "vim" | "vi" | "nano" | "emacs" | "less" | "more" | "top" | "htop"
735 | "ssh" | "python" | "python3" | "node" | "irb" | "ghci"
736 | "psql" | "mysql" | "sqlite3" | "mongo" | "redis-cli"
737 ) || command.contains("--watch")
738 || command.contains("run dev")
739 || command.contains("run start")
740 || command.contains("run serve")
741 {
742 return Ok(input.to_string());
743 }
744
745 let base_cmd = base;
747
748 let escaped_base = if base_cmd
749 .chars()
750 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
751 {
752 base_cmd.to_string()
753 } else {
754 format!("'{}'", base_cmd.replace('\'', "'\\''"))
755 };
756
757 let rewritten = format!(
762 "{} 2>&1 | sqz compress --cmd {}",
763 command, escaped_base,
764 );
765
766 let output = serde_json::json!({
768 "decision": "approve",
769 "reason": "sqz: command output will be compressed for token savings",
770 "updatedInput": {
771 "command": rewritten
772 },
773 "args": {
774 "command": rewritten
775 }
776 });
777
778 serde_json::to_string(&output)
779 .map_err(|e| crate::error::SqzError::Other(format!("opencode hook: serialize error: {e}")))
780}
781
782#[cfg(test)]
785mod tests {
786 use super::*;
787
788 #[test]
789 fn test_generate_opencode_plugin_contains_sqz_path() {
790 let content = generate_opencode_plugin("/usr/local/bin/sqz");
791 assert!(content.contains("/usr/local/bin/sqz"));
792 assert!(content.contains("SqzPlugin"));
793 assert!(content.contains("tool.execute.before"));
794 }
795
796 #[test]
797 fn test_generate_opencode_plugin_windows_path_escaped() {
798 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
803 let content = generate_opencode_plugin(windows_path);
804 assert!(
808 content.contains(r#"const SQZ_PATH = "C:\\Users\\SqzUser\\.cargo\\bin\\sqz.exe""#),
809 "expected JS-escaped path in plugin — got:\n{content}"
810 );
811 assert!(
814 !content.contains(r#"const SQZ_PATH = "C:\U"#),
815 "plugin must not contain unescaped backslashes in the string literal"
816 );
817 }
818
819 #[test]
820 fn test_generate_opencode_plugin_has_interactive_check() {
821 let content = generate_opencode_plugin("sqz");
822 assert!(content.contains("isInteractive"));
823 assert!(content.contains("vim"));
824 assert!(content.contains("--watch"));
825 }
826
827 #[test]
843 fn test_generate_opencode_plugin_declares_v1_id() {
844 let content = generate_opencode_plugin("sqz");
845 assert!(
846 content.contains("id: \"sqz\""),
847 "plugin must default-export `id: \"sqz\"` so OpenCode's \
848 V1 loader (shared.ts readV1Plugin/resolvePluginId) \
849 displays \"sqz\" in the UI instead of the file path; \
850 got:\n{content}"
851 );
852 assert!(
853 content.contains("server: SqzPluginFactory"),
854 "plugin must default-export `server: <factory>` for V1 \
855 loader compliance; got:\n{content}"
856 );
857 assert!(
858 content.contains("export default {"),
859 "plugin must have a default export per OpenCode V1 shape; \
860 got:\n{content}"
861 );
862 }
863
864 #[test]
875 fn test_generate_opencode_plugin_legacy_named_export_preserved() {
876 let content = generate_opencode_plugin("sqz");
877 assert!(
878 content.contains("export const SqzPlugin = SqzPluginFactory"),
879 "legacy named export must alias the same factory reference \
880 as the V1 default export — otherwise old OpenCode versions \
881 would see two distinct factories in `Object.values(mod)` \
882 and fire the hook twice; got:\n{content}"
883 );
884 }
885
886 #[test]
893 fn test_process_opencode_hook_rewrites_bash() {
894 let input = r#"{"tool":"bash","args":{"command":"git status"}}"#;
895 let result = process_opencode_hook(input).unwrap();
896 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
897 assert_eq!(parsed["decision"].as_str().unwrap(), "approve");
898 let cmd = parsed["args"]["command"].as_str().unwrap();
899 assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
900 assert!(cmd.contains("git status"), "should preserve original: {cmd}");
901 assert!(cmd.contains("--cmd git"), "should pass base command via --cmd: {cmd}");
905 assert!(
906 !cmd.contains("SQZ_CMD="),
907 "must not emit legacy sh-style env prefix: {cmd}"
908 );
909 }
910
911 #[test]
912 fn test_process_opencode_hook_passes_non_shell() {
913 let input = r#"{"tool":"read_file","args":{"path":"file.txt"}}"#;
914 let result = process_opencode_hook(input).unwrap();
915 assert_eq!(result, input, "non-shell tools should pass through");
916 }
917
918 #[test]
919 fn test_process_opencode_hook_skips_sqz_commands() {
920 let input = r#"{"tool":"bash","args":{"command":"sqz stats"}}"#;
921 let result = process_opencode_hook(input).unwrap();
922 assert_eq!(result, input, "sqz commands should not be double-wrapped");
923 }
924
925 #[test]
926 fn test_process_opencode_hook_skips_interactive() {
927 let input = r#"{"tool":"bash","args":{"command":"vim file.txt"}}"#;
928 let result = process_opencode_hook(input).unwrap();
929 assert_eq!(result, input, "interactive commands should pass through");
930 }
931
932 #[test]
933 fn test_process_opencode_hook_skips_watch() {
934 let input = r#"{"tool":"bash","args":{"command":"npm run dev --watch"}}"#;
935 let result = process_opencode_hook(input).unwrap();
936 assert_eq!(result, input, "watch mode should pass through");
937 }
938
939 #[test]
940 fn test_process_opencode_hook_invalid_json() {
941 let result = process_opencode_hook("not json");
942 assert!(result.is_err());
943 }
944
945 #[test]
946 fn test_process_opencode_hook_empty_command() {
947 let input = r#"{"tool":"bash","args":{"command":""}}"#;
948 let result = process_opencode_hook(input).unwrap();
949 assert_eq!(result, input);
950 }
951
952 #[test]
953 fn test_process_opencode_hook_run_shell_command() {
954 let input = r#"{"tool":"run_shell_command","args":{"command":"ls -la"}}"#;
955 let result = process_opencode_hook(input).unwrap();
956 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
957 let cmd = parsed["args"]["command"].as_str().unwrap();
958 assert!(cmd.contains("sqz compress"));
959 }
960
961 #[test]
962 fn test_install_opencode_plugin_creates_file() {
963 let dir = tempfile::tempdir().unwrap();
964 std::env::set_var("HOME", dir.path());
966 let result = install_opencode_plugin("sqz");
967 assert!(result.is_ok());
968 let plugin_path = dir
970 .path()
971 .join(".config/opencode/plugins/sqz.ts");
972 assert!(plugin_path.exists(), "plugin file should exist");
973 let content = std::fs::read_to_string(&plugin_path).unwrap();
974 assert!(content.contains("SqzPlugin"));
975 }
976
977 #[test]
978 fn test_update_opencode_config_creates_new() {
979 let dir = tempfile::tempdir().unwrap();
980 let result = update_opencode_config(dir.path()).unwrap();
981 assert!(result, "should create new config");
982 let config_path = dir.path().join("opencode.json");
983 assert!(config_path.exists());
984 let content = std::fs::read_to_string(&config_path).unwrap();
985 assert!(content.contains("\"sqz\""));
986 assert!(content.contains("sqz-mcp"));
987
988 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
995 assert!(
996 parsed.get("plugin").is_none(),
997 "fresh-install opencode.json must not include `plugin`; got: {content}"
998 );
999 assert_eq!(
1000 parsed["mcp"]["sqz"]["type"].as_str(),
1001 Some("local"),
1002 "mcp.sqz must be present"
1003 );
1004 }
1005
1006 #[test]
1007 fn test_update_opencode_config_adds_to_existing() {
1008 let dir = tempfile::tempdir().unwrap();
1009 let config_path = dir.path().join("opencode.json");
1010 std::fs::write(
1011 &config_path,
1012 r#"{"$schema":"https://opencode.ai/config.json","plugin":["other"]}"#,
1013 )
1014 .unwrap();
1015
1016 let result = update_opencode_config(dir.path()).unwrap();
1017 assert!(result, "should update existing config");
1018 let content = std::fs::read_to_string(&config_path).unwrap();
1019 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1020 let plugins = parsed["plugin"].as_array().unwrap();
1025 assert!(
1026 !plugins.iter().any(|v| v.as_str() == Some("sqz")),
1027 "issue #10: sqz must NOT be registered as a config-level plugin \
1028 (the local plugin file at ~/.config/opencode/plugins/sqz.ts \
1029 already loads it; double-registering causes double hook firing)"
1030 );
1031 assert!(
1032 plugins.iter().any(|v| v.as_str() == Some("other")),
1033 "pre-existing plugin entries from OTHER plugins must be preserved"
1034 );
1035 assert_eq!(
1038 parsed["mcp"]["sqz"]["type"].as_str(),
1039 Some("local"),
1040 "mcp.sqz must be added"
1041 );
1042 }
1043
1044 #[test]
1049 fn test_update_opencode_config_removes_legacy_sqz_plugin_entry() {
1050 let dir = tempfile::tempdir().unwrap();
1051 let config_path = dir.path().join("opencode.json");
1052 std::fs::write(
1053 &config_path,
1054 r#"{"plugin":["other","sqz"]}"#,
1055 )
1056 .unwrap();
1057
1058 let changed = update_opencode_config(dir.path()).unwrap();
1059 assert!(changed, "must report that the legacy plugin entry was stripped");
1060
1061 let after = std::fs::read_to_string(&config_path).unwrap();
1062 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1063 let plugins = parsed["plugin"].as_array().unwrap();
1064 assert!(
1065 !plugins.iter().any(|v| v.as_str() == Some("sqz")),
1066 "legacy sqz plugin entry must be stripped on re-init"
1067 );
1068 assert!(
1069 plugins.iter().any(|v| v.as_str() == Some("other")),
1070 "other plugin entries must survive the cleanup"
1071 );
1072 }
1073
1074 #[test]
1078 fn test_update_opencode_config_drops_empty_plugin_array_after_cleanup() {
1079 let dir = tempfile::tempdir().unwrap();
1080 let config_path = dir.path().join("opencode.json");
1081 std::fs::write(&config_path, r#"{"plugin":["sqz"]}"#).unwrap();
1082
1083 update_opencode_config(dir.path()).unwrap();
1084
1085 let after = std::fs::read_to_string(&config_path).unwrap();
1086 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1087 assert!(
1088 parsed.get("plugin").is_none(),
1089 "empty plugin array should be dropped entirely, got: {after}"
1090 );
1091 }
1092
1093 #[test]
1094 fn test_update_opencode_config_skips_if_present() {
1095 let dir = tempfile::tempdir().unwrap();
1096 let config_path = dir.path().join("opencode.json");
1097 std::fs::write(
1101 &config_path,
1102 r#"{
1103 "mcp": {
1104 "sqz": {
1105 "type": "local",
1106 "command": ["sqz-mcp", "--transport", "stdio"]
1107 }
1108 }
1109}"#,
1110 )
1111 .unwrap();
1112
1113 let result = update_opencode_config(dir.path()).unwrap();
1114 assert!(
1115 !result,
1116 "a config that already has just the mcp.sqz entry (no plugin[]) \
1117 must be idempotent — nothing more to do"
1118 );
1119 }
1120
1121 #[test]
1126 fn test_update_opencode_config_adds_missing_mcp_entry() {
1127 let dir = tempfile::tempdir().unwrap();
1128 let config_path = dir.path().join("opencode.json");
1129 std::fs::write(&config_path, r#"{"plugin":["sqz"]}"#).unwrap();
1130
1131 let changed = update_opencode_config(dir.path()).unwrap();
1132 assert!(changed, "must report that mcp.sqz was added");
1133
1134 let after = std::fs::read_to_string(&config_path).unwrap();
1135 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1136 assert_eq!(
1137 parsed["mcp"]["sqz"]["type"].as_str(),
1138 Some("local"),
1139 "mcp.sqz must be populated with the default server entry"
1140 );
1141 }
1142
1143 #[test]
1154 fn test_process_opencode_hook_skips_already_wrapped_sqz_cmd_prefix() {
1155 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"}}"#;
1156 let result = process_opencode_hook(input).unwrap();
1157 assert_eq!(
1158 result, input,
1159 "already-wrapped command must pass through unchanged; \
1160 otherwise each pass accumulates another SQZ_CMD= prefix"
1161 );
1162 }
1163
1164 #[test]
1167 fn test_process_opencode_hook_guard_is_case_insensitive() {
1168 let input = r#"{"tool":"bash","args":{"command":"SQZ_CMD=git git status"}}"#;
1169 let result = process_opencode_hook(input).unwrap();
1170 assert_eq!(
1171 result, input,
1172 "uppercase SQZ_CMD= prefix must short-circuit the wrap"
1173 );
1174 }
1175
1176 #[test]
1182 fn test_process_opencode_hook_skips_leading_env_assignments_for_base() {
1183 let input = r#"{"tool":"bash","args":{"command":"FOO=bar BAZ=qux make test"}}"#;
1184 let result = process_opencode_hook(input).unwrap();
1185 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1186 let cmd = parsed["args"]["command"].as_str().unwrap();
1187 assert!(
1188 cmd.contains("--cmd make"),
1189 "base command must be `make`, not `FOO=bar`; got: {cmd}"
1190 );
1191 assert!(
1192 cmd.contains("FOO=bar BAZ=qux make test"),
1193 "original command must be preserved: {cmd}"
1194 );
1195 }
1196
1197 #[test]
1199 fn test_process_opencode_hook_skips_bare_sqz_invocation() {
1200 for cmd in ["sqz stats", "sqz gain", "/usr/local/bin/sqz compress"] {
1201 let input = format!(
1202 r#"{{"tool":"bash","args":{{"command":"{cmd}"}}}}"#
1203 );
1204 let result = process_opencode_hook(&input).unwrap();
1205 assert_eq!(
1206 result, input,
1207 "sqz-invoking command `{cmd}` must not be rewrapped"
1208 );
1209 }
1210 }
1211
1212 #[test]
1216 fn test_generate_opencode_plugin_has_double_wrap_guard() {
1217 let content = generate_opencode_plugin("sqz");
1218 assert!(
1219 content.contains("function isAlreadyWrapped(cmd: string): boolean"),
1220 "generated plugin must define isAlreadyWrapped helper"
1221 );
1222 assert!(
1223 content.contains(r#"lowered.includes("sqz_cmd=")"#),
1224 "plugin must check for the SQZ_CMD= prior-wrap prefix"
1225 );
1226 assert!(
1227 content.contains(r#"lowered.includes("sqz compress")"#),
1228 "plugin must check for the `sqz compress` prior-wrap tail"
1229 );
1230 assert!(
1231 content.contains("isAlreadyWrapped(cmd)"),
1232 "plugin hook body must call isAlreadyWrapped on the command"
1233 );
1234 assert!(
1235 content.contains("function extractBaseCmd(cmd: string): string"),
1236 "plugin must define extractBaseCmd that skips env assignments"
1237 );
1238 assert!(
1239 content.contains("extractBaseCmd(cmd)"),
1240 "plugin hook body must use extractBaseCmd, not raw split"
1241 );
1242 }
1243
1244 #[test]
1247 fn test_is_already_wrapped_detects_all_marker_shapes() {
1248 assert!(is_already_wrapped("SQZ_CMD=git git status"));
1249 assert!(is_already_wrapped("sqz_cmd=git git status"));
1250 assert!(is_already_wrapped("git status | sqz compress"));
1251 assert!(is_already_wrapped("git status 2>&1 | /path/sqz compress"));
1252 assert!(is_already_wrapped("ls -la | sqz compress-stream"));
1253 assert!(is_already_wrapped("sqz stats"));
1254 assert!(is_already_wrapped("/usr/local/bin/sqz gain"));
1255 assert!(is_already_wrapped("SQZ_FOO=bar cmd"));
1256 assert!(!is_already_wrapped("git status"));
1257 assert!(!is_already_wrapped("grep sqz logfile.txt"));
1258 assert!(!is_already_wrapped("cargo test --package my-sqz-crate"));
1259 }
1260
1261 #[test]
1262 fn test_extract_base_cmd_skips_env_assignments() {
1263 assert_eq!(extract_base_cmd("make test"), "make");
1264 assert_eq!(extract_base_cmd("FOO=bar make test"), "make");
1265 assert_eq!(extract_base_cmd("FOO=bar BAZ=qux make test"), "make");
1266 assert_eq!(extract_base_cmd("/usr/bin/git status"), "git");
1267 assert_eq!(extract_base_cmd(""), "unknown");
1268 assert_eq!(extract_base_cmd("FOO=bar"), "unknown");
1269 }
1270
1271 #[test]
1272 fn test_is_env_assignment() {
1273 assert!(is_env_assignment("FOO=bar"));
1274 assert!(is_env_assignment("FOO="));
1275 assert!(is_env_assignment("_underscore=1"));
1276 assert!(is_env_assignment("MixedCase_1=x"));
1277 assert!(!is_env_assignment("=bar"));
1278 assert!(!is_env_assignment("FOO"));
1279 assert!(!is_env_assignment("--flag=value"));
1280 assert!(!is_env_assignment("123=value"));
1281 assert!(!is_env_assignment("FOO BAR=baz"));
1282 }
1283
1284 #[test]
1293 fn test_update_merges_into_existing_jsonc() {
1294 let dir = tempfile::tempdir().unwrap();
1295 let jsonc = dir.path().join("opencode.jsonc");
1296 std::fs::write(
1297 &jsonc,
1298 r#"{
1299 // user's own config with a comment
1300 "$schema": "https://opencode.ai/config.json",
1301 "model": "anthropic/claude-sonnet-4-5",
1302 /* another comment */
1303 "plugin": ["other-plugin"]
1304}
1305"#,
1306 )
1307 .unwrap();
1308
1309 let changed = update_opencode_config(dir.path()).unwrap();
1310 assert!(changed, "must merge sqz entries into the existing .jsonc");
1311
1312 assert!(jsonc.exists(), "original .jsonc must still exist");
1314 assert!(
1315 !dir.path().join("opencode.json").exists(),
1316 "must not create a parallel opencode.json alongside .jsonc \
1317 (that's the issue #6 bug)"
1318 );
1319
1320 let after = std::fs::read_to_string(&jsonc).unwrap();
1321 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1322 let plugins = parsed["plugin"].as_array().unwrap();
1323 assert!(
1327 !plugins.iter().any(|v| v.as_str() == Some("sqz")),
1328 "issue #10: sqz must NOT be added to plugin[]"
1329 );
1330 assert!(
1331 plugins.iter().any(|v| v.as_str() == Some("other-plugin")),
1332 "pre-existing plugin entries must be preserved"
1333 );
1334 assert_eq!(
1335 parsed["model"].as_str(),
1336 Some("anthropic/claude-sonnet-4-5"),
1337 "unrelated user keys must survive the merge"
1338 );
1339 assert_eq!(
1340 parsed["mcp"]["sqz"]["type"].as_str(),
1341 Some("local"),
1342 "mcp.sqz must be registered"
1343 );
1344 }
1345
1346 #[test]
1350 fn test_update_opencode_config_detailed_reports_comments_lost() {
1351 let dir = tempfile::tempdir().unwrap();
1352 let jsonc = dir.path().join("opencode.jsonc");
1353 std::fs::write(
1354 &jsonc,
1355 r#"{
1356 // comment to be dropped
1357 "plugin": ["other"]
1358}
1359"#,
1360 )
1361 .unwrap();
1362
1363 let (changed, comments_lost) =
1364 update_opencode_config_detailed(dir.path()).unwrap();
1365 assert!(changed);
1366 assert!(
1367 comments_lost,
1368 "merger must report that comments were dropped from .jsonc"
1369 );
1370 }
1371
1372 #[test]
1376 fn test_update_creates_plain_json_when_nothing_exists() {
1377 let dir = tempfile::tempdir().unwrap();
1378 update_opencode_config(dir.path()).unwrap();
1379 assert!(dir.path().join("opencode.json").exists());
1380 assert!(!dir.path().join("opencode.jsonc").exists());
1381 }
1382
1383 #[test]
1385 fn test_find_opencode_config_prefers_jsonc() {
1386 let dir = tempfile::tempdir().unwrap();
1387 std::fs::write(dir.path().join("opencode.json"), "{}").unwrap();
1388 std::fs::write(dir.path().join("opencode.jsonc"), "{}").unwrap();
1389 let found = find_opencode_config(dir.path()).unwrap();
1390 assert_eq!(
1391 found.file_name().unwrap(),
1392 "opencode.jsonc",
1393 "must prefer the .jsonc variant when both exist — the user \
1394 is maintaining .jsonc for its comment support"
1395 );
1396 }
1397
1398 #[test]
1399 fn test_find_opencode_config_returns_none_when_missing() {
1400 let dir = tempfile::tempdir().unwrap();
1401 assert!(find_opencode_config(dir.path()).is_none());
1402 }
1403
1404 #[test]
1405 fn test_opencode_config_has_comments_detects_jsonc_comments() {
1406 let dir = tempfile::tempdir().unwrap();
1407 std::fs::write(
1408 dir.path().join("opencode.jsonc"),
1409 "// a line comment\n{\"plugin\":[]}\n",
1410 )
1411 .unwrap();
1412 assert!(opencode_config_has_comments(dir.path()));
1413 }
1414
1415 #[test]
1416 fn test_opencode_config_has_comments_ignores_plain_json() {
1417 let dir = tempfile::tempdir().unwrap();
1418 std::fs::write(
1420 dir.path().join("opencode.json"),
1421 r#"{"url":"http://example.com"}"#,
1422 )
1423 .unwrap();
1424 assert!(!opencode_config_has_comments(dir.path()));
1425 }
1426
1427 #[test]
1430 fn test_strip_jsonc_comments_removes_line_comments() {
1431 let src = "{\n // leading comment\n \"a\": 1 // trailing\n}";
1432 let stripped = strip_jsonc_comments(src);
1433 assert!(!stripped.contains("leading comment"));
1434 assert!(!stripped.contains("trailing"));
1435 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1436 assert_eq!(parsed["a"], 1);
1437 }
1438
1439 #[test]
1440 fn test_strip_jsonc_comments_removes_block_comments() {
1441 let src = "{\n /* block\n comment */\n \"a\": 1\n}";
1442 let stripped = strip_jsonc_comments(src);
1443 assert!(!stripped.contains("block"));
1444 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1445 assert_eq!(parsed["a"], 1);
1446 }
1447
1448 #[test]
1449 fn test_strip_jsonc_comments_preserves_strings() {
1450 let src = r#"{"url": "http://example.com", "re": "/* not a comment */"}"#;
1455 let stripped = strip_jsonc_comments(src);
1456 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1457 assert_eq!(parsed["url"], "http://example.com");
1458 assert_eq!(parsed["re"], "/* not a comment */");
1459 }
1460
1461 #[test]
1462 fn test_strip_jsonc_comments_preserves_escaped_quote_in_string() {
1463 let src = r#"{"s": "a\"//b"}"#;
1464 let stripped = strip_jsonc_comments(src);
1465 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1466 assert_eq!(parsed["s"], r#"a"//b"#);
1467 }
1468
1469 #[test]
1470 fn test_strip_jsonc_comments_tolerates_unterminated_block() {
1471 let src = "{\"a\":1 /* never ends";
1473 let _ = strip_jsonc_comments(src); }
1475
1476 #[test]
1484 fn test_remove_sqz_preserves_other_user_config() {
1485 let dir = tempfile::tempdir().unwrap();
1486 let config = dir.path().join("opencode.json");
1487 std::fs::write(
1488 &config,
1489 r#"{
1490 "$schema": "https://opencode.ai/config.json",
1491 "model": "anthropic/claude-sonnet-4-5",
1492 "plugin": ["other-plugin", "sqz"],
1493 "mcp": {
1494 "sqz": { "type": "local", "command": ["sqz-mcp"] },
1495 "jira": { "type": "remote", "url": "https://jira.example.com/mcp" }
1496 }
1497}
1498"#,
1499 )
1500 .unwrap();
1501
1502 let (path, changed) =
1503 remove_sqz_from_opencode_config(dir.path()).unwrap().unwrap();
1504 assert_eq!(path, config);
1505 assert!(changed, "must report that sqz entries were removed");
1506 assert!(
1507 config.exists(),
1508 "file must NOT be deleted — only sqz's entries removed"
1509 );
1510
1511 let after = std::fs::read_to_string(&config).unwrap();
1512 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1513 let plugins = parsed["plugin"].as_array().unwrap();
1514 assert!(!plugins.iter().any(|v| v.as_str() == Some("sqz")));
1515 assert!(plugins.iter().any(|v| v.as_str() == Some("other-plugin")));
1516 let mcp = parsed["mcp"].as_object().unwrap();
1517 assert!(!mcp.contains_key("sqz"), "mcp.sqz must be gone");
1518 assert!(mcp.contains_key("jira"), "mcp.jira must survive");
1519 assert_eq!(
1520 parsed["model"].as_str(),
1521 Some("anthropic/claude-sonnet-4-5"),
1522 "unrelated keys must survive"
1523 );
1524 }
1525
1526 #[test]
1530 fn test_remove_sqz_deletes_file_when_nothing_else_remains() {
1531 let dir = tempfile::tempdir().unwrap();
1532 let config = dir.path().join("opencode.json");
1533 std::fs::write(
1535 &config,
1536 r#"{
1537 "$schema": "https://opencode.ai/config.json",
1538 "mcp": {
1539 "sqz": { "type": "local", "command": ["sqz-mcp", "--transport", "stdio"] }
1540 },
1541 "plugin": ["sqz"]
1542}
1543"#,
1544 )
1545 .unwrap();
1546
1547 let (_, changed) =
1548 remove_sqz_from_opencode_config(dir.path()).unwrap().unwrap();
1549 assert!(changed);
1550 assert!(
1551 !config.exists(),
1552 "file with only $schema + sqz entries must be removed"
1553 );
1554 }
1555
1556 #[test]
1559 fn test_remove_sqz_returns_none_when_config_missing() {
1560 let dir = tempfile::tempdir().unwrap();
1561 let result = remove_sqz_from_opencode_config(dir.path()).unwrap();
1562 assert!(result.is_none());
1563 }
1564
1565 #[test]
1568 fn test_remove_sqz_from_jsonc_drops_comments() {
1569 let dir = tempfile::tempdir().unwrap();
1570 let jsonc = dir.path().join("opencode.jsonc");
1571 std::fs::write(
1572 &jsonc,
1573 r#"{
1574 // user's comment
1575 "model": "x",
1576 "plugin": ["sqz", "other"]
1577}
1578"#,
1579 )
1580 .unwrap();
1581
1582 let (path, changed) =
1583 remove_sqz_from_opencode_config(dir.path()).unwrap().unwrap();
1584 assert_eq!(path, jsonc);
1585 assert!(changed);
1586 assert!(path.exists(), "jsonc file kept because `model` and `other` remain");
1587
1588 let after = std::fs::read_to_string(&jsonc).unwrap();
1589 assert!(
1590 !after.contains("// user's comment"),
1591 "comments are dropped by the serde_json round-trip; \
1592 documented in update_opencode_config_detailed"
1593 );
1594 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1595 let plugins = parsed["plugin"].as_array().unwrap();
1596 assert_eq!(plugins.len(), 1);
1597 assert_eq!(plugins[0], "other");
1598 }
1599
1600 #[test]
1610 fn issue_10_opencode_rewrite_works_in_powershell_syntax() {
1611 let input = r#"{"tool":"bash","args":{"command":"dotnet build NewNeonCheckers3.sln"}}"#;
1612 let result = process_opencode_hook(input).unwrap();
1613 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1614 let cmd = parsed["args"]["command"].as_str().unwrap();
1615
1616 assert!(
1620 !cmd.contains("SQZ_CMD="),
1621 "issue #10: rewrite must not emit `SQZ_CMD=` (breaks on \
1622 PowerShell/cmd.exe); got: {cmd}"
1623 );
1624 assert!(
1626 cmd.contains("--cmd dotnet"),
1627 "rewrite must pass label via --cmd; got: {cmd}"
1628 );
1629 let first_token = cmd.split_whitespace().next().unwrap_or("");
1634 assert_eq!(
1635 first_token, "dotnet",
1636 "first token of the rewritten command must be the user's \
1637 command itself, not an env-var assignment; got: {cmd}"
1638 );
1639 }
1640
1641 #[test]
1645 fn issue_10_ts_plugin_emits_cmd_flag_not_env_prefix() {
1646 let content = generate_opencode_plugin("sqz");
1647 assert!(
1651 content.contains("compress --cmd"),
1652 "TS plugin must build rewrite with `compress --cmd ${{base}}`"
1653 );
1654 assert!(
1660 !content.contains("SQZ_CMD=${base}"),
1661 "TS plugin must not emit the legacy `SQZ_CMD=${{base}}` prefix"
1662 );
1663 }
1664
1665 #[test]
1680 fn issue_10_fresh_opencode_config_has_no_plugin_entry() {
1681 let dir = tempfile::tempdir().unwrap();
1682 update_opencode_config(dir.path()).unwrap();
1683 let content = std::fs::read_to_string(dir.path().join("opencode.json")).unwrap();
1684 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1685
1686 assert!(
1688 parsed.get("plugin").is_none(),
1689 "issue #10: fresh opencode.json must not include `plugin` key; got: {content}"
1690 );
1691
1692 assert_eq!(
1695 parsed["mcp"]["sqz"]["type"].as_str(),
1696 Some("local"),
1697 "mcp.sqz is the one sqz-authored entry that belongs in \
1698 opencode.json; must still be registered"
1699 );
1700 }
1701
1702 #[test]
1707 fn issue_10_reinit_strips_legacy_plugin_entry() {
1708 let dir = tempfile::tempdir().unwrap();
1709 let config = dir.path().join("opencode.json");
1710 std::fs::write(
1711 &config,
1712 r#"{"$schema":"https://opencode.ai/config.json","mcp":{"sqz":{"type":"local","command":["sqz-mcp","--transport","stdio"]}},"plugin":["sqz"]}"#,
1714 )
1715 .unwrap();
1716
1717 let changed = update_opencode_config(dir.path()).unwrap();
1718 assert!(changed, "re-init must report a change (the legacy entry was stripped)");
1719
1720 let after = std::fs::read_to_string(&config).unwrap();
1721 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1722 assert!(
1723 parsed.get("plugin").is_none(),
1724 "legacy `plugin: [\"sqz\"]` must be stripped on re-init; got: {after}"
1725 );
1726 assert_eq!(
1728 parsed["mcp"]["sqz"]["type"].as_str(),
1729 Some("local"),
1730 "mcp.sqz must survive cleanup of the plugin entry"
1731 );
1732 }
1733}