Skip to main content

atomcode_core/setup/
mod.rs

1//! Setup wizard — install seed files (skills/commands/hooks/MCP) to `$ATOMCODE_HOME/`.
2//!
3//! Simplified pipeline: lock → scan → install all seeds → setup-state → report.
4
5pub mod error;
6pub mod fs_atomic;
7pub mod install;
8pub mod lock;
9pub mod scan;
10pub mod seeds;
11pub mod state;
12pub mod types;
13
14pub use error::{SetupError, SetupResult};
15pub use types::*;
16
17use std::path::PathBuf;
18
19#[derive(Debug, Clone)]
20pub struct RunOptions {
21    pub project_root: PathBuf,
22    pub force: bool,
23}
24
25impl RunOptions {
26    pub fn new(project_root: PathBuf) -> Self {
27        Self {
28            project_root,
29            force: false,
30        }
31    }
32}
33
34use crate::setup::install::{InstalledSummary, ReloadDirective};
35use crate::setup::seeds::ensure_seeds_extracted;
36
37/// Simplified pipeline: lock → scan → install all embedded seeds → setup-state → report.
38///
39/// This is a synchronous function — all operations (lock, scan, install, state write)
40/// are blocking. If called from an async context, use `tokio::task::spawn_blocking`.
41pub fn run(opts: RunOptions) -> SetupResult<SetupReport> {
42    let started = std::time::Instant::now();
43
44    // 1. Lock — RAII; released on function exit / panic.
45    let _lock = lock::SetupLock::acquire(&opts.project_root, opts.force).map_err(|e| match e {
46        lock::LockError::Held {
47            pid,
48            start_time,
49            host,
50        } => SetupError::LockHeld {
51            pid,
52            start_time,
53            host,
54        },
55        lock::LockError::Io(io) => SetupError::LockIo(io),
56    })?;
57
58    // 2. Scan project for signals_hash (used by setup-state).
59    let signals = scan::scan(&opts.project_root);
60
61    // 3. Install all embedded seeds (skills + commands + hooks + mcp).
62    let seeds_cache_root = crate::config::Config::config_dir();
63    let cache_dir = ensure_seeds_extracted(&seeds_cache_root).map_err(SetupError::Other)?;
64
65    let mut txn =
66        install::InstalledTxn::new(opts.project_root.clone()).map_err(SetupError::Io)?;
67    let mut summary = InstalledSummary::default();
68
69    // Install directory-style skills (e.g., atomcode-automation-recommender/).
70    install_directory_skills_from_seeds(&cache_dir, &mut summary, opts.force);
71
72    // Append .gitignore marker for .atomcode/local/.
73    if let Err(e) = txn.append_gitignore(&opts.project_root) {
74        tracing::warn!("failed to append .gitignore: {e}");
75    }
76
77    let _written = txn.commit();
78
79    // 4. Write setup-state.json.
80    let state_data = state::SetupState {
81        schema_version: state::CURRENT_SCHEMA_VERSION,
82        signals_hash: signals.signals_hash.clone(),
83        completed_at: chrono::Utc::now(),
84        atomcode_version: env!("CARGO_PKG_VERSION").to_string(),
85        accepted: summary
86            .installed
87            .iter()
88            .map(|(id, _)| state::RecIdRef {
89                kind: format!("{:?}", id.kind).to_lowercase(),
90                slug: id.slug.clone(),
91            })
92            .collect(),
93    };
94    if let Err(e) = state::save_setup_state(&opts.project_root, &state_data) {
95        tracing::warn!("failed to save setup-state.json: {e}");
96    }
97
98    Ok(SetupReport {
99        summary,
100        duration_ms: started.elapsed().as_millis() as u64,
101    })
102}
103
104/// Copy directory-style skills from seeds-cache to $ATOMCODE_HOME/skills/.
105/// E.g., `atomcode-automation-recommender/SKILL.md` + `references/`.
106///
107/// When `force` is true, skills are reinstalled even if the content hash matches
108/// (i.e., `--force` forces a clean reinstall, not just a lock bypass).
109fn install_directory_skills_from_seeds(
110    cache_dir: &std::path::Path,
111    summary: &mut InstalledSummary,
112    force: bool,
113) {
114    let seeds_skills = cache_dir.join("skills");
115    // Target path must match SkillRegistry::reload's scan path: a single
116    // unified config dir (Config::config_dir()) that resolves to
117    // ATOMCODE_HOME when set, else $HOME/.atomcode.
118    let target_skills = crate::config::Config::config_dir().join("skills");
119
120    let entries = match std::fs::read_dir(&seeds_skills) {
121        Ok(e) => e,
122        Err(_) => return,
123    };
124
125    for entry in entries.flatten() {
126        let path = entry.path();
127        if path.is_dir() && path.join("SKILL.md").exists() {
128            let name = match path.file_name() {
129                Some(n) => n.to_string_lossy().to_string(),
130                None => continue,
131            };
132            let dest = target_skills.join(&name);
133            let src_hash = compute_dir_hash(&path);
134
135            if dest.exists() {
136                // Version check: compare content hash.
137                let installed_hash = read_seed_hash(&dest);
138                if !force && installed_hash.as_deref() == Some(src_hash.as_str()) {
139                    summary.skipped.push((
140                        RecId::new(RecKind::Skill, &name),
141                        install::SkipReason::AlreadyInstalled,
142                    ));
143                    continue;
144                }
145                // Hash differs or --force → remove old and reinstall.
146                if force {
147                    tracing::info!(skill = %name, "forced reinstall of seed skill");
148                } else {
149                    tracing::info!(skill = %name, "seed skill updated — reinstalling");
150                }
151                let _ = std::fs::remove_dir_all(&dest);
152            }
153
154            match copy_dir_recursive(&path, &dest) {
155                Ok(()) => {
156                    write_seed_hash(&dest, &src_hash);
157                    summary.installed.push((RecId::new(RecKind::Skill, &name), dest));
158                    summary.reload_directives.insert(ReloadDirective::Skill);
159                }
160                Err(e) => {
161                    tracing::warn!("failed to install directory skill {name}: {e}");
162                    summary.failed.push((RecId::new(RecKind::Skill, &name), e.to_string()));
163                }
164            }
165        }
166    }
167}
168
169fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> {
170    std::fs::create_dir_all(dst)?;
171    for entry in std::fs::read_dir(src)? {
172        let entry = entry?;
173        let ty = entry.file_type()?;
174        let dest_path = dst.join(entry.file_name());
175        if ty.is_dir() {
176            copy_dir_recursive(&entry.path(), &dest_path)?;
177        } else {
178            std::fs::copy(entry.path(), &dest_path)?;
179        }
180    }
181    Ok(())
182}
183
184const SEED_HASH_FILE: &str = ".seed-hash";
185
186/// Compute a content hash of all files in a directory (recursive, sorted).
187fn compute_dir_hash(dir: &std::path::Path) -> String {
188    use sha2::{Digest, Sha256};
189    let mut h = Sha256::new();
190    let mut paths = Vec::new();
191    collect_file_paths(dir, &mut paths);
192    paths.sort();
193    for p in &paths {
194        if let Ok(content) = std::fs::read(p) {
195            h.update(p.strip_prefix(dir).unwrap_or(p).to_string_lossy().as_bytes());
196            h.update(b"\0");
197            h.update(&content);
198            h.update(b"\0");
199        }
200    }
201    format!("{:x}", h.finalize())
202}
203
204fn collect_file_paths(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
205    if let Ok(entries) = std::fs::read_dir(dir) {
206        for entry in entries.flatten() {
207            let path = entry.path();
208            if path.is_dir() {
209                // Skip .seed-hash from hash computation to avoid self-reference.
210                collect_file_paths(&path, out);
211            } else if path.file_name().and_then(|n| n.to_str()) != Some(SEED_HASH_FILE) {
212                out.push(path);
213            }
214        }
215    }
216}
217
218fn read_seed_hash(dir: &std::path::Path) -> Option<String> {
219    std::fs::read_to_string(dir.join(SEED_HASH_FILE)).ok()
220}
221
222fn write_seed_hash(dir: &std::path::Path, hash: &str) {
223    let _ = std::fs::write(dir.join(SEED_HASH_FILE), hash);
224}
225
226// ── SetupReport ────────────────────────────────────────────────────
227
228#[derive(Debug)]
229pub struct SetupReport {
230    pub summary: InstalledSummary,
231    pub duration_ms: u64,
232}
233
234impl SetupReport {
235    pub fn render_cli(&self) -> String {
236        use crate::i18n::{t, Msg};
237
238        let kind_str = |k: &RecKind| format!("{:?}", k).to_lowercase();
239
240        let mut out = String::new();
241        out.push_str(&t(Msg::SetupHeader {
242            installed: self.summary.installed.len(),
243            skipped: self.summary.skipped.len(),
244            failed: self.summary.failed.len(),
245            duration_ms: self.duration_ms,
246        }));
247
248        if !self.summary.installed.is_empty() {
249            out.push_str(&t(Msg::SetupInstalledLabel));
250            for (id, path) in &self.summary.installed {
251                out.push_str(&t(Msg::SetupInstalledRow {
252                    kind: &kind_str(&id.kind),
253                    slug: &id.slug,
254                    path: &path.display().to_string(),
255                }));
256            }
257        }
258        if !self.summary.skipped.is_empty() {
259            out.push_str(&t(Msg::SetupSkippedLabel));
260            for (id, reason) in &self.summary.skipped {
261                out.push_str(&t(Msg::SetupSkippedRow {
262                    kind: &kind_str(&id.kind),
263                    slug: &id.slug,
264                    reason: &format!("{:?}", reason),
265                }));
266            }
267        }
268        if !self.summary.failed.is_empty() {
269            out.push_str(&t(Msg::SetupFailedLabel));
270            for (id, err) in &self.summary.failed {
271                out.push_str(&t(Msg::SetupFailedRow {
272                    kind: &kind_str(&id.kind),
273                    slug: &id.slug,
274                    error: err,
275                }));
276            }
277        }
278        out
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn render_includes_installed_count() {
288        let _g = crate::i18n::test_lock();
289        crate::i18n::set_locale(crate::locale::Locale::ZhCn);
290
291        let mut sum = InstalledSummary::default();
292        sum.installed
293            .push((RecId::new(RecKind::Skill, "x"), PathBuf::from("/p/x.md")));
294        let report = SetupReport {
295            summary: sum,
296            duration_ms: 123,
297        };
298        let rendered = report.render_cli();
299        assert!(rendered.contains("1"));
300        assert!(rendered.contains("/p/x.md"));
301        assert!(rendered.contains("123ms"));
302    }
303
304}