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    },
391    /// Sync a topic and local Markdown file using newest timestamp.
392    #[command(visible_alias = "sy")]
393    Sync {
394        /// Discourse name.
395        discourse: String,
396        /// Topic ID.
397        topic_id: u64,
398        /// Local Markdown file path.
399        local_path: PathBuf,
400        /// Skip sync confirmation prompt.
401        #[arg(long, short = 'y')]
402        yes: bool,
403    },
404    /// Reply to a topic with content from a file or stdin.
405    #[command(visible_alias = "r")]
406    Reply {
407        /// Discourse name.
408        discourse: String,
409        /// Topic ID.
410        topic_id: u64,
411        /// Input file path. Reads stdin when omitted or `-`.
412        local_path: Option<PathBuf>,
413    },
414    /// Create a new topic in a category, body from a file or stdin.
415    #[command(visible_alias = "n")]
416    New {
417        /// Discourse name.
418        discourse: String,
419        /// Target category ID.
420        category_id: u64,
421        /// Topic title.
422        #[arg(long, short = 't')]
423        title: String,
424        /// Input file path. Reads stdin when omitted or `-`.
425        local_path: Option<PathBuf>,
426    },
427    /// Add a tag to a topic.
428    Tag {
429        /// Discourse name.
430        discourse: String,
431        /// Topic ID.
432        topic_id: u64,
433        /// Tag to add.
434        tag: String,
435    },
436    /// Remove a tag from a topic.
437    Untag {
438        /// Discourse name.
439        discourse: String,
440        /// Topic ID.
441        topic_id: u64,
442        /// Tag to remove.
443        tag: String,
444    },
445}
446
447#[derive(Subcommand)]
448pub enum CategoryCommand {
449    /// List categories.
450    #[command(visible_alias = "ls")]
451    List {
452        /// Discourse name.
453        discourse: String,
454        /// Output format.
455        #[arg(long, short = 'f', value_enum, default_value = "text")]
456        format: ListFormat,
457        /// Include additional fields where supported.
458        #[arg(long, short = 'v')]
459        verbose: bool,
460        /// Show category hierarchy tree.
461        #[arg(long)]
462        tree: bool,
463    },
464    /// Copy a category to another Discourse.
465    #[command(visible_alias = "cp")]
466    Copy {
467        /// Source discourse name.
468        discourse: String,
469        /// Target discourse name (defaults to source when omitted).
470        #[arg(long, short = 't')]
471        target: Option<String>,
472        /// Category ID or slug.
473        category: String,
474    },
475    /// Pull all topics from a category into local Markdown files.
476    #[command(visible_alias = "pl")]
477    Pull {
478        /// Discourse name.
479        discourse: String,
480        /// Category ID or slug.
481        category: String,
482        /// Destination directory (auto-derived when omitted).
483        local_path: Option<PathBuf>,
484    },
485    /// Push local Markdown files into a category.
486    #[command(visible_alias = "ps")]
487    Push {
488        /// Discourse name.
489        discourse: String,
490        /// Category ID or slug.
491        category: String,
492        /// Local directory containing Markdown files.
493        local_path: PathBuf,
494    },
495}
496
497#[derive(Subcommand)]
498pub enum GroupCommand {
499    /// List groups.
500    #[command(visible_alias = "ls")]
501    List {
502        /// Discourse name.
503        discourse: String,
504        /// Output format.
505        #[arg(long, short = 'f', value_enum, default_value = "text")]
506        format: ListFormat,
507        /// Include additional fields where supported.
508        #[arg(long, short = 'v')]
509        verbose: bool,
510    },
511    /// Show group details.
512    #[command(visible_alias = "i")]
513    Info {
514        /// Discourse name.
515        discourse: String,
516        /// Group ID.
517        group: u64,
518        /// Output format.
519        #[arg(long, short = 'f', value_enum, default_value = "json")]
520        format: StructuredFormat,
521    },
522    /// List members of a group.
523    #[command(visible_alias = "m")]
524    Members {
525        /// Discourse name.
526        discourse: String,
527        /// Group ID.
528        group: u64,
529        /// Output format.
530        #[arg(long, short = 'f', value_enum, default_value = "text")]
531        format: ListFormat,
532    },
533    /// Copy a group to another Discourse.
534    #[command(visible_alias = "cp")]
535    Copy {
536        /// Source discourse name.
537        discourse: String,
538        /// Target discourse name (defaults to source when omitted).
539        #[arg(long, short = 't')]
540        target: Option<String>,
541        /// Group ID.
542        group: u64,
543    },
544    /// Bulk add members to a group from a file (or stdin) of email addresses.
545    #[command(visible_alias = "a")]
546    Add {
547        /// Discourse name.
548        discourse: String,
549        /// Group ID.
550        group: u64,
551        /// Path to a file of email addresses (one per line; blank
552        /// lines and `#` comments are ignored). Reads stdin when
553        /// omitted or `-`.
554        local_path: Option<PathBuf>,
555        /// Send Discourse notifications to added users.
556        #[arg(long)]
557        notify: bool,
558    },
559}
560
561#[derive(Subcommand)]
562pub enum BackupCommand {
563    /// Create a new backup.
564    #[command(visible_alias = "cr")]
565    Create {
566        /// Discourse name.
567        discourse: String,
568    },
569    /// List backups.
570    #[command(visible_alias = "ls")]
571    List {
572        /// Discourse name.
573        discourse: String,
574        /// Output format.
575        #[arg(long, short = 'f', value_enum, default_value = "text")]
576        format: OutputFormat,
577        /// Include additional fields where supported.
578        #[arg(long, short = 'v')]
579        verbose: bool,
580    },
581    /// Pull (download) a backup to a local file.
582    #[command(visible_alias = "pl")]
583    Pull {
584        /// Discourse name.
585        discourse: String,
586        /// Backup filename on the server (from `dsc backup list`).
587        backup_filename: String,
588        /// Local output path. Defaults to the backup filename in the current directory.
589        local_path: Option<PathBuf>,
590    },
591    /// Push (restore) a backup on the server (alias: restore).
592    #[command(visible_alias = "ps", alias = "restore")]
593    Push {
594        /// Discourse name.
595        discourse: String,
596        /// Backup filename/path on the target system.
597        backup_path: String,
598    },
599}
600
601#[derive(Subcommand)]
602pub enum PaletteCommand {
603    /// List color palettes.
604    #[command(visible_alias = "ls")]
605    List {
606        /// Discourse name.
607        discourse: String,
608        /// Output format.
609        #[arg(long, short = 'f', value_enum, default_value = "text")]
610        format: ListFormat,
611        /// Include additional fields where supported.
612        #[arg(long, short = 'v')]
613        verbose: bool,
614    },
615    /// Pull a palette to local JSON.
616    #[command(visible_alias = "pl")]
617    Pull {
618        /// Discourse name.
619        discourse: String,
620        /// Palette ID.
621        palette_id: u64,
622        /// Destination file path (auto-derived when omitted).
623        local_path: Option<PathBuf>,
624    },
625    /// Push local JSON to create or update a palette.
626    #[command(visible_alias = "ps")]
627    Push {
628        /// Discourse name.
629        discourse: String,
630        /// Local JSON file path.
631        local_path: PathBuf,
632        /// Palette ID to update (creates a new palette when omitted).
633        palette_id: Option<u64>,
634    },
635}
636
637#[derive(Subcommand)]
638pub enum PluginCommand {
639    /// List installed plugins.
640    #[command(visible_alias = "ls")]
641    List {
642        /// Discourse name.
643        discourse: String,
644        /// Output format.
645        #[arg(long, short = 'f', value_enum, default_value = "text")]
646        format: ListFormat,
647        /// Include additional fields where supported.
648        #[arg(long, short = 'v')]
649        verbose: bool,
650    },
651    /// Install a plugin from URL.
652    #[command(visible_alias = "i")]
653    Install {
654        /// Discourse name.
655        discourse: String,
656        /// Plugin repository URL.
657        url: String,
658    },
659    /// Remove a plugin by name.
660    #[command(visible_alias = "rm")]
661    Remove {
662        /// Discourse name.
663        discourse: String,
664        /// Plugin name.
665        name: String,
666    },
667}
668
669#[derive(Subcommand)]
670pub enum ThemeCommand {
671    /// List installed themes.
672    #[command(visible_alias = "ls")]
673    List {
674        /// Discourse name.
675        discourse: String,
676        /// Output format.
677        #[arg(long, short = 'f', value_enum, default_value = "text")]
678        format: ListFormat,
679        /// Include additional fields where supported.
680        #[arg(long, short = 'v')]
681        verbose: bool,
682    },
683    /// Install a theme from URL.
684    #[command(visible_alias = "i")]
685    Install {
686        /// Discourse name.
687        discourse: String,
688        /// Theme repository URL.
689        url: String,
690    },
691    /// Remove a theme by name.
692    #[command(visible_alias = "rm")]
693    Remove {
694        /// Discourse name.
695        discourse: String,
696        /// Theme name.
697        name: String,
698    },
699    /// Pull a theme to a local JSON file.
700    #[command(visible_alias = "pl")]
701    Pull {
702        /// Discourse name.
703        discourse: String,
704        /// Theme ID (from `dsc theme list`).
705        theme_id: u64,
706        /// Destination file path (auto-derived from theme name when omitted).
707        local_path: Option<PathBuf>,
708    },
709    /// Push a local JSON file to create or update a theme.
710    #[command(visible_alias = "ps")]
711    Push {
712        /// Discourse name.
713        discourse: String,
714        /// Local JSON file path.
715        local_path: PathBuf,
716        /// Theme ID to update (creates a new theme when omitted).
717        theme_id: Option<u64>,
718    },
719    /// Duplicate a theme and print the new theme ID.
720    #[command(visible_alias = "dup")]
721    Duplicate {
722        /// Discourse name.
723        discourse: String,
724        /// Theme ID to duplicate (from `dsc theme list`).
725        theme_id: u64,
726    },
727}
728
729#[derive(Subcommand)]
730pub enum PmCommand {
731    /// Send a private message.
732    #[command(visible_alias = "s")]
733    Send {
734        /// Discourse name.
735        discourse: String,
736        /// Recipient(s) — comma-separated usernames or group names.
737        recipients: String,
738        /// PM title / subject.
739        #[arg(long, short = 't')]
740        title: String,
741        /// Input file path. Reads stdin when omitted or `-`.
742        local_path: Option<PathBuf>,
743    },
744    /// List PMs for a user.
745    #[command(visible_alias = "ls")]
746    List {
747        /// Discourse name.
748        discourse: String,
749        /// Username whose PMs to list.
750        username: String,
751        /// Direction / view: inbox | sent | archive | unread | new.
752        #[arg(long, short = 'd', default_value = "inbox")]
753        direction: String,
754        /// Output format.
755        #[arg(long, short = 'f', value_enum, default_value = "text")]
756        format: ListFormat,
757    },
758}
759
760#[derive(Subcommand)]
761pub enum ApiKeyCommand {
762    /// List API keys.
763    #[command(visible_alias = "ls")]
764    List {
765        /// Discourse name.
766        discourse: String,
767        /// Output format.
768        #[arg(long, short = 'f', value_enum, default_value = "text")]
769        format: ListFormat,
770    },
771    /// Create a new API key. The secret is only shown at creation time —
772    /// capture it from the output.
773    #[command(visible_alias = "cr")]
774    Create {
775        /// Discourse name.
776        discourse: String,
777        /// Description / label for the key (shown in admin UI).
778        description: String,
779        /// Username the key acts as. Omit for a global all-users key.
780        #[arg(long, short = 'u')]
781        username: Option<String>,
782        /// Output format.
783        #[arg(long, short = 'f', value_enum, default_value = "text")]
784        format: ListFormat,
785    },
786    /// Revoke an API key by ID.
787    #[command(visible_alias = "rm")]
788    Revoke {
789        /// Discourse name.
790        discourse: String,
791        /// API key ID (from `dsc api-key list`).
792        key_id: u64,
793    },
794}
795
796#[derive(Subcommand)]
797pub enum InviteCommand {
798    /// Invite a single email address.
799    #[command(visible_alias = "s")]
800    Send {
801        /// Discourse name.
802        discourse: String,
803        /// Email address to invite.
804        email: String,
805        /// Add invitee to one or more groups on accept (repeatable).
806        #[arg(long, short = 'g')]
807        group: Vec<u64>,
808        /// Land the invitee on a specific topic on accept.
809        #[arg(long, short = 't')]
810        topic: Option<u64>,
811        /// Custom invitation message.
812        #[arg(long, short = 'm')]
813        message: Option<String>,
814    },
815    /// Bulk-invite from a file (or stdin) of email addresses.
816    #[command(visible_alias = "b")]
817    Bulk {
818        /// Discourse name.
819        discourse: String,
820        /// Path to a file of email addresses (one per line; blank lines and
821        /// `#` comments ignored). Reads stdin when omitted or `-`.
822        local_path: Option<PathBuf>,
823        /// Add every invitee to one or more groups on accept (repeatable).
824        #[arg(long, short = 'g')]
825        group: Vec<u64>,
826        /// Land every invitee on a specific topic on accept.
827        #[arg(long, short = 't')]
828        topic: Option<u64>,
829        /// Custom invitation message attached to each invite.
830        #[arg(long, short = 'm')]
831        message: Option<String>,
832    },
833}
834
835#[derive(Subcommand)]
836pub enum UserCommand {
837    /// List users via the admin users endpoint.
838    #[command(visible_alias = "ls")]
839    List {
840        /// Discourse name.
841        discourse: String,
842        /// Listing type: active | new | staff | suspended | silenced | staged.
843        #[arg(long, short = 'l', default_value = "active")]
844        listing: String,
845        /// Page number (Discourse paginates 100 per page).
846        #[arg(long, short = 'p', default_value_t = 1)]
847        page: u32,
848        /// Output format.
849        #[arg(long, short = 'f', value_enum, default_value = "text")]
850        format: ListFormat,
851    },
852    /// Show detailed info for a user.
853    #[command(visible_alias = "i")]
854    Info {
855        /// Discourse name.
856        discourse: String,
857        /// Username.
858        username: String,
859        /// Output format.
860        #[arg(long, short = 'f', value_enum, default_value = "text")]
861        format: ListFormat,
862    },
863    /// Suspend a user.
864    #[command(visible_alias = "sus")]
865    Suspend {
866        /// Discourse name.
867        discourse: String,
868        /// Username.
869        username: String,
870        /// When the suspension ends. ISO-8601 timestamp (e.g.
871        /// `2026-12-31T00:00:00Z`) or `forever`.
872        #[arg(long, short = 'u', default_value = "forever")]
873        until: String,
874        /// Reason shown to the user and in the audit log.
875        #[arg(long, short = 'r', default_value = "")]
876        reason: String,
877    },
878    /// Remove a suspension from a user.
879    #[command(visible_alias = "uns")]
880    Unsuspend {
881        /// Discourse name.
882        discourse: String,
883        /// Username.
884        username: String,
885    },
886    /// Silence a user (prevents posting; less visible than suspend).
887    #[command(visible_alias = "sil")]
888    Silence {
889        /// Discourse name.
890        discourse: String,
891        /// Username.
892        username: String,
893        /// When the silence ends. ISO-8601 timestamp; empty means
894        /// indefinite.
895        #[arg(long, short = 'u', default_value = "")]
896        until: String,
897        /// Reason shown to the user and in the audit log.
898        #[arg(long, short = 'r', default_value = "")]
899        reason: String,
900    },
901    /// Lift a silence on a user.
902    #[command(visible_alias = "unsil")]
903    Unsilence {
904        /// Discourse name.
905        discourse: String,
906        /// Username.
907        username: String,
908    },
909    /// Grant the user the admin or moderator role.
910    #[command(visible_alias = "pr")]
911    Promote {
912        /// Discourse name.
913        discourse: String,
914        /// Username.
915        username: String,
916        /// Role to grant.
917        #[arg(long, short = 'r', value_enum)]
918        role: RoleArg,
919    },
920    /// Revoke the user's admin or moderator role.
921    #[command(visible_alias = "de")]
922    Demote {
923        /// Discourse name.
924        discourse: String,
925        /// Username.
926        username: String,
927        /// Role to revoke.
928        #[arg(long, short = 'r', value_enum)]
929        role: RoleArg,
930    },
931    /// Create a new user. `--approve` also marks the account approved
932    /// (needed when site requires manual approval). Password is either
933    /// supplied via stdin (`--password-stdin`) or omitted — in the
934    /// latter case the user will have to set one via the reset flow.
935    #[command(visible_alias = "cr")]
936    Create {
937        /// Discourse name.
938        discourse: String,
939        /// New user's email address.
940        email: String,
941        /// New user's username.
942        username: String,
943        /// Display name (optional).
944        #[arg(long, short = 'N')]
945        name: Option<String>,
946        /// Read the password from stdin instead of auto-reset.
947        #[arg(long)]
948        password_stdin: bool,
949        /// Also mark the user approved (for sites with manual approval).
950        #[arg(long)]
951        approve: bool,
952    },
953    /// Trigger Discourse's password-reset email flow for a user.
954    #[command(name = "password-reset", visible_aliases = ["pwreset", "pw-reset"])]
955    PasswordReset {
956        /// Discourse name.
957        discourse: String,
958        /// Username or email.
959        username: String,
960    },
961    /// Set a user's primary email address. Requires admin scope.
962    #[command(name = "email-set", visible_alias = "email")]
963    EmailSet {
964        /// Discourse name.
965        discourse: String,
966        /// Username.
967        username: String,
968        /// New email address.
969        email: String,
970    },
971    /// Show a user's recent public activity (topics + replies by default).
972    ///
973    /// Built for the "archive my own activity to a journal forum" loop —
974    /// pipe the markdown output straight into `dsc topic reply`/`topic new`.
975    #[command(visible_alias = "act")]
976    Activity {
977        /// Discourse name (the *source* forum to read activity from).
978        discourse: String,
979        /// Username whose activity to read.
980        username: String,
981        /// How far back to look. Accepts `7d`, `24h`, `30m`, `1w`, `90s`, or
982        /// an ISO-8601 timestamp / date. Omit to fetch everything available.
983        #[arg(long, short = 's')]
984        since: Option<String>,
985        /// Action types to include, comma-separated. Default: topics,replies.
986        /// Also recognises: mentions, quotes, likes, edits, responses.
987        #[arg(long, short = 't', default_value = "topics,replies")]
988        types: String,
989        /// Hard cap on number of items returned.
990        #[arg(long, short = 'L')]
991        limit: Option<u32>,
992        /// Output format.
993        #[arg(long, short = 'f', value_enum, default_value = "markdown")]
994        format: ActivityFormatArg,
995    },
996    /// Manage a user's group memberships.
997    #[command(visible_alias = "g")]
998    Groups {
999        #[command(subcommand)]
1000        command: UserGroupsCommand,
1001    },
1002}
1003
1004#[derive(ValueEnum, Clone, Copy)]
1005pub enum SectionArg {
1006    All,
1007    Growth,
1008    Activity,
1009    Health,
1010}
1011
1012#[derive(ValueEnum, Clone, Copy)]
1013pub enum AnalyticsFormat {
1014    /// Plain text (default). Fixed-width columns, no borders.
1015    Text,
1016    /// DuckDB-style box-drawing table. Falls through to `text` when
1017    /// stdout isn't a TTY.
1018    Table,
1019    /// Pretty JSON.
1020    Json,
1021    /// YAML.
1022    #[value(alias = "yml")]
1023    Yaml,
1024    /// Markdown bullet list per section.
1025    #[value(alias = "md")]
1026    Markdown,
1027    /// Markdown table per section.
1028    #[value(alias = "md-table", name = "markdown-table")]
1029    MarkdownTable,
1030    /// CSV — one row per metric.
1031    Csv,
1032}
1033
1034#[derive(ValueEnum, Clone, Copy)]
1035pub enum ActivityFormatArg {
1036    Text,
1037    Json,
1038    #[value(alias = "yml")]
1039    Yaml,
1040    #[value(alias = "md")]
1041    Markdown,
1042    Csv,
1043}
1044
1045#[derive(ValueEnum, Clone, Copy)]
1046pub enum RoleArg {
1047    Admin,
1048    Moderator,
1049}
1050
1051#[derive(Subcommand)]
1052pub enum UserGroupsCommand {
1053    /// List the groups a user belongs to.
1054    #[command(visible_alias = "ls")]
1055    List {
1056        /// Discourse name.
1057        discourse: String,
1058        /// Target username.
1059        username: String,
1060        /// Output format.
1061        #[arg(long, short = 'f', value_enum, default_value = "text")]
1062        format: ListFormat,
1063    },
1064    /// Add a user to a group.
1065    #[command(visible_alias = "a")]
1066    Add {
1067        /// Discourse name.
1068        discourse: String,
1069        /// Target username.
1070        username: String,
1071        /// Group ID.
1072        group_id: u64,
1073        /// Send Discourse notification to the user.
1074        #[arg(long)]
1075        notify: bool,
1076    },
1077    /// Remove a user from a group.
1078    #[command(visible_alias = "rm")]
1079    Remove {
1080        /// Discourse name.
1081        discourse: String,
1082        /// Target username.
1083        username: String,
1084        /// Group ID.
1085        group_id: u64,
1086    },
1087}
1088
1089#[derive(Subcommand)]
1090pub enum PostCommand {
1091    /// Pull a post's raw Markdown to a local file.
1092    #[command(visible_alias = "pl")]
1093    Pull {
1094        /// Discourse name.
1095        discourse: String,
1096        /// Post ID.
1097        post_id: u64,
1098        /// Output file path. Prints to stdout when omitted.
1099        local_path: Option<PathBuf>,
1100    },
1101    /// Push a local file to update a post (alias: edit).
1102    #[command(visible_alias = "ps", alias = "edit")]
1103    Push {
1104        /// Discourse name.
1105        discourse: String,
1106        /// Post ID.
1107        post_id: u64,
1108        /// Input file path. Reads stdin when omitted or `-`.
1109        local_path: Option<PathBuf>,
1110    },
1111    /// Delete a post by ID.
1112    #[command(visible_alias = "rm")]
1113    Delete {
1114        /// Discourse name.
1115        discourse: String,
1116        /// Post ID.
1117        post_id: u64,
1118    },
1119    /// Move a post to a different topic.
1120    #[command(visible_alias = "mv")]
1121    Move {
1122        /// Discourse name.
1123        discourse: String,
1124        /// Post ID to move.
1125        post_id: u64,
1126        /// Destination topic ID.
1127        #[arg(long = "to-topic", short = 't')]
1128        to_topic: u64,
1129    },
1130}
1131
1132#[derive(Subcommand)]
1133pub enum TagCommand {
1134    /// List every tag on the Discourse.
1135    #[command(visible_alias = "ls")]
1136    List {
1137        /// Discourse name.
1138        discourse: String,
1139        /// Output format.
1140        #[arg(long, short = 'f', value_enum, default_value = "text")]
1141        format: ListFormat,
1142    },
1143    /// Pull the tag taxonomy (tags + tag groups) to a local file.
1144    #[command(visible_alias = "pl")]
1145    Pull {
1146        /// Discourse name.
1147        discourse: String,
1148        /// Output file (default: tags.yaml). Extension determines format (.yaml/.json).
1149        #[arg(default_value = "tags.yaml")]
1150        local_path: PathBuf,
1151    },
1152    /// Push a local taxonomy file to the server (upsert; optionally prune).
1153    #[command(visible_alias = "ps")]
1154    Push {
1155        /// Discourse name.
1156        discourse: String,
1157        /// Input taxonomy file.
1158        local_path: PathBuf,
1159        /// Delete server tags/groups absent from the file.
1160        #[arg(long)]
1161        prune: bool,
1162    },
1163    /// Rename a tag, preserving topic associations.
1164    ///
1165    /// Discourse rewrites every topic's tag list in-place, so this avoids
1166    /// the delete-and-recreate pattern that loses topic membership.
1167    #[command(visible_alias = "rn")]
1168    Rename {
1169        /// Discourse name.
1170        discourse: String,
1171        /// Current tag name.
1172        old_name: String,
1173        /// New tag name.
1174        new_name: String,
1175    },
1176}
1177
1178#[derive(Subcommand)]
1179pub enum SettingCommand {
1180    /// Set a site setting on a Discourse (or all tagged Discourses).
1181    ///
1182    /// Usage:
1183    ///   dsc setting set <discourse> <setting> <value>
1184    ///   dsc setting set --tags <tag1,tag2> <setting> <value>
1185    #[command(visible_alias = "s")]
1186    Set {
1187        /// Discourse name. Required unless `--tags` is provided.
1188        discourse: Option<String>,
1189        /// Setting key. Required.
1190        setting: Option<String>,
1191        /// Setting value. Required.
1192        value: Option<String>,
1193        /// Tag filter (comma/semicolon separated, match-any). Apply across all
1194        /// Discourses matching any of the tags. When set, omit `<discourse>`
1195        /// and pass `<setting> <value>` as the only positionals.
1196        #[arg(long, value_name = "tag1,tag2")]
1197        tags: Option<String>,
1198    },
1199
1200    /// Get the current value of a site setting.
1201    #[command(visible_alias = "g")]
1202    Get {
1203        /// Discourse name.
1204        discourse: String,
1205        /// Setting key.
1206        setting: String,
1207    },
1208
1209    /// List all site settings.
1210    #[command(visible_alias = "ls")]
1211    List {
1212        /// Discourse name.
1213        discourse: String,
1214        /// Output format.
1215        #[arg(long, short = 'f', value_enum, default_value = "text")]
1216        format: ListFormat,
1217        /// Show output even when list is empty.
1218        #[arg(long, short = 'v')]
1219        verbose: bool,
1220    },
1221
1222    /// Snapshot all site settings (with metadata) to a local file.
1223    ///
1224    /// See spec/setting-sync.md for the full schema and workflow. The
1225    /// generated file is a self-documenting YAML (or JSON) including each
1226    /// setting's default, type, category, and description.
1227    #[command(visible_alias = "pl")]
1228    Pull {
1229        /// Discourse name.
1230        discourse: String,
1231        /// Output path. Format detected by extension (.json → JSON,
1232        /// otherwise YAML). Defaults to `settings.yaml`.
1233        #[arg(default_value = "settings.yaml")]
1234        local_path: PathBuf,
1235        /// Only include settings whose value differs from default. Produces
1236        /// a manageable file (~50-100 entries) suitable for version control.
1237        #[arg(long, short = 'c')]
1238        changed_only: bool,
1239        /// Limit to settings in this category (e.g. `required`, `email`,
1240        /// `security`).
1241        #[arg(long)]
1242        category: Option<String>,
1243    },
1244
1245    /// Apply a settings snapshot file to a Discourse (idempotent).
1246    ///
1247    /// Compares each setting in the file against the server and PUTs only
1248    /// values that differ. Combine with `--dry-run` to preview the plan.
1249    #[command(visible_alias = "ph")]
1250    Push {
1251        /// Discourse name.
1252        discourse: String,
1253        /// Path to the settings snapshot file (YAML or JSON).
1254        local_path: PathBuf,
1255        /// For settings present on the server but absent from the file,
1256        /// reset them to their default value. Off by default (file describes
1257        /// only the values you care about).
1258        #[arg(long)]
1259        reset_unlisted: bool,
1260    },
1261
1262    /// Compare site settings between two sources.
1263    ///
1264    /// Each source can be a Discourse name (live fetch) or a path to a
1265    /// snapshot file produced by `dsc setting pull`. Sources are detected
1266    /// by whether the argument refers to an existing file on disk; if not,
1267    /// it is treated as a Discourse name.
1268    #[command(visible_alias = "df")]
1269    Diff {
1270        /// First source: Discourse name or snapshot file path.
1271        source: String,
1272        /// Second source: Discourse name or snapshot file path.
1273        target: String,
1274        /// Filter to settings where at least one source differs from default.
1275        /// Reduces noise when most settings on both sides are still default.
1276        #[arg(long, short = 'c')]
1277        changed_only: bool,
1278        /// Limit to settings in this category (e.g. `required`, `email`).
1279        /// Only effective when both sources carry category metadata.
1280        #[arg(long)]
1281        category: Option<String>,
1282        /// Output format.
1283        #[arg(long, short = 'f', value_enum, default_value = "text")]
1284        format: ListFormat,
1285    },
1286}
1287
1288#[derive(ValueEnum, Clone, Copy)]
1289pub enum CompletionShell {
1290    /// Bash shell.
1291    Bash,
1292    /// Zsh shell.
1293    Zsh,
1294    /// Fish shell.
1295    Fish,
1296}
1297
1298impl From<CompletionShell> for Shell {
1299    fn from(value: CompletionShell) -> Self {
1300        match value {
1301            CompletionShell::Bash => Shell::Bash,
1302            CompletionShell::Zsh => Shell::Zsh,
1303            CompletionShell::Fish => Shell::Fish,
1304        }
1305    }
1306}
1307
1308#[derive(ValueEnum, Clone)]
1309pub enum OutputFormat {
1310    /// Plain text.
1311    #[value(alias = "plaintext")]
1312    Text,
1313    /// Markdown list.
1314    Markdown,
1315    /// Markdown table.
1316    MarkdownTable,
1317    /// Pretty JSON.
1318    Json,
1319    /// YAML.
1320    #[value(alias = "yml")]
1321    Yaml,
1322    /// CSV.
1323    Csv,
1324    /// One base URL per line (pipe-friendly).
1325    #[value(alias = "url")]
1326    Urls,
1327}
1328
1329#[derive(ValueEnum, Clone, Copy)]
1330pub enum ListFormat {
1331    /// Plain text.
1332    Text,
1333    /// Pretty JSON.
1334    Json,
1335    /// YAML.
1336    #[value(alias = "yml")]
1337    Yaml,
1338}
1339
1340#[derive(ValueEnum, Clone, Copy)]
1341pub enum StructuredFormat {
1342    /// Pretty JSON.
1343    Json,
1344    /// YAML.
1345    #[value(alias = "yml")]
1346    Yaml,
1347}