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