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#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct EmittedFile {
12 pub path: String,
13 pub hash: String,
14 #[serde(rename = "baselineHash")]
15 pub baseline_hash: String,
16}
17
18#[derive(Debug, Clone)]
19pub struct PlannedFile {
20 pub path: String,
21 pub content: Vec<u8>,
22 pub allow_existing: bool,
23}
24
25impl PlannedFile {
26 pub fn new(path: String, content: Vec<u8>) -> Self {
27 Self {
28 path,
29 content,
30 allow_existing: false,
31 }
32 }
33
34 pub fn mergeable(path: String, content: Vec<u8>) -> Self {
35 Self {
36 path,
37 content,
38 allow_existing: true,
39 }
40 }
41}
42
43#[derive(Debug, Clone)]
44pub struct NativeHookConfig {
45 pub fragment: serde_json::Value,
46 pub source_files: Vec<(String, Vec<u8>)>,
47}
48
49#[derive(Debug, Clone)]
50pub enum HookRenderDiagnosticLevel {
51 Warning,
52}
53
54#[derive(Debug, Clone)]
55pub struct HookRenderDiagnostic {
56 pub level: HookRenderDiagnosticLevel,
57 pub message: String,
58}
59
60#[derive(Debug, Clone)]
61pub struct HookRenderContext<'a> {
62 pub capability_id: &'a str,
63 pub hook: &'a HookConfig,
64 pub source_files: &'a [(String, Vec<u8>)],
65 pub repo_root: &'a Path,
66 pub track_managed_hooks: bool,
67}
68
69#[derive(Debug, Clone)]
70pub struct HookRenderPlan {
71 pub files: Vec<PlannedFile>,
72 pub managed_hooks: Vec<crate::lockfile::ManagedHook>,
73 pub diagnostics: Vec<HookRenderDiagnostic>,
74}
75
76#[derive(Debug, Clone)]
77pub enum HookDefinition {
78 Command(crate::manifest::HookConfig),
79 Native(NativeHookConfig),
80}
81
82#[derive(Debug, Clone)]
83pub enum CapabilityKind {
84 Skill,
85 Tool {
86 parameters: serde_json::Value,
87 implementation: crate::manifest::ImplementationConfig,
88 },
89 Hook {
90 hook: HookDefinition,
91 },
92 Workflow {
93 workflow: crate::manifest::WorkflowConfig,
94 },
95}
96
97impl CapabilityKind {
98 pub fn capability_type(&self) -> CapabilityType {
99 match self {
100 Self::Skill => CapabilityType::Skill,
101 Self::Tool { .. } => CapabilityType::Tool,
102 Self::Hook { .. } => CapabilityType::Hook,
103 Self::Workflow { .. } => CapabilityType::Workflow,
104 }
105 }
106}
107
108pub struct ResolvedCapability {
109 pub id: String,
110 pub capability_type: CapabilityType,
111 pub version: String,
112 pub description: String,
113 pub source_files: Vec<(String, Vec<u8>)>,
114 pub source_dir: PathBuf,
115 pub kind: CapabilityKind,
116}
117
118pub fn resolve_capability(manifest: &CapabilityManifest) -> Result<ResolvedCapability> {
119 let source_files = manifest.read_source_contents_with_names()?;
120 let kind =
121 match manifest.capability_type {
122 CapabilityType::Skill => CapabilityKind::Skill,
123 CapabilityType::Tool => CapabilityKind::Tool {
124 parameters: manifest.parameters.clone().ok_or_else(|| {
125 TuffError::new("tool capability requires [parameters] section")
126 })?,
127 implementation: manifest.implementation.clone().ok_or_else(|| {
128 TuffError::new("tool capability requires [implementation] section")
129 })?,
130 },
131 CapabilityType::Hook => {
132 CapabilityKind::Hook {
133 hook: HookDefinition::Command(manifest.hook.clone().ok_or_else(|| {
134 TuffError::new("hook capability requires [hook] section")
135 })?),
136 }
137 }
138 CapabilityType::Workflow => CapabilityKind::Workflow {
139 workflow: manifest.workflow.clone().ok_or_else(|| {
140 TuffError::new("workflow capability requires [workflow] section")
141 })?,
142 },
143 CapabilityType::Policy => {
144 return Err(TuffError::new(
145 "policy capabilities are not installable yet",
146 ));
147 }
148 };
149 Ok(ResolvedCapability {
150 id: manifest.id.clone(),
151 capability_type: manifest.capability_type,
152 version: manifest.version.clone(),
153 description: manifest.description.clone(),
154 source_files,
155 source_dir: manifest.root.clone(),
156 kind,
157 })
158}
159
160pub trait AgentAdapter {
161 fn id(&self) -> &'static str;
162 fn display_name(&self) -> &'static str;
163 fn dir_prefix(&self) -> &'static str;
164 fn mcp_config_relpath(&self) -> &'static str;
165 fn supported_agents(&self) -> &[&'static str];
166 fn hook_compatibility(&self) -> &'static CompatibilityMatrix;
167 fn hook_settings_relpath(&self) -> &'static str;
168 fn scaffold_hook_event(&self) -> &'static str;
169 fn hook_filename(&self) -> &'static str;
170 fn hook_file_content(&self, hook_cfg: &crate::manifest::HookConfig) -> Result<Vec<u8>>;
171 fn render_standard_hook(&self, context: HookRenderContext<'_>) -> Result<HookRenderPlan> {
172 let matrix = self.hook_compatibility();
173 let Some(entry) = matrix.find_event(&context.hook.event) else {
174 return Err(TuffError::new(format!(
175 "{} does not support hook event '{}'. Supported events: {}",
176 self.display_name(),
177 context.hook.event,
178 matrix.supported_native_events().join(", ")
179 )));
180 };
181 let Some(native_event) = entry.native_event_name() else {
182 let suffix = entry
183 .caveat
184 .map(|caveat| format!(": {caveat}"))
185 .unwrap_or_default();
186 return Err(TuffError::new(format!(
187 "{} does not support hook event '{}'{}",
188 self.display_name(),
189 context.hook.event,
190 suffix
191 )));
192 };
193
194 let command = format!(
195 "sh {}/hooks/{}/{}",
196 self.dir_prefix(),
197 context.capability_id,
198 self.hook_filename()
199 );
200 let target_path = context
201 .repo_root
202 .join(self.dir_prefix())
203 .join("hooks")
204 .join(context.capability_id)
205 .join(self.hook_filename());
206 let script = self.hook_file_content(context.hook)?;
207 let settings_relpath = self.hook_settings_relpath();
208 let fragment = self.command_hook_fragment(native_event, &command);
209 let settings_path = context.repo_root.join(settings_relpath);
210 let existing = if settings_path.is_file() {
211 Some(std::fs::read(&settings_path)?)
212 } else {
213 None
214 };
215 let merged = self.merge_hook_fragment(existing.as_deref(), &fragment)?;
216
217 let mut files = vec![PlannedFile::new(
218 relative_or_absolute_fs(&target_path, context.repo_root),
219 script,
220 )];
221 for (relative, content) in context.source_files {
222 let path = context
223 .repo_root
224 .join(self.dir_prefix())
225 .join("hooks")
226 .join(context.capability_id)
227 .join(relative);
228 files.push(PlannedFile::new(
229 relative_or_absolute_fs(&path, context.repo_root),
230 content.clone(),
231 ));
232 }
233 files.push(PlannedFile::mergeable(
234 relative_or_absolute_fs(&settings_path, context.repo_root),
235 merged,
236 ));
237
238 let mut diagnostics = Vec::new();
239 if entry.coverage == CoverageLevel::Partial {
240 let scope = if entry.scope.is_empty() {
241 "partial coverage".to_string()
242 } else {
243 format!("scope: {}", entry.scope.join(", "))
244 };
245 let caveat = entry
246 .caveat
247 .map(|caveat| format!("; {caveat}"))
248 .unwrap_or_default();
249 diagnostics.push(HookRenderDiagnostic {
250 level: HookRenderDiagnosticLevel::Warning,
251 message: format!(
252 "{} renders '{}' with partial compatibility ({scope}{caveat})",
253 self.display_name(),
254 entry.event
255 ),
256 });
257 }
258
259 let managed_hooks = if context.track_managed_hooks {
260 crate::lockfile::managed_hooks_from_fragment_with_canonical(
261 context.repo_root,
262 settings_relpath,
263 &fragment,
264 Some(entry.event.as_str()),
265 )?
266 } else {
267 Vec::new()
268 };
269
270 Ok(HookRenderPlan {
271 files,
272 managed_hooks,
273 diagnostics,
274 })
275 }
276 fn command_hook_fragment(&self, native_event: &str, command: &str) -> serde_json::Value;
277 fn merge_hook_fragment(
278 &self,
279 existing: Option<&[u8]>,
280 fragment: &serde_json::Value,
281 ) -> Result<Vec<u8>>;
282 fn remove_hook_settings(
283 &self,
284 repo_root: &Path,
285 managed_hooks: &[crate::lockfile::ManagedHook],
286 ) -> Result<()>;
287 fn detect(&self, repo_root: &Path) -> bool;
288
289 fn kinds_supported(&self) -> &[CapabilityType];
290
291 fn supports(&self, capability_type: CapabilityType) -> bool {
292 self.kinds_supported().contains(&capability_type)
293 }
294
295 fn native_hook_event(&self, raw_event: &str) -> Result<&'static str> {
296 let matrix = self.hook_compatibility();
297 let Some(entry) = matrix.find_event(raw_event) else {
298 return Err(TuffError::new(format!(
299 "{} does not support hook event '{}'. Supported events: {}",
300 self.display_name(),
301 raw_event,
302 matrix.supported_native_events().join(", ")
303 )));
304 };
305 entry.native_event_name().ok_or_else(|| {
306 let suffix = entry
307 .caveat
308 .map(|caveat| format!(": {caveat}"))
309 .unwrap_or_default();
310 TuffError::new(format!(
311 "{} does not support hook event '{}'{}",
312 self.display_name(),
313 raw_event,
314 suffix
315 ))
316 })
317 }
318
319 fn canonical_hook_event(&self, raw_event: &str) -> Result<&'static str> {
320 let matrix = self.hook_compatibility();
321 let Some(entry) = matrix.find_event(raw_event) else {
322 return Err(TuffError::new(format!(
323 "{} does not support hook event '{}'",
324 self.display_name(),
325 raw_event
326 )));
327 };
328 entry
329 .coverage
330 .is_supported()
331 .then_some(entry.event.as_str())
332 .ok_or_else(|| {
333 let suffix = entry
334 .caveat
335 .map(|caveat| format!(": {caveat}"))
336 .unwrap_or_default();
337 TuffError::new(format!(
338 "{} does not support hook event '{}'{}",
339 self.display_name(),
340 raw_event,
341 suffix
342 ))
343 })
344 }
345
346 fn ensure_project_dir(&self, repo_root: &Path) -> std::io::Result<()> {
347 std::fs::create_dir_all(repo_root.join(self.dir_prefix()))
348 }
349
350 fn plan(&self, capability: &ResolvedCapability, repo_root: &Path) -> Result<Vec<PlannedFile>> {
351 match capability.capability_type {
352 CapabilityType::Tool => self.plan_tool(capability, repo_root),
353 CapabilityType::Hook => self.plan_hook(capability, repo_root),
354 CapabilityType::Workflow => self.plan_workflow(capability, repo_root),
355 CapabilityType::Policy => Err(TuffError::new(
356 "policy capabilities are not installable yet",
357 )),
358 _ => self.plan_skill(capability, repo_root),
359 }
360 }
361
362 fn remove(
363 &self,
364 primitive_id: &str,
365 repo_root: &Path,
366 managed_hooks: &[crate::lockfile::ManagedHook],
367 ) -> Result<()> {
368 let prefix = self.dir_prefix();
369 for kind in &["skills", "tools", "hooks", "workflows"] {
370 self.remove_dir(repo_root, prefix, kind, primitive_id)?;
371 }
372 crate::mcp::remove_tool(&repo_root.join(self.mcp_config_relpath()), primitive_id)?;
373 self.remove_hook_settings(repo_root, managed_hooks)?;
374 Ok(())
375 }
376
377 fn plan_skill(
380 &self,
381 capability: &ResolvedCapability,
382 repo_root: &Path,
383 ) -> Result<Vec<PlannedFile>> {
384 if capability.source_files.is_empty() {
385 return Err(TuffError::new("no source files to emit"));
386 }
387
388 let mut files = Vec::new();
389 for (rel_path, content) in &capability.source_files {
390 let target_path = repo_root
391 .join(self.dir_prefix())
392 .join("skills")
393 .join(&capability.id)
394 .join(rel_path);
395
396 files.push(PlannedFile::new(
397 relative_or_absolute_fs(&target_path, repo_root),
398 content.clone(),
399 ));
400 }
401 Ok(files)
402 }
403
404 fn plan_tool(
405 &self,
406 capability: &ResolvedCapability,
407 repo_root: &Path,
408 ) -> Result<Vec<PlannedFile>> {
409 let mut files = Vec::new();
410
411 for (rel_path, content) in &capability.source_files {
412 let target_path = repo_root
413 .join(self.dir_prefix())
414 .join("tools")
415 .join(&capability.id)
416 .join(rel_path);
417
418 files.push(PlannedFile::new(
419 relative_or_absolute_fs(&target_path, repo_root),
420 content.clone(),
421 ));
422 }
423
424 if capability.source_files.is_empty() {
425 let placeholder = repo_root
426 .join(self.dir_prefix())
427 .join("tools")
428 .join(&capability.id)
429 .join(".gitkeep");
430 files.push(PlannedFile::new(
431 relative_or_absolute_fs(&placeholder, repo_root),
432 vec![],
433 ));
434 }
435
436 Ok(files)
437 }
438
439 fn plan_hook(
440 &self,
441 capability: &ResolvedCapability,
442 repo_root: &Path,
443 ) -> Result<Vec<PlannedFile>> {
444 let CapabilityKind::Hook { hook } = &capability.kind else {
445 return Err(TuffError::new("plan_hook called on non-hook capability"));
446 };
447
448 match hook {
449 HookDefinition::Command(hook_cfg) => {
450 let render = self.render_standard_hook(HookRenderContext {
451 capability_id: &capability.id,
452 hook: hook_cfg,
453 source_files: &capability.source_files,
454 repo_root,
455 track_managed_hooks: false,
456 })?;
457 Ok(render.files)
458 }
459 HookDefinition::Native(native) => self.plan_native_hook(capability, native, repo_root),
460 }
461 }
462
463 fn plan_native_hook(
464 &self,
465 capability: &ResolvedCapability,
466 native: &NativeHookConfig,
467 repo_root: &Path,
468 ) -> Result<Vec<PlannedFile>> {
469 let hook_root = repo_root
470 .join(self.dir_prefix())
471 .join("hooks")
472 .join(&capability.id);
473 let hook_root_rel = relative_or_absolute_fs(&hook_root, repo_root);
474 let in_harness_source =
475 path_is_under(&capability.source_dir, &repo_root.join(self.dir_prefix()));
476
477 let mut files = Vec::new();
478 if in_harness_source {
479 for (rel_path, content) in &native.source_files {
480 let target_path = capability.source_dir.join(rel_path);
481 files.push(PlannedFile::mergeable(
482 relative_or_absolute_fs(&target_path, repo_root),
483 content.clone(),
484 ));
485 }
486 } else {
487 for (rel_path, content) in &native.source_files {
488 let target_path = hook_root.join(rel_path);
489 files.push(PlannedFile::new(
490 relative_or_absolute_fs(&target_path, repo_root),
491 content.clone(),
492 ));
493 }
494 }
495
496 let fragment = replace_hook_dir_placeholder(native.fragment.clone(), &hook_root_rel);
497 let settings_relpath = self.hook_settings_relpath();
498 let settings_path = repo_root.join(settings_relpath);
499 let existing = if settings_path.is_file() {
500 Some(std::fs::read(&settings_path)?)
501 } else {
502 None
503 };
504 let merged = self.merge_hook_fragment(existing.as_deref(), &fragment)?;
505 files.push(PlannedFile::mergeable(
506 relative_or_absolute_fs(&settings_path, repo_root),
507 merged,
508 ));
509 Ok(files)
510 }
511
512 fn plan_workflow(
513 &self,
514 capability: &ResolvedCapability,
515 repo_root: &Path,
516 ) -> Result<Vec<PlannedFile>> {
517 let CapabilityKind::Workflow { workflow: wf } = &capability.kind else {
518 return Err(TuffError::new(
519 "plan_workflow called on non-workflow capability",
520 ));
521 };
522
523 let target_path = repo_root
524 .join(self.dir_prefix())
525 .join("workflows")
526 .join(&capability.id)
527 .join("workflow.toml");
528
529 let mut content = format!(
530 "id = \"{}\"\nversion = \"{}\"\ntype = \"workflow\"\ndescription = \"{}\"\n",
531 capability.id, capability.version, capability.description
532 );
533 for req in &wf.requires {
534 content.push_str(&format!(
535 "[[workflow.requires]]\nid = \"{}\"\ntype = \"{}\"\n",
536 req.id, req.capability_type
537 ));
538 }
539
540 Ok(vec![PlannedFile::new(
541 relative_or_absolute_fs(&target_path, repo_root),
542 content.into_bytes(),
543 )])
544 }
545
546 fn remove_dir(
547 &self,
548 repo_root: &Path,
549 base: &str,
550 kind: &str,
551 primitive_id: &str,
552 ) -> Result<()> {
553 let dir = repo_root.join(base).join(kind).join(primitive_id);
554
555 if dir.exists() {
556 std::fs::remove_dir_all(&dir)?;
557 }
558
559 let kind_dir = dir.parent().expect("kind dir should have parent");
560 if kind_dir.exists() {
561 let mut rd = match std::fs::read_dir(kind_dir) {
562 Ok(rd) => rd,
563 Err(_) => return Ok(()),
564 };
565 if rd.next().is_none() {
566 std::fs::remove_dir(kind_dir)?;
567 }
568 }
569
570 let base_dir = kind_dir.parent().expect("base dir should have parent");
571 if base_dir.exists() {
572 let mut rd = match std::fs::read_dir(base_dir) {
573 Ok(rd) => rd,
574 Err(_) => return Ok(()),
575 };
576 if rd.next().is_none() {
577 std::fs::remove_dir(base_dir)?;
578 }
579 }
580
581 Ok(())
582 }
583}
584
585fn path_is_under(path: &Path, root: &Path) -> bool {
586 let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
587 let canonical_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
588 canonical_path.starts_with(canonical_root)
589}
590
591pub fn replace_hook_dir_placeholder(
592 mut value: serde_json::Value,
593 hook_dir: &str,
594) -> serde_json::Value {
595 match &mut value {
596 serde_json::Value::String(s) => {
597 *s = s.replace("{{hook_dir}}", hook_dir);
598 }
599 serde_json::Value::Array(items) => {
600 for item in items {
601 *item = replace_hook_dir_placeholder(item.take(), hook_dir);
602 }
603 }
604 serde_json::Value::Object(map) => {
605 for item in map.values_mut() {
606 *item = replace_hook_dir_placeholder(item.take(), hook_dir);
607 }
608 }
609 _ => {}
610 }
611 value
612}
613
614fn relative_or_absolute_fs(path: &Path, repo_root: &Path) -> String {
615 crate::lockfile::relative_or_absolute_fs(path, repo_root)
616}