Skip to main content

git_worktree_manager/operations/setup_claude/
mod.rs

1//! Local-marketplace installer for Claude Code integration.
2//!
3//! `gw setup-claude` writes a self-contained Claude Code marketplace tree
4//! under the OS data-local dir (e.g. `~/.local/share/git-worktree-manager/
5//! claude-marketplace/` on Linux/macOS, `%LOCALAPPDATA%\git-worktree-
6//! manager\claude-marketplace\` on Windows). After writing, we shell out
7//! to the `claude` CLI to register the marketplace and install/update the
8//! plugin so Claude Code actually loads it.
9//!
10//! Re-runs are idempotent: file content is content-addressed, and the
11//! `claude` CLI calls switch between fresh install and update based on a
12//! sentinel marker we drop on first run.
13
14use std::path::{Path, PathBuf};
15
16use console::style;
17
18use crate::constants::home_dir_or_fallback;
19use crate::error::{CwError, Result};
20
21pub mod claude_cli;
22pub mod command_gw;
23pub mod legacy;
24pub mod manifest;
25pub mod paths;
26mod skill_delegate;
27mod skill_manage;
28pub mod writer;
29
30use claude_cli::{ClaudeCli, RealClaudeCli};
31
32/// Tracks which branch of the `claude` CLI invocation path was taken.
33enum CliOutcome {
34    /// `claude` CLI was not available; we wrote files but did not call it.
35    NotRun,
36    /// We called `marketplace_add` + `plugin_install` (fresh registration).
37    AddInstall,
38    /// We called `marketplace_update` + `plugin_update` (refresh).
39    UpdateUpdate,
40}
41
42#[doc(hidden)]
43pub fn manage_skill_content_for_test() -> &'static str {
44    skill_manage::content()
45}
46
47#[doc(hidden)]
48pub fn manage_reference_content_for_test() -> &'static str {
49    skill_manage::reference_content()
50}
51
52#[doc(hidden)]
53pub fn delegate_skill_content_for_test() -> &'static str {
54    skill_delegate::content()
55}
56
57/// True if our marketplace tree exists at the canonical data-local path.
58pub fn is_installed() -> bool {
59    let dl = data_local_or_fallback();
60    paths::sentinel_under(&dl).exists()
61}
62
63/// Backward-compat alias used by `gw doctor`. Returns true if either the
64/// new install OR a legacy install (any of three layouts) is present.
65pub fn is_skill_installed() -> bool {
66    is_installed() || legacy::any_legacy_present()
67}
68
69/// Backward-compat alias kept for diagnostics.rs. Same meaning as
70/// `is_installed()`.
71pub fn is_plugin_installed() -> bool {
72    is_installed()
73}
74
75/// Production entry point: resolves real home + data-local dirs and uses
76/// the real `claude` CLI.
77pub fn setup_claude() -> Result<()> {
78    let home = home_dir_or_fallback();
79    let data_local = data_local_or_fallback();
80    setup_claude_with_cli(&home, &data_local, &RealClaudeCli)
81}
82
83/// Test/composition entry point. Lets callers inject the home root, the
84/// data-local root, and a `ClaudeCli` impl independently.
85pub fn setup_claude_with_cli(home: &Path, data_local: &Path, cli: &dyn ClaudeCli) -> Result<()> {
86    legacy::remove_legacy_installs_under(home);
87
88    // Check whether Claude Code has our plugin registered in its own state
89    // file. This is the source of truth for CLI branching: if Claude Code has
90    // uninstalled the plugin (e.g. via `claude plugin uninstall`), the sentinel
91    // file still exists but the plugin is gone — we must re-run add+install,
92    // not update+update, to re-register it.
93    let claude_registered = claude_has_plugin_registered(home);
94
95    let any_changed = write_files(data_local)?;
96
97    let cli_outcome = if cli.is_available() {
98        if claude_registered {
99            // Refresh: pull marketplace source (no-op for local), then
100            // bump the cached plugin to the new version if plugin.json
101            // changed.
102            let _ = cli.marketplace_update(paths::MARKETPLACE_NAME);
103            let _ = cli.plugin_update(paths::PLUGIN_SLUG);
104            CliOutcome::UpdateUpdate
105        } else {
106            cli.marketplace_add(&paths::marketplace_root_under(data_local))
107                .map_err(|e| {
108                    CwError::Other(format!("`claude plugin marketplace add` failed: {e}"))
109                })?;
110            cli.plugin_install(paths::PLUGIN_SLUG)
111                .map_err(|e| CwError::Other(format!("`claude plugin install` failed: {e}")))?;
112            CliOutcome::AddInstall
113        }
114    } else {
115        eprintln!(
116            "{} `claude` CLI not found on PATH. Files were written but the plugin",
117            style("!").yellow()
118        );
119        eprintln!("  is not registered with Claude Code. Install Claude Code, then run:");
120        eprintln!(
121            "    claude plugin marketplace add {}",
122            paths::marketplace_root_under(data_local).display()
123        );
124        eprintln!("    claude plugin install {}", paths::PLUGIN_SLUG);
125        CliOutcome::NotRun
126    };
127
128    print_outcome(data_local, any_changed, cli_outcome);
129    Ok(())
130}
131
132/// Returns true iff Claude Code's `installed_plugins.json` contains a
133/// non-empty entry for our plugin slug (`gw@gw-local`).
134///
135/// Treats any I/O or parse error as "not registered" so we fall back safely
136/// to a fresh add+install rather than silently doing nothing.
137fn claude_has_plugin_registered(home: &Path) -> bool {
138    let path = paths::installed_plugins_json_under(home);
139    let Ok(text) = std::fs::read_to_string(&path) else {
140        return false;
141    };
142    let Ok(json): std::result::Result<serde_json::Value, _> = serde_json::from_str(&text) else {
143        return false;
144    };
145    json.get("plugins")
146        .and_then(|p| p.get(paths::PLUGIN_SLUG))
147        .and_then(|v| v.as_array())
148        .map(|arr| !arr.is_empty())
149        .unwrap_or(false)
150}
151
152fn write_files(data_local: &Path) -> Result<bool> {
153    let mut any_changed = false;
154    any_changed |= writer::write_if_changed(
155        &paths::marketplace_manifest_under(data_local),
156        manifest::marketplace_json(),
157    )?;
158    any_changed |= writer::write_if_changed(
159        &paths::plugin_manifest_under(data_local),
160        &manifest::plugin_json(),
161    )?;
162    any_changed |=
163        writer::write_if_changed(&paths::command_gw_under(data_local), command_gw::content())?;
164    any_changed |= writer::write_if_changed(
165        &paths::skill_delegate_under(data_local),
166        skill_delegate::content(),
167    )?;
168    any_changed |= writer::write_if_changed(
169        &paths::skill_manage_under(data_local),
170        skill_manage::content(),
171    )?;
172    any_changed |= writer::write_if_changed(
173        &paths::skill_manage_reference_under(data_local),
174        skill_manage::reference_content(),
175    )?;
176    let sentinel = paths::sentinel_under(data_local);
177    if !writer::sentinel_present(&sentinel) {
178        writer::write_sentinel(&sentinel)?;
179        any_changed = true;
180    }
181    Ok(any_changed)
182}
183
184fn print_outcome(data_local: &Path, any_changed: bool, cli_outcome: CliOutcome) {
185    let location = paths::marketplace_root_under(data_local);
186    if !any_changed {
187        match cli_outcome {
188            CliOutcome::AddInstall => {
189                println!(
190                    "{} gw plugin re-registered with Claude Code (files unchanged).",
191                    style("*").green()
192                );
193                println!("  Location: {}", style(location.display()).dim());
194            }
195            CliOutcome::NotRun | CliOutcome::UpdateUpdate => {
196                println!("{} gw plugin already up to date.", style("*").green());
197                println!("  Location: {}", style(location.display()).dim());
198            }
199        }
200        return;
201    }
202
203    let verb = match cli_outcome {
204        CliOutcome::UpdateUpdate => "refreshed",
205        CliOutcome::AddInstall | CliOutcome::NotRun => "installed",
206    };
207    println!(
208        "{} gw plugin {} at {}.",
209        style("*").green().bold(),
210        verb,
211        style(location.display()).dim()
212    );
213    println!(
214        "  Use {} in Claude Code to delegate tasks to worktrees.",
215        style("/gw").cyan()
216    );
217    println!(
218        "  The bundled '{}' skill recommends hooks (e.g. SessionStart sanity)",
219        style("manage").cyan()
220    );
221    println!("  in-session when relevant. It edits your project's .claude/settings.json");
222    println!("  on your consent — gw itself never modifies any settings file.");
223}
224
225fn data_local_or_fallback() -> PathBuf {
226    dirs::data_local_dir().unwrap_or_else(|| home_dir_or_fallback().join(".local").join("share"))
227}