bamboo_tools/tools/
slash_command_tool.rs1use async_trait::async_trait;
2use bamboo_agent_core::{Tool, ToolCtx, ToolError, ToolOutcome, ToolResult};
3use serde::Deserialize;
4use serde_json::json;
5
6use super::workspace_state;
7
8#[derive(Debug, Deserialize)]
9struct SlashCommandArgs {
10 command: String,
11}
12
13pub struct SlashCommandTool;
14
15impl SlashCommandTool {
16 pub fn new() -> Self {
17 Self
18 }
19}
20
21impl Default for SlashCommandTool {
22 fn default() -> Self {
23 Self::new()
24 }
25}
26
27#[async_trait]
28impl Tool for SlashCommandTool {
29 fn name(&self) -> &str {
30 "SlashCommand"
31 }
32
33 fn description(&self) -> &str {
34 "Execute a slash command within the main conversation"
35 }
36
37 fn parameters_schema(&self) -> serde_json::Value {
38 json!({
39 "type": "object",
40 "properties": {
41 "command": {
42 "type": "string",
43 "description": "Slash command text, including arguments"
44 }
45 },
46 "required": ["command"],
47 "additionalProperties": false
48 })
49 }
50
51 async fn invoke(
52 &self,
53 args: serde_json::Value,
54 ctx: ToolCtx,
55 ) -> Result<ToolOutcome, ToolError> {
56 let parsed: SlashCommandArgs = serde_json::from_value(args).map_err(|e| {
57 ToolError::InvalidArguments(format!("Invalid SlashCommand args: {}", e))
58 })?;
59
60 let raw = parsed.command.trim();
61 if raw.is_empty() {
62 return Err(ToolError::InvalidArguments(
63 "command cannot be empty".to_string(),
64 ));
65 }
66
67 let mut parts = raw.split_whitespace();
68 let head = parts.next().unwrap_or_default();
69 let tail = parts.collect::<Vec<_>>().join(" ");
70
71 let project_path = Some(bamboo_config::paths::path_to_display_string(
72 &workspace_state::workspace_or_process_cwd(ctx.session_id()),
73 ));
74
75 let commands = crate::slash_commands::slash_commands_list(project_path)
76 .await
77 .map_err(ToolError::Execution)?;
78
79 if let Some(command) = commands
80 .into_iter()
81 .find(|value| value.full_command == head)
82 {
83 let resolved = if command.accepts_arguments {
84 command.content.replace("$ARGUMENTS", tail.trim())
85 } else {
86 command.content.clone()
87 };
88
89 return Ok(ToolOutcome::Completed(ToolResult {
90 success: true,
91 result: json!({
92 "command": raw,
93 "resolved_command": command.full_command,
94 "content": resolved,
95 })
96 .to_string(),
97 display_preference: Some("Collapsible".to_string()),
98 images: Vec::new(),
99 }));
100 }
101
102 let fallback = crate::slash_commands::slash_commands_list(None)
103 .await
104 .unwrap_or_default();
105 let available = fallback
106 .iter()
107 .take(5)
108 .map(|value| value.full_command.clone())
109 .collect::<Vec<_>>();
110
111 Err(ToolError::Execution(format!(
112 "Slash command '{}' not found. Available commands: {}",
113 head,
114 available.join(", ")
115 )))
116 }
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122
123 #[tokio::test]
124 async fn slash_command_uses_session_workspace_for_project_commands() {
125 let dir = tempfile::tempdir().unwrap();
126 let commands_dir = dir.path().join(".claude/commands");
127 tokio::fs::create_dir_all(&commands_dir).await.unwrap();
128 tokio::fs::write(commands_dir.join("hello.md"), "Hi $ARGUMENTS")
129 .await
130 .unwrap();
131
132 let session = format!("session_{}", uuid::Uuid::new_v4());
133 super::workspace_state::set_workspace(&session, dir.path().to_path_buf());
134
135 let tool = SlashCommandTool::new();
136 let out = tool
137 .invoke(
138 json!({ "command": "/hello world" }),
139 ToolCtx {
140 session_id: Some(std::sync::Arc::from(session.as_str())),
141 tool_call_id: std::sync::Arc::from("call_1"),
142 event_tx: None,
143 available_tool_schemas: std::sync::Arc::from(Vec::new()),
144 bypass_permissions: false,
145 can_async_resume: false,
146 async_completion_sink: None,
147 bash_completion_sink: None,
148 },
149 )
150 .await
151 .unwrap();
152 let ToolOutcome::Completed(result) = out else {
153 panic!("expected Completed")
154 };
155
156 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
157 assert_eq!(payload["resolved_command"], "/hello");
158 assert_eq!(payload["content"], "Hi world");
159 }
160}