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