stmo-cli 0.7.0

CLI for version controlling Redash queries and dashboards
Documentation
mod api;
mod commands;
mod models;

use anyhow::{Context, Result};
use api::RedashClient;
use clap::{Parser, Subcommand};
use moz_cli_version_check::VersionChecker;

#[derive(Parser)]
#[command(name = "stmo-cli", version)]
#[command(about = "CLI tool for version controlling Redash queries and dashboards", long_about = None)]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    #[command(about = "List all queries from Redash")]
    Discover,

    #[command(about = "Scaffold a new query/dashboard repository")]
    Init,

    #[command(about = "Fetch queries from Redash")]
    Fetch {
        #[arg(help = "Query IDs to fetch (e.g., 123 456 789)")]
        query_ids: Vec<u64>,
        #[arg(
            long,
            help = "Fetch all queries currently tracked in queries/ directory"
        )]
        all: bool,
    },

    #[command(about = "Deploy local changes to Redash (only changed queries by default)")]
    Deploy {
        #[arg(help = "Query IDs to deploy (e.g., 123 456 789)")]
        query_ids: Vec<u64>,
        #[arg(long, help = "Deploy all queries instead of only changed ones")]
        all: bool,
    },

    #[command(about = "Execute a query and display results")]
    Execute {
        #[arg(help = "Query ID to execute (must be fetched locally first)")]
        query_id: u64,

        #[arg(
            long,
            help = "Query parameter in format: name=value (can be used multiple times)"
        )]
        param: Vec<String>,

        #[arg(
            long,
            short = 'f',
            default_value = "json",
            help = "Output format: json or table"
        )]
        format: String,

        #[arg(
            long,
            short = 'i',
            help = "Prompt for missing parameters interactively"
        )]
        interactive: bool,

        #[arg(long, default_value = "300", help = "Timeout in seconds")]
        timeout: u64,

        #[arg(long, help = "Limit number of rows displayed (default: all)")]
        limit: Option<usize>,
    },

    #[command(about = "List and explore data sources")]
    DataSources {
        #[arg(help = "Optional: Data source ID to inspect")]
        data_source_id: Option<u64>,

        #[arg(long, help = "Show table schema for the data source")]
        schema: bool,

        #[arg(
            long,
            help = "Force refresh schema from data source (slower but always up-to-date)"
        )]
        refresh: bool,

        #[arg(
            long,
            short = 'f',
            default_value = "json",
            help = "Output format: json or table"
        )]
        format: String,
    },

    #[command(about = "Archive queries in Redash and remove local files")]
    Archive {
        #[arg(help = "Query IDs to archive (e.g., 123 456 789)")]
        query_ids: Vec<u64>,

        #[arg(
            long,
            help = "Remove local files for queries already archived in Redash"
        )]
        cleanup: bool,
    },

    #[command(about = "Restore archived queries")]
    Unarchive {
        #[arg(help = "Query IDs to unarchive (e.g., 123 456 789)")]
        query_ids: Vec<u64>,
    },

    #[command(about = "Manage dashboards")]
    Dashboards {
        #[command(subcommand)]
        command: DashboardCommands,
    },

    #[command(about = "Update stmo-cli to the latest version")]
    Update,
}

#[derive(Subcommand)]
enum DashboardCommands {
    #[command(about = "List all dashboards from Redash")]
    Discover,

    #[command(about = "Fetch dashboards from Redash")]
    Fetch {
        #[arg(
            help = "Dashboard slugs to fetch (e.g., firefox-desktop-on-steamos bug-2006698---ccov-build-regression)"
        )]
        slugs: Vec<String>,
    },

    #[command(about = "Deploy dashboard changes to Redash")]
    Deploy {
        #[arg(
            help = "Dashboard slugs to deploy (e.g., firefox-desktop-on-steamos bug-2006698---ccov-build-regression)"
        )]
        slugs: Vec<String>,
        #[arg(long, help = "Deploy all tracked dashboards")]
        all: bool,
    },

    #[command(about = "Archive dashboards in Redash and remove local files")]
    Archive {
        #[arg(
            help = "Dashboard slugs to archive (e.g., firefox-desktop-on-steamos bug-2006698---ccov-build-regression)"
        )]
        slugs: Vec<String>,
    },

    #[command(about = "Restore archived dashboards")]
    Unarchive {
        #[arg(
            help = "Dashboard slugs to unarchive (e.g., firefox-desktop-on-steamos bug-2006698---ccov-build-regression)"
        )]
        slugs: Vec<String>,
    },
}

async fn run_command(client: RedashClient, command: Commands) -> Result<()> {
    match command {
        Commands::Discover => commands::discover::discover(&client).await?,
        Commands::Init | Commands::Update => unreachable!(),
        Commands::Fetch { query_ids, all } => {
            commands::fetch::fetch(&client, query_ids, all).await?;
        }
        Commands::Deploy { query_ids, all } => {
            commands::deploy::deploy(&client, query_ids, all).await?;
        }
        Commands::Execute {
            query_id,
            param,
            format,
            interactive,
            timeout,
            limit,
        } => {
            let output_format = format
                .parse::<commands::OutputFormat>()
                .context("Invalid output format")?;
            commands::execute::execute(
                &client,
                query_id,
                param,
                output_format,
                interactive,
                timeout,
                limit,
            )
            .await?;
        }
        Commands::DataSources {
            data_source_id,
            schema,
            refresh,
            format,
        } => {
            let output_format = format
                .parse::<commands::OutputFormat>()
                .context("Invalid output format")?;
            if let Some(id) = data_source_id {
                commands::datasources::show_data_source(
                    &client,
                    id,
                    schema,
                    refresh,
                    output_format,
                )
                .await?;
            } else {
                commands::datasources::list_data_sources(&client, output_format).await?;
            }
        }
        Commands::Archive { query_ids, cleanup } => {
            if cleanup {
                commands::archive::cleanup(&client).await?;
            } else if !query_ids.is_empty() {
                commands::archive::archive(&client, query_ids).await?;
            } else {
                anyhow::bail!(
                    "No query IDs specified. Use specific query IDs or --cleanup flag.\n\nExamples:\n  stmo-cli archive 123 456\n  stmo-cli archive --cleanup"
                );
            }
        }
        Commands::Unarchive { query_ids } => {
            if query_ids.is_empty() {
                anyhow::bail!(
                    "No query IDs specified. Provide query IDs to unarchive.\n\nExample:\n  stmo-cli unarchive 123 456"
                );
            }
            commands::archive::unarchive(&client, query_ids).await?;
        }
        Commands::Dashboards { command } => match command {
            DashboardCommands::Discover => commands::dashboards::discover(&client).await?,
            DashboardCommands::Fetch { slugs } => {
                commands::dashboards::fetch(&client, slugs).await?;
            }
            DashboardCommands::Deploy { slugs, all } => {
                commands::dashboards::deploy(&client, slugs, all).await?;
            }
            DashboardCommands::Archive { slugs } => {
                commands::dashboards::archive(&client, slugs).await?;
            }
            DashboardCommands::Unarchive { slugs } => {
                commands::dashboards::unarchive(&client, slugs).await?;
            }
        },
    }
    Ok(())
}

fn is_llm_environment() -> bool {
    std::env::var("CLAUDECODE").is_ok()
        || std::env::var("CODEX_SANDBOX").is_ok()
        || std::env::var("GEMINI_CLI").is_ok()
        || std::env::var("OPENCODE").is_ok()
}

fn print_llm_help() {
    print!(
        r#"stmo-cli — version control Redash queries/dashboards on sql.telemetry.mozilla.org
REDASH_API_KEY required | REDASH_URL optional (default: https://sql.telemetry.mozilla.org)
API key: https://sql.telemetry.mozilla.org/users/me → API Key section

discover | fetch [IDs] [--all] | deploy [IDs] [--all] | execute ID [--format table|json] [--param k=v]... [--interactive] [--limit N]
data-sources [ID] [--schema] [--refresh] | archive IDs | archive --cleanup | unarchive IDs | init | update
dashboards discover|fetch SLUGS|deploy SLUGS [--all]|archive SLUGS|unarchive SLUGS

deploy: uses git diff by default; --all required outside a git repo
archive IDs: archives on server + deletes local | archive --cleanup: deletes local only for already-archived (does NOT archive on server)
dashboards: addressed by slug not ID; only favorited dashboards appear in dashboards discover

Files: queries/<id>-<slug>.sql + .yaml, dashboards/<id>-<slug>.yaml | id=0 for new resources, auto-renamed after first deploy
Required YAML fields: id name data_source_id options.parameters(can be []) visualizations(can be [])
Slug from name: lowercase, non-alphanum→'-', collapse dashes (e.g. "Mozilla's .rpm"→"mozilla-s-rpm")
enumOptions: use YAML multiline (|-), NOT escaped \n or deploy fails
Multi-value enum params require JSON array: --param channels='["release","beta"]'
JSON export: stmo-cli execute ID --format json 2>/dev/null > data.json
"#
    );
}

#[tokio::main]
async fn main() -> Result<()> {
    let version_checker = VersionChecker::new("stmo-cli", env!("CARGO_PKG_VERSION"));
    version_checker.check_async();

    if is_llm_environment() && std::env::args().any(|arg| arg == "--help" || arg == "-h") {
        print_llm_help();
        version_checker.print_warning();
        return Ok(());
    }

    let cli = match Cli::try_parse() {
        Ok(cli) => cli,
        Err(e) => {
            e.print()?;
            if e.kind() == clap::error::ErrorKind::DisplayVersion {
                version_checker.print_warning_sync();
            } else {
                version_checker.print_warning();
            }
            std::process::exit(e.exit_code());
        }
    };

    if let Commands::Init = cli.command {
        let result = commands::init::init();
        version_checker.print_warning();
        return result;
    }

    if let Commands::Update = cli.command {
        let result = commands::update::update();
        version_checker.print_warning();
        return result;
    }

    let api_key =
        std::env::var("REDASH_API_KEY").context("REDASH_API_KEY environment variable not set")?;
    let base_url = std::env::var("REDASH_URL")
        .unwrap_or_else(|_| "https://sql.telemetry.mozilla.org".to_string());
    let client = RedashClient::new(base_url, &api_key)?;

    run_command(client, cli.command).await?;
    version_checker.print_warning();
    Ok(())
}