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