1use std::io::{self, IsTerminal};
8use std::path::{Path, PathBuf};
9
10use anyhow::{Context, Result};
11
12const 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
31const 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
91fn 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
101fn 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
109fn 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
117fn 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
142fn check(msg: &str) {
144 eprintln!(" {} {msg}", c(GREEN, "✓"));
145}
146
147pub fn run() -> Result<()> {
150 let root = std::env::current_dir().context("failed to determine current directory")?;
151
152 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#[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 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 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}