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