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