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