Skip to content

Commit fd51016

Browse files
authored
fix: OCX registry compatibility and profile support (#67)
* fix: OCX registry compatibility and profile support - Fix CI workflow to copy registry output to docs site root (index.json at /systematic/index.json, not /systematic/registry/index.json) - Fix build script: include author field, strip v prefix from git tags, add files:[] for bundle/plugin packuments, remove version from component entries and packument version data - Normalize target paths in build output (agents/ → agent/, commands/ → command/) to match OCX's required singular directory names while keeping plural in source - Add profile components (standalone, omo) to registry with validation - Fix .well-known/ocx.json registry path - Update README and OCX guide with verified commands and profile docs - Remove structural integrity tests (registry.test.ts) * fix: harden registry build validation - Refactor validateRegistry() to reduce cognitive complexity - Resolve author from package.json when missing in registry source - Harden git tag parsing to require semver or v-prefixed semver - Add functional build-registry tests (validation, version errors, target path normalization)
1 parent 14b2f28 commit fd51016

8 files changed

Lines changed: 217 additions & 497 deletions

File tree

.github/workflows/docs.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ jobs:
4646
run: bun run --cwd docs build
4747

4848
- name: Copy registry to docs output
49-
run: cp -r dist/registry docs/dist/registry
49+
run: cp -r dist/registry/. docs/dist/
5050

5151
- name: Create GitHub App token
5252
if: github.repository == 'marcusrbrown/systematic'

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,8 @@ ocx add systematic/skills # All 11 skills
8484
ocx add systematic/agents # All 24 agents
8585
ocx add systematic/commands # All 9 commands
8686

87-
# Or use a profile
87+
# Or use a profile (requires --global registry)
88+
ocx registry add https://fro.bot/systematic --name systematic --global
8889
ocx profile add sys --from systematic/standalone
8990
```
9091

docs/public/.well-known/ocx.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
22
"version": 1,
3-
"registry": "/systematic/registry/index.json"
3+
"registry": "/systematic/index.json"
44
}

docs/src/content/docs/guides/ocx-registry.mdx

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ To use the Systematic registry with OCX, first add the registry to your local co
1818
ocx registry add https://fro.bot/systematic --name systematic
1919
```
2020

21-
The registry discovery file is located at `https://fro.bot/systematic/.well-known/ocx.json`.
21+
OCX resolves the registry automatically from the URL.
2222

2323
## Installing Components
2424

@@ -76,6 +76,14 @@ ocx add systematic/commands
7676

7777
Profiles provide a complete, "opinionated" configuration for Systematic. They can be installed as standalone projects or merged into existing ones.
7878

79+
:::caution
80+
Profile installation requires the registry to be configured **globally** (not just in the project). Add the `--global` flag when adding the registry:
81+
82+
```bash
83+
ocx registry add https://fro.bot/systematic --name systematic --global
84+
```
85+
:::
86+
7987
### Standalone Profile
8088
The `standalone` profile provides a minimal Systematic setup with only the `@fro.bot/systematic` plugin and all bundled assets.
8189

@@ -165,8 +173,10 @@ If you have the npm plugin installed AND have added OCX components, Systematic w
165173

166174
### Profile not working
167175
If a profile installation fails or doesn't seem to apply:
168-
1. Verify the profile exists: `ocx profile list`
169-
2. Check your `OCX_PROFILE` environment variable if you are using profile switching.
176+
1. Verify the registry is configured **globally**: `ocx registry list --global`
177+
2. If missing, add it: `ocx registry add https://fro.bot/systematic --name systematic --global`
178+
3. Check existing profiles: `ocx profile list`
179+
4. If the profile already exists, remove it first: `ocx profile rm <name>`
170180

171181
### Missing skills after OCX install
172182
Individual skills installed via OCX do not automatically get "bootstrap injection" (the automatic loading of `using-systematic`). To get this feature, you must have the `@fro.bot/systematic` plugin installed in your project or globally.
@@ -176,6 +186,5 @@ If `ocx diff` shows differences but `ocx update` says you are up to date, the re
176186

177187
## Technical Details
178188

179-
- **Registry URL:** `https://fro.bot/systematic/registry/`
180-
- **Discovery Path:** `/.well-known/ocx.json`
189+
- **Registry URL:** `https://fro.bot/systematic`
181190
- **Compatibility:** Systematic components are compatible with OpenCode 0.12.0 and later.

registry/registry.jsonc

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -743,6 +743,48 @@
743743
],
744744
"files": []
745745
},
746+
{
747+
"name": "standalone",
748+
"type": "ocx:profile",
749+
"description": "Standalone Systematic profile — structured engineering workflows with zero extra configuration",
750+
"files": [
751+
{
752+
"path": "profiles/standalone/opencode.jsonc",
753+
"target": "opencode.jsonc"
754+
},
755+
{
756+
"path": "profiles/standalone/ocx.jsonc",
757+
"target": "ocx.jsonc"
758+
},
759+
{
760+
"path": "profiles/standalone/AGENTS.md",
761+
"target": "AGENTS.md"
762+
}
763+
]
764+
},
765+
{
766+
"name": "omo",
767+
"type": "ocx:profile",
768+
"description": "OhMyOpenCode (OMO) profile — Systematic with enhanced orchestration and delegation",
769+
"files": [
770+
{
771+
"path": "profiles/omo/opencode.jsonc",
772+
"target": "opencode.jsonc"
773+
},
774+
{
775+
"path": "profiles/omo/ocx.jsonc",
776+
"target": "ocx.jsonc"
777+
},
778+
{
779+
"path": "profiles/omo/oh-my-opencode.jsonc",
780+
"target": "oh-my-opencode.jsonc"
781+
},
782+
{
783+
"path": "profiles/omo/AGENTS.md",
784+
"target": "AGENTS.md"
785+
}
786+
]
787+
},
746788
{
747789
"name": "plugin",
748790
"type": "ocx:plugin",

scripts/build-registry.ts

Lines changed: 90 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@ interface PackumentFile {
5858
interface PackumentVersion {
5959
name: string
6060
type: string
61-
version: string
6261
description?: string
6362
files?: PackumentFile[]
6463
dependencies?: string[]
@@ -74,14 +73,14 @@ interface Packument {
7473
interface IndexComponent {
7574
name: string
7675
type: string
77-
version: string
7876
description?: string
7977
}
8078

8179
interface RegistryIndex {
8280
name: string
8381
namespace: string
8482
version: string
83+
author: string
8584
components: IndexComponent[]
8685
}
8786

@@ -127,7 +126,14 @@ function resolveVersion(explicit: string | null): string {
127126
cwd: PROJECT_ROOT,
128127
}).trim()
129128
if (tag.length > 0) {
130-
return tag
129+
const normalized = tag.startsWith('v') ? tag.slice(1) : tag
130+
if (SEMVER_REGEX.test(normalized)) {
131+
return normalized
132+
}
133+
console.error(
134+
`Error: Invalid git tag format "${tag}". Expected semver or v-prefixed semver (e.g., v1.2.3)`,
135+
)
136+
process.exit(1)
131137
}
132138
} catch {
133139
// git tag failed, fall through to package.json
@@ -190,33 +196,45 @@ function resolveComponentFilePath(
190196
return path.join(PROJECT_ROOT, file.path)
191197
}
192198

199+
if (type === 'ocx:profile') {
200+
return path.join(PROJECT_ROOT, 'registry/files', file.path)
201+
}
202+
193203
return path.join(PROJECT_ROOT, 'registry/files', file.path)
194204
}
195205

196206
function validateRegistry(source: RegistrySource): string[] {
197207
const errors: string[] = []
198208
const componentNames = new Set<string>()
199209

210+
const validators: Record<
211+
string,
212+
(component: RegistryComponent, errors: string[]) => void
213+
> = {
214+
'ocx:skill': validateSkillComponent,
215+
'ocx:agent': validateFileComponent,
216+
'ocx:command': validateFileComponent,
217+
'ocx:bundle': (component, currentErrors) =>
218+
validateBundleComponent(component, source, currentErrors),
219+
'ocx:profile': validateProfileComponent,
220+
'ocx:plugin': () => undefined,
221+
}
222+
200223
for (const component of source.components) {
201224
if (componentNames.has(component.name)) {
202225
errors.push(`Duplicate component name: "${component.name}"`)
203226
}
204227
componentNames.add(component.name)
205228

206-
if (component.type === 'ocx:skill') {
207-
validateSkillComponent(component, errors)
208-
} else if (component.type === 'ocx:agent') {
209-
validateFileComponent(component, errors)
210-
} else if (component.type === 'ocx:command') {
211-
validateFileComponent(component, errors)
212-
} else if (component.type === 'ocx:bundle') {
213-
validateBundleComponent(component, source, errors)
214-
} else if (component.type === 'ocx:plugin') {
215-
} else {
229+
const validator = validators[component.type]
230+
if (validator == null) {
216231
errors.push(
217232
`[${component.name}] Unknown component type: "${component.type}"`,
218233
)
234+
continue
219235
}
236+
237+
validator(component, errors)
220238
}
221239

222240
return errors
@@ -286,6 +304,27 @@ function validateFileComponent(
286304
}
287305
}
288306

307+
function validateProfileComponent(
308+
component: RegistryComponent,
309+
errors: string[],
310+
): void {
311+
const prefix = `[${component.name}]`
312+
313+
if (!Array.isArray(component.files) || component.files.length === 0) {
314+
errors.push(`${prefix} Profile component must have at least one file`)
315+
return
316+
}
317+
318+
for (const file of component.files) {
319+
const diskPath = resolveComponentFilePath(component, file)
320+
if (!fs.existsSync(diskPath)) {
321+
errors.push(
322+
`${prefix} File not found: ${file.path} (expected at ${diskPath})`,
323+
)
324+
}
325+
}
326+
}
327+
289328
function validateBundleComponent(
290329
component: RegistryComponent,
291330
source: RegistrySource,
@@ -329,6 +368,21 @@ function walkFiles(dir: string): string[] {
329368
return results
330369
}
331370

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+
332386
/** SHA-256 integrity: sha256-{base64(digest)} per OCX spec */
333387
function computeIntegrity(content: Buffer): string {
334388
const hash = createHash('sha256').update(content).digest('base64')
@@ -347,7 +401,6 @@ function buildRegistry(source: RegistrySource, version: string): void {
347401
const entry: IndexComponent = {
348402
name: component.name,
349403
type: component.type,
350-
version,
351404
}
352405
if (component.description != null) {
353406
entry.description = component.description
@@ -367,6 +420,7 @@ function buildRegistry(source: RegistrySource, version: string): void {
367420
name: source.name,
368421
namespace: source.namespace,
369422
version,
423+
author: resolveAuthor(source),
370424
components: indexComponents,
371425
}
372426

@@ -392,7 +446,7 @@ function buildPackument(component: RegistryComponent, version: string): void {
392446

393447
packumentFiles.push({
394448
path: file.path,
395-
target: file.target,
449+
target: normalizeTargetPath(file.target),
396450
integrity,
397451
})
398452

@@ -409,7 +463,6 @@ function buildPackument(component: RegistryComponent, version: string): void {
409463
const versionData: PackumentVersion = {
410464
name: component.name,
411465
type: component.type,
412-
version,
413466
}
414467
if (component.description != null) {
415468
versionData.description = component.description
@@ -428,18 +481,37 @@ function buildPackument(component: RegistryComponent, version: string): void {
428481
)
429482
}
430483

484+
function resolveAuthor(source: RegistrySource): string {
485+
if (source.author != null && source.author.trim().length > 0) {
486+
return source.author
487+
}
488+
489+
const pkgPath = path.join(PROJECT_ROOT, 'package.json')
490+
try {
491+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) as {
492+
author?: string
493+
}
494+
const author = pkg.author
495+
if (typeof author === 'string' && author.trim().length > 0) {
496+
return author
497+
}
498+
} catch {}
499+
500+
return ''
501+
}
502+
431503
function buildBundlePackument(
432504
component: RegistryComponent,
433505
version: string,
434506
): void {
435507
const versionData: PackumentVersion = {
436508
name: component.name,
437509
type: component.type,
438-
version,
439510
}
440511
if (component.description != null) {
441512
versionData.description = component.description
442513
}
514+
versionData.files = []
443515
if (Array.isArray(component.dependencies)) {
444516
versionData.dependencies = component.dependencies
445517
}
@@ -463,11 +535,11 @@ function buildPluginPackument(
463535
const versionData: PackumentVersion = {
464536
name: component.name,
465537
type: component.type,
466-
version,
467538
}
468539
if (component.description != null) {
469540
versionData.description = component.description
470541
}
542+
versionData.files = []
471543
if (component.opencode != null) {
472544
versionData.opencode = component.opencode
473545
}

0 commit comments

Comments
 (0)