diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7f4a6a3..0959391 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,22 +18,22 @@ jobs: include: - os: ubuntu-latest target: x86_64-unknown-linux-gnu - archive: eullm-agent-linux-x64.tar.gz + binary: eullm-agent-linux-x64 - os: ubuntu-latest target: aarch64-unknown-linux-gnu - archive: eullm-agent-linux-arm64.tar.gz + binary: eullm-agent-linux-arm64 use_cross: true - os: macos-latest target: aarch64-apple-darwin - archive: eullm-agent-macos-arm64.tar.gz + binary: eullm-agent-macos-arm64 # macOS Intel x64 disabled: macos-13 runners have very low availability # and queue for 15-30+ min. Re-enable when runner availability improves. # - os: macos-13 # target: x86_64-apple-darwin - # archive: eullm-agent-macos-x64.tar.gz + # binary: eullm-agent-macos-x64 - os: windows-latest target: x86_64-pc-windows-msvc - archive: eullm-agent-windows-x64.zip + binary: eullm-agent-windows-x64.exe steps: - uses: actions/checkout@v4 @@ -63,25 +63,23 @@ jobs: - name: Package (Unix) if: runner.os != 'Windows' run: | - cp target/${{ matrix.target }}/release/eullm-agent ./eullm-agent - tar czf ${{ matrix.archive }} eullm-agent - sha256sum ${{ matrix.archive }} > ${{ matrix.archive }}.sha256 + cp target/${{ matrix.target }}/release/eullm-agent ${{ matrix.binary }} + sha256sum ${{ matrix.binary }} > ${{ matrix.binary }}.sha256 - name: Package (Windows) if: runner.os == 'Windows' shell: pwsh run: | - Copy-Item "target\${{ matrix.target }}\release\eullm-agent.exe" . - Compress-Archive -Path "eullm-agent.exe" -DestinationPath "${{ matrix.archive }}" - $hash = (Get-FileHash "${{ matrix.archive }}" -Algorithm SHA256).Hash.ToLower() - "$hash ${{ matrix.archive }}" | Out-File -Encoding ascii "${{ matrix.archive }}.sha256" + Copy-Item "target\${{ matrix.target }}\release\eullm-agent.exe" "${{ matrix.binary }}" + $hash = (Get-FileHash "${{ matrix.binary }}" -Algorithm SHA256).Hash.ToLower() + "$hash ${{ matrix.binary }}" | Out-File -Encoding ascii "${{ matrix.binary }}.sha256" - uses: actions/upload-artifact@v4 with: - name: ${{ matrix.archive }} + name: ${{ matrix.binary }} path: | - ${{ matrix.archive }} - ${{ matrix.archive }}.sha256 + ${{ matrix.binary }} + ${{ matrix.binary }}.sha256 - name: Notify Telegram on build failure if: failure() diff --git a/src/config.rs b/src/config.rs index e693d50..eab58d7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5,7 +5,7 @@ use std::path::PathBuf; #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Config { pub provider: ProviderConfig, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub telegram: Option, #[serde(default)] pub tools: ToolsConfig, @@ -19,7 +19,7 @@ fn default_max_iterations() -> usize { 20 } -fn default_system_prompt() -> String { +pub fn default_system_prompt() -> String { "You are EULLM Agent, an autonomous task executor running on EU infrastructure. \ Think step by step. Use the available tools to complete tasks accurately and efficiently. \ When the task is complete, summarise the result clearly." @@ -43,6 +43,7 @@ pub enum ProviderConfig { api_key: String, #[serde(default = "default_openai_model")] model: String, + #[serde(skip_serializing_if = "Option::is_none")] base_url: Option, }, } @@ -60,7 +61,7 @@ fn default_openai_model() -> String { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct TelegramConfig { pub token: String, - #[serde(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub allowed_users: Vec, } diff --git a/src/main.rs b/src/main.rs index 144e565..b5f67b0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ mod config; mod llm; mod telegram; mod tools; +mod wizard; use config::{Config, ProviderConfig}; use llm::{ @@ -54,8 +55,13 @@ async fn main() -> Result<()> { .init(); let cli = Cli::parse(); - let config = Config::load(&cli.config) - .with_context(|| format!("Cannot load config from {:?}", cli.config))?; + + let config = if cli.config.exists() { + Config::load(&cli.config) + .with_context(|| format!("Cannot load config from {:?}", cli.config))? + } else { + wizard::run(&cli.config)? + }; let llm: Arc = match &config.provider { ProviderConfig::Eullm { base_url, model } => { diff --git a/src/wizard.rs b/src/wizard.rs new file mode 100644 index 0000000..cf4d4ee --- /dev/null +++ b/src/wizard.rs @@ -0,0 +1,115 @@ +use anyhow::Result; +use std::io::{self, Write}; + +use crate::config::{default_system_prompt, Config, ProviderConfig, TelegramConfig, ToolsConfig}; + +pub fn run(config_path: &std::path::Path) -> Result { + println!("\nNo config file found at '{}'.", config_path.display()); + println!("Answer a few questions to get started.\n"); + + let provider = pick_provider()?; + let telegram = pick_telegram()?; + + let config = Config { + provider, + telegram, + tools: ToolsConfig::default(), + max_iterations: 20, + system_prompt: default_system_prompt(), + }; + + let yaml = serde_yaml::to_string(&config)?; + std::fs::write(config_path, &yaml)?; + println!("\nConfig saved to '{}'. Starting...\n", config_path.display()); + + Ok(config) +} + +fn pick_provider() -> Result { + println!("Choose a provider:"); + println!(" 1. Anthropic (Claude) [default]"); + println!(" 2. EULLM Engine (local)"); + println!(" 3. OpenAI / compatible"); + println!(); + + loop { + let choice = prompt("Provider", "1")?; + match choice.trim() { + "1" | "" => return setup_anthropic(), + "2" => return setup_eullm(), + "3" => return setup_openai(), + _ => println!("Enter 1, 2 or 3."), + } + } +} + +fn setup_anthropic() -> Result { + println!(); + let api_key = prompt_required("Anthropic API key")?; + let model = prompt("Model", "claude-sonnet-4-6")?; + Ok(ProviderConfig::Anthropic { api_key, model }) +} + +fn setup_eullm() -> Result { + println!(); + let base_url = prompt("Server URL", "http://localhost:11434")?; + let model = prompt_required("Model name")?; + Ok(ProviderConfig::Eullm { base_url, model }) +} + +fn setup_openai() -> Result { + println!(); + let api_key = prompt_required("OpenAI API key")?; + let model = prompt("Model", "gpt-4o")?; + let base_url_raw = prompt("Base URL (blank = api.openai.com)", "")?; + Ok(ProviderConfig::OpenAI { + api_key, + model, + base_url: if base_url_raw.is_empty() { None } else { Some(base_url_raw) }, + }) +} + +fn pick_telegram() -> Result> { + println!(); + let enable = prompt("Enable Telegram bot? [y/N]", "N")?; + let enable_lower = enable.to_lowercase(); + if !matches!(enable_lower.trim(), "y" | "yes") { + return Ok(None); + } + let token = prompt_required("Bot token")?; + let ids_raw = prompt("Allowed user IDs, comma-separated (blank = allow all)", "")?; + let allowed_users = ids_raw + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .filter_map(|s| s.parse::().ok()) + .collect(); + Ok(Some(TelegramConfig { token, allowed_users })) +} + +fn prompt(label: &str, default: &str) -> Result { + if default.is_empty() { + print!("{}: ", label); + } else { + print!("{} [{}]: ", label, default); + } + io::stdout().flush()?; + let mut buf = String::new(); + io::stdin().read_line(&mut buf)?; + let val = buf.trim().to_string(); + Ok(if val.is_empty() { default.to_string() } else { val }) +} + +fn prompt_required(label: &str) -> Result { + loop { + print!("{}: ", label); + io::stdout().flush()?; + let mut buf = String::new(); + io::stdin().read_line(&mut buf)?; + let val = buf.trim().to_string(); + if !val.is_empty() { + return Ok(val); + } + println!(" (required — please enter a value)"); + } +}