kura-cli 0.1.16

Kura Training CLI for interacting with the Kura API and MCP runtime.
Documentation
use clap::Args;
use serde::Deserialize;
use serde_json::Value;

use crate::util::{
    admin_surface_enabled, api_request, exit_error, is_admin_api_path, read_json_from_file,
    resolve_token,
};

#[derive(Args)]
pub struct ExecArgs {
    /// JSON envelope file path (use '-' for stdin)
    #[arg(long, default_value = "-")]
    request_file: String,
}

#[derive(Debug, Deserialize)]
struct ExecEnvelope {
    method: String,
    path: String,
    #[serde(default)]
    body: Option<Value>,
    #[serde(default)]
    query: Option<Value>,
    #[serde(default)]
    headers: Option<Value>,
    #[serde(default)]
    include: bool,
    #[serde(default)]
    raw: bool,
    #[serde(default)]
    no_auth: Option<bool>,
    #[serde(default)]
    allow_async_analysis_job: bool,
}

pub async fn run(api_url: &str, cli_no_auth: bool, args: ExecArgs) -> i32 {
    let payload = match read_json_from_file(&args.request_file) {
        Ok(value) => value,
        Err(err) => exit_error(
            &err,
            Some("Provide a valid JSON envelope via --request-file (or '-' for stdin)."),
        ),
    };

    let envelope: ExecEnvelope = match serde_json::from_value(payload) {
        Ok(value) => value,
        Err(err) => exit_error(
            &format!("Invalid exec envelope JSON: {err}"),
            Some(
                "Envelope fields: method, path, body?, query?, headers?, include?, raw?, no_auth?, allow_async_analysis_job?",
            ),
        ),
    };

    let method = parse_method(&envelope.method);
    let path = normalize_path(&envelope.path);

    if is_admin_api_path(&path) && !admin_surface_enabled() {
        exit_error(
            "Admin API paths are disabled in CLI by default.",
            Some("Set KURA_ENABLE_ADMIN_SURFACE=1 only in trusted developer/admin sessions."),
        );
    }

    if is_async_analysis_job_create_path(&path)
        && method == reqwest::Method::POST
        && !envelope.allow_async_analysis_job
    {
        exit_error(
            "Direct async analysis-job creation via POST /v1/analysis/jobs is blocked by default.",
            Some(
                "Use `kura analysis run --objective \"...\"` for user-facing analyses. Set allow_async_analysis_job=true only for explicit background-job workflows.",
            ),
        );
    }

    let query = parse_key_value_entries(envelope.query, "query").unwrap_or_else(|err| {
        exit_error(
            &err,
            Some(
                "Use either an object {\"a\":\"b\"} or an array of {\"key\":\"a\",\"value\":\"b\"} entries.",
            ),
        )
    });

    let headers = parse_key_value_entries(envelope.headers, "headers").unwrap_or_else(|err| {
        exit_error(
            &err,
            Some(
                "Use either an object {\"Header\":\"Value\"} or an array of {\"key\":\"Header\",\"value\":\"Value\"} entries.",
            ),
        )
    });

    let no_auth = envelope.no_auth.unwrap_or(cli_no_auth);
    let token = if no_auth {
        None
    } else {
        match resolve_token(api_url).await {
            Ok(token) => Some(token),
            Err(err) => exit_error(
                &err.to_string(),
                Some("Run `kura login`, set KURA_API_KEY, or set no_auth=true in the envelope."),
            ),
        }
    };

    api_request(
        api_url,
        method,
        &path,
        token.as_deref(),
        envelope.body,
        &query,
        &headers,
        envelope.raw,
        envelope.include,
    )
    .await
}

fn parse_method(raw: &str) -> reqwest::Method {
    match raw.trim().to_ascii_uppercase().as_str() {
        "GET" => reqwest::Method::GET,
        "POST" => reqwest::Method::POST,
        "PUT" => reqwest::Method::PUT,
        "DELETE" => reqwest::Method::DELETE,
        "PATCH" => reqwest::Method::PATCH,
        "HEAD" => reqwest::Method::HEAD,
        "OPTIONS" => reqwest::Method::OPTIONS,
        other => exit_error(
            &format!("Unknown HTTP method in exec envelope: {other}"),
            Some("Supported methods: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS"),
        ),
    }
}

fn normalize_path(raw: &str) -> String {
    let trimmed = raw.trim();
    if trimmed.is_empty() {
        exit_error(
            "Exec envelope field 'path' must not be empty.",
            Some("Example: \"path\": \"/v1/events\""),
        );
    }
    if trimmed.starts_with('/') {
        trimmed.to_string()
    } else {
        format!("/{trimmed}")
    }
}

fn parse_key_value_entries(
    input: Option<Value>,
    field_name: &str,
) -> Result<Vec<(String, String)>, String> {
    let Some(input) = input else {
        return Ok(Vec::new());
    };

    match input {
        Value::Null => Ok(Vec::new()),
        Value::Object(map) => {
            let mut out = Vec::with_capacity(map.len());
            for (key, value) in map {
                let value = value.as_str().ok_or_else(|| {
                    format!(
                        "Exec envelope field '{field_name}.{key}' must be a string, got {value}."
                    )
                })?;
                out.push((key, value.to_string()));
            }
            Ok(out)
        }
        Value::Array(items) => {
            let mut out = Vec::with_capacity(items.len());
            for (index, item) in items.into_iter().enumerate() {
                match item {
                    Value::Object(obj) => {
                        let key = obj
                            .get("key")
                            .and_then(|value| value.as_str())
                            .ok_or_else(|| {
                                format!(
                                    "Exec envelope field '{field_name}[{index}].key' must be a string."
                                )
                            })?;
                        let value = obj
                            .get("value")
                            .and_then(|value| value.as_str())
                            .ok_or_else(|| {
                                format!(
                                    "Exec envelope field '{field_name}[{index}].value' must be a string."
                                )
                            })?;
                        out.push((key.to_string(), value.to_string()));
                    }
                    Value::Array(pair) if pair.len() == 2 => {
                        let key = pair[0].as_str().ok_or_else(|| {
                            format!(
                                "Exec envelope field '{field_name}[{index}][0]' must be a string."
                            )
                        })?;
                        let value = pair[1].as_str().ok_or_else(|| {
                            format!(
                                "Exec envelope field '{field_name}[{index}][1]' must be a string."
                            )
                        })?;
                        out.push((key.to_string(), value.to_string()));
                    }
                    _ => {
                        return Err(format!(
                            "Exec envelope field '{field_name}' supports object entries, {{\"key\":...,\"value\":...}} objects, or [key,value] pairs."
                        ));
                    }
                }
            }
            Ok(out)
        }
        other => Err(format!(
            "Exec envelope field '{field_name}' must be an object or array, got {other}."
        )),
    }
}

fn is_async_analysis_job_create_path(path: &str) -> bool {
    let trimmed = path.trim();
    if trimmed.is_empty() {
        return false;
    }

    let normalized = if trimmed.starts_with('/') {
        trimmed.to_ascii_lowercase()
    } else {
        format!("/{trimmed}").to_ascii_lowercase()
    };

    normalized == "/v1/analysis/jobs" || normalized == "/v1/analysis/jobs/"
}

#[cfg(test)]
mod tests {
    use super::{is_async_analysis_job_create_path, parse_key_value_entries};
    use serde_json::json;

    #[test]
    fn parse_key_value_entries_accepts_object_shape() {
        let parsed = parse_key_value_entries(Some(json!({"a": "b", "x": "y"})), "query").unwrap();
        assert_eq!(
            parsed,
            vec![
                ("a".to_string(), "b".to_string()),
                ("x".to_string(), "y".to_string())
            ]
        );
    }

    #[test]
    fn parse_key_value_entries_accepts_object_array_shape() {
        let parsed = parse_key_value_entries(
            Some(json!([
                {"key": "a", "value": "b"},
                {"key": "x", "value": "y"}
            ])),
            "headers",
        )
        .unwrap();
        assert_eq!(
            parsed,
            vec![
                ("a".to_string(), "b".to_string()),
                ("x".to_string(), "y".to_string())
            ]
        );
    }

    #[test]
    fn parse_key_value_entries_rejects_non_string_values() {
        let err = parse_key_value_entries(Some(json!({"a": 1})), "query").unwrap_err();
        assert!(err.contains("must be a string"));
    }

    #[test]
    fn async_analysis_job_create_path_detection_is_exact() {
        assert!(is_async_analysis_job_create_path("/v1/analysis/jobs"));
        assert!(is_async_analysis_job_create_path("v1/analysis/jobs"));
        assert!(!is_async_analysis_job_create_path("/v1/analysis/jobs/run"));
        assert!(!is_async_analysis_job_create_path("/v1/agent/context"));
    }
}