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
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,
}
})
193 changes: 193 additions & 0 deletions packages/fold-agent/src/Compatibility/GrokInstructions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { homedir } from 'node:os'
import { dirname, join, relative, resolve, sep } from 'node:path'

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

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

export type GrokInstructionOptions = {
readonly cwd: string
readonly home?: string
readonly grokHome?: string
readonly projectRoot?: string
}

const instructionNames = [
'Agents.md',
'Claude.md',
'CLAUDE.md',
'CLAUDE.local.md',
'AGENT.md',
'AGENTS.md',
'.claude/CLAUDE.md',
'.claude/CLAUDE.local.md',
] as const

const projectRuleDirectories = ['.grok/rules', '.claude/rules', '.cursor/rules'] as const

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, boundary: string | null): ReadonlyArray<string> => {
const directories: Array<string> = []
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 stripRuleFrontmatter = (content: string): string => {
const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
if (!normalized.startsWith('---\n')) return normalized
const end = normalized.indexOf('\n---', 4)
return end < 0 ? normalized : normalized.slice(end + 4).trimStart()
}

const globPattern = (pattern: string): RegExp => {
const escaped = pattern
.split('')
.map((character) => {
if (character === '*') return '.*'
if (character === '?') return '.'
return /[\\^$+.()|{}[\]]/.test(character) ? `\\${character}` : character
})
.join('')
return new RegExp(`(^|/)${escaped}${pattern.endsWith('/') ? '' : '($|/)'}`)
}

const makeGitIgnorePredicate = (contents: string): ((path: string) => boolean) => {
const rules = contents
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith('#'))
.map((line) => ({
negated: line.startsWith('!'),
pattern: globPattern(line.replace(/^!\/?/, '').replace(/^\//, '')),
}))
return (path) => {
let ignored = false
for (const rule of rules) if (rule.pattern.test(path)) ignored = !rule.negated
return ignored
}
}

const readNonEmpty = (
path: string,
scope: 'global' | 'ancestor',
isRule: boolean,
): Effect.Effect<GrokInstructionSource | null, never, FileSystem.FileSystem> =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
return yield* fs.readFileString(path).pipe(
Effect.map((raw) => {
const content = isRule ? stripRuleFrontmatter(raw) : raw
return content.trim().length === 0 ? null : { path, content, scope }
}),
Effect.orElseSucceed(() => null),
)
})

const markdownRules = (directory: string): Effect.Effect<ReadonlyArray<string>, never, FileSystem.FileSystem> =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
return yield* fs.readDirectory(directory).pipe(
Effect.map((entries) =>
entries
.filter((entry) => entry.toLowerCase().endsWith('.md'))
.sort()
.map((entry) => join(directory, entry)),
),
Effect.orElseSucceed(() => []),
)
})

export const loadGrokInstructions = Effect.fn('fold.grok_compatibility.load_instructions')(function* (
options: GrokInstructionOptions,
) {
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 grokHome = resolve(options.grokHome ?? join(home ?? homedir(), '.grok'))
const explicitRoot = options.projectRoot === undefined ? null : resolve(options.projectRoot)
const boundary =
explicitRoot !== null && isAncestor(explicitRoot, cwd)
? explicitRoot
: home !== null && isAncestor(home, cwd)
? home
: null
const directories = directoriesToBoundary(cwd, boundary)
const ignoreRoot = explicitRoot !== null && isAncestor(explicitRoot, cwd) ? explicitRoot : directories[0]
const gitignore =
ignoreRoot === undefined
? ''
: yield* fs.readFileString(join(ignoreRoot, '.gitignore')).pipe(Effect.orElseSucceed(() => ''))
const isIgnored = makeGitIgnorePredicate(gitignore)
const sources: Array<GrokInstructionSource> = []
const seen = new Set<string>()

const add = (source: GrokInstructionSource | null, projectRoot?: string) => {
if (source === null || seen.has(source.path)) return
if (projectRoot !== undefined) {
const projectPath = relative(projectRoot, source.path).split(sep).join('/')
if (isIgnored(projectPath)) return
}
seen.add(source.path)
sources.push(source)
}

for (const name of instructionNames) add(yield* readNonEmpty(join(grokHome, name), 'global', false))
for (const path of yield* markdownRules(join(grokHome, 'rules'))) add(yield* readNonEmpty(path, 'global', true))
if (home !== null) {
for (const vendor of ['.claude', '.cursor']) {
for (const name of instructionNames) add(yield* readNonEmpty(join(home, vendor, name), 'global', false))
for (const path of yield* markdownRules(join(home, vendor, 'rules')))
add(yield* readNonEmpty(path, 'global', true))
}
}

for (const directory of directories) {
for (const name of instructionNames)
add(yield* readNonEmpty(join(directory, name), 'ancestor', false), ignoreRoot)
for (const rulesDirectory of projectRuleDirectories)
for (const path of yield* markdownRules(join(directory, rulesDirectory)))
add(yield* readNonEmpty(path, 'ancestor', true), ignoreRoot)
}

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 renderGrokInstructions = (sources: ReadonlyArray<GrokInstructionSource>): 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>`
}
156 changes: 156 additions & 0 deletions packages/fold-agent/src/Compatibility/GrokPlugins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { homedir } from 'node:os'
import { basename, dirname, join, resolve } from 'node:path'

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

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

export type GrokPluginSkillRoot = { readonly name: string; readonly path: string }

export type GrokPluginOptions = {
readonly cwd: string
readonly home?: string
readonly grokHome?: string
readonly projectRoot?: string
readonly configuredPaths?: ReadonlyArray<string>
}

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

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 ancestorDirectories = (cwd: string, boundary: string | null): ReadonlyArray<string> => {
const directories: Array<string> = []
let current = cwd
while (true) {
directories.push(current)
if (current === boundary) break
const parent = dirname(current)
if (parent === current) break
current = parent
}
return directories
}

const safeRelativePath = (value: unknown): string | null => {
if (typeof value !== 'string' || value.length === 0 || value.includes('\\') || value.includes('\0')) return null
const normalized = value.replace(/^\.\//, '')
if (
normalized.length === 0 ||
normalized.startsWith('/') ||
/^[A-Za-z]:\//.test(normalized) ||
normalized.split('/').includes('..')
)
return null
return normalized
}

const readManifest = (
root: string,
): Effect.Effect<{ path: string; value: unknown } | null, never, FileSystem.FileSystem> =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
for (const name of ['plugin.json', '.grok-plugin/plugin.json', '.claude-plugin/plugin.json']) {
const path = join(root, name)
const contents = yield* fs.readFileString(path).pipe(Effect.orElseSucceed(() => null))
if (contents === null) continue
const value = yield* Effect.try(() => JSON.parse(contents)).pipe(Effect.orElseSucceed(() => null))
return { path, value }
}
return null
})

export const discoverGrokPluginSkillRoots = Effect.fn('fold.grok_compatibility.discover_plugin_skills')(function* (
options: GrokPluginOptions,
) {
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 grokHome = resolve(options.grokHome ?? join(home ?? homedir(), '.grok'))
const projectRoot = options.projectRoot === undefined ? null : resolve(options.projectRoot)
const boundary =
projectRoot !== null && isAncestor(projectRoot, cwd)
? projectRoot
: home !== null && isAncestor(home, cwd)
? home
: null
const pluginParents = [
...(options.configuredPaths ?? []).map((path) => resolve(path)),
...ancestorDirectories(cwd, boundary).flatMap((directory) => [
join(directory, '.grok', 'plugins'),
join(directory, '.claude', 'plugins'),
]),
join(grokHome, 'plugins'),
...(home === null ? [] : [join(home, '.claude', 'plugins')]),
]
const diagnostics: Array<GrokPluginDiagnostic> = []
const roots: Array<GrokPluginSkillRoot> = []
const seenPaths = new Set<string>()
const seenNames = new Set<string>()

for (const parent of pluginParents) {
const parentManifest = yield* readManifest(parent)
const candidates =
parentManifest === null
? (yield* fs.readDirectory(parent).pipe(Effect.orElseSucceed(() => [])))
.sort((left, right) => left.localeCompare(right))
.map((entry) => join(parent, entry))
: [parent]
for (const candidate of candidates) {
const normalized = resolve(candidate)
if (seenPaths.has(normalized)) continue
seenPaths.add(normalized)
const manifest =
candidate === parent && parentManifest !== null ? parentManifest : yield* readManifest(candidate)
if (manifest !== null && !isRecord(manifest.value)) {
diagnostics.push({ stage: 'manifest', code: 'manifest_parse_failed', path: manifest.path })
continue
}
const manifestValue = manifest === null || !isRecord(manifest.value) ? null : manifest.value
const name =
manifestValue !== null && typeof manifestValue.name === 'string'
? manifestValue.name
: basename(candidate)
if (name.length === 0 || seenNames.has(name)) continue
const declared =
manifestValue === null || manifestValue.skills === undefined
? ['skills']
: Array.isArray(manifestValue.skills)
? manifestValue.skills
: [manifestValue.skills]
const skillRoots: Array<string> = []
for (const value of declared) {
const relativePath = safeRelativePath(value)
if (relativePath === null) {
diagnostics.push({
stage: 'manifest',
code: 'invalid_skill_root',
path: manifest?.path ?? candidate,
})
continue
}
const path = resolve(candidate, relativePath)
if (yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false))) skillRoots.push(path)
}
if (skillRoots.length === 0) continue
seenNames.add(name)
for (const path of skillRoots) roots.push({ name, path })
}
}
return { roots, diagnostics }
})
Loading
Loading