@openhands/extensions 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/custom-codereview-guide.md +25 -0
- package/.github/pull_request_template.md +38 -0
- package/.github/release.yml +14 -0
- package/.github/workflows/check-extensions.yml +72 -0
- package/.github/workflows/npm-publish.yml +89 -0
- package/.github/workflows/pr.yml +30 -0
- package/.github/workflows/release.yml +24 -0
- package/.github/workflows/tests.yml +25 -0
- package/.github/workflows/vulnerability-scan.yml +87 -0
- package/.release-please-manifest.json +3 -0
- package/AGENTS.md +132 -0
- package/README.md +10 -0
- package/analysis_results.md +162 -0
- package/index.d.ts +2 -0
- package/index.js +1 -0
- package/marketplaces/large-codebase.json +66 -0
- package/marketplaces/openhands-extensions.json +682 -0
- package/package.json +12 -10
- package/plugins/README.md +30 -0
- package/plugins/city-weather/.plugin/plugin.json +13 -0
- package/plugins/city-weather/README.md +145 -0
- package/plugins/city-weather/commands/now.md +56 -0
- package/plugins/cobol-modernization/.plugin/plugin.json +19 -0
- package/plugins/cobol-modernization/README.md +201 -0
- package/plugins/cobol-modernization/references/troubleshooting.md +18 -0
- package/plugins/cobol-modernization/skills/build-setup/SKILL.md +78 -0
- package/plugins/cobol-modernization/skills/build-setup/scripts/install-gnucobol.sh +32 -0
- package/plugins/cobol-modernization/skills/cobol-modernization-overview/SKILL.md +113 -0
- package/plugins/cobol-modernization/skills/mainfraime-removal/SKILL.md +62 -0
- package/plugins/cobol-modernization/skills/mainfraime-removal/references/cics-transformation-examples.md +45 -0
- package/plugins/cobol-modernization/skills/mainframe-planning/SKILL.md +78 -0
- package/plugins/cobol-modernization/skills/to-java-migration/SKILL.md +59 -0
- package/plugins/cobol-modernization/skills/to-java-migration/references/cobol-to-java-example.md +58 -0
- package/plugins/cobol-modernization/skills/to-java-migration/references/datatype-mappings.md +19 -0
- package/plugins/issue-duplicate-checker/.plugin/plugin.json +13 -0
- package/plugins/issue-duplicate-checker/README.md +51 -0
- package/plugins/issue-duplicate-checker/action.yml +349 -0
- package/plugins/issue-duplicate-checker/scripts/auto_close_duplicate_issues.py +569 -0
- package/plugins/issue-duplicate-checker/scripts/issue_duplicate_check_openhands.py +681 -0
- package/plugins/issue-duplicate-checker/scripts/post_duplicate_notice.js +220 -0
- package/plugins/issue-duplicate-checker/scripts/remove_duplicate_candidate_label.js +27 -0
- package/plugins/magic-test/.plugin/plugin.json +13 -0
- package/plugins/magic-test/skills/magic-word/SKILL.md +33 -0
- package/plugins/migration-scoring/.plugin/plugin.json +19 -0
- package/plugins/migration-scoring/README.md +244 -0
- package/plugins/migration-scoring/skills/migration-mapping/SKILL.md +72 -0
- package/plugins/migration-scoring/skills/migration-report/SKILL.md +118 -0
- package/plugins/migration-scoring/skills/migration-scoring-overview/SKILL.md +126 -0
- package/plugins/migration-scoring/skills/score-quality/SKILL.md +54 -0
- package/plugins/migration-scoring/skills/score-quality/references/scoring-criteria.md +30 -0
- package/plugins/migration-scoring/skills/score-style/SKILL.md +106 -0
- package/plugins/onboarding/.plugin/plugin.json +20 -0
- package/plugins/onboarding/README.md +30 -0
- package/plugins/onboarding/references/criteria.md +144 -0
- package/plugins/onboarding/skills/agent-readiness-report/README.md +23 -0
- package/plugins/onboarding/skills/agent-readiness-report/SKILL.md +122 -0
- package/plugins/onboarding/skills/agent-readiness-report/scripts/scan_agent_instructions.sh +88 -0
- package/plugins/onboarding/skills/agent-readiness-report/scripts/scan_build_env.sh +114 -0
- package/plugins/onboarding/skills/agent-readiness-report/scripts/scan_feedback_loops.sh +133 -0
- package/plugins/onboarding/skills/agent-readiness-report/scripts/scan_policy.sh +113 -0
- package/plugins/onboarding/skills/agent-readiness-report/scripts/scan_workflows.sh +127 -0
- package/plugins/onboarding/skills/improve-agent-readiness/README.md +19 -0
- package/plugins/onboarding/skills/improve-agent-readiness/SKILL.md +167 -0
- package/plugins/onboarding/skills/setup-agents-md/README.md +15 -0
- package/plugins/onboarding/skills/setup-agents-md/SKILL.md +150 -0
- package/plugins/onboarding/skills/setup-openhands/README.md +20 -0
- package/plugins/onboarding/skills/setup-openhands/SKILL.md +56 -0
- package/plugins/onboarding/skills/setup-pr-review/README.md +23 -0
- package/plugins/onboarding/skills/setup-pr-review/SKILL.md +72 -0
- package/plugins/openhands/.plugin/plugin.json +13 -0
- package/plugins/openhands/README.md +52 -0
- package/plugins/openhands/SKILL.md +61 -0
- package/plugins/openhands/commands/create.md +55 -0
- package/plugins/openhands/commands/openhands-cloud.md +8 -0
- package/plugins/openhands/scripts/run.sh +69 -0
- package/plugins/pr-review/.plugin/plugin.json +13 -0
- package/plugins/pr-review/README.md +393 -0
- package/plugins/pr-review/action.yml +298 -0
- package/plugins/pr-review/scripts/agent_script.py +1282 -0
- package/plugins/pr-review/scripts/evaluate_review.py +655 -0
- package/plugins/pr-review/scripts/prompt.py +260 -0
- package/plugins/pr-review/workflows/pr-review-by-openhands.yml +51 -0
- package/plugins/pr-review/workflows/pr-review-evaluation.yml +85 -0
- package/plugins/qa-changes/.plugin/plugin.json +11 -0
- package/plugins/qa-changes/README.md +185 -0
- package/plugins/qa-changes/action.yml +181 -0
- package/plugins/qa-changes/scripts/agent_script.py +406 -0
- package/plugins/qa-changes/scripts/evaluate_qa_changes.py +385 -0
- package/plugins/qa-changes/scripts/prompt.py +174 -0
- package/plugins/qa-changes/workflows/qa-changes-by-openhands.yml +50 -0
- package/plugins/qa-changes/workflows/qa-changes-evaluation.yml +85 -0
- package/plugins/release-notes/.plugin/plugin.json +19 -0
- package/plugins/release-notes/README.md +283 -0
- package/plugins/release-notes/SKILL.md +83 -0
- package/plugins/release-notes/action.yml +117 -0
- package/plugins/release-notes/commands/release-notes.md +8 -0
- package/plugins/release-notes/scripts/agent_script.py +292 -0
- package/plugins/release-notes/scripts/generate_release_notes.py +733 -0
- package/plugins/release-notes/scripts/prompt.py +90 -0
- package/plugins/release-notes/scripts/validate_release_notes.py +328 -0
- package/plugins/release-notes/workflows/release-notes.yml +76 -0
- package/plugins/vulnerability-remediation/.plugin/plugin.json +19 -0
- package/plugins/vulnerability-remediation/README.md +217 -0
- package/plugins/vulnerability-remediation/action.yml +187 -0
- package/plugins/vulnerability-remediation/scripts/scan_and_remediate.py +561 -0
- package/plugins/vulnerability-remediation/workflows/vulnerability-scan.yml +87 -0
- package/pyproject.toml +12 -0
- package/release-please-config.json +16 -0
- package/scripts/build-skills-catalog.mjs +100 -0
- package/scripts/sync_extensions.py +494 -0
- package/scripts/sync_openhands_sdk_skill.py +264 -0
- package/skills/README.md +159 -0
- package/skills/add-javadoc/.plugin/plugin.json +18 -0
- package/skills/add-javadoc/README.md +40 -0
- package/skills/add-javadoc/SKILL.md +35 -0
- package/skills/add-javadoc/references/example.md +32 -0
- package/skills/add-skill/.plugin/plugin.json +18 -0
- package/skills/add-skill/README.md +67 -0
- package/skills/add-skill/SKILL.md +47 -0
- package/skills/add-skill/scripts/fetch_skill.py +259 -0
- package/skills/agent-creator/.plugin/plugin.json +20 -0
- package/skills/agent-creator/README.md +104 -0
- package/skills/agent-creator/SKILL.md +190 -0
- package/skills/agent-creator/commands/agent-creator.md +8 -0
- package/skills/agent-creator/references/fallback.md +117 -0
- package/skills/agent-memory/.plugin/plugin.json +18 -0
- package/skills/agent-memory/README.md +35 -0
- package/skills/agent-memory/SKILL.md +30 -0
- package/skills/agent-memory/commands/remember.md +8 -0
- package/skills/agent-sdk-builder/.plugin/plugin.json +18 -0
- package/skills/agent-sdk-builder/README.md +40 -0
- package/skills/agent-sdk-builder/SKILL.md +37 -0
- package/skills/agent-sdk-builder/commands/agent-builder.md +8 -0
- package/skills/azure-devops/.plugin/plugin.json +18 -0
- package/skills/azure-devops/README.md +55 -0
- package/skills/azure-devops/SKILL.md +50 -0
- package/skills/bitbucket/.plugin/plugin.json +17 -0
- package/skills/bitbucket/README.md +50 -0
- package/skills/bitbucket/SKILL.md +45 -0
- package/skills/code-review/.plugin/plugin.json +19 -0
- package/skills/code-review/README.md +18 -0
- package/skills/code-review/SKILL.md +208 -0
- package/skills/code-review/commands/codereview-roasted.md +8 -0
- package/skills/code-review/commands/codereview.md +8 -0
- package/skills/code-review/references/risk-evaluation.md +41 -0
- package/skills/code-review/references/supply-chain-security.md +31 -0
- package/skills/code-simplifier/.plugin/plugin.json +21 -0
- package/skills/code-simplifier/README.md +30 -0
- package/skills/code-simplifier/SKILL.md +91 -0
- package/skills/code-simplifier/commands/simplify.md +8 -0
- package/skills/code-simplifier/references/code-quality-review.md +86 -0
- package/skills/code-simplifier/references/code-reuse-review.md +63 -0
- package/skills/code-simplifier/references/efficiency-review.md +81 -0
- package/skills/datadog/.plugin/plugin.json +19 -0
- package/skills/datadog/README.md +100 -0
- package/skills/datadog/SKILL.md +95 -0
- package/skills/deno/.plugin/plugin.json +18 -0
- package/skills/deno/README.md +5 -0
- package/skills/deno/SKILL.md +99 -0
- package/skills/deno/references/README.md +6 -0
- package/skills/discord/.plugin/plugin.json +18 -0
- package/skills/discord/README.md +31 -0
- package/skills/discord/SKILL.md +109 -0
- package/skills/discord/__init__.py +0 -0
- package/skills/discord/references/REFERENCE.md +78 -0
- package/skills/discord/scripts/__init__.py +0 -0
- package/skills/discord/scripts/_http.py +127 -0
- package/skills/discord/scripts/post_webhook.py +106 -0
- package/skills/discord/scripts/send_message.py +102 -0
- package/skills/docker/.plugin/plugin.json +17 -0
- package/skills/docker/README.md +34 -0
- package/skills/docker/SKILL.md +29 -0
- package/skills/evidence-based-citations/.plugin/plugin.json +20 -0
- package/skills/evidence-based-citations/README.md +31 -0
- package/skills/evidence-based-citations/SKILL.md +59 -0
- package/skills/flarglebargle/.plugin/plugin.json +16 -0
- package/skills/flarglebargle/README.md +14 -0
- package/skills/flarglebargle/SKILL.md +9 -0
- package/skills/frontend-design/.plugin/plugin.json +21 -0
- package/skills/frontend-design/LICENSE.txt +177 -0
- package/skills/frontend-design/README.md +42 -0
- package/skills/frontend-design/SKILL.md +42 -0
- package/skills/github/.plugin/plugin.json +19 -0
- package/skills/github/README.md +42 -0
- package/skills/github/SKILL.md +106 -0
- package/skills/github-pr-review/.plugin/plugin.json +18 -0
- package/skills/github-pr-review/README.md +145 -0
- package/skills/github-pr-review/SKILL.md +186 -0
- package/skills/github-pr-review/commands/github-pr-review.md +8 -0
- package/skills/github-pr-reviewer/.plugin/plugin.json +20 -0
- package/skills/github-pr-reviewer/README.md +34 -0
- package/skills/github-pr-reviewer/SKILL.md +89 -0
- package/skills/github-pr-reviewer/commands/pr-reviewer:setup.md +8 -0
- package/skills/github-repo-monitor/.plugin/plugin.json +22 -0
- package/skills/github-repo-monitor/README.md +70 -0
- package/skills/github-repo-monitor/SKILL.md +316 -0
- package/skills/github-repo-monitor/commands/github-monitor:poll.md +8 -0
- package/skills/github-repo-monitor/references/github-api.md +241 -0
- package/skills/github-repo-monitor/references/state-schema.md +160 -0
- package/skills/github-repo-monitor/scripts/main.py +915 -0
- package/skills/github-repo-monitor/tests/test_main.py +400 -0
- package/skills/gitlab/.plugin/plugin.json +17 -0
- package/skills/gitlab/README.md +37 -0
- package/skills/gitlab/SKILL.md +32 -0
- package/skills/incident-retrospective/.plugin/plugin.json +21 -0
- package/skills/incident-retrospective/README.md +34 -0
- package/skills/incident-retrospective/SKILL.md +98 -0
- package/skills/incident-retrospective/commands/incident-retro:setup.md +8 -0
- package/skills/index.d.ts +11 -0
- package/skills/index.js +465 -0
- package/skills/iterate/.plugin/plugin.json +13 -0
- package/skills/iterate/README.md +25 -0
- package/skills/iterate/SKILL.md +399 -0
- package/skills/iterate/commands/babysit.md +8 -0
- package/skills/iterate/commands/iterate.md +8 -0
- package/skills/iterate/commands/verify.md +8 -0
- package/skills/iterate/references/heuristics.md +58 -0
- package/skills/iterate/references/verification.md +96 -0
- package/skills/jupyter/.plugin/plugin.json +18 -0
- package/skills/jupyter/README.md +55 -0
- package/skills/jupyter/SKILL.md +50 -0
- package/skills/kubernetes/.plugin/plugin.json +18 -0
- package/skills/kubernetes/README.md +53 -0
- package/skills/kubernetes/SKILL.md +48 -0
- package/skills/learn-from-code-review/.plugin/plugin.json +19 -0
- package/skills/learn-from-code-review/README.md +64 -0
- package/skills/learn-from-code-review/SKILL.md +186 -0
- package/skills/learn-from-code-review/commands/learn-from-reviews.md +8 -0
- package/skills/linear/.plugin/plugin.json +19 -0
- package/skills/linear/README.md +58 -0
- package/skills/linear/SKILL.md +213 -0
- package/skills/linear-triage/.plugin/plugin.json +21 -0
- package/skills/linear-triage/README.md +34 -0
- package/skills/linear-triage/SKILL.md +91 -0
- package/skills/linear-triage/commands/linear-triage:setup.md +8 -0
- package/skills/notion/.plugin/plugin.json +17 -0
- package/skills/notion/README.md +114 -0
- package/skills/notion/SKILL.md +109 -0
- package/skills/npm/.plugin/plugin.json +17 -0
- package/skills/npm/README.md +14 -0
- package/skills/npm/SKILL.md +9 -0
- package/skills/openhands-api/.plugin/plugin.json +22 -0
- package/skills/openhands-api/README.md +48 -0
- package/skills/openhands-api/SKILL.md +399 -0
- package/skills/openhands-api/references/README.md +33 -0
- package/skills/openhands-api/references/TROUBLESHOOTING.md +81 -0
- package/skills/openhands-api/references/example_prompt.md +12 -0
- package/skills/openhands-api/scripts/openhands_api.py +606 -0
- package/skills/openhands-api/scripts/openhands_api.ts +252 -0
- package/skills/openhands-automation/.plugin/plugin.json +19 -0
- package/skills/openhands-automation/README.md +89 -0
- package/skills/openhands-automation/SKILL.md +875 -0
- package/skills/openhands-automation/commands/automation:create.md +8 -0
- package/skills/openhands-automation/references/ab-testing.md +185 -0
- package/skills/openhands-automation/references/custom-automation.md +644 -0
- package/skills/openhands-sdk/.plugin/plugin.json +20 -0
- package/skills/openhands-sdk/README.md +22 -0
- package/skills/openhands-sdk/SKILL.md +230 -0
- package/skills/openhands-sdk/commands/sdk.md +8 -0
- package/skills/pdflatex/.plugin/plugin.json +18 -0
- package/skills/pdflatex/README.md +39 -0
- package/skills/pdflatex/SKILL.md +34 -0
- package/skills/prd/.plugin/plugin.json +19 -0
- package/skills/prd/README.md +28 -0
- package/skills/prd/SKILL.md +237 -0
- package/skills/prd/commands/prd.md +8 -0
- package/skills/qa-changes/README.md +18 -0
- package/skills/qa-changes/SKILL.md +229 -0
- package/skills/qa-changes/commands/qa-changes.md +8 -0
- package/skills/release-notes/README.md +24 -0
- package/skills/release-notes/SKILL.md +19 -0
- package/skills/release-notes/commands/release-notes.md +8 -0
- package/skills/research-brief/.plugin/plugin.json +20 -0
- package/skills/research-brief/README.md +34 -0
- package/skills/research-brief/SKILL.md +99 -0
- package/skills/research-brief/commands/research-brief:setup.md +8 -0
- package/skills/security/.plugin/plugin.json +18 -0
- package/skills/security/README.md +38 -0
- package/skills/security/SKILL.md +33 -0
- package/skills/skill-creator/.plugin/plugin.json +17 -0
- package/skills/skill-creator/LICENSE.txt +202 -0
- package/skills/skill-creator/README.md +182 -0
- package/skills/skill-creator/SKILL.md +545 -0
- package/skills/skill-creator/references/output-patterns.md +82 -0
- package/skills/skill-creator/references/workflows.md +28 -0
- package/skills/skill-creator/scripts/init_skill.py +303 -0
- package/skills/skill-creator/scripts/quick_validate.py +95 -0
- package/skills/slack-channel-monitor/.plugin/plugin.json +21 -0
- package/skills/slack-channel-monitor/README.md +91 -0
- package/skills/slack-channel-monitor/SKILL.md +276 -0
- package/skills/slack-channel-monitor/commands/slack-monitor:poll.md +8 -0
- package/skills/slack-channel-monitor/references/slack-api.md +207 -0
- package/skills/slack-channel-monitor/references/state-schema.md +180 -0
- package/skills/slack-channel-monitor/scripts/main.py +962 -0
- package/skills/slack-standup-digest/.plugin/plugin.json +21 -0
- package/skills/slack-standup-digest/README.md +34 -0
- package/skills/slack-standup-digest/SKILL.md +92 -0
- package/skills/slack-standup-digest/commands/standup-digest:setup.md +8 -0
- package/skills/spark-version-upgrade/.plugin/plugin.json +20 -0
- package/skills/spark-version-upgrade/README.md +54 -0
- package/skills/spark-version-upgrade/SKILL.md +233 -0
- package/skills/ssh/.plugin/plugin.json +18 -0
- package/skills/ssh/README.md +140 -0
- package/skills/ssh/SKILL.md +135 -0
- package/skills/swift-linux/.plugin/plugin.json +17 -0
- package/skills/swift-linux/README.md +86 -0
- package/skills/swift-linux/SKILL.md +81 -0
- package/skills/theme-factory/.plugin/plugin.json +19 -0
- package/skills/theme-factory/LICENSE.txt +202 -0
- package/skills/theme-factory/README.md +58 -0
- package/skills/theme-factory/SKILL.md +59 -0
- package/skills/theme-factory/theme-showcase.pdf +0 -0
- package/skills/theme-factory/themes/arctic-frost.md +19 -0
- package/skills/theme-factory/themes/botanical-garden.md +19 -0
- package/skills/theme-factory/themes/desert-rose.md +19 -0
- package/skills/theme-factory/themes/forest-canopy.md +19 -0
- package/skills/theme-factory/themes/golden-hour.md +19 -0
- package/skills/theme-factory/themes/midnight-galaxy.md +19 -0
- package/skills/theme-factory/themes/modern-minimalist.md +19 -0
- package/skills/theme-factory/themes/ocean-depths.md +19 -0
- package/skills/theme-factory/themes/sunset-boulevard.md +19 -0
- package/skills/theme-factory/themes/tech-innovation.md +19 -0
- package/skills/uv/.plugin/plugin.json +18 -0
- package/skills/uv/README.md +5 -0
- package/skills/uv/SKILL.md +95 -0
- package/skills/uv/references/README.md +5 -0
- package/skills/vercel/.plugin/plugin.json +18 -0
- package/skills/vercel/README.md +108 -0
- package/skills/vercel/SKILL.md +103 -0
- package/tests/test_add_skill_installs_to_agents_dir.py +42 -0
- package/tests/test_catalogs.py +109 -0
- package/tests/test_code_review_risk_evaluation.py +94 -0
- package/tests/test_github_pr_review_skill.py +114 -0
- package/tests/test_issue_duplicate_checker.py +240 -0
- package/tests/test_openhands_api_python.py +152 -0
- package/tests/test_plugin_manifest.py +83 -0
- package/tests/test_pr_review_diff_payload.py +202 -0
- package/tests/test_pr_review_feedback.py +263 -0
- package/tests/test_pr_review_prompt.py +152 -0
- package/tests/test_pr_review_review_context.py +253 -0
- package/tests/test_qa_changes.py +232 -0
- package/tests/test_qa_changes_evaluation.py +259 -0
- package/tests/test_release_notes_generator.py +990 -0
- package/tests/test_sdk_loading.py +150 -0
- package/tests/test_skill_plugin_loading.py +149 -0
- package/tests/test_skills_catalog.py +336 -0
- package/tests/test_skills_have_readme.py +66 -0
- package/tests/test_sync_extensions.py +292 -0
- package/tests/test_workflow_sync.py +46 -0
- package/utils/analysis/README.md +7 -0
- package/utils/analysis/laminar_signals/README.md +211 -0
- package/utils/analysis/laminar_signals/analyze.py +780 -0
- package/utils/analysis/laminar_signals/templates/default.j2 +49 -0
- package/utils/analysis/laminar_signals/templates/pr_review.j2 +61 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Test that marketplaces, skills, and plugins can be loaded by the SDK."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from openhands.sdk.skills import Skill
|
|
7
|
+
from openhands.sdk.marketplace import Marketplace, MarketplacePluginEntry
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_repo_root():
|
|
11
|
+
"""Get the path to the repository root."""
|
|
12
|
+
return Path(__file__).parent.parent
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_all_skill_directories():
|
|
16
|
+
"""Get all skill directory names that have a SKILL.md file."""
|
|
17
|
+
skills_dir = get_repo_root() / "skills"
|
|
18
|
+
return [
|
|
19
|
+
d for d in skills_dir.iterdir()
|
|
20
|
+
if d.is_dir() and not d.name.startswith('.') and (d / "SKILL.md").exists()
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TestDefaultMarketplace:
|
|
25
|
+
"""Test that the default marketplace can be loaded by the SDK."""
|
|
26
|
+
|
|
27
|
+
def test_marketplace_loads_with_sdk(self):
|
|
28
|
+
"""Verify the default marketplace can be loaded using SDK's Marketplace model."""
|
|
29
|
+
marketplace_path = get_repo_root() / "marketplaces" / "openhands-extensions.json"
|
|
30
|
+
|
|
31
|
+
# Load using SDK's pydantic model
|
|
32
|
+
import json
|
|
33
|
+
with open(marketplace_path) as f:
|
|
34
|
+
data = json.load(f)
|
|
35
|
+
|
|
36
|
+
marketplace = Marketplace.model_validate({**data, "path": str(get_repo_root())})
|
|
37
|
+
|
|
38
|
+
assert marketplace.name == "openhands-extensions"
|
|
39
|
+
assert marketplace.owner is not None
|
|
40
|
+
assert marketplace.owner.name == "OpenHands"
|
|
41
|
+
assert len(marketplace.plugins) > 0
|
|
42
|
+
|
|
43
|
+
def test_all_plugin_entries_valid(self):
|
|
44
|
+
"""Verify all plugin entries can be validated as MarketplacePluginEntry."""
|
|
45
|
+
marketplace_path = get_repo_root() / "marketplaces" / "openhands-extensions.json"
|
|
46
|
+
|
|
47
|
+
import json
|
|
48
|
+
with open(marketplace_path) as f:
|
|
49
|
+
data = json.load(f)
|
|
50
|
+
|
|
51
|
+
errors = []
|
|
52
|
+
for plugin_data in data.get("plugins", []):
|
|
53
|
+
try:
|
|
54
|
+
entry = MarketplacePluginEntry.model_validate(plugin_data)
|
|
55
|
+
assert entry.name, f"Plugin missing name"
|
|
56
|
+
assert entry.source, f"Plugin {entry.name} missing source"
|
|
57
|
+
assert entry.description, f"Plugin {entry.name} missing description"
|
|
58
|
+
except Exception as e:
|
|
59
|
+
errors.append(f"{plugin_data.get('name', 'unknown')}: {e}")
|
|
60
|
+
|
|
61
|
+
assert len(errors) == 0, f"Plugin validation errors:\n" + "\n".join(errors)
|
|
62
|
+
|
|
63
|
+
def test_marketplace_source_paths_exist(self):
|
|
64
|
+
"""Verify all source paths in the marketplace resolve to real directories."""
|
|
65
|
+
import json
|
|
66
|
+
|
|
67
|
+
marketplace_path = get_repo_root() / "marketplaces" / "openhands-extensions.json"
|
|
68
|
+
with open(marketplace_path) as f:
|
|
69
|
+
data = json.load(f)
|
|
70
|
+
|
|
71
|
+
root = get_repo_root()
|
|
72
|
+
missing = []
|
|
73
|
+
for entry in data["plugins"]:
|
|
74
|
+
src = entry["source"]
|
|
75
|
+
resolved = root / src
|
|
76
|
+
if not resolved.exists():
|
|
77
|
+
missing.append(f"{entry['name']}: {src} -> {resolved}")
|
|
78
|
+
assert len(missing) == 0, (
|
|
79
|
+
f"Source paths that don't exist on disk:\n" + "\n".join(missing)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def test_marketplace_includes_all_skills(self):
|
|
83
|
+
"""Verify every skill directory is referenced in at least one marketplace."""
|
|
84
|
+
import json
|
|
85
|
+
|
|
86
|
+
marketplaces_dir = get_repo_root() / "marketplaces"
|
|
87
|
+
marketplace_files = sorted(marketplaces_dir.glob("*.json"))
|
|
88
|
+
assert len(marketplace_files) > 0, "No marketplace JSON files found"
|
|
89
|
+
|
|
90
|
+
# Collect sources across all marketplaces
|
|
91
|
+
all_sources = set()
|
|
92
|
+
for mp_path in marketplace_files:
|
|
93
|
+
with open(mp_path) as f:
|
|
94
|
+
data = json.load(f)
|
|
95
|
+
marketplace = Marketplace.model_validate({**data, "path": str(get_repo_root())})
|
|
96
|
+
for plugin in marketplace.plugins:
|
|
97
|
+
source = plugin.source if isinstance(plugin.source, str) else ""
|
|
98
|
+
# Normalize: strip leading "./" and resolve parent refs to get the leaf name
|
|
99
|
+
name = Path(source).name
|
|
100
|
+
all_sources.add(name)
|
|
101
|
+
|
|
102
|
+
# Get all skill directories
|
|
103
|
+
skill_dirs = {d.name for d in get_all_skill_directories()}
|
|
104
|
+
|
|
105
|
+
missing = skill_dirs - all_sources
|
|
106
|
+
assert len(missing) == 0, f"Skills not in any marketplace: {sorted(missing)}"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class TestSkillsLoadWithSDK:
|
|
110
|
+
"""Test that all skills can be loaded by the SDK."""
|
|
111
|
+
|
|
112
|
+
def test_all_skills_load_with_sdk(self):
|
|
113
|
+
"""Verify all SKILL.md files can be loaded using SDK's Skill.load()."""
|
|
114
|
+
skill_dirs = get_all_skill_directories()
|
|
115
|
+
|
|
116
|
+
errors = []
|
|
117
|
+
for skill_dir in skill_dirs:
|
|
118
|
+
skill_path = skill_dir / "SKILL.md"
|
|
119
|
+
try:
|
|
120
|
+
skill = Skill.load(skill_path, skill_base_dir=skill_dir)
|
|
121
|
+
assert skill.name, f"Skill {skill_dir.name} has no name"
|
|
122
|
+
assert skill.content, f"Skill {skill_dir.name} has no content"
|
|
123
|
+
except Exception as e:
|
|
124
|
+
errors.append(f"{skill_dir.name}: {e}")
|
|
125
|
+
|
|
126
|
+
assert len(errors) == 0, (
|
|
127
|
+
f"Failed to load {len(errors)} skills:\n" + "\n".join(errors)
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def test_skills_have_valid_metadata(self):
|
|
131
|
+
"""Verify all skills have valid metadata (name, description)."""
|
|
132
|
+
skill_dirs = get_all_skill_directories()
|
|
133
|
+
|
|
134
|
+
missing_metadata = []
|
|
135
|
+
for skill_dir in skill_dirs:
|
|
136
|
+
skill_path = skill_dir / "SKILL.md"
|
|
137
|
+
try:
|
|
138
|
+
skill = Skill.load(skill_path, skill_base_dir=skill_dir)
|
|
139
|
+
if not skill.description:
|
|
140
|
+
missing_metadata.append(f"{skill_dir.name}: missing description")
|
|
141
|
+
except Exception:
|
|
142
|
+
pass # Already caught in test_all_skills_load_with_sdk
|
|
143
|
+
|
|
144
|
+
assert len(missing_metadata) == 0, (
|
|
145
|
+
f"Skills with missing metadata:\n" + "\n".join(missing_metadata)
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
if __name__ == "__main__":
|
|
150
|
+
pytest.main([__file__, "-v"])
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Test that skills listed in marketplaces can be loaded as Codex/Claude plugins.
|
|
2
|
+
|
|
3
|
+
Every marketplace entry that references a ``skills/`` directory needs a
|
|
4
|
+
``.plugin/plugin.json`` manifest and vendor symlinks (``.codex-plugin``,
|
|
5
|
+
``.claude-plugin``) so that Codex and Claude Code can discover and load them.
|
|
6
|
+
|
|
7
|
+
Regression test for: https://github.com/OpenHands/extensions/issues/201
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
REPO_ROOT = Path(__file__).parent.parent
|
|
17
|
+
SKILLS_DIR = REPO_ROOT / "skills"
|
|
18
|
+
PLUGINS_DIR = REPO_ROOT / "plugins"
|
|
19
|
+
MARKETPLACES_DIR = REPO_ROOT / "marketplaces"
|
|
20
|
+
|
|
21
|
+
REQUIRED_MANIFEST_FIELDS = ["name", "description", "author", "version"]
|
|
22
|
+
VENDOR_SYMLINKS = [".claude-plugin", ".codex-plugin"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _all_dirs_with_plugin_manifest():
|
|
26
|
+
"""Yield directories (plugins/ and skills/) that have .plugin/plugin.json."""
|
|
27
|
+
for base in [PLUGINS_DIR, SKILLS_DIR]:
|
|
28
|
+
if not base.is_dir():
|
|
29
|
+
continue
|
|
30
|
+
for entry_dir in sorted(base.iterdir()):
|
|
31
|
+
if not entry_dir.is_dir() or entry_dir.name.startswith("."):
|
|
32
|
+
continue
|
|
33
|
+
manifest = entry_dir / ".plugin" / "plugin.json"
|
|
34
|
+
if manifest.exists():
|
|
35
|
+
yield entry_dir
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _marketplace_skill_entries():
|
|
39
|
+
"""Yield (skill_name, source_path) for every marketplace entry under skills/."""
|
|
40
|
+
for mp_file in sorted(MARKETPLACES_DIR.glob("*.json")):
|
|
41
|
+
with open(mp_file) as f:
|
|
42
|
+
data = json.load(f)
|
|
43
|
+
for p in data.get("plugins", []):
|
|
44
|
+
src = p["source"]
|
|
45
|
+
if src.startswith("./skills/"):
|
|
46
|
+
yield p["name"], REPO_ROOT / src.lstrip("./")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TestAllMarketplaceSkillsHaveManifests:
|
|
50
|
+
"""Every skill listed in a marketplace must have a plugin manifest for Codex."""
|
|
51
|
+
|
|
52
|
+
def test_all_marketplace_skills_have_plugin_json(self):
|
|
53
|
+
"""Every marketplace skill entry must have .plugin/plugin.json."""
|
|
54
|
+
missing = []
|
|
55
|
+
for name, path in _marketplace_skill_entries():
|
|
56
|
+
manifest = path / ".plugin" / "plugin.json"
|
|
57
|
+
if not manifest.exists():
|
|
58
|
+
missing.append(name)
|
|
59
|
+
assert not missing, (
|
|
60
|
+
f"Skills missing .plugin/plugin.json (Codex can't load them): "
|
|
61
|
+
f"{', '.join(missing)}"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def test_all_marketplace_skills_have_vendor_symlinks(self):
|
|
65
|
+
"""Every marketplace skill with a manifest must have vendor symlinks."""
|
|
66
|
+
problems = []
|
|
67
|
+
for name, path in _marketplace_skill_entries():
|
|
68
|
+
if not (path / ".plugin" / "plugin.json").exists():
|
|
69
|
+
continue
|
|
70
|
+
for vendor in VENDOR_SYMLINKS:
|
|
71
|
+
link = path / vendor
|
|
72
|
+
if not link.is_symlink():
|
|
73
|
+
problems.append(f"{name}/{vendor}")
|
|
74
|
+
assert not problems, (
|
|
75
|
+
f"Missing vendor symlinks: {', '.join(problems)}"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def test_all_manifests_have_required_fields(self):
|
|
79
|
+
"""Every skill manifest must have name, description, author, version."""
|
|
80
|
+
problems = []
|
|
81
|
+
for name, path in _marketplace_skill_entries():
|
|
82
|
+
manifest = path / ".plugin" / "plugin.json"
|
|
83
|
+
if not manifest.exists():
|
|
84
|
+
continue
|
|
85
|
+
data = json.loads(manifest.read_text())
|
|
86
|
+
for field in REQUIRED_MANIFEST_FIELDS:
|
|
87
|
+
if field not in data:
|
|
88
|
+
problems.append(f"{name}: missing '{field}'")
|
|
89
|
+
author = data.get("author")
|
|
90
|
+
if not isinstance(author, dict):
|
|
91
|
+
problems.append(f"{name}: 'author' must be an object")
|
|
92
|
+
elif "name" not in author:
|
|
93
|
+
problems.append(f"{name}: author missing 'name'")
|
|
94
|
+
assert not problems, (
|
|
95
|
+
f"Manifest validation errors:\n" + "\n".join(problems)
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def test_manifest_name_matches_directory(self):
|
|
99
|
+
"""The 'name' in plugin.json must match the directory name."""
|
|
100
|
+
problems = []
|
|
101
|
+
for name, path in _marketplace_skill_entries():
|
|
102
|
+
manifest = path / ".plugin" / "plugin.json"
|
|
103
|
+
if not manifest.exists():
|
|
104
|
+
continue
|
|
105
|
+
data = json.loads(manifest.read_text())
|
|
106
|
+
if data.get("name") != path.name:
|
|
107
|
+
problems.append(f"{name}: manifest name '{data.get('name')}' != dir '{path.name}'")
|
|
108
|
+
assert not problems, (
|
|
109
|
+
f"Name mismatches:\n" + "\n".join(problems)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class TestIteratePluginLoading:
|
|
114
|
+
"""Verify the iterate skill can be loaded as a plugin (issue #201)."""
|
|
115
|
+
|
|
116
|
+
def test_iterate_loads_as_sdk_plugin(self):
|
|
117
|
+
"""The iterate skill must load via Plugin.load() without error."""
|
|
118
|
+
from openhands.sdk.plugin.plugin import Plugin
|
|
119
|
+
|
|
120
|
+
plugin = Plugin.load(SKILLS_DIR / "iterate")
|
|
121
|
+
assert plugin.name == "iterate"
|
|
122
|
+
assert plugin.version == "1.0.0"
|
|
123
|
+
assert len(plugin.commands) > 0
|
|
124
|
+
command_names = {c.name for c in plugin.commands}
|
|
125
|
+
assert "iterate" in command_names
|
|
126
|
+
assert "babysit" in command_names
|
|
127
|
+
assert "verify" in command_names
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class TestVendorSymlinksForManifests:
|
|
131
|
+
"""Every directory with .plugin/ must have vendor symlinks."""
|
|
132
|
+
|
|
133
|
+
@pytest.fixture(
|
|
134
|
+
params=list(_all_dirs_with_plugin_manifest()),
|
|
135
|
+
ids=lambda d: f"{d.parent.name}/{d.name}",
|
|
136
|
+
)
|
|
137
|
+
def dir_with_manifest(self, request):
|
|
138
|
+
return request.param
|
|
139
|
+
|
|
140
|
+
def test_has_vendor_symlinks(self, dir_with_manifest):
|
|
141
|
+
"""Directories with .plugin/ must have .claude-plugin and .codex-plugin symlinks."""
|
|
142
|
+
for vendor in VENDOR_SYMLINKS:
|
|
143
|
+
link = dir_with_manifest / vendor
|
|
144
|
+
assert link.is_symlink(), (
|
|
145
|
+
f"{link.relative_to(REPO_ROOT)} must be a symlink to .plugin"
|
|
146
|
+
)
|
|
147
|
+
assert link.resolve() == (dir_with_manifest / ".plugin").resolve(), (
|
|
148
|
+
f"{link.relative_to(REPO_ROOT)} must point to .plugin"
|
|
149
|
+
)
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""Tests for the skills catalog codegen (scripts/build-skills-catalog.mjs).
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
- parseFrontmatter edge cases (hyphenated keys, multiline, list items, etc.)
|
|
5
|
+
- End-to-end buildCatalog against temp fixtures
|
|
6
|
+
- Generated skills/index.js validity and structure
|
|
7
|
+
- Determinism: re-running the script produces identical output
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import subprocess
|
|
12
|
+
import textwrap
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
16
|
+
SCRIPT = ROOT / "scripts" / "build-skills-catalog.mjs"
|
|
17
|
+
SKILLS_INDEX = ROOT / "skills" / "index.js"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run_node(script: str, *, cwd: str | Path = ROOT, check: bool = True) -> subprocess.CompletedProcess:
|
|
21
|
+
return subprocess.run(
|
|
22
|
+
["node", "--input-type=module", "-e", script],
|
|
23
|
+
cwd=str(cwd),
|
|
24
|
+
capture_output=True,
|
|
25
|
+
text=True,
|
|
26
|
+
check=check,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# parseFrontmatter unit tests (via Node subprocess)
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
class TestParseFrontmatter:
|
|
35
|
+
"""Unit tests for parseFrontmatter, exercised via Node import."""
|
|
36
|
+
|
|
37
|
+
def _parse(self, frontmatter_body: str) -> dict:
|
|
38
|
+
"""Run parseFrontmatter on *frontmatter_body* and return the result."""
|
|
39
|
+
escaped = json.dumps(frontmatter_body)
|
|
40
|
+
script = textwrap.dedent(f"""\
|
|
41
|
+
import {{ parseFrontmatter }} from './scripts/build-skills-catalog.mjs';
|
|
42
|
+
const result = parseFrontmatter({escaped});
|
|
43
|
+
process.stdout.write(JSON.stringify(result));
|
|
44
|
+
""")
|
|
45
|
+
result = run_node(script)
|
|
46
|
+
return json.loads(result.stdout)
|
|
47
|
+
|
|
48
|
+
def test_simple_key_value(self):
|
|
49
|
+
fm = self._parse("name: my-skill\ndescription: A test skill")
|
|
50
|
+
assert fm["name"] == "my-skill"
|
|
51
|
+
assert fm["description"] == "A test skill"
|
|
52
|
+
|
|
53
|
+
def test_hyphenated_keys(self):
|
|
54
|
+
fm = self._parse("name: my-skill\ncompatibility: works-everywhere")
|
|
55
|
+
assert fm["compatibility"] == "works-everywhere"
|
|
56
|
+
|
|
57
|
+
def test_hyphenated_key_not_silently_dropped(self):
|
|
58
|
+
"""Keys like event-key: should be parsed, not ignored."""
|
|
59
|
+
# We verify indirectly: if a hyphenated key precedes a list,
|
|
60
|
+
# the list items must be collected under that key.
|
|
61
|
+
script = textwrap.dedent("""\
|
|
62
|
+
import { parseFrontmatter } from './scripts/build-skills-catalog.mjs';
|
|
63
|
+
// parseFrontmatter returns a fixed shape, so test the internal
|
|
64
|
+
// parsing by checking a known hyphenated output key.
|
|
65
|
+
const fm = parseFrontmatter("license: MIT");
|
|
66
|
+
// Also verify a raw parse captures hyphenated keys internally
|
|
67
|
+
// by re-implementing a quick check:
|
|
68
|
+
const raw = "event-key: hello";
|
|
69
|
+
const match = raw.match(/^[\\w-]+:\\s*(.*)/);
|
|
70
|
+
if (!match) { console.error("Regex failed for hyphenated key"); process.exit(1); }
|
|
71
|
+
process.stdout.write("ok");
|
|
72
|
+
""")
|
|
73
|
+
result = run_node(script)
|
|
74
|
+
assert result.stdout == "ok"
|
|
75
|
+
|
|
76
|
+
def test_list_items(self):
|
|
77
|
+
fm = self._parse("name: test\ntriggers:\n- github\n- git\n- pull request")
|
|
78
|
+
assert fm["triggers"] == ["github", "git", "pull request"]
|
|
79
|
+
|
|
80
|
+
def test_empty_triggers_when_scalar(self):
|
|
81
|
+
fm = self._parse("name: test\ntriggers: not-a-list")
|
|
82
|
+
assert fm["triggers"] == []
|
|
83
|
+
|
|
84
|
+
def test_multiline_description_folded(self):
|
|
85
|
+
fm = self._parse("name: test\ndescription: >\n This is a long\n description text")
|
|
86
|
+
assert "This is a long" in fm["description"]
|
|
87
|
+
assert "description text" in fm["description"]
|
|
88
|
+
|
|
89
|
+
def test_multiline_description_literal(self):
|
|
90
|
+
fm = self._parse("name: test\ndescription: |\n Line one\n Line two")
|
|
91
|
+
assert "Line one" in fm["description"]
|
|
92
|
+
assert "Line two" in fm["description"]
|
|
93
|
+
|
|
94
|
+
def test_missing_name_returns_empty(self):
|
|
95
|
+
fm = self._parse("description: no name here")
|
|
96
|
+
assert fm["name"] == ""
|
|
97
|
+
|
|
98
|
+
def test_missing_description_returns_empty(self):
|
|
99
|
+
fm = self._parse("name: test")
|
|
100
|
+
assert fm["description"] == ""
|
|
101
|
+
assert fm["triggers"] == []
|
|
102
|
+
|
|
103
|
+
def test_optional_fields_omitted_when_absent(self):
|
|
104
|
+
fm = self._parse("name: test\ndescription: desc")
|
|
105
|
+
assert "license" not in fm
|
|
106
|
+
assert "compatibility" not in fm
|
|
107
|
+
|
|
108
|
+
def test_optional_fields_present_when_set(self):
|
|
109
|
+
fm = self._parse("name: test\nlicense: MIT\ncompatibility: all platforms")
|
|
110
|
+
assert fm["license"] == "MIT"
|
|
111
|
+
assert fm["compatibility"] == "all platforms"
|
|
112
|
+
|
|
113
|
+
def test_empty_input(self):
|
|
114
|
+
fm = self._parse("")
|
|
115
|
+
assert fm["name"] == ""
|
|
116
|
+
assert fm["description"] == ""
|
|
117
|
+
assert fm["triggers"] == []
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# End-to-end codegen tests with temp fixtures (using buildCatalog)
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
class TestCodegenEndToEnd:
|
|
125
|
+
"""Run buildCatalog against temp SKILL.md fixtures."""
|
|
126
|
+
|
|
127
|
+
def _run_codegen(self, tmp_path: Path, skills: dict[str, str]) -> list[dict]:
|
|
128
|
+
"""Create temp skill dirs, run buildCatalog, return parsed catalog."""
|
|
129
|
+
skills_dir = tmp_path / "skills"
|
|
130
|
+
skills_dir.mkdir()
|
|
131
|
+
|
|
132
|
+
for name, content in skills.items():
|
|
133
|
+
skill_dir = skills_dir / name
|
|
134
|
+
skill_dir.mkdir()
|
|
135
|
+
(skill_dir / "SKILL.md").write_text(content)
|
|
136
|
+
|
|
137
|
+
script = textwrap.dedent(f"""\
|
|
138
|
+
import {{ buildCatalog }} from './scripts/build-skills-catalog.mjs';
|
|
139
|
+
const entries = buildCatalog({json.dumps(str(skills_dir))});
|
|
140
|
+
process.stdout.write(JSON.stringify(entries));
|
|
141
|
+
""")
|
|
142
|
+
result = run_node(script)
|
|
143
|
+
return json.loads(result.stdout)
|
|
144
|
+
|
|
145
|
+
def test_basic_skill(self, tmp_path):
|
|
146
|
+
entries = self._run_codegen(tmp_path, {
|
|
147
|
+
"my-skill": "---\nname: my-skill\ndescription: A skill\ntriggers:\n- test\n---\nBody content here"
|
|
148
|
+
})
|
|
149
|
+
assert len(entries) == 1
|
|
150
|
+
assert entries[0]["name"] == "my-skill"
|
|
151
|
+
assert entries[0]["description"] == "A skill"
|
|
152
|
+
assert entries[0]["triggers"] == ["test"]
|
|
153
|
+
assert entries[0]["content"] == "Body content here"
|
|
154
|
+
|
|
155
|
+
def test_name_falls_back_to_directory(self, tmp_path):
|
|
156
|
+
entries = self._run_codegen(tmp_path, {
|
|
157
|
+
"fallback-dir": "---\ndescription: no name field\n---\nBody"
|
|
158
|
+
})
|
|
159
|
+
assert entries[0]["name"] == "fallback-dir"
|
|
160
|
+
|
|
161
|
+
def test_missing_frontmatter_skipped(self, tmp_path):
|
|
162
|
+
entries = self._run_codegen(tmp_path, {
|
|
163
|
+
"good-skill": "---\nname: good\ndescription: yes\n---\nBody",
|
|
164
|
+
"bad-skill": "No frontmatter delimiters at all",
|
|
165
|
+
})
|
|
166
|
+
assert len(entries) == 1
|
|
167
|
+
assert entries[0]["name"] == "good"
|
|
168
|
+
|
|
169
|
+
def test_missing_frontmatter_warns(self, tmp_path):
|
|
170
|
+
"""SKILL.md without frontmatter should produce a stderr warning."""
|
|
171
|
+
skills_dir = tmp_path / "skills"
|
|
172
|
+
skills_dir.mkdir()
|
|
173
|
+
(skills_dir / "bad").mkdir()
|
|
174
|
+
(skills_dir / "bad" / "SKILL.md").write_text("No frontmatter here")
|
|
175
|
+
|
|
176
|
+
script = textwrap.dedent(f"""\
|
|
177
|
+
import {{ buildCatalog }} from './scripts/build-skills-catalog.mjs';
|
|
178
|
+
buildCatalog({json.dumps(str(skills_dir))});
|
|
179
|
+
""")
|
|
180
|
+
result = run_node(script)
|
|
181
|
+
assert "Warning" in result.stderr
|
|
182
|
+
assert "missing frontmatter" in result.stderr
|
|
183
|
+
|
|
184
|
+
def test_body_with_triple_dashes_preserved(self, tmp_path):
|
|
185
|
+
content = "---\nname: test\ndescription: d\n---\nBefore\n---\nAfter"
|
|
186
|
+
entries = self._run_codegen(tmp_path, {"test-skill": content})
|
|
187
|
+
assert "---" in entries[0]["content"]
|
|
188
|
+
assert "Before" in entries[0]["content"]
|
|
189
|
+
assert "After" in entries[0]["content"]
|
|
190
|
+
|
|
191
|
+
def test_generated_file_is_valid_js(self, tmp_path):
|
|
192
|
+
"""Write a catalog to a file and verify it's importable as JS."""
|
|
193
|
+
skills_dir = tmp_path / "skills"
|
|
194
|
+
skills_dir.mkdir()
|
|
195
|
+
for name in ("alpha", "beta"):
|
|
196
|
+
(skills_dir / name).mkdir()
|
|
197
|
+
(skills_dir / name / "SKILL.md").write_text(
|
|
198
|
+
f"---\nname: {name}\ndescription: {name} desc\n---\nBody {name}"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
output = tmp_path / "output.js"
|
|
202
|
+
script = textwrap.dedent(f"""\
|
|
203
|
+
import {{ writeFileSync }} from "node:fs";
|
|
204
|
+
import {{ buildCatalog }} from './scripts/build-skills-catalog.mjs';
|
|
205
|
+
const entries = buildCatalog({json.dumps(str(skills_dir))});
|
|
206
|
+
const src = "export const SKILLS_CATALOG = " + JSON.stringify(entries) + ";\\nexport default SKILLS_CATALOG;\\n";
|
|
207
|
+
writeFileSync({json.dumps(str(output))}, src);
|
|
208
|
+
""")
|
|
209
|
+
run_node(script)
|
|
210
|
+
|
|
211
|
+
verify = textwrap.dedent(f"""\
|
|
212
|
+
const mod = await import({json.dumps(str(output))});
|
|
213
|
+
if (!Array.isArray(mod.SKILLS_CATALOG)) process.exit(1);
|
|
214
|
+
if (mod.SKILLS_CATALOG.length !== 2) process.exit(1);
|
|
215
|
+
if (mod.default !== mod.SKILLS_CATALOG) process.exit(1);
|
|
216
|
+
""")
|
|
217
|
+
run_node(verify)
|
|
218
|
+
|
|
219
|
+
def test_entries_sorted_by_directory_name(self, tmp_path):
|
|
220
|
+
entries = self._run_codegen(tmp_path, {
|
|
221
|
+
"zebra": "---\nname: zebra\ndescription: z\n---\nZ",
|
|
222
|
+
"alpha": "---\nname: alpha\ndescription: a\n---\nA",
|
|
223
|
+
"middle": "---\nname: middle\ndescription: m\n---\nM",
|
|
224
|
+
})
|
|
225
|
+
names = [e["name"] for e in entries]
|
|
226
|
+
assert names == ["alpha", "middle", "zebra"]
|
|
227
|
+
|
|
228
|
+
def test_optional_fields_included_when_present(self, tmp_path):
|
|
229
|
+
entries = self._run_codegen(tmp_path, {
|
|
230
|
+
"lic": "---\nname: lic\ndescription: d\nlicense: MIT\ncompatibility: all\n---\nBody"
|
|
231
|
+
})
|
|
232
|
+
assert entries[0]["license"] == "MIT"
|
|
233
|
+
assert entries[0]["compatibility"] == "all"
|
|
234
|
+
|
|
235
|
+
def test_optional_fields_omitted_when_absent(self, tmp_path):
|
|
236
|
+
entries = self._run_codegen(tmp_path, {
|
|
237
|
+
"bare": "---\nname: bare\ndescription: d\n---\nBody"
|
|
238
|
+
})
|
|
239
|
+
assert "license" not in entries[0]
|
|
240
|
+
assert "compatibility" not in entries[0]
|
|
241
|
+
|
|
242
|
+
def test_directories_without_skill_md_ignored(self, tmp_path):
|
|
243
|
+
skills_dir = tmp_path / "skills"
|
|
244
|
+
skills_dir.mkdir()
|
|
245
|
+
(skills_dir / "has-skill").mkdir()
|
|
246
|
+
(skills_dir / "has-skill" / "SKILL.md").write_text("---\nname: ok\ndescription: d\n---\nBody")
|
|
247
|
+
(skills_dir / "no-skill").mkdir() # no SKILL.md
|
|
248
|
+
|
|
249
|
+
script = textwrap.dedent(f"""\
|
|
250
|
+
import {{ buildCatalog }} from './scripts/build-skills-catalog.mjs';
|
|
251
|
+
const entries = buildCatalog({json.dumps(str(skills_dir))});
|
|
252
|
+
process.stdout.write(JSON.stringify(entries));
|
|
253
|
+
""")
|
|
254
|
+
result = run_node(script)
|
|
255
|
+
entries = json.loads(result.stdout)
|
|
256
|
+
assert len(entries) == 1
|
|
257
|
+
assert entries[0]["name"] == "ok"
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ---------------------------------------------------------------------------
|
|
261
|
+
# Generated skills/index.js validation (real repo data)
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
class TestGeneratedSkillsIndex:
|
|
265
|
+
"""Validate the checked-in skills/index.js against the live SKILL.md files."""
|
|
266
|
+
|
|
267
|
+
def test_index_js_is_valid_and_exports_array(self):
|
|
268
|
+
"""The generated skills/index.js is valid JS with a non-empty array export."""
|
|
269
|
+
script = textwrap.dedent("""\
|
|
270
|
+
import { SKILLS_CATALOG } from './skills/index.js';
|
|
271
|
+
if (!Array.isArray(SKILLS_CATALOG)) process.exit(1);
|
|
272
|
+
if (SKILLS_CATALOG.length === 0) process.exit(1);
|
|
273
|
+
import SKILLS from './skills/index.js';
|
|
274
|
+
if (SKILLS !== SKILLS_CATALOG) process.exit(1);
|
|
275
|
+
""")
|
|
276
|
+
run_node(script)
|
|
277
|
+
|
|
278
|
+
def test_every_entry_has_required_fields(self):
|
|
279
|
+
"""Each catalog entry must have name, description, triggers, and content."""
|
|
280
|
+
script = textwrap.dedent("""\
|
|
281
|
+
import { SKILLS_CATALOG } from './skills/index.js';
|
|
282
|
+
for (const entry of SKILLS_CATALOG) {
|
|
283
|
+
if (typeof entry.name !== 'string' || !entry.name) {
|
|
284
|
+
console.error('Missing name:', JSON.stringify(entry).slice(0, 100));
|
|
285
|
+
process.exit(1);
|
|
286
|
+
}
|
|
287
|
+
if (typeof entry.description !== 'string') {
|
|
288
|
+
console.error('Missing description for:', entry.name);
|
|
289
|
+
process.exit(1);
|
|
290
|
+
}
|
|
291
|
+
if (!Array.isArray(entry.triggers)) {
|
|
292
|
+
console.error('Missing triggers for:', entry.name);
|
|
293
|
+
process.exit(1);
|
|
294
|
+
}
|
|
295
|
+
if (typeof entry.content !== 'string') {
|
|
296
|
+
console.error('Missing content for:', entry.name);
|
|
297
|
+
process.exit(1);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
""")
|
|
301
|
+
run_node(script)
|
|
302
|
+
|
|
303
|
+
def test_no_duplicate_names(self):
|
|
304
|
+
"""Skill names should be unique in the catalog."""
|
|
305
|
+
script = textwrap.dedent("""\
|
|
306
|
+
import { SKILLS_CATALOG } from './skills/index.js';
|
|
307
|
+
const names = SKILLS_CATALOG.map(e => e.name);
|
|
308
|
+
const dupes = names.filter((n, i) => names.indexOf(n) !== i);
|
|
309
|
+
if (dupes.length > 0) {
|
|
310
|
+
console.error('Duplicate names:', dupes);
|
|
311
|
+
process.exit(1);
|
|
312
|
+
}
|
|
313
|
+
""")
|
|
314
|
+
run_node(script)
|
|
315
|
+
|
|
316
|
+
def test_catalog_is_sorted(self):
|
|
317
|
+
"""Entries should be sorted alphabetically by name."""
|
|
318
|
+
script = textwrap.dedent("""\
|
|
319
|
+
import { SKILLS_CATALOG } from './skills/index.js';
|
|
320
|
+
const names = SKILLS_CATALOG.map(e => e.name);
|
|
321
|
+
const sorted = [...names].sort();
|
|
322
|
+
for (let i = 0; i < names.length; i++) {
|
|
323
|
+
if (names[i] !== sorted[i]) {
|
|
324
|
+
console.error('Not sorted: ' + names[i] + ' should be ' + sorted[i]);
|
|
325
|
+
process.exit(1);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
""")
|
|
329
|
+
run_node(script)
|
|
330
|
+
|
|
331
|
+
def test_index_is_up_to_date(self):
|
|
332
|
+
"""Re-running the codegen script should produce identical output."""
|
|
333
|
+
before = SKILLS_INDEX.read_text()
|
|
334
|
+
subprocess.run(["node", str(SCRIPT)], cwd=str(ROOT), check=True, capture_output=True)
|
|
335
|
+
after = SKILLS_INDEX.read_text()
|
|
336
|
+
assert before == after, "skills/index.js is out of date — run: node scripts/build-skills-catalog.mjs"
|