Skip to main content

sqz_engine/
opencode_plugin.rs

1/// OpenCode plugin support for sqz.
2///
3/// OpenCode uses TypeScript plugins loaded from `~/.config/opencode/plugins/`.
4/// The plugin hooks into `tool.execute.before` to rewrite bash commands,
5/// piping output through `sqz compress` for token savings.
6///
7/// Unlike Claude Code / Cursor / Gemini (which use JSON hook configs),
8/// OpenCode requires a TypeScript file that exports a factory function.
9///
10/// Plugin path: `~/.config/opencode/plugins/sqz.ts`
11/// Config path: `opencode.json` OR `opencode.jsonc` in the project root.
12/// The installer (`update_opencode_config`) discovers either variant and
13/// merges sqz's entries into whichever exists; a fresh install defaults
14/// to `opencode.json`. See issue #6 for the reason the installer must
15/// look past the `.json` extension.
16
17use std::path::{Path, PathBuf};
18
19use crate::error::Result;
20
21/// Generate the OpenCode TypeScript plugin content.
22///
23/// The plugin intercepts shell tool calls and rewrites them to pipe
24/// output through `sqz hook opencode`, which compresses the output.
25pub fn generate_opencode_plugin(sqz_path: &str) -> String {
26    // Escape for embedding in a double-quoted TypeScript string literal.
27    // On Windows, sqz_path contains backslashes that must be escaped —
28    // same reason we escape hook JSON in generate_hook_configs. See issue #2.
29    let sqz_path = crate::tool_hooks::json_escape_string_value(sqz_path);
30    format!(
31        r#"/**
32 * sqz — OpenCode plugin for transparent context compression.
33 *
34 * Intercepts shell commands and pipes output through sqz for token savings.
35 * Install: copy to ~/.config/opencode/plugins/sqz.ts
36 * Config:  add "plugin": ["sqz"] to opencode.json or opencode.jsonc
37 */
38
39export const SqzPlugin = async (ctx: any) => {{
40  const SQZ_PATH = "{sqz_path}";
41
42  // Commands that should not be intercepted.
43  const INTERACTIVE = new Set([
44    "vim", "vi", "nano", "emacs", "less", "more", "top", "htop",
45    "ssh", "python", "python3", "node", "irb", "ghci",
46    "psql", "mysql", "sqlite3", "mongo", "redis-cli",
47  ]);
48
49  function isInteractive(cmd: string): boolean {{
50    const base = cmd.split(/\s+/)[0]?.split("/").pop() ?? "";
51    if (INTERACTIVE.has(base)) return true;
52    if (cmd.includes("--watch") || cmd.includes("run dev") ||
53        cmd.includes("run start") || cmd.includes("run serve")) return true;
54    return false;
55  }}
56
57  function shouldIntercept(tool: string): boolean {{
58    return ["bash", "shell", "terminal", "run_shell_command"].includes(tool.toLowerCase());
59  }}
60
61  // Detect that a command has already been wrapped by sqz. Before this
62  // guard was in place OpenCode could call the hook twice on the same
63  // command (for retried tool calls, or when a previous rewrite was
64  // echoed back to the agent and the agent re-submitted it) and each
65  // pass would prepend another `SQZ_CMD=$base` prefix, producing monsters
66  // like `SQZ_CMD=SQZ_CMD=ddev SQZ_CMD=ddev ddev exec ...` (reported as
67  // a follow-up to issue #5). We skip if any of these markers appear:
68  //   * the case-insensitive substring "sqz_cmd=" or "sqz compress"
69  //     (covers the tail of prior wraps regardless of case)
70  //   * a leading `VAR=` assignment that starts with SQZ_
71  //     (defensive catch-all for exotic wrap variants)
72  //   * the base command itself is sqz or sqz-mcp (running sqz directly
73  //     — compressing sqz's own output is pointless and causes loops)
74  function isAlreadyWrapped(cmd: string): boolean {{
75    const lowered = cmd.toLowerCase();
76    if (lowered.includes("sqz_cmd=")) return true;
77    if (lowered.includes("sqz compress")) return true;
78    if (lowered.includes("| sqz ") || lowered.includes("| sqz\t")) return true;
79    if (/^\s*SQZ_[A-Z0-9_]+=/.test(cmd)) return true;
80    const base = extractBaseCmd(cmd);
81    if (base === "sqz" || base === "sqz-mcp" || base === "sqz.exe") return true;
82    return false;
83  }}
84
85  // Extract the base command name defensively. If the command has
86  // leading env-var assignments (VAR=val VAR2=val2 actual_cmd arg1),
87  // skip past them so the base is `actual_cmd` — not `VAR=val`.
88  function extractBaseCmd(cmd: string): string {{
89    const tokens = cmd.split(/\s+/).filter(t => t.length > 0);
90    for (const tok of tokens) {{
91      // A token is an env assignment if it matches NAME=VALUE where NAME
92      // is a valid env var identifier. Skip it and keep looking.
93      if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tok)) continue;
94      return tok.split("/").pop() ?? "unknown";
95    }}
96    return "unknown";
97  }}
98
99  return {{
100    "tool.execute.before": async (input: any, output: any) => {{
101      const tool = input.tool ?? "";
102      if (!shouldIntercept(tool)) return;
103
104      const cmd = output.args?.command ?? "";
105      if (!cmd || isAlreadyWrapped(cmd) || isInteractive(cmd)) return;
106
107      // Rewrite: pipe through sqz compress
108      const base = extractBaseCmd(cmd);
109      output.args.command = `SQZ_CMD=${{base}} ${{cmd}} 2>&1 | ${{SQZ_PATH}} compress`;
110    }},
111  }};
112}};
113"#
114    )
115}
116
117/// Default path for the OpenCode plugin file.
118pub fn opencode_plugin_path() -> PathBuf {
119    let home = std::env::var("HOME")
120        .or_else(|_| std::env::var("USERPROFILE"))
121        .map(PathBuf::from)
122        .unwrap_or_else(|_| PathBuf::from("."));
123    home.join(".config")
124        .join("opencode")
125        .join("plugins")
126        .join("sqz.ts")
127}
128
129/// Install the OpenCode plugin to `~/.config/opencode/plugins/sqz.ts`.
130///
131/// Returns `true` if the plugin was installed, `false` if it already exists.
132pub fn install_opencode_plugin(sqz_path: &str) -> Result<bool> {
133    let plugin_path = opencode_plugin_path();
134
135    if plugin_path.exists() {
136        return Ok(false);
137    }
138
139    if let Some(parent) = plugin_path.parent() {
140        std::fs::create_dir_all(parent).map_err(|e| {
141            crate::error::SqzError::Other(format!(
142                "failed to create OpenCode plugins dir {}: {e}",
143                parent.display()
144            ))
145        })?;
146    }
147
148    let content = generate_opencode_plugin(sqz_path);
149    std::fs::write(&plugin_path, &content).map_err(|e| {
150        crate::error::SqzError::Other(format!(
151            "failed to write OpenCode plugin to {}: {e}",
152            plugin_path.display()
153        ))
154    })?;
155
156    Ok(true)
157}
158
159/// Locate an existing OpenCode project config. Returns the path to
160/// `opencode.jsonc` if present, else `opencode.json` if present, else
161/// `None`. Prefers `.jsonc` because a user who bothered to write a
162/// comment-annotated config is more invested in it, and sqz must not
163/// silently create a parallel `.json` that would leave the `.jsonc`
164/// looking un-updated (reported in issue #6).
165pub fn find_opencode_config(project_dir: &Path) -> Option<PathBuf> {
166    let jsonc = project_dir.join("opencode.jsonc");
167    if jsonc.exists() {
168        return Some(jsonc);
169    }
170    let json = project_dir.join("opencode.json");
171    if json.exists() {
172        return Some(json);
173    }
174    None
175}
176
177/// Return `true` if the user's OpenCode project config is a `.jsonc`
178/// file that contains comments. Callers use this to decide whether to
179/// warn the user that sqz's upcoming merge will drop those comments
180/// (serde_json round-trips discard them).
181pub fn opencode_config_has_comments(project_dir: &Path) -> bool {
182    let path = match find_opencode_config(project_dir) {
183        Some(p) => p,
184        None => return false,
185    };
186    if path.extension().map(|e| e != "jsonc").unwrap_or(true) {
187        return false;
188    }
189    let content = match std::fs::read_to_string(&path) {
190        Ok(s) => s,
191        Err(_) => return false,
192    };
193    strip_jsonc_comments(&content) != content
194}
195
196/// Strip JSONC-style comments from `src` while preserving string literals
197/// byte-exact. Handles:
198/// - `// line comments` through end-of-line
199/// - `/* block comments */` (non-nested, which matches standard JSONC)
200/// - Escape-aware string parsing so `"//"` inside a string is not stripped
201///
202/// Returns a string suitable for `serde_json::from_str`. Does not
203/// attempt to preserve or round-trip the comments — callers that need
204/// to write the file back must be explicit about losing comments.
205pub fn strip_jsonc_comments(src: &str) -> String {
206    let mut out = String::with_capacity(src.len());
207    let bytes = src.as_bytes();
208    let mut i = 0;
209    let len = bytes.len();
210
211    while i < len {
212        let b = bytes[i];
213
214        // Enter a string literal: copy verbatim until the matching close
215        // quote, honouring backslash escapes.
216        if b == b'"' {
217            out.push('"');
218            i += 1;
219            while i < len {
220                let c = bytes[i];
221                out.push(c as char);
222                if c == b'\\' && i + 1 < len {
223                    // Preserve the escape and the escaped char together.
224                    out.push(bytes[i + 1] as char);
225                    i += 2;
226                    continue;
227                }
228                i += 1;
229                if c == b'"' {
230                    break;
231                }
232            }
233            continue;
234        }
235
236        // Line comment: skip through newline (but keep the newline so
237        // line numbers line up for error messages).
238        if b == b'/' && i + 1 < len && bytes[i + 1] == b'/' {
239            i += 2;
240            while i < len && bytes[i] != b'\n' {
241                i += 1;
242            }
243            continue;
244        }
245
246        // Block comment: skip through `*/`.
247        if b == b'/' && i + 1 < len && bytes[i + 1] == b'*' {
248            i += 2;
249            while i + 1 < len && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
250                // Preserve newlines so line numbers still line up.
251                if bytes[i] == b'\n' {
252                    out.push('\n');
253                }
254                i += 1;
255            }
256            // Skip the terminating `*/` if we found it; tolerate
257            // unterminated comments by exiting the loop.
258            if i + 1 < len {
259                i += 2;
260            }
261            continue;
262        }
263
264        out.push(b as char);
265        i += 1;
266    }
267
268    out
269}
270
271/// Update an existing `opencode.json`/`opencode.jsonc`, or create a
272/// fresh `opencode.json`, so that sqz's plugin and MCP server are
273/// registered. Idempotent.
274///
275/// If a `.jsonc` file exists, it is read with comment-stripping, merged,
276/// and written back WITHOUT the comments — we can't losslessly round-trip
277/// comments through serde_json. The caller is warned via the return
278/// value's second field so `sqz init` can surface the fact.
279///
280/// If both files exist for some reason (OpenCode merges both), the
281/// `.jsonc` is treated as authoritative (per `find_opencode_config`).
282///
283/// Returns `(updated, comments_lost)` where `updated` is true if any
284/// change was written to disk, and `comments_lost` is true if sqz had
285/// to drop comments from a `.jsonc` during the merge.
286pub fn update_opencode_config(project_dir: &Path) -> Result<bool> {
287    let (updated, _) = update_opencode_config_detailed(project_dir)?;
288    Ok(updated)
289}
290
291/// Like `update_opencode_config` but also reports whether comments had
292/// to be dropped from a JSONC file during the merge. Used by the `sqz
293/// init` CLI to print a warning.
294pub fn update_opencode_config_detailed(project_dir: &Path) -> Result<(bool, bool)> {
295    // Desired shape of sqz's entry in the merged config.
296    fn sqz_mcp_value() -> serde_json::Value {
297        serde_json::json!({
298            "type": "local",
299            "command": ["sqz-mcp", "--transport", "stdio"]
300        })
301    }
302
303    if let Some(existing_path) = find_opencode_config(project_dir) {
304        let is_jsonc = existing_path
305            .extension()
306            .map(|e| e == "jsonc")
307            .unwrap_or(false);
308        let content = std::fs::read_to_string(&existing_path).map_err(|e| {
309            crate::error::SqzError::Other(format!(
310                "failed to read {}: {e}",
311                existing_path.display()
312            ))
313        })?;
314
315        let parseable = if is_jsonc {
316            strip_jsonc_comments(&content)
317        } else {
318            content.clone()
319        };
320
321        // Detect whether comments were present — relevant for
322        // comments_lost reporting only if we end up mutating the file.
323        let had_comments = is_jsonc && parseable != content;
324
325        // Parse existing config.
326        let mut config: serde_json::Value = serde_json::from_str(&parseable).map_err(|e| {
327            crate::error::SqzError::Other(format!(
328                "failed to parse {}: {e}",
329                existing_path.display()
330            ))
331        })?;
332
333        let obj = config.as_object_mut().ok_or_else(|| {
334            crate::error::SqzError::Other(format!(
335                "{} root is not a JSON object",
336                existing_path.display()
337            ))
338        })?;
339
340        let mut changed = false;
341
342        // Merge `plugin`: add "sqz" if not present. Create the array if
343        // the key is missing; preserve the user's existing entries.
344        let plugin_entry = obj.entry("plugin").or_insert_with(|| serde_json::json!([]));
345        if let Some(arr) = plugin_entry.as_array_mut() {
346            let has_sqz = arr.iter().any(|v| v.as_str() == Some("sqz"));
347            if !has_sqz {
348                arr.push(serde_json::json!("sqz"));
349                changed = true;
350            }
351        } else {
352            // `plugin` exists but isn't an array — bail rather than
353            // silently clobber a user's weird-but-valid config.
354            return Err(crate::error::SqzError::Other(format!(
355                "{} has a `plugin` field that is not an array; \
356                 refusing to modify it automatically",
357                existing_path.display()
358            )));
359        }
360
361        // Merge `mcp.sqz`: add our MCP server entry if not present.
362        let mcp_entry = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
363        if let Some(mcp_obj) = mcp_entry.as_object_mut() {
364            if !mcp_obj.contains_key("sqz") {
365                mcp_obj.insert("sqz".to_string(), sqz_mcp_value());
366                changed = true;
367            }
368            // If the entry exists we do NOT overwrite — the user may
369            // have tuned it. That's the idempotent-merge contract.
370        } else {
371            return Err(crate::error::SqzError::Other(format!(
372                "{} has an `mcp` field that is not an object; \
373                 refusing to modify it automatically",
374                existing_path.display()
375            )));
376        }
377
378        if !changed {
379            return Ok((false, false));
380        }
381
382        // Serialize and write back to the SAME file (whether .json or
383        // .jsonc). We do not migrate .jsonc to .json or vice versa.
384        let updated = serde_json::to_string_pretty(&config).map_err(|e| {
385            crate::error::SqzError::Other(format!("failed to serialize config: {e}"))
386        })?;
387        std::fs::write(&existing_path, format!("{updated}\n")).map_err(|e| {
388            crate::error::SqzError::Other(format!(
389                "failed to write {}: {e}",
390                existing_path.display()
391            ))
392        })?;
393
394        Ok((true, had_comments))
395    } else {
396        // Fresh install: create opencode.json with both plugin and MCP.
397        let config = serde_json::json!({
398            "$schema": "https://opencode.ai/config.json",
399            "mcp": {
400                "sqz": sqz_mcp_value()
401            },
402            "plugin": ["sqz"]
403        });
404        let content = serde_json::to_string_pretty(&config).map_err(|e| {
405            crate::error::SqzError::Other(format!("failed to serialize opencode.json: {e}"))
406        })?;
407        let path = project_dir.join("opencode.json");
408        std::fs::write(&path, format!("{content}\n")).map_err(|e| {
409            crate::error::SqzError::Other(format!("failed to write opencode.json: {e}"))
410        })?;
411        Ok((true, false))
412    }
413}
414
415/// Remove sqz's entries from an existing `opencode.json`/`opencode.jsonc`
416/// without deleting the whole file. Removes `mcp.sqz` and any `"sqz"`
417/// entry from `plugin`. If this leaves `mcp` or `plugin` empty the keys
418/// are dropped too. Returns `(path, changed)` — `changed` is `false`
419/// when neither sqz entry was present.
420///
421/// Callers are expected to honour a `.jsonc` file's comments losing
422/// fidelity on write: we parse with comment-stripping and emit as plain
423/// JSON. The file keeps its original extension so OpenCode keeps reading
424/// it. If the resulting config is completely empty (or would be the
425/// near-empty shape we'd create from scratch), we remove the file
426/// entirely since that's the cleaner uninstall state.
427pub fn remove_sqz_from_opencode_config(project_dir: &Path) -> Result<Option<(PathBuf, bool)>> {
428    let path = match find_opencode_config(project_dir) {
429        Some(p) => p,
430        None => return Ok(None),
431    };
432    let is_jsonc = path.extension().map(|e| e == "jsonc").unwrap_or(false);
433    let raw = std::fs::read_to_string(&path).map_err(|e| {
434        crate::error::SqzError::Other(format!("failed to read {}: {e}", path.display()))
435    })?;
436    let parseable = if is_jsonc {
437        strip_jsonc_comments(&raw)
438    } else {
439        raw.clone()
440    };
441    let mut config: serde_json::Value = match serde_json::from_str(&parseable) {
442        Ok(v) => v,
443        Err(_) => {
444            // Can't parse — be conservative and leave it alone.
445            return Ok(Some((path, false)));
446        }
447    };
448
449    let mut changed = false;
450
451    if let Some(obj) = config.as_object_mut() {
452        // Drop `"sqz"` from `plugin[]`.
453        if let Some(plugin) = obj.get_mut("plugin").and_then(|v| v.as_array_mut()) {
454            let before = plugin.len();
455            plugin.retain(|v| v.as_str() != Some("sqz"));
456            if plugin.len() != before {
457                changed = true;
458            }
459            // Drop the whole `plugin` key if it's now empty.
460            if plugin.is_empty() {
461                obj.remove("plugin");
462            }
463        }
464
465        // Drop `mcp.sqz`, and drop `mcp` itself if that was the only key.
466        if let Some(mcp) = obj.get_mut("mcp").and_then(|v| v.as_object_mut()) {
467            if mcp.remove("sqz").is_some() {
468                changed = true;
469            }
470            if mcp.is_empty() {
471                obj.remove("mcp");
472            }
473        }
474    }
475
476    if !changed {
477        return Ok(Some((path, false)));
478    }
479
480    // If the remaining config is empty or nearly-so, just remove the file.
481    // (A bare `{}` or `{ "$schema": "..." }` is what sqz's own
482    // first-install would leave behind, and the user clearly doesn't
483    // want sqz here — so nuking the sqz-authored shell is correct.)
484    let essentially_empty = match config.as_object() {
485        Some(obj) => {
486            obj.is_empty()
487                || (obj.len() == 1
488                    && obj.get("$schema").and_then(|v| v.as_str())
489                        == Some("https://opencode.ai/config.json"))
490        }
491        None => false,
492    };
493
494    if essentially_empty {
495        std::fs::remove_file(&path).map_err(|e| {
496            crate::error::SqzError::Other(format!(
497                "failed to remove {}: {e}",
498                path.display()
499            ))
500        })?;
501        return Ok(Some((path, true)));
502    }
503
504    // Otherwise write back the pruned config. This loses any comments
505    // a `.jsonc` had; the caller should surface that fact to the user.
506    let updated = serde_json::to_string_pretty(&config).map_err(|e| {
507        crate::error::SqzError::Other(format!("failed to serialize config: {e}"))
508    })?;
509    std::fs::write(&path, format!("{updated}\n")).map_err(|e| {
510        crate::error::SqzError::Other(format!(
511            "failed to write {}: {e}",
512            path.display()
513        ))
514    })?;
515    Ok(Some((path, true)))
516}
517
518/// Return `true` if `command` has already been wrapped by an earlier sqz
519/// hook pass (or otherwise contains an sqz invocation we should skip).
520/// Used by `process_opencode_hook` and the equivalent TS guard in
521/// `generate_opencode_plugin` to prevent double-wrapping.
522///
523/// Checks for any of:
524/// - case-insensitive `sqz_cmd=` (prior-wrap prefix)
525/// - case-insensitive `sqz compress` (prior-wrap tail)
526/// - case-insensitive `| sqz ` or `| sqz\t` (any sqz subcommand pipe)
527/// - a leading `SQZ_*=...` env assignment
528/// - the base command itself is `sqz`/`sqz-mcp` (running sqz directly)
529fn is_already_wrapped(command: &str) -> bool {
530    let lowered = command.to_ascii_lowercase();
531    if lowered.contains("sqz_cmd=") {
532        return true;
533    }
534    if lowered.contains("sqz compress") {
535        return true;
536    }
537    if lowered.contains("| sqz ") || lowered.contains("| sqz\t") {
538        return true;
539    }
540    // Leading `SQZ_*=...` assignment.
541    let trimmed = command.trim_start();
542    if let Some(eq_idx) = trimmed.find('=') {
543        let name = &trimmed[..eq_idx];
544        if name.starts_with("SQZ_")
545            && !name.is_empty()
546            && name
547                .chars()
548                .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
549        {
550            return true;
551        }
552    }
553    // Running sqz or sqz-mcp directly (e.g. `sqz stats`, `sqz-mcp --help`).
554    let base = extract_base_cmd(command);
555    if base == "sqz" || base == "sqz-mcp" || base == "sqz.exe" {
556        return true;
557    }
558    false
559}
560
561/// Extract the base command name from a shell command string, skipping any
562/// leading `VAR=value` env-var assignments. Mirrors `extractBaseCmd` in the
563/// TS plugin — without this, a command like
564/// `FOO=bar BAZ=qux make test` would pick `FOO=bar` as the base, which is
565/// nonsense (and caused the recursive `SQZ_CMD=SQZ_CMD=...` reported as a
566/// follow-up to issue #5).
567fn extract_base_cmd(command: &str) -> &str {
568    for tok in command.split_whitespace() {
569        if is_env_assignment(tok) {
570            continue;
571        }
572        return tok.rsplit('/').next().unwrap_or("unknown");
573    }
574    "unknown"
575}
576
577/// Return `true` if `token` has the shape `NAME=VALUE` where `NAME` is a
578/// valid env-var identifier (letters/digits/underscores, starting with a
579/// letter or underscore). Empty token → `false`.
580fn is_env_assignment(token: &str) -> bool {
581    let eq = match token.find('=') {
582        Some(i) => i,
583        None => return false,
584    };
585    if eq == 0 {
586        return false;
587    }
588    let name = &token[..eq];
589    let mut chars = name.chars();
590    match chars.next() {
591        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
592        _ => return false,
593    }
594    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
595}
596
597/// Process an OpenCode `tool.execute.before` hook invocation.
598///
599/// OpenCode's hook format differs from Claude Code / Cursor:
600/// - Input: `{ "tool": "bash", "sessionID": "...", "callID": "..." }`
601/// - Args:  `{ "command": "git status" }`
602///
603/// The hook receives both `input` and `output` (args) as separate objects,
604/// but when invoked via CLI (`sqz hook opencode`), we receive a combined
605/// JSON with both fields.
606pub fn process_opencode_hook(input: &str) -> Result<String> {
607    let parsed: serde_json::Value = serde_json::from_str(input)
608        .map_err(|e| crate::error::SqzError::Other(format!("opencode hook: invalid JSON: {e}")))?;
609
610    let tool = parsed
611        .get("tool")
612        .or_else(|| parsed.get("toolName"))
613        .or_else(|| parsed.get("tool_name"))
614        .and_then(|v| v.as_str())
615        .unwrap_or("");
616
617    // Only intercept shell tool calls
618    if !matches!(
619        tool.to_lowercase().as_str(),
620        "bash" | "shell" | "terminal" | "run_shell_command"
621    ) {
622        return Ok(input.to_string());
623    }
624
625    // OpenCode puts args in a separate "args" field or in "toolCall"
626    let command = parsed
627        .get("args")
628        .or_else(|| parsed.get("toolCall"))
629        .or_else(|| parsed.get("tool_input"))
630        .and_then(|v| v.get("command"))
631        .and_then(|v| v.as_str())
632        .unwrap_or("");
633
634    if command.is_empty() || is_already_wrapped(command) {
635        return Ok(input.to_string());
636    }
637
638    // Determine the base command name. Skip leading VAR=VALUE assignments
639    // so an operator-prefixed command like `FOO=bar make test` still picks
640    // `make` as the base instead of `FOO=bar`.
641    let base = extract_base_cmd(command);
642
643    if matches!(
644        base,
645        "vim" | "vi" | "nano" | "emacs" | "less" | "more" | "top" | "htop"
646            | "ssh" | "python" | "python3" | "node" | "irb" | "ghci"
647            | "psql" | "mysql" | "sqlite3" | "mongo" | "redis-cli"
648    ) || command.contains("--watch")
649        || command.contains("run dev")
650        || command.contains("run start")
651        || command.contains("run serve")
652    {
653        return Ok(input.to_string());
654    }
655
656    // Rewrite the command
657    let base_cmd = base;
658
659    let escaped_base = if base_cmd
660        .chars()
661        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
662    {
663        base_cmd.to_string()
664    } else {
665        format!("'{}'", base_cmd.replace('\'', "'\\''"))
666    };
667
668    let rewritten = format!(
669        "SQZ_CMD={} {} 2>&1 | sqz compress",
670        escaped_base, command
671    );
672
673    // Output in the format OpenCode expects (same as Claude Code for CLI path)
674    let output = serde_json::json!({
675        "decision": "approve",
676        "reason": "sqz: command output will be compressed for token savings",
677        "updatedInput": {
678            "command": rewritten
679        },
680        "args": {
681            "command": rewritten
682        }
683    });
684
685    serde_json::to_string(&output)
686        .map_err(|e| crate::error::SqzError::Other(format!("opencode hook: serialize error: {e}")))
687}
688
689// ── Tests ─────────────────────────────────────────────────────────────────
690
691#[cfg(test)]
692mod tests {
693    use super::*;
694
695    #[test]
696    fn test_generate_opencode_plugin_contains_sqz_path() {
697        let content = generate_opencode_plugin("/usr/local/bin/sqz");
698        assert!(content.contains("/usr/local/bin/sqz"));
699        assert!(content.contains("SqzPlugin"));
700        assert!(content.contains("tool.execute.before"));
701    }
702
703    #[test]
704    fn test_generate_opencode_plugin_windows_path_escaped() {
705        // Issue #2: Windows paths embedded in the TS string literal must
706        // have backslashes escaped. Before the fix, raw backslashes were
707        // interpreted as JS escape sequences (\U, \S, \b) producing an
708        // invalid or silently-wrong SQZ_PATH.
709        let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
710        let content = generate_opencode_plugin(windows_path);
711        // The string literal in the generated TS should contain the
712        // path with doubled backslashes so that the runtime JS string
713        // value equals the original path.
714        assert!(
715            content.contains(r#"const SQZ_PATH = "C:\\Users\\SqzUser\\.cargo\\bin\\sqz.exe""#),
716            "expected JS-escaped path in plugin — got:\n{content}"
717        );
718        // And must NOT contain an unescaped backslash-sequence like \U
719        // (which JS would interpret as a unicode escape and then fail).
720        assert!(
721            !content.contains(r#"const SQZ_PATH = "C:\U"#),
722            "plugin must not contain unescaped backslashes in the string literal"
723        );
724    }
725
726    #[test]
727    fn test_generate_opencode_plugin_has_interactive_check() {
728        let content = generate_opencode_plugin("sqz");
729        assert!(content.contains("isInteractive"));
730        assert!(content.contains("vim"));
731        assert!(content.contains("--watch"));
732    }
733
734    // Note: the older `test_generate_opencode_plugin_has_sqz_guard` was
735    // replaced by `test_generate_opencode_plugin_has_double_wrap_guard`
736    // (defined further below). The old assertion codified a too-broad
737    // guard (`cmd.includes("sqz")`) that the runaway-prefix fix had to
738    // tighten — keeping it would pin the bug in place.
739
740    #[test]
741    fn test_process_opencode_hook_rewrites_bash() {
742        let input = r#"{"tool":"bash","args":{"command":"git status"}}"#;
743        let result = process_opencode_hook(input).unwrap();
744        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
745        assert_eq!(parsed["decision"].as_str().unwrap(), "approve");
746        let cmd = parsed["args"]["command"].as_str().unwrap();
747        assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
748        assert!(cmd.contains("git status"), "should preserve original: {cmd}");
749        assert!(cmd.contains("SQZ_CMD=git"), "should set SQZ_CMD: {cmd}");
750    }
751
752    #[test]
753    fn test_process_opencode_hook_passes_non_shell() {
754        let input = r#"{"tool":"read_file","args":{"path":"file.txt"}}"#;
755        let result = process_opencode_hook(input).unwrap();
756        assert_eq!(result, input, "non-shell tools should pass through");
757    }
758
759    #[test]
760    fn test_process_opencode_hook_skips_sqz_commands() {
761        let input = r#"{"tool":"bash","args":{"command":"sqz stats"}}"#;
762        let result = process_opencode_hook(input).unwrap();
763        assert_eq!(result, input, "sqz commands should not be double-wrapped");
764    }
765
766    #[test]
767    fn test_process_opencode_hook_skips_interactive() {
768        let input = r#"{"tool":"bash","args":{"command":"vim file.txt"}}"#;
769        let result = process_opencode_hook(input).unwrap();
770        assert_eq!(result, input, "interactive commands should pass through");
771    }
772
773    #[test]
774    fn test_process_opencode_hook_skips_watch() {
775        let input = r#"{"tool":"bash","args":{"command":"npm run dev --watch"}}"#;
776        let result = process_opencode_hook(input).unwrap();
777        assert_eq!(result, input, "watch mode should pass through");
778    }
779
780    #[test]
781    fn test_process_opencode_hook_invalid_json() {
782        let result = process_opencode_hook("not json");
783        assert!(result.is_err());
784    }
785
786    #[test]
787    fn test_process_opencode_hook_empty_command() {
788        let input = r#"{"tool":"bash","args":{"command":""}}"#;
789        let result = process_opencode_hook(input).unwrap();
790        assert_eq!(result, input);
791    }
792
793    #[test]
794    fn test_process_opencode_hook_run_shell_command() {
795        let input = r#"{"tool":"run_shell_command","args":{"command":"ls -la"}}"#;
796        let result = process_opencode_hook(input).unwrap();
797        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
798        let cmd = parsed["args"]["command"].as_str().unwrap();
799        assert!(cmd.contains("sqz compress"));
800    }
801
802    #[test]
803    fn test_install_opencode_plugin_creates_file() {
804        let dir = tempfile::tempdir().unwrap();
805        // Override HOME to use temp dir
806        std::env::set_var("HOME", dir.path());
807        let result = install_opencode_plugin("sqz");
808        assert!(result.is_ok());
809        // Plugin should be created at ~/.config/opencode/plugins/sqz.ts
810        let plugin_path = dir
811            .path()
812            .join(".config/opencode/plugins/sqz.ts");
813        assert!(plugin_path.exists(), "plugin file should exist");
814        let content = std::fs::read_to_string(&plugin_path).unwrap();
815        assert!(content.contains("SqzPlugin"));
816    }
817
818    #[test]
819    fn test_update_opencode_config_creates_new() {
820        let dir = tempfile::tempdir().unwrap();
821        let result = update_opencode_config(dir.path()).unwrap();
822        assert!(result, "should create new config");
823        let config_path = dir.path().join("opencode.json");
824        assert!(config_path.exists());
825        let content = std::fs::read_to_string(&config_path).unwrap();
826        assert!(content.contains("\"sqz\""));
827        assert!(content.contains("sqz-mcp"));
828    }
829
830    #[test]
831    fn test_update_opencode_config_adds_to_existing() {
832        let dir = tempfile::tempdir().unwrap();
833        let config_path = dir.path().join("opencode.json");
834        std::fs::write(
835            &config_path,
836            r#"{"$schema":"https://opencode.ai/config.json","plugin":["other"]}"#,
837        )
838        .unwrap();
839
840        let result = update_opencode_config(dir.path()).unwrap();
841        assert!(result, "should update existing config");
842        let content = std::fs::read_to_string(&config_path).unwrap();
843        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
844        let plugins = parsed["plugin"].as_array().unwrap();
845        assert!(plugins.iter().any(|v| v.as_str() == Some("sqz")));
846        assert!(plugins.iter().any(|v| v.as_str() == Some("other")));
847    }
848
849    #[test]
850    fn test_update_opencode_config_skips_if_present() {
851        let dir = tempfile::tempdir().unwrap();
852        let config_path = dir.path().join("opencode.json");
853        // A complete sqz install has BOTH the plugin entry and the MCP
854        // server entry. Writing only `plugin[]` leaves the MCP server
855        // unregistered; the updated merger considers this incomplete
856        // and will add the missing `mcp.sqz` key. A truly idempotent
857        // "nothing to do" state requires both pieces to be present.
858        std::fs::write(
859            &config_path,
860            r#"{
861  "plugin": ["sqz"],
862  "mcp": {
863    "sqz": {
864      "type": "local",
865      "command": ["sqz-mcp", "--transport", "stdio"]
866    }
867  }
868}"#,
869        )
870        .unwrap();
871
872        let result = update_opencode_config(dir.path()).unwrap();
873        assert!(
874            !result,
875            "complete install (plugin + mcp.sqz) must be idempotent"
876        );
877    }
878
879    /// Companion to the above: when only `plugin[\"sqz\"]` is present
880    /// the merger must add the missing `mcp.sqz` entry — before the
881    /// issue #6 fix the updater only ever touched the plugin array,
882    /// leaving MCP registration to chance.
883    #[test]
884    fn test_update_opencode_config_adds_missing_mcp_entry() {
885        let dir = tempfile::tempdir().unwrap();
886        let config_path = dir.path().join("opencode.json");
887        std::fs::write(&config_path, r#"{"plugin":["sqz"]}"#).unwrap();
888
889        let changed = update_opencode_config(dir.path()).unwrap();
890        assert!(changed, "must report that mcp.sqz was added");
891
892        let after = std::fs::read_to_string(&config_path).unwrap();
893        let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
894        assert_eq!(
895            parsed["mcp"]["sqz"]["type"].as_str(),
896            Some("local"),
897            "mcp.sqz must be populated with the default server entry"
898        );
899    }
900
901    // ── Issue #5 follow-up: runaway SQZ_CMD= prefix ───────────────────
902
903    /// Regression for the runaway-prefix report on issue #5.
904    ///
905    /// The user observed `SQZ_CMD=SQZ_CMD=ddev SQZ_CMD=ddev ddev exec ...`
906    /// in OpenCode's output — the plugin/hook wrapped a command that had
907    /// already been wrapped by a prior pass. Before the fix,
908    /// `process_opencode_hook`'s guard was only `command.contains("sqz")`
909    /// which missed the uppercase `SQZ_CMD=` prefix and let the wrap
910    /// accumulate.
911    #[test]
912    fn test_process_opencode_hook_skips_already_wrapped_sqz_cmd_prefix() {
913        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"}}"#;
914        let result = process_opencode_hook(input).unwrap();
915        assert_eq!(
916            result, input,
917            "already-wrapped command must pass through unchanged; \
918             otherwise each pass accumulates another SQZ_CMD= prefix"
919        );
920    }
921
922    /// Guard must be case-insensitive: `SQZ_CMD=` contains no lowercase
923    /// `sqz` and the old `command.contains("sqz")` check missed it.
924    #[test]
925    fn test_process_opencode_hook_guard_is_case_insensitive() {
926        let input = r#"{"tool":"bash","args":{"command":"SQZ_CMD=git git status"}}"#;
927        let result = process_opencode_hook(input).unwrap();
928        assert_eq!(
929            result, input,
930            "uppercase SQZ_CMD= prefix must short-circuit the wrap"
931        );
932    }
933
934    /// When a user command begins with legitimate env-var assignments
935    /// (e.g. `FOO=bar make test`) the base command should be `make`,
936    /// not `FOO=bar`. The old implementation picked `FOO=bar` and
937    /// produced `SQZ_CMD=FOO=bar` wraps.
938    #[test]
939    fn test_process_opencode_hook_skips_leading_env_assignments_for_base() {
940        let input = r#"{"tool":"bash","args":{"command":"FOO=bar BAZ=qux make test"}}"#;
941        let result = process_opencode_hook(input).unwrap();
942        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
943        let cmd = parsed["args"]["command"].as_str().unwrap();
944        assert!(
945            cmd.contains("SQZ_CMD=make"),
946            "base command must be `make`, not `FOO=bar`; got: {cmd}"
947        );
948        assert!(
949            cmd.contains("FOO=bar BAZ=qux make test"),
950            "original command must be preserved: {cmd}"
951        );
952    }
953
954    /// Running sqz directly (e.g. `sqz stats`) must not be wrapped.
955    #[test]
956    fn test_process_opencode_hook_skips_bare_sqz_invocation() {
957        for cmd in ["sqz stats", "sqz gain", "/usr/local/bin/sqz compress"] {
958            let input = format!(
959                r#"{{"tool":"bash","args":{{"command":"{cmd}"}}}}"#
960            );
961            let result = process_opencode_hook(&input).unwrap();
962            assert_eq!(
963                result, input,
964                "sqz-invoking command `{cmd}` must not be rewrapped"
965            );
966        }
967    }
968
969    /// The generated TypeScript plugin must carry the same hardened
970    /// guard the Rust hook has. We can't run the TS from Rust tests,
971    /// but we can assert the generated source contains the key markers.
972    #[test]
973    fn test_generate_opencode_plugin_has_double_wrap_guard() {
974        let content = generate_opencode_plugin("sqz");
975        assert!(
976            content.contains("function isAlreadyWrapped(cmd: string): boolean"),
977            "generated plugin must define isAlreadyWrapped helper"
978        );
979        assert!(
980            content.contains(r#"lowered.includes("sqz_cmd=")"#),
981            "plugin must check for the SQZ_CMD= prior-wrap prefix"
982        );
983        assert!(
984            content.contains(r#"lowered.includes("sqz compress")"#),
985            "plugin must check for the `sqz compress` prior-wrap tail"
986        );
987        assert!(
988            content.contains("isAlreadyWrapped(cmd)"),
989            "plugin hook body must call isAlreadyWrapped on the command"
990        );
991        assert!(
992            content.contains("function extractBaseCmd(cmd: string): string"),
993            "plugin must define extractBaseCmd that skips env assignments"
994        );
995        assert!(
996            content.contains("extractBaseCmd(cmd)"),
997            "plugin hook body must use extractBaseCmd, not raw split"
998        );
999    }
1000
1001    // ── Unit tests for the helper functions ──────────────────────────
1002
1003    #[test]
1004    fn test_is_already_wrapped_detects_all_marker_shapes() {
1005        assert!(is_already_wrapped("SQZ_CMD=git git status"));
1006        assert!(is_already_wrapped("sqz_cmd=git git status"));
1007        assert!(is_already_wrapped("git status | sqz compress"));
1008        assert!(is_already_wrapped("git status 2>&1 | /path/sqz compress"));
1009        assert!(is_already_wrapped("ls -la | sqz compress-stream"));
1010        assert!(is_already_wrapped("sqz stats"));
1011        assert!(is_already_wrapped("/usr/local/bin/sqz gain"));
1012        assert!(is_already_wrapped("SQZ_FOO=bar cmd"));
1013        assert!(!is_already_wrapped("git status"));
1014        assert!(!is_already_wrapped("grep sqz logfile.txt"));
1015        assert!(!is_already_wrapped("cargo test --package my-sqz-crate"));
1016    }
1017
1018    #[test]
1019    fn test_extract_base_cmd_skips_env_assignments() {
1020        assert_eq!(extract_base_cmd("make test"), "make");
1021        assert_eq!(extract_base_cmd("FOO=bar make test"), "make");
1022        assert_eq!(extract_base_cmd("FOO=bar BAZ=qux make test"), "make");
1023        assert_eq!(extract_base_cmd("/usr/bin/git status"), "git");
1024        assert_eq!(extract_base_cmd(""), "unknown");
1025        assert_eq!(extract_base_cmd("FOO=bar"), "unknown");
1026    }
1027
1028    #[test]
1029    fn test_is_env_assignment() {
1030        assert!(is_env_assignment("FOO=bar"));
1031        assert!(is_env_assignment("FOO="));
1032        assert!(is_env_assignment("_underscore=1"));
1033        assert!(is_env_assignment("MixedCase_1=x"));
1034        assert!(!is_env_assignment("=bar"));
1035        assert!(!is_env_assignment("FOO"));
1036        assert!(!is_env_assignment("--flag=value"));
1037        assert!(!is_env_assignment("123=value"));
1038        assert!(!is_env_assignment("FOO BAR=baz"));
1039    }
1040
1041    // ── Issue #6: opencode.jsonc support ─────────────────────────────
1042
1043    /// Regression for issue #6 (@Icaruk). When a user has
1044    /// `opencode.jsonc` (OpenCode supports both `.json` and `.jsonc`),
1045    /// sqz init must MERGE into it rather than creating a parallel
1046    /// `opencode.json`. Before the fix `find_opencode_config` didn't
1047    /// exist and `update_opencode_config` was hardcoded to the `.json`
1048    /// path, so users with `.jsonc` ended up with two configs.
1049    #[test]
1050    fn test_update_merges_into_existing_jsonc() {
1051        let dir = tempfile::tempdir().unwrap();
1052        let jsonc = dir.path().join("opencode.jsonc");
1053        std::fs::write(
1054            &jsonc,
1055            r#"{
1056  // user's own config with a comment
1057  "$schema": "https://opencode.ai/config.json",
1058  "model": "anthropic/claude-sonnet-4-5",
1059  /* another comment */
1060  "plugin": ["other-plugin"]
1061}
1062"#,
1063        )
1064        .unwrap();
1065
1066        let changed = update_opencode_config(dir.path()).unwrap();
1067        assert!(changed, "must merge sqz entries into the existing .jsonc");
1068
1069        // The .jsonc file is the one we wrote back to — NOT a new .json.
1070        assert!(jsonc.exists(), "original .jsonc must still exist");
1071        assert!(
1072            !dir.path().join("opencode.json").exists(),
1073            "must not create a parallel opencode.json alongside .jsonc \
1074             (that's the issue #6 bug)"
1075        );
1076
1077        let after = std::fs::read_to_string(&jsonc).unwrap();
1078        let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1079        let plugins = parsed["plugin"].as_array().unwrap();
1080        assert!(
1081            plugins.iter().any(|v| v.as_str() == Some("sqz")),
1082            "plugin[] must contain sqz after merge"
1083        );
1084        assert!(
1085            plugins.iter().any(|v| v.as_str() == Some("other-plugin")),
1086            "pre-existing plugin entries must be preserved"
1087        );
1088        assert_eq!(
1089            parsed["model"].as_str(),
1090            Some("anthropic/claude-sonnet-4-5"),
1091            "unrelated user keys must survive the merge"
1092        );
1093        assert_eq!(
1094            parsed["mcp"]["sqz"]["type"].as_str(),
1095            Some("local"),
1096            "mcp.sqz must be registered"
1097        );
1098    }
1099
1100    /// Detailed variant: comments_lost must be reported when we
1101    /// rewrite a `.jsonc` that had comments. Callers (sqz init) use
1102    /// this to warn the user.
1103    #[test]
1104    fn test_update_opencode_config_detailed_reports_comments_lost() {
1105        let dir = tempfile::tempdir().unwrap();
1106        let jsonc = dir.path().join("opencode.jsonc");
1107        std::fs::write(
1108            &jsonc,
1109            r#"{
1110  // comment to be dropped
1111  "plugin": ["other"]
1112}
1113"#,
1114        )
1115        .unwrap();
1116
1117        let (changed, comments_lost) =
1118            update_opencode_config_detailed(dir.path()).unwrap();
1119        assert!(changed);
1120        assert!(
1121            comments_lost,
1122            "merger must report that comments were dropped from .jsonc"
1123        );
1124    }
1125
1126    /// When no existing config is present, we still default to
1127    /// creating `opencode.json` (not `.jsonc`). The `.jsonc` variant
1128    /// is the user's choice to make; we don't force it.
1129    #[test]
1130    fn test_update_creates_plain_json_when_nothing_exists() {
1131        let dir = tempfile::tempdir().unwrap();
1132        update_opencode_config(dir.path()).unwrap();
1133        assert!(dir.path().join("opencode.json").exists());
1134        assert!(!dir.path().join("opencode.jsonc").exists());
1135    }
1136
1137    /// `find_opencode_config` prefers `.jsonc` when both exist.
1138    #[test]
1139    fn test_find_opencode_config_prefers_jsonc() {
1140        let dir = tempfile::tempdir().unwrap();
1141        std::fs::write(dir.path().join("opencode.json"), "{}").unwrap();
1142        std::fs::write(dir.path().join("opencode.jsonc"), "{}").unwrap();
1143        let found = find_opencode_config(dir.path()).unwrap();
1144        assert_eq!(
1145            found.file_name().unwrap(),
1146            "opencode.jsonc",
1147            "must prefer the .jsonc variant when both exist — the user \
1148             is maintaining .jsonc for its comment support"
1149        );
1150    }
1151
1152    #[test]
1153    fn test_find_opencode_config_returns_none_when_missing() {
1154        let dir = tempfile::tempdir().unwrap();
1155        assert!(find_opencode_config(dir.path()).is_none());
1156    }
1157
1158    #[test]
1159    fn test_opencode_config_has_comments_detects_jsonc_comments() {
1160        let dir = tempfile::tempdir().unwrap();
1161        std::fs::write(
1162            dir.path().join("opencode.jsonc"),
1163            "// a line comment\n{\"plugin\":[]}\n",
1164        )
1165        .unwrap();
1166        assert!(opencode_config_has_comments(dir.path()));
1167    }
1168
1169    #[test]
1170    fn test_opencode_config_has_comments_ignores_plain_json() {
1171        let dir = tempfile::tempdir().unwrap();
1172        // The fake `//` is inside a JSON string — NOT a comment.
1173        std::fs::write(
1174            dir.path().join("opencode.json"),
1175            r#"{"url":"http://example.com"}"#,
1176        )
1177        .unwrap();
1178        assert!(!opencode_config_has_comments(dir.path()));
1179    }
1180
1181    // ── JSONC comment stripper ───────────────────────────────────────
1182
1183    #[test]
1184    fn test_strip_jsonc_comments_removes_line_comments() {
1185        let src = "{\n  // leading comment\n  \"a\": 1 // trailing\n}";
1186        let stripped = strip_jsonc_comments(src);
1187        assert!(!stripped.contains("leading comment"));
1188        assert!(!stripped.contains("trailing"));
1189        let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1190        assert_eq!(parsed["a"], 1);
1191    }
1192
1193    #[test]
1194    fn test_strip_jsonc_comments_removes_block_comments() {
1195        let src = "{\n  /* block\n     comment */\n  \"a\": 1\n}";
1196        let stripped = strip_jsonc_comments(src);
1197        assert!(!stripped.contains("block"));
1198        let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1199        assert_eq!(parsed["a"], 1);
1200    }
1201
1202    #[test]
1203    fn test_strip_jsonc_comments_preserves_strings() {
1204        // The `//` inside the URL must NOT be treated as a line comment,
1205        // and the `/* ... */` pattern inside the string must NOT be
1206        // treated as a block comment. This is the classic JSONC parser
1207        // bug — we want to prove our stripper is string-aware.
1208        let src = r#"{"url": "http://example.com", "re": "/* not a comment */"}"#;
1209        let stripped = strip_jsonc_comments(src);
1210        let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1211        assert_eq!(parsed["url"], "http://example.com");
1212        assert_eq!(parsed["re"], "/* not a comment */");
1213    }
1214
1215    #[test]
1216    fn test_strip_jsonc_comments_preserves_escaped_quote_in_string() {
1217        let src = r#"{"s": "a\"//b"}"#;
1218        let stripped = strip_jsonc_comments(src);
1219        let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1220        assert_eq!(parsed["s"], r#"a"//b"#);
1221    }
1222
1223    #[test]
1224    fn test_strip_jsonc_comments_tolerates_unterminated_block() {
1225        // We don't want to panic or infinite-loop on malformed input.
1226        let src = "{\"a\":1 /* never ends";
1227        let _ = strip_jsonc_comments(src); // should return without panic
1228    }
1229
1230    // ── Surgical uninstall ───────────────────────────────────────────
1231
1232    /// Regression for the uninstall-wipes-user-config concern tied to
1233    /// issue #6. Before this change `sqz uninstall` called
1234    /// `remove_file` on the entire `opencode.json`, destroying any
1235    /// user config that had been merged with sqz's entries. The
1236    /// surgical helper keeps the file, removes only sqz's keys.
1237    #[test]
1238    fn test_remove_sqz_preserves_other_user_config() {
1239        let dir = tempfile::tempdir().unwrap();
1240        let config = dir.path().join("opencode.json");
1241        std::fs::write(
1242            &config,
1243            r#"{
1244  "$schema": "https://opencode.ai/config.json",
1245  "model": "anthropic/claude-sonnet-4-5",
1246  "plugin": ["other-plugin", "sqz"],
1247  "mcp": {
1248    "sqz": { "type": "local", "command": ["sqz-mcp"] },
1249    "jira": { "type": "remote", "url": "https://jira.example.com/mcp" }
1250  }
1251}
1252"#,
1253        )
1254        .unwrap();
1255
1256        let (path, changed) =
1257            remove_sqz_from_opencode_config(dir.path()).unwrap().unwrap();
1258        assert_eq!(path, config);
1259        assert!(changed, "must report that sqz entries were removed");
1260        assert!(
1261            config.exists(),
1262            "file must NOT be deleted — only sqz's entries removed"
1263        );
1264
1265        let after = std::fs::read_to_string(&config).unwrap();
1266        let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1267        let plugins = parsed["plugin"].as_array().unwrap();
1268        assert!(!plugins.iter().any(|v| v.as_str() == Some("sqz")));
1269        assert!(plugins.iter().any(|v| v.as_str() == Some("other-plugin")));
1270        let mcp = parsed["mcp"].as_object().unwrap();
1271        assert!(!mcp.contains_key("sqz"), "mcp.sqz must be gone");
1272        assert!(mcp.contains_key("jira"), "mcp.jira must survive");
1273        assert_eq!(
1274            parsed["model"].as_str(),
1275            Some("anthropic/claude-sonnet-4-5"),
1276            "unrelated keys must survive"
1277        );
1278    }
1279
1280    /// If the file was CREATED by sqz (just $schema + sqz entries),
1281    /// removing sqz's entries should delete the whole file since
1282    /// there's nothing else the user wanted to keep.
1283    #[test]
1284    fn test_remove_sqz_deletes_file_when_nothing_else_remains() {
1285        let dir = tempfile::tempdir().unwrap();
1286        let config = dir.path().join("opencode.json");
1287        // This is exactly the shape sqz writes on fresh install.
1288        std::fs::write(
1289            &config,
1290            r#"{
1291  "$schema": "https://opencode.ai/config.json",
1292  "mcp": {
1293    "sqz": { "type": "local", "command": ["sqz-mcp", "--transport", "stdio"] }
1294  },
1295  "plugin": ["sqz"]
1296}
1297"#,
1298        )
1299        .unwrap();
1300
1301        let (_, changed) =
1302            remove_sqz_from_opencode_config(dir.path()).unwrap().unwrap();
1303        assert!(changed);
1304        assert!(
1305            !config.exists(),
1306            "file with only $schema + sqz entries must be removed"
1307        );
1308    }
1309
1310    /// When there's nothing to uninstall (no config present), the
1311    /// surgical helper returns None rather than erroring.
1312    #[test]
1313    fn test_remove_sqz_returns_none_when_config_missing() {
1314        let dir = tempfile::tempdir().unwrap();
1315        let result = remove_sqz_from_opencode_config(dir.path()).unwrap();
1316        assert!(result.is_none());
1317    }
1318
1319    /// Surgical uninstall against a .jsonc file: strips comments on
1320    /// read, writes back as plain JSON (to the same .jsonc path).
1321    #[test]
1322    fn test_remove_sqz_from_jsonc_drops_comments() {
1323        let dir = tempfile::tempdir().unwrap();
1324        let jsonc = dir.path().join("opencode.jsonc");
1325        std::fs::write(
1326            &jsonc,
1327            r#"{
1328  // user's comment
1329  "model": "x",
1330  "plugin": ["sqz", "other"]
1331}
1332"#,
1333        )
1334        .unwrap();
1335
1336        let (path, changed) =
1337            remove_sqz_from_opencode_config(dir.path()).unwrap().unwrap();
1338        assert_eq!(path, jsonc);
1339        assert!(changed);
1340        assert!(path.exists(), "jsonc file kept because `model` and `other` remain");
1341
1342        let after = std::fs::read_to_string(&jsonc).unwrap();
1343        assert!(
1344            !after.contains("// user's comment"),
1345            "comments are dropped by the serde_json round-trip; \
1346             documented in update_opencode_config_detailed"
1347        );
1348        let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1349        let plugins = parsed["plugin"].as_array().unwrap();
1350        assert_eq!(plugins.len(), 1);
1351        assert_eq!(plugins[0], "other");
1352    }
1353}