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({
|
export const Skills = z.object({
|
||||||
paths: z.array(z.string()).optional().describe("Additional paths to skill folders"),
|
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>
|
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 { Flag } from "@/flag/flag"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import { Session } from "@/session"
|
import { Session } from "@/session"
|
||||||
|
import { Discovery } from "./discovery"
|
||||||
|
|
||||||
export namespace Skill {
|
export namespace Skill {
|
||||||
const log = Log.create({ service: "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 {
|
return {
|
||||||
skills,
|
skills,
|
||||||
dirs: Array.from(dirs),
|
dirs: Array.from(dirs),
|
||||||
|
|||||||
Reference in New Issue
Block a user