use std::fs;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use anyhow::{Context as _, Result, bail, ensure};
use openmoat_hosts::Host;
use serde_json::{Value, json};
use toml_edit::DocumentMut;
use crate::cli::UninstallArgs;
use crate::exit::Code;
use crate::home::{Home, user_home, write_private};
use crate::install::{self, HookLayout, HostConfig, Recorded, read_or_empty};
use crate::integrity;
use crate::render::Deferred;
use crate::sandbox::backend::{SandboxBackend, Undo};
use crate::sandbox::install as host_sandbox;
pub fn run(args: &UninstallArgs) -> Result<Code> {
crate::terminal::refuse_agent("openmoat uninstall")?;
if !crate::terminal::interactive() {
bail!(
"`openmoat uninstall` must be run by a person in a terminal, not from a hook or script"
);
}
let home = Home::locate()?;
let hosts = match &args.hosts {
Some(explicit) => explicit.clone(),
None => Host::ALL
.into_iter()
.filter(|h| HostConfig::for_host(&home, *h).is_ok())
.collect(),
};
let mut out = Deferred::default();
if let Err(e) = super::mcp::unwrap_servers(&home, &hosts, &[], &mut out) {
writeln!(
out,
"! mcp servers {e:#}; `openmoat mcp unwrap` restores the wrapped ones"
)?;
}
let mut undone = Vec::new();
let mut recorded = Recorded::load(&home)?;
for host in hosts.iter().copied() {
let config = HostConfig::for_host(&home, host)?;
let sandbox = host_sandbox::backend(host).map(SandboxBackend::undo);
let with_hooks = match sandbox {
Some(Undo::WithHooks(remove)) => Some(remove),
_ => None,
};
let owned = &recorded.claude_rules;
let ours = &recorded.backups;
let strip = |root: &mut Value| {
config.remove(root) | with_hooks.is_some_and(|remove| remove(root, owned))
};
let path = &config.settings_path;
let plugin = matches!(config.layout, HookLayout::Plugin(_));
let hook = match config.layout {
HookLayout::Owned(file) => undo_whole(path, ours, |text| file.is_ours(text))?,
HookLayout::Entries(_) | HookLayout::Plugin(_) => {
undo_host_json(path, ours, strip, plugin)?
}
};
let mut files = vec![(path.clone(), hook)];
match sandbox {
Some(Undo::Toml(remove)) => {
let path = host_sandbox::settings_path(&home, host)?;
files.push((path.clone(), undo_toml(&path, ours, remove)?));
}
Some(Undo::Json(remove)) => {
let path = host_sandbox::settings_path(&home, host)?;
files.push((path.clone(), undo_json(&path, ours, remove)?));
}
Some(Undo::WithHooks(_)) | None => {}
}
if plugin && let Some(plugin) = install::openclaw::remove_plugin(&config)? {
files.push((plugin, Some(Undone::Deleted)));
}
if files.iter().all(|(_, done)| done.is_none()) {
writeln!(out, "· {:<16} not set up", host.display_name())?;
}
for (path, done) in files {
if let Some(done) = done {
writeln!(
out,
"✔ {:<16} {} {done}",
host.display_name(),
path.display()
)?;
undone.push(path);
}
}
}
let before = recorded.dirs.len();
recorded
.dirs
.retain(|id, _| !hosts.iter().any(|h| h.id() == id));
recorded
.files
.retain(|id, _| !hosts.iter().any(|h| h.id() == id));
let forgot_rules = hosts
.iter()
.filter_map(|h| host_sandbox::backend(*h))
.any(|b| matches!(b.undo(), Undo::WithHooks(_)))
&& !recorded.claude_rules.is_empty();
if forgot_rules {
recorded.claude_rules.clear();
}
let backups_before = recorded.backups.len();
recorded.backups.retain(|b| b.exists());
let mut rewritten = Vec::new();
if recorded.dirs.len() != before || forgot_rules || recorded.backups.len() != backups_before {
recorded.save(&home)?;
rewritten.push(home.hosts_path());
}
integrity::unpin(&home, &undone, &rewritten)?;
match super::service::manager(&home).and_then(|m| m.uninstall()) {
Ok(Some(path)) => writeln!(out, "✔ proxy service removed: {}", path.display())?,
Ok(None) => {}
Err(e) => writeln!(
out,
"! proxy service {e:#}; `openmoat proxy uninstall` removes it by hand"
)?,
}
if args.purge {
purge(&home)?;
writeln!(out, "✔ state directory {} deleted", home.root().display())?;
} else if home.exists() {
writeln!(
out,
"· state directory {} kept (policy, audit log); `openmoat uninstall --purge` deletes it",
home.root().display()
)?;
}
out.finish()?;
Ok(Code::Ok)
}
enum Undone {
Restored(PathBuf),
Deleted,
Stripped(Vec<PathBuf>),
}
impl std::fmt::Display for Undone {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Restored(backup) => write!(f, "restored from {}", backup.display()),
Self::Deleted => write!(f, "deleted (openmoat init created it)"),
Self::Stripped(kept) if kept.is_empty() => {
write!(f, "OpenMoat's entries removed, your changes kept")
}
Self::Stripped(kept) => {
let kept: Vec<String> = kept.iter().map(|p| p.display().to_string()).collect();
write!(
f,
"OpenMoat's entries removed, your changes kept (backup kept: {})",
kept.join(", ")
)
}
}
}
}
fn undo_json(
path: &Path,
ours: &[PathBuf],
strip: impl Fn(&mut Value) -> bool,
) -> Result<Option<Undone>> {
undo_host_json(path, ours, strip, false)
}
fn undo_host_json(
path: &Path,
ours: &[PathBuf],
strip: impl Fn(&mut Value) -> bool,
plugin: bool,
) -> Result<Option<Undone>> {
if !path.is_file() {
return Ok(None);
}
let read = |p: &Path| {
if plugin {
install::openclaw::read(p)
} else {
read_or_empty(p)
}
};
let mut root = read(path)?;
if !strip(&mut root) {
return Ok(None);
}
let backups = install::backups(path);
let originals: Vec<(&PathBuf, Value)> = backups
.iter()
.filter_map(|b| Some((b, read(b).ok()?)))
.filter(|(_, v)| !strip(&mut v.clone()))
.collect();
let original = originals.iter().find(|(_, v)| *v == root).map(|(b, _)| *b);
let created = originals.is_empty() && root == json!({});
if plugin && original.is_none() && !created {
install::openclaw::read_for_edit(path)?;
}
let text = serde_json::to_string_pretty(&root)? + "\n";
finish(path, &owned(&backups, ours), original, created, &text).map(Some)
}
fn undo_whole(
path: &Path,
ours: &[PathBuf],
is_ours: impl Fn(&str) -> bool,
) -> Result<Option<Undone>> {
if !path.is_file() {
return Ok(None);
}
let text = fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
if !is_ours(&text) {
return Ok(None);
}
let backups = install::backups(path);
let original = backups
.iter()
.find(|b| fs::read_to_string(b).is_ok_and(|t| !is_ours(&t)));
finish(
path,
&owned(&backups, ours),
original,
original.is_none(),
"",
)
.map(Some)
}
fn undo_toml(
path: &Path,
ours: &[PathBuf],
strip: fn(&mut DocumentMut) -> bool,
) -> Result<Option<Undone>> {
if !path.is_file() {
return Ok(None);
}
let read = crate::sandbox::install::read_toml;
let mut doc = read(path)?;
if !strip(&mut doc) {
return Ok(None);
}
let text = doc.to_string();
let backups = install::backups(path);
let originals: Vec<&PathBuf> = backups
.iter()
.filter(|b| read(b).is_ok_and(|mut d| !strip(&mut d)))
.collect();
let original = originals
.iter()
.find(|b| fs::read_to_string(b).is_ok_and(|t| t == text))
.copied();
let created = originals.is_empty() && text.trim().is_empty();
finish(path, &owned(&backups, ours), original, created, &text).map(Some)
}
fn owned(backups: &[PathBuf], ours: &[PathBuf]) -> Vec<PathBuf> {
backups
.iter()
.filter(|b| ours.contains(b))
.cloned()
.collect()
}
fn finish(
path: &Path,
backups: &[PathBuf],
original: Option<&PathBuf>,
created: bool,
text: &str,
) -> Result<Undone> {
let undone = match original {
Some(backup) => {
let bytes =
fs::read(backup).with_context(|| format!("reading {}", backup.display()))?;
write_private(path, &bytes)?;
Undone::Restored(backup.clone())
}
None if created => {
fs::remove_file(path).with_context(|| format!("deleting {}", path.display()))?;
Undone::Deleted
}
None => {
write_private(path, text.as_bytes())?;
return Ok(Undone::Stripped(backups.to_vec()));
}
};
for backup in backups {
fs::remove_file(backup).with_context(|| format!("deleting {}", backup.display()))?;
}
Ok(undone)
}
fn purge(home: &Home) -> Result<()> {
if !home.exists() {
return Ok(());
}
let root = home.root();
ensure!(
root != user_home()? && (home.policy_path().is_file() || home.lock_path().is_file()),
"{} does not look like OpenMoat's state directory; delete it yourself",
root.display()
);
fs::remove_dir_all(root).with_context(|| format!("deleting {}", root.display()))
}