@openhands/extensions 0.0.1-alpha → 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,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,66 @@
1
+ """Test that all skills have a README.md file in their directory."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+
7
+ def get_skills_directory():
8
+ """Get the path to the skills directory."""
9
+ # Get the directory containing this test file
10
+ test_dir = Path(__file__).parent
11
+ # Go up one level to the repo root, then into skills/
12
+ return test_dir.parent / "skills"
13
+
14
+
15
+ def test_all_skills_have_readme():
16
+ """Verify that every skill directory contains a README.md file."""
17
+ skills_dir = get_skills_directory()
18
+
19
+ # Get all subdirectories in the skills directory (excluding files)
20
+ skill_dirs = [
21
+ d for d in skills_dir.iterdir()
22
+ if d.is_dir() and not d.name.startswith('.')
23
+ ]
24
+
25
+ assert len(skill_dirs) > 0, "No skill directories found"
26
+
27
+ missing_readmes = []
28
+ for skill_dir in skill_dirs:
29
+ readme_path = skill_dir / "README.md"
30
+ if not readme_path.exists():
31
+ missing_readmes.append(skill_dir.name)
32
+
33
+ assert len(missing_readmes) == 0, (
34
+ f"The following skills are missing README.md: {', '.join(missing_readmes)}"
35
+ )
36
+
37
+
38
+ def test_readme_is_readable():
39
+ """Verify that README.md files are readable (symlinks resolve correctly)."""
40
+ skills_dir = get_skills_directory()
41
+
42
+ skill_dirs = [
43
+ d for d in skills_dir.iterdir()
44
+ if d.is_dir() and not d.name.startswith('.')
45
+ ]
46
+
47
+ unreadable_readmes = []
48
+ for skill_dir in skill_dirs:
49
+ readme_path = skill_dir / "README.md"
50
+ if readme_path.exists():
51
+ try:
52
+ content = readme_path.read_text()
53
+ if len(content) == 0:
54
+ unreadable_readmes.append(f"{skill_dir.name} (empty)")
55
+ except Exception as e:
56
+ unreadable_readmes.append(f"{skill_dir.name} ({e})")
57
+
58
+ assert len(unreadable_readmes) == 0, (
59
+ f"The following README.md files are unreadable: {', '.join(unreadable_readmes)}"
60
+ )
61
+
62
+
63
+ if __name__ == "__main__":
64
+ test_all_skills_have_readme()
65
+ test_readme_is_readable()
66
+ print("All tests passed!")
@@ -0,0 +1,292 @@
1
+ """Tests for scripts/sync_extensions.py core functions."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
9
+ from sync_extensions import (
10
+ CMD_MARKER,
11
+ CommandSpec,
12
+ REPO_ROOT,
13
+ _entry_type,
14
+ build_command_content,
15
+ collect_needed_commands,
16
+ generate_catalog,
17
+ load_marketplaces,
18
+ parse_frontmatter,
19
+ slash_triggers,
20
+ sync_commands,
21
+ )
22
+
23
+
24
+ # ── parse_frontmatter ────────────────────────────────────────────────
25
+
26
+ class TestParseFrontmatter:
27
+ def test_basic(self):
28
+ text = (
29
+ "---\n"
30
+ "name: test-skill\n"
31
+ "description: A test skill\n"
32
+ "triggers:\n"
33
+ " - /test\n"
34
+ "---\n"
35
+ "Body"
36
+ )
37
+ meta = parse_frontmatter(text)
38
+ assert meta["name"] == "test-skill"
39
+ assert meta["description"] == "A test skill"
40
+ assert meta["triggers"] == ["/test"]
41
+
42
+ def test_no_frontmatter(self):
43
+ assert parse_frontmatter("No frontmatter here") == {}
44
+
45
+ def test_empty_frontmatter(self):
46
+ meta = parse_frontmatter("---\n---\nBody")
47
+ assert meta.get("triggers", []) == []
48
+ assert "name" not in meta
49
+
50
+ def test_multiple_triggers(self):
51
+ text = (
52
+ "---\n"
53
+ "name: multi\n"
54
+ "triggers:\n"
55
+ " - /foo\n"
56
+ " - /bar\n"
57
+ " - baz\n"
58
+ "---\n"
59
+ )
60
+ meta = parse_frontmatter(text)
61
+ assert meta["triggers"] == ["/foo", "/bar", "baz"]
62
+
63
+ def test_no_triggers_key(self):
64
+ text = "---\nname: no-triggers\ndescription: desc\n---\n"
65
+ meta = parse_frontmatter(text)
66
+ assert meta["triggers"] == []
67
+
68
+ def test_triggers_with_comments(self):
69
+ text = (
70
+ "---\n"
71
+ "triggers:\n"
72
+ " - /cmd\n"
73
+ " # this is a comment\n"
74
+ " - /other\n"
75
+ "---\n"
76
+ )
77
+ meta = parse_frontmatter(text)
78
+ assert "/cmd" in meta["triggers"]
79
+ assert "/other" in meta["triggers"]
80
+
81
+ def test_folded_block_scalar_description(self):
82
+ text = (
83
+ "---\n"
84
+ "name: test\n"
85
+ "description: >\n"
86
+ " This is a long\n"
87
+ " description text\n"
88
+ "other: value\n"
89
+ "---\n"
90
+ )
91
+ meta = parse_frontmatter(text)
92
+ assert meta["description"] == "This is a long description text"
93
+
94
+ def test_unicode(self):
95
+ text = (
96
+ "---\n"
97
+ "name: unic\u00f6de-skill\n"
98
+ "description: \u00dcn\u00efc\u00f6d\u00e9 description \U0001f680\n"
99
+ "---\n"
100
+ )
101
+ meta = parse_frontmatter(text)
102
+ assert meta["name"] == "unic\u00f6de-skill"
103
+ assert "\U0001f680" in meta["description"]
104
+
105
+
106
+ class TestParseFrontmatterEdgeCases:
107
+ """Edge-case tests ensuring graceful degradation."""
108
+
109
+ def test_malformed_yaml_missing_close(self):
110
+ assert parse_frontmatter("---\nname: incomplete\n") == {}
111
+
112
+ def test_invalid_yaml_returns_empty(self):
113
+ assert parse_frontmatter("---\n: [invalid yaml{{\n---\n") == {}
114
+
115
+ def test_colon_in_description(self):
116
+ text = '---\nname: test\ndescription: "key: value pair"\n---\n'
117
+ meta = parse_frontmatter(text)
118
+ assert meta["description"] == "key: value pair"
119
+
120
+ def test_quoted_values_stripped(self):
121
+ text = "---\nname: \"quoted-name\"\ndescription: 'single-quoted'\n---\n"
122
+ meta = parse_frontmatter(text)
123
+ assert meta["name"] == "quoted-name"
124
+ assert meta["description"] == "single-quoted"
125
+
126
+ def test_triggers_end_at_next_key(self):
127
+ text = (
128
+ "---\n"
129
+ "triggers:\n"
130
+ " - /a\n"
131
+ " - /b\n"
132
+ "name: after-triggers\n"
133
+ "---\n"
134
+ )
135
+ meta = parse_frontmatter(text)
136
+ assert meta["triggers"] == ["/a", "/b"]
137
+ assert meta["name"] == "after-triggers"
138
+
139
+ def test_null_trigger_skipped(self):
140
+ text = (
141
+ "---\n"
142
+ "triggers:\n"
143
+ " - /cmd\n"
144
+ " -\n"
145
+ " - /other\n"
146
+ "---\n"
147
+ )
148
+ meta = parse_frontmatter(text)
149
+ assert "/cmd" in meta["triggers"]
150
+ assert "/other" in meta["triggers"]
151
+
152
+ def test_frontmatter_only_block(self):
153
+ meta = parse_frontmatter("---\nname: only\n---")
154
+ assert meta["name"] == "only"
155
+
156
+
157
+ # ── slash_triggers ────────────────────────────────────────────────────
158
+
159
+ class TestSlashTriggers:
160
+ def test_filters_slash_only(self):
161
+ meta = {"triggers": ["/init", "keyword", "/build"]}
162
+ assert slash_triggers(meta) == ["/init", "/build"]
163
+
164
+ def test_empty(self):
165
+ assert slash_triggers({}) == []
166
+ assert slash_triggers({"triggers": []}) == []
167
+
168
+
169
+ # ── _entry_type ───────────────────────────────────────────────────────
170
+
171
+ class TestEntryType:
172
+ def test_skills_path(self):
173
+ assert _entry_type("./skills/foo") == "skill"
174
+
175
+ def test_plugins_path(self):
176
+ assert _entry_type("./plugins/bar") == "plugin"
177
+
178
+ def test_relative_plugins(self):
179
+ assert _entry_type("../plugins/baz") == "plugin"
180
+
181
+ def test_relative_skills(self):
182
+ assert _entry_type("../skills/qux") == "skill"
183
+
184
+ def test_fallback_is_skill(self):
185
+ assert _entry_type("./nonexistent-xyz") == "skill"
186
+
187
+
188
+ # ── collect_needed_commands ───────────────────────────────────────────
189
+
190
+ class TestCollectNeededCommands:
191
+ def test_returns_command_specs(self):
192
+ specs = collect_needed_commands()
193
+ assert all(isinstance(s, CommandSpec) for s in specs)
194
+ assert len(specs) > 0
195
+
196
+ def test_paths_are_under_commands_dir(self):
197
+ for spec in collect_needed_commands():
198
+ assert spec.path.parent.name == "commands"
199
+ assert spec.path.suffix == ".md"
200
+
201
+
202
+ # ── load_marketplaces ────────────────────────────────────────────────
203
+
204
+ class TestLoadMarketplaces:
205
+ def test_loads_at_least_one(self):
206
+ mps = load_marketplaces()
207
+ assert len(mps) > 0
208
+
209
+ def test_marketplaces_have_required_fields(self):
210
+ for mp in load_marketplaces():
211
+ assert "plugins" in mp
212
+ assert "_file" in mp
213
+
214
+
215
+ # ── generate_catalog ─────────────────────────────────────────────────
216
+
217
+ class TestGenerateCatalog:
218
+ def test_catalog_contains_marketplace_names(self):
219
+ catalog = generate_catalog()
220
+ assert "openhands-extensions" in catalog
221
+
222
+ def test_catalog_has_table_header(self):
223
+ catalog = generate_catalog()
224
+ assert "| Name | Type | Description | Commands |" in catalog
225
+
226
+ def test_catalog_counts_are_consistent(self):
227
+ catalog = generate_catalog()
228
+ assert "marketplace(s)" in catalog
229
+ assert "extensions" in catalog
230
+
231
+ def test_catalog_only_has_skill_or_plugin_types(self):
232
+ """Every table row's Type column must be 'skill' or 'plugin'."""
233
+ catalog = generate_catalog()
234
+ types_found: list[str] = []
235
+ for line in catalog.splitlines():
236
+ # Skip non-table lines, header, and separator
237
+ if not line.startswith("|") or line.startswith("|---") or "Type" in line:
238
+ continue
239
+ cols = [c.strip() for c in line.split("|")]
240
+ # cols[0] is empty (before first |), cols[1]=Name, cols[2]=Type
241
+ if len(cols) >= 3:
242
+ types_found.append(cols[2])
243
+ assert len(types_found) > 0, "Expected at least one table entry"
244
+ invalid = [t for t in types_found if t not in ("skill", "plugin")]
245
+ assert not invalid, f"Found invalid types in catalog: {invalid}"
246
+
247
+
248
+ # ── sync_commands ────────────────────────────────────────────────────
249
+
250
+ class TestSyncCommands:
251
+ def test_check_mode_reports_no_problems_when_in_sync(self):
252
+ problems = sync_commands(check=True)
253
+ assert problems == [], f"Unexpected problems: {problems}"
254
+
255
+ def test_manually_edited_file_detected_in_check_mode(self, tmp_path, monkeypatch):
256
+ """A command file without the header should be flagged in --check mode."""
257
+ # Create a skill with a slash trigger
258
+ skill_dir = tmp_path / "skills" / "test-skill"
259
+ skill_dir.mkdir(parents=True)
260
+ skill_md = skill_dir / "SKILL.md"
261
+ skill_md.write_text(
262
+ "---\nname: test-skill\ndescription: Test\ntriggers:\n - /test-cmd\n---\nBody\n"
263
+ )
264
+ # Create a manually-edited command file (no header)
265
+ cmd_dir = skill_dir / "commands"
266
+ cmd_dir.mkdir()
267
+ (cmd_dir / "test-cmd.md").write_text("Custom content, no header\n")
268
+
269
+ monkeypatch.setattr("sync_extensions.REPO_ROOT", tmp_path)
270
+ monkeypatch.setattr("sync_extensions.SKILL_DIRS", [tmp_path / "skills"])
271
+ monkeypatch.setattr("sync_extensions._slash_cmd_cache", {})
272
+
273
+ problems = sync_commands(check=True)
274
+ assert any("manually-edited" in p for p in problems)
275
+
276
+
277
+ # ── marketplace source paths ─────────────────────────────────────────
278
+
279
+ class TestMarketplaceSourcePaths:
280
+ def test_all_source_paths_exist(self):
281
+ """Every source path in every marketplace should resolve on disk."""
282
+ import json
283
+
284
+ for mp in load_marketplaces():
285
+ mp_file = mp["_file"]
286
+ for plugin in mp["plugins"]:
287
+ src = plugin.get("source", "")
288
+ resolved = REPO_ROOT / src.lstrip("./")
289
+ assert resolved.exists(), (
290
+ f"{mp_file.name}: {plugin.get('name', '?')} source "
291
+ f"'{src}' does not exist at {resolved}"
292
+ )