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