use std::fs;
use std::io::{Cursor, Read};
use std::path::{Path, PathBuf};
use jsonc_parser::cst::{CstInputValue, CstObject, CstRootNode};
use jsonc_parser::ParseOptions;
use serde_json::Value;
use super::{AgentId, DetectedAgent, Scope};
use crate::core::config::atomic_write;
use crate::core::error::{SsError, ERR_WRITER_UNSUPPORTED, ERR_WRITE_ROLLBACK};
use crate::core::registry::InstallChange;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Confidence {
High,
Medium,
Low,
}
impl Confidence {
pub fn label(self) -> &'static str {
match self {
Confidence::High => "high",
Confidence::Medium => "medium",
Confidence::Low => "low",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VerifyStatus {
Ok,
Missing,
Malformed,
}
#[derive(Debug, Clone, Default)]
pub struct ResolvedItem {
pub slug: String,
pub name: String,
pub kind: String,
pub mcp_entry: Option<Value>,
pub skill_zip: Option<Vec<u8>>,
pub skill_md: Option<String>,
pub rules_body: Option<Vec<u8>>,
pub hook_entry: Option<Value>,
pub plugin_zip: Option<Vec<u8>>,
pub component_path: Option<String>,
pub plugin_marketplace: Option<String>,
pub plugin_version: Option<String>,
pub mcp_is_heuristic: bool,
}
pub trait ConfigWriter {
fn id(&self) -> AgentId;
fn confidence(&self) -> Confidence;
fn supports_kind(&self, kind: &str, agent: &DetectedAgent) -> bool;
fn install(
&self,
item: &ResolvedItem,
agent: &DetectedAgent,
dry_run: bool,
) -> Result<Vec<InstallChange>, SsError>;
fn uninstall(&self, changes: &[InstallChange]) -> Result<(), SsError>;
fn verify(&self, item: &ResolvedItem, agent: &DetectedAgent) -> VerifyStatus;
}
fn json_to_cst(v: &Value) -> CstInputValue {
match v {
Value::Null => CstInputValue::Null,
Value::Bool(b) => CstInputValue::Bool(*b),
Value::Number(n) => CstInputValue::Number(n.to_string()),
Value::String(s) => CstInputValue::String(s.clone()),
Value::Array(a) => CstInputValue::Array(a.iter().map(json_to_cst).collect()),
Value::Object(o) => {
CstInputValue::Object(o.iter().map(|(k, v)| (k.clone(), json_to_cst(v))).collect())
}
}
}
fn path_str(p: &Path) -> String {
p.to_string_lossy().into_owned()
}
fn read_or_empty(path: &Path) -> Result<String, SsError> {
match fs::read_to_string(path) {
Ok(s) => Ok(s),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
Err(e) => Err(SsError::new(
ERR_WRITE_ROLLBACK,
format!("Failed to read {}: {e}", path.display()),
)),
}
}
fn parse_cst(source: &str, path: &Path) -> Result<CstRootNode, SsError> {
let text = if source.trim().is_empty() {
"{}"
} else {
source
};
CstRootNode::parse(text, &ParseOptions::default()).map_err(|e| {
SsError::new(
ERR_WRITE_ROLLBACK,
format!("{} is not valid JSON: {e}", path.display()),
)
.with_suggestion(
"Fix the file by hand, then re-run — SaferSkills won't overwrite invalid JSON.",
)
})
}
fn container_or_create(root: &CstRootNode, key_path: &[&str]) -> CstObject {
let mut cur = root.object_value_or_set();
for seg in key_path {
cur = cur.object_value_or_set(seg);
}
cur
}
fn dotted(key_path: &[&str], name: &str) -> String {
let mut segs: Vec<&str> = key_path.to_vec();
segs.push(name);
segs.join(".")
}
pub fn merge_json_mcp(
path: &Path,
key_path: &[&str],
name: &str,
entry: &Value,
dry_run: bool,
) -> Result<InstallChange, SsError> {
let source = read_or_empty(path)?;
let root = parse_cst(&source, path)?;
let container = container_or_create(&root, key_path);
let prior = container
.get(name)
.and_then(|p| p.value())
.and_then(|n| n.to_serde_value());
match container.get(name) {
Some(prop) => prop.set_value(json_to_cst(entry)),
None => {
container.append(name, json_to_cst(entry));
}
}
if !dry_run {
atomic_write(path, root.to_string().as_bytes())?;
}
Ok(InstallChange::ConfigKey {
file: path_str(path),
key: dotted(key_path, name),
prior,
})
}
fn restore_json_key(file: &str, key: &str, prior: &Option<Value>) -> Result<(), SsError> {
let path = PathBuf::from(file);
let source = match fs::read_to_string(&path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => {
return Err(SsError::new(
ERR_WRITE_ROLLBACK,
format!("Failed to read {}: {e}", path.display()),
))
}
};
let root = parse_cst(&source, &path)?;
let Some(mut container) = root.object_value() else {
return Ok(());
};
let segs: Vec<&str> = key.split('.').collect();
let Some((name, container_path)) = segs.split_last() else {
return Ok(());
};
for seg in container_path {
match container.object_value(seg) {
Some(next) => container = next,
None => return Ok(()), }
}
match prior {
Some(v) => match container.get(name) {
Some(prop) => prop.set_value(json_to_cst(v)),
None => {
container.append(name, json_to_cst(v));
}
},
None => {
if let Some(prop) = container.get(name) {
prop.remove();
}
}
}
atomic_write(&path, root.to_string().as_bytes())
}
pub fn verify_json_mcp(path: &Path, key_path: &[&str], name: &str) -> VerifyStatus {
let Ok(source) = fs::read_to_string(path) else {
return VerifyStatus::Missing;
};
let Ok(root) = CstRootNode::parse(
if source.trim().is_empty() {
"{}"
} else {
&source
},
&ParseOptions::default(),
) else {
return VerifyStatus::Malformed;
};
let Some(mut container) = root.object_value() else {
return VerifyStatus::Missing;
};
for seg in key_path {
match container.object_value(seg) {
Some(next) => container = next,
None => return VerifyStatus::Missing,
}
}
if container.get(name).is_some() {
VerifyStatus::Ok
} else {
VerifyStatus::Missing
}
}
pub fn openclaw_key(path: &Path) -> Vec<&'static str> {
let Ok(text) = fs::read_to_string(path) else {
return vec!["mcpServers"];
};
let Ok(root) = CstRootNode::parse(
if text.trim().is_empty() { "{}" } else { &text },
&ParseOptions::default(),
) else {
return vec!["mcpServers"];
};
if let Some(obj) = root.object_value() {
if obj.get("mcpServers").is_some() {
return vec!["mcpServers"];
}
if let Some(mcp) = obj.object_value("mcp") {
if mcp.get("servers").is_some() {
return vec!["mcp", "servers"];
}
}
}
vec!["mcpServers"]
}
fn json_to_toml(v: &Value) -> toml_edit::Item {
use toml_edit::{Array, Item, Value as TVal};
match v {
Value::Null => Item::Value(TVal::from("")),
Value::Bool(b) => Item::Value(TVal::from(*b)),
Value::Number(n) => {
if let Some(i) = n.as_i64() {
Item::Value(TVal::from(i))
} else {
Item::Value(TVal::from(n.as_f64().unwrap_or(0.0)))
}
}
Value::String(s) => Item::Value(TVal::from(s.as_str())),
Value::Array(a) => {
let mut arr = Array::new();
for el in a {
if let Item::Value(val) = json_to_toml(el) {
arr.push(val);
}
}
Item::Value(TVal::Array(arr))
}
Value::Object(o) => {
let mut table = toml_edit::Table::new();
for (k, val) in o {
table.insert(k, json_to_toml(val));
}
Item::Table(table)
}
}
}
pub(crate) fn toml_to_json(item: &toml_edit::Item) -> Value {
use toml_edit::Value as TVal;
match item {
toml_edit::Item::Value(TVal::String(s)) => Value::String(s.value().clone()),
toml_edit::Item::Value(TVal::Integer(i)) => Value::from(*i.value()),
toml_edit::Item::Value(TVal::Float(f)) => Value::from(*f.value()),
toml_edit::Item::Value(TVal::Boolean(b)) => Value::Bool(*b.value()),
toml_edit::Item::Value(TVal::Array(a)) => Value::Array(
a.iter()
.map(|v| toml_to_json(&toml_edit::Item::Value(v.clone())))
.collect(),
),
toml_edit::Item::Value(TVal::InlineTable(t)) => {
let mut map = serde_json::Map::new();
for (k, v) in t.iter() {
map.insert(
k.to_string(),
toml_to_json(&toml_edit::Item::Value(v.clone())),
);
}
Value::Object(map)
}
toml_edit::Item::Table(t) => {
let mut map = serde_json::Map::new();
for (k, v) in t.iter() {
map.insert(k.to_string(), toml_to_json(v));
}
Value::Object(map)
}
_ => Value::Null,
}
}
pub fn merge_toml_mcp(
path: &Path,
name: &str,
entry: &Value,
dry_run: bool,
) -> Result<InstallChange, SsError> {
let source = read_or_empty(path)?;
let mut doc = source.parse::<toml_edit::DocumentMut>().map_err(|e| {
SsError::new(
ERR_WRITE_ROLLBACK,
format!("{} is not valid TOML: {e}", path.display()),
)
.with_suggestion("Fix the file by hand, then re-run.")
})?;
let prior = doc
.get("mcp_servers")
.and_then(|s| s.get(name))
.map(toml_to_json);
if doc.get("mcp_servers").is_none() {
doc["mcp_servers"] = toml_edit::Item::Table(toml_edit::Table::new());
}
doc["mcp_servers"][name] = json_to_toml(entry);
if !dry_run {
atomic_write(path, doc.to_string().as_bytes())?;
}
Ok(InstallChange::ConfigKey {
file: path_str(path),
key: format!("mcp_servers.{name}"),
prior,
})
}
fn restore_toml_key(file: &str, key: &str, prior: &Option<Value>) -> Result<(), SsError> {
let path = PathBuf::from(file);
let Ok(source) = fs::read_to_string(&path) else {
return Ok(());
};
let mut doc = match source.parse::<toml_edit::DocumentMut>() {
Ok(d) => d,
Err(_) => return Ok(()),
};
let name = key.strip_prefix("mcp_servers.").unwrap_or(key);
match prior {
Some(v) => {
if doc.get("mcp_servers").is_none() {
doc["mcp_servers"] = toml_edit::Item::Table(toml_edit::Table::new());
}
doc["mcp_servers"][name] = json_to_toml(v);
}
None => {
if let Some(servers) = doc.get_mut("mcp_servers").and_then(|s| s.as_table_mut()) {
servers.remove(name);
}
}
}
atomic_write(&path, doc.to_string().as_bytes())
}
pub fn verify_toml_mcp(path: &Path, name: &str) -> VerifyStatus {
let Ok(source) = fs::read_to_string(path) else {
return VerifyStatus::Missing;
};
let Ok(doc) = source.parse::<toml_edit::DocumentMut>() else {
return VerifyStatus::Malformed;
};
if doc.get("mcp_servers").and_then(|s| s.get(name)).is_some() {
VerifyStatus::Ok
} else {
VerifyStatus::Missing
}
}
fn safe_join(base: &Path, rel: &str) -> Option<PathBuf> {
let mut out = base.to_path_buf();
for comp in Path::new(rel).components() {
match comp {
std::path::Component::Normal(c) => out.push(c),
std::path::Component::CurDir => {}
_ => return None, }
}
Some(out)
}
fn strip_component_prefix(rel: &str, prefix: &str) -> Option<String> {
let rel = rel.replace('\\', "/");
if prefix.is_empty() {
return Some(rel);
}
let prefix = prefix.trim_end_matches('/');
match rel.strip_prefix(prefix).and_then(|r| r.strip_prefix('/')) {
Some(s) if !s.is_empty() => Some(s.to_string()),
Some(_) => None, None => Some(rel), }
}
fn unzip_into(dest: &Path, zip_bytes: &[u8], strip_prefix: &str) -> Result<(), SsError> {
let mut archive = zip::ZipArchive::new(Cursor::new(zip_bytes))
.map_err(|e| SsError::new(ERR_WRITE_ROLLBACK, format!("Invalid archive: {e}")))?;
fs::create_dir_all(dest).map_err(|e| {
SsError::new(
ERR_WRITE_ROLLBACK,
format!("Failed to create {}: {e}", dest.display()),
)
})?;
for i in 0..archive.len() {
let mut entry = archive
.by_index(i)
.map_err(|e| SsError::new(ERR_WRITE_ROLLBACK, format!("Corrupt archive: {e}")))?;
let raw = entry.name().to_string();
let Some(rel) = strip_component_prefix(&raw, strip_prefix) else {
continue;
};
let Some(target) = safe_join(dest, &rel) else {
return Err(SsError::new(
ERR_WRITE_ROLLBACK,
format!("Refusing unsafe path in archive: {raw}"),
));
};
if entry.is_dir() {
fs::create_dir_all(&target).ok();
continue;
}
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).ok();
}
let mut buf = Vec::new();
entry.read_to_end(&mut buf).map_err(|e| {
SsError::new(
ERR_WRITE_ROLLBACK,
format!("Failed to read archive entry: {e}"),
)
})?;
atomic_write(&target, &buf)?;
}
Ok(())
}
pub fn install_skill(
skill_dir: &Path,
name: &str,
zip_bytes: &[u8],
dry_run: bool,
) -> Result<InstallChange, SsError> {
let dest = skill_dir.join(name);
if dry_run {
return Ok(InstallChange::File {
path: path_str(&dest),
});
}
unzip_into(&dest, zip_bytes, "")?;
Ok(InstallChange::File {
path: path_str(&dest),
})
}
pub fn install_rules_file(
rules_dir: &Path,
file_name: &str,
body: &[u8],
dry_run: bool,
) -> Result<InstallChange, SsError> {
let dest = rules_dir.join(file_name);
if !dry_run {
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).map_err(|e| {
SsError::new(
ERR_WRITE_ROLLBACK,
format!("Failed to create {}: {e}", parent.display()),
)
})?;
}
atomic_write(&dest, body)?;
}
Ok(InstallChange::File {
path: path_str(&dest),
})
}
pub fn write_file_change(
dest: &Path,
body: &[u8],
dry_run: bool,
) -> Result<InstallChange, SsError> {
if !dry_run {
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).map_err(|e| {
SsError::new(
ERR_WRITE_ROLLBACK,
format!("Failed to create {}: {e}", parent.display()),
)
})?;
}
atomic_write(dest, body)?;
}
Ok(InstallChange::File {
path: path_str(dest),
})
}
fn malformed_marker_err(path: &Path) -> SsError {
SsError::new(
ERR_WRITE_ROLLBACK,
format!(
"{} contains a malformed SaferSkills marker block.",
path.display()
),
)
.with_suggestion(
"Resolve the `<!-- saferskills:start/end -->` markers in that file manually, then retry.",
)
}
fn marker_span(text: &str, path: &Path) -> Result<Option<std::ops::Range<usize>>, SsError> {
let start_marker = super::writers::render::MARKER_START;
let end_marker = super::writers::render::MARKER_END;
let first_start = text.find(start_marker);
let first_end = text.find(end_marker);
match (first_start, first_end) {
(None, None) => Ok(None),
(Some(_), None) | (None, Some(_)) => Err(malformed_marker_err(path)),
(Some(s), Some(e)) => {
if e < s + start_marker.len() {
return Err(malformed_marker_err(path));
}
let block_end = e + end_marker.len();
if text[block_end..].contains(start_marker) {
return Err(malformed_marker_err(path));
}
Ok(Some(s..block_end))
}
}
}
pub(crate) fn has_complete_marker_block(text: &str) -> bool {
let start_marker = super::writers::render::MARKER_START;
let end_marker = super::writers::render::MARKER_END;
match (text.find(start_marker), text.find(end_marker)) {
(Some(s), Some(e)) => e >= s + start_marker.len(),
_ => false,
}
}
fn replace_block(text: &str, span: Option<std::ops::Range<usize>>, block: &str) -> String {
match span {
Some(range) => {
let mut out = String::with_capacity(text.len() + block.len());
out.push_str(&text[..range.start]);
out.push_str(block);
out.push_str(&text[range.end..]);
out
}
None => {
if text.trim().is_empty() {
format!("{block}\n")
} else {
let sep = if text.ends_with('\n') { "\n" } else { "\n\n" };
format!("{text}{sep}{block}\n")
}
}
}
}
pub fn merge_marker_block(
path: &Path,
block: &str,
dry_run: bool,
) -> Result<InstallChange, SsError> {
let source = read_or_empty(path)?;
let span = marker_span(&source, path)?;
let prior = span.clone().map(|r| source[r].to_string());
if !dry_run {
let merged = replace_block(&source, span, block);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| {
SsError::new(
ERR_WRITE_ROLLBACK,
format!("Failed to create {}: {e}", parent.display()),
)
})?;
}
atomic_write(path, merged.as_bytes())?;
}
Ok(InstallChange::MarkerBlock {
file: path_str(path),
prior,
})
}
fn revert_marker_block(file: &str, prior: &Option<String>) -> Result<(), SsError> {
let path = PathBuf::from(file);
let source = match fs::read_to_string(&path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => {
return Err(SsError::new(
ERR_WRITE_ROLLBACK,
format!("Failed to read {file}: {e}"),
))
}
};
let Some(range) = marker_span(&source, &path)? else {
return Ok(());
};
let replacement = prior.clone().unwrap_or_default();
let mut out = String::with_capacity(source.len());
out.push_str(&source[..range.start]);
out.push_str(&replacement);
out.push_str(&source[range.end..]);
if prior.is_none() && out.trim().is_empty() {
return remove_path(file);
}
atomic_write(&path, out.as_bytes())
}
pub fn merge_json_hook(
settings_path: &Path,
hook_block: &Value,
dry_run: bool,
) -> Result<Vec<InstallChange>, SsError> {
let Value::Object(events) = hook_block else {
return Err(SsError::new(
ERR_WRITE_ROLLBACK,
"Hook spec is not an object of {event: [groups]}.",
));
};
let source = read_or_empty(settings_path)?;
let root = parse_cst(&source, settings_path)?;
let root_obj = root.object_value_or_set();
let hooks = root_obj.object_value_or_set("hooks");
let mut changes = Vec::new();
for (event, groups) in events {
if !matches!(groups, Value::Array(_)) {
continue;
}
let prior = hooks
.get(event)
.and_then(|p| p.value())
.and_then(|n| n.to_serde_value());
match hooks.get(event) {
Some(prop) => {
let mut merged = match prop.value().and_then(|v| v.to_serde_value()) {
Some(Value::Array(a)) => a,
_ => Vec::new(),
};
if let Value::Array(new_groups) = groups {
merged.extend(new_groups.iter().cloned());
}
prop.set_value(json_to_cst(&Value::Array(merged)));
}
None => {
hooks.append(event, json_to_cst(groups));
}
}
changes.push(InstallChange::ConfigKey {
file: path_str(settings_path),
key: format!("hooks.{event}"),
prior,
});
}
if !dry_run {
atomic_write(settings_path, root.to_string().as_bytes())?;
}
Ok(changes)
}
pub fn verify_hook(settings_path: &Path, events: &[String]) -> VerifyStatus {
let Ok(source) = fs::read_to_string(settings_path) else {
return VerifyStatus::Missing;
};
let Ok(root) = CstRootNode::parse(
if source.trim().is_empty() {
"{}"
} else {
&source
},
&ParseOptions::default(),
) else {
return VerifyStatus::Malformed;
};
let Some(obj) = root.object_value() else {
return VerifyStatus::Missing;
};
let Some(hooks) = obj.object_value("hooks") else {
return VerifyStatus::Missing;
};
if events.iter().all(|e| hooks.get(e).is_some()) {
VerifyStatus::Ok
} else {
VerifyStatus::Missing
}
}
#[allow(clippy::too_many_arguments)]
pub fn install_plugin(
plugins_root: &Path,
mp: &str,
plugin: &str,
version: &str,
component_path: &str,
zip_bytes: &[u8],
dry_run: bool,
) -> Result<Vec<InstallChange>, SsError> {
let version_dir = plugins_root
.join("cache")
.join(mp)
.join(plugin)
.join(version);
let ledger_path = plugins_root.join("installed_plugins.json");
let source = read_or_empty(&ledger_path)?;
let root = parse_cst(&source, &ledger_path)?;
let root_obj = root.object_value_or_set();
let prior = root_obj
.get("plugins")
.and_then(|p| p.value())
.and_then(|n| n.to_serde_value());
let plugins = root_obj.object_value_or_set("plugins");
let ledger_key = format!("{plugin}@{mp}");
let install = serde_json::json!({ "scope": "user", "version": version });
match plugins.get(&ledger_key) {
Some(prop) => {
let mut entry = match prop.value().and_then(|v| v.to_serde_value()) {
Some(Value::Object(m)) => m,
_ => serde_json::Map::new(),
};
let mut installs = match entry.get("installs") {
Some(Value::Array(a)) => a.clone(),
_ => Vec::new(),
};
installs.push(install);
entry.insert("installs".to_string(), Value::Array(installs));
prop.set_value(json_to_cst(&Value::Object(entry)));
}
None => {
plugins.append(
&ledger_key,
json_to_cst(&serde_json::json!({ "installs": [install] })),
);
}
}
if !dry_run {
unzip_into(&version_dir, zip_bytes, component_path)?;
atomic_write(&ledger_path, root.to_string().as_bytes())?;
}
Ok(vec![
InstallChange::File {
path: path_str(&version_dir),
},
InstallChange::ConfigKey {
file: path_str(&ledger_path),
key: "plugins".to_string(),
prior,
},
])
}
pub fn verify_plugin(plugins_root: &Path, mp: &str, plugin: &str, version: &str) -> VerifyStatus {
let version_dir = plugins_root
.join("cache")
.join(mp)
.join(plugin)
.join(version);
if !version_dir.is_dir() {
return VerifyStatus::Missing;
}
let ledger_path = plugins_root.join("installed_plugins.json");
let Ok(source) = fs::read_to_string(&ledger_path) else {
return VerifyStatus::Missing;
};
let Ok(root) = CstRootNode::parse(
if source.trim().is_empty() {
"{}"
} else {
&source
},
&ParseOptions::default(),
) else {
return VerifyStatus::Malformed;
};
let present = root
.object_value()
.and_then(|o| o.object_value("plugins"))
.and_then(|p| p.get(&format!("{plugin}@{mp}")))
.is_some();
if present {
VerifyStatus::Ok
} else {
VerifyStatus::Missing
}
}
fn remove_path(path: &str) -> Result<(), SsError> {
let p = PathBuf::from(path);
let res = if p.is_dir() {
fs::remove_dir_all(&p)
} else if p.exists() {
fs::remove_file(&p)
} else {
return Ok(());
};
res.map_err(|e| SsError::new(ERR_WRITE_ROLLBACK, format!("Failed to remove {path}: {e}")))
}
pub fn revert_changes(changes: &[InstallChange]) -> Result<(), SsError> {
for change in changes.iter().rev() {
match change {
InstallChange::File { path } => remove_path(path)?,
InstallChange::ConfigKey { file, key, prior } => {
if file.ends_with(".toml") {
restore_toml_key(file, key, prior)?;
} else {
restore_json_key(file, key, prior)?;
}
}
InstallChange::MarkerBlock { file, prior } => revert_marker_block(file, prior)?,
}
}
Ok(())
}
pub fn kind_supported(kind: &str, agent: &DetectedAgent) -> bool {
match kind {
"mcp_server" => true,
"skill" => {
agent.skill_dir.is_some()
|| agent.rules_dir.is_some()
|| matches!(
agent.id,
AgentId::Codex | AgentId::Copilot | AgentId::Gemini
)
}
"rules" => agent.rules_dir.is_some(),
"hook" => agent.hooks_path.is_some(),
"plugin" => agent.plugin_dir.is_some(),
_ => false,
}
}
pub fn reject_project_if_unsupported(
supports_project: bool,
agent: &DetectedAgent,
) -> Result<(), SsError> {
if !supports_project && agent.scope == Scope::Project {
return Err(SsError::new(
ERR_WRITER_UNSUPPORTED,
format!(
"{} has no project-scoped config — it is global-only.",
agent.id.display_name()
),
)
.with_suggestion("Re-run without --project to install globally."));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn entry() -> Value {
serde_json::json!({"command": "npx", "args": ["-y", "pkg"], "env": {}})
}
#[test]
fn json_merge_preserves_comments_and_records_prior() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mcp.json");
fs::write(&path, "{\n // keep me\n \"mcpServers\": {}\n}\n").unwrap();
let change = merge_json_mcp(&path, &["mcpServers"], "github", &entry(), false).unwrap();
let after = fs::read_to_string(&path).unwrap();
assert!(after.contains("// keep me"), "comment preserved: {after}");
assert!(after.contains("\"github\""));
match change {
InstallChange::ConfigKey { key, prior, .. } => {
assert_eq!(key, "mcpServers.github");
assert!(prior.is_none());
}
_ => panic!("expected ConfigKey"),
}
assert_eq!(
verify_json_mcp(&path, &["mcpServers"], "github"),
VerifyStatus::Ok
);
}
#[test]
fn json_uninstall_restores_byte_for_byte() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mcp.json");
let original =
"{\n // header\n \"mcpServers\": {\n \"other\": { \"command\": \"x\" }\n }\n}\n";
fs::write(&path, original).unwrap();
let change = merge_json_mcp(&path, &["mcpServers"], "github", &entry(), false).unwrap();
assert!(fs::read_to_string(&path).unwrap().contains("github"));
revert_changes(&[change]).unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), original);
}
#[test]
fn json_merge_into_missing_file_creates_it() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested").join("mcp.json");
let change = merge_json_mcp(&path, &["mcpServers"], "g", &entry(), false).unwrap();
assert_eq!(
verify_json_mcp(&path, &["mcpServers"], "g"),
VerifyStatus::Ok
);
revert_changes(&[change]).unwrap();
assert_eq!(
verify_json_mcp(&path, &["mcpServers"], "g"),
VerifyStatus::Missing
);
}
#[test]
fn nested_key_path_for_openclaw_style() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("openclaw.json");
fs::write(&path, "{\n \"mcp\": { \"servers\": {} }\n}\n").unwrap();
assert_eq!(openclaw_key(&path), vec!["mcp", "servers"]);
let change = merge_json_mcp(&path, &["mcp", "servers"], "g", &entry(), false).unwrap();
assert_eq!(
verify_json_mcp(&path, &["mcp", "servers"], "g"),
VerifyStatus::Ok
);
revert_changes(&[change]).unwrap();
}
#[test]
fn openclaw_key_defaults_to_mcpservers_for_fresh_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nope.json");
assert_eq!(openclaw_key(&path), vec!["mcpServers"]);
}
#[test]
fn toml_merge_and_uninstall() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(&path, "# codex config\nmodel = \"o3\"\n").unwrap();
let change = merge_toml_mcp(&path, "github", &entry(), false).unwrap();
let after = fs::read_to_string(&path).unwrap();
assert!(after.contains("# codex config"), "comment preserved");
assert!(after.contains("[mcp_servers.github]"));
assert_eq!(verify_toml_mcp(&path, "github"), VerifyStatus::Ok);
revert_changes(&[change]).unwrap();
assert_eq!(verify_toml_mcp(&path, "github"), VerifyStatus::Missing);
assert!(fs::read_to_string(&path)
.unwrap()
.contains("model = \"o3\""));
}
#[test]
fn skill_install_and_uninstall() {
let dir = tempfile::tempdir().unwrap();
let skills = dir.path().join("skills");
let mut buf = Vec::new();
{
let mut w = zip::ZipWriter::new(Cursor::new(&mut buf));
let opts: zip::write::FileOptions<'_, ()> = zip::write::FileOptions::default();
use std::io::Write as _;
w.start_file("SKILL.md", opts).unwrap();
w.write_all(b"---\nname: pdf\n---\n").unwrap();
w.finish().unwrap();
}
let change = install_skill(&skills, "pdf", &buf, false).unwrap();
assert!(skills.join("pdf").join("SKILL.md").exists());
revert_changes(&[change]).unwrap();
assert!(!skills.join("pdf").exists());
}
#[test]
fn dry_run_writes_nothing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mcp.json");
let change = merge_json_mcp(&path, &["mcpServers"], "g", &entry(), true).unwrap();
assert!(!path.exists(), "dry-run must not write");
assert!(matches!(change, InstallChange::ConfigKey { .. }));
}
#[test]
fn safe_join_rejects_traversal() {
let base = Path::new("/tmp/x");
assert!(safe_join(base, "a/b.txt").is_some());
assert!(safe_join(base, "../escape").is_none());
}
use super::super::writers::render::{MARKER_END, MARKER_START};
fn block(inner: &str) -> String {
format!("{MARKER_START}\n## SaferSkills\n\n{inner}\n{MARKER_END}")
}
#[test]
fn marker_block_appends_when_absent() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("AGENTS.md");
fs::write(&path, "# Repo guide\n\nKeep tests green.\n").unwrap();
let change = merge_marker_block(&path, &block("scan first"), false).unwrap();
let after = fs::read_to_string(&path).unwrap();
assert!(after.contains("# Repo guide"), "preexisting content kept");
assert!(after.contains("## SaferSkills"), "block appended");
assert!(matches!(
change,
InstallChange::MarkerBlock { prior: None, .. }
));
}
#[test]
fn marker_block_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("AGENTS.md");
fs::write(&path, "# Guide\n").unwrap();
merge_marker_block(&path, &block("scan first"), false).unwrap();
let once = fs::read_to_string(&path).unwrap();
merge_marker_block(&path, &block("scan first"), false).unwrap();
let twice = fs::read_to_string(&path).unwrap();
assert_eq!(once, twice, "second identical merge changes nothing");
assert_eq!(once.matches(MARKER_START).count(), 1, "exactly one block");
}
#[test]
fn marker_block_replaces_in_place_and_captures_prior() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("GEMINI.md");
fs::write(&path, "# Guide\n").unwrap();
merge_marker_block(&path, &block("v1"), false).unwrap();
let change = merge_marker_block(&path, &block("v2"), false).unwrap();
let after = fs::read_to_string(&path).unwrap();
assert!(after.contains("v2"), "block replaced");
assert!(!after.contains("v1"), "old block gone");
assert_eq!(after.matches(MARKER_START).count(), 1, "single block");
match change {
InstallChange::MarkerBlock { prior: Some(p), .. } => {
assert!(p.contains("v1"), "prior block captured for restore");
}
_ => panic!("expected MarkerBlock with prior"),
}
}
#[test]
fn marker_block_uninstall_restores_prior() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("AGENTS.md");
fs::write(&path, "# Guide\n").unwrap();
let c1 = merge_marker_block(&path, &block("v1"), false).unwrap();
let c2 = merge_marker_block(&path, &block("v2"), false).unwrap();
revert_changes(&[c2]).unwrap();
let restored = fs::read_to_string(&path).unwrap();
assert!(restored.contains("v1"), "prior v1 block restored");
assert!(!restored.contains("v2"), "v2 block removed");
revert_changes(&[c1]).unwrap();
let original = fs::read_to_string(&path).unwrap();
assert_eq!(original.trim(), "# Guide", "host content intact");
assert!(!original.contains(MARKER_START), "no block remains");
}
#[test]
fn marker_block_uninstall_deletes_file_when_we_created_it() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("AGENTS.md");
let change = merge_marker_block(&path, &block("only us"), false).unwrap();
assert!(path.exists(), "host file created");
revert_changes(&[change]).unwrap();
assert!(
!path.exists(),
"host file we created is removed on uninstall"
);
}
#[test]
fn marker_block_dry_run_writes_nothing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("AGENTS.md");
let change = merge_marker_block(&path, &block("x"), true).unwrap();
assert!(!path.exists(), "dry-run must not write");
assert!(matches!(change, InstallChange::MarkerBlock { .. }));
}
#[test]
fn marker_block_refuses_orphan_start_leaving_file_untouched() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("AGENTS.md");
let original = format!("# Repo guide\n\n{MARKER_START}\n\nMy own important notes.\n");
fs::write(&path, &original).unwrap();
let err = merge_marker_block(&path, &block("scan first"), false).unwrap_err();
assert_eq!(err.code, ERR_WRITE_ROLLBACK);
assert_eq!(fs::read_to_string(&path).unwrap(), original);
assert_eq!(
fs::read_to_string(&path)
.unwrap()
.matches(MARKER_START)
.count(),
1,
"no second block appended"
);
}
#[test]
fn marker_block_refuses_orphan_end() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("AGENTS.md");
let original = format!("# Guide\n{MARKER_END}\nstray\n");
fs::write(&path, &original).unwrap();
let err = merge_marker_block(&path, &block("x"), false).unwrap_err();
assert_eq!(err.code, ERR_WRITE_ROLLBACK);
assert_eq!(fs::read_to_string(&path).unwrap(), original, "untouched");
}
#[test]
fn marker_block_refuses_second_start() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("AGENTS.md");
let original = format!("{}\n\n{MARKER_START}\nstray second start\n", block("v1"));
fs::write(&path, &original).unwrap();
let err = merge_marker_block(&path, &block("v2"), false).unwrap_err();
assert_eq!(err.code, ERR_WRITE_ROLLBACK);
assert_eq!(fs::read_to_string(&path).unwrap(), original, "untouched");
}
#[test]
fn marker_block_revert_refuses_malformed_host() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("AGENTS.md");
let original = format!("# Guide\n{MARKER_START}\nuser content\n");
fs::write(&path, &original).unwrap();
let change = InstallChange::MarkerBlock {
file: path.to_string_lossy().into_owned(),
prior: None,
};
let err = revert_changes(&[change]).unwrap_err();
assert_eq!(err.code, ERR_WRITE_ROLLBACK);
assert_eq!(fs::read_to_string(&path).unwrap(), original, "untouched");
}
#[test]
fn well_formed_block_still_round_trips_after_fix() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("AGENTS.md");
fs::write(&path, "# Guide\n").unwrap();
let c1 = merge_marker_block(&path, &block("v1"), false).unwrap();
merge_marker_block(&path, &block("v1"), false).unwrap();
assert_eq!(
fs::read_to_string(&path)
.unwrap()
.matches(MARKER_START)
.count(),
1
);
let c2 = merge_marker_block(&path, &block("v2"), false).unwrap();
revert_changes(&[c2]).unwrap();
assert!(
fs::read_to_string(&path).unwrap().contains("v1"),
"prior restored"
);
revert_changes(&[c1]).unwrap();
let final_text = fs::read_to_string(&path).unwrap();
assert_eq!(final_text.trim(), "# Guide", "host intact");
assert!(!has_complete_marker_block(&final_text), "no block remains");
}
#[test]
fn has_complete_marker_block_rejects_lone_start() {
assert!(!has_complete_marker_block(&format!(
"x\n{MARKER_START}\ny\n"
)));
assert!(!has_complete_marker_block("nothing here"));
assert!(has_complete_marker_block(&block("ok")));
}
}