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};
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/// Cline integration.
53#[derive(Debug, Clone, Copy, Default)]
54pub struct ClineAgent {
55    _private: (),
56}
57
58impl ClineAgent {
59    /// Construct an instance. Stateless.
60    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    /// `.clinerules/hooks/` (Local). Global is unsupported (Cline's
78    /// `~/Documents/Cline/Hooks/` is macOS/Linux-only and the path
79    /// convention is unstable enough that we leave it out of v0.1).
80    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    /// Reports installed if either the rules file *or* a hook script for the
103    /// caller exists. (Tag is the consumer ID; hooks are keyed by event name
104    /// and recorded by tag in the ledger.)
105    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            // Surface a backup file if it exists for the rules markdown.
148            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            // Cline's hook contract is a `bash`-shebanged script chmod'd
210            // executable. On native Windows the chmod is a no-op and the
211            // shebang is meaningless, so the install would silently
212            // produce a non-runnable hook. Refuse before any mutation.
213            #[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            // Cline's hook script body is bash-shebanged and chmod'd 0o755;
350            // both are no-ops on native Windows. Refuse before any
351            // mutation so the install does not silently produce a
352            // non-runnable hook. See `plan_install` for the matching
353            // refusal in dry-run mode.
354            #[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                // Refuse to overwrite a hook owned by a different consumer.
385                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        // Any hook scripts owned by this tag.
430        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        // Tidy: prune empty hooks/ then .clinerules/ in case the rules path
463        // already pruned them.
464        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/// Map [`Event`] to Cline's filename convention. Custom names become
480/// file names, so they must be path-safe single components.
481#[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/// Minimal default hook body: pipe stdin to the rendered command and forward
519/// exit code. Safe program commands are shell-quoted before they reach here;
520/// unchecked shell commands intentionally pass through as shell syntax.
521#[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    // `Event` is imported in the parent module behind `cfg(not(windows))` so
534    // the lib build on Windows stays warning-clean. The test mod still needs
535    // it because `#[cfg(windows)]` tests in this file construct `HookSpec`s
536    // with `Event::PreToolUse`. Re-importing here keeps the symbol in scope
537    // on every platform without producing a duplicate-import warning.
538    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        // Rules-only spec does not enter the script-writing branch, so it
636        // must still install on Windows.
637        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        // appA's hook untouched.
756        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        // Hook surface install is POSIX-only (Cline writes a bash-shebanged
897        // script and refuses on native Windows with `UnsupportedPlatform`).
898        // Skip the hook portion of this dual-surface test on Windows; the
899        // rules-surface assertion above already exercises `is_installed`.
900        #[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}