diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..f4ddf47 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,24 @@ +{ + "name": "amd-skills", + "owner": { + "name": "AMD" + }, + "metadata": { + "description": "Agent Skills for AMD-optimized workflows", + "version": "0.1.0" + }, + "plugins": [ + { + "name": "local-ai-app-integration", + "source": "./skills/local-ai-app-integration", + "skills": "./", + "description": "Integrate local AI into cloud LLM apps for offline support, better privacy, and lower API costs." + }, + { + "name": "local-ai-use", + "source": "./skills/local-ai-use", + "skills": "./", + "description": "Route image generation, text-to-speech, and speech-to-text through a local AI Server to reduce token/cost usage." + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..6cf15c3 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,21 @@ +{ + "name": "amd-skills", + "description": "Agent Skills for AMD-optimized workflows.", + "version": "0.1.0", + "author": { + "name": "AMD" + }, + "homepage": "https://github.com/amd/skills", + "repository": "https://github.com/amd/skills", + "license": "MIT", + "keywords": [ + "amd", + "rocm", + "hip", + "ryzen-ai", + "migraphx", + "vllm", + "lemonade", + "local-ai" + ] +} diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json new file mode 100644 index 0000000..c8bb3e6 --- /dev/null +++ b/.cursor-plugin/marketplace.json @@ -0,0 +1,18 @@ +{ + "name": "amd-skills", + "owner": { + "name": "AMD" + }, + "metadata": { + "description": "Agent Skills for AMD-optimized workflows.", + "version": "0.1.0" + }, + "plugins": [ + { + "name": "amd-skills", + "source": ".", + "skills": "skills", + "description": "Agent Skills for AMD-optimized workflows." + } + ] +} diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json new file mode 100644 index 0000000..cf0287b --- /dev/null +++ b/.cursor-plugin/plugin.json @@ -0,0 +1,22 @@ +{ + "name": "amd-skills", + "skills": "skills", + "description": "Agent Skills for AMD-optimized workflows.", + "version": "0.1.0", + "author": { + "name": "AMD" + }, + "homepage": "https://github.com/amd/skills", + "repository": "https://github.com/amd/skills", + "license": "MIT", + "keywords": [ + "amd", + "rocm", + "hip", + "ryzen-ai", + "migraphx", + "vllm", + "lemonade", + "local-ai" + ] +} diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 27c09fb..fd6aef7 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -8,12 +8,14 @@ on: - "scripts/**" - "**/SKILL.md" - "skills/**" + - ".claude-plugin/**" + - ".cursor-plugin/**" - ".github/workflows/validate.yml" workflow_dispatch: jobs: validate: - name: Validate skills + name: Validate skills and plugin manifests runs-on: ubuntu-latest steps: - name: Check out repository @@ -22,5 +24,5 @@ jobs: - name: Set up uv uses: astral-sh/setup-uv@v7 - - name: Validate skills (size + standardized format) + - name: Validate skills and generated manifests run: ./scripts/check.sh diff --git a/AUTHORING.md b/AUTHORING.md index 5572da5..40a0c3f 100644 --- a/AUTHORING.md +++ b/AUTHORING.md @@ -155,7 +155,7 @@ Test the skill the way users will hit it: The structural rules from this guide (frontmatter shape, name format, description length, and `SKILL.md` body size) are enforced by `scripts/validate_skills.py` and run on every pull request. Run them locally before pushing: ```bash -./scripts/check.sh # validates every skill (same command CI runs) +./scripts/check.sh # validates every skill and plugin manifests (same command CI runs) ``` The validator checks every skill under `skills/` for: @@ -163,4 +163,9 @@ The validator checks every skill under `skills/` for: - a `SKILL.md` file with a valid YAML frontmatter block - `name`: lowercase-with-hyphens, ≤ 64 characters, no `anthropic` / `claude` substrings, matches the directory name - `description`: non-empty, ≤ 1024 characters -- `SKILL.md` body: ≤ 500 lines (push longer reference material into sibling files) +- `SKILL.md` body: ≤ 500 lines + +It also checks the plugin manifests: + +- every skill under `skills/` has a matching entry in `.claude-plugin/marketplace.json` (and vice versa), with `source` set to `./skills/` and a non-empty human-readable `description` +- `.cursor-plugin/plugin.json` is up to date with `.claude-plugin/plugin.json` and the discovered skills (regenerate with `./scripts/publish.sh`) (push longer reference material into sibling files) diff --git a/README.md b/README.md index d4faddc..1d55afd 100644 --- a/README.md +++ b/README.md @@ -64,8 +64,8 @@ Embed AMD-optimized AI into end-user applications. | Skill | What it does | | --- | --- | -| `local-ai-app-integration` | Add private, on-device AI to apps that use OpenAI, Anthropic, or Ollama APIs by bundling Embeddable Lemonade as a subprocess. | -| `local-ai-use` | Apply a Lemonade-first strategy so agents default to local image generation, text-to-speech, and speech-to-text to reduce token/cost usage before any cloud fallback. | +| `local-ai-app-integration` | Integrate local AI into cloud LLM apps for offline support, better privacy, and lower API costs. | +| `local-ai-use` | Route image generation, text-to-speech, and speech-to-text through a local AI Server to reduce token/cost usage. | ### Cross-stack porting @@ -151,11 +151,11 @@ scripts/ # Tooling for publishing and regenerating manifests ## Installation -Detailed install steps for each supported agent will land alongside the first published skills. The general flow: +AMD Skills are compatible with Cursor, Claude Code, OpenAI Codex, and Gemini CLI. The general flow: ### Cursor -Install the AMD plugin from this repository through the Cursor plugin flow. The repo ships a `.cursor-plugin/plugin.json` and an `.mcp.json` so skills are discoverable as soon as the plugin is enabled. +Install the AMD plugin from this repository through the Cursor plugin flow. The repo ships a `.cursor-plugin/plugin.json` so skills are discoverable as soon as the plugin is enabled. ### Claude Code @@ -172,7 +172,7 @@ Copy or symlink the desired folders from `skills/` into one of Codex's standard ### Gemini CLI -A `gemini-extension.json` is provided so the repo can be installed as a Gemini CLI extension: +A `gemini-extension.json` will be provided so the repo can be installed as a Gemini CLI extension: ```bash gemini extensions install https://github.com/amd/skills.git --consent @@ -201,12 +201,16 @@ Best for cross-cutting skills that do not have a natural product home. 1. Copy an existing skill folder under `skills/` as a starting point and rename it. 2. Update the `SKILL.md` frontmatter so the `name` and `description` clearly explain *what* the skill does and *when* an agent should reach for it. 3. Add the supporting scripts, templates, and reference docs your instructions point to. Keep skills focused: one well-scoped task per skill is better than one mega-skill. -4. Register the skill in `.claude-plugin/marketplace.json` with a human-readable description. -5. Validate the skill locally before pushing: +4. Register the skill in `.claude-plugin/marketplace.json` with a human-readable description (the marketplace description is for humans browsing the catalog; the `SKILL.md` description is what the agent uses for routing). +5. Regenerate the Cursor manifest so it tracks the new skill: ```bash - ./scripts/check.sh # validates every SKILL.md + ./scripts/publish.sh # writes .cursor-plugin/plugin.json ``` -6. Open a pull request. The `validate` GitHub Actions workflow runs `./scripts/check.sh` and must pass before merge. See [AUTHORING.md](AUTHORING.md#validating-locally) for the full set of enforced rules. +6. Validate the skill locally before pushing: + ```bash + ./scripts/check.sh # validates every SKILL.md and that manifests are in sync + ``` +7. Open a pull request. The `validate` GitHub Actions workflow runs `./scripts/check.sh` and must pass before merge. See [AUTHORING.md](AUTHORING.md#validating-locally) for the full set of enforced rules. ### Path B: Skills authored in a product repository diff --git a/scripts/check.sh b/scripts/check.sh index 9f51803..93f7d3b 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -1,17 +1,11 @@ #!/usr/bin/env bash -# Validate every SKILL.md in the catalog. +# Validate every SKILL.md and that generated plugin manifests are up to date. # # Usage: -# ./scripts/check.sh Validate every skill. +# ./scripts/check.sh Validate every skill and check manifests. # ./scripts/check.sh -h|--help Print this help. # # Requires `uv` (https://github.com/astral-sh/uv). -# -# Note: this repo does not publish anything yet. When we add catalog -# manifests (.cursor-plugin/plugin.json, .claude-plugin/marketplace.json, -# .mcp.json, etc.) we'll add a separate scripts/publish.sh for generation -# and check.sh will gain a `--check` mode that diffs the regenerated -# artifacts against the committed copy. set -euo pipefail @@ -25,6 +19,7 @@ usage() { case "${1:-}" in "") uv run scripts/validate_skills.py + uv run scripts/generate_cursor_plugin.py --check ;; -h|--help) usage diff --git a/scripts/generate_cursor_plugin.py b/scripts/generate_cursor_plugin.py new file mode 100644 index 0000000..5308684 --- /dev/null +++ b/scripts/generate_cursor_plugin.py @@ -0,0 +1,166 @@ +#!/usr/bin/env -S uv run --quiet +# /// script +# requires-python = ">=3.10" +# dependencies = [] +# /// +"""Generate Cursor plugin manifest from existing repo metadata. + +Outputs: +- .cursor-plugin/plugin.json + +Design goals: +- Keep Claude + Cursor metadata in sync. +- Reuse `.claude-plugin/plugin.json` as the primary metadata source. +- Discover skills from `skills/*/SKILL.md` so the manifest tracks the + catalog automatically. + +Usage: + uv run scripts/generate_cursor_plugin.py # write + uv run scripts/generate_cursor_plugin.py --check # validate only +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +CLAUDE_PLUGIN_MANIFEST = ROOT / ".claude-plugin" / "plugin.json" +CURSOR_PLUGIN_DIR = ROOT / ".cursor-plugin" +CURSOR_PLUGIN_MANIFEST = CURSOR_PLUGIN_DIR / "plugin.json" + +# Fields copied verbatim from the Claude plugin manifest into the Cursor +# manifest so the two stay in lock-step. +COPIED_FIELDS = ( + "description", + "version", + "author", + "homepage", + "repository", + "license", + "keywords", + "logo", +) + +PLUGIN_NAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$") + + +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 parse_frontmatter(text: str) -> dict[str, str]: + """Return the YAML frontmatter at the top of `text` as a flat mapping. + + Only top-level scalar keys are extracted. That is sufficient for `name`, + which is all this script needs. + """ + match = re.search(r"^---\s*\n(.*?)\n---\s*", text, re.DOTALL) + if not match: + return {} + data: dict[str, str] = {} + for line in match.group(1).splitlines(): + # Skip continuation lines so multi-line `description: >-` values + # don't get parsed as keys. + if ":" not in line or line.startswith((" ", "\t")): + continue + key, value = line.split(":", 1) + data[key.strip()] = value.strip() + return data + + +def collect_skills() -> list[str]: + skills: list[str] = [] + for skill_md in sorted(ROOT.glob("skills/*/SKILL.md")): + meta = parse_frontmatter(skill_md.read_text(encoding="utf-8")) + name = meta.get("name", "").strip() + if not name: + continue + skills.append(name) + return skills + + +def validate_plugin_name(name: str) -> None: + if not PLUGIN_NAME_RE.match(name): + raise ValueError( + "Invalid plugin name in .claude-plugin/plugin.json: " + f"'{name}'. Must be lowercase and match {PLUGIN_NAME_RE.pattern}" + ) + + +def build_cursor_plugin_manifest() -> dict: + src = load_json(CLAUDE_PLUGIN_MANIFEST) + + name = src.get("name") + if not isinstance(name, str) or not name: + raise ValueError(".claude-plugin/plugin.json must define a non-empty 'name'") + validate_plugin_name(name) + + skills = collect_skills() + if not skills: + raise ValueError("No skills discovered under skills/*/SKILL.md") + + manifest: dict = {"name": name, "skills": "skills"} + for key in COPIED_FIELDS: + if key in src: + manifest[key] = src[key] + + return manifest + + +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. + + In write mode (check=False) the file is written first, so the return + value is always True in that branch. + """ + 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() -> None: + parser = argparse.ArgumentParser( + description="Generate Cursor plugin manifest from .claude-plugin/plugin.json" + ) + parser.add_argument( + "--check", + action="store_true", + help="Validate the generated manifest is up to date without writing changes.", + ) + args = parser.parse_args() + + plugin_manifest = render_json(build_cursor_plugin_manifest()) + ok_plugin = write_or_check(CURSOR_PLUGIN_MANIFEST, plugin_manifest, check=args.check) + + if args.check: + if not ok_plugin: + print("Generated Cursor manifest is out of date:", file=sys.stderr) + print(f" - {CURSOR_PLUGIN_MANIFEST.relative_to(ROOT)}", file=sys.stderr) + print("Run: uv run scripts/generate_cursor_plugin.py", file=sys.stderr) + sys.exit(1) + + print("Cursor plugin manifest is up to date.") + return + + print(f"Wrote {CURSOR_PLUGIN_MANIFEST.relative_to(ROOT)}") + + +if __name__ == "__main__": + main() diff --git a/scripts/publish.sh b/scripts/publish.sh new file mode 100644 index 0000000..dbb78e6 --- /dev/null +++ b/scripts/publish.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Regenerate every committed artifact derived from skills/ and the +# Claude plugin manifest. +# +# Usage: +# ./scripts/publish.sh Regenerate all derived artifacts. +# ./scripts/publish.sh --check Verify derived artifacts are up to date. +# ./scripts/publish.sh -h|--help Print this help. +# +# Currently regenerates: +# - .cursor-plugin/plugin.json (from .claude-plugin/plugin.json + skills/) +# +# `.claude-plugin/marketplace.json` and `.cursor-plugin/marketplace.json` +# are hand-maintained because their human-facing descriptions intentionally +# differ from the SKILL.md routing descriptions; ./scripts/check.sh enforces +# that the marketplace listing matches skills/ on disk. +# +# 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 scripts/generate_cursor_plugin.py + echo "Publish artifacts generated successfully." + ;; + --check) + uv run scripts/generate_cursor_plugin.py --check + ;; + -h|--help) + usage + ;; + *) + echo "Unknown option: $1" >&2 + echo "Run with --help for usage." >&2 + exit 2 + ;; +esac diff --git a/scripts/validate_skills.py b/scripts/validate_skills.py index afec27b..55fbfb8 100644 --- a/scripts/validate_skills.py +++ b/scripts/validate_skills.py @@ -14,6 +14,10 @@ - `description` is a non-empty string <=1024 chars - SKILL.md body is <=500 lines +Also validates that `.claude-plugin/marketplace.json` is in sync with the +skills on disk: every skill must have a marketplace entry, and every +marketplace entry must point at an existing skill. + Run from the repo root: ./scripts/check.sh # used by CI; thin wrapper @@ -26,6 +30,7 @@ from __future__ import annotations import argparse +import json import re import sys from dataclasses import dataclass, field @@ -35,6 +40,7 @@ REPO_ROOT = Path(__file__).resolve().parent.parent DEFAULT_SKILLS_DIR = REPO_ROOT / "skills" +CLAUDE_MARKETPLACE = REPO_ROOT / ".claude-plugin" / "marketplace.json" # Limits from AUTHORING.md and the standardized Agent Skills format. MAX_NAME_LEN = 64 @@ -153,6 +159,74 @@ def discover_skills(root: Path) -> list[Path]: ) +def validate_claude_marketplace(skill_dirs: list[Path]) -> list[str]: + """Return error strings if marketplace entries don't match skills/ on disk. + + The marketplace's human-readable `description` is intentionally allowed + to differ from the SKILL.md description (per AUTHORING.md), so this only + enforces that names and source paths line up. + """ + if not CLAUDE_MARKETPLACE.exists(): + return [ + f"Missing {CLAUDE_MARKETPLACE.relative_to(REPO_ROOT)}; expected one " + "entry per skill." + ] + + try: + data = json.loads(CLAUDE_MARKETPLACE.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + return [f"{CLAUDE_MARKETPLACE.relative_to(REPO_ROOT)}: invalid JSON: {exc}"] + + plugins = data.get("plugins") if isinstance(data, dict) else None + if not isinstance(plugins, list): + return [ + f"{CLAUDE_MARKETPLACE.relative_to(REPO_ROOT)}: top-level `plugins` " + "array is missing." + ] + + errors: list[str] = [] + skill_names = {p.name for p in skill_dirs} + listed_names: set[str] = set() + + for idx, entry in enumerate(plugins): + if not isinstance(entry, dict): + errors.append(f"plugins[{idx}] must be an object.") + continue + name = entry.get("name") + source = entry.get("source") + description = entry.get("description") + + if not isinstance(name, str) or not name: + errors.append(f"plugins[{idx}] is missing a non-empty `name`.") + continue + listed_names.add(name) + + if name not in skill_names: + errors.append( + f"plugins[{idx}] (`{name}`) has no matching directory under skills/." + ) + continue + + expected_source = f"./skills/{name}" + if source != expected_source: + errors.append( + f"plugins[{idx}] (`{name}`): `source` must be `{expected_source}`, " + f"got `{source}`." + ) + if not isinstance(description, str) or not description.strip(): + errors.append( + f"plugins[{idx}] (`{name}`) is missing a non-empty `description`." + ) + + for missing in sorted(skill_names - listed_names): + errors.append( + f"skills/{missing} has no entry in " + f"{CLAUDE_MARKETPLACE.relative_to(REPO_ROOT)}." + ) + + return errors + + def run(skills_dir: Path) -> int: skills = discover_skills(skills_dir) if not skills: @@ -169,6 +243,13 @@ def run(skills_dir: Path) -> int: print(f" {err}") total_errors += len(report.errors) + marketplace_errors = validate_claude_marketplace(skills) + marketplace_status = "OK " if not marketplace_errors else "FAIL" + print(f"\n[{marketplace_status}] .claude-plugin/marketplace.json") + for err in marketplace_errors: + print(f" {err}") + total_errors += len(marketplace_errors) + print(f"\nSummary: {total_errors} error(s) across {len(skills)} skill(s)") return 0 if total_errors == 0 else 1