Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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-<yyyymmdd-hhmmss>.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/<name>` | 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://<name>.<base-host><port>/`. Modern browsers resolve
Expand Down
52 changes: 47 additions & 5 deletions cmd/shared/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"time"

"github.com/sdelcore/shared/internal/web"
"github.com/sdelcore/shared/skills"
)

const usage = `usage: shared <command> [arguments]
Expand All @@ -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() {
Expand Down Expand Up @@ -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 {
Expand All @@ -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()
Expand All @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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:
Expand Down
39 changes: 39 additions & 0 deletions internal/server/skills.go
Original file line number Diff line number Diff line change
@@ -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)
}
123 changes: 0 additions & 123 deletions internal/web/init/SKILL.md

This file was deleted.

3 changes: 0 additions & 3 deletions internal/web/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,3 @@ var HomeHTML []byte

//go:embed init/index.html
var InitIndexHTML []byte

//go:embed init/SKILL.md
var InitSkillMD []byte
Loading
Loading