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