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#[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#[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
35pub fn run_setup(options: &SetupOptions) -> Result<SetupReport, SetupError> {
37 let home = home_dir()?;
38
39 verify_binary_on_path()?;
41
42 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 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 let mut skills_installed = Vec::new();
69 if !options.skip_skills {
70 skills_installed = install_skills(&home, options)?;
71 }
72
73 let mut hooks_installed = Vec::new();
75 if !options.skip_hooks {
76 hooks_installed = install_hooks(&home, options)?;
77 }
78
79 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
103fn 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
114pub 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 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
175fn 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
206fn 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
251fn 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
294pub fn apply_marker_content(existing: &str, snippet: &str) -> String {
299 if existing.contains(CLAUDE_MD_BEGIN) && existing.contains(CLAUDE_MD_END) {
300 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}