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(); + } + }); +});