diff --git a/AGENTS.md b/AGENTS.md
index 7fd3e18..18a1399 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -68,6 +68,7 @@ If told an implementation was wrong, apply the correction and then record what w
User-facing skills live under `skills/`:
+- `rhdh-templates` — Software Templates authoring and validation
- `skill-maker` — Create, audit, and consolidate Agent Skills
When adding a skill, update [README.md](./README.md) and keep `SKILL.md` `name` aligned with the directory name per the Agent Skills spec.
diff --git a/README.md b/README.md
index b5dfadb..5f482f2 100644
--- a/README.md
+++ b/README.md
@@ -9,9 +9,35 @@ Agent Skills for adopting and using [Red Hat Developer Hub](https://developers.r
## What's included
-| Skill | Use when you want to… |
-| ----- | --------------------- |
-| [skill-maker](./skills/skill-maker/SKILL.md) | Create, audit, and consolidate Agent Skills following the open standard |
+| Skill | Use when you want to… |
+| -------------------------------------------------- | ----------------------------------------------------------------------- |
+| [rhdh-templates](./skills/rhdh-templates/SKILL.md) | Author, validate, and test RHDH Software Templates (Scaffolder) |
+| [skill-maker](./skills/skill-maker/SKILL.md) | Create, audit, and consolidate Agent Skills following the open standard |
+
+### Software Templates (`rhdh-templates`)
+
+Interactive authoring for RHDH Scaffolder templates — templatize an existing repo, create from scratch, fix common gotchas, and validate locally or against a running instance.
+
+- **[rhdh-templates](./skills/rhdh-templates/SKILL.md)** — Interactive authoring and validation for Software Templates. Includes curated reference catalog (official library + AI quickstarts), worked examples (`nodejs-backend`, `java-springboot`) and bundled JSON Schema validation. Sub-commands:
+ - **[init](./skills/rhdh-templates/references/init.md)** — Check tooling, scaffold template repo layout, optional RHDH connectivity.
+ - **[templatize](./skills/rhdh-templates/references/templatize.md)** — Convert existing codebase into a parameterized template.
+ - **[create](./skills/rhdh-templates/references/create.md)** — Guided from-scratch template authoring when no reference code exists.
+ - **[add-parameter](./skills/rhdh-templates/references/add-parameter.md)** — Add a parameter or parameter group to existing `template.yaml`.
+ - **[add-step](./skills/rhdh-templates/references/add-step.md)** — Add a scaffolder step to existing `template.yaml`.
+ - **[add-skeleton](./skills/rhdh-templates/references/add-skeleton.md)** — Add or parameterize skeleton files with Nunjucks.
+ - **[create-location](./skills/rhdh-templates/references/create-location.md)** — Generate or update root `location.yaml` for catalog registration.
+ - **[fix-gotchas](./skills/rhdh-templates/references/fix-gotchas.md)** — Auto-fix common RHDH template mistakes (raw/endraw blocks, catalog-info path, etc.).
+ - **[validate](./skills/rhdh-templates/references/validate.md)** — Local YAML schema, gotcha validation, and optional Nunjucks lint via `--lint-skeleton` (no RHDH required).
+ - **[list-actions](./skills/rhdh-templates/references/list-actions.md)** — List available Scaffolder actions from a running RHDH instance.
+ - **[dry-run](./skills/rhdh-templates/references/dry-run.md)** — Test template execution via Scaffolder v2 dry-run API.
+ - **[explain-action](./skills/rhdh-templates/references/explain-action.md)** — Show action input schema or template parameter schema.
+ - **[example-catalog](./skills/rhdh-templates/references/example-catalog.md)** — Browse curated reference templates (official library, AI quickstarts, bundled).
+
+Example prompts:
+
+- "Help me turn this Node.js repo into an RHDH Software Template"
+- "Validate my `template.yaml` and fix Scaffolder gotchas"
+- "List scaffolder actions available on my RHDH instance"
### Agent Skills authoring (`skill-maker`)
@@ -29,9 +55,10 @@ Example prompts:
npx skills add redhat-developer/rhdh-users-skill-pack
```
-Or install only this skill:
+Or install only one skill:
```bash
+npx skills add redhat-developer/rhdh-users-skill-pack --skill rhdh-templates
npx skills add redhat-developer/rhdh-users-skill-pack --skill skill-maker
```
@@ -61,11 +88,12 @@ npx skills add ./rhdh-users-skill-pack
1. **Install** the pack (see above).
2. **Open your project** in an agent-enabled editor or CLI.
-3. **Describe your goal in plain language** — for example, "help me write a skill for our RHDH golden paths."
+3. **Describe your goal in plain language** — for example, "help me turn this repo into an RHDH Software Template."
You can also name the skill explicitly:
```
+Use the rhdh-templates skill to validate my template.yaml
Use the skill-maker skill to audit my SKILL.md
```
diff --git a/pyproject.toml b/pyproject.toml
index f5ccd93..89ace8b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -12,6 +12,7 @@ requires-python = ">=3.10"
dev = [
"pytest>=9.0.3",
"pyyaml>=6.0", # SKILL.md structure tests
+ "jsonschema>=4.0",
"ruff>=0.4.0",
]
diff --git a/skills/rhdh-templates/SKILL.md b/skills/rhdh-templates/SKILL.md
new file mode 100644
index 0000000..19c7660
--- /dev/null
+++ b/skills/rhdh-templates/SKILL.md
@@ -0,0 +1,139 @@
+---
+name: rhdh-templates
+description: >-
+ Author and validate RHDH Software Templates (Scaffolder) with AI-guided workflows. Use when
+ asked to "create software template", "templatize a codebase", "convert repo to
+ template", "write template.yaml", "location.yaml for templates", "scaffolder
+ template", "golden path template", "parameterize skeleton files", "fix template
+ gotchas", "validate template", "dry-run template", "list scaffolder actions",
+ "explain scaffolder action", "Nunjucks in template", "template best practices",
+ "reference templates", "example templates", "what templates are commonly used",
+ "Template Editor", or mentions RHDH template
+ authoring, Software Catalog templates, or /rhdh-templates commands. Covers setup,
+ templatize (highest value), from-scratch create, reference example discovery,
+ incremental parameter/step/skeleton authoring, location.yaml generation, common
+ convention fixes, local validation, and live Scaffolder API dry-run/action discovery.
+---
+
+
+
+## Domain
+
+Software Templates are `kind: Template` entities processed by the Scaffolder. Each template has:
+
+- `template.yaml` — metadata, `spec.parameters` (form), `spec.steps` (actions), `spec.output`
+- `skeleton/` — files copied/templated into the target repo (Nunjucks `{% raw %}` blocks where needed)
+- `location.yaml` (repo root) — `kind: Location` registering all `template.yaml` files for catalog import
+
+Read `references/conventions.md` before editing any template artifact — it encodes RHDH-specific rules that differ from generic Backstage docs.
+
+Read `references/best-practices.md` when authoring or reviewing templates — it encodes Red Hat's 10 tips for repository layout, Template Editor workflow, custom fields, Nunjucks, secrets, type/tags, TechDocs, and maintenance.
+
+## Authoring stance
+
+- **Interactive, not fully automatic.** Templatize proposes parameterization; the user confirms each literal-to-parameter mapping.
+- **Conservative parameterization.** Under-parameterize rather than expose every string — users can add parameters incrementally.
+- **First-try correctness.** Generated artifacts should pass local `validate` with zero critical findings before merge.
+
+## Script paths
+
+All `scripts/` and `references/` paths are relative to this SKILL.md directory. Resolve them before invoking.
+
+
+
+
+
+## Setup gates (non-optional before file edits)
+
+| Gate | Required check | If fail |
+|------|----------------|---------|
+| Command | Sub-command reference loaded | Load the matching `references/.md` (or `example-catalog.md` for `examples`) |
+| Layout | Template project initialized or path confirmed | Run `init` or ask user for template repo root |
+| Conventions | `references/conventions.md` read for authoring commands | Read it first |
+
+
+
+
+
+## What would you like to do?
+
+| # | Command |
+|---|---------|
+| 1 | `init` |
+| 2 | `templatize` |
+| 3 | `create` |
+| 4 | `add-parameter` |
+| 5 | `add-step` |
+| 6 | `add-skeleton` |
+| 7 | `create-location` |
+| 8 | `fix-gotchas` |
+| 9 | `validate` |
+| 10 | `list-actions` |
+| 11 | `dry-run` |
+| 12 | `explain-action` |
+| 13 | `examples` |
+
+Command descriptions and argument hints: `scripts/command-metadata.json`
+
+**Wait for response before proceeding.**
+
+
+
+
+
+| Response | Reference |
+|----------|-----------|
+| 1, "init", "setup", "scaffold", "prerequisites" | [references/init.md](references/init.md) |
+| 2, "templatize", "convert", "parameterize repo", "existing codebase" | [references/templatize.md](references/templatize.md) |
+| 3, "create", "from scratch", "new template" | [references/create.md](references/create.md) |
+| 4, "add-parameter", "add parameter", "form field" | [references/add-parameter.md](references/add-parameter.md) |
+| 5, "add-step", "add step", "scaffolder action", "pipeline step" | [references/add-step.md](references/add-step.md) |
+| 6, "add-skeleton", "skeleton file", "nunjucks" | [references/add-skeleton.md](references/add-skeleton.md) |
+| 7, "create-location", "location.yaml", "register templates" | [references/create-location.md](references/create-location.md) |
+| 8, "fix-gotchas", "fix template", "gotchas", "raw endraw" | [references/fix-gotchas.md](references/fix-gotchas.md) |
+| 9, "validate", "lint template", "check template", "lint-nunjucks", "lint nunjucks", "djlint", "nunjucks lint" | [references/validate.md](references/validate.md) |
+| 10, "list-actions", "list actions", "scaffolder actions" | [references/list-actions.md](references/list-actions.md) |
+| 11, "dry-run", "dry run", "test template remotely" | [references/dry-run.md](references/dry-run.md) |
+| 12, "explain-action", "action schema", "parameter schema" | [references/explain-action.md](references/explain-action.md) |
+| 13, "examples", "reference templates", "show me templates", "what templates exist" | [references/example-catalog.md](references/example-catalog.md) |
+| First word doesn't match | Infer from context. "Turn my Spring Boot app into a template" → `templatize`. "Add owner picker to my template" → `add-parameter`. "Does my template validate?" → `validate`. "What example templates exist?" → `examples`. |
+
+
+
+
+
+## Bundled scripts
+
+```bash
+# Resolve skill directory (adjust if SKILL.md path differs)
+SKILL_DIR="/skills/rhdh-templates"
+
+python "$SKILL_DIR/scripts/init.py" --help
+python "$SKILL_DIR/scripts/analyze.py" --help
+python "$SKILL_DIR/scripts/create_location.py" --help
+python "$SKILL_DIR/scripts/fix_gotchas.py" --help
+python "$SKILL_DIR/scripts/validate.py" --help
+python "$SKILL_DIR/scripts/list_actions.py" --help
+python "$SKILL_DIR/scripts/dry_run.py" --help
+python "$SKILL_DIR/scripts/explain_action.py" --help
+python "$SKILL_DIR/scripts/list_examples.py" --help
+```
+
+Run `init.py` for deterministic tooling checks and project scaffolding. Use `analyze.py` during `templatize` Phase 1. Use `list_examples.py` during `create`, `templatize`, or `examples` to rank upstream reference templates. Use `create_location.py` and `fix_gotchas.py` where the reference files direct you — they produce structured JSON when piped.
+
+Validation scripts: `validate.py` for local checks (include `--lint-skeleton` for Nunjucks/djLint); `list_actions.py`, `dry_run.py`, and `explain_action.py` require a reachable RHDH `--rhdh-url` and optional bearer token (`RHDH_TOKEN` env or `--token`).
+
+
+
+
+
+| File | Load when... |
+|------|-------------|
+| `references/conventions.md` | Any authoring command — RHDH template rules |
+| `references/best-practices.md` | Authoring/review — Red Hat 10 tips and pre-merge checklist |
+| `references/template-structure.md` | Writing or reviewing `template.yaml` anatomy |
+| `references/parameter-widgets.md` | Choosing form fields and UI widgets for parameters |
+| `references/example-catalog.md` | Command `examples` or picking upstream study references (`assets/example-catalog.json` is the data source; bundled templates under `assets/examples/`) |
+| `references/schemas/template-v1beta3.schema.json` | Bundled JSON Schema for deep `validate` checks |
+
+
diff --git a/skills/rhdh-templates/assets/example-catalog.json b/skills/rhdh-templates/assets/example-catalog.json
new file mode 100644
index 0000000..e9b4615
--- /dev/null
+++ b/skills/rhdh-templates/assets/example-catalog.json
@@ -0,0 +1,386 @@
+{
+ "version": 1,
+ "disclaimer": "Upstream templates are learning aids, not production-ready. Validate, test, and customize before use in your organization.",
+ "sources": [
+ {
+ "id": "rhdh-software-templates",
+ "repo": "redhat-developer/red-hat-developer-hub-software-templates",
+ "url": "https://github.com/redhat-developer/red-hat-developer-hub-software-templates",
+ "description": "Official RHDH Software Templates library (registered via templates.yaml)",
+ "official": true
+ },
+ {
+ "id": "ai-quickstart-templates",
+ "repo": "redhat-developer/aiquickstarttemplates",
+ "url": "https://github.com/redhat-developer/aiquickstarttemplates",
+ "description": "Red Hat AI Quickstart Backstage templates for OpenShift AI",
+ "official": true
+ },
+ {
+ "id": "ai-lab-template",
+ "repo": "redhat-ai-dev/ai-lab-template",
+ "url": "https://github.com/redhat-ai-dev/ai-lab-template",
+ "description": "Podman Desktop AI Lab sample templates (RAG, chatbot, codegen, etc.)",
+ "official": false
+ },
+ {
+ "id": "bundled",
+ "repo": "redhat-developer/rhdh-skill",
+ "url": "https://github.com/redhat-developer/rhdh-skill/tree/main/skills/rhdh-templates/assets/examples",
+ "description": "Minimal worked examples shipped with the rhdh-templates skill for local validation",
+ "official": true
+ }
+ ],
+ "examples": [
+ {
+ "id": "go-backend",
+ "title": "Create a Go Backend application with a CI pipeline",
+ "source": "rhdh-software-templates",
+ "path": "templates/github/go-backend",
+ "url": "https://github.com/redhat-developer/red-hat-developer-hub-software-templates/tree/main/templates/github/go-backend",
+ "category": "backend",
+ "tags": ["recommended", "go", "backend", "ci", "github"],
+ "stack": ["go"],
+ "use_cases": ["golden path backend service", "new microservice with pipeline"],
+ "recommended": true,
+ "in_templates_yaml": true,
+ "local_bundled": null
+ },
+ {
+ "id": "nodejs-backend",
+ "title": "Create a Node.js Backend application with a CI pipeline",
+ "source": "rhdh-software-templates",
+ "path": "templates/github/nodejs-backend",
+ "url": "https://github.com/redhat-developer/red-hat-developer-hub-software-templates/tree/main/templates/github/nodejs-backend",
+ "category": "backend",
+ "tags": ["recommended", "nodejs", "javascript", "typescript", "express", "backend", "ci", "github"],
+ "stack": ["nodejs"],
+ "use_cases": ["nodejs api service", "express backend with publish and register"],
+ "recommended": true,
+ "in_templates_yaml": true,
+ "local_bundled": "nodejs-backend"
+ },
+ {
+ "id": "spring-boot-backend",
+ "title": "Create a Spring Boot Backend application with a CI pipeline",
+ "source": "rhdh-software-templates",
+ "path": "templates/github/spring-boot-backend",
+ "url": "https://github.com/redhat-developer/red-hat-developer-hub-software-templates/tree/main/templates/github/spring-boot-backend",
+ "category": "backend",
+ "tags": ["recommended", "spring-boot", "java", "maven", "backend", "ci", "github"],
+ "stack": ["java", "spring-boot"],
+ "use_cases": ["java spring boot service", "maven backend golden path"],
+ "recommended": true,
+ "in_templates_yaml": true,
+ "local_bundled": "java-springboot"
+ },
+ {
+ "id": "quarkus-backend",
+ "title": "Create a Quarkus Backend application with a CI pipeline",
+ "source": "rhdh-software-templates",
+ "path": "templates/github/quarkus-backend",
+ "url": "https://github.com/redhat-developer/red-hat-developer-hub-software-templates/tree/main/templates/github/quarkus-backend",
+ "category": "backend",
+ "tags": ["quarkus", "java", "maven", "backend", "ci", "github"],
+ "stack": ["java", "quarkus"],
+ "use_cases": ["quarkus microservice", "cloud-native java backend"],
+ "recommended": false,
+ "in_templates_yaml": true,
+ "local_bundled": null
+ },
+ {
+ "id": "python-backend-gitlab",
+ "title": "Create a Python backend application in GitLab with a CI pipeline",
+ "source": "rhdh-software-templates",
+ "path": "templates/gitlab/python-backend",
+ "url": "https://github.com/redhat-developer/red-hat-developer-hub-software-templates/tree/main/templates/gitlab/python-backend",
+ "category": "backend",
+ "tags": ["python", "flask", "backend", "ci", "gitlab"],
+ "stack": ["python"],
+ "use_cases": ["python api on gitlab", "flask backend service"],
+ "recommended": false,
+ "in_templates_yaml": true,
+ "local_bundled": null
+ },
+ {
+ "id": "dotnet-frontend",
+ "title": "Create a .NET Frontend application with a CI pipeline",
+ "source": "rhdh-software-templates",
+ "path": "templates/azure/dotnet-frontend",
+ "url": "https://github.com/redhat-developer/red-hat-developer-hub-software-templates/tree/main/templates/azure/dotnet-frontend",
+ "category": "frontend",
+ "tags": ["dotnet", "csharp", "frontend", "ci", "azure"],
+ "stack": ["dotnet"],
+ "use_cases": ["dotnet web frontend", "azure devops pipeline"],
+ "recommended": false,
+ "in_templates_yaml": true,
+ "local_bundled": null
+ },
+ {
+ "id": "register-component",
+ "title": "Register existing component to Software Catalog",
+ "source": "rhdh-software-templates",
+ "path": "templates/github/register-component",
+ "url": "https://github.com/redhat-developer/red-hat-developer-hub-software-templates/tree/main/templates/github/register-component",
+ "category": "catalog",
+ "tags": ["recommended", "import", "catalog", "register", "github"],
+ "stack": [],
+ "use_cases": ["import existing repo", "onboard legacy service to catalog"],
+ "recommended": true,
+ "in_templates_yaml": true,
+ "local_bundled": null
+ },
+ {
+ "id": "tekton",
+ "title": "Create a Tekton CI Pipeline",
+ "source": "rhdh-software-templates",
+ "path": "templates/github/tekton",
+ "url": "https://github.com/redhat-developer/red-hat-developer-hub-software-templates/tree/main/templates/github/tekton",
+ "category": "cicd",
+ "tags": ["tekton", "ci", "openshift", "pipeline"],
+ "stack": ["tekton"],
+ "use_cases": ["add tekton pipeline to project", "openshift ci"],
+ "recommended": false,
+ "in_templates_yaml": true,
+ "local_bundled": null
+ },
+ {
+ "id": "argocd",
+ "title": "Add ArgoCD to an existing project",
+ "source": "rhdh-software-templates",
+ "path": "templates/github/argocd",
+ "url": "https://github.com/redhat-developer/red-hat-developer-hub-software-templates/tree/main/templates/github/argocd",
+ "category": "gitops",
+ "tags": ["recommended", "argocd", "gitops", "kubernetes"],
+ "stack": ["argocd"],
+ "use_cases": ["gitops bootstrap", "add argocd app to repo"],
+ "recommended": true,
+ "in_templates_yaml": true,
+ "local_bundled": null
+ },
+ {
+ "id": "define-ansible-job",
+ "title": "Define an Ansible Job Template within Ansible Automation Platform",
+ "source": "rhdh-software-templates",
+ "path": "templates/github/define-ansible-job",
+ "url": "https://github.com/redhat-developer/red-hat-developer-hub-software-templates/tree/main/templates/github/define-ansible-job",
+ "category": "automation",
+ "tags": ["recommended", "ansible", "aap", "automation"],
+ "stack": ["ansible"],
+ "use_cases": ["ansible job template", "aap integration"],
+ "recommended": true,
+ "in_templates_yaml": true,
+ "local_bundled": null
+ },
+ {
+ "id": "launch-ansible-job",
+ "title": "Launch an Ansible Job within Ansible Automation Platform",
+ "source": "rhdh-software-templates",
+ "path": "templates/github/launch-ansible-job",
+ "url": "https://github.com/redhat-developer/red-hat-developer-hub-software-templates/tree/main/templates/github/launch-ansible-job",
+ "category": "automation",
+ "tags": ["ansible", "aap", "automation"],
+ "stack": ["ansible"],
+ "use_cases": ["run ansible job from template", "aap workflow trigger"],
+ "recommended": false,
+ "in_templates_yaml": true,
+ "local_bundled": null
+ },
+ {
+ "id": "techdocs",
+ "title": "Create a TechDocs sample",
+ "source": "rhdh-software-templates",
+ "path": "templates/github/techdocs",
+ "url": "https://github.com/redhat-developer/red-hat-developer-hub-software-templates/tree/main/templates/github/techdocs",
+ "category": "docs",
+ "tags": ["techdocs", "documentation", "mkdocs"],
+ "stack": ["techdocs"],
+ "use_cases": ["documentation starter", "techdocs onboarding"],
+ "recommended": false,
+ "in_templates_yaml": true,
+ "local_bundled": null
+ },
+ {
+ "id": "create-backend-plugin",
+ "title": "Create Backend Plugin Template",
+ "source": "rhdh-software-templates",
+ "path": "templates/create-backend-plugin",
+ "url": "https://github.com/redhat-developer/red-hat-developer-hub-software-templates/tree/main/templates/create-backend-plugin",
+ "category": "plugin",
+ "tags": ["backend-plugin", "backstage-plugin", "dynamic-plugin"],
+ "stack": ["typescript"],
+ "use_cases": ["scaffold rhdh backend plugin", "plugin golden path"],
+ "recommended": false,
+ "in_templates_yaml": true,
+ "local_bundled": null
+ },
+ {
+ "id": "create-frontend-plugin",
+ "title": "Create Frontend Plugin Template",
+ "source": "rhdh-software-templates",
+ "path": "templates/create-frontend-plugin",
+ "url": "https://github.com/redhat-developer/red-hat-developer-hub-software-templates/tree/main/templates/create-frontend-plugin",
+ "category": "plugin",
+ "tags": ["frontend-plugin", "backstage-plugin", "dynamic-plugin"],
+ "stack": ["typescript", "react"],
+ "use_cases": ["scaffold rhdh frontend plugin", "plugin golden path"],
+ "recommended": false,
+ "in_templates_yaml": true,
+ "local_bundled": null
+ },
+ {
+ "id": "obc",
+ "title": "Add OBC to an existing project",
+ "source": "rhdh-software-templates",
+ "path": "templates/github/obc",
+ "url": "https://github.com/redhat-developer/red-hat-developer-hub-software-templates/tree/main/templates/github/obc",
+ "category": "infrastructure",
+ "tags": ["obc", "openshift", "storage"],
+ "stack": ["openshift"],
+ "use_cases": ["object bucket claim", "openshift storage"],
+ "recommended": false,
+ "in_templates_yaml": false,
+ "local_bundled": null
+ },
+ {
+ "id": "sdlc-app",
+ "title": "SDLC environments Application",
+ "source": "rhdh-software-templates",
+ "path": "templates/github/sdlc-app",
+ "url": "https://github.com/redhat-developer/red-hat-developer-hub-software-templates/tree/main/templates/github/sdlc-app",
+ "category": "application",
+ "tags": ["application", "sdlc", "environments"],
+ "stack": [],
+ "use_cases": ["multi-component application", "sdlc environment setup"],
+ "recommended": false,
+ "in_templates_yaml": false,
+ "local_bundled": null
+ },
+ {
+ "id": "enterprise-rag-chatbot",
+ "title": "Enterprise RAG Chatbot",
+ "source": "ai-quickstart-templates",
+ "path": "templates/enterprise-rag-chatbot",
+ "url": "https://github.com/redhat-developer/aiquickstarttemplates/tree/main/templates/enterprise-rag-chatbot",
+ "category": "ai",
+ "tags": ["ai", "rag", "llm", "chatbot", "vector-db", "openshift-ai"],
+ "stack": ["python", "ai", "rag"],
+ "use_cases": ["internal knowledge base", "document q&a chatbot", "enterprise rag"],
+ "recommended": true,
+ "in_templates_yaml": false,
+ "local_bundled": null
+ },
+ {
+ "id": "it-self-service-agent",
+ "title": "IT Self-Service Agent",
+ "source": "ai-quickstart-templates",
+ "path": "templates/it-self-service-agent",
+ "url": "https://github.com/redhat-developer/aiquickstarttemplates/tree/main/templates/it-self-service-agent",
+ "category": "ai",
+ "tags": ["ai", "agent", "llm", "automation", "openshift-ai"],
+ "stack": ["python", "ai", "agent"],
+ "use_cases": ["it automation agent", "self-service helpdesk bot"],
+ "recommended": true,
+ "in_templates_yaml": false,
+ "local_bundled": null
+ },
+ {
+ "id": "llm-cpu-serving",
+ "title": "LLM CPU Serving",
+ "source": "ai-quickstart-templates",
+ "path": "templates/llm-cpu-serving",
+ "url": "https://github.com/redhat-developer/aiquickstarttemplates/tree/main/templates/llm-cpu-serving",
+ "category": "ai",
+ "tags": ["ai", "llm", "cpu", "inference", "openshift-ai"],
+ "stack": ["python", "ai", "llm"],
+ "use_cases": ["lightweight llm endpoint", "dev/test model serving"],
+ "recommended": false,
+ "in_templates_yaml": false,
+ "local_bundled": null
+ },
+ {
+ "id": "generic-ai-quickstart",
+ "title": "Generic AI Quickstart",
+ "source": "ai-quickstart-templates",
+ "path": "templates/generic-ai-quickstart",
+ "url": "https://github.com/redhat-developer/aiquickstarttemplates/tree/main/templates/generic-ai-quickstart",
+ "category": "ai",
+ "tags": ["ai", "quickstart", "openshift-ai", "flexible"],
+ "stack": ["python", "ai"],
+ "use_cases": ["custom ai project bootstrap", "adaptable ai template"],
+ "recommended": false,
+ "in_templates_yaml": false,
+ "local_bundled": null
+ },
+ {
+ "id": "ai-virtual-agent",
+ "title": "AI Virtual Agent",
+ "source": "ai-quickstart-templates",
+ "path": "templates/ai-virtual-agent",
+ "url": "https://github.com/redhat-developer/aiquickstarttemplates/tree/main/templates/ai-virtual-agent",
+ "category": "ai",
+ "tags": ["ai", "agent", "virtual-agent", "openshift-ai"],
+ "stack": ["python", "ai", "agent"],
+ "use_cases": ["virtual assistant deployment", "agentic ai application"],
+ "recommended": false,
+ "in_templates_yaml": false,
+ "local_bundled": null
+ },
+ {
+ "id": "ai-lab-rag",
+ "title": "RAG Chatbot Application",
+ "source": "ai-lab-template",
+ "path": "templates/rag",
+ "url": "https://github.com/redhat-ai-dev/ai-lab-template/tree/main/templates/rag",
+ "category": "ai",
+ "tags": ["ai", "rag", "chatbot", "llm"],
+ "stack": ["python", "ai", "rag"],
+ "use_cases": ["rag chatbot sample", "ai lab starter"],
+ "recommended": false,
+ "in_templates_yaml": false,
+ "local_bundled": null
+ },
+ {
+ "id": "ai-lab-chatbot",
+ "title": "Chatbot Application",
+ "source": "ai-lab-template",
+ "path": "templates/chatbot",
+ "url": "https://github.com/redhat-ai-dev/ai-lab-template/tree/main/templates/chatbot",
+ "category": "ai",
+ "tags": ["ai", "chatbot", "llm"],
+ "stack": ["python", "ai"],
+ "use_cases": ["simple chatbot sample", "ai lab starter"],
+ "recommended": false,
+ "in_templates_yaml": false,
+ "local_bundled": null
+ },
+ {
+ "id": "ai-lab-codegen",
+ "title": "Codegen Application",
+ "source": "ai-lab-template",
+ "path": "templates/codegen",
+ "url": "https://github.com/redhat-ai-dev/ai-lab-template/tree/main/templates/codegen",
+ "category": "ai",
+ "tags": ["ai", "codegen", "llm"],
+ "stack": ["python", "ai"],
+ "use_cases": ["code generation sample", "developer assistant"],
+ "recommended": false,
+ "in_templates_yaml": false,
+ "local_bundled": null
+ },
+ {
+ "id": "minimal-template",
+ "title": "Minimal starter template (bundled)",
+ "source": "bundled",
+ "path": "assets/examples/minimal-template",
+ "url": "https://github.com/redhat-developer/rhdh-skill/tree/main/skills/rhdh-templates/assets/examples/minimal-template",
+ "category": "starter",
+ "tags": ["starter", "minimal", "validation"],
+ "stack": [],
+ "use_cases": ["learn template structure", "validate locally without network"],
+ "recommended": false,
+ "in_templates_yaml": false,
+ "local_bundled": "minimal-template"
+ }
+ ]
+}
diff --git a/skills/rhdh-templates/assets/examples/README.md b/skills/rhdh-templates/assets/examples/README.md
new file mode 100644
index 0000000..d3861ec
--- /dev/null
+++ b/skills/rhdh-templates/assets/examples/README.md
@@ -0,0 +1,29 @@
+# RHDH Templates Examples
+
+Bundled worked examples for **local learning and validation**. Each passes `validate.py` with zero critical findings.
+
+For the full curated catalog of upstream reference templates (official library, AI quickstarts), run:
+
+```bash
+python skills/rhdh-templates/scripts/list_examples.py --recommended --json
+```
+
+See [../references/example-catalog.md](../references/example-catalog.md) for category guide and common-demand context.
+
+| Example | Stack | Highlights |
+|---------|-------|------------|
+| [minimal-template](./minimal-template/) | Generic | Starter scaffold from `init` — single parameter form |
+| [nodejs-backend](./nodejs-backend/) | Node.js | `EntityPicker`, `RepoUrlPicker`, publish + register, GitHub Actions `{% raw %}` |
+| [java-springboot](./java-springboot/) | Java / Spring Boot | Maven `pom.xml`, `Application.java`, multi-section forms |
+
+These bundled examples correspond to upstream references in [red-hat-developer-hub-software-templates](https://github.com/redhat-developer/red-hat-developer-hub-software-templates): `nodejs-backend` → `templates/github/nodejs-backend`, `java-springboot` → `templates/github/spring-boot-backend`.
+
+Validate any example locally:
+
+```bash
+python skills/rhdh-templates/scripts/validate.py \
+ --path skills/rhdh-templates/assets/examples/nodejs-backend \
+ --repo --lint-skeleton --json
+```
+
+Replace `nodejs-backend` with `java-springboot` or `minimal-template` as needed.
diff --git a/skills/rhdh-templates/assets/examples/java-springboot/README.md b/skills/rhdh-templates/assets/examples/java-springboot/README.md
new file mode 100644
index 0000000..cb54c5a
--- /dev/null
+++ b/skills/rhdh-templates/assets/examples/java-springboot/README.md
@@ -0,0 +1,24 @@
+# Java Spring Boot Service
+
+Scaffolds a minimal Spring Boot 3 service with:
+
+- Maven `pom.xml` parameterized by component ID and Java version
+- `Application.java` and `application.properties`
+- `catalog-info.yaml` for Software Catalog registration
+
+## Parameters
+
+| Parameter | Purpose |
+|-----------|---------|
+| `componentId` | Catalog entity name and Maven artifact ID |
+| `description` | Shown in catalog and repository |
+| `owner` | Catalog owner entity ref |
+| `javaVersion` | Java LTS version (`17` or `21`) |
+| `packageName` | Java base package for generated sources |
+| `repoUrl` | Target GitHub repository |
+
+## Post-scaffold steps
+
+1. Run `./mvnw spring-boot:run` locally
+2. Confirm CI passes after first push
+3. Verify catalog registration in RHDH
diff --git a/skills/rhdh-templates/assets/examples/java-springboot/skeleton/README.md b/skills/rhdh-templates/assets/examples/java-springboot/skeleton/README.md
new file mode 100644
index 0000000..ad34bdd
--- /dev/null
+++ b/skills/rhdh-templates/assets/examples/java-springboot/skeleton/README.md
@@ -0,0 +1,13 @@
+# {{ values.componentId }}
+
+{{ values.description }}
+
+Owner: {{ values.owner }}
+
+## Development
+
+```bash
+./mvnw spring-boot:run
+```
+
+Uses Java {{ values.javaVersion }} and package `{{ values.packageName }}`.
diff --git a/skills/rhdh-templates/assets/examples/java-springboot/skeleton/catalog-info.yaml b/skills/rhdh-templates/assets/examples/java-springboot/skeleton/catalog-info.yaml
new file mode 100644
index 0000000..acea76f
--- /dev/null
+++ b/skills/rhdh-templates/assets/examples/java-springboot/skeleton/catalog-info.yaml
@@ -0,0 +1,9 @@
+apiVersion: backstage.io/v1alpha1
+kind: Component
+metadata:
+ name: {{ values.componentId }}
+ description: {{ values.description }}
+spec:
+ type: service
+ lifecycle: experimental
+ owner: {{ values.owner }}
diff --git a/skills/rhdh-templates/assets/examples/java-springboot/skeleton/pom.xml b/skills/rhdh-templates/assets/examples/java-springboot/skeleton/pom.xml
new file mode 100644
index 0000000..7946fc2
--- /dev/null
+++ b/skills/rhdh-templates/assets/examples/java-springboot/skeleton/pom.xml
@@ -0,0 +1,44 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.4.0
+
+
+
+ {{ values.packageName }}
+ {{ values.componentId }}
+ 0.0.1-SNAPSHOT
+ {{ values.componentId }}
+ {{ values.description }}
+
+
+ {{ values.javaVersion }}
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
diff --git a/skills/rhdh-templates/assets/examples/java-springboot/skeleton/src/main/java/com/example/demo/Application.java b/skills/rhdh-templates/assets/examples/java-springboot/skeleton/src/main/java/com/example/demo/Application.java
new file mode 100644
index 0000000..4973964
--- /dev/null
+++ b/skills/rhdh-templates/assets/examples/java-springboot/skeleton/src/main/java/com/example/demo/Application.java
@@ -0,0 +1,22 @@
+package {{ values.packageName }};
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@SpringBootApplication
+public class Application {
+
+ public static void main(String[] args) {
+ SpringApplication.run(Application.class, args);
+ }
+
+ @RestController
+ static class HelloController {
+ @GetMapping("/")
+ public String hello() {
+ return "{{ values.componentId }} is running";
+ }
+ }
+}
diff --git a/skills/rhdh-templates/assets/examples/java-springboot/skeleton/src/main/resources/application.properties b/skills/rhdh-templates/assets/examples/java-springboot/skeleton/src/main/resources/application.properties
new file mode 100644
index 0000000..3a3779e
--- /dev/null
+++ b/skills/rhdh-templates/assets/examples/java-springboot/skeleton/src/main/resources/application.properties
@@ -0,0 +1,2 @@
+spring.application.name={{ values.componentId }}
+server.port=8080
diff --git a/skills/rhdh-templates/assets/examples/java-springboot/template.yaml b/skills/rhdh-templates/assets/examples/java-springboot/template.yaml
new file mode 100644
index 0000000..1c98baa
--- /dev/null
+++ b/skills/rhdh-templates/assets/examples/java-springboot/template.yaml
@@ -0,0 +1,101 @@
+apiVersion: scaffolder.backstage.io/v1beta3
+kind: Template
+metadata:
+ name: java-springboot
+ title: Java Spring Boot Service
+ description: Scaffold a Spring Boot microservice with Maven, catalog registration, and GitHub publish
+ tags:
+ - recommended
+ - java
+ - spring-boot
+ - microservice
+spec:
+ owner: group:default/platform-team
+ type: service
+
+ parameters:
+ - title: Component details
+ required:
+ - componentId
+ - owner
+ - description
+ - javaVersion
+ properties:
+ componentId:
+ title: Component ID
+ type: string
+ description: Unique ID used for artifact name and catalog entity
+ pattern: '^[a-z0-9-]*[a-z0-9]$'
+ ui:autofocus: true
+ description:
+ title: Description
+ type: string
+ description: What this service does
+ owner:
+ title: Owner
+ type: string
+ ui:field: EntityPicker
+ ui:options:
+ catalogFilter:
+ kind:
+ - Group
+ - User
+ javaVersion:
+ title: Java version
+ type: string
+ enum:
+ - "17"
+ - "21"
+ default: "21"
+ packageName:
+ title: Java package
+ type: string
+ description: Base package (e.g. com.example.demo)
+ pattern: '^[a-z][a-z0-9.]*$'
+ - title: Repository location
+ required:
+ - repoUrl
+ properties:
+ repoUrl:
+ title: Repository location
+ type: string
+ ui:field: RepoUrlPicker
+ ui:options:
+ allowedHosts:
+ - github.com
+
+ steps:
+ - id: fetch-base
+ name: Fetch skeleton
+ action: fetch:template
+ input:
+ url: ./skeleton
+ values:
+ componentId: ${{ parameters.componentId }}
+ description: ${{ parameters.description }}
+ owner: ${{ parameters.owner }}
+ javaVersion: ${{ parameters.javaVersion }}
+ packageName: ${{ parameters.packageName }}
+
+ - id: publish
+ name: Publish to GitHub
+ action: publish:github
+ input:
+ repoUrl: ${{ parameters.repoUrl }}
+ description: ${{ parameters.description }}
+ defaultBranch: main
+
+ - id: register
+ name: Register in catalog
+ action: catalog:register
+ input:
+ repoContentsUrl: ${{ steps.publish.output.repoContentsUrl }}
+ catalogInfoPath: /catalog-info.yaml
+
+ output:
+ links:
+ - title: Open repository
+ url: ${{ steps.publish.output.remoteUrl }}
+ - title: View in catalog
+ icon: catalog
+ entityRef: ${{ steps.register.output.entityRef }}
diff --git a/skills/rhdh-templates/assets/examples/minimal-template/skeleton/README.md b/skills/rhdh-templates/assets/examples/minimal-template/skeleton/README.md
new file mode 100644
index 0000000..6553204
--- /dev/null
+++ b/skills/rhdh-templates/assets/examples/minimal-template/skeleton/README.md
@@ -0,0 +1,5 @@
+# {{ values.componentId }}
+
+{{ values.description }}
+
+Owner: {{ values.owner }}
diff --git a/skills/rhdh-templates/assets/examples/minimal-template/skeleton/catalog-info.yaml b/skills/rhdh-templates/assets/examples/minimal-template/skeleton/catalog-info.yaml
new file mode 100644
index 0000000..acea76f
--- /dev/null
+++ b/skills/rhdh-templates/assets/examples/minimal-template/skeleton/catalog-info.yaml
@@ -0,0 +1,9 @@
+apiVersion: backstage.io/v1alpha1
+kind: Component
+metadata:
+ name: {{ values.componentId }}
+ description: {{ values.description }}
+spec:
+ type: service
+ lifecycle: experimental
+ owner: {{ values.owner }}
diff --git a/skills/rhdh-templates/assets/examples/minimal-template/template.yaml b/skills/rhdh-templates/assets/examples/minimal-template/template.yaml
new file mode 100644
index 0000000..29f1eac
--- /dev/null
+++ b/skills/rhdh-templates/assets/examples/minimal-template/template.yaml
@@ -0,0 +1,54 @@
+apiVersion: scaffolder.backstage.io/v1beta3
+kind: Template
+metadata:
+ name: minimal-example
+ title: Minimal Example Template
+ description: Starter template scaffolded by rhdh-templates init
+ tags:
+ - example
+spec:
+ owner: group:default/platform-team
+ type: service
+
+ parameters:
+ - title: Component details
+ required:
+ - componentId
+ - owner
+ properties:
+ componentId:
+ title: Component ID
+ type: string
+ pattern: '^[a-z0-9-]*[a-z0-9]$'
+ description: Unique ID for the new component
+ ui:help: Lowercase letters, digits, and dashes only
+ ui:autofocus: true
+ description:
+ title: Description
+ type: string
+ description: What this component does
+ owner:
+ title: Owner
+ type: string
+ ui:field: EntityPicker
+ ui:options:
+ catalogFilter:
+ kind:
+ - Group
+ - User
+
+ steps:
+ - id: fetch-base
+ name: Fetch skeleton
+ action: fetch:template
+ input:
+ url: ./skeleton
+ values:
+ componentId: ${{ parameters.componentId }}
+ description: ${{ parameters.description }}
+ owner: ${{ parameters.owner }}
+
+ output:
+ links:
+ - title: Template output
+ url: https://backstage.io/docs/features/software-templates/
diff --git a/skills/rhdh-templates/assets/examples/nodejs-backend/README.md b/skills/rhdh-templates/assets/examples/nodejs-backend/README.md
new file mode 100644
index 0000000..afca09d
--- /dev/null
+++ b/skills/rhdh-templates/assets/examples/nodejs-backend/README.md
@@ -0,0 +1,23 @@
+# Node.js Backend Service
+
+Scaffolds a minimal Express-style Node.js service with:
+
+- `package.json` parameterized by component ID and Node version
+- `catalog-info.yaml` for Software Catalog registration
+- GitHub Actions CI workflow (with `{% raw %}` for Actions syntax)
+
+## Parameters
+
+| Parameter | Purpose |
+|-----------|---------|
+| `componentId` | Catalog entity name and npm package name |
+| `description` | Shown in catalog and repository |
+| `owner` | Catalog owner entity ref |
+| `nodeVersion` | Node.js LTS version (`20` or `22`) |
+| `repoUrl` | Target GitHub repository |
+
+## Post-scaffold steps
+
+1. Run `npm install` in the new repository
+2. Push triggers CI via GitHub Actions
+3. Confirm the component appears in the Software Catalog
diff --git a/skills/rhdh-templates/assets/examples/nodejs-backend/skeleton/.github/workflows/ci.yaml b/skills/rhdh-templates/assets/examples/nodejs-backend/skeleton/.github/workflows/ci.yaml
new file mode 100644
index 0000000..88fcd3b
--- /dev/null
+++ b/skills/rhdh-templates/assets/examples/nodejs-backend/skeleton/.github/workflows/ci.yaml
@@ -0,0 +1,20 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+{% raw %}
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ - run: npm install
+ - run: npm test
+{% endraw %}
diff --git a/skills/rhdh-templates/assets/examples/nodejs-backend/skeleton/README.md b/skills/rhdh-templates/assets/examples/nodejs-backend/skeleton/README.md
new file mode 100644
index 0000000..976d4de
--- /dev/null
+++ b/skills/rhdh-templates/assets/examples/nodejs-backend/skeleton/README.md
@@ -0,0 +1,14 @@
+# {{ values.componentId }}
+
+{{ values.description }}
+
+Owner: {{ values.owner }}
+
+## Development
+
+```bash
+npm install
+npm start
+```
+
+Runs on Node.js {{ values.nodeVersion }}.
diff --git a/skills/rhdh-templates/assets/examples/nodejs-backend/skeleton/catalog-info.yaml b/skills/rhdh-templates/assets/examples/nodejs-backend/skeleton/catalog-info.yaml
new file mode 100644
index 0000000..acea76f
--- /dev/null
+++ b/skills/rhdh-templates/assets/examples/nodejs-backend/skeleton/catalog-info.yaml
@@ -0,0 +1,9 @@
+apiVersion: backstage.io/v1alpha1
+kind: Component
+metadata:
+ name: {{ values.componentId }}
+ description: {{ values.description }}
+spec:
+ type: service
+ lifecycle: experimental
+ owner: {{ values.owner }}
diff --git a/skills/rhdh-templates/assets/examples/nodejs-backend/skeleton/package.json b/skills/rhdh-templates/assets/examples/nodejs-backend/skeleton/package.json
new file mode 100644
index 0000000..794ac07
--- /dev/null
+++ b/skills/rhdh-templates/assets/examples/nodejs-backend/skeleton/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "{{ values.componentId }}",
+ "version": "0.1.0",
+ "private": true,
+ "description": "{{ values.description }}",
+ "main": "src/index.js",
+ "engines": {
+ "node": ">={{ values.nodeVersion }}.0.0"
+ },
+ "scripts": {
+ "start": "node src/index.js",
+ "test": "node --test"
+ }
+}
diff --git a/skills/rhdh-templates/assets/examples/nodejs-backend/skeleton/src/index.js b/skills/rhdh-templates/assets/examples/nodejs-backend/skeleton/src/index.js
new file mode 100644
index 0000000..946c96a
--- /dev/null
+++ b/skills/rhdh-templates/assets/examples/nodejs-backend/skeleton/src/index.js
@@ -0,0 +1,12 @@
+const http = require('http');
+
+const port = process.env.PORT || 3000;
+
+const server = http.createServer((_req, res) => {
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ service: '{{ values.componentId }}', status: 'ok' }));
+});
+
+server.listen(port, () => {
+ console.log(`{{ values.componentId }} listening on port ${port}`);
+});
diff --git a/skills/rhdh-templates/assets/examples/nodejs-backend/template.yaml b/skills/rhdh-templates/assets/examples/nodejs-backend/template.yaml
new file mode 100644
index 0000000..2a38035
--- /dev/null
+++ b/skills/rhdh-templates/assets/examples/nodejs-backend/template.yaml
@@ -0,0 +1,93 @@
+apiVersion: scaffolder.backstage.io/v1beta3
+kind: Template
+metadata:
+ name: nodejs-backend
+ title: Node.js Backend Service
+ description: Scaffold a Node.js REST API with catalog registration and GitHub publish
+ tags:
+ - recommended
+ - nodejs
+ - backend
+spec:
+ owner: group:default/platform-team
+ type: service
+
+ parameters:
+ - title: Component details
+ required:
+ - componentId
+ - owner
+ - description
+ properties:
+ componentId:
+ title: Component ID
+ type: string
+ description: Unique ID for the new component
+ pattern: '^[a-z0-9-]*[a-z0-9]$'
+ ui:autofocus: true
+ description:
+ title: Description
+ type: string
+ description: What this service does
+ owner:
+ title: Owner
+ type: string
+ ui:field: EntityPicker
+ ui:options:
+ catalogFilter:
+ kind:
+ - Group
+ - User
+ nodeVersion:
+ title: Node.js version
+ type: string
+ enum:
+ - "20"
+ - "22"
+ default: "20"
+ - title: Repository location
+ required:
+ - repoUrl
+ properties:
+ repoUrl:
+ title: Repository location
+ type: string
+ ui:field: RepoUrlPicker
+ ui:options:
+ allowedHosts:
+ - github.com
+
+ steps:
+ - id: fetch-base
+ name: Fetch skeleton
+ action: fetch:template
+ input:
+ url: ./skeleton
+ values:
+ componentId: ${{ parameters.componentId }}
+ description: ${{ parameters.description }}
+ owner: ${{ parameters.owner }}
+ nodeVersion: ${{ parameters.nodeVersion }}
+
+ - id: publish
+ name: Publish to GitHub
+ action: publish:github
+ input:
+ repoUrl: ${{ parameters.repoUrl }}
+ description: ${{ parameters.description }}
+ defaultBranch: main
+
+ - id: register
+ name: Register in catalog
+ action: catalog:register
+ input:
+ repoContentsUrl: ${{ steps.publish.output.repoContentsUrl }}
+ catalogInfoPath: /catalog-info.yaml
+
+ output:
+ links:
+ - title: Open repository
+ url: ${{ steps.publish.output.remoteUrl }}
+ - title: View in catalog
+ icon: catalog
+ entityRef: ${{ steps.register.output.entityRef }}
diff --git a/skills/rhdh-templates/references/add-parameter.md b/skills/rhdh-templates/references/add-parameter.md
new file mode 100644
index 0000000..5e9e37d
--- /dev/null
+++ b/skills/rhdh-templates/references/add-parameter.md
@@ -0,0 +1,74 @@
+# add-parameter — Incremental Parameter Authoring
+
+
+
+- `conventions.md`
+- `template-structure.md`
+- `parameter-widgets.md`
+
+
+
+
+
+Extend an existing `template.yaml` without re-running full `templatize` or `create`.
+
+## Step 1: Locate template
+
+Confirm path to `templates//template.yaml`.
+
+## Step 2: Define parameter
+
+Gather from user:
+
+| Field | Notes |
+|-------|-------|
+| Name | camelCase key (e.g., `repoName`) |
+| Title | Form label |
+| Type | `string`, `number`, `boolean`, `array` |
+| Widget | default, `EntityPicker`, `RepoUrlPicker`, `radio`, etc. |
+| Section | existing `parameters[].title` or new section |
+| Required | yes/no |
+
+## Step 3: Edit template.yaml
+
+Add to appropriate `parameters` section:
+
+```yaml
+repoName:
+ title: Repository Name
+ type: string
+ description: GitHub repository name for the new component
+ ui:autofocus: true
+```
+
+For conditional fields, add `dependencies` block per `template-structure.md`.
+
+## Step 4: Wire into steps
+
+Every new parameter used in skeleton or actions must appear in a `fetch:template` `values` map:
+
+```yaml
+values:
+ repoName: ${{ parameters.repoName }}
+```
+
+Search all `steps[].input` for missing wiring after the edit.
+
+## Step 5: Update skeleton (if needed)
+
+If the parameter replaces a literal in skeleton files, update Nunjucks to `{{ values. }}`.
+
+## Step 6: Verify
+
+Run `fix_gotchas.py` on the template path. Confirm parameter appears in form and values map.
+
+
+
+
+
+- Parameter added to correct form section with type and UI field
+- All `fetch:template` steps pass the parameter in `values`
+- Skeleton references updated when parameter replaces literals
+- No duplicate parameter keys
+
+
diff --git a/skills/rhdh-templates/references/add-skeleton.md b/skills/rhdh-templates/references/add-skeleton.md
new file mode 100644
index 0000000..a1af475
--- /dev/null
+++ b/skills/rhdh-templates/references/add-skeleton.md
@@ -0,0 +1,87 @@
+# add-skeleton — Incremental Skeleton Authoring
+
+
+
+- `conventions.md`
+
+
+
+
+
+Add or extend files under `templates//skeleton/` for an existing template.
+
+## Step 1: Confirm template context
+
+Locate `templates//template.yaml` and existing `skeleton/` tree.
+
+## Step 2: Determine file role
+
+| File type | Templating approach |
+|-----------|---------------------|
+| App source, README, YAML config | Nunjucks `{{ values.* }}` |
+| GitHub Actions, Helm with `{{` | `{% raw %}` … `{% endraw %}` OR `copyWithoutTemplating` |
+| Binary / images | Do not template — document manual copy |
+
+## Step 3: Add file
+
+Create file under `skeleton/` mirroring target repo layout.
+
+Example `skeleton/catalog-info.yaml`:
+
+```yaml
+apiVersion: backstage.io/v1alpha1
+kind: Component
+metadata:
+ name: {{ values.componentId }}
+ description: {{ values.description }}
+spec:
+ type: service
+ lifecycle: experimental
+ owner: {{ values.owner }}
+```
+
+## Step 4: Sync template.yaml
+
+1. Ensure parameters exist for every `values.*` key used.
+2. Update `fetch:template` step `values` map.
+3. If adding CI workflows, set `copyWithoutTemplating` or raw blocks:
+
+```yaml
+input:
+ url: ./skeleton
+ copyWithoutTemplating:
+ - .github/workflows/
+```
+
+## Step 5: Optional additional fetch step
+
+When skeleton has CI overlay from shared path (RHDH software-templates pattern):
+
+```yaml
+- id: ci-template
+ name: Add CI skeleton
+ action: fetch:template
+ input:
+ url: ${{ parameters.ci }}
+ copyWithoutTemplating:
+ - .github/workflows/
+ values:
+ repoName: ${{ parameters.repoName }}
+```
+
+## Step 6: Verify
+
+- Grep skeleton for `parameters.` — should be **zero** matches (use `values.` only).
+- Grep for unwrapped `{{` in workflow files — should be inside `{% raw %}` or excluded via `copyWithoutTemplating`.
+- Run `validate --lint-skeleton` for Nunjucks syntax checks (see `validate.md`).
+
+
+
+
+
+- New skeleton files use `values.*` references only
+- Workflow/chart files protected from accidental Nunjucks processing
+- `fetch:template` `values` map includes all new placeholders
+- File paths match expected output repo structure
+
+
diff --git a/skills/rhdh-templates/references/add-step.md b/skills/rhdh-templates/references/add-step.md
new file mode 100644
index 0000000..7b1adf6
--- /dev/null
+++ b/skills/rhdh-templates/references/add-step.md
@@ -0,0 +1,80 @@
+# add-step — Incremental Step Authoring
+
+
+
+- `conventions.md`
+- `template-structure.md`
+
+
+
+
+
+Add a scaffolder step to an existing template without rebuilding from scratch.
+
+## Step 1: Identify action
+
+Ask user what the step should do. Map to a scaffolder action:
+
+| Intent | Typical action |
+|--------|----------------|
+| Copy/template files | `fetch:template` |
+| Fetch plain files | `fetch:plain` |
+| Publish to GitHub | `publish:github` |
+| Register catalog entity | `catalog:register` |
+| Run custom action | `custom:` |
+
+Action IDs are camelCase. When unsure of installed actions, use the `list-actions` command to query the live instance.
+
+## Step 2: Choose position
+
+Steps run **in series**. Ask where to insert:
+
+- Before publish (materialize content)
+- After publish (register, notify, trigger CI)
+
+Assign unique `id` (kebab-case) and human-readable `name`.
+
+## Step 3: Build input
+
+Reference parameters and prior step outputs:
+
+```yaml
+- id: notify-team
+ name: Notify platform team
+ action: notification:send
+ input:
+ recipients: entity:group:default/platform-team
+ title: New component ${{ parameters.componentId }}
+ info: ${{ steps.publish.output.remoteUrl }}
+```
+
+## Step 4: Update output (if needed)
+
+If the step produces user-facing results, add `spec.output.links` referencing `${{ steps..output.* }}`.
+
+## Step 5: Verify wiring
+
+Checklist:
+
+- [ ] `id` unique among all steps
+- [ ] `action` uses correct camelCase ID
+- [ ] All `${{ parameters.* }}` exist in form
+- [ ] All `${{ steps.*.output.* }}` reference prior step IDs
+- [ ] `fetch:template` steps include complete `values` map
+
+## Step 6: fix-gotchas
+
+```bash
+python /scripts/fix_gotchas.py --path [--apply] [--json]
+```
+
+
+
+
+
+- New step inserted at correct position with unique `id`
+- Action ID and inputs match conventions.md
+- Downstream steps and `output` updated if they depend on new step
+- fix-gotchas reports no critical action-casing or expression errors
+
+
diff --git a/skills/rhdh-templates/references/best-practices.md b/skills/rhdh-templates/references/best-practices.md
new file mode 100644
index 0000000..4f0db06
--- /dev/null
+++ b/skills/rhdh-templates/references/best-practices.md
@@ -0,0 +1,150 @@
+# Backstage Software Template Best Practices
+
+
+
+Load when authoring, reviewing, or improving templates — especially during `create`, `templatize`, `add-parameter`, `add-skeleton`, and pre-merge `validate`.
+
+Source: [10 tips for better Backstage Software Templates](https://developers.redhat.com/articles/2025/03/17/10-tips-better-backstage-software-templates) (Red Hat Developer, 2025).
+
+
+
+
+
+## 1. Structure your template repository
+
+Use a **central template repository** with one folder per template and a root `location.yaml` that registers all templates via glob — new templates appear in the catalog after commit without per-template import.
+
+**Why:** Reduces operational toil — platform engineers commit a folder; catalog sync picks it up. Split into multiple repos only when authorship or access boundaries require it.
+
+**RHDH convention:** See `conventions.md` for the `./templates/**/template.yaml` layout, `location.yaml` pattern, and catalog registration via the location URL (not individual template files).
+
+Reference implementations: [backstage/software-templates](https://github.com/backstage/software-templates/), [rhdh-demo-gh/templates](https://github.com/rhdh-demo-gh/templates/).
+
+## 2. Experiment with the Template Editor
+
+Avoid the slow loop of push → wait for catalog sync → test run. Use Backstage's **Template Editor** at `/create/edit` (or **Template Editor** link on the Software Templates page) to:
+
+- Edit templates from a local directory or paste YAML
+- Preview form rendering live on the right
+- Experiment with custom field extensions before committing
+
+**When to use:** After local `validate` passes, paste into Template Editor for form UX review. Copy final YAML back to the repo.
+
+**Skill workflow:** `validate` locally first → Template Editor for UX → `dry-run` against live RHDH for execution.
+
+## 3. Explore installed actions
+
+Action IDs and schemas vary by RHDH instance (installed plugins). Never guess action names from docs alone.
+
+| Method | Path / command |
+|--------|----------------|
+| RHDH UI | Software Catalog → **Installed Actions**, or `/create/actions` |
+| Skill CLI | `list-actions --rhdh-url …` |
+| Single action schema | `explain-action --action-id …` |
+
+Use the exact `id` string from the live instance in `steps[].action`. Plugin actions follow `namespace:actionName` (e.g., `quay:create-repository`).
+
+## 4. Improve DevEx with custom field extensions
+
+Forms use [react-jsonschema-form](https://github.com/rjsf-team/react-jsonschema-form). Built-in **Custom Field Extensions** reduce errors vs free-text entry:
+
+| Field | Use when |
+|-------|----------|
+| `EntityPicker` | Owner, system, component, domain — catalog-backed selection |
+| `RepoUrlPicker` | SCM URL with host/org validation |
+| `OwnerPicker` | Shortcut for owner groups |
+| `Secret` | Passwords, tokens, API keys (see tip 7) |
+
+Set `allowArbitraryValues: false` when the value must resolve to a catalog entity. See `parameter-widgets.md` for wiring patterns and examples.
+
+## 5. Process structured data with template filters
+
+[Template filters](https://backstage.io/docs/features/software-templates/template-extensions) transform values in step expressions. Entity pickers return string refs like `component:default/my-service`.
+
+```yaml
+title: "Deploy ${{ parameters.component | parseEntityRef | pick('name') }}"
+targetPath: ./${{ parameters.component | parseEntityRef | pick('name') }}
+```
+
+Common filters: `parseEntityRef`, `pick`, `json`, `replace`. Use filters in step `input` and `output` — not in skeleton Nunjucks (skeleton uses `values.*` from `fetch:template`; see `conventions.md`).
+
+## 6. Use the Nunjucks API in skeletons
+
+`fetch:template` processes skeleton files with [Nunjucks](https://mozilla.github.io/nunjucks/templating.html). Pass data via the step `values` map; reference as `{{ values.name }}` in skeleton files.
+
+**Tags beyond substitution:**
+
+| Tag | Use when |
+|-----|----------|
+| `{% if %}…{% endif %}` | Conditional files or sections based on parameters |
+| `{% for %}…{% endfor %}` | Iterate arrays passed in `values` |
+| `{% raw %}…{% endraw %}` | Preserve literal `{{` / `{%` (GitHub Actions, Helm) |
+
+GitHub Actions and Helm files need `{% raw %}` or `copyWithoutTemplating` — see `conventions.md`, `template-structure.md`, and `add-skeleton.md`. `fix-gotchas` and `validate --lint-skeleton` detect common mistakes.
+
+## 7. Protect secrets
+
+Never collect credentials with a plain `type: string` text field. Use Backstage's **Secret** field — see `parameter-widgets.md` for the form definition.
+
+Secrets are masked in the form, review screen, and logs. In steps, reference integration secrets (e.g. `${{ secrets.user.github.token }}`) — never hardcode tokens. Exact secret paths depend on configured integrations; confirm against your RHDH instance. `fix-gotchas` flags obvious hardcoded tokens in step inputs.
+
+## 8. Specify template type and tags
+
+`spec.type` is required but often left as generic `service`. Set a meaningful type (`website`, `microservice`, `library`, `infrastructure`) so the Create UI groups templates. Add `metadata.tags` for subcategory filtering.
+
+See `template-structure.md` for `metadata` and `spec.type` fields. Use the `recommended` tag to highlight golden paths.
+
+**Why:** The Software Templates page becomes unusable at scale without type/tag filters.
+
+## 9. Document your templates
+
+Self-service templates need docs beyond `description`. Two levels:
+
+**Human README** — `templates//README.md` with purpose, parameters, post-scaffold steps.
+
+**TechDocs** (when RHDH has TechDocs configured): add `backstage.io/techdocs-ref: dir:.` annotation and `mkdocs.yml` beside `template.yaml`. See `template-structure.md` for the annotation pattern.
+
+Documented templates show a **View TechDocs** link in the Create UI. Example: [rhdh-demo-gh/templates/deploy-component](https://github.com/rhdh-demo-gh/templates/tree/main/deploy-component).
+
+## 10. Plan for maintenance
+
+Templates codify best practices — outdated templates undermine trust. Treat template repos like application code:
+
+| Practice | How the skill supports it |
+|----------|---------------------------|
+| Keep skeleton dependencies current | Re-run `templatize` when source repos change |
+| Automated regression | `dry-run` via Scaffolder HTTP API after changes |
+| Pre-merge checks | `validate` + `fix-gotchas` with zero critical findings |
+| Periodic review | Schedule dependency bumps in skeleton `package.json`, Dockerfiles, CI versions |
+
+**Failure modes to avoid:** Scaffolded apps that fail on first build, frameworks with known CVEs, broken publish/register wiring after SCM API changes.
+
+## Bonus: Accelerate the development loop
+
+Shrink feedback time with local RHDH:
+
+| Approach | When |
+|----------|------|
+| **rhdh-local** skill | Fast local RHDH for plugin/template testing |
+| Template Editor | Form UX without catalog sync |
+| `validate --lint-skeleton` | Nunjucks/skeleton checks without RHDH |
+| `dry-run` | End-to-end step execution against live instance |
+
+Recommended loop: `init` → author → `validate` → Template Editor → `dry-run` → commit.
+
+
+
+
+
+Before merge, confirm:
+
+- [ ] Repo follows central `location.yaml` + `templates//` layout
+- [ ] `spec.type` and `metadata.tags` set for discoverability
+- [ ] EntityPicker / RepoUrlPicker / Secret used instead of free-text where appropriate
+- [ ] Skeleton uses `values.*`; workflows use `{% raw %}` or `copyWithoutTemplating`
+- [ ] Sensitive inputs use `ui:field: Secret`; step tokens use `${{ secrets.* }}`
+- [ ] README or TechDocs present for non-trivial templates
+- [ ] `validate.py` reports zero critical findings
+- [ ] Template tested in Template Editor or via `dry-run`
+
+
diff --git a/skills/rhdh-templates/references/conventions.md b/skills/rhdh-templates/references/conventions.md
new file mode 100644
index 0000000..0c654c5
--- /dev/null
+++ b/skills/rhdh-templates/references/conventions.md
@@ -0,0 +1,115 @@
+# RHDH Software Template Conventions
+
+
+
+Load this file before editing `template.yaml`, skeleton files, or `location.yaml`.
+
+
+
+
+
+## API versions
+
+| Artifact | apiVersion | kind |
+|----------|------------|------|
+| `template.yaml` | `scaffolder.backstage.io/v1beta3` | `Template` |
+| `location.yaml` | `backstage.io/v1alpha1` | `Location` |
+| `catalog-info.yaml` (in skeleton) | `backstage.io/v1alpha1` | `Component` (typical) |
+
+Use v1beta3 for new templates — it uses `${{ }}` step expressions. Do not mix v1beta2 `{{ }}` syntax in the same template.
+
+## Action IDs
+
+Scaffolder actions use **camelCase** IDs:
+
+| Correct | Wrong |
+|---------|-------|
+| `fetch:template` | `fetch:template` with wrong casing in docs only — verify against live instance |
+| `publish:github` | `publish:GitHub` |
+| `catalog:register` | `catalog:Register` |
+
+When unsure, list actions from a running RHDH instance with the `list-actions` command.
+
+## Parameter form conventions
+
+- Group related fields under `parameters[].title` sections (e.g., "Provide information about the new component").
+- Use `ui:field: EntityPicker` with `catalogFilter.kind` for owner/system pickers.
+- Use `ui:field: RepoUrlPicker` with `allowedHosts` for repo URL parameters.
+- Use `pattern` + `ui:help` for constrained IDs (see `assets/examples/minimal-template/template.yaml`).
+
+## Skeleton templating
+
+Skeleton files use Nunjucks with **values from `fetch:template` steps**, not `parameters` directly:
+
+```yaml
+# template.yaml step
+action: fetch:template
+input:
+ url: ./skeleton
+ values:
+ repoName: ${{ parameters.repoName }}
+```
+
+```text
+# skeleton/README.md
+# Project: {{ values.repoName }}
+```
+
+### When to use `{% raw %}` … `{% endraw %}`
+
+Wrap content that must pass through unchanged and contains `{{` or `{%` — common in:
+
+- GitHub Actions workflows (`.github/workflows/*.yaml`)
+- Helm charts with Go templates
+- Any file where braces are literal syntax, not Nunjucks
+
+## Secrets in templates
+
+Use Backstage/RHDH secrets syntax in step inputs — never hardcode tokens in skeleton files:
+
+```yaml
+token: ${{ secrets.user.github.token }}
+```
+
+Exact secret paths depend on configured integrations; confirm against your RHDH instance.
+
+## Repository layout
+
+```
+template-repo/
+├── location.yaml # kind: Location — registers all templates
+└── templates/
+ └── my-template/
+ ├── template.yaml
+ ├── skeleton/ # optional README, catalog-info.yaml, app source
+ └── README.md # optional human docs
+```
+
+## location.yaml pattern
+
+```yaml
+apiVersion: backstage.io/v1alpha1
+kind: Location
+metadata:
+ name: my-org-templates
+ description: Software Templates for My Org
+spec:
+ targets:
+ - ./templates/**/template.yaml
+```
+
+Register the **location.yaml URL** in RHDH (Catalog Import or `catalog.locations` in app-config), not individual template files.
+
+For repository layout rationale and multi-repo splitting guidance, see `best-practices.md` tip 1.
+
+## Common publish + register sequence
+
+Most service templates end with:
+
+1. `fetch:template` — materialize skeleton
+2. `publish:github` (or `publish:gitlab`, etc.) — push to remote
+3. `catalog:register` — register `catalog-info.yaml` from the new repo
+
+Wire `repoContentsUrl` from publish output into register input.
+
+
diff --git a/skills/rhdh-templates/references/create-location.md b/skills/rhdh-templates/references/create-location.md
new file mode 100644
index 0000000..b76726a
--- /dev/null
+++ b/skills/rhdh-templates/references/create-location.md
@@ -0,0 +1,70 @@
+# create-location — Generate location.yaml
+
+
+
+- `conventions.md`
+
+
+
+
+
+Standalone utility for generating or refreshing root `location.yaml`. Templatize and create flows may also produce this file — use this command when templates were added manually or the glob target is stale.
+
+## Step 1: Confirm repo root
+
+The template repository root contains `templates/` with one or more `template.yaml` files.
+
+## Step 2: Run script
+
+```bash
+python /scripts/create_location.py \
+ --path \
+ --name \
+ [--description "Human description"] \
+ [--json]
+```
+
+| Flag | Default |
+|------|---------|
+| `--path` | current directory |
+| `--name` | derived from directory name + `-templates` suffix |
+| `--description` | auto-generated |
+
+The script:
+
+1. Discovers `templates/**/template.yaml`
+2. Writes or updates `location.yaml` at repo root with glob target `./templates/**/template.yaml`
+3. Lists discovered templates in JSON output
+
+## Step 3: Review output
+
+Show user the generated `location.yaml` and template count.
+
+If zero templates found, stop — run `init` or `create` first.
+
+## Step 4: Registration reminder
+
+Tell user to register **the location.yaml URL** in RHDH:
+
+- Catalog Import UI: `/catalog-import`
+- Or `catalog.locations` in app-config:
+
+```yaml
+catalog:
+ locations:
+ - type: url
+ target: https://github.com/acme-corp/templates/blob/main/location.yaml
+ rules:
+ - allow: [Location, Template]
+```
+
+
+
+
+
+- `location.yaml` exists at repo root with `kind: Location`
+- `spec.targets` includes `./templates/**/template.yaml`
+- Script JSON reports all discovered template paths
+- User knows how to register the Location in RHDH
+
+
diff --git a/skills/rhdh-templates/references/create.md b/skills/rhdh-templates/references/create.md
new file mode 100644
index 0000000..2000308
--- /dev/null
+++ b/skills/rhdh-templates/references/create.md
@@ -0,0 +1,122 @@
+# create — From-Scratch Template
+
+
+
+- `conventions.md`
+- `template-structure.md`
+- `parameter-widgets.md`
+- `assets/examples/minimal-template/template.yaml`
+- `assets/examples/nodejs-backend/template.yaml` — full publish/register pipeline
+- `assets/examples/java-springboot/template.yaml` — Spring Boot + Maven
+
+
+
+
+
+Use when **no reference codebase** exists. For converting existing code, use `templatize` instead.
+
+## Step 1: Gather intent
+
+Ask (one round, not necessarily one question each):
+
+1. **Template purpose** — what golden path does this enable?
+2. **Target type** — `service`, `website`, `library`, `plugin`, etc.
+3. **Parameters** — minimum form fields (name, owner, repo URL, …)
+4. **Steps** — fetch skeleton only, or publish + register too?
+5. **SCM** — GitHub, GitLab, Bitbucket?
+
+## Step 1b: Match reference examples (recommended)
+
+Before writing files, query the curated catalog and suggest 1–3 study references:
+
+```bash
+python /scripts/list_examples.py \
+ --match "" \
+ --limit 3 --json
+```
+
+Present upstream URLs from the matches. When a match includes `local_bundled`, also point at `assets/examples//` for offline patterns. Ask whether to mirror a reference's step sequence or start from `assets/examples/minimal-template/`.
+
+Load `example-catalog.md` when the user asks what templates teams typically build.
+
+## Step 2: Ensure layout
+
+If no template repo exists, run `init` first.
+
+## Step 3: Create template directory
+
+```
+templates//
+├── template.yaml
+├── skeleton/
+│ ├── README.md
+│ └── catalog-info.yaml # when registering a Component
+└── README.md # optional
+```
+
+Use `assets/examples/minimal-template/` as the starting skeleton for simple templates.
+
+## Step 4: Build template.yaml
+
+1. `metadata` — descriptive `name`, `title`, `description`, meaningful `tags` (see `best-practices.md` tip 8)
+2. `spec.type` — set to a discoverable category, not generic default when possible
+3. `spec.parameters` — at least one section with required fields; use EntityPicker/Secret widgets per `best-practices.md` tips 4 and 7
+4. `spec.steps` — minimal path:
+
+```yaml
+steps:
+ - id: fetch-base
+ name: Fetch skeleton
+ action: fetch:template
+ input:
+ url: ./skeleton
+ values:
+ componentId: ${{ parameters.componentId }}
+ owner: ${{ parameters.owner }}
+ description: ${{ parameters.description }}
+```
+
+Add publish/register steps when user wants full end-to-end flow.
+
+## Step 5: Write skeleton files
+
+Keep skeleton minimal but valid:
+
+- `README.md` with `{{ values.componentId }}` placeholder
+- `catalog-info.yaml` when using `catalog:register`
+
+## Step 6: Register templates
+
+```bash
+python /scripts/create_location.py --path [--json]
+```
+
+## Step 7: fix-gotchas
+
+```bash
+python /scripts/fix_gotchas.py --path templates//template.yaml [--apply] [--json]
+```
+
+### Critique and fix loop
+
+Before finishing, verify:
+
+1. Parameters use appropriate widgets from `parameter-widgets.md`
+2. Steps match the user's SCM choice (publish + register when requested)
+3. `fix_gotchas.py` reports zero critical findings after `--apply`
+4. `create_location.py` lists the new template
+
+5. Add `README.md` (or TechDocs per `best-practices.md` tip 9) for non-trivial templates
+
+Exit bar: minimal but complete template for the described use case, passes `validate`, ready for Template Editor (`best-practices.md` tip 2).
+
+
+
+
+
+- Valid v1beta3 `template.yaml` with parameters, steps, and output appropriate to use case
+- `skeleton/` contains at least one templated file
+- `location.yaml` registers the new template
+- fix-gotchas passes with no critical findings
+
+
diff --git a/skills/rhdh-templates/references/dry-run.md b/skills/rhdh-templates/references/dry-run.md
new file mode 100644
index 0000000..770183e
--- /dev/null
+++ b/skills/rhdh-templates/references/dry-run.md
@@ -0,0 +1,77 @@
+# dry-run — Remote Template Execution Test
+
+
+
+- `conventions.md`
+
+
+
+
+
+Execute a template against the Scaffolder v2 dry-run API without creating real resources. Mutation steps (publish, register) are simulated — warn the user that full E2E testing still requires running the template in RHDH.
+
+**Prerequisite:** run local `validate` first (route to the `validate` command) until `critical_count` is 0.
+
+## Capability gate
+
+Requires:
+
+- Reachable RHDH with Scaffolder v2 API
+- PyYAML available (`uv sync --extra dev` in this repo; or system PyYAML)
+- Valid `template.yaml` and optional `skeleton/` directory
+
+If RHDH is unreachable, skip and suggest local `validate` only.
+
+## Step 1: Prepare values
+
+Create a JSON file with parameter values matching `spec.parameters`:
+
+```json
+{
+ "componentId": "demo-service",
+ "owner": "group:default/team-a",
+ "description": "Demo from dry-run"
+}
+```
+
+Use fake data — never real tokens or production identifiers unless the user explicitly provides them.
+
+## Step 2: Run dry-run script
+
+```bash
+python /scripts/dry_run.py \
+ --rhdh-url https://rhdh.example.com \
+ --path templates/my-template/ \
+ --values /tmp/values.json \
+ [--secrets /tmp/secrets.json] \
+ [--token TOKEN] \
+ [--json]
+```
+
+The script:
+
+1. Parses `template.yaml`
+2. Serializes `skeleton/` as base64 directory contents
+3. POSTs to `/api/scaffolder/v2/dry-run`
+4. Returns log lines, output metadata, and generated file count
+
+## Step 3: Interpret failures
+
+| Symptom | Likely cause |
+|---------|--------------|
+| 401/403 | Missing or expired token |
+| 400 with `errors` | Invalid parameter values or malformed template |
+| Action not found | Wrong action ID — run `list-actions` |
+| Empty skeleton output | Missing `fetch:template` step or wrong `values` wiring |
+
+Re-run local `validate` and `fix-gotchas` before retrying dry-run.
+
+
+
+
+
+- Dry-run completes with exit code 0
+- Log shows expected steps executed (especially `fetch:template`)
+- User understands dry-run skips real mutations and may need manual E2E verification
+
+
diff --git a/skills/rhdh-templates/references/example-catalog.md b/skills/rhdh-templates/references/example-catalog.md
new file mode 100644
index 0000000..eb6e641
--- /dev/null
+++ b/skills/rhdh-templates/references/example-catalog.md
@@ -0,0 +1,116 @@
+# example-catalog — Reference Template Catalog
+
+
+
+- `assets/example-catalog.json` (data source — do not edit by hand during normal authoring)
+
+
+
+
+
+Surface curated reference templates teams reach for most often. Use when the user asks "what templates exist?", "show me an example", "what do teams usually build?", or before `create` / `templatize` to pick a study reference.
+
+## Step 1: Parse intent
+
+Extract from the user's message:
+
+- **Stack** — go, nodejs, java, spring-boot, quarkus, python, ai, rag, ansible, …
+- **Workflow** — new service, import existing, add CI/GitOps, AI agent, plugin scaffold
+- **Constraints** — offline/local only, official sources only, recommended starters
+
+## Step 2: Query the catalog
+
+```bash
+python /scripts/list_examples.py --match "" --limit 5 --json
+```
+
+Use filters when intent is narrow:
+
+```bash
+python /scripts/list_examples.py --category backend --recommended --json
+python /scripts/list_examples.py --stack python --json
+python /scripts/list_examples.py --local-only --json
+```
+
+Consume full JSON output. Never pipe through `head`, `tail`, or `grep`.
+
+## Step 3: Present results
+
+For each match, show:
+
+1. **Title** and **category**
+2. **URL** to upstream `template.yaml` (or bundled `assets/examples/` path)
+3. **Why it matches** — stack, use case, or recommended tag
+4. **Next step** — "Study this before authoring" or "Open bundled `assets/examples/` for offline validation"
+
+Repeat the catalog **disclaimer** once (see below).
+
+## Step 4: Offer handoff
+
+Ask whether to:
+
+- **`create`** — build a similar template from scratch
+- **`templatize`** — convert their existing codebase using the reference as a pattern guide
+- **`validate`** — check their current template against conventions
+
+If the user only wanted a list, stop after Step 3.
+
+---
+
+## Disclaimer
+
+Upstream templates in GitHub are **learning aids**, not production-ready golden paths. Always validate, test in a safe RHDH environment, and customize for your organization's standards. See the upstream repo caution in [red-hat-developer-hub-software-templates](https://github.com/redhat-developer/red-hat-developer-hub-software-templates).
+
+## Primary sources
+
+| Source | Repo | When to study it |
+|--------|------|------------------|
+| Official library | [red-hat-developer-hub-software-templates](https://github.com/redhat-developer/red-hat-developer-hub-software-templates) | Backend + CI, catalog import, Tekton/ArgoCD, Ansible, plugin scaffolding |
+| AI quickstarts | [aiquickstarttemplates](https://github.com/redhat-developer/aiquickstarttemplates) | RAG chatbots, IT agents, LLM serving on OpenShift AI |
+| AI Lab samples | [ai-lab-template](https://github.com/redhat-ai-dev/ai-lab-template) | Smaller AI samples (RAG, chatbot, codegen) |
+| Bundled skill examples | `assets/examples/` in this skill | Local validation, minimal patterns without cloning repos |
+
+## Categories (common demand order)
+
+1. **backend** — Go, Node.js, Spring Boot, Quarkus, Python services with CI (most common golden path)
+2. **catalog** — Register existing repos into the Software Catalog
+3. **cicd / gitops** — Tekton pipelines, ArgoCD bootstrap
+4. **automation** — Ansible Automation Platform job templates
+5. **ai** — RAG, agents, LLM serving (fastest-growing ask)
+6. **plugin** — Dynamic frontend/backend plugin scaffolding
+7. **docs** — TechDocs starter
+8. **starter** — Bundled minimal examples for local learning
+
+Templates marked **recommended** upstream (`tags: recommended` in their `template.yaml`) are the default "start here" references.
+
+## How to use references during authoring
+
+| Situation | Action |
+|-----------|--------|
+| User describes a new template | Run `--match` on their description; suggest top 1–3 upstream URLs to study |
+| Match has `local_bundled` | Also open `assets/examples//` for offline validation patterns |
+| Templatizing existing code | Compare detected stack from `analyze.py` to `--stack` filter |
+| AI / RAG / agent request | Prefer `ai-quickstart-templates` over generic backend examples |
+| Import-only workflow | Point at `register-component` |
+
+Do not copy upstream skeletons wholesale into target repos without review — study their **parameter forms**, **step sequences**, and **conventions**, then adapt.
+
+## Catalog maintenance
+
+Data lives in `assets/example-catalog.json`. Refresh when:
+
+- `templates.yaml` changes in the official library
+- New AI quickstart templates ship
+- Teams report a recurring pattern not yet listed
+
+Run `list_examples.py --json` after edits to verify parsing.
+
+
+
+
+
+- `list_examples.py --json` returns at least one relevant match for the stated intent, or clearly reports zero matches with suggested broader filters
+- User receives clickable upstream URLs plus local bundled paths when available
+- Disclaimer stated once per session when showing upstream templates
+
+
diff --git a/skills/rhdh-templates/references/explain-action.md b/skills/rhdh-templates/references/explain-action.md
new file mode 100644
index 0000000..5ac80fb
--- /dev/null
+++ b/skills/rhdh-templates/references/explain-action.md
@@ -0,0 +1,65 @@
+# explain-action — Action or Template Schema Reference
+
+
+
+- `conventions.md`
+
+
+
+
+
+Show input/output JSON Schema for a Scaffolder action, or the parameter form schema for a catalog Template entity.
+
+## Mode selection
+
+| User asks about | Flag |
+|-----------------|------|
+| Scaffolder action input fields (`publish:github`, `fetch:template`, …) | `--action ` |
+| Template form parameters for a registered template | `--template-ref template:namespace/name` |
+
+Provide exactly one mode per invocation.
+
+## Step 1: Run explain script
+
+**Action schema** (from list-actions response):
+
+```bash
+python /scripts/explain_action.py \
+ --rhdh-url https://rhdh.example.com \
+ --action publish:github \
+ [--token TOKEN] \
+ [--json]
+```
+
+**Template parameter schema** (from parameter-schema endpoint):
+
+```bash
+python /scripts/explain_action.py \
+ --rhdh-url https://rhdh.example.com \
+ --template-ref template:default/my-template \
+ [--token TOKEN] \
+ [--json]
+```
+
+## Step 2: Apply schema to authoring
+
+For `add-step`:
+
+- Wire `input:` fields to match `schema.input` required properties
+- Reference prior step outputs using `${{ steps..output. }}`
+
+For `add-parameter`:
+
+- Compare proposed fields against an existing template's parameter schema when templating from a registered example
+
+If action is not found, run `list-actions --filter ` to discover the correct id.
+
+
+
+
+
+- Schema JSON returned for the requested action or template
+- User can author a step or parameter block without guessing field names
+- Action id matches live instance casing
+
+
diff --git a/skills/rhdh-templates/references/fix-gotchas.md b/skills/rhdh-templates/references/fix-gotchas.md
new file mode 100644
index 0000000..db39c25
--- /dev/null
+++ b/skills/rhdh-templates/references/fix-gotchas.md
@@ -0,0 +1,65 @@
+# fix-gotchas — Apply Common Template Corrections
+
+
+
+- `conventions.md`
+
+
+
+
+
+Apply packaged rules to catch mistakes that pass YAML parsing but fail in Template Editor or at execution time.
+
+## Step 1: Target path
+
+Accept:
+
+- Path to `template.yaml`
+- Path to `templates//` directory (script finds `template.yaml`)
+
+## Step 2: Run checker
+
+```bash
+python /scripts/fix_gotchas.py \
+ --path \
+ [--apply] \
+ [--json]
+```
+
+Without `--apply`: report findings only.
+With `--apply`: write safe automatic fixes in place.
+
+## Step 3: Review findings
+
+| Severity | Action |
+|----------|--------|
+| critical | Must fix before merge — apply or manual edit |
+| warning | Recommend fix — explain to user |
+| info | Convention suggestion |
+
+Rule definitions (id, severity, check, auto-fix availability) live in `references/gotchas-rules.json`. The same rules run inside `validate.py`.
+
+## Step 4: Manual fixes
+
+Some rules are **detect-only** (require human judgment):
+
+- Correct `owner` entity refs
+- Choosing `copyWithoutTemplating` vs `{% raw %}`
+- Secret path selection for the user's integrations
+
+Document manual items in the response.
+
+## Step 5: Re-run until clean
+
+Loop until no critical findings remain, then run `validate` for full pre-merge checks.
+
+
+
+
+
+- `fix_gotchas.py` executed with JSON output reviewed
+- All critical findings resolved (auto or manual)
+- User informed of detect-only items
+- Template ready for Template Editor validation
+
+
diff --git a/skills/rhdh-templates/references/gotchas-rules.json b/skills/rhdh-templates/references/gotchas-rules.json
new file mode 100644
index 0000000..5ae335e
--- /dev/null
+++ b/skills/rhdh-templates/references/gotchas-rules.json
@@ -0,0 +1,88 @@
+{
+ "rules": [
+ {
+ "id": "api-version",
+ "severity": "critical",
+ "description": "template.yaml must use scaffolder.backstage.io/v1beta3",
+ "check": "api_version",
+ "fix": "set_api_version_v1beta3"
+ },
+ {
+ "id": "action-pascal-case",
+ "severity": "critical",
+ "description": "Scaffolder action IDs use camelCase after the colon (e.g., publish:github not publish:GitHub)",
+ "check": "action_pascal_case",
+ "fix": "lowercase_action_segment"
+ },
+ {
+ "id": "v1beta2-expressions",
+ "severity": "critical",
+ "description": "v1beta3 templates must use ${{ }} not bare {{ parameters. }}",
+ "check": "v1beta2_expression_syntax",
+ "fix": "convert_to_v1beta3_expressions"
+ },
+ {
+ "id": "hardcoded-token",
+ "severity": "critical",
+ "description": "Step inputs should not contain obvious hardcoded tokens or API keys",
+ "check": "hardcoded_secret",
+ "fix": null
+ },
+ {
+ "id": "missing-parameters-section",
+ "severity": "warning",
+ "description": "Production templates should define spec.parameters",
+ "check": "missing_parameters",
+ "fix": null
+ },
+ {
+ "id": "missing-steps-section",
+ "severity": "warning",
+ "description": "Templates should define at least one spec.steps entry",
+ "check": "missing_steps",
+ "fix": null
+ },
+ {
+ "id": "fetch-template-values",
+ "severity": "warning",
+ "description": "fetch:template steps should pass a values map when skeleton uses placeholders",
+ "check": "fetch_template_values",
+ "fix": null
+ },
+ {
+ "id": "workflow-raw-blocks",
+ "severity": "warning",
+ "description": "GitHub workflow files in skeleton with {{ should use {% raw %} or copyWithoutTemplating",
+ "check": "workflow_raw_blocks",
+ "fix": null
+ },
+ {
+ "id": "skeleton-parameters-ref",
+ "severity": "info",
+ "description": "Skeleton files should reference values.* not parameters.*",
+ "check": "skeleton_parameters_ref",
+ "fix": null
+ },
+ {
+ "id": "metadata-tags",
+ "severity": "warning",
+ "description": "metadata.tags improves template discoverability in the Create UI (best-practices tip 8)",
+ "check": "metadata_tags",
+ "fix": null
+ },
+ {
+ "id": "sensitive-param-secret-field",
+ "severity": "warning",
+ "description": "Sensitive parameters (password, token, secret) should use ui:field: Secret (best-practices tip 7)",
+ "check": "sensitive_param_secret_field",
+ "fix": null
+ },
+ {
+ "id": "template-docs",
+ "severity": "info",
+ "description": "Non-trivial templates should include README.md or TechDocs annotation (best-practices tip 9)",
+ "check": "template_docs",
+ "fix": null
+ }
+ ]
+}
diff --git a/skills/rhdh-templates/references/init.md b/skills/rhdh-templates/references/init.md
new file mode 100644
index 0000000..7d1548a
--- /dev/null
+++ b/skills/rhdh-templates/references/init.md
@@ -0,0 +1,60 @@
+# init — Setup Template Authoring Environment
+
+
+
+- `conventions.md` — target repository layout
+
+
+
+
+
+## Step 1: Run the init script
+
+From the user's target directory (or current directory):
+
+```bash
+python /scripts/init.py --path . [--rhdh-url https://rhdh.example.com] [--json]
+```
+
+The script:
+
+1. Checks required tools (`python3`, `git`)
+2. Reports recommended tools (`djlint` for Nunjucks — used by `validate --lint-skeleton`)
+3. Scaffolds `templates/example-template/` with starter `template.yaml` and `skeleton/`
+4. Creates root `location.yaml` if missing
+5. Optionally probes `GET /api/scaffolder/v2/actions` when `--rhdh-url` is set
+
+Consume full JSON output when `--json` is passed. Do not pipe through `head`, `tail`, or `grep`.
+
+## Step 2: Interpret results
+
+| Exit code | Meaning |
+|-----------|---------|
+| 0 | Ready — required tools present, layout scaffolded or already valid |
+| 1 | Partial — layout created but optional tools or RHDH unreachable |
+| 2 | Usage error |
+
+If RHDH is unreachable, tell the user local authoring and `validate` still work; use `list-actions` / `dry-run` when connectivity is available.
+
+## Step 3: Confirm with user
+
+Show:
+
+- Which tools are missing (if any) and install hints
+- Scaffolded paths
+- RHDH connectivity status (if checked)
+
+Ask whether to rename `example-template` or start `templatize` / `create` on the new layout.
+
+Mention the recommended dev loop from `best-practices.md`: local `validate` → Template Editor (`/create/edit`) → `dry-run` when RHDH is reachable. Route to `rhdh-local` skill if the user wants a local RHDH instance.
+
+
+
+
+
+- `init.py` exit code 0 or 1 with clear user messaging
+- `templates/` directory exists with at least one template folder
+- Root `location.yaml` exists with glob target `./templates/**/template.yaml`
+- User knows next command (`templatize`, `create`, or incremental add-*)
+
+
diff --git a/skills/rhdh-templates/references/list-actions.md b/skills/rhdh-templates/references/list-actions.md
new file mode 100644
index 0000000..2ab5db4
--- /dev/null
+++ b/skills/rhdh-templates/references/list-actions.md
@@ -0,0 +1,58 @@
+# list-actions — Discover Scaffolder Actions
+
+
+
+- `conventions.md` — action ID casing rules
+
+
+
+
+
+Query a running RHDH instance for installed Scaffolder actions. Requires network access to the RHDH backend.
+
+Equivalent UI: **Installed Actions** in Software Catalog or `/create/actions` (`best-practices.md` tip 3).
+
+## Capability gate
+
+Run only when:
+
+- User provides `--rhdh-url` or confirms their RHDH base URL
+- RHDH instance is reachable from the agent environment
+
+If unreachable, state in one line that the step is skipped and offer local `validate` instead. Do not ask the user to install tooling.
+
+## Step 1: Run list-actions script
+
+```bash
+python /scripts/list_actions.py --rhdh-url https://rhdh.example.com [--filter publish] [--token TOKEN] [--json]
+```
+
+Token resolution order:
+
+1. `--token` flag
+2. `RHDH_TOKEN` environment variable
+3. `BACKSTAGE_TOKEN` environment variable
+
+When RHDH uses backend permissions, the user may need a browser session token from the frontend.
+
+## Step 2: Use results
+
+Each action includes:
+
+- `id` — use this exact string in `template.yaml` `action:` fields
+- `description` — human-readable summary
+- `schema.input` / `schema.output` — JSON Schema for step wiring
+
+Filter with `--filter` when the user asks about a specific action family (e.g., `publish`, `fetch`, `catalog`).
+
+For detailed schema of one action, route to `explain-action`.
+
+
+
+
+
+- Script exits 0 with action list JSON
+- User can pick correct action IDs for `add-step` or template authoring
+- Action IDs match live instance (not guessed from docs)
+
+
diff --git a/skills/rhdh-templates/references/parameter-widgets.md b/skills/rhdh-templates/references/parameter-widgets.md
new file mode 100644
index 0000000..0086d96
--- /dev/null
+++ b/skills/rhdh-templates/references/parameter-widgets.md
@@ -0,0 +1,135 @@
+# Parameter Form Widgets
+
+
+
+Load when adding parameters via `add-parameter`, building `spec.parameters` in `templatize` or `create`, or choosing the right UI field for a form input.
+
+
+
+
+
+## Common property patterns
+
+| Use case | Type | Widget / options |
+|----------|------|------------------|
+| Component or repo ID | `string` | `pattern` + `ui:help` for kebab-case IDs |
+| Free text description | `string` | default text field |
+| Catalog owner | `string` | `ui:field: EntityPicker`, filter `Group`/`User` |
+| Catalog system | `string` | `ui:field: EntityPicker`, filter `System` |
+| Repository URL | `string` | `ui:field: RepoUrlPicker` + `allowedHosts` |
+| Environment choice | `string` | `enum` or `ui:widget: radio` |
+| Boolean toggle | `boolean` | default checkbox |
+| Numeric replicas / port | `number` | `minimum` / `maximum` when bounded |
+| Multi-select owners | `array` | `ui:widget: checkboxes` or EntityPicker multi |
+| Password / API token | `string` | `ui:field: Secret` (masks input in form, review, logs) |
+
+## EntityPicker (owner, system, domain)
+
+```yaml
+owner:
+ title: Owner
+ type: string
+ ui:field: EntityPicker
+ ui:options:
+ catalogFilter:
+ kind:
+ - Group
+ - User
+```
+
+Use `allowArbitraryValues: false` when the picker must resolve to catalog entities only.
+
+## RepoUrlPicker (GitHub / GitLab / Bitbucket)
+
+```yaml
+repoUrl:
+ title: Repository Location
+ type: string
+ ui:field: RepoUrlPicker
+ ui:options:
+ allowedHosts:
+ - github.com
+ - gitlab.com
+```
+
+Pair with `allowedOwners` when restricting orgs. The publish step reads `${{ parameters.repoUrl }}`.
+
+## Constrained IDs
+
+```yaml
+componentId:
+ title: Component ID
+ type: string
+ pattern: '^[a-z0-9-]*[a-z0-9]$'
+ ui:help: Lowercase letters, digits, and dashes only
+ ui:autofocus: true
+```
+
+## Conditional fields (dependencies)
+
+Show fields only when another field matches:
+
+```yaml
+parameters:
+ - title: Repository details
+ properties:
+ repoChoice:
+ title: Repository host
+ type: string
+ enum:
+ - github
+ - gitlab
+ default: github
+ githubOrg:
+ title: GitHub organization
+ type: string
+ dependencies:
+ repoChoice:
+ oneOf:
+ - properties:
+ repoChoice:
+ enum: [github]
+ githubOrg:
+ title: GitHub organization
+ type: string
+ required: [githubOrg]
+```
+
+## Parameter → skeleton wiring
+
+Form keys use `parameters.`. Skeleton files use `values.` populated in `fetch:template`:
+
+```yaml
+values:
+ componentId: ${{ parameters.componentId }}
+ owner: ${{ parameters.owner }}
+```
+
+Every parameter referenced in skeleton or downstream steps must appear in a `values` map or step `input`.
+
+## Secret field (sensitive inputs)
+
+Use for passwords, tokens, and API keys — never a plain text field:
+
+```yaml
+apiToken:
+ title: API Token
+ type: string
+ ui:field: Secret
+ ui:options:
+ visibilityToggle: true
+```
+
+See `best-practices.md` tip 7 for step-level `${{ secrets.* }}` wiring.
+
+## Template filters in step expressions
+
+EntityPicker values are string refs (`component:default/my-service`). Use filters in step `input`, not skeleton files:
+
+```yaml
+repoName: ${{ parameters.component | parseEntityRef | pick('name') }}
+```
+
+See `best-practices.md` tip 5 for common filters.
+
+
diff --git a/skills/rhdh-templates/references/schemas/template-v1beta3.schema.json b/skills/rhdh-templates/references/schemas/template-v1beta3.schema.json
new file mode 100644
index 0000000..f285e2b
--- /dev/null
+++ b/skills/rhdh-templates/references/schemas/template-v1beta3.schema.json
@@ -0,0 +1,156 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "https://rhdh-skill.local/schemas/template-v1beta3.schema.json",
+ "title": "Backstage Software Template v1beta3",
+ "description": "Standalone subset of backstage/backstage Template.v1beta3.schema.json for local validation (no external $ref).",
+ "type": "object",
+ "required": ["apiVersion", "kind", "metadata", "spec"],
+ "additionalProperties": true,
+ "properties": {
+ "apiVersion": {
+ "type": "string",
+ "enum": ["scaffolder.backstage.io/v1beta3"]
+ },
+ "kind": {
+ "type": "string",
+ "enum": ["Template"]
+ },
+ "metadata": {
+ "type": "object",
+ "required": ["name"],
+ "properties": {
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
+ },
+ "title": { "type": "string", "minLength": 1 },
+ "description": { "type": "string" },
+ "tags": {
+ "type": "array",
+ "items": { "type": "string", "minLength": 1 }
+ },
+ "annotations": {
+ "type": "object",
+ "additionalProperties": { "type": "string" }
+ }
+ },
+ "additionalProperties": true
+ },
+ "spec": {
+ "type": "object",
+ "required": ["type", "steps"],
+ "properties": {
+ "owner": { "type": "string", "minLength": 1 },
+ "type": { "type": "string", "minLength": 1 },
+ "lifecycle": { "type": "string", "minLength": 1 },
+ "parameters": {
+ "oneOf": [
+ { "$ref": "#/definitions/parameterForm" },
+ {
+ "type": "array",
+ "items": { "$ref": "#/definitions/parameterForm" }
+ }
+ ]
+ },
+ "steps": {
+ "type": "array",
+ "minItems": 1,
+ "items": { "$ref": "#/definitions/templateStep" }
+ },
+ "output": {
+ "type": "object",
+ "properties": {
+ "links": {
+ "type": "array",
+ "items": { "$ref": "#/definitions/outputLink" }
+ },
+ "text": {
+ "type": "array",
+ "items": { "type": "object" }
+ }
+ },
+ "additionalProperties": { "type": "string" }
+ },
+ "secrets": {
+ "type": "object",
+ "properties": {
+ "schema": { "type": "object" }
+ },
+ "additionalProperties": true
+ }
+ },
+ "additionalProperties": true
+ }
+ },
+ "definitions": {
+ "parameterForm": {
+ "type": "object",
+ "properties": {
+ "title": { "type": "string" },
+ "description": { "type": "string" },
+ "required": {
+ "type": "array",
+ "items": { "type": "string" }
+ },
+ "properties": {
+ "type": "object",
+ "additionalProperties": { "$ref": "#/definitions/jsonSchemaProperty" }
+ },
+ "dependencies": { "type": "object" },
+ "oneOf": { "type": "array" },
+ "allOf": { "type": "array" }
+ },
+ "additionalProperties": true
+ },
+ "jsonSchemaProperty": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["string", "number", "integer", "boolean", "array", "object", "null"]
+ },
+ "title": { "type": "string" },
+ "description": { "type": "string" },
+ "enum": { "type": "array" },
+ "items": { "type": "object" },
+ "properties": { "type": "object" },
+ "ui:field": { "type": "string" },
+ "ui:widget": { "type": "string" },
+ "ui:options": { "type": "object" }
+ },
+ "additionalProperties": true
+ },
+ "templateStep": {
+ "type": "object",
+ "required": ["action"],
+ "properties": {
+ "id": { "type": "string", "minLength": 1 },
+ "name": { "type": "string" },
+ "action": {
+ "type": "string",
+ "minLength": 1,
+ "pattern": "^[a-z][a-z0-9-]*:[a-z][a-zA-Z0-9]*$"
+ },
+ "input": { "type": "object" },
+ "if": {
+ "oneOf": [{ "type": "string" }, { "type": "boolean" }]
+ }
+ },
+ "additionalProperties": true
+ },
+ "outputLink": {
+ "type": "object",
+ "properties": {
+ "title": { "type": "string" },
+ "url": { "type": "string" },
+ "entityRef": { "type": "string" },
+ "icon": { "type": "string" },
+ "if": {
+ "oneOf": [{ "type": "string" }, { "type": "boolean" }]
+ }
+ },
+ "additionalProperties": true
+ }
+ }
+}
diff --git a/skills/rhdh-templates/references/template-structure.md b/skills/rhdh-templates/references/template-structure.md
new file mode 100644
index 0000000..5ca2364
--- /dev/null
+++ b/skills/rhdh-templates/references/template-structure.md
@@ -0,0 +1,125 @@
+# template.yaml Structure
+
+
+
+Load when writing, reviewing, or explaining `template.yaml` sections.
+
+
+
+
+
+## Minimal anatomy
+
+```yaml
+apiVersion: scaffolder.backstage.io/v1beta3
+kind: Template
+metadata:
+ name: example-template
+ title: Example Template
+ description: Short description shown in the Create UI
+ tags:
+ - recommended
+spec:
+ owner: group:default/platform-team
+ type: service
+ parameters: [] # form sections
+ steps: [] # scaffolder actions in order
+ output: {} # links shown after completion
+```
+
+## metadata
+
+| Field | Purpose |
+|-------|---------|
+| `name` | Machine ID (lowercase, hyphens) — unique in catalog |
+| `title` | Human label in Create UI |
+| `description` | Shown in template picker |
+| `tags` | Filtering in UI (`recommended` highlights template) |
+| `annotations.backstage.io/techdocs-ref` | Optional — `dir:.` when template ships TechDocs |
+
+## spec.parameters
+
+Array of **form sections**. Each section has `title`, optional `required`, `properties`, and optional `dependencies` for conditional fields.
+
+```yaml
+parameters:
+ - title: Component details
+ required:
+ - repoName
+ - owner
+ properties:
+ repoName:
+ title: Repository Name
+ type: string
+ owner:
+ title: Owner
+ type: string
+ ui:field: EntityPicker
+ ui:options:
+ catalogFilter:
+ kind: [Group, User]
+```
+
+Use `dependencies` + `oneOf` / `allOf` for conditional fields (see RHDH software-templates examples).
+
+## spec.steps
+
+Ordered list of actions. Each step needs unique `id`, human `name`, `action`, and `input`.
+
+```yaml
+steps:
+ - id: fetch-base
+ name: Fetch skeleton
+ action: fetch:template
+ input:
+ url: ./skeleton
+ values:
+ repoName: ${{ parameters.repoName }}
+ owner: ${{ parameters.owner }}
+
+ - id: publish
+ name: Publish to GitHub
+ action: publish:github
+ input:
+ repoUrl: ${{ parameters.repoUrl }}
+ description: ${{ parameters.description }}
+ defaultBranch: main
+
+ - id: register
+ name: Register in catalog
+ action: catalog:register
+ input:
+ repoContentsUrl: ${{ steps.publish.output.repoContentsUrl }}
+ catalogInfoPath: /catalog-info.yaml
+```
+
+Reference prior step outputs as `${{ steps..output. }}`.
+
+## spec.output
+
+Optional links/icons after success:
+
+```yaml
+output:
+ links:
+ - title: Open repository
+ url: ${{ steps.publish.output.remoteUrl }}
+ - title: View in catalog
+ icon: catalog
+ entityRef: ${{ steps.register.output.entityRef }}
+```
+
+## copyWithoutTemplating
+
+On `fetch:template`, exclude paths that must not be Nunjucks-processed:
+
+```yaml
+input:
+ url: ./skeleton
+ copyWithoutTemplating:
+ - .github/workflows/
+```
+
+Use when workflow files are parameterized separately or wrapped in `{% raw %}`.
+
+
diff --git a/skills/rhdh-templates/references/templatize.md b/skills/rhdh-templates/references/templatize.md
new file mode 100644
index 0000000..9ef4b52
--- /dev/null
+++ b/skills/rhdh-templates/references/templatize.md
@@ -0,0 +1,156 @@
+# templatize — Convert Existing Codebase to Template
+
+
+
+- `conventions.md`
+- `template-structure.md`
+- `example-catalog.md` — upstream references by stack and workflow
+
+
+
+
+
+Templatize is the **highest-value workflow**: most platform engineers start from working code, not a blank template.
+
+## Overview
+
+```
+analyze → review → scaffold → template.yaml → location.yaml → fix-gotchas handoff
+```
+
+Interactive at every decision point. Do not auto-parameterize without user confirmation.
+
+---
+
+### Phase 1: Analyze
+
+1. Confirm source path — local directory or cloned repo.
+2. Run the analyzer script (deterministic scan):
+
+```bash
+python /scripts/analyze.py --path [--json]
+```
+
+Consume full JSON output. It reports:
+
+- `project_types` — detected stack markers (nodejs, java-maven, python, …)
+- `candidate_literals` — heuristic list with category, sources, and `usually_parameterize` hint
+- `workflow_files` — `.github/workflows/*` files that may need `{% raw %}`
+
+3. Supplement script output with manual review for business-specific literals the heuristics miss.
+4. Match reference templates for the detected stack:
+
+```bash
+python /scripts/list_examples.py \
+ --match "" \
+ --limit 3 --json
+```
+
+Suggest upstream examples to study (parameter forms, publish/register steps, CI patterns). Prefer `local_bundled` paths when offline validation is needed.
+5. List **candidate literals** for parameterization:
+
+| Category | Examples | Usually parameterize? |
+|----------|----------|----------------------|
+| Names | repo name, app name, namespace | Yes |
+| Org/owner | GitHub org, catalog owner | Yes |
+| URLs/hosts | registry, cluster API | Often |
+| Ports, replicas | `8080`, `3` | Sometimes |
+| Boilerplate | framework defaults, LICENSE | Rarely |
+
+6. Flag files needing `{% raw %}` (`.github/workflows/`, Helm templates).
+
+Output a **candidate table** for user review before editing files.
+
+---
+
+### Phase 2: Review (user gate)
+
+Present the candidate table. For each row ask: parameterize / keep literal / skip.
+
+Principles:
+
+- **Conservative** — when uncertain, keep literal; user can `add-parameter` later.
+- **Group parameters** — repo + owner + system belong in one form section.
+- **Match RHDH examples** — compare against [red-hat-developer-hub-software-templates](https://github.com/redhat-developer/red-hat-developer-hub-software-templates) patterns for similar stacks.
+
+Do not proceed until user confirms the parameter list.
+
+---
+
+### Phase 3: Scaffold
+
+1. Create `templates//skeleton/` in the **template repo** (not inside source repo unless user directs).
+2. Copy source files into `skeleton/`, preserving structure.
+3. Replace confirmed literals with Nunjucks `{{ values. }}` placeholders.
+4. Add `{% raw %}` blocks to CI/workflow files as needed.
+5. Include `catalog-info.yaml` in skeleton when the template should register a Component.
+
+---
+
+### Phase 4: template.yaml
+
+1. Read `template-structure.md` and `assets/examples/minimal-template/template.yaml`.
+2. Set `metadata.name`, `title`, `description`, `tags` from user input.
+3. Build `spec.parameters` from confirmed parameter list with appropriate `ui:field` widgets.
+4. Build `spec.steps`:
+
+ | Typical order | Action |
+ |---------------|--------|
+ | 1 | `fetch:template` → `./skeleton` with full `values` map |
+ | 2 | `publish:github` or user's SCM action |
+ | 3 | `catalog:register` |
+
+5. Add `spec.output` links to repo and catalog entity.
+
+---
+
+### Phase 5: location.yaml
+
+If root `location.yaml` missing or stale:
+
+```bash
+python /scripts/create_location.py --path [--json]
+```
+
+Templatize may emit `location.yaml` inline; `create-location` remains the standalone utility for updates.
+
+---
+
+### Phase 6: fix-gotchas handoff
+
+```bash
+python /scripts/fix_gotchas.py --path templates//template.yaml [--apply] [--json]
+```
+
+Review script output. Apply fixes with `--apply`. Re-run until no critical findings remain.
+
+Run local validation:
+
+```bash
+python /scripts/validate.py --path templates// --repo [--lint-skeleton] [--json]
+```
+
+When RHDH is reachable, optionally run `dry-run` with sample parameter values.
+
+### Critique and fix loop
+
+Before finishing, self-check and patch until no material issues remain:
+
+1. Did the user confirm every parameterized literal?
+2. Does `validate.py --json` report zero critical findings?
+3. Are all skeleton placeholders wired through `fetch:template` `values`?
+4. Are workflow files either wrapped in `{% raw %}` or listed in `copyWithoutTemplating`?
+
+Exit bar: template passes `validate.py` with no critical issues and matches `conventions.md`.
+
+
+
+
+
+- User confirmed parameter map before skeleton edits
+- `templates//template.yaml`, `skeleton/`, and root `location.yaml` exist
+- Skeleton uses `values.*` not `parameters.*`
+- `validate.py` reports zero critical issues
+- Template structure matches conventions.md
+
+
diff --git a/skills/rhdh-templates/references/validate.md b/skills/rhdh-templates/references/validate.md
new file mode 100644
index 0000000..0da3b07
--- /dev/null
+++ b/skills/rhdh-templates/references/validate.md
@@ -0,0 +1,81 @@
+# validate — Local Template Validation
+
+
+
+- `conventions.md`
+
+
+
+
+
+Validate without a running RHDH instance. Combines YAML structure checks, JSON Schema validation (structural subset always; full bundled schema when `jsonschema` is installed), packaged gotcha rules, optional repo `location.yaml` verification, and optional djLint for skeleton files.
+
+## Step 1: Run validate script
+
+```bash
+python /scripts/validate.py --path [--repo] [--lint-skeleton] [--json]
+```
+
+| Flag | Purpose |
+|------|---------|
+| `--repo` | Also check root `location.yaml` for the template repository |
+| `--lint-skeleton` | Run djLint on `skeleton/` when installed |
+| `--no-json-schema` | Skip optional full JSON Schema validation (structural checks still run) |
+| `--json` | Structured output for agent consumption |
+
+Consume full JSON output when `--json` is passed. Do not pipe through `head`, `tail`, or `grep`.
+
+## Step 2: Interpret results
+
+| Exit code | Meaning |
+|-----------|---------|
+| 0 | No critical findings |
+| 1 | Critical findings remain |
+| 2 | Usage error |
+
+Severity levels:
+
+- **critical** — likely Template Editor failure (wrong apiVersion, action casing, invalid schema, v1beta2 expressions)
+- **warning** — should fix before merge (missing parameters, unknown parameter refs, workflow raw blocks)
+- **info** — optional tooling skipped (PyYAML/djlint/jsonschema not installed)
+
+## Step 3: Fix and re-run
+
+1. Run `fix-gotchas` with `--apply` for auto-fixable critical issues.
+2. Manually address remaining findings.
+3. Re-run `validate.py` until `critical_count` is 0.
+
+For remote execution validation against a live RHDH instance, use `dry-run` after local validation passes.
+
+Review warning/info findings against the `` in `best-practices.md` (tags, docs, Secret fields, maintenance).
+
+## Nunjucks skeleton lint (`--lint-skeleton`)
+
+Use when the user asks to lint Nunjucks, run djLint, or check skeleton syntax only. There is no separate script — pass `--lint-skeleton` to `validate.py`.
+
+Install djLint when missing:
+
+```bash
+pip install djlint
+# or: uv tool install djlint
+```
+
+If djLint is absent, `validate` reports an **info** finding and continues — YAML and gotcha checks still run.
+
+Common Nunjucks fixes:
+
+- Wrap GitHub Actions `${{ }}` in `{% raw %}…{% endraw %}` (see `conventions.md`)
+- Use `{{ values.field }}` not `{{ parameters.field }}` in skeleton files
+- Add missing `{% endif %}` / `{% endfor %}` closers
+
+For pre-merge checks, run full `validate` with and without `--lint-skeleton`, or combine both in one invocation.
+
+
+
+
+
+- `validate.py --json` reports `ok: true` and `critical_count: 0`
+- User understands any warnings and whether to fix them before merge
+- When `--lint-skeleton` is used, Nunjucks issues are surfaced or djlint skip is documented
+
+
diff --git a/skills/rhdh-templates/scripts/analyze.py b/skills/rhdh-templates/scripts/analyze.py
new file mode 100644
index 0000000..9db5782
--- /dev/null
+++ b/skills/rhdh-templates/scripts/analyze.py
@@ -0,0 +1,329 @@
+#!/usr/bin/env python3
+"""Analyze a source codebase for Software Template templatize workflow.
+
+Detects project type, candidate literals for parameterization, and files
+that likely need {% raw %} blocks or copyWithoutTemplating.
+
+Stdlib only per project ADR-0002.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import re
+import sys
+from pathlib import Path
+
+EXIT_SUCCESS = 0
+EXIT_USAGE = 2
+
+_no_color = os.environ.get("NO_COLOR") is not None
+_is_tty = sys.stderr.isatty() and not _no_color
+
+TEXT_EXTENSIONS = {
+ ".md",
+ ".yaml",
+ ".yml",
+ ".json",
+ ".xml",
+ ".properties",
+ ".env",
+ ".txt",
+ ".gradle",
+ ".kts",
+ ".toml",
+ ".ini",
+ ".cfg",
+ ".sh",
+ ".bash",
+ ".Dockerfile",
+}
+TEXT_FILENAMES = {
+ "Dockerfile",
+ "Makefile",
+ "pom.xml",
+ "build.gradle",
+ "settings.gradle",
+ "go.mod",
+ "catalog-info.yaml",
+ "package.json",
+ "pyproject.toml",
+ "Chart.yaml",
+}
+
+PROJECT_MARKERS = [
+ ("nodejs", ["package.json"]),
+ ("java-maven", ["pom.xml"]),
+ ("java-gradle", ["build.gradle", "build.gradle.kts"]),
+ ("python", ["pyproject.toml", "setup.py", "requirements.txt"]),
+ ("go", ["go.mod"]),
+ ("dotnet", ["*.csproj"]),
+ ("helm", ["Chart.yaml"]),
+ ("quarkus", ["pom.xml", ".quarkus"]),
+ ("spring-boot", ["pom.xml", "src/main/resources/application.properties"]),
+ ("kubernetes", ["k8s", "kubernetes", "deploy", "manifests"]),
+]
+
+GITHUB_URL = re.compile(r"github\.com[/:]([\w.-]+)/([\w.-]+)")
+GITLAB_URL = re.compile(r"gitlab\.(?:com|[^/]+)[/:]([\w.-]+)/([\w.-]+)")
+K8S_NAME = re.compile(r"^\s*name:\s*['\"]?([\w.-]+)['\"]?\s*$", re.MULTILINE)
+K8S_NAMESPACE = re.compile(r"^\s*namespace:\s*['\"]?([\w.-]+)['\"]?\s*$", re.MULTILINE)
+PORT = re.compile(r"\b(?:port|PORT)\s*[=:]\s*(\d{2,5})\b")
+
+
+def _read_text(path: Path, max_bytes: int = 256_000) -> str | None:
+ try:
+ data = path.read_bytes()
+ except OSError:
+ return None
+ if len(data) > max_bytes:
+ data = data[:max_bytes]
+ try:
+ return data.decode("utf-8")
+ except UnicodeDecodeError:
+ return None
+
+
+def _is_text_candidate(path: Path) -> bool:
+ if path.name in TEXT_FILENAMES:
+ return True
+ if path.suffix.lower() in TEXT_EXTENSIONS:
+ return True
+ return False
+
+
+def detect_project_types(root: Path) -> list[str]:
+ found: list[str] = []
+ names = {p.name for p in root.rglob("*") if p.is_file()}
+ rel_dirs = {p.relative_to(root).parts[0] for p in root.rglob("*") if p.is_dir() and p != root}
+
+ for project_type, markers in PROJECT_MARKERS:
+ for marker in markers:
+ if marker.startswith("*"):
+ if any(n.endswith(marker[1:]) for n in names):
+ found.append(project_type)
+ break
+ elif marker in names:
+ found.append(project_type)
+ break
+ elif marker in rel_dirs:
+ found.append(project_type)
+ break
+ return sorted(set(found))
+
+
+def find_workflow_files(root: Path) -> list[dict]:
+ results = []
+ for path in sorted(root.rglob("*")):
+ if not path.is_file():
+ continue
+ rel = path.relative_to(root).as_posix()
+ if ".github/workflows" not in rel:
+ continue
+ text = _read_text(path)
+ if text is None:
+ continue
+ needs_raw = "{{" in text and "{% raw %}" not in text
+ results.append(
+ {
+ "path": rel,
+ "needs_raw_block": needs_raw,
+ "reason": "Contains '{{' without {% raw %} wrapper" if needs_raw else None,
+ }
+ )
+ return results
+
+
+def _add_candidate(
+ candidates: dict[str, dict],
+ value: str,
+ *,
+ category: str,
+ source: str,
+ usually_parameterize: str = "maybe",
+) -> None:
+ value = value.strip().strip("'\"")
+ if not value or len(value) < 2:
+ return
+ if value in {".", "..", "main", "master", "true", "false", "null"}:
+ return
+ key = value.lower()
+ if key not in candidates:
+ candidates[key] = {
+ "value": value,
+ "category": category,
+ "sources": [source],
+ "usually_parameterize": usually_parameterize,
+ }
+ elif source not in candidates[key]["sources"]:
+ candidates[key]["sources"].append(source)
+
+
+def extract_from_package_json(path: Path, rel: str, candidates: dict[str, dict]) -> None:
+ text = _read_text(path)
+ if not text:
+ return
+ try:
+ data = json.loads(text)
+ except json.JSONDecodeError:
+ return
+ if isinstance(data.get("name"), str):
+ _add_candidate(
+ candidates, data["name"], category="name", source=rel, usually_parameterize="yes"
+ )
+ if isinstance(data.get("description"), str) and len(data["description"]) < 120:
+ _add_candidate(
+ candidates,
+ data["description"],
+ category="description",
+ source=rel,
+ usually_parameterize="sometimes",
+ )
+
+
+def extract_from_catalog_info(path: Path, rel: str, candidates: dict[str, dict]) -> None:
+ text = _read_text(path)
+ if not text:
+ return
+ for match in re.finditer(r"^\s*name:\s*['\"]?([\w.-]+)['\"]?\s*$", text, re.MULTILINE):
+ _add_candidate(
+ candidates, match.group(1), category="name", source=rel, usually_parameterize="yes"
+ )
+ for match in re.finditer(r"^\s*owner:\s*['\"]?([\w:./-]+)['\"]?\s*$", text, re.MULTILINE):
+ _add_candidate(
+ candidates, match.group(1), category="owner", source=rel, usually_parameterize="yes"
+ )
+
+
+def extract_from_pom(path: Path, rel: str, candidates: dict[str, dict]) -> None:
+ text = _read_text(path)
+ if not text:
+ return
+ for tag in ("artifactId", "groupId", "name"):
+ for match in re.finditer(rf"<{tag}>([^<]+){tag}>", text):
+ cat = "name" if tag != "groupId" else "org"
+ usually = "yes" if tag in {"artifactId", "name"} else "often"
+ _add_candidate(
+ candidates, match.group(1), category=cat, source=rel, usually_parameterize=usually
+ )
+
+
+def extract_urls(text: str, rel: str, candidates: dict[str, dict]) -> None:
+ for match in GITHUB_URL.finditer(text):
+ org, repo = match.group(1), match.group(2).removesuffix(".git")
+ _add_candidate(candidates, org, category="org", source=rel, usually_parameterize="yes")
+ _add_candidate(candidates, repo, category="name", source=rel, usually_parameterize="yes")
+ for match in GITLAB_URL.finditer(text):
+ org, repo = match.group(1), match.group(2).removesuffix(".git")
+ _add_candidate(candidates, org, category="org", source=rel, usually_parameterize="yes")
+ _add_candidate(candidates, repo, category="name", source=rel, usually_parameterize="yes")
+
+
+def scan_candidates(root: Path) -> list[dict]:
+ candidates: dict[str, dict] = {}
+ for path in sorted(root.rglob("*")):
+ if not path.is_file():
+ continue
+ rel = path.relative_to(root).as_posix()
+ if path.name == "package.json":
+ extract_from_package_json(path, rel, candidates)
+ elif path.name == "catalog-info.yaml":
+ extract_from_catalog_info(path, rel, candidates)
+ elif path.name == "pom.xml":
+ extract_from_pom(path, rel, candidates)
+ if not _is_text_candidate(path):
+ continue
+ text = _read_text(path)
+ if not text:
+ continue
+ extract_urls(text, rel, candidates)
+ for match in K8S_NAME.finditer(text):
+ _add_candidate(
+ candidates,
+ match.group(1),
+ category="name",
+ source=rel,
+ usually_parameterize="often",
+ )
+ for match in K8S_NAMESPACE.finditer(text):
+ _add_candidate(
+ candidates,
+ match.group(1),
+ category="namespace",
+ source=rel,
+ usually_parameterize="often",
+ )
+ for match in PORT.finditer(text):
+ _add_candidate(
+ candidates,
+ match.group(1),
+ category="port",
+ source=rel,
+ usually_parameterize="sometimes",
+ )
+ rows = list(candidates.values())
+ rows.sort(key=lambda r: (r["usually_parameterize"] != "yes", r["category"], r["value"]))
+ return rows
+
+
+def analyze(root: Path) -> dict:
+ project_types = detect_project_types(root)
+ workflows = find_workflow_files(root)
+ candidates = scan_candidates(root)
+ return {
+ "ok": True,
+ "path": str(root),
+ "project_types": project_types or ["unknown"],
+ "file_count": sum(1 for p in root.rglob("*") if p.is_file()),
+ "workflow_files": workflows,
+ "candidate_literals": candidates,
+ "candidate_count": len(candidates),
+ }
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(
+ description="Analyze a codebase for RHDH Software Template templatize workflow.",
+ )
+ parser.add_argument("source", type=Path, nargs="?", help="Source directory to analyze")
+ parser.add_argument("--path", type=Path, help="Alias for source directory")
+ parser.add_argument("--json", action="store_true", help="Emit JSON result")
+ args = parser.parse_args()
+
+ source = args.path or args.source
+ if source is None:
+ parser.print_help()
+ return EXIT_USAGE
+
+ root = source.resolve()
+ if not root.exists() or not root.is_dir():
+ print(f"Not a directory: {root}", file=sys.stderr)
+ return EXIT_USAGE
+
+ result = analyze(root)
+
+ if args.json:
+ print(json.dumps(result, indent=2 if _is_tty else None))
+ else:
+ print(f"Source: {root}")
+ print(f"Project types: {', '.join(result['project_types'])}")
+ print(f"Files: {result['file_count']}")
+ print(f"Candidate literals: {result['candidate_count']}")
+ for row in result["candidate_literals"][:20]:
+ print(
+ f" [{row['category']}] {row['value']} "
+ f"({row['usually_parameterize']}) — {', '.join(row['sources'][:2])}"
+ )
+ if result["workflow_files"]:
+ print("Workflow files:")
+ for wf in result["workflow_files"]:
+ flag = "needs raw" if wf["needs_raw_block"] else "ok"
+ print(f" {wf['path']}: {flag}")
+
+ return EXIT_SUCCESS
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/skills/rhdh-templates/scripts/command-metadata.json b/skills/rhdh-templates/scripts/command-metadata.json
new file mode 100644
index 0000000..ec5ee9d
--- /dev/null
+++ b/skills/rhdh-templates/scripts/command-metadata.json
@@ -0,0 +1,54 @@
+{
+ "init": {
+ "description": "Check required tooling, scaffold a recommended template-authoring layout, and optionally verify RHDH connectivity.",
+ "argumentHint": "[--rhdh-url URL] [--path DIR]"
+ },
+ "templatize": {
+ "description": "Convert an existing codebase into a parameterized Software Template via interactive analyze → review → scaffold → template.yaml → location.yaml flow.",
+ "argumentHint": "[source directory or repo path]"
+ },
+ "create": {
+ "description": "Guide from-scratch Software Template creation when no reference codebase exists.",
+ "argumentHint": "[template name or description]"
+ },
+ "add-parameter": {
+ "description": "Add a parameter (or parameter group) to an existing template.yaml with RHDH form conventions.",
+ "argumentHint": "[parameter name or description]"
+ },
+ "add-step": {
+ "description": "Add a scaffolder step to an existing template.yaml with correct action IDs and input wiring.",
+ "argumentHint": "[step description or action name]"
+ },
+ "add-skeleton": {
+ "description": "Add or extend skeleton files for an existing template, including Nunjucks parameterization.",
+ "argumentHint": "[file path or skeleton description]"
+ },
+ "create-location": {
+ "description": "Generate or update location.yaml that registers all template.yaml files under templates/.",
+ "argumentHint": "[--path DIR] [--name NAME]"
+ },
+ "fix-gotchas": {
+ "description": "Apply common RHDH template corrections (action casing, secrets syntax, raw/endraw, apiVersion) using packaged rules.",
+ "argumentHint": "[template directory or template.yaml path]"
+ },
+ "validate": {
+ "description": "Local template validation — YAML structure, JSON Schema checks, gotcha rules, optional location.yaml and djLint skeleton lint (--lint-skeleton). No RHDH instance required.",
+ "argumentHint": "[--path DIR] [--repo] [--lint-skeleton] [--no-json-schema]"
+ },
+ "list-actions": {
+ "description": "List Scaffolder actions from a running RHDH instance via GET /api/scaffolder/v2/actions.",
+ "argumentHint": "[--rhdh-url URL] [--filter substring]"
+ },
+ "dry-run": {
+ "description": "Execute template dry-run against RHDH Scaffolder v2 API without creating real resources.",
+ "argumentHint": "[--rhdh-url URL] [--path template-dir] [--values values.json]"
+ },
+ "explain-action": {
+ "description": "Show input/output JSON Schema for a Scaffolder action or parameter schema for a catalog Template.",
+ "argumentHint": "[--action id | --template-ref template:ns/name] [--rhdh-url URL]"
+ },
+ "examples": {
+ "description": "Browse curated reference Software Templates from the official library, AI quickstarts, and bundled skill examples.",
+ "argumentHint": "[--match \"intent\" | --category NAME | --stack NAME | --recommended]"
+ }
+}
diff --git a/skills/rhdh-templates/scripts/create_location.py b/skills/rhdh-templates/scripts/create_location.py
new file mode 100755
index 0000000..3ae4b3e
--- /dev/null
+++ b/skills/rhdh-templates/scripts/create_location.py
@@ -0,0 +1,109 @@
+#!/usr/bin/env python3
+"""Generate or update location.yaml for an RHDH template repository.
+
+Discovers templates/**/template.yaml and writes a Location entity at repo root.
+
+Stdlib only per project ADR-0002.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+from pathlib import Path
+
+EXIT_SUCCESS = 0
+EXIT_FAILURE = 1
+EXIT_USAGE = 2
+
+_no_color = os.environ.get("NO_COLOR") is not None
+_is_tty = sys.stderr.isatty() and not _no_color
+
+
+def discover_templates(root: Path) -> list[str]:
+ templates_dir = root / "templates"
+ if not templates_dir.is_dir():
+ return []
+ found: list[str] = []
+ for path in sorted(templates_dir.rglob("template.yaml")):
+ rel = path.relative_to(root).as_posix()
+ found.append(rel)
+ return found
+
+
+def build_location_yaml(name: str, description: str, targets: list[str]) -> str:
+ lines = [
+ "apiVersion: backstage.io/v1alpha1",
+ "kind: Location",
+ "metadata:",
+ f" name: {name}",
+ f" description: {description}",
+ "spec:",
+ " targets:",
+ ]
+ if targets:
+ for target in targets:
+ lines.append(f" - ./{target}")
+ else:
+ lines.append(" - ./templates/**/template.yaml")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Generate location.yaml for template repo.")
+ parser.add_argument("--path", type=Path, default=Path.cwd(), help="Repository root")
+ parser.add_argument("--name", help="metadata.name for Location (default:
-templates)")
+ parser.add_argument("--description", help="metadata.description")
+ parser.add_argument("--json", action="store_true", help="Emit JSON result")
+ parser.add_argument("--dry-run", action="store_true", help="Print YAML without writing")
+ args = parser.parse_args()
+
+ root = args.path.resolve()
+ if not root.exists():
+ root.mkdir(parents=True, exist_ok=True)
+ elif not root.is_dir():
+ print(f"Not a directory: {root}", file=sys.stderr)
+ return EXIT_USAGE
+
+ templates = discover_templates(root)
+ name = args.name or f"{root.name}-templates"
+ description = args.description or f"Software Templates in {root.name}"
+ content = build_location_yaml(name, description, templates)
+
+ location_path = root / "location.yaml"
+ written = False
+ if not args.dry_run:
+ location_path.write_text(content, encoding="utf-8")
+ written = True
+
+ result = {
+ "ok": True,
+ "path": str(root),
+ "location_file": str(location_path),
+ "written": written,
+ "template_count": len(templates),
+ "templates": templates,
+ "metadata_name": name,
+ }
+
+ if args.json:
+ print(json.dumps(result, indent=2 if _is_tty else None))
+ else:
+ print(f"Location: {location_path}")
+ print(f"Templates discovered: {len(templates)}")
+ for t in templates:
+ print(f" - {t}")
+ if written:
+ print("Wrote location.yaml")
+ elif args.dry_run:
+ print("--- dry-run ---")
+ print(content)
+
+ return EXIT_SUCCESS
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/skills/rhdh-templates/scripts/dry_run.py b/skills/rhdh-templates/scripts/dry_run.py
new file mode 100755
index 0000000..54b8a22
--- /dev/null
+++ b/skills/rhdh-templates/scripts/dry_run.py
@@ -0,0 +1,168 @@
+#!/usr/bin/env python3
+"""Dry-run a Software Template against a running RHDH Scaffolder.
+
+Uses POST /api/scaffolder/v2/dry-run with template.yaml, skeleton files, and
+parameter values.
+
+Requires PyYAML to parse template.yaml (available in project dev dependencies).
+
+Stdlib + optional PyYAML per project ADR-0002.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+from pathlib import Path
+
+EXIT_SUCCESS = 0
+EXIT_FAILURE = 1
+EXIT_USAGE = 2
+
+SCRIPTS_DIR = Path(__file__).resolve().parent
+sys.path.insert(0, str(SCRIPTS_DIR))
+
+from scaffolder_api import dry_run, load_directory_contents # noqa: E402
+
+_no_color = os.environ.get("NO_COLOR") is not None
+_is_tty = sys.stderr.isatty() and not _no_color
+
+
+def load_yaml_file(path: Path) -> dict:
+ try:
+ import yaml # type: ignore[import-untyped]
+ except ImportError as exc:
+ raise RuntimeError(
+ "PyYAML is required for dry-run. Install dev deps: uv sync --extra dev"
+ ) from exc
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
+ if not isinstance(data, dict):
+ raise ValueError(f"{path} must contain a YAML mapping")
+ return data
+
+
+def resolve_template_dir(path: Path) -> tuple[Path, Path]:
+ path = path.resolve()
+ if path.is_file():
+ template_yaml = path
+ template_dir = path.parent
+ elif path.is_dir():
+ template_dir = path
+ template_yaml = path / "template.yaml"
+ else:
+ raise FileNotFoundError(f"Path not found: {path}")
+ if not template_yaml.is_file():
+ raise FileNotFoundError(f"No template.yaml at {template_yaml}")
+ return template_yaml, template_dir
+
+
+def summarize_log(log: list) -> list[str]:
+ lines: list[str] = []
+ for entry in log or []:
+ body = entry.get("body") if isinstance(entry, dict) else None
+ if isinstance(body, dict) and body.get("message"):
+ lines.append(str(body["message"]))
+ return lines
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Dry-run an RHDH Software Template.")
+ parser.add_argument("--rhdh-url", required=True, help="RHDH base URL")
+ parser.add_argument(
+ "--path",
+ required=True,
+ type=Path,
+ help="Template directory or template.yaml path",
+ )
+ parser.add_argument(
+ "--values",
+ type=Path,
+ help="JSON file with parameter values (default: {})",
+ )
+ parser.add_argument(
+ "--secrets",
+ type=Path,
+ help="JSON file with secrets map (optional)",
+ )
+ parser.add_argument(
+ "--skeleton-dir",
+ type=Path,
+ help="Skeleton directory (default: /skeleton)",
+ )
+ parser.add_argument("--token", help="Bearer token (default: RHDH_TOKEN or BACKSTAGE_TOKEN env)")
+ parser.add_argument("--json", action="store_true", help="Emit JSON result")
+ args = parser.parse_args()
+
+ try:
+ template_yaml, template_dir = resolve_template_dir(args.path)
+ template = load_yaml_file(template_yaml)
+ values: dict = {}
+ if args.values:
+ values = json.loads(args.values.read_text(encoding="utf-8"))
+ if not isinstance(values, dict):
+ raise ValueError("--values file must contain a JSON object")
+ secrets: dict[str, str] | None = None
+ if args.secrets:
+ raw = json.loads(args.secrets.read_text(encoding="utf-8"))
+ if not isinstance(raw, dict):
+ raise ValueError("--secrets file must contain a JSON object")
+ secrets = {str(k): str(v) for k, v in raw.items()}
+
+ directory_contents: list[dict[str, str]] = []
+ content_root = args.skeleton_dir or template_dir
+ if content_root.is_dir():
+ if args.skeleton_dir:
+ prefix = content_root.relative_to(template_dir).as_posix()
+ for item in load_directory_contents(content_root):
+ directory_contents.append(
+ {
+ "path": f"{prefix}/{item['path']}",
+ "base64Content": item["base64Content"],
+ }
+ )
+ else:
+ directory_contents = load_directory_contents(content_root)
+
+ response = dry_run(
+ args.rhdh_url,
+ template=template,
+ values=values,
+ directory_contents=directory_contents,
+ secrets=secrets,
+ token=args.token,
+ )
+ except (RuntimeError, ValueError, FileNotFoundError, json.JSONDecodeError) as exc:
+ print(str(exc), file=sys.stderr)
+ return EXIT_FAILURE if isinstance(exc, RuntimeError) else EXIT_USAGE
+
+ log_lines = summarize_log(response.get("log", []))
+ output_files = response.get("directoryContents") or []
+ result = {
+ "ok": True,
+ "template": str(template_yaml),
+ "log_line_count": len(log_lines),
+ "log": log_lines,
+ "output_file_count": len(output_files),
+ "output": response.get("output"),
+ "steps": response.get("steps"),
+ }
+
+ if args.json:
+ print(json.dumps(result, indent=2 if _is_tty else None))
+ else:
+ print(f"Dry-run succeeded for {template_yaml}")
+ print(f"Log lines: {len(log_lines)}")
+ for line in log_lines[-10:]:
+ print(f" {line}")
+ print(f"Output files: {len(output_files)}")
+ if response.get("output"):
+ print("Output:")
+ print(json.dumps(response["output"], indent=2))
+
+ return EXIT_SUCCESS
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/skills/rhdh-templates/scripts/explain_action.py b/skills/rhdh-templates/scripts/explain_action.py
new file mode 100755
index 0000000..1a490a5
--- /dev/null
+++ b/skills/rhdh-templates/scripts/explain_action.py
@@ -0,0 +1,101 @@
+#!/usr/bin/env python3
+"""Explain a Scaffolder action or template parameter schema.
+
+For actions: fetches GET /api/scaffolder/v2/actions and returns the matching
+action's input/output JSON Schema.
+
+For templates: fetches GET /api/scaffolder/v2/templates/:ns/:kind/:name/parameter-schema.
+
+Stdlib only per project ADR-0002.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+from pathlib import Path
+
+EXIT_SUCCESS = 0
+EXIT_FAILURE = 1
+EXIT_USAGE = 2
+
+SCRIPTS_DIR = Path(__file__).resolve().parent
+sys.path.insert(0, str(SCRIPTS_DIR))
+
+from scaffolder_api import get_action_schema, get_template_parameter_schema # noqa: E402
+
+_no_color = os.environ.get("NO_COLOR") is not None
+_is_tty = sys.stderr.isatty() and not _no_color
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Explain Scaffolder action or template schema.")
+ parser.add_argument("--rhdh-url", required=True, help="RHDH base URL")
+ parser.add_argument("--action", help="Action id (e.g. publish:github)")
+ parser.add_argument(
+ "--template-ref",
+ help="Catalog template ref (e.g. template:default/my-template)",
+ )
+ parser.add_argument("--token", help="Bearer token (default: RHDH_TOKEN or BACKSTAGE_TOKEN env)")
+ parser.add_argument("--json", action="store_true", help="Emit JSON result")
+ args = parser.parse_args()
+
+ if bool(args.action) == bool(args.template_ref):
+ print("Provide exactly one of --action or --template-ref", file=sys.stderr)
+ return EXIT_USAGE
+
+ try:
+ if args.action:
+ action = get_action_schema(args.rhdh_url, args.action, token=args.token)
+ if action is None:
+ print(f"Action not found: {args.action}", file=sys.stderr)
+ return EXIT_FAILURE
+ result = {
+ "ok": True,
+ "type": "action",
+ "id": action.get("id"),
+ "description": action.get("description"),
+ "schema": action.get("schema"),
+ "examples": action.get("examples"),
+ }
+ else:
+ schema = get_template_parameter_schema(
+ args.rhdh_url,
+ args.template_ref,
+ token=args.token,
+ )
+ result = {
+ "ok": True,
+ "type": "template-parameter-schema",
+ "template_ref": args.template_ref,
+ "schema": schema,
+ }
+ except (RuntimeError, ValueError) as exc:
+ print(str(exc), file=sys.stderr)
+ return EXIT_FAILURE
+
+ if args.json:
+ print(json.dumps(result, indent=2 if _is_tty else None))
+ else:
+ if result["type"] == "action":
+ print(f"Action: {result['id']}")
+ if result.get("description"):
+ print(result["description"])
+ schema = result.get("schema") or {}
+ if schema.get("input"):
+ print("\nInput schema:")
+ print(json.dumps(schema["input"], indent=2))
+ if schema.get("output"):
+ print("\nOutput schema:")
+ print(json.dumps(schema["output"], indent=2))
+ else:
+ print(f"Template: {result['template_ref']}")
+ print(json.dumps(result["schema"], indent=2))
+
+ return EXIT_SUCCESS
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/skills/rhdh-templates/scripts/fix_gotchas.py b/skills/rhdh-templates/scripts/fix_gotchas.py
new file mode 100755
index 0000000..c953d37
--- /dev/null
+++ b/skills/rhdh-templates/scripts/fix_gotchas.py
@@ -0,0 +1,367 @@
+#!/usr/bin/env python3
+"""Check and fix common RHDH Software Template gotchas.
+
+Loads rules from references/gotchas-rules.json adjacent to the skill root.
+
+Stdlib only per project ADR-0002.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import re
+import sys
+from pathlib import Path
+
+EXIT_SUCCESS = 0
+EXIT_FINDINGS = 1
+EXIT_USAGE = 2
+
+_no_color = os.environ.get("NO_COLOR") is not None
+_is_tty = sys.stderr.isatty() and not _no_color
+
+TOKEN_PATTERNS = [
+ re.compile(r"(?i)(ghp_[a-zA-Z0-9]{20,})"),
+ re.compile(r"(?i)(github_pat_[a-zA-Z0-9_]{20,})"),
+ re.compile(r"(?i)(glpat-[a-zA-Z0-9\-]{20,})"),
+ re.compile(r"(?i)token:\s*['\"]?[a-zA-Z0-9_\-]{24,}"),
+]
+
+ACTION_PATTERN = re.compile(r"^\s*action:\s*([a-zA-Z]+:[a-zA-Z][a-zA-Z0-9]*)", re.MULTILINE)
+V1BETA2_EXPR = re.compile(r"(? list[dict]:
+ rules_path = skill_dir / "references" / "gotchas-rules.json"
+ data = json.loads(rules_path.read_text(encoding="utf-8"))
+ return data.get("rules", [])
+
+
+def resolve_template_path(path: Path) -> Path:
+ path = path.resolve()
+ if path.is_file():
+ return path
+ if path.is_dir():
+ candidate = path / "template.yaml"
+ if candidate.is_file():
+ return candidate
+ raise FileNotFoundError(f"No template.yaml at {path}")
+
+
+def check_api_version(content: str) -> list[dict]:
+ findings = []
+ match = API_VERSION_PATTERN.search(content)
+ if not match:
+ findings.append({"line": 0, "message": "Missing apiVersion field"})
+ elif "scaffolder.backstage.io/v1beta3" not in match.group(1):
+ findings.append({"line": 0, "message": f"Unexpected apiVersion: {match.group(1).strip()}"})
+ return findings
+
+
+def fix_api_version(content: str) -> str:
+ if API_VERSION_PATTERN.search(content):
+ return API_VERSION_PATTERN.sub(
+ "apiVersion: scaffolder.backstage.io/v1beta3", content, count=1
+ )
+ return "apiVersion: scaffolder.backstage.io/v1beta3\n" + content
+
+
+def check_action_casing(content: str) -> list[dict]:
+ findings = []
+ for match in ACTION_PATTERN.finditer(content):
+ action = match.group(1)
+ if ":" not in action:
+ continue
+ ns, name = action.split(":", 1)
+ if name != name.lower() and any(c.isupper() for c in name):
+ line = content[: match.start()].count("\n") + 1
+ findings.append(
+ {
+ "line": line,
+ "message": f"Action '{action}' may use wrong casing — expected camelCase segment",
+ "action": action,
+ }
+ )
+ return findings
+
+
+def fix_action_casing(content: str) -> str:
+ def repl(m: re.Match) -> str:
+ action = m.group(1)
+ if ":" not in action:
+ return m.group(0)
+ ns, name = action.split(":", 1)
+ if not name:
+ return m.group(0)
+ if name != name.lower():
+ # Backstage/RHDH built-in actions use lowercase segments (publish:github).
+ fixed = f"{ns}:{name.lower()}"
+ else:
+ fixed = action
+ return f"action: {fixed}"
+
+ return ACTION_PATTERN.sub(repl, content)
+
+
+def check_v1beta2_expressions(content: str) -> list[dict]:
+ findings = []
+ for match in V1BETA2_EXPR.finditer(content):
+ line = content[: match.start()].count("\n") + 1
+ findings.append(
+ {
+ "line": line,
+ "message": "v1beta2 expression '{{ parameters.' in v1beta3 template",
+ }
+ )
+ break
+ return findings
+
+
+def convert_expressions(content: str) -> str:
+ return re.sub(
+ r"\{\{\s*parameters\.([^}]+)\}\}",
+ r"${{ parameters.\1 }}",
+ content,
+ )
+
+
+def check_hardcoded_secrets(content: str) -> list[dict]:
+ findings = []
+ for i, line in enumerate(content.splitlines(), start=1):
+ for pattern in TOKEN_PATTERNS:
+ if pattern.search(line) and "secrets." not in line:
+ findings.append({"line": i, "message": "Possible hardcoded token in step input"})
+ break
+ return findings
+
+
+def check_missing_section(content: str, section: str) -> list[dict]:
+ if re.search(rf"^ {section}:", content, re.MULTILINE):
+ return []
+ return [{"line": 0, "message": f"Missing spec.{section} section"}]
+
+
+def check_fetch_template_values(content: str) -> list[dict]:
+ findings = []
+ blocks = re.split(r"\n\s*-\s+id:", content)
+ for block in blocks:
+ if "action: fetch:template" not in block:
+ continue
+ if "values:" not in block:
+ findings.append({"line": 0, "message": "fetch:template step missing values map"})
+ return findings
+
+
+def check_skeleton_parameters(template_path: Path) -> list[dict]:
+ findings = []
+ skeleton = template_path.parent / "skeleton"
+ if not skeleton.is_dir():
+ return findings
+ for file in skeleton.rglob("*"):
+ if not file.is_file():
+ continue
+ try:
+ text = file.read_text(encoding="utf-8")
+ except (UnicodeDecodeError, OSError):
+ continue
+ if "parameters." in text and "values." not in text:
+ findings.append(
+ {
+ "line": 0,
+ "message": f"Skeleton file {file.relative_to(template_path.parent)} may use parameters.* instead of values.*",
+ }
+ )
+ return findings
+
+
+def check_workflow_raw_blocks(template_path: Path) -> list[dict]:
+ findings = []
+ skeleton = template_path.parent / "skeleton"
+ if not skeleton.is_dir():
+ return findings
+ for wf in skeleton.rglob(".github/workflows/*"):
+ if not wf.is_file():
+ continue
+ try:
+ text = wf.read_text(encoding="utf-8")
+ except (UnicodeDecodeError, OSError):
+ continue
+ if "{{" in text and "{% raw %}" not in text:
+ findings.append(
+ {
+ "line": 0,
+ "message": f"Workflow {wf.relative_to(template_path.parent)} contains '{{' without raw block",
+ }
+ )
+ return findings
+
+
+SENSITIVE_PARAM_NAMES = re.compile(
+ r"(?i)(password|passwd|secret|api[_-]?key|auth[_-]?token|access[_-]?token|token)"
+)
+
+
+def check_metadata_tags(content: str) -> list[dict]:
+ if re.search(r"^\s*tags:\s*\n\s*-\s+", content, re.MULTILINE):
+ return []
+ if re.search(r"^\s*tags:\s*\[.+\]", content, re.MULTILINE):
+ return []
+ return [{"line": 0, "message": "metadata.tags is missing — add tags for Create UI filtering"}]
+
+
+def check_sensitive_param_secret_field(content: str) -> list[dict]:
+ """Flag password/token-like parameter keys without ui:field: Secret."""
+ findings = []
+ # Focus on spec.parameters section only
+ spec_match = re.search(r"^spec:\s*\n(.*)", content, re.MULTILINE | re.DOTALL)
+ if not spec_match:
+ return findings
+ params_section = spec_match.group(1)
+ if "parameters:" not in params_section:
+ return findings
+
+ skip_keys = {"properties", "title", "required", "dependencies", "oneOf", "allOf", "enum"}
+ lines = params_section.splitlines()
+ i = 0
+ while i < len(lines):
+ key_match = re.match(r"^\s{8,}([a-zA-Z][a-zA-Z0-9]*):\s*$", lines[i])
+ if key_match:
+ key = key_match.group(1)
+ if key not in skip_keys and SENSITIVE_PARAM_NAMES.search(key):
+ block_end = min(i + 12, len(lines))
+ block = "\n".join(lines[i:block_end])
+ if "ui:field: Secret" not in block:
+ findings.append(
+ {
+ "line": 0,
+ "message": f"Parameter '{key}' looks sensitive — use ui:field: Secret",
+ }
+ )
+ i += 1
+ return findings
+
+
+def check_template_docs(content: str, template_path: Path) -> list[dict]:
+ if "backstage.io/techdocs-ref" in content:
+ return []
+ readme = template_path.parent / "README.md"
+ if readme.is_file():
+ return []
+ return [
+ {
+ "line": 0,
+ "message": "No README.md or backstage.io/techdocs-ref annotation — add template documentation",
+ }
+ ]
+
+
+CHECKERS = {
+ "api_version": lambda c, p: check_api_version(c),
+ "action_pascal_case": lambda c, p: check_action_casing(c),
+ "v1beta2_expression_syntax": lambda c, p: check_v1beta2_expressions(c),
+ "hardcoded_secret": lambda c, p: check_hardcoded_secrets(c),
+ "missing_parameters": lambda c, p: check_missing_section(c, "parameters"),
+ "missing_steps": lambda c, p: check_missing_section(c, "steps"),
+ "fetch_template_values": lambda c, p: check_fetch_template_values(c),
+ "workflow_raw_blocks": lambda c, p: check_workflow_raw_blocks(p),
+ "skeleton_parameters_ref": lambda c, p: check_skeleton_parameters(p),
+ "metadata_tags": lambda c, p: check_metadata_tags(c),
+ "sensitive_param_secret_field": lambda c, p: check_sensitive_param_secret_field(c),
+ "template_docs": lambda c, p: check_template_docs(c, p),
+}
+
+FIXERS = {
+ "set_api_version_v1beta3": fix_api_version,
+ "lowercase_action_segment": fix_action_casing,
+ "convert_to_v1beta3_expressions": convert_expressions,
+}
+
+
+def run_checks(content: str, template_path: Path, rules: list[dict]) -> list[dict]:
+ results = []
+ for rule in rules:
+ checker = CHECKERS.get(rule.get("check", ""))
+ if not checker:
+ continue
+ for finding in checker(content, template_path):
+ results.append(
+ {
+ "rule_id": rule["id"],
+ "severity": rule["severity"],
+ "description": rule["description"],
+ **finding,
+ }
+ )
+ return results
+
+
+def apply_fixes(content: str, rules: list[dict]) -> str:
+ updated = content
+ for rule in rules:
+ fix_name = rule.get("fix")
+ if not fix_name:
+ continue
+ fixer = FIXERS.get(fix_name)
+ if fixer:
+ updated = fixer(updated)
+ return updated
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Check and fix RHDH template gotchas.")
+ parser.add_argument(
+ "--path", required=True, type=Path, help="template.yaml or template directory"
+ )
+ parser.add_argument("--apply", action="store_true", help="Apply automatic fixes")
+ parser.add_argument("--json", action="store_true", help="Emit JSON result")
+ args = parser.parse_args()
+
+ skill_dir = Path(__file__).resolve().parent.parent
+ try:
+ template_path = resolve_template_path(args.path)
+ except FileNotFoundError as exc:
+ print(str(exc), file=sys.stderr)
+ return EXIT_USAGE
+
+ rules = load_rules(skill_dir)
+ original = template_path.read_text(encoding="utf-8")
+ findings = run_checks(original, template_path, rules)
+
+ updated = original
+ if args.apply:
+ updated = apply_fixes(original, rules)
+ if updated != original:
+ template_path.write_text(updated, encoding="utf-8")
+ findings = run_checks(updated, template_path, rules)
+
+ critical = [f for f in findings if f["severity"] == "critical"]
+ result = {
+ "ok": len(critical) == 0,
+ "template": str(template_path),
+ "finding_count": len(findings),
+ "critical_count": len(critical),
+ "findings": findings,
+ "applied": args.apply and updated != original,
+ }
+
+ if args.json:
+ print(json.dumps(result, indent=2 if _is_tty else None))
+ else:
+ print(f"Template: {template_path}")
+ print(f"Findings: {len(findings)} ({len(critical)} critical)")
+ for f in findings:
+ sev = f["severity"].upper()
+ line = f.get("line", 0)
+ loc = f"line {line}: " if line else ""
+ print(f" [{sev}] {loc}{f['message']}")
+ if args.apply and updated != original:
+ print("Applied automatic fixes.")
+
+ return EXIT_SUCCESS if len(critical) == 0 else EXIT_FINDINGS
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/skills/rhdh-templates/scripts/init.py b/skills/rhdh-templates/scripts/init.py
new file mode 100755
index 0000000..b76f84c
--- /dev/null
+++ b/skills/rhdh-templates/scripts/init.py
@@ -0,0 +1,213 @@
+#!/usr/bin/env python3
+"""Initialize an RHDH Software Template authoring workspace.
+
+Checks required tooling, scaffolds recommended directory layout, and
+optionally probes RHDH Scaffolder API connectivity.
+
+Stdlib only per project ADR-0002.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import shutil
+import sys
+import urllib.error
+import urllib.request
+from pathlib import Path
+
+EXIT_SUCCESS = 0
+EXIT_PARTIAL = 1
+EXIT_USAGE = 2
+
+REQUIRED_TOOLS = ("python3", "git")
+RECOMMENDED_TOOLS = ("djlint",)
+
+_no_color = os.environ.get("NO_COLOR") is not None
+_is_tty = sys.stderr.isatty() and not _no_color
+
+
+def _c(code: str, text: str) -> str:
+ return f"{code}{text}\033[0m" if _is_tty else text
+
+
+def green(t: str) -> str:
+ return _c("\033[0;32m", t)
+
+
+def yellow(t: str) -> str:
+ return _c("\033[1;33m", t)
+
+
+def red(t: str) -> str:
+ return _c("\033[0;31m", t)
+
+
+def tool_available(name: str) -> bool:
+ return shutil.which(name) is not None
+
+
+def check_tools() -> dict:
+ results = {"required": {}, "recommended": {}}
+ for tool in REQUIRED_TOOLS:
+ results["required"][tool] = tool_available(tool)
+ for tool in RECOMMENDED_TOOLS:
+ results["recommended"][tool] = tool_available(tool)
+ return results
+
+
+def probe_rhdh(url: str, timeout: int = 10) -> dict:
+ base = url.rstrip("/")
+ endpoint = f"{base}/api/scaffolder/v2/actions"
+ try:
+ req = urllib.request.Request(endpoint, headers={"Accept": "application/json"})
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
+ body = resp.read().decode("utf-8")
+ data = json.loads(body) if body else []
+ count = len(data) if isinstance(data, list) else 0
+ return {"reachable": True, "endpoint": endpoint, "action_count": count}
+ except (
+ urllib.error.URLError,
+ urllib.error.HTTPError,
+ json.JSONDecodeError,
+ TimeoutError,
+ ) as exc:
+ return {"reachable": False, "endpoint": endpoint, "error": str(exc)}
+
+
+def scaffold_layout(root: Path, skill_dir: Path) -> dict:
+ created: list[str] = []
+ existing: list[str] = []
+
+ templates_dir = root / "templates"
+ example_dir = templates_dir / "example-template"
+ skeleton_dir = example_dir / "skeleton"
+
+ for path in (templates_dir, example_dir, skeleton_dir):
+ if path.exists():
+ existing.append(str(path.relative_to(root)))
+ else:
+ path.mkdir(parents=True, exist_ok=True)
+ created.append(str(path.relative_to(root)))
+
+ example_template = skill_dir / "assets" / "examples" / "minimal-template" / "template.yaml"
+ target_template = example_dir / "template.yaml"
+ if not target_template.exists() and example_template.exists():
+ shutil.copy2(example_template, target_template)
+ created.append(str(target_template.relative_to(root)))
+
+ example_skeleton = skill_dir / "assets" / "examples" / "minimal-template" / "skeleton"
+ if example_skeleton.is_dir():
+ for item in example_skeleton.iterdir():
+ dest = skeleton_dir / item.name
+ if not dest.exists():
+ if item.is_dir():
+ shutil.copytree(item, dest)
+ else:
+ shutil.copy2(item, dest)
+ created.append(str(dest.relative_to(root)))
+
+ example_readme = example_dir / "README.md"
+ if not example_readme.exists():
+ example_readme.write_text(
+ "# example-template\n\nRename this directory and customize `template.yaml`.\n",
+ encoding="utf-8",
+ )
+ created.append(str(example_readme.relative_to(root)))
+
+ location_path = root / "location.yaml"
+ if not location_path.exists():
+ content = (
+ "apiVersion: backstage.io/v1alpha1\n"
+ "kind: Location\n"
+ "metadata:\n"
+ f" name: {root.name}-templates\n"
+ " description: Software Templates\n"
+ "spec:\n"
+ " targets:\n"
+ " - ./templates/**/template.yaml\n"
+ )
+ location_path.write_text(content, encoding="utf-8")
+ created.append("location.yaml")
+
+ return {"created": created, "existing": existing}
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(
+ description="Initialize RHDH Software Template authoring workspace.",
+ )
+ parser.add_argument(
+ "--path",
+ type=Path,
+ default=Path.cwd(),
+ help="Template repository root (default: current directory)",
+ )
+ parser.add_argument(
+ "--rhdh-url",
+ help="Optional RHDH base URL to probe Scaffolder API",
+ )
+ parser.add_argument("--json", action="store_true", help="Emit JSON result")
+ args = parser.parse_args()
+
+ root = args.path.resolve()
+ skill_dir = Path(__file__).resolve().parent.parent
+
+ if not root.exists():
+ root.mkdir(parents=True, exist_ok=True)
+ elif not root.is_dir():
+ print(red(f"Not a directory: {root}"), file=sys.stderr)
+ return EXIT_USAGE
+
+ tools = check_tools()
+ missing_required = [k for k, v in tools["required"].items() if not v]
+ missing_recommended = [k for k, v in tools["recommended"].items() if not v]
+
+ layout = scaffold_layout(root, skill_dir)
+ rhdh = probe_rhdh(args.rhdh_url) if args.rhdh_url else None
+
+ ok = not missing_required
+ partial = bool(missing_recommended) or (rhdh and not rhdh.get("reachable"))
+
+ result = {
+ "ok": ok,
+ "path": str(root),
+ "tools": tools,
+ "missing_required": missing_required,
+ "missing_recommended": missing_recommended,
+ "layout": layout,
+ "rhdh": rhdh,
+ }
+
+ if args.json:
+ print(json.dumps(result, indent=2 if _is_tty else None))
+ else:
+ print(green("RHDH Templates init"))
+ print(f" Path: {root}")
+ for tool, present in tools["required"].items():
+ status = green("ok") if present else red("missing")
+ print(f" {tool}: {status}")
+ for tool, present in tools["recommended"].items():
+ status = green("ok") if present else yellow("missing (optional)")
+ print(f" {tool}: {status}")
+ if layout["created"]:
+ print(green("Created:"))
+ for p in layout["created"]:
+ print(f" {p}")
+ if rhdh:
+ if rhdh.get("reachable"):
+ print(green(f"RHDH reachable — {rhdh.get('action_count', 0)} actions"))
+ else:
+ print(yellow(f"RHDH unreachable: {rhdh.get('error')}"))
+ if missing_required:
+ print(red("Install missing required tools before authoring."))
+
+ if not ok:
+ return EXIT_PARTIAL
+ return EXIT_PARTIAL if partial else EXIT_SUCCESS
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/skills/rhdh-templates/scripts/list_actions.py b/skills/rhdh-templates/scripts/list_actions.py
new file mode 100755
index 0000000..5d50661
--- /dev/null
+++ b/skills/rhdh-templates/scripts/list_actions.py
@@ -0,0 +1,74 @@
+#!/usr/bin/env python3
+"""List Scaffolder actions from a running RHDH instance.
+
+Uses GET /api/scaffolder/v2/actions.
+
+Stdlib only per project ADR-0002.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+from pathlib import Path
+
+EXIT_SUCCESS = 0
+EXIT_FAILURE = 1
+EXIT_USAGE = 2
+
+SCRIPTS_DIR = Path(__file__).resolve().parent
+sys.path.insert(0, str(SCRIPTS_DIR))
+
+from scaffolder_api import list_actions # noqa: E402
+
+_no_color = os.environ.get("NO_COLOR") is not None
+_is_tty = sys.stderr.isatty() and not _no_color
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="List Scaffolder actions from RHDH.")
+ parser.add_argument(
+ "--rhdh-url", required=True, help="RHDH base URL (e.g. https://rhdh.example.com)"
+ )
+ parser.add_argument("--token", help="Bearer token (default: RHDH_TOKEN or BACKSTAGE_TOKEN env)")
+ parser.add_argument("--filter", help="Case-insensitive substring filter on action id")
+ parser.add_argument("--json", action="store_true", help="Emit JSON result")
+ args = parser.parse_args()
+
+ try:
+ actions = list_actions(args.rhdh_url, token=args.token)
+ except RuntimeError as exc:
+ print(str(exc), file=sys.stderr)
+ return EXIT_FAILURE
+
+ if args.filter:
+ needle = args.filter.lower()
+ actions = [a for a in actions if needle in a.get("id", "").lower()]
+
+ result = {
+ "ok": True,
+ "rhdh_url": args.rhdh_url.rstrip("/"),
+ "action_count": len(actions),
+ "actions": actions,
+ }
+
+ if args.json:
+ print(json.dumps(result, indent=2 if _is_tty else None))
+ else:
+ print(f"RHDH: {args.rhdh_url.rstrip('/')}")
+ print(f"Actions: {len(actions)}")
+ for action in actions:
+ desc = action.get("description") or ""
+ if desc:
+ desc = desc.split("\n", 1)[0][:80]
+ print(f" {action['id']} — {desc}")
+ else:
+ print(f" {action['id']}")
+
+ return EXIT_SUCCESS
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/skills/rhdh-templates/scripts/list_examples.py b/skills/rhdh-templates/scripts/list_examples.py
new file mode 100644
index 0000000..645a88a
--- /dev/null
+++ b/skills/rhdh-templates/scripts/list_examples.py
@@ -0,0 +1,243 @@
+#!/usr/bin/env python3
+"""List curated RHDH Software Template reference examples.
+
+Reads the bundled example catalog and filters or ranks entries for authoring
+workflows. Stdlib only per project ADR-0002.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import sys
+from pathlib import Path
+
+EXIT_SUCCESS = 0
+EXIT_USAGE = 2
+
+SKILL_DIR = Path(__file__).resolve().parent.parent
+DEFAULT_CATALOG = SKILL_DIR / "assets" / "example-catalog.json"
+BUNDLED_EXAMPLES_DIR = "assets/examples"
+
+
+def load_catalog(path: Path) -> dict:
+ if not path.is_file():
+ raise FileNotFoundError(f"Catalog not found: {path}")
+ return json.loads(path.read_text(encoding="utf-8"))
+
+
+def source_map(catalog: dict) -> dict[str, dict]:
+ return {item["id"]: item for item in catalog.get("sources", [])}
+
+
+def tokenize(text: str) -> list[str]:
+ return [t for t in re.split(r"[^a-z0-9]+", text.lower()) if t]
+
+
+def example_blob(example: dict) -> str:
+ parts = [
+ example.get("id", ""),
+ example.get("title", ""),
+ example.get("category", ""),
+ " ".join(example.get("tags", [])),
+ " ".join(example.get("stack", [])),
+ " ".join(example.get("use_cases", [])),
+ ]
+ return " ".join(parts).lower()
+
+
+def score_example(example: dict, query_tokens: list[str]) -> int:
+ if not query_tokens:
+ return 0
+ blob = example_blob(example)
+ score = 0
+ for token in query_tokens:
+ if token in blob:
+ score += 2
+ for part in blob.split():
+ if token in part or part in token:
+ score += 1
+ if example.get("recommended"):
+ score += 1
+ return score
+
+
+def filter_examples(
+ catalog: dict,
+ *,
+ category: str | None = None,
+ tag: str | None = None,
+ stack: str | None = None,
+ recommended: bool = False,
+ local_only: bool = False,
+ official_only: bool = False,
+ query: str | None = None,
+ match: str | None = None,
+ limit: int | None = None,
+) -> list[dict]:
+ examples = list(catalog.get("examples", []))
+ sources = source_map(catalog)
+
+ if category:
+ examples = [e for e in examples if e.get("category") == category]
+ if tag:
+ examples = [e for e in examples if tag in e.get("tags", [])]
+ if stack:
+ examples = [
+ e for e in examples if stack in e.get("stack", []) or stack in e.get("tags", [])
+ ]
+ if recommended:
+ examples = [e for e in examples if e.get("recommended")]
+ if local_only:
+ examples = [e for e in examples if e.get("local_bundled")]
+ if official_only:
+ official_ids = {sid for sid, src in sources.items() if src.get("official")}
+ examples = [e for e in examples if e.get("source") in official_ids]
+
+ search_text = match or query
+ if search_text:
+ tokens = tokenize(search_text)
+ scored = [(score_example(e, tokens), e) for e in examples]
+ scored = [(score, e) for score, e in scored if score > 0]
+ scored.sort(key=lambda item: (-item[0], item[1].get("title", "")))
+ examples = [e for _, e in scored]
+ if match:
+ for score, example in scored:
+ example["_match_score"] = score
+ elif match is not None:
+ examples = []
+
+ if limit is not None and limit >= 0:
+ examples = examples[:limit]
+
+ return examples
+
+
+def enrich_example(example: dict, catalog: dict) -> dict:
+ sources = source_map(catalog)
+ source = sources.get(example.get("source", ""), {})
+ enriched = dict(example)
+ enriched["source_repo"] = source.get("repo")
+ enriched["source_url"] = source.get("url")
+ if example.get("local_bundled"):
+ enriched["local_path"] = f"{BUNDLED_EXAMPLES_DIR}/{example['local_bundled']}"
+ return enriched
+
+
+def format_text(examples: list[dict], catalog: dict) -> str:
+ if not examples:
+ return "No matching reference templates found."
+
+ lines = []
+ disclaimer = catalog.get("disclaimer")
+ if disclaimer:
+ lines.append(disclaimer)
+ lines.append("")
+
+ for example in examples:
+ score = example.get("_match_score")
+ prefix = f"[{score}] " if score is not None else ""
+ lines.append(f"{prefix}{example['title']} ({example['id']})")
+ lines.append(f" category: {example.get('category')}")
+ lines.append(f" url: {example.get('url')}")
+ if example.get("local_bundled"):
+ lines.append(f" local example: {BUNDLED_EXAMPLES_DIR}/{example['local_bundled']}")
+ if example.get("recommended"):
+ lines.append(" recommended: yes")
+ lines.append("")
+
+ return "\n".join(lines).rstrip()
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ description="List curated RHDH Software Template reference examples.",
+ )
+ parser.add_argument(
+ "--catalog",
+ type=Path,
+ default=DEFAULT_CATALOG,
+ help=f"Path to example catalog JSON (default: {DEFAULT_CATALOG})",
+ )
+ parser.add_argument("--category", help="Filter by category (backend, ai, catalog, ...)")
+ parser.add_argument("--tag", help="Filter by tag (recommended, go, rag, ...)")
+ parser.add_argument("--stack", help="Filter by stack marker (go, java, python, ai, ...)")
+ parser.add_argument(
+ "--recommended",
+ action="store_true",
+ help="Only templates tagged recommended upstream",
+ )
+ parser.add_argument(
+ "--local-only",
+ action="store_true",
+ help="Only examples with a bundled local counterpart in assets/examples/",
+ )
+ parser.add_argument(
+ "--official-only",
+ action="store_true",
+ help="Only examples from official Red Hat Developer sources",
+ )
+ parser.add_argument("--query", help="Substring token search across title, tags, and use cases")
+ parser.add_argument(
+ "--match",
+ help="Rank examples by relevance to a natural-language intent (e.g. 'spring boot backend with ci')",
+ )
+ parser.add_argument(
+ "--limit",
+ type=int,
+ default=None,
+ help="Maximum number of results (default: all)",
+ )
+ parser.add_argument(
+ "--json",
+ action="store_true",
+ help="Emit structured JSON (compact when piped)",
+ )
+ return parser
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = build_parser()
+ args = parser.parse_args(argv)
+
+ try:
+ catalog = load_catalog(args.catalog)
+ except FileNotFoundError as exc:
+ print(str(exc), file=sys.stderr)
+ return EXIT_USAGE
+
+ examples = filter_examples(
+ catalog,
+ category=args.category,
+ tag=args.tag,
+ stack=args.stack,
+ recommended=args.recommended,
+ local_only=args.local_only,
+ official_only=args.official_only,
+ query=args.query,
+ match=args.match,
+ limit=args.limit,
+ )
+
+ enriched = [enrich_example(example, catalog) for example in examples]
+ for item in enriched:
+ item.pop("_match_score", None)
+
+ if args.json:
+ payload = {
+ "ok": True,
+ "count": len(enriched),
+ "disclaimer": catalog.get("disclaimer"),
+ "examples": enriched,
+ }
+ indent = 2 if sys.stdout.isatty() else None
+ print(json.dumps(payload, indent=indent))
+ return EXIT_SUCCESS
+
+ print(format_text(examples, catalog))
+ return EXIT_SUCCESS
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/skills/rhdh-templates/scripts/scaffolder_api.py b/skills/rhdh-templates/scripts/scaffolder_api.py
new file mode 100644
index 0000000..578cd07
--- /dev/null
+++ b/skills/rhdh-templates/scripts/scaffolder_api.py
@@ -0,0 +1,156 @@
+#!/usr/bin/env python3
+"""Shared HTTP helpers for RHDH Scaffolder v2 API calls.
+
+Stdlib only per project ADR-0002.
+"""
+
+from __future__ import annotations
+
+import base64
+import json
+import os
+import urllib.error
+import urllib.request
+from pathlib import Path
+from typing import Any
+
+DEFAULT_TIMEOUT = 30
+
+
+def auth_headers(token: str | None = None) -> dict[str, str]:
+ headers = {"Accept": "application/json", "Content-Type": "application/json"}
+ resolved = token or os.environ.get("RHDH_TOKEN") or os.environ.get("BACKSTAGE_TOKEN")
+ if resolved:
+ headers["Authorization"] = f"Bearer {resolved}"
+ return headers
+
+
+def api_url(base_url: str, path: str) -> str:
+ return f"{base_url.rstrip('/')}{path}"
+
+
+def request_json(
+ method: str,
+ url: str,
+ *,
+ token: str | None = None,
+ body: dict[str, Any] | None = None,
+ timeout: int = DEFAULT_TIMEOUT,
+) -> tuple[int, Any]:
+ data = None
+ headers = auth_headers(token)
+ if body is not None:
+ data = json.dumps(body).encode("utf-8")
+ req = urllib.request.Request(url, data=data, headers=headers, method=method.upper())
+ try:
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
+ raw = resp.read().decode("utf-8")
+ if not raw:
+ return resp.status, None
+ return resp.status, json.loads(raw)
+ except urllib.error.HTTPError as exc:
+ raw = exc.read().decode("utf-8", errors="replace")
+ try:
+ payload = json.loads(raw) if raw else {"error": exc.reason}
+ except json.JSONDecodeError:
+ payload = {"error": raw or exc.reason}
+ return exc.code, payload
+
+
+def list_actions(base_url: str, *, token: str | None = None) -> list[dict[str, Any]]:
+ status, payload = request_json(
+ "GET",
+ api_url(base_url, "/api/scaffolder/v2/actions"),
+ token=token,
+ )
+ if status != 200:
+ raise RuntimeError(f"list-actions failed ({status}): {payload}")
+ if not isinstance(payload, list):
+ raise RuntimeError(f"Unexpected list-actions response: {payload!r}")
+ return payload
+
+
+def get_action_schema(
+ base_url: str,
+ action_id: str,
+ *,
+ token: str | None = None,
+) -> dict[str, Any] | None:
+ actions = list_actions(base_url, token=token)
+ for action in actions:
+ if action.get("id") == action_id:
+ return action
+ return None
+
+
+def get_template_parameter_schema(
+ base_url: str,
+ template_ref: str,
+ *,
+ token: str | None = None,
+) -> dict[str, Any]:
+ """Fetch parameter schema for a catalog Template entity."""
+ kind, namespace, name = parse_template_ref(template_ref)
+ path = f"/api/scaffolder/v2/templates/{namespace}/{kind}/{name}/parameter-schema"
+ status, payload = request_json("GET", api_url(base_url, path), token=token)
+ if status != 200:
+ raise RuntimeError(f"parameter-schema failed ({status}): {payload}")
+ return payload
+
+
+def parse_template_ref(template_ref: str) -> tuple[str, str, str]:
+ """Parse template:namespace/name into (kind, namespace, name)."""
+ ref = template_ref.strip()
+ if ":" not in ref or "/" not in ref:
+ raise ValueError(
+ f"Invalid template ref {template_ref!r} — expected template:namespace/name"
+ )
+ kind_part, rest = ref.split(":", 1)
+ namespace, name = rest.split("/", 1)
+ return kind_part.lower(), namespace, name
+
+
+def load_directory_contents(root: Path) -> list[dict[str, str]]:
+ """Serialize a directory tree for Scaffolder dry-run API."""
+ contents: list[dict[str, str]] = []
+ for path in sorted(root.rglob("*")):
+ if not path.is_file():
+ continue
+ rel = path.relative_to(root).as_posix()
+ raw = path.read_bytes()
+ contents.append(
+ {
+ "path": rel,
+ "base64Content": base64.b64encode(raw).decode("ascii"),
+ }
+ )
+ return contents
+
+
+def dry_run(
+ base_url: str,
+ *,
+ template: dict[str, Any],
+ values: dict[str, Any],
+ directory_contents: list[dict[str, str]],
+ secrets: dict[str, str] | None = None,
+ token: str | None = None,
+) -> dict[str, Any]:
+ body: dict[str, Any] = {
+ "template": template,
+ "values": values,
+ "directoryContents": directory_contents,
+ }
+ if secrets:
+ body["secrets"] = secrets
+ status, payload = request_json(
+ "POST",
+ api_url(base_url, "/api/scaffolder/v2/dry-run"),
+ token=token,
+ body=body,
+ )
+ if status != 200:
+ raise RuntimeError(f"dry-run failed ({status}): {payload}")
+ if not isinstance(payload, dict):
+ raise RuntimeError(f"Unexpected dry-run response: {payload!r}")
+ return payload
diff --git a/skills/rhdh-templates/scripts/schema_validate.py b/skills/rhdh-templates/scripts/schema_validate.py
new file mode 100644
index 0000000..ed2998c
--- /dev/null
+++ b/skills/rhdh-templates/scripts/schema_validate.py
@@ -0,0 +1,658 @@
+#!/usr/bin/env python3
+"""JSON Schema and structural validation for Software Template entities.
+
+Provides stdlib structural checks (always available) and optional full JSON
+Schema validation when jsonschema is installed.
+
+Stdlib only per project ADR-0002 (optional jsonschema when installed).
+"""
+
+from __future__ import annotations
+
+import json
+import re
+from pathlib import Path
+from typing import Any
+
+PARAM_EXPR = re.compile(r"\$\{\{\s*parameters\.([a-zA-Z0-9_.]+)\s*\}\}")
+STEP_OUTPUT_EXPR = re.compile(
+ r"\$\{\{\s*steps(?:\[['\"]([^'\"]+)['\"]\]|\.([a-zA-Z0-9_-]+))\.output"
+)
+STEP_REF_BRACKET = re.compile(r"steps\[['\"]([^'\"]+)['\"]\]")
+STEP_REF_DOT = re.compile(r"steps\.([a-zA-Z0-9_-]+)\.output")
+
+VALID_PARAM_TYPES = frozenset({"string", "number", "integer", "boolean", "array", "object", "null"})
+RESERVED_PARAM_KEYS = frozenset(
+ {
+ "properties",
+ "required",
+ "dependencies",
+ "oneOf",
+ "allOf",
+ "anyOf",
+ "not",
+ "if",
+ "then",
+ "else",
+ "title",
+ "description",
+ "type",
+ "enum",
+ "const",
+ "default",
+ "items",
+ "additionalProperties",
+ "pattern",
+ "minLength",
+ "maxLength",
+ "minimum",
+ "maximum",
+ "format",
+ "ui:field",
+ "ui:widget",
+ "ui:options",
+ "ui:help",
+ "ui:autofocus",
+ "ui:placeholder",
+ "ui:disabled",
+ "ui:readonly",
+ "backstage:permissions",
+ }
+)
+
+
+def _finding(
+ check: str,
+ severity: str,
+ message: str,
+ *,
+ path: str = "",
+ line: int = 0,
+) -> dict:
+ return {
+ "check": check,
+ "severity": severity,
+ "message": message,
+ "path": path,
+ "line": line,
+ }
+
+
+def schema_path(skill_dir: Path) -> Path:
+ return skill_dir / "references" / "schemas" / "template-v1beta3.schema.json"
+
+
+def normalize_parameter_forms(parameters: Any) -> list[dict]:
+ if parameters is None:
+ return []
+ if isinstance(parameters, dict):
+ return [parameters]
+ if isinstance(parameters, list):
+ return [item for item in parameters if isinstance(item, dict)]
+ return []
+
+
+def collect_parameter_keys(parameters: Any) -> set[str]:
+ keys: set[str] = set()
+ for form in normalize_parameter_forms(parameters):
+ props = form.get("properties")
+ if isinstance(props, dict):
+ keys.update(props.keys())
+ required = form.get("required")
+ if isinstance(required, list):
+ keys.update(str(item) for item in required if isinstance(item, str))
+ return keys
+
+
+def validate_parameter_property(name: str, prop: Any, path: str) -> list[dict]:
+ findings: list[dict] = []
+ if not isinstance(prop, dict):
+ findings.append(
+ _finding(
+ "json_schema",
+ "critical",
+ f"Parameter '{name}' must be an object",
+ path=path,
+ )
+ )
+ return findings
+
+ prop_type = prop.get("type")
+ if prop_type is not None:
+ if isinstance(prop_type, str):
+ if prop_type not in VALID_PARAM_TYPES:
+ findings.append(
+ _finding(
+ "json_schema",
+ "warning",
+ f"Parameter '{name}' has unknown type '{prop_type}'",
+ path=f"{path}.type",
+ )
+ )
+ elif isinstance(prop_type, list):
+ unknown = [t for t in prop_type if t not in VALID_PARAM_TYPES]
+ if unknown:
+ findings.append(
+ _finding(
+ "json_schema",
+ "warning",
+ f"Parameter '{name}' has unknown types: {', '.join(unknown)}",
+ path=f"{path}.type",
+ )
+ )
+ else:
+ findings.append(
+ _finding(
+ "json_schema",
+ "warning",
+ f"Parameter '{name}' type must be a string or list",
+ path=f"{path}.type",
+ )
+ )
+ elif "enum" not in prop and "const" not in prop and "oneOf" not in prop:
+ findings.append(
+ _finding(
+ "json_schema",
+ "warning",
+ f"Parameter '{name}' missing type (add type or enum)",
+ path=path,
+ )
+ )
+
+ if prop_type == "object" or (isinstance(prop_type, list) and "object" in prop_type):
+ nested = prop.get("properties")
+ if isinstance(nested, dict):
+ for nested_name, nested_prop in nested.items():
+ findings.extend(
+ validate_parameter_property(
+ f"{name}.{nested_name}",
+ nested_prop,
+ f"{path}.properties.{nested_name}",
+ )
+ )
+
+ if prop_type == "array" or (isinstance(prop_type, list) and "array" in prop_type):
+ items = prop.get("items")
+ if items is not None and not isinstance(items, dict):
+ findings.append(
+ _finding(
+ "json_schema",
+ "warning",
+ f"Parameter '{name}' array items must be an object schema",
+ path=f"{path}.items",
+ )
+ )
+
+ return findings
+
+
+def validate_parameter_forms(parameters: Any) -> list[dict]:
+ findings: list[dict] = []
+ forms = normalize_parameter_forms(parameters)
+ if parameters is not None and not forms:
+ findings.append(
+ _finding(
+ "json_schema",
+ "critical",
+ "spec.parameters must be an object or array of form sections",
+ path="spec.parameters",
+ )
+ )
+ return findings
+
+ for index, form in enumerate(forms):
+ base = f"spec.parameters[{index}]"
+ if not form.get("title"):
+ findings.append(
+ _finding(
+ "json_schema",
+ "warning",
+ "Parameter form section missing title",
+ path=f"{base}.title",
+ )
+ )
+ props = form.get("properties")
+ if props is None:
+ findings.append(
+ _finding(
+ "json_schema",
+ "warning",
+ "Parameter form section missing properties",
+ path=f"{base}.properties",
+ )
+ )
+ continue
+ if not isinstance(props, dict):
+ findings.append(
+ _finding(
+ "json_schema",
+ "critical",
+ "Parameter form properties must be an object",
+ path=f"{base}.properties",
+ )
+ )
+ continue
+
+ required = form.get("required", [])
+ if required is not None and not isinstance(required, list):
+ findings.append(
+ _finding(
+ "json_schema",
+ "critical",
+ "Parameter form required must be an array",
+ path=f"{base}.required",
+ )
+ )
+ elif isinstance(required, list):
+ for req_key in required:
+ if not isinstance(req_key, str):
+ continue
+ if req_key not in props:
+ findings.append(
+ _finding(
+ "json_schema",
+ "critical",
+ f"Required parameter '{req_key}' is not defined in properties",
+ path=f"{base}.required",
+ )
+ )
+
+ for name, prop in props.items():
+ if name in RESERVED_PARAM_KEYS:
+ continue
+ findings.extend(validate_parameter_property(name, prop, f"{base}.properties.{name}"))
+
+ return findings
+
+
+def validate_steps(steps: Any) -> list[dict]:
+ findings: list[dict] = []
+ if not isinstance(steps, list):
+ findings.append(
+ _finding(
+ "json_schema",
+ "critical",
+ "spec.steps must be an array",
+ path="spec.steps",
+ )
+ )
+ return findings
+ if not steps:
+ findings.append(
+ _finding(
+ "json_schema",
+ "warning",
+ "spec.steps is empty",
+ path="spec.steps",
+ )
+ )
+
+ seen_ids: set[str] = set()
+ for index, step in enumerate(steps):
+ path = f"spec.steps[{index}]"
+ if not isinstance(step, dict):
+ findings.append(
+ _finding(
+ "json_schema",
+ "critical",
+ "Each step must be an object",
+ path=path,
+ )
+ )
+ continue
+
+ step_id = step.get("id")
+ if step_id is not None:
+ if not isinstance(step_id, str) or not step_id.strip():
+ findings.append(
+ _finding(
+ "json_schema",
+ "critical",
+ "Step id must be a non-empty string",
+ path=f"{path}.id",
+ )
+ )
+ elif step_id in seen_ids:
+ findings.append(
+ _finding(
+ "json_schema",
+ "critical",
+ f"Duplicate step id '{step_id}'",
+ path=f"{path}.id",
+ )
+ )
+ else:
+ seen_ids.add(step_id)
+
+ action = step.get("action")
+ if not action:
+ findings.append(
+ _finding(
+ "json_schema",
+ "critical",
+ "Step missing required action",
+ path=f"{path}.action",
+ )
+ )
+ elif not isinstance(action, str) or ":" not in action:
+ findings.append(
+ _finding(
+ "json_schema",
+ "critical",
+ f"Step action '{action}' must use namespace:actionName format",
+ path=f"{path}.action",
+ )
+ )
+
+ step_input = step.get("input")
+ if step_input is not None and not isinstance(step_input, dict):
+ findings.append(
+ _finding(
+ "json_schema",
+ "critical",
+ "Step input must be an object",
+ path=f"{path}.input",
+ )
+ )
+
+ return findings
+
+
+def validate_output(output: Any) -> list[dict]:
+ findings: list[dict] = []
+ if output is None:
+ return findings
+ if not isinstance(output, dict):
+ findings.append(
+ _finding(
+ "json_schema",
+ "critical",
+ "spec.output must be an object",
+ path="spec.output",
+ )
+ )
+ return findings
+
+ links = output.get("links")
+ if links is not None:
+ if not isinstance(links, list):
+ findings.append(
+ _finding(
+ "json_schema",
+ "critical",
+ "spec.output.links must be an array",
+ path="spec.output.links",
+ )
+ )
+ else:
+ for index, link in enumerate(links):
+ path = f"spec.output.links[{index}]"
+ if not isinstance(link, dict):
+ findings.append(
+ _finding(
+ "json_schema",
+ "critical",
+ "Output link must be an object",
+ path=path,
+ )
+ )
+ continue
+ if not any(link.get(key) for key in ("title", "url", "entityRef")):
+ findings.append(
+ _finding(
+ "json_schema",
+ "warning",
+ "Output link should include title, url, or entityRef",
+ path=path,
+ )
+ )
+
+ return findings
+
+
+def _extract_param_refs_from_value(value: Any) -> set[str]:
+ refs: set[str] = set()
+ if isinstance(value, str):
+ for match in PARAM_EXPR.finditer(value):
+ refs.add(match.group(1).split(".")[0])
+ elif isinstance(value, dict):
+ for nested in value.values():
+ refs.update(_extract_param_refs_from_value(nested))
+ elif isinstance(value, list):
+ for nested in value:
+ refs.update(_extract_param_refs_from_value(nested))
+ return refs
+
+
+def _extract_step_refs_from_value(value: Any) -> set[str]:
+ refs: set[str] = set()
+ if isinstance(value, str):
+ for match in STEP_OUTPUT_EXPR.finditer(value):
+ step_id = match.group(1) or match.group(2)
+ if step_id:
+ refs.add(step_id)
+ for match in STEP_REF_BRACKET.finditer(value):
+ refs.add(match.group(1))
+ for match in STEP_REF_DOT.finditer(value):
+ refs.add(match.group(1))
+ elif isinstance(value, dict):
+ for nested in value.values():
+ refs.update(_extract_step_refs_from_value(nested))
+ elif isinstance(value, list):
+ for nested in value:
+ refs.update(_extract_step_refs_from_value(nested))
+ return refs
+
+
+def validate_cross_references(data: dict) -> list[dict]:
+ findings: list[dict] = []
+ spec = data.get("spec")
+ if not isinstance(spec, dict):
+ return findings
+
+ param_keys = collect_parameter_keys(spec.get("parameters"))
+ steps = spec.get("steps")
+ if not isinstance(steps, list):
+ return findings
+
+ step_ids = {
+ step.get("id")
+ for step in steps
+ if isinstance(step, dict) and isinstance(step.get("id"), str)
+ }
+
+ for index, step in enumerate(steps):
+ if not isinstance(step, dict):
+ continue
+ path = f"spec.steps[{index}]"
+ for ref in _extract_param_refs_from_value(step.get("input")):
+ if param_keys and ref not in param_keys:
+ findings.append(
+ _finding(
+ "json_schema",
+ "warning",
+ f"Step references unknown parameter '{ref}'",
+ path=path,
+ )
+ )
+
+ for ref in _extract_step_refs_from_value(spec.get("output")):
+ if step_ids and ref not in step_ids:
+ findings.append(
+ _finding(
+ "json_schema",
+ "warning",
+ f"Output references unknown step id '{ref}'",
+ path="spec.output",
+ )
+ )
+
+ return findings
+
+
+def validate_metadata(data: dict) -> list[dict]:
+ findings: list[dict] = []
+ metadata = data.get("metadata")
+ if not isinstance(metadata, dict):
+ findings.append(
+ _finding(
+ "json_schema",
+ "critical",
+ "metadata must be an object",
+ path="metadata",
+ )
+ )
+ return findings
+
+ name = metadata.get("name")
+ if not name:
+ findings.append(
+ _finding(
+ "json_schema",
+ "critical",
+ "metadata.name is required",
+ path="metadata.name",
+ )
+ )
+ elif not isinstance(name, str) or not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name):
+ findings.append(
+ _finding(
+ "json_schema",
+ "warning",
+ "metadata.name should be lowercase alphanumeric with hyphens",
+ path="metadata.name",
+ )
+ )
+
+ tags = metadata.get("tags")
+ if tags is not None and not isinstance(tags, list):
+ findings.append(
+ _finding(
+ "json_schema",
+ "critical",
+ "metadata.tags must be an array",
+ path="metadata.tags",
+ )
+ )
+
+ return findings
+
+
+def validate_spec_root(data: dict) -> list[dict]:
+ findings: list[dict] = []
+ spec = data.get("spec")
+ if not isinstance(spec, dict):
+ findings.append(
+ _finding(
+ "json_schema",
+ "critical",
+ "spec must be an object",
+ path="spec",
+ )
+ )
+ return findings
+
+ template_type = spec.get("type")
+ if not template_type:
+ findings.append(
+ _finding(
+ "json_schema",
+ "critical",
+ "spec.type is required",
+ path="spec.type",
+ )
+ )
+ elif not isinstance(template_type, str) or not template_type.strip():
+ findings.append(
+ _finding(
+ "json_schema",
+ "critical",
+ "spec.type must be a non-empty string",
+ path="spec.type",
+ )
+ )
+
+ findings.extend(validate_parameter_forms(spec.get("parameters")))
+ findings.extend(validate_steps(spec.get("steps")))
+ findings.extend(validate_output(spec.get("output")))
+ findings.extend(validate_cross_references(data))
+ return findings
+
+
+def validate_structural(data: dict) -> list[dict]:
+ """Always-available structural and JSON Schema subset validation."""
+ findings: list[dict] = []
+ if data.get("apiVersion") != "scaffolder.backstage.io/v1beta3":
+ findings.append(
+ _finding(
+ "json_schema",
+ "critical",
+ "apiVersion must be scaffolder.backstage.io/v1beta3",
+ path="apiVersion",
+ )
+ )
+ if data.get("kind") != "Template":
+ findings.append(
+ _finding(
+ "json_schema",
+ "critical",
+ "kind must be Template",
+ path="kind",
+ )
+ )
+ findings.extend(validate_metadata(data))
+ findings.extend(validate_spec_root(data))
+ return findings
+
+
+def validate_with_jsonschema(data: dict, skill_dir: Path) -> tuple[list[dict], str | None]:
+ """Optional full JSON Schema validation when jsonschema is installed."""
+ try:
+ import jsonschema # type: ignore[import-untyped]
+ except ImportError:
+ return [], "jsonschema not installed — structural checks only"
+
+ schema_file = schema_path(skill_dir)
+ if not schema_file.is_file():
+ return [
+ _finding(
+ "json_schema",
+ "info",
+ f"Bundled schema not found at {schema_file}",
+ )
+ ], None
+
+ schema = json.loads(schema_file.read_text(encoding="utf-8"))
+ validator = jsonschema.Draft7Validator(schema)
+ findings: list[dict] = []
+ for error in sorted(validator.iter_errors(data), key=lambda e: list(e.path)):
+ path = ".".join(str(part) for part in error.path) or "(root)"
+ findings.append(
+ _finding(
+ "json_schema",
+ "critical",
+ error.message,
+ path=path,
+ )
+ )
+ return findings, None
+
+
+def run_schema_validation(data: dict, skill_dir: Path, *, use_jsonschema: bool = True) -> dict:
+ """Run structural validation and optional jsonschema validation."""
+ structural = validate_structural(data)
+ jsonschema_findings: list[dict] = []
+ note: str | None = None
+
+ if use_jsonschema:
+ jsonschema_findings, note = validate_with_jsonschema(data, skill_dir)
+
+ # Structural checks are more specific for cross-refs; prefer them over duplicate
+ # jsonschema messages for the same paths when both fire.
+ combined = structural + jsonschema_findings
+ return {
+ "findings": combined,
+ "structural_count": len(structural),
+ "jsonschema_count": len(jsonschema_findings),
+ "note": note,
+ }
diff --git a/skills/rhdh-templates/scripts/validate.py b/skills/rhdh-templates/scripts/validate.py
new file mode 100755
index 0000000..f93e000
--- /dev/null
+++ b/skills/rhdh-templates/scripts/validate.py
@@ -0,0 +1,450 @@
+#!/usr/bin/env python3
+"""Local validation for RHDH Software Templates.
+
+Combines YAML structure checks, gotcha rules from fix_gotchas.py, JSON Schema
+validation (structural subset always; bundled schema when jsonschema is installed), optional
+location.yaml verification, and optional djLint for skeleton Nunjucks files.
+
+Stdlib only per project ADR-0002 (optional PyYAML and djlint when installed).
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+EXIT_SUCCESS = 0
+EXIT_FINDINGS = 1
+EXIT_USAGE = 2
+
+_no_color = os.environ.get("NO_COLOR") is not None
+_is_tty = sys.stderr.isatty() and not _no_color
+
+SCRIPTS_DIR = Path(__file__).resolve().parent
+SKILL_DIR = SCRIPTS_DIR.parent
+sys.path.insert(0, str(SCRIPTS_DIR))
+
+from fix_gotchas import load_rules, resolve_template_path, run_checks # noqa: E402
+from schema_validate import run_schema_validation # noqa: E402
+
+
+def _c(code: str, text: str) -> str:
+ return f"{code}{text}\033[0m" if _is_tty else text
+
+
+def green(t: str) -> str:
+ return _c("\033[0;32m", t)
+
+
+def red(t: str) -> str:
+ return _c("\033[0;31m", t)
+
+
+def yellow(t: str) -> str:
+ return _c("\033[1;33m", t)
+
+
+def load_yaml(text: str) -> tuple[dict | None, str | None]:
+ try:
+ import yaml # type: ignore[import-untyped]
+ except ImportError:
+ return None, "PyYAML not installed — skipping YAML syntax parse (gotcha checks still run)"
+ try:
+ data = yaml.safe_load(text)
+ except yaml.YAMLError as exc:
+ return None, f"YAML syntax error: {exc}"
+ if not isinstance(data, dict):
+ return None, "template.yaml root must be a mapping"
+ return data, None
+
+
+def check_yaml_structure(data: dict) -> list[dict]:
+ findings: list[dict] = []
+ if data.get("kind") != "Template":
+ findings.append(
+ {
+ "check": "yaml_structure",
+ "severity": "critical",
+ "message": f"Expected kind: Template, got {data.get('kind')!r}",
+ }
+ )
+ api = data.get("apiVersion", "")
+ if api != "scaffolder.backstage.io/v1beta3":
+ findings.append(
+ {
+ "check": "yaml_structure",
+ "severity": "critical",
+ "message": f"Expected apiVersion scaffolder.backstage.io/v1beta3, got {api!r}",
+ }
+ )
+ spec = data.get("spec")
+ if not isinstance(spec, dict):
+ findings.append(
+ {
+ "check": "yaml_structure",
+ "severity": "critical",
+ "message": "Missing or invalid spec section",
+ }
+ )
+ return findings
+ if "parameters" not in spec:
+ findings.append(
+ {
+ "check": "yaml_structure",
+ "severity": "warning",
+ "message": "spec.parameters is missing",
+ }
+ )
+ if "steps" not in spec:
+ findings.append(
+ {
+ "check": "yaml_structure",
+ "severity": "warning",
+ "message": "spec.steps is missing",
+ }
+ )
+ elif isinstance(spec.get("steps"), list) and len(spec["steps"]) == 0:
+ findings.append(
+ {
+ "check": "yaml_structure",
+ "severity": "warning",
+ "message": "spec.steps is empty",
+ }
+ )
+ metadata = data.get("metadata")
+ if not isinstance(metadata, dict) or not metadata.get("name"):
+ findings.append(
+ {
+ "check": "yaml_structure",
+ "severity": "critical",
+ "message": "metadata.name is required",
+ }
+ )
+ return findings
+
+
+def check_location_yaml(repo_root: Path) -> list[dict]:
+ findings: list[dict] = []
+ location = repo_root / "location.yaml"
+ if not location.is_file():
+ findings.append(
+ {
+ "check": "location",
+ "severity": "warning",
+ "message": "Root location.yaml not found",
+ }
+ )
+ return findings
+ text = location.read_text(encoding="utf-8")
+ if "kind: Location" not in text:
+ findings.append(
+ {
+ "check": "location",
+ "severity": "critical",
+ "message": "location.yaml missing kind: Location",
+ }
+ )
+ if "templates/**/template.yaml" not in text and "targets:" not in text:
+ findings.append(
+ {
+ "check": "location",
+ "severity": "warning",
+ "message": "location.yaml may not register template.yaml files",
+ }
+ )
+ return findings
+
+
+# djlint defaults to --extension=html; scaffolder skeletons are mostly non-HTML.
+SKELETON_LINT_SKIP_SUFFIXES = frozenset(
+ {
+ ".png",
+ ".jpg",
+ ".jpeg",
+ ".gif",
+ ".webp",
+ ".ico",
+ ".svg",
+ ".pdf",
+ ".zip",
+ ".tar",
+ ".gz",
+ ".bz2",
+ ".xz",
+ ".7z",
+ ".jar",
+ ".war",
+ ".ear",
+ ".class",
+ ".so",
+ ".dll",
+ ".dylib",
+ ".exe",
+ ".bin",
+ ".dat",
+ ".woff",
+ ".woff2",
+ ".ttf",
+ ".eot",
+ ".mp3",
+ ".mp4",
+ ".avi",
+ ".mov",
+ ".wav",
+ }
+)
+
+
+def collect_skeleton_lint_targets(skeleton_dir: Path) -> tuple[set[str], list[Path]]:
+ """Return (extensions, extensionless files) djlint should lint under skeleton/."""
+ extensions: set[str] = set()
+ extensionless: list[Path] = []
+ for path in skeleton_dir.rglob("*"):
+ if not path.is_file():
+ continue
+ suffix = path.suffix.lower()
+ if suffix in SKELETON_LINT_SKIP_SUFFIXES:
+ continue
+ try:
+ path.read_text(encoding="utf-8")
+ except (UnicodeDecodeError, OSError):
+ continue
+ if suffix:
+ extensions.add(suffix.lstrip("."))
+ else:
+ extensionless.append(path)
+ return extensions, sorted(extensionless)
+
+
+def _parse_djlint_output(proc: subprocess.CompletedProcess) -> list[dict]:
+ combined = f"{proc.stdout}\n{proc.stderr}"
+ if "No files to check" in combined:
+ return [
+ {
+ "check": "nunjucks_lint",
+ "severity": "warning",
+ "message": "djlint found no files to check for this target",
+ }
+ ]
+ if proc.returncode == 0:
+ return []
+ findings: list[dict] = []
+ for line in proc.stdout.splitlines():
+ line = line.strip()
+ if not line:
+ continue
+ findings.append(
+ {
+ "check": "nunjucks_lint",
+ "severity": "warning",
+ "message": line,
+ }
+ )
+ if not findings:
+ findings.append(
+ {
+ "check": "nunjucks_lint",
+ "severity": "warning",
+ "message": proc.stderr.strip() or "djlint reported issues",
+ }
+ )
+ return findings
+
+
+def _run_djlint_cmd(cmd: list[str]) -> list[dict]:
+ try:
+ proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
+ except OSError as exc:
+ return [
+ {
+ "check": "nunjucks_lint",
+ "severity": "info",
+ "message": f"djlint skipped: {exc}",
+ }
+ ]
+ return _parse_djlint_output(proc)
+
+
+def run_djlint(skeleton_dir: Path) -> list[dict]:
+ import shutil
+
+ if not shutil.which("djlint"):
+ return [
+ {
+ "check": "nunjucks_lint",
+ "severity": "info",
+ "message": "djlint not installed — skipping Nunjucks lint",
+ }
+ ]
+
+ extensions, extensionless = collect_skeleton_lint_targets(skeleton_dir)
+ if not extensions and not extensionless:
+ file_count = sum(1 for path in skeleton_dir.rglob("*") if path.is_file())
+ if file_count:
+ return [
+ {
+ "check": "nunjucks_lint",
+ "severity": "info",
+ "message": "No readable text skeleton files to lint",
+ }
+ ]
+ return [
+ {
+ "check": "nunjucks_lint",
+ "severity": "info",
+ "message": "Skeleton directory is empty — nothing to lint",
+ }
+ ]
+
+ findings: list[dict] = []
+ for ext in sorted(extensions):
+ cmd = [
+ "djlint",
+ str(skeleton_dir),
+ "-e",
+ ext,
+ "--profile=jinja",
+ "--lint",
+ "--quiet",
+ ]
+ findings.extend(_run_djlint_cmd(cmd))
+ for path in extensionless:
+ cmd = [
+ "djlint",
+ str(path),
+ "--profile=jinja",
+ "--lint",
+ "--quiet",
+ ]
+ findings.extend(_run_djlint_cmd(cmd))
+ return findings
+
+
+def validate_template(
+ path: Path, *, check_repo: bool, lint_skeleton: bool, use_jsonschema: bool = True
+) -> dict:
+ template_path = resolve_template_path(path)
+ content = template_path.read_text(encoding="utf-8")
+ template_dir = template_path.parent
+ repo_root = (
+ template_dir.parent.parent if template_dir.parent.name == "templates" else template_dir
+ )
+
+ findings: list[dict] = []
+
+ parsed, yaml_note = load_yaml(content)
+ if yaml_note and parsed is None and yaml_note.startswith("YAML syntax"):
+ findings.append({"check": "yaml_syntax", "severity": "critical", "message": yaml_note})
+ elif parsed is not None:
+ findings.extend(check_yaml_structure(parsed))
+ schema_result = run_schema_validation(parsed, SKILL_DIR, use_jsonschema=use_jsonschema)
+ for item in schema_result["findings"]:
+ findings.append(
+ {
+ "check": item["check"],
+ "severity": item["severity"],
+ "message": item["message"],
+ "path": item.get("path", ""),
+ }
+ )
+ if schema_result.get("note"):
+ findings.append(
+ {
+ "check": "json_schema",
+ "severity": "info",
+ "message": schema_result["note"],
+ }
+ )
+ elif yaml_note:
+ findings.append({"check": "yaml_syntax", "severity": "info", "message": yaml_note})
+
+ rules = load_rules(SKILL_DIR)
+ for item in run_checks(content, template_path, rules):
+ findings.append(
+ {
+ "check": item.get("rule_id", "gotcha"),
+ "severity": item["severity"],
+ "message": item.get("message") or item.get("description", ""),
+ "line": item.get("line", 0),
+ }
+ )
+
+ if check_repo:
+ findings.extend(check_location_yaml(repo_root))
+
+ skeleton = template_dir / "skeleton"
+ if lint_skeleton and skeleton.is_dir():
+ findings.extend(run_djlint(skeleton))
+
+ critical = [f for f in findings if f["severity"] == "critical"]
+ warnings = [f for f in findings if f["severity"] == "warning"]
+ return {
+ "ok": len(critical) == 0,
+ "template": str(template_path),
+ "finding_count": len(findings),
+ "critical_count": len(critical),
+ "warning_count": len(warnings),
+ "findings": findings,
+ }
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Validate RHDH Software Template locally.")
+ parser.add_argument(
+ "--path", required=True, type=Path, help="template.yaml or template directory"
+ )
+ parser.add_argument(
+ "--repo",
+ action="store_true",
+ help="Also validate root location.yaml for the template repo",
+ )
+ parser.add_argument(
+ "--lint-skeleton",
+ action="store_true",
+ help="Run djlint on skeleton/ when djlint is installed",
+ )
+ parser.add_argument(
+ "--no-json-schema",
+ action="store_true",
+ help="Skip optional full JSON Schema validation (structural checks still run)",
+ )
+ parser.add_argument("--json", action="store_true", help="Emit JSON result")
+ args = parser.parse_args()
+
+ try:
+ result = validate_template(
+ args.path,
+ check_repo=args.repo,
+ lint_skeleton=args.lint_skeleton,
+ use_jsonschema=not args.no_json_schema,
+ )
+ except FileNotFoundError as exc:
+ print(str(exc), file=sys.stderr)
+ return EXIT_USAGE
+
+ if args.json:
+ print(json.dumps(result, indent=2 if _is_tty else None))
+ else:
+ status = green("PASS") if result["ok"] else red("FAIL")
+ print(f"Validation: {status}")
+ print(f"Template: {result['template']}")
+ print(
+ f"Findings: {result['finding_count']} "
+ f"({result['critical_count']} critical, {result['warning_count']} warnings)"
+ )
+ for finding in result["findings"]:
+ sev = finding["severity"].upper()
+ line = finding.get("line", 0)
+ loc = f"line {line}: " if line else ""
+ color = red if finding["severity"] == "critical" else yellow
+ print(f" {color(f'[{sev}]')} {loc}{finding['message']}")
+
+ return EXIT_SUCCESS if result["ok"] else EXIT_FINDINGS
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tests/conftest.py b/tests/conftest.py
index 3834c87..27845a8 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -5,9 +5,16 @@
import pytest
PROJECT_ROOT = Path(__file__).parent.parent
+RHDH_TEMPLATES_SKILL_DIR = PROJECT_ROOT / "skills" / "rhdh-templates"
@pytest.fixture
def skill_root():
"""Return the project root path."""
return PROJECT_ROOT
+
+
+@pytest.fixture
+def rhdh_templates_skill_dir():
+ """Return the rhdh-templates skill directory path."""
+ return RHDH_TEMPLATES_SKILL_DIR
diff --git a/tests/integration/test_rhdh_templates_live.py b/tests/integration/test_rhdh_templates_live.py
new file mode 100644
index 0000000..de6b430
--- /dev/null
+++ b/tests/integration/test_rhdh_templates_live.py
@@ -0,0 +1,122 @@
+"""Optional live RHDH integration tests for rhdh-templates scripts.
+
+Skipped when RHDH_URL is unset or the Scaffolder API is unreachable.
+Run manually against rhdh-local:
+
+ RHDH_URL=http://localhost:7007 uv run pytest tests/integration/test_rhdh_templates_live.py -v
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import subprocess
+import sys
+import urllib.error
+import urllib.request
+from pathlib import Path
+
+import pytest
+import yaml
+
+SKILL_DIR = Path(__file__).resolve().parents[2] / "skills" / "rhdh-templates"
+SCRIPTS = SKILL_DIR / "scripts"
+MINIMAL_TEMPLATE = SKILL_DIR / "assets" / "examples" / "minimal-template"
+
+
+def _rhdh_url() -> str | None:
+ return os.environ.get("RHDH_URL", "http://localhost:7007").strip() or None
+
+
+def _rhdh_reachable(url: str) -> bool:
+ headers = {"Accept": "application/json"}
+ token = os.environ.get("RHDH_TOKEN") or os.environ.get("BACKSTAGE_TOKEN")
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+ try:
+ req = urllib.request.Request(
+ f"{url.rstrip('/')}/api/scaffolder/v2/actions",
+ headers=headers,
+ method="GET",
+ )
+ with urllib.request.urlopen(req, timeout=5) as resp:
+ return resp.status == 200
+ except (urllib.error.URLError, TimeoutError, OSError):
+ return False
+
+
+@pytest.fixture(scope="module")
+def rhdh_url() -> str:
+ url = _rhdh_url()
+ if not url or not _rhdh_reachable(url):
+ pytest.skip("RHDH Scaffolder API not reachable — set RHDH_URL to run live tests")
+ return url
+
+
+def run_script(script: str, *args: str) -> subprocess.CompletedProcess:
+ return subprocess.run(
+ [sys.executable, str(SCRIPTS / script), *args],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+
+class TestLiveScaffolderApi:
+ def test_list_actions(self, rhdh_url: str) -> None:
+ result = run_script("list_actions.py", "--rhdh-url", rhdh_url, "--json")
+ assert result.returncode == 0, result.stderr
+ data = json.loads(result.stdout)
+ assert data["ok"] is True
+ assert data["action_count"] >= 1
+ ids = {a["id"] for a in data["actions"]}
+ assert "fetch:template" in ids or "debug:log" in ids
+
+ def test_explain_action_debug_log(self, rhdh_url: str) -> None:
+ result = run_script(
+ "explain_action.py",
+ "--rhdh-url",
+ rhdh_url,
+ "--action",
+ "debug:log",
+ "--json",
+ )
+ if result.returncode != 0:
+ pytest.skip("debug:log action not available on this instance")
+ data = json.loads(result.stdout)
+ assert data["ok"] is True
+ assert data["id"] == "debug:log"
+ assert data.get("schema") is not None
+
+ def test_dry_run_minimal_template(self, rhdh_url: str, tmp_path: Path) -> None:
+ values = {
+ "componentId": "dogfood-demo",
+ "owner": "group:default/team-a",
+ "description": "Live integration test",
+ }
+ values_file = tmp_path / "values.json"
+ values_file.write_text(json.dumps(values), encoding="utf-8")
+
+ result = run_script(
+ "dry_run.py",
+ "--rhdh-url",
+ rhdh_url,
+ "--path",
+ str(MINIMAL_TEMPLATE),
+ "--values",
+ str(values_file),
+ "--json",
+ )
+ assert result.returncode == 0, result.stderr or result.stdout
+ data = json.loads(result.stdout)
+ assert data["ok"] is True
+ assert data.get("log_line_count", 0) >= 0
+
+ def test_minimal_template_passes_local_validate_before_live(self) -> None:
+ """Gate: bundled example must pass local validation (dogfood prerequisite)."""
+ result = run_script("validate.py", "--path", str(MINIMAL_TEMPLATE), "--repo", "--json")
+ data = json.loads(result.stdout)
+ assert result.returncode == 0, data
+ assert data["ok"] is True
+ template = yaml.safe_load((MINIMAL_TEMPLATE / "template.yaml").read_text(encoding="utf-8"))
+ assert template["kind"] == "Template"
diff --git a/tests/unit/test_claude_md.py b/tests/unit/test_claude_md.py
index efa42fc..a197677 100644
--- a/tests/unit/test_claude_md.py
+++ b/tests/unit/test_claude_md.py
@@ -50,4 +50,5 @@ def test_has_verification_command(self, agents_md):
def test_has_available_skills_section(self, agents_md):
"""AGENTS.md should list user-facing skills."""
assert "## Available skills" in agents_md
+ assert "rhdh-templates" in agents_md
assert "skill-maker" in agents_md
diff --git a/tests/unit/test_rhdh_templates.py b/tests/unit/test_rhdh_templates.py
new file mode 100644
index 0000000..d460996
--- /dev/null
+++ b/tests/unit/test_rhdh_templates.py
@@ -0,0 +1,576 @@
+"""Tests for rhdh-templates skill scripts."""
+
+from __future__ import annotations
+
+import json
+import re
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+import yaml
+
+SKILL_DIR = Path(__file__).resolve().parents[2] / "skills" / "rhdh-templates"
+SCRIPTS = SKILL_DIR / "scripts"
+REFERENCES = SKILL_DIR / "references"
+BUNDLED_EXAMPLES = SKILL_DIR / "assets" / "examples"
+
+
+def run_script(script: str, *args: str) -> subprocess.CompletedProcess:
+ return subprocess.run(
+ [sys.executable, str(SCRIPTS / script), *args],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+
+class TestRhdhTemplatesSkillMd:
+ @pytest.fixture
+ def skill_md(self) -> str:
+ return (SKILL_DIR / "SKILL.md").read_text(encoding="utf-8")
+
+ @pytest.fixture
+ def skill_frontmatter(self, skill_md: str) -> dict:
+ match = re.match(r"^---\n(.*?)\n---", skill_md, re.DOTALL)
+ assert match, "SKILL.md missing YAML frontmatter"
+ return yaml.safe_load(match.group(1))
+
+ def test_frontmatter_name(self, skill_frontmatter: dict) -> None:
+ assert skill_frontmatter["name"] == "rhdh-templates"
+ assert SKILL_DIR.name == skill_frontmatter["name"]
+
+ def test_frontmatter_description(self, skill_frontmatter: dict) -> None:
+ assert len(skill_frontmatter["description"]) > 50
+ assert len(skill_frontmatter["description"]) <= 1024
+
+ def test_has_intake_and_routing(self, skill_md: str) -> None:
+ assert "" in skill_md
+ assert "" in skill_md
+ assert "**Wait for response before proceeding.**" in skill_md
+
+ def test_has_essential_principles(self, skill_md: str) -> None:
+ assert "" in skill_md
+ assert "" not in skill_md
+
+ def test_command_metadata_exists(self) -> None:
+ meta = SCRIPTS / "command-metadata.json"
+ data = json.loads(meta.read_text(encoding="utf-8"))
+ expected = {
+ "init",
+ "templatize",
+ "create",
+ "add-parameter",
+ "add-step",
+ "add-skeleton",
+ "create-location",
+ "fix-gotchas",
+ "validate",
+ "list-actions",
+ "dry-run",
+ "explain-action",
+ "examples",
+ }
+ assert expected.issubset(set(data.keys()))
+
+
+class TestRhdhTemplatesReferences:
+ @pytest.fixture
+ def skill_md(self) -> str:
+ return (SKILL_DIR / "SKILL.md").read_text(encoding="utf-8")
+
+ def test_all_referenced_files_exist(self, skill_md: str) -> None:
+ refs = re.findall(r"references/([\w./-]+\.(?:md|json))", skill_md)
+ for ref in sorted(set(refs)):
+ path = SKILL_DIR / "references" / ref
+ assert path.is_file(), f"Missing reference file: {ref}"
+
+ def test_schema_file_exists(self) -> None:
+ schema = SKILL_DIR / "references" / "schemas" / "template-v1beta3.schema.json"
+ assert schema.is_file()
+ data = json.loads(schema.read_text(encoding="utf-8"))
+ assert data["properties"]["apiVersion"]["enum"] == ["scaffolder.backstage.io/v1beta3"]
+
+ def test_best_practices_reference_structure(self) -> None:
+ content = (REFERENCES / "best-practices.md").read_text(encoding="utf-8")
+ assert "" in content
+ assert "" in content
+ assert "Template Editor" in content
+ assert "parameter-widgets.md" in content
+ assert "parseEntityRef" in content
+
+ def test_command_references_have_xml_structure(self) -> None:
+ command_refs = [
+ "init.md",
+ "templatize.md",
+ "create.md",
+ "add-parameter.md",
+ "add-step.md",
+ "add-skeleton.md",
+ "create-location.md",
+ "fix-gotchas.md",
+ "validate.md",
+ "list-actions.md",
+ "dry-run.md",
+ "explain-action.md",
+ "example-catalog.md",
+ ]
+ for name in command_refs:
+ content = (REFERENCES / name).read_text(encoding="utf-8")
+ assert "" in content, f"{name} missing "
+ assert "" in content, f"{name} missing "
+
+
+class TestInitScript:
+ def test_scaffolds_layout(self, tmp_path: Path) -> None:
+ result = run_script("init.py", "--path", str(tmp_path), "--json")
+ assert result.returncode in (0, 1)
+ data = json.loads(result.stdout)
+ assert (tmp_path / "templates" / "example-template" / "template.yaml").exists()
+ assert (tmp_path / "location.yaml").exists()
+ assert data["ok"] is True
+
+
+class TestAnalyzeScript:
+ def test_detects_nodejs_project(self, tmp_path: Path) -> None:
+ (tmp_path / "package.json").write_text(
+ json.dumps({"name": "demo-service", "description": "Demo app"}),
+ encoding="utf-8",
+ )
+ (tmp_path / "catalog-info.yaml").write_text(
+ "metadata:\n name: demo-service\n owner: group:default/team-a\n",
+ encoding="utf-8",
+ )
+ wf_dir = tmp_path / ".github" / "workflows"
+ wf_dir.mkdir(parents=True)
+ (wf_dir / "ci.yaml").write_text(
+ "jobs:\n build:\n steps:\n - run: echo ${{ github.ref }}\n"
+ )
+
+ result = run_script("analyze.py", "--path", str(tmp_path), "--json")
+ assert result.returncode == 0
+ data = json.loads(result.stdout)
+ assert "nodejs" in data["project_types"]
+ assert data["candidate_count"] >= 1
+ assert data["workflow_files"][0]["needs_raw_block"] is True
+
+
+class TestCreateLocationScript:
+ def test_discovers_templates(self, tmp_path: Path) -> None:
+ template_dir = tmp_path / "templates" / "demo"
+ template_dir.mkdir(parents=True)
+ (template_dir / "template.yaml").write_text("kind: Template\n", encoding="utf-8")
+
+ result = run_script("create_location.py", "--path", str(tmp_path), "--json")
+ assert result.returncode == 0
+ data = json.loads(result.stdout)
+ assert data["template_count"] == 1
+ assert (tmp_path / "location.yaml").exists()
+
+ def test_dry_run_does_not_write(self, tmp_path: Path) -> None:
+ template_dir = tmp_path / "templates" / "demo"
+ template_dir.mkdir(parents=True)
+ (template_dir / "template.yaml").write_text("kind: Template\n", encoding="utf-8")
+
+ result = run_script("create_location.py", "--path", str(tmp_path), "--dry-run", "--json")
+ assert result.returncode == 0
+ data = json.loads(result.stdout)
+ assert data["written"] is False
+ assert not (tmp_path / "location.yaml").exists()
+
+
+class TestFixGotchasScript:
+ def test_minimal_example_passes(self) -> None:
+ template = BUNDLED_EXAMPLES / "minimal-template" / "template.yaml"
+ result = run_script("fix_gotchas.py", "--path", str(template), "--json")
+ assert result.returncode == 0
+ data = json.loads(result.stdout)
+ assert data["critical_count"] == 0
+
+ def test_detects_wrong_api_version(self, tmp_path: Path) -> None:
+ bad = tmp_path / "template.yaml"
+ bad.write_text(
+ "apiVersion: backstage.io/v1beta2\nkind: Template\nspec:\n steps: []\n",
+ encoding="utf-8",
+ )
+ result = run_script("fix_gotchas.py", "--path", str(bad), "--json")
+ data = json.loads(result.stdout)
+ assert data["critical_count"] >= 1
+
+ apply = run_script("fix_gotchas.py", "--path", str(bad), "--apply", "--json")
+ assert "scaffolder.backstage.io/v1beta3" in bad.read_text(encoding="utf-8")
+ assert json.loads(apply.stdout)["applied"] is True
+
+ def test_fixes_pascal_case_action(self, tmp_path: Path) -> None:
+ bad = tmp_path / "template.yaml"
+ bad.write_text(
+ "apiVersion: scaffolder.backstage.io/v1beta3\n"
+ "kind: Template\n"
+ "spec:\n"
+ " parameters: []\n"
+ " steps:\n"
+ " - id: pub\n"
+ " action: publish:GitHub\n"
+ " input: {}\n",
+ encoding="utf-8",
+ )
+ run_script("fix_gotchas.py", "--path", str(bad), "--apply")
+ assert "action: publish:github" in bad.read_text(encoding="utf-8")
+
+ def test_detects_sensitive_param_without_secret_field(self, tmp_path: Path) -> None:
+ bad = tmp_path / "template.yaml"
+ bad.write_text(
+ "apiVersion: scaffolder.backstage.io/v1beta3\n"
+ "kind: Template\n"
+ "metadata:\n"
+ " name: bad\n"
+ " tags:\n"
+ " - test\n"
+ "spec:\n"
+ " parameters:\n"
+ " - title: Auth\n"
+ " properties:\n"
+ " apiToken:\n"
+ " title: API Token\n"
+ " type: string\n"
+ " steps: []\n",
+ encoding="utf-8",
+ )
+ result = run_script("fix_gotchas.py", "--path", str(bad), "--json")
+ data = json.loads(result.stdout)
+ rule_ids = {f["rule_id"] for f in data["findings"]}
+ assert "sensitive-param-secret-field" in rule_ids
+
+ def test_detects_missing_metadata_tags(self, tmp_path: Path) -> None:
+ bad = tmp_path / "template.yaml"
+ bad.write_text(
+ "apiVersion: scaffolder.backstage.io/v1beta3\n"
+ "kind: Template\n"
+ "metadata:\n"
+ " name: bad\n"
+ "spec:\n"
+ " parameters: []\n"
+ " steps: []\n",
+ encoding="utf-8",
+ )
+ result = run_script("fix_gotchas.py", "--path", str(bad), "--json")
+ data = json.loads(result.stdout)
+ rule_ids = {f["rule_id"] for f in data["findings"]}
+ assert "metadata-tags" in rule_ids
+
+
+class TestExampleTemplates:
+ @pytest.mark.parametrize(
+ "example_dir",
+ [
+ "minimal-template",
+ "nodejs-backend",
+ "java-springboot",
+ ],
+ )
+ def test_examples_pass_validate(self, example_dir: str) -> None:
+ path = BUNDLED_EXAMPLES / example_dir
+ result = run_script("validate.py", "--path", str(path), "--json")
+ data = json.loads(result.stdout)
+ assert result.returncode == 0, data
+ assert data["ok"] is True
+ assert data["critical_count"] == 0
+
+ def test_examples_readme_exists(self) -> None:
+ readme = BUNDLED_EXAMPLES / "README.md"
+ assert readme.is_file()
+ text = readme.read_text(encoding="utf-8")
+ assert "nodejs-backend" in text
+ assert "java-springboot" in text
+
+
+class TestSchemaValidateModule:
+ @pytest.fixture
+ def schema_module(self):
+ sys.path.insert(0, str(SCRIPTS))
+ import schema_validate
+
+ return schema_validate
+
+ def test_detects_missing_required_parameter(self, schema_module) -> None:
+ data = {
+ "apiVersion": "scaffolder.backstage.io/v1beta3",
+ "kind": "Template",
+ "metadata": {"name": "bad"},
+ "spec": {
+ "type": "service",
+ "parameters": [
+ {
+ "title": "Details",
+ "required": ["missing"],
+ "properties": {"name": {"type": "string", "title": "Name"}},
+ }
+ ],
+ "steps": [{"id": "x", "action": "debug:log", "input": {}}],
+ },
+ }
+ findings = schema_module.validate_structural(data)
+ messages = " ".join(f["message"] for f in findings)
+ assert "missing" in messages
+
+ def test_detects_duplicate_step_ids(self, schema_module) -> None:
+ data = {
+ "apiVersion": "scaffolder.backstage.io/v1beta3",
+ "kind": "Template",
+ "metadata": {"name": "dup"},
+ "spec": {
+ "type": "service",
+ "parameters": [],
+ "steps": [
+ {"id": "same", "action": "debug:log", "input": {}},
+ {"id": "same", "action": "debug:log", "input": {}},
+ ],
+ },
+ }
+ findings = schema_module.validate_structural(data)
+ assert any("Duplicate step id" in f["message"] for f in findings)
+
+ def test_detects_unknown_parameter_reference(self, schema_module) -> None:
+ data = {
+ "apiVersion": "scaffolder.backstage.io/v1beta3",
+ "kind": "Template",
+ "metadata": {"name": "ref"},
+ "spec": {
+ "type": "service",
+ "parameters": [
+ {
+ "title": "Details",
+ "properties": {"name": {"type": "string", "title": "Name"}},
+ }
+ ],
+ "steps": [
+ {
+ "id": "fetch",
+ "action": "fetch:template",
+ "input": {"url": "./s", "values": {"x": "${{ parameters.unknown }}"}},
+ }
+ ],
+ },
+ }
+ findings = schema_module.validate_cross_references(data)
+ assert any("unknown parameter" in f["message"] for f in findings)
+
+ def test_jsonschema_validates_good_example(self, schema_module) -> None:
+ template = BUNDLED_EXAMPLES / "nodejs-backend" / "template.yaml"
+ data = yaml.safe_load(template.read_text(encoding="utf-8"))
+ findings, note = schema_module.validate_with_jsonschema(data, SKILL_DIR)
+ if note and "not installed" in note:
+ pytest.skip(note)
+ critical = [f for f in findings if f["severity"] == "critical"]
+ assert critical == []
+
+
+class TestValidateScript:
+ def test_minimal_example_passes(self) -> None:
+ template = BUNDLED_EXAMPLES / "minimal-template"
+ result = run_script("validate.py", "--path", str(template), "--json")
+ assert result.returncode == 0
+ data = json.loads(result.stdout)
+ assert data["ok"] is True
+ assert data["critical_count"] == 0
+
+ def test_collect_skeleton_lint_targets_includes_non_html_extensions(self) -> None:
+ sys.path.insert(0, str(SCRIPTS))
+ import validate
+
+ skeleton = BUNDLED_EXAMPLES / "java-springboot" / "skeleton"
+ extensions, extensionless = validate.collect_skeleton_lint_targets(skeleton)
+ assert {"java", "md", "properties", "xml", "yaml"}.issubset(extensions)
+ assert extensionless == []
+
+ def test_run_djlint_uses_per_extension_targets(self, monkeypatch, tmp_path: Path) -> None:
+ sys.path.insert(0, str(SCRIPTS))
+ import validate
+
+ skeleton = tmp_path / "skeleton"
+ skeleton.mkdir()
+ (skeleton / "App.java").write_text("public class App {}\n", encoding="utf-8")
+ (skeleton / "config.yaml").write_text("name: {{ values.name }}\n", encoding="utf-8")
+
+ calls: list[list[str]] = []
+
+ def fake_run(cmd, **kwargs):
+ calls.append(cmd)
+ return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
+
+ monkeypatch.setattr(subprocess, "run", fake_run)
+ monkeypatch.setattr("shutil.which", lambda _: "/usr/bin/djlint")
+
+ findings = validate.run_djlint(skeleton)
+ assert findings == []
+ assert len(calls) == 2
+ assert all(call[0] == "djlint" for call in calls)
+ assert all("-e" in call for call in calls)
+ used_extensions = {call[call.index("-e") + 1] for call in calls}
+ assert used_extensions == {"java", "yaml"}
+
+ def test_run_djlint_warns_when_djlint_checks_nothing(self, monkeypatch, tmp_path: Path) -> None:
+ sys.path.insert(0, str(SCRIPTS))
+ import validate
+
+ skeleton = tmp_path / "skeleton"
+ skeleton.mkdir()
+ (skeleton / "config.yaml").write_text("name: test\n", encoding="utf-8")
+
+ def fake_run(cmd, **kwargs):
+ return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="No files to check! 😢")
+
+ monkeypatch.setattr(subprocess, "run", fake_run)
+ monkeypatch.setattr("shutil.which", lambda _: "/usr/bin/djlint")
+
+ findings = validate.run_djlint(skeleton)
+ assert any(
+ f["check"] == "nunjucks_lint" and "no files to check" in f["message"].lower()
+ for f in findings
+ )
+
+ def test_detects_bad_api_version(self, tmp_path: Path) -> None:
+ bad = tmp_path / "template.yaml"
+ bad.write_text(
+ "apiVersion: backstage.io/v1beta2\n"
+ "kind: Template\n"
+ "metadata:\n"
+ " name: bad\n"
+ "spec:\n"
+ " parameters: []\n"
+ " steps: []\n",
+ encoding="utf-8",
+ )
+ result = run_script("validate.py", "--path", str(bad), "--json")
+ assert result.returncode == 1
+ data = json.loads(result.stdout)
+ assert data["critical_count"] >= 1
+
+ def test_detects_missing_required_in_schema(self, tmp_path: Path) -> None:
+ bad = tmp_path / "template.yaml"
+ bad.write_text(
+ "apiVersion: scaffolder.backstage.io/v1beta3\n"
+ "kind: Template\n"
+ "metadata:\n"
+ " name: bad\n"
+ "spec:\n"
+ " type: service\n"
+ " parameters:\n"
+ " - title: Details\n"
+ " required:\n"
+ " - ghost\n"
+ " properties:\n"
+ " name:\n"
+ " type: string\n"
+ " steps:\n"
+ " - id: x\n"
+ " action: debug:log\n"
+ " input: {}\n",
+ encoding="utf-8",
+ )
+ result = run_script("validate.py", "--path", str(bad), "--json")
+ data = json.loads(result.stdout)
+ schema_findings = [f for f in data["findings"] if f["check"] == "json_schema"]
+ assert any("ghost" in f["message"] for f in schema_findings)
+
+ def test_repo_flag_checks_location(self, tmp_path: Path) -> None:
+ template_dir = tmp_path / "templates" / "demo"
+ template_dir.mkdir(parents=True)
+ (template_dir / "template.yaml").write_text(
+ (BUNDLED_EXAMPLES / "minimal-template" / "template.yaml").read_text(encoding="utf-8"),
+ encoding="utf-8",
+ )
+ result = run_script("validate.py", "--path", str(template_dir), "--repo", "--json")
+ data = json.loads(result.stdout)
+ location_findings = [f for f in data["findings"] if f["check"] == "location"]
+ assert any("location.yaml not found" in f["message"] for f in location_findings)
+
+
+class TestScaffolderApiHelpers:
+ def test_parse_template_ref(self) -> None:
+ sys.path.insert(0, str(SCRIPTS))
+ from scaffolder_api import parse_template_ref
+
+ kind, namespace, name = parse_template_ref("template:default/my-template")
+ assert (kind, namespace, name) == ("template", "default", "my-template")
+
+ def test_load_directory_contents(self, tmp_path: Path) -> None:
+ sys.path.insert(0, str(SCRIPTS))
+ from scaffolder_api import load_directory_contents
+
+ (tmp_path / "README.md").write_text("hello", encoding="utf-8")
+ nested = tmp_path / "nested"
+ nested.mkdir()
+ (nested / "file.txt").write_text("data", encoding="utf-8")
+
+ contents = load_directory_contents(tmp_path)
+ paths = {item["path"] for item in contents}
+ assert paths == {"README.md", "nested/file.txt"}
+ assert all("base64Content" in item for item in contents)
+
+
+class TestExplainActionScript:
+ def test_requires_exactly_one_mode(self) -> None:
+ result = run_script(
+ "explain_action.py",
+ "--rhdh-url",
+ "http://localhost:7007",
+ "--json",
+ )
+ assert result.returncode == 2
+ assert "exactly one" in result.stderr.lower()
+
+
+class TestListExamplesScript:
+ def test_help(self) -> None:
+ result = run_script("list_examples.py", "--help")
+ assert result.returncode == 0
+ assert "--match" in result.stdout
+
+ def test_recommended_backend_match(self) -> None:
+ result = run_script(
+ "list_examples.py",
+ "--match",
+ "spring boot backend with ci",
+ "--limit",
+ "3",
+ "--json",
+ )
+ assert result.returncode == 0
+ data = json.loads(result.stdout)
+ assert data["ok"] is True
+ assert data["count"] >= 1
+ ids = {item["id"] for item in data["examples"]}
+ assert "spring-boot-backend" in ids
+
+ def test_local_only_filter(self) -> None:
+ result = run_script("list_examples.py", "--local-only", "--json")
+ data = json.loads(result.stdout)
+ assert data["count"] >= 2
+ assert all(item.get("local_bundled") for item in data["examples"])
+
+ def test_catalog_file_exists(self) -> None:
+ catalog = SKILL_DIR / "assets" / "example-catalog.json"
+ assert catalog.is_file()
+ data = json.loads(catalog.read_text(encoding="utf-8"))
+ assert len(data["examples"]) >= 20
+
+
+class TestListActionsScript:
+ def test_help(self) -> None:
+ result = run_script("list_actions.py", "--help")
+ assert result.returncode == 0
+ assert "--rhdh-url" in result.stdout
+
+
+class TestDryRunScript:
+ def test_missing_template(self, tmp_path: Path) -> None:
+ result = run_script(
+ "dry_run.py",
+ "--rhdh-url",
+ "http://localhost:7007",
+ "--path",
+ str(tmp_path),
+ )
+ assert result.returncode != 0
diff --git a/tests/unit/test_skill_structure.py b/tests/unit/test_skill_structure.py
index c6c72fd..5c13df3 100644
--- a/tests/unit/test_skill_structure.py
+++ b/tests/unit/test_skill_structure.py
@@ -6,6 +6,34 @@
import yaml
+class TestRhdhTemplatesSkillMd:
+ """Test that rhdh-templates SKILL.md has required structure."""
+
+ @pytest.fixture
+ def skill_md(self, rhdh_templates_skill_dir):
+ """Load rhdh-templates SKILL.md content."""
+ skill_path = rhdh_templates_skill_dir / "SKILL.md"
+ return skill_path.read_text(encoding="utf-8")
+
+ @pytest.fixture
+ def skill_frontmatter(self, skill_md):
+ """Parse YAML frontmatter from SKILL.md."""
+ match = re.match(r"^---\n(.*?)\n---", skill_md, re.DOTALL)
+ if not match:
+ pytest.fail("SKILL.md missing YAML frontmatter")
+ return yaml.safe_load(match.group(1))
+
+ def test_frontmatter_has_name(self, skill_frontmatter):
+ """SKILL.md must have a name field matching the directory."""
+ assert "name" in skill_frontmatter
+ assert skill_frontmatter["name"] == "rhdh-templates"
+
+ def test_frontmatter_has_description(self, skill_frontmatter):
+ """SKILL.md must have a description field."""
+ assert "description" in skill_frontmatter
+ assert len(skill_frontmatter["description"]) > 20
+
+
class TestSkillMakerSkillMd:
"""Test that skill-maker SKILL.md has required structure."""
@@ -37,7 +65,7 @@ def test_frontmatter_has_description(self, skill_frontmatter):
class TestUserFacingSkillDirectories:
"""Ensure only expected user-facing skills are present."""
- EXPECTED_SKILLS = {"skill-maker"}
+ EXPECTED_SKILLS = {"rhdh-templates", "skill-maker"}
def test_skill_directories(self, skill_root):
"""skills/ should contain exactly the user-facing skill set."""
diff --git a/uv.lock b/uv.lock
index 12c003b..0984b72 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,6 +1,19 @@
version = 1
revision = 3
requires-python = ">=3.10"
+resolution-markers = [
+ "python_full_version >= '3.11'",
+ "python_full_version < '3.11'",
+]
+
+[[package]]
+name = "attrs"
+version = "26.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
+]
[[package]]
name = "colorama"
@@ -32,6 +45,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
+[[package]]
+name = "jsonschema"
+version = "4.26.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "jsonschema-specifications" },
+ { name = "referencing" },
+ { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
+]
+
+[[package]]
+name = "jsonschema-specifications"
+version = "2025.9.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "referencing" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
+]
+
[[package]]
name = "packaging"
version = "26.2"
@@ -141,6 +182,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
+[[package]]
+name = "referencing"
+version = "0.37.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
+]
+
[[package]]
name = "rhdh-users-skill-pack"
version = "0.1.0"
@@ -148,6 +204,7 @@ source = { virtual = "." }
[package.optional-dependencies]
dev = [
+ { name = "jsonschema" },
{ name = "pytest" },
{ name = "pyyaml" },
{ name = "ruff" },
@@ -155,12 +212,264 @@ dev = [
[package.metadata]
requires-dist = [
+ { name = "jsonschema", marker = "extra == 'dev'", specifier = ">=4.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" },
{ name = "pyyaml", marker = "extra == 'dev'", specifier = ">=6.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4.0" },
]
provides-extras = ["dev"]
+[[package]]
+name = "rpds-py"
+version = "0.30.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.11'",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" },
+ { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" },
+ { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" },
+ { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" },
+ { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" },
+ { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" },
+ { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" },
+ { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" },
+ { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" },
+ { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" },
+ { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" },
+ { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" },
+ { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" },
+ { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" },
+ { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" },
+ { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" },
+ { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" },
+ { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" },
+ { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" },
+ { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" },
+ { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" },
+ { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" },
+ { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" },
+ { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" },
+ { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" },
+ { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" },
+ { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" },
+ { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" },
+ { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" },
+ { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" },
+ { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" },
+ { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" },
+ { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" },
+ { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" },
+ { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" },
+ { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" },
+ { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" },
+ { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" },
+ { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" },
+ { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" },
+ { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" },
+ { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" },
+ { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" },
+ { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" },
+ { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" },
+ { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" },
+ { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" },
+ { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" },
+ { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" },
+ { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" },
+ { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" },
+]
+
+[[package]]
+name = "rpds-py"
+version = "2026.6.3"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.11'",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" },
+ { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" },
+ { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" },
+ { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" },
+ { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" },
+ { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" },
+ { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" },
+ { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" },
+ { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" },
+ { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" },
+ { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" },
+ { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" },
+ { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" },
+ { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" },
+ { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" },
+ { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" },
+ { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" },
+ { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" },
+ { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" },
+ { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" },
+ { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" },
+ { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" },
+ { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" },
+ { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" },
+ { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" },
+ { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" },
+ { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" },
+ { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" },
+ { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" },
+ { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" },
+ { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" },
+ { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" },
+ { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" },
+ { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" },
+ { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" },
+ { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" },
+ { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" },
+]
+
[[package]]
name = "ruff"
version = "0.15.20"