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