diff --git a/packages/fold-agent/src/Compatibility/GrokCompatibility.ts b/packages/fold-agent/src/Compatibility/GrokCompatibility.ts new file mode 100644 index 0000000..8e699cb --- /dev/null +++ b/packages/fold-agent/src/Compatibility/GrokCompatibility.ts @@ -0,0 +1,41 @@ +import type { SkillSourceService } from '@humanlayer/fold-core' +import { Effect } from 'effect' + +import { loadGrokInstructions, renderGrokInstructions, type GrokInstructionSource } from './GrokInstructions' +import { discoverGrokPluginSkillRoots, type GrokPluginDiagnostic } from './GrokPlugins' +import { makeGrokSkillSource, type GrokSkillOptions } from './GrokSkills' + +export type GrokCompatibilityOptions = GrokSkillOptions & { + readonly configuredPluginPaths?: ReadonlyArray +} + +export type GrokCompatibility = { + readonly instructions: ReadonlyArray + readonly instructionBlock: string | null + readonly skills: SkillSourceService + readonly diagnostics: ReadonlyArray +} + +export const loadGrokCompatibility = Effect.fn('fold.grok_compatibility.load')(function* ( + options: GrokCompatibilityOptions, +) { + const pluginOptions = { + cwd: options.cwd, + ...(options.home === undefined ? {} : { home: options.home }), + ...(options.grokHome === undefined ? {} : { grokHome: options.grokHome }), + ...(options.projectRoot === undefined ? {} : { projectRoot: options.projectRoot }), + ...(options.configuredPluginPaths === undefined ? {} : { configuredPaths: options.configuredPluginPaths }), + } + const plugins = yield* discoverGrokPluginSkillRoots(pluginOptions) + const instructions = yield* loadGrokInstructions(options) + const skills = yield* makeGrokSkillSource({ + ...options, + pluginPaths: [...(options.pluginPaths ?? []), ...plugins.roots], + }) + return { + instructions, + instructionBlock: renderGrokInstructions(instructions), + skills, + diagnostics: plugins.diagnostics, + } +}) diff --git a/packages/fold-agent/src/Compatibility/GrokInstructions.ts b/packages/fold-agent/src/Compatibility/GrokInstructions.ts new file mode 100644 index 0000000..abe95da --- /dev/null +++ b/packages/fold-agent/src/Compatibility/GrokInstructions.ts @@ -0,0 +1,193 @@ +import { homedir } from 'node:os' +import { dirname, join, relative, resolve, sep } from 'node:path' + +import { Effect, FileSystem, Schema } from 'effect' + +export const GrokInstructionSource = Schema.Struct({ + path: Schema.String, + content: Schema.String, + scope: Schema.Literals(['global', 'ancestor']), +}) +export type GrokInstructionSource = typeof GrokInstructionSource.Type + +export type GrokInstructionOptions = { + readonly cwd: string + readonly home?: string + readonly grokHome?: string + readonly projectRoot?: string +} + +const instructionNames = [ + 'Agents.md', + 'Claude.md', + 'CLAUDE.md', + 'CLAUDE.local.md', + 'AGENT.md', + 'AGENTS.md', + '.claude/CLAUDE.md', + '.claude/CLAUDE.local.md', +] as const + +const projectRuleDirectories = ['.grok/rules', '.claude/rules', '.cursor/rules'] as const + +const isAncestor = (ancestor: string, path: string): boolean => { + let current = path + while (true) { + if (current === ancestor) return true + const parent = dirname(current) + if (parent === current) return false + current = parent + } +} + +const directoriesToBoundary = (cwd: string, boundary: string | null): ReadonlyArray => { + 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 = ( + path: string, + scope: 'global' | 'ancestor', + isRule: boolean, +): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + return yield* fs.readFileString(path).pipe( + Effect.map((raw) => { + const content = isRule ? stripRuleFrontmatter(raw) : raw + return content.trim().length === 0 ? null : { path, content, scope } + }), + Effect.orElseSucceed(() => null), + ) + }) + +const markdownRules = (directory: string): Effect.Effect, never, FileSystem.FileSystem> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + return yield* fs.readDirectory(directory).pipe( + Effect.map((entries) => + entries + .filter((entry) => entry.toLowerCase().endsWith('.md')) + .sort() + .map((entry) => join(directory, entry)), + ), + Effect.orElseSucceed(() => []), + ) + }) + +export const loadGrokInstructions = Effect.fn('fold.grok_compatibility.load_instructions')(function* ( + options: GrokInstructionOptions, +) { + const fs = yield* FileSystem.FileSystem + const cwd = resolve(options.cwd) + const homeValue = options.home === undefined ? homedir() : options.home + const home = homeValue.length === 0 ? null : resolve(homeValue) + const grokHome = resolve(options.grokHome ?? join(home ?? homedir(), '.grok')) + const explicitRoot = options.projectRoot === undefined ? null : resolve(options.projectRoot) + const boundary = + explicitRoot !== null && isAncestor(explicitRoot, cwd) + ? explicitRoot + : home !== null && isAncestor(home, cwd) + ? home + : null + const directories = directoriesToBoundary(cwd, boundary) + const ignoreRoot = explicitRoot !== null && isAncestor(explicitRoot, cwd) ? explicitRoot : directories[0] + const gitignore = + ignoreRoot === undefined + ? '' + : yield* fs.readFileString(join(ignoreRoot, '.gitignore')).pipe(Effect.orElseSucceed(() => '')) + const isIgnored = makeGitIgnorePredicate(gitignore) + const sources: Array = [] + 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(join(grokHome, name), 'global', false)) + for (const path of yield* markdownRules(join(grokHome, 'rules'))) add(yield* readNonEmpty(path, 'global', true)) + if (home !== null) { + for (const vendor of ['.claude', '.cursor']) { + for (const name of instructionNames) add(yield* readNonEmpty(join(home, vendor, name), 'global', false)) + for (const path of yield* markdownRules(join(home, vendor, 'rules'))) + add(yield* readNonEmpty(path, 'global', true)) + } + } + + for (const directory of directories) { + for (const name of instructionNames) + add(yield* readNonEmpty(join(directory, name), 'ancestor', false), ignoreRoot) + for (const rulesDirectory of projectRuleDirectories) + for (const path of yield* markdownRules(join(directory, rulesDirectory))) + add(yield* readNonEmpty(path, 'ancestor', true), ignoreRoot) + } + + return sources +}) + +const escapeXmlAttribute = (text: string): string => + text + .replace(/&/g, '&') + .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..225ef1f --- /dev/null +++ b/packages/fold-agent/src/Compatibility/GrokPlugins.ts @@ -0,0 +1,156 @@ +import { homedir } from 'node:os' +import { basename, dirname, join, resolve } from 'node:path' + +import { Effect, FileSystem, Schema } from 'effect' + +export const GrokPluginDiagnostic = Schema.Struct({ + stage: Schema.Literals(['manifest', 'discovery']), + code: Schema.String, + path: Schema.String, +}) +export type GrokPluginDiagnostic = typeof GrokPluginDiagnostic.Type + +export type GrokPluginSkillRoot = { readonly name: string; readonly path: string } + +export type GrokPluginOptions = { + readonly cwd: string + readonly home?: string + readonly grokHome?: string + readonly projectRoot?: string + readonly configuredPaths?: ReadonlyArray +} + +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 = ( + root: string, +): Effect.Effect<{ path: string; value: unknown } | null, never, FileSystem.FileSystem> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + for (const name of ['plugin.json', '.grok-plugin/plugin.json', '.claude-plugin/plugin.json']) { + const path = join(root, name) + const contents = yield* fs.readFileString(path).pipe(Effect.orElseSucceed(() => null)) + if (contents === null) continue + const value = yield* Effect.try(() => JSON.parse(contents)).pipe(Effect.orElseSucceed(() => null)) + return { path, value } + } + return null + }) + +export const discoverGrokPluginSkillRoots = Effect.fn('fold.grok_compatibility.discover_plugin_skills')(function* ( + options: GrokPluginOptions, +) { + const fs = yield* FileSystem.FileSystem + const cwd = resolve(options.cwd) + const homeValue = options.home === undefined ? homedir() : options.home + const home = homeValue.length === 0 ? null : resolve(homeValue) + const grokHome = resolve(options.grokHome ?? join(home ?? homedir(), '.grok')) + const projectRoot = options.projectRoot === undefined ? null : resolve(options.projectRoot) + const boundary = + projectRoot !== null && isAncestor(projectRoot, cwd) + ? projectRoot + : home !== null && isAncestor(home, cwd) + ? home + : null + const pluginParents = [ + ...(options.configuredPaths ?? []).map((path) => resolve(path)), + ...ancestorDirectories(cwd, boundary).flatMap((directory) => [ + join(directory, '.grok', 'plugins'), + join(directory, '.claude', 'plugins'), + ]), + join(grokHome, 'plugins'), + ...(home === null ? [] : [join(home, '.claude', 'plugins')]), + ] + const diagnostics: Array = [] + const roots: Array = [] + const seenPaths = new Set() + const seenNames = new Set() + + for (const parent of pluginParents) { + const parentManifest = yield* readManifest(parent) + const candidates = + parentManifest === null + ? (yield* fs.readDirectory(parent).pipe(Effect.orElseSucceed(() => []))) + .sort((left, right) => left.localeCompare(right)) + .map((entry) => join(parent, entry)) + : [parent] + for (const candidate of candidates) { + const normalized = resolve(candidate) + if (seenPaths.has(normalized)) continue + seenPaths.add(normalized) + const manifest = + candidate === parent && parentManifest !== null ? parentManifest : yield* readManifest(candidate) + if (manifest !== null && !isRecord(manifest.value)) { + diagnostics.push({ stage: 'manifest', code: 'manifest_parse_failed', path: manifest.path }) + continue + } + const manifestValue = manifest === null || !isRecord(manifest.value) ? null : manifest.value + const name = + manifestValue !== null && typeof manifestValue.name === 'string' + ? manifestValue.name + : basename(candidate) + if (name.length === 0 || seenNames.has(name)) continue + const declared = + manifestValue === null || manifestValue.skills === undefined + ? ['skills'] + : Array.isArray(manifestValue.skills) + ? manifestValue.skills + : [manifestValue.skills] + const skillRoots: Array = [] + 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..e21a97e --- /dev/null +++ b/packages/fold-agent/src/Compatibility/GrokSkills.ts @@ -0,0 +1,175 @@ +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 { parse as parseYaml } from 'yaml' + +export type GrokSkillOptions = { + readonly cwd: string + readonly home?: string + readonly grokHome?: string + readonly projectRoot?: string + readonly configuredPaths?: ReadonlyArray + readonly bundledPaths?: ReadonlyArray + readonly pluginPaths?: ReadonlyArray<{ readonly name: string; readonly path: string }> + readonly ignoredPaths?: ReadonlyArray +} + +const exists = (path: string): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false)) + }) + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + +const isAncestor = (ancestor: string, 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 = (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 scanRoot = ( + root: string, + ignoredPaths: ReadonlyArray, + namespace?: string, +): Effect.Effect, never, FileSystem.FileSystem> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + if (!(yield* exists(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(skillPath)) { + const skill = yield* loadSkill(skillPath, namespace) + if (skill !== null) found.push(skill) + return + } + const entries = yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => [])) + for (const entry of [...entries].sort()) { + if (entry.startsWith('.') || entry === 'node_modules') continue + const child = join(directory, entry) + const info = yield* fs.stat(child).pipe(Effect.orElseSucceed(() => null)) + if (info?.type === 'Directory') yield* scan(child) + } + }) + yield* scan(root) + return found + }) + +export const makeGrokSkillSource = Effect.fn('fold.grok_compatibility.make_skill_source')(function* ( + options: GrokSkillOptions, +) { + const fs = yield* FileSystem.FileSystem + const 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) + }), + ), + } 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..841ef01 --- /dev/null +++ b/packages/fold-agent/test/Compatibility/GrokCompatibility.vi.test.ts @@ -0,0 +1,161 @@ +import { homedir } from 'node:os' + +import { expect, it } from '@effect/vitest' +import { Effect, FileSystem } from 'effect' + +import { loadGrokCompatibility, loadGrokInstructions } from '../../src/index' +import { memoryFileSystem } from '../TestHelpers' + +const skill = (name: string, description: string, marker = name): string => + ['---', `name: ${name}`, `description: ${description}`, '---', '', marker].join('\n') + +it.effect('loads Grok global and root-to-cwd instructions while respecting gitignore', () => + Effect.gen(function* () { + const fs = memoryFileSystem({ + '/home/user/.grok/AGENTS.md': 'global grok', + '/home/user/.grok/rules/a.md': '---\nglobs: src/**\n---\nglobal rule', + '/home/user/.claude/CLAUDE.md': 'global claude compatibility', + '/repo/.gitignore': '.grok/rules/ignored.md\n', + '/repo/AGENTS.md': 'repo agents', + '/repo/CLAUDE.md': 'repo claude', + '/repo/.grok/rules/a.md': 'repo grok rule', + '/repo/.grok/rules/ignored.md': 'must not load', + '/repo/.claude/rules/a.md': 'repo claude rule', + '/repo/apps/AGENT.md': 'apps agent', + '/repo/apps/service/.cursor/rules/z.md': 'service cursor rule', + }) + + const sources = yield* loadGrokInstructions({ + cwd: '/repo/apps/service', + projectRoot: '/repo', + home: '/home/user', + }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) + + expect(sources.map(({ content }) => content)).toEqual([ + 'global grok', + 'global rule', + 'global claude compatibility', + 'repo claude', + 'repo agents', + 'repo grok rule', + 'repo claude rule', + 'apps agent', + 'service cursor rule', + ]) + }), +) + +it.effect('loads Grok, Agents, Claude, configured, and plugin skills with provider-local precedence', () => + Effect.gen(function* () { + const fs = memoryFileSystem({ + '/repo/.grok/skills/shared/SKILL.md': skill('shared', 'Repo Grok winner', 'repo grok'), + '/repo/.agents/skills/agents/SKILL.md': skill('agents', 'Agents compatibility'), + '/repo/apps/.claude/skills/claude/SKILL.md': skill('claude', 'Claude compatibility'), + '/configured/custom/SKILL.md': skill('configured', 'Configured skill'), + '/home/user/.grok/skills/global/SKILL.md': skill('global', 'Global Grok skill'), + '/repo/.grok/plugins/acme/plugin.json': JSON.stringify({ name: 'acme', skills: ['./custom-skills'] }), + '/repo/.grok/plugins/acme/custom-skills/deploy/SKILL.md': skill('deploy', 'Plugin deploy'), + '/repo/.claude/plugins/ignored/plugin.json': JSON.stringify({ name: 'acme' }), + '/repo/.claude/plugins/ignored/skills/loser/SKILL.md': skill('loser', 'Name collision loser'), + }) + + const compatibility = yield* loadGrokCompatibility({ + cwd: '/repo/apps', + projectRoot: '/repo', + home: '/home/user', + configuredPaths: ['/configured'], + }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) + const names = (yield* compatibility.skills.list).map(({ name }) => name) + + expect(names).toEqual(['claude', 'shared', 'agents', 'configured', 'global', 'acme:deploy']) + expect((yield* compatibility.skills.load('shared')).content).toBe('repo grok') + expect(compatibility.diagnostics).toEqual([]) + }), +) + +it.effect('keeps Codex-only roots out of Grok compatibility', () => + Effect.gen(function* () { + const fs = memoryFileSystem({ + '/home/user/.codex/AGENTS.md': 'codex global', + '/home/user/.codex/skills/codex/SKILL.md': skill('codex', 'Codex only'), + '/repo/.codex/skills/project/SKILL.md': skill('project', 'Codex project only'), + }) + const compatibility = yield* loadGrokCompatibility({ + cwd: '/repo', + projectRoot: '/repo', + home: '/home/user', + }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) + + expect(compatibility.instructionBlock).toBeNull() + expect(yield* compatibility.skills.list).toEqual([]) + }), +) + +it.effect('reports malformed plugin metadata without failing compatibility loading', () => + Effect.gen(function* () { + const fs = memoryFileSystem({ + '/repo/.grok/plugins/broken/plugin.json': '{not-json', + '/repo/.grok/plugins/unsafe/plugin.json': JSON.stringify({ name: 'unsafe', skills: ['../outside'] }), + }) + const compatibility = yield* loadGrokCompatibility({ + cwd: '/repo', + projectRoot: '/repo', + home: '/home/user', + }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) + + expect(yield* compatibility.skills.list).toEqual([]) + expect(compatibility.diagnostics).toEqual([ + { + stage: 'manifest', + code: 'manifest_parse_failed', + path: '/repo/.grok/plugins/broken/plugin.json', + }, + { + stage: 'manifest', + code: 'invalid_skill_root', + path: '/repo/.grok/plugins/unsafe/plugin.json', + }, + ]) + }), +) + +it.effect('uses the operating-system home for default Grok plugin discovery', () => + Effect.gen(function* () { + const home = homedir() + const fs = memoryFileSystem({ + [`${home}/.grok/plugins/default-home/plugin.json`]: JSON.stringify({ name: 'default-home' }), + [`${home}/.grok/plugins/default-home/skills/proof/SKILL.md`]: skill('proof', 'Default home plugin'), + }) + + const compatibility = yield* loadGrokCompatibility({ cwd: '/repo', projectRoot: '/repo' }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + ) + + expect((yield* compatibility.skills.list).map(({ name }) => name)).toContain('default-home:proof') + }), +) + +it.effect('skips malformed skill frontmatter and rejects backslash plugin roots without defects', () => + Effect.gen(function* () { + const fs = memoryFileSystem({ + '/repo/.grok/skills/broken/SKILL.md': '---\nname: [unterminated\n---\nBroken', + '/repo/.grok/plugins/unsafe/plugin.json': JSON.stringify({ + name: 'unsafe', + skills: ['..\\outside'], + }), + }) + + const compatibility = yield* loadGrokCompatibility({ + cwd: '/repo', + projectRoot: '/repo', + home: '/home/user', + }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) + + expect(yield* compatibility.skills.list).toEqual([]) + expect(compatibility.diagnostics).toContainEqual({ + stage: 'manifest', + code: 'invalid_skill_root', + path: '/repo/.grok/plugins/unsafe/plugin.json', + }) + }), +)