Skip to main content

claude_wrapper/command/
plugin.rs

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