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