agent-migrate-adapters 0.6.1

Codex, Pi, and Claude Code configuration adapters for agent-migrate
Documentation
use std::path::Path;

use agent_migrate_core::{FileKind, SecretPolicy, SecretRef, redact_json};
use toml_edit::{Array, ArrayOfTables, DocumentMut, InlineTable, Item, Table, Value, value};

use crate::error::AdapterError;

#[derive(Debug)]
pub(crate) struct SanitizedResource {
    pub(crate) content: Vec<u8>,
    pub(crate) mcp: Option<Vec<u8>>,
    pub(crate) references: Vec<SecretRef>,
}

pub(crate) fn sanitize(
    path: &Path,
    kind: FileKind,
    bytes: &[u8],
) -> Result<SanitizedResource, AdapterError> {
    if !matches!(kind, FileKind::Settings | FileKind::Mcp | FileKind::Hook) {
        return Ok(SanitizedResource {
            content: bytes.to_vec(),
            mcp: None,
            references: Vec::new(),
        });
    }
    let text = std::str::from_utf8(bytes).map_err(|error| AdapterError::Structured {
        path: path.to_path_buf(),
        message: error.to_string(),
    })?;
    if path.extension().and_then(|extension| extension.to_str()) == Some("toml") {
        return sanitize_toml(path, text);
    }
    let mut value: serde_json::Value =
        serde_json::from_str(text).map_err(|error| AdapterError::Structured {
            path: path.to_path_buf(),
            message: error.to_string(),
        })?;
    filter_dynamic_state(path, &mut value);
    let mut redacted = redact_json(&value, &SecretPolicy::default())?;
    disable_embedded_hooks(&mut redacted.value);
    let mcp = if kind == FileKind::Settings {
        extract_mcp(&mut redacted.value).map(|servers| serde_json::json!({"mcpServers": servers}))
    } else {
        None
    };
    Ok(SanitizedResource {
        content: serialize(path, &disabled_hook(kind, redacted.value))?,
        mcp: mcp.map(|value| serialize(path, &value)).transpose()?,
        references: redacted.references,
    })
}

fn sanitize_toml(path: &Path, text: &str) -> Result<SanitizedResource, AdapterError> {
    let mut document = text
        .parse::<DocumentMut>()
        .map_err(|error| structured(path, error))?;
    let mut redactor = TomlRedactor::new();
    redactor.table(document.as_table_mut(), "$", path)?;
    let value: serde_json::Value =
        toml_edit::de::from_str(&document.to_string()).map_err(|error| structured(path, error))?;
    let mut mcp_value = value;
    let mcp = extract_mcp(&mut mcp_value)
        .map(|servers| serialize(path, &serde_json::json!({"mcpServers": servers})));
    let _removed = document.remove("mcp_servers");
    Ok(SanitizedResource {
        content: document.to_string().into_bytes(),
        mcp: mcp.transpose()?,
        references: redactor.references,
    })
}

#[derive(Debug)]
struct TomlRedactor {
    policy: SecretPolicy,
    references: Vec<SecretRef>,
}

impl TomlRedactor {
    fn new() -> Self {
        Self {
            policy: SecretPolicy::default(),
            references: Vec::new(),
        }
    }

    fn table(
        &mut self,
        table: &mut Table,
        location: &str,
        path: &Path,
    ) -> Result<(), AdapterError> {
        for (key, item) in table.iter_mut() {
            let key = key.get().to_owned();
            let child = format!("{location}.{key}");
            if self.policy.is_sensitive(&key) {
                self.reference(&key, &child);
                *item = value(format!("${{SECRET_REF:{}}}", reference_name(&key)));
            } else {
                self.item(item, &child, path)?;
            }
        }
        Ok(())
    }

    fn item(&mut self, item: &mut Item, location: &str, path: &Path) -> Result<(), AdapterError> {
        match item {
            Item::Table(table) => self.table(table, location, path),
            Item::ArrayOfTables(tables) => self.array_of_tables(tables, location, path),
            Item::Value(value) => self.toml_value(value, location, path),
            Item::None => Ok(()),
        }
    }

    fn array_of_tables(
        &mut self,
        tables: &mut ArrayOfTables,
        location: &str,
        path: &Path,
    ) -> Result<(), AdapterError> {
        for (index, table) in tables.iter_mut().enumerate() {
            self.table(table, &format!("{location}[{index}]"), path)?;
        }
        Ok(())
    }

    fn toml_value(
        &mut self,
        value: &mut Value,
        location: &str,
        path: &Path,
    ) -> Result<(), AdapterError> {
        match value {
            Value::InlineTable(table) => self.inline_table(table, location, path),
            Value::Array(array) => self.array(array, location, path),
            Value::String(_)
            | Value::Integer(_)
            | Value::Float(_)
            | Value::Boolean(_)
            | Value::Datetime(_) => Ok(()),
        }
    }

    fn inline_table(
        &mut self,
        table: &mut InlineTable,
        location: &str,
        path: &Path,
    ) -> Result<(), AdapterError> {
        for (key, value) in table.iter_mut() {
            let key = key.get().to_owned();
            let child = format!("{location}.{key}");
            if self.policy.is_sensitive(&key) {
                self.reference(&key, &child);
                *value = Value::from(format!("${{SECRET_REF:{}}}", reference_name(&key)));
            } else {
                self.toml_value(value, &child, path)?;
            }
        }
        Ok(())
    }

    fn array(
        &mut self,
        array: &mut Array,
        location: &str,
        path: &Path,
    ) -> Result<(), AdapterError> {
        for (index, value) in array.iter_mut().enumerate() {
            self.toml_value(value, &format!("{location}[{index}]"), path)?;
        }
        Ok(())
    }

    fn reference(&mut self, key: &str, logical_location: &str) {
        self.references.push(SecretRef {
            name: reference_name(key),
            logical_location: logical_location.to_owned(),
            required: true,
        });
    }
}

fn filter_dynamic_state(path: &Path, value: &mut serde_json::Value) {
    if path.file_name().and_then(std::ffi::OsStr::to_str) != Some(".claude.json") {
        return;
    }
    let Some(object) = value.as_object_mut() else {
        return;
    };
    for key in [
        "projects",
        "history",
        "fileHistory",
        "trust",
        "oauthAccount",
        "userID",
        "telemetry",
        "daemon",
        "backups",
        "cachedStatsigGates",
    ] {
        let _removed = object.remove(key);
    }
}

fn disable_embedded_hooks(value: &mut serde_json::Value) {
    match value {
        serde_json::Value::Object(object) => {
            for (key, child) in object.iter_mut() {
                if key.eq_ignore_ascii_case("hooks") {
                    let source = std::mem::take(child);
                    *child = serde_json::json!({"enabled": false, "source": source});
                } else {
                    disable_embedded_hooks(child);
                }
            }
        }
        serde_json::Value::Array(values) => {
            for value in values {
                disable_embedded_hooks(value);
            }
        }
        _ => {}
    }
}

fn disabled_hook(kind: FileKind, value: serde_json::Value) -> serde_json::Value {
    if kind == FileKind::Hook {
        serde_json::json!({"enabled": false, "source": value})
    } else {
        value
    }
}

fn reference_name(key: &str) -> String {
    key.chars()
        .map(|character| {
            if character.is_ascii_alphanumeric() {
                character.to_ascii_uppercase()
            } else {
                '_'
            }
        })
        .collect()
}

fn structured(path: &Path, error: impl std::fmt::Display) -> AdapterError {
    AdapterError::Structured {
        path: path.to_path_buf(),
        message: error.to_string(),
    }
}

fn extract_mcp(value: &mut serde_json::Value) -> Option<serde_json::Value> {
    let object = value.as_object_mut()?;
    object
        .remove("mcp_servers")
        .or_else(|| object.remove("mcpServers"))
}

fn serialize(path: &Path, value: &serde_json::Value) -> Result<Vec<u8>, AdapterError> {
    serde_json::to_vec_pretty(value).map_err(|error| AdapterError::Structured {
        path: path.to_path_buf(),
        message: error.to_string(),
    })
}