Skip to main content

git_worktree_manager/operations/setup_claude/
mod.rs

1//! `gw setup-claude` — project-local one-click installer for Claude Code
2//! integration.
3//!
4//! Writes skill files into `<repo>/.claude/skills/{gw-delegate,gw-manage}/`
5//! and registers three Claude Code hooks (PreToolUse Bash guard,
6//! WorktreeCreate, WorktreeRemove) in `<repo>/.claude/settings.json`.
7//! Idempotent — re-running only writes files whose content changed.
8
9use std::path::Path;
10
11use console::style;
12
13use crate::error::{CwError, Result};
14use crate::git;
15use crate::operations::sync_claude;
16
17mod skill_delegate;
18mod skill_manage;
19
20#[doc(hidden)]
21pub fn manage_skill_content_for_test() -> &'static str {
22    skill_manage::content()
23}
24
25#[doc(hidden)]
26pub fn manage_reference_content_for_test() -> &'static str {
27    skill_manage::reference_content()
28}
29
30#[doc(hidden)]
31pub fn delegate_skill_content_for_test() -> &'static str {
32    skill_delegate::content()
33}
34
35/// Production entry point.
36pub fn setup_claude() -> Result<()> {
37    let repo_root = git::get_repo_root(None).map_err(|_| {
38        CwError::Other(
39            "setup-claude: not inside a git repository. Run from within a git repo.".to_string(),
40        )
41    })?;
42
43    let mut wrote_any = false;
44    wrote_any |= write_skill_files(&repo_root)?;
45    wrote_any |= sync_hooks(&repo_root)?;
46
47    print_outcome(&repo_root, wrote_any);
48    Ok(())
49}
50
51/// Returns true if any skill file was written/updated. A file is only
52/// rewritten when its on-disk content differs from the embedded content.
53fn write_skill_files(repo_root: &Path) -> Result<bool> {
54    let mut changed = false;
55    let triples = [
56        (
57            ".claude/skills/gw-delegate/SKILL.md",
58            skill_delegate::content(),
59        ),
60        (".claude/skills/gw-manage/SKILL.md", skill_manage::content()),
61        (
62            ".claude/skills/gw-manage/references/gw-commands.md",
63            skill_manage::reference_content(),
64        ),
65    ];
66    for (rel, content) in triples {
67        let path = repo_root.join(rel);
68        if let Some(parent) = path.parent() {
69            std::fs::create_dir_all(parent).map_err(|e| {
70                CwError::Other(format!(
71                    "setup-claude: failed to create {}: {}",
72                    parent.display(),
73                    e
74                ))
75            })?;
76        }
77        let current = std::fs::read_to_string(&path).ok();
78        if current.as_deref() != Some(content) {
79            std::fs::write(&path, content).map_err(|e| {
80                CwError::Other(format!(
81                    "setup-claude: failed to write {}: {}",
82                    path.display(),
83                    e
84                ))
85            })?;
86            changed = true;
87        }
88    }
89    Ok(changed)
90}
91
92/// Returns true if `.claude/settings.json` was updated (i.e. at least one
93/// hook was newly merged in).
94fn sync_hooks(repo_root: &Path) -> Result<bool> {
95    let claude_dir = repo_root.join(".claude");
96    let settings_path = claude_dir.join("settings.json");
97    std::fs::create_dir_all(&claude_dir).map_err(|e| {
98        CwError::Other(format!(
99            "setup-claude: failed to create {}: {}",
100            claude_dir.display(),
101            e
102        ))
103    })?;
104
105    let mut settings: serde_json::Value = if settings_path.exists() {
106        let raw = std::fs::read_to_string(&settings_path).map_err(|e| {
107            CwError::Other(format!(
108                "setup-claude: failed to read {}: {}",
109                settings_path.display(),
110                e
111            ))
112        })?;
113        serde_json::from_str(&raw).map_err(|e| {
114            CwError::Other(format!(
115                "setup-claude: malformed JSON in {}: {}. Fix the file manually before re-running.",
116                settings_path.display(),
117                e
118            ))
119        })?
120    } else {
121        serde_json::Value::Object(serde_json::Map::new())
122    };
123
124    let changed = sync_claude::merge_hooks_into(&mut settings)?;
125    if changed {
126        let mut out = serde_json::to_string_pretty(&settings).map_err(|e| {
127            CwError::Other(format!("setup-claude: failed to serialize settings: {}", e))
128        })?;
129        out.push('\n');
130        std::fs::write(&settings_path, &out).map_err(|e| {
131            CwError::Other(format!(
132                "setup-claude: failed to write {}: {}",
133                settings_path.display(),
134                e
135            ))
136        })?;
137    }
138    Ok(changed)
139}
140
141/// Returns true if all skill files exist under the repo root's
142/// `.claude/skills/gw-*/`.
143pub fn is_installed_in_repo(repo_root: &Path) -> bool {
144    repo_root
145        .join(".claude/skills/gw-delegate/SKILL.md")
146        .exists()
147        && repo_root.join(".claude/skills/gw-manage/SKILL.md").exists()
148        && repo_root
149            .join(".claude/skills/gw-manage/references/gw-commands.md")
150            .exists()
151}
152
153/// Back-compat alias for `gw doctor`. Resolves repo root and checks installation.
154pub fn is_installed() -> bool {
155    if let Ok(root) = git::get_repo_root(None) {
156        is_installed_in_repo(&root)
157    } else {
158        false
159    }
160}
161
162fn print_outcome(repo_root: &Path, wrote_any: bool) {
163    let location = repo_root.join(".claude");
164    if wrote_any {
165        println!(
166            "{} Claude Code integration installed at {}.",
167            style("*").green().bold(),
168            style(location.display()).dim()
169        );
170    } else {
171        println!(
172            "{} Claude Code integration already up to date.",
173            style("*").green()
174        );
175    }
176    println!(
177        "  Skills: {} {}",
178        style("gw-delegate").cyan(),
179        style("gw-manage").cyan()
180    );
181    println!("  Hooks: PreToolUse(Bash), WorktreeCreate, WorktreeRemove");
182    println!("  Re-run `gw setup-claude` after upgrading gw to refresh skills/hooks.");
183}