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}