From 309803153da0787d8ff4ff63ffe99d25aac1867f Mon Sep 17 00:00:00 2001 From: Spencer Delcore Date: Tue, 11 Aug 2026 21:51:21 -0400 Subject: [PATCH] skills: one canonical skill dir, served over HTTP, fetched by the CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared-sites skill had drifted into separate copies: one embedded at internal/web/init/SKILL.md and shipped by the CLI, and hand-maintained copies elsewhere. The embedded copy was also stale against the server — it documented neither streaming chat, nor ai.image, nor the per-site AI rate limit, all of which the served shared.js and the AI handlers already support. Make the repo the single source and the server the distribution point: - Move the skill to skills/shared-sites/SKILL.md, beside install-shared-cli, and embed both through a new top-level skills package (go:embed cannot reach above its own package directory, so the embed lives there). - Fold the missing API surface into the skill: streaming chat, ai.image, the AI env vars and rate limit, and the silent-failure notes for db.subscribe, ws.onMessage, and the positional args of ai.chat. - Serve them: GET /skill.md on the base host, plus GET /api/skills and GET /api/skills/{name}. /skill.md is base-host only, so a deployed site file of the same name still wins, unlike /shared.js which is global. - shared skill install now fetches from the server and falls back to the built-in copy when it is unreachable, so an agent reads the skill for the server it deploys to rather than the one the CLI was built from. --- README.md | 28 +++++- cmd/shared/main.go | 52 ++++++++++- internal/server/server.go | 6 ++ internal/server/skills.go | 39 ++++++++ internal/web/init/SKILL.md | 123 ------------------------- internal/web/web.go | 3 - skills/shared-sites/SKILL.md | 172 +++++++++++++++++++++++++++++++++++ skills/skills.go | 52 +++++++++++ 8 files changed, 340 insertions(+), 135 deletions(-) create mode 100644 internal/server/skills.go delete mode 100644 internal/web/init/SKILL.md create mode 100644 skills/shared-sites/SKILL.md create mode 100644 skills/skills.go diff --git a/README.md b/README.md index 75b9fa9..e90d23e 100644 --- a/README.md +++ b/README.md @@ -143,13 +143,14 @@ shared list **Or let your agent do it.** The repo ships an [install-shared-cli](skills/install-shared-cli/SKILL.md) agent skill that -picks the right method for the current OS. Copy it into your agent's skill -directory and ask it to install the CLI: +picks the right method for the current OS. A running server serves it, so no +clone is needed: ```sh # Claude Code -git clone --depth 1 https://github.com/sdelcore/shared /tmp/shared-skill -cp -r /tmp/shared-skill/skills/install-shared-cli ~/.claude/skills/ +mkdir -p ~/.claude/skills/install-shared-cli +curl -sfo ~/.claude/skills/install-shared-cli/SKILL.md \ + "$SHARED_SERVER/api/skills/install-shared-cli" ``` ## CLI @@ -178,6 +179,25 @@ it is available to agents in every project, not just a scaffolded one; it skips an existing file unless `--force` is given. `shared backup` defaults to `shared-backup-.tar.gz` in the current directory. +## Agent skills + +The skills live in [`skills/`](skills/) as ordinary markdown, are embedded in +both binaries, and are served by a running server: + +| Endpoint | Returns | +|---|---| +| `GET /skill.md` | the `shared-sites` skill (base host only) | +| `GET /api/skills` | the available skills and their URLs | +| `GET /api/skills/` | one skill as `text/markdown` | + +`/skill.md` is served on the base host only. On a site host that path belongs +to the site, so a deployed file of the same name still wins. + +`shared skill install` fetches from the server first and falls back to its +built-in copy when the server is unreachable. The reason: an agent then reads +the skill for the server it is deploying to, not the one the CLI was built +from. + ## Subdomain routing Each site lives at `http://./`. Modern browsers resolve diff --git a/cmd/shared/main.go b/cmd/shared/main.go index 535b7ac..deb7adc 100644 --- a/cmd/shared/main.go +++ b/cmd/shared/main.go @@ -23,6 +23,7 @@ import ( "time" "github.com/sdelcore/shared/internal/web" + "github.com/sdelcore/shared/skills" ) const usage = `usage: shared [arguments] @@ -37,7 +38,7 @@ commands: versions NAME [--server URL] list a site's saved versions backup [file] [--server URL] download a tarball of all server data init [dir] scaffold a new site directory - skill install [--force] install the shared-sites skill into ~/.claude/skills + skill install [--force] [--server URL] install the shared-sites skill into ~/.claude/skills ` func main() { @@ -627,7 +628,7 @@ func cmdInit(args []string) { content []byte }{ {filepath.Join(dir, "index.html"), web.InitIndexHTML}, - {filepath.Join(dir, ".claude", "skills", "shared-sites", "SKILL.md"), web.InitSkillMD}, + {filepath.Join(dir, ".claude", "skills", "shared-sites", "SKILL.md"), embeddedSkill()}, } for _, file := range files { if _, err := os.Stat(file.path); err == nil { @@ -646,13 +647,46 @@ func cmdInit(args []string) { } } +func embeddedSkill() []byte { + body, err := skills.Get(skills.SharedSites) + if err != nil { + fatal("%v", err) + } + return body +} + +// fetchSkill pulls the skill from the server so the installed copy matches the +// server that is actually running, not the version this CLI was built from. +func fetchSkill(server, name string) ([]byte, error) { + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(strings.TrimRight(server, "/") + "/api/skills/" + name) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s", resp.Status) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, err + } + // An older server has no skills endpoint and a proxy may answer with an + // error page, so require the frontmatter a skill file always starts with. + if !bytes.HasPrefix(body, []byte("---\n")) { + return nil, fmt.Errorf("not a skill file") + } + return body, nil +} + func cmdSkill(args []string) { if len(args) < 1 || args[0] != "install" { - fmt.Fprintln(os.Stderr, "usage: shared skill install [--force]") + fmt.Fprintln(os.Stderr, "usage: shared skill install [--force] [--server URL]") os.Exit(2) } fs := flag.NewFlagSet("skill install", flag.ExitOnError) force := fs.Bool("force", false, "overwrite an existing skill file") + server := fs.String("server", defaultServer(), "shared server URL") fs.Parse(args[1:]) home, err := os.UserHomeDir() @@ -666,13 +700,21 @@ func cmdSkill(args []string) { } else if err != nil && !os.IsNotExist(err) { fatal("%v", err) } + + body, err := fetchSkill(*server, skills.SharedSites) + source := *server + if err != nil { + fmt.Fprintf(os.Stderr, "shared: fetching skill from %s: %v (using built-in copy)\n", *server, err) + body, source = embeddedSkill(), "built-in copy" + } + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { fatal("%v", err) } - if err := os.WriteFile(dest, web.InitSkillMD, 0o644); err != nil { + if err := os.WriteFile(dest, body, 0o644); err != nil { fatal("writing %s: %v", dest, err) } - fmt.Printf("wrote %s\n", dest) + fmt.Printf("wrote %s (from %s)\n", dest, source) } func humanSize(n int64) string { diff --git a/internal/server/server.go b/internal/server/server.go index 5b11107..ad87507 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -65,6 +65,8 @@ func New(addr, dataDir, baseHost string, keepVersions int) (*Server, error) { s.api.HandleFunc("POST /api/uploads", s.handleUpload) s.api.HandleFunc("GET /api/identity", s.handleIdentity) s.api.HandleFunc("GET /api/ws", s.handleWS) + s.api.HandleFunc("GET /api/skills", s.handleSkillsList) + s.api.HandleFunc("GET /api/skills/{name}", s.handleSkillGet) return s, nil } @@ -76,6 +78,10 @@ func (s *Server) ListenAndServe() error { s.api.ServeHTTP(w, r) case r.URL.Path == "/shared.js": s.handleSharedJS(w, r) + // Base host only: on a site host this path belongs to the site, and + // intercepting it would shadow a deployed file of the same name. + case r.URL.Path == "/skill.md" && siteFromHost(r.Host, s.BaseHost) == "": + s.handleSkillMD(w, r) case strings.HasPrefix(r.URL.Path, "/uploads/"): s.handleServeUpload(w, r) default: diff --git a/internal/server/skills.go b/internal/server/skills.go new file mode 100644 index 0000000..f92f134 --- /dev/null +++ b/internal/server/skills.go @@ -0,0 +1,39 @@ +package server + +import ( + "net/http" + + "github.com/sdelcore/shared/skills" +) + +// handleSkillMD serves the shared-sites skill so an agent on any machine can +// read it straight from the server it will deploy to, without cloning the repo +// or trusting a stale local copy. Base host only — see ListenAndServe. +func (s *Server) handleSkillMD(w http.ResponseWriter, r *http.Request) { + s.writeSkill(w, skills.SharedSites) +} + +// handleSkillsList advertises the embedded skills and where to fetch each one. +func (s *Server) handleSkillsList(w http.ResponseWriter, r *http.Request) { + names := skills.Names() + out := make([]map[string]string, 0, len(names)) + for _, name := range names { + out = append(out, map[string]string{"name": name, "url": "/api/skills/" + name}) + } + writeJSON(w, http.StatusOK, out) +} + +func (s *Server) handleSkillGet(w http.ResponseWriter, r *http.Request) { + s.writeSkill(w, r.PathValue("name")) +} + +func (s *Server) writeSkill(w http.ResponseWriter, name string) { + body, err := skills.Get(name) + if err != nil { + writeErr(w, http.StatusNotFound, "skill not found") + return + } + w.Header().Set("Content-Type", "text/markdown; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + w.Write(body) +} diff --git a/internal/web/init/SKILL.md b/internal/web/init/SKILL.md deleted file mode 100644 index b2dfc84..0000000 --- a/internal/web/init/SKILL.md +++ /dev/null @@ -1,123 +0,0 @@ ---- -name: shared-sites -description: Building and deploying static sites/apps on the self-hosted shared platform — its /shared.js client API (document DB, AI chat, uploads, websocket channels, identity) and the deploy/rollback flow. ---- - -# shared-sites - -Build a site as plain static files (an `index.html` plus whatever assets), add -``, and deploy the directory. The server hosts -each site at its own subdomain and gives every page a client API scoped to that -site automatically. - -**No auth.** Single user, trusted LAN only. Anyone who can reach the server can -read and write every site's data. Do not expose it to the open internet. - -## Client API (`/shared.js` → `window.shared`) - -All calls are promise-based and scoped to the current site by its subdomain. - -### shared.db - -Per-collection JSON document store. Docs get server-managed `id`, `createdAt`, -`updatedAt`. - -```js -const posts = shared.db.collection('posts'); -const doc = await posts.create({ title: 'hi' }); // POST → created doc -const all = await posts.list(); // array, sorted by createdAt -const one = await posts.get(doc.id); -await posts.update(doc.id, { title: 'yo' }); // PUT → updated doc -await posts.delete(doc.id); - -const sub = posts.subscribe({ - onCreate(doc) {}, - onUpdate(doc) {}, - onDelete(doc) {}, -}); -sub.close(); // stop listening -``` - -`subscribe` takes a handlers object (not a callback); each handler receives the -doc. It opens a websocket that auto-reconnects (1s backoff) on drop. - -### shared.ai - -Proxy to an OpenAI-compatible chat API; the key stays on the server. Returns the -reply text (a string). - -```js -const reply = await shared.ai.chat('Summarize: ...'); -const reply2 = await shared.ai.chat( - [{ role: 'user', content: 'hi' }], - { system: 'Be terse.', model: 'some-model', max_tokens: 256 }, -); -``` - -Needs `OPENAI_BASE_URL` and `OPENAI_API_KEY` set on the server, else it errors. - -### shared.uploads - -```js -const { url } = await shared.uploads.upload(fileInput.files[0]); -img.src = url; // served from /uploads//- -``` - -### shared.ws - -Per-site broadcast channels. A message is relayed to every *other* member of the -same channel — not echoed back to the sender. - -```js -const room = shared.ws.channel('lobby'); // default channel: 'default' -room.onMessage(msg => console.log(msg)); // JSON-parsed, or raw string -room.send({ hello: 'all' }); // objects are JSON-stringified -room.close(); -``` - -Sends issued before the socket is open are dropped (no send queue); it -auto-reconnects on close, so send after `onMessage` starts firing. - -### shared.identity - -```js -const me = await shared.identity(); // { email, name } -``` - -## Deploy flow - -```sh -shared init [dir] # scaffold index.html + this skill (skips existing) -shared deploy --name mysite -``` - -Deploy packs the directory (dotfiles and `node_modules` excluded) into a gzipped -tarball and POSTs it. The site goes live immediately at -`http://./` — e.g. `http://mysite.localhost:8787/`. -`--name` defaults to the lowercased directory base name; `--server` overrides -the target (default `http://localhost:8787`, or `$SHARED_SERVER`). - -Deploys are attributed (git email if configured, plus `user@hostname`) and -guarded against overwriting -someone else's deploy: if the site changed since your last deploy, the CLI -asks before overwriting. Non-interactive runs get "deploy cancelled" — -re-run with `--force` if overwriting is intended. - -Data is scoped strictly by the first label of the request Host, so one site -cannot reach another's db/uploads/ws. Site names must match -`^[a-z0-9][a-z0-9-]{0,62}$`. - -## Managing sites - -```sh -shared list # deployed sites with size, views, last deployer -shared open mysite # print + open the site URL -shared versions mysite # saved prior deploys, newest first -shared rollback mysite # swap in the newest version (reversible) -shared rm mysite # delete the site, its db, uploads, and versions -shared backup [file] # download a gzipped tarball of all server data -``` - -Each replacement deploy keeps the previous copy as a version (default 3 per -site, `SHARED_KEEP_VERSIONS`); rollback restores the newest and keeps the -current as a new version, so it is reversible. diff --git a/internal/web/web.go b/internal/web/web.go index e77b364..f5a1466 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -10,6 +10,3 @@ var HomeHTML []byte //go:embed init/index.html var InitIndexHTML []byte - -//go:embed init/SKILL.md -var InitSkillMD []byte diff --git a/skills/shared-sites/SKILL.md b/skills/shared-sites/SKILL.md new file mode 100644 index 0000000..fe10b3f --- /dev/null +++ b/skills/shared-sites/SKILL.md @@ -0,0 +1,172 @@ +--- +name: shared-sites +description: Building and deploying static sites/apps on the self-hosted shared platform — its /shared.js client API (document DB, AI chat and images, uploads, websocket channels, identity) and the deploy/rollback flow. +--- + +# shared-sites + +Build a site as plain static files (an `index.html` plus whatever assets), add +``, and deploy the directory. The server hosts +each site at its own subdomain and gives every page a client API scoped to that +site automatically. There is no build step and no backend to write. + +**No auth.** Single user, trusted LAN only. Anyone who can reach the server can +read and write every site's data. Do not expose it to the open internet, and do +not put secrets in site data. + +## Client API (`/shared.js` → `window.shared`) + +All calls are promise-based and scoped to the current site by its subdomain. + +The served `/shared.js` is the source of truth for signatures, and it moves +ahead of this file. Run `curl $SHARED_SERVER/shared.js` and read the function +you are about to call. The callback-shaped APIs (`db.subscribe`, `ws.channel`) +fail silently when called wrongly, so a mismatch looks like "the feature does +not work" rather than an error. + +### shared.db + +Per-collection JSON document store. Docs get server-managed `id`, `createdAt`, +`updatedAt`. + +```js +const posts = shared.db.collection('posts'); +const doc = await posts.create({ title: 'hi' }); // POST → created doc +const all = await posts.list(); // array, sorted by createdAt +const one = await posts.get(doc.id); +await posts.update(doc.id, { title: 'yo' }); // PUT → updated doc +await posts.delete(doc.id); + +const sub = posts.subscribe({ + onCreate(doc) {}, + onUpdate(doc) {}, + onDelete(doc) {}, +}); +sub.close(); // stop listening +``` + +`subscribe` takes a handlers object, **not** a callback. A bare function is +accepted and then never fires, which is the most common way to ship a dead +realtime UI here. Each handler receives the document itself, so there is no +event wrapper and no `e.type` / `e.doc`. `subscribe` returns `{ close }`, not an +unsubscribe function. The socket auto-reconnects (1s backoff) and replays what +was missed through the same handlers; `onDelete` receives `{ id }` on that +replay and the full doc live. + +### shared.ai + +Proxy to an OpenAI-compatible chat API; the key stays on the server. + +```js +// chat(messages, opts) — two positional args. A string is wrapped for you. +const reply = await shared.ai.chat('Summarize: ...'); +const reply2 = await shared.ai.chat( + [{ role: 'user', content: 'hi' }], + { system: 'Be terse.', model: 'some-model', max_tokens: 256 }, +); + +// streaming — prefer it for anything long, and required for models that only +// support streaming. Still resolves to the full text at the end. +const full = await shared.ai.chat(q, { stream: true, onToken: t => out.append(t) }); + +// image generation — the PNG is saved to this site's uploads and the URL is +// permanent. +const { url } = await shared.ai.image('a red bicycle', { size: '1024x1024' }); +``` + +Do not pass a single options object as the first argument to `chat`. +`{ messages, system }` is sent as the message list and the call fails. Message +roles must be `user` or `assistant`; put the system prompt in `opts.system`. + +Server-side configuration, all environment variables on `sharedd`: + +| Variable | Effect | +|---|---| +| `OPENAI_BASE_URL`, `OPENAI_API_KEY` | required; both AI endpoints 503 without them | +| `SHARED_AI_MODEL` | default chat model | +| `SHARED_AI_IMAGE_MODEL` | default image model; `ai.image` 503s until it is set or `model` is passed | +| `SHARED_AI_RATE` | AI requests per minute per site (default 30, burst 10, 0 disables) | + +Do not hardcode model names in site code unless the user wants a per-call +override. A model the gateway does not serve fails with a 400 at request time. +Over the rate limit the call fails with 429, which is the server refusing, not +a bug in the site. + +### shared.uploads + +```js +const { url } = await shared.uploads.upload(fileInput.files[0]); +img.src = url; // served from /uploads//- +``` + +### shared.ws + +Per-site broadcast channels. A message is relayed to every *other* member of the +same channel — not echoed back to the sender. + +```js +const room = shared.ws.channel('lobby'); // default channel: 'default' +room.onMessage(msg => console.log(msg)); // JSON-parsed, or raw string +room.send({ hello: 'all' }); // objects are JSON-stringified +room.close(); +``` + +`onMessage` is a method; `room.onmessage = fn` does nothing. Call it more than +once to register several listeners, and every listener gets each message. Sends +issued before the socket is open are dropped (there is no send queue), so send +after `onMessage` starts firing. The channel auto-reconnects on close. + +### shared.identity + +```js +const me = await shared.identity(); // { email, name } +``` + +## Deploy flow + +```sh +shared init [dir] # scaffold index.html + this skill (skips existing) +shared deploy --name mysite +``` + +Deploy packs the directory (dotfiles and `node_modules` excluded) into a gzipped +tarball and POSTs it. The site goes live immediately at +`http://./` — e.g. `http://mysite.localhost:8787/`. +`--name` defaults to the lowercased directory base name; `--server` overrides +the target (default `http://localhost:8787`, or `$SHARED_SERVER`). The base host +lists every deployed site on its homepage. + +Deploys are attributed (git email if configured, plus `user@hostname`) and +guarded against overwriting someone else's deploy: if the site changed since +your last deploy, the CLI asks before overwriting. Non-interactive runs get +"deploy cancelled" — re-run with `--force` if overwriting is intended. + +Data is scoped strictly by the first label of the request Host, so one site +cannot reach another's db/uploads/ws. Site names must match +`^[a-z0-9][a-z0-9-]{0,62}$`. + +## Managing sites + +```sh +shared list # deployed sites with size, views, last deployer +shared open mysite # print + open the site URL +shared versions mysite # saved prior deploys, newest first +shared rollback mysite # swap in the newest version (reversible) +shared rm mysite # delete the site, its db, uploads, and versions +shared backup [file] # download a gzipped tarball of all server data +``` + +Each replacement deploy keeps the previous copy as a version (default 3 per +site, `SHARED_KEEP_VERSIONS`); rollback restores the newest and keeps the +current as a new version, so it is reversible. + +## Tips + +- Keep the site static and let `shared.db`/`ai`/`uploads`/`ws` be the backend. +- Build the whole feature client-side; there is no server code to add. +- Use `subscribe` for live UIs instead of polling. +- Smoke-test a platform call against the real server before blaming the site: + `curl -X POST http://mysite./api/db/ -H 'Content-Type: + application/json' -d '{}'`, and the same for `/api/ai/chat`. +- This file is served by the running server at `/skill.md`. Fetch it from there + to match the deployed version: `curl $SHARED_SERVER/skill.md`. diff --git a/skills/skills.go b/skills/skills.go new file mode 100644 index 0000000..2c6294e --- /dev/null +++ b/skills/skills.go @@ -0,0 +1,52 @@ +// Package skills embeds the agent skills shipped with shared. The files stay +// readable in the repo (and on GitHub) while both binaries carry a copy: the +// CLI writes them out, and the server hands them to any agent over HTTP. +package skills + +import ( + "embed" + "errors" + "io/fs" + "path" + "sort" +) + +//go:embed */SKILL.md +var files embed.FS + +// ErrNotFound is returned by Get for an unknown skill name. +var ErrNotFound = errors.New("skill not found") + +// SharedSites documents the client API and deploy flow. It is the skill the +// CLI installs and the server serves at /skill.md. +const SharedSites = "shared-sites" + +// Get returns the SKILL.md body for a skill name. +func Get(name string) ([]byte, error) { + // Reject any path trickery before it reaches the embedded FS: names come + // from request paths on the server side. + if name == "" || name != path.Base(name) || name == "." || name == ".." { + return nil, ErrNotFound + } + b, err := files.ReadFile(path.Join(name, "SKILL.md")) + if err != nil { + return nil, ErrNotFound + } + return b, nil +} + +// Names lists the embedded skills in sorted order. +func Names() []string { + entries, err := fs.ReadDir(files, ".") + if err != nil { + return nil + } + names := make([]string, 0, len(entries)) + for _, e := range entries { + if e.IsDir() { + names = append(names, e.Name()) + } + } + sort.Strings(names) + return names +}