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