From 5d9d17bf712cba8431150cc6cd7bc6be7cccf826 Mon Sep 17 00:00:00 2001 From: Reid <61492567+reidliu41@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:43:17 +0800 Subject: [PATCH] feat(cli): add `ironclaw channels list` subcommand (#933) --- FEATURE_PARITY.md | 2 +- src/cli/channels.rs | 281 ++++++++++++++++++ src/cli/mod.rs | 11 + ...li__tests__help_output_without_import.snap | 1 + ...ests__long_help_output_without_import.snap | 1 + src/main.rs | 8 + 6 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 src/cli/channels.rs diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md index 2df78f47..3be0f6ca 100644 --- a/FEATURE_PARITY.md +++ b/FEATURE_PARITY.md @@ -159,7 +159,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | `tui` | ✅ | ✅ | - | Ratatui TUI | | `config` | ✅ | ✅ | - | Read/write config plus validate/path helpers | | `backup` | ✅ | ❌ | P3 | Create/verify local backup archives | -| `channels` | ✅ | ❌ | P2 | Channel management | +| `channels` | ✅ | 🚧 | P2 | `list` implemented; `enable`/`disable`/`status` deferred pending config source unification | | `models` | ✅ | 🚧 | - | Model selector in TUI | | `status` | ✅ | ✅ | - | System status (enriched session details) | | `agents` | ✅ | ❌ | P3 | Multi-agent management | diff --git a/src/cli/channels.rs b/src/cli/channels.rs new file mode 100644 index 00000000..0c1eff32 --- /dev/null +++ b/src/cli/channels.rs @@ -0,0 +1,281 @@ +//! Channel management CLI commands. +//! +//! Lists configured messaging channels and their status. +//! Enable/disable/status subcommands are deferred pending channel config source +//! unification (see module-level note below). +//! +//! ## Why only `list` for now +//! +//! `enable`/`disable` require modifying channel configuration, but the config +//! source is currently split: built-in channels (cli, http, gateway, signal) +//! are resolved from environment variables in `ChannelsConfig::resolve()`, +//! while `settings.channels.*` fields are not consumed by that path. +//! Until `resolve()` falls back to settings (or the CLI writes `.env`), +//! an `enable`/`disable` command would silently fail to take effect. +//! +//! `status` (runtime health) requires connecting to a running IronClaw instance +//! via IPC or HTTP, which does not exist yet as a CLI control plane. + +use std::path::Path; + +use clap::Subcommand; + +#[derive(Subcommand, Debug, Clone)] +pub enum ChannelsCommand { + /// List all configured channels + List { + /// Show detailed information (host, port, config source) + #[arg(short, long)] + verbose: bool, + + /// Output as JSON + #[arg(long)] + json: bool, + }, +} + +/// Run the channels CLI subcommand. +pub async fn run_channels_command( + cmd: ChannelsCommand, + config_path: Option<&Path>, +) -> anyhow::Result<()> { + let config = crate::config::Config::from_env_with_toml(config_path) + .await + .map_err(|e| anyhow::anyhow!("{e:#}"))?; + + match cmd { + ChannelsCommand::List { verbose, json } => cmd_list(&config.channels, verbose, json).await, + } +} + +/// Channel entry for display. +struct ChannelInfo { + name: String, + kind: &'static str, + enabled: bool, + details: Vec<(&'static str, String)>, +} + +/// List all configured channels. +async fn cmd_list( + config: &crate::config::ChannelsConfig, + verbose: bool, + json: bool, +) -> anyhow::Result<()> { + let mut channels = Vec::new(); + + // Built-in: CLI + channels.push(ChannelInfo { + name: "cli".to_string(), + kind: "built-in", + enabled: config.cli.enabled, + details: vec![], + }); + + // Built-in: Gateway + if let Some(ref gw) = config.gateway { + channels.push(ChannelInfo { + name: "gateway".to_string(), + kind: "built-in", + enabled: true, + details: vec![("host", gw.host.clone()), ("port", gw.port.to_string())], + }); + } else { + channels.push(ChannelInfo { + name: "gateway".to_string(), + kind: "built-in", + enabled: false, + details: vec![], + }); + } + + // Built-in: HTTP webhook + if let Some(ref http) = config.http { + channels.push(ChannelInfo { + name: "http".to_string(), + kind: "built-in", + enabled: true, + details: vec![("host", http.host.clone()), ("port", http.port.to_string())], + }); + } else { + channels.push(ChannelInfo { + name: "http".to_string(), + kind: "built-in", + enabled: false, + details: vec![], + }); + } + + // Built-in: Signal + if let Some(ref sig) = config.signal { + channels.push(ChannelInfo { + name: "signal".to_string(), + kind: "built-in", + enabled: true, + details: vec![ + ("http_url", sig.http_url.clone()), + ("account", sig.account.clone()), + ("dm_policy", sig.dm_policy.clone()), + ("group_policy", sig.group_policy.clone()), + ], + }); + } else { + channels.push(ChannelInfo { + name: "signal".to_string(), + kind: "built-in", + enabled: false, + details: vec![], + }); + } + + // WASM channels: scan directory + if config.wasm_channels_enabled { + let wasm_channels = discover_wasm_channels(&config.wasm_channels_dir).await; + for name in wasm_channels { + let owner = config.wasm_channel_owner_ids.get(&name); + let mut details = vec![]; + if let Some(id) = owner { + details.push(("owner_id", id.to_string())); + } + channels.push(ChannelInfo { + name, + kind: "wasm", + enabled: true, + details, + }); + } + } + + if json { + let entries: Vec = channels + .iter() + .map(|ch| { + let mut v = serde_json::json!({ + "name": ch.name, + "kind": ch.kind, + "enabled": ch.enabled, + }); + if verbose { + let details: serde_json::Map = ch + .details + .iter() + .map(|(k, v)| (k.to_string(), serde_json::Value::String(v.clone()))) + .collect(); + v["details"] = serde_json::Value::Object(details); + } + v + }) + .collect(); + println!( + "{}", + serde_json::to_string_pretty(&entries).unwrap_or_else(|_| "[]".to_string()) + ); + return Ok(()); + } + + let enabled_count = channels.iter().filter(|c| c.enabled).count(); + println!( + "Configured channels ({} enabled, {} total):\n", + enabled_count, + channels.len() + ); + + for ch in &channels { + let status = if ch.enabled { "enabled" } else { "disabled" }; + if verbose { + println!(" {} [{}] ({})", ch.name, status, ch.kind); + for (key, val) in &ch.details { + println!(" {}: {}", key, val); + } + if ch.details.is_empty() && ch.enabled { + println!(" (default config)"); + } + println!(); + } else { + let detail_str = if ch.enabled && !ch.details.is_empty() { + let parts: Vec = + ch.details.iter().map(|(k, v)| format!("{k}={v}")).collect(); + format!(" ({})", parts.join(", ")) + } else { + String::new() + }; + println!( + " {:<16} {:<10} {:<10}{}", + ch.name, status, ch.kind, detail_str + ); + } + } + + if !verbose { + println!(); + println!("Use --verbose for details."); + println!(); + println!("Note: enable/disable not yet available. Channel configuration is"); + println!("managed via environment variables. See 'ironclaw onboard --channels-only'."); + } + + Ok(()) +} + +/// Discover WASM channel names by scanning the channels directory for `*.wasm` files. +/// +/// Matches the real loader's discovery logic (`WasmChannelLoader::load_from_dir`): +/// scans only top-level `*.wasm` files in the directory. +async fn discover_wasm_channels(dir: &Path) -> Vec { + let mut names = Vec::new(); + let mut entries = match tokio::fs::read_dir(dir).await { + Ok(entries) => entries, + Err(_) => return names, + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("wasm") + && let Some(stem) = path.file_stem().and_then(|s| s.to_str()) + { + names.push(stem.to_string()); + } + } + + names.sort(); + names +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn discover_wasm_channels_empty_on_missing_dir() { + let result = discover_wasm_channels(Path::new("/nonexistent/path")).await; + assert!(result.is_empty()); + } + + #[tokio::test] + async fn discover_wasm_channels_finds_flat_wasm_files() { + let tmp = tempfile::tempdir().unwrap(); + // Flat .wasm files — matches real loader (load_from_dir) + std::fs::File::create(tmp.path().join("slack.wasm")).unwrap(); + std::fs::File::create(tmp.path().join("telegram.wasm")).unwrap(); + // Non-.wasm files should be skipped + std::fs::File::create(tmp.path().join("readme.txt")).unwrap(); + // Directories should be skipped + std::fs::create_dir(tmp.path().join("somedir")).unwrap(); + + let result = discover_wasm_channels(tmp.path()).await; + assert_eq!(result, vec!["slack", "telegram"]); + } + + #[test] + fn channel_info_struct() { + let info = ChannelInfo { + name: "test".to_string(), + kind: "built-in", + enabled: true, + details: vec![("port", "3000".to_string())], + }; + assert!(info.enabled); + assert_eq!(info.kind, "built-in"); + assert_eq!(info.details.len(), 1); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 7d76e019..7efab882 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -8,9 +8,11 @@ //! - Managing MCP servers (`mcp add`, `mcp auth`, `mcp list`, `mcp test`) //! - Querying workspace memory (`memory search`, `memory read`, `memory write`) //! - Managing OS service (`service install`, `service start`, `service stop`) +//! - Listing configured channels (`channels list`) //! - Active health diagnostics (`doctor`) //! - Checking system health (`status`) +mod channels; mod completion; mod config; mod doctor; @@ -26,6 +28,7 @@ mod skills; pub mod status; mod tool; +pub use channels::{ChannelsCommand, run_channels_command}; pub use completion::Completion; pub use config::{ConfigCommand, run_config_command}; pub use doctor::run_doctor_command; @@ -136,6 +139,14 @@ pub enum Command { )] Registry(RegistryCommand), + /// List and inspect messaging channels + #[command( + subcommand, + about = "Manage channels", + long_about = "List configured messaging channels.\nExamples:\n ironclaw channels list\n ironclaw channels list --verbose\n ironclaw channels list --json" + )] + Channels(ChannelsCommand), + /// Manage MCP servers (hosted tool providers) #[command( subcommand, diff --git a/src/cli/snapshots/ironclaw__cli__tests__help_output_without_import.snap b/src/cli/snapshots/ironclaw__cli__tests__help_output_without_import.snap index 72e609e8..c1afbc58 100644 --- a/src/cli/snapshots/ironclaw__cli__tests__help_output_without_import.snap +++ b/src/cli/snapshots/ironclaw__cli__tests__help_output_without_import.snap @@ -12,6 +12,7 @@ Commands: config Manage app configs tool Manage WASM tools registry Browse/install extensions + channels Manage channels mcp Manage MCP servers memory Manage workspace memory pairing Manage DM pairing diff --git a/src/cli/snapshots/ironclaw__cli__tests__long_help_output_without_import.snap b/src/cli/snapshots/ironclaw__cli__tests__long_help_output_without_import.snap index c8e12c29..7c821c52 100644 --- a/src/cli/snapshots/ironclaw__cli__tests__long_help_output_without_import.snap +++ b/src/cli/snapshots/ironclaw__cli__tests__long_help_output_without_import.snap @@ -15,6 +15,7 @@ Commands: config Manage app configs tool Manage WASM tools registry Browse/install extensions + channels Manage channels mcp Manage MCP servers memory Manage workspace memory pairing Manage DM pairing diff --git a/src/main.rs b/src/main.rs index a2e5e441..6444242c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -59,6 +59,14 @@ async fn async_main() -> anyhow::Result<()> { init_cli_tracing(); return ironclaw::cli::run_registry_command(registry_cmd.clone()).await; } + Some(Command::Channels(channels_cmd)) => { + init_cli_tracing(); + return ironclaw::cli::run_channels_command( + channels_cmd.clone(), + cli.config.as_deref(), + ) + .await; + } Some(Command::Mcp(mcp_cmd)) => { init_cli_tracing(); return run_mcp_command(*mcp_cmd.clone()).await;