From 99e52303e6af87b5754285ca5db52a82a852c28b Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 8 Jul 2026 16:34:41 -0400 Subject: [PATCH] feat(core): discover ecosystem skill directories (#35956) --- packages/core/src/config.ts | 46 +++++++++++++++++++--- packages/core/src/config/plugin/agent.ts | 1 + packages/core/src/config/plugin/command.ts | 1 + packages/core/src/config/plugin/skill.ts | 10 +++++ packages/core/src/plugin/skill.ts | 1 + packages/core/test/config/config.test.ts | 27 ++++++++++++- packages/core/test/config/skill.test.ts | 10 +++++ 7 files changed, 89 insertions(+), 7 deletions(-) diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index ae05ddef7d..4533f9b378 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -119,7 +119,17 @@ export class Directory extends Schema.Class("Config.Directory")({ path: AbsolutePath, }) {} -export type Entry = Document | Directory +export class AgentsDirectory extends Schema.Class("Config.AgentsDirectory")({ + type: Schema.Literal("agents"), + path: AbsolutePath, +}) {} + +export class ClaudeDirectory extends Schema.Class("Config.ClaudeDirectory")({ + type: Schema.Literal("claude"), + path: AbsolutePath, +}) {} + +export type Entry = Document | Directory | AgentsDirectory | ClaudeDirectory export function latest(entries: readonly Entry[], key: K): Info[K] | undefined { return entries @@ -176,16 +186,37 @@ const layer = Layer.effect( const discover = Effect.fn("Config.discover")(function* () { const globalDirectory = AbsolutePath.make(global.config) + const globalAgentsDirectory = AbsolutePath.make(path.join(global.home, ".agents")) + const globalClaudeDirectory = AbsolutePath.make(path.join(global.home, ".claude")) const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config) const discovered = locationIsGlobal ? [] : yield* fs .up({ - targets: [".opencode", ...names.toReversed()], + targets: [".opencode", ".claude", ".agents", ...names.toReversed()], start: location.directory, stop: location.project.directory, }) - .pipe(Effect.orDie) + .pipe(Effect.orDie) + + // We load certain files from a few other folders in the ecosystem + const claude = [ + ...((yield* fs.isDir(globalClaudeDirectory)) + ? [new ClaudeDirectory({ type: "claude", path: globalClaudeDirectory })] + : []), + ...discovered + .filter((item) => path.basename(item) === ".claude") + .map((directory) => new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(directory) })), + ] + const agents = [ + ...((yield* fs.isDir(globalAgentsDirectory)) + ? [new AgentsDirectory({ type: "agents", path: globalAgentsDirectory })] + : []), + ...discovered + .filter((item) => path.basename(item) === ".agents") + .map((directory) => new AgentsDirectory({ type: "agents", path: AbsolutePath.make(directory) })), + ] + const directories = [ globalDirectory, ...discovered @@ -193,15 +224,18 @@ const layer = Layer.effect( .toReversed() .map((directory) => AbsolutePath.make(directory)), ] - const directPaths = discovered.filter((item) => path.basename(item) !== ".opencode").toReversed() + const directPaths = discovered + .filter((item) => ![".agents", ".claude", ".opencode"].includes(path.basename(item))) + .toReversed() const direct = yield* Effect.forEach(directPaths, loadFile).pipe( Effect.orDie, Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)), ) + const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie) return { - entries: [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()], - directories, + entries: [...claude, ...agents, ...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()], + directories: [...directories, ...claude.map((entry) => entry.path), ...agents.map((entry) => entry.path)], files: directPaths, } }) diff --git a/packages/core/src/config/plugin/agent.ts b/packages/core/src/config/plugin/agent.ts index e6ed0b0ed8..fd494a7631 100644 --- a/packages/core/src/config/plugin/agent.ts +++ b/packages/core/src/config/plugin/agent.ts @@ -40,6 +40,7 @@ export const Plugin = define({ const load = Effect.fn("ConfigAgentPlugin.load")(function* () { return yield* Effect.forEach(yield* config.entries(), (entry) => { if (entry.type === "document") return Effect.succeed([entry]) + if (entry.type !== "directory") return Effect.succeed([]) return Effect.gen(function* () { const files = yield* discover(fs, entry.path) return yield* Effect.forEach(files, (file) => diff --git a/packages/core/src/config/plugin/command.ts b/packages/core/src/config/plugin/command.ts index b294ab15b9..744f58f7a7 100644 --- a/packages/core/src/config/plugin/command.ts +++ b/packages/core/src/config/plugin/command.ts @@ -19,6 +19,7 @@ export const Plugin = define({ const load = Effect.fn("ConfigCommandPlugin.load")(function* () { return yield* Effect.forEach(yield* config.entries(), (entry) => { if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }]) + if (entry.type !== "directory") return Effect.succeed([]) return loadDirectory(fs, entry.path).pipe( Effect.map((commands) => [ { commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }, diff --git a/packages/core/src/config/plugin/skill.ts b/packages/core/src/config/plugin/skill.ts index 84546b901c..de2f28ac9c 100644 --- a/packages/core/src/config/plugin/skill.ts +++ b/packages/core/src/config/plugin/skill.ts @@ -17,8 +17,18 @@ export const Plugin = define({ const location = yield* Location.Service const loaded = { entries: yield* config.entries() } yield* ctx.skill.transform((draft) => { + const claude = loaded.entries.flatMap((entry) => (entry.type === "claude" ? [entry.path] : [])) + const agents = loaded.entries.flatMap((entry) => (entry.type === "agents" ? [entry.path] : [])) const directories = loaded.entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : [])) const items = loaded.entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : [])) + for (const directory of [...claude, ...agents]) { + draft.source( + SkillV2.DirectorySource.make({ + type: "directory", + path: AbsolutePath.make(path.join(directory, "skills")), + }), + ) + } for (const directory of directories) { draft.source( SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }), diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index 439b77fd36..9e8726c4e4 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -92,6 +92,7 @@ const configuredPlugins = Effect.fn("SkillPlugin.configuredPlugins")(function* ( }), ) } + if (entry.type !== "directory") return Effect.succeed([]) return fs .glob("{plugin,plugins}/*.{ts,js}", { cwd: entry.path, diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 85db0a6bf3..4df256c86b 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -44,7 +44,7 @@ function testLayer( ) return AppNodeBuilder.build(LayerNode.group([Config.node, EventV2.node]), [ [Location.node, locationLayer], - [Global.node, Global.layerWith({ config: globalDirectory })], + [Global.node, Global.layerWith({ config: globalDirectory, home: path.join(globalDirectory, "home") })], ...(watcher ? ([[Watcher.node, watcher]] as const) : []), ]) } @@ -112,6 +112,7 @@ describe("Config", () => { info: new Config.Info({ model: selection("openrouter/openai/gpt-5") }), }), new Config.Directory({ type: "directory", path: AbsolutePath.make("/skills") }), + new Config.AgentsDirectory({ type: "agents", path: AbsolutePath.make("/agents") }), new Config.Document({ type: "document", info: new Config.Info({}) }), new Config.Document({ type: "document", @@ -848,11 +849,19 @@ describe("Config", () => { const root = path.join(tmp.path, "repo") const parent = path.join(root, "packages") const directory = path.join(parent, "app") + const globalAgents = path.join(global, "home", ".agents") + const globalClaude = path.join(global, "home", ".claude") return Effect.gen(function* () { yield* Effect.promise(async () => { await fs.mkdir(global, { recursive: true }) + await fs.mkdir(globalAgents, { recursive: true }) + await fs.mkdir(globalClaude, { recursive: true }) await fs.mkdir(directory, { recursive: true }) + await fs.mkdir(path.join(root, ".agents"), { recursive: true }) + await fs.mkdir(path.join(root, ".claude"), { recursive: true }) await fs.mkdir(path.join(root, ".opencode"), { recursive: true }) + await fs.mkdir(path.join(directory, ".agents"), { recursive: true }) + await fs.mkdir(path.join(directory, ".claude"), { recursive: true }) await fs.mkdir(path.join(directory, ".opencode"), { recursive: true }) await Promise.all([ fs.writeFile(path.join(tmp.path, "opencode.json"), JSON.stringify({ $schema: "outside" })), @@ -878,6 +887,16 @@ describe("Config", () => { AbsolutePath.make(path.join(root, ".opencode")), AbsolutePath.make(path.join(directory, ".opencode")), ]) + expect(entries.filter((entry) => entry.type === "agents").map((entry) => entry.path)).toEqual([ + AbsolutePath.make(globalAgents), + AbsolutePath.make(path.join(directory, ".agents")), + AbsolutePath.make(path.join(root, ".agents")), + ]) + expect(entries.filter((entry) => entry.type === "claude").map((entry) => entry.path)).toEqual([ + AbsolutePath.make(globalClaude), + AbsolutePath.make(path.join(directory, ".claude")), + AbsolutePath.make(path.join(root, ".claude")), + ]) expect(documents.map((document) => document.info.$schema)).toEqual([ "global", "root", @@ -887,6 +906,12 @@ describe("Config", () => { "directory-dot", ]) expect(entries.map((entry) => (entry.type === "document" ? entry.info.$schema : entry.path))).toEqual([ + AbsolutePath.make(globalClaude), + AbsolutePath.make(path.join(directory, ".claude")), + AbsolutePath.make(path.join(root, ".claude")), + AbsolutePath.make(globalAgents), + AbsolutePath.make(path.join(directory, ".agents")), + AbsolutePath.make(path.join(root, ".agents")), "global", AbsolutePath.make(global), "root", diff --git a/packages/core/test/config/skill.test.ts b/packages/core/test/config/skill.test.ts index 28ae7956dc..8b5b0bdf62 100644 --- a/packages/core/test/config/skill.test.ts +++ b/packages/core/test/config/skill.test.ts @@ -46,6 +46,8 @@ describe("ConfigSkillPlugin.Plugin", () => { Config.Service.of({ entries: () => Effect.succeed([ + new Config.ClaudeDirectory({ type: "claude", path: AbsolutePath.make("/repo/.claude") }), + new Config.AgentsDirectory({ type: "agents", path: AbsolutePath.make("/repo/.agents") }), new Config.Directory({ type: "directory", path: AbsolutePath.make("/repo/.opencode") }), new Config.Document({ type: "document", @@ -59,6 +61,14 @@ describe("ConfigSkillPlugin.Plugin", () => { ) expect(sources).toEqual([ + SkillV2.DirectorySource.make({ + type: "directory", + path: AbsolutePath.make(path.join("/repo/.claude", "skills")), + }), + SkillV2.DirectorySource.make({ + type: "directory", + path: AbsolutePath.make(path.join("/repo/.agents", "skills")), + }), SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join("/repo/.opencode", "skill")),