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 => serde_json::json!({
419                "type": "http",
420                "url": server.url.clone().unwrap_or_default(),
421            }),
422        }
423    }
424
425    fn plan(&self, capability: &ResolvedCapability, repo_root: &Path) -> Result<Vec<PlannedFile>> {
426        match capability.capability_type {
427            CapabilityType::Tool => self.plan_tool(capability, repo_root),
428            CapabilityType::Hook => self.plan_hook(capability, repo_root),
429            CapabilityType::Workflow => self.plan_workflow(capability, repo_root),
430            CapabilityType::McpServer => self.plan_mcp_server(capability, repo_root),
431            CapabilityType::Policy => Err(TuffError::unsupported(
432                "policy capabilities are not installable yet",
433            )),
434            CapabilityType::Skill => self.plan_skill(capability, repo_root),
435        }
436    }
437
438    fn remove(
439        &self,
440        primitive_id: &str,
441        repo_root: &Path,
442        managed_hooks: &[crate::lockfile::ManagedHook],
443    ) -> Result<()> {
444        let prefix = self.dir_prefix();
445        for kind in &["skills", "tools", "hooks", "workflows", "mcp-servers"] {
446            self.remove_dir(repo_root, prefix, kind, primitive_id)?;
447        }
448        crate::mcp::remove_tool(&repo_root.join(self.mcp_config_relpath()), primitive_id)?;
449        self.remove_hook_settings(repo_root, managed_hooks)?;
450        Ok(())
451    }
452
453    // ── internal helpers ───────────────────────────────────────────────
454
455    fn plan_skill(
456        &self,
457        capability: &ResolvedCapability,
458        repo_root: &Path,
459    ) -> Result<Vec<PlannedFile>> {
460        if capability.source_files.is_empty() {
461            return Err(TuffError::usage("no source files to emit"));
462        }
463
464        let mut files = Vec::new();
465        for (rel_path, content) in &capability.source_files {
466            let target_path = repo_root
467                .join(self.dir_prefix())
468                .join("skills")
469                .join(&capability.id)
470                .join(rel_path);
471
472            files.push(PlannedFile::new(
473                relative_or_absolute_fs(&target_path, repo_root),
474                content.clone(),
475            ));
476        }
477        Ok(files)
478    }
479
480    fn plan_tool(
481        &self,
482        capability: &ResolvedCapability,
483        repo_root: &Path,
484    ) -> Result<Vec<PlannedFile>> {
485        let mut files = Vec::new();
486
487        for (rel_path, content) in &capability.source_files {
488            let target_path = repo_root
489                .join(self.dir_prefix())
490                .join("tools")
491                .join(&capability.id)
492                .join(rel_path);
493
494            files.push(PlannedFile::new(
495                relative_or_absolute_fs(&target_path, repo_root),
496                content.clone(),
497            ));
498        }
499
500        if capability.source_files.is_empty() {
501            let placeholder = repo_root
502                .join(self.dir_prefix())
503                .join("tools")
504                .join(&capability.id)
505                .join(".gitkeep");
506            files.push(PlannedFile::new(
507                relative_or_absolute_fs(&placeholder, repo_root),
508                vec![],
509            ));
510        }
511
512        Ok(files)
513    }
514
515    fn plan_hook(
516        &self,
517        capability: &ResolvedCapability,
518        repo_root: &Path,
519    ) -> Result<Vec<PlannedFile>> {
520        let CapabilityKind::Hook { hook } = &capability.kind else {
521            return Err(TuffError::new("plan_hook called on non-hook capability"));
522        };
523
524        match hook {
525            HookDefinition::Command(hook_cfg) => {
526                let render = self.render_standard_hook(HookRenderContext {
527                    capability_id: &capability.id,
528                    hook: hook_cfg,
529                    source_files: &capability.source_files,
530                    repo_root,
531                    track_managed_hooks: false,
532                })?;
533                Ok(render.files)
534            }
535            HookDefinition::Native(native) => self.plan_native_hook(capability, native, repo_root),
536        }
537    }
538
539    fn plan_native_hook(
540        &self,
541        capability: &ResolvedCapability,
542        native: &NativeHookConfig,
543        repo_root: &Path,
544    ) -> Result<Vec<PlannedFile>> {
545        let hook_root = repo_root
546            .join(self.dir_prefix())
547            .join("hooks")
548            .join(&capability.id);
549        let hook_root_rel = relative_or_absolute_fs(&hook_root, repo_root);
550        let in_harness_source =
551            path_is_under(&capability.source_dir, &repo_root.join(self.dir_prefix()));
552
553        let mut files = Vec::new();
554        if in_harness_source {
555            for (rel_path, content) in &native.source_files {
556                let target_path = capability.source_dir.join(rel_path);
557                files.push(PlannedFile::mergeable(
558                    relative_or_absolute_fs(&target_path, repo_root),
559                    content.clone(),
560                ));
561            }
562        } else {
563            for (rel_path, content) in &native.source_files {
564                let target_path = hook_root.join(rel_path);
565                files.push(PlannedFile::new(
566                    relative_or_absolute_fs(&target_path, repo_root),
567                    content.clone(),
568                ));
569            }
570        }
571
572        let fragment = replace_hook_dir_placeholder(native.fragment.clone(), &hook_root_rel);
573        let settings_relpath = self.hook_settings_relpath();
574        let settings_path = repo_root.join(settings_relpath);
575        let existing = if settings_path.is_file() {
576            Some(std::fs::read(&settings_path)?)
577        } else {
578            None
579        };
580        let merged = self.merge_hook_fragment(existing.as_deref(), &fragment)?;
581        files.push(PlannedFile::mergeable(
582            relative_or_absolute_fs(&settings_path, repo_root),
583            merged,
584        ));
585        Ok(files)
586    }
587
588    fn plan_workflow(
589        &self,
590        capability: &ResolvedCapability,
591        repo_root: &Path,
592    ) -> Result<Vec<PlannedFile>> {
593        let CapabilityKind::Workflow { workflow: wf } = &capability.kind else {
594            return Err(TuffError::new(
595                "plan_workflow called on non-workflow capability",
596            ));
597        };
598
599        let target_path = repo_root
600            .join(self.dir_prefix())
601            .join("workflows")
602            .join(&capability.id)
603            .join("workflow.toml");
604
605        let content = serialize_workflow(capability, wf)?;
606
607        Ok(vec![PlannedFile::new(
608            relative_or_absolute_fs(&target_path, repo_root),
609            content,
610        )])
611    }
612
613    /// Emit the canonical `server.toml` record. The JSON entry in the
614    /// harness's MCP config is the artifact the harness reads; this file is
615    /// what gives the capability a tree to hash, so `check`/`diff`/`delete`
616    /// work exactly as they do for every other kind.
617    fn plan_mcp_server(
618        &self,
619        capability: &ResolvedCapability,
620        repo_root: &Path,
621    ) -> Result<Vec<PlannedFile>> {
622        let CapabilityKind::McpServer { server } = &capability.kind else {
623            return Err(TuffError::new(
624                "plan_mcp_server called on non-mcp-server capability",
625            ));
626        };
627
628        let target_path = repo_root
629            .join(self.dir_prefix())
630            .join("mcp-servers")
631            .join(&capability.id)
632            .join("server.toml");
633
634        let content = serialize_mcp_server(capability, server)?;
635
636        Ok(vec![PlannedFile::new(
637            relative_or_absolute_fs(&target_path, repo_root),
638            content,
639        )])
640    }
641
642    fn remove_dir(
643        &self,
644        repo_root: &Path,
645        base: &str,
646        kind: &str,
647        primitive_id: &str,
648    ) -> Result<()> {
649        let dir = repo_root.join(base).join(kind).join(primitive_id);
650
651        if dir.exists() {
652            std::fs::remove_dir_all(&dir)?;
653        }
654
655        let kind_dir = dir.parent().expect("kind dir should have parent");
656        if kind_dir.exists() {
657            let mut rd = match std::fs::read_dir(kind_dir) {
658                Ok(rd) => rd,
659                Err(_) => return Ok(()),
660            };
661            if rd.next().is_none() {
662                std::fs::remove_dir(kind_dir)?;
663            }
664        }
665
666        let base_dir = kind_dir.parent().expect("base dir should have parent");
667        if base_dir.exists() {
668            let mut rd = match std::fs::read_dir(base_dir) {
669                Ok(rd) => rd,
670                Err(_) => return Ok(()),
671            };
672            if rd.next().is_none() {
673                std::fs::remove_dir(base_dir)?;
674            }
675        }
676
677        Ok(())
678    }
679}
680
681fn render_hook_script(hook_cfg: &HookConfig) -> Result<Vec<u8>> {
682    let working_directory = shell_single_quote(&hook_cfg.working_directory)?;
683    let command = shell_single_quote(&hook_cfg.command)?;
684    Ok(format!(
685        "#!/usr/bin/env bash\nset -euo pipefail\ncd -- {working_directory}\nexec bash -euo pipefail -c {command}\n"
686    )
687    .into_bytes())
688}
689
690fn shell_single_quote(value: &str) -> Result<String> {
691    if value.contains('\0') {
692        return Err(TuffError::usage(
693            "hook working directory and command cannot contain NUL bytes",
694        ));
695    }
696    Ok(format!("'{}'", value.replace('\'', "'\"'\"'")))
697}
698
699fn serialize_workflow(
700    capability: &ResolvedCapability,
701    workflow: &crate::manifest::WorkflowConfig,
702) -> Result<Vec<u8>> {
703    let document = WorkflowDocument {
704        id: &capability.id,
705        version: &capability.version,
706        capability_type: capability.capability_type,
707        description: &capability.description,
708        workflow,
709    };
710    let mut content = toml::to_string_pretty(&document)?;
711    if !content.ends_with('\n') {
712        content.push('\n');
713    }
714    Ok(content.into_bytes())
715}
716
717#[derive(Serialize)]
718struct McpServerDocument<'a> {
719    id: &'a str,
720    version: &'a str,
721    #[serde(rename = "type")]
722    capability_type: CapabilityType,
723    description: &'a str,
724    server: &'a crate::manifest::McpServerConfig,
725}
726
727fn serialize_mcp_server(
728    capability: &ResolvedCapability,
729    server: &crate::manifest::McpServerConfig,
730) -> Result<Vec<u8>> {
731    let document = McpServerDocument {
732        id: &capability.id,
733        version: &capability.version,
734        capability_type: capability.capability_type,
735        description: &capability.description,
736        server,
737    };
738    let mut content = toml::to_string_pretty(&document)?;
739    if !content.ends_with('\n') {
740        content.push('\n');
741    }
742    Ok(content.into_bytes())
743}
744
745fn path_is_under(path: &Path, root: &Path) -> bool {
746    let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
747    let canonical_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
748    canonical_path.starts_with(canonical_root)
749}
750
751pub fn replace_hook_dir_placeholder(
752    mut value: serde_json::Value,
753    hook_dir: &str,
754) -> serde_json::Value {
755    match &mut value {
756        serde_json::Value::String(s) => {
757            *s = s.replace("{{hook_dir}}", hook_dir);
758        }
759        serde_json::Value::Array(items) => {
760            for item in items {
761                *item = replace_hook_dir_placeholder(item.take(), hook_dir);
762            }
763        }
764        serde_json::Value::Object(map) => {
765            for item in map.values_mut() {
766                *item = replace_hook_dir_placeholder(item.take(), hook_dir);
767            }
768        }
769        _ => {}
770    }
771    value
772}
773
774fn relative_or_absolute_fs(path: &Path, repo_root: &Path) -> String {
775    crate::lockfile::relative_or_absolute_fs(path, repo_root)
776}
777
778#[cfg(test)]
779mod tests {
780    use super::*;
781    use crate::manifest::{Requirement, WorkflowConfig};
782
783    #[cfg(unix)]
784    #[test]
785    fn hook_script_preserves_shell_sensitive_values() {
786        use std::process::Command;
787
788        let temp = tempfile::tempdir().expect("tempdir");
789        let working_directory = temp.path().join("directory with ' quote");
790        std::fs::create_dir(&working_directory).expect("create working directory");
791        let hook = HookConfig {
792            event: "stop".to_string(),
793            command: "printf '%s\\n' 'safe; $HOME `literal`' > result.txt".to_string(),
794            working_directory: working_directory.to_string_lossy().into_owned(),
795        };
796        let script_path = temp.path().join("run.sh");
797        std::fs::write(
798            &script_path,
799            render_hook_script(&hook).expect("render script"),
800        )
801        .expect("write script");
802
803        let syntax = Command::new("bash")
804            .arg("-n")
805            .arg(&script_path)
806            .status()
807            .expect("check script syntax");
808        assert!(syntax.success());
809        let executed = Command::new("bash")
810            .arg(&script_path)
811            .status()
812            .expect("execute script");
813        assert!(executed.success());
814        assert_eq!(
815            std::fs::read_to_string(working_directory.join("result.txt"))
816                .expect("read command output"),
817            "safe; $HOME `literal`\n"
818        );
819    }
820
821    #[test]
822    fn hook_script_rejects_nul_bytes() {
823        let hook = HookConfig {
824            event: "stop".to_string(),
825            command: "printf '\0'".to_string(),
826            working_directory: ".".to_string(),
827        };
828
829        assert!(render_hook_script(&hook).is_err());
830    }
831
832    #[test]
833    fn workflow_serialization_escapes_manifest_values() {
834        let workflow = WorkflowConfig {
835            requires: vec![Requirement {
836                id: "dependency\"\\name".to_string(),
837                capability_type: CapabilityType::Skill,
838            }],
839        };
840        let capability = ResolvedCapability {
841            id: "workflow\"id".to_string(),
842            capability_type: CapabilityType::Workflow,
843            version: "1.0.0".to_string(),
844            description: "first line\nsecond \"line\" \\ value".to_string(),
845            source_files: Vec::new(),
846            source_dir: PathBuf::new(),
847            kind: CapabilityKind::Workflow {
848                workflow: workflow.clone(),
849            },
850        };
851
852        let bytes = serialize_workflow(&capability, &workflow).expect("serialize workflow");
853        let parsed: toml::Value = toml::from_slice(&bytes).expect("parse emitted workflow");
854
855        assert_eq!(parsed["id"].as_str(), Some("workflow\"id"));
856        assert_eq!(
857            parsed["description"].as_str(),
858            Some("first line\nsecond \"line\" \\ value")
859        );
860        assert_eq!(
861            parsed["workflow"]["requires"][0]["id"].as_str(),
862            Some("dependency\"\\name")
863        );
864    }
865}