agent_trace/commands/
init.rs1use crate::config::{PollingConfig, StoreConfig, StoreInfo};
2use crate::git_store::GitStore;
3use crate::manifest::Manifest;
4use crate::observability::CliOutput;
5use crate::types::DocType;
6use anyhow::Result;
7use std::path::Path;
8
9pub fn run(path: &Path, scan: bool, output: &dyn CliOutput) -> Result<()> {
10 let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
11
12 let store_dir = path.join(".agent-trace");
14 if store_dir.join("config.toml").exists() {
15 output.line(&format!("Store already initialised at {}", path.display()))?;
16 output.line(&format!(
17 " Config: {}",
18 store_dir.join("config.toml").display()
19 ))?;
20 output.line(&format!(
21 " Manifest: {}",
22 store_dir.join("manifest.toml").display()
23 ))?;
24 return Ok(());
25 }
26
27 #[cfg(unix)]
29 {
30 use std::os::unix::fs::DirBuilderExt;
31 std::fs::DirBuilder::new()
32 .recursive(true)
33 .mode(0o700)
34 .create(&store_dir)?;
35 }
36 #[cfg(not(unix))]
37 {
38 std::fs::create_dir_all(&store_dir)?;
39 }
40
41 std::fs::create_dir_all(store_dir.join("locks"))?;
43
44 let context_updates = store_dir.join("context_updates.jsonl");
46 if !context_updates.exists() {
47 std::fs::write(&context_updates, "")?;
48 }
49 let summary_events = store_dir.join("summary_events.jsonl");
50 if !summary_events.exists() {
51 std::fs::write(&summary_events, "")?;
52 }
53 let cmd_history = store_dir.join("command_history.txt");
54 if !cmd_history.exists() {
55 std::fs::write(&cmd_history, "")?;
56 }
57
58 let store_name = path
60 .file_name()
61 .map(|n| n.to_string_lossy().to_string())
62 .unwrap_or_else(|| "agent-trace-store".to_string());
63 let store_info = StoreInfo::new(store_name);
64 let store_config = StoreConfig {
65 store: store_info.clone(),
66 llm: None,
67 synthesis: None,
68 polling: PollingConfig::default(),
69 };
70 store_config.save(&path)?;
71
72 let git = GitStore::init(&path)?;
74
75 let mut manifest = Manifest::create_empty(store_info, &path)?;
77
78 let gitignore = path.join(".gitignore");
80 if !gitignore.exists() {
81 std::fs::write(&gitignore, DEFAULT_GITIGNORE)?;
82 }
83
84 let agent_trace_content = crate::agent_trace_md::generate(&path, &manifest);
86 std::fs::write(path.join("AGENT-TRACE.md"), &agent_trace_content)?;
87 {
88 let agent_trace_info = crate::git_store::CommitInfo {
89 action: crate::types::Action::Create,
90 files: vec![
91 (
92 std::path::PathBuf::from("AGENT-TRACE.md"),
93 crate::types::Action::Create,
94 crate::types::DocType::Reference,
95 ),
96 (
97 std::path::PathBuf::from(".gitignore"),
98 crate::types::Action::Create,
99 DocType::Reference,
100 ),
101 ],
102 actor: crate::types::Actor::System,
103 summary: "init: create AGENT-TRACE.md and .gitignore".into(),
104 agent_name: None,
105 session_id: None,
106 };
107 git.commit(&agent_trace_info)?;
108 }
109
110 if scan {
112 let count = scan_and_register(&path, &mut manifest)?;
113 manifest.save(&path)?;
114 if count > 0 {
116 let agent_trace_content = crate::agent_trace_md::generate(&path, &manifest);
118 std::fs::write(path.join("AGENT-TRACE.md"), &agent_trace_content)?;
119
120 let mut files: Vec<_> = manifest
121 .documents()
122 .iter()
123 .map(|d| {
124 (
125 d.path.clone(),
126 crate::types::Action::Create,
127 d.doc_type.clone(),
128 )
129 })
130 .collect();
131 files.push((
132 std::path::PathBuf::from("AGENT-TRACE.md"),
133 crate::types::Action::Modify,
134 crate::types::DocType::Reference,
135 ));
136 let info = crate::git_store::CommitInfo {
137 action: crate::types::Action::Init,
138 files,
139 actor: crate::types::Actor::System,
140 summary: format!("scanned {count} existing markdown files"),
141 agent_name: None,
142 session_id: None,
143 };
144 git.commit(&info)?;
145 output.line(&format!(
146 "Registered {count} existing markdown files as scratch."
147 ))?;
148 }
149 }
150
151 output.line(&format!(
152 "Initialised agent-trace store at {}",
153 path.display()
154 ))?;
155 output.line("Store ready. Use `agent-trace mcp` or `agent-trace open` to start monitoring.")?;
156 Ok(())
157}
158
159fn scan_and_register(root: &Path, manifest: &mut Manifest) -> Result<usize> {
160 let mut count = 0;
161 for entry in walkdir_md(root) {
162 let rel = entry.strip_prefix(root).unwrap_or(&entry);
163 if rel.starts_with(".agent-trace") {
165 continue;
166 }
167 if rel == std::path::Path::new("AGENT-TRACE.md")
168 || rel == std::path::Path::new("context.md")
169 {
170 continue;
171 }
172 if manifest.is_tracked(rel) {
173 continue;
174 }
175 manifest.register(rel, DocType::Scratch, "")?;
176 count += 1;
177 }
178 Ok(count)
179}
180
181fn walkdir_md(root: &Path) -> Vec<std::path::PathBuf> {
182 let mut results = Vec::new();
183 walk(root, &mut results);
184 results
185}
186
187fn walk(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
188 let Ok(entries) = std::fs::read_dir(dir) else {
189 return;
190 };
191 for entry in entries.flatten() {
192 let path = entry.path();
193 if path.is_dir() {
194 if path
196 .file_name()
197 .map(|n| n.to_string_lossy().starts_with('.'))
198 .unwrap_or(false)
199 {
200 continue;
201 }
202 walk(&path, out);
203 } else if path.extension().and_then(|e| e.to_str()) == Some("md") {
204 out.push(path);
205 }
206 }
207}
208
209const DEFAULT_GITIGNORE: &str = r#"# agent-trace defaults
210.DS_Store
211*.tmp
212*.swp
213*.swo
214~*
215.venv/
216venv/
217node_modules/
218__pycache__/
219*.pyc
220"#;
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225 use crate::observability::NoopOutput;
226 use tempfile::TempDir;
227
228 #[test]
229 fn test_init_empty_directory() {
230 let tmp = TempDir::new().unwrap();
231 run(tmp.path(), false, &NoopOutput).unwrap();
232 assert!(tmp.path().join(".agent-trace").exists());
233 assert!(tmp.path().join(".agent-trace").join("config.toml").exists());
234 assert!(tmp
235 .path()
236 .join(".agent-trace")
237 .join("manifest.toml")
238 .exists());
239 assert!(tmp.path().join(".agent-trace").join("repo").exists());
240 assert!(tmp.path().join(".agent-trace").join("locks").exists());
241 assert!(tmp.path().join(".gitignore").exists());
242 }
243
244 #[test]
245 fn test_init_idempotent() {
246 let tmp = TempDir::new().unwrap();
247 run(tmp.path(), false, &NoopOutput).unwrap();
248 run(tmp.path(), false, &NoopOutput).unwrap();
250 }
251
252 #[test]
253 fn test_init_with_scan() {
254 let tmp = TempDir::new().unwrap();
255 std::fs::write(tmp.path().join("prd.md"), "# PRD").unwrap();
256 std::fs::write(tmp.path().join("notes.md"), "notes").unwrap();
257 run(tmp.path(), true, &NoopOutput).unwrap();
258 let manifest = crate::manifest::Manifest::load(tmp.path()).unwrap();
259 assert_eq!(manifest.len(), 2);
260 assert!(manifest
261 .documents()
262 .iter()
263 .all(|d| d.doc_type == DocType::Scratch));
264 }
265
266 #[test]
267 fn test_config_has_uuid() {
268 let tmp = TempDir::new().unwrap();
269 run(tmp.path(), false, &NoopOutput).unwrap();
270 let cfg = crate::config::StoreConfig::load(tmp.path()).unwrap();
271 assert!(cfg.store.id.0.parse::<uuid::Uuid>().is_ok());
272 assert!(!cfg.store.agent_trace_version.is_empty());
273 }
274
275 #[cfg(unix)]
276 #[test]
277 fn test_agent_trace_dir_permissions() {
278 use std::os::unix::fs::MetadataExt;
279 let tmp = TempDir::new().unwrap();
280 run(tmp.path(), false, &NoopOutput).unwrap();
281 let meta = std::fs::metadata(tmp.path().join(".agent-trace")).unwrap();
282 assert_eq!(meta.mode() & 0o777, 0o700);
284 }
285}