use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
use std::fs;
use std::path::{Path, PathBuf};
use super::cchooks::hook_is_portable;
use super::confedit::{remove_file_idem, write_file_idem};
use super::mcpjson::{self, RemoteShape, ServerShape};
use super::report;
use super::{AgentBackend, BackendState};
use crate::components::{HookBinding, MarkdownDoc};
use crate::doctor::{CheckStatus, DoctorCheck, DoctorReport};
use crate::error::{Error, Result};
use crate::host::{Capabilities, Desired, Outcome, Plugin, Scope, Source};
pub(crate) struct ClineBackend;
const SHAPE: ServerShape = ServerShape::plain().with_remote(RemoteShape::StreamableHttpValue);
impl AgentBackend for ClineBackend {
fn id(&self) -> &'static str {
"cline"
}
fn detect(&self) -> bool {
which::which("cline").is_ok() || detect_dirs().iter().any(|d| d.is_dir())
}
fn capabilities(&self) -> Capabilities {
Capabilities {
plugins: false,
mcp: true,
hooks: true,
commands: true,
agents: false,
skills: false,
instructions: false,
statusline: false,
scopes: &["user", "project"],
}
}
fn probe(&self, plugin: &Plugin, scope: &Scope, source: &Source) -> Result<BackendState> {
let comp = plugin.components(source)?.with_client(self.id());
let mcp = mcpjson::probe_surface(&mcp_settings_path()?, &["mcpServers"], &comp.mcp_servers, SHAPE)?;
let hooks = probe_hooks(&hooks_dir(scope)?, plugin.name, &comp.hooks)?;
let commands = probe_workflows(&workflows_dir(scope)?, plugin.name, &comp.commands)?;
Ok(report::compose([mcp, hooks, commands].into_iter().flatten()))
}
fn reconcile(&self, plugin: &Plugin, desired: &Desired, scope: &Scope) -> Result<Outcome> {
let comp = plugin.components(&desired.source)?.with_client(self.id());
let mut changed = false;
let settings = mcp_settings_path()?;
changed |= mcpjson::reconcile(&settings, &["mcpServers"], &comp.mcp_servers, SHAPE)? != Outcome::NoOp;
changed |= reconcile_hooks(&hooks_dir(scope)?, plugin.name, &comp.hooks)?;
if let Some(retired) = retired_hooks_dir(scope)? {
changed |= remove_hooks(&retired, plugin.name, &comp.hooks)?;
}
let wf_root = workflows_dir(scope)?;
for doc in &comp.commands {
changed |= write_file_idem(&wf_root.join(workflow_file(plugin.name, doc)), workflow_body(doc).as_bytes())?;
}
Ok(if changed { Outcome::Installed } else { Outcome::NoOp })
}
fn remove(&self, plugin: &Plugin, scope: &Scope, source: &Source) -> Result<Outcome> {
let comp = plugin.components(source)?.with_client(self.id());
let mut changed = false;
let settings = mcp_settings_path()?;
changed |= mcpjson::remove(&settings, &["mcpServers"], &comp.mcp_servers, SHAPE)? != Outcome::NoOp;
changed |= remove_hooks(&hooks_dir(scope)?, plugin.name, &comp.hooks)?;
let wf_root = workflows_dir(scope)?;
for doc in &comp.commands {
changed |= remove_file_idem(&wf_root.join(workflow_file(plugin.name, doc)))?;
}
Ok(if changed { Outcome::Removed } else { Outcome::NoOp })
}
fn report(&self, plugin: &Plugin, source: &Source) -> DoctorReport {
DoctorReport::from_checks(report_checks(self, plugin, source))
}
}
fn env_path(var: &str) -> Option<PathBuf> {
std::env::var_os(var).filter(|v| !v.is_empty()).map(PathBuf::from)
}
fn store_root() -> Option<PathBuf> {
env_path("CLINE_DIR").or_else(|| dirs::home_dir().map(|h| h.join(".cline")))
}
fn detect_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
dirs.extend(store_root()); if let Ok(settings) = mcp_settings_path() {
dirs.extend(settings.parent().map(Path::to_path_buf));
}
if let Some(home) = dirs::home_dir() {
dirs.push(home.join("Documents").join("Cline")); }
if let Some(config) = dirs::config_dir() {
dirs.push(config.join("Code").join("User").join("globalStorage").join("saoudrizwan.claude-dev"));
}
dirs
}
fn mcp_settings_path() -> Result<PathBuf> {
if let Some(path) = env_path("CLINE_MCP_SETTINGS_PATH") {
return Ok(path);
}
if let Some(data) = env_path("CLINE_DATA_DIR").or_else(|| env_path("CLINE_DIR").map(|d| d.join("data"))) {
return Ok(data.join("settings").join("cline_mcp_settings.json"));
}
let mut candidates = Vec::new();
if let Some(home) = dirs::home_dir() {
candidates.push(home.join(".cline").join("data").join("settings").join("cline_mcp_settings.json"));
}
if let Some(config) = dirs::config_dir() {
candidates.push(
config
.join("Code")
.join("User")
.join("globalStorage")
.join("saoudrizwan.claude-dev")
.join("settings")
.join("cline_mcp_settings.json"),
);
}
candidates
.iter()
.find(|p| p.exists())
.cloned()
.or_else(|| candidates.into_iter().next())
.ok_or_else(|| Error::Tree("no home or config directory; cannot locate cline_mcp_settings.json".into()))
}
fn hooks_dir(scope: &Scope) -> Result<PathBuf> {
match scope {
Scope::User => global_store().map(|s| s.join("Hooks")),
Scope::Project { path } => Ok(path.join(".clinerules").join("hooks")),
}
}
fn retired_hooks_dir(scope: &Scope) -> Result<Option<PathBuf>> {
match scope {
Scope::User => Ok(Some(global_store()?.join("Rules").join("Hooks"))),
Scope::Project { .. } => Ok(None),
}
}
fn workflows_dir(scope: &Scope) -> Result<PathBuf> {
match scope {
Scope::User => global_store().map(|s| s.join("Workflows")),
Scope::Project { path } => Ok(path.join(".clinerules").join("workflows")),
}
}
fn global_store() -> Result<PathBuf> {
dirs::home_dir()
.map(|h| h.join("Documents").join("Cline"))
.ok_or_else(|| Error::Tree("no home directory (HOME unset); cannot locate ~/Documents/Cline".into()))
}
fn map_event(cc_event: &str) -> Option<&'static str> {
match cc_event {
"UserPromptSubmit" => Some("UserPromptSubmit"),
"PreToolUse" => Some("PreToolUse"),
"PostToolUse" => Some("PostToolUse"),
"PreCompact" => Some("PreCompact"),
"SessionEnd" => Some("SessionShutdown"),
_ => None,
}
}
fn ownership_tag(plugin: &str) -> String {
format!("agentgear-managed:{plugin}")
}
fn file_is_ours(path: &Path, plugin: &str) -> bool {
fs::read_to_string(path).map(|s| s.contains(&ownership_tag(plugin))).unwrap_or(false)
}
fn render_hook_script(plugin: &str, hooks: &[&HookBinding]) -> String {
const TEMPLATE: &str = r##"#!/usr/bin/env bash
# __TAG__
# agentgear-translated cline hook. cline pipes the event JSON on stdin and reads
# {"cancel","contextModification"} from stdout; we inject the CC hook's stdout as
# context and never cancel. Do not edit: overwritten by the host's `setup`.
set -euo pipefail
cat >/dev/null
ctx=""
__CMDS__esc=$(printf '%s' "$ctx" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' | sed ':a;N;$!ba;s/\n/\\n/g')
printf '{"cancel": false, "contextModification": "%s"}\n' "$esc"
"##;
let mut cmds = String::new();
for hook in hooks {
let _ = writeln!(cmds, "ctx=\"$ctx$(bash -c {} 2>/dev/null || true)\"", shell_quote(&hook.command));
}
TEMPLATE.replace("__TAG__", &ownership_tag(plugin)).replace("__CMDS__", &cmds)
}
fn shell_quote(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('\'');
for ch in s.chars() {
if ch == '\'' {
out.push_str("'\\''");
} else {
out.push(ch);
}
}
out.push('\'');
out
}
fn reconcile_hooks(dir: &Path, plugin: &str, hooks: &[HookBinding]) -> Result<bool> {
let mut by_event: BTreeMap<&'static str, Vec<&HookBinding>> = BTreeMap::new();
for hook in hooks.iter().filter(|h| hook_is_portable(h)) {
if let Some(event) = map_event(&hook.event) {
by_event.entry(event).or_default().push(hook);
}
}
let mut changed = false;
for (event, group) in by_event {
let path = dir.join(event);
if path.exists() && !file_is_ours(&path, plugin) {
continue;
}
if write_file_idem(&path, render_hook_script(plugin, &group).as_bytes())? {
set_executable(&path)?;
changed = true;
}
}
Ok(changed)
}
fn expected_hooks(dir: &Path, plugin: &str, hooks: &[HookBinding]) -> Vec<(PathBuf, Vec<u8>)> {
let mut by_event: BTreeMap<&'static str, Vec<&HookBinding>> = BTreeMap::new();
for hook in hooks.iter().filter(|h| hook_is_portable(h)) {
if let Some(event) = map_event(&hook.event) {
by_event.entry(event).or_default().push(hook);
}
}
by_event.into_iter().map(|(event, group)| (dir.join(event), render_hook_script(plugin, &group).into_bytes())).collect()
}
fn probe_hooks(dir: &Path, plugin: &str, hooks: &[HookBinding]) -> Result<Option<BackendState>> {
let tag = ownership_tag(plugin);
report::probe_files(&expected_hooks(dir, plugin, hooks), |_, existing| std::str::from_utf8(existing).is_ok_and(|s| s.contains(&tag)))
}
fn probe_workflows(wf_root: &Path, plugin: &str, commands: &[MarkdownDoc]) -> Result<Option<BackendState>> {
let expected: Vec<(PathBuf, Vec<u8>)> =
commands.iter().map(|doc| (wf_root.join(workflow_file(plugin, doc)), workflow_body(doc).into_bytes())).collect();
report::probe_files(&expected, |_, _| true)
}
fn remove_hooks(dir: &Path, plugin: &str, hooks: &[HookBinding]) -> Result<bool> {
let events: BTreeSet<&'static str> = hooks.iter().filter(|h| hook_is_portable(h)).filter_map(|h| map_event(&h.event)).collect();
let mut changed = false;
for event in events {
let path = dir.join(event);
if path.exists() && file_is_ours(&path, plugin) {
changed |= remove_file_idem(&path)?;
}
}
Ok(changed)
}
#[cfg(unix)]
fn set_executable(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
use crate::error::IoContext;
let mut perms = fs::metadata(path).io_ctx(|| format!("stat {}", path.display()))?.permissions();
perms.set_mode(0o755);
fs::set_permissions(path, perms).io_ctx(|| format!("chmod +x {}", path.display()))
}
#[cfg(not(unix))]
fn set_executable(_path: &Path) -> Result<()> {
Ok(())
}
fn workflow_file(plugin: &str, doc: &MarkdownDoc) -> String {
format!("{plugin}-{}.md", flat_stem(&doc.rel, "commands/"))
}
fn flat_stem(rel: &str, prefix: &str) -> String {
let stripped = rel.strip_prefix(prefix).unwrap_or(rel);
let stem = stripped.strip_suffix(".md").unwrap_or(stripped);
stem.replace(['/', '\\'], "-")
}
fn workflow_body(doc: &MarkdownDoc) -> String {
let mut body = doc.body.trim().to_string();
body.push('\n');
body
}
fn report_checks(backend: &ClineBackend, plugin: &Plugin, source: &Source) -> Vec<DoctorCheck> {
let mut checks = Vec::new();
checks.push(if backend.detect() {
DoctorCheck { name: "cline detected", status: CheckStatus::Ok("`cline` on PATH or a cline store dir present".into()) }
} else {
DoctorCheck {
name: "cline detected",
status: CheckStatus::Fail {
problem: "cline not detected".into(),
fix: "install the Cline VS Code extension or the `cline` CLI".into(),
},
}
});
let settings = match mcp_settings_path() {
Ok(p) => p,
Err(e) => {
checks.push(DoctorCheck { name: "mcp settings file", status: CheckStatus::Warn(e.to_string()) });
return checks;
}
};
let root = report::read_json_config(&mut checks, "mcp settings file", &settings);
let Some(comp) = report::components(&mut checks, plugin, source).map(|c| c.with_client(backend.id())) else {
return checks;
};
checks.push(report::check_mcp_registered(
&comp.mcp_servers,
root.as_ref(),
&["mcpServers"],
"not in cline_mcp_settings.json",
"run the host's `setup`",
));
checks.push(report::check_mcp_command(&comp.mcp_servers));
checks.push(check_workflows_present(&comp.commands, plugin.name));
checks.push(check_hooks_present(&comp.hooks));
checks
}
fn check_workflows_present(commands: &[MarkdownDoc], plugin: &str) -> DoctorCheck {
let name = "translated workflows present";
if commands.is_empty() {
return DoctorCheck { name, status: CheckStatus::Ok("no commands to translate".into()) };
}
let wf_root = match workflows_dir(&Scope::User) {
Ok(p) => p,
Err(e) => return DoctorCheck { name, status: CheckStatus::Warn(e.to_string()) },
};
let missing: Vec<String> = commands.iter().map(|d| workflow_file(plugin, d)).filter(|f| !wf_root.join(f).exists()).collect();
if missing.is_empty() {
DoctorCheck { name, status: CheckStatus::Ok(format!("{} workflow file(s) present", commands.len())) }
} else {
DoctorCheck {
name,
status: CheckStatus::Fail {
problem: format!("workflow file(s) missing: {}", missing.join(", ")),
fix: "run the host's `setup`".into(),
},
}
}
}
fn check_hooks_present(hooks: &[HookBinding]) -> DoctorCheck {
let name = "translated hooks present";
let skipped = report::skipped_hooks(hooks);
let events: BTreeSet<&'static str> = hooks.iter().filter(|h| hook_is_portable(h)).filter_map(|h| map_event(&h.event)).collect();
if events.is_empty() {
return report::note_skipped(DoctorCheck { name, status: CheckStatus::Ok("no hooks map to a cline event".into()) }, &skipped);
}
let hook_root = match hooks_dir(&Scope::User) {
Ok(p) => p,
Err(e) => return DoctorCheck { name, status: CheckStatus::Warn(e.to_string()) },
};
let missing: Vec<&str> = events.iter().copied().filter(|e| !hook_root.join(e).exists()).collect();
let check = if missing.is_empty() {
DoctorCheck { name, status: CheckStatus::Ok(format!("{} hook script(s) present", events.len())) }
} else {
DoctorCheck {
name,
status: CheckStatus::Fail {
problem: format!("hook script(s) missing: {}", missing.join(", ")),
fix: "run the host's `setup` (or a user hook already owns that event)".into(),
},
}
};
report::note_skipped(check, &skipped)
}
#[cfg(test)]
#[path = "../../tests/unit/cline.rs"]
mod cline_tests;