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)]
8pub struct Cli {
9    /// Path to the config file. If omitted, dsc searches standard locations.
10    #[arg(long, short = 'c')]
11    pub config: Option<PathBuf>,
12    /// Describe destructive actions without sending them. Read-only commands
13    /// ignore the flag.
14    #[arg(long, short = 'n', global = true)]
15    pub dry_run: bool,
16    #[command(subcommand)]
17    pub command: Commands,
18}
19
20#[derive(Subcommand)]
21pub enum Commands {
22    /// List configured Discourses.
23    #[command(visible_alias = "ls")]
24    List {
25        /// Output format for the listing.
26        #[arg(long, short = 'f', value_enum, default_value = "text")]
27        format: OutputFormat,
28        /// Filter by tags (comma/semicolon separated, match-any).
29        #[arg(long, value_name = "tag1,tag2")]
30        tags: Option<String>,
31        /// Open each listed Discourse base URL in a browser tab/window.
32        #[arg(long, short = 'o')]
33        open: bool,
34        /// Include empty results and verbose listing details where supported.
35        #[arg(long, short = 'v')]
36        verbose: bool,
37        #[command(subcommand)]
38        command: Option<ListCommand>,
39    },
40    /// Add one or more Discourses to the config.
41    #[command(visible_alias = "a")]
42    Add {
43        /// Comma-separated discourse names to add.
44        names: String,
45        /// Prompt for additional optional fields while adding.
46        #[arg(long, short = 'i')]
47        interactive: bool,
48    },
49    /// Import Discourses from a file or stdin.
50    #[command(visible_alias = "imp")]
51    Import {
52        /// Path to import input (text/CSV). Reads stdin when omitted.
53        path: Option<PathBuf>,
54    },
55    /// Run remote OS + Discourse update workflow for one or all Discourses.
56    #[command(visible_alias = "up")]
57    Update {
58        /// Discourse name, or 'all' to update every configured Discourse.
59        name: String,
60        /// Parallel update mode for `dsc update all`.
61        #[arg(long, short = 'p')]
62        parallel: bool,
63        /// Maximum workers when parallel mode is enabled (default: 3).
64        #[arg(long, short = 'm')]
65        max: Option<usize>,
66        /// Disable changelog posting (posting prompt is on by default).
67        #[arg(long = "no-changelog", action = ArgAction::SetFalse, default_value_t = true)]
68        post_changelog: bool,
69        /// Auto-confirm changelog posting prompt (non-interactive mode).
70        #[arg(long, short = 'y')]
71        yes: bool,
72    },
73    /// Manage custom emoji.
74    #[command(visible_alias = "em")]
75    Emoji {
76        #[command(subcommand)]
77        command: EmojiCommand,
78    },
79    /// Pull/push/sync topics as local Markdown.
80    #[command(visible_alias = "t")]
81    Topic {
82        #[command(subcommand)]
83        command: TopicCommand,
84    },
85    /// List/copy/pull/push categories.
86    #[command(visible_alias = "cat")]
87    Category {
88        #[command(subcommand)]
89        command: CategoryCommand,
90    },
91    /// List/inspect/copy groups.
92    #[command(visible_alias = "grp")]
93    Group {
94        #[command(subcommand)]
95        command: GroupCommand,
96    },
97    /// Operations that act from a user's perspective.
98    #[command(visible_alias = "usr")]
99    User {
100        #[command(subcommand)]
101        command: UserCommand,
102    },
103    /// Send invites — single or bulk from a file.
104    #[command(visible_alias = "inv")]
105    Invite {
106        #[command(subcommand)]
107        command: InviteCommand,
108    },
109    /// Create/list/restore backups.
110    #[command(visible_alias = "bk")]
111    Backup {
112        #[command(subcommand)]
113        command: BackupCommand,
114    },
115    /// List/pull/push color palettes.
116    #[command(visible_alias = "pal")]
117    Palette {
118        #[command(subcommand)]
119        command: PaletteCommand,
120    },
121    /// List/install/remove plugins.
122    #[command(visible_alias = "plg")]
123    Plugin {
124        #[command(subcommand)]
125        command: PluginCommand,
126    },
127    /// List/install/remove/pull/push/duplicate themes.
128    #[command(visible_alias = "th")]
129    Theme {
130        #[command(subcommand)]
131        command: ThemeCommand,
132    },
133    /// Update site settings.
134    #[command(visible_alias = "set")]
135    Setting {
136        #[command(subcommand)]
137        command: SettingCommand,
138    },
139    /// List tags and apply/remove them on topics.
140    #[command(visible_alias = "tg")]
141    Tag {
142        #[command(subcommand)]
143        command: TagCommand,
144    },
145    /// Post-level operations: edit / delete / move.
146    #[command(visible_alias = "po")]
147    Post {
148        #[command(subcommand)]
149        command: PostCommand,
150    },
151    /// Open a Discourse in the default browser.
152    #[command(visible_alias = "o")]
153    Open {
154        /// Discourse name.
155        discourse: String,
156    },
157    /// Search topics on a Discourse.
158    #[command(visible_alias = "s")]
159    Search {
160        /// Discourse name.
161        discourse: String,
162        /// Search query (passed through verbatim, including any
163        /// Discourse filter syntax like `category:foo` or `@user`).
164        query: String,
165        /// Output format.
166        #[arg(long, short = 'f', value_enum, default_value = "text")]
167        format: ListFormat,
168    },
169    /// Upload a file. Prints the resulting upload:// short URL by default.
170    #[command(visible_alias = "u")]
171    Upload {
172        /// Discourse name.
173        discourse: String,
174        /// Path to the file to upload.
175        file: PathBuf,
176        /// Discourse upload context. Default `composer` is correct for
177        /// embedding in posts; other values include `avatar`,
178        /// `profile_background`, `card_background`, `custom_emoji`.
179        #[arg(long, short = 't', default_value = "composer")]
180        upload_type: String,
181        /// Output format. Text mode prints just the short URL.
182        #[arg(long, short = 'f', value_enum, default_value = "text")]
183        format: ListFormat,
184    },
185    /// Inspect and validate configuration.
186    #[command(visible_alias = "cfg")]
187    Config {
188        #[command(subcommand)]
189        command: ConfigCommand,
190    },
191    /// Generate shell completion scripts.
192    #[command(visible_alias = "comp")]
193    Completions {
194        /// Target shell.
195        #[arg(value_enum)]
196        shell: CompletionShell,
197        /// Output directory. Prints to stdout when omitted.
198        #[arg(long, short = 'd')]
199        dir: Option<PathBuf>,
200    },
201    /// Print the dsc version.
202    #[command(visible_alias = "ver")]
203    Version,
204}
205
206#[derive(Subcommand)]
207pub enum ConfigCommand {
208    /// Probe each configured Discourse: API auth and (optionally) SSH reachability.
209    #[command(visible_alias = "ck")]
210    Check {
211        /// Output format.
212        #[arg(long, short = 'f', value_enum, default_value = "text")]
213        format: ListFormat,
214        /// Skip the SSH reachability probe.
215        #[arg(long)]
216        skip_ssh: bool,
217    },
218}
219
220#[derive(Subcommand)]
221pub enum ListCommand {
222    /// Sort discourse entries by name and rewrite config in-place.
223    /// Also inserts placeholder values for unset template keys.
224    #[command(visible_alias = "ty")]
225    Tidy,
226}
227
228#[derive(Subcommand)]
229pub enum EmojiCommand {
230    /// Upload one emoji file, or bulk-upload from a directory.
231    #[command(visible_alias = "a")]
232    Add {
233        /// Discourse name.
234        discourse: String,
235        /// Local file or directory path.
236        emoji_path: PathBuf,
237        /// Optional emoji name (file uploads only).
238        emoji_name: Option<String>,
239    },
240
241    /// List custom emojis on a Discourse.
242    #[command(visible_alias = "ls")]
243    List {
244        /// Discourse name.
245        discourse: String,
246        /// Output format.
247        #[arg(long, short = 'f', value_enum, default_value = "text")]
248        format: ListFormat,
249        /// Include additional fields where supported.
250        #[arg(long, short = 'v')]
251        verbose: bool,
252        /// Render inline images when terminal protocol support is available.
253        #[arg(long, short = 'i')]
254        inline: bool,
255    },
256}
257
258#[derive(Subcommand)]
259pub enum TopicCommand {
260    /// Pull a topic to a local Markdown file.
261    #[command(visible_alias = "pl")]
262    Pull {
263        /// Discourse name.
264        discourse: String,
265        /// Topic ID.
266        topic_id: u64,
267        /// Destination file or directory (auto-derived when omitted).
268        local_path: Option<PathBuf>,
269    },
270    /// Push a local Markdown file to a topic.
271    #[command(visible_alias = "ps")]
272    Push {
273        /// Discourse name.
274        discourse: String,
275        /// Topic ID.
276        topic_id: u64,
277        /// Local Markdown file path.
278        local_path: PathBuf,
279    },
280    /// Sync a topic and local Markdown file using newest timestamp.
281    #[command(visible_alias = "sy")]
282    Sync {
283        /// Discourse name.
284        discourse: String,
285        /// Topic ID.
286        topic_id: u64,
287        /// Local Markdown file path.
288        local_path: PathBuf,
289        /// Skip sync confirmation prompt.
290        #[arg(long, short = 'y')]
291        yes: bool,
292    },
293    /// Reply to a topic with content from a file or stdin.
294    #[command(visible_alias = "r")]
295    Reply {
296        /// Discourse name.
297        discourse: String,
298        /// Topic ID.
299        topic_id: u64,
300        /// Input file path. Reads stdin when omitted or `-`.
301        local_path: Option<PathBuf>,
302    },
303    /// Create a new topic in a category, body from a file or stdin.
304    #[command(visible_alias = "n")]
305    New {
306        /// Discourse name.
307        discourse: String,
308        /// Target category ID.
309        category_id: u64,
310        /// Topic title.
311        #[arg(long, short = 't')]
312        title: String,
313        /// Input file path. Reads stdin when omitted or `-`.
314        local_path: Option<PathBuf>,
315    },
316}
317
318#[derive(Subcommand)]
319pub enum CategoryCommand {
320    /// List categories.
321    #[command(visible_alias = "ls")]
322    List {
323        /// Discourse name.
324        discourse: String,
325        /// Output format.
326        #[arg(long, short = 'f', value_enum, default_value = "text")]
327        format: ListFormat,
328        /// Include additional fields where supported.
329        #[arg(long, short = 'v')]
330        verbose: bool,
331        /// Show category hierarchy tree.
332        #[arg(long)]
333        tree: bool,
334    },
335    /// Copy a category to another Discourse.
336    #[command(visible_alias = "cp")]
337    Copy {
338        /// Source discourse name.
339        discourse: String,
340        /// Target discourse name (defaults to source when omitted).
341        #[arg(long, short = 't')]
342        target: Option<String>,
343        /// Category ID or slug.
344        category: String,
345    },
346    /// Pull all topics from a category into local Markdown files.
347    #[command(visible_alias = "pl")]
348    Pull {
349        /// Discourse name.
350        discourse: String,
351        /// Category ID or slug.
352        category: String,
353        /// Destination directory (auto-derived when omitted).
354        local_path: Option<PathBuf>,
355    },
356    /// Push local Markdown files into a category.
357    #[command(visible_alias = "ps")]
358    Push {
359        /// Discourse name.
360        discourse: String,
361        /// Category ID or slug.
362        category: String,
363        /// Local directory containing Markdown files.
364        local_path: PathBuf,
365    },
366}
367
368#[derive(Subcommand)]
369pub enum GroupCommand {
370    /// List groups.
371    #[command(visible_alias = "ls")]
372    List {
373        /// Discourse name.
374        discourse: String,
375        /// Output format.
376        #[arg(long, short = 'f', value_enum, default_value = "text")]
377        format: ListFormat,
378        /// Include additional fields where supported.
379        #[arg(long, short = 'v')]
380        verbose: bool,
381    },
382    /// Show group details.
383    #[command(visible_alias = "i")]
384    Info {
385        /// Discourse name.
386        discourse: String,
387        /// Group ID.
388        group: u64,
389        /// Output format.
390        #[arg(long, short = 'f', value_enum, default_value = "json")]
391        format: StructuredFormat,
392    },
393    /// List members of a group.
394    #[command(visible_alias = "m")]
395    Members {
396        /// Discourse name.
397        discourse: String,
398        /// Group ID.
399        group: u64,
400        /// Output format.
401        #[arg(long, short = 'f', value_enum, default_value = "text")]
402        format: ListFormat,
403    },
404    /// Copy a group to another Discourse.
405    #[command(visible_alias = "cp")]
406    Copy {
407        /// Source discourse name.
408        discourse: String,
409        /// Target discourse name (defaults to source when omitted).
410        #[arg(long, short = 't')]
411        target: Option<String>,
412        /// Group ID.
413        group: u64,
414    },
415    /// Bulk add members to a group from a file (or stdin) of email addresses.
416    #[command(visible_alias = "a")]
417    Add {
418        /// Discourse name.
419        discourse: String,
420        /// Group ID.
421        group: u64,
422        /// Path to a file of email addresses (one per line; blank
423        /// lines and `#` comments are ignored). Reads stdin when
424        /// omitted or `-`.
425        local_path: Option<PathBuf>,
426        /// Send Discourse notifications to added users.
427        #[arg(long)]
428        notify: bool,
429    },
430}
431
432#[derive(Subcommand)]
433pub enum BackupCommand {
434    /// Create a new backup.
435    #[command(visible_alias = "cr")]
436    Create {
437        /// Discourse name.
438        discourse: String,
439    },
440    /// List backups.
441    #[command(visible_alias = "ls")]
442    List {
443        /// Discourse name.
444        discourse: String,
445        /// Output format.
446        #[arg(long, short = 'f', value_enum, default_value = "text")]
447        format: OutputFormat,
448        /// Include additional fields where supported.
449        #[arg(long, short = 'v')]
450        verbose: bool,
451    },
452    /// Restore a backup.
453    #[command(visible_alias = "rs")]
454    Restore {
455        /// Discourse name.
456        discourse: String,
457        /// Backup filename/path on the target system.
458        backup_path: String,
459    },
460}
461
462#[derive(Subcommand)]
463pub enum PaletteCommand {
464    /// List color palettes.
465    #[command(visible_alias = "ls")]
466    List {
467        /// Discourse name.
468        discourse: String,
469        /// Output format.
470        #[arg(long, short = 'f', value_enum, default_value = "text")]
471        format: ListFormat,
472        /// Include additional fields where supported.
473        #[arg(long, short = 'v')]
474        verbose: bool,
475    },
476    /// Pull a palette to local JSON.
477    #[command(visible_alias = "pl")]
478    Pull {
479        /// Discourse name.
480        discourse: String,
481        /// Palette ID.
482        palette_id: u64,
483        /// Destination file path (auto-derived when omitted).
484        local_path: Option<PathBuf>,
485    },
486    /// Push local JSON to create or update a palette.
487    #[command(visible_alias = "ps")]
488    Push {
489        /// Discourse name.
490        discourse: String,
491        /// Local JSON file path.
492        local_path: PathBuf,
493        /// Palette ID to update (creates a new palette when omitted).
494        palette_id: Option<u64>,
495    },
496}
497
498#[derive(Subcommand)]
499pub enum PluginCommand {
500    /// List installed plugins.
501    #[command(visible_alias = "ls")]
502    List {
503        /// Discourse name.
504        discourse: String,
505        /// Output format.
506        #[arg(long, short = 'f', value_enum, default_value = "text")]
507        format: ListFormat,
508        /// Include additional fields where supported.
509        #[arg(long, short = 'v')]
510        verbose: bool,
511    },
512    /// Install a plugin from URL.
513    #[command(visible_alias = "i")]
514    Install {
515        /// Discourse name.
516        discourse: String,
517        /// Plugin repository URL.
518        url: String,
519    },
520    /// Remove a plugin by name.
521    #[command(visible_alias = "rm")]
522    Remove {
523        /// Discourse name.
524        discourse: String,
525        /// Plugin name.
526        name: String,
527    },
528}
529
530#[derive(Subcommand)]
531pub enum ThemeCommand {
532    /// List installed themes.
533    #[command(visible_alias = "ls")]
534    List {
535        /// Discourse name.
536        discourse: String,
537        /// Output format.
538        #[arg(long, short = 'f', value_enum, default_value = "text")]
539        format: ListFormat,
540        /// Include additional fields where supported.
541        #[arg(long, short = 'v')]
542        verbose: bool,
543    },
544    /// Install a theme from URL.
545    #[command(visible_alias = "i")]
546    Install {
547        /// Discourse name.
548        discourse: String,
549        /// Theme repository URL.
550        url: String,
551    },
552    /// Remove a theme by name.
553    #[command(visible_alias = "rm")]
554    Remove {
555        /// Discourse name.
556        discourse: String,
557        /// Theme name.
558        name: String,
559    },
560    /// Pull a theme to a local JSON file.
561    #[command(visible_alias = "pl")]
562    Pull {
563        /// Discourse name.
564        discourse: String,
565        /// Theme ID (from `dsc theme list`).
566        theme_id: u64,
567        /// Destination file path (auto-derived from theme name when omitted).
568        local_path: Option<PathBuf>,
569    },
570    /// Push a local JSON file to create or update a theme.
571    #[command(visible_alias = "ps")]
572    Push {
573        /// Discourse name.
574        discourse: String,
575        /// Local JSON file path.
576        local_path: PathBuf,
577        /// Theme ID to update (creates a new theme when omitted).
578        theme_id: Option<u64>,
579    },
580    /// Duplicate a theme and print the new theme ID.
581    #[command(visible_alias = "dup")]
582    Duplicate {
583        /// Discourse name.
584        discourse: String,
585        /// Theme ID to duplicate (from `dsc theme list`).
586        theme_id: u64,
587    },
588}
589
590#[derive(Subcommand)]
591pub enum InviteCommand {
592    /// Invite a single email address.
593    #[command(visible_alias = "s")]
594    Send {
595        /// Discourse name.
596        discourse: String,
597        /// Email address to invite.
598        email: String,
599        /// Add invitee to one or more groups on accept (repeatable).
600        #[arg(long, short = 'g')]
601        group: Vec<u64>,
602        /// Land the invitee on a specific topic on accept.
603        #[arg(long, short = 't')]
604        topic: Option<u64>,
605        /// Custom invitation message.
606        #[arg(long, short = 'm')]
607        message: Option<String>,
608    },
609    /// Bulk-invite from a file (or stdin) of email addresses.
610    #[command(visible_alias = "b")]
611    Bulk {
612        /// Discourse name.
613        discourse: String,
614        /// Path to a file of email addresses (one per line; blank lines and
615        /// `#` comments ignored). Reads stdin when omitted or `-`.
616        local_path: Option<PathBuf>,
617        /// Add every invitee to one or more groups on accept (repeatable).
618        #[arg(long, short = 'g')]
619        group: Vec<u64>,
620        /// Land every invitee on a specific topic on accept.
621        #[arg(long, short = 't')]
622        topic: Option<u64>,
623        /// Custom invitation message attached to each invite.
624        #[arg(long, short = 'm')]
625        message: Option<String>,
626    },
627}
628
629#[derive(Subcommand)]
630pub enum UserCommand {
631    /// List users via the admin users endpoint.
632    #[command(visible_alias = "ls")]
633    List {
634        /// Discourse name.
635        discourse: String,
636        /// Listing type: active | new | staff | suspended | silenced | staged.
637        #[arg(long, short = 'l', default_value = "active")]
638        listing: String,
639        /// Page number (Discourse paginates 100 per page).
640        #[arg(long, short = 'p', default_value_t = 1)]
641        page: u32,
642        /// Output format.
643        #[arg(long, short = 'f', value_enum, default_value = "text")]
644        format: ListFormat,
645    },
646    /// Show detailed info for a user.
647    #[command(visible_alias = "i")]
648    Info {
649        /// Discourse name.
650        discourse: String,
651        /// Username.
652        username: String,
653        /// Output format.
654        #[arg(long, short = 'f', value_enum, default_value = "text")]
655        format: ListFormat,
656    },
657    /// Suspend a user.
658    #[command(visible_alias = "sus")]
659    Suspend {
660        /// Discourse name.
661        discourse: String,
662        /// Username.
663        username: String,
664        /// When the suspension ends. ISO-8601 timestamp (e.g.
665        /// `2026-12-31T00:00:00Z`) or `forever`.
666        #[arg(long, short = 'u', default_value = "forever")]
667        until: String,
668        /// Reason shown to the user and in the audit log.
669        #[arg(long, short = 'r', default_value = "")]
670        reason: String,
671    },
672    /// Remove a suspension from a user.
673    #[command(visible_alias = "uns")]
674    Unsuspend {
675        /// Discourse name.
676        discourse: String,
677        /// Username.
678        username: String,
679    },
680    /// Silence a user (prevents posting; less visible than suspend).
681    #[command(visible_alias = "sil")]
682    Silence {
683        /// Discourse name.
684        discourse: String,
685        /// Username.
686        username: String,
687        /// When the silence ends. ISO-8601 timestamp; empty means
688        /// indefinite.
689        #[arg(long, short = 'u', default_value = "")]
690        until: String,
691        /// Reason shown to the user and in the audit log.
692        #[arg(long, short = 'r', default_value = "")]
693        reason: String,
694    },
695    /// Lift a silence on a user.
696    #[command(visible_alias = "unsil")]
697    Unsilence {
698        /// Discourse name.
699        discourse: String,
700        /// Username.
701        username: String,
702    },
703    /// Grant the user the admin or moderator role.
704    #[command(visible_alias = "pr")]
705    Promote {
706        /// Discourse name.
707        discourse: String,
708        /// Username.
709        username: String,
710        /// Role to grant.
711        #[arg(long, short = 'r', value_enum)]
712        role: RoleArg,
713    },
714    /// Revoke the user's admin or moderator role.
715    #[command(visible_alias = "de")]
716    Demote {
717        /// Discourse name.
718        discourse: String,
719        /// Username.
720        username: String,
721        /// Role to revoke.
722        #[arg(long, short = 'r', value_enum)]
723        role: RoleArg,
724    },
725    /// Manage a user's group memberships.
726    #[command(visible_alias = "g")]
727    Groups {
728        #[command(subcommand)]
729        command: UserGroupsCommand,
730    },
731}
732
733#[derive(ValueEnum, Clone, Copy)]
734pub enum RoleArg {
735    Admin,
736    Moderator,
737}
738
739#[derive(Subcommand)]
740pub enum UserGroupsCommand {
741    /// List the groups a user belongs to.
742    #[command(visible_alias = "ls")]
743    List {
744        /// Discourse name.
745        discourse: String,
746        /// Target username.
747        username: String,
748        /// Output format.
749        #[arg(long, short = 'f', value_enum, default_value = "text")]
750        format: ListFormat,
751    },
752    /// Add a user to a group.
753    #[command(visible_alias = "a")]
754    Add {
755        /// Discourse name.
756        discourse: String,
757        /// Target username.
758        username: String,
759        /// Group ID.
760        group_id: u64,
761        /// Send Discourse notification to the user.
762        #[arg(long)]
763        notify: bool,
764    },
765    /// Remove a user from a group.
766    #[command(visible_alias = "rm")]
767    Remove {
768        /// Discourse name.
769        discourse: String,
770        /// Target username.
771        username: String,
772        /// Group ID.
773        group_id: u64,
774    },
775}
776
777#[derive(Subcommand)]
778pub enum PostCommand {
779    /// Edit a post by ID. Reads the new body from file or stdin.
780    #[command(visible_alias = "e")]
781    Edit {
782        /// Discourse name.
783        discourse: String,
784        /// Post ID.
785        post_id: u64,
786        /// Input file path. Reads stdin when omitted or `-`.
787        local_path: Option<PathBuf>,
788    },
789    /// Delete a post by ID.
790    #[command(visible_alias = "rm")]
791    Delete {
792        /// Discourse name.
793        discourse: String,
794        /// Post ID.
795        post_id: u64,
796    },
797    /// Move a post to a different topic.
798    #[command(visible_alias = "mv")]
799    Move {
800        /// Discourse name.
801        discourse: String,
802        /// Post ID to move.
803        post_id: u64,
804        /// Destination topic ID.
805        #[arg(long = "to-topic", short = 't')]
806        to_topic: u64,
807    },
808}
809
810#[derive(Subcommand)]
811pub enum TagCommand {
812    /// List every tag on the Discourse.
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    },
821    /// Add a tag to a topic.
822    #[command(visible_alias = "a")]
823    Apply {
824        /// Discourse name.
825        discourse: String,
826        /// Topic ID.
827        topic_id: u64,
828        /// Tag to add.
829        tag: String,
830    },
831    /// Remove a tag from a topic.
832    #[command(visible_alias = "rm")]
833    Remove {
834        /// Discourse name.
835        discourse: String,
836        /// Topic ID.
837        topic_id: u64,
838        /// Tag to remove.
839        tag: String,
840    },
841}
842
843#[derive(Subcommand)]
844pub enum SettingCommand {
845    /// Set a site setting on a Discourse (or all tagged Discourses).
846    #[command(visible_alias = "s")]
847    Set {
848        /// Discourse name. Required when targeting a single discourse.
849        discourse: String,
850        /// Setting key.
851        setting: String,
852        /// Setting value.
853        value: String,
854        /// Optional tag filter (comma/semicolon separated, match-any). Ignored when discourse is specified.
855        #[arg(long, value_name = "tag1,tag2")]
856        tags: Option<String>,
857    },
858
859    /// Get the current value of a site setting.
860    #[command(visible_alias = "g")]
861    Get {
862        /// Discourse name.
863        discourse: String,
864        /// Setting key.
865        setting: String,
866    },
867
868    /// List all site settings.
869    #[command(visible_alias = "ls")]
870    List {
871        /// Discourse name.
872        discourse: String,
873        /// Output format.
874        #[arg(long, short = 'f', value_enum, default_value = "text")]
875        format: ListFormat,
876        /// Show output even when list is empty.
877        #[arg(long, short = 'v')]
878        verbose: bool,
879    },
880}
881
882#[derive(ValueEnum, Clone, Copy)]
883pub enum CompletionShell {
884    /// Bash shell.
885    Bash,
886    /// Zsh shell.
887    Zsh,
888    /// Fish shell.
889    Fish,
890}
891
892impl From<CompletionShell> for Shell {
893    fn from(value: CompletionShell) -> Self {
894        match value {
895            CompletionShell::Bash => Shell::Bash,
896            CompletionShell::Zsh => Shell::Zsh,
897            CompletionShell::Fish => Shell::Fish,
898        }
899    }
900}
901
902#[derive(ValueEnum, Clone)]
903pub enum OutputFormat {
904    /// Plain text.
905    #[value(alias = "plaintext")]
906    Text,
907    /// Markdown list.
908    Markdown,
909    /// Markdown table.
910    MarkdownTable,
911    /// Pretty JSON.
912    Json,
913    /// YAML.
914    #[value(alias = "yml")]
915    Yaml,
916    /// CSV.
917    Csv,
918    /// One base URL per line (pipe-friendly).
919    #[value(alias = "url")]
920    Urls,
921}
922
923#[derive(ValueEnum, Clone, Copy)]
924pub enum ListFormat {
925    /// Plain text.
926    Text,
927    /// Pretty JSON.
928    Json,
929    /// YAML.
930    #[value(alias = "yml")]
931    Yaml,
932}
933
934#[derive(ValueEnum, Clone, Copy)]
935pub enum StructuredFormat {
936    /// Pretty JSON.
937    Json,
938    /// YAML.
939    #[value(alias = "yml")]
940    Yaml,
941}