Skip to main content

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)]
8pub struct Cli {
9    /// Path to the config file. If omitted, dsc searches standard locations.
10    #[arg(long, short = 'c')]
11    pub config: Option<PathBuf>,
12    /// Describe destructive actions without sending them. Supported on a subset
13    /// of commands; unsupported commands run normally.
14    #[arg(long, short = 'n', global = true)]
15    pub dry_run: bool,
16    #[command(subcommand)]
17    pub command: Commands,
18}
19
20#[derive(Subcommand)]
21pub enum Commands {
22    /// List configured Discourses.
23    #[command(visible_alias = "ls")]
24    List {
25        /// Output format for the listing.
26        #[arg(long, short = 'f', value_enum, default_value = "text")]
27        format: OutputFormat,
28        /// Filter by tags (comma/semicolon separated, match-any).
29        #[arg(long, value_name = "tag1,tag2")]
30        tags: Option<String>,
31        /// Open each listed Discourse base URL in a browser tab/window.
32        #[arg(long, short = 'o')]
33        open: bool,
34        /// Include empty results and verbose listing details where supported.
35        #[arg(long, short = 'v')]
36        verbose: bool,
37        #[command(subcommand)]
38        command: Option<ListCommand>,
39    },
40    /// Add one or more Discourses to the config.
41    #[command(visible_alias = "a")]
42    Add {
43        /// Comma-separated discourse names to add.
44        names: String,
45        /// Prompt for additional optional fields while adding.
46        #[arg(long, short = 'i')]
47        interactive: bool,
48    },
49    /// Import Discourses from a file or stdin.
50    #[command(visible_alias = "imp")]
51    Import {
52        /// Path to import input (text/CSV). Reads stdin when omitted.
53        path: Option<PathBuf>,
54    },
55    /// Run remote OS + Discourse update workflow for one or all Discourses.
56    #[command(visible_alias = "up")]
57    Update {
58        /// Discourse name, or 'all' to update every configured Discourse.
59        name: String,
60        /// Parallel update mode for `dsc update all`.
61        #[arg(long, short = 'p')]
62        parallel: bool,
63        /// Maximum workers when parallel mode is enabled (default: 3).
64        #[arg(long, short = 'm')]
65        max: Option<usize>,
66        /// Disable changelog posting (posting prompt is on by default).
67        #[arg(long = "no-changelog", action = ArgAction::SetFalse, default_value_t = true)]
68        post_changelog: bool,
69        /// Auto-confirm changelog posting prompt (non-interactive mode).
70        #[arg(long, short = 'y')]
71        yes: bool,
72    },
73    /// Manage custom emoji.
74    #[command(visible_alias = "em")]
75    Emoji {
76        #[command(subcommand)]
77        command: EmojiCommand,
78    },
79    /// Pull/push/sync topics as local Markdown.
80    #[command(visible_alias = "t")]
81    Topic {
82        #[command(subcommand)]
83        command: TopicCommand,
84    },
85    /// List/copy/pull/push categories.
86    #[command(visible_alias = "cat")]
87    Category {
88        #[command(subcommand)]
89        command: CategoryCommand,
90    },
91    /// List/inspect/copy groups.
92    #[command(visible_alias = "grp")]
93    Group {
94        #[command(subcommand)]
95        command: GroupCommand,
96    },
97    /// Create/list/restore backups.
98    #[command(visible_alias = "bk")]
99    Backup {
100        #[command(subcommand)]
101        command: BackupCommand,
102    },
103    /// List/pull/push color palettes.
104    #[command(visible_alias = "pal")]
105    Palette {
106        #[command(subcommand)]
107        command: PaletteCommand,
108    },
109    /// List/install/remove plugins.
110    #[command(visible_alias = "plg")]
111    Plugin {
112        #[command(subcommand)]
113        command: PluginCommand,
114    },
115    /// List/install/remove/pull/push/duplicate themes.
116    #[command(visible_alias = "th")]
117    Theme {
118        #[command(subcommand)]
119        command: ThemeCommand,
120    },
121    /// Update site settings.
122    #[command(visible_alias = "set")]
123    Setting {
124        #[command(subcommand)]
125        command: SettingCommand,
126    },
127    /// Open a Discourse in the default browser.
128    #[command(visible_alias = "o")]
129    Open {
130        /// Discourse name.
131        discourse: String,
132    },
133    /// Inspect and validate configuration.
134    #[command(visible_alias = "cfg")]
135    Config {
136        #[command(subcommand)]
137        command: ConfigCommand,
138    },
139    /// Generate shell completion scripts.
140    #[command(visible_alias = "comp")]
141    Completions {
142        /// Target shell.
143        #[arg(value_enum)]
144        shell: CompletionShell,
145        /// Output directory. Prints to stdout when omitted.
146        #[arg(long, short = 'd')]
147        dir: Option<PathBuf>,
148    },
149    /// Print the dsc version.
150    #[command(visible_alias = "ver")]
151    Version,
152}
153
154#[derive(Subcommand)]
155pub enum ConfigCommand {
156    /// Probe each configured Discourse: API auth and (optionally) SSH reachability.
157    #[command(visible_alias = "ck")]
158    Check {
159        /// Output format.
160        #[arg(long, short = 'f', value_enum, default_value = "text")]
161        format: ListFormat,
162        /// Skip the SSH reachability probe.
163        #[arg(long)]
164        skip_ssh: bool,
165    },
166}
167
168#[derive(Subcommand)]
169pub enum ListCommand {
170    /// Sort discourse entries by name and rewrite config in-place.
171    /// Also inserts placeholder values for unset template keys.
172    #[command(visible_alias = "ty")]
173    Tidy,
174}
175
176#[derive(Subcommand)]
177pub enum EmojiCommand {
178    /// Upload one emoji file, or bulk-upload from a directory.
179    #[command(visible_alias = "a")]
180    Add {
181        /// Discourse name.
182        discourse: String,
183        /// Local file or directory path.
184        emoji_path: PathBuf,
185        /// Optional emoji name (file uploads only).
186        emoji_name: Option<String>,
187    },
188
189    /// List custom emojis on a Discourse.
190    #[command(visible_alias = "ls")]
191    List {
192        /// Discourse name.
193        discourse: String,
194        /// Output format.
195        #[arg(long, short = 'f', value_enum, default_value = "text")]
196        format: ListFormat,
197        /// Include additional fields where supported.
198        #[arg(long, short = 'v')]
199        verbose: bool,
200        /// Render inline images when terminal protocol support is available.
201        #[arg(long, short = 'i')]
202        inline: bool,
203    },
204}
205
206#[derive(Subcommand)]
207pub enum TopicCommand {
208    /// Pull a topic to a local Markdown file.
209    #[command(visible_alias = "pl")]
210    Pull {
211        /// Discourse name.
212        discourse: String,
213        /// Topic ID.
214        topic_id: u64,
215        /// Destination file or directory (auto-derived when omitted).
216        local_path: Option<PathBuf>,
217    },
218    /// Push a local Markdown file to a topic.
219    #[command(visible_alias = "ps")]
220    Push {
221        /// Discourse name.
222        discourse: String,
223        /// Topic ID.
224        topic_id: u64,
225        /// Local Markdown file path.
226        local_path: PathBuf,
227    },
228    /// Sync a topic and local Markdown file using newest timestamp.
229    #[command(visible_alias = "sy")]
230    Sync {
231        /// Discourse name.
232        discourse: String,
233        /// Topic ID.
234        topic_id: u64,
235        /// Local Markdown file path.
236        local_path: PathBuf,
237        /// Skip sync confirmation prompt.
238        #[arg(long, short = 'y')]
239        yes: bool,
240    },
241    /// Reply to a topic with content from a file or stdin.
242    #[command(visible_alias = "r")]
243    Reply {
244        /// Discourse name.
245        discourse: String,
246        /// Topic ID.
247        topic_id: u64,
248        /// Input file path. Reads stdin when omitted or `-`.
249        local_path: Option<PathBuf>,
250    },
251    /// Create a new topic in a category, body from a file or stdin.
252    #[command(visible_alias = "n")]
253    New {
254        /// Discourse name.
255        discourse: String,
256        /// Target category ID.
257        category_id: u64,
258        /// Topic title.
259        #[arg(long, short = 't')]
260        title: String,
261        /// Input file path. Reads stdin when omitted or `-`.
262        local_path: Option<PathBuf>,
263    },
264}
265
266#[derive(Subcommand)]
267pub enum CategoryCommand {
268    /// List categories.
269    #[command(visible_alias = "ls")]
270    List {
271        /// Discourse name.
272        discourse: String,
273        /// Output format.
274        #[arg(long, short = 'f', value_enum, default_value = "text")]
275        format: ListFormat,
276        /// Include additional fields where supported.
277        #[arg(long, short = 'v')]
278        verbose: bool,
279        /// Show category hierarchy tree.
280        #[arg(long)]
281        tree: bool,
282    },
283    /// Copy a category to another Discourse.
284    #[command(visible_alias = "cp")]
285    Copy {
286        /// Source discourse name.
287        discourse: String,
288        /// Target discourse name (defaults to source when omitted).
289        #[arg(long, short = 't')]
290        target: Option<String>,
291        /// Category ID or slug.
292        category: String,
293    },
294    /// Pull all topics from a category into local Markdown files.
295    #[command(visible_alias = "pl")]
296    Pull {
297        /// Discourse name.
298        discourse: String,
299        /// Category ID or slug.
300        category: String,
301        /// Destination directory (auto-derived when omitted).
302        local_path: Option<PathBuf>,
303    },
304    /// Push local Markdown files into a category.
305    #[command(visible_alias = "ps")]
306    Push {
307        /// Discourse name.
308        discourse: String,
309        /// Category ID or slug.
310        category: String,
311        /// Local directory containing Markdown files.
312        local_path: PathBuf,
313    },
314}
315
316#[derive(Subcommand)]
317pub enum GroupCommand {
318    /// List groups.
319    #[command(visible_alias = "ls")]
320    List {
321        /// Discourse name.
322        discourse: String,
323        /// Output format.
324        #[arg(long, short = 'f', value_enum, default_value = "text")]
325        format: ListFormat,
326        /// Include additional fields where supported.
327        #[arg(long, short = 'v')]
328        verbose: bool,
329    },
330    /// Show group details.
331    #[command(visible_alias = "i")]
332    Info {
333        /// Discourse name.
334        discourse: String,
335        /// Group ID.
336        group: u64,
337        /// Output format.
338        #[arg(long, short = 'f', value_enum, default_value = "json")]
339        format: StructuredFormat,
340    },
341    /// List members of a group.
342    #[command(visible_alias = "m")]
343    Members {
344        /// Discourse name.
345        discourse: String,
346        /// Group ID.
347        group: u64,
348        /// Output format.
349        #[arg(long, short = 'f', value_enum, default_value = "text")]
350        format: ListFormat,
351    },
352    /// Copy a group to another Discourse.
353    #[command(visible_alias = "cp")]
354    Copy {
355        /// Source discourse name.
356        discourse: String,
357        /// Target discourse name (defaults to source when omitted).
358        #[arg(long, short = 't')]
359        target: Option<String>,
360        /// Group ID.
361        group: u64,
362    },
363}
364
365#[derive(Subcommand)]
366pub enum BackupCommand {
367    /// Create a new backup.
368    #[command(visible_alias = "cr")]
369    Create {
370        /// Discourse name.
371        discourse: String,
372    },
373    /// List backups.
374    #[command(visible_alias = "ls")]
375    List {
376        /// Discourse name.
377        discourse: String,
378        /// Output format.
379        #[arg(long, short = 'f', value_enum, default_value = "text")]
380        format: OutputFormat,
381        /// Include additional fields where supported.
382        #[arg(long, short = 'v')]
383        verbose: bool,
384    },
385    /// Restore a backup.
386    #[command(visible_alias = "rs")]
387    Restore {
388        /// Discourse name.
389        discourse: String,
390        /// Backup filename/path on the target system.
391        backup_path: String,
392    },
393}
394
395#[derive(Subcommand)]
396pub enum PaletteCommand {
397    /// List color palettes.
398    #[command(visible_alias = "ls")]
399    List {
400        /// Discourse name.
401        discourse: String,
402        /// Output format.
403        #[arg(long, short = 'f', value_enum, default_value = "text")]
404        format: ListFormat,
405        /// Include additional fields where supported.
406        #[arg(long, short = 'v')]
407        verbose: bool,
408    },
409    /// Pull a palette to local JSON.
410    #[command(visible_alias = "pl")]
411    Pull {
412        /// Discourse name.
413        discourse: String,
414        /// Palette ID.
415        palette_id: u64,
416        /// Destination file path (auto-derived when omitted).
417        local_path: Option<PathBuf>,
418    },
419    /// Push local JSON to create or update a palette.
420    #[command(visible_alias = "ps")]
421    Push {
422        /// Discourse name.
423        discourse: String,
424        /// Local JSON file path.
425        local_path: PathBuf,
426        /// Palette ID to update (creates a new palette when omitted).
427        palette_id: Option<u64>,
428    },
429}
430
431#[derive(Subcommand)]
432pub enum PluginCommand {
433    /// List installed plugins.
434    #[command(visible_alias = "ls")]
435    List {
436        /// Discourse name.
437        discourse: String,
438        /// Output format.
439        #[arg(long, short = 'f', value_enum, default_value = "text")]
440        format: ListFormat,
441        /// Include additional fields where supported.
442        #[arg(long, short = 'v')]
443        verbose: bool,
444    },
445    /// Install a plugin from URL.
446    #[command(visible_alias = "i")]
447    Install {
448        /// Discourse name.
449        discourse: String,
450        /// Plugin repository URL.
451        url: String,
452    },
453    /// Remove a plugin by name.
454    #[command(visible_alias = "rm")]
455    Remove {
456        /// Discourse name.
457        discourse: String,
458        /// Plugin name.
459        name: String,
460    },
461}
462
463#[derive(Subcommand)]
464pub enum ThemeCommand {
465    /// List installed themes.
466    #[command(visible_alias = "ls")]
467    List {
468        /// Discourse name.
469        discourse: String,
470        /// Output format.
471        #[arg(long, short = 'f', value_enum, default_value = "text")]
472        format: ListFormat,
473        /// Include additional fields where supported.
474        #[arg(long, short = 'v')]
475        verbose: bool,
476    },
477    /// Install a theme from URL.
478    #[command(visible_alias = "i")]
479    Install {
480        /// Discourse name.
481        discourse: String,
482        /// Theme repository URL.
483        url: String,
484    },
485    /// Remove a theme by name.
486    #[command(visible_alias = "rm")]
487    Remove {
488        /// Discourse name.
489        discourse: String,
490        /// Theme name.
491        name: String,
492    },
493    /// Pull a theme to a local JSON file.
494    #[command(visible_alias = "pl")]
495    Pull {
496        /// Discourse name.
497        discourse: String,
498        /// Theme ID (from `dsc theme list`).
499        theme_id: u64,
500        /// Destination file path (auto-derived from theme name when omitted).
501        local_path: Option<PathBuf>,
502    },
503    /// Push a local JSON file to create or update a theme.
504    #[command(visible_alias = "ps")]
505    Push {
506        /// Discourse name.
507        discourse: String,
508        /// Local JSON file path.
509        local_path: PathBuf,
510        /// Theme ID to update (creates a new theme when omitted).
511        theme_id: Option<u64>,
512    },
513    /// Duplicate a theme and print the new theme ID.
514    #[command(visible_alias = "dup")]
515    Duplicate {
516        /// Discourse name.
517        discourse: String,
518        /// Theme ID to duplicate (from `dsc theme list`).
519        theme_id: u64,
520    },
521}
522
523#[derive(Subcommand)]
524pub enum SettingCommand {
525    /// Set a site setting on a Discourse (or all tagged Discourses).
526    #[command(visible_alias = "s")]
527    Set {
528        /// Discourse name. Required when targeting a single discourse.
529        discourse: String,
530        /// Setting key.
531        setting: String,
532        /// Setting value.
533        value: String,
534        /// Optional tag filter (comma/semicolon separated, match-any). Ignored when discourse is specified.
535        #[arg(long, value_name = "tag1,tag2")]
536        tags: Option<String>,
537    },
538
539    /// Get the current value of a site setting.
540    #[command(visible_alias = "g")]
541    Get {
542        /// Discourse name.
543        discourse: String,
544        /// Setting key.
545        setting: String,
546    },
547
548    /// List all site settings.
549    #[command(visible_alias = "ls")]
550    List {
551        /// Discourse name.
552        discourse: String,
553        /// Output format.
554        #[arg(long, short = 'f', value_enum, default_value = "text")]
555        format: ListFormat,
556        /// Show output even when list is empty.
557        #[arg(long, short = 'v')]
558        verbose: bool,
559    },
560}
561
562#[derive(ValueEnum, Clone, Copy)]
563pub enum CompletionShell {
564    /// Bash shell.
565    Bash,
566    /// Zsh shell.
567    Zsh,
568    /// Fish shell.
569    Fish,
570}
571
572impl From<CompletionShell> for Shell {
573    fn from(value: CompletionShell) -> Self {
574        match value {
575            CompletionShell::Bash => Shell::Bash,
576            CompletionShell::Zsh => Shell::Zsh,
577            CompletionShell::Fish => Shell::Fish,
578        }
579    }
580}
581
582#[derive(ValueEnum, Clone)]
583pub enum OutputFormat {
584    /// Plain text.
585    #[value(alias = "plaintext")]
586    Text,
587    /// Markdown list.
588    Markdown,
589    /// Markdown table.
590    MarkdownTable,
591    /// Pretty JSON.
592    Json,
593    /// YAML.
594    #[value(alias = "yml")]
595    Yaml,
596    /// CSV.
597    Csv,
598    /// One base URL per line (pipe-friendly).
599    #[value(alias = "url")]
600    Urls,
601}
602
603#[derive(ValueEnum, Clone, Copy)]
604pub enum ListFormat {
605    /// Plain text.
606    Text,
607    /// Pretty JSON.
608    Json,
609    /// YAML.
610    #[value(alias = "yml")]
611    Yaml,
612}
613
614#[derive(ValueEnum, Clone, Copy)]
615pub enum StructuredFormat {
616    /// Pretty JSON.
617    Json,
618    /// YAML.
619    #[value(alias = "yml")]
620    Yaml,
621}