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