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