Skip to main content

chronicle/setup/
mod.rs

1pub mod embedded;
2
3use std::path::{Path, PathBuf};
4
5use crate::config::user_config::{ProviderConfig, ProviderType, UserConfig};
6use crate::error::setup_error::{
7    BinaryNotFoundSnafu, InteractiveInputSnafu, NoHomeDirectorySnafu, ReadFileSnafu, WriteFileSnafu,
8};
9use crate::error::SetupError;
10use snafu::ResultExt;
11
12const CLAUDE_MD_BEGIN: &str = "<!-- chronicle-setup-begin -->";
13const CLAUDE_MD_END: &str = "<!-- chronicle-setup-end -->";
14
15/// Options for the setup command.
16#[derive(Debug)]
17pub struct SetupOptions {
18    pub force: bool,
19    pub dry_run: bool,
20    pub skip_skills: bool,
21    pub skip_hooks: bool,
22    pub skip_claude_md: bool,
23}
24
25/// Report of what setup did.
26#[derive(Debug)]
27pub struct SetupReport {
28    pub provider_type: ProviderType,
29    pub config_path: PathBuf,
30    pub skills_installed: Vec<PathBuf>,
31    pub hooks_installed: Vec<PathBuf>,
32    pub claude_md_updated: bool,
33}
34
35/// Run the full setup process.
36pub fn run_setup(options: &SetupOptions) -> Result<SetupReport, SetupError> {
37    let home = home_dir()?;
38
39    // 1. Verify binary on PATH
40    verify_binary_on_path()?;
41
42    // 2. Prompt for provider selection
43    let provider_config = if options.dry_run {
44        eprintln!("[dry-run] Would prompt for provider selection");
45        ProviderConfig {
46            provider_type: ProviderType::ClaudeCode,
47            model: None,
48            api_key_env: None,
49        }
50    } else {
51        prompt_provider_selection()?
52    };
53
54    let provider_type = provider_config.provider_type.clone();
55
56    // 3. Write user config
57    let config_path = UserConfig::path()?;
58    let user_config = UserConfig {
59        provider: provider_config,
60    };
61    if options.dry_run {
62        eprintln!("[dry-run] Would write {}", config_path.display());
63    } else {
64        user_config.save()?;
65    }
66
67    // 4. Install skills
68    let mut skills_installed = Vec::new();
69    if !options.skip_skills {
70        skills_installed = install_skills(&home, options)?;
71    }
72
73    // 5. Install hooks
74    let mut hooks_installed = Vec::new();
75    if !options.skip_hooks {
76        hooks_installed = install_hooks(&home, options)?;
77    }
78
79    // 6. Update CLAUDE.md
80    let claude_md_updated = if !options.skip_claude_md {
81        update_claude_md(&home, options)?
82    } else {
83        false
84    };
85
86    Ok(SetupReport {
87        provider_type,
88        config_path,
89        skills_installed,
90        hooks_installed,
91        claude_md_updated,
92    })
93}
94
95fn home_dir() -> Result<PathBuf, SetupError> {
96    std::env::var("HOME")
97        .ok()
98        .map(PathBuf::from)
99        .filter(|p| p.is_absolute())
100        .ok_or_else(|| NoHomeDirectorySnafu.build())
101}
102
103/// Verify that git-chronicle is accessible on PATH.
104fn verify_binary_on_path() -> Result<(), SetupError> {
105    match std::process::Command::new("git-chronicle")
106        .arg("--version")
107        .output()
108    {
109        Ok(output) if output.status.success() => Ok(()),
110        _ => BinaryNotFoundSnafu.fail(),
111    }
112}
113
114/// Interactive provider selection prompt.
115pub fn prompt_provider_selection() -> Result<ProviderConfig, SetupError> {
116    eprintln!();
117    eprintln!("Select LLM provider for batch annotation:");
118    eprintln!("  [1] Claude Code (recommended) — uses existing Claude Code auth");
119    eprintln!("  [2] Anthropic API key — uses ANTHROPIC_API_KEY env var");
120    eprintln!("  [3] None — skip for now, live path still works");
121    eprintln!();
122    eprint!("Choice [1]: ");
123
124    let mut input = String::new();
125    std::io::stdin()
126        .read_line(&mut input)
127        .context(InteractiveInputSnafu)?;
128    let choice = input.trim();
129
130    match choice {
131        "" | "1" => {
132            // Validate claude CLI exists
133            let claude_ok = std::process::Command::new("claude")
134                .arg("--version")
135                .output()
136                .map(|o| o.status.success())
137                .unwrap_or(false);
138
139            if !claude_ok {
140                eprintln!("warning: `claude` CLI not found on PATH. Install Claude Code to use this provider.");
141            }
142
143            Ok(ProviderConfig {
144                provider_type: ProviderType::ClaudeCode,
145                model: None,
146                api_key_env: None,
147            })
148        }
149        "2" => {
150            if std::env::var("ANTHROPIC_API_KEY").is_err() {
151                eprintln!("warning: ANTHROPIC_API_KEY is not currently set.");
152            }
153            Ok(ProviderConfig {
154                provider_type: ProviderType::Anthropic,
155                model: None,
156                api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
157            })
158        }
159        "3" => Ok(ProviderConfig {
160            provider_type: ProviderType::None,
161            model: None,
162            api_key_env: None,
163        }),
164        _ => {
165            eprintln!("Invalid choice, defaulting to Claude Code");
166            Ok(ProviderConfig {
167                provider_type: ProviderType::ClaudeCode,
168                model: None,
169                api_key_env: None,
170            })
171        }
172    }
173}
174
175/// Install skill files to ~/.claude/skills/chronicle/.
176fn install_skills(home: &Path, options: &SetupOptions) -> Result<Vec<PathBuf>, SetupError> {
177    let skills = [
178        ("context/SKILL.md", embedded::SKILL_CONTEXT),
179        ("annotate/SKILL.md", embedded::SKILL_ANNOTATE),
180        ("backfill/SKILL.md", embedded::SKILL_BACKFILL),
181    ];
182
183    let base = home.join(".claude").join("skills").join("chronicle");
184    let mut installed = Vec::new();
185
186    for (rel_path, content) in &skills {
187        let full_path = base.join(rel_path);
188        if options.dry_run {
189            eprintln!("[dry-run] Would create {}", full_path.display());
190        } else {
191            if let Some(parent) = full_path.parent() {
192                std::fs::create_dir_all(parent).context(WriteFileSnafu {
193                    path: parent.display().to_string(),
194                })?;
195            }
196            std::fs::write(&full_path, content).context(WriteFileSnafu {
197                path: full_path.display().to_string(),
198            })?;
199        }
200        installed.push(full_path);
201    }
202
203    Ok(installed)
204}
205
206/// Install hook files to ~/.claude/hooks/.
207fn install_hooks(home: &Path, options: &SetupOptions) -> Result<Vec<PathBuf>, SetupError> {
208    let hooks = [
209        (
210            "post-tool-use/chronicle-annotate-reminder.sh",
211            embedded::HOOK_ANNOTATE_REMINDER,
212        ),
213        (
214            "pre-tool-use/chronicle-read-context-hint.sh",
215            embedded::HOOK_READ_CONTEXT_HINT,
216        ),
217    ];
218
219    let base = home.join(".claude").join("hooks");
220    let mut installed = Vec::new();
221
222    for (rel_path, content) in &hooks {
223        let full_path = base.join(rel_path);
224        if options.dry_run {
225            eprintln!("[dry-run] Would create {}", full_path.display());
226        } else {
227            if let Some(parent) = full_path.parent() {
228                std::fs::create_dir_all(parent).context(WriteFileSnafu {
229                    path: parent.display().to_string(),
230                })?;
231            }
232            std::fs::write(&full_path, content).context(WriteFileSnafu {
233                path: full_path.display().to_string(),
234            })?;
235
236            #[cfg(unix)]
237            {
238                use std::os::unix::fs::PermissionsExt;
239                let perms = std::fs::Permissions::from_mode(0o755);
240                std::fs::set_permissions(&full_path, perms).context(WriteFileSnafu {
241                    path: full_path.display().to_string(),
242                })?;
243            }
244        }
245        installed.push(full_path);
246    }
247
248    Ok(installed)
249}
250
251/// Update ~/.claude/CLAUDE.md with marker-delimited Chronicle section.
252fn update_claude_md(home: &Path, options: &SetupOptions) -> Result<bool, SetupError> {
253    let claude_md_path = home.join(".claude").join("CLAUDE.md");
254
255    if options.dry_run {
256        if claude_md_path.exists() {
257            eprintln!(
258                "[dry-run] Would update {} (add/replace Chronicle section)",
259                claude_md_path.display()
260            );
261        } else {
262            eprintln!(
263                "[dry-run] Would create {} with Chronicle section",
264                claude_md_path.display()
265            );
266        }
267        return Ok(true);
268    }
269
270    if let Some(parent) = claude_md_path.parent() {
271        std::fs::create_dir_all(parent).context(WriteFileSnafu {
272            path: parent.display().to_string(),
273        })?;
274    }
275
276    let existing = if claude_md_path.exists() {
277        std::fs::read_to_string(&claude_md_path).context(ReadFileSnafu {
278            path: claude_md_path.display().to_string(),
279        })?
280    } else {
281        String::new()
282    };
283
284    let snippet = embedded::CLAUDE_MD_SNIPPET;
285    let new_content = apply_marker_content(&existing, snippet);
286
287    std::fs::write(&claude_md_path, &new_content).context(WriteFileSnafu {
288        path: claude_md_path.display().to_string(),
289    })?;
290
291    Ok(true)
292}
293
294/// Apply marker-delimited content to a string.
295/// - If markers exist, replace content between them.
296/// - If no markers, append the content.
297/// - If the string is empty, just use the content.
298pub fn apply_marker_content(existing: &str, snippet: &str) -> String {
299    if existing.contains(CLAUDE_MD_BEGIN) && existing.contains(CLAUDE_MD_END) {
300        // Replace content between markers (inclusive)
301        let mut result = String::new();
302        let mut in_section = false;
303        let mut replaced = false;
304        for line in existing.lines() {
305            if line.contains(CLAUDE_MD_BEGIN) {
306                in_section = true;
307                if !replaced {
308                    result.push_str(snippet);
309                    result.push('\n');
310                    replaced = true;
311                }
312                continue;
313            }
314            if line.contains(CLAUDE_MD_END) {
315                in_section = false;
316                continue;
317            }
318            if !in_section {
319                result.push_str(line);
320                result.push('\n');
321            }
322        }
323        result
324    } else if existing.is_empty() {
325        format!("{snippet}\n")
326    } else {
327        let mut content = existing.to_string();
328        if !content.ends_with('\n') {
329            content.push('\n');
330        }
331        content.push('\n');
332        content.push_str(snippet);
333        content.push('\n');
334        content
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    #[test]
343    fn test_apply_marker_empty_file() {
344        let result = apply_marker_content(
345            "",
346            "<!-- chronicle-setup-begin -->\nHello\n<!-- chronicle-setup-end -->",
347        );
348        assert!(result.contains("<!-- chronicle-setup-begin -->"));
349        assert!(result.contains("Hello"));
350        assert!(result.contains("<!-- chronicle-setup-end -->"));
351    }
352
353    #[test]
354    fn test_apply_marker_no_markers() {
355        let existing = "# My Project\n\nSome content.\n";
356        let snippet =
357            "<!-- chronicle-setup-begin -->\nChronicle section\n<!-- chronicle-setup-end -->";
358        let result = apply_marker_content(existing, snippet);
359        assert!(result.starts_with("# My Project"));
360        assert!(result.contains("Chronicle section"));
361        assert!(result.contains("<!-- chronicle-setup-begin -->"));
362    }
363
364    #[test]
365    fn test_apply_marker_existing_markers() {
366        let existing = "# My Project\n\n<!-- chronicle-setup-begin -->\nOld content\n<!-- chronicle-setup-end -->\n\nOther stuff\n";
367        let snippet = "<!-- chronicle-setup-begin -->\nNew content\n<!-- chronicle-setup-end -->";
368        let result = apply_marker_content(existing, snippet);
369        assert!(result.contains("New content"));
370        assert!(!result.contains("Old content"));
371        assert!(result.contains("Other stuff"));
372    }
373
374    #[test]
375    fn test_apply_marker_idempotent() {
376        let snippet =
377            "<!-- chronicle-setup-begin -->\nChronicle section\n<!-- chronicle-setup-end -->";
378        let first = apply_marker_content("", snippet);
379        let second = apply_marker_content(&first, snippet);
380        assert_eq!(first, second);
381    }
382}