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    /// Show a richer view of one theme/component than `list`.
750    Show {
751        /// Discourse name.
752        discourse: String,
753        /// Theme ID (from `dsc theme list`).
754        theme_id: u64,
755        /// Output format.
756        #[arg(long, short = 'f', value_enum, default_value = "text")]
757        format: ListFormat,
758    },
759    /// Read and write a theme/component's settings (not site settings).
760    Setting {
761        #[command(subcommand)]
762        command: ThemeSettingCommand,
763    },
764    /// Enable a theme or component.
765    Enable {
766        /// Discourse name.
767        discourse: String,
768        /// Theme ID (from `dsc theme list`).
769        theme_id: u64,
770    },
771    /// Disable a theme or component.
772    Disable {
773        /// Discourse name.
774        discourse: String,
775        /// Theme ID (from `dsc theme list`).
776        theme_id: u64,
777    },
778    /// Attach a component to a parent theme (makes it active on that theme).
779    Attach {
780        /// Discourse name.
781        discourse: String,
782        /// Parent theme ID.
783        parent_id: u64,
784        /// Component (child theme) ID to attach.
785        component_id: u64,
786    },
787    /// Detach a component from a parent theme.
788    Detach {
789        /// Discourse name.
790        discourse: String,
791        /// Parent theme ID.
792        parent_id: u64,
793        /// Component (child theme) ID to detach.
794        component_id: u64,
795    },
796}
797
798#[derive(Subcommand)]
799pub enum ThemeSettingCommand {
800    /// List a theme/component's settings.
801    #[command(visible_alias = "ls")]
802    List {
803        /// Discourse name.
804        discourse: String,
805        /// Theme ID (from `dsc theme list`).
806        theme_id: u64,
807        /// Output format.
808        #[arg(long, short = 'f', value_enum, default_value = "text")]
809        format: ListFormat,
810    },
811    /// Print a single setting's current value.
812    Get {
813        /// Discourse name.
814        discourse: String,
815        /// Theme ID.
816        theme_id: u64,
817        /// Setting key (the `setting` name from `theme setting list`).
818        key: String,
819    },
820    /// Set a single setting. Value is sent verbatim (pass JSON text for
821    /// json-schema list settings). Honours global `--dry-run`.
822    Set {
823        /// Discourse name.
824        discourse: String,
825        /// Theme ID.
826        theme_id: u64,
827        /// Setting key.
828        key: String,
829        /// New value (verbatim).
830        value: String,
831    },
832}
833
834#[derive(Subcommand)]
835pub enum PmCommand {
836    /// Send a private message.
837    #[command(visible_alias = "s")]
838    Send {
839        /// Discourse name.
840        discourse: String,
841        /// Recipient(s) — comma-separated usernames or group names.
842        recipients: String,
843        /// PM title / subject.
844        #[arg(long, short = 't')]
845        title: String,
846        /// Input file path. Reads stdin when omitted or `-`.
847        local_path: Option<PathBuf>,
848    },
849    /// List PMs for a user.
850    #[command(visible_alias = "ls")]
851    List {
852        /// Discourse name.
853        discourse: String,
854        /// Username whose PMs to list.
855        username: String,
856        /// Direction / view: inbox | sent | archive | unread | new.
857        #[arg(long, short = 'd', default_value = "inbox")]
858        direction: String,
859        /// Output format.
860        #[arg(long, short = 'f', value_enum, default_value = "text")]
861        format: ListFormat,
862    },
863}
864
865#[derive(Subcommand)]
866pub enum ApiKeyCommand {
867    /// List API keys.
868    #[command(visible_alias = "ls")]
869    List {
870        /// Discourse name.
871        discourse: String,
872        /// Output format.
873        #[arg(long, short = 'f', value_enum, default_value = "text")]
874        format: ListFormat,
875    },
876    /// Create a new API key. The secret is only shown at creation time —
877    /// capture it from the output.
878    #[command(visible_alias = "cr")]
879    Create {
880        /// Discourse name.
881        discourse: String,
882        /// Description / label for the key (shown in admin UI).
883        description: String,
884        /// Username the key acts as. Omit for a global all-users key.
885        #[arg(long, short = 'u')]
886        username: Option<String>,
887        /// Output format.
888        #[arg(long, short = 'f', value_enum, default_value = "text")]
889        format: ListFormat,
890    },
891    /// Revoke an API key by ID.
892    #[command(visible_alias = "rm")]
893    Revoke {
894        /// Discourse name.
895        discourse: String,
896        /// API key ID (from `dsc api-key list`).
897        key_id: u64,
898    },
899}
900
901#[derive(Subcommand)]
902pub enum InviteCommand {
903    /// Invite a single email address.
904    #[command(visible_alias = "s")]
905    Send {
906        /// Discourse name.
907        discourse: String,
908        /// Email address to invite.
909        email: String,
910        /// Add invitee to one or more groups on accept (repeatable).
911        #[arg(long, short = 'g')]
912        group: Vec<u64>,
913        /// Land the invitee on a specific topic on accept.
914        #[arg(long, short = 't')]
915        topic: Option<u64>,
916        /// Custom invitation message.
917        #[arg(long, short = 'm')]
918        message: Option<String>,
919    },
920    /// Bulk-invite from a file (or stdin) of email addresses.
921    #[command(visible_alias = "b")]
922    Bulk {
923        /// Discourse name.
924        discourse: String,
925        /// Path to a file of email addresses (one per line; blank lines and
926        /// `#` comments ignored). Reads stdin when omitted or `-`.
927        local_path: Option<PathBuf>,
928        /// Add every invitee to one or more groups on accept (repeatable).
929        #[arg(long, short = 'g')]
930        group: Vec<u64>,
931        /// Land every invitee on a specific topic on accept.
932        #[arg(long, short = 't')]
933        topic: Option<u64>,
934        /// Custom invitation message attached to each invite.
935        #[arg(long, short = 'm')]
936        message: Option<String>,
937    },
938}
939
940#[derive(Subcommand)]
941pub enum UserCommand {
942    /// List users via the admin users endpoint.
943    #[command(visible_alias = "ls")]
944    List {
945        /// Discourse name.
946        discourse: String,
947        /// Listing type: active | new | staff | suspended | silenced | staged.
948        #[arg(long, short = 'l', default_value = "active")]
949        listing: String,
950        /// Page number (Discourse paginates 100 per page).
951        #[arg(long, short = 'p', default_value_t = 1)]
952        page: u32,
953        /// Output format.
954        #[arg(long, short = 'f', value_enum, default_value = "text")]
955        format: ListFormat,
956    },
957    /// Show detailed info for a user.
958    #[command(visible_alias = "i")]
959    Info {
960        /// Discourse name.
961        discourse: String,
962        /// Username.
963        username: String,
964        /// Output format.
965        #[arg(long, short = 'f', value_enum, default_value = "text")]
966        format: ListFormat,
967    },
968    /// Suspend a user.
969    #[command(visible_alias = "sus")]
970    Suspend {
971        /// Discourse name.
972        discourse: String,
973        /// Username.
974        username: String,
975        /// When the suspension ends. ISO-8601 timestamp (e.g.
976        /// `2026-12-31T00:00:00Z`) or `forever`.
977        #[arg(long, short = 'u', default_value = "forever")]
978        until: String,
979        /// Reason shown to the user and in the audit log.
980        #[arg(long, short = 'r', default_value = "")]
981        reason: String,
982    },
983    /// Remove a suspension from a user.
984    #[command(visible_alias = "uns")]
985    Unsuspend {
986        /// Discourse name.
987        discourse: String,
988        /// Username.
989        username: String,
990    },
991    /// Silence a user (prevents posting; less visible than suspend).
992    #[command(visible_alias = "sil")]
993    Silence {
994        /// Discourse name.
995        discourse: String,
996        /// Username.
997        username: String,
998        /// When the silence ends. ISO-8601 timestamp; empty means
999        /// indefinite.
1000        #[arg(long, short = 'u', default_value = "")]
1001        until: String,
1002        /// Reason shown to the user and in the audit log.
1003        #[arg(long, short = 'r', default_value = "")]
1004        reason: String,
1005    },
1006    /// Lift a silence on a user.
1007    #[command(visible_alias = "unsil")]
1008    Unsilence {
1009        /// Discourse name.
1010        discourse: String,
1011        /// Username.
1012        username: String,
1013    },
1014    /// Grant the user the admin or moderator role.
1015    #[command(visible_alias = "pr")]
1016    Promote {
1017        /// Discourse name.
1018        discourse: String,
1019        /// Username.
1020        username: String,
1021        /// Role to grant.
1022        #[arg(long, short = 'r', value_enum)]
1023        role: RoleArg,
1024    },
1025    /// Revoke the user's admin or moderator role.
1026    #[command(visible_alias = "de")]
1027    Demote {
1028        /// Discourse name.
1029        discourse: String,
1030        /// Username.
1031        username: String,
1032        /// Role to revoke.
1033        #[arg(long, short = 'r', value_enum)]
1034        role: RoleArg,
1035    },
1036    /// Create a new user. `--approve` also marks the account approved
1037    /// (needed when site requires manual approval). Password is either
1038    /// supplied via stdin (`--password-stdin`) or omitted — in the
1039    /// latter case the user will have to set one via the reset flow.
1040    #[command(visible_alias = "cr")]
1041    Create {
1042        /// Discourse name.
1043        discourse: String,
1044        /// New user's email address.
1045        email: String,
1046        /// New user's username.
1047        username: String,
1048        /// Display name (optional).
1049        #[arg(long, short = 'N')]
1050        name: Option<String>,
1051        /// Read the password from stdin instead of auto-reset.
1052        #[arg(long)]
1053        password_stdin: bool,
1054        /// Also mark the user approved (for sites with manual approval).
1055        #[arg(long)]
1056        approve: bool,
1057    },
1058    /// Trigger Discourse's password-reset email flow for a user.
1059    #[command(name = "password-reset", visible_aliases = ["pwreset", "pw-reset"])]
1060    PasswordReset {
1061        /// Discourse name.
1062        discourse: String,
1063        /// Username or email.
1064        username: String,
1065    },
1066    /// Set a user's primary email address. Requires admin scope.
1067    #[command(name = "email-set", visible_alias = "email")]
1068    EmailSet {
1069        /// Discourse name.
1070        discourse: String,
1071        /// Username.
1072        username: String,
1073        /// New email address.
1074        email: String,
1075    },
1076    /// Show a user's recent public activity (topics + replies by default).
1077    ///
1078    /// Built for the "archive my own activity to a journal forum" loop —
1079    /// pipe the markdown output straight into `dsc topic reply`/`topic new`.
1080    #[command(visible_alias = "act")]
1081    Activity {
1082        /// Discourse name (the *source* forum to read activity from).
1083        discourse: String,
1084        /// Username whose activity to read.
1085        username: String,
1086        /// How far back to look. Accepts `7d`, `24h`, `30m`, `1w`, `90s`, or
1087        /// an ISO-8601 timestamp / date. Omit to fetch everything available.
1088        #[arg(long, short = 's')]
1089        since: Option<String>,
1090        /// Action types to include, comma-separated. Default: topics,replies.
1091        /// Also recognises: mentions, quotes, likes, edits, responses.
1092        #[arg(long, short = 't', default_value = "topics,replies")]
1093        types: String,
1094        /// Hard cap on number of items returned.
1095        #[arg(long, short = 'L')]
1096        limit: Option<u32>,
1097        /// Output format.
1098        #[arg(long, short = 'f', value_enum, default_value = "markdown")]
1099        format: ActivityFormatArg,
1100    },
1101    /// Manage a user's group memberships.
1102    #[command(visible_alias = "g")]
1103    Groups {
1104        #[command(subcommand)]
1105        command: UserGroupsCommand,
1106    },
1107}
1108
1109#[derive(ValueEnum, Clone, Copy)]
1110pub enum SectionArg {
1111    All,
1112    Growth,
1113    Activity,
1114    Health,
1115}
1116
1117#[derive(ValueEnum, Clone, Copy)]
1118pub enum AnalyticsFormat {
1119    /// Plain text (default). Fixed-width columns, no borders.
1120    Text,
1121    /// DuckDB-style box-drawing table. Falls through to `text` when
1122    /// stdout isn't a TTY.
1123    Table,
1124    /// Pretty JSON.
1125    Json,
1126    /// YAML.
1127    #[value(alias = "yml")]
1128    Yaml,
1129    /// Markdown bullet list per section.
1130    #[value(alias = "md")]
1131    Markdown,
1132    /// Markdown table per section.
1133    #[value(alias = "md-table", name = "markdown-table")]
1134    MarkdownTable,
1135    /// CSV — one row per metric.
1136    Csv,
1137}
1138
1139#[derive(ValueEnum, Clone, Copy)]
1140pub enum ActivityFormatArg {
1141    Text,
1142    Json,
1143    #[value(alias = "yml")]
1144    Yaml,
1145    #[value(alias = "md")]
1146    Markdown,
1147    Csv,
1148}
1149
1150#[derive(ValueEnum, Clone, Copy)]
1151pub enum RoleArg {
1152    Admin,
1153    Moderator,
1154}
1155
1156#[derive(Subcommand)]
1157pub enum UserGroupsCommand {
1158    /// List the groups a user belongs to.
1159    #[command(visible_alias = "ls")]
1160    List {
1161        /// Discourse name.
1162        discourse: String,
1163        /// Target username.
1164        username: String,
1165        /// Output format.
1166        #[arg(long, short = 'f', value_enum, default_value = "text")]
1167        format: ListFormat,
1168    },
1169    /// Add a user to a group.
1170    #[command(visible_alias = "a")]
1171    Add {
1172        /// Discourse name.
1173        discourse: String,
1174        /// Target username.
1175        username: String,
1176        /// Group ID.
1177        group_id: u64,
1178        /// Send Discourse notification to the user.
1179        #[arg(long)]
1180        notify: bool,
1181    },
1182    /// Remove a user from a group.
1183    #[command(visible_alias = "rm")]
1184    Remove {
1185        /// Discourse name.
1186        discourse: String,
1187        /// Target username.
1188        username: String,
1189        /// Group ID.
1190        group_id: u64,
1191    },
1192}
1193
1194#[derive(Subcommand)]
1195pub enum PostCommand {
1196    /// Pull a post's raw Markdown to a local file.
1197    #[command(visible_alias = "pl")]
1198    Pull {
1199        /// Discourse name.
1200        discourse: String,
1201        /// Post ID.
1202        post_id: u64,
1203        /// Output file path. Prints to stdout when omitted.
1204        local_path: Option<PathBuf>,
1205    },
1206    /// Push a local file to update a post (alias: edit).
1207    #[command(visible_alias = "ps", alias = "edit")]
1208    Push {
1209        /// Discourse name.
1210        discourse: String,
1211        /// Post ID.
1212        post_id: u64,
1213        /// Input file path. Reads stdin when omitted or `-`.
1214        local_path: Option<PathBuf>,
1215    },
1216    /// Delete a post by ID.
1217    #[command(visible_alias = "rm")]
1218    Delete {
1219        /// Discourse name.
1220        discourse: String,
1221        /// Post ID.
1222        post_id: u64,
1223    },
1224    /// Move a post to a different topic.
1225    #[command(visible_alias = "mv")]
1226    Move {
1227        /// Discourse name.
1228        discourse: String,
1229        /// Post ID to move.
1230        post_id: u64,
1231        /// Destination topic ID.
1232        #[arg(long = "to-topic", short = 't')]
1233        to_topic: u64,
1234    },
1235}
1236
1237#[derive(Subcommand)]
1238pub enum TagCommand {
1239    /// List every tag on the Discourse.
1240    #[command(visible_alias = "ls")]
1241    List {
1242        /// Discourse name.
1243        discourse: String,
1244        /// Output format.
1245        #[arg(long, short = 'f', value_enum, default_value = "text")]
1246        format: ListFormat,
1247    },
1248    /// Pull the tag taxonomy (tags + tag groups) to a local file.
1249    #[command(visible_alias = "pl")]
1250    Pull {
1251        /// Discourse name.
1252        discourse: String,
1253        /// Output file (default: tags.yaml). Extension determines format (.yaml/.json).
1254        #[arg(default_value = "tags.yaml")]
1255        local_path: PathBuf,
1256    },
1257    /// Push a local taxonomy file to the server (upsert; optionally prune).
1258    #[command(visible_alias = "ps")]
1259    Push {
1260        /// Discourse name.
1261        discourse: String,
1262        /// Input taxonomy file.
1263        local_path: PathBuf,
1264        /// Delete server tags/groups absent from the file.
1265        #[arg(long)]
1266        prune: bool,
1267    },
1268    /// Rename a tag, preserving topic associations.
1269    ///
1270    /// Discourse rewrites every topic's tag list in-place, so this avoids
1271    /// the delete-and-recreate pattern that loses topic membership.
1272    #[command(visible_alias = "rn")]
1273    Rename {
1274        /// Discourse name.
1275        discourse: String,
1276        /// Current tag name.
1277        old_name: String,
1278        /// New tag name.
1279        new_name: String,
1280    },
1281}
1282
1283#[derive(Subcommand)]
1284pub enum SettingCommand {
1285    /// Set a site setting on a Discourse (or all tagged Discourses).
1286    ///
1287    /// Usage:
1288    ///   dsc setting set <discourse> <setting> <value>
1289    ///   dsc setting set --tags <tag1,tag2> <setting> <value>
1290    #[command(visible_alias = "s")]
1291    Set {
1292        /// Discourse name. Required unless `--tags` is provided.
1293        discourse: Option<String>,
1294        /// Setting key. Required.
1295        setting: Option<String>,
1296        /// Setting value. Required.
1297        value: Option<String>,
1298        /// Tag filter (comma/semicolon separated, match-any). Apply across all
1299        /// Discourses matching any of the tags. When set, omit `<discourse>`
1300        /// and pass `<setting> <value>` as the only positionals.
1301        #[arg(long, value_name = "tag1,tag2")]
1302        tags: Option<String>,
1303    },
1304
1305    /// Get the current value of a site setting.
1306    #[command(visible_alias = "g")]
1307    Get {
1308        /// Discourse name.
1309        discourse: String,
1310        /// Setting key.
1311        setting: String,
1312    },
1313
1314    /// List all site settings.
1315    #[command(visible_alias = "ls")]
1316    List {
1317        /// Discourse name.
1318        discourse: String,
1319        /// Output format.
1320        #[arg(long, short = 'f', value_enum, default_value = "text")]
1321        format: ListFormat,
1322        /// Show output even when list is empty.
1323        #[arg(long, short = 'v')]
1324        verbose: bool,
1325    },
1326
1327    /// Snapshot all site settings (with metadata) to a local file.
1328    ///
1329    /// See spec/setting-sync.md for the full schema and workflow. The
1330    /// generated file is a self-documenting YAML (or JSON) including each
1331    /// setting's default, type, category, and description.
1332    #[command(visible_alias = "pl")]
1333    Pull {
1334        /// Discourse name.
1335        discourse: String,
1336        /// Output path. Format detected by extension (.json → JSON,
1337        /// otherwise YAML). Defaults to `settings.yaml`.
1338        #[arg(default_value = "settings.yaml")]
1339        local_path: PathBuf,
1340        /// Only include settings whose value differs from default. Produces
1341        /// a manageable file (~50-100 entries) suitable for version control.
1342        #[arg(long, short = 'c')]
1343        changed_only: bool,
1344        /// Limit to settings in this category (e.g. `required`, `email`,
1345        /// `security`).
1346        #[arg(long)]
1347        category: Option<String>,
1348    },
1349
1350    /// Apply a settings snapshot file to a Discourse (idempotent).
1351    ///
1352    /// Compares each setting in the file against the server and PUTs only
1353    /// values that differ. Combine with `--dry-run` to preview the plan.
1354    #[command(visible_alias = "ph")]
1355    Push {
1356        /// Discourse name.
1357        discourse: String,
1358        /// Path to the settings snapshot file (YAML or JSON).
1359        local_path: PathBuf,
1360        /// For settings present on the server but absent from the file,
1361        /// reset them to their default value. Off by default (file describes
1362        /// only the values you care about).
1363        #[arg(long)]
1364        reset_unlisted: bool,
1365    },
1366
1367    /// Compare site settings between two sources.
1368    ///
1369    /// Each source can be a Discourse name (live fetch) or a path to a
1370    /// snapshot file produced by `dsc setting pull`. Sources are detected
1371    /// by whether the argument refers to an existing file on disk; if not,
1372    /// it is treated as a Discourse name.
1373    #[command(visible_alias = "df")]
1374    Diff {
1375        /// First source: Discourse name or snapshot file path.
1376        source: String,
1377        /// Second source: Discourse name or snapshot file path.
1378        target: String,
1379        /// Filter to settings where at least one source differs from default.
1380        /// Reduces noise when most settings on both sides are still default.
1381        #[arg(long, short = 'c')]
1382        changed_only: bool,
1383        /// Limit to settings in this category (e.g. `required`, `email`).
1384        /// Only effective when both sources carry category metadata.
1385        #[arg(long)]
1386        category: Option<String>,
1387        /// Output format.
1388        #[arg(long, short = 'f', value_enum, default_value = "text")]
1389        format: ListFormat,
1390    },
1391}
1392
1393#[derive(ValueEnum, Clone, Copy)]
1394pub enum CompletionShell {
1395    /// Bash shell.
1396    Bash,
1397    /// Zsh shell.
1398    Zsh,
1399    /// Fish shell.
1400    Fish,
1401}
1402
1403impl From<CompletionShell> for Shell {
1404    fn from(value: CompletionShell) -> Self {
1405        match value {
1406            CompletionShell::Bash => Shell::Bash,
1407            CompletionShell::Zsh => Shell::Zsh,
1408            CompletionShell::Fish => Shell::Fish,
1409        }
1410    }
1411}
1412
1413#[derive(ValueEnum, Clone)]
1414pub enum OutputFormat {
1415    /// Plain text.
1416    #[value(alias = "plaintext")]
1417    Text,
1418    /// Markdown list.
1419    Markdown,
1420    /// Markdown table.
1421    MarkdownTable,
1422    /// Pretty JSON.
1423    Json,
1424    /// YAML.
1425    #[value(alias = "yml")]
1426    Yaml,
1427    /// CSV.
1428    Csv,
1429    /// One base URL per line (pipe-friendly).
1430    #[value(alias = "url")]
1431    Urls,
1432}
1433
1434#[derive(ValueEnum, Clone, Copy)]
1435pub enum ListFormat {
1436    /// Plain text.
1437    Text,
1438    /// Pretty JSON.
1439    Json,
1440    /// YAML.
1441    #[value(alias = "yml")]
1442    Yaml,
1443}
1444
1445#[derive(ValueEnum, Clone, Copy)]
1446pub enum StructuredFormat {
1447    /// Pretty JSON.
1448    Json,
1449    /// YAML.
1450    #[value(alias = "yml")]
1451    Yaml,
1452}