Skip to main content

upskill/
plugin.rs

1//! Client CLI shellout for plugin installation (ADR-0008).
2//!
3//! Plugins are installed by shelling out to each client's native CLI:
4//! - Claude Code: `claude plugin marketplace add` + `claude plugin install`
5//! - Copilot CLI: `copilot plugin marketplace add` + `copilot plugin install`
6//! - VS Code: `code --install-extension`
7//! - opencode: `opencode plugin`
8//!
9//! This module exposes typed install/uninstall functions per client.
10//! All functions return structured results (never write to stdout/stderr)
11//! and handle CLI-not-found gracefully per the warn-skip policy.
12
13use crate::model::bundle::{
14    ClaudePluginDescriptor, CopilotPluginDescriptor, OpencodePluginDescriptor,
15    VscodePluginDescriptor,
16};
17use std::io::ErrorKind;
18use std::process::Command;
19
20/// Scope for Claude plugin installation, derived from upskill's
21/// project/global flag.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum PluginScope {
24    /// Project-scoped install (`claude plugin install --scope project`).
25    Project,
26    /// User-scoped install (`claude plugin install --scope user`).
27    User,
28}
29
30impl PluginScope {
31    /// Returns the CLI flag value for `claude plugin install --scope`.
32    pub fn as_claude_flag(&self) -> &'static str {
33        match self {
34            Self::Project => "project",
35            Self::User => "user",
36        }
37    }
38}
39
40/// Outcome of a plugin install or uninstall attempt.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum PluginOutcome {
43    /// Command executed successfully (exit code 0).
44    Success,
45    /// Client CLI not found on PATH — plugin skipped.
46    CliNotFound,
47    /// Client CLI found but command returned non-zero.
48    Failed {
49        exit_code: Option<i32>,
50        stderr: String,
51    },
52}
53
54impl PluginOutcome {
55    /// True when the plugin was successfully installed/uninstalled.
56    pub fn is_success(&self) -> bool {
57        matches!(self, Self::Success)
58    }
59
60    /// True when the CLI was not found (warn-skip scenario).
61    pub fn is_cli_not_found(&self) -> bool {
62        matches!(self, Self::CliNotFound)
63    }
64}
65
66// ---------------------------------------------------------------------------
67// Install functions
68// ---------------------------------------------------------------------------
69
70/// Install a Claude Code plugin via `claude plugin marketplace add` followed
71/// by `claude plugin install`. The marketplace-add step is idempotent.
72pub fn install_claude_plugin(
73    descriptor: &ClaudePluginDescriptor,
74    scope: PluginScope,
75) -> PluginOutcome {
76    // Step 1: Add marketplace source (idempotent).
77    let result = run_command(
78        "claude",
79        &["plugin", "marketplace", "add", &descriptor.source],
80    );
81    match result {
82        PluginOutcome::CliNotFound => return PluginOutcome::CliNotFound,
83        PluginOutcome::Failed { .. } => return result,
84        PluginOutcome::Success => {}
85    }
86
87    // Step 2: Install plugin with scope.
88    let install_ref = format!("{}@{}", descriptor.plugin, descriptor.source);
89    run_command(
90        "claude",
91        &[
92            "plugin",
93            "install",
94            &install_ref,
95            "--scope",
96            scope.as_claude_flag(),
97        ],
98    )
99}
100
101/// Install a VS Code extension via `code --install-extension`.
102pub fn install_vscode_extension(descriptor: &VscodePluginDescriptor) -> PluginOutcome {
103    run_command("code", &["--install-extension", &descriptor.extension])
104}
105
106/// Install an opencode module via `opencode plugin`.
107pub fn install_opencode_plugin(descriptor: &OpencodePluginDescriptor) -> PluginOutcome {
108    run_command("opencode", &["plugin", &descriptor.module])
109}
110
111/// Install a GitHub Copilot CLI plugin via `copilot plugin marketplace add`
112/// followed by `copilot plugin install`. The marketplace-add step is
113/// idempotent. Unlike Claude, Copilot CLI does not support a `--scope` flag.
114pub fn install_copilot_plugin(descriptor: &CopilotPluginDescriptor) -> PluginOutcome {
115    // Step 1: Add marketplace source (idempotent).
116    let result = run_command(
117        "copilot",
118        &["plugin", "marketplace", "add", &descriptor.source],
119    );
120    match result {
121        PluginOutcome::CliNotFound => return PluginOutcome::CliNotFound,
122        PluginOutcome::Failed { .. } => return result,
123        PluginOutcome::Success => {}
124    }
125
126    // Step 2: Install plugin from marketplace.
127    let install_ref = format!("{}@{}", descriptor.plugin, descriptor.source);
128    run_command("copilot", &["plugin", "install", &install_ref])
129}
130
131// ---------------------------------------------------------------------------
132// Uninstall functions
133// ---------------------------------------------------------------------------
134
135/// Uninstall a Claude Code plugin.
136pub fn uninstall_claude_plugin(plugin: &str, source: &str, scope: PluginScope) -> PluginOutcome {
137    let install_ref = format!("{plugin}@{source}");
138    run_command(
139        "claude",
140        &[
141            "plugin",
142            "uninstall",
143            &install_ref,
144            "--scope",
145            scope.as_claude_flag(),
146        ],
147    )
148}
149
150/// Uninstall a VS Code extension.
151pub fn uninstall_vscode_extension(extension: &str) -> PluginOutcome {
152    run_command("code", &["--uninstall-extension", extension])
153}
154
155/// Uninstall an opencode module.
156pub fn uninstall_opencode_plugin(module: &str) -> PluginOutcome {
157    run_command("opencode", &["plugin", "remove", module])
158}
159
160/// Uninstall a GitHub Copilot CLI plugin.
161pub fn uninstall_copilot_plugin(plugin: &str, source: &str) -> PluginOutcome {
162    let install_ref = format!("{plugin}@{source}");
163    run_command("copilot", &["plugin", "uninstall", &install_ref])
164}
165
166// ---------------------------------------------------------------------------
167// CLI availability check
168// ---------------------------------------------------------------------------
169
170/// Returns `true` if the named CLI binary is available on PATH.
171///
172/// Uses `Command::new(cli).arg("--version")` and checks for
173/// `ErrorKind::NotFound`. Does not validate the command succeeds — only
174/// that the binary can be spawned.
175pub fn is_cli_available(cli: &str) -> bool {
176    match spawn_command(cli, &["--version"]) {
177        Ok(out) if is_command_not_found(&out) => false,
178        Ok(_) => true,
179        Err(e) if e.kind() == ErrorKind::NotFound => false,
180        // Other errors (e.g., permission denied) — the binary exists but
181        // can't be run. Treat as "available but broken" so the install
182        // attempt surfaces the real error.
183        Err(_) => true,
184    }
185}
186
187// ---------------------------------------------------------------------------
188// Plugin list / reconciliation (ADR-0008 / issue #151)
189// ---------------------------------------------------------------------------
190
191/// Result of checking whether a specific plugin is currently installed in
192/// the client.  Used by `doctor` for plugin reconciliation.
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub enum PluginCheckResult {
195    /// Plugin found in the client's installed list.
196    Installed,
197    /// Plugin NOT found — present in lockfile but absent from client.
198    NotInstalled,
199    /// Client CLI not found on PATH — install state cannot be determined.
200    CliNotFound,
201    /// CLI present but the list command returned non-zero.
202    QueryFailed {
203        exit_code: Option<i32>,
204        stderr: String,
205    },
206}
207
208impl PluginCheckResult {
209    /// True when the plugin is confirmed installed.
210    pub fn is_installed(&self) -> bool {
211        matches!(self, Self::Installed)
212    }
213
214    /// True when the plugin is confirmed NOT installed.
215    pub fn is_not_installed(&self) -> bool {
216        matches!(self, Self::NotInstalled)
217    }
218
219    /// True when the CLI was not found on PATH.
220    pub fn is_cli_not_found(&self) -> bool {
221        matches!(self, Self::CliNotFound)
222    }
223}
224
225/// Check whether a Claude Code plugin is installed for the given scope.
226///
227/// Runs `claude plugin list --scope <scope>` and searches for `plugin_name`
228/// as a substring of any output line.
229pub fn check_claude_plugin_installed(plugin_name: &str, scope: PluginScope) -> PluginCheckResult {
230    let out = run_command_output(
231        "claude",
232        &["plugin", "list", "--scope", scope.as_claude_flag()],
233    );
234    check_output_for_substring(out, plugin_name)
235}
236
237/// Check whether a VS Code extension is installed.
238///
239/// Runs `code --list-extensions` and checks for the extension ID as an
240/// exact (case-insensitive) line match.
241pub fn check_vscode_extension_installed(extension_id: &str) -> PluginCheckResult {
242    let out = run_command_output("code", &["--list-extensions"]);
243    check_output_for_exact_line(out, extension_id)
244}
245
246/// Check whether an opencode plugin is installed.
247///
248/// Runs `opencode plugin list` and searches for `module_name` as a
249/// substring of any output line.
250pub fn check_opencode_plugin_installed(module_name: &str) -> PluginCheckResult {
251    let out = run_command_output("opencode", &["plugin", "list"]);
252    check_output_for_substring(out, module_name)
253}
254
255// ---------------------------------------------------------------------------
256// Internal command output helpers
257// ---------------------------------------------------------------------------
258
259/// Captured result of a CLI invocation.
260enum CommandOutput {
261    Success {
262        stdout: String,
263    },
264    CliNotFound,
265    Failed {
266        exit_code: Option<i32>,
267        stderr: String,
268    },
269}
270
271fn run_command_output(program: &str, args: &[&str]) -> CommandOutput {
272    let result = spawn_command(program, args);
273    match result {
274        Ok(out) if out.status.success() => CommandOutput::Success {
275            stdout: String::from_utf8_lossy(&out.stdout).to_string(),
276        },
277        Ok(out) if is_command_not_found(&out) => CommandOutput::CliNotFound,
278        Ok(out) => CommandOutput::Failed {
279            exit_code: out.status.code(),
280            stderr: String::from_utf8_lossy(&out.stderr).trim().to_string(),
281        },
282        Err(e) if e.kind() == ErrorKind::NotFound => CommandOutput::CliNotFound,
283        Err(e) => CommandOutput::Failed {
284            exit_code: None,
285            stderr: format!("failed to spawn {program}: {e}"),
286        },
287    }
288}
289
290/// Spawn a command, using `cmd /c` on Windows so that PATHEXT resolution
291/// finds `.cmd` and `.bat` wrappers (e.g. `code.cmd` for VS Code).
292fn spawn_command(program: &str, args: &[&str]) -> std::io::Result<std::process::Output> {
293    #[cfg(windows)]
294    {
295        let mut cmd_args = vec!["/c", program];
296        cmd_args.extend(args);
297        Command::new("cmd").args(&cmd_args).output()
298    }
299    #[cfg(not(windows))]
300    {
301        Command::new(program).args(args).output()
302    }
303}
304
305/// On Windows, `cmd /c nonexistent` prints "is not recognized" to stderr.
306/// The exit code varies by Windows version (1 or 9009), so we detect
307/// the stderr message instead.
308fn is_command_not_found(output: &std::process::Output) -> bool {
309    #[cfg(windows)]
310    {
311        let stderr = String::from_utf8_lossy(&output.stderr);
312        stderr.contains("is not recognized")
313    }
314    #[cfg(not(windows))]
315    {
316        let _ = output;
317        false
318    }
319}
320
321/// Map `CommandOutput` to `PluginCheckResult` by searching for `needle` as
322/// a substring of any stdout line.
323fn check_output_for_substring(output: CommandOutput, needle: &str) -> PluginCheckResult {
324    match output {
325        CommandOutput::CliNotFound => PluginCheckResult::CliNotFound,
326        CommandOutput::Failed { exit_code, stderr } => {
327            PluginCheckResult::QueryFailed { exit_code, stderr }
328        }
329        CommandOutput::Success { stdout } => {
330            if stdout.lines().any(|line| line.contains(needle)) {
331                PluginCheckResult::Installed
332            } else {
333                PluginCheckResult::NotInstalled
334            }
335        }
336    }
337}
338
339/// Map `CommandOutput` to `PluginCheckResult` by checking for `needle` as an
340/// exact (case-insensitive, trimmed) line in stdout.
341fn check_output_for_exact_line(output: CommandOutput, needle: &str) -> PluginCheckResult {
342    match output {
343        CommandOutput::CliNotFound => PluginCheckResult::CliNotFound,
344        CommandOutput::Failed { exit_code, stderr } => {
345            PluginCheckResult::QueryFailed { exit_code, stderr }
346        }
347        CommandOutput::Success { stdout } => {
348            if stdout
349                .lines()
350                .any(|line| line.trim().eq_ignore_ascii_case(needle))
351            {
352                PluginCheckResult::Installed
353            } else {
354                PluginCheckResult::NotInstalled
355            }
356        }
357    }
358}
359
360/// Execute a CLI command and map the result to a `PluginOutcome`.
361fn run_command(program: &str, args: &[&str]) -> PluginOutcome {
362    let output = match spawn_command(program, args) {
363        Ok(output) if is_command_not_found(&output) => {
364            return PluginOutcome::CliNotFound;
365        }
366        Ok(output) => output,
367        Err(e) if e.kind() == ErrorKind::NotFound => {
368            return PluginOutcome::CliNotFound;
369        }
370        Err(e) => {
371            return PluginOutcome::Failed {
372                exit_code: None,
373                stderr: format!("failed to spawn {program}: {e}"),
374            };
375        }
376    };
377
378    if output.status.success() {
379        PluginOutcome::Success
380    } else {
381        PluginOutcome::Failed {
382            exit_code: output.status.code(),
383            stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
384        }
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    #[test]
393    fn plugin_scope_as_claude_flag() {
394        assert_eq!(PluginScope::Project.as_claude_flag(), "project");
395        assert_eq!(PluginScope::User.as_claude_flag(), "user");
396    }
397
398    #[test]
399    fn is_cli_available_returns_true_for_known_binary() {
400        // `sh` is always available on Unix.
401        assert!(is_cli_available("sh"));
402    }
403
404    #[test]
405    fn is_cli_available_returns_false_for_nonexistent_binary() {
406        assert!(!is_cli_available(
407            "nonexistent-binary-that-does-not-exist-xyz-42"
408        ));
409    }
410
411    #[test]
412    fn install_claude_returns_cli_not_found_when_binary_missing() {
413        // Using a descriptor that references a nonexistent binary would
414        // require overriding the program name. Instead, we test via
415        // run_command directly.
416        let result = run_command(
417            "nonexistent-claude-xyz-42",
418            &["plugin", "marketplace", "add", "test-source"],
419        );
420        assert_eq!(result, PluginOutcome::CliNotFound);
421    }
422
423    #[test]
424    fn install_vscode_returns_cli_not_found_when_binary_missing() {
425        let descriptor = VscodePluginDescriptor {
426            extension: "test.extension".to_string(),
427            install_url: None,
428        };
429        // code is likely not on PATH in CI; if it is, this still works
430        // because --install-extension with a fake extension just fails.
431        let result = run_command(
432            "nonexistent-code-xyz-42",
433            &["--install-extension", &descriptor.extension],
434        );
435        assert_eq!(result, PluginOutcome::CliNotFound);
436    }
437
438    #[test]
439    fn install_opencode_returns_cli_not_found_when_binary_missing() {
440        let result = run_command("nonexistent-opencode-xyz-42", &["plugin", "test-module"]);
441        assert_eq!(result, PluginOutcome::CliNotFound);
442    }
443
444    #[test]
445    fn run_command_returns_success_on_zero_exit() {
446        // `true` always exits 0 on Unix.
447        let result = run_command("true", &[]);
448        assert_eq!(result, PluginOutcome::Success);
449    }
450
451    #[test]
452    fn run_command_returns_failed_on_nonzero_exit() {
453        // `false` always exits 1 on Unix.
454        let result = run_command("false", &[]);
455        assert!(matches!(
456            result,
457            PluginOutcome::Failed {
458                exit_code: Some(1),
459                ..
460            }
461        ));
462    }
463
464    #[test]
465    fn outcome_is_success_predicate() {
466        assert!(PluginOutcome::Success.is_success());
467        assert!(!PluginOutcome::CliNotFound.is_success());
468        assert!(
469            !PluginOutcome::Failed {
470                exit_code: Some(1),
471                stderr: String::new(),
472            }
473            .is_success()
474        );
475    }
476
477    #[test]
478    fn outcome_is_cli_not_found_predicate() {
479        assert!(PluginOutcome::CliNotFound.is_cli_not_found());
480        assert!(!PluginOutcome::Success.is_cli_not_found());
481    }
482
483    #[test]
484    fn install_copilot_returns_cli_not_found_when_binary_missing() {
485        // Using a descriptor that references a nonexistent binary would
486        // require overriding the program name. Instead, we test via
487        // run_command directly (same pattern as the Claude tests above).
488        let result = run_command(
489            "nonexistent-copilot-xyz-42",
490            &["plugin", "marketplace", "add", "test-source"],
491        );
492        assert_eq!(result, PluginOutcome::CliNotFound);
493    }
494
495    #[test]
496    fn uninstall_copilot_returns_cli_not_found_when_binary_missing() {
497        let result = run_command(
498            "nonexistent-copilot-xyz-42",
499            &["plugin", "uninstall", "test@source"],
500        );
501        assert_eq!(result, PluginOutcome::CliNotFound);
502    }
503
504    // -----------------------------------------------------------------------
505    // PluginCheckResult tests (check_output_for_* helpers)
506    // -----------------------------------------------------------------------
507
508    #[test]
509    fn check_output_for_exact_line_finds_matching_extension() {
510        let output = CommandOutput::Success {
511            stdout: "ms-python.python\nanthropic.superpowers\n".to_string(),
512        };
513        assert_eq!(
514            check_output_for_exact_line(output, "anthropic.superpowers"),
515            PluginCheckResult::Installed
516        );
517    }
518
519    #[test]
520    fn check_output_for_exact_line_case_insensitive() {
521        let output = CommandOutput::Success {
522            stdout: "Anthropic.SuperPowers\n".to_string(),
523        };
524        assert_eq!(
525            check_output_for_exact_line(output, "anthropic.superpowers"),
526            PluginCheckResult::Installed
527        );
528    }
529
530    #[test]
531    fn check_output_for_exact_line_returns_not_installed_when_absent() {
532        let output = CommandOutput::Success {
533            stdout: "ms-python.python\n".to_string(),
534        };
535        assert_eq!(
536            check_output_for_exact_line(output, "anthropic.superpowers"),
537            PluginCheckResult::NotInstalled
538        );
539    }
540
541    #[test]
542    fn check_output_for_exact_line_cli_not_found() {
543        assert_eq!(
544            check_output_for_exact_line(CommandOutput::CliNotFound, "anything"),
545            PluginCheckResult::CliNotFound
546        );
547    }
548
549    #[test]
550    fn check_output_for_substring_finds_plugin_name() {
551        let output = CommandOutput::Success {
552            stdout: "superpowers@anthropics/claude-plugins\nother-plugin\n".to_string(),
553        };
554        assert_eq!(
555            check_output_for_substring(output, "superpowers"),
556            PluginCheckResult::Installed
557        );
558    }
559
560    #[test]
561    fn check_output_for_substring_returns_not_installed_when_absent() {
562        let output = CommandOutput::Success {
563            stdout: "other-plugin\n".to_string(),
564        };
565        assert_eq!(
566            check_output_for_substring(output, "superpowers"),
567            PluginCheckResult::NotInstalled
568        );
569    }
570
571    #[test]
572    fn check_output_for_substring_cli_not_found() {
573        assert_eq!(
574            check_output_for_substring(CommandOutput::CliNotFound, "anything"),
575            PluginCheckResult::CliNotFound
576        );
577    }
578
579    #[test]
580    fn check_output_query_failed_maps_correctly() {
581        let output = CommandOutput::Failed {
582            exit_code: Some(1),
583            stderr: "some error".to_string(),
584        };
585        assert!(matches!(
586            check_output_for_substring(output, "anything"),
587            PluginCheckResult::QueryFailed {
588                exit_code: Some(1),
589                ..
590            }
591        ));
592    }
593
594    #[test]
595    fn check_vscode_extension_installed_returns_cli_not_found_when_no_binary() {
596        // `nonexistent-code-xyz-42` is never on PATH.
597        let result =
598            check_output_for_exact_line(CommandOutput::CliNotFound, "anthropic.superpowers");
599        assert_eq!(result, PluginCheckResult::CliNotFound);
600    }
601
602    #[test]
603    fn plugin_check_result_predicates() {
604        assert!(PluginCheckResult::Installed.is_installed());
605        assert!(!PluginCheckResult::NotInstalled.is_installed());
606
607        assert!(PluginCheckResult::NotInstalled.is_not_installed());
608        assert!(!PluginCheckResult::Installed.is_not_installed());
609
610        assert!(PluginCheckResult::CliNotFound.is_cli_not_found());
611        assert!(!PluginCheckResult::Installed.is_cli_not_found());
612    }
613}