@openhands/extensions 0.1.0 → 0.2.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.
Files changed (347) hide show
  1. package/.agents/skills/custom-codereview-guide.md +25 -0
  2. package/.github/pull_request_template.md +38 -0
  3. package/.github/release.yml +14 -0
  4. package/.github/workflows/check-extensions.yml +72 -0
  5. package/.github/workflows/npm-publish.yml +89 -0
  6. package/.github/workflows/pr.yml +30 -0
  7. package/.github/workflows/release.yml +24 -0
  8. package/.github/workflows/tests.yml +25 -0
  9. package/.github/workflows/vulnerability-scan.yml +87 -0
  10. package/.release-please-manifest.json +3 -0
  11. package/AGENTS.md +132 -0
  12. package/README.md +10 -0
  13. package/analysis_results.md +162 -0
  14. package/marketplaces/large-codebase.json +66 -0
  15. package/marketplaces/openhands-extensions.json +682 -0
  16. package/package.json +4 -10
  17. package/plugins/README.md +30 -0
  18. package/plugins/city-weather/.plugin/plugin.json +13 -0
  19. package/plugins/city-weather/README.md +145 -0
  20. package/plugins/city-weather/commands/now.md +56 -0
  21. package/plugins/cobol-modernization/.plugin/plugin.json +19 -0
  22. package/plugins/cobol-modernization/README.md +201 -0
  23. package/plugins/cobol-modernization/references/troubleshooting.md +18 -0
  24. package/plugins/cobol-modernization/skills/build-setup/SKILL.md +78 -0
  25. package/plugins/cobol-modernization/skills/build-setup/scripts/install-gnucobol.sh +32 -0
  26. package/plugins/cobol-modernization/skills/cobol-modernization-overview/SKILL.md +113 -0
  27. package/plugins/cobol-modernization/skills/mainfraime-removal/SKILL.md +62 -0
  28. package/plugins/cobol-modernization/skills/mainfraime-removal/references/cics-transformation-examples.md +45 -0
  29. package/plugins/cobol-modernization/skills/mainframe-planning/SKILL.md +78 -0
  30. package/plugins/cobol-modernization/skills/to-java-migration/SKILL.md +59 -0
  31. package/plugins/cobol-modernization/skills/to-java-migration/references/cobol-to-java-example.md +58 -0
  32. package/plugins/cobol-modernization/skills/to-java-migration/references/datatype-mappings.md +19 -0
  33. package/plugins/issue-duplicate-checker/.plugin/plugin.json +13 -0
  34. package/plugins/issue-duplicate-checker/README.md +51 -0
  35. package/plugins/issue-duplicate-checker/action.yml +349 -0
  36. package/plugins/issue-duplicate-checker/scripts/auto_close_duplicate_issues.py +569 -0
  37. package/plugins/issue-duplicate-checker/scripts/issue_duplicate_check_openhands.py +681 -0
  38. package/plugins/issue-duplicate-checker/scripts/post_duplicate_notice.js +220 -0
  39. package/plugins/issue-duplicate-checker/scripts/remove_duplicate_candidate_label.js +27 -0
  40. package/plugins/magic-test/.plugin/plugin.json +13 -0
  41. package/plugins/magic-test/skills/magic-word/SKILL.md +33 -0
  42. package/plugins/migration-scoring/.plugin/plugin.json +19 -0
  43. package/plugins/migration-scoring/README.md +244 -0
  44. package/plugins/migration-scoring/skills/migration-mapping/SKILL.md +72 -0
  45. package/plugins/migration-scoring/skills/migration-report/SKILL.md +118 -0
  46. package/plugins/migration-scoring/skills/migration-scoring-overview/SKILL.md +126 -0
  47. package/plugins/migration-scoring/skills/score-quality/SKILL.md +54 -0
  48. package/plugins/migration-scoring/skills/score-quality/references/scoring-criteria.md +30 -0
  49. package/plugins/migration-scoring/skills/score-style/SKILL.md +106 -0
  50. package/plugins/onboarding/.plugin/plugin.json +20 -0
  51. package/plugins/onboarding/README.md +30 -0
  52. package/plugins/onboarding/references/criteria.md +144 -0
  53. package/plugins/onboarding/skills/agent-readiness-report/README.md +23 -0
  54. package/plugins/onboarding/skills/agent-readiness-report/SKILL.md +122 -0
  55. package/plugins/onboarding/skills/agent-readiness-report/scripts/scan_agent_instructions.sh +88 -0
  56. package/plugins/onboarding/skills/agent-readiness-report/scripts/scan_build_env.sh +114 -0
  57. package/plugins/onboarding/skills/agent-readiness-report/scripts/scan_feedback_loops.sh +133 -0
  58. package/plugins/onboarding/skills/agent-readiness-report/scripts/scan_policy.sh +113 -0
  59. package/plugins/onboarding/skills/agent-readiness-report/scripts/scan_workflows.sh +127 -0
  60. package/plugins/onboarding/skills/improve-agent-readiness/README.md +19 -0
  61. package/plugins/onboarding/skills/improve-agent-readiness/SKILL.md +167 -0
  62. package/plugins/onboarding/skills/setup-agents-md/README.md +15 -0
  63. package/plugins/onboarding/skills/setup-agents-md/SKILL.md +150 -0
  64. package/plugins/onboarding/skills/setup-openhands/README.md +20 -0
  65. package/plugins/onboarding/skills/setup-openhands/SKILL.md +56 -0
  66. package/plugins/onboarding/skills/setup-pr-review/README.md +23 -0
  67. package/plugins/onboarding/skills/setup-pr-review/SKILL.md +72 -0
  68. package/plugins/openhands/.plugin/plugin.json +13 -0
  69. package/plugins/openhands/README.md +52 -0
  70. package/plugins/openhands/SKILL.md +61 -0
  71. package/plugins/openhands/commands/create.md +55 -0
  72. package/plugins/openhands/commands/openhands-cloud.md +8 -0
  73. package/plugins/openhands/scripts/run.sh +69 -0
  74. package/plugins/pr-review/.plugin/plugin.json +13 -0
  75. package/plugins/pr-review/README.md +393 -0
  76. package/plugins/pr-review/action.yml +298 -0
  77. package/plugins/pr-review/scripts/agent_script.py +1282 -0
  78. package/plugins/pr-review/scripts/evaluate_review.py +655 -0
  79. package/plugins/pr-review/scripts/prompt.py +260 -0
  80. package/plugins/pr-review/workflows/pr-review-by-openhands.yml +51 -0
  81. package/plugins/pr-review/workflows/pr-review-evaluation.yml +85 -0
  82. package/plugins/qa-changes/.plugin/plugin.json +11 -0
  83. package/plugins/qa-changes/README.md +185 -0
  84. package/plugins/qa-changes/action.yml +181 -0
  85. package/plugins/qa-changes/scripts/agent_script.py +406 -0
  86. package/plugins/qa-changes/scripts/evaluate_qa_changes.py +385 -0
  87. package/plugins/qa-changes/scripts/prompt.py +174 -0
  88. package/plugins/qa-changes/workflows/qa-changes-by-openhands.yml +50 -0
  89. package/plugins/qa-changes/workflows/qa-changes-evaluation.yml +85 -0
  90. package/plugins/release-notes/.plugin/plugin.json +19 -0
  91. package/plugins/release-notes/README.md +283 -0
  92. package/plugins/release-notes/SKILL.md +83 -0
  93. package/plugins/release-notes/action.yml +117 -0
  94. package/plugins/release-notes/commands/release-notes.md +8 -0
  95. package/plugins/release-notes/scripts/agent_script.py +292 -0
  96. package/plugins/release-notes/scripts/generate_release_notes.py +733 -0
  97. package/plugins/release-notes/scripts/prompt.py +90 -0
  98. package/plugins/release-notes/scripts/validate_release_notes.py +328 -0
  99. package/plugins/release-notes/workflows/release-notes.yml +76 -0
  100. package/plugins/vulnerability-remediation/.plugin/plugin.json +19 -0
  101. package/plugins/vulnerability-remediation/README.md +217 -0
  102. package/plugins/vulnerability-remediation/action.yml +187 -0
  103. package/plugins/vulnerability-remediation/scripts/scan_and_remediate.py +561 -0
  104. package/plugins/vulnerability-remediation/workflows/vulnerability-scan.yml +87 -0
  105. package/pyproject.toml +12 -0
  106. package/release-please-config.json +16 -0
  107. package/scripts/sync_extensions.py +494 -0
  108. package/scripts/sync_openhands_sdk_skill.py +264 -0
  109. package/skills/README.md +159 -0
  110. package/skills/add-javadoc/.plugin/plugin.json +18 -0
  111. package/skills/add-javadoc/README.md +40 -0
  112. package/skills/add-javadoc/SKILL.md +35 -0
  113. package/skills/add-javadoc/references/example.md +32 -0
  114. package/skills/add-skill/.plugin/plugin.json +18 -0
  115. package/skills/add-skill/README.md +67 -0
  116. package/skills/add-skill/SKILL.md +47 -0
  117. package/skills/add-skill/scripts/fetch_skill.py +259 -0
  118. package/skills/agent-creator/.plugin/plugin.json +20 -0
  119. package/skills/agent-creator/README.md +104 -0
  120. package/skills/agent-creator/SKILL.md +190 -0
  121. package/skills/agent-creator/commands/agent-creator.md +8 -0
  122. package/skills/agent-creator/references/fallback.md +117 -0
  123. package/skills/agent-memory/.plugin/plugin.json +18 -0
  124. package/skills/agent-memory/README.md +35 -0
  125. package/skills/agent-memory/SKILL.md +30 -0
  126. package/skills/agent-memory/commands/remember.md +8 -0
  127. package/skills/agent-sdk-builder/.plugin/plugin.json +18 -0
  128. package/skills/agent-sdk-builder/README.md +40 -0
  129. package/skills/agent-sdk-builder/SKILL.md +37 -0
  130. package/skills/agent-sdk-builder/commands/agent-builder.md +8 -0
  131. package/skills/azure-devops/.plugin/plugin.json +18 -0
  132. package/skills/azure-devops/README.md +55 -0
  133. package/skills/azure-devops/SKILL.md +50 -0
  134. package/skills/bitbucket/.plugin/plugin.json +17 -0
  135. package/skills/bitbucket/README.md +50 -0
  136. package/skills/bitbucket/SKILL.md +45 -0
  137. package/skills/code-review/.plugin/plugin.json +19 -0
  138. package/skills/code-review/README.md +18 -0
  139. package/skills/code-review/SKILL.md +208 -0
  140. package/skills/code-review/commands/codereview-roasted.md +8 -0
  141. package/skills/code-review/commands/codereview.md +8 -0
  142. package/skills/code-review/references/risk-evaluation.md +41 -0
  143. package/skills/code-review/references/supply-chain-security.md +31 -0
  144. package/skills/code-simplifier/.plugin/plugin.json +21 -0
  145. package/skills/code-simplifier/README.md +30 -0
  146. package/skills/code-simplifier/SKILL.md +91 -0
  147. package/skills/code-simplifier/commands/simplify.md +8 -0
  148. package/skills/code-simplifier/references/code-quality-review.md +86 -0
  149. package/skills/code-simplifier/references/code-reuse-review.md +63 -0
  150. package/skills/code-simplifier/references/efficiency-review.md +81 -0
  151. package/skills/datadog/.plugin/plugin.json +19 -0
  152. package/skills/datadog/README.md +100 -0
  153. package/skills/datadog/SKILL.md +95 -0
  154. package/skills/deno/.plugin/plugin.json +18 -0
  155. package/skills/deno/README.md +5 -0
  156. package/skills/deno/SKILL.md +99 -0
  157. package/skills/deno/references/README.md +6 -0
  158. package/skills/discord/.plugin/plugin.json +18 -0
  159. package/skills/discord/README.md +31 -0
  160. package/skills/discord/SKILL.md +109 -0
  161. package/skills/discord/__init__.py +0 -0
  162. package/skills/discord/references/REFERENCE.md +78 -0
  163. package/skills/discord/scripts/__init__.py +0 -0
  164. package/skills/discord/scripts/_http.py +127 -0
  165. package/skills/discord/scripts/post_webhook.py +106 -0
  166. package/skills/discord/scripts/send_message.py +102 -0
  167. package/skills/docker/.plugin/plugin.json +17 -0
  168. package/skills/docker/README.md +34 -0
  169. package/skills/docker/SKILL.md +29 -0
  170. package/skills/evidence-based-citations/.plugin/plugin.json +20 -0
  171. package/skills/evidence-based-citations/README.md +31 -0
  172. package/skills/evidence-based-citations/SKILL.md +59 -0
  173. package/skills/flarglebargle/.plugin/plugin.json +16 -0
  174. package/skills/flarglebargle/README.md +14 -0
  175. package/skills/flarglebargle/SKILL.md +9 -0
  176. package/skills/frontend-design/.plugin/plugin.json +21 -0
  177. package/skills/frontend-design/LICENSE.txt +177 -0
  178. package/skills/frontend-design/README.md +42 -0
  179. package/skills/frontend-design/SKILL.md +42 -0
  180. package/skills/github/.plugin/plugin.json +19 -0
  181. package/skills/github/README.md +42 -0
  182. package/skills/github/SKILL.md +106 -0
  183. package/skills/github-pr-review/.plugin/plugin.json +18 -0
  184. package/skills/github-pr-review/README.md +145 -0
  185. package/skills/github-pr-review/SKILL.md +148 -0
  186. package/skills/github-pr-review/commands/github-pr-review.md +8 -0
  187. package/skills/github-pr-reviewer/.plugin/plugin.json +20 -0
  188. package/skills/github-pr-reviewer/README.md +34 -0
  189. package/skills/github-pr-reviewer/SKILL.md +89 -0
  190. package/skills/github-pr-reviewer/commands/pr-reviewer:setup.md +8 -0
  191. package/skills/github-repo-monitor/.plugin/plugin.json +22 -0
  192. package/skills/github-repo-monitor/README.md +70 -0
  193. package/skills/github-repo-monitor/SKILL.md +316 -0
  194. package/skills/github-repo-monitor/commands/github-monitor:poll.md +8 -0
  195. package/skills/github-repo-monitor/references/github-api.md +241 -0
  196. package/skills/github-repo-monitor/references/state-schema.md +160 -0
  197. package/skills/github-repo-monitor/scripts/main.py +915 -0
  198. package/skills/github-repo-monitor/tests/test_main.py +400 -0
  199. package/skills/gitlab/.plugin/plugin.json +17 -0
  200. package/skills/gitlab/README.md +37 -0
  201. package/skills/gitlab/SKILL.md +32 -0
  202. package/skills/incident-retrospective/.plugin/plugin.json +21 -0
  203. package/skills/incident-retrospective/README.md +34 -0
  204. package/skills/incident-retrospective/SKILL.md +98 -0
  205. package/skills/incident-retrospective/commands/incident-retro:setup.md +8 -0
  206. package/skills/iterate/.plugin/plugin.json +13 -0
  207. package/skills/iterate/README.md +25 -0
  208. package/skills/iterate/SKILL.md +399 -0
  209. package/skills/iterate/commands/babysit.md +8 -0
  210. package/skills/iterate/commands/iterate.md +8 -0
  211. package/skills/iterate/commands/verify.md +8 -0
  212. package/skills/iterate/references/heuristics.md +58 -0
  213. package/skills/iterate/references/verification.md +96 -0
  214. package/skills/jupyter/.plugin/plugin.json +18 -0
  215. package/skills/jupyter/README.md +55 -0
  216. package/skills/jupyter/SKILL.md +50 -0
  217. package/skills/kubernetes/.plugin/plugin.json +18 -0
  218. package/skills/kubernetes/README.md +53 -0
  219. package/skills/kubernetes/SKILL.md +48 -0
  220. package/skills/learn-from-code-review/.plugin/plugin.json +19 -0
  221. package/skills/learn-from-code-review/README.md +64 -0
  222. package/skills/learn-from-code-review/SKILL.md +186 -0
  223. package/skills/learn-from-code-review/commands/learn-from-reviews.md +8 -0
  224. package/skills/linear/.plugin/plugin.json +19 -0
  225. package/skills/linear/README.md +58 -0
  226. package/skills/linear/SKILL.md +213 -0
  227. package/skills/linear-triage/.plugin/plugin.json +21 -0
  228. package/skills/linear-triage/README.md +34 -0
  229. package/skills/linear-triage/SKILL.md +91 -0
  230. package/skills/linear-triage/commands/linear-triage:setup.md +8 -0
  231. package/skills/notion/.plugin/plugin.json +17 -0
  232. package/skills/notion/README.md +114 -0
  233. package/skills/notion/SKILL.md +109 -0
  234. package/skills/npm/.plugin/plugin.json +17 -0
  235. package/skills/npm/README.md +14 -0
  236. package/skills/npm/SKILL.md +9 -0
  237. package/skills/openhands-api/.plugin/plugin.json +22 -0
  238. package/skills/openhands-api/README.md +48 -0
  239. package/skills/openhands-api/SKILL.md +399 -0
  240. package/skills/openhands-api/references/README.md +33 -0
  241. package/skills/openhands-api/references/TROUBLESHOOTING.md +81 -0
  242. package/skills/openhands-api/references/example_prompt.md +12 -0
  243. package/skills/openhands-api/scripts/openhands_api.py +606 -0
  244. package/skills/openhands-api/scripts/openhands_api.ts +252 -0
  245. package/skills/openhands-automation/.plugin/plugin.json +19 -0
  246. package/skills/openhands-automation/README.md +89 -0
  247. package/skills/openhands-automation/SKILL.md +875 -0
  248. package/skills/openhands-automation/commands/automation:create.md +8 -0
  249. package/skills/openhands-automation/references/ab-testing.md +185 -0
  250. package/skills/openhands-automation/references/custom-automation.md +644 -0
  251. package/skills/openhands-sdk/.plugin/plugin.json +20 -0
  252. package/skills/openhands-sdk/README.md +22 -0
  253. package/skills/openhands-sdk/SKILL.md +229 -0
  254. package/skills/openhands-sdk/commands/sdk.md +8 -0
  255. package/skills/pdflatex/.plugin/plugin.json +18 -0
  256. package/skills/pdflatex/README.md +39 -0
  257. package/skills/pdflatex/SKILL.md +34 -0
  258. package/skills/prd/.plugin/plugin.json +19 -0
  259. package/skills/prd/README.md +28 -0
  260. package/skills/prd/SKILL.md +237 -0
  261. package/skills/prd/commands/prd.md +8 -0
  262. package/skills/qa-changes/README.md +18 -0
  263. package/skills/qa-changes/SKILL.md +229 -0
  264. package/skills/qa-changes/commands/qa-changes.md +8 -0
  265. package/skills/release-notes/README.md +24 -0
  266. package/skills/release-notes/SKILL.md +19 -0
  267. package/skills/release-notes/commands/release-notes.md +8 -0
  268. package/skills/research-brief/.plugin/plugin.json +20 -0
  269. package/skills/research-brief/README.md +34 -0
  270. package/skills/research-brief/SKILL.md +99 -0
  271. package/skills/research-brief/commands/research-brief:setup.md +8 -0
  272. package/skills/security/.plugin/plugin.json +18 -0
  273. package/skills/security/README.md +38 -0
  274. package/skills/security/SKILL.md +33 -0
  275. package/skills/skill-creator/.plugin/plugin.json +17 -0
  276. package/skills/skill-creator/LICENSE.txt +202 -0
  277. package/skills/skill-creator/README.md +182 -0
  278. package/skills/skill-creator/SKILL.md +545 -0
  279. package/skills/skill-creator/references/output-patterns.md +82 -0
  280. package/skills/skill-creator/references/workflows.md +28 -0
  281. package/skills/skill-creator/scripts/init_skill.py +303 -0
  282. package/skills/skill-creator/scripts/quick_validate.py +95 -0
  283. package/skills/slack-channel-monitor/.plugin/plugin.json +21 -0
  284. package/skills/slack-channel-monitor/README.md +91 -0
  285. package/skills/slack-channel-monitor/SKILL.md +276 -0
  286. package/skills/slack-channel-monitor/commands/slack-monitor:poll.md +8 -0
  287. package/skills/slack-channel-monitor/references/slack-api.md +207 -0
  288. package/skills/slack-channel-monitor/references/state-schema.md +180 -0
  289. package/skills/slack-channel-monitor/scripts/main.py +962 -0
  290. package/skills/slack-standup-digest/.plugin/plugin.json +21 -0
  291. package/skills/slack-standup-digest/README.md +34 -0
  292. package/skills/slack-standup-digest/SKILL.md +92 -0
  293. package/skills/slack-standup-digest/commands/standup-digest:setup.md +8 -0
  294. package/skills/spark-version-upgrade/.plugin/plugin.json +20 -0
  295. package/skills/spark-version-upgrade/README.md +54 -0
  296. package/skills/spark-version-upgrade/SKILL.md +233 -0
  297. package/skills/ssh/.plugin/plugin.json +18 -0
  298. package/skills/ssh/README.md +140 -0
  299. package/skills/ssh/SKILL.md +135 -0
  300. package/skills/swift-linux/.plugin/plugin.json +17 -0
  301. package/skills/swift-linux/README.md +86 -0
  302. package/skills/swift-linux/SKILL.md +81 -0
  303. package/skills/theme-factory/.plugin/plugin.json +19 -0
  304. package/skills/theme-factory/LICENSE.txt +202 -0
  305. package/skills/theme-factory/README.md +58 -0
  306. package/skills/theme-factory/SKILL.md +59 -0
  307. package/skills/theme-factory/theme-showcase.pdf +0 -0
  308. package/skills/theme-factory/themes/arctic-frost.md +19 -0
  309. package/skills/theme-factory/themes/botanical-garden.md +19 -0
  310. package/skills/theme-factory/themes/desert-rose.md +19 -0
  311. package/skills/theme-factory/themes/forest-canopy.md +19 -0
  312. package/skills/theme-factory/themes/golden-hour.md +19 -0
  313. package/skills/theme-factory/themes/midnight-galaxy.md +19 -0
  314. package/skills/theme-factory/themes/modern-minimalist.md +19 -0
  315. package/skills/theme-factory/themes/ocean-depths.md +19 -0
  316. package/skills/theme-factory/themes/sunset-boulevard.md +19 -0
  317. package/skills/theme-factory/themes/tech-innovation.md +19 -0
  318. package/skills/uv/.plugin/plugin.json +18 -0
  319. package/skills/uv/README.md +5 -0
  320. package/skills/uv/SKILL.md +95 -0
  321. package/skills/uv/references/README.md +5 -0
  322. package/skills/vercel/.plugin/plugin.json +18 -0
  323. package/skills/vercel/README.md +108 -0
  324. package/skills/vercel/SKILL.md +103 -0
  325. package/tests/test_add_skill_installs_to_agents_dir.py +42 -0
  326. package/tests/test_catalogs.py +109 -0
  327. package/tests/test_code_review_risk_evaluation.py +94 -0
  328. package/tests/test_issue_duplicate_checker.py +240 -0
  329. package/tests/test_openhands_api_python.py +152 -0
  330. package/tests/test_plugin_manifest.py +83 -0
  331. package/tests/test_pr_review_diff_payload.py +202 -0
  332. package/tests/test_pr_review_feedback.py +263 -0
  333. package/tests/test_pr_review_prompt.py +152 -0
  334. package/tests/test_pr_review_review_context.py +253 -0
  335. package/tests/test_qa_changes.py +232 -0
  336. package/tests/test_qa_changes_evaluation.py +259 -0
  337. package/tests/test_release_notes_generator.py +990 -0
  338. package/tests/test_sdk_loading.py +150 -0
  339. package/tests/test_skill_plugin_loading.py +149 -0
  340. package/tests/test_skills_have_readme.py +66 -0
  341. package/tests/test_sync_extensions.py +292 -0
  342. package/tests/test_workflow_sync.py +46 -0
  343. package/utils/analysis/README.md +7 -0
  344. package/utils/analysis/laminar_signals/README.md +211 -0
  345. package/utils/analysis/laminar_signals/analyze.py +780 -0
  346. package/utils/analysis/laminar_signals/templates/default.j2 +49 -0
  347. package/utils/analysis/laminar_signals/templates/pr_review.j2 +61 -0
@@ -0,0 +1,109 @@
1
+ import json
2
+ import re
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+
7
+ ROOT = Path(__file__).resolve().parents[1]
8
+
9
+
10
+ def load_catalog_entries(relative_path: str):
11
+ entries = []
12
+ for entry_path in sorted((ROOT / relative_path).glob("*.json")):
13
+ entry = json.loads(entry_path.read_text())
14
+ assert entry["id"] == entry_path.stem
15
+ entries.append(entry)
16
+ return entries
17
+
18
+
19
+ def test_catalog_ids_are_unique_and_automations_reference_existing_integrations():
20
+ integrations = load_catalog_entries("integrations/catalog")
21
+ automations = load_catalog_entries("automations/catalog")
22
+
23
+ integration_ids = [entry["id"] for entry in integrations]
24
+ automation_ids = [entry["id"] for entry in automations]
25
+
26
+ assert len(integration_ids) == len(set(integration_ids))
27
+ assert len(automation_ids) == len(set(automation_ids))
28
+
29
+ known_integration_ids = set(integration_ids)
30
+ for automation in automations:
31
+ assert automation["requiredIntegrationIds"]
32
+ missing_ids = (
33
+ set(automation["requiredIntegrationIds"]) - known_integration_ids
34
+ )
35
+ assert missing_ids == set()
36
+
37
+
38
+ def test_catalog_entries_have_required_fields():
39
+ for entry in load_catalog_entries("integrations/catalog"):
40
+ assert entry["id"]
41
+ assert entry["name"]
42
+ assert entry["description"]
43
+ assert entry["kind"] in {"mcp", "http"}
44
+ assert entry["iconBg"]
45
+ assert entry["connectionOptions"]
46
+ assert entry["defaultConnectionOptionId"]
47
+ for option in entry["connectionOptions"]:
48
+ assert option["id"]
49
+ assert option["provider"] in {"mcp", "http"}
50
+ assert option["auth"]["strategy"] in {
51
+ "none",
52
+ "api_key",
53
+ "bearer",
54
+ "basic",
55
+ "oauth2",
56
+ }
57
+ if option["provider"] == "mcp":
58
+ assert option["transport"]["kind"] in {"stdio", "shttp", "sse"}
59
+ if option["transport"]["kind"] == "stdio":
60
+ assert option["transport"]["serverName"]
61
+ assert option["transport"]["command"]
62
+ assert isinstance(option["transport"]["args"], list)
63
+ else:
64
+ assert option["transport"]["url"].startswith("https://")
65
+
66
+ for entry in load_catalog_entries("automations/catalog"):
67
+ assert entry["id"]
68
+ assert entry["name"]
69
+ assert entry["prompt"]
70
+ assert entry["exampleImplementation"]
71
+ assert isinstance(entry["popularityRank"], int)
72
+ assert isinstance(entry["estimatedSetupMinutes"], int)
73
+
74
+
75
+ def test_credential_fields_have_helper_text_and_link():
76
+ """All password fields must have helperText plus a link (either a helperLink field or a
77
+ markdown link embedded in helperText) so users know how to get credentials."""
78
+ markdown_link_re = re.compile(r"\[.+?\]\(https://[^)]+\)")
79
+
80
+ for entry in load_catalog_entries("integrations/catalog"):
81
+ for option in entry["connectionOptions"]:
82
+ transport = option.get("transport", {})
83
+ for field_group in ("envFields", "argFields"):
84
+ for field in transport.get(field_group, []):
85
+ if field.get("type") == "password":
86
+ field_key = field.get("key", "<unknown>")
87
+ assert "helperText" in field, (
88
+ f"{entry['id']}: password field '{field_key}' is missing helperText"
89
+ )
90
+ assert field["helperText"], (
91
+ f"{entry['id']}: password field '{field_key}' has empty helperText"
92
+ )
93
+ has_helper_link = field.get("helperLink", "").startswith("https://")
94
+ has_inline_link = bool(markdown_link_re.search(field["helperText"]))
95
+ assert has_helper_link or has_inline_link, (
96
+ f"{entry['id']}: password field '{field_key}' must have a helperLink "
97
+ f"or a markdown link in helperText"
98
+ )
99
+
100
+
101
+ def test_node_package_exports_catalogs():
102
+ script = """
103
+ import { INTEGRATION_CATALOG, AUTOMATION_CATALOG } from './index.js';
104
+ if (!Array.isArray(INTEGRATION_CATALOG) || INTEGRATION_CATALOG.length === 0) process.exit(1);
105
+ if (!Array.isArray(AUTOMATION_CATALOG) || AUTOMATION_CATALOG.length === 0) process.exit(1);
106
+ if (!INTEGRATION_CATALOG.some((entry) => entry.id === 'github')) process.exit(1);
107
+ if (!AUTOMATION_CATALOG.some((entry) => entry.id === 'github-pr-reviewer')) process.exit(1);
108
+ """
109
+ subprocess.run(["node", "--input-type=module", "-e", script], cwd=ROOT, check=True)
@@ -0,0 +1,94 @@
1
+ """Test that risk/safety evaluation is integrated into the unified code review skill."""
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ def get_repo_root():
7
+ return Path(__file__).parent.parent
8
+
9
+
10
+ def get_code_review_skill():
11
+ return (get_repo_root() / "skills" / "code-review" / "SKILL.md").read_text()
12
+
13
+
14
+ def get_risk_evaluation_reference():
15
+ return (
16
+ get_repo_root()
17
+ / "skills"
18
+ / "code-review"
19
+ / "references"
20
+ / "risk-evaluation.md"
21
+ ).read_text()
22
+
23
+
24
+ class TestRiskEvaluationReference:
25
+ """Verify the risk-evaluation.md reference file is complete."""
26
+
27
+ def test_reference_file_exists(self):
28
+ path = (
29
+ get_repo_root()
30
+ / "skills"
31
+ / "code-review"
32
+ / "references"
33
+ / "risk-evaluation.md"
34
+ )
35
+ assert path.exists()
36
+
37
+ def test_has_all_risk_levels(self):
38
+ content = get_risk_evaluation_reference()
39
+ assert "Low Risk" in content
40
+ assert "Medium Risk" in content
41
+ assert "High Risk" in content
42
+
43
+ def test_has_risk_factors(self):
44
+ content = get_risk_evaluation_reference()
45
+ factors = [
46
+ "Pattern conformance",
47
+ "Security sensitivity",
48
+ "Infrastructure dependencies",
49
+ "Blast radius",
50
+ "Core system impact",
51
+ ]
52
+ for factor in factors:
53
+ assert factor in content, f"Missing risk factor: {factor}"
54
+
55
+ def test_has_high_risk_escalation_guidance(self):
56
+ content = get_risk_evaluation_reference()
57
+ assert "not auto-merg" in content.lower()
58
+ assert "human" in content.lower()
59
+
60
+ def test_has_repo_specific_risk_rules(self):
61
+ content = get_risk_evaluation_reference()
62
+ assert "AGENTS.md" in content
63
+ assert "repo-specific" in content.lower() or "Repo-specific" in content
64
+
65
+ def test_has_risk_assessment_output_format(self):
66
+ content = get_risk_evaluation_reference()
67
+ assert "Risk Assessment" in content
68
+
69
+
70
+ class TestCodeReviewSkillReferencesRisk:
71
+ """Verify the unified code-review skill references the risk evaluation."""
72
+
73
+ def test_has_risk_evaluation_scenario(self):
74
+ content = get_code_review_skill()
75
+ assert "Risk and Safety Evaluation" in content
76
+
77
+ def test_references_risk_evaluation_file(self):
78
+ content = get_code_review_skill()
79
+ assert "references/risk-evaluation.md" in content
80
+
81
+ def test_risk_section_appears_after_dependency_section(self):
82
+ content = get_code_review_skill()
83
+ dep_pos = content.index("8. **Dependency Changes**")
84
+ risk_pos = content.index("9. **Risk and Safety Evaluation**")
85
+ assert risk_pos > dep_pos
86
+
87
+ def test_always_include_risk_instruction(self):
88
+ content = get_code_review_skill()
89
+ assert "Always include the" in content
90
+ assert "Risk and Safety Evaluation" in content
91
+
92
+ def test_has_risk_assessment_in_output_format(self):
93
+ content = get_code_review_skill()
94
+ assert "RISK ASSESSMENT" in content
@@ -0,0 +1,240 @@
1
+ import importlib.util
2
+ import io
3
+ import urllib.error
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+
9
+ ROOT = Path(__file__).resolve().parents[1]
10
+ PLUGIN_DIR = ROOT / "plugins" / "issue-duplicate-checker" / "scripts"
11
+
12
+
13
+ def load_script(name: str):
14
+ path = PLUGIN_DIR / f"{name}.py"
15
+ spec = importlib.util.spec_from_file_location(name, path)
16
+ module = importlib.util.module_from_spec(spec)
17
+ assert spec.loader is not None
18
+ spec.loader.exec_module(module)
19
+ return module
20
+
21
+
22
+ def test_action_shell_blocks_do_not_interpolate_expressions_directly():
23
+ action_path = ROOT / "plugins" / "issue-duplicate-checker" / "action.yml"
24
+ lines = action_path.read_text().splitlines()
25
+ in_block = False
26
+ block_indent = 0
27
+ for line_number, line in enumerate(lines, start=1):
28
+ stripped = line.lstrip()
29
+ indent = len(line) - len(stripped)
30
+ if in_block and stripped and indent <= block_indent:
31
+ in_block = False
32
+ if stripped.startswith(("run:", "script:")):
33
+ block_scalar = stripped.split(":", 1)[1].strip()
34
+ if block_scalar.startswith(("|", ">")):
35
+ in_block = True
36
+ block_indent = indent
37
+ continue
38
+ assert not (in_block and "${{" in line), (
39
+ f"Move GitHub expression on line {line_number} into env before using it"
40
+ )
41
+
42
+
43
+ def test_normalize_result_preserves_model_should_comment_false():
44
+ script = load_script("issue_duplicate_check_openhands")
45
+
46
+ result = script.normalize_result(
47
+ {
48
+ "should_comment": False,
49
+ "is_duplicate": True,
50
+ "auto_close_candidate": True,
51
+ "classification": "duplicate",
52
+ "confidence": "high",
53
+ "canonical_issue_number": 123,
54
+ "candidate_issues": [{"number": 123}],
55
+ }
56
+ )
57
+
58
+ assert result["should_comment"] is False
59
+ assert result["auto_close_candidate"] is False
60
+
61
+
62
+ def test_github_headers_require_token(monkeypatch):
63
+ script = load_script("issue_duplicate_check_openhands")
64
+ monkeypatch.delenv("GITHUB_TOKEN", raising=False)
65
+
66
+ with pytest.raises(
67
+ RuntimeError,
68
+ match="GITHUB_TOKEN environment variable is required",
69
+ ):
70
+ script.github_headers()
71
+
72
+ monkeypatch.setenv("GITHUB_TOKEN", "token")
73
+ headers = script.github_headers()
74
+ assert headers["Authorization"] == "Bearer token"
75
+
76
+
77
+ def test_issue_check_request_json_raises_structured_http_error(monkeypatch):
78
+ script = load_script("issue_duplicate_check_openhands")
79
+
80
+ def fake_urlopen(*args, **kwargs):
81
+ raise urllib.error.HTTPError(
82
+ url="https://api.example.test/example",
83
+ code=403,
84
+ msg="Forbidden",
85
+ hdrs=None,
86
+ fp=io.BytesIO(b'{"message":"rate limited"}'),
87
+ )
88
+
89
+ monkeypatch.setattr(script.urllib.request, "urlopen", fake_urlopen)
90
+
91
+ with pytest.raises(script.HTTPError) as exc_info:
92
+ script.request_json("https://api.example.test", "/example")
93
+
94
+ assert exc_info.value.status_code == 403
95
+ assert exc_info.value.url == "https://api.example.test/example"
96
+
97
+
98
+ def test_parse_agent_json_extracts_fenced_json():
99
+ script = load_script("issue_duplicate_check_openhands")
100
+
101
+ result = script.parse_agent_json(
102
+ 'Here is the result:\n```json\n'
103
+ '{"classification":"duplicate","confidence":"high"}\n```'
104
+ )
105
+
106
+ assert result == {"classification": "duplicate", "confidence": "high"}
107
+
108
+
109
+ def test_normalize_result_guards_auto_close_and_infers_canonical_issue():
110
+ script = load_script("issue_duplicate_check_openhands")
111
+
112
+ overlapping = script.normalize_result(
113
+ {
114
+ "should_comment": True,
115
+ "is_duplicate": False,
116
+ "auto_close_candidate": True,
117
+ "classification": "overlapping-scope",
118
+ "confidence": "high",
119
+ "candidate_issues": [{"number": 10}],
120
+ }
121
+ )
122
+ assert overlapping["should_comment"] is True
123
+ assert overlapping["auto_close_candidate"] is False
124
+
125
+ low_confidence = script.normalize_result(
126
+ {
127
+ "should_comment": True,
128
+ "is_duplicate": True,
129
+ "auto_close_candidate": True,
130
+ "classification": "duplicate",
131
+ "confidence": "low",
132
+ "candidate_issues": [{"number": 11}],
133
+ }
134
+ )
135
+ assert low_confidence["should_comment"] is False
136
+ assert low_confidence["auto_close_candidate"] is False
137
+
138
+ inferred = script.normalize_result(
139
+ {
140
+ "should_comment": True,
141
+ "is_duplicate": True,
142
+ "auto_close_candidate": True,
143
+ "classification": "duplicate",
144
+ "confidence": "high",
145
+ "canonical_issue_number": None,
146
+ "candidate_issues": [{"number": "12"}],
147
+ }
148
+ )
149
+ assert inferred["auto_close_candidate"] is True
150
+ assert inferred["canonical_issue_number"] == 12
151
+
152
+
153
+ def test_build_prompt_includes_required_schema_keys():
154
+ script = load_script("issue_duplicate_check_openhands")
155
+
156
+ prompt = script.build_prompt(
157
+ "OpenHands/extensions",
158
+ {
159
+ "number": 123,
160
+ "title": "Duplicate bug",
161
+ "body": "Looks related",
162
+ "html_url": "https://github.com/OpenHands/extensions/issues/123",
163
+ },
164
+ )
165
+
166
+ for key in [
167
+ "should_comment",
168
+ "is_duplicate",
169
+ "auto_close_candidate",
170
+ "classification",
171
+ "confidence",
172
+ "canonical_issue_number",
173
+ "candidate_issues",
174
+ ]:
175
+ assert key in prompt
176
+
177
+
178
+ def test_find_latest_auto_close_comment_uses_newest_auto_close_marker():
179
+ script = load_script("auto_close_duplicate_issues")
180
+
181
+ latest, canonical = script.find_latest_auto_close_comment(
182
+ [
183
+ {
184
+ "id": 1,
185
+ "created_at": "2026-01-01T00:00:00Z",
186
+ "body": "<!-- openhands-duplicate-check canonical=10 auto-close=true -->",
187
+ },
188
+ {
189
+ "id": 2,
190
+ "created_at": "2026-01-03T00:00:00Z",
191
+ "body": "<!-- openhands-duplicate-check canonical=20 auto-close=false -->",
192
+ },
193
+ {
194
+ "id": 3,
195
+ "created_at": None,
196
+ "body": "<!-- openhands-duplicate-check canonical=30 auto-close=true -->",
197
+ },
198
+ {
199
+ "id": 4,
200
+ "created_at": "2026-01-02T00:00:00Z",
201
+ "body": "<!-- openhands-duplicate-check canonical=40 auto-close=true -->",
202
+ },
203
+ ]
204
+ )
205
+
206
+ assert latest["id"] == 4
207
+ assert canonical == 40
208
+
209
+
210
+ def test_request_json_raises_structured_http_error(monkeypatch):
211
+ script = load_script("auto_close_duplicate_issues")
212
+ monkeypatch.setenv("GITHUB_TOKEN", "token")
213
+
214
+ def fake_urlopen(*args, **kwargs):
215
+ raise urllib.error.HTTPError(
216
+ url="https://api.github.com/example",
217
+ code=404,
218
+ msg="Not Found",
219
+ hdrs=None,
220
+ fp=io.BytesIO(b'{"message":"missing"}'),
221
+ )
222
+
223
+ monkeypatch.setattr(script.urllib.request, "urlopen", fake_urlopen)
224
+
225
+ with pytest.raises(script.HTTPError) as exc_info:
226
+ script.request_json("/example")
227
+
228
+ assert exc_info.value.status_code == 404
229
+ assert exc_info.value.path == "/example"
230
+
231
+
232
+ def test_fetch_issue_returns_none_on_404(monkeypatch):
233
+ script = load_script("auto_close_duplicate_issues")
234
+
235
+ def fake_request_json(path):
236
+ raise script.HTTPError("GET", path, 404, "missing")
237
+
238
+ monkeypatch.setattr(script, "request_json", fake_request_json)
239
+
240
+ assert script.fetch_issue("OpenHands/extensions", 123) is None
@@ -0,0 +1,152 @@
1
+ import importlib.util
2
+ import sys
3
+ from pathlib import Path
4
+
5
+
6
+ def _load_openhands_api_module():
7
+ skill_path = Path(__file__).parent.parent / "skills" / "openhands-api" / "scripts" / "openhands_api.py"
8
+ spec = importlib.util.spec_from_file_location("openhands_api", skill_path)
9
+ mod = importlib.util.module_from_spec(spec)
10
+ sys.modules["openhands_api"] = mod
11
+ spec.loader.exec_module(mod)
12
+ return mod
13
+
14
+
15
+ def test_app_conversation_start_builds_v1_payload(monkeypatch):
16
+ mod = _load_openhands_api_module()
17
+ OpenHandsAPI = mod.OpenHandsAPI
18
+
19
+ captured = {}
20
+
21
+ class FakeResp:
22
+ def raise_for_status(self):
23
+ return None
24
+
25
+ def json(self):
26
+ return {"id": "task-1", "status": "WORKING"}
27
+
28
+ class FakeClient:
29
+ def __init__(self, **kwargs):
30
+ self.kwargs = kwargs
31
+
32
+ def post(self, url, json=None, timeout=None):
33
+ captured["url"] = url
34
+ captured["json"] = json
35
+ captured["timeout"] = timeout
36
+ return FakeResp()
37
+
38
+ def close(self):
39
+ return None
40
+
41
+ monkeypatch.setattr(mod.httpx, "Client", lambda **kwargs: FakeClient(**kwargs))
42
+
43
+ api = OpenHandsAPI(api_key="k", base_url="https://example.com/")
44
+ resp = api.app_conversation_start(
45
+ initial_message="hi",
46
+ selected_repository="o/r",
47
+ selected_branch="main",
48
+ title="Test title",
49
+ run=False,
50
+ )
51
+
52
+ assert resp == {"id": "task-1", "status": "WORKING"}
53
+ assert captured["url"] == "https://example.com/api/v1/app-conversations"
54
+ assert captured["timeout"] == 120
55
+ assert captured["json"] == {
56
+ "initial_message": {
57
+ "role": "user",
58
+ "content": [{"type": "text", "text": "hi"}],
59
+ "run": False,
60
+ },
61
+ "selected_repository": "o/r",
62
+ "selected_branch": "main",
63
+ "title": "Test title",
64
+ }
65
+
66
+
67
+ def test_app_conversations_get_batch_passes_ids(monkeypatch):
68
+ mod = _load_openhands_api_module()
69
+ OpenHandsAPI = mod.OpenHandsAPI
70
+
71
+ captured = {}
72
+
73
+ class FakeResp:
74
+ def raise_for_status(self):
75
+ return None
76
+
77
+ def json(self):
78
+ return [{"id": "conv-1"}]
79
+
80
+ class FakeClient:
81
+ def __init__(self, **kwargs):
82
+ self.kwargs = kwargs
83
+
84
+ def get(self, url, params=None):
85
+ captured["url"] = url
86
+ captured["params"] = params
87
+ return FakeResp()
88
+
89
+ def close(self):
90
+ return None
91
+
92
+ monkeypatch.setattr(mod.httpx, "Client", lambda **kwargs: FakeClient(**kwargs))
93
+
94
+ api = OpenHandsAPI(api_key="k", base_url="https://example.com")
95
+ conversations = api.app_conversations_get_batch(ids=["conv-1", "conv-2"])
96
+
97
+ assert conversations == [{"id": "conv-1"}]
98
+ assert captured["url"] == "https://example.com/api/v1/app-conversations"
99
+ assert captured["params"] == {"ids": ["conv-1", "conv-2"]}
100
+
101
+
102
+ def test_poll_start_task_until_ready_uses_start_task_endpoint(monkeypatch):
103
+ mod = _load_openhands_api_module()
104
+ OpenHandsAPI = mod.OpenHandsAPI
105
+
106
+ states = [
107
+ {"id": "task-1", "status": "WORKING"},
108
+ {"id": "task-1", "status": "READY", "app_conversation_id": "conv-1"},
109
+ ]
110
+ call_count = {"sleep": 0}
111
+
112
+ class FakeClient:
113
+ def __init__(self, **kwargs):
114
+ self.kwargs = kwargs
115
+
116
+ def get(self, url, params=None):
117
+ class FakeResp:
118
+ def raise_for_status(self_nonlocal):
119
+ return None
120
+
121
+ def json(self_nonlocal):
122
+ return [states.pop(0)]
123
+
124
+ return FakeResp()
125
+
126
+ def close(self):
127
+ return None
128
+
129
+ monkeypatch.setattr(mod.httpx, "Client", lambda **kwargs: FakeClient(**kwargs))
130
+ monkeypatch.setattr(mod.time, "sleep", lambda *_args, **_kwargs: call_count.__setitem__("sleep", call_count["sleep"] + 1))
131
+
132
+ api = OpenHandsAPI(api_key="k", base_url="https://example.com")
133
+ ready = api.poll_start_task_until_ready("task-1", timeout_s=10, poll_interval_s=0.01, backoff_factor=1.0)
134
+
135
+ assert ready == {"id": "task-1", "status": "READY", "app_conversation_id": "conv-1"}
136
+ assert call_count["sleep"] == 1
137
+
138
+
139
+ def test_legacy_alias_still_exists(monkeypatch):
140
+ mod = _load_openhands_api_module()
141
+
142
+ class FakeClient:
143
+ def __init__(self, **kwargs):
144
+ self.kwargs = kwargs
145
+
146
+ def close(self):
147
+ return None
148
+
149
+ monkeypatch.setattr(mod.httpx, "Client", lambda **kwargs: FakeClient(**kwargs))
150
+
151
+ api = mod.OpenHandsV1API(api_key="k")
152
+ assert isinstance(api, mod.OpenHandsAPI)
@@ -0,0 +1,83 @@
1
+ """Test that all plugin.json manifests are valid and Claude Code compatible."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+
9
+ def get_plugins_directory():
10
+ """Get the path to the plugins directory."""
11
+ test_dir = Path(__file__).parent
12
+ return test_dir.parent / "plugins"
13
+
14
+
15
+ def get_all_plugin_manifests():
16
+ """Yield (plugin_name, manifest_path) for every plugin with a .plugin/plugin.json."""
17
+ plugins_dir = get_plugins_directory()
18
+ for plugin_dir in sorted(plugins_dir.iterdir()):
19
+ if not plugin_dir.is_dir() or plugin_dir.name.startswith("."):
20
+ continue
21
+ manifest = plugin_dir / ".plugin" / "plugin.json"
22
+ if manifest.exists():
23
+ yield plugin_dir.name, manifest
24
+
25
+
26
+ REQUIRED_FIELDS = ["name", "description", "author", "version"]
27
+
28
+
29
+ @pytest.fixture(params=list(get_all_plugin_manifests()), ids=lambda p: p[0])
30
+ def plugin_manifest(request):
31
+ """Parametrized fixture returning (name, path, parsed_json) for each plugin."""
32
+ name, path = request.param
33
+ data = json.loads(path.read_text())
34
+ return name, path, data
35
+
36
+
37
+ def test_plugin_has_required_fields(plugin_manifest):
38
+ name, path, data = plugin_manifest
39
+ for field in REQUIRED_FIELDS:
40
+ assert field in data, (
41
+ f"Plugin '{name}' is missing required field '{field}' in {path}"
42
+ )
43
+
44
+
45
+ def test_plugin_author_is_object(plugin_manifest):
46
+ """Claude Code requires 'author' to be an object with at least a 'name' key."""
47
+ name, path, data = plugin_manifest
48
+ author = data.get("author")
49
+ assert isinstance(author, dict), (
50
+ f"Plugin '{name}': 'author' must be an object (got {type(author).__name__}). "
51
+ f"Use {{\"name\": \"...\", \"email\": \"...\"}} format for Claude Code compatibility."
52
+ )
53
+ assert "name" in author, (
54
+ f"Plugin '{name}': 'author' object must contain a 'name' field."
55
+ )
56
+
57
+
58
+ def test_plugin_json_is_valid(plugin_manifest):
59
+ """Ensure plugin.json is valid JSON with expected types."""
60
+ name, path, data = plugin_manifest
61
+ assert isinstance(data.get("name"), str), f"Plugin '{name}': 'name' must be a string"
62
+ assert isinstance(data.get("version"), str), f"Plugin '{name}': 'version' must be a string"
63
+ assert isinstance(data.get("description"), str), f"Plugin '{name}': 'description' must be a string"
64
+
65
+
66
+ def test_all_plugins_have_manifest():
67
+ """Ensure every plugin directory has a .plugin/plugin.json manifest."""
68
+ plugins_dir = get_plugins_directory()
69
+ plugin_dirs = [
70
+ d.name
71
+ for d in plugins_dir.iterdir()
72
+ if d.is_dir() and not d.name.startswith(".")
73
+ ]
74
+ assert len(plugin_dirs) > 0, "No plugin directories found"
75
+
76
+ missing = [
77
+ name
78
+ for name in plugin_dirs
79
+ if not (plugins_dir / name / ".plugin" / "plugin.json").exists()
80
+ ]
81
+ assert len(missing) == 0, (
82
+ f"Plugins missing .plugin/plugin.json: {', '.join(missing)}"
83
+ )