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) -> 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
50/// Hook command string written to `.cursor/hooks.json`.
51pub const KAIZEN_CURSOR_HOOK_CMD: &str = "kaizen ingest hook --source cursor";
52/// Hook command string written to `.claude/settings.json`.
53pub const KAIZEN_CLAUDE_HOOK_CMD: &str = "kaizen ingest hook --source claude";
54
55/// `true` if every Cursor hook event points at the kaizen ingest command.
56fn 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        // Migrate any bare {command,type} entries missing the `hooks` wrapper.
189        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
230/// Read-only: `.cursor/hooks.json` missing, valid JSON with full kaizen wiring, or not.
231pub 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
241/// Read-only: `.claude/settings.json` hooks all reference kaizen (same as post-patch `entry_has_kaizen_cmd`).
242pub 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
294/// Text that `kaizen init` would print to stdout.
295pub 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
339/// Idempotent workspace setup.
340pub fn cmd_init(workspace: Option<&Path>) -> Result<()> {
341    print!("{}", init_text(workspace)?);
342    Ok(())
343}