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