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