use crate::model::bundle::{
ClaudePluginDescriptor, CopilotPluginDescriptor, OpencodePluginDescriptor,
VscodePluginDescriptor,
};
use std::io::ErrorKind;
use std::process::Command;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PluginScope {
Project,
User,
}
impl PluginScope {
pub fn as_claude_flag(&self) -> &'static str {
match self {
Self::Project => "project",
Self::User => "user",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PluginOutcome {
Success,
CliNotFound,
Failed {
exit_code: Option<i32>,
stderr: String,
},
}
impl PluginOutcome {
pub fn is_success(&self) -> bool {
matches!(self, Self::Success)
}
pub fn is_cli_not_found(&self) -> bool {
matches!(self, Self::CliNotFound)
}
}
pub fn install_claude_plugin(
descriptor: &ClaudePluginDescriptor,
scope: PluginScope,
) -> PluginOutcome {
let result = run_command(
"claude",
&["plugin", "marketplace", "add", &descriptor.source],
);
match result {
PluginOutcome::CliNotFound => return PluginOutcome::CliNotFound,
PluginOutcome::Failed { .. } => return result,
PluginOutcome::Success => {}
}
let install_ref = format!("{}@{}", descriptor.plugin, descriptor.source);
run_command(
"claude",
&[
"plugin",
"install",
&install_ref,
"--scope",
scope.as_claude_flag(),
],
)
}
pub fn install_vscode_extension(descriptor: &VscodePluginDescriptor) -> PluginOutcome {
run_command("code", &["--install-extension", &descriptor.extension])
}
pub fn install_opencode_plugin(descriptor: &OpencodePluginDescriptor) -> PluginOutcome {
run_command("opencode", &["plugin", &descriptor.module])
}
pub fn install_copilot_plugin(descriptor: &CopilotPluginDescriptor) -> PluginOutcome {
let result = run_command(
"copilot",
&["plugin", "marketplace", "add", &descriptor.source],
);
match result {
PluginOutcome::CliNotFound => return PluginOutcome::CliNotFound,
PluginOutcome::Failed { .. } => return result,
PluginOutcome::Success => {}
}
let install_ref = format!("{}@{}", descriptor.plugin, descriptor.source);
run_command("copilot", &["plugin", "install", &install_ref])
}
pub fn uninstall_claude_plugin(plugin: &str, source: &str, scope: PluginScope) -> PluginOutcome {
let install_ref = format!("{plugin}@{source}");
run_command(
"claude",
&[
"plugin",
"uninstall",
&install_ref,
"--scope",
scope.as_claude_flag(),
],
)
}
pub fn uninstall_vscode_extension(extension: &str) -> PluginOutcome {
run_command("code", &["--uninstall-extension", extension])
}
pub fn uninstall_opencode_plugin(module: &str) -> PluginOutcome {
run_command("opencode", &["plugin", "remove", module])
}
pub fn uninstall_copilot_plugin(plugin: &str, source: &str) -> PluginOutcome {
let install_ref = format!("{plugin}@{source}");
run_command("copilot", &["plugin", "uninstall", &install_ref])
}
pub fn is_cli_available(cli: &str) -> bool {
match spawn_command(cli, &["--version"]) {
Ok(out) if is_command_not_found(&out) => false,
Ok(_) => true,
Err(e) if e.kind() == ErrorKind::NotFound => false,
Err(_) => true,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PluginCheckResult {
Installed,
NotInstalled,
CliNotFound,
QueryFailed {
exit_code: Option<i32>,
stderr: String,
},
}
impl PluginCheckResult {
pub fn is_installed(&self) -> bool {
matches!(self, Self::Installed)
}
pub fn is_not_installed(&self) -> bool {
matches!(self, Self::NotInstalled)
}
pub fn is_cli_not_found(&self) -> bool {
matches!(self, Self::CliNotFound)
}
}
pub fn check_claude_plugin_installed(plugin_name: &str, scope: PluginScope) -> PluginCheckResult {
let out = run_command_output(
"claude",
&["plugin", "list", "--scope", scope.as_claude_flag()],
);
check_output_for_substring(out, plugin_name)
}
pub fn check_vscode_extension_installed(extension_id: &str) -> PluginCheckResult {
let out = run_command_output("code", &["--list-extensions"]);
check_output_for_exact_line(out, extension_id)
}
pub fn check_opencode_plugin_installed(module_name: &str) -> PluginCheckResult {
let out = run_command_output("opencode", &["plugin", "list"]);
check_output_for_substring(out, module_name)
}
enum CommandOutput {
Success {
stdout: String,
},
CliNotFound,
Failed {
exit_code: Option<i32>,
stderr: String,
},
}
fn run_command_output(program: &str, args: &[&str]) -> CommandOutput {
let result = spawn_command(program, args);
match result {
Ok(out) if out.status.success() => CommandOutput::Success {
stdout: String::from_utf8_lossy(&out.stdout).to_string(),
},
Ok(out) if is_command_not_found(&out) => CommandOutput::CliNotFound,
Ok(out) => CommandOutput::Failed {
exit_code: out.status.code(),
stderr: String::from_utf8_lossy(&out.stderr).trim().to_string(),
},
Err(e) if e.kind() == ErrorKind::NotFound => CommandOutput::CliNotFound,
Err(e) => CommandOutput::Failed {
exit_code: None,
stderr: format!("failed to spawn {program}: {e}"),
},
}
}
fn spawn_command(program: &str, args: &[&str]) -> std::io::Result<std::process::Output> {
#[cfg(windows)]
{
let mut cmd_args = vec!["/c", program];
cmd_args.extend(args);
Command::new("cmd").args(&cmd_args).output()
}
#[cfg(not(windows))]
{
Command::new(program).args(args).output()
}
}
fn is_command_not_found(output: &std::process::Output) -> bool {
#[cfg(windows)]
{
let stderr = String::from_utf8_lossy(&output.stderr);
stderr.contains("is not recognized")
}
#[cfg(not(windows))]
{
let _ = output;
false
}
}
fn check_output_for_substring(output: CommandOutput, needle: &str) -> PluginCheckResult {
match output {
CommandOutput::CliNotFound => PluginCheckResult::CliNotFound,
CommandOutput::Failed { exit_code, stderr } => {
PluginCheckResult::QueryFailed { exit_code, stderr }
}
CommandOutput::Success { stdout } => {
if stdout.lines().any(|line| line.contains(needle)) {
PluginCheckResult::Installed
} else {
PluginCheckResult::NotInstalled
}
}
}
}
fn check_output_for_exact_line(output: CommandOutput, needle: &str) -> PluginCheckResult {
match output {
CommandOutput::CliNotFound => PluginCheckResult::CliNotFound,
CommandOutput::Failed { exit_code, stderr } => {
PluginCheckResult::QueryFailed { exit_code, stderr }
}
CommandOutput::Success { stdout } => {
if stdout
.lines()
.any(|line| line.trim().eq_ignore_ascii_case(needle))
{
PluginCheckResult::Installed
} else {
PluginCheckResult::NotInstalled
}
}
}
}
fn run_command(program: &str, args: &[&str]) -> PluginOutcome {
let output = match spawn_command(program, args) {
Ok(output) if is_command_not_found(&output) => {
return PluginOutcome::CliNotFound;
}
Ok(output) => output,
Err(e) if e.kind() == ErrorKind::NotFound => {
return PluginOutcome::CliNotFound;
}
Err(e) => {
return PluginOutcome::Failed {
exit_code: None,
stderr: format!("failed to spawn {program}: {e}"),
};
}
};
if output.status.success() {
PluginOutcome::Success
} else {
PluginOutcome::Failed {
exit_code: output.status.code(),
stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plugin_scope_as_claude_flag() {
assert_eq!(PluginScope::Project.as_claude_flag(), "project");
assert_eq!(PluginScope::User.as_claude_flag(), "user");
}
#[test]
fn is_cli_available_returns_true_for_known_binary() {
assert!(is_cli_available("sh"));
}
#[test]
fn is_cli_available_returns_false_for_nonexistent_binary() {
assert!(!is_cli_available(
"nonexistent-binary-that-does-not-exist-xyz-42"
));
}
#[test]
fn install_claude_returns_cli_not_found_when_binary_missing() {
let result = run_command(
"nonexistent-claude-xyz-42",
&["plugin", "marketplace", "add", "test-source"],
);
assert_eq!(result, PluginOutcome::CliNotFound);
}
#[test]
fn install_vscode_returns_cli_not_found_when_binary_missing() {
let descriptor = VscodePluginDescriptor {
extension: "test.extension".to_string(),
install_url: None,
};
let result = run_command(
"nonexistent-code-xyz-42",
&["--install-extension", &descriptor.extension],
);
assert_eq!(result, PluginOutcome::CliNotFound);
}
#[test]
fn install_opencode_returns_cli_not_found_when_binary_missing() {
let result = run_command("nonexistent-opencode-xyz-42", &["plugin", "test-module"]);
assert_eq!(result, PluginOutcome::CliNotFound);
}
#[test]
fn run_command_returns_success_on_zero_exit() {
let result = run_command("true", &[]);
assert_eq!(result, PluginOutcome::Success);
}
#[test]
fn run_command_returns_failed_on_nonzero_exit() {
let result = run_command("false", &[]);
assert!(matches!(
result,
PluginOutcome::Failed {
exit_code: Some(1),
..
}
));
}
#[test]
fn outcome_is_success_predicate() {
assert!(PluginOutcome::Success.is_success());
assert!(!PluginOutcome::CliNotFound.is_success());
assert!(
!PluginOutcome::Failed {
exit_code: Some(1),
stderr: String::new(),
}
.is_success()
);
}
#[test]
fn outcome_is_cli_not_found_predicate() {
assert!(PluginOutcome::CliNotFound.is_cli_not_found());
assert!(!PluginOutcome::Success.is_cli_not_found());
}
#[test]
fn install_copilot_returns_cli_not_found_when_binary_missing() {
let result = run_command(
"nonexistent-copilot-xyz-42",
&["plugin", "marketplace", "add", "test-source"],
);
assert_eq!(result, PluginOutcome::CliNotFound);
}
#[test]
fn uninstall_copilot_returns_cli_not_found_when_binary_missing() {
let result = run_command(
"nonexistent-copilot-xyz-42",
&["plugin", "uninstall", "test@source"],
);
assert_eq!(result, PluginOutcome::CliNotFound);
}
#[test]
fn check_output_for_exact_line_finds_matching_extension() {
let output = CommandOutput::Success {
stdout: "ms-python.python\nanthropic.superpowers\n".to_string(),
};
assert_eq!(
check_output_for_exact_line(output, "anthropic.superpowers"),
PluginCheckResult::Installed
);
}
#[test]
fn check_output_for_exact_line_case_insensitive() {
let output = CommandOutput::Success {
stdout: "Anthropic.SuperPowers\n".to_string(),
};
assert_eq!(
check_output_for_exact_line(output, "anthropic.superpowers"),
PluginCheckResult::Installed
);
}
#[test]
fn check_output_for_exact_line_returns_not_installed_when_absent() {
let output = CommandOutput::Success {
stdout: "ms-python.python\n".to_string(),
};
assert_eq!(
check_output_for_exact_line(output, "anthropic.superpowers"),
PluginCheckResult::NotInstalled
);
}
#[test]
fn check_output_for_exact_line_cli_not_found() {
assert_eq!(
check_output_for_exact_line(CommandOutput::CliNotFound, "anything"),
PluginCheckResult::CliNotFound
);
}
#[test]
fn check_output_for_substring_finds_plugin_name() {
let output = CommandOutput::Success {
stdout: "superpowers@anthropics/claude-plugins\nother-plugin\n".to_string(),
};
assert_eq!(
check_output_for_substring(output, "superpowers"),
PluginCheckResult::Installed
);
}
#[test]
fn check_output_for_substring_returns_not_installed_when_absent() {
let output = CommandOutput::Success {
stdout: "other-plugin\n".to_string(),
};
assert_eq!(
check_output_for_substring(output, "superpowers"),
PluginCheckResult::NotInstalled
);
}
#[test]
fn check_output_for_substring_cli_not_found() {
assert_eq!(
check_output_for_substring(CommandOutput::CliNotFound, "anything"),
PluginCheckResult::CliNotFound
);
}
#[test]
fn check_output_query_failed_maps_correctly() {
let output = CommandOutput::Failed {
exit_code: Some(1),
stderr: "some error".to_string(),
};
assert!(matches!(
check_output_for_substring(output, "anything"),
PluginCheckResult::QueryFailed {
exit_code: Some(1),
..
}
));
}
#[test]
fn check_vscode_extension_installed_returns_cli_not_found_when_no_binary() {
let result =
check_output_for_exact_line(CommandOutput::CliNotFound, "anthropic.superpowers");
assert_eq!(result, PluginCheckResult::CliNotFound);
}
#[test]
fn plugin_check_result_predicates() {
assert!(PluginCheckResult::Installed.is_installed());
assert!(!PluginCheckResult::NotInstalled.is_installed());
assert!(PluginCheckResult::NotInstalled.is_not_installed());
assert!(!PluginCheckResult::Installed.is_not_installed());
assert!(PluginCheckResult::CliNotFound.is_cli_not_found());
assert!(!PluginCheckResult::Installed.is_cli_not_found());
}
}