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