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
10pub 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}
110
111impl CapabilityKind {
112 pub fn capability_type(&self) -> CapabilityType {
113 match self {
114 Self::Skill => CapabilityType::Skill,
115 Self::Tool { .. } => CapabilityType::Tool,
116 Self::Hook { .. } => CapabilityType::Hook,
117 Self::Workflow { .. } => CapabilityType::Workflow,
118 }
119 }
120}
121
122pub struct ResolvedCapability {
123 pub id: String,
124 pub capability_type: CapabilityType,
125 pub version: String,
126 pub description: String,
127 pub source_files: Vec<(String, Vec<u8>)>,
128 pub source_dir: PathBuf,
129 pub kind: CapabilityKind,
130}
131
132#[derive(Serialize)]
133struct WorkflowDocument<'a> {
134 id: &'a str,
135 version: &'a str,
136 #[serde(rename = "type")]
137 capability_type: CapabilityType,
138 description: &'a str,
139 workflow: &'a crate::manifest::WorkflowConfig,
140}
141
142pub fn resolve_capability(manifest: &CapabilityManifest) -> Result<ResolvedCapability> {
143 let source_files = manifest.read_source_contents_with_names()?;
144 let kind =
145 match manifest.capability_type {
146 CapabilityType::Skill => CapabilityKind::Skill,
147 CapabilityType::Tool => CapabilityKind::Tool {
148 parameters: manifest.parameters.clone().ok_or_else(|| {
149 TuffError::new("tool capability requires [parameters] section")
150 })?,
151 implementation: manifest.implementation.clone().ok_or_else(|| {
152 TuffError::new("tool capability requires [implementation] section")
153 })?,
154 },
155 CapabilityType::Hook => {
156 CapabilityKind::Hook {
157 hook: HookDefinition::Command(manifest.hook.clone().ok_or_else(|| {
158 TuffError::new("hook capability requires [hook] section")
159 })?),
160 }
161 }
162 CapabilityType::Workflow => CapabilityKind::Workflow {
163 workflow: manifest.workflow.clone().ok_or_else(|| {
164 TuffError::new("workflow capability requires [workflow] section")
165 })?,
166 },
167 CapabilityType::Policy => {
168 return Err(TuffError::new(
169 "policy capabilities are not installable yet",
170 ));
171 }
172 };
173 Ok(ResolvedCapability {
174 id: manifest.id.clone(),
175 capability_type: manifest.capability_type,
176 version: manifest.version.clone(),
177 description: manifest.description.clone(),
178 source_files,
179 source_dir: manifest.root.clone(),
180 kind,
181 })
182}
183
184pub trait AgentAdapter {
185 fn id(&self) -> &'static str;
186 fn display_name(&self) -> &'static str;
187 fn dir_prefix(&self) -> &'static str;
188 fn mcp_config_relpath(&self) -> &'static str;
189 fn supported_agents(&self) -> &[&'static str];
190 fn hook_compatibility(&self) -> &'static CompatibilityMatrix;
191 fn hook_settings_relpath(&self) -> &'static str;
192 fn scaffold_hook_event(&self) -> &'static str;
193 fn hook_filename(&self) -> &'static str;
194 fn hook_file_content(&self, hook_cfg: &crate::manifest::HookConfig) -> Result<Vec<u8>> {
195 render_hook_script(hook_cfg)
196 }
197 fn render_standard_hook(&self, context: HookRenderContext<'_>) -> Result<HookRenderPlan> {
198 let matrix = self.hook_compatibility();
199 let Some(entry) = matrix.find_event(&context.hook.event) else {
200 return Err(TuffError::new(format!(
201 "{} does not support hook event '{}'. Supported events: {}",
202 self.display_name(),
203 context.hook.event,
204 matrix.supported_native_events().join(", ")
205 )));
206 };
207 let Some(native_event) = entry.native_event_name() else {
208 let suffix = entry
209 .caveat
210 .map(|caveat| format!(": {caveat}"))
211 .unwrap_or_default();
212 return Err(TuffError::new(format!(
213 "{} does not support hook event '{}'{}",
214 self.display_name(),
215 context.hook.event,
216 suffix
217 )));
218 };
219
220 let command = format!(
221 "sh {}/hooks/{}/{}",
222 self.dir_prefix(),
223 context.capability_id,
224 self.hook_filename()
225 );
226 let target_path = context
227 .repo_root
228 .join(self.dir_prefix())
229 .join("hooks")
230 .join(context.capability_id)
231 .join(self.hook_filename());
232 let script = self.hook_file_content(context.hook)?;
233 let settings_relpath = self.hook_settings_relpath();
234 let fragment = self.command_hook_fragment(native_event, &command);
235 let settings_path = context.repo_root.join(settings_relpath);
236 let existing = if settings_path.is_file() {
237 Some(std::fs::read(&settings_path)?)
238 } else {
239 None
240 };
241 let merged = self.merge_hook_fragment(existing.as_deref(), &fragment)?;
242
243 let mut files = vec![PlannedFile::new(
244 relative_or_absolute_fs(&target_path, context.repo_root),
245 script,
246 )];
247 for (relative, content) in context.source_files {
248 let path = context
249 .repo_root
250 .join(self.dir_prefix())
251 .join("hooks")
252 .join(context.capability_id)
253 .join(relative);
254 files.push(PlannedFile::new(
255 relative_or_absolute_fs(&path, context.repo_root),
256 content.clone(),
257 ));
258 }
259 files.push(PlannedFile::mergeable(
260 relative_or_absolute_fs(&settings_path, context.repo_root),
261 merged,
262 ));
263
264 let mut diagnostics = Vec::new();
265 if entry.coverage == CoverageLevel::Partial {
266 let scope = if entry.scope.is_empty() {
267 "partial coverage".to_string()
268 } else {
269 format!("scope: {}", entry.scope.join(", "))
270 };
271 let caveat = entry
272 .caveat
273 .map(|caveat| format!("; {caveat}"))
274 .unwrap_or_default();
275 diagnostics.push(HookRenderDiagnostic {
276 level: HookRenderDiagnosticLevel::Warning,
277 message: format!(
278 "{} renders '{}' with partial compatibility ({scope}{caveat})",
279 self.display_name(),
280 entry.event
281 ),
282 });
283 }
284
285 let managed_hooks = if context.track_managed_hooks {
286 crate::lockfile::managed_hooks_from_fragment_with_canonical(
287 context.repo_root,
288 settings_relpath,
289 &fragment,
290 Some(entry.event.as_str()),
291 )?
292 } else {
293 Vec::new()
294 };
295
296 Ok(HookRenderPlan {
297 files,
298 managed_hooks,
299 diagnostics,
300 })
301 }
302 fn command_hook_fragment(&self, native_event: &str, command: &str) -> serde_json::Value;
303 fn merge_hook_fragment(
304 &self,
305 existing: Option<&[u8]>,
306 fragment: &serde_json::Value,
307 ) -> Result<Vec<u8>>;
308 fn remove_hook_settings(
309 &self,
310 repo_root: &Path,
311 managed_hooks: &[crate::lockfile::ManagedHook],
312 ) -> Result<()>;
313 fn detect(&self, repo_root: &Path) -> bool;
314
315 fn kinds_supported(&self) -> &[CapabilityType];
316
317 fn supports(&self, capability_type: CapabilityType) -> bool {
318 self.kinds_supported().contains(&capability_type)
319 }
320
321 fn native_hook_event(&self, raw_event: &str) -> Result<&'static str> {
322 let matrix = self.hook_compatibility();
323 let Some(entry) = matrix.find_event(raw_event) else {
324 return Err(TuffError::new(format!(
325 "{} does not support hook event '{}'. Supported events: {}",
326 self.display_name(),
327 raw_event,
328 matrix.supported_native_events().join(", ")
329 )));
330 };
331 entry.native_event_name().ok_or_else(|| {
332 let suffix = entry
333 .caveat
334 .map(|caveat| format!(": {caveat}"))
335 .unwrap_or_default();
336 TuffError::new(format!(
337 "{} does not support hook event '{}'{}",
338 self.display_name(),
339 raw_event,
340 suffix
341 ))
342 })
343 }
344
345 fn canonical_hook_event(&self, raw_event: &str) -> Result<&'static str> {
346 let matrix = self.hook_compatibility();
347 let Some(entry) = matrix.find_event(raw_event) else {
348 return Err(TuffError::new(format!(
349 "{} does not support hook event '{}'",
350 self.display_name(),
351 raw_event
352 )));
353 };
354 entry
355 .coverage
356 .is_supported()
357 .then_some(entry.event.as_str())
358 .ok_or_else(|| {
359 let suffix = entry
360 .caveat
361 .map(|caveat| format!(": {caveat}"))
362 .unwrap_or_default();
363 TuffError::new(format!(
364 "{} does not support hook event '{}'{}",
365 self.display_name(),
366 raw_event,
367 suffix
368 ))
369 })
370 }
371
372 fn ensure_project_dir(&self, repo_root: &Path) -> std::io::Result<()> {
373 std::fs::create_dir_all(repo_root.join(self.dir_prefix()))
374 }
375
376 fn plan(&self, capability: &ResolvedCapability, repo_root: &Path) -> Result<Vec<PlannedFile>> {
377 match capability.capability_type {
378 CapabilityType::Tool => self.plan_tool(capability, repo_root),
379 CapabilityType::Hook => self.plan_hook(capability, repo_root),
380 CapabilityType::Workflow => self.plan_workflow(capability, repo_root),
381 CapabilityType::Policy => Err(TuffError::new(
382 "policy capabilities are not installable yet",
383 )),
384 _ => self.plan_skill(capability, repo_root),
385 }
386 }
387
388 fn remove(
389 &self,
390 primitive_id: &str,
391 repo_root: &Path,
392 managed_hooks: &[crate::lockfile::ManagedHook],
393 ) -> Result<()> {
394 let prefix = self.dir_prefix();
395 for kind in &["skills", "tools", "hooks", "workflows"] {
396 self.remove_dir(repo_root, prefix, kind, primitive_id)?;
397 }
398 crate::mcp::remove_tool(&repo_root.join(self.mcp_config_relpath()), primitive_id)?;
399 self.remove_hook_settings(repo_root, managed_hooks)?;
400 Ok(())
401 }
402
403 fn plan_skill(
406 &self,
407 capability: &ResolvedCapability,
408 repo_root: &Path,
409 ) -> Result<Vec<PlannedFile>> {
410 if capability.source_files.is_empty() {
411 return Err(TuffError::new("no source files to emit"));
412 }
413
414 let mut files = Vec::new();
415 for (rel_path, content) in &capability.source_files {
416 let target_path = repo_root
417 .join(self.dir_prefix())
418 .join("skills")
419 .join(&capability.id)
420 .join(rel_path);
421
422 files.push(PlannedFile::new(
423 relative_or_absolute_fs(&target_path, repo_root),
424 content.clone(),
425 ));
426 }
427 Ok(files)
428 }
429
430 fn plan_tool(
431 &self,
432 capability: &ResolvedCapability,
433 repo_root: &Path,
434 ) -> Result<Vec<PlannedFile>> {
435 let mut files = Vec::new();
436
437 for (rel_path, content) in &capability.source_files {
438 let target_path = repo_root
439 .join(self.dir_prefix())
440 .join("tools")
441 .join(&capability.id)
442 .join(rel_path);
443
444 files.push(PlannedFile::new(
445 relative_or_absolute_fs(&target_path, repo_root),
446 content.clone(),
447 ));
448 }
449
450 if capability.source_files.is_empty() {
451 let placeholder = repo_root
452 .join(self.dir_prefix())
453 .join("tools")
454 .join(&capability.id)
455 .join(".gitkeep");
456 files.push(PlannedFile::new(
457 relative_or_absolute_fs(&placeholder, repo_root),
458 vec![],
459 ));
460 }
461
462 Ok(files)
463 }
464
465 fn plan_hook(
466 &self,
467 capability: &ResolvedCapability,
468 repo_root: &Path,
469 ) -> Result<Vec<PlannedFile>> {
470 let CapabilityKind::Hook { hook } = &capability.kind else {
471 return Err(TuffError::new("plan_hook called on non-hook capability"));
472 };
473
474 match hook {
475 HookDefinition::Command(hook_cfg) => {
476 let render = self.render_standard_hook(HookRenderContext {
477 capability_id: &capability.id,
478 hook: hook_cfg,
479 source_files: &capability.source_files,
480 repo_root,
481 track_managed_hooks: false,
482 })?;
483 Ok(render.files)
484 }
485 HookDefinition::Native(native) => self.plan_native_hook(capability, native, repo_root),
486 }
487 }
488
489 fn plan_native_hook(
490 &self,
491 capability: &ResolvedCapability,
492 native: &NativeHookConfig,
493 repo_root: &Path,
494 ) -> Result<Vec<PlannedFile>> {
495 let hook_root = repo_root
496 .join(self.dir_prefix())
497 .join("hooks")
498 .join(&capability.id);
499 let hook_root_rel = relative_or_absolute_fs(&hook_root, repo_root);
500 let in_harness_source =
501 path_is_under(&capability.source_dir, &repo_root.join(self.dir_prefix()));
502
503 let mut files = Vec::new();
504 if in_harness_source {
505 for (rel_path, content) in &native.source_files {
506 let target_path = capability.source_dir.join(rel_path);
507 files.push(PlannedFile::mergeable(
508 relative_or_absolute_fs(&target_path, repo_root),
509 content.clone(),
510 ));
511 }
512 } else {
513 for (rel_path, content) in &native.source_files {
514 let target_path = hook_root.join(rel_path);
515 files.push(PlannedFile::new(
516 relative_or_absolute_fs(&target_path, repo_root),
517 content.clone(),
518 ));
519 }
520 }
521
522 let fragment = replace_hook_dir_placeholder(native.fragment.clone(), &hook_root_rel);
523 let settings_relpath = self.hook_settings_relpath();
524 let settings_path = repo_root.join(settings_relpath);
525 let existing = if settings_path.is_file() {
526 Some(std::fs::read(&settings_path)?)
527 } else {
528 None
529 };
530 let merged = self.merge_hook_fragment(existing.as_deref(), &fragment)?;
531 files.push(PlannedFile::mergeable(
532 relative_or_absolute_fs(&settings_path, repo_root),
533 merged,
534 ));
535 Ok(files)
536 }
537
538 fn plan_workflow(
539 &self,
540 capability: &ResolvedCapability,
541 repo_root: &Path,
542 ) -> Result<Vec<PlannedFile>> {
543 let CapabilityKind::Workflow { workflow: wf } = &capability.kind else {
544 return Err(TuffError::new(
545 "plan_workflow called on non-workflow capability",
546 ));
547 };
548
549 let target_path = repo_root
550 .join(self.dir_prefix())
551 .join("workflows")
552 .join(&capability.id)
553 .join("workflow.toml");
554
555 let content = serialize_workflow(capability, wf)?;
556
557 Ok(vec![PlannedFile::new(
558 relative_or_absolute_fs(&target_path, repo_root),
559 content,
560 )])
561 }
562
563 fn remove_dir(
564 &self,
565 repo_root: &Path,
566 base: &str,
567 kind: &str,
568 primitive_id: &str,
569 ) -> Result<()> {
570 let dir = repo_root.join(base).join(kind).join(primitive_id);
571
572 if dir.exists() {
573 std::fs::remove_dir_all(&dir)?;
574 }
575
576 let kind_dir = dir.parent().expect("kind dir should have parent");
577 if kind_dir.exists() {
578 let mut rd = match std::fs::read_dir(kind_dir) {
579 Ok(rd) => rd,
580 Err(_) => return Ok(()),
581 };
582 if rd.next().is_none() {
583 std::fs::remove_dir(kind_dir)?;
584 }
585 }
586
587 let base_dir = kind_dir.parent().expect("base dir should have parent");
588 if base_dir.exists() {
589 let mut rd = match std::fs::read_dir(base_dir) {
590 Ok(rd) => rd,
591 Err(_) => return Ok(()),
592 };
593 if rd.next().is_none() {
594 std::fs::remove_dir(base_dir)?;
595 }
596 }
597
598 Ok(())
599 }
600}
601
602fn render_hook_script(hook_cfg: &HookConfig) -> Result<Vec<u8>> {
603 let working_directory = shell_single_quote(&hook_cfg.working_directory)?;
604 let command = shell_single_quote(&hook_cfg.command)?;
605 Ok(format!(
606 "#!/usr/bin/env bash\nset -euo pipefail\ncd -- {working_directory}\nexec bash -euo pipefail -c {command}\n"
607 )
608 .into_bytes())
609}
610
611fn shell_single_quote(value: &str) -> Result<String> {
612 if value.contains('\0') {
613 return Err(TuffError::new(
614 "hook working directory and command cannot contain NUL bytes",
615 ));
616 }
617 Ok(format!("'{}'", value.replace('\'', "'\"'\"'")))
618}
619
620fn serialize_workflow(
621 capability: &ResolvedCapability,
622 workflow: &crate::manifest::WorkflowConfig,
623) -> Result<Vec<u8>> {
624 let document = WorkflowDocument {
625 id: &capability.id,
626 version: &capability.version,
627 capability_type: capability.capability_type,
628 description: &capability.description,
629 workflow,
630 };
631 let mut content = toml::to_string_pretty(&document)?;
632 if !content.ends_with('\n') {
633 content.push('\n');
634 }
635 Ok(content.into_bytes())
636}
637
638fn path_is_under(path: &Path, root: &Path) -> bool {
639 let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
640 let canonical_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
641 canonical_path.starts_with(canonical_root)
642}
643
644pub fn replace_hook_dir_placeholder(
645 mut value: serde_json::Value,
646 hook_dir: &str,
647) -> serde_json::Value {
648 match &mut value {
649 serde_json::Value::String(s) => {
650 *s = s.replace("{{hook_dir}}", hook_dir);
651 }
652 serde_json::Value::Array(items) => {
653 for item in items {
654 *item = replace_hook_dir_placeholder(item.take(), hook_dir);
655 }
656 }
657 serde_json::Value::Object(map) => {
658 for item in map.values_mut() {
659 *item = replace_hook_dir_placeholder(item.take(), hook_dir);
660 }
661 }
662 _ => {}
663 }
664 value
665}
666
667fn relative_or_absolute_fs(path: &Path, repo_root: &Path) -> String {
668 crate::lockfile::relative_or_absolute_fs(path, repo_root)
669}
670
671#[cfg(test)]
672mod tests {
673 use super::*;
674 use crate::manifest::{Requirement, WorkflowConfig};
675
676 #[cfg(unix)]
677 #[test]
678 fn hook_script_preserves_shell_sensitive_values() {
679 use std::process::Command;
680
681 let temp = tempfile::tempdir().expect("tempdir");
682 let working_directory = temp.path().join("directory with ' quote");
683 std::fs::create_dir(&working_directory).expect("create working directory");
684 let hook = HookConfig {
685 event: "stop".to_string(),
686 command: "printf '%s\\n' 'safe; $HOME `literal`' > result.txt".to_string(),
687 working_directory: working_directory.to_string_lossy().into_owned(),
688 };
689 let script_path = temp.path().join("run.sh");
690 std::fs::write(
691 &script_path,
692 render_hook_script(&hook).expect("render script"),
693 )
694 .expect("write script");
695
696 let syntax = Command::new("bash")
697 .arg("-n")
698 .arg(&script_path)
699 .status()
700 .expect("check script syntax");
701 assert!(syntax.success());
702 let executed = Command::new("bash")
703 .arg(&script_path)
704 .status()
705 .expect("execute script");
706 assert!(executed.success());
707 assert_eq!(
708 std::fs::read_to_string(working_directory.join("result.txt"))
709 .expect("read command output"),
710 "safe; $HOME `literal`\n"
711 );
712 }
713
714 #[test]
715 fn hook_script_rejects_nul_bytes() {
716 let hook = HookConfig {
717 event: "stop".to_string(),
718 command: "printf '\0'".to_string(),
719 working_directory: ".".to_string(),
720 };
721
722 assert!(render_hook_script(&hook).is_err());
723 }
724
725 #[test]
726 fn workflow_serialization_escapes_manifest_values() {
727 let workflow = WorkflowConfig {
728 requires: vec![Requirement {
729 id: "dependency\"\\name".to_string(),
730 capability_type: CapabilityType::Skill,
731 }],
732 };
733 let capability = ResolvedCapability {
734 id: "workflow\"id".to_string(),
735 capability_type: CapabilityType::Workflow,
736 version: "1.0.0".to_string(),
737 description: "first line\nsecond \"line\" \\ value".to_string(),
738 source_files: Vec::new(),
739 source_dir: PathBuf::new(),
740 kind: CapabilityKind::Workflow {
741 workflow: workflow.clone(),
742 },
743 };
744
745 let bytes = serialize_workflow(&capability, &workflow).expect("serialize workflow");
746 let parsed: toml::Value = toml::from_slice(&bytes).expect("parse emitted workflow");
747
748 assert_eq!(parsed["id"].as_str(), Some("workflow\"id"));
749 assert_eq!(
750 parsed["description"].as_str(),
751 Some("first line\nsecond \"line\" \\ value")
752 );
753 assert_eq!(
754 parsed["workflow"]["requires"][0]["id"].as_str(),
755 Some("dependency\"\\name")
756 );
757 }
758}