use clap::{Parser, Subcommand}; use miette::IntoDiagnostic; use mlf_cli::credentials::Scope; use mlf_cli::logout::Target as LogoutTarget; use mlf_cli::{check, diff, fetch, generate, init, login, logout, publish, status, unpublish}; use std::path::PathBuf; use std::process; // Import optional code generator plugins // These are automatically registered via inventory when the feature is enabled #[cfg(feature = "typescript")] use mlf_codegen_typescript as _; #[cfg(feature = "go")] use mlf_codegen_go as _; #[cfg(feature = "rust")] use mlf_codegen_rust as _; #[derive(Parser)] #[command(name = "mlf")] #[command(about = "MLF (Matt's Lexicon Format) CLI tool", long_about = None)] struct Cli { #[command(subcommand)] command: Commands, } #[derive(Subcommand)] enum Commands { Init { #[arg(long, help = "Skip prompts and use defaults")] yes: bool, }, Check { #[arg( help = "MLF lexicon file(s) or directory to validate. If omitted, checks source directory from mlf.toml" )] input: Vec, #[arg( long, help = "Root directory for namespace calculation (defaults to mlf.toml source directory or current directory)" )] root: Option, }, Validate { #[arg(help = "MLF lexicon file")] lexicon: PathBuf, #[arg(help = "JSON record file to validate against the lexicon")] record: PathBuf, }, Generate { #[command(subcommand)] command: Option, }, Fetch { #[arg( help = "Namespace to fetch (e.g., stream.place). If omitted, fetches all dependencies from mlf.toml" )] nsid: Option, #[arg(long, help = "Add namespace to dependencies in mlf.toml")] save: bool, #[arg( long, help = "Update dependencies to latest versions (ignores lockfile)" )] update: bool, #[arg(long, help = "Require lockfile and fail if dependencies need updating")] locked: bool, }, #[command(about = "Show the publish status of each lexicon in the package")] Status, #[command(about = "Diff local lexicons against what's currently published")] Diff { #[arg(help = "NSID to diff. If omitted, diffs every changed, new, or removed NSID.")] nsid: Option, }, #[command(about = "Log in to the PDS and/or a DNS plugin")] Login { #[command(subcommand)] target: Option, #[arg( long, help = "Write to the project-local credentials file instead of global" )] project: bool, #[arg(long, help = "Fail if any field needs an interactive prompt")] non_interactive: bool, }, #[command(about = "Publish every lexicon in the workspace to the configured PDS + DNS")] Publish { #[arg(long, help = "Compute the plan but don't perform any network writes")] dry_run: bool, #[arg(long, help = "Override breaking-change aborts and DNS-mismatch aborts")] force: bool, #[arg( long, help = "Fail instead of prompting for any missing credentials or confirmations" )] non_interactive: bool, #[arg( long, help = "In a workspace, publish only the named member (matched by [package].name)" )] package: Option, /// Ephemeral credential overrides (not stored). Syntax mirrors /// login: `--pds-=value` or `--dns--=value`. #[arg( allow_hyphen_values = true, trailing_var_arg = true, num_args = 0.., help = "Optional credential overrides forwarded into the session (e.g. --pds-app-password=TKN)" )] args: Vec, }, #[command(about = "Delete every lexicon the workspace has published, per the manifest")] Unpublish { #[arg(long, help = "Skip the interactive confirmation prompt")] yes: bool, }, #[command(about = "Clear stored credentials")] Logout { #[command(subcommand)] target: Option, #[arg( long, help = "Clear from the project-local credentials file instead of global" )] project: bool, }, } #[derive(Subcommand)] enum LoginTargetCmd { #[command(about = "Log in as the publishing account (handle + app password)")] Pds { #[arg(long, help = "Handle (e.g. matt.example.com)")] handle: Option, #[arg(long, help = "App password")] app_password: Option, }, #[command(about = "Log in to a DNS plugin")] Dns { #[arg(help = "Plugin name, e.g. cloudflare")] plugin: String, /// Trailing `--field value` pairs are parsed against the plugin's /// options schema after the handshake. We use `allow_hyphen_values` /// + `trailing_var_arg` so clap doesn't reject unknown flags. #[arg( allow_hyphen_values = true, trailing_var_arg = true, num_args = 0.., help = "Plugin-specific --= or -- arguments" )] args: Vec, }, } #[derive(Subcommand)] enum LogoutTargetCmd { Pds, Dns { #[arg(help = "Plugin name. If omitted, every [dns.*] entry is cleared.")] plugin: Option, }, } #[derive(Subcommand)] enum GenerateCommands { Lexicon { #[arg( short, long, help = "Input MLF file(s) or directory. If omitted, uses source directory from mlf.toml" )] input: Vec, #[arg( short, long, help = "Output directory. If omitted, uses first lexicon output from mlf.toml" )] output: Option, #[arg( long, help = "Root directory for namespace calculation (defaults to mlf.toml source directory or current directory)" )] root: Option, #[arg(long, help = "Use flat file structure (e.g., app.bsky.post.json)")] flat: bool, }, Code { #[arg( short, long, help = "Generator to use (typescript, go, rust, etc.). If omitted, uses first code output from mlf.toml" )] generator: Option, #[arg( short, long, help = "Input MLF file(s) or directory. If omitted, uses source directory from mlf.toml" )] input: Vec, #[arg( short, long, help = "Output directory. If omitted, uses matching output from mlf.toml" )] output: Option, #[arg( long, help = "Root directory for namespace calculation (defaults to mlf.toml source directory or current directory)" )] root: Option, #[arg(long, help = "Use flat file structure (e.g., app.bsky.post.ts)")] flat: bool, }, Mlf { #[arg( short, long, help = "Input JSON lexicon files (glob patterns supported)" )] input: Vec, #[arg( short, long, help = "Output directory. If omitted, uses first mlf output from mlf.toml" )] output: Option, #[arg(long, help = "Use flat file structure (e.g., app.bsky.post.json)")] flat: bool, }, } #[tokio::main] async fn main() { let cli = Cli::parse(); let result: Result<(), miette::Report> = match cli.command { Commands::Init { yes } => init::run_init(yes).into_diagnostic(), Commands::Check { input, root } => check::run_check(input, root).into_diagnostic(), Commands::Validate { lexicon, record } => { check::validate(lexicon, record).into_diagnostic() } Commands::Generate { command } => match command { Some(GenerateCommands::Lexicon { input, output, root, flat, }) => generate::lexicon::run(input, output, root, flat).into_diagnostic(), Some(GenerateCommands::Code { generator, input, output, root, flat, }) => generate::code::run(generator, input, output, root, flat).into_diagnostic(), Some(GenerateCommands::Mlf { input, output, flat, }) => generate::mlf::run(input, output, flat).into_diagnostic(), None => { // Run all outputs from mlf.toml generate::run_all().into_diagnostic() } }, Commands::Fetch { nsid, save, update, locked, } => fetch::run_fetch(nsid, save, update, locked) .await .into_diagnostic(), Commands::Status => status::run_status().await.into_diagnostic(), Commands::Diff { nsid } => diff::run_diff(nsid).await.into_diagnostic(), Commands::Login { target, project, non_interactive, } => { let scope = if project { Scope::Project } else { Scope::Global }; let mut args = login::LoginArgs { scope, non_interactive, ..Default::default() }; match target { None => login::run_login_all(args).await.into_diagnostic(), Some(LoginTargetCmd::Pds { handle, app_password, }) => { if let Some(h) = handle { args.field_overrides.insert("handle".into(), h); } if let Some(p) = app_password { args.field_overrides.insert("app_password".into(), p); } login::run_login_pds(args).await.into_diagnostic() } Some(LoginTargetCmd::Dns { plugin, args: raw_args, }) => { for (k, v) in parse_field_args(&raw_args) { args.field_overrides.insert(k, v); } login::run_login_dns(&plugin, args).await.into_diagnostic() } } } Commands::Publish { dry_run, force, non_interactive, package, args, } => { let overrides = parse_publish_overrides(&args); publish::run_publish(publish::PublishOpts { dry_run, force, non_interactive, package, overrides, }) .await .into_diagnostic() } Commands::Unpublish { yes } => unpublish::run_unpublish(unpublish::UnpublishOpts { yes }) .await .into_diagnostic(), Commands::Logout { target, project } => { let scope = if project { Scope::Project } else { Scope::Global }; let (tgt, name) = match target { None => (LogoutTarget::All, None), Some(LogoutTargetCmd::Pds) => (LogoutTarget::Pds, None), Some(LogoutTargetCmd::Dns { plugin: None }) => (LogoutTarget::AllDns, None), Some(LogoutTargetCmd::Dns { plugin: Some(p) }) => (LogoutTarget::Dns, Some(p)), }; logout::run_logout(tgt, name.as_deref(), scope).into_diagnostic() } }; if let Err(e) = result { eprintln!("{:?}", e); process::exit(1); } } /// Parse plugin-forwarded `--field value` / `--field=value` pairs into /// a flat `(field_name, value)` list. The plugin-declared options schema /// determines which names are valid; we just collect whatever was /// passed so login.rs can match against the schema. /// Parse ephemeral credential overrides from `mlf publish`'s trailing /// args. Recognises `--pds-=value` and `--dns--=value` /// (and the `--flag value` form). Unknown tokens are silently skipped. fn parse_publish_overrides(raw: &[String]) -> std::collections::BTreeMap { let mut out = std::collections::BTreeMap::new(); let mut i = 0; while i < raw.len() { let tok = &raw[i]; if let Some(rest) = tok.strip_prefix("--") { let (key, value) = if let Some(eq) = rest.find('=') { (rest[..eq].to_string(), rest[eq + 1..].to_string()) } else if i + 1 < raw.len() { let v = raw[i + 1].clone(); i += 1; (rest.to_string(), v) } else { i += 1; continue; }; out.insert(key.replace('-', "_"), value); } i += 1; } out } fn parse_field_args(raw: &[String]) -> Vec<(String, String)> { let mut out = Vec::new(); let mut i = 0; while i < raw.len() { let tok = &raw[i]; if let Some(rest) = tok.strip_prefix("--") { if let Some(eq) = rest.find('=') { let (k, v) = rest.split_at(eq); out.push((k.replace('-', "_"), v[1..].to_string())); } else if i + 1 < raw.len() { let k = rest.replace('-', "_"); out.push((k, raw[i + 1].clone())); i += 1; } } i += 1; } out }