Skip to main content

spool/installers/
opencode.rs

1//! OpenCode installer adapter.
2//!
3//! ## What we touch
4//! - `~/.opencode/config.json`    — `mcpServers.spool` entry
5//! - `~/.opencode/hooks.json`     — hook entries for on_session_start,
6//!   on_session_end pointing to our hook scripts
7//! - `~/.opencode/hooks/spool-*.sh` — two hook scripts
8//!
9//! ## OpenCode hook format
10//! OpenCode uses a flat `hooks.json` with the shape:
11//! ```json
12//! {
13//!   "hooks": {
14//!     "on_session_start": [{"command": "...", "timeout_ms": 5000}],
15//!     "on_session_end":   [{"command": "...", "timeout_ms": 10000}]
16//!   }
17//! }
18//! ```
19//!
20//! Same flat format as Codex but with only two hook events.
21//!
22//! ## Binary resolution
23//! Same strategy as Claude/Codex/Cursor: default to
24//! `~/.cargo/bin/spool-mcp`, with `--binary-path` as an escape hatch.
25
26use anyhow::{Context, Result};
27use serde_json::{Value, json};
28use std::path::{Path, PathBuf};
29
30use super::shared::{
31    self, McpMergeOutcome, McpRemoveOutcome, build_mcp_entry, merge_mcp_entry, remove_mcp_entry,
32};
33use super::templates::{self, opencode_hook_specs};
34use super::{
35    ClientId, DiagnosticCheck, DiagnosticReport, DiagnosticStatus, InstallContext, InstallReport,
36    InstallStatus, Installer, UninstallReport, UninstallStatus, UpdateReport, UpdateStatus,
37};
38
39/// Default timeout for on_session_start hooks (ms).
40const HOOK_TIMEOUT_DEFAULT_MS: u64 = 5000;
41/// Longer timeout for on_session_end (distill pipeline may take longer).
42const HOOK_TIMEOUT_SESSION_END_MS: u64 = 10000;
43
44pub struct OpenCodeInstaller {
45    home_override: Option<PathBuf>,
46}
47
48impl OpenCodeInstaller {
49    pub fn new() -> Self {
50        Self {
51            home_override: None,
52        }
53    }
54
55    #[doc(hidden)]
56    pub fn with_home_root(root: PathBuf) -> Self {
57        Self {
58            home_override: Some(root),
59        }
60    }
61
62    fn home(&self) -> Result<PathBuf> {
63        match &self.home_override {
64            Some(p) => Ok(p.clone()),
65            None => shared::home_dir(),
66        }
67    }
68
69    fn opencode_dir(&self) -> Result<PathBuf> {
70        Ok(self.home()?.join(".opencode"))
71    }
72
73    fn config_json_path(&self) -> Result<PathBuf> {
74        Ok(self.opencode_dir()?.join("config.json"))
75    }
76
77    fn hooks_json_path(&self) -> Result<PathBuf> {
78        Ok(self.opencode_dir()?.join("hooks.json"))
79    }
80
81    fn hooks_dir(&self) -> Result<PathBuf> {
82        Ok(self.opencode_dir()?.join("hooks"))
83    }
84
85    fn default_binary_path(&self) -> Result<PathBuf> {
86        Ok(self.home()?.join(".cargo").join("bin").join("spool-mcp"))
87    }
88
89    fn resolve_binary_path(&self, ctx: &InstallContext) -> Result<PathBuf> {
90        match &ctx.binary_path {
91            Some(p) => Ok(p.clone()),
92            None => self.default_binary_path(),
93        }
94    }
95
96    fn validate_inputs(&self, ctx: &InstallContext, binary_path: &Path) -> Result<()> {
97        if !ctx.config_path.is_absolute() {
98            anyhow::bail!(
99                "config path must be absolute, got: {}",
100                ctx.config_path.display()
101            );
102        }
103        if !binary_path.is_absolute() {
104            anyhow::bail!(
105                "binary path must be absolute, got: {}",
106                binary_path.display()
107            );
108        }
109        Ok(())
110    }
111}
112
113impl Default for OpenCodeInstaller {
114    fn default() -> Self {
115        Self::new()
116    }
117}
118
119impl Installer for OpenCodeInstaller {
120    fn id(&self) -> ClientId {
121        ClientId::OpenCode
122    }
123
124    fn detect(&self) -> Result<bool> {
125        let opencode_dir = self.opencode_dir()?;
126        Ok(opencode_dir.exists())
127    }
128
129    fn install(&self, ctx: &InstallContext) -> Result<InstallReport> {
130        let binary_path = self.resolve_binary_path(ctx)?;
131        self.validate_inputs(ctx, &binary_path)?;
132
133        let mut planned_writes: Vec<PathBuf> = Vec::new();
134        let mut backups: Vec<PathBuf> = Vec::new();
135        let mut notes: Vec<String> = Vec::new();
136
137        // ── 1. mcpServers entry in ~/.opencode/config.json ───────
138        let config_json = self.config_json_path()?;
139        let mut config_doc = shared::read_json_or_empty(&config_json)?;
140        let desired = build_mcp_entry(&binary_path, &ctx.config_path);
141        let mcp_outcome = merge_mcp_entry(&mut config_doc, "spool", desired, ctx.force);
142
143        if !binary_path.exists() {
144            notes.push(format!(
145                "spool-mcp binary not found at {}. Run `cargo install --path .` or pass --binary-path.",
146                binary_path.display()
147            ));
148        }
149
150        let mcp_status = match mcp_outcome {
151            McpMergeOutcome::Inserted => MergeStatus::Changed,
152            McpMergeOutcome::Unchanged => MergeStatus::Unchanged,
153            McpMergeOutcome::Conflict {
154                force_applied: true,
155            } => {
156                notes.push(format!(
157                    "Existing spool mcpServers entry was overwritten (force=true). Backup at {}.",
158                    config_json.display()
159                ));
160                MergeStatus::Changed
161            }
162            McpMergeOutcome::Conflict {
163                force_applied: false,
164            } => {
165                notes.push(
166                    "Existing spool mcpServers entry differs. Re-run with --force to overwrite, or `spool mcp uninstall` first.".to_string(),
167                );
168                MergeStatus::Conflict
169            }
170        };
171
172        if matches!(mcp_status, MergeStatus::Conflict) {
173            return Ok(InstallReport {
174                client: ClientId::OpenCode.as_str().to_string(),
175                binary_path,
176                config_path: ctx.config_path.clone(),
177                status: InstallStatus::Conflict,
178                planned_writes,
179                backups,
180                notes,
181            });
182        }
183
184        // ── 2. hooks.json entries ────────────────────────────────
185        let hooks_json = self.hooks_json_path()?;
186        let mut hooks_doc = shared::read_json_or_empty(&hooks_json)?;
187        let hooks_dir = self.hooks_dir()?;
188        let hook_specs = opencode_hook_specs();
189        let mut hooks_json_changed = false;
190        for spec in &hook_specs {
191            let target_path = hooks_dir.join(spec.file_name);
192            let target_str = target_path.to_string_lossy().into_owned();
193            let timeout = timeout_for_event(spec.hook_event);
194            match upsert_opencode_hook_entry(&mut hooks_doc, spec.hook_event, &target_str, timeout)
195            {
196                OpenCodeHookOutcome::Appended => hooks_json_changed = true,
197                OpenCodeHookOutcome::Unchanged => {}
198            }
199        }
200
201        // ── 3. plan hook script writes ───────────────────────────
202        let spool_bin_for_hook = templates::bin_path_for_hook(&binary_path);
203        let hook_files: Vec<HookFilePlan> = hook_specs
204            .iter()
205            .map(|spec| HookFilePlan {
206                target_path: hooks_dir.join(spec.file_name),
207                rendered: templates::render_hook(spec.body, &spool_bin_for_hook, &ctx.config_path),
208            })
209            .collect();
210        let hook_files_changed = hook_files
211            .iter()
212            .any(|p| !file_has_exact_contents(&p.target_path, &p.rendered));
213
214        // ── 4. dry-run preview ───────────────────────────────────
215        let any_change =
216            matches!(mcp_status, MergeStatus::Changed) || hooks_json_changed || hook_files_changed;
217
218        if ctx.dry_run {
219            if matches!(mcp_status, MergeStatus::Changed) {
220                planned_writes.push(config_json.clone());
221            }
222            if hooks_json_changed {
223                planned_writes.push(hooks_json.clone());
224            }
225            for plan in &hook_files {
226                if !file_has_exact_contents(&plan.target_path, &plan.rendered) {
227                    planned_writes.push(plan.target_path.clone());
228                }
229            }
230            return Ok(InstallReport {
231                client: ClientId::OpenCode.as_str().to_string(),
232                binary_path,
233                config_path: ctx.config_path.clone(),
234                status: if any_change {
235                    InstallStatus::DryRun
236                } else {
237                    InstallStatus::Unchanged
238                },
239                planned_writes,
240                backups,
241                notes,
242            });
243        }
244
245        // ── 5. apply mcpServers ──────────────────────────────────
246        if matches!(mcp_status, MergeStatus::Changed) {
247            if let Some(b) = shared::backup_file(&config_json)
248                .with_context(|| format!("backing up {}", config_json.display()))?
249            {
250                backups.push(b);
251            }
252            shared::write_json_atomic(&config_json, &config_doc)
253                .with_context(|| format!("writing {}", config_json.display()))?;
254            planned_writes.push(config_json.clone());
255        }
256
257        // ── 6. apply hooks.json ──────────────────────────────────
258        if hooks_json_changed {
259            if let Some(b) = shared::backup_file(&hooks_json)
260                .with_context(|| format!("backing up {}", hooks_json.display()))?
261            {
262                backups.push(b);
263            }
264            shared::write_json_atomic(&hooks_json, &hooks_doc)
265                .with_context(|| format!("writing {}", hooks_json.display()))?;
266            planned_writes.push(hooks_json.clone());
267        }
268
269        // ── 7. apply hook script files ───────────────────────────
270        if !hooks_dir.exists() {
271            std::fs::create_dir_all(&hooks_dir)
272                .with_context(|| format!("creating {}", hooks_dir.display()))?;
273        }
274        for plan in &hook_files {
275            if file_has_exact_contents(&plan.target_path, &plan.rendered) {
276                continue;
277            }
278            std::fs::write(&plan.target_path, &plan.rendered)
279                .with_context(|| format!("writing {}", plan.target_path.display()))?;
280            set_executable(&plan.target_path)?;
281            planned_writes.push(plan.target_path.clone());
282        }
283
284        let final_status = if any_change {
285            InstallStatus::Installed
286        } else {
287            InstallStatus::Unchanged
288        };
289
290        Ok(InstallReport {
291            client: ClientId::OpenCode.as_str().to_string(),
292            binary_path,
293            config_path: ctx.config_path.clone(),
294            status: final_status,
295            planned_writes,
296            backups,
297            notes,
298        })
299    }
300
301    fn update(&self, ctx: &InstallContext) -> Result<UpdateReport> {
302        let report = self.install(ctx)?;
303        let status = match report.status {
304            InstallStatus::Installed => UpdateStatus::Updated,
305            InstallStatus::Unchanged => UpdateStatus::Unchanged,
306            InstallStatus::DryRun => UpdateStatus::DryRun,
307            InstallStatus::Conflict => UpdateStatus::NotInstalled,
308        };
309        Ok(UpdateReport {
310            client: report.client,
311            status,
312            updated_paths: report.planned_writes,
313            notes: report.notes,
314        })
315    }
316
317    fn uninstall(&self, ctx: &InstallContext) -> Result<UninstallReport> {
318        let config_json = self.config_json_path()?;
319        let hooks_json = self.hooks_json_path()?;
320        let hooks_dir = self.hooks_dir()?;
321
322        let mut notes: Vec<String> = Vec::new();
323        let mut removed_paths: Vec<PathBuf> = Vec::new();
324        let mut backups: Vec<PathBuf> = Vec::new();
325        let mut any_change = false;
326
327        // ── 1. mcpServers ────────────────────────────────────────
328        let config_doc_after_purge = if config_json.exists() {
329            let mut doc = shared::read_json_or_empty(&config_json)?;
330            match remove_mcp_entry(&mut doc, "spool") {
331                McpRemoveOutcome::Removed => {
332                    any_change = true;
333                    Some(doc)
334                }
335                McpRemoveOutcome::NotPresent => None,
336            }
337        } else {
338            None
339        };
340
341        // ── 2. hooks.json purge ──────────────────────────────────
342        let hooks_doc_after_purge = if hooks_json.exists() {
343            let mut doc = shared::read_json_or_empty(&hooks_json)?;
344            let removed = purge_opencode_hook_entries(&mut doc, "spool-");
345            if removed > 0 {
346                any_change = true;
347                Some(doc)
348            } else {
349                None
350            }
351        } else {
352            None
353        };
354
355        // ── 3. plan file removals ────────────────────────────────
356        let hook_files: Vec<PathBuf> = opencode_hook_specs()
357            .iter()
358            .map(|s| hooks_dir.join(s.file_name))
359            .filter(|p| p.exists())
360            .collect();
361        if !hook_files.is_empty() {
362            any_change = true;
363        }
364
365        if !any_change {
366            notes.push("nothing to uninstall — no spool artifacts found.".to_string());
367            return Ok(UninstallReport {
368                client: ClientId::OpenCode.as_str().to_string(),
369                status: UninstallStatus::NotInstalled,
370                removed_paths,
371                backups,
372                notes,
373            });
374        }
375
376        if ctx.dry_run {
377            if config_doc_after_purge.is_some() {
378                removed_paths.push(config_json);
379            }
380            if hooks_doc_after_purge.is_some() {
381                removed_paths.push(hooks_json);
382            }
383            removed_paths.extend(hook_files);
384            return Ok(UninstallReport {
385                client: ClientId::OpenCode.as_str().to_string(),
386                status: UninstallStatus::DryRun,
387                removed_paths,
388                backups,
389                notes,
390            });
391        }
392
393        // ── 4. apply mcpServers purge ────────────────────────────
394        if let Some(doc) = config_doc_after_purge {
395            if let Some(b) = shared::backup_file(&config_json)? {
396                backups.push(b);
397            }
398            shared::write_json_atomic(&config_json, &doc)?;
399            removed_paths.push(config_json);
400        }
401
402        // ── 5. apply hooks.json purge ────────────────────────────
403        if let Some(doc) = hooks_doc_after_purge {
404            if let Some(b) = shared::backup_file(&hooks_json)? {
405                backups.push(b);
406            }
407            shared::write_json_atomic(&hooks_json, &doc)?;
408            removed_paths.push(hooks_json);
409        }
410
411        // ── 6. delete hook scripts ───────────────────────────────
412        for p in hook_files {
413            std::fs::remove_file(&p).with_context(|| format!("removing {}", p.display()))?;
414            removed_paths.push(p);
415        }
416
417        Ok(UninstallReport {
418            client: ClientId::OpenCode.as_str().to_string(),
419            status: UninstallStatus::Removed,
420            removed_paths,
421            backups,
422            notes,
423        })
424    }
425
426    fn diagnose(&self, ctx: &InstallContext) -> Result<DiagnosticReport> {
427        let mut checks = Vec::new();
428
429        // OpenCode dir presence
430        let opencode_dir = self.opencode_dir()?;
431        checks.push(DiagnosticCheck {
432            name: "opencode_dir_exists".into(),
433            status: if opencode_dir.exists() {
434                DiagnosticStatus::Ok
435            } else {
436                DiagnosticStatus::Warn
437            },
438            detail: format!("{}", opencode_dir.display()),
439        });
440
441        // mcpServers.spool registration
442        let config_json = self.config_json_path()?;
443        let registration_status = if config_json.exists() {
444            let doc = shared::read_json_or_empty(&config_json)?;
445            if doc.get("mcpServers").and_then(|v| v.get("spool")).is_some() {
446                DiagnosticStatus::Ok
447            } else {
448                DiagnosticStatus::Warn
449            }
450        } else {
451            DiagnosticStatus::NotApplicable
452        };
453        checks.push(DiagnosticCheck {
454            name: "mcp_servers_spool_registered".into(),
455            status: registration_status,
456            detail: "mcpServers.spool entry presence".into(),
457        });
458
459        // Binary path
460        let binary_path = self.resolve_binary_path(ctx)?;
461        checks.push(DiagnosticCheck {
462            name: "spool_mcp_binary".into(),
463            status: if binary_path.exists() {
464                DiagnosticStatus::Ok
465            } else {
466                DiagnosticStatus::Fail
467            },
468            detail: format!("{}", binary_path.display()),
469        });
470
471        // Config TOML readable
472        checks.push(DiagnosticCheck {
473            name: "spool_config_readable".into(),
474            status: if ctx.config_path.exists() {
475                DiagnosticStatus::Ok
476            } else {
477                DiagnosticStatus::Fail
478            },
479            detail: format!("{}", ctx.config_path.display()),
480        });
481
482        // hooks.json has spool entries
483        let hooks_json = self.hooks_json_path()?;
484        let hooks_registered_status = if hooks_json.exists() {
485            let doc = shared::read_json_or_empty(&hooks_json)?;
486            if has_any_spool_hook_entry(&doc) {
487                DiagnosticStatus::Ok
488            } else {
489                DiagnosticStatus::Warn
490            }
491        } else {
492            DiagnosticStatus::Warn
493        };
494        checks.push(DiagnosticCheck {
495            name: "opencode_hooks_registered".into(),
496            status: hooks_registered_status,
497            detail: format!("{}", hooks_json.display()),
498        });
499
500        // hook script files present
501        let hooks_dir = self.hooks_dir()?;
502        let mut missing: Vec<String> = Vec::new();
503        for spec in opencode_hook_specs() {
504            let p = hooks_dir.join(spec.file_name);
505            if !p.exists() {
506                missing.push(spec.file_name.to_string());
507            }
508        }
509        let hook_files_detail = if missing.is_empty() {
510            format!("{} (2/2 present)", hooks_dir.display())
511        } else {
512            format!("{} missing: {}", hooks_dir.display(), missing.join(", "))
513        };
514        checks.push(DiagnosticCheck {
515            name: "spool_hook_scripts".into(),
516            status: if missing.is_empty() {
517                DiagnosticStatus::Ok
518            } else {
519                DiagnosticStatus::Warn
520            },
521            detail: hook_files_detail,
522        });
523
524        Ok(DiagnosticReport {
525            client: ClientId::OpenCode.as_str().to_string(),
526            checks,
527        })
528    }
529}
530
531// ─────────────────────────────────────────────────────────────────────
532// internal helpers
533// ─────────────────────────────────────────────────────────────────────
534
535#[derive(Debug, Clone, Copy, PartialEq, Eq)]
536enum MergeStatus {
537    Changed,
538    Unchanged,
539    Conflict,
540}
541
542struct HookFilePlan {
543    target_path: PathBuf,
544    rendered: String,
545}
546
547#[derive(Debug, Clone, PartialEq, Eq)]
548enum OpenCodeHookOutcome {
549    Appended,
550    Unchanged,
551}
552
553fn timeout_for_event(event: &str) -> u64 {
554    match event {
555        "on_session_end" => HOOK_TIMEOUT_SESSION_END_MS,
556        _ => HOOK_TIMEOUT_DEFAULT_MS,
557    }
558}
559
560/// Ensure `doc.hooks.{event}` contains an entry with `command_path`.
561/// OpenCode hook entries are flat: `{"command": "...", "timeout_ms": N}`.
562fn upsert_opencode_hook_entry(
563    doc: &mut Value,
564    event: &str,
565    command_path: &str,
566    timeout_ms: u64,
567) -> OpenCodeHookOutcome {
568    let root = match doc.as_object_mut() {
569        Some(obj) => obj,
570        None => {
571            *doc = json!({});
572            doc.as_object_mut().expect("just inserted")
573        }
574    };
575    let hooks = root.entry("hooks").or_insert_with(|| json!({}));
576    if !hooks.is_object() {
577        *hooks = json!({});
578    }
579    let hooks_obj = hooks.as_object_mut().expect("hooks must be object");
580    let entries = hooks_obj
581        .entry(event)
582        .or_insert_with(|| Value::Array(Vec::new()));
583    if !entries.is_array() {
584        *entries = Value::Array(Vec::new());
585    }
586    let array = entries.as_array_mut().expect("entries must be array");
587
588    for entry in array.iter() {
589        if entry.get("command").and_then(Value::as_str) == Some(command_path) {
590            return OpenCodeHookOutcome::Unchanged;
591        }
592    }
593
594    array.push(json!({
595        "command": command_path,
596        "timeout_ms": timeout_ms,
597    }));
598    OpenCodeHookOutcome::Appended
599}
600
601/// Remove entries from `doc.hooks.{event}` whose `command` contains
602/// `marker_substring`. Returns the number of entries removed.
603fn purge_opencode_hook_entries(doc: &mut Value, marker_substring: &str) -> usize {
604    let mut removed = 0usize;
605    let Some(root) = doc.as_object_mut() else {
606        return 0;
607    };
608    let Some(hooks) = root.get_mut("hooks").and_then(|v| v.as_object_mut()) else {
609        return 0;
610    };
611    for (_event, entries) in hooks.iter_mut() {
612        let Some(array) = entries.as_array_mut() else {
613            continue;
614        };
615        let before = array.len();
616        array.retain(|entry| {
617            !entry
618                .get("command")
619                .and_then(Value::as_str)
620                .is_some_and(|c| c.contains(marker_substring))
621        });
622        removed += before - array.len();
623    }
624    hooks.retain(|_event, entries| !entries.as_array().is_some_and(|a| a.is_empty()));
625    if hooks.is_empty() {
626        root.remove("hooks");
627    }
628    removed
629}
630
631fn has_any_spool_hook_entry(doc: &Value) -> bool {
632    let Some(hooks) = doc.get("hooks").and_then(|v| v.as_object()) else {
633        return false;
634    };
635    for entries in hooks.values() {
636        let Some(arr) = entries.as_array() else {
637            continue;
638        };
639        for entry in arr {
640            if entry
641                .get("command")
642                .and_then(Value::as_str)
643                .is_some_and(|c| c.contains("spool-"))
644            {
645                return true;
646            }
647        }
648    }
649    false
650}
651
652fn file_has_exact_contents(path: &Path, expected: &str) -> bool {
653    if !path.exists() {
654        return false;
655    }
656    match std::fs::read_to_string(path) {
657        Ok(actual) => actual == expected,
658        Err(_) => false,
659    }
660}
661
662#[cfg(unix)]
663fn set_executable(path: &Path) -> Result<()> {
664    use std::os::unix::fs::PermissionsExt;
665    let mut perms = std::fs::metadata(path)
666        .with_context(|| format!("stat {}", path.display()))?
667        .permissions();
668    perms.set_mode(0o755);
669    std::fs::set_permissions(path, perms).with_context(|| format!("chmod {}", path.display()))?;
670    Ok(())
671}
672
673#[cfg(not(unix))]
674fn set_executable(_path: &Path) -> Result<()> {
675    Ok(())
676}
677
678#[cfg(test)]
679mod tests {
680    use super::*;
681    use std::fs;
682    use tempfile::tempdir;
683
684    fn setup() -> (tempfile::TempDir, OpenCodeInstaller, InstallContext) {
685        let temp = tempdir().unwrap();
686        let home = temp.path().to_path_buf();
687        let installer = OpenCodeInstaller::with_home_root(home.clone());
688
689        let config_path = home.join("spool.toml");
690        fs::write(&config_path, "[vault]\nroot=\"/tmp\"\n").unwrap();
691        let binary_path = home.join("fake-spool-mcp");
692        fs::write(&binary_path, "#!/bin/sh\nexit 0\n").unwrap();
693
694        let ctx = InstallContext {
695            binary_path: Some(binary_path),
696            config_path,
697            dry_run: false,
698            force: false,
699        };
700        (temp, installer, ctx)
701    }
702
703    #[test]
704    fn detect_returns_false_when_no_opencode_dir() {
705        let temp = tempdir().unwrap();
706        let installer = OpenCodeInstaller::with_home_root(temp.path().to_path_buf());
707        assert!(!installer.detect().unwrap());
708    }
709
710    #[test]
711    fn detect_returns_true_when_opencode_dir_present() {
712        let temp = tempdir().unwrap();
713        fs::create_dir_all(temp.path().join(".opencode")).unwrap();
714        let installer = OpenCodeInstaller::with_home_root(temp.path().to_path_buf());
715        assert!(installer.detect().unwrap());
716    }
717
718    #[test]
719    fn install_creates_config_and_hooks() {
720        let (temp, installer, ctx) = setup();
721        let report = installer.install(&ctx).unwrap();
722        assert_eq!(report.status, InstallStatus::Installed);
723
724        // config.json written with mcpServers.spool
725        let config_json = temp.path().join(".opencode").join("config.json");
726        assert!(config_json.exists());
727        let doc: Value = serde_json::from_str(&fs::read_to_string(&config_json).unwrap()).unwrap();
728        assert!(doc["mcpServers"]["spool"].is_object());
729
730        // hooks.json written with 2 events
731        let hooks_json = temp.path().join(".opencode").join("hooks.json");
732        assert!(hooks_json.exists());
733        let hooks_doc: Value =
734            serde_json::from_str(&fs::read_to_string(&hooks_json).unwrap()).unwrap();
735        assert!(hooks_doc["hooks"]["on_session_start"].is_array());
736        assert!(hooks_doc["hooks"]["on_session_end"].is_array());
737
738        // on_session_end has longer timeout
739        let end_entry = &hooks_doc["hooks"]["on_session_end"][0];
740        assert_eq!(end_entry["timeout_ms"], HOOK_TIMEOUT_SESSION_END_MS);
741
742        // hook script files exist
743        for spec in opencode_hook_specs() {
744            let p = temp
745                .path()
746                .join(".opencode")
747                .join("hooks")
748                .join(spec.file_name);
749            assert!(p.exists(), "{} missing", p.display());
750        }
751    }
752
753    #[test]
754    fn install_dry_run_does_not_write() {
755        let (temp, installer, mut ctx) = setup();
756        ctx.dry_run = true;
757        let report = installer.install(&ctx).unwrap();
758        assert_eq!(report.status, InstallStatus::DryRun);
759        assert!(!report.planned_writes.is_empty());
760        assert!(!temp.path().join(".opencode").exists());
761    }
762
763    #[test]
764    fn install_unchanged_on_repeat() {
765        let (_temp, installer, ctx) = setup();
766        let _ = installer.install(&ctx).unwrap();
767        let second = installer.install(&ctx).unwrap();
768        assert_eq!(second.status, InstallStatus::Unchanged);
769    }
770
771    #[test]
772    fn uninstall_removes_entries() {
773        let (temp, installer, ctx) = setup();
774        let _ = installer.install(&ctx).unwrap();
775
776        let report = installer.uninstall(&ctx).unwrap();
777        assert_eq!(report.status, UninstallStatus::Removed);
778
779        // hook scripts gone
780        for spec in opencode_hook_specs() {
781            let p = temp
782                .path()
783                .join(".opencode")
784                .join("hooks")
785                .join(spec.file_name);
786            assert!(!p.exists(), "{} should be removed", p.display());
787        }
788
789        // mcpServers.spool gone
790        let config_json = temp.path().join(".opencode").join("config.json");
791        let doc: Value = serde_json::from_str(&fs::read_to_string(&config_json).unwrap()).unwrap();
792        assert!(doc["mcpServers"].get("spool").is_none());
793    }
794
795    #[test]
796    fn uninstall_not_installed_when_clean() {
797        let (_temp, installer, ctx) = setup();
798        let report = installer.uninstall(&ctx).unwrap();
799        assert_eq!(report.status, UninstallStatus::NotInstalled);
800    }
801
802    #[test]
803    fn diagnose_reports_full_check_set_after_install() {
804        let (_temp, installer, ctx) = setup();
805        let _ = installer.install(&ctx).unwrap();
806        let report = installer.diagnose(&ctx).unwrap();
807        let names: Vec<&str> = report.checks.iter().map(|c| c.name.as_str()).collect();
808        for expected in [
809            "opencode_dir_exists",
810            "mcp_servers_spool_registered",
811            "spool_mcp_binary",
812            "spool_config_readable",
813            "opencode_hooks_registered",
814            "spool_hook_scripts",
815        ] {
816            assert!(names.contains(&expected), "missing check {}", expected);
817        }
818    }
819
820    #[cfg(unix)]
821    #[test]
822    fn install_makes_hook_scripts_executable() {
823        use std::os::unix::fs::PermissionsExt;
824        let (temp, installer, ctx) = setup();
825        let _ = installer.install(&ctx).unwrap();
826        let session = temp
827            .path()
828            .join(".opencode")
829            .join("hooks")
830            .join("spool-on_session_start.sh");
831        let perms = fs::metadata(&session).unwrap().permissions();
832        assert_eq!(perms.mode() & 0o777, 0o755);
833    }
834
835    #[test]
836    fn opencode_hook_entries_use_flat_format() {
837        let mut doc = json!({});
838        upsert_opencode_hook_entry(
839            &mut doc,
840            "on_session_start",
841            "/abs/spool-on_session_start.sh",
842            5000,
843        );
844        let entries = doc["hooks"]["on_session_start"].as_array().unwrap();
845        assert_eq!(entries.len(), 1);
846        assert_eq!(entries[0]["command"], "/abs/spool-on_session_start.sh");
847        assert_eq!(entries[0]["timeout_ms"], 5000);
848    }
849
850    #[test]
851    fn upsert_opencode_hook_unchanged_on_repeat() {
852        let mut doc = json!({});
853        upsert_opencode_hook_entry(&mut doc, "on_session_start", "/abs/hook.sh", 5000);
854        let outcome =
855            upsert_opencode_hook_entry(&mut doc, "on_session_start", "/abs/hook.sh", 5000);
856        assert_eq!(outcome, OpenCodeHookOutcome::Unchanged);
857        assert_eq!(
858            doc["hooks"]["on_session_start"].as_array().unwrap().len(),
859            1
860        );
861    }
862
863    #[test]
864    fn purge_opencode_hook_entries_removes_spool_only() {
865        let mut doc = json!({
866            "hooks": {
867                "on_session_start": [
868                    {"command": "/other/tool", "timeout_ms": 3000},
869                    {"command": "/abs/.opencode/hooks/spool-on_session_start.sh", "timeout_ms": 5000}
870                ],
871                "on_session_end": [
872                    {"command": "/abs/spool-on_session_end.sh", "timeout_ms": 10000}
873                ]
874            }
875        });
876        let removed = purge_opencode_hook_entries(&mut doc, "spool-");
877        assert_eq!(removed, 2);
878        let entries = doc["hooks"]["on_session_start"].as_array().unwrap();
879        assert_eq!(entries.len(), 1);
880        assert_eq!(entries[0]["command"], "/other/tool");
881        // on_session_end is now empty → swept.
882        assert!(doc["hooks"].get("on_session_end").is_none());
883    }
884}