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