@openhands/extensions 0.2.0 → 0.4.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 (44) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/README.md +3 -2
  3. package/automations/catalog/github-pr-reviewer.json +1 -1
  4. package/automations/catalog/github-repo-monitor.json +1 -1
  5. package/index.d.ts +2 -0
  6. package/index.js +1 -0
  7. package/marketplaces/openhands-extensions.json +13 -0
  8. package/package.json +9 -1
  9. package/plugins/issue-duplicate-checker/action.yml +2 -2
  10. package/plugins/pr-review/README.md +1 -1
  11. package/plugins/qa-changes/action.yml +4 -2
  12. package/plugins/release-notes/action.yml +3 -2
  13. package/plugins/release-notes/workflows/release-notes.yml +10 -4
  14. package/plugins/vulnerability-remediation/README.md +4 -2
  15. package/plugins/vulnerability-remediation/action.yml +8 -4
  16. package/pyproject.toml +1 -1
  17. package/scripts/build-skills-catalog.mjs +100 -0
  18. package/scripts/sync_extensions.py +2 -1
  19. package/skills/github-actions/.plugin/plugin.json +19 -0
  20. package/skills/github-actions/README.md +190 -0
  21. package/skills/github-actions/SKILL.md +44 -0
  22. package/skills/github-pr-review/SKILL.md +40 -2
  23. package/skills/github-repo-monitor/README.md +1 -1
  24. package/skills/github-repo-monitor/SKILL.md +9 -9
  25. package/skills/github-repo-monitor/references/github-api.md +3 -3
  26. package/skills/github-repo-monitor/scripts/main.py +11 -11
  27. package/skills/index.d.ts +11 -0
  28. package/skills/index.js +477 -0
  29. package/skills/openhands-sdk/SKILL.md +1 -0
  30. package/skills/slack-channel-monitor/README.md +2 -1
  31. package/skills/slack-channel-monitor/SKILL.md +22 -4
  32. package/tests/test_github_pr_review_skill.py +114 -0
  33. package/tests/test_skills_catalog.py +336 -0
  34. package/tests/test_sync_extensions.py +14 -0
  35. /package/plugins/issue-duplicate-checker/scripts/{post_duplicate_notice.js → post_duplicate_notice.cjs} +0 -0
  36. /package/plugins/issue-duplicate-checker/scripts/{remove_duplicate_candidate_label.js → remove_duplicate_candidate_label.cjs} +0 -0
  37. /package/skills/github-pr-reviewer/commands/{pr-reviewer:setup.md → pr-reviewer-setup.md} +0 -0
  38. /package/skills/github-repo-monitor/commands/{github-monitor:poll.md → github-monitor-poll.md} +0 -0
  39. /package/skills/incident-retrospective/commands/{incident-retro:setup.md → incident-retro-setup.md} +0 -0
  40. /package/skills/linear-triage/commands/{linear-triage:setup.md → linear-triage-setup.md} +0 -0
  41. /package/skills/openhands-automation/commands/{automation:create.md → automation-create.md} +0 -0
  42. /package/skills/research-brief/commands/{research-brief:setup.md → research-brief-setup.md} +0 -0
  43. /package/skills/slack-channel-monitor/commands/{slack-monitor:poll.md → slack-monitor-poll.md} +0 -0
  44. /package/skills/slack-standup-digest/commands/{standup-digest:setup.md → standup-digest-setup.md} +0 -0
@@ -189,6 +189,7 @@ Source: [`examples/`](https://github.com/OpenHands/software-agent-sdk/tree/main/
189
189
  - [`49_switch_llm_tool.py`](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/49_switch_llm_tool.py)
190
190
  - [`50_async_cancellation.py`](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/50_async_cancellation.py)
191
191
  - [`51_agent_hooks`](https://github.com/OpenHands/software-agent-sdk/tree/main/examples/01_standalone_sdk/51_agent_hooks)
192
+ - [`52_dynamic_workflow.py`](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/52_dynamic_workflow.py)
192
193
 
193
194
  ### [`02_remote_agent_server/`](https://github.com/OpenHands/software-agent-sdk/tree/main/examples/02_remote_agent_server)
194
195
 
@@ -66,7 +66,8 @@ The skill will:
66
66
  1. Verify your Slack token is available
67
67
  2. Resolve channel names to IDs
68
68
  3. Confirm the trigger phrase (or use the default `@openhands`)
69
- 4. Generate and upload a customised automation script
69
+ 4. Generate and upload a customised automation script by copying the template
70
+ and changing only the configuration constants
70
71
  5. Create the automation with cron schedule `* * * * *`
71
72
 
72
73
  ## How It Works
@@ -119,8 +119,13 @@ Accepted values: any non-empty string unlikely to appear accidentally, e.g.
119
119
 
120
120
  ### Step 3 - Generate the automation script
121
121
 
122
- Read `scripts/main.py` from this skill's directory. Apply exactly three
123
- constant substitutions near the top of the file:
122
+ Read `scripts/main.py` from this skill's directory and **copy it verbatim**.
123
+ Apply exactly three constant substitutions near the top of the file:
124
+
125
+ > **Do not reimplement, simplify, or hand-write a replacement script.**
126
+ > The template already contains the correct secret-loading, state-path,
127
+ > conversation-creation, and context-forwarding logic. Only the three
128
+ > configuration constants below should change unless syntax validation fails.
124
129
 
125
130
  | Placeholder | Replace with |
126
131
  |---|---|
@@ -131,7 +136,8 @@ constant substitutions near the top of the file:
131
136
  Write the customised script to a temporary directory:
132
137
  ```bash
133
138
  mkdir -p /tmp/slack-monitor-build
134
- # (write the customised main.py to /tmp/slack-monitor-build/main.py)
139
+ # copy scripts/main.py to /tmp/slack-monitor-build/main.py
140
+ # then replace only the three constants above
135
141
  ```
136
142
 
137
143
  Validate syntax before packaging:
@@ -139,7 +145,19 @@ Validate syntax before packaging:
139
145
  python3 -m py_compile /tmp/slack-monitor-build/main.py && echo "Syntax OK"
140
146
  ```
141
147
 
142
- Fix any syntax errors before proceeding.
148
+ Then run a quick integrity check to confirm the template structure is still
149
+ present and only the configuration block was customised:
150
+ ```bash
151
+ grep -n 'TRIGGER_PHRASE = "' /tmp/slack-monitor-build/main.py
152
+ grep -n 'CHANNEL_IDS: list\[str\] =' /tmp/slack-monitor-build/main.py
153
+ grep -n 'DEFAULT_OPENHANDS_URL = "' /tmp/slack-monitor-build/main.py
154
+ grep -n 'def get_secret' /tmp/slack-monitor-build/main.py
155
+ grep -n 'def _state_file_path' /tmp/slack-monitor-build/main.py
156
+ grep -n 'def create_conversation' /tmp/slack-monitor-build/main.py
157
+ ```
158
+
159
+ If any of those checks fail, stop and re-copy the template instead of trying to
160
+ repair a hand-written variant.
143
161
 
144
162
  ### Step 4 - Package and upload
145
163
 
@@ -0,0 +1,114 @@
1
+ """Tests for the github-pr-review skill content.
2
+
3
+ These tests guard the guidance the agent reads when it builds a GitHub review
4
+ with inline suggestions. The skill must teach the agent how GitHub
5
+ ``suggestion`` blocks actually behave (range replacement), so accepted
6
+ suggestions don't silently duplicate or delete lines, and so the prose
7
+ description always matches the resulting change.
8
+
9
+ See: https://github.com/OpenHands/extensions/issues/292
10
+ """
11
+
12
+ from pathlib import Path
13
+
14
+ import pytest
15
+
16
+ SKILL_PATH = (
17
+ Path(__file__).parent.parent / "skills" / "github-pr-review" / "SKILL.md"
18
+ )
19
+
20
+
21
+ @pytest.fixture(scope="module")
22
+ def skill_text() -> str:
23
+ return SKILL_PATH.read_text(encoding="utf-8")
24
+
25
+
26
+ def test_skill_file_exists():
27
+ assert SKILL_PATH.is_file(), f"missing skill file: {SKILL_PATH}"
28
+
29
+
30
+ def test_skill_does_not_claim_suggestion_must_match_range_length(skill_text: str):
31
+ """Regression: the old "same number of lines as the range" rule was wrong.
32
+
33
+ GitHub replaces the targeted range with the suggestion body regardless of
34
+ how many lines the body contains. Telling the agent the counts must match
35
+ pushed it to either delete lines (when shrinking a block) or duplicate
36
+ lines (when expanding one), depending on which side it adjusted to satisfy
37
+ the bogus rule. The skill must no longer state that constraint.
38
+ """
39
+ forbidden_phrases = [
40
+ "suggestion must have the same number of lines as the range",
41
+ "must have the same number of lines as the range",
42
+ ]
43
+ for phrase in forbidden_phrases:
44
+ assert phrase not in skill_text, (
45
+ f"SKILL.md still contains the misleading rule {phrase!r}; "
46
+ "GitHub suggestions replace the range with any number of lines."
47
+ )
48
+
49
+
50
+ def test_skill_explains_replace_semantics(skill_text: str):
51
+ """The skill must say in plain language that suggestions REPLACE the range."""
52
+ assert "How Suggestions Actually Work" in skill_text
53
+ # Mention replacement explicitly so the agent does not treat the suggestion
54
+ # body as "extra lines added on top of the original".
55
+ lowered = skill_text.lower()
56
+ assert "replace" in lowered
57
+ assert "any number of lines" in lowered
58
+
59
+
60
+ def test_skill_covers_add_remove_change_patterns(skill_text: str):
61
+ """The skill should give explicit recipes for the three intents that
62
+ most commonly produced broken suggestions: changing, adding, and
63
+ deleting lines. Without these, the agent invents wrong patterns.
64
+ """
65
+ for keyword in ("Change line", "Add", "Delete"):
66
+ assert keyword in skill_text, (
67
+ f"SKILL.md is missing guidance for intent {keyword!r}; "
68
+ "see issue #292."
69
+ )
70
+
71
+
72
+ def test_skill_warns_about_duplicated_and_deleted_lines(skill_text: str):
73
+ """The skill must call out the two failure modes from issue #292:
74
+ accepted suggestions that duplicate or remove lines."""
75
+ lowered = skill_text.lower()
76
+ assert "duplicat" in lowered, (
77
+ "SKILL.md should warn about suggestions that duplicate lines."
78
+ )
79
+ # "disappear"/"disappearing" is unique to the failure-mode description in
80
+ # the "Common Mistakes" section. The generic terms "delete" and "remove"
81
+ # also appear in the recipe table for intentional deletions, so checking
82
+ # for those would let the failure-mode guidance be removed silently.
83
+ assert "disappear" in lowered, (
84
+ "SKILL.md should warn about suggestions that silently drop lines "
85
+ "(see 'Disappearing lines' under Common Mistakes)."
86
+ )
87
+
88
+
89
+ def test_skill_requires_description_to_match_suggestion(skill_text: str):
90
+ """The skill must explicitly require the prose description to match the
91
+ actual code change in the suggestion block (the third bug from #292)."""
92
+ lowered = skill_text.lower()
93
+ assert any(phrase in lowered for phrase in ("description does not match", "prose description", "description must match"))
94
+ # The text should connect the description to the resulting code change.
95
+ assert (
96
+ "match" in lowered
97
+ ), "SKILL.md should say the description must match the change."
98
+
99
+
100
+ def test_skill_has_pre_post_verification_step(skill_text: str):
101
+ """Before posting, the agent must verify the suggestion against the file."""
102
+ assert "Mandatory Verification" in skill_text or "Verification" in skill_text
103
+ # Should point the agent at the real file content, not just the diff.
104
+ assert "sed -n" in skill_text or "read the actual file" in skill_text.lower()
105
+
106
+
107
+ def test_skill_offers_prose_fallback(skill_text: str):
108
+ """When unsure, the agent should drop the suggestion block and use prose
109
+ instead — a correct comment beats a broken one-click fix."""
110
+ lowered = skill_text.lower()
111
+ assert "prose" in lowered, (
112
+ "SKILL.md should tell the agent to fall back to a prose comment "
113
+ "when the suggestion cannot be verified."
114
+ )
@@ -0,0 +1,336 @@
1
+ """Tests for the skills catalog codegen (scripts/build-skills-catalog.mjs).
2
+
3
+ Covers:
4
+ - parseFrontmatter edge cases (hyphenated keys, multiline, list items, etc.)
5
+ - End-to-end buildCatalog against temp fixtures
6
+ - Generated skills/index.js validity and structure
7
+ - Determinism: re-running the script produces identical output
8
+ """
9
+
10
+ import json
11
+ import subprocess
12
+ import textwrap
13
+ from pathlib import Path
14
+
15
+ ROOT = Path(__file__).resolve().parents[1]
16
+ SCRIPT = ROOT / "scripts" / "build-skills-catalog.mjs"
17
+ SKILLS_INDEX = ROOT / "skills" / "index.js"
18
+
19
+
20
+ def run_node(script: str, *, cwd: str | Path = ROOT, check: bool = True) -> subprocess.CompletedProcess:
21
+ return subprocess.run(
22
+ ["node", "--input-type=module", "-e", script],
23
+ cwd=str(cwd),
24
+ capture_output=True,
25
+ text=True,
26
+ check=check,
27
+ )
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # parseFrontmatter unit tests (via Node subprocess)
32
+ # ---------------------------------------------------------------------------
33
+
34
+ class TestParseFrontmatter:
35
+ """Unit tests for parseFrontmatter, exercised via Node import."""
36
+
37
+ def _parse(self, frontmatter_body: str) -> dict:
38
+ """Run parseFrontmatter on *frontmatter_body* and return the result."""
39
+ escaped = json.dumps(frontmatter_body)
40
+ script = textwrap.dedent(f"""\
41
+ import {{ parseFrontmatter }} from './scripts/build-skills-catalog.mjs';
42
+ const result = parseFrontmatter({escaped});
43
+ process.stdout.write(JSON.stringify(result));
44
+ """)
45
+ result = run_node(script)
46
+ return json.loads(result.stdout)
47
+
48
+ def test_simple_key_value(self):
49
+ fm = self._parse("name: my-skill\ndescription: A test skill")
50
+ assert fm["name"] == "my-skill"
51
+ assert fm["description"] == "A test skill"
52
+
53
+ def test_hyphenated_keys(self):
54
+ fm = self._parse("name: my-skill\ncompatibility: works-everywhere")
55
+ assert fm["compatibility"] == "works-everywhere"
56
+
57
+ def test_hyphenated_key_not_silently_dropped(self):
58
+ """Keys like event-key: should be parsed, not ignored."""
59
+ # We verify indirectly: if a hyphenated key precedes a list,
60
+ # the list items must be collected under that key.
61
+ script = textwrap.dedent("""\
62
+ import { parseFrontmatter } from './scripts/build-skills-catalog.mjs';
63
+ // parseFrontmatter returns a fixed shape, so test the internal
64
+ // parsing by checking a known hyphenated output key.
65
+ const fm = parseFrontmatter("license: MIT");
66
+ // Also verify a raw parse captures hyphenated keys internally
67
+ // by re-implementing a quick check:
68
+ const raw = "event-key: hello";
69
+ const match = raw.match(/^[\\w-]+:\\s*(.*)/);
70
+ if (!match) { console.error("Regex failed for hyphenated key"); process.exit(1); }
71
+ process.stdout.write("ok");
72
+ """)
73
+ result = run_node(script)
74
+ assert result.stdout == "ok"
75
+
76
+ def test_list_items(self):
77
+ fm = self._parse("name: test\ntriggers:\n- github\n- git\n- pull request")
78
+ assert fm["triggers"] == ["github", "git", "pull request"]
79
+
80
+ def test_empty_triggers_when_scalar(self):
81
+ fm = self._parse("name: test\ntriggers: not-a-list")
82
+ assert fm["triggers"] == []
83
+
84
+ def test_multiline_description_folded(self):
85
+ fm = self._parse("name: test\ndescription: >\n This is a long\n description text")
86
+ assert "This is a long" in fm["description"]
87
+ assert "description text" in fm["description"]
88
+
89
+ def test_multiline_description_literal(self):
90
+ fm = self._parse("name: test\ndescription: |\n Line one\n Line two")
91
+ assert "Line one" in fm["description"]
92
+ assert "Line two" in fm["description"]
93
+
94
+ def test_missing_name_returns_empty(self):
95
+ fm = self._parse("description: no name here")
96
+ assert fm["name"] == ""
97
+
98
+ def test_missing_description_returns_empty(self):
99
+ fm = self._parse("name: test")
100
+ assert fm["description"] == ""
101
+ assert fm["triggers"] == []
102
+
103
+ def test_optional_fields_omitted_when_absent(self):
104
+ fm = self._parse("name: test\ndescription: desc")
105
+ assert "license" not in fm
106
+ assert "compatibility" not in fm
107
+
108
+ def test_optional_fields_present_when_set(self):
109
+ fm = self._parse("name: test\nlicense: MIT\ncompatibility: all platforms")
110
+ assert fm["license"] == "MIT"
111
+ assert fm["compatibility"] == "all platforms"
112
+
113
+ def test_empty_input(self):
114
+ fm = self._parse("")
115
+ assert fm["name"] == ""
116
+ assert fm["description"] == ""
117
+ assert fm["triggers"] == []
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # End-to-end codegen tests with temp fixtures (using buildCatalog)
122
+ # ---------------------------------------------------------------------------
123
+
124
+ class TestCodegenEndToEnd:
125
+ """Run buildCatalog against temp SKILL.md fixtures."""
126
+
127
+ def _run_codegen(self, tmp_path: Path, skills: dict[str, str]) -> list[dict]:
128
+ """Create temp skill dirs, run buildCatalog, return parsed catalog."""
129
+ skills_dir = tmp_path / "skills"
130
+ skills_dir.mkdir()
131
+
132
+ for name, content in skills.items():
133
+ skill_dir = skills_dir / name
134
+ skill_dir.mkdir()
135
+ (skill_dir / "SKILL.md").write_text(content)
136
+
137
+ script = textwrap.dedent(f"""\
138
+ import {{ buildCatalog }} from './scripts/build-skills-catalog.mjs';
139
+ const entries = buildCatalog({json.dumps(str(skills_dir))});
140
+ process.stdout.write(JSON.stringify(entries));
141
+ """)
142
+ result = run_node(script)
143
+ return json.loads(result.stdout)
144
+
145
+ def test_basic_skill(self, tmp_path):
146
+ entries = self._run_codegen(tmp_path, {
147
+ "my-skill": "---\nname: my-skill\ndescription: A skill\ntriggers:\n- test\n---\nBody content here"
148
+ })
149
+ assert len(entries) == 1
150
+ assert entries[0]["name"] == "my-skill"
151
+ assert entries[0]["description"] == "A skill"
152
+ assert entries[0]["triggers"] == ["test"]
153
+ assert entries[0]["content"] == "Body content here"
154
+
155
+ def test_name_falls_back_to_directory(self, tmp_path):
156
+ entries = self._run_codegen(tmp_path, {
157
+ "fallback-dir": "---\ndescription: no name field\n---\nBody"
158
+ })
159
+ assert entries[0]["name"] == "fallback-dir"
160
+
161
+ def test_missing_frontmatter_skipped(self, tmp_path):
162
+ entries = self._run_codegen(tmp_path, {
163
+ "good-skill": "---\nname: good\ndescription: yes\n---\nBody",
164
+ "bad-skill": "No frontmatter delimiters at all",
165
+ })
166
+ assert len(entries) == 1
167
+ assert entries[0]["name"] == "good"
168
+
169
+ def test_missing_frontmatter_warns(self, tmp_path):
170
+ """SKILL.md without frontmatter should produce a stderr warning."""
171
+ skills_dir = tmp_path / "skills"
172
+ skills_dir.mkdir()
173
+ (skills_dir / "bad").mkdir()
174
+ (skills_dir / "bad" / "SKILL.md").write_text("No frontmatter here")
175
+
176
+ script = textwrap.dedent(f"""\
177
+ import {{ buildCatalog }} from './scripts/build-skills-catalog.mjs';
178
+ buildCatalog({json.dumps(str(skills_dir))});
179
+ """)
180
+ result = run_node(script)
181
+ assert "Warning" in result.stderr
182
+ assert "missing frontmatter" in result.stderr
183
+
184
+ def test_body_with_triple_dashes_preserved(self, tmp_path):
185
+ content = "---\nname: test\ndescription: d\n---\nBefore\n---\nAfter"
186
+ entries = self._run_codegen(tmp_path, {"test-skill": content})
187
+ assert "---" in entries[0]["content"]
188
+ assert "Before" in entries[0]["content"]
189
+ assert "After" in entries[0]["content"]
190
+
191
+ def test_generated_file_is_valid_js(self, tmp_path):
192
+ """Write a catalog to a file and verify it's importable as JS."""
193
+ skills_dir = tmp_path / "skills"
194
+ skills_dir.mkdir()
195
+ for name in ("alpha", "beta"):
196
+ (skills_dir / name).mkdir()
197
+ (skills_dir / name / "SKILL.md").write_text(
198
+ f"---\nname: {name}\ndescription: {name} desc\n---\nBody {name}"
199
+ )
200
+
201
+ output = tmp_path / "output.js"
202
+ script = textwrap.dedent(f"""\
203
+ import {{ writeFileSync }} from "node:fs";
204
+ import {{ buildCatalog }} from './scripts/build-skills-catalog.mjs';
205
+ const entries = buildCatalog({json.dumps(str(skills_dir))});
206
+ const src = "export const SKILLS_CATALOG = " + JSON.stringify(entries) + ";\\nexport default SKILLS_CATALOG;\\n";
207
+ writeFileSync({json.dumps(str(output))}, src);
208
+ """)
209
+ run_node(script)
210
+
211
+ verify = textwrap.dedent(f"""\
212
+ const mod = await import({json.dumps(str(output))});
213
+ if (!Array.isArray(mod.SKILLS_CATALOG)) process.exit(1);
214
+ if (mod.SKILLS_CATALOG.length !== 2) process.exit(1);
215
+ if (mod.default !== mod.SKILLS_CATALOG) process.exit(1);
216
+ """)
217
+ run_node(verify)
218
+
219
+ def test_entries_sorted_by_directory_name(self, tmp_path):
220
+ entries = self._run_codegen(tmp_path, {
221
+ "zebra": "---\nname: zebra\ndescription: z\n---\nZ",
222
+ "alpha": "---\nname: alpha\ndescription: a\n---\nA",
223
+ "middle": "---\nname: middle\ndescription: m\n---\nM",
224
+ })
225
+ names = [e["name"] for e in entries]
226
+ assert names == ["alpha", "middle", "zebra"]
227
+
228
+ def test_optional_fields_included_when_present(self, tmp_path):
229
+ entries = self._run_codegen(tmp_path, {
230
+ "lic": "---\nname: lic\ndescription: d\nlicense: MIT\ncompatibility: all\n---\nBody"
231
+ })
232
+ assert entries[0]["license"] == "MIT"
233
+ assert entries[0]["compatibility"] == "all"
234
+
235
+ def test_optional_fields_omitted_when_absent(self, tmp_path):
236
+ entries = self._run_codegen(tmp_path, {
237
+ "bare": "---\nname: bare\ndescription: d\n---\nBody"
238
+ })
239
+ assert "license" not in entries[0]
240
+ assert "compatibility" not in entries[0]
241
+
242
+ def test_directories_without_skill_md_ignored(self, tmp_path):
243
+ skills_dir = tmp_path / "skills"
244
+ skills_dir.mkdir()
245
+ (skills_dir / "has-skill").mkdir()
246
+ (skills_dir / "has-skill" / "SKILL.md").write_text("---\nname: ok\ndescription: d\n---\nBody")
247
+ (skills_dir / "no-skill").mkdir() # no SKILL.md
248
+
249
+ script = textwrap.dedent(f"""\
250
+ import {{ buildCatalog }} from './scripts/build-skills-catalog.mjs';
251
+ const entries = buildCatalog({json.dumps(str(skills_dir))});
252
+ process.stdout.write(JSON.stringify(entries));
253
+ """)
254
+ result = run_node(script)
255
+ entries = json.loads(result.stdout)
256
+ assert len(entries) == 1
257
+ assert entries[0]["name"] == "ok"
258
+
259
+
260
+ # ---------------------------------------------------------------------------
261
+ # Generated skills/index.js validation (real repo data)
262
+ # ---------------------------------------------------------------------------
263
+
264
+ class TestGeneratedSkillsIndex:
265
+ """Validate the checked-in skills/index.js against the live SKILL.md files."""
266
+
267
+ def test_index_js_is_valid_and_exports_array(self):
268
+ """The generated skills/index.js is valid JS with a non-empty array export."""
269
+ script = textwrap.dedent("""\
270
+ import { SKILLS_CATALOG } from './skills/index.js';
271
+ if (!Array.isArray(SKILLS_CATALOG)) process.exit(1);
272
+ if (SKILLS_CATALOG.length === 0) process.exit(1);
273
+ import SKILLS from './skills/index.js';
274
+ if (SKILLS !== SKILLS_CATALOG) process.exit(1);
275
+ """)
276
+ run_node(script)
277
+
278
+ def test_every_entry_has_required_fields(self):
279
+ """Each catalog entry must have name, description, triggers, and content."""
280
+ script = textwrap.dedent("""\
281
+ import { SKILLS_CATALOG } from './skills/index.js';
282
+ for (const entry of SKILLS_CATALOG) {
283
+ if (typeof entry.name !== 'string' || !entry.name) {
284
+ console.error('Missing name:', JSON.stringify(entry).slice(0, 100));
285
+ process.exit(1);
286
+ }
287
+ if (typeof entry.description !== 'string') {
288
+ console.error('Missing description for:', entry.name);
289
+ process.exit(1);
290
+ }
291
+ if (!Array.isArray(entry.triggers)) {
292
+ console.error('Missing triggers for:', entry.name);
293
+ process.exit(1);
294
+ }
295
+ if (typeof entry.content !== 'string') {
296
+ console.error('Missing content for:', entry.name);
297
+ process.exit(1);
298
+ }
299
+ }
300
+ """)
301
+ run_node(script)
302
+
303
+ def test_no_duplicate_names(self):
304
+ """Skill names should be unique in the catalog."""
305
+ script = textwrap.dedent("""\
306
+ import { SKILLS_CATALOG } from './skills/index.js';
307
+ const names = SKILLS_CATALOG.map(e => e.name);
308
+ const dupes = names.filter((n, i) => names.indexOf(n) !== i);
309
+ if (dupes.length > 0) {
310
+ console.error('Duplicate names:', dupes);
311
+ process.exit(1);
312
+ }
313
+ """)
314
+ run_node(script)
315
+
316
+ def test_catalog_is_sorted(self):
317
+ """Entries should be sorted alphabetically by name."""
318
+ script = textwrap.dedent("""\
319
+ import { SKILLS_CATALOG } from './skills/index.js';
320
+ const names = SKILLS_CATALOG.map(e => e.name);
321
+ const sorted = [...names].sort();
322
+ for (let i = 0; i < names.length; i++) {
323
+ if (names[i] !== sorted[i]) {
324
+ console.error('Not sorted: ' + names[i] + ' should be ' + sorted[i]);
325
+ process.exit(1);
326
+ }
327
+ }
328
+ """)
329
+ run_node(script)
330
+
331
+ def test_index_is_up_to_date(self):
332
+ """Re-running the codegen script should produce identical output."""
333
+ before = SKILLS_INDEX.read_text()
334
+ subprocess.run(["node", str(SCRIPT)], cwd=str(ROOT), check=True, capture_output=True)
335
+ after = SKILLS_INDEX.read_text()
336
+ assert before == after, "skills/index.js is out of date — run: node scripts/build-skills-catalog.mjs"
@@ -198,6 +198,20 @@ class TestCollectNeededCommands:
198
198
  assert spec.path.parent.name == "commands"
199
199
  assert spec.path.suffix == ".md"
200
200
 
201
+ def test_colon_triggers_are_normalized_for_filenames(self, tmp_path, monkeypatch):
202
+ skill_dir = tmp_path / "skills" / "test-skill"
203
+ skill_dir.mkdir(parents=True)
204
+ (skill_dir / "SKILL.md").write_text(
205
+ "---\nname: test-skill\ndescription: Test\ntriggers:\n - /test:command\n---\nBody\n"
206
+ )
207
+
208
+ monkeypatch.setattr("sync_extensions.SKILL_DIRS", [tmp_path / "skills"])
209
+
210
+ specs = collect_needed_commands()
211
+ assert [spec.path.relative_to(tmp_path).as_posix() for spec in specs] == [
212
+ "skills/test-skill/commands/test-command.md"
213
+ ]
214
+
201
215
 
202
216
  # ── load_marketplaces ────────────────────────────────────────────────
203
217