offline-cli 0.3.3

The Offline Protocol developer CLI
//! `offline registry`: list and search packages, workflows, skills, templates.

use anyhow::{bail, Result};
use clap::{Args, Subcommand, ValueEnum};
use offline_registry::{llms, Registry};
use serde_json::json;

use crate::output;

#[derive(Subcommand)]
pub enum RegistryCommand {
    /// List registry artifacts
    List(ListArgs),
    /// Search packages, workflows, skills, capabilities, and examples
    Search(SearchArgs),
    /// Show one artifact in full (package, workflow, or template)
    Show(ShowArgs),
    /// Export the registry as an llms.txt digest for LLM ingestion
    ExportLlms(ExportArgs),
}

#[derive(Args)]
pub struct ListArgs {
    /// What to list
    #[arg(value_enum, default_value = "packages")]
    pub kind: Kind,
    /// Output JSON
    #[arg(long)]
    pub json: bool,
}

#[derive(Clone, Copy, ValueEnum)]
pub enum Kind {
    Packages,
    Workflows,
    Skills,
    Templates,
    Capabilities,
}

#[derive(Args)]
pub struct SearchArgs {
    /// Search keywords
    pub query: String,
    /// Output JSON
    #[arg(long)]
    pub json: bool,
}

#[derive(Args)]
pub struct ShowArgs {
    /// Artifact name (e.g. @offline-protocol/mesh-sdk, community-app)
    pub name: String,
    /// Output JSON
    #[arg(long)]
    pub json: bool,
}

#[derive(Args)]
pub struct ExportArgs {
    /// Include full skill bodies and workflow details
    #[arg(long)]
    pub full: bool,
    /// Write to a file instead of stdout
    #[arg(long)]
    pub out: Option<std::path::PathBuf>,
}

pub fn run(cmd: RegistryCommand) -> Result<()> {
    let registry = Registry::load_default()?;
    match cmd {
        RegistryCommand::List(args) => list(&registry, args),
        RegistryCommand::Search(args) => search(&registry, args),
        RegistryCommand::Show(args) => show(&registry, args),
        RegistryCommand::ExportLlms(args) => export(&registry, args),
    }
}

fn list(registry: &Registry, args: ListArgs) -> Result<()> {
    match args.kind {
        Kind::Packages => {
            if args.json {
                return output::json(&registry.packages().collect::<Vec<_>>());
            }
            output::heading("Packages");
            for p in registry.packages() {
                output::item(
                    &p.name,
                    &format!(
                        "v{} [{}] — {}",
                        p.version,
                        p.platforms.join(", "),
                        p.description
                    ),
                );
            }
        }
        Kind::Workflows => {
            if args.json {
                return output::json(&registry.workflows().collect::<Vec<_>>());
            }
            output::heading("Workflows");
            for w in registry.workflows() {
                output::item(&w.name, &w.description);
            }
        }
        Kind::Skills => {
            if args.json {
                let skills: Vec<_> = registry
                    .skills()
                    .map(
                        |s| json!({"name": s.name, "title": s.title, "description": s.description}),
                    )
                    .collect();
                return output::json(&skills);
            }
            output::heading("Skills");
            for s in registry.skills() {
                output::item(&s.name, &s.description);
            }
        }
        Kind::Templates => {
            if args.json {
                return output::json(&registry.templates().collect::<Vec<_>>());
            }
            output::heading("Templates");
            for t in registry.templates() {
                output::item(&t.name, &format!("[{}] {}", t.platform, t.description));
            }
        }
        Kind::Capabilities => {
            let caps = registry.capabilities();
            if args.json {
                return output::json(&caps);
            }
            output::heading("Capabilities");
            for c in caps {
                output::item(&c.name, &format!("packages: {}", c.packages.join(", ")));
            }
        }
    }
    Ok(())
}

fn search(registry: &Registry, args: SearchArgs) -> Result<()> {
    let hits = registry.search_all(&args.query, 20);

    if args.json {
        return output::json(&hits);
    }
    if hits.is_empty() {
        output::warn(&format!("no matches for `{}`", args.query));
        return Ok(());
    }
    output::heading(&format!("Results for `{}`", args.query));
    for h in hits {
        output::item(
            &format!("[{}] {}", h.kind, h.name),
            &truncate(&h.description, 100),
        );
    }
    Ok(())
}

fn show(registry: &Registry, args: ShowArgs) -> Result<()> {
    if let Some(p) = registry
        .package(&args.name)
        .or_else(|| registry.package(&format!("@offline-protocol/{}", args.name)))
    {
        if args.json {
            return output::json(p);
        }
        println!("{}", serde_json::to_string_pretty(p)?);
        return Ok(());
    }
    if let Some(w) = registry.workflow(&args.name) {
        if args.json {
            return output::json(w);
        }
        println!("{}", serde_json::to_string_pretty(w)?);
        return Ok(());
    }
    if let Some(t) = registry.template(&args.name) {
        if args.json {
            return output::json(t);
        }
        println!("{}", serde_json::to_string_pretty(t)?);
        return Ok(());
    }
    bail!(
        "`{}` not found — try `offline registry search {}`",
        args.name,
        args.name
    );
}

fn export(registry: &Registry, args: ExportArgs) -> Result<()> {
    let text = if args.full {
        llms::llms_full_txt(registry)
    } else {
        llms::llms_txt(registry)
    };
    match args.out {
        Some(path) => {
            std::fs::write(&path, text)?;
            output::success(&format!("wrote {}", path.display()));
        }
        None => print!("{text}"),
    }
    Ok(())
}

fn truncate(s: &str, max: usize) -> String {
    if s.chars().count() <= max {
        s.to_string()
    } else {
        let cut: String = s.chars().take(max).collect();
        format!("{cut}…")
    }
}