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.
25///
26/// ## Plugin shape (issue #10 comment by @itguy327)
27///
28/// OpenCode's V1 plugin loader (packages/opencode/src/plugin/shared.ts,
29/// function `readV1Plugin` + `resolvePluginId`) requires file-source
30/// plugins to default-export `{ id: string, server: Plugin }`. Without
31/// an `id`, OpenCode's loader throws "Path plugin ... must export id"
32/// — but the loader is lenient and falls through to the "legacy"
33/// path (`getLegacyPlugins`), which iterates all exports looking for
34/// a factory function. That fallback works but gives the plugin no
35/// human-readable name, so OpenCode's UI displays the raw
36/// `file:///...` spec instead of "sqz". Reported by @itguy327 on
37/// issue #10.
38///
39/// The fix is a dual-export shape:
40///
41/// 1. **Default export** — V1 object `{ id: "sqz", server: factory }`
42///    so the modern loader identifies the plugin by name.
43/// 2. **Named export** `SqzPlugin` — legacy factory fallback. Old
44///    OpenCode versions that don't know about V1 walk
45///    `Object.values(mod)`; default export dedups against the named
46///    export via `Set` identity in `getLegacyPlugins` so the factory
47///    fires exactly once either way.
48///
49/// Concrete verification that the dedup holds: the `seen` Set in
50/// `getLegacyPlugins` uses identity, and we assign the same factory
51/// reference to both. Also verified end-to-end by loading the
52/// generated file under the V1 loader and asserting only one hook
53/// registration.
54pub fn generate_opencode_plugin(sqz_path: &str) -> String {
55    // Escape for embedding in a double-quoted TypeScript string literal.
56    // On Windows, sqz_path contains backslashes that must be escaped —
57    // same reason we escape hook JSON in generate_hook_configs. See issue #2.
58    let sqz_path = crate::tool_hooks::json_escape_string_value(sqz_path);
59    format!(
60        r#"/**
61 * sqz — OpenCode plugin for transparent context compression.
62 *
63 * Intercepts shell commands and pipes output through sqz for token savings.
64 * Install: copy to ~/.config/opencode/plugins/sqz.ts
65 * Discovery is automatic — no opencode.json entry needed (and in fact
66 * including one causes the plugin to load twice, per issue #10).
67 */
68
69const SqzPluginFactory = async (ctx: any) => {{
70  const SQZ_PATH = "{sqz_path}";
71
72  // Commands that should not be intercepted.
73  const INTERACTIVE = new Set([
74    "vim", "vi", "nano", "emacs", "less", "more", "top", "htop",
75    "ssh", "python", "python3", "node", "irb", "ghci",
76    "psql", "mysql", "sqlite3", "mongo", "redis-cli",
77  ]);
78
79  function isInteractive(cmd: string): boolean {{
80    const base = cmd.split(/\s+/)[0]?.split("/").pop() ?? "";
81    if (INTERACTIVE.has(base)) return true;
82    if (cmd.includes("--watch") || cmd.includes("run dev") ||
83        cmd.includes("run start") || cmd.includes("run serve")) return true;
84    return false;
85  }}
86
87  // Don't rewrite commands containing shell operators. Appending
88  // `2>&1 | sqz compress` to a heredoc, compound command, existing
89  // pipe, or redirect corrupts the command. Issue #22: heredoc
90  // terminators like `EOF 2>&1 | sqz compress --cmd git` are not
91  // valid delimiters.
92  function hasShellOperators(cmd: string): boolean {{
93    if (cmd.includes("&&") || cmd.includes("||") || cmd.includes(";")) return true;
94    if (cmd.includes(">") || cmd.includes("<")) return true;
95    if (cmd.includes("|")) return true;
96    if (cmd.includes("<<")) return true;
97    if (cmd.includes("$(") || cmd.includes("`")) return true;
98    return false;
99  }}
100
101  function shouldIntercept(tool: string): boolean {{
102    return ["bash", "shell", "terminal", "run_shell_command"].includes(tool.toLowerCase());
103  }}
104
105  // Detect that a command has already been wrapped by sqz. Before this
106  // guard was in place OpenCode could call the hook twice on the same
107  // command (for retried tool calls, or when a previous rewrite was
108  // echoed back to the agent and the agent re-submitted it) and each
109  // pass would prepend another `SQZ_CMD=$base` prefix, producing monsters
110  // like `SQZ_CMD=SQZ_CMD=ddev SQZ_CMD=ddev ddev exec ...` (reported as
111  // a follow-up to issue #5). We skip if any of these markers appear:
112  //   * the case-insensitive substring "sqz_cmd=" or "sqz compress"
113  //     (covers the tail of prior wraps regardless of case; SQZ_CMD= is
114  //     legacy pre-issue-#10 but still valid in POSIX shell hooks)
115  //   * a leading `VAR=` assignment that starts with SQZ_
116  //     (defensive catch-all for exotic wrap variants)
117  //   * the base command itself is sqz or sqz-mcp (running sqz directly
118  //     — compressing sqz's own output is pointless and causes loops)
119  function isAlreadyWrapped(cmd: string): boolean {{
120    const lowered = cmd.toLowerCase();
121    if (lowered.includes("sqz_cmd=")) return true;
122    if (lowered.includes("sqz compress")) return true;
123    if (lowered.includes("| sqz ") || lowered.includes("| sqz\t")) return true;
124    if (/^\s*SQZ_[A-Z0-9_]+=/.test(cmd)) return true;
125    const base = extractBaseCmd(cmd);
126    if (base === "sqz" || base === "sqz-mcp" || base === "sqz.exe") return true;
127    return false;
128  }}
129
130  // Extract the base command name defensively. If the command has
131  // leading env-var assignments (VAR=val VAR2=val2 actual_cmd arg1),
132  // skip past them so the base is `actual_cmd` — not `VAR=val`.
133  function extractBaseCmd(cmd: string): string {{
134    const tokens = cmd.split(/\s+/).filter(t => t.length > 0);
135    for (const tok of tokens) {{
136      // A token is an env assignment if it matches NAME=VALUE where NAME
137      // is a valid env var identifier. Skip it and keep looking.
138      if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tok)) continue;
139      return tok.split("/").pop() ?? "unknown";
140    }}
141    return "unknown";
142  }}
143
144  // Shell-escape a command-name label so it's safe to inline into the
145  // rewritten shell command. Agents occasionally invoke commands via
146  // paths with spaces (`"/my tools/foo" --arg`) and in the LLM
147  // roundtrip that can survive to `extractBaseCmd`'s output. Quote the
148  // label unless it's pure ASCII alphanumeric.
149  function shellEscapeLabel(s: string): string {{
150    if (/^[A-Za-z0-9_.-]+$/.test(s)) return s;
151    return "'" + s.replace(/'/g, "'\\''") + "'";
152  }}
153
154  // A pipe exits with sqz's status, so the command's own status rides
155  // through as a trailer sqz strips and exits with. That form is POSIX;
156  // OpenCode uses a POSIX shell outside Windows (it refuses fish and nu).
157  const POSIX = process.platform !== "win32" &&
158    !/(^|\/)(fish|nu|csh|tcsh|xonsh|elvish)$/.test(process.env.SHELL ?? "");
159
160  function wrap(cmd: string, label: string): string {{
161    if (!POSIX) return `${{cmd}} 2>&1 | ${{SQZ_PATH}} compress --cmd ${{label}}`;
162    return `{{ ( ${{cmd}}\n); printf '\\n__SQZ_EXIT_%d__\\n' "$?"; }} 2>&1 | ${{SQZ_PATH}} compress --cmd ${{label}}`;
163  }}
164
165  return {{
166    "tool.execute.before": async (input: any, output: any) => {{
167      const tool = input.tool ?? "";
168      if (!shouldIntercept(tool)) return;
169
170      const cmd = output.args?.command ?? "";
171      if (!cmd || isAlreadyWrapped(cmd) || isInteractive(cmd) || hasShellOperators(cmd)) return;
172
173      // Rewrite: pipe through `sqz compress --cmd <base>`.
174      //
175      // Issue #10: the previous form was `SQZ_CMD=<base> <cmd> 2>&1 |
176      // <sqz> compress`, which uses sh-specific inline env-var syntax.
177      // On Windows, OpenCode Desktop routes bash-tool commands through
178      // PowerShell (or cmd.exe when $SHELL is unset), and both parse
179      // `SQZ_CMD=cmd` as a command name — raising CommandNotFoundException
180      // and producing zero compression. `--cmd NAME` is a normal CLI
181      // argument, shell-neutral, works in POSIX sh, zsh, fish, PowerShell,
182      // and cmd.exe.
183      const base = extractBaseCmd(cmd);
184      output.args.command = wrap(cmd, shellEscapeLabel(base));
185    }},
186  }};
187}};
188
189// V1 default export — modern OpenCode (post-V1 loader) reads `id` here
190// and displays "sqz" in the plugin list. Without this, OpenCode falls
191// back to the raw `file:///...` spec as the plugin name (@itguy327 on
192// issue #10). `readV1Plugin` in OpenCode's plugin/shared.ts requires
193// file-source plugins to declare an id — otherwise `resolvePluginId`
194// throws.
195export default {{
196  id: "sqz",
197  server: SqzPluginFactory,
198}};
199
200// Legacy named export — pre-V1 OpenCode versions walk Object.values(mod)
201// looking for factory functions. Assigning the same reference as the
202// default export's `.server` means the legacy `seen` Set dedups via
203// identity, so the factory fires exactly once either way. Kept for
204// backward compatibility with OpenCode versions that predate the V1
205// loader (roughly anything before mid-2025).
206export const SqzPlugin = SqzPluginFactory;
207"#
208    )
209}
210
211/// Default path for the OpenCode plugin file.
212pub fn opencode_plugin_path() -> PathBuf {
213    let home = std::env::var("HOME")
214        .or_else(|_| std::env::var("USERPROFILE"))
215        .map(PathBuf::from)
216        .unwrap_or_else(|_| PathBuf::from("."));
217    home.join(".config")
218        .join("opencode")
219        .join("plugins")
220        .join("sqz.ts")
221}
222
223/// Install the OpenCode plugin to `~/.config/opencode/plugins/sqz.ts`.
224///
225/// Always writes the latest generated plugin, overwriting any previous
226/// version. The file is machine-generated (not user-edited), so
227/// overwriting is safe and ensures fixes like the V1 id export and the
228/// --cmd rewrite propagate on re-init. Previously this skipped if the
229/// file existed, which left stale plugins in place after upgrades
230/// (@itguy327 on issue #10: "that odd display issue is still there").
231///
232/// Returns `true` if the file was created or updated, `false` if the
233/// content was already identical (no disk write needed).
234pub fn install_opencode_plugin(sqz_path: &str) -> Result<bool> {
235    install_opencode_plugin_at(&opencode_plugin_path(), sqz_path)
236}
237
238pub(crate) fn install_opencode_plugin_at(plugin_path: &Path, sqz_path: &str) -> Result<bool> {
239    let new_content = generate_opencode_plugin(sqz_path);
240
241    // Skip the write if the file already has identical content.
242    if plugin_path.exists() {
243        if let Ok(existing) = std::fs::read_to_string(&plugin_path) {
244            if existing == new_content {
245                return Ok(false);
246            }
247        }
248    }
249
250    if let Some(parent) = plugin_path.parent() {
251        std::fs::create_dir_all(parent).map_err(|e| {
252            crate::error::SqzError::Other(format!(
253                "failed to create OpenCode plugins dir {}: {e}",
254                parent.display()
255            ))
256        })?;
257    }
258
259    let content = generate_opencode_plugin(sqz_path);
260    std::fs::write(&plugin_path, &content).map_err(|e| {
261        crate::error::SqzError::Other(format!(
262            "failed to write OpenCode plugin to {}: {e}",
263            plugin_path.display()
264        ))
265    })?;
266
267    Ok(true)
268}
269
270/// Locate an existing OpenCode project config. Returns the path to
271/// `opencode.jsonc` if present, else `opencode.json` if present, else
272/// `None`. Prefers `.jsonc` because a user who bothered to write a
273/// comment-annotated config is more invested in it, and sqz must not
274/// silently create a parallel `.json` that would leave the `.jsonc`
275/// looking un-updated (reported in issue #6).
276pub fn find_opencode_config(project_dir: &Path) -> Option<PathBuf> {
277    let jsonc = project_dir.join("opencode.jsonc");
278    if jsonc.exists() {
279        return Some(jsonc);
280    }
281    let json = project_dir.join("opencode.json");
282    if json.exists() {
283        return Some(json);
284    }
285    None
286}
287
288/// Return `true` if the user's OpenCode project config is a `.jsonc`
289/// file that contains comments. Callers use this to decide whether to
290/// warn the user that sqz's upcoming merge will drop those comments
291/// (serde_json round-trips discard them).
292pub fn opencode_config_has_comments(project_dir: &Path) -> bool {
293    let path = match find_opencode_config(project_dir) {
294        Some(p) => p,
295        None => return false,
296    };
297    if path.extension().map(|e| e != "jsonc").unwrap_or(true) {
298        return false;
299    }
300    let content = match std::fs::read_to_string(&path) {
301        Ok(s) => s,
302        Err(_) => return false,
303    };
304    strip_jsonc_comments(&content) != content
305}
306
307/// Strip JSONC-style comments from `src` while preserving string literals
308/// byte-exact. Handles:
309/// - `// line comments` through end-of-line
310/// - `/* block comments */` (non-nested, which matches standard JSONC)
311/// - Escape-aware string parsing so `"//"` inside a string is not stripped
312///
313/// Returns a string suitable for `serde_json::from_str`. Does not
314/// attempt to preserve or round-trip the comments — callers that need
315/// to write the file back must be explicit about losing comments.
316pub fn strip_jsonc_comments(src: &str) -> String {
317    let mut out = String::with_capacity(src.len());
318    let bytes = src.as_bytes();
319    let mut i = 0;
320    let len = bytes.len();
321
322    while i < len {
323        let b = bytes[i];
324
325        // Enter a string literal: copy verbatim until the matching close
326        // quote, honouring backslash escapes.
327        if b == b'"' {
328            out.push('"');
329            i += 1;
330            while i < len {
331                let c = bytes[i];
332                out.push(c as char);
333                if c == b'\\' && i + 1 < len {
334                    // Preserve the escape and the escaped char together.
335                    out.push(bytes[i + 1] as char);
336                    i += 2;
337                    continue;
338                }
339                i += 1;
340                if c == b'"' {
341                    break;
342                }
343            }
344            continue;
345        }
346
347        // Line comment: skip through newline (but keep the newline so
348        // line numbers line up for error messages).
349        if b == b'/' && i + 1 < len && bytes[i + 1] == b'/' {
350            i += 2;
351            while i < len && bytes[i] != b'\n' {
352                i += 1;
353            }
354            continue;
355        }
356
357        // Block comment: skip through `*/`.
358        if b == b'/' && i + 1 < len && bytes[i + 1] == b'*' {
359            i += 2;
360            while i + 1 < len && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
361                // Preserve newlines so line numbers still line up.
362                if bytes[i] == b'\n' {
363                    out.push('\n');
364                }
365                i += 1;
366            }
367            // Skip the terminating `*/` if we found it; tolerate
368            // unterminated comments by exiting the loop.
369            if i + 1 < len {
370                i += 2;
371            }
372            continue;
373        }
374
375        out.push(b as char);
376        i += 1;
377    }
378
379    // Second pass: strip trailing commas before `]` and `}`.
380    // Trailing commas are valid JSONC but invalid JSON. Without this,
381    // serde_json::from_str fails silently and the config merge is
382    // skipped — the root cause of issue #6 ("I see no changes in
383    // opencode.jsonc") when the user's JSONC had trailing commas.
384    strip_trailing_commas(&out)
385}
386
387/// Remove trailing commas before `]` and `}` in a JSON-like string.
388///
389/// Handles whitespace and newlines between the comma and the closing
390/// bracket. String-aware: commas inside quoted strings are never touched.
391fn strip_trailing_commas(src: &str) -> String {
392    let mut out = String::with_capacity(src.len());
393    let bytes = src.as_bytes();
394    let mut i = 0;
395    let len = bytes.len();
396
397    while i < len {
398        let b = bytes[i];
399
400        // Skip over string literals verbatim.
401        if b == b'"' {
402            out.push('"');
403            i += 1;
404            while i < len {
405                let c = bytes[i];
406                out.push(c as char);
407                if c == b'\\' && i + 1 < len {
408                    out.push(bytes[i + 1] as char);
409                    i += 2;
410                    continue;
411                }
412                i += 1;
413                if c == b'"' {
414                    break;
415                }
416            }
417            continue;
418        }
419
420        // When we see a comma, look ahead (skipping whitespace) to see
421        // if the next non-whitespace char is `]` or `}`. If so, drop
422        // the comma but keep the whitespace.
423        if b == b',' {
424            let mut j = i + 1;
425            while j < len && bytes[j].is_ascii_whitespace() {
426                j += 1;
427            }
428            if j < len && (bytes[j] == b']' || bytes[j] == b'}') {
429                // Drop the comma; emit the whitespace and let the
430                // main loop pick up the closing bracket.
431                for k in (i + 1)..j {
432                    out.push(bytes[k] as char);
433                }
434                i = j;
435                continue;
436            }
437        }
438
439        out.push(b as char);
440        i += 1;
441    }
442
443    out
444}
445
446/// Update an existing `opencode.json`/`opencode.jsonc`, or create a
447/// fresh `opencode.json`, so that sqz's plugin and MCP server are
448/// registered. Idempotent.
449///
450/// If a `.jsonc` file exists, it is read with comment-stripping, merged,
451/// and written back WITHOUT the comments — we can't losslessly round-trip
452/// comments through serde_json. The caller is warned via the return
453/// value's second field so `sqz init` can surface the fact.
454///
455/// If both files exist for some reason (OpenCode merges both), the
456/// `.jsonc` is treated as authoritative (per `find_opencode_config`).
457///
458/// Returns `(updated, comments_lost)` where `updated` is true if any
459/// change was written to disk, and `comments_lost` is true if sqz had
460/// to drop comments from a `.jsonc` during the merge.
461pub fn update_opencode_config(project_dir: &Path) -> Result<bool> {
462    let (updated, _) = update_opencode_config_detailed(project_dir)?;
463    Ok(updated)
464}
465
466/// Like `update_opencode_config` but also reports whether comments had
467/// to be dropped from a JSONC file during the merge. Used by the `sqz
468/// init` CLI to print a warning.
469pub fn update_opencode_config_detailed(project_dir: &Path) -> Result<(bool, bool)> {
470    let planned = plan_opencode_config_change(project_dir)?;
471    if !planned.will_change {
472        return Ok((false, false));
473    }
474    // Re-run through the same logic, actually writing this time.
475    apply_opencode_config_change(project_dir, &planned)
476}
477
478/// Dry-run preview of what `update_opencode_config_detailed` would do.
479///
480/// Returns a `PlannedOpencodeChange` describing whether any write would
481/// happen (`will_change`) and whether comments would be lost in the
482/// process (`comments_lost`). Callers that only need the boolean answer
483/// (e.g. the `sqz init` plan builder deciding whether to list OpenCode
484/// in the plan) can check `will_change` directly.
485///
486/// Added after @Icaruk reported on issue #6 that the plan announced an
487/// OpenCode merge on every re-run even when the file was already fully
488/// configured, so users saw "no changes" and assumed the tool was
489/// broken.
490pub fn plan_opencode_config_change(project_dir: &Path) -> Result<PlannedOpencodeChange> {
491    compute_opencode_change(project_dir, /*apply=*/ false).map(|r| r.0)
492}
493
494/// Result of a dry-run over the OpenCode config.
495#[derive(Debug, Clone, PartialEq, Eq)]
496pub struct PlannedOpencodeChange {
497    /// The file sqz would touch (exists or not).
498    pub target_path: PathBuf,
499    /// True if writing is needed. False means the config is already in
500    /// the desired shape.
501    pub will_change: bool,
502    /// True if the file is `.jsonc` with comments that would be
503    /// stripped during the serde_json round-trip.
504    pub comments_lost: bool,
505}
506
507fn apply_opencode_config_change(
508    project_dir: &Path,
509    _planned: &PlannedOpencodeChange,
510) -> Result<(bool, bool)> {
511    let (planned, _) = compute_opencode_change(project_dir, /*apply=*/ true)?;
512    Ok((planned.will_change, planned.comments_lost))
513}
514
515/// Shared core: compute-or-apply the OpenCode config change.
516///
517/// Factored out so `plan_opencode_config_change` (dry-run) and
518/// `update_opencode_config_detailed` (write) share the same merge
519/// logic. The `apply` flag gates the final write.
520fn compute_opencode_change(
521    project_dir: &Path,
522    apply: bool,
523) -> Result<(PlannedOpencodeChange, ())> {
524    fn sqz_mcp_value() -> serde_json::Value {
525        serde_json::json!({
526            "type": "local",
527            "command": ["sqz-mcp", "--transport", "stdio"],
528            "enabled": true
529        })
530    }
531
532    if let Some(existing_path) = find_opencode_config(project_dir) {
533        let is_jsonc = existing_path
534            .extension()
535            .map(|e| e == "jsonc")
536            .unwrap_or(false);
537        let content = std::fs::read_to_string(&existing_path).map_err(|e| {
538            crate::error::SqzError::Other(format!(
539                "failed to read {}: {e}",
540                existing_path.display()
541            ))
542        })?;
543        let parseable = if is_jsonc {
544            strip_jsonc_comments(&content)
545        } else {
546            content.clone()
547        };
548        let had_comments = is_jsonc && parseable != content;
549
550        let mut config: serde_json::Value = serde_json::from_str(&parseable).map_err(|e| {
551            crate::error::SqzError::Other(format!(
552                "failed to parse {}: {e}",
553                existing_path.display()
554            ))
555        })?;
556        let obj = config.as_object_mut().ok_or_else(|| {
557            crate::error::SqzError::Other(format!(
558                "{} root is not a JSON object",
559                existing_path.display()
560            ))
561        })?;
562
563        let mut changed = false;
564
565        if let Some(arr) = obj.get_mut("plugin").and_then(|v| v.as_array_mut()) {
566            let before = arr.len();
567            arr.retain(|v| v.as_str() != Some("sqz"));
568            if arr.len() != before {
569                changed = true;
570            }
571            if arr.is_empty() {
572                obj.remove("plugin");
573                changed = true;
574            }
575        }
576
577        let mcp_entry = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
578        if let Some(mcp_obj) = mcp_entry.as_object_mut() {
579            if !mcp_obj.contains_key("sqz") {
580                mcp_obj.insert("sqz".to_string(), sqz_mcp_value());
581                changed = true;
582            } else if let Some(sqz_entry) = mcp_obj.get_mut("sqz").and_then(|v| v.as_object_mut()) {
583                if !sqz_entry.contains_key("enabled") {
584                    sqz_entry.insert("enabled".to_string(), serde_json::json!(true));
585                    changed = true;
586                }
587            }
588        } else {
589            return Err(crate::error::SqzError::Other(format!(
590                "{} has an `mcp` field that is not an object; \
591                 refusing to modify it automatically",
592                existing_path.display()
593            )));
594        }
595
596        let planned = PlannedOpencodeChange {
597            target_path: existing_path.clone(),
598            will_change: changed,
599            comments_lost: changed && had_comments,
600        };
601
602        if apply && changed {
603            let mut updated = serde_json::to_string_pretty(&config).map_err(|e| {
604                crate::error::SqzError::Other(format!("failed to serialize config: {e}"))
605            })?;
606            if content.ends_with('\n') {
607                updated.push('\n');
608            }
609            std::fs::write(&existing_path, updated).map_err(|e| {
610                crate::error::SqzError::Other(format!(
611                    "failed to write {}: {e}",
612                    existing_path.display()
613                ))
614            })?;
615        }
616
617        Ok((planned, ()))
618    } else {
619        // No existing config — a fresh opencode.json would be created.
620        let target = project_dir.join("opencode.json");
621        let planned = PlannedOpencodeChange {
622            target_path: target.clone(),
623            will_change: true,
624            comments_lost: false,
625        };
626
627        if apply {
628            let config = serde_json::json!({
629                "$schema": "https://opencode.ai/config.json",
630                "mcp": {
631                    "sqz": sqz_mcp_value()
632                }
633            });
634            let content = serde_json::to_string_pretty(&config).map_err(|e| {
635                crate::error::SqzError::Other(format!("failed to serialize opencode.json: {e}"))
636            })?;
637            std::fs::write(&target, format!("{content}\n")).map_err(|e| {
638                crate::error::SqzError::Other(format!("failed to write opencode.json: {e}"))
639            })?;
640        }
641
642        Ok((planned, ()))
643    }
644}
645
646/// Remove sqz's entries from an existing `opencode.json`/`opencode.jsonc`
647/// without deleting the whole file. Removes `mcp.sqz` and any `"sqz"`
648/// entry from `plugin`. If this leaves `mcp` or `plugin` empty the keys
649/// are dropped too. Returns `(path, changed)` — `changed` is `false`
650/// when neither sqz entry was present.
651///
652/// Callers are expected to honour a `.jsonc` file's comments losing
653/// fidelity on write: we parse with comment-stripping and emit as plain
654/// JSON. The file keeps its original extension so OpenCode keeps reading
655/// it. If the resulting config is completely empty (or would be the
656/// near-empty shape we'd create from scratch), we remove the file
657/// entirely since that's the cleaner uninstall state.
658pub fn remove_sqz_from_opencode_config(project_dir: &Path) -> Result<Option<(PathBuf, bool)>> {
659    let path = match find_opencode_config(project_dir) {
660        Some(p) => p,
661        None => return Ok(None),
662    };
663    let is_jsonc = path.extension().map(|e| e == "jsonc").unwrap_or(false);
664    let raw = std::fs::read_to_string(&path).map_err(|e| {
665        crate::error::SqzError::Other(format!("failed to read {}: {e}", path.display()))
666    })?;
667    let parseable = if is_jsonc {
668        strip_jsonc_comments(&raw)
669    } else {
670        raw.clone()
671    };
672    let mut config: serde_json::Value = match serde_json::from_str(&parseable) {
673        Ok(v) => v,
674        Err(_) => {
675            // Can't parse — be conservative and leave it alone.
676            return Ok(Some((path, false)));
677        }
678    };
679
680    let mut changed = false;
681
682    if let Some(obj) = config.as_object_mut() {
683        // Drop `"sqz"` from `plugin[]`.
684        if let Some(plugin) = obj.get_mut("plugin").and_then(|v| v.as_array_mut()) {
685            let before = plugin.len();
686            plugin.retain(|v| v.as_str() != Some("sqz"));
687            if plugin.len() != before {
688                changed = true;
689            }
690            // Drop the whole `plugin` key if it's now empty.
691            if plugin.is_empty() {
692                obj.remove("plugin");
693            }
694        }
695
696        // Drop `mcp.sqz`, and drop `mcp` itself if that was the only key.
697        if let Some(mcp) = obj.get_mut("mcp").and_then(|v| v.as_object_mut()) {
698            if mcp.remove("sqz").is_some() {
699                changed = true;
700            }
701            if mcp.is_empty() {
702                obj.remove("mcp");
703            }
704        }
705    }
706
707    if !changed {
708        return Ok(Some((path, false)));
709    }
710
711    // If the remaining config is empty or nearly-so, just remove the file.
712    // (A bare `{}` or `{ "$schema": "..." }` is what sqz's own
713    // first-install would leave behind, and the user clearly doesn't
714    // want sqz here — so nuking the sqz-authored shell is correct.)
715    let essentially_empty = match config.as_object() {
716        Some(obj) => {
717            obj.is_empty()
718                || (obj.len() == 1
719                    && obj.get("$schema").and_then(|v| v.as_str())
720                        == Some("https://opencode.ai/config.json"))
721        }
722        None => false,
723    };
724
725    if essentially_empty {
726        std::fs::remove_file(&path).map_err(|e| {
727            crate::error::SqzError::Other(format!(
728                "failed to remove {}: {e}",
729                path.display()
730            ))
731        })?;
732        return Ok(Some((path, true)));
733    }
734
735    // Otherwise write back the pruned config. This loses any comments
736    // a `.jsonc` had; the caller should surface that fact to the user.
737    let mut updated = serde_json::to_string_pretty(&config).map_err(|e| {
738        crate::error::SqzError::Other(format!("failed to serialize config: {e}"))
739    })?;
740    if raw.ends_with('\n') {
741        updated.push('\n');
742    }
743    std::fs::write(&path, updated).map_err(|e| {
744        crate::error::SqzError::Other(format!(
745            "failed to write {}: {e}",
746            path.display()
747        ))
748    })?;
749    Ok(Some((path, true)))
750}
751
752/// Return `true` if `command` has already been wrapped by an earlier sqz
753/// hook pass (or otherwise contains an sqz invocation we should skip).
754/// Used by `process_opencode_hook` and the equivalent TS guard in
755/// `generate_opencode_plugin` to prevent double-wrapping.
756///
757/// Checks for any of:
758/// - case-insensitive `sqz_cmd=` (prior-wrap prefix)
759/// - case-insensitive `sqz compress` (prior-wrap tail)
760/// - case-insensitive `| sqz ` or `| sqz\t` (any sqz subcommand pipe)
761/// - a leading `SQZ_*=...` env assignment
762/// - the base command itself is `sqz`/`sqz-mcp` (running sqz directly)
763fn is_already_wrapped(command: &str) -> bool {
764    let lowered = command.to_ascii_lowercase();
765    if lowered.contains("sqz_cmd=") {
766        return true;
767    }
768    if lowered.contains("sqz compress") {
769        return true;
770    }
771    if lowered.contains("| sqz ") || lowered.contains("| sqz\t") {
772        return true;
773    }
774    // Leading `SQZ_*=...` assignment.
775    let trimmed = command.trim_start();
776    if let Some(eq_idx) = trimmed.find('=') {
777        let name = &trimmed[..eq_idx];
778        if name.starts_with("SQZ_")
779            && !name.is_empty()
780            && name
781                .chars()
782                .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
783        {
784            return true;
785        }
786    }
787    // Running sqz or sqz-mcp directly (e.g. `sqz stats`, `sqz-mcp --help`).
788    let base = extract_base_cmd(command);
789    if base == "sqz" || base == "sqz-mcp" || base == "sqz.exe" {
790        return true;
791    }
792    false
793}
794
795/// Extract the base command name from a shell command string, skipping any
796/// leading `VAR=value` env-var assignments. Mirrors `extractBaseCmd` in the
797/// TS plugin — without this, a command like
798/// `FOO=bar BAZ=qux make test` would pick `FOO=bar` as the base, which is
799/// nonsense (and caused the recursive `SQZ_CMD=SQZ_CMD=...` reported as a
800/// follow-up to issue #5).
801fn extract_base_cmd(command: &str) -> &str {
802    for tok in command.split_whitespace() {
803        if is_env_assignment(tok) {
804            continue;
805        }
806        return tok.rsplit('/').next().unwrap_or("unknown");
807    }
808    "unknown"
809}
810
811/// Return `true` if `token` has the shape `NAME=VALUE` where `NAME` is a
812/// valid env-var identifier (letters/digits/underscores, starting with a
813/// letter or underscore). Empty token → `false`.
814fn is_env_assignment(token: &str) -> bool {
815    let eq = match token.find('=') {
816        Some(i) => i,
817        None => return false,
818    };
819    if eq == 0 {
820        return false;
821    }
822    let name = &token[..eq];
823    let mut chars = name.chars();
824    match chars.next() {
825        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
826        _ => return false,
827    }
828    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
829}
830
831/// Process an OpenCode `tool.execute.before` hook invocation.
832///
833/// OpenCode's hook format differs from Claude Code / Cursor:
834/// - Input: `{ "tool": "bash", "sessionID": "...", "callID": "..." }`
835/// - Args:  `{ "command": "git status" }`
836///
837/// The hook receives both `input` and `output` (args) as separate objects,
838/// but when invoked via CLI (`sqz hook opencode`), we receive a combined
839/// JSON with both fields.
840pub fn process_opencode_hook(input: &str) -> Result<String> {
841    let parsed: serde_json::Value = serde_json::from_str(input)
842        .map_err(|e| crate::error::SqzError::Other(format!("opencode hook: invalid JSON: {e}")))?;
843
844    let tool = parsed
845        .get("tool")
846        .or_else(|| parsed.get("toolName"))
847        .or_else(|| parsed.get("tool_name"))
848        .and_then(|v| v.as_str())
849        .unwrap_or("");
850
851    // Only intercept shell tool calls
852    if !matches!(
853        tool.to_lowercase().as_str(),
854        "bash" | "shell" | "terminal" | "run_shell_command"
855    ) {
856        return Ok(input.to_string());
857    }
858
859    // OpenCode puts args in a separate "args" field or in "toolCall"
860    let command = parsed
861        .get("args")
862        .or_else(|| parsed.get("toolCall"))
863        .or_else(|| parsed.get("tool_input"))
864        .and_then(|v| v.get("command"))
865        .and_then(|v| v.as_str())
866        .unwrap_or("");
867
868    if command.is_empty() || is_already_wrapped(command) {
869        return Ok(input.to_string());
870    }
871
872    // Don't rewrite commands with shell operators — appending `2>&1 | sqz
873    // compress` to a heredoc, compound command, or existing pipe corrupts
874    // the command. Reported in issue #22: heredoc terminators get mangled.
875    if crate::tool_hooks::has_shell_operators(command) {
876        return Ok(input.to_string());
877    }
878
879    // Determine the base command name. Skip leading VAR=VALUE assignments
880    // so an operator-prefixed command like `FOO=bar make test` still picks
881    // `make` as the base instead of `FOO=bar`.
882    let base = extract_base_cmd(command);
883
884    if matches!(
885        base,
886        "vim" | "vi" | "nano" | "emacs" | "less" | "more" | "top" | "htop"
887            | "ssh" | "python" | "python3" | "node" | "irb" | "ghci"
888            | "psql" | "mysql" | "sqlite3" | "mongo" | "redis-cli"
889    ) || command.contains("--watch")
890        || command.contains("run dev")
891        || command.contains("run start")
892        || command.contains("run serve")
893    {
894        return Ok(input.to_string());
895    }
896
897    // Rewrite the command
898    let base_cmd = base;
899
900    let escaped_base = if base_cmd
901        .chars()
902        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
903    {
904        base_cmd.to_string()
905    } else {
906        format!("'{}'", base_cmd.replace('\'', "'\\''"))
907    };
908
909    // Issue #10: use `--cmd NAME` instead of a sh-specific `SQZ_CMD=NAME`
910    // prefix. Ensures the rewrite works in PowerShell and cmd.exe on
911    // Windows (OpenCode Desktop's default bash-tool shell when $SHELL
912    // is unset or set to a Windows shell), not just POSIX shells.
913    let rewritten = if cfg!(windows) {
914        format!("{} 2>&1 | sqz compress --cmd {}", command, escaped_base)
915    } else {
916        crate::exit_marker::posix_rewrite(command, &escaped_base)
917    };
918
919    // Output in the format OpenCode expects (same as Claude Code for CLI path)
920    let output = serde_json::json!({
921        "updatedInput": {
922            "command": rewritten
923        },
924        "args": {
925            "command": rewritten
926        }
927    });
928
929    serde_json::to_string(&output)
930        .map_err(|e| crate::error::SqzError::Other(format!("opencode hook: serialize error: {e}")))
931}
932
933// ── Tests ─────────────────────────────────────────────────────────────────
934
935#[cfg(test)]
936mod tests {
937    use super::*;
938
939    #[test]
940    fn test_generate_opencode_plugin_contains_sqz_path() {
941        let content = generate_opencode_plugin("/usr/local/bin/sqz");
942        assert!(content.contains("/usr/local/bin/sqz"));
943        assert!(content.contains("SqzPlugin"));
944        assert!(content.contains("tool.execute.before"));
945    }
946
947    #[test]
948    fn test_generate_opencode_plugin_windows_path_escaped() {
949        // Issue #2: Windows paths embedded in the TS string literal must
950        // have backslashes escaped. Before the fix, raw backslashes were
951        // interpreted as JS escape sequences (\U, \S, \b) producing an
952        // invalid or silently-wrong SQZ_PATH.
953        let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
954        let content = generate_opencode_plugin(windows_path);
955        // The string literal in the generated TS should contain the
956        // path with doubled backslashes so that the runtime JS string
957        // value equals the original path.
958        assert!(
959            content.contains(r#"const SQZ_PATH = "C:\\Users\\SqzUser\\.cargo\\bin\\sqz.exe""#),
960            "expected JS-escaped path in plugin — got:\n{content}"
961        );
962        // And must NOT contain an unescaped backslash-sequence like \U
963        // (which JS would interpret as a unicode escape and then fail).
964        assert!(
965            !content.contains(r#"const SQZ_PATH = "C:\U"#),
966            "plugin must not contain unescaped backslashes in the string literal"
967        );
968    }
969
970    #[test]
971    fn test_generate_opencode_plugin_has_interactive_check() {
972        let content = generate_opencode_plugin("sqz");
973        assert!(content.contains("isInteractive"));
974        assert!(content.contains("vim"));
975        assert!(content.contains("--watch"));
976    }
977
978    /// Issue #22: the generated plugin must contain a `hasShellOperators`
979    /// guard so heredocs, compound commands, and pipes aren't corrupted.
980    #[test]
981    fn test_generate_opencode_plugin_has_shell_operator_guard() {
982        let content = generate_opencode_plugin("sqz");
983        assert!(
984            content.contains("function hasShellOperators(cmd: string): boolean"),
985            "plugin must define hasShellOperators guard (issue #22)"
986        );
987        assert!(
988            content.contains("hasShellOperators(cmd)"),
989            "plugin hook body must call hasShellOperators (issue #22)"
990        );
991        assert!(
992            content.contains("<<"),
993            "hasShellOperators must check for heredoc operator"
994        );
995    }
996
997    /// Issue #10 follow-up (@itguy327 comment): OpenCode's plugin UI
998    /// shows the raw `file:///...` spec as the plugin name instead of
999    /// "sqz" because our generated plugin lacked the V1 `id` field.
1000    ///
1001    /// OpenCode's V1 loader in `packages/opencode/src/plugin/shared.ts`
1002    /// requires file-source plugins to default-export an object with an
1003    /// `id` field — `resolvePluginId` literally throws "Path plugin …
1004    /// must export id" if it's missing. When the default export is
1005    /// absent, the loader falls through to the legacy path which works
1006    /// but provides no name, so OpenCode displays the file spec
1007    /// instead.
1008    ///
1009    /// Fix: the plugin default-exports `{ id: "sqz", server: factory }`.
1010    /// This test locks in that shape — dropping either field would
1011    /// regress the fix.
1012    #[test]
1013    fn test_generate_opencode_plugin_declares_v1_id() {
1014        let content = generate_opencode_plugin("sqz");
1015        assert!(
1016            content.contains("id: \"sqz\""),
1017            "plugin must default-export `id: \"sqz\"` so OpenCode's \
1018             V1 loader (shared.ts readV1Plugin/resolvePluginId) \
1019             displays \"sqz\" in the UI instead of the file path; \
1020             got:\n{content}"
1021        );
1022        assert!(
1023            content.contains("server: SqzPluginFactory"),
1024            "plugin must default-export `server: <factory>` for V1 \
1025             loader compliance; got:\n{content}"
1026        );
1027        assert!(
1028            content.contains("export default {"),
1029            "plugin must have a default export per OpenCode V1 shape; \
1030             got:\n{content}"
1031        );
1032    }
1033
1034    /// Companion to the V1-shape test: the legacy named export must
1035    /// stay in place for backward compat with pre-V1 OpenCode.
1036    ///
1037    /// The legacy loader walks `Object.values(mod)` and dedupes via a
1038    /// `Set`, so if our default export's `.server` is the same function
1039    /// reference as the `SqzPlugin` named export, the factory fires
1040    /// exactly once either way. This test asserts both exports are
1041    /// present AND share the same factory name — if someone later
1042    /// splits them into different functions they'd double-load on old
1043    /// OpenCode versions.
1044    #[test]
1045    fn test_generate_opencode_plugin_legacy_named_export_preserved() {
1046        let content = generate_opencode_plugin("sqz");
1047        assert!(
1048            content.contains("export const SqzPlugin = SqzPluginFactory"),
1049            "legacy named export must alias the same factory reference \
1050             as the V1 default export — otherwise old OpenCode versions \
1051             would see two distinct factories in `Object.values(mod)` \
1052             and fire the hook twice; got:\n{content}"
1053        );
1054    }
1055
1056    // Note: the older `test_generate_opencode_plugin_has_sqz_guard` was
1057    // replaced by `test_generate_opencode_plugin_has_double_wrap_guard`
1058    // (defined further below). The old assertion codified a too-broad
1059    // guard (`cmd.includes("sqz")`) that the runaway-prefix fix had to
1060    // tighten — keeping it would pin the bug in place.
1061
1062    #[test]
1063    fn test_process_opencode_hook_rewrites_bash() {
1064        let input = r#"{"tool":"bash","args":{"command":"git status"}}"#;
1065        let result = process_opencode_hook(input).unwrap();
1066        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1067        // "approve" would skip the permission prompt for the command.
1068        assert!(parsed.get("decision").is_none() && parsed.get("reason").is_none(), "{parsed}");
1069        let cmd = parsed["args"]["command"].as_str().unwrap();
1070        assert_eq!(parsed["updatedInput"]["command"].as_str(), Some(cmd));
1071        if cfg!(unix) {
1072            assert_eq!(cmd, crate::exit_marker::posix_rewrite("git status", "git"));
1073        }
1074        assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
1075        assert!(cmd.contains("git status"), "should preserve original: {cmd}");
1076        // Issue #10: label is passed via `--cmd NAME` (shell-neutral),
1077        // not via the sh-specific `SQZ_CMD=NAME` prefix that breaks
1078        // PowerShell and cmd.exe.
1079        assert!(cmd.contains("--cmd git"), "should pass base command via --cmd: {cmd}");
1080        assert!(
1081            !cmd.contains("SQZ_CMD="),
1082            "must not emit legacy sh-style env prefix: {cmd}"
1083        );
1084    }
1085
1086    #[test]
1087    fn test_process_opencode_hook_passes_non_shell() {
1088        let input = r#"{"tool":"read_file","args":{"path":"file.txt"}}"#;
1089        let result = process_opencode_hook(input).unwrap();
1090        assert_eq!(result, input, "non-shell tools should pass through");
1091    }
1092
1093    #[test]
1094    fn test_process_opencode_hook_skips_sqz_commands() {
1095        let input = r#"{"tool":"bash","args":{"command":"sqz stats"}}"#;
1096        let result = process_opencode_hook(input).unwrap();
1097        assert_eq!(result, input, "sqz commands should not be double-wrapped");
1098    }
1099
1100    #[test]
1101    fn test_process_opencode_hook_skips_interactive() {
1102        let input = r#"{"tool":"bash","args":{"command":"vim file.txt"}}"#;
1103        let result = process_opencode_hook(input).unwrap();
1104        assert_eq!(result, input, "interactive commands should pass through");
1105    }
1106
1107    #[test]
1108    fn test_process_opencode_hook_skips_watch() {
1109        let input = r#"{"tool":"bash","args":{"command":"npm run dev --watch"}}"#;
1110        let result = process_opencode_hook(input).unwrap();
1111        assert_eq!(result, input, "watch mode should pass through");
1112    }
1113
1114    /// Regression for issue #22: heredocs, compound commands, pipes, and
1115    /// redirects must not be rewritten. Appending `2>&1 | sqz compress`
1116    /// corrupts the command structure.
1117    #[test]
1118    fn test_process_opencode_hook_skips_shell_operators() {
1119        // Heredoc (the exact case from issue #22)
1120        let heredoc = r#"{"tool":"bash","args":{"command":"git commit -F- <<'EOF'\nfeat: subject\n\nbody\nEOF"}}"#;
1121        let result = process_opencode_hook(heredoc).unwrap();
1122        assert_eq!(result, heredoc, "heredoc must not be rewritten (issue #22)");
1123
1124        // Compound command
1125        let compound = r#"{"tool":"bash","args":{"command":"cargo build && cargo test"}}"#;
1126        let result = process_opencode_hook(compound).unwrap();
1127        assert_eq!(result, compound, "compound commands must pass through");
1128
1129        // Existing pipe
1130        let pipe = r#"{"tool":"bash","args":{"command":"cat file.txt | grep foo"}}"#;
1131        let result = process_opencode_hook(pipe).unwrap();
1132        assert_eq!(result, pipe, "existing pipes must pass through");
1133
1134        // Redirect
1135        let redirect = r#"{"tool":"bash","args":{"command":"echo hello > output.txt"}}"#;
1136        let result = process_opencode_hook(redirect).unwrap();
1137        assert_eq!(result, redirect, "redirects must pass through");
1138    }
1139
1140    #[test]
1141    fn test_process_opencode_hook_invalid_json() {
1142        let result = process_opencode_hook("not json");
1143        assert!(result.is_err());
1144    }
1145
1146    #[test]
1147    fn test_process_opencode_hook_empty_command() {
1148        let input = r#"{"tool":"bash","args":{"command":""}}"#;
1149        let result = process_opencode_hook(input).unwrap();
1150        assert_eq!(result, input);
1151    }
1152
1153    #[test]
1154    fn test_process_opencode_hook_run_shell_command() {
1155        let input = r#"{"tool":"run_shell_command","args":{"command":"ls -la"}}"#;
1156        let result = process_opencode_hook(input).unwrap();
1157        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1158        let cmd = parsed["args"]["command"].as_str().unwrap();
1159        assert!(cmd.contains("sqz compress"));
1160    }
1161
1162    #[test]
1163    fn test_install_opencode_plugin_creates_file() {
1164        let dir = tempfile::tempdir().unwrap();
1165        let plugin_path = dir
1166            .path()
1167            .join(".config/opencode/plugins/sqz.ts");
1168        let result = install_opencode_plugin_at(&plugin_path, "sqz");
1169        assert!(result.is_ok());
1170        assert!(plugin_path.exists(), "plugin file should exist");
1171        let content = std::fs::read_to_string(&plugin_path).unwrap();
1172        assert!(content.contains("SqzPlugin"));
1173    }
1174
1175    #[test]
1176    fn test_update_opencode_config_creates_new() {
1177        let dir = tempfile::tempdir().unwrap();
1178        let result = update_opencode_config(dir.path()).unwrap();
1179        assert!(result, "should create new config");
1180        let config_path = dir.path().join("opencode.json");
1181        assert!(config_path.exists());
1182        let content = std::fs::read_to_string(&config_path).unwrap();
1183        assert!(content.contains("\"sqz\""));
1184        assert!(content.contains("sqz-mcp"));
1185
1186        // Issue #10: fresh-install must NOT include `"plugin": ["sqz"]`.
1187        // The local plugin file at ~/.config/opencode/plugins/sqz.ts is
1188        // what actually installs the hook. Listing sqz in the config's
1189        // plugin array would make OpenCode try to also load it as an
1190        // npm package, producing two live copies of the plugin (reported
1191        // in issue #10).
1192        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1193        assert!(
1194            parsed.get("plugin").is_none(),
1195            "fresh-install opencode.json must not include `plugin`; got: {content}"
1196        );
1197        assert_eq!(
1198            parsed["mcp"]["sqz"]["type"].as_str(),
1199            Some("local"),
1200            "mcp.sqz must be present"
1201        );
1202    }
1203
1204    #[test]
1205    fn test_update_opencode_config_adds_to_existing() {
1206        let dir = tempfile::tempdir().unwrap();
1207        let config_path = dir.path().join("opencode.json");
1208        std::fs::write(
1209            &config_path,
1210            r#"{"$schema":"https://opencode.ai/config.json","plugin":["other"]}"#,
1211        )
1212        .unwrap();
1213
1214        let result = update_opencode_config(dir.path()).unwrap();
1215        assert!(result, "should update existing config");
1216        let content = std::fs::read_to_string(&config_path).unwrap();
1217        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1218        // Issue #10: sqz is NOT added to the `plugin` array any more
1219        // (double-load fix). But pre-existing plugin entries from
1220        // OTHER plugins must be preserved. And the MCP entry must
1221        // be added.
1222        let plugins = parsed["plugin"].as_array().unwrap();
1223        assert!(
1224            !plugins.iter().any(|v| v.as_str() == Some("sqz")),
1225            "issue #10: sqz must NOT be registered as a config-level plugin \
1226             (the local plugin file at ~/.config/opencode/plugins/sqz.ts \
1227             already loads it; double-registering causes double hook firing)"
1228        );
1229        assert!(
1230            plugins.iter().any(|v| v.as_str() == Some("other")),
1231            "pre-existing plugin entries from OTHER plugins must be preserved"
1232        );
1233        // MCP server registration IS still added — that's the separate,
1234        // non-duplicated path.
1235        assert_eq!(
1236            parsed["mcp"]["sqz"]["type"].as_str(),
1237            Some("local"),
1238            "mcp.sqz must be added"
1239        );
1240    }
1241
1242    /// Issue #10 upgrade path: a user who ran an older sqz release and
1243    /// got `"plugin": ["sqz"]` written into their config should have
1244    /// that entry surgically removed when they re-run `sqz init` on a
1245    /// newer release. Pre-existing entries from other plugins survive.
1246    #[test]
1247    fn test_update_opencode_config_removes_legacy_sqz_plugin_entry() {
1248        let dir = tempfile::tempdir().unwrap();
1249        let config_path = dir.path().join("opencode.json");
1250        std::fs::write(
1251            &config_path,
1252            r#"{"plugin":["other","sqz"]}"#,
1253        )
1254        .unwrap();
1255
1256        let changed = update_opencode_config(dir.path()).unwrap();
1257        assert!(changed, "must report that the legacy plugin entry was stripped");
1258
1259        let after = std::fs::read_to_string(&config_path).unwrap();
1260        let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1261        let plugins = parsed["plugin"].as_array().unwrap();
1262        assert!(
1263            !plugins.iter().any(|v| v.as_str() == Some("sqz")),
1264            "legacy sqz plugin entry must be stripped on re-init"
1265        );
1266        assert!(
1267            plugins.iter().any(|v| v.as_str() == Some("other")),
1268            "other plugin entries must survive the cleanup"
1269        );
1270    }
1271
1272    /// Issue #10: when the legacy `"plugin": ["sqz"]` was the ONLY
1273    /// entry in the plugin array, the whole `plugin` key should be
1274    /// dropped rather than left as `"plugin": []`.
1275    #[test]
1276    fn test_update_opencode_config_drops_empty_plugin_array_after_cleanup() {
1277        let dir = tempfile::tempdir().unwrap();
1278        let config_path = dir.path().join("opencode.json");
1279        std::fs::write(&config_path, r#"{"plugin":["sqz"]}"#).unwrap();
1280
1281        update_opencode_config(dir.path()).unwrap();
1282
1283        let after = std::fs::read_to_string(&config_path).unwrap();
1284        let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1285        assert!(
1286            parsed.get("plugin").is_none(),
1287            "empty plugin array should be dropped entirely, got: {after}"
1288        );
1289    }
1290
1291    #[test]
1292    fn test_update_opencode_config_skips_if_present() {
1293        let dir = tempfile::tempdir().unwrap();
1294        let config_path = dir.path().join("opencode.json");
1295        std::fs::write(
1296            &config_path,
1297            r#"{
1298  "mcp": {
1299    "sqz": {
1300      "type": "local",
1301      "command": ["sqz-mcp", "--transport", "stdio"],
1302      "enabled": true
1303    }
1304  }
1305}"#,
1306        )
1307        .unwrap();
1308
1309        let result = update_opencode_config(dir.path()).unwrap();
1310        assert!(
1311            !result,
1312            "a config with mcp.sqz including enabled:true must be idempotent"
1313        );
1314    }
1315
1316    /// When only `plugin[\"sqz\"]` is present the merger must add the
1317    /// missing `mcp.sqz` entry AND strip the legacy plugin entry.
1318    /// Before the issue #6 fix the updater only ever touched the
1319    /// plugin array, leaving MCP registration to chance.
1320    #[test]
1321    fn test_update_opencode_config_adds_missing_mcp_entry() {
1322        let dir = tempfile::tempdir().unwrap();
1323        let config_path = dir.path().join("opencode.json");
1324        std::fs::write(&config_path, r#"{"plugin":["sqz"]}"#).unwrap();
1325
1326        let changed = update_opencode_config(dir.path()).unwrap();
1327        assert!(changed, "must report that mcp.sqz was added");
1328
1329        let after = std::fs::read_to_string(&config_path).unwrap();
1330        let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1331        assert_eq!(
1332            parsed["mcp"]["sqz"]["type"].as_str(),
1333            Some("local"),
1334            "mcp.sqz must be populated with the default server entry"
1335        );
1336    }
1337
1338    // ── Issue #5 follow-up: runaway SQZ_CMD= prefix ───────────────────
1339
1340    /// Regression for the runaway-prefix report on issue #5.
1341    ///
1342    /// The user observed `SQZ_CMD=SQZ_CMD=ddev SQZ_CMD=ddev ddev exec ...`
1343    /// in OpenCode's output — the plugin/hook wrapped a command that had
1344    /// already been wrapped by a prior pass. Before the fix,
1345    /// `process_opencode_hook`'s guard was only `command.contains("sqz")`
1346    /// which missed the uppercase `SQZ_CMD=` prefix and let the wrap
1347    /// accumulate.
1348    #[test]
1349    fn test_process_opencode_hook_skips_already_wrapped_sqz_cmd_prefix() {
1350        let input = r#"{"tool":"bash","args":{"command":"SQZ_CMD=ddev ddev exec --dir=/var/www/html php -v 2>&1 | /home/user/.cargo/bin/sqz compress"}}"#;
1351        let result = process_opencode_hook(input).unwrap();
1352        assert_eq!(
1353            result, input,
1354            "already-wrapped command must pass through unchanged; \
1355             otherwise each pass accumulates another SQZ_CMD= prefix"
1356        );
1357    }
1358
1359    /// Guard must be case-insensitive: `SQZ_CMD=` contains no lowercase
1360    /// `sqz` and the old `command.contains("sqz")` check missed it.
1361    #[test]
1362    fn test_process_opencode_hook_guard_is_case_insensitive() {
1363        let input = r#"{"tool":"bash","args":{"command":"SQZ_CMD=git git status"}}"#;
1364        let result = process_opencode_hook(input).unwrap();
1365        assert_eq!(
1366            result, input,
1367            "uppercase SQZ_CMD= prefix must short-circuit the wrap"
1368        );
1369    }
1370
1371    /// When a user command begins with legitimate env-var assignments
1372    /// (e.g. `FOO=bar make test`) the base command should be `make`,
1373    /// not `FOO=bar`. The old implementation picked `FOO=bar` and
1374    /// produced `SQZ_CMD=FOO=bar` wraps. Now it should produce
1375    /// `--cmd make` (issue #10).
1376    #[test]
1377    fn test_process_opencode_hook_skips_leading_env_assignments_for_base() {
1378        let input = r#"{"tool":"bash","args":{"command":"FOO=bar BAZ=qux make test"}}"#;
1379        let result = process_opencode_hook(input).unwrap();
1380        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1381        let cmd = parsed["args"]["command"].as_str().unwrap();
1382        assert!(
1383            cmd.contains("--cmd make"),
1384            "base command must be `make`, not `FOO=bar`; got: {cmd}"
1385        );
1386        assert!(
1387            cmd.contains("FOO=bar BAZ=qux make test"),
1388            "original command must be preserved: {cmd}"
1389        );
1390    }
1391
1392    /// Running sqz directly (e.g. `sqz stats`) must not be wrapped.
1393    #[test]
1394    fn test_process_opencode_hook_skips_bare_sqz_invocation() {
1395        for cmd in ["sqz stats", "sqz gain", "/usr/local/bin/sqz compress"] {
1396            let input = format!(
1397                r#"{{"tool":"bash","args":{{"command":"{cmd}"}}}}"#
1398            );
1399            let result = process_opencode_hook(&input).unwrap();
1400            assert_eq!(
1401                result, input,
1402                "sqz-invoking command `{cmd}` must not be rewrapped"
1403            );
1404        }
1405    }
1406
1407    /// The generated TypeScript plugin must carry the same hardened
1408    /// guard the Rust hook has. We can't run the TS from Rust tests,
1409    /// but we can assert the generated source contains the key markers.
1410    #[test]
1411    fn test_generate_opencode_plugin_has_double_wrap_guard() {
1412        let content = generate_opencode_plugin("sqz");
1413        assert!(
1414            content.contains("function isAlreadyWrapped(cmd: string): boolean"),
1415            "generated plugin must define isAlreadyWrapped helper"
1416        );
1417        assert!(
1418            content.contains(r#"lowered.includes("sqz_cmd=")"#),
1419            "plugin must check for the SQZ_CMD= prior-wrap prefix"
1420        );
1421        assert!(
1422            content.contains(r#"lowered.includes("sqz compress")"#),
1423            "plugin must check for the `sqz compress` prior-wrap tail"
1424        );
1425        assert!(
1426            content.contains("isAlreadyWrapped(cmd)"),
1427            "plugin hook body must call isAlreadyWrapped on the command"
1428        );
1429        assert!(
1430            content.contains("function extractBaseCmd(cmd: string): string"),
1431            "plugin must define extractBaseCmd that skips env assignments"
1432        );
1433        assert!(
1434            content.contains("extractBaseCmd(cmd)"),
1435            "plugin hook body must use extractBaseCmd, not raw split"
1436        );
1437    }
1438
1439    // ── Unit tests for the helper functions ──────────────────────────
1440
1441    #[test]
1442    fn test_is_already_wrapped_detects_all_marker_shapes() {
1443        assert!(is_already_wrapped("SQZ_CMD=git git status"));
1444        assert!(is_already_wrapped("sqz_cmd=git git status"));
1445        assert!(is_already_wrapped("git status | sqz compress"));
1446        assert!(is_already_wrapped("git status 2>&1 | /path/sqz compress"));
1447        assert!(is_already_wrapped("ls -la | sqz compress-stream"));
1448        assert!(is_already_wrapped("sqz stats"));
1449        assert!(is_already_wrapped("/usr/local/bin/sqz gain"));
1450        assert!(is_already_wrapped("SQZ_FOO=bar cmd"));
1451        assert!(!is_already_wrapped("git status"));
1452        assert!(!is_already_wrapped("grep sqz logfile.txt"));
1453        assert!(!is_already_wrapped("cargo test --package my-sqz-crate"));
1454    }
1455
1456    #[test]
1457    fn test_extract_base_cmd_skips_env_assignments() {
1458        assert_eq!(extract_base_cmd("make test"), "make");
1459        assert_eq!(extract_base_cmd("FOO=bar make test"), "make");
1460        assert_eq!(extract_base_cmd("FOO=bar BAZ=qux make test"), "make");
1461        assert_eq!(extract_base_cmd("/usr/bin/git status"), "git");
1462        assert_eq!(extract_base_cmd(""), "unknown");
1463        assert_eq!(extract_base_cmd("FOO=bar"), "unknown");
1464    }
1465
1466    #[test]
1467    fn test_is_env_assignment() {
1468        assert!(is_env_assignment("FOO=bar"));
1469        assert!(is_env_assignment("FOO="));
1470        assert!(is_env_assignment("_underscore=1"));
1471        assert!(is_env_assignment("MixedCase_1=x"));
1472        assert!(!is_env_assignment("=bar"));
1473        assert!(!is_env_assignment("FOO"));
1474        assert!(!is_env_assignment("--flag=value"));
1475        assert!(!is_env_assignment("123=value"));
1476        assert!(!is_env_assignment("FOO BAR=baz"));
1477    }
1478
1479    // ── Issue #6: opencode.jsonc support ─────────────────────────────
1480
1481    /// Regression for issue #6 (@Icaruk). When a user has
1482    /// `opencode.jsonc` (OpenCode supports both `.json` and `.jsonc`),
1483    /// sqz init must MERGE into it rather than creating a parallel
1484    /// `opencode.json`. Before the fix `find_opencode_config` didn't
1485    /// exist and `update_opencode_config` was hardcoded to the `.json`
1486    /// path, so users with `.jsonc` ended up with two configs.
1487    #[test]
1488    fn test_update_merges_into_existing_jsonc() {
1489        let dir = tempfile::tempdir().unwrap();
1490        let jsonc = dir.path().join("opencode.jsonc");
1491        std::fs::write(
1492            &jsonc,
1493            r#"{
1494  // user's own config with a comment
1495  "$schema": "https://opencode.ai/config.json",
1496  "model": "anthropic/claude-sonnet-4-5",
1497  /* another comment */
1498  "plugin": ["other-plugin"]
1499}
1500"#,
1501        )
1502        .unwrap();
1503
1504        let changed = update_opencode_config(dir.path()).unwrap();
1505        assert!(changed, "must merge sqz entries into the existing .jsonc");
1506
1507        // The .jsonc file is the one we wrote back to — NOT a new .json.
1508        assert!(jsonc.exists(), "original .jsonc must still exist");
1509        assert!(
1510            !dir.path().join("opencode.json").exists(),
1511            "must not create a parallel opencode.json alongside .jsonc \
1512             (that's the issue #6 bug)"
1513        );
1514
1515        let after = std::fs::read_to_string(&jsonc).unwrap();
1516        let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1517        let plugins = parsed["plugin"].as_array().unwrap();
1518        // Issue #10: sqz is NOT registered in the plugin array any more
1519        // (double-load fix). Pre-existing OTHER-plugin entries still
1520        // survive. The MCP server entry is the one we register now.
1521        assert!(
1522            !plugins.iter().any(|v| v.as_str() == Some("sqz")),
1523            "issue #10: sqz must NOT be added to plugin[]"
1524        );
1525        assert!(
1526            plugins.iter().any(|v| v.as_str() == Some("other-plugin")),
1527            "pre-existing plugin entries must be preserved"
1528        );
1529        assert_eq!(
1530            parsed["model"].as_str(),
1531            Some("anthropic/claude-sonnet-4-5"),
1532            "unrelated user keys must survive the merge"
1533        );
1534        assert_eq!(
1535            parsed["mcp"]["sqz"]["type"].as_str(),
1536            Some("local"),
1537            "mcp.sqz must be registered"
1538        );
1539    }
1540
1541    /// Detailed variant: comments_lost must be reported when we
1542    /// rewrite a `.jsonc` that had comments. Callers (sqz init) use
1543    /// this to warn the user.
1544    #[test]
1545    fn test_update_opencode_config_detailed_reports_comments_lost() {
1546        let dir = tempfile::tempdir().unwrap();
1547        let jsonc = dir.path().join("opencode.jsonc");
1548        std::fs::write(
1549            &jsonc,
1550            r#"{
1551  // comment to be dropped
1552  "plugin": ["other"]
1553}
1554"#,
1555        )
1556        .unwrap();
1557
1558        let (changed, comments_lost) =
1559            update_opencode_config_detailed(dir.path()).unwrap();
1560        assert!(changed);
1561        assert!(
1562            comments_lost,
1563            "merger must report that comments were dropped from .jsonc"
1564        );
1565    }
1566
1567    /// Issue #6 follow-up: dry-run must report no change when the
1568    /// config is already fully configured, so the `sqz init` plan
1569    /// doesn't announce a merge that won't happen.
1570    #[test]
1571    fn plan_opencode_reports_no_change_when_already_configured() {
1572        let dir = tempfile::tempdir().unwrap();
1573        // First pass configures it.
1574        update_opencode_config(dir.path()).unwrap();
1575        // Second pass should be a no-op.
1576        let planned = plan_opencode_config_change(dir.path()).unwrap();
1577        assert!(
1578            !planned.will_change,
1579            "re-running against a fully configured file must be a no-op"
1580        );
1581        assert!(!planned.comments_lost);
1582    }
1583
1584    /// Dry-run must report `will_change=true` when mcp.sqz is missing,
1585    /// and must NOT actually write the file.
1586    #[test]
1587    fn plan_opencode_reports_change_without_writing() {
1588        let dir = tempfile::tempdir().unwrap();
1589        let path = dir.path().join("opencode.json");
1590        std::fs::write(&path, r#"{"plugin":["other"]}"#).unwrap();
1591        let before = std::fs::read_to_string(&path).unwrap();
1592
1593        let planned = plan_opencode_config_change(dir.path()).unwrap();
1594        assert!(planned.will_change);
1595        assert_eq!(planned.target_path, path);
1596
1597        let after = std::fs::read_to_string(&path).unwrap();
1598        assert_eq!(before, after, "dry-run must not modify the file");
1599    }
1600
1601    /// When no config exists, dry-run reports a fresh create and
1602    /// points at the default `opencode.json` path.
1603    #[test]
1604    fn plan_opencode_reports_fresh_create() {
1605        let dir = tempfile::tempdir().unwrap();
1606        let planned = plan_opencode_config_change(dir.path()).unwrap();
1607        assert!(planned.will_change);
1608        assert_eq!(planned.target_path, dir.path().join("opencode.json"));
1609        assert!(!dir.path().join("opencode.json").exists(),
1610            "dry-run must not create the file");
1611    }
1612
1613    /// When no existing config is present, we still default to
1614    /// creating `opencode.json` (not `.jsonc`). The `.jsonc` variant
1615    /// is the user's choice to make; we don't force it.
1616    #[test]
1617    fn test_update_creates_plain_json_when_nothing_exists() {
1618        let dir = tempfile::tempdir().unwrap();
1619        update_opencode_config(dir.path()).unwrap();
1620        assert!(dir.path().join("opencode.json").exists());
1621        assert!(!dir.path().join("opencode.jsonc").exists());
1622    }
1623
1624    /// Rewrites keep the file's final newline, or the lack of one, and its mode.
1625    #[test]
1626    fn config_rewrites_keep_the_final_newline_and_mode() {
1627        for ending in ["", "\n"] {
1628            let dir = tempfile::tempdir().unwrap();
1629            let path = dir.path().join("opencode.json");
1630            std::fs::write(&path, format!("{{\"plugin\":[\"other\"]}}{ending}")).unwrap();
1631            #[cfg(unix)]
1632            {
1633                use std::os::unix::fs::PermissionsExt;
1634                std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap();
1635            }
1636
1637            assert!(update_opencode_config(dir.path()).unwrap());
1638            assert_eq!(std::fs::read_to_string(&path).unwrap().ends_with('\n'), !ending.is_empty());
1639            assert!(remove_sqz_from_opencode_config(dir.path()).unwrap().unwrap().1);
1640            assert_eq!(std::fs::read_to_string(&path).unwrap().ends_with('\n'), !ending.is_empty());
1641            #[cfg(unix)]
1642            {
1643                use std::os::unix::fs::PermissionsExt;
1644                let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1645                assert_eq!(mode, 0o640);
1646            }
1647        }
1648    }
1649
1650    /// `find_opencode_config` prefers `.jsonc` when both exist.
1651    #[test]
1652    fn test_find_opencode_config_prefers_jsonc() {
1653        let dir = tempfile::tempdir().unwrap();
1654        std::fs::write(dir.path().join("opencode.json"), "{}").unwrap();
1655        std::fs::write(dir.path().join("opencode.jsonc"), "{}").unwrap();
1656        let found = find_opencode_config(dir.path()).unwrap();
1657        assert_eq!(
1658            found.file_name().unwrap(),
1659            "opencode.jsonc",
1660            "must prefer the .jsonc variant when both exist — the user \
1661             is maintaining .jsonc for its comment support"
1662        );
1663    }
1664
1665    #[test]
1666    fn test_find_opencode_config_returns_none_when_missing() {
1667        let dir = tempfile::tempdir().unwrap();
1668        assert!(find_opencode_config(dir.path()).is_none());
1669    }
1670
1671    #[test]
1672    fn test_opencode_config_has_comments_detects_jsonc_comments() {
1673        let dir = tempfile::tempdir().unwrap();
1674        std::fs::write(
1675            dir.path().join("opencode.jsonc"),
1676            "// a line comment\n{\"plugin\":[]}\n",
1677        )
1678        .unwrap();
1679        assert!(opencode_config_has_comments(dir.path()));
1680    }
1681
1682    #[test]
1683    fn test_opencode_config_has_comments_ignores_plain_json() {
1684        let dir = tempfile::tempdir().unwrap();
1685        // The fake `//` is inside a JSON string — NOT a comment.
1686        std::fs::write(
1687            dir.path().join("opencode.json"),
1688            r#"{"url":"http://example.com"}"#,
1689        )
1690        .unwrap();
1691        assert!(!opencode_config_has_comments(dir.path()));
1692    }
1693
1694    // ── JSONC comment stripper ───────────────────────────────────────
1695
1696    #[test]
1697    fn test_strip_jsonc_comments_removes_line_comments() {
1698        let src = "{\n  // leading comment\n  \"a\": 1 // trailing\n}";
1699        let stripped = strip_jsonc_comments(src);
1700        assert!(!stripped.contains("leading comment"));
1701        assert!(!stripped.contains("trailing"));
1702        let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1703        assert_eq!(parsed["a"], 1);
1704    }
1705
1706    #[test]
1707    fn test_strip_jsonc_comments_removes_block_comments() {
1708        let src = "{\n  /* block\n     comment */\n  \"a\": 1\n}";
1709        let stripped = strip_jsonc_comments(src);
1710        assert!(!stripped.contains("block"));
1711        let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1712        assert_eq!(parsed["a"], 1);
1713    }
1714
1715    #[test]
1716    fn test_strip_jsonc_comments_preserves_strings() {
1717        // The `//` inside the URL must NOT be treated as a line comment,
1718        // and the `/* ... */` pattern inside the string must NOT be
1719        // treated as a block comment. This is the classic JSONC parser
1720        // bug — we want to prove our stripper is string-aware.
1721        let src = r#"{"url": "http://example.com", "re": "/* not a comment */"}"#;
1722        let stripped = strip_jsonc_comments(src);
1723        let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1724        assert_eq!(parsed["url"], "http://example.com");
1725        assert_eq!(parsed["re"], "/* not a comment */");
1726    }
1727
1728    #[test]
1729    fn test_strip_jsonc_comments_preserves_escaped_quote_in_string() {
1730        let src = r#"{"s": "a\"//b"}"#;
1731        let stripped = strip_jsonc_comments(src);
1732        let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1733        assert_eq!(parsed["s"], r#"a"//b"#);
1734    }
1735
1736    #[test]
1737    fn test_strip_jsonc_comments_tolerates_unterminated_block() {
1738        // We don't want to panic or infinite-loop on malformed input.
1739        let src = "{\"a\":1 /* never ends";
1740        let _ = strip_jsonc_comments(src); // should return without panic
1741    }
1742
1743    #[test]
1744    fn test_strip_jsonc_comments_removes_trailing_commas() {
1745        // Trailing commas are valid JSONC but invalid JSON. The
1746        // stripping must produce valid JSON that serde_json can parse.
1747        let src = r#"{
1748  "a": [1, 2, 3,],
1749  "b": {"x": 1, "y": 2,},
1750}"#;
1751        let stripped = strip_jsonc_comments(src);
1752        let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1753        assert_eq!(parsed["a"], serde_json::json!([1, 2, 3]));
1754        assert_eq!(parsed["b"]["x"], 1);
1755        assert_eq!(parsed["b"]["y"], 2);
1756    }
1757
1758    #[test]
1759    fn test_strip_jsonc_comments_trailing_comma_in_string_preserved() {
1760        // A comma followed by `}` inside a string must NOT be stripped.
1761        let src = r#"{"s": "a,}"}"#;
1762        let stripped = strip_jsonc_comments(src);
1763        let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1764        assert_eq!(parsed["s"], "a,}");
1765    }
1766
1767    #[test]
1768    fn test_strip_jsonc_full_opencode_jsonc_with_comments_and_trailing_commas() {
1769        // End-to-end: a realistic opencode.jsonc with both comments and
1770        // trailing commas — the exact shape that caused issue #6.
1771        let src = r#"{
1772  // User's OpenCode config
1773  "$schema": "https://opencode.ai/config.json",
1774  "mcp": {
1775    // MCP servers
1776    "dart": {
1777      "type": "local",
1778      "command": ["dart", "mcp-server"],
1779    },
1780  },
1781}"#;
1782        let stripped = strip_jsonc_comments(src);
1783        let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1784        assert_eq!(
1785            parsed["mcp"]["dart"]["type"], "local",
1786            "must parse after stripping comments + trailing commas"
1787        );
1788    }
1789
1790    // ── Surgical uninstall ───────────────────────────────────────────
1791
1792    /// Regression for the uninstall-wipes-user-config concern tied to
1793    /// issue #6. Before this change `sqz uninstall` called
1794    /// `remove_file` on the entire `opencode.json`, destroying any
1795    /// user config that had been merged with sqz's entries. The
1796    /// surgical helper keeps the file, removes only sqz's keys.
1797    #[test]
1798    fn test_remove_sqz_preserves_other_user_config() {
1799        let dir = tempfile::tempdir().unwrap();
1800        let config = dir.path().join("opencode.json");
1801        std::fs::write(
1802            &config,
1803            r#"{
1804  "$schema": "https://opencode.ai/config.json",
1805  "model": "anthropic/claude-sonnet-4-5",
1806  "plugin": ["other-plugin", "sqz"],
1807  "mcp": {
1808    "sqz": { "type": "local", "command": ["sqz-mcp"] },
1809    "jira": { "type": "remote", "url": "https://jira.example.com/mcp" }
1810  }
1811}
1812"#,
1813        )
1814        .unwrap();
1815
1816        let (path, changed) =
1817            remove_sqz_from_opencode_config(dir.path()).unwrap().unwrap();
1818        assert_eq!(path, config);
1819        assert!(changed, "must report that sqz entries were removed");
1820        assert!(
1821            config.exists(),
1822            "file must NOT be deleted — only sqz's entries removed"
1823        );
1824
1825        let after = std::fs::read_to_string(&config).unwrap();
1826        let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1827        let plugins = parsed["plugin"].as_array().unwrap();
1828        assert!(!plugins.iter().any(|v| v.as_str() == Some("sqz")));
1829        assert!(plugins.iter().any(|v| v.as_str() == Some("other-plugin")));
1830        let mcp = parsed["mcp"].as_object().unwrap();
1831        assert!(!mcp.contains_key("sqz"), "mcp.sqz must be gone");
1832        assert!(mcp.contains_key("jira"), "mcp.jira must survive");
1833        assert_eq!(
1834            parsed["model"].as_str(),
1835            Some("anthropic/claude-sonnet-4-5"),
1836            "unrelated keys must survive"
1837        );
1838    }
1839
1840    /// If the file was CREATED by sqz (just $schema + sqz entries),
1841    /// removing sqz's entries should delete the whole file since
1842    /// there's nothing else the user wanted to keep.
1843    #[test]
1844    fn test_remove_sqz_deletes_file_when_nothing_else_remains() {
1845        let dir = tempfile::tempdir().unwrap();
1846        let config = dir.path().join("opencode.json");
1847        // This is exactly the shape sqz writes on fresh install.
1848        std::fs::write(
1849            &config,
1850            r#"{
1851  "$schema": "https://opencode.ai/config.json",
1852  "mcp": {
1853    "sqz": { "type": "local", "command": ["sqz-mcp", "--transport", "stdio"] }
1854  },
1855  "plugin": ["sqz"]
1856}
1857"#,
1858        )
1859        .unwrap();
1860
1861        let (_, changed) =
1862            remove_sqz_from_opencode_config(dir.path()).unwrap().unwrap();
1863        assert!(changed);
1864        assert!(
1865            !config.exists(),
1866            "file with only $schema + sqz entries must be removed"
1867        );
1868    }
1869
1870    /// When there's nothing to uninstall (no config present), the
1871    /// surgical helper returns None rather than erroring.
1872    #[test]
1873    fn test_remove_sqz_returns_none_when_config_missing() {
1874        let dir = tempfile::tempdir().unwrap();
1875        let result = remove_sqz_from_opencode_config(dir.path()).unwrap();
1876        assert!(result.is_none());
1877    }
1878
1879    /// Surgical uninstall against a .jsonc file: strips comments on
1880    /// read, writes back as plain JSON (to the same .jsonc path).
1881    #[test]
1882    fn test_remove_sqz_from_jsonc_drops_comments() {
1883        let dir = tempfile::tempdir().unwrap();
1884        let jsonc = dir.path().join("opencode.jsonc");
1885        std::fs::write(
1886            &jsonc,
1887            r#"{
1888  // user's comment
1889  "model": "x",
1890  "plugin": ["sqz", "other"]
1891}
1892"#,
1893        )
1894        .unwrap();
1895
1896        let (path, changed) =
1897            remove_sqz_from_opencode_config(dir.path()).unwrap().unwrap();
1898        assert_eq!(path, jsonc);
1899        assert!(changed);
1900        assert!(path.exists(), "jsonc file kept because `model` and `other` remain");
1901
1902        let after = std::fs::read_to_string(&jsonc).unwrap();
1903        assert!(
1904            !after.contains("// user's comment"),
1905            "comments are dropped by the serde_json round-trip; \
1906             documented in update_opencode_config_detailed"
1907        );
1908        let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1909        let plugins = parsed["plugin"].as_array().unwrap();
1910        assert_eq!(plugins.len(), 1);
1911        assert_eq!(plugins[0], "other");
1912    }
1913
1914    // ── Issue #10: Windows shell + duplicate plugin load ──────────────────
1915
1916    /// End-to-end regression for issue #10. The reporter ran `dotnet
1917    /// build` via OpenCode Desktop on Windows and got a
1918    /// CommandNotFoundException from PowerShell because sqz emitted
1919    /// the sh-specific `SQZ_CMD=cmd cmd /c dotnet build …` form.
1920    ///
1921    /// The fix: use `sqz compress --cmd NAME` — a normal CLI argument
1922    /// every shell accepts.
1923    #[test]
1924    fn issue_10_opencode_rewrite_works_in_powershell_syntax() {
1925        let input = r#"{"tool":"bash","args":{"command":"dotnet build NewNeonCheckers3.sln"}}"#;
1926        let result = process_opencode_hook(input).unwrap();
1927        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1928        let cmd = parsed["args"]["command"].as_str().unwrap();
1929
1930        // Regression asserts: the rewrite must not contain the
1931        // sh-specific env-var assignment that breaks in PowerShell and
1932        // cmd.exe.
1933        assert!(
1934            !cmd.contains("SQZ_CMD="),
1935            "issue #10: rewrite must not emit `SQZ_CMD=` (breaks on \
1936             PowerShell/cmd.exe); got: {cmd}"
1937        );
1938        // And it must use the shell-neutral --cmd form instead.
1939        assert!(
1940            cmd.contains("--cmd dotnet"),
1941            "rewrite must pass label via --cmd; got: {cmd}"
1942        );
1943        // PowerShell tokenises on whitespace: a command that begins
1944        // with a word that is NOT an env assignment must be what
1945        // PowerShell will execute. "dotnet build …" is valid in every
1946        // shell; "SQZ_CMD=… dotnet build …" is not.
1947        if cfg!(windows) {
1948            let first_token = cmd.split_whitespace().next().unwrap_or("");
1949            assert_eq!(
1950                first_token, "dotnet",
1951                "first token of the rewritten command must be the user's \
1952                 command itself, not an env-var assignment; got: {cmd}"
1953            );
1954        }
1955    }
1956
1957    /// The plugin's rewrite must carry the command's exit status, or a
1958    /// failing test run reports success. Runs the generated wrap() in node
1959    /// when it's available and checks the result in a real shell.
1960    #[test]
1961    fn ts_plugin_carries_exit_status_on_posix() {
1962        let content = generate_opencode_plugin("sqz");
1963        assert!(content.contains("printf '\\\\n__SQZ_EXIT_%d__\\\\n' \"$?\""), "{content}");
1964        assert!(content.contains("process.platform !== \"win32\""));
1965        assert!(content.contains("output.args.command = wrap(cmd, shellEscapeLabel(base));"));
1966
1967        let Some(node) = which_node().filter(|_| cfg!(unix)) else { return };
1968        let start = content.find("  const POSIX").unwrap();
1969        let end = content[start..].find("\n  }\n").unwrap() + start + 4;
1970        // Strip the TypeScript annotations so plain node can run it.
1971        let js = content[start..end]
1972            .replace("(cmd: string, label: string): string", "(cmd, label)")
1973            .replace("SQZ_PATH", "\"sqz\"");
1974        let script = format!("{js}\nprocess.stdout.write(wrap(\"cargo test\", \"cargo\"));");
1975        let out = std::process::Command::new(node)
1976            .args(["-e", &script])
1977            .env("SHELL", "/bin/zsh")
1978            .output()
1979            .unwrap();
1980        assert_eq!(
1981            String::from_utf8_lossy(&out.stdout),
1982            crate::exit_marker::posix_rewrite("cargo test", "cargo"),
1983            "{}",
1984            String::from_utf8_lossy(&out.stderr)
1985        );
1986    }
1987
1988    fn which_node() -> Option<&'static str> {
1989        let out = std::process::Command::new("node").arg("--version").output().ok()?;
1990        out.status.success().then_some("node")
1991    }
1992
1993    /// Companion to the above: the TS plugin (which runs inside
1994    /// OpenCode's Bun runtime) must emit the same shell-neutral form.
1995    /// Both the Rust-side hook and the TS plugin exist so we test both.
1996    #[test]
1997    fn issue_10_ts_plugin_emits_cmd_flag_not_env_prefix() {
1998        let content = generate_opencode_plugin("sqz");
1999        // The plugin builds its rewrite with a template literal. Look
2000        // for the `--cmd` pattern and make sure the legacy `SQZ_CMD=`
2001        // prefix is nowhere in the output template.
2002        assert!(
2003            content.contains("compress --cmd"),
2004            "TS plugin must build rewrite with `compress --cmd ${{base}}`"
2005        );
2006        // The plugin still CONTAINS the SQZ_CMD= string — in a regex
2007        // (`/^\\s*SQZ_[A-Z0-9_]+=/`) used by `isAlreadyWrapped` to
2008        // detect legacy pre-wrapped commands from older sqz versions.
2009        // So we assert specifically that the EMITTED COMMAND has no
2010        // `SQZ_CMD=${base} ${cmd}` template.
2011        assert!(
2012            !content.contains("SQZ_CMD=${base}"),
2013            "TS plugin must not emit the legacy `SQZ_CMD=${{base}}` prefix"
2014        );
2015    }
2016
2017    /// Bug #1 from issue #10: plugin loaded twice.
2018    ///
2019    /// Before the fix, `sqz init` wrote both:
2020    ///   1. `"plugin": ["sqz"]` in opencode.json
2021    ///   2. `~/.config/opencode/plugins/sqz.ts`
2022    ///
2023    /// Per OpenCode docs: "a local plugin and an npm plugin with
2024    /// similar names are both loaded separately." So (1) + (2)
2025    /// produced two live plugin instances firing on every tool call.
2026    ///
2027    /// The fix: don't write (1). Rely on (2) — OpenCode auto-loads
2028    /// `.ts` files from the plugins directory. Keep the MCP server
2029    /// registration in opencode.json (that's a separate, non-
2030    /// duplicating concern).
2031    #[test]
2032    fn issue_10_fresh_opencode_config_has_no_plugin_entry() {
2033        let dir = tempfile::tempdir().unwrap();
2034        update_opencode_config(dir.path()).unwrap();
2035        let content = std::fs::read_to_string(dir.path().join("opencode.json")).unwrap();
2036        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
2037
2038        // The deliberate absence of `plugin` is the whole fix.
2039        assert!(
2040            parsed.get("plugin").is_none(),
2041            "issue #10: fresh opencode.json must not include `plugin` key; got: {content}"
2042        );
2043
2044        // MCP server registration must still be present — it's the
2045        // separate, non-duplicating path.
2046        assert_eq!(
2047            parsed["mcp"]["sqz"]["type"].as_str(),
2048            Some("local"),
2049            "mcp.sqz is the one sqz-authored entry that belongs in \
2050             opencode.json; must still be registered"
2051        );
2052    }
2053
2054    /// When a user upgrades from an older sqz (which wrote `plugin:
2055    /// ["sqz"]`), running `sqz init` on the new version must
2056    /// surgically remove the legacy entry so the double-load bug is
2057    /// actually resolved — not just prevented for fresh installs.
2058    #[test]
2059    fn issue_10_reinit_strips_legacy_plugin_entry() {
2060        let dir = tempfile::tempdir().unwrap();
2061        let config = dir.path().join("opencode.json");
2062        std::fs::write(
2063            &config,
2064            // The exact shape an older sqz install would have produced.
2065            r#"{"$schema":"https://opencode.ai/config.json","mcp":{"sqz":{"type":"local","command":["sqz-mcp","--transport","stdio"]}},"plugin":["sqz"]}"#,
2066        )
2067        .unwrap();
2068
2069        let changed = update_opencode_config(dir.path()).unwrap();
2070        assert!(changed, "re-init must report a change (the legacy entry was stripped)");
2071
2072        let after = std::fs::read_to_string(&config).unwrap();
2073        let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
2074        assert!(
2075            parsed.get("plugin").is_none(),
2076            "legacy `plugin: [\"sqz\"]` must be stripped on re-init; got: {after}"
2077        );
2078        // MCP entry must survive.
2079        assert_eq!(
2080            parsed["mcp"]["sqz"]["type"].as_str(),
2081            Some("local"),
2082            "mcp.sqz must survive cleanup of the plugin entry"
2083        );
2084    }
2085}