harn-cli 0.7.34

CLI for the Harn programming language — run, test, REPL, format, and lint
use std::path::Path;
use std::process;

use crate::cli::{PersonaInspectArgs, PersonaListArgs};
use crate::package::{self, PersonaManifestEntry, ResolvedPersonaManifest};

pub(crate) fn run_list(manifest: Option<&Path>, args: &PersonaListArgs) {
    let catalog = load_catalog_or_exit(manifest);
    if args.json {
        let personas: Vec<serde_json::Value> = catalog
            .personas
            .iter()
            .map(|persona| persona_to_json(persona, &catalog))
            .collect();
        println!(
            "{}",
            serde_json::to_string_pretty(&personas)
                .unwrap_or_else(|error| fatal(&format!("failed to serialize personas: {error}")))
        );
        return;
    }

    if catalog.personas.is_empty() {
        println!(
            "No personas declared in {}.",
            catalog.manifest_path.display()
        );
        return;
    }

    println!("Personas in {}:", catalog.manifest_path.display());
    let name_width = catalog
        .personas
        .iter()
        .filter_map(|persona| persona.name.as_ref())
        .map(String::len)
        .max()
        .unwrap_or(4);
    for persona in &catalog.personas {
        let name = persona.name.as_deref().unwrap_or("<unnamed>");
        let tier = persona
            .autonomy_tier
            .map(|tier| tier.as_str())
            .unwrap_or("<missing>");
        let receipts = persona
            .receipt_policy
            .map(|policy| policy.as_str())
            .unwrap_or("<missing>");
        let entry = persona.entry_workflow.as_deref().unwrap_or("<missing>");
        println!(
            "  {name:<name_width$}  tier={tier:<17} receipts={receipts:<8} entry={entry}",
            name_width = name_width
        );
    }
}

pub(crate) fn run_inspect(manifest: Option<&Path>, args: &PersonaInspectArgs) {
    let catalog = load_catalog_or_exit(manifest);
    let Some(persona) = catalog
        .personas
        .iter()
        .find(|persona| persona.name.as_deref() == Some(args.name.as_str()))
    else {
        fatal(&format!(
            "persona '{}' not found in {}",
            args.name,
            catalog.manifest_path.display()
        ));
    };

    if args.json {
        let json = persona_to_json(persona, &catalog);
        println!(
            "{}",
            serde_json::to_string_pretty(&json)
                .unwrap_or_else(|error| fatal(&format!("failed to serialize persona: {error}")))
        );
        return;
    }

    println!(
        "name:           {}",
        persona.name.as_deref().unwrap_or_default()
    );
    if let Some(version) = &persona.version {
        println!("version:        {version}");
    }
    println!(
        "description:    {}",
        persona.description.as_deref().unwrap_or_default()
    );
    println!(
        "entry_workflow: {}",
        persona.entry_workflow.as_deref().unwrap_or_default()
    );
    println!("tools:          {}", comma_or_dash(&persona.tools));
    println!("capabilities:   {}", comma_or_dash(&persona.capabilities));
    println!(
        "autonomy_tier:  {}",
        persona
            .autonomy_tier
            .map(|tier| tier.as_str())
            .unwrap_or_default()
    );
    println!(
        "receipt_policy: {}",
        persona
            .receipt_policy
            .map(|policy| policy.as_str())
            .unwrap_or_default()
    );
    println!("triggers:       {}", comma_or_dash(&persona.triggers));
    println!("schedules:      {}", comma_or_dash(&persona.schedules));
    println!("handoffs:       {}", comma_or_dash(&persona.handoffs));
    println!("context_packs:  {}", comma_or_dash(&persona.context_packs));
    println!("evals:          {}", comma_or_dash(&persona.evals));
    if let Some(owner) = &persona.owner {
        println!("owner:          {owner}");
    }
    println!("manifest:       {}", catalog.manifest_path.display());
}

fn load_catalog_or_exit(manifest: Option<&Path>) -> ResolvedPersonaManifest {
    let result = if let Some(path) = manifest {
        package::load_personas_from_manifest_path(path).map(Some)
    } else {
        package::load_personas_config(None)
    };
    match result {
        Ok(Some(catalog)) => catalog,
        Ok(None) => {
            fatal("no harn.toml found; pass --manifest <path> or run inside a Harn project")
        }
        Err(errors) => {
            for error in &errors {
                eprintln!("error: {error}");
            }
            process::exit(1);
        }
    }
}

fn persona_to_json(
    persona: &PersonaManifestEntry,
    catalog: &ResolvedPersonaManifest,
) -> serde_json::Value {
    serde_json::json!({
        "name": persona.name.as_deref().unwrap_or_default(),
        "version": persona.version.as_deref(),
        "description": persona.description.as_deref().unwrap_or_default(),
        "entry_workflow": persona.entry_workflow.as_deref().unwrap_or_default(),
        "tools": &persona.tools,
        "capabilities": &persona.capabilities,
        "autonomy_tier": persona.autonomy_tier.map(|tier| tier.as_str()).unwrap_or_default(),
        "receipt_policy": persona.receipt_policy.map(|policy| policy.as_str()).unwrap_or_default(),
        "triggers": &persona.triggers,
        "schedules": &persona.schedules,
        "model_policy": {
            "default_model": persona.model_policy.default_model.as_deref(),
            "escalation_model": persona.model_policy.escalation_model.as_deref(),
            "fallback_models": &persona.model_policy.fallback_models,
            "reasoning_effort": persona.model_policy.reasoning_effort.as_deref(),
        },
        "budget": {
            "daily_usd": persona.budget.daily_usd,
            "hourly_usd": persona.budget.hourly_usd,
            "run_usd": persona.budget.run_usd,
            "frontier_escalations": persona.budget.frontier_escalations,
            "max_tokens": persona.budget.max_tokens,
            "max_runtime_seconds": persona.budget.max_runtime_seconds,
        },
        "handoffs": &persona.handoffs,
        "context_packs": &persona.context_packs,
        "evals": &persona.evals,
        "owner": persona.owner.as_deref(),
        "package_source": {
            "package": persona.package_source.package.as_deref(),
            "path": persona.package_source.path.as_deref(),
            "git": persona.package_source.git.as_deref(),
            "rev": persona.package_source.rev.as_deref(),
        },
        "rollout_policy": {
            "mode": persona.rollout_policy.mode.as_deref(),
            "percentage": persona.rollout_policy.percentage,
            "cohorts": &persona.rollout_policy.cohorts,
        },
        "source": {
            "manifest_path": &catalog.manifest_path,
            "manifest_dir": &catalog.manifest_dir,
        },
    })
}

fn comma_or_dash(values: &[String]) -> String {
    if values.is_empty() {
        "-".to_string()
    } else {
        values.join(", ")
    }
}

fn fatal(message: &str) -> ! {
    eprintln!("error: {message}");
    process::exit(1);
}