diff --git a/packages/fold-agent/src/Compatibility/CodexCompatibility.ts b/packages/fold-agent/src/Compatibility/CodexCompatibility.ts index c7ab2e6..43b7053 100644 --- a/packages/fold-agent/src/Compatibility/CodexCompatibility.ts +++ b/packages/fold-agent/src/Compatibility/CodexCompatibility.ts @@ -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' @@ -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({ diff --git a/packages/fold-agent/src/Compatibility/CodexInstructions.ts b/packages/fold-agent/src/Compatibility/CodexInstructions.ts index 607240c..261968e 100644 --- a/packages/fold-agent/src/Compatibility/CodexInstructions.ts +++ b/packages/fold-agent/src/Compatibility/CodexInstructions.ts @@ -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, @@ -27,58 +26,66 @@ const readNonEmpty = (path: string): Effect.Effect { - 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 => + 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 => { - 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() -} +const directoriesToBoundary = ( + cwd: string, + home: string | null, +): Effect.Effect, never, Path.Path> => + Effect.gen(function* () { + const path = yield* Path.Path + const directories: Array = [] + 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, never, FileSystem.FileSystem> => +): Effect.Effect, 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 = [] 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) } diff --git a/packages/fold-agent/src/Compatibility/CodexPlugins.ts b/packages/fold-agent/src/Compatibility/CodexPlugins.ts index 23ad7c8..b6c5b3a 100644 --- a/packages/fold-agent/src/Compatibility/CodexPlugins.ts +++ b/packages/fold-agent/src/Compatibility/CodexPlugins.ts @@ -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']), @@ -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 = [] - 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 = [] 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 = [] 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 @@ -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), }) diff --git a/packages/fold-agent/src/Compatibility/CodexSkills.ts b/packages/fold-agent/src/Compatibility/CodexSkills.ts index 1c54542..3ecfb3e 100644 --- a/packages/fold-agent/src/Compatibility/CodexSkills.ts +++ b/packages/fold-agent/src/Compatibility/CodexSkills.ts @@ -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 = { @@ -20,37 +19,45 @@ const exists = (path: string): Effect.Effect 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 => + 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 => { - 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 ancestorSkillRoots = (cwd: string, home: string | null): Effect.Effect, never, Path.Path> => + Effect.gen(function* () { + const path = yield* Path.Path + const roots: Array = [] + 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 => typeof value === 'object' && value !== null && !Array.isArray(value) -const loadSkill = (path: string, namespace?: string): Effect.Effect => +const loadSkill = ( + skillPath: string, + namespace?: string, +): Effect.Effect => 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 @@ -63,9 +70,9 @@ const loadSkill = (path: string, namespace?: string): Effect.Effect 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, @@ -81,14 +88,15 @@ const loadSkill = (path: string, namespace?: string): Effect.Effect, never, FileSystem.FileSystem> => +): Effect.Effect, 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 = [] - const scan = (directory: string): Effect.Effect => + const scan = (directory: string): Effect.Effect => 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) @@ -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) } @@ -108,18 +116,19 @@ const scanRoot = ( export const makeCodexSkillSource = ( options: CodexSkillOptions, -): Effect.Effect => +): Effect.Effect => 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* () { @@ -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) => diff --git a/packages/fold-agent/src/Compatibility/GrokCompatibility.ts b/packages/fold-agent/src/Compatibility/GrokCompatibility.ts new file mode 100644 index 0000000..8e699cb --- /dev/null +++ b/packages/fold-agent/src/Compatibility/GrokCompatibility.ts @@ -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 +} + +export type GrokCompatibility = { + readonly instructions: ReadonlyArray + readonly instructionBlock: string | null + readonly skills: SkillSourceService + readonly diagnostics: ReadonlyArray +} + +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, + } +}) diff --git a/packages/fold-agent/src/Compatibility/GrokInstructions.ts b/packages/fold-agent/src/Compatibility/GrokInstructions.ts new file mode 100644 index 0000000..92419e5 --- /dev/null +++ b/packages/fold-agent/src/Compatibility/GrokInstructions.ts @@ -0,0 +1,202 @@ +import { homedir } from 'node:os' + +import { Effect, FileSystem, Path, 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, candidate: string): Effect.Effect => + 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, + boundary: string | null, +): Effect.Effect, never, Path.Path> => + Effect.gen(function* () { + const path = yield* Path.Path + const directories: Array = [] + 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() + }) + +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 => + 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, never, FileSystem.FileSystem | Path.Path> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + return yield* fs.readDirectory(directory).pipe( + Effect.map((entries) => + entries + .filter((entry) => entry.toLowerCase().endsWith('.md')) + .sort() + .map((entry) => path.join(directory, entry)), + ), + Effect.orElseSucceed(() => []), + ) + }) + +export const loadGrokInstructions = Effect.fn('fold.grok_compatibility.load_instructions')(function* ( + options: GrokInstructionOptions, +) { + const fs = yield* FileSystem.FileSystem + 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 : path.resolve(homeValue) + const grokHome = path.resolve(options.grokHome ?? path.join(home ?? homedir(), '.grok')) + const explicitRoot = options.projectRoot === undefined ? null : path.resolve(options.projectRoot) + const explicitRootIsAncestor = explicitRoot !== null && (yield* isAncestor(explicitRoot, cwd)) + const homeIsAncestor = home !== null && (yield* isAncestor(home, cwd)) + const boundary = explicitRootIsAncestor ? explicitRoot : homeIsAncestor ? home : null + const directories = yield* directoriesToBoundary(cwd, boundary) + const ignoreRoot = explicitRootIsAncestor ? explicitRoot : directories[0] + const gitignore = + ignoreRoot === undefined + ? '' + : yield* fs.readFileString(path.join(ignoreRoot, '.gitignore')).pipe(Effect.orElseSucceed(() => '')) + const isIgnored = makeGitIgnorePredicate(gitignore) + const sources: Array = [] + const seen = new Set() + + const add = (source: GrokInstructionSource | null, projectRoot?: string) => { + if (source === null || seen.has(source.path)) return + if (projectRoot !== undefined) { + const projectPath = path.relative(projectRoot, source.path).split(path.sep).join('/') + if (isIgnored(projectPath)) return + } + seen.add(source.path) + sources.push(source) + } + + for (const name of instructionNames) add(yield* readNonEmpty(path.join(grokHome, name), 'global', false)) + for (const rulePath of yield* markdownRules(path.join(grokHome, 'rules'))) + add(yield* readNonEmpty(rulePath, 'global', true)) + if (home !== null) { + for (const vendor of ['.claude', '.cursor']) { + for (const name of instructionNames) + add(yield* readNonEmpty(path.join(home, vendor, name), 'global', false)) + for (const rulePath of yield* markdownRules(path.join(home, vendor, 'rules'))) + add(yield* readNonEmpty(rulePath, 'global', true)) + } + } + + for (const directory of directories) { + for (const name of instructionNames) + add(yield* readNonEmpty(path.join(directory, name), 'ancestor', false), ignoreRoot) + for (const rulesDirectory of projectRuleDirectories) + for (const rulePath of yield* markdownRules(path.join(directory, rulesDirectory))) + add(yield* readNonEmpty(rulePath, 'ancestor', true), ignoreRoot) + } + + return sources +}) + +const escapeXmlAttribute = (text: string): string => + text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + +export const renderGrokInstructions = (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/GrokPlugins.ts b/packages/fold-agent/src/Compatibility/GrokPlugins.ts new file mode 100644 index 0000000..a467cae --- /dev/null +++ b/packages/fold-agent/src/Compatibility/GrokPlugins.ts @@ -0,0 +1,161 @@ +import { homedir } from 'node:os' + +import { Effect, FileSystem, Path, 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 +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + +const isAncestor = (ancestor: string, candidate: string): Effect.Effect => + 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 ancestorDirectories = ( + cwd: string, + boundary: string | null, +): Effect.Effect, never, Path.Path> => + Effect.gen(function* () { + const path = yield* Path.Path + const directories: Array = [] + 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 + }) + +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 | Path.Path> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + for (const name of ['plugin.json', '.grok-plugin/plugin.json', '.claude-plugin/plugin.json']) { + const manifestPath = path.join(root, name) + const contents = yield* fs.readFileString(manifestPath).pipe(Effect.orElseSucceed(() => null)) + if (contents === null) continue + const value = yield* Effect.try(() => JSON.parse(contents)).pipe(Effect.orElseSucceed(() => null)) + return { path: manifestPath, value } + } + return null + }) + +export const discoverGrokPluginSkillRoots = Effect.fn('fold.grok_compatibility.discover_plugin_skills')(function* ( + options: GrokPluginOptions, +) { + const fs = yield* FileSystem.FileSystem + 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 : path.resolve(homeValue) + const grokHome = path.resolve(options.grokHome ?? path.join(home ?? homedir(), '.grok')) + const projectRoot = options.projectRoot === undefined ? null : path.resolve(options.projectRoot) + const projectRootIsAncestor = projectRoot !== null && (yield* isAncestor(projectRoot, cwd)) + const homeIsAncestor = home !== null && (yield* isAncestor(home, cwd)) + const boundary = projectRootIsAncestor ? projectRoot : homeIsAncestor ? home : null + const pluginParents = [ + ...(options.configuredPaths ?? []).map((configuredPath) => path.resolve(configuredPath)), + ...(yield* ancestorDirectories(cwd, boundary)).flatMap((directory) => [ + path.join(directory, '.grok', 'plugins'), + path.join(directory, '.claude', 'plugins'), + ]), + path.join(grokHome, 'plugins'), + ...(home === null ? [] : [path.join(home, '.claude', 'plugins')]), + ] + const diagnostics: Array = [] + const roots: Array = [] + const seenPaths = new Set() + const seenNames = new Set() + + 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) => path.join(parent, entry)) + : [parent] + for (const candidate of candidates) { + const normalized = path.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 + : path.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 = [] + 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 skillRoot = path.resolve(candidate, relativePath) + if (yield* fs.exists(skillRoot).pipe(Effect.orElseSucceed(() => false))) skillRoots.push(skillRoot) + } + if (skillRoots.length === 0) continue + seenNames.add(name) + for (const skillRoot of skillRoots) roots.push({ name, path: skillRoot }) + } + } + return { roots, diagnostics } +}) diff --git a/packages/fold-agent/src/Compatibility/GrokSkills.ts b/packages/fold-agent/src/Compatibility/GrokSkills.ts new file mode 100644 index 0000000..994cf0c --- /dev/null +++ b/packages/fold-agent/src/Compatibility/GrokSkills.ts @@ -0,0 +1,190 @@ +import { homedir } from 'node:os' + +import { SkillNotFoundError, type Skill, type SkillMeta, type SkillSourceService } from '@humanlayer/fold-core' +import { Effect, FileSystem, Path } from 'effect' +import { parse as parseYaml } from 'yaml' + +export type GrokSkillOptions = { + readonly cwd: string + readonly home?: string + readonly grokHome?: string + readonly projectRoot?: string + readonly configuredPaths?: ReadonlyArray + readonly bundledPaths?: ReadonlyArray + readonly pluginPaths?: ReadonlyArray<{ readonly name: string; readonly path: string }> + readonly ignoredPaths?: ReadonlyArray +} + +const exists = (path: string): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false)) + }) + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + +const isAncestor = (ancestor: string, candidate: string): Effect.Effect => + 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, + boundary: string | null, +): Effect.Effect, never, Path.Path> => + Effect.gen(function* () { + const path = yield* Path.Path + const roots: Array = [] + let current = cwd + while (true) { + for (const vendor of ['.grok', '.agents', '.claude', '.cursor']) + roots.push(path.join(current, vendor, 'skills')) + if (current === boundary) break + const parent = path.dirname(current) + if (parent === current) break + current = parent + } + return roots + }) + +const loadSkill = ( + skillPath: string, + namespace?: string, +): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + return yield* fs.readFileString(skillPath).pipe( + Effect.flatMap((raw) => + Effect.try(() => { + const normalized = raw.replace(/\r\n/g, '\n').replace(/\r/g, '\n') + const directory = path.dirname(skillPath) + let parsed: unknown = null + let content = normalized.trim() + if (normalized.startsWith('---\n')) { + const end = normalized.indexOf('\n---', 4) + if (end >= 0) { + parsed = parseYaml(normalized.slice(4, end)) + content = normalized.slice(end + 4).trim() + } + } + const record = isRecord(parsed) ? parsed : {} + const rawName = + typeof record.name === 'string' && record.name.length > 0 + ? record.name + : path.basename(directory) + const name = namespace === undefined ? rawName : `${namespace}:${rawName}` + const description = + typeof record.description === 'string' && record.description.trim().length > 0 + ? record.description.trim() + : content + .split(/\n\s*\n/)[0] + ?.replace(/^#+\s*/, '') + .trim() || rawName + return { name, description, content, baseDir: directory } + }), + ), + Effect.orElseSucceed(() => null), + ) + }) + +const scanRoot = ( + root: string, + ignoredPaths: ReadonlyArray, + namespace?: string, +): Effect.Effect, 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 = [] + const scan = (directory: string): Effect.Effect => + Effect.gen(function* () { + const resolvedDirectory = path.resolve(directory) + for (const ignoredPath of ignoredPaths) + if (resolvedDirectory === ignoredPath || (yield* isAncestor(ignoredPath, resolvedDirectory))) return + const skillPath = path.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.orElseSucceed(() => [])) + for (const entry of [...entries].sort()) { + if (entry.startsWith('.') || entry === 'node_modules') continue + const child = path.join(directory, entry) + const info = yield* fs.stat(child).pipe(Effect.orElseSucceed(() => null)) + if (info?.type === 'Directory') yield* scan(child) + } + }) + yield* scan(root) + return found + }) + +export const makeGrokSkillSource = Effect.fn('fold.grok_compatibility.make_skill_source')(function* ( + options: GrokSkillOptions, +) { + const fs = yield* FileSystem.FileSystem + 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 : path.resolve(homeValue) + const grokHome = path.resolve(options.grokHome ?? path.join(home ?? homedir(), '.grok')) + const projectRoot = options.projectRoot === undefined ? null : path.resolve(options.projectRoot) + const projectRootIsAncestor = projectRoot !== null && (yield* isAncestor(projectRoot, cwd)) + const homeIsAncestor = home !== null && (yield* isAncestor(home, cwd)) + const boundary = projectRootIsAncestor ? projectRoot : homeIsAncestor ? home : null + const roots = [ + ...(yield* ancestorSkillRoots(cwd, boundary)), + ...(options.configuredPaths ?? []), + path.join(grokHome, 'skills'), + ...(home === null + ? [] + : [ + path.join(home, '.agents', 'skills'), + path.join(home, '.claude', 'skills'), + path.join(home, '.cursor', 'skills'), + ]), + ...(options.bundledPaths ?? []), + ] + const ignoredPaths = (options.ignoredPaths ?? []).map((ignoredPath) => path.resolve(ignoredPath)) + const scanSkillCatalog = Effect.fn('fold.grok_compatibility.scan_skill_catalog')(function* () { + const byName = new Map() + for (const root of roots) + for (const skill of yield* scanRoot(path.resolve(root), ignoredPaths)) + if (!byName.has(skill.name)) byName.set(skill.name, skill) + for (const plugin of options.pluginPaths ?? []) + for (const skill of yield* scanRoot(path.resolve(plugin.path), ignoredPaths, plugin.name)) + if (!byName.has(skill.name)) byName.set(skill.name, skill) + return byName + }) + const scanSkillCatalogWithPlatformServices = () => + scanSkillCatalog().pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ) + return { + list: scanSkillCatalogWithPlatformServices().pipe( + Effect.map((skills) => + [...skills.values()].map(({ name, description }): SkillMeta => ({ name, description })), + ), + ), + load: (name: string) => + scanSkillCatalogWithPlatformServices().pipe( + Effect.flatMap((skills) => { + const skill = skills.get(name) + return skill === undefined + ? Effect.fail(new SkillNotFoundError({ name, availableSkills: [...skills.keys()] })) + : Effect.succeed(skill) + }), + ), + } satisfies SkillSourceService +}) diff --git a/packages/fold-agent/src/index.ts b/packages/fold-agent/src/index.ts index a02195a..fa20eef 100644 --- a/packages/fold-agent/src/index.ts +++ b/packages/fold-agent/src/index.ts @@ -15,6 +15,10 @@ export * from './Compatibility/CodexCompatibility' export * from './Compatibility/CodexInstructions' export * from './Compatibility/CodexPlugins' export * from './Compatibility/CodexSkills' +export * from './Compatibility/GrokCompatibility' +export * from './Compatibility/GrokInstructions' +export * from './Compatibility/GrokPlugins' +export * from './Compatibility/GrokSkills' 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 index 9e08fab..233a47d 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, FileSystem } from 'effect' +import { Effect, FileSystem, Path } from 'effect' import { loadCodexCompatibility, loadCodexInstructions, makeCodexSkillSource } from '../../src/index' import { memoryFileSystem } from '../TestHelpers' @@ -23,7 +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', - }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) + }).pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.provide(Path.layer)) expect(sources.map(({ path }) => path)).toEqual([ '/home/user/.codex/AGENTS.md', @@ -44,6 +44,7 @@ it.effect('walks to the filesystem root when home is not an ancestor', () => }) const sources = yield* loadCodexInstructions({ cwd: '/srv/repo', home: '/home/user' }).pipe( Effect.provideService(FileSystem.FileSystem, fs), + Effect.provide(Path.layer), ) expect(sources.map(({ path }) => path)).toEqual(['/AGENTS.md', '/srv/AGENTS.md', '/srv/repo/AGENTS.md']) }), @@ -60,7 +61,7 @@ it.effect('loads ancestor skills and keeps the closest skill name', () => const source = yield* makeCodexSkillSource({ cwd: '/home/user/work/repo', home: '/home/user', - }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) + }).pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.provide(Path.layer)) expect(yield* source.list).toEqual([ { name: 'local', description: 'Local skill' }, { name: 'shared', description: 'Repo skill' }, @@ -98,7 +99,7 @@ it.effect('loads enabled plugin skills from local or the newest cached version', cwd: '/repo', home: '/home/user', codexHome: '/codex', - }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) + }).pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.provide(Path.layer)) const names = (yield* compatibility.skills.list).map(({ name }) => name) expect(names).toEqual(['alpha:right', 'semver:new']) expect(compatibility.diagnostics).toEqual([]) diff --git a/packages/fold-agent/test/Compatibility/GrokCompatibility.vi.test.ts b/packages/fold-agent/test/Compatibility/GrokCompatibility.vi.test.ts new file mode 100644 index 0000000..b114917 --- /dev/null +++ b/packages/fold-agent/test/Compatibility/GrokCompatibility.vi.test.ts @@ -0,0 +1,162 @@ +import { homedir } from 'node:os' + +import { expect, it } from '@effect/vitest' +import { Effect, FileSystem, Path } from 'effect' + +import { loadGrokCompatibility, loadGrokInstructions } 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('loads Grok global and root-to-cwd instructions while respecting gitignore', () => + Effect.gen(function* () { + const fs = memoryFileSystem({ + '/home/user/.grok/AGENTS.md': 'global grok', + '/home/user/.grok/rules/a.md': '---\nglobs: src/**\n---\nglobal rule', + '/home/user/.claude/CLAUDE.md': 'global claude compatibility', + '/repo/.gitignore': '.grok/rules/ignored.md\n', + '/repo/AGENTS.md': 'repo agents', + '/repo/CLAUDE.md': 'repo claude', + '/repo/.grok/rules/a.md': 'repo grok rule', + '/repo/.grok/rules/ignored.md': 'must not load', + '/repo/.claude/rules/a.md': 'repo claude rule', + '/repo/apps/AGENT.md': 'apps agent', + '/repo/apps/service/.cursor/rules/z.md': 'service cursor rule', + }) + + const sources = yield* loadGrokInstructions({ + cwd: '/repo/apps/service', + projectRoot: '/repo', + home: '/home/user', + }).pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.provide(Path.layer)) + + expect(sources.map(({ content }) => content)).toEqual([ + 'global grok', + 'global rule', + 'global claude compatibility', + 'repo claude', + 'repo agents', + 'repo grok rule', + 'repo claude rule', + 'apps agent', + 'service cursor rule', + ]) + }), +) + +it.effect('loads Grok, Agents, Claude, configured, and plugin skills with provider-local precedence', () => + Effect.gen(function* () { + const fs = memoryFileSystem({ + '/repo/.grok/skills/shared/SKILL.md': skill('shared', 'Repo Grok winner', 'repo grok'), + '/repo/.agents/skills/agents/SKILL.md': skill('agents', 'Agents compatibility'), + '/repo/apps/.claude/skills/claude/SKILL.md': skill('claude', 'Claude compatibility'), + '/configured/custom/SKILL.md': skill('configured', 'Configured skill'), + '/home/user/.grok/skills/global/SKILL.md': skill('global', 'Global Grok skill'), + '/repo/.grok/plugins/acme/plugin.json': JSON.stringify({ name: 'acme', skills: ['./custom-skills'] }), + '/repo/.grok/plugins/acme/custom-skills/deploy/SKILL.md': skill('deploy', 'Plugin deploy'), + '/repo/.claude/plugins/ignored/plugin.json': JSON.stringify({ name: 'acme' }), + '/repo/.claude/plugins/ignored/skills/loser/SKILL.md': skill('loser', 'Name collision loser'), + }) + + const compatibility = yield* loadGrokCompatibility({ + cwd: '/repo/apps', + projectRoot: '/repo', + home: '/home/user', + configuredPaths: ['/configured'], + }).pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.provide(Path.layer)) + const names = (yield* compatibility.skills.list).map(({ name }) => name) + + expect(names).toEqual(['claude', 'shared', 'agents', 'configured', 'global', 'acme:deploy']) + expect((yield* compatibility.skills.load('shared')).content).toBe('repo grok') + expect(compatibility.diagnostics).toEqual([]) + }), +) + +it.effect('keeps Codex-only roots out of Grok compatibility', () => + Effect.gen(function* () { + const fs = memoryFileSystem({ + '/home/user/.codex/AGENTS.md': 'codex global', + '/home/user/.codex/skills/codex/SKILL.md': skill('codex', 'Codex only'), + '/repo/.codex/skills/project/SKILL.md': skill('project', 'Codex project only'), + }) + const compatibility = yield* loadGrokCompatibility({ + cwd: '/repo', + projectRoot: '/repo', + home: '/home/user', + }).pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.provide(Path.layer)) + + expect(compatibility.instructionBlock).toBeNull() + expect(yield* compatibility.skills.list).toEqual([]) + }), +) + +it.effect('reports malformed plugin metadata without failing compatibility loading', () => + Effect.gen(function* () { + const fs = memoryFileSystem({ + '/repo/.grok/plugins/broken/plugin.json': '{not-json', + '/repo/.grok/plugins/unsafe/plugin.json': JSON.stringify({ name: 'unsafe', skills: ['../outside'] }), + }) + const compatibility = yield* loadGrokCompatibility({ + cwd: '/repo', + projectRoot: '/repo', + home: '/home/user', + }).pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.provide(Path.layer)) + + expect(yield* compatibility.skills.list).toEqual([]) + expect(compatibility.diagnostics).toEqual([ + { + stage: 'manifest', + code: 'manifest_parse_failed', + path: '/repo/.grok/plugins/broken/plugin.json', + }, + { + stage: 'manifest', + code: 'invalid_skill_root', + path: '/repo/.grok/plugins/unsafe/plugin.json', + }, + ]) + }), +) + +it.effect('uses the operating-system home for default Grok plugin discovery', () => + Effect.gen(function* () { + const home = homedir() + const fs = memoryFileSystem({ + [`${home}/.grok/plugins/default-home/plugin.json`]: JSON.stringify({ name: 'default-home' }), + [`${home}/.grok/plugins/default-home/skills/proof/SKILL.md`]: skill('proof', 'Default home plugin'), + }) + + const compatibility = yield* loadGrokCompatibility({ cwd: '/repo', projectRoot: '/repo' }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provide(Path.layer), + ) + + expect((yield* compatibility.skills.list).map(({ name }) => name)).toContain('default-home:proof') + }), +) + +it.effect('skips malformed skill frontmatter and rejects backslash plugin roots without defects', () => + Effect.gen(function* () { + const fs = memoryFileSystem({ + '/repo/.grok/skills/broken/SKILL.md': '---\nname: [unterminated\n---\nBroken', + '/repo/.grok/plugins/unsafe/plugin.json': JSON.stringify({ + name: 'unsafe', + skills: ['..\\outside'], + }), + }) + + const compatibility = yield* loadGrokCompatibility({ + cwd: '/repo', + projectRoot: '/repo', + home: '/home/user', + }).pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.provide(Path.layer)) + + expect(yield* compatibility.skills.list).toEqual([]) + expect(compatibility.diagnostics).toContainEqual({ + stage: 'manifest', + code: 'invalid_skill_root', + path: '/repo/.grok/plugins/unsafe/plugin.json', + }) + }), +)