ocx 0.1.1 → 1.0.1

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.
@@ -1,168 +0,0 @@
1
- /**
2
- * Registry Fetcher with in-memory caching
3
- * Based on: https://github.com/shadcn-ui/ui/blob/main/packages/shadcn/src/registry/fetcher.ts
4
- */
5
-
6
- import type { ComponentManifest, RegistryIndex, McpServer, Packument } from "../schemas/registry.js"
7
- import {
8
- componentManifestSchema,
9
- registryIndexSchema,
10
- packumentSchema,
11
- } from "../schemas/registry.js"
12
- import { NotFoundError, NetworkError, ValidationError } from "../utils/errors.js"
13
-
14
- // In-memory cache for deduplication
15
- const cache = new Map<string, Promise<unknown>>()
16
-
17
- /**
18
- * Fetch with caching - deduplicates concurrent requests
19
- */
20
- async function fetchWithCache<T>(url: string, parse: (data: unknown) => T): Promise<T> {
21
- const cached = cache.get(url)
22
- if (cached) {
23
- return cached as Promise<T>
24
- }
25
-
26
- const promise = (async () => {
27
- const response = await fetch(url)
28
-
29
- if (!response.ok) {
30
- if (response.status === 404) {
31
- throw new NotFoundError(`Not found: ${url}`)
32
- }
33
- throw new NetworkError(`Failed to fetch ${url}: ${response.status} ${response.statusText}`)
34
- }
35
-
36
- const data = await response.json()
37
- return parse(data)
38
- })()
39
-
40
- cache.set(url, promise)
41
-
42
- // Clean up cache on error
43
- promise.catch(() => cache.delete(url))
44
-
45
- return promise
46
- }
47
-
48
- /**
49
- * Clear the fetch cache
50
- */
51
- export function clearCache(): void {
52
- cache.clear()
53
- }
54
-
55
- /**
56
- * Fetch registry index
57
- */
58
- export async function fetchRegistryIndex(baseUrl: string): Promise<RegistryIndex> {
59
- const url = `${baseUrl.replace(/\/$/, "")}/index.json`
60
-
61
- return fetchWithCache(url, (data) => {
62
- const result = registryIndexSchema.safeParse(data)
63
- if (!result.success) {
64
- throw new ValidationError(`Invalid registry format at ${url}: ${result.error.message}`)
65
- }
66
- return result.data
67
- })
68
- }
69
-
70
- /**
71
- * Fetch a component from registry and return the latest manifest
72
- */
73
- export async function fetchComponent(baseUrl: string, name: string): Promise<ComponentManifest> {
74
- const url = `${baseUrl.replace(/\/$/, "")}/components/${name}.json`
75
-
76
- return fetchWithCache(url, (data) => {
77
- // 1. Parse as packument
78
- const packumentResult = packumentSchema.safeParse(data)
79
- if (!packumentResult.success) {
80
- throw new ValidationError(
81
- `Invalid packument format for "${name}": ${packumentResult.error.message}`,
82
- )
83
- }
84
-
85
- const packument = packumentResult.data
86
- const latestVersion = packument["dist-tags"].latest
87
- const manifest = packument.versions[latestVersion]
88
-
89
- if (!manifest) {
90
- throw new ValidationError(
91
- `Component "${name}" has no manifest for latest version ${latestVersion}`,
92
- )
93
- }
94
-
95
- // 2. Validate manifest
96
- const manifestResult = componentManifestSchema.safeParse(manifest)
97
- if (!manifestResult.success) {
98
- throw new ValidationError(
99
- `Invalid component manifest for "${name}@${latestVersion}": ${manifestResult.error.message}`,
100
- )
101
- }
102
-
103
- return manifestResult.data
104
- })
105
- }
106
-
107
- /**
108
- * Fetch multiple components in parallel
109
- */
110
- export async function fetchComponents(
111
- baseUrl: string,
112
- names: string[],
113
- ): Promise<ComponentManifest[]> {
114
- const results = await Promise.allSettled(names.map((name) => fetchComponent(baseUrl, name)))
115
-
116
- const components: ComponentManifest[] = []
117
- const errors: string[] = []
118
-
119
- for (let i = 0; i < results.length; i++) {
120
- const result = results[i]
121
- if (result.status === "fulfilled") {
122
- components.push(result.value)
123
- } else {
124
- errors.push(`${names[i]}: ${result.reason.message}`)
125
- }
126
- }
127
-
128
- if (errors.length > 0) {
129
- throw new NetworkError(`Failed to fetch components:\n${errors.join("\n")}`)
130
- }
131
-
132
- return components
133
- }
134
-
135
- /**
136
- * Check if a component exists in registry
137
- */
138
- export async function componentExists(baseUrl: string, name: string): Promise<boolean> {
139
- try {
140
- await fetchComponent(baseUrl, name)
141
- return true
142
- } catch {
143
- return false
144
- }
145
- }
146
-
147
- /**
148
- * Fetch actual file content from registry
149
- */
150
- export async function fetchFileContent(
151
- baseUrl: string,
152
- componentName: string,
153
- filePath: string,
154
- ): Promise<string> {
155
- const url = `${baseUrl.replace(/\/$/, "")}/components/${componentName}/${filePath}`
156
-
157
- const response = await fetch(url)
158
- if (!response.ok) {
159
- throw new NetworkError(
160
- `Failed to fetch file ${filePath} for ${componentName}: ${response.status} ${response.statusText}`,
161
- )
162
- }
163
-
164
- return response.text()
165
- }
166
-
167
- // Re-export types for convenience
168
- export type { ComponentManifest, RegistryIndex, McpServer }
@@ -1,2 +0,0 @@
1
- export * from "./fetcher.js"
2
- export * from "./resolver.js"
@@ -1,182 +0,0 @@
1
- /**
2
- * OpenCode.json Modifier
3
- * Handles reading, merging, and writing opencode.json configuration
4
- *
5
- * Key responsibilities:
6
- * - Add MCP server definitions
7
- * - Deep merge without clobbering user config
8
- */
9
-
10
- import type { McpServer } from "../schemas/registry.js"
11
-
12
- export interface OpencodeConfig {
13
- $schema?: string
14
- mcp?: Record<string, McpServerConfig>
15
- tools?: Record<string, boolean>
16
- agent?: Record<string, AgentConfig>
17
- default_agent?: string
18
- [key: string]: unknown
19
- }
20
-
21
- export interface McpServerConfig {
22
- type: "remote" | "local"
23
- url?: string
24
- command?: string[]
25
- headers?: Record<string, string>
26
- enabled?: boolean
27
- }
28
-
29
- export interface AgentConfig {
30
- disable?: boolean
31
- tools?: Record<string, boolean>
32
- [key: string]: unknown
33
- }
34
-
35
- /**
36
- * Read opencode.json or opencode.jsonc from a directory
37
- */
38
- export async function readOpencodeConfig(cwd: string): Promise<{
39
- config: OpencodeConfig
40
- path: string
41
- } | null> {
42
- const jsonPath = `${cwd}/opencode.json`
43
- const jsoncPath = `${cwd}/opencode.jsonc`
44
-
45
- // Try opencode.jsonc first, then opencode.json
46
- for (const configPath of [jsoncPath, jsonPath]) {
47
- const file = Bun.file(configPath)
48
- if (await file.exists()) {
49
- const content = await file.text()
50
- // Strip comments for JSONC
51
- const stripped = configPath.endsWith(".jsonc") ? stripJsonComments(content) : content
52
- return {
53
- config: JSON.parse(stripped) as OpencodeConfig,
54
- path: configPath,
55
- }
56
- }
57
- }
58
-
59
- return null
60
- }
61
-
62
- /**
63
- * Write opencode.json config
64
- */
65
- export async function writeOpencodeConfig(path: string, config: OpencodeConfig): Promise<void> {
66
- const content = JSON.stringify(config, null, 2)
67
- await Bun.write(path, content)
68
- }
69
-
70
- /**
71
- * Apply MCP servers to opencode config
72
- * Non-destructive: only adds new servers, doesn't overwrite existing
73
- */
74
- export function applyMcpServers(
75
- config: OpencodeConfig,
76
- mcpServers: Record<string, McpServer>,
77
- ): { config: OpencodeConfig; added: string[]; skipped: string[] } {
78
- const added: string[] = []
79
- const skipped: string[] = []
80
-
81
- if (!config.mcp) {
82
- config.mcp = {}
83
- }
84
-
85
- for (const [name, server] of Object.entries(mcpServers)) {
86
- if (config.mcp[name]) {
87
- // Already exists, skip
88
- skipped.push(name)
89
- } else {
90
- // Add new server
91
- const serverConfig: McpServerConfig = {
92
- type: server.type,
93
- enabled: server.enabled,
94
- }
95
-
96
- if (server.type === "remote" && server.url) {
97
- serverConfig.url = server.url
98
- }
99
- if (server.type === "local" && server.command) {
100
- serverConfig.command = server.command
101
- }
102
- if (server.headers) {
103
- serverConfig.headers = server.headers
104
- }
105
-
106
- config.mcp[name] = serverConfig
107
- added.push(name)
108
- }
109
- }
110
-
111
- return { config, added, skipped }
112
- }
113
-
114
- /**
115
- * Strip JSON comments (simple implementation)
116
- */
117
- function stripJsonComments(content: string): string {
118
- // Remove single-line comments
119
- let result = content.replace(/\/\/.*$/gm, "")
120
- // Remove multi-line comments
121
- result = result.replace(/\/\*[\s\S]*?\*\//g, "")
122
- return result
123
- }
124
-
125
- /**
126
- * Create or update opencode.json with required configuration
127
- */
128
- export async function updateOpencodeConfig(
129
- cwd: string,
130
- options: {
131
- mcpServers?: Record<string, McpServer>
132
- defaultAgent?: string
133
- },
134
- ): Promise<{
135
- path: string
136
- created: boolean
137
- mcpAdded: string[]
138
- mcpSkipped: string[]
139
- }> {
140
- let existing = await readOpencodeConfig(cwd)
141
- let config: OpencodeConfig
142
- let configPath: string
143
- let created = false
144
-
145
- if (existing) {
146
- config = existing.config
147
- configPath = existing.path
148
- } else {
149
- // Create new config
150
- config = {
151
- $schema: "https://opencode.ai/config.json",
152
- }
153
- configPath = `${cwd}/opencode.json`
154
- created = true
155
- }
156
-
157
- let mcpAdded: string[] = []
158
- let mcpSkipped: string[] = []
159
-
160
- // Apply MCP servers
161
- if (options.mcpServers && Object.keys(options.mcpServers).length > 0) {
162
- const result = applyMcpServers(config, options.mcpServers)
163
- config = result.config
164
- mcpAdded = result.added
165
- mcpSkipped = result.skipped
166
- }
167
-
168
- // Set default agent if provided and not already set
169
- if (options.defaultAgent && !config.default_agent) {
170
- config.default_agent = options.defaultAgent
171
- }
172
-
173
- // Write config
174
- await writeOpencodeConfig(configPath, config)
175
-
176
- return {
177
- path: configPath,
178
- created,
179
- mcpAdded,
180
- mcpSkipped,
181
- }
182
- }
@@ -1,127 +0,0 @@
1
- /**
2
- * Dependency Resolver with topological sort
3
- * Based on: https://github.com/shadcn-ui/ui/blob/main/packages/shadcn/src/registry/resolver.ts
4
- */
5
-
6
- import type { ComponentManifest, McpServer } from "../schemas/registry.js"
7
- import { fetchComponent } from "./fetcher.js"
8
- import { ValidationError, OCXError } from "../utils/errors.js"
9
- import type { RegistryConfig } from "../schemas/config.js"
10
-
11
- export interface ResolvedComponent extends ComponentManifest {
12
- registryName: string
13
- baseUrl: string
14
- }
15
-
16
- export interface ResolvedDependencies {
17
- /** All components in dependency order (dependencies first) */
18
- components: ResolvedComponent[]
19
- /** Install order (component names) */
20
- installOrder: string[]
21
- /** Aggregated MCP servers from all components */
22
- mcpServers: Record<string, McpServer>
23
- }
24
-
25
- /**
26
- * Resolve all dependencies for a set of components across multiple registries
27
- * Returns components in topological order (dependencies first)
28
- */
29
- export async function resolveDependencies(
30
- registries: Record<string, RegistryConfig>,
31
- componentNames: string[],
32
- ): Promise<ResolvedDependencies> {
33
- const resolved = new Map<string, ResolvedComponent>()
34
- const visiting = new Set<string>()
35
- const mcpServers: Record<string, McpServer> = {}
36
-
37
- async function resolve(name: string, path: string[] = []): Promise<void> {
38
- // Already resolved
39
- if (resolved.has(name)) {
40
- return
41
- }
42
-
43
- // Cycle detection
44
- if (visiting.has(name)) {
45
- const cycle = [...path, name].join(" → ")
46
- throw new ValidationError(`Circular dependency detected: ${cycle}`)
47
- }
48
-
49
- visiting.add(name)
50
-
51
- // Find component in any registry
52
- let component: ComponentManifest | null = null
53
- let foundRegistry: { name: string; url: string } | null = null
54
-
55
- const registryEntries = Object.entries(registries)
56
-
57
- for (const [regName, regConfig] of registryEntries) {
58
- try {
59
- const manifest = await fetchComponent(regConfig.url, name)
60
- component = manifest
61
- foundRegistry = { name: regName, url: regConfig.url }
62
- break
63
- } catch (_err) {
64
- // Component not in this registry, try next
65
- continue
66
- }
67
- }
68
-
69
- if (!component || !foundRegistry) {
70
- throw new OCXError(
71
- `Component '${name}' not found in any configured registry.`,
72
- "NOT_FOUND",
73
- )
74
- }
75
-
76
- // Resolve dependencies first (depth-first)
77
- for (const dep of component.dependencies) {
78
- await resolve(dep, [...path, name])
79
- }
80
-
81
- // Add to resolved (dependencies are already added)
82
- resolved.set(name, {
83
- ...component,
84
- registryName: foundRegistry.name,
85
- baseUrl: foundRegistry.url,
86
- })
87
- visiting.delete(name)
88
-
89
- // Collect MCP servers
90
- if (component.mcpServers) {
91
- for (const [serverName, config] of Object.entries(component.mcpServers)) {
92
- mcpServers[serverName] = config as McpServer
93
- }
94
- }
95
- }
96
-
97
- // Resolve all requested components
98
- for (const name of componentNames) {
99
- await resolve(name)
100
- }
101
-
102
- // Convert to array (already in topological order due to depth-first)
103
- const components = Array.from(resolved.values())
104
- const installOrder = Array.from(resolved.keys())
105
-
106
- return {
107
- components,
108
- installOrder,
109
- mcpServers,
110
- }
111
- }
112
-
113
- /**
114
- * Check if installing components would create conflicts
115
- */
116
- export function checkConflicts(existing: string[], toInstall: string[]): string[] {
117
- const conflicts: string[] = []
118
- const existingSet = new Set(existing)
119
-
120
- for (const name of toInstall) {
121
- if (existingSet.has(name)) {
122
- conflicts.push(name)
123
- }
124
- }
125
-
126
- return conflicts
127
- }
@@ -1,207 +0,0 @@
1
- /**
2
- * Config & Lockfile Schemas
3
- *
4
- * Schemas for ocx.jsonc (user config) and ocx.lock (auto-generated lockfile).
5
- */
6
-
7
- import { z } from "zod"
8
- import { mcpServerSchema } from "./registry.js"
9
-
10
- // =============================================================================
11
- // OCX CONFIG SCHEMA (ocx.jsonc)
12
- // =============================================================================
13
-
14
- /**
15
- * Registry configuration in ocx.jsonc
16
- */
17
- export const registryConfigSchema = z.object({
18
- /** Registry URL */
19
- url: z.string().url("Registry URL must be a valid URL"),
20
-
21
- /** Optional version pin */
22
- version: z.string().optional(),
23
-
24
- /** Optional auth headers (supports ${ENV_VAR} expansion) */
25
- headers: z.record(z.string()).optional(),
26
- })
27
-
28
- export type RegistryConfig = z.infer<typeof registryConfigSchema>
29
-
30
- /**
31
- * Main OCX config schema (ocx.jsonc)
32
- */
33
- export const ocxConfigSchema = z.object({
34
- /** Schema URL for IDE support */
35
- $schema: z.string().optional(),
36
-
37
- /** Configured registries */
38
- registries: z.record(registryConfigSchema).default({}),
39
-
40
- /** Lock registries - prevent adding/removing (enterprise feature) */
41
- lockRegistries: z.boolean().default(false),
42
- })
43
-
44
- export type OcxConfig = z.infer<typeof ocxConfigSchema>
45
-
46
- // =============================================================================
47
- // OCX LOCKFILE SCHEMA (ocx.lock)
48
- // =============================================================================
49
-
50
- /**
51
- * Installed component entry in lockfile
52
- */
53
- export const installedComponentSchema = z.object({
54
- /** Registry this was installed from */
55
- registry: z.string(),
56
-
57
- /** Version at time of install */
58
- version: z.string(),
59
-
60
- /** SHA-256 hash of installed files for integrity */
61
- hash: z.string(),
62
-
63
- /** Target path where installed */
64
- target: z.string(),
65
-
66
- /** ISO timestamp of installation */
67
- installedAt: z.string(),
68
- })
69
-
70
- export type InstalledComponent = z.infer<typeof installedComponentSchema>
71
-
72
- /**
73
- * OCX lockfile schema (ocx.lock)
74
- */
75
- export const ocxLockSchema = z.object({
76
- /** Lockfile format version */
77
- lockVersion: z.literal(1),
78
-
79
- /** Installed components */
80
- installed: z.record(installedComponentSchema).default({}),
81
- })
82
-
83
- export type OcxLock = z.infer<typeof ocxLockSchema>
84
-
85
- // =============================================================================
86
- // OPENCODE.JSON MODIFICATION SCHEMAS
87
- // =============================================================================
88
-
89
- /**
90
- * MCP server config for opencode.json
91
- */
92
- export const opencodeMcpSchema = z.record(mcpServerSchema)
93
-
94
- /**
95
- * Agent config for opencode.json
96
- */
97
- export const opencodeAgentSchema = z.object({
98
- disable: z.boolean().optional(),
99
- tools: z.record(z.boolean()).optional(),
100
- })
101
-
102
- /**
103
- * Partial opencode.json schema (what OCX modifies)
104
- */
105
- export const opencodeConfigPatchSchema = z.object({
106
- /** Default agent */
107
- default_agent: z.string().optional(),
108
-
109
- /** MCP servers */
110
- mcp: opencodeMcpSchema.optional(),
111
-
112
- /** Tool configuration */
113
- tools: z.record(z.boolean()).optional(),
114
-
115
- /** Agent configuration */
116
- agent: z.record(opencodeAgentSchema).optional(),
117
- })
118
-
119
- export type OpencodeConfigPatch = z.infer<typeof opencodeConfigPatchSchema>
120
-
121
- // =============================================================================
122
- // SCHEMA INDEX
123
- // =============================================================================
124
-
125
- export const schemas = {
126
- config: ocxConfigSchema,
127
- lock: ocxLockSchema,
128
- registryConfig: registryConfigSchema,
129
- installedComponent: installedComponentSchema,
130
- opencodeConfigPatch: opencodeConfigPatchSchema,
131
- } as const
132
-
133
- // =============================================================================
134
- // CONFIG FILE HELPERS
135
- // =============================================================================
136
-
137
- const CONFIG_FILE = "ocx.jsonc"
138
- const LOCK_FILE = "ocx.lock"
139
-
140
- /**
141
- * Strip JSONC comments for parsing.
142
- * Robust version that respects strings (avoids breaking URLs).
143
- */
144
- export function stripJsonComments(content: string): string {
145
- // Regex that matches either a string, a block comment, or a line comment
146
- // We use this to correctly skip slashes inside strings
147
- return content.replace(/("(?:[^"\\]|\\.)*")|(\/\*[\s\S]*?\*\/)|(\/\/.*)$/gm, (match, string, block, line) => {
148
- if (string) return string; // Return string as is
149
- return ""; // Remove comment
150
- });
151
- }
152
-
153
- /**
154
- * Read ocx.jsonc config file
155
- */
156
- export async function readOcxConfig(cwd: string): Promise<OcxConfig | null> {
157
- const configPath = `${cwd}/${CONFIG_FILE}`
158
- const file = Bun.file(configPath)
159
-
160
- if (!(await file.exists())) {
161
- return null
162
- }
163
-
164
- const content = await file.text()
165
- try {
166
- const json = JSON.parse(stripJsonComments(content))
167
- return ocxConfigSchema.parse(json)
168
- } catch (error) {
169
- // If parsing fails, we want to know why
170
- console.error(`Error parsing ${configPath}:`, error)
171
- throw error
172
- }
173
- }
174
-
175
- /**
176
- * Write ocx.jsonc config file
177
- */
178
- export async function writeOcxConfig(cwd: string, config: OcxConfig): Promise<void> {
179
- const configPath = `${cwd}/${CONFIG_FILE}`
180
- const content = JSON.stringify(config, null, 2)
181
- await Bun.write(configPath, content)
182
- }
183
-
184
- /**
185
- * Read ocx.lock lockfile
186
- */
187
- export async function readOcxLock(cwd: string): Promise<OcxLock | null> {
188
- const lockPath = `${cwd}/${LOCK_FILE}`
189
- const file = Bun.file(lockPath)
190
-
191
- if (!(await file.exists())) {
192
- return null
193
- }
194
-
195
- const content = await file.text()
196
- const json = JSON.parse(stripJsonComments(content))
197
- return ocxLockSchema.parse(json)
198
- }
199
-
200
- /**
201
- * Write ocx.lock lockfile
202
- */
203
- export async function writeOcxLock(cwd: string, lock: OcxLock): Promise<void> {
204
- const lockPath = `${cwd}/${LOCK_FILE}`
205
- const content = JSON.stringify(lock, null, 2)
206
- await Bun.write(lockPath, content)
207
- }
@@ -1,6 +0,0 @@
1
- /**
2
- * Schema exports
3
- */
4
-
5
- export * from "./registry.js"
6
- export * from "./config.js"