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 SQZ_PATH = "{sqz_path}";
70
71// Commands that should not be intercepted.
72const INTERACTIVE = new Set([
73 "vim", "vi", "nano", "emacs", "less", "more", "top", "htop",
74 "ssh", "python", "python3", "node", "irb", "ghci",
75 "psql", "mysql", "sqlite3", "mongo", "redis-cli",
76]);
77
78function isInteractive(cmd: string): boolean {{
79 const base = cmd.split(/\s+/)[0]?.split("/").pop() ?? "";
80 if (INTERACTIVE.has(base)) return true;
81 if (cmd.includes("--watch") || cmd.includes("run dev") ||
82 cmd.includes("run start") || cmd.includes("run serve")) return true;
83 return false;
84}}
85
86// Don't rewrite commands containing shell operators. Appending
87// `2>&1 | sqz compress` to a heredoc, compound command, existing
88// pipe, or redirect corrupts the command. Issue #22: heredoc
89// terminators like `EOF 2>&1 | sqz compress --cmd git` are not
90// valid delimiters.
91function hasShellOperators(cmd: string): boolean {{
92 if (cmd.includes("&&") || cmd.includes("||") || cmd.includes(";")) return true;
93 if (cmd.includes(">") || cmd.includes("<")) return true;
94 if (cmd.includes("|")) return true;
95 if (cmd.includes("<<")) return true;
96 if (cmd.includes("$(") || cmd.includes("`")) return true;
97 return false;
98}}
99
100function shouldIntercept(tool: string): boolean {{
101 return ["bash", "shell", "terminal", "run_shell_command"].includes(tool.toLowerCase());
102}}
103
104// Detect that a command has already been wrapped by sqz. Before this
105// guard was in place OpenCode could call the hook twice on the same
106// command (for retried tool calls, or when a previous rewrite was
107// echoed back to the agent and the agent re-submitted it) and each
108// pass would prepend another `SQZ_CMD=$base` prefix, producing monsters
109// like `SQZ_CMD=SQZ_CMD=ddev SQZ_CMD=ddev ddev exec ...` (reported as
110// a follow-up to issue #5). We skip if any of these markers appear:
111// * the case-insensitive substring "sqz_cmd=" or "sqz compress"
112// (covers the tail of prior wraps regardless of case; SQZ_CMD= is
113// legacy pre-issue-#10 but still valid in POSIX shell hooks)
114// * a leading `VAR=` assignment that starts with SQZ_
115// (defensive catch-all for exotic wrap variants)
116// * the base command itself is sqz or sqz-mcp (running sqz directly
117// — compressing sqz's own output is pointless and causes loops)
118function isAlreadyWrapped(cmd: string): boolean {{
119 const lowered = cmd.toLowerCase();
120 if (lowered.includes("sqz_cmd=")) return true;
121 if (lowered.includes("sqz compress")) return true;
122 if (lowered.includes("| sqz ") || lowered.includes("| sqz\t")) return true;
123 if (/^\s*SQZ_[A-Z0-9_]+=/.test(cmd)) return true;
124 const base = extractBaseCmd(cmd);
125 if (base === "sqz" || base === "sqz-mcp" || base === "sqz.exe") return true;
126 return false;
127}}
128
129// Extract the base command name defensively. If the command has
130// leading env-var assignments (VAR=val VAR2=val2 actual_cmd arg1),
131// skip past them so the base is `actual_cmd` — not `VAR=val`.
132function extractBaseCmd(cmd: string): string {{
133 const tokens = cmd.split(/\s+/).filter(t => t.length > 0);
134 for (const tok of tokens) {{
135 // A token is an env assignment if it matches NAME=VALUE where NAME
136 // is a valid env var identifier. Skip it and keep looking.
137 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tok)) continue;
138 return tok.split("/").pop() ?? "unknown";
139 }}
140 return "unknown";
141}}
142
143// Shell-escape a command-name label so it's safe to inline into the
144// rewritten shell command. Agents occasionally invoke commands via
145// paths with spaces (`"/my tools/foo" --arg`) and in the LLM
146// roundtrip that can survive to `extractBaseCmd`'s output. Quote the
147// label unless it's pure ASCII alphanumeric.
148function shellEscapeLabel(s: string): string {{
149 if (/^[A-Za-z0-9_.-]+$/.test(s)) return s;
150 return "'" + s.replace(/'/g, "'\\''") + "'";
151}}
152
153// A pipe exits with sqz's status, so the command's own status rides
154// through as a trailer sqz strips and exits with. That form is POSIX;
155// OpenCode uses a POSIX shell outside Windows (it refuses fish and nu).
156const POSIX = process.platform !== "win32" &&
157 !/(^|\/)(fish|nu|csh|tcsh|xonsh|elvish)$/.test(process.env.SHELL ?? "");
158
159function wrap(cmd: string, label: string): string {{
160 if (!POSIX) return `${{cmd}} 2>&1 | ${{SQZ_PATH}} compress --cmd ${{label}}`;
161 return `{{ ( ${{cmd}}\n); printf '\\n__SQZ_EXIT_%d__\\n' "$?"; }} 2>&1 | ${{SQZ_PATH}} compress --cmd ${{label}}`;
162}}
163
164function rewriteCommand(cmd: string): string | undefined {{
165 if (!cmd || isAlreadyWrapped(cmd) || isInteractive(cmd) || hasShellOperators(cmd)) return;
166
167 // Rewrite: pipe through `sqz compress --cmd <base>`.
168 //
169 // Issue #10: the previous form was `SQZ_CMD=<base> <cmd> 2>&1 |
170 // <sqz> compress`, which uses sh-specific inline env-var syntax.
171 // On Windows, OpenCode Desktop routes bash-tool commands through
172 // PowerShell (or cmd.exe when $SHELL is unset), and both parse
173 // `SQZ_CMD=cmd` as a command name — raising CommandNotFoundException
174 // and producing zero compression. `--cmd NAME` is a normal CLI
175 // argument, shell-neutral, works in POSIX sh, zsh, fish, PowerShell,
176 // and cmd.exe.
177 const base = extractBaseCmd(cmd);
178 return wrap(cmd, shellEscapeLabel(base));
179}}
180
181const SqzPluginFactory = async (ctx: any) => {{
182 return {{
183 "tool.execute.before": async (input: any, output: any) => {{
184 const tool = input.tool ?? "";
185 if (!shouldIntercept(tool)) return;
186
187 const cmd = output.args?.command ?? "";
188 const rewritten = rewriteCommand(cmd);
189 if (rewritten) output.args.command = rewritten;
190 }},
191 }};
192}};
193
194// V1 default export — modern OpenCode (post-V1 loader) reads `id` here
195// and displays "sqz" in the plugin list. Without this, OpenCode falls
196// back to the raw `file:///...` spec as the plugin name (@itguy327 on
197// issue #10). `readV1Plugin` in OpenCode's plugin/shared.ts requires
198// file-source plugins to declare an id — otherwise `resolvePluginId`
199// throws.
200export default {{
201 id: "sqz",
202 server: SqzPluginFactory,
203 async setup(ctx: any) {{
204 await ctx.tool.hook("execute.before", (event: any) => {{
205 const tool = event.tool ?? "";
206 if (!shouldIntercept(tool)) return;
207
208 const cmd = event.input?.command;
209 if (typeof cmd !== "string") return;
210 if (event.input.background === true) return;
211 const rewritten = rewriteCommand(cmd);
212 if (rewritten) event.input = {{ ...event.input, command: rewritten }};
213 }});
214 }},
215}};
216
217// Legacy named export — pre-V1 OpenCode versions walk Object.values(mod)
218// looking for factory functions. Assigning the same reference as the
219// default export's `.server` means the legacy `seen` Set dedups via
220// identity, so the factory fires exactly once either way. Kept for
221// backward compatibility with OpenCode versions that predate the V1
222// loader (roughly anything before mid-2025).
223export const SqzPlugin = SqzPluginFactory;
224"#
225 )
226}
227
228pub fn opencode_plugin_path() -> PathBuf {
230 let home = std::env::var("HOME")
231 .or_else(|_| std::env::var("USERPROFILE"))
232 .map(PathBuf::from)
233 .unwrap_or_else(|_| PathBuf::from("."));
234 home.join(".config")
235 .join("opencode")
236 .join("plugins")
237 .join("sqz.ts")
238}
239
240pub fn install_opencode_plugin(sqz_path: &str) -> Result<bool> {
252 install_opencode_plugin_at(&opencode_plugin_path(), sqz_path)
253}
254
255pub(crate) fn install_opencode_plugin_at(plugin_path: &Path, sqz_path: &str) -> Result<bool> {
256 let new_content = generate_opencode_plugin(sqz_path);
257
258 if plugin_path.exists() {
260 if let Ok(existing) = std::fs::read_to_string(&plugin_path) {
261 if existing == new_content {
262 return Ok(false);
263 }
264 }
265 }
266
267 if let Some(parent) = plugin_path.parent() {
268 std::fs::create_dir_all(parent).map_err(|e| {
269 crate::error::SqzError::Other(format!(
270 "failed to create OpenCode plugins dir {}: {e}",
271 parent.display()
272 ))
273 })?;
274 }
275
276 let content = generate_opencode_plugin(sqz_path);
277 std::fs::write(&plugin_path, &content).map_err(|e| {
278 crate::error::SqzError::Other(format!(
279 "failed to write OpenCode plugin to {}: {e}",
280 plugin_path.display()
281 ))
282 })?;
283
284 Ok(true)
285}
286
287pub fn opencode_plugin_stale(path: &Path) -> bool {
289 let Ok(text) = std::fs::read_to_string(path) else { return false };
290 crate::tool_hooks::sqz_hook_binaries(path)
291 .first()
292 .is_some_and(|bin| text.replace("\r\n", "\n") != generate_opencode_plugin(bin))
293}
294
295pub fn refresh_opencode_plugin(path: &Path, fallback_sqz: &str) -> Result<bool> {
297 if !opencode_plugin_stale(path) {
298 return Ok(false);
299 }
300 let sqz_path = crate::tool_hooks::sqz_hook_binaries(path)
301 .into_iter()
302 .find(|p| crate::tool_hooks::resolve_sqz_binary(p).is_some())
303 .unwrap_or_else(|| fallback_sqz.to_string());
304 if crate::tool_hooks::resolve_sqz_binary(&sqz_path).is_none() {
305 return Err(crate::error::SqzError::Other(format!(
306 "{sqz_path} does not exist, so the plugin was left as it is"
307 )));
308 }
309 install_opencode_plugin_at(path, &sqz_path)
310}
311
312pub fn find_opencode_config(project_dir: &Path) -> Option<PathBuf> {
319 let jsonc = project_dir.join("opencode.jsonc");
320 if jsonc.exists() {
321 return Some(jsonc);
322 }
323 let json = project_dir.join("opencode.json");
324 if json.exists() {
325 return Some(json);
326 }
327 None
328}
329
330pub fn opencode_config_has_comments(project_dir: &Path) -> bool {
335 let path = match find_opencode_config(project_dir) {
336 Some(p) => p,
337 None => return false,
338 };
339 if path.extension().map(|e| e != "jsonc").unwrap_or(true) {
340 return false;
341 }
342 let content = match std::fs::read_to_string(&path) {
343 Ok(s) => s,
344 Err(_) => return false,
345 };
346 strip_jsonc_comments(&content) != content
347}
348
349pub fn strip_jsonc_comments(src: &str) -> String {
359 let mut out = String::with_capacity(src.len());
360 let bytes = src.as_bytes();
361 let mut i = 0;
362 let len = bytes.len();
363
364 while i < len {
365 let b = bytes[i];
366
367 if b == b'"' {
370 out.push('"');
371 i += 1;
372 while i < len {
373 let c = bytes[i];
374 out.push(c as char);
375 if c == b'\\' && i + 1 < len {
376 out.push(bytes[i + 1] as char);
378 i += 2;
379 continue;
380 }
381 i += 1;
382 if c == b'"' {
383 break;
384 }
385 }
386 continue;
387 }
388
389 if b == b'/' && i + 1 < len && bytes[i + 1] == b'/' {
392 i += 2;
393 while i < len && bytes[i] != b'\n' {
394 i += 1;
395 }
396 continue;
397 }
398
399 if b == b'/' && i + 1 < len && bytes[i + 1] == b'*' {
401 i += 2;
402 while i + 1 < len && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
403 if bytes[i] == b'\n' {
405 out.push('\n');
406 }
407 i += 1;
408 }
409 if i + 1 < len {
412 i += 2;
413 }
414 continue;
415 }
416
417 out.push(b as char);
418 i += 1;
419 }
420
421 strip_trailing_commas(&out)
427}
428
429fn strip_trailing_commas(src: &str) -> String {
434 let mut out = String::with_capacity(src.len());
435 let bytes = src.as_bytes();
436 let mut i = 0;
437 let len = bytes.len();
438
439 while i < len {
440 let b = bytes[i];
441
442 if b == b'"' {
444 out.push('"');
445 i += 1;
446 while i < len {
447 let c = bytes[i];
448 out.push(c as char);
449 if c == b'\\' && i + 1 < len {
450 out.push(bytes[i + 1] as char);
451 i += 2;
452 continue;
453 }
454 i += 1;
455 if c == b'"' {
456 break;
457 }
458 }
459 continue;
460 }
461
462 if b == b',' {
466 let mut j = i + 1;
467 while j < len && bytes[j].is_ascii_whitespace() {
468 j += 1;
469 }
470 if j < len && (bytes[j] == b']' || bytes[j] == b'}') {
471 for k in (i + 1)..j {
474 out.push(bytes[k] as char);
475 }
476 i = j;
477 continue;
478 }
479 }
480
481 out.push(b as char);
482 i += 1;
483 }
484
485 out
486}
487
488pub fn update_opencode_config(project_dir: &Path) -> Result<bool> {
504 let (updated, _) = update_opencode_config_detailed(project_dir)?;
505 Ok(updated)
506}
507
508pub fn update_opencode_config_detailed(project_dir: &Path) -> Result<(bool, bool)> {
512 let planned = plan_opencode_config_change(project_dir)?;
513 if !planned.will_change {
514 return Ok((false, false));
515 }
516 apply_opencode_config_change(project_dir, &planned)
518}
519
520pub fn plan_opencode_config_change(project_dir: &Path) -> Result<PlannedOpencodeChange> {
533 compute_opencode_change(project_dir, false).map(|r| r.0)
534}
535
536#[derive(Debug, Clone, PartialEq, Eq)]
538pub struct PlannedOpencodeChange {
539 pub target_path: PathBuf,
541 pub will_change: bool,
544 pub comments_lost: bool,
547}
548
549fn apply_opencode_config_change(
550 project_dir: &Path,
551 _planned: &PlannedOpencodeChange,
552) -> Result<(bool, bool)> {
553 let (planned, _) = compute_opencode_change(project_dir, true)?;
554 Ok((planned.will_change, planned.comments_lost))
555}
556
557fn compute_opencode_change(
563 project_dir: &Path,
564 apply: bool,
565) -> Result<(PlannedOpencodeChange, ())> {
566 fn sqz_mcp_value() -> serde_json::Value {
567 serde_json::json!({
568 "type": "local",
569 "command": ["sqz-mcp", "--transport", "stdio"],
570 "enabled": true
571 })
572 }
573
574 if let Some(existing_path) = find_opencode_config(project_dir) {
575 let is_jsonc = existing_path
576 .extension()
577 .map(|e| e == "jsonc")
578 .unwrap_or(false);
579 let content = std::fs::read_to_string(&existing_path).map_err(|e| {
580 crate::error::SqzError::Other(format!(
581 "failed to read {}: {e}",
582 existing_path.display()
583 ))
584 })?;
585 let parseable = if is_jsonc {
586 strip_jsonc_comments(&content)
587 } else {
588 content.clone()
589 };
590 let had_comments = is_jsonc && parseable != content;
591
592 let mut config: serde_json::Value = serde_json::from_str(&parseable).map_err(|e| {
593 crate::error::SqzError::Other(format!(
594 "failed to parse {}: {e}",
595 existing_path.display()
596 ))
597 })?;
598 let obj = config.as_object_mut().ok_or_else(|| {
599 crate::error::SqzError::Other(format!(
600 "{} root is not a JSON object",
601 existing_path.display()
602 ))
603 })?;
604
605 let mut changed = false;
606
607 if let Some(arr) = obj.get_mut("plugin").and_then(|v| v.as_array_mut()) {
608 let before = arr.len();
609 arr.retain(|v| v.as_str() != Some("sqz"));
610 if arr.len() != before {
611 changed = true;
612 }
613 if arr.is_empty() {
614 obj.remove("plugin");
615 changed = true;
616 }
617 }
618
619 let mcp_entry = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
620 if let Some(mcp_obj) = mcp_entry.as_object_mut() {
621 if !mcp_obj.contains_key("sqz") {
622 mcp_obj.insert("sqz".to_string(), sqz_mcp_value());
623 changed = true;
624 } else if let Some(sqz_entry) = mcp_obj.get_mut("sqz").and_then(|v| v.as_object_mut()) {
625 if !sqz_entry.contains_key("enabled") {
626 sqz_entry.insert("enabled".to_string(), serde_json::json!(true));
627 changed = true;
628 }
629 }
630 } else {
631 return Err(crate::error::SqzError::Other(format!(
632 "{} has an `mcp` field that is not an object; \
633 refusing to modify it automatically",
634 existing_path.display()
635 )));
636 }
637
638 let planned = PlannedOpencodeChange {
639 target_path: existing_path.clone(),
640 will_change: changed,
641 comments_lost: changed && had_comments,
642 };
643
644 if apply && changed {
645 let mut updated = serde_json::to_string_pretty(&config).map_err(|e| {
646 crate::error::SqzError::Other(format!("failed to serialize config: {e}"))
647 })?;
648 if content.ends_with('\n') {
649 updated.push('\n');
650 }
651 std::fs::write(&existing_path, updated).map_err(|e| {
652 crate::error::SqzError::Other(format!(
653 "failed to write {}: {e}",
654 existing_path.display()
655 ))
656 })?;
657 }
658
659 Ok((planned, ()))
660 } else {
661 let target = project_dir.join("opencode.json");
663 let planned = PlannedOpencodeChange {
664 target_path: target.clone(),
665 will_change: true,
666 comments_lost: false,
667 };
668
669 if apply {
670 let config = serde_json::json!({
671 "$schema": "https://opencode.ai/config.json",
672 "mcp": {
673 "sqz": sqz_mcp_value()
674 }
675 });
676 let content = serde_json::to_string_pretty(&config).map_err(|e| {
677 crate::error::SqzError::Other(format!("failed to serialize opencode.json: {e}"))
678 })?;
679 std::fs::write(&target, format!("{content}\n")).map_err(|e| {
680 crate::error::SqzError::Other(format!("failed to write opencode.json: {e}"))
681 })?;
682 }
683
684 Ok((planned, ()))
685 }
686}
687
688pub fn remove_sqz_from_opencode_config(project_dir: &Path) -> Result<Option<(PathBuf, bool)>> {
701 let path = match find_opencode_config(project_dir) {
702 Some(p) => p,
703 None => return Ok(None),
704 };
705 let is_jsonc = path.extension().map(|e| e == "jsonc").unwrap_or(false);
706 let raw = std::fs::read_to_string(&path).map_err(|e| {
707 crate::error::SqzError::Other(format!("failed to read {}: {e}", path.display()))
708 })?;
709 let parseable = if is_jsonc {
710 strip_jsonc_comments(&raw)
711 } else {
712 raw.clone()
713 };
714 let mut config: serde_json::Value = match serde_json::from_str(&parseable) {
715 Ok(v) => v,
716 Err(_) => {
717 return Ok(Some((path, false)));
719 }
720 };
721
722 let mut changed = false;
723
724 if let Some(obj) = config.as_object_mut() {
725 if let Some(plugin) = obj.get_mut("plugin").and_then(|v| v.as_array_mut()) {
727 let before = plugin.len();
728 plugin.retain(|v| v.as_str() != Some("sqz"));
729 if plugin.len() != before {
730 changed = true;
731 }
732 if plugin.is_empty() {
734 obj.remove("plugin");
735 }
736 }
737
738 if let Some(mcp) = obj.get_mut("mcp").and_then(|v| v.as_object_mut()) {
740 if mcp.remove("sqz").is_some() {
741 changed = true;
742 }
743 if mcp.is_empty() {
744 obj.remove("mcp");
745 }
746 }
747 }
748
749 if !changed {
750 return Ok(Some((path, false)));
751 }
752
753 let essentially_empty = match config.as_object() {
758 Some(obj) => {
759 obj.is_empty()
760 || (obj.len() == 1
761 && obj.get("$schema").and_then(|v| v.as_str())
762 == Some("https://opencode.ai/config.json"))
763 }
764 None => false,
765 };
766
767 if essentially_empty {
768 std::fs::remove_file(&path).map_err(|e| {
769 crate::error::SqzError::Other(format!(
770 "failed to remove {}: {e}",
771 path.display()
772 ))
773 })?;
774 return Ok(Some((path, true)));
775 }
776
777 let mut updated = serde_json::to_string_pretty(&config).map_err(|e| {
780 crate::error::SqzError::Other(format!("failed to serialize config: {e}"))
781 })?;
782 if raw.ends_with('\n') {
783 updated.push('\n');
784 }
785 std::fs::write(&path, updated).map_err(|e| {
786 crate::error::SqzError::Other(format!(
787 "failed to write {}: {e}",
788 path.display()
789 ))
790 })?;
791 Ok(Some((path, true)))
792}
793
794fn is_already_wrapped(command: &str) -> bool {
806 let lowered = command.to_ascii_lowercase();
807 if lowered.contains("sqz_cmd=") {
808 return true;
809 }
810 if lowered.contains("sqz compress") {
811 return true;
812 }
813 if lowered.contains("| sqz ") || lowered.contains("| sqz\t") {
814 return true;
815 }
816 let trimmed = command.trim_start();
818 if let Some(eq_idx) = trimmed.find('=') {
819 let name = &trimmed[..eq_idx];
820 if name.starts_with("SQZ_")
821 && !name.is_empty()
822 && name
823 .chars()
824 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
825 {
826 return true;
827 }
828 }
829 let base = extract_base_cmd(command);
831 if base == "sqz" || base == "sqz-mcp" || base == "sqz.exe" {
832 return true;
833 }
834 false
835}
836
837fn extract_base_cmd(command: &str) -> &str {
844 for tok in command.split_whitespace() {
845 if is_env_assignment(tok) {
846 continue;
847 }
848 return tok.rsplit('/').next().unwrap_or("unknown");
849 }
850 "unknown"
851}
852
853fn is_env_assignment(token: &str) -> bool {
857 let eq = match token.find('=') {
858 Some(i) => i,
859 None => return false,
860 };
861 if eq == 0 {
862 return false;
863 }
864 let name = &token[..eq];
865 let mut chars = name.chars();
866 match chars.next() {
867 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
868 _ => return false,
869 }
870 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
871}
872
873pub fn process_opencode_hook(input: &str) -> Result<String> {
883 let parsed: serde_json::Value = serde_json::from_str(input)
884 .map_err(|e| crate::error::SqzError::Other(format!("opencode hook: invalid JSON: {e}")))?;
885
886 let tool = parsed
887 .get("tool")
888 .or_else(|| parsed.get("toolName"))
889 .or_else(|| parsed.get("tool_name"))
890 .and_then(|v| v.as_str())
891 .unwrap_or("");
892
893 if !matches!(
895 tool.to_lowercase().as_str(),
896 "bash" | "shell" | "terminal" | "run_shell_command"
897 ) {
898 return Ok(input.to_string());
899 }
900
901 let command = parsed
903 .get("args")
904 .or_else(|| parsed.get("toolCall"))
905 .or_else(|| parsed.get("tool_input"))
906 .and_then(|v| v.get("command"))
907 .and_then(|v| v.as_str())
908 .unwrap_or("");
909
910 if command.is_empty() || is_already_wrapped(command) {
911 return Ok(input.to_string());
912 }
913
914 if crate::tool_hooks::has_shell_operators(command) {
918 return Ok(input.to_string());
919 }
920
921 let base = extract_base_cmd(command);
925
926 if matches!(
927 base,
928 "vim" | "vi" | "nano" | "emacs" | "less" | "more" | "top" | "htop"
929 | "ssh" | "python" | "python3" | "node" | "irb" | "ghci"
930 | "psql" | "mysql" | "sqlite3" | "mongo" | "redis-cli"
931 ) || command.contains("--watch")
932 || command.contains("run dev")
933 || command.contains("run start")
934 || command.contains("run serve")
935 {
936 return Ok(input.to_string());
937 }
938
939 let base_cmd = base;
941
942 let escaped_base = if base_cmd
943 .chars()
944 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
945 {
946 base_cmd.to_string()
947 } else {
948 format!("'{}'", base_cmd.replace('\'', "'\\''"))
949 };
950
951 let rewritten = if cfg!(windows) {
956 format!("{} 2>&1 | sqz compress --cmd {}", command, escaped_base)
957 } else {
958 crate::exit_marker::posix_rewrite(command, &escaped_base)
959 };
960
961 let output = serde_json::json!({
963 "updatedInput": {
964 "command": rewritten
965 },
966 "args": {
967 "command": rewritten
968 }
969 });
970
971 serde_json::to_string(&output)
972 .map_err(|e| crate::error::SqzError::Other(format!("opencode hook: serialize error: {e}")))
973}
974
975#[cfg(test)]
978mod tests {
979 use super::*;
980
981 #[test]
982 fn test_generate_opencode_plugin_contains_sqz_path() {
983 let content = generate_opencode_plugin("/usr/local/bin/sqz");
984 assert!(content.contains("/usr/local/bin/sqz"));
985 assert!(content.contains("SqzPlugin"));
986 assert!(content.contains("tool.execute.before"));
987 }
988
989 #[test]
990 fn test_generate_opencode_plugin_windows_path_escaped() {
991 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
996 let content = generate_opencode_plugin(windows_path);
997 assert!(
1001 content.contains(r#"const SQZ_PATH = "C:\\Users\\SqzUser\\.cargo\\bin\\sqz.exe""#),
1002 "expected JS-escaped path in plugin — got:\n{content}"
1003 );
1004 assert!(
1007 !content.contains(r#"const SQZ_PATH = "C:\U"#),
1008 "plugin must not contain unescaped backslashes in the string literal"
1009 );
1010 }
1011
1012 #[test]
1013 fn test_generate_opencode_plugin_has_interactive_check() {
1014 let content = generate_opencode_plugin("sqz");
1015 assert!(content.contains("isInteractive"));
1016 assert!(content.contains("vim"));
1017 assert!(content.contains("--watch"));
1018 }
1019
1020 #[test]
1023 fn test_generate_opencode_plugin_has_shell_operator_guard() {
1024 let content = generate_opencode_plugin("sqz");
1025 assert!(
1026 content.contains("function hasShellOperators(cmd: string): boolean"),
1027 "plugin must define hasShellOperators guard (issue #22)"
1028 );
1029 assert!(
1030 content.contains("hasShellOperators(cmd)"),
1031 "plugin hook body must call hasShellOperators (issue #22)"
1032 );
1033 assert!(
1034 content.contains("<<"),
1035 "hasShellOperators must check for heredoc operator"
1036 );
1037 }
1038
1039 #[test]
1055 fn test_generate_opencode_plugin_declares_v1_id() {
1056 let content = generate_opencode_plugin("sqz");
1057 assert!(
1058 content.contains("id: \"sqz\""),
1059 "plugin must default-export `id: \"sqz\"` so OpenCode's \
1060 V1 loader (shared.ts readV1Plugin/resolvePluginId) \
1061 displays \"sqz\" in the UI instead of the file path; \
1062 got:\n{content}"
1063 );
1064 assert!(
1065 content.contains("server: SqzPluginFactory"),
1066 "plugin must default-export `server: <factory>` for V1 \
1067 loader compliance; got:\n{content}"
1068 );
1069 assert!(
1070 content.contains("export default {"),
1071 "plugin must have a default export per OpenCode V1 shape; \
1072 got:\n{content}"
1073 );
1074 }
1075
1076 #[test]
1087 fn test_generate_opencode_plugin_legacy_named_export_preserved() {
1088 let content = generate_opencode_plugin("sqz");
1089 assert!(
1090 content.contains("export const SqzPlugin = SqzPluginFactory"),
1091 "legacy named export must alias the same factory reference \
1092 as the V1 default export — otherwise old OpenCode versions \
1093 would see two distinct factories in `Object.values(mod)` \
1094 and fire the hook twice; got:\n{content}"
1095 );
1096 }
1097
1098 #[test]
1105 fn test_process_opencode_hook_rewrites_bash() {
1106 let input = r#"{"tool":"bash","args":{"command":"git status"}}"#;
1107 let result = process_opencode_hook(input).unwrap();
1108 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1109 assert!(parsed.get("decision").is_none() && parsed.get("reason").is_none(), "{parsed}");
1111 let cmd = parsed["args"]["command"].as_str().unwrap();
1112 assert_eq!(parsed["updatedInput"]["command"].as_str(), Some(cmd));
1113 if cfg!(unix) {
1114 assert_eq!(cmd, crate::exit_marker::posix_rewrite("git status", "git"));
1115 }
1116 assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
1117 assert!(cmd.contains("git status"), "should preserve original: {cmd}");
1118 assert!(cmd.contains("--cmd git"), "should pass base command via --cmd: {cmd}");
1122 assert!(
1123 !cmd.contains("SQZ_CMD="),
1124 "must not emit legacy sh-style env prefix: {cmd}"
1125 );
1126 }
1127
1128 #[test]
1129 fn test_process_opencode_hook_passes_non_shell() {
1130 let input = r#"{"tool":"read_file","args":{"path":"file.txt"}}"#;
1131 let result = process_opencode_hook(input).unwrap();
1132 assert_eq!(result, input, "non-shell tools should pass through");
1133 }
1134
1135 #[test]
1136 fn test_process_opencode_hook_skips_sqz_commands() {
1137 let input = r#"{"tool":"bash","args":{"command":"sqz stats"}}"#;
1138 let result = process_opencode_hook(input).unwrap();
1139 assert_eq!(result, input, "sqz commands should not be double-wrapped");
1140 }
1141
1142 #[test]
1143 fn test_process_opencode_hook_skips_interactive() {
1144 let input = r#"{"tool":"bash","args":{"command":"vim file.txt"}}"#;
1145 let result = process_opencode_hook(input).unwrap();
1146 assert_eq!(result, input, "interactive commands should pass through");
1147 }
1148
1149 #[test]
1150 fn test_process_opencode_hook_skips_watch() {
1151 let input = r#"{"tool":"bash","args":{"command":"npm run dev --watch"}}"#;
1152 let result = process_opencode_hook(input).unwrap();
1153 assert_eq!(result, input, "watch mode should pass through");
1154 }
1155
1156 #[test]
1160 fn test_process_opencode_hook_skips_shell_operators() {
1161 let heredoc = r#"{"tool":"bash","args":{"command":"git commit -F- <<'EOF'\nfeat: subject\n\nbody\nEOF"}}"#;
1163 let result = process_opencode_hook(heredoc).unwrap();
1164 assert_eq!(result, heredoc, "heredoc must not be rewritten (issue #22)");
1165
1166 let compound = r#"{"tool":"bash","args":{"command":"cargo build && cargo test"}}"#;
1168 let result = process_opencode_hook(compound).unwrap();
1169 assert_eq!(result, compound, "compound commands must pass through");
1170
1171 let pipe = r#"{"tool":"bash","args":{"command":"cat file.txt | grep foo"}}"#;
1173 let result = process_opencode_hook(pipe).unwrap();
1174 assert_eq!(result, pipe, "existing pipes must pass through");
1175
1176 let redirect = r#"{"tool":"bash","args":{"command":"echo hello > output.txt"}}"#;
1178 let result = process_opencode_hook(redirect).unwrap();
1179 assert_eq!(result, redirect, "redirects must pass through");
1180 }
1181
1182 #[test]
1183 fn test_process_opencode_hook_invalid_json() {
1184 let result = process_opencode_hook("not json");
1185 assert!(result.is_err());
1186 }
1187
1188 #[test]
1189 fn test_process_opencode_hook_empty_command() {
1190 let input = r#"{"tool":"bash","args":{"command":""}}"#;
1191 let result = process_opencode_hook(input).unwrap();
1192 assert_eq!(result, input);
1193 }
1194
1195 #[test]
1196 fn test_process_opencode_hook_run_shell_command() {
1197 let input = r#"{"tool":"run_shell_command","args":{"command":"ls -la"}}"#;
1198 let result = process_opencode_hook(input).unwrap();
1199 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1200 let cmd = parsed["args"]["command"].as_str().unwrap();
1201 assert!(cmd.contains("sqz compress"));
1202 }
1203
1204 #[test]
1205 fn test_install_opencode_plugin_creates_file() {
1206 let dir = tempfile::tempdir().unwrap();
1207 let plugin_path = dir
1208 .path()
1209 .join(".config/opencode/plugins/sqz.ts");
1210 let result = install_opencode_plugin_at(&plugin_path, "sqz");
1211 assert!(result.is_ok());
1212 assert!(plugin_path.exists(), "plugin file should exist");
1213 let content = std::fs::read_to_string(&plugin_path).unwrap();
1214 assert!(content.contains("SqzPlugin"));
1215 }
1216
1217 #[test]
1218 fn test_update_opencode_config_creates_new() {
1219 let dir = tempfile::tempdir().unwrap();
1220 let result = update_opencode_config(dir.path()).unwrap();
1221 assert!(result, "should create new config");
1222 let config_path = dir.path().join("opencode.json");
1223 assert!(config_path.exists());
1224 let content = std::fs::read_to_string(&config_path).unwrap();
1225 assert!(content.contains("\"sqz\""));
1226 assert!(content.contains("sqz-mcp"));
1227
1228 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1235 assert!(
1236 parsed.get("plugin").is_none(),
1237 "fresh-install opencode.json must not include `plugin`; got: {content}"
1238 );
1239 assert_eq!(
1240 parsed["mcp"]["sqz"]["type"].as_str(),
1241 Some("local"),
1242 "mcp.sqz must be present"
1243 );
1244 }
1245
1246 #[test]
1247 fn test_update_opencode_config_adds_to_existing() {
1248 let dir = tempfile::tempdir().unwrap();
1249 let config_path = dir.path().join("opencode.json");
1250 std::fs::write(
1251 &config_path,
1252 r#"{"$schema":"https://opencode.ai/config.json","plugin":["other"]}"#,
1253 )
1254 .unwrap();
1255
1256 let result = update_opencode_config(dir.path()).unwrap();
1257 assert!(result, "should update existing config");
1258 let content = std::fs::read_to_string(&config_path).unwrap();
1259 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1260 let plugins = parsed["plugin"].as_array().unwrap();
1265 assert!(
1266 !plugins.iter().any(|v| v.as_str() == Some("sqz")),
1267 "issue #10: sqz must NOT be registered as a config-level plugin \
1268 (the local plugin file at ~/.config/opencode/plugins/sqz.ts \
1269 already loads it; double-registering causes double hook firing)"
1270 );
1271 assert!(
1272 plugins.iter().any(|v| v.as_str() == Some("other")),
1273 "pre-existing plugin entries from OTHER plugins must be preserved"
1274 );
1275 assert_eq!(
1278 parsed["mcp"]["sqz"]["type"].as_str(),
1279 Some("local"),
1280 "mcp.sqz must be added"
1281 );
1282 }
1283
1284 #[test]
1289 fn test_update_opencode_config_removes_legacy_sqz_plugin_entry() {
1290 let dir = tempfile::tempdir().unwrap();
1291 let config_path = dir.path().join("opencode.json");
1292 std::fs::write(
1293 &config_path,
1294 r#"{"plugin":["other","sqz"]}"#,
1295 )
1296 .unwrap();
1297
1298 let changed = update_opencode_config(dir.path()).unwrap();
1299 assert!(changed, "must report that the legacy plugin entry was stripped");
1300
1301 let after = std::fs::read_to_string(&config_path).unwrap();
1302 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1303 let plugins = parsed["plugin"].as_array().unwrap();
1304 assert!(
1305 !plugins.iter().any(|v| v.as_str() == Some("sqz")),
1306 "legacy sqz plugin entry must be stripped on re-init"
1307 );
1308 assert!(
1309 plugins.iter().any(|v| v.as_str() == Some("other")),
1310 "other plugin entries must survive the cleanup"
1311 );
1312 }
1313
1314 #[test]
1318 fn test_update_opencode_config_drops_empty_plugin_array_after_cleanup() {
1319 let dir = tempfile::tempdir().unwrap();
1320 let config_path = dir.path().join("opencode.json");
1321 std::fs::write(&config_path, r#"{"plugin":["sqz"]}"#).unwrap();
1322
1323 update_opencode_config(dir.path()).unwrap();
1324
1325 let after = std::fs::read_to_string(&config_path).unwrap();
1326 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1327 assert!(
1328 parsed.get("plugin").is_none(),
1329 "empty plugin array should be dropped entirely, got: {after}"
1330 );
1331 }
1332
1333 #[test]
1334 fn test_update_opencode_config_skips_if_present() {
1335 let dir = tempfile::tempdir().unwrap();
1336 let config_path = dir.path().join("opencode.json");
1337 std::fs::write(
1338 &config_path,
1339 r#"{
1340 "mcp": {
1341 "sqz": {
1342 "type": "local",
1343 "command": ["sqz-mcp", "--transport", "stdio"],
1344 "enabled": true
1345 }
1346 }
1347}"#,
1348 )
1349 .unwrap();
1350
1351 let result = update_opencode_config(dir.path()).unwrap();
1352 assert!(
1353 !result,
1354 "a config with mcp.sqz including enabled:true must be idempotent"
1355 );
1356 }
1357
1358 #[test]
1363 fn test_update_opencode_config_adds_missing_mcp_entry() {
1364 let dir = tempfile::tempdir().unwrap();
1365 let config_path = dir.path().join("opencode.json");
1366 std::fs::write(&config_path, r#"{"plugin":["sqz"]}"#).unwrap();
1367
1368 let changed = update_opencode_config(dir.path()).unwrap();
1369 assert!(changed, "must report that mcp.sqz was added");
1370
1371 let after = std::fs::read_to_string(&config_path).unwrap();
1372 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1373 assert_eq!(
1374 parsed["mcp"]["sqz"]["type"].as_str(),
1375 Some("local"),
1376 "mcp.sqz must be populated with the default server entry"
1377 );
1378 }
1379
1380 #[test]
1391 fn test_process_opencode_hook_skips_already_wrapped_sqz_cmd_prefix() {
1392 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"}}"#;
1393 let result = process_opencode_hook(input).unwrap();
1394 assert_eq!(
1395 result, input,
1396 "already-wrapped command must pass through unchanged; \
1397 otherwise each pass accumulates another SQZ_CMD= prefix"
1398 );
1399 }
1400
1401 #[test]
1404 fn test_process_opencode_hook_guard_is_case_insensitive() {
1405 let input = r#"{"tool":"bash","args":{"command":"SQZ_CMD=git git status"}}"#;
1406 let result = process_opencode_hook(input).unwrap();
1407 assert_eq!(
1408 result, input,
1409 "uppercase SQZ_CMD= prefix must short-circuit the wrap"
1410 );
1411 }
1412
1413 #[test]
1419 fn test_process_opencode_hook_skips_leading_env_assignments_for_base() {
1420 let input = r#"{"tool":"bash","args":{"command":"FOO=bar BAZ=qux make test"}}"#;
1421 let result = process_opencode_hook(input).unwrap();
1422 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1423 let cmd = parsed["args"]["command"].as_str().unwrap();
1424 assert!(
1425 cmd.contains("--cmd make"),
1426 "base command must be `make`, not `FOO=bar`; got: {cmd}"
1427 );
1428 assert!(
1429 cmd.contains("FOO=bar BAZ=qux make test"),
1430 "original command must be preserved: {cmd}"
1431 );
1432 }
1433
1434 #[test]
1436 fn test_process_opencode_hook_skips_bare_sqz_invocation() {
1437 for cmd in ["sqz stats", "sqz gain", "/usr/local/bin/sqz compress"] {
1438 let input = format!(
1439 r#"{{"tool":"bash","args":{{"command":"{cmd}"}}}}"#
1440 );
1441 let result = process_opencode_hook(&input).unwrap();
1442 assert_eq!(
1443 result, input,
1444 "sqz-invoking command `{cmd}` must not be rewrapped"
1445 );
1446 }
1447 }
1448
1449 #[test]
1453 fn test_generate_opencode_plugin_has_double_wrap_guard() {
1454 let content = generate_opencode_plugin("sqz");
1455 assert!(
1456 content.contains("function isAlreadyWrapped(cmd: string): boolean"),
1457 "generated plugin must define isAlreadyWrapped helper"
1458 );
1459 assert!(
1460 content.contains(r#"lowered.includes("sqz_cmd=")"#),
1461 "plugin must check for the SQZ_CMD= prior-wrap prefix"
1462 );
1463 assert!(
1464 content.contains(r#"lowered.includes("sqz compress")"#),
1465 "plugin must check for the `sqz compress` prior-wrap tail"
1466 );
1467 assert!(
1468 content.contains("isAlreadyWrapped(cmd)"),
1469 "plugin hook body must call isAlreadyWrapped on the command"
1470 );
1471 assert!(
1472 content.contains("function extractBaseCmd(cmd: string): string"),
1473 "plugin must define extractBaseCmd that skips env assignments"
1474 );
1475 assert!(
1476 content.contains("extractBaseCmd(cmd)"),
1477 "plugin hook body must use extractBaseCmd, not raw split"
1478 );
1479 }
1480
1481 #[test]
1484 fn test_is_already_wrapped_detects_all_marker_shapes() {
1485 assert!(is_already_wrapped("SQZ_CMD=git git status"));
1486 assert!(is_already_wrapped("sqz_cmd=git git status"));
1487 assert!(is_already_wrapped("git status | sqz compress"));
1488 assert!(is_already_wrapped("git status 2>&1 | /path/sqz compress"));
1489 assert!(is_already_wrapped("ls -la | sqz compress-stream"));
1490 assert!(is_already_wrapped("sqz stats"));
1491 assert!(is_already_wrapped("/usr/local/bin/sqz gain"));
1492 assert!(is_already_wrapped("SQZ_FOO=bar cmd"));
1493 assert!(!is_already_wrapped("git status"));
1494 assert!(!is_already_wrapped("grep sqz logfile.txt"));
1495 assert!(!is_already_wrapped("cargo test --package my-sqz-crate"));
1496 }
1497
1498 #[test]
1499 fn test_extract_base_cmd_skips_env_assignments() {
1500 assert_eq!(extract_base_cmd("make test"), "make");
1501 assert_eq!(extract_base_cmd("FOO=bar make test"), "make");
1502 assert_eq!(extract_base_cmd("FOO=bar BAZ=qux make test"), "make");
1503 assert_eq!(extract_base_cmd("/usr/bin/git status"), "git");
1504 assert_eq!(extract_base_cmd(""), "unknown");
1505 assert_eq!(extract_base_cmd("FOO=bar"), "unknown");
1506 }
1507
1508 #[test]
1509 fn test_is_env_assignment() {
1510 assert!(is_env_assignment("FOO=bar"));
1511 assert!(is_env_assignment("FOO="));
1512 assert!(is_env_assignment("_underscore=1"));
1513 assert!(is_env_assignment("MixedCase_1=x"));
1514 assert!(!is_env_assignment("=bar"));
1515 assert!(!is_env_assignment("FOO"));
1516 assert!(!is_env_assignment("--flag=value"));
1517 assert!(!is_env_assignment("123=value"));
1518 assert!(!is_env_assignment("FOO BAR=baz"));
1519 }
1520
1521 #[test]
1530 fn test_update_merges_into_existing_jsonc() {
1531 let dir = tempfile::tempdir().unwrap();
1532 let jsonc = dir.path().join("opencode.jsonc");
1533 std::fs::write(
1534 &jsonc,
1535 r#"{
1536 // user's own config with a comment
1537 "$schema": "https://opencode.ai/config.json",
1538 "model": "anthropic/claude-sonnet-4-5",
1539 /* another comment */
1540 "plugin": ["other-plugin"]
1541}
1542"#,
1543 )
1544 .unwrap();
1545
1546 let changed = update_opencode_config(dir.path()).unwrap();
1547 assert!(changed, "must merge sqz entries into the existing .jsonc");
1548
1549 assert!(jsonc.exists(), "original .jsonc must still exist");
1551 assert!(
1552 !dir.path().join("opencode.json").exists(),
1553 "must not create a parallel opencode.json alongside .jsonc \
1554 (that's the issue #6 bug)"
1555 );
1556
1557 let after = std::fs::read_to_string(&jsonc).unwrap();
1558 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1559 let plugins = parsed["plugin"].as_array().unwrap();
1560 assert!(
1564 !plugins.iter().any(|v| v.as_str() == Some("sqz")),
1565 "issue #10: sqz must NOT be added to plugin[]"
1566 );
1567 assert!(
1568 plugins.iter().any(|v| v.as_str() == Some("other-plugin")),
1569 "pre-existing plugin entries must be preserved"
1570 );
1571 assert_eq!(
1572 parsed["model"].as_str(),
1573 Some("anthropic/claude-sonnet-4-5"),
1574 "unrelated user keys must survive the merge"
1575 );
1576 assert_eq!(
1577 parsed["mcp"]["sqz"]["type"].as_str(),
1578 Some("local"),
1579 "mcp.sqz must be registered"
1580 );
1581 }
1582
1583 #[test]
1587 fn test_update_opencode_config_detailed_reports_comments_lost() {
1588 let dir = tempfile::tempdir().unwrap();
1589 let jsonc = dir.path().join("opencode.jsonc");
1590 std::fs::write(
1591 &jsonc,
1592 r#"{
1593 // comment to be dropped
1594 "plugin": ["other"]
1595}
1596"#,
1597 )
1598 .unwrap();
1599
1600 let (changed, comments_lost) =
1601 update_opencode_config_detailed(dir.path()).unwrap();
1602 assert!(changed);
1603 assert!(
1604 comments_lost,
1605 "merger must report that comments were dropped from .jsonc"
1606 );
1607 }
1608
1609 #[test]
1613 fn plan_opencode_reports_no_change_when_already_configured() {
1614 let dir = tempfile::tempdir().unwrap();
1615 update_opencode_config(dir.path()).unwrap();
1617 let planned = plan_opencode_config_change(dir.path()).unwrap();
1619 assert!(
1620 !planned.will_change,
1621 "re-running against a fully configured file must be a no-op"
1622 );
1623 assert!(!planned.comments_lost);
1624 }
1625
1626 #[test]
1629 fn plan_opencode_reports_change_without_writing() {
1630 let dir = tempfile::tempdir().unwrap();
1631 let path = dir.path().join("opencode.json");
1632 std::fs::write(&path, r#"{"plugin":["other"]}"#).unwrap();
1633 let before = std::fs::read_to_string(&path).unwrap();
1634
1635 let planned = plan_opencode_config_change(dir.path()).unwrap();
1636 assert!(planned.will_change);
1637 assert_eq!(planned.target_path, path);
1638
1639 let after = std::fs::read_to_string(&path).unwrap();
1640 assert_eq!(before, after, "dry-run must not modify the file");
1641 }
1642
1643 #[test]
1646 fn plan_opencode_reports_fresh_create() {
1647 let dir = tempfile::tempdir().unwrap();
1648 let planned = plan_opencode_config_change(dir.path()).unwrap();
1649 assert!(planned.will_change);
1650 assert_eq!(planned.target_path, dir.path().join("opencode.json"));
1651 assert!(!dir.path().join("opencode.json").exists(),
1652 "dry-run must not create the file");
1653 }
1654
1655 #[test]
1659 fn test_update_creates_plain_json_when_nothing_exists() {
1660 let dir = tempfile::tempdir().unwrap();
1661 update_opencode_config(dir.path()).unwrap();
1662 assert!(dir.path().join("opencode.json").exists());
1663 assert!(!dir.path().join("opencode.jsonc").exists());
1664 }
1665
1666 #[test]
1668 fn config_rewrites_keep_the_final_newline_and_mode() {
1669 for ending in ["", "\n"] {
1670 let dir = tempfile::tempdir().unwrap();
1671 let path = dir.path().join("opencode.json");
1672 std::fs::write(&path, format!("{{\"plugin\":[\"other\"]}}{ending}")).unwrap();
1673 #[cfg(unix)]
1674 {
1675 use std::os::unix::fs::PermissionsExt;
1676 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap();
1677 }
1678
1679 assert!(update_opencode_config(dir.path()).unwrap());
1680 assert_eq!(std::fs::read_to_string(&path).unwrap().ends_with('\n'), !ending.is_empty());
1681 assert!(remove_sqz_from_opencode_config(dir.path()).unwrap().unwrap().1);
1682 assert_eq!(std::fs::read_to_string(&path).unwrap().ends_with('\n'), !ending.is_empty());
1683 #[cfg(unix)]
1684 {
1685 use std::os::unix::fs::PermissionsExt;
1686 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1687 assert_eq!(mode, 0o640);
1688 }
1689 }
1690 }
1691
1692 #[test]
1694 fn test_find_opencode_config_prefers_jsonc() {
1695 let dir = tempfile::tempdir().unwrap();
1696 std::fs::write(dir.path().join("opencode.json"), "{}").unwrap();
1697 std::fs::write(dir.path().join("opencode.jsonc"), "{}").unwrap();
1698 let found = find_opencode_config(dir.path()).unwrap();
1699 assert_eq!(
1700 found.file_name().unwrap(),
1701 "opencode.jsonc",
1702 "must prefer the .jsonc variant when both exist — the user \
1703 is maintaining .jsonc for its comment support"
1704 );
1705 }
1706
1707 #[test]
1708 fn test_find_opencode_config_returns_none_when_missing() {
1709 let dir = tempfile::tempdir().unwrap();
1710 assert!(find_opencode_config(dir.path()).is_none());
1711 }
1712
1713 #[test]
1714 fn test_opencode_config_has_comments_detects_jsonc_comments() {
1715 let dir = tempfile::tempdir().unwrap();
1716 std::fs::write(
1717 dir.path().join("opencode.jsonc"),
1718 "// a line comment\n{\"plugin\":[]}\n",
1719 )
1720 .unwrap();
1721 assert!(opencode_config_has_comments(dir.path()));
1722 }
1723
1724 #[test]
1725 fn test_opencode_config_has_comments_ignores_plain_json() {
1726 let dir = tempfile::tempdir().unwrap();
1727 std::fs::write(
1729 dir.path().join("opencode.json"),
1730 r#"{"url":"http://example.com"}"#,
1731 )
1732 .unwrap();
1733 assert!(!opencode_config_has_comments(dir.path()));
1734 }
1735
1736 #[test]
1739 fn test_strip_jsonc_comments_removes_line_comments() {
1740 let src = "{\n // leading comment\n \"a\": 1 // trailing\n}";
1741 let stripped = strip_jsonc_comments(src);
1742 assert!(!stripped.contains("leading comment"));
1743 assert!(!stripped.contains("trailing"));
1744 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1745 assert_eq!(parsed["a"], 1);
1746 }
1747
1748 #[test]
1749 fn test_strip_jsonc_comments_removes_block_comments() {
1750 let src = "{\n /* block\n comment */\n \"a\": 1\n}";
1751 let stripped = strip_jsonc_comments(src);
1752 assert!(!stripped.contains("block"));
1753 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1754 assert_eq!(parsed["a"], 1);
1755 }
1756
1757 #[test]
1758 fn test_strip_jsonc_comments_preserves_strings() {
1759 let src = r#"{"url": "http://example.com", "re": "/* not a comment */"}"#;
1764 let stripped = strip_jsonc_comments(src);
1765 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1766 assert_eq!(parsed["url"], "http://example.com");
1767 assert_eq!(parsed["re"], "/* not a comment */");
1768 }
1769
1770 #[test]
1771 fn test_strip_jsonc_comments_preserves_escaped_quote_in_string() {
1772 let src = r#"{"s": "a\"//b"}"#;
1773 let stripped = strip_jsonc_comments(src);
1774 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1775 assert_eq!(parsed["s"], r#"a"//b"#);
1776 }
1777
1778 #[test]
1779 fn test_strip_jsonc_comments_tolerates_unterminated_block() {
1780 let src = "{\"a\":1 /* never ends";
1782 let _ = strip_jsonc_comments(src); }
1784
1785 #[test]
1786 fn test_strip_jsonc_comments_removes_trailing_commas() {
1787 let src = r#"{
1790 "a": [1, 2, 3,],
1791 "b": {"x": 1, "y": 2,},
1792}"#;
1793 let stripped = strip_jsonc_comments(src);
1794 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1795 assert_eq!(parsed["a"], serde_json::json!([1, 2, 3]));
1796 assert_eq!(parsed["b"]["x"], 1);
1797 assert_eq!(parsed["b"]["y"], 2);
1798 }
1799
1800 #[test]
1801 fn test_strip_jsonc_comments_trailing_comma_in_string_preserved() {
1802 let src = r#"{"s": "a,}"}"#;
1804 let stripped = strip_jsonc_comments(src);
1805 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1806 assert_eq!(parsed["s"], "a,}");
1807 }
1808
1809 #[test]
1810 fn test_strip_jsonc_full_opencode_jsonc_with_comments_and_trailing_commas() {
1811 let src = r#"{
1814 // User's OpenCode config
1815 "$schema": "https://opencode.ai/config.json",
1816 "mcp": {
1817 // MCP servers
1818 "dart": {
1819 "type": "local",
1820 "command": ["dart", "mcp-server"],
1821 },
1822 },
1823}"#;
1824 let stripped = strip_jsonc_comments(src);
1825 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1826 assert_eq!(
1827 parsed["mcp"]["dart"]["type"], "local",
1828 "must parse after stripping comments + trailing commas"
1829 );
1830 }
1831
1832 #[test]
1840 fn test_remove_sqz_preserves_other_user_config() {
1841 let dir = tempfile::tempdir().unwrap();
1842 let config = dir.path().join("opencode.json");
1843 std::fs::write(
1844 &config,
1845 r#"{
1846 "$schema": "https://opencode.ai/config.json",
1847 "model": "anthropic/claude-sonnet-4-5",
1848 "plugin": ["other-plugin", "sqz"],
1849 "mcp": {
1850 "sqz": { "type": "local", "command": ["sqz-mcp"] },
1851 "jira": { "type": "remote", "url": "https://jira.example.com/mcp" }
1852 }
1853}
1854"#,
1855 )
1856 .unwrap();
1857
1858 let (path, changed) =
1859 remove_sqz_from_opencode_config(dir.path()).unwrap().unwrap();
1860 assert_eq!(path, config);
1861 assert!(changed, "must report that sqz entries were removed");
1862 assert!(
1863 config.exists(),
1864 "file must NOT be deleted — only sqz's entries removed"
1865 );
1866
1867 let after = std::fs::read_to_string(&config).unwrap();
1868 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1869 let plugins = parsed["plugin"].as_array().unwrap();
1870 assert!(!plugins.iter().any(|v| v.as_str() == Some("sqz")));
1871 assert!(plugins.iter().any(|v| v.as_str() == Some("other-plugin")));
1872 let mcp = parsed["mcp"].as_object().unwrap();
1873 assert!(!mcp.contains_key("sqz"), "mcp.sqz must be gone");
1874 assert!(mcp.contains_key("jira"), "mcp.jira must survive");
1875 assert_eq!(
1876 parsed["model"].as_str(),
1877 Some("anthropic/claude-sonnet-4-5"),
1878 "unrelated keys must survive"
1879 );
1880 }
1881
1882 #[test]
1886 fn test_remove_sqz_deletes_file_when_nothing_else_remains() {
1887 let dir = tempfile::tempdir().unwrap();
1888 let config = dir.path().join("opencode.json");
1889 std::fs::write(
1891 &config,
1892 r#"{
1893 "$schema": "https://opencode.ai/config.json",
1894 "mcp": {
1895 "sqz": { "type": "local", "command": ["sqz-mcp", "--transport", "stdio"] }
1896 },
1897 "plugin": ["sqz"]
1898}
1899"#,
1900 )
1901 .unwrap();
1902
1903 let (_, changed) =
1904 remove_sqz_from_opencode_config(dir.path()).unwrap().unwrap();
1905 assert!(changed);
1906 assert!(
1907 !config.exists(),
1908 "file with only $schema + sqz entries must be removed"
1909 );
1910 }
1911
1912 #[test]
1915 fn test_remove_sqz_returns_none_when_config_missing() {
1916 let dir = tempfile::tempdir().unwrap();
1917 let result = remove_sqz_from_opencode_config(dir.path()).unwrap();
1918 assert!(result.is_none());
1919 }
1920
1921 #[test]
1924 fn test_remove_sqz_from_jsonc_drops_comments() {
1925 let dir = tempfile::tempdir().unwrap();
1926 let jsonc = dir.path().join("opencode.jsonc");
1927 std::fs::write(
1928 &jsonc,
1929 r#"{
1930 // user's comment
1931 "model": "x",
1932 "plugin": ["sqz", "other"]
1933}
1934"#,
1935 )
1936 .unwrap();
1937
1938 let (path, changed) =
1939 remove_sqz_from_opencode_config(dir.path()).unwrap().unwrap();
1940 assert_eq!(path, jsonc);
1941 assert!(changed);
1942 assert!(path.exists(), "jsonc file kept because `model` and `other` remain");
1943
1944 let after = std::fs::read_to_string(&jsonc).unwrap();
1945 assert!(
1946 !after.contains("// user's comment"),
1947 "comments are dropped by the serde_json round-trip; \
1948 documented in update_opencode_config_detailed"
1949 );
1950 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1951 let plugins = parsed["plugin"].as_array().unwrap();
1952 assert_eq!(plugins.len(), 1);
1953 assert_eq!(plugins[0], "other");
1954 }
1955
1956 #[test]
1966 fn issue_10_opencode_rewrite_works_in_powershell_syntax() {
1967 let input = r#"{"tool":"bash","args":{"command":"dotnet build NewNeonCheckers3.sln"}}"#;
1968 let result = process_opencode_hook(input).unwrap();
1969 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1970 let cmd = parsed["args"]["command"].as_str().unwrap();
1971
1972 assert!(
1976 !cmd.contains("SQZ_CMD="),
1977 "issue #10: rewrite must not emit `SQZ_CMD=` (breaks on \
1978 PowerShell/cmd.exe); got: {cmd}"
1979 );
1980 assert!(
1982 cmd.contains("--cmd dotnet"),
1983 "rewrite must pass label via --cmd; got: {cmd}"
1984 );
1985 if cfg!(windows) {
1990 let first_token = cmd.split_whitespace().next().unwrap_or("");
1991 assert_eq!(
1992 first_token, "dotnet",
1993 "first token of the rewritten command must be the user's \
1994 command itself, not an env-var assignment; got: {cmd}"
1995 );
1996 }
1997 }
1998
1999 #[test]
2003 fn ts_plugin_carries_exit_status_on_posix() {
2004 let content = generate_opencode_plugin("sqz");
2005 assert!(content.contains("printf '\\\\n__SQZ_EXIT_%d__\\\\n' \"$?\""), "{content}");
2006 assert!(content.contains("process.platform !== \"win32\""));
2007 assert!(content.contains("return wrap(cmd, shellEscapeLabel(base));"));
2008
2009 let Some(node) = which_node().filter(|_| cfg!(unix)) else { return };
2010 let start = content.find("const POSIX").unwrap();
2011 let end = content[start..].find("\n}\n").unwrap() + start + 2;
2012 let js = content[start..end]
2014 .replace("(cmd: string, label: string): string", "(cmd, label)")
2015 .replace("SQZ_PATH", "\"sqz\"");
2016 let script = format!("{js}\nprocess.stdout.write(wrap(\"cargo test\", \"cargo\"));");
2017 let out = std::process::Command::new(node)
2018 .args(["-e", &script])
2019 .env("SHELL", "/bin/zsh")
2020 .output()
2021 .unwrap();
2022 assert_eq!(
2023 String::from_utf8_lossy(&out.stdout),
2024 crate::exit_marker::posix_rewrite("cargo test", "cargo"),
2025 "{}",
2026 String::from_utf8_lossy(&out.stderr)
2027 );
2028 }
2029
2030 fn which_node() -> Option<&'static str> {
2031 let out = std::process::Command::new("node").arg("--version").output().ok()?;
2032 out.status.success().then_some("node")
2033 }
2034
2035 #[test]
2036 fn plugin_default_export_has_a_setup_for_opencode_2() {
2037 let content = generate_opencode_plugin("sqz");
2038 let default = &content[content.find("export default {").unwrap()..];
2039 assert!(
2040 default.contains(" async setup(ctx: any) {\n await ctx.tool.hook(\"execute.before\", (event: any) => {"),
2041 "{content}"
2042 );
2043 assert_eq!(content.matches("rewriteCommand(cmd)").count(), 2, "{content}");
2044 assert_eq!(content.matches("wrap(cmd, ").count(), 1, "{content}");
2045 }
2046
2047 #[test]
2048 fn ts_plugin_rewrites_the_same_on_opencode_1_and_2() {
2049 let Some(node) = which_node().filter(|_| cfg!(unix)) else { return };
2050 let dir = tempfile::tempdir().unwrap();
2051 let js = generate_opencode_plugin("sqz")
2053 .replace(": string | undefined", "")
2054 .replace(": string", "")
2055 .replace(": boolean", "")
2056 .replace(": any", "");
2057 std::fs::write(dir.path().join("sqz.mjs"), js).unwrap();
2058 let wrapped = crate::exit_marker::posix_rewrite("cargo test", "cargo");
2059 let commands = serde_json::json!(["cargo test", "vim notes.txt", wrapped]);
2060 let driver = r#"import plugin, { SqzPlugin } from "./sqz.mjs";
2061const commands = COMMANDS;
2062const hooks = await plugin.server({});
2063const v1 = [];
2064for (const command of commands) {
2065 const output = { args: { command } };
2066 await hooks["tool.execute.before"]({ tool: "bash" }, output);
2067 v1.push(output.args.command);
2068}
2069const registered = [];
2070await plugin.setup({ tool: { hook: async (name, callback) => { registered.push([name, callback]); } } });
2071const v2 = [];
2072for (const command of commands) {
2073 const event = { tool: "shell", input: { command, workdir: "/tmp" } };
2074 for (const [name, callback] of registered) if (name === "execute.before") await callback(event);
2075 v2.push(event.input);
2076}
2077const background = { tool: "shell", input: { command: "cargo test", background: true } };
2078for (const [name, callback] of registered) if (name === "execute.before") await callback(background);
2079process.stdout.write(JSON.stringify({ id: plugin.id, names: registered.map(([name]) => name), legacy: SqzPlugin === plugin.server, v1, v2, background: background.input }));
2080"#
2081 .replace("COMMANDS", &commands.to_string());
2082 std::fs::write(dir.path().join("drive.mjs"), driver).unwrap();
2083 let out = std::process::Command::new(node)
2084 .arg(dir.path().join("drive.mjs"))
2085 .env("SHELL", "/bin/zsh")
2086 .output()
2087 .unwrap();
2088 let got: serde_json::Value = serde_json::from_slice(&out.stdout)
2089 .unwrap_or_else(|e| panic!("{e}: {}", String::from_utf8_lossy(&out.stderr)));
2090 let input = |command: &str| serde_json::json!({ "command": command, "workdir": "/tmp" });
2091 assert_eq!(got["id"], "sqz");
2092 assert_eq!(got["names"], serde_json::json!(["execute.before"]));
2093 assert_eq!(got["legacy"], true);
2094 assert_eq!(got["v1"], serde_json::json!([wrapped, "vim notes.txt", wrapped]));
2095 assert_eq!(got["v2"], serde_json::json!([input(&wrapped), input("vim notes.txt"), input(&wrapped)]));
2096 assert_eq!(got["background"], serde_json::json!({ "command": "cargo test", "background": true }));
2097 }
2098
2099 #[test]
2100 fn plugin_from_an_older_sqz_is_stale() {
2101 let dir = tempfile::tempdir().unwrap();
2102 let path = dir.path().join("sqz.ts");
2103 assert!(!opencode_plugin_stale(&path));
2104 let current = generate_opencode_plugin("/opt/sqz");
2105 let old = "const SQZ_PATH = \"/opt/sqz\";\nexport const SqzPlugin = async () => ({});\n";
2106 let mine = "export default { id: \"mine\", server: async () => ({}) };\n";
2107 for (text, stale) in [
2108 (current.clone(), false),
2109 (current.replace('\n', "\r\n"), false),
2110 (old.to_string(), true),
2111 (mine.to_string(), false),
2112 ] {
2113 std::fs::write(&path, &text).unwrap();
2114 assert_eq!(opencode_plugin_stale(&path), stale, "{text}");
2115 }
2116 }
2117
2118 #[test]
2119 fn refreshing_a_stale_plugin_keeps_its_sqz_binary() {
2120 let dir = tempfile::tempdir().unwrap();
2121 let (kept, fallback) = (dir.path().join("sqz"), dir.path().join("fallback-sqz"));
2122 std::fs::write(&kept, "").unwrap();
2123 std::fs::write(&fallback, "").unwrap();
2124 let (kept, fallback) = (kept.to_string_lossy().into_owned(), fallback.to_string_lossy().into_owned());
2125 let old = |bin: &str| {
2126 format!("const SQZ_PATH = {};\nexport const SqzPlugin = async () => ({{}});\n", serde_json::to_string(bin).unwrap())
2127 };
2128 let path = dir.path().join("sqz.ts");
2129
2130 std::fs::write(&path, old(&kept)).unwrap();
2131 assert!(refresh_opencode_plugin(&path, &fallback).unwrap());
2132 assert_eq!(std::fs::read_to_string(&path).unwrap(), generate_opencode_plugin(&kept));
2133 assert!(!refresh_opencode_plugin(&path, &fallback).unwrap());
2134
2135 std::fs::write(&path, old("/gone/sqz")).unwrap();
2136 assert!(refresh_opencode_plugin(&path, &fallback).unwrap());
2137 assert_eq!(std::fs::read_to_string(&path).unwrap(), generate_opencode_plugin(&fallback));
2138
2139 std::fs::write(&path, old("/gone/sqz")).unwrap();
2140 assert!(refresh_opencode_plugin(&path, "/gone/too").is_err());
2141 assert_eq!(std::fs::read_to_string(&path).unwrap(), old("/gone/sqz"));
2142
2143 let mine = "export default { id: \"mine\", server: async () => ({}) };\n";
2144 std::fs::write(&path, mine).unwrap();
2145 assert!(!refresh_opencode_plugin(&path, &fallback).unwrap());
2146 assert_eq!(std::fs::read_to_string(&path).unwrap(), mine);
2147 }
2148
2149 #[test]
2153 fn issue_10_ts_plugin_emits_cmd_flag_not_env_prefix() {
2154 let content = generate_opencode_plugin("sqz");
2155 assert!(
2159 content.contains("compress --cmd"),
2160 "TS plugin must build rewrite with `compress --cmd ${{base}}`"
2161 );
2162 assert!(
2168 !content.contains("SQZ_CMD=${base}"),
2169 "TS plugin must not emit the legacy `SQZ_CMD=${{base}}` prefix"
2170 );
2171 }
2172
2173 #[test]
2188 fn issue_10_fresh_opencode_config_has_no_plugin_entry() {
2189 let dir = tempfile::tempdir().unwrap();
2190 update_opencode_config(dir.path()).unwrap();
2191 let content = std::fs::read_to_string(dir.path().join("opencode.json")).unwrap();
2192 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
2193
2194 assert!(
2196 parsed.get("plugin").is_none(),
2197 "issue #10: fresh opencode.json must not include `plugin` key; got: {content}"
2198 );
2199
2200 assert_eq!(
2203 parsed["mcp"]["sqz"]["type"].as_str(),
2204 Some("local"),
2205 "mcp.sqz is the one sqz-authored entry that belongs in \
2206 opencode.json; must still be registered"
2207 );
2208 }
2209
2210 #[test]
2215 fn issue_10_reinit_strips_legacy_plugin_entry() {
2216 let dir = tempfile::tempdir().unwrap();
2217 let config = dir.path().join("opencode.json");
2218 std::fs::write(
2219 &config,
2220 r#"{"$schema":"https://opencode.ai/config.json","mcp":{"sqz":{"type":"local","command":["sqz-mcp","--transport","stdio"]}},"plugin":["sqz"]}"#,
2222 )
2223 .unwrap();
2224
2225 let changed = update_opencode_config(dir.path()).unwrap();
2226 assert!(changed, "re-init must report a change (the legacy entry was stripped)");
2227
2228 let after = std::fs::read_to_string(&config).unwrap();
2229 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
2230 assert!(
2231 parsed.get("plugin").is_none(),
2232 "legacy `plugin: [\"sqz\"]` must be stripped on re-init; got: {after}"
2233 );
2234 assert_eq!(
2236 parsed["mcp"]["sqz"]["type"].as_str(),
2237 Some("local"),
2238 "mcp.sqz must survive cleanup of the plugin entry"
2239 );
2240 }
2241}