agent-migrate-adapters 0.6.1

Codex, Pi, and Claude Code configuration adapters for agent-migrate
Documentation
use agent_migrate_core::{
    AgentKind, Bundle, BundleRelativePath, ChangeKind, Compatibility, FileKind, ImportDecision,
    ImportPolicy, PathMappingSet, PlannedWrite, Scope, WritePlan,
};

use crate::error::AdapterError;
use crate::render_content::{merge_rendered, render_content};
use crate::report::{ConversionReport, ResourceConversion};
use crate::target_layout::TargetLayout;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderRequest {
    target: AgentKind,
    scope: Scope,
    policy: ImportPolicy,
    bridge_healthy: bool,
    path_mappings: Option<PathMappingSet>,
}

impl RenderRequest {
    #[must_use]
    pub const fn new(target: AgentKind, scope: Scope) -> Self {
        Self {
            target,
            scope,
            policy: ImportPolicy::strict(),
            bridge_healthy: false,
            path_mappings: None,
        }
    }

    #[must_use]
    pub const fn accept_lossy(mut self) -> Self {
        self.policy = ImportPolicy::accept_lossy();
        self
    }

    #[must_use]
    pub const fn with_bridge_healthy(mut self) -> Self {
        self.bridge_healthy = true;
        self
    }

    #[must_use]
    pub fn with_path_mappings(mut self, path_mappings: PathMappingSet) -> Self {
        self.path_mappings = Some(path_mappings);
        self
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderedConfig {
    pub plan: WritePlan,
    pub report: ConversionReport,
}

/// Plans target paths and compatibility without writing or rendering files.
///
/// # Errors
///
/// Returns an error when a Bundle path cannot be mapped to the target layout.
pub fn plan_conversion(
    bundle: &Bundle,
    target: AgentKind,
    scope: Scope,
) -> Result<ConversionReport, AdapterError> {
    let source = bundle.manifest().source.agent;
    let layout = TargetLayout::new(target, scope);
    let resources = bundle
        .manifest()
        .files
        .iter()
        .map(|(path, record)| {
            let compatibility = compatibility(source, target, record.kind);
            let target_path = layout.path(record.kind, path)?;
            Ok(ResourceConversion {
                source_path: path.clone(),
                kind: record.kind,
                compatibility,
                target_path: Some(target_path),
                change: ChangeKind::Add,
            })
        })
        .collect::<Result<Vec<_>, AdapterError>>()?;
    Ok(ConversionReport::new(
        source,
        target,
        resources,
        bundle.manifest().secret_references.clone(),
    ))
}

/// Renders a Bundle into a policy-approved target write plan.
///
/// # Errors
///
/// Returns an error for blocked, unaccepted lossy, unavailable bridge, or invalid content.
pub fn render_bundle(
    bundle: &Bundle,
    request: &RenderRequest,
) -> Result<RenderedConfig, AdapterError> {
    let report = plan_conversion(bundle, request.target, request.scope)?;
    let mut plan = WritePlan::default();
    for resource in &report.resources {
        enforce_policy(resource, request)?;
        let target_path = resource
            .target_path
            .clone()
            .ok_or(AdapterError::InternalInvariant)?;
        let content = bundle
            .files()
            .get(&resource.source_path)
            .ok_or(AdapterError::InternalInvariant)?;
        let mapped = request
            .path_mappings
            .as_ref()
            .map(|mappings| crate::render_content::map_paths(resource.kind, content, mappings))
            .transpose()?;
        let content = mapped.as_deref().unwrap_or(content);
        let rendered =
            render_content(request.target, resource.kind, content).map_err(|message| {
                AdapterError::Render {
                    path: resource.source_path.to_string(),
                    message,
                }
            })?;
        let rendered = if let Some(current) = plan.get(&target_path) {
            merge_rendered(request.target, &current.content, &rendered).map_err(|message| {
                AdapterError::Render {
                    path: resource.source_path.to_string(),
                    message,
                }
            })?
        } else {
            rendered
        };
        let mode = bundle
            .manifest()
            .files
            .get(&resource.source_path)
            .ok_or(AdapterError::InternalInvariant)?
            .mode;
        plan.insert(PlannedWrite::new(target_path, rendered).with_mode(mode));
        if request.target == AgentKind::Pi && resource.kind == FileKind::Mcp {
            plan.insert(pi_bridge_write(request.scope)?);
        }
    }
    Ok(RenderedConfig { plan, report })
}

fn pi_bridge_write(scope: Scope) -> Result<PlannedWrite, AdapterError> {
    let path = match scope {
        Scope::Global => ".pi/agent/extensions/agent-migrate-mcp.ts",
        Scope::Project => ".pi/extensions/agent-migrate-mcp.ts",
    };
    let path = path.parse::<BundleRelativePath>()?;
    let content = include_bytes!("../assets/pi-mcp-bridge.mjs").to_vec();
    Ok(PlannedWrite::new(path, content))
}

fn enforce_policy(
    resource: &ResourceConversion,
    request: &RenderRequest,
) -> Result<(), AdapterError> {
    match request
        .policy
        .decide(&resource.compatibility, request.bridge_healthy)
    {
        ImportDecision::Allowed => Ok(()),
        ImportDecision::NeedsLossyAcceptance => Err(AdapterError::LossyAcceptanceRequired {
            path: resource.source_path.to_string(),
        }),
        ImportDecision::BridgeUnavailable => Err(AdapterError::BridgeUnavailable {
            path: resource.source_path.to_string(),
        }),
        ImportDecision::Blocked => Err(AdapterError::CompatibilityBlocked {
            path: resource.source_path.to_string(),
        }),
    }
}

fn compatibility(source: AgentKind, target: AgentKind, kind: FileKind) -> Compatibility {
    if source == target {
        return Compatibility::Exact;
    }
    match (target, kind) {
        (_, FileKind::Skill) => Compatibility::Exact,
        (AgentKind::Pi, FileKind::Mcp) => Compatibility::Bridged {
            requirement: "pi-mcp-bridge".to_owned(),
        },
        (AgentKind::Pi, FileKind::Agent) => Compatibility::Lossy {
            reason: "Pi represents agents as callable prompts".to_owned(),
        },
        (AgentKind::Pi, FileKind::Hook) => Compatibility::PreservedUnavailable {
            reason: "Pi hooks remain disabled preserved resources".to_owned(),
        },
        (_, FileKind::Passthrough) => Compatibility::PreservedUnavailable {
            reason: "adapter-specific resource has no guaranteed target behavior".to_owned(),
        },
        _ => Compatibility::Converted,
    }
}