Skip to main content

zeroclaw_runtime/integrations/
mod.rs

1pub mod platform;
2pub mod registry;
3
4use anyhow::Result;
5use zeroclaw_config::schema::Config;
6
7/// Integration status
8///
9/// Two states only: an integration is either configured (`Active`) or it
10/// exists in the schema but isn't configured (`Available`). There is no
11/// "coming soon" state — if it is not real, it does not get listed.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
13pub enum IntegrationStatus {
14    /// Fully implemented and ready to use
15    Available,
16    /// Configured and active
17    Active,
18}
19
20/// Integration category
21#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
22pub enum IntegrationCategory {
23    Chat,
24    AiModel,
25    ToolsAutomation,
26    Platform,
27}
28
29impl IntegrationCategory {
30    pub fn label(self) -> &'static str {
31        match self {
32            Self::Chat => "Chat Providers",
33            Self::AiModel => "AI Models",
34            Self::ToolsAutomation => "Tools & Automation",
35            Self::Platform => "Platforms",
36        }
37    }
38
39    pub fn all() -> &'static [Self] {
40        &[
41            Self::Chat,
42            Self::AiModel,
43            Self::ToolsAutomation,
44            Self::Platform,
45        ]
46    }
47}
48
49/// A registered integration. The `status` is computed against a
50/// specific `&Config` at construction time (see
51/// `registry::all_integrations`). `name` and `description` are owned
52/// strings so the schema-derived path can build them at runtime from
53/// the `ChannelsConfig` field set.
54pub struct IntegrationEntry {
55    pub name: String,
56    pub description: String,
57    pub category: IntegrationCategory,
58    pub status: IntegrationStatus,
59}
60
61/// Handle the `integrations` CLI command
62pub fn show_integration_info(config: &Config, name: &str) -> Result<()> {
63    let entries = registry::all_integrations(config);
64    let name_lower = name.to_lowercase();
65
66    let Some(entry) = entries.iter().find(|e| e.name.to_lowercase() == name_lower) else {
67        anyhow::bail!(
68            "Unknown integration: {name}. Check README for supported integrations or run `zeroclaw quickstart` to configure a model provider, then `zeroclaw config set channels.<name>.<field>=<value>` for channels."
69        );
70    };
71
72    let (icon, label) = match entry.status {
73        IntegrationStatus::Active => ("✅", "Active"),
74        IntegrationStatus::Available => ("⚪", "Available"),
75    };
76
77    println!();
78    println!(
79        "  {} {} — {}",
80        icon,
81        console::style(&entry.name).white().bold(),
82        entry.description
83    );
84    println!("  Category: {}", entry.category.label());
85    println!("  Status:   {label}");
86    println!();
87
88    // Setup hints. Channel-specific steps that are not yet covered by a
89    // standalone book walkthrough stay here so `zeroclaw integration info
90    // <name>` keeps producing actionable output. The Chat-category catch-all
91    // points operators at the per-channel config keys.
92    match entry.name.as_str() {
93        "Telegram" => {
94            println!("  Setup:");
95            println!("    1. Message @BotFather on Telegram");
96            println!("    2. Create a bot and copy the token");
97            println!("    3. Run: zeroclaw config set channels.telegram.default.bot-token <token>");
98            println!("    4. Start: zeroclaw channel start");
99        }
100        "Discord" => {
101            println!("  Setup:");
102            println!("    1. Go to https://discord.com/developers/applications");
103            println!("    2. Create app → Bot → Copy token");
104            println!("    3. Enable MESSAGE CONTENT intent");
105            println!("    4. Run: zeroclaw config set channels.discord.default.bot-token <token>");
106        }
107        "Slack" => {
108            println!("  Setup:");
109            println!("    1. Go to https://api.slack.com/apps");
110            println!("    2. Create app → Bot Token Scopes → Install");
111            println!("    3. Run: zeroclaw config set channels.slack.default.bot-token <token>");
112        }
113        "iMessage" => {
114            println!("  Setup (macOS only):");
115            println!("    Uses AppleScript bridge to send/receive iMessages.");
116            println!("    Requires Full Disk Access in System Settings → Privacy.");
117        }
118        "OpenRouter" => {
119            println!("  Setup:");
120            println!("    1. Get API key at https://openrouter.ai/keys");
121            println!("    2. Run: zeroclaw quickstart --model-provider openrouter --api-key <key>");
122            println!("    Access 200+ models with one key.");
123        }
124        "Ollama" => {
125            println!("  Setup:");
126            println!("    1. Install: brew install ollama");
127            println!("    2. Pull a model: ollama pull llama3");
128            println!("    3. Set model_provider to 'ollama' in config.toml");
129        }
130        "GitHub" => {
131            println!("  Setup:");
132            println!("    1. Create a personal access token at https://github.com/settings/tokens");
133            println!("    2. Add to config: [integrations.github] token = \"ghp_...\"");
134        }
135        "Browser" => {
136            println!("  Built-in:");
137            println!("    ZeroClaw can control Chrome/Chromium for web tasks.");
138            println!("    Uses headless browser automation.");
139        }
140        "Cron" => {
141            println!("  Built-in:");
142            println!("    Schedule tasks in ~/.zeroclaw/workspace/cron/");
143            println!("    Run: zeroclaw cron list");
144        }
145        "Weather" => {
146            println!("  Built-in:");
147            println!("    Fetches live conditions from wttr.in, no API key required.");
148            println!("    Supports city names, IATA airport codes, GPS coordinates,");
149            println!("    postal/zip codes, and Unicode location names.");
150        }
151        _ if entry.category == IntegrationCategory::Chat => {
152            println!("  Setup:");
153            println!("    Run: zeroclaw config set channels.<name>.<field>=<value>");
154            println!("    (see docs/book/src/channels/overview.md for the per-channel field list)");
155        }
156        _ => {}
157    }
158
159    println!();
160    Ok(())
161}
162
163#[cfg(all(test, zeroclaw_root_crate))]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn integration_category_all_includes_every_variant_once() {
169        let all = IntegrationCategory::all();
170        assert_eq!(all.len(), 4);
171
172        let labels: Vec<&str> = all.iter().map(|cat| cat.label()).collect();
173        assert!(labels.contains(&"Chat Providers"));
174        assert!(labels.contains(&"AI Models"));
175        assert!(labels.contains(&"Tools & Automation"));
176        assert!(labels.contains(&"Platforms"));
177    }
178
179    #[test]
180    fn handle_command_info_is_case_insensitive_for_known_integrations() {
181        let config = Config::default();
182        let first_name = registry::all_integrations(&config)
183            .first()
184            .expect("registry should define at least one integration")
185            .name
186            .to_lowercase();
187
188        let result = handle_command(
189            crate::IntegrationCommands::Info { name: first_name },
190            &config,
191        );
192
193        assert!(result.is_ok());
194    }
195
196    #[test]
197    fn handle_command_info_returns_error_for_unknown_integration() {
198        let config = Config::default();
199        let result = handle_command(
200            crate::IntegrationCommands::Info {
201                name: "definitely-not-a-real-integration".into(),
202            },
203            &config,
204        );
205
206        assert!(result.is_err());
207        let err = result.unwrap_err().to_string();
208        assert!(err.contains("Unknown integration"));
209    }
210}