1use std::path::PathBuf;
27
28use crate::error::AgentConfigError;
29use crate::integration::{InstallReport, Integration, UninstallReport};
30use crate::plan::{
31 has_refusal, InstallPlan, PlanTarget as DryPlanTarget, PlannedChange, RefusalReason,
32 UninstallPlan,
33};
34use crate::scope::{Scope, ScopeKind};
35use crate::spec::HookSpec;
36#[cfg(not(windows))]
37use crate::spec::{Event, ScriptTemplate};
38use crate::status::{InstallStatus, PathStatus, PlanTarget, StatusReport, StatusWarning};
39#[cfg(not(windows))]
40use crate::util::fs_atomic;
41use crate::util::{file_lock, ownership, planning, rules_dir, safe_fs};
42
43mod instructions;
44mod mcp;
45mod skills;
46
47pub(super) const RULES_DIR: &str = ".clinerules";
48const HOOKS_SUBDIR: &str = "hooks";
49#[cfg(not(windows))]
50const KIND: &str = "cline hook";
51
52#[derive(Debug, Clone, Copy, Default)]
54pub struct ClineAgent {
55 _private: (),
56}
57
58impl ClineAgent {
59 pub const fn new() -> Self {
61 Self { _private: () }
62 }
63
64 pub(super) fn project_root<'a>(
65 &self,
66 scope: &'a Scope,
67 ) -> Result<&'a std::path::Path, AgentConfigError> {
68 match scope {
69 Scope::Local(p) => Ok(p),
70 Scope::Global => Err(AgentConfigError::UnsupportedScope {
71 id: "cline",
72 scope: ScopeKind::Global,
73 }),
74 }
75 }
76
77 fn hooks_dir(&self, scope: &Scope) -> Result<PathBuf, AgentConfigError> {
81 Ok(self.project_root(scope)?.join(RULES_DIR).join(HOOKS_SUBDIR))
82 }
83
84 fn ledger_path(&self, scope: &Scope) -> Result<PathBuf, AgentConfigError> {
85 Ok(self.hooks_dir(scope)?.join(".agent-config-hooks.json"))
86 }
87}
88
89impl Integration for ClineAgent {
90 fn id(&self) -> &'static str {
91 "cline"
92 }
93
94 fn display_name(&self) -> &'static str {
95 "Cline"
96 }
97
98 fn supported_scopes(&self) -> &'static [ScopeKind] {
99 &[ScopeKind::Local]
100 }
101
102 fn status(&self, scope: &Scope, tag: &str) -> Result<StatusReport, AgentConfigError> {
106 HookSpec::validate_tag(tag)?;
107 let root = self.project_root(scope)?;
108 let rules_file = rules_dir::target_path(root, RULES_DIR, tag);
109 let rules_exists = rules_file.exists();
110
111 let ledger = self.ledger_path(scope)?;
112 let owned_hook_count = if ledger.exists() {
113 let v = crate::util::json_patch::read_or_empty(&ledger)?;
114 v.get("entries")
115 .and_then(|e| e.as_object())
116 .map(|m| {
117 m.values()
118 .filter(|entry| entry.get("owner").and_then(|o| o.as_str()) == Some(tag))
119 .count()
120 })
121 .unwrap_or(0)
122 } else {
123 0
124 };
125
126 let mut files = vec![if rules_exists {
127 PathStatus::Exists {
128 path: rules_file.clone(),
129 }
130 } else {
131 PathStatus::Missing {
132 path: rules_file.clone(),
133 }
134 }];
135 if ledger.exists() {
136 files.push(PathStatus::Exists {
137 path: ledger.clone(),
138 });
139 }
140
141 let mut warnings = Vec::new();
142 let status = if rules_exists || owned_hook_count > 0 {
143 InstallStatus::InstalledOwned {
144 owner: tag.to_string(),
145 }
146 } else {
147 let mut bak = rules_file.clone();
149 if let Some(name) = bak.file_name().map(|n| n.to_os_string()) {
150 if let Ok(mut s) = name.into_string() {
151 s.push_str(".bak");
152 bak.set_file_name(s);
153 if bak.exists() {
154 warnings.push(StatusWarning::BackupExists { path: bak });
155 }
156 }
157 }
158 InstallStatus::Absent
159 };
160
161 Ok(StatusReport {
162 target: PlanTarget::Hook {
163 tag: tag.to_string(),
164 },
165 status,
166 config_path: Some(rules_file),
167 ledger_path: Some(ledger),
168 files,
169 warnings,
170 })
171 }
172
173 fn plan_install(
174 &self,
175 scope: &Scope,
176 spec: &HookSpec,
177 ) -> Result<InstallPlan, AgentConfigError> {
178 HookSpec::validate_tag(&spec.tag)?;
179 let target = DryPlanTarget::Hook {
180 integration_id: Integration::id(self),
181 scope: scope.clone(),
182 tag: spec.tag.clone(),
183 };
184 let root = match self.project_root(scope) {
185 Ok(root) => root,
186 Err(AgentConfigError::UnsupportedScope { .. }) => {
187 return Ok(InstallPlan::refused(
188 target,
189 None,
190 RefusalReason::UnsupportedScope,
191 ));
192 }
193 Err(e) => return Err(e),
194 };
195 let mut changes = Vec::new();
196 if let Some(rules) = &spec.rules {
197 changes.extend(rules_dir::plan_install(
198 root,
199 RULES_DIR,
200 &spec.tag,
201 &rules.content,
202 )?);
203 }
204 if has_refusal(&changes) {
205 return Ok(InstallPlan::from_changes(target, changes));
206 }
207
208 if spec.script.is_some() || spec.rules.is_none() {
209 #[cfg(windows)]
214 {
215 return Ok(InstallPlan::refused(
216 target,
217 None,
218 RefusalReason::UnsupportedPlatform,
219 ));
220 }
221 #[cfg(not(windows))]
222 {
223 let body = match &spec.script {
224 Some(ScriptTemplate::Shell(s)) => {
225 fs_atomic::ensure_trailing_newline(&prefix_shebang(s))
226 }
227 Some(ScriptTemplate::TypeScript(_)) => {
228 return Ok(InstallPlan::refused(
229 target,
230 None,
231 RefusalReason::MissingRequiredSpecField,
232 ));
233 }
234 None => default_hook_body(&spec.command.render_shell()),
235 };
236 let event_filename = event_to_filename(&spec.event)?;
237 let path = self.hooks_dir(scope)?.join(&event_filename);
238 let ledger = self.ledger_path(scope)?;
239 let actual_owner = ownership::owner_of(&ledger, &event_filename)?;
240 match (actual_owner.as_deref(), path.exists()) {
241 (Some(owner), _) if owner != spec.tag => {
242 changes.push(PlannedChange::Refuse {
243 path: Some(ledger),
244 reason: RefusalReason::OwnerMismatch,
245 });
246 return Ok(InstallPlan::from_changes(target, changes));
247 }
248 (None, true) => {
249 changes.push(PlannedChange::Refuse {
250 path: Some(path),
251 reason: RefusalReason::UserInstalledEntry,
252 });
253 return Ok(InstallPlan::from_changes(target, changes));
254 }
255 _ => {}
256 }
257 planning::plan_write_file(&mut changes, &path, body.as_bytes(), false)?;
258 planning::plan_set_permissions(&mut changes, &path, 0o755);
259 let owner_changed = actual_owner.as_deref() != Some(spec.tag.as_str());
260 let file_changed = changes.iter().any(|change| {
261 matches!(
262 change,
263 PlannedChange::CreateFile { .. } | PlannedChange::PatchFile { .. }
264 )
265 });
266 if owner_changed || file_changed {
267 planning::plan_write_ledger(&mut changes, &ledger, &event_filename, &spec.tag);
268 }
269 }
270 }
271
272 Ok(InstallPlan::from_changes(target, changes))
273 }
274
275 fn plan_uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallPlan, AgentConfigError> {
276 HookSpec::validate_tag(tag)?;
277 let target = DryPlanTarget::Hook {
278 integration_id: Integration::id(self),
279 scope: scope.clone(),
280 tag: tag.to_string(),
281 };
282 let root = match self.project_root(scope) {
283 Ok(root) => root,
284 Err(AgentConfigError::UnsupportedScope { .. }) => {
285 return Ok(UninstallPlan::refused(
286 target,
287 None,
288 RefusalReason::UnsupportedScope,
289 ));
290 }
291 Err(e) => return Err(e),
292 };
293 let mut changes = rules_dir::plan_uninstall(root, RULES_DIR, tag)?;
294 let ledger = self.ledger_path(scope)?;
295 if ledger.exists() {
296 let v = match crate::util::json_patch::read_or_empty(&ledger) {
297 Ok(v) => v,
298 Err(AgentConfigError::JsonInvalid { .. }) => {
299 changes.push(PlannedChange::Refuse {
300 path: Some(ledger),
301 reason: RefusalReason::InvalidConfig,
302 });
303 return Ok(UninstallPlan::from_changes(target, changes));
304 }
305 Err(e) => return Err(e),
306 };
307 let owned: Vec<String> = v
308 .get("entries")
309 .and_then(|e| e.as_object())
310 .map(|m| {
311 m.iter()
312 .filter(|(_, entry)| {
313 entry.get("owner").and_then(|o| o.as_str()) == Some(tag)
314 })
315 .map(|(k, _)| k.clone())
316 .collect()
317 })
318 .unwrap_or_default();
319 for filename in owned {
320 if validate_custom_event_filename(&filename).is_err() {
321 changes.push(PlannedChange::Refuse {
322 path: Some(ledger),
323 reason: RefusalReason::InvalidConfig,
324 });
325 return Ok(UninstallPlan::from_changes(target, changes));
326 }
327 let path = self.hooks_dir(scope)?.join(&filename);
328 if path.exists() {
329 changes.push(PlannedChange::RemoveFile { path });
330 }
331 planning::plan_remove_ledger_entry(&mut changes, &ledger, &filename);
332 }
333 }
334 Ok(UninstallPlan::from_changes(target, changes))
335 }
336
337 fn install(&self, scope: &Scope, spec: &HookSpec) -> Result<InstallReport, AgentConfigError> {
338 HookSpec::validate_tag(&spec.tag)?;
339 let root = self.project_root(scope)?;
340 let mut report = InstallReport::default();
341
342 if let Some(rules) = &spec.rules {
343 scope.ensure_contained(&rules_dir::target_path(root, RULES_DIR, &spec.tag))?;
344 let r = rules_dir::install(scope, RULES_DIR, &spec.tag, &rules.content)?;
345 report.merge(r);
346 }
347
348 if spec.script.is_some() || spec.rules.is_none() {
349 #[cfg(windows)]
355 {
356 return Err(AgentConfigError::UnsupportedPlatform {
357 id: "cline",
358 reason: "Cline hooks require a POSIX shell environment; native Windows is not supported",
359 });
360 }
361 #[cfg(not(windows))]
362 let hooks_dir = self.hooks_dir(scope)?;
363 #[cfg(not(windows))]
364 scope.ensure_contained(&hooks_dir)?;
365 #[cfg(not(windows))]
366 file_lock::with_lock(&hooks_dir, || {
367 let body = match &spec.script {
368 Some(ScriptTemplate::Shell(s)) => {
369 fs_atomic::ensure_trailing_newline(&prefix_shebang(s))
370 }
371 Some(ScriptTemplate::TypeScript(_)) => {
372 return Err(AgentConfigError::MissingSpecField {
373 id: "cline",
374 field: "script (Shell — TypeScript not supported)",
375 });
376 }
377 None => default_hook_body(&spec.command.render_shell()),
378 };
379
380 let event_filename = event_to_filename(&spec.event)?;
381 let path = hooks_dir.join(&event_filename);
382 let ledger = hooks_dir.join(".agent-config-hooks.json");
383
384 ownership::require_owner(&ledger, &event_filename, &spec.tag, KIND, path.exists())?;
386
387 let outcome = safe_fs::write(scope, &path, body.as_bytes(), false)?;
388 #[cfg(unix)]
389 safe_fs::chmod(scope, &path, 0o755)?;
390 if !outcome.no_change {
391 if outcome.existed {
392 report.patched.push(outcome.path.clone());
393 } else {
394 report.created.push(outcome.path.clone());
395 }
396 let hash = ownership::content_hash(body.as_bytes());
397 ownership::record_install(&ledger, &event_filename, &spec.tag, Some(&hash))?;
398 report.already_installed = false;
399 } else {
400 let prior = ownership::owner_of(&ledger, &event_filename)?;
401 if prior.as_deref() != Some(spec.tag.as_str()) {
402 let hash = ownership::content_hash(body.as_bytes());
403 ownership::record_install(
404 &ledger,
405 &event_filename,
406 &spec.tag,
407 Some(&hash),
408 )?;
409 report.already_installed = false;
410 } else if report.created.is_empty() && report.patched.is_empty() {
411 report.already_installed = true;
412 }
413 }
414 Ok::<(), AgentConfigError>(())
415 })?;
416 }
417 Ok(report)
418 }
419
420 fn uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallReport, AgentConfigError> {
421 HookSpec::validate_tag(tag)?;
422 let root = self.project_root(scope)?;
423 let mut report = UninstallReport::default();
424
425 scope.ensure_contained(&rules_dir::target_path(root, RULES_DIR, tag))?;
426 let r = rules_dir::uninstall(scope, RULES_DIR, tag)?;
427 report.merge(r);
428
429 let hooks_dir = self.hooks_dir(scope)?;
431 scope.ensure_contained(&hooks_dir)?;
432 file_lock::with_lock(&hooks_dir, || {
433 let ledger = hooks_dir.join(".agent-config-hooks.json");
434 if ledger.exists() {
435 let v = crate::util::json_patch::read_or_empty(&ledger)?;
436 let owned: Vec<String> = v
437 .get("entries")
438 .and_then(|e| e.as_object())
439 .map(|m| {
440 m.iter()
441 .filter(|(_, entry)| {
442 entry.get("owner").and_then(|o| o.as_str()) == Some(tag)
443 })
444 .map(|(k, _)| k.clone())
445 .collect()
446 })
447 .unwrap_or_default();
448
449 for filename in owned {
450 validate_custom_event_filename(&filename)?;
451 let path = hooks_dir.join(&filename);
452 if path.exists() {
453 safe_fs::remove_file(scope, &path)?;
454 report.removed.push(path);
455 }
456 ownership::record_uninstall(&ledger, &filename)?;
457 }
458 }
459 Ok::<(), AgentConfigError>(())
460 })?;
461
462 for empty_dir in [self.hooks_dir(scope)?, root.join(RULES_DIR)] {
465 if let Ok(mut entries) = std::fs::read_dir(&empty_dir) {
466 if entries.next().is_none() {
467 let _ = safe_fs::remove_empty_dir(scope, &empty_dir);
468 }
469 }
470 }
471
472 if report.removed.is_empty() && report.patched.is_empty() && report.restored.is_empty() {
473 report.not_installed = true;
474 }
475 Ok(report)
476 }
477}
478
479#[cfg(not(windows))]
482fn event_to_filename(event: &Event) -> Result<String, AgentConfigError> {
483 match event {
484 Event::PreToolUse => Ok("PreToolUse".into()),
485 Event::PostToolUse => Ok("PostToolUse".into()),
486 Event::Custom(s) => validate_custom_event_filename(s).map(|()| s.clone()),
487 }
488}
489
490fn validate_custom_event_filename(name: &str) -> Result<(), AgentConfigError> {
491 if name.is_empty() {
492 return Err(AgentConfigError::InvalidTag {
493 tag: name.into(),
494 reason: "Cline custom event must not be empty",
495 });
496 }
497 if !name
498 .chars()
499 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
500 {
501 return Err(AgentConfigError::InvalidTag {
502 tag: name.into(),
503 reason: "Cline custom event may only contain ASCII letters, digits, '_' and '-'",
504 });
505 }
506 Ok(())
507}
508
509#[cfg(not(windows))]
510fn prefix_shebang(s: &str) -> String {
511 if s.starts_with("#!") {
512 s.to_string()
513 } else {
514 format!("#!/usr/bin/env bash\n{s}")
515 }
516}
517
518#[cfg(not(windows))]
522fn default_hook_body(command: &str) -> String {
523 format!(
524 "#!/usr/bin/env bash\n# Generated by agent-config.\n# Forwards Cline's JSON event payload to the consumer command.\n{command}\n"
525 )
526}
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531 use crate::integration::McpSurface;
532 use crate::spec::McpSpec;
533 use crate::spec::Event;
539 use std::fs;
540 use tempfile::tempdir;
541
542 fn rules_spec(tag: &str, body: &str) -> HookSpec {
543 HookSpec::builder(tag)
544 .command_program("noop", [] as [&str; 0])
545 .rules(body)
546 .build()
547 }
548
549 fn hook_spec(tag: &str, event: Event, command: &str) -> HookSpec {
550 HookSpec::builder(tag)
551 .command_shell_unchecked(command)
552 .event(event)
553 .build()
554 }
555
556 #[test]
557 fn install_rules_writes_dot_clinerules_file() {
558 let dir = tempdir().unwrap();
559 let agent = ClineAgent::new();
560 let scope = Scope::Local(dir.path().to_path_buf());
561 agent
562 .install(&scope, &rules_spec("alpha", "rule body"))
563 .unwrap();
564 let p = dir.path().join(".clinerules/alpha.md");
565 assert!(p.exists());
566 assert_eq!(fs::read_to_string(&p).unwrap(), "rule body\n");
567 }
568
569 #[cfg(not(windows))]
570 #[test]
571 fn install_hook_default_writes_executable_script() {
572 let dir = tempdir().unwrap();
573 let agent = ClineAgent::new();
574 let scope = Scope::Local(dir.path().to_path_buf());
575 agent
576 .install(
577 &scope,
578 &hook_spec("alpha", Event::PreToolUse, "myapp hook cline"),
579 )
580 .unwrap();
581 let p = dir.path().join(".clinerules/hooks/PreToolUse");
582 assert!(p.exists());
583 let body = fs::read_to_string(&p).unwrap();
584 assert!(body.starts_with("#!/usr/bin/env bash"));
585 assert!(body.contains("myapp hook cline"));
586 #[cfg(unix)]
587 {
588 use std::os::unix::fs::PermissionsExt;
589 let mode = fs::metadata(&p).unwrap().permissions().mode() & 0o777;
590 assert_eq!(mode, 0o755);
591 }
592 }
593
594 #[cfg(windows)]
595 #[test]
596 fn plan_install_hook_refuses_on_windows() {
597 let dir = tempdir().unwrap();
598 let agent = ClineAgent::new();
599 let scope = Scope::Local(dir.path().to_path_buf());
600 let spec = hook_spec("alpha", Event::PreToolUse, "noop");
601 let plan = agent.plan_install(&scope, &spec).unwrap();
602 assert_eq!(plan.status, crate::plan::PlanStatus::Refused);
603 let refusal = plan
604 .changes
605 .iter()
606 .find_map(|c| match c {
607 PlannedChange::Refuse { reason, .. } => Some(*reason),
608 _ => None,
609 })
610 .expect("plan should contain a refusal");
611 assert!(matches!(refusal, RefusalReason::UnsupportedPlatform));
612 }
613
614 #[cfg(windows)]
615 #[test]
616 fn install_hook_returns_unsupported_platform_on_windows() {
617 let dir = tempdir().unwrap();
618 let agent = ClineAgent::new();
619 let scope = Scope::Local(dir.path().to_path_buf());
620 let spec = hook_spec("alpha", Event::PreToolUse, "noop");
621 let err = agent.install(&scope, &spec).unwrap_err();
622 assert!(matches!(
623 err,
624 AgentConfigError::UnsupportedPlatform { id: "cline", .. }
625 ));
626 assert!(!dir.path().join(".clinerules/hooks/PreToolUse").exists());
627 }
628
629 #[cfg(windows)]
630 #[test]
631 fn install_rules_only_still_works_on_windows() {
632 let dir = tempdir().unwrap();
633 let agent = ClineAgent::new();
634 let scope = Scope::Local(dir.path().to_path_buf());
635 agent
638 .install(&scope, &rules_spec("alpha", "rule body"))
639 .unwrap();
640 assert!(dir.path().join(".clinerules/alpha.md").exists());
641 }
642
643 #[cfg(not(windows))]
644 #[test]
645 fn install_hook_default_quotes_program_arguments() {
646 let dir = tempdir().unwrap();
647 let agent = ClineAgent::new();
648 let scope = Scope::Local(dir.path().to_path_buf());
649 let spec = HookSpec::builder("alpha")
650 .command_program(
651 "my hook",
652 ["repo path", "semi;$(not run)", "`tick`", "quote's"],
653 )
654 .build();
655
656 agent.install(&scope, &spec).unwrap();
657
658 let body = fs::read_to_string(dir.path().join(".clinerules/hooks/PreToolUse")).unwrap();
659 assert!(body.contains("\n'my hook' 'repo path' 'semi;$(not run)' '`tick`' 'quote'\\''s'\n"));
660 }
661
662 #[cfg(not(windows))]
663 #[test]
664 fn install_hook_with_custom_script_body() {
665 let dir = tempdir().unwrap();
666 let agent = ClineAgent::new();
667 let scope = Scope::Local(dir.path().to_path_buf());
668 let s = HookSpec::builder("alpha")
669 .command_program("noop", [] as [&str; 0])
670 .event(Event::Custom("TaskStart".into()))
671 .script(ScriptTemplate::Shell("echo started".into()))
672 .build();
673 agent.install(&scope, &s).unwrap();
674 let p = dir.path().join(".clinerules/hooks/TaskStart");
675 assert!(p.exists());
676 let body = fs::read_to_string(&p).unwrap();
677 assert!(body.contains("echo started"));
678 }
679
680 #[cfg(not(windows))]
681 #[test]
682 fn install_hook_rejects_unsafe_custom_event_filename() {
683 let dir = tempdir().unwrap();
684 let agent = ClineAgent::new();
685 let scope = Scope::Local(dir.path().to_path_buf());
686
687 for bad in [
688 "../TaskStart",
689 "/tmp/TaskStart",
690 "C:\\TaskStart",
691 "Task.Start",
692 ] {
693 let spec = HookSpec::builder("alpha")
694 .command_program("noop", [] as [&str; 0])
695 .event(Event::Custom(bad.into()))
696 .build();
697 let err = agent.install(&scope, &spec).unwrap_err();
698 assert!(
699 matches!(err, AgentConfigError::InvalidTag { .. }),
700 "expected invalid custom event for {bad:?}"
701 );
702 }
703
704 assert!(!dir.path().join(".clinerules/hooks").exists());
705 }
706
707 #[cfg(not(windows))]
708 #[test]
709 fn plan_hook_rejects_unsafe_custom_event_filename() {
710 let dir = tempdir().unwrap();
711 let agent = ClineAgent::new();
712 let scope = Scope::Local(dir.path().to_path_buf());
713 let spec = HookSpec::builder("alpha")
714 .command_program("noop", [] as [&str; 0])
715 .event(Event::Custom("../TaskStart".into()))
716 .build();
717
718 let err = agent.plan_install(&scope, &spec).unwrap_err();
719 assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
720 }
721
722 #[cfg(not(windows))]
723 #[test]
724 fn install_hook_records_ownership() {
725 let dir = tempdir().unwrap();
726 let agent = ClineAgent::new();
727 let scope = Scope::Local(dir.path().to_path_buf());
728 agent
729 .install(&scope, &hook_spec("myapp", Event::PreToolUse, "noop"))
730 .unwrap();
731 let ledger = dir
732 .path()
733 .join(".clinerules/hooks/.agent-config-hooks.json");
734 assert!(ledger.exists());
735 let v: serde_json::Value = serde_json::from_slice(&fs::read(&ledger).unwrap()).unwrap();
736 assert_eq!(
737 v["entries"]["PreToolUse"]["owner"],
738 serde_json::json!("myapp")
739 );
740 }
741
742 #[cfg(not(windows))]
743 #[test]
744 fn install_hook_collision_with_other_owner_refused() {
745 let dir = tempdir().unwrap();
746 let agent = ClineAgent::new();
747 let scope = Scope::Local(dir.path().to_path_buf());
748 agent
749 .install(&scope, &hook_spec("appA", Event::PreToolUse, "a"))
750 .unwrap();
751 let err = agent
752 .install(&scope, &hook_spec("appB", Event::PreToolUse, "b"))
753 .unwrap_err();
754 assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
755 let body = fs::read_to_string(dir.path().join(".clinerules/hooks/PreToolUse")).unwrap();
757 assert!(body.contains("a\n"));
758 }
759
760 #[cfg(not(windows))]
761 #[test]
762 fn install_idempotent_for_hook() {
763 let dir = tempdir().unwrap();
764 let agent = ClineAgent::new();
765 let scope = Scope::Local(dir.path().to_path_buf());
766 let s = hook_spec("alpha", Event::PreToolUse, "noop");
767 agent.install(&scope, &s).unwrap();
768 let r2 = agent.install(&scope, &s).unwrap();
769 assert!(r2.already_installed);
770 }
771
772 #[cfg(not(windows))]
773 #[test]
774 fn install_typescript_script_rejected() {
775 let dir = tempdir().unwrap();
776 let agent = ClineAgent::new();
777 let scope = Scope::Local(dir.path().to_path_buf());
778 let s = HookSpec::builder("alpha")
779 .command_program("noop", [] as [&str; 0])
780 .script(ScriptTemplate::TypeScript("export {}".into()))
781 .build();
782 let err = agent.install(&scope, &s).unwrap_err();
783 assert!(matches!(err, AgentConfigError::MissingSpecField { .. }));
784 }
785
786 #[test]
787 fn install_with_only_rules_does_not_create_hook() {
788 let dir = tempdir().unwrap();
789 let agent = ClineAgent::new();
790 let scope = Scope::Local(dir.path().to_path_buf());
791 agent.install(&scope, &rules_spec("alpha", "body")).unwrap();
792 assert!(dir.path().join(".clinerules/alpha.md").exists());
793 assert!(!dir.path().join(".clinerules/hooks").exists());
794 }
795
796 #[cfg(not(windows))]
797 #[test]
798 fn install_with_rules_and_script_creates_both() {
799 let dir = tempdir().unwrap();
800 let agent = ClineAgent::new();
801 let scope = Scope::Local(dir.path().to_path_buf());
802 let s = HookSpec::builder("alpha")
803 .command_program("noop", [] as [&str; 0])
804 .event(Event::PreToolUse)
805 .rules("rules body")
806 .script(ScriptTemplate::Shell("echo hi".into()))
807 .build();
808 agent.install(&scope, &s).unwrap();
809 assert!(dir.path().join(".clinerules/alpha.md").exists());
810 assert!(dir.path().join(".clinerules/hooks/PreToolUse").exists());
811 }
812
813 #[cfg(not(windows))]
814 #[test]
815 fn uninstall_removes_rules_and_owned_hooks() {
816 let dir = tempdir().unwrap();
817 let agent = ClineAgent::new();
818 let scope = Scope::Local(dir.path().to_path_buf());
819 agent
820 .install(
821 &scope,
822 &HookSpec::builder("alpha")
823 .command_program("noop", [] as [&str; 0])
824 .event(Event::PreToolUse)
825 .rules("body")
826 .build(),
827 )
828 .unwrap();
829 agent.uninstall(&scope, "alpha").unwrap();
830 assert!(!dir.path().join(".clinerules").exists());
831 }
832
833 #[cfg(not(windows))]
834 #[test]
835 fn uninstall_keeps_other_consumers_hooks() {
836 let dir = tempdir().unwrap();
837 let agent = ClineAgent::new();
838 let scope = Scope::Local(dir.path().to_path_buf());
839 agent
840 .install(&scope, &hook_spec("appA", Event::PreToolUse, "a"))
841 .unwrap();
842 agent
843 .install(&scope, &hook_spec("appB", Event::PostToolUse, "b"))
844 .unwrap();
845 agent.uninstall(&scope, "appA").unwrap();
846 assert!(!dir.path().join(".clinerules/hooks/PreToolUse").exists());
847 assert!(dir.path().join(".clinerules/hooks/PostToolUse").exists());
848 }
849
850 #[test]
851 fn uninstall_rejects_unsafe_ledger_filename() {
852 let dir = tempdir().unwrap();
853 let agent = ClineAgent::new();
854 let scope = Scope::Local(dir.path().to_path_buf());
855 let hooks_dir = dir.path().join(".clinerules/hooks");
856 fs::create_dir_all(&hooks_dir).unwrap();
857 fs::write(
858 hooks_dir.join(".agent-config-hooks.json"),
859 r#"{"entries":{"../escape":{"owner":"alpha"}}}"#,
860 )
861 .unwrap();
862 let escaped = dir.path().join(".clinerules/escape");
863 fs::write(&escaped, "do not remove").unwrap();
864
865 let err = agent.uninstall(&scope, "alpha").unwrap_err();
866 assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
867 assert!(escaped.exists());
868 }
869
870 #[test]
871 fn uninstall_unknown_tag_is_noop() {
872 let dir = tempdir().unwrap();
873 let agent = ClineAgent::new();
874 let scope = Scope::Local(dir.path().to_path_buf());
875 let r = agent.uninstall(&scope, "ghost").unwrap();
876 assert!(r.not_installed);
877 }
878
879 #[test]
880 fn rejects_global_scope() {
881 let agent = ClineAgent::new();
882 let err = agent.is_installed(&Scope::Global, "x").unwrap_err();
883 assert!(matches!(err, AgentConfigError::UnsupportedScope { .. }));
884 }
885
886 #[test]
887 fn is_installed_detects_either_surface() {
888 let dir = tempdir().unwrap();
889 let agent = ClineAgent::new();
890 let scope = Scope::Local(dir.path().to_path_buf());
891 assert!(!agent.is_installed(&scope, "alpha").unwrap());
892 agent.install(&scope, &rules_spec("alpha", "body")).unwrap();
893 assert!(agent.is_installed(&scope, "alpha").unwrap());
894 agent.uninstall(&scope, "alpha").unwrap();
895
896 #[cfg(not(windows))]
901 {
902 agent
903 .install(&scope, &hook_spec("alpha", Event::PreToolUse, "x"))
904 .unwrap();
905 assert!(agent.is_installed(&scope, "alpha").unwrap());
906 }
907 }
908
909 #[test]
910 fn mcp_supports_global_only() {
911 let agent = ClineAgent::new();
912 assert_eq!(agent.supported_mcp_scopes(), &[ScopeKind::Global]);
913
914 let dir = tempdir().unwrap();
915 let scope = Scope::Local(dir.path().to_path_buf());
916 let spec = McpSpec::builder("github")
917 .owner("myapp")
918 .stdio("npx", ["@example/server"])
919 .build();
920 let err = agent.install_mcp(&scope, &spec).unwrap_err();
921 assert!(matches!(
922 err,
923 AgentConfigError::UnsupportedScope {
924 scope: ScopeKind::Local,
925 ..
926 }
927 ));
928 }
929}