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 #[serde(rename = "mcp-server")]
19 McpServer,
20}
21
22impl CapabilityType {
23 pub fn plural_dir(&self) -> &'static str {
24 match self {
25 Self::Skill => "skills",
26 Self::Tool => "tools",
27 Self::Hook => "hooks",
28 Self::Workflow => "workflows",
29 Self::Policy => "policies",
30 Self::McpServer => "mcp-servers",
31 }
32 }
33
34 pub fn as_str(&self) -> &'static str {
35 match self {
36 Self::Skill => "skill",
37 Self::Tool => "tool",
38 Self::Hook => "hook",
39 Self::Workflow => "workflow",
40 Self::Policy => "policy",
41 Self::McpServer => "mcp-server",
42 }
43 }
44
45 pub fn parse(s: &str) -> Option<Self> {
46 match s {
47 "skill" => Some(Self::Skill),
48 "tool" => Some(Self::Tool),
49 "hook" => Some(Self::Hook),
50 "workflow" => Some(Self::Workflow),
51 "policy" => Some(Self::Policy),
52 "mcp-server" | "mcp" => Some(Self::McpServer),
53 _ => None,
54 }
55 }
56}
57
58impl std::fmt::Display for CapabilityType {
59 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60 f.write_str(self.as_str())
61 }
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct CapabilityManifest {
66 pub id: String,
67 pub version: String,
68 #[serde(rename = "type")]
69 pub capability_type: CapabilityType,
70 pub description: String,
71 #[serde(default)]
72 pub files: Vec<String>,
73 #[serde(default)]
74 pub parameters: Option<serde_json::Value>,
75 #[serde(default)]
76 pub implementation: Option<ImplementationConfig>,
77 #[serde(default)]
78 pub hook: Option<HookConfig>,
79 #[serde(default)]
80 pub workflow: Option<WorkflowConfig>,
81 #[serde(default)]
82 pub server: Option<McpServerConfig>,
83 #[serde(default)]
84 #[allow(dead_code)]
85 pub targets: Vec<String>,
86
87 #[serde(skip)]
88 pub root: PathBuf,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct McpServerConfig {
98 #[serde(default)]
99 pub transport: McpTransport,
100 #[serde(default)]
101 pub command: Option<String>,
102 #[serde(default)]
103 pub args: Vec<String>,
104 #[serde(default)]
105 pub url: Option<String>,
106 #[serde(default)]
107 pub env: std::collections::BTreeMap<String, EnvRef>,
108 #[serde(default)]
109 pub metadata: Option<McpServerMetadata>,
110}
111
112#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(rename_all = "lowercase")]
114pub enum McpTransport {
115 #[default]
116 Stdio,
117 Http,
118}
119
120impl McpTransport {
121 pub fn as_str(&self) -> &'static str {
122 match self {
123 Self::Stdio => "stdio",
124 Self::Http => "http",
125 }
126 }
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(deny_unknown_fields)]
134pub struct EnvRef {
135 pub from_env: String,
136}
137
138#[derive(Debug, Clone, Default, Serialize, Deserialize)]
139pub struct McpServerMetadata {
140 #[serde(default)]
141 pub tools_summary: Option<String>,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct ImplementationConfig {
146 pub language: String,
147 pub entrypoint: String,
148 #[serde(default)]
149 pub mcp: bool,
150 #[serde(default)]
151 pub runtime_deps: Vec<String>,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct HookConfig {
156 pub event: String,
157 pub command: String,
158 #[serde(default = "default_cwd")]
159 pub working_directory: String,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct WorkflowConfig {
164 pub requires: Vec<Requirement>,
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct Requirement {
169 pub id: String,
170 #[serde(rename = "type")]
171 pub capability_type: CapabilityType,
172}
173
174fn default_cwd() -> String {
175 ".".to_string()
176}
177
178impl CapabilityManifest {
179 pub fn source_files(&self) -> Result<Vec<PathBuf>> {
180 let mut paths = Vec::new();
181
182 for f in &self.files {
183 let clean = f.trim_start_matches("./");
184 let path = self.root.join(clean);
185 if !path.exists() {
186 return Err(TuffError::new(format!(
187 "capability source file not found: {}",
188 path.display()
189 )));
190 }
191 paths.push(path);
192 }
193
194 if self.capability_type == CapabilityType::Tool
195 && let Some(ref imp) = self.implementation
196 {
197 let ep_path = self.root.join(&imp.entrypoint);
198 if !paths.contains(&ep_path) && ep_path.exists() {
199 paths.push(ep_path);
200 }
201 }
202
203 Ok(paths)
204 }
205
206 pub fn read_source_contents_with_names(&self) -> Result<Vec<(String, Vec<u8>)>> {
207 self.source_files()?
208 .iter()
209 .map(|p| {
210 let rel = p
211 .strip_prefix(&self.root)
212 .unwrap_or(p)
213 .to_string_lossy()
214 .replace('\\', "/");
215 let rel = rel.strip_prefix("src/").unwrap_or(&rel).to_string();
216 let content = std::fs::read(p)?;
217 Ok((rel, content))
218 })
219 .collect()
220 }
221}
222
223fn validate_non_empty(field: &str, value: &str) -> Result<()> {
224 if value.is_empty() {
225 return Err(TuffError::new(format!(
226 "capability manifest field '{field}' must be a non-empty string"
227 )));
228 }
229 Ok(())
230}
231
232pub fn load_manifest(capability_dir: &Path) -> Result<CapabilityManifest> {
233 let manifest_path = capability_dir.join("tuff.toml");
234 if !manifest_path.exists() {
235 return Err(TuffError::new(format!(
236 "capability manifest not found: {}",
237 manifest_path.display()
238 )));
239 }
240
241 let raw = std::fs::read_to_string(&manifest_path)?;
242 let mut manifest = parse_manifest(&raw, &manifest_path)?;
243 manifest.root = capability_dir.to_path_buf();
244
245 validate_non_empty("id", &manifest.id)?;
246 validate_non_empty("version", &manifest.version)?;
247 validate_non_empty("type", &manifest.capability_type.to_string())?;
248 validate_non_empty("description", &manifest.description)?;
249
250 match manifest.capability_type {
251 CapabilityType::Skill => {
252 if manifest.files.is_empty() {
253 return Err(TuffError::new("skill capability 'files' must not be empty"));
254 }
255 manifest.source_files()?;
256 }
257 CapabilityType::Tool => {
258 if manifest.parameters.is_none() {
259 return Err(TuffError::new(
260 "tool capability requires a [parameters] section with JSON Schema",
261 ));
262 }
263 if manifest.implementation.is_none() {
264 return Err(TuffError::new(
265 "tool capability requires an [implementation] section",
266 ));
267 }
268
269 let params = manifest.parameters.as_ref().unwrap();
270 crate::tool::validate_json_schema(params)?;
271
272 let impl_cfg = manifest.implementation.as_ref().unwrap();
273 crate::tool::validate_entrypoint(&manifest.root, &impl_cfg.entrypoint)?;
274
275 if !impl_cfg.runtime_deps.is_empty() {
276 eprintln!(
277 "note: this tool requires runtime dependencies: {}",
278 impl_cfg.runtime_deps.join(", ")
279 );
280 }
281
282 if !manifest.files.is_empty() {
283 manifest.source_files()?;
284 }
285 }
286 CapabilityType::Hook => {
287 let hook_cfg = manifest
288 .hook
289 .as_ref()
290 .ok_or_else(|| TuffError::new("hook capability requires a [hook] section"))?;
291
292 if hook_cfg.event.trim().is_empty() {
293 return Err(TuffError::new("hook 'event' must be a non-empty string"));
294 }
295 if hook_cfg.command.trim().is_empty() {
296 return Err(TuffError::new("hook 'command' must be a non-empty string"));
297 }
298
299 crate::tool::check_path_traversal(&hook_cfg.working_directory)?;
300
301 eprintln!(
302 "note: this hook runs '{}' on event '{}' — it will not be executed during install",
303 hook_cfg.command, hook_cfg.event
304 );
305
306 if !manifest.files.is_empty() {
307 manifest.source_files()?;
308 }
309 }
310 CapabilityType::Workflow => {
311 let wf = manifest.workflow.as_ref().ok_or_else(|| {
312 TuffError::new("workflow capability requires a [[workflow.requires]] section")
313 })?;
314
315 if wf.requires.is_empty() {
316 return Err(TuffError::new(
317 "workflow 'requires' must have at least one entry",
318 ));
319 }
320
321 let mut seen = std::collections::HashSet::new();
322 for req in &wf.requires {
323 if req.id.trim().is_empty() {
324 return Err(TuffError::new(
325 "workflow requirement 'id' must not be empty",
326 ));
327 }
328 if req.id == manifest.id {
329 return Err(TuffError::new("workflow cannot require itself"));
330 }
331 if !seen.insert(&req.id) {
332 return Err(TuffError::new(format!(
333 "duplicate requirement '{}' in workflow",
334 req.id
335 )));
336 }
337 }
338
339 let names: Vec<_> = wf
340 .requires
341 .iter()
342 .map(|r| format!("{} ({})", r.id, r.capability_type))
343 .collect();
344 eprintln!(
345 "note: workflow '{}' requires {} capabilities: {}",
346 manifest.id,
347 names.len(),
348 names.join(", ")
349 );
350 }
351 CapabilityType::Policy => {
352 return Err(TuffError::new("policy capabilities are not supported yet"));
353 }
354 CapabilityType::McpServer => {
355 let server = manifest.server.as_ref().ok_or_else(|| {
356 TuffError::new("mcp-server capability requires a [server] section")
357 })?;
358 validate_mcp_server(server)?;
359
360 if !manifest.files.is_empty() {
361 manifest.source_files()?;
362 }
363 }
364 }
365
366 Ok(manifest)
367}
368
369pub fn validate_mcp_server(server: &McpServerConfig) -> Result<()> {
370 match server.transport {
371 McpTransport::Stdio => {
372 if server
373 .command
374 .as_deref()
375 .is_none_or(|c| c.trim().is_empty())
376 {
377 return Err(TuffError::new(
378 "mcp-server with transport = \"stdio\" requires a non-empty 'command'",
379 ));
380 }
381 }
382 McpTransport::Http => {
383 if server.url.as_deref().is_none_or(|u| u.trim().is_empty()) {
384 return Err(TuffError::new(
385 "mcp-server with transport = \"http\" requires a non-empty 'url'",
386 ));
387 }
388 }
389 }
390 for (name, reference) in &server.env {
391 if name.trim().is_empty() {
392 return Err(TuffError::new("[server.env] keys must be non-empty"));
393 }
394 if reference.from_env.trim().is_empty() {
395 return Err(TuffError::new(format!(
396 "[server.env] {name} must reference a variable: {name} = {{ from_env = \"VAR\" }}"
397 )));
398 }
399 }
400 Ok(())
401}
402
403fn parse_manifest(raw: &str, manifest_path: &Path) -> Result<CapabilityManifest> {
407 toml::from_str(raw).map_err(|error: toml::de::Error| {
408 let message = error.to_string();
409 let literal_env = raw.contains("[server.env]")
410 && (message.contains("invalid type: string") || message.contains("expected a table"));
411 if literal_env {
412 TuffError::new(format!(
413 "invalid manifest at {}: [server.env] values must be references, never \
414 literals — write NAME = {{ from_env = \"NAME\" }} ({})",
415 manifest_path.display(),
416 message.trim()
417 ))
418 } else {
419 TuffError::from(error)
420 }
421 })
422}
423
424pub fn write_manifest(path: &Path, manifest: &CapabilityManifest) -> Result<()> {
430 std::fs::write(path, toml::to_string_pretty(manifest)?)?;
431 Ok(())
432}
433
434pub fn synthetic_manifest(
435 skill_dir: &Path,
436 name: &str,
437 version: &str,
438) -> Result<CapabilityManifest> {
439 let skill_file = skill_dir.join("SKILL.md");
440 if !skill_file.exists() {
441 return Err(TuffError::new(format!(
442 "skill entrypoint not found: {}",
443 skill_file.display()
444 )));
445 }
446 let mut files = Vec::new();
447 walk_skill_dir(skill_dir, "", &mut files)?;
448 files.sort();
449
450 Ok(CapabilityManifest {
451 id: name.to_string(),
452 version: version.to_string(),
453 capability_type: CapabilityType::Skill,
454 description: "Installed from git source.".to_string(),
455 files,
456 parameters: None,
457 implementation: None,
458 hook: None,
459 workflow: None,
460 server: None,
461 targets: Vec::new(),
462 root: skill_dir.to_path_buf(),
463 })
464}
465
466fn walk_skill_dir(base: &Path, prefix: &str, files: &mut Vec<String>) -> Result<()> {
467 for entry in std::fs::read_dir(base)? {
468 let entry = entry?;
469 let path = entry.path();
470 let metadata = std::fs::symlink_metadata(&path)?;
471 if metadata.file_type().is_symlink() {
472 return Err(TuffError::new(format!(
473 "symbolic links are not allowed in capability sources: {}",
474 path.display()
475 )));
476 }
477 let rel = if prefix.is_empty() {
478 entry.file_name().to_string_lossy().to_string()
479 } else {
480 format!("{}/{}", prefix, entry.file_name().to_string_lossy())
481 };
482 if metadata.is_dir() {
483 walk_skill_dir(&path, &rel, files)?;
484 } else if metadata.is_file() && rel != "tuff.toml" {
485 files.push(rel);
486 }
487 }
488 Ok(())
489}
490
491#[cfg(test)]
492mod tests {
493 use super::*;
494 use std::fs;
495 use tempfile::TempDir;
496
497 fn write_manifest(dir: &std::path::Path, content: &str) {
498 fs::write(dir.join("tuff.toml"), content).unwrap();
499 }
500
501 #[test]
502 fn load_skill_manifest_succeeds() {
503 let tmp = TempDir::new().unwrap();
504 fs::create_dir_all(tmp.path().join("src")).unwrap();
505 fs::write(tmp.path().join("src").join("SKILL.md"), "# Skill").unwrap();
506 write_manifest(
507 tmp.path(),
508 r#"id = "test"
509version = "1.0.0"
510type = "skill"
511description = "A test skill"
512files = ["src/SKILL.md"]
513"#,
514 );
515 let m = load_manifest(tmp.path()).unwrap();
516 assert_eq!(m.id, "test");
517 assert_eq!(m.capability_type, CapabilityType::Skill);
518 }
519
520 #[test]
521 fn load_tool_manifest_succeeds() {
522 let tmp = TempDir::new().unwrap();
523 fs::write(tmp.path().join("run.sh"), "echo ok").unwrap();
524 write_manifest(
525 tmp.path(),
526 r#"id = "tool1"
527version = "1.0.0"
528type = "tool"
529description = "A test tool"
530files = ["run.sh"]
531
532[parameters]
533type = "object"
534required = ["x"]
535[parameters.properties.x]
536type = "string"
537description = "x"
538
539[implementation]
540language = "bash"
541entrypoint = "run.sh"
542"#,
543 );
544 let m = load_manifest(tmp.path()).unwrap();
545 assert_eq!(m.capability_type, CapabilityType::Tool);
546 assert!(m.implementation.is_some());
547 }
548
549 #[test]
550 fn load_hook_manifest_succeeds() {
551 let tmp = TempDir::new().unwrap();
552 write_manifest(
553 tmp.path(),
554 r#"id = "hook1"
555version = "1.0.0"
556type = "hook"
557description = "A test hook"
558
559[hook]
560event = "before_finish"
561command = "cargo test"
562"#,
563 );
564 let m = load_manifest(tmp.path()).unwrap();
565 assert_eq!(m.capability_type, CapabilityType::Hook);
566 assert!(m.hook.is_some());
567 }
568
569 #[test]
570 fn load_rejects_unsupported_type() {
571 let tmp = TempDir::new().unwrap();
572 write_manifest(
573 tmp.path(),
574 r#"id = "bad"
575version = "1.0.0"
576type = "unknown"
577description = "Bad"
578files = ["SKILL.md"]
579"#,
580 );
581 assert!(load_manifest(tmp.path()).is_err());
582 }
583
584 #[test]
585 fn load_rejects_missing_manifest() {
586 let tmp = TempDir::new().unwrap();
587 assert!(load_manifest(tmp.path()).is_err());
588 }
589
590 #[test]
591 fn source_files_resolves_paths() {
592 let tmp = TempDir::new().unwrap();
593 fs::create_dir_all(tmp.path().join("src")).unwrap();
594 fs::write(tmp.path().join("src").join("SKILL.md"), "skill").unwrap();
595 let m = CapabilityManifest {
596 id: "t".into(),
597 version: "1.0".into(),
598 capability_type: CapabilityType::Skill,
599 description: "desc".into(),
600 files: vec!["src/SKILL.md".into()],
601 parameters: None,
602 implementation: None,
603 hook: None,
604 workflow: None,
605 server: None,
606 targets: vec![],
607 root: tmp.path().to_path_buf(),
608 };
609 let files = m.source_files().unwrap();
610 assert_eq!(files.len(), 1);
611 assert!(files[0].ends_with("SKILL.md"));
612 }
613
614 #[test]
615 fn source_files_rejects_missing_file() {
616 let tmp = TempDir::new().unwrap();
617 let m = CapabilityManifest {
618 id: "t".into(),
619 version: "1.0".into(),
620 capability_type: CapabilityType::Skill,
621 description: "desc".into(),
622 files: vec!["src/MISSING.md".into()],
623 parameters: None,
624 implementation: None,
625 hook: None,
626 workflow: None,
627 server: None,
628 targets: vec![],
629 root: tmp.path().to_path_buf(),
630 };
631 assert!(m.source_files().is_err());
632 }
633
634 #[test]
635 fn validate_non_empty_rejects_empty() {
636 assert!(validate_non_empty("id", "").is_err());
637 assert!(validate_non_empty("id", "ok").is_ok());
638 }
639
640 fn load_mcp(toml_body: &str) -> Result<CapabilityManifest> {
641 let tmp = TempDir::new().unwrap();
642 fs::write(tmp.path().join("tuff.toml"), toml_body).unwrap();
643 load_manifest(tmp.path())
644 }
645
646 const MCP_HEAD: &str =
647 "id = \"srv\"\nversion = \"1.0.0\"\ntype = \"mcp-server\"\ndescription = \"d\"\n";
648
649 #[test]
650 fn mcp_server_requires_server_section() {
651 let error = load_mcp(MCP_HEAD).unwrap_err().to_string();
652 assert!(error.contains("requires a [server] section"), "{error}");
653 }
654
655 #[test]
656 fn mcp_server_stdio_requires_command_and_http_requires_url() {
657 let error = load_mcp(&format!("{MCP_HEAD}[server]\ntransport = \"stdio\"\n"))
658 .unwrap_err()
659 .to_string();
660 assert!(error.contains("requires a non-empty 'command'"), "{error}");
661 let error = load_mcp(&format!("{MCP_HEAD}[server]\ntransport = \"http\"\n"))
662 .unwrap_err()
663 .to_string();
664 assert!(error.contains("requires a non-empty 'url'"), "{error}");
665 let ok = load_mcp(&format!(
666 "{MCP_HEAD}[server]\ntransport = \"http\"\nurl = \"https://example.test/mcp\"\n"
667 ))
668 .unwrap();
669 assert_eq!(ok.server.unwrap().transport, McpTransport::Http);
670 }
671
672 #[test]
673 fn mcp_server_env_must_be_a_reference_not_a_literal() {
674 let error = load_mcp(&format!(
675 "{MCP_HEAD}[server]\ncommand = \"npx\"\n[server.env]\nTOKEN = \"literal\"\n"
676 ))
677 .unwrap_err()
678 .to_string();
679 assert!(error.contains("from_env"), "{error}");
680
681 let ok = load_mcp(&format!(
682 "{MCP_HEAD}[server]\ncommand = \"npx\"\n[server.env]\nTOKEN = {{ from_env = \"MY_TOKEN\" }}\n"
683 ))
684 .unwrap();
685 assert_eq!(ok.server.unwrap().env["TOKEN"].from_env, "MY_TOKEN");
686 }
687
688 #[test]
689 fn capability_type_round_trips_the_hyphenated_name() {
690 assert_eq!(CapabilityType::McpServer.as_str(), "mcp-server");
691 assert_eq!(
692 CapabilityType::parse("mcp-server"),
693 Some(CapabilityType::McpServer)
694 );
695 assert_eq!(
696 CapabilityType::parse("mcp"),
697 Some(CapabilityType::McpServer)
698 );
699 let wire = toml::to_string(&Requirement {
700 id: "x".into(),
701 capability_type: CapabilityType::McpServer,
702 })
703 .unwrap();
704 assert!(wire.contains("type = \"mcp-server\""), "{wire}");
705 }
706}