1use crate::security::SecurityPolicy;
23use crate::skills::Skill;
24use crate::tools::traits::{Tool, ToolResult};
25use async_trait::async_trait;
26use serde_json::{Value, json};
27use std::path::PathBuf;
28use std::sync::Arc;
29
30pub trait SkillSource: Send + Sync {
35 fn load(&self) -> Vec<Skill>;
36}
37
38pub struct DiskSkillSource {
41 workspace_dir: PathBuf,
42 open_skills_enabled: bool,
43 open_skills_dir: Option<String>,
44}
45
46impl DiskSkillSource {
47 pub fn new(
48 workspace_dir: PathBuf,
49 open_skills_enabled: bool,
50 open_skills_dir: Option<String>,
51 ) -> Self {
52 Self {
53 workspace_dir,
54 open_skills_enabled,
55 open_skills_dir,
56 }
57 }
58}
59
60impl SkillSource for DiskSkillSource {
61 fn load(&self) -> Vec<Skill> {
62 crate::skills::load_skills_with_open_skills_settings(
63 &self.workspace_dir,
64 self.open_skills_enabled,
65 self.open_skills_dir.as_deref(),
66 )
67 }
68}
69
70fn summarize_skill(skill: &Skill) -> Value {
71 let location = skill
72 .location
73 .as_ref()
74 .map(|p| p.to_string_lossy().into_owned());
75 json!({
76 "id": skill.name,
77 "name": skill.name,
78 "description": skill.description,
79 "version": skill.version,
80 "author": skill.author,
81 "tags": skill.tags,
82 "location": location,
83 "tool_count": skill.tools.len(),
84 })
85}
86
87fn find_skill<'a>(skills: &'a [Skill], id: &str) -> Option<&'a Skill> {
88 skills
89 .iter()
90 .find(|s| s.name.eq_ignore_ascii_case(id.trim()))
91}
92
93pub struct SkillsListTool {
97 source: Arc<dyn SkillSource>,
98}
99
100impl SkillsListTool {
101 pub fn new(source: Arc<dyn SkillSource>) -> Self {
102 Self { source }
103 }
104}
105
106#[async_trait]
107impl Tool for SkillsListTool {
108 fn name(&self) -> &str {
109 "skills_list"
110 }
111
112 fn description(&self) -> &str {
113 "List all Construct skills available to the local daemon. Returns a JSON array of { id, name, description, version, tags, tool_count, location } objects."
114 }
115
116 fn parameters_schema(&self) -> Value {
117 json!({ "type": "object", "properties": {}, "additionalProperties": false })
118 }
119
120 async fn execute(&self, _args: Value) -> anyhow::Result<ToolResult> {
121 let skills = self.source.load();
122 let payload: Vec<Value> = skills.iter().map(summarize_skill).collect();
123 Ok(ToolResult {
124 success: true,
125 output: serde_json::to_string_pretty(&payload)?,
126 error: None,
127 })
128 }
129}
130
131pub struct SkillsDescribeTool {
135 source: Arc<dyn SkillSource>,
136}
137
138impl SkillsDescribeTool {
139 pub fn new(source: Arc<dyn SkillSource>) -> Self {
140 Self { source }
141 }
142}
143
144#[async_trait]
145impl Tool for SkillsDescribeTool {
146 fn name(&self) -> &str {
147 "skills_describe"
148 }
149
150 fn description(&self) -> &str {
151 "Return the full body (markdown / SKILL.toml) plus metadata for a single Construct skill by id."
152 }
153
154 fn parameters_schema(&self) -> Value {
155 json!({
156 "type": "object",
157 "properties": {
158 "skill_id": {
159 "type": "string",
160 "description": "Exact skill name/id as returned by skills_list."
161 }
162 },
163 "required": ["skill_id"]
164 })
165 }
166
167 async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
168 let id = args
169 .get("skill_id")
170 .and_then(Value::as_str)
171 .map(str::trim)
172 .filter(|s| !s.is_empty());
173 let Some(id) = id else {
174 return Ok(ToolResult {
175 success: false,
176 output: String::new(),
177 error: Some("skills_describe requires `skill_id`".into()),
178 });
179 };
180
181 let skills = self.source.load();
182 let Some(skill) = find_skill(&skills, id) else {
183 let mut names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect();
184 names.sort_unstable();
185 return Ok(ToolResult {
186 success: false,
187 output: String::new(),
188 error: Some(format!(
189 "Unknown skill '{id}'. Available: {}",
190 if names.is_empty() {
191 "none".into()
192 } else {
193 names.join(", ")
194 }
195 )),
196 });
197 };
198
199 let body = if let Some(loc) = &skill.location {
200 tokio::fs::read_to_string(loc).await.unwrap_or_default()
201 } else {
202 String::new()
203 };
204
205 let payload = json!({
206 "id": skill.name,
207 "description": skill.description,
208 "version": skill.version,
209 "author": skill.author,
210 "tags": skill.tags,
211 "tools": skill.tools.iter().map(|t| json!({
212 "name": t.name,
213 "description": t.description,
214 "kind": t.kind,
215 })).collect::<Vec<_>>(),
216 "body": body,
217 "location": skill.location.as_ref().map(|p| p.to_string_lossy().into_owned()),
218 });
219
220 Ok(ToolResult {
221 success: true,
222 output: serde_json::to_string_pretty(&payload)?,
223 error: None,
224 })
225 }
226}
227
228#[async_trait]
232pub trait SkillExecutor: Send + Sync {
233 async fn run(
234 &self,
235 skill: &Skill,
236 sub_tool: Option<&str>,
237 arguments: Value,
238 ) -> anyhow::Result<ToolResult>;
239}
240
241pub struct DefaultSkillExecutor {
245 security: Arc<SecurityPolicy>,
246}
247
248impl DefaultSkillExecutor {
249 pub fn new(security: Arc<SecurityPolicy>) -> Self {
250 Self { security }
251 }
252}
253
254#[async_trait]
255impl SkillExecutor for DefaultSkillExecutor {
256 async fn run(
257 &self,
258 skill: &Skill,
259 sub_tool: Option<&str>,
260 arguments: Value,
261 ) -> anyhow::Result<ToolResult> {
262 if skill.tools.is_empty() {
264 let body = if let Some(loc) = &skill.location {
265 tokio::fs::read_to_string(loc).await.unwrap_or_default()
266 } else {
267 String::new()
268 };
269 return Ok(ToolResult {
270 success: true,
271 output: body,
272 error: None,
273 });
274 }
275
276 let tool = if let Some(name) = sub_tool {
278 skill.tools.iter().find(|t| t.name == name)
279 } else {
280 skill.tools.first()
281 };
282
283 let Some(tool) = tool else {
284 return Ok(ToolResult {
285 success: false,
286 output: String::new(),
287 error: Some(format!(
288 "Skill '{}' has no [[tools]] entry matching '{}'",
289 skill.name,
290 sub_tool.unwrap_or("(first)")
291 )),
292 });
293 };
294
295 match tool.kind.as_str() {
296 "shell" | "script" => {
297 let t = crate::tools::skill_tool::SkillShellTool::new(
298 &skill.name,
299 tool,
300 self.security.clone(),
301 );
302 t.execute(arguments).await
303 }
304 "http" => {
305 let t = crate::tools::skill_http::SkillHttpTool::new(&skill.name, tool);
306 t.execute(arguments).await
307 }
308 other => Ok(ToolResult {
309 success: false,
310 output: String::new(),
311 error: Some(format!("Unsupported skill tool kind: {other}")),
312 }),
313 }
314 }
315}
316
317pub struct SkillsExecuteTool {
319 source: Arc<dyn SkillSource>,
320 executor: Arc<dyn SkillExecutor>,
321}
322
323impl SkillsExecuteTool {
324 pub fn new(source: Arc<dyn SkillSource>, executor: Arc<dyn SkillExecutor>) -> Self {
325 Self { source, executor }
326 }
327}
328
329#[async_trait]
330impl Tool for SkillsExecuteTool {
331 fn name(&self) -> &str {
332 "skills_execute"
333 }
334
335 fn description(&self) -> &str {
336 "Execute a Construct skill by id. For markdown skills this returns the skill body; for skills with [[tools]] entries it invokes the named sub-tool (or the first one) with the supplied `arguments` object."
337 }
338
339 fn parameters_schema(&self) -> Value {
340 json!({
341 "type": "object",
342 "properties": {
343 "skill_id": { "type": "string", "description": "Skill id/name." },
344 "tool": { "type": "string", "description": "Optional sub-tool name within the skill's [[tools]]." },
345 "arguments": { "type": "object", "description": "Arguments for the skill sub-tool." }
346 },
347 "required": ["skill_id"]
348 })
349 }
350
351 async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
352 let id = args
353 .get("skill_id")
354 .and_then(Value::as_str)
355 .map(str::trim)
356 .filter(|s| !s.is_empty());
357 let Some(id) = id else {
358 return Ok(ToolResult {
359 success: false,
360 output: String::new(),
361 error: Some("skills_execute requires `skill_id`".into()),
362 });
363 };
364 let sub = args.get("tool").and_then(Value::as_str);
365 let arguments = args.get("arguments").cloned().unwrap_or_else(|| json!({}));
366
367 let skills = self.source.load();
368 let Some(skill) = find_skill(&skills, id) else {
369 return Ok(ToolResult {
370 success: false,
371 output: String::new(),
372 error: Some(format!("Unknown skill '{id}'")),
373 });
374 };
375
376 self.executor.run(skill, sub, arguments).await
377 }
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383 use std::path::PathBuf;
384 use std::sync::Mutex;
385
386 struct StaticSource(Vec<Skill>);
387 impl SkillSource for StaticSource {
388 fn load(&self) -> Vec<Skill> {
389 self.0.clone()
390 }
391 }
392
393 fn skill(name: &str) -> Skill {
394 Skill {
395 name: name.to_string(),
396 description: format!("desc-{name}"),
397 version: "0.1.0".into(),
398 author: None,
399 tags: vec!["t1".into()],
400 tools: vec![],
401 prompts: vec![],
402 location: None,
403 }
404 }
405
406 #[tokio::test]
407 async fn skills_list_returns_store_contents() {
408 let source: Arc<dyn SkillSource> =
409 Arc::new(StaticSource(vec![skill("alpha"), skill("beta")]));
410 let tool = SkillsListTool::new(source);
411 let res = tool.execute(json!({})).await.unwrap();
412 assert!(res.success);
413 let v: Value = serde_json::from_str(&res.output).unwrap();
414 let arr = v.as_array().unwrap();
415 assert_eq!(arr.len(), 2);
416 assert_eq!(arr[0]["id"], "alpha");
417 assert_eq!(arr[1]["id"], "beta");
418 }
419
420 #[tokio::test]
421 async fn skills_list_empty_returns_empty_array() {
422 let source: Arc<dyn SkillSource> = Arc::new(StaticSource(vec![]));
423 let tool = SkillsListTool::new(source);
424 let res = tool.execute(json!({})).await.unwrap();
425 assert!(res.success);
426 assert_eq!(res.output.trim(), "[]");
427 }
428
429 #[tokio::test]
430 async fn skills_describe_unknown_skill_errors_with_available_list() {
431 let source: Arc<dyn SkillSource> = Arc::new(StaticSource(vec![skill("alpha")]));
432 let tool = SkillsDescribeTool::new(source);
433 let res = tool.execute(json!({ "skill_id": "zeta" })).await.unwrap();
434 assert!(!res.success);
435 assert!(res.error.as_deref().unwrap().contains("alpha"));
436 }
437
438 struct RecordingExecutor {
439 calls: Mutex<Vec<(String, Option<String>, Value)>>,
440 response: String,
441 }
442 #[async_trait]
443 impl SkillExecutor for RecordingExecutor {
444 async fn run(
445 &self,
446 skill: &Skill,
447 sub: Option<&str>,
448 arguments: Value,
449 ) -> anyhow::Result<ToolResult> {
450 self.calls.lock().unwrap().push((
451 skill.name.clone(),
452 sub.map(str::to_string),
453 arguments,
454 ));
455 Ok(ToolResult {
456 success: true,
457 output: self.response.clone(),
458 error: None,
459 })
460 }
461 }
462
463 #[tokio::test]
464 async fn skills_execute_dispatches_to_executor_with_arguments() {
465 let source: Arc<dyn SkillSource> = Arc::new(StaticSource(vec![skill("deploy")]));
466 let exec = Arc::new(RecordingExecutor {
467 calls: Mutex::new(Vec::new()),
468 response: "shipped!".into(),
469 });
470 let tool = SkillsExecuteTool::new(source, exec.clone());
471 let res = tool
472 .execute(json!({
473 "skill_id": "deploy",
474 "tool": "run",
475 "arguments": { "env": "prod" }
476 }))
477 .await
478 .unwrap();
479 assert!(res.success);
480 assert_eq!(res.output, "shipped!");
481 let calls = exec.calls.lock().unwrap();
482 assert_eq!(calls.len(), 1);
483 assert_eq!(calls[0].0, "deploy");
484 assert_eq!(calls[0].1.as_deref(), Some("run"));
485 assert_eq!(calls[0].2["env"], "prod");
486 }
487
488 #[tokio::test]
489 async fn skills_execute_markdown_skill_returns_body() {
490 let tmp = tempfile::TempDir::new().unwrap();
491 let skill_path = tmp.path().join("DEPLOY.md");
492 std::fs::write(&skill_path, "# Deploy\nmarkdown body").unwrap();
493 let mut s = skill("deploy");
494 s.location = Some(skill_path);
495 let source: Arc<dyn SkillSource> = Arc::new(StaticSource(vec![s]));
496 let executor = Arc::new(DefaultSkillExecutor::new(Arc::new(
497 SecurityPolicy::default(),
498 )));
499 let tool = SkillsExecuteTool::new(source, executor);
500 let res = tool.execute(json!({ "skill_id": "deploy" })).await.unwrap();
501 assert!(res.success);
502 assert!(res.output.contains("markdown body"));
503 }
504
505 #[tokio::test]
506 async fn disk_skill_source_reads_from_workspace_skills_dir() {
507 let tmp = tempfile::TempDir::new().unwrap();
508 let skill_dir = tmp.path().join("skills/widget");
509 std::fs::create_dir_all(&skill_dir).unwrap();
510 std::fs::write(skill_dir.join("SKILL.md"), "# Widget\nbody").unwrap();
511 let source = DiskSkillSource::new(tmp.path().to_path_buf(), false, None);
512 let loaded = source.load();
513 assert!(loaded.iter().any(|s| s.name == "widget"));
514 }
515
516 #[allow(dead_code)]
518 fn _path_ref() -> PathBuf {
519 PathBuf::new()
520 }
521}