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/// True if our marketplace tree exists at the canonical data-local path.
53pub fn is_installed() -> bool {
54    let dl = data_local_or_fallback();
55    paths::sentinel_under(&dl).exists()
56}
57
58/// Backward-compat alias used by `gw doctor`. Returns true if either the
59/// new install OR a legacy install (any of three layouts) is present.
60pub fn is_skill_installed() -> bool {
61    is_installed() || legacy::any_legacy_present()
62}
63
64/// Backward-compat alias kept for diagnostics.rs. Same meaning as
65/// `is_installed()`.
66pub fn is_plugin_installed() -> bool {
67    is_installed()
68}
69
70/// Production entry point: resolves real home + data-local dirs and uses
71/// the real `claude` CLI.
72pub fn setup_claude() -> Result<()> {
73    let home = home_dir_or_fallback();
74    let data_local = data_local_or_fallback();
75    setup_claude_with_cli(&home, &data_local, &RealClaudeCli)
76}
77
78/// Test/composition entry point. Lets callers inject the home root, the
79/// data-local root, and a `ClaudeCli` impl independently.
80pub fn setup_claude_with_cli(home: &Path, data_local: &Path, cli: &dyn ClaudeCli) -> Result<()> {
81    legacy::remove_legacy_installs_under(home);
82
83    // Check whether Claude Code has our plugin registered in its own state
84    // file. This is the source of truth for CLI branching: if Claude Code has
85    // uninstalled the plugin (e.g. via `claude plugin uninstall`), the sentinel
86    // file still exists but the plugin is gone — we must re-run add+install,
87    // not update+update, to re-register it.
88    let claude_registered = claude_has_plugin_registered(home);
89
90    let any_changed = write_files(data_local)?;
91
92    let cli_outcome = if cli.is_available() {
93        if claude_registered {
94            // Refresh: pull marketplace source (no-op for local), then
95            // bump the cached plugin to the new version if plugin.json
96            // changed.
97            let _ = cli.marketplace_update(paths::MARKETPLACE_NAME);
98            let _ = cli.plugin_update(paths::PLUGIN_SLUG);
99            CliOutcome::UpdateUpdate
100        } else {
101            cli.marketplace_add(&paths::marketplace_root_under(data_local))
102                .map_err(|e| {
103                    CwError::Other(format!("`claude plugin marketplace add` failed: {e}"))
104                })?;
105            cli.plugin_install(paths::PLUGIN_SLUG)
106                .map_err(|e| CwError::Other(format!("`claude plugin install` failed: {e}")))?;
107            CliOutcome::AddInstall
108        }
109    } else {
110        eprintln!(
111            "{} `claude` CLI not found on PATH. Files were written but the plugin",
112            style("!").yellow()
113        );
114        eprintln!("  is not registered with Claude Code. Install Claude Code, then run:");
115        eprintln!(
116            "    claude plugin marketplace add {}",
117            paths::marketplace_root_under(data_local).display()
118        );
119        eprintln!("    claude plugin install {}", paths::PLUGIN_SLUG);
120        CliOutcome::NotRun
121    };
122
123    print_outcome(data_local, any_changed, cli_outcome);
124    Ok(())
125}
126
127/// Returns true iff Claude Code's `installed_plugins.json` contains a
128/// non-empty entry for our plugin slug (`gw@gw-local`).
129///
130/// Treats any I/O or parse error as "not registered" so we fall back safely
131/// to a fresh add+install rather than silently doing nothing.
132fn claude_has_plugin_registered(home: &Path) -> bool {
133    let path = paths::installed_plugins_json_under(home);
134    let Ok(text) = std::fs::read_to_string(&path) else {
135        return false;
136    };
137    let Ok(json): std::result::Result<serde_json::Value, _> = serde_json::from_str(&text) else {
138        return false;
139    };
140    json.get("plugins")
141        .and_then(|p| p.get(paths::PLUGIN_SLUG))
142        .and_then(|v| v.as_array())
143        .map(|arr| !arr.is_empty())
144        .unwrap_or(false)
145}
146
147fn write_files(data_local: &Path) -> Result<bool> {
148    let mut any_changed = false;
149    any_changed |= writer::write_if_changed(
150        &paths::marketplace_manifest_under(data_local),
151        manifest::marketplace_json(),
152    )?;
153    any_changed |= writer::write_if_changed(
154        &paths::plugin_manifest_under(data_local),
155        &manifest::plugin_json(),
156    )?;
157    any_changed |=
158        writer::write_if_changed(&paths::command_gw_under(data_local), command_gw::content())?;
159    any_changed |= writer::write_if_changed(
160        &paths::skill_delegate_under(data_local),
161        skill_delegate::content(),
162    )?;
163    any_changed |= writer::write_if_changed(
164        &paths::skill_manage_under(data_local),
165        skill_manage::content(),
166    )?;
167    any_changed |= writer::write_if_changed(
168        &paths::skill_manage_reference_under(data_local),
169        skill_manage::reference_content(),
170    )?;
171    let sentinel = paths::sentinel_under(data_local);
172    if !writer::sentinel_present(&sentinel) {
173        writer::write_sentinel(&sentinel)?;
174        any_changed = true;
175    }
176    Ok(any_changed)
177}
178
179fn print_outcome(data_local: &Path, any_changed: bool, cli_outcome: CliOutcome) {
180    let location = paths::marketplace_root_under(data_local);
181    if !any_changed {
182        match cli_outcome {
183            CliOutcome::AddInstall => {
184                println!(
185                    "{} gw plugin re-registered with Claude Code (files unchanged).",
186                    style("*").green()
187                );
188                println!("  Location: {}", style(location.display()).dim());
189            }
190            CliOutcome::NotRun | CliOutcome::UpdateUpdate => {
191                println!("{} gw plugin already up to date.", style("*").green());
192                println!("  Location: {}", style(location.display()).dim());
193            }
194        }
195        return;
196    }
197
198    let verb = match cli_outcome {
199        CliOutcome::UpdateUpdate => "refreshed",
200        CliOutcome::AddInstall | CliOutcome::NotRun => "installed",
201    };
202    println!(
203        "{} gw plugin {} at {}.",
204        style("*").green().bold(),
205        verb,
206        style(location.display()).dim()
207    );
208    println!(
209        "  Use {} in Claude Code to delegate tasks to worktrees.",
210        style("/gw").cyan()
211    );
212    println!(
213        "  The bundled '{}' skill recommends hooks (e.g. SessionStart sanity)",
214        style("manage").cyan()
215    );
216    println!("  in-session when relevant. It edits your project's .claude/settings.json");
217    println!("  on your consent — gw itself never modifies any settings file.");
218}
219
220fn data_local_or_fallback() -> PathBuf {
221    dirs::data_local_dir().unwrap_or_else(|| home_dir_or_fallback().join(".local").join("share"))
222}