cli: carlos skills — list and fetch the published agent skills
carlosframework.com publishes the platform's skills at /.well-known/agent-skills/index.json per the Agent Skills Discovery draft (cloudflare/agent-skills-discovery-rfc). This is the bootstrap pointer: any agent holding the CLI can list what is published and pull one recipe — carlos skills getting-started > SKILL.md — with the sha256 digest verified against the index before a byte is printed; a mismatch is corruption or tampering and the command refuses. --index points a self-hosted deployment (or a test) at its own index. Zero infra, zero login: two HTTPS GETs, per the no-infra command rule.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+404
−0
docs/site/cli.md+18 −0main.go+4 −0skills.go+175 −0skills_test.go+207 −0
CI passed — run details
diff --git a/docs/site/cli.md b/docs/site/cli.md| index 2e8ee9ec5..ccac0200e 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -496,6 +496,24 @@ It refuses to run as root, and refuses on a box: box binaries are rolled by |
| the platform, and a self-updating box would fight that machinery. `-y` skips |
| the confirmation, though it still wants a terminal. |
| +### carlos skills |
| + |
| +The platform publishes its agent skills at |
| +`/.well-known/agent-skills/index.json`, per the [Agent Skills Discovery |
| +draft](https://github.com/cloudflare/agent-skills-discovery-rfc). `carlos |
| +skills` lists what is published; `carlos skills <name>` fetches one, checks |
| +its sha256 digest against what the index claims, and prints the SKILL.md to |
| +stdout — a mismatch means corruption or tampering, and the command refuses |
| +to print it. |
| + |
| +```sh |
| +carlos skills |
| +carlos skills getting-started > SKILL.md |
| +``` |
| + |
| +`-index <url>` points it at another deployment's index, for a self-hosted |
| +site or a test. |
| + |
| ## Your deployment |
| The verbs for the person who owns the boxes. Some of these run against a |
diff --git a/main.go b/main.go| index 0d8b67ae3..5a4cc6d0f 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -83,6 +83,8 @@ func main() { |
| runChannels(args) |
| case "pipeline": |
| runPipeline(args) |
| + case "skills": |
| + runSkills(args) |
| case "releases": |
| // One sub-verb, `retention` — the `carlos version target` judgment |
| // call: flags-only verbs stay flat until a word genuinely names a |
| @@ -193,6 +195,8 @@ Your apps — ship and run software on a CARLOS deployment |
| update update this carlos binary to the latest release (or name |
| the brew/apt command when a package manager owns it); |
| -y skips the prompt, terminal still required |
| + skills the platform's published agent skills: list them, or print one — |
| + "carlos skills getting-started" pipes the recipe to an agent |
| Your deployment — stand up and administer boxes, routes and accounts |
| bootstrap prepare a host: units, dirs, user |
diff --git a/skills.go b/skills.go| new file mode 100644 |
| index 000000000..6c7a6a267 |
| --- /dev/null |
| +++ b/skills.go |
| @@ -0,0 +1,175 @@ |
| +// Skills: carlosframework.com publishes the platform's agent skills per the |
| +// Agent Skills Discovery draft (github.com/cloudflare/agent-skills-discovery-rfc) |
| +// at a well-known index. This command is the bootstrap pointer — any agent |
| +// holding the CLI can list what is published and fetch one, verified, |
| +// without knowing the site's layout in advance. |
| +package main |
| + |
| +import ( |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "flag" |
| + "fmt" |
| + "io" |
| + "log" |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + "time" |
| +) |
| + |
| +// skillsIndexURL is a var, not a const, so tests can point it at an |
| +// httptest server and -index can point a self-hosted deployment elsewhere. |
| +// Production never changes it. |
| +var skillsIndexURL = "https://carlosframework.com/.well-known/agent-skills/index.json" |
| + |
| +// skillEntry is one row of the published index. The draft allows unknown |
| +// fields ("//" among them) — only what this command uses is declared here. |
| +type skillEntry struct { |
| + Name string `json:"name"` |
| + Type string `json:"type"` |
| + Description string `json:"description"` |
| + URL string `json:"url"` |
| + Digest string `json:"digest"` |
| +} |
| + |
| +type skillsIndex struct { |
| + Skills []skillEntry `json:"skills"` |
| +} |
| + |
| +// skillsHTTPTimeout bounds both the index fetch and the skill fetch — a |
| +// slow or hung site must fail loudly, not hang an agent's session. |
| +const skillsHTTPTimeout = 10 * time.Second |
| + |
| +// fetchSkillsIndex GETs and parses the index. Errors are returned rather |
| +// than fatal'd so runSkills is the only place in this file that exits. |
| +func fetchSkillsIndex(indexURL string) (*skillsIndex, error) { |
| + client := &http.Client{Timeout: skillsHTTPTimeout} |
| + res, err := client.Get(indexURL) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer res.Body.Close() |
| + if res.StatusCode != http.StatusOK { |
| + return nil, fmt.Errorf("%s: status %d", indexURL, res.StatusCode) |
| + } |
| + var idx skillsIndex |
| + if err := json.NewDecoder(res.Body).Decode(&idx); err != nil { |
| + return nil, fmt.Errorf("%s: %v", indexURL, err) |
| + } |
| + return &idx, nil |
| +} |
| + |
| +// resolveSkillURL turns a site-relative entry.url into the absolute URL to |
| +// fetch, resolved against indexURL per the draft (net/url ResolveReference, |
| +// not string concatenation — a leading "/" must land at the site root |
| +// even when the index itself lives under a path prefix). |
| +func resolveSkillURL(indexURL, ref string) (string, error) { |
| + base, err := url.Parse(indexURL) |
| + if err != nil { |
| + return "", err |
| + } |
| + rel, err := url.Parse(ref) |
| + if err != nil { |
| + return "", err |
| + } |
| + return base.ResolveReference(rel).String(), nil |
| +} |
| + |
| +// listSkills renders the index: name, then indented description, then |
| +// indented absolute url, blank line between entries. |
| +func listSkills(w io.Writer, indexURL string, idx *skillsIndex) error { |
| + for i, s := range idx.Skills { |
| + if i > 0 { |
| + fmt.Fprintln(w) |
| + } |
| + abs, err := resolveSkillURL(indexURL, s.URL) |
| + if err != nil { |
| + return err |
| + } |
| + fmt.Fprintln(w, s.Name) |
| + fmt.Fprintf(w, " %s\n", s.Description) |
| + fmt.Fprintf(w, " %s\n", abs) |
| + } |
| + return nil |
| +} |
| + |
| +// fetchSkill finds name in the index at indexURL, fetches its url, and |
| +// verifies the sha256 digest before returning the bytes. A mismatch means |
| +// corruption or tampering (the draft's own words) — the skill must not be |
| +// used, so the mismatched bytes are never returned to the caller. |
| +func fetchSkill(indexURL, name string) ([]byte, error) { |
| + idx, err := fetchSkillsIndex(indexURL) |
| + if err != nil { |
| + return nil, err |
| + } |
| + var entry *skillEntry |
| + names := make([]string, 0, len(idx.Skills)) |
| + for i := range idx.Skills { |
| + names = append(names, idx.Skills[i].Name) |
| + if idx.Skills[i].Name == name { |
| + entry = &idx.Skills[i] |
| + } |
| + } |
| + if entry == nil { |
| + return nil, fmt.Errorf("no skill named %q — available: %s", name, strings.Join(names, ", ")) |
| + } |
| + abs, err := resolveSkillURL(indexURL, entry.URL) |
| + if err != nil { |
| + return nil, err |
| + } |
| + client := &http.Client{Timeout: skillsHTTPTimeout} |
| + res, err := client.Get(abs) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer res.Body.Close() |
| + if res.StatusCode != http.StatusOK { |
| + return nil, fmt.Errorf("%s: status %d", abs, res.StatusCode) |
| + } |
| + raw, err := io.ReadAll(res.Body) |
| + if err != nil { |
| + return nil, err |
| + } |
| + sum := sha256.Sum256(raw) |
| + got := "sha256:" + hex.EncodeToString(sum[:]) |
| + if got != entry.Digest { |
| + return nil, fmt.Errorf("%s: digest mismatch (index says %s, fetched bytes are %s) — corrupted or tampered, refusing to use it", |
| + name, entry.Digest, got) |
| + } |
| + return raw, nil |
| +} |
| + |
| +const skillsUsage = `usage: carlos skills [<name>] [--index <url>] |
| + |
| +Lists the agent skills the platform publishes at |
| +/.well-known/agent-skills/index.json (the Agent Skills Discovery draft), |
| +or prints one SKILL.md to stdout after verifying its sha256 digest |
| +against the index — "carlos skills getting-started > SKILL.md" hands an |
| +agent the recipe.` |
| + |
| +func runSkills(args []string) { |
| + fs := flag.NewFlagSet("skills", flag.ExitOnError) |
| + index := fs.String("index", skillsIndexURL, "agent-skills index URL") |
| + fs.Usage = flagUsage(fs, skillsUsage) |
| + fs.Parse(args) |
| + |
| + if fs.NArg() == 0 { |
| + idx, err := fetchSkillsIndex(*index) |
| + if err != nil { |
| + log.Fatalf("skills: %v", err) |
| + } |
| + if err := listSkills(stdout, *index, idx); err != nil { |
| + log.Fatalf("skills: %v", err) |
| + } |
| + return |
| + } |
| + raw, err := fetchSkill(*index, fs.Arg(0)) |
| + if err != nil { |
| + log.Fatalf("skills: %v", err) |
| + } |
| + if _, err := stdout.Write(raw); err != nil { |
| + log.Fatalf("skills: %v", err) |
| + } |
| +} |
diff --git a/skills_test.go b/skills_test.go| new file mode 100644 |
| index 000000000..f1ba8546d |
| --- /dev/null |
| +++ b/skills_test.go |
| @@ -0,0 +1,207 @@ |
| +package main |
| + |
| +import ( |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "fmt" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// skillsFixture is one served (path, body) pair plus the digest it produces, |
| +// so tests can build an index without hardcoding sha256 hex by hand. |
| +type skillsFixture struct { |
| + path string |
| + body string |
| +} |
| + |
| +func (f skillsFixture) digest() string { |
| + sum := sha256.Sum256([]byte(f.body)) |
| + return "sha256:" + hex.EncodeToString(sum[:]) |
| +} |
| + |
| +// skillsServer serves index.json at indexPath (built from the fixtures, with |
| +// url fields site-relative as the real index publishes them) plus each |
| +// fixture's own bytes at its own path. |
| +func skillsServer(t *testing.T, indexPath string, fixtures ...skillsFixture) *httptest.Server { |
| + t.Helper() |
| + mux := http.NewServeMux() |
| + type entry struct { |
| + Name string `json:"name"` |
| + Type string `json:"type"` |
| + Description string `json:"description"` |
| + URL string `json:"url"` |
| + Digest string `json:"digest"` |
| + } |
| + var idx struct { |
| + Schema string `json:"$schema"` |
| + Notice string `json:"//"` |
| + Skills []entry `json:"skills"` |
| + } |
| + idx.Schema = "https://example.com/schema.json" |
| + idx.Notice = "generated — do not edit" |
| + for i, f := range fixtures { |
| + name := fmt.Sprintf("skill-%d", i) |
| + idx.Skills = append(idx.Skills, entry{ |
| + Name: name, |
| + Type: "skill-md", |
| + Description: "does the " + name + " thing", |
| + URL: f.path, |
| + Digest: f.digest(), |
| + }) |
| + body := f.body |
| + mux.HandleFunc(f.path, func(w http.ResponseWriter, r *http.Request) { |
| + w.Write([]byte(body)) |
| + }) |
| + } |
| + raw, err := json.Marshal(idx) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + mux.HandleFunc(indexPath, func(w http.ResponseWriter, r *http.Request) { |
| + w.Write(raw) |
| + }) |
| + ts := httptest.NewServer(mux) |
| + t.Cleanup(ts.Close) |
| + return ts |
| +} |
| + |
| +func TestSkillsListPrintsNamesAndAbsoluteURLs(t *testing.T) { |
| + f1 := skillsFixture{path: "/.well-known/agent-skills/one/SKILL.md", body: "# one\nrecipe one\n"} |
| + f2 := skillsFixture{path: "/.well-known/agent-skills/two/SKILL.md", body: "# two\nrecipe two\n"} |
| + ts := skillsServer(t, "/.well-known/agent-skills/index.json", f1, f2) |
| + indexURL := ts.URL + "/.well-known/agent-skills/index.json" |
| + |
| + out := captureStdout(t) |
| + runSkills([]string{"-index", indexURL}) |
| + |
| + got := out.String() |
| + for i, f := range []skillsFixture{f1, f2} { |
| + name := fmt.Sprintf("skill-%d", i) |
| + if !strings.Contains(got, name) { |
| + t.Errorf("output missing name %q:\n%s", name, got) |
| + } |
| + if !strings.Contains(got, "does the "+name+" thing") { |
| + t.Errorf("output missing description for %q:\n%s", name, got) |
| + } |
| + if !strings.Contains(got, ts.URL+f.path) { |
| + t.Errorf("output missing absolute url %q:\n%s", ts.URL+f.path, got) |
| + } |
| + } |
| +} |
| + |
| +func TestSkillsFetchWritesExactBytesOnDigestMatch(t *testing.T) { |
| + body := "---\nname: getting-started\n---\n\nDo the thing.\n" |
| + f := skillsFixture{path: "/.well-known/agent-skills/getting-started/SKILL.md", body: body} |
| + ts := skillsServer(t, "/.well-known/agent-skills/index.json", f) |
| + indexURL := ts.URL + "/.well-known/agent-skills/index.json" |
| + |
| + out := captureStdout(t) |
| + runSkills([]string{"-index", indexURL, "skill-0"}) |
| + |
| + if out.String() != body { |
| + t.Fatalf("stdout = %q, want the served bytes verbatim %q", out.String(), body) |
| + } |
| +} |
| + |
| +func TestFetchSkillDigestMismatchNamesBothDigests(t *testing.T) { |
| + served := skillsFixture{path: "/skills/x/SKILL.md", body: "actual bytes on the wire"} |
| + // The index claims a digest that does not match what the url actually |
| + // serves — simulating tampering or a stale index. |
| + claimed := "sha256:" + strings.Repeat("00", 32) |
| + |
| + type entry struct { |
| + Name string `json:"name"` |
| + Type string `json:"type"` |
| + Description string `json:"description"` |
| + URL string `json:"url"` |
| + Digest string `json:"digest"` |
| + } |
| + var idx struct { |
| + Skills []entry `json:"skills"` |
| + } |
| + idx.Skills = []entry{{Name: "x", Type: "skill-md", Description: "d", URL: served.path, Digest: claimed}} |
| + raw, err := json.Marshal(idx) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("/index.json", func(w http.ResponseWriter, r *http.Request) { w.Write(raw) }) |
| + mux.HandleFunc(served.path, func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(served.body)) }) |
| + ts := httptest.NewServer(mux) |
| + t.Cleanup(ts.Close) |
| + |
| + _, err = fetchSkill(ts.URL+"/index.json", "x") |
| + if err == nil { |
| + t.Fatal("want an error on digest mismatch, got nil") |
| + } |
| + if !strings.Contains(err.Error(), claimed) || !strings.Contains(err.Error(), served.digest()) { |
| + t.Fatalf("error %q does not name both the claimed and computed digests", err.Error()) |
| + } |
| +} |
| + |
| +func TestFetchSkillUnknownNameListsAvailable(t *testing.T) { |
| + f1 := skillsFixture{path: "/a/SKILL.md", body: "a"} |
| + f2 := skillsFixture{path: "/b/SKILL.md", body: "b"} |
| + ts := skillsServer(t, "/index.json", f1, f2) |
| + indexURL := ts.URL + "/index.json" |
| + |
| + _, err := fetchSkill(indexURL, "does-not-exist") |
| + if err == nil { |
| + t.Fatal("want an error for an unknown skill name, got nil") |
| + } |
| + if !strings.Contains(err.Error(), "skill-0") || !strings.Contains(err.Error(), "skill-1") { |
| + t.Fatalf("error %q does not list the available names", err.Error()) |
| + } |
| +} |
| + |
| +func TestSkillsResolvesSiteRelativeURLAgainstIndexURL(t *testing.T) { |
| + body := "the recipe" |
| + mux := http.NewServeMux() |
| + type entry struct { |
| + Name string `json:"name"` |
| + Type string `json:"type"` |
| + Description string `json:"description"` |
| + URL string `json:"url"` |
| + Digest string `json:"digest"` |
| + } |
| + var idx struct { |
| + Skills []entry `json:"skills"` |
| + } |
| + sum := sha256.Sum256([]byte(body)) |
| + digest := "sha256:" + hex.EncodeToString(sum[:]) |
| + // Site-relative (leading slash): must resolve against the server root, |
| + // NOT against /prefix/, even though the index itself lives under /prefix/. |
| + idx.Skills = []entry{{Name: "x", Type: "skill-md", Description: "d", URL: "/skills/x/SKILL.md", Digest: digest}} |
| + raw, err := json.Marshal(idx) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + mux.HandleFunc("/prefix/index.json", func(w http.ResponseWriter, r *http.Request) { w.Write(raw) }) |
| + requestedRootPath := false |
| + mux.HandleFunc("/skills/x/SKILL.md", func(w http.ResponseWriter, r *http.Request) { |
| + requestedRootPath = true |
| + w.Write([]byte(body)) |
| + }) |
| + // A wrong resolution would ask for this instead — must never be hit. |
| + mux.HandleFunc("/prefix/skills/x/SKILL.md", func(w http.ResponseWriter, r *http.Request) { |
| + t.Error("resolved the url against the index's directory instead of the site root") |
| + http.NotFound(w, r) |
| + }) |
| + ts := httptest.NewServer(mux) |
| + t.Cleanup(ts.Close) |
| + |
| + got, err := fetchSkill(ts.URL+"/prefix/index.json", "x") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if string(got) != body { |
| + t.Fatalf("fetched %q, want %q", got, body) |
| + } |
| + if !requestedRootPath { |
| + t.Fatal("never hit the root-relative path") |
| + } |
| +} |