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