carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
6package main
7
8import (
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.
25var 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.
29type 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
37type 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.
43const 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.
47func 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).
68func 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.
82func 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.
102func 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
144const skillsUsage = `usage: carlos skills [<name>] [--index <url>]
145
146Lists the agent skills the platform publishes at
147/.well-known/agent-skills/index.json (the Agent Skills Discovery draft),
148or prints one SKILL.md to stdout after verifying its sha256 digest
149against the index — "carlos skills getting-started > SKILL.md" hands an
150agent the recipe.`
151
152func 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