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 strip_trailing_commas(&out)
358}
359
360fn strip_trailing_commas(src: &str) -> String {
365 let mut out = String::with_capacity(src.len());
366 let bytes = src.as_bytes();
367 let mut i = 0;
368 let len = bytes.len();
369
370 while i < len {
371 let b = bytes[i];
372
373 if b == b'"' {
375 out.push('"');
376 i += 1;
377 while i < len {
378 let c = bytes[i];
379 out.push(c as char);
380 if c == b'\\' && i + 1 < len {
381 out.push(bytes[i + 1] as char);
382 i += 2;
383 continue;
384 }
385 i += 1;
386 if c == b'"' {
387 break;
388 }
389 }
390 continue;
391 }
392
393 if b == b',' {
397 let mut j = i + 1;
398 while j < len && bytes[j].is_ascii_whitespace() {
399 j += 1;
400 }
401 if j < len && (bytes[j] == b']' || bytes[j] == b'}') {
402 for k in (i + 1)..j {
405 out.push(bytes[k] as char);
406 }
407 i = j;
408 continue;
409 }
410 }
411
412 out.push(b as char);
413 i += 1;
414 }
415
416 out
417}
418
419pub fn update_opencode_config(project_dir: &Path) -> Result<bool> {
435 let (updated, _) = update_opencode_config_detailed(project_dir)?;
436 Ok(updated)
437}
438
439pub fn update_opencode_config_detailed(project_dir: &Path) -> Result<(bool, bool)> {
443 let planned = plan_opencode_config_change(project_dir)?;
444 if !planned.will_change {
445 return Ok((false, false));
446 }
447 apply_opencode_config_change(project_dir, &planned)
449}
450
451pub fn plan_opencode_config_change(project_dir: &Path) -> Result<PlannedOpencodeChange> {
464 compute_opencode_change(project_dir, false).map(|r| r.0)
465}
466
467#[derive(Debug, Clone, PartialEq, Eq)]
469pub struct PlannedOpencodeChange {
470 pub target_path: PathBuf,
472 pub will_change: bool,
475 pub comments_lost: bool,
478}
479
480fn apply_opencode_config_change(
481 project_dir: &Path,
482 _planned: &PlannedOpencodeChange,
483) -> Result<(bool, bool)> {
484 let (planned, _) = compute_opencode_change(project_dir, true)?;
485 Ok((planned.will_change, planned.comments_lost))
486}
487
488fn compute_opencode_change(
494 project_dir: &Path,
495 apply: bool,
496) -> Result<(PlannedOpencodeChange, ())> {
497 fn sqz_mcp_value() -> serde_json::Value {
498 serde_json::json!({
499 "type": "local",
500 "command": ["sqz-mcp", "--transport", "stdio"],
501 "enabled": true
502 })
503 }
504
505 if let Some(existing_path) = find_opencode_config(project_dir) {
506 let is_jsonc = existing_path
507 .extension()
508 .map(|e| e == "jsonc")
509 .unwrap_or(false);
510 let content = std::fs::read_to_string(&existing_path).map_err(|e| {
511 crate::error::SqzError::Other(format!(
512 "failed to read {}: {e}",
513 existing_path.display()
514 ))
515 })?;
516 let parseable = if is_jsonc {
517 strip_jsonc_comments(&content)
518 } else {
519 content.clone()
520 };
521 let had_comments = is_jsonc && parseable != content;
522
523 let mut config: serde_json::Value = serde_json::from_str(&parseable).map_err(|e| {
524 crate::error::SqzError::Other(format!(
525 "failed to parse {}: {e}",
526 existing_path.display()
527 ))
528 })?;
529 let obj = config.as_object_mut().ok_or_else(|| {
530 crate::error::SqzError::Other(format!(
531 "{} root is not a JSON object",
532 existing_path.display()
533 ))
534 })?;
535
536 let mut changed = false;
537
538 if let Some(arr) = obj.get_mut("plugin").and_then(|v| v.as_array_mut()) {
539 let before = arr.len();
540 arr.retain(|v| v.as_str() != Some("sqz"));
541 if arr.len() != before {
542 changed = true;
543 }
544 if arr.is_empty() {
545 obj.remove("plugin");
546 changed = true;
547 }
548 }
549
550 let mcp_entry = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
551 if let Some(mcp_obj) = mcp_entry.as_object_mut() {
552 if !mcp_obj.contains_key("sqz") {
553 mcp_obj.insert("sqz".to_string(), sqz_mcp_value());
554 changed = true;
555 } else if let Some(sqz_entry) = mcp_obj.get_mut("sqz").and_then(|v| v.as_object_mut()) {
556 if !sqz_entry.contains_key("enabled") {
557 sqz_entry.insert("enabled".to_string(), serde_json::json!(true));
558 changed = true;
559 }
560 }
561 } else {
562 return Err(crate::error::SqzError::Other(format!(
563 "{} has an `mcp` field that is not an object; \
564 refusing to modify it automatically",
565 existing_path.display()
566 )));
567 }
568
569 let planned = PlannedOpencodeChange {
570 target_path: existing_path.clone(),
571 will_change: changed,
572 comments_lost: changed && had_comments,
573 };
574
575 if apply && changed {
576 let updated = serde_json::to_string_pretty(&config).map_err(|e| {
577 crate::error::SqzError::Other(format!("failed to serialize config: {e}"))
578 })?;
579 std::fs::write(&existing_path, format!("{updated}\n")).map_err(|e| {
580 crate::error::SqzError::Other(format!(
581 "failed to write {}: {e}",
582 existing_path.display()
583 ))
584 })?;
585 }
586
587 Ok((planned, ()))
588 } else {
589 let target = project_dir.join("opencode.json");
591 let planned = PlannedOpencodeChange {
592 target_path: target.clone(),
593 will_change: true,
594 comments_lost: false,
595 };
596
597 if apply {
598 let config = serde_json::json!({
599 "$schema": "https://opencode.ai/config.json",
600 "mcp": {
601 "sqz": sqz_mcp_value()
602 }
603 });
604 let content = serde_json::to_string_pretty(&config).map_err(|e| {
605 crate::error::SqzError::Other(format!("failed to serialize opencode.json: {e}"))
606 })?;
607 std::fs::write(&target, format!("{content}\n")).map_err(|e| {
608 crate::error::SqzError::Other(format!("failed to write opencode.json: {e}"))
609 })?;
610 }
611
612 Ok((planned, ()))
613 }
614}
615
616pub fn remove_sqz_from_opencode_config(project_dir: &Path) -> Result<Option<(PathBuf, bool)>> {
629 let path = match find_opencode_config(project_dir) {
630 Some(p) => p,
631 None => return Ok(None),
632 };
633 let is_jsonc = path.extension().map(|e| e == "jsonc").unwrap_or(false);
634 let raw = std::fs::read_to_string(&path).map_err(|e| {
635 crate::error::SqzError::Other(format!("failed to read {}: {e}", path.display()))
636 })?;
637 let parseable = if is_jsonc {
638 strip_jsonc_comments(&raw)
639 } else {
640 raw.clone()
641 };
642 let mut config: serde_json::Value = match serde_json::from_str(&parseable) {
643 Ok(v) => v,
644 Err(_) => {
645 return Ok(Some((path, false)));
647 }
648 };
649
650 let mut changed = false;
651
652 if let Some(obj) = config.as_object_mut() {
653 if let Some(plugin) = obj.get_mut("plugin").and_then(|v| v.as_array_mut()) {
655 let before = plugin.len();
656 plugin.retain(|v| v.as_str() != Some("sqz"));
657 if plugin.len() != before {
658 changed = true;
659 }
660 if plugin.is_empty() {
662 obj.remove("plugin");
663 }
664 }
665
666 if let Some(mcp) = obj.get_mut("mcp").and_then(|v| v.as_object_mut()) {
668 if mcp.remove("sqz").is_some() {
669 changed = true;
670 }
671 if mcp.is_empty() {
672 obj.remove("mcp");
673 }
674 }
675 }
676
677 if !changed {
678 return Ok(Some((path, false)));
679 }
680
681 let essentially_empty = match config.as_object() {
686 Some(obj) => {
687 obj.is_empty()
688 || (obj.len() == 1
689 && obj.get("$schema").and_then(|v| v.as_str())
690 == Some("https://opencode.ai/config.json"))
691 }
692 None => false,
693 };
694
695 if essentially_empty {
696 std::fs::remove_file(&path).map_err(|e| {
697 crate::error::SqzError::Other(format!(
698 "failed to remove {}: {e}",
699 path.display()
700 ))
701 })?;
702 return Ok(Some((path, true)));
703 }
704
705 let updated = serde_json::to_string_pretty(&config).map_err(|e| {
708 crate::error::SqzError::Other(format!("failed to serialize config: {e}"))
709 })?;
710 std::fs::write(&path, format!("{updated}\n")).map_err(|e| {
711 crate::error::SqzError::Other(format!(
712 "failed to write {}: {e}",
713 path.display()
714 ))
715 })?;
716 Ok(Some((path, true)))
717}
718
719fn is_already_wrapped(command: &str) -> bool {
731 let lowered = command.to_ascii_lowercase();
732 if lowered.contains("sqz_cmd=") {
733 return true;
734 }
735 if lowered.contains("sqz compress") {
736 return true;
737 }
738 if lowered.contains("| sqz ") || lowered.contains("| sqz\t") {
739 return true;
740 }
741 let trimmed = command.trim_start();
743 if let Some(eq_idx) = trimmed.find('=') {
744 let name = &trimmed[..eq_idx];
745 if name.starts_with("SQZ_")
746 && !name.is_empty()
747 && name
748 .chars()
749 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
750 {
751 return true;
752 }
753 }
754 let base = extract_base_cmd(command);
756 if base == "sqz" || base == "sqz-mcp" || base == "sqz.exe" {
757 return true;
758 }
759 false
760}
761
762fn extract_base_cmd(command: &str) -> &str {
769 for tok in command.split_whitespace() {
770 if is_env_assignment(tok) {
771 continue;
772 }
773 return tok.rsplit('/').next().unwrap_or("unknown");
774 }
775 "unknown"
776}
777
778fn is_env_assignment(token: &str) -> bool {
782 let eq = match token.find('=') {
783 Some(i) => i,
784 None => return false,
785 };
786 if eq == 0 {
787 return false;
788 }
789 let name = &token[..eq];
790 let mut chars = name.chars();
791 match chars.next() {
792 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
793 _ => return false,
794 }
795 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
796}
797
798pub fn process_opencode_hook(input: &str) -> Result<String> {
808 let parsed: serde_json::Value = serde_json::from_str(input)
809 .map_err(|e| crate::error::SqzError::Other(format!("opencode hook: invalid JSON: {e}")))?;
810
811 let tool = parsed
812 .get("tool")
813 .or_else(|| parsed.get("toolName"))
814 .or_else(|| parsed.get("tool_name"))
815 .and_then(|v| v.as_str())
816 .unwrap_or("");
817
818 if !matches!(
820 tool.to_lowercase().as_str(),
821 "bash" | "shell" | "terminal" | "run_shell_command"
822 ) {
823 return Ok(input.to_string());
824 }
825
826 let command = parsed
828 .get("args")
829 .or_else(|| parsed.get("toolCall"))
830 .or_else(|| parsed.get("tool_input"))
831 .and_then(|v| v.get("command"))
832 .and_then(|v| v.as_str())
833 .unwrap_or("");
834
835 if command.is_empty() || is_already_wrapped(command) {
836 return Ok(input.to_string());
837 }
838
839 let base = extract_base_cmd(command);
843
844 if matches!(
845 base,
846 "vim" | "vi" | "nano" | "emacs" | "less" | "more" | "top" | "htop"
847 | "ssh" | "python" | "python3" | "node" | "irb" | "ghci"
848 | "psql" | "mysql" | "sqlite3" | "mongo" | "redis-cli"
849 ) || command.contains("--watch")
850 || command.contains("run dev")
851 || command.contains("run start")
852 || command.contains("run serve")
853 {
854 return Ok(input.to_string());
855 }
856
857 let base_cmd = base;
859
860 let escaped_base = if base_cmd
861 .chars()
862 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
863 {
864 base_cmd.to_string()
865 } else {
866 format!("'{}'", base_cmd.replace('\'', "'\\''"))
867 };
868
869 let rewritten = format!(
874 "{} 2>&1 | sqz compress --cmd {}",
875 command, escaped_base,
876 );
877
878 let output = serde_json::json!({
880 "decision": "approve",
881 "reason": "sqz: command output will be compressed for token savings",
882 "updatedInput": {
883 "command": rewritten
884 },
885 "args": {
886 "command": rewritten
887 }
888 });
889
890 serde_json::to_string(&output)
891 .map_err(|e| crate::error::SqzError::Other(format!("opencode hook: serialize error: {e}")))
892}
893
894#[cfg(test)]
897mod tests {
898 use super::*;
899
900 #[test]
901 fn test_generate_opencode_plugin_contains_sqz_path() {
902 let content = generate_opencode_plugin("/usr/local/bin/sqz");
903 assert!(content.contains("/usr/local/bin/sqz"));
904 assert!(content.contains("SqzPlugin"));
905 assert!(content.contains("tool.execute.before"));
906 }
907
908 #[test]
909 fn test_generate_opencode_plugin_windows_path_escaped() {
910 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
915 let content = generate_opencode_plugin(windows_path);
916 assert!(
920 content.contains(r#"const SQZ_PATH = "C:\\Users\\SqzUser\\.cargo\\bin\\sqz.exe""#),
921 "expected JS-escaped path in plugin — got:\n{content}"
922 );
923 assert!(
926 !content.contains(r#"const SQZ_PATH = "C:\U"#),
927 "plugin must not contain unescaped backslashes in the string literal"
928 );
929 }
930
931 #[test]
932 fn test_generate_opencode_plugin_has_interactive_check() {
933 let content = generate_opencode_plugin("sqz");
934 assert!(content.contains("isInteractive"));
935 assert!(content.contains("vim"));
936 assert!(content.contains("--watch"));
937 }
938
939 #[test]
955 fn test_generate_opencode_plugin_declares_v1_id() {
956 let content = generate_opencode_plugin("sqz");
957 assert!(
958 content.contains("id: \"sqz\""),
959 "plugin must default-export `id: \"sqz\"` so OpenCode's \
960 V1 loader (shared.ts readV1Plugin/resolvePluginId) \
961 displays \"sqz\" in the UI instead of the file path; \
962 got:\n{content}"
963 );
964 assert!(
965 content.contains("server: SqzPluginFactory"),
966 "plugin must default-export `server: <factory>` for V1 \
967 loader compliance; got:\n{content}"
968 );
969 assert!(
970 content.contains("export default {"),
971 "plugin must have a default export per OpenCode V1 shape; \
972 got:\n{content}"
973 );
974 }
975
976 #[test]
987 fn test_generate_opencode_plugin_legacy_named_export_preserved() {
988 let content = generate_opencode_plugin("sqz");
989 assert!(
990 content.contains("export const SqzPlugin = SqzPluginFactory"),
991 "legacy named export must alias the same factory reference \
992 as the V1 default export — otherwise old OpenCode versions \
993 would see two distinct factories in `Object.values(mod)` \
994 and fire the hook twice; got:\n{content}"
995 );
996 }
997
998 #[test]
1005 fn test_process_opencode_hook_rewrites_bash() {
1006 let input = r#"{"tool":"bash","args":{"command":"git status"}}"#;
1007 let result = process_opencode_hook(input).unwrap();
1008 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1009 assert_eq!(parsed["decision"].as_str().unwrap(), "approve");
1010 let cmd = parsed["args"]["command"].as_str().unwrap();
1011 assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
1012 assert!(cmd.contains("git status"), "should preserve original: {cmd}");
1013 assert!(cmd.contains("--cmd git"), "should pass base command via --cmd: {cmd}");
1017 assert!(
1018 !cmd.contains("SQZ_CMD="),
1019 "must not emit legacy sh-style env prefix: {cmd}"
1020 );
1021 }
1022
1023 #[test]
1024 fn test_process_opencode_hook_passes_non_shell() {
1025 let input = r#"{"tool":"read_file","args":{"path":"file.txt"}}"#;
1026 let result = process_opencode_hook(input).unwrap();
1027 assert_eq!(result, input, "non-shell tools should pass through");
1028 }
1029
1030 #[test]
1031 fn test_process_opencode_hook_skips_sqz_commands() {
1032 let input = r#"{"tool":"bash","args":{"command":"sqz stats"}}"#;
1033 let result = process_opencode_hook(input).unwrap();
1034 assert_eq!(result, input, "sqz commands should not be double-wrapped");
1035 }
1036
1037 #[test]
1038 fn test_process_opencode_hook_skips_interactive() {
1039 let input = r#"{"tool":"bash","args":{"command":"vim file.txt"}}"#;
1040 let result = process_opencode_hook(input).unwrap();
1041 assert_eq!(result, input, "interactive commands should pass through");
1042 }
1043
1044 #[test]
1045 fn test_process_opencode_hook_skips_watch() {
1046 let input = r#"{"tool":"bash","args":{"command":"npm run dev --watch"}}"#;
1047 let result = process_opencode_hook(input).unwrap();
1048 assert_eq!(result, input, "watch mode should pass through");
1049 }
1050
1051 #[test]
1052 fn test_process_opencode_hook_invalid_json() {
1053 let result = process_opencode_hook("not json");
1054 assert!(result.is_err());
1055 }
1056
1057 #[test]
1058 fn test_process_opencode_hook_empty_command() {
1059 let input = r#"{"tool":"bash","args":{"command":""}}"#;
1060 let result = process_opencode_hook(input).unwrap();
1061 assert_eq!(result, input);
1062 }
1063
1064 #[test]
1065 fn test_process_opencode_hook_run_shell_command() {
1066 let input = r#"{"tool":"run_shell_command","args":{"command":"ls -la"}}"#;
1067 let result = process_opencode_hook(input).unwrap();
1068 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1069 let cmd = parsed["args"]["command"].as_str().unwrap();
1070 assert!(cmd.contains("sqz compress"));
1071 }
1072
1073 #[test]
1074 fn test_install_opencode_plugin_creates_file() {
1075 let dir = tempfile::tempdir().unwrap();
1076 std::env::set_var("HOME", dir.path());
1078 let result = install_opencode_plugin("sqz");
1079 assert!(result.is_ok());
1080 let plugin_path = dir
1082 .path()
1083 .join(".config/opencode/plugins/sqz.ts");
1084 assert!(plugin_path.exists(), "plugin file should exist");
1085 let content = std::fs::read_to_string(&plugin_path).unwrap();
1086 assert!(content.contains("SqzPlugin"));
1087 }
1088
1089 #[test]
1090 fn test_update_opencode_config_creates_new() {
1091 let dir = tempfile::tempdir().unwrap();
1092 let result = update_opencode_config(dir.path()).unwrap();
1093 assert!(result, "should create new config");
1094 let config_path = dir.path().join("opencode.json");
1095 assert!(config_path.exists());
1096 let content = std::fs::read_to_string(&config_path).unwrap();
1097 assert!(content.contains("\"sqz\""));
1098 assert!(content.contains("sqz-mcp"));
1099
1100 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1107 assert!(
1108 parsed.get("plugin").is_none(),
1109 "fresh-install opencode.json must not include `plugin`; got: {content}"
1110 );
1111 assert_eq!(
1112 parsed["mcp"]["sqz"]["type"].as_str(),
1113 Some("local"),
1114 "mcp.sqz must be present"
1115 );
1116 }
1117
1118 #[test]
1119 fn test_update_opencode_config_adds_to_existing() {
1120 let dir = tempfile::tempdir().unwrap();
1121 let config_path = dir.path().join("opencode.json");
1122 std::fs::write(
1123 &config_path,
1124 r#"{"$schema":"https://opencode.ai/config.json","plugin":["other"]}"#,
1125 )
1126 .unwrap();
1127
1128 let result = update_opencode_config(dir.path()).unwrap();
1129 assert!(result, "should update existing config");
1130 let content = std::fs::read_to_string(&config_path).unwrap();
1131 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1132 let plugins = parsed["plugin"].as_array().unwrap();
1137 assert!(
1138 !plugins.iter().any(|v| v.as_str() == Some("sqz")),
1139 "issue #10: sqz must NOT be registered as a config-level plugin \
1140 (the local plugin file at ~/.config/opencode/plugins/sqz.ts \
1141 already loads it; double-registering causes double hook firing)"
1142 );
1143 assert!(
1144 plugins.iter().any(|v| v.as_str() == Some("other")),
1145 "pre-existing plugin entries from OTHER plugins must be preserved"
1146 );
1147 assert_eq!(
1150 parsed["mcp"]["sqz"]["type"].as_str(),
1151 Some("local"),
1152 "mcp.sqz must be added"
1153 );
1154 }
1155
1156 #[test]
1161 fn test_update_opencode_config_removes_legacy_sqz_plugin_entry() {
1162 let dir = tempfile::tempdir().unwrap();
1163 let config_path = dir.path().join("opencode.json");
1164 std::fs::write(
1165 &config_path,
1166 r#"{"plugin":["other","sqz"]}"#,
1167 )
1168 .unwrap();
1169
1170 let changed = update_opencode_config(dir.path()).unwrap();
1171 assert!(changed, "must report that the legacy plugin entry was stripped");
1172
1173 let after = std::fs::read_to_string(&config_path).unwrap();
1174 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1175 let plugins = parsed["plugin"].as_array().unwrap();
1176 assert!(
1177 !plugins.iter().any(|v| v.as_str() == Some("sqz")),
1178 "legacy sqz plugin entry must be stripped on re-init"
1179 );
1180 assert!(
1181 plugins.iter().any(|v| v.as_str() == Some("other")),
1182 "other plugin entries must survive the cleanup"
1183 );
1184 }
1185
1186 #[test]
1190 fn test_update_opencode_config_drops_empty_plugin_array_after_cleanup() {
1191 let dir = tempfile::tempdir().unwrap();
1192 let config_path = dir.path().join("opencode.json");
1193 std::fs::write(&config_path, r#"{"plugin":["sqz"]}"#).unwrap();
1194
1195 update_opencode_config(dir.path()).unwrap();
1196
1197 let after = std::fs::read_to_string(&config_path).unwrap();
1198 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1199 assert!(
1200 parsed.get("plugin").is_none(),
1201 "empty plugin array should be dropped entirely, got: {after}"
1202 );
1203 }
1204
1205 #[test]
1206 fn test_update_opencode_config_skips_if_present() {
1207 let dir = tempfile::tempdir().unwrap();
1208 let config_path = dir.path().join("opencode.json");
1209 std::fs::write(
1210 &config_path,
1211 r#"{
1212 "mcp": {
1213 "sqz": {
1214 "type": "local",
1215 "command": ["sqz-mcp", "--transport", "stdio"],
1216 "enabled": true
1217 }
1218 }
1219}"#,
1220 )
1221 .unwrap();
1222
1223 let result = update_opencode_config(dir.path()).unwrap();
1224 assert!(
1225 !result,
1226 "a config with mcp.sqz including enabled:true must be idempotent"
1227 );
1228 }
1229
1230 #[test]
1235 fn test_update_opencode_config_adds_missing_mcp_entry() {
1236 let dir = tempfile::tempdir().unwrap();
1237 let config_path = dir.path().join("opencode.json");
1238 std::fs::write(&config_path, r#"{"plugin":["sqz"]}"#).unwrap();
1239
1240 let changed = update_opencode_config(dir.path()).unwrap();
1241 assert!(changed, "must report that mcp.sqz was added");
1242
1243 let after = std::fs::read_to_string(&config_path).unwrap();
1244 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1245 assert_eq!(
1246 parsed["mcp"]["sqz"]["type"].as_str(),
1247 Some("local"),
1248 "mcp.sqz must be populated with the default server entry"
1249 );
1250 }
1251
1252 #[test]
1263 fn test_process_opencode_hook_skips_already_wrapped_sqz_cmd_prefix() {
1264 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"}}"#;
1265 let result = process_opencode_hook(input).unwrap();
1266 assert_eq!(
1267 result, input,
1268 "already-wrapped command must pass through unchanged; \
1269 otherwise each pass accumulates another SQZ_CMD= prefix"
1270 );
1271 }
1272
1273 #[test]
1276 fn test_process_opencode_hook_guard_is_case_insensitive() {
1277 let input = r#"{"tool":"bash","args":{"command":"SQZ_CMD=git git status"}}"#;
1278 let result = process_opencode_hook(input).unwrap();
1279 assert_eq!(
1280 result, input,
1281 "uppercase SQZ_CMD= prefix must short-circuit the wrap"
1282 );
1283 }
1284
1285 #[test]
1291 fn test_process_opencode_hook_skips_leading_env_assignments_for_base() {
1292 let input = r#"{"tool":"bash","args":{"command":"FOO=bar BAZ=qux make test"}}"#;
1293 let result = process_opencode_hook(input).unwrap();
1294 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1295 let cmd = parsed["args"]["command"].as_str().unwrap();
1296 assert!(
1297 cmd.contains("--cmd make"),
1298 "base command must be `make`, not `FOO=bar`; got: {cmd}"
1299 );
1300 assert!(
1301 cmd.contains("FOO=bar BAZ=qux make test"),
1302 "original command must be preserved: {cmd}"
1303 );
1304 }
1305
1306 #[test]
1308 fn test_process_opencode_hook_skips_bare_sqz_invocation() {
1309 for cmd in ["sqz stats", "sqz gain", "/usr/local/bin/sqz compress"] {
1310 let input = format!(
1311 r#"{{"tool":"bash","args":{{"command":"{cmd}"}}}}"#
1312 );
1313 let result = process_opencode_hook(&input).unwrap();
1314 assert_eq!(
1315 result, input,
1316 "sqz-invoking command `{cmd}` must not be rewrapped"
1317 );
1318 }
1319 }
1320
1321 #[test]
1325 fn test_generate_opencode_plugin_has_double_wrap_guard() {
1326 let content = generate_opencode_plugin("sqz");
1327 assert!(
1328 content.contains("function isAlreadyWrapped(cmd: string): boolean"),
1329 "generated plugin must define isAlreadyWrapped helper"
1330 );
1331 assert!(
1332 content.contains(r#"lowered.includes("sqz_cmd=")"#),
1333 "plugin must check for the SQZ_CMD= prior-wrap prefix"
1334 );
1335 assert!(
1336 content.contains(r#"lowered.includes("sqz compress")"#),
1337 "plugin must check for the `sqz compress` prior-wrap tail"
1338 );
1339 assert!(
1340 content.contains("isAlreadyWrapped(cmd)"),
1341 "plugin hook body must call isAlreadyWrapped on the command"
1342 );
1343 assert!(
1344 content.contains("function extractBaseCmd(cmd: string): string"),
1345 "plugin must define extractBaseCmd that skips env assignments"
1346 );
1347 assert!(
1348 content.contains("extractBaseCmd(cmd)"),
1349 "plugin hook body must use extractBaseCmd, not raw split"
1350 );
1351 }
1352
1353 #[test]
1356 fn test_is_already_wrapped_detects_all_marker_shapes() {
1357 assert!(is_already_wrapped("SQZ_CMD=git git status"));
1358 assert!(is_already_wrapped("sqz_cmd=git git status"));
1359 assert!(is_already_wrapped("git status | sqz compress"));
1360 assert!(is_already_wrapped("git status 2>&1 | /path/sqz compress"));
1361 assert!(is_already_wrapped("ls -la | sqz compress-stream"));
1362 assert!(is_already_wrapped("sqz stats"));
1363 assert!(is_already_wrapped("/usr/local/bin/sqz gain"));
1364 assert!(is_already_wrapped("SQZ_FOO=bar cmd"));
1365 assert!(!is_already_wrapped("git status"));
1366 assert!(!is_already_wrapped("grep sqz logfile.txt"));
1367 assert!(!is_already_wrapped("cargo test --package my-sqz-crate"));
1368 }
1369
1370 #[test]
1371 fn test_extract_base_cmd_skips_env_assignments() {
1372 assert_eq!(extract_base_cmd("make test"), "make");
1373 assert_eq!(extract_base_cmd("FOO=bar make test"), "make");
1374 assert_eq!(extract_base_cmd("FOO=bar BAZ=qux make test"), "make");
1375 assert_eq!(extract_base_cmd("/usr/bin/git status"), "git");
1376 assert_eq!(extract_base_cmd(""), "unknown");
1377 assert_eq!(extract_base_cmd("FOO=bar"), "unknown");
1378 }
1379
1380 #[test]
1381 fn test_is_env_assignment() {
1382 assert!(is_env_assignment("FOO=bar"));
1383 assert!(is_env_assignment("FOO="));
1384 assert!(is_env_assignment("_underscore=1"));
1385 assert!(is_env_assignment("MixedCase_1=x"));
1386 assert!(!is_env_assignment("=bar"));
1387 assert!(!is_env_assignment("FOO"));
1388 assert!(!is_env_assignment("--flag=value"));
1389 assert!(!is_env_assignment("123=value"));
1390 assert!(!is_env_assignment("FOO BAR=baz"));
1391 }
1392
1393 #[test]
1402 fn test_update_merges_into_existing_jsonc() {
1403 let dir = tempfile::tempdir().unwrap();
1404 let jsonc = dir.path().join("opencode.jsonc");
1405 std::fs::write(
1406 &jsonc,
1407 r#"{
1408 // user's own config with a comment
1409 "$schema": "https://opencode.ai/config.json",
1410 "model": "anthropic/claude-sonnet-4-5",
1411 /* another comment */
1412 "plugin": ["other-plugin"]
1413}
1414"#,
1415 )
1416 .unwrap();
1417
1418 let changed = update_opencode_config(dir.path()).unwrap();
1419 assert!(changed, "must merge sqz entries into the existing .jsonc");
1420
1421 assert!(jsonc.exists(), "original .jsonc must still exist");
1423 assert!(
1424 !dir.path().join("opencode.json").exists(),
1425 "must not create a parallel opencode.json alongside .jsonc \
1426 (that's the issue #6 bug)"
1427 );
1428
1429 let after = std::fs::read_to_string(&jsonc).unwrap();
1430 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1431 let plugins = parsed["plugin"].as_array().unwrap();
1432 assert!(
1436 !plugins.iter().any(|v| v.as_str() == Some("sqz")),
1437 "issue #10: sqz must NOT be added to plugin[]"
1438 );
1439 assert!(
1440 plugins.iter().any(|v| v.as_str() == Some("other-plugin")),
1441 "pre-existing plugin entries must be preserved"
1442 );
1443 assert_eq!(
1444 parsed["model"].as_str(),
1445 Some("anthropic/claude-sonnet-4-5"),
1446 "unrelated user keys must survive the merge"
1447 );
1448 assert_eq!(
1449 parsed["mcp"]["sqz"]["type"].as_str(),
1450 Some("local"),
1451 "mcp.sqz must be registered"
1452 );
1453 }
1454
1455 #[test]
1459 fn test_update_opencode_config_detailed_reports_comments_lost() {
1460 let dir = tempfile::tempdir().unwrap();
1461 let jsonc = dir.path().join("opencode.jsonc");
1462 std::fs::write(
1463 &jsonc,
1464 r#"{
1465 // comment to be dropped
1466 "plugin": ["other"]
1467}
1468"#,
1469 )
1470 .unwrap();
1471
1472 let (changed, comments_lost) =
1473 update_opencode_config_detailed(dir.path()).unwrap();
1474 assert!(changed);
1475 assert!(
1476 comments_lost,
1477 "merger must report that comments were dropped from .jsonc"
1478 );
1479 }
1480
1481 #[test]
1485 fn plan_opencode_reports_no_change_when_already_configured() {
1486 let dir = tempfile::tempdir().unwrap();
1487 update_opencode_config(dir.path()).unwrap();
1489 let planned = plan_opencode_config_change(dir.path()).unwrap();
1491 assert!(
1492 !planned.will_change,
1493 "re-running against a fully configured file must be a no-op"
1494 );
1495 assert!(!planned.comments_lost);
1496 }
1497
1498 #[test]
1501 fn plan_opencode_reports_change_without_writing() {
1502 let dir = tempfile::tempdir().unwrap();
1503 let path = dir.path().join("opencode.json");
1504 std::fs::write(&path, r#"{"plugin":["other"]}"#).unwrap();
1505 let before = std::fs::read_to_string(&path).unwrap();
1506
1507 let planned = plan_opencode_config_change(dir.path()).unwrap();
1508 assert!(planned.will_change);
1509 assert_eq!(planned.target_path, path);
1510
1511 let after = std::fs::read_to_string(&path).unwrap();
1512 assert_eq!(before, after, "dry-run must not modify the file");
1513 }
1514
1515 #[test]
1518 fn plan_opencode_reports_fresh_create() {
1519 let dir = tempfile::tempdir().unwrap();
1520 let planned = plan_opencode_config_change(dir.path()).unwrap();
1521 assert!(planned.will_change);
1522 assert_eq!(planned.target_path, dir.path().join("opencode.json"));
1523 assert!(!dir.path().join("opencode.json").exists(),
1524 "dry-run must not create the file");
1525 }
1526
1527 #[test]
1531 fn test_update_creates_plain_json_when_nothing_exists() {
1532 let dir = tempfile::tempdir().unwrap();
1533 update_opencode_config(dir.path()).unwrap();
1534 assert!(dir.path().join("opencode.json").exists());
1535 assert!(!dir.path().join("opencode.jsonc").exists());
1536 }
1537
1538 #[test]
1540 fn test_find_opencode_config_prefers_jsonc() {
1541 let dir = tempfile::tempdir().unwrap();
1542 std::fs::write(dir.path().join("opencode.json"), "{}").unwrap();
1543 std::fs::write(dir.path().join("opencode.jsonc"), "{}").unwrap();
1544 let found = find_opencode_config(dir.path()).unwrap();
1545 assert_eq!(
1546 found.file_name().unwrap(),
1547 "opencode.jsonc",
1548 "must prefer the .jsonc variant when both exist — the user \
1549 is maintaining .jsonc for its comment support"
1550 );
1551 }
1552
1553 #[test]
1554 fn test_find_opencode_config_returns_none_when_missing() {
1555 let dir = tempfile::tempdir().unwrap();
1556 assert!(find_opencode_config(dir.path()).is_none());
1557 }
1558
1559 #[test]
1560 fn test_opencode_config_has_comments_detects_jsonc_comments() {
1561 let dir = tempfile::tempdir().unwrap();
1562 std::fs::write(
1563 dir.path().join("opencode.jsonc"),
1564 "// a line comment\n{\"plugin\":[]}\n",
1565 )
1566 .unwrap();
1567 assert!(opencode_config_has_comments(dir.path()));
1568 }
1569
1570 #[test]
1571 fn test_opencode_config_has_comments_ignores_plain_json() {
1572 let dir = tempfile::tempdir().unwrap();
1573 std::fs::write(
1575 dir.path().join("opencode.json"),
1576 r#"{"url":"http://example.com"}"#,
1577 )
1578 .unwrap();
1579 assert!(!opencode_config_has_comments(dir.path()));
1580 }
1581
1582 #[test]
1585 fn test_strip_jsonc_comments_removes_line_comments() {
1586 let src = "{\n // leading comment\n \"a\": 1 // trailing\n}";
1587 let stripped = strip_jsonc_comments(src);
1588 assert!(!stripped.contains("leading comment"));
1589 assert!(!stripped.contains("trailing"));
1590 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1591 assert_eq!(parsed["a"], 1);
1592 }
1593
1594 #[test]
1595 fn test_strip_jsonc_comments_removes_block_comments() {
1596 let src = "{\n /* block\n comment */\n \"a\": 1\n}";
1597 let stripped = strip_jsonc_comments(src);
1598 assert!(!stripped.contains("block"));
1599 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1600 assert_eq!(parsed["a"], 1);
1601 }
1602
1603 #[test]
1604 fn test_strip_jsonc_comments_preserves_strings() {
1605 let src = r#"{"url": "http://example.com", "re": "/* not a comment */"}"#;
1610 let stripped = strip_jsonc_comments(src);
1611 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1612 assert_eq!(parsed["url"], "http://example.com");
1613 assert_eq!(parsed["re"], "/* not a comment */");
1614 }
1615
1616 #[test]
1617 fn test_strip_jsonc_comments_preserves_escaped_quote_in_string() {
1618 let src = r#"{"s": "a\"//b"}"#;
1619 let stripped = strip_jsonc_comments(src);
1620 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1621 assert_eq!(parsed["s"], r#"a"//b"#);
1622 }
1623
1624 #[test]
1625 fn test_strip_jsonc_comments_tolerates_unterminated_block() {
1626 let src = "{\"a\":1 /* never ends";
1628 let _ = strip_jsonc_comments(src); }
1630
1631 #[test]
1632 fn test_strip_jsonc_comments_removes_trailing_commas() {
1633 let src = r#"{
1636 "a": [1, 2, 3,],
1637 "b": {"x": 1, "y": 2,},
1638}"#;
1639 let stripped = strip_jsonc_comments(src);
1640 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1641 assert_eq!(parsed["a"], serde_json::json!([1, 2, 3]));
1642 assert_eq!(parsed["b"]["x"], 1);
1643 assert_eq!(parsed["b"]["y"], 2);
1644 }
1645
1646 #[test]
1647 fn test_strip_jsonc_comments_trailing_comma_in_string_preserved() {
1648 let src = r#"{"s": "a,}"}"#;
1650 let stripped = strip_jsonc_comments(src);
1651 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1652 assert_eq!(parsed["s"], "a,}");
1653 }
1654
1655 #[test]
1656 fn test_strip_jsonc_full_opencode_jsonc_with_comments_and_trailing_commas() {
1657 let src = r#"{
1660 // User's OpenCode config
1661 "$schema": "https://opencode.ai/config.json",
1662 "mcp": {
1663 // MCP servers
1664 "dart": {
1665 "type": "local",
1666 "command": ["dart", "mcp-server"],
1667 },
1668 },
1669}"#;
1670 let stripped = strip_jsonc_comments(src);
1671 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1672 assert_eq!(
1673 parsed["mcp"]["dart"]["type"], "local",
1674 "must parse after stripping comments + trailing commas"
1675 );
1676 }
1677
1678 #[test]
1686 fn test_remove_sqz_preserves_other_user_config() {
1687 let dir = tempfile::tempdir().unwrap();
1688 let config = dir.path().join("opencode.json");
1689 std::fs::write(
1690 &config,
1691 r#"{
1692 "$schema": "https://opencode.ai/config.json",
1693 "model": "anthropic/claude-sonnet-4-5",
1694 "plugin": ["other-plugin", "sqz"],
1695 "mcp": {
1696 "sqz": { "type": "local", "command": ["sqz-mcp"] },
1697 "jira": { "type": "remote", "url": "https://jira.example.com/mcp" }
1698 }
1699}
1700"#,
1701 )
1702 .unwrap();
1703
1704 let (path, changed) =
1705 remove_sqz_from_opencode_config(dir.path()).unwrap().unwrap();
1706 assert_eq!(path, config);
1707 assert!(changed, "must report that sqz entries were removed");
1708 assert!(
1709 config.exists(),
1710 "file must NOT be deleted — only sqz's entries removed"
1711 );
1712
1713 let after = std::fs::read_to_string(&config).unwrap();
1714 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1715 let plugins = parsed["plugin"].as_array().unwrap();
1716 assert!(!plugins.iter().any(|v| v.as_str() == Some("sqz")));
1717 assert!(plugins.iter().any(|v| v.as_str() == Some("other-plugin")));
1718 let mcp = parsed["mcp"].as_object().unwrap();
1719 assert!(!mcp.contains_key("sqz"), "mcp.sqz must be gone");
1720 assert!(mcp.contains_key("jira"), "mcp.jira must survive");
1721 assert_eq!(
1722 parsed["model"].as_str(),
1723 Some("anthropic/claude-sonnet-4-5"),
1724 "unrelated keys must survive"
1725 );
1726 }
1727
1728 #[test]
1732 fn test_remove_sqz_deletes_file_when_nothing_else_remains() {
1733 let dir = tempfile::tempdir().unwrap();
1734 let config = dir.path().join("opencode.json");
1735 std::fs::write(
1737 &config,
1738 r#"{
1739 "$schema": "https://opencode.ai/config.json",
1740 "mcp": {
1741 "sqz": { "type": "local", "command": ["sqz-mcp", "--transport", "stdio"] }
1742 },
1743 "plugin": ["sqz"]
1744}
1745"#,
1746 )
1747 .unwrap();
1748
1749 let (_, changed) =
1750 remove_sqz_from_opencode_config(dir.path()).unwrap().unwrap();
1751 assert!(changed);
1752 assert!(
1753 !config.exists(),
1754 "file with only $schema + sqz entries must be removed"
1755 );
1756 }
1757
1758 #[test]
1761 fn test_remove_sqz_returns_none_when_config_missing() {
1762 let dir = tempfile::tempdir().unwrap();
1763 let result = remove_sqz_from_opencode_config(dir.path()).unwrap();
1764 assert!(result.is_none());
1765 }
1766
1767 #[test]
1770 fn test_remove_sqz_from_jsonc_drops_comments() {
1771 let dir = tempfile::tempdir().unwrap();
1772 let jsonc = dir.path().join("opencode.jsonc");
1773 std::fs::write(
1774 &jsonc,
1775 r#"{
1776 // user's comment
1777 "model": "x",
1778 "plugin": ["sqz", "other"]
1779}
1780"#,
1781 )
1782 .unwrap();
1783
1784 let (path, changed) =
1785 remove_sqz_from_opencode_config(dir.path()).unwrap().unwrap();
1786 assert_eq!(path, jsonc);
1787 assert!(changed);
1788 assert!(path.exists(), "jsonc file kept because `model` and `other` remain");
1789
1790 let after = std::fs::read_to_string(&jsonc).unwrap();
1791 assert!(
1792 !after.contains("// user's comment"),
1793 "comments are dropped by the serde_json round-trip; \
1794 documented in update_opencode_config_detailed"
1795 );
1796 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1797 let plugins = parsed["plugin"].as_array().unwrap();
1798 assert_eq!(plugins.len(), 1);
1799 assert_eq!(plugins[0], "other");
1800 }
1801
1802 #[test]
1812 fn issue_10_opencode_rewrite_works_in_powershell_syntax() {
1813 let input = r#"{"tool":"bash","args":{"command":"dotnet build NewNeonCheckers3.sln"}}"#;
1814 let result = process_opencode_hook(input).unwrap();
1815 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1816 let cmd = parsed["args"]["command"].as_str().unwrap();
1817
1818 assert!(
1822 !cmd.contains("SQZ_CMD="),
1823 "issue #10: rewrite must not emit `SQZ_CMD=` (breaks on \
1824 PowerShell/cmd.exe); got: {cmd}"
1825 );
1826 assert!(
1828 cmd.contains("--cmd dotnet"),
1829 "rewrite must pass label via --cmd; got: {cmd}"
1830 );
1831 let first_token = cmd.split_whitespace().next().unwrap_or("");
1836 assert_eq!(
1837 first_token, "dotnet",
1838 "first token of the rewritten command must be the user's \
1839 command itself, not an env-var assignment; got: {cmd}"
1840 );
1841 }
1842
1843 #[test]
1847 fn issue_10_ts_plugin_emits_cmd_flag_not_env_prefix() {
1848 let content = generate_opencode_plugin("sqz");
1849 assert!(
1853 content.contains("compress --cmd"),
1854 "TS plugin must build rewrite with `compress --cmd ${{base}}`"
1855 );
1856 assert!(
1862 !content.contains("SQZ_CMD=${base}"),
1863 "TS plugin must not emit the legacy `SQZ_CMD=${{base}}` prefix"
1864 );
1865 }
1866
1867 #[test]
1882 fn issue_10_fresh_opencode_config_has_no_plugin_entry() {
1883 let dir = tempfile::tempdir().unwrap();
1884 update_opencode_config(dir.path()).unwrap();
1885 let content = std::fs::read_to_string(dir.path().join("opencode.json")).unwrap();
1886 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1887
1888 assert!(
1890 parsed.get("plugin").is_none(),
1891 "issue #10: fresh opencode.json must not include `plugin` key; got: {content}"
1892 );
1893
1894 assert_eq!(
1897 parsed["mcp"]["sqz"]["type"].as_str(),
1898 Some("local"),
1899 "mcp.sqz is the one sqz-authored entry that belongs in \
1900 opencode.json; must still be registered"
1901 );
1902 }
1903
1904 #[test]
1909 fn issue_10_reinit_strips_legacy_plugin_entry() {
1910 let dir = tempfile::tempdir().unwrap();
1911 let config = dir.path().join("opencode.json");
1912 std::fs::write(
1913 &config,
1914 r#"{"$schema":"https://opencode.ai/config.json","mcp":{"sqz":{"type":"local","command":["sqz-mcp","--transport","stdio"]}},"plugin":["sqz"]}"#,
1916 )
1917 .unwrap();
1918
1919 let changed = update_opencode_config(dir.path()).unwrap();
1920 assert!(changed, "re-init must report a change (the legacy entry was stripped)");
1921
1922 let after = std::fs::read_to_string(&config).unwrap();
1923 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1924 assert!(
1925 parsed.get("plugin").is_none(),
1926 "legacy `plugin: [\"sqz\"]` must be stripped on re-init; got: {after}"
1927 );
1928 assert_eq!(
1930 parsed["mcp"]["sqz"]["type"].as_str(),
1931 Some("local"),
1932 "mcp.sqz must survive cleanup of the plugin entry"
1933 );
1934 }
1935}