@@ -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) ]
493806fn 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