Skip to main content

synaps_cli/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(items.into_iter().map(|v| interpolate_slash_args(v, slash_args)).collect()),
40        Value::Object(obj) => Value::Object(
41            obj.into_iter()
42                .map(|(k, v)| (k, interpolate_slash_args(v, slash_args)))
43                .collect(),
44        ),
45        other => other,
46    }
47}
48
49pub async fn execute_plugin_command(
50    command: &RegisteredPluginCommand,
51    slash_args: &str,
52) -> crate::Result<PluginCommandOutput> {
53    match &command.backend {
54        RegisteredPluginCommandBackend::Shell { command: executable, args } => {
55            validate_command_path(executable)?;
56
57            let mut cmd = tokio::process::Command::new(executable);
58            cmd.current_dir(&command.plugin_root)
59                .args(args)
60                .args(slash_words(slash_args))
61                .stdin(Stdio::null())
62                .stdout(Stdio::piped())
63                .stderr(Stdio::piped());
64
65            let output = cmd.output().await.map_err(|e| {
66                crate::RuntimeError::Tool(format!(
67                    "failed to run plugin command /{}:{}: {}",
68                    command.plugin, command.name, e
69                ))
70            })?;
71
72            Ok(PluginCommandOutput {
73                status: output.status.code(),
74                stdout: String::from_utf8_lossy(&output.stdout).to_string(),
75                stderr: String::from_utf8_lossy(&output.stderr).to_string(),
76            })
77        }
78        RegisteredPluginCommandBackend::SkillPrompt { skill, prompt } => {
79            let mut text = prompt.replace("${args}", slash_args);
80            if !slash_args.trim().is_empty() && !prompt.contains("${args}") {
81                text.push('\n');
82                text.push_str(slash_args);
83            }
84            Ok(PluginCommandOutput {
85                status: Some(0),
86                stdout: format!("Skill prompt for /{}:{} (skill: {})\n{}", command.plugin, command.name, skill, text),
87                stderr: String::new(),
88            })
89        }
90        RegisteredPluginCommandBackend::ExtensionTool { .. } => Err(crate::RuntimeError::Tool(
91            "extension-backed plugin command requires execute_plugin_command_with_tools".to_string(),
92        )),
93        RegisteredPluginCommandBackend::Interactive { .. } => Err(crate::RuntimeError::Tool(
94            "interactive plugin command requires ExtensionManager::invoke_command".to_string(),
95        )),
96    }
97}
98
99pub async fn execute_plugin_command_with_tools(
100    command: &RegisteredPluginCommand,
101    slash_args: &str,
102    tools: Arc<tokio::sync::RwLock<ToolRegistry>>,
103) -> crate::Result<PluginCommandOutput> {
104    if let RegisteredPluginCommandBackend::ExtensionTool { tool, input } = &command.backend {
105        let runtime_tool_name = format!("{}:{}", command.plugin, tool);
106        let params = interpolate_slash_args(input.clone(), slash_args);
107        let registry = tools.read().await;
108        let tool = registry.get(&runtime_tool_name).ok_or_else(|| {
109            crate::RuntimeError::Tool(format!("extension tool '{}' is not registered", runtime_tool_name))
110        })?.clone();
111        drop(registry);
112        let stdout = tool.execute(params, empty_tool_context()).await?;
113        Ok(PluginCommandOutput { status: Some(0), stdout, stderr: String::new() })
114    } else {
115        execute_plugin_command(command, slash_args).await
116    }
117}
118
119fn empty_tool_context() -> ToolContext {
120    ToolContext {
121        channels: ToolChannels { tx_delta: None, tx_events: None },
122        capabilities: ToolCapabilities {
123            watcher_exit_path: None,
124            tool_register_tx: None,
125            session_manager: None,
126            subagent_registry: None,
127            event_queue: None,
128            secret_prompt: None,
129        },
130        limits: ToolLimits {
131            max_tool_output: 30_000,
132            bash_timeout: 30,
133            bash_max_timeout: 300,
134            subagent_timeout: 300,
135        },
136    }
137}
138
139fn slash_words(input: &str) -> Vec<String> {
140    input.split_whitespace().map(str::to_string).collect()
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use async_trait::async_trait;
147    use std::path::PathBuf;
148    use crate::Tool;
149
150    fn cmd(command: &str, args: Vec<&str>, root: PathBuf) -> RegisteredPluginCommand {
151        RegisteredPluginCommand {
152            plugin: "p".to_string(),
153            name: "hello".to_string(),
154            description: None,
155            backend: RegisteredPluginCommandBackend::Shell {
156                command: command.to_string(),
157                args: args.into_iter().map(str::to_string).collect(),
158            },
159            plugin_root: root,
160        }
161    }
162
163    fn tempdir() -> PathBuf {
164        let dir = std::env::temp_dir().join(format!(
165            "synaps-plugin-cmd-test-{}-{}",
166            std::process::id(),
167            crate::epoch_millis()
168        ));
169        std::fs::create_dir_all(&dir).unwrap();
170        dir
171    }
172
173    #[tokio::test]
174    async fn execute_plugin_command_runs_from_plugin_root_and_appends_slash_args() {
175        let root = tempdir();
176        let command = cmd("printf", vec!["cwd=%s arg=%s", "."], root);
177
178        let output = execute_plugin_command(&command, "extra").await.unwrap();
179
180        assert_eq!(output.status, Some(0));
181        assert_eq!(output.stdout, "cwd=. arg=extra");
182    }
183
184    #[tokio::test]
185    async fn execute_plugin_command_rejects_parent_dir_command_path() {
186        let root = tempdir();
187        let command = cmd("../evil", vec![], root);
188
189        let result = execute_plugin_command(&command, "").await;
190
191        assert!(result.is_err());
192    }
193
194    #[tokio::test]
195    async fn skill_prompt_command_outputs_prompt_with_args() {
196        let command = RegisteredPluginCommand {
197            plugin: "p".to_string(),
198            name: "review".to_string(),
199            description: None,
200            backend: RegisteredPluginCommandBackend::SkillPrompt {
201                skill: "reviewer".to_string(),
202                prompt: "Review: ${args}".to_string(),
203            },
204            plugin_root: PathBuf::from("/tmp/p"),
205        };
206
207        let output = execute_plugin_command(&command, "diff").await.unwrap();
208        assert_eq!(output.status, Some(0));
209        assert!(output.stdout.contains("Review: diff"));
210    }
211
212    struct EchoTool;
213
214    #[async_trait]
215    impl Tool for EchoTool {
216        fn name(&self) -> &str { "p:echo" }
217        fn description(&self) -> &str { "echo" }
218        fn parameters(&self) -> Value { serde_json::json!({"type":"object"}) }
219        async fn execute(&self, params: Value, _ctx: ToolContext) -> crate::Result<String> {
220            Ok(format!("echo {}", params["text"].as_str().unwrap()))
221        }
222    }
223
224    #[tokio::test]
225    async fn extension_tool_command_executes_namespaced_registered_tool() {
226        let command = RegisteredPluginCommand {
227            plugin: "p".to_string(),
228            name: "echo".to_string(),
229            description: None,
230            backend: RegisteredPluginCommandBackend::ExtensionTool {
231                tool: "echo".to_string(),
232                input: serde_json::json!({"text":"${args}"}),
233            },
234            plugin_root: PathBuf::from("/tmp/p"),
235        };
236        let registry = Arc::new(tokio::sync::RwLock::new(ToolRegistry::without_subagent()));
237        registry.write().await.register(Arc::new(EchoTool));
238
239        let output = execute_plugin_command_with_tools(&command, "hello", registry).await.unwrap();
240
241        assert_eq!(output.status, Some(0));
242        assert_eq!(output.stdout, "echo hello");
243    }
244}