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::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
409/// Parse a manifest, turning serde's opaque "invalid type: string" failure
410/// for a literal `[server.env]` value into an error that says what to write
411/// instead.
412fn 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
430/// Writes a capability manifest as deterministic TOML.
431///
432/// # Errors
433///
434/// Returns an error when serialization or filesystem writing fails.
435pub 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}