Skip to content

Commit 581f357

Browse files
authored
feat(registry): automate component generation and migrate to OCX V2 schema (#315)
* feat(registry): migrate build-registry and registry.jsonc to OCX V2 schema - Drop ocx: prefix from component types (skill, agent, bundle, profile, plugin) - Add string shorthand support for file entries (path === target) - Simplify resolveComponentFilePath to a uniform path.join(PROJECT_ROOT, file.path) - Remove OCX_TARGET_REWRITES and normalizeTargetPath singularization - Update schema URL to V2 - Migrate registry.jsonc V1 entries to V2 format Build script now accepts both string shorthand and {path, target} object entries. Generated entries (skills/agents) use string shorthand; profile entries keep object form since their install target differs from source path. * feat(registry): add generate-registry script with auto-population from filesystem Generates skill and agent components in registry.jsonc from skills/ and agents/ directories. Bundles, profiles, and plugin entries stay hand-curated; bundles named 'skills' or 'agents' get their dependencies arrays auto-populated with all components of the matching type. - Skill component name = directory basename (with underscores replaced by hyphens) to satisfy V2 schema pattern. generate_command -> generate-command. - Agent component name = 'agent-' + filename stem. - File entries use V2 string shorthand (path === target). - Generator exits with error on any component with empty description. - Excludes .DS_Store, .gitkeep, AGENTS.md, .bak/.tmp/~ files, and node_modules/ __pycache__/.pytest_cache directories from skill file lists. - Fresh header comment block written on each generation. Skill validator updated to derive skill directory from SKILL.md path so unlisted- file checks work even when component name differs from directory basename due to sanitization. Registry now contains 100 components (45 skills + 50 agents + 5 curated). * feat(registry): remove unused skills and agents symlinks from registry/files/ The build script no longer uses these symlinks \u2014 file paths in registry.jsonc now resolve directly from the repo root (V2 string shorthand). The symlinks were left over from earlier path-resolution logic that prepended registry/files/ to component file paths. registry/files/profiles/ stays untouched \u2014 those are real files that profile components reference. * feat(registry): add --check drift mode and wire into CI build job The generator now supports --check that exits 0 if registry.jsonc matches what would be generated, exit 1 otherwise. Trailing whitespace is normalized before comparison to avoid false positives. CI build job runs `bun scripts/generate-registry.ts --check` after the content- integrity gate. Adding a new skill or agent without regenerating the registry will now block the build automatically. * test(registry): add 29-test suite for generator + match Biome JSON formatting Tests cover: - sanitizeComponentName underscore->hyphen transformation - isExcludedFile (.DS_Store, .gitkeep, AGENTS.md, .bak/.tmp/~, node_modules) - countComponents and normalizeForCompare helpers - Skill discovery with files, references/, templates/ structure - Agent discovery with agent- prefix - Curated entry preservation (bundle/profile/plugin) with V1 to V2 type migration - Bundle dependency auto-population for skills/agents bundles only - Component sort order: generated alphabetical, then curated - Fresh header comment block (does not preserve V1-era stale comments) - Empty-description error path for skills and agents - Idempotence and drift detection Refactored the generator to accept rootDir as a parameter so tests can use synthetic temp-dir fixtures without mocking the filesystem. main() guards the script-only entry point with import.meta.main. The generator now inlines single-element string arrays in its output to match Biome's JSON formatter, keeping the drift check stable under bun run lint. Also fixed a pre-existing useOptionalChain warning in skills/onboarding/scripts/ inventory.mjs that biome auto-fix surfaced. * fix(registry): address Fro Bot review feedback on PR #315 Blocking: - Replace GenerationError class with a tagged-error factory function and isGenerationError type guard. Keeps the codebase class-free per project convention. Non-blocking improvements: - Replace inlineSingleStringArrays regex with a real Biome subprocess call (`biome format --stdin-file-path`). Generator output now inherits any future Biome formatting changes for free instead of duplicating the inlining heuristic. Also drops the regex's brittleness if Biome's line-length threshold ever changes. - normalizeForCompare now strips CRLF before trailing whitespace so the drift check works on Windows / git autocrlf checkouts. - Add a regression test that round-trips generator output through Biome and asserts byte-identical equality, catching future Biome format changes before the drift check ever sees them.
1 parent 5cf7167 commit 581f357

8 files changed

Lines changed: 1739 additions & 471 deletions

File tree

.github/workflows/main.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ jobs:
6262
- name: Content integrity gate
6363
run: bun scripts/content-integrity.ts
6464

65+
- name: Registry drift check
66+
run: bun scripts/generate-registry.ts --check
67+
6568
typecheck:
6669
name: Typecheck
6770
runs-on: ubuntu-latest

registry/files/agents

Lines changed: 0 additions & 1 deletion
This file was deleted.

registry/files/skills

Lines changed: 0 additions & 1 deletion
This file was deleted.

registry/registry.jsonc

Lines changed: 665 additions & 395 deletions
Large diffs are not rendered by default.

scripts/build-registry.ts

Lines changed: 59 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,14 @@ import { execSync } from 'node:child_process'
44
* Build OCX Registry
55
*
66
* Reads registry/registry.jsonc, validates all referenced files exist,
7-
* and produces dist/registry/ output following OCX Registry Protocol v1.
7+
* and produces dist/registry/ output following OCX Registry Protocol v2.
8+
*
9+
* V2 schema differences from V1:
10+
* - Component types are unprefixed (`skill`, `agent`, `bundle`, `profile`, `plugin`) — no `ocx:` prefix
11+
* - File entries are repo-root-relative paths
12+
* - File entries support string shorthand (path === target) in addition to {path, target} objects
13+
* - No `.opencode/` prefix on targets — OCX CLI auto-resolves install location by component type
14+
* - No singularization rewrites (V2 accepts plural target prefixes like `agents/`, `skills/`)
815
*
916
* Usage:
1017
* bun scripts/build-registry.ts # Full build
@@ -30,16 +37,30 @@ interface RegistryFile {
3037
target: string
3138
}
3239

40+
/**
41+
* V2 file entries can be either:
42+
* - A {path, target} object when source and target paths differ (e.g., profiles)
43+
* - A string shorthand when source path equals target path (e.g., generated skills/agents)
44+
*/
45+
type RegistryFileEntry = RegistryFile | string
46+
3347
interface RegistryComponent {
3448
name: string
3549
type: string
3650
version: string
3751
description?: string
38-
files?: RegistryFile[]
52+
files?: RegistryFileEntry[]
3953
dependencies?: string[]
4054
opencode?: Record<string, unknown>
4155
}
4256

57+
function normalizeFileEntry(entry: RegistryFileEntry): RegistryFile {
58+
if (typeof entry === 'string') {
59+
return { path: entry, target: entry }
60+
}
61+
return entry
62+
}
63+
4364
interface RegistrySource {
4465
$schema?: string
4566
name: string
@@ -175,32 +196,14 @@ function loadRegistrySource(): RegistrySource {
175196
}
176197

177198
/**
178-
* Maps a component's file entry to its actual disk location.
179-
* Skills use paths relative to skills/{name}/, agents/commands use project-root-relative paths.
199+
* Resolves a V2 file entry to its actual disk location.
200+
* V2 file paths are always repo-root-relative regardless of component type.
180201
*/
181202
function resolveComponentFilePath(
182-
component: RegistryComponent,
203+
_component: RegistryComponent,
183204
file: RegistryFile,
184205
): string {
185-
const type = component.type
186-
187-
if (type === 'ocx:skill') {
188-
return path.join(PROJECT_ROOT, 'skills', component.name, file.path)
189-
}
190-
191-
if (type === 'ocx:agent') {
192-
return path.join(PROJECT_ROOT, file.path)
193-
}
194-
195-
if (type === 'ocx:command') {
196-
return path.join(PROJECT_ROOT, file.path)
197-
}
198-
199-
if (type === 'ocx:profile') {
200-
return path.join(PROJECT_ROOT, 'registry/files', file.path)
201-
}
202-
203-
return path.join(PROJECT_ROOT, 'registry/files', file.path)
206+
return path.join(PROJECT_ROOT, file.path)
204207
}
205208

206209
function validateRegistry(source: RegistrySource): string[] {
@@ -211,13 +214,13 @@ function validateRegistry(source: RegistrySource): string[] {
211214
string,
212215
(component: RegistryComponent, errors: string[]) => void
213216
> = {
214-
'ocx:skill': validateSkillComponent,
215-
'ocx:agent': validateFileComponent,
216-
'ocx:command': validateFileComponent,
217-
'ocx:bundle': (component, currentErrors) =>
217+
skill: validateSkillComponent,
218+
agent: validateFileComponent,
219+
command: validateFileComponent,
220+
bundle: (component, currentErrors) =>
218221
validateBundleComponent(component, source, currentErrors),
219-
'ocx:profile': validateProfileComponent,
220-
'ocx:plugin': () => undefined,
222+
profile: validateProfileComponent,
223+
plugin: () => undefined,
221224
}
222225

223226
for (const component of source.components) {
@@ -251,12 +254,15 @@ function validateSkillComponent(
251254
return
252255
}
253256

254-
const hasSkillMd = component.files.some((f) => f.path === 'SKILL.md')
255-
if (!hasSkillMd) {
257+
const normalizedFiles = component.files.map(normalizeFileEntry)
258+
const skillMdFile = normalizedFiles.find(
259+
(f) => f.path === 'SKILL.md' || f.path.endsWith('/SKILL.md'),
260+
)
261+
if (skillMdFile == null) {
256262
errors.push(`${prefix} Skill component missing SKILL.md`)
257263
}
258264

259-
for (const file of component.files) {
265+
for (const file of normalizedFiles) {
260266
const diskPath = resolveComponentFilePath(component, file)
261267
if (!fs.existsSync(diskPath)) {
262268
errors.push(
@@ -265,15 +271,21 @@ function validateSkillComponent(
265271
}
266272
}
267273

268-
const skillDir = path.join(PROJECT_ROOT, 'skills', component.name)
274+
// Derive the skill directory from the SKILL.md location so unlisted-file checks
275+
// work regardless of any directory-name → component-name sanitization (e.g.,
276+
// skills/generate_command/ → component name "generate-command").
277+
if (skillMdFile == null) return
278+
const skillDir = path.join(PROJECT_ROOT, path.dirname(skillMdFile.path))
269279
if (fs.existsSync(skillDir)) {
270280
const diskFiles = walkFiles(skillDir)
271-
const declaredPaths = new Set(component.files.map((f) => f.path))
281+
const declaredPaths = new Set(normalizedFiles.map((f) => f.path))
272282

273283
for (const diskFile of diskFiles) {
274-
const rel = path.relative(skillDir, diskFile)
275-
if (!declaredPaths.has(rel)) {
276-
errors.push(`${prefix} Unlisted file in skill directory: ${rel}`)
284+
const repoRelative = path.relative(PROJECT_ROOT, diskFile)
285+
if (!declaredPaths.has(repoRelative)) {
286+
errors.push(
287+
`${prefix} Unlisted file in skill directory: ${repoRelative}`,
288+
)
277289
}
278290
}
279291
}
@@ -290,7 +302,8 @@ function validateFileComponent(
290302
return
291303
}
292304

293-
for (const file of component.files) {
305+
const normalizedFiles = component.files.map(normalizeFileEntry)
306+
for (const file of normalizedFiles) {
294307
if (!file.path.endsWith('.md')) {
295308
errors.push(`${prefix} File should be .md: ${file.path}`)
296309
}
@@ -315,7 +328,8 @@ function validateProfileComponent(
315328
return
316329
}
317330

318-
for (const file of component.files) {
331+
const normalizedFiles = component.files.map(normalizeFileEntry)
332+
for (const file of normalizedFiles) {
319333
const diskPath = resolveComponentFilePath(component, file)
320334
if (!fs.existsSync(diskPath)) {
321335
errors.push(
@@ -368,21 +382,6 @@ function walkFiles(dir: string): string[] {
368382
return results
369383
}
370384

371-
/** OCX requires singular directory names for agent/command targets */
372-
const OCX_TARGET_REWRITES: ReadonlyArray<[RegExp, string]> = [
373-
[/^\.opencode\/agents\//, '.opencode/agent/'],
374-
[/^\.opencode\/commands\//, '.opencode/command/'],
375-
]
376-
377-
function normalizeTargetPath(target: string): string {
378-
for (const [pattern, replacement] of OCX_TARGET_REWRITES) {
379-
if (pattern.test(target)) {
380-
return target.replace(pattern, replacement)
381-
}
382-
}
383-
return target
384-
}
385-
386385
/** SHA-256 integrity: sha256-{base64(digest)} per OCX spec */
387386
function computeIntegrity(content: Buffer): string {
388387
const hash = createHash('sha256').update(content).digest('base64')
@@ -409,9 +408,9 @@ function buildRegistry(source: RegistrySource, version: string): void {
409408

410409
if (Array.isArray(component.files) && component.files.length > 0) {
411410
buildPackument(component, version)
412-
} else if (component.type === 'ocx:bundle') {
411+
} else if (component.type === 'bundle') {
413412
buildBundlePackument(component, version)
414-
} else if (component.type === 'ocx:plugin') {
413+
} else if (component.type === 'plugin') {
415414
buildPluginPackument(component, version)
416415
}
417416
}
@@ -434,7 +433,8 @@ function buildPackument(component: RegistryComponent, version: string): void {
434433
const files = component.files ?? []
435434
const packumentFiles: PackumentFile[] = []
436435

437-
for (const file of files) {
436+
for (const entry of files) {
437+
const file = normalizeFileEntry(entry)
438438
const sourcePath = resolveComponentFilePath(component, file)
439439
let content: Buffer
440440
try {
@@ -446,7 +446,7 @@ function buildPackument(component: RegistryComponent, version: string): void {
446446

447447
packumentFiles.push({
448448
path: file.path,
449-
target: normalizeTargetPath(file.target),
449+
target: file.target,
450450
integrity,
451451
})
452452

0 commit comments

Comments
 (0)