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::not_found(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::usage(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::not_found(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::usage(
254 "skill capability 'files' must not be empty",
255 ));
256 }
257 manifest.source_files()?;
258 }
259 CapabilityType::Tool => {
260 if manifest.parameters.is_none() {
261 return Err(TuffError::usage(
262 "tool capability requires a [parameters] section with JSON Schema",
263 ));
264 }
265 if manifest.implementation.is_none() {
266 return Err(TuffError::usage(
267 "tool capability requires an [implementation] section",
268 ));
269 }
270
271 let params = manifest.parameters.as_ref().unwrap();
272 crate::tool::validate_json_schema(params)?;
273
274 let impl_cfg = manifest.implementation.as_ref().unwrap();
275 crate::tool::validate_entrypoint(&manifest.root, &impl_cfg.entrypoint)?;
276
277 if !impl_cfg.runtime_deps.is_empty() {
278 eprintln!(
279 "note: this tool requires runtime dependencies: {}",
280 impl_cfg.runtime_deps.join(", ")
281 );
282 }
283
284 if !manifest.files.is_empty() {
285 manifest.source_files()?;
286 }
287 }
288 CapabilityType::Hook => {
289 let hook_cfg = manifest
290 .hook
291 .as_ref()
292 .ok_or_else(|| TuffError::usage("hook capability requires a [hook] section"))?;
293
294 if hook_cfg.event.trim().is_empty() {
295 return Err(TuffError::usage("hook 'event' must be a non-empty string"));
296 }
297 if hook_cfg.command.trim().is_empty() {
298 return Err(TuffError::usage(
299 "hook 'command' must be a non-empty string",
300 ));
301 }
302
303 crate::tool::check_path_traversal(&hook_cfg.working_directory)?;
304
305 eprintln!(
306 "note: this hook runs '{}' on event '{}' — it will not be executed during install",
307 hook_cfg.command, hook_cfg.event
308 );
309
310 if !manifest.files.is_empty() {
311 manifest.source_files()?;
312 }
313 }
314 CapabilityType::Workflow => {
315 let wf = manifest.workflow.as_ref().ok_or_else(|| {
316 TuffError::usage("workflow capability requires a [[workflow.requires]] section")
317 })?;
318
319 if wf.requires.is_empty() {
320 return Err(TuffError::usage(
321 "workflow 'requires' must have at least one entry",
322 ));
323 }
324
325 let mut seen = std::collections::HashSet::new();
326 for req in &wf.requires {
327 if req.id.trim().is_empty() {
328 return Err(TuffError::usage(
329 "workflow requirement 'id' must not be empty",
330 ));
331 }
332 if req.id == manifest.id {
333 return Err(TuffError::usage("workflow cannot require itself"));
334 }
335 if !seen.insert(&req.id) {
336 return Err(TuffError::usage(format!(
337 "duplicate requirement '{}' in workflow",
338 req.id
339 )));
340 }
341 }
342
343 let names: Vec<_> = wf
344 .requires
345 .iter()
346 .map(|r| format!("{} ({})", r.id, r.capability_type))
347 .collect();
348 eprintln!(
349 "note: workflow '{}' requires {} capabilities: {}",
350 manifest.id,
351 names.len(),
352 names.join(", ")
353 );
354 }
355 CapabilityType::Policy => {
356 return Err(TuffError::unsupported(
357 "policy capabilities are not supported yet",
358 ));
359 }
360 CapabilityType::McpServer => {
361 let server = manifest.server.as_ref().ok_or_else(|| {
362 TuffError::usage("mcp-server capability requires a [server] section")
363 })?;
364 validate_mcp_server(server)?;
365
366 if !manifest.files.is_empty() {
367 manifest.source_files()?;
368 }
369 }
370 }
371
372 Ok(manifest)
373}
374
375pub fn validate_mcp_server(server: &McpServerConfig) -> Result<()> {
376 match server.transport {
377 McpTransport::Stdio => {
378 if server
379 .command
380 .as_deref()
381 .is_none_or(|c| c.trim().is_empty())
382 {
383 return Err(TuffError::usage(
384 "mcp-server with transport = \"stdio\" requires a non-empty 'command'",
385 ));
386 }
387 }
388 McpTransport::Http => {
389 if server.url.as_deref().is_none_or(|u| u.trim().is_empty()) {
390 return Err(TuffError::usage(
391 "mcp-server with transport = \"http\" requires a non-empty 'url'",
392 ));
393 }
394 }
395 }
396 for (name, reference) in &server.env {
397 if name.trim().is_empty() {
398 return Err(TuffError::usage("[server.env] keys must be non-empty"));
399 }
400 if reference.from_env.trim().is_empty() {
401 return Err(TuffError::usage(format!(
402 "[server.env] {name} must reference a variable: {name} = {{ from_env = \"VAR\" }}"
403 )));
404 }
405 }
406 Ok(())
407}
408
409fn parse_manifest(raw: &str, manifest_path: &Path) -> Result<CapabilityManifest> {
413 toml::from_str(raw).map_err(|error: toml::de::Error| {
414 let message = error.to_string();
415 let literal_env = raw.contains("[server.env]")
416 && (message.contains("invalid type: string") || message.contains("expected a table"));
417 if literal_env {
418 TuffError::usage(format!(
419 "invalid manifest at {}: [server.env] values must be references, never \
420 literals — write NAME = {{ from_env = \"NAME\" }} ({})",
421 manifest_path.display(),
422 message.trim()
423 ))
424 } else {
425 TuffError::from(error)
426 }
427 })
428}
429
430pub fn write_manifest(path: &Path, manifest: &CapabilityManifest) -> Result<()> {
436 std::fs::write(path, toml::to_string_pretty(manifest)?)?;
437 Ok(())
438}
439
440pub fn synthetic_manifest(
441 skill_dir: &Path,
442 name: &str,
443 version: &str,
444) -> Result<CapabilityManifest> {
445 let skill_file = skill_dir.join("SKILL.md");
446 if !skill_file.exists() {
447 return Err(TuffError::not_found(format!(
448 "skill entrypoint not found: {}",
449 skill_file.display()
450 )));
451 }
452 let mut files = Vec::new();
453 walk_skill_dir(skill_dir, "", &mut files)?;
454 files.sort();
455
456 Ok(CapabilityManifest {
457 id: name.to_string(),
458 version: version.to_string(),
459 capability_type: CapabilityType::Skill,
460 description: "Installed from git source.".to_string(),
461 files,
462 parameters: None,
463 implementation: None,
464 hook: None,
465 workflow: None,
466 server: None,
467 targets: Vec::new(),
468 root: skill_dir.to_path_buf(),
469 })
470}
471
472fn walk_skill_dir(base: &Path, prefix: &str, files: &mut Vec<String>) -> Result<()> {
473 for entry in std::fs::read_dir(base)? {
474 let entry = entry?;
475 let path = entry.path();
476 let metadata = std::fs::symlink_metadata(&path)?;
477 if metadata.file_type().is_symlink() {
478 return Err(TuffError::refused(format!(
479 "symbolic links are not allowed in capability sources: {}",
480 path.display()
481 )));
482 }
483 let rel = if prefix.is_empty() {
484 entry.file_name().to_string_lossy().to_string()
485 } else {
486 format!("{}/{}", prefix, entry.file_name().to_string_lossy())
487 };
488 if metadata.is_dir() {
489 walk_skill_dir(&path, &rel, files)?;
490 } else if metadata.is_file() && rel != "tuff.toml" {
491 files.push(rel);
492 }
493 }
494 Ok(())
495}
496
497#[cfg(test)]
498mod tests {
499 use super::*;
500 use std::fs;
501 use tempfile::TempDir;
502
503 fn write_manifest(dir: &std::path::Path, content: &str) {
504 fs::write(dir.join("tuff.toml"), content).unwrap();
505 }
506
507 #[test]
508 fn load_skill_manifest_succeeds() {
509 let tmp = TempDir::new().unwrap();
510 fs::create_dir_all(tmp.path().join("src")).unwrap();
511 fs::write(tmp.path().join("src").join("SKILL.md"), "# Skill").unwrap();
512 write_manifest(
513 tmp.path(),
514 r#"id = "test"
515version = "1.0.0"
516type = "skill"
517description = "A test skill"
518files = ["src/SKILL.md"]
519"#,
520 );
521 let m = load_manifest(tmp.path()).unwrap();
522 assert_eq!(m.id, "test");
523 assert_eq!(m.capability_type, CapabilityType::Skill);
524 }
525
526 #[test]
527 fn load_tool_manifest_succeeds() {
528 let tmp = TempDir::new().unwrap();
529 fs::write(tmp.path().join("run.sh"), "echo ok").unwrap();
530 write_manifest(
531 tmp.path(),
532 r#"id = "tool1"
533version = "1.0.0"
534type = "tool"
535description = "A test tool"
536files = ["run.sh"]
537
538[parameters]
539type = "object"
540required = ["x"]
541[parameters.properties.x]
542type = "string"
543description = "x"
544
545[implementation]
546language = "bash"
547entrypoint = "run.sh"
548"#,
549 );
550 let m = load_manifest(tmp.path()).unwrap();
551 assert_eq!(m.capability_type, CapabilityType::Tool);
552 assert!(m.implementation.is_some());
553 }
554
555 #[test]
556 fn load_hook_manifest_succeeds() {
557 let tmp = TempDir::new().unwrap();
558 write_manifest(
559 tmp.path(),
560 r#"id = "hook1"
561version = "1.0.0"
562type = "hook"
563description = "A test hook"
564
565[hook]
566event = "before_finish"
567command = "cargo test"
568"#,
569 );
570 let m = load_manifest(tmp.path()).unwrap();
571 assert_eq!(m.capability_type, CapabilityType::Hook);
572 assert!(m.hook.is_some());
573 }
574
575 #[test]
576 fn load_rejects_unsupported_type() {
577 let tmp = TempDir::new().unwrap();
578 write_manifest(
579 tmp.path(),
580 r#"id = "bad"
581version = "1.0.0"
582type = "unknown"
583description = "Bad"
584files = ["SKILL.md"]
585"#,
586 );
587 assert!(load_manifest(tmp.path()).is_err());
588 }
589
590 #[test]
591 fn load_rejects_missing_manifest() {
592 let tmp = TempDir::new().unwrap();
593 assert!(load_manifest(tmp.path()).is_err());
594 }
595
596 #[test]
597 fn source_files_resolves_paths() {
598 let tmp = TempDir::new().unwrap();
599 fs::create_dir_all(tmp.path().join("src")).unwrap();
600 fs::write(tmp.path().join("src").join("SKILL.md"), "skill").unwrap();
601 let m = CapabilityManifest {
602 id: "t".into(),
603 version: "1.0".into(),
604 capability_type: CapabilityType::Skill,
605 description: "desc".into(),
606 files: vec!["src/SKILL.md".into()],
607 parameters: None,
608 implementation: None,
609 hook: None,
610 workflow: None,
611 server: None,
612 targets: vec![],
613 root: tmp.path().to_path_buf(),
614 };
615 let files = m.source_files().unwrap();
616 assert_eq!(files.len(), 1);
617 assert!(files[0].ends_with("SKILL.md"));
618 }
619
620 #[test]
621 fn source_files_rejects_missing_file() {
622 let tmp = TempDir::new().unwrap();
623 let m = CapabilityManifest {
624 id: "t".into(),
625 version: "1.0".into(),
626 capability_type: CapabilityType::Skill,
627 description: "desc".into(),
628 files: vec!["src/MISSING.md".into()],
629 parameters: None,
630 implementation: None,
631 hook: None,
632 workflow: None,
633 server: None,
634 targets: vec![],
635 root: tmp.path().to_path_buf(),
636 };
637 assert!(m.source_files().is_err());
638 }
639
640 #[test]
641 fn validate_non_empty_rejects_empty() {
642 assert!(validate_non_empty("id", "").is_err());
643 assert!(validate_non_empty("id", "ok").is_ok());
644 }
645
646 fn load_mcp(toml_body: &str) -> Result<CapabilityManifest> {
647 let tmp = TempDir::new().unwrap();
648 fs::write(tmp.path().join("tuff.toml"), toml_body).unwrap();
649 load_manifest(tmp.path())
650 }
651
652 const MCP_HEAD: &str =
653 "id = \"srv\"\nversion = \"1.0.0\"\ntype = \"mcp-server\"\ndescription = \"d\"\n";
654
655 #[test]
656 fn mcp_server_requires_server_section() {
657 let error = load_mcp(MCP_HEAD).unwrap_err().to_string();
658 assert!(error.contains("requires a [server] section"), "{error}");
659 }
660
661 #[test]
662 fn mcp_server_stdio_requires_command_and_http_requires_url() {
663 let error = load_mcp(&format!("{MCP_HEAD}[server]\ntransport = \"stdio\"\n"))
664 .unwrap_err()
665 .to_string();
666 assert!(error.contains("requires a non-empty 'command'"), "{error}");
667 let error = load_mcp(&format!("{MCP_HEAD}[server]\ntransport = \"http\"\n"))
668 .unwrap_err()
669 .to_string();
670 assert!(error.contains("requires a non-empty 'url'"), "{error}");
671 let ok = load_mcp(&format!(
672 "{MCP_HEAD}[server]\ntransport = \"http\"\nurl = \"https://example.test/mcp\"\n"
673 ))
674 .unwrap();
675 assert_eq!(ok.server.unwrap().transport, McpTransport::Http);
676 }
677
678 #[test]
679 fn mcp_server_env_must_be_a_reference_not_a_literal() {
680 let error = load_mcp(&format!(
681 "{MCP_HEAD}[server]\ncommand = \"npx\"\n[server.env]\nTOKEN = \"literal\"\n"
682 ))
683 .unwrap_err()
684 .to_string();
685 assert!(error.contains("from_env"), "{error}");
686
687 let ok = load_mcp(&format!(
688 "{MCP_HEAD}[server]\ncommand = \"npx\"\n[server.env]\nTOKEN = {{ from_env = \"MY_TOKEN\" }}\n"
689 ))
690 .unwrap();
691 assert_eq!(ok.server.unwrap().env["TOKEN"].from_env, "MY_TOKEN");
692 }
693
694 #[test]
695 fn capability_type_round_trips_the_hyphenated_name() {
696 assert_eq!(CapabilityType::McpServer.as_str(), "mcp-server");
697 assert_eq!(
698 CapabilityType::parse("mcp-server"),
699 Some(CapabilityType::McpServer)
700 );
701 assert_eq!(
702 CapabilityType::parse("mcp"),
703 Some(CapabilityType::McpServer)
704 );
705 let wire = toml::to_string(&Requirement {
706 id: "x".into(),
707 capability_type: CapabilityType::McpServer,
708 })
709 .unwrap();
710 assert!(wire.contains("type = \"mcp-server\""), "{wire}");
711 }
712}