1use std::path::{Path, PathBuf};
15
16use serde_json::{Map, Value};
17
18use crate::domain::harness::{HarnessSpec, HookBase, HookBinding};
19use crate::domain::hooks::{render, HookSpec};
20use crate::domain::sync::deep_merge;
21use crate::errors::OneharnessError;
22use crate::io::sync::{write_atomically, FileStatus};
23
24const DEFAULT_PLUGIN_NAME: &str = "oneharness";
26
27pub enum Scope<'a> {
30 Project(&'a Path),
32 Global(&'a GlobalDirs),
34}
35
36#[derive(Debug, Clone, Default)]
40pub struct GlobalDirs {
41 pub home: Option<PathBuf>,
43 pub config_home: Option<PathBuf>,
45}
46
47impl GlobalDirs {
48 pub fn from_env() -> Self {
51 Self::from_vars(
52 std::env::var_os("HOME"),
53 std::env::var_os("XDG_CONFIG_HOME"),
54 std::env::var_os("USERPROFILE"),
55 )
56 }
57
58 fn from_vars(
66 home: Option<std::ffi::OsString>,
67 config_home: Option<std::ffi::OsString>,
68 userprofile: Option<std::ffi::OsString>,
69 ) -> Self {
70 let home = if cfg!(windows) {
71 userprofile.or(home)
72 } else {
73 let _ = userprofile;
74 home
75 };
76 Self {
77 home: home.map(PathBuf::from),
78 config_home: config_home.map(PathBuf::from),
79 }
80 }
81
82 fn resolve(&self, base: HookBase) -> Option<PathBuf> {
85 match base {
86 HookBase::Home => self.home.clone(),
87 HookBase::ConfigHome => self
88 .config_home
89 .clone()
90 .or_else(|| self.home.as_ref().map(|h| h.join(".config"))),
91 }
92 }
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct HookWrite {
98 pub path: PathBuf,
99 pub status: FileStatus,
100}
101
102pub fn install(
109 scope: Scope,
110 spec: &HarnessSpec,
111 hook: &HookSpec,
112 check: bool,
113) -> Result<Vec<HookWrite>, OneharnessError> {
114 let Some(binding) = &spec.hooks else {
115 return Err(OneharnessError::HookUnsupported { id: spec.id.into() });
116 };
117 let name = hook.plugin_name.as_deref().unwrap_or(DEFAULT_PLUGIN_NAME);
118 let anchor = anchor_for(&scope, spec, binding, name)?;
119
120 match binding {
121 HookBinding::SameFile { shape, path } => {
122 let fragment = wrap(path, render(hook, *shape));
123 Ok(vec![merge_json(&anchor, &fragment, None, check)?])
124 }
125 HookBinding::File {
126 shape, path, seed, ..
127 } => {
128 let fragment = wrap(path, render(hook, *shape));
129 Ok(vec![merge_json(&anchor, &fragment, *seed, check)?])
130 }
131 HookBinding::GoosePlugin {
132 shape,
133 manifest,
134 path,
135 ..
136 } => {
137 let mut manifest_json = parse_seed(&manifest.replace("{name}", name));
142 if let (Some(desc), Value::Object(map)) = (&hook.description, &mut manifest_json) {
143 map.insert("description".into(), Value::String(desc.clone()));
144 }
145 let manifest_write =
146 merge_json(&anchor.join("plugin.json"), &manifest_json, None, check)?;
147 let fragment = wrap(path, render(hook, *shape));
148 let hooks_write = merge_json(
149 &anchor.join("hooks").join("hooks.json"),
150 &fragment,
151 None,
152 check,
153 )?;
154 Ok(vec![manifest_write, hooks_write])
155 }
156 HookBinding::JsPlugin { template, .. } => {
157 let content = render_shim(template, name, &hook.command);
158 Ok(vec![write_text(&anchor, &content, check)?])
159 }
160 }
161}
162
163#[derive(Debug, Default)]
170pub struct HookSnapshot {
171 entries: Vec<(PathBuf, Option<Vec<u8>>)>,
173 created_dirs: Vec<PathBuf>,
175}
176
177impl HookSnapshot {
178 pub fn capture(paths: &[PathBuf]) -> Self {
181 let mut snapshot = HookSnapshot::default();
182 for path in paths {
183 snapshot
184 .entries
185 .push((path.clone(), std::fs::read(path).ok()));
186 let mut missing = Vec::new();
190 let mut cursor = path.parent();
191 while let Some(dir) = cursor {
192 if dir.as_os_str().is_empty() || dir.exists() {
193 break;
194 }
195 missing.push(dir.to_path_buf());
196 cursor = dir.parent();
197 }
198 for dir in missing.into_iter().rev() {
199 if !snapshot.created_dirs.contains(&dir) {
200 snapshot.created_dirs.push(dir);
201 }
202 }
203 }
204 snapshot
205 }
206
207 pub fn extend(&mut self, other: HookSnapshot) {
211 self.entries.extend(other.entries);
212 for dir in other.created_dirs {
213 if !self.created_dirs.contains(&dir) {
214 self.created_dirs.push(dir);
215 }
216 }
217 }
218
219 pub fn restore(&self) -> Vec<(PathBuf, std::io::Error)> {
225 let mut failures = Vec::new();
226 for (path, prior) in &self.entries {
227 let result = match prior {
228 Some(bytes) => std::fs::write(path, bytes),
229 None => match std::fs::remove_file(path) {
230 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
232 other => other,
233 },
234 };
235 if let Err(err) = result {
236 failures.push((path.clone(), err));
237 }
238 }
239 for dir in self.created_dirs.iter().rev() {
243 let _ = std::fs::remove_dir(dir);
244 }
245 failures
246 }
247}
248
249fn anchor_for(
253 scope: &Scope,
254 spec: &HarnessSpec,
255 binding: &HookBinding,
256 name: &str,
257) -> Result<PathBuf, OneharnessError> {
258 match scope {
259 Scope::Project(dir) => Ok(project_anchor(dir, spec, binding, name)),
260 Scope::Global(dirs) => {
261 let global = spec
262 .global_hook
263 .ok_or_else(|| OneharnessError::HookGlobalUnsupported { id: spec.id.into() })?;
264 let base =
265 dirs.resolve(global.base)
266 .ok_or_else(|| OneharnessError::HookGlobalDirMissing {
267 id: spec.id.into(),
268 var: match global.base {
269 HookBase::Home => "HOME",
270 HookBase::ConfigHome => "XDG_CONFIG_HOME or HOME",
271 },
272 })?;
273 Ok(base.join(global.anchor.replace("{name}", name)))
274 }
275 }
276}
277
278fn project_anchor(
280 project_dir: &Path,
281 spec: &HarnessSpec,
282 binding: &HookBinding,
283 name: &str,
284) -> PathBuf {
285 match binding {
286 HookBinding::SameFile { .. } => {
287 let sync = spec
290 .sync
291 .as_ref()
292 .expect("SameFile hooks require a sync config file");
293 sync.alt_files
294 .iter()
295 .map(|f| project_dir.join(f))
296 .find(|p| p.is_file())
297 .unwrap_or_else(|| project_dir.join(sync.file))
298 }
299 HookBinding::File { file, .. } => project_dir.join(file.replace("{name}", name)),
300 HookBinding::GoosePlugin { plugins_dir, .. } => project_dir.join(plugins_dir).join(name),
301 HookBinding::JsPlugin { plugin_dir, .. } => {
302 project_dir.join(plugin_dir).join(format!("{name}.js"))
303 }
304 }
305}
306
307fn wrap(path: &[&str], value: Value) -> Value {
310 let mut node = value;
311 for key in path.iter().rev() {
312 let mut map = Map::new();
313 map.insert((*key).to_string(), node);
314 node = Value::Object(map);
315 }
316 node
317}
318
319fn render_shim(template: &str, name: &str, command: &str) -> String {
322 let argv: Vec<&str> = command.split_whitespace().collect();
323 let argv_json = serde_json::to_string(&argv).expect("argv of strings serializes");
324 template
325 .replace("{export}", &js_identifier(name))
326 .replace("{argv}", &argv_json)
327 .replace("{name}", name)
328}
329
330fn js_identifier(name: &str) -> String {
333 let mut out: String = name
334 .chars()
335 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
336 .collect();
337 if out.chars().next().is_none_or(|c| c.is_ascii_digit()) {
338 out.insert(0, '_');
339 }
340 out
341}
342
343fn parse_seed(text: &str) -> Value {
345 serde_json::from_str(text).expect("registry seed/manifest is valid JSON (test-pinned)")
346}
347
348fn merge_json(
352 target: &Path,
353 fragment: &Value,
354 seed: Option<&str>,
355 check: bool,
356) -> Result<HookWrite, OneharnessError> {
357 let existing: Option<Value> = match std::fs::read_to_string(target) {
358 Ok(text) => Some(serde_json::from_str(&text).map_err(|e| {
359 OneharnessError::HarnessConfigUnmergeable {
360 path: target.display().to_string(),
361 message: format!("not valid JSON ({e}); fix or remove it and re-run"),
362 }
363 })?),
364 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
365 Err(source) => {
366 return Err(OneharnessError::HarnessConfigRead {
367 path: target.display().to_string(),
368 source,
369 })
370 }
371 };
372
373 let (merged, status) = match &existing {
374 Some(existing) => {
375 let merged = deep_merge(existing, fragment);
376 let status = if &merged == existing {
377 FileStatus::Unchanged
378 } else {
379 FileStatus::Updated
380 };
381 (merged, status)
382 }
383 None => {
384 let base = seed.map(parse_seed).unwrap_or(Value::Object(Map::new()));
385 (deep_merge(&base, fragment), FileStatus::Created)
386 }
387 };
388
389 if !check && status != FileStatus::Unchanged {
390 write_atomically(target, &merged)?;
391 }
392 Ok(HookWrite {
393 path: target.to_path_buf(),
394 status,
395 })
396}
397
398fn write_text(target: &Path, content: &str, check: bool) -> Result<HookWrite, OneharnessError> {
401 let existing = match std::fs::read_to_string(target) {
402 Ok(text) => Some(text),
403 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
404 Err(source) => {
405 return Err(OneharnessError::HarnessConfigRead {
406 path: target.display().to_string(),
407 source,
408 })
409 }
410 };
411 let status = match &existing {
412 Some(text) if text == content => FileStatus::Unchanged,
413 Some(_) => FileStatus::Updated,
414 None => FileStatus::Created,
415 };
416
417 if !check && status != FileStatus::Unchanged {
418 let write_err = |source: std::io::Error| OneharnessError::HarnessConfigWrite {
419 path: target.display().to_string(),
420 source,
421 };
422 if let Some(parent) = target.parent() {
423 std::fs::create_dir_all(parent).map_err(write_err)?;
424 }
425 let tmp = target.with_extension("oneharness.tmp");
426 std::fs::write(&tmp, content).map_err(write_err)?;
427 std::fs::rename(&tmp, target).map_err(write_err)?;
428 }
429 Ok(HookWrite {
430 path: target.to_path_buf(),
431 status,
432 })
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438 use crate::domain::harness;
439 use serde_json::json;
440
441 #[test]
442 fn global_home_prefers_userprofile_on_windows_only() {
443 let dirs = GlobalDirs::from_vars(Some("/home/u".into()), None, Some(r"C:\Users\u".into()));
446 if cfg!(windows) {
447 assert_eq!(dirs.home, Some(PathBuf::from(r"C:\Users\u")));
448 } else {
449 assert_eq!(dirs.home, Some(PathBuf::from("/home/u")));
450 }
451 }
452
453 fn temp_project(tag: &str) -> PathBuf {
454 let dir = std::env::temp_dir().join(format!(
455 "oneharness-hooks-{tag}-{}-{:?}",
456 std::process::id(),
457 std::thread::current().id()
458 ));
459 let _ = std::fs::remove_dir_all(&dir);
460 std::fs::create_dir_all(&dir).unwrap();
461 dir
462 }
463
464 fn install_one(dir: &Path, id: &str, hook: &HookSpec) -> Vec<HookWrite> {
465 install(
466 Scope::Project(dir),
467 harness::by_id(id).unwrap(),
468 hook,
469 false,
470 )
471 .expect("install should succeed")
472 }
473
474 fn read_json(path: &Path) -> Value {
475 serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap()
476 }
477
478 #[test]
481 fn same_file_merges_into_claude_settings_without_clobbering() {
482 let dir = temp_project("claude");
483 std::fs::create_dir_all(dir.join(".claude")).unwrap();
484 std::fs::write(
485 dir.join(".claude/settings.json"),
486 r#"{"permissions":{"allow":["Read"]}}"#,
487 )
488 .unwrap();
489 let hook = HookSpec {
490 command: "guard hook claude-code".into(),
491 matcher: Some("Bash".into()),
492 timeout: None,
493 plugin_name: None,
494 description: None,
495 };
496 let writes = install_one(&dir, "claude-code", &hook);
497 assert_eq!(writes.len(), 1);
498 assert_eq!(writes[0].status, FileStatus::Updated);
499 assert_eq!(
500 read_json(&dir.join(".claude/settings.json")),
501 json!({
502 "permissions": { "allow": ["Read"] },
503 "hooks": {
504 "PreToolUse": [
505 { "matcher": "Bash", "hooks": [{ "type": "command", "command": "guard hook claude-code" }] }
506 ]
507 }
508 }),
509 );
510 let _ = std::fs::remove_dir_all(&dir);
511 }
512
513 #[test]
515 fn file_strategy_creates_codex_hooks_file() {
516 let dir = temp_project("codex");
517 let writes = install_one(&dir, "codex", &HookSpec::command("guard hook codex"));
518 assert_eq!(writes[0].status, FileStatus::Created);
519 assert_eq!(writes[0].path, dir.join(".codex/hooks.json"));
520 assert_eq!(
521 read_json(&dir.join(".codex/hooks.json")),
522 json!({
523 "hooks": { "PreToolUse": [{ "hooks": [{ "type": "command", "command": "guard hook codex" }] }] }
524 }),
525 );
526 let _ = std::fs::remove_dir_all(&dir);
527 }
528
529 #[test]
533 fn file_strategy_seeds_cursor_version() {
534 let dir = temp_project("cursor");
535 install_one(&dir, "cursor", &HookSpec::command("guard hook cursor"));
536 assert_eq!(
537 read_json(&dir.join(".cursor/hooks.json")),
538 json!({
539 "version": 1,
540 "hooks": {
541 "beforeShellExecution": [{ "command": "guard hook cursor" }],
542 "beforeReadFile": [{ "command": "guard hook cursor" }],
543 "beforeMCPExecution": [{ "command": "guard hook cursor" }],
544 "preToolUse": [{ "command": "guard hook cursor" }],
545 }
546 }),
547 );
548 let _ = std::fs::remove_dir_all(&dir);
549 }
550
551 #[test]
553 fn file_strategy_names_copilot_file_after_plugin() {
554 let dir = temp_project("copilot");
555 let hook = HookSpec {
556 plugin_name: Some("guard".into()),
557 ..HookSpec::command("guard hook copilot")
558 };
559 let writes = install_one(&dir, "copilot", &hook);
560 assert_eq!(writes[0].path, dir.join(".github/hooks/guard.json"));
561 assert_eq!(
562 read_json(&dir.join(".github/hooks/guard.json")),
563 json!({
564 "version": 1,
565 "hooks": {
566 "preToolUse": [
567 { "type": "command", "bash": "guard hook copilot", "powershell": "guard hook copilot" }
568 ]
569 }
570 }),
571 );
572 let _ = std::fs::remove_dir_all(&dir);
573 }
574
575 #[test]
578 fn goose_plugin_writes_manifest_and_hooks() {
579 let dir = temp_project("goose");
580 let hook = HookSpec {
581 command: "guard hook goose".into(),
582 matcher: Some("^(shell|read)$".into()),
583 timeout: Some(10),
584 plugin_name: None,
585 description: None,
586 };
587 let writes = install_one(&dir, "goose", &hook);
588 assert_eq!(writes.len(), 2);
589 let plugin = dir.join(".agents/plugins/oneharness");
590 assert_eq!(
591 read_json(&plugin.join("plugin.json")),
592 json!({
593 "name": "oneharness",
594 "version": "0.1.0",
595 "description": "Pre-tool hook installed by oneharness.",
596 }),
597 );
598 assert_eq!(
599 read_json(&plugin.join("hooks/hooks.json")),
600 json!({
601 "hooks": {
602 "PreToolUse": [
603 {
604 "matcher": "^(shell|read)$",
605 "hooks": [{ "type": "command", "command": "guard hook goose", "timeout": 10 }]
606 }
607 ]
608 }
609 }),
610 );
611 let _ = std::fs::remove_dir_all(&dir);
612 }
613
614 #[test]
618 fn goose_manifest_honors_a_caller_description() {
619 let dir = temp_project("goose-desc");
620 let hook = HookSpec {
621 description: Some("Gate AI-agent shell commands through allowlister.".into()),
622 ..HookSpec::command("allowlister hook goose")
623 };
624 install_one(&dir, "goose", &hook);
625 let manifest = read_json(&dir.join(".agents/plugins/oneharness/plugin.json"));
626 assert_eq!(
627 manifest["description"],
628 "Gate AI-agent shell commands through allowlister."
629 );
630 let _ = std::fs::remove_dir_all(&dir);
631 }
632
633 #[test]
636 fn js_plugin_writes_shim_with_command_argv() {
637 let dir = temp_project("opencode");
638 let hook = HookSpec {
639 plugin_name: Some("guard".into()),
640 ..HookSpec::command("guard hook opencode")
641 };
642 let writes = install_one(&dir, "opencode", &hook);
643 assert_eq!(writes[0].path, dir.join(".opencode/plugin/guard.js"));
644 let shim = std::fs::read_to_string(writes[0].path.clone()).unwrap();
645 assert!(
646 shim.contains(r#"Bun.spawn(["guard","hook","opencode"]"#),
647 "command must be wired into the shim as an argv array:\n{shim}"
648 );
649 assert!(
650 shim.contains("export const guard ="),
651 "export uses the plugin identity:\n{shim}"
652 );
653 assert!(
656 shim.contains("const session_id = (input && input.sessionID) || undefined;"),
657 "session id must be read from input.sessionID:\n{shim}"
658 );
659 assert!(
660 shim.contains("JSON.stringify({ tool_name, tool_input: args, cwd, session_id })"),
661 "session_id must be on the stdin payload:\n{shim}"
662 );
663 let _ = std::fs::remove_dir_all(&dir);
664 }
665
666 #[test]
670 fn snapshot_restores_existing_files_and_removes_created_ones() {
671 let dir = temp_project("snapshot");
672 let existing = dir.join("crush.json");
673 std::fs::write(&existing, r#"{"keep":"me"}"#).unwrap();
674 let created = dir.join(".codex").join("hooks.json");
675
676 let snapshot = HookSnapshot::capture(&[existing.clone(), created.clone()]);
677 std::fs::write(&existing, r#"{"keep":"me","hooks":{}}"#).unwrap();
679 std::fs::create_dir_all(created.parent().unwrap()).unwrap();
680 std::fs::write(&created, r#"{"hooks":{}}"#).unwrap();
681
682 let failures = snapshot.restore();
683 assert!(failures.is_empty(), "{failures:?}");
684 assert_eq!(
685 std::fs::read_to_string(&existing).unwrap(),
686 r#"{"keep":"me"}"#
687 );
688 assert!(!created.exists(), "created file must be deleted");
689 assert!(
690 !dir.join(".codex").exists(),
691 "the directory the install created must be pruned"
692 );
693 let _ = std::fs::remove_dir_all(&dir);
694 }
695
696 #[test]
700 fn snapshot_restore_never_removes_nonempty_dirs_and_tolerates_absence() {
701 let dir = temp_project("snapshot-keep");
702 let created = dir.join(".codex").join("hooks.json");
703 let snapshot = HookSnapshot::capture(std::slice::from_ref(&created));
704 std::fs::create_dir_all(created.parent().unwrap()).unwrap();
705 std::fs::write(dir.join(".codex").join("user.txt"), "mine").unwrap();
708
709 let failures = snapshot.restore();
710 assert!(failures.is_empty(), "{failures:?}");
711 assert!(
712 dir.join(".codex").join("user.txt").exists(),
713 "restore must not touch user content"
714 );
715 let mut a = HookSnapshot::capture(std::slice::from_ref(&created));
717 let b = HookSnapshot::capture(&[created]);
718 a.extend(b);
719 let _ = a.restore();
720 let _ = std::fs::remove_dir_all(&dir);
721 }
722
723 #[test]
726 fn reinstall_is_idempotent_across_strategies() {
727 for id in [
728 "claude-code",
729 "codex",
730 "cursor",
731 "copilot",
732 "goose",
733 "opencode",
734 ] {
735 let dir = temp_project(&format!("idem-{id}"));
736 let hook = HookSpec {
737 command: format!("guard hook {id}"),
738 matcher: Some("Bash".into()),
739 timeout: Some(10),
740 plugin_name: None,
741 description: None,
742 };
743 install_one(&dir, id, &hook);
744 let second = install(
745 Scope::Project(dir.as_path()),
746 harness::by_id(id).unwrap(),
747 &hook,
748 false,
749 )
750 .unwrap();
751 assert!(
752 second.iter().all(|w| w.status == FileStatus::Unchanged),
753 "second install of `{id}` must be all-unchanged, got {second:?}"
754 );
755 let _ = std::fs::remove_dir_all(&dir);
756 }
757 }
758
759 #[test]
761 fn check_mode_writes_nothing() {
762 let dir = temp_project("check");
763 let writes = install(
764 Scope::Project(&dir),
765 harness::by_id("codex").unwrap(),
766 &HookSpec::command("x"),
767 true,
768 )
769 .unwrap();
770 assert_eq!(writes[0].status, FileStatus::Created);
771 assert!(!dir.join(".codex/hooks.json").exists());
772 let _ = std::fs::remove_dir_all(&dir);
773 }
774
775 #[test]
777 fn unparseable_target_is_refused_and_untouched() {
778 let dir = temp_project("bad");
779 std::fs::create_dir_all(dir.join(".codex")).unwrap();
780 let path = dir.join(".codex/hooks.json");
781 std::fs::write(&path, "{ not json").unwrap();
782 let err = install(
783 Scope::Project(&dir),
784 harness::by_id("codex").unwrap(),
785 &HookSpec::command("x"),
786 false,
787 )
788 .unwrap_err();
789 assert!(err.to_string().contains("not valid JSON"), "{err}");
790 assert_eq!(std::fs::read_to_string(&path).unwrap(), "{ not json");
791 let _ = std::fs::remove_dir_all(&dir);
792 }
793
794 #[test]
797 fn every_harness_supports_hook_install() {
798 for spec in harness::all() {
799 let dir = temp_project(&format!("all-{}", spec.id));
800 let writes = install(
801 Scope::Project(&dir),
802 spec,
803 &HookSpec::command("guard hook x"),
804 false,
805 )
806 .unwrap_or_else(|e| panic!("{}: {e}", spec.id));
807 assert!(!writes.is_empty(), "{}: no writes", spec.id);
808 let _ = std::fs::remove_dir_all(&dir);
809 }
810 }
811
812 #[test]
817 fn global_scope_anchors_at_each_harness_user_location() {
818 let root = temp_project("global");
819 let home = root.join("home");
820 let xdg = root.join("xdg");
821 let dirs = GlobalDirs {
822 home: Some(home.clone()),
823 config_home: Some(xdg.clone()),
824 };
825 let hook = HookSpec {
826 plugin_name: Some("guard".into()),
827 ..HookSpec::command("guard hook x")
828 };
829 let cases: &[(&str, PathBuf)] = &[
830 ("claude-code", home.join(".claude/settings.json")),
831 ("codex", home.join(".codex/hooks.json")),
832 ("qwen", home.join(".qwen/settings.json")),
833 ("cursor", home.join(".cursor/hooks.json")),
834 ("copilot", home.join(".copilot/hooks/guard.json")),
835 ("crush", xdg.join("crush/crush.json")),
836 ("opencode", xdg.join("opencode/plugin/guard.js")),
837 ];
838 for (id, expected) in cases {
839 let writes = install(
840 Scope::Global(&dirs),
841 harness::by_id(id).unwrap(),
842 &hook,
843 false,
844 )
845 .unwrap();
846 assert_eq!(writes[0].path, *expected, "{id} global anchor");
847 assert!(expected.is_file(), "{id}: file not written at {expected:?}");
848 }
849 let goose = install(
851 Scope::Global(&dirs),
852 harness::by_id("goose").unwrap(),
853 &hook,
854 false,
855 )
856 .unwrap();
857 let plugin = home.join(".agents/plugins/guard");
858 assert_eq!(goose[0].path, plugin.join("plugin.json"));
859 assert_eq!(goose[1].path, plugin.join("hooks/hooks.json"));
860 let _ = std::fs::remove_dir_all(&root);
861 }
862
863 #[test]
866 fn global_and_project_opencode_shims_match() {
867 let root = temp_project("opencode-scope");
868 let project = root.join("proj");
869 std::fs::create_dir_all(&project).unwrap();
870 let dirs = GlobalDirs {
871 home: Some(root.join("home")),
872 config_home: Some(root.join("xdg")),
873 };
874 let hook = HookSpec::command("allowlister hook opencode");
875 let spec = harness::by_id("opencode").unwrap();
876 let proj = install(Scope::Project(&project), spec, &hook, false).unwrap();
877 let glob = install(Scope::Global(&dirs), spec, &hook, false).unwrap();
878 let proj_js = std::fs::read_to_string(&proj[0].path).unwrap();
879 let glob_js = std::fs::read_to_string(&glob[0].path).unwrap();
880 assert_eq!(
881 proj_js, glob_js,
882 "global and project shims must be identical"
883 );
884 let _ = std::fs::remove_dir_all(&root);
885 }
886
887 #[test]
889 fn config_home_falls_back_to_dot_config() {
890 let root = temp_project("xdg-fallback");
891 let home = root.join("home");
892 let dirs = GlobalDirs {
893 home: Some(home.clone()),
894 config_home: None,
895 };
896 let writes = install(
897 Scope::Global(&dirs),
898 harness::by_id("crush").unwrap(),
899 &HookSpec::command("x"),
900 false,
901 )
902 .unwrap();
903 assert_eq!(writes[0].path, home.join(".config/crush/crush.json"));
904 let _ = std::fs::remove_dir_all(&root);
905 }
906
907 #[test]
910 fn global_without_base_dir_is_a_loud_error() {
911 let dirs = GlobalDirs::default(); let err = install(
913 Scope::Global(&dirs),
914 harness::by_id("claude-code").unwrap(),
915 &HookSpec::command("x"),
916 false,
917 )
918 .unwrap_err();
919 assert!(err.to_string().contains("HOME is not set"), "{err}");
920 }
921}