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