From 7528916b3079b258188bab2dc7afb7f4d0f23766 Mon Sep 17 00:00:00 2001 From: iroiro147 Date: Wed, 8 Jul 2026 23:58:28 +0530 Subject: [PATCH] fix(eve): support symlinked skill discovery Signed-off-by: iroiro147 --- .changeset/skill-discovery-symlinks.md | 5 + docs/skills.mdx | 2 +- docs/subagents.mdx | 2 +- .../eve/src/compiler/workspace-resources.ts | 2 +- .../src/discover/skills.integration.test.ts | 156 ++++++++++++++++++ packages/eve/src/discover/skills.ts | 49 +++++- .../scenarios/compile-agent.scenario.test.ts | 73 +++++++- 7 files changed, 277 insertions(+), 12 deletions(-) create mode 100644 .changeset/skill-discovery-symlinks.md diff --git a/.changeset/skill-discovery-symlinks.md b/.changeset/skill-discovery-symlinks.md new file mode 100644 index 000000000..49dd9ad96 --- /dev/null +++ b/.changeset/skill-discovery-symlinks.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Support symlinked skill files and skill packages during authored skill discovery. diff --git a/docs/skills.mdx b/docs/skills.mdx index 5ffc5d2f4..25fcbee56 100644 --- a/docs/skills.mdx +++ b/docs/skills.mdx @@ -59,7 +59,7 @@ eve generates `SKILL.md` from `markdown`, and each `files` entry becomes a packa ## Skills are scoped per agent -Skills are scoped to the agent that declares them. A [subagent](./subagents)'s `skills/` are invisible to the root agent, and the reverse holds too. There's no shared-skill mechanism, so put shared executable helpers in `lib/`. +Skills are scoped to the agent that declares them. A [subagent](./subagents)'s `skills/` are invisible to the root agent, and the reverse holds too. To reuse an authored skill package locally, symlink its markdown file or package directory into each agent's own `skills/` directory. eve follows those links during discovery and copies their resolved contents into the compiled workspace resources, so deployment artifacts remain self-contained. Put shared executable helpers in `lib/`. ## Read skill files at runtime diff --git a/docs/subagents.mdx b/docs/subagents.mdx index 99a500b6f..7e16871b0 100644 --- a/docs/subagents.mdx +++ b/docs/subagents.mdx @@ -67,7 +67,7 @@ A declared subagent inherits nothing from the root's authored slots. Discovery t | Channels | Root-only | Root-only | | Schedules | Root-only | Root-only | -For a declared subagent this means duplicating anything the child needs. When two subagents need the same procedure, copy the markdown under each `skills/` directory, or share typed helpers via `lib/`. The sandbox does not inherit from the parent; it falls back to the framework default unless the subagent authors `subagents//sandbox.ts` or seeds files via `subagents//sandbox/workspace/`. +For a declared subagent this means explicitly placing anything the child needs in its own authored slots. When two subagents need the same procedure, copy the markdown or symlink the shared skill file/package into each `skills/` directory; eve dereferences linked skill contents into self-contained compile artifacts. Share typed helpers via `lib/`. The sandbox does not inherit from the parent; it falls back to the framework default unless the subagent authors `subagents//sandbox.ts` or seeds files via `subagents//sandbox/workspace/`. The built-in `agent` tool is the exception. Its children share the parent's sandbox and tools because they are copies of the same agent working on the same files. diff --git a/packages/eve/src/compiler/workspace-resources.ts b/packages/eve/src/compiler/workspace-resources.ts index 9956d0ab3..9c0ad921f 100644 --- a/packages/eve/src/compiler/workspace-resources.ts +++ b/packages/eve/src/compiler/workspace-resources.ts @@ -109,7 +109,7 @@ async function materializeSkill(input: { const skillRoot = join(input.nodeRoot, RESOURCE_SKILLS_DIRECTORY, input.skill.name); if (input.skill.sourceKind === "skill-package") { - await cp(input.skill.rootPath, skillRoot, { recursive: true }); + await cp(input.skill.rootPath, skillRoot, { dereference: true, recursive: true }); return; } diff --git a/packages/eve/src/discover/skills.integration.test.ts b/packages/eve/src/discover/skills.integration.test.ts index 6a1e3ebe3..ab53a9fba 100644 --- a/packages/eve/src/discover/skills.integration.test.ts +++ b/packages/eve/src/discover/skills.integration.test.ts @@ -1,3 +1,5 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { describe, expect, it } from "vitest"; @@ -12,6 +14,43 @@ import { discoverSkills, } from "#discover/skills.js"; +async function withDiskAgentProject( + callback: (paths: { agentRoot: string; appRoot: string; tempRoot: string }) => Promise, +): Promise { + const tempRoot = await mkdtemp(join(tmpdir(), "eve-skills-")); + const appRoot = join(tempRoot, "app"); + const agentRoot = join(appRoot, "agent"); + + try { + await mkdir(join(agentRoot, "skills"), { recursive: true }); + return await callback({ agentRoot, appRoot, tempRoot }); + } finally { + await rm(tempRoot, { force: true, recursive: true }); + } +} + +async function trySymlink( + target: string, + path: string, + type?: "dir" | "file" | "junction", +): Promise { + try { + await symlink(target, path, type); + return true; + } catch (error) { + if ( + typeof error === "object" && + error !== null && + "code" in error && + (error.code === "EPERM" || error.code === "EACCES") + ) { + return false; + } + + throw error; + } +} + describe("discoverSkills (memory)", () => { it("discovers packaged, markdown, and module-backed skills", async () => { const project = buildMemoryAgentProject({ @@ -237,3 +276,120 @@ describe("discoverSkills (memory)", () => { ]); }); }); + +describe("discoverSkills (disk)", () => { + it("discovers flat markdown skills through file symlinks", async ({ skip }) => { + await withDiskAgentProject(async ({ agentRoot, tempRoot }) => { + const sharedSkillPath = join(tempRoot, "shared", "research-source.md"); + await mkdir(join(tempRoot, "shared"), { recursive: true }); + await writeFile( + sharedSkillPath, + [ + "---", + "description: Research complex marketplace questions before replying.", + "---", + "Use the shared research process.", + ].join("\n"), + ); + + const symlinkPath = join(agentRoot, "skills", "market-research.md"); + if (!(await trySymlink(sharedSkillPath, symlinkPath, "file"))) { + skip("file symlink creation is unavailable on this platform"); + } + + const result = await discoverSkills({ agentRoot }); + + expect(result.diagnostics).toEqual([]); + expect(result.skills).toEqual([ + { + definition: { + description: "Research complex marketplace questions before replying.", + markdown: "Use the shared research process.", + }, + sourceKind: "markdown", + logicalPath: "skills/market-research.md", + sourceId: "skills/market-research.md", + }, + ]); + }); + }); + + it("discovers packaged skills whose SKILL.md is a file symlink", async ({ skip }) => { + await withDiskAgentProject(async ({ agentRoot, tempRoot }) => { + const sharedSkillPath = join(tempRoot, "shared", "SKILL.md"); + const skillRoot = join(agentRoot, "skills", "market-research"); + const symlinkPath = join(skillRoot, "SKILL.md"); + await mkdir(join(tempRoot, "shared"), { recursive: true }); + await mkdir(skillRoot, { recursive: true }); + await writeFile( + sharedSkillPath, + [ + "---", + "description: Research complex marketplace questions before replying.", + "---", + "Use the packaged research process.", + ].join("\n"), + ); + + if (!(await trySymlink(sharedSkillPath, symlinkPath, "file"))) { + skip("file symlink creation is unavailable on this platform"); + } + + const result = await discoverSkills({ agentRoot }); + + expect(result.diagnostics).toEqual([]); + expect(result.skills).toEqual([ + { + description: "Research complex marketplace questions before replying.", + sourceKind: "skill-package", + logicalPath: "skills/market-research/SKILL.md", + markdown: "Use the packaged research process.", + name: "market-research", + rootPath: skillRoot, + skillFilePath: symlinkPath, + skillId: "market-research", + sourceId: "skills/market-research/SKILL.md", + }, + ]); + }); + }); + + it("discovers packaged skills through directory symlinks or junctions", async ({ skip }) => { + await withDiskAgentProject(async ({ agentRoot, tempRoot }) => { + const sharedSkillRoot = join(tempRoot, "shared", "market-research"); + const symlinkPath = join(agentRoot, "skills", "market-research"); + await mkdir(sharedSkillRoot, { recursive: true }); + await writeFile( + join(sharedSkillRoot, "SKILL.md"), + [ + "---", + "description: Research complex marketplace questions before replying.", + "---", + "Use the directory-linked research process.", + ].join("\n"), + ); + + const linkType = process.platform === "win32" ? "junction" : "dir"; + if (!(await trySymlink(sharedSkillRoot, symlinkPath, linkType))) { + skip("directory symlink or junction creation is unavailable on this platform"); + } + + const result = await discoverSkills({ agentRoot }); + + expect(result.diagnostics).toEqual([]); + expect(result.skills).toEqual([ + { + description: "Research complex marketplace questions before replying.", + sourceKind: "skill-package", + logicalPath: "skills/market-research/SKILL.md", + markdown: "Use the directory-linked research process.", + name: "market-research", + rootPath: symlinkPath, + skillFilePath: join(symlinkPath, "SKILL.md"), + skillId: "market-research", + sourceId: "skills/market-research/SKILL.md", + }, + ]); + }); + }); +}); diff --git a/packages/eve/src/discover/skills.ts b/packages/eve/src/discover/skills.ts index 27ad4cd60..9c5c2cdf3 100644 --- a/packages/eve/src/discover/skills.ts +++ b/packages/eve/src/discover/skills.ts @@ -8,6 +8,7 @@ import { classifySkillsDirectoryEntry, getDirectoryEntryType, getSupportedModuleBaseName, + type DirectoryEntryType, normalizeLogicalPath, } from "#discover/filesystem.js"; import { readSortedDirectoryEntries } from "#discover/grammar.js"; @@ -96,9 +97,10 @@ export async function discoverSkills(input: DiscoverSkillsInput): Promise { const entries = await readSortedDirectoryEntries(input.source, input.skillRootPath); - const skillFileName = entries.find( - (entry) => entry.isFile() && entry.name.toLowerCase() === "skill.md", - )?.name; + let skillFileName: string | undefined; + + for (const entry of entries) { + if (entry.name.toLowerCase() !== "skill.md") { + continue; + } + + const entryType = await resolveSkillEntryType( + input.source, + join(input.skillRootPath, entry.name), + getDirectoryEntryType(entry), + ); + + if (entryType === "file") { + skillFileName = entry.name; + break; + } + } + const skillFilePath = join(input.skillRootPath, skillFileName ?? "SKILL.md"); const logicalPath = normalizeLogicalPath( join(input.logicalSkillsPath, input.skillId, skillFileName ?? "SKILL.md"), @@ -393,11 +411,13 @@ async function discoverSkillPackagePaths( } = {}; for (const entry of entries) { - if (!entry.isDirectory()) { - continue; - } + const entryType = await resolveSkillEntryType( + source, + join(skillRootPath, entry.name), + getDirectoryEntryType(entry), + ); - switch (classifySkillPackageEntry(entry.name, getDirectoryEntryType(entry))) { + switch (classifySkillPackageEntry(entry.name, entryType)) { case "skill-assets-directory": packagePaths.assetsPath = join(skillRootPath, entry.name); break; @@ -415,6 +435,19 @@ async function discoverSkillPackagePaths( return packagePaths; } +async function resolveSkillEntryType( + source: ProjectSource, + entryPath: string, + entryType: DirectoryEntryType, +): Promise { + if (entryType !== "other") { + return entryType; + } + + const targetType = await source.stat(entryPath); + return targetType === "directory" || targetType === "file" ? targetType : "other"; +} + function formatSkillDiscoveryError(skillFilePath: string, error: unknown): string { return `Invalid authored skill frontmatter in "${skillFilePath}": ${toErrorMessage(error)}`; } diff --git a/packages/eve/test/scenarios/compile-agent.scenario.test.ts b/packages/eve/test/scenarios/compile-agent.scenario.test.ts index f1be307bc..b1c0c7ffe 100644 --- a/packages/eve/test/scenarios/compile-agent.scenario.test.ts +++ b/packages/eve/test/scenarios/compile-agent.scenario.test.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { lstat, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; @@ -840,6 +840,77 @@ describe("compileAgent", () => { expect(moduleMapText).not.toContain("Find primary sources"); }); + it("dereferences linked skill packages into self-contained workspace resources", async ({ + skip, + }) => { + const { agentRoot, appRoot } = await createAppRoot( + "eve-compile-linked-skill-package-", + APP_ROOT_OPTIONS, + ); + const sharedSkillRoot = join(appRoot, "shared-skills", "research"); + const linkedSkillRoot = join(agentRoot, "skills", "research"); + + await mkdir(join(sharedSkillRoot, "references"), { recursive: true }); + await mkdir(join(agentRoot, "skills"), { recursive: true }); + await writeFile(join(agentRoot, "agent.mjs"), 'export default { model: "openai/gpt-5.4" };\n'); + await writeFile(join(agentRoot, "instructions.md"), "You are a precise assistant."); + await writeFile( + join(sharedSkillRoot, "SKILL.md"), + [ + "---", + "description: Research unfamiliar topics.", + "---", + "Gather evidence first.", + ].join("\n"), + ); + await writeFile( + join(sharedSkillRoot, "references", "checklist.md"), + "# Checklist\n\n- Find primary sources.\n", + ); + try { + await symlink( + sharedSkillRoot, + linkedSkillRoot, + process.platform === "win32" ? "junction" : "dir", + ); + } catch (error) { + if ( + typeof error === "object" && + error !== null && + "code" in error && + (error.code === "EPERM" || error.code === "EACCES") + ) { + skip("directory symlink or junction creation is unavailable on this platform"); + } + + throw error; + } + + const result = await compileAgent({ startPath: appRoot }); + const compiledSkillRoot = join( + result.paths.compileDirectoryPath, + "workspace-resources", + ROOT_COMPILED_AGENT_NODE_ID, + "skills", + "research", + ); + + await rm(sharedSkillRoot, { recursive: true }); + + const [skillStats, referenceStats, skillMarkdown, checklist] = await Promise.all([ + lstat(join(compiledSkillRoot, "SKILL.md")), + lstat(join(compiledSkillRoot, "references", "checklist.md")), + readFile(join(compiledSkillRoot, "SKILL.md"), "utf8"), + readFile(join(compiledSkillRoot, "references", "checklist.md"), "utf8"), + ]); + + expect(skillStats.isSymbolicLink()).toBe(false); + expect(referenceStats.isSymbolicLink()).toBe(false); + expect(skillMarkdown).toContain("Gather evidence first."); + expect(checklist).toBe("# Checklist\n\n- Find primary sources.\n"); + expect(result.manifest.workspaceResourceRoot?.contentHash).toMatch(/^[a-f0-9]{64}$/); + }); + it("compiles nested authored tools using the path-derived tool name", async () => { const { agentRoot, appRoot } = await createAppRoot( "eve-compile-nested-tools-",