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");
22const KAIZEN_EVAL_SKILL: &str = include_str!("../../assets/kaizen-eval-SKILL.md");
23
24const CURSOR_HOOK_EVENTS: &[&str] = &["SessionStart", "PreToolUse", "PostToolUse", "Stop"];
25const CLAUDE_HOOK_EVENTS: &[&str] = &["SessionStart", "PreToolUse", "PostToolUse", "Stop"];
26
27fn ts_ms() -> u64 {
28 SystemTime::now()
29 .duration_since(UNIX_EPOCH)
30 .unwrap_or_default()
31 .as_millis() as u64
32}
33
34fn backup_path(ws: &Path, filename: &str) -> Result<PathBuf> {
35 let dir = crate::core::paths::project_data_dir(ws)?.join("backup");
36 std::fs::create_dir_all(&dir)?;
37 Ok(dir.join(format!("{}.{}.bak", filename, ts_ms())))
38}
39
40fn ensure_config(out: &mut String, ws: &Path) -> Result<()> {
41 let data_dir = crate::core::paths::project_data_dir(ws)?;
42 let path = data_dir.join("config.toml");
43 if path.exists() {
44 writeln!(out, " skipped config.toml (project data dir)").unwrap();
45 return Ok(());
46 }
47 std::fs::write(&path, CONFIG_TOML)?;
48 writeln!(out, " created {}", path.display()).unwrap();
49 Ok(())
50}
51
52pub const KAIZEN_CURSOR_HOOK_CMD: &str = "kaizen ingest hook --source cursor";
54pub const KAIZEN_OPENCLAW_HOOK_CMD: &str = "kaizen ingest hook --source openclaw";
55const KAIZEN_OPENCLAW_SPAWN_ARGS: &str = r#""ingest", "hook", "--source", "openclaw""#;
56pub const KAIZEN_CLAUDE_HOOK_CMD: &str = "kaizen ingest hook --source claude";
58
59fn cursor_hooks_done(root: &serde_json::Value) -> bool {
61 CURSOR_HOOK_EVENTS
62 .iter()
63 .all(|event| cursor_hook_exists(root, event))
64}
65
66fn cursor_hook_exists(root: &serde_json::Value, event: &str) -> bool {
67 if let Some(arr) = root
68 .pointer(&format!("/hooks/{event}"))
69 .and_then(|v| v.as_array())
70 {
71 return arr
72 .iter()
73 .any(|v| v.get("command").and_then(|c| c.as_str()) == Some(KAIZEN_CURSOR_HOOK_CMD));
74 }
75 if let Some(arr) = root.as_array() {
76 return arr.iter().any(|v| {
77 v.get("matcher").and_then(|m| m.as_str()) == Some(event)
78 && v.get("command").and_then(|c| c.as_str()) == Some(KAIZEN_CURSOR_HOOK_CMD)
79 });
80 }
81 false
82}
83
84fn patch_cursor_hooks(out: &mut String, ws: &Path) -> Result<()> {
85 let Some(cursor_dir) = cursor_user_dir() else {
86 writeln!(out, " skipped ~/.cursor/hooks.json (HOME unset)").unwrap();
87 return Ok(());
88 };
89 let path = cursor_dir.join("hooks.json");
90 if !path.exists() {
91 std::fs::create_dir_all(path.parent().unwrap())?;
92 let mut obj = serde_json::Map::new();
93 let mut hooks = serde_json::Map::new();
94 for event in CURSOR_HOOK_EVENTS {
95 hooks.insert(
96 (*event).to_string(),
97 serde_json::json!([{"command": KAIZEN_CURSOR_HOOK_CMD}]),
98 );
99 }
100 obj.insert("hooks".to_string(), serde_json::Value::Object(hooks));
101 write_atomic(&path, &serde_json::to_string_pretty(&obj)?)?;
102 writeln!(out, " created ~/.cursor/hooks.json").unwrap();
103 return Ok(());
104 }
105 let raw = std::fs::read_to_string(&path)?;
106 let mut root: serde_json::Value = match serde_json::from_str(&raw) {
107 Ok(v) => v,
108 Err(e) => {
109 writeln!(out, " error ~/.cursor/hooks.json: {e}").unwrap();
110 anyhow::bail!("malformed ~/.cursor/hooks.json: {e}");
111 }
112 };
113 if cursor_hooks_done(&root) {
114 writeln!(out, " skipped ~/.cursor/hooks.json").unwrap();
115 return Ok(());
116 }
117 let bak = backup_path(ws, "cursor_hooks")?;
118 std::fs::copy(&path, &bak)?;
119 if let Some(obj) = root.pointer_mut("/hooks").and_then(|v| v.as_object_mut()) {
120 for event in CURSOR_HOOK_EVENTS {
121 let arr = obj
122 .entry((*event).to_string())
123 .or_insert_with(|| serde_json::json!([]));
124 if let Some(hooks) = arr.as_array_mut()
125 && !hooks.iter().any(|v| {
126 v.get("command").and_then(|c| c.as_str()) == Some(KAIZEN_CURSOR_HOOK_CMD)
127 })
128 {
129 hooks.push(serde_json::json!({"command": KAIZEN_CURSOR_HOOK_CMD}));
130 }
131 }
132 } else if let Some(arr) = root.as_array_mut() {
133 for event in CURSOR_HOOK_EVENTS {
134 if !cursor_hook_exists(&serde_json::Value::Array(arr.clone()), event) {
135 arr.push(serde_json::json!({"matcher": event, "command": KAIZEN_CURSOR_HOOK_CMD}));
136 }
137 }
138 }
139 write_atomic(&path, &serde_json::to_string_pretty(&root)?)?;
140 writeln!(
141 out,
142 " patched ~/.cursor/hooks.json (+session/tool hooks)"
143 )
144 .unwrap();
145 Ok(())
146}
147
148fn entry_has_kaizen_cmd(entry: &serde_json::Value) -> bool {
149 if entry.get("command").and_then(|c| c.as_str()) == Some(KAIZEN_CLAUDE_HOOK_CMD) {
150 return true;
151 }
152 entry
153 .get("hooks")
154 .and_then(|v| v.as_array())
155 .is_some_and(|inner| {
156 inner
157 .iter()
158 .any(|h| h.get("command").and_then(|c| c.as_str()) == Some(KAIZEN_CLAUDE_HOOK_CMD))
159 })
160}
161
162fn patch_claude_settings(out: &mut String, ws: &Path) -> Result<()> {
163 let Some(claude_dir) = claude_user_dir() else {
164 writeln!(out, " skipped ~/.claude/settings.json (HOME unset)").unwrap();
165 return Ok(());
166 };
167 let path = claude_dir.join("settings.json");
168 if !path.exists() {
169 std::fs::create_dir_all(path.parent().unwrap())?;
170 let mut obj = serde_json::Map::new();
171 let mut hooks = serde_json::Map::new();
172 for event in CLAUDE_HOOK_EVENTS {
173 hooks.insert(
174 (*event).to_string(),
175 serde_json::json!([
176 {"hooks": [{"type": "command", "command": KAIZEN_CLAUDE_HOOK_CMD}]}
177 ]),
178 );
179 }
180 obj.insert("hooks".to_string(), serde_json::Value::Object(hooks));
181 write_atomic(&path, &serde_json::to_string_pretty(&obj)?)?;
182 writeln!(out, " created ~/.claude/settings.json").unwrap();
183 return Ok(());
184 }
185 let raw = std::fs::read_to_string(&path)?;
186 let mut obj: serde_json::Map<String, serde_json::Value> = match serde_json::from_str(&raw) {
187 Ok(v) => v,
188 Err(e) => {
189 writeln!(out, " error ~/.claude/settings.json: {e}").unwrap();
190 anyhow::bail!("malformed ~/.claude/settings.json: {e}");
191 }
192 };
193 let hooks = obj.entry("hooks").or_insert_with(|| serde_json::json!({}));
194 let hooks_obj = hooks.as_object_mut().unwrap();
195 let mut changed = false;
196 for event in CLAUDE_HOOK_EVENTS {
197 let arr = hooks_obj
198 .entry((*event).to_string())
199 .or_insert_with(|| serde_json::json!([]));
200 let Some(entries) = arr.as_array_mut() else {
201 continue;
202 };
203 for entry in entries.iter_mut() {
205 if entry.get("hooks").is_some() {
206 continue;
207 }
208 if let Some(obj) = entry.as_object()
209 && obj.contains_key("command")
210 {
211 let inner = entry.clone();
212 *entry = serde_json::json!({ "hooks": [inner] });
213 changed = true;
214 }
215 }
216 if !entries.iter().any(entry_has_kaizen_cmd) {
217 entries.push(serde_json::json!({
218 "hooks": [
219 {"type": "command", "command": KAIZEN_CLAUDE_HOOK_CMD}
220 ]
221 }));
222 changed = true;
223 }
224 }
225 if !changed {
226 writeln!(
227 out,
228 " skipped ~/.claude/settings.json (already configured)"
229 )
230 .unwrap();
231 return Ok(());
232 }
233 let bak = backup_path(ws, "claude_settings")?;
234 std::fs::copy(&path, &bak)?;
235 write_atomic(&path, &serde_json::to_string_pretty(&obj)?)?;
236 writeln!(
237 out,
238 " patched ~/.claude/settings.json (+session/tool hooks)"
239 )
240 .unwrap();
241 Ok(())
242}
243
244pub fn cursor_kaizen_hook_wiring(ws: &Path) -> Result<Option<bool>, String> {
246 let _ = ws;
247 let Some(cursor_dir) = cursor_user_dir() else {
248 return Ok(None);
249 };
250 let path = cursor_dir.join("hooks.json");
251 if !path.exists() {
252 return Ok(None);
253 }
254 let raw = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
255 let root: serde_json::Value = serde_json::from_str(&raw).map_err(|e| e.to_string())?;
256 Ok(Some(cursor_hooks_done(&root)))
257}
258
259pub fn claude_kaizen_hook_wiring(ws: &Path) -> Result<Option<bool>, String> {
261 let _ = ws;
262 let Some(claude_dir) = claude_user_dir() else {
263 return Ok(None);
264 };
265 let path = claude_dir.join("settings.json");
266 if !path.exists() {
267 return Ok(None);
268 }
269 let raw = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
270 let obj: serde_json::Map<String, serde_json::Value> =
271 serde_json::from_str(&raw).map_err(|e| e.to_string())?;
272 let Some(hooks) = obj.get("hooks").and_then(|v| v.as_object()) else {
273 return Ok(Some(false));
274 };
275 for event in CLAUDE_HOOK_EVENTS {
276 let Some(arr) = hooks.get(*event).and_then(|v| v.as_array()) else {
277 return Ok(Some(false));
278 };
279 if !arr.iter().any(entry_has_kaizen_cmd) {
280 return Ok(Some(false));
281 }
282 }
283 Ok(Some(true))
284}
285
286pub fn detect_legacy_wiring(ws: &Path) -> Vec<PathBuf> {
288 let mut found = Vec::new();
289 let cursor_local = ws.join(".cursor/hooks.json");
290 if cursor_local.exists()
291 && let Ok(raw) = std::fs::read_to_string(&cursor_local)
292 && raw.contains(KAIZEN_CURSOR_HOOK_CMD)
293 {
294 found.push(cursor_local);
295 }
296 let claude_local = ws.join(".claude/settings.json");
297 if claude_local.exists()
298 && let Ok(raw) = std::fs::read_to_string(&claude_local)
299 && raw.contains(KAIZEN_CLAUDE_HOOK_CMD)
300 {
301 found.push(claude_local);
302 }
303 found
304}
305
306fn write_eval_skill(out: &mut String, ws: &Path) -> Result<()> {
307 let Some(cursor_dir) = cursor_user_dir() else {
308 writeln!(
309 out,
310 " skipped ~/.cursor/skills/kaizen-eval/SKILL.md (HOME unset)"
311 )
312 .unwrap();
313 return Ok(());
314 };
315 let path = cursor_dir.join("skills/kaizen-eval/SKILL.md");
316 let _ = ws;
317 std::fs::create_dir_all(path.parent().unwrap())?;
318 if path.exists() {
319 let existing = std::fs::read_to_string(&path)?;
320 if !existing.contains("placeholder") && !existing.trim().is_empty() {
321 writeln!(out, " skipped ~/.cursor/skills/kaizen-eval/SKILL.md").unwrap();
322 return Ok(());
323 }
324 }
325 std::fs::write(&path, KAIZEN_EVAL_SKILL)?;
326 writeln!(out, " wrote ~/.cursor/skills/kaizen-eval/SKILL.md").unwrap();
327 Ok(())
328}
329
330fn write_skill(out: &mut String, ws: &Path) -> Result<()> {
331 let Some(cursor_dir) = cursor_user_dir() else {
332 writeln!(
333 out,
334 " skipped ~/.cursor/skills/kaizen-retro/SKILL.md (HOME unset)"
335 )
336 .unwrap();
337 return Ok(());
338 };
339 let path = cursor_dir.join("skills/kaizen-retro/SKILL.md");
340 let _ = ws;
341 std::fs::create_dir_all(path.parent().unwrap())?;
342 if path.exists() {
343 let existing = std::fs::read_to_string(&path)?;
344 if !existing.contains("placeholder") && !existing.trim().is_empty() {
345 writeln!(out, " skipped ~/.cursor/skills/kaizen-retro/SKILL.md").unwrap();
346 return Ok(());
347 }
348 }
349 std::fs::write(&path, KAIZEN_RETRO_SKILL)?;
350 writeln!(out, " wrote ~/.cursor/skills/kaizen-retro/SKILL.md").unwrap();
351 Ok(())
352}
353
354const OPENCLAW_HOOK_EVENTS: &[&str] = &[
355 "message:received",
356 "message:sent",
357 "command:new",
358 "command:reset",
359 "command:stop",
360 "session:compact:before",
361 "session:compact:after",
362 "session:patch",
363];
364
365const OPENCLAW_HANDLER_TS: &str = r#"import { spawn } from "child_process";
366
367export async function handler(event: Record<string, unknown>) {
368 const payload = JSON.stringify({
369 event: event["type"] ?? event["event"],
370 session_id: event["sessionId"] ?? event["session_id"] ?? "",
371 timestamp_ms: typeof event["timestamp"] === "number" ? event["timestamp"] : Date.now(),
372 ...event,
373 });
374 const child = spawn("kaizen", ["ingest", "hook", "--source", "openclaw"], {
375 stdio: ["pipe", "ignore", "ignore"],
376 });
377 child.stdin?.write(payload + "\n");
378 child.stdin?.end();
379}
380"#;
381
382const OPENCLAW_HOOK_MD: &str = "# kaizen-events\n\nCaptures OpenClaw sessions for kaizen.\n";
383
384fn cursor_user_dir() -> Option<PathBuf> {
385 std::env::var("HOME")
386 .ok()
387 .map(|h| PathBuf::from(h).join(".cursor"))
388}
389
390fn claude_user_dir() -> Option<PathBuf> {
391 std::env::var("HOME")
392 .ok()
393 .map(|h| PathBuf::from(h).join(".claude"))
394}
395
396fn write_atomic(path: &Path, content: &str) -> Result<()> {
397 let mut tmp = tempfile::NamedTempFile::new_in(path.parent().unwrap())?;
398 std::io::Write::write_all(&mut tmp, content.as_bytes())?;
399 tmp.persist(path)?;
400 Ok(())
401}
402
403fn openclaw_hooks_dir() -> Option<PathBuf> {
404 std::env::var("HOME")
405 .ok()
406 .map(|h| PathBuf::from(h).join(".openclaw/hooks/kaizen-events"))
407}
408
409pub fn patch_openclaw_handlers(out: &mut String, ws: &Path) -> Result<()> {
413 let Some(hook_dir) = openclaw_hooks_dir() else {
414 writeln!(
415 out,
416 " skipped ~/.openclaw/hooks/kaizen-events (HOME unset)"
417 )
418 .unwrap();
419 return Ok(());
420 };
421 let handler_path = hook_dir.join("handler.ts");
422 if handler_path.exists() {
423 let existing = std::fs::read_to_string(&handler_path)?;
424 if openclaw_handler_contains_kaizen(&existing) {
425 writeln!(out, " skipped ~/.openclaw/hooks/kaizen-events/handler.ts").unwrap();
426 return Ok(());
427 }
428 let bak = backup_path(ws, "openclaw_hook")?;
429 std::fs::copy(&handler_path, &bak)?;
430 }
431 std::fs::create_dir_all(&hook_dir)?;
432 std::fs::write(&handler_path, OPENCLAW_HANDLER_TS)?;
433 std::fs::write(hook_dir.join("HOOK.md"), OPENCLAW_HOOK_MD)?;
434 writeln!(out, " created ~/.openclaw/hooks/kaizen-events/handler.ts").unwrap();
435 let _ = std::process::Command::new("openclaw")
436 .args(["hooks", "enable", "kaizen-events"])
437 .status();
438 for event in OPENCLAW_HOOK_EVENTS {
439 let _ = std::process::Command::new("openclaw")
440 .args(["hooks", "subscribe", "kaizen-events", event])
441 .status();
442 }
443 Ok(())
444}
445
446pub fn openclaw_kaizen_hook_wiring(_ws: &Path) -> Result<Option<bool>, String> {
448 let Some(hook_dir) = openclaw_hooks_dir() else {
449 return Ok(None);
450 };
451 if !hook_dir.is_dir() {
452 return Ok(None);
453 }
454 let handler_path = hook_dir.join("handler.ts");
455 let hook_md = hook_dir.join("HOOK.md");
456 if !handler_path.exists() || !hook_md.exists() {
457 return Ok(Some(false));
458 }
459 let raw = std::fs::read_to_string(&handler_path).map_err(|e| e.to_string())?;
460 Ok(Some(openclaw_handler_contains_kaizen(&raw)))
461}
462
463fn openclaw_handler_contains_kaizen(raw: &str) -> bool {
464 raw.contains(KAIZEN_OPENCLAW_HOOK_CMD)
465 || (raw.contains(r#"spawn("kaizen""#) && raw.contains(KAIZEN_OPENCLAW_SPAWN_ARGS))
466}
467
468pub fn init_text(workspace: Option<&std::path::Path>) -> Result<String> {
470 let ws = match workspace {
471 Some(p) => p.to_path_buf(),
472 None => std::env::current_dir()?,
473 };
474 let mut out = String::new();
475 if let Ok(data_dir) = crate::core::paths::project_data_dir(&ws) {
476 match crate::core::migrate_home::migrate_legacy_in_repo(&ws, &data_dir) {
477 Ok(crate::core::migrate_home::MigrationOutcome::Migrated) => {
478 writeln!(out, " migrated .kaizen/ → {}", data_dir.display()).unwrap();
479 }
480 Ok(crate::core::migrate_home::MigrationOutcome::Conflict) => {
481 writeln!(
482 out,
483 " warning .kaizen/ and {} both non-empty — skipping auto-migration",
484 data_dir.display()
485 )
486 .unwrap();
487 }
488 _ => {}
489 }
490 }
491 ensure_config(&mut out, &ws)?;
492 patch_cursor_hooks(&mut out, &ws)?;
493 patch_claude_settings(&mut out, &ws)?;
494 patch_openclaw_handlers(&mut out, &ws)?;
495 write_skill(&mut out, &ws)?;
496 write_eval_skill(&mut out, &ws)?;
497 let cws = crate::core::workspace::canonical(&ws);
498 if let Err(e) = crate::core::machine_registry::record_init(&cws) {
499 tracing::warn!("machine registry: {e:#}");
500 }
501 writeln!(out).unwrap();
502 writeln!(
503 out,
504 "kaizen init complete — Cursor + Claude Code + OpenClaw hooks wired."
505 )
506 .unwrap();
507 writeln!(out).unwrap();
508 writeln!(out, "Run Cursor or Claude Code in this repo once, then:").unwrap();
509 writeln!(
510 out,
511 " kaizen summary # cost + rollups (agent / model)"
512 )
513 .unwrap();
514 writeln!(
515 out,
516 " kaizen insights # activity, top tools, guidance"
517 )
518 .unwrap();
519 writeln!(out, " kaizen tui # live session browser").unwrap();
520 writeln!(out, " kaizen retro --days 7 # weekly heuristic bets").unwrap();
521 writeln!(out).unwrap();
522 writeln!(
523 out,
524 "Agents: `kaizen mcp` exposes every command as MCP tools — see docs/mcp.md."
525 )
526 .unwrap();
527 if let Ok(data_dir) = crate::core::paths::project_data_dir(&ws) {
528 writeln!(out).unwrap();
529 writeln!(out, "Project data: {}", data_dir.display()).unwrap();
530 }
531 Ok(out)
532}
533
534pub fn cmd_init(workspace: Option<&Path>) -> Result<()> {
536 print!("{}", init_text(workspace)?);
537 Ok(())
538}