Skip to main content

engram_core/
init_handler.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use engram_storage::Database;
5
6use crate::config::home_directory;
7use crate::error::CoreError;
8
9const ENGRAM_DIRECTORY: &str = ".engram";
10const CONFIG_FILENAME: &str = "engram.toml";
11const AGENT_MD_FILENAME: &str = "AGENT.md";
12const DATABASE_FILENAME: &str = "engram.db";
13const SOCKET_FILENAME: &str = "engram.sock";
14const MCP_JSON_FILENAME: &str = ".mcp.json";
15const GITIGNORE_FILENAME: &str = ".gitignore";
16const GITIGNORE_MARKER: &str = ".engram/";
17const AGENT_MD_CONTENT: &str = include_str!("../AGENT.md");
18const UNIX_SOCKET_PATH_MAX_BYTES: usize = 104;
19const MCP_NPX_PACKAGE: &str = "@engramm/engram-mcp-server";
20const DEFAULT_ENGRAM_BIN_NAME: &str = "engram";
21
22const DEFAULT_CONFIG_TEMPLATE: &str = r#"# Global engram config. Runtime always prefers per-project state under
23# <project>/.engram/{engram.db, engram.sock} (discovered by walking up from cwd
24# like .git). The database.path and server.socket_path values below are
25# fallbacks used only when no .engram/ marker is found and no ENGRAM_DB_PATH /
26# ENGRAM_SOCKET_PATH env override is set.
27
28[database]
29path = "~/.engram/memories.db"
30
31[embedding]
32provider = "voyage"
33model = "voyage-code-3"
34dimension = 1024
35# hyde_threshold: opt-in HyDE. 0 = disabled (default). N>0 = enable HyDE
36# when the query has fewer than N words. HyDE adds ~1.5s latency on cache
37# miss but improves recall on terse queries; cache is keyed by the
38# original query so repeated calls are instant.
39hyde_threshold = 0
40
41[llm]
42provider = "openai"
43model = "gpt-4o-mini"
44
45[server]
46socket_path = "~/.engram/engram.sock"
47reindex_interval_secs = 3600
48
49[hnsw]
50max_connections = 16
51ef_construction = 200
52ef_search = 40
53dimension = 1024
54
55[consolidation]
56stale_days = 90
57min_score = 0.3
58"#;
59
60#[derive(Debug)]
61pub enum McpCommand {
62    Node { script_path: PathBuf },
63    Npx,
64}
65
66pub fn execute() -> Result<(), CoreError> {
67    let cwd = std::env::current_dir()
68        .map_err(|error| CoreError::InitFailed(format!("cwd unavailable: {error}")))?;
69    let home = home_directory()
70        .ok_or_else(|| CoreError::InitFailed("HOME environment variable not set".into()))?;
71    try_interactive_wizard();
72    execute_with_dirs(&cwd, Path::new(&home))
73}
74
75pub fn execute_with_dirs(project_dir: &Path, home_dir: &Path) -> Result<(), CoreError> {
76    validate_socket_path(project_dir)?;
77
78    let home_config_path = home_dir.join(ENGRAM_DIRECTORY).join(CONFIG_FILENAME);
79    let mcp_command = resolve_mcp_server_command();
80
81    if home_config_path.exists() {
82        create_engram_directory(project_dir)?;
83        initialize_database(project_dir)?;
84        write_agent_instructions(project_dir)?;
85        write_mcp_json(project_dir, &mcp_command)?;
86        write_gitignore(project_dir)?;
87        return Ok(());
88    }
89
90    create_engram_directory(project_dir)?;
91    write_default_config_to_home(home_dir)?;
92    write_mcp_json(project_dir, &mcp_command)?;
93    initialize_database(project_dir)?;
94    write_agent_instructions(project_dir)?;
95    write_gitignore(project_dir)?;
96    print_completion_info(project_dir);
97    Ok(())
98}
99
100fn try_interactive_wizard() {
101    if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
102        return;
103    }
104    let _ = std::process::Command::new("engram-tui")
105        .arg("init")
106        .status();
107}
108
109fn validate_socket_path(project_dir: &Path) -> Result<(), CoreError> {
110    let socket_path = project_dir.join(ENGRAM_DIRECTORY).join(SOCKET_FILENAME);
111    let length = socket_path.as_os_str().as_encoded_bytes().len();
112    if length >= UNIX_SOCKET_PATH_MAX_BYTES {
113        return Err(CoreError::InitFailed(format!(
114            "project path too deep: socket path '{}' is {length} bytes, max {} (UNIX limit)",
115            socket_path.display(),
116            UNIX_SOCKET_PATH_MAX_BYTES - 1
117        )));
118    }
119    Ok(())
120}
121
122fn create_engram_directory(project_dir: &Path) -> Result<(), CoreError> {
123    let target = project_dir.join(ENGRAM_DIRECTORY);
124    fs::create_dir_all(&target).map_err(|error| {
125        CoreError::InitFailed(format!("failed to create {}: {error}", target.display()))
126    })
127}
128
129fn write_default_config_to_home(home_dir: &Path) -> Result<(), CoreError> {
130    let home_engram = home_dir.join(ENGRAM_DIRECTORY);
131    fs::create_dir_all(&home_engram).map_err(|error| {
132        CoreError::InitFailed(format!(
133            "failed to create {}: {error}",
134            home_engram.display()
135        ))
136    })?;
137    let config_path = home_engram.join(CONFIG_FILENAME);
138    if config_path.exists() {
139        return Ok(());
140    }
141    fs::write(&config_path, DEFAULT_CONFIG_TEMPLATE)
142        .map_err(|error| CoreError::InitFailed(format!("failed to write config: {error}")))
143}
144
145fn initialize_database(project_dir: &Path) -> Result<(), CoreError> {
146    let engram_dir = project_dir.join(ENGRAM_DIRECTORY);
147    fs::create_dir_all(&engram_dir).map_err(|error| {
148        CoreError::InitFailed(format!("failed to create database directory: {error}"))
149    })?;
150    let database_path = engram_dir.join(DATABASE_FILENAME);
151    let database_path_str = database_path
152        .to_str()
153        .ok_or_else(|| CoreError::InitFailed("database path is not valid utf-8".into()))?;
154    let _database = Database::open(database_path_str)?;
155    println!("Database initialized at {database_path_str}");
156    Ok(())
157}
158
159fn write_agent_instructions(project_dir: &Path) -> Result<(), CoreError> {
160    let agent_md_path = project_dir.join(ENGRAM_DIRECTORY).join(AGENT_MD_FILENAME);
161    fs::write(&agent_md_path, AGENT_MD_CONTENT)
162        .map_err(|error| CoreError::InitFailed(format!("failed to write AGENT.md: {error}")))?;
163    println!("Agent instructions written to {}", agent_md_path.display());
164    Ok(())
165}
166
167fn resolve_mcp_server_command() -> McpCommand {
168    if let Ok(env_path) = std::env::var("ENGRAM_MCP_SERVER_PATH") {
169        let path = PathBuf::from(&env_path);
170        if path.exists() {
171            return McpCommand::Node { script_path: path };
172        }
173    }
174    if let Some(bin_path) = resolve_engram_bin()
175        && let Some(bin_dir) = bin_path.parent()
176    {
177        let candidate = bin_dir
178            .join("..")
179            .join("mcp-server")
180            .join("dist")
181            .join("index.js");
182        if candidate.exists() {
183            if let Ok(canonical) = candidate.canonicalize() {
184                return McpCommand::Node {
185                    script_path: canonical,
186                };
187            }
188            return McpCommand::Node {
189                script_path: candidate,
190            };
191        }
192    }
193    eprintln!(
194        "warning: could not locate bundled engram mcp-server script; falling back to 'npx {MCP_NPX_PACKAGE}'. \
195         Set ENGRAM_MCP_SERVER_PATH to an absolute path of mcp-server/dist/index.js to silence this warning."
196    );
197    McpCommand::Npx
198}
199
200fn resolve_engram_bin() -> Option<PathBuf> {
201    if let Ok(value) = std::env::var("ENGRAM_BIN") {
202        let candidate = PathBuf::from(value);
203        if candidate.exists() {
204            return Some(candidate);
205        }
206    }
207    let output = std::process::Command::new("which")
208        .arg(DEFAULT_ENGRAM_BIN_NAME)
209        .output()
210        .ok()?;
211    if !output.status.success() {
212        return None;
213    }
214    let stdout = String::from_utf8(output.stdout).ok()?;
215    let trimmed = stdout.trim();
216    if trimmed.is_empty() {
217        return None;
218    }
219    Some(PathBuf::from(trimmed))
220}
221
222fn write_mcp_json(project_dir: &Path, command: &McpCommand) -> Result<(), CoreError> {
223    let (command_name, args): (&str, Vec<String>) = match command {
224        McpCommand::Node { script_path } => {
225            let path_str = script_path
226                .to_str()
227                .ok_or_else(|| CoreError::InitFailed("mcp script path is not valid utf-8".into()))?
228                .to_string();
229            ("node", vec![path_str])
230        }
231        McpCommand::Npx => ("npx", vec![MCP_NPX_PACKAGE.to_string()]),
232    };
233    let engram_bin = resolve_engram_bin();
234    let engram_bin_string = engram_bin.as_ref().and_then(|path| {
235        let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
236        canonical.to_str().map(|value| value.to_string())
237    });
238    let mut server_entry = serde_json::json!({
239        "command": command_name,
240        "args": args,
241    });
242    if let Some(bin_string) = engram_bin_string.as_ref() {
243        server_entry["env"] = serde_json::json!({ "ENGRAM_BIN": bin_string });
244    } else {
245        eprintln!(
246            "warning: could not resolve engram binary; MCP config has no ENGRAM_BIN env — set it manually before starting Claude Code"
247        );
248    }
249    let document = serde_json::json!({
250        "mcpServers": {
251            "engram": server_entry,
252        }
253    });
254    let serialized = serde_json::to_string_pretty(&document)
255        .map_err(|error| CoreError::InitFailed(format!("failed to serialize mcp json: {error}")))?;
256    let mcp_path = project_dir.join(MCP_JSON_FILENAME);
257    fs::write(&mcp_path, serialized)
258        .map_err(|error| CoreError::InitFailed(format!("failed to write .mcp.json: {error}")))?;
259    println!("MCP config written to {}", mcp_path.display());
260    Ok(())
261}
262
263fn write_gitignore(project_dir: &Path) -> Result<(), CoreError> {
264    let gitignore_path = project_dir.join(GITIGNORE_FILENAME);
265    let existing = match fs::read_to_string(&gitignore_path) {
266        Ok(content) => content,
267        Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
268        Err(error) => {
269            return Err(CoreError::InitFailed(format!(
270                "failed to read .gitignore: {error}"
271            )));
272        }
273    };
274    if gitignore_contains_marker(&existing) {
275        return Ok(());
276    }
277    let mut updated = existing;
278    if !updated.is_empty() && !updated.ends_with('\n') {
279        updated.push('\n');
280    }
281    updated.push_str(GITIGNORE_MARKER);
282    updated.push('\n');
283    fs::write(&gitignore_path, updated)
284        .map_err(|error| CoreError::InitFailed(format!("failed to write .gitignore: {error}")))
285}
286
287fn gitignore_contains_marker(content: &str) -> bool {
288    content.lines().any(|line| {
289        line.trim() == GITIGNORE_MARKER.trim_end_matches('/') || line.trim() == GITIGNORE_MARKER
290    })
291}
292
293fn print_completion_info(project_dir: &Path) {
294    println!();
295    println!("Engram initialized at {}", project_dir.display());
296    println!();
297    println!("Set API keys via environment variables:");
298    println!("  export ENGRAM_VOYAGE_API_KEY=your-voyage-key");
299    println!("  export ENGRAM_OPENAI_API_KEY=your-openai-key");
300    println!();
301    println!("MCP config written to .mcp.json; Claude Code will pick it up automatically.");
302    println!("Add to your project's CLAUDE.md:");
303    println!("  Engram memory system: .engram/AGENT.md");
304}