cueloop 0.6.0

A Rust CLI for managing AI agent loops with a structured JSON task queue
Documentation
use crate::constants::versions::CURSOR_SDK_VERSION;
use crate::runutil::{ManagedCommand, TimeoutClass, execute_managed_command};
use serde::Deserialize;
use std::process::Command;

const MIN_CURSOR_SDK_NODE_MAJOR: u32 = 18;

#[derive(Debug, Deserialize)]
pub(crate) struct CursorSdkPackageCheck {
    pub(crate) selected: Option<CursorSdkSelected>,
    #[serde(default)]
    pub(crate) attempted_sources: Vec<CursorSdkAttempt>,
    #[serde(default)]
    pub(crate) warnings: Vec<String>,
    pub(crate) preferred_sdk_version: String,
    #[serde(default)]
    pub(crate) proceeded_best_effort: bool,
    pub(crate) fatal_cause: Option<String>,
}

#[derive(Debug, Deserialize)]
pub(crate) struct CursorSdkSelected {
    pub(crate) source: String,
    pub(crate) entrypoint: String,
    pub(crate) package_json: Option<String>,
    pub(crate) sdk_version: Option<String>,
    pub(crate) global_root: Option<String>,
}

#[derive(Debug, Deserialize)]
pub(crate) struct CursorSdkAttempt {
    pub(crate) source: String,
    pub(crate) location: Option<String>,
    pub(crate) entrypoint: Option<String>,
    pub(crate) package_json: Option<String>,
    pub(crate) sdk_version: Option<String>,
    pub(crate) global_root: Option<String>,
    pub(crate) status: String,
    pub(crate) error: Option<String>,
}

impl CursorSdkPackageCheck {
    pub(crate) fn version_mismatch(&self) -> bool {
        self.selected
            .as_ref()
            .and_then(|selected| selected.sdk_version.as_deref())
            .is_some_and(|version| version != self.preferred_sdk_version)
    }

    pub(crate) fn attempted_sources_summary(&self) -> String {
        self.attempted_sources
            .iter()
            .map(|attempt| {
                let location = attempt.location.as_deref().unwrap_or("unknown");
                let entrypoint = attempt
                    .entrypoint
                    .as_deref()
                    .map(|value| format!(" -> {value}"))
                    .unwrap_or_default();
                let version = attempt
                    .sdk_version
                    .as_deref()
                    .map(|value| format!(", version {value}"))
                    .unwrap_or_default();
                let package_json = attempt
                    .package_json
                    .as_deref()
                    .map(|value| format!(", package {value}"))
                    .unwrap_or_default();
                let global_root = attempt
                    .global_root
                    .as_deref()
                    .map(|value| format!(", global root {value}"))
                    .unwrap_or_default();
                let error = attempt
                    .error
                    .as_deref()
                    .map(|value| format!(", error: {value}"))
                    .unwrap_or_default();
                format!(
                    "{} {location}{entrypoint} [{}{version}{package_json}{global_root}{error}]",
                    attempt.source, attempt.status
                )
            })
            .collect::<Vec<_>>()
            .join("; ")
    }
}

pub(crate) fn check_cursor_sdk_package(
    node_bin: &str,
    cwd: &std::path::Path,
) -> anyhow::Result<CursorSdkPackageCheck> {
    let script = include_str!("../../../assets/cursor_sdk_doctor_probe.cjs")
        .replace("__CUELOOP_CURSOR_SDK_VERSION__", CURSOR_SDK_VERSION);
    let mut command = Command::new(node_bin);
    command
        .current_dir(cwd)
        .arg("-e")
        .arg(script)
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped());
    let output = execute_managed_command(ManagedCommand::new(
        command,
        "doctor runner probe: Cursor SDK package".to_string(),
        TimeoutClass::Probe,
    ))?
    .into_output();

    if output.status.success() {
        Ok(serde_json::from_slice(&output.stdout)?)
    } else {
        let stderr = String::from_utf8_lossy(&output.stderr);
        Err(anyhow::anyhow!(
            "{}",
            if stderr.trim().is_empty() {
                "@cursor/sdk could not be resolved from CUELOOP_CURSOR_SDK_MODULE_PATH, the workspace, or global npm roots".to_string()
            } else {
                stderr.trim().to_string()
            }
        ))
    }
}

pub(crate) fn check_cursor_sdk_node_version(node_bin: &str) -> anyhow::Result<()> {
    let mut command = Command::new(node_bin);
    command
        .args(["-p", "process.versions.node"])
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped());
    let output = execute_managed_command(ManagedCommand::new(
        command,
        format!("doctor runner probe: {node_bin} Cursor SDK Node version"),
        TimeoutClass::Probe,
    ))?
    .into_output();

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        anyhow::bail!(
            "{}",
            if stderr.trim().is_empty() {
                format!(
                    "failed to read Node version, exit status: {}",
                    output.status
                )
            } else {
                stderr.trim().to_string()
            }
        );
    }

    let version = String::from_utf8_lossy(&output.stdout);
    ensure_cursor_sdk_node_version_supported(version.trim())
}

pub(crate) fn ensure_cursor_sdk_node_version_supported(version: &str) -> anyhow::Result<()> {
    let major = version
        .trim_start_matches('v')
        .split('.')
        .next()
        .and_then(|part| part.parse::<u32>().ok());

    match major {
        Some(major) if major >= MIN_CURSOR_SDK_NODE_MAJOR => Ok(()),
        Some(major) => anyhow::bail!(
            "Node {major} is unsupported; Cursor SDK requires Node {MIN_CURSOR_SDK_NODE_MAJOR} or newer"
        ),
        None => anyhow::bail!(
            "could not parse Node version '{version}'; Cursor SDK requires Node {MIN_CURSOR_SDK_NODE_MAJOR} or newer"
        ),
    }
}

pub(crate) fn cursor_sdk_blocking_reason(message: &str) -> &'static str {
    if message.contains("missing_sdk") {
        "cursor_sdk_missing"
    } else if message.contains("invalid_module_path")
        || message.contains("CUELOOP_CURSOR_SDK_MODULE_PATH is set but unusable")
    {
        "cursor_sdk_invalid_module_path"
    } else if message.contains("incompatible_api") || message.contains("does not expose") {
        "cursor_sdk_incompatible_api"
    } else if message.contains("import_failed") {
        "cursor_sdk_import_failed"
    } else {
        "cursor_sdk_missing"
    }
}