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