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 accm    # the forum's live Discourse version + git commit")]
415    Version {
416        /// Forum name. When given, print that forum's live Discourse version
417        /// and git commit (from /about.json, via the configured API key)
418        /// instead of dsc's own version.
419        discourse: Option<String>,
420    },
421}
422
423#[derive(Subcommand)]
424#[command(next_display_order = None)]
425pub enum ConfigCommand {
426    /// Probe each configured Discourse: API auth and (optionally) SSH reachability.
427    #[command(visible_alias = "ck")]
428    Check {
429        /// Output format.
430        #[arg(long, short = 'f', value_enum, default_value = "text")]
431        format: ListFormat,
432        /// Skip the SSH reachability probe.
433        #[arg(long)]
434        skip_ssh: bool,
435        /// Probe forums concurrently (results stream fastest-first). Much
436        /// faster on a large fleet.
437        #[arg(long, short = 'p')]
438        parallel: bool,
439        /// Maximum workers when --parallel is set (default: 8).
440        #[arg(long, short = 'm')]
441        max: Option<usize>,
442    },
443}
444
445#[derive(Subcommand)]
446#[command(next_display_order = None)]
447pub enum ListCommand {
448    /// Sort discourse entries by name and rewrite config in-place.
449    /// Also inserts placeholder values for unset template keys.
450    #[command(visible_alias = "ty")]
451    Tidy,
452}
453
454#[derive(Subcommand)]
455#[command(next_display_order = None)]
456pub enum EmojiCommand {
457    /// Pull all custom emoji from a Discourse into a local directory.
458    #[command(visible_alias = "pl")]
459    Pull {
460        /// Discourse name.
461        discourse: String,
462        /// Local directory to save emoji images into.
463        output_dir: PathBuf,
464    },
465    /// Push (upload) one emoji file, or bulk-upload from a directory (alias: add).
466    #[command(visible_alias = "ps", alias = "add")]
467    Push {
468        /// Discourse name.
469        discourse: String,
470        /// Local file or directory path.
471        emoji_path: PathBuf,
472        /// Optional emoji name (file uploads only).
473        emoji_name: Option<String>,
474    },
475
476    /// List custom emojis on a Discourse.
477    #[command(visible_alias = "ls")]
478    List {
479        /// Discourse name.
480        discourse: String,
481        /// Output format.
482        #[arg(long, short = 'f', value_enum, default_value = "text")]
483        format: ListFormat,
484        /// Include additional fields where supported.
485        #[arg(long, short = 'v')]
486        verbose: bool,
487        /// Render inline images when terminal protocol support is available.
488        #[arg(long, short = 'i')]
489        inline: bool,
490    },
491}
492
493#[derive(Subcommand)]
494#[command(next_display_order = None)]
495pub enum TopicCommand {
496    /// Pull a topic to a local Markdown file.
497    #[command(visible_alias = "pl")]
498    Pull {
499        /// Discourse name.
500        discourse: String,
501        /// Topic ID.
502        topic_id: u64,
503        /// Destination file or directory (auto-derived when omitted).
504        local_path: Option<PathBuf>,
505        /// Pull the entire thread (every post) as a single Markdown file
506        /// with YAML frontmatter and per-post headings. Default behaviour
507        /// (no `--full`) writes only the OP, which is what `topic push`
508        /// expects.
509        #[arg(long, short = 'F')]
510        full: bool,
511    },
512    /// Push a local Markdown file to a topic.
513    #[command(visible_alias = "ps")]
514    Push {
515        /// Discourse name.
516        discourse: String,
517        /// Topic ID.
518        topic_id: u64,
519        /// Local Markdown file path.
520        local_path: PathBuf,
521        /// Update the post without bumping the topic in the activity feed.
522        /// Use for silent maintenance edits (sends post[no_bump]=true).
523        #[arg(long)]
524        no_bump: bool,
525        /// Update the post without recording an edit-history revision
526        /// (sends post[skip_revision]=true). Suppresses the online audit
527        /// trail - use sparingly.
528        #[arg(long)]
529        skip_revision: bool,
530    },
531    /// Sync a topic and local Markdown file using newest timestamp.
532    #[command(visible_alias = "sy")]
533    Sync {
534        /// Discourse name.
535        discourse: String,
536        /// Topic ID.
537        topic_id: u64,
538        /// Local Markdown file path.
539        local_path: PathBuf,
540        /// Skip sync confirmation prompt.
541        #[arg(long, short = 'y')]
542        yes: bool,
543    },
544    /// Reply to a topic with content from a file or stdin.
545    #[command(visible_alias = "r")]
546    Reply {
547        /// Discourse name.
548        discourse: String,
549        /// Topic ID.
550        topic_id: u64,
551        /// Input file path. Reads stdin when omitted or `-`.
552        local_path: Option<PathBuf>,
553        /// Output format.
554        #[arg(long, short = 'f', value_enum, default_value = "text")]
555        format: ListFormat,
556    },
557    /// Create a new topic in a category, body from a file or stdin.
558    #[command(visible_alias = "n")]
559    New {
560        /// Discourse name.
561        discourse: String,
562        /// Target category ID.
563        category_id: u64,
564        /// Topic title.
565        #[arg(long, short = 't')]
566        title: String,
567        /// Input file path. Reads stdin when omitted or `-`.
568        local_path: Option<PathBuf>,
569        /// Output format.
570        #[arg(long, short = 'f', value_enum, default_value = "text")]
571        format: ListFormat,
572    },
573    /// Add a tag to a topic.
574    Tag {
575        /// Discourse name.
576        discourse: String,
577        /// Topic ID.
578        topic_id: u64,
579        /// Tag to add.
580        tag: String,
581    },
582    /// Remove a tag from a topic.
583    Untag {
584        /// Discourse name.
585        discourse: String,
586        /// Topic ID.
587        topic_id: u64,
588        /// Tag to remove.
589        tag: String,
590    },
591    /// Rename a topic's title (changes its URL slug). Honours `--dry-run`.
592    Title {
593        /// Discourse name.
594        discourse: String,
595        /// Topic ID.
596        topic_id: u64,
597        /// New title.
598        title: String,
599    },
600    /// Set a topic's full tag list, replacing existing tags. Pass no tags to
601    /// clear all tags. Honours `--dry-run`.
602    Tags {
603        /// Discourse name.
604        discourse: String,
605        /// Topic ID.
606        topic_id: u64,
607        /// Tags to set (space-separated; omit to clear all tags).
608        tags: Vec<String>,
609    },
610}
611
612#[derive(Subcommand)]
613#[command(next_display_order = None)]
614pub enum CategoryCommand {
615    /// List categories.
616    #[command(visible_alias = "ls")]
617    List {
618        /// Discourse name.
619        discourse: String,
620        /// Output format.
621        #[arg(long, short = 'f', value_enum, default_value = "text")]
622        format: ListFormat,
623        /// Include additional fields where supported.
624        #[arg(long, short = 'v')]
625        verbose: bool,
626        /// Show category hierarchy tree.
627        #[arg(long)]
628        tree: bool,
629    },
630    /// Copy a category to another Discourse.
631    #[command(visible_alias = "cp")]
632    Copy {
633        /// Source discourse name.
634        discourse: String,
635        /// Target discourse name (defaults to source when omitted).
636        #[arg(long, short = 't')]
637        target: Option<String>,
638        /// Category ID or slug.
639        category: String,
640    },
641    /// Pull all topics from a category into local Markdown files.
642    #[command(visible_alias = "pl")]
643    Pull {
644        /// Discourse name.
645        discourse: String,
646        /// Category ID or slug.
647        category: String,
648        /// Destination directory (auto-derived when omitted).
649        local_path: Option<PathBuf>,
650    },
651    /// Push local Markdown files into a category.
652    #[command(visible_alias = "ps")]
653    Push {
654        /// Discourse name.
655        discourse: String,
656        /// Category ID or slug.
657        category: String,
658        /// Local directory containing Markdown files.
659        local_path: PathBuf,
660        /// Only update existing topics; error instead of creating a new topic
661        /// when a local file has no remote match.
662        #[arg(long)]
663        updates_only: bool,
664        /// Update posts without bumping their topics in the activity feed.
665        /// Use for silent bulk maintenance edits (sends post[no_bump]=true).
666        #[arg(long)]
667        no_bump: bool,
668        /// Update posts without recording edit-history revisions
669        /// (sends post[skip_revision]=true). Suppresses the online audit
670        /// trail - use sparingly.
671        #[arg(long)]
672        skip_revision: bool,
673    },
674}
675
676#[derive(Subcommand)]
677#[command(next_display_order = None)]
678pub enum GroupCommand {
679    /// List groups.
680    #[command(visible_alias = "ls")]
681    List {
682        /// Discourse name.
683        discourse: String,
684        /// Output format.
685        #[arg(long, short = 'f', value_enum, default_value = "text")]
686        format: ListFormat,
687        /// Include additional fields where supported.
688        #[arg(long, short = 'v')]
689        verbose: bool,
690    },
691    /// Show group details.
692    #[command(visible_alias = "i")]
693    Info {
694        /// Discourse name.
695        discourse: String,
696        /// Group ID.
697        group: u64,
698        /// Output format.
699        #[arg(long, short = 'f', value_enum, default_value = "json")]
700        format: StructuredFormat,
701    },
702    /// List members of a group.
703    #[command(visible_alias = "m")]
704    Members {
705        /// Discourse name.
706        discourse: String,
707        /// Group ID.
708        group: u64,
709        /// Output format.
710        #[arg(long, short = 'f', value_enum, default_value = "text")]
711        format: ListFormat,
712    },
713    /// Copy a group to another Discourse.
714    #[command(visible_alias = "cp")]
715    Copy {
716        /// Source discourse name.
717        discourse: String,
718        /// Target discourse name (defaults to source when omitted).
719        #[arg(long, short = 't')]
720        target: Option<String>,
721        /// Group ID.
722        group: u64,
723    },
724    /// Bulk add members to a group from a file (or stdin) of email addresses.
725    #[command(visible_alias = "a")]
726    Add {
727        /// Discourse name.
728        discourse: String,
729        /// Group ID.
730        group: u64,
731        /// Path to a file of email addresses (one per line; blank
732        /// lines and `#` comments are ignored). Reads stdin when
733        /// omitted or `-`.
734        local_path: Option<PathBuf>,
735        /// Send Discourse notifications to added users.
736        #[arg(long)]
737        notify: bool,
738    },
739}
740
741#[derive(Subcommand)]
742#[command(next_display_order = None)]
743pub enum BackupCommand {
744    /// Create a new backup.
745    #[command(visible_alias = "cr")]
746    Create {
747        /// Discourse name.
748        discourse: String,
749    },
750    /// List backups.
751    #[command(visible_alias = "ls")]
752    List {
753        /// Discourse name.
754        discourse: String,
755        /// Output format.
756        #[arg(long, short = 'f', value_enum, default_value = "text")]
757        format: OutputFormat,
758        /// Include additional fields where supported.
759        #[arg(long, short = 'v')]
760        verbose: bool,
761    },
762    /// Pull (download) a backup to a local file.
763    #[command(visible_alias = "pl")]
764    Pull {
765        /// Discourse name.
766        discourse: String,
767        /// Backup filename on the server (from `dsc backup list`).
768        backup_filename: String,
769        /// Local output path. Defaults to the backup filename in the current directory.
770        local_path: Option<PathBuf>,
771    },
772    /// Push (restore) a backup on the server (alias: restore).
773    #[command(visible_alias = "ps", alias = "restore")]
774    Push {
775        /// Discourse name.
776        discourse: String,
777        /// Backup filename/path on the target system.
778        backup_path: String,
779    },
780    /// Provision an S3 backup bucket + a scoped IAM user and point Discourse at
781    /// it (one command for the per-forum AWS backup runbook). Requires the
782    /// `aws` CLI configured with IAM + S3 admin rights. Always preview with
783    /// `-n` / `--dry-run` first.
784    #[command(after_help = "Examples:
785  dsc backup setup-s3 -n myforum                  # preview the full plan (review gate)
786  dsc backup setup-s3 myforum --region eu-west-1
787  dsc backup setup-s3 myforum --no-test")]
788    SetupS3 {
789        /// Discourse name.
790        discourse: String,
791        /// AWS region for the bucket.
792        #[arg(long, default_value = "eu-west-2")]
793        region: String,
794        /// Bucket name (default: `<name>-discourse-backups`).
795        #[arg(long)]
796        bucket: Option<String>,
797        /// Skip the verification backup after provisioning.
798        #[arg(long)]
799        no_test: bool,
800    },
801}
802
803#[derive(Subcommand)]
804#[command(next_display_order = None)]
805pub enum PaletteCommand {
806    /// List color palettes.
807    #[command(visible_alias = "ls")]
808    List {
809        /// Discourse name.
810        discourse: String,
811        /// Output format.
812        #[arg(long, short = 'f', value_enum, default_value = "text")]
813        format: ListFormat,
814        /// Include additional fields where supported.
815        #[arg(long, short = 'v')]
816        verbose: bool,
817    },
818    /// Pull a palette to local JSON.
819    #[command(visible_alias = "pl")]
820    Pull {
821        /// Discourse name.
822        discourse: String,
823        /// Palette ID.
824        palette_id: u64,
825        /// Destination file path (auto-derived when omitted).
826        local_path: Option<PathBuf>,
827    },
828    /// Push local JSON to create or update a palette.
829    #[command(visible_alias = "ps")]
830    Push {
831        /// Discourse name.
832        discourse: String,
833        /// Local JSON file path.
834        local_path: PathBuf,
835        /// Palette ID to update (creates a new palette when omitted).
836        palette_id: Option<u64>,
837    },
838}
839
840#[derive(Subcommand)]
841#[command(next_display_order = None)]
842pub enum PluginCommand {
843    /// List installed plugins.
844    #[command(visible_alias = "ls")]
845    List {
846        /// Discourse name.
847        discourse: String,
848        /// Output format.
849        #[arg(long, short = 'f', value_enum, default_value = "text")]
850        format: ListFormat,
851        /// Include additional fields where supported.
852        #[arg(long, short = 'v')]
853        verbose: bool,
854    },
855    /// Install a plugin from URL.
856    #[command(visible_alias = "i")]
857    Install {
858        /// Discourse name.
859        discourse: String,
860        /// Plugin repository URL.
861        url: String,
862    },
863    /// Remove a plugin by name.
864    #[command(visible_alias = "rm")]
865    Remove {
866        /// Discourse name.
867        discourse: String,
868        /// Plugin name.
869        name: String,
870    },
871}
872
873#[derive(Subcommand)]
874#[command(next_display_order = None)]
875pub enum ThemeCommand {
876    /// List installed themes.
877    #[command(visible_alias = "ls")]
878    List {
879        /// Discourse name.
880        discourse: String,
881        /// Output format.
882        #[arg(long, short = 'f', value_enum, default_value = "text")]
883        format: ListFormat,
884        /// Include additional fields where supported.
885        #[arg(long, short = 'v')]
886        verbose: bool,
887    },
888    /// Install a theme from a git repo or a local bundle file (via the API).
889    #[command(visible_alias = "i")]
890    #[command(after_help = "Examples:
891  dsc theme install myforum https://github.com/org/theme
892  dsc theme install myforum https://user:TOKEN@github.com/org/private-theme.git
893  dsc theme install myforum ./my-theme.tar.gz")]
894    Install {
895        /// Discourse name.
896        discourse: String,
897        /// Git repo URL (creds may be embedded for private repos) or a local
898        /// bundle file (`.tar.gz`/zip theme export).
899        source: String,
900        /// Git branch to import (remote only; defaults to the repo's default).
901        #[arg(long, short = 'b')]
902        branch: Option<String>,
903    },
904    /// Remove a theme by name (SSH rake). Prefer `theme delete <id>` (API).
905    #[command(visible_alias = "rm")]
906    Remove {
907        /// Discourse name.
908        discourse: String,
909        /// Theme name.
910        name: String,
911    },
912    /// Delete a theme/component by id via the API. Honours `--dry-run`.
913    Delete {
914        /// Discourse name.
915        discourse: String,
916        /// Theme ID (from `dsc theme list`).
917        theme_id: u64,
918    },
919    /// Pull a theme to a local JSON file.
920    #[command(visible_alias = "pl")]
921    Pull {
922        /// Discourse name.
923        discourse: String,
924        /// Theme ID (from `dsc theme list`).
925        theme_id: u64,
926        /// Destination file path (auto-derived from theme name when omitted).
927        local_path: Option<PathBuf>,
928    },
929    /// Push a local JSON file to create or update a theme.
930    #[command(visible_alias = "ps")]
931    Push {
932        /// Discourse name.
933        discourse: String,
934        /// Local JSON file path.
935        local_path: PathBuf,
936        /// Theme ID to update (creates a new theme when omitted).
937        theme_id: Option<u64>,
938    },
939    /// Duplicate a theme and print the new theme ID.
940    #[command(visible_alias = "dup")]
941    Duplicate {
942        /// Discourse name.
943        discourse: String,
944        /// Theme ID to duplicate (from `dsc theme list`).
945        theme_id: u64,
946        /// Output format.
947        #[arg(long, short = 'f', value_enum, default_value = "text")]
948        format: ListFormat,
949    },
950    /// Show a richer view of one theme/component than `list`.
951    Show {
952        /// Discourse name.
953        discourse: String,
954        /// Theme ID (from `dsc theme list`).
955        theme_id: u64,
956        /// Output format.
957        #[arg(long, short = 'f', value_enum, default_value = "text")]
958        format: ListFormat,
959    },
960    /// Read and write a theme/component's settings (not site settings).
961    #[command(after_help = "Examples:
962  dsc theme setting list myforum 17
963  dsc theme setting pull myforum 17 header.yml   # header_links expands to a real list
964  dsc theme setting push myforum 17 header.yml --dry-run")]
965    Setting {
966        #[command(subcommand)]
967        command: ThemeSettingCommand,
968    },
969    /// Enable a theme or component.
970    Enable {
971        /// Discourse name.
972        discourse: String,
973        /// Theme ID (from `dsc theme list`).
974        theme_id: u64,
975    },
976    /// Disable a theme or component.
977    Disable {
978        /// Discourse name.
979        discourse: String,
980        /// Theme ID (from `dsc theme list`).
981        theme_id: u64,
982    },
983    /// Attach a component to a parent theme (makes it active on that theme).
984    Attach {
985        /// Discourse name.
986        discourse: String,
987        /// Parent theme ID.
988        parent_id: u64,
989        /// Component (child theme) ID to attach.
990        component_id: u64,
991    },
992    /// Detach a component from a parent theme.
993    Detach {
994        /// Discourse name.
995        discourse: String,
996        /// Parent theme ID.
997        parent_id: u64,
998        /// Component (child theme) ID to detach.
999        component_id: u64,
1000    },
1001    /// Read/write individual theme fields (SCSS, head_tag, ...).
1002    #[command(after_help = "Examples:
1003  dsc theme field list myforum 11
1004  dsc theme field pull myforum 11 common/scss common.scss
1005  dsc theme field push myforum 11 common/scss common.scss --dry-run")]
1006    Field {
1007        #[command(subcommand)]
1008        command: ThemeFieldCommand,
1009    },
1010    /// Upload and bind theme-scoped image/font assets (`$var`).
1011    #[command(after_help = "Examples:
1012  dsc theme asset list myforum 11
1013  dsc theme asset set myforum 11 centred_logo ./logo.png")]
1014    Asset {
1015        #[command(subcommand)]
1016        command: ThemeAssetCommand,
1017    },
1018    /// Pull a git-backed remote component to its latest upstream commit.
1019    #[command(after_help = "Examples:
1020  dsc theme update myforum 17 --check   # how far behind?
1021  dsc theme update myforum 17           # pull the latest commit")]
1022    Update {
1023        /// Discourse name.
1024        discourse: String,
1025        /// Theme ID (from `dsc theme list`).
1026        theme_id: u64,
1027        /// Only check for updates; don't pull.
1028        #[arg(long)]
1029        check: bool,
1030    },
1031    /// Manage colour palettes (colour schemes). The canonical home for what
1032    /// was `dsc palette`.
1033    Palette {
1034        #[command(subcommand)]
1035        command: PaletteCommand,
1036    },
1037}
1038
1039#[derive(Subcommand)]
1040#[command(next_display_order = None)]
1041pub enum ThemeFieldCommand {
1042    /// List a theme's editable fields.
1043    #[command(visible_alias = "ls")]
1044    List {
1045        /// Discourse name.
1046        discourse: String,
1047        /// Theme ID.
1048        theme_id: u64,
1049        /// Output format.
1050        #[arg(long, short = 'f', value_enum, default_value = "text")]
1051        format: ListFormat,
1052    },
1053    /// Pull one field's body (e.g. `common/scss`) to a file.
1054    Pull {
1055        /// Discourse name.
1056        discourse: String,
1057        /// Theme ID.
1058        theme_id: u64,
1059        /// Field spec: `target/name`, e.g. `common/scss`.
1060        field: String,
1061        /// Destination file (auto-derived from the field name when omitted).
1062        local_path: Option<PathBuf>,
1063    },
1064    /// Push a file back to one field. Honours global `--dry-run`.
1065    Push {
1066        /// Discourse name.
1067        discourse: String,
1068        /// Theme ID.
1069        theme_id: u64,
1070        /// Field spec: `target/name`, e.g. `common/scss`.
1071        field: String,
1072        /// File whose contents become the field body.
1073        local_path: PathBuf,
1074    },
1075}
1076
1077#[derive(Subcommand)]
1078#[command(next_display_order = None)]
1079pub enum ThemeAssetCommand {
1080    /// List a theme's bound upload assets.
1081    #[command(visible_alias = "ls")]
1082    List {
1083        /// Discourse name.
1084        discourse: String,
1085        /// Theme ID.
1086        theme_id: u64,
1087        /// Output format.
1088        #[arg(long, short = 'f', value_enum, default_value = "text")]
1089        format: ListFormat,
1090    },
1091    /// Upload a file and bind it to a `$var`. Honours global `--dry-run`.
1092    Set {
1093        /// Discourse name.
1094        discourse: String,
1095        /// Theme ID.
1096        theme_id: u64,
1097        /// Upload-var name (referenced as `$name` in SCSS).
1098        name: String,
1099        /// File to upload (image/font).
1100        file: PathBuf,
1101    },
1102    /// Remove a bound upload asset (`$var`). Honours global `--dry-run`.
1103    Unset {
1104        /// Discourse name.
1105        discourse: String,
1106        /// Theme ID.
1107        theme_id: u64,
1108        /// Upload-var name to unbind.
1109        name: String,
1110    },
1111}
1112
1113#[derive(Subcommand)]
1114#[command(next_display_order = None)]
1115pub enum ThemeSettingCommand {
1116    /// List a theme/component's settings.
1117    #[command(visible_alias = "ls")]
1118    List {
1119        /// Discourse name.
1120        discourse: String,
1121        /// Theme ID (from `dsc theme list`).
1122        theme_id: u64,
1123        /// Output format.
1124        #[arg(long, short = 'f', value_enum, default_value = "text")]
1125        format: ListFormat,
1126    },
1127    /// Print a single setting's current value.
1128    Get {
1129        /// Discourse name.
1130        discourse: String,
1131        /// Theme ID.
1132        theme_id: u64,
1133        /// Setting key (the `setting` name from `theme setting list`).
1134        key: String,
1135        /// Output format.
1136        #[arg(long, short = 'f', value_enum, default_value = "text")]
1137        format: ListFormat,
1138    },
1139    /// Set a single setting. Value is sent verbatim (pass JSON text for
1140    /// json-schema list settings). Honours global `--dry-run`.
1141    Set {
1142        /// Discourse name.
1143        discourse: String,
1144        /// Theme ID.
1145        theme_id: u64,
1146        /// Setting key.
1147        key: String,
1148        /// New value (verbatim).
1149        value: String,
1150    },
1151    /// Pull a component's settings to a file for offline editing. JSON-list
1152    /// settings (`header_links`, `dropdown_links`) are expanded to real,
1153    /// editable arrays. YAML by default; a `.json` path writes JSON.
1154    Pull {
1155        /// Discourse name.
1156        discourse: String,
1157        /// Theme ID.
1158        theme_id: u64,
1159        /// Destination file (auto-derived from the theme name when omitted).
1160        local_path: Option<PathBuf>,
1161    },
1162    /// Push a settings file back, PUTting only the changed settings. Honours
1163    /// global `--dry-run`.
1164    Push {
1165        /// Discourse name.
1166        discourse: String,
1167        /// Theme ID.
1168        theme_id: u64,
1169        /// Settings file to apply.
1170        local_path: PathBuf,
1171    },
1172}
1173
1174#[derive(Subcommand)]
1175#[command(next_display_order = None)]
1176pub enum PmCommand {
1177    /// Send a private message.
1178    #[command(visible_alias = "s")]
1179    Send {
1180        /// Discourse name.
1181        discourse: String,
1182        /// Recipient(s) — comma-separated usernames or group names.
1183        recipients: String,
1184        /// PM title / subject.
1185        #[arg(long, short = 't')]
1186        title: String,
1187        /// Input file path. Reads stdin when omitted or `-`.
1188        local_path: Option<PathBuf>,
1189    },
1190    /// List PMs for a user.
1191    #[command(visible_alias = "ls")]
1192    List {
1193        /// Discourse name.
1194        discourse: String,
1195        /// Username whose PMs to list.
1196        username: String,
1197        /// Direction / view: inbox | sent | archive | unread | new.
1198        #[arg(long, short = 'd', default_value = "inbox")]
1199        direction: String,
1200        /// Output format.
1201        #[arg(long, short = 'f', value_enum, default_value = "text")]
1202        format: ListFormat,
1203    },
1204}
1205
1206#[derive(Subcommand)]
1207#[command(next_display_order = None)]
1208pub enum ApiKeyCommand {
1209    /// List API keys.
1210    #[command(visible_alias = "ls")]
1211    List {
1212        /// Discourse name.
1213        discourse: String,
1214        /// Output format.
1215        #[arg(long, short = 'f', value_enum, default_value = "text")]
1216        format: ListFormat,
1217    },
1218    /// Create a new API key. The secret is only shown at creation time —
1219    /// capture it from the output.
1220    #[command(visible_alias = "cr")]
1221    Create {
1222        /// Discourse name.
1223        discourse: String,
1224        /// Description / label for the key (shown in admin UI).
1225        description: String,
1226        /// Username the key acts as. Omit for a global all-users key.
1227        #[arg(long, short = 'u')]
1228        username: Option<String>,
1229        /// Output format.
1230        #[arg(long, short = 'f', value_enum, default_value = "text")]
1231        format: ListFormat,
1232    },
1233    /// Revoke an API key by ID.
1234    #[command(visible_alias = "rm")]
1235    Revoke {
1236        /// Discourse name.
1237        discourse: String,
1238        /// API key ID (from `dsc api-key list`).
1239        key_id: u64,
1240    },
1241}
1242
1243#[derive(Subcommand)]
1244#[command(next_display_order = None)]
1245pub enum InviteCommand {
1246    /// Invite a single email address.
1247    #[command(visible_alias = "s")]
1248    Send {
1249        /// Discourse name.
1250        discourse: String,
1251        /// Email address to invite.
1252        email: String,
1253        /// Add invitee to one or more groups on accept (repeatable).
1254        #[arg(long, short = 'g')]
1255        group: Vec<u64>,
1256        /// Land the invitee on a specific topic on accept.
1257        #[arg(long, short = 't')]
1258        topic: Option<u64>,
1259        /// Custom invitation message.
1260        #[arg(long, short = 'm')]
1261        message: Option<String>,
1262    },
1263    /// Bulk-invite from a file (or stdin) of email addresses.
1264    #[command(visible_alias = "b")]
1265    Bulk {
1266        /// Discourse name.
1267        discourse: String,
1268        /// Path to a file of email addresses (one per line; blank lines and
1269        /// `#` comments ignored). Reads stdin when omitted or `-`.
1270        local_path: Option<PathBuf>,
1271        /// Add every invitee to one or more groups on accept (repeatable).
1272        #[arg(long, short = 'g')]
1273        group: Vec<u64>,
1274        /// Land every invitee on a specific topic on accept.
1275        #[arg(long, short = 't')]
1276        topic: Option<u64>,
1277        /// Custom invitation message attached to each invite.
1278        #[arg(long, short = 'm')]
1279        message: Option<String>,
1280    },
1281}
1282
1283#[derive(Subcommand)]
1284#[command(next_display_order = None)]
1285pub enum UserCommand {
1286    /// List users via the admin users endpoint.
1287    #[command(visible_alias = "ls")]
1288    List {
1289        /// Discourse name.
1290        discourse: String,
1291        /// Listing type: active | new | staff | suspended | silenced | staged.
1292        #[arg(long, short = 'l', default_value = "active")]
1293        listing: String,
1294        /// Page number (Discourse paginates 100 per page).
1295        #[arg(long, short = 'p', default_value_t = 1)]
1296        page: u32,
1297        /// Output format.
1298        #[arg(long, short = 'f', value_enum, default_value = "text")]
1299        format: ListFormat,
1300    },
1301    /// Show detailed info for a user.
1302    #[command(visible_alias = "i")]
1303    Info {
1304        /// Discourse name.
1305        discourse: String,
1306        /// Username.
1307        username: String,
1308        /// Output format.
1309        #[arg(long, short = 'f', value_enum, default_value = "text")]
1310        format: ListFormat,
1311    },
1312    /// Suspend a user.
1313    #[command(visible_alias = "sus")]
1314    Suspend {
1315        /// Discourse name.
1316        discourse: String,
1317        /// Username.
1318        username: String,
1319        /// When the suspension ends. ISO-8601 timestamp (e.g.
1320        /// `2026-12-31T00:00:00Z`) or `forever`.
1321        #[arg(long, short = 'u', default_value = "forever")]
1322        until: String,
1323        /// Reason shown to the user and in the audit log.
1324        #[arg(long, short = 'r', default_value = "")]
1325        reason: String,
1326    },
1327    /// Remove a suspension from a user.
1328    #[command(visible_alias = "uns")]
1329    Unsuspend {
1330        /// Discourse name.
1331        discourse: String,
1332        /// Username.
1333        username: String,
1334    },
1335    /// Silence a user (prevents posting; less visible than suspend).
1336    #[command(visible_alias = "sil")]
1337    Silence {
1338        /// Discourse name.
1339        discourse: String,
1340        /// Username.
1341        username: String,
1342        /// When the silence ends. ISO-8601 timestamp; empty means
1343        /// indefinite.
1344        #[arg(long, short = 'u', default_value = "")]
1345        until: String,
1346        /// Reason shown to the user and in the audit log.
1347        #[arg(long, short = 'r', default_value = "")]
1348        reason: String,
1349    },
1350    /// Lift a silence on a user.
1351    #[command(visible_alias = "unsil")]
1352    Unsilence {
1353        /// Discourse name.
1354        discourse: String,
1355        /// Username.
1356        username: String,
1357    },
1358    /// Grant the user the admin or moderator role.
1359    #[command(visible_alias = "pr")]
1360    Promote {
1361        /// Discourse name.
1362        discourse: String,
1363        /// Username.
1364        username: String,
1365        /// Role to grant.
1366        #[arg(long, short = 'r', value_enum)]
1367        role: RoleArg,
1368    },
1369    /// Revoke the user's admin or moderator role.
1370    #[command(visible_alias = "de")]
1371    Demote {
1372        /// Discourse name.
1373        discourse: String,
1374        /// Username.
1375        username: String,
1376        /// Role to revoke.
1377        #[arg(long, short = 'r', value_enum)]
1378        role: RoleArg,
1379    },
1380    /// Create a new user. `--approve` also marks the account approved
1381    /// (needed when site requires manual approval). Password is either
1382    /// supplied via stdin (`--password-stdin`) or omitted — in the
1383    /// latter case the user will have to set one via the reset flow.
1384    #[command(visible_alias = "cr")]
1385    Create {
1386        /// Discourse name.
1387        discourse: String,
1388        /// New user's email address.
1389        email: String,
1390        /// New user's username.
1391        username: String,
1392        /// Display name (optional).
1393        #[arg(long, short = 'N')]
1394        name: Option<String>,
1395        /// Read the password from stdin instead of auto-reset.
1396        #[arg(long)]
1397        password_stdin: bool,
1398        /// Also mark the user approved (for sites with manual approval).
1399        #[arg(long)]
1400        approve: bool,
1401    },
1402    /// Trigger Discourse's password-reset email flow for a user.
1403    #[command(name = "password-reset", visible_aliases = ["pwreset", "pw-reset"])]
1404    PasswordReset {
1405        /// Discourse name.
1406        discourse: String,
1407        /// Username or email.
1408        username: String,
1409    },
1410    /// Set a user's primary email address. Requires admin scope.
1411    #[command(name = "email-set", visible_alias = "email")]
1412    EmailSet {
1413        /// Discourse name.
1414        discourse: String,
1415        /// Username.
1416        username: String,
1417        /// New email address.
1418        email: String,
1419    },
1420    /// Show a user's recent public activity (topics + replies by default).
1421    ///
1422    /// Built for the "archive my own activity to a journal forum" loop —
1423    /// pipe the markdown output straight into `dsc topic reply`/`topic new`.
1424    #[command(visible_alias = "act")]
1425    Activity {
1426        /// Discourse name (the *source* forum to read activity from).
1427        discourse: String,
1428        /// Username whose activity to read.
1429        username: String,
1430        /// How far back to look. Accepts `7d`, `24h`, `30m`, `1w`, `90s`, or
1431        /// an ISO-8601 timestamp / date. Omit to fetch everything available.
1432        #[arg(long, short = 's')]
1433        since: Option<String>,
1434        /// Action types to include, comma-separated. Default: topics,replies.
1435        /// Also recognises: mentions, quotes, likes, edits, responses.
1436        #[arg(long, short = 't', default_value = "topics,replies")]
1437        types: String,
1438        /// Hard cap on number of items returned.
1439        #[arg(long, short = 'L')]
1440        limit: Option<u32>,
1441        /// Output format.
1442        #[arg(long, short = 'f', value_enum, default_value = "markdown")]
1443        format: ActivityFormatArg,
1444    },
1445    /// Manage a user's group memberships.
1446    #[command(visible_alias = "g")]
1447    Groups {
1448        #[command(subcommand)]
1449        command: UserGroupsCommand,
1450    },
1451}
1452
1453#[derive(ValueEnum, Clone, Copy)]
1454pub enum SectionArg {
1455    All,
1456    Growth,
1457    Activity,
1458    Health,
1459}
1460
1461#[derive(ValueEnum, Clone, Copy)]
1462pub enum AnalyticsFormat {
1463    /// Plain text (default). Fixed-width columns, no borders.
1464    Text,
1465    /// DuckDB-style box-drawing table. Falls through to `text` when
1466    /// stdout isn't a TTY.
1467    Table,
1468    /// Pretty JSON.
1469    Json,
1470    /// YAML.
1471    #[value(alias = "yml")]
1472    Yaml,
1473    /// Markdown bullet list per section.
1474    #[value(alias = "md")]
1475    Markdown,
1476    /// Markdown table per section.
1477    #[value(alias = "md-table", name = "markdown-table")]
1478    MarkdownTable,
1479    /// CSV — one row per metric.
1480    Csv,
1481}
1482
1483#[derive(ValueEnum, Clone, Copy)]
1484pub enum ActivityFormatArg {
1485    Text,
1486    Json,
1487    #[value(alias = "yml")]
1488    Yaml,
1489    #[value(alias = "md")]
1490    Markdown,
1491    Csv,
1492}
1493
1494#[derive(ValueEnum, Clone, Copy)]
1495pub enum RoleArg {
1496    Admin,
1497    Moderator,
1498}
1499
1500#[derive(Subcommand)]
1501#[command(next_display_order = None)]
1502pub enum UserGroupsCommand {
1503    /// List the groups a user belongs to.
1504    #[command(visible_alias = "ls")]
1505    List {
1506        /// Discourse name.
1507        discourse: String,
1508        /// Target username.
1509        username: String,
1510        /// Output format.
1511        #[arg(long, short = 'f', value_enum, default_value = "text")]
1512        format: ListFormat,
1513    },
1514    /// Add a user to a group.
1515    #[command(visible_alias = "a")]
1516    Add {
1517        /// Discourse name.
1518        discourse: String,
1519        /// Target username.
1520        username: String,
1521        /// Group ID.
1522        group_id: u64,
1523        /// Send Discourse notification to the user.
1524        #[arg(long)]
1525        notify: bool,
1526    },
1527    /// Remove a user from a group.
1528    #[command(visible_alias = "rm")]
1529    Remove {
1530        /// Discourse name.
1531        discourse: String,
1532        /// Target username.
1533        username: String,
1534        /// Group ID.
1535        group_id: u64,
1536    },
1537}
1538
1539#[derive(Subcommand)]
1540#[command(next_display_order = None)]
1541pub enum PostCommand {
1542    /// Pull a post's raw Markdown to a local file.
1543    #[command(visible_alias = "pl")]
1544    Pull {
1545        /// Discourse name.
1546        discourse: String,
1547        /// Post ID.
1548        post_id: u64,
1549        /// Output file path. Prints to stdout when omitted.
1550        local_path: Option<PathBuf>,
1551    },
1552    /// Push a local file to update a post (alias: edit).
1553    #[command(visible_alias = "ps", alias = "edit")]
1554    Push {
1555        /// Discourse name.
1556        discourse: String,
1557        /// Post ID.
1558        post_id: u64,
1559        /// Input file path. Reads stdin when omitted or `-`.
1560        local_path: Option<PathBuf>,
1561    },
1562    /// Delete a post by ID.
1563    #[command(visible_alias = "rm")]
1564    Delete {
1565        /// Discourse name.
1566        discourse: String,
1567        /// Post ID.
1568        post_id: u64,
1569    },
1570    /// Move a post to a different topic.
1571    #[command(visible_alias = "mv")]
1572    Move {
1573        /// Discourse name.
1574        discourse: String,
1575        /// Post ID to move.
1576        post_id: u64,
1577        /// Destination topic ID.
1578        #[arg(long = "to-topic", short = 't')]
1579        to_topic: u64,
1580    },
1581}
1582
1583#[derive(Subcommand)]
1584#[command(next_display_order = None)]
1585pub enum TagCommand {
1586    /// List every tag on the Discourse.
1587    #[command(visible_alias = "ls")]
1588    List {
1589        /// Discourse name.
1590        discourse: String,
1591        /// Output format.
1592        #[arg(long, short = 'f', value_enum, default_value = "text")]
1593        format: ListFormat,
1594    },
1595    /// Pull the tag taxonomy (tags + tag groups) to a local file.
1596    #[command(visible_alias = "pl")]
1597    Pull {
1598        /// Discourse name.
1599        discourse: String,
1600        /// Output file (default: tags.yaml). Extension determines format (.yaml/.json).
1601        #[arg(default_value = "tags.yaml")]
1602        local_path: PathBuf,
1603    },
1604    /// Push a local taxonomy file to the server (upsert; optionally prune).
1605    #[command(visible_alias = "ps")]
1606    Push {
1607        /// Discourse name.
1608        discourse: String,
1609        /// Input taxonomy file.
1610        local_path: PathBuf,
1611        /// Delete server tags/groups absent from the file.
1612        #[arg(long)]
1613        prune: bool,
1614    },
1615    /// Rename a tag, preserving topic associations.
1616    ///
1617    /// Discourse rewrites every topic's tag list in-place, so this avoids
1618    /// the delete-and-recreate pattern that loses topic membership.
1619    #[command(visible_alias = "rn")]
1620    Rename {
1621        /// Discourse name.
1622        discourse: String,
1623        /// Current tag name.
1624        old_name: String,
1625        /// New tag name.
1626        new_name: String,
1627    },
1628}
1629
1630#[derive(Subcommand)]
1631#[command(next_display_order = None)]
1632pub enum SettingCommand {
1633    /// Set a site setting on a Discourse (or all tagged Discourses).
1634    ///
1635    /// Usage:
1636    ///   dsc setting set <discourse> <setting> <value>
1637    ///   dsc setting set --tags <tag1,tag2> <setting> <value>
1638    #[command(visible_alias = "s")]
1639    Set {
1640        /// Discourse name. Required unless `--tags` is provided.
1641        discourse: Option<String>,
1642        /// Setting key. Required.
1643        setting: Option<String>,
1644        /// Setting value. Required.
1645        value: Option<String>,
1646        /// Tag filter (comma/semicolon separated, match-any). Apply across all
1647        /// Discourses matching any of the tags. When set, omit `<discourse>`
1648        /// and pass `<setting> <value>` as the only positionals.
1649        #[arg(long, value_name = "tag1,tag2")]
1650        tags: Option<String>,
1651    },
1652
1653    /// Get the current value of a site setting.
1654    #[command(visible_alias = "g")]
1655    Get {
1656        /// Discourse name.
1657        discourse: String,
1658        /// Setting key.
1659        setting: String,
1660        /// Output format.
1661        #[arg(long, short = 'f', value_enum, default_value = "text")]
1662        format: ListFormat,
1663    },
1664
1665    /// List all site settings.
1666    #[command(visible_alias = "ls")]
1667    List {
1668        /// Discourse name.
1669        discourse: String,
1670        /// Output format.
1671        #[arg(long, short = 'f', value_enum, default_value = "text")]
1672        format: ListFormat,
1673        /// Show output even when list is empty.
1674        #[arg(long, short = 'v')]
1675        verbose: bool,
1676    },
1677
1678    /// Snapshot every site setting to a file - the reference for what settings exist.
1679    ///
1680    /// See spec/setting-sync.md for the full schema and workflow. The
1681    /// generated file is a self-documenting YAML (or JSON) including each
1682    /// setting's value, default, type, category, and Discourse's own
1683    /// description - so it doubles as a catalog of available settings.
1684    #[command(visible_alias = "pl")]
1685    Pull {
1686        /// Discourse name.
1687        discourse: String,
1688        /// Output path. Format detected by extension (.json → JSON,
1689        /// otherwise YAML). Defaults to `settings.yaml`.
1690        #[arg(default_value = "settings.yaml")]
1691        local_path: PathBuf,
1692        /// Only include settings whose value differs from default. Produces
1693        /// a manageable file (~50-100 entries) suitable for version control.
1694        #[arg(long, short = 'c')]
1695        changed_only: bool,
1696        /// Limit to settings in this category (e.g. `required`, `email`,
1697        /// `security`).
1698        #[arg(long)]
1699        category: Option<String>,
1700    },
1701
1702    /// Apply a settings snapshot file to a Discourse (idempotent).
1703    ///
1704    /// Compares each setting in the file against the server and PUTs only
1705    /// values that differ. Combine with `--dry-run` to preview the plan.
1706    #[command(visible_alias = "ph")]
1707    Push {
1708        /// Discourse name.
1709        discourse: String,
1710        /// Path to the settings snapshot file (YAML or JSON).
1711        local_path: PathBuf,
1712        /// For settings present on the server but absent from the file,
1713        /// reset them to their default value. Off by default (file describes
1714        /// only the values you care about).
1715        #[arg(long)]
1716        reset_unlisted: bool,
1717    },
1718
1719    /// Compare site settings between two sources.
1720    ///
1721    /// Each source can be a Discourse name (live fetch) or a path to a
1722    /// snapshot file produced by `dsc setting pull`. Sources are detected
1723    /// by whether the argument refers to an existing file on disk; if not,
1724    /// it is treated as a Discourse name.
1725    #[command(visible_alias = "df")]
1726    Diff {
1727        /// First source: Discourse name or snapshot file path.
1728        source: String,
1729        /// Second source: Discourse name or snapshot file path.
1730        target: String,
1731        /// Filter to settings where at least one source differs from default.
1732        /// Reduces noise when most settings on both sides are still default.
1733        #[arg(long, short = 'c')]
1734        changed_only: bool,
1735        /// Limit to settings in this category (e.g. `required`, `email`).
1736        /// Only effective when both sources carry category metadata.
1737        #[arg(long)]
1738        category: Option<String>,
1739        /// Output format.
1740        #[arg(long, short = 'f', value_enum, default_value = "text")]
1741        format: ListFormat,
1742    },
1743
1744    /// Show the value of one setting across every configured forum
1745    /// (optionally filtered by `--tags`). Diff-friendly; distinct from `diff`,
1746    /// which compares two specific sources across all settings.
1747    Audit {
1748        /// Setting key.
1749        setting: String,
1750        /// Only audit forums carrying at least one of these tags
1751        /// (comma/semicolon-separated). Omit to audit every configured forum.
1752        #[arg(long, value_name = "tag1,tag2")]
1753        tags: Option<String>,
1754        /// Output format.
1755        #[arg(long, short = 'f', value_enum, default_value = "text")]
1756        format: ListFormat,
1757    },
1758}
1759
1760#[derive(Subcommand, Clone)]
1761pub enum CompletionCommand {
1762    /// Install completions for the current user.
1763    Install {
1764        /// Shell to install completions for. Detected from $SHELL when omitted.
1765        #[arg(long)]
1766        shell: Option<CompletionShell>,
1767        /// Completion directory to write to.
1768        #[arg(long, short = 'd')]
1769        dir: Option<PathBuf>,
1770    },
1771}
1772
1773#[derive(ValueEnum, Clone, Copy)]
1774pub enum CompletionShell {
1775    /// Bash shell.
1776    Bash,
1777    /// Zsh shell.
1778    Zsh,
1779    /// Fish shell.
1780    Fish,
1781    /// PowerShell.
1782    #[value(name = "powershell", alias = "power-shell")]
1783    PowerShell,
1784}
1785
1786impl From<CompletionShell> for Shell {
1787    fn from(value: CompletionShell) -> Self {
1788        match value {
1789            CompletionShell::Bash => Shell::Bash,
1790            CompletionShell::Zsh => Shell::Zsh,
1791            CompletionShell::Fish => Shell::Fish,
1792            CompletionShell::PowerShell => Shell::PowerShell,
1793        }
1794    }
1795}
1796
1797#[derive(ValueEnum, Clone)]
1798pub enum OutputFormat {
1799    /// Plain text.
1800    #[value(alias = "plaintext")]
1801    Text,
1802    /// Markdown list.
1803    Markdown,
1804    /// Markdown table.
1805    MarkdownTable,
1806    /// Pretty JSON.
1807    Json,
1808    /// YAML.
1809    #[value(alias = "yml")]
1810    Yaml,
1811    /// CSV.
1812    Csv,
1813    /// One base URL per line (pipe-friendly).
1814    #[value(alias = "url")]
1815    Urls,
1816}
1817
1818#[derive(ValueEnum, Clone, Copy)]
1819pub enum ListFormat {
1820    /// Plain text.
1821    Text,
1822    /// Pretty JSON.
1823    Json,
1824    /// YAML.
1825    #[value(alias = "yml")]
1826    Yaml,
1827}
1828
1829#[derive(ValueEnum, Clone, Copy)]
1830pub enum StructuredFormat {
1831    /// Pretty JSON.
1832    Json,
1833    /// YAML.
1834    #[value(alias = "yml")]
1835    Yaml,
1836}