oh-my-opencode-medium 0.8.2

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 (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +284 -0
  3. package/dist/agents/designer.d.ts +2 -0
  4. package/dist/agents/explorer.d.ts +2 -0
  5. package/dist/agents/fixer.d.ts +2 -0
  6. package/dist/agents/index.d.ts +22 -0
  7. package/dist/agents/librarian.d.ts +2 -0
  8. package/dist/agents/oracle.d.ts +2 -0
  9. package/dist/agents/orchestrator.d.ts +15 -0
  10. package/dist/background/background-manager.d.ts +175 -0
  11. package/dist/background/index.d.ts +2 -0
  12. package/dist/background/tmux-session-manager.d.ts +63 -0
  13. package/dist/cli/chutes-selection.d.ts +3 -0
  14. package/dist/cli/config-io.d.ts +26 -0
  15. package/dist/cli/config-manager.d.ts +12 -0
  16. package/dist/cli/custom-skills.d.ts +29 -0
  17. package/dist/cli/dynamic-model-selection.d.ts +14 -0
  18. package/dist/cli/external-rankings.d.ts +8 -0
  19. package/dist/cli/index.d.ts +2 -0
  20. package/dist/cli/index.js +17077 -0
  21. package/dist/cli/install.d.ts +2 -0
  22. package/dist/cli/model-key-normalization.d.ts +1 -0
  23. package/dist/cli/model-selection.d.ts +30 -0
  24. package/dist/cli/opencode-models.d.ts +18 -0
  25. package/dist/cli/opencode-selection.d.ts +3 -0
  26. package/dist/cli/paths.d.ts +9 -0
  27. package/dist/cli/precedence-resolver.d.ts +16 -0
  28. package/dist/cli/providers.d.ts +204 -0
  29. package/dist/cli/scoring-v2/engine.d.ts +4 -0
  30. package/dist/cli/scoring-v2/features.d.ts +3 -0
  31. package/dist/cli/scoring-v2/index.d.ts +4 -0
  32. package/dist/cli/scoring-v2/types.d.ts +17 -0
  33. package/dist/cli/scoring-v2/weights.d.ts +2 -0
  34. package/dist/cli/skills.d.ts +52 -0
  35. package/dist/cli/system.d.ts +6 -0
  36. package/dist/cli/types.d.ts +138 -0
  37. package/dist/config/agent-mcps.d.ts +15 -0
  38. package/dist/config/constants.d.ts +14 -0
  39. package/dist/config/index.d.ts +4 -0
  40. package/dist/config/loader.d.ts +30 -0
  41. package/dist/config/schema.d.ts +217 -0
  42. package/dist/config/utils.d.ts +10 -0
  43. package/dist/hooks/auto-update-checker/cache.d.ts +6 -0
  44. package/dist/hooks/auto-update-checker/checker.d.ts +28 -0
  45. package/dist/hooks/auto-update-checker/constants.d.ts +11 -0
  46. package/dist/hooks/auto-update-checker/index.d.ts +17 -0
  47. package/dist/hooks/auto-update-checker/types.d.ts +23 -0
  48. package/dist/hooks/chat-headers.d.ts +16 -0
  49. package/dist/hooks/delegate-task-retry/guidance.d.ts +2 -0
  50. package/dist/hooks/delegate-task-retry/hook.d.ts +8 -0
  51. package/dist/hooks/delegate-task-retry/index.d.ts +4 -0
  52. package/dist/hooks/delegate-task-retry/patterns.d.ts +11 -0
  53. package/dist/hooks/index.d.ts +7 -0
  54. package/dist/hooks/json-error-recovery/hook.d.ts +18 -0
  55. package/dist/hooks/json-error-recovery/index.d.ts +1 -0
  56. package/dist/hooks/phase-reminder/index.d.ts +26 -0
  57. package/dist/hooks/post-read-nudge/index.d.ts +18 -0
  58. package/dist/index.d.ts +5 -0
  59. package/dist/index.js +33970 -0
  60. package/dist/mcp/context7.d.ts +6 -0
  61. package/dist/mcp/grep-app.d.ts +6 -0
  62. package/dist/mcp/index.d.ts +6 -0
  63. package/dist/mcp/types.d.ts +12 -0
  64. package/dist/mcp/websearch.d.ts +6 -0
  65. package/dist/skills/loader.d.ts +13 -0
  66. package/dist/skills/register.d.ts +2 -0
  67. package/dist/tools/ast-grep/cli.d.ts +15 -0
  68. package/dist/tools/ast-grep/constants.d.ts +25 -0
  69. package/dist/tools/ast-grep/downloader.d.ts +5 -0
  70. package/dist/tools/ast-grep/index.d.ts +10 -0
  71. package/dist/tools/ast-grep/tools.d.ts +3 -0
  72. package/dist/tools/ast-grep/types.d.ts +30 -0
  73. package/dist/tools/ast-grep/utils.d.ts +4 -0
  74. package/dist/tools/background.d.ts +13 -0
  75. package/dist/tools/grep/cli.d.ts +3 -0
  76. package/dist/tools/grep/constants.d.ts +18 -0
  77. package/dist/tools/grep/downloader.d.ts +3 -0
  78. package/dist/tools/grep/index.d.ts +5 -0
  79. package/dist/tools/grep/tools.d.ts +2 -0
  80. package/dist/tools/grep/types.d.ts +35 -0
  81. package/dist/tools/grep/utils.d.ts +2 -0
  82. package/dist/tools/index.d.ts +4 -0
  83. package/dist/tools/lsp/client.d.ts +42 -0
  84. package/dist/tools/lsp/config.d.ts +4 -0
  85. package/dist/tools/lsp/constants.d.ts +8 -0
  86. package/dist/tools/lsp/index.d.ts +3 -0
  87. package/dist/tools/lsp/tools.d.ts +5 -0
  88. package/dist/tools/lsp/types.d.ts +28 -0
  89. package/dist/tools/lsp/utils.d.ts +21 -0
  90. package/dist/utils/agent-variant.d.ts +47 -0
  91. package/dist/utils/env.d.ts +1 -0
  92. package/dist/utils/frontmatter.d.ts +15 -0
  93. package/dist/utils/index.d.ts +8 -0
  94. package/dist/utils/internal-initiator.d.ts +6 -0
  95. package/dist/utils/logger.d.ts +1 -0
  96. package/dist/utils/polling.d.ts +21 -0
  97. package/dist/utils/tmux.d.ts +32 -0
  98. package/dist/utils/zip-extractor.d.ts +1 -0
  99. package/package.json +68 -0
  100. package/src/skills/cartography/README.md +57 -0
  101. package/src/skills/cartography/SKILL.md +137 -0
  102. package/src/skills/cartography/scripts/cartographer.py +456 -0
  103. package/src/skills/cartography/scripts/test_cartographer.py +87 -0
  104. package/src/skills/loader.test.ts +452 -0
  105. package/src/skills/loader.ts +300 -0
  106. package/src/skills/register.test.ts +55 -0
  107. package/src/skills/register.ts +15 -0
@@ -0,0 +1,87 @@
1
+ import unittest
2
+ import os
3
+ import shutil
4
+ import json
5
+ import tempfile
6
+ import hashlib
7
+ from pathlib import Path
8
+ from cartographer import PatternMatcher, compute_file_hash, compute_folder_hash, select_files
9
+
10
+ class TestCartographer(unittest.TestCase):
11
+ def test_pattern_matcher(self):
12
+ patterns = ["node_modules/", "dist/", "*.log", "src/**/*.ts"]
13
+ matcher = PatternMatcher(patterns)
14
+
15
+ # Directory patterns
16
+ self.assertTrue(matcher.matches("node_modules/foo.js"))
17
+ self.assertTrue(matcher.matches("vendor/node_modules/bar.js"))
18
+ self.assertTrue(matcher.matches("dist/main.js"))
19
+ self.assertTrue(matcher.matches("src/dist/output.js"))
20
+
21
+ # Glob patterns
22
+ self.assertTrue(matcher.matches("error.log"))
23
+ self.assertTrue(matcher.matches("logs/access.log"))
24
+
25
+ # Recursive glob patterns
26
+ self.assertTrue(matcher.matches("src/index.ts"))
27
+ self.assertTrue(matcher.matches("src/utils/helper.ts"))
28
+
29
+ # Non-matches
30
+ self.assertFalse(matcher.matches("README.md"))
31
+ self.assertFalse(matcher.matches("tests/test.py"))
32
+
33
+ def test_compute_file_hash(self):
34
+ # Use binary mode to avoid any newline translation issues
35
+ with tempfile.NamedTemporaryFile(mode='wb', delete=False) as f:
36
+ f.write(b"test content")
37
+ f_path = f.name
38
+
39
+ try:
40
+ h1 = compute_file_hash(Path(f_path))
41
+ # md5 of b"test content" is 9473fdd0d880a43c21b7778d34872157
42
+ expected = hashlib.md5(b"test content").hexdigest()
43
+ self.assertEqual(h1, expected)
44
+ self.assertEqual(h1, "9473fdd0d880a43c21b7778d34872157")
45
+ finally:
46
+ if os.path.exists(f_path):
47
+ os.unlink(f_path)
48
+
49
+ def test_compute_folder_hash(self):
50
+ file_hashes = {
51
+ "src/a.ts": "hash-a",
52
+ "src/b.ts": "hash-b",
53
+ "tests/test.ts": "hash-test"
54
+ }
55
+
56
+ h1 = compute_folder_hash("src", file_hashes)
57
+ h2 = compute_folder_hash("src", file_hashes)
58
+ self.assertEqual(h1, h2)
59
+
60
+ file_hashes_alt = {
61
+ "src/a.ts": "hash-a-modified",
62
+ "src/b.ts": "hash-b"
63
+ }
64
+ h3 = compute_folder_hash("src", file_hashes_alt)
65
+ self.assertNotEqual(h1, h3)
66
+
67
+ def test_select_files(self):
68
+ with tempfile.TemporaryDirectory() as tmpdir:
69
+ root = Path(tmpdir)
70
+ (root / "src").mkdir()
71
+ (root / "node_modules").mkdir()
72
+ (root / "src" / "index.ts").write_text("code")
73
+ (root / "src" / "index.test.ts").write_text("test")
74
+ (root / "node_modules" / "foo.js").write_text("dep")
75
+ (root / "package.json").write_text("{}")
76
+
77
+ includes = ["src/**/*.ts", "package.json"]
78
+ excludes = ["**/*.test.ts", "node_modules/"]
79
+ exceptions = []
80
+
81
+ selected = select_files(root, includes, excludes, exceptions, [])
82
+
83
+ rel_selected = sorted([os.path.relpath(f, root) for f in selected])
84
+ self.assertEqual(rel_selected, ["package.json", "src/index.ts"])
85
+
86
+ if __name__ == "__main__":
87
+ unittest.main()
@@ -0,0 +1,452 @@
1
+ import { afterEach, describe, expect, it } from 'bun:test';
2
+ import {
3
+ chmod,
4
+ mkdir,
5
+ mkdtemp,
6
+ rm,
7
+ symlink,
8
+ writeFile,
9
+ } from 'node:fs/promises';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import { discoverAllSkills, loadSkillsFromDirectories } from './loader';
13
+
14
+ const tempDirs: string[] = [];
15
+
16
+ afterEach(async () => {
17
+ await Promise.all(
18
+ tempDirs.splice(0).map((dir) =>
19
+ rm(dir, {
20
+ recursive: true,
21
+ force: true,
22
+ }),
23
+ ),
24
+ );
25
+ });
26
+
27
+ describe('loadSkillsFromDirectories', () => {
28
+ it('loads flat markdown skills', async () => {
29
+ const root = await mkdtemp(join(tmpdir(), 'skill-loader-'));
30
+ tempDirs.push(root);
31
+ await writeFile(
32
+ join(root, 'summarize.md'),
33
+ '---\ndescription: Summarize code\n---\nSummarize:\n$ARGUMENTS\n',
34
+ );
35
+
36
+ const skills = await loadSkillsFromDirectories([
37
+ { path: root, source: 'agents' },
38
+ ]);
39
+
40
+ expect(skills.summarize?.description).toBe('Summarize code');
41
+ expect(skills.summarize?.template).toContain('Summarize:');
42
+ expect(skills.summarize?.template.match(/\$ARGUMENTS/g)?.length).toBe(1);
43
+ });
44
+
45
+ it('loads nested SKILL.md and prefers frontmatter name', async () => {
46
+ const root = await mkdtemp(join(tmpdir(), 'skill-loader-'));
47
+ tempDirs.push(root);
48
+ const skillDir = join(root, 'repo-map');
49
+ await mkdir(skillDir, { recursive: true });
50
+ await writeFile(
51
+ join(skillDir, 'SKILL.md'),
52
+ '---\nname: atlas\ndescription: Map repo\nagent: explorer\nsubtask: true\n---\nUse @docs/README.md when mapping.\nIgnore @param tokens.\n',
53
+ );
54
+ await mkdir(join(skillDir, 'docs'), { recursive: true });
55
+ await writeFile(join(skillDir, 'docs', 'README.md'), '# repo map\n');
56
+
57
+ const skills = await loadSkillsFromDirectories([
58
+ { path: root, source: 'agents' },
59
+ ]);
60
+
61
+ expect(skills.atlas).toBeDefined();
62
+ expect(skills.atlas?.agent).toBe('explorer');
63
+ expect(skills.atlas?.subtask).toBe(true);
64
+ expect(skills.atlas?.template).toContain('<skill-instruction>');
65
+ expect(skills.atlas?.template).toContain('Base directory for this skill:');
66
+ expect(skills.atlas?.template).toContain(`${skillDir}/docs/README.md`);
67
+ expect(skills.atlas?.template).toContain('Ignore @param tokens.');
68
+ expect(skills.atlas?.template).toContain('<user-request>');
69
+ expect(skills.atlas?.template).toContain('$ARGUMENTS');
70
+ });
71
+
72
+ it('does not append duplicate $ARGUMENTS blocks to wrapped skills', async () => {
73
+ const root = await mkdtemp(join(tmpdir(), 'skill-loader-'));
74
+ tempDirs.push(root);
75
+ const skillDir = join(root, 'repo-map');
76
+ await mkdir(skillDir, { recursive: true });
77
+ await writeFile(
78
+ join(skillDir, 'SKILL.md'),
79
+ '---\ndescription: Map repo\n---\nUse this skill with:\n<user-request>\n$ARGUMENTS\n</user-request>\n',
80
+ );
81
+
82
+ const skills = await loadSkillsFromDirectories([
83
+ { path: root, source: 'agents' },
84
+ ]);
85
+
86
+ expect(skills['repo-map']?.template.match(/\$ARGUMENTS/g)?.length).toBe(1);
87
+ expect(skills['repo-map']?.template.match(/<user-request>/g)?.length).toBe(
88
+ 1,
89
+ );
90
+ });
91
+
92
+ it('resolves root-level @path references relative to the skill directory', async () => {
93
+ const root = await mkdtemp(join(tmpdir(), 'skill-loader-'));
94
+ tempDirs.push(root);
95
+ const skillDir = join(root, 'repo-map');
96
+ await mkdir(skillDir, { recursive: true });
97
+ await writeFile(
98
+ join(skillDir, 'SKILL.md'),
99
+ '---\ndescription: Map repo\n---\nRead @README.md before editing.\nIgnore @param tokens.\n',
100
+ );
101
+ await writeFile(join(skillDir, 'README.md'), '# repo map\n');
102
+
103
+ const skills = await loadSkillsFromDirectories([
104
+ { path: root, source: 'agents' },
105
+ ]);
106
+
107
+ expect(skills['repo-map']?.template).toContain(`${skillDir}/README.md`);
108
+ expect(skills['repo-map']?.template).toContain('Ignore @param tokens.');
109
+ });
110
+
111
+ it('uses colon-delimited relative paths for nested wrapped skills', async () => {
112
+ const root = await mkdtemp(join(tmpdir(), 'skill-loader-'));
113
+ tempDirs.push(root);
114
+ const skillDir = join(root, 'bundle', 'repo-map');
115
+ await mkdir(skillDir, { recursive: true });
116
+ await writeFile(
117
+ join(skillDir, 'SKILL.md'),
118
+ '---\ndescription: Deep skill\n---\nDeep prompt\n',
119
+ );
120
+
121
+ const skills = await loadSkillsFromDirectories([
122
+ { path: root, source: 'agents' },
123
+ ]);
124
+
125
+ expect(skills['bundle:repo-map']?.description).toBe('Deep skill');
126
+ expect(skills['bundle:repo-map']?.template).toContain('Deep prompt');
127
+ });
128
+
129
+ it('loads a skill from a symlinked skill directory', async () => {
130
+ const root = await mkdtemp(join(tmpdir(), 'skill-loader-'));
131
+ const targetRoot = await mkdtemp(join(tmpdir(), 'skill-loader-target-'));
132
+ tempDirs.push(root, targetRoot);
133
+ const skillDir = join(targetRoot, 'planning-with-files');
134
+ await mkdir(skillDir, { recursive: true });
135
+ await writeFile(
136
+ join(skillDir, 'SKILL.md'),
137
+ '---\ndescription: Planning skill\n---\nPlan carefully\n',
138
+ );
139
+ await symlink(skillDir, join(root, 'planning-with-files'));
140
+
141
+ const skills = await loadSkillsFromDirectories([
142
+ { path: root, source: 'agents' },
143
+ ]);
144
+
145
+ expect(skills['planning-with-files']?.description).toBe('Planning skill');
146
+ });
147
+
148
+ it('keeps the relative path for nested skills under symlinked roots', async () => {
149
+ const root = await mkdtemp(join(tmpdir(), 'skill-loader-'));
150
+ const targetRoot = await mkdtemp(join(tmpdir(), 'skill-loader-target-'));
151
+ tempDirs.push(root, targetRoot);
152
+ const skillDir = join(targetRoot, 'superpowers', 'brainstorming');
153
+ await mkdir(skillDir, { recursive: true });
154
+ await writeFile(
155
+ join(skillDir, 'SKILL.md'),
156
+ '---\ndescription: Brainstorm\n---\nBrainstorm carefully\n',
157
+ );
158
+ await symlink(join(targetRoot, 'superpowers'), join(root, 'superpowers'));
159
+
160
+ const skills = await loadSkillsFromDirectories([
161
+ { path: root, source: 'agents' },
162
+ ]);
163
+
164
+ expect(skills['superpowers:brainstorming']?.description).toBe('Brainstorm');
165
+ });
166
+
167
+ it('keeps the namespace for nested wrapped skills with frontmatter names', async () => {
168
+ const root = await mkdtemp(join(tmpdir(), 'skill-loader-'));
169
+ tempDirs.push(root);
170
+ const skillDir = join(root, 'superpowers', 'brainstorming');
171
+ await mkdir(skillDir, { recursive: true });
172
+ await writeFile(
173
+ join(skillDir, 'SKILL.md'),
174
+ '---\nname: brainstorming\ndescription: Brainstorm\n---\nBrainstorm carefully\n',
175
+ );
176
+
177
+ const skills = await loadSkillsFromDirectories([
178
+ { path: root, source: 'agents' },
179
+ ]);
180
+
181
+ expect(skills['superpowers:brainstorming']?.description).toBe('Brainstorm');
182
+ expect(skills.brainstorming).toBeUndefined();
183
+ });
184
+
185
+ it('avoids repeated traversal through cyclic symlink directories', async () => {
186
+ const root = await mkdtemp(join(tmpdir(), 'skill-loader-'));
187
+ tempDirs.push(root);
188
+ await mkdir(join(root, 'dir'), { recursive: true });
189
+ await writeFile(join(root, 'dir', 'note.txt'), 'x');
190
+ await symlink(join(root, 'dir'), join(root, 'alias'));
191
+ await symlink(root, join(root, 'dir', 'back'));
192
+
193
+ await expect(
194
+ Promise.race([
195
+ loadSkillsFromDirectories([{ path: root, source: 'agents' }]),
196
+ new Promise<never>((_, reject) =>
197
+ setTimeout(
198
+ () => reject(new Error('timed out traversing symlinks')),
199
+ 200,
200
+ ),
201
+ ),
202
+ ]),
203
+ ).resolves.toEqual({});
204
+ });
205
+
206
+ it('ignores non-skill markdown files', async () => {
207
+ const root = await mkdtemp(join(tmpdir(), 'skill-loader-'));
208
+ tempDirs.push(root);
209
+ await mkdir(join(root, 'notes'), { recursive: true });
210
+ await writeFile(
211
+ join(root, 'SKILL.md'),
212
+ '---\ndescription: wrong\n---\nWrong\n',
213
+ );
214
+ await writeFile(join(root, 'notes', 'README.md'), '# docs\n');
215
+
216
+ const skills = await loadSkillsFromDirectories([
217
+ { path: root, source: 'agents' },
218
+ ]);
219
+
220
+ expect(skills.SKILL).toBeUndefined();
221
+ expect(skills.README).toBeUndefined();
222
+ });
223
+
224
+ it('loads only root-level flat markdown files', async () => {
225
+ const root = await mkdtemp(join(tmpdir(), 'skill-loader-'));
226
+ tempDirs.push(root);
227
+ await mkdir(join(root, 'notes'), { recursive: true });
228
+ await writeFile(
229
+ join(root, 'summarize.md'),
230
+ '---\ndescription: root\n---\nRoot\n',
231
+ );
232
+ await writeFile(
233
+ join(root, 'notes', 'summarize.md'),
234
+ '---\ndescription: nested\n---\nNested\n',
235
+ );
236
+
237
+ const skills = await loadSkillsFromDirectories([
238
+ { path: root, source: 'agents' },
239
+ ]);
240
+
241
+ expect(skills.summarize?.description).toBe('root');
242
+ expect(skills.summarize?.template).toContain('Root');
243
+ });
244
+
245
+ it('lets later directories override earlier directories', async () => {
246
+ const globalDir = await mkdtemp(join(tmpdir(), 'skill-loader-global-'));
247
+ const projectDir = await mkdtemp(join(tmpdir(), 'skill-loader-project-'));
248
+ tempDirs.push(globalDir, projectDir);
249
+
250
+ await writeFile(
251
+ join(globalDir, 'review.md'),
252
+ '---\ndescription: global\n---\nGlobal review\n',
253
+ );
254
+ await writeFile(
255
+ join(projectDir, 'review.md'),
256
+ '---\ndescription: project\n---\nProject review\n',
257
+ );
258
+
259
+ const skills = await loadSkillsFromDirectories([
260
+ { path: globalDir, source: 'agents' },
261
+ { path: projectDir, source: 'agents' },
262
+ ]);
263
+
264
+ expect(skills.review?.description).toBe('project');
265
+ expect(skills.review?.template).toContain('Project review');
266
+ });
267
+
268
+ it('skips unreadable skill directories', async () => {
269
+ const root = await mkdtemp(join(tmpdir(), 'skill-loader-'));
270
+ tempDirs.push(root);
271
+ await writeFile(
272
+ join(root, 'review.md'),
273
+ '---\ndescription: review\n---\nReview\n',
274
+ );
275
+
276
+ await chmod(root, 0o000);
277
+
278
+ try {
279
+ const skills = await loadSkillsFromDirectories([
280
+ { path: root, source: 'agents' },
281
+ ]);
282
+
283
+ expect(skills).toEqual({});
284
+ } finally {
285
+ await chmod(root, 0o755);
286
+ }
287
+ });
288
+
289
+ it('skips unreadable skill files', async () => {
290
+ const root = await mkdtemp(join(tmpdir(), 'skill-loader-'));
291
+ tempDirs.push(root);
292
+ const skillPath = join(root, 'review.md');
293
+ await writeFile(skillPath, '---\ndescription: review\n---\nReview\n');
294
+
295
+ await chmod(skillPath, 0o000);
296
+
297
+ try {
298
+ const skills = await loadSkillsFromDirectories([
299
+ { path: root, source: 'agents' },
300
+ ]);
301
+
302
+ expect(skills).toEqual({});
303
+ } finally {
304
+ await chmod(skillPath, 0o644);
305
+ }
306
+ });
307
+
308
+ it('drops model from agents skills but keeps it for opencode skills', async () => {
309
+ const agentsDir = await mkdtemp(join(tmpdir(), 'skill-loader-agents-'));
310
+ const opencodeDir = await mkdtemp(join(tmpdir(), 'skill-loader-opencode-'));
311
+ tempDirs.push(agentsDir, opencodeDir);
312
+
313
+ await writeFile(
314
+ join(agentsDir, 'review.md'),
315
+ '---\ndescription: agents\nmodel: openai/gpt-5\n---\nAgents review\n',
316
+ );
317
+ await writeFile(
318
+ join(opencodeDir, 'review.md'),
319
+ '---\ndescription: opencode\nmodel: openai/gpt-5\n---\nOpencode review\n',
320
+ );
321
+
322
+ const agentsSkills = await loadSkillsFromDirectories([
323
+ { path: agentsDir, source: 'agents' },
324
+ ]);
325
+ const opencodeSkills = await loadSkillsFromDirectories([
326
+ { path: opencodeDir, source: 'opencode' },
327
+ ]);
328
+
329
+ expect(agentsSkills.review?.model).toBeUndefined();
330
+ expect(opencodeSkills.review?.model).toBe('openai/gpt-5');
331
+ });
332
+
333
+ it('skips skills with malformed frontmatter', async () => {
334
+ const root = await mkdtemp(join(tmpdir(), 'skill-loader-'));
335
+ tempDirs.push(root);
336
+ await writeFile(
337
+ join(root, 'broken.md'),
338
+ '---\ndescription [oops\n---\nPrompt\n',
339
+ );
340
+
341
+ const skills = await loadSkillsFromDirectories([
342
+ { path: root, source: 'agents' },
343
+ ]);
344
+
345
+ expect(skills).toEqual({});
346
+ });
347
+
348
+ it('loads skills with yaml comments and structured metadata', async () => {
349
+ const root = await mkdtemp(join(tmpdir(), 'skill-loader-'));
350
+ tempDirs.push(root);
351
+ await writeFile(
352
+ join(root, 'review.md'),
353
+ '---\n# comment\ndescription: Review code\ntags:\n - docs\nnotes: |\n line 1\nagent: explorer\n---\nReview:\n$ARGUMENTS\n',
354
+ );
355
+
356
+ const skills = await loadSkillsFromDirectories([
357
+ { path: root, source: 'agents' },
358
+ ]);
359
+
360
+ expect(skills.review?.description).toBe('Review code');
361
+ expect(skills.review?.agent).toBe('explorer');
362
+ });
363
+
364
+ it('parses subtask metadata when the scalar has an inline comment', async () => {
365
+ const root = await mkdtemp(join(tmpdir(), 'skill-loader-'));
366
+ tempDirs.push(root);
367
+ await writeFile(
368
+ join(root, 'atlas.md'),
369
+ '---\ndescription: Review code\nsubtask: true # keep boolean\n---\nReview\n',
370
+ );
371
+
372
+ const skills = await loadSkillsFromDirectories([
373
+ { path: root, source: 'agents' },
374
+ ]);
375
+
376
+ expect(skills.atlas?.subtask).toBe(true);
377
+ });
378
+ });
379
+
380
+ describe('discoverAllSkills', () => {
381
+ it('loads installed opencode skills instead of src/skills directly', async () => {
382
+ const projectDir = await mkdtemp(join(tmpdir(), 'skill-loader-project-'));
383
+ const homeDir = await mkdtemp(join(tmpdir(), 'skill-loader-home-'));
384
+ tempDirs.push(projectDir, homeDir);
385
+
386
+ await mkdir(join(homeDir, '.config', 'opencode', 'skills', 'bundle'), {
387
+ recursive: true,
388
+ });
389
+ await writeFile(
390
+ join(homeDir, '.config', 'opencode', 'skills', 'bundle', 'SKILL.md'),
391
+ '---\ndescription: bundled\n---\nBundled skill\n',
392
+ );
393
+
394
+ const skills = await discoverAllSkills(projectDir, homeDir);
395
+
396
+ expect(skills.bundle?.description).toBe('bundled');
397
+ });
398
+
399
+ it('reuses discovered skills for the same project and home directories', async () => {
400
+ const projectDir = await mkdtemp(join(tmpdir(), 'skill-loader-project-'));
401
+ const homeDir = await mkdtemp(join(tmpdir(), 'skill-loader-home-'));
402
+ tempDirs.push(projectDir, homeDir);
403
+
404
+ const skillPath = join(homeDir, '.config', 'opencode', 'skills', 'bundle');
405
+ await mkdir(skillPath, { recursive: true });
406
+ await writeFile(
407
+ join(skillPath, 'SKILL.md'),
408
+ '---\ndescription: bundled\n---\nBundled skill\n',
409
+ );
410
+
411
+ const firstSkills = await discoverAllSkills(projectDir, homeDir);
412
+
413
+ await writeFile(
414
+ join(skillPath, 'SKILL.md'),
415
+ '---\ndescription: changed\n---\nChanged skill\n',
416
+ );
417
+
418
+ const secondSkills = await discoverAllSkills(projectDir, homeDir);
419
+
420
+ expect(firstSkills.bundle?.description).toBe('bundled');
421
+ expect(secondSkills.bundle?.description).toBe('bundled');
422
+ });
423
+
424
+ it('loads opencode skills from XDG_CONFIG_HOME when set', async () => {
425
+ const projectDir = await mkdtemp(join(tmpdir(), 'skill-loader-project-'));
426
+ const homeDir = await mkdtemp(join(tmpdir(), 'skill-loader-home-'));
427
+ const xdgConfigHome = await mkdtemp(join(tmpdir(), 'skill-loader-xdg-'));
428
+ tempDirs.push(projectDir, homeDir, xdgConfigHome);
429
+
430
+ await mkdir(join(xdgConfigHome, 'opencode', 'skills', 'bundle'), {
431
+ recursive: true,
432
+ });
433
+ await writeFile(
434
+ join(xdgConfigHome, 'opencode', 'skills', 'bundle', 'SKILL.md'),
435
+ '---\ndescription: xdg bundled\n---\nBundled skill\n',
436
+ );
437
+
438
+ const previousXdgConfigHome = process.env.XDG_CONFIG_HOME;
439
+ process.env.XDG_CONFIG_HOME = xdgConfigHome;
440
+
441
+ try {
442
+ const skills = await discoverAllSkills(projectDir, homeDir);
443
+ expect(skills.bundle?.description).toBe('xdg bundled');
444
+ } finally {
445
+ if (previousXdgConfigHome === undefined) {
446
+ delete process.env.XDG_CONFIG_HOME;
447
+ } else {
448
+ process.env.XDG_CONFIG_HOME = previousXdgConfigHome;
449
+ }
450
+ }
451
+ });
452
+ });