Skip to main content

bamboo_server/handlers/command/
handlers.rs

1use std::collections::HashSet;
2
3use actix_web::{web, HttpResponse};
4
5use crate::app_state::AppState;
6use crate::error::AppError;
7use crate::handlers::settings::is_safe_workflow_name;
8
9use super::sources::{
10    legacy_workflow_catalog_entry_to_command, list_markdown_commands, list_mcp_tools_as_commands,
11    list_prompt_presets_as_commands, safe_project_commands_dir, skill_catalog_entry_to_command,
12};
13use super::types::{CommandItem, CommandListResponse, GetCommandQuery, ListCommandsQuery};
14
15struct SessionResourceContext {
16    workspace: Option<std::path::PathBuf>,
17    project_id: Option<bamboo_domain::ProjectId>,
18    project_home: Option<std::path::PathBuf>,
19}
20
21pub(super) fn append_unique(
22    commands: &mut Vec<CommandItem>,
23    seen: &mut HashSet<(String, String)>,
24    items: Vec<CommandItem>,
25) {
26    for item in items {
27        if seen.insert((item.command_type.clone(), item.name.clone())) {
28            commands.push(item);
29        }
30    }
31}
32
33pub(super) fn expand_arguments(template: &str, arguments: &str) -> String {
34    template.replace("$ARGUMENTS", arguments)
35}
36
37async fn session_resource_context(
38    app_state: &AppState,
39    session_id: Option<&str>,
40    legacy_workspace_path: Option<&str>,
41) -> Result<SessionResourceContext, AppError> {
42    if legacy_workspace_path.is_some_and(|value| !value.trim().is_empty()) {
43        return Err(AppError::BadRequest(
44            "workspace_path is deprecated; provide session_id so workspace access is session-bound"
45                .to_string(),
46        ));
47    }
48    let Some(session_id) = session_id.map(str::trim).filter(|value| !value.is_empty()) else {
49        return Ok(SessionResourceContext {
50            workspace: None,
51            project_id: None,
52            project_home: None,
53        });
54    };
55    let session = app_state
56        .load_session(session_id)
57        .await
58        .ok_or_else(|| AppError::NotFound(format!("Session '{session_id}'")))?;
59    let project_id =
60        match bamboo_engine::project_context::ProjectContextResolver::session_project_identity(
61            &session,
62        ) {
63            bamboo_engine::project_context::SessionProjectIdentity::Assigned(project_id) => {
64                Some(project_id)
65            }
66            bamboo_engine::project_context::SessionProjectIdentity::Unassigned => None,
67            bamboo_engine::project_context::SessionProjectIdentity::Invalid { raw, message } => {
68                return Err(AppError::BadRequest(format!(
69                    "Session carries an invalid Project identity '{raw}': {message}"
70                )));
71            }
72        };
73    let persisted_workspace = (session
74        .metadata
75        .get(bamboo_engine::project_context::WORKSPACE_SOURCE_METADATA_KEY)
76        .map(String::as_str)
77        != Some(bamboo_engine::project_context::WorkspaceSource::ProjectDefault.as_str()))
78    .then(|| session.workspace_path_meta())
79    .flatten();
80    let workspace = crate::project_context::validate_workspace_assignment_with_resolver(
81        &app_state.project_store,
82        project_id.as_ref(),
83        persisted_workspace.as_deref(),
84        &app_state.workspace_resolver,
85    )
86    .map_err(|error| match error {
87        crate::project_context::ProjectWorkspaceValidationError::Invalid { .. }
88        | crate::project_context::ProjectWorkspaceValidationError::Conflict { .. } => {
89            AppError::BadRequest(error.to_string())
90        }
91        crate::project_context::ProjectWorkspaceValidationError::Store(error) => {
92            AppError::InternalError(anyhow::anyhow!(error))
93        }
94    })?;
95    let project_home = if let Some(project_id) = project_id.as_ref() {
96        app_state.project_store.get(project_id).map_err(|error| {
97            AppError::BadRequest(format!("Assigned Project is unavailable: {error}"))
98        })?;
99        Some(app_state.project_store.paths().project_home(project_id))
100    } else {
101        None
102    };
103    Ok(SessionResourceContext {
104        workspace,
105        project_id,
106        project_home,
107    })
108}
109
110async fn scoped_skill_store(
111    app_state: &AppState,
112    context: &SessionResourceContext,
113) -> Result<std::sync::Arc<bamboo_skills::SkillStore>, AppError> {
114    if let (Some(project_id), Some(project_home)) =
115        (context.project_id.as_ref(), context.project_home.as_ref())
116    {
117        app_state
118            .skill_manager
119            .store_for_project_workspace(project_id, project_home, context.workspace.as_deref())
120            .await
121    } else {
122        app_state
123            .skill_manager
124            .store_for_workspace(context.workspace.as_deref())
125            .await
126    }
127    .map_err(|error| AppError::BadRequest(format!("Invalid session resource scope: {error}")))
128}
129
130/// Lists all available commands from workflows, skills, and MCP tools.
131pub async fn list_commands(
132    app_state: web::Data<AppState>,
133    query: web::Query<ListCommandsQuery>,
134) -> Result<HttpResponse, AppError> {
135    let mut commands = Vec::new();
136    let mut seen = HashSet::new();
137    let context = session_resource_context(
138        app_state.get_ref(),
139        query.session_id.as_deref(),
140        query.workspace_path.as_deref(),
141    )
142    .await?;
143
144    // Conflict precedence is deliberately source-based and stable:
145    // workspace markdown > Project markdown > global markdown > global preset
146    // > workflow/skill catalog > MCP.
147    if let Some(workspace_path) = context.workspace.as_ref() {
148        if let Some(dir) = safe_project_commands_dir(workspace_path.to_string_lossy().as_ref()) {
149            let workspace_commands = list_markdown_commands(&dir, "workspace")
150                .await
151                .into_iter()
152                .map(|command| command.item)
153                .collect();
154            append_unique(&mut commands, &mut seen, workspace_commands);
155        }
156    }
157    if let Some(project_id) = context.project_id.as_ref() {
158        let project = list_markdown_commands(
159            &app_state.project_store.paths().commands_dir(project_id),
160            "project",
161        )
162        .await
163        .into_iter()
164        .map(|command| command.item)
165        .collect();
166        append_unique(&mut commands, &mut seen, project);
167    }
168
169    let global_dir = bamboo_config::paths::commands_dir_in(&app_state.app_data_dir);
170    let global = list_markdown_commands(&global_dir, "global")
171        .await
172        .into_iter()
173        .map(|command| command.item)
174        .collect();
175    append_unique(&mut commands, &mut seen, global);
176
177    append_unique(
178        &mut commands,
179        &mut seen,
180        list_prompt_presets_as_commands(&app_state.app_data_dir).await,
181    );
182
183    let store = scoped_skill_store(app_state.get_ref(), &context).await?;
184    let (skill_catalog, workflow_catalog) = store.command_catalog_snapshots().await;
185    let skill_commands = skill_catalog
186        .entries
187        .into_iter()
188        .filter_map(|entry| skill_catalog_entry_to_command(&entry))
189        .collect();
190    append_unique(&mut commands, &mut seen, skill_commands);
191    let workflow_commands = workflow_catalog
192        .entries
193        .into_iter()
194        .filter_map(|entry| legacy_workflow_catalog_entry_to_command(&entry))
195        .collect();
196    append_unique(&mut commands, &mut seen, workflow_commands);
197
198    match list_mcp_tools_as_commands(app_state.get_ref()).await {
199        Ok(mcp_tools) => append_unique(&mut commands, &mut seen, mcp_tools),
200        Err(error) => {
201            tracing::warn!("Failed to load MCP tools: {error}");
202        }
203    }
204
205    commands.sort_by(|left, right| {
206        left.name
207            .cmp(&right.name)
208            .then_with(|| left.command_type.cmp(&right.command_type))
209    });
210    Ok(HttpResponse::Ok().json(CommandListResponse {
211        total: commands.len(),
212        commands,
213    }))
214}
215
216/// Retrieves a specific command by type and ID.
217pub async fn get_command(
218    app_state: web::Data<AppState>,
219    path: web::Path<(String, String)>,
220    query: web::Query<GetCommandQuery>,
221) -> Result<HttpResponse, AppError> {
222    let (command_type, id) = path.into_inner();
223    let context = session_resource_context(
224        app_state.get_ref(),
225        query.session_id.as_deref(),
226        query.workspace_path.as_deref(),
227    )
228    .await?;
229
230    match command_type.as_str() {
231        "prompt" => {
232            let mut sources = Vec::new();
233            if let Some(workspace_path) = context.workspace.as_ref() {
234                if let Some(dir) =
235                    safe_project_commands_dir(workspace_path.to_string_lossy().as_ref())
236                {
237                    sources.push((dir, "workspace"));
238                }
239            }
240            if let Some(project_id) = context.project_id.as_ref() {
241                sources.push((
242                    app_state.project_store.paths().commands_dir(project_id),
243                    "project",
244                ));
245            }
246            sources.push((
247                bamboo_config::paths::commands_dir_in(&app_state.app_data_dir),
248                "global",
249            ));
250
251            for (dir, source) in sources {
252                if let Some(command) = list_markdown_commands(&dir, source)
253                    .await
254                    .into_iter()
255                    .find(|command| command.item.name == id)
256                {
257                    let arguments = query.arguments.as_deref().unwrap_or_default();
258                    let content = expand_arguments(&command.content, arguments);
259                    return Ok(HttpResponse::Ok().json(serde_json::json!({
260                        "id": command.item.id,
261                        "name": command.item.name,
262                        "content": content,
263                        "type": "prompt",
264                        "metadata": command.item.metadata,
265                    })));
266                }
267            }
268            if let Some(preset) = list_prompt_presets_as_commands(&app_state.app_data_dir)
269                .await
270                .into_iter()
271                .find(|command| command.name == id)
272            {
273                let arguments = query.arguments.as_deref().unwrap_or_default();
274                let content = preset.metadata["prompt"].as_str().unwrap_or_default();
275                return Ok(HttpResponse::Ok().json(serde_json::json!({
276                    "id": preset.id,
277                    "name": preset.name,
278                    "content": expand_arguments(content, arguments),
279                    "type": "prompt",
280                    "metadata": preset.metadata,
281                })));
282            }
283            Err(AppError::NotFound(format!("Prompt command {id} not found")))
284        }
285        "workflow" => {
286            if !is_safe_workflow_name(&id) {
287                return Err(AppError::BadRequest("Invalid workflow name".to_string()));
288            }
289
290            let store = scoped_skill_store(app_state.get_ref(), &context).await?;
291            let filepath = store
292                .get_legacy_workflow_source(&id)
293                .await
294                .map_err(|_| AppError::NotFound(format!("Workflow {id} not found")))?;
295            let content = bamboo_skills::legacy::read_legacy_markdown_workflow(&filepath)
296                .await
297                .map_err(|_| AppError::NotFound(format!("Workflow {id} not found")))?;
298
299            Ok(HttpResponse::Ok().json(serde_json::json!({
300                "id": format!("workflow-{id}"),
301                "name": id,
302                "content": content,
303                "type": "workflow"
304            })))
305        }
306        "skill" => {
307            let store = scoped_skill_store(app_state.get_ref(), &context).await?;
308            match store.get_skill(&id).await {
309                Ok(skill) => Ok(HttpResponse::Ok().json(skill)),
310                Err(error) => Err(AppError::NotFound(format!("Skill {id} not found: {error}"))),
311            }
312        }
313        "mcp" => Err(AppError::NotFound(
314            "MCP tools do not support content retrieval".to_string(),
315        )),
316        _ => Err(AppError::NotFound(format!(
317            "Unknown command type: {command_type}"
318        ))),
319    }
320}
321
322/// Configures command-related routes.
323pub fn config(cfg: &mut web::ServiceConfig) {
324    cfg.route("/commands", web::get().to(list_commands)).route(
325        "/commands/{command_type}/{id:.*}",
326        web::get().to(get_command),
327    );
328}