mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
feat(cli): add ironclaw channels list subcommand (#933)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user