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 // Don't rewrite commands containing shell operators. Appending
88 // `2>&1 | sqz compress` to a heredoc, compound command, existing
89 // pipe, or redirect corrupts the command. Issue #22: heredoc
90 // terminators like `EOF 2>&1 | sqz compress --cmd git` are not
91 // valid delimiters.
92 function hasShellOperators(cmd: string): boolean {{
93 if (cmd.includes("&&") || cmd.includes("||") || cmd.includes(";")) return true;
94 if (cmd.includes(">") || cmd.includes("<")) return true;
95 if (cmd.includes("|")) return true;
96 if (cmd.includes("<<")) return true;
97 if (cmd.includes("$(") || cmd.includes("`")) return true;
98 return false;
99 }}
100
101 function shouldIntercept(tool: string): boolean {{
102 return ["bash", "shell", "terminal", "run_shell_command"].includes(tool.toLowerCase());
103 }}
104
105 // Detect that a command has already been wrapped by sqz. Before this
106 // guard was in place OpenCode could call the hook twice on the same
107 // command (for retried tool calls, or when a previous rewrite was
108 // echoed back to the agent and the agent re-submitted it) and each
109 // pass would prepend another `SQZ_CMD=$base` prefix, producing monsters
110 // like `SQZ_CMD=SQZ_CMD=ddev SQZ_CMD=ddev ddev exec ...` (reported as
111 // a follow-up to issue #5). We skip if any of these markers appear:
112 // * the case-insensitive substring "sqz_cmd=" or "sqz compress"
113 // (covers the tail of prior wraps regardless of case; SQZ_CMD= is
114 // legacy pre-issue-#10 but still valid in POSIX shell hooks)
115 // * a leading `VAR=` assignment that starts with SQZ_
116 // (defensive catch-all for exotic wrap variants)
117 // * the base command itself is sqz or sqz-mcp (running sqz directly
118 // — compressing sqz's own output is pointless and causes loops)
119 function isAlreadyWrapped(cmd: string): boolean {{
120 const lowered = cmd.toLowerCase();
121 if (lowered.includes("sqz_cmd=")) return true;
122 if (lowered.includes("sqz compress")) return true;
123 if (lowered.includes("| sqz ") || lowered.includes("| sqz\t")) return true;
124 if (/^\s*SQZ_[A-Z0-9_]+=/.test(cmd)) return true;
125 const base = extractBaseCmd(cmd);
126 if (base === "sqz" || base === "sqz-mcp" || base === "sqz.exe") return true;
127 return false;
128 }}
129
130 // Extract the base command name defensively. If the command has
131 // leading env-var assignments (VAR=val VAR2=val2 actual_cmd arg1),
132 // skip past them so the base is `actual_cmd` — not `VAR=val`.
133 function extractBaseCmd(cmd: string): string {{
134 const tokens = cmd.split(/\s+/).filter(t => t.length > 0);
135 for (const tok of tokens) {{
136 // A token is an env assignment if it matches NAME=VALUE where NAME
137 // is a valid env var identifier. Skip it and keep looking.
138 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tok)) continue;
139 return tok.split("/").pop() ?? "unknown";
140 }}
141 return "unknown";
142 }}
143
144 // Shell-escape a command-name label so it's safe to inline into the
145 // rewritten shell command. Agents occasionally invoke commands via
146 // paths with spaces (`"/my tools/foo" --arg`) and in the LLM
147 // roundtrip that can survive to `extractBaseCmd`'s output. Quote the
148 // label unless it's pure ASCII alphanumeric.
149 function shellEscapeLabel(s: string): string {{
150 if (/^[A-Za-z0-9_.-]+$/.test(s)) return s;
151 return "'" + s.replace(/'/g, "'\\''") + "'";
152 }}
153
154 // A pipe exits with sqz's status, so the command's own status rides
155 // through as a trailer sqz strips and exits with. That form is POSIX;
156 // OpenCode uses a POSIX shell outside Windows (it refuses fish and nu).
157 const POSIX = process.platform !== "win32" &&
158 !/(^|\/)(fish|nu|csh|tcsh|xonsh|elvish)$/.test(process.env.SHELL ?? "");
159
160 function wrap(cmd: string, label: string): string {{
161 if (!POSIX) return `${{cmd}} 2>&1 | ${{SQZ_PATH}} compress --cmd ${{label}}`;
162 return `{{ ( ${{cmd}}\n); printf '\\n__SQZ_EXIT_%d__\\n' "$?"; }} 2>&1 | ${{SQZ_PATH}} compress --cmd ${{label}}`;
163 }}
164
165 return {{
166 "tool.execute.before": async (input: any, output: any) => {{
167 const tool = input.tool ?? "";
168 if (!shouldIntercept(tool)) return;
169
170 const cmd = output.args?.command ?? "";
171 if (!cmd || isAlreadyWrapped(cmd) || isInteractive(cmd) || hasShellOperators(cmd)) return;
172
173 // Rewrite: pipe through `sqz compress --cmd <base>`.
174 //
175 // Issue #10: the previous form was `SQZ_CMD=<base> <cmd> 2>&1 |
176 // <sqz> compress`, which uses sh-specific inline env-var syntax.
177 // On Windows, OpenCode Desktop routes bash-tool commands through
178 // PowerShell (or cmd.exe when $SHELL is unset), and both parse
179 // `SQZ_CMD=cmd` as a command name — raising CommandNotFoundException
180 // and producing zero compression. `--cmd NAME` is a normal CLI
181 // argument, shell-neutral, works in POSIX sh, zsh, fish, PowerShell,
182 // and cmd.exe.
183 const base = extractBaseCmd(cmd);
184 output.args.command = wrap(cmd, shellEscapeLabel(base));
185 }},
186 }};
187}};
188
189// V1 default export — modern OpenCode (post-V1 loader) reads `id` here
190// and displays "sqz" in the plugin list. Without this, OpenCode falls
191// back to the raw `file:///...` spec as the plugin name (@itguy327 on
192// issue #10). `readV1Plugin` in OpenCode's plugin/shared.ts requires
193// file-source plugins to declare an id — otherwise `resolvePluginId`
194// throws.
195export default {{
196 id: "sqz",
197 server: SqzPluginFactory,
198}};
199
200// Legacy named export — pre-V1 OpenCode versions walk Object.values(mod)
201// looking for factory functions. Assigning the same reference as the
202// default export's `.server` means the legacy `seen` Set dedups via
203// identity, so the factory fires exactly once either way. Kept for
204// backward compatibility with OpenCode versions that predate the V1
205// loader (roughly anything before mid-2025).
206export const SqzPlugin = SqzPluginFactory;
207"#
208 )
209}
210
211pub fn opencode_plugin_path() -> PathBuf {
213 let home = std::env::var("HOME")
214 .or_else(|_| std::env::var("USERPROFILE"))
215 .map(PathBuf::from)
216 .unwrap_or_else(|_| PathBuf::from("."));
217 home.join(".config")
218 .join("opencode")
219 .join("plugins")
220 .join("sqz.ts")
221}
222
223pub fn install_opencode_plugin(sqz_path: &str) -> Result<bool> {
235 install_opencode_plugin_at(&opencode_plugin_path(), sqz_path)
236}
237
238pub(crate) fn install_opencode_plugin_at(plugin_path: &Path, sqz_path: &str) -> Result<bool> {
239 let new_content = generate_opencode_plugin(sqz_path);
240
241 if plugin_path.exists() {
243 if let Ok(existing) = std::fs::read_to_string(&plugin_path) {
244 if existing == new_content {
245 return Ok(false);
246 }
247 }
248 }
249
250 if let Some(parent) = plugin_path.parent() {
251 std::fs::create_dir_all(parent).map_err(|e| {
252 crate::error::SqzError::Other(format!(
253 "failed to create OpenCode plugins dir {}: {e}",
254 parent.display()
255 ))
256 })?;
257 }
258
259 let content = generate_opencode_plugin(sqz_path);
260 std::fs::write(&plugin_path, &content).map_err(|e| {
261 crate::error::SqzError::Other(format!(
262 "failed to write OpenCode plugin to {}: {e}",
263 plugin_path.display()
264 ))
265 })?;
266
267 Ok(true)
268}
269
270pub fn find_opencode_config(project_dir: &Path) -> Option<PathBuf> {
277 let jsonc = project_dir.join("opencode.jsonc");
278 if jsonc.exists() {
279 return Some(jsonc);
280 }
281 let json = project_dir.join("opencode.json");
282 if json.exists() {
283 return Some(json);
284 }
285 None
286}
287
288pub fn opencode_config_has_comments(project_dir: &Path) -> bool {
293 let path = match find_opencode_config(project_dir) {
294 Some(p) => p,
295 None => return false,
296 };
297 if path.extension().map(|e| e != "jsonc").unwrap_or(true) {
298 return false;
299 }
300 let content = match std::fs::read_to_string(&path) {
301 Ok(s) => s,
302 Err(_) => return false,
303 };
304 strip_jsonc_comments(&content) != content
305}
306
307pub fn strip_jsonc_comments(src: &str) -> String {
317 let mut out = String::with_capacity(src.len());
318 let bytes = src.as_bytes();
319 let mut i = 0;
320 let len = bytes.len();
321
322 while i < len {
323 let b = bytes[i];
324
325 if b == b'"' {
328 out.push('"');
329 i += 1;
330 while i < len {
331 let c = bytes[i];
332 out.push(c as char);
333 if c == b'\\' && i + 1 < len {
334 out.push(bytes[i + 1] as char);
336 i += 2;
337 continue;
338 }
339 i += 1;
340 if c == b'"' {
341 break;
342 }
343 }
344 continue;
345 }
346
347 if b == b'/' && i + 1 < len && bytes[i + 1] == b'/' {
350 i += 2;
351 while i < len && bytes[i] != b'\n' {
352 i += 1;
353 }
354 continue;
355 }
356
357 if b == b'/' && i + 1 < len && bytes[i + 1] == b'*' {
359 i += 2;
360 while i + 1 < len && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
361 if bytes[i] == b'\n' {
363 out.push('\n');
364 }
365 i += 1;
366 }
367 if i + 1 < len {
370 i += 2;
371 }
372 continue;
373 }
374
375 out.push(b as char);
376 i += 1;
377 }
378
379 strip_trailing_commas(&out)
385}
386
387fn strip_trailing_commas(src: &str) -> String {
392 let mut out = String::with_capacity(src.len());
393 let bytes = src.as_bytes();
394 let mut i = 0;
395 let len = bytes.len();
396
397 while i < len {
398 let b = bytes[i];
399
400 if b == b'"' {
402 out.push('"');
403 i += 1;
404 while i < len {
405 let c = bytes[i];
406 out.push(c as char);
407 if c == b'\\' && i + 1 < len {
408 out.push(bytes[i + 1] as char);
409 i += 2;
410 continue;
411 }
412 i += 1;
413 if c == b'"' {
414 break;
415 }
416 }
417 continue;
418 }
419
420 if b == b',' {
424 let mut j = i + 1;
425 while j < len && bytes[j].is_ascii_whitespace() {
426 j += 1;
427 }
428 if j < len && (bytes[j] == b']' || bytes[j] == b'}') {
429 for k in (i + 1)..j {
432 out.push(bytes[k] as char);
433 }
434 i = j;
435 continue;
436 }
437 }
438
439 out.push(b as char);
440 i += 1;
441 }
442
443 out
444}
445
446pub fn update_opencode_config(project_dir: &Path) -> Result<bool> {
462 let (updated, _) = update_opencode_config_detailed(project_dir)?;
463 Ok(updated)
464}
465
466pub fn update_opencode_config_detailed(project_dir: &Path) -> Result<(bool, bool)> {
470 let planned = plan_opencode_config_change(project_dir)?;
471 if !planned.will_change {
472 return Ok((false, false));
473 }
474 apply_opencode_config_change(project_dir, &planned)
476}
477
478pub fn plan_opencode_config_change(project_dir: &Path) -> Result<PlannedOpencodeChange> {
491 compute_opencode_change(project_dir, false).map(|r| r.0)
492}
493
494#[derive(Debug, Clone, PartialEq, Eq)]
496pub struct PlannedOpencodeChange {
497 pub target_path: PathBuf,
499 pub will_change: bool,
502 pub comments_lost: bool,
505}
506
507fn apply_opencode_config_change(
508 project_dir: &Path,
509 _planned: &PlannedOpencodeChange,
510) -> Result<(bool, bool)> {
511 let (planned, _) = compute_opencode_change(project_dir, true)?;
512 Ok((planned.will_change, planned.comments_lost))
513}
514
515fn compute_opencode_change(
521 project_dir: &Path,
522 apply: bool,
523) -> Result<(PlannedOpencodeChange, ())> {
524 fn sqz_mcp_value() -> serde_json::Value {
525 serde_json::json!({
526 "type": "local",
527 "command": ["sqz-mcp", "--transport", "stdio"],
528 "enabled": true
529 })
530 }
531
532 if let Some(existing_path) = find_opencode_config(project_dir) {
533 let is_jsonc = existing_path
534 .extension()
535 .map(|e| e == "jsonc")
536 .unwrap_or(false);
537 let content = std::fs::read_to_string(&existing_path).map_err(|e| {
538 crate::error::SqzError::Other(format!(
539 "failed to read {}: {e}",
540 existing_path.display()
541 ))
542 })?;
543 let parseable = if is_jsonc {
544 strip_jsonc_comments(&content)
545 } else {
546 content.clone()
547 };
548 let had_comments = is_jsonc && parseable != content;
549
550 let mut config: serde_json::Value = serde_json::from_str(&parseable).map_err(|e| {
551 crate::error::SqzError::Other(format!(
552 "failed to parse {}: {e}",
553 existing_path.display()
554 ))
555 })?;
556 let obj = config.as_object_mut().ok_or_else(|| {
557 crate::error::SqzError::Other(format!(
558 "{} root is not a JSON object",
559 existing_path.display()
560 ))
561 })?;
562
563 let mut changed = false;
564
565 if let Some(arr) = obj.get_mut("plugin").and_then(|v| v.as_array_mut()) {
566 let before = arr.len();
567 arr.retain(|v| v.as_str() != Some("sqz"));
568 if arr.len() != before {
569 changed = true;
570 }
571 if arr.is_empty() {
572 obj.remove("plugin");
573 changed = true;
574 }
575 }
576
577 let mcp_entry = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
578 if let Some(mcp_obj) = mcp_entry.as_object_mut() {
579 if !mcp_obj.contains_key("sqz") {
580 mcp_obj.insert("sqz".to_string(), sqz_mcp_value());
581 changed = true;
582 } else if let Some(sqz_entry) = mcp_obj.get_mut("sqz").and_then(|v| v.as_object_mut()) {
583 if !sqz_entry.contains_key("enabled") {
584 sqz_entry.insert("enabled".to_string(), serde_json::json!(true));
585 changed = true;
586 }
587 }
588 } else {
589 return Err(crate::error::SqzError::Other(format!(
590 "{} has an `mcp` field that is not an object; \
591 refusing to modify it automatically",
592 existing_path.display()
593 )));
594 }
595
596 let planned = PlannedOpencodeChange {
597 target_path: existing_path.clone(),
598 will_change: changed,
599 comments_lost: changed && had_comments,
600 };
601
602 if apply && changed {
603 let mut updated = serde_json::to_string_pretty(&config).map_err(|e| {
604 crate::error::SqzError::Other(format!("failed to serialize config: {e}"))
605 })?;
606 if content.ends_with('\n') {
607 updated.push('\n');
608 }
609 std::fs::write(&existing_path, updated).map_err(|e| {
610 crate::error::SqzError::Other(format!(
611 "failed to write {}: {e}",
612 existing_path.display()
613 ))
614 })?;
615 }
616
617 Ok((planned, ()))
618 } else {
619 let target = project_dir.join("opencode.json");
621 let planned = PlannedOpencodeChange {
622 target_path: target.clone(),
623 will_change: true,
624 comments_lost: false,
625 };
626
627 if apply {
628 let config = serde_json::json!({
629 "$schema": "https://opencode.ai/config.json",
630 "mcp": {
631 "sqz": sqz_mcp_value()
632 }
633 });
634 let content = serde_json::to_string_pretty(&config).map_err(|e| {
635 crate::error::SqzError::Other(format!("failed to serialize opencode.json: {e}"))
636 })?;
637 std::fs::write(&target, format!("{content}\n")).map_err(|e| {
638 crate::error::SqzError::Other(format!("failed to write opencode.json: {e}"))
639 })?;
640 }
641
642 Ok((planned, ()))
643 }
644}
645
646pub fn remove_sqz_from_opencode_config(project_dir: &Path) -> Result<Option<(PathBuf, bool)>> {
659 let path = match find_opencode_config(project_dir) {
660 Some(p) => p,
661 None => return Ok(None),
662 };
663 let is_jsonc = path.extension().map(|e| e == "jsonc").unwrap_or(false);
664 let raw = std::fs::read_to_string(&path).map_err(|e| {
665 crate::error::SqzError::Other(format!("failed to read {}: {e}", path.display()))
666 })?;
667 let parseable = if is_jsonc {
668 strip_jsonc_comments(&raw)
669 } else {
670 raw.clone()
671 };
672 let mut config: serde_json::Value = match serde_json::from_str(&parseable) {
673 Ok(v) => v,
674 Err(_) => {
675 return Ok(Some((path, false)));
677 }
678 };
679
680 let mut changed = false;
681
682 if let Some(obj) = config.as_object_mut() {
683 if let Some(plugin) = obj.get_mut("plugin").and_then(|v| v.as_array_mut()) {
685 let before = plugin.len();
686 plugin.retain(|v| v.as_str() != Some("sqz"));
687 if plugin.len() != before {
688 changed = true;
689 }
690 if plugin.is_empty() {
692 obj.remove("plugin");
693 }
694 }
695
696 if let Some(mcp) = obj.get_mut("mcp").and_then(|v| v.as_object_mut()) {
698 if mcp.remove("sqz").is_some() {
699 changed = true;
700 }
701 if mcp.is_empty() {
702 obj.remove("mcp");
703 }
704 }
705 }
706
707 if !changed {
708 return Ok(Some((path, false)));
709 }
710
711 let essentially_empty = match config.as_object() {
716 Some(obj) => {
717 obj.is_empty()
718 || (obj.len() == 1
719 && obj.get("$schema").and_then(|v| v.as_str())
720 == Some("https://opencode.ai/config.json"))
721 }
722 None => false,
723 };
724
725 if essentially_empty {
726 std::fs::remove_file(&path).map_err(|e| {
727 crate::error::SqzError::Other(format!(
728 "failed to remove {}: {e}",
729 path.display()
730 ))
731 })?;
732 return Ok(Some((path, true)));
733 }
734
735 let mut updated = serde_json::to_string_pretty(&config).map_err(|e| {
738 crate::error::SqzError::Other(format!("failed to serialize config: {e}"))
739 })?;
740 if raw.ends_with('\n') {
741 updated.push('\n');
742 }
743 std::fs::write(&path, updated).map_err(|e| {
744 crate::error::SqzError::Other(format!(
745 "failed to write {}: {e}",
746 path.display()
747 ))
748 })?;
749 Ok(Some((path, true)))
750}
751
752fn is_already_wrapped(command: &str) -> bool {
764 let lowered = command.to_ascii_lowercase();
765 if lowered.contains("sqz_cmd=") {
766 return true;
767 }
768 if lowered.contains("sqz compress") {
769 return true;
770 }
771 if lowered.contains("| sqz ") || lowered.contains("| sqz\t") {
772 return true;
773 }
774 let trimmed = command.trim_start();
776 if let Some(eq_idx) = trimmed.find('=') {
777 let name = &trimmed[..eq_idx];
778 if name.starts_with("SQZ_")
779 && !name.is_empty()
780 && name
781 .chars()
782 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
783 {
784 return true;
785 }
786 }
787 let base = extract_base_cmd(command);
789 if base == "sqz" || base == "sqz-mcp" || base == "sqz.exe" {
790 return true;
791 }
792 false
793}
794
795fn extract_base_cmd(command: &str) -> &str {
802 for tok in command.split_whitespace() {
803 if is_env_assignment(tok) {
804 continue;
805 }
806 return tok.rsplit('/').next().unwrap_or("unknown");
807 }
808 "unknown"
809}
810
811fn is_env_assignment(token: &str) -> bool {
815 let eq = match token.find('=') {
816 Some(i) => i,
817 None => return false,
818 };
819 if eq == 0 {
820 return false;
821 }
822 let name = &token[..eq];
823 let mut chars = name.chars();
824 match chars.next() {
825 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
826 _ => return false,
827 }
828 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
829}
830
831pub fn process_opencode_hook(input: &str) -> Result<String> {
841 let parsed: serde_json::Value = serde_json::from_str(input)
842 .map_err(|e| crate::error::SqzError::Other(format!("opencode hook: invalid JSON: {e}")))?;
843
844 let tool = parsed
845 .get("tool")
846 .or_else(|| parsed.get("toolName"))
847 .or_else(|| parsed.get("tool_name"))
848 .and_then(|v| v.as_str())
849 .unwrap_or("");
850
851 if !matches!(
853 tool.to_lowercase().as_str(),
854 "bash" | "shell" | "terminal" | "run_shell_command"
855 ) {
856 return Ok(input.to_string());
857 }
858
859 let command = parsed
861 .get("args")
862 .or_else(|| parsed.get("toolCall"))
863 .or_else(|| parsed.get("tool_input"))
864 .and_then(|v| v.get("command"))
865 .and_then(|v| v.as_str())
866 .unwrap_or("");
867
868 if command.is_empty() || is_already_wrapped(command) {
869 return Ok(input.to_string());
870 }
871
872 if crate::tool_hooks::has_shell_operators(command) {
876 return Ok(input.to_string());
877 }
878
879 let base = extract_base_cmd(command);
883
884 if matches!(
885 base,
886 "vim" | "vi" | "nano" | "emacs" | "less" | "more" | "top" | "htop"
887 | "ssh" | "python" | "python3" | "node" | "irb" | "ghci"
888 | "psql" | "mysql" | "sqlite3" | "mongo" | "redis-cli"
889 ) || command.contains("--watch")
890 || command.contains("run dev")
891 || command.contains("run start")
892 || command.contains("run serve")
893 {
894 return Ok(input.to_string());
895 }
896
897 let base_cmd = base;
899
900 let escaped_base = if base_cmd
901 .chars()
902 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
903 {
904 base_cmd.to_string()
905 } else {
906 format!("'{}'", base_cmd.replace('\'', "'\\''"))
907 };
908
909 let rewritten = if cfg!(windows) {
914 format!("{} 2>&1 | sqz compress --cmd {}", command, escaped_base)
915 } else {
916 crate::exit_marker::posix_rewrite(command, &escaped_base)
917 };
918
919 let output = serde_json::json!({
921 "updatedInput": {
922 "command": rewritten
923 },
924 "args": {
925 "command": rewritten
926 }
927 });
928
929 serde_json::to_string(&output)
930 .map_err(|e| crate::error::SqzError::Other(format!("opencode hook: serialize error: {e}")))
931}
932
933#[cfg(test)]
936mod tests {
937 use super::*;
938
939 #[test]
940 fn test_generate_opencode_plugin_contains_sqz_path() {
941 let content = generate_opencode_plugin("/usr/local/bin/sqz");
942 assert!(content.contains("/usr/local/bin/sqz"));
943 assert!(content.contains("SqzPlugin"));
944 assert!(content.contains("tool.execute.before"));
945 }
946
947 #[test]
948 fn test_generate_opencode_plugin_windows_path_escaped() {
949 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
954 let content = generate_opencode_plugin(windows_path);
955 assert!(
959 content.contains(r#"const SQZ_PATH = "C:\\Users\\SqzUser\\.cargo\\bin\\sqz.exe""#),
960 "expected JS-escaped path in plugin — got:\n{content}"
961 );
962 assert!(
965 !content.contains(r#"const SQZ_PATH = "C:\U"#),
966 "plugin must not contain unescaped backslashes in the string literal"
967 );
968 }
969
970 #[test]
971 fn test_generate_opencode_plugin_has_interactive_check() {
972 let content = generate_opencode_plugin("sqz");
973 assert!(content.contains("isInteractive"));
974 assert!(content.contains("vim"));
975 assert!(content.contains("--watch"));
976 }
977
978 #[test]
981 fn test_generate_opencode_plugin_has_shell_operator_guard() {
982 let content = generate_opencode_plugin("sqz");
983 assert!(
984 content.contains("function hasShellOperators(cmd: string): boolean"),
985 "plugin must define hasShellOperators guard (issue #22)"
986 );
987 assert!(
988 content.contains("hasShellOperators(cmd)"),
989 "plugin hook body must call hasShellOperators (issue #22)"
990 );
991 assert!(
992 content.contains("<<"),
993 "hasShellOperators must check for heredoc operator"
994 );
995 }
996
997 #[test]
1013 fn test_generate_opencode_plugin_declares_v1_id() {
1014 let content = generate_opencode_plugin("sqz");
1015 assert!(
1016 content.contains("id: \"sqz\""),
1017 "plugin must default-export `id: \"sqz\"` so OpenCode's \
1018 V1 loader (shared.ts readV1Plugin/resolvePluginId) \
1019 displays \"sqz\" in the UI instead of the file path; \
1020 got:\n{content}"
1021 );
1022 assert!(
1023 content.contains("server: SqzPluginFactory"),
1024 "plugin must default-export `server: <factory>` for V1 \
1025 loader compliance; got:\n{content}"
1026 );
1027 assert!(
1028 content.contains("export default {"),
1029 "plugin must have a default export per OpenCode V1 shape; \
1030 got:\n{content}"
1031 );
1032 }
1033
1034 #[test]
1045 fn test_generate_opencode_plugin_legacy_named_export_preserved() {
1046 let content = generate_opencode_plugin("sqz");
1047 assert!(
1048 content.contains("export const SqzPlugin = SqzPluginFactory"),
1049 "legacy named export must alias the same factory reference \
1050 as the V1 default export — otherwise old OpenCode versions \
1051 would see two distinct factories in `Object.values(mod)` \
1052 and fire the hook twice; got:\n{content}"
1053 );
1054 }
1055
1056 #[test]
1063 fn test_process_opencode_hook_rewrites_bash() {
1064 let input = r#"{"tool":"bash","args":{"command":"git status"}}"#;
1065 let result = process_opencode_hook(input).unwrap();
1066 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1067 assert!(parsed.get("decision").is_none() && parsed.get("reason").is_none(), "{parsed}");
1069 let cmd = parsed["args"]["command"].as_str().unwrap();
1070 assert_eq!(parsed["updatedInput"]["command"].as_str(), Some(cmd));
1071 if cfg!(unix) {
1072 assert_eq!(cmd, crate::exit_marker::posix_rewrite("git status", "git"));
1073 }
1074 assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
1075 assert!(cmd.contains("git status"), "should preserve original: {cmd}");
1076 assert!(cmd.contains("--cmd git"), "should pass base command via --cmd: {cmd}");
1080 assert!(
1081 !cmd.contains("SQZ_CMD="),
1082 "must not emit legacy sh-style env prefix: {cmd}"
1083 );
1084 }
1085
1086 #[test]
1087 fn test_process_opencode_hook_passes_non_shell() {
1088 let input = r#"{"tool":"read_file","args":{"path":"file.txt"}}"#;
1089 let result = process_opencode_hook(input).unwrap();
1090 assert_eq!(result, input, "non-shell tools should pass through");
1091 }
1092
1093 #[test]
1094 fn test_process_opencode_hook_skips_sqz_commands() {
1095 let input = r#"{"tool":"bash","args":{"command":"sqz stats"}}"#;
1096 let result = process_opencode_hook(input).unwrap();
1097 assert_eq!(result, input, "sqz commands should not be double-wrapped");
1098 }
1099
1100 #[test]
1101 fn test_process_opencode_hook_skips_interactive() {
1102 let input = r#"{"tool":"bash","args":{"command":"vim file.txt"}}"#;
1103 let result = process_opencode_hook(input).unwrap();
1104 assert_eq!(result, input, "interactive commands should pass through");
1105 }
1106
1107 #[test]
1108 fn test_process_opencode_hook_skips_watch() {
1109 let input = r#"{"tool":"bash","args":{"command":"npm run dev --watch"}}"#;
1110 let result = process_opencode_hook(input).unwrap();
1111 assert_eq!(result, input, "watch mode should pass through");
1112 }
1113
1114 #[test]
1118 fn test_process_opencode_hook_skips_shell_operators() {
1119 let heredoc = r#"{"tool":"bash","args":{"command":"git commit -F- <<'EOF'\nfeat: subject\n\nbody\nEOF"}}"#;
1121 let result = process_opencode_hook(heredoc).unwrap();
1122 assert_eq!(result, heredoc, "heredoc must not be rewritten (issue #22)");
1123
1124 let compound = r#"{"tool":"bash","args":{"command":"cargo build && cargo test"}}"#;
1126 let result = process_opencode_hook(compound).unwrap();
1127 assert_eq!(result, compound, "compound commands must pass through");
1128
1129 let pipe = r#"{"tool":"bash","args":{"command":"cat file.txt | grep foo"}}"#;
1131 let result = process_opencode_hook(pipe).unwrap();
1132 assert_eq!(result, pipe, "existing pipes must pass through");
1133
1134 let redirect = r#"{"tool":"bash","args":{"command":"echo hello > output.txt"}}"#;
1136 let result = process_opencode_hook(redirect).unwrap();
1137 assert_eq!(result, redirect, "redirects must pass through");
1138 }
1139
1140 #[test]
1141 fn test_process_opencode_hook_invalid_json() {
1142 let result = process_opencode_hook("not json");
1143 assert!(result.is_err());
1144 }
1145
1146 #[test]
1147 fn test_process_opencode_hook_empty_command() {
1148 let input = r#"{"tool":"bash","args":{"command":""}}"#;
1149 let result = process_opencode_hook(input).unwrap();
1150 assert_eq!(result, input);
1151 }
1152
1153 #[test]
1154 fn test_process_opencode_hook_run_shell_command() {
1155 let input = r#"{"tool":"run_shell_command","args":{"command":"ls -la"}}"#;
1156 let result = process_opencode_hook(input).unwrap();
1157 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1158 let cmd = parsed["args"]["command"].as_str().unwrap();
1159 assert!(cmd.contains("sqz compress"));
1160 }
1161
1162 #[test]
1163 fn test_install_opencode_plugin_creates_file() {
1164 let dir = tempfile::tempdir().unwrap();
1165 let plugin_path = dir
1166 .path()
1167 .join(".config/opencode/plugins/sqz.ts");
1168 let result = install_opencode_plugin_at(&plugin_path, "sqz");
1169 assert!(result.is_ok());
1170 assert!(plugin_path.exists(), "plugin file should exist");
1171 let content = std::fs::read_to_string(&plugin_path).unwrap();
1172 assert!(content.contains("SqzPlugin"));
1173 }
1174
1175 #[test]
1176 fn test_update_opencode_config_creates_new() {
1177 let dir = tempfile::tempdir().unwrap();
1178 let result = update_opencode_config(dir.path()).unwrap();
1179 assert!(result, "should create new config");
1180 let config_path = dir.path().join("opencode.json");
1181 assert!(config_path.exists());
1182 let content = std::fs::read_to_string(&config_path).unwrap();
1183 assert!(content.contains("\"sqz\""));
1184 assert!(content.contains("sqz-mcp"));
1185
1186 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1193 assert!(
1194 parsed.get("plugin").is_none(),
1195 "fresh-install opencode.json must not include `plugin`; got: {content}"
1196 );
1197 assert_eq!(
1198 parsed["mcp"]["sqz"]["type"].as_str(),
1199 Some("local"),
1200 "mcp.sqz must be present"
1201 );
1202 }
1203
1204 #[test]
1205 fn test_update_opencode_config_adds_to_existing() {
1206 let dir = tempfile::tempdir().unwrap();
1207 let config_path = dir.path().join("opencode.json");
1208 std::fs::write(
1209 &config_path,
1210 r#"{"$schema":"https://opencode.ai/config.json","plugin":["other"]}"#,
1211 )
1212 .unwrap();
1213
1214 let result = update_opencode_config(dir.path()).unwrap();
1215 assert!(result, "should update existing config");
1216 let content = std::fs::read_to_string(&config_path).unwrap();
1217 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1218 let plugins = parsed["plugin"].as_array().unwrap();
1223 assert!(
1224 !plugins.iter().any(|v| v.as_str() == Some("sqz")),
1225 "issue #10: sqz must NOT be registered as a config-level plugin \
1226 (the local plugin file at ~/.config/opencode/plugins/sqz.ts \
1227 already loads it; double-registering causes double hook firing)"
1228 );
1229 assert!(
1230 plugins.iter().any(|v| v.as_str() == Some("other")),
1231 "pre-existing plugin entries from OTHER plugins must be preserved"
1232 );
1233 assert_eq!(
1236 parsed["mcp"]["sqz"]["type"].as_str(),
1237 Some("local"),
1238 "mcp.sqz must be added"
1239 );
1240 }
1241
1242 #[test]
1247 fn test_update_opencode_config_removes_legacy_sqz_plugin_entry() {
1248 let dir = tempfile::tempdir().unwrap();
1249 let config_path = dir.path().join("opencode.json");
1250 std::fs::write(
1251 &config_path,
1252 r#"{"plugin":["other","sqz"]}"#,
1253 )
1254 .unwrap();
1255
1256 let changed = update_opencode_config(dir.path()).unwrap();
1257 assert!(changed, "must report that the legacy plugin entry was stripped");
1258
1259 let after = std::fs::read_to_string(&config_path).unwrap();
1260 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1261 let plugins = parsed["plugin"].as_array().unwrap();
1262 assert!(
1263 !plugins.iter().any(|v| v.as_str() == Some("sqz")),
1264 "legacy sqz plugin entry must be stripped on re-init"
1265 );
1266 assert!(
1267 plugins.iter().any(|v| v.as_str() == Some("other")),
1268 "other plugin entries must survive the cleanup"
1269 );
1270 }
1271
1272 #[test]
1276 fn test_update_opencode_config_drops_empty_plugin_array_after_cleanup() {
1277 let dir = tempfile::tempdir().unwrap();
1278 let config_path = dir.path().join("opencode.json");
1279 std::fs::write(&config_path, r#"{"plugin":["sqz"]}"#).unwrap();
1280
1281 update_opencode_config(dir.path()).unwrap();
1282
1283 let after = std::fs::read_to_string(&config_path).unwrap();
1284 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1285 assert!(
1286 parsed.get("plugin").is_none(),
1287 "empty plugin array should be dropped entirely, got: {after}"
1288 );
1289 }
1290
1291 #[test]
1292 fn test_update_opencode_config_skips_if_present() {
1293 let dir = tempfile::tempdir().unwrap();
1294 let config_path = dir.path().join("opencode.json");
1295 std::fs::write(
1296 &config_path,
1297 r#"{
1298 "mcp": {
1299 "sqz": {
1300 "type": "local",
1301 "command": ["sqz-mcp", "--transport", "stdio"],
1302 "enabled": true
1303 }
1304 }
1305}"#,
1306 )
1307 .unwrap();
1308
1309 let result = update_opencode_config(dir.path()).unwrap();
1310 assert!(
1311 !result,
1312 "a config with mcp.sqz including enabled:true must be idempotent"
1313 );
1314 }
1315
1316 #[test]
1321 fn test_update_opencode_config_adds_missing_mcp_entry() {
1322 let dir = tempfile::tempdir().unwrap();
1323 let config_path = dir.path().join("opencode.json");
1324 std::fs::write(&config_path, r#"{"plugin":["sqz"]}"#).unwrap();
1325
1326 let changed = update_opencode_config(dir.path()).unwrap();
1327 assert!(changed, "must report that mcp.sqz was added");
1328
1329 let after = std::fs::read_to_string(&config_path).unwrap();
1330 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1331 assert_eq!(
1332 parsed["mcp"]["sqz"]["type"].as_str(),
1333 Some("local"),
1334 "mcp.sqz must be populated with the default server entry"
1335 );
1336 }
1337
1338 #[test]
1349 fn test_process_opencode_hook_skips_already_wrapped_sqz_cmd_prefix() {
1350 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"}}"#;
1351 let result = process_opencode_hook(input).unwrap();
1352 assert_eq!(
1353 result, input,
1354 "already-wrapped command must pass through unchanged; \
1355 otherwise each pass accumulates another SQZ_CMD= prefix"
1356 );
1357 }
1358
1359 #[test]
1362 fn test_process_opencode_hook_guard_is_case_insensitive() {
1363 let input = r#"{"tool":"bash","args":{"command":"SQZ_CMD=git git status"}}"#;
1364 let result = process_opencode_hook(input).unwrap();
1365 assert_eq!(
1366 result, input,
1367 "uppercase SQZ_CMD= prefix must short-circuit the wrap"
1368 );
1369 }
1370
1371 #[test]
1377 fn test_process_opencode_hook_skips_leading_env_assignments_for_base() {
1378 let input = r#"{"tool":"bash","args":{"command":"FOO=bar BAZ=qux make test"}}"#;
1379 let result = process_opencode_hook(input).unwrap();
1380 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1381 let cmd = parsed["args"]["command"].as_str().unwrap();
1382 assert!(
1383 cmd.contains("--cmd make"),
1384 "base command must be `make`, not `FOO=bar`; got: {cmd}"
1385 );
1386 assert!(
1387 cmd.contains("FOO=bar BAZ=qux make test"),
1388 "original command must be preserved: {cmd}"
1389 );
1390 }
1391
1392 #[test]
1394 fn test_process_opencode_hook_skips_bare_sqz_invocation() {
1395 for cmd in ["sqz stats", "sqz gain", "/usr/local/bin/sqz compress"] {
1396 let input = format!(
1397 r#"{{"tool":"bash","args":{{"command":"{cmd}"}}}}"#
1398 );
1399 let result = process_opencode_hook(&input).unwrap();
1400 assert_eq!(
1401 result, input,
1402 "sqz-invoking command `{cmd}` must not be rewrapped"
1403 );
1404 }
1405 }
1406
1407 #[test]
1411 fn test_generate_opencode_plugin_has_double_wrap_guard() {
1412 let content = generate_opencode_plugin("sqz");
1413 assert!(
1414 content.contains("function isAlreadyWrapped(cmd: string): boolean"),
1415 "generated plugin must define isAlreadyWrapped helper"
1416 );
1417 assert!(
1418 content.contains(r#"lowered.includes("sqz_cmd=")"#),
1419 "plugin must check for the SQZ_CMD= prior-wrap prefix"
1420 );
1421 assert!(
1422 content.contains(r#"lowered.includes("sqz compress")"#),
1423 "plugin must check for the `sqz compress` prior-wrap tail"
1424 );
1425 assert!(
1426 content.contains("isAlreadyWrapped(cmd)"),
1427 "plugin hook body must call isAlreadyWrapped on the command"
1428 );
1429 assert!(
1430 content.contains("function extractBaseCmd(cmd: string): string"),
1431 "plugin must define extractBaseCmd that skips env assignments"
1432 );
1433 assert!(
1434 content.contains("extractBaseCmd(cmd)"),
1435 "plugin hook body must use extractBaseCmd, not raw split"
1436 );
1437 }
1438
1439 #[test]
1442 fn test_is_already_wrapped_detects_all_marker_shapes() {
1443 assert!(is_already_wrapped("SQZ_CMD=git git status"));
1444 assert!(is_already_wrapped("sqz_cmd=git git status"));
1445 assert!(is_already_wrapped("git status | sqz compress"));
1446 assert!(is_already_wrapped("git status 2>&1 | /path/sqz compress"));
1447 assert!(is_already_wrapped("ls -la | sqz compress-stream"));
1448 assert!(is_already_wrapped("sqz stats"));
1449 assert!(is_already_wrapped("/usr/local/bin/sqz gain"));
1450 assert!(is_already_wrapped("SQZ_FOO=bar cmd"));
1451 assert!(!is_already_wrapped("git status"));
1452 assert!(!is_already_wrapped("grep sqz logfile.txt"));
1453 assert!(!is_already_wrapped("cargo test --package my-sqz-crate"));
1454 }
1455
1456 #[test]
1457 fn test_extract_base_cmd_skips_env_assignments() {
1458 assert_eq!(extract_base_cmd("make test"), "make");
1459 assert_eq!(extract_base_cmd("FOO=bar make test"), "make");
1460 assert_eq!(extract_base_cmd("FOO=bar BAZ=qux make test"), "make");
1461 assert_eq!(extract_base_cmd("/usr/bin/git status"), "git");
1462 assert_eq!(extract_base_cmd(""), "unknown");
1463 assert_eq!(extract_base_cmd("FOO=bar"), "unknown");
1464 }
1465
1466 #[test]
1467 fn test_is_env_assignment() {
1468 assert!(is_env_assignment("FOO=bar"));
1469 assert!(is_env_assignment("FOO="));
1470 assert!(is_env_assignment("_underscore=1"));
1471 assert!(is_env_assignment("MixedCase_1=x"));
1472 assert!(!is_env_assignment("=bar"));
1473 assert!(!is_env_assignment("FOO"));
1474 assert!(!is_env_assignment("--flag=value"));
1475 assert!(!is_env_assignment("123=value"));
1476 assert!(!is_env_assignment("FOO BAR=baz"));
1477 }
1478
1479 #[test]
1488 fn test_update_merges_into_existing_jsonc() {
1489 let dir = tempfile::tempdir().unwrap();
1490 let jsonc = dir.path().join("opencode.jsonc");
1491 std::fs::write(
1492 &jsonc,
1493 r#"{
1494 // user's own config with a comment
1495 "$schema": "https://opencode.ai/config.json",
1496 "model": "anthropic/claude-sonnet-4-5",
1497 /* another comment */
1498 "plugin": ["other-plugin"]
1499}
1500"#,
1501 )
1502 .unwrap();
1503
1504 let changed = update_opencode_config(dir.path()).unwrap();
1505 assert!(changed, "must merge sqz entries into the existing .jsonc");
1506
1507 assert!(jsonc.exists(), "original .jsonc must still exist");
1509 assert!(
1510 !dir.path().join("opencode.json").exists(),
1511 "must not create a parallel opencode.json alongside .jsonc \
1512 (that's the issue #6 bug)"
1513 );
1514
1515 let after = std::fs::read_to_string(&jsonc).unwrap();
1516 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1517 let plugins = parsed["plugin"].as_array().unwrap();
1518 assert!(
1522 !plugins.iter().any(|v| v.as_str() == Some("sqz")),
1523 "issue #10: sqz must NOT be added to plugin[]"
1524 );
1525 assert!(
1526 plugins.iter().any(|v| v.as_str() == Some("other-plugin")),
1527 "pre-existing plugin entries must be preserved"
1528 );
1529 assert_eq!(
1530 parsed["model"].as_str(),
1531 Some("anthropic/claude-sonnet-4-5"),
1532 "unrelated user keys must survive the merge"
1533 );
1534 assert_eq!(
1535 parsed["mcp"]["sqz"]["type"].as_str(),
1536 Some("local"),
1537 "mcp.sqz must be registered"
1538 );
1539 }
1540
1541 #[test]
1545 fn test_update_opencode_config_detailed_reports_comments_lost() {
1546 let dir = tempfile::tempdir().unwrap();
1547 let jsonc = dir.path().join("opencode.jsonc");
1548 std::fs::write(
1549 &jsonc,
1550 r#"{
1551 // comment to be dropped
1552 "plugin": ["other"]
1553}
1554"#,
1555 )
1556 .unwrap();
1557
1558 let (changed, comments_lost) =
1559 update_opencode_config_detailed(dir.path()).unwrap();
1560 assert!(changed);
1561 assert!(
1562 comments_lost,
1563 "merger must report that comments were dropped from .jsonc"
1564 );
1565 }
1566
1567 #[test]
1571 fn plan_opencode_reports_no_change_when_already_configured() {
1572 let dir = tempfile::tempdir().unwrap();
1573 update_opencode_config(dir.path()).unwrap();
1575 let planned = plan_opencode_config_change(dir.path()).unwrap();
1577 assert!(
1578 !planned.will_change,
1579 "re-running against a fully configured file must be a no-op"
1580 );
1581 assert!(!planned.comments_lost);
1582 }
1583
1584 #[test]
1587 fn plan_opencode_reports_change_without_writing() {
1588 let dir = tempfile::tempdir().unwrap();
1589 let path = dir.path().join("opencode.json");
1590 std::fs::write(&path, r#"{"plugin":["other"]}"#).unwrap();
1591 let before = std::fs::read_to_string(&path).unwrap();
1592
1593 let planned = plan_opencode_config_change(dir.path()).unwrap();
1594 assert!(planned.will_change);
1595 assert_eq!(planned.target_path, path);
1596
1597 let after = std::fs::read_to_string(&path).unwrap();
1598 assert_eq!(before, after, "dry-run must not modify the file");
1599 }
1600
1601 #[test]
1604 fn plan_opencode_reports_fresh_create() {
1605 let dir = tempfile::tempdir().unwrap();
1606 let planned = plan_opencode_config_change(dir.path()).unwrap();
1607 assert!(planned.will_change);
1608 assert_eq!(planned.target_path, dir.path().join("opencode.json"));
1609 assert!(!dir.path().join("opencode.json").exists(),
1610 "dry-run must not create the file");
1611 }
1612
1613 #[test]
1617 fn test_update_creates_plain_json_when_nothing_exists() {
1618 let dir = tempfile::tempdir().unwrap();
1619 update_opencode_config(dir.path()).unwrap();
1620 assert!(dir.path().join("opencode.json").exists());
1621 assert!(!dir.path().join("opencode.jsonc").exists());
1622 }
1623
1624 #[test]
1626 fn config_rewrites_keep_the_final_newline_and_mode() {
1627 for ending in ["", "\n"] {
1628 let dir = tempfile::tempdir().unwrap();
1629 let path = dir.path().join("opencode.json");
1630 std::fs::write(&path, format!("{{\"plugin\":[\"other\"]}}{ending}")).unwrap();
1631 #[cfg(unix)]
1632 {
1633 use std::os::unix::fs::PermissionsExt;
1634 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap();
1635 }
1636
1637 assert!(update_opencode_config(dir.path()).unwrap());
1638 assert_eq!(std::fs::read_to_string(&path).unwrap().ends_with('\n'), !ending.is_empty());
1639 assert!(remove_sqz_from_opencode_config(dir.path()).unwrap().unwrap().1);
1640 assert_eq!(std::fs::read_to_string(&path).unwrap().ends_with('\n'), !ending.is_empty());
1641 #[cfg(unix)]
1642 {
1643 use std::os::unix::fs::PermissionsExt;
1644 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1645 assert_eq!(mode, 0o640);
1646 }
1647 }
1648 }
1649
1650 #[test]
1652 fn test_find_opencode_config_prefers_jsonc() {
1653 let dir = tempfile::tempdir().unwrap();
1654 std::fs::write(dir.path().join("opencode.json"), "{}").unwrap();
1655 std::fs::write(dir.path().join("opencode.jsonc"), "{}").unwrap();
1656 let found = find_opencode_config(dir.path()).unwrap();
1657 assert_eq!(
1658 found.file_name().unwrap(),
1659 "opencode.jsonc",
1660 "must prefer the .jsonc variant when both exist — the user \
1661 is maintaining .jsonc for its comment support"
1662 );
1663 }
1664
1665 #[test]
1666 fn test_find_opencode_config_returns_none_when_missing() {
1667 let dir = tempfile::tempdir().unwrap();
1668 assert!(find_opencode_config(dir.path()).is_none());
1669 }
1670
1671 #[test]
1672 fn test_opencode_config_has_comments_detects_jsonc_comments() {
1673 let dir = tempfile::tempdir().unwrap();
1674 std::fs::write(
1675 dir.path().join("opencode.jsonc"),
1676 "// a line comment\n{\"plugin\":[]}\n",
1677 )
1678 .unwrap();
1679 assert!(opencode_config_has_comments(dir.path()));
1680 }
1681
1682 #[test]
1683 fn test_opencode_config_has_comments_ignores_plain_json() {
1684 let dir = tempfile::tempdir().unwrap();
1685 std::fs::write(
1687 dir.path().join("opencode.json"),
1688 r#"{"url":"http://example.com"}"#,
1689 )
1690 .unwrap();
1691 assert!(!opencode_config_has_comments(dir.path()));
1692 }
1693
1694 #[test]
1697 fn test_strip_jsonc_comments_removes_line_comments() {
1698 let src = "{\n // leading comment\n \"a\": 1 // trailing\n}";
1699 let stripped = strip_jsonc_comments(src);
1700 assert!(!stripped.contains("leading comment"));
1701 assert!(!stripped.contains("trailing"));
1702 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1703 assert_eq!(parsed["a"], 1);
1704 }
1705
1706 #[test]
1707 fn test_strip_jsonc_comments_removes_block_comments() {
1708 let src = "{\n /* block\n comment */\n \"a\": 1\n}";
1709 let stripped = strip_jsonc_comments(src);
1710 assert!(!stripped.contains("block"));
1711 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1712 assert_eq!(parsed["a"], 1);
1713 }
1714
1715 #[test]
1716 fn test_strip_jsonc_comments_preserves_strings() {
1717 let src = r#"{"url": "http://example.com", "re": "/* not a comment */"}"#;
1722 let stripped = strip_jsonc_comments(src);
1723 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1724 assert_eq!(parsed["url"], "http://example.com");
1725 assert_eq!(parsed["re"], "/* not a comment */");
1726 }
1727
1728 #[test]
1729 fn test_strip_jsonc_comments_preserves_escaped_quote_in_string() {
1730 let src = r#"{"s": "a\"//b"}"#;
1731 let stripped = strip_jsonc_comments(src);
1732 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1733 assert_eq!(parsed["s"], r#"a"//b"#);
1734 }
1735
1736 #[test]
1737 fn test_strip_jsonc_comments_tolerates_unterminated_block() {
1738 let src = "{\"a\":1 /* never ends";
1740 let _ = strip_jsonc_comments(src); }
1742
1743 #[test]
1744 fn test_strip_jsonc_comments_removes_trailing_commas() {
1745 let src = r#"{
1748 "a": [1, 2, 3,],
1749 "b": {"x": 1, "y": 2,},
1750}"#;
1751 let stripped = strip_jsonc_comments(src);
1752 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1753 assert_eq!(parsed["a"], serde_json::json!([1, 2, 3]));
1754 assert_eq!(parsed["b"]["x"], 1);
1755 assert_eq!(parsed["b"]["y"], 2);
1756 }
1757
1758 #[test]
1759 fn test_strip_jsonc_comments_trailing_comma_in_string_preserved() {
1760 let src = r#"{"s": "a,}"}"#;
1762 let stripped = strip_jsonc_comments(src);
1763 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1764 assert_eq!(parsed["s"], "a,}");
1765 }
1766
1767 #[test]
1768 fn test_strip_jsonc_full_opencode_jsonc_with_comments_and_trailing_commas() {
1769 let src = r#"{
1772 // User's OpenCode config
1773 "$schema": "https://opencode.ai/config.json",
1774 "mcp": {
1775 // MCP servers
1776 "dart": {
1777 "type": "local",
1778 "command": ["dart", "mcp-server"],
1779 },
1780 },
1781}"#;
1782 let stripped = strip_jsonc_comments(src);
1783 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1784 assert_eq!(
1785 parsed["mcp"]["dart"]["type"], "local",
1786 "must parse after stripping comments + trailing commas"
1787 );
1788 }
1789
1790 #[test]
1798 fn test_remove_sqz_preserves_other_user_config() {
1799 let dir = tempfile::tempdir().unwrap();
1800 let config = dir.path().join("opencode.json");
1801 std::fs::write(
1802 &config,
1803 r#"{
1804 "$schema": "https://opencode.ai/config.json",
1805 "model": "anthropic/claude-sonnet-4-5",
1806 "plugin": ["other-plugin", "sqz"],
1807 "mcp": {
1808 "sqz": { "type": "local", "command": ["sqz-mcp"] },
1809 "jira": { "type": "remote", "url": "https://jira.example.com/mcp" }
1810 }
1811}
1812"#,
1813 )
1814 .unwrap();
1815
1816 let (path, changed) =
1817 remove_sqz_from_opencode_config(dir.path()).unwrap().unwrap();
1818 assert_eq!(path, config);
1819 assert!(changed, "must report that sqz entries were removed");
1820 assert!(
1821 config.exists(),
1822 "file must NOT be deleted — only sqz's entries removed"
1823 );
1824
1825 let after = std::fs::read_to_string(&config).unwrap();
1826 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1827 let plugins = parsed["plugin"].as_array().unwrap();
1828 assert!(!plugins.iter().any(|v| v.as_str() == Some("sqz")));
1829 assert!(plugins.iter().any(|v| v.as_str() == Some("other-plugin")));
1830 let mcp = parsed["mcp"].as_object().unwrap();
1831 assert!(!mcp.contains_key("sqz"), "mcp.sqz must be gone");
1832 assert!(mcp.contains_key("jira"), "mcp.jira must survive");
1833 assert_eq!(
1834 parsed["model"].as_str(),
1835 Some("anthropic/claude-sonnet-4-5"),
1836 "unrelated keys must survive"
1837 );
1838 }
1839
1840 #[test]
1844 fn test_remove_sqz_deletes_file_when_nothing_else_remains() {
1845 let dir = tempfile::tempdir().unwrap();
1846 let config = dir.path().join("opencode.json");
1847 std::fs::write(
1849 &config,
1850 r#"{
1851 "$schema": "https://opencode.ai/config.json",
1852 "mcp": {
1853 "sqz": { "type": "local", "command": ["sqz-mcp", "--transport", "stdio"] }
1854 },
1855 "plugin": ["sqz"]
1856}
1857"#,
1858 )
1859 .unwrap();
1860
1861 let (_, changed) =
1862 remove_sqz_from_opencode_config(dir.path()).unwrap().unwrap();
1863 assert!(changed);
1864 assert!(
1865 !config.exists(),
1866 "file with only $schema + sqz entries must be removed"
1867 );
1868 }
1869
1870 #[test]
1873 fn test_remove_sqz_returns_none_when_config_missing() {
1874 let dir = tempfile::tempdir().unwrap();
1875 let result = remove_sqz_from_opencode_config(dir.path()).unwrap();
1876 assert!(result.is_none());
1877 }
1878
1879 #[test]
1882 fn test_remove_sqz_from_jsonc_drops_comments() {
1883 let dir = tempfile::tempdir().unwrap();
1884 let jsonc = dir.path().join("opencode.jsonc");
1885 std::fs::write(
1886 &jsonc,
1887 r#"{
1888 // user's comment
1889 "model": "x",
1890 "plugin": ["sqz", "other"]
1891}
1892"#,
1893 )
1894 .unwrap();
1895
1896 let (path, changed) =
1897 remove_sqz_from_opencode_config(dir.path()).unwrap().unwrap();
1898 assert_eq!(path, jsonc);
1899 assert!(changed);
1900 assert!(path.exists(), "jsonc file kept because `model` and `other` remain");
1901
1902 let after = std::fs::read_to_string(&jsonc).unwrap();
1903 assert!(
1904 !after.contains("// user's comment"),
1905 "comments are dropped by the serde_json round-trip; \
1906 documented in update_opencode_config_detailed"
1907 );
1908 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1909 let plugins = parsed["plugin"].as_array().unwrap();
1910 assert_eq!(plugins.len(), 1);
1911 assert_eq!(plugins[0], "other");
1912 }
1913
1914 #[test]
1924 fn issue_10_opencode_rewrite_works_in_powershell_syntax() {
1925 let input = r#"{"tool":"bash","args":{"command":"dotnet build NewNeonCheckers3.sln"}}"#;
1926 let result = process_opencode_hook(input).unwrap();
1927 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1928 let cmd = parsed["args"]["command"].as_str().unwrap();
1929
1930 assert!(
1934 !cmd.contains("SQZ_CMD="),
1935 "issue #10: rewrite must not emit `SQZ_CMD=` (breaks on \
1936 PowerShell/cmd.exe); got: {cmd}"
1937 );
1938 assert!(
1940 cmd.contains("--cmd dotnet"),
1941 "rewrite must pass label via --cmd; got: {cmd}"
1942 );
1943 if cfg!(windows) {
1948 let first_token = cmd.split_whitespace().next().unwrap_or("");
1949 assert_eq!(
1950 first_token, "dotnet",
1951 "first token of the rewritten command must be the user's \
1952 command itself, not an env-var assignment; got: {cmd}"
1953 );
1954 }
1955 }
1956
1957 #[test]
1961 fn ts_plugin_carries_exit_status_on_posix() {
1962 let content = generate_opencode_plugin("sqz");
1963 assert!(content.contains("printf '\\\\n__SQZ_EXIT_%d__\\\\n' \"$?\""), "{content}");
1964 assert!(content.contains("process.platform !== \"win32\""));
1965 assert!(content.contains("output.args.command = wrap(cmd, shellEscapeLabel(base));"));
1966
1967 let Some(node) = which_node().filter(|_| cfg!(unix)) else { return };
1968 let start = content.find(" const POSIX").unwrap();
1969 let end = content[start..].find("\n }\n").unwrap() + start + 4;
1970 let js = content[start..end]
1972 .replace("(cmd: string, label: string): string", "(cmd, label)")
1973 .replace("SQZ_PATH", "\"sqz\"");
1974 let script = format!("{js}\nprocess.stdout.write(wrap(\"cargo test\", \"cargo\"));");
1975 let out = std::process::Command::new(node)
1976 .args(["-e", &script])
1977 .env("SHELL", "/bin/zsh")
1978 .output()
1979 .unwrap();
1980 assert_eq!(
1981 String::from_utf8_lossy(&out.stdout),
1982 crate::exit_marker::posix_rewrite("cargo test", "cargo"),
1983 "{}",
1984 String::from_utf8_lossy(&out.stderr)
1985 );
1986 }
1987
1988 fn which_node() -> Option<&'static str> {
1989 let out = std::process::Command::new("node").arg("--version").output().ok()?;
1990 out.status.success().then_some("node")
1991 }
1992
1993 #[test]
1997 fn issue_10_ts_plugin_emits_cmd_flag_not_env_prefix() {
1998 let content = generate_opencode_plugin("sqz");
1999 assert!(
2003 content.contains("compress --cmd"),
2004 "TS plugin must build rewrite with `compress --cmd ${{base}}`"
2005 );
2006 assert!(
2012 !content.contains("SQZ_CMD=${base}"),
2013 "TS plugin must not emit the legacy `SQZ_CMD=${{base}}` prefix"
2014 );
2015 }
2016
2017 #[test]
2032 fn issue_10_fresh_opencode_config_has_no_plugin_entry() {
2033 let dir = tempfile::tempdir().unwrap();
2034 update_opencode_config(dir.path()).unwrap();
2035 let content = std::fs::read_to_string(dir.path().join("opencode.json")).unwrap();
2036 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
2037
2038 assert!(
2040 parsed.get("plugin").is_none(),
2041 "issue #10: fresh opencode.json must not include `plugin` key; got: {content}"
2042 );
2043
2044 assert_eq!(
2047 parsed["mcp"]["sqz"]["type"].as_str(),
2048 Some("local"),
2049 "mcp.sqz is the one sqz-authored entry that belongs in \
2050 opencode.json; must still be registered"
2051 );
2052 }
2053
2054 #[test]
2059 fn issue_10_reinit_strips_legacy_plugin_entry() {
2060 let dir = tempfile::tempdir().unwrap();
2061 let config = dir.path().join("opencode.json");
2062 std::fs::write(
2063 &config,
2064 r#"{"$schema":"https://opencode.ai/config.json","mcp":{"sqz":{"type":"local","command":["sqz-mcp","--transport","stdio"]}},"plugin":["sqz"]}"#,
2066 )
2067 .unwrap();
2068
2069 let changed = update_opencode_config(dir.path()).unwrap();
2070 assert!(changed, "re-init must report a change (the legacy entry was stripped)");
2071
2072 let after = std::fs::read_to_string(&config).unwrap();
2073 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
2074 assert!(
2075 parsed.get("plugin").is_none(),
2076 "legacy `plugin: [\"sqz\"]` must be stripped on re-init; got: {after}"
2077 );
2078 assert_eq!(
2080 parsed["mcp"]["sqz"]["type"].as_str(),
2081 Some("local"),
2082 "mcp.sqz must survive cleanup of the plugin entry"
2083 );
2084 }
2085}