Skip to main content

tuff_core/
adapter.rs

1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5use tuff_hooks_spec::{CompatibilityMatrix, CoverageLevel};
6
7use crate::error::{Result, TuffError};
8use crate::manifest::{CapabilityManifest, CapabilityType, HookConfig};
9
10/// Append hook groups that this event does not already register.
11///
12/// `tuff add` is re-runnable and a pack may be installed over an existing
13/// install, so the same hook fragment is merged more than once. Appending
14/// unconditionally leaves a duplicate group behind on every re-add, and the
15/// harness then runs that hook once per copy.
16pub fn extend_hook_groups(existing: &mut Vec<serde_json::Value>, additions: &[serde_json::Value]) {
17    for addition in additions {
18        if !existing.iter().any(|group| group == addition) {
19            existing.push(addition.clone());
20        }
21    }
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct EmittedFile {
26    pub path: String,
27    pub hash: String,
28    #[serde(rename = "baselineHash")]
29    pub baseline_hash: String,
30}
31
32#[derive(Debug, Clone)]
33pub struct PlannedFile {
34    pub path: String,
35    pub content: Vec<u8>,
36    pub allow_existing: bool,
37}
38
39impl PlannedFile {
40    pub fn new(path: String, content: Vec<u8>) -> Self {
41        Self {
42            path,
43            content,
44            allow_existing: false,
45        }
46    }
47
48    pub fn mergeable(path: String, content: Vec<u8>) -> Self {
49        Self {
50            path,
51            content,
52            allow_existing: true,
53        }
54    }
55}
56
57#[derive(Debug, Clone)]
58pub struct NativeHookConfig {
59    pub fragment: serde_json::Value,
60    pub source_files: Vec<(String, Vec<u8>)>,
61}
62
63#[derive(Debug, Clone)]
64pub enum HookRenderDiagnosticLevel {
65    Warning,
66}
67
68#[derive(Debug, Clone)]
69pub struct HookRenderDiagnostic {
70    pub level: HookRenderDiagnosticLevel,
71    pub message: String,
72}
73
74#[derive(Debug, Clone)]
75pub struct HookRenderContext<'a> {
76    pub capability_id: &'a str,
77    pub hook: &'a HookConfig,
78    pub source_files: &'a [(String, Vec<u8>)],
79    pub repo_root: &'a Path,
80    pub track_managed_hooks: bool,
81}
82
83#[derive(Debug, Clone)]
84pub struct HookRenderPlan {
85    pub files: Vec<PlannedFile>,
86    pub managed_hooks: Vec<crate::lockfile::ManagedHook>,
87    pub diagnostics: Vec<HookRenderDiagnostic>,
88}
89
90#[derive(Debug, Clone)]
91pub enum HookDefinition {
92    Command(crate::manifest::HookConfig),
93    Native(NativeHookConfig),
94}
95
96#[derive(Debug, Clone)]
97pub enum CapabilityKind {
98    Skill,
99    Tool {
100        parameters: serde_json::Value,
101        implementation: crate::manifest::ImplementationConfig,
102    },
103    Hook {
104        hook: HookDefinition,
105    },
106    Workflow {
107        workflow: crate::manifest::WorkflowConfig,
108    },
109}
110
111impl CapabilityKind {
112    pub fn capability_type(&self) -> CapabilityType {
113        match self {
114            Self::Skill => CapabilityType::Skill,
115            Self::Tool { .. } => CapabilityType::Tool,
116            Self::Hook { .. } => CapabilityType::Hook,
117            Self::Workflow { .. } => CapabilityType::Workflow,
118        }
119    }
120}
121
122pub struct ResolvedCapability {
123    pub id: String,
124    pub capability_type: CapabilityType,
125    pub version: String,
126    pub description: String,
127    pub source_files: Vec<(String, Vec<u8>)>,
128    pub source_dir: PathBuf,
129    pub kind: CapabilityKind,
130}
131
132#[derive(Serialize)]
133struct WorkflowDocument<'a> {
134    id: &'a str,
135    version: &'a str,
136    #[serde(rename = "type")]
137    capability_type: CapabilityType,
138    description: &'a str,
139    workflow: &'a crate::manifest::WorkflowConfig,
140}
141
142pub fn resolve_capability(manifest: &CapabilityManifest) -> Result<ResolvedCapability> {
143    let source_files = manifest.read_source_contents_with_names()?;
144    let kind =
145        match manifest.capability_type {
146            CapabilityType::Skill => CapabilityKind::Skill,
147            CapabilityType::Tool => CapabilityKind::Tool {
148                parameters: manifest.parameters.clone().ok_or_else(|| {
149                    TuffError::new("tool capability requires [parameters] section")
150                })?,
151                implementation: manifest.implementation.clone().ok_or_else(|| {
152                    TuffError::new("tool capability requires [implementation] section")
153                })?,
154            },
155            CapabilityType::Hook => {
156                CapabilityKind::Hook {
157                    hook: HookDefinition::Command(manifest.hook.clone().ok_or_else(|| {
158                        TuffError::new("hook capability requires [hook] section")
159                    })?),
160                }
161            }
162            CapabilityType::Workflow => CapabilityKind::Workflow {
163                workflow: manifest.workflow.clone().ok_or_else(|| {
164                    TuffError::new("workflow capability requires [workflow] section")
165                })?,
166            },
167            CapabilityType::Policy => {
168                return Err(TuffError::new(
169                    "policy capabilities are not installable yet",
170                ));
171            }
172        };
173    Ok(ResolvedCapability {
174        id: manifest.id.clone(),
175        capability_type: manifest.capability_type,
176        version: manifest.version.clone(),
177        description: manifest.description.clone(),
178        source_files,
179        source_dir: manifest.root.clone(),
180        kind,
181    })
182}
183
184pub trait AgentAdapter {
185    fn id(&self) -> &'static str;
186    fn display_name(&self) -> &'static str;
187    fn dir_prefix(&self) -> &'static str;
188    fn mcp_config_relpath(&self) -> &'static str;
189    fn supported_agents(&self) -> &[&'static str];
190    fn hook_compatibility(&self) -> &'static CompatibilityMatrix;
191    fn hook_settings_relpath(&self) -> &'static str;
192    fn scaffold_hook_event(&self) -> &'static str;
193    fn hook_filename(&self) -> &'static str;
194    fn hook_file_content(&self, hook_cfg: &crate::manifest::HookConfig) -> Result<Vec<u8>> {
195        render_hook_script(hook_cfg)
196    }
197    fn render_standard_hook(&self, context: HookRenderContext<'_>) -> Result<HookRenderPlan> {
198        let matrix = self.hook_compatibility();
199        let Some(entry) = matrix.find_event(&context.hook.event) else {
200            return Err(TuffError::new(format!(
201                "{} does not support hook event '{}'. Supported events: {}",
202                self.display_name(),
203                context.hook.event,
204                matrix.supported_native_events().join(", ")
205            )));
206        };
207        let Some(native_event) = entry.native_event_name() else {
208            let suffix = entry
209                .caveat
210                .map(|caveat| format!(": {caveat}"))
211                .unwrap_or_default();
212            return Err(TuffError::new(format!(
213                "{} does not support hook event '{}'{}",
214                self.display_name(),
215                context.hook.event,
216                suffix
217            )));
218        };
219
220        let command = format!(
221            "sh {}/hooks/{}/{}",
222            self.dir_prefix(),
223            context.capability_id,
224            self.hook_filename()
225        );
226        let target_path = context
227            .repo_root
228            .join(self.dir_prefix())
229            .join("hooks")
230            .join(context.capability_id)
231            .join(self.hook_filename());
232        let script = self.hook_file_content(context.hook)?;
233        let settings_relpath = self.hook_settings_relpath();
234        let fragment = self.command_hook_fragment(native_event, &command);
235        let settings_path = context.repo_root.join(settings_relpath);
236        let existing = if settings_path.is_file() {
237            Some(std::fs::read(&settings_path)?)
238        } else {
239            None
240        };
241        let merged = self.merge_hook_fragment(existing.as_deref(), &fragment)?;
242
243        let mut files = vec![PlannedFile::new(
244            relative_or_absolute_fs(&target_path, context.repo_root),
245            script,
246        )];
247        for (relative, content) in context.source_files {
248            let path = context
249                .repo_root
250                .join(self.dir_prefix())
251                .join("hooks")
252                .join(context.capability_id)
253                .join(relative);
254            files.push(PlannedFile::new(
255                relative_or_absolute_fs(&path, context.repo_root),
256                content.clone(),
257            ));
258        }
259        files.push(PlannedFile::mergeable(
260            relative_or_absolute_fs(&settings_path, context.repo_root),
261            merged,
262        ));
263
264        let mut diagnostics = Vec::new();
265        if entry.coverage == CoverageLevel::Partial {
266            let scope = if entry.scope.is_empty() {
267                "partial coverage".to_string()
268            } else {
269                format!("scope: {}", entry.scope.join(", "))
270            };
271            let caveat = entry
272                .caveat
273                .map(|caveat| format!("; {caveat}"))
274                .unwrap_or_default();
275            diagnostics.push(HookRenderDiagnostic {
276                level: HookRenderDiagnosticLevel::Warning,
277                message: format!(
278                    "{} renders '{}' with partial compatibility ({scope}{caveat})",
279                    self.display_name(),
280                    entry.event
281                ),
282            });
283        }
284
285        let managed_hooks = if context.track_managed_hooks {
286            crate::lockfile::managed_hooks_from_fragment_with_canonical(
287                context.repo_root,
288                settings_relpath,
289                &fragment,
290                Some(entry.event.as_str()),
291            )?
292        } else {
293            Vec::new()
294        };
295
296        Ok(HookRenderPlan {
297            files,
298            managed_hooks,
299            diagnostics,
300        })
301    }
302    fn command_hook_fragment(&self, native_event: &str, command: &str) -> serde_json::Value;
303    fn merge_hook_fragment(
304        &self,
305        existing: Option<&[u8]>,
306        fragment: &serde_json::Value,
307    ) -> Result<Vec<u8>>;
308    fn remove_hook_settings(
309        &self,
310        repo_root: &Path,
311        managed_hooks: &[crate::lockfile::ManagedHook],
312    ) -> Result<()>;
313    fn detect(&self, repo_root: &Path) -> bool;
314
315    fn kinds_supported(&self) -> &[CapabilityType];
316
317    fn supports(&self, capability_type: CapabilityType) -> bool {
318        self.kinds_supported().contains(&capability_type)
319    }
320
321    fn native_hook_event(&self, raw_event: &str) -> Result<&'static str> {
322        let matrix = self.hook_compatibility();
323        let Some(entry) = matrix.find_event(raw_event) else {
324            return Err(TuffError::new(format!(
325                "{} does not support hook event '{}'. Supported events: {}",
326                self.display_name(),
327                raw_event,
328                matrix.supported_native_events().join(", ")
329            )));
330        };
331        entry.native_event_name().ok_or_else(|| {
332            let suffix = entry
333                .caveat
334                .map(|caveat| format!(": {caveat}"))
335                .unwrap_or_default();
336            TuffError::new(format!(
337                "{} does not support hook event '{}'{}",
338                self.display_name(),
339                raw_event,
340                suffix
341            ))
342        })
343    }
344
345    fn canonical_hook_event(&self, raw_event: &str) -> Result<&'static str> {
346        let matrix = self.hook_compatibility();
347        let Some(entry) = matrix.find_event(raw_event) else {
348            return Err(TuffError::new(format!(
349                "{} does not support hook event '{}'",
350                self.display_name(),
351                raw_event
352            )));
353        };
354        entry
355            .coverage
356            .is_supported()
357            .then_some(entry.event.as_str())
358            .ok_or_else(|| {
359                let suffix = entry
360                    .caveat
361                    .map(|caveat| format!(": {caveat}"))
362                    .unwrap_or_default();
363                TuffError::new(format!(
364                    "{} does not support hook event '{}'{}",
365                    self.display_name(),
366                    raw_event,
367                    suffix
368                ))
369            })
370    }
371
372    fn ensure_project_dir(&self, repo_root: &Path) -> std::io::Result<()> {
373        std::fs::create_dir_all(repo_root.join(self.dir_prefix()))
374    }
375
376    fn plan(&self, capability: &ResolvedCapability, repo_root: &Path) -> Result<Vec<PlannedFile>> {
377        match capability.capability_type {
378            CapabilityType::Tool => self.plan_tool(capability, repo_root),
379            CapabilityType::Hook => self.plan_hook(capability, repo_root),
380            CapabilityType::Workflow => self.plan_workflow(capability, repo_root),
381            CapabilityType::Policy => Err(TuffError::new(
382                "policy capabilities are not installable yet",
383            )),
384            _ => self.plan_skill(capability, repo_root),
385        }
386    }
387
388    fn remove(
389        &self,
390        primitive_id: &str,
391        repo_root: &Path,
392        managed_hooks: &[crate::lockfile::ManagedHook],
393    ) -> Result<()> {
394        let prefix = self.dir_prefix();
395        for kind in &["skills", "tools", "hooks", "workflows"] {
396            self.remove_dir(repo_root, prefix, kind, primitive_id)?;
397        }
398        crate::mcp::remove_tool(&repo_root.join(self.mcp_config_relpath()), primitive_id)?;
399        self.remove_hook_settings(repo_root, managed_hooks)?;
400        Ok(())
401    }
402
403    // ── internal helpers ───────────────────────────────────────────────
404
405    fn plan_skill(
406        &self,
407        capability: &ResolvedCapability,
408        repo_root: &Path,
409    ) -> Result<Vec<PlannedFile>> {
410        if capability.source_files.is_empty() {
411            return Err(TuffError::new("no source files to emit"));
412        }
413
414        let mut files = Vec::new();
415        for (rel_path, content) in &capability.source_files {
416            let target_path = repo_root
417                .join(self.dir_prefix())
418                .join("skills")
419                .join(&capability.id)
420                .join(rel_path);
421
422            files.push(PlannedFile::new(
423                relative_or_absolute_fs(&target_path, repo_root),
424                content.clone(),
425            ));
426        }
427        Ok(files)
428    }
429
430    fn plan_tool(
431        &self,
432        capability: &ResolvedCapability,
433        repo_root: &Path,
434    ) -> Result<Vec<PlannedFile>> {
435        let mut files = Vec::new();
436
437        for (rel_path, content) in &capability.source_files {
438            let target_path = repo_root
439                .join(self.dir_prefix())
440                .join("tools")
441                .join(&capability.id)
442                .join(rel_path);
443
444            files.push(PlannedFile::new(
445                relative_or_absolute_fs(&target_path, repo_root),
446                content.clone(),
447            ));
448        }
449
450        if capability.source_files.is_empty() {
451            let placeholder = repo_root
452                .join(self.dir_prefix())
453                .join("tools")
454                .join(&capability.id)
455                .join(".gitkeep");
456            files.push(PlannedFile::new(
457                relative_or_absolute_fs(&placeholder, repo_root),
458                vec![],
459            ));
460        }
461
462        Ok(files)
463    }
464
465    fn plan_hook(
466        &self,
467        capability: &ResolvedCapability,
468        repo_root: &Path,
469    ) -> Result<Vec<PlannedFile>> {
470        let CapabilityKind::Hook { hook } = &capability.kind else {
471            return Err(TuffError::new("plan_hook called on non-hook capability"));
472        };
473
474        match hook {
475            HookDefinition::Command(hook_cfg) => {
476                let render = self.render_standard_hook(HookRenderContext {
477                    capability_id: &capability.id,
478                    hook: hook_cfg,
479                    source_files: &capability.source_files,
480                    repo_root,
481                    track_managed_hooks: false,
482                })?;
483                Ok(render.files)
484            }
485            HookDefinition::Native(native) => self.plan_native_hook(capability, native, repo_root),
486        }
487    }
488
489    fn plan_native_hook(
490        &self,
491        capability: &ResolvedCapability,
492        native: &NativeHookConfig,
493        repo_root: &Path,
494    ) -> Result<Vec<PlannedFile>> {
495        let hook_root = repo_root
496            .join(self.dir_prefix())
497            .join("hooks")
498            .join(&capability.id);
499        let hook_root_rel = relative_or_absolute_fs(&hook_root, repo_root);
500        let in_harness_source =
501            path_is_under(&capability.source_dir, &repo_root.join(self.dir_prefix()));
502
503        let mut files = Vec::new();
504        if in_harness_source {
505            for (rel_path, content) in &native.source_files {
506                let target_path = capability.source_dir.join(rel_path);
507                files.push(PlannedFile::mergeable(
508                    relative_or_absolute_fs(&target_path, repo_root),
509                    content.clone(),
510                ));
511            }
512        } else {
513            for (rel_path, content) in &native.source_files {
514                let target_path = hook_root.join(rel_path);
515                files.push(PlannedFile::new(
516                    relative_or_absolute_fs(&target_path, repo_root),
517                    content.clone(),
518                ));
519            }
520        }
521
522        let fragment = replace_hook_dir_placeholder(native.fragment.clone(), &hook_root_rel);
523        let settings_relpath = self.hook_settings_relpath();
524        let settings_path = repo_root.join(settings_relpath);
525        let existing = if settings_path.is_file() {
526            Some(std::fs::read(&settings_path)?)
527        } else {
528            None
529        };
530        let merged = self.merge_hook_fragment(existing.as_deref(), &fragment)?;
531        files.push(PlannedFile::mergeable(
532            relative_or_absolute_fs(&settings_path, repo_root),
533            merged,
534        ));
535        Ok(files)
536    }
537
538    fn plan_workflow(
539        &self,
540        capability: &ResolvedCapability,
541        repo_root: &Path,
542    ) -> Result<Vec<PlannedFile>> {
543        let CapabilityKind::Workflow { workflow: wf } = &capability.kind else {
544            return Err(TuffError::new(
545                "plan_workflow called on non-workflow capability",
546            ));
547        };
548
549        let target_path = repo_root
550            .join(self.dir_prefix())
551            .join("workflows")
552            .join(&capability.id)
553            .join("workflow.toml");
554
555        let content = serialize_workflow(capability, wf)?;
556
557        Ok(vec![PlannedFile::new(
558            relative_or_absolute_fs(&target_path, repo_root),
559            content,
560        )])
561    }
562
563    fn remove_dir(
564        &self,
565        repo_root: &Path,
566        base: &str,
567        kind: &str,
568        primitive_id: &str,
569    ) -> Result<()> {
570        let dir = repo_root.join(base).join(kind).join(primitive_id);
571
572        if dir.exists() {
573            std::fs::remove_dir_all(&dir)?;
574        }
575
576        let kind_dir = dir.parent().expect("kind dir should have parent");
577        if kind_dir.exists() {
578            let mut rd = match std::fs::read_dir(kind_dir) {
579                Ok(rd) => rd,
580                Err(_) => return Ok(()),
581            };
582            if rd.next().is_none() {
583                std::fs::remove_dir(kind_dir)?;
584            }
585        }
586
587        let base_dir = kind_dir.parent().expect("base dir should have parent");
588        if base_dir.exists() {
589            let mut rd = match std::fs::read_dir(base_dir) {
590                Ok(rd) => rd,
591                Err(_) => return Ok(()),
592            };
593            if rd.next().is_none() {
594                std::fs::remove_dir(base_dir)?;
595            }
596        }
597
598        Ok(())
599    }
600}
601
602fn render_hook_script(hook_cfg: &HookConfig) -> Result<Vec<u8>> {
603    let working_directory = shell_single_quote(&hook_cfg.working_directory)?;
604    let command = shell_single_quote(&hook_cfg.command)?;
605    Ok(format!(
606        "#!/usr/bin/env bash\nset -euo pipefail\ncd -- {working_directory}\nexec bash -euo pipefail -c {command}\n"
607    )
608    .into_bytes())
609}
610
611fn shell_single_quote(value: &str) -> Result<String> {
612    if value.contains('\0') {
613        return Err(TuffError::new(
614            "hook working directory and command cannot contain NUL bytes",
615        ));
616    }
617    Ok(format!("'{}'", value.replace('\'', "'\"'\"'")))
618}
619
620fn serialize_workflow(
621    capability: &ResolvedCapability,
622    workflow: &crate::manifest::WorkflowConfig,
623) -> Result<Vec<u8>> {
624    let document = WorkflowDocument {
625        id: &capability.id,
626        version: &capability.version,
627        capability_type: capability.capability_type,
628        description: &capability.description,
629        workflow,
630    };
631    let mut content = toml::to_string_pretty(&document)?;
632    if !content.ends_with('\n') {
633        content.push('\n');
634    }
635    Ok(content.into_bytes())
636}
637
638fn path_is_under(path: &Path, root: &Path) -> bool {
639    let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
640    let canonical_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
641    canonical_path.starts_with(canonical_root)
642}
643
644pub fn replace_hook_dir_placeholder(
645    mut value: serde_json::Value,
646    hook_dir: &str,
647) -> serde_json::Value {
648    match &mut value {
649        serde_json::Value::String(s) => {
650            *s = s.replace("{{hook_dir}}", hook_dir);
651        }
652        serde_json::Value::Array(items) => {
653            for item in items {
654                *item = replace_hook_dir_placeholder(item.take(), hook_dir);
655            }
656        }
657        serde_json::Value::Object(map) => {
658            for item in map.values_mut() {
659                *item = replace_hook_dir_placeholder(item.take(), hook_dir);
660            }
661        }
662        _ => {}
663    }
664    value
665}
666
667fn relative_or_absolute_fs(path: &Path, repo_root: &Path) -> String {
668    crate::lockfile::relative_or_absolute_fs(path, repo_root)
669}
670
671#[cfg(test)]
672mod tests {
673    use super::*;
674    use crate::manifest::{Requirement, WorkflowConfig};
675
676    #[cfg(unix)]
677    #[test]
678    fn hook_script_preserves_shell_sensitive_values() {
679        use std::process::Command;
680
681        let temp = tempfile::tempdir().expect("tempdir");
682        let working_directory = temp.path().join("directory with ' quote");
683        std::fs::create_dir(&working_directory).expect("create working directory");
684        let hook = HookConfig {
685            event: "stop".to_string(),
686            command: "printf '%s\\n' 'safe; $HOME `literal`' > result.txt".to_string(),
687            working_directory: working_directory.to_string_lossy().into_owned(),
688        };
689        let script_path = temp.path().join("run.sh");
690        std::fs::write(
691            &script_path,
692            render_hook_script(&hook).expect("render script"),
693        )
694        .expect("write script");
695
696        let syntax = Command::new("bash")
697            .arg("-n")
698            .arg(&script_path)
699            .status()
700            .expect("check script syntax");
701        assert!(syntax.success());
702        let executed = Command::new("bash")
703            .arg(&script_path)
704            .status()
705            .expect("execute script");
706        assert!(executed.success());
707        assert_eq!(
708            std::fs::read_to_string(working_directory.join("result.txt"))
709                .expect("read command output"),
710            "safe; $HOME `literal`\n"
711        );
712    }
713
714    #[test]
715    fn hook_script_rejects_nul_bytes() {
716        let hook = HookConfig {
717            event: "stop".to_string(),
718            command: "printf '\0'".to_string(),
719            working_directory: ".".to_string(),
720        };
721
722        assert!(render_hook_script(&hook).is_err());
723    }
724
725    #[test]
726    fn workflow_serialization_escapes_manifest_values() {
727        let workflow = WorkflowConfig {
728            requires: vec![Requirement {
729                id: "dependency\"\\name".to_string(),
730                capability_type: CapabilityType::Skill,
731            }],
732        };
733        let capability = ResolvedCapability {
734            id: "workflow\"id".to_string(),
735            capability_type: CapabilityType::Workflow,
736            version: "1.0.0".to_string(),
737            description: "first line\nsecond \"line\" \\ value".to_string(),
738            source_files: Vec::new(),
739            source_dir: PathBuf::new(),
740            kind: CapabilityKind::Workflow {
741                workflow: workflow.clone(),
742            },
743        };
744
745        let bytes = serialize_workflow(&capability, &workflow).expect("serialize workflow");
746        let parsed: toml::Value = toml::from_slice(&bytes).expect("parse emitted workflow");
747
748        assert_eq!(parsed["id"].as_str(), Some("workflow\"id"));
749        assert_eq!(
750            parsed["description"].as_str(),
751            Some("first line\nsecond \"line\" \\ value")
752        );
753        assert_eq!(
754            parsed["workflow"]["requires"][0]["id"].as_str(),
755            Some("dependency\"\\name")
756        );
757    }
758}