Răsfoiți Sursa

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
Dax Raad 2 săptămâni în urmă
părinte
comite
266de27a0b

+ 4 - 0
packages/opencode/src/config/config.ts

@@ -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 - 0
packages/opencode/src/skill/discovery.ts

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

+ 17 - 0
packages/opencode/src/skill/skill.ts

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