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