zilliz 1.4.2

TUI and CLI tool for managing Zilliz Cloud clusters and Milvus operations
Documentation
use std::io::{self, BufRead, IsTerminal, Write};

use anyhow::{Context, Result};

use crate::api::client::ApiClient;
use crate::api::error::ApiError;
use crate::cli::args::{AuthCommands, ContextCommands};
use crate::cli::dispatch::OutputOpts;
use crate::config::credentials::resolve_api_key;
use crate::config::manager::ConfigManager;
use crate::model::loader::Models;

/// Resolve whether the flow should run interactively. Returns `false` when
/// the caller passed `--non-interactive`, or when stdout is not a TTY.
pub fn is_interactive(non_interactive_flag: bool) -> bool {
    if non_interactive_flag {
        return false;
    }
    io::stdout().is_terminal()
}

/// Top-level quickstart entry point.
pub async fn run(
    models: &Models,
    config_mgr: &ConfigManager,
    non_interactive: bool,
    skip_login: bool,
    output_opts: &OutputOpts<'_>,
) -> Result<()> {
    let interactive = is_interactive(non_interactive);

    if !interactive {
        print_cheatsheet(config_mgr);
        return Ok(());
    }

    print_banner();

    // Step 1: auth bootstrap
    if !skip_login {
        auth_bootstrap(models, config_mgr).await?;
    } else {
        println!("(Skipping auth bootstrap. Run `zilliz login` if you need to sign in.)\n");
    }

    // Step 2: org / cluster context
    context_bootstrap(config_mgr)?;

    // Step 3 + 4: intent menu loop
    intent_menu(models, config_mgr, output_opts).await?;

    // Step 5: cheatsheet
    println!();
    print_cheatsheet(config_mgr);

    Ok(())
}

fn print_banner() {
    println!("Welcome to the Zilliz Cloud CLI quickstart.");
    println!("This walks you through login, picking a cluster, and a few common operations.");
    println!("Press Ctrl+C at any prompt to abort. Nothing is written without confirmation.\n");
}

// ---------------------------------------------------------------------------
// Step 1: auth bootstrap
// ---------------------------------------------------------------------------

async fn auth_bootstrap(models: &Models, config_mgr: &ConfigManager) -> Result<()> {
    if let Some(user) = config_mgr.get_user_info() {
        println!("Already authenticated as {} ({}).", user.name, user.email);
        return Ok(());
    }
    if config_mgr.get_credential("api_key").is_some() {
        println!("Already authenticated with API key.");
        return Ok(());
    }

    println!("Step 1/3: Sign in to Zilliz Cloud.");
    println!("  [1] Browser OAuth login (recommended)");
    println!("  [2] API key");
    println!("  [3] Skip — finish quickstart without authenticating");
    let choice = prompt_line("Select [1-3]: ")?;

    match choice.trim() {
        "1" => {
            let auth_config = models.control_plane.auth.as_ref();
            crate::cli::auth::login(config_mgr, auth_config, false, None, false, false).await?;
        }
        "2" => {
            let auth_config = models.control_plane.auth.as_ref();
            crate::cli::auth::login(config_mgr, auth_config, true, Some(""), false, false).await?;
        }
        _ => {
            println!("Skipped sign-in. Some next steps may be unavailable.");
        }
    }
    println!();
    Ok(())
}

// ---------------------------------------------------------------------------
// Step 2: org / cluster context
// ---------------------------------------------------------------------------

fn context_bootstrap(config_mgr: &ConfigManager) -> Result<()> {
    println!("Step 2/3: Choose your organization context.");
    let orgs = config_mgr.get_all_orgs();
    if orgs.is_empty() {
        println!("(No organizations resolved yet — skip until you've signed in.)\n");
        return Ok(());
    }
    if orgs.len() == 1 {
        let org = &orgs[0];
        println!(
            "Single organization detected: {} ({}). No switch needed.\n",
            org.name, org.org_id
        );
        return Ok(());
    }

    let confirm = prompt_line("Switch organization now? [y/N]: ")?;
    if matches!(confirm.trim().to_ascii_lowercase().as_str(), "y" | "yes") {
        // Reuse the existing interactive switch flow; it persists only on confirm.
        crate::cli::auth_cmd::run(config_mgr, AuthCommands::Switch { org_id: None })?;
    }
    println!();
    Ok(())
}

// ---------------------------------------------------------------------------
// Step 3 + 4: intent menu
// ---------------------------------------------------------------------------

async fn intent_menu(
    models: &Models,
    config_mgr: &ConfigManager,
    output_opts: &OutputOpts<'_>,
) -> Result<()> {
    loop {
        println!("Step 3/3: What would you like to do?");
        println!("  [1] List clusters");
        println!("  [2] Set active cluster context");
        println!("  [3] List collections in the active cluster");
        println!("  [4] View billing usage");
        println!("  [5] Exit quickstart");
        let choice = prompt_line("Select [1-5]: ")?;

        match choice.trim() {
            "1" => {
                echo_command(&["cluster", "list"]);
                crate::cli::dispatch::run(
                    models,
                    config_mgr,
                    "cluster",
                    "list",
                    &[],
                    output_opts,
                    false,
                )
                .await?;
            }
            "2" => {
                let api_key_owned = output_opts.api_key.map(|s| s.to_string());
                match prompt_cluster_id(models, config_mgr, api_key_owned.as_deref()).await? {
                    Some(cluster_id) => {
                        echo_command(&["context", "set", "--cluster-id", &cluster_id]);
                        crate::cli::context::run(
                            models,
                            config_mgr,
                            ContextCommands::Set {
                                cluster_id: Some(cluster_id),
                                endpoint: None,
                                database: None,
                                on_demand: false,
                            },
                            output_opts.format,
                            api_key_owned.as_deref(),
                        )
                        .await?;
                    }
                    None => {
                        // User aborted or no clusters available; message already printed.
                    }
                }
            }
            "3" => {
                if config_mgr.get_context().cluster_id.is_none() {
                    println!("No active cluster context. Pick option 2 first to select a cluster.");
                } else {
                    echo_command(&["collection", "list"]);
                    crate::cli::dispatch::run(
                        models,
                        config_mgr,
                        "collection",
                        "list",
                        &[],
                        output_opts,
                        false,
                    )
                    .await?;
                }
            }
            "4" => {
                echo_command(&["billing", "usage"]);
                crate::cli::billing::usage(models, config_mgr, &[], output_opts).await?;
            }
            "5" | "" | "q" | "quit" | "exit" => {
                println!("Exiting intent menu.");
                return Ok(());
            }
            other => {
                println!("Unknown selection: '{}'. Pick 1-5.", other);
                continue;
            }
        }

        let again = prompt_line("\nPick another action? [y/N]: ")?;
        if !matches!(again.trim().to_ascii_lowercase().as_str(), "y" | "yes") {
            return Ok(());
        }
    }
}

/// Fetch clusters from `/v2/clusters` and prompt the user to pick one.
/// Returns `Ok(None)` if the user aborts or no clusters are available so the
/// caller can fall through gracefully instead of propagating a "no choice" error.
async fn prompt_cluster_id(
    models: &Models,
    config_mgr: &ConfigManager,
    api_key_override: Option<&str>,
) -> Result<Option<String>> {
    let api_key = resolve_api_key(api_key_override, config_mgr).ok_or(ApiError::NoApiKey)?;
    let base_url =
        crate::cli::endpoint::resolve_control_plane_url(config_mgr, &models.control_plane, None);
    let client = ApiClient::new(api_key, base_url);

    let result = client.call("GET", "/v2/clusters", None, None).await?;
    let clusters = result
        .get("clusters")
        .and_then(|v| v.as_array())
        .or_else(|| result.as_array())
        .context("Failed to fetch clusters")?;

    if clusters.is_empty() {
        println!(
            "No clusters found in the current organization. Pick option [2] to create one first."
        );
        return Ok(None);
    }

    println!("Available clusters:");
    for (i, c) in clusters.iter().enumerate() {
        let id = c.get("clusterId").and_then(|v| v.as_str()).unwrap_or("?");
        let name = c.get("clusterName").and_then(|v| v.as_str()).unwrap_or("");
        let region = c.get("regionId").and_then(|v| v.as_str()).unwrap_or("");
        let status = c.get("status").and_then(|v| v.as_str()).unwrap_or("");
        let plan = c
            .get("deploymentOption")
            .or_else(|| c.get("plan"))
            .and_then(|v| v.as_str())
            .unwrap_or("");
        println!(
            "  [{:>2}] {:<24} {:<20} {:<10} {:<12} {}",
            i + 1,
            id,
            name,
            plan,
            status,
            region
        );
    }

    let input = prompt_line(&format!(
        "Select cluster [1-{}] (Enter to cancel): ",
        clusters.len()
    ))?;
    let trimmed = input.trim();
    if trimmed.is_empty() {
        println!("Cancelled. Active context unchanged.");
        return Ok(None);
    }
    let idx: usize = match trimmed.parse() {
        Ok(n) => n,
        Err(_) => {
            println!("Invalid selection '{}'. Active context unchanged.", trimmed);
            return Ok(None);
        }
    };
    if idx < 1 || idx > clusters.len() {
        println!("Selection out of range. Active context unchanged.");
        return Ok(None);
    }

    let id = clusters[idx - 1]
        .get("clusterId")
        .and_then(|v| v.as_str())
        .map(|s| s.to_string())
        .context("Selected cluster has no clusterId")?;
    Ok(Some(id))
}

// ---------------------------------------------------------------------------
// Step 5: cheatsheet
// ---------------------------------------------------------------------------

fn print_cheatsheet(_config_mgr: &ConfigManager) {
    println!("================ Zilliz CLI cheatsheet ================");

    println!("\nAuthentication:");
    println!("  zilliz login                # browser OAuth login");
    println!("  zilliz login --api-key      # log in with an API key");
    println!("  zilliz whoami               # show current identity");
    println!("  zilliz switch               # switch organization (<orgId>)");

    println!("\nContext:");
    println!("  zilliz context current      # show active cluster context");
    println!("  zilliz context set --cluster-id <clusterId>   # set the active cluster");
    println!("  zilliz context clear        # clear the active cluster");

    println!("\nClusters:");
    println!("  zilliz cluster list                                 # list clusters in current organization");
    println!("  zilliz cluster create --name <name> --type free     # create a free cluster");
    println!("  zilliz cluster describe --cluster-id <clusterId>   # show cluster details");
    println!("  zilliz cluster suspend  --cluster-id <clusterId>   # suspend the cluster");
    println!("  zilliz cluster delete   --cluster-id <clusterId>   # delete the cluster");

    println!("\nCollections & Vectors:");
    println!("  zilliz collection list                                       # list collections in active cluster");
    println!(
        "  zilliz collection describe --name <collection>               # show collection schema"
    );
    println!("  zilliz vector query  --collection <collection> --filter 'id > 0'");
    println!(
        "  zilliz vector search --collection <collection> --data '[[0.1, 0.2, 0.3]]' --limit 10"
    );

    println!("\nOutput formatting:");
    println!("  zilliz cluster list -o json            # JSON output");
    println!("  zilliz cluster list -o yaml            # YAML output");
    println!("  zilliz cluster list --query '[*].id'   # JMESPath filter");

    println!("\nHelp:");
    println!("  zilliz --help                          # full command list");
    println!("  zilliz <command> --help                # help for any command");
    println!("  zilliz quickstart --non-interactive    # reprint this cheatsheet");

    println!("\n=======================================================");
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

fn echo_command(args: &[&str]) {
    use colored::Colorize;
    let line = format!("> zilliz {}", args.join(" "));
    println!("{}", line.cyan().bold());
    println!();
}

fn prompt_line(prompt: &str) -> Result<String> {
    print!("{}", prompt);
    io::stdout().flush()?;
    let mut buf = String::new();
    io::stdin().lock().read_line(&mut buf)?;
    Ok(buf)
}