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)? + "\n";
89    // Atomic write: a plain `fs::write` truncates the target first, so a crash
90    // mid-write leaves the user's agent config (settings.json, …) corrupted or
91    // empty. Write to a sibling temp file and rename over the target — rename is
92    // atomic within the same directory/filesystem, so readers always see either
93    // the old file or the fully-written new one, never a partial.
94    let tmp = path.with_extension("tmp");
95    std::fs::write(&tmp, txt.as_bytes())?;
96    if let Err(e) = std::fs::rename(&tmp, path) {
97        let _ = std::fs::remove_file(&tmp);
98        return Err(e.into());
99    }
100    Ok(())
101}
102
103/// Strip `//` and `/* */` comments from a JSONC string.
104fn strip_jsonc_comments(s: &str) -> String {
105    let mut out = String::with_capacity(s.len());
106    let mut chars = s.chars().peekable();
107    let mut in_str = false;
108    let mut escape = false;
109
110    while let Some(c) = chars.next() {
111        if escape {
112            out.push(c);
113            escape = false;
114            continue;
115        }
116        if in_str {
117            if c == '\\' {
118                escape = true;
119                out.push(c);
120                continue;
121            }
122            if c == '"' {
123                in_str = false;
124            }
125            out.push(c);
126            continue;
127        }
128        if c == '"' {
129            in_str = true;
130            out.push(c);
131            continue;
132        }
133        if c == '/' {
134            match chars.peek() {
135                Some('/') => {
136                    for nc in chars.by_ref() {
137                        if nc == '\n' {
138                            out.push('\n');
139                            break;
140                        }
141                    }
142                    continue;
143                }
144                Some('*') => {
145                    chars.next();
146                    while let Some(nc) = chars.next() {
147                        if nc == '*' && chars.peek() == Some(&'/') {
148                            chars.next();
149                            break;
150                        }
151                    }
152                    continue;
153                }
154                _ => {}
155            }
156        }
157        out.push(c);
158    }
159    out
160}
161
162/// Remove all `[prefix.*]` TOML sections (and their keys) from a TOML string.
163/// Used to replace an existing innate block when re-configuring.
164fn strip_toml_section(toml: &str, section_prefix: &str) -> String {
165    let mut out = String::new();
166    let mut skip = false;
167    for line in toml.lines() {
168        let trimmed = line.trim();
169        if trimmed.starts_with('[') {
170            // New section header — check if it belongs to the prefix we're stripping.
171            let header = trimmed.trim_start_matches('[').trim_end_matches(']');
172            skip = header == section_prefix || header.starts_with(&format!("{section_prefix}."));
173        }
174        if !skip {
175            out.push_str(line);
176            out.push('\n');
177        }
178    }
179    out
180}