1use crate::model::bundle::{
14 ClaudePluginDescriptor, CopilotPluginDescriptor, OpencodePluginDescriptor,
15 VscodePluginDescriptor,
16};
17use std::io::ErrorKind;
18use std::process::Command;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum PluginScope {
24 Project,
26 User,
28}
29
30impl PluginScope {
31 pub fn as_claude_flag(&self) -> &'static str {
33 match self {
34 Self::Project => "project",
35 Self::User => "user",
36 }
37 }
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum PluginOutcome {
43 Success,
45 CliNotFound,
47 Failed {
49 exit_code: Option<i32>,
50 stderr: String,
51 },
52}
53
54impl PluginOutcome {
55 pub fn is_success(&self) -> bool {
57 matches!(self, Self::Success)
58 }
59
60 pub fn is_cli_not_found(&self) -> bool {
62 matches!(self, Self::CliNotFound)
63 }
64}
65
66pub fn install_claude_plugin(
73 descriptor: &ClaudePluginDescriptor,
74 scope: PluginScope,
75) -> PluginOutcome {
76 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 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
101pub fn install_vscode_extension(descriptor: &VscodePluginDescriptor) -> PluginOutcome {
103 run_command("code", &["--install-extension", &descriptor.extension])
104}
105
106pub fn install_opencode_plugin(descriptor: &OpencodePluginDescriptor) -> PluginOutcome {
108 run_command("opencode", &["plugin", &descriptor.module])
109}
110
111pub fn install_copilot_plugin(descriptor: &CopilotPluginDescriptor) -> PluginOutcome {
115 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 let install_ref = format!("{}@{}", descriptor.plugin, descriptor.source);
128 run_command("copilot", &["plugin", "install", &install_ref])
129}
130
131pub 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
150pub fn uninstall_vscode_extension(extension: &str) -> PluginOutcome {
152 run_command("code", &["--uninstall-extension", extension])
153}
154
155pub fn uninstall_opencode_plugin(module: &str) -> PluginOutcome {
157 run_command("opencode", &["plugin", "remove", module])
158}
159
160pub 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
166pub 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 Err(_) => true,
184 }
185}
186
187#[derive(Debug, Clone, PartialEq, Eq)]
194pub enum PluginCheckResult {
195 Installed,
197 NotInstalled,
199 CliNotFound,
201 QueryFailed {
203 exit_code: Option<i32>,
204 stderr: String,
205 },
206}
207
208impl PluginCheckResult {
209 pub fn is_installed(&self) -> bool {
211 matches!(self, Self::Installed)
212 }
213
214 pub fn is_not_installed(&self) -> bool {
216 matches!(self, Self::NotInstalled)
217 }
218
219 pub fn is_cli_not_found(&self) -> bool {
221 matches!(self, Self::CliNotFound)
222 }
223}
224
225pub 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
237pub 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
246pub 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
255enum 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
290fn 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
305fn 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
321fn 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
339fn 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
360fn 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 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 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 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 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 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 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 #[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 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}