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