Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "auplc-skills",
"owner": {
"name": "AMD Research"
},
"description": "Agent Skills for deploying and maintaining AUP Learning Cloud.",
"metadata": {
"version": "0.1.1"
},
"plugins": [
{
"name": "auplc",
"source": "./",
"description": "Skills for deploying and maintaining AUP Learning Cloud: install, deploy, configure courses, build images, upgrade, troubleshoot, configure auth, manage users and quota, monitor, expose with TLS/storage, configure repo cloning, and author courses for the multi-node JupyterHub-on-k3s platform for AMD GPUs."
}
]
}
21 changes: 21 additions & 0 deletions .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "auplc",
"description": "Skills for deploying and maintaining AUP Learning Cloud: install, deploy, configure courses, build images, upgrade, troubleshoot, configure auth, manage users and quota, monitor, expose with TLS/storage, configure repo cloning, and author courses for the multi-node JupyterHub-on-k3s platform for AMD GPUs.",
"version": "0.1.1",
"author": {
"name": "AMD Research"
},
"homepage": "https://github.com/AMDResearch/aup-learning-cloud",
"repository": "https://github.com/AMDResearch/aup-learning-cloud",
"keywords": [
"aup-learning-cloud",
"auplc",
"jupyterhub",
"k3s",
"rocm",
"pxe",
"ansible",
"helm",
"deployment"
]
}
17 changes: 17 additions & 0 deletions .cursor-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "auplc-skills",
"owner": {
"name": "AMD Research"
},
"description": "Agent Skills for deploying and maintaining AUP Learning Cloud.",
"metadata": {
"version": "0.1.1"
},
"plugins": [
{
"name": "auplc",
"source": "./",
"description": "Skills for deploying and maintaining AUP Learning Cloud: install, deploy, configure courses, build images, upgrade, troubleshoot, configure auth, manage users and quota, monitor, expose with TLS/storage, configure repo cloning, and author courses for the multi-node JupyterHub-on-k3s platform for AMD GPUs."
}
]
}
19 changes: 19 additions & 0 deletions .cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "auplc",
"version": "0.1.1",
"description": "Skills for deploying and maintaining AUP Learning Cloud: install, deploy, configure courses, build images, upgrade, troubleshoot, configure auth, manage users and quota, monitor, expose with TLS/storage, configure repo cloning, and author courses for the multi-node JupyterHub-on-k3s platform for AMD GPUs.",
"author": {
"name": "AMD Research"
},
"keywords": [
"aup-learning-cloud",
"auplc",
"jupyterhub",
"k3s",
"rocm",
"pxe",
"ansible",
"helm",
"deployment"
]
}
34 changes: 34 additions & 0 deletions .github/scripts/check.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Validate skills, version metadata, skill tests, and generated plugin manifests.
#
# Usage:
# ./.github/scripts/check.sh Run every skill-package validation.
# ./.github/scripts/check.sh -h|--help Print this help.
#
# Requires `uv` (https://github.com/astral-sh/uv).

set -euo pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"

usage() {
sed -n 's/^# \{0,1\}//p' "${BASH_SOURCE[0]}" | sed -n '/^Usage:/,/^Requires/p'
}

case "${1:-}" in
"")
uv run .github/scripts/validate_skills.py
uv run python scripts/check_skills_version.py
uv run --extra test pytest tests/skills
uv run .github/scripts/generate_cursor_marketplace.py --check
;;
-h|--help)
usage
;;
*)
echo "Unknown option: $1" >&2
echo "Run with --help for usage." >&2
exit 2
;;
esac
195 changes: 195 additions & 0 deletions .github/scripts/generate_cursor_marketplace.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
#!/usr/bin/env -S uv run --quiet
# /// script
# requires-python = ">=3.10"
# dependencies = []
# ///
"""Generate the Cursor plugin manifests from the canonical sources.

`auplc-skills` ships as a single bundled plugin: the whole repository is one
plugin whose `skills/` folder every supported agent discovers automatically
(this mirrors how `cloudflare/skills` is published). To avoid drift, the Cursor
manifests are generated from the Claude manifests rather than hand-maintained.

Sources of truth:
- `plugin-metadata.json` (repo root): shared identity and discovery metadata
(name, description, version, author, homepage, repository,
keywords). This is the vendor-neutral metadata file, reused by every
marketplace/manifest target. It is NOT a plugin manifest.
- `.claude-plugin/marketplace.json`: the marketplace catalog with the single
bundled plugin entry and its human-readable description (hand-maintained,
since the catalog blurb intentionally differs from the SKILL.md routing
descriptions).
- `.claude-plugin/plugin.json`: the bundled plugin manifest (hand-maintained).

Outputs:
- `.cursor-plugin/marketplace.json`: a mirror of the Claude marketplace so
Cursor exposes exactly the same plugin as Claude.
- `.cursor-plugin/plugin.json`: the Cursor plugin manifest derived from the
Claude plugin manifest + `plugin-metadata.json`.

Usage:
uv run .github/scripts/generate_cursor_marketplace.py # write
uv run .github/scripts/generate_cursor_marketplace.py --check # validate only

`--check` fails if any generated file is stale or if the Claude manifests'
top-level identity has drifted from `plugin-metadata.json`.
"""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent.parent
PLUGIN_METADATA = ROOT / "plugin-metadata.json"
CLAUDE_MARKETPLACE = ROOT / ".claude-plugin" / "marketplace.json"
CLAUDE_PLUGIN = ROOT / ".claude-plugin" / "plugin.json"
CURSOR_MARKETPLACE = ROOT / ".cursor-plugin" / "marketplace.json"
CURSOR_PLUGIN = ROOT / ".cursor-plugin" / "plugin.json"


def load_json(path: Path) -> dict:
if not path.exists():
raise FileNotFoundError(f"Missing required file: {path}")
return json.loads(path.read_text(encoding="utf-8"))


def check_identity_consistency(metadata: dict, claude: dict, claude_plugin: dict) -> list[str]:
"""Return error strings if the Claude manifests' identity has drifted from
the canonical `plugin-metadata.json`."""
errors: list[str] = []

name = metadata.get("name")
description = metadata.get("description")
version = metadata.get("version")

if claude.get("name") != name:
errors.append(
f".claude-plugin/marketplace.json `name` ({claude.get('name')!r}) "
f"must match plugin-metadata.json `name` ({name!r})."
)
claude_description = claude.get("description")
if claude_description != description:
errors.append(".claude-plugin/marketplace.json `description` must match plugin-metadata.json `description`.")
claude_version = (claude.get("metadata") or {}).get("version")
if claude_version != version:
errors.append(
f".claude-plugin/marketplace.json metadata.version "
f"({claude_version!r}) must match plugin-metadata.json `version` "
f"({version!r})."
)

# The single bundled plugin entry's name must match the plugin manifest.
plugins = claude.get("plugins")
if not isinstance(plugins, list) or len(plugins) != 1:
errors.append(".claude-plugin/marketplace.json must list exactly one bundled plugin (source `./`).")
else:
entry_name = plugins[0].get("name")
if entry_name != claude_plugin.get("name"):
errors.append(
f".claude-plugin/marketplace.json plugin `name` ({entry_name!r}) "
f"must match .claude-plugin/plugin.json `name` "
f"({claude_plugin.get('name')!r})."
)

if claude_plugin.get("version") != version:
errors.append(
f".claude-plugin/plugin.json `version` "
f"({claude_plugin.get('version')!r}) must match plugin-metadata.json "
f"`version` ({version!r})."
)
return errors


def build_cursor_marketplace(metadata: dict, claude: dict) -> dict:
author = metadata.get("author") or {}
owner_name = author.get("name") if isinstance(author, dict) else None

return {
"name": metadata["name"],
"owner": {"name": owner_name} if owner_name else {},
"description": metadata["description"],
"metadata": {
"version": metadata["version"],
},
"plugins": claude.get("plugins", []),
}


def build_cursor_plugin(metadata: dict, claude_plugin: dict) -> dict:
return {
"name": claude_plugin["name"],
"version": metadata["version"],
"description": claude_plugin.get("description", metadata["description"]),
"author": metadata.get("author") or {},
"keywords": metadata.get("keywords", []),
}


def render_json(data: dict) -> str:
return json.dumps(data, indent=2, ensure_ascii=False) + "\n"


def write_or_check(path: Path, content: str, check: bool) -> bool:
"""Return True when the file is already up to date."""
current = path.read_text(encoding="utf-8") if path.exists() else None
if current == content:
return True
if check:
return False
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
return True


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Generate the .cursor-plugin/ manifests from the canonical "
"Claude manifests and plugin-metadata.json."
)
parser.add_argument(
"--check",
action="store_true",
help="Validate the generated manifests are up to date without writing.",
)
args = parser.parse_args(argv)

metadata = load_json(PLUGIN_METADATA)
claude = load_json(CLAUDE_MARKETPLACE)
claude_plugin = load_json(CLAUDE_PLUGIN)

identity_errors = check_identity_consistency(metadata, claude, claude_plugin)
if identity_errors:
print("Plugin manifest identity is inconsistent:", file=sys.stderr)
for err in identity_errors:
print(f" - {err}", file=sys.stderr)
return 1

targets = {
CURSOR_MARKETPLACE: render_json(build_cursor_marketplace(metadata, claude)),
CURSOR_PLUGIN: render_json(build_cursor_plugin(metadata, claude_plugin)),
}

stale = [path for path, content in targets.items() if not write_or_check(path, content, check=args.check)]

if args.check:
if stale:
for path in stale:
print(f"{path.relative_to(ROOT)} is out of date.", file=sys.stderr)
print(
"Run: uv run .github/scripts/generate_cursor_marketplace.py",
file=sys.stderr,
)
return 1
print("Cursor plugin manifests are up to date.")
return 0

for path in targets:
print(f"Wrote {path.relative_to(ROOT)}")
return 0


if __name__ == "__main__":
raise SystemExit(main())
47 changes: 47 additions & 0 deletions .github/scripts/publish.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Regenerate every committed artifact derived from skills/ and the
# canonical marketplace + metadata sources.
#
# Usage:
# ./.github/scripts/publish.sh Regenerate all derived artifacts.
# ./.github/scripts/publish.sh --check Verify derived artifacts are up to date.
# ./.github/scripts/publish.sh -h|--help Print this help.
#
# Currently regenerates:
# - .cursor-plugin/marketplace.json (mirror of .claude-plugin/marketplace.json)
# - .cursor-plugin/plugin.json (derived from .claude-plugin/plugin.json
# + plugin-metadata.json)
#
# The `.claude-plugin/` manifests are hand-maintained because the human-facing
# plugin description intentionally differs from the SKILL.md routing
# descriptions; ./.github/scripts/check.sh enforces that they stay consistent
# with plugin-metadata.json.
#
# Requires `uv` (https://github.com/astral-sh/uv).

set -euo pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"

usage() {
sed -n 's/^# \{0,1\}//p' "${BASH_SOURCE[0]}" | sed -n '/^Usage:/,/^Requires/p'
}

case "${1:-}" in
"")
uv run .github/scripts/generate_cursor_marketplace.py
echo "Publish artifacts generated successfully."
;;
--check)
uv run .github/scripts/generate_cursor_marketplace.py --check
;;
-h|--help)
usage
;;
*)
echo "Unknown option: $1" >&2
echo "Run with --help for usage." >&2
exit 2
;;
esac
Loading
Loading