synaps_cli/tools/
agent.rs1use super::util::expand_path;
9
10fn is_valid_name(s: &str) -> bool {
13 !s.is_empty()
14 && !s.contains('/')
15 && !s.contains('\\')
16 && !s.contains("..")
17 && s.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_')
18}
19
20pub fn resolve_agent_prompt(name: &str) -> std::result::Result<String, String> {
22 if name.chars().all(|c| c.is_whitespace() || c.is_control()) {
27 return Err(
28 "Empty 'agent' parameter. Pass a non-empty agent name, omit the field entirely, \
29 or provide 'system_prompt' inline instead.".to_string()
30 );
31 }
32
33 if name.contains('/') {
35 let path = expand_path(name);
36 let content = std::fs::read_to_string(&path)
37 .map_err(|e| format!("Failed to read agent file '{}': {}", path.display(), e))?;
38 return Ok(strip_frontmatter(&content));
39 }
40
41 if let Some((plugin, agent)) = name.split_once(':') {
43 if !is_valid_name(plugin) || !is_valid_name(agent) {
45 return Err(format!(
46 "Invalid agent syntax '{}'. Expected 'plugin:agent' where both are \
47 identifiers (alphanumeric, hyphens, underscores).",
48 name
49 ));
50 }
51 let plugins_dir = crate::config::base_dir().join("plugins");
52 let plugin_dir = plugins_dir.join(plugin);
53 if !plugin_dir.is_dir() {
54 return Err(format!(
55 "Plugin '{}' not found at {}",
56 plugin,
57 plugin_dir.display()
58 ));
59 }
60 let canonical_plugins = plugins_dir.canonicalize().unwrap_or_else(|_| plugins_dir.clone());
62 let canonical_plugin = plugin_dir.canonicalize().unwrap_or_else(|_| plugin_dir.clone());
63 if !canonical_plugin.starts_with(&canonical_plugins) {
64 return Err(format!("Invalid plugin name: '{}'", plugin));
65 }
66 return resolve_namespaced_agent(agent, &plugin_dir);
67 }
68
69 let agents_dir = crate::config::base_dir().join("agents");
71 let agent_path = agents_dir.join(format!("{}.md", name));
72
73 if agent_path.exists() {
74 let content = std::fs::read_to_string(&agent_path)
75 .map_err(|e| format!("Failed to read agent '{}': {}", agent_path.display(), e))?;
76 return Ok(strip_frontmatter(&content));
77 }
78
79 Err(format!(
80 "Agent '{}' not found. Searched:\n - {}\n\
81 Create the file, pass a system_prompt directly, or use 'plugin:agent' syntax for plugin agents.",
82 name,
83 agent_path.display()
84 ))
85}
86
87fn resolve_namespaced_agent(
90 agent: &str,
91 plugin_dir: &std::path::Path,
92) -> std::result::Result<String, String> {
93 let skills_dir = plugin_dir.join("skills");
94 let entries = std::fs::read_dir(&skills_dir).map_err(|e| {
95 format!(
96 "No skills directory in plugin at {}: {}",
97 plugin_dir.display(),
98 e
99 )
100 })?;
101
102 let mut matches: Vec<std::path::PathBuf> = Vec::new();
103 for entry in entries {
104 let entry = entry.map_err(|e| format!("Error reading skills dir: {}", e))?;
105 let agent_path = entry.path().join("agents").join(format!("{}.md", agent));
106 if agent_path.exists() {
107 matches.push(agent_path);
108 }
109 }
110
111 match matches.len() {
112 0 => Err(format!(
113 "Agent '{}' not found in plugin at {}. Searched skills/*/agents/{}.md",
114 agent,
115 plugin_dir.display(),
116 agent
117 )),
118 1 => {
119 let content = std::fs::read_to_string(&matches[0])
120 .map_err(|e| format!("Failed to read agent '{}': {}", matches[0].display(), e))?;
121 Ok(strip_frontmatter(&content))
122 }
123 n => Err(format!(
124 "Ambiguous agent '{}': found in {} skills. Use the full path instead.\n {}",
125 agent,
126 n,
127 matches.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join("\n ")
128 )),
129 }
130}
131
132pub(crate) fn strip_frontmatter(content: &str) -> String {
133 if let Some(rest) = content.strip_prefix("---") {
134 if let Some(end) = rest.find("\n---") {
135 return rest[end + 4..].trim().to_string();
137 }
138 }
139 content.to_string()
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145
146 #[test]
147 fn resolve_namespaced_agent_finds_plugin_agent() {
148 let tmp = tempfile::tempdir().unwrap();
149 let agents_dir = tmp
150 .path()
151 .join("skills")
152 .join("bbe")
153 .join("agents");
154 std::fs::create_dir_all(&agents_dir).unwrap();
155 std::fs::write(
156 agents_dir.join("sage.md"),
157 "---\nname: bbe-sage\ndescription: d\n---\nYou are sage.",
158 )
159 .unwrap();
160
161 let result = resolve_namespaced_agent("sage", tmp.path());
162 assert!(result.is_ok());
163 assert_eq!(result.unwrap(), "You are sage.");
164 }
165
166 #[test]
167 fn resolve_namespaced_agent_not_found() {
168 let tmp = tempfile::tempdir().unwrap();
169 std::fs::create_dir_all(tmp.path().join("skills")).unwrap();
170
171 let result = resolve_namespaced_agent("nonexistent", tmp.path());
172 assert!(result.is_err());
173 assert!(result.unwrap_err().contains("not found"));
174 }
175
176 #[test]
177 fn resolve_namespaced_agent_no_skills_dir() {
178 let tmp = tempfile::tempdir().unwrap();
179 let result = resolve_namespaced_agent("sage", tmp.path());
180 assert!(result.is_err());
181 assert!(result.unwrap_err().contains("No skills directory"));
182 }
183
184 #[test]
185 fn resolve_namespaced_agent_strips_frontmatter() {
186 let tmp = tempfile::tempdir().unwrap();
187 let agents_dir = tmp.path().join("skills").join("s").join("agents");
188 std::fs::create_dir_all(&agents_dir).unwrap();
189 std::fs::write(
190 agents_dir.join("a.md"),
191 "---\nname: x\ndescription: d\n---\nClean body",
192 )
193 .unwrap();
194
195 let result = resolve_namespaced_agent("a", tmp.path()).unwrap();
196 assert_eq!(result, "Clean body");
197 }
198
199 #[test]
200 fn resolve_namespaced_agent_ambiguous_errors() {
201 let tmp = tempfile::tempdir().unwrap();
202 let skill1 = tmp.path().join("skills").join("skill-a").join("agents");
204 let skill2 = tmp.path().join("skills").join("skill-b").join("agents");
205 std::fs::create_dir_all(&skill1).unwrap();
206 std::fs::create_dir_all(&skill2).unwrap();
207 std::fs::write(skill1.join("sage.md"), "---\nname: sage\n---\nA").unwrap();
208 std::fs::write(skill2.join("sage.md"), "---\nname: sage\n---\nB").unwrap();
209
210 let result = resolve_namespaced_agent("sage", tmp.path());
211 assert!(result.is_err());
212 let err = result.unwrap_err();
213 assert!(err.contains("Ambiguous"), "Expected ambiguity error, got: {}", err);
214 assert!(err.contains("2 skills"), "Expected '2 skills' in error, got: {}", err);
215 }
216
217 #[test]
218 fn strip_frontmatter_removes_yaml_header() {
219 let input = "---\nname: x\n---\nBody text";
220 assert_eq!(strip_frontmatter(input), "Body text");
221 }
222
223 #[test]
224 fn strip_frontmatter_passes_through_plain_text() {
225 assert_eq!(strip_frontmatter("Just text"), "Just text");
226 }
227
228 #[test]
229 fn strip_frontmatter_unclosed_returns_raw() {
230 let input = "---\nname: x\nno closing delimiter\nBody";
232 assert_eq!(strip_frontmatter(input), input);
233 }
234
235 #[test]
236 fn is_valid_name_rejects_traversal() {
237 assert!(!is_valid_name("../../etc"));
238 assert!(!is_valid_name("foo/bar"));
239 assert!(!is_valid_name(""));
240 assert!(!is_valid_name("foo..bar"));
241 assert!(!is_valid_name("foo\\bar"));
242 }
243
244 #[test]
245 fn is_valid_name_accepts_good_names() {
246 assert!(is_valid_name("dev-tools"));
247 assert!(is_valid_name("sage"));
248 assert!(is_valid_name("my_agent_123"));
249 assert!(is_valid_name("BBE"));
250 }
251
252 #[test]
253 fn resolve_agent_prompt_rejects_blank_name() {
254 for name in ["", " ", " \t ", "\n", "\u{0}"] {
256 let err = resolve_agent_prompt(name).unwrap_err();
257 assert!(
258 err.contains("Empty 'agent' parameter"),
259 "blank name {:?} should produce the empty-agent error, got: {}",
260 name,
261 err,
262 );
263 assert!(
266 !err.contains("agents/.md"),
267 "blank name {:?} leaked path-search error: {}",
268 name,
269 err,
270 );
271 }
272 }
273
274 #[test]
275 fn test_strip_frontmatter_removes_frontmatter() {
276 let content = "---\ntitle: test\ndate: 2023-01-01\n---\nThis is the content.";
277 let result = strip_frontmatter(content);
278 assert_eq!(result, "This is the content.");
279 }
280
281 #[test]
282 fn test_strip_frontmatter_without_frontmatter() {
283 let content = "This is just plain content.";
284 let result = strip_frontmatter(content);
285 assert_eq!(result, content);
286 }
287
288 #[test]
289 fn test_strip_frontmatter_only_opening_delimiter() {
290 let content = "---\ntitle: test\nno closing delimiter";
291 let result = strip_frontmatter(content);
292 assert_eq!(result, content);
293 }
294
295 #[test]
296 fn test_resolve_agent_prompt_nonexistent() {
297 let result = resolve_agent_prompt("definitely_does_not_exist_12345");
298 assert!(result.is_err());
299 let error = result.unwrap_err();
300 assert!(error.contains("Agent 'definitely_does_not_exist_12345' not found"));
301 }
302
303 #[test]
304 fn test_resolve_agent_prompt_path_not_found() {
305 let result = resolve_agent_prompt("/nonexistent/path/agent.md");
306 assert!(result.is_err());
307 let error = result.unwrap_err();
308 assert!(error.contains("Failed to read agent file"));
309 }
310
311 #[test]
312 fn test_resolve_agent_prompt_blank_rejected_without_agent_lookup() {
313 let result = resolve_agent_prompt("");
314 assert!(result.is_err());
315 let error = result.unwrap_err();
316 assert!(error.contains("Empty 'agent' parameter"));
317 assert!(!error.contains("agents/.md"));
318 }
319}