Skip to main content

bamboo_agent/server/handlers/
command.rs

1use crate::agent::skill::SkillDefinition;
2use crate::server::app_state::AppState;
3use crate::server::error::AppError;
4use actix_web::{web, HttpResponse};
5use serde::{Deserialize, Serialize};
6
7/// Command type enumeration for categorizing different command sources
8#[derive(Debug, Serialize, Deserialize, Clone)]
9#[serde(rename_all = "lowercase")]
10pub enum CommandType {
11    /// Workflow commands from markdown files
12    Workflow,
13    /// Skill commands defined in the skill system
14    Skill,
15    /// MCP (Model Context Protocol) tool commands
16    Mcp,
17}
18
19/// Represents a unified command item from various sources (workflows, skills, MCP tools)
20#[derive(Debug, Serialize)]
21pub struct CommandItem {
22    /// Unique identifier for the command (e.g., "workflow-myworkflow", "skill-myskill", "mcp-server-tool")
23    pub id: String,
24    /// Short name/identifier for the command
25    pub name: String,
26    /// Human-readable display name
27    pub display_name: String,
28    /// Description of what the command does
29    pub description: String,
30    /// Type of command: "workflow", "skill", or "mcp"
31    #[serde(rename = "type")]
32    pub command_type: String,
33    /// Optional category for grouping commands
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub category: Option<String>,
36    /// Optional tags for filtering and search
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub tags: Option<Vec<String>>,
39    /// Additional metadata specific to the command type
40    pub metadata: serde_json::Value,
41}
42
43/// Response structure for listing all available commands
44#[derive(Debug, Serialize)]
45pub struct CommandListResponse {
46    /// List of all available commands
47    pub commands: Vec<CommandItem>,
48    /// Total number of commands
49    pub total: usize,
50}
51
52/// Lists all available commands from workflows, skills, and MCP tools
53///
54/// # HTTP Route
55/// `GET /commands`
56///
57/// # Response Format
58/// Returns a [`CommandListResponse`] containing all available commands:
59/// ```json
60/// {
61///   "commands": [
62///     {
63///       "id": "workflow-myworkflow",
64///       "name": "myworkflow",
65///       "display_name": "myworkflow",
66///       "description": "Workflow: myworkflow",
67///       "type": "workflow",
68///       "metadata": { ... }
69///     }
70///   ],
71///   "total": 10
72/// }
73/// ```
74///
75/// # Response Status
76/// - `200 OK`: Successfully retrieved command list
77///
78/// # Example
79/// ```bash
80/// curl http://localhost:3000/commands
81/// ```
82pub async fn list_commands(app_state: web::Data<AppState>) -> Result<HttpResponse, AppError> {
83    let mut commands = Vec::new();
84
85    // 1. Load Workflows
86    match list_workflows_as_commands(&app_state.app_data_dir).await {
87        Ok(workflows) => commands.extend(workflows),
88        Err(e) => {
89            log::warn!("Failed to load workflows: {}", e);
90            // Continue loading other types, don't interrupt
91        }
92    }
93
94    // 2. Load Skills
95    let skills = app_state
96        .skill_manager
97        .store()
98        .list_skills(None, false)
99        .await;
100
101    let skill_commands: Vec<CommandItem> = skills
102        .into_iter()
103        .map(|skill| skill_to_command(&skill))
104        .collect();
105    commands.extend(skill_commands);
106
107    // 3. Load MCP Tools
108    match list_mcp_tools_as_commands(app_state.get_ref()).await {
109        Ok(mcp_tools) => commands.extend(mcp_tools),
110        Err(e) => {
111            log::warn!("Failed to load MCP tools: {}", e);
112        }
113    }
114
115    // Sort by name
116    commands.sort_by(|a, b| a.name.cmp(&b.name));
117
118    Ok(HttpResponse::Ok().json(CommandListResponse {
119        total: commands.len(),
120        commands,
121    }))
122}
123
124/// Retrieves a specific command by type and ID
125///
126/// # HTTP Route
127/// `GET /commands/{command_type}/{id}`
128///
129/// # Path Parameters
130/// - `command_type`: Type of command ("workflow", "skill", or "mcp")
131/// - `id`: Unique identifier of the command
132///
133/// # Response Format
134/// Returns command details including content (for workflows and skills):
135/// ```json
136/// {
137///   "id": "workflow-myworkflow",
138///   "name": "myworkflow",
139///   "content": "# My Workflow\n...",
140///   "type": "workflow"
141/// }
142/// ```
143///
144/// # Response Status
145/// - `200 OK`: Command found and returned
146/// - `404 Not Found`: Command not found or MCP tool (which doesn't support content retrieval)
147///
148/// # Example
149/// ```bash
150/// curl http://localhost:3000/commands/workflow/myworkflow
151/// curl http://localhost:3000/commands/skill/myskill
152/// ```
153pub async fn get_command(
154    app_state: web::Data<AppState>,
155    path: web::Path<(String, String)>,
156) -> Result<HttpResponse, AppError> {
157    let (command_type, id) = path.into_inner();
158
159    match command_type.as_str() {
160        "workflow" => {
161            // Call existing workflow retrieval logic
162            let workflows_dir = app_state.app_data_dir.join("workflows");
163            let filename = format!("{}.md", id);
164            let filepath = workflows_dir.join(&filename);
165
166            if !filepath.exists() {
167                return Err(AppError::NotFound(format!("Workflow {} not found", id)));
168            }
169
170            let content = tokio::fs::read_to_string(&filepath).await.map_err(|e| {
171                AppError::InternalError(anyhow::anyhow!("Failed to read workflow: {}", e))
172            })?;
173
174            Ok(HttpResponse::Ok().json(serde_json::json!({
175                "id": format!("workflow-{}", id),
176                "name": id,
177                "content": content,
178                "type": "workflow"
179            })))
180        }
181        "skill" => match app_state.skill_manager.store().get_skill(&id).await {
182            Ok(skill) => Ok(HttpResponse::Ok().json(skill)),
183            Err(e) => Err(AppError::NotFound(format!("Skill {} not found: {}", id, e))),
184        },
185        "mcp" => {
186            // MCP tools don't need separate content retrieval
187            Err(AppError::NotFound(
188                "MCP tools do not support content retrieval".to_string(),
189            ))
190        }
191        _ => Err(AppError::NotFound(format!(
192            "Unknown command type: {}",
193            command_type
194        ))),
195    }
196}
197
198/// Internal helper to list all workflow markdown files as command items
199///
200/// Scans the workflows directory and creates command items for each `.md` file
201async fn list_workflows_as_commands(
202    data_dir: &std::path::Path,
203) -> Result<Vec<CommandItem>, AppError> {
204    let dir = data_dir.join("workflows");
205    tokio::fs::create_dir_all(&dir).await.map_err(|e| {
206        AppError::InternalError(anyhow::anyhow!("Failed to create workflows dir: {}", e))
207    })?;
208
209    let mut entries = tokio::fs::read_dir(&dir).await.map_err(|e| {
210        AppError::InternalError(anyhow::anyhow!("Failed to read workflows dir: {}", e))
211    })?;
212
213    let mut commands = Vec::new();
214
215    while let Some(entry) = entries
216        .next_entry()
217        .await
218        .map_err(|e| AppError::InternalError(anyhow::anyhow!("Failed to read entry: {}", e)))?
219    {
220        let path = entry.path();
221        if path.extension().and_then(|s| s.to_str()) != Some("md") {
222            continue;
223        }
224
225        let name = path
226            .file_stem()
227            .and_then(|s| s.to_str())
228            .unwrap_or_default()
229            .to_string();
230
231        if name.is_empty() {
232            continue;
233        }
234
235        let metadata = entry.metadata().await.map_err(|e| {
236            AppError::InternalError(anyhow::anyhow!("Failed to read metadata: {}", e))
237        })?;
238
239        let filename = path
240            .file_name()
241            .and_then(|s| s.to_str())
242            .unwrap_or_default()
243            .to_string();
244
245        commands.push(CommandItem {
246            id: format!("workflow-{}", name),
247            name: name.clone(),
248            display_name: name.clone(),
249            description: format!("Workflow: {}", name),
250            command_type: "workflow".to_string(),
251            category: None,
252            tags: None,
253            metadata: serde_json::json!({
254                "filename": filename,
255                "size": metadata.len(),
256                "source": "global"
257            }),
258        });
259    }
260
261    Ok(commands)
262}
263
264/// Internal helper to convert a skill definition to a command item
265fn skill_to_command(skill: &SkillDefinition) -> CommandItem {
266    CommandItem {
267        id: format!("skill-{}", skill.id),
268        name: skill.id.clone(),
269        display_name: skill.name.clone(),
270        description: skill.description.clone(),
271        command_type: "skill".to_string(),
272        category: Some(skill.category.clone()),
273        tags: Some(skill.tags.clone()),
274        metadata: serde_json::json!({
275            "prompt": skill.prompt,
276            "toolRefs": skill.tool_refs,
277            "workflowRefs": skill.workflow_refs,
278            "visibility": skill.visibility,
279        }),
280    }
281}
282
283/// Internal helper to list all MCP tools as command items
284///
285/// Retrieves all tool aliases from the MCP manager and converts them to command items
286async fn list_mcp_tools_as_commands(state: &AppState) -> Result<Vec<CommandItem>, AppError> {
287    let aliases = state.mcp_manager.tool_index().all_aliases();
288
289    let commands: Vec<CommandItem> = aliases
290        .into_iter()
291        .filter_map(|alias| {
292            state
293                .mcp_manager
294                .get_tool_info(&alias.server_id, &alias.original_name)
295                .map(|tool| CommandItem {
296                    id: format!("mcp-{}-{}", alias.server_id, alias.original_name),
297                    name: alias.alias.clone(),
298                    display_name: alias.alias.clone(),
299                    description: tool.description.clone(),
300                    command_type: "mcp".to_string(),
301                    category: Some("MCP Tools".to_string()),
302                    tags: None,
303                    metadata: serde_json::json!({
304                        "serverId": alias.server_id,
305                        "originalName": alias.original_name,
306                    }),
307                })
308        })
309        .collect();
310
311    Ok(commands)
312}
313
314/// Configures command-related routes
315///
316/// # Routes
317/// - `GET /commands` - List all commands
318/// - `GET /commands/{command_type}/{id}` - Get specific command details
319pub fn config(cfg: &mut web::ServiceConfig) {
320    cfg.route("/commands", web::get().to(list_commands))
321        .route("/commands/{command_type}/{id}", web::get().to(get_command));
322}