@openhands/extensions 0.14.0 → 0.16.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/.release-please-manifest.json +1 -1
- package/README.md +4 -1
- package/automations/README.md +22 -9
- package/automations/index.d.ts +126 -1
- package/automations/interface.json +90 -5
- package/automations/interface.schema.json +267 -7
- package/marketplaces/large-codebase.json +2 -2
- package/marketplaces/openhands-extensions.json +48 -48
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/python/openhands_extensions/_version.py +1 -1
- package/scripts/build-skills-catalog.mjs +76 -3
- package/skills/index.d.ts +21 -0
- package/skills/index.js +109 -52
- package/tests/test_interface_manifest.py +20 -0
- package/tests/test_skills_catalog.py +214 -4
|
@@ -76,6 +76,26 @@ def test_featured_automations_resolve_to_catalog_entries():
|
|
|
76
76
|
constraints={"max": 50}
|
|
77
77
|
),
|
|
78
78
|
),
|
|
79
|
+
(
|
|
80
|
+
"a sub-page the interface does not serve",
|
|
81
|
+
lambda doc: doc["navigation"]["subPages"][0].update(
|
|
82
|
+
page="workflows"
|
|
83
|
+
),
|
|
84
|
+
),
|
|
85
|
+
(
|
|
86
|
+
"an icon outside the host's icon map",
|
|
87
|
+
lambda doc: doc["navigation"]["subPages"][0].update(icon="rocket"),
|
|
88
|
+
),
|
|
89
|
+
(
|
|
90
|
+
"tile copy using a placeholder its metric does not expose",
|
|
91
|
+
lambda doc: doc["pages"]["list"]["overview"]["tiles"][2].update(
|
|
92
|
+
detail="{{active}} runs"
|
|
93
|
+
),
|
|
94
|
+
),
|
|
95
|
+
(
|
|
96
|
+
"a status filter without the all option",
|
|
97
|
+
lambda doc: doc["pages"]["list"]["filters"][0]["options"].pop(0),
|
|
98
|
+
),
|
|
79
99
|
],
|
|
80
100
|
)
|
|
81
101
|
def test_schema_rejects(case, mutation):
|
|
@@ -27,6 +27,13 @@ def run_node(script: str, *, cwd: str | Path = ROOT, check: bool = True) -> subp
|
|
|
27
27
|
)
|
|
28
28
|
|
|
29
29
|
|
|
30
|
+
def empty_marketplaces(tmp_path: Path) -> Path:
|
|
31
|
+
"""Isolates fixtures from the real manifests, so a fixture named after a real skill can't inherit its category."""
|
|
32
|
+
path = tmp_path / "marketplaces"
|
|
33
|
+
path.mkdir()
|
|
34
|
+
return path
|
|
35
|
+
|
|
36
|
+
|
|
30
37
|
# ---------------------------------------------------------------------------
|
|
31
38
|
# parseFrontmatter unit tests (via Node subprocess)
|
|
32
39
|
# ---------------------------------------------------------------------------
|
|
@@ -134,9 +141,11 @@ class TestCodegenEndToEnd:
|
|
|
134
141
|
skill_dir.mkdir()
|
|
135
142
|
(skill_dir / "SKILL.md").write_text(content)
|
|
136
143
|
|
|
144
|
+
markets_dir = empty_marketplaces(tmp_path)
|
|
145
|
+
|
|
137
146
|
script = textwrap.dedent(f"""\
|
|
138
147
|
import {{ buildCatalog }} from './scripts/build-skills-catalog.mjs';
|
|
139
|
-
const entries = buildCatalog({json.dumps(str(skills_dir))});
|
|
148
|
+
const entries = buildCatalog({json.dumps(str(skills_dir))}, {json.dumps(str(markets_dir))});
|
|
140
149
|
process.stdout.write(JSON.stringify(entries));
|
|
141
150
|
""")
|
|
142
151
|
result = run_node(script)
|
|
@@ -173,9 +182,11 @@ class TestCodegenEndToEnd:
|
|
|
173
182
|
(skills_dir / "bad").mkdir()
|
|
174
183
|
(skills_dir / "bad" / "SKILL.md").write_text("No frontmatter here")
|
|
175
184
|
|
|
185
|
+
markets_dir = empty_marketplaces(tmp_path)
|
|
186
|
+
|
|
176
187
|
script = textwrap.dedent(f"""\
|
|
177
188
|
import {{ buildCatalog }} from './scripts/build-skills-catalog.mjs';
|
|
178
|
-
buildCatalog({json.dumps(str(skills_dir))});
|
|
189
|
+
buildCatalog({json.dumps(str(skills_dir))}, {json.dumps(str(markets_dir))});
|
|
179
190
|
""")
|
|
180
191
|
result = run_node(script)
|
|
181
192
|
assert "Warning" in result.stderr
|
|
@@ -198,11 +209,13 @@ class TestCodegenEndToEnd:
|
|
|
198
209
|
f"---\nname: {name}\ndescription: {name} desc\n---\nBody {name}"
|
|
199
210
|
)
|
|
200
211
|
|
|
212
|
+
markets_dir = empty_marketplaces(tmp_path)
|
|
213
|
+
|
|
201
214
|
output = tmp_path / "output.js"
|
|
202
215
|
script = textwrap.dedent(f"""\
|
|
203
216
|
import {{ writeFileSync }} from "node:fs";
|
|
204
217
|
import {{ buildCatalog }} from './scripts/build-skills-catalog.mjs';
|
|
205
|
-
const entries = buildCatalog({json.dumps(str(skills_dir))});
|
|
218
|
+
const entries = buildCatalog({json.dumps(str(skills_dir))}, {json.dumps(str(markets_dir))});
|
|
206
219
|
const src = "export const SKILLS_CATALOG = " + JSON.stringify(entries) + ";\\nexport default SKILLS_CATALOG;\\n";
|
|
207
220
|
writeFileSync({json.dumps(str(output))}, src);
|
|
208
221
|
""")
|
|
@@ -246,9 +259,11 @@ class TestCodegenEndToEnd:
|
|
|
246
259
|
(skills_dir / "has-skill" / "SKILL.md").write_text("---\nname: ok\ndescription: d\n---\nBody")
|
|
247
260
|
(skills_dir / "no-skill").mkdir() # no SKILL.md
|
|
248
261
|
|
|
262
|
+
markets_dir = empty_marketplaces(tmp_path)
|
|
263
|
+
|
|
249
264
|
script = textwrap.dedent(f"""\
|
|
250
265
|
import {{ buildCatalog }} from './scripts/build-skills-catalog.mjs';
|
|
251
|
-
const entries = buildCatalog({json.dumps(str(skills_dir))});
|
|
266
|
+
const entries = buildCatalog({json.dumps(str(skills_dir))}, {json.dumps(str(markets_dir))});
|
|
252
267
|
process.stdout.write(JSON.stringify(entries));
|
|
253
268
|
""")
|
|
254
269
|
result = run_node(script)
|
|
@@ -334,3 +349,198 @@ class TestGeneratedSkillsIndex:
|
|
|
334
349
|
subprocess.run(["node", str(SCRIPT)], cwd=str(ROOT), check=True, capture_output=True)
|
|
335
350
|
after = SKILLS_INDEX.read_text()
|
|
336
351
|
assert before == after, "skills/index.js is out of date — run: node scripts/build-skills-catalog.mjs"
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# ---------------------------------------------------------------------------
|
|
355
|
+
# Marketplace skill categories (consumed by the agent-canvas /skills rail)
|
|
356
|
+
# ---------------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
MARKETPLACES_DIR = ROOT / "marketplaces"
|
|
359
|
+
|
|
360
|
+
SKILL_CATEGORY_IDS = {
|
|
361
|
+
"automations",
|
|
362
|
+
"environment",
|
|
363
|
+
"code-hosting",
|
|
364
|
+
"agent-authoring",
|
|
365
|
+
"code-quality",
|
|
366
|
+
"integrations",
|
|
367
|
+
"writing",
|
|
368
|
+
"design",
|
|
369
|
+
"other",
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
# Skills with no marketplace entry, so they fall back to "other".
|
|
373
|
+
# Adding entries would mean creating .plugin/plugin.json and vendor symlinks (see test_skill_plugin_loading.py), which publishes them as Codex/Claude Code plugins.
|
|
374
|
+
SKILLS_WITHOUT_MARKETPLACE_ENTRY = {"qa-changes", "release-notes"}
|
|
375
|
+
|
|
376
|
+
EXPECTED_CATEGORY_COUNTS = {
|
|
377
|
+
"environment": 10,
|
|
378
|
+
"automations": 9,
|
|
379
|
+
"code-hosting": 8,
|
|
380
|
+
"agent-authoring": 8,
|
|
381
|
+
"code-quality": 6,
|
|
382
|
+
"integrations": 6,
|
|
383
|
+
"writing": 4,
|
|
384
|
+
"design": 2,
|
|
385
|
+
"other": 1,
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _marketplace_skill_categories() -> dict[str, str]:
|
|
390
|
+
"""Map skill directory name -> category, across every marketplace manifest."""
|
|
391
|
+
result: dict[str, str] = {}
|
|
392
|
+
for path in sorted(MARKETPLACES_DIR.glob("*.json")):
|
|
393
|
+
manifest = json.loads(path.read_text())
|
|
394
|
+
for entry in manifest.get("plugins", []):
|
|
395
|
+
source = entry.get("source", "")
|
|
396
|
+
if not source.startswith("./skills/"):
|
|
397
|
+
continue
|
|
398
|
+
result[source.split("/")[-1]] = entry.get("category")
|
|
399
|
+
return result
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
class TestMarketplaceSkillCategories:
|
|
403
|
+
def test_every_skill_entry_uses_a_known_category(self):
|
|
404
|
+
bad = {
|
|
405
|
+
name: category
|
|
406
|
+
for name, category in _marketplace_skill_categories().items()
|
|
407
|
+
if category not in SKILL_CATEGORY_IDS
|
|
408
|
+
}
|
|
409
|
+
assert bad == {}, f"Unknown categories: {bad}"
|
|
410
|
+
|
|
411
|
+
def test_uncovered_skills_are_exactly_the_known_exceptions(self):
|
|
412
|
+
dirs = {
|
|
413
|
+
d.name
|
|
414
|
+
for d in (ROOT / "skills").iterdir()
|
|
415
|
+
if d.is_dir() and not d.name.startswith(".")
|
|
416
|
+
}
|
|
417
|
+
uncovered = dirs - set(_marketplace_skill_categories())
|
|
418
|
+
assert uncovered == SKILLS_WITHOUT_MARKETPLACE_ENTRY
|
|
419
|
+
|
|
420
|
+
def test_category_distribution_is_balanced(self):
|
|
421
|
+
from collections import Counter
|
|
422
|
+
|
|
423
|
+
counts = dict(Counter(_marketplace_skill_categories().values()))
|
|
424
|
+
assert counts == EXPECTED_CATEGORY_COUNTS
|
|
425
|
+
|
|
426
|
+
def test_plugin_entries_keep_their_own_taxonomy(self):
|
|
427
|
+
"""Plugin entries are for Claude Code browsing and must not be rewritten."""
|
|
428
|
+
categories = set()
|
|
429
|
+
for path in sorted(MARKETPLACES_DIR.glob("*.json")):
|
|
430
|
+
manifest = json.loads(path.read_text())
|
|
431
|
+
for entry in manifest.get("plugins", []):
|
|
432
|
+
if entry.get("source", "").startswith("./skills/"):
|
|
433
|
+
continue
|
|
434
|
+
categories.add(entry.get("category"))
|
|
435
|
+
assert categories - SKILL_CATEGORY_IDS, (
|
|
436
|
+
"Plugin entries appear to have been rewritten to the skill taxonomy"
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
class TestCategoryJoin:
|
|
441
|
+
def _build(self, tmp_path, skills: dict[str, str], manifests: dict[str, dict], check: bool = True):
|
|
442
|
+
skills_dir = tmp_path / "skills"
|
|
443
|
+
skills_dir.mkdir()
|
|
444
|
+
for name, content in skills.items():
|
|
445
|
+
(skills_dir / name).mkdir()
|
|
446
|
+
(skills_dir / name / "SKILL.md").write_text(content)
|
|
447
|
+
|
|
448
|
+
markets_dir = tmp_path / "marketplaces"
|
|
449
|
+
markets_dir.mkdir()
|
|
450
|
+
for filename, manifest in manifests.items():
|
|
451
|
+
(markets_dir / filename).write_text(json.dumps(manifest))
|
|
452
|
+
|
|
453
|
+
script = textwrap.dedent(f"""\
|
|
454
|
+
import {{ buildCatalog }} from './scripts/build-skills-catalog.mjs';
|
|
455
|
+
const entries = buildCatalog({json.dumps(str(skills_dir))}, {json.dumps(str(markets_dir))});
|
|
456
|
+
process.stdout.write(JSON.stringify(entries));
|
|
457
|
+
""")
|
|
458
|
+
return run_node(script, check=check)
|
|
459
|
+
|
|
460
|
+
def test_category_is_joined_from_the_manifest(self, tmp_path):
|
|
461
|
+
result = self._build(
|
|
462
|
+
tmp_path,
|
|
463
|
+
{"docker": "---\nname: docker\ndescription: d\n---\nBody"},
|
|
464
|
+
{"m.json": {"plugins": [{"name": "docker", "source": "./skills/docker", "category": "environment"}]}},
|
|
465
|
+
)
|
|
466
|
+
entries = json.loads(result.stdout)
|
|
467
|
+
assert entries[0]["category"] == "environment"
|
|
468
|
+
|
|
469
|
+
def test_plugin_entries_are_ignored_when_building_the_map(self, tmp_path):
|
|
470
|
+
result = self._build(
|
|
471
|
+
tmp_path,
|
|
472
|
+
{"docker": "---\nname: docker\ndescription: d\n---\nBody"},
|
|
473
|
+
{"m.json": {"plugins": [
|
|
474
|
+
{"name": "some-plugin", "source": "./plugins/some-plugin", "category": "utilities"},
|
|
475
|
+
{"name": "docker", "source": "./skills/docker", "category": "environment"},
|
|
476
|
+
]}},
|
|
477
|
+
)
|
|
478
|
+
entries = json.loads(result.stdout)
|
|
479
|
+
assert entries[0]["category"] == "environment"
|
|
480
|
+
|
|
481
|
+
def test_unknown_category_throws_naming_the_skill(self, tmp_path):
|
|
482
|
+
result = self._build(
|
|
483
|
+
tmp_path,
|
|
484
|
+
{"docker": "---\nname: docker\ndescription: d\n---\nBody"},
|
|
485
|
+
{"m.json": {"plugins": [{"name": "docker", "source": "./skills/docker", "category": "code-hostig"}]}},
|
|
486
|
+
check=False,
|
|
487
|
+
)
|
|
488
|
+
assert result.returncode != 0
|
|
489
|
+
assert "docker" in result.stderr
|
|
490
|
+
assert "code-hostig" in result.stderr
|
|
491
|
+
assert "environment" in result.stderr # the legal set is printed
|
|
492
|
+
|
|
493
|
+
def test_conflicting_categories_across_manifests_throws(self, tmp_path):
|
|
494
|
+
result = self._build(
|
|
495
|
+
tmp_path,
|
|
496
|
+
{"docker": "---\nname: docker\ndescription: d\n---\nBody"},
|
|
497
|
+
{
|
|
498
|
+
"a.json": {"plugins": [{"name": "docker", "source": "./skills/docker", "category": "environment"}]},
|
|
499
|
+
"b.json": {"plugins": [{"name": "docker", "source": "./skills/docker", "category": "design"}]},
|
|
500
|
+
},
|
|
501
|
+
check=False,
|
|
502
|
+
)
|
|
503
|
+
assert result.returncode != 0
|
|
504
|
+
assert "docker" in result.stderr
|
|
505
|
+
assert "Conflicting categories" in result.stderr
|
|
506
|
+
assert "environment" in result.stderr # the first manifest's value
|
|
507
|
+
assert "design" in result.stderr # the second manifest's conflicting value
|
|
508
|
+
|
|
509
|
+
def test_skill_without_an_entry_gets_other_and_warns(self, tmp_path):
|
|
510
|
+
result = self._build(
|
|
511
|
+
tmp_path,
|
|
512
|
+
{"lonely": "---\nname: lonely\ndescription: d\n---\nBody"},
|
|
513
|
+
{"m.json": {"plugins": []}},
|
|
514
|
+
)
|
|
515
|
+
entries = json.loads(result.stdout)
|
|
516
|
+
assert entries[0]["category"] == "other"
|
|
517
|
+
assert "lonely" in result.stderr
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
class TestGeneratedCategories:
|
|
521
|
+
def test_every_entry_has_a_known_category(self):
|
|
522
|
+
script = textwrap.dedent("""\
|
|
523
|
+
import { SKILLS_CATALOG, SKILL_CATEGORY_IDS } from './skills/index.js';
|
|
524
|
+
const legal = new Set(SKILL_CATEGORY_IDS);
|
|
525
|
+
for (const entry of SKILLS_CATALOG) {
|
|
526
|
+
if (!legal.has(entry.category)) {
|
|
527
|
+
console.error('Bad category for ' + entry.name + ': ' + entry.category);
|
|
528
|
+
process.exit(1);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
""")
|
|
532
|
+
run_node(script)
|
|
533
|
+
|
|
534
|
+
def test_uncovered_skills_land_in_other(self):
|
|
535
|
+
# flarglebargle is the one skill whose marketplace entry sets "other" on purpose: it is a trigger-testing skill, not a real category member.
|
|
536
|
+
script = textwrap.dedent(f"""\
|
|
537
|
+
import {{ SKILLS_CATALOG }} from './skills/index.js';
|
|
538
|
+
const expected = {json.dumps(sorted(SKILLS_WITHOUT_MARKETPLACE_ENTRY))};
|
|
539
|
+
const actual = SKILLS_CATALOG.filter(e => e.category === 'other').map(e => e.name).sort();
|
|
540
|
+
const extra = actual.filter(n => !expected.includes(n) && n !== 'flarglebargle');
|
|
541
|
+
if (extra.length) {{
|
|
542
|
+
console.error('Unexpected uncategorized skills: ' + extra.join(', '));
|
|
543
|
+
process.exit(1);
|
|
544
|
+
}}
|
|
545
|
+
""")
|
|
546
|
+
run_node(script)
|