Skip to main content

agent_config/agents/cline/
mod.rs

1//! Cline integration.
2//!
3//! Surfaces:
4//!
5//! 1. **Rules** — project-local markdown files at `.clinerules/<tag>.md`.
6//!    Same model as Roo / Kilo (one file per consumer, owned outright).
7//!
8//! 2. **Hooks (v3.36+)** — executable scripts at
9//!    `.clinerules/hooks/<event>` (Local) or `~/Documents/Cline/Hooks/<event>`
10//!    (Global, macOS/Linux only). Cline reads JSON event payloads on stdin
11//!    and inspects the script's exit code / JSON stdout. Filenames are event
12//!    names, so concurrent consumers wanting the same event must coordinate;
13//!    we record ownership in a sibling `.agent-config-hooks.json` ledger and
14//!    refuse to overwrite a hook owned by a different consumer.
15//!
16//! 3. **MCP servers** — global VS Code extension config at
17//!    `Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json`,
18//!    keyed by server name under `mcpServers`. Lives in `mcp.rs`.
19//!
20//! 4. **Skills** — directory-scoped skills at `.cline/skills/<name>/`
21//!    (Local) or `~/.cline/skills/<name>/` (Global). Lives in `skills.rs`.
22//!
23//! 5. **Instructions** — standalone files in `.clinerules/`. Lives in
24//!    `instructions.rs`.
25
26use 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/// Cline integration.
54#[derive(Debug, Clone, Copy, Default)]
55pub struct ClineAgent {
56    _private: (),
57}
58
59impl ClineAgent {
60    /// Construct an instance. Stateless.
61    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    /// Reports installed if either the rules file *or* a hook script for the
114    /// caller exists. (Tag is the consumer ID; hooks are keyed by event name
115    /// and recorded by tag in the ledger.)
116    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                // Surface a backup file if it exists for the rules markdown.
182                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            // Cline's hook contract is a `bash`-shebanged script chmod'd
268            // executable. On native Windows the chmod is a no-op and the
269            // shebang is meaningless, so the install would silently
270            // produce a non-runnable hook. Refuse before any mutation.
271            #[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            // Cline's hook script body is bash-shebanged and chmod'd 0o755;
419            // both are no-ops on native Windows. Refuse before any
420            // mutation so the install does not silently produce a
421            // non-runnable hook. See `plan_install` for the matching
422            // refusal in dry-run mode.
423            #[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                // Refuse to overwrite a hook owned by a different consumer.
454                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        // Any hook scripts owned by this tag (primary and legacy).
503        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        // Tidy: prune empty hooks/ then .cline/rules and .clinerules/ in case the rules path
544        // already pruned them.
545        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/// Map [`Event`] to Cline's filename convention. Custom names become
576/// file names, so they must be path-safe single components.
577#[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/// Minimal default hook body: pipe stdin to the rendered command and forward
658/// exit code. Safe program commands are shell-quoted before they reach here;
659/// unchecked shell commands intentionally pass through as shell syntax.
660#[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    // `Event` is imported in the parent module behind `cfg(not(windows))` so
673    // the lib build on Windows stays warning-clean. The test mod still needs
674    // it because `#[cfg(windows)]` tests in this file construct `HookSpec`s
675    // with `Event::PreToolUse`. Re-importing here keeps the symbol in scope
676    // on every platform without producing a duplicate-import warning.
677    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        // Rules-only spec does not enter the script-writing branch, so it
775        // must still install on Windows.
776        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        // appA's hook untouched.
893        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        // Hook surface install is POSIX-only (Cline writes a bash-shebanged
1035        // script and refuses on native Windows with `UnsupportedPlatform`).
1036        // Skip the hook portion of this dual-surface test on Windows; the
1037        // rules-surface assertion above already exercises `is_installed`.
1038        #[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}