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 --format json # structured {name, version}
415 dsc version accm # the forum's live Discourse version + git commit")]
416 Version {
417 /// Forum name. When given, print that forum's live Discourse version
418 /// and git commit (from /about.json, via the configured API key)
419 /// instead of dsc's own version.
420 discourse: Option<String>,
421 /// Output format.
422 #[arg(long, short = 'f', value_enum, default_value = "text")]
423 format: ListFormat,
424 },
425}
426
427#[derive(Subcommand)]
428#[command(next_display_order = None)]
429pub enum ConfigCommand {
430 /// Probe each configured Discourse: API auth and (optionally) SSH reachability.
431 #[command(visible_alias = "ck")]
432 Check {
433 /// Output format.
434 #[arg(long, short = 'f', value_enum, default_value = "text")]
435 format: ListFormat,
436 /// Skip the SSH reachability probe.
437 #[arg(long)]
438 skip_ssh: bool,
439 /// Probe forums concurrently (results stream fastest-first). Much
440 /// faster on a large fleet.
441 #[arg(long, short = 'p')]
442 parallel: bool,
443 /// Maximum workers when --parallel is set (default: 8).
444 #[arg(long, short = 'm')]
445 max: Option<usize>,
446 },
447}
448
449#[derive(Subcommand)]
450#[command(next_display_order = None)]
451pub enum ListCommand {
452 /// Sort discourse entries by name and rewrite config in-place.
453 /// Also inserts placeholder values for unset template keys.
454 #[command(visible_alias = "ty")]
455 Tidy,
456}
457
458#[derive(Subcommand)]
459#[command(next_display_order = None)]
460pub enum EmojiCommand {
461 /// Pull all custom emoji from a Discourse into a local directory.
462 #[command(visible_alias = "pl")]
463 Pull {
464 /// Discourse name.
465 discourse: String,
466 /// Local directory to save emoji images into.
467 output_dir: PathBuf,
468 },
469 /// Push (upload) one emoji file, or bulk-upload from a directory (alias: add).
470 #[command(visible_alias = "ps", alias = "add")]
471 Push {
472 /// Discourse name.
473 discourse: String,
474 /// Local file or directory path.
475 emoji_path: PathBuf,
476 /// Optional emoji name (file uploads only).
477 emoji_name: Option<String>,
478 },
479
480 /// List custom emojis on a Discourse.
481 #[command(visible_alias = "ls")]
482 List {
483 /// Discourse name.
484 discourse: String,
485 /// Output format.
486 #[arg(long, short = 'f', value_enum, default_value = "text")]
487 format: ListFormat,
488 /// Include additional fields where supported.
489 #[arg(long, short = 'v')]
490 verbose: bool,
491 /// Render inline images when terminal protocol support is available.
492 #[arg(long, short = 'i')]
493 inline: bool,
494 },
495}
496
497#[derive(Subcommand)]
498#[command(next_display_order = None)]
499pub enum TopicCommand {
500 /// Pull a topic to a local Markdown file.
501 #[command(visible_alias = "pl")]
502 Pull {
503 /// Discourse name.
504 discourse: String,
505 /// Topic ID.
506 topic_id: u64,
507 /// Destination file or directory (auto-derived when omitted).
508 local_path: Option<PathBuf>,
509 /// Pull the entire thread (every post) as a single Markdown file
510 /// with YAML frontmatter and per-post headings. Default behaviour
511 /// (no `--full`) writes only the OP, which is what `topic push`
512 /// expects.
513 #[arg(long, short = 'F')]
514 full: bool,
515 },
516 /// Push a local Markdown file to a topic.
517 #[command(visible_alias = "ps")]
518 Push {
519 /// Discourse name.
520 discourse: String,
521 /// Topic ID.
522 topic_id: u64,
523 /// Local Markdown file path.
524 local_path: PathBuf,
525 /// Update the post without bumping the topic in the activity feed.
526 /// Use for silent maintenance edits (sends post[no_bump]=true).
527 #[arg(long)]
528 no_bump: bool,
529 /// Update the post without recording an edit-history revision
530 /// (sends post[skip_revision]=true). Suppresses the online audit
531 /// trail - use sparingly.
532 #[arg(long)]
533 skip_revision: bool,
534 },
535 /// Sync a topic and local Markdown file using newest timestamp.
536 #[command(visible_alias = "sy")]
537 Sync {
538 /// Discourse name.
539 discourse: String,
540 /// Topic ID.
541 topic_id: u64,
542 /// Local Markdown file path.
543 local_path: PathBuf,
544 /// Skip sync confirmation prompt.
545 #[arg(long, short = 'y')]
546 yes: bool,
547 },
548 /// Reply to a topic with content from a file or stdin.
549 #[command(visible_alias = "r")]
550 Reply {
551 /// Discourse name.
552 discourse: String,
553 /// Topic ID.
554 topic_id: u64,
555 /// Input file path. Reads stdin when omitted or `-`.
556 local_path: Option<PathBuf>,
557 /// Output format.
558 #[arg(long, short = 'f', value_enum, default_value = "text")]
559 format: ListFormat,
560 },
561 /// Create a new topic in a category, body from a file or stdin.
562 #[command(visible_alias = "n")]
563 New {
564 /// Discourse name.
565 discourse: String,
566 /// Target category ID.
567 category_id: u64,
568 /// Topic title.
569 #[arg(long, short = 't')]
570 title: String,
571 /// Input file path. Reads stdin when omitted or `-`.
572 local_path: Option<PathBuf>,
573 /// Output format.
574 #[arg(long, short = 'f', value_enum, default_value = "text")]
575 format: ListFormat,
576 },
577 /// Add a tag to a topic.
578 Tag {
579 /// Discourse name.
580 discourse: String,
581 /// Topic ID.
582 topic_id: u64,
583 /// Tag to add.
584 tag: String,
585 },
586 /// Remove a tag from a topic.
587 Untag {
588 /// Discourse name.
589 discourse: String,
590 /// Topic ID.
591 topic_id: u64,
592 /// Tag to remove.
593 tag: String,
594 },
595 /// Rename a topic's title (changes its URL slug). Honours `--dry-run`.
596 Title {
597 /// Discourse name.
598 discourse: String,
599 /// Topic ID.
600 topic_id: u64,
601 /// New title.
602 title: String,
603 },
604 /// Set a topic's full tag list, replacing existing tags. Pass no tags to
605 /// clear all tags. Honours `--dry-run`.
606 Tags {
607 /// Discourse name.
608 discourse: String,
609 /// Topic ID.
610 topic_id: u64,
611 /// Tags to set (space-separated; omit to clear all tags).
612 tags: Vec<String>,
613 },
614}
615
616#[derive(Subcommand)]
617#[command(next_display_order = None)]
618pub enum CategoryCommand {
619 /// List categories.
620 #[command(visible_alias = "ls")]
621 List {
622 /// Discourse name.
623 discourse: String,
624 /// Output format.
625 #[arg(long, short = 'f', value_enum, default_value = "text")]
626 format: ListFormat,
627 /// Include additional fields where supported.
628 #[arg(long, short = 'v')]
629 verbose: bool,
630 /// Show category hierarchy tree.
631 #[arg(long)]
632 tree: bool,
633 },
634 /// Copy a category to another Discourse.
635 #[command(visible_alias = "cp")]
636 Copy {
637 /// Source discourse name.
638 discourse: String,
639 /// Target discourse name (defaults to source when omitted).
640 #[arg(long, short = 't')]
641 target: Option<String>,
642 /// Category ID or slug.
643 category: String,
644 },
645 /// Pull all topics from a category into local Markdown files.
646 #[command(visible_alias = "pl")]
647 Pull {
648 /// Discourse name.
649 discourse: String,
650 /// Category ID or slug.
651 category: String,
652 /// Destination directory (auto-derived when omitted).
653 local_path: Option<PathBuf>,
654 },
655 /// Push local Markdown files into a category.
656 #[command(visible_alias = "ps")]
657 Push {
658 /// Discourse name.
659 discourse: String,
660 /// Category ID or slug.
661 category: String,
662 /// Local directory containing Markdown files.
663 local_path: PathBuf,
664 /// Only update existing topics; error instead of creating a new topic
665 /// when a local file has no remote match.
666 #[arg(long)]
667 updates_only: bool,
668 /// Update posts without bumping their topics in the activity feed.
669 /// Use for silent bulk maintenance edits (sends post[no_bump]=true).
670 #[arg(long)]
671 no_bump: bool,
672 /// Update posts without recording edit-history revisions
673 /// (sends post[skip_revision]=true). Suppresses the online audit
674 /// trail - use sparingly.
675 #[arg(long)]
676 skip_revision: bool,
677 },
678}
679
680#[derive(Subcommand)]
681#[command(next_display_order = None)]
682pub enum GroupCommand {
683 /// List groups.
684 #[command(visible_alias = "ls")]
685 List {
686 /// Discourse name.
687 discourse: String,
688 /// Output format.
689 #[arg(long, short = 'f', value_enum, default_value = "text")]
690 format: ListFormat,
691 /// Include additional fields where supported.
692 #[arg(long, short = 'v')]
693 verbose: bool,
694 },
695 /// Show group details.
696 #[command(visible_alias = "i")]
697 Info {
698 /// Discourse name.
699 discourse: String,
700 /// Group ID.
701 group: u64,
702 /// Output format.
703 #[arg(long, short = 'f', value_enum, default_value = "json")]
704 format: StructuredFormat,
705 },
706 /// List members of a group.
707 #[command(visible_alias = "m")]
708 Members {
709 /// Discourse name.
710 discourse: String,
711 /// Group ID.
712 group: u64,
713 /// Output format.
714 #[arg(long, short = 'f', value_enum, default_value = "text")]
715 format: ListFormat,
716 },
717 /// Copy a group to another Discourse.
718 #[command(visible_alias = "cp")]
719 Copy {
720 /// Source discourse name.
721 discourse: String,
722 /// Target discourse name (defaults to source when omitted).
723 #[arg(long, short = 't')]
724 target: Option<String>,
725 /// Group ID.
726 group: u64,
727 },
728 /// Bulk add members to a group from a file (or stdin) of email addresses.
729 #[command(visible_alias = "a")]
730 Add {
731 /// Discourse name.
732 discourse: String,
733 /// Group ID.
734 group: u64,
735 /// Path to a file of email addresses (one per line; blank
736 /// lines and `#` comments are ignored). Reads stdin when
737 /// omitted or `-`.
738 local_path: Option<PathBuf>,
739 /// Send Discourse notifications to added users.
740 #[arg(long)]
741 notify: bool,
742 },
743}
744
745#[derive(Subcommand)]
746#[command(next_display_order = None)]
747pub enum BackupCommand {
748 /// Create a new backup.
749 #[command(visible_alias = "cr")]
750 Create {
751 /// Discourse name.
752 discourse: String,
753 },
754 /// List backups.
755 #[command(visible_alias = "ls")]
756 List {
757 /// Discourse name.
758 discourse: String,
759 /// Output format.
760 #[arg(long, short = 'f', value_enum, default_value = "text")]
761 format: OutputFormat,
762 /// Include additional fields where supported.
763 #[arg(long, short = 'v')]
764 verbose: bool,
765 },
766 /// Pull (download) a backup to a local file.
767 #[command(visible_alias = "pl")]
768 Pull {
769 /// Discourse name.
770 discourse: String,
771 /// Backup filename on the server (from `dsc backup list`).
772 backup_filename: String,
773 /// Local output path. Defaults to the backup filename in the current directory.
774 local_path: Option<PathBuf>,
775 },
776 /// Push (restore) a backup on the server (alias: restore).
777 #[command(visible_alias = "ps", alias = "restore")]
778 Push {
779 /// Discourse name.
780 discourse: String,
781 /// Backup filename/path on the target system.
782 backup_path: String,
783 },
784 /// Provision an S3 backup bucket + a scoped IAM user and point Discourse at
785 /// it (one command for the per-forum AWS backup runbook). Requires the
786 /// `aws` CLI configured with IAM + S3 admin rights. Always preview with
787 /// `-n` / `--dry-run` first.
788 #[command(after_help = "Examples:
789 dsc backup setup-s3 -n myforum # preview the full plan (review gate)
790 dsc backup setup-s3 myforum --region eu-west-1
791 dsc backup setup-s3 myforum --no-test")]
792 SetupS3 {
793 /// Discourse name.
794 discourse: String,
795 /// AWS region for the bucket.
796 #[arg(long, default_value = "eu-west-2")]
797 region: String,
798 /// Bucket name (default: `<name>-discourse-backups`).
799 #[arg(long)]
800 bucket: Option<String>,
801 /// Skip the verification backup after provisioning.
802 #[arg(long)]
803 no_test: bool,
804 },
805}
806
807#[derive(Subcommand)]
808#[command(next_display_order = None)]
809pub enum PaletteCommand {
810 /// List color palettes.
811 #[command(visible_alias = "ls")]
812 List {
813 /// Discourse name.
814 discourse: String,
815 /// Output format.
816 #[arg(long, short = 'f', value_enum, default_value = "text")]
817 format: ListFormat,
818 /// Include additional fields where supported.
819 #[arg(long, short = 'v')]
820 verbose: bool,
821 },
822 /// Pull a palette to local JSON.
823 #[command(visible_alias = "pl")]
824 Pull {
825 /// Discourse name.
826 discourse: String,
827 /// Palette ID.
828 palette_id: u64,
829 /// Destination file path (auto-derived when omitted).
830 local_path: Option<PathBuf>,
831 },
832 /// Push local JSON to create or update a palette.
833 #[command(visible_alias = "ps")]
834 Push {
835 /// Discourse name.
836 discourse: String,
837 /// Local JSON file path.
838 local_path: PathBuf,
839 /// Palette ID to update (creates a new palette when omitted).
840 palette_id: Option<u64>,
841 },
842}
843
844#[derive(Subcommand)]
845#[command(next_display_order = None)]
846pub enum PluginCommand {
847 /// List installed plugins.
848 #[command(visible_alias = "ls")]
849 List {
850 /// Discourse name.
851 discourse: String,
852 /// Output format.
853 #[arg(long, short = 'f', value_enum, default_value = "text")]
854 format: ListFormat,
855 /// Include additional fields where supported.
856 #[arg(long, short = 'v')]
857 verbose: bool,
858 },
859 /// Install a plugin from URL.
860 #[command(visible_alias = "i")]
861 Install {
862 /// Discourse name.
863 discourse: String,
864 /// Plugin repository URL.
865 url: String,
866 },
867 /// Remove a plugin by name.
868 #[command(visible_alias = "rm")]
869 Remove {
870 /// Discourse name.
871 discourse: String,
872 /// Plugin name.
873 name: String,
874 },
875}
876
877#[derive(Subcommand)]
878#[command(next_display_order = None)]
879pub enum ThemeCommand {
880 /// List installed themes.
881 #[command(visible_alias = "ls")]
882 List {
883 /// Discourse name.
884 discourse: String,
885 /// Output format.
886 #[arg(long, short = 'f', value_enum, default_value = "text")]
887 format: ListFormat,
888 /// Include additional fields where supported.
889 #[arg(long, short = 'v')]
890 verbose: bool,
891 },
892 /// Install a theme from a git repo or a local bundle file (via the API).
893 #[command(visible_alias = "i")]
894 #[command(after_help = "Examples:
895 dsc theme install myforum https://github.com/org/theme
896 dsc theme install myforum https://user:TOKEN@github.com/org/private-theme.git
897 dsc theme install myforum ./my-theme.tar.gz")]
898 Install {
899 /// Discourse name.
900 discourse: String,
901 /// Git repo URL (creds may be embedded for private repos) or a local
902 /// bundle file (`.tar.gz`/zip theme export).
903 source: String,
904 /// Git branch to import (remote only; defaults to the repo's default).
905 #[arg(long, short = 'b')]
906 branch: Option<String>,
907 },
908 /// Remove a theme by name (SSH rake). Prefer `theme delete <id>` (API).
909 #[command(visible_alias = "rm")]
910 Remove {
911 /// Discourse name.
912 discourse: String,
913 /// Theme name.
914 name: String,
915 },
916 /// Delete a theme/component by id via the API. Honours `--dry-run`.
917 Delete {
918 /// Discourse name.
919 discourse: String,
920 /// Theme ID (from `dsc theme list`).
921 theme_id: u64,
922 },
923 /// Pull a theme to a local JSON file.
924 #[command(visible_alias = "pl")]
925 Pull {
926 /// Discourse name.
927 discourse: String,
928 /// Theme ID (from `dsc theme list`).
929 theme_id: u64,
930 /// Destination file path (auto-derived from theme name when omitted).
931 local_path: Option<PathBuf>,
932 },
933 /// Push a local JSON file to create or update a theme.
934 #[command(visible_alias = "ps")]
935 Push {
936 /// Discourse name.
937 discourse: String,
938 /// Local JSON file path.
939 local_path: PathBuf,
940 /// Theme ID to update (creates a new theme when omitted).
941 theme_id: Option<u64>,
942 },
943 /// Duplicate a theme and print the new theme ID.
944 #[command(visible_alias = "dup")]
945 Duplicate {
946 /// Discourse name.
947 discourse: String,
948 /// Theme ID to duplicate (from `dsc theme list`).
949 theme_id: u64,
950 /// Output format.
951 #[arg(long, short = 'f', value_enum, default_value = "text")]
952 format: ListFormat,
953 },
954 /// Show a richer view of one theme/component than `list`.
955 Show {
956 /// Discourse name.
957 discourse: String,
958 /// Theme ID (from `dsc theme list`).
959 theme_id: u64,
960 /// Output format.
961 #[arg(long, short = 'f', value_enum, default_value = "text")]
962 format: ListFormat,
963 },
964 /// Read and write a theme/component's settings (not site settings).
965 #[command(after_help = "Examples:
966 dsc theme setting list myforum 17
967 dsc theme setting pull myforum 17 header.yml # header_links expands to a real list
968 dsc theme setting push myforum 17 header.yml --dry-run")]
969 Setting {
970 #[command(subcommand)]
971 command: ThemeSettingCommand,
972 },
973 /// Enable a theme or component.
974 Enable {
975 /// Discourse name.
976 discourse: String,
977 /// Theme ID (from `dsc theme list`).
978 theme_id: u64,
979 },
980 /// Disable a theme or component.
981 Disable {
982 /// Discourse name.
983 discourse: String,
984 /// Theme ID (from `dsc theme list`).
985 theme_id: u64,
986 },
987 /// Attach a component to a parent theme (makes it active on that theme).
988 Attach {
989 /// Discourse name.
990 discourse: String,
991 /// Parent theme ID.
992 parent_id: u64,
993 /// Component (child theme) ID to attach.
994 component_id: u64,
995 },
996 /// Detach a component from a parent theme.
997 Detach {
998 /// Discourse name.
999 discourse: String,
1000 /// Parent theme ID.
1001 parent_id: u64,
1002 /// Component (child theme) ID to detach.
1003 component_id: u64,
1004 },
1005 /// Read/write individual theme fields (SCSS, head_tag, ...).
1006 #[command(after_help = "Examples:
1007 dsc theme field list myforum 11
1008 dsc theme field pull myforum 11 common/scss common.scss
1009 dsc theme field push myforum 11 common/scss common.scss --dry-run")]
1010 Field {
1011 #[command(subcommand)]
1012 command: ThemeFieldCommand,
1013 },
1014 /// Upload and bind theme-scoped image/font assets (`$var`).
1015 #[command(after_help = "Examples:
1016 dsc theme asset list myforum 11
1017 dsc theme asset set myforum 11 centred_logo ./logo.png")]
1018 Asset {
1019 #[command(subcommand)]
1020 command: ThemeAssetCommand,
1021 },
1022 /// Pull a git-backed remote component to its latest upstream commit.
1023 #[command(after_help = "Examples:
1024 dsc theme update myforum 17 --check # how far behind?
1025 dsc theme update myforum 17 # pull the latest commit")]
1026 Update {
1027 /// Discourse name.
1028 discourse: String,
1029 /// Theme ID (from `dsc theme list`).
1030 theme_id: u64,
1031 /// Only check for updates; don't pull.
1032 #[arg(long)]
1033 check: bool,
1034 },
1035 /// Manage colour palettes (colour schemes). The canonical home for what
1036 /// was `dsc palette`.
1037 Palette {
1038 #[command(subcommand)]
1039 command: PaletteCommand,
1040 },
1041}
1042
1043#[derive(Subcommand)]
1044#[command(next_display_order = None)]
1045pub enum ThemeFieldCommand {
1046 /// List a theme's editable fields.
1047 #[command(visible_alias = "ls")]
1048 List {
1049 /// Discourse name.
1050 discourse: String,
1051 /// Theme ID.
1052 theme_id: u64,
1053 /// Output format.
1054 #[arg(long, short = 'f', value_enum, default_value = "text")]
1055 format: ListFormat,
1056 },
1057 /// Pull one field's body (e.g. `common/scss`) to a file.
1058 Pull {
1059 /// Discourse name.
1060 discourse: String,
1061 /// Theme ID.
1062 theme_id: u64,
1063 /// Field spec: `target/name`, e.g. `common/scss`.
1064 field: String,
1065 /// Destination file (auto-derived from the field name when omitted).
1066 local_path: Option<PathBuf>,
1067 },
1068 /// Push a file back to one field. Honours global `--dry-run`.
1069 Push {
1070 /// Discourse name.
1071 discourse: String,
1072 /// Theme ID.
1073 theme_id: u64,
1074 /// Field spec: `target/name`, e.g. `common/scss`.
1075 field: String,
1076 /// File whose contents become the field body.
1077 local_path: PathBuf,
1078 },
1079}
1080
1081#[derive(Subcommand)]
1082#[command(next_display_order = None)]
1083pub enum ThemeAssetCommand {
1084 /// List a theme's bound upload assets.
1085 #[command(visible_alias = "ls")]
1086 List {
1087 /// Discourse name.
1088 discourse: String,
1089 /// Theme ID.
1090 theme_id: u64,
1091 /// Output format.
1092 #[arg(long, short = 'f', value_enum, default_value = "text")]
1093 format: ListFormat,
1094 },
1095 /// Upload a file and bind it to a `$var`. Honours global `--dry-run`.
1096 Set {
1097 /// Discourse name.
1098 discourse: String,
1099 /// Theme ID.
1100 theme_id: u64,
1101 /// Upload-var name (referenced as `$name` in SCSS).
1102 name: String,
1103 /// File to upload (image/font).
1104 file: PathBuf,
1105 },
1106 /// Remove a bound upload asset (`$var`). Honours global `--dry-run`.
1107 Unset {
1108 /// Discourse name.
1109 discourse: String,
1110 /// Theme ID.
1111 theme_id: u64,
1112 /// Upload-var name to unbind.
1113 name: String,
1114 },
1115}
1116
1117#[derive(Subcommand)]
1118#[command(next_display_order = None)]
1119pub enum ThemeSettingCommand {
1120 /// List a theme/component's settings.
1121 #[command(visible_alias = "ls")]
1122 List {
1123 /// Discourse name.
1124 discourse: String,
1125 /// Theme ID (from `dsc theme list`).
1126 theme_id: u64,
1127 /// Output format.
1128 #[arg(long, short = 'f', value_enum, default_value = "text")]
1129 format: ListFormat,
1130 },
1131 /// Print a single setting's current value.
1132 Get {
1133 /// Discourse name.
1134 discourse: String,
1135 /// Theme ID.
1136 theme_id: u64,
1137 /// Setting key (the `setting` name from `theme setting list`).
1138 key: String,
1139 /// Output format.
1140 #[arg(long, short = 'f', value_enum, default_value = "text")]
1141 format: ListFormat,
1142 },
1143 /// Set a single setting. Value is sent verbatim (pass JSON text for
1144 /// json-schema list settings). Honours global `--dry-run`.
1145 Set {
1146 /// Discourse name.
1147 discourse: String,
1148 /// Theme ID.
1149 theme_id: u64,
1150 /// Setting key.
1151 key: String,
1152 /// New value (verbatim).
1153 value: String,
1154 },
1155 /// Pull a component's settings to a file for offline editing. JSON-list
1156 /// settings (`header_links`, `dropdown_links`) are expanded to real,
1157 /// editable arrays. YAML by default; a `.json` path writes JSON.
1158 Pull {
1159 /// Discourse name.
1160 discourse: String,
1161 /// Theme ID.
1162 theme_id: u64,
1163 /// Destination file (auto-derived from the theme name when omitted).
1164 local_path: Option<PathBuf>,
1165 },
1166 /// Push a settings file back, PUTting only the changed settings. Honours
1167 /// global `--dry-run`.
1168 Push {
1169 /// Discourse name.
1170 discourse: String,
1171 /// Theme ID.
1172 theme_id: u64,
1173 /// Settings file to apply.
1174 local_path: PathBuf,
1175 },
1176}
1177
1178#[derive(Subcommand)]
1179#[command(next_display_order = None)]
1180pub enum PmCommand {
1181 /// Send a private message.
1182 #[command(visible_alias = "s")]
1183 Send {
1184 /// Discourse name.
1185 discourse: String,
1186 /// Recipient(s) — comma-separated usernames or group names.
1187 recipients: String,
1188 /// PM title / subject.
1189 #[arg(long, short = 't')]
1190 title: String,
1191 /// Input file path. Reads stdin when omitted or `-`.
1192 local_path: Option<PathBuf>,
1193 },
1194 /// List PMs for a user.
1195 #[command(visible_alias = "ls")]
1196 List {
1197 /// Discourse name.
1198 discourse: String,
1199 /// Username whose PMs to list.
1200 username: String,
1201 /// Direction / view: inbox | sent | archive | unread | new.
1202 #[arg(long, short = 'd', default_value = "inbox")]
1203 direction: String,
1204 /// Output format.
1205 #[arg(long, short = 'f', value_enum, default_value = "text")]
1206 format: ListFormat,
1207 },
1208}
1209
1210#[derive(Subcommand)]
1211#[command(next_display_order = None)]
1212pub enum ApiKeyCommand {
1213 /// List API keys.
1214 #[command(visible_alias = "ls")]
1215 List {
1216 /// Discourse name.
1217 discourse: String,
1218 /// Output format.
1219 #[arg(long, short = 'f', value_enum, default_value = "text")]
1220 format: ListFormat,
1221 },
1222 /// Create a new API key. The secret is only shown at creation time —
1223 /// capture it from the output.
1224 #[command(visible_alias = "cr")]
1225 Create {
1226 /// Discourse name.
1227 discourse: String,
1228 /// Description / label for the key (shown in admin UI).
1229 description: String,
1230 /// Username the key acts as. Omit for a global all-users key.
1231 #[arg(long, short = 'u')]
1232 username: Option<String>,
1233 /// Output format.
1234 #[arg(long, short = 'f', value_enum, default_value = "text")]
1235 format: ListFormat,
1236 },
1237 /// Revoke an API key by ID.
1238 #[command(visible_alias = "rm")]
1239 Revoke {
1240 /// Discourse name.
1241 discourse: String,
1242 /// API key ID (from `dsc api-key list`).
1243 key_id: u64,
1244 },
1245}
1246
1247#[derive(Subcommand)]
1248#[command(next_display_order = None)]
1249pub enum InviteCommand {
1250 /// Invite a single email address.
1251 #[command(visible_alias = "s")]
1252 Send {
1253 /// Discourse name.
1254 discourse: String,
1255 /// Email address to invite.
1256 email: String,
1257 /// Add invitee to one or more groups on accept (repeatable).
1258 #[arg(long, short = 'g')]
1259 group: Vec<u64>,
1260 /// Land the invitee on a specific topic on accept.
1261 #[arg(long, short = 't')]
1262 topic: Option<u64>,
1263 /// Custom invitation message.
1264 #[arg(long, short = 'm')]
1265 message: Option<String>,
1266 },
1267 /// Bulk-invite from a file (or stdin) of email addresses.
1268 #[command(visible_alias = "b")]
1269 Bulk {
1270 /// Discourse name.
1271 discourse: String,
1272 /// Path to a file of email addresses (one per line; blank lines and
1273 /// `#` comments ignored). Reads stdin when omitted or `-`.
1274 local_path: Option<PathBuf>,
1275 /// Add every invitee to one or more groups on accept (repeatable).
1276 #[arg(long, short = 'g')]
1277 group: Vec<u64>,
1278 /// Land every invitee on a specific topic on accept.
1279 #[arg(long, short = 't')]
1280 topic: Option<u64>,
1281 /// Custom invitation message attached to each invite.
1282 #[arg(long, short = 'm')]
1283 message: Option<String>,
1284 },
1285}
1286
1287#[derive(Subcommand)]
1288#[command(next_display_order = None)]
1289pub enum UserCommand {
1290 /// List users via the admin users endpoint.
1291 #[command(visible_alias = "ls")]
1292 List {
1293 /// Discourse name.
1294 discourse: String,
1295 /// Listing type: active | new | staff | suspended | silenced | staged.
1296 #[arg(long, short = 'l', default_value = "active")]
1297 listing: String,
1298 /// Page number (Discourse paginates 100 per page).
1299 #[arg(long, short = 'p', default_value_t = 1)]
1300 page: u32,
1301 /// Output format.
1302 #[arg(long, short = 'f', value_enum, default_value = "text")]
1303 format: ListFormat,
1304 },
1305 /// Show detailed info for a user.
1306 #[command(visible_alias = "i")]
1307 Info {
1308 /// Discourse name.
1309 discourse: String,
1310 /// Username.
1311 username: String,
1312 /// Output format.
1313 #[arg(long, short = 'f', value_enum, default_value = "text")]
1314 format: ListFormat,
1315 },
1316 /// Suspend a user.
1317 #[command(visible_alias = "sus")]
1318 Suspend {
1319 /// Discourse name.
1320 discourse: String,
1321 /// Username.
1322 username: String,
1323 /// When the suspension ends. ISO-8601 timestamp (e.g.
1324 /// `2026-12-31T00:00:00Z`) or `forever`.
1325 #[arg(long, short = 'u', default_value = "forever")]
1326 until: String,
1327 /// Reason shown to the user and in the audit log.
1328 #[arg(long, short = 'r', default_value = "")]
1329 reason: String,
1330 },
1331 /// Remove a suspension from a user.
1332 #[command(visible_alias = "uns")]
1333 Unsuspend {
1334 /// Discourse name.
1335 discourse: String,
1336 /// Username.
1337 username: String,
1338 },
1339 /// Silence a user (prevents posting; less visible than suspend).
1340 #[command(visible_alias = "sil")]
1341 Silence {
1342 /// Discourse name.
1343 discourse: String,
1344 /// Username.
1345 username: String,
1346 /// When the silence ends. ISO-8601 timestamp; empty means
1347 /// indefinite.
1348 #[arg(long, short = 'u', default_value = "")]
1349 until: String,
1350 /// Reason shown to the user and in the audit log.
1351 #[arg(long, short = 'r', default_value = "")]
1352 reason: String,
1353 },
1354 /// Lift a silence on a user.
1355 #[command(visible_alias = "unsil")]
1356 Unsilence {
1357 /// Discourse name.
1358 discourse: String,
1359 /// Username.
1360 username: String,
1361 },
1362 /// Grant the user the admin or moderator role.
1363 #[command(visible_alias = "pr")]
1364 Promote {
1365 /// Discourse name.
1366 discourse: String,
1367 /// Username.
1368 username: String,
1369 /// Role to grant.
1370 #[arg(long, short = 'r', value_enum)]
1371 role: RoleArg,
1372 },
1373 /// Revoke the user's admin or moderator role.
1374 #[command(visible_alias = "de")]
1375 Demote {
1376 /// Discourse name.
1377 discourse: String,
1378 /// Username.
1379 username: String,
1380 /// Role to revoke.
1381 #[arg(long, short = 'r', value_enum)]
1382 role: RoleArg,
1383 },
1384 /// Create a new user. `--approve` also marks the account approved
1385 /// (needed when site requires manual approval). Password is either
1386 /// supplied via stdin (`--password-stdin`) or omitted — in the
1387 /// latter case the user will have to set one via the reset flow.
1388 #[command(visible_alias = "cr")]
1389 Create {
1390 /// Discourse name.
1391 discourse: String,
1392 /// New user's email address.
1393 email: String,
1394 /// New user's username.
1395 username: String,
1396 /// Display name (optional).
1397 #[arg(long, short = 'N')]
1398 name: Option<String>,
1399 /// Read the password from stdin instead of auto-reset.
1400 #[arg(long)]
1401 password_stdin: bool,
1402 /// Also mark the user approved (for sites with manual approval).
1403 #[arg(long)]
1404 approve: bool,
1405 },
1406 /// Trigger Discourse's password-reset email flow for a user.
1407 #[command(name = "password-reset", visible_aliases = ["pwreset", "pw-reset"])]
1408 PasswordReset {
1409 /// Discourse name.
1410 discourse: String,
1411 /// Username or email.
1412 username: String,
1413 },
1414 /// Set a user's primary email address. Requires admin scope.
1415 #[command(name = "email-set", visible_alias = "email")]
1416 EmailSet {
1417 /// Discourse name.
1418 discourse: String,
1419 /// Username.
1420 username: String,
1421 /// New email address.
1422 email: String,
1423 },
1424 /// Show a user's recent public activity (topics + replies by default).
1425 ///
1426 /// Built for the "archive my own activity to a journal forum" loop —
1427 /// pipe the markdown output straight into `dsc topic reply`/`topic new`.
1428 #[command(visible_alias = "act")]
1429 Activity {
1430 /// Discourse name (the *source* forum to read activity from).
1431 discourse: String,
1432 /// Username whose activity to read.
1433 username: String,
1434 /// How far back to look. Accepts `7d`, `24h`, `30m`, `1w`, `90s`, or
1435 /// an ISO-8601 timestamp / date. Omit to fetch everything available.
1436 #[arg(long, short = 's')]
1437 since: Option<String>,
1438 /// Action types to include, comma-separated. Default: topics,replies.
1439 /// Also recognises: mentions, quotes, likes, edits, responses.
1440 #[arg(long, short = 't', default_value = "topics,replies")]
1441 types: String,
1442 /// Hard cap on number of items returned.
1443 #[arg(long, short = 'L')]
1444 limit: Option<u32>,
1445 /// Output format.
1446 #[arg(long, short = 'f', value_enum, default_value = "markdown")]
1447 format: ActivityFormatArg,
1448 },
1449 /// Manage a user's group memberships.
1450 #[command(visible_alias = "g")]
1451 Groups {
1452 #[command(subcommand)]
1453 command: UserGroupsCommand,
1454 },
1455}
1456
1457#[derive(ValueEnum, Clone, Copy)]
1458pub enum SectionArg {
1459 All,
1460 Growth,
1461 Activity,
1462 Health,
1463}
1464
1465#[derive(ValueEnum, Clone, Copy)]
1466pub enum AnalyticsFormat {
1467 /// Plain text (default). Fixed-width columns, no borders.
1468 Text,
1469 /// DuckDB-style box-drawing table. Falls through to `text` when
1470 /// stdout isn't a TTY.
1471 Table,
1472 /// Pretty JSON.
1473 Json,
1474 /// YAML.
1475 #[value(alias = "yml")]
1476 Yaml,
1477 /// Markdown bullet list per section.
1478 #[value(alias = "md")]
1479 Markdown,
1480 /// Markdown table per section.
1481 #[value(alias = "md-table", name = "markdown-table")]
1482 MarkdownTable,
1483 /// CSV — one row per metric.
1484 Csv,
1485}
1486
1487#[derive(ValueEnum, Clone, Copy)]
1488pub enum ActivityFormatArg {
1489 Text,
1490 Json,
1491 #[value(alias = "yml")]
1492 Yaml,
1493 #[value(alias = "md")]
1494 Markdown,
1495 Csv,
1496}
1497
1498#[derive(ValueEnum, Clone, Copy)]
1499pub enum RoleArg {
1500 Admin,
1501 Moderator,
1502}
1503
1504#[derive(Subcommand)]
1505#[command(next_display_order = None)]
1506pub enum UserGroupsCommand {
1507 /// List the groups a user belongs to.
1508 #[command(visible_alias = "ls")]
1509 List {
1510 /// Discourse name.
1511 discourse: String,
1512 /// Target username.
1513 username: String,
1514 /// Output format.
1515 #[arg(long, short = 'f', value_enum, default_value = "text")]
1516 format: ListFormat,
1517 },
1518 /// Add a user to a group.
1519 #[command(visible_alias = "a")]
1520 Add {
1521 /// Discourse name.
1522 discourse: String,
1523 /// Target username.
1524 username: String,
1525 /// Group ID.
1526 group_id: u64,
1527 /// Send Discourse notification to the user.
1528 #[arg(long)]
1529 notify: bool,
1530 },
1531 /// Remove a user from a group.
1532 #[command(visible_alias = "rm")]
1533 Remove {
1534 /// Discourse name.
1535 discourse: String,
1536 /// Target username.
1537 username: String,
1538 /// Group ID.
1539 group_id: u64,
1540 },
1541}
1542
1543#[derive(Subcommand)]
1544#[command(next_display_order = None)]
1545pub enum PostCommand {
1546 /// Pull a post's raw Markdown to a local file.
1547 #[command(visible_alias = "pl")]
1548 Pull {
1549 /// Discourse name.
1550 discourse: String,
1551 /// Post ID.
1552 post_id: u64,
1553 /// Output file path. Prints to stdout when omitted.
1554 local_path: Option<PathBuf>,
1555 },
1556 /// Push a local file to update a post (alias: edit).
1557 #[command(visible_alias = "ps", alias = "edit")]
1558 Push {
1559 /// Discourse name.
1560 discourse: String,
1561 /// Post ID.
1562 post_id: u64,
1563 /// Input file path. Reads stdin when omitted or `-`.
1564 local_path: Option<PathBuf>,
1565 },
1566 /// Delete a post by ID.
1567 #[command(visible_alias = "rm")]
1568 Delete {
1569 /// Discourse name.
1570 discourse: String,
1571 /// Post ID.
1572 post_id: u64,
1573 },
1574 /// Move a post to a different topic.
1575 #[command(visible_alias = "mv")]
1576 Move {
1577 /// Discourse name.
1578 discourse: String,
1579 /// Post ID to move.
1580 post_id: u64,
1581 /// Destination topic ID.
1582 #[arg(long = "to-topic", short = 't')]
1583 to_topic: u64,
1584 },
1585}
1586
1587#[derive(Subcommand)]
1588#[command(next_display_order = None)]
1589pub enum TagCommand {
1590 /// List every tag on the Discourse.
1591 #[command(visible_alias = "ls")]
1592 List {
1593 /// Discourse name.
1594 discourse: String,
1595 /// Output format.
1596 #[arg(long, short = 'f', value_enum, default_value = "text")]
1597 format: ListFormat,
1598 },
1599 /// Pull the tag taxonomy (tags + tag groups) to a local file.
1600 #[command(visible_alias = "pl")]
1601 Pull {
1602 /// Discourse name.
1603 discourse: String,
1604 /// Output file (default: tags.yaml). Extension determines format (.yaml/.json).
1605 #[arg(default_value = "tags.yaml")]
1606 local_path: PathBuf,
1607 },
1608 /// Push a local taxonomy file to the server (upsert; optionally prune).
1609 #[command(visible_alias = "ps")]
1610 Push {
1611 /// Discourse name.
1612 discourse: String,
1613 /// Input taxonomy file.
1614 local_path: PathBuf,
1615 /// Delete server tags/groups absent from the file.
1616 #[arg(long)]
1617 prune: bool,
1618 },
1619 /// Rename a tag, preserving topic associations.
1620 ///
1621 /// Discourse rewrites every topic's tag list in-place, so this avoids
1622 /// the delete-and-recreate pattern that loses topic membership.
1623 #[command(visible_alias = "rn")]
1624 Rename {
1625 /// Discourse name.
1626 discourse: String,
1627 /// Current tag name.
1628 old_name: String,
1629 /// New tag name.
1630 new_name: String,
1631 },
1632}
1633
1634#[derive(Subcommand)]
1635#[command(next_display_order = None)]
1636pub enum SettingCommand {
1637 /// Set a site setting on a Discourse (or all tagged Discourses).
1638 ///
1639 /// Usage:
1640 /// dsc setting set <discourse> <setting> <value>
1641 /// dsc setting set --tags <tag1,tag2> <setting> <value>
1642 #[command(visible_alias = "s")]
1643 Set {
1644 /// Discourse name. Required unless `--tags` is provided.
1645 discourse: Option<String>,
1646 /// Setting key. Required.
1647 setting: Option<String>,
1648 /// Setting value. Required.
1649 value: Option<String>,
1650 /// Tag filter (comma/semicolon separated, match-any). Apply across all
1651 /// Discourses matching any of the tags. When set, omit `<discourse>`
1652 /// and pass `<setting> <value>` as the only positionals.
1653 #[arg(long, value_name = "tag1,tag2")]
1654 tags: Option<String>,
1655 },
1656
1657 /// Get the current value of a site setting.
1658 #[command(visible_alias = "g")]
1659 Get {
1660 /// Discourse name.
1661 discourse: String,
1662 /// Setting key.
1663 setting: String,
1664 /// Output format.
1665 #[arg(long, short = 'f', value_enum, default_value = "text")]
1666 format: ListFormat,
1667 },
1668
1669 /// List all site settings.
1670 #[command(visible_alias = "ls")]
1671 List {
1672 /// Discourse name.
1673 discourse: String,
1674 /// Output format.
1675 #[arg(long, short = 'f', value_enum, default_value = "text")]
1676 format: ListFormat,
1677 /// Show output even when list is empty.
1678 #[arg(long, short = 'v')]
1679 verbose: bool,
1680 },
1681
1682 /// Snapshot every site setting to a file - the reference for what settings exist.
1683 ///
1684 /// See spec/setting-sync.md for the full schema and workflow. The
1685 /// generated file is a self-documenting YAML (or JSON) including each
1686 /// setting's value, default, type, category, and Discourse's own
1687 /// description - so it doubles as a catalog of available settings.
1688 #[command(visible_alias = "pl")]
1689 Pull {
1690 /// Discourse name.
1691 discourse: String,
1692 /// Output path. Format detected by extension (.json → JSON,
1693 /// otherwise YAML). Defaults to `settings.yaml`.
1694 #[arg(default_value = "settings.yaml")]
1695 local_path: PathBuf,
1696 /// Only include settings whose value differs from default. Produces
1697 /// a manageable file (~50-100 entries) suitable for version control.
1698 #[arg(long, short = 'c')]
1699 changed_only: bool,
1700 /// Limit to settings in this category (e.g. `required`, `email`,
1701 /// `security`).
1702 #[arg(long)]
1703 category: Option<String>,
1704 },
1705
1706 /// Apply a settings snapshot file to a Discourse (idempotent).
1707 ///
1708 /// Compares each setting in the file against the server and PUTs only
1709 /// values that differ. Combine with `--dry-run` to preview the plan.
1710 #[command(visible_alias = "ph")]
1711 Push {
1712 /// Discourse name.
1713 discourse: String,
1714 /// Path to the settings snapshot file (YAML or JSON).
1715 local_path: PathBuf,
1716 /// For settings present on the server but absent from the file,
1717 /// reset them to their default value. Off by default (file describes
1718 /// only the values you care about).
1719 #[arg(long)]
1720 reset_unlisted: bool,
1721 },
1722
1723 /// Compare site settings between two sources.
1724 ///
1725 /// Each source can be a Discourse name (live fetch) or a path to a
1726 /// snapshot file produced by `dsc setting pull`. Sources are detected
1727 /// by whether the argument refers to an existing file on disk; if not,
1728 /// it is treated as a Discourse name.
1729 #[command(visible_alias = "df")]
1730 Diff {
1731 /// First source: Discourse name or snapshot file path.
1732 source: String,
1733 /// Second source: Discourse name or snapshot file path.
1734 target: String,
1735 /// Filter to settings where at least one source differs from default.
1736 /// Reduces noise when most settings on both sides are still default.
1737 #[arg(long, short = 'c')]
1738 changed_only: bool,
1739 /// Limit to settings in this category (e.g. `required`, `email`).
1740 /// Only effective when both sources carry category metadata.
1741 #[arg(long)]
1742 category: Option<String>,
1743 /// Output format.
1744 #[arg(long, short = 'f', value_enum, default_value = "text")]
1745 format: ListFormat,
1746 },
1747
1748 /// Show the value of one setting across every configured forum
1749 /// (optionally filtered by `--tags`). Diff-friendly; distinct from `diff`,
1750 /// which compares two specific sources across all settings.
1751 Audit {
1752 /// Setting key.
1753 setting: String,
1754 /// Only audit forums carrying at least one of these tags
1755 /// (comma/semicolon-separated). Omit to audit every configured forum.
1756 #[arg(long, value_name = "tag1,tag2")]
1757 tags: Option<String>,
1758 /// Output format.
1759 #[arg(long, short = 'f', value_enum, default_value = "text")]
1760 format: ListFormat,
1761 },
1762}
1763
1764#[derive(Subcommand, Clone)]
1765pub enum CompletionCommand {
1766 /// Install completions for the current user.
1767 Install {
1768 /// Shell to install completions for. Detected from $SHELL when omitted.
1769 #[arg(long)]
1770 shell: Option<CompletionShell>,
1771 /// Completion directory to write to.
1772 #[arg(long, short = 'd')]
1773 dir: Option<PathBuf>,
1774 },
1775}
1776
1777#[derive(ValueEnum, Clone, Copy)]
1778pub enum CompletionShell {
1779 /// Bash shell.
1780 Bash,
1781 /// Zsh shell.
1782 Zsh,
1783 /// Fish shell.
1784 Fish,
1785 /// PowerShell.
1786 #[value(name = "powershell", alias = "power-shell")]
1787 PowerShell,
1788}
1789
1790impl From<CompletionShell> for Shell {
1791 fn from(value: CompletionShell) -> Self {
1792 match value {
1793 CompletionShell::Bash => Shell::Bash,
1794 CompletionShell::Zsh => Shell::Zsh,
1795 CompletionShell::Fish => Shell::Fish,
1796 CompletionShell::PowerShell => Shell::PowerShell,
1797 }
1798 }
1799}
1800
1801#[derive(ValueEnum, Clone)]
1802pub enum OutputFormat {
1803 /// Plain text.
1804 #[value(alias = "plaintext")]
1805 Text,
1806 /// Markdown list.
1807 Markdown,
1808 /// Markdown table.
1809 MarkdownTable,
1810 /// Pretty JSON.
1811 Json,
1812 /// YAML.
1813 #[value(alias = "yml")]
1814 Yaml,
1815 /// CSV.
1816 Csv,
1817 /// One base URL per line (pipe-friendly).
1818 #[value(alias = "url")]
1819 Urls,
1820}
1821
1822#[derive(ValueEnum, Clone, Copy)]
1823pub enum ListFormat {
1824 /// Plain text.
1825 Text,
1826 /// Pretty JSON.
1827 Json,
1828 /// YAML.
1829 #[value(alias = "yml")]
1830 Yaml,
1831}
1832
1833#[derive(ValueEnum, Clone, Copy)]
1834pub enum StructuredFormat {
1835 /// Pretty JSON.
1836 Json,
1837 /// YAML.
1838 #[value(alias = "yml")]
1839 Yaml,
1840}