diff --git a/packages/fold-agent/src/Compatibility/CodexCompatibility.ts b/packages/fold-agent/src/Compatibility/CodexCompatibility.ts new file mode 100644 index 0000000..c7ab2e6 --- /dev/null +++ b/packages/fold-agent/src/Compatibility/CodexCompatibility.ts @@ -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 + readonly instructionBlock: string | null + readonly skills: SkillSourceService + readonly diagnostics: ReadonlyArray +} + +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, + } + }) diff --git a/packages/fold-agent/src/Compatibility/CodexInstructions.ts b/packages/fold-agent/src/Compatibility/CodexInstructions.ts new file mode 100644 index 0000000..607240c --- /dev/null +++ b/packages/fold-agent/src/Compatibility/CodexInstructions.ts @@ -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 => + 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 => { + const directories: Array = [] + 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, 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 = [] + + 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, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + +export const renderCodexInstructions = (sources: ReadonlyArray): string | null => { + if (sources.length === 0) return null + return `\n${sources + .map( + (source) => + `\n${source.content.trim()}\n`, + ) + .join('\n')}\n` +} diff --git a/packages/fold-agent/src/Compatibility/CodexPlugins.ts b/packages/fold-agent/src/Compatibility/CodexPlugins.ts new file mode 100644 index 0000000..23ad7c8 --- /dev/null +++ b/packages/fold-agent/src/Compatibility/CodexPlugins.ts @@ -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 => { + if (/^\s*plugins\s*=\s*false\s*$/m.test(contents)) return [] + const plugins: Array = [] + 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 = [] + 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 +} + +const isRecord = (value: unknown): value is Record => + 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 | null => { + if (versions.includes('local')) return 'local' + return versions.reduce( + (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 = [] + const configPath = join(options.codexHome, 'config.toml') + const config = yield* fs.readFileString(configPath).pipe(Effect.catch(() => Effect.succeed(''))) + const roots: Array = [] + 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 = [] + 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 } + }) diff --git a/packages/fold-agent/src/Compatibility/CodexSkills.ts b/packages/fold-agent/src/Compatibility/CodexSkills.ts new file mode 100644 index 0000000..1c54542 --- /dev/null +++ b/packages/fold-agent/src/Compatibility/CodexSkills.ts @@ -0,0 +1,152 @@ +import { homedir } from 'node:os' +import { basename, dirname, join, resolve } from 'node:path' + +import { SkillNotFoundError, type Skill, type SkillMeta, type SkillSourceService } from '@humanlayer/fold-core' +import { Effect, FileSystem } from 'effect' +import { parse as parseYaml } from 'yaml' + +export type CodexSkillOptions = { + readonly cwd: string + readonly home?: string + readonly codexHome?: string + readonly configuredPaths?: ReadonlyArray + readonly bundledPaths?: ReadonlyArray + readonly pluginPaths?: ReadonlyArray<{ readonly name: string; readonly path: string }> +} + +const exists = (path: string): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + return yield* fs.exists(path).pipe(Effect.catch(() => Effect.succeed(false))) + }) + +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 ancestorSkillRoots = (cwd: string, home: string | null): ReadonlyArray => { + const roots: Array = [] + const boundary = home !== null && isAncestor(home, cwd) ? home : null + let current = cwd + while (true) { + roots.push(join(current, '.agents', 'skills')) + if (current === boundary) break + const parent = dirname(current) + if (parent === current) break + current = parent + } + return roots +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + +const loadSkill = (path: string, namespace?: string): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + return yield* fs.readFileString(path).pipe( + Effect.map((raw) => { + const normalized = raw.replace(/\r\n/g, '\n').replace(/\r/g, '\n') + if (!normalized.startsWith('---\n')) return null + const end = normalized.indexOf('\n---', 4) + if (end < 0) return null + const parsed: unknown = parseYaml(normalized.slice(4, end)) + if ( + !isRecord(parsed) || + typeof parsed.description !== 'string' || + parsed.description.trim().length === 0 + ) + return null + const directory = dirname(path) + const rawName = + typeof parsed.name === 'string' && parsed.name.length > 0 ? parsed.name : basename(directory) + const name = namespace === undefined ? rawName : `${namespace}:${rawName}` + return { + name, + description: parsed.description.trim(), + content: normalized.slice(end + 4).trim(), + baseDir: directory, + } + }), + Effect.catch(() => Effect.succeed(null)), + ) + }) + +const scanRoot = ( + root: string, + namespace?: string, +): Effect.Effect, never, FileSystem.FileSystem> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + if (!(yield* exists(root))) return [] + const found: Array = [] + const scan = (directory: string): Effect.Effect => + Effect.gen(function* () { + const skillPath = join(directory, 'SKILL.md') + if (yield* exists(skillPath)) { + const skill = yield* loadSkill(skillPath, namespace) + if (skill !== null) found.push(skill) + return + } + const entries = yield* fs.readDirectory(directory).pipe(Effect.catch(() => Effect.succeed([]))) + for (const entry of [...entries].sort()) { + if (entry.startsWith('.') || entry === 'node_modules') continue + const child = join(directory, entry) + const info = yield* fs.stat(child).pipe(Effect.catch(() => Effect.succeed(null))) + if (info?.type === 'Directory') yield* scan(child) + } + }) + yield* scan(root) + return found + }) + +export const makeCodexSkillSource = ( + options: CodexSkillOptions, +): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const cwd = resolve(options.cwd) + const homeValue = options.home === undefined ? homedir() : options.home + const home = homeValue.length === 0 ? null : resolve(homeValue) + const codexHome = resolve(options.codexHome ?? join(home ?? homedir(), '.codex')) + const roots = [ + ...ancestorSkillRoots(cwd, home), + ...(options.configuredPaths ?? []), + join(codexHome, 'skills'), + ...(home === null ? [] : [join(home, '.agents', 'skills')]), + ...(options.bundledPaths ?? []), + ] + const scan = Effect.gen(function* () { + const byName = new Map() + for (const root of roots) { + for (const skill of yield* scanRoot(root)) if (!byName.has(skill.name)) byName.set(skill.name, skill) + } + for (const plugin of options.pluginPaths ?? []) { + for (const skill of yield* scanRoot(plugin.path, plugin.name)) + if (!byName.has(skill.name)) byName.set(skill.name, skill) + } + return byName + }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) + return { + list: scan.pipe( + Effect.map((skills) => + [...skills.values()].map(({ name, description }): SkillMeta => ({ name, description })), + ), + ), + load: (name) => + scan.pipe( + Effect.flatMap((skills) => { + const skill = skills.get(name) + return skill === undefined + ? Effect.fail(new SkillNotFoundError({ name, availableSkills: [...skills.keys()] })) + : Effect.succeed(skill) + }), + ), + } + }) diff --git a/packages/fold-agent/src/index.ts b/packages/fold-agent/src/index.ts index a9daef3..a02195a 100644 --- a/packages/fold-agent/src/index.ts +++ b/packages/fold-agent/src/index.ts @@ -11,6 +11,10 @@ export * from './Config/Load' export * from './Config/ModelSelections' export * from './Config/ProviderConfig' export * from './Config/FoldInfo' +export * from './Compatibility/CodexCompatibility' +export * from './Compatibility/CodexInstructions' +export * from './Compatibility/CodexPlugins' +export * from './Compatibility/CodexSkills' export * from './EventLog/JsonlDescriptor' export * from './EventLog/JsonlLayer' export * from './Fs/DefaultFileSystem' diff --git a/packages/fold-agent/test/Compatibility/CodexCompatibility.vi.test.ts b/packages/fold-agent/test/Compatibility/CodexCompatibility.vi.test.ts new file mode 100644 index 0000000..9e08fab --- /dev/null +++ b/packages/fold-agent/test/Compatibility/CodexCompatibility.vi.test.ts @@ -0,0 +1,106 @@ +import { expect, it } from '@effect/vitest' +import { Effect, FileSystem } from 'effect' + +import { loadCodexCompatibility, loadCodexInstructions, makeCodexSkillSource } from '../../src/index' +import { memoryFileSystem } from '../TestHelpers' + +const skill = (name: string, description: string, marker = name): string => + ['---', `name: ${name}`, `description: ${description}`, '---', '', marker].join('\n') + +it.effect('walks from home to cwd and combines override, base, and local instructions', () => + Effect.gen(function* () { + const fs = memoryFileSystem({ + '/home/user/.codex/AGENTS.md': 'global', + '/home/user/AGENTS.md': 'home base', + '/home/user/AGENTS.local.md': 'home local', + '/home/user/work/AGENTS.md': 'workspace base must be replaced', + '/home/user/work/AGENTS.override.md': 'workspace override', + '/home/user/work/AGENTS.local.md': 'workspace local must not load with override', + '/home/user/work/repo/AGENTS.local.md': 'repo local only', + '/AGENTS.md': 'outside home boundary', + }) + + const sources = yield* loadCodexInstructions({ + cwd: '/home/user/work/repo', + home: '/home/user', + }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) + + expect(sources.map(({ path }) => path)).toEqual([ + '/home/user/.codex/AGENTS.md', + '/home/user/AGENTS.md', + '/home/user/AGENTS.local.md', + '/home/user/work/AGENTS.override.md', + '/home/user/work/repo/AGENTS.local.md', + ]) + }), +) + +it.effect('walks to the filesystem root when home is not an ancestor', () => + Effect.gen(function* () { + const fs = memoryFileSystem({ + '/AGENTS.md': 'root', + '/srv/AGENTS.md': 'srv', + '/srv/repo/AGENTS.md': 'repo', + }) + const sources = yield* loadCodexInstructions({ cwd: '/srv/repo', home: '/home/user' }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + ) + expect(sources.map(({ path }) => path)).toEqual(['/AGENTS.md', '/srv/AGENTS.md', '/srv/repo/AGENTS.md']) + }), +) + +it.effect('loads ancestor skills and keeps the closest skill name', () => + Effect.gen(function* () { + const fs = memoryFileSystem({ + '/home/user/.agents/skills/global/SKILL.md': skill('global', 'Global skill'), + '/home/user/work/.agents/skills/shared/SKILL.md': skill('shared', 'Workspace skill', 'workspace'), + '/home/user/work/repo/.agents/skills/shared/SKILL.md': skill('shared', 'Repo skill', 'repo'), + '/home/user/work/repo/.agents/skills/local/SKILL.md': skill('local', 'Local skill'), + }) + const source = yield* makeCodexSkillSource({ + cwd: '/home/user/work/repo', + home: '/home/user', + }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) + expect(yield* source.list).toEqual([ + { name: 'local', description: 'Local skill' }, + { name: 'shared', description: 'Repo skill' }, + { name: 'global', description: 'Global skill' }, + ]) + expect((yield* source.load('shared')).content).toBe('repo') + }), +) + +it.effect('loads enabled plugin skills from local or the newest cached version', () => + Effect.gen(function* () { + const fs = memoryFileSystem({ + '/codex/config.toml': [ + '[features]', + 'plugins = true', + '[plugins."alpha@company"]', + 'enabled = true', + '[plugins."semver@company"]', + 'enabled = true', + '[plugins."disabled@company"]', + 'enabled = false', + ].join('\n'), + '/codex/plugins/cache/company/alpha/9.0.0/.codex-plugin/plugin.json': JSON.stringify({ name: 'alpha' }), + '/codex/plugins/cache/company/alpha/9.0.0/skills/wrong/SKILL.md': skill('wrong', 'Wrong'), + '/codex/plugins/cache/company/alpha/local/.codex-plugin/plugin.json': JSON.stringify({ name: 'alpha' }), + '/codex/plugins/cache/company/alpha/local/skills/right/SKILL.md': skill('right', 'Right'), + '/codex/plugins/cache/company/semver/1.9.0/plugin.json': JSON.stringify({ name: 'semver' }), + '/codex/plugins/cache/company/semver/1.9.0/skills/old/SKILL.md': skill('old', 'Old'), + '/codex/plugins/cache/company/semver/1.10.0/plugin.json': JSON.stringify({ name: 'semver' }), + '/codex/plugins/cache/company/semver/1.10.0/skills/new/SKILL.md': skill('new', 'New'), + '/codex/plugins/cache/company/disabled/1.0.0/plugin.json': JSON.stringify({ name: 'disabled' }), + '/codex/plugins/cache/company/disabled/1.0.0/skills/nope/SKILL.md': skill('nope', 'Nope'), + }) + const compatibility = yield* loadCodexCompatibility({ + cwd: '/repo', + home: '/home/user', + codexHome: '/codex', + }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) + const names = (yield* compatibility.skills.list).map(({ name }) => name) + expect(names).toEqual(['alpha:right', 'semver:new']) + expect(compatibility.diagnostics).toEqual([]) + }), +)