ares/cli/
init.rs

1//! Init command implementation
2//!
3//! Scaffolds a new A.R.E.S project with all necessary configuration files.
4
5use super::output::Output;
6use std::fs;
7use std::path::Path;
8
9/// Result of the init operation
10pub enum InitResult {
11    /// Initialization completed successfully
12    Success,
13    /// Project already exists (ares.toml found)
14    AlreadyExists,
15    /// An error occurred during initialization
16    Error(String),
17}
18
19/// Configuration for the init command
20pub struct InitConfig {
21    /// Directory to initialize
22    pub path: std::path::PathBuf,
23    /// Overwrite existing files
24    pub force: bool,
25    /// Create minimal configuration
26    pub minimal: bool,
27    /// Skip creating example TOON files
28    pub no_examples: bool,
29    /// LLM provider to configure (ollama, openai, or both)
30    pub provider: String,
31    /// Host address for the server
32    pub host: String,
33    /// Port for the server
34    pub port: u16,
35}
36
37/// Run the init command
38pub fn run(config: InitConfig, output: &Output) -> InitResult {
39    output.banner();
40    output.header("Initializing A.R.E.S Project");
41
42    let base_path = &config.path;
43
44    // Check if ares.toml already exists
45    let config_path = base_path.join("ares.toml");
46    if config_path.exists() && !config.force {
47        output.warning("ares.toml already exists!");
48        output.hint("Use --force to overwrite existing files");
49        return InitResult::AlreadyExists;
50    }
51
52    // Create directories
53    output.subheader("Creating directories");
54
55    let directories = [
56        "data",
57        "config",
58        "config/agents",
59        "config/models",
60        "config/tools",
61        "config/workflows",
62        "config/mcps",
63    ];
64
65    for dir in &directories {
66        let dir_path = base_path.join(dir);
67        if !dir_path.exists() {
68            if let Err(e) = fs::create_dir_all(&dir_path) {
69                output.error(&format!("Failed to create {}: {}", dir, e));
70                return InitResult::Error(e.to_string());
71            }
72            output.created_dir(dir);
73        } else {
74            output.skipped(dir, "already exists");
75        }
76    }
77
78    // Create ares.toml
79    output.subheader("Creating configuration files");
80
81    let toml_content = generate_ares_toml(&config);
82    if let Err(e) = write_file(&config_path, &toml_content, config.force) {
83        output.error(&format!("Failed to create ares.toml: {}", e));
84        return InitResult::Error(e.to_string());
85    }
86    output.created("config", "ares.toml");
87
88    // Create .env.example
89    let env_example_path = base_path.join(".env.example");
90    let env_content = generate_env_example();
91    if let Err(e) = write_file(&env_example_path, &env_content, config.force) {
92        output.error(&format!("Failed to create .env.example: {}", e));
93        return InitResult::Error(e.to_string());
94    }
95    output.created("env", ".env.example");
96
97    // Create TOON files if not --no-examples
98    if !config.no_examples {
99        output.subheader("Creating example configurations");
100
101        // Models
102        create_model_files(base_path, &config, output);
103
104        // Agents
105        create_agent_files(base_path, &config, output);
106
107        // Tools
108        create_tool_files(base_path, output);
109
110        // Workflows
111        create_workflow_files(base_path, output);
112    }
113
114    // Create .gitignore if it doesn't exist
115    let gitignore_path = base_path.join(".gitignore");
116    if !gitignore_path.exists() {
117        let gitignore_content = generate_gitignore();
118        if let Err(e) = write_file(&gitignore_path, &gitignore_content, false) {
119            output.warning(&format!("Failed to create .gitignore: {}", e));
120        } else {
121            output.created("file", ".gitignore");
122        }
123    }
124
125    // Print completion message and next steps
126    output.complete("A.R.E.S project initialized successfully!");
127
128    output.header("Next Steps");
129    output.newline();
130    output.info("1. Set up environment variables:");
131    output.command("cp .env.example .env");
132    output.command("# Edit .env and set JWT_SECRET (min 32 chars) and API_KEY");
133    output.newline();
134
135    if config.provider == "ollama" {
136        output.info("2. Start Ollama (if not running):");
137        output.command("ollama serve");
138        output.command("ollama pull ministral-3:3b  # or your preferred model");
139        output.newline();
140    }
141
142    output.info("3. Start the server:");
143    output.command("ares-server");
144    output.newline();
145
146    output.hint(&format!(
147        "Server will be available at http://{}:{}",
148        config.host, config.port
149    ));
150    output.hint("API docs available at /swagger-ui/ (requires 'swagger-ui' feature)");
151    output.hint("Build with: cargo build --features swagger-ui");
152
153    InitResult::Success
154}
155
156fn write_file(path: &Path, content: &str, force: bool) -> std::io::Result<()> {
157    if path.exists() && !force {
158        return Ok(()); // Skip existing files unless force is true
159    }
160    fs::write(path, content)
161}
162
163fn generate_ares_toml(config: &InitConfig) -> String {
164    let provider_section = if config.provider == "openai" {
165        r#"# OpenAI API (set OPENAI_API_KEY in .env)
166[providers.openai]
167type = "openai"
168api_key_env = "OPENAI_API_KEY"
169api_base = "https://api.openai.com/v1"
170default_model = "gpt-4o-mini"
171"#
172    } else if config.provider == "both" {
173        r#"# Ollama - Local inference (default)
174[providers.ollama-local]
175type = "ollama"
176base_url = "http://localhost:11434"
177default_model = "ministral-3:3b"
178
179# OpenAI API (set OPENAI_API_KEY in .env)
180[providers.openai]
181type = "openai"
182api_key_env = "OPENAI_API_KEY"
183api_base = "https://api.openai.com/v1"
184default_model = "gpt-4o-mini"
185"#
186    } else {
187        // Default to ollama
188        r#"# Ollama - Local inference (no API key required)
189[providers.ollama-local]
190type = "ollama"
191base_url = "http://localhost:11434"
192default_model = "ministral-3:3b"
193"#
194    };
195
196    let model_provider = if config.provider == "openai" {
197        "openai"
198    } else {
199        "ollama-local"
200    };
201
202    let model_name = if config.provider == "openai" {
203        "gpt-4o-mini"
204    } else {
205        "ministral-3:3b"
206    };
207
208    format!(
209        r#"# A.R.E.S Configuration
210# =====================
211# Generated by: ares-server init
212#
213# REQUIRED: Set these environment variables before starting:
214#   - JWT_SECRET: A secret key for JWT signing (min 32 characters)
215#   - API_KEY: API key for service-to-service authentication
216#
217# Hot Reloading: Changes to this file are automatically detected and applied
218# without restarting the server.
219
220# =============================================================================
221# Server Configuration
222# =============================================================================
223[server]
224host = "{host}"
225port = {port}
226log_level = "info"
227
228# =============================================================================
229# Authentication Configuration
230# =============================================================================
231[auth]
232jwt_secret_env = "JWT_SECRET"
233jwt_access_expiry = 900
234jwt_refresh_expiry = 604800
235api_key_env = "API_KEY"
236
237# =============================================================================
238# Database Configuration
239# =============================================================================
240[database]
241url = "./data/ares.db"
242
243# =============================================================================
244# LLM Providers
245# =============================================================================
246{provider_section}
247# =============================================================================
248# Model Configurations
249# =============================================================================
250[models.fast]
251provider = "{model_provider}"
252model = "{model_name}"
253temperature = 0.7
254max_tokens = 256
255
256[models.balanced]
257provider = "{model_provider}"
258model = "{model_name}"
259temperature = 0.7
260max_tokens = 512
261
262[models.powerful]
263provider = "{model_provider}"
264model = "{model_name}"
265temperature = 0.5
266max_tokens = 1024
267
268# =============================================================================
269# Tools Configuration
270# =============================================================================
271[tools.calculator]
272enabled = true
273description = "Performs basic arithmetic operations (+, -, *, /)"
274timeout_secs = 10
275
276[tools.web_search]
277enabled = true
278description = "Search the web using DuckDuckGo (no API key required)"
279timeout_secs = 30
280
281# =============================================================================
282# Agent Configurations
283# =============================================================================
284[agents.router]
285model = "fast"
286tools = []
287max_tool_iterations = 1
288parallel_tools = false
289system_prompt = """
290You are a routing agent that classifies user queries.
291
292Available agents:
293- orchestrator: General purpose agent for complex queries
294
295Respond with ONLY the agent name (one word, lowercase).
296"""
297
298[agents.orchestrator]
299model = "powerful"
300tools = ["calculator", "web_search"]
301max_tool_iterations = 10
302parallel_tools = false
303system_prompt = """
304You are an orchestrator agent for complex queries.
305
306Capabilities:
307- Break down complex requests
308- Perform web searches
309- Execute calculations
310- Provide comprehensive answers
311
312Be helpful, accurate, and thorough.
313"""
314
315# =============================================================================
316# Workflow Configurations
317# =============================================================================
318[workflows.default]
319entry_agent = "router"
320fallback_agent = "orchestrator"
321max_depth = 3
322max_iterations = 5
323
324# =============================================================================
325# RAG Configuration
326# =============================================================================
327[rag]
328embedding_model = "BAAI/bge-small-en-v1.5"
329chunk_size = 1000
330chunk_overlap = 200
331
332# =============================================================================
333# Dynamic Configuration Paths (TOON Files)
334# =============================================================================
335[config]
336agents_dir = "config/agents"
337workflows_dir = "config/workflows"
338models_dir = "config/models"
339tools_dir = "config/tools"
340mcps_dir = "config/mcps"
341hot_reload = true
342watch_interval_ms = 1000
343"#,
344        host = config.host,
345        port = config.port,
346        provider_section = provider_section,
347        model_provider = model_provider,
348        model_name = model_name,
349    )
350}
351
352fn generate_env_example() -> String {
353    r#"# A.R.E.S Environment Variables
354# =============================
355# Copy this file to .env and fill in the values.
356
357# REQUIRED: JWT secret for authentication (minimum 32 characters)
358# Generate with: openssl rand -base64 32
359JWT_SECRET=change-me-in-production-use-at-least-32-characters
360
361# REQUIRED: API key for service-to-service authentication
362API_KEY=your-api-key-here
363
364# Optional: Logging level (trace, debug, info, warn, error)
365RUST_LOG=info,ares=debug
366
367# Optional: OpenAI API key (if using OpenAI provider)
368# OPENAI_API_KEY=sk-...
369
370# Optional: Turso cloud database (if using remote database)
371# TURSO_URL=libsql://your-db.turso.io
372# TURSO_AUTH_TOKEN=your-token
373
374# Optional: Qdrant vector database
375# QDRANT_URL=http://localhost:6334
376# QDRANT_API_KEY=your-key
377"#
378    .to_string()
379}
380
381fn generate_gitignore() -> String {
382    r#"# A.R.E.S Generated Files
383/data/
384*.db
385*.db-journal
386
387# Environment
388.env
389.env.local
390.env.*.local
391
392# Rust
393/target/
394Cargo.lock
395
396# IDE
397.idea/
398.vscode/
399*.swp
400*.swo
401*~
402
403# OS
404.DS_Store
405Thumbs.db
406"#
407    .to_string()
408}
409
410fn create_model_files(base_path: &Path, config: &InitConfig, output: &Output) {
411    let model_provider = if config.provider == "openai" {
412        "openai"
413    } else {
414        "ollama-local"
415    };
416
417    let model_name = if config.provider == "openai" {
418        "gpt-4o-mini"
419    } else {
420        "ministral-3:3b"
421    };
422
423    let models = [
424        (
425            "fast.toon",
426            format!(
427                r#"name: fast
428provider: {provider}
429model: {model}
430temperature: 0.7
431max_tokens: 256
432"#,
433                provider = model_provider,
434                model = model_name
435            ),
436        ),
437        (
438            "balanced.toon",
439            format!(
440                r#"name: balanced
441provider: {provider}
442model: {model}
443temperature: 0.7
444max_tokens: 512
445"#,
446                provider = model_provider,
447                model = model_name
448            ),
449        ),
450        (
451            "powerful.toon",
452            format!(
453                r#"name: powerful
454provider: {provider}
455model: {model}
456temperature: 0.5
457max_tokens: 1024
458"#,
459                provider = model_provider,
460                model = model_name
461            ),
462        ),
463    ];
464
465    for (filename, content) in &models {
466        let path = base_path.join("config/models").join(filename);
467        if let Err(e) = write_file(&path, content, config.force) {
468            output.warning(&format!("Failed to create {}: {}", filename, e));
469        } else {
470            output.created("model", &format!("config/models/{}", filename));
471        }
472    }
473}
474
475fn create_agent_files(base_path: &Path, config: &InitConfig, output: &Output) {
476    let agents = [
477        (
478            "router.toon",
479            r#"name: router
480model: fast
481max_tool_iterations: 1
482parallel_tools: false
483tools[0]:
484system_prompt: "You are a routing agent that classifies user queries and routes them to the appropriate specialized agent.\n\nAvailable agents:\n- orchestrator: Complex queries requiring multiple steps or research\n\nAnalyze the user's query and respond with ONLY the agent name (lowercase, one word)."
485"#.to_string(),
486        ),
487        (
488            "orchestrator.toon",
489            r#"name: orchestrator
490model: powerful
491max_tool_iterations: 10
492parallel_tools: false
493tools[0]: calculator
494tools[1]: web_search
495system_prompt: "You are an orchestrator agent for complex queries.\n\nCapabilities:\n- Break down complex requests\n- Perform web searches\n- Execute calculations\n- Synthesize information\n\nProvide comprehensive, well-structured answers."
496"#.to_string(),
497        ),
498    ];
499
500    for (filename, content) in &agents {
501        let path = base_path.join("config/agents").join(filename);
502        if let Err(e) = write_file(&path, content, config.force) {
503            output.warning(&format!("Failed to create {}: {}", filename, e));
504        } else {
505            output.created("agent", &format!("config/agents/{}", filename));
506        }
507    }
508}
509
510fn create_tool_files(base_path: &Path, output: &Output) {
511    let tools = [
512        (
513            "calculator.toon",
514            r#"name: calculator
515enabled: true
516description: Performs basic arithmetic operations (+, -, *, /)
517timeout_secs: 10
518"#,
519        ),
520        (
521            "web_search.toon",
522            r#"name: web_search
523enabled: true
524description: Search the web using DuckDuckGo (no API key required)
525timeout_secs: 30
526"#,
527        ),
528    ];
529
530    for (filename, content) in &tools {
531        let path = base_path.join("config/tools").join(filename);
532        if let Err(e) = write_file(&path, content, false) {
533            output.warning(&format!("Failed to create {}: {}", filename, e));
534        } else {
535            output.created("tool", &format!("config/tools/{}", filename));
536        }
537    }
538}
539
540fn create_workflow_files(base_path: &Path, output: &Output) {
541    let workflows = [
542        (
543            "default.toon",
544            r#"name: default
545entry_agent: router
546fallback_agent: orchestrator
547max_depth: 3
548max_iterations: 5
549parallel_subagents: false
550"#,
551        ),
552        (
553            "research.toon",
554            r#"name: research
555entry_agent: orchestrator
556max_depth: 3
557max_iterations: 10
558parallel_subagents: true
559"#,
560        ),
561    ];
562
563    for (filename, content) in &workflows {
564        let path = base_path.join("config/workflows").join(filename);
565        if let Err(e) = write_file(&path, content, false) {
566            output.warning(&format!("Failed to create {}: {}", filename, e));
567        } else {
568            output.created("workflow", &format!("config/workflows/{}", filename));
569        }
570    }
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576    use tempfile::TempDir;
577
578    fn create_test_config(temp_dir: &TempDir) -> InitConfig {
579        InitConfig {
580            path: temp_dir.path().to_path_buf(),
581            force: false,
582            minimal: false,
583            no_examples: false,
584            provider: "ollama".to_string(),
585            host: "127.0.0.1".to_string(),
586            port: 3000,
587        }
588    }
589
590    #[test]
591    fn test_init_config_creation() {
592        let config = InitConfig {
593            path: std::path::PathBuf::from("/tmp/test"),
594            force: false,
595            minimal: false,
596            no_examples: false,
597            provider: "ollama".to_string(),
598            host: "127.0.0.1".to_string(),
599            port: 3000,
600        };
601
602        assert_eq!(config.path, std::path::PathBuf::from("/tmp/test"));
603        assert!(!config.force);
604        assert!(!config.minimal);
605        assert_eq!(config.provider, "ollama");
606        assert_eq!(config.port, 3000);
607    }
608
609    #[test]
610    fn test_init_result_variants() {
611        // Verify enum variants exist and can be matched
612        let success = InitResult::Success;
613        let exists = InitResult::AlreadyExists;
614        let error = InitResult::Error("test error".to_string());
615
616        match success {
617            InitResult::Success => (),
618            _ => panic!("Expected Success"),
619        }
620
621        match exists {
622            InitResult::AlreadyExists => (),
623            _ => panic!("Expected AlreadyExists"),
624        }
625
626        match error {
627            InitResult::Error(msg) => assert_eq!(msg, "test error"),
628            _ => panic!("Expected Error"),
629        }
630    }
631
632    #[test]
633    fn test_generate_ares_toml_ollama() {
634        let config = InitConfig {
635            path: std::path::PathBuf::from("/tmp"),
636            force: false,
637            minimal: false,
638            no_examples: false,
639            provider: "ollama".to_string(),
640            host: "127.0.0.1".to_string(),
641            port: 3000,
642        };
643
644        let content = generate_ares_toml(&config);
645
646        assert!(content.contains("[server]"));
647        assert!(content.contains("host = \"127.0.0.1\""));
648        assert!(content.contains("port = 3000"));
649        assert!(content.contains("[providers.ollama-local]"));
650        assert!(content.contains("type = \"ollama\""));
651    }
652
653    #[test]
654    fn test_generate_ares_toml_openai() {
655        let config = InitConfig {
656            path: std::path::PathBuf::from("/tmp"),
657            force: false,
658            minimal: false,
659            no_examples: false,
660            provider: "openai".to_string(),
661            host: "0.0.0.0".to_string(),
662            port: 8080,
663        };
664
665        let content = generate_ares_toml(&config);
666
667        assert!(content.contains("host = \"0.0.0.0\""));
668        assert!(content.contains("port = 8080"));
669        assert!(content.contains("[providers.openai]"));
670        assert!(content.contains("OPENAI_API_KEY"));
671    }
672
673    #[test]
674    fn test_generate_ares_toml_both() {
675        let config = InitConfig {
676            path: std::path::PathBuf::from("/tmp"),
677            force: false,
678            minimal: false,
679            no_examples: false,
680            provider: "both".to_string(),
681            host: "127.0.0.1".to_string(),
682            port: 3000,
683        };
684
685        let content = generate_ares_toml(&config);
686
687        assert!(content.contains("[providers.ollama-local]"));
688        assert!(content.contains("[providers.openai]"));
689    }
690
691    #[test]
692    fn test_generate_env_example() {
693        let content = generate_env_example();
694
695        assert!(content.contains("JWT_SECRET"));
696        assert!(content.contains("API_KEY"));
697        assert!(content.contains("RUST_LOG"));
698        assert!(content.contains("OPENAI_API_KEY"));
699    }
700
701    #[test]
702    fn test_generate_gitignore() {
703        let content = generate_gitignore();
704
705        assert!(content.contains("/data/"));
706        assert!(content.contains(".env"));
707        assert!(content.contains("/target/"));
708        assert!(content.contains(".DS_Store"));
709    }
710
711    #[test]
712    fn test_write_file_creates_new() {
713        let temp_dir = TempDir::new().expect("Failed to create temp dir");
714        let file_path = temp_dir.path().join("test.txt");
715
716        let result = write_file(&file_path, "test content", false);
717        assert!(result.is_ok());
718        assert!(file_path.exists());
719
720        let content = fs::read_to_string(&file_path).expect("Failed to read file");
721        assert_eq!(content, "test content");
722    }
723
724    #[test]
725    fn test_write_file_skips_existing_without_force() {
726        let temp_dir = TempDir::new().expect("Failed to create temp dir");
727        let file_path = temp_dir.path().join("test.txt");
728
729        // Create initial file
730        fs::write(&file_path, "original").expect("Failed to write");
731
732        // Try to write without force
733        let result = write_file(&file_path, "new content", false);
734        assert!(result.is_ok());
735
736        // Content should remain original
737        let content = fs::read_to_string(&file_path).expect("Failed to read file");
738        assert_eq!(content, "original");
739    }
740
741    #[test]
742    fn test_write_file_overwrites_with_force() {
743        let temp_dir = TempDir::new().expect("Failed to create temp dir");
744        let file_path = temp_dir.path().join("test.txt");
745
746        // Create initial file
747        fs::write(&file_path, "original").expect("Failed to write");
748
749        // Write with force
750        let result = write_file(&file_path, "new content", true);
751        assert!(result.is_ok());
752
753        // Content should be new
754        let content = fs::read_to_string(&file_path).expect("Failed to read file");
755        assert_eq!(content, "new content");
756    }
757
758    #[test]
759    fn test_run_creates_all_files() {
760        let temp_dir = TempDir::new().expect("Failed to create temp dir");
761        let config = create_test_config(&temp_dir);
762        let output = Output::no_color();
763
764        let result = run(config, &output);
765
766        match result {
767            InitResult::Success => (),
768            _ => panic!("Expected Success"),
769        }
770
771        // Check all expected files exist
772        assert!(temp_dir.path().join("ares.toml").exists());
773        assert!(temp_dir.path().join(".env.example").exists());
774        assert!(temp_dir.path().join(".gitignore").exists());
775        assert!(temp_dir.path().join("data").is_dir());
776        assert!(temp_dir.path().join("config/agents").is_dir());
777        assert!(temp_dir.path().join("config/models").is_dir());
778        assert!(temp_dir.path().join("config/tools").is_dir());
779        assert!(temp_dir.path().join("config/workflows").is_dir());
780    }
781
782    #[test]
783    fn test_run_no_examples_skips_toon_files() {
784        let temp_dir = TempDir::new().expect("Failed to create temp dir");
785        let config = InitConfig {
786            path: temp_dir.path().to_path_buf(),
787            force: false,
788            minimal: false,
789            no_examples: true,
790            provider: "ollama".to_string(),
791            host: "127.0.0.1".to_string(),
792            port: 3000,
793        };
794        let output = Output::no_color();
795
796        let result = run(config, &output);
797
798        match result {
799            InitResult::Success => (),
800            _ => panic!("Expected Success"),
801        }
802
803        // ares.toml should exist
804        assert!(temp_dir.path().join("ares.toml").exists());
805
806        // TOON files should not exist
807        assert!(!temp_dir.path().join("config/models/fast.toon").exists());
808        assert!(!temp_dir.path().join("config/agents/router.toon").exists());
809    }
810
811    #[test]
812    fn test_run_already_exists_without_force() {
813        let temp_dir = TempDir::new().expect("Failed to create temp dir");
814
815        // Create initial ares.toml
816        fs::write(temp_dir.path().join("ares.toml"), "existing").expect("Failed to write");
817
818        let config = create_test_config(&temp_dir);
819        let output = Output::no_color();
820
821        let result = run(config, &output);
822
823        match result {
824            InitResult::AlreadyExists => (),
825            _ => panic!("Expected AlreadyExists"),
826        }
827    }
828
829    #[test]
830    fn test_run_force_overwrites() {
831        let temp_dir = TempDir::new().expect("Failed to create temp dir");
832
833        // Create initial ares.toml
834        fs::write(temp_dir.path().join("ares.toml"), "existing").expect("Failed to write");
835
836        let config = InitConfig {
837            path: temp_dir.path().to_path_buf(),
838            force: true,
839            minimal: false,
840            no_examples: true,
841            provider: "ollama".to_string(),
842            host: "127.0.0.1".to_string(),
843            port: 3000,
844        };
845        let output = Output::no_color();
846
847        let result = run(config, &output);
848
849        match result {
850            InitResult::Success => (),
851            _ => panic!("Expected Success"),
852        }
853
854        // ares.toml should be overwritten
855        let content =
856            fs::read_to_string(temp_dir.path().join("ares.toml")).expect("Failed to read");
857        assert!(content.contains("[server]"));
858        assert!(!content.contains("existing"));
859    }
860}