feat(skill): add skill discovery from URLs via well-known RFC
Implement the Agent Skills Discovery RFC to allow fetching skills from URLs:
- Add 'urls' field to config.skills for specifying skill registry URLs
- Create Discovery namespace in skill/discovery.ts with pull() function
- Download skills from /.well-known/skills/index.json endpoints
- Cache downloaded skills to ~/.cache/opencode/skills/
- Skip re-downloading existing files for efficiency
Users can now configure:
{
"skills": {
"urls": ["https://example.com/.well-known/skills/"]
}
}
Implements: https://github.com/cloudflare/agent-skills-discovery-rfc
This commit is contained in:
@@ -660,6 +660,10 @@ export namespace Config {
|
||||
|
||||
export const Skills = z.object({
|
||||
paths: z.array(z.string()).optional().describe("Additional paths to skill folders"),
|
||||
urls: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe("URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)"),
|
||||
})
|
||||
export type Skills = z.infer<typeof Skills>
|
||||
|
||||
|
||||
80
packages/opencode/src/skill/discovery.ts
Normal file
80
packages/opencode/src/skill/discovery.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import path from "path"
|
||||
import { mkdir } from "fs/promises"
|
||||
import { Log } from "../util/log"
|
||||
import { Global } from "@/global"
|
||||
|
||||
export namespace Discovery {
|
||||
const log = Log.create({ service: "skill-discovery" })
|
||||
|
||||
type Index = {
|
||||
skills: Array<{
|
||||
name: string
|
||||
description: string
|
||||
files: string[]
|
||||
}>
|
||||
}
|
||||
|
||||
export function dir() {
|
||||
return path.join(Global.Path.cache, "skills")
|
||||
}
|
||||
|
||||
async function get(url: string, dest: string): Promise<boolean> {
|
||||
if (await Bun.file(dest).exists()) return true
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
log.error("failed to download", { url, status: response.status })
|
||||
return false
|
||||
}
|
||||
const content = await response.text()
|
||||
await Bun.write(dest, content)
|
||||
return true
|
||||
} catch (err) {
|
||||
log.error("failed to download", { url, err })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function pull(url: string): Promise<string[]> {
|
||||
const result: string[] = []
|
||||
const indexUrl = new URL("index.json", url.endsWith("/") ? url : `${url}/`).href
|
||||
const cacheDir = dir()
|
||||
|
||||
try {
|
||||
log.info("fetching index", { url: indexUrl })
|
||||
const response = await fetch(indexUrl)
|
||||
if (!response.ok) {
|
||||
log.error("failed to fetch index", { url: indexUrl, status: response.status })
|
||||
return result
|
||||
}
|
||||
|
||||
const index = (await response.json()) as Index
|
||||
if (!index.skills || !Array.isArray(index.skills)) {
|
||||
log.warn("invalid index format", { url: indexUrl })
|
||||
return result
|
||||
}
|
||||
|
||||
for (const skill of index.skills) {
|
||||
if (!skill.name || !skill.files || !Array.isArray(skill.files)) {
|
||||
log.warn("invalid skill entry", { url: indexUrl, skill })
|
||||
continue
|
||||
}
|
||||
|
||||
const skillDir = path.join(cacheDir, skill.name)
|
||||
for (const file of skill.files) {
|
||||
const fileUrl = new URL(file, `${url.replace(/\/$/, "")}/${skill.name}/`).href
|
||||
const localPath = path.join(skillDir, file)
|
||||
await mkdir(path.dirname(localPath), { recursive: true })
|
||||
await get(fileUrl, localPath)
|
||||
}
|
||||
|
||||
const skillMd = path.join(skillDir, "SKILL.md")
|
||||
if (await Bun.file(skillMd).exists()) result.push(skillDir)
|
||||
}
|
||||
} catch (err) {
|
||||
log.error("failed to fetch from URL", { url, err })
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { Filesystem } from "@/util/filesystem"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Bus } from "@/bus"
|
||||
import { Session } from "@/session"
|
||||
import { Discovery } from "./discovery"
|
||||
|
||||
export namespace Skill {
|
||||
const log = Log.create({ service: "skill" })
|
||||
@@ -151,6 +152,22 @@ export namespace Skill {
|
||||
}
|
||||
}
|
||||
|
||||
// Download and load skills from URLs
|
||||
for (const skillUrl of config.skills?.urls ?? []) {
|
||||
const downloadedDirs = await Discovery.pull(skillUrl)
|
||||
for (const dir of downloadedDirs) {
|
||||
dirs.add(dir)
|
||||
for await (const match of SKILL_GLOB.scan({
|
||||
cwd: dir,
|
||||
absolute: true,
|
||||
onlyFiles: true,
|
||||
followSymlinks: true,
|
||||
})) {
|
||||
await addSkill(match)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
skills,
|
||||
dirs: Array.from(dirs),
|
||||
|
||||
Reference in New Issue
Block a user