Skip to main content

tuff_core/
adapter.rs

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