Skip to main content

kaizen/shell/
init.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2//! `kaizen init` — idempotent workspace setup.
3
4use 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
52/// Hook command string written to `.cursor/hooks.json`.
53pub const KAIZEN_CURSOR_HOOK_CMD: &str = "kaizen ingest hook --source cursor";
54pub const KAIZEN_OPENCLAW_HOOK_CMD: &str = "kaizen ingest hook --source openclaw";
55/// Hook command string written to `.claude/settings.json`.
56pub const KAIZEN_CLAUDE_HOOK_CMD: &str = "kaizen ingest hook --source claude";
57
58/// `true` if every Cursor hook event points at the kaizen ingest command.
59fn 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        // Migrate any bare {command,type} entries missing the `hooks` wrapper.
203        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
243/// Read-only: `~/.cursor/hooks.json` missing, valid JSON with full kaizen wiring, or not.
244pub 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
258/// Read-only: `~/.claude/settings.json` hooks all reference kaizen (same as post-patch `entry_has_kaizen_cmd`).
259pub 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
285/// Returns any workspace-local files that still contain kaizen wiring (legacy from pre-global init).
286pub 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
408/// Write (or idempotently skip) the OpenClaw TS hook handler.
409///
410/// Backs up any pre-existing `handler.ts` that does not already reference kaizen.
411pub 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
445/// Read-only: `~/.openclaw/hooks/kaizen-events` absent / wired / partial.
446pub 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
462/// Text that `kaizen init` would print to stdout.
463pub 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
528/// Idempotent workspace setup.
529pub fn cmd_init(workspace: Option<&Path>) -> Result<()> {
530    print!("{}", init_text(workspace)?);
531    Ok(())
532}