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