Skip to main content

innate_core/install/
mod.rs

1//! `innate install` — interactive setup wizard (clack-style TUI).
2//!
3//! No extra dependencies — uses only what Innate already pulls in.
4//! Configures Claude Code, Codex CLI, and opencode to use innate's MCP server.
5
6use std::io::{self, BufRead, Write};
7use std::path::{Path, PathBuf};
8
9use chrono::Utc;
10use serde_json::{json, Value};
11
12const SKILL_MD: &str = include_str!("../../assets/SKILL.md");
13
14mod agents;
15mod path;
16mod settings;
17mod skills;
18mod ui;
19mod uninstall;
20mod wizard;
21
22pub use uninstall::run_uninstall;
23pub use wizard::run_install;
24
25const INNATE_TOOLS: &[&str] = &[
26    "innate_recall",
27    "innate_record",
28    "innate_add",
29    "innate_spark",
30    "innate_evolve",
31    "innate_inspect",
32    "innate_approve",
33    "innate_archive",
34    "innate_invalidate",
35    "innate_restore",
36    "innate_mature_spark",
37    "innate_promote_spark",
38    "innate_drop_spark",
39];
40
41// ── Clack-style output ────────────────────────────────────────────────────────
42
43// ── Helpers ───────────────────────────────────────────────────────────────────
44
45fn home_dir() -> PathBuf {
46    dirs_next::home_dir().unwrap_or_else(|| PathBuf::from("."))
47}
48
49fn tilde_path(p: &Path) -> String {
50    let home = home_dir();
51    if let Ok(rel) = p.strip_prefix(&home) {
52        format!("~/{}", rel.display())
53    } else {
54        p.display().to_string()
55    }
56}
57
58fn read_json(path: &Path) -> Option<Value> {
59    let txt = std::fs::read_to_string(path).ok()?;
60    serde_json::from_str(&txt).ok()
61}
62
63/// Read a JSON config file whose root must be an object.
64/// A missing file yields an empty object; an unreadable, unparseable, or
65/// non-object file yields `Err` so an existing user config is never
66/// silently replaced by a rewrite.
67fn read_json_object(path: &Path) -> Result<Value, String> {
68    match std::fs::read_to_string(path) {
69        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(json!({})),
70        Err(e) => Err(format!("cannot read {}: {e}", path.display())),
71        Ok(txt) => match serde_json::from_str::<Value>(&txt) {
72            Err(e) => Err(format!(
73                "cannot parse {}: {e} — fix the file and re-run",
74                path.display()
75            )),
76            Ok(v) if !v.is_object() => {
77                Err(format!("{}: root is not a JSON object", path.display()))
78            }
79            Ok(v) => Ok(v),
80        },
81    }
82}
83
84fn write_json(path: &Path, value: &Value) -> anyhow::Result<()> {
85    if let Some(parent) = path.parent() {
86        std::fs::create_dir_all(parent)?;
87    }
88    let txt = serde_json::to_string_pretty(value)?;
89    std::fs::write(path, txt + "\n")?;
90    Ok(())
91}
92
93/// Strip `//` and `/* */` comments from a JSONC string.
94fn strip_jsonc_comments(s: &str) -> String {
95    let mut out = String::with_capacity(s.len());
96    let mut chars = s.chars().peekable();
97    let mut in_str = false;
98    let mut escape = false;
99
100    while let Some(c) = chars.next() {
101        if escape {
102            out.push(c);
103            escape = false;
104            continue;
105        }
106        if in_str {
107            if c == '\\' {
108                escape = true;
109                out.push(c);
110                continue;
111            }
112            if c == '"' {
113                in_str = false;
114            }
115            out.push(c);
116            continue;
117        }
118        if c == '"' {
119            in_str = true;
120            out.push(c);
121            continue;
122        }
123        if c == '/' {
124            match chars.peek() {
125                Some('/') => {
126                    for nc in chars.by_ref() {
127                        if nc == '\n' {
128                            out.push('\n');
129                            break;
130                        }
131                    }
132                    continue;
133                }
134                Some('*') => {
135                    chars.next();
136                    while let Some(nc) = chars.next() {
137                        if nc == '*' && chars.peek() == Some(&'/') {
138                            chars.next();
139                            break;
140                        }
141                    }
142                    continue;
143                }
144                _ => {}
145            }
146        }
147        out.push(c);
148    }
149    out
150}
151
152/// Remove all `[prefix.*]` TOML sections (and their keys) from a TOML string.
153/// Used to replace an existing innate block when re-configuring.
154fn strip_toml_section(toml: &str, section_prefix: &str) -> String {
155    let mut out = String::new();
156    let mut skip = false;
157    for line in toml.lines() {
158        let trimmed = line.trim();
159        if trimmed.starts_with('[') {
160            // New section header — check if it belongs to the prefix we're stripping.
161            let header = trimmed.trim_start_matches('[').trim_end_matches(']');
162            skip = header == section_prefix || header.starts_with(&format!("{section_prefix}."));
163        }
164        if !skip {
165            out.push_str(line);
166            out.push('\n');
167        }
168    }
169    out
170}