Skip to main content

dsc/
cli.rs

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