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