carlos / platform Public

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>
Paul Campbell pushed by paul@keymail.dev 8f97ced7cb17808f2d1a323f8faccc4cf9c72a38 parent 5ef69c6
4 files changed, +404 −0
  • docs/site/cli.md +18 −0
  • main.go +4 −0
  • skills.go +175 −0
  • skills_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")
+ }
+}