pidgin-cli 0.2.1

CLI for the Pidgin agent handoff protocol
use std::fs;
use std::path::{Path, PathBuf};

use pidgin_lang::ast::{FieldValue, PgnPacket};
use pidgin_lang::expander::expand_to_run_packet;
use pidgin_lang::logging::{LogEvent, log_event};
use pidgin_lang::parser::parse_packet;
use pidgin_lang::registry::{ActionRegistry, SafetyRules, WorkflowRegistry};
use pidgin_lang::resolver::{ReferenceAliases, ResolverContext, load_aliases, resolve_all};
use pidgin_lang::safety::{check_resolved_refs_safety, check_safety};
use pidgin_lang::validator::schema::validate_schema;
use pidgin_lang::validator::syntax::validate_syntax;

pub fn canonicalize_host(host: &Path) -> PathBuf {
    host.canonicalize().unwrap_or_else(|e| {
        eprintln!(
            "error: cannot canonicalize host path {}: {}",
            host.display(),
            e
        );
        std::process::exit(1);
    })
}

pub fn load_pipeline_configs(host: &Path) -> PipelineConfig {
    let config_dir = host.join(".pidgin");
    PipelineConfig {
        workflows: load_or_exit(
            pidgin_lang::registry::load_workflow_registry(
                &config_dir.join("WORKFLOW_REGISTRY.yaml"),
            ),
            "workflow registry",
        ),
        actions: load_or_exit(
            pidgin_lang::registry::load_action_registry(&config_dir.join("ACTION_REGISTRY.yaml")),
            "action registry",
        ),
        safety_rules: load_or_exit(
            pidgin_lang::registry::load_safety_rules(&config_dir.join("SAFETY_RULES.yaml")),
            "safety rules",
        ),
        aliases: load_or_exit(
            load_aliases(&config_dir.join("REFERENCE_ALIASES.yaml")),
            "reference aliases",
        ),
    }
}

pub struct PipelineConfig {
    pub workflows: WorkflowRegistry,
    pub actions: ActionRegistry,
    pub safety_rules: SafetyRules,
    pub aliases: ReferenceAliases,
}

pub fn load_or_exit<T>(result: Result<T, impl std::fmt::Display>, label: &str) -> T {
    match result {
        Ok(v) => v,
        Err(e) => {
            eprintln!("Error loading {}: {}", label, e);
            std::process::exit(4);
        }
    }
}

pub fn get_required_inputs(packet: &PgnPacket, workflows: &WorkflowRegistry) -> Vec<String> {
    packet
        .fields
        .get("wf")
        .and_then(|v| match v {
            FieldValue::Scalar(s) => workflows.workflows.get(s),
            _ => None,
        })
        .map(|w| w.required_inputs.clone())
        .unwrap_or_default()
}

pub fn process_packet(
    path: &Path,
    cfg: &PipelineConfig,
    host_root: &Path,
    outbox: &Path,
    config_dir: &Path,
) {
    let content = match fs::read_to_string(path) {
        Ok(c) => c,
        Err(e) => {
            eprintln!("  error reading: {}", e);
            return;
        }
    };
    let packet = match parse_packet(&content) {
        Ok(p) => p,
        Err(e) => {
            eprintln!("  parse error: {}", e);
            let _ = log_event(
                &config_dir.join("logs").join("PROTOCOL_ERRORS.csv"),
                &LogEvent::Parse {
                    run_id: path.display().to_string(),
                    ok: false,
                },
            );
            return;
        }
    };

    let mut valid = true;
    for err in &validate_syntax(&packet) {
        eprintln!("  [{}] {}", err.code, err.message);
        valid = false;
    }
    for err in &validate_schema(&packet, &cfg.workflows) {
        eprintln!("  [{}] {}", err.code, err.message);
        valid = false;
    }
    if !valid {
        let _ = log_event(
            &config_dir.join("logs").join("PROTOCOL_ERRORS.csv"),
            &LogEvent::Validate {
                run_id: packet.run_id.clone(),
                ok: false,
            },
        );
        return;
    }

    let safety = check_safety(&packet, &cfg.actions, &cfg.safety_rules, &cfg.workflows);
    if safety.blocked {
        eprintln!("  blocked by safety:");
        for rule in &safety.fired_rules {
            eprintln!("    [{}]", rule);
        }
        let _ = log_event(
            &config_dir.join("logs").join("PROTOCOL_ERRORS.csv"),
            &LogEvent::SafetyGate {
                run_id: packet.run_id.clone(),
                blocked: true,
                rules: safety
                    .fired_rules
                    .iter()
                    .map(|r| r.to_string())
                    .collect::<Vec<_>>()
                    .join(","),
            },
        );
        return;
    }

    let required_inputs = get_required_inputs(&packet, &cfg.workflows);
    let ctx = ResolverContext {
        host_root: host_root.to_path_buf(),
        aliases: cfg.aliases.clone(),
        required_inputs,
    };
    let resolved = resolve_all(&packet, &ctx);

    let resolved_fired = check_resolved_refs_safety(&resolved, &cfg.safety_rules.private_paths);
    if !resolved_fired.is_empty() {
        eprintln!("  blocked by safety after resolution:");
        for rule in &resolved_fired {
            eprintln!("    [{}]", rule);
        }
        return;
    }

    let expanded = expand_to_run_packet(&packet, &resolved, &safety, &cfg.workflows);
    let out_path = outbox.join(format!(
        "{}.RUN_PACKET.yaml",
        packet.run_id.replace('.', "_")
    ));
    let yaml = match serde_yaml::to_string(&expanded) {
        Ok(y) => y,
        Err(e) => {
            eprintln!("  error serializing: {}", e);
            return;
        }
    };
    match fs::write(&out_path, &yaml) {
        Ok(_) => eprintln!("  -> {}", out_path.display()),
        Err(e) => eprintln!("  error writing: {}", e),
    }

    let _ = log_event(
        &config_dir.join("logs").join("PIDGIN_RUNTIME_RUNS.csv"),
        &LogEvent::Run {
            run_id: packet.run_id.clone(),
            status: "watch_ok".to_string(),
        },
    );
}

pub const DEFAULT_WORKFLOW_REGISTRY: &str = r#"workflows:
  generic_review:
    description: Review a piece of content or code against a set of source references.
    risk_default: med
    allowed_modes: [draft, review, approval]
    required_inputs: [primary_subject, source_refs]
    expected_outputs: [review_notes, risk_flags, approval]
    recommended_executor: claude-code
    fallback_executor: opencode

  generic_health_check:
    description: Check a host's structure, configuration, and logs for drift or errors.
    risk_default: low
    allowed_modes: [review, patch]
    required_inputs: [host_tree, configs, logs]
    expected_outputs: [health_report, review_required]
    recommended_executor: opencode
    fallback_executor: claude-code

  generic_draft_and_distribute:
    description: Draft a piece of output content from a source and prepare it for
      multiple destination formats, gated on human approval before anything is sent.
    risk_default: med
    allowed_modes: [draft, review, approval]
    required_inputs: [source, style_guide]
    expected_outputs: [drafts, approval]
    recommended_executor: claude-code
    fallback_executor: codex
"#;

pub const DEFAULT_ACTION_REGISTRY: &str = r#"safe:
  - read
  - retrieve
  - summarize
  - classify
  - draft
  - review
  - score
  - rank
  - flag
  - compare
  - extract
  - package
  - validate
  - log
  - index

controlled:
  - patch
  - move
  - rename
  - update
  - append
  - reindex
  - optimize
  - compress
  - expand
  - rerank

human_gated:
  - publish
  - send
  - delete
  - moderate
  - archive
  - credential
  - approve
  - reject
  - promote_memory
  - change_policy
  - external_action
"#;

pub const DEFAULT_SAFETY_RULES: &str = r#"private_paths:
  - ".env"
  - ".env.*"
  - "*.key"
  - "*.pem"
  - ".git/"
  - "**/secrets/**"
  - "**/.ssh/**"

human_required:
  actions:
    - publish
    - send
    - delete
    - moderate
    - credential
    - promote_memory
    - external_action
  risk_levels:
    - high
    - crit
"#;

pub const DEFAULT_REFERENCE_ALIASES: &str = r#"aliases: {}
common: {}
"#;

pub mod check;
pub mod context_plan;
pub mod docs;
pub mod doctor;
pub mod expand;
pub mod init;
pub mod measure;
pub mod parse;
pub mod resolve;
pub mod run;
#[cfg(feature = "server")]
pub mod serve;
pub mod validate;
pub mod watch;