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};
8pub use crate::hook_settings::{HookSettingsShape, extend_hook_groups};
9use crate::manifest::{CapabilityManifest, CapabilityType, HookConfig};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct EmittedFile {
13    pub path: String,
14    pub hash: String,
15    #[serde(rename = "baselineHash")]
16    pub baseline_hash: String,
17}
18
19#[derive(Debug, Clone)]
20pub struct PlannedFile {
21    pub path: String,
22    pub content: Vec<u8>,
23    pub allow_existing: bool,
24}
25
26impl PlannedFile {
27    pub fn new(path: String, content: Vec<u8>) -> Self {
28        Self {
29            path,
30            content,
31            allow_existing: false,
32        }
33    }
34
35    pub fn mergeable(path: String, content: Vec<u8>) -> Self {
36        Self {
37            path,
38            content,
39            allow_existing: true,
40        }
41    }
42}
43
44#[derive(Debug, Clone)]
45pub struct NativeHookConfig {
46    pub fragment: serde_json::Value,
47    pub source_files: Vec<(String, Vec<u8>)>,
48}
49
50#[derive(Debug, Clone)]
51pub enum HookRenderDiagnosticLevel {
52    Warning,
53}
54
55#[derive(Debug, Clone)]
56pub struct HookRenderDiagnostic {
57    pub level: HookRenderDiagnosticLevel,
58    pub message: String,
59}
60
61#[derive(Debug, Clone)]
62pub struct HookRenderContext<'a> {
63    pub capability_id: &'a str,
64    pub hook: &'a HookConfig,
65    pub source_files: &'a [(String, Vec<u8>)],
66    pub repo_root: &'a Path,
67    pub track_managed_hooks: bool,
68}
69
70#[derive(Debug, Clone)]
71pub struct HookRenderPlan {
72    pub files: Vec<PlannedFile>,
73    pub managed_hooks: Vec<crate::lockfile::ManagedHook>,
74    pub diagnostics: Vec<HookRenderDiagnostic>,
75}
76
77#[derive(Debug, Clone)]
78pub enum HookDefinition {
79    Command(crate::manifest::HookConfig),
80    Native(NativeHookConfig),
81}
82
83#[derive(Debug, Clone)]
84pub enum CapabilityKind {
85    Skill,
86    Tool {
87        parameters: serde_json::Value,
88        implementation: crate::manifest::ImplementationConfig,
89    },
90    Hook {
91        hook: HookDefinition,
92    },
93    Workflow {
94        workflow: crate::manifest::WorkflowConfig,
95    },
96    McpServer {
97        server: crate::manifest::McpServerConfig,
98    },
99    Policy {
100        policy: crate::policy::PolicyConfig,
101    },
102}
103
104impl CapabilityKind {
105    pub fn capability_type(&self) -> CapabilityType {
106        match self {
107            Self::Skill => CapabilityType::Skill,
108            Self::Tool { .. } => CapabilityType::Tool,
109            Self::Hook { .. } => CapabilityType::Hook,
110            Self::Workflow { .. } => CapabilityType::Workflow,
111            Self::McpServer { .. } => CapabilityType::McpServer,
112            Self::Policy { .. } => CapabilityType::Policy,
113        }
114    }
115}
116
117pub struct ResolvedCapability {
118    pub id: String,
119    pub capability_type: CapabilityType,
120    pub version: String,
121    pub description: String,
122    pub source_files: Vec<(String, Vec<u8>)>,
123    pub source_dir: PathBuf,
124    pub kind: CapabilityKind,
125}
126
127#[derive(Serialize)]
128struct WorkflowDocument<'a> {
129    id: &'a str,
130    version: &'a str,
131    #[serde(rename = "type")]
132    capability_type: CapabilityType,
133    description: &'a str,
134    workflow: &'a crate::manifest::WorkflowConfig,
135}
136
137pub fn resolve_capability(manifest: &CapabilityManifest) -> Result<ResolvedCapability> {
138    let source_files = manifest.read_source_contents_with_names()?;
139    let kind =
140        match manifest.capability_type {
141            CapabilityType::Skill => CapabilityKind::Skill,
142            CapabilityType::Tool => CapabilityKind::Tool {
143                parameters: manifest.parameters.clone().ok_or_else(|| {
144                    TuffError::usage("tool capability requires [parameters] section")
145                })?,
146                implementation: manifest.implementation.clone().ok_or_else(|| {
147                    TuffError::usage("tool capability requires [implementation] section")
148                })?,
149            },
150            CapabilityType::Hook => {
151                CapabilityKind::Hook {
152                    hook: HookDefinition::Command(manifest.hook.clone().ok_or_else(|| {
153                        TuffError::usage("hook capability requires [hook] section")
154                    })?),
155                }
156            }
157            CapabilityType::Workflow => CapabilityKind::Workflow {
158                workflow: manifest.workflow.clone().ok_or_else(|| {
159                    TuffError::usage("workflow capability requires [workflow] section")
160                })?,
161            },
162            CapabilityType::Policy => CapabilityKind::Policy {
163                policy: manifest.policy.clone().ok_or_else(|| {
164                    TuffError::usage("policy capability requires a [policy] section")
165                })?,
166            },
167            CapabilityType::McpServer => CapabilityKind::McpServer {
168                server: manifest.server.clone().ok_or_else(|| {
169                    TuffError::usage("mcp-server capability requires [server] section")
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
184/// What a harness adapter declares, and what Tuff does with it.
185///
186/// An adapter is a declaration: where the harness keeps its files, which
187/// hook events it has and how they map onto Tuff's, the shape of its hook
188/// settings file, and how to recognise a project that uses it. Everything
189/// else, planning files, rendering hooks, merging into and removing from
190/// the settings file, is a default method here, so there is one
191/// implementation of each and an adapter overrides one only when its
192/// harness genuinely differs (Cursor's `${env:VAR}` spelling, say).
193pub trait AgentAdapter {
194    fn id(&self) -> &'static str;
195    fn display_name(&self) -> &'static str;
196    fn dir_prefix(&self) -> &'static str;
197    fn mcp_config_relpath(&self) -> &'static str;
198    fn supported_agents(&self) -> &[&'static str];
199    fn hook_compatibility(&self) -> &'static CompatibilityMatrix;
200    fn hook_settings_relpath(&self) -> &'static str;
201    /// How the settings file at [`hook_settings_relpath`] lays out its
202    /// registrations. This is the whole of what differs between harnesses
203    /// in hook handling; the merge and removal follow from it.
204    ///
205    /// [`hook_settings_relpath`]: AgentAdapter::hook_settings_relpath
206    fn hook_settings_shape(&self) -> HookSettingsShape;
207    /// The native event `tuff create` registers a scaffolded hook under.
208    fn scaffold_hook_event(&self) -> &'static str;
209    fn hook_filename(&self) -> &'static str {
210        "run.sh"
211    }
212    fn hook_file_content(&self, hook_cfg: &crate::manifest::HookConfig) -> Result<Vec<u8>> {
213        render_hook_script(hook_cfg)
214    }
215    fn render_standard_hook(&self, context: HookRenderContext<'_>) -> Result<HookRenderPlan> {
216        let matrix = self.hook_compatibility();
217        let Some(entry) = matrix.find_event(&context.hook.event) else {
218            return Err(TuffError::unsupported(format!(
219                "{} does not support hook event '{}'. A manifest can use: {}",
220                self.display_name(),
221                context.hook.event,
222                matrix.accepted_events_summary()
223            )));
224        };
225        let Some(native_event) = entry.native_event_name() else {
226            let suffix = entry
227                .caveat
228                .map(|caveat| format!(": {}", caveat.trim_end_matches('.')))
229                .unwrap_or_default();
230            return Err(TuffError::unsupported(format!(
231                "{} does not support hook event '{}'{}. A manifest can use: {}",
232                self.display_name(),
233                context.hook.event,
234                suffix,
235                matrix.accepted_events_summary()
236            )));
237        };
238
239        // The wrapper is what the registration runs and what the install
240        // note describes. A listed runtime file landing on the same path
241        // would silently replace it, so the harness would run a script the
242        // user was never shown.
243        if let Some((listed, _)) = context
244            .source_files
245            .iter()
246            .find(|(relative, _)| relative == self.hook_filename())
247        {
248            return Err(TuffError::refused(format!(
249                "hook '{}' lists a file installed as '{listed}', which is the wrapper Tuff generates to run its command",
250                context.capability_id
251            ))
252            .with_hint(format!(
253                "rename that file; '{}' is reserved in the hook directory",
254                self.hook_filename()
255            )));
256        }
257
258        let command = format!(
259            "sh {}/hooks/{}/{}",
260            self.dir_prefix(),
261            context.capability_id,
262            self.hook_filename()
263        );
264        let target_path = context
265            .repo_root
266            .join(self.dir_prefix())
267            .join("hooks")
268            .join(context.capability_id)
269            .join(self.hook_filename());
270        let script = self.hook_file_content(context.hook)?;
271        let settings_relpath = self.hook_settings_relpath();
272        let fragment = self.command_hook_fragment(native_event, &command);
273        let settings_path = context.repo_root.join(settings_relpath);
274        let existing = if settings_path.is_file() {
275            Some(std::fs::read(&settings_path)?)
276        } else {
277            None
278        };
279        let merged = self.merge_hook_fragment(existing.as_deref(), &fragment)?;
280
281        let mut files = vec![PlannedFile::new(
282            relative_or_absolute_fs(&target_path, context.repo_root),
283            script,
284        )];
285        for (relative, content) in context.source_files {
286            let path = context
287                .repo_root
288                .join(self.dir_prefix())
289                .join("hooks")
290                .join(context.capability_id)
291                .join(relative);
292            files.push(PlannedFile::new(
293                relative_or_absolute_fs(&path, context.repo_root),
294                content.clone(),
295            ));
296        }
297        files.push(PlannedFile::mergeable(
298            relative_or_absolute_fs(&settings_path, context.repo_root),
299            merged,
300        ));
301
302        let mut diagnostics = Vec::new();
303        if entry.coverage == CoverageLevel::Partial {
304            let scope = if entry.scope.is_empty() {
305                "partial coverage".to_string()
306            } else {
307                format!("scope: {}", entry.scope.join(", "))
308            };
309            let caveat = entry
310                .caveat
311                .map(|caveat| format!("; {caveat}"))
312                .unwrap_or_default();
313            diagnostics.push(HookRenderDiagnostic {
314                level: HookRenderDiagnosticLevel::Warning,
315                message: format!(
316                    "{} renders '{}' with partial compatibility ({scope}{caveat})",
317                    self.display_name(),
318                    entry.event
319                ),
320            });
321        }
322
323        let managed_hooks = if context.track_managed_hooks {
324            crate::lockfile::managed_hooks_from_fragment_with_canonical(
325                context.repo_root,
326                settings_relpath,
327                &fragment,
328                Some(entry.event.as_str()),
329            )?
330        } else {
331            Vec::new()
332        };
333
334        Ok(HookRenderPlan {
335            files,
336            managed_hooks,
337            diagnostics,
338        })
339    }
340    /// The hooks-only fragment that registers `command` under `native_event`.
341    fn command_hook_fragment(&self, native_event: &str, command: &str) -> serde_json::Value {
342        self.hook_settings_shape()
343            .command_fragment(native_event, command)
344    }
345    /// Merge a hooks-only fragment into the settings file's current bytes.
346    fn merge_hook_fragment(
347        &self,
348        existing: Option<&[u8]>,
349        fragment: &serde_json::Value,
350    ) -> Result<Vec<u8>> {
351        self.hook_settings_shape()
352            .merge_fragment(self.hook_settings_relpath(), existing, fragment)
353    }
354    /// Take Tuff's registrations out of the settings file, leaving the
355    /// user's own alone.
356    fn remove_hook_settings(
357        &self,
358        repo_root: &Path,
359        managed_hooks: &[crate::lockfile::ManagedHook],
360    ) -> Result<()> {
361        crate::hook_settings::remove_registrations(
362            self.hook_settings_relpath(),
363            self.display_name(),
364            repo_root,
365            managed_hooks,
366        )
367    }
368    /// Whether a project already uses this harness.
369    fn detect(&self, repo_root: &Path) -> bool;
370
371    /// How this harness enforces each kind of policy rule, one row per
372    /// effect and subject. The default says, for every row, that Tuff does
373    /// not compile policies for this harness, so a policy is refused for it
374    /// rather than reported as installed.
375    fn policy_compatibility(&self) -> Vec<crate::policy::PolicyCoverageEntry> {
376        crate::policy::not_implemented_matrix()
377    }
378
379    /// The settings file this harness reads native permission rules from,
380    /// when it has one a repository can carry.
381    fn permissions_settings_relpath(&self) -> Option<&'static str> {
382        None
383    }
384
385    /// The native permission rules one policy rule compiles to on this
386    /// harness, or `None` when the harness has no such rules. The list the
387    /// rules go in follows the policy rule's effect.
388    fn native_permission_rules(
389        &self,
390        _rule: &crate::policy::PolicyRule,
391    ) -> Result<Option<Vec<String>>> {
392        Ok(None)
393    }
394
395    fn kinds_supported(&self) -> &[CapabilityType];
396
397    fn supports(&self, capability_type: CapabilityType) -> bool {
398        self.kinds_supported().contains(&capability_type)
399    }
400
401    fn native_hook_event(&self, raw_event: &str) -> Result<&'static str> {
402        let matrix = self.hook_compatibility();
403        let Some(entry) = matrix.find_event(raw_event) else {
404            return Err(TuffError::unsupported(format!(
405                "{} does not support hook event '{}'. A manifest can use: {}",
406                self.display_name(),
407                raw_event,
408                matrix.accepted_events_summary()
409            )));
410        };
411        entry.native_event_name().ok_or_else(|| {
412            let suffix = entry
413                .caveat
414                .map(|caveat| format!(": {}", caveat.trim_end_matches('.')))
415                .unwrap_or_default();
416            TuffError::unsupported(format!(
417                "{} does not support hook event '{}'{}. A manifest can use: {}",
418                self.display_name(),
419                raw_event,
420                suffix,
421                matrix.accepted_events_summary()
422            ))
423        })
424    }
425
426    fn canonical_hook_event(&self, raw_event: &str) -> Result<&'static str> {
427        let matrix = self.hook_compatibility();
428        let Some(entry) = matrix.find_event(raw_event) else {
429            return Err(TuffError::unsupported(format!(
430                "{} does not support hook event '{}'",
431                self.display_name(),
432                raw_event
433            )));
434        };
435        entry
436            .coverage
437            .is_supported()
438            .then_some(entry.event.as_str())
439            .ok_or_else(|| {
440                let suffix = entry
441                    .caveat
442                    .map(|caveat| format!(": {caveat}"))
443                    .unwrap_or_default();
444                TuffError::unsupported(format!(
445                    "{} does not support hook event '{}'{}",
446                    self.display_name(),
447                    raw_event,
448                    suffix
449                ))
450            })
451    }
452
453    fn ensure_project_dir(&self, repo_root: &Path) -> std::io::Result<()> {
454        std::fs::create_dir_all(repo_root.join(self.dir_prefix()))
455    }
456
457    /// How this harness spells a reference to an environment variable inside
458    /// its MCP config. Claude Code and most stdio clients expand `${VAR}`.
459    fn mcp_env_reference(&self, var: &str) -> String {
460        format!("${{{var}}}")
461    }
462
463    /// The `mcpServers.<id>` entry this harness needs for an external MCP
464    /// server. Secrets are emitted as env references, never values.
465    fn mcp_server_entry(&self, server: &crate::manifest::McpServerConfig) -> serde_json::Value {
466        use crate::manifest::McpTransport;
467        match server.transport {
468            McpTransport::Stdio => {
469                let mut entry = serde_json::json!({
470                    "command": server.command.clone().unwrap_or_default(),
471                    "args": server.args,
472                });
473                if !server.env.is_empty() {
474                    let env: serde_json::Map<String, serde_json::Value> = server
475                        .env
476                        .iter()
477                        .map(|(name, reference)| {
478                            (
479                                name.clone(),
480                                serde_json::Value::String(
481                                    self.mcp_env_reference(&reference.from_env),
482                                ),
483                            )
484                        })
485                        .collect();
486                    entry["env"] = serde_json::Value::Object(env);
487                }
488                entry
489            }
490            McpTransport::Http => {
491                let mut entry = serde_json::Map::new();
492                if self.mcp_http_declares_type() {
493                    entry.insert("type".into(), serde_json::Value::String("http".into()));
494                }
495                entry.insert(
496                    "url".into(),
497                    serde_json::Value::String(server.url.clone().unwrap_or_default()),
498                );
499                if !server.headers.is_empty() {
500                    let headers: serde_json::Map<String, serde_json::Value> = server
501                        .headers
502                        .iter()
503                        .map(|(name, reference)| {
504                            let value =
505                                reference.render(&self.mcp_env_reference(&reference.from_env));
506                            (name.clone(), serde_json::Value::String(value))
507                        })
508                        .collect();
509                    entry.insert("headers".into(), serde_json::Value::Object(headers));
510                }
511                serde_json::Value::Object(entry)
512            }
513        }
514    }
515
516    /// Whether this harness wants an explicit `"type": "http"` on a remote
517    /// server entry. Claude Code and Codex do; Cursor infers the transport
518    /// from `url` and has no `type` key for remote servers.
519    fn mcp_http_declares_type(&self) -> bool {
520        true
521    }
522
523    fn plan(&self, capability: &ResolvedCapability, repo_root: &Path) -> Result<Vec<PlannedFile>> {
524        match capability.capability_type {
525            CapabilityType::Tool => self.plan_tool(capability, repo_root),
526            CapabilityType::Hook => self.plan_hook(capability, repo_root),
527            CapabilityType::Workflow => self.plan_workflow(capability, repo_root),
528            CapabilityType::McpServer => self.plan_mcp_server(capability, repo_root),
529            CapabilityType::Policy => self.plan_policy(capability, repo_root),
530            CapabilityType::Skill => self.plan_skill(capability, repo_root),
531        }
532    }
533
534    fn remove(
535        &self,
536        primitive_id: &str,
537        repo_root: &Path,
538        managed_hooks: &[crate::lockfile::ManagedHook],
539    ) -> Result<()> {
540        // Settings first, files last. Taking registrations out of a settings
541        // file is the step that can refuse, when the user's file is not valid
542        // JSON; deleting directories cannot meaningfully be refused. In this
543        // order a corrupt file stops the removal with every file still in
544        // place and the capability still tracked, instead of after its files
545        // are gone.
546        self.remove_hook_settings(repo_root, managed_hooks)?;
547        crate::mcp::remove_tool(&repo_root.join(self.mcp_config_relpath()), primitive_id)?;
548        let prefix = self.dir_prefix();
549        for kind in &[
550            "skills",
551            "tools",
552            "hooks",
553            "workflows",
554            "mcp-servers",
555            "policies",
556        ] {
557            self.remove_dir(repo_root, prefix, kind, primitive_id)?;
558        }
559        Ok(())
560    }
561
562    // ── internal helpers ───────────────────────────────────────────────
563
564    fn plan_skill(
565        &self,
566        capability: &ResolvedCapability,
567        repo_root: &Path,
568    ) -> Result<Vec<PlannedFile>> {
569        if capability.source_files.is_empty() {
570            return Err(TuffError::usage("no source files to emit"));
571        }
572
573        let mut files = Vec::new();
574        for (rel_path, content) in &capability.source_files {
575            let target_path = repo_root
576                .join(self.dir_prefix())
577                .join("skills")
578                .join(&capability.id)
579                .join(rel_path);
580
581            files.push(PlannedFile::new(
582                relative_or_absolute_fs(&target_path, repo_root),
583                content.clone(),
584            ));
585        }
586        Ok(files)
587    }
588
589    fn plan_tool(
590        &self,
591        capability: &ResolvedCapability,
592        repo_root: &Path,
593    ) -> Result<Vec<PlannedFile>> {
594        let mut files = Vec::new();
595
596        for (rel_path, content) in &capability.source_files {
597            let target_path = repo_root
598                .join(self.dir_prefix())
599                .join("tools")
600                .join(&capability.id)
601                .join(rel_path);
602
603            files.push(PlannedFile::new(
604                relative_or_absolute_fs(&target_path, repo_root),
605                content.clone(),
606            ));
607        }
608
609        if capability.source_files.is_empty() {
610            let placeholder = repo_root
611                .join(self.dir_prefix())
612                .join("tools")
613                .join(&capability.id)
614                .join(".gitkeep");
615            files.push(PlannedFile::new(
616                relative_or_absolute_fs(&placeholder, repo_root),
617                vec![],
618            ));
619        }
620
621        Ok(files)
622    }
623
624    fn plan_hook(
625        &self,
626        capability: &ResolvedCapability,
627        repo_root: &Path,
628    ) -> Result<Vec<PlannedFile>> {
629        let CapabilityKind::Hook { hook } = &capability.kind else {
630            return Err(TuffError::new("plan_hook called on non-hook capability"));
631        };
632
633        match hook {
634            HookDefinition::Command(hook_cfg) => {
635                let render = self.render_standard_hook(HookRenderContext {
636                    capability_id: &capability.id,
637                    hook: hook_cfg,
638                    source_files: &capability.source_files,
639                    repo_root,
640                    track_managed_hooks: false,
641                })?;
642                Ok(render.files)
643            }
644            HookDefinition::Native(native) => self.plan_native_hook(capability, native, repo_root),
645        }
646    }
647
648    fn plan_native_hook(
649        &self,
650        capability: &ResolvedCapability,
651        native: &NativeHookConfig,
652        repo_root: &Path,
653    ) -> Result<Vec<PlannedFile>> {
654        let hook_root = repo_root
655            .join(self.dir_prefix())
656            .join("hooks")
657            .join(&capability.id);
658        let hook_root_rel = relative_or_absolute_fs(&hook_root, repo_root);
659        let in_harness_source =
660            path_is_under(&capability.source_dir, &repo_root.join(self.dir_prefix()));
661
662        let mut files = Vec::new();
663        if in_harness_source {
664            for (rel_path, content) in &native.source_files {
665                let target_path = capability.source_dir.join(rel_path);
666                files.push(PlannedFile::mergeable(
667                    relative_or_absolute_fs(&target_path, repo_root),
668                    content.clone(),
669                ));
670            }
671        } else {
672            for (rel_path, content) in &native.source_files {
673                let target_path = hook_root.join(rel_path);
674                files.push(PlannedFile::new(
675                    relative_or_absolute_fs(&target_path, repo_root),
676                    content.clone(),
677                ));
678            }
679        }
680
681        let fragment = replace_hook_dir_placeholder(native.fragment.clone(), &hook_root_rel);
682        let settings_relpath = self.hook_settings_relpath();
683        let settings_path = repo_root.join(settings_relpath);
684        let existing = if settings_path.is_file() {
685            Some(std::fs::read(&settings_path)?)
686        } else {
687            None
688        };
689        let merged = self.merge_hook_fragment(existing.as_deref(), &fragment)?;
690        files.push(PlannedFile::mergeable(
691            relative_or_absolute_fs(&settings_path, repo_root),
692            merged,
693        ));
694        Ok(files)
695    }
696
697    fn plan_workflow(
698        &self,
699        capability: &ResolvedCapability,
700        repo_root: &Path,
701    ) -> Result<Vec<PlannedFile>> {
702        let CapabilityKind::Workflow { workflow: wf } = &capability.kind else {
703            return Err(TuffError::new(
704                "plan_workflow called on non-workflow capability",
705            ));
706        };
707
708        let target_path = repo_root
709            .join(self.dir_prefix())
710            .join("workflows")
711            .join(&capability.id)
712            .join("workflow.toml");
713
714        let content = serialize_workflow(capability, wf)?;
715
716        Ok(vec![PlannedFile::new(
717            relative_or_absolute_fs(&target_path, repo_root),
718            content,
719        )])
720    }
721
722    /// Emit the canonical `server.toml` record. The JSON entry in the
723    /// harness's MCP config is the artifact the harness reads; this file is
724    /// what gives the capability a tree to hash, so `check`/`diff`/`delete`
725    /// work exactly as they do for every other kind.
726    fn plan_mcp_server(
727        &self,
728        capability: &ResolvedCapability,
729        repo_root: &Path,
730    ) -> Result<Vec<PlannedFile>> {
731        let CapabilityKind::McpServer { server } = &capability.kind else {
732            return Err(TuffError::new(
733                "plan_mcp_server called on non-mcp-server capability",
734            ));
735        };
736
737        let target_path = repo_root
738            .join(self.dir_prefix())
739            .join("mcp-servers")
740            .join(&capability.id)
741            .join("server.toml");
742
743        let content = serialize_mcp_server(capability, server)?;
744
745        Ok(vec![PlannedFile::new(
746            relative_or_absolute_fs(&target_path, repo_root),
747            content,
748        )])
749    }
750
751    /// Emit the canonical `policy.toml` record. The rules the harness reads
752    /// live in its settings file; this record gives the capability a tree
753    /// to hash, so `check`, `diff`, and `delete` treat it like every other
754    /// kind, and `update` can tell when the policy's rules changed.
755    fn plan_policy(
756        &self,
757        capability: &ResolvedCapability,
758        repo_root: &Path,
759    ) -> Result<Vec<PlannedFile>> {
760        let CapabilityKind::Policy { policy } = &capability.kind else {
761            return Err(TuffError::new(
762                "plan_policy called on non-policy capability",
763            ));
764        };
765        let target_path = repo_root
766            .join(self.dir_prefix())
767            .join("policies")
768            .join(&capability.id)
769            .join("policy.toml");
770        let content = serialize_policy(capability, policy)?;
771        Ok(vec![PlannedFile::new(
772            relative_or_absolute_fs(&target_path, repo_root),
773            content,
774        )])
775    }
776
777    fn remove_dir(
778        &self,
779        repo_root: &Path,
780        base: &str,
781        kind: &str,
782        primitive_id: &str,
783    ) -> Result<()> {
784        let dir = repo_root.join(base).join(kind).join(primitive_id);
785
786        if dir.exists() {
787            std::fs::remove_dir_all(&dir)?;
788        }
789
790        let kind_dir = dir.parent().expect("kind dir should have parent");
791        if kind_dir.exists() {
792            let mut rd = match std::fs::read_dir(kind_dir) {
793                Ok(rd) => rd,
794                Err(_) => return Ok(()),
795            };
796            if rd.next().is_none() {
797                std::fs::remove_dir(kind_dir)?;
798            }
799        }
800
801        let base_dir = kind_dir.parent().expect("base dir should have parent");
802        if base_dir.exists() {
803            let mut rd = match std::fs::read_dir(base_dir) {
804                Ok(rd) => rd,
805                Err(_) => return Ok(()),
806            };
807            if rd.next().is_none() {
808                std::fs::remove_dir(base_dir)?;
809            }
810        }
811
812        Ok(())
813    }
814}
815
816fn render_hook_script(hook_cfg: &HookConfig) -> Result<Vec<u8>> {
817    let working_directory = shell_single_quote(&hook_cfg.working_directory)?;
818    let command = shell_single_quote(&hook_cfg.command)?;
819    Ok(format!(
820        "#!/usr/bin/env bash\nset -euo pipefail\ncd -- {working_directory}\nexec bash -euo pipefail -c {command}\n"
821    )
822    .into_bytes())
823}
824
825fn shell_single_quote(value: &str) -> Result<String> {
826    if value.contains('\0') {
827        return Err(TuffError::usage(
828            "hook working directory and command cannot contain NUL bytes",
829        ));
830    }
831    Ok(format!("'{}'", value.replace('\'', "'\"'\"'")))
832}
833
834fn serialize_workflow(
835    capability: &ResolvedCapability,
836    workflow: &crate::manifest::WorkflowConfig,
837) -> Result<Vec<u8>> {
838    let document = WorkflowDocument {
839        id: &capability.id,
840        version: &capability.version,
841        capability_type: capability.capability_type,
842        description: &capability.description,
843        workflow,
844    };
845    let mut content = toml::to_string_pretty(&document)?;
846    if !content.ends_with('\n') {
847        content.push('\n');
848    }
849    Ok(content.into_bytes())
850}
851
852#[derive(Serialize)]
853struct McpServerDocument<'a> {
854    id: &'a str,
855    version: &'a str,
856    #[serde(rename = "type")]
857    capability_type: CapabilityType,
858    description: &'a str,
859    server: &'a crate::manifest::McpServerConfig,
860}
861
862fn serialize_mcp_server(
863    capability: &ResolvedCapability,
864    server: &crate::manifest::McpServerConfig,
865) -> Result<Vec<u8>> {
866    let document = McpServerDocument {
867        id: &capability.id,
868        version: &capability.version,
869        capability_type: capability.capability_type,
870        description: &capability.description,
871        server,
872    };
873    let mut content = toml::to_string_pretty(&document)?;
874    if !content.ends_with('\n') {
875        content.push('\n');
876    }
877    Ok(content.into_bytes())
878}
879
880#[derive(Serialize)]
881struct PolicyDocument<'a> {
882    id: &'a str,
883    version: &'a str,
884    #[serde(rename = "type")]
885    capability_type: CapabilityType,
886    description: &'a str,
887    policy: &'a crate::policy::PolicyConfig,
888}
889
890fn serialize_policy(
891    capability: &ResolvedCapability,
892    policy: &crate::policy::PolicyConfig,
893) -> Result<Vec<u8>> {
894    let document = PolicyDocument {
895        id: &capability.id,
896        version: &capability.version,
897        capability_type: capability.capability_type,
898        description: &capability.description,
899        policy,
900    };
901    let mut content = toml::to_string_pretty(&document)?;
902    if !content.ends_with('\n') {
903        content.push('\n');
904    }
905    Ok(content.into_bytes())
906}
907
908fn path_is_under(path: &Path, root: &Path) -> bool {
909    let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
910    let canonical_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
911    canonical_path.starts_with(canonical_root)
912}
913
914pub fn replace_hook_dir_placeholder(
915    mut value: serde_json::Value,
916    hook_dir: &str,
917) -> serde_json::Value {
918    match &mut value {
919        serde_json::Value::String(s) => {
920            *s = s.replace("{{hook_dir}}", hook_dir);
921        }
922        serde_json::Value::Array(items) => {
923            for item in items {
924                *item = replace_hook_dir_placeholder(item.take(), hook_dir);
925            }
926        }
927        serde_json::Value::Object(map) => {
928            for item in map.values_mut() {
929                *item = replace_hook_dir_placeholder(item.take(), hook_dir);
930            }
931        }
932        _ => {}
933    }
934    value
935}
936
937fn relative_or_absolute_fs(path: &Path, repo_root: &Path) -> String {
938    crate::lockfile::relative_or_absolute_fs(path, repo_root)
939}
940
941#[cfg(test)]
942mod tests {
943    use super::*;
944    use crate::manifest::{Requirement, WorkflowConfig};
945
946    #[cfg(unix)]
947    #[test]
948    fn hook_script_preserves_shell_sensitive_values() {
949        use std::process::Command;
950
951        let temp = tempfile::tempdir().expect("tempdir");
952        let working_directory = temp.path().join("directory with ' quote");
953        std::fs::create_dir(&working_directory).expect("create working directory");
954        let hook = HookConfig {
955            event: "stop".to_string(),
956            command: "printf '%s\\n' 'safe; $HOME `literal`' > result.txt".to_string(),
957            working_directory: working_directory.to_string_lossy().into_owned(),
958        };
959        let script_path = temp.path().join("run.sh");
960        std::fs::write(
961            &script_path,
962            render_hook_script(&hook).expect("render script"),
963        )
964        .expect("write script");
965
966        let syntax = Command::new("bash")
967            .arg("-n")
968            .arg(&script_path)
969            .status()
970            .expect("check script syntax");
971        assert!(syntax.success());
972        let executed = Command::new("bash")
973            .arg(&script_path)
974            .status()
975            .expect("execute script");
976        assert!(executed.success());
977        assert_eq!(
978            std::fs::read_to_string(working_directory.join("result.txt"))
979                .expect("read command output"),
980            "safe; $HOME `literal`\n"
981        );
982    }
983
984    #[test]
985    fn hook_script_rejects_nul_bytes() {
986        let hook = HookConfig {
987            event: "stop".to_string(),
988            command: "printf '\0'".to_string(),
989            working_directory: ".".to_string(),
990        };
991
992        assert!(render_hook_script(&hook).is_err());
993    }
994
995    #[test]
996    fn workflow_serialization_escapes_manifest_values() {
997        let workflow = WorkflowConfig {
998            requires: vec![Requirement {
999                id: "dependency\"\\name".to_string(),
1000                capability_type: CapabilityType::Skill,
1001            }],
1002        };
1003        let capability = ResolvedCapability {
1004            id: "workflow\"id".to_string(),
1005            capability_type: CapabilityType::Workflow,
1006            version: "1.0.0".to_string(),
1007            description: "first line\nsecond \"line\" \\ value".to_string(),
1008            source_files: Vec::new(),
1009            source_dir: PathBuf::new(),
1010            kind: CapabilityKind::Workflow {
1011                workflow: workflow.clone(),
1012            },
1013        };
1014
1015        let bytes = serialize_workflow(&capability, &workflow).expect("serialize workflow");
1016        let parsed: toml::Value = toml::from_slice(&bytes).expect("parse emitted workflow");
1017
1018        assert_eq!(parsed["id"].as_str(), Some("workflow\"id"));
1019        assert_eq!(
1020            parsed["description"].as_str(),
1021            Some("first line\nsecond \"line\" \\ value")
1022        );
1023        assert_eq!(
1024            parsed["workflow"]["requires"][0]["id"].as_str(),
1025            Some("dependency\"\\name")
1026        );
1027    }
1028}