Skip to main content

tuff_core/
adapter.rs

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