stmo-cli 0.11.0

Turn Claude Code into a data analyst on sql.telemetry.mozilla.org
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
mod api;
mod commands;
mod config;
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 = "Turn Claude Code into a data analyst on sql.telemetry.mozilla.org", long_about = None)]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    #[command(
        about = "List queries and dashboards from Redash",
        long_about = "List queries and dashboards from Redash.\n\nWithout --search, lists your own queries.\nWith --search, performs a full-text search across all queries and dashboards."
    )]
    Discover {
        #[arg(long, short = 'q', help = "Search queries and dashboards by text")]
        search: Option<String>,
        #[arg(long, default_value_t = 50, help = "Max results per section")]
        limit: usize,
    },

    #[command(about = "Scaffold a new query/dashboard repository")]
    Init {
        #[arg(help = "Directory to scaffold into (default: current directory)")]
        path: Option<std::path::PathBuf>,
    },

    #[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 tracked query, or run ad-hoc SQL against a data source")]
    Execute {
        #[arg(
            help = "Query ID to execute (must be fetched locally first); omit and use \
                    --data-source to run ad-hoc SQL"
        )]
        query_id: Option<u64>,

        #[arg(
            long,
            help = "Data source ID to run ad-hoc SQL against (no tracked query is created); \
                    SQL comes from --file or stdin"
        )]
        data_source: Option<u64>,

        #[arg(
            long,
            help = "Path to a .sql file to read the ad-hoc SQL from; pass '-' or omit to read \
                    SQL from stdin (e.g. echo 'SELECT 1' | stmo-cli execute --data-source ID)"
        )]
        file: Option<String>,

        #[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 = "Manage Redash query snippets")]
    Snippets {
        #[command(subcommand)]
        command: SnippetCommands,
    },

    #[command(
        about = "Set or clear a query's refresh schedule (updates local YAML; run 'deploy' to push to Redash)",
        long_about = "Set or clear a query's refresh schedule.\n\nUpdates the schedule field in each query's local YAML file. The change is not pushed to Redash until you run 'stmo-cli deploy'.\n\nExamples:\n  stmo-cli schedule 123 456 --interval 86400 --time 07:15\n  stmo-cli schedule 123 --clear"
    )]
    Schedule {
        #[arg(help = "Query IDs to update (e.g., 123 456 789)")]
        query_ids: Vec<u64>,

        #[arg(
            long,
            help = "Refresh interval in seconds (e.g., 86400 for daily)",
            conflicts_with = "clear"
        )]
        interval: Option<u64>,

        #[arg(
            long,
            help = "Time of day for the refresh in HH:MM format (e.g., 07:15)",
            requires = "interval"
        )]
        time: Option<String>,

        #[arg(
            long,
            help = "Day of week for the refresh (0=Sunday through 6=Saturday)",
            requires = "interval"
        )]
        day_of_week: Option<String>,

        #[arg(long, help = "Clear the refresh schedule", conflicts_with = "interval")]
        clear: bool,
    },

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

    #[command(about = "Store your Redash API key in the macOS Keychain")]
    Login,
}

#[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>,
    },
}

#[derive(Subcommand)]
enum SnippetCommands {
    #[command(about = "List query snippets from Redash")]
    List,

    #[command(about = "Fetch query snippets from Redash")]
    Fetch {
        #[arg(help = "Snippet IDs to fetch (e.g., 31 42)")]
        snippet_ids: Vec<u64>,
        #[arg(
            long,
            help = "Fetch all snippets currently tracked in snippets/ directory"
        )]
        all: bool,
    },

    #[command(about = "Deploy local changes to Redash (only changed snippets by default)")]
    Deploy {
        #[arg(help = "Snippet IDs to deploy (e.g., 31 42)")]
        snippet_ids: Vec<u64>,
        #[arg(long, help = "Deploy all snippets instead of only changed ones")]
        all: bool,
    },

    #[command(about = "Delete query snippets in Redash and remove local files")]
    Delete {
        #[arg(help = "Snippet IDs to delete (e.g., 31 42)")]
        snippet_ids: Vec<u64>,
    },
}

#[allow(clippy::too_many_lines)]
async fn run_command(client: RedashClient, command: Commands) -> Result<()> {
    match command {
        Commands::Discover { search, limit } => {
            commands::discover::discover(&client, search.as_deref(), limit).await?;
        }
        Commands::Init { .. } | Commands::Update | Commands::Login => 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,
            data_source,
            file,
            param,
            format,
            interactive,
            timeout,
            limit,
        } => {
            let output_format = format
                .parse::<commands::OutputFormat>()
                .context("Invalid output format")?;
            let args = commands::execute::ExecuteArgs {
                query_id,
                data_source,
                file,
                param_args: param,
                format: output_format,
                interactive,
                timeout_secs: timeout,
                limit_rows: limit,
            };
            commands::execute::execute(&client, args).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::Schedule {
            query_ids,
            interval,
            time,
            day_of_week,
            clear,
        } => {
            if query_ids.is_empty() {
                anyhow::bail!(
                    "No query IDs specified. Provide query IDs to update.\n\nExamples:\n  stmo-cli schedule 123 456 --interval 86400 --time 07:15\n  stmo-cli schedule 123 --clear"
                );
            }
            commands::schedule::schedule(
                &query_ids,
                interval,
                time.as_deref(),
                day_of_week.as_deref(),
                clear,
            )?;
        }
        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?;
            }
        },
        Commands::Snippets { command } => match command {
            SnippetCommands::List => commands::snippets::list(&client).await?,
            SnippetCommands::Fetch { snippet_ids, all } => {
                commands::snippets::fetch(&client, snippet_ids, all).await?;
            }
            SnippetCommands::Deploy { snippet_ids, all } => {
                commands::snippets::deploy(&client, snippet_ids, all).await?;
            }
            SnippetCommands::Delete { snippet_ids } => {
                if snippet_ids.is_empty() {
                    anyhow::bail!(
                        "No snippet IDs specified. Provide snippet IDs to delete.\n\nExample:\n  stmo-cli snippets delete 31 42"
                    );
                }
                commands::snippets::delete(&client, snippet_ids).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()
}

const LLM_HELP: &str = r#"stmo-cli — Redash CLI for sql.telemetry.mozilla.org. Explore data sources, run queries, deploy dashboards.
REDASH_API_KEY required | REDASH_URL optional (default: https://sql.telemetry.mozilla.org)
On macOS, REDASH_API_KEY falls back to the 'stmo-cli' item in the macOS Keychain; run `stmo-cli login` once in your own terminal to store it there (a Claude Code session has no terminal to prompt in).
`init` is an interactive wizard (git repo? initial commit? linters? pre-commit hooks? CLAUDE.md?) and likewise needs a real terminal — run it yourself, not from an AI coding assistant session.
API key: https://sql.telemetry.mozilla.org/users/me → API Key section

discover [--search TEXT] [--limit N] | fetch [IDs] [--all] | deploy [IDs] [--all] | execute ID [--format table|json] [--param k=v]... [--interactive] [--limit N] [--timeout SECS]
execute --data-source ID [--file PATH|-] [--param k=v]...: ad-hoc SQL from a file or stdin ('-' or omit --file = read stdin), no tracked query created (no schema, so no d_* dates or multi-value expansion — inline values in the SQL)
data-sources [ID] [--schema] [--refresh] [--format table|json] | archive IDs | archive --cleanup | unarchive IDs | init [PATH] | update | login
dashboards discover|fetch SLUGS|deploy SLUGS [--all]|archive SLUGS|unarchive SLUGS
snippets list|fetch [IDs] [--all]|deploy [IDs] [--all]|delete IDs

schedule IDs --interval SECS [--time HH:MM] [--day-of-week N] | schedule IDs --clear (writes YAML; run deploy to push)
deploy: no args = only queries that differ from the server-stored copy (no git needed); --all deploys regardless of differences
execute ID: deploys local .sql/.yaml first if it differs from the server-stored query, then always runs the up-to-date server copy
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, snippets/<id>-<trigger>.sql + .yaml | id=0 for new resources, auto-renamed after first deploy
snippets: no archive concept in Redash — delete IDs removes on server + local files (irreversible, unlike archive)
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"]'
Dynamic date tokens resolved client-side (tracked queries only): d_now/d_yesterday (date types); d_today/d_last_7_days/d_last_month/d_this_week/... (range types)
JSON export: stmo-cli execute ID --format json 2>/dev/null > data.json
"#;

fn print_llm_help() {
    print!("{LLM_HELP}");
}

#[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 { path } = cli.command {
        let result = commands::init::init(path);
        version_checker.print_warning();
        return result;
    }

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

    if let Commands::Login = cli.command {
        let result = config::login();
        version_checker.print_warning();
        return result;
    }

    let api_key = config::resolve_api_key()?;
    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(())
}

#[cfg(test)]
mod llm_help_guard {
    use super::{Cli, LLM_HELP};
    use clap::CommandFactory;

    fn collect_names(cmd: &clap::Command, names: &mut Vec<String>) {
        for sub in cmd.get_subcommands() {
            names.push(sub.get_name().to_string());
            for arg in sub.get_arguments() {
                if let Some(long) = arg.get_long()
                    && long != "help"
                    && long != "version"
                {
                    names.push(format!("--{long}"));
                }
            }
            collect_names(sub, names);
        }
    }

    #[test]
    fn llm_help_mentions_every_subcommand_and_flag() {
        let cmd = Cli::command();
        let mut names = Vec::new();
        collect_names(&cmd, &mut names);

        let missing: Vec<&String> = names
            .iter()
            .filter(|name| !LLM_HELP.contains(name.as_str()))
            .collect();

        assert!(
            missing.is_empty(),
            "LLM_HELP is missing these subcommands/flags: {missing:?}\n\
             Update the LLM_HELP const in src/main.rs to document them."
        );
    }

    #[test]
    fn skill_defers_to_help_for_the_command_catalog() {
        let skill = include_str!("../.claude/skills/stmo/SKILL.md");
        assert!(
            skill.contains("stmo-cli --help"),
            "The vendored SKILL.md (.claude/skills/stmo/SKILL.md) must instruct running \
             `stmo-cli --help` for the command/flag catalog, so it stays version-matched \
             instead of restating (and drifting from) LLM_HELP."
        );
    }
}