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). No database.path setting is needed for normal use; the runtime
25# resolves it from the project marker, and ENGRAM_DB_PATH env var overrides
26# both.
27
28[embedding]
29provider = "voyage"
30model = "voyage-4"
31dimension = 1024
32# hyde_threshold: opt-in HyDE. 0 = disabled (default). N>0 = enable HyDE
33# when the query has fewer than N words. HyDE adds ~1.5s latency on cache
34# miss but improves recall on terse queries; cache is keyed by the
35# original query so repeated calls are instant.
36hyde_threshold = 0
37
38[llm]
39provider = "openai"
40model = "gpt-4o-mini"
41
42[server]
43# socket_path is optional and only used as a last-resort fallback when no
44# project .engram/ marker is found and ENGRAM_SOCKET_PATH is not set.
45# Uncomment to provide an explicit fallback location:
46# socket_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
60const PROJECT_LOCAL_CONFIG_TEMPLATE: &str = r#"# Project-local engram config. Every value here is deep-merged over the
61# global ~/.engram/engram.toml — uncomment only the fields this project
62# needs to override; anything left commented is inherited from global.
63# Effective priority: ENGRAM_* env vars > this file > global config.
64#
65# Provider API keys are intentionally absent here — they are always taken
66# from the global config and can never be set or overridden per project.
67
68# [embedding]
69# model = "voyage-4"
70# dimension = 1024
71# output_dimension = 1024
72# hyde_threshold = 0
73
74# [llm]
75# model = "gpt-4o-mini"
76
77# [server]
78# reindex_interval_secs = 3600
79
80# [hnsw]
81# max_connections = 16
82# ef_construction = 200
83# ef_search = 40
84# dimension = 1024
85
86# [consolidation]
87# stale_days = 90
88# min_score = 0.3
89"#;
90
91#[derive(Debug)]
92pub enum McpCommand {
93    Node { script_path: PathBuf },
94    Npx,
95}
96
97pub fn execute() -> Result<(), CoreError> {
98    let cwd = std::env::current_dir()
99        .map_err(|error| CoreError::InitFailed(format!("cwd unavailable: {error}")))?;
100    let home = home_directory()
101        .ok_or_else(|| CoreError::InitFailed("HOME environment variable not set".into()))?;
102    try_interactive_wizard();
103    execute_with_dirs(&cwd, Path::new(&home))
104}
105
106pub fn execute_with_dirs(project_dir: &Path, home_dir: &Path) -> Result<(), CoreError> {
107    validate_socket_path(project_dir)?;
108
109    let home_config_path = home_dir.join(ENGRAM_DIRECTORY).join(CONFIG_FILENAME);
110    let mcp_command = resolve_mcp_server_command();
111
112    if home_config_path.exists() {
113        create_engram_directory(project_dir)?;
114        write_project_local_config(project_dir)?;
115        initialize_database(project_dir)?;
116        write_agent_instructions(project_dir)?;
117        write_mcp_json(project_dir, &mcp_command)?;
118        write_gitignore(project_dir)?;
119        return Ok(());
120    }
121
122    create_engram_directory(project_dir)?;
123    write_project_local_config(project_dir)?;
124    write_default_config_to_home(home_dir)?;
125    write_mcp_json(project_dir, &mcp_command)?;
126    initialize_database(project_dir)?;
127    write_agent_instructions(project_dir)?;
128    write_gitignore(project_dir)?;
129    print_completion_info(project_dir);
130    Ok(())
131}
132
133fn try_interactive_wizard() {
134    if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
135        return;
136    }
137    let _ = std::process::Command::new("engram-tui")
138        .arg("init")
139        .status();
140}
141
142fn validate_socket_path(project_dir: &Path) -> Result<(), CoreError> {
143    let socket_path = project_dir.join(ENGRAM_DIRECTORY).join(SOCKET_FILENAME);
144    let length = socket_path.as_os_str().as_encoded_bytes().len();
145    if length >= UNIX_SOCKET_PATH_MAX_BYTES {
146        return Err(CoreError::InitFailed(format!(
147            "project path too deep: socket path '{}' is {length} bytes, max {} (UNIX limit)",
148            socket_path.display(),
149            UNIX_SOCKET_PATH_MAX_BYTES - 1
150        )));
151    }
152    Ok(())
153}
154
155fn create_engram_directory(project_dir: &Path) -> Result<(), CoreError> {
156    let target = project_dir.join(ENGRAM_DIRECTORY);
157    fs::create_dir_all(&target).map_err(|error| {
158        CoreError::InitFailed(format!("failed to create {}: {error}", target.display()))
159    })
160}
161
162fn write_project_local_config(project_dir: &Path) -> Result<(), CoreError> {
163    let config_path = project_dir.join(ENGRAM_DIRECTORY).join(CONFIG_FILENAME);
164    if config_path.exists() {
165        return Ok(());
166    }
167    fs::write(&config_path, PROJECT_LOCAL_CONFIG_TEMPLATE).map_err(|error| {
168        CoreError::InitFailed(format!(
169            "failed to write {}: {error}",
170            config_path.display()
171        ))
172    })
173}
174
175fn write_default_config_to_home(home_dir: &Path) -> Result<(), CoreError> {
176    let home_engram = home_dir.join(ENGRAM_DIRECTORY);
177    fs::create_dir_all(&home_engram).map_err(|error| {
178        CoreError::InitFailed(format!(
179            "failed to create {}: {error}",
180            home_engram.display()
181        ))
182    })?;
183    let config_path = home_engram.join(CONFIG_FILENAME);
184    if config_path.exists() {
185        return Ok(());
186    }
187    fs::write(&config_path, DEFAULT_CONFIG_TEMPLATE)
188        .map_err(|error| CoreError::InitFailed(format!("failed to write config: {error}")))
189}
190
191fn initialize_database(project_dir: &Path) -> Result<(), CoreError> {
192    let engram_dir = project_dir.join(ENGRAM_DIRECTORY);
193    fs::create_dir_all(&engram_dir).map_err(|error| {
194        CoreError::InitFailed(format!("failed to create database directory: {error}"))
195    })?;
196    let database_path = engram_dir.join(DATABASE_FILENAME);
197    let database_path_str = database_path
198        .to_str()
199        .ok_or_else(|| CoreError::InitFailed("database path is not valid utf-8".into()))?;
200    let _database = Database::open(database_path_str)?;
201    println!("Database initialized at {database_path_str}");
202    Ok(())
203}
204
205fn write_agent_instructions(project_dir: &Path) -> Result<(), CoreError> {
206    let agent_md_path = project_dir.join(ENGRAM_DIRECTORY).join(AGENT_MD_FILENAME);
207    fs::write(&agent_md_path, AGENT_MD_CONTENT)
208        .map_err(|error| CoreError::InitFailed(format!("failed to write AGENT.md: {error}")))?;
209    println!("Agent instructions written to {}", agent_md_path.display());
210    Ok(())
211}
212
213fn resolve_mcp_server_command() -> McpCommand {
214    if let Ok(env_path) = std::env::var("ENGRAM_MCP_SERVER_PATH") {
215        let path = PathBuf::from(&env_path);
216        if path.exists() {
217            return McpCommand::Node { script_path: path };
218        }
219    }
220    if let Some(bin_path) = resolve_engram_bin()
221        && let Some(bin_dir) = bin_path.parent()
222    {
223        let candidate = bin_dir
224            .join("..")
225            .join("mcp-server")
226            .join("dist")
227            .join("index.js");
228        if candidate.exists() {
229            if let Ok(canonical) = candidate.canonicalize() {
230                return McpCommand::Node {
231                    script_path: canonical,
232                };
233            }
234            return McpCommand::Node {
235                script_path: candidate,
236            };
237        }
238    }
239    eprintln!(
240        "warning: could not locate bundled engram mcp-server script; falling back to 'npx {MCP_NPX_PACKAGE}'. \
241         Set ENGRAM_MCP_SERVER_PATH to an absolute path of mcp-server/dist/index.js to silence this warning."
242    );
243    McpCommand::Npx
244}
245
246fn resolve_engram_bin() -> Option<PathBuf> {
247    if let Ok(value) = std::env::var("ENGRAM_BIN") {
248        let candidate = PathBuf::from(value);
249        if candidate.exists() {
250            return Some(candidate);
251        }
252    }
253    let output = std::process::Command::new("which")
254        .arg(DEFAULT_ENGRAM_BIN_NAME)
255        .output()
256        .ok()?;
257    if !output.status.success() {
258        return None;
259    }
260    let stdout = String::from_utf8(output.stdout).ok()?;
261    let trimmed = stdout.trim();
262    if trimmed.is_empty() {
263        return None;
264    }
265    Some(PathBuf::from(trimmed))
266}
267
268fn write_mcp_json(project_dir: &Path, command: &McpCommand) -> Result<(), CoreError> {
269    let (command_name, args): (&str, Vec<String>) = match command {
270        McpCommand::Node { script_path } => {
271            let path_str = script_path
272                .to_str()
273                .ok_or_else(|| CoreError::InitFailed("mcp script path is not valid utf-8".into()))?
274                .to_string();
275            ("node", vec![path_str])
276        }
277        McpCommand::Npx => ("npx", vec![MCP_NPX_PACKAGE.to_string()]),
278    };
279    let engram_bin = resolve_engram_bin();
280    let engram_bin_string = engram_bin.as_ref().and_then(|path| {
281        let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
282        canonical.to_str().map(|value| value.to_string())
283    });
284    let mut server_entry = serde_json::json!({
285        "command": command_name,
286        "args": args,
287    });
288    if let Some(bin_string) = engram_bin_string.as_ref() {
289        server_entry["env"] = serde_json::json!({ "ENGRAM_BIN": bin_string });
290    } else {
291        eprintln!(
292            "warning: could not resolve engram binary; MCP config has no ENGRAM_BIN env — set it manually before starting Claude Code"
293        );
294    }
295    let document = serde_json::json!({
296        "mcpServers": {
297            "engram": server_entry,
298        }
299    });
300    let serialized = serde_json::to_string_pretty(&document)
301        .map_err(|error| CoreError::InitFailed(format!("failed to serialize mcp json: {error}")))?;
302    let mcp_path = project_dir.join(MCP_JSON_FILENAME);
303    fs::write(&mcp_path, serialized)
304        .map_err(|error| CoreError::InitFailed(format!("failed to write .mcp.json: {error}")))?;
305    println!("MCP config written to {}", mcp_path.display());
306    Ok(())
307}
308
309fn write_gitignore(project_dir: &Path) -> Result<(), CoreError> {
310    let gitignore_path = project_dir.join(GITIGNORE_FILENAME);
311    let existing = match fs::read_to_string(&gitignore_path) {
312        Ok(content) => content,
313        Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
314        Err(error) => {
315            return Err(CoreError::InitFailed(format!(
316                "failed to read .gitignore: {error}"
317            )));
318        }
319    };
320    if gitignore_contains_marker(&existing) {
321        return Ok(());
322    }
323    let mut updated = existing;
324    if !updated.is_empty() && !updated.ends_with('\n') {
325        updated.push('\n');
326    }
327    updated.push_str(GITIGNORE_MARKER);
328    updated.push('\n');
329    fs::write(&gitignore_path, updated)
330        .map_err(|error| CoreError::InitFailed(format!("failed to write .gitignore: {error}")))
331}
332
333fn gitignore_contains_marker(content: &str) -> bool {
334    content.lines().any(|line| {
335        line.trim() == GITIGNORE_MARKER.trim_end_matches('/') || line.trim() == GITIGNORE_MARKER
336    })
337}
338
339fn print_completion_info(project_dir: &Path) {
340    println!();
341    println!("Engram initialized at {}", project_dir.display());
342    println!();
343    println!("Set API keys via environment variables:");
344    println!("  export ENGRAM_VOYAGE_API_KEY=your-voyage-key");
345    println!("  export ENGRAM_OPENAI_API_KEY=your-openai-key");
346    println!();
347    println!("MCP config written to .mcp.json; Claude Code will pick it up automatically.");
348    println!("Add to your project's CLAUDE.md:");
349    println!("  Engram memory system: .engram/AGENT.md");
350}