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` consults `$DSC_CONFIG`,
10    /// then searches `./dsc.toml`, `$DSC_CONFIG_HOME/dsc.toml`
11    /// (default `~/.config/dsc/dsc.toml`), then system locations.
12    /// Errors if the given file does not exist (no silent fallthrough).
13    /// See `dsc config` for the active selection.
14    #[arg(long, short = 'c')]
15    pub config: Option<PathBuf>,
16    /// Describe destructive actions without sending them. Read-only commands
17    /// ignore the flag.
18    #[arg(long, short = 'n', global = true)]
19    pub dry_run: bool,
20    #[command(subcommand)]
21    pub command: Commands,
22}
23
24#[derive(Subcommand)]
25pub enum Commands {
26    /// List configured Discourses.
27    #[command(visible_alias = "ls")]
28    List {
29        /// Output format for the listing.
30        #[arg(long, short = 'f', value_enum, default_value = "text")]
31        format: OutputFormat,
32        /// Filter by tags (comma/semicolon separated, match-any).
33        #[arg(long, value_name = "tag1,tag2")]
34        tags: Option<String>,
35        /// Open each listed Discourse base URL in a browser tab/window.
36        #[arg(long, short = 'o')]
37        open: bool,
38        /// Include empty results and verbose listing details where supported.
39        #[arg(long, short = 'v')]
40        verbose: bool,
41        #[command(subcommand)]
42        command: Option<ListCommand>,
43    },
44    /// Add one or more Discourses to the config.
45    #[command(visible_alias = "a")]
46    Add {
47        /// Comma-separated discourse names to add.
48        names: String,
49        /// Prompt for additional optional fields while adding.
50        #[arg(long, short = 'i')]
51        interactive: bool,
52    },
53    /// Import Discourses from a file or stdin.
54    #[command(visible_alias = "imp")]
55    Import {
56        /// Path to import input (text/CSV). Reads stdin when omitted.
57        path: Option<PathBuf>,
58    },
59    /// Run remote OS + Discourse update workflow for one or all Discourses.
60    #[command(visible_alias = "up")]
61    Update {
62        /// Discourse name, or 'all' to update every configured Discourse.
63        name: String,
64        /// Parallel update mode for `dsc update all`.
65        #[arg(long, short = 'p')]
66        parallel: bool,
67        /// Maximum workers when parallel mode is enabled (default: 3).
68        #[arg(long, short = 'm')]
69        max: Option<usize>,
70        /// Disable changelog posting (posting prompt is on by default).
71        #[arg(long = "no-changelog", action = ArgAction::SetFalse, default_value_t = true)]
72        post_changelog: bool,
73        /// Auto-confirm changelog posting prompt (non-interactive mode).
74        #[arg(long, short = 'y')]
75        yes: bool,
76    },
77    /// Manage custom emoji.
78    #[command(visible_alias = "em")]
79    Emoji {
80        #[command(subcommand)]
81        command: EmojiCommand,
82    },
83    /// Pull/push/sync topics as local Markdown.
84    #[command(visible_alias = "t")]
85    Topic {
86        #[command(subcommand)]
87        command: TopicCommand,
88    },
89    /// List/copy/pull/push categories.
90    #[command(visible_alias = "cat")]
91    Category {
92        #[command(subcommand)]
93        command: CategoryCommand,
94    },
95    /// List/inspect/copy groups.
96    #[command(visible_alias = "grp")]
97    Group {
98        #[command(subcommand)]
99        command: GroupCommand,
100    },
101    /// Operations that act from a user's perspective.
102    #[command(visible_alias = "usr")]
103    User {
104        #[command(subcommand)]
105        command: UserCommand,
106    },
107    /// Send invites — single or bulk from a file.
108    #[command(visible_alias = "inv")]
109    Invite {
110        #[command(subcommand)]
111        command: InviteCommand,
112    },
113    /// Manage API keys (admin scope).
114    #[command(visible_alias = "ak")]
115    ApiKey {
116        #[command(subcommand)]
117        command: ApiKeyCommand,
118    },
119    /// Send and list private messages.
120    #[command(visible_alias = "msg")]
121    Pm {
122        #[command(subcommand)]
123        command: PmCommand,
124    },
125    /// Create/list/restore backups.
126    #[command(visible_alias = "bk")]
127    Backup {
128        #[command(subcommand)]
129        command: BackupCommand,
130    },
131    /// List/pull/push color palettes.
132    #[command(visible_alias = "pal")]
133    Palette {
134        #[command(subcommand)]
135        command: PaletteCommand,
136    },
137    /// List/install/remove plugins.
138    #[command(visible_alias = "plg")]
139    Plugin {
140        #[command(subcommand)]
141        command: PluginCommand,
142    },
143    /// List/install/remove/pull/push/duplicate themes.
144    #[command(visible_alias = "th")]
145    Theme {
146        #[command(subcommand)]
147        command: ThemeCommand,
148    },
149    /// Update site settings.
150    #[command(visible_alias = "set")]
151    Setting {
152        #[command(subcommand)]
153        command: SettingCommand,
154    },
155    /// Manage the tag taxonomy: list/pull/push tags and tag groups.
156    #[command(visible_alias = "tg")]
157    Tag {
158        #[command(subcommand)]
159        command: TagCommand,
160    },
161    /// Post-level operations: edit / delete / move.
162    #[command(visible_alias = "po")]
163    Post {
164        #[command(subcommand)]
165        command: PostCommand,
166    },
167    /// Open a Discourse in the default browser.
168    #[command(visible_alias = "o")]
169    Open {
170        /// Discourse name.
171        discourse: String,
172    },
173    /// Harden a fresh Ubuntu server reachable via `ssh root@host`.
174    ///
175    /// **Stage 1 (current):** creates a non-root sudo user, installs the
176    /// given pubkey to their authorized_keys, and verifies the new-user
177    /// SSH login works. Does NOT yet tighten sshd_config, install Docker
178    /// / fail2ban / etc — those come in follow-up releases.
179    ///
180    /// Defaults can be overridden in the `[harden]` block of dsc.toml;
181    /// the flags below override that block on a per-run basis.
182    #[command(visible_alias = "hd")]
183    Harden {
184        /// Target hostname or IP (reachable via SSH).
185        host: String,
186        /// Username to SSH in as initially. Defaults to `root`, which is
187        /// what a fresh cloud-provisioned box typically has.
188        #[arg(long, default_value = "root")]
189        ssh_user: String,
190        /// Username for the new sudo-enabled non-root account. Overrides
191        /// `[harden].new_user` from dsc.toml. Built-in default: `discourse`.
192        #[arg(long)]
193        new_user: Option<String>,
194        /// SSH port to move the daemon to in stage 2. Overrides
195        /// `[harden].ssh_port`. Built-in default: 2227. Parsed now so the
196        /// CLI is stable; not yet applied in stage 1.
197        #[arg(long)]
198        ssh_port: Option<u16>,
199        /// Path to an SSH public key file whose contents will be added to
200        /// the new user's authorized_keys. A typical value is
201        /// `~/.ssh/<hostname>.pub` — the per-server keypair pattern in
202        /// the Bawmedical hardening playbook.
203        #[arg(long)]
204        pubkey_file: PathBuf,
205    },
206    /// Community-health analytics — growth, activity, and health metrics
207    /// for a Discourse, with optional period-over-period comparison.
208    ///
209    /// See `spec/analytics.md` for the full spec. v1 ships every metric
210    /// that maps onto a single `/admin/reports/{id}.json` endpoint;
211    /// derivation-heavy ones (e.g. lost regulars, top-10 share) print
212    /// `— (n/i)` until follow-up implementation lands.
213    #[command(visible_alias = "stats")]
214    Analytics {
215        /// Discourse name.
216        discourse: String,
217        /// Window to report on. Same syntax as `dsc user activity --since`
218        /// (e.g. `7d`, `24h`, `1m`, ISO-8601). Ignored when `--snapshot`
219        /// is set. Default: 30d.
220        #[arg(long, short = 's', default_value = "30d")]
221        since: String,
222        /// Also fetch the immediately preceding window of equal length and
223        /// show a delta column. Mutually exclusive with `--snapshot`.
224        #[arg(long, short = 'c', conflicts_with = "snapshot")]
225        compare: bool,
226        /// Multi-window snapshot mode. Reports each metric across several
227        /// preset windows (`--periods`) so you see growth/health trends
228        /// at a glance. Replaces `--since` + `--compare`.
229        #[arg(long)]
230        snapshot: bool,
231        /// Comma-separated periods for `--snapshot`. Default: `24h,7d,30d,1y`.
232        #[arg(long, requires = "snapshot")]
233        periods: Option<String>,
234        /// Restrict output to one section.
235        #[arg(long, value_enum, default_value = "all")]
236        section: SectionArg,
237        /// Output format. `table` is DuckDB-style box-drawing; falls
238        /// through to `text` automatically when stdout isn't a TTY.
239        #[arg(long, short = 'f', value_enum, default_value = "text")]
240        format: AnalyticsFormat,
241    },
242    /// Search topics on a Discourse.
243    #[command(visible_alias = "s")]
244    Search {
245        /// Discourse name.
246        discourse: String,
247        /// Search query (passed through verbatim, including any
248        /// Discourse filter syntax like `category:foo` or `@user`).
249        query: String,
250        /// Output format.
251        #[arg(long, short = 'f', value_enum, default_value = "text")]
252        format: ListFormat,
253    },
254    /// Upload a file. Prints the resulting upload:// short URL by default.
255    #[command(visible_alias = "u")]
256    Upload {
257        /// Discourse name.
258        discourse: String,
259        /// Path to the file to upload.
260        file: PathBuf,
261        /// Discourse upload context. Default `composer` is correct for
262        /// embedding in posts; other values include `avatar`,
263        /// `profile_background`, `card_background`, `custom_emoji`.
264        #[arg(long, short = 't', default_value = "composer")]
265        upload_type: String,
266        /// Output format. Text mode prints just the short URL.
267        #[arg(long, short = 'f', value_enum, default_value = "text")]
268        format: ListFormat,
269    },
270    /// Inspect and validate configuration.
271    #[command(visible_alias = "cfg")]
272    Config {
273        #[command(subcommand)]
274        command: Option<ConfigCommand>,
275    },
276    /// Generate shell completion scripts.
277    #[command(visible_alias = "comp")]
278    Completions {
279        /// Target shell.
280        #[arg(value_enum)]
281        shell: CompletionShell,
282        /// Output directory. Prints to stdout when omitted.
283        #[arg(long, short = 'd')]
284        dir: Option<PathBuf>,
285    },
286    /// Generate man pages for `dsc` and every subcommand.
287    ///
288    /// Writes one ROFF-formatted file per (sub)command (e.g. `dsc.1`,
289    /// `dsc-tag-pull.1`) into the given directory. Distro packagers
290    /// install these into section 1 of the man path. Run `gzip -9` on
291    /// the output if your packaging convention expects compressed pages.
292    #[command(visible_alias = "manpages")]
293    Man {
294        /// Output directory. Required - this command always writes to disk.
295        #[arg(long, short = 'd')]
296        dir: PathBuf,
297    },
298    /// Print the dsc version.
299    #[command(visible_alias = "ver")]
300    Version,
301}
302
303#[derive(Subcommand)]
304pub enum ConfigCommand {
305    /// Probe each configured Discourse: API auth and (optionally) SSH reachability.
306    #[command(visible_alias = "ck")]
307    Check {
308        /// Output format.
309        #[arg(long, short = 'f', value_enum, default_value = "text")]
310        format: ListFormat,
311        /// Skip the SSH reachability probe.
312        #[arg(long)]
313        skip_ssh: bool,
314    },
315}
316
317#[derive(Subcommand)]
318pub enum ListCommand {
319    /// Sort discourse entries by name and rewrite config in-place.
320    /// Also inserts placeholder values for unset template keys.
321    #[command(visible_alias = "ty")]
322    Tidy,
323}
324
325#[derive(Subcommand)]
326pub enum EmojiCommand {
327    /// Pull all custom emoji from a Discourse into a local directory.
328    #[command(visible_alias = "pl")]
329    Pull {
330        /// Discourse name.
331        discourse: String,
332        /// Local directory to save emoji images into.
333        output_dir: PathBuf,
334    },
335    /// Push (upload) one emoji file, or bulk-upload from a directory (alias: add).
336    #[command(visible_alias = "ps", alias = "add")]
337    Push {
338        /// Discourse name.
339        discourse: String,
340        /// Local file or directory path.
341        emoji_path: PathBuf,
342        /// Optional emoji name (file uploads only).
343        emoji_name: Option<String>,
344    },
345
346    /// List custom emojis on a Discourse.
347    #[command(visible_alias = "ls")]
348    List {
349        /// Discourse name.
350        discourse: String,
351        /// Output format.
352        #[arg(long, short = 'f', value_enum, default_value = "text")]
353        format: ListFormat,
354        /// Include additional fields where supported.
355        #[arg(long, short = 'v')]
356        verbose: bool,
357        /// Render inline images when terminal protocol support is available.
358        #[arg(long, short = 'i')]
359        inline: bool,
360    },
361}
362
363#[derive(Subcommand)]
364pub enum TopicCommand {
365    /// Pull a topic to a local Markdown file.
366    #[command(visible_alias = "pl")]
367    Pull {
368        /// Discourse name.
369        discourse: String,
370        /// Topic ID.
371        topic_id: u64,
372        /// Destination file or directory (auto-derived when omitted).
373        local_path: Option<PathBuf>,
374        /// Pull the entire thread (every post) as a single Markdown file
375        /// with YAML frontmatter and per-post headings. Default behaviour
376        /// (no `--full`) writes only the OP, which is what `topic push`
377        /// expects.
378        #[arg(long, short = 'F')]
379        full: bool,
380    },
381    /// Push a local Markdown file to a topic.
382    #[command(visible_alias = "ps")]
383    Push {
384        /// Discourse name.
385        discourse: String,
386        /// Topic ID.
387        topic_id: u64,
388        /// Local Markdown file path.
389        local_path: PathBuf,
390        /// Update the post without bumping the topic in the activity feed.
391        /// Use for silent maintenance edits (sends post[no_bump]=true).
392        #[arg(long)]
393        no_bump: bool,
394        /// Update the post without recording an edit-history revision
395        /// (sends post[skip_revision]=true). Suppresses the online audit
396        /// trail - use sparingly.
397        #[arg(long)]
398        skip_revision: bool,
399    },
400    /// Sync a topic and local Markdown file using newest timestamp.
401    #[command(visible_alias = "sy")]
402    Sync {
403        /// Discourse name.
404        discourse: String,
405        /// Topic ID.
406        topic_id: u64,
407        /// Local Markdown file path.
408        local_path: PathBuf,
409        /// Skip sync confirmation prompt.
410        #[arg(long, short = 'y')]
411        yes: bool,
412    },
413    /// Reply to a topic with content from a file or stdin.
414    #[command(visible_alias = "r")]
415    Reply {
416        /// Discourse name.
417        discourse: String,
418        /// Topic ID.
419        topic_id: u64,
420        /// Input file path. Reads stdin when omitted or `-`.
421        local_path: Option<PathBuf>,
422    },
423    /// Create a new topic in a category, body from a file or stdin.
424    #[command(visible_alias = "n")]
425    New {
426        /// Discourse name.
427        discourse: String,
428        /// Target category ID.
429        category_id: u64,
430        /// Topic title.
431        #[arg(long, short = 't')]
432        title: String,
433        /// Input file path. Reads stdin when omitted or `-`.
434        local_path: Option<PathBuf>,
435    },
436    /// Add a tag to a topic.
437    Tag {
438        /// Discourse name.
439        discourse: String,
440        /// Topic ID.
441        topic_id: u64,
442        /// Tag to add.
443        tag: String,
444    },
445    /// Remove a tag from a topic.
446    Untag {
447        /// Discourse name.
448        discourse: String,
449        /// Topic ID.
450        topic_id: u64,
451        /// Tag to remove.
452        tag: String,
453    },
454}
455
456#[derive(Subcommand)]
457pub enum CategoryCommand {
458    /// List categories.
459    #[command(visible_alias = "ls")]
460    List {
461        /// Discourse name.
462        discourse: String,
463        /// Output format.
464        #[arg(long, short = 'f', value_enum, default_value = "text")]
465        format: ListFormat,
466        /// Include additional fields where supported.
467        #[arg(long, short = 'v')]
468        verbose: bool,
469        /// Show category hierarchy tree.
470        #[arg(long)]
471        tree: bool,
472    },
473    /// Copy a category to another Discourse.
474    #[command(visible_alias = "cp")]
475    Copy {
476        /// Source discourse name.
477        discourse: String,
478        /// Target discourse name (defaults to source when omitted).
479        #[arg(long, short = 't')]
480        target: Option<String>,
481        /// Category ID or slug.
482        category: String,
483    },
484    /// Pull all topics from a category into local Markdown files.
485    #[command(visible_alias = "pl")]
486    Pull {
487        /// Discourse name.
488        discourse: String,
489        /// Category ID or slug.
490        category: String,
491        /// Destination directory (auto-derived when omitted).
492        local_path: Option<PathBuf>,
493    },
494    /// Push local Markdown files into a category.
495    #[command(visible_alias = "ps")]
496    Push {
497        /// Discourse name.
498        discourse: String,
499        /// Category ID or slug.
500        category: String,
501        /// Local directory containing Markdown files.
502        local_path: PathBuf,
503        /// Only update existing topics; error instead of creating a new topic
504        /// when a local file has no remote match.
505        #[arg(long)]
506        updates_only: bool,
507        /// Update posts without bumping their topics in the activity feed.
508        /// Use for silent bulk maintenance edits (sends post[no_bump]=true).
509        #[arg(long)]
510        no_bump: bool,
511        /// Update posts without recording edit-history revisions
512        /// (sends post[skip_revision]=true). Suppresses the online audit
513        /// trail - use sparingly.
514        #[arg(long)]
515        skip_revision: bool,
516    },
517}
518
519#[derive(Subcommand)]
520pub enum GroupCommand {
521    /// List groups.
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: ListFormat,
529        /// Include additional fields where supported.
530        #[arg(long, short = 'v')]
531        verbose: bool,
532    },
533    /// Show group details.
534    #[command(visible_alias = "i")]
535    Info {
536        /// Discourse name.
537        discourse: String,
538        /// Group ID.
539        group: u64,
540        /// Output format.
541        #[arg(long, short = 'f', value_enum, default_value = "json")]
542        format: StructuredFormat,
543    },
544    /// List members of a group.
545    #[command(visible_alias = "m")]
546    Members {
547        /// Discourse name.
548        discourse: String,
549        /// Group ID.
550        group: u64,
551        /// Output format.
552        #[arg(long, short = 'f', value_enum, default_value = "text")]
553        format: ListFormat,
554    },
555    /// Copy a group to another Discourse.
556    #[command(visible_alias = "cp")]
557    Copy {
558        /// Source discourse name.
559        discourse: String,
560        /// Target discourse name (defaults to source when omitted).
561        #[arg(long, short = 't')]
562        target: Option<String>,
563        /// Group ID.
564        group: u64,
565    },
566    /// Bulk add members to a group from a file (or stdin) of email addresses.
567    #[command(visible_alias = "a")]
568    Add {
569        /// Discourse name.
570        discourse: String,
571        /// Group ID.
572        group: u64,
573        /// Path to a file of email addresses (one per line; blank
574        /// lines and `#` comments are ignored). Reads stdin when
575        /// omitted or `-`.
576        local_path: Option<PathBuf>,
577        /// Send Discourse notifications to added users.
578        #[arg(long)]
579        notify: bool,
580    },
581}
582
583#[derive(Subcommand)]
584pub enum BackupCommand {
585    /// Create a new backup.
586    #[command(visible_alias = "cr")]
587    Create {
588        /// Discourse name.
589        discourse: String,
590    },
591    /// List backups.
592    #[command(visible_alias = "ls")]
593    List {
594        /// Discourse name.
595        discourse: String,
596        /// Output format.
597        #[arg(long, short = 'f', value_enum, default_value = "text")]
598        format: OutputFormat,
599        /// Include additional fields where supported.
600        #[arg(long, short = 'v')]
601        verbose: bool,
602    },
603    /// Pull (download) a backup to a local file.
604    #[command(visible_alias = "pl")]
605    Pull {
606        /// Discourse name.
607        discourse: String,
608        /// Backup filename on the server (from `dsc backup list`).
609        backup_filename: String,
610        /// Local output path. Defaults to the backup filename in the current directory.
611        local_path: Option<PathBuf>,
612    },
613    /// Push (restore) a backup on the server (alias: restore).
614    #[command(visible_alias = "ps", alias = "restore")]
615    Push {
616        /// Discourse name.
617        discourse: String,
618        /// Backup filename/path on the target system.
619        backup_path: String,
620    },
621}
622
623#[derive(Subcommand)]
624pub enum PaletteCommand {
625    /// List color palettes.
626    #[command(visible_alias = "ls")]
627    List {
628        /// Discourse name.
629        discourse: String,
630        /// Output format.
631        #[arg(long, short = 'f', value_enum, default_value = "text")]
632        format: ListFormat,
633        /// Include additional fields where supported.
634        #[arg(long, short = 'v')]
635        verbose: bool,
636    },
637    /// Pull a palette to local JSON.
638    #[command(visible_alias = "pl")]
639    Pull {
640        /// Discourse name.
641        discourse: String,
642        /// Palette ID.
643        palette_id: u64,
644        /// Destination file path (auto-derived when omitted).
645        local_path: Option<PathBuf>,
646    },
647    /// Push local JSON to create or update a palette.
648    #[command(visible_alias = "ps")]
649    Push {
650        /// Discourse name.
651        discourse: String,
652        /// Local JSON file path.
653        local_path: PathBuf,
654        /// Palette ID to update (creates a new palette when omitted).
655        palette_id: Option<u64>,
656    },
657}
658
659#[derive(Subcommand)]
660pub enum PluginCommand {
661    /// List installed plugins.
662    #[command(visible_alias = "ls")]
663    List {
664        /// Discourse name.
665        discourse: String,
666        /// Output format.
667        #[arg(long, short = 'f', value_enum, default_value = "text")]
668        format: ListFormat,
669        /// Include additional fields where supported.
670        #[arg(long, short = 'v')]
671        verbose: bool,
672    },
673    /// Install a plugin from URL.
674    #[command(visible_alias = "i")]
675    Install {
676        /// Discourse name.
677        discourse: String,
678        /// Plugin repository URL.
679        url: String,
680    },
681    /// Remove a plugin by name.
682    #[command(visible_alias = "rm")]
683    Remove {
684        /// Discourse name.
685        discourse: String,
686        /// Plugin name.
687        name: String,
688    },
689}
690
691#[derive(Subcommand)]
692pub enum ThemeCommand {
693    /// List installed themes.
694    #[command(visible_alias = "ls")]
695    List {
696        /// Discourse name.
697        discourse: String,
698        /// Output format.
699        #[arg(long, short = 'f', value_enum, default_value = "text")]
700        format: ListFormat,
701        /// Include additional fields where supported.
702        #[arg(long, short = 'v')]
703        verbose: bool,
704    },
705    /// Install a theme from URL.
706    #[command(visible_alias = "i")]
707    Install {
708        /// Discourse name.
709        discourse: String,
710        /// Theme repository URL.
711        url: String,
712    },
713    /// Remove a theme by name.
714    #[command(visible_alias = "rm")]
715    Remove {
716        /// Discourse name.
717        discourse: String,
718        /// Theme name.
719        name: String,
720    },
721    /// Pull a theme to a local JSON file.
722    #[command(visible_alias = "pl")]
723    Pull {
724        /// Discourse name.
725        discourse: String,
726        /// Theme ID (from `dsc theme list`).
727        theme_id: u64,
728        /// Destination file path (auto-derived from theme name when omitted).
729        local_path: Option<PathBuf>,
730    },
731    /// Push a local JSON file to create or update a theme.
732    #[command(visible_alias = "ps")]
733    Push {
734        /// Discourse name.
735        discourse: String,
736        /// Local JSON file path.
737        local_path: PathBuf,
738        /// Theme ID to update (creates a new theme when omitted).
739        theme_id: Option<u64>,
740    },
741    /// Duplicate a theme and print the new theme ID.
742    #[command(visible_alias = "dup")]
743    Duplicate {
744        /// Discourse name.
745        discourse: String,
746        /// Theme ID to duplicate (from `dsc theme list`).
747        theme_id: u64,
748    },
749}
750
751#[derive(Subcommand)]
752pub enum PmCommand {
753    /// Send a private message.
754    #[command(visible_alias = "s")]
755    Send {
756        /// Discourse name.
757        discourse: String,
758        /// Recipient(s) — comma-separated usernames or group names.
759        recipients: String,
760        /// PM title / subject.
761        #[arg(long, short = 't')]
762        title: String,
763        /// Input file path. Reads stdin when omitted or `-`.
764        local_path: Option<PathBuf>,
765    },
766    /// List PMs for a user.
767    #[command(visible_alias = "ls")]
768    List {
769        /// Discourse name.
770        discourse: String,
771        /// Username whose PMs to list.
772        username: String,
773        /// Direction / view: inbox | sent | archive | unread | new.
774        #[arg(long, short = 'd', default_value = "inbox")]
775        direction: String,
776        /// Output format.
777        #[arg(long, short = 'f', value_enum, default_value = "text")]
778        format: ListFormat,
779    },
780}
781
782#[derive(Subcommand)]
783pub enum ApiKeyCommand {
784    /// List API keys.
785    #[command(visible_alias = "ls")]
786    List {
787        /// Discourse name.
788        discourse: String,
789        /// Output format.
790        #[arg(long, short = 'f', value_enum, default_value = "text")]
791        format: ListFormat,
792    },
793    /// Create a new API key. The secret is only shown at creation time —
794    /// capture it from the output.
795    #[command(visible_alias = "cr")]
796    Create {
797        /// Discourse name.
798        discourse: String,
799        /// Description / label for the key (shown in admin UI).
800        description: String,
801        /// Username the key acts as. Omit for a global all-users key.
802        #[arg(long, short = 'u')]
803        username: Option<String>,
804        /// Output format.
805        #[arg(long, short = 'f', value_enum, default_value = "text")]
806        format: ListFormat,
807    },
808    /// Revoke an API key by ID.
809    #[command(visible_alias = "rm")]
810    Revoke {
811        /// Discourse name.
812        discourse: String,
813        /// API key ID (from `dsc api-key list`).
814        key_id: u64,
815    },
816}
817
818#[derive(Subcommand)]
819pub enum InviteCommand {
820    /// Invite a single email address.
821    #[command(visible_alias = "s")]
822    Send {
823        /// Discourse name.
824        discourse: String,
825        /// Email address to invite.
826        email: String,
827        /// Add invitee to one or more groups on accept (repeatable).
828        #[arg(long, short = 'g')]
829        group: Vec<u64>,
830        /// Land the invitee on a specific topic on accept.
831        #[arg(long, short = 't')]
832        topic: Option<u64>,
833        /// Custom invitation message.
834        #[arg(long, short = 'm')]
835        message: Option<String>,
836    },
837    /// Bulk-invite from a file (or stdin) of email addresses.
838    #[command(visible_alias = "b")]
839    Bulk {
840        /// Discourse name.
841        discourse: String,
842        /// Path to a file of email addresses (one per line; blank lines and
843        /// `#` comments ignored). Reads stdin when omitted or `-`.
844        local_path: Option<PathBuf>,
845        /// Add every invitee to one or more groups on accept (repeatable).
846        #[arg(long, short = 'g')]
847        group: Vec<u64>,
848        /// Land every invitee on a specific topic on accept.
849        #[arg(long, short = 't')]
850        topic: Option<u64>,
851        /// Custom invitation message attached to each invite.
852        #[arg(long, short = 'm')]
853        message: Option<String>,
854    },
855}
856
857#[derive(Subcommand)]
858pub enum UserCommand {
859    /// List users via the admin users endpoint.
860    #[command(visible_alias = "ls")]
861    List {
862        /// Discourse name.
863        discourse: String,
864        /// Listing type: active | new | staff | suspended | silenced | staged.
865        #[arg(long, short = 'l', default_value = "active")]
866        listing: String,
867        /// Page number (Discourse paginates 100 per page).
868        #[arg(long, short = 'p', default_value_t = 1)]
869        page: u32,
870        /// Output format.
871        #[arg(long, short = 'f', value_enum, default_value = "text")]
872        format: ListFormat,
873    },
874    /// Show detailed info for a user.
875    #[command(visible_alias = "i")]
876    Info {
877        /// Discourse name.
878        discourse: String,
879        /// Username.
880        username: String,
881        /// Output format.
882        #[arg(long, short = 'f', value_enum, default_value = "text")]
883        format: ListFormat,
884    },
885    /// Suspend a user.
886    #[command(visible_alias = "sus")]
887    Suspend {
888        /// Discourse name.
889        discourse: String,
890        /// Username.
891        username: String,
892        /// When the suspension ends. ISO-8601 timestamp (e.g.
893        /// `2026-12-31T00:00:00Z`) or `forever`.
894        #[arg(long, short = 'u', default_value = "forever")]
895        until: String,
896        /// Reason shown to the user and in the audit log.
897        #[arg(long, short = 'r', default_value = "")]
898        reason: String,
899    },
900    /// Remove a suspension from a user.
901    #[command(visible_alias = "uns")]
902    Unsuspend {
903        /// Discourse name.
904        discourse: String,
905        /// Username.
906        username: String,
907    },
908    /// Silence a user (prevents posting; less visible than suspend).
909    #[command(visible_alias = "sil")]
910    Silence {
911        /// Discourse name.
912        discourse: String,
913        /// Username.
914        username: String,
915        /// When the silence ends. ISO-8601 timestamp; empty means
916        /// indefinite.
917        #[arg(long, short = 'u', default_value = "")]
918        until: String,
919        /// Reason shown to the user and in the audit log.
920        #[arg(long, short = 'r', default_value = "")]
921        reason: String,
922    },
923    /// Lift a silence on a user.
924    #[command(visible_alias = "unsil")]
925    Unsilence {
926        /// Discourse name.
927        discourse: String,
928        /// Username.
929        username: String,
930    },
931    /// Grant the user the admin or moderator role.
932    #[command(visible_alias = "pr")]
933    Promote {
934        /// Discourse name.
935        discourse: String,
936        /// Username.
937        username: String,
938        /// Role to grant.
939        #[arg(long, short = 'r', value_enum)]
940        role: RoleArg,
941    },
942    /// Revoke the user's admin or moderator role.
943    #[command(visible_alias = "de")]
944    Demote {
945        /// Discourse name.
946        discourse: String,
947        /// Username.
948        username: String,
949        /// Role to revoke.
950        #[arg(long, short = 'r', value_enum)]
951        role: RoleArg,
952    },
953    /// Create a new user. `--approve` also marks the account approved
954    /// (needed when site requires manual approval). Password is either
955    /// supplied via stdin (`--password-stdin`) or omitted — in the
956    /// latter case the user will have to set one via the reset flow.
957    #[command(visible_alias = "cr")]
958    Create {
959        /// Discourse name.
960        discourse: String,
961        /// New user's email address.
962        email: String,
963        /// New user's username.
964        username: String,
965        /// Display name (optional).
966        #[arg(long, short = 'N')]
967        name: Option<String>,
968        /// Read the password from stdin instead of auto-reset.
969        #[arg(long)]
970        password_stdin: bool,
971        /// Also mark the user approved (for sites with manual approval).
972        #[arg(long)]
973        approve: bool,
974    },
975    /// Trigger Discourse's password-reset email flow for a user.
976    #[command(name = "password-reset", visible_aliases = ["pwreset", "pw-reset"])]
977    PasswordReset {
978        /// Discourse name.
979        discourse: String,
980        /// Username or email.
981        username: String,
982    },
983    /// Set a user's primary email address. Requires admin scope.
984    #[command(name = "email-set", visible_alias = "email")]
985    EmailSet {
986        /// Discourse name.
987        discourse: String,
988        /// Username.
989        username: String,
990        /// New email address.
991        email: String,
992    },
993    /// Show a user's recent public activity (topics + replies by default).
994    ///
995    /// Built for the "archive my own activity to a journal forum" loop —
996    /// pipe the markdown output straight into `dsc topic reply`/`topic new`.
997    #[command(visible_alias = "act")]
998    Activity {
999        /// Discourse name (the *source* forum to read activity from).
1000        discourse: String,
1001        /// Username whose activity to read.
1002        username: String,
1003        /// How far back to look. Accepts `7d`, `24h`, `30m`, `1w`, `90s`, or
1004        /// an ISO-8601 timestamp / date. Omit to fetch everything available.
1005        #[arg(long, short = 's')]
1006        since: Option<String>,
1007        /// Action types to include, comma-separated. Default: topics,replies.
1008        /// Also recognises: mentions, quotes, likes, edits, responses.
1009        #[arg(long, short = 't', default_value = "topics,replies")]
1010        types: String,
1011        /// Hard cap on number of items returned.
1012        #[arg(long, short = 'L')]
1013        limit: Option<u32>,
1014        /// Output format.
1015        #[arg(long, short = 'f', value_enum, default_value = "markdown")]
1016        format: ActivityFormatArg,
1017    },
1018    /// Manage a user's group memberships.
1019    #[command(visible_alias = "g")]
1020    Groups {
1021        #[command(subcommand)]
1022        command: UserGroupsCommand,
1023    },
1024}
1025
1026#[derive(ValueEnum, Clone, Copy)]
1027pub enum SectionArg {
1028    All,
1029    Growth,
1030    Activity,
1031    Health,
1032}
1033
1034#[derive(ValueEnum, Clone, Copy)]
1035pub enum AnalyticsFormat {
1036    /// Plain text (default). Fixed-width columns, no borders.
1037    Text,
1038    /// DuckDB-style box-drawing table. Falls through to `text` when
1039    /// stdout isn't a TTY.
1040    Table,
1041    /// Pretty JSON.
1042    Json,
1043    /// YAML.
1044    #[value(alias = "yml")]
1045    Yaml,
1046    /// Markdown bullet list per section.
1047    #[value(alias = "md")]
1048    Markdown,
1049    /// Markdown table per section.
1050    #[value(alias = "md-table", name = "markdown-table")]
1051    MarkdownTable,
1052    /// CSV — one row per metric.
1053    Csv,
1054}
1055
1056#[derive(ValueEnum, Clone, Copy)]
1057pub enum ActivityFormatArg {
1058    Text,
1059    Json,
1060    #[value(alias = "yml")]
1061    Yaml,
1062    #[value(alias = "md")]
1063    Markdown,
1064    Csv,
1065}
1066
1067#[derive(ValueEnum, Clone, Copy)]
1068pub enum RoleArg {
1069    Admin,
1070    Moderator,
1071}
1072
1073#[derive(Subcommand)]
1074pub enum UserGroupsCommand {
1075    /// List the groups a user belongs to.
1076    #[command(visible_alias = "ls")]
1077    List {
1078        /// Discourse name.
1079        discourse: String,
1080        /// Target username.
1081        username: String,
1082        /// Output format.
1083        #[arg(long, short = 'f', value_enum, default_value = "text")]
1084        format: ListFormat,
1085    },
1086    /// Add a user to a group.
1087    #[command(visible_alias = "a")]
1088    Add {
1089        /// Discourse name.
1090        discourse: String,
1091        /// Target username.
1092        username: String,
1093        /// Group ID.
1094        group_id: u64,
1095        /// Send Discourse notification to the user.
1096        #[arg(long)]
1097        notify: bool,
1098    },
1099    /// Remove a user from a group.
1100    #[command(visible_alias = "rm")]
1101    Remove {
1102        /// Discourse name.
1103        discourse: String,
1104        /// Target username.
1105        username: String,
1106        /// Group ID.
1107        group_id: u64,
1108    },
1109}
1110
1111#[derive(Subcommand)]
1112pub enum PostCommand {
1113    /// Pull a post's raw Markdown to a local file.
1114    #[command(visible_alias = "pl")]
1115    Pull {
1116        /// Discourse name.
1117        discourse: String,
1118        /// Post ID.
1119        post_id: u64,
1120        /// Output file path. Prints to stdout when omitted.
1121        local_path: Option<PathBuf>,
1122    },
1123    /// Push a local file to update a post (alias: edit).
1124    #[command(visible_alias = "ps", alias = "edit")]
1125    Push {
1126        /// Discourse name.
1127        discourse: String,
1128        /// Post ID.
1129        post_id: u64,
1130        /// Input file path. Reads stdin when omitted or `-`.
1131        local_path: Option<PathBuf>,
1132    },
1133    /// Delete a post by ID.
1134    #[command(visible_alias = "rm")]
1135    Delete {
1136        /// Discourse name.
1137        discourse: String,
1138        /// Post ID.
1139        post_id: u64,
1140    },
1141    /// Move a post to a different topic.
1142    #[command(visible_alias = "mv")]
1143    Move {
1144        /// Discourse name.
1145        discourse: String,
1146        /// Post ID to move.
1147        post_id: u64,
1148        /// Destination topic ID.
1149        #[arg(long = "to-topic", short = 't')]
1150        to_topic: u64,
1151    },
1152}
1153
1154#[derive(Subcommand)]
1155pub enum TagCommand {
1156    /// List every tag on the Discourse.
1157    #[command(visible_alias = "ls")]
1158    List {
1159        /// Discourse name.
1160        discourse: String,
1161        /// Output format.
1162        #[arg(long, short = 'f', value_enum, default_value = "text")]
1163        format: ListFormat,
1164    },
1165    /// Pull the tag taxonomy (tags + tag groups) to a local file.
1166    #[command(visible_alias = "pl")]
1167    Pull {
1168        /// Discourse name.
1169        discourse: String,
1170        /// Output file (default: tags.yaml). Extension determines format (.yaml/.json).
1171        #[arg(default_value = "tags.yaml")]
1172        local_path: PathBuf,
1173    },
1174    /// Push a local taxonomy file to the server (upsert; optionally prune).
1175    #[command(visible_alias = "ps")]
1176    Push {
1177        /// Discourse name.
1178        discourse: String,
1179        /// Input taxonomy file.
1180        local_path: PathBuf,
1181        /// Delete server tags/groups absent from the file.
1182        #[arg(long)]
1183        prune: bool,
1184    },
1185    /// Rename a tag, preserving topic associations.
1186    ///
1187    /// Discourse rewrites every topic's tag list in-place, so this avoids
1188    /// the delete-and-recreate pattern that loses topic membership.
1189    #[command(visible_alias = "rn")]
1190    Rename {
1191        /// Discourse name.
1192        discourse: String,
1193        /// Current tag name.
1194        old_name: String,
1195        /// New tag name.
1196        new_name: String,
1197    },
1198}
1199
1200#[derive(Subcommand)]
1201pub enum SettingCommand {
1202    /// Set a site setting on a Discourse (or all tagged Discourses).
1203    ///
1204    /// Usage:
1205    ///   dsc setting set <discourse> <setting> <value>
1206    ///   dsc setting set --tags <tag1,tag2> <setting> <value>
1207    #[command(visible_alias = "s")]
1208    Set {
1209        /// Discourse name. Required unless `--tags` is provided.
1210        discourse: Option<String>,
1211        /// Setting key. Required.
1212        setting: Option<String>,
1213        /// Setting value. Required.
1214        value: Option<String>,
1215        /// Tag filter (comma/semicolon separated, match-any). Apply across all
1216        /// Discourses matching any of the tags. When set, omit `<discourse>`
1217        /// and pass `<setting> <value>` as the only positionals.
1218        #[arg(long, value_name = "tag1,tag2")]
1219        tags: Option<String>,
1220    },
1221
1222    /// Get the current value of a site setting.
1223    #[command(visible_alias = "g")]
1224    Get {
1225        /// Discourse name.
1226        discourse: String,
1227        /// Setting key.
1228        setting: String,
1229    },
1230
1231    /// List all site settings.
1232    #[command(visible_alias = "ls")]
1233    List {
1234        /// Discourse name.
1235        discourse: String,
1236        /// Output format.
1237        #[arg(long, short = 'f', value_enum, default_value = "text")]
1238        format: ListFormat,
1239        /// Show output even when list is empty.
1240        #[arg(long, short = 'v')]
1241        verbose: bool,
1242    },
1243
1244    /// Snapshot all site settings (with metadata) to a local file.
1245    ///
1246    /// See spec/setting-sync.md for the full schema and workflow. The
1247    /// generated file is a self-documenting YAML (or JSON) including each
1248    /// setting's default, type, category, and description.
1249    #[command(visible_alias = "pl")]
1250    Pull {
1251        /// Discourse name.
1252        discourse: String,
1253        /// Output path. Format detected by extension (.json → JSON,
1254        /// otherwise YAML). Defaults to `settings.yaml`.
1255        #[arg(default_value = "settings.yaml")]
1256        local_path: PathBuf,
1257        /// Only include settings whose value differs from default. Produces
1258        /// a manageable file (~50-100 entries) suitable for version control.
1259        #[arg(long, short = 'c')]
1260        changed_only: bool,
1261        /// Limit to settings in this category (e.g. `required`, `email`,
1262        /// `security`).
1263        #[arg(long)]
1264        category: Option<String>,
1265    },
1266
1267    /// Apply a settings snapshot file to a Discourse (idempotent).
1268    ///
1269    /// Compares each setting in the file against the server and PUTs only
1270    /// values that differ. Combine with `--dry-run` to preview the plan.
1271    #[command(visible_alias = "ph")]
1272    Push {
1273        /// Discourse name.
1274        discourse: String,
1275        /// Path to the settings snapshot file (YAML or JSON).
1276        local_path: PathBuf,
1277        /// For settings present on the server but absent from the file,
1278        /// reset them to their default value. Off by default (file describes
1279        /// only the values you care about).
1280        #[arg(long)]
1281        reset_unlisted: bool,
1282    },
1283
1284    /// Compare site settings between two sources.
1285    ///
1286    /// Each source can be a Discourse name (live fetch) or a path to a
1287    /// snapshot file produced by `dsc setting pull`. Sources are detected
1288    /// by whether the argument refers to an existing file on disk; if not,
1289    /// it is treated as a Discourse name.
1290    #[command(visible_alias = "df")]
1291    Diff {
1292        /// First source: Discourse name or snapshot file path.
1293        source: String,
1294        /// Second source: Discourse name or snapshot file path.
1295        target: String,
1296        /// Filter to settings where at least one source differs from default.
1297        /// Reduces noise when most settings on both sides are still default.
1298        #[arg(long, short = 'c')]
1299        changed_only: bool,
1300        /// Limit to settings in this category (e.g. `required`, `email`).
1301        /// Only effective when both sources carry category metadata.
1302        #[arg(long)]
1303        category: Option<String>,
1304        /// Output format.
1305        #[arg(long, short = 'f', value_enum, default_value = "text")]
1306        format: ListFormat,
1307    },
1308}
1309
1310#[derive(ValueEnum, Clone, Copy)]
1311pub enum CompletionShell {
1312    /// Bash shell.
1313    Bash,
1314    /// Zsh shell.
1315    Zsh,
1316    /// Fish shell.
1317    Fish,
1318}
1319
1320impl From<CompletionShell> for Shell {
1321    fn from(value: CompletionShell) -> Self {
1322        match value {
1323            CompletionShell::Bash => Shell::Bash,
1324            CompletionShell::Zsh => Shell::Zsh,
1325            CompletionShell::Fish => Shell::Fish,
1326        }
1327    }
1328}
1329
1330#[derive(ValueEnum, Clone)]
1331pub enum OutputFormat {
1332    /// Plain text.
1333    #[value(alias = "plaintext")]
1334    Text,
1335    /// Markdown list.
1336    Markdown,
1337    /// Markdown table.
1338    MarkdownTable,
1339    /// Pretty JSON.
1340    Json,
1341    /// YAML.
1342    #[value(alias = "yml")]
1343    Yaml,
1344    /// CSV.
1345    Csv,
1346    /// One base URL per line (pipe-friendly).
1347    #[value(alias = "url")]
1348    Urls,
1349}
1350
1351#[derive(ValueEnum, Clone, Copy)]
1352pub enum ListFormat {
1353    /// Plain text.
1354    Text,
1355    /// Pretty JSON.
1356    Json,
1357    /// YAML.
1358    #[value(alias = "yml")]
1359    Yaml,
1360}
1361
1362#[derive(ValueEnum, Clone, Copy)]
1363pub enum StructuredFormat {
1364    /// Pretty JSON.
1365    Json,
1366    /// YAML.
1367    #[value(alias = "yml")]
1368    Yaml,
1369}