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 /// Send invites — single or bulk from a file.
104 #[command(visible_alias = "inv")]
105 Invite {
106 #[command(subcommand)]
107 command: InviteCommand,
108 },
109 /// Manage API keys (admin scope).
110 #[command(visible_alias = "ak")]
111 ApiKey {
112 #[command(subcommand)]
113 command: ApiKeyCommand,
114 },
115 /// Send and list private messages.
116 #[command(visible_alias = "msg")]
117 Pm {
118 #[command(subcommand)]
119 command: PmCommand,
120 },
121 /// Create/list/restore backups.
122 #[command(visible_alias = "bk")]
123 Backup {
124 #[command(subcommand)]
125 command: BackupCommand,
126 },
127 /// List/pull/push color palettes.
128 #[command(visible_alias = "pal")]
129 Palette {
130 #[command(subcommand)]
131 command: PaletteCommand,
132 },
133 /// List/install/remove plugins.
134 #[command(visible_alias = "plg")]
135 Plugin {
136 #[command(subcommand)]
137 command: PluginCommand,
138 },
139 /// List/install/remove/pull/push/duplicate themes.
140 #[command(visible_alias = "th")]
141 Theme {
142 #[command(subcommand)]
143 command: ThemeCommand,
144 },
145 /// Update site settings.
146 #[command(visible_alias = "set")]
147 Setting {
148 #[command(subcommand)]
149 command: SettingCommand,
150 },
151 /// List tags and apply/remove them on topics.
152 #[command(visible_alias = "tg")]
153 Tag {
154 #[command(subcommand)]
155 command: TagCommand,
156 },
157 /// Post-level operations: edit / delete / move.
158 #[command(visible_alias = "po")]
159 Post {
160 #[command(subcommand)]
161 command: PostCommand,
162 },
163 /// Open a Discourse in the default browser.
164 #[command(visible_alias = "o")]
165 Open {
166 /// Discourse name.
167 discourse: String,
168 },
169 /// Harden a fresh Ubuntu server reachable via `ssh root@host`.
170 ///
171 /// **Stage 1 (current):** creates a non-root sudo user, installs the
172 /// given pubkey to their authorized_keys, and verifies the new-user
173 /// SSH login works. Does NOT yet tighten sshd_config, install Docker
174 /// / fail2ban / etc — those come in follow-up releases.
175 ///
176 /// Defaults can be overridden in the `[harden]` block of dsc.toml;
177 /// the flags below override that block on a per-run basis.
178 #[command(visible_alias = "hd")]
179 Harden {
180 /// Target hostname or IP (reachable via SSH).
181 host: String,
182 /// Username to SSH in as initially. Defaults to `root`, which is
183 /// what a fresh cloud-provisioned box typically has.
184 #[arg(long, default_value = "root")]
185 ssh_user: String,
186 /// Username for the new sudo-enabled non-root account. Overrides
187 /// `[harden].new_user` from dsc.toml. Built-in default: `discourse`.
188 #[arg(long)]
189 new_user: Option<String>,
190 /// SSH port to move the daemon to in stage 2. Overrides
191 /// `[harden].ssh_port`. Built-in default: 2227. Parsed now so the
192 /// CLI is stable; not yet applied in stage 1.
193 #[arg(long)]
194 ssh_port: Option<u16>,
195 /// Path to an SSH public key file whose contents will be added to
196 /// the new user's authorized_keys. A typical value is
197 /// `~/.ssh/<hostname>.pub` — the per-server keypair pattern in
198 /// the Bawmedical hardening playbook.
199 #[arg(long)]
200 pubkey_file: PathBuf,
201 },
202 /// Community-health analytics — growth, activity, and health metrics
203 /// for a Discourse, with optional period-over-period comparison.
204 ///
205 /// See `spec/analytics.md` for the full spec. v1 ships every metric
206 /// that maps onto a single `/admin/reports/{id}.json` endpoint;
207 /// derivation-heavy ones (e.g. lost regulars, top-10 share) print
208 /// `— (n/i)` until follow-up implementation lands.
209 #[command(visible_alias = "stats")]
210 Analytics {
211 /// Discourse name.
212 discourse: String,
213 /// Window to report on. Same syntax as `dsc user activity --since`
214 /// (e.g. `7d`, `24h`, `1m`, ISO-8601). Ignored when `--snapshot`
215 /// is set. Default: 30d.
216 #[arg(long, short = 's', default_value = "30d")]
217 since: String,
218 /// Also fetch the immediately preceding window of equal length and
219 /// show a delta column. Mutually exclusive with `--snapshot`.
220 #[arg(long, short = 'c', conflicts_with = "snapshot")]
221 compare: bool,
222 /// Multi-window snapshot mode. Reports each metric across several
223 /// preset windows (`--periods`) so you see growth/health trends
224 /// at a glance. Replaces `--since` + `--compare`.
225 #[arg(long)]
226 snapshot: bool,
227 /// Comma-separated periods for `--snapshot`. Default: `24h,7d,30d,1y`.
228 #[arg(long, requires = "snapshot")]
229 periods: Option<String>,
230 /// Restrict output to one section.
231 #[arg(long, value_enum, default_value = "all")]
232 section: SectionArg,
233 /// Output format. `table` is DuckDB-style box-drawing; falls
234 /// through to `text` automatically when stdout isn't a TTY.
235 #[arg(long, short = 'f', value_enum, default_value = "text")]
236 format: AnalyticsFormat,
237 },
238 /// Search topics on a Discourse.
239 #[command(visible_alias = "s")]
240 Search {
241 /// Discourse name.
242 discourse: String,
243 /// Search query (passed through verbatim, including any
244 /// Discourse filter syntax like `category:foo` or `@user`).
245 query: String,
246 /// Output format.
247 #[arg(long, short = 'f', value_enum, default_value = "text")]
248 format: ListFormat,
249 },
250 /// Upload a file. Prints the resulting upload:// short URL by default.
251 #[command(visible_alias = "u")]
252 Upload {
253 /// Discourse name.
254 discourse: String,
255 /// Path to the file to upload.
256 file: PathBuf,
257 /// Discourse upload context. Default `composer` is correct for
258 /// embedding in posts; other values include `avatar`,
259 /// `profile_background`, `card_background`, `custom_emoji`.
260 #[arg(long, short = 't', default_value = "composer")]
261 upload_type: String,
262 /// Output format. Text mode prints just the short URL.
263 #[arg(long, short = 'f', value_enum, default_value = "text")]
264 format: ListFormat,
265 },
266 /// Inspect and validate configuration.
267 #[command(visible_alias = "cfg")]
268 Config {
269 #[command(subcommand)]
270 command: ConfigCommand,
271 },
272 /// Generate shell completion scripts.
273 #[command(visible_alias = "comp")]
274 Completions {
275 /// Target shell.
276 #[arg(value_enum)]
277 shell: CompletionShell,
278 /// Output directory. Prints to stdout when omitted.
279 #[arg(long, short = 'd')]
280 dir: Option<PathBuf>,
281 },
282 /// Print the dsc version.
283 #[command(visible_alias = "ver")]
284 Version,
285}
286
287#[derive(Subcommand)]
288pub enum ConfigCommand {
289 /// Probe each configured Discourse: API auth and (optionally) SSH reachability.
290 #[command(visible_alias = "ck")]
291 Check {
292 /// Output format.
293 #[arg(long, short = 'f', value_enum, default_value = "text")]
294 format: ListFormat,
295 /// Skip the SSH reachability probe.
296 #[arg(long)]
297 skip_ssh: bool,
298 },
299}
300
301#[derive(Subcommand)]
302pub enum ListCommand {
303 /// Sort discourse entries by name and rewrite config in-place.
304 /// Also inserts placeholder values for unset template keys.
305 #[command(visible_alias = "ty")]
306 Tidy,
307}
308
309#[derive(Subcommand)]
310pub enum EmojiCommand {
311 /// Upload one emoji file, or bulk-upload from a directory.
312 #[command(visible_alias = "a")]
313 Add {
314 /// Discourse name.
315 discourse: String,
316 /// Local file or directory path.
317 emoji_path: PathBuf,
318 /// Optional emoji name (file uploads only).
319 emoji_name: Option<String>,
320 },
321
322 /// List custom emojis on a Discourse.
323 #[command(visible_alias = "ls")]
324 List {
325 /// Discourse name.
326 discourse: String,
327 /// Output format.
328 #[arg(long, short = 'f', value_enum, default_value = "text")]
329 format: ListFormat,
330 /// Include additional fields where supported.
331 #[arg(long, short = 'v')]
332 verbose: bool,
333 /// Render inline images when terminal protocol support is available.
334 #[arg(long, short = 'i')]
335 inline: bool,
336 },
337}
338
339#[derive(Subcommand)]
340pub enum TopicCommand {
341 /// Pull a topic to a local Markdown file.
342 #[command(visible_alias = "pl")]
343 Pull {
344 /// Discourse name.
345 discourse: String,
346 /// Topic ID.
347 topic_id: u64,
348 /// Destination file or directory (auto-derived when omitted).
349 local_path: Option<PathBuf>,
350 },
351 /// Push a local Markdown file to a topic.
352 #[command(visible_alias = "ps")]
353 Push {
354 /// Discourse name.
355 discourse: String,
356 /// Topic ID.
357 topic_id: u64,
358 /// Local Markdown file path.
359 local_path: PathBuf,
360 },
361 /// Sync a topic and local Markdown file using newest timestamp.
362 #[command(visible_alias = "sy")]
363 Sync {
364 /// Discourse name.
365 discourse: String,
366 /// Topic ID.
367 topic_id: u64,
368 /// Local Markdown file path.
369 local_path: PathBuf,
370 /// Skip sync confirmation prompt.
371 #[arg(long, short = 'y')]
372 yes: bool,
373 },
374 /// Reply to a topic with content from a file or stdin.
375 #[command(visible_alias = "r")]
376 Reply {
377 /// Discourse name.
378 discourse: String,
379 /// Topic ID.
380 topic_id: u64,
381 /// Input file path. Reads stdin when omitted or `-`.
382 local_path: Option<PathBuf>,
383 },
384 /// Create a new topic in a category, body from a file or stdin.
385 #[command(visible_alias = "n")]
386 New {
387 /// Discourse name.
388 discourse: String,
389 /// Target category ID.
390 category_id: u64,
391 /// Topic title.
392 #[arg(long, short = 't')]
393 title: String,
394 /// Input file path. Reads stdin when omitted or `-`.
395 local_path: Option<PathBuf>,
396 },
397}
398
399#[derive(Subcommand)]
400pub enum CategoryCommand {
401 /// List categories.
402 #[command(visible_alias = "ls")]
403 List {
404 /// Discourse name.
405 discourse: String,
406 /// Output format.
407 #[arg(long, short = 'f', value_enum, default_value = "text")]
408 format: ListFormat,
409 /// Include additional fields where supported.
410 #[arg(long, short = 'v')]
411 verbose: bool,
412 /// Show category hierarchy tree.
413 #[arg(long)]
414 tree: bool,
415 },
416 /// Copy a category to another Discourse.
417 #[command(visible_alias = "cp")]
418 Copy {
419 /// Source discourse name.
420 discourse: String,
421 /// Target discourse name (defaults to source when omitted).
422 #[arg(long, short = 't')]
423 target: Option<String>,
424 /// Category ID or slug.
425 category: String,
426 },
427 /// Pull all topics from a category into local Markdown files.
428 #[command(visible_alias = "pl")]
429 Pull {
430 /// Discourse name.
431 discourse: String,
432 /// Category ID or slug.
433 category: String,
434 /// Destination directory (auto-derived when omitted).
435 local_path: Option<PathBuf>,
436 },
437 /// Push local Markdown files into a category.
438 #[command(visible_alias = "ps")]
439 Push {
440 /// Discourse name.
441 discourse: String,
442 /// Category ID or slug.
443 category: String,
444 /// Local directory containing Markdown files.
445 local_path: PathBuf,
446 },
447}
448
449#[derive(Subcommand)]
450pub enum GroupCommand {
451 /// List groups.
452 #[command(visible_alias = "ls")]
453 List {
454 /// Discourse name.
455 discourse: String,
456 /// Output format.
457 #[arg(long, short = 'f', value_enum, default_value = "text")]
458 format: ListFormat,
459 /// Include additional fields where supported.
460 #[arg(long, short = 'v')]
461 verbose: bool,
462 },
463 /// Show group details.
464 #[command(visible_alias = "i")]
465 Info {
466 /// Discourse name.
467 discourse: String,
468 /// Group ID.
469 group: u64,
470 /// Output format.
471 #[arg(long, short = 'f', value_enum, default_value = "json")]
472 format: StructuredFormat,
473 },
474 /// List members of a group.
475 #[command(visible_alias = "m")]
476 Members {
477 /// Discourse name.
478 discourse: String,
479 /// Group ID.
480 group: u64,
481 /// Output format.
482 #[arg(long, short = 'f', value_enum, default_value = "text")]
483 format: ListFormat,
484 },
485 /// Copy a group to another Discourse.
486 #[command(visible_alias = "cp")]
487 Copy {
488 /// Source discourse name.
489 discourse: String,
490 /// Target discourse name (defaults to source when omitted).
491 #[arg(long, short = 't')]
492 target: Option<String>,
493 /// Group ID.
494 group: u64,
495 },
496 /// Bulk add members to a group from a file (or stdin) of email addresses.
497 #[command(visible_alias = "a")]
498 Add {
499 /// Discourse name.
500 discourse: String,
501 /// Group ID.
502 group: u64,
503 /// Path to a file of email addresses (one per line; blank
504 /// lines and `#` comments are ignored). Reads stdin when
505 /// omitted or `-`.
506 local_path: Option<PathBuf>,
507 /// Send Discourse notifications to added users.
508 #[arg(long)]
509 notify: bool,
510 },
511}
512
513#[derive(Subcommand)]
514pub enum BackupCommand {
515 /// Create a new backup.
516 #[command(visible_alias = "cr")]
517 Create {
518 /// Discourse name.
519 discourse: String,
520 },
521 /// List backups.
522 #[command(visible_alias = "ls")]
523 List {
524 /// Discourse name.
525 discourse: String,
526 /// Output format.
527 #[arg(long, short = 'f', value_enum, default_value = "text")]
528 format: OutputFormat,
529 /// Include additional fields where supported.
530 #[arg(long, short = 'v')]
531 verbose: bool,
532 },
533 /// Restore a backup.
534 #[command(visible_alias = "rs")]
535 Restore {
536 /// Discourse name.
537 discourse: String,
538 /// Backup filename/path on the target system.
539 backup_path: String,
540 },
541}
542
543#[derive(Subcommand)]
544pub enum PaletteCommand {
545 /// List color palettes.
546 #[command(visible_alias = "ls")]
547 List {
548 /// Discourse name.
549 discourse: String,
550 /// Output format.
551 #[arg(long, short = 'f', value_enum, default_value = "text")]
552 format: ListFormat,
553 /// Include additional fields where supported.
554 #[arg(long, short = 'v')]
555 verbose: bool,
556 },
557 /// Pull a palette to local JSON.
558 #[command(visible_alias = "pl")]
559 Pull {
560 /// Discourse name.
561 discourse: String,
562 /// Palette ID.
563 palette_id: u64,
564 /// Destination file path (auto-derived when omitted).
565 local_path: Option<PathBuf>,
566 },
567 /// Push local JSON to create or update a palette.
568 #[command(visible_alias = "ps")]
569 Push {
570 /// Discourse name.
571 discourse: String,
572 /// Local JSON file path.
573 local_path: PathBuf,
574 /// Palette ID to update (creates a new palette when omitted).
575 palette_id: Option<u64>,
576 },
577}
578
579#[derive(Subcommand)]
580pub enum PluginCommand {
581 /// List installed plugins.
582 #[command(visible_alias = "ls")]
583 List {
584 /// Discourse name.
585 discourse: String,
586 /// Output format.
587 #[arg(long, short = 'f', value_enum, default_value = "text")]
588 format: ListFormat,
589 /// Include additional fields where supported.
590 #[arg(long, short = 'v')]
591 verbose: bool,
592 },
593 /// Install a plugin from URL.
594 #[command(visible_alias = "i")]
595 Install {
596 /// Discourse name.
597 discourse: String,
598 /// Plugin repository URL.
599 url: String,
600 },
601 /// Remove a plugin by name.
602 #[command(visible_alias = "rm")]
603 Remove {
604 /// Discourse name.
605 discourse: String,
606 /// Plugin name.
607 name: String,
608 },
609}
610
611#[derive(Subcommand)]
612pub enum ThemeCommand {
613 /// List installed themes.
614 #[command(visible_alias = "ls")]
615 List {
616 /// Discourse name.
617 discourse: String,
618 /// Output format.
619 #[arg(long, short = 'f', value_enum, default_value = "text")]
620 format: ListFormat,
621 /// Include additional fields where supported.
622 #[arg(long, short = 'v')]
623 verbose: bool,
624 },
625 /// Install a theme from URL.
626 #[command(visible_alias = "i")]
627 Install {
628 /// Discourse name.
629 discourse: String,
630 /// Theme repository URL.
631 url: String,
632 },
633 /// Remove a theme by name.
634 #[command(visible_alias = "rm")]
635 Remove {
636 /// Discourse name.
637 discourse: String,
638 /// Theme name.
639 name: String,
640 },
641 /// Pull a theme to a local JSON file.
642 #[command(visible_alias = "pl")]
643 Pull {
644 /// Discourse name.
645 discourse: String,
646 /// Theme ID (from `dsc theme list`).
647 theme_id: u64,
648 /// Destination file path (auto-derived from theme name when omitted).
649 local_path: Option<PathBuf>,
650 },
651 /// Push a local JSON file to create or update a theme.
652 #[command(visible_alias = "ps")]
653 Push {
654 /// Discourse name.
655 discourse: String,
656 /// Local JSON file path.
657 local_path: PathBuf,
658 /// Theme ID to update (creates a new theme when omitted).
659 theme_id: Option<u64>,
660 },
661 /// Duplicate a theme and print the new theme ID.
662 #[command(visible_alias = "dup")]
663 Duplicate {
664 /// Discourse name.
665 discourse: String,
666 /// Theme ID to duplicate (from `dsc theme list`).
667 theme_id: u64,
668 },
669}
670
671#[derive(Subcommand)]
672pub enum PmCommand {
673 /// Send a private message.
674 #[command(visible_alias = "s")]
675 Send {
676 /// Discourse name.
677 discourse: String,
678 /// Recipient(s) — comma-separated usernames or group names.
679 recipients: String,
680 /// PM title / subject.
681 #[arg(long, short = 't')]
682 title: String,
683 /// Input file path. Reads stdin when omitted or `-`.
684 local_path: Option<PathBuf>,
685 },
686 /// List PMs for a user.
687 #[command(visible_alias = "ls")]
688 List {
689 /// Discourse name.
690 discourse: String,
691 /// Username whose PMs to list.
692 username: String,
693 /// Direction / view: inbox | sent | archive | unread | new.
694 #[arg(long, short = 'd', default_value = "inbox")]
695 direction: String,
696 /// Output format.
697 #[arg(long, short = 'f', value_enum, default_value = "text")]
698 format: ListFormat,
699 },
700}
701
702#[derive(Subcommand)]
703pub enum ApiKeyCommand {
704 /// List API keys.
705 #[command(visible_alias = "ls")]
706 List {
707 /// Discourse name.
708 discourse: String,
709 /// Output format.
710 #[arg(long, short = 'f', value_enum, default_value = "text")]
711 format: ListFormat,
712 },
713 /// Create a new API key. The secret is only shown at creation time —
714 /// capture it from the output.
715 #[command(visible_alias = "cr")]
716 Create {
717 /// Discourse name.
718 discourse: String,
719 /// Description / label for the key (shown in admin UI).
720 description: String,
721 /// Username the key acts as. Omit for a global all-users key.
722 #[arg(long, short = 'u')]
723 username: Option<String>,
724 /// Output format.
725 #[arg(long, short = 'f', value_enum, default_value = "text")]
726 format: ListFormat,
727 },
728 /// Revoke an API key by ID.
729 #[command(visible_alias = "rm")]
730 Revoke {
731 /// Discourse name.
732 discourse: String,
733 /// API key ID (from `dsc api-key list`).
734 key_id: u64,
735 },
736}
737
738#[derive(Subcommand)]
739pub enum InviteCommand {
740 /// Invite a single email address.
741 #[command(visible_alias = "s")]
742 Send {
743 /// Discourse name.
744 discourse: String,
745 /// Email address to invite.
746 email: String,
747 /// Add invitee to one or more groups on accept (repeatable).
748 #[arg(long, short = 'g')]
749 group: Vec<u64>,
750 /// Land the invitee on a specific topic on accept.
751 #[arg(long, short = 't')]
752 topic: Option<u64>,
753 /// Custom invitation message.
754 #[arg(long, short = 'm')]
755 message: Option<String>,
756 },
757 /// Bulk-invite from a file (or stdin) of email addresses.
758 #[command(visible_alias = "b")]
759 Bulk {
760 /// Discourse name.
761 discourse: String,
762 /// Path to a file of email addresses (one per line; blank lines and
763 /// `#` comments ignored). Reads stdin when omitted or `-`.
764 local_path: Option<PathBuf>,
765 /// Add every invitee to one or more groups on accept (repeatable).
766 #[arg(long, short = 'g')]
767 group: Vec<u64>,
768 /// Land every invitee on a specific topic on accept.
769 #[arg(long, short = 't')]
770 topic: Option<u64>,
771 /// Custom invitation message attached to each invite.
772 #[arg(long, short = 'm')]
773 message: Option<String>,
774 },
775}
776
777#[derive(Subcommand)]
778pub enum UserCommand {
779 /// List users via the admin users endpoint.
780 #[command(visible_alias = "ls")]
781 List {
782 /// Discourse name.
783 discourse: String,
784 /// Listing type: active | new | staff | suspended | silenced | staged.
785 #[arg(long, short = 'l', default_value = "active")]
786 listing: String,
787 /// Page number (Discourse paginates 100 per page).
788 #[arg(long, short = 'p', default_value_t = 1)]
789 page: u32,
790 /// Output format.
791 #[arg(long, short = 'f', value_enum, default_value = "text")]
792 format: ListFormat,
793 },
794 /// Show detailed info for a user.
795 #[command(visible_alias = "i")]
796 Info {
797 /// Discourse name.
798 discourse: String,
799 /// Username.
800 username: String,
801 /// Output format.
802 #[arg(long, short = 'f', value_enum, default_value = "text")]
803 format: ListFormat,
804 },
805 /// Suspend a user.
806 #[command(visible_alias = "sus")]
807 Suspend {
808 /// Discourse name.
809 discourse: String,
810 /// Username.
811 username: String,
812 /// When the suspension ends. ISO-8601 timestamp (e.g.
813 /// `2026-12-31T00:00:00Z`) or `forever`.
814 #[arg(long, short = 'u', default_value = "forever")]
815 until: String,
816 /// Reason shown to the user and in the audit log.
817 #[arg(long, short = 'r', default_value = "")]
818 reason: String,
819 },
820 /// Remove a suspension from a user.
821 #[command(visible_alias = "uns")]
822 Unsuspend {
823 /// Discourse name.
824 discourse: String,
825 /// Username.
826 username: String,
827 },
828 /// Silence a user (prevents posting; less visible than suspend).
829 #[command(visible_alias = "sil")]
830 Silence {
831 /// Discourse name.
832 discourse: String,
833 /// Username.
834 username: String,
835 /// When the silence ends. ISO-8601 timestamp; empty means
836 /// indefinite.
837 #[arg(long, short = 'u', default_value = "")]
838 until: String,
839 /// Reason shown to the user and in the audit log.
840 #[arg(long, short = 'r', default_value = "")]
841 reason: String,
842 },
843 /// Lift a silence on a user.
844 #[command(visible_alias = "unsil")]
845 Unsilence {
846 /// Discourse name.
847 discourse: String,
848 /// Username.
849 username: String,
850 },
851 /// Grant the user the admin or moderator role.
852 #[command(visible_alias = "pr")]
853 Promote {
854 /// Discourse name.
855 discourse: String,
856 /// Username.
857 username: String,
858 /// Role to grant.
859 #[arg(long, short = 'r', value_enum)]
860 role: RoleArg,
861 },
862 /// Revoke the user's admin or moderator role.
863 #[command(visible_alias = "de")]
864 Demote {
865 /// Discourse name.
866 discourse: String,
867 /// Username.
868 username: String,
869 /// Role to revoke.
870 #[arg(long, short = 'r', value_enum)]
871 role: RoleArg,
872 },
873 /// Create a new user. `--approve` also marks the account approved
874 /// (needed when site requires manual approval). Password is either
875 /// supplied via stdin (`--password-stdin`) or omitted — in the
876 /// latter case the user will have to set one via the reset flow.
877 #[command(visible_alias = "cr")]
878 Create {
879 /// Discourse name.
880 discourse: String,
881 /// New user's email address.
882 email: String,
883 /// New user's username.
884 username: String,
885 /// Display name (optional).
886 #[arg(long, short = 'N')]
887 name: Option<String>,
888 /// Read the password from stdin instead of auto-reset.
889 #[arg(long)]
890 password_stdin: bool,
891 /// Also mark the user approved (for sites with manual approval).
892 #[arg(long)]
893 approve: bool,
894 },
895 /// Trigger Discourse's password-reset email flow for a user.
896 #[command(name = "password-reset", visible_aliases = ["pwreset", "pw-reset"])]
897 PasswordReset {
898 /// Discourse name.
899 discourse: String,
900 /// Username or email.
901 username: String,
902 },
903 /// Set a user's primary email address. Requires admin scope.
904 #[command(name = "email-set", visible_alias = "email")]
905 EmailSet {
906 /// Discourse name.
907 discourse: String,
908 /// Username.
909 username: String,
910 /// New email address.
911 email: String,
912 },
913 /// Show a user's recent public activity (topics + replies by default).
914 ///
915 /// Built for the "archive my own activity to a journal forum" loop —
916 /// pipe the markdown output straight into `dsc topic reply`/`topic new`.
917 #[command(visible_alias = "act")]
918 Activity {
919 /// Discourse name (the *source* forum to read activity from).
920 discourse: String,
921 /// Username whose activity to read.
922 username: String,
923 /// How far back to look. Accepts `7d`, `24h`, `30m`, `1w`, `90s`, or
924 /// an ISO-8601 timestamp / date. Omit to fetch everything available.
925 #[arg(long, short = 's')]
926 since: Option<String>,
927 /// Action types to include, comma-separated. Default: topics,replies.
928 /// Also recognises: mentions, quotes, likes, edits, responses.
929 #[arg(long, short = 't', default_value = "topics,replies")]
930 types: String,
931 /// Hard cap on number of items returned.
932 #[arg(long, short = 'L')]
933 limit: Option<u32>,
934 /// Output format.
935 #[arg(long, short = 'f', value_enum, default_value = "markdown")]
936 format: ActivityFormatArg,
937 },
938 /// Manage a user's group memberships.
939 #[command(visible_alias = "g")]
940 Groups {
941 #[command(subcommand)]
942 command: UserGroupsCommand,
943 },
944}
945
946#[derive(ValueEnum, Clone, Copy)]
947pub enum SectionArg {
948 All,
949 Growth,
950 Activity,
951 Health,
952}
953
954#[derive(ValueEnum, Clone, Copy)]
955pub enum AnalyticsFormat {
956 /// Plain text (default). Fixed-width columns, no borders.
957 Text,
958 /// DuckDB-style box-drawing table. Falls through to `text` when
959 /// stdout isn't a TTY.
960 Table,
961 /// Pretty JSON.
962 Json,
963 /// YAML.
964 #[value(alias = "yml")]
965 Yaml,
966 /// Markdown bullet list per section.
967 #[value(alias = "md")]
968 Markdown,
969 /// Markdown table per section.
970 #[value(alias = "md-table", name = "markdown-table")]
971 MarkdownTable,
972 /// CSV — one row per metric.
973 Csv,
974}
975
976#[derive(ValueEnum, Clone, Copy)]
977pub enum ActivityFormatArg {
978 Text,
979 Json,
980 #[value(alias = "yml")]
981 Yaml,
982 #[value(alias = "md")]
983 Markdown,
984 Csv,
985}
986
987#[derive(ValueEnum, Clone, Copy)]
988pub enum RoleArg {
989 Admin,
990 Moderator,
991}
992
993#[derive(Subcommand)]
994pub enum UserGroupsCommand {
995 /// List the groups a user belongs to.
996 #[command(visible_alias = "ls")]
997 List {
998 /// Discourse name.
999 discourse: String,
1000 /// Target username.
1001 username: String,
1002 /// Output format.
1003 #[arg(long, short = 'f', value_enum, default_value = "text")]
1004 format: ListFormat,
1005 },
1006 /// Add a user to a group.
1007 #[command(visible_alias = "a")]
1008 Add {
1009 /// Discourse name.
1010 discourse: String,
1011 /// Target username.
1012 username: String,
1013 /// Group ID.
1014 group_id: u64,
1015 /// Send Discourse notification to the user.
1016 #[arg(long)]
1017 notify: bool,
1018 },
1019 /// Remove a user from a group.
1020 #[command(visible_alias = "rm")]
1021 Remove {
1022 /// Discourse name.
1023 discourse: String,
1024 /// Target username.
1025 username: String,
1026 /// Group ID.
1027 group_id: u64,
1028 },
1029}
1030
1031#[derive(Subcommand)]
1032pub enum PostCommand {
1033 /// Edit a post by ID. Reads the new body from file or stdin.
1034 #[command(visible_alias = "e")]
1035 Edit {
1036 /// Discourse name.
1037 discourse: String,
1038 /// Post ID.
1039 post_id: u64,
1040 /// Input file path. Reads stdin when omitted or `-`.
1041 local_path: Option<PathBuf>,
1042 },
1043 /// Delete a post by ID.
1044 #[command(visible_alias = "rm")]
1045 Delete {
1046 /// Discourse name.
1047 discourse: String,
1048 /// Post ID.
1049 post_id: u64,
1050 },
1051 /// Move a post to a different topic.
1052 #[command(visible_alias = "mv")]
1053 Move {
1054 /// Discourse name.
1055 discourse: String,
1056 /// Post ID to move.
1057 post_id: u64,
1058 /// Destination topic ID.
1059 #[arg(long = "to-topic", short = 't')]
1060 to_topic: u64,
1061 },
1062}
1063
1064#[derive(Subcommand)]
1065pub enum TagCommand {
1066 /// List every tag on the Discourse.
1067 #[command(visible_alias = "ls")]
1068 List {
1069 /// Discourse name.
1070 discourse: String,
1071 /// Output format.
1072 #[arg(long, short = 'f', value_enum, default_value = "text")]
1073 format: ListFormat,
1074 },
1075 /// Add a tag to a topic.
1076 #[command(visible_alias = "a")]
1077 Apply {
1078 /// Discourse name.
1079 discourse: String,
1080 /// Topic ID.
1081 topic_id: u64,
1082 /// Tag to add.
1083 tag: String,
1084 },
1085 /// Remove a tag from a topic.
1086 #[command(visible_alias = "rm")]
1087 Remove {
1088 /// Discourse name.
1089 discourse: String,
1090 /// Topic ID.
1091 topic_id: u64,
1092 /// Tag to remove.
1093 tag: String,
1094 },
1095}
1096
1097#[derive(Subcommand)]
1098pub enum SettingCommand {
1099 /// Set a site setting on a Discourse (or all tagged Discourses).
1100 #[command(visible_alias = "s")]
1101 Set {
1102 /// Discourse name. Required when targeting a single discourse.
1103 discourse: String,
1104 /// Setting key.
1105 setting: String,
1106 /// Setting value.
1107 value: String,
1108 /// Optional tag filter (comma/semicolon separated, match-any). Ignored when discourse is specified.
1109 #[arg(long, value_name = "tag1,tag2")]
1110 tags: Option<String>,
1111 },
1112
1113 /// Get the current value of a site setting.
1114 #[command(visible_alias = "g")]
1115 Get {
1116 /// Discourse name.
1117 discourse: String,
1118 /// Setting key.
1119 setting: String,
1120 },
1121
1122 /// List all site settings.
1123 #[command(visible_alias = "ls")]
1124 List {
1125 /// Discourse name.
1126 discourse: String,
1127 /// Output format.
1128 #[arg(long, short = 'f', value_enum, default_value = "text")]
1129 format: ListFormat,
1130 /// Show output even when list is empty.
1131 #[arg(long, short = 'v')]
1132 verbose: bool,
1133 },
1134}
1135
1136#[derive(ValueEnum, Clone, Copy)]
1137pub enum CompletionShell {
1138 /// Bash shell.
1139 Bash,
1140 /// Zsh shell.
1141 Zsh,
1142 /// Fish shell.
1143 Fish,
1144}
1145
1146impl From<CompletionShell> for Shell {
1147 fn from(value: CompletionShell) -> Self {
1148 match value {
1149 CompletionShell::Bash => Shell::Bash,
1150 CompletionShell::Zsh => Shell::Zsh,
1151 CompletionShell::Fish => Shell::Fish,
1152 }
1153 }
1154}
1155
1156#[derive(ValueEnum, Clone)]
1157pub enum OutputFormat {
1158 /// Plain text.
1159 #[value(alias = "plaintext")]
1160 Text,
1161 /// Markdown list.
1162 Markdown,
1163 /// Markdown table.
1164 MarkdownTable,
1165 /// Pretty JSON.
1166 Json,
1167 /// YAML.
1168 #[value(alias = "yml")]
1169 Yaml,
1170 /// CSV.
1171 Csv,
1172 /// One base URL per line (pipe-friendly).
1173 #[value(alias = "url")]
1174 Urls,
1175}
1176
1177#[derive(ValueEnum, Clone, Copy)]
1178pub enum ListFormat {
1179 /// Plain text.
1180 Text,
1181 /// Pretty JSON.
1182 Json,
1183 /// YAML.
1184 #[value(alias = "yml")]
1185 Yaml,
1186}
1187
1188#[derive(ValueEnum, Clone, Copy)]
1189pub enum StructuredFormat {
1190 /// Pretty JSON.
1191 Json,
1192 /// YAML.
1193 #[value(alias = "yml")]
1194 Yaml,
1195}