git_worktree_manager/operations/setup_claude/
mod.rs1use 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
32enum CliOutcome {
34 NotRun,
36 AddInstall,
38 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
52pub fn is_installed() -> bool {
54 let dl = data_local_or_fallback();
55 paths::sentinel_under(&dl).exists()
56}
57
58pub fn is_skill_installed() -> bool {
61 is_installed() || legacy::any_legacy_present()
62}
63
64pub fn is_plugin_installed() -> bool {
67 is_installed()
68}
69
70pub 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
78pub fn setup_claude_with_cli(home: &Path, data_local: &Path, cli: &dyn ClaudeCli) -> Result<()> {
81 legacy::remove_legacy_installs_under(home);
82
83 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 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
127fn 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}