atomcode_core/setup/
mod.rs1pub 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
37pub fn run(opts: RunOptions) -> SetupResult<SetupReport> {
42 let started = std::time::Instant::now();
43
44 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 let signals = scan::scan(&opts.project_root);
60
61 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_skills_from_seeds(&cache_dir, &mut summary, opts.force);
71
72 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 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
104fn 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 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 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 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
186fn 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 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#[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}