dsc/cli.rs
1use clap::{ArgAction, Parser, Subcommand, ValueEnum};
2use clap_complete::Shell;
3use std::path::PathBuf;
4
5#[derive(Parser)]
6#[command(name = "dsc")]
7#[command(about = "Discourse CLI", long_about = None)]
8pub struct Cli {
9 /// Path to the config file. If omitted, `dsc` consults `$DSC_CONFIG`,
10 /// then searches `./dsc.toml`, `$DSC_CONFIG_HOME/dsc.toml`
11 /// (default `~/.config/dsc/dsc.toml`), then system locations.
12 /// Errors if the given file does not exist (no silent fallthrough).
13 /// See `dsc config` for the active selection.
14 #[arg(long, short = 'c')]
15 pub config: Option<PathBuf>,
16 /// Describe destructive actions without sending them. Read-only commands
17 /// ignore the flag.
18 #[arg(long, short = 'n', global = true)]
19 pub dry_run: bool,
20 #[command(subcommand)]
21 pub command: Commands,
22}
23
24#[derive(Subcommand)]
25pub enum Commands {
26 /// List configured Discourses.
27 #[command(visible_alias = "ls")]
28 List {
29 /// Output format for the listing.
30 #[arg(long, short = 'f', value_enum, default_value = "text")]
31 format: OutputFormat,
32 /// Filter by tags (comma/semicolon separated, match-any).
33 #[arg(long, value_name = "tag1,tag2")]
34 tags: Option<String>,
35 /// Open each listed Discourse base URL in a browser tab/window.
36 #[arg(long, short = 'o')]
37 open: bool,
38 /// Include empty results and verbose listing details where supported.
39 #[arg(long, short = 'v')]
40 verbose: bool,
41 #[command(subcommand)]
42 command: Option<ListCommand>,
43 },
44 /// Add one or more Discourses to the config.
45 #[command(visible_alias = "a")]
46 Add {
47 /// Comma-separated discourse names to add.
48 names: String,
49 /// Prompt for additional optional fields while adding.
50 #[arg(long, short = 'i')]
51 interactive: bool,
52 },
53 /// Import Discourses from a file or stdin.
54 #[command(visible_alias = "imp")]
55 Import {
56 /// Path to import input (text/CSV). Reads stdin when omitted.
57 path: Option<PathBuf>,
58 },
59 /// Run remote OS + Discourse update workflow for one or all Discourses.
60 #[command(visible_alias = "up")]
61 Update {
62 /// Discourse name, or 'all' to update every configured Discourse.
63 name: String,
64 /// Parallel update mode for `dsc update all`.
65 #[arg(long, short = 'p')]
66 parallel: bool,
67 /// Maximum workers when parallel mode is enabled (default: 3).
68 #[arg(long, short = 'm')]
69 max: Option<usize>,
70 /// Disable changelog posting (posting prompt is on by default).
71 #[arg(long = "no-changelog", action = ArgAction::SetFalse, default_value_t = true)]
72 post_changelog: bool,
73 /// Auto-confirm changelog posting prompt (non-interactive mode).
74 #[arg(long, short = 'y')]
75 yes: bool,
76 },
77 /// Manage custom emoji.
78 #[command(visible_alias = "em")]
79 Emoji {
80 #[command(subcommand)]
81 command: EmojiCommand,
82 },
83 /// Pull/push/sync topics as local Markdown.
84 #[command(visible_alias = "t")]
85 Topic {
86 #[command(subcommand)]
87 command: TopicCommand,
88 },
89 /// List/copy/pull/push categories.
90 #[command(visible_alias = "cat")]
91 Category {
92 #[command(subcommand)]
93 command: CategoryCommand,
94 },
95 /// List/inspect/copy groups.
96 #[command(visible_alias = "grp")]
97 Group {
98 #[command(subcommand)]
99 command: GroupCommand,
100 },
101 /// Operations that act from a user's perspective.
102 #[command(visible_alias = "usr")]
103 User {
104 #[command(subcommand)]
105 command: UserCommand,
106 },
107 /// Send invites — single or bulk from a file.
108 #[command(visible_alias = "inv")]
109 Invite {
110 #[command(subcommand)]
111 command: InviteCommand,
112 },
113 /// Manage API keys (admin scope).
114 #[command(visible_alias = "ak")]
115 ApiKey {
116 #[command(subcommand)]
117 command: ApiKeyCommand,
118 },
119 /// Send and list private messages.
120 #[command(visible_alias = "msg")]
121 Pm {
122 #[command(subcommand)]
123 command: PmCommand,
124 },
125 /// Create/list/restore backups.
126 #[command(visible_alias = "bk")]
127 Backup {
128 #[command(subcommand)]
129 command: BackupCommand,
130 },
131 /// List/pull/push color palettes.
132 #[command(visible_alias = "pal")]
133 Palette {
134 #[command(subcommand)]
135 command: PaletteCommand,
136 },
137 /// List/install/remove plugins.
138 #[command(visible_alias = "plg")]
139 Plugin {
140 #[command(subcommand)]
141 command: PluginCommand,
142 },
143 /// List/install/remove/pull/push/duplicate themes.
144 #[command(visible_alias = "th")]
145 Theme {
146 #[command(subcommand)]
147 command: ThemeCommand,
148 },
149 /// Update site settings.
150 #[command(visible_alias = "set")]
151 Setting {
152 #[command(subcommand)]
153 command: SettingCommand,
154 },
155 /// Manage the tag taxonomy: list/pull/push tags and tag groups.
156 #[command(visible_alias = "tg")]
157 Tag {
158 #[command(subcommand)]
159 command: TagCommand,
160 },
161 /// Post-level operations: edit / delete / move.
162 #[command(visible_alias = "po")]
163 Post {
164 #[command(subcommand)]
165 command: PostCommand,
166 },
167 /// Open a Discourse in the default browser.
168 #[command(visible_alias = "o")]
169 Open {
170 /// Discourse name.
171 discourse: String,
172 },
173 /// Harden a fresh Ubuntu server reachable via `ssh root@host`.
174 ///
175 /// **Stage 1 (current):** creates a non-root sudo user, installs the
176 /// given pubkey to their authorized_keys, and verifies the new-user
177 /// SSH login works. Does NOT yet tighten sshd_config, install Docker
178 /// / fail2ban / etc — those come in follow-up releases.
179 ///
180 /// Defaults can be overridden in the `[harden]` block of dsc.toml;
181 /// the flags below override that block on a per-run basis.
182 #[command(visible_alias = "hd")]
183 Harden {
184 /// Target hostname or IP (reachable via SSH).
185 host: String,
186 /// Username to SSH in as initially. Defaults to `root`, which is
187 /// what a fresh cloud-provisioned box typically has.
188 #[arg(long, default_value = "root")]
189 ssh_user: String,
190 /// Username for the new sudo-enabled non-root account. Overrides
191 /// `[harden].new_user` from dsc.toml. Built-in default: `discourse`.
192 #[arg(long)]
193 new_user: Option<String>,
194 /// SSH port to move the daemon to in stage 2. Overrides
195 /// `[harden].ssh_port`. Built-in default: 2227. Parsed now so the
196 /// CLI is stable; not yet applied in stage 1.
197 #[arg(long)]
198 ssh_port: Option<u16>,
199 /// Path to an SSH public key file whose contents will be added to
200 /// the new user's authorized_keys. A typical value is
201 /// `~/.ssh/<hostname>.pub` — the per-server keypair pattern in
202 /// the Bawmedical hardening playbook.
203 #[arg(long)]
204 pubkey_file: PathBuf,
205 },
206 /// Community-health analytics — growth, activity, and health metrics
207 /// for a Discourse, with optional period-over-period comparison.
208 ///
209 /// See `spec/analytics.md` for the full spec. v1 ships every metric
210 /// that maps onto a single `/admin/reports/{id}.json` endpoint;
211 /// derivation-heavy ones (e.g. lost regulars, top-10 share) print
212 /// `— (n/i)` until follow-up implementation lands.
213 #[command(visible_alias = "stats")]
214 Analytics {
215 /// Discourse name.
216 discourse: String,
217 /// Window to report on. Same syntax as `dsc user activity --since`
218 /// (e.g. `7d`, `24h`, `1m`, ISO-8601). Ignored when `--snapshot`
219 /// is set. Default: 30d.
220 #[arg(long, short = 's', default_value = "30d")]
221 since: String,
222 /// Also fetch the immediately preceding window of equal length and
223 /// show a delta column. Mutually exclusive with `--snapshot`.
224 #[arg(long, short = 'c', conflicts_with = "snapshot")]
225 compare: bool,
226 /// Multi-window snapshot mode. Reports each metric across several
227 /// preset windows (`--periods`) so you see growth/health trends
228 /// at a glance. Replaces `--since` + `--compare`.
229 #[arg(long)]
230 snapshot: bool,
231 /// Comma-separated periods for `--snapshot`. Default: `24h,7d,30d,1y`.
232 #[arg(long, requires = "snapshot")]
233 periods: Option<String>,
234 /// Restrict output to one section.
235 #[arg(long, value_enum, default_value = "all")]
236 section: SectionArg,
237 /// Output format. `table` is DuckDB-style box-drawing; falls
238 /// through to `text` automatically when stdout isn't a TTY.
239 #[arg(long, short = 'f', value_enum, default_value = "text")]
240 format: AnalyticsFormat,
241 },
242 /// Search topics on a Discourse.
243 #[command(visible_alias = "s")]
244 Search {
245 /// Discourse name.
246 discourse: String,
247 /// Search query (passed through verbatim, including any
248 /// Discourse filter syntax like `category:foo` or `@user`).
249 query: String,
250 /// Output format.
251 #[arg(long, short = 'f', value_enum, default_value = "text")]
252 format: ListFormat,
253 },
254 /// Upload a file. Prints the resulting upload:// short URL by default.
255 #[command(visible_alias = "u")]
256 Upload {
257 /// Discourse name.
258 discourse: String,
259 /// Path to the file to upload.
260 file: PathBuf,
261 /// Discourse upload context. Default `composer` is correct for
262 /// embedding in posts; other values include `avatar`,
263 /// `profile_background`, `card_background`, `custom_emoji`.
264 #[arg(long, short = 't', default_value = "composer")]
265 upload_type: String,
266 /// Output format. Text mode prints just the short URL.
267 #[arg(long, short = 'f', value_enum, default_value = "text")]
268 format: ListFormat,
269 },
270 /// Inspect and validate configuration.
271 #[command(visible_alias = "cfg")]
272 Config {
273 #[command(subcommand)]
274 command: Option<ConfigCommand>,
275 },
276 /// Generate shell completion scripts.
277 #[command(visible_alias = "comp")]
278 Completions {
279 /// Target shell.
280 #[arg(value_enum)]
281 shell: CompletionShell,
282 /// Output directory. Prints to stdout when omitted.
283 #[arg(long, short = 'd')]
284 dir: Option<PathBuf>,
285 },
286 /// Generate man pages for `dsc` and every subcommand.
287 ///
288 /// Writes one ROFF-formatted file per (sub)command (e.g. `dsc.1`,
289 /// `dsc-tag-pull.1`) into the given directory. Distro packagers
290 /// install these into section 1 of the man path. Run `gzip -9` on
291 /// the output if your packaging convention expects compressed pages.
292 #[command(visible_alias = "manpages")]
293 Man {
294 /// Output directory. Required - this command always writes to disk.
295 #[arg(long, short = 'd')]
296 dir: PathBuf,
297 },
298 /// Print the dsc version.
299 #[command(visible_alias = "ver")]
300 Version,
301}
302
303#[derive(Subcommand)]
304pub enum ConfigCommand {
305 /// Probe each configured Discourse: API auth and (optionally) SSH reachability.
306 #[command(visible_alias = "ck")]
307 Check {
308 /// Output format.
309 #[arg(long, short = 'f', value_enum, default_value = "text")]
310 format: ListFormat,
311 /// Skip the SSH reachability probe.
312 #[arg(long)]
313 skip_ssh: bool,
314 },
315}
316
317#[derive(Subcommand)]
318pub enum ListCommand {
319 /// Sort discourse entries by name and rewrite config in-place.
320 /// Also inserts placeholder values for unset template keys.
321 #[command(visible_alias = "ty")]
322 Tidy,
323}
324
325#[derive(Subcommand)]
326pub enum EmojiCommand {
327 /// Pull all custom emoji from a Discourse into a local directory.
328 #[command(visible_alias = "pl")]
329 Pull {
330 /// Discourse name.
331 discourse: String,
332 /// Local directory to save emoji images into.
333 output_dir: PathBuf,
334 },
335 /// Push (upload) one emoji file, or bulk-upload from a directory (alias: add).
336 #[command(visible_alias = "ps", alias = "add")]
337 Push {
338 /// Discourse name.
339 discourse: String,
340 /// Local file or directory path.
341 emoji_path: PathBuf,
342 /// Optional emoji name (file uploads only).
343 emoji_name: Option<String>,
344 },
345
346 /// List custom emojis on a Discourse.
347 #[command(visible_alias = "ls")]
348 List {
349 /// Discourse name.
350 discourse: String,
351 /// Output format.
352 #[arg(long, short = 'f', value_enum, default_value = "text")]
353 format: ListFormat,
354 /// Include additional fields where supported.
355 #[arg(long, short = 'v')]
356 verbose: bool,
357 /// Render inline images when terminal protocol support is available.
358 #[arg(long, short = 'i')]
359 inline: bool,
360 },
361}
362
363#[derive(Subcommand)]
364pub enum TopicCommand {
365 /// Pull a topic to a local Markdown file.
366 #[command(visible_alias = "pl")]
367 Pull {
368 /// Discourse name.
369 discourse: String,
370 /// Topic ID.
371 topic_id: u64,
372 /// Destination file or directory (auto-derived when omitted).
373 local_path: Option<PathBuf>,
374 /// Pull the entire thread (every post) as a single Markdown file
375 /// with YAML frontmatter and per-post headings. Default behaviour
376 /// (no `--full`) writes only the OP, which is what `topic push`
377 /// expects.
378 #[arg(long, short = 'F')]
379 full: bool,
380 },
381 /// Push a local Markdown file to a topic.
382 #[command(visible_alias = "ps")]
383 Push {
384 /// Discourse name.
385 discourse: String,
386 /// Topic ID.
387 topic_id: u64,
388 /// Local Markdown file path.
389 local_path: PathBuf,
390 /// Update the post without bumping the topic in the activity feed.
391 /// Use for silent maintenance edits (sends post[no_bump]=true).
392 #[arg(long)]
393 no_bump: bool,
394 /// Update the post without recording an edit-history revision
395 /// (sends post[skip_revision]=true). Suppresses the online audit
396 /// trail - use sparingly.
397 #[arg(long)]
398 skip_revision: bool,
399 },
400 /// Sync a topic and local Markdown file using newest timestamp.
401 #[command(visible_alias = "sy")]
402 Sync {
403 /// Discourse name.
404 discourse: String,
405 /// Topic ID.
406 topic_id: u64,
407 /// Local Markdown file path.
408 local_path: PathBuf,
409 /// Skip sync confirmation prompt.
410 #[arg(long, short = 'y')]
411 yes: bool,
412 },
413 /// Reply to a topic with content from a file or stdin.
414 #[command(visible_alias = "r")]
415 Reply {
416 /// Discourse name.
417 discourse: String,
418 /// Topic ID.
419 topic_id: u64,
420 /// Input file path. Reads stdin when omitted or `-`.
421 local_path: Option<PathBuf>,
422 /// Output format.
423 #[arg(long, short = 'f', value_enum, default_value = "text")]
424 format: ListFormat,
425 },
426 /// Create a new topic in a category, body from a file or stdin.
427 #[command(visible_alias = "n")]
428 New {
429 /// Discourse name.
430 discourse: String,
431 /// Target category ID.
432 category_id: u64,
433 /// Topic title.
434 #[arg(long, short = 't')]
435 title: String,
436 /// Input file path. Reads stdin when omitted or `-`.
437 local_path: Option<PathBuf>,
438 /// Output format.
439 #[arg(long, short = 'f', value_enum, default_value = "text")]
440 format: ListFormat,
441 },
442 /// Add a tag to a topic.
443 Tag {
444 /// Discourse name.
445 discourse: String,
446 /// Topic ID.
447 topic_id: u64,
448 /// Tag to add.
449 tag: String,
450 },
451 /// Remove a tag from a topic.
452 Untag {
453 /// Discourse name.
454 discourse: String,
455 /// Topic ID.
456 topic_id: u64,
457 /// Tag to remove.
458 tag: String,
459 },
460 /// Rename a topic's title (changes its URL slug). Honours `--dry-run`.
461 Title {
462 /// Discourse name.
463 discourse: String,
464 /// Topic ID.
465 topic_id: u64,
466 /// New title.
467 title: String,
468 },
469 /// Set a topic's full tag list, replacing existing tags. Pass no tags to
470 /// clear all tags. Honours `--dry-run`.
471 Tags {
472 /// Discourse name.
473 discourse: String,
474 /// Topic ID.
475 topic_id: u64,
476 /// Tags to set (space-separated; omit to clear all tags).
477 tags: Vec<String>,
478 },
479}
480
481#[derive(Subcommand)]
482pub enum CategoryCommand {
483 /// List categories.
484 #[command(visible_alias = "ls")]
485 List {
486 /// Discourse name.
487 discourse: String,
488 /// Output format.
489 #[arg(long, short = 'f', value_enum, default_value = "text")]
490 format: ListFormat,
491 /// Include additional fields where supported.
492 #[arg(long, short = 'v')]
493 verbose: bool,
494 /// Show category hierarchy tree.
495 #[arg(long)]
496 tree: bool,
497 },
498 /// Copy a category to another Discourse.
499 #[command(visible_alias = "cp")]
500 Copy {
501 /// Source discourse name.
502 discourse: String,
503 /// Target discourse name (defaults to source when omitted).
504 #[arg(long, short = 't')]
505 target: Option<String>,
506 /// Category ID or slug.
507 category: String,
508 },
509 /// Pull all topics from a category into local Markdown files.
510 #[command(visible_alias = "pl")]
511 Pull {
512 /// Discourse name.
513 discourse: String,
514 /// Category ID or slug.
515 category: String,
516 /// Destination directory (auto-derived when omitted).
517 local_path: Option<PathBuf>,
518 },
519 /// Push local Markdown files into a category.
520 #[command(visible_alias = "ps")]
521 Push {
522 /// Discourse name.
523 discourse: String,
524 /// Category ID or slug.
525 category: String,
526 /// Local directory containing Markdown files.
527 local_path: PathBuf,
528 /// Only update existing topics; error instead of creating a new topic
529 /// when a local file has no remote match.
530 #[arg(long)]
531 updates_only: bool,
532 /// Update posts without bumping their topics in the activity feed.
533 /// Use for silent bulk maintenance edits (sends post[no_bump]=true).
534 #[arg(long)]
535 no_bump: bool,
536 /// Update posts without recording edit-history revisions
537 /// (sends post[skip_revision]=true). Suppresses the online audit
538 /// trail - use sparingly.
539 #[arg(long)]
540 skip_revision: bool,
541 },
542}
543
544#[derive(Subcommand)]
545pub enum GroupCommand {
546 /// List groups.
547 #[command(visible_alias = "ls")]
548 List {
549 /// Discourse name.
550 discourse: String,
551 /// Output format.
552 #[arg(long, short = 'f', value_enum, default_value = "text")]
553 format: ListFormat,
554 /// Include additional fields where supported.
555 #[arg(long, short = 'v')]
556 verbose: bool,
557 },
558 /// Show group details.
559 #[command(visible_alias = "i")]
560 Info {
561 /// Discourse name.
562 discourse: String,
563 /// Group ID.
564 group: u64,
565 /// Output format.
566 #[arg(long, short = 'f', value_enum, default_value = "json")]
567 format: StructuredFormat,
568 },
569 /// List members of a group.
570 #[command(visible_alias = "m")]
571 Members {
572 /// Discourse name.
573 discourse: String,
574 /// Group ID.
575 group: u64,
576 /// Output format.
577 #[arg(long, short = 'f', value_enum, default_value = "text")]
578 format: ListFormat,
579 },
580 /// Copy a group to another Discourse.
581 #[command(visible_alias = "cp")]
582 Copy {
583 /// Source discourse name.
584 discourse: String,
585 /// Target discourse name (defaults to source when omitted).
586 #[arg(long, short = 't')]
587 target: Option<String>,
588 /// Group ID.
589 group: u64,
590 },
591 /// Bulk add members to a group from a file (or stdin) of email addresses.
592 #[command(visible_alias = "a")]
593 Add {
594 /// Discourse name.
595 discourse: String,
596 /// Group ID.
597 group: u64,
598 /// Path to a file of email addresses (one per line; blank
599 /// lines and `#` comments are ignored). Reads stdin when
600 /// omitted or `-`.
601 local_path: Option<PathBuf>,
602 /// Send Discourse notifications to added users.
603 #[arg(long)]
604 notify: bool,
605 },
606}
607
608#[derive(Subcommand)]
609pub enum BackupCommand {
610 /// Create a new backup.
611 #[command(visible_alias = "cr")]
612 Create {
613 /// Discourse name.
614 discourse: String,
615 },
616 /// List backups.
617 #[command(visible_alias = "ls")]
618 List {
619 /// Discourse name.
620 discourse: String,
621 /// Output format.
622 #[arg(long, short = 'f', value_enum, default_value = "text")]
623 format: OutputFormat,
624 /// Include additional fields where supported.
625 #[arg(long, short = 'v')]
626 verbose: bool,
627 },
628 /// Pull (download) a backup to a local file.
629 #[command(visible_alias = "pl")]
630 Pull {
631 /// Discourse name.
632 discourse: String,
633 /// Backup filename on the server (from `dsc backup list`).
634 backup_filename: String,
635 /// Local output path. Defaults to the backup filename in the current directory.
636 local_path: Option<PathBuf>,
637 },
638 /// Push (restore) a backup on the server (alias: restore).
639 #[command(visible_alias = "ps", alias = "restore")]
640 Push {
641 /// Discourse name.
642 discourse: String,
643 /// Backup filename/path on the target system.
644 backup_path: String,
645 },
646}
647
648#[derive(Subcommand)]
649pub enum PaletteCommand {
650 /// List color palettes.
651 #[command(visible_alias = "ls")]
652 List {
653 /// Discourse name.
654 discourse: String,
655 /// Output format.
656 #[arg(long, short = 'f', value_enum, default_value = "text")]
657 format: ListFormat,
658 /// Include additional fields where supported.
659 #[arg(long, short = 'v')]
660 verbose: bool,
661 },
662 /// Pull a palette to local JSON.
663 #[command(visible_alias = "pl")]
664 Pull {
665 /// Discourse name.
666 discourse: String,
667 /// Palette ID.
668 palette_id: u64,
669 /// Destination file path (auto-derived when omitted).
670 local_path: Option<PathBuf>,
671 },
672 /// Push local JSON to create or update a palette.
673 #[command(visible_alias = "ps")]
674 Push {
675 /// Discourse name.
676 discourse: String,
677 /// Local JSON file path.
678 local_path: PathBuf,
679 /// Palette ID to update (creates a new palette when omitted).
680 palette_id: Option<u64>,
681 },
682}
683
684#[derive(Subcommand)]
685pub enum PluginCommand {
686 /// List installed plugins.
687 #[command(visible_alias = "ls")]
688 List {
689 /// Discourse name.
690 discourse: String,
691 /// Output format.
692 #[arg(long, short = 'f', value_enum, default_value = "text")]
693 format: ListFormat,
694 /// Include additional fields where supported.
695 #[arg(long, short = 'v')]
696 verbose: bool,
697 },
698 /// Install a plugin from URL.
699 #[command(visible_alias = "i")]
700 Install {
701 /// Discourse name.
702 discourse: String,
703 /// Plugin repository URL.
704 url: String,
705 },
706 /// Remove a plugin by name.
707 #[command(visible_alias = "rm")]
708 Remove {
709 /// Discourse name.
710 discourse: String,
711 /// Plugin name.
712 name: String,
713 },
714}
715
716#[derive(Subcommand)]
717pub enum ThemeCommand {
718 /// List installed themes.
719 #[command(visible_alias = "ls")]
720 List {
721 /// Discourse name.
722 discourse: String,
723 /// Output format.
724 #[arg(long, short = 'f', value_enum, default_value = "text")]
725 format: ListFormat,
726 /// Include additional fields where supported.
727 #[arg(long, short = 'v')]
728 verbose: bool,
729 },
730 /// Install a theme from URL.
731 #[command(visible_alias = "i")]
732 Install {
733 /// Discourse name.
734 discourse: String,
735 /// Theme repository URL.
736 url: String,
737 },
738 /// Remove a theme by name.
739 #[command(visible_alias = "rm")]
740 Remove {
741 /// Discourse name.
742 discourse: String,
743 /// Theme name.
744 name: String,
745 },
746 /// Pull a theme to a local JSON file.
747 #[command(visible_alias = "pl")]
748 Pull {
749 /// Discourse name.
750 discourse: String,
751 /// Theme ID (from `dsc theme list`).
752 theme_id: u64,
753 /// Destination file path (auto-derived from theme name when omitted).
754 local_path: Option<PathBuf>,
755 },
756 /// Push a local JSON file to create or update a theme.
757 #[command(visible_alias = "ps")]
758 Push {
759 /// Discourse name.
760 discourse: String,
761 /// Local JSON file path.
762 local_path: PathBuf,
763 /// Theme ID to update (creates a new theme when omitted).
764 theme_id: Option<u64>,
765 },
766 /// Duplicate a theme and print the new theme ID.
767 #[command(visible_alias = "dup")]
768 Duplicate {
769 /// Discourse name.
770 discourse: String,
771 /// Theme ID to duplicate (from `dsc theme list`).
772 theme_id: u64,
773 /// Output format.
774 #[arg(long, short = 'f', value_enum, default_value = "text")]
775 format: ListFormat,
776 },
777 /// Show a richer view of one theme/component than `list`.
778 Show {
779 /// Discourse name.
780 discourse: String,
781 /// Theme ID (from `dsc theme list`).
782 theme_id: u64,
783 /// Output format.
784 #[arg(long, short = 'f', value_enum, default_value = "text")]
785 format: ListFormat,
786 },
787 /// Read and write a theme/component's settings (not site settings).
788 Setting {
789 #[command(subcommand)]
790 command: ThemeSettingCommand,
791 },
792 /// Enable a theme or component.
793 Enable {
794 /// Discourse name.
795 discourse: String,
796 /// Theme ID (from `dsc theme list`).
797 theme_id: u64,
798 },
799 /// Disable a theme or component.
800 Disable {
801 /// Discourse name.
802 discourse: String,
803 /// Theme ID (from `dsc theme list`).
804 theme_id: u64,
805 },
806 /// Attach a component to a parent theme (makes it active on that theme).
807 Attach {
808 /// Discourse name.
809 discourse: String,
810 /// Parent theme ID.
811 parent_id: u64,
812 /// Component (child theme) ID to attach.
813 component_id: u64,
814 },
815 /// Detach a component from a parent theme.
816 Detach {
817 /// Discourse name.
818 discourse: String,
819 /// Parent theme ID.
820 parent_id: u64,
821 /// Component (child theme) ID to detach.
822 component_id: u64,
823 },
824 /// Manage colour palettes (colour schemes). The canonical home for what
825 /// was `dsc palette`.
826 Palette {
827 #[command(subcommand)]
828 command: PaletteCommand,
829 },
830}
831
832#[derive(Subcommand)]
833pub enum ThemeSettingCommand {
834 /// List a theme/component's settings.
835 #[command(visible_alias = "ls")]
836 List {
837 /// Discourse name.
838 discourse: String,
839 /// Theme ID (from `dsc theme list`).
840 theme_id: u64,
841 /// Output format.
842 #[arg(long, short = 'f', value_enum, default_value = "text")]
843 format: ListFormat,
844 },
845 /// Print a single setting's current value.
846 Get {
847 /// Discourse name.
848 discourse: String,
849 /// Theme ID.
850 theme_id: u64,
851 /// Setting key (the `setting` name from `theme setting list`).
852 key: String,
853 /// Output format.
854 #[arg(long, short = 'f', value_enum, default_value = "text")]
855 format: ListFormat,
856 },
857 /// Set a single setting. Value is sent verbatim (pass JSON text for
858 /// json-schema list settings). Honours global `--dry-run`.
859 Set {
860 /// Discourse name.
861 discourse: String,
862 /// Theme ID.
863 theme_id: u64,
864 /// Setting key.
865 key: String,
866 /// New value (verbatim).
867 value: String,
868 },
869}
870
871#[derive(Subcommand)]
872pub enum PmCommand {
873 /// Send a private message.
874 #[command(visible_alias = "s")]
875 Send {
876 /// Discourse name.
877 discourse: String,
878 /// Recipient(s) — comma-separated usernames or group names.
879 recipients: String,
880 /// PM title / subject.
881 #[arg(long, short = 't')]
882 title: String,
883 /// Input file path. Reads stdin when omitted or `-`.
884 local_path: Option<PathBuf>,
885 },
886 /// List PMs for a user.
887 #[command(visible_alias = "ls")]
888 List {
889 /// Discourse name.
890 discourse: String,
891 /// Username whose PMs to list.
892 username: String,
893 /// Direction / view: inbox | sent | archive | unread | new.
894 #[arg(long, short = 'd', default_value = "inbox")]
895 direction: String,
896 /// Output format.
897 #[arg(long, short = 'f', value_enum, default_value = "text")]
898 format: ListFormat,
899 },
900}
901
902#[derive(Subcommand)]
903pub enum ApiKeyCommand {
904 /// List API keys.
905 #[command(visible_alias = "ls")]
906 List {
907 /// Discourse name.
908 discourse: String,
909 /// Output format.
910 #[arg(long, short = 'f', value_enum, default_value = "text")]
911 format: ListFormat,
912 },
913 /// Create a new API key. The secret is only shown at creation time —
914 /// capture it from the output.
915 #[command(visible_alias = "cr")]
916 Create {
917 /// Discourse name.
918 discourse: String,
919 /// Description / label for the key (shown in admin UI).
920 description: String,
921 /// Username the key acts as. Omit for a global all-users key.
922 #[arg(long, short = 'u')]
923 username: Option<String>,
924 /// Output format.
925 #[arg(long, short = 'f', value_enum, default_value = "text")]
926 format: ListFormat,
927 },
928 /// Revoke an API key by ID.
929 #[command(visible_alias = "rm")]
930 Revoke {
931 /// Discourse name.
932 discourse: String,
933 /// API key ID (from `dsc api-key list`).
934 key_id: u64,
935 },
936}
937
938#[derive(Subcommand)]
939pub enum InviteCommand {
940 /// Invite a single email address.
941 #[command(visible_alias = "s")]
942 Send {
943 /// Discourse name.
944 discourse: String,
945 /// Email address to invite.
946 email: String,
947 /// Add invitee to one or more groups on accept (repeatable).
948 #[arg(long, short = 'g')]
949 group: Vec<u64>,
950 /// Land the invitee on a specific topic on accept.
951 #[arg(long, short = 't')]
952 topic: Option<u64>,
953 /// Custom invitation message.
954 #[arg(long, short = 'm')]
955 message: Option<String>,
956 },
957 /// Bulk-invite from a file (or stdin) of email addresses.
958 #[command(visible_alias = "b")]
959 Bulk {
960 /// Discourse name.
961 discourse: String,
962 /// Path to a file of email addresses (one per line; blank lines and
963 /// `#` comments ignored). Reads stdin when omitted or `-`.
964 local_path: Option<PathBuf>,
965 /// Add every invitee to one or more groups on accept (repeatable).
966 #[arg(long, short = 'g')]
967 group: Vec<u64>,
968 /// Land every invitee on a specific topic on accept.
969 #[arg(long, short = 't')]
970 topic: Option<u64>,
971 /// Custom invitation message attached to each invite.
972 #[arg(long, short = 'm')]
973 message: Option<String>,
974 },
975}
976
977#[derive(Subcommand)]
978pub enum UserCommand {
979 /// List users via the admin users endpoint.
980 #[command(visible_alias = "ls")]
981 List {
982 /// Discourse name.
983 discourse: String,
984 /// Listing type: active | new | staff | suspended | silenced | staged.
985 #[arg(long, short = 'l', default_value = "active")]
986 listing: String,
987 /// Page number (Discourse paginates 100 per page).
988 #[arg(long, short = 'p', default_value_t = 1)]
989 page: u32,
990 /// Output format.
991 #[arg(long, short = 'f', value_enum, default_value = "text")]
992 format: ListFormat,
993 },
994 /// Show detailed info for a user.
995 #[command(visible_alias = "i")]
996 Info {
997 /// Discourse name.
998 discourse: String,
999 /// Username.
1000 username: String,
1001 /// Output format.
1002 #[arg(long, short = 'f', value_enum, default_value = "text")]
1003 format: ListFormat,
1004 },
1005 /// Suspend a user.
1006 #[command(visible_alias = "sus")]
1007 Suspend {
1008 /// Discourse name.
1009 discourse: String,
1010 /// Username.
1011 username: String,
1012 /// When the suspension ends. ISO-8601 timestamp (e.g.
1013 /// `2026-12-31T00:00:00Z`) or `forever`.
1014 #[arg(long, short = 'u', default_value = "forever")]
1015 until: String,
1016 /// Reason shown to the user and in the audit log.
1017 #[arg(long, short = 'r', default_value = "")]
1018 reason: String,
1019 },
1020 /// Remove a suspension from a user.
1021 #[command(visible_alias = "uns")]
1022 Unsuspend {
1023 /// Discourse name.
1024 discourse: String,
1025 /// Username.
1026 username: String,
1027 },
1028 /// Silence a user (prevents posting; less visible than suspend).
1029 #[command(visible_alias = "sil")]
1030 Silence {
1031 /// Discourse name.
1032 discourse: String,
1033 /// Username.
1034 username: String,
1035 /// When the silence ends. ISO-8601 timestamp; empty means
1036 /// indefinite.
1037 #[arg(long, short = 'u', default_value = "")]
1038 until: String,
1039 /// Reason shown to the user and in the audit log.
1040 #[arg(long, short = 'r', default_value = "")]
1041 reason: String,
1042 },
1043 /// Lift a silence on a user.
1044 #[command(visible_alias = "unsil")]
1045 Unsilence {
1046 /// Discourse name.
1047 discourse: String,
1048 /// Username.
1049 username: String,
1050 },
1051 /// Grant the user the admin or moderator role.
1052 #[command(visible_alias = "pr")]
1053 Promote {
1054 /// Discourse name.
1055 discourse: String,
1056 /// Username.
1057 username: String,
1058 /// Role to grant.
1059 #[arg(long, short = 'r', value_enum)]
1060 role: RoleArg,
1061 },
1062 /// Revoke the user's admin or moderator role.
1063 #[command(visible_alias = "de")]
1064 Demote {
1065 /// Discourse name.
1066 discourse: String,
1067 /// Username.
1068 username: String,
1069 /// Role to revoke.
1070 #[arg(long, short = 'r', value_enum)]
1071 role: RoleArg,
1072 },
1073 /// Create a new user. `--approve` also marks the account approved
1074 /// (needed when site requires manual approval). Password is either
1075 /// supplied via stdin (`--password-stdin`) or omitted — in the
1076 /// latter case the user will have to set one via the reset flow.
1077 #[command(visible_alias = "cr")]
1078 Create {
1079 /// Discourse name.
1080 discourse: String,
1081 /// New user's email address.
1082 email: String,
1083 /// New user's username.
1084 username: String,
1085 /// Display name (optional).
1086 #[arg(long, short = 'N')]
1087 name: Option<String>,
1088 /// Read the password from stdin instead of auto-reset.
1089 #[arg(long)]
1090 password_stdin: bool,
1091 /// Also mark the user approved (for sites with manual approval).
1092 #[arg(long)]
1093 approve: bool,
1094 },
1095 /// Trigger Discourse's password-reset email flow for a user.
1096 #[command(name = "password-reset", visible_aliases = ["pwreset", "pw-reset"])]
1097 PasswordReset {
1098 /// Discourse name.
1099 discourse: String,
1100 /// Username or email.
1101 username: String,
1102 },
1103 /// Set a user's primary email address. Requires admin scope.
1104 #[command(name = "email-set", visible_alias = "email")]
1105 EmailSet {
1106 /// Discourse name.
1107 discourse: String,
1108 /// Username.
1109 username: String,
1110 /// New email address.
1111 email: String,
1112 },
1113 /// Show a user's recent public activity (topics + replies by default).
1114 ///
1115 /// Built for the "archive my own activity to a journal forum" loop —
1116 /// pipe the markdown output straight into `dsc topic reply`/`topic new`.
1117 #[command(visible_alias = "act")]
1118 Activity {
1119 /// Discourse name (the *source* forum to read activity from).
1120 discourse: String,
1121 /// Username whose activity to read.
1122 username: String,
1123 /// How far back to look. Accepts `7d`, `24h`, `30m`, `1w`, `90s`, or
1124 /// an ISO-8601 timestamp / date. Omit to fetch everything available.
1125 #[arg(long, short = 's')]
1126 since: Option<String>,
1127 /// Action types to include, comma-separated. Default: topics,replies.
1128 /// Also recognises: mentions, quotes, likes, edits, responses.
1129 #[arg(long, short = 't', default_value = "topics,replies")]
1130 types: String,
1131 /// Hard cap on number of items returned.
1132 #[arg(long, short = 'L')]
1133 limit: Option<u32>,
1134 /// Output format.
1135 #[arg(long, short = 'f', value_enum, default_value = "markdown")]
1136 format: ActivityFormatArg,
1137 },
1138 /// Manage a user's group memberships.
1139 #[command(visible_alias = "g")]
1140 Groups {
1141 #[command(subcommand)]
1142 command: UserGroupsCommand,
1143 },
1144}
1145
1146#[derive(ValueEnum, Clone, Copy)]
1147pub enum SectionArg {
1148 All,
1149 Growth,
1150 Activity,
1151 Health,
1152}
1153
1154#[derive(ValueEnum, Clone, Copy)]
1155pub enum AnalyticsFormat {
1156 /// Plain text (default). Fixed-width columns, no borders.
1157 Text,
1158 /// DuckDB-style box-drawing table. Falls through to `text` when
1159 /// stdout isn't a TTY.
1160 Table,
1161 /// Pretty JSON.
1162 Json,
1163 /// YAML.
1164 #[value(alias = "yml")]
1165 Yaml,
1166 /// Markdown bullet list per section.
1167 #[value(alias = "md")]
1168 Markdown,
1169 /// Markdown table per section.
1170 #[value(alias = "md-table", name = "markdown-table")]
1171 MarkdownTable,
1172 /// CSV — one row per metric.
1173 Csv,
1174}
1175
1176#[derive(ValueEnum, Clone, Copy)]
1177pub enum ActivityFormatArg {
1178 Text,
1179 Json,
1180 #[value(alias = "yml")]
1181 Yaml,
1182 #[value(alias = "md")]
1183 Markdown,
1184 Csv,
1185}
1186
1187#[derive(ValueEnum, Clone, Copy)]
1188pub enum RoleArg {
1189 Admin,
1190 Moderator,
1191}
1192
1193#[derive(Subcommand)]
1194pub enum UserGroupsCommand {
1195 /// List the groups a user belongs to.
1196 #[command(visible_alias = "ls")]
1197 List {
1198 /// Discourse name.
1199 discourse: String,
1200 /// Target username.
1201 username: String,
1202 /// Output format.
1203 #[arg(long, short = 'f', value_enum, default_value = "text")]
1204 format: ListFormat,
1205 },
1206 /// Add a user to a group.
1207 #[command(visible_alias = "a")]
1208 Add {
1209 /// Discourse name.
1210 discourse: String,
1211 /// Target username.
1212 username: String,
1213 /// Group ID.
1214 group_id: u64,
1215 /// Send Discourse notification to the user.
1216 #[arg(long)]
1217 notify: bool,
1218 },
1219 /// Remove a user from a group.
1220 #[command(visible_alias = "rm")]
1221 Remove {
1222 /// Discourse name.
1223 discourse: String,
1224 /// Target username.
1225 username: String,
1226 /// Group ID.
1227 group_id: u64,
1228 },
1229}
1230
1231#[derive(Subcommand)]
1232pub enum PostCommand {
1233 /// Pull a post's raw Markdown to a local file.
1234 #[command(visible_alias = "pl")]
1235 Pull {
1236 /// Discourse name.
1237 discourse: String,
1238 /// Post ID.
1239 post_id: u64,
1240 /// Output file path. Prints to stdout when omitted.
1241 local_path: Option<PathBuf>,
1242 },
1243 /// Push a local file to update a post (alias: edit).
1244 #[command(visible_alias = "ps", alias = "edit")]
1245 Push {
1246 /// Discourse name.
1247 discourse: String,
1248 /// Post ID.
1249 post_id: u64,
1250 /// Input file path. Reads stdin when omitted or `-`.
1251 local_path: Option<PathBuf>,
1252 },
1253 /// Delete a post by ID.
1254 #[command(visible_alias = "rm")]
1255 Delete {
1256 /// Discourse name.
1257 discourse: String,
1258 /// Post ID.
1259 post_id: u64,
1260 },
1261 /// Move a post to a different topic.
1262 #[command(visible_alias = "mv")]
1263 Move {
1264 /// Discourse name.
1265 discourse: String,
1266 /// Post ID to move.
1267 post_id: u64,
1268 /// Destination topic ID.
1269 #[arg(long = "to-topic", short = 't')]
1270 to_topic: u64,
1271 },
1272}
1273
1274#[derive(Subcommand)]
1275pub enum TagCommand {
1276 /// List every tag on the Discourse.
1277 #[command(visible_alias = "ls")]
1278 List {
1279 /// Discourse name.
1280 discourse: String,
1281 /// Output format.
1282 #[arg(long, short = 'f', value_enum, default_value = "text")]
1283 format: ListFormat,
1284 },
1285 /// Pull the tag taxonomy (tags + tag groups) to a local file.
1286 #[command(visible_alias = "pl")]
1287 Pull {
1288 /// Discourse name.
1289 discourse: String,
1290 /// Output file (default: tags.yaml). Extension determines format (.yaml/.json).
1291 #[arg(default_value = "tags.yaml")]
1292 local_path: PathBuf,
1293 },
1294 /// Push a local taxonomy file to the server (upsert; optionally prune).
1295 #[command(visible_alias = "ps")]
1296 Push {
1297 /// Discourse name.
1298 discourse: String,
1299 /// Input taxonomy file.
1300 local_path: PathBuf,
1301 /// Delete server tags/groups absent from the file.
1302 #[arg(long)]
1303 prune: bool,
1304 },
1305 /// Rename a tag, preserving topic associations.
1306 ///
1307 /// Discourse rewrites every topic's tag list in-place, so this avoids
1308 /// the delete-and-recreate pattern that loses topic membership.
1309 #[command(visible_alias = "rn")]
1310 Rename {
1311 /// Discourse name.
1312 discourse: String,
1313 /// Current tag name.
1314 old_name: String,
1315 /// New tag name.
1316 new_name: String,
1317 },
1318}
1319
1320#[derive(Subcommand)]
1321pub enum SettingCommand {
1322 /// Set a site setting on a Discourse (or all tagged Discourses).
1323 ///
1324 /// Usage:
1325 /// dsc setting set <discourse> <setting> <value>
1326 /// dsc setting set --tags <tag1,tag2> <setting> <value>
1327 #[command(visible_alias = "s")]
1328 Set {
1329 /// Discourse name. Required unless `--tags` is provided.
1330 discourse: Option<String>,
1331 /// Setting key. Required.
1332 setting: Option<String>,
1333 /// Setting value. Required.
1334 value: Option<String>,
1335 /// Tag filter (comma/semicolon separated, match-any). Apply across all
1336 /// Discourses matching any of the tags. When set, omit `<discourse>`
1337 /// and pass `<setting> <value>` as the only positionals.
1338 #[arg(long, value_name = "tag1,tag2")]
1339 tags: Option<String>,
1340 },
1341
1342 /// Get the current value of a site setting.
1343 #[command(visible_alias = "g")]
1344 Get {
1345 /// Discourse name.
1346 discourse: String,
1347 /// Setting key.
1348 setting: String,
1349 /// Output format.
1350 #[arg(long, short = 'f', value_enum, default_value = "text")]
1351 format: ListFormat,
1352 },
1353
1354 /// List all site settings.
1355 #[command(visible_alias = "ls")]
1356 List {
1357 /// Discourse name.
1358 discourse: String,
1359 /// Output format.
1360 #[arg(long, short = 'f', value_enum, default_value = "text")]
1361 format: ListFormat,
1362 /// Show output even when list is empty.
1363 #[arg(long, short = 'v')]
1364 verbose: bool,
1365 },
1366
1367 /// Snapshot all site settings (with metadata) to a local file.
1368 ///
1369 /// See spec/setting-sync.md for the full schema and workflow. The
1370 /// generated file is a self-documenting YAML (or JSON) including each
1371 /// setting's default, type, category, and description.
1372 #[command(visible_alias = "pl")]
1373 Pull {
1374 /// Discourse name.
1375 discourse: String,
1376 /// Output path. Format detected by extension (.json → JSON,
1377 /// otherwise YAML). Defaults to `settings.yaml`.
1378 #[arg(default_value = "settings.yaml")]
1379 local_path: PathBuf,
1380 /// Only include settings whose value differs from default. Produces
1381 /// a manageable file (~50-100 entries) suitable for version control.
1382 #[arg(long, short = 'c')]
1383 changed_only: bool,
1384 /// Limit to settings in this category (e.g. `required`, `email`,
1385 /// `security`).
1386 #[arg(long)]
1387 category: Option<String>,
1388 },
1389
1390 /// Apply a settings snapshot file to a Discourse (idempotent).
1391 ///
1392 /// Compares each setting in the file against the server and PUTs only
1393 /// values that differ. Combine with `--dry-run` to preview the plan.
1394 #[command(visible_alias = "ph")]
1395 Push {
1396 /// Discourse name.
1397 discourse: String,
1398 /// Path to the settings snapshot file (YAML or JSON).
1399 local_path: PathBuf,
1400 /// For settings present on the server but absent from the file,
1401 /// reset them to their default value. Off by default (file describes
1402 /// only the values you care about).
1403 #[arg(long)]
1404 reset_unlisted: bool,
1405 },
1406
1407 /// Compare site settings between two sources.
1408 ///
1409 /// Each source can be a Discourse name (live fetch) or a path to a
1410 /// snapshot file produced by `dsc setting pull`. Sources are detected
1411 /// by whether the argument refers to an existing file on disk; if not,
1412 /// it is treated as a Discourse name.
1413 #[command(visible_alias = "df")]
1414 Diff {
1415 /// First source: Discourse name or snapshot file path.
1416 source: String,
1417 /// Second source: Discourse name or snapshot file path.
1418 target: String,
1419 /// Filter to settings where at least one source differs from default.
1420 /// Reduces noise when most settings on both sides are still default.
1421 #[arg(long, short = 'c')]
1422 changed_only: bool,
1423 /// Limit to settings in this category (e.g. `required`, `email`).
1424 /// Only effective when both sources carry category metadata.
1425 #[arg(long)]
1426 category: Option<String>,
1427 /// Output format.
1428 #[arg(long, short = 'f', value_enum, default_value = "text")]
1429 format: ListFormat,
1430 },
1431}
1432
1433#[derive(ValueEnum, Clone, Copy)]
1434pub enum CompletionShell {
1435 /// Bash shell.
1436 Bash,
1437 /// Zsh shell.
1438 Zsh,
1439 /// Fish shell.
1440 Fish,
1441}
1442
1443impl From<CompletionShell> for Shell {
1444 fn from(value: CompletionShell) -> Self {
1445 match value {
1446 CompletionShell::Bash => Shell::Bash,
1447 CompletionShell::Zsh => Shell::Zsh,
1448 CompletionShell::Fish => Shell::Fish,
1449 }
1450 }
1451}
1452
1453#[derive(ValueEnum, Clone)]
1454pub enum OutputFormat {
1455 /// Plain text.
1456 #[value(alias = "plaintext")]
1457 Text,
1458 /// Markdown list.
1459 Markdown,
1460 /// Markdown table.
1461 MarkdownTable,
1462 /// Pretty JSON.
1463 Json,
1464 /// YAML.
1465 #[value(alias = "yml")]
1466 Yaml,
1467 /// CSV.
1468 Csv,
1469 /// One base URL per line (pipe-friendly).
1470 #[value(alias = "url")]
1471 Urls,
1472}
1473
1474#[derive(ValueEnum, Clone, Copy)]
1475pub enum ListFormat {
1476 /// Plain text.
1477 Text,
1478 /// Pretty JSON.
1479 Json,
1480 /// YAML.
1481 #[value(alias = "yml")]
1482 Yaml,
1483}
1484
1485#[derive(ValueEnum, Clone, Copy)]
1486pub enum StructuredFormat {
1487 /// Pretty JSON.
1488 Json,
1489 /// YAML.
1490 #[value(alias = "yml")]
1491 Yaml,
1492}