use std::fs;
use std::path::{Path, PathBuf};
use serde_json::Value;
use super::mcpjson::{self, ServerShape};
use super::report;
use super::{AgentBackend, BackendState};
use crate::components::McpServer;
use crate::doctor::{CheckStatus, DoctorCheck, DoctorReport};
use crate::error::{Error, Result};
use crate::host::{Capabilities, Desired, Outcome, Plugin, Scope, Source};
pub(crate) struct AmpBackend;
const MCP_KEY: &[&str] = &["amp.mcpServers"];
impl AgentBackend for AmpBackend {
fn id(&self) -> &'static str {
"amp"
}
fn detect(&self) -> bool {
which::which("amp").is_ok() || user_config_dir().is_some_and(|d| d.is_dir())
}
fn capabilities(&self) -> Capabilities {
Capabilities {
plugins: false,
mcp: true,
hooks: false,
commands: false,
agents: false,
skills: false,
instructions: false,
scopes: &["user"],
}
}
fn probe(&self, plugin: &Plugin, scope: &Scope, source: &Source) -> Result<BackendState> {
let comp = plugin.components(source)?.with_client(self.id());
probe_mcp(&settings_path(scope)?, &comp.mcp_servers)
}
fn reconcile(&self, plugin: &Plugin, desired: &Desired, scope: &Scope) -> Result<Outcome> {
let comp = plugin.components(&desired.source)?.with_client(self.id());
reconcile_mcp(&settings_path(scope)?, &comp.mcp_servers)
}
fn remove(&self, plugin: &Plugin, scope: &Scope, source: &Source) -> Result<Outcome> {
let comp = plugin.components(source)?.with_client(self.id());
remove_mcp(&settings_path(scope)?, &comp.mcp_servers)
}
fn report(&self, plugin: &Plugin, source: &Source) -> DoctorReport {
DoctorReport::from_checks(report_checks(self, plugin, source))
}
}
fn user_config_dir() -> Option<PathBuf> {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.filter(|p| p.is_absolute())
.or_else(|| dirs::home_dir().map(|h| h.join(".config")))
.map(|c| c.join("amp"))
}
fn amp_dir(scope: &Scope) -> Result<PathBuf> {
match scope {
Scope::User => user_config_dir()
.ok_or_else(|| Error::Tree("no config directory (XDG_CONFIG_HOME and HOME both unset); cannot locate ~/.config/amp".into())),
Scope::Project { path } => Ok(path.join(".amp")),
}
}
fn settings_path(scope: &Scope) -> Result<PathBuf> {
Ok(amp_dir(scope)?.join("settings.json"))
}
fn jsonc_sibling(settings: &Path) -> Option<PathBuf> {
let jsonc = settings.with_extension("jsonc");
jsonc.is_file().then_some(jsonc)
}
fn reconcile_mcp(settings: &Path, servers: &[McpServer]) -> Result<Outcome> {
if let Some(jsonc) = jsonc_sibling(settings) {
return Err(Error::Config {
path: jsonc.display().to_string(),
detail: "amp reads this comment-bearing settings.jsonc, which agentgear cannot parse or merge; refusing to \
write a shadowing settings.json. consolidate into one plain settings.json (drop the comments) and re-run setup"
.into(),
});
}
mcpjson::reconcile(settings, MCP_KEY, servers, ServerShape::plain())
}
fn probe_mcp(settings: &Path, servers: &[McpServer]) -> Result<BackendState> {
mcpjson::probe(settings, MCP_KEY, servers, ServerShape::plain())
}
fn remove_mcp(settings: &Path, servers: &[McpServer]) -> Result<Outcome> {
mcpjson::remove(settings, MCP_KEY, servers, ServerShape::plain())
}
fn report_checks(backend: &AmpBackend, plugin: &Plugin, source: &Source) -> Vec<DoctorCheck> {
let mut checks = Vec::new();
checks.push(if backend.detect() {
DoctorCheck { name: "amp detected", status: CheckStatus::Ok("`amp` on PATH or ~/.config/amp present".into()) }
} else {
DoctorCheck {
name: "amp detected",
status: CheckStatus::Fail {
problem: "amp CLI not detected".into(),
fix: "install it with `curl -fsSL https://ampcode.com/install.sh | bash`".into(),
},
}
});
let settings = match settings_path(&Scope::User) {
Ok(settings) => settings,
Err(e) => {
checks.push(DoctorCheck { name: "settings file", status: CheckStatus::Warn(e.to_string()) });
return checks;
}
};
let root = match fs::read(&settings) {
Ok(bytes) => match serde_json::from_slice::<Value>(&bytes) {
Ok(v) => {
checks.push(DoctorCheck { name: "settings file", status: CheckStatus::Ok(format!("{} parses", settings.display())) });
Some(v)
}
Err(e) => {
checks.push(DoctorCheck {
name: "settings file",
status: CheckStatus::Fail {
problem: format!("{} does not parse: {e}", settings.display()),
fix: "fix the JSON syntax; agentgear writes plain settings.json and cannot merge a comment-bearing settings.jsonc"
.into(),
},
});
None
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let status = match jsonc_sibling(&settings) {
Some(jsonc) => CheckStatus::Warn(format!(
"{} is comment-bearing; agentgear writes plain settings.json and cannot merge it, so setup refuses rather than shadow it",
jsonc.display()
)),
None => CheckStatus::Warn(format!("{} does not exist yet (run setup)", settings.display())),
};
checks.push(DoctorCheck { name: "settings file", status });
None
}
Err(e) => {
checks.push(DoctorCheck {
name: "settings file",
status: CheckStatus::Warn(format!("could not read {}: {e}", settings.display())),
});
None
}
};
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(),
&["amp.mcpServers"],
"not under `amp.mcpServers` in settings.json",
"run the host's `setup`",
));
checks.push(report::check_mcp_command(&comp.mcp_servers));
checks
}
#[cfg(test)]
#[path = "../../tests/unit/amp.rs"]
mod amp_tests;