Skip to content

Commit e0df7b9

Browse files
authored
feat: interactive setup wizard (memoria init -i) and improved install script (#41)
## What type of PR is this? - [x] feat (new feature) ## Which issue(s) this PR fixes N/A — new feature ## What this PR does / why we need it ### Interactive setup wizard (`memoria init -i`) Adds a guided interactive mode to `memoria init` that walks users through configuration step by step: 1. **AI Tool** — multi-select (Kiro, Cursor, Claude Code) 2. **Database** — host/port/user/password/database fields with defaults 3. **Embedding service** — SiliconFlow (recommended) / OpenAI / Ollama / Custom 4. **Confirm** — summary review before writing config Key behaviors: - `-i` flag enters interactive mode; without it, original flag-based CLI is unchanged - Non-first-run: reads existing `mcp.json` and uses current values as defaults - API key input is hidden (no echo) with masked display of existing values (`sk-zpk...eky`) - SiliconFlow is recommended as default embedding provider (free tier, BAAI/bge-m3 1024d) ### Improved install script Rewrites `scripts/install.sh` following starship/rustup patterns: - Default install to `/usr/local/bin` with automatic `sudo` elevation when needed - PATH detection with shell-specific config hints (bash/zsh/fish) - Confirmation prompt before install (`-y` to skip for CI) - Colored output with status indicators (✓/!/x) - POSIX `sh` compatible (was `bash`) - Recommends `memoria init -i` after installation ### Changes | File | Change | |------|--------| | `memoria/crates/memoria-cli/src/main.rs` | Interactive wizard, `--tool` now optional, `-i` flag | | `memoria/crates/memoria-cli/Cargo.toml` | Add `libc` (unix, for terminal echo control) | | `scripts/install.sh` | Rewrite with sudo/PATH/confirm/colors |
1 parent fb38d6c commit e0df7b9

5 files changed

Lines changed: 533 additions & 59 deletions

File tree

.kiro/settings/mcp.json.example

Lines changed: 0 additions & 13 deletions
This file was deleted.

memoria/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

memoria/crates/memoria-cli/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,6 @@ tracing-subscriber = { workspace = true }
3030
tower-http = { workspace = true }
3131
sqlx = { workspace = true }
3232
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "blocking"] }
33+
34+
[target.'cfg(unix)'.dependencies]
35+
libc = "0.2"

memoria/crates/memoria-cli/src/main.rs

Lines changed: 329 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -110,11 +110,14 @@ enum Commands {
110110
#[arg(long, env = "MCP_PORT", default_value = "8200")]
111111
mcp_port: u16,
112112
},
113-
/// Write MCP config + steering rules
113+
/// Write MCP config + steering rules (-i for interactive wizard)
114114
Init {
115-
/// AI tool to configure (required)
116-
#[arg(long, required = true, value_name = "kiro|cursor|claude")]
115+
/// AI tool to configure
116+
#[arg(long, value_name = "kiro|cursor|claude")]
117117
tool: Vec<ToolName>,
118+
/// Interactive setup wizard
119+
#[arg(short = 'i', long)]
120+
interactive: bool,
118121
#[arg(long)]
119122
db_url: Option<String>,
120123
#[arg(long)]
@@ -489,6 +492,316 @@ fn configure_claude(project_dir: &Path, entry: &serde_json::Value, _force: bool)
489492
results
490493
}
491494

495+
// ── Interactive wizard ─────────────────────────────────────────────────────────
496+
497+
/// Existing config parsed from mcp.json for use as defaults.
498+
struct ExistingConfig {
499+
tools: Vec<ToolName>,
500+
db_host: String,
501+
db_port: String,
502+
db_user: String,
503+
db_pass: String,
504+
db_name: String,
505+
emb_provider: String,
506+
emb_base_url: String,
507+
emb_api_key: String,
508+
emb_model: String,
509+
emb_dim: String,
510+
}
511+
512+
impl Default for ExistingConfig {
513+
fn default() -> Self {
514+
Self {
515+
tools: vec![],
516+
db_host: "localhost".into(), db_port: "6001".into(),
517+
db_user: "root".into(), db_pass: "111".into(), db_name: "memoria".into(),
518+
emb_provider: String::new(), emb_base_url: String::new(),
519+
emb_api_key: String::new(), emb_model: String::new(), emb_dim: String::new(),
520+
}
521+
}
522+
}
523+
524+
fn load_existing_config(project_dir: &Path) -> ExistingConfig {
525+
let mut cfg = ExistingConfig::default();
526+
let candidates = [
527+
("kiro", ".kiro/settings/mcp.json"),
528+
("cursor", ".cursor/mcp.json"),
529+
("claude", ".mcp.json"),
530+
];
531+
let mut found_entry: Option<serde_json::Value> = None;
532+
for (tool, path) in &candidates {
533+
let full = project_dir.join(path);
534+
if let Ok(content) = std::fs::read_to_string(&full) {
535+
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
536+
if json.get("mcpServers").and_then(|s| s.get(MCP_KEY)).is_some() {
537+
match *tool {
538+
"kiro" => cfg.tools.push(ToolName::Kiro),
539+
"cursor" => cfg.tools.push(ToolName::Cursor),
540+
"claude" => cfg.tools.push(ToolName::Claude),
541+
_ => {}
542+
}
543+
if found_entry.is_none() {
544+
found_entry = Some(json["mcpServers"][MCP_KEY].clone());
545+
}
546+
}
547+
}
548+
}
549+
}
550+
if let Some(entry) = &found_entry {
551+
// Parse db_url from args: mysql://user:pass@host:port/db
552+
if let Some(args) = entry["args"].as_array() {
553+
for i in 0..args.len() {
554+
if args[i].as_str() == Some("--db-url") {
555+
if let Some(url) = args.get(i + 1).and_then(|v| v.as_str()) {
556+
if let Some(rest) = url.strip_prefix("mysql://") {
557+
if let Some((userpass, hostdb)) = rest.split_once('@') {
558+
let (u, p) = userpass.split_once(':').unwrap_or((userpass, ""));
559+
cfg.db_user = u.to_string();
560+
cfg.db_pass = p.to_string();
561+
if let Some((hostport, db)) = hostdb.split_once('/') {
562+
cfg.db_name = db.to_string();
563+
let (h, port) = hostport.split_once(':').unwrap_or((hostport, "6001"));
564+
cfg.db_host = h.to_string();
565+
cfg.db_port = port.to_string();
566+
}
567+
}
568+
}
569+
}
570+
}
571+
}
572+
}
573+
if let Some(env) = entry["env"].as_object() {
574+
let get = |k: &str| env.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string();
575+
cfg.emb_provider = get("EMBEDDING_PROVIDER");
576+
cfg.emb_base_url = get("EMBEDDING_BASE_URL");
577+
cfg.emb_api_key = get("EMBEDDING_API_KEY");
578+
cfg.emb_model = get("EMBEDDING_MODEL");
579+
cfg.emb_dim = get("EMBEDDING_DIM");
580+
}
581+
}
582+
cfg
583+
}
584+
585+
fn mask_key(key: &str) -> String {
586+
if key.len() <= 9 { return "*".repeat(key.len()); }
587+
format!("{}...{}", &key[..6], &key[key.len()-3..])
588+
}
589+
590+
fn prompt(label: &str, default: &str) -> String {
591+
use std::io::{self, Write};
592+
if default.is_empty() {
593+
print!(" {}: ", label);
594+
} else {
595+
print!(" {} [{}]: ", label, default);
596+
}
597+
io::stdout().flush().ok();
598+
let mut buf = String::new();
599+
io::stdin().read_line(&mut buf).unwrap_or(0);
600+
let val = buf.trim().to_string();
601+
if val.is_empty() { default.to_string() } else { val }
602+
}
603+
604+
fn prompt_secret(label: &str, existing: &str) -> String {
605+
use std::io::{self, Write};
606+
if existing.is_empty() {
607+
print!(" {}: ", label);
608+
} else {
609+
print!(" {} [{}]: ", label, mask_key(existing));
610+
}
611+
io::stdout().flush().ok();
612+
let val = read_password_line();
613+
println!();
614+
if val.is_empty() { existing.to_string() } else { val }
615+
}
616+
617+
fn read_password_line() -> String {
618+
#[cfg(unix)]
619+
{
620+
unsafe {
621+
let mut termios: libc::termios = std::mem::zeroed();
622+
libc::tcgetattr(0, &mut termios);
623+
let old = termios;
624+
termios.c_lflag &= !libc::ECHO;
625+
libc::tcsetattr(0, libc::TCSANOW, &termios);
626+
let mut buf = String::new();
627+
std::io::stdin().read_line(&mut buf).ok();
628+
libc::tcsetattr(0, libc::TCSANOW, &old);
629+
buf.trim().to_string()
630+
}
631+
}
632+
#[cfg(not(unix))]
633+
{
634+
let mut buf = String::new();
635+
std::io::stdin().read_line(&mut buf).ok();
636+
buf.trim().to_string()
637+
}
638+
}
639+
640+
fn prompt_multi_choice(label: &str, options: &[&str], defaults: &[bool]) -> Vec<bool> {
641+
use std::io::{self, Write};
642+
println!("\n {} (comma-separated, e.g. 1,2)", label);
643+
println!();
644+
for (i, opt) in options.iter().enumerate() {
645+
let mark = if defaults.get(i).copied().unwrap_or(false) { "*" } else { " " };
646+
println!(" [{}]{} {}", i + 1, mark, opt);
647+
}
648+
println!();
649+
print!(" > ");
650+
io::stdout().flush().ok();
651+
let mut buf = String::new();
652+
io::stdin().read_line(&mut buf).unwrap_or(0);
653+
let input = buf.trim();
654+
if input.is_empty() { return defaults.to_vec(); }
655+
let mut result = vec![false; options.len()];
656+
for part in input.split(',') {
657+
if let Ok(n) = part.trim().parse::<usize>() {
658+
if n >= 1 && n <= options.len() { result[n - 1] = true; }
659+
}
660+
}
661+
if result.iter().all(|&v| !v) { defaults.to_vec() } else { result }
662+
}
663+
664+
fn prompt_choice(label: &str, options: &[&str], default: usize) -> usize {
665+
use std::io::{self, Write};
666+
println!("\n {}", label);
667+
println!();
668+
for (i, opt) in options.iter().enumerate() {
669+
for (li, line) in opt.lines().enumerate() {
670+
if li == 0 {
671+
let mark = if i == default { ">" } else { " " };
672+
println!(" {} [{}] {}", mark, i + 1, line);
673+
} else {
674+
println!(" {}", line);
675+
}
676+
}
677+
}
678+
println!();
679+
print!(" > ");
680+
io::stdout().flush().ok();
681+
let mut buf = String::new();
682+
io::stdin().read_line(&mut buf).unwrap_or(0);
683+
let input = buf.trim();
684+
if input.is_empty() { return default; }
685+
input.parse::<usize>().unwrap_or(default + 1).saturating_sub(1).min(options.len() - 1)
686+
}
687+
688+
fn prompt_confirm(label: &str) -> bool {
689+
use std::io::{self, Write};
690+
print!("\n {} [Y/n] ", label);
691+
io::stdout().flush().ok();
692+
let mut buf = String::new();
693+
io::stdin().read_line(&mut buf).unwrap_or(0);
694+
!buf.trim().eq_ignore_ascii_case("n")
695+
}
696+
697+
fn cmd_init_interactive(project_dir: &Path, force: bool) {
698+
let existing = load_existing_config(project_dir);
699+
700+
println!("\n Memoria Setup Wizard\n");
701+
println!(" 📁 Project: {}\n", project_dir.display());
702+
703+
// ── Step 1: AI Tool (multi-select) ──
704+
println!("━━━ Step 1: AI Tool ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
705+
let tool_options = ["Kiro", "Cursor", "Claude Code"];
706+
let tool_defaults = if existing.tools.is_empty() {
707+
vec![true, false, false]
708+
} else {
709+
vec![
710+
existing.tools.iter().any(|t| matches!(t, ToolName::Kiro)),
711+
existing.tools.iter().any(|t| matches!(t, ToolName::Cursor)),
712+
existing.tools.iter().any(|t| matches!(t, ToolName::Claude)),
713+
]
714+
};
715+
let selected = prompt_multi_choice("Which AI tools?", &tool_options, &tool_defaults);
716+
let mut tools = vec![];
717+
if selected[0] { tools.push(ToolName::Kiro); }
718+
if selected[1] { tools.push(ToolName::Cursor); }
719+
if selected[2] { tools.push(ToolName::Claude); }
720+
if tools.is_empty() {
721+
eprintln!(" No tool selected, aborting.");
722+
return;
723+
}
724+
725+
// ── Step 2: Database ──
726+
println!("\n━━━ Step 2: Database (MatrixOne) ━━━━━━━━━━━━━━━━━━━━━━");
727+
println!();
728+
let db_host = prompt("Host", &existing.db_host);
729+
let db_port = prompt("Port", &existing.db_port);
730+
let db_user = prompt("User", &existing.db_user);
731+
let db_pass = prompt_secret("Password", &existing.db_pass);
732+
let db_name = prompt("Database", &existing.db_name);
733+
let db_url = format!("mysql://{}:{}@{}:{}/{}", db_user, db_pass, db_host, db_port, db_name);
734+
735+
// ── Step 3: Embedding ──
736+
println!("\n━━━ Step 3: Embedding Service ━━━━━━━━━━━━━━━━━━━━━━━━━");
737+
println!("\n ⚠ Embedding dimension is locked on first startup.");
738+
let emb_options = [
739+
"SiliconFlow (recommended, free tier)\n BAAI/bge-m3, 1024d — https://siliconflow.cn",
740+
"OpenAI\n text-embedding-3-small, 1536d",
741+
"Ollama (local service)\n nomic-embed-text, 768d",
742+
"Custom (OpenAI-compatible endpoint)",
743+
];
744+
let emb_default = match existing.emb_provider.as_str() {
745+
"openai" if existing.emb_base_url.contains("siliconflow") => 0,
746+
"openai" if existing.emb_base_url.contains("localhost:11434") => 2,
747+
"openai" if existing.emb_base_url.is_empty() => 1,
748+
"openai" => 3,
749+
_ => 0,
750+
};
751+
let emb_choice = prompt_choice("Which embedding service?", &emb_options, emb_default);
752+
753+
let (emb_provider, emb_base_url, emb_api_key, emb_model, emb_dim) = match emb_choice {
754+
0 => {
755+
let key = prompt_secret("SiliconFlow API Key", &existing.emb_api_key);
756+
("openai".into(), "https://api.siliconflow.cn/v1".into(), key, "BAAI/bge-m3".into(), "1024".into())
757+
}
758+
1 => {
759+
let key = prompt_secret("OpenAI API Key", &existing.emb_api_key);
760+
("openai".into(), String::new(), key, "text-embedding-3-small".into(), "1536".into())
761+
}
762+
2 => {
763+
let url = prompt("Ollama URL", if existing.emb_base_url.is_empty() { "http://localhost:11434/v1" } else { &existing.emb_base_url });
764+
let model = prompt("Model", if existing.emb_model.is_empty() { "nomic-embed-text" } else { &existing.emb_model });
765+
let dim = prompt("Dimension", if existing.emb_dim.is_empty() { "768" } else { &existing.emb_dim });
766+
("openai".into(), url, String::new(), model, dim)
767+
}
768+
_ => {
769+
let url = prompt("Base URL", &existing.emb_base_url);
770+
let key = prompt_secret("API Key (empty if none)", &existing.emb_api_key);
771+
let model = prompt("Model", &existing.emb_model);
772+
let dim = prompt("Dimension", &existing.emb_dim);
773+
("openai".into(), url, key, model, dim)
774+
}
775+
};
776+
777+
// ── Confirm ──
778+
let tool_names: Vec<&str> = tools.iter().map(|t| match t {
779+
ToolName::Kiro => "Kiro",
780+
ToolName::Cursor => "Cursor",
781+
ToolName::Claude => "Claude Code",
782+
}).collect();
783+
let emb_label = match emb_choice { 0 => "SiliconFlow", 1 => "OpenAI", 2 => "Ollama", _ => "Custom" };
784+
785+
println!("\n━━━ Confirm ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
786+
println!();
787+
println!(" Tools: {}", tool_names.join(", "));
788+
println!(" Database: mysql://{}:***@{}:{}/{}", db_user, db_host, db_port, db_name);
789+
println!(" Embedding: {} / {} / {}d", emb_label, emb_model, emb_dim);
790+
791+
if !prompt_confirm("Proceed?") {
792+
println!(" Aborted.");
793+
return;
794+
}
795+
796+
println!();
797+
cmd_init(
798+
project_dir, tools,
799+
Some(db_url), None, None, "default".into(), force,
800+
Some(emb_provider), Some(emb_model), Some(emb_dim),
801+
Some(emb_api_key), Some(emb_base_url),
802+
);
803+
}
804+
492805
#[allow(clippy::too_many_arguments)]
493806
fn cmd_init(
494807
project_dir: &Path, tools: Vec<ToolName>,
@@ -693,15 +1006,22 @@ fn main() -> Result<()> {
6931006
))?;
6941007
}
6951008
Commands::Init {
696-
tool, db_url, api_url, token, user, force,
1009+
tool, interactive, db_url, api_url, token, user, force,
6971010
embedding_provider, embedding_model, embedding_dim,
6981011
embedding_api_key, embedding_base_url,
6991012
} => {
700-
cmd_init(
701-
&project_dir, tool, db_url, api_url, token, user, force,
702-
embedding_provider, embedding_model, embedding_dim,
703-
embedding_api_key, embedding_base_url,
704-
);
1013+
if interactive {
1014+
cmd_init_interactive(&project_dir, force);
1015+
} else if tool.is_empty() {
1016+
eprintln!("error: --tool is required (or use -i for interactive wizard)");
1017+
std::process::exit(1);
1018+
} else {
1019+
cmd_init(
1020+
&project_dir, tool, db_url, api_url, token, user, force,
1021+
embedding_provider, embedding_model, embedding_dim,
1022+
embedding_api_key, embedding_base_url,
1023+
);
1024+
}
7051025
}
7061026
Commands::Status => cmd_status(&project_dir),
7071027
Commands::UpdateRules => cmd_update_rules(&project_dir),

0 commit comments

Comments
 (0)