Skip to main content

bamboo_agent/
read_cli.rs

1//! The `bamboo skills list` / `bamboo mcp list` read CLI.
2//!
3//! These inspect the configured skill and MCP surfaces the way the running
4//! server does, but OFFLINE — straight from `<data_dir>` — so a headless / CI /
5//! server-less user can see what the agent would load without first booting
6//! `bamboo serve` and hitting `GET /api/v1/skills` or `/api/v1/mcp/servers`
7//! (both of which sit behind the access-password middleware). The config /
8//! skills store are the single source of truth; this module only resolves the
9//! data dir and pretty-prints.
10
11use std::path::PathBuf;
12
13use bamboo_skills::{SkillStore, SkillStoreConfig};
14use colored::Colorize;
15
16/// Resolve the effective data dir (`--data-dir` or `~/.bamboo`).
17fn resolve_data_dir(data_dir: Option<PathBuf>) -> PathBuf {
18    data_dir.unwrap_or_else(bamboo_config::paths::resolve_bamboo_dir)
19}
20
21/// Truncate a cell to `max` chars with an ellipsis, so table columns line up.
22fn truncate(s: &str, max: usize) -> String {
23    if s.chars().count() <= max {
24        return s.to_string();
25    }
26    let head: String = s.chars().take(max.saturating_sub(1)).collect();
27    format!("{head}…")
28}
29
30/// `bamboo skills list` — the skills the agent would discover under
31/// `<data_dir>/skills`, read directly from disk (no server).
32pub async fn skills_list(data_dir: Option<PathBuf>) -> anyhow::Result<()> {
33    let data_dir = resolve_data_dir(data_dir);
34    let skills_dir = data_dir.join("skills");
35
36    // Same construction the server uses (app_state/init.rs), minus project-local
37    // and mode-specific discovery — this is the global surface.
38    let store = SkillStore::new(SkillStoreConfig {
39        skills_dir: skills_dir.clone(),
40        project_dir: None,
41        active_mode: None,
42    });
43    let skills = store.list_skills(None, true).await;
44
45    if skills.is_empty() {
46        println!(
47            "(no skills in {})",
48            bamboo_config::paths::path_to_display_string(&skills_dir)
49        );
50        return Ok(());
51    }
52
53    // Plain (un-colored) header cells so the widths line up — ANSI escapes would
54    // otherwise be counted against the padding.
55    println!("{:<28} {:<32} DESCRIPTION", "ID", "NAME");
56    for skill in &skills {
57        println!(
58            "{:<28} {:<32} {}",
59            truncate(&skill.id, 28),
60            truncate(&skill.name, 32),
61            truncate(&skill.description, 60)
62        );
63    }
64    println!("\n{} skill(s).", skills.len());
65    Ok(())
66}
67
68/// `bamboo mcp list` — the MCP servers configured in `<data_dir>/config.json`,
69/// read directly (no server). Live connection status / tool counts require a
70/// running server (`GET /api/v1/mcp/servers`); this is the static config view.
71pub async fn mcp_list(data_dir: Option<PathBuf>) -> anyhow::Result<()> {
72    use bamboo_domain::mcp_config::TransportConfig;
73
74    let data_dir = resolve_data_dir(data_dir);
75    let config = bamboo_config::Config::from_data_dir(Some(data_dir));
76    let servers = &config.mcp.servers;
77
78    if servers.is_empty() {
79        println!("(no MCP servers configured)");
80        return Ok(());
81    }
82
83    // Plain cells (no ANSI in the table) so the columns line up; color is used
84    // only in the summary line below.
85    println!("{:<24} {:<8} {:<16} NAME", "ID", "ENABLED", "TRANSPORT");
86    for server in servers {
87        let transport = match &server.transport {
88            TransportConfig::Stdio(_) => "stdio",
89            TransportConfig::Sse(_) => "sse",
90            TransportConfig::StreamableHttp(_) => "streamable_http",
91        };
92        let name = server.name.as_deref().unwrap_or("");
93        println!(
94            "{:<24} {:<8} {:<16} {}",
95            truncate(&server.id, 24),
96            if server.enabled { "yes" } else { "no" },
97            transport,
98            name
99        );
100    }
101    let live = servers.iter().filter(|s| s.enabled).count();
102    println!(
103        "\n{live} enabled of {}. Live status: {}",
104        servers.len(),
105        "bamboo status".cyan()
106    );
107    Ok(())
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn truncate_shortens_long_cells() {
116        assert_eq!(truncate("hello", 10), "hello");
117        assert_eq!(truncate("hello-world", 5), "hell…");
118    }
119
120    #[tokio::test]
121    async fn skills_list_empty_dir_is_ok() {
122        let dir = tempfile::tempdir().expect("tempdir");
123        // A data dir with no skills/ subtree must not error — just report empty.
124        assert!(skills_list(Some(dir.path().to_path_buf())).await.is_ok());
125    }
126}