From 4c07b99a0eeddf27c58f59ed64551b07fe0f4b04 Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Fri, 28 Aug 2026 17:19:01 -0700 Subject: [PATCH 1/4] Add Grok compatibility discovery HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a04abb-cb1a-715c-808b-1d922a5225e1 --- .../src/Compatibility/GrokCompatibility.ts | 43 ++++ .../src/Compatibility/GrokInstructions.ts | 192 ++++++++++++++++++ .../src/Compatibility/GrokPlugins.ts | 159 +++++++++++++++ .../src/Compatibility/GrokSkills.ts | 170 ++++++++++++++++ packages/fold-agent/src/index.ts | 4 + .../GrokCompatibility.vi.test.ts | 164 +++++++++++++++ 6 files changed, 732 insertions(+) create mode 100644 packages/fold-agent/src/Compatibility/GrokCompatibility.ts create mode 100644 packages/fold-agent/src/Compatibility/GrokInstructions.ts create mode 100644 packages/fold-agent/src/Compatibility/GrokPlugins.ts create mode 100644 packages/fold-agent/src/Compatibility/GrokSkills.ts create mode 100644 packages/fold-agent/test/Compatibility/GrokCompatibility.vi.test.ts diff --git a/packages/fold-agent/src/Compatibility/GrokCompatibility.ts b/packages/fold-agent/src/Compatibility/GrokCompatibility.ts new file mode 100644 index 0000000..b523bff --- /dev/null +++ b/packages/fold-agent/src/Compatibility/GrokCompatibility.ts @@ -0,0 +1,43 @@ +import type { SkillSourceService } from '@humanlayer/fold-core' +import { Effect, type FileSystem } 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 fileSystem?: FileSystem.FileSystem + 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.fileSystem === undefined ? {} : { fileSystem: options.fileSystem }), + ...(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..4e9b110 --- /dev/null +++ b/packages/fold-agent/src/Compatibility/GrokInstructions.ts @@ -0,0 +1,192 @@ +import { homedir } from 'node:os' +import { dirname, join, relative, resolve, sep } from 'node:path' + +import { Effect, type FileSystem, Schema } from 'effect' + +import { fileSystemFor } from '../Fs/DefaultFileSystem' + +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 + readonly fileSystem?: FileSystem.FileSystem +} + +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 => { + const directories: Array = [] + 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 = ( + fs: FileSystem.FileSystem, + path: string, + scope: 'global' | 'ancestor', + isRule: boolean, +): Effect.Effect => + 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 = (fs: FileSystem.FileSystem, directory: string): Effect.Effect> => + 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 = fileSystemFor(options) + 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 = [] + const seen = new Set() + + 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(fs, join(grokHome, name), 'global', false)) + for (const path of yield* markdownRules(fs, join(grokHome, 'rules'))) + add(yield* readNonEmpty(fs, path, 'global', true)) + if (home !== null) { + for (const vendor of ['.claude', '.cursor']) { + for (const name of instructionNames) add(yield* readNonEmpty(fs, join(home, vendor, name), 'global', false)) + for (const path of yield* markdownRules(fs, join(home, vendor, 'rules'))) + add(yield* readNonEmpty(fs, path, 'global', true)) + } + } + + for (const directory of directories) { + for (const name of instructionNames) + add(yield* readNonEmpty(fs, join(directory, name), 'ancestor', false), ignoreRoot) + for (const rulesDirectory of projectRuleDirectories) + for (const path of yield* markdownRules(fs, join(directory, rulesDirectory))) + add(yield* readNonEmpty(fs, path, '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..eb232be --- /dev/null +++ b/packages/fold-agent/src/Compatibility/GrokPlugins.ts @@ -0,0 +1,159 @@ +import { homedir } from 'node:os' +import { basename, dirname, join, resolve } from 'node:path' + +import { Effect, type FileSystem, Schema } from 'effect' + +import { fileSystemFor } from '../Fs/DefaultFileSystem' + +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 + readonly fileSystem?: FileSystem.FileSystem +} + +const isRecord = (value: unknown): value is Record => + 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 => { + const directories: Array = [] + 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 = ( + fs: FileSystem.FileSystem, + root: string, +): Effect.Effect<{ path: string; value: unknown } | null> => + Effect.gen(function* () { + 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 = fileSystemFor(options) + 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 = [] + const roots: Array = [] + const seenPaths = new Set() + const seenNames = new Set() + + for (const parent of pluginParents) { + const parentManifest = yield* readManifest(fs, 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(fs, 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 = [] + 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 } +}) diff --git a/packages/fold-agent/src/Compatibility/GrokSkills.ts b/packages/fold-agent/src/Compatibility/GrokSkills.ts new file mode 100644 index 0000000..60d4318 --- /dev/null +++ b/packages/fold-agent/src/Compatibility/GrokSkills.ts @@ -0,0 +1,170 @@ +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 GrokSkillOptions = { + readonly cwd: string + readonly home?: string + readonly grokHome?: string + readonly projectRoot?: string + readonly fileSystem?: FileSystem.FileSystem + readonly configuredPaths?: ReadonlyArray + readonly bundledPaths?: ReadonlyArray + readonly pluginPaths?: ReadonlyArray<{ readonly name: string; readonly path: string }> + readonly ignoredPaths?: ReadonlyArray +} + +const exists = (fs: FileSystem.FileSystem, path: string): Effect.Effect => + 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, 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, boundary: string | null): ReadonlyArray => { + const roots: Array = [] + let current = cwd + while (true) { + for (const vendor of ['.grok', '.agents', '.claude', '.cursor']) roots.push(join(current, vendor, 'skills')) + if (current === boundary) break + const parent = dirname(current) + if (parent === current) break + current = parent + } + return roots +} + +const loadSkill = (fs: FileSystem.FileSystem, path: string, namespace?: string): Effect.Effect => + fs.readFileString(path).pipe( + Effect.flatMap((raw) => + Effect.try(() => { + const normalized = raw.replace(/\r\n/g, '\n').replace(/\r/g, '\n') + const directory = dirname(path) + 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 : 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 = ( + fs: FileSystem.FileSystem, + root: string, + ignoredPaths: ReadonlyArray, + 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 resolvedDirectory = resolve(directory) + if ( + ignoredPaths.some( + (ignored) => resolvedDirectory === ignored || isAncestor(ignored, resolvedDirectory), + ) + ) + return + 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.orElseSucceed(() => [])) + 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.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')((options: GrokSkillOptions) => + 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 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 roots = [ + ...ancestorSkillRoots(cwd, boundary), + ...(options.configuredPaths ?? []), + join(grokHome, 'skills'), + ...(home === null + ? [] + : [join(home, '.agents', 'skills'), join(home, '.claude', 'skills'), join(home, '.cursor', 'skills')]), + ...(options.bundledPaths ?? []), + ] + const ignoredPaths = (options.ignoredPaths ?? []).map((path) => resolve(path)) + 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(fs, 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(fs, resolve(plugin.path), ignoredPaths, plugin.name)) + if (!byName.has(skill.name)) byName.set(skill.name, skill) + return byName + }) + return { + list: scanSkillCatalog().pipe( + Effect.map((skills) => + [...skills.values()].map(({ name, description }): SkillMeta => ({ name, description })), + ), + ), + load: (name: string) => + scanSkillCatalog().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/GrokCompatibility.vi.test.ts b/packages/fold-agent/test/Compatibility/GrokCompatibility.vi.test.ts new file mode 100644 index 0000000..3b305ed --- /dev/null +++ b/packages/fold-agent/test/Compatibility/GrokCompatibility.vi.test.ts @@ -0,0 +1,164 @@ +import { homedir } from 'node:os' + +import { expect, it } from '@effect/vitest' +import { Effect } 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', + fileSystem: fs, + }) + + 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', + fileSystem: fs, + configuredPaths: ['/configured'], + }) + 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', + fileSystem: fs, + }) + + 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', + fileSystem: fs, + }) + + 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', fileSystem: fs }) + + 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', + fileSystem: fs, + }) + + expect(yield* compatibility.skills.list).toEqual([]) + expect(compatibility.diagnostics).toContainEqual({ + stage: 'manifest', + code: 'invalid_skill_root', + path: '/repo/.grok/plugins/unsafe/plugin.json', + }) + }), +) From bb8d85a32e68e576423ba12eae53d55fb338e8fe Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Fri, 28 Aug 2026 18:13:30 -0700 Subject: [PATCH 2/4] Model Grok filesystem as an Effect requirement HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a04abb-cb1a-715c-808b-1d922a5225e1 --- .../src/Compatibility/CodexSkills.ts | 14 +- .../src/Compatibility/GrokCompatibility.ts | 4 +- .../src/Compatibility/GrokInstructions.ts | 69 +++---- .../src/Compatibility/GrokPlugins.ts | 15 +- .../src/Compatibility/GrokSkills.ts | 191 +++++++++--------- .../GrokCompatibility.vi.test.ts | 21 +- 6 files changed, 156 insertions(+), 158 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)) diff --git a/packages/fold-agent/src/Compatibility/GrokCompatibility.ts b/packages/fold-agent/src/Compatibility/GrokCompatibility.ts index b523bff..8e699cb 100644 --- a/packages/fold-agent/src/Compatibility/GrokCompatibility.ts +++ b/packages/fold-agent/src/Compatibility/GrokCompatibility.ts @@ -1,12 +1,11 @@ import type { SkillSourceService } from '@humanlayer/fold-core' -import { Effect, type FileSystem } from 'effect' +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 fileSystem?: FileSystem.FileSystem readonly configuredPluginPaths?: ReadonlyArray } @@ -25,7 +24,6 @@ export const loadGrokCompatibility = Effect.fn('fold.grok_compatibility.load')(f ...(options.home === undefined ? {} : { home: options.home }), ...(options.grokHome === undefined ? {} : { grokHome: options.grokHome }), ...(options.projectRoot === undefined ? {} : { projectRoot: options.projectRoot }), - ...(options.fileSystem === undefined ? {} : { fileSystem: options.fileSystem }), ...(options.configuredPluginPaths === undefined ? {} : { configuredPaths: options.configuredPluginPaths }), } const plugins = yield* discoverGrokPluginSkillRoots(pluginOptions) diff --git a/packages/fold-agent/src/Compatibility/GrokInstructions.ts b/packages/fold-agent/src/Compatibility/GrokInstructions.ts index 4e9b110..abe95da 100644 --- a/packages/fold-agent/src/Compatibility/GrokInstructions.ts +++ b/packages/fold-agent/src/Compatibility/GrokInstructions.ts @@ -1,9 +1,7 @@ import { homedir } from 'node:os' import { dirname, join, relative, resolve, sep } from 'node:path' -import { Effect, type FileSystem, Schema } from 'effect' - -import { fileSystemFor } from '../Fs/DefaultFileSystem' +import { Effect, FileSystem, Schema } from 'effect' export const GrokInstructionSource = Schema.Struct({ path: Schema.String, @@ -17,7 +15,6 @@ export type GrokInstructionOptions = { readonly home?: string readonly grokHome?: string readonly projectRoot?: string - readonly fileSystem?: FileSystem.FileSystem } const instructionNames = [ @@ -92,34 +89,39 @@ const makeGitIgnorePredicate = (contents: string): ((path: string) => boolean) = } const readNonEmpty = ( - fs: FileSystem.FileSystem, path: string, scope: 'global' | 'ancestor', isRule: boolean, -): Effect.Effect => - 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 = (fs: FileSystem.FileSystem, directory: string): Effect.Effect> => - fs.readDirectory(directory).pipe( - Effect.map((entries) => - entries - .filter((entry) => entry.toLowerCase().endsWith('.md')) - .sort() - .map((entry) => join(directory, entry)), - ), - Effect.orElseSucceed(() => []), - ) +): 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> => + 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 = fileSystemFor(options) + 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) @@ -151,23 +153,22 @@ export const loadGrokInstructions = Effect.fn('fold.grok_compatibility.load_inst sources.push(source) } - for (const name of instructionNames) add(yield* readNonEmpty(fs, join(grokHome, name), 'global', false)) - for (const path of yield* markdownRules(fs, join(grokHome, 'rules'))) - add(yield* readNonEmpty(fs, path, 'global', true)) + 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(fs, join(home, vendor, name), 'global', false)) - for (const path of yield* markdownRules(fs, join(home, vendor, 'rules'))) - add(yield* readNonEmpty(fs, path, 'global', true)) + 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(fs, join(directory, name), 'ancestor', false), ignoreRoot) + add(yield* readNonEmpty(join(directory, name), 'ancestor', false), ignoreRoot) for (const rulesDirectory of projectRuleDirectories) - for (const path of yield* markdownRules(fs, join(directory, rulesDirectory))) - add(yield* readNonEmpty(fs, path, 'ancestor', true), ignoreRoot) + for (const path of yield* markdownRules(join(directory, rulesDirectory))) + add(yield* readNonEmpty(path, 'ancestor', true), ignoreRoot) } return sources diff --git a/packages/fold-agent/src/Compatibility/GrokPlugins.ts b/packages/fold-agent/src/Compatibility/GrokPlugins.ts index eb232be..225ef1f 100644 --- a/packages/fold-agent/src/Compatibility/GrokPlugins.ts +++ b/packages/fold-agent/src/Compatibility/GrokPlugins.ts @@ -1,9 +1,7 @@ import { homedir } from 'node:os' import { basename, 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 GrokPluginDiagnostic = Schema.Struct({ stage: Schema.Literals(['manifest', 'discovery']), @@ -20,7 +18,6 @@ export type GrokPluginOptions = { readonly grokHome?: string readonly projectRoot?: string readonly configuredPaths?: ReadonlyArray - readonly fileSystem?: FileSystem.FileSystem } const isRecord = (value: unknown): value is Record => @@ -63,10 +60,10 @@ const safeRelativePath = (value: unknown): string | null => { } const readManifest = ( - fs: FileSystem.FileSystem, root: string, -): Effect.Effect<{ path: string; value: unknown } | null> => +): 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)) @@ -80,7 +77,7 @@ const readManifest = ( export const discoverGrokPluginSkillRoots = Effect.fn('fold.grok_compatibility.discover_plugin_skills')(function* ( options: GrokPluginOptions, ) { - const fs = fileSystemFor(options) + 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) @@ -107,7 +104,7 @@ export const discoverGrokPluginSkillRoots = Effect.fn('fold.grok_compatibility.d const seenNames = new Set() for (const parent of pluginParents) { - const parentManifest = yield* readManifest(fs, parent) + const parentManifest = yield* readManifest(parent) const candidates = parentManifest === null ? (yield* fs.readDirectory(parent).pipe(Effect.orElseSucceed(() => []))) @@ -119,7 +116,7 @@ export const discoverGrokPluginSkillRoots = Effect.fn('fold.grok_compatibility.d if (seenPaths.has(normalized)) continue seenPaths.add(normalized) const manifest = - candidate === parent && parentManifest !== null ? parentManifest : yield* readManifest(fs, candidate) + 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 diff --git a/packages/fold-agent/src/Compatibility/GrokSkills.ts b/packages/fold-agent/src/Compatibility/GrokSkills.ts index 60d4318..e21a97e 100644 --- a/packages/fold-agent/src/Compatibility/GrokSkills.ts +++ b/packages/fold-agent/src/Compatibility/GrokSkills.ts @@ -2,25 +2,25 @@ 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 GrokSkillOptions = { readonly cwd: string readonly home?: string readonly grokHome?: string readonly projectRoot?: string - readonly fileSystem?: FileSystem.FileSystem readonly configuredPaths?: ReadonlyArray readonly bundledPaths?: ReadonlyArray readonly pluginPaths?: ReadonlyArray<{ readonly name: string; readonly path: string }> readonly ignoredPaths?: ReadonlyArray } -const exists = (fs: FileSystem.FileSystem, path: string): Effect.Effect => - fs.exists(path).pipe(Effect.orElseSucceed(() => false)) +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) @@ -48,48 +48,51 @@ const ancestorSkillRoots = (cwd: string, boundary: string | null): ReadonlyArray return roots } -const loadSkill = (fs: FileSystem.FileSystem, path: string, namespace?: string): Effect.Effect => - fs.readFileString(path).pipe( - Effect.flatMap((raw) => - Effect.try(() => { - const normalized = raw.replace(/\r\n/g, '\n').replace(/\r/g, '\n') - const directory = dirname(path) - 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 loadSkill = (path: string, namespace?: string): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + return yield* fs.readFileString(path).pipe( + Effect.flatMap((raw) => + Effect.try(() => { + const normalized = raw.replace(/\r\n/g, '\n').replace(/\r/g, '\n') + const directory = dirname(path) + 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 : 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 record = isRecord(parsed) ? parsed : {} + const rawName = + typeof record.name === 'string' && record.name.length > 0 ? record.name : 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 = ( - fs: FileSystem.FileSystem, root: string, ignoredPaths: ReadonlyArray, namespace?: string, -): Effect.Effect> => +): 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 resolvedDirectory = resolve(directory) if ( @@ -99,8 +102,8 @@ const scanRoot = ( ) return 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 } @@ -116,55 +119,57 @@ const scanRoot = ( return found }) -export const makeGrokSkillSource = Effect.fn('fold.grok_compatibility.make_skill_source')((options: GrokSkillOptions) => - 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 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 roots = [ - ...ancestorSkillRoots(cwd, boundary), - ...(options.configuredPaths ?? []), - join(grokHome, 'skills'), - ...(home === null - ? [] - : [join(home, '.agents', 'skills'), join(home, '.claude', 'skills'), join(home, '.cursor', 'skills')]), - ...(options.bundledPaths ?? []), - ] - const ignoredPaths = (options.ignoredPaths ?? []).map((path) => resolve(path)) - 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(fs, 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(fs, resolve(plugin.path), ignoredPaths, plugin.name)) - if (!byName.has(skill.name)) byName.set(skill.name, skill) - return byName - }) - return { - list: scanSkillCatalog().pipe( - Effect.map((skills) => - [...skills.values()].map(({ name, description }): SkillMeta => ({ name, description })), - ), +export const makeGrokSkillSource = Effect.fn('fold.grok_compatibility.make_skill_source')(function* ( + options: GrokSkillOptions, +) { + 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 roots = [ + ...ancestorSkillRoots(cwd, boundary), + ...(options.configuredPaths ?? []), + join(grokHome, 'skills'), + ...(home === null + ? [] + : [join(home, '.agents', 'skills'), join(home, '.claude', 'skills'), join(home, '.cursor', 'skills')]), + ...(options.bundledPaths ?? []), + ] + const ignoredPaths = (options.ignoredPaths ?? []).map((path) => resolve(path)) + 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(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(resolve(plugin.path), ignoredPaths, plugin.name)) + if (!byName.has(skill.name)) byName.set(skill.name, skill) + return byName + }) + const scanSkillCatalogWithFileSystem = () => + scanSkillCatalog().pipe(Effect.provideService(FileSystem.FileSystem, fs)) + return { + list: scanSkillCatalogWithFileSystem().pipe( + Effect.map((skills) => + [...skills.values()].map(({ name, description }): SkillMeta => ({ name, description })), + ), + ), + load: (name: string) => + scanSkillCatalogWithFileSystem().pipe( + Effect.flatMap((skills) => { + const skill = skills.get(name) + return skill === undefined + ? Effect.fail(new SkillNotFoundError({ name, availableSkills: [...skills.keys()] })) + : Effect.succeed(skill) + }), ), - load: (name: string) => - scanSkillCatalog().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 - }), -) + } satisfies SkillSourceService +}) diff --git a/packages/fold-agent/test/Compatibility/GrokCompatibility.vi.test.ts b/packages/fold-agent/test/Compatibility/GrokCompatibility.vi.test.ts index 3b305ed..841ef01 100644 --- a/packages/fold-agent/test/Compatibility/GrokCompatibility.vi.test.ts +++ b/packages/fold-agent/test/Compatibility/GrokCompatibility.vi.test.ts @@ -1,7 +1,7 @@ import { homedir } from 'node:os' import { expect, it } from '@effect/vitest' -import { Effect } from 'effect' +import { Effect, FileSystem } from 'effect' import { loadGrokCompatibility, loadGrokInstructions } from '../../src/index' import { memoryFileSystem } from '../TestHelpers' @@ -29,8 +29,7 @@ it.effect('loads Grok global and root-to-cwd instructions while respecting gitig cwd: '/repo/apps/service', projectRoot: '/repo', home: '/home/user', - fileSystem: fs, - }) + }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) expect(sources.map(({ content }) => content)).toEqual([ 'global grok', @@ -64,9 +63,8 @@ it.effect('loads Grok, Agents, Claude, configured, and plugin skills with provid cwd: '/repo/apps', projectRoot: '/repo', home: '/home/user', - fileSystem: fs, configuredPaths: ['/configured'], - }) + }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) const names = (yield* compatibility.skills.list).map(({ name }) => name) expect(names).toEqual(['claude', 'shared', 'agents', 'configured', 'global', 'acme:deploy']) @@ -86,8 +84,7 @@ it.effect('keeps Codex-only roots out of Grok compatibility', () => cwd: '/repo', projectRoot: '/repo', home: '/home/user', - fileSystem: fs, - }) + }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) expect(compatibility.instructionBlock).toBeNull() expect(yield* compatibility.skills.list).toEqual([]) @@ -104,8 +101,7 @@ it.effect('reports malformed plugin metadata without failing compatibility loadi cwd: '/repo', projectRoot: '/repo', home: '/home/user', - fileSystem: fs, - }) + }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) expect(yield* compatibility.skills.list).toEqual([]) expect(compatibility.diagnostics).toEqual([ @@ -131,7 +127,9 @@ it.effect('uses the operating-system home for default Grok plugin discovery', () [`${home}/.grok/plugins/default-home/skills/proof/SKILL.md`]: skill('proof', 'Default home plugin'), }) - const compatibility = yield* loadGrokCompatibility({ cwd: '/repo', projectRoot: '/repo', fileSystem: fs }) + const compatibility = yield* loadGrokCompatibility({ cwd: '/repo', projectRoot: '/repo' }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + ) expect((yield* compatibility.skills.list).map(({ name }) => name)).toContain('default-home:proof') }), @@ -151,8 +149,7 @@ it.effect('skips malformed skill frontmatter and rejects backslash plugin roots cwd: '/repo', projectRoot: '/repo', home: '/home/user', - fileSystem: fs, - }) + }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) expect(yield* compatibility.skills.list).toEqual([]) expect(compatibility.diagnostics).toContainEqual({ From ba005f7a24318c0fcd82d33327be29ff5f68a14e Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Fri, 28 Aug 2026 18:31:27 -0700 Subject: [PATCH 3/4] Model compatibility paths as an Effect requirement HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a04abb-cb1a-715c-808b-1d922a5225e1 --- .../src/Compatibility/CodexCompatibility.ts | 6 +- .../src/Compatibility/CodexInstructions.ts | 73 ++++++++------- .../src/Compatibility/CodexPlugins.ts | 16 ++-- .../src/Compatibility/CodexSkills.ts | 89 ++++++++++--------- .../CodexCompatibility.vi.test.ts | 9 +- 5 files changed, 105 insertions(+), 88 deletions(-) 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/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([]) From 9f9f6f9cb7a8b8296f0d1f1ca53b863923685c6e Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Fri, 28 Aug 2026 18:37:39 -0700 Subject: [PATCH 4/4] Model Grok paths as an Effect requirement HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a04abb-cb1a-715c-808b-1d922a5225e1 --- .../src/Compatibility/GrokInstructions.ts | 103 +++++++------- .../src/Compatibility/GrokPlugins.ts | 103 +++++++------- .../src/Compatibility/GrokSkills.ts | 131 ++++++++++-------- .../GrokCompatibility.vi.test.ts | 13 +- 4 files changed, 190 insertions(+), 160 deletions(-) diff --git a/packages/fold-agent/src/Compatibility/GrokInstructions.ts b/packages/fold-agent/src/Compatibility/GrokInstructions.ts index abe95da..92419e5 100644 --- a/packages/fold-agent/src/Compatibility/GrokInstructions.ts +++ b/packages/fold-agent/src/Compatibility/GrokInstructions.ts @@ -1,7 +1,6 @@ import { homedir } from 'node:os' -import { dirname, join, relative, resolve, sep } from 'node:path' -import { Effect, FileSystem, Schema } from 'effect' +import { Effect, FileSystem, Path, Schema } from 'effect' export const GrokInstructionSource = Schema.Struct({ path: Schema.String, @@ -30,28 +29,35 @@ const instructionNames = [ 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 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): ReadonlyArray => { - const directories: Array = [] - 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, + 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') @@ -104,15 +110,18 @@ const readNonEmpty = ( ) }) -const markdownRules = (directory: string): Effect.Effect, never, FileSystem.FileSystem> => +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) => join(directory, entry)), + .map((entry) => path.join(directory, entry)), ), Effect.orElseSucceed(() => []), ) @@ -122,23 +131,21 @@ export const loadGrokInstructions = Effect.fn('fold.grok_compatibility.load_inst options: GrokInstructionOptions, ) { 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 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 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(join(ignoreRoot, '.gitignore')).pipe(Effect.orElseSucceed(() => '')) + : yield* fs.readFileString(path.join(ignoreRoot, '.gitignore')).pipe(Effect.orElseSucceed(() => '')) const isIgnored = makeGitIgnorePredicate(gitignore) const sources: Array = [] const seen = new Set() @@ -146,29 +153,31 @@ export const loadGrokInstructions = Effect.fn('fold.grok_compatibility.load_inst 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('/') + 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(join(grokHome, name), 'global', false)) - for (const path of yield* markdownRules(join(grokHome, 'rules'))) add(yield* readNonEmpty(path, 'global', true)) + 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(join(home, vendor, name), 'global', false)) - for (const path of yield* markdownRules(join(home, vendor, 'rules'))) - add(yield* readNonEmpty(path, 'global', true)) + 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(join(directory, name), 'ancestor', false), ignoreRoot) + add(yield* readNonEmpty(path.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) + for (const rulePath of yield* markdownRules(path.join(directory, rulesDirectory))) + add(yield* readNonEmpty(rulePath, 'ancestor', true), ignoreRoot) } return sources diff --git a/packages/fold-agent/src/Compatibility/GrokPlugins.ts b/packages/fold-agent/src/Compatibility/GrokPlugins.ts index 225ef1f..a467cae 100644 --- a/packages/fold-agent/src/Compatibility/GrokPlugins.ts +++ b/packages/fold-agent/src/Compatibility/GrokPlugins.ts @@ -1,7 +1,6 @@ import { homedir } from 'node:os' -import { basename, dirname, join, resolve } from 'node:path' -import { Effect, FileSystem, Schema } from 'effect' +import { Effect, FileSystem, Path, Schema } from 'effect' export const GrokPluginDiagnostic = Schema.Struct({ stage: Schema.Literals(['manifest', 'discovery']), @@ -23,28 +22,35 @@ export type GrokPluginOptions = { const isRecord = (value: unknown): value is Record => 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 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): ReadonlyArray => { - const directories: Array = [] - 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 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 @@ -61,15 +67,16 @@ const safeRelativePath = (value: unknown): string | null => { const readManifest = ( root: string, -): Effect.Effect<{ path: string; value: unknown } | null, never, FileSystem.FileSystem> => +): 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 path = join(root, name) - const contents = yield* fs.readFileString(path).pipe(Effect.orElseSucceed(() => null)) + 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, value } + return { path: manifestPath, value } } return null }) @@ -78,25 +85,23 @@ export const discoverGrokPluginSkillRoots = Effect.fn('fold.grok_compatibility.d options: GrokPluginOptions, ) { 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 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 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((path) => resolve(path)), - ...ancestorDirectories(cwd, boundary).flatMap((directory) => [ - join(directory, '.grok', 'plugins'), - join(directory, '.claude', 'plugins'), + ...(options.configuredPaths ?? []).map((configuredPath) => path.resolve(configuredPath)), + ...(yield* ancestorDirectories(cwd, boundary)).flatMap((directory) => [ + path.join(directory, '.grok', 'plugins'), + path.join(directory, '.claude', 'plugins'), ]), - join(grokHome, 'plugins'), - ...(home === null ? [] : [join(home, '.claude', 'plugins')]), + path.join(grokHome, 'plugins'), + ...(home === null ? [] : [path.join(home, '.claude', 'plugins')]), ] const diagnostics: Array = [] const roots: Array = [] @@ -109,10 +114,10 @@ export const discoverGrokPluginSkillRoots = Effect.fn('fold.grok_compatibility.d parentManifest === null ? (yield* fs.readDirectory(parent).pipe(Effect.orElseSucceed(() => []))) .sort((left, right) => left.localeCompare(right)) - .map((entry) => join(parent, entry)) + .map((entry) => path.join(parent, entry)) : [parent] for (const candidate of candidates) { - const normalized = resolve(candidate) + const normalized = path.resolve(candidate) if (seenPaths.has(normalized)) continue seenPaths.add(normalized) const manifest = @@ -125,7 +130,7 @@ export const discoverGrokPluginSkillRoots = Effect.fn('fold.grok_compatibility.d const name = manifestValue !== null && typeof manifestValue.name === 'string' ? manifestValue.name - : basename(candidate) + : path.basename(candidate) if (name.length === 0 || seenNames.has(name)) continue const declared = manifestValue === null || manifestValue.skills === undefined @@ -144,12 +149,12 @@ export const discoverGrokPluginSkillRoots = Effect.fn('fold.grok_compatibility.d }) continue } - const path = resolve(candidate, relativePath) - if (yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false))) skillRoots.push(path) + 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 path of skillRoots) roots.push({ name, path }) + 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 index e21a97e..994cf0c 100644 --- a/packages/fold-agent/src/Compatibility/GrokSkills.ts +++ b/packages/fold-agent/src/Compatibility/GrokSkills.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 GrokSkillOptions = { @@ -25,37 +24,49 @@ const exists = (path: string): Effect.Effect => 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 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): ReadonlyArray => { - const roots: Array = [] - let current = cwd - while (true) { - for (const vendor of ['.grok', '.agents', '.claude', '.cursor']) roots.push(join(current, vendor, 'skills')) - if (current === boundary) break - const parent = dirname(current) - if (parent === current) break - current = parent - } - return roots -} +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 = (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.flatMap((raw) => Effect.try(() => { const normalized = raw.replace(/\r\n/g, '\n').replace(/\r/g, '\n') - const directory = dirname(path) + const directory = path.dirname(skillPath) let parsed: unknown = null let content = normalized.trim() if (normalized.startsWith('---\n')) { @@ -67,7 +78,9 @@ const loadSkill = (path: string, namespace?: string): Effect.Effect 0 ? record.name : basename(directory) + 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 @@ -87,21 +100,18 @@ const scanRoot = ( root: string, ignoredPaths: ReadonlyArray, 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 resolvedDirectory = resolve(directory) - if ( - ignoredPaths.some( - (ignored) => resolvedDirectory === ignored || isAncestor(ignored, resolvedDirectory), - ) - ) - return - const skillPath = join(directory, 'SKILL.md') + 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) @@ -110,7 +120,7 @@ const scanRoot = ( const entries = yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => [])) 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.orElseSucceed(() => null)) if (info?.type === 'Directory') yield* scan(child) } @@ -123,47 +133,52 @@ export const makeGrokSkillSource = Effect.fn('fold.grok_compatibility.make_skill options: GrokSkillOptions, ) { 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 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 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 = [ - ...ancestorSkillRoots(cwd, boundary), + ...(yield* ancestorSkillRoots(cwd, boundary)), ...(options.configuredPaths ?? []), - join(grokHome, 'skills'), + path.join(grokHome, 'skills'), ...(home === null ? [] - : [join(home, '.agents', 'skills'), join(home, '.claude', 'skills'), join(home, '.cursor', 'skills')]), + : [ + path.join(home, '.agents', 'skills'), + path.join(home, '.claude', 'skills'), + path.join(home, '.cursor', 'skills'), + ]), ...(options.bundledPaths ?? []), ] - const ignoredPaths = (options.ignoredPaths ?? []).map((path) => resolve(path)) + 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(resolve(root), ignoredPaths)) + 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(resolve(plugin.path), ignoredPaths, plugin.name)) + 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 scanSkillCatalogWithFileSystem = () => - scanSkillCatalog().pipe(Effect.provideService(FileSystem.FileSystem, fs)) + const scanSkillCatalogWithPlatformServices = () => + scanSkillCatalog().pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ) return { - list: scanSkillCatalogWithFileSystem().pipe( + list: scanSkillCatalogWithPlatformServices().pipe( Effect.map((skills) => [...skills.values()].map(({ name, description }): SkillMeta => ({ name, description })), ), ), load: (name: string) => - scanSkillCatalogWithFileSystem().pipe( + scanSkillCatalogWithPlatformServices().pipe( Effect.flatMap((skills) => { const skill = skills.get(name) return skill === undefined diff --git a/packages/fold-agent/test/Compatibility/GrokCompatibility.vi.test.ts b/packages/fold-agent/test/Compatibility/GrokCompatibility.vi.test.ts index 841ef01..b114917 100644 --- a/packages/fold-agent/test/Compatibility/GrokCompatibility.vi.test.ts +++ b/packages/fold-agent/test/Compatibility/GrokCompatibility.vi.test.ts @@ -1,7 +1,7 @@ import { homedir } from 'node:os' import { expect, it } from '@effect/vitest' -import { Effect, FileSystem } from 'effect' +import { Effect, FileSystem, Path } from 'effect' import { loadGrokCompatibility, loadGrokInstructions } from '../../src/index' import { memoryFileSystem } from '../TestHelpers' @@ -29,7 +29,7 @@ it.effect('loads Grok global and root-to-cwd instructions while respecting gitig cwd: '/repo/apps/service', projectRoot: '/repo', home: '/home/user', - }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) + }).pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.provide(Path.layer)) expect(sources.map(({ content }) => content)).toEqual([ 'global grok', @@ -64,7 +64,7 @@ it.effect('loads Grok, Agents, Claude, configured, and plugin skills with provid projectRoot: '/repo', home: '/home/user', configuredPaths: ['/configured'], - }).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(['claude', 'shared', 'agents', 'configured', 'global', 'acme:deploy']) @@ -84,7 +84,7 @@ it.effect('keeps Codex-only roots out of Grok compatibility', () => cwd: '/repo', projectRoot: '/repo', home: '/home/user', - }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) + }).pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.provide(Path.layer)) expect(compatibility.instructionBlock).toBeNull() expect(yield* compatibility.skills.list).toEqual([]) @@ -101,7 +101,7 @@ it.effect('reports malformed plugin metadata without failing compatibility loadi cwd: '/repo', projectRoot: '/repo', home: '/home/user', - }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) + }).pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.provide(Path.layer)) expect(yield* compatibility.skills.list).toEqual([]) expect(compatibility.diagnostics).toEqual([ @@ -129,6 +129,7 @@ it.effect('uses the operating-system home for default Grok plugin discovery', () 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') @@ -149,7 +150,7 @@ it.effect('skips malformed skill frontmatter and rejects backslash plugin roots cwd: '/repo', projectRoot: '/repo', home: '/home/user', - }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) + }).pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.provide(Path.layer)) expect(yield* compatibility.skills.list).toEqual([]) expect(compatibility.diagnostics).toContainEqual({