@openhands/extensions 0.13.0 → 0.15.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.
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "upstream-fork-sync",
3
+ "version": "1.0.0",
4
+ "description": "Create a cron automation that keeps a long-lived fork synchronized with its upstream. Fetches upstream changes, rebases local customizations on top, verifies the software works, and replaces the running version when the rebase is clean.",
5
+ "author": {
6
+ "name": "OpenHands",
7
+ "email": "contact@all-hands.dev"
8
+ },
9
+ "homepage": "https://github.com/OpenHands/extensions",
10
+ "repository": "https://github.com/OpenHands/extensions",
11
+ "license": "MIT",
12
+ "keywords": [
13
+ "fork",
14
+ "upstream",
15
+ "rebase",
16
+ "sync",
17
+ "cron",
18
+ "automation",
19
+ "long-lived-fork"
20
+ ]
21
+ }
@@ -0,0 +1,38 @@
1
+ # Upstream Fork Sync Skill
2
+
3
+ An OpenHands skill that creates a cron automation to keep a long-lived
4
+ fork synchronized with its upstream source.
5
+
6
+ ## What it does
7
+
8
+ 1. **Fetches** the latest upstream changes for the fork on a schedule.
9
+ 2. **Rebases** the fork's local customizations on top of the newest
10
+ upstream HEAD.
11
+ 3. **Verifies** the software still works (using a configured command or an
12
+ inferred build check).
13
+ 4. **Replaces** the deployed version when verification passes, or
14
+ **fails safe** and reports the problem when it does not.
15
+
16
+ ## When to use
17
+
18
+ Use this skill when a user wants to maintain customizations on top of an
19
+ upstream project over time — the "long-lived fork" pattern — without
20
+ manually redoing the work after every upstream release.
21
+
22
+ ## Files
23
+
24
+ ```
25
+ upstream-fork-sync/
26
+ ├── SKILL.md ← agent instructions (loaded automatically)
27
+ └── README.md ← this file
28
+ ```
29
+
30
+ ## Quick start
31
+
32
+ Just tell OpenHands:
33
+
34
+ > *"Set up an upstream fork sync for `owner/repo`"*
35
+
36
+ The skill will walk through token verification, upstream-remote selection,
37
+ local-changes description, verification command, and schedule, then create
38
+ the automation automatically.
@@ -0,0 +1,122 @@
1
+ ---
2
+ name: upstream-fork-sync
3
+ description: >
4
+ This skill should be used when the user asks to "keep a fork in sync",
5
+ "rebase local changes on upstream", "sync my fork nightly",
6
+ "long-lived fork", or "automate upstream rebases". Guides the user through
7
+ creating a cron automation that fetches upstream changes, rebases local
8
+ customizations on top, verifies the software works, and replaces the
9
+ running version when the rebase is clean.
10
+ triggers:
11
+ - /upstream-fork-sync:setup
12
+ ---
13
+
14
+ # Upstream Fork Sync Automation
15
+
16
+ Create a cron automation that keeps a long-lived fork current with its
17
+ upstream source. On every run it fetches the latest upstream changes,
18
+ rebases the fork's local customizations on top, runs a verification check,
19
+ and replaces the deployed version only when the software still works.
20
+
21
+ This implements the "long-lived fork" pattern: instead of repeatedly
22
+ re-deriving a customization, the local changes are preserved across
23
+ upstream releases and kept working automatically.
24
+
25
+ Windows PowerShell equivalents for the setup, packaging, upload, and API-check
26
+ shell snippets are in `references/windows.md`.
27
+
28
+ ---
29
+
30
+ ## Prerequisites
31
+
32
+ ### Required secret
33
+
34
+ Verify that the following secret is set in **OpenHands Settings -> Secrets**:
35
+
36
+ | Secret name | Token type | Minimum permissions |
37
+ |---|---|---|
38
+ | `GITHUB_PERSONAL_ACCESS_TOKEN` | Classic PAT | `repo` |
39
+ | `GITHUB_PERSONAL_ACCESS_TOKEN` | Fine-grained PAT | Contents: Read and Write, Metadata: Read |
40
+
41
+ Check with:
42
+ ```bash
43
+ curl -s https://api.github.com/user \
44
+ -H "Authorization: Bearer $GITHUB_PERSONAL_ACCESS_TOKEN" \
45
+ | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('login') or d.get('message'))"
46
+ ```
47
+
48
+ If the token is missing or invalid, inform the user and stop.
49
+
50
+ ---
51
+
52
+ ## Setup Workflow
53
+
54
+ Follow these steps in order.
55
+
56
+ ### Step 1 - Verify `GITHUB_PERSONAL_ACCESS_TOKEN`
57
+
58
+ Run the `curl` check above.
59
+
60
+ - If absent: *"GITHUB_PERSONAL_ACCESS_TOKEN is not set. Please add it in
61
+ OpenHands Settings -> Secrets."* Stop.
62
+ - If the API returns `{"message": "Bad credentials"}`: tell the user the
63
+ token is invalid and ask them to update it. Stop.
64
+
65
+ ### Step 2 - Collect configuration
66
+
67
+ Confirm with the user:
68
+
69
+ - **Repository** — the long-lived fork to keep synchronized (owner/repo).
70
+ - **Upstream remote** (optional) — the remote the fork tracks. Defaults to
71
+ the repository's GitHub parent.
72
+ - **Local changes** (optional) — a plain-language description of the
73
+ customizations to preserve across rebase.
74
+ - **Verify command** (optional) — the command that confirms the software
75
+ works (e.g. `make test`). If blank, infer a sensible check from the
76
+ repository's build system.
77
+ - **Sync schedule** — how often to run the sync. Default: nightly (`0 3 * * *`).
78
+
79
+ ### Step 3 - Create the automation
80
+
81
+ Create the automation via the prompt preset:
82
+
83
+ ```bash
84
+ curl -s -X POST "$AUTOMATION_API_URL/v1/preset/prompt" \
85
+ -H "Authorization: Bearer $OPENHANDS_AUTOMATION_API_KEY" \
86
+ -H "Content-Type: application/json" \
87
+ -d '{
88
+ "name": "Upstream fork sync - '"$REPO"'",
89
+ "prompt": "Fetch the latest upstream changes for the fork '"$REPO"' and rebase all local changes on top of upstream. Local changes to preserve: '"$LOCAL_CHANGES"'. Check that the software works as intended; if it does, replace the current version, otherwise leave the running version untouched and report what failed.",
90
+ "repos": [{"url": "'"$REPO"'", "provider": "github"}],
91
+ "trigger": {"type": "cron", "schedule": "'"$SCHEDULE"'", "timezone": "'"$TIMEZONE"'"}
92
+ }'
93
+ ```
94
+
95
+ Confirm the automation was created (HTTP 201) and report its ID to the user.
96
+
97
+ ---
98
+
99
+ ## Runtime behavior
100
+
101
+ On each scheduled run the automation:
102
+
103
+ 1. Clones the fork and fetches the latest from its upstream remote.
104
+ 2. Rebases every local customization commit on top of the newest upstream
105
+ HEAD, resolving conflicts in favor of the local changes where the
106
+ description indicates intent.
107
+ 3. Runs the verification command. If none was supplied, infers one from the
108
+ repo's build system (e.g. `make test`, `npm test`, `pytest`).
109
+ 4. On success, force-pushes the rebased branch and replaces the currently
110
+ deployed version with the freshly built one.
111
+ 5. On failure, leaves the running version untouched and reports the conflict
112
+ or failing check so a human can intervene.
113
+
114
+ ---
115
+
116
+ ## Notes
117
+
118
+ - The automation is idempotent: a clean upstream with no new commits is a
119
+ no-op.
120
+ - Force-push targets the fork's working branch only, never upstream.
121
+ - If a rebase conflict cannot be resolved automatically, the run fails safe
122
+ and the previously deployed version keeps running.
@@ -0,0 +1,8 @@
1
+ ---
2
+ # auto-generated by sync_extensions.py
3
+ description: This skill should be used when the user asks to "keep a fork in sync", "rebase local changes on upstream", "sync my fork nightly", "long-lived fork", or "automate upstream rebases". Guides the user through creating a cron automation that fetches upstream changes, rebases local customizations on top, verifies the software works, and replaces the running version when the rebase is clean.
4
+ ---
5
+
6
+ Read and follow the complete instructions in the SKILL.md file located in this skill's directory.
7
+
8
+ $ARGUMENTS
@@ -0,0 +1,223 @@
1
+ {
2
+ "automationId": "upstream-fork-sync",
3
+ "description": "Direct scheduled setup. Every create request body below is verified against the live CreatePromptAutomationRequest model in OpenHands/automation (openhands/automation/preset_router.py).",
4
+ "capabilities": "supported",
5
+ "blockedBy": [
6
+ "notReady"
7
+ ],
8
+ "scenarios": [
9
+ {
10
+ "id": "happy-path",
11
+ "description": "Defaults plus a repository, a description of the local changes, and a verify command. Preflight passes, the automation is created.",
12
+ "formValues": {
13
+ "repository": "OpenHands/agent-server-gui",
14
+ "upstreamRemote": "",
15
+ "localChanges": "high-contrast UI theme",
16
+ "verifyCommand": "make test",
17
+ "schedule": "0 3 * * *",
18
+ "timezone": "UTC"
19
+ },
20
+ "preflight": {
21
+ "request": {
22
+ "method": "POST",
23
+ "path": "/v1/validate",
24
+ "body": {
25
+ "automationId": "upstream-fork-sync",
26
+ "endpoint": "/v1/preset/prompt",
27
+ "draft": {
28
+ "name": "Upstream fork sync - OpenHands/agent-server-gui",
29
+ "prompt": "Fetch the latest upstream changes for the fork OpenHands/agent-server-gui and rebase all local changes on top of upstream. Local changes to preserve: high-contrast UI theme. Check that the software works as intended; if it does, replace the current version, otherwise leave the running version untouched and report what failed.",
30
+ "repos": [
31
+ {
32
+ "url": "OpenHands/agent-server-gui",
33
+ "provider": "github"
34
+ }
35
+ ],
36
+ "trigger": {
37
+ "type": "cron",
38
+ "schedule": "0 3 * * *",
39
+ "timezone": "UTC"
40
+ }
41
+ }
42
+ }
43
+ },
44
+ "response": {
45
+ "status": 200,
46
+ "body": {
47
+ "valid": true,
48
+ "errors": []
49
+ }
50
+ }
51
+ },
52
+ "create": {
53
+ "request": {
54
+ "method": "POST",
55
+ "path": "/v1/preset/prompt",
56
+ "body": {
57
+ "name": "Upstream fork sync - OpenHands/agent-server-gui",
58
+ "prompt": "Fetch the latest upstream changes for the fork OpenHands/agent-server-gui and rebase all local changes on top of upstream. Local changes to preserve: high-contrast UI theme. Check that the software works as intended; if it does, replace the current version, otherwise leave the running version untouched and report what failed.",
59
+ "repos": [
60
+ {
61
+ "url": "OpenHands/agent-server-gui",
62
+ "provider": "github"
63
+ }
64
+ ],
65
+ "trigger": {
66
+ "type": "cron",
67
+ "schedule": "0 3 * * *",
68
+ "timezone": "UTC"
69
+ }
70
+ }
71
+ },
72
+ "response": {
73
+ "status": 201,
74
+ "body": {
75
+ "id": "9a2c7b15-3c4d-4e5f-8a1b-4c9d0e1f2a33",
76
+ "user_id": "1a2b3c4d-0000-4000-8000-000000000001",
77
+ "org_id": "1a2b3c4d-0000-4000-8000-000000000002",
78
+ "model": null,
79
+ "name": "Upstream fork sync - OpenHands/agent-server-gui",
80
+ "prompt": "Fetch the latest upstream changes for the fork OpenHands/agent-server-gui and rebase all local changes on top of upstream. Local changes to preserve: high-contrast UI theme. Check that the software works as intended; if it does, replace the current version, otherwise leave the running version untouched and report what failed.",
81
+ "trigger": {
82
+ "type": "cron",
83
+ "schedule": "0 3 * * *",
84
+ "timezone": "UTC"
85
+ },
86
+ "tarball_path": "oh-internal://uploads/7e1b2c30-9f23-5b66-c2e3-7df1b4c5a990",
87
+ "setup_script_path": "setup.sh",
88
+ "entrypoint": ".venv/bin/python main.py",
89
+ "timeout": 600,
90
+ "keep_alive": null,
91
+ "enabled": true,
92
+ "last_triggered_at": null,
93
+ "created_at": "2026-08-03T12:00:00Z",
94
+ "updated_at": "2026-08-03T12:00:00Z"
95
+ }
96
+ }
97
+ }
98
+ },
99
+ {
100
+ "id": "interval-below-deployment-minimum",
101
+ "description": "A one-minute schedule. The create model accepts it, so only the deployment can reject it - this is precisely why preflight exists and why local validation is not authoritative.",
102
+ "formValues": {
103
+ "repository": "OpenHands/agent-server-gui",
104
+ "upstreamRemote": "",
105
+ "localChanges": "high-contrast UI theme",
106
+ "verifyCommand": "make test",
107
+ "schedule": "*/1 * * * *",
108
+ "timezone": "UTC"
109
+ },
110
+ "preflight": {
111
+ "request": {
112
+ "method": "POST",
113
+ "path": "/v1/validate",
114
+ "body": {
115
+ "automationId": "upstream-fork-sync",
116
+ "endpoint": "/v1/preset/prompt",
117
+ "draft": {
118
+ "name": "Upstream fork sync - OpenHands/agent-server-gui",
119
+ "prompt": "Fetch the latest upstream changes for the fork OpenHands/agent-server-gui and rebase all local changes on top of upstream. Local changes to preserve: high-contrast UI theme. Check that the software works as intended; if it does, replace the current version, otherwise leave the running version untouched and report what failed.",
120
+ "repos": [
121
+ {
122
+ "url": "OpenHands/agent-server-gui",
123
+ "provider": "github"
124
+ }
125
+ ],
126
+ "trigger": {
127
+ "type": "cron",
128
+ "schedule": "*/1 * * * *",
129
+ "timezone": "UTC"
130
+ }
131
+ }
132
+ }
133
+ },
134
+ "response": {
135
+ "status": 200,
136
+ "body": {
137
+ "valid": false,
138
+ "errors": [
139
+ {
140
+ "field": "trigger.schedule",
141
+ "code": "interval_too_short",
142
+ "message": "Minimum interval for this deployment is 5 minutes."
143
+ }
144
+ ]
145
+ }
146
+ }
147
+ },
148
+ "expectedFieldErrors": {
149
+ "schedule": "Minimum interval for this deployment is 5 minutes."
150
+ }
151
+ },
152
+ {
153
+ "id": "unfireable-cron-rejected-at-create",
154
+ "description": "31 February. Syntactically valid, so a local check passes, but the service rejects it. Shows the real 422 body: detail is a list and the loc path carries the trigger discriminator tag.",
155
+ "formValues": {
156
+ "repository": "OpenHands/agent-server-gui",
157
+ "upstreamRemote": "",
158
+ "localChanges": "high-contrast UI theme",
159
+ "verifyCommand": "make test",
160
+ "schedule": "0 0 31 2 *",
161
+ "timezone": "UTC"
162
+ },
163
+ "create": {
164
+ "request": {
165
+ "method": "POST",
166
+ "path": "/v1/preset/prompt",
167
+ "body": {
168
+ "name": "Upstream fork sync - OpenHands/agent-server-gui",
169
+ "prompt": "Fetch the latest upstream changes for the fork OpenHands/agent-server-gui and rebase all local changes on top of upstream. Local changes to preserve: high-contrast UI theme. Check that the software works as intended; if it does, replace the current version, otherwise leave the running version untouched and report what failed.",
170
+ "repos": [
171
+ {
172
+ "url": "OpenHands/agent-server-gui",
173
+ "provider": "github"
174
+ }
175
+ ],
176
+ "trigger": {
177
+ "type": "cron",
178
+ "schedule": "0 0 31 2 *",
179
+ "timezone": "UTC"
180
+ }
181
+ }
182
+ },
183
+ "response": {
184
+ "status": 422,
185
+ "body": {
186
+ "detail": [
187
+ {
188
+ "type": "value_error",
189
+ "loc": [
190
+ "body",
191
+ "trigger",
192
+ "cron",
193
+ "schedule"
194
+ ],
195
+ "msg": "Value error, Cron expression cannot produce any future fire times: 0 0 31 2 *"
196
+ }
197
+ ]
198
+ }
199
+ }
200
+ },
201
+ "expectedFieldErrors": {
202
+ "schedule": "Value error, Cron expression cannot produce any future fire times: 0 0 31 2 *"
203
+ }
204
+ },
205
+ {
206
+ "id": "fallback-conversation-when-direct-unavailable",
207
+ "description": "A cron-only deployment cannot run a webhook trigger, but this entry is cron-only and so runs directly; the assisted message seeds the conversation when the deployment is not ready.",
208
+ "formValues": {
209
+ "repository": "OpenHands/agent-server-gui",
210
+ "upstreamRemote": "",
211
+ "localChanges": "high-contrast UI theme",
212
+ "verifyCommand": "make test",
213
+ "schedule": "0 3 * * *",
214
+ "timezone": "UTC"
215
+ },
216
+ "conversation": {
217
+ "request": {
218
+ "message": "This deployment cannot run the scheduled fork-sync automation directly. Set it up in this conversation instead: confirm the fork repository, the upstream remote, the local changes to preserve, the verification command, and the nightly schedule, then create the automation."
219
+ }
220
+ }
221
+ }
222
+ ]
223
+ }
@@ -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)