Skip to main content

claude_wrapper/command/
plugin.rs

1//! Plugin subcommand builders.
2//!
3//! Builders for the `claude plugin` surface: list, install, uninstall,
4//! enable, disable, update, validate, details, prune, and tag. See
5//! [`crate::command::marketplace`] for managing the marketplaces
6//! plugins are installed from.
7
8#[cfg(feature = "async")]
9use crate::Claude;
10use crate::command::ClaudeCommand;
11#[cfg(feature = "async")]
12use crate::error::Result;
13#[cfg(feature = "async")]
14use crate::exec;
15use crate::exec::CommandOutput;
16use crate::types::Scope;
17
18/// List installed plugins.
19///
20/// # Example
21///
22/// ```no_run
23/// use claude_wrapper::{Claude, ClaudeCommand, PluginListCommand};
24///
25/// # async fn example() -> claude_wrapper::Result<()> {
26/// let claude = Claude::builder().build()?;
27/// let output = PluginListCommand::new().json().execute(&claude).await?;
28/// println!("{}", output.stdout);
29/// # Ok(())
30/// # }
31/// ```
32#[derive(Debug, Clone, Default)]
33pub struct PluginListCommand {
34    json: bool,
35    available: bool,
36}
37
38impl PluginListCommand {
39    /// Creates a new plugin list command.
40    #[must_use]
41    pub fn new() -> Self {
42        Self::default()
43    }
44
45    /// Output as JSON.
46    #[must_use]
47    pub fn json(mut self) -> Self {
48        self.json = true;
49        self
50    }
51
52    /// Include available plugins from marketplaces (requires `json()`).
53    #[must_use]
54    pub fn available(mut self) -> Self {
55        self.available = true;
56        self
57    }
58}
59
60impl ClaudeCommand for PluginListCommand {
61    type Output = CommandOutput;
62
63    fn args(&self) -> Vec<String> {
64        let mut args = vec!["plugin".to_string(), "list".to_string()];
65        if self.json {
66            args.push("--json".to_string());
67        }
68        if self.available {
69            args.push("--available".to_string());
70        }
71        args
72    }
73
74    #[cfg(feature = "async")]
75    async fn execute(&self, claude: &Claude) -> Result<CommandOutput> {
76        exec::run_claude(claude, self.args()).await
77    }
78}
79
80/// Install a plugin.
81///
82/// # Example
83///
84/// ```no_run
85/// use claude_wrapper::{Claude, ClaudeCommand, PluginInstallCommand, Scope};
86///
87/// # async fn example() -> claude_wrapper::Result<()> {
88/// let claude = Claude::builder().build()?;
89/// PluginInstallCommand::new("my-plugin")
90///     .scope(Scope::User)
91///     .execute(&claude)
92///     .await?;
93/// # Ok(())
94/// # }
95/// ```
96#[derive(Debug, Clone)]
97pub struct PluginInstallCommand {
98    plugin: String,
99    scope: Option<Scope>,
100}
101
102impl PluginInstallCommand {
103    /// Creates a command to install a plugin by name.
104    #[must_use]
105    pub fn new(plugin: impl Into<String>) -> Self {
106        Self {
107            plugin: plugin.into(),
108            scope: None,
109        }
110    }
111
112    /// Set the installation scope.
113    #[must_use]
114    pub fn scope(mut self, scope: Scope) -> Self {
115        self.scope = Some(scope);
116        self
117    }
118}
119
120impl ClaudeCommand for PluginInstallCommand {
121    type Output = CommandOutput;
122
123    fn args(&self) -> Vec<String> {
124        let mut args = vec!["plugin".to_string(), "install".to_string()];
125        if let Some(ref scope) = self.scope {
126            args.push("--scope".to_string());
127            args.push(scope.as_arg().to_string());
128        }
129        args.push(self.plugin.clone());
130        args
131    }
132
133    #[cfg(feature = "async")]
134    async fn execute(&self, claude: &Claude) -> Result<CommandOutput> {
135        exec::run_claude(claude, self.args()).await
136    }
137}
138
139/// Uninstall a plugin.
140///
141/// **Headless callers should pass [`Self::yes`]** -- the underlying
142/// CLI requires `-y` whenever stdin/stdout isn't a TTY and will
143/// otherwise wait on a prompt that no one is around to answer.
144#[derive(Debug, Clone)]
145pub struct PluginUninstallCommand {
146    plugin: String,
147    scope: Option<Scope>,
148    keep_data: bool,
149    prune: bool,
150    yes: bool,
151}
152
153impl PluginUninstallCommand {
154    /// Creates a command to uninstall a plugin by name.
155    #[must_use]
156    pub fn new(plugin: impl Into<String>) -> Self {
157        Self {
158            plugin: plugin.into(),
159            scope: None,
160            keep_data: false,
161            prune: false,
162            yes: false,
163        }
164    }
165
166    /// Set the scope.
167    #[must_use]
168    pub fn scope(mut self, scope: Scope) -> Self {
169        self.scope = Some(scope);
170        self
171    }
172
173    /// Preserve the plugin's persistent data directory
174    /// (`~/.claude/plugins/data/{id}/`) on uninstall (`--keep-data`).
175    /// Default: data is removed alongside the plugin.
176    #[must_use]
177    pub fn keep_data(mut self) -> Self {
178        self.keep_data = true;
179        self
180    }
181
182    /// Also remove auto-installed dependencies that are no longer
183    /// needed (`--prune`). Requires [`Self::yes`] in non-interactive
184    /// contexts (which the wrapper always is).
185    #[must_use]
186    pub fn prune(mut self) -> Self {
187        self.prune = true;
188        self
189    }
190
191    /// Skip the `--prune` confirmation prompt (`-y`). **Required for
192    /// non-TTY callers** -- without it, the CLI will hang waiting on
193    /// stdin. Every wrapper consumer running under `execute()` is
194    /// non-TTY by definition, so you almost always want this on.
195    #[must_use]
196    pub fn yes(mut self) -> Self {
197        self.yes = true;
198        self
199    }
200}
201
202impl ClaudeCommand for PluginUninstallCommand {
203    type Output = CommandOutput;
204
205    fn args(&self) -> Vec<String> {
206        let mut args = vec!["plugin".to_string(), "uninstall".to_string()];
207        if let Some(ref scope) = self.scope {
208            args.push("--scope".to_string());
209            args.push(scope.as_arg().to_string());
210        }
211        if self.keep_data {
212            args.push("--keep-data".to_string());
213        }
214        if self.prune {
215            args.push("--prune".to_string());
216        }
217        if self.yes {
218            args.push("--yes".to_string());
219        }
220        args.push(self.plugin.clone());
221        args
222    }
223
224    #[cfg(feature = "async")]
225    async fn execute(&self, claude: &Claude) -> Result<CommandOutput> {
226        exec::run_claude(claude, self.args()).await
227    }
228}
229
230/// Enable a disabled plugin.
231#[derive(Debug, Clone)]
232pub struct PluginEnableCommand {
233    plugin: String,
234    scope: Option<Scope>,
235}
236
237impl PluginEnableCommand {
238    /// Creates a command to enable a plugin by name.
239    #[must_use]
240    pub fn new(plugin: impl Into<String>) -> Self {
241        Self {
242            plugin: plugin.into(),
243            scope: None,
244        }
245    }
246
247    /// Set the scope.
248    #[must_use]
249    pub fn scope(mut self, scope: Scope) -> Self {
250        self.scope = Some(scope);
251        self
252    }
253}
254
255impl ClaudeCommand for PluginEnableCommand {
256    type Output = CommandOutput;
257
258    fn args(&self) -> Vec<String> {
259        let mut args = vec!["plugin".to_string(), "enable".to_string()];
260        if let Some(ref scope) = self.scope {
261            args.push("--scope".to_string());
262            args.push(scope.as_arg().to_string());
263        }
264        args.push(self.plugin.clone());
265        args
266    }
267
268    #[cfg(feature = "async")]
269    async fn execute(&self, claude: &Claude) -> Result<CommandOutput> {
270        exec::run_claude(claude, self.args()).await
271    }
272}
273
274/// Disable an enabled plugin.
275#[derive(Debug, Clone)]
276pub struct PluginDisableCommand {
277    plugin: Option<String>,
278    scope: Option<Scope>,
279    all: bool,
280}
281
282impl PluginDisableCommand {
283    /// Creates a command to disable a plugin by name. To disable all plugins, use [`PluginDisableCommand::all`].
284    #[must_use]
285    pub fn new(plugin: impl Into<String>) -> Self {
286        Self {
287            plugin: Some(plugin.into()),
288            scope: None,
289            all: false,
290        }
291    }
292
293    /// Disable all enabled plugins.
294    #[must_use]
295    pub fn all() -> Self {
296        Self {
297            plugin: None,
298            scope: None,
299            all: true,
300        }
301    }
302
303    /// Set the scope.
304    #[must_use]
305    pub fn scope(mut self, scope: Scope) -> Self {
306        self.scope = Some(scope);
307        self
308    }
309}
310
311impl ClaudeCommand for PluginDisableCommand {
312    type Output = CommandOutput;
313
314    fn args(&self) -> Vec<String> {
315        let mut args = vec!["plugin".to_string(), "disable".to_string()];
316        if self.all {
317            args.push("--all".to_string());
318        }
319        if let Some(ref scope) = self.scope {
320            args.push("--scope".to_string());
321            args.push(scope.as_arg().to_string());
322        }
323        if let Some(ref plugin) = self.plugin {
324            args.push(plugin.clone());
325        }
326        args
327    }
328
329    #[cfg(feature = "async")]
330    async fn execute(&self, claude: &Claude) -> Result<CommandOutput> {
331        exec::run_claude(claude, self.args()).await
332    }
333}
334
335/// Update a plugin to the latest version.
336#[derive(Debug, Clone)]
337pub struct PluginUpdateCommand {
338    plugin: String,
339    scope: Option<Scope>,
340}
341
342impl PluginUpdateCommand {
343    /// Creates a command to update a plugin to the latest version.
344    #[must_use]
345    pub fn new(plugin: impl Into<String>) -> Self {
346        Self {
347            plugin: plugin.into(),
348            scope: None,
349        }
350    }
351
352    /// Set the scope.
353    #[must_use]
354    pub fn scope(mut self, scope: Scope) -> Self {
355        self.scope = Some(scope);
356        self
357    }
358}
359
360impl ClaudeCommand for PluginUpdateCommand {
361    type Output = CommandOutput;
362
363    fn args(&self) -> Vec<String> {
364        let mut args = vec!["plugin".to_string(), "update".to_string()];
365        if let Some(ref scope) = self.scope {
366            args.push("--scope".to_string());
367            args.push(scope.as_arg().to_string());
368        }
369        args.push(self.plugin.clone());
370        args
371    }
372
373    #[cfg(feature = "async")]
374    async fn execute(&self, claude: &Claude) -> Result<CommandOutput> {
375        exec::run_claude(claude, self.args()).await
376    }
377}
378
379/// Validate a plugin or marketplace manifest.
380#[derive(Debug, Clone)]
381pub struct PluginValidateCommand {
382    path: String,
383}
384
385impl PluginValidateCommand {
386    /// Creates a command to validate a plugin manifest at the given path.
387    #[must_use]
388    pub fn new(path: impl Into<String>) -> Self {
389        Self { path: path.into() }
390    }
391}
392
393impl ClaudeCommand for PluginValidateCommand {
394    type Output = CommandOutput;
395
396    fn args(&self) -> Vec<String> {
397        vec![
398            "plugin".to_string(),
399            "validate".to_string(),
400            self.path.clone(),
401        ]
402    }
403
404    #[cfg(feature = "async")]
405    async fn execute(&self, claude: &Claude) -> Result<CommandOutput> {
406        exec::run_claude(claude, self.args()).await
407    }
408}
409
410/// Create a `{name}--v{version}` git tag for a plugin release.
411///
412/// Runs `claude plugin tag [path]`, validating that the plugin's
413/// `plugin.json` and any enclosing marketplace entry agree on the
414/// version before tagging.
415///
416/// # Example
417///
418/// ```no_run
419/// # #[cfg(feature = "async")] {
420/// use claude_wrapper::{Claude, ClaudeCommand, PluginTagCommand};
421///
422/// # async fn example() -> claude_wrapper::Result<()> {
423/// let claude = Claude::builder().build()?;
424/// let out = PluginTagCommand::new()
425///     .path("./my-plugin")
426///     .message("release %s")
427///     .push()
428///     .execute(&claude)
429///     .await?;
430/// println!("{}", out.stdout);
431/// # Ok(()) }
432/// # }
433/// ```
434#[derive(Debug, Clone, Default)]
435pub struct PluginTagCommand {
436    path: Option<String>,
437    dry_run: bool,
438    force: bool,
439    message: Option<String>,
440    push: bool,
441    remote: Option<String>,
442}
443
444impl PluginTagCommand {
445    /// Create a new tag command. Without [`path`](Self::path), the CLI
446    /// uses the current directory.
447    #[must_use]
448    pub fn new() -> Self {
449        Self::default()
450    }
451
452    /// Path to the plugin directory.
453    #[must_use]
454    pub fn path(mut self, path: impl Into<String>) -> Self {
455        self.path = Some(path.into());
456        self
457    }
458
459    /// Print what would be tagged without creating anything.
460    #[must_use]
461    pub fn dry_run(mut self) -> Self {
462        self.dry_run = true;
463        self
464    }
465
466    /// Skip dirty-working-tree and tag-already-exists checks.
467    #[must_use]
468    pub fn force(mut self) -> Self {
469        self.force = true;
470        self
471    }
472
473    /// Tag annotation message; `%s` is substituted with the version.
474    #[must_use]
475    pub fn message(mut self, msg: impl Into<String>) -> Self {
476        self.message = Some(msg.into());
477        self
478    }
479
480    /// Push the tag after creating it.
481    #[must_use]
482    pub fn push(mut self) -> Self {
483        self.push = true;
484        self
485    }
486
487    /// Override the remote pushed to with [`push`](Self::push) (default `origin`).
488    #[must_use]
489    pub fn remote(mut self, remote: impl Into<String>) -> Self {
490        self.remote = Some(remote.into());
491        self
492    }
493}
494
495impl ClaudeCommand for PluginTagCommand {
496    type Output = CommandOutput;
497
498    fn args(&self) -> Vec<String> {
499        let mut args = vec!["plugin".to_string(), "tag".to_string()];
500        if self.dry_run {
501            args.push("--dry-run".to_string());
502        }
503        if self.force {
504            args.push("--force".to_string());
505        }
506        if let Some(ref msg) = self.message {
507            args.push("--message".to_string());
508            args.push(msg.clone());
509        }
510        if self.push {
511            args.push("--push".to_string());
512        }
513        if let Some(ref remote) = self.remote {
514            args.push("--remote".to_string());
515            args.push(remote.clone());
516        }
517        if let Some(ref path) = self.path {
518            args.push(path.clone());
519        }
520        args
521    }
522
523    #[cfg(feature = "async")]
524    async fn execute(&self, claude: &Claude) -> Result<CommandOutput> {
525        exec::run_claude(claude, self.args()).await
526    }
527}
528
529/// Show a plugin's component inventory and projected token cost
530/// (`claude plugin details <name>`).
531#[derive(Debug, Clone)]
532pub struct PluginDetailsCommand {
533    plugin: String,
534}
535
536impl PluginDetailsCommand {
537    /// Create a details command for the given plugin name.
538    #[must_use]
539    pub fn new(plugin: impl Into<String>) -> Self {
540        Self {
541            plugin: plugin.into(),
542        }
543    }
544}
545
546impl ClaudeCommand for PluginDetailsCommand {
547    type Output = CommandOutput;
548
549    fn args(&self) -> Vec<String> {
550        vec![
551            "plugin".to_string(),
552            "details".to_string(),
553            self.plugin.clone(),
554        ]
555    }
556
557    #[cfg(feature = "async")]
558    async fn execute(&self, claude: &Claude) -> Result<CommandOutput> {
559        exec::run_claude(claude, self.args()).await
560    }
561}
562
563/// Remove auto-installed dependencies that are no longer needed
564/// (`claude plugin prune` -- alias `autoremove`).
565///
566/// Non-TTY callers should pass [`Self::yes`] -- the underlying CLI
567/// requires `-y` whenever stdin/stdout isn't a TTY and will
568/// otherwise wait on a confirmation prompt.
569#[derive(Debug, Clone, Default)]
570pub struct PluginPruneCommand {
571    dry_run: bool,
572    scope: Option<Scope>,
573    yes: bool,
574}
575
576impl PluginPruneCommand {
577    /// Create a new prune command.
578    #[must_use]
579    pub fn new() -> Self {
580        Self::default()
581    }
582
583    /// Print what would be removed without removing anything
584    /// (`--dry-run`).
585    #[must_use]
586    pub fn dry_run(mut self) -> Self {
587        self.dry_run = true;
588        self
589    }
590
591    /// Set the scope (`-s/--scope`). Default: `user`.
592    #[must_use]
593    pub fn scope(mut self, scope: Scope) -> Self {
594        self.scope = Some(scope);
595        self
596    }
597
598    /// Skip the confirmation prompt (`-y`). **Required for non-TTY
599    /// callers** -- without it the CLI will hang waiting on stdin.
600    #[must_use]
601    pub fn yes(mut self) -> Self {
602        self.yes = true;
603        self
604    }
605}
606
607impl ClaudeCommand for PluginPruneCommand {
608    type Output = CommandOutput;
609
610    fn args(&self) -> Vec<String> {
611        let mut args = vec!["plugin".to_string(), "prune".to_string()];
612        if self.dry_run {
613            args.push("--dry-run".to_string());
614        }
615        if let Some(ref scope) = self.scope {
616            args.push("--scope".to_string());
617            args.push(scope.as_arg().to_string());
618        }
619        if self.yes {
620            args.push("--yes".to_string());
621        }
622        args
623    }
624
625    #[cfg(feature = "async")]
626    async fn execute(&self, claude: &Claude) -> Result<CommandOutput> {
627        exec::run_claude(claude, self.args()).await
628    }
629}
630
631#[cfg(test)]
632mod tests {
633    use super::*;
634    use crate::command::ClaudeCommand;
635
636    #[test]
637    fn test_plugin_list() {
638        let cmd = PluginListCommand::new().json().available();
639        assert_eq!(
640            ClaudeCommand::args(&cmd),
641            vec!["plugin", "list", "--json", "--available"]
642        );
643    }
644
645    #[test]
646    fn test_plugin_install() {
647        let cmd = PluginInstallCommand::new("my-plugin").scope(Scope::User);
648        assert_eq!(
649            ClaudeCommand::args(&cmd),
650            vec!["plugin", "install", "--scope", "user", "my-plugin"]
651        );
652    }
653
654    #[test]
655    fn test_plugin_uninstall() {
656        let cmd = PluginUninstallCommand::new("old-plugin");
657        assert_eq!(
658            ClaudeCommand::args(&cmd),
659            vec!["plugin", "uninstall", "old-plugin"]
660        );
661    }
662
663    #[test]
664    fn test_plugin_uninstall_with_all_flags() {
665        let cmd = PluginUninstallCommand::new("old-plugin")
666            .scope(Scope::User)
667            .keep_data()
668            .prune()
669            .yes();
670        assert_eq!(
671            ClaudeCommand::args(&cmd),
672            vec![
673                "plugin",
674                "uninstall",
675                "--scope",
676                "user",
677                "--keep-data",
678                "--prune",
679                "--yes",
680                "old-plugin"
681            ]
682        );
683    }
684
685    #[test]
686    fn test_plugin_uninstall_yes_alone() {
687        // Most common headless case: just need to skip the prompt.
688        let cmd = PluginUninstallCommand::new("p").yes();
689        assert_eq!(
690            ClaudeCommand::args(&cmd),
691            vec!["plugin", "uninstall", "--yes", "p"]
692        );
693    }
694
695    #[test]
696    fn test_plugin_enable() {
697        let cmd = PluginEnableCommand::new("my-plugin").scope(Scope::Project);
698        assert_eq!(
699            ClaudeCommand::args(&cmd),
700            vec!["plugin", "enable", "--scope", "project", "my-plugin"]
701        );
702    }
703
704    #[test]
705    fn test_plugin_disable_specific() {
706        let cmd = PluginDisableCommand::new("my-plugin");
707        assert_eq!(
708            ClaudeCommand::args(&cmd),
709            vec!["plugin", "disable", "my-plugin"]
710        );
711    }
712
713    #[test]
714    fn test_plugin_disable_all() {
715        let cmd = PluginDisableCommand::all();
716        assert_eq!(
717            ClaudeCommand::args(&cmd),
718            vec!["plugin", "disable", "--all"]
719        );
720    }
721
722    #[test]
723    fn test_plugin_update() {
724        let cmd = PluginUpdateCommand::new("my-plugin").scope(Scope::Local);
725        assert_eq!(
726            ClaudeCommand::args(&cmd),
727            vec!["plugin", "update", "--scope", "local", "my-plugin"]
728        );
729    }
730
731    #[test]
732    fn test_plugin_validate() {
733        let cmd = PluginValidateCommand::new("/path/to/manifest");
734        assert_eq!(
735            ClaudeCommand::args(&cmd),
736            vec!["plugin", "validate", "/path/to/manifest"]
737        );
738    }
739
740    #[test]
741    fn plugin_tag_defaults_to_just_subcommand() {
742        let cmd = PluginTagCommand::new();
743        assert_eq!(ClaudeCommand::args(&cmd), vec!["plugin", "tag"]);
744    }
745
746    #[test]
747    fn plugin_tag_with_all_options() {
748        let cmd = PluginTagCommand::new()
749            .path("./plugin")
750            .dry_run()
751            .force()
752            .message("release %s")
753            .push()
754            .remote("upstream");
755        assert_eq!(
756            ClaudeCommand::args(&cmd),
757            vec![
758                "plugin",
759                "tag",
760                "--dry-run",
761                "--force",
762                "--message",
763                "release %s",
764                "--push",
765                "--remote",
766                "upstream",
767                "./plugin",
768            ]
769        );
770    }
771
772    #[test]
773    fn test_plugin_details() {
774        let cmd = PluginDetailsCommand::new("some-plugin");
775        assert_eq!(
776            ClaudeCommand::args(&cmd),
777            vec!["plugin", "details", "some-plugin"]
778        );
779    }
780
781    #[test]
782    fn test_plugin_prune_default() {
783        let cmd = PluginPruneCommand::new();
784        assert_eq!(ClaudeCommand::args(&cmd), vec!["plugin", "prune"]);
785    }
786
787    #[test]
788    fn test_plugin_prune_all_flags() {
789        let cmd = PluginPruneCommand::new().dry_run().scope(Scope::User).yes();
790        assert_eq!(
791            ClaudeCommand::args(&cmd),
792            vec!["plugin", "prune", "--dry-run", "--scope", "user", "--yes"]
793        );
794    }
795
796    #[test]
797    fn test_scope_managed_renders_as_arg() {
798        // `claude plugin update --scope managed` added in 2.1.143.
799        let cmd = PluginUpdateCommand::new("p").scope(Scope::Managed);
800        assert_eq!(
801            ClaudeCommand::args(&cmd),
802            vec!["plugin", "update", "--scope", "managed", "p"]
803        );
804    }
805}