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