Skip to main content

dsc/
cli.rs

1use clap::{ArgAction, Parser, Subcommand, ValueEnum};
2use clap_complete::Shell;
3use std::path::PathBuf;
4
5#[derive(Parser)]
6#[command(name = "dsc")]
7#[command(about = "Discourse CLI", long_about = None)]
8pub struct Cli {
9    /// Path to the config file. If omitted, `dsc` consults `$DSC_CONFIG`,
10    /// then searches `./dsc.toml`, `$DSC_CONFIG_HOME/dsc.toml`
11    /// (default `~/.config/dsc/dsc.toml`), then system locations.
12    /// Errors if the given file does not exist (no silent fallthrough).
13    /// See `dsc config` for the active selection.
14    #[arg(long, short = 'c')]
15    pub config: Option<PathBuf>,
16    /// Describe destructive actions without sending them. Read-only commands
17    /// ignore the flag.
18    #[arg(long, short = 'n', global = true)]
19    pub dry_run: bool,
20    #[command(subcommand)]
21    pub command: Commands,
22}
23
24#[derive(Subcommand)]
25pub enum Commands {
26    /// List configured Discourses.
27    #[command(visible_alias = "ls")]
28    List {
29        /// Output format for the listing.
30        #[arg(long, short = 'f', value_enum, default_value = "text")]
31        format: OutputFormat,
32        /// Filter by tags (comma/semicolon separated, match-any).
33        #[arg(long, value_name = "tag1,tag2")]
34        tags: Option<String>,
35        /// Open each listed Discourse base URL in a browser tab/window.
36        #[arg(long, short = 'o')]
37        open: bool,
38        /// Include empty results and verbose listing details where supported.
39        #[arg(long, short = 'v')]
40        verbose: bool,
41        #[command(subcommand)]
42        command: Option<ListCommand>,
43    },
44    /// Add one or more Discourses to the config.
45    #[command(visible_alias = "a")]
46    Add {
47        /// Comma-separated discourse names to add.
48        names: String,
49        /// Prompt for additional optional fields while adding.
50        #[arg(long, short = 'i')]
51        interactive: bool,
52    },
53    /// Import Discourses from a file or stdin.
54    #[command(visible_alias = "imp")]
55    Import {
56        /// Path to import input (text/CSV). Reads stdin when omitted.
57        path: Option<PathBuf>,
58    },
59    /// Run remote OS + Discourse update workflow for one or all Discourses.
60    #[command(visible_alias = "up")]
61    Update {
62        /// Discourse name, or 'all' to update every configured Discourse.
63        name: String,
64        /// Parallel update mode for `dsc update all`.
65        #[arg(long, short = 'p')]
66        parallel: bool,
67        /// Maximum workers when parallel mode is enabled (default: 3).
68        #[arg(long, short = 'm')]
69        max: Option<usize>,
70        /// Disable changelog posting (posting prompt is on by default).
71        #[arg(long = "no-changelog", action = ArgAction::SetFalse, default_value_t = true)]
72        post_changelog: bool,
73        /// Auto-confirm changelog posting prompt (non-interactive mode).
74        #[arg(long, short = 'y')]
75        yes: bool,
76    },
77    /// Manage custom emoji.
78    #[command(visible_alias = "em")]
79    Emoji {
80        #[command(subcommand)]
81        command: EmojiCommand,
82    },
83    /// Pull/push/sync topics as local Markdown.
84    #[command(visible_alias = "t")]
85    Topic {
86        #[command(subcommand)]
87        command: TopicCommand,
88    },
89    /// List/copy/pull/push categories.
90    #[command(visible_alias = "cat")]
91    Category {
92        #[command(subcommand)]
93        command: CategoryCommand,
94    },
95    /// List/inspect/copy groups.
96    #[command(visible_alias = "grp")]
97    Group {
98        #[command(subcommand)]
99        command: GroupCommand,
100    },
101    /// Operations that act from a user's perspective.
102    #[command(visible_alias = "usr")]
103    User {
104        #[command(subcommand)]
105        command: UserCommand,
106    },
107    /// Send invites — single or bulk from a file.
108    #[command(visible_alias = "inv")]
109    Invite {
110        #[command(subcommand)]
111        command: InviteCommand,
112    },
113    /// Manage API keys (admin scope).
114    #[command(visible_alias = "ak")]
115    ApiKey {
116        #[command(subcommand)]
117        command: ApiKeyCommand,
118    },
119    /// Send and list private messages.
120    #[command(visible_alias = "msg")]
121    Pm {
122        #[command(subcommand)]
123        command: PmCommand,
124    },
125    /// Create/list/restore backups.
126    #[command(visible_alias = "bk")]
127    Backup {
128        #[command(subcommand)]
129        command: BackupCommand,
130    },
131    /// List/pull/push color palettes.
132    #[command(visible_alias = "pal")]
133    Palette {
134        #[command(subcommand)]
135        command: PaletteCommand,
136    },
137    /// List/install/remove plugins.
138    #[command(visible_alias = "plg")]
139    Plugin {
140        #[command(subcommand)]
141        command: PluginCommand,
142    },
143    /// List/install/remove/pull/push/duplicate themes.
144    #[command(visible_alias = "th")]
145    Theme {
146        #[command(subcommand)]
147        command: ThemeCommand,
148    },
149    /// Update site settings.
150    #[command(visible_alias = "set")]
151    Setting {
152        #[command(subcommand)]
153        command: SettingCommand,
154    },
155    /// Export everything a forum holds about one person into a reviewable
156    /// Subject Access Request (SAR / GDPR Art. 15) bundle. Single forum.
157    Sar {
158        /// Discourse name.
159        discourse: String,
160        /// Subject: a username or an email address.
161        user: String,
162        /// Output directory (default `sar-<username>-<date>/`).
163        #[arg(long, short = 'o')]
164        output: Option<PathBuf>,
165        /// Also collect the subject's private messages. Off by default: PMs
166        /// contain third-party personal data and need a disclose/redact
167        /// judgement. Written with a REVIEW REQUIRED banner when included.
168        #[arg(long)]
169        messages: bool,
170    },
171    /// Manage the tag taxonomy: list/pull/push tags and tag groups.
172    #[command(visible_alias = "tg")]
173    Tag {
174        #[command(subcommand)]
175        command: TagCommand,
176    },
177    /// Post-level operations: edit / delete / move.
178    #[command(visible_alias = "po")]
179    Post {
180        #[command(subcommand)]
181        command: PostCommand,
182    },
183    /// Open a Discourse in the default browser.
184    #[command(visible_alias = "o")]
185    Open {
186        /// Discourse name.
187        discourse: String,
188    },
189    /// Harden a fresh Ubuntu server reachable via `ssh root@host`.
190    ///
191    /// **Stage 1 (current):** creates a non-root sudo user, installs the
192    /// given pubkey to their authorized_keys, and verifies the new-user
193    /// SSH login works. Does NOT yet tighten sshd_config, install Docker
194    /// / fail2ban / etc — those come in follow-up releases.
195    ///
196    /// Defaults can be overridden in the `[harden]` block of dsc.toml;
197    /// the flags below override that block on a per-run basis.
198    #[command(visible_alias = "hd")]
199    Harden {
200        /// Target hostname or IP (reachable via SSH).
201        host: String,
202        /// Username to SSH in as initially. Defaults to `root`, which is
203        /// what a fresh cloud-provisioned box typically has.
204        #[arg(long, default_value = "root")]
205        ssh_user: String,
206        /// Username for the new sudo-enabled non-root account. Overrides
207        /// `[harden].new_user` from dsc.toml. Built-in default: `discourse`.
208        #[arg(long)]
209        new_user: Option<String>,
210        /// SSH port to move the daemon to in stage 2. Overrides
211        /// `[harden].ssh_port`. Built-in default: 2227. Parsed now so the
212        /// CLI is stable; not yet applied in stage 1.
213        #[arg(long)]
214        ssh_port: Option<u16>,
215        /// Path to an SSH public key file whose contents will be added to
216        /// the new user's authorized_keys. A typical value is
217        /// `~/.ssh/<hostname>.pub` — the per-server keypair pattern in
218        /// the Bawmedical hardening playbook.
219        #[arg(long)]
220        pubkey_file: PathBuf,
221    },
222    /// Community-health analytics — growth, activity, and health metrics
223    /// for a Discourse, with optional period-over-period comparison.
224    ///
225    /// See `spec/analytics.md` for the full spec. v1 ships every metric
226    /// that maps onto a single `/admin/reports/{id}.json` endpoint;
227    /// derivation-heavy ones (e.g. lost regulars, top-10 share) print
228    /// `— (n/i)` until follow-up implementation lands.
229    #[command(visible_alias = "stats")]
230    Analytics {
231        /// Discourse name.
232        discourse: String,
233        /// Window to report on. Same syntax as `dsc user activity --since`
234        /// (e.g. `7d`, `24h`, `1m`, ISO-8601). Ignored when `--snapshot`
235        /// is set. Default: 30d.
236        #[arg(long, short = 's', default_value = "30d")]
237        since: String,
238        /// Also fetch the immediately preceding window of equal length and
239        /// show a delta column. Mutually exclusive with `--snapshot`.
240        #[arg(long, short = 'c', conflicts_with = "snapshot")]
241        compare: bool,
242        /// Multi-window snapshot mode. Reports each metric across several
243        /// preset windows (`--periods`) so you see growth/health trends
244        /// at a glance. Replaces `--since` + `--compare`.
245        #[arg(long)]
246        snapshot: bool,
247        /// Comma-separated periods for `--snapshot`. Default: `24h,7d,30d,1y`.
248        #[arg(long, requires = "snapshot")]
249        periods: Option<String>,
250        /// Restrict output to one section.
251        #[arg(long, value_enum, default_value = "all")]
252        section: SectionArg,
253        /// Output format. `table` is DuckDB-style box-drawing; falls
254        /// through to `text` automatically when stdout isn't a TTY.
255        #[arg(long, short = 'f', value_enum, default_value = "text")]
256        format: AnalyticsFormat,
257    },
258    /// Search topics on a Discourse.
259    #[command(visible_alias = "s")]
260    Search {
261        /// Discourse name.
262        discourse: String,
263        /// Search query (passed through verbatim, including any
264        /// Discourse filter syntax like `category:foo` or `@user`).
265        query: String,
266        /// Output format.
267        #[arg(long, short = 'f', value_enum, default_value = "text")]
268        format: ListFormat,
269    },
270    /// Upload a file. Prints the resulting upload:// short URL by default.
271    #[command(visible_alias = "u")]
272    Upload {
273        /// Discourse name.
274        discourse: String,
275        /// Path to the file to upload.
276        file: PathBuf,
277        /// Discourse upload context. Default `composer` is correct for
278        /// embedding in posts; other values include `avatar`,
279        /// `profile_background`, `card_background`, `custom_emoji`.
280        #[arg(long, short = 't', default_value = "composer")]
281        upload_type: String,
282        /// Output format. Text mode prints just the short URL.
283        #[arg(long, short = 'f', value_enum, default_value = "text")]
284        format: ListFormat,
285    },
286    /// Inspect and validate configuration.
287    #[command(visible_alias = "cfg")]
288    Config {
289        #[command(subcommand)]
290        command: Option<ConfigCommand>,
291    },
292    /// Generate shell completion scripts.
293    #[command(visible_alias = "comp")]
294    Completions {
295        /// Target shell.
296        #[arg(value_enum)]
297        shell: CompletionShell,
298        /// Output directory. Prints to stdout when omitted.
299        #[arg(long, short = 'd')]
300        dir: Option<PathBuf>,
301    },
302    /// Generate man pages for `dsc` and every subcommand.
303    ///
304    /// Writes one ROFF-formatted file per (sub)command (e.g. `dsc.1`,
305    /// `dsc-tag-pull.1`) into the given directory. Distro packagers
306    /// install these into section 1 of the man path. Run `gzip -9` on
307    /// the output if your packaging convention expects compressed pages.
308    #[command(visible_alias = "manpages")]
309    Man {
310        /// Output directory. Required - this command always writes to disk.
311        #[arg(long, short = 'd')]
312        dir: PathBuf,
313    },
314    /// Print the dsc version.
315    #[command(visible_alias = "ver")]
316    Version,
317}
318
319#[derive(Subcommand)]
320pub enum ConfigCommand {
321    /// Probe each configured Discourse: API auth and (optionally) SSH reachability.
322    #[command(visible_alias = "ck")]
323    Check {
324        /// Output format.
325        #[arg(long, short = 'f', value_enum, default_value = "text")]
326        format: ListFormat,
327        /// Skip the SSH reachability probe.
328        #[arg(long)]
329        skip_ssh: bool,
330    },
331}
332
333#[derive(Subcommand)]
334pub enum ListCommand {
335    /// Sort discourse entries by name and rewrite config in-place.
336    /// Also inserts placeholder values for unset template keys.
337    #[command(visible_alias = "ty")]
338    Tidy,
339}
340
341#[derive(Subcommand)]
342pub enum EmojiCommand {
343    /// Pull all custom emoji from a Discourse into a local directory.
344    #[command(visible_alias = "pl")]
345    Pull {
346        /// Discourse name.
347        discourse: String,
348        /// Local directory to save emoji images into.
349        output_dir: PathBuf,
350    },
351    /// Push (upload) one emoji file, or bulk-upload from a directory (alias: add).
352    #[command(visible_alias = "ps", alias = "add")]
353    Push {
354        /// Discourse name.
355        discourse: String,
356        /// Local file or directory path.
357        emoji_path: PathBuf,
358        /// Optional emoji name (file uploads only).
359        emoji_name: Option<String>,
360    },
361
362    /// List custom emojis on a Discourse.
363    #[command(visible_alias = "ls")]
364    List {
365        /// Discourse name.
366        discourse: String,
367        /// Output format.
368        #[arg(long, short = 'f', value_enum, default_value = "text")]
369        format: ListFormat,
370        /// Include additional fields where supported.
371        #[arg(long, short = 'v')]
372        verbose: bool,
373        /// Render inline images when terminal protocol support is available.
374        #[arg(long, short = 'i')]
375        inline: bool,
376    },
377}
378
379#[derive(Subcommand)]
380pub enum TopicCommand {
381    /// Pull a topic to a local Markdown file.
382    #[command(visible_alias = "pl")]
383    Pull {
384        /// Discourse name.
385        discourse: String,
386        /// Topic ID.
387        topic_id: u64,
388        /// Destination file or directory (auto-derived when omitted).
389        local_path: Option<PathBuf>,
390        /// Pull the entire thread (every post) as a single Markdown file
391        /// with YAML frontmatter and per-post headings. Default behaviour
392        /// (no `--full`) writes only the OP, which is what `topic push`
393        /// expects.
394        #[arg(long, short = 'F')]
395        full: bool,
396    },
397    /// Push a local Markdown file to a topic.
398    #[command(visible_alias = "ps")]
399    Push {
400        /// Discourse name.
401        discourse: String,
402        /// Topic ID.
403        topic_id: u64,
404        /// Local Markdown file path.
405        local_path: PathBuf,
406        /// Update the post without bumping the topic in the activity feed.
407        /// Use for silent maintenance edits (sends post[no_bump]=true).
408        #[arg(long)]
409        no_bump: bool,
410        /// Update the post without recording an edit-history revision
411        /// (sends post[skip_revision]=true). Suppresses the online audit
412        /// trail - use sparingly.
413        #[arg(long)]
414        skip_revision: bool,
415    },
416    /// Sync a topic and local Markdown file using newest timestamp.
417    #[command(visible_alias = "sy")]
418    Sync {
419        /// Discourse name.
420        discourse: String,
421        /// Topic ID.
422        topic_id: u64,
423        /// Local Markdown file path.
424        local_path: PathBuf,
425        /// Skip sync confirmation prompt.
426        #[arg(long, short = 'y')]
427        yes: bool,
428    },
429    /// Reply to a topic with content from a file or stdin.
430    #[command(visible_alias = "r")]
431    Reply {
432        /// Discourse name.
433        discourse: String,
434        /// Topic ID.
435        topic_id: u64,
436        /// Input file path. Reads stdin when omitted or `-`.
437        local_path: Option<PathBuf>,
438        /// Output format.
439        #[arg(long, short = 'f', value_enum, default_value = "text")]
440        format: ListFormat,
441    },
442    /// Create a new topic in a category, body from a file or stdin.
443    #[command(visible_alias = "n")]
444    New {
445        /// Discourse name.
446        discourse: String,
447        /// Target category ID.
448        category_id: u64,
449        /// Topic title.
450        #[arg(long, short = 't')]
451        title: String,
452        /// Input file path. Reads stdin when omitted or `-`.
453        local_path: Option<PathBuf>,
454        /// Output format.
455        #[arg(long, short = 'f', value_enum, default_value = "text")]
456        format: ListFormat,
457    },
458    /// Add a tag to a topic.
459    Tag {
460        /// Discourse name.
461        discourse: String,
462        /// Topic ID.
463        topic_id: u64,
464        /// Tag to add.
465        tag: String,
466    },
467    /// Remove a tag from a topic.
468    Untag {
469        /// Discourse name.
470        discourse: String,
471        /// Topic ID.
472        topic_id: u64,
473        /// Tag to remove.
474        tag: String,
475    },
476    /// Rename a topic's title (changes its URL slug). Honours `--dry-run`.
477    Title {
478        /// Discourse name.
479        discourse: String,
480        /// Topic ID.
481        topic_id: u64,
482        /// New title.
483        title: String,
484    },
485    /// Set a topic's full tag list, replacing existing tags. Pass no tags to
486    /// clear all tags. Honours `--dry-run`.
487    Tags {
488        /// Discourse name.
489        discourse: String,
490        /// Topic ID.
491        topic_id: u64,
492        /// Tags to set (space-separated; omit to clear all tags).
493        tags: Vec<String>,
494    },
495}
496
497#[derive(Subcommand)]
498pub enum CategoryCommand {
499    /// List categories.
500    #[command(visible_alias = "ls")]
501    List {
502        /// Discourse name.
503        discourse: String,
504        /// Output format.
505        #[arg(long, short = 'f', value_enum, default_value = "text")]
506        format: ListFormat,
507        /// Include additional fields where supported.
508        #[arg(long, short = 'v')]
509        verbose: bool,
510        /// Show category hierarchy tree.
511        #[arg(long)]
512        tree: bool,
513    },
514    /// Copy a category to another Discourse.
515    #[command(visible_alias = "cp")]
516    Copy {
517        /// Source discourse name.
518        discourse: String,
519        /// Target discourse name (defaults to source when omitted).
520        #[arg(long, short = 't')]
521        target: Option<String>,
522        /// Category ID or slug.
523        category: String,
524    },
525    /// Pull all topics from a category into local Markdown files.
526    #[command(visible_alias = "pl")]
527    Pull {
528        /// Discourse name.
529        discourse: String,
530        /// Category ID or slug.
531        category: String,
532        /// Destination directory (auto-derived when omitted).
533        local_path: Option<PathBuf>,
534    },
535    /// Push local Markdown files into a category.
536    #[command(visible_alias = "ps")]
537    Push {
538        /// Discourse name.
539        discourse: String,
540        /// Category ID or slug.
541        category: String,
542        /// Local directory containing Markdown files.
543        local_path: PathBuf,
544        /// Only update existing topics; error instead of creating a new topic
545        /// when a local file has no remote match.
546        #[arg(long)]
547        updates_only: bool,
548        /// Update posts without bumping their topics in the activity feed.
549        /// Use for silent bulk maintenance edits (sends post[no_bump]=true).
550        #[arg(long)]
551        no_bump: bool,
552        /// Update posts without recording edit-history revisions
553        /// (sends post[skip_revision]=true). Suppresses the online audit
554        /// trail - use sparingly.
555        #[arg(long)]
556        skip_revision: bool,
557    },
558}
559
560#[derive(Subcommand)]
561pub enum GroupCommand {
562    /// List groups.
563    #[command(visible_alias = "ls")]
564    List {
565        /// Discourse name.
566        discourse: String,
567        /// Output format.
568        #[arg(long, short = 'f', value_enum, default_value = "text")]
569        format: ListFormat,
570        /// Include additional fields where supported.
571        #[arg(long, short = 'v')]
572        verbose: bool,
573    },
574    /// Show group details.
575    #[command(visible_alias = "i")]
576    Info {
577        /// Discourse name.
578        discourse: String,
579        /// Group ID.
580        group: u64,
581        /// Output format.
582        #[arg(long, short = 'f', value_enum, default_value = "json")]
583        format: StructuredFormat,
584    },
585    /// List members of a group.
586    #[command(visible_alias = "m")]
587    Members {
588        /// Discourse name.
589        discourse: String,
590        /// Group ID.
591        group: u64,
592        /// Output format.
593        #[arg(long, short = 'f', value_enum, default_value = "text")]
594        format: ListFormat,
595    },
596    /// Copy a group to another Discourse.
597    #[command(visible_alias = "cp")]
598    Copy {
599        /// Source discourse name.
600        discourse: String,
601        /// Target discourse name (defaults to source when omitted).
602        #[arg(long, short = 't')]
603        target: Option<String>,
604        /// Group ID.
605        group: u64,
606    },
607    /// Bulk add members to a group from a file (or stdin) of email addresses.
608    #[command(visible_alias = "a")]
609    Add {
610        /// Discourse name.
611        discourse: String,
612        /// Group ID.
613        group: u64,
614        /// Path to a file of email addresses (one per line; blank
615        /// lines and `#` comments are ignored). Reads stdin when
616        /// omitted or `-`.
617        local_path: Option<PathBuf>,
618        /// Send Discourse notifications to added users.
619        #[arg(long)]
620        notify: bool,
621    },
622}
623
624#[derive(Subcommand)]
625pub enum BackupCommand {
626    /// Create a new backup.
627    #[command(visible_alias = "cr")]
628    Create {
629        /// Discourse name.
630        discourse: String,
631    },
632    /// List backups.
633    #[command(visible_alias = "ls")]
634    List {
635        /// Discourse name.
636        discourse: String,
637        /// Output format.
638        #[arg(long, short = 'f', value_enum, default_value = "text")]
639        format: OutputFormat,
640        /// Include additional fields where supported.
641        #[arg(long, short = 'v')]
642        verbose: bool,
643    },
644    /// Pull (download) a backup to a local file.
645    #[command(visible_alias = "pl")]
646    Pull {
647        /// Discourse name.
648        discourse: String,
649        /// Backup filename on the server (from `dsc backup list`).
650        backup_filename: String,
651        /// Local output path. Defaults to the backup filename in the current directory.
652        local_path: Option<PathBuf>,
653    },
654    /// Push (restore) a backup on the server (alias: restore).
655    #[command(visible_alias = "ps", alias = "restore")]
656    Push {
657        /// Discourse name.
658        discourse: String,
659        /// Backup filename/path on the target system.
660        backup_path: String,
661    },
662}
663
664#[derive(Subcommand)]
665pub enum PaletteCommand {
666    /// List color palettes.
667    #[command(visible_alias = "ls")]
668    List {
669        /// Discourse name.
670        discourse: String,
671        /// Output format.
672        #[arg(long, short = 'f', value_enum, default_value = "text")]
673        format: ListFormat,
674        /// Include additional fields where supported.
675        #[arg(long, short = 'v')]
676        verbose: bool,
677    },
678    /// Pull a palette to local JSON.
679    #[command(visible_alias = "pl")]
680    Pull {
681        /// Discourse name.
682        discourse: String,
683        /// Palette ID.
684        palette_id: u64,
685        /// Destination file path (auto-derived when omitted).
686        local_path: Option<PathBuf>,
687    },
688    /// Push local JSON to create or update a palette.
689    #[command(visible_alias = "ps")]
690    Push {
691        /// Discourse name.
692        discourse: String,
693        /// Local JSON file path.
694        local_path: PathBuf,
695        /// Palette ID to update (creates a new palette when omitted).
696        palette_id: Option<u64>,
697    },
698}
699
700#[derive(Subcommand)]
701pub enum PluginCommand {
702    /// List installed plugins.
703    #[command(visible_alias = "ls")]
704    List {
705        /// Discourse name.
706        discourse: String,
707        /// Output format.
708        #[arg(long, short = 'f', value_enum, default_value = "text")]
709        format: ListFormat,
710        /// Include additional fields where supported.
711        #[arg(long, short = 'v')]
712        verbose: bool,
713    },
714    /// Install a plugin from URL.
715    #[command(visible_alias = "i")]
716    Install {
717        /// Discourse name.
718        discourse: String,
719        /// Plugin repository URL.
720        url: String,
721    },
722    /// Remove a plugin by name.
723    #[command(visible_alias = "rm")]
724    Remove {
725        /// Discourse name.
726        discourse: String,
727        /// Plugin name.
728        name: String,
729    },
730}
731
732#[derive(Subcommand)]
733pub enum ThemeCommand {
734    /// List installed themes.
735    #[command(visible_alias = "ls")]
736    List {
737        /// Discourse name.
738        discourse: String,
739        /// Output format.
740        #[arg(long, short = 'f', value_enum, default_value = "text")]
741        format: ListFormat,
742        /// Include additional fields where supported.
743        #[arg(long, short = 'v')]
744        verbose: bool,
745    },
746    /// Install a theme from URL.
747    #[command(visible_alias = "i")]
748    Install {
749        /// Discourse name.
750        discourse: String,
751        /// Theme repository URL.
752        url: String,
753    },
754    /// Remove a theme by name.
755    #[command(visible_alias = "rm")]
756    Remove {
757        /// Discourse name.
758        discourse: String,
759        /// Theme name.
760        name: String,
761    },
762    /// Pull a theme to a local JSON file.
763    #[command(visible_alias = "pl")]
764    Pull {
765        /// Discourse name.
766        discourse: String,
767        /// Theme ID (from `dsc theme list`).
768        theme_id: u64,
769        /// Destination file path (auto-derived from theme name when omitted).
770        local_path: Option<PathBuf>,
771    },
772    /// Push a local JSON file to create or update a theme.
773    #[command(visible_alias = "ps")]
774    Push {
775        /// Discourse name.
776        discourse: String,
777        /// Local JSON file path.
778        local_path: PathBuf,
779        /// Theme ID to update (creates a new theme when omitted).
780        theme_id: Option<u64>,
781    },
782    /// Duplicate a theme and print the new theme ID.
783    #[command(visible_alias = "dup")]
784    Duplicate {
785        /// Discourse name.
786        discourse: String,
787        /// Theme ID to duplicate (from `dsc theme list`).
788        theme_id: u64,
789        /// Output format.
790        #[arg(long, short = 'f', value_enum, default_value = "text")]
791        format: ListFormat,
792    },
793    /// Show a richer view of one theme/component than `list`.
794    Show {
795        /// Discourse name.
796        discourse: String,
797        /// Theme ID (from `dsc theme list`).
798        theme_id: u64,
799        /// Output format.
800        #[arg(long, short = 'f', value_enum, default_value = "text")]
801        format: ListFormat,
802    },
803    /// Read and write a theme/component's settings (not site settings).
804    Setting {
805        #[command(subcommand)]
806        command: ThemeSettingCommand,
807    },
808    /// Enable a theme or component.
809    Enable {
810        /// Discourse name.
811        discourse: String,
812        /// Theme ID (from `dsc theme list`).
813        theme_id: u64,
814    },
815    /// Disable a theme or component.
816    Disable {
817        /// Discourse name.
818        discourse: String,
819        /// Theme ID (from `dsc theme list`).
820        theme_id: u64,
821    },
822    /// Attach a component to a parent theme (makes it active on that theme).
823    Attach {
824        /// Discourse name.
825        discourse: String,
826        /// Parent theme ID.
827        parent_id: u64,
828        /// Component (child theme) ID to attach.
829        component_id: u64,
830    },
831    /// Detach a component from a parent theme.
832    Detach {
833        /// Discourse name.
834        discourse: String,
835        /// Parent theme ID.
836        parent_id: u64,
837        /// Component (child theme) ID to detach.
838        component_id: u64,
839    },
840    /// Manage colour palettes (colour schemes). The canonical home for what
841    /// was `dsc palette`.
842    Palette {
843        #[command(subcommand)]
844        command: PaletteCommand,
845    },
846}
847
848#[derive(Subcommand)]
849pub enum ThemeSettingCommand {
850    /// List a theme/component's settings.
851    #[command(visible_alias = "ls")]
852    List {
853        /// Discourse name.
854        discourse: String,
855        /// Theme ID (from `dsc theme list`).
856        theme_id: u64,
857        /// Output format.
858        #[arg(long, short = 'f', value_enum, default_value = "text")]
859        format: ListFormat,
860    },
861    /// Print a single setting's current value.
862    Get {
863        /// Discourse name.
864        discourse: String,
865        /// Theme ID.
866        theme_id: u64,
867        /// Setting key (the `setting` name from `theme setting list`).
868        key: String,
869        /// Output format.
870        #[arg(long, short = 'f', value_enum, default_value = "text")]
871        format: ListFormat,
872    },
873    /// Set a single setting. Value is sent verbatim (pass JSON text for
874    /// json-schema list settings). Honours global `--dry-run`.
875    Set {
876        /// Discourse name.
877        discourse: String,
878        /// Theme ID.
879        theme_id: u64,
880        /// Setting key.
881        key: String,
882        /// New value (verbatim).
883        value: String,
884    },
885}
886
887#[derive(Subcommand)]
888pub enum PmCommand {
889    /// Send a private message.
890    #[command(visible_alias = "s")]
891    Send {
892        /// Discourse name.
893        discourse: String,
894        /// Recipient(s) — comma-separated usernames or group names.
895        recipients: String,
896        /// PM title / subject.
897        #[arg(long, short = 't')]
898        title: String,
899        /// Input file path. Reads stdin when omitted or `-`.
900        local_path: Option<PathBuf>,
901    },
902    /// List PMs for a user.
903    #[command(visible_alias = "ls")]
904    List {
905        /// Discourse name.
906        discourse: String,
907        /// Username whose PMs to list.
908        username: String,
909        /// Direction / view: inbox | sent | archive | unread | new.
910        #[arg(long, short = 'd', default_value = "inbox")]
911        direction: String,
912        /// Output format.
913        #[arg(long, short = 'f', value_enum, default_value = "text")]
914        format: ListFormat,
915    },
916}
917
918#[derive(Subcommand)]
919pub enum ApiKeyCommand {
920    /// List API keys.
921    #[command(visible_alias = "ls")]
922    List {
923        /// Discourse name.
924        discourse: String,
925        /// Output format.
926        #[arg(long, short = 'f', value_enum, default_value = "text")]
927        format: ListFormat,
928    },
929    /// Create a new API key. The secret is only shown at creation time —
930    /// capture it from the output.
931    #[command(visible_alias = "cr")]
932    Create {
933        /// Discourse name.
934        discourse: String,
935        /// Description / label for the key (shown in admin UI).
936        description: String,
937        /// Username the key acts as. Omit for a global all-users key.
938        #[arg(long, short = 'u')]
939        username: Option<String>,
940        /// Output format.
941        #[arg(long, short = 'f', value_enum, default_value = "text")]
942        format: ListFormat,
943    },
944    /// Revoke an API key by ID.
945    #[command(visible_alias = "rm")]
946    Revoke {
947        /// Discourse name.
948        discourse: String,
949        /// API key ID (from `dsc api-key list`).
950        key_id: u64,
951    },
952}
953
954#[derive(Subcommand)]
955pub enum InviteCommand {
956    /// Invite a single email address.
957    #[command(visible_alias = "s")]
958    Send {
959        /// Discourse name.
960        discourse: String,
961        /// Email address to invite.
962        email: String,
963        /// Add invitee to one or more groups on accept (repeatable).
964        #[arg(long, short = 'g')]
965        group: Vec<u64>,
966        /// Land the invitee on a specific topic on accept.
967        #[arg(long, short = 't')]
968        topic: Option<u64>,
969        /// Custom invitation message.
970        #[arg(long, short = 'm')]
971        message: Option<String>,
972    },
973    /// Bulk-invite from a file (or stdin) of email addresses.
974    #[command(visible_alias = "b")]
975    Bulk {
976        /// Discourse name.
977        discourse: String,
978        /// Path to a file of email addresses (one per line; blank lines and
979        /// `#` comments ignored). Reads stdin when omitted or `-`.
980        local_path: Option<PathBuf>,
981        /// Add every invitee to one or more groups on accept (repeatable).
982        #[arg(long, short = 'g')]
983        group: Vec<u64>,
984        /// Land every invitee on a specific topic on accept.
985        #[arg(long, short = 't')]
986        topic: Option<u64>,
987        /// Custom invitation message attached to each invite.
988        #[arg(long, short = 'm')]
989        message: Option<String>,
990    },
991}
992
993#[derive(Subcommand)]
994pub enum UserCommand {
995    /// List users via the admin users endpoint.
996    #[command(visible_alias = "ls")]
997    List {
998        /// Discourse name.
999        discourse: String,
1000        /// Listing type: active | new | staff | suspended | silenced | staged.
1001        #[arg(long, short = 'l', default_value = "active")]
1002        listing: String,
1003        /// Page number (Discourse paginates 100 per page).
1004        #[arg(long, short = 'p', default_value_t = 1)]
1005        page: u32,
1006        /// Output format.
1007        #[arg(long, short = 'f', value_enum, default_value = "text")]
1008        format: ListFormat,
1009    },
1010    /// Show detailed info for a user.
1011    #[command(visible_alias = "i")]
1012    Info {
1013        /// Discourse name.
1014        discourse: String,
1015        /// Username.
1016        username: String,
1017        /// Output format.
1018        #[arg(long, short = 'f', value_enum, default_value = "text")]
1019        format: ListFormat,
1020    },
1021    /// Suspend a user.
1022    #[command(visible_alias = "sus")]
1023    Suspend {
1024        /// Discourse name.
1025        discourse: String,
1026        /// Username.
1027        username: String,
1028        /// When the suspension ends. ISO-8601 timestamp (e.g.
1029        /// `2026-12-31T00:00:00Z`) or `forever`.
1030        #[arg(long, short = 'u', default_value = "forever")]
1031        until: String,
1032        /// Reason shown to the user and in the audit log.
1033        #[arg(long, short = 'r', default_value = "")]
1034        reason: String,
1035    },
1036    /// Remove a suspension from a user.
1037    #[command(visible_alias = "uns")]
1038    Unsuspend {
1039        /// Discourse name.
1040        discourse: String,
1041        /// Username.
1042        username: String,
1043    },
1044    /// Silence a user (prevents posting; less visible than suspend).
1045    #[command(visible_alias = "sil")]
1046    Silence {
1047        /// Discourse name.
1048        discourse: String,
1049        /// Username.
1050        username: String,
1051        /// When the silence ends. ISO-8601 timestamp; empty means
1052        /// indefinite.
1053        #[arg(long, short = 'u', default_value = "")]
1054        until: String,
1055        /// Reason shown to the user and in the audit log.
1056        #[arg(long, short = 'r', default_value = "")]
1057        reason: String,
1058    },
1059    /// Lift a silence on a user.
1060    #[command(visible_alias = "unsil")]
1061    Unsilence {
1062        /// Discourse name.
1063        discourse: String,
1064        /// Username.
1065        username: String,
1066    },
1067    /// Grant the user the admin or moderator role.
1068    #[command(visible_alias = "pr")]
1069    Promote {
1070        /// Discourse name.
1071        discourse: String,
1072        /// Username.
1073        username: String,
1074        /// Role to grant.
1075        #[arg(long, short = 'r', value_enum)]
1076        role: RoleArg,
1077    },
1078    /// Revoke the user's admin or moderator role.
1079    #[command(visible_alias = "de")]
1080    Demote {
1081        /// Discourse name.
1082        discourse: String,
1083        /// Username.
1084        username: String,
1085        /// Role to revoke.
1086        #[arg(long, short = 'r', value_enum)]
1087        role: RoleArg,
1088    },
1089    /// Create a new user. `--approve` also marks the account approved
1090    /// (needed when site requires manual approval). Password is either
1091    /// supplied via stdin (`--password-stdin`) or omitted — in the
1092    /// latter case the user will have to set one via the reset flow.
1093    #[command(visible_alias = "cr")]
1094    Create {
1095        /// Discourse name.
1096        discourse: String,
1097        /// New user's email address.
1098        email: String,
1099        /// New user's username.
1100        username: String,
1101        /// Display name (optional).
1102        #[arg(long, short = 'N')]
1103        name: Option<String>,
1104        /// Read the password from stdin instead of auto-reset.
1105        #[arg(long)]
1106        password_stdin: bool,
1107        /// Also mark the user approved (for sites with manual approval).
1108        #[arg(long)]
1109        approve: bool,
1110    },
1111    /// Trigger Discourse's password-reset email flow for a user.
1112    #[command(name = "password-reset", visible_aliases = ["pwreset", "pw-reset"])]
1113    PasswordReset {
1114        /// Discourse name.
1115        discourse: String,
1116        /// Username or email.
1117        username: String,
1118    },
1119    /// Set a user's primary email address. Requires admin scope.
1120    #[command(name = "email-set", visible_alias = "email")]
1121    EmailSet {
1122        /// Discourse name.
1123        discourse: String,
1124        /// Username.
1125        username: String,
1126        /// New email address.
1127        email: String,
1128    },
1129    /// Show a user's recent public activity (topics + replies by default).
1130    ///
1131    /// Built for the "archive my own activity to a journal forum" loop —
1132    /// pipe the markdown output straight into `dsc topic reply`/`topic new`.
1133    #[command(visible_alias = "act")]
1134    Activity {
1135        /// Discourse name (the *source* forum to read activity from).
1136        discourse: String,
1137        /// Username whose activity to read.
1138        username: String,
1139        /// How far back to look. Accepts `7d`, `24h`, `30m`, `1w`, `90s`, or
1140        /// an ISO-8601 timestamp / date. Omit to fetch everything available.
1141        #[arg(long, short = 's')]
1142        since: Option<String>,
1143        /// Action types to include, comma-separated. Default: topics,replies.
1144        /// Also recognises: mentions, quotes, likes, edits, responses.
1145        #[arg(long, short = 't', default_value = "topics,replies")]
1146        types: String,
1147        /// Hard cap on number of items returned.
1148        #[arg(long, short = 'L')]
1149        limit: Option<u32>,
1150        /// Output format.
1151        #[arg(long, short = 'f', value_enum, default_value = "markdown")]
1152        format: ActivityFormatArg,
1153    },
1154    /// Manage a user's group memberships.
1155    #[command(visible_alias = "g")]
1156    Groups {
1157        #[command(subcommand)]
1158        command: UserGroupsCommand,
1159    },
1160}
1161
1162#[derive(ValueEnum, Clone, Copy)]
1163pub enum SectionArg {
1164    All,
1165    Growth,
1166    Activity,
1167    Health,
1168}
1169
1170#[derive(ValueEnum, Clone, Copy)]
1171pub enum AnalyticsFormat {
1172    /// Plain text (default). Fixed-width columns, no borders.
1173    Text,
1174    /// DuckDB-style box-drawing table. Falls through to `text` when
1175    /// stdout isn't a TTY.
1176    Table,
1177    /// Pretty JSON.
1178    Json,
1179    /// YAML.
1180    #[value(alias = "yml")]
1181    Yaml,
1182    /// Markdown bullet list per section.
1183    #[value(alias = "md")]
1184    Markdown,
1185    /// Markdown table per section.
1186    #[value(alias = "md-table", name = "markdown-table")]
1187    MarkdownTable,
1188    /// CSV — one row per metric.
1189    Csv,
1190}
1191
1192#[derive(ValueEnum, Clone, Copy)]
1193pub enum ActivityFormatArg {
1194    Text,
1195    Json,
1196    #[value(alias = "yml")]
1197    Yaml,
1198    #[value(alias = "md")]
1199    Markdown,
1200    Csv,
1201}
1202
1203#[derive(ValueEnum, Clone, Copy)]
1204pub enum RoleArg {
1205    Admin,
1206    Moderator,
1207}
1208
1209#[derive(Subcommand)]
1210pub enum UserGroupsCommand {
1211    /// List the groups a user belongs to.
1212    #[command(visible_alias = "ls")]
1213    List {
1214        /// Discourse name.
1215        discourse: String,
1216        /// Target username.
1217        username: String,
1218        /// Output format.
1219        #[arg(long, short = 'f', value_enum, default_value = "text")]
1220        format: ListFormat,
1221    },
1222    /// Add a user to a group.
1223    #[command(visible_alias = "a")]
1224    Add {
1225        /// Discourse name.
1226        discourse: String,
1227        /// Target username.
1228        username: String,
1229        /// Group ID.
1230        group_id: u64,
1231        /// Send Discourse notification to the user.
1232        #[arg(long)]
1233        notify: bool,
1234    },
1235    /// Remove a user from a group.
1236    #[command(visible_alias = "rm")]
1237    Remove {
1238        /// Discourse name.
1239        discourse: String,
1240        /// Target username.
1241        username: String,
1242        /// Group ID.
1243        group_id: u64,
1244    },
1245}
1246
1247#[derive(Subcommand)]
1248pub enum PostCommand {
1249    /// Pull a post's raw Markdown to a local file.
1250    #[command(visible_alias = "pl")]
1251    Pull {
1252        /// Discourse name.
1253        discourse: String,
1254        /// Post ID.
1255        post_id: u64,
1256        /// Output file path. Prints to stdout when omitted.
1257        local_path: Option<PathBuf>,
1258    },
1259    /// Push a local file to update a post (alias: edit).
1260    #[command(visible_alias = "ps", alias = "edit")]
1261    Push {
1262        /// Discourse name.
1263        discourse: String,
1264        /// Post ID.
1265        post_id: u64,
1266        /// Input file path. Reads stdin when omitted or `-`.
1267        local_path: Option<PathBuf>,
1268    },
1269    /// Delete a post by ID.
1270    #[command(visible_alias = "rm")]
1271    Delete {
1272        /// Discourse name.
1273        discourse: String,
1274        /// Post ID.
1275        post_id: u64,
1276    },
1277    /// Move a post to a different topic.
1278    #[command(visible_alias = "mv")]
1279    Move {
1280        /// Discourse name.
1281        discourse: String,
1282        /// Post ID to move.
1283        post_id: u64,
1284        /// Destination topic ID.
1285        #[arg(long = "to-topic", short = 't')]
1286        to_topic: u64,
1287    },
1288}
1289
1290#[derive(Subcommand)]
1291pub enum TagCommand {
1292    /// List every tag on the Discourse.
1293    #[command(visible_alias = "ls")]
1294    List {
1295        /// Discourse name.
1296        discourse: String,
1297        /// Output format.
1298        #[arg(long, short = 'f', value_enum, default_value = "text")]
1299        format: ListFormat,
1300    },
1301    /// Pull the tag taxonomy (tags + tag groups) to a local file.
1302    #[command(visible_alias = "pl")]
1303    Pull {
1304        /// Discourse name.
1305        discourse: String,
1306        /// Output file (default: tags.yaml). Extension determines format (.yaml/.json).
1307        #[arg(default_value = "tags.yaml")]
1308        local_path: PathBuf,
1309    },
1310    /// Push a local taxonomy file to the server (upsert; optionally prune).
1311    #[command(visible_alias = "ps")]
1312    Push {
1313        /// Discourse name.
1314        discourse: String,
1315        /// Input taxonomy file.
1316        local_path: PathBuf,
1317        /// Delete server tags/groups absent from the file.
1318        #[arg(long)]
1319        prune: bool,
1320    },
1321    /// Rename a tag, preserving topic associations.
1322    ///
1323    /// Discourse rewrites every topic's tag list in-place, so this avoids
1324    /// the delete-and-recreate pattern that loses topic membership.
1325    #[command(visible_alias = "rn")]
1326    Rename {
1327        /// Discourse name.
1328        discourse: String,
1329        /// Current tag name.
1330        old_name: String,
1331        /// New tag name.
1332        new_name: String,
1333    },
1334}
1335
1336#[derive(Subcommand)]
1337pub enum SettingCommand {
1338    /// Set a site setting on a Discourse (or all tagged Discourses).
1339    ///
1340    /// Usage:
1341    ///   dsc setting set <discourse> <setting> <value>
1342    ///   dsc setting set --tags <tag1,tag2> <setting> <value>
1343    #[command(visible_alias = "s")]
1344    Set {
1345        /// Discourse name. Required unless `--tags` is provided.
1346        discourse: Option<String>,
1347        /// Setting key. Required.
1348        setting: Option<String>,
1349        /// Setting value. Required.
1350        value: Option<String>,
1351        /// Tag filter (comma/semicolon separated, match-any). Apply across all
1352        /// Discourses matching any of the tags. When set, omit `<discourse>`
1353        /// and pass `<setting> <value>` as the only positionals.
1354        #[arg(long, value_name = "tag1,tag2")]
1355        tags: Option<String>,
1356    },
1357
1358    /// Get the current value of a site setting.
1359    #[command(visible_alias = "g")]
1360    Get {
1361        /// Discourse name.
1362        discourse: String,
1363        /// Setting key.
1364        setting: String,
1365        /// Output format.
1366        #[arg(long, short = 'f', value_enum, default_value = "text")]
1367        format: ListFormat,
1368    },
1369
1370    /// List all site settings.
1371    #[command(visible_alias = "ls")]
1372    List {
1373        /// Discourse name.
1374        discourse: String,
1375        /// Output format.
1376        #[arg(long, short = 'f', value_enum, default_value = "text")]
1377        format: ListFormat,
1378        /// Show output even when list is empty.
1379        #[arg(long, short = 'v')]
1380        verbose: bool,
1381    },
1382
1383    /// Snapshot all site settings (with metadata) to a local file.
1384    ///
1385    /// See spec/setting-sync.md for the full schema and workflow. The
1386    /// generated file is a self-documenting YAML (or JSON) including each
1387    /// setting's default, type, category, and description.
1388    #[command(visible_alias = "pl")]
1389    Pull {
1390        /// Discourse name.
1391        discourse: String,
1392        /// Output path. Format detected by extension (.json → JSON,
1393        /// otherwise YAML). Defaults to `settings.yaml`.
1394        #[arg(default_value = "settings.yaml")]
1395        local_path: PathBuf,
1396        /// Only include settings whose value differs from default. Produces
1397        /// a manageable file (~50-100 entries) suitable for version control.
1398        #[arg(long, short = 'c')]
1399        changed_only: bool,
1400        /// Limit to settings in this category (e.g. `required`, `email`,
1401        /// `security`).
1402        #[arg(long)]
1403        category: Option<String>,
1404    },
1405
1406    /// Apply a settings snapshot file to a Discourse (idempotent).
1407    ///
1408    /// Compares each setting in the file against the server and PUTs only
1409    /// values that differ. Combine with `--dry-run` to preview the plan.
1410    #[command(visible_alias = "ph")]
1411    Push {
1412        /// Discourse name.
1413        discourse: String,
1414        /// Path to the settings snapshot file (YAML or JSON).
1415        local_path: PathBuf,
1416        /// For settings present on the server but absent from the file,
1417        /// reset them to their default value. Off by default (file describes
1418        /// only the values you care about).
1419        #[arg(long)]
1420        reset_unlisted: bool,
1421    },
1422
1423    /// Compare site settings between two sources.
1424    ///
1425    /// Each source can be a Discourse name (live fetch) or a path to a
1426    /// snapshot file produced by `dsc setting pull`. Sources are detected
1427    /// by whether the argument refers to an existing file on disk; if not,
1428    /// it is treated as a Discourse name.
1429    #[command(visible_alias = "df")]
1430    Diff {
1431        /// First source: Discourse name or snapshot file path.
1432        source: String,
1433        /// Second source: Discourse name or snapshot file path.
1434        target: String,
1435        /// Filter to settings where at least one source differs from default.
1436        /// Reduces noise when most settings on both sides are still default.
1437        #[arg(long, short = 'c')]
1438        changed_only: bool,
1439        /// Limit to settings in this category (e.g. `required`, `email`).
1440        /// Only effective when both sources carry category metadata.
1441        #[arg(long)]
1442        category: Option<String>,
1443        /// Output format.
1444        #[arg(long, short = 'f', value_enum, default_value = "text")]
1445        format: ListFormat,
1446    },
1447
1448    /// Show the value of one setting across every configured forum
1449    /// (optionally filtered by `--tags`). Diff-friendly; distinct from `diff`,
1450    /// which compares two specific sources across all settings.
1451    Audit {
1452        /// Setting key.
1453        setting: String,
1454        /// Only audit forums carrying at least one of these tags
1455        /// (comma/semicolon-separated). Omit to audit every configured forum.
1456        #[arg(long, value_name = "tag1,tag2")]
1457        tags: Option<String>,
1458        /// Output format.
1459        #[arg(long, short = 'f', value_enum, default_value = "text")]
1460        format: ListFormat,
1461    },
1462}
1463
1464#[derive(ValueEnum, Clone, Copy)]
1465pub enum CompletionShell {
1466    /// Bash shell.
1467    Bash,
1468    /// Zsh shell.
1469    Zsh,
1470    /// Fish shell.
1471    Fish,
1472}
1473
1474impl From<CompletionShell> for Shell {
1475    fn from(value: CompletionShell) -> Self {
1476        match value {
1477            CompletionShell::Bash => Shell::Bash,
1478            CompletionShell::Zsh => Shell::Zsh,
1479            CompletionShell::Fish => Shell::Fish,
1480        }
1481    }
1482}
1483
1484#[derive(ValueEnum, Clone)]
1485pub enum OutputFormat {
1486    /// Plain text.
1487    #[value(alias = "plaintext")]
1488    Text,
1489    /// Markdown list.
1490    Markdown,
1491    /// Markdown table.
1492    MarkdownTable,
1493    /// Pretty JSON.
1494    Json,
1495    /// YAML.
1496    #[value(alias = "yml")]
1497    Yaml,
1498    /// CSV.
1499    Csv,
1500    /// One base URL per line (pipe-friendly).
1501    #[value(alias = "url")]
1502    Urls,
1503}
1504
1505#[derive(ValueEnum, Clone, Copy)]
1506pub enum ListFormat {
1507    /// Plain text.
1508    Text,
1509    /// Pretty JSON.
1510    Json,
1511    /// YAML.
1512    #[value(alias = "yml")]
1513    Yaml,
1514}
1515
1516#[derive(ValueEnum, Clone, Copy)]
1517pub enum StructuredFormat {
1518    /// Pretty JSON.
1519    Json,
1520    /// YAML.
1521    #[value(alias = "yml")]
1522    Yaml,
1523}