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