diff --git a/.agents/catalog-skills.yaml b/.agents/catalog-skills.yaml
new file mode 100644
index 00000000000..31a71ad2f5b
--- /dev/null
+++ b/.agents/catalog-skills.yaml
@@ -0,0 +1,40 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+# Explicit allowlist for NemoClaw skills exported to the NVIDIA Verified Skills catalog.
+# Keep this file deterministic: no timestamps, no generated comments, and no implicit globs.
+# The export is regenerated with: python3 scripts/export-catalog-skills.py
+version: 1
+source: .agents/skills
+export: skills/nemoclaw
+include:
+ - skill: nemoclaw-skills-guide
+ rationale: Public index for user-facing NemoClaw skills.
+ - skill: nemoclaw-user-agent-skills
+ rationale: Public user documentation skill.
+ - skill: nemoclaw-user-configure-inference
+ rationale: Public user documentation skill.
+ - skill: nemoclaw-user-configure-security
+ rationale: Public user documentation skill.
+ - skill: nemoclaw-user-deploy-remote
+ rationale: Public user documentation skill.
+ - skill: nemoclaw-user-get-started
+ rationale: Public user documentation skill.
+ - skill: nemoclaw-user-manage-policy
+ rationale: Public user documentation skill.
+ - skill: nemoclaw-user-manage-sandboxes
+ rationale: Public user documentation skill.
+ - skill: nemoclaw-user-monitor-sandbox
+ rationale: Public user documentation skill.
+ - skill: nemoclaw-user-overview
+ rationale: Public user documentation skill.
+ - skill: nemoclaw-user-reference
+ rationale: Public user documentation skill.
+exclude:
+ - pattern: nemoclaw-maintainer-*
+ rationale: Internal maintainer workflows are not catalog/customer-facing.
+ - pattern: nemoclaw-contributor-*
+ rationale: Contributor workflows are repo-local until explicitly approved for catalog publication.
+metadata:
+ minNemoClawVersion: "0.1.0"
+ testedNemoClawVersion: "0.1.0"
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 00000000000..d20165355a1
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,5 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+/skills/nemoclaw/** linguist-generated=true
+/skills/nemoclaw/**/*.md diff=markdown
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 03ada15300a..939fb96731d 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -30,6 +30,11 @@
/docs/ @NVIDIA/nemoclaw-engineer
/spark-install.md @NVIDIA/nemoclaw-engineer
+# ── Agent skills catalog ──
+/.agents/catalog-skills.yaml @NVIDIA/nemoclaw-maintainer @NVIDIA/nemoclaw-engineer
+/.agents/skills/ @NVIDIA/nemoclaw-maintainer @NVIDIA/nemoclaw-engineer
+/skills/nemoclaw/ @NVIDIA/nemoclaw-maintainer @NVIDIA/nemoclaw-engineer
+
# ── Tests ──
/test/ @NVIDIA/nemoclaw-engineer
diff --git a/.github/catalog-skills-signing-flow.md b/.github/catalog-skills-signing-flow.md
new file mode 100644
index 00000000000..870b61524da
--- /dev/null
+++ b/.github/catalog-skills-signing-flow.md
@@ -0,0 +1,79 @@
+
+
+
+# NemoClaw catalog skills signing flow
+
+This diagram shows the required sequence for publishing NemoClaw user-facing skills into the NVIDIA Verified Skills catalog through the generated `skills/nemoclaw/` export.
+
+```mermaid
+sequenceDiagram
+ autonumber
+ actor Maintainer as Human maintainer
+ participant Source as NemoClaw source
.agents/skills + .agents/catalog-skills.yaml
+ participant Exporter as scripts/export-catalog-skills.py
+ participant Export as Generated export
skills/nemoclaw
+ participant PRCI as PR workflow
CI / Pull Request
+ participant Refresh as Skills / Catalog Refresh workflow
+ participant PR as Same-repo refresh PR
+ participant NVSkills as NVSkills CI signer
+ participant Main as NVIDIA/NemoClaw main
+ participant Target as NVIDIA/skills sync
+
+ Note over Source,Export: Implementation PR path added by issue #4282
+ Maintainer->>Source: Curate catalog-safe skills in .agents/catalog-skills.yaml
+ Maintainer->>Exporter: Run python3 scripts/export-catalog-skills.py
+ Exporter->>Export: Copy allowlisted skills as real files
write catalog-metadata.json
preserve skill.oms.sig + skill-card.md if present
+ Maintainer->>PRCI: Open implementation or content PR
+ PRCI->>Exporter: python3 scripts/export-catalog-skills.py --check --allow-missing
+ Exporter-->>PRCI: Pass before first export exists;
after refresh PR, fail if skills/nemoclaw is stale or hand-edited
+ Maintainer->>Main: Merge reviewed PR after checks pass
+
+ Note over Refresh,PR: Post-merge refresh automation added by this PR
+ Maintainer->>Refresh: Optional manual workflow_dispatch
dry_run=true first
+ Refresh->>Exporter: Regenerate export and show diff only
+ Refresh-->>Maintainer: No branch or PR created in dry run
+ Maintainer->>Refresh: Run dry_run=false when ready
optionally request_nvskills_ci=true
+ Refresh->>Exporter: Regenerate export
+ Exporter->>Export: Update generated files if source changed
+ Refresh->>PR: Create/update automation/catalog-skills-refresh PR
with export diff
+
+ alt request_nvskills_ci=true and bot is accepted
+ Refresh->>PR: Comment /nvskills-ci
+ else bot rejected or manual process preferred
+ Maintainer->>PR: Comment /nvskills-ci manually
+ end
+
+ NVSkills->>PR: Push signing artifacts
skill.oms.sig + skill-card.md
+ PRCI->>Exporter: Re-run --check; signer artifacts are preserved
+ Maintainer->>PR: Review generated export and signing artifacts
+ Maintainer->>Main: Merge signed refresh PR
+ Target->>Main: Sync configured NemoClaw catalog path
+ Target->>Target: Keep only skills with skill.oms.sig and skill-card.md
+```
+
+## Human handoff points
+
+These are the manual review and approval points in the catalog signing flow.
+
+- Curate `.agents/catalog-skills.yaml` when public skill scope changes.
+- Review the generated `skills/nemoclaw/` diff in the same PR as the allowlist/source update.
+- Manually comment `/nvskills-ci` if the workflow bot cannot request signing.
+- Review and merge the signer-updated PR before expecting `NVIDIA/skills` to sync the signed skills.
+
+## Workflow steps added in this PR
+
+These checks and workflow steps automate export freshness while keeping signing under maintainer control.
+
+- `CI / Pull Request` runs `python3 scripts/export-catalog-skills.py --check --allow-missing` so this infrastructure PR can merge before the first generated export, while later export PRs still reject stale or hand-edited files.
+- `Skills / Catalog Refresh` supports:
+ - `dry_run=true` to regenerate and report changes without pushing.
+ - `dry_run=false` to create or update `automation/catalog-skills-refresh`.
+ - `request_nvskills_ci=true` to attempt the `/nvskills-ci` comment after opening/updating the PR.
+ - scheduled no-op/refresh behavior using the same exporter.
+
+## Next Steps
+
+- Review the exporter implementation in [`scripts/export-catalog-skills.py`](../scripts/export-catalog-skills.py).
+- Update the catalog allowlist in [`.agents/catalog-skills.yaml`](../.agents/catalog-skills.yaml) when public skill scope changes.
+- Review generated export diffs under `skills/nemoclaw/` in the refresh PR before requesting or accepting signing artifacts.
+- Check the workflow definitions in [`.github/workflows/pr.yaml`](workflows/pr.yaml) and [`.github/workflows/catalog-skills-refresh.yaml`](workflows/catalog-skills-refresh.yaml).
diff --git a/.github/workflows/catalog-skills-refresh.yaml b/.github/workflows/catalog-skills-refresh.yaml
new file mode 100644
index 00000000000..6f85d187c47
--- /dev/null
+++ b/.github/workflows/catalog-skills-refresh.yaml
@@ -0,0 +1,102 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+name: Skills / Catalog Refresh
+
+on:
+ workflow_dispatch:
+ inputs:
+ dry_run:
+ description: "Regenerate and report changes without pushing or opening a PR"
+ type: boolean
+ required: true
+ default: true
+ request_nvskills_ci:
+ description: "Comment /nvskills-ci on the refresh PR after opening/updating it"
+ type: boolean
+ required: true
+ default: false
+ schedule:
+ - cron: "17 10 * * *"
+
+permissions:
+ contents: write
+ pull-requests: write
+ issues: write
+
+concurrency:
+ group: catalog-skills-refresh
+ cancel-in-progress: false
+
+jobs:
+ refresh:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Configure git author
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+
+ - name: Regenerate catalog skills export
+ run: python3 scripts/export-catalog-skills.py
+
+ - name: Check for changes
+ id: diff
+ run: |
+ if git diff --quiet -- .agents/catalog-skills.yaml skills/nemoclaw; then
+ echo "changed=false" >> "$GITHUB_OUTPUT"
+ echo "No catalog skill export changes detected."
+ else
+ echo "changed=true" >> "$GITHUB_OUTPUT"
+ git diff --stat -- .agents/catalog-skills.yaml skills/nemoclaw
+ fi
+
+ - name: Stop after dry run
+ if: ${{ (github.event_name == 'workflow_dispatch' && inputs.dry_run) || (github.event_name == 'schedule' && steps.diff.outputs.changed != 'true') }}
+ run: |
+ if [[ "${{ steps.diff.outputs.changed }}" == "true" ]]; then
+ echo "Dry run detected catalog skill export changes; no branch or PR was created."
+ else
+ echo "Catalog skill export is already current."
+ fi
+
+ - name: Create or update refresh pull request
+ id: cpr
+ if: ${{ steps.diff.outputs.changed == 'true' && (github.event_name == 'schedule' || !inputs.dry_run) }}
+ uses: peter-evans/create-pull-request@8ce3b843f60ac63fbde403f79364ff7d80b5fbb1 # v7.0.8
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ branch: automation/catalog-skills-refresh
+ delete-branch: true
+ commit-message: "chore(skills): refresh catalog export"
+ title: "chore(skills): refresh catalog export"
+ body: |
+ ## Summary
+ - Regenerates `skills/nemoclaw/` from `.agents/catalog-skills.yaml` and `.agents/skills/`.
+ - Keeps the NVIDIA Verified Skills catalog export deterministic and reviewable.
+
+ ## Validation
+ - `python3 scripts/export-catalog-skills.py --check`
+
+ After maintainer review, request signing by commenting `/nvskills-ci` on this PR if the workflow did not do so automatically.
+ labels: |
+ documentation
+ CI/CD
+ add-paths: |
+ .agents/catalog-skills.yaml
+ skills/nemoclaw
+
+ - name: Request NVSkills signing
+ if: ${{ steps.cpr.outputs.pull-request-number != '' && github.event_name == 'workflow_dispatch' && inputs.request_nvskills_ci && !inputs.dry_run }}
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ PR_NUMBER: ${{ steps.cpr.outputs.pull-request-number }}
+ run: |
+ gh pr comment "$PR_NUMBER" --body "/nvskills-ci"
diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml
index 5b8c8e91846..9f169813a38 100644
--- a/.github/workflows/pr.yaml
+++ b/.github/workflows/pr.yaml
@@ -51,6 +51,9 @@ jobs:
- name: Verify platform matrix is in sync
run: python3 scripts/generate-platform-docs.py --check
+ - name: Verify catalog skills export is in sync
+ run: python3 scripts/export-catalog-skills.py --check --allow-missing
+
test-e2e-ollama-proxy:
needs: [checks, changes]
if: needs.changes.outputs.code == 'true'
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index e87c97e09ad..6d312033442 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -65,7 +65,7 @@ repos:
name: SPDX license headers (insert if missing)
entry: bash scripts/check-spdx-headers.sh --fix
language: system
- files: ^(nemoclaw/src/.*\.ts|scripts/.*\.ts|nemoclaw-blueprint/.*\.py|.*\.sh)$
+ files: ^(nemoclaw/src/.*\.ts|scripts/export-catalog-skills\.py|scripts/.*\.ts|nemoclaw-blueprint/.*\.py|.*\.sh)$
exclude: ^nemoclaw-blueprint/.*__init__\.py$
pass_filenames: true
priority: 4
@@ -157,6 +157,14 @@ repos:
pass_filenames: false
priority: 10
+ - id: catalog-skills-export
+ name: Verify catalog skills export
+ entry: python3 scripts/export-catalog-skills.py --check --allow-missing
+ language: system
+ files: ^(\.agents/catalog-skills\.yaml|\.agents/skills/.*|skills/nemoclaw/.*|scripts/export-catalog-skills\.py)$
+ pass_filenames: false
+ priority: 10
+
- id: env-var-docs
name: NEMOCLAW_* env-var documentation gate
entry: npx tsx scripts/check-env-var-docs.ts
diff --git a/scripts/export-catalog-skills.py b/scripts/export-catalog-skills.py
new file mode 100755
index 00000000000..ef7f2fc8eb2
--- /dev/null
+++ b/scripts/export-catalog-skills.py
@@ -0,0 +1,407 @@
+#!/usr/bin/env python3
+# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+"""Export catalog-safe NemoClaw skills to the NVSkills watched directory.
+
+The repository source of truth remains `.agents/skills/`. This script copies the
+checked-in allowlist from `.agents/catalog-skills.yaml` into `skills/nemoclaw/`
+using deterministic ordering and metadata so CI can detect stale or hand-edited
+catalog exports.
+"""
+
+from __future__ import annotations
+
+import argparse
+import filecmp
+import fnmatch
+import hashlib
+import json
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+DEFAULT_ALLOWLIST = Path(".agents/catalog-skills.yaml")
+GENERATED_HEADER = """
+
+
+# Generated NemoClaw Catalog Skills
+
+This directory is generated from `.agents/catalog-skills.yaml` and `.agents/skills/`.
+Do not edit files here directly. The exporter preserves NVSkills signing artifacts (`skill.oms.sig` and `skill-card.md`) when regenerating an already-signed export.
+
+To update this export, edit the source skills or allowlist, then run:
+
+```bash
+python3 scripts/export-catalog-skills.py
+```
+
+CI verifies the directory with:
+
+```bash
+python3 scripts/export-catalog-skills.py --check
+```
+"""
+
+PRESERVED_SIGNING_FILES = {"skill.oms.sig", "skill-card.md"}
+
+
+@dataclass(frozen=True)
+class CatalogConfig:
+ source: Path
+ export: Path
+ skills: tuple[str, ...]
+ excluded_patterns: tuple[str, ...]
+ metadata: dict[str, str]
+
+
+def repo_path(path: Path) -> str:
+ return path.as_posix()
+
+
+def parse_scalar(value: str) -> str | int:
+ stripped = value.strip()
+ if stripped.startswith('"') and stripped.endswith('"'):
+ return stripped[1:-1]
+ if stripped.isdigit():
+ return int(stripped)
+ return stripped
+
+
+def load_allowlist_yaml(path: Path) -> dict[str, Any]:
+ """Parse the small checked-in allowlist schema without external YAML deps."""
+ raw: dict[str, Any] = {"include": [], "exclude": [], "metadata": {}}
+ section: str | None = None
+
+ lines = path.read_text(encoding="utf-8").splitlines()
+ for line_number, original in enumerate(lines, start=1):
+ line = original.split("#", 1)[0].rstrip()
+ if not line.strip():
+ continue
+
+ if not line.startswith(" "):
+ key, separator, value = line.partition(":")
+ if not separator:
+ raise ValueError(f"{repo_path(path)}:{line_number}: expected key: value")
+ key = key.strip()
+ if value.strip():
+ raw[key] = parse_scalar(value)
+ section = None
+ else:
+ section = key
+ raw.setdefault(key, [] if key in {"include", "exclude"} else {})
+ continue
+
+ if section in {"include", "exclude"}:
+ stripped = line.strip()
+ if stripped.startswith("- "):
+ key, separator, value = stripped[2:].partition(":")
+ if not separator:
+ raise ValueError(
+ f"{repo_path(path)}:{line_number}: expected list item mapping"
+ )
+ raw[section].append({key.strip(): parse_scalar(value)})
+ elif raw[section] and ":" in stripped:
+ key, _, value = stripped.partition(":")
+ raw[section][-1][key.strip()] = parse_scalar(value)
+ else:
+ raise ValueError(f"{repo_path(path)}:{line_number}: unsupported {section} entry")
+ continue
+
+ if section == "metadata":
+ key, separator, value = line.strip().partition(":")
+ if not separator:
+ raise ValueError(f"{repo_path(path)}:{line_number}: expected metadata key: value")
+ raw["metadata"][key.strip()] = parse_scalar(value)
+ continue
+
+ raise ValueError(f"{repo_path(path)}:{line_number}: unsupported nested content")
+
+ return raw
+
+
+def load_config(path: Path) -> CatalogConfig:
+ raw = load_allowlist_yaml(path)
+
+ version = raw.get("version")
+ if version != 1:
+ raise ValueError(f"{repo_path(path)} version must be 1")
+
+ source = Path(str(raw.get("source", ".agents/skills")))
+ export = Path(str(raw.get("export", "skills/nemoclaw")))
+ for label, candidate in (("source", source), ("export", export)):
+ if candidate.is_absolute() or ".." in candidate.parts:
+ raise ValueError(f"{repo_path(path)} {label} must be a safe relative path")
+
+ include = raw.get("include")
+ exclude = raw.get("exclude", [])
+ metadata = raw.get("metadata", {})
+
+ if not isinstance(include, list) or not include:
+ raise ValueError(f"{repo_path(path)} include must be a non-empty list")
+ if not isinstance(exclude, list):
+ raise ValueError(f"{repo_path(path)} exclude must be a list")
+ if not isinstance(metadata, dict):
+ raise ValueError(f"{repo_path(path)} metadata must be a mapping")
+
+ skills: list[str] = []
+ for idx, item in enumerate(include):
+ if not isinstance(item, dict) or not isinstance(item.get("skill"), str):
+ raise ValueError(f"{repo_path(path)} include[{idx}] must contain a string skill")
+ skill = item["skill"].strip()
+ if not skill:
+ raise ValueError(f"{repo_path(path)} include[{idx}].skill must not be empty")
+ skill_path = Path(skill)
+ if skill_path.is_absolute() or ".." in skill_path.parts or len(skill_path.parts) != 1:
+ raise ValueError(
+ f"{repo_path(path)} include[{idx}].skill must be a single directory name"
+ )
+ skills.append(skill)
+
+ if skills != sorted(skills):
+ raise ValueError(f"{repo_path(path)} include must be sorted by skill name")
+ if len(set(skills)) != len(skills):
+ raise ValueError(f"{repo_path(path)} include contains duplicate skills")
+
+ excluded_patterns: list[str] = []
+ for idx, item in enumerate(exclude):
+ if not isinstance(item, dict) or not isinstance(item.get("pattern"), str):
+ raise ValueError(f"{repo_path(path)} exclude[{idx}] must contain a string pattern")
+ excluded_patterns.append(item["pattern"].strip())
+
+ normalized_metadata = {str(key): str(value) for key, value in sorted(metadata.items())}
+ return CatalogConfig(
+ source=source,
+ export=export,
+ skills=tuple(skills),
+ excluded_patterns=tuple(excluded_patterns),
+ metadata=normalized_metadata,
+ )
+
+
+def git_commit() -> str:
+ try:
+ result = subprocess.run(
+ ["git", "rev-parse", "HEAD"],
+ cwd=REPO_ROOT,
+ check=True,
+ text=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.DEVNULL,
+ )
+ return result.stdout.strip()
+ except (OSError, subprocess.CalledProcessError):
+ return "unknown"
+
+
+def copy_skill(source_dir: Path, target_dir: Path) -> None:
+ target_dir.mkdir(parents=True, exist_ok=True)
+ for root, dirs, files in os.walk(source_dir):
+ dirs.sort()
+ files.sort()
+ rel_root = Path(root).relative_to(source_dir)
+ for directory in dirs:
+ (target_dir / rel_root / directory).mkdir(parents=True, exist_ok=True)
+ for filename in files:
+ src = Path(root) / filename
+ dst = target_dir / rel_root / filename
+ dst.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(src, dst)
+
+
+def hash_file(path: Path, base: Path, digest: hashlib._Hash) -> None:
+ rel = path.relative_to(base).as_posix()
+ digest.update(rel.encode("utf-8"))
+ digest.update(b"\0")
+ digest.update(path.read_bytes())
+ digest.update(b"\0")
+
+
+def hash_tree(paths: list[Path], base: Path) -> str:
+ digest = hashlib.sha256()
+ for path in sorted(paths, key=lambda item: item.relative_to(base).as_posix()):
+ hash_file(path, base, digest)
+ return digest.hexdigest()
+
+
+def list_files(root: Path) -> list[Path]:
+ if not root.exists():
+ return []
+ files = (path for path in root.rglob("*") if path.is_file())
+ return sorted(files, key=lambda item: item.as_posix())
+
+
+def write_manifest(target_root: Path, config: CatalogConfig, source_root: Path) -> None:
+ exported_files = [
+ path
+ for skill in config.skills
+ for path in list_files(target_root / skill)
+ if path.name not in PRESERVED_SIGNING_FILES
+ ]
+ source_files = [path for skill in config.skills for path in list_files(source_root / skill)]
+ manifest = {
+ "schemaVersion": 1,
+ "generatedBy": "scripts/export-catalog-skills.py",
+ "source": repo_path(config.source),
+ "sourceCommit": git_commit(),
+ "sourceContentSha256": hash_tree(source_files, source_root),
+ "exportContentSha256": hash_tree(exported_files, target_root),
+ "metadata": config.metadata,
+ "skills": list(config.skills),
+ }
+ (target_root / "catalog-metadata.json").write_text(
+ json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8"
+ )
+
+
+def preserve_signing_artifacts(
+ existing_root: Path, temp_root: Path, skills: tuple[str, ...]
+) -> None:
+ for skill in skills:
+ existing_skill = existing_root / skill
+ if not existing_skill.exists():
+ continue
+ for artifact_name in sorted(PRESERVED_SIGNING_FILES):
+ artifact = existing_skill / artifact_name
+ if artifact.is_file():
+ destination = temp_root / skill / artifact_name
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(artifact, destination)
+
+
+def validate_preserved_signing_artifacts(
+ existing_root: Path, temp_root: Path, skills: tuple[str, ...]
+) -> None:
+ missing: list[str] = []
+ for skill in skills:
+ existing_skill = existing_root / skill
+ if not existing_skill.exists():
+ continue
+ for artifact_name in sorted(PRESERVED_SIGNING_FILES):
+ if (existing_skill / artifact_name).is_file() and not (
+ temp_root / skill / artifact_name
+ ).is_file():
+ missing.append(f"{skill}/{artifact_name}")
+ if missing:
+ preview = ", ".join(missing[:10])
+ suffix = f" (+{len(missing) - 10} more)" if len(missing) > 10 else ""
+ raise FileNotFoundError(f"Missing preserved signing artifacts: {preview}{suffix}")
+
+
+def render_export(config: CatalogConfig, target_root: Path, preserve_from: Path | None = None) -> None:
+ source_root = REPO_ROOT / config.source
+ if not source_root.is_dir():
+ raise FileNotFoundError(f"Source skills directory not found: {repo_path(config.source)}")
+
+ target_root.mkdir(parents=True, exist_ok=True)
+ (target_root / "README.md").write_text(GENERATED_HEADER, encoding="utf-8")
+
+ for skill in config.skills:
+ source_skill = source_root / skill
+ if not source_skill.is_dir():
+ raise FileNotFoundError(f"Allowlisted skill not found: {repo_path(config.source / skill)}")
+ for pattern in config.excluded_patterns:
+ if fnmatch.fnmatch(skill, pattern):
+ raise ValueError(f"Allowlisted skill {skill!r} matches excluded pattern {pattern!r}")
+ copy_skill(source_skill, target_root / skill)
+
+ if preserve_from is not None:
+ preserve_signing_artifacts(preserve_from, target_root, config.skills)
+ validate_preserved_signing_artifacts(preserve_from, target_root, config.skills)
+
+ write_manifest(target_root, config, source_root)
+
+
+def dircmp_diff(left: Path, right: Path) -> list[str]:
+ messages: list[str] = []
+
+ def visit(cmp: filecmp.dircmp[str]) -> None:
+ for name in sorted(cmp.left_only):
+ messages.append(f"unexpected: {(Path(cmp.left) / name).relative_to(left).as_posix()}")
+ for name in sorted(cmp.right_only):
+ messages.append(f"missing: {(Path(cmp.right) / name).relative_to(right).as_posix()}")
+ for name in sorted(cmp.diff_files):
+ messages.append(f"stale: {(Path(cmp.left) / name).relative_to(left).as_posix()}")
+ for subdir in sorted(cmp.subdirs):
+ visit(cmp.subdirs[subdir])
+
+ visit(filecmp.dircmp(left, right))
+ return messages
+
+
+def replace_directory(source: Path, destination: Path) -> None:
+ if destination.exists():
+ shutil.rmtree(destination)
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ shutil.move(str(source), str(destination))
+
+
+def export_catalog(allowlist: Path, check: bool, allow_missing: bool) -> int:
+ config = load_config(allowlist)
+ export_root = REPO_ROOT / config.export
+
+ with tempfile.TemporaryDirectory(prefix="nemoclaw-catalog-skills-") as tmp:
+ expected = Path(tmp) / "expected"
+ render_export(config, expected, preserve_from=export_root if export_root.exists() else None)
+
+ if check:
+ if not export_root.exists():
+ if allow_missing:
+ print(
+ f"Catalog export is not present yet: {repo_path(config.export)} "
+ "(allowed by --allow-missing)",
+ )
+ return 0
+ print(f"Catalog export is missing: {repo_path(config.export)}", file=sys.stderr)
+ return 1
+ diffs = dircmp_diff(export_root, expected)
+ if diffs:
+ print("Catalog skills export is stale. Run:", file=sys.stderr)
+ print(" python3 scripts/export-catalog-skills.py", file=sys.stderr)
+ for diff in diffs[:50]:
+ print(f" - {diff}", file=sys.stderr)
+ if len(diffs) > 50:
+ print(f" ... {len(diffs) - 50} more difference(s)", file=sys.stderr)
+ return 1
+ print(f"Catalog skills export is current: {repo_path(config.export)}")
+ return 0
+
+ replace_directory(expected, export_root)
+ print(f"Exported {len(config.skills)} catalog skill(s) to {repo_path(config.export)}")
+ return 0
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--allowlist",
+ type=Path,
+ default=DEFAULT_ALLOWLIST,
+ help="Catalog skill allowlist YAML (default: .agents/catalog-skills.yaml)",
+ )
+ parser.add_argument(
+ "--check",
+ action="store_true",
+ help="Check whether the generated export is current without writing files",
+ )
+ parser.add_argument(
+ "--allow-missing",
+ action="store_true",
+ help="In --check mode, pass when the export directory has not been created yet",
+ )
+ return parser.parse_args()
+
+
+def main() -> int:
+ args = parse_args()
+ allowlist = args.allowlist if args.allowlist.is_absolute() else REPO_ROOT / args.allowlist
+ return export_catalog(allowlist, bool(args.check), bool(args.allow_missing))
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/test/catalog-skills-export.test.ts b/test/catalog-skills-export.test.ts
new file mode 100644
index 00000000000..d5e7af821a6
--- /dev/null
+++ b/test/catalog-skills-export.test.ts
@@ -0,0 +1,210 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { execFileSync } from "node:child_process";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const repoRoot = path.resolve(__dirname, "..");
+const exporter = path.join(repoRoot, "scripts", "export-catalog-skills.py");
+const sourceRoot = path.join(repoRoot, ".agents", "skills");
+
+function listSkillDirs(root: string): string[] {
+ return fs
+ .readdirSync(root, { withFileTypes: true })
+ .filter((entry) => entry.isDirectory())
+ .map((entry) => entry.name)
+ .sort();
+}
+
+describe("catalog skills export", () => {
+ it("allows the export to be absent before the first refresh PR", () => {
+ const tempDir = fs.mkdtempSync(
+ path.join(os.tmpdir(), "nemoclaw-catalog-missing-"),
+ );
+ const cleanup = () => fs.rmSync(tempDir, { recursive: true, force: true });
+
+ try {
+ const tempAgents = path.join(tempDir, ".agents");
+ const tempScripts = path.join(tempDir, "scripts");
+ fs.mkdirSync(tempAgents, { recursive: true });
+ fs.mkdirSync(tempScripts, { recursive: true });
+ fs.cpSync(sourceRoot, path.join(tempAgents, "skills"), {
+ recursive: true,
+ });
+ fs.copyFileSync(
+ path.join(repoRoot, ".agents", "catalog-skills.yaml"),
+ path.join(tempAgents, "catalog-skills.yaml"),
+ );
+ fs.copyFileSync(
+ exporter,
+ path.join(tempScripts, "export-catalog-skills.py"),
+ );
+
+ const output = execFileSync(
+ "python3",
+ [
+ path.join(tempScripts, "export-catalog-skills.py"),
+ "--check",
+ "--allow-missing",
+ ],
+ {
+ cwd: tempDir,
+ encoding: "utf8",
+ },
+ );
+
+ expect(output).toContain("Catalog export is not present yet");
+ } finally {
+ cleanup();
+ }
+ });
+
+ it("preserves existing signing artifacts when regenerating", () => {
+ const tempDir = fs.mkdtempSync(
+ path.join(os.tmpdir(), "nemoclaw-catalog-export-"),
+ );
+ const cleanup = () => fs.rmSync(tempDir, { recursive: true, force: true });
+
+ try {
+ const tempAgents = path.join(tempDir, ".agents");
+ const tempScripts = path.join(tempDir, "scripts");
+ const tempSkills = path.join(tempDir, "skills", "nemoclaw");
+ fs.mkdirSync(tempAgents, { recursive: true });
+ fs.mkdirSync(tempScripts, { recursive: true });
+ fs.cpSync(sourceRoot, path.join(tempAgents, "skills"), {
+ recursive: true,
+ });
+ fs.copyFileSync(
+ path.join(repoRoot, ".agents", "catalog-skills.yaml"),
+ path.join(tempAgents, "catalog-skills.yaml"),
+ );
+ fs.copyFileSync(
+ exporter,
+ path.join(tempScripts, "export-catalog-skills.py"),
+ );
+
+ const signedSkill = path.join(tempSkills, "nemoclaw-user-get-started");
+ fs.mkdirSync(signedSkill, { recursive: true });
+ fs.writeFileSync(path.join(signedSkill, "skill.oms.sig"), "signature\n");
+ fs.writeFileSync(
+ path.join(signedSkill, "skill-card.md"),
+ "# Signed card\n",
+ );
+
+ execFileSync(
+ "python3",
+ [path.join(tempScripts, "export-catalog-skills.py")],
+ {
+ cwd: tempDir,
+ encoding: "utf8",
+ },
+ );
+
+ expect(
+ fs.readFileSync(path.join(signedSkill, "skill.oms.sig"), "utf8"),
+ ).toBe("signature\n");
+ expect(
+ fs.readFileSync(path.join(signedSkill, "skill-card.md"), "utf8"),
+ ).toBe("# Signed card\n");
+ expect(listSkillDirs(tempSkills)).toEqual([
+ "nemoclaw-skills-guide",
+ "nemoclaw-user-agent-skills",
+ "nemoclaw-user-configure-inference",
+ "nemoclaw-user-configure-security",
+ "nemoclaw-user-deploy-remote",
+ "nemoclaw-user-get-started",
+ "nemoclaw-user-manage-policy",
+ "nemoclaw-user-manage-sandboxes",
+ "nemoclaw-user-monitor-sandbox",
+ "nemoclaw-user-overview",
+ "nemoclaw-user-reference",
+ ]);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it("rejects unsafe allowlist path fragments", () => {
+ const tempDir = fs.mkdtempSync(
+ path.join(os.tmpdir(), "nemoclaw-catalog-config-"),
+ );
+ const cleanup = () => fs.rmSync(tempDir, { recursive: true, force: true });
+
+ try {
+ const config = path.join(tempDir, "catalog-skills.yaml");
+ fs.writeFileSync(
+ config,
+ [
+ "version: 1",
+ "source: ../outside",
+ "export: skills/nemoclaw",
+ "include:",
+ " - skill: ../escape",
+ "",
+ ].join("\n"),
+ );
+
+ expect(() =>
+ execFileSync("python3", [exporter, "--allowlist", config, "--check"], {
+ cwd: repoRoot,
+ encoding: "utf8",
+ stdio: "pipe",
+ }),
+ ).toThrow(/source must be a safe relative path/);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it("fails when preserved signing artifacts are not copied into the final export", () => {
+ const tempDir = fs.mkdtempSync(
+ path.join(os.tmpdir(), "nemoclaw-catalog-export-"),
+ );
+ const cleanup = () => fs.rmSync(tempDir, { recursive: true, force: true });
+
+ try {
+ const tempAgents = path.join(tempDir, ".agents");
+ const tempScripts = path.join(tempDir, "scripts");
+ const tempSkills = path.join(tempDir, "skills", "nemoclaw");
+ fs.mkdirSync(tempAgents, { recursive: true });
+ fs.mkdirSync(tempScripts, { recursive: true });
+ fs.cpSync(sourceRoot, path.join(tempAgents, "skills"), {
+ recursive: true,
+ });
+ fs.copyFileSync(
+ path.join(repoRoot, ".agents", "catalog-skills.yaml"),
+ path.join(tempAgents, "catalog-skills.yaml"),
+ );
+ const tempExporter = path.join(tempScripts, "export-catalog-skills.py");
+ fs.copyFileSync(exporter, tempExporter);
+ let exporterSource = fs.readFileSync(tempExporter, "utf8");
+ exporterSource = exporterSource.replace(
+ "def preserve_signing_artifacts(\n existing_root: Path, temp_root: Path, skills: tuple[str, ...]\n) -> None:",
+ "def preserve_signing_artifacts(\n existing_root: Path, temp_root: Path, skills: tuple[str, ...]\n) -> None:\n return",
+ );
+ fs.writeFileSync(tempExporter, exporterSource);
+
+ const signedSkill = path.join(tempSkills, "nemoclaw-user-get-started");
+ fs.mkdirSync(signedSkill, { recursive: true });
+ fs.writeFileSync(path.join(signedSkill, "skill.oms.sig"), "signature\n");
+
+ expect(() =>
+ execFileSync("python3", [tempExporter], {
+ cwd: tempDir,
+ encoding: "utf8",
+ stdio: "pipe",
+ }),
+ ).toThrow(
+ /Missing preserved signing artifacts: nemoclaw-user-get-started\/skill\.oms\.sig/,
+ );
+ } finally {
+ cleanup();
+ }
+ });
+});