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