Skip to main content

agent_trace/commands/
init.rs

1use 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    // Check if already initialised.
13    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    // Create .agent-trace/ with restricted permissions.
28    #[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    // Create subdirectories.
42    std::fs::create_dir_all(store_dir.join("locks"))?;
43
44    // Create empty files.
45    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    // Generate store config.
59    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    // Initialise git store.
73    let git = GitStore::init(&path)?;
74
75    // Create empty manifest.
76    let mut manifest = Manifest::create_empty(store_info, &path)?;
77
78    // Write .gitignore at store root.
79    let gitignore = path.join(".gitignore");
80    if !gitignore.exists() {
81        std::fs::write(&gitignore, DEFAULT_GITIGNORE)?;
82    }
83
84    // Generate initial AGENT-TRACE.md and commit it.
85    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: register all .md files.
111    if scan {
112        let count = scan_and_register(&path, &mut manifest)?;
113        manifest.save(&path)?;
114        // Commit the scanned files (not AGENT-TRACE.md — already committed).
115        if count > 0 {
116            // Regenerate AGENT-TRACE.md now that the manifest has content.
117            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        // Skip .agent-trace directory and agent-trace-managed files.
164        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            // Skip hidden directories.
195            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        // Second init should not error.
249        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        // 0700 = rwx------
283        assert_eq!(meta.mode() & 0o777, 0o700);
284    }
285}