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) -> PathBuf {
35 ws.join(format!(".kaizen/backup/{}.{}.bak", filename, ts_ms()))
36}
37
38fn ensure_config(out: &mut String, ws: &Path) -> Result<()> {
39 let path = ws.join(".kaizen/config.toml");
40 if path.exists() {
41 writeln!(out, " skipped .kaizen/config.toml").unwrap();
42 return Ok(());
43 }
44 std::fs::create_dir_all(ws.join(".kaizen"))?;
45 std::fs::write(&path, CONFIG_TOML)?;
46 writeln!(out, " created .kaizen/config.toml").unwrap();
47 Ok(())
48}
49
50pub const KAIZEN_CURSOR_HOOK_CMD: &str = "kaizen ingest hook --source cursor";
52pub const KAIZEN_CLAUDE_HOOK_CMD: &str = "kaizen ingest hook --source claude";
54
55fn cursor_hooks_done(root: &serde_json::Value) -> bool {
57 CURSOR_HOOK_EVENTS
58 .iter()
59 .all(|event| cursor_hook_exists(root, event))
60}
61
62fn cursor_hook_exists(root: &serde_json::Value, event: &str) -> bool {
63 if let Some(arr) = root
64 .pointer(&format!("/hooks/{event}"))
65 .and_then(|v| v.as_array())
66 {
67 return arr
68 .iter()
69 .any(|v| v.get("command").and_then(|c| c.as_str()) == Some(KAIZEN_CURSOR_HOOK_CMD));
70 }
71 if let Some(arr) = root.as_array() {
72 return arr.iter().any(|v| {
73 v.get("matcher").and_then(|m| m.as_str()) == Some(event)
74 && v.get("command").and_then(|c| c.as_str()) == Some(KAIZEN_CURSOR_HOOK_CMD)
75 });
76 }
77 false
78}
79
80fn patch_cursor_hooks(out: &mut String, ws: &Path) -> Result<()> {
81 let path = ws.join(".cursor/hooks.json");
82 if !path.exists() {
83 std::fs::create_dir_all(path.parent().unwrap())?;
84 let mut obj = serde_json::Map::new();
85 let mut hooks = serde_json::Map::new();
86 for event in CURSOR_HOOK_EVENTS {
87 hooks.insert(
88 (*event).to_string(),
89 serde_json::json!([{"command": KAIZEN_CURSOR_HOOK_CMD}]),
90 );
91 }
92 obj.insert("hooks".to_string(), serde_json::Value::Object(hooks));
93 std::fs::write(&path, serde_json::to_string_pretty(&obj)?)?;
94 writeln!(out, " created .cursor/hooks.json").unwrap();
95 return Ok(());
96 }
97 let raw = std::fs::read_to_string(&path)?;
98 let mut root: serde_json::Value = match serde_json::from_str(&raw) {
99 Ok(v) => v,
100 Err(e) => {
101 writeln!(out, " error .cursor/hooks.json: {e}").unwrap();
102 anyhow::bail!("malformed .cursor/hooks.json: {e}");
103 }
104 };
105 if cursor_hooks_done(&root) {
106 writeln!(out, " skipped .cursor/hooks.json").unwrap();
107 return Ok(());
108 }
109 let bak = backup_path(ws, "cursor_hooks");
110 std::fs::create_dir_all(bak.parent().unwrap())?;
111 std::fs::copy(&path, &bak)?;
112 if let Some(obj) = root.pointer_mut("/hooks").and_then(|v| v.as_object_mut()) {
113 for event in CURSOR_HOOK_EVENTS {
114 let arr = obj
115 .entry((*event).to_string())
116 .or_insert_with(|| serde_json::json!([]));
117 if let Some(hooks) = arr.as_array_mut()
118 && !hooks.iter().any(|v| {
119 v.get("command").and_then(|c| c.as_str()) == Some(KAIZEN_CURSOR_HOOK_CMD)
120 })
121 {
122 hooks.push(serde_json::json!({"command": KAIZEN_CURSOR_HOOK_CMD}));
123 }
124 }
125 } else if let Some(arr) = root.as_array_mut() {
126 for event in CURSOR_HOOK_EVENTS {
127 if !cursor_hook_exists(&serde_json::Value::Array(arr.clone()), event) {
128 arr.push(serde_json::json!({"matcher": event, "command": KAIZEN_CURSOR_HOOK_CMD}));
129 }
130 }
131 }
132 std::fs::write(&path, serde_json::to_string_pretty(&root)?)?;
133 writeln!(out, " patched .cursor/hooks.json (+session/tool hooks)").unwrap();
134 Ok(())
135}
136
137fn entry_has_kaizen_cmd(entry: &serde_json::Value) -> bool {
138 if entry.get("command").and_then(|c| c.as_str()) == Some(KAIZEN_CLAUDE_HOOK_CMD) {
139 return true;
140 }
141 entry
142 .get("hooks")
143 .and_then(|v| v.as_array())
144 .is_some_and(|inner| {
145 inner
146 .iter()
147 .any(|h| h.get("command").and_then(|c| c.as_str()) == Some(KAIZEN_CLAUDE_HOOK_CMD))
148 })
149}
150
151fn patch_claude_settings(out: &mut String, ws: &Path) -> Result<()> {
152 let path = ws.join(".claude/settings.json");
153 if !path.exists() {
154 std::fs::create_dir_all(path.parent().unwrap())?;
155 let mut obj = serde_json::Map::new();
156 let mut hooks = serde_json::Map::new();
157 for event in CLAUDE_HOOK_EVENTS {
158 hooks.insert(
159 (*event).to_string(),
160 serde_json::json!([
161 {"hooks": [{"type": "command", "command": KAIZEN_CLAUDE_HOOK_CMD}]}
162 ]),
163 );
164 }
165 obj.insert("hooks".to_string(), serde_json::Value::Object(hooks));
166 std::fs::write(&path, serde_json::to_string_pretty(&obj)?)?;
167 writeln!(out, " created .claude/settings.json").unwrap();
168 return Ok(());
169 }
170 let raw = std::fs::read_to_string(&path)?;
171 let mut obj: serde_json::Map<String, serde_json::Value> = match serde_json::from_str(&raw) {
172 Ok(v) => v,
173 Err(e) => {
174 writeln!(out, " error .claude/settings.json: {e}").unwrap();
175 anyhow::bail!("malformed .claude/settings.json: {e}");
176 }
177 };
178 let hooks = obj.entry("hooks").or_insert_with(|| serde_json::json!({}));
179 let hooks_obj = hooks.as_object_mut().unwrap();
180 let mut changed = false;
181 for event in CLAUDE_HOOK_EVENTS {
182 let arr = hooks_obj
183 .entry((*event).to_string())
184 .or_insert_with(|| serde_json::json!([]));
185 let Some(entries) = arr.as_array_mut() else {
186 continue;
187 };
188 for entry in entries.iter_mut() {
190 if entry.get("hooks").is_some() {
191 continue;
192 }
193 if let Some(obj) = entry.as_object()
194 && obj.contains_key("command")
195 {
196 let inner = entry.clone();
197 *entry = serde_json::json!({ "hooks": [inner] });
198 changed = true;
199 }
200 }
201 if !entries.iter().any(entry_has_kaizen_cmd) {
202 entries.push(serde_json::json!({
203 "hooks": [
204 {"type": "command", "command": KAIZEN_CLAUDE_HOOK_CMD}
205 ]
206 }));
207 changed = true;
208 }
209 }
210 if !changed {
211 writeln!(
212 out,
213 " skipped .claude/settings.json (already configured)"
214 )
215 .unwrap();
216 return Ok(());
217 }
218 let bak = backup_path(ws, "claude_settings");
219 std::fs::create_dir_all(bak.parent().unwrap())?;
220 std::fs::copy(&path, &bak)?;
221 std::fs::write(&path, serde_json::to_string_pretty(&obj)?)?;
222 writeln!(
223 out,
224 " patched .claude/settings.json (+session/tool hooks)"
225 )
226 .unwrap();
227 Ok(())
228}
229
230pub fn cursor_kaizen_hook_wiring(ws: &Path) -> Result<Option<bool>, String> {
232 let path = ws.join(".cursor/hooks.json");
233 if !path.exists() {
234 return Ok(None);
235 }
236 let raw = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
237 let root: serde_json::Value = serde_json::from_str(&raw).map_err(|e| e.to_string())?;
238 Ok(Some(cursor_hooks_done(&root)))
239}
240
241pub fn claude_kaizen_hook_wiring(ws: &Path) -> Result<Option<bool>, String> {
243 let path = ws.join(".claude/settings.json");
244 if !path.exists() {
245 return Ok(None);
246 }
247 let raw = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
248 let obj: serde_json::Map<String, serde_json::Value> =
249 serde_json::from_str(&raw).map_err(|e| e.to_string())?;
250 let Some(hooks) = obj.get("hooks").and_then(|v| v.as_object()) else {
251 return Ok(Some(false));
252 };
253 for event in CLAUDE_HOOK_EVENTS {
254 let Some(arr) = hooks.get(*event).and_then(|v| v.as_array()) else {
255 return Ok(Some(false));
256 };
257 if !arr.iter().any(entry_has_kaizen_cmd) {
258 return Ok(Some(false));
259 }
260 }
261 Ok(Some(true))
262}
263
264fn write_eval_skill(out: &mut String, ws: &Path) -> Result<()> {
265 let path = ws.join(".cursor/skills/kaizen-eval/SKILL.md");
266 std::fs::create_dir_all(path.parent().unwrap())?;
267 if path.exists() {
268 let existing = std::fs::read_to_string(&path)?;
269 if !existing.contains("placeholder") && !existing.trim().is_empty() {
270 writeln!(out, " skipped .cursor/skills/kaizen-eval/SKILL.md").unwrap();
271 return Ok(());
272 }
273 }
274 std::fs::write(&path, KAIZEN_EVAL_SKILL)?;
275 writeln!(out, " wrote .cursor/skills/kaizen-eval/SKILL.md").unwrap();
276 Ok(())
277}
278
279fn write_skill(out: &mut String, ws: &Path) -> Result<()> {
280 let path = ws.join(".cursor/skills/kaizen-retro/SKILL.md");
281 std::fs::create_dir_all(path.parent().unwrap())?;
282 if path.exists() {
283 let existing = std::fs::read_to_string(&path)?;
284 if !existing.contains("placeholder") && !existing.trim().is_empty() {
285 writeln!(out, " skipped .cursor/skills/kaizen-retro/SKILL.md").unwrap();
286 return Ok(());
287 }
288 }
289 std::fs::write(&path, KAIZEN_RETRO_SKILL)?;
290 writeln!(out, " wrote .cursor/skills/kaizen-retro/SKILL.md").unwrap();
291 Ok(())
292}
293
294pub fn init_text(workspace: Option<&std::path::Path>) -> Result<String> {
296 let ws = match workspace {
297 Some(p) => p.to_path_buf(),
298 None => std::env::current_dir()?,
299 };
300 let mut out = String::new();
301 ensure_config(&mut out, &ws)?;
302 patch_cursor_hooks(&mut out, &ws)?;
303 patch_claude_settings(&mut out, &ws)?;
304 write_skill(&mut out, &ws)?;
305 write_eval_skill(&mut out, &ws)?;
306 let cws = crate::core::workspace::canonical(&ws);
307 if let Err(e) = crate::core::machine_registry::record_init(&cws) {
308 tracing::warn!("machine registry: {e:#}");
309 }
310 writeln!(out).unwrap();
311 writeln!(
312 out,
313 "kaizen init complete — Cursor + Claude Code hooks wired."
314 )
315 .unwrap();
316 writeln!(out).unwrap();
317 writeln!(out, "Run Cursor or Claude Code in this repo once, then:").unwrap();
318 writeln!(
319 out,
320 " kaizen summary # cost + rollups (agent / model)"
321 )
322 .unwrap();
323 writeln!(
324 out,
325 " kaizen insights # activity, top tools, guidance"
326 )
327 .unwrap();
328 writeln!(out, " kaizen tui # live session browser").unwrap();
329 writeln!(out, " kaizen retro --days 7 # weekly heuristic bets").unwrap();
330 writeln!(out).unwrap();
331 writeln!(
332 out,
333 "Agents: `kaizen mcp` exposes every command as MCP tools — see docs/mcp.md."
334 )
335 .unwrap();
336 Ok(out)
337}
338
339pub fn cmd_init(workspace: Option<&Path>) -> Result<()> {
341 print!("{}", init_text(workspace)?);
342 Ok(())
343}