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