diff --git a/Cargo.lock b/Cargo.lock index 1118dea..379d37f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -78,6 +78,11 @@ dependencies = [ "subtle", ] +[[package]] +name = "agent-skills" +version = "0.1.0" +source = "git+https://github.com/datadog-labs/agent-skills?rev=6d3e516b7072bd1470563a5d10e941e2cad58ebe#6d3e516b7072bd1470563a5d10e941e2cad58ebe" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -2729,6 +2734,7 @@ name = "pup" version = "0.65.3" dependencies = [ "aes-gcm 0.10.3", + "agent-skills", "anyhow", "async-trait", "base64 0.22.1", diff --git a/Cargo.toml b/Cargo.toml index ed18a37..d1ab50f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,6 +68,12 @@ browser = [ ] [dependencies] +# Skills content from datadog-labs/agent-skills, consumed as a Cargo git dep. +# Pinned to a specific rev so updates are explicit and auditable, matching +# the same pattern used for datadog-api-client below. +# Requires agent-skills to carry a Cargo.toml + src/lib.rs (see companion PR). +agent-skills = { git = "https://github.com/datadog-labs/agent-skills", rev = "6d3e516b7072bd1470563a5d10e941e2cad58ebe" } + # CLI (optional — not needed for browser WASM library) clap = { version = "4", features = ["derive"], optional = true } clap_complete = { version = "4", optional = true } diff --git a/src/commands/skills.rs b/src/commands/skills.rs index c7b2b3b..2e3ba7c 100644 --- a/src/commands/skills.rs +++ b/src/commands/skills.rs @@ -293,6 +293,33 @@ mod tests { use super::*; use crate::config::Config; use crate::test_support::TempDir; + use agent_skills; + + fn count_files_recursive(dir: &std::path::Path) -> usize { + std::fs::read_dir(dir) + .unwrap_or_else(|e| { + panic!( + "count_files_recursive: failed to read '{}': {e}", + dir.display() + ) + }) + .map(|e| { + let p = e + .unwrap_or_else(|err| { + panic!( + "count_files_recursive: entry error in '{}': {err}", + dir.display() + ) + }) + .path(); + if p.is_dir() { + count_files_recursive(&p) + } else { + 1 + } + }) + .sum() + } fn base_cfg() -> Config { Config { @@ -463,19 +490,30 @@ mod tests { "dd-pup-pi extension should be installed when `all` is selected" ); assert!(tmp.path().join("dd-pup-pi/package.json").exists()); - // With path dedup, --dir writes each unique destination exactly once: - // dd-apm/SKILL.md + dd-pup-pi/index.ts + dd-pup-pi/package.json + dd-pup-pi/README.md - let file_count = std::fs::read_dir(tmp.path()) - .unwrap() - .flat_map(|d| { - std::fs::read_dir(d.unwrap().path()) - .unwrap() - .map(|f| f.unwrap().path()) - }) - .count(); - assert_eq!( - file_count, 4, - "expected 4 unique files (1 skill + 3 extension)" + assert!(tmp.path().join("dd-pup-pi/README.md").exists()); + assert!( + tmp.path() + .join("dd-apm/k8s-ssi/agent-install/SKILL.md") + .exists(), + "k8s-ssi sub-skill nested path must be preserved" + ); + assert!( + tmp.path() + .join("dd-apm/linux-ssi/agent-install/SKILL.md") + .exists(), + "linux-ssi sub-skill nested path must be preserved" + ); + // Guard against silent mass-install failure: floor is derived from the + // crate's own constants so it grows automatically as sub-skills are added. + let ext_files = 3usize; // dd-pup-pi: index.ts + package.json + README.md + let apm_min = 1 + agent_skills::DD_APM_SUB_SKILLS.len(); // root + sub-skills + let file_count = count_files_recursive(tmp.path()); + assert!( + file_count >= ext_files + apm_min, + "expected at least {exp} files (1+{subs} apm + {ext} ext), got {file_count}", + exp = ext_files + apm_min, + subs = agent_skills::DD_APM_SUB_SKILLS.len(), + ext = ext_files, ); } diff --git a/src/skills.rs b/src/skills.rs index a3049e7..81aab99 100644 --- a/src/skills.rs +++ b/src/skills.rs @@ -12,9 +12,14 @@ pub struct SkillEntry { /// Platform slug for entry_type == "extension". One of: "pi". /// Empty for skills and agents. pub platform: &'static str, - /// Files to materialize for entry_type == "extension". - /// Each tuple is `(relative_path_within_extension_dir, file_contents)`. - /// Empty for skills and agents. + /// Extra files to materialize alongside the entry. + /// Each tuple is `(relative_path, file_contents)`, written verbatim. + /// - For `extension`: relative to the extension's install dir; this is + /// the only source of files (`content` is empty). + /// - For `skill`: relative to the parent skill's install dir, used to + /// ship nested sub-skill SKILL.md files (e.g. `dd-apm` ships + /// `service-remapping/SKILL.md` and the k8s-ssi/linux-ssi trees). + /// - For `agent`: unused; leave empty. pub files: &'static [(&'static str, &'static str)], } @@ -64,9 +69,9 @@ pub static SKILLS: &[SkillEntry] = &[ name: "dd-apm", description: "APM - traces, services, dependencies, performance analysis.", entry_type: "skill", - content: include_str!("../skills/dd-apm/SKILL.md"), + content: agent_skills::DD_APM_SKILL, platform: "", - files: &[], + files: agent_skills::DD_APM_SUB_SKILLS, }, SkillEntry { name: "dd-debugger", @@ -928,7 +933,19 @@ pub fn install_paths( else { return Ok(vec![]); }; - Ok(vec![(path, format_content(entry, &fmt))]) + let mut out = vec![(path.clone(), format_content(entry, &fmt))]; + // Skills can ship nested sub-skill files alongside the root SKILL.md + // (e.g. dd-apm's k8s-ssi/, linux-ssi/, service-remapping/ trees). + // Only applies when the parent installs as a skill directory; subagent + // .md files have no surrounding directory to nest under. + if fmt == InstallFormat::SkillMd && !entry.files.is_empty() { + if let Some(parent_dir) = path.parent() { + for (rel, body) in entry.files { + out.push((parent_dir.join(rel), (*body).to_string())); + } + } + } + Ok(out) } #[derive(Debug, PartialEq)] @@ -1501,6 +1518,48 @@ mod tests { assert_eq!(extensions_dir("claude-code", &root, false), None); } + #[test] + fn test_install_paths_skill_with_sub_skills() { + static SUB_FILES: &[(&str, &str)] = &[ + ("service-remapping/SKILL.md", "# Service Remapping"), + ("k8s-ssi/agent-install/SKILL.md", "# K8s Agent Install"), + ]; + let root = PathBuf::from("/tmp/proj"); + let e = SkillEntry { + files: SUB_FILES, + ..entry("dd-apm", "skill", "body") + }; + let paths = install_paths(&e, "claude-code", &root, None, false).unwrap(); + assert_eq!(paths.len(), 3); + assert_eq!(paths[0].0, root.join(".claude/skills/dd-apm/SKILL.md")); + assert_eq!( + paths[1].0, + root.join(".claude/skills/dd-apm/service-remapping/SKILL.md") + ); + assert_eq!( + paths[2].0, + root.join(".claude/skills/dd-apm/k8s-ssi/agent-install/SKILL.md") + ); + } + + #[test] + fn test_install_paths_sub_skills_skipped_for_agent_md() { + // AgentMd format (Claude Code agents dir) has no surrounding directory, + // so sub-skill files must not be written. + static SUB_FILES: &[(&str, &str)] = &[("sub/SKILL.md", "# Sub")]; + let root = PathBuf::from("/tmp/proj"); + let e = SkillEntry { + files: SUB_FILES, + ..entry("dd-apm", "agent", "body") + }; + let paths = install_paths(&e, "claude-code", &root, None, false).unwrap(); + assert_eq!( + paths.len(), + 1, + "sub-skills must not be written for agent-md format" + ); + } + #[test] fn test_install_paths_skill_single_file() { let root = PathBuf::from("/tmp/proj");