Skip to main content

git_worktree_manager/operations/setup_claude/
claude_cli.rs

1//! Wrapper around the external `claude` CLI for plugin/marketplace ops.
2//!
3//! The trait exists so tests can inject a fake without spawning processes.
4//! `RealClaudeCli` shells out via `std::process::Command`.
5
6use std::path::Path;
7use std::process::Command;
8
9#[derive(Debug)]
10pub enum ClaudeCliError {
11    /// `claude` binary not on PATH.
12    NotInstalled,
13    /// `claude` exited with a non-zero status. Stderr captured for the user.
14    NonZeroExit { code: Option<i32>, stderr: String },
15    /// Failed to spawn the process for an OS-level reason.
16    Io(std::io::Error),
17}
18
19impl std::fmt::Display for ClaudeCliError {
20    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        match self {
22            ClaudeCliError::NotInstalled => write!(f, "`claude` CLI not found on PATH"),
23            ClaudeCliError::NonZeroExit { code, stderr } => write!(
24                f,
25                "`claude` exited with status {:?}: {}",
26                code,
27                stderr.trim()
28            ),
29            ClaudeCliError::Io(e) => write!(f, "failed to invoke `claude`: {}", e),
30        }
31    }
32}
33
34impl std::error::Error for ClaudeCliError {}
35
36pub trait ClaudeCli {
37    fn is_available(&self) -> bool;
38    fn marketplace_add(&self, path: &Path) -> Result<(), ClaudeCliError>;
39    fn marketplace_update(&self, name: &str) -> Result<(), ClaudeCliError>;
40    fn plugin_install(&self, slug: &str) -> Result<(), ClaudeCliError>;
41    fn plugin_update(&self, slug: &str) -> Result<(), ClaudeCliError>;
42}
43
44pub struct RealClaudeCli;
45
46impl RealClaudeCli {
47    fn run(&self, args: &[&str]) -> Result<(), ClaudeCliError> {
48        let out = Command::new("claude").args(args).output().map_err(|e| {
49            if e.kind() == std::io::ErrorKind::NotFound {
50                ClaudeCliError::NotInstalled
51            } else {
52                ClaudeCliError::Io(e)
53            }
54        })?;
55        if !out.status.success() {
56            return Err(ClaudeCliError::NonZeroExit {
57                code: out.status.code(),
58                stderr: String::from_utf8_lossy(&out.stderr).to_string(),
59            });
60        }
61        Ok(())
62    }
63}
64
65impl ClaudeCli for RealClaudeCli {
66    fn is_available(&self) -> bool {
67        Command::new("claude")
68            .arg("--version")
69            .output()
70            .map(|o| o.status.success())
71            .unwrap_or(false)
72    }
73    fn marketplace_add(&self, path: &Path) -> Result<(), ClaudeCliError> {
74        // `--scope user` so the registration is global, not per-project.
75        let path_str = path.display().to_string();
76        self.run(&["plugin", "marketplace", "add", &path_str, "--scope", "user"])
77    }
78    // No `--scope` flag: `claude plugin marketplace update` operates on the
79    // marketplace registration itself, which has no scope concept.
80    fn marketplace_update(&self, name: &str) -> Result<(), ClaudeCliError> {
81        self.run(&["plugin", "marketplace", "update", name])
82    }
83    fn plugin_install(&self, slug: &str) -> Result<(), ClaudeCliError> {
84        self.run(&["plugin", "install", slug, "--scope", "user"])
85    }
86    // No `--scope` arg: `claude plugin update`'s default scope is `user`,
87    // which matches the scope we install under in `plugin_install`.
88    fn plugin_update(&self, slug: &str) -> Result<(), ClaudeCliError> {
89        self.run(&["plugin", "update", slug])
90    }
91}