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
52#[doc(hidden)]
53pub fn delegate_skill_content_for_test() -> &'static str {
54 skill_delegate::content()
55}
56
57pub fn is_installed() -> bool {
59 let dl = data_local_or_fallback();
60 paths::sentinel_under(&dl).exists()
61}
62
63pub fn is_skill_installed() -> bool {
66 is_installed() || legacy::any_legacy_present()
67}
68
69pub fn is_plugin_installed() -> bool {
72 is_installed()
73}
74
75pub 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
83pub fn setup_claude_with_cli(home: &Path, data_local: &Path, cli: &dyn ClaudeCli) -> Result<()> {
86 legacy::remove_legacy_installs_under(home);
87
88 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 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
132fn 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}