feat(core): discover ecosystem skill directories (#35956)
This commit is contained in:
@@ -119,7 +119,17 @@ export class Directory extends Schema.Class<Directory>("Config.Directory")({
|
|||||||
path: AbsolutePath,
|
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 {
|
export function latest<K extends keyof Info>(entries: readonly Entry[], key: K): Info[K] | undefined {
|
||||||
return entries
|
return entries
|
||||||
@@ -176,16 +186,37 @@ const layer = Layer.effect(
|
|||||||
|
|
||||||
const discover = Effect.fn("Config.discover")(function* () {
|
const discover = Effect.fn("Config.discover")(function* () {
|
||||||
const globalDirectory = AbsolutePath.make(global.config)
|
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 locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config)
|
||||||
const discovered = locationIsGlobal
|
const discovered = locationIsGlobal
|
||||||
? []
|
? []
|
||||||
: yield* fs
|
: yield* fs
|
||||||
.up({
|
.up({
|
||||||
targets: [".opencode", ...names.toReversed()],
|
targets: [".opencode", ".claude", ".agents", ...names.toReversed()],
|
||||||
start: location.directory,
|
start: location.directory,
|
||||||
stop: location.project.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 = [
|
const directories = [
|
||||||
globalDirectory,
|
globalDirectory,
|
||||||
...discovered
|
...discovered
|
||||||
@@ -193,15 +224,18 @@ const layer = Layer.effect(
|
|||||||
.toReversed()
|
.toReversed()
|
||||||
.map((directory) => AbsolutePath.make(directory)),
|
.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(
|
const direct = yield* Effect.forEach(directPaths, loadFile).pipe(
|
||||||
Effect.orDie,
|
Effect.orDie,
|
||||||
Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)),
|
Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
|
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
|
||||||
return {
|
return {
|
||||||
entries: [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()],
|
entries: [...claude, ...agents, ...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()],
|
||||||
directories,
|
directories: [...directories, ...claude.map((entry) => entry.path), ...agents.map((entry) => entry.path)],
|
||||||
files: directPaths,
|
files: directPaths,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export const Plugin = define({
|
|||||||
const load = Effect.fn("ConfigAgentPlugin.load")(function* () {
|
const load = Effect.fn("ConfigAgentPlugin.load")(function* () {
|
||||||
return yield* Effect.forEach(yield* config.entries(), (entry) => {
|
return yield* Effect.forEach(yield* config.entries(), (entry) => {
|
||||||
if (entry.type === "document") return Effect.succeed([entry])
|
if (entry.type === "document") return Effect.succeed([entry])
|
||||||
|
if (entry.type !== "directory") return Effect.succeed([])
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const files = yield* discover(fs, entry.path)
|
const files = yield* discover(fs, entry.path)
|
||||||
return yield* Effect.forEach(files, (file) =>
|
return yield* Effect.forEach(files, (file) =>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export const Plugin = define({
|
|||||||
const load = Effect.fn("ConfigCommandPlugin.load")(function* () {
|
const load = Effect.fn("ConfigCommandPlugin.load")(function* () {
|
||||||
return yield* Effect.forEach(yield* config.entries(), (entry) => {
|
return yield* Effect.forEach(yield* config.entries(), (entry) => {
|
||||||
if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }])
|
if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }])
|
||||||
|
if (entry.type !== "directory") return Effect.succeed([])
|
||||||
return loadDirectory(fs, entry.path).pipe(
|
return loadDirectory(fs, entry.path).pipe(
|
||||||
Effect.map((commands) => [
|
Effect.map((commands) => [
|
||||||
{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) },
|
{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) },
|
||||||
|
|||||||
@@ -17,8 +17,18 @@ export const Plugin = define({
|
|||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
const loaded = { entries: yield* config.entries() }
|
const loaded = { entries: yield* config.entries() }
|
||||||
yield* ctx.skill.transform((draft) => {
|
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 directories = loaded.entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
|
||||||
const items = loaded.entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
|
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) {
|
for (const directory of directories) {
|
||||||
draft.source(
|
draft.source(
|
||||||
SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
|
SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ const configuredPlugins = Effect.fn("SkillPlugin.configuredPlugins")(function* (
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (entry.type !== "directory") return Effect.succeed([])
|
||||||
return fs
|
return fs
|
||||||
.glob("{plugin,plugins}/*.{ts,js}", {
|
.glob("{plugin,plugins}/*.{ts,js}", {
|
||||||
cwd: entry.path,
|
cwd: entry.path,
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ function testLayer(
|
|||||||
)
|
)
|
||||||
return AppNodeBuilder.build(LayerNode.group([Config.node, EventV2.node]), [
|
return AppNodeBuilder.build(LayerNode.group([Config.node, EventV2.node]), [
|
||||||
[Location.node, locationLayer],
|
[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) : []),
|
...(watcher ? ([[Watcher.node, watcher]] as const) : []),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
@@ -112,6 +112,7 @@ describe("Config", () => {
|
|||||||
info: new Config.Info({ model: selection("openrouter/openai/gpt-5") }),
|
info: new Config.Info({ model: selection("openrouter/openai/gpt-5") }),
|
||||||
}),
|
}),
|
||||||
new Config.Directory({ type: "directory", path: AbsolutePath.make("/skills") }),
|
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", info: new Config.Info({}) }),
|
||||||
new Config.Document({
|
new Config.Document({
|
||||||
type: "document",
|
type: "document",
|
||||||
@@ -848,11 +849,19 @@ describe("Config", () => {
|
|||||||
const root = path.join(tmp.path, "repo")
|
const root = path.join(tmp.path, "repo")
|
||||||
const parent = path.join(root, "packages")
|
const parent = path.join(root, "packages")
|
||||||
const directory = path.join(parent, "app")
|
const directory = path.join(parent, "app")
|
||||||
|
const globalAgents = path.join(global, "home", ".agents")
|
||||||
|
const globalClaude = path.join(global, "home", ".claude")
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
yield* Effect.promise(async () => {
|
yield* Effect.promise(async () => {
|
||||||
await fs.mkdir(global, { recursive: true })
|
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(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(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 fs.mkdir(path.join(directory, ".opencode"), { recursive: true })
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
fs.writeFile(path.join(tmp.path, "opencode.json"), JSON.stringify({ $schema: "outside" })),
|
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(root, ".opencode")),
|
||||||
AbsolutePath.make(path.join(directory, ".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([
|
expect(documents.map((document) => document.info.$schema)).toEqual([
|
||||||
"global",
|
"global",
|
||||||
"root",
|
"root",
|
||||||
@@ -887,6 +906,12 @@ describe("Config", () => {
|
|||||||
"directory-dot",
|
"directory-dot",
|
||||||
])
|
])
|
||||||
expect(entries.map((entry) => (entry.type === "document" ? entry.info.$schema : entry.path))).toEqual([
|
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",
|
"global",
|
||||||
AbsolutePath.make(global),
|
AbsolutePath.make(global),
|
||||||
"root",
|
"root",
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ describe("ConfigSkillPlugin.Plugin", () => {
|
|||||||
Config.Service.of({
|
Config.Service.of({
|
||||||
entries: () =>
|
entries: () =>
|
||||||
Effect.succeed([
|
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.Directory({ type: "directory", path: AbsolutePath.make("/repo/.opencode") }),
|
||||||
new Config.Document({
|
new Config.Document({
|
||||||
type: "document",
|
type: "document",
|
||||||
@@ -59,6 +61,14 @@ describe("ConfigSkillPlugin.Plugin", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(sources).toEqual([
|
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({
|
SkillV2.DirectorySource.make({
|
||||||
type: "directory",
|
type: "directory",
|
||||||
path: AbsolutePath.make(path.join("/repo/.opencode", "skill")),
|
path: AbsolutePath.make(path.join("/repo/.opencode", "skill")),
|
||||||
|
|||||||
Reference in New Issue
Block a user