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