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:
Dax Raad
2026-02-06 00:29:43 -05:00
parent 0f1fdeceda
commit 266de27a0b
3 changed files with 101 additions and 0 deletions

View File

@@ -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>

View 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
}
}

View File

@@ -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),