1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::{Result, TuffError};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
8#[serde(rename_all = "lowercase")]
9pub enum CapabilityType {
10 Skill,
11 Tool,
12 Hook,
13 Workflow,
14 Policy,
15}
16
17impl CapabilityType {
18 pub fn plural_dir(&self) -> &'static str {
19 match self {
20 Self::Skill => "skills",
21 Self::Tool => "tools",
22 Self::Hook => "hooks",
23 Self::Workflow => "workflows",
24 Self::Policy => "policies",
25 }
26 }
27
28 pub fn as_str(&self) -> &'static str {
29 match self {
30 Self::Skill => "skill",
31 Self::Tool => "tool",
32 Self::Hook => "hook",
33 Self::Workflow => "workflow",
34 Self::Policy => "policy",
35 }
36 }
37
38 pub fn parse(s: &str) -> Option<Self> {
39 match s {
40 "skill" => Some(Self::Skill),
41 "tool" => Some(Self::Tool),
42 "hook" => Some(Self::Hook),
43 "workflow" => Some(Self::Workflow),
44 "policy" => Some(Self::Policy),
45 _ => None,
46 }
47 }
48}
49
50impl std::fmt::Display for CapabilityType {
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 f.write_str(self.as_str())
53 }
54}
55
56#[derive(Debug, Deserialize)]
57pub struct CapabilityManifest {
58 pub id: String,
59 pub version: String,
60 #[serde(rename = "type")]
61 pub capability_type: CapabilityType,
62 pub description: String,
63 #[serde(default)]
64 pub files: Vec<String>,
65 #[serde(default)]
66 pub parameters: Option<serde_json::Value>,
67 #[serde(default)]
68 pub implementation: Option<ImplementationConfig>,
69 #[serde(default)]
70 pub hook: Option<HookConfig>,
71 #[serde(default)]
72 pub workflow: Option<WorkflowConfig>,
73 #[serde(default)]
74 #[allow(dead_code)]
75 pub targets: Vec<String>,
76
77 #[serde(skip)]
78 pub root: PathBuf,
79}
80
81#[derive(Debug, Clone, Deserialize)]
82pub struct ImplementationConfig {
83 pub language: String,
84 pub entrypoint: String,
85 #[serde(default)]
86 pub mcp: bool,
87 #[serde(default)]
88 pub runtime_deps: Vec<String>,
89}
90
91#[derive(Debug, Clone, Deserialize)]
92pub struct HookConfig {
93 pub event: String,
94 pub command: String,
95 #[serde(default = "default_cwd")]
96 pub working_directory: String,
97}
98
99#[derive(Debug, Clone, Deserialize)]
100pub struct WorkflowConfig {
101 pub requires: Vec<Requirement>,
102}
103
104#[derive(Debug, Clone, Deserialize)]
105pub struct Requirement {
106 pub id: String,
107 #[serde(rename = "type")]
108 pub capability_type: CapabilityType,
109}
110
111fn default_cwd() -> String {
112 ".".to_string()
113}
114
115impl CapabilityManifest {
116 pub fn source_files(&self) -> Result<Vec<PathBuf>> {
117 let mut paths = Vec::new();
118
119 for f in &self.files {
120 let clean = f.trim_start_matches("./");
121 let path = self.root.join(clean);
122 if !path.exists() {
123 return Err(TuffError::new(format!(
124 "capability source file not found: {}",
125 path.display()
126 )));
127 }
128 paths.push(path);
129 }
130
131 if self.capability_type == CapabilityType::Tool
132 && let Some(ref imp) = self.implementation
133 {
134 let ep_path = self.root.join(&imp.entrypoint);
135 if !paths.contains(&ep_path) && ep_path.exists() {
136 paths.push(ep_path);
137 }
138 }
139
140 Ok(paths)
141 }
142
143 pub fn read_source_contents_with_names(&self) -> Result<Vec<(String, Vec<u8>)>> {
144 self.source_files()?
145 .iter()
146 .map(|p| {
147 let rel = p
148 .strip_prefix(&self.root)
149 .unwrap_or(p)
150 .to_string_lossy()
151 .replace('\\', "/");
152 let rel = rel.strip_prefix("src/").unwrap_or(&rel).to_string();
153 let content = std::fs::read(p)?;
154 Ok((rel, content))
155 })
156 .collect()
157 }
158}
159
160fn validate_non_empty(field: &str, value: &str) -> Result<()> {
161 if value.is_empty() {
162 return Err(TuffError::new(format!(
163 "capability manifest field '{field}' must be a non-empty string"
164 )));
165 }
166 Ok(())
167}
168
169pub fn load_manifest(capability_dir: &Path) -> Result<CapabilityManifest> {
170 let manifest_path = capability_dir.join("tuff.toml");
171 if !manifest_path.exists() {
172 return Err(TuffError::new(format!(
173 "capability manifest not found: {}",
174 manifest_path.display()
175 )));
176 }
177
178 let mut manifest: CapabilityManifest =
179 toml::from_str(&std::fs::read_to_string(&manifest_path)?)?;
180 manifest.root = capability_dir.to_path_buf();
181
182 validate_non_empty("id", &manifest.id)?;
183 validate_non_empty("version", &manifest.version)?;
184 validate_non_empty("type", &manifest.capability_type.to_string())?;
185 validate_non_empty("description", &manifest.description)?;
186
187 match manifest.capability_type {
188 CapabilityType::Skill => {
189 if manifest.files.is_empty() {
190 return Err(TuffError::new("skill capability 'files' must not be empty"));
191 }
192 manifest.source_files()?;
193 }
194 CapabilityType::Tool => {
195 if manifest.parameters.is_none() {
196 return Err(TuffError::new(
197 "tool capability requires a [parameters] section with JSON Schema",
198 ));
199 }
200 if manifest.implementation.is_none() {
201 return Err(TuffError::new(
202 "tool capability requires an [implementation] section",
203 ));
204 }
205
206 let params = manifest.parameters.as_ref().unwrap();
207 crate::tool::validate_json_schema(params)?;
208
209 let impl_cfg = manifest.implementation.as_ref().unwrap();
210 crate::tool::validate_entrypoint(&manifest.root, &impl_cfg.entrypoint)?;
211
212 if !impl_cfg.runtime_deps.is_empty() {
213 eprintln!(
214 "note: this tool requires runtime dependencies: {}",
215 impl_cfg.runtime_deps.join(", ")
216 );
217 }
218
219 if !manifest.files.is_empty() {
220 manifest.source_files()?;
221 }
222 }
223 CapabilityType::Hook => {
224 let hook_cfg = manifest
225 .hook
226 .as_ref()
227 .ok_or_else(|| TuffError::new("hook capability requires a [hook] section"))?;
228
229 if hook_cfg.event.trim().is_empty() {
230 return Err(TuffError::new("hook 'event' must be a non-empty string"));
231 }
232 if hook_cfg.command.trim().is_empty() {
233 return Err(TuffError::new("hook 'command' must be a non-empty string"));
234 }
235
236 crate::tool::check_path_traversal(&hook_cfg.working_directory)?;
237
238 eprintln!(
239 "note: this hook runs '{}' on event '{}' — it will not be executed during install",
240 hook_cfg.command, hook_cfg.event
241 );
242
243 if !manifest.files.is_empty() {
244 manifest.source_files()?;
245 }
246 }
247 CapabilityType::Workflow => {
248 let wf = manifest.workflow.as_ref().ok_or_else(|| {
249 TuffError::new("workflow capability requires a [[workflow.requires]] section")
250 })?;
251
252 if wf.requires.is_empty() {
253 return Err(TuffError::new(
254 "workflow 'requires' must have at least one entry",
255 ));
256 }
257
258 let mut seen = std::collections::HashSet::new();
259 for req in &wf.requires {
260 if req.id.trim().is_empty() {
261 return Err(TuffError::new(
262 "workflow requirement 'id' must not be empty",
263 ));
264 }
265 if req.id == manifest.id {
266 return Err(TuffError::new("workflow cannot require itself"));
267 }
268 if !seen.insert(&req.id) {
269 return Err(TuffError::new(format!(
270 "duplicate requirement '{}' in workflow",
271 req.id
272 )));
273 }
274 }
275
276 let names: Vec<_> = wf
277 .requires
278 .iter()
279 .map(|r| format!("{} ({})", r.id, r.capability_type))
280 .collect();
281 eprintln!(
282 "note: workflow '{}' requires {} capabilities: {}",
283 manifest.id,
284 names.len(),
285 names.join(", ")
286 );
287 }
288 CapabilityType::Policy => {
289 return Err(TuffError::new("policy capabilities are not supported yet"));
290 }
291 }
292
293 Ok(manifest)
294}
295
296pub fn synthetic_manifest(
297 skill_dir: &Path,
298 name: &str,
299 version: &str,
300) -> Result<CapabilityManifest> {
301 let skill_file = skill_dir.join("SKILL.md");
302 if !skill_file.exists() {
303 return Err(TuffError::new(format!(
304 "skill entrypoint not found: {}",
305 skill_file.display()
306 )));
307 }
308 let mut files = Vec::new();
309 walk_skill_dir(skill_dir, "", &mut files)?;
310 files.sort();
311
312 Ok(CapabilityManifest {
313 id: name.to_string(),
314 version: version.to_string(),
315 capability_type: CapabilityType::Skill,
316 description: "Installed from git source.".to_string(),
317 files,
318 parameters: None,
319 implementation: None,
320 hook: None,
321 workflow: None,
322 targets: Vec::new(),
323 root: skill_dir.to_path_buf(),
324 })
325}
326
327fn walk_skill_dir(base: &Path, prefix: &str, files: &mut Vec<String>) -> Result<()> {
328 for entry in std::fs::read_dir(base)? {
329 let entry = entry?;
330 let path = entry.path();
331 let rel = if prefix.is_empty() {
332 entry.file_name().to_string_lossy().to_string()
333 } else {
334 format!("{}/{}", prefix, entry.file_name().to_string_lossy())
335 };
336 if path.is_dir() {
337 walk_skill_dir(&path, &rel, files)?;
338 } else if rel != "tuff.toml" {
339 files.push(rel);
340 }
341 }
342 Ok(())
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348 use std::fs;
349 use tempfile::TempDir;
350
351 fn write_manifest(dir: &std::path::Path, content: &str) {
352 fs::write(dir.join("tuff.toml"), content).unwrap();
353 }
354
355 #[test]
356 fn load_skill_manifest_succeeds() {
357 let tmp = TempDir::new().unwrap();
358 fs::create_dir_all(tmp.path().join("src")).unwrap();
359 fs::write(tmp.path().join("src").join("SKILL.md"), "# Skill").unwrap();
360 write_manifest(
361 tmp.path(),
362 r#"id = "test"
363version = "1.0.0"
364type = "skill"
365description = "A test skill"
366files = ["src/SKILL.md"]
367"#,
368 );
369 let m = load_manifest(tmp.path()).unwrap();
370 assert_eq!(m.id, "test");
371 assert_eq!(m.capability_type, CapabilityType::Skill);
372 }
373
374 #[test]
375 fn load_tool_manifest_succeeds() {
376 let tmp = TempDir::new().unwrap();
377 fs::write(tmp.path().join("run.sh"), "echo ok").unwrap();
378 write_manifest(
379 tmp.path(),
380 r#"id = "tool1"
381version = "1.0.0"
382type = "tool"
383description = "A test tool"
384files = ["run.sh"]
385
386[parameters]
387type = "object"
388required = ["x"]
389[parameters.properties.x]
390type = "string"
391description = "x"
392
393[implementation]
394language = "bash"
395entrypoint = "run.sh"
396"#,
397 );
398 let m = load_manifest(tmp.path()).unwrap();
399 assert_eq!(m.capability_type, CapabilityType::Tool);
400 assert!(m.implementation.is_some());
401 }
402
403 #[test]
404 fn load_hook_manifest_succeeds() {
405 let tmp = TempDir::new().unwrap();
406 write_manifest(
407 tmp.path(),
408 r#"id = "hook1"
409version = "1.0.0"
410type = "hook"
411description = "A test hook"
412
413[hook]
414event = "before_finish"
415command = "cargo test"
416"#,
417 );
418 let m = load_manifest(tmp.path()).unwrap();
419 assert_eq!(m.capability_type, CapabilityType::Hook);
420 assert!(m.hook.is_some());
421 }
422
423 #[test]
424 fn load_rejects_unsupported_type() {
425 let tmp = TempDir::new().unwrap();
426 write_manifest(
427 tmp.path(),
428 r#"id = "bad"
429version = "1.0.0"
430type = "unknown"
431description = "Bad"
432files = ["SKILL.md"]
433"#,
434 );
435 assert!(load_manifest(tmp.path()).is_err());
436 }
437
438 #[test]
439 fn load_rejects_missing_manifest() {
440 let tmp = TempDir::new().unwrap();
441 assert!(load_manifest(tmp.path()).is_err());
442 }
443
444 #[test]
445 fn source_files_resolves_paths() {
446 let tmp = TempDir::new().unwrap();
447 fs::create_dir_all(tmp.path().join("src")).unwrap();
448 fs::write(tmp.path().join("src").join("SKILL.md"), "skill").unwrap();
449 let m = CapabilityManifest {
450 id: "t".into(),
451 version: "1.0".into(),
452 capability_type: CapabilityType::Skill,
453 description: "desc".into(),
454 files: vec!["src/SKILL.md".into()],
455 parameters: None,
456 implementation: None,
457 hook: None,
458 workflow: None,
459 targets: vec![],
460 root: tmp.path().to_path_buf(),
461 };
462 let files = m.source_files().unwrap();
463 assert_eq!(files.len(), 1);
464 assert!(files[0].ends_with("SKILL.md"));
465 }
466
467 #[test]
468 fn source_files_rejects_missing_file() {
469 let tmp = TempDir::new().unwrap();
470 let m = CapabilityManifest {
471 id: "t".into(),
472 version: "1.0".into(),
473 capability_type: CapabilityType::Skill,
474 description: "desc".into(),
475 files: vec!["src/MISSING.md".into()],
476 parameters: None,
477 implementation: None,
478 hook: None,
479 workflow: None,
480 targets: vec![],
481 root: tmp.path().to_path_buf(),
482 };
483 assert!(m.source_files().is_err());
484 }
485
486 #[test]
487 fn validate_non_empty_rejects_empty() {
488 assert!(validate_non_empty("id", "").is_err());
489 assert!(validate_non_empty("id", "ok").is_ok());
490 }
491}