Skip to main content

dsc/
cli.rs

1use clap::{ArgAction, Parser, Subcommand, ValueEnum};
2use clap_complete::Shell;
3use std::path::PathBuf;
4
5#[derive(Parser)]
6#[command(name = "dsc")]
7#[command(about = "Discourse CLI", long_about = None)]
8pub struct Cli {
9    /// Path to the config file. If omitted, dsc searches standard locations.
10    #[arg(long, short = 'c')]
11    pub config: Option<PathBuf>,
12    /// Describe destructive actions without sending them. Read-only commands
13    /// ignore the flag.
14    #[arg(long, short = 'n', global = true)]
15    pub dry_run: bool,
16    #[command(subcommand)]
17    pub command: Commands,
18}
19
20#[derive(Subcommand)]
21pub enum Commands {
22    /// List configured Discourses.
23    #[command(visible_alias = "ls")]
24    List {
25        /// Output format for the listing.
26        #[arg(long, short = 'f', value_enum, default_value = "text")]
27        format: OutputFormat,
28        /// Filter by tags (comma/semicolon separated, match-any).
29        #[arg(long, value_name = "tag1,tag2")]
30        tags: Option<String>,
31        /// Open each listed Discourse base URL in a browser tab/window.
32        #[arg(long, short = 'o')]
33        open: bool,
34        /// Include empty results and verbose listing details where supported.
35        #[arg(long, short = 'v')]
36        verbose: bool,
37        #[command(subcommand)]
38        command: Option<ListCommand>,
39    },
40    /// Add one or more Discourses to the config.
41    #[command(visible_alias = "a")]
42    Add {
43        /// Comma-separated discourse names to add.
44        names: String,
45        /// Prompt for additional optional fields while adding.
46        #[arg(long, short = 'i')]
47        interactive: bool,
48    },
49    /// Import Discourses from a file or stdin.
50    #[command(visible_alias = "imp")]
51    Import {
52        /// Path to import input (text/CSV). Reads stdin when omitted.
53        path: Option<PathBuf>,
54    },
55    /// Run remote OS + Discourse update workflow for one or all Discourses.
56    #[command(visible_alias = "up")]
57    Update {
58        /// Discourse name, or 'all' to update every configured Discourse.
59        name: String,
60        /// Parallel update mode for `dsc update all`.
61        #[arg(long, short = 'p')]
62        parallel: bool,
63        /// Maximum workers when parallel mode is enabled (default: 3).
64        #[arg(long, short = 'm')]
65        max: Option<usize>,
66        /// Disable changelog posting (posting prompt is on by default).
67        #[arg(long = "no-changelog", action = ArgAction::SetFalse, default_value_t = true)]
68        post_changelog: bool,
69        /// Auto-confirm changelog posting prompt (non-interactive mode).
70        #[arg(long, short = 'y')]
71        yes: bool,
72    },
73    /// Manage custom emoji.
74    #[command(visible_alias = "em")]
75    Emoji {
76        #[command(subcommand)]
77        command: EmojiCommand,
78    },
79    /// Pull/push/sync topics as local Markdown.
80    #[command(visible_alias = "t")]
81    Topic {
82        #[command(subcommand)]
83        command: TopicCommand,
84    },
85    /// List/copy/pull/push categories.
86    #[command(visible_alias = "cat")]
87    Category {
88        #[command(subcommand)]
89        command: CategoryCommand,
90    },
91    /// List/inspect/copy groups.
92    #[command(visible_alias = "grp")]
93    Group {
94        #[command(subcommand)]
95        command: GroupCommand,
96    },
97    /// Operations that act from a user's perspective.
98    #[command(visible_alias = "usr")]
99    User {
100        #[command(subcommand)]
101        command: UserCommand,
102    },
103    /// Create/list/restore backups.
104    #[command(visible_alias = "bk")]
105    Backup {
106        #[command(subcommand)]
107        command: BackupCommand,
108    },
109    /// List/pull/push color palettes.
110    #[command(visible_alias = "pal")]
111    Palette {
112        #[command(subcommand)]
113        command: PaletteCommand,
114    },
115    /// List/install/remove plugins.
116    #[command(visible_alias = "plg")]
117    Plugin {
118        #[command(subcommand)]
119        command: PluginCommand,
120    },
121    /// List/install/remove/pull/push/duplicate themes.
122    #[command(visible_alias = "th")]
123    Theme {
124        #[command(subcommand)]
125        command: ThemeCommand,
126    },
127    /// Update site settings.
128    #[command(visible_alias = "set")]
129    Setting {
130        #[command(subcommand)]
131        command: SettingCommand,
132    },
133    /// List tags and apply/remove them on topics.
134    #[command(visible_alias = "tg")]
135    Tag {
136        #[command(subcommand)]
137        command: TagCommand,
138    },
139    /// Post-level operations: edit / delete / move.
140    #[command(visible_alias = "po")]
141    Post {
142        #[command(subcommand)]
143        command: PostCommand,
144    },
145    /// Open a Discourse in the default browser.
146    #[command(visible_alias = "o")]
147    Open {
148        /// Discourse name.
149        discourse: String,
150    },
151    /// Search topics on a Discourse.
152    #[command(visible_alias = "s")]
153    Search {
154        /// Discourse name.
155        discourse: String,
156        /// Search query (passed through verbatim, including any
157        /// Discourse filter syntax like `category:foo` or `@user`).
158        query: String,
159        /// Output format.
160        #[arg(long, short = 'f', value_enum, default_value = "text")]
161        format: ListFormat,
162    },
163    /// Upload a file. Prints the resulting upload:// short URL by default.
164    #[command(visible_alias = "u")]
165    Upload {
166        /// Discourse name.
167        discourse: String,
168        /// Path to the file to upload.
169        file: PathBuf,
170        /// Discourse upload context. Default `composer` is correct for
171        /// embedding in posts; other values include `avatar`,
172        /// `profile_background`, `card_background`, `custom_emoji`.
173        #[arg(long, short = 't', default_value = "composer")]
174        upload_type: String,
175        /// Output format. Text mode prints just the short URL.
176        #[arg(long, short = 'f', value_enum, default_value = "text")]
177        format: ListFormat,
178    },
179    /// Inspect and validate configuration.
180    #[command(visible_alias = "cfg")]
181    Config {
182        #[command(subcommand)]
183        command: ConfigCommand,
184    },
185    /// Generate shell completion scripts.
186    #[command(visible_alias = "comp")]
187    Completions {
188        /// Target shell.
189        #[arg(value_enum)]
190        shell: CompletionShell,
191        /// Output directory. Prints to stdout when omitted.
192        #[arg(long, short = 'd')]
193        dir: Option<PathBuf>,
194    },
195    /// Print the dsc version.
196    #[command(visible_alias = "ver")]
197    Version,
198}
199
200#[derive(Subcommand)]
201pub enum ConfigCommand {
202    /// Probe each configured Discourse: API auth and (optionally) SSH reachability.
203    #[command(visible_alias = "ck")]
204    Check {
205        /// Output format.
206        #[arg(long, short = 'f', value_enum, default_value = "text")]
207        format: ListFormat,
208        /// Skip the SSH reachability probe.
209        #[arg(long)]
210        skip_ssh: bool,
211    },
212}
213
214#[derive(Subcommand)]
215pub enum ListCommand {
216    /// Sort discourse entries by name and rewrite config in-place.
217    /// Also inserts placeholder values for unset template keys.
218    #[command(visible_alias = "ty")]
219    Tidy,
220}
221
222#[derive(Subcommand)]
223pub enum EmojiCommand {
224    /// Upload one emoji file, or bulk-upload from a directory.
225    #[command(visible_alias = "a")]
226    Add {
227        /// Discourse name.
228        discourse: String,
229        /// Local file or directory path.
230        emoji_path: PathBuf,
231        /// Optional emoji name (file uploads only).
232        emoji_name: Option<String>,
233    },
234
235    /// List custom emojis on a Discourse.
236    #[command(visible_alias = "ls")]
237    List {
238        /// Discourse name.
239        discourse: String,
240        /// Output format.
241        #[arg(long, short = 'f', value_enum, default_value = "text")]
242        format: ListFormat,
243        /// Include additional fields where supported.
244        #[arg(long, short = 'v')]
245        verbose: bool,
246        /// Render inline images when terminal protocol support is available.
247        #[arg(long, short = 'i')]
248        inline: bool,
249    },
250}
251
252#[derive(Subcommand)]
253pub enum TopicCommand {
254    /// Pull a topic to a local Markdown file.
255    #[command(visible_alias = "pl")]
256    Pull {
257        /// Discourse name.
258        discourse: String,
259        /// Topic ID.
260        topic_id: u64,
261        /// Destination file or directory (auto-derived when omitted).
262        local_path: Option<PathBuf>,
263    },
264    /// Push a local Markdown file to a topic.
265    #[command(visible_alias = "ps")]
266    Push {
267        /// Discourse name.
268        discourse: String,
269        /// Topic ID.
270        topic_id: u64,
271        /// Local Markdown file path.
272        local_path: PathBuf,
273    },
274    /// Sync a topic and local Markdown file using newest timestamp.
275    #[command(visible_alias = "sy")]
276    Sync {
277        /// Discourse name.
278        discourse: String,
279        /// Topic ID.
280        topic_id: u64,
281        /// Local Markdown file path.
282        local_path: PathBuf,
283        /// Skip sync confirmation prompt.
284        #[arg(long, short = 'y')]
285        yes: bool,
286    },
287    /// Reply to a topic with content from a file or stdin.
288    #[command(visible_alias = "r")]
289    Reply {
290        /// Discourse name.
291        discourse: String,
292        /// Topic ID.
293        topic_id: u64,
294        /// Input file path. Reads stdin when omitted or `-`.
295        local_path: Option<PathBuf>,
296    },
297    /// Create a new topic in a category, body from a file or stdin.
298    #[command(visible_alias = "n")]
299    New {
300        /// Discourse name.
301        discourse: String,
302        /// Target category ID.
303        category_id: u64,
304        /// Topic title.
305        #[arg(long, short = 't')]
306        title: String,
307        /// Input file path. Reads stdin when omitted or `-`.
308        local_path: Option<PathBuf>,
309    },
310}
311
312#[derive(Subcommand)]
313pub enum CategoryCommand {
314    /// List categories.
315    #[command(visible_alias = "ls")]
316    List {
317        /// Discourse name.
318        discourse: String,
319        /// Output format.
320        #[arg(long, short = 'f', value_enum, default_value = "text")]
321        format: ListFormat,
322        /// Include additional fields where supported.
323        #[arg(long, short = 'v')]
324        verbose: bool,
325        /// Show category hierarchy tree.
326        #[arg(long)]
327        tree: bool,
328    },
329    /// Copy a category to another Discourse.
330    #[command(visible_alias = "cp")]
331    Copy {
332        /// Source discourse name.
333        discourse: String,
334        /// Target discourse name (defaults to source when omitted).
335        #[arg(long, short = 't')]
336        target: Option<String>,
337        /// Category ID or slug.
338        category: String,
339    },
340    /// Pull all topics from a category into local Markdown files.
341    #[command(visible_alias = "pl")]
342    Pull {
343        /// Discourse name.
344        discourse: String,
345        /// Category ID or slug.
346        category: String,
347        /// Destination directory (auto-derived when omitted).
348        local_path: Option<PathBuf>,
349    },
350    /// Push local Markdown files into a category.
351    #[command(visible_alias = "ps")]
352    Push {
353        /// Discourse name.
354        discourse: String,
355        /// Category ID or slug.
356        category: String,
357        /// Local directory containing Markdown files.
358        local_path: PathBuf,
359    },
360}
361
362#[derive(Subcommand)]
363pub enum GroupCommand {
364    /// List groups.
365    #[command(visible_alias = "ls")]
366    List {
367        /// Discourse name.
368        discourse: String,
369        /// Output format.
370        #[arg(long, short = 'f', value_enum, default_value = "text")]
371        format: ListFormat,
372        /// Include additional fields where supported.
373        #[arg(long, short = 'v')]
374        verbose: bool,
375    },
376    /// Show group details.
377    #[command(visible_alias = "i")]
378    Info {
379        /// Discourse name.
380        discourse: String,
381        /// Group ID.
382        group: u64,
383        /// Output format.
384        #[arg(long, short = 'f', value_enum, default_value = "json")]
385        format: StructuredFormat,
386    },
387    /// List members of a group.
388    #[command(visible_alias = "m")]
389    Members {
390        /// Discourse name.
391        discourse: String,
392        /// Group ID.
393        group: u64,
394        /// Output format.
395        #[arg(long, short = 'f', value_enum, default_value = "text")]
396        format: ListFormat,
397    },
398    /// Copy a group to another Discourse.
399    #[command(visible_alias = "cp")]
400    Copy {
401        /// Source discourse name.
402        discourse: String,
403        /// Target discourse name (defaults to source when omitted).
404        #[arg(long, short = 't')]
405        target: Option<String>,
406        /// Group ID.
407        group: u64,
408    },
409    /// Bulk add members to a group from a file (or stdin) of email addresses.
410    #[command(visible_alias = "a")]
411    Add {
412        /// Discourse name.
413        discourse: String,
414        /// Group ID.
415        group: u64,
416        /// Path to a file of email addresses (one per line; blank
417        /// lines and `#` comments are ignored). Reads stdin when
418        /// omitted or `-`.
419        local_path: Option<PathBuf>,
420        /// Send Discourse notifications to added users.
421        #[arg(long)]
422        notify: bool,
423    },
424}
425
426#[derive(Subcommand)]
427pub enum BackupCommand {
428    /// Create a new backup.
429    #[command(visible_alias = "cr")]
430    Create {
431        /// Discourse name.
432        discourse: String,
433    },
434    /// List backups.
435    #[command(visible_alias = "ls")]
436    List {
437        /// Discourse name.
438        discourse: String,
439        /// Output format.
440        #[arg(long, short = 'f', value_enum, default_value = "text")]
441        format: OutputFormat,
442        /// Include additional fields where supported.
443        #[arg(long, short = 'v')]
444        verbose: bool,
445    },
446    /// Restore a backup.
447    #[command(visible_alias = "rs")]
448    Restore {
449        /// Discourse name.
450        discourse: String,
451        /// Backup filename/path on the target system.
452        backup_path: String,
453    },
454}
455
456#[derive(Subcommand)]
457pub enum PaletteCommand {
458    /// List color palettes.
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    },
470    /// Pull a palette to local JSON.
471    #[command(visible_alias = "pl")]
472    Pull {
473        /// Discourse name.
474        discourse: String,
475        /// Palette ID.
476        palette_id: u64,
477        /// Destination file path (auto-derived when omitted).
478        local_path: Option<PathBuf>,
479    },
480    /// Push local JSON to create or update a palette.
481    #[command(visible_alias = "ps")]
482    Push {
483        /// Discourse name.
484        discourse: String,
485        /// Local JSON file path.
486        local_path: PathBuf,
487        /// Palette ID to update (creates a new palette when omitted).
488        palette_id: Option<u64>,
489    },
490}
491
492#[derive(Subcommand)]
493pub enum PluginCommand {
494    /// List installed plugins.
495    #[command(visible_alias = "ls")]
496    List {
497        /// Discourse name.
498        discourse: String,
499        /// Output format.
500        #[arg(long, short = 'f', value_enum, default_value = "text")]
501        format: ListFormat,
502        /// Include additional fields where supported.
503        #[arg(long, short = 'v')]
504        verbose: bool,
505    },
506    /// Install a plugin from URL.
507    #[command(visible_alias = "i")]
508    Install {
509        /// Discourse name.
510        discourse: String,
511        /// Plugin repository URL.
512        url: String,
513    },
514    /// Remove a plugin by name.
515    #[command(visible_alias = "rm")]
516    Remove {
517        /// Discourse name.
518        discourse: String,
519        /// Plugin name.
520        name: String,
521    },
522}
523
524#[derive(Subcommand)]
525pub enum ThemeCommand {
526    /// List installed themes.
527    #[command(visible_alias = "ls")]
528    List {
529        /// Discourse name.
530        discourse: String,
531        /// Output format.
532        #[arg(long, short = 'f', value_enum, default_value = "text")]
533        format: ListFormat,
534        /// Include additional fields where supported.
535        #[arg(long, short = 'v')]
536        verbose: bool,
537    },
538    /// Install a theme from URL.
539    #[command(visible_alias = "i")]
540    Install {
541        /// Discourse name.
542        discourse: String,
543        /// Theme repository URL.
544        url: String,
545    },
546    /// Remove a theme by name.
547    #[command(visible_alias = "rm")]
548    Remove {
549        /// Discourse name.
550        discourse: String,
551        /// Theme name.
552        name: String,
553    },
554    /// Pull a theme to a local JSON file.
555    #[command(visible_alias = "pl")]
556    Pull {
557        /// Discourse name.
558        discourse: String,
559        /// Theme ID (from `dsc theme list`).
560        theme_id: u64,
561        /// Destination file path (auto-derived from theme name when omitted).
562        local_path: Option<PathBuf>,
563    },
564    /// Push a local JSON file to create or update a theme.
565    #[command(visible_alias = "ps")]
566    Push {
567        /// Discourse name.
568        discourse: String,
569        /// Local JSON file path.
570        local_path: PathBuf,
571        /// Theme ID to update (creates a new theme when omitted).
572        theme_id: Option<u64>,
573    },
574    /// Duplicate a theme and print the new theme ID.
575    #[command(visible_alias = "dup")]
576    Duplicate {
577        /// Discourse name.
578        discourse: String,
579        /// Theme ID to duplicate (from `dsc theme list`).
580        theme_id: u64,
581    },
582}
583
584#[derive(Subcommand)]
585pub enum UserCommand {
586    /// Manage a user's group memberships.
587    #[command(visible_alias = "g")]
588    Groups {
589        #[command(subcommand)]
590        command: UserGroupsCommand,
591    },
592}
593
594#[derive(Subcommand)]
595pub enum UserGroupsCommand {
596    /// List the groups a user belongs to.
597    #[command(visible_alias = "ls")]
598    List {
599        /// Discourse name.
600        discourse: String,
601        /// Target username.
602        username: String,
603        /// Output format.
604        #[arg(long, short = 'f', value_enum, default_value = "text")]
605        format: ListFormat,
606    },
607    /// Add a user to a group.
608    #[command(visible_alias = "a")]
609    Add {
610        /// Discourse name.
611        discourse: String,
612        /// Target username.
613        username: String,
614        /// Group ID.
615        group_id: u64,
616        /// Send Discourse notification to the user.
617        #[arg(long)]
618        notify: bool,
619    },
620    /// Remove a user from a group.
621    #[command(visible_alias = "rm")]
622    Remove {
623        /// Discourse name.
624        discourse: String,
625        /// Target username.
626        username: String,
627        /// Group ID.
628        group_id: u64,
629    },
630}
631
632#[derive(Subcommand)]
633pub enum PostCommand {
634    /// Edit a post by ID. Reads the new body from file or stdin.
635    #[command(visible_alias = "e")]
636    Edit {
637        /// Discourse name.
638        discourse: String,
639        /// Post ID.
640        post_id: u64,
641        /// Input file path. Reads stdin when omitted or `-`.
642        local_path: Option<PathBuf>,
643    },
644    /// Delete a post by ID.
645    #[command(visible_alias = "rm")]
646    Delete {
647        /// Discourse name.
648        discourse: String,
649        /// Post ID.
650        post_id: u64,
651    },
652    /// Move a post to a different topic.
653    #[command(visible_alias = "mv")]
654    Move {
655        /// Discourse name.
656        discourse: String,
657        /// Post ID to move.
658        post_id: u64,
659        /// Destination topic ID.
660        #[arg(long = "to-topic", short = 't')]
661        to_topic: u64,
662    },
663}
664
665#[derive(Subcommand)]
666pub enum TagCommand {
667    /// List every tag on the Discourse.
668    #[command(visible_alias = "ls")]
669    List {
670        /// Discourse name.
671        discourse: String,
672        /// Output format.
673        #[arg(long, short = 'f', value_enum, default_value = "text")]
674        format: ListFormat,
675    },
676    /// Add a tag to a topic.
677    #[command(visible_alias = "a")]
678    Apply {
679        /// Discourse name.
680        discourse: String,
681        /// Topic ID.
682        topic_id: u64,
683        /// Tag to add.
684        tag: String,
685    },
686    /// Remove a tag from a topic.
687    #[command(visible_alias = "rm")]
688    Remove {
689        /// Discourse name.
690        discourse: String,
691        /// Topic ID.
692        topic_id: u64,
693        /// Tag to remove.
694        tag: String,
695    },
696}
697
698#[derive(Subcommand)]
699pub enum SettingCommand {
700    /// Set a site setting on a Discourse (or all tagged Discourses).
701    #[command(visible_alias = "s")]
702    Set {
703        /// Discourse name. Required when targeting a single discourse.
704        discourse: String,
705        /// Setting key.
706        setting: String,
707        /// Setting value.
708        value: String,
709        /// Optional tag filter (comma/semicolon separated, match-any). Ignored when discourse is specified.
710        #[arg(long, value_name = "tag1,tag2")]
711        tags: Option<String>,
712    },
713
714    /// Get the current value of a site setting.
715    #[command(visible_alias = "g")]
716    Get {
717        /// Discourse name.
718        discourse: String,
719        /// Setting key.
720        setting: String,
721    },
722
723    /// List all site settings.
724    #[command(visible_alias = "ls")]
725    List {
726        /// Discourse name.
727        discourse: String,
728        /// Output format.
729        #[arg(long, short = 'f', value_enum, default_value = "text")]
730        format: ListFormat,
731        /// Show output even when list is empty.
732        #[arg(long, short = 'v')]
733        verbose: bool,
734    },
735}
736
737#[derive(ValueEnum, Clone, Copy)]
738pub enum CompletionShell {
739    /// Bash shell.
740    Bash,
741    /// Zsh shell.
742    Zsh,
743    /// Fish shell.
744    Fish,
745}
746
747impl From<CompletionShell> for Shell {
748    fn from(value: CompletionShell) -> Self {
749        match value {
750            CompletionShell::Bash => Shell::Bash,
751            CompletionShell::Zsh => Shell::Zsh,
752            CompletionShell::Fish => Shell::Fish,
753        }
754    }
755}
756
757#[derive(ValueEnum, Clone)]
758pub enum OutputFormat {
759    /// Plain text.
760    #[value(alias = "plaintext")]
761    Text,
762    /// Markdown list.
763    Markdown,
764    /// Markdown table.
765    MarkdownTable,
766    /// Pretty JSON.
767    Json,
768    /// YAML.
769    #[value(alias = "yml")]
770    Yaml,
771    /// CSV.
772    Csv,
773    /// One base URL per line (pipe-friendly).
774    #[value(alias = "url")]
775    Urls,
776}
777
778#[derive(ValueEnum, Clone, Copy)]
779pub enum ListFormat {
780    /// Plain text.
781    Text,
782    /// Pretty JSON.
783    Json,
784    /// YAML.
785    #[value(alias = "yml")]
786    Yaml,
787}
788
789#[derive(ValueEnum, Clone, Copy)]
790pub enum StructuredFormat {
791    /// Pretty JSON.
792    Json,
793    /// YAML.
794    #[value(alias = "yml")]
795    Yaml,
796}