Skip to main content

sqz_engine/
opencode_plugin.rs

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