1use anyhow::Result;
5use std::fmt::Write;
6use std::path::{Path, PathBuf};
7use std::time::{SystemTime, UNIX_EPOCH};
8
9const CONFIG_TOML: &str = r#"[kaizen]
10
11# Optional sync (usually override secrets in ~/.kaizen/config.toml):
12# [sync]
13# endpoint = "https://ingest.example.com"
14# team_token = "Bearer-token-from-server"
15# team_id = "your-team"
16# events_per_batch_max = 500
17# max_body_bytes = 1000000
18# flush_interval_ms = 10000
19# sample_rate = 1.0
20"#;
21const KAIZEN_RETRO_SKILL: &str = include_str!("../../assets/kaizen-retro-SKILL.md");
22
23const CURSOR_HOOK_EVENTS: &[&str] = &["SessionStart", "PreToolUse", "PostToolUse", "Stop"];
24const CLAUDE_HOOK_EVENTS: &[&str] = &["SessionStart", "PreToolUse", "PostToolUse", "Stop"];
25
26fn ts_ms() -> u64 {
27 SystemTime::now()
28 .duration_since(UNIX_EPOCH)
29 .unwrap_or_default()
30 .as_millis() as u64
31}
32
33fn backup_path(ws: &Path, filename: &str) -> PathBuf {
34 ws.join(format!(".kaizen/backup/{}.{}.bak", filename, ts_ms()))
35}
36
37fn ensure_config(out: &mut String, ws: &Path) -> Result<()> {
38 let path = ws.join(".kaizen/config.toml");
39 if path.exists() {
40 writeln!(out, " skipped .kaizen/config.toml").unwrap();
41 return Ok(());
42 }
43 std::fs::create_dir_all(ws.join(".kaizen"))?;
44 std::fs::write(&path, CONFIG_TOML)?;
45 writeln!(out, " created .kaizen/config.toml").unwrap();
46 Ok(())
47}
48
49pub const KAIZEN_CURSOR_HOOK_CMD: &str = "kaizen ingest hook --source cursor";
51pub const KAIZEN_CLAUDE_HOOK_CMD: &str = "kaizen ingest hook --source claude";
53
54fn cursor_hooks_done(root: &serde_json::Value) -> bool {
56 CURSOR_HOOK_EVENTS
57 .iter()
58 .all(|event| cursor_hook_exists(root, event))
59}
60
61fn cursor_hook_exists(root: &serde_json::Value, event: &str) -> bool {
62 if let Some(arr) = root
63 .pointer(&format!("/hooks/{event}"))
64 .and_then(|v| v.as_array())
65 {
66 return arr
67 .iter()
68 .any(|v| v.get("command").and_then(|c| c.as_str()) == Some(KAIZEN_CURSOR_HOOK_CMD));
69 }
70 if let Some(arr) = root.as_array() {
71 return arr.iter().any(|v| {
72 v.get("matcher").and_then(|m| m.as_str()) == Some(event)
73 && v.get("command").and_then(|c| c.as_str()) == Some(KAIZEN_CURSOR_HOOK_CMD)
74 });
75 }
76 false
77}
78
79fn patch_cursor_hooks(out: &mut String, ws: &Path) -> Result<()> {
80 let path = ws.join(".cursor/hooks.json");
81 if !path.exists() {
82 std::fs::create_dir_all(path.parent().unwrap())?;
83 let mut obj = serde_json::Map::new();
84 let mut hooks = serde_json::Map::new();
85 for event in CURSOR_HOOK_EVENTS {
86 hooks.insert(
87 (*event).to_string(),
88 serde_json::json!([{"command": KAIZEN_CURSOR_HOOK_CMD}]),
89 );
90 }
91 obj.insert("hooks".to_string(), serde_json::Value::Object(hooks));
92 std::fs::write(&path, serde_json::to_string_pretty(&obj)?)?;
93 writeln!(out, " created .cursor/hooks.json").unwrap();
94 return Ok(());
95 }
96 let raw = std::fs::read_to_string(&path)?;
97 let mut root: serde_json::Value = match serde_json::from_str(&raw) {
98 Ok(v) => v,
99 Err(e) => {
100 writeln!(out, " error .cursor/hooks.json: {e}").unwrap();
101 anyhow::bail!("malformed .cursor/hooks.json: {e}");
102 }
103 };
104 if cursor_hooks_done(&root) {
105 writeln!(out, " skipped .cursor/hooks.json").unwrap();
106 return Ok(());
107 }
108 let bak = backup_path(ws, "cursor_hooks");
109 std::fs::create_dir_all(bak.parent().unwrap())?;
110 std::fs::copy(&path, &bak)?;
111 if let Some(obj) = root.pointer_mut("/hooks").and_then(|v| v.as_object_mut()) {
112 for event in CURSOR_HOOK_EVENTS {
113 let arr = obj
114 .entry((*event).to_string())
115 .or_insert_with(|| serde_json::json!([]));
116 if let Some(hooks) = arr.as_array_mut()
117 && !hooks.iter().any(|v| {
118 v.get("command").and_then(|c| c.as_str()) == Some(KAIZEN_CURSOR_HOOK_CMD)
119 })
120 {
121 hooks.push(serde_json::json!({"command": KAIZEN_CURSOR_HOOK_CMD}));
122 }
123 }
124 } else if let Some(arr) = root.as_array_mut() {
125 for event in CURSOR_HOOK_EVENTS {
126 if !cursor_hook_exists(&serde_json::Value::Array(arr.clone()), event) {
127 arr.push(serde_json::json!({"matcher": event, "command": KAIZEN_CURSOR_HOOK_CMD}));
128 }
129 }
130 }
131 std::fs::write(&path, serde_json::to_string_pretty(&root)?)?;
132 writeln!(out, " patched .cursor/hooks.json (+session/tool hooks)").unwrap();
133 Ok(())
134}
135
136fn entry_has_kaizen_cmd(entry: &serde_json::Value) -> bool {
137 if entry.get("command").and_then(|c| c.as_str()) == Some(KAIZEN_CLAUDE_HOOK_CMD) {
138 return true;
139 }
140 entry
141 .get("hooks")
142 .and_then(|v| v.as_array())
143 .is_some_and(|inner| {
144 inner
145 .iter()
146 .any(|h| h.get("command").and_then(|c| c.as_str()) == Some(KAIZEN_CLAUDE_HOOK_CMD))
147 })
148}
149
150fn patch_claude_settings(out: &mut String, ws: &Path) -> Result<()> {
151 let path = ws.join(".claude/settings.json");
152 if !path.exists() {
153 std::fs::create_dir_all(path.parent().unwrap())?;
154 let mut obj = serde_json::Map::new();
155 let mut hooks = serde_json::Map::new();
156 for event in CLAUDE_HOOK_EVENTS {
157 hooks.insert(
158 (*event).to_string(),
159 serde_json::json!([
160 {"hooks": [{"type": "command", "command": KAIZEN_CLAUDE_HOOK_CMD}]}
161 ]),
162 );
163 }
164 obj.insert("hooks".to_string(), serde_json::Value::Object(hooks));
165 std::fs::write(&path, serde_json::to_string_pretty(&obj)?)?;
166 writeln!(out, " created .claude/settings.json").unwrap();
167 return Ok(());
168 }
169 let raw = std::fs::read_to_string(&path)?;
170 let mut obj: serde_json::Map<String, serde_json::Value> = match serde_json::from_str(&raw) {
171 Ok(v) => v,
172 Err(e) => {
173 writeln!(out, " error .claude/settings.json: {e}").unwrap();
174 anyhow::bail!("malformed .claude/settings.json: {e}");
175 }
176 };
177 let hooks = obj.entry("hooks").or_insert_with(|| serde_json::json!({}));
178 let hooks_obj = hooks.as_object_mut().unwrap();
179 let mut changed = false;
180 for event in CLAUDE_HOOK_EVENTS {
181 let arr = hooks_obj
182 .entry((*event).to_string())
183 .or_insert_with(|| serde_json::json!([]));
184 let Some(entries) = arr.as_array_mut() else {
185 continue;
186 };
187 for entry in entries.iter_mut() {
189 if entry.get("hooks").is_some() {
190 continue;
191 }
192 if let Some(obj) = entry.as_object()
193 && obj.contains_key("command")
194 {
195 let inner = entry.clone();
196 *entry = serde_json::json!({ "hooks": [inner] });
197 changed = true;
198 }
199 }
200 if !entries.iter().any(entry_has_kaizen_cmd) {
201 entries.push(serde_json::json!({
202 "hooks": [
203 {"type": "command", "command": KAIZEN_CLAUDE_HOOK_CMD}
204 ]
205 }));
206 changed = true;
207 }
208 }
209 if !changed {
210 writeln!(
211 out,
212 " skipped .claude/settings.json (already configured)"
213 )
214 .unwrap();
215 return Ok(());
216 }
217 let bak = backup_path(ws, "claude_settings");
218 std::fs::create_dir_all(bak.parent().unwrap())?;
219 std::fs::copy(&path, &bak)?;
220 std::fs::write(&path, serde_json::to_string_pretty(&obj)?)?;
221 writeln!(
222 out,
223 " patched .claude/settings.json (+session/tool hooks)"
224 )
225 .unwrap();
226 Ok(())
227}
228
229pub fn cursor_kaizen_hook_wiring(ws: &Path) -> Result<Option<bool>, String> {
231 let path = ws.join(".cursor/hooks.json");
232 if !path.exists() {
233 return Ok(None);
234 }
235 let raw = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
236 let root: serde_json::Value = serde_json::from_str(&raw).map_err(|e| e.to_string())?;
237 Ok(Some(cursor_hooks_done(&root)))
238}
239
240pub fn claude_kaizen_hook_wiring(ws: &Path) -> Result<Option<bool>, String> {
242 let path = ws.join(".claude/settings.json");
243 if !path.exists() {
244 return Ok(None);
245 }
246 let raw = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
247 let obj: serde_json::Map<String, serde_json::Value> =
248 serde_json::from_str(&raw).map_err(|e| e.to_string())?;
249 let Some(hooks) = obj.get("hooks").and_then(|v| v.as_object()) else {
250 return Ok(Some(false));
251 };
252 for event in CLAUDE_HOOK_EVENTS {
253 let Some(arr) = hooks.get(*event).and_then(|v| v.as_array()) else {
254 return Ok(Some(false));
255 };
256 if !arr.iter().any(entry_has_kaizen_cmd) {
257 return Ok(Some(false));
258 }
259 }
260 Ok(Some(true))
261}
262
263fn write_skill(out: &mut String, ws: &Path) -> Result<()> {
264 let path = ws.join(".cursor/skills/kaizen-retro/SKILL.md");
265 std::fs::create_dir_all(path.parent().unwrap())?;
266 if path.exists() {
267 let existing = std::fs::read_to_string(&path)?;
268 if !existing.contains("placeholder") && !existing.trim().is_empty() {
269 writeln!(out, " skipped .cursor/skills/kaizen-retro/SKILL.md").unwrap();
270 return Ok(());
271 }
272 }
273 std::fs::write(&path, KAIZEN_RETRO_SKILL)?;
274 writeln!(out, " wrote .cursor/skills/kaizen-retro/SKILL.md").unwrap();
275 Ok(())
276}
277
278pub fn init_text(workspace: Option<&std::path::Path>) -> Result<String> {
280 let ws = match workspace {
281 Some(p) => p.to_path_buf(),
282 None => std::env::current_dir()?,
283 };
284 let mut out = String::new();
285 ensure_config(&mut out, &ws)?;
286 patch_cursor_hooks(&mut out, &ws)?;
287 patch_claude_settings(&mut out, &ws)?;
288 write_skill(&mut out, &ws)?;
289 let cws = crate::core::workspace::canonical(&ws);
290 if let Err(e) = crate::core::machine_registry::record_init(&cws) {
291 tracing::warn!("machine registry: {e:#}");
292 }
293 writeln!(out).unwrap();
294 writeln!(
295 out,
296 "kaizen init complete — Cursor + Claude Code hooks wired."
297 )
298 .unwrap();
299 writeln!(out).unwrap();
300 writeln!(out, "Run Cursor or Claude Code in this repo once, then:").unwrap();
301 writeln!(
302 out,
303 " kaizen summary # cost + rollups (agent / model)"
304 )
305 .unwrap();
306 writeln!(
307 out,
308 " kaizen insights # activity, top tools, guidance"
309 )
310 .unwrap();
311 writeln!(out, " kaizen tui # live session browser").unwrap();
312 writeln!(out, " kaizen retro --days 7 # weekly heuristic bets").unwrap();
313 writeln!(out).unwrap();
314 writeln!(
315 out,
316 "Agents: `kaizen mcp` exposes every command as MCP tools — see docs/mcp.md."
317 )
318 .unwrap();
319 Ok(out)
320}
321
322pub fn cmd_init(workspace: Option<&Path>) -> Result<()> {
324 print!("{}", init_text(workspace)?);
325 Ok(())
326}