bamboo_server/handlers/command/
handlers.rs1use 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
130pub 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 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
216pub 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
322pub 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}