feat(cli): add ironclaw channels list subcommand (#933)

This commit is contained in:
Reid
2026-03-12 03:43:17 -07:00
committed by GitHub
parent 269b3f462f
commit 5d9d17bf71
6 changed files with 303 additions and 1 deletions
+1 -1
View File
@@ -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 |
+281
View File
@@ -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<serde_json::Value> = 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<String, serde_json::Value> = 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<String> =
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<String> {
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);
}
}
+11
View File
@@ -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,
@@ -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
@@ -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
+8
View File
@@ -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;