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
6 changes: 3 additions & 3 deletions packages/fold-agent/src/Compatibility/CodexCompatibility.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { homedir } from 'node:os'
import { join, resolve } from 'node:path'

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

import { loadCodexInstructions, renderCodexInstructions, type CodexInstructionSource } from './CodexInstructions'
import { discoverCodexPluginSkillRoots, type CodexPluginDiagnostic } from './CodexPlugins'
Expand All @@ -19,8 +18,9 @@ export type CodexCompatibility = {

export const loadCodexCompatibility = (options: CodexCompatibilityOptions) =>
Effect.gen(function* () {
const path = yield* Path.Path
const homeValue = options.home === undefined ? homedir() : options.home
const codexHome = resolve(options.codexHome ?? join(homeValue, '.codex'))
const codexHome = path.resolve(options.codexHome ?? path.join(homeValue, '.codex'))
const plugins = yield* discoverCodexPluginSkillRoots({ codexHome })
const instructions = yield* loadCodexInstructions(options)
const skills = yield* makeCodexSkillSource({
Expand Down
73 changes: 40 additions & 33 deletions packages/fold-agent/src/Compatibility/CodexInstructions.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { homedir } from 'node:os'
import { dirname, join, resolve } from 'node:path'

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

export const CodexInstructionSource = Schema.Struct({
path: Schema.String,
Expand All @@ -27,58 +26,66 @@ const readNonEmpty = (path: string): Effect.Effect<CodexInstructionSource | 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 isAncestor = (ancestor: string, candidate: string): Effect.Effect<boolean, never, Path.Path> =>
Effect.gen(function* () {
const path = yield* Path.Path
let current = candidate
while (true) {
if (current === ancestor) return true
const parent = path.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()
}
const directoriesToBoundary = (
cwd: string,
home: string | null,
): Effect.Effect<ReadonlyArray<string>, never, Path.Path> =>
Effect.gen(function* () {
const path = yield* Path.Path
const directories: Array<string> = []
const boundary = home !== null && (yield* isAncestor(home, cwd)) ? home : null
let current = cwd
while (true) {
directories.push(current)
if (current === boundary) break
const parent = path.dirname(current)
if (parent === current) break
current = parent
}
return directories.reverse()
})

export const loadCodexInstructions = (
options: CodexInstructionOptions,
): Effect.Effect<ReadonlyArray<CodexInstructionSource>, never, FileSystem.FileSystem> =>
): Effect.Effect<ReadonlyArray<CodexInstructionSource>, never, FileSystem.FileSystem | Path.Path> =>
Effect.gen(function* () {
const cwd = resolve(options.cwd)
const path = yield* Path.Path
const cwd = path.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 resolvedHome = home.length === 0 ? null : path.resolve(home)
const codexHome = path.resolve(options.codexHome ?? path.join(resolvedHome ?? homedir(), '.codex'))
const sources: Array<CodexInstructionSource> = []

for (const name of ['AGENTS.override.md', 'AGENTS.md']) {
const source = yield* readNonEmpty(join(codexHome, name))
const source = yield* readNonEmpty(path.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'))
for (const directory of yield* directoriesToBoundary(cwd, resolvedHome)) {
const override = yield* readNonEmpty(path.join(directory, 'AGENTS.override.md'))
if (override !== null) {
sources.push(override)
continue
}

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

Expand Down
16 changes: 8 additions & 8 deletions packages/fold-agent/src/Compatibility/CodexPlugins.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { createHash } from 'node:crypto'
import { join, resolve } from 'node:path'

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

export const CodexPluginDiagnostic = Schema.Struct({
stage: Schema.Literals(['config', 'cache', 'manifest']),
Expand Down Expand Up @@ -108,26 +107,27 @@ const safeRelativeSkillRoot = (value: unknown): string | null => {
export const discoverCodexPluginSkillRoots = (options: CodexPluginOptions) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const diagnostics: Array<CodexPluginDiagnostic> = []
const configPath = join(options.codexHome, 'config.toml')
const configPath = path.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 cachePath = path.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)))
const info = yield* fs.stat(path.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)
const bundle = path.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 candidate = path.join(bundle, relativePath)
const contents = yield* fs.readFileString(candidate).pipe(Effect.catch(() => Effect.succeed(null)))
if (contents === null) continue
manifestPath = candidate
Expand Down Expand Up @@ -158,7 +158,7 @@ export const discoverCodexPluginSkillRoots = (options: CodexPluginOptions) =>
}
roots.push({
name: plugin.name,
path: resolve(bundle, relativeRoot),
path: path.resolve(bundle, relativeRoot),
identityToken: token('plugin', identity),
versionToken: token('version', version),
})
Expand Down
89 changes: 49 additions & 40 deletions packages/fold-agent/src/Compatibility/CodexSkills.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
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 { Effect, FileSystem, Path } from 'effect'
import { parse as parseYaml } from 'yaml'

export type CodexSkillOptions = {
Expand All @@ -20,37 +19,45 @@ const exists = (path: string): Effect.Effect<boolean, never, FileSystem.FileSyst
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 isAncestor = (ancestor: string, candidate: string): Effect.Effect<boolean, never, Path.Path> =>
Effect.gen(function* () {
const path = yield* Path.Path
let current = candidate
while (true) {
if (current === ancestor) return true
const parent = path.dirname(current)
if (parent === current) return false
current = parent
}
})

const ancestorSkillRoots = (cwd: string, home: string | null): ReadonlyArray<string> => {
const roots: Array<string> = []
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 ancestorSkillRoots = (cwd: string, home: string | null): Effect.Effect<ReadonlyArray<string>, never, Path.Path> =>
Effect.gen(function* () {
const path = yield* Path.Path
const roots: Array<string> = []
const boundary = home !== null && (yield* isAncestor(home, cwd)) ? home : null
let current = cwd
while (true) {
roots.push(path.join(current, '.agents', 'skills'))
if (current === boundary) break
const parent = path.dirname(current)
if (parent === current) break
current = parent
}
return roots
})

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

const loadSkill = (path: string, namespace?: string): Effect.Effect<Skill | null, never, FileSystem.FileSystem> =>
const loadSkill = (
skillPath: string,
namespace?: string,
): Effect.Effect<Skill | null, never, FileSystem.FileSystem | Path.Path> =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
return yield* fs.readFileString(path).pipe(
const path = yield* Path.Path
return yield* fs.readFileString(skillPath).pipe(
Effect.map((raw) => {
const normalized = raw.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
if (!normalized.startsWith('---\n')) return null
Expand All @@ -63,9 +70,9 @@ const loadSkill = (path: string, namespace?: string): Effect.Effect<Skill | null
parsed.description.trim().length === 0
)
return null
const directory = dirname(path)
const directory = path.dirname(skillPath)
const rawName =
typeof parsed.name === 'string' && parsed.name.length > 0 ? parsed.name : basename(directory)
typeof parsed.name === 'string' && parsed.name.length > 0 ? parsed.name : path.basename(directory)
const name = namespace === undefined ? rawName : `${namespace}:${rawName}`
return {
name,
Expand All @@ -81,14 +88,15 @@ const loadSkill = (path: string, namespace?: string): Effect.Effect<Skill | null
const scanRoot = (
root: string,
namespace?: string,
): Effect.Effect<ReadonlyArray<Skill>, never, FileSystem.FileSystem> =>
): Effect.Effect<ReadonlyArray<Skill>, never, FileSystem.FileSystem | Path.Path> =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
if (!(yield* exists(root))) return []
const found: Array<Skill> = []
const scan = (directory: string): Effect.Effect<void, never, FileSystem.FileSystem> =>
const scan = (directory: string): Effect.Effect<void, never, FileSystem.FileSystem | Path.Path> =>
Effect.gen(function* () {
const skillPath = join(directory, 'SKILL.md')
const skillPath = path.join(directory, 'SKILL.md')
if (yield* exists(skillPath)) {
const skill = yield* loadSkill(skillPath, namespace)
if (skill !== null) found.push(skill)
Expand All @@ -97,7 +105,7 @@ const scanRoot = (
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 child = path.join(directory, entry)
const info = yield* fs.stat(child).pipe(Effect.catch(() => Effect.succeed(null)))
if (info?.type === 'Directory') yield* scan(child)
}
Expand All @@ -108,18 +116,19 @@ const scanRoot = (

export const makeCodexSkillSource = (
options: CodexSkillOptions,
): Effect.Effect<SkillSourceService, never, FileSystem.FileSystem> =>
): Effect.Effect<SkillSourceService, never, FileSystem.FileSystem | Path.Path> =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const cwd = resolve(options.cwd)
const path = yield* Path.Path
const cwd = path.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 home = homeValue.length === 0 ? null : path.resolve(homeValue)
const codexHome = path.resolve(options.codexHome ?? path.join(home ?? homedir(), '.codex'))
const roots = [
...ancestorSkillRoots(cwd, home),
...(yield* ancestorSkillRoots(cwd, home)),
...(options.configuredPaths ?? []),
join(codexHome, 'skills'),
...(home === null ? [] : [join(home, '.agents', 'skills')]),
path.join(codexHome, 'skills'),
...(home === null ? [] : [path.join(home, '.agents', 'skills')]),
...(options.bundledPaths ?? []),
]
const scan = Effect.gen(function* () {
Expand All @@ -132,7 +141,7 @@ export const makeCodexSkillSource = (
if (!byName.has(skill.name)) byName.set(skill.name, skill)
}
return byName
}).pipe(Effect.provideService(FileSystem.FileSystem, fs))
}).pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path))
return {
list: scan.pipe(
Effect.map((skills) =>
Expand Down
41 changes: 41 additions & 0 deletions packages/fold-agent/src/Compatibility/GrokCompatibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { SkillSourceService } from '@humanlayer/fold-core'
import { Effect } from 'effect'

import { loadGrokInstructions, renderGrokInstructions, type GrokInstructionSource } from './GrokInstructions'
import { discoverGrokPluginSkillRoots, type GrokPluginDiagnostic } from './GrokPlugins'
import { makeGrokSkillSource, type GrokSkillOptions } from './GrokSkills'

export type GrokCompatibilityOptions = GrokSkillOptions & {
readonly configuredPluginPaths?: ReadonlyArray<string>
}

export type GrokCompatibility = {
readonly instructions: ReadonlyArray<GrokInstructionSource>
readonly instructionBlock: string | null
readonly skills: SkillSourceService
readonly diagnostics: ReadonlyArray<GrokPluginDiagnostic>
}

export const loadGrokCompatibility = Effect.fn('fold.grok_compatibility.load')(function* (
options: GrokCompatibilityOptions,
) {
const pluginOptions = {
cwd: options.cwd,
...(options.home === undefined ? {} : { home: options.home }),
...(options.grokHome === undefined ? {} : { grokHome: options.grokHome }),
...(options.projectRoot === undefined ? {} : { projectRoot: options.projectRoot }),
...(options.configuredPluginPaths === undefined ? {} : { configuredPaths: options.configuredPluginPaths }),
}
const plugins = yield* discoverGrokPluginSkillRoots(pluginOptions)
const instructions = yield* loadGrokInstructions(options)
const skills = yield* makeGrokSkillSource({
...options,
pluginPaths: [...(options.pluginPaths ?? []), ...plugins.roots],
})
return {
instructions,
instructionBlock: renderGrokInstructions(instructions),
skills,
diagnostics: plugins.diagnostics,
}
})
Loading
Loading