From 4c07b99a0eeddf27c58f59ed64551b07fe0f4b04 Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Fri, 28 Aug 2026 17:19:01 -0700 Subject: [PATCH 1/2] 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/2] 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({