diff --git a/CHANGELOG.md b/CHANGELOG.md index 352a55ea..1279227c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Added +- Interactive configuration wizard via `zeph init` subcommand with 5-step setup (LLM provider, memory, channels, secrets backend, config generation) +- clap-based CLI argument parsing with `--help`, `--version` support +- `Serialize` derive on `Config` and all nested types for TOML generation +- `dialoguer` dependency for interactive terminal prompts - Structured LLM output via `chat_typed()` on `LlmProvider` trait with JSON schema enforcement (#456) - OpenAI/Compatible native `response_format: json_schema` structured output (#457) - Claude structured output via forced tool use pattern (#458) diff --git a/Cargo.lock b/Cargo.lock index 7ae29576..8af10d7e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -123,12 +123,56 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + [[package]] name = "anstyle" version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.101" @@ -875,6 +919,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5caf74d17c3aec5495110c34cc3f78644bfa89af6c8993ed4de2790e49b6499" dependencies = [ "clap_builder", + "clap_derive", ] [[package]] @@ -883,8 +928,22 @@ version = "4.5.59" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "370daa45065b80218950227371916a1633217ae42b2715b2287b606dcd618e24" dependencies = [ + "anstream", "anstyle", "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.116", ] [[package]] @@ -902,6 +961,12 @@ dependencies = [ "cc", ] +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "colored" version = "3.1.1" @@ -1490,6 +1555,19 @@ dependencies = [ "syn 2.0.116", ] +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "shell-words", + "tempfile", + "thiserror 1.0.69", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -3122,6 +3200,12 @@ dependencies = [ "serde", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.10.5" @@ -3919,6 +4003,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "oorandom" version = "11.1.5" @@ -5820,6 +5910,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -8512,6 +8608,8 @@ name = "zeph" version = "0.10.0" dependencies = [ "anyhow", + "clap", + "dialoguer", "opentelemetry", "opentelemetry-otlp", "opentelemetry_sdk", @@ -8519,6 +8617,7 @@ dependencies = [ "tokio", "tokio-stream", "tokio-util", + "toml 1.0.2+spec-1.1.0", "tracing", "tracing-opentelemetry", "tracing-subscriber", diff --git a/Cargo.toml b/Cargo.toml index f18341f4..056e31b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,8 @@ repository = "https://github.com/bug-ops/zeph" [workspace.dependencies] age = { version = "0.11.2", default-features = false } +clap = { version = "4.5", features = ["derive"] } +dialoguer = "0.11" anyhow = "1.0" candle-core = { version = "0.9", default-features = false } candle-nn = { version = "0.9", default-features = false } @@ -126,6 +128,9 @@ stt = ["zeph-llm/stt"] [dependencies] anyhow.workspace = true +clap.workspace = true +dialoguer.workspace = true +toml.workspace = true tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal", "sync", "time"] } tokio-util.workspace = true tracing.workspace = true diff --git a/README.md b/README.md index 317bbf2c..10b88e5c 100644 --- a/README.md +++ b/README.md @@ -23,30 +23,59 @@ Most AI agent frameworks are **token furnaces**. They dump every tool descriptio Zeph takes the opposite approach: **automated context engineering**. Only relevant data enters the context. Everything else is filtered, compressed, or retrieved on demand. The result — dramatically lower costs, faster responses, and an agent that runs on hardware you already have. +## Installation + +```bash +# From source +cargo install --git https://github.com/bug-ops/zeph +``` + +Pre-built binaries for Linux, macOS, and Windows: [GitHub Releases](https://github.com/bug-ops/zeph/releases/latest) · [Docker](https://bug-ops.github.io/zeph/guide/docker.html) + ## Quick Start ```bash -git clone https://github.com/bug-ops/zeph && cd zeph -cargo build --release +# Interactive setup wizard — generates config.toml with provider, memory, and channel settings +zeph init + +# Run the agent +zeph + +# Or with TUI dashboard (requires `tui` feature) +zeph --tui +``` +Manual configuration is also supported: + +```bash # Local models — no API costs ollama pull mistral:7b && ollama pull qwen3-embedding -./target/release/zeph +zeph # Cloud providers -ZEPH_LLM_PROVIDER=claude ZEPH_CLAUDE_API_KEY=sk-ant-... ./target/release/zeph -ZEPH_LLM_PROVIDER=openai ZEPH_OPENAI_API_KEY=sk-... ./target/release/zeph +ZEPH_LLM_PROVIDER=claude ZEPH_CLAUDE_API_KEY=sk-ant-... zeph +ZEPH_LLM_PROVIDER=openai ZEPH_OPENAI_API_KEY=sk-... zeph # Any OpenAI-compatible API (Together AI, Groq, Fireworks, etc.) ZEPH_LLM_PROVIDER=compatible ZEPH_COMPATIBLE_BASE_URL=https://api.together.xyz/v1 \ - ZEPH_COMPATIBLE_API_KEY=... ./target/release/zeph + ZEPH_COMPATIBLE_API_KEY=... zeph ``` -Pre-built binaries for Linux, macOS, and Windows: [GitHub Releases](https://github.com/bug-ops/zeph/releases/latest) · [Docker](https://bug-ops.github.io/zeph/guide/docker.html) - > [!TIP] > Full setup walkthrough: [Installation](https://bug-ops.github.io/zeph/getting-started/installation.html) · [Configuration](https://bug-ops.github.io/zeph/getting-started/configuration.html) · [Secrets management](https://bug-ops.github.io/zeph/guide/vault.html) +## CLI Usage + +``` +zeph Run the agent (default) +zeph init Interactive configuration wizard +zeph init -o path.toml Write generated config to a specific path +zeph --tui Run with TUI dashboard +zeph --config Use a custom config file +zeph --version Print version +zeph --help Show help +``` + ## Automated Context Engineering This is the core idea behind Zeph. Every byte that enters the LLM context window is there because it's **useful for the model** — not because the framework was too lazy to filter it. diff --git a/crates/zeph-core/src/config/types.rs b/crates/zeph-core/src/config/types.rs index 1372e6ab..4792a728 100644 --- a/crates/zeph-core/src/config/types.rs +++ b/crates/zeph-core/src/config/types.rs @@ -1,19 +1,22 @@ use std::collections::HashMap; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use zeph_skills::TrustLevel; use zeph_tools::{AutonomyLevel, ToolsConfig}; use crate::vault::Secret; -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct Config { pub agent: AgentConfig, pub llm: LlmConfig, pub skills: SkillsConfig, pub memory: MemoryConfig, + #[serde(skip_serializing_if = "Option::is_none")] pub telegram: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub discord: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub slack: Option, #[serde(default)] pub tools: ToolsConfig, @@ -49,7 +52,7 @@ fn default_max_tool_iterations() -> usize { 10 } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct AgentConfig { pub name: String, #[serde(default = "default_max_tool_iterations")] @@ -59,7 +62,7 @@ pub struct AgentConfig { } /// LLM provider backend selector. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "lowercase")] pub enum ProviderKind { Ollama, @@ -92,19 +95,24 @@ impl std::fmt::Display for ProviderKind { } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct LlmConfig { pub provider: ProviderKind, pub base_url: String, pub model: String, #[serde(default = "default_embedding_model")] pub embedding_model: String, + #[serde(skip_serializing_if = "Option::is_none")] pub cloud: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub openai: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub candle: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub orchestrator: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub compatible: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub router: Option, pub stt: Option, } @@ -113,7 +121,7 @@ fn default_embedding_model() -> String { "qwen3-embedding".into() } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct SttConfig { #[serde(default = "default_stt_provider")] pub provider: String, @@ -129,13 +137,13 @@ fn default_stt_model() -> String { "whisper-1".into() } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct CloudLlmConfig { pub model: String, pub max_tokens: u32, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct OpenAiConfig { pub base_url: String, pub model: String, @@ -146,7 +154,7 @@ pub struct OpenAiConfig { pub reasoning_effort: Option, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct CompatibleConfig { pub name: String, pub base_url: String, @@ -156,12 +164,12 @@ pub struct CompatibleConfig { pub embedding_model: Option, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct RouterConfig { pub chain: Vec, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct CandleConfig { #[serde(default = "default_candle_source")] pub source: String, @@ -191,7 +199,7 @@ fn default_candle_device() -> String { "cpu".into() } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct GenerationParams { #[serde(default = "default_temperature")] pub temperature: f64, @@ -252,7 +260,7 @@ fn default_repeat_last_n() -> usize { 64 } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct OrchestratorConfig { pub default: String, pub embed: String, @@ -262,7 +270,7 @@ pub struct OrchestratorConfig { pub routes: std::collections::HashMap>, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct OrchestratorProviderConfig { #[serde(rename = "type")] pub provider_type: String, @@ -274,7 +282,7 @@ pub struct OrchestratorProviderConfig { pub device: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct SkillsConfig { pub paths: Vec, #[serde(default = "default_max_active_skills")] @@ -291,7 +299,7 @@ fn default_disambiguation_threshold() -> f32 { 0.05 } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct TrustConfig { #[serde(default = "default_trust_default_level")] pub default_level: TrustLevel, @@ -327,7 +335,7 @@ fn default_max_active_skills() -> usize { 5 } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct LearningConfig { #[serde(default)] pub enabled: bool, @@ -381,7 +389,7 @@ fn default_cooldown_minutes() -> u64 { 60 } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct MemoryConfig { pub sqlite_path: String, pub history_limit: u32, @@ -409,7 +417,7 @@ fn default_qdrant_url() -> String { "http://localhost:6334".into() } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct IndexConfig { #[serde(default)] pub enabled: bool, @@ -493,7 +501,7 @@ fn default_cross_session_score_threshold() -> f32 { 0.35 } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct SemanticConfig { #[serde(default = "default_semantic_enabled")] pub enabled: bool, @@ -532,7 +540,7 @@ fn default_keyword_weight() -> f64 { 0.3 } -#[derive(Clone, Deserialize)] +#[derive(Clone, Deserialize, Serialize)] pub struct TelegramConfig { pub token: Option, #[serde(default)] @@ -548,7 +556,7 @@ impl std::fmt::Debug for TelegramConfig { } } -#[derive(Clone, Deserialize)] +#[derive(Clone, Deserialize, Serialize)] pub struct DiscordConfig { pub token: Option, pub application_id: Option, @@ -580,7 +588,7 @@ fn default_slack_webhook_host() -> String { "127.0.0.1".into() } -#[derive(Clone, Deserialize)] +#[derive(Clone, Deserialize, Serialize)] pub struct SlackConfig { pub bot_token: Option, pub signing_secret: Option, @@ -610,7 +618,7 @@ impl std::fmt::Debug for SlackConfig { } } -#[derive(Deserialize)] +#[derive(Deserialize, Serialize)] pub struct A2aServerConfig { #[serde(default)] pub enabled: bool, @@ -703,7 +711,7 @@ fn default_max_parallel_tools() -> usize { 8 } -#[derive(Debug, Clone, Copy, Deserialize)] +#[derive(Debug, Clone, Copy, Deserialize, Serialize)] pub struct SecurityConfig { #[serde(default = "default_true")] pub redact_secrets: bool, @@ -720,7 +728,7 @@ impl Default for SecurityConfig { } } -#[derive(Debug, Clone, Copy, Deserialize)] +#[derive(Debug, Clone, Copy, Deserialize, Serialize)] pub struct TimeoutConfig { #[serde(default = "default_llm_timeout")] pub llm_seconds: u64, @@ -743,7 +751,7 @@ impl Default for TimeoutConfig { } } -#[derive(Debug, Clone, Default, Deserialize)] +#[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct McpConfig { #[serde(default)] pub servers: Vec, @@ -757,7 +765,7 @@ fn default_max_dynamic_servers() -> usize { 10 } -#[derive(Clone, Deserialize)] +#[derive(Clone, Deserialize, Serialize)] pub struct McpServerConfig { pub id: String, /// Stdio transport: command to spawn. @@ -794,7 +802,7 @@ fn default_mcp_timeout() -> u64 { 30 } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct VaultConfig { #[serde(default = "default_vault_backend")] pub backend: String, @@ -812,7 +820,7 @@ fn default_vault_backend() -> String { "env".into() } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct CostConfig { #[serde(default)] pub enabled: bool, @@ -833,7 +841,7 @@ impl Default for CostConfig { } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct ObservabilityConfig { #[serde(default)] pub exporter: String, @@ -854,7 +862,7 @@ impl Default for ObservabilityConfig { } } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct GatewayConfig { #[serde(default)] pub enabled: bool, @@ -899,7 +907,7 @@ impl Default for GatewayConfig { } } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct DaemonConfig { #[serde(default)] pub enabled: bool, @@ -934,7 +942,7 @@ impl Default for DaemonConfig { } } -#[derive(Debug, Clone, Default, Deserialize)] +#[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct SchedulerConfig { #[serde(default)] pub enabled: bool, @@ -942,13 +950,13 @@ pub struct SchedulerConfig { pub tasks: Vec, } -#[derive(Debug, Clone, Copy, Default, Deserialize)] +#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)] pub struct TuiConfig { #[serde(default)] pub show_source_labels: bool, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct ScheduledTaskConfig { pub name: String, pub cron: String, @@ -967,8 +975,8 @@ pub struct ResolvedSecrets { pub slack_signing_secret: Option, } -impl Config { - pub(crate) fn default() -> Self { +impl Default for Config { + fn default() -> Self { Self { agent: AgentConfig { name: "Zeph".into(), @@ -1028,3 +1036,21 @@ impl Config { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_serialize_roundtrip() { + let config = Config::default(); + let toml_str = toml::to_string_pretty(&config).expect("serialize"); + let back: Config = toml::from_str(&toml_str).expect("deserialize"); + assert_eq!(back.agent.name, config.agent.name); + assert_eq!(back.llm.provider, config.llm.provider); + assert_eq!(back.llm.model, config.llm.model); + assert_eq!(back.memory.sqlite_path, config.memory.sqlite_path); + assert_eq!(back.memory.history_limit, config.memory.history_limit); + assert_eq!(back.vault.backend, config.vault.backend); + } +} diff --git a/crates/zeph-tools/src/config.rs b/crates/zeph-tools/src/config.rs index ad165d24..89b12ab3 100644 --- a/crates/zeph-tools/src/config.rs +++ b/crates/zeph-tools/src/config.rs @@ -1,4 +1,4 @@ -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use crate::permissions::{AutonomyLevel, PermissionPolicy, PermissionsConfig}; @@ -26,7 +26,7 @@ fn default_audit_destination() -> String { } /// Top-level configuration for tool execution. -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct ToolsConfig { #[serde(default = "default_true")] pub enabled: bool, @@ -61,7 +61,7 @@ impl ToolsConfig { } /// Shell-specific configuration: timeout, command blocklist, and allowlist overrides. -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct ShellConfig { #[serde(default = "default_timeout")] pub timeout: u64, @@ -78,7 +78,7 @@ pub struct ShellConfig { } /// Configuration for audit logging of tool executions. -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct AuditConfig { #[serde(default)] pub enabled: bool, @@ -131,7 +131,7 @@ fn default_max_body_bytes() -> usize { } /// Configuration for the web scrape tool. -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct ScrapeConfig { #[serde(default = "default_scrape_timeout")] pub timeout: u64, diff --git a/crates/zeph-tools/src/filter/mod.rs b/crates/zeph-tools/src/filter/mod.rs index 86fa1481..6b6435b1 100644 --- a/crates/zeph-tools/src/filter/mod.rs +++ b/crates/zeph-tools/src/filter/mod.rs @@ -11,7 +11,7 @@ mod test_output; use std::sync::{LazyLock, Mutex}; use regex::Regex; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; pub use self::cargo_build::CargoBuildFilter; pub use self::clippy::ClippyFilter; @@ -269,7 +269,7 @@ fn default_max_diff_lines() -> usize { } /// Configuration for output filters. -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct FilterConfig { #[serde(default = "default_true")] pub enabled: bool, @@ -311,7 +311,7 @@ impl Default for FilterConfig { } } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct TestFilterConfig { #[serde(default = "default_true")] pub enabled: bool, @@ -331,7 +331,7 @@ impl Default for TestFilterConfig { } } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct GitFilterConfig { #[serde(default = "default_true")] pub enabled: bool, @@ -351,7 +351,7 @@ impl Default for GitFilterConfig { } } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct ClippyFilterConfig { #[serde(default = "default_true")] pub enabled: bool, @@ -363,7 +363,7 @@ impl Default for ClippyFilterConfig { } } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct CargoBuildFilterConfig { #[serde(default = "default_true")] pub enabled: bool, @@ -375,7 +375,7 @@ impl Default for CargoBuildFilterConfig { } } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct DirListingFilterConfig { #[serde(default = "default_true")] pub enabled: bool, @@ -387,7 +387,7 @@ impl Default for DirListingFilterConfig { } } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct LogDedupFilterConfig { #[serde(default = "default_true")] pub enabled: bool, @@ -399,7 +399,7 @@ impl Default for LogDedupFilterConfig { } } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct SecurityFilterConfig { #[serde(default = "default_true")] pub enabled: bool, diff --git a/crates/zeph-tools/src/permissions.rs b/crates/zeph-tools/src/permissions.rs index 0757a0f7..14c7bc74 100644 --- a/crates/zeph-tools/src/permissions.rs +++ b/crates/zeph-tools/src/permissions.rs @@ -1,10 +1,10 @@ use std::collections::HashMap; use glob::Pattern; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; /// Tool access level controlling agent autonomy. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "lowercase")] pub enum AutonomyLevel { /// Read-only tools: `file_read`, `file_glob`, `file_grep`, `web_scrape` @@ -17,7 +17,7 @@ pub enum AutonomyLevel { } /// Action a permission rule resolves to. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "lowercase")] pub enum PermissionAction { Allow, @@ -26,7 +26,7 @@ pub enum PermissionAction { } /// Single permission rule: glob `pattern` + action. -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct PermissionRule { pub pattern: String, pub action: PermissionAction, @@ -136,7 +136,7 @@ impl PermissionPolicy { } /// TOML-deserializable permissions config section. -#[derive(Debug, Clone, Deserialize, Default)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct PermissionsConfig { #[serde(flatten)] pub tools: HashMap>, diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 225fbef0..5b506469 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -10,6 +10,7 @@ # Guide +- [CLI Reference](guide/cli.md) - [Skills](guide/skills.md) - [Semantic Memory](guide/semantic-memory.md) - [Context Engineering](guide/context.md) diff --git a/docs/src/getting-started/configuration.md b/docs/src/getting-started/configuration.md index aa9d0f7a..04f5931f 100644 --- a/docs/src/getting-started/configuration.md +++ b/docs/src/getting-started/configuration.md @@ -1,5 +1,27 @@ # Configuration +## Configuration Wizard + +Run `zeph init` to generate a `config.toml` interactively. The wizard walks through five steps: + +1. **LLM Provider** -- select Ollama (local), Claude, OpenAI, or a compatible endpoint. Provide the base URL, model name, and API key as needed. Choose an embedding model (default: `qwen3-embedding`). +2. **Memory** -- set the SQLite database path and optionally enable semantic memory with Qdrant. +3. **Channel** -- pick CLI (default), Telegram, Discord, or Slack. Provide tokens and credentials for the selected channel. +4. **Secrets backend** -- choose `env` (environment variables) or `age` (encrypted file via `~/.zeph/vault.age`). +5. **Review and write** -- inspect the generated TOML, confirm the output path, and save. + +Specify the output path directly: + +```bash +zeph init --output ~/.zeph/config.toml +``` + +If the target file already exists, the wizard asks before overwriting. + +After writing, the wizard prints the environment variables you need to set (API keys, tokens) depending on the chosen secrets backend. + +## Config File Resolution + Zeph loads `config/default.toml` at startup and applies environment variable overrides. The config path can be overridden via CLI argument or environment variable: diff --git a/docs/src/getting-started/installation.md b/docs/src/getting-started/installation.md index 3e8875bc..f34a6208 100644 --- a/docs/src/getting-started/installation.md +++ b/docs/src/getting-started/installation.md @@ -1,6 +1,32 @@ # Installation -Install Zeph from source, pre-built binaries, or Docker. +Install Zeph from source, the install script, pre-built binaries, or Docker. + +## Install Script (recommended) + +Run the one-liner to download and install the latest release: + +```bash +curl -sSf https://raw.githubusercontent.com/bug-ops/zeph/main/install/install.sh | sh +``` + +The script detects your OS and architecture, downloads the binary to `~/.zeph/bin/zeph`, and adds it to your `PATH`. Override the install directory with `ZEPH_INSTALL_DIR`: + +```bash +ZEPH_INSTALL_DIR=/usr/local/bin curl -sSf https://raw.githubusercontent.com/bug-ops/zeph/main/install/install.sh | sh +``` + +Install a specific version: + +```bash +curl -sSf https://raw.githubusercontent.com/bug-ops/zeph/main/install/install.sh | sh -s -- --version v0.10.0 +``` + +After installation, run the configuration wizard: + +```bash +zeph init +``` ## From Source @@ -10,7 +36,7 @@ cd zeph cargo build --release ``` -The binary is produced at `target/release/zeph`. +The binary is produced at `target/release/zeph`. Run `zeph init` to generate a config file. ## Pre-built Binaries diff --git a/docs/src/guide/cli.md b/docs/src/guide/cli.md new file mode 100644 index 00000000..f96d9d40 --- /dev/null +++ b/docs/src/guide/cli.md @@ -0,0 +1,57 @@ +# CLI Reference + +Zeph uses [clap](https://docs.rs/clap) for argument parsing. Run `zeph --help` for the full synopsis. + +## Usage + +``` +zeph [OPTIONS] [COMMAND] +``` + +## Subcommands + +| Command | Description | +|---------|-------------| +| `init` | Interactive configuration wizard (see [Configuration](../getting-started/configuration.md)) | + +When no subcommand is given, Zeph starts the agent loop. + +### `zeph init` + +Generate a `config.toml` through a guided wizard. + +```bash +zeph init # write to ./config.toml (default) +zeph init --output ~/.zeph/config.toml # specify output path +``` + +Options: + +| Flag | Short | Description | +|------|-------|-------------| +| `--output ` | `-o` | Output path for the generated config file | + +## Global Options + +| Flag | Description | +|------|-------------| +| `--tui` | Run with the TUI dashboard (requires the `tui` feature) | +| `--config ` | Path to a TOML config file (overrides `ZEPH_CONFIG` env var) | +| `--version` | Print version and exit | +| `--help` | Print help and exit | + +## Examples + +```bash +# Start the agent with defaults +zeph + +# Start with a custom config +zeph --config ~/.zeph/config.toml + +# Start with TUI dashboard +zeph --tui + +# Generate a new config interactively +zeph init +``` diff --git a/src/init.rs b/src/init.rs new file mode 100644 index 00000000..4e43d092 --- /dev/null +++ b/src/init.rs @@ -0,0 +1,441 @@ +use std::path::PathBuf; + +use dialoguer::{Confirm, Input, Password, Select}; +use zeph_core::config::{ + CompatibleConfig, Config, DiscordConfig, LlmConfig, MemoryConfig, ProviderKind, SemanticConfig, + SlackConfig, TelegramConfig, VaultConfig, +}; + +#[derive(Default)] +#[cfg_attr(test, derive(Clone))] +pub(crate) struct WizardState { + pub(crate) provider: Option, + pub(crate) base_url: Option, + pub(crate) model: Option, + pub(crate) embedding_model: Option, + pub(crate) api_key: Option, + pub(crate) compatible_name: Option, + pub(crate) sqlite_path: Option, + pub(crate) qdrant_url: Option, + pub(crate) semantic_enabled: bool, + pub(crate) channel: ChannelChoice, + pub(crate) telegram_token: Option, + pub(crate) telegram_users: Vec, + pub(crate) discord_token: Option, + pub(crate) discord_app_id: Option, + pub(crate) slack_bot_token: Option, + pub(crate) slack_signing_secret: Option, + pub(crate) vault_backend: String, +} + +#[derive(Default, Clone, Copy)] +pub(crate) enum ChannelChoice { + #[default] + Cli, + Telegram, + Discord, + Slack, +} + +pub fn run(output: Option) -> anyhow::Result<()> { + println!("zeph init - configuration wizard\n"); + + let mut state = WizardState { + vault_backend: "env".into(), + semantic_enabled: true, + ..WizardState::default() + }; + + step_llm(&mut state)?; + step_memory(&mut state)?; + step_channel(&mut state)?; + step_vault(&mut state)?; + step_review_and_write(&state, output)?; + + Ok(()) +} + +fn step_llm(state: &mut WizardState) -> anyhow::Result<()> { + println!("== Step 1/5: LLM Provider ==\n"); + + let providers = [ + "Ollama (local)", + "Claude (API)", + "OpenAI (API)", + "Compatible (custom)", + ]; + let selection = Select::new() + .with_prompt("Select LLM provider") + .items(&providers) + .default(0) + .interact()?; + + match selection { + 0 => { + state.provider = Some(ProviderKind::Ollama); + state.base_url = Some( + Input::new() + .with_prompt("Ollama base URL") + .default("http://localhost:11434".into()) + .interact_text()?, + ); + state.model = Some( + Input::new() + .with_prompt("Model name") + .default("mistral:7b".into()) + .interact_text()?, + ); + } + 1 => { + state.provider = Some(ProviderKind::Claude); + state.api_key = Some(Password::new().with_prompt("Claude API key").interact()?); + state.model = Some( + Input::new() + .with_prompt("Model name") + .default("claude-sonnet-4-5-20250929".into()) + .interact_text()?, + ); + } + 2 => { + state.provider = Some(ProviderKind::OpenAi); + state.api_key = Some(Password::new().with_prompt("OpenAI API key").interact()?); + state.base_url = Some( + Input::new() + .with_prompt("Base URL") + .default("https://api.openai.com/v1".into()) + .interact_text()?, + ); + state.model = Some( + Input::new() + .with_prompt("Model name") + .default("gpt-4o".into()) + .interact_text()?, + ); + } + 3 => { + state.provider = Some(ProviderKind::Compatible); + state.compatible_name = + Some(Input::new().with_prompt("Provider name").interact_text()?); + state.base_url = Some(Input::new().with_prompt("Base URL").interact_text()?); + state.model = Some(Input::new().with_prompt("Model name").interact_text()?); + state.api_key = Some( + Password::new() + .with_prompt("API key (leave empty if none)") + .allow_empty_password(true) + .interact()?, + ); + } + _ => unreachable!(), + } + + state.embedding_model = Some( + Input::new() + .with_prompt("Embedding model") + .default("qwen3-embedding".into()) + .interact_text()?, + ); + + println!(); + Ok(()) +} + +fn step_memory(state: &mut WizardState) -> anyhow::Result<()> { + println!("== Step 2/5: Memory ==\n"); + + state.sqlite_path = Some( + Input::new() + .with_prompt("SQLite database path") + .default("./data/zeph.db".into()) + .interact_text()?, + ); + + state.semantic_enabled = Confirm::new() + .with_prompt("Enable semantic memory (requires Qdrant)?") + .default(true) + .interact()?; + + if state.semantic_enabled { + state.qdrant_url = Some( + Input::new() + .with_prompt("Qdrant URL") + .default("http://localhost:6334".into()) + .interact_text()?, + ); + } + + println!(); + Ok(()) +} + +fn step_channel(state: &mut WizardState) -> anyhow::Result<()> { + println!("== Step 3/5: Channel ==\n"); + + let channels = ["CLI only (default)", "Telegram", "Discord", "Slack"]; + let selection = Select::new() + .with_prompt("Select communication channel") + .items(&channels) + .default(0) + .interact()?; + + match selection { + 0 => state.channel = ChannelChoice::Cli, + 1 => { + state.channel = ChannelChoice::Telegram; + state.telegram_token = Some( + Password::new() + .with_prompt("Telegram bot token") + .interact()?, + ); + let users: String = Input::new() + .with_prompt("Allowed usernames (comma-separated)") + .default(String::new()) + .interact_text()?; + state.telegram_users = users + .split(',') + .map(|s| s.trim().to_owned()) + .filter(|s| !s.is_empty()) + .collect(); + } + 2 => { + state.channel = ChannelChoice::Discord; + state.discord_token = Some( + Password::new() + .with_prompt("Discord bot token") + .interact()?, + ); + state.discord_app_id = Some( + Input::new() + .with_prompt("Discord application ID") + .interact_text()?, + ); + } + 3 => { + state.channel = ChannelChoice::Slack; + state.slack_bot_token = + Some(Password::new().with_prompt("Slack bot token").interact()?); + state.slack_signing_secret = Some( + Password::new() + .with_prompt("Slack signing secret") + .interact()?, + ); + } + _ => unreachable!(), + } + + println!(); + Ok(()) +} + +fn step_vault(state: &mut WizardState) -> anyhow::Result<()> { + println!("== Step 4/5: Secrets Backend ==\n"); + + let backends = ["env (environment variables)", "age (encrypted file)"]; + let selection = Select::new() + .with_prompt("Select secrets backend") + .items(&backends) + .default(0) + .interact()?; + + state.vault_backend = match selection { + 0 => "env".into(), + 1 => "age".into(), + _ => unreachable!(), + }; + + println!(); + Ok(()) +} + +pub(crate) fn build_config(state: &WizardState) -> Config { + let mut config = Config::default(); + let provider = state.provider.unwrap_or(ProviderKind::Ollama); + + config.llm = LlmConfig { + provider, + base_url: state + .base_url + .clone() + .unwrap_or_else(|| "http://localhost:11434".into()), + model: state.model.clone().unwrap_or_else(|| "mistral:7b".into()), + embedding_model: state + .embedding_model + .clone() + .unwrap_or_else(|| "qwen3-embedding".into()), + cloud: None, + openai: None, + candle: None, + orchestrator: None, + compatible: if provider == ProviderKind::Compatible { + Some(vec![CompatibleConfig { + name: state + .compatible_name + .clone() + .unwrap_or_else(|| "custom".into()), + base_url: state.base_url.clone().unwrap_or_default(), + model: state.model.clone().unwrap_or_default(), + max_tokens: 4096, + embedding_model: None, + }]) + } else { + None + }, + router: None, + stt: None, + }; + + config.memory = MemoryConfig { + sqlite_path: state + .sqlite_path + .clone() + .unwrap_or_else(|| "./data/zeph.db".into()), + qdrant_url: state + .qdrant_url + .clone() + .unwrap_or_else(|| "http://localhost:6334".into()), + semantic: SemanticConfig { + enabled: state.semantic_enabled, + ..SemanticConfig::default() + }, + ..config.memory + }; + + match state.channel { + ChannelChoice::Cli => {} + ChannelChoice::Telegram => { + config.telegram = Some(TelegramConfig { + token: None, + allowed_users: state.telegram_users.clone(), + }); + } + ChannelChoice::Discord => { + config.discord = Some(DiscordConfig { + token: None, + application_id: state.discord_app_id.clone(), + allowed_user_ids: vec![], + allowed_role_ids: vec![], + allowed_channel_ids: vec![], + }); + } + ChannelChoice::Slack => { + config.slack = Some(SlackConfig { + bot_token: None, + signing_secret: None, + webhook_host: "127.0.0.1".into(), + port: 3000, + allowed_user_ids: vec![], + allowed_channel_ids: vec![], + }); + } + } + + config.vault = VaultConfig { + backend: state.vault_backend.clone(), + }; + + config +} + +fn step_review_and_write(state: &WizardState, output: Option) -> anyhow::Result<()> { + println!("== Step 5/5: Review & Write ==\n"); + + let config = build_config(state); + let toml_str = toml::to_string_pretty(&config)?; + + println!("--- Generated config ---"); + println!("{toml_str}"); + println!("------------------------\n"); + + let default_path = PathBuf::from("config.toml"); + let path = output.unwrap_or_else(|| { + Input::new() + .with_prompt("Write config to") + .default(default_path.display().to_string()) + .interact_text() + .map(PathBuf::from) + .unwrap_or(default_path) + }); + + if path.exists() { + let overwrite = Confirm::new() + .with_prompt(format!("{} already exists. Overwrite?", path.display())) + .default(false) + .interact()?; + if !overwrite { + println!("Aborted."); + return Ok(()); + } + } + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&path, &toml_str)?; + println!("Config written to {}", path.display()); + + print_secrets_instructions(state); + print_next_steps(&path); + + Ok(()) +} + +fn print_secrets_instructions(state: &WizardState) { + let mut secrets = Vec::new(); + + if let Some(ref key) = state.api_key + && !key.is_empty() + { + let var = match state.provider { + Some(ProviderKind::Claude) => "ZEPH_CLAUDE_API_KEY", + Some(ProviderKind::OpenAi) => "ZEPH_OPENAI_API_KEY", + Some(ProviderKind::Compatible) => { + let name = state + .compatible_name + .as_deref() + .unwrap_or("custom") + .to_uppercase(); + // Leak is fine here: runs once at CLI exit + let var = format!("ZEPH_COMPATIBLE_{name}_API_KEY"); + secrets.push(var); + secrets.last().map(String::as_str).unwrap_or_default() + } + _ => "", + }; + if !var.is_empty() && !secrets.iter().any(|s| s == var) { + secrets.push(var.to_owned()); + } + } + + if state.telegram_token.is_some() { + secrets.push("ZEPH_TELEGRAM_TOKEN".into()); + } + if state.discord_token.is_some() { + secrets.push("ZEPH_DISCORD_TOKEN".into()); + } + if state.slack_bot_token.is_some() { + secrets.push("ZEPH_SLACK_BOT_TOKEN".into()); + } + if state.slack_signing_secret.is_some() { + secrets.push("ZEPH_SLACK_SIGNING_SECRET".into()); + } + + if secrets.is_empty() { + return; + } + + if state.vault_backend == "env" { + println!("\nAdd the following to your shell profile:"); + for var in &secrets { + println!(" export {var}=\"\""); + } + } else { + println!("\nStore secrets via: zeph vault set "); + println!("Required keys: {}", secrets.join(", ")); + } +} + +fn print_next_steps(path: &std::path::Path) { + println!("\nNext steps:"); + println!(" 1. Set required environment variables (see above)"); + println!(" 2. Run: zeph --config {}", path.display()); + println!(" 3. Or with TUI: zeph --tui --config {}", path.display()); +} diff --git a/src/main.rs b/src/main.rs index 219b7362..0b82ca3a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,11 @@ +mod init; + use std::path::PathBuf; #[cfg(feature = "tui")] use std::time::Duration; +use clap::{Parser, Subcommand}; + #[cfg(any(feature = "a2a", feature = "tui"))] use tokio::sync::watch; use zeph_channels::AnyChannel; @@ -103,9 +107,46 @@ impl Channel for AppChannel { } } +#[derive(Parser)] +#[command( + name = "zeph", + version, + about = "Lightweight AI agent with hybrid inference" +)] +struct Cli { + /// Run with TUI dashboard + #[arg(long)] + tui: bool, + + /// Path to config file + #[arg(long, value_name = "PATH")] + config: Option, + + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +enum Command { + /// Interactive configuration wizard + Init { + /// Output path for generated config + #[arg(long, short, value_name = "PATH")] + output: Option, + }, +} + #[tokio::main] #[allow(clippy::too_many_lines)] async fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + + if let Some(Command::Init { output }) = cli.command { + return init::run(output); + } + + // --config and --tui are also read by resolve_config_path() and + // is_tui_requested() from std::env::args(), so no extra plumbing needed. #[cfg(feature = "tui")] let tui_active = is_tui_requested(); #[cfg(feature = "tui")] @@ -988,6 +1029,157 @@ mod tests { ); } + #[test] + fn cli_parse_no_args_runs_default() { + let cli = Cli::try_parse_from(["zeph"]).unwrap(); + assert!(cli.command.is_none()); + assert!(!cli.tui); + assert!(cli.config.is_none()); + } + + #[test] + fn cli_parse_init_subcommand() { + let cli = Cli::try_parse_from(["zeph", "init"]).unwrap(); + assert!(matches!(cli.command, Some(Command::Init { output: None }))); + } + + #[test] + fn cli_parse_init_with_output() { + let cli = Cli::try_parse_from(["zeph", "init", "-o", "/tmp/cfg.toml"]).unwrap(); + match cli.command { + Some(Command::Init { output }) => { + assert_eq!(output.unwrap(), PathBuf::from("/tmp/cfg.toml")); + } + _ => panic!("expected Init subcommand"), + } + } + + #[test] + fn cli_parse_tui_flag() { + let cli = Cli::try_parse_from(["zeph", "--tui"]).unwrap(); + assert!(cli.tui); + } + + #[test] + fn cli_parse_config_flag() { + let cli = Cli::try_parse_from(["zeph", "--config", "my.toml"]).unwrap(); + assert_eq!(cli.config.unwrap(), PathBuf::from("my.toml")); + } + + #[test] + fn build_config_ollama_defaults() { + use crate::init::{WizardState, build_config}; + + let state = WizardState { + provider: Some(ProviderKind::Ollama), + base_url: Some("http://localhost:11434".into()), + model: Some("llama3".into()), + ..WizardState::default() + }; + let config = build_config(&state); + assert_eq!(config.llm.provider, ProviderKind::Ollama); + assert_eq!(config.llm.model, "llama3"); + assert!(config.telegram.is_none()); + } + + #[test] + fn build_config_claude_provider() { + use crate::init::{WizardState, build_config}; + + let state = WizardState { + provider: Some(ProviderKind::Claude), + model: Some("claude-sonnet-4-5-20250929".into()), + api_key: Some("sk-test".into()), + ..WizardState::default() + }; + let config = build_config(&state); + assert_eq!(config.llm.provider, ProviderKind::Claude); + } + + #[test] + fn build_config_compatible_provider() { + use crate::init::{WizardState, build_config}; + + let state = WizardState { + provider: Some(ProviderKind::Compatible), + compatible_name: Some("groq".into()), + base_url: Some("https://api.groq.com/v1".into()), + model: Some("mixtral".into()), + ..WizardState::default() + }; + let config = build_config(&state); + assert!(config.llm.compatible.is_some()); + let compat = config.llm.compatible.unwrap(); + assert_eq!(compat[0].name, "groq"); + } + + #[test] + fn build_config_telegram_channel() { + use crate::init::{ChannelChoice, WizardState, build_config}; + + let state = WizardState { + channel: ChannelChoice::Telegram, + telegram_token: Some("tok".into()), + telegram_users: vec!["alice".into()], + ..WizardState::default() + }; + let config = build_config(&state); + assert!(config.telegram.is_some()); + assert_eq!(config.telegram.unwrap().allowed_users, vec!["alice"]); + } + + #[test] + fn build_config_discord_channel() { + use crate::init::{ChannelChoice, WizardState, build_config}; + + let state = WizardState { + channel: ChannelChoice::Discord, + discord_token: Some("tok".into()), + discord_app_id: Some("123".into()), + ..WizardState::default() + }; + let config = build_config(&state); + assert!(config.discord.is_some()); + } + + #[test] + fn build_config_slack_channel() { + use crate::init::{ChannelChoice, WizardState, build_config}; + + let state = WizardState { + channel: ChannelChoice::Slack, + slack_bot_token: Some("xoxb".into()), + slack_signing_secret: Some("secret".into()), + ..WizardState::default() + }; + let config = build_config(&state); + assert!(config.slack.is_some()); + } + + #[test] + fn build_config_vault_age() { + use crate::init::{WizardState, build_config}; + + let state = WizardState { + vault_backend: "age".into(), + ..WizardState::default() + }; + let config = build_config(&state); + assert_eq!(config.vault.backend, "age"); + } + + #[test] + fn build_config_semantic_disabled() { + use crate::init::{WizardState, build_config}; + + let state = WizardState { + semantic_enabled: false, + ..WizardState::default() + }; + let config = build_config(&state); + assert!(!config.memory.semantic.enabled); + } + #[cfg(feature = "a2a")] #[test] fn agent_task_processor_construction() {