Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions packages/fold-agent/src/Compatibility/CodexCompatibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { homedir } from 'node:os'
import { join, resolve } from 'node:path'

import type { SkillSourceService } from '@humanlayer/fold-core'
import { Effect } from 'effect'

import { loadCodexInstructions, renderCodexInstructions, type CodexInstructionSource } from './CodexInstructions'
import { discoverCodexPluginSkillRoots, type CodexPluginDiagnostic } from './CodexPlugins'
import { makeCodexSkillSource, type CodexSkillOptions } from './CodexSkills'

export type CodexCompatibilityOptions = CodexSkillOptions

export type CodexCompatibility = {
readonly instructions: ReadonlyArray<CodexInstructionSource>
readonly instructionBlock: string | null
readonly skills: SkillSourceService
readonly diagnostics: ReadonlyArray<CodexPluginDiagnostic>
}

export const loadCodexCompatibility = (options: CodexCompatibilityOptions) =>
Effect.gen(function* () {
const homeValue = options.home === undefined ? homedir() : options.home
const codexHome = resolve(options.codexHome ?? join(homeValue, '.codex'))
const plugins = yield* discoverCodexPluginSkillRoots({ codexHome })
const instructions = yield* loadCodexInstructions(options)
const skills = yield* makeCodexSkillSource({
...options,
pluginPaths: [...(options.pluginPaths ?? []), ...plugins.roots],
})
return {
instructions,
instructionBlock: renderCodexInstructions(instructions),
skills,
diagnostics: plugins.diagnostics,
}
})
104 changes: 104 additions & 0 deletions packages/fold-agent/src/Compatibility/CodexInstructions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { homedir } from 'node:os'
import { dirname, join, resolve } from 'node:path'

import { Effect, FileSystem, Schema } from 'effect'

export const CodexInstructionSource = Schema.Struct({
path: Schema.String,
content: Schema.String,
scope: Schema.Literals(['global', 'ancestor']),
})
export type CodexInstructionSource = typeof CodexInstructionSource.Type

export type CodexInstructionOptions = {
readonly cwd: string
readonly home?: string
readonly codexHome?: string
}

const readNonEmpty = (path: string): Effect.Effect<CodexInstructionSource | null, never, FileSystem.FileSystem> =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
return yield* fs.readFileString(path).pipe(
Effect.map((content) =>
content.trim().length === 0 ? null : { path, content, scope: 'ancestor' as const },
),
Effect.catch(() => Effect.succeed(null)),
)
})

const isAncestor = (ancestor: string, path: string): boolean => {
let current = path
while (true) {
if (current === ancestor) return true
const parent = dirname(current)
if (parent === current) return false
current = parent
}
}

const directoriesToBoundary = (cwd: string, home: string | null): ReadonlyArray<string> => {
const directories: Array<string> = []
const boundary = home !== null && isAncestor(home, cwd) ? home : null
let current = cwd
while (true) {
directories.push(current)
if (current === boundary) break
const parent = dirname(current)
if (parent === current) break
current = parent
}
return directories.reverse()
}

export const loadCodexInstructions = (
options: CodexInstructionOptions,
): Effect.Effect<ReadonlyArray<CodexInstructionSource>, never, FileSystem.FileSystem> =>
Effect.gen(function* () {
const cwd = resolve(options.cwd)
const home = options.home === undefined ? homedir() : options.home
const resolvedHome = home.length === 0 ? null : resolve(home)
const codexHome = resolve(options.codexHome ?? join(resolvedHome ?? homedir(), '.codex'))
const sources: Array<CodexInstructionSource> = []

for (const name of ['AGENTS.override.md', 'AGENTS.md']) {
const source = yield* readNonEmpty(join(codexHome, name))
if (source !== null) {
sources.push({ ...source, scope: 'global' })
break
}
}

for (const directory of directoriesToBoundary(cwd, resolvedHome)) {
const override = yield* readNonEmpty(join(directory, 'AGENTS.override.md'))
if (override !== null) {
sources.push(override)
continue
}

const base = yield* readNonEmpty(join(directory, 'AGENTS.md'))
if (base !== null) sources.push(base)
const local = yield* readNonEmpty(join(directory, 'AGENTS.local.md'))
if (local !== null) sources.push(local)
}

return sources
})

const escapeXmlAttribute = (text: string): string =>
text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')

export const renderCodexInstructions = (sources: ReadonlyArray<CodexInstructionSource>): string | null => {
if (sources.length === 0) return null
return `<project_context>\n${sources
.map(
(source) =>
`<project_instructions path="${escapeXmlAttribute(source.path)}">\n${source.content.trim()}\n</project_instructions>`,
)
.join('\n')}\n</project_context>`
}
168 changes: 168 additions & 0 deletions packages/fold-agent/src/Compatibility/CodexPlugins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { createHash } from 'node:crypto'
import { join, resolve } from 'node:path'

import { Effect, FileSystem, Schema } from 'effect'

export const CodexPluginDiagnostic = Schema.Struct({
stage: Schema.Literals(['config', 'cache', 'manifest']),
code: Schema.String,
path: Schema.String,
})
export type CodexPluginDiagnostic = typeof CodexPluginDiagnostic.Type

export type CodexPluginSkillRoot = {
readonly name: string
readonly path: string
readonly identityToken: string
readonly versionToken: string
}

export type CodexPluginOptions = {
readonly codexHome: string
}

type EnabledPlugin = { readonly name: string; readonly marketplace: string }

const token = (kind: string, value: string): string =>
`${kind}-${createHash('sha256').update(value).digest('hex').slice(0, 16)}`

const parseEnabledPlugins = (contents: string): ReadonlyArray<EnabledPlugin> => {
if (/^\s*plugins\s*=\s*false\s*$/m.test(contents)) return []
const plugins: Array<EnabledPlugin> = []
const lines = contents.split(/\r?\n/)
for (let index = 0; index < lines.length; index += 1) {
const match = /^\s*\[plugins\."([^"\\/]+)@([^"\\/]+)"\]\s*$/.exec(lines[index] ?? '')
if (match === null) continue
const body: Array<string> = []
for (let bodyIndex = index + 1; bodyIndex < lines.length; bodyIndex += 1) {
const line = lines[bodyIndex] ?? ''
if (/^\s*\[/.test(line)) break
body.push(line)
}
if (body.some((line) => /^\s*enabled\s*=\s*false\s*$/.test(line))) continue
const name = match[1]
const marketplace = match[2]
if (name !== undefined && marketplace !== undefined) plugins.push({ name, marketplace })
}
return plugins
}

type SemanticVersion = {
readonly major: string
readonly minor: string
readonly patch: string
readonly prerelease: Array<string>
}

const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value)

const parseSemanticVersion = (value: string): SemanticVersion | null => {
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(value)
if (match === null || match[1] === undefined || match[2] === undefined || match[3] === undefined) return null
return { major: match[1], minor: match[2], patch: match[3], prerelease: match[4]?.split('.') ?? [] }
}

const compareNumeric = (left: string, right: string): number =>
left.length === right.length ? left.localeCompare(right) : left.length - right.length

const compareVersions = (left: string, right: string): number => {
const leftVersion = parseSemanticVersion(left)
const rightVersion = parseSemanticVersion(right)
if (leftVersion === null || rightVersion === null) return left.localeCompare(right)
for (const field of ['major', 'minor', 'patch'] as const) {
const compared = compareNumeric(leftVersion[field], rightVersion[field])
if (compared !== 0) return compared
}
if (leftVersion.prerelease.length === 0 || rightVersion.prerelease.length === 0) {
return leftVersion.prerelease.length === rightVersion.prerelease.length
? 0
: leftVersion.prerelease.length === 0
? 1
: -1
}
return leftVersion.prerelease.join('.').localeCompare(rightVersion.prerelease.join('.'))
}

const selectedVersion = (versions: ReadonlyArray<string>): string | null => {
if (versions.includes('local')) return 'local'
return versions.reduce<string | null>(
(selected, version) => (selected === null || compareVersions(selected, version) < 0 ? version : selected),
null,
)
}

const safeRelativeSkillRoot = (value: unknown): string | null => {
if (
typeof value !== 'string' ||
!value.startsWith('./') ||
value === './' ||
value.includes('\\') ||
value.includes('\0')
)
return null
if (value.split('/').includes('..')) return null
return value
}

export const discoverCodexPluginSkillRoots = (options: CodexPluginOptions) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const diagnostics: Array<CodexPluginDiagnostic> = []
const configPath = join(options.codexHome, 'config.toml')
const config = yield* fs.readFileString(configPath).pipe(Effect.catch(() => Effect.succeed('')))
const roots: Array<CodexPluginSkillRoot> = []
for (const plugin of parseEnabledPlugins(config)) {
const identity = `${plugin.name}@${plugin.marketplace}`
const cachePath = join(options.codexHome, 'plugins', 'cache', plugin.marketplace, plugin.name)
const entries = yield* fs.readDirectory(cachePath).pipe(Effect.catch(() => Effect.succeed([])))
const directories: Array<string> = []
for (const entry of entries) {
const info = yield* fs.stat(join(cachePath, entry)).pipe(Effect.catch(() => Effect.succeed(null)))
if (info?.type === 'Directory') directories.push(entry)
}
const version = selectedVersion(directories)
if (version === null) continue
const bundle = resolve(cachePath, version)
let manifest: unknown = null
let manifestPath = ''
for (const relativePath of ['plugin.json', '.codex-plugin/plugin.json', '.claude-plugin/plugin.json']) {
const candidate = join(bundle, relativePath)
const contents = yield* fs.readFileString(candidate).pipe(Effect.catch(() => Effect.succeed(null)))
if (contents === null) continue
manifestPath = candidate
try {
manifest = JSON.parse(contents)
} catch {
diagnostics.push({ stage: 'manifest', code: 'manifest_parse_failed', path: candidate })
}
break
}
if (!isRecord(manifest)) continue
const record = manifest
if (record.name !== plugin.name) {
diagnostics.push({ stage: 'manifest', code: 'manifest_name_mismatch', path: manifestPath })
continue
}
const declared =
record.skills === undefined
? ['./skills']
: Array.isArray(record.skills)
? record.skills
: [record.skills]
for (const value of declared) {
const relativeRoot = safeRelativeSkillRoot(value)
if (relativeRoot === null) {
diagnostics.push({ stage: 'manifest', code: 'invalid_skill_root', path: manifestPath })
continue
}
roots.push({
name: plugin.name,
path: resolve(bundle, relativeRoot),
identityToken: token('plugin', identity),
versionToken: token('version', version),
})
}
}
return { roots, diagnostics }
})
Loading
Loading