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 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        // Migrate any bare {command,type} entries missing the `hooks` wrapper.
191        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
231/// Read-only: `.cursor/hooks.json` missing, valid JSON with full kaizen wiring, or not.
232pub 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
242/// Read-only: `.claude/settings.json` hooks all reference kaizen (same as post-patch `entry_has_kaizen_cmd`).
243pub 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
331/// Write (or idempotently skip) the OpenClaw TS hook handler.
332///
333/// Backs up any pre-existing `handler.ts` that does not already reference kaizen.
334pub 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
368/// Read-only: `~/.openclaw/hooks/kaizen-events` absent / wired / partial.
369pub 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
385/// Text that `kaizen init` would print to stdout.
386pub 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
451/// Idempotent workspace setup.
452pub fn cmd_init(workspace: Option<&Path>) -> Result<()> {
453    print!("{}", init_text(workspace)?);
454    Ok(())
455}