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