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    /// Harden a fresh Ubuntu server reachable via `ssh root@host`.
170    ///
171    /// **Stage 1 (current):** creates a non-root sudo user, installs the
172    /// given pubkey to their authorized_keys, and verifies the new-user
173    /// SSH login works. Does NOT yet tighten sshd_config, install Docker
174    /// / fail2ban / etc — those come in follow-up releases.
175    ///
176    /// Defaults can be overridden in the `[harden]` block of dsc.toml;
177    /// the flags below override that block on a per-run basis.
178    #[command(visible_alias = "hd")]
179    Harden {
180        /// Target hostname or IP (reachable via SSH).
181        host: String,
182        /// Username to SSH in as initially. Defaults to `root`, which is
183        /// what a fresh cloud-provisioned box typically has.
184        #[arg(long, default_value = "root")]
185        ssh_user: String,
186        /// Username for the new sudo-enabled non-root account. Overrides
187        /// `[harden].new_user` from dsc.toml. Built-in default: `discourse`.
188        #[arg(long)]
189        new_user: Option<String>,
190        /// SSH port to move the daemon to in stage 2. Overrides
191        /// `[harden].ssh_port`. Built-in default: 2227. Parsed now so the
192        /// CLI is stable; not yet applied in stage 1.
193        #[arg(long)]
194        ssh_port: Option<u16>,
195        /// Path to an SSH public key file whose contents will be added to
196        /// the new user's authorized_keys. A typical value is
197        /// `~/.ssh/<hostname>.pub` — the per-server keypair pattern in
198        /// the Bawmedical hardening playbook.
199        #[arg(long)]
200        pubkey_file: PathBuf,
201    },
202    /// Community-health analytics — growth, activity, and health metrics
203    /// for a Discourse, with optional period-over-period comparison.
204    ///
205    /// See `spec/analytics.md` for the full spec. v1 ships every metric
206    /// that maps onto a single `/admin/reports/{id}.json` endpoint;
207    /// derivation-heavy ones (e.g. lost regulars, top-10 share) print
208    /// `— (n/i)` until follow-up implementation lands.
209    #[command(visible_alias = "stats")]
210    Analytics {
211        /// Discourse name.
212        discourse: String,
213        /// Window to report on. Same syntax as `dsc user activity --since`
214        /// (e.g. `7d`, `24h`, `1m`, ISO-8601). Ignored when `--snapshot`
215        /// is set. Default: 30d.
216        #[arg(long, short = 's', default_value = "30d")]
217        since: String,
218        /// Also fetch the immediately preceding window of equal length and
219        /// show a delta column. Mutually exclusive with `--snapshot`.
220        #[arg(long, short = 'c', conflicts_with = "snapshot")]
221        compare: bool,
222        /// Multi-window snapshot mode. Reports each metric across several
223        /// preset windows (`--periods`) so you see growth/health trends
224        /// at a glance. Replaces `--since` + `--compare`.
225        #[arg(long)]
226        snapshot: bool,
227        /// Comma-separated periods for `--snapshot`. Default: `24h,7d,30d,1y`.
228        #[arg(long, requires = "snapshot")]
229        periods: Option<String>,
230        /// Restrict output to one section.
231        #[arg(long, value_enum, default_value = "all")]
232        section: SectionArg,
233        /// Output format. `table` is DuckDB-style box-drawing; falls
234        /// through to `text` automatically when stdout isn't a TTY.
235        #[arg(long, short = 'f', value_enum, default_value = "text")]
236        format: AnalyticsFormat,
237    },
238    /// Search topics on a Discourse.
239    #[command(visible_alias = "s")]
240    Search {
241        /// Discourse name.
242        discourse: String,
243        /// Search query (passed through verbatim, including any
244        /// Discourse filter syntax like `category:foo` or `@user`).
245        query: String,
246        /// Output format.
247        #[arg(long, short = 'f', value_enum, default_value = "text")]
248        format: ListFormat,
249    },
250    /// Upload a file. Prints the resulting upload:// short URL by default.
251    #[command(visible_alias = "u")]
252    Upload {
253        /// Discourse name.
254        discourse: String,
255        /// Path to the file to upload.
256        file: PathBuf,
257        /// Discourse upload context. Default `composer` is correct for
258        /// embedding in posts; other values include `avatar`,
259        /// `profile_background`, `card_background`, `custom_emoji`.
260        #[arg(long, short = 't', default_value = "composer")]
261        upload_type: String,
262        /// Output format. Text mode prints just the short URL.
263        #[arg(long, short = 'f', value_enum, default_value = "text")]
264        format: ListFormat,
265    },
266    /// Inspect and validate configuration.
267    #[command(visible_alias = "cfg")]
268    Config {
269        #[command(subcommand)]
270        command: ConfigCommand,
271    },
272    /// Generate shell completion scripts.
273    #[command(visible_alias = "comp")]
274    Completions {
275        /// Target shell.
276        #[arg(value_enum)]
277        shell: CompletionShell,
278        /// Output directory. Prints to stdout when omitted.
279        #[arg(long, short = 'd')]
280        dir: Option<PathBuf>,
281    },
282    /// Print the dsc version.
283    #[command(visible_alias = "ver")]
284    Version,
285}
286
287#[derive(Subcommand)]
288pub enum ConfigCommand {
289    /// Probe each configured Discourse: API auth and (optionally) SSH reachability.
290    #[command(visible_alias = "ck")]
291    Check {
292        /// Output format.
293        #[arg(long, short = 'f', value_enum, default_value = "text")]
294        format: ListFormat,
295        /// Skip the SSH reachability probe.
296        #[arg(long)]
297        skip_ssh: bool,
298    },
299}
300
301#[derive(Subcommand)]
302pub enum ListCommand {
303    /// Sort discourse entries by name and rewrite config in-place.
304    /// Also inserts placeholder values for unset template keys.
305    #[command(visible_alias = "ty")]
306    Tidy,
307}
308
309#[derive(Subcommand)]
310pub enum EmojiCommand {
311    /// Upload one emoji file, or bulk-upload from a directory.
312    #[command(visible_alias = "a")]
313    Add {
314        /// Discourse name.
315        discourse: String,
316        /// Local file or directory path.
317        emoji_path: PathBuf,
318        /// Optional emoji name (file uploads only).
319        emoji_name: Option<String>,
320    },
321
322    /// List custom emojis on a Discourse.
323    #[command(visible_alias = "ls")]
324    List {
325        /// Discourse name.
326        discourse: String,
327        /// Output format.
328        #[arg(long, short = 'f', value_enum, default_value = "text")]
329        format: ListFormat,
330        /// Include additional fields where supported.
331        #[arg(long, short = 'v')]
332        verbose: bool,
333        /// Render inline images when terminal protocol support is available.
334        #[arg(long, short = 'i')]
335        inline: bool,
336    },
337}
338
339#[derive(Subcommand)]
340pub enum TopicCommand {
341    /// Pull a topic to a local Markdown file.
342    #[command(visible_alias = "pl")]
343    Pull {
344        /// Discourse name.
345        discourse: String,
346        /// Topic ID.
347        topic_id: u64,
348        /// Destination file or directory (auto-derived when omitted).
349        local_path: Option<PathBuf>,
350    },
351    /// Push a local Markdown file to a topic.
352    #[command(visible_alias = "ps")]
353    Push {
354        /// Discourse name.
355        discourse: String,
356        /// Topic ID.
357        topic_id: u64,
358        /// Local Markdown file path.
359        local_path: PathBuf,
360    },
361    /// Sync a topic and local Markdown file using newest timestamp.
362    #[command(visible_alias = "sy")]
363    Sync {
364        /// Discourse name.
365        discourse: String,
366        /// Topic ID.
367        topic_id: u64,
368        /// Local Markdown file path.
369        local_path: PathBuf,
370        /// Skip sync confirmation prompt.
371        #[arg(long, short = 'y')]
372        yes: bool,
373    },
374    /// Reply to a topic with content from a file or stdin.
375    #[command(visible_alias = "r")]
376    Reply {
377        /// Discourse name.
378        discourse: String,
379        /// Topic ID.
380        topic_id: u64,
381        /// Input file path. Reads stdin when omitted or `-`.
382        local_path: Option<PathBuf>,
383    },
384    /// Create a new topic in a category, body from a file or stdin.
385    #[command(visible_alias = "n")]
386    New {
387        /// Discourse name.
388        discourse: String,
389        /// Target category ID.
390        category_id: u64,
391        /// Topic title.
392        #[arg(long, short = 't')]
393        title: String,
394        /// Input file path. Reads stdin when omitted or `-`.
395        local_path: Option<PathBuf>,
396    },
397}
398
399#[derive(Subcommand)]
400pub enum CategoryCommand {
401    /// List categories.
402    #[command(visible_alias = "ls")]
403    List {
404        /// Discourse name.
405        discourse: String,
406        /// Output format.
407        #[arg(long, short = 'f', value_enum, default_value = "text")]
408        format: ListFormat,
409        /// Include additional fields where supported.
410        #[arg(long, short = 'v')]
411        verbose: bool,
412        /// Show category hierarchy tree.
413        #[arg(long)]
414        tree: bool,
415    },
416    /// Copy a category 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        /// Category ID or slug.
425        category: String,
426    },
427    /// Pull all topics from a category into local Markdown files.
428    #[command(visible_alias = "pl")]
429    Pull {
430        /// Discourse name.
431        discourse: String,
432        /// Category ID or slug.
433        category: String,
434        /// Destination directory (auto-derived when omitted).
435        local_path: Option<PathBuf>,
436    },
437    /// Push local Markdown files into a category.
438    #[command(visible_alias = "ps")]
439    Push {
440        /// Discourse name.
441        discourse: String,
442        /// Category ID or slug.
443        category: String,
444        /// Local directory containing Markdown files.
445        local_path: PathBuf,
446    },
447}
448
449#[derive(Subcommand)]
450pub enum GroupCommand {
451    /// List groups.
452    #[command(visible_alias = "ls")]
453    List {
454        /// Discourse name.
455        discourse: String,
456        /// Output format.
457        #[arg(long, short = 'f', value_enum, default_value = "text")]
458        format: ListFormat,
459        /// Include additional fields where supported.
460        #[arg(long, short = 'v')]
461        verbose: bool,
462    },
463    /// Show group details.
464    #[command(visible_alias = "i")]
465    Info {
466        /// Discourse name.
467        discourse: String,
468        /// Group ID.
469        group: u64,
470        /// Output format.
471        #[arg(long, short = 'f', value_enum, default_value = "json")]
472        format: StructuredFormat,
473    },
474    /// List members of a group.
475    #[command(visible_alias = "m")]
476    Members {
477        /// Discourse name.
478        discourse: String,
479        /// Group ID.
480        group: u64,
481        /// Output format.
482        #[arg(long, short = 'f', value_enum, default_value = "text")]
483        format: ListFormat,
484    },
485    /// Copy a group to another Discourse.
486    #[command(visible_alias = "cp")]
487    Copy {
488        /// Source discourse name.
489        discourse: String,
490        /// Target discourse name (defaults to source when omitted).
491        #[arg(long, short = 't')]
492        target: Option<String>,
493        /// Group ID.
494        group: u64,
495    },
496    /// Bulk add members to a group from a file (or stdin) of email addresses.
497    #[command(visible_alias = "a")]
498    Add {
499        /// Discourse name.
500        discourse: String,
501        /// Group ID.
502        group: u64,
503        /// Path to a file of email addresses (one per line; blank
504        /// lines and `#` comments are ignored). Reads stdin when
505        /// omitted or `-`.
506        local_path: Option<PathBuf>,
507        /// Send Discourse notifications to added users.
508        #[arg(long)]
509        notify: bool,
510    },
511}
512
513#[derive(Subcommand)]
514pub enum BackupCommand {
515    /// Create a new backup.
516    #[command(visible_alias = "cr")]
517    Create {
518        /// Discourse name.
519        discourse: String,
520    },
521    /// List backups.
522    #[command(visible_alias = "ls")]
523    List {
524        /// Discourse name.
525        discourse: String,
526        /// Output format.
527        #[arg(long, short = 'f', value_enum, default_value = "text")]
528        format: OutputFormat,
529        /// Include additional fields where supported.
530        #[arg(long, short = 'v')]
531        verbose: bool,
532    },
533    /// Restore a backup.
534    #[command(visible_alias = "rs")]
535    Restore {
536        /// Discourse name.
537        discourse: String,
538        /// Backup filename/path on the target system.
539        backup_path: String,
540    },
541}
542
543#[derive(Subcommand)]
544pub enum PaletteCommand {
545    /// List color palettes.
546    #[command(visible_alias = "ls")]
547    List {
548        /// Discourse name.
549        discourse: String,
550        /// Output format.
551        #[arg(long, short = 'f', value_enum, default_value = "text")]
552        format: ListFormat,
553        /// Include additional fields where supported.
554        #[arg(long, short = 'v')]
555        verbose: bool,
556    },
557    /// Pull a palette to local JSON.
558    #[command(visible_alias = "pl")]
559    Pull {
560        /// Discourse name.
561        discourse: String,
562        /// Palette ID.
563        palette_id: u64,
564        /// Destination file path (auto-derived when omitted).
565        local_path: Option<PathBuf>,
566    },
567    /// Push local JSON to create or update a palette.
568    #[command(visible_alias = "ps")]
569    Push {
570        /// Discourse name.
571        discourse: String,
572        /// Local JSON file path.
573        local_path: PathBuf,
574        /// Palette ID to update (creates a new palette when omitted).
575        palette_id: Option<u64>,
576    },
577}
578
579#[derive(Subcommand)]
580pub enum PluginCommand {
581    /// List installed plugins.
582    #[command(visible_alias = "ls")]
583    List {
584        /// Discourse name.
585        discourse: String,
586        /// Output format.
587        #[arg(long, short = 'f', value_enum, default_value = "text")]
588        format: ListFormat,
589        /// Include additional fields where supported.
590        #[arg(long, short = 'v')]
591        verbose: bool,
592    },
593    /// Install a plugin from URL.
594    #[command(visible_alias = "i")]
595    Install {
596        /// Discourse name.
597        discourse: String,
598        /// Plugin repository URL.
599        url: String,
600    },
601    /// Remove a plugin by name.
602    #[command(visible_alias = "rm")]
603    Remove {
604        /// Discourse name.
605        discourse: String,
606        /// Plugin name.
607        name: String,
608    },
609}
610
611#[derive(Subcommand)]
612pub enum ThemeCommand {
613    /// List installed themes.
614    #[command(visible_alias = "ls")]
615    List {
616        /// Discourse name.
617        discourse: String,
618        /// Output format.
619        #[arg(long, short = 'f', value_enum, default_value = "text")]
620        format: ListFormat,
621        /// Include additional fields where supported.
622        #[arg(long, short = 'v')]
623        verbose: bool,
624    },
625    /// Install a theme from URL.
626    #[command(visible_alias = "i")]
627    Install {
628        /// Discourse name.
629        discourse: String,
630        /// Theme repository URL.
631        url: String,
632    },
633    /// Remove a theme by name.
634    #[command(visible_alias = "rm")]
635    Remove {
636        /// Discourse name.
637        discourse: String,
638        /// Theme name.
639        name: String,
640    },
641    /// Pull a theme to a local JSON file.
642    #[command(visible_alias = "pl")]
643    Pull {
644        /// Discourse name.
645        discourse: String,
646        /// Theme ID (from `dsc theme list`).
647        theme_id: u64,
648        /// Destination file path (auto-derived from theme name when omitted).
649        local_path: Option<PathBuf>,
650    },
651    /// Push a local JSON file to create or update a theme.
652    #[command(visible_alias = "ps")]
653    Push {
654        /// Discourse name.
655        discourse: String,
656        /// Local JSON file path.
657        local_path: PathBuf,
658        /// Theme ID to update (creates a new theme when omitted).
659        theme_id: Option<u64>,
660    },
661    /// Duplicate a theme and print the new theme ID.
662    #[command(visible_alias = "dup")]
663    Duplicate {
664        /// Discourse name.
665        discourse: String,
666        /// Theme ID to duplicate (from `dsc theme list`).
667        theme_id: u64,
668    },
669}
670
671#[derive(Subcommand)]
672pub enum PmCommand {
673    /// Send a private message.
674    #[command(visible_alias = "s")]
675    Send {
676        /// Discourse name.
677        discourse: String,
678        /// Recipient(s) — comma-separated usernames or group names.
679        recipients: String,
680        /// PM title / subject.
681        #[arg(long, short = 't')]
682        title: String,
683        /// Input file path. Reads stdin when omitted or `-`.
684        local_path: Option<PathBuf>,
685    },
686    /// List PMs for a user.
687    #[command(visible_alias = "ls")]
688    List {
689        /// Discourse name.
690        discourse: String,
691        /// Username whose PMs to list.
692        username: String,
693        /// Direction / view: inbox | sent | archive | unread | new.
694        #[arg(long, short = 'd', default_value = "inbox")]
695        direction: String,
696        /// Output format.
697        #[arg(long, short = 'f', value_enum, default_value = "text")]
698        format: ListFormat,
699    },
700}
701
702#[derive(Subcommand)]
703pub enum ApiKeyCommand {
704    /// List API keys.
705    #[command(visible_alias = "ls")]
706    List {
707        /// Discourse name.
708        discourse: String,
709        /// Output format.
710        #[arg(long, short = 'f', value_enum, default_value = "text")]
711        format: ListFormat,
712    },
713    /// Create a new API key. The secret is only shown at creation time —
714    /// capture it from the output.
715    #[command(visible_alias = "cr")]
716    Create {
717        /// Discourse name.
718        discourse: String,
719        /// Description / label for the key (shown in admin UI).
720        description: String,
721        /// Username the key acts as. Omit for a global all-users key.
722        #[arg(long, short = 'u')]
723        username: Option<String>,
724        /// Output format.
725        #[arg(long, short = 'f', value_enum, default_value = "text")]
726        format: ListFormat,
727    },
728    /// Revoke an API key by ID.
729    #[command(visible_alias = "rm")]
730    Revoke {
731        /// Discourse name.
732        discourse: String,
733        /// API key ID (from `dsc api-key list`).
734        key_id: u64,
735    },
736}
737
738#[derive(Subcommand)]
739pub enum InviteCommand {
740    /// Invite a single email address.
741    #[command(visible_alias = "s")]
742    Send {
743        /// Discourse name.
744        discourse: String,
745        /// Email address to invite.
746        email: String,
747        /// Add invitee to one or more groups on accept (repeatable).
748        #[arg(long, short = 'g')]
749        group: Vec<u64>,
750        /// Land the invitee on a specific topic on accept.
751        #[arg(long, short = 't')]
752        topic: Option<u64>,
753        /// Custom invitation message.
754        #[arg(long, short = 'm')]
755        message: Option<String>,
756    },
757    /// Bulk-invite from a file (or stdin) of email addresses.
758    #[command(visible_alias = "b")]
759    Bulk {
760        /// Discourse name.
761        discourse: String,
762        /// Path to a file of email addresses (one per line; blank lines and
763        /// `#` comments ignored). Reads stdin when omitted or `-`.
764        local_path: Option<PathBuf>,
765        /// Add every invitee to one or more groups on accept (repeatable).
766        #[arg(long, short = 'g')]
767        group: Vec<u64>,
768        /// Land every invitee on a specific topic on accept.
769        #[arg(long, short = 't')]
770        topic: Option<u64>,
771        /// Custom invitation message attached to each invite.
772        #[arg(long, short = 'm')]
773        message: Option<String>,
774    },
775}
776
777#[derive(Subcommand)]
778pub enum UserCommand {
779    /// List users via the admin users endpoint.
780    #[command(visible_alias = "ls")]
781    List {
782        /// Discourse name.
783        discourse: String,
784        /// Listing type: active | new | staff | suspended | silenced | staged.
785        #[arg(long, short = 'l', default_value = "active")]
786        listing: String,
787        /// Page number (Discourse paginates 100 per page).
788        #[arg(long, short = 'p', default_value_t = 1)]
789        page: u32,
790        /// Output format.
791        #[arg(long, short = 'f', value_enum, default_value = "text")]
792        format: ListFormat,
793    },
794    /// Show detailed info for a user.
795    #[command(visible_alias = "i")]
796    Info {
797        /// Discourse name.
798        discourse: String,
799        /// Username.
800        username: String,
801        /// Output format.
802        #[arg(long, short = 'f', value_enum, default_value = "text")]
803        format: ListFormat,
804    },
805    /// Suspend a user.
806    #[command(visible_alias = "sus")]
807    Suspend {
808        /// Discourse name.
809        discourse: String,
810        /// Username.
811        username: String,
812        /// When the suspension ends. ISO-8601 timestamp (e.g.
813        /// `2026-12-31T00:00:00Z`) or `forever`.
814        #[arg(long, short = 'u', default_value = "forever")]
815        until: String,
816        /// Reason shown to the user and in the audit log.
817        #[arg(long, short = 'r', default_value = "")]
818        reason: String,
819    },
820    /// Remove a suspension from a user.
821    #[command(visible_alias = "uns")]
822    Unsuspend {
823        /// Discourse name.
824        discourse: String,
825        /// Username.
826        username: String,
827    },
828    /// Silence a user (prevents posting; less visible than suspend).
829    #[command(visible_alias = "sil")]
830    Silence {
831        /// Discourse name.
832        discourse: String,
833        /// Username.
834        username: String,
835        /// When the silence ends. ISO-8601 timestamp; empty means
836        /// indefinite.
837        #[arg(long, short = 'u', default_value = "")]
838        until: String,
839        /// Reason shown to the user and in the audit log.
840        #[arg(long, short = 'r', default_value = "")]
841        reason: String,
842    },
843    /// Lift a silence on a user.
844    #[command(visible_alias = "unsil")]
845    Unsilence {
846        /// Discourse name.
847        discourse: String,
848        /// Username.
849        username: String,
850    },
851    /// Grant the user the admin or moderator role.
852    #[command(visible_alias = "pr")]
853    Promote {
854        /// Discourse name.
855        discourse: String,
856        /// Username.
857        username: String,
858        /// Role to grant.
859        #[arg(long, short = 'r', value_enum)]
860        role: RoleArg,
861    },
862    /// Revoke the user's admin or moderator role.
863    #[command(visible_alias = "de")]
864    Demote {
865        /// Discourse name.
866        discourse: String,
867        /// Username.
868        username: String,
869        /// Role to revoke.
870        #[arg(long, short = 'r', value_enum)]
871        role: RoleArg,
872    },
873    /// Create a new user. `--approve` also marks the account approved
874    /// (needed when site requires manual approval). Password is either
875    /// supplied via stdin (`--password-stdin`) or omitted — in the
876    /// latter case the user will have to set one via the reset flow.
877    #[command(visible_alias = "cr")]
878    Create {
879        /// Discourse name.
880        discourse: String,
881        /// New user's email address.
882        email: String,
883        /// New user's username.
884        username: String,
885        /// Display name (optional).
886        #[arg(long, short = 'N')]
887        name: Option<String>,
888        /// Read the password from stdin instead of auto-reset.
889        #[arg(long)]
890        password_stdin: bool,
891        /// Also mark the user approved (for sites with manual approval).
892        #[arg(long)]
893        approve: bool,
894    },
895    /// Trigger Discourse's password-reset email flow for a user.
896    #[command(name = "password-reset", visible_aliases = ["pwreset", "pw-reset"])]
897    PasswordReset {
898        /// Discourse name.
899        discourse: String,
900        /// Username or email.
901        username: String,
902    },
903    /// Set a user's primary email address. Requires admin scope.
904    #[command(name = "email-set", visible_alias = "email")]
905    EmailSet {
906        /// Discourse name.
907        discourse: String,
908        /// Username.
909        username: String,
910        /// New email address.
911        email: String,
912    },
913    /// Show a user's recent public activity (topics + replies by default).
914    ///
915    /// Built for the "archive my own activity to a journal forum" loop —
916    /// pipe the markdown output straight into `dsc topic reply`/`topic new`.
917    #[command(visible_alias = "act")]
918    Activity {
919        /// Discourse name (the *source* forum to read activity from).
920        discourse: String,
921        /// Username whose activity to read.
922        username: String,
923        /// How far back to look. Accepts `7d`, `24h`, `30m`, `1w`, `90s`, or
924        /// an ISO-8601 timestamp / date. Omit to fetch everything available.
925        #[arg(long, short = 's')]
926        since: Option<String>,
927        /// Action types to include, comma-separated. Default: topics,replies.
928        /// Also recognises: mentions, quotes, likes, edits, responses.
929        #[arg(long, short = 't', default_value = "topics,replies")]
930        types: String,
931        /// Hard cap on number of items returned.
932        #[arg(long, short = 'L')]
933        limit: Option<u32>,
934        /// Output format.
935        #[arg(long, short = 'f', value_enum, default_value = "markdown")]
936        format: ActivityFormatArg,
937    },
938    /// Manage a user's group memberships.
939    #[command(visible_alias = "g")]
940    Groups {
941        #[command(subcommand)]
942        command: UserGroupsCommand,
943    },
944}
945
946#[derive(ValueEnum, Clone, Copy)]
947pub enum SectionArg {
948    All,
949    Growth,
950    Activity,
951    Health,
952}
953
954#[derive(ValueEnum, Clone, Copy)]
955pub enum AnalyticsFormat {
956    /// Plain text (default). Fixed-width columns, no borders.
957    Text,
958    /// DuckDB-style box-drawing table. Falls through to `text` when
959    /// stdout isn't a TTY.
960    Table,
961    /// Pretty JSON.
962    Json,
963    /// YAML.
964    #[value(alias = "yml")]
965    Yaml,
966    /// Markdown bullet list per section.
967    #[value(alias = "md")]
968    Markdown,
969    /// Markdown table per section.
970    #[value(alias = "md-table", name = "markdown-table")]
971    MarkdownTable,
972    /// CSV — one row per metric.
973    Csv,
974}
975
976#[derive(ValueEnum, Clone, Copy)]
977pub enum ActivityFormatArg {
978    Text,
979    Json,
980    #[value(alias = "yml")]
981    Yaml,
982    #[value(alias = "md")]
983    Markdown,
984    Csv,
985}
986
987#[derive(ValueEnum, Clone, Copy)]
988pub enum RoleArg {
989    Admin,
990    Moderator,
991}
992
993#[derive(Subcommand)]
994pub enum UserGroupsCommand {
995    /// List the groups a user belongs to.
996    #[command(visible_alias = "ls")]
997    List {
998        /// Discourse name.
999        discourse: String,
1000        /// Target username.
1001        username: String,
1002        /// Output format.
1003        #[arg(long, short = 'f', value_enum, default_value = "text")]
1004        format: ListFormat,
1005    },
1006    /// Add a user to a group.
1007    #[command(visible_alias = "a")]
1008    Add {
1009        /// Discourse name.
1010        discourse: String,
1011        /// Target username.
1012        username: String,
1013        /// Group ID.
1014        group_id: u64,
1015        /// Send Discourse notification to the user.
1016        #[arg(long)]
1017        notify: bool,
1018    },
1019    /// Remove a user from a group.
1020    #[command(visible_alias = "rm")]
1021    Remove {
1022        /// Discourse name.
1023        discourse: String,
1024        /// Target username.
1025        username: String,
1026        /// Group ID.
1027        group_id: u64,
1028    },
1029}
1030
1031#[derive(Subcommand)]
1032pub enum PostCommand {
1033    /// Edit a post by ID. Reads the new body from file or stdin.
1034    #[command(visible_alias = "e")]
1035    Edit {
1036        /// Discourse name.
1037        discourse: String,
1038        /// Post ID.
1039        post_id: u64,
1040        /// Input file path. Reads stdin when omitted or `-`.
1041        local_path: Option<PathBuf>,
1042    },
1043    /// Delete a post by ID.
1044    #[command(visible_alias = "rm")]
1045    Delete {
1046        /// Discourse name.
1047        discourse: String,
1048        /// Post ID.
1049        post_id: u64,
1050    },
1051    /// Move a post to a different topic.
1052    #[command(visible_alias = "mv")]
1053    Move {
1054        /// Discourse name.
1055        discourse: String,
1056        /// Post ID to move.
1057        post_id: u64,
1058        /// Destination topic ID.
1059        #[arg(long = "to-topic", short = 't')]
1060        to_topic: u64,
1061    },
1062}
1063
1064#[derive(Subcommand)]
1065pub enum TagCommand {
1066    /// List every tag on the Discourse.
1067    #[command(visible_alias = "ls")]
1068    List {
1069        /// Discourse name.
1070        discourse: String,
1071        /// Output format.
1072        #[arg(long, short = 'f', value_enum, default_value = "text")]
1073        format: ListFormat,
1074    },
1075    /// Add a tag to a topic.
1076    #[command(visible_alias = "a")]
1077    Apply {
1078        /// Discourse name.
1079        discourse: String,
1080        /// Topic ID.
1081        topic_id: u64,
1082        /// Tag to add.
1083        tag: String,
1084    },
1085    /// Remove a tag from a topic.
1086    #[command(visible_alias = "rm")]
1087    Remove {
1088        /// Discourse name.
1089        discourse: String,
1090        /// Topic ID.
1091        topic_id: u64,
1092        /// Tag to remove.
1093        tag: String,
1094    },
1095}
1096
1097#[derive(Subcommand)]
1098pub enum SettingCommand {
1099    /// Set a site setting on a Discourse (or all tagged Discourses).
1100    #[command(visible_alias = "s")]
1101    Set {
1102        /// Discourse name. Required when targeting a single discourse.
1103        discourse: String,
1104        /// Setting key.
1105        setting: String,
1106        /// Setting value.
1107        value: String,
1108        /// Optional tag filter (comma/semicolon separated, match-any). Ignored when discourse is specified.
1109        #[arg(long, value_name = "tag1,tag2")]
1110        tags: Option<String>,
1111    },
1112
1113    /// Get the current value of a site setting.
1114    #[command(visible_alias = "g")]
1115    Get {
1116        /// Discourse name.
1117        discourse: String,
1118        /// Setting key.
1119        setting: String,
1120    },
1121
1122    /// List all site settings.
1123    #[command(visible_alias = "ls")]
1124    List {
1125        /// Discourse name.
1126        discourse: String,
1127        /// Output format.
1128        #[arg(long, short = 'f', value_enum, default_value = "text")]
1129        format: ListFormat,
1130        /// Show output even when list is empty.
1131        #[arg(long, short = 'v')]
1132        verbose: bool,
1133    },
1134}
1135
1136#[derive(ValueEnum, Clone, Copy)]
1137pub enum CompletionShell {
1138    /// Bash shell.
1139    Bash,
1140    /// Zsh shell.
1141    Zsh,
1142    /// Fish shell.
1143    Fish,
1144}
1145
1146impl From<CompletionShell> for Shell {
1147    fn from(value: CompletionShell) -> Self {
1148        match value {
1149            CompletionShell::Bash => Shell::Bash,
1150            CompletionShell::Zsh => Shell::Zsh,
1151            CompletionShell::Fish => Shell::Fish,
1152        }
1153    }
1154}
1155
1156#[derive(ValueEnum, Clone)]
1157pub enum OutputFormat {
1158    /// Plain text.
1159    #[value(alias = "plaintext")]
1160    Text,
1161    /// Markdown list.
1162    Markdown,
1163    /// Markdown table.
1164    MarkdownTable,
1165    /// Pretty JSON.
1166    Json,
1167    /// YAML.
1168    #[value(alias = "yml")]
1169    Yaml,
1170    /// CSV.
1171    Csv,
1172    /// One base URL per line (pipe-friendly).
1173    #[value(alias = "url")]
1174    Urls,
1175}
1176
1177#[derive(ValueEnum, Clone, Copy)]
1178pub enum ListFormat {
1179    /// Plain text.
1180    Text,
1181    /// Pretty JSON.
1182    Json,
1183    /// YAML.
1184    #[value(alias = "yml")]
1185    Yaml,
1186}
1187
1188#[derive(ValueEnum, Clone, Copy)]
1189pub enum StructuredFormat {
1190    /// Pretty JSON.
1191    Json,
1192    /// YAML.
1193    #[value(alias = "yml")]
1194    Yaml,
1195}