Skip to main content

agent_engine/skills/
commands.rs

1//! Execution helper for plugin manifest commands.
2
3use std::path::{Component, Path};
4use std::process::Stdio;
5use std::sync::Arc;
6
7use serde_json::Value;
8
9use crate::skills::registry::{RegisteredPluginCommand, RegisteredPluginCommandBackend};
10use crate::tools::{ToolCapabilities, ToolChannels, ToolLimits};
11use crate::{ToolContext, ToolRegistry};
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct PluginCommandOutput {
15    pub status: Option<i32>,
16    pub stdout: String,
17    pub stderr: String,
18}
19
20fn validate_command_path(command: &str) -> crate::Result<()> {
21    let path = Path::new(command);
22    if path.is_absolute() {
23        return Err(crate::RuntimeError::Tool(
24            "plugin command must be relative to plugin root or resolved from PATH".to_string(),
25        ));
26    }
27    if path.components().any(|c| matches!(c, Component::ParentDir)) {
28        return Err(crate::RuntimeError::Tool(
29            "plugin command may not contain '..' path components".to_string(),
30        ));
31    }
32    Ok(())
33}
34
35fn interpolate_slash_args(value: Value, slash_args: &str) -> Value {
36    match value {
37        Value::String(s) if s == "${args}" => Value::String(slash_args.to_string()),
38        Value::String(s) => Value::String(s.replace("${args}", slash_args)),
39        Value::Array(items) => Value::Array(
40            items
41                .into_iter()
42                .map(|v| interpolate_slash_args(v, slash_args))
43                .collect(),
44        ),
45        Value::Object(obj) => Value::Object(
46            obj.into_iter()
47                .map(|(k, v)| (k, interpolate_slash_args(v, slash_args)))
48                .collect(),
49        ),
50        other => other,
51    }
52}
53
54pub async fn execute_plugin_command(
55    command: &RegisteredPluginCommand,
56    slash_args: &str,
57) -> crate::Result<PluginCommandOutput> {
58    match &command.backend {
59        RegisteredPluginCommandBackend::Shell {
60            command: executable,
61            args,
62        } => {
63            validate_command_path(executable)?;
64
65            let mut cmd = tokio::process::Command::new(executable);
66            cmd.current_dir(&command.plugin_root)
67                .args(args)
68                .args(slash_words(slash_args))
69                .stdin(Stdio::null())
70                .stdout(Stdio::piped())
71                .stderr(Stdio::piped());
72
73            let output = cmd.output().await.map_err(|e| {
74                crate::RuntimeError::Tool(format!(
75                    "failed to run plugin command /{}:{}: {}",
76                    command.plugin, command.name, e
77                ))
78            })?;
79
80            Ok(PluginCommandOutput {
81                status: output.status.code(),
82                stdout: String::from_utf8_lossy(&output.stdout).to_string(),
83                stderr: String::from_utf8_lossy(&output.stderr).to_string(),
84            })
85        }
86        RegisteredPluginCommandBackend::SkillPrompt { skill, prompt } => {
87            let mut text = prompt.replace("${args}", slash_args);
88            if !slash_args.trim().is_empty() && !prompt.contains("${args}") {
89                text.push('\n');
90                text.push_str(slash_args);
91            }
92            Ok(PluginCommandOutput {
93                status: Some(0),
94                stdout: format!(
95                    "Skill prompt for /{}:{} (skill: {})\n{}",
96                    command.plugin, command.name, skill, text
97                ),
98                stderr: String::new(),
99            })
100        }
101        RegisteredPluginCommandBackend::ExtensionTool { .. } => Err(crate::RuntimeError::Tool(
102            "extension-backed plugin command requires execute_plugin_command_with_tools"
103                .to_string(),
104        )),
105        RegisteredPluginCommandBackend::Interactive { .. } => Err(crate::RuntimeError::Tool(
106            "interactive plugin command requires ExtensionManager::invoke_command".to_string(),
107        )),
108    }
109}
110
111pub async fn execute_plugin_command_with_tools(
112    command: &RegisteredPluginCommand,
113    slash_args: &str,
114    tools: Arc<tokio::sync::RwLock<ToolRegistry>>,
115) -> crate::Result<PluginCommandOutput> {
116    if let RegisteredPluginCommandBackend::ExtensionTool { tool, input } = &command.backend {
117        let runtime_tool_name = format!("{}:{}", command.plugin, tool);
118        let params = interpolate_slash_args(input.clone(), slash_args);
119        let registry = tools.read().await;
120        let tool = registry
121            .get(&runtime_tool_name)
122            .ok_or_else(|| {
123                crate::RuntimeError::Tool(format!(
124                    "extension tool '{}' is not registered",
125                    runtime_tool_name
126                ))
127            })?
128            .clone();
129        drop(registry);
130        let stdout = tool.execute(params, empty_tool_context()).await?;
131        Ok(PluginCommandOutput {
132            status: Some(0),
133            stdout,
134            stderr: String::new(),
135        })
136    } else {
137        execute_plugin_command(command, slash_args).await
138    }
139}
140
141fn empty_tool_context() -> ToolContext {
142    ToolContext {
143        channels: ToolChannels {
144            tx_delta: None,
145            tx_events: None,
146        },
147        capabilities: ToolCapabilities {
148            session_allow_all: None,
149            launch_cancel: None,
150            memory_backend: None,
151            telemetry: None,
152            watcher_exit_path: None,
153            tool_register_tx: None,
154            session_manager: None,
155            subagent_registry: None,
156            event_queue: None,
157            delegation_parent: None,
158            codex_parent_plan: None,
159            secret_prompt: None,
160            orchestration: None,
161            tool_activation: None,
162            mcp_leases: None,
163            extension_leases: None,
164            memory_context: None,
165            cwd: None,
166            env: None,
167            env_stripped: Vec::new(),
168            env_warned: Default::default(),
169            session_id: None,
170        },
171        limits: ToolLimits {
172            max_tool_output: 30_000,
173            max_tool_buffer: 256 * 1024,
174            bash_timeout: 30,
175            bash_max_timeout: 300,
176            subagent_timeout: 300,
177        },
178    }
179}
180
181fn slash_words(input: &str) -> Vec<String> {
182    input.split_whitespace().map(str::to_string).collect()
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use crate::Tool;
189    use async_trait::async_trait;
190    use std::path::PathBuf;
191
192    fn cmd(command: &str, args: Vec<&str>, root: PathBuf) -> RegisteredPluginCommand {
193        RegisteredPluginCommand {
194            plugin: "p".to_string(),
195            name: "hello".to_string(),
196            description: None,
197            backend: RegisteredPluginCommandBackend::Shell {
198                command: command.to_string(),
199                args: args.into_iter().map(str::to_string).collect(),
200            },
201            plugin_root: root,
202        }
203    }
204
205    fn tempdir() -> PathBuf {
206        let dir = std::env::temp_dir().join(format!(
207            "synaps-plugin-cmd-test-{}-{}",
208            std::process::id(),
209            crate::epoch_millis()
210        ));
211        std::fs::create_dir_all(&dir).unwrap();
212        dir
213    }
214
215    #[tokio::test]
216    async fn execute_plugin_command_runs_from_plugin_root_and_appends_slash_args() {
217        let root = tempdir();
218        let command = cmd("printf", vec!["cwd=%s arg=%s", "."], root);
219
220        let output = execute_plugin_command(&command, "extra").await.unwrap();
221
222        assert_eq!(output.status, Some(0));
223        assert_eq!(output.stdout, "cwd=. arg=extra");
224    }
225
226    #[tokio::test]
227    async fn execute_plugin_command_rejects_parent_dir_command_path() {
228        let root = tempdir();
229        let command = cmd("../evil", vec![], root);
230
231        let result = execute_plugin_command(&command, "").await;
232
233        assert!(result.is_err());
234    }
235
236    #[tokio::test]
237    async fn skill_prompt_command_outputs_prompt_with_args() {
238        let command = RegisteredPluginCommand {
239            plugin: "p".to_string(),
240            name: "review".to_string(),
241            description: None,
242            backend: RegisteredPluginCommandBackend::SkillPrompt {
243                skill: "reviewer".to_string(),
244                prompt: "Review: ${args}".to_string(),
245            },
246            plugin_root: PathBuf::from("/tmp/p"),
247        };
248
249        let output = execute_plugin_command(&command, "diff").await.unwrap();
250        assert_eq!(output.status, Some(0));
251        assert!(output.stdout.contains("Review: diff"));
252    }
253
254    struct EchoTool;
255
256    #[async_trait]
257    impl Tool for EchoTool {
258        fn name(&self) -> &str {
259            "p:echo"
260        }
261        fn description(&self) -> &str {
262            "echo"
263        }
264        fn parameters(&self) -> Value {
265            serde_json::json!({"type":"object"})
266        }
267        async fn execute(&self, params: Value, _ctx: ToolContext) -> crate::Result<String> {
268            Ok(format!("echo {}", params["text"].as_str().unwrap()))
269        }
270    }
271
272    #[tokio::test]
273    async fn extension_tool_command_executes_namespaced_registered_tool() {
274        let command = RegisteredPluginCommand {
275            plugin: "p".to_string(),
276            name: "echo".to_string(),
277            description: None,
278            backend: RegisteredPluginCommandBackend::ExtensionTool {
279                tool: "echo".to_string(),
280                input: serde_json::json!({"text":"${args}"}),
281            },
282            plugin_root: PathBuf::from("/tmp/p"),
283        };
284        let registry = Arc::new(tokio::sync::RwLock::new(ToolRegistry::without_subagent()));
285        registry.write().await.register(Arc::new(EchoTool));
286
287        let output = execute_plugin_command_with_tools(&command, "hello", registry)
288            .await
289            .unwrap();
290
291        assert_eq!(output.status, Some(0));
292        assert_eq!(output.stdout, "echo hello");
293    }
294}