From 48f63dc45624282573565449c75dc7d9b2639eb6 Mon Sep 17 00:00:00 2001 From: rossirpaulo Date: Fri, 12 Jun 2026 01:56:36 -0700 Subject: [PATCH 1/2] Make agent skill installs reproducible --- .github/workflows/release-baml-language.yml | 84 +++- .../crates/baml_cli/src/agent_command.rs | 450 +++++++++++++----- scripts/package-agent-skills | 150 ++++++ 3 files changed, 545 insertions(+), 139 deletions(-) create mode 100755 scripts/package-agent-skills diff --git a/.github/workflows/release-baml-language.yml b/.github/workflows/release-baml-language.yml index a6433837f04..e4084bc5c12 100644 --- a/.github/workflows/release-baml-language.yml +++ b/.github/workflows/release-baml-language.yml @@ -42,6 +42,7 @@ env: AWS_REGION: us-east-1 AWS_ROLE_ARN: arn:aws:iam::277707123528:role/pkg-boundaryml-com-github-release PKG_BUCKET: pkgboundarymlcomstack-pkgboundarymlcomsitebucket4f-ybhvygkqittp + BAML_AGENT_SKILLS_REPOSITORY: BoundaryML/baml-skill jobs: plan: @@ -284,6 +285,38 @@ jobs: name: baml-vscode-vsix path: typescript2/app-vscode-ext/*.vsix + build-agent-skills: + name: Build agent skills + needs: [plan] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.plan.outputs.source_sha }} + persist-credentials: false + - uses: actions/checkout@v4 + with: + repository: ${{ env.BAML_AGENT_SKILLS_REPOSITORY }} + path: agent-skills-source + persist-credentials: false + - name: Package agent skills + env: + VERSION: ${{ needs.plan.outputs.version }} + run: | + set -euo pipefail + scripts/package-agent-skills \ + --source agent-skills-source \ + --version "$VERSION" \ + --out-dir dist/agent-skills + + archive="dist/agent-skills/baml-agent-skills-$VERSION.tar.gz" + tar -tzf "$archive" | tee agent-skills-list.txt + grep -E '/SKILL\.md$' agent-skills-list.txt + - uses: actions/upload-artifact@v4 + with: + name: agent-skills + path: dist/agent-skills/* + build-wrapper: name: Build wrapper (${{ matrix.target }}) needs: [plan] @@ -441,9 +474,9 @@ jobs: source_sha: ${{ needs.plan.outputs.source_sha }} channel: ${{ needs.plan.outputs.channel }} - publish-github-release: - name: Publish GitHub release assets - needs: [plan, all-builds] + publish-toolchain-release: + name: Publish toolchain release assets + needs: [plan, all-builds, build-agent-skills] if: ${{ needs.plan.outputs.dry_run == 'false' && needs.plan.outputs.source_branch == 'canary' }} runs-on: ubuntu-latest env: @@ -456,15 +489,14 @@ jobs: pattern: toolchain-* path: dist/toolchain merge-multiple: true - - uses: actions/download-artifact@v4 - with: - pattern: wrapper-* - path: dist/wrapper - merge-multiple: true - uses: actions/download-artifact@v4 with: name: baml-vscode-vsix path: dist/vsix + - uses: actions/download-artifact@v4 + with: + name: agent-skills + path: dist/agent-skills - name: Generate checksum sidecars run: | set -euo pipefail @@ -480,14 +512,36 @@ jobs: run: | set -euo pipefail if gh release view "$TAG" >/dev/null 2>&1; then - gh release upload "$TAG" dist/toolchain/* dist/vsix/* --clobber + gh release upload "$TAG" dist/toolchain/* dist/vsix/* dist/agent-skills/* --clobber else - gh release create "$TAG" dist/toolchain/* dist/vsix/* \ + gh release create "$TAG" dist/toolchain/* dist/vsix/* dist/agent-skills/* \ --title "BAML Language $VERSION" \ --notes "BAML language toolchain $VERSION" fi + + publish-wrapper-release: + name: Publish wrapper release assets + needs: [plan, all-builds] + if: ${{ needs.plan.outputs.wrapper_changed == 'true' && needs.plan.outputs.dry_run == 'false' && needs.plan.outputs.source_branch == 'canary' }} + runs-on: ubuntu-latest + env: + GH_REPO: ${{ github.repository }} + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + pattern: wrapper-* + path: dist/wrapper + merge-multiple: true + - name: Generate checksum sidecars + run: | + set -euo pipefail + find dist/wrapper -type f \( -name '*.tar.gz' -o -name '*.zip' \) -print0 | + while IFS= read -r -d '' file; do + (cd "$(dirname "$file")" && sha256sum "$(basename "$file")" > "$(basename "$file").sha256") + done - name: Publish wrapper release - if: ${{ needs.plan.outputs.wrapper_changed == 'true' }} env: GH_TOKEN: ${{ github.token }} WRAPPER_VERSION: ${{ needs.plan.outputs.wrapper_version }} @@ -504,8 +558,8 @@ jobs: publish-pkg-boundaryml-com: name: Publish pkg.boundaryml.com manifests - needs: [plan, all-builds, publish-pypi, publish-nodejs-sdk, publish-github-release, publish-homebrew, publish-aur] - if: ${{ always() && needs.plan.outputs.dry_run == 'false' && needs.plan.outputs.source_branch == 'canary' && needs.publish-pypi.result == 'success' && needs.publish-nodejs-sdk.result == 'success' && needs.publish-github-release.result == 'success' && (needs.plan.outputs.wrapper_changed != 'true' || (needs.publish-homebrew.result == 'success' && needs.publish-aur.result == 'success')) }} + needs: [plan, all-builds, publish-pypi, publish-nodejs-sdk, publish-toolchain-release, publish-wrapper-release, publish-homebrew, publish-aur] + if: ${{ always() && needs.plan.outputs.dry_run == 'false' && needs.plan.outputs.source_branch == 'canary' && needs.publish-pypi.result == 'success' && needs.publish-nodejs-sdk.result == 'success' && needs.publish-toolchain-release.result == 'success' && (needs.plan.outputs.wrapper_changed != 'true' || (needs.publish-wrapper-release.result == 'success' && needs.publish-homebrew.result == 'success' && needs.publish-aur.result == 'success')) }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -624,7 +678,7 @@ jobs: publish-homebrew: name: Publish Homebrew wrapper formula - needs: [plan, all-builds, publish-github-release] + needs: [plan, all-builds, publish-wrapper-release] if: ${{ needs.plan.outputs.wrapper_changed == 'true' && needs.plan.outputs.dry_run == 'false' && needs.plan.outputs.source_branch == 'canary' }} runs-on: ubuntu-latest steps: @@ -662,7 +716,7 @@ jobs: publish-aur: name: Publish AUR wrapper packages - needs: [plan, all-builds, publish-github-release] + needs: [plan, all-builds, publish-wrapper-release] if: ${{ needs.plan.outputs.wrapper_changed == 'true' && needs.plan.outputs.dry_run == 'false' && needs.plan.outputs.source_branch == 'canary' }} runs-on: ubuntu-latest steps: diff --git a/baml_language/crates/baml_cli/src/agent_command.rs b/baml_language/crates/baml_cli/src/agent_command.rs index 4d93309d61f..3cd95fdeb31 100644 --- a/baml_language/crates/baml_cli/src/agent_command.rs +++ b/baml_language/crates/baml_cli/src/agent_command.rs @@ -16,29 +16,6 @@ const SKILL_SOURCE_URL: &str = "https://codeload.github.com/BoundaryML/baml-skill/tar.gz/refs/heads/main"; const HTTP_TIMEOUT: Duration = Duration::from_secs(60); -const SKILLS: &[SkillSpec] = &[ - SkillSpec { - direct_name: "baml-core", - legacy_name: "core", - }, - SkillSpec { - direct_name: "baml-llm-functions", - legacy_name: "llm-functions", - }, - SkillSpec { - direct_name: "baml-pipelines", - legacy_name: "pipelines", - }, - SkillSpec { - direct_name: "baml-testing", - legacy_name: "testing", - }, - SkillSpec { - direct_name: "baml-bridges", - legacy_name: "bridges", - }, -]; - #[derive(Args, Clone, Debug)] pub(crate) struct AgentArgs { #[command(subcommand)] @@ -59,18 +36,33 @@ pub(crate) struct AgentInstallArgs { /// then the git root, then the current directory. #[arg(long, value_name = "PATH")] pub dir: Option, -} -#[derive(Clone, Copy)] -struct SkillSpec { - direct_name: &'static str, - legacy_name: &'static str, + /// Install skills from the current BoundaryML/baml-skill main branch. + #[arg(long, conflicts_with = "from")] + pub latest: bool, + + /// Install skills from a tar.gz URL, local tar.gz archive, or local directory. + #[arg(long, value_name = "URL_OR_PATH", conflicts_with = "latest")] + pub from: Option, } #[derive(Debug)] struct Skill { - name: &'static str, + name: String, + content: String, +} + +#[derive(Debug)] +struct RawSkill { + name: String, + legacy_name: Option, content: String, + source_path: PathBuf, +} + +enum SkillArchivePath { + Direct { name: String }, + Legacy { legacy_name: String, name: String }, } impl AgentArgs { @@ -87,15 +79,94 @@ impl AgentInstallArgs { Some(dir) => explicit_install_root(dir)?, None => detect_install_root()?, }; - let archive = fetch_latest_skill_archive()?; - let skills = skills_from_archive(&archive)?; + let skills = load_skills(self)?; install_skills(&root, &skills)?; print_success(&root)?; Ok(ExitCode::Success) } } -fn fetch_latest_skill_archive() -> Result> { +fn load_skills(args: &AgentInstallArgs) -> Result> { + if args.latest { + let archive = fetch_url_bytes(SKILL_SOURCE_URL)?; + return skills_from_archive(&archive); + } + + if let Some(source) = &args.from { + if is_http_url(source) { + let archive = fetch_url_bytes(source)?; + return skills_from_archive(&archive); + } + + let path = Path::new(source); + if path.is_dir() { + return skills_from_dir(path); + } + + let archive = fs::read(path).with_context(|| { + format!( + "failed to read BAML agent skills archive {}", + path.display() + ) + })?; + return skills_from_archive(&archive); + } + + let version = env_var_nonempty("BAML_AGENT_SKILLS_RELEASE_VERSION") + .unwrap_or_else(|| baml_version::CANONICAL_VERSION.to_string()); + let url = versioned_skill_archive_url(&version); + let archive = fetch_url_bytes(&url)?; + let checksum_text = fetch_url_text(&format!("{url}.sha256"))?; + baml_release::verify_release_archive_checksum_text(&archive, &url, &checksum_text) + .context("verifying agent skills archive checksum failed")?; + skills_from_archive(&archive) +} + +fn is_http_url(value: &str) -> bool { + value.starts_with("https://") || value.starts_with("http://") +} + +fn env_var_nonempty(name: &str) -> Option { + std::env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn versioned_skill_archive_url(version: &str) -> String { + let base_url = env_var_nonempty("BAML_AGENT_SKILLS_RELEASE_BASE_URL"); + let repo = env_var_nonempty("BAML_AGENT_SKILLS_RELEASE_REPO") + .unwrap_or_else(baml_release::release_repo); + versioned_skill_archive_url_with_env(version, base_url.as_deref(), Some(&repo)) +} + +fn versioned_skill_archive_url_with_env( + version: &str, + base_url: Option<&str>, + repo: Option<&str>, +) -> String { + let filename = versioned_skill_archive_filename(version); + if let Some(base_url) = base_url.map(str::trim).filter(|value| !value.is_empty()) { + return format!("{}/{}", base_url.trim_end_matches('/'), filename); + } + + let repo = repo + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(baml_release::DEFAULT_RELEASE_REPO); + format!("https://github.com/{repo}/releases/download/baml-language-{version}/{filename}") +} + +fn versioned_skill_archive_filename(version: &str) -> String { + format!("baml-agent-skills-{version}.tar.gz") +} + +fn fetch_url_text(url: &str) -> Result { + let bytes = fetch_url_bytes(url)?; + String::from_utf8(bytes).with_context(|| format!("{url} was not valid UTF-8")) +} + +fn fetch_url_bytes(url: &str) -> Result> { let client = reqwest::blocking::Client::builder() .connect_timeout(Duration::from_secs(10)) .timeout(HTTP_TIMEOUT) @@ -104,12 +175,12 @@ fn fetch_latest_skill_archive() -> Result> { .context("failed to build HTTP client")?; let response = client - .get(SKILL_SOURCE_URL) + .get(url) .send() - .with_context(|| format!("failed to fetch {SKILL_SOURCE_URL}"))?; + .with_context(|| format!("failed to fetch {url}"))?; if !response.status().is_success() { anyhow::bail!( - "failed to fetch BAML agent skills from {SKILL_SOURCE_URL}: HTTP {}", + "failed to fetch BAML agent skills from {url}: HTTP {}", response.status() ); } @@ -158,7 +229,7 @@ fn detect_install_root() -> Result { fn skills_from_archive(archive: &[u8]) -> Result> { let decoder = flate2::read::GzDecoder::new(Cursor::new(archive)); let mut archive = tar::Archive::new(decoder); - let mut found = BTreeMap::<&'static str, String>::new(); + let mut raw = Vec::new(); for entry in archive.entries().context("failed to read skill archive")? { let mut entry = entry.context("failed to read skill archive entry")?; @@ -170,7 +241,7 @@ fn skills_from_archive(archive: &[u8]) -> Result> { .context("failed to read skill archive entry path")? .into_owned(); let parts = normalized_components(&path)?; - let Some(spec) = skill_spec_for_archive_path(&parts) else { + let Some(skill_path) = skill_archive_path(&parts) else { continue; }; @@ -179,29 +250,104 @@ fn skills_from_archive(archive: &[u8]) -> Result> { .read_to_string(&mut content) .with_context(|| format!("failed to read {}", path.display()))?; - let normalized = if archive_path_is_direct_skill(&parts, spec.direct_name) { - validate_skill_name(&content, spec.direct_name)?; - normalize_direct_skill_references(content, spec) - } else { - normalize_legacy_skill_content(&content, spec)? + raw.push(raw_skill(skill_path, content, path)); + } + + normalize_skills(raw) +} + +fn skills_from_dir(root: &Path) -> Result> { + let mut skill_files = Vec::new(); + collect_skill_files(root, &mut skill_files)?; + + let mut raw = Vec::new(); + for path in skill_files { + let relative = path + .strip_prefix(root) + .with_context(|| format!("failed to relativize {}", path.display()))?; + let parts = normalized_components(relative)?; + let Some(skill_path) = skill_archive_path(&parts) else { + continue; }; - found.insert(spec.direct_name, normalized); + let content = fs::read_to_string(&path) + .with_context(|| format!("failed to read {}", path.display()))?; + raw.push(raw_skill(skill_path, content, relative.to_path_buf())); } - let mut skills = Vec::new(); - for spec in SKILLS { - let Some(content) = found.remove(spec.direct_name) else { + normalize_skills(raw) +} + +fn collect_skill_files(dir: &Path, files: &mut Vec) -> Result<()> { + for entry in fs::read_dir(dir).with_context(|| format!("failed to read {}", dir.display()))? { + let entry = entry.with_context(|| format!("failed to read {}", dir.display()))?; + let path = entry.path(); + let file_type = entry + .file_type() + .with_context(|| format!("failed to inspect {}", path.display()))?; + if file_type.is_dir() { + collect_skill_files(&path, files)?; + } else if file_type.is_file() && path.file_name().is_some_and(|name| name == "SKILL.md") { + files.push(path); + } + } + Ok(()) +} + +fn raw_skill(skill_path: SkillArchivePath, content: String, source_path: PathBuf) -> RawSkill { + match skill_path { + SkillArchivePath::Direct { name } => RawSkill { + name, + legacy_name: None, + content, + source_path, + }, + SkillArchivePath::Legacy { legacy_name, name } => RawSkill { + name, + legacy_name: Some(legacy_name), + content, + source_path, + }, + } +} + +fn normalize_skills(raw: Vec) -> Result> { + if raw.is_empty() { + anyhow::bail!( + "BAML agent skills source did not contain any skills/baml-*/SKILL.md entries" + ); + } + + let mut found = BTreeMap::::new(); + for skill in raw { + if let Some(previous) = found.insert(skill.name.clone(), skill) { anyhow::bail!( - "BAML agent skills archive is missing skills/{}/SKILL.md", - spec.direct_name + "BAML agent skills source contains duplicate skill `{}` at {}", + previous.name, + previous.source_path.display() ); - }; - skills.push(Skill { - name: spec.direct_name, - content, - }); + } } - Ok(skills) + + let skill_names = found.keys().cloned().collect::>(); + found + .into_values() + .map(|skill| { + let content = if skill.legacy_name.is_some() { + normalize_legacy_skill_content(&skill.content, &skill.name).with_context(|| { + format!("failed to normalize {}", skill.source_path.display()) + })? + } else { + validate_skill_name(&skill.content, &skill.name).with_context(|| { + format!("failed to validate {}", skill.source_path.display()) + })?; + skill.content + }; + Ok(Skill { + name: skill.name, + content: normalize_skill_references(content, &skill_names), + }) + }) + .collect() } fn normalized_components(path: &Path) -> Result> { @@ -218,43 +364,44 @@ fn normalized_components(path: &Path) -> Result> { Ok(parts) } -fn skill_spec_for_archive_path(parts: &[String]) -> Option { - for spec in SKILLS { - if archive_path_is_direct_skill(parts, spec.direct_name) - || archive_path_is_legacy_plugin_skill(parts, spec.legacy_name) - { - return Some(*spec); - } - } - None -} - -fn archive_path_is_direct_skill(parts: &[String], skill_name: &str) -> bool { - parts.len() >= 3 +fn skill_archive_path(parts: &[String]) -> Option { + if parts.len() >= 3 && parts[parts.len() - 3] == "skills" - && parts[parts.len() - 2] == skill_name && parts[parts.len() - 1] == "SKILL.md" -} + { + let name = parts[parts.len() - 2].clone(); + if name.starts_with("baml-") { + return Some(SkillArchivePath::Direct { name }); + } + } -fn archive_path_is_legacy_plugin_skill(parts: &[String], skill_name: &str) -> bool { - parts.len() >= 5 + if parts.len() >= 5 && parts[parts.len() - 5] == "plugins" && parts[parts.len() - 4] == "baml" && parts[parts.len() - 3] == "skills" - && parts[parts.len() - 2] == skill_name && parts[parts.len() - 1] == "SKILL.md" + { + let legacy_name = parts[parts.len() - 2].clone(); + if !legacy_name.is_empty() { + let name = format!("baml-{legacy_name}"); + return Some(SkillArchivePath::Legacy { legacy_name, name }); + } + } + + None } -fn normalize_legacy_skill_content(content: &str, spec: SkillSpec) -> Result { +fn normalize_legacy_skill_content(content: &str, name: &str) -> Result { let (frontmatter, body) = split_frontmatter(content)?; - let frontmatter = replace_frontmatter_name(frontmatter, spec.direct_name)?; - let content = format!("---\n{frontmatter}---\n{body}"); - Ok(normalize_direct_skill_references(content, spec)) + let frontmatter = replace_frontmatter_name(frontmatter, name)?; + Ok(format!("---\n{frontmatter}---\n{body}")) } -fn normalize_direct_skill_references(mut content: String, _spec: SkillSpec) -> String { - for skill in SKILLS { - content = content.replace(&format!("baml:{}", skill.legacy_name), skill.direct_name); +fn normalize_skill_references(mut content: String, skill_names: &[String]) -> String { + for skill_name in skill_names { + if let Some(legacy_name) = skill_name.strip_prefix("baml-") { + content = content.replace(&format!("baml:{legacy_name}"), skill_name); + } } content = content.replace("baml:*", "baml-*"); content @@ -328,7 +475,7 @@ fn install_skills_to(root: &Path, relative_skills_dir: PathBuf, skills: &[Skill] let result = (|| -> Result<()> { for skill in skills { - let skill_dir = tmp_dir.join(skill.name); + let skill_dir = tmp_dir.join(&skill.name); fs::create_dir_all(&skill_dir) .with_context(|| format!("failed to create {}", skill_dir.display()))?; write_atomic(&skill_dir.join("SKILL.md"), &skill.content)?; @@ -351,9 +498,9 @@ fn install_skills_to(root: &Path, relative_skills_dir: PathBuf, skills: &[Skill] } fn replace_skill_dir(skills_dir: &Path, tmp_dir: &Path, skill: &Skill) -> Result<()> { - let final_dir = skills_dir.join(skill.name); - let next_dir = tmp_dir.join(skill.name); - let backup_dir = unique_backup_dir(skills_dir, skill.name)?; + let final_dir = skills_dir.join(&skill.name); + let next_dir = tmp_dir.join(&skill.name); + let backup_dir = unique_backup_dir(skills_dir, &skill.name)?; let mut has_backup = false; if final_dir.exists() { @@ -425,7 +572,7 @@ fn write_atomic(path: &Path, content: &str) -> Result<()> { fn print_success(root: &Path) -> Result<()> { writeln!( std::io::stdout(), - "Installed latest BAML agent skills in {}\n\nClaude Code:\n .claude/skills/baml-*/SKILL.md\n\nCodex / OpenCode:\n .agents/skills/baml-*/SKILL.md\n\nRestart any already-running agent session to pick them up.", + "Installed BAML agent skills in {}\n\nClaude Code:\n .claude/skills/baml-*/SKILL.md\n\nCodex / OpenCode:\n .agents/skills/baml-*/SKILL.md\n\nRestart any already-running agent session to pick them up.", root.display() )?; Ok(()) @@ -441,26 +588,57 @@ mod tests { fn direct_archive_layout_is_loaded() { let content = skill("baml-core"); let archive = make_archive(&[("skills/baml-core/SKILL.md", content.as_str())]); - let err = skills_from_archive(&archive).unwrap_err().to_string(); - assert!(err.contains("baml-llm-functions"), "{err}"); + let skills = skills_from_archive(&archive).unwrap(); + + assert_eq!(skills.len(), 1); + assert_eq!(skills[0].name, "baml-core"); + assert!(skills[0].content.contains("name: baml-core")); } #[test] - fn legacy_plugin_layout_is_normalized() { - let entries = SKILLS + fn direct_archive_layout_installs_all_baml_skills() { + let entries = + direct_skill_entries(&["baml-core", "baml-bridges", "baml-serving", "baml-testing"]); + let archive = make_archive(&entry_refs(&entries)); + + let skills = skills_from_archive(&archive).unwrap(); + let names = skills .iter() - .map(|spec| { - ( - format!("plugins/baml/skills/{}/SKILL.md", spec.legacy_name), - skill(spec.legacy_name), - ) - }) + .map(|skill| skill.name.as_str()) .collect::>(); - let entries = entries - .iter() - .map(|(path, content)| (path.as_str(), content.as_str())) + + assert_eq!( + names, + vec!["baml-bridges", "baml-core", "baml-serving", "baml-testing"] + ); + } + + #[test] + fn archive_ignores_unrelated_entries_before_utf8_decode() { + let content = skill("baml-core"); + let archive = make_archive_bytes(&[ + ("skills/baml-core/SKILL.md", content.as_bytes()), + ("skills/baml-core/._SKILL.md", &[0xff, 0xfe]), + ]); + + let skills = skills_from_archive(&archive).unwrap(); + + assert_eq!(skills.len(), 1); + assert_eq!(skills[0].name, "baml-core"); + } + + #[test] + fn legacy_plugin_layout_is_normalized() { + let entries = ["core", "bridges", "testing"] + .into_iter() + .map(|legacy_name| { + let content = format!( + "---\nname: {legacy_name}\ndescription: Use baml:testing, baml:bridges, and baml:*.\n---\n# {legacy_name}\n\nSee baml:testing and baml:bridges.\n" + ); + (format!("plugins/baml/skills/{legacy_name}/SKILL.md"), content) + }) .collect::>(); - let archive = make_archive(&entries); + let archive = make_archive(&entry_refs(&entries)); let skills = skills_from_archive(&archive).unwrap(); let core = skills @@ -469,26 +647,16 @@ mod tests { .unwrap(); assert!(core.content.contains("name: baml-core")); assert!(!core.content.contains("baml:testing")); + assert!(core.content.contains("baml-testing")); + assert!(core.content.contains("baml-bridges")); + assert!(core.content.contains("baml-*")); } #[test] fn direct_layout_requires_matching_frontmatter_name() { - let entries = SKILLS - .iter() - .map(|spec| { - ( - format!("skills/{}/SKILL.md", spec.direct_name), - skill(spec.legacy_name), - ) - }) - .collect::>(); - let entries = entries - .iter() - .map(|(path, content)| (path.as_str(), content.as_str())) - .collect::>(); - let archive = make_archive(&entries); + let archive = make_archive(&[("skills/baml-core/SKILL.md", skill("core").as_str())]); - let err = skills_from_archive(&archive).unwrap_err().to_string(); + let err = format!("{:#}", skills_from_archive(&archive).unwrap_err()); assert!( err.contains("frontmatter name must be `baml-core`"), "{err}" @@ -506,11 +674,11 @@ mod tests { fs::create_dir_all(unrelated.parent().unwrap()).unwrap(); fs::write(&unrelated, "keep").unwrap(); - let skills = SKILLS - .iter() - .map(|spec| Skill { - name: spec.direct_name, - content: skill(spec.direct_name), + let skills = ["baml-core", "baml-bridges"] + .into_iter() + .map(|name| Skill { + name: name.to_string(), + content: skill(name), }) .collect::>(); @@ -534,6 +702,22 @@ mod tests { ); } + #[test] + fn versioned_archive_url_defaults_to_release_asset() { + assert_eq!( + versioned_skill_archive_url_with_env("1.2.3", None, None), + "https://github.com/BoundaryML/baml/releases/download/baml-language-1.2.3/baml-agent-skills-1.2.3.tar.gz" + ); + assert_eq!( + versioned_skill_archive_url_with_env("1.2.3", Some("https://example.com/base/"), None), + "https://example.com/base/baml-agent-skills-1.2.3.tar.gz" + ); + assert_eq!( + versioned_skill_archive_url_with_env("1.2.3", None, Some("BoundaryML/custom")), + "https://github.com/BoundaryML/custom/releases/download/baml-language-1.2.3/baml-agent-skills-1.2.3.tar.gz" + ); + } + #[test] fn root_detection_prefers_nearest_baml_toml() { let _guard = cwd_lock().lock().unwrap(); @@ -587,28 +771,46 @@ mod tests { ) } + fn direct_skill_entries(names: &[&str]) -> Vec<(String, String)> { + names + .iter() + .map(|name| (format!("skills/{name}/SKILL.md"), skill(name))) + .collect() + } + + fn entry_refs(entries: &[(String, String)]) -> Vec<(&str, &str)> { + entries + .iter() + .map(|(path, content)| (path.as_str(), content.as_str())) + .collect() + } + fn cwd_lock() -> &'static Mutex<()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) } fn make_archive(entries: &[(&str, &str)]) -> Vec { + let entries = entries + .iter() + .map(|(path, content)| (*path, content.as_bytes())) + .collect::>(); + make_archive_bytes(&entries) + } + + fn make_archive_bytes(entries: &[(&str, &[u8])]) -> Vec { let mut archive_bytes = Vec::new(); { let encoder = flate2::write::GzEncoder::new(&mut archive_bytes, flate2::Compression::default()); let mut builder = tar::Builder::new(encoder); - for (path, content) in entries { + for (path, content) in entries.iter().copied() { let mut header = tar::Header::new_gnu(); header.set_size(content.len() as u64); header.set_mode(0o644); header.set_cksum(); builder - .append_data( - &mut header, - format!("baml-skill-main/{path}"), - content.as_bytes(), - ) + .append_data(&mut header, format!("baml-skill-main/{path}"), content) .unwrap(); } builder.finish().unwrap(); diff --git a/scripts/package-agent-skills b/scripts/package-agent-skills new file mode 100755 index 00000000000..bef9595e029 --- /dev/null +++ b/scripts/package-agent-skills @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat >&2 <<'USAGE' +Usage: scripts/package-agent-skills --source DIR --version VERSION --out-dir DIR +USAGE +} + +source_dir="" +version="" +out_dir="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --source) + source_dir="${2:-}" + shift 2 + ;; + --version) + version="${2:-}" + shift 2 + ;; + --out-dir) + out_dir="${2:-}" + shift 2 + ;; + -h | --help) + usage + exit 0 + ;; + *) + usage + exit 2 + ;; + esac +done + +if [[ -z "$source_dir" || -z "$version" || -z "$out_dir" ]]; then + usage + exit 2 +fi + +if [[ ! -d "$source_dir" ]]; then + echo "source directory does not exist: $source_dir" >&2 + exit 1 +fi + +mkdir -p "$out_dir" +staging="$(mktemp -d "${TMPDIR:-/tmp}/baml-agent-skills.XXXXXX")" +cleanup() { + rm -rf "$staging" +} +trap cleanup EXIT + +rewrite_skill() { + local input="$1" + local output="$2" + local name="$3" + local tmp + tmp="$(mktemp "${TMPDIR:-/tmp}/baml-agent-skill.XXXXXX")" + awk -v name="$name" ' + BEGIN { + in_frontmatter = 0 + saw_open = 0 + replaced = 0 + } + NR == 1 && $0 == "---" { + print + in_frontmatter = 1 + saw_open = 1 + next + } + saw_open && in_frontmatter && $0 == "---" { + if (!replaced) { + exit 42 + } + print + in_frontmatter = 0 + next + } + saw_open && in_frontmatter && $0 ~ /^[[:space:]]*name:[[:space:]]*/ { + print "name: " name + replaced = 1 + next + } + { + print + } + END { + if (!saw_open || !replaced) { + exit 42 + } + } + ' "$input" > "$tmp" || { + rm -f "$tmp" + echo "failed to rewrite frontmatter name in $input" >&2 + exit 1 + } + mv "$tmp" "$output" +} + +stage_skill() { + local input="$1" + local name="$2" + local dest_dir="$staging/skills/$name" + local dest="$dest_dir/SKILL.md" + + if [[ -e "$dest" ]]; then + echo "duplicate skill after normalization: $name" >&2 + exit 1 + fi + + mkdir -p "$dest_dir" + rewrite_skill "$input" "$dest" "$name" +} + +count=0 + +if [[ -d "$source_dir/skills" ]]; then + while IFS= read -r -d '' skill_file; do + skill_dir="$(basename "$(dirname "$skill_file")")" + stage_skill "$skill_file" "$skill_dir" + count=$((count + 1)) + done < <(find "$source_dir/skills" -mindepth 2 -maxdepth 2 -type f -path '*/baml-*/SKILL.md' -print0 | sort -z) +fi + +if [[ -d "$source_dir/plugins/baml/skills" ]]; then + while IFS= read -r -d '' skill_file; do + legacy_name="$(basename "$(dirname "$skill_file")")" + stage_skill "$skill_file" "baml-$legacy_name" + count=$((count + 1)) + done < <(find "$source_dir/plugins/baml/skills" -mindepth 2 -maxdepth 2 -type f -name 'SKILL.md' -print0 | sort -z) +fi + +if [[ "$count" -eq 0 ]]; then + echo "no BAML agent skills found in $source_dir" >&2 + exit 1 +fi + +archive_name="baml-agent-skills-$version.tar.gz" +archive="$out_dir/$archive_name" + +COPYFILE_DISABLE=1 tar -C "$staging" -czf "$archive" skills + +if command -v sha256sum >/dev/null 2>&1; then + (cd "$out_dir" && sha256sum "$archive_name" > "$archive_name.sha256") +else + (cd "$out_dir" && shasum -a 256 "$archive_name" > "$archive_name.sha256") +fi From ffa77d8c45129506a4cb570cf89e58cea516f376 Mon Sep 17 00:00:00 2001 From: rossirpaulo Date: Fri, 12 Jun 2026 02:12:21 -0700 Subject: [PATCH 2/2] Address agent skills packaging feedback --- .github/workflows/release-baml-language.yml | 1 + .../crates/baml_cli/src/agent_command.rs | 23 +++++++- scripts/package-agent-skills | 54 ++++++++++++++++++- 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release-baml-language.yml b/.github/workflows/release-baml-language.yml index e4084bc5c12..2dd47d4d8c7 100644 --- a/.github/workflows/release-baml-language.yml +++ b/.github/workflows/release-baml-language.yml @@ -422,6 +422,7 @@ jobs: - build-wrapper - build-python-sdk - build-nodejs-sdk + - build-agent-skills steps: - run: echo "all release build jobs completed" diff --git a/baml_language/crates/baml_cli/src/agent_command.rs b/baml_language/crates/baml_cli/src/agent_command.rs index 3cd95fdeb31..55c6a1c6717 100644 --- a/baml_language/crates/baml_cli/src/agent_command.rs +++ b/baml_language/crates/baml_cli/src/agent_command.rs @@ -410,11 +410,19 @@ fn normalize_skill_references(mut content: String, skill_names: &[String]) -> St fn split_frontmatter(content: &str) -> Result<(&str, &str)> { let rest = content .strip_prefix("---\n") + .or_else(|| content.strip_prefix("---\r\n")) .ok_or_else(|| anyhow!("SKILL.md is missing opening frontmatter marker"))?; - let Some((frontmatter, body)) = rest.split_once("\n---\n") else { + let Some((closing_start, closing_marker)) = ["\n---\n", "\r\n---\r\n"] + .into_iter() + .filter_map(|marker| rest.find(marker).map(|index| (index, marker))) + .min_by_key(|(index, _)| *index) + else { anyhow::bail!("SKILL.md is missing closing frontmatter marker"); }; - Ok((frontmatter, body)) + Ok(( + &rest[..closing_start], + &rest[closing_start + closing_marker.len()..], + )) } fn validate_skill_name(content: &str, expected_name: &str) -> Result<()> { @@ -663,6 +671,17 @@ mod tests { ); } + #[test] + fn direct_layout_accepts_crlf_frontmatter_delimiters() { + let content = "---\r\nname: baml-core\r\ndescription: test\r\n---\r\n# Core\r\n"; + let archive = make_archive(&[("skills/baml-core/SKILL.md", content)]); + + let skills = skills_from_archive(&archive).unwrap(); + + assert_eq!(skills.len(), 1); + assert_eq!(skills[0].name, "baml-core"); + } + #[test] fn install_refreshes_baml_skills_and_preserves_unrelated_skills() { let tmp = tempfile::tempdir().unwrap(); diff --git a/scripts/package-agent-skills b/scripts/package-agent-skills index bef9595e029..830246925dd 100755 --- a/scripts/package-agent-skills +++ b/scripts/package-agent-skills @@ -10,18 +10,31 @@ USAGE source_dir="" version="" out_dir="" +tar_list="" while [[ $# -gt 0 ]]; do case "$1" in --source) + if [[ -z "${2:-}" ]]; then + usage + exit 2 + fi source_dir="${2:-}" shift 2 ;; --version) + if [[ -z "${2:-}" ]]; then + usage + exit 2 + fi version="${2:-}" shift 2 ;; --out-dir) + if [[ -z "${2:-}" ]]; then + usage + exit 2 + fi out_dir="${2:-}" shift 2 ;; @@ -50,6 +63,9 @@ mkdir -p "$out_dir" staging="$(mktemp -d "${TMPDIR:-/tmp}/baml-agent-skills.XXXXXX")" cleanup() { rm -rf "$staging" + if [[ -n "${tar_list:-}" ]]; then + rm -f "$tar_list" + fi } trap cleanup EXIT @@ -141,7 +157,43 @@ fi archive_name="baml-agent-skills-$version.tar.gz" archive="$out_dir/$archive_name" -COPYFILE_DISABLE=1 tar -C "$staging" -czf "$archive" skills +find "$staging" -type d -exec chmod 755 {} + +find "$staging" -type f -exec chmod 644 {} + +find "$staging" -exec touch -t 197001010000.00 {} + +export COPYFILE_DISABLE=1 +export GZIP=-n + +tar_help="$(tar --help 2>/dev/null || true)" +if grep -q -- '--sort' <<< "$tar_help"; then + tar_flags=( + --sort=name + --mtime=1970-01-01 + --numeric-owner + --owner=0 + --group=0 + ) + if grep -q -- '--no-acls' <<< "$tar_help"; then + tar_flags+=(--no-acls) + fi + if grep -q -- '--no-xattrs' <<< "$tar_help"; then + tar_flags+=(--no-xattrs) + fi + tar -C "$staging" "${tar_flags[@]}" -czf "$archive" skills +else + tar_list="$(mktemp "${TMPDIR:-/tmp}/baml-agent-skills-list.XXXXXX")" + ( + cd "$staging" + find skills -print | LC_ALL=C sort > "$tar_list" + tar --no-recursion \ + --uid 0 \ + --gid 0 \ + --uname root \ + --gname root \ + --format ustar \ + -T "$tar_list" \ + -czf "$archive" + ) +fi if command -v sha256sum >/dev/null 2>&1; then (cd "$out_dir" && sha256sum "$archive_name" > "$archive_name.sha256")