diff --git a/.gen.json b/.gen.json index 9817c154..3bd13eb0 100644 --- a/.gen.json +++ b/.gen.json @@ -1,3 +1,3 @@ { - "source_commit": "cbacac03c4f1a2a98f74db669a6fb07c0c56598c" + "source_commit": "1a682947cba32cf352d0afab77f5f9bf49e0689a" } diff --git a/scripts/build_claude_plugin.py b/scripts/build_claude_plugin.py new file mode 100644 index 00000000..8d5ff943 --- /dev/null +++ b/scripts/build_claude_plugin.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Build a complete Claude plugin source snapshot.""" + +import argparse +import os +import shutil +import stat +import tempfile +from pathlib import Path + +from skillsgen.generate import generate_all + + +# Universe excludes public release state from its source mirror. This SemVer +# marks local artifacts as unreleased without constraining marketplace releases. +UNRELEASED_PLUGIN_VERSION = "0.0.0-unreleased" + + +def _make_writable(root: Path) -> None: + """Make the copied source tree writable for generation.""" + root.chmod(root.stat().st_mode | stat.S_IWUSR) + for directory, directory_names, file_names in os.walk(root): + for name in directory_names + file_names: + path = Path(directory) / name + path.chmod(path.stat().st_mode | stat.S_IWUSR) + + +def build_claude_plugin( + source_root: Path, + output_directory: Path, +) -> None: + """Generate and copy the complete Claude provider artifact.""" + with tempfile.TemporaryDirectory(prefix="databricks-agent-skills-") as temporary: + working_root = Path(temporary) / "databricks-agent-skills" + shutil.copytree(source_root, working_root) + _make_writable(working_root) + + generate_all( + working_root, + version_override=UNRELEASED_PLUGIN_VERSION, + ) + generated_plugin = working_root / "plugins" / "databricks" / "claude" + if not generated_plugin.is_dir(): + message = f"The generator did not produce {generated_plugin}." + raise RuntimeError(message) + if output_directory.exists(): + shutil.rmtree(output_directory) + shutil.copytree(generated_plugin, output_directory) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--source-marker", required=True) + parser.add_argument("--output", required=True) + args = parser.parse_args() + + source_marker = Path(args.source_marker) + source_root = source_marker.parent.parent + build_claude_plugin(source_root, Path(args.output)) + + +if __name__ == "__main__": + main() diff --git a/scripts/skills.py b/scripts/skills.py index 01bef4bf..b9d27431 100644 --- a/scripts/skills.py +++ b/scripts/skills.py @@ -30,6 +30,8 @@ SHARED_ASSETS, STABLE_REPO_DIR, EXPERIMENTAL_REPO_DIR, + UNPUBLISHED_FILENAMES, + is_publishable_path, iter_skill_dirs, iter_experimental_skill_dirs, extract_version_from_skill, diff --git a/scripts/skillsgen/bundle.py b/scripts/skillsgen/bundle.py index f95aa721..2ef1413b 100644 --- a/scripts/skillsgen/bundle.py +++ b/scripts/skillsgen/bundle.py @@ -28,6 +28,7 @@ from skillsgen.commands import render_command_files from skillsgen.common import BUNDLE_DIR, _serialize_plugin_json +from skillsgen.discovery import is_publishable_path from skillsgen.plugins import ( _GENERATED_README, build_claude_plugin, @@ -56,12 +57,8 @@ def _provider_specs() -> dict: def _is_noise(rel_parts: tuple) -> bool: - """Files that must never ship (mirrors discovery.iter_skill_files).""" - if any(part.startswith(".") for part in rel_parts): - return True - if "__pycache__" in rel_parts: - return True - return any(part.endswith(".pyc") for part in rel_parts) + """Return whether a source path must not ship.""" + return not is_publishable_path(rel_parts) def _iter_copy(repo_root: Path, src_dir: str): diff --git a/scripts/skillsgen/discovery.py b/scripts/skillsgen/discovery.py index b45f1188..b722c5b9 100644 --- a/scripts/skillsgen/discovery.py +++ b/scripts/skillsgen/discovery.py @@ -19,6 +19,26 @@ STABLE_REPO_DIR = "skills" EXPERIMENTAL_REPO_DIR = "experimental" +UNPUBLISHED_FILENAMES = frozenset( + { + "BUILD", + "BUILD.bazel", + "METADATA", + "OWNERS", + } +) + + +def is_publishable_path(relative_parts: tuple[str, ...]) -> bool: + """Return whether a source path may appear in a published artifact.""" + if any(part.startswith(".") for part in relative_parts): + return False + if "__pycache__" in relative_parts: + return False + if any(part.endswith(".pyc") for part in relative_parts): + return False + return not any(part in UNPUBLISHED_FILENAMES for part in relative_parts) + def iter_skill_dirs(repo_root: Path, parent: str = STABLE_REPO_DIR): """Yield skill directories under `parent` that contain SKILL.md.""" @@ -68,21 +88,16 @@ def extract_version_from_skill(skill_path: Path) -> str: def iter_skill_files(skill_path: Path): - """Yield tracked files in a skill directory, skipping VCS-ignored noise. + """Yield files in a skill directory that may be published. - Filters out dot-prefixed paths (.DS_Store, .git, etc.), __pycache__ - directories, and *.pyc files so manifest output stays reproducible - across machines. + This applies the same governance and generated-file exclusions as bundle + generation so the manifest and provider artifacts cannot diverge. """ for file_path in skill_path.rglob("*"): if not file_path.is_file(): continue rel_parts = file_path.relative_to(skill_path).parts - if any(part.startswith(".") for part in rel_parts): - continue - if "__pycache__" in rel_parts: - continue - if file_path.suffix == ".pyc": + if not is_publishable_path(rel_parts): continue yield file_path diff --git a/tests/skills_generator_test.py b/tests/skills_generator_test.py index 439e654d..52552606 100644 --- a/tests/skills_generator_test.py +++ b/tests/skills_generator_test.py @@ -396,7 +396,7 @@ def test_copilot_bundle_uses_root_hooks_file(self): self.assertTrue((cph / "hooks.json").exists()) self.assertFalse((cph / "hooks" / "hooks.json").exists()) - def test_bundle_skips_vcs_noise(self): + def test_bundle_skips_unpublished_files(self): with tempfile.TemporaryDirectory() as d: root = Path(d) for dd in self._SRC_DIRS: @@ -405,10 +405,14 @@ def test_bundle_skips_vcs_noise(self): noise.mkdir(parents=True, exist_ok=True) (noise / "x.pyc").write_text("x") (root / "skills" / "databricks-core" / ".DS_Store").write_text("x") + for filename in skills.UNPUBLISHED_FILENAMES: + (root / "skills" / "databricks-core" / filename).write_text("x") skills.generate_bundle(root, self.meta) seeded = root / "plugins/databricks/claude/skills/databricks-core" self.assertFalse((seeded / ".DS_Store").exists()) self.assertFalse((seeded / "__pycache__").exists()) + for filename in skills.UNPUBLISHED_FILENAMES: + self.assertFalse((seeded / filename).exists()) class ScopedSourcesTest(unittest.TestCase): @@ -513,6 +517,22 @@ def test_generate_all_resyncs_stale_skill_asset(self): skills.generate_all(root, version_override="9.9.9") self.assertEqual(skills.check_codex_metadata(root), []) + def test_generate_all_excludes_unpublished_files(self): + with tempfile.TemporaryDirectory() as d: + root = self._seed(Path(d)) + core = root / "skills/databricks-core" + for filename in skills.UNPUBLISHED_FILENAMES: + (core / filename).write_text("universe-only") + + result = skills.generate_all(root, version_override="9.9.9") + + core_manifest = result["manifest"]["skills"]["databricks-core"] + manifest_files = set(core_manifest["files"]) + self.assertTrue(manifest_files.isdisjoint(skills.UNPUBLISHED_FILENAMES)) + bundled_core = root / "plugins/databricks/claude/skills/databricks-core" + for filename in skills.UNPUBLISHED_FILENAMES: + self.assertFalse((bundled_core / filename).exists()) + if __name__ == "__main__": unittest.main()