bot-forge 1.0.2

Rust CLI for installing agent skills and developer tools from configurable forms.
Documentation
//! Shared domain models exchanged by configuration, planning, execution, state, and output.
//!
//! These types describe data contracts rather than implementation state. Serialized models use
//! stable names because they are consumed by CLI JSON output and the persistent registry.

use std::path::PathBuf;

use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

use crate::config::schema::{
    AptMirrorDef, CheckSpec, ConfigDocument, EnvironmentMutation, InstallSpec, OriginMap,
};
use crate::util::valid_config_id;

mod output;

pub use crate::model::output::{
    InstallOutcome, InstallPreview, InstallReport, SkillStatus, ToolInstallOutcome,
    ToolInstallResult, ToolStatus,
};

#[derive(Debug, Clone, Default, PartialEq, Eq)]
/// A built-in or configuration-defined installation profile.
pub enum Profile {
    /// Small bootstrap profile.
    Minimal,
    /// Default development profile.
    #[default]
    Standard,
    /// Extended development profile.
    Advanced,
    /// Configuration-defined profile identifier.
    Custom(String),
}

impl Profile {
    /// Parse a profile identifier, accepting built-ins and valid custom configuration IDs.
    pub fn parse(value: &str) -> Option<Self> {
        match value {
            "minimal" => Some(Self::Minimal),
            "standard" => Some(Self::Standard),
            "advanced" => Some(Self::Advanced),
            value if valid_config_id(value) => Some(Self::Custom(value.to_string())),
            _ => None,
        }
    }

    /// Return the canonical configuration identifier for this profile.
    pub fn as_str(&self) -> &str {
        match self {
            Self::Minimal => "minimal",
            Self::Standard => "standard",
            Self::Advanced => "advanced",
            Self::Custom(value) => value,
        }
    }
}

#[cfg(test)]
mod tests {
    use crate::model::Profile;

    #[test]
    fn cli_profiles_use_the_config_id_contract() {
        assert!(Profile::parse("ci-agent-2").is_some());
        for invalid in ["CI", "ci_agent", "-ci", "ci-", "ci--agent"] {
            assert!(Profile::parse(invalid).is_none(), "accepted {invalid}");
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
/// Agent integration target for an installed skill.
pub enum Agent {
    /// Claude skill directory layout.
    Claude,
    /// OpenCode skill directory layout.
    OpenCode,
}

impl Agent {
    /// Return the serialized agent identifier.
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Claude => "claude",
            Self::OpenCode => "opencode",
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
/// Whether a registry entry represents a tool or a skill.
pub enum InstallKind {
    /// Executable or development tool.
    Tool,
    /// Agent skill payload.
    Skill,
}

impl InstallKind {
    /// Return the serialized installation-kind identifier.
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Tool => "tool",
            Self::Skill => "skill",
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
/// Typed installation backend selected by a component definition.
pub enum BackendKind {
    /// Debian-family APT package manager.
    Apt,
    /// Homebrew package manager.
    Brew,
    /// Cargo package installer.
    Cargo,
    /// Rustup toolchain manager.
    Rustup,
    /// npm package installer.
    Npm,
    /// Python pip package installer.
    Pip,
    /// uv isolated tool installer.
    #[serde(rename = "uv-tool")]
    UvTool,
    /// Windows Package Manager.
    Winget,
    /// Verified downloadable archive or file.
    Archive,
    /// Pinned Git source build.
    Git,
    /// Policy-gated shell operation.
    Shell,
}

impl BackendKind {
    /// Return the serialized backend identifier.
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Apt => "apt",
            Self::Brew => "brew",
            Self::Cargo => "cargo",
            Self::Rustup => "rustup",
            Self::Npm => "npm",
            Self::Pip => "pip",
            Self::UvTool => "uv-tool",
            Self::Winget => "winget",
            Self::Archive => "archive",
            Self::Git => "git",
            Self::Shell => "shell",
        }
    }
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
/// Minimal tool declaration used by the normalized install model.
pub(crate) struct ToolDef {
    pub name: String,
    pub display_name: Option<String>,
    pub version: Option<String>,
    pub optional: bool,
    pub allow_insecure_hosts: Vec<String>,
    pub detect: Option<CheckSpec>,
    pub install: Option<InstallSpec>,
    pub verify: Option<CheckSpec>,
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
/// Archive payload format understood by archive backends.
pub enum ArchiveFormat {
    /// Single-file payload.
    #[default]
    File,
    /// Gzip-compressed tar archive.
    TarGz,
    /// XZ-compressed tar archive.
    TarXz,
    /// ZIP archive.
    Zip,
}

impl ToolDef {
    pub(crate) fn backend(&self) -> Option<BackendKind> {
        match self.install.as_ref()? {
            InstallSpec::Apt(_) => Some(BackendKind::Apt),
            InstallSpec::Brew(_) => Some(BackendKind::Brew),
            InstallSpec::Cargo(_) => Some(BackendKind::Cargo),
            InstallSpec::Rustup(_) => Some(BackendKind::Rustup),
            InstallSpec::Npm(_) => Some(BackendKind::Npm),
            InstallSpec::Pip(_) => Some(BackendKind::Pip),
            InstallSpec::UvTool(_) => Some(BackendKind::UvTool),
            InstallSpec::Winget(_) => Some(BackendKind::Winget),
            InstallSpec::Archive(_) => Some(BackendKind::Archive),
            InstallSpec::Git(_) => Some(BackendKind::Git),
            InstallSpec::Shell(_) => Some(BackendKind::Shell),
        }
    }
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
/// Skill declaration and its agent destinations.
pub(crate) struct SkillDef {
    pub name: String,
    pub display_name: Option<String>,
    pub optional: bool,
    pub source: String,
    pub agents: Vec<Agent>,
    pub revision: Option<String>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
/// Normalized collection of environment, tools, and skills.
pub(crate) struct InstallConfig {
    pub environment: EnvironmentDef,
    pub apt_mirror: Option<AptMirrorDef>,
    pub tools: Vec<ToolDef>,
    pub skills: Vec<SkillDef>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
/// Environment mutations selected by configuration.
pub(crate) struct EnvironmentDef {
    pub mutations: Vec<EnvironmentMutation>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
/// Parsed configuration together with field origins and its source path.
pub struct LoadedConfig {
    /// Canonical configuration after catalog expansion, overlays, and reference resolution.
    pub document: ConfigDocument,
    /// Per-field provenance retained for explanation and plan construction.
    pub origins: OriginMap,
    /// Selected primary file, or `None` when only the embedded catalog was loaded.
    pub path: Option<PathBuf>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
/// Persisted record of a managed installation and its activation targets.
pub struct RegistryEntry {
    /// Canonical component or skill name.
    pub name: String,
    /// Whether the entry represents a tool or a skill.
    pub kind: InstallKind,
    /// Canonical source identity recorded for status and recovery.
    pub source: String,
    /// Profile that requested the installation.
    pub profile: String,
    /// Managed filesystem targets owned by this entry.
    pub targets: Vec<RegistryTarget>,
    /// Unix timestamp at which the entry was committed.
    pub installed_at: u64,
    #[serde(deserialize_with = "deserialize_explicit_option")]
    #[schemars(schema_with = "nullable_string_schema", required)]
    /// Immutable artifact currently activated for the entry, when applicable.
    pub artifact_id: Option<String>,
    #[serde(deserialize_with = "deserialize_explicit_option")]
    #[schemars(schema_with = "nullable_string_schema", required)]
    /// Artifact that can be restored when the current activation is removed.
    pub previous_artifact_id: Option<String>,
    #[serde(deserialize_with = "deserialize_explicit_option")]
    #[schemars(schema_with = "nullable_string_schema", required)]
    /// Canonical configuration hash associated with the installation run.
    pub config_hash: Option<String>,
    #[serde(deserialize_with = "deserialize_explicit_option")]
    #[schemars(schema_with = "nullable_string_schema", required)]
    /// Immutable execution-plan hash associated with the installation run.
    pub plan_hash: Option<String>,
    #[serde(deserialize_with = "deserialize_explicit_option")]
    #[schemars(schema_with = "nullable_string_schema", required)]
    /// Resolved upstream revision, when the backend has one.
    pub source_revision: Option<String>,
    #[serde(deserialize_with = "deserialize_explicit_option")]
    #[schemars(schema_with = "nullable_backend_schema", required)]
    /// Typed backend responsible for the managed tool lifecycle.
    pub backend: Option<BackendKind>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
/// One filesystem target associated with a registry entry.
pub struct RegistryTarget {
    /// Managed filesystem path owned by the registry entry.
    pub path: PathBuf,
    #[serde(deserialize_with = "deserialize_explicit_option")]
    #[schemars(schema_with = "nullable_string_schema", required)]
    /// Logical executable name for activation restoration, when this target is a binary.
    pub binary: Option<String>,
}

fn nullable_string_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
    generator.subschema_for::<Option<String>>()
}

fn nullable_backend_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
    generator.subschema_for::<Option<BackendKind>>()
}

fn deserialize_explicit_option<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
where
    D: serde::Deserializer<'de>,
    T: Deserialize<'de>,
{
    Option::<T>::deserialize(deserializer)
}

impl RegistryEntry {
    /// Return the stable `kind:name` identity used for registry replacement.
    pub fn stable_id(&self) -> String {
        format!("{}:{}", self.kind.as_str(), self.name)
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
/// User-selected inputs used to load and plan an installation.
pub struct InstallOptions {
    /// Profile to resolve.
    pub profile: Profile,
    /// Optional primary configuration path.
    pub config_path: Option<PathBuf>,
    /// Ordered overlay paths applied after the primary configuration.
    pub overlay_paths: Vec<PathBuf>,
    /// Whether interactive terminal progress is enabled.
    pub status_bar: bool,
    /// Whether selection and confirmation prompts are bypassed.
    pub yes: bool,
    /// Optional inclusive component filters.
    pub only: Vec<String>,
    /// Component filters removed after profile resolution.
    pub exclude: Vec<String>,
}

impl Default for InstallOptions {
    fn default() -> Self {
        Self {
            profile: Profile::Standard,
            config_path: None,
            overlay_paths: Vec::new(),
            status_bar: true,
            yes: false,
            only: Vec::new(),
            exclude: Vec::new(),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
/// One timed diagnostic finding emitted by `doctor`.
pub(crate) struct DiagnosticCheck {
    /// Stable check identifier used by machine consumers.
    pub(crate) id: String,
    /// Machine-readable severity label.
    pub(crate) severity: String,
    /// Human-readable finding.
    pub(crate) summary: String,
    /// Optional corrective action.
    pub(crate) suggestion: Option<String>,
    /// Check execution duration in milliseconds.
    pub(crate) duration_ms: u128,
}