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