Skip to main content

life_cli/
init.rs

1//! `life init` — create a `.life/` directory in the current project.
2//!
3//! Initializes a project-local `.life/` configuration directory with sensible
4//! defaults.  Secrets are never written to config files — they live in the
5//! system keychain or `~/.life/credentials/.env` (managed by `life setup`).
6
7use std::io::{self, IsTerminal};
8use std::path::{Path, PathBuf};
9
10use anyhow::{Context, Result};
11
12// ── ANSI helpers ──────────────────────────────────────────────────────────
13
14const RESET: &str = "\x1b[0m";
15const GREEN: &str = "\x1b[32m";
16const DIM: &str = "\x1b[2m";
17const CYAN: &str = "\x1b[36m";
18
19fn use_color() -> bool {
20    io::stdout().is_terminal()
21}
22
23fn c(code: &str, text: &str) -> String {
24    if use_color() {
25        format!("{code}{text}{RESET}")
26    } else {
27        text.to_string()
28    }
29}
30
31// ── Default content ───────────────────────────────────────────────────────
32
33const DEFAULT_CONFIG_TOML: &str = r#"# Life Agent OS — project configuration
34# Secrets are stored in the system keychain (life setup).
35
36[provider]
37name = "anthropic"
38model = "claude-sonnet-4-5-20250929"
39
40[consciousness]
41enabled = true
42
43[arcan]
44port = 3000
45"#;
46
47const DEFAULT_POLICY_YAML: &str = r#"# Life Agent OS — control policy
48# Profiles define escalating levels of autonomy.
49# Gates are sequential quality checks.
50
51profiles:
52  baseline:
53    description: "Default profile — manual approval required"
54    auto_approve: false
55  governed:
56    description: "CI-governed — auto-approve if gates pass"
57    auto_approve: true
58    require_gates: [smoke, check]
59  autonomous:
60    description: "Full autonomy — all gates must pass"
61    auto_approve: true
62    require_gates: [smoke, check, test, audit]
63
64gates:
65  smoke:
66    description: "Quick format/syntax/build check"
67    command: "cargo fmt --check && cargo check"
68    timeout_secs: 30
69  check:
70    description: "Format + clippy + test"
71    command: "cargo fmt --check && cargo clippy --workspace && cargo test --workspace"
72    timeout_secs: 120
73  test:
74    description: "Full test suite"
75    command: "cargo test --workspace"
76    timeout_secs: 300
77  audit:
78    description: "Governance compliance audit"
79    command: "make audit"
80    timeout_secs: 60
81"#;
82
83const GITIGNORE_PATTERNS: &[&str] = &[
84    "# Life Agent OS",
85    ".life/*",
86    "!.life/config.toml",
87    "!.life/control/",
88    ".life/credentials/",
89];
90
91// ── Core logic ────────────────────────────────────────────────────────────
92
93/// Create the `.life/` directory tree in `root`.
94fn create_life_dir(root: &Path) -> Result<PathBuf> {
95    let life_dir = root.join(".life");
96    std::fs::create_dir_all(&life_dir).context("failed to create .life/ directory")?;
97    std::fs::create_dir_all(life_dir.join("control")).context("failed to create .life/control/")?;
98    Ok(life_dir)
99}
100
101/// Write `.life/config.toml` with default provider settings.
102fn write_config(life_dir: &Path) -> Result<()> {
103    let path = life_dir.join("config.toml");
104    std::fs::write(&path, DEFAULT_CONFIG_TOML)
105        .with_context(|| format!("failed to write {}", path.display()))?;
106    Ok(())
107}
108
109/// Write `.life/control/policy.yaml` with default governance rules.
110fn write_policy(life_dir: &Path) -> Result<()> {
111    let path = life_dir.join("control").join("policy.yaml");
112    std::fs::write(&path, DEFAULT_POLICY_YAML)
113        .with_context(|| format!("failed to write {}", path.display()))?;
114    Ok(())
115}
116
117/// Append `.life/` gitignore patterns to the project `.gitignore`.
118/// Idempotent — skips if the sentinel pattern is already present.
119fn update_gitignore(root: &Path) -> Result<()> {
120    let gitignore_path = root.join(".gitignore");
121    let existing = std::fs::read_to_string(&gitignore_path).unwrap_or_default();
122
123    if existing.contains(".life/*") {
124        return Ok(());
125    }
126
127    let mut content = existing;
128    if !content.is_empty() && !content.ends_with('\n') {
129        content.push('\n');
130    }
131    content.push('\n');
132    for pattern in GITIGNORE_PATTERNS {
133        content.push_str(pattern);
134        content.push('\n');
135    }
136
137    std::fs::write(&gitignore_path, &content)
138        .with_context(|| format!("failed to write {}", gitignore_path.display()))?;
139    Ok(())
140}
141
142/// Print a colored check line to stderr.
143fn check(msg: &str) {
144    eprintln!("  {} {msg}", c(GREEN, "✓"));
145}
146
147// ── Public entry point ────────────────────────────────────────────────────
148
149pub fn run() -> Result<()> {
150    let root = std::env::current_dir().context("failed to determine current directory")?;
151
152    // Guard: already initialized
153    if root.join(".life").join("config.toml").is_file() {
154        eprintln!(
155            "  {} .life/ already initialized in {}",
156            c(CYAN, "●"),
157            c(DIM, &root.display().to_string()),
158        );
159        eprintln!(
160            "  {} Run {} to reconfigure.",
161            c(DIM, "→"),
162            c(CYAN, "life setup"),
163        );
164        return Ok(());
165    }
166
167    eprintln!();
168    eprintln!(
169        "  Initializing .life/ in {}",
170        c(DIM, &root.display().to_string())
171    );
172    eprintln!();
173
174    let life_dir = create_life_dir(&root)?;
175    check("Created .life/ directory");
176
177    write_config(&life_dir)?;
178    check("Wrote .life/config.toml");
179
180    write_policy(&life_dir)?;
181    check("Wrote .life/control/policy.yaml");
182
183    update_gitignore(&root)?;
184    check("Updated .gitignore");
185
186    eprintln!();
187    eprintln!("  {} Project initialized.", c(GREEN, "✓"));
188    eprintln!(
189        "  Run {} to configure providers & credentials.",
190        c(CYAN, "life setup")
191    );
192    eprintln!();
193
194    Ok(())
195}
196
197// ── Tests ─────────────────────────────────────────────────────────────────
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn init_creates_config() {
205        let tmp = tempfile::TempDir::new().unwrap();
206        let root = tmp.path();
207
208        let life_dir = create_life_dir(root).unwrap();
209        write_config(&life_dir).unwrap();
210        write_policy(&life_dir).unwrap();
211
212        let config = std::fs::read_to_string(root.join(".life/config.toml")).unwrap();
213        assert!(config.contains("[provider]"));
214        assert!(config.contains("name = \"anthropic\""));
215        assert!(config.contains("[consciousness]"));
216        assert!(config.contains("enabled = true"));
217        assert!(config.contains("[arcan]"));
218        assert!(config.contains("port = 3000"));
219        // Must NOT contain api_key
220        assert!(!config.contains("api_key"));
221
222        let policy = std::fs::read_to_string(root.join(".life/control/policy.yaml")).unwrap();
223        assert!(policy.contains("profiles:"));
224        assert!(policy.contains("baseline:"));
225        assert!(policy.contains("governed:"));
226        assert!(policy.contains("autonomous:"));
227        assert!(policy.contains("gates:"));
228        assert!(policy.contains("smoke:"));
229    }
230
231    #[test]
232    fn update_gitignore_adds_patterns() {
233        let tmp = tempfile::TempDir::new().unwrap();
234        let root = tmp.path();
235
236        // Start with an existing .gitignore
237        std::fs::write(root.join(".gitignore"), "target/\n").unwrap();
238        update_gitignore(root).unwrap();
239
240        let content = std::fs::read_to_string(root.join(".gitignore")).unwrap();
241        assert!(content.contains("target/"));
242        assert!(content.contains(".life/*"));
243        assert!(content.contains("!.life/config.toml"));
244        assert!(content.contains("!.life/control/"));
245        assert!(content.contains(".life/credentials/"));
246    }
247
248    #[test]
249    fn update_gitignore_idempotent() {
250        let tmp = tempfile::TempDir::new().unwrap();
251        let root = tmp.path();
252
253        std::fs::write(root.join(".gitignore"), "").unwrap();
254        update_gitignore(root).unwrap();
255        let first = std::fs::read_to_string(root.join(".gitignore")).unwrap();
256        let first_count = first.matches(".life/*").count();
257
258        update_gitignore(root).unwrap();
259        let second = std::fs::read_to_string(root.join(".gitignore")).unwrap();
260        let second_count = second.matches(".life/*").count();
261
262        assert_eq!(first_count, 1);
263        assert_eq!(second_count, 1);
264    }
265}