Skip to main content

fraiseql_cli/
runner.rs

1//! CLI command dispatch and helper utilities.
2
3use std::{env, process, str::FromStr};
4
5use clap::{CommandFactory, Parser};
6use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
7
8use crate::cli::{
9    Cli, Commands, FederationCommands, GenerateClientCommands, IntrospectCommands, MigrateCommands,
10    PerfCommands, PerfExploreCommands, SchemaCommands, ValidateCommands,
11};
12
13/// Run the FraiseQL CLI. Called from both the `fraiseql-cli` and `fraiseql` binary entry points.
14#[allow(clippy::cognitive_complexity)] // Reason: CLI dispatch with many subcommand branches
15pub async fn run() {
16    use crate::{commands, output};
17
18    if let Some(code) = handle_introspection_flags() {
19        process::exit(code);
20    }
21
22    let cli = Cli::parse();
23
24    init_logging(cli.verbose, cli.debug);
25
26    let json_output = cli.json;
27    let debug_output = cli.debug;
28
29    let result = match cli.command {
30        Commands::Compile {
31            input,
32            types,
33            schema_dir,
34            type_files,
35            query_files,
36            mutation_files,
37            output,
38            check,
39            skip_hash,
40            database,
41            emit_ddl,
42            check_migrations,
43            allow_drift,
44        } => {
45            commands::compile::run(
46                &input,
47                types.as_deref(),
48                schema_dir.as_deref(),
49                type_files,
50                query_files,
51                mutation_files,
52                &output,
53                check,
54                database.as_deref(),
55                emit_ddl.as_deref(),
56                check_migrations,
57                skip_hash,
58                allow_drift,
59            )
60            .await
61        },
62
63        Commands::Extract {
64            input,
65            language,
66            recursive,
67            output,
68        } => commands::extract::run(&input, language.as_deref(), recursive, &output),
69
70        Commands::Explain { query } => match commands::explain::run(&query) {
71            Ok(result) => {
72                println!("{}", output::OutputFormatter::new(cli.json, cli.quiet).format(&result));
73                Ok(())
74            },
75            Err(e) => Err(e),
76        },
77
78        Commands::Cost { query } => match commands::cost::run(&query) {
79            Ok(result) => {
80                println!("{}", output::OutputFormatter::new(cli.json, cli.quiet).format(&result));
81                Ok(())
82            },
83            Err(e) => Err(e),
84        },
85
86        Commands::Analyze { schema } => match commands::analyze::run(&schema) {
87            Ok(result) => {
88                println!("{}", output::OutputFormatter::new(cli.json, cli.quiet).format(&result));
89                Ok(())
90            },
91            Err(e) => Err(e),
92        },
93
94        Commands::DependencyGraph { schema, format } => {
95            match commands::dependency_graph::GraphFormat::from_str(&format) {
96                Ok(fmt) => match commands::dependency_graph::run(&schema, fmt) {
97                    Ok(result) => {
98                        println!(
99                            "{}",
100                            output::OutputFormatter::new(cli.json, cli.quiet).format(&result)
101                        );
102                        Ok(())
103                    },
104                    Err(e) => Err(e),
105                },
106                Err(e) => Err(anyhow::anyhow!(e)),
107            }
108        },
109
110        Commands::Lint {
111            schema,
112            federation,
113            cost,
114            cache,
115            auth,
116            compilation,
117            fail_on_critical,
118            fail_on_warning,
119        } => {
120            let opts = commands::lint::LintOptions {
121                fail_on_critical,
122                fail_on_warning,
123                filter: commands::lint::LintCategoryFilter {
124                    federation,
125                    cost,
126                    cache,
127                    auth,
128                    compilation,
129                },
130            };
131            match commands::lint::run(&schema, opts) {
132                Ok(result) => {
133                    println!(
134                        "{}",
135                        output::OutputFormatter::new(cli.json, cli.quiet).format(&result)
136                    );
137                    // H21: `--fail-on-critical`/`--fail-on-warning` must affect the
138                    // exit code, not just the printed status.
139                    enforce_exit_code(&result);
140                    Ok(())
141                },
142                Err(e) => Err(e),
143            }
144        },
145
146        Commands::Federation { command } => match command {
147            FederationCommands::Graph { schema, format } => {
148                match commands::federation::graph::GraphFormat::from_str(&format) {
149                    Ok(fmt) => match commands::federation::graph::run(&schema, fmt) {
150                        Ok(result) => {
151                            println!(
152                                "{}",
153                                output::OutputFormatter::new(cli.json, cli.quiet).format(&result)
154                            );
155                            Ok(())
156                        },
157                        Err(e) => Err(e),
158                    },
159                    Err(e) => Err(anyhow::anyhow!(e)),
160                }
161            },
162            FederationCommands::Check {
163                schema,
164                against,
165                json,
166            } => {
167                match commands::federation::check::run(
168                    &schema,
169                    against.as_deref(),
170                    json || cli.json,
171                ) {
172                    Ok(result) => {
173                        // `check::run` already prints the JSON result when either the
174                        // subcommand `--json` or the global `--json` is set (it receives
175                        // `json || cli.json`). Only format/print here when neither is set,
176                        // otherwise the result is emitted twice on global `--json`.
177                        if !(json || cli.json) {
178                            println!(
179                                "{}",
180                                output::OutputFormatter::new(cli.json, cli.quiet).format(&result)
181                            );
182                        }
183                        // H22: a composition failure must fail the process, not
184                        // print the error and exit 0.
185                        enforce_exit_code(&result);
186                        Ok(())
187                    },
188                    Err(e) => Err(e),
189                }
190            },
191        },
192
193        Commands::GenerateViews {
194            schema,
195            entity,
196            view,
197            refresh_strategy,
198            output,
199            include_composition_views,
200            include_monitoring,
201            validate,
202            gen_verbose,
203        } => match commands::generate_views::RefreshStrategy::parse(&refresh_strategy) {
204            Ok(refresh_strat) => {
205                let config = commands::generate_views::GenerateViewsConfig {
206                    schema_path: schema,
207                    entity,
208                    view,
209                    refresh_strategy: refresh_strat,
210                    output,
211                    include_composition_views,
212                    include_monitoring,
213                    validate_only: validate,
214                    verbose: cli.verbose || gen_verbose,
215                };
216
217                let formatter = output::OutputFormatter::new(cli.json, cli.quiet);
218                commands::generate_views::run(config, &formatter).await
219            },
220            Err(e) => Err(anyhow::anyhow!(e)),
221        },
222
223        Commands::GenerateCaptureTriggers {
224            schema,
225            output,
226            no_function,
227        } => {
228            let config = commands::generate_capture_triggers::GenerateCaptureTriggersConfig {
229                schema_path: schema,
230                output,
231                include_function: !no_function,
232            };
233            let formatter = output::OutputFormatter::new(cli.json, cli.quiet);
234            commands::generate_capture_triggers::run(config, &formatter)
235        },
236
237        Commands::Validate {
238            command,
239            input,
240            check_cycles,
241            check_unused,
242            strict,
243            types,
244            against_db,
245        } => match command {
246            Some(ValidateCommands::Facts { schema, database }) => {
247                let formatter = output::OutputFormatter::new(cli.json, cli.quiet);
248                commands::validate_facts::run(std::path::Path::new(&schema), &database, &formatter)
249                    .await
250            },
251            None => match (against_db, input) {
252                (Some(db_url), Some(input)) => {
253                    commands::validate::run_against_db(&input, &db_url, cli.json).await
254                },
255                (Some(_), None) => Err(anyhow::anyhow!(
256                    "INPUT (schema.compiled.json) is required with --against-db"
257                )),
258                (None, Some(input)) => {
259                    let opts = commands::validate::ValidateOptions {
260                        check_cycles,
261                        check_unused,
262                        strict,
263                        filter_types: types,
264                    };
265                    match commands::validate::run_with_options(&input, opts) {
266                        Ok(result) => {
267                            println!(
268                                "{}",
269                                output::OutputFormatter::new(cli.json, cli.quiet).format(&result)
270                            );
271                            // Exit 2 for "the schema is invalid", 1 for "the tool broke" —
272                            // the contract `--help-json` publishes, and what `lint` and
273                            // `federation check` already do. This used to wrap the status in
274                            // an `anyhow!("Validation failed")`, which fell through to the
275                            // generic handler and `process::exit(1)`, so CI could not tell a
276                            // cyclic schema from a broken toolchain. It also discarded the
277                            // per-error list that the JSON on stdout already carried
278                            // (#868 item 6).
279                            enforce_exit_code(&result);
280                            Ok(())
281                        },
282                        Err(e) => Err(e),
283                    }
284                },
285                (None, None) => Err(anyhow::anyhow!("INPUT required when no subcommand provided")),
286            },
287        },
288
289        Commands::Introspect { command } => match command {
290            IntrospectCommands::Facts { database, format } => {
291                match commands::introspect_facts::OutputFormat::parse(&format) {
292                    Ok(fmt) => {
293                        let formatter = output::OutputFormatter::new(cli.json, cli.quiet);
294                        commands::introspect_facts::run(&database, fmt, &formatter).await
295                    },
296                    Err(e) => Err(anyhow::anyhow!(e)),
297                }
298            },
299        },
300
301        Commands::Generate {
302            input,
303            language,
304            output,
305        } => match commands::init::Language::from_str(&language) {
306            Ok(lang) => commands::generate::run(&input, lang, output.as_deref()),
307            Err(e) => Err(anyhow::anyhow!(e)),
308        },
309
310        Commands::GenerateClient { language } => match language {
311            GenerateClientCommands::Typescript { schema, out, force } => {
312                commands::generate_client::run(
313                    commands::generate_client::ClientLanguage::TypeScript,
314                    schema.as_deref(),
315                    &out,
316                    force,
317                )
318            },
319            GenerateClientCommands::Python { schema, out, force } => {
320                commands::generate_client::run(
321                    commands::generate_client::ClientLanguage::Python,
322                    schema.as_deref(),
323                    &out,
324                    force,
325                )
326            },
327            GenerateClientCommands::Go { schema, out, force } => commands::generate_client::run(
328                commands::generate_client::ClientLanguage::Go,
329                schema.as_deref(),
330                &out,
331                force,
332            ),
333            GenerateClientCommands::Rust { schema, out, force } => commands::generate_client::run(
334                commands::generate_client::ClientLanguage::Rust,
335                schema.as_deref(),
336                &out,
337                force,
338            ),
339        },
340
341        Commands::Init {
342            project_name,
343            language,
344            database,
345            size,
346            no_git,
347        } => {
348            match (
349                commands::init::Language::from_str(&language),
350                commands::init::Database::from_str(&database),
351                commands::init::ProjectSize::from_str(&size),
352            ) {
353                (Ok(lang), Ok(db), Ok(sz)) => {
354                    let config = commands::init::InitConfig {
355                        project_name,
356                        language: lang,
357                        database: db,
358                        size: sz,
359                        no_git,
360                    };
361                    commands::init::run(&config)
362                },
363                (Err(e), _, _) | (_, Err(e), _) | (_, _, Err(e)) => Err(anyhow::anyhow!(e)),
364            }
365        },
366
367        Commands::Migrate { command } => {
368            let formatter = output::OutputFormatter::new(cli.json, cli.quiet);
369            match command {
370                MigrateCommands::Up { database, dir } => {
371                    let db_url = commands::migrate::resolve_database_url(database.as_deref());
372                    match db_url {
373                        Ok(url) => {
374                            let mig_dir = commands::migrate::resolve_migration_dir(dir.as_deref());
375                            let action = commands::migrate::MigrateAction::Up {
376                                database_url: url,
377                                dir:          mig_dir,
378                            };
379                            commands::migrate::run(&action, &formatter)
380                        },
381                        Err(e) => Err(e),
382                    }
383                },
384                MigrateCommands::Down {
385                    database,
386                    dir,
387                    steps,
388                } => {
389                    let db_url = commands::migrate::resolve_database_url(database.as_deref());
390                    match db_url {
391                        Ok(url) => {
392                            let mig_dir = commands::migrate::resolve_migration_dir(dir.as_deref());
393                            let action = commands::migrate::MigrateAction::Down {
394                                database_url: url,
395                                dir: mig_dir,
396                                steps,
397                            };
398                            commands::migrate::run(&action, &formatter)
399                        },
400                        Err(e) => Err(e),
401                    }
402                },
403                MigrateCommands::Status { database, dir } => {
404                    let db_url = commands::migrate::resolve_database_url(database.as_deref());
405                    match db_url {
406                        Ok(url) => {
407                            let mig_dir = commands::migrate::resolve_migration_dir(dir.as_deref());
408                            let action = commands::migrate::MigrateAction::Status {
409                                database_url: url,
410                                dir:          mig_dir,
411                            };
412                            commands::migrate::run(&action, &formatter)
413                        },
414                        Err(e) => Err(e),
415                    }
416                },
417                MigrateCommands::Create { name, dir } => {
418                    let mig_dir = commands::migrate::resolve_migration_dir(dir.as_deref());
419                    let action = commands::migrate::MigrateAction::Create { name, dir: mig_dir };
420                    commands::migrate::run(&action, &formatter)
421                },
422                MigrateCommands::Generate { name, dir } => {
423                    let mig_dir = commands::migrate::resolve_migration_dir(dir.as_deref());
424                    let action = commands::migrate::MigrateAction::Generate { name, dir: mig_dir };
425                    commands::migrate::run(&action, &formatter)
426                },
427                MigrateCommands::Validate { dir } => {
428                    let mig_dir = commands::migrate::resolve_migration_dir(dir.as_deref());
429                    let action = commands::migrate::MigrateAction::Validate { dir: mig_dir };
430                    commands::migrate::run(&action, &formatter)
431                },
432                MigrateCommands::Preflight { dir } => {
433                    let mig_dir = commands::migrate::resolve_migration_dir(dir.as_deref());
434                    let action = commands::migrate::MigrateAction::Preflight { dir: mig_dir };
435                    commands::migrate::run(&action, &formatter)
436                },
437            }
438        },
439
440        Commands::Sbom { format, output } => match commands::sbom::SbomFormat::from_str(&format) {
441            Ok(fmt) => commands::sbom::run(fmt, output.as_deref()),
442            Err(e) => Err(anyhow::anyhow!(e)),
443        },
444
445        #[cfg(feature = "run-server")]
446        Commands::Run {
447            input,
448            database,
449            port,
450            bind,
451            watch,
452            introspection,
453        } => {
454            // Box::pin: `run` dispatches across up to 4 adapter init futures,
455            // pushing the combined future past clippy's `large_futures` 16-KiB
456            // stack threshold. Heap-allocating once per CLI invocation is fine.
457            Box::pin(commands::run::run(
458                input.as_deref(),
459                database,
460                port,
461                bind,
462                watch,
463                introspection,
464            ))
465            .await
466        },
467
468        Commands::Watch {
469            input,
470            output,
471            reload_url,
472            admin_token,
473            database,
474        } => {
475            commands::watch::run(
476                &input,
477                &output,
478                reload_url.as_deref(),
479                admin_token.as_deref(),
480                database.as_deref(),
481            )
482            .await
483        },
484
485        Commands::ValidateDocuments {
486            manifest,
487            max_cost,
488            schema,
489        } => {
490            let formatter = output::OutputFormatter::new(cli.json, cli.quiet);
491            match commands::validate_documents::run(
492                &manifest,
493                max_cost,
494                schema.as_deref(),
495                &formatter,
496            ) {
497                Ok(true) => Ok(()),
498                Ok(false) => {
499                    process::exit(2);
500                },
501                Err(e) => Err(e),
502            }
503        },
504
505        Commands::Setup { database, dry_run } => {
506            let formatter = output::OutputFormatter::new(cli.json, cli.quiet);
507            commands::setup::run(database.as_deref(), dry_run, &formatter).await
508        },
509
510        Commands::Schema { command } => match command {
511            SchemaCommands::Metadata { server, token } => {
512                commands::schema::metadata::run(&server, token.as_deref()).await
513            },
514        },
515
516        Commands::Query {
517            query,
518            schema,
519            database,
520            variables,
521            dry_run,
522        } => {
523            // Box::pin: the in-process executor + adapter init future is large
524            // enough to trip clippy's `large_futures` stack threshold.
525            Box::pin(commands::query::run(&query, &schema, database, variables, dry_run)).await
526        },
527
528        Commands::Doctor {
529            config,
530            schema,
531            db_url,
532            against_db,
533            runtime,
534            schemas,
535            json: json_flag,
536        } => {
537            let all_passed = commands::doctor::run_with_db_checks(
538                &config,
539                &schema,
540                db_url.as_deref(),
541                against_db.as_deref(),
542                runtime,
543                &schemas,
544                json_flag,
545            )
546            .await;
547            if all_passed {
548                Ok(())
549            } else {
550                process::exit(1);
551            }
552        },
553
554        Commands::Perf { command } => match command {
555            PerfCommands::RegressionScan {
556                database,
557                recent_days,
558                baseline_days,
559                min_samples,
560                threshold_pct,
561                min_delta_ms,
562                object_type,
563                fail_on_regression,
564            } => {
565                let args = commands::perf::RegressionScanArgs {
566                    database,
567                    recent_days,
568                    baseline_days,
569                    min_samples,
570                    threshold_pct,
571                    min_delta_ms,
572                    object_type,
573                    fail_on_regression,
574                    json: cli.json,
575                    quiet: cli.quiet,
576                };
577                match commands::perf::run_regression_scan(args).await {
578                    Ok(true) => Ok(()),
579                    Ok(false) => process::exit(1),
580                    Err(e) => Err(e),
581                }
582            },
583            PerfCommands::Explore { command } => match command {
584                PerfExploreCommands::Slowest {
585                    database,
586                    days,
587                    object_type,
588                    limit,
589                } => {
590                    commands::perf::run_explore_slowest(
591                        database,
592                        days,
593                        object_type,
594                        limit,
595                        cli.json,
596                        cli.quiet,
597                    )
598                    .await
599                },
600                PerfExploreCommands::NullRate {
601                    database,
602                    days,
603                    object_type,
604                } => {
605                    commands::perf::run_explore_null_rate(
606                        database,
607                        days,
608                        object_type,
609                        cli.json,
610                        cli.quiet,
611                    )
612                    .await
613                },
614                PerfExploreCommands::Summary {
615                    database,
616                    days,
617                    object_type,
618                } => {
619                    commands::perf::run_explore_summary(
620                        database,
621                        days,
622                        object_type,
623                        cli.json,
624                        cli.quiet,
625                    )
626                    .await
627                },
628            },
629        },
630
631        Commands::Sources {
632            schema,
633            db_url,
634            json,
635        } => {
636            commands::sources::run(commands::sources::SourcesArgs {
637                schema,
638                database: db_url,
639                json,
640            })
641            .await
642        },
643
644        #[cfg(feature = "functions-invoke")]
645        Commands::Functions(command) => match command {
646            crate::cli::FunctionsCommands::Invoke {
647                name,
648                payload,
649                schema,
650                mock_http,
651                mock_query,
652                idempotency_token,
653                explain,
654            } => match commands::functions::invoke(
655                &name,
656                &payload,
657                &schema,
658                mock_http.as_deref(),
659                mock_query.as_deref(),
660                idempotency_token,
661                explain,
662                cli.json,
663            )
664            .await
665            {
666                // The harness printed its own output; exit with the outcome code.
667                Ok(code) => process::exit(code),
668                // A config/harness error goes through the normal error report (exit 1).
669                Err(error) => Err(error),
670            },
671        },
672    };
673
674    if let Err(e) = result {
675        let debug_info = if debug_output {
676            Some(format!("{e:?}"))
677        } else {
678            None
679        };
680        let causes = error_causes(&e);
681        eprintln!(
682            "{}",
683            format_cli_error(&e.to_string(), &causes, debug_info.as_deref(), json_output, 1)
684        );
685        process::exit(1);
686    }
687}
688
689/// Enforce the CLI exit-code contract for a command that returns a [`CommandResult`].
690///
691/// Commands that print a [`CommandResult`] must also reflect its status in the
692/// process exit code. The contract (mirroring [`crate::output::get_exit_codes`]):
693///
694/// - `"success"` → return normally (the process exits 0)
695/// - `"validation-failed"` → exit **2** (the command ran, but the checked artifact failed its gate
696///   — e.g. a lint quality gate or a federation composition error)
697/// - any other status (e.g. `"error"`) → exit **1** (operational failure)
698///
699/// Operational errors surfaced as `Err(_)` are handled separately at the end of
700/// [`run`] and also exit 1.
701fn enforce_exit_code(result: &crate::output::CommandResult) {
702    match result.status.as_str() {
703        "success" => {},
704        "validation-failed" => process::exit(2),
705        _ => process::exit(1),
706    }
707}
708
709/// Extract an `anyhow` error's source chain — every cause **below** the top-level
710/// message — as display strings, outermost first.
711///
712/// The top-level context (rendered as the error `message`) is skipped, so a
713/// `.context("outer")` over a `bail!("root")` yields `["root"]`. Surfacing this
714/// chain is what turns an opaque `Failed to convert schema to compiled format`
715/// into an actionable `… caused by: Type 'Order' … is missing field 'id'`.
716pub(crate) fn error_causes(err: &anyhow::Error) -> Vec<String> {
717    err.chain().skip(1).map(ToString::to_string).collect()
718}
719
720/// Format a CLI error as either plain text or a JSON object for machine-readable output.
721///
722/// `causes` is the error's source chain below `message` (see [`error_causes`]); it is
723/// surfaced in **both** output modes so a failure is never reduced to its top-level
724/// context alone.
725///
726/// When `json` is `true`, produces a JSON object:
727/// ```json
728/// { "error": { "message": "...", "causes": ["..."], "code": 1 } }
729/// ```
730/// When `json` is `false`, produces a human-readable string:
731/// ```text
732/// Error: <message>
733///   caused by: <cause 1>
734///   caused by: <cause 2>
735/// ```
736/// If `debug_info` is provided and `json` is `false`, a debug section is appended.
737pub(crate) fn format_cli_error(
738    message: &str,
739    causes: &[String],
740    debug_info: Option<&str>,
741    json: bool,
742    code: i32,
743) -> String {
744    if json {
745        serde_json::json!({
746            "error": {
747                "message": message,
748                "causes": causes,
749                "code": code
750            }
751        })
752        .to_string()
753    } else {
754        let mut out = format!("Error: {message}");
755        for cause in causes {
756            out.push_str(&format!("\n  caused by: {cause}"));
757        }
758        if let Some(debug) = debug_info {
759            out.push_str("\n\nDebug info:\n");
760            out.push_str(debug);
761        }
762        out
763    }
764}
765
766fn init_logging(verbose: bool, debug: bool) {
767    let filter = if debug {
768        "fraiseql=debug,fraiseql_core=debug"
769    } else if verbose {
770        "fraiseql=info,fraiseql_core=info"
771    } else {
772        "fraiseql=warn,fraiseql_core=warn"
773    };
774
775    tracing_subscriber::registry()
776        .with(
777            tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| filter.into()),
778        )
779        // Diagnostics go to stderr, never stdout — so a command whose stdout is
780        // the payload (e.g. `generate-capture-triggers … | psql`) is never
781        // corrupted by a stray WARN/INFO line.
782        .with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr))
783        .init();
784}
785
786/// Serialize a value to a JSON `Value`, printing to stderr and exiting with code 2 on failure.
787fn serialize_or_exit<T: serde::Serialize>(value: &T, context: &str) -> serde_json::Value {
788    serde_json::to_value(value).unwrap_or_else(|e| {
789        eprintln!("fraiseql: failed to serialize {context}: {e}");
790        std::process::exit(2);
791    })
792}
793
794/// Serialize a value to pretty-printed JSON, printing to stderr and exiting with code 2 on failure.
795fn pretty_or_exit<T: serde::Serialize>(value: &T, context: &str) -> String {
796    serde_json::to_string_pretty(value).unwrap_or_else(|e| {
797        eprintln!("fraiseql: failed to format {context}: {e}");
798        std::process::exit(2);
799    })
800}
801
802fn handle_introspection_flags() -> Option<i32> {
803    let args: Vec<String> = env::args().collect();
804
805    if args.iter().any(|a| a == "--help-json") {
806        let cmd = Cli::command();
807        let version = env!("CARGO_PKG_VERSION");
808        let help = crate::introspection::extract_cli_help(&cmd, version);
809        let result =
810            crate::output::CommandResult::success("help", serialize_or_exit(&help, "help output"));
811        println!("{}", pretty_or_exit(&result, "command result"));
812        return Some(0);
813    }
814
815    if args.iter().any(|a| a == "--list-commands") {
816        let cmd = Cli::command();
817        let commands = crate::introspection::list_commands(&cmd);
818        let result = crate::output::CommandResult::success(
819            "list-commands",
820            serialize_or_exit(&commands, "command list"),
821        );
822        println!("{}", pretty_or_exit(&result, "command result"));
823        return Some(0);
824    }
825
826    let idx = args.iter().position(|a| a == "--show-output-schema")?;
827    let available = crate::output_schemas::list_schema_commands().join(", ");
828
829    let Some(cmd_name) = args.get(idx + 1) else {
830        let result = crate::output::CommandResult::error(
831            "show-output-schema",
832            &format!("Missing command name. Available: {available}"),
833            "MISSING_ARGUMENT",
834        );
835        println!("{}", pretty_or_exit(&result, "command result"));
836        return Some(1);
837    };
838
839    if let Some(schema) = crate::output_schemas::get_output_schema(cmd_name) {
840        let result = crate::output::CommandResult::success(
841            "show-output-schema",
842            serialize_or_exit(&schema, "output schema"),
843        );
844        println!("{}", pretty_or_exit(&result, "command result"));
845        return Some(0);
846    }
847
848    let result = crate::output::CommandResult::error(
849        "show-output-schema",
850        &format!("Unknown command: {cmd_name}. Available: {available}"),
851        "UNKNOWN_COMMAND",
852    );
853    println!("{}", pretty_or_exit(&result, "command result"));
854    Some(1)
855}