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    /// Create a new user. `--approve` also marks the account approved
805    /// (needed when site requires manual approval). Password is either
806    /// supplied via stdin (`--password-stdin`) or omitted — in the
807    /// latter case the user will have to set one via the reset flow.
808    #[command(visible_alias = "cr")]
809    Create {
810        /// Discourse name.
811        discourse: String,
812        /// New user's email address.
813        email: String,
814        /// New user's username.
815        username: String,
816        /// Display name (optional).
817        #[arg(long, short = 'N')]
818        name: Option<String>,
819        /// Read the password from stdin instead of auto-reset.
820        #[arg(long)]
821        password_stdin: bool,
822        /// Also mark the user approved (for sites with manual approval).
823        #[arg(long)]
824        approve: bool,
825    },
826    /// Trigger Discourse's password-reset email flow for a user.
827    #[command(name = "password-reset", visible_aliases = ["pwreset", "pw-reset"])]
828    PasswordReset {
829        /// Discourse name.
830        discourse: String,
831        /// Username or email.
832        username: String,
833    },
834    /// Set a user's primary email address. Requires admin scope.
835    #[command(name = "email-set", visible_alias = "email")]
836    EmailSet {
837        /// Discourse name.
838        discourse: String,
839        /// Username.
840        username: String,
841        /// New email address.
842        email: String,
843    },
844    /// Show a user's recent public activity (topics + replies by default).
845    ///
846    /// Built for the "archive my own activity to a journal forum" loop —
847    /// pipe the markdown output straight into `dsc topic reply`/`topic new`.
848    #[command(visible_alias = "act")]
849    Activity {
850        /// Discourse name (the *source* forum to read activity from).
851        discourse: String,
852        /// Username whose activity to read.
853        username: String,
854        /// How far back to look. Accepts `7d`, `24h`, `30m`, `1w`, `90s`, or
855        /// an ISO-8601 timestamp / date. Omit to fetch everything available.
856        #[arg(long, short = 's')]
857        since: Option<String>,
858        /// Action types to include, comma-separated. Default: topics,replies.
859        /// Also recognises: mentions, quotes, likes, edits, responses.
860        #[arg(long, short = 't', default_value = "topics,replies")]
861        types: String,
862        /// Hard cap on number of items returned.
863        #[arg(long, short = 'L')]
864        limit: Option<u32>,
865        /// Output format.
866        #[arg(long, short = 'f', value_enum, default_value = "markdown")]
867        format: ActivityFormatArg,
868    },
869    /// Manage a user's group memberships.
870    #[command(visible_alias = "g")]
871    Groups {
872        #[command(subcommand)]
873        command: UserGroupsCommand,
874    },
875}
876
877#[derive(ValueEnum, Clone, Copy)]
878pub enum ActivityFormatArg {
879    Text,
880    Json,
881    #[value(alias = "yml")]
882    Yaml,
883    #[value(alias = "md")]
884    Markdown,
885    Csv,
886}
887
888#[derive(ValueEnum, Clone, Copy)]
889pub enum RoleArg {
890    Admin,
891    Moderator,
892}
893
894#[derive(Subcommand)]
895pub enum UserGroupsCommand {
896    /// List the groups a user belongs to.
897    #[command(visible_alias = "ls")]
898    List {
899        /// Discourse name.
900        discourse: String,
901        /// Target username.
902        username: String,
903        /// Output format.
904        #[arg(long, short = 'f', value_enum, default_value = "text")]
905        format: ListFormat,
906    },
907    /// Add a user to a group.
908    #[command(visible_alias = "a")]
909    Add {
910        /// Discourse name.
911        discourse: String,
912        /// Target username.
913        username: String,
914        /// Group ID.
915        group_id: u64,
916        /// Send Discourse notification to the user.
917        #[arg(long)]
918        notify: bool,
919    },
920    /// Remove a user from a group.
921    #[command(visible_alias = "rm")]
922    Remove {
923        /// Discourse name.
924        discourse: String,
925        /// Target username.
926        username: String,
927        /// Group ID.
928        group_id: u64,
929    },
930}
931
932#[derive(Subcommand)]
933pub enum PostCommand {
934    /// Edit a post by ID. Reads the new body from file or stdin.
935    #[command(visible_alias = "e")]
936    Edit {
937        /// Discourse name.
938        discourse: String,
939        /// Post ID.
940        post_id: u64,
941        /// Input file path. Reads stdin when omitted or `-`.
942        local_path: Option<PathBuf>,
943    },
944    /// Delete a post by ID.
945    #[command(visible_alias = "rm")]
946    Delete {
947        /// Discourse name.
948        discourse: String,
949        /// Post ID.
950        post_id: u64,
951    },
952    /// Move a post to a different topic.
953    #[command(visible_alias = "mv")]
954    Move {
955        /// Discourse name.
956        discourse: String,
957        /// Post ID to move.
958        post_id: u64,
959        /// Destination topic ID.
960        #[arg(long = "to-topic", short = 't')]
961        to_topic: u64,
962    },
963}
964
965#[derive(Subcommand)]
966pub enum TagCommand {
967    /// List every tag on the Discourse.
968    #[command(visible_alias = "ls")]
969    List {
970        /// Discourse name.
971        discourse: String,
972        /// Output format.
973        #[arg(long, short = 'f', value_enum, default_value = "text")]
974        format: ListFormat,
975    },
976    /// Add a tag to a topic.
977    #[command(visible_alias = "a")]
978    Apply {
979        /// Discourse name.
980        discourse: String,
981        /// Topic ID.
982        topic_id: u64,
983        /// Tag to add.
984        tag: String,
985    },
986    /// Remove a tag from a topic.
987    #[command(visible_alias = "rm")]
988    Remove {
989        /// Discourse name.
990        discourse: String,
991        /// Topic ID.
992        topic_id: u64,
993        /// Tag to remove.
994        tag: String,
995    },
996}
997
998#[derive(Subcommand)]
999pub enum SettingCommand {
1000    /// Set a site setting on a Discourse (or all tagged Discourses).
1001    #[command(visible_alias = "s")]
1002    Set {
1003        /// Discourse name. Required when targeting a single discourse.
1004        discourse: String,
1005        /// Setting key.
1006        setting: String,
1007        /// Setting value.
1008        value: String,
1009        /// Optional tag filter (comma/semicolon separated, match-any). Ignored when discourse is specified.
1010        #[arg(long, value_name = "tag1,tag2")]
1011        tags: Option<String>,
1012    },
1013
1014    /// Get the current value of a site setting.
1015    #[command(visible_alias = "g")]
1016    Get {
1017        /// Discourse name.
1018        discourse: String,
1019        /// Setting key.
1020        setting: String,
1021    },
1022
1023    /// List all site settings.
1024    #[command(visible_alias = "ls")]
1025    List {
1026        /// Discourse name.
1027        discourse: String,
1028        /// Output format.
1029        #[arg(long, short = 'f', value_enum, default_value = "text")]
1030        format: ListFormat,
1031        /// Show output even when list is empty.
1032        #[arg(long, short = 'v')]
1033        verbose: bool,
1034    },
1035}
1036
1037#[derive(ValueEnum, Clone, Copy)]
1038pub enum CompletionShell {
1039    /// Bash shell.
1040    Bash,
1041    /// Zsh shell.
1042    Zsh,
1043    /// Fish shell.
1044    Fish,
1045}
1046
1047impl From<CompletionShell> for Shell {
1048    fn from(value: CompletionShell) -> Self {
1049        match value {
1050            CompletionShell::Bash => Shell::Bash,
1051            CompletionShell::Zsh => Shell::Zsh,
1052            CompletionShell::Fish => Shell::Fish,
1053        }
1054    }
1055}
1056
1057#[derive(ValueEnum, Clone)]
1058pub enum OutputFormat {
1059    /// Plain text.
1060    #[value(alias = "plaintext")]
1061    Text,
1062    /// Markdown list.
1063    Markdown,
1064    /// Markdown table.
1065    MarkdownTable,
1066    /// Pretty JSON.
1067    Json,
1068    /// YAML.
1069    #[value(alias = "yml")]
1070    Yaml,
1071    /// CSV.
1072    Csv,
1073    /// One base URL per line (pipe-friendly).
1074    #[value(alias = "url")]
1075    Urls,
1076}
1077
1078#[derive(ValueEnum, Clone, Copy)]
1079pub enum ListFormat {
1080    /// Plain text.
1081    Text,
1082    /// Pretty JSON.
1083    Json,
1084    /// YAML.
1085    #[value(alias = "yml")]
1086    Yaml,
1087}
1088
1089#[derive(ValueEnum, Clone, Copy)]
1090pub enum StructuredFormat {
1091    /// Pretty JSON.
1092    Json,
1093    /// YAML.
1094    #[value(alias = "yml")]
1095    Yaml,
1096}