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#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct EmittedFile {
12    pub path: String,
13    pub hash: String,
14    #[serde(rename = "baselineHash")]
15    pub baseline_hash: String,
16}
17
18#[derive(Debug, Clone)]
19pub struct PlannedFile {
20    pub path: String,
21    pub content: Vec<u8>,
22    pub allow_existing: bool,
23}
24
25impl PlannedFile {
26    pub fn new(path: String, content: Vec<u8>) -> Self {
27        Self {
28            path,
29            content,
30            allow_existing: false,
31        }
32    }
33
34    pub fn mergeable(path: String, content: Vec<u8>) -> Self {
35        Self {
36            path,
37            content,
38            allow_existing: true,
39        }
40    }
41}
42
43#[derive(Debug, Clone)]
44pub struct NativeHookConfig {
45    pub fragment: serde_json::Value,
46    pub source_files: Vec<(String, Vec<u8>)>,
47}
48
49#[derive(Debug, Clone)]
50pub enum HookRenderDiagnosticLevel {
51    Warning,
52}
53
54#[derive(Debug, Clone)]
55pub struct HookRenderDiagnostic {
56    pub level: HookRenderDiagnosticLevel,
57    pub message: String,
58}
59
60#[derive(Debug, Clone)]
61pub struct HookRenderContext<'a> {
62    pub capability_id: &'a str,
63    pub hook: &'a HookConfig,
64    pub source_files: &'a [(String, Vec<u8>)],
65    pub repo_root: &'a Path,
66    pub track_managed_hooks: bool,
67}
68
69#[derive(Debug, Clone)]
70pub struct HookRenderPlan {
71    pub files: Vec<PlannedFile>,
72    pub managed_hooks: Vec<crate::lockfile::ManagedHook>,
73    pub diagnostics: Vec<HookRenderDiagnostic>,
74}
75
76#[derive(Debug, Clone)]
77pub enum HookDefinition {
78    Command(crate::manifest::HookConfig),
79    Native(NativeHookConfig),
80}
81
82#[derive(Debug, Clone)]
83pub enum CapabilityKind {
84    Skill,
85    Tool {
86        parameters: serde_json::Value,
87        implementation: crate::manifest::ImplementationConfig,
88    },
89    Hook {
90        hook: HookDefinition,
91    },
92    Workflow {
93        workflow: crate::manifest::WorkflowConfig,
94    },
95}
96
97impl CapabilityKind {
98    pub fn capability_type(&self) -> CapabilityType {
99        match self {
100            Self::Skill => CapabilityType::Skill,
101            Self::Tool { .. } => CapabilityType::Tool,
102            Self::Hook { .. } => CapabilityType::Hook,
103            Self::Workflow { .. } => CapabilityType::Workflow,
104        }
105    }
106}
107
108pub struct ResolvedCapability {
109    pub id: String,
110    pub capability_type: CapabilityType,
111    pub version: String,
112    pub description: String,
113    pub source_files: Vec<(String, Vec<u8>)>,
114    pub source_dir: PathBuf,
115    pub kind: CapabilityKind,
116}
117
118pub fn resolve_capability(manifest: &CapabilityManifest) -> Result<ResolvedCapability> {
119    let source_files = manifest.read_source_contents_with_names()?;
120    let kind =
121        match manifest.capability_type {
122            CapabilityType::Skill => CapabilityKind::Skill,
123            CapabilityType::Tool => CapabilityKind::Tool {
124                parameters: manifest.parameters.clone().ok_or_else(|| {
125                    TuffError::new("tool capability requires [parameters] section")
126                })?,
127                implementation: manifest.implementation.clone().ok_or_else(|| {
128                    TuffError::new("tool capability requires [implementation] section")
129                })?,
130            },
131            CapabilityType::Hook => {
132                CapabilityKind::Hook {
133                    hook: HookDefinition::Command(manifest.hook.clone().ok_or_else(|| {
134                        TuffError::new("hook capability requires [hook] section")
135                    })?),
136                }
137            }
138            CapabilityType::Workflow => CapabilityKind::Workflow {
139                workflow: manifest.workflow.clone().ok_or_else(|| {
140                    TuffError::new("workflow capability requires [workflow] section")
141                })?,
142            },
143            CapabilityType::Policy => {
144                return Err(TuffError::new(
145                    "policy capabilities are not installable yet",
146                ));
147            }
148        };
149    Ok(ResolvedCapability {
150        id: manifest.id.clone(),
151        capability_type: manifest.capability_type,
152        version: manifest.version.clone(),
153        description: manifest.description.clone(),
154        source_files,
155        source_dir: manifest.root.clone(),
156        kind,
157    })
158}
159
160pub trait AgentAdapter {
161    fn id(&self) -> &'static str;
162    fn display_name(&self) -> &'static str;
163    fn dir_prefix(&self) -> &'static str;
164    fn mcp_config_relpath(&self) -> &'static str;
165    fn supported_agents(&self) -> &[&'static str];
166    fn hook_compatibility(&self) -> &'static CompatibilityMatrix;
167    fn hook_settings_relpath(&self) -> &'static str;
168    fn scaffold_hook_event(&self) -> &'static str;
169    fn hook_filename(&self) -> &'static str;
170    fn hook_file_content(&self, hook_cfg: &crate::manifest::HookConfig) -> Result<Vec<u8>>;
171    fn render_standard_hook(&self, context: HookRenderContext<'_>) -> Result<HookRenderPlan> {
172        let matrix = self.hook_compatibility();
173        let Some(entry) = matrix.find_event(&context.hook.event) else {
174            return Err(TuffError::new(format!(
175                "{} does not support hook event '{}'. Supported events: {}",
176                self.display_name(),
177                context.hook.event,
178                matrix.supported_native_events().join(", ")
179            )));
180        };
181        let Some(native_event) = entry.native_event_name() else {
182            let suffix = entry
183                .caveat
184                .map(|caveat| format!(": {caveat}"))
185                .unwrap_or_default();
186            return Err(TuffError::new(format!(
187                "{} does not support hook event '{}'{}",
188                self.display_name(),
189                context.hook.event,
190                suffix
191            )));
192        };
193
194        let command = format!(
195            "sh {}/hooks/{}/{}",
196            self.dir_prefix(),
197            context.capability_id,
198            self.hook_filename()
199        );
200        let target_path = context
201            .repo_root
202            .join(self.dir_prefix())
203            .join("hooks")
204            .join(context.capability_id)
205            .join(self.hook_filename());
206        let script = self.hook_file_content(context.hook)?;
207        let settings_relpath = self.hook_settings_relpath();
208        let fragment = self.command_hook_fragment(native_event, &command);
209        let settings_path = context.repo_root.join(settings_relpath);
210        let existing = if settings_path.is_file() {
211            Some(std::fs::read(&settings_path)?)
212        } else {
213            None
214        };
215        let merged = self.merge_hook_fragment(existing.as_deref(), &fragment)?;
216
217        let mut files = vec![PlannedFile::new(
218            relative_or_absolute_fs(&target_path, context.repo_root),
219            script,
220        )];
221        for (relative, content) in context.source_files {
222            let path = context
223                .repo_root
224                .join(self.dir_prefix())
225                .join("hooks")
226                .join(context.capability_id)
227                .join(relative);
228            files.push(PlannedFile::new(
229                relative_or_absolute_fs(&path, context.repo_root),
230                content.clone(),
231            ));
232        }
233        files.push(PlannedFile::mergeable(
234            relative_or_absolute_fs(&settings_path, context.repo_root),
235            merged,
236        ));
237
238        let mut diagnostics = Vec::new();
239        if entry.coverage == CoverageLevel::Partial {
240            let scope = if entry.scope.is_empty() {
241                "partial coverage".to_string()
242            } else {
243                format!("scope: {}", entry.scope.join(", "))
244            };
245            let caveat = entry
246                .caveat
247                .map(|caveat| format!("; {caveat}"))
248                .unwrap_or_default();
249            diagnostics.push(HookRenderDiagnostic {
250                level: HookRenderDiagnosticLevel::Warning,
251                message: format!(
252                    "{} renders '{}' with partial compatibility ({scope}{caveat})",
253                    self.display_name(),
254                    entry.event
255                ),
256            });
257        }
258
259        let managed_hooks = if context.track_managed_hooks {
260            crate::lockfile::managed_hooks_from_fragment_with_canonical(
261                context.repo_root,
262                settings_relpath,
263                &fragment,
264                Some(entry.event.as_str()),
265            )?
266        } else {
267            Vec::new()
268        };
269
270        Ok(HookRenderPlan {
271            files,
272            managed_hooks,
273            diagnostics,
274        })
275    }
276    fn command_hook_fragment(&self, native_event: &str, command: &str) -> serde_json::Value;
277    fn merge_hook_fragment(
278        &self,
279        existing: Option<&[u8]>,
280        fragment: &serde_json::Value,
281    ) -> Result<Vec<u8>>;
282    fn remove_hook_settings(
283        &self,
284        repo_root: &Path,
285        managed_hooks: &[crate::lockfile::ManagedHook],
286    ) -> Result<()>;
287    fn detect(&self, repo_root: &Path) -> bool;
288
289    fn kinds_supported(&self) -> &[CapabilityType];
290
291    fn supports(&self, capability_type: CapabilityType) -> bool {
292        self.kinds_supported().contains(&capability_type)
293    }
294
295    fn native_hook_event(&self, raw_event: &str) -> Result<&'static str> {
296        let matrix = self.hook_compatibility();
297        let Some(entry) = matrix.find_event(raw_event) else {
298            return Err(TuffError::new(format!(
299                "{} does not support hook event '{}'. Supported events: {}",
300                self.display_name(),
301                raw_event,
302                matrix.supported_native_events().join(", ")
303            )));
304        };
305        entry.native_event_name().ok_or_else(|| {
306            let suffix = entry
307                .caveat
308                .map(|caveat| format!(": {caveat}"))
309                .unwrap_or_default();
310            TuffError::new(format!(
311                "{} does not support hook event '{}'{}",
312                self.display_name(),
313                raw_event,
314                suffix
315            ))
316        })
317    }
318
319    fn canonical_hook_event(&self, raw_event: &str) -> Result<&'static str> {
320        let matrix = self.hook_compatibility();
321        let Some(entry) = matrix.find_event(raw_event) else {
322            return Err(TuffError::new(format!(
323                "{} does not support hook event '{}'",
324                self.display_name(),
325                raw_event
326            )));
327        };
328        entry
329            .coverage
330            .is_supported()
331            .then_some(entry.event.as_str())
332            .ok_or_else(|| {
333                let suffix = entry
334                    .caveat
335                    .map(|caveat| format!(": {caveat}"))
336                    .unwrap_or_default();
337                TuffError::new(format!(
338                    "{} does not support hook event '{}'{}",
339                    self.display_name(),
340                    raw_event,
341                    suffix
342                ))
343            })
344    }
345
346    fn ensure_project_dir(&self, repo_root: &Path) -> std::io::Result<()> {
347        std::fs::create_dir_all(repo_root.join(self.dir_prefix()))
348    }
349
350    fn plan(&self, capability: &ResolvedCapability, repo_root: &Path) -> Result<Vec<PlannedFile>> {
351        match capability.capability_type {
352            CapabilityType::Tool => self.plan_tool(capability, repo_root),
353            CapabilityType::Hook => self.plan_hook(capability, repo_root),
354            CapabilityType::Workflow => self.plan_workflow(capability, repo_root),
355            CapabilityType::Policy => Err(TuffError::new(
356                "policy capabilities are not installable yet",
357            )),
358            _ => self.plan_skill(capability, repo_root),
359        }
360    }
361
362    fn remove(
363        &self,
364        primitive_id: &str,
365        repo_root: &Path,
366        managed_hooks: &[crate::lockfile::ManagedHook],
367    ) -> Result<()> {
368        let prefix = self.dir_prefix();
369        for kind in &["skills", "tools", "hooks", "workflows"] {
370            self.remove_dir(repo_root, prefix, kind, primitive_id)?;
371        }
372        crate::mcp::remove_tool(&repo_root.join(self.mcp_config_relpath()), primitive_id)?;
373        self.remove_hook_settings(repo_root, managed_hooks)?;
374        Ok(())
375    }
376
377    // ── internal helpers ───────────────────────────────────────────────
378
379    fn plan_skill(
380        &self,
381        capability: &ResolvedCapability,
382        repo_root: &Path,
383    ) -> Result<Vec<PlannedFile>> {
384        if capability.source_files.is_empty() {
385            return Err(TuffError::new("no source files to emit"));
386        }
387
388        let mut files = Vec::new();
389        for (rel_path, content) in &capability.source_files {
390            let target_path = repo_root
391                .join(self.dir_prefix())
392                .join("skills")
393                .join(&capability.id)
394                .join(rel_path);
395
396            files.push(PlannedFile::new(
397                relative_or_absolute_fs(&target_path, repo_root),
398                content.clone(),
399            ));
400        }
401        Ok(files)
402    }
403
404    fn plan_tool(
405        &self,
406        capability: &ResolvedCapability,
407        repo_root: &Path,
408    ) -> Result<Vec<PlannedFile>> {
409        let mut files = Vec::new();
410
411        for (rel_path, content) in &capability.source_files {
412            let target_path = repo_root
413                .join(self.dir_prefix())
414                .join("tools")
415                .join(&capability.id)
416                .join(rel_path);
417
418            files.push(PlannedFile::new(
419                relative_or_absolute_fs(&target_path, repo_root),
420                content.clone(),
421            ));
422        }
423
424        if capability.source_files.is_empty() {
425            let placeholder = repo_root
426                .join(self.dir_prefix())
427                .join("tools")
428                .join(&capability.id)
429                .join(".gitkeep");
430            files.push(PlannedFile::new(
431                relative_or_absolute_fs(&placeholder, repo_root),
432                vec![],
433            ));
434        }
435
436        Ok(files)
437    }
438
439    fn plan_hook(
440        &self,
441        capability: &ResolvedCapability,
442        repo_root: &Path,
443    ) -> Result<Vec<PlannedFile>> {
444        let CapabilityKind::Hook { hook } = &capability.kind else {
445            return Err(TuffError::new("plan_hook called on non-hook capability"));
446        };
447
448        match hook {
449            HookDefinition::Command(hook_cfg) => {
450                let render = self.render_standard_hook(HookRenderContext {
451                    capability_id: &capability.id,
452                    hook: hook_cfg,
453                    source_files: &capability.source_files,
454                    repo_root,
455                    track_managed_hooks: false,
456                })?;
457                Ok(render.files)
458            }
459            HookDefinition::Native(native) => self.plan_native_hook(capability, native, repo_root),
460        }
461    }
462
463    fn plan_native_hook(
464        &self,
465        capability: &ResolvedCapability,
466        native: &NativeHookConfig,
467        repo_root: &Path,
468    ) -> Result<Vec<PlannedFile>> {
469        let hook_root = repo_root
470            .join(self.dir_prefix())
471            .join("hooks")
472            .join(&capability.id);
473        let hook_root_rel = relative_or_absolute_fs(&hook_root, repo_root);
474        let in_harness_source =
475            path_is_under(&capability.source_dir, &repo_root.join(self.dir_prefix()));
476
477        let mut files = Vec::new();
478        if in_harness_source {
479            for (rel_path, content) in &native.source_files {
480                let target_path = capability.source_dir.join(rel_path);
481                files.push(PlannedFile::mergeable(
482                    relative_or_absolute_fs(&target_path, repo_root),
483                    content.clone(),
484                ));
485            }
486        } else {
487            for (rel_path, content) in &native.source_files {
488                let target_path = hook_root.join(rel_path);
489                files.push(PlannedFile::new(
490                    relative_or_absolute_fs(&target_path, repo_root),
491                    content.clone(),
492                ));
493            }
494        }
495
496        let fragment = replace_hook_dir_placeholder(native.fragment.clone(), &hook_root_rel);
497        let settings_relpath = self.hook_settings_relpath();
498        let settings_path = repo_root.join(settings_relpath);
499        let existing = if settings_path.is_file() {
500            Some(std::fs::read(&settings_path)?)
501        } else {
502            None
503        };
504        let merged = self.merge_hook_fragment(existing.as_deref(), &fragment)?;
505        files.push(PlannedFile::mergeable(
506            relative_or_absolute_fs(&settings_path, repo_root),
507            merged,
508        ));
509        Ok(files)
510    }
511
512    fn plan_workflow(
513        &self,
514        capability: &ResolvedCapability,
515        repo_root: &Path,
516    ) -> Result<Vec<PlannedFile>> {
517        let CapabilityKind::Workflow { workflow: wf } = &capability.kind else {
518            return Err(TuffError::new(
519                "plan_workflow called on non-workflow capability",
520            ));
521        };
522
523        let target_path = repo_root
524            .join(self.dir_prefix())
525            .join("workflows")
526            .join(&capability.id)
527            .join("workflow.toml");
528
529        let mut content = format!(
530            "id = \"{}\"\nversion = \"{}\"\ntype = \"workflow\"\ndescription = \"{}\"\n",
531            capability.id, capability.version, capability.description
532        );
533        for req in &wf.requires {
534            content.push_str(&format!(
535                "[[workflow.requires]]\nid = \"{}\"\ntype = \"{}\"\n",
536                req.id, req.capability_type
537            ));
538        }
539
540        Ok(vec![PlannedFile::new(
541            relative_or_absolute_fs(&target_path, repo_root),
542            content.into_bytes(),
543        )])
544    }
545
546    fn remove_dir(
547        &self,
548        repo_root: &Path,
549        base: &str,
550        kind: &str,
551        primitive_id: &str,
552    ) -> Result<()> {
553        let dir = repo_root.join(base).join(kind).join(primitive_id);
554
555        if dir.exists() {
556            std::fs::remove_dir_all(&dir)?;
557        }
558
559        let kind_dir = dir.parent().expect("kind dir should have parent");
560        if kind_dir.exists() {
561            let mut rd = match std::fs::read_dir(kind_dir) {
562                Ok(rd) => rd,
563                Err(_) => return Ok(()),
564            };
565            if rd.next().is_none() {
566                std::fs::remove_dir(kind_dir)?;
567            }
568        }
569
570        let base_dir = kind_dir.parent().expect("base dir should have parent");
571        if base_dir.exists() {
572            let mut rd = match std::fs::read_dir(base_dir) {
573                Ok(rd) => rd,
574                Err(_) => return Ok(()),
575            };
576            if rd.next().is_none() {
577                std::fs::remove_dir(base_dir)?;
578            }
579        }
580
581        Ok(())
582    }
583}
584
585fn path_is_under(path: &Path, root: &Path) -> bool {
586    let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
587    let canonical_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
588    canonical_path.starts_with(canonical_root)
589}
590
591pub fn replace_hook_dir_placeholder(
592    mut value: serde_json::Value,
593    hook_dir: &str,
594) -> serde_json::Value {
595    match &mut value {
596        serde_json::Value::String(s) => {
597            *s = s.replace("{{hook_dir}}", hook_dir);
598        }
599        serde_json::Value::Array(items) => {
600            for item in items {
601                *item = replace_hook_dir_placeholder(item.take(), hook_dir);
602            }
603        }
604        serde_json::Value::Object(map) => {
605            for item in map.values_mut() {
606                *item = replace_hook_dir_placeholder(item.take(), hook_dir);
607            }
608        }
609        _ => {}
610    }
611    value
612}
613
614fn relative_or_absolute_fs(path: &Path, repo_root: &Path) -> String {
615    crate::lockfile::relative_or_absolute_fs(path, repo_root)
616}