351 lines
14 KiB
Python
351 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
import argparse
|
|
import json
|
|
import shutil
|
|
import zipfile
|
|
from pathlib import Path
|
|
import yaml
|
|
|
|
|
|
def read_simple_yaml(path: Path) -> dict:
|
|
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
|
|
|
|
def read_frontmatter(skill_md: Path) -> dict:
|
|
if not skill_md.exists():
|
|
raise FileNotFoundError(f"Missing required file: {skill_md}")
|
|
text = skill_md.read_text(encoding="utf-8")
|
|
if not text.startswith("---"):
|
|
return {}
|
|
parts = text.split("---", 2)
|
|
if len(parts) < 3:
|
|
return {}
|
|
return yaml.safe_load(parts[1]) or {}
|
|
|
|
|
|
def read_interface(skill_dir: Path) -> dict:
|
|
path = skill_dir / "agents" / "interface.yaml"
|
|
if not path.exists():
|
|
return {}
|
|
raw = read_simple_yaml(path)
|
|
return raw
|
|
|
|
|
|
def require_fields(payload: dict, fields: list[str], label: str) -> None:
|
|
missing = [field for field in fields if not payload.get(field)]
|
|
if missing:
|
|
raise ValueError(f"Missing required {label} fields: {', '.join(missing)}")
|
|
|
|
|
|
def require_target_degradation(
|
|
degradation: dict,
|
|
targets: list[str],
|
|
) -> None:
|
|
missing = [target for target in targets if not degradation.get(target)]
|
|
if missing:
|
|
raise ValueError(f"Missing degradation entries for targets: {', '.join(missing)}")
|
|
|
|
|
|
def build_manifest(skill_dir: Path, platform: str) -> dict:
|
|
frontmatter = read_frontmatter(skill_dir / "SKILL.md")
|
|
interface_doc = read_interface(skill_dir)
|
|
interface = interface_doc.get("interface", {})
|
|
compatibility = interface_doc.get("compatibility", {})
|
|
activation = compatibility.get("activation", {})
|
|
execution = compatibility.get("execution", {})
|
|
trust = compatibility.get("trust", {})
|
|
degradation = compatibility.get("degradation", {})
|
|
require_fields(frontmatter, ["name", "description"], "frontmatter")
|
|
require_fields(interface, ["display_name", "short_description", "default_prompt"], "interface")
|
|
require_fields(compatibility, ["canonical_format", "adapter_targets"], "compatibility")
|
|
require_fields(activation, ["mode"], "compatibility.activation")
|
|
require_fields(execution, ["context", "shell"], "compatibility.execution")
|
|
require_fields(
|
|
trust,
|
|
["source_tier", "remote_inline_execution", "remote_metadata_policy"],
|
|
"compatibility.trust",
|
|
)
|
|
require_target_degradation(degradation, compatibility.get("adapter_targets", []))
|
|
return {
|
|
"name": frontmatter.get("name", skill_dir.name),
|
|
"description": frontmatter.get("description", ""),
|
|
"version": frontmatter.get("version", "1.0.0"),
|
|
"platform": platform,
|
|
"skill_root": skill_dir.name,
|
|
"display_name": interface.get("display_name", skill_dir.name),
|
|
"short_description": interface.get("short_description", ""),
|
|
"default_prompt": interface.get("default_prompt", ""),
|
|
"canonical_metadata": "agents/interface.yaml",
|
|
"canonical_format": compatibility.get("canonical_format", "agent-skills"),
|
|
"adapter_targets": compatibility.get("adapter_targets", []),
|
|
"activation_mode": activation.get("mode", "manual"),
|
|
"activation_paths": activation.get("paths", []),
|
|
"execution_context": execution.get("context", "inline"),
|
|
"shell": execution.get("shell", "bash"),
|
|
"trust_level": trust.get("source_tier", "local"),
|
|
"remote_inline_execution": trust.get("remote_inline_execution", "forbid"),
|
|
"remote_metadata_policy": trust.get("remote_metadata_policy", "allow-metadata-only"),
|
|
"degradation_strategy": degradation.get(platform, "neutral-source"),
|
|
"portability_profile": {
|
|
"activation_mode": activation.get("mode", "manual"),
|
|
"activation_paths": activation.get("paths", []),
|
|
"execution_context": execution.get("context", "inline"),
|
|
"shell": execution.get("shell", "bash"),
|
|
"source_tier": trust.get("source_tier", "local"),
|
|
"remote_inline_execution": trust.get("remote_inline_execution", "forbid"),
|
|
"remote_metadata_policy": trust.get("remote_metadata_policy", "allow-metadata-only"),
|
|
"degradation_strategy": degradation.get(platform, "neutral-source"),
|
|
},
|
|
}
|
|
|
|
|
|
PLATFORM_CONTRACTS = {
|
|
"openai": {
|
|
"required_fields": [
|
|
"name",
|
|
"description",
|
|
"version",
|
|
"display_name",
|
|
"short_description",
|
|
"default_prompt",
|
|
"canonical_metadata",
|
|
"canonical_format",
|
|
"activation_mode",
|
|
"execution_context",
|
|
"shell",
|
|
"trust_level",
|
|
"remote_inline_execution",
|
|
"degradation_strategy",
|
|
"portability_profile",
|
|
],
|
|
"required_files": ["targets/openai/adapter.json", "targets/openai/agents/openai.yaml"],
|
|
"field_mapping": {
|
|
"display_name": "interface.display_name",
|
|
"short_description": "interface.short_description",
|
|
"default_prompt": "interface.default_prompt",
|
|
"execution_context": "compatibility.execution.context",
|
|
"shell": "compatibility.execution.shell",
|
|
},
|
|
},
|
|
"claude": {
|
|
"required_fields": [
|
|
"name",
|
|
"description",
|
|
"version",
|
|
"display_name",
|
|
"short_description",
|
|
"default_prompt",
|
|
"canonical_metadata",
|
|
"canonical_format",
|
|
"activation_mode",
|
|
"execution_context",
|
|
"shell",
|
|
"trust_level",
|
|
"remote_inline_execution",
|
|
"degradation_strategy",
|
|
"portability_profile",
|
|
],
|
|
"required_files": ["targets/claude/adapter.json", "targets/claude/README.md"],
|
|
"field_mapping": {
|
|
"display_name": "adapter.display_name",
|
|
"short_description": "adapter.short_description",
|
|
"default_prompt": "adapter.default_prompt",
|
|
"execution_context": "compatibility.execution.context",
|
|
"shell": "compatibility.execution.shell",
|
|
},
|
|
},
|
|
"generic": {
|
|
"required_fields": [
|
|
"name",
|
|
"description",
|
|
"version",
|
|
"display_name",
|
|
"short_description",
|
|
"default_prompt",
|
|
"canonical_metadata",
|
|
"canonical_format",
|
|
"activation_mode",
|
|
"execution_context",
|
|
"shell",
|
|
"trust_level",
|
|
"remote_inline_execution",
|
|
"degradation_strategy",
|
|
"portability_profile",
|
|
],
|
|
"required_files": ["targets/generic/adapter.json"],
|
|
"field_mapping": {
|
|
"display_name": "adapter.display_name",
|
|
"short_description": "adapter.short_description",
|
|
"default_prompt": "adapter.default_prompt",
|
|
"execution_context": "compatibility.execution.context",
|
|
"shell": "compatibility.execution.shell",
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def write_yaml_like(path: Path, payload: dict) -> None:
|
|
interface = payload.get("interface", {})
|
|
compatibility = payload.get("compatibility", {})
|
|
lines = ["interface:"]
|
|
for key in ("display_name", "short_description", "default_prompt"):
|
|
value = interface.get(key, "")
|
|
lines.append(f' {key}: "{value}"')
|
|
lines.extend(
|
|
[
|
|
"compatibility:",
|
|
f' canonical_format: "{compatibility.get("canonical_format", "")}"',
|
|
f' activation_mode: "{compatibility.get("activation_mode", "")}"',
|
|
f' execution_context: "{compatibility.get("execution_context", "")}"',
|
|
f' shell: "{compatibility.get("shell", "")}"',
|
|
f' trust_level: "{compatibility.get("trust_level", "")}"',
|
|
f' remote_inline_execution: "{compatibility.get("remote_inline_execution", "")}"',
|
|
f' degradation_strategy: "{compatibility.get("degradation_strategy", "")}"',
|
|
]
|
|
)
|
|
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
|
|
|
|
def write_adapter(skill_dir: Path, out_dir: Path, platform: str) -> Path:
|
|
target_dir = out_dir / "targets" / platform
|
|
target_dir.mkdir(parents=True, exist_ok=True)
|
|
if platform not in PLATFORM_CONTRACTS:
|
|
raise ValueError(f"Unsupported platform: {platform}")
|
|
payload = build_manifest(skill_dir, platform)
|
|
if platform == "openai":
|
|
meta_dir = target_dir / "agents"
|
|
meta_dir.mkdir(parents=True, exist_ok=True)
|
|
write_yaml_like(
|
|
meta_dir / "openai.yaml",
|
|
{
|
|
"interface": {
|
|
"display_name": payload["display_name"],
|
|
"short_description": payload["short_description"],
|
|
"default_prompt": payload["default_prompt"],
|
|
},
|
|
"compatibility": {
|
|
"canonical_format": payload["canonical_format"],
|
|
"activation_mode": payload["activation_mode"],
|
|
"execution_context": payload["execution_context"],
|
|
"shell": payload["shell"],
|
|
"trust_level": payload["trust_level"],
|
|
"remote_inline_execution": payload["remote_inline_execution"],
|
|
"degradation_strategy": payload["degradation_strategy"],
|
|
},
|
|
},
|
|
)
|
|
payload["install_hint"] = f"Use the packaged skill and include targets/openai/agents/openai.yaml when the client expects OpenAI-style interface metadata."
|
|
elif platform == "claude":
|
|
notes = target_dir / "README.md"
|
|
notes.write_text(
|
|
f"# Claude-Compatible Package\n\nUse `{skill_dir.name}` with its neutral source files. This target does not require vendor metadata by default.\n",
|
|
encoding="utf-8",
|
|
)
|
|
payload["install_hint"] = f"Use the packaged skill directly; this target relies on SKILL.md and optional neutral metadata."
|
|
else:
|
|
payload["install_hint"] = f"Use {skill_dir.name} as an Agent Skills compatible package."
|
|
path = target_dir / "adapter.json"
|
|
payload["contract"] = PLATFORM_CONTRACTS.get(platform, PLATFORM_CONTRACTS["generic"])
|
|
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
return path
|
|
|
|
|
|
def make_zip(skill_dir: Path, out_dir: Path) -> Path:
|
|
zip_path = out_dir / f"{skill_dir.name}.zip"
|
|
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
|
for path in skill_dir.rglob("*"):
|
|
if path.is_file():
|
|
zf.write(path, arcname=str(path.relative_to(skill_dir.parent)))
|
|
return zip_path
|
|
|
|
|
|
def copy_manifest(skill_dir: Path, out_dir: Path) -> Path:
|
|
manifest_path = out_dir / "manifest.json"
|
|
manifest_path.write_text(
|
|
json.dumps(build_manifest(skill_dir, "generic"), ensure_ascii=False, indent=2),
|
|
encoding="utf-8",
|
|
)
|
|
return manifest_path
|
|
|
|
|
|
def load_expectations(path: Path | None) -> dict:
|
|
if path is None:
|
|
return {}
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
def validate_exports(out_dir: Path, expectations: dict) -> dict:
|
|
failures = []
|
|
required_targets = expectations.get("required_targets", [])
|
|
required_fields = expectations.get("required_fields", [])
|
|
required_by_target = {
|
|
"openai": expectations.get("openai_required_files", []),
|
|
"claude": expectations.get("claude_required_files", []),
|
|
"generic": expectations.get("generic_required_files", []),
|
|
}
|
|
|
|
for target in required_targets:
|
|
adapter_path = out_dir / "targets" / target / "adapter.json"
|
|
if not adapter_path.exists():
|
|
failures.append(f"missing adapter for target: {target}")
|
|
continue
|
|
payload = json.loads(adapter_path.read_text(encoding="utf-8"))
|
|
for field in required_fields:
|
|
if field not in payload:
|
|
failures.append(f"missing field '{field}' in {adapter_path.relative_to(out_dir)}")
|
|
for rel in required_by_target.get(target, []):
|
|
if not (out_dir / rel).exists():
|
|
failures.append(f"missing file: {rel}")
|
|
return {"ok": not failures, "failures": failures}
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="Generate lightweight cross-platform packaging artifacts.")
|
|
parser.add_argument("skill_dir", help="Path to the skill directory")
|
|
parser.add_argument("--platform", action="append", default=[], help="Target platform: openai, claude, generic")
|
|
parser.add_argument("--output-dir", default="dist", help="Output directory")
|
|
parser.add_argument("--expectations", help="JSON file describing packaging expectations")
|
|
parser.add_argument("--zip", action="store_true", help="Create a zip package")
|
|
args = parser.parse_args()
|
|
|
|
skill_dir = Path(args.skill_dir).resolve()
|
|
out_dir = Path(args.output_dir).resolve()
|
|
if out_dir.exists():
|
|
shutil.rmtree(out_dir)
|
|
out_dir.mkdir(parents=True)
|
|
|
|
generated = []
|
|
failures = []
|
|
try:
|
|
manifest = copy_manifest(skill_dir, out_dir)
|
|
generated.append(str(manifest))
|
|
for platform in (args.platform or ["generic"]):
|
|
generated.append(str(write_adapter(skill_dir, out_dir, platform)))
|
|
if args.zip:
|
|
generated.append(str(make_zip(skill_dir, out_dir)))
|
|
except (FileNotFoundError, ValueError, yaml.YAMLError) as exc:
|
|
failures.append(str(exc))
|
|
|
|
expectations = load_expectations(Path(args.expectations).resolve()) if args.expectations else {}
|
|
validation = validate_exports(out_dir, expectations) if expectations and not failures else None
|
|
report = {
|
|
"output_dir": str(out_dir),
|
|
"generated": generated,
|
|
"contracts": PLATFORM_CONTRACTS,
|
|
"validation": validation,
|
|
"failure_handling": {
|
|
"missing_required_file": "exit with code 2 when expectations are provided and validation fails",
|
|
"missing_required_field": "exit with code 2 when expectations are provided and validation fails",
|
|
"invalid_yaml_or_frontmatter": "exit with code 2 when parsing fails",
|
|
"unsupported_platform": "exit with code 2 when the platform is not defined in PLATFORM_CONTRACTS",
|
|
},
|
|
"failures": failures,
|
|
}
|
|
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
if failures or (validation and not validation["ok"]):
|
|
raise SystemExit(2)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|