use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use serde::Serialize;
use serde_json::{Map, Value};
use serde_norway::{Mapping, Value as Yaml};
use super::cchooks::{hook_is_portable, render_hook_group};
use super::confedit::{write_file_idem, yaml_edit, yaml_prune_map, yaml_remove};
use super::report;
use super::skillsdir;
use super::{AgentBackend, BackendState};
use crate::components::{HookBinding, McpKind, McpServer};
use crate::doctor::{CheckStatus, DoctorCheck, DoctorReport};
use crate::error::{Error, Result};
use crate::host::{Capabilities, Desired, Outcome, Plugin, Scope, Source};
const DEFAULT_TIMEOUT: u32 = 300;
pub(crate) struct GooseBackend;
impl AgentBackend for GooseBackend {
fn id(&self) -> &'static str {
"goose"
}
fn detect(&self) -> bool {
which::which("goose").is_ok() || goose_config_dir().is_some_and(|c| c.is_dir())
}
fn capabilities(&self) -> Capabilities {
Capabilities {
plugins: false,
mcp: true,
hooks: true,
commands: false,
agents: false,
skills: true,
instructions: false,
statusline: false,
scopes: &["user"],
}
}
fn probe(&self, plugin: &Plugin, scope: &Scope, source: &Source) -> Result<BackendState> {
let comp = plugin.components(source)?.with_client(self.id());
let mcp = if comp.mcp_servers.iter().any(is_writable) { Some(probe_mcp(&config_yaml()?, &comp.mcp_servers)?) } else { None };
let hooks = probe_hooks(&hooks_json_path(scope, plugin.name)?, &comp.hooks)?;
let skills = skillsdir::probe(&skills_dir(scope, plugin.name)?, plugin, &comp.skills)?;
Ok(report::compose([mcp, hooks, skills].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;
changed |= reconcile_mcp(&config_yaml()?, &comp.mcp_servers, desired.reenable)?;
changed |= reconcile_hooks(&hooks_json_path(scope, plugin.name)?, &comp.hooks)?;
changed |= skillsdir::reconcile(&skills_dir(scope, plugin.name)?, plugin, &comp.skills)?;
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;
changed |= remove_mcp(&config_yaml()?, &writable_names(&comp.mcp_servers))?;
changed |= remove_plugin_dir(&plugin_dir(scope, plugin.name)?)?;
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 goose_path_root() -> Option<PathBuf> {
std::env::var_os("GOOSE_PATH_ROOT").filter(|v| !v.is_empty()).map(PathBuf::from)
}
fn goose_config_dir() -> Option<PathBuf> {
dirs::config_dir().map(|c| c.join("goose"))
}
fn config_yaml() -> Result<PathBuf> {
if let Some(root) = goose_path_root() {
return Ok(root.join("config").join("config.yaml"));
}
goose_config_dir()
.map(|d| d.join("config.yaml"))
.ok_or_else(|| Error::Tree("no config directory (HOME and XDG_CONFIG_HOME unset); cannot locate ~/.config/goose".into()))
}
fn plugin_dir(scope: &Scope, plugin: &str) -> Result<PathBuf> {
let base = match scope {
Scope::User => match goose_path_root() {
Some(root) => root,
None => {
dirs::home_dir().ok_or_else(|| Error::Tree("no home directory (HOME unset); cannot locate ~/.agents/plugins".into()))?
}
},
Scope::Project { path } => path.clone(),
};
Ok(base.join(".agents").join("plugins").join(plugin))
}
fn hooks_json_path(scope: &Scope, plugin: &str) -> Result<PathBuf> {
Ok(plugin_dir(scope, plugin)?.join("hooks").join("hooks.json"))
}
fn skills_dir(scope: &Scope, plugin: &str) -> Result<PathBuf> {
Ok(plugin_dir(scope, plugin)?.join("skills"))
}
fn is_writable(server: &McpServer) -> bool {
server.is_portable() && !matches!(server.kind, McpKind::Sse { .. })
}
fn writable_names(servers: &[McpServer]) -> Vec<&str> {
servers.iter().filter(|s| is_writable(s)).map(|s| s.name.as_str()).collect()
}
#[derive(Serialize)]
struct StdioExt<'a> {
name: &'a str,
#[serde(rename = "type")]
kind: &'static str,
cmd: &'a str,
args: &'a [String],
envs: &'a BTreeMap<String, String>,
enabled: bool,
timeout: u32,
}
#[derive(Serialize)]
struct RemoteExt<'a> {
name: &'a str,
#[serde(rename = "type")]
kind: &'static str,
uri: &'a str,
enabled: bool,
timeout: u32,
}
fn render_ext(server: &McpServer, enabled: bool) -> Result<Yaml> {
let value = match &server.kind {
McpKind::Stdio => serde_norway::to_value(StdioExt {
name: &server.name,
kind: "stdio",
cmd: &server.command,
args: &server.args,
envs: &server.env,
enabled,
timeout: DEFAULT_TIMEOUT,
}),
McpKind::Http { url } => {
serde_norway::to_value(RemoteExt { name: &server.name, kind: "streamable_http", uri: url, enabled, timeout: DEFAULT_TIMEOUT })
}
McpKind::Sse { .. } => {
return Err(Error::Config {
path: "<goose extension>".into(),
detail: format!("goose cannot host an SSE extension ({}); it must be skipped, not rendered", server.name),
});
}
};
value.map_err(|e| Error::Config { path: "<goose extension>".into(), detail: format!("rendering extension: {e}") })
}
fn ext_map(root: &mut Yaml) -> &mut Mapping {
let Yaml::Mapping(map) = root else { unreachable!("yaml_edit guarantees a mapping root") };
let entry = map.entry(Yaml::from("extensions")).or_insert_with(|| Yaml::Mapping(Mapping::new()));
if !entry.is_mapping() {
*entry = Yaml::Mapping(Mapping::new());
}
let Yaml::Mapping(exts) = entry else { unreachable!("just ensured a mapping") };
exts
}
fn reconcile_mcp(config: &Path, servers: &[McpServer], reenable: bool) -> Result<bool> {
let portable: Vec<&McpServer> = servers.iter().filter(|s| is_writable(s)).collect();
if portable.is_empty() {
return Ok(false);
}
yaml_edit(config, |root| {
let exts = ext_map(root);
for server in &portable {
let currently_disabled = exts.get(server.name.as_str()).and_then(|v| v.get("enabled")).and_then(Yaml::as_bool) == Some(false);
let enabled = reenable || !currently_disabled;
exts.insert(Yaml::from(server.name.clone()), render_ext(server, enabled)?);
}
Ok(())
})
}
fn remove_mcp(config: &Path, names: &[&str]) -> Result<bool> {
if !config.exists() || names.is_empty() {
return Ok(false);
}
yaml_remove(config, |root| {
yaml_prune_map(root, "extensions", |exts| {
for name in names {
exts.remove(*name);
}
Ok(())
})
.map(|_| ())
})
}
fn probe_mcp(config: &Path, servers: &[McpServer]) -> Result<BackendState> {
let portable: Vec<&McpServer> = servers.iter().filter(|s| is_writable(s)).collect();
if portable.is_empty() {
return Ok(BackendState::Healthy);
}
let bytes = match fs::read(config) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BackendState::Absent),
Err(source) => return Err(Error::Io { context: format!("reading {}", config.display()), source }),
};
let root: Yaml = if bytes.iter().all(u8::is_ascii_whitespace) {
Yaml::Mapping(Mapping::new())
} else {
serde_norway::from_slice(&bytes).map_err(|e| Error::Config { path: config.display().to_string(), detail: e.to_string() })?
};
let exts = root.get("extensions");
let mut present = 0usize;
let mut enabled = 0usize;
let mut disabled = 0usize;
for server in &portable {
if let Some(existing) = exts.and_then(|e| e.get(server.name.as_str())) {
present += 1;
if *existing == render_ext(server, true)? {
enabled += 1;
} else if *existing == render_ext(server, false)? {
disabled += 1;
}
}
}
Ok(if present == 0 {
BackendState::Absent
} else if disabled == portable.len() {
BackendState::Disabled
} else if enabled == portable.len() {
BackendState::Healthy
} else {
BackendState::NeedsRepair
})
}
const GOOSE_EVENTS: &[&str] = &[
"SessionStart",
"SessionEnd",
"Stop",
"UserPromptSubmit",
"PreToolUse",
"PostToolUse",
"PostToolUseFailure",
"BeforeReadFile",
"AfterFileEdit",
"BeforeShellExecution",
"AfterShellExecution",
];
fn map_event(cc_event: &str) -> Option<&'static str> {
GOOSE_EVENTS.iter().copied().find(|e| *e == cc_event)
}
fn build_hooks_json(hooks: &[HookBinding]) -> Result<Option<Vec<u8>>> {
let writable: Vec<(&'static str, &HookBinding)> =
hooks.iter().filter(|h| hook_is_portable(h)).filter_map(|h| map_event(&h.event).map(|e| (e, h))).collect();
if writable.is_empty() {
return Ok(None);
}
let mut events = Map::new();
for (event, hook) in &writable {
let entry = events.entry((*event).to_string()).or_insert_with(|| Value::Array(Vec::new()));
if let Value::Array(list) = entry {
list.push(render_hook_group(hook));
}
}
let mut root = Map::new();
root.insert("hooks".into(), Value::Object(events));
let mut bytes =
serde_json::to_vec_pretty(&Value::Object(root)).map_err(|source| Error::Json { what: "goose hooks.json".into(), source })?;
bytes.push(b'\n');
Ok(Some(bytes))
}
fn reconcile_hooks(hooks_json: &Path, hooks: &[HookBinding]) -> Result<bool> {
match build_hooks_json(hooks)? {
Some(bytes) => write_file_idem(hooks_json, &bytes),
None => Ok(false),
}
}
fn probe_hooks(hooks_json: &Path, hooks: &[HookBinding]) -> Result<Option<BackendState>> {
match build_hooks_json(hooks)? {
Some(bytes) => report::probe_files(&[(hooks_json.to_path_buf(), bytes)], |_, _| true),
None => Ok(None),
}
}
fn remove_plugin_dir(dir: &Path) -> Result<bool> {
match fs::remove_dir_all(dir) {
Ok(()) => Ok(true),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(source) => Err(Error::Io { context: format!("removing {}", dir.display()), source }),
}
}
fn report_checks(backend: &GooseBackend, plugin: &Plugin, source: &Source) -> Vec<DoctorCheck> {
let mut checks = Vec::new();
checks.push(if backend.detect() {
DoctorCheck { name: "goose detected", status: CheckStatus::Ok("`goose` on PATH or ~/.config/goose present".into()) }
} else {
DoctorCheck {
name: "goose detected",
status: CheckStatus::Fail {
problem: "goose CLI not detected".into(),
fix: "install it with the goose `download_cli.sh` installer".into(),
},
}
});
let config = match config_yaml() {
Ok(config) => config,
Err(e) => {
checks.push(DoctorCheck { name: "config file", status: CheckStatus::Warn(e.to_string()) });
return checks;
}
};
let root = match fs::read(&config) {
Ok(bytes) => match serde_norway::from_slice::<Yaml>(&bytes) {
Ok(v) => {
checks.push(DoctorCheck { name: "config file", status: CheckStatus::Ok(format!("{} parses", config.display())) });
Some(v)
}
Err(e) => {
checks.push(DoctorCheck {
name: "config file",
status: CheckStatus::Fail {
problem: format!("{} does not parse: {e}", config.display()),
fix: "fix the YAML syntax or remove the file".into(),
},
});
None
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
checks.push(DoctorCheck {
name: "config file",
status: CheckStatus::Warn(format!("{} does not exist yet (run setup)", config.display())),
});
None
}
Err(e) => {
checks
.push(DoctorCheck { name: "config file", status: CheckStatus::Warn(format!("could not read {}: {e}", config.display())) });
None
}
};
let Some(comp) = report::components(&mut checks, plugin, source).map(|c| c.with_client(backend.id())) else {
return checks;
};
checks.push(check_mcp_registered(&comp.mcp_servers, root.as_ref()));
checks.push(report::check_mcp_command(&comp.mcp_servers));
match hooks_json_path(&Scope::User, plugin.name) {
Ok(hooks_json) => checks.push(check_hooks_present(&comp.hooks, &hooks_json)),
Err(e) => checks.push(DoctorCheck { name: "translated hooks present", status: CheckStatus::Warn(e.to_string()) }),
}
checks
}
fn check_mcp_registered(servers: &[McpServer], root: Option<&Yaml>) -> DoctorCheck {
let name = "mcp extension registered";
let portable: Vec<&str> = writable_names(servers);
let skipped = report::skipped_mcp(servers, &portable);
if portable.is_empty() {
return report::note_skipped(DoctorCheck { name, status: CheckStatus::Ok(report::NO_MCP.into()) }, &skipped);
}
let exts = root.and_then(|r| r.get("extensions"));
let missing: Vec<&str> = portable.iter().copied().filter(|n| exts.and_then(|e| e.get(*n)).is_none()).collect();
if !missing.is_empty() {
return DoctorCheck {
name,
status: CheckStatus::Fail {
problem: format!("mcp extension(s) not under `extensions` in config.yaml: {}", missing.join(", ")),
fix: "run the host's `setup`".into(),
},
};
}
report::note_skipped(DoctorCheck { name, status: CheckStatus::Ok(format!("{} registered", portable.join(", "))) }, &skipped)
}
fn check_hooks_present(hooks: &[HookBinding], hooks_json: &Path) -> DoctorCheck {
let name = "translated hooks present";
let skipped = report::skipped_hooks(hooks);
let ours: Vec<&str> =
hooks.iter().filter(|h| hook_is_portable(h) && map_event(&h.event).is_some()).map(|h| h.command.as_str()).collect();
if ours.is_empty() {
return report::note_skipped(DoctorCheck { name, status: CheckStatus::Ok("no hooks to translate".into()) }, &skipped);
}
let text = fs::read_to_string(hooks_json).unwrap_or_default();
let missing: Vec<&str> = ours.iter().copied().filter(|c| !text.contains(c)).collect();
let check = if missing.is_empty() {
DoctorCheck { name, status: CheckStatus::Ok(format!("{} hook(s) present in {}", ours.len(), hooks_json.display())) }
} else {
DoctorCheck {
name,
status: CheckStatus::Fail {
problem: format!("hook(s) missing from hooks.json: {}", missing.join(", ")),
fix: "run the host's `setup`".into(),
},
}
};
report::note_skipped(check, &skipped)
}
#[cfg(test)]
#[path = "../../tests/unit/goose.rs"]
mod goose_tests;