malvin 0.2.5

Non-interactive research and coding agent
use std::path::PathBuf;
use std::process::Output;
use std::time::Duration;

use crate::agent_or_cursor_agent_bin;
use crate::ansi_strip::strip_ansi_escapes;
use crate::command_output_timeout::{command_output_with_timeout, timeout_ms_from_env};
use crate::model_id::CURSOR_PREFIX;
use crate::output::{MALVIN_WHO, print_stdout_line};

use super::line_matches_prefix;
use super::models_cmd_parse::{print_parsed_or_fallback_prefixed, trim_trailing_tip_lines};

pub const DEFAULT_CURSOR_LIST_MODELS_TIMEOUT_MS: u64 = 30_000;

#[must_use]
pub fn cursor_list_models_timeout() -> Duration {
    timeout_ms_from_env(
        "MALVIN_CURSOR_LIST_MODELS_TIMEOUT_MS",
        DEFAULT_CURSOR_LIST_MODELS_TIMEOUT_MS,
    )
}

pub(super) fn print_cursor_models(filter: Option<&str>) -> Result<(), String> {
    if print_cursor_models_via_sdk(filter).is_ok() {
        return Ok(());
    }
    print_cursor_models_via_cli(filter)
}

fn print_cursor_models_via_sdk(filter: Option<&str>) -> Result<(), String> {
    let output = run_cursor_sdk_models_js()?;
    let raw = String::from_utf8_lossy(&output.stdout);
    if !sdk_catalog_has_model_rows(&raw) {
        return Err("cursor SDK models returned an empty catalog".to_string());
    }
    let rows = sdk_model_rows_from_stdout(&raw);
    print_filtered_model_rows(&rows, filter);
    Ok(())
}

pub(super) fn sdk_catalog_has_model_rows(raw: &str) -> bool {
    raw.lines().any(|line| {
        let t = line.trim();
        !t.is_empty() && !t.starts_with('{')
    })
}

fn run_cursor_sdk_models_js() -> Result<Output, String> {
    let models_js = crate::cursor_sdk::bridge_path::resolve_models_js()?;
    let node = crate::cursor_sdk::node_resolve::resolve_node_bin()?;
    let mut cmd = crate::malvin_sandbox::malvin_std_command(&node);
    crate::cursor_sdk::node_resolve::apply_quiet_node_cli_std(&mut cmd);
    cmd.arg(&models_js);
    let output =
        command_output_with_timeout(cmd, cursor_list_models_timeout(), "cursor SDK models")?;
    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        return Err(format!("cursor SDK models failed: {}", stderr.trim()));
    }
    Ok(output)
}

pub(super) fn sdk_model_rows_from_stdout(raw: &str) -> Vec<String> {
    let mut rows = Vec::new();
    let mut saw_auto = false;
    for line in raw.lines() {
        let t = line.trim();
        if t.is_empty() || t.starts_with('{') {
            continue;
        }
        let id = t.split('\t').next().unwrap_or(t).trim();
        if id == "cursor:auto" {
            saw_auto = true;
        }
        rows.push(t.to_string());
    }
    if !saw_auto {
        rows.insert(0, format!("{CURSOR_PREFIX}auto"));
    }
    rows
}

fn print_filtered_model_rows(rows: &[String], filter: Option<&str>) {
    for row in rows {
        if line_matches_prefix(row, filter) {
            print_stdout_line(MALVIN_WHO, row);
        }
    }
}

pub(super) fn resolve_models_cli() -> Result<PathBuf, String> {
    agent_or_cursor_agent_bin().ok_or_else(|| {
        "Neither `agent` nor `cursor-agent` was found on PATH. Install the Cursor CLI agent to list models (`malvin admin models`)."
            .to_string()
    })
}

pub(super) fn print_cursor_models_via_cli(filter: Option<&str>) -> Result<(), String> {
    let bin = resolve_models_cli()?;
    let mut cmd = crate::malvin_sandbox::malvin_std_command(&bin);
    cmd.arg("models");
    let output = command_output_with_timeout(
        cmd,
        cursor_list_models_timeout(),
        &format!("{} models", bin.display()),
    )?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        let msg = stderr.trim();
        let detail = if msg.is_empty() {
            format!("`{} models` exited with {}", bin.display(), output.status)
        } else {
            format!("`{} models` failed: {msg}", bin.display())
        };
        return Err(format!("agent models failed: {detail}"));
    }

    let raw = String::from_utf8_lossy(&output.stdout);
    let text = strip_ansi_escapes(raw.as_ref());
    let cleaned = trim_trailing_tip_lines(&text);
    print_parsed_or_fallback_prefixed(&cleaned, CURSOR_PREFIX, filter);
    Ok(())
}