| 1 | // Skills: carlosframework.com publishes the platform's agent skills per the |
| 2 | // Agent Skills Discovery draft (github.com/cloudflare/agent-skills-discovery-rfc) |
| 3 | // at a well-known index. This command is the bootstrap pointer — any agent |
| 4 | // holding the CLI can list what is published and fetch one, verified, |
| 5 | // without knowing the site's layout in advance. |
| 6 | package main |
| 7 | |
| 8 | import ( |
| 9 | "crypto/sha256" |
| 10 | "encoding/hex" |
| 11 | "encoding/json" |
| 12 | "flag" |
| 13 | "fmt" |
| 14 | "io" |
| 15 | "log" |
| 16 | "net/http" |
| 17 | "net/url" |
| 18 | "strings" |
| 19 | "time" |
| 20 | ) |
| 21 | |
| 22 | // skillsIndexURL is a var, not a const, so tests can point it at an |
| 23 | // httptest server and -index can point a self-hosted deployment elsewhere. |
| 24 | // Production never changes it. |
| 25 | var skillsIndexURL = "https://carlosframework.com/.well-known/agent-skills/index.json" |
| 26 | |
| 27 | // skillEntry is one row of the published index. The draft allows unknown |
| 28 | // fields ("//" among them) — only what this command uses is declared here. |
| 29 | type skillEntry struct { |
| 30 | Name string `json:"name"` |
| 31 | Type string `json:"type"` |
| 32 | Description string `json:"description"` |
| 33 | URL string `json:"url"` |
| 34 | Digest string `json:"digest"` |
| 35 | } |
| 36 | |
| 37 | type skillsIndex struct { |
| 38 | Skills []skillEntry `json:"skills"` |
| 39 | } |
| 40 | |
| 41 | // skillsHTTPTimeout bounds both the index fetch and the skill fetch — a |
| 42 | // slow or hung site must fail loudly, not hang an agent's session. |
| 43 | const skillsHTTPTimeout = 10 * time.Second |
| 44 | |
| 45 | // fetchSkillsIndex GETs and parses the index. Errors are returned rather |
| 46 | // than fatal'd so runSkills is the only place in this file that exits. |
| 47 | func fetchSkillsIndex(indexURL string) (*skillsIndex, error) { |
| 48 | client := &http.Client{Timeout: skillsHTTPTimeout} |
| 49 | res, err := client.Get(indexURL) |
| 50 | if err != nil { |
| 51 | return nil, err |
| 52 | } |
| 53 | defer res.Body.Close() |
| 54 | if res.StatusCode != http.StatusOK { |
| 55 | return nil, fmt.Errorf("%s: status %d", indexURL, res.StatusCode) |
| 56 | } |
| 57 | var idx skillsIndex |
| 58 | if err := json.NewDecoder(res.Body).Decode(&idx); err != nil { |
| 59 | return nil, fmt.Errorf("%s: %v", indexURL, err) |
| 60 | } |
| 61 | return &idx, nil |
| 62 | } |
| 63 | |
| 64 | // resolveSkillURL turns a site-relative entry.url into the absolute URL to |
| 65 | // fetch, resolved against indexURL per the draft (net/url ResolveReference, |
| 66 | // not string concatenation — a leading "/" must land at the site root |
| 67 | // even when the index itself lives under a path prefix). |
| 68 | func resolveSkillURL(indexURL, ref string) (string, error) { |
| 69 | base, err := url.Parse(indexURL) |
| 70 | if err != nil { |
| 71 | return "", err |
| 72 | } |
| 73 | rel, err := url.Parse(ref) |
| 74 | if err != nil { |
| 75 | return "", err |
| 76 | } |
| 77 | return base.ResolveReference(rel).String(), nil |
| 78 | } |
| 79 | |
| 80 | // listSkills renders the index: name, then indented description, then |
| 81 | // indented absolute url, blank line between entries. |
| 82 | func listSkills(w io.Writer, indexURL string, idx *skillsIndex) error { |
| 83 | for i, s := range idx.Skills { |
| 84 | if i > 0 { |
| 85 | fmt.Fprintln(w) |
| 86 | } |
| 87 | abs, err := resolveSkillURL(indexURL, s.URL) |
| 88 | if err != nil { |
| 89 | return err |
| 90 | } |
| 91 | fmt.Fprintln(w, s.Name) |
| 92 | fmt.Fprintf(w, " %s\n", s.Description) |
| 93 | fmt.Fprintf(w, " %s\n", abs) |
| 94 | } |
| 95 | return nil |
| 96 | } |
| 97 | |
| 98 | // fetchSkill finds name in the index at indexURL, fetches its url, and |
| 99 | // verifies the sha256 digest before returning the bytes. A mismatch means |
| 100 | // corruption or tampering (the draft's own words) — the skill must not be |
| 101 | // used, so the mismatched bytes are never returned to the caller. |
| 102 | func fetchSkill(indexURL, name string) ([]byte, error) { |
| 103 | idx, err := fetchSkillsIndex(indexURL) |
| 104 | if err != nil { |
| 105 | return nil, err |
| 106 | } |
| 107 | var entry *skillEntry |
| 108 | names := make([]string, 0, len(idx.Skills)) |
| 109 | for i := range idx.Skills { |
| 110 | names = append(names, idx.Skills[i].Name) |
| 111 | if idx.Skills[i].Name == name { |
| 112 | entry = &idx.Skills[i] |
| 113 | } |
| 114 | } |
| 115 | if entry == nil { |
| 116 | return nil, fmt.Errorf("no skill named %q — available: %s", name, strings.Join(names, ", ")) |
| 117 | } |
| 118 | abs, err := resolveSkillURL(indexURL, entry.URL) |
| 119 | if err != nil { |
| 120 | return nil, err |
| 121 | } |
| 122 | client := &http.Client{Timeout: skillsHTTPTimeout} |
| 123 | res, err := client.Get(abs) |
| 124 | if err != nil { |
| 125 | return nil, err |
| 126 | } |
| 127 | defer res.Body.Close() |
| 128 | if res.StatusCode != http.StatusOK { |
| 129 | return nil, fmt.Errorf("%s: status %d", abs, res.StatusCode) |
| 130 | } |
| 131 | raw, err := io.ReadAll(res.Body) |
| 132 | if err != nil { |
| 133 | return nil, err |
| 134 | } |
| 135 | sum := sha256.Sum256(raw) |
| 136 | got := "sha256:" + hex.EncodeToString(sum[:]) |
| 137 | if got != entry.Digest { |
| 138 | return nil, fmt.Errorf("%s: digest mismatch (index says %s, fetched bytes are %s) — corrupted or tampered, refusing to use it", |
| 139 | name, entry.Digest, got) |
| 140 | } |
| 141 | return raw, nil |
| 142 | } |
| 143 | |
| 144 | const skillsUsage = `usage: carlos skills [<name>] [--index <url>] |
| 145 | |
| 146 | Lists the agent skills the platform publishes at |
| 147 | /.well-known/agent-skills/index.json (the Agent Skills Discovery draft), |
| 148 | or prints one SKILL.md to stdout after verifying its sha256 digest |
| 149 | against the index — "carlos skills getting-started > SKILL.md" hands an |
| 150 | agent the recipe.` |
| 151 | |
| 152 | func runSkills(args []string) { |
| 153 | fs := flag.NewFlagSet("skills", flag.ExitOnError) |
| 154 | index := fs.String("index", skillsIndexURL, "agent-skills index URL") |
| 155 | fs.Usage = flagUsage(fs, skillsUsage) |
| 156 | fs.Parse(args) |
| 157 | |
| 158 | if fs.NArg() == 0 { |
| 159 | idx, err := fetchSkillsIndex(*index) |
| 160 | if err != nil { |
| 161 | log.Fatalf("skills: %v", err) |
| 162 | } |
| 163 | if err := listSkills(stdout, *index, idx); err != nil { |
| 164 | log.Fatalf("skills: %v", err) |
| 165 | } |
| 166 | return |
| 167 | } |
| 168 | raw, err := fetchSkill(*index, fs.Arg(0)) |
| 169 | if err != nil { |
| 170 | log.Fatalf("skills: %v", err) |
| 171 | } |
| 172 | if _, err := stdout.Write(raw); err != nil { |
| 173 | log.Fatalf("skills: %v", err) |
| 174 | } |
| 175 | } |
| 176 | |