Skip to main content

tuff_core/
manifest.rs

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    /// An external MCP server Tuff wires into each harness's native MCP
16    /// config. Distinct from a `tool` with `implementation.mcp = true`,
17    /// whose server code Tuff ships itself.
18    #[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/// Declaration of an external MCP server (`type = "mcp-server"`).
92///
93/// Secrets never appear here: every `[server.env]` value must be an
94/// [`EnvRef`] naming the variable to read on the developer's machine, so a
95/// manifest can be committed and shared without leaking anything.
96#[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/// A reference to an environment variable on the machine running the
130/// harness. Deliberately the only shape an env value can take — a bare
131/// string literal is rejected at parse time.
132#[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
403/// Parse a manifest, turning serde's opaque "invalid type: string" failure
404/// for a literal `[server.env]` value into an error that says what to write
405/// instead.
406fn 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
424/// Writes a capability manifest as deterministic TOML.
425///
426/// # Errors
427///
428/// Returns an error when serialization or filesystem writing fails.
429pub 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}