Bladeren bron

feat(core): discover ecosystem skill directories (#35956)

James Long 1 maand geleden
bovenliggende
commit
99e52303e6

+ 40 - 6
packages/core/src/config.ts

@@ -119,7 +119,17 @@ export class Directory extends Schema.Class<Directory>("Config.Directory")({
   path: AbsolutePath,
 }) {}
 
-export type Entry = Document | Directory
+export class AgentsDirectory extends Schema.Class<AgentsDirectory>("Config.AgentsDirectory")({
+  type: Schema.Literal("agents"),
+  path: AbsolutePath,
+}) {}
+
+export class ClaudeDirectory extends Schema.Class<ClaudeDirectory>("Config.ClaudeDirectory")({
+  type: Schema.Literal("claude"),
+  path: AbsolutePath,
+}) {}
+
+export type Entry = Document | Directory | AgentsDirectory | ClaudeDirectory
 
 export function latest<K extends keyof Info>(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,
       }
     })

+ 1 - 0
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) =>

+ 1 - 0
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])) },

+ 10 - 0
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")) }),

+ 1 - 0
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,

+ 26 - 1
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",

+ 10 - 0
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")),