Skip to main content

bamboo_tools/tools/
slash_command_tool.rs

1use 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}