From 5d3da5b0399ed8a3b57fc21584cef9890de811f7 Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Thu, 27 Aug 2026 20:58:36 -0700 Subject: [PATCH 1/4] Add Codex compatibility discovery HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a0465e-7fad-7fda-8f34-1168081eb729 --- .../src/Compatibility/CodexCompatibility.ts | 40 ++++ .../src/Compatibility/CodexInstructions.ts | 103 +++++++++++ .../src/Compatibility/CodexPlugins.ts | 171 ++++++++++++++++++ .../src/Compatibility/CodexSkills.ts | 140 ++++++++++++++ packages/fold-agent/src/index.ts | 4 + .../CodexCompatibility.vi.test.ts | 107 +++++++++++ 6 files changed, 565 insertions(+) create mode 100644 packages/fold-agent/src/Compatibility/CodexCompatibility.ts create mode 100644 packages/fold-agent/src/Compatibility/CodexInstructions.ts create mode 100644 packages/fold-agent/src/Compatibility/CodexPlugins.ts create mode 100644 packages/fold-agent/src/Compatibility/CodexSkills.ts create mode 100644 packages/fold-agent/test/Compatibility/CodexCompatibility.vi.test.ts diff --git a/packages/fold-agent/src/Compatibility/CodexCompatibility.ts b/packages/fold-agent/src/Compatibility/CodexCompatibility.ts new file mode 100644 index 0000000..8a9e111 --- /dev/null +++ b/packages/fold-agent/src/Compatibility/CodexCompatibility.ts @@ -0,0 +1,40 @@ +import { homedir } from 'node:os' +import { join, resolve } from 'node:path' + +import type { SkillSourceService } from '@humanlayer/fold-core' +import { Effect, type FileSystem } 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 & { + readonly fileSystem?: FileSystem.FileSystem +} + +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( + options.fileSystem === undefined ? { codexHome } : { codexHome, fileSystem: options.fileSystem }, + ) + 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..754b26e --- /dev/null +++ b/packages/fold-agent/src/Compatibility/CodexInstructions.ts @@ -0,0 +1,103 @@ +import { homedir } from 'node:os' +import { dirname, join, resolve } from 'node:path' + +import { Effect, type FileSystem, Schema } from 'effect' + +import { fileSystemFor } from '../Fs/DefaultFileSystem' + +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 + readonly fileSystem?: FileSystem.FileSystem +} + +const readNonEmpty = (fs: FileSystem.FileSystem, path: string): Effect.Effect => + 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> => + Effect.gen(function* () { + const fs = fileSystemFor(options) + 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(fs, join(codexHome, name)) + if (source !== null) { + sources.push({ ...source, scope: 'global' }) + break + } + } + + for (const directory of directoriesToBoundary(cwd, resolvedHome)) { + const override = yield* readNonEmpty(fs, join(directory, 'AGENTS.override.md')) + if (override !== null) { + sources.push(override) + continue + } + + const base = yield* readNonEmpty(fs, join(directory, 'AGENTS.md')) + if (base !== null) sources.push(base) + const local = yield* readNonEmpty(fs, 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..f72eae0 --- /dev/null +++ b/packages/fold-agent/src/Compatibility/CodexPlugins.ts @@ -0,0 +1,171 @@ +import { createHash } from 'node:crypto' +import { join, resolve } from 'node:path' + +import { Effect, type FileSystem, Schema } from 'effect' + +import { fileSystemFor } from '../Fs/DefaultFileSystem' + +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 + readonly fileSystem?: FileSystem.FileSystem +} + +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 = fileSystemFor(options) + 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..1269bf7 --- /dev/null +++ b/packages/fold-agent/src/Compatibility/CodexSkills.ts @@ -0,0 +1,140 @@ +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, type FileSystem } from 'effect' +import { parse as parseYaml } from 'yaml' + +import { fileSystemFor } from '../Fs/DefaultFileSystem' + +export type CodexSkillOptions = { + readonly cwd: string + readonly home?: string + readonly codexHome?: string + readonly fileSystem?: FileSystem.FileSystem + readonly configuredPaths?: ReadonlyArray + readonly bundledPaths?: ReadonlyArray + readonly pluginPaths?: ReadonlyArray<{ readonly name: string; readonly path: string }> +} + +const exists = (fs: FileSystem.FileSystem, path: string): Effect.Effect => + 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 = (fs: FileSystem.FileSystem, path: string, namespace?: string): Effect.Effect => + 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 = (fs: FileSystem.FileSystem, root: string, namespace?: string): Effect.Effect> => + Effect.gen(function* () { + if (!(yield* exists(fs, root))) return [] + const found: Array = [] + const scan = (directory: string): Effect.Effect => + Effect.gen(function* () { + const skillPath = join(directory, 'SKILL.md') + if (yield* exists(fs, skillPath)) { + const skill = yield* loadSkill(fs, 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.sync(() => { + const fs = fileSystemFor(options) + 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(fs, root)) + if (!byName.has(skill.name)) byName.set(skill.name, skill) + } + for (const plugin of options.pluginPaths ?? []) { + for (const skill of yield* scanRoot(fs, plugin.path, plugin.name)) + if (!byName.has(skill.name)) byName.set(skill.name, skill) + } + return byName + }) + 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..7e7fc1d --- /dev/null +++ b/packages/fold-agent/test/Compatibility/CodexCompatibility.vi.test.ts @@ -0,0 +1,107 @@ +import { expect, it } from '@effect/vitest' +import { Effect } 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', + 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', 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', + 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', + fileSystem: fs, + }) + const names = (yield* compatibility.skills.list).map(({ name }) => name) + expect(names).toEqual(['alpha:right', 'semver:new']) + expect(compatibility.diagnostics).toEqual([]) + }), +) From ae6f681387feb4b71a5c5a73d29c30203dec942b Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Thu, 27 Aug 2026 21:02:22 -0700 Subject: [PATCH 2/4] Provide default filesystem for compatibility discovery HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a0465e-7fad-7fda-8f34-1168081eb729 --- .../fold-agent/src/Fs/DefaultFileSystem.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/fold-agent/src/Fs/DefaultFileSystem.ts b/packages/fold-agent/src/Fs/DefaultFileSystem.ts index 0558104..00a1727 100644 --- a/packages/fold-agent/src/Fs/DefaultFileSystem.ts +++ b/packages/fold-agent/src/Fs/DefaultFileSystem.ts @@ -1,2 +1,25 @@ +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' +import { Context, Effect, FileSystem, Layer } from 'effect' + +let nodeFileSystem: FileSystem.FileSystem | null = null + +const defaultNodeFileSystem = (): FileSystem.FileSystem => { + if (nodeFileSystem === null) { + nodeFileSystem = Effect.runSync( + Effect.scoped( + Layer.build(NodeFileSystem.layer).pipe( + Effect.map((context) => Context.get(context, FileSystem.FileSystem)), + ), + ), + ) + } + + return nodeFileSystem +} + +/** Resolve the filesystem a handler should use. */ +export const fileSystemFor = (options?: { readonly fileSystem?: FileSystem.FileSystem }): FileSystem.FileSystem => + options?.fileSystem ?? defaultNodeFileSystem() + /** Resolve the working directory a tool handler should resolve relative paths against. */ export const cwdFor = (options?: { readonly cwd?: string }): string => options?.cwd ?? process.cwd() From c2bc0735006da7e1864ca271897ae91decd94c6e Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Fri, 28 Aug 2026 18:10:10 -0700 Subject: [PATCH 3/4] Model compatibility filesystem as an Effect requirement HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a04abb-cb1a-715c-808b-1d922a5225e1 --- .../src/Compatibility/CodexCompatibility.ts | 10 +- .../src/Compatibility/CodexInstructions.ts | 31 ++++--- .../src/Compatibility/CodexPlugins.ts | 7 +- .../src/Compatibility/CodexSkills.ts | 92 +++++++++++-------- .../fold-agent/src/Fs/DefaultFileSystem.ts | 23 ----- .../CodexCompatibility.vi.test.ts | 15 ++- 6 files changed, 80 insertions(+), 98 deletions(-) diff --git a/packages/fold-agent/src/Compatibility/CodexCompatibility.ts b/packages/fold-agent/src/Compatibility/CodexCompatibility.ts index 8a9e111..c7ab2e6 100644 --- a/packages/fold-agent/src/Compatibility/CodexCompatibility.ts +++ b/packages/fold-agent/src/Compatibility/CodexCompatibility.ts @@ -2,15 +2,13 @@ import { homedir } from 'node:os' import { join, resolve } from 'node:path' import type { SkillSourceService } from '@humanlayer/fold-core' -import { Effect, type FileSystem } from 'effect' +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 & { - readonly fileSystem?: FileSystem.FileSystem -} +export type CodexCompatibilityOptions = CodexSkillOptions export type CodexCompatibility = { readonly instructions: ReadonlyArray @@ -23,9 +21,7 @@ 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( - options.fileSystem === undefined ? { codexHome } : { codexHome, fileSystem: options.fileSystem }, - ) + const plugins = yield* discoverCodexPluginSkillRoots({ codexHome }) const instructions = yield* loadCodexInstructions(options) const skills = yield* makeCodexSkillSource({ ...options, diff --git a/packages/fold-agent/src/Compatibility/CodexInstructions.ts b/packages/fold-agent/src/Compatibility/CodexInstructions.ts index 754b26e..607240c 100644 --- a/packages/fold-agent/src/Compatibility/CodexInstructions.ts +++ b/packages/fold-agent/src/Compatibility/CodexInstructions.ts @@ -1,9 +1,7 @@ import { homedir } from 'node:os' import { dirname, join, resolve } from 'node:path' -import { Effect, type FileSystem, Schema } from 'effect' - -import { fileSystemFor } from '../Fs/DefaultFileSystem' +import { Effect, FileSystem, Schema } from 'effect' export const CodexInstructionSource = Schema.Struct({ path: Schema.String, @@ -16,14 +14,18 @@ export type CodexInstructionOptions = { readonly cwd: string readonly home?: string readonly codexHome?: string - readonly fileSystem?: FileSystem.FileSystem } -const readNonEmpty = (fs: FileSystem.FileSystem, path: string): Effect.Effect => - fs.readFileString(path).pipe( - Effect.map((content) => (content.trim().length === 0 ? null : { path, content, scope: 'ancestor' as const })), - Effect.catch(() => Effect.succeed(null)), - ) +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 @@ -51,9 +53,8 @@ const directoriesToBoundary = (cwd: string, home: string | null): ReadonlyArray< export const loadCodexInstructions = ( options: CodexInstructionOptions, -): Effect.Effect> => +): Effect.Effect, never, FileSystem.FileSystem> => Effect.gen(function* () { - const fs = fileSystemFor(options) const cwd = resolve(options.cwd) const home = options.home === undefined ? homedir() : options.home const resolvedHome = home.length === 0 ? null : resolve(home) @@ -61,7 +62,7 @@ export const loadCodexInstructions = ( const sources: Array = [] for (const name of ['AGENTS.override.md', 'AGENTS.md']) { - const source = yield* readNonEmpty(fs, join(codexHome, name)) + const source = yield* readNonEmpty(join(codexHome, name)) if (source !== null) { sources.push({ ...source, scope: 'global' }) break @@ -69,15 +70,15 @@ export const loadCodexInstructions = ( } for (const directory of directoriesToBoundary(cwd, resolvedHome)) { - const override = yield* readNonEmpty(fs, join(directory, 'AGENTS.override.md')) + const override = yield* readNonEmpty(join(directory, 'AGENTS.override.md')) if (override !== null) { sources.push(override) continue } - const base = yield* readNonEmpty(fs, join(directory, 'AGENTS.md')) + const base = yield* readNonEmpty(join(directory, 'AGENTS.md')) if (base !== null) sources.push(base) - const local = yield* readNonEmpty(fs, join(directory, 'AGENTS.local.md')) + const local = yield* readNonEmpty(join(directory, 'AGENTS.local.md')) if (local !== null) sources.push(local) } diff --git a/packages/fold-agent/src/Compatibility/CodexPlugins.ts b/packages/fold-agent/src/Compatibility/CodexPlugins.ts index f72eae0..23ad7c8 100644 --- a/packages/fold-agent/src/Compatibility/CodexPlugins.ts +++ b/packages/fold-agent/src/Compatibility/CodexPlugins.ts @@ -1,9 +1,7 @@ import { createHash } from 'node:crypto' import { join, resolve } from 'node:path' -import { Effect, type FileSystem, Schema } from 'effect' - -import { fileSystemFor } from '../Fs/DefaultFileSystem' +import { Effect, FileSystem, Schema } from 'effect' export const CodexPluginDiagnostic = Schema.Struct({ stage: Schema.Literals(['config', 'cache', 'manifest']), @@ -21,7 +19,6 @@ export type CodexPluginSkillRoot = { export type CodexPluginOptions = { readonly codexHome: string - readonly fileSystem?: FileSystem.FileSystem } type EnabledPlugin = { readonly name: string; readonly marketplace: string } @@ -110,7 +107,7 @@ const safeRelativeSkillRoot = (value: unknown): string | null => { export const discoverCodexPluginSkillRoots = (options: CodexPluginOptions) => Effect.gen(function* () { - const fs = fileSystemFor(options) + 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(''))) diff --git a/packages/fold-agent/src/Compatibility/CodexSkills.ts b/packages/fold-agent/src/Compatibility/CodexSkills.ts index 1269bf7..5999d90 100644 --- a/packages/fold-agent/src/Compatibility/CodexSkills.ts +++ b/packages/fold-agent/src/Compatibility/CodexSkills.ts @@ -2,23 +2,23 @@ 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, type FileSystem } from 'effect' +import { Effect, FileSystem } from 'effect' import { parse as parseYaml } from 'yaml' -import { fileSystemFor } from '../Fs/DefaultFileSystem' - export type CodexSkillOptions = { readonly cwd: string readonly home?: string readonly codexHome?: string - readonly fileSystem?: FileSystem.FileSystem readonly configuredPaths?: ReadonlyArray readonly bundledPaths?: ReadonlyArray readonly pluginPaths?: ReadonlyArray<{ readonly name: string; readonly path: string }> } -const exists = (fs: FileSystem.FileSystem, path: string): Effect.Effect => - fs.exists(path).pipe(Effect.catch(() => Effect.succeed(false))) +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 @@ -47,39 +47,49 @@ const ancestorSkillRoots = (cwd: string, home: string | null): ReadonlyArray => typeof value === 'object' && value !== null && !Array.isArray(value) -const loadSkill = (fs: FileSystem.FileSystem, path: string, namespace?: string): Effect.Effect => - 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 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 = (fs: FileSystem.FileSystem, root: string, namespace?: string): Effect.Effect> => +const scanRoot = ( + root: string, + namespace?: string, +): Effect.Effect, never, FileSystem.FileSystem> => Effect.gen(function* () { - if (!(yield* exists(fs, root))) return [] + const fs = yield* FileSystem.FileSystem + if (!(yield* exists(root))) return [] const found: Array = [] - const scan = (directory: string): Effect.Effect => + const scan = (directory: string): Effect.Effect => Effect.gen(function* () { const skillPath = join(directory, 'SKILL.md') - if (yield* exists(fs, skillPath)) { - const skill = yield* loadSkill(fs, skillPath, namespace) + if (yield* exists(skillPath)) { + const skill = yield* loadSkill(skillPath, namespace) if (skill !== null) found.push(skill) return } @@ -95,9 +105,11 @@ const scanRoot = (fs: FileSystem.FileSystem, root: string, namespace?: string): return found }) -export const makeCodexSkillSource = (options: CodexSkillOptions): Effect.Effect => - Effect.sync(() => { - const fs = fileSystemFor(options) +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) @@ -112,15 +124,15 @@ export const makeCodexSkillSource = (options: CodexSkillOptions): Effect.Effect< const scan = Effect.gen(function* () { const byName = new Map() for (const root of roots) { - for (const skill of yield* scanRoot(fs, root)) + 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(fs, plugin.path, plugin.name)) + 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) => diff --git a/packages/fold-agent/src/Fs/DefaultFileSystem.ts b/packages/fold-agent/src/Fs/DefaultFileSystem.ts index 00a1727..0558104 100644 --- a/packages/fold-agent/src/Fs/DefaultFileSystem.ts +++ b/packages/fold-agent/src/Fs/DefaultFileSystem.ts @@ -1,25 +1,2 @@ -import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' -import { Context, Effect, FileSystem, Layer } from 'effect' - -let nodeFileSystem: FileSystem.FileSystem | null = null - -const defaultNodeFileSystem = (): FileSystem.FileSystem => { - if (nodeFileSystem === null) { - nodeFileSystem = Effect.runSync( - Effect.scoped( - Layer.build(NodeFileSystem.layer).pipe( - Effect.map((context) => Context.get(context, FileSystem.FileSystem)), - ), - ), - ) - } - - return nodeFileSystem -} - -/** Resolve the filesystem a handler should use. */ -export const fileSystemFor = (options?: { readonly fileSystem?: FileSystem.FileSystem }): FileSystem.FileSystem => - options?.fileSystem ?? defaultNodeFileSystem() - /** Resolve the working directory a tool handler should resolve relative paths against. */ export const cwdFor = (options?: { readonly cwd?: string }): string => options?.cwd ?? process.cwd() diff --git a/packages/fold-agent/test/Compatibility/CodexCompatibility.vi.test.ts b/packages/fold-agent/test/Compatibility/CodexCompatibility.vi.test.ts index 7e7fc1d..9e08fab 100644 --- a/packages/fold-agent/test/Compatibility/CodexCompatibility.vi.test.ts +++ b/packages/fold-agent/test/Compatibility/CodexCompatibility.vi.test.ts @@ -1,5 +1,5 @@ import { expect, it } from '@effect/vitest' -import { Effect } from 'effect' +import { Effect, FileSystem } from 'effect' import { loadCodexCompatibility, loadCodexInstructions, makeCodexSkillSource } from '../../src/index' import { memoryFileSystem } from '../TestHelpers' @@ -23,8 +23,7 @@ it.effect('walks from home to cwd and combines override, base, and local instruc const sources = yield* loadCodexInstructions({ cwd: '/home/user/work/repo', home: '/home/user', - fileSystem: fs, - }) + }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) expect(sources.map(({ path }) => path)).toEqual([ '/home/user/.codex/AGENTS.md', @@ -43,7 +42,9 @@ it.effect('walks to the filesystem root when home is not an ancestor', () => '/srv/AGENTS.md': 'srv', '/srv/repo/AGENTS.md': 'repo', }) - const sources = yield* loadCodexInstructions({ cwd: '/srv/repo', home: '/home/user', fileSystem: fs }) + 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']) }), ) @@ -59,8 +60,7 @@ it.effect('loads ancestor skills and keeps the closest skill name', () => const source = yield* makeCodexSkillSource({ cwd: '/home/user/work/repo', home: '/home/user', - fileSystem: fs, - }) + }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) expect(yield* source.list).toEqual([ { name: 'local', description: 'Local skill' }, { name: 'shared', description: 'Repo skill' }, @@ -98,8 +98,7 @@ it.effect('loads enabled plugin skills from local or the newest cached version', cwd: '/repo', home: '/home/user', codexHome: '/codex', - fileSystem: fs, - }) + }).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([]) From ba67773db15c2ca21094f874095a935c1ef16265 Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Fri, 28 Aug 2026 18:12:52 -0700 Subject: [PATCH 4/4] Format compatibility filesystem refactor HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a04abb-cb1a-715c-808b-1d922a5225e1 --- .../fold-agent/src/Compatibility/CodexSkills.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/fold-agent/src/Compatibility/CodexSkills.ts b/packages/fold-agent/src/Compatibility/CodexSkills.ts index 5999d90..1c54542 100644 --- a/packages/fold-agent/src/Compatibility/CodexSkills.ts +++ b/packages/fold-agent/src/Compatibility/CodexSkills.ts @@ -47,10 +47,7 @@ const ancestorSkillRoots = (cwd: string, home: string | null): ReadonlyArray => typeof value === 'object' && value !== null && !Array.isArray(value) -const loadSkill = ( - path: string, - namespace?: string, -): Effect.Effect => +const loadSkill = (path: string, namespace?: string): Effect.Effect => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem return yield* fs.readFileString(path).pipe( @@ -60,7 +57,11 @@ const loadSkill = ( 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) + if ( + !isRecord(parsed) || + typeof parsed.description !== 'string' || + parsed.description.trim().length === 0 + ) return null const directory = dirname(path) const rawName = @@ -124,8 +125,7 @@ export const makeCodexSkillSource = ( 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 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))