Files
magpie/internal/gui/library.go
T
yetone 92469ee9f9 library, gui: a check for updates lists the skills a repository added beside the library's skills from it, to Add or Ignore (Raven on Discord)
Raven: "check for updates" only updated skills already in the library; when
dontbesilent2025/dbskill or mattpocock/skills adds a skill, magpie never said.
CheckSkills now asks GitHub's git trees API once per repo@ref (ETag-cached, a
304 is free) and only when a skill folder under the installed skills' common
folder (newBase) is neither in the library nor already offered does findNew
fetch the tarball for names and descriptions. library.json's seenSkills keeps,
per repo@ref, the paths the install picker showed and the ones set aside, so
an offered skill is offered once. The Skills page shows them under "Also in
their repositories" with Add (to the agents that have the repo's other skills)
and Ignore, plus Add all / Ignore all (/api/library/skills/{add-new,ignore-new}).

Verified: TestCheckSkillsFindsNewOnes (fake repo: nothing offered after
install, no tarball on a second check; two added skills offered, a nested one
and a .wip one not; Add installs with the repo's agents; Ignore keeps it out
on the next check via 304; remove clears) fails without the change (no
NewSkills / findNew) and passes -race -count=10; TestNewBase. A real run in a
sandbox HOME against GitHub: tdd from mattpocock/skills/skills/engineering
offered its 19 siblings; dbs from dbskill offered nothing (the picker showed
all), 33 with seenSkills wiped, and Add installed dbs-action. GUI:
skill-new-in-repo.test.cjs (Chromium+WebKit, 860/440px, en+zh) fails on
main's library.js and passes; gui-ja, gui-de, click-scroll, library-items,
every-skill, pick-skills, import/remove-all-skills, skill-groups,
skill-repo-groups, github-token, library-all pass. -race on library, gui,
tui; gofmt, vet, darwin/linux/windows builds.
2026-10-07 00:26:54 +08:00

445 lines
14 KiB
Go

package gui
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"slices"
"sync"
"time"
"github.com/yetone/magpie/internal/library"
"github.com/yetone/magpie/internal/mcpauth"
)
// The problems of the last change to the library stay on the page until the
// next one, so a reload still says what an agent couldn't be given.
var lastProblems struct {
sync.Mutex
p []library.Problem
}
type libraryJSON struct {
*library.View
Result *library.Result `json:"result,omitempty"`
Home string `json:"home"` // for the page to show paths under it as ~
// Problems are every one still standing, the page's list of what an
// agent couldn't be given: not only those a chip can carry
Problems []library.Problem `json:"problems,omitempty"`
}
func libraryView(res *library.Result) (libraryJSON, error) {
lastProblems.Lock()
if res != nil {
lastProblems.p = res.Problems
}
p := lastProblems.p
lastProblems.Unlock()
v, err := library.Read(p)
home, _ := os.UserHomeDir()
return libraryJSON{View: v, Result: res, Home: home, Problems: p}, err
}
// marketJSON is a market's list, and why it may be short: a search that
// couldn't reach the registry still shows what magpie has of its own.
type marketJSON struct {
Items any `json:"items"`
Error string `json:"error,omitempty"`
// Custom is a server to add by hand, for a search that is an address
// nothing listed is at
Custom *library.Custom `json:"custom,omitempty"`
}
func errText(err error) string {
if err == nil {
return ""
}
return err.Error()
}
// revealable is every path the Library page shows: the only ones it may
// ask to be shown in the file manager.
func revealable(v *library.View) []string {
out := []string{v.Dir, v.Backups}
for _, a := range v.Agents {
out = append(out, a.Instructions, a.MCP, a.Skills)
}
for _, s := range v.Skills {
out = append(out, library.SkillPath(s.Name))
if s.Kind == "folder" {
out = append(out, s.Source)
}
}
for _, s := range v.FoundSkills {
out = append(out, s.Link, s.Shared)
}
for _, p := range v.Projects {
out = append(out, p.Dir)
for _, e := range p.Placed {
out = append(out, filepath.Join(p.Dir, filepath.FromSlash(e)))
}
for f := range p.Wrote {
out = append(out, filepath.Join(p.Dir, filepath.FromSlash(f)))
}
}
return out
}
func libraryRoutes(mux *http.ServeMux, w Windows) {
mux.HandleFunc("GET /api/library", func(rw http.ResponseWriter, r *http.Request) {
v, err := libraryView(nil)
if err != nil {
fail(rw, err)
return
}
writeJSON(rw, v)
})
mux.HandleFunc("GET /api/library/skill", func(rw http.ResponseWriter, r *http.Request) {
text, err := library.SkillText(r.URL.Query().Get("name"))
if err != nil {
fail(rw, err)
return
}
writeJSON(rw, map[string]string{"text": text, "path": library.SkillPath(r.URL.Query().Get("name"))})
})
// the market: what it offers for a search, and each skill's description
// once asked for, which skills.sh gives one at a time
mux.HandleFunc("GET /api/library/market/servers", func(rw http.ResponseWriter, r *http.Request) {
list, err := library.MarketServers(r.URL.Query().Get("q"))
writeJSON(rw, marketJSON{Items: list, Error: errText(err), Custom: library.CustomAt(r.URL.Query().Get("q"), list)})
})
mux.HandleFunc("GET /api/library/market/skills", func(rw http.ResponseWriter, r *http.Request) {
list, err := library.MarketSkills(r.URL.Query().Get("q"))
writeJSON(rw, marketJSON{Items: list, Error: errText(err)})
})
mux.HandleFunc("POST /api/library/market/about", func(rw http.ResponseWriter, r *http.Request) {
var in struct{ IDs []string }
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
fail(rw, err)
return
}
if len(in.IDs) > 60 {
in.IDs = in.IDs[:60]
}
writeJSON(rw, library.SkillsAbout(in.IDs))
})
mux.HandleFunc("GET /api/library/icon", func(rw http.ResponseWriter, r *http.Request) {
b, ct, err := library.Icon(r.URL.Query().Get("u"))
if err != nil {
http.Error(rw, err.Error(), http.StatusNotFound)
return
}
rw.Header().Set("Content-Type", ct)
rw.Header().Set("Cache-Control", "max-age=604800")
rw.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'") // an SVG opened on its own runs nothing
rw.Write(b)
})
mux.HandleFunc("POST /api/library/skills/probe", func(rw http.ResponseWriter, r *http.Request) {
var in struct{ Source string }
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
fail(rw, err)
return
}
p, err := library.ProbeSkills(in.Source)
if err != nil {
fail(rw, err)
return
}
writeJSON(rw, p)
})
// which skills GitHub has changed: the page as it is, each skill from
// there carrying what the check found of it
mux.HandleFunc("POST /api/library/skills/check", func(rw http.ResponseWriter, r *http.Request) {
if _, err := library.CheckSkills(); err != nil {
fail(rw, err)
return
}
v, err := libraryView(nil)
if err != nil {
fail(rw, err)
return
}
writeJSON(rw, v)
})
// RTK:which agents have its hook, switching one on or off, and
// installing rtk when the page is asked to
mux.HandleFunc("GET /api/library/rtk", func(rw http.ResponseWriter, r *http.Request) {
v := library.ReadRTK()
// its latest release, and whether winget or Homebrew has it yet,
// when they answer in time: the page is drawn without them
// otherwise, and has them next time
if v.Path != "" {
checked := make(chan *library.RTKView, 1)
go func() { c := *v; c.CheckLatest(); checked <- &c }()
select {
case v = <-checked:
case <-time.After(3 * time.Second):
}
}
writeJSON(rw, v)
})
mux.HandleFunc("POST /api/library/rtk", func(rw http.ResponseWriter, r *http.Request) {
var in struct {
Agent string
On bool
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
fail(rw, err)
return
}
v, err := library.SetRTK(in.Agent, in.On)
if err != nil {
fail(rw, err)
return
}
writeJSON(rw, v)
})
mux.HandleFunc("POST /api/library/rtk/upgrade", func(rw http.ResponseWriter, r *http.Request) {
v, err := library.UpgradeRTK()
if err != nil {
fail(rw, err)
return
}
writeJSON(rw, v)
})
// rtk put on the PATH the agents get, when magpie found it off it
mux.HandleFunc("POST /api/library/rtk/path", func(rw http.ResponseWriter, r *http.Request) {
v, err := library.PathRTK()
if err != nil {
fail(rw, err)
return
}
writeJSON(rw, v)
})
mux.HandleFunc("POST /api/library/rtk/install", func(rw http.ResponseWriter, r *http.Request) {
v, err := library.InstallRTK()
if err != nil {
fail(rw, err)
return
}
writeJSON(rw, v)
})
// a project's folder, from the system's picker; "" when it's cancelled
mux.HandleFunc("POST /api/library/projects/choose", func(rw http.ResponseWriter, r *http.Request) {
dir, err := w.ChooseFolder("Choose a project")
if err != nil {
fail(rw, err)
return
}
writeJSON(rw, map[string]string{"dir": dir})
})
mux.HandleFunc("POST /api/library/reveal", func(rw http.ResponseWriter, r *http.Request) {
var in struct{ Path string }
if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Path == "" {
http.Error(rw, "no path", http.StatusBadRequest)
return
}
v, err := library.Read(nil)
if err != nil {
fail(rw, err)
return
}
if !slices.Contains(revealable(v), in.Path) {
http.Error(rw, "not a path the library shows", http.StatusForbidden)
return
}
// a file is shown in its folder; one not written yet, the folder it will be in
p := in.Path
for {
fi, err := os.Stat(p)
if err == nil && fi.IsDir() {
break
}
up := filepath.Dir(p)
if up == p {
break
}
p = up
}
if err := w.OpenFolder(p); err != nil {
fail(rw, err)
return
}
rw.WriteHeader(http.StatusNoContent)
})
// magpie signs in to a remote server once, for every agent given it (#615)
mux.HandleFunc("POST /api/library/mcp-signin", func(rw http.ResponseWriter, r *http.Request) {
var in struct{ Name string }
if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Name == "" {
http.Error(rw, "no server", http.StatusBadRequest)
return
}
st, err := library.SignInServer(r.Context(), in.Name)
if err != nil {
fail(rw, err)
return
}
w.OpenURL(st.URL)
writeJSON(rw, st)
})
mux.HandleFunc("GET /api/library/mcp-signin/{id}", func(rw http.ResponseWriter, r *http.Request) {
st, ok := mcpauth.Progress(r.PathValue("id"))
if !ok {
http.NotFound(rw, r)
return
}
writeJSON(rw, st)
})
mux.HandleFunc("POST /api/library/mcp-signin/{id}/cancel", func(rw http.ResponseWriter, r *http.Request) {
mcpauth.Cancel(r.PathValue("id"))
rw.WriteHeader(http.StatusNoContent)
})
mux.HandleFunc("POST /api/library/mcp-signout", func(rw http.ResponseWriter, r *http.Request) {
var in struct{ Name string }
if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Name == "" {
http.Error(rw, "no server", http.StatusBadRequest)
return
}
if err := library.SignOutServer(in.Name); err != nil {
fail(rw, err)
return
}
v, err := libraryView(nil)
if err != nil {
fail(rw, err)
return
}
writeJSON(rw, v)
})
// whether the servers work, by connecting to them: those named, or every
// one; a server checked this session as it is now is answered from then
mux.HandleFunc("POST /api/library/mcp/check", func(rw http.ResponseWriter, r *http.Request) {
var in struct {
Names []string
Fresh bool
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
fail(rw, err)
return
}
m, err := library.CheckServers(r.Context(), in.Names, in.Fresh)
if err != nil {
fail(rw, err)
return
}
writeJSON(rw, map[string]any{"servers": m})
})
// every change answers with the page as it is after it, and what it did
mux.HandleFunc("POST /api/library/{what}/{action}", func(rw http.ResponseWriter, r *http.Request) {
var in struct {
Name string
Old string
// Agents is named as the page sends it, so that it is this
// and not the instructions' own Agents that "agents" fills
Agents []string `json:"agents"`
Source string
Paths []string
Names []string // the skills to update, of those a check found changed; to bring in, of those found in the agents
Server library.Server
Agent string // the agent whose own skill is in the library's way
ID string // a market server's, or a market skill's in its repository
Values map[string]string // what a market server needs
Dir string // a project's folder
Copy bool // a project gets copies, not links
Keep bool // a project removed keeps what magpie put in it
On bool // every skill or server given to the agents, or taken from them
How string // link or copy: how skills are given to Agent, or to every agent with none (#896)
library.InstructionsChange
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
fail(rw, err)
return
}
var res *library.Result
var err error
switch r.PathValue("what") + "/" + r.PathValue("action") {
case "instructions/save":
c := in.InstructionsChange
c.Agents = in.Agents
res, err = library.SaveInstructions(c)
case "instructions/import":
res, err = library.ImportInstructions(in.Name)
case "servers/save":
res, err = library.SaveServer(in.Old, in.Server)
case "servers/agents":
res, err = library.ServerAgents(in.Name, in.Agents)
case "servers/agents-all":
res, err = library.EveryServerAgents(in.Agents, in.On)
case "servers/agents-some":
res, err = library.SomeServersAgents(in.Names, in.Agents, in.On)
case "servers/remove":
res, err = library.RemoveServer(in.Name)
case "servers/remove-all":
res, err = library.RemoveServers(in.Names)
case "servers/import":
res, err = library.ImportServer(in.Name)
case "skills/install":
res, err = library.InstallSkills(in.Source, in.Paths, in.Agents)
case "skills/update":
res, err = library.UpdateSkill(in.Name)
case "skills/update-all":
res, err = library.UpdateSkills()
case "skills/update-some":
res, err = library.UpdateSomeSkills(in.Names)
case "skills/agents":
res, err = library.SkillAgents(in.Name, in.Agents)
case "skills/agents-all":
res, err = library.EverySkillAgents(in.Agents, in.On)
case "skills/agents-some":
res, err = library.SomeSkillsAgents(in.Names, in.Agents, in.On)
case "skills/remove":
res, err = library.RemoveSkill(in.Name)
case "skills/remove-all":
res, err = library.RemoveSkills(in.Names)
case "skills/group":
res, err = library.GroupSkills(in.Old, in.Name, in.Names)
case "skills/ungroup":
res, err = library.UngroupSkills(in.Name, in.Names)
case "skills/add-new":
res, err = library.AddNewSkills(in.Names)
case "skills/ignore-new":
err = library.IgnoreNewSkills(in.Names)
case "skills/import":
res, err = library.ImportSkill(in.Name)
case "skills/import-all":
res, err = library.ImportSkills(in.Names)
case "skills/use-library":
res, err = library.UseLibrarySkill(in.Name, in.Agent)
case "skills/how":
res, err = library.SetSkillHow(in.Agent, in.How)
case "skills/keep-own":
res, err = library.KeepAgentSkill(in.Name, in.Agent)
case "market/server":
res, err = library.InstallServer(in.ID, in.Values, in.Agents)
case "market/skill":
res, err = library.InstallMarketSkill(in.Source, in.ID, in.Agents)
case "projects/add":
res, err = library.AddProject(in.Dir)
case "projects/remove":
res, err = library.RemoveProject(in.Dir, in.Keep)
case "projects/skill":
res, err = library.ProjectSkill(in.Dir, in.Name, in.Agents)
case "projects/server":
res, err = library.ProjectServer(in.Dir, in.Name, in.Agents)
case "projects/copy":
res, err = library.ProjectCopy(in.Dir, in.Copy)
case "all/sync":
res, err = library.Sync()
default:
http.NotFound(rw, r)
return
}
if err != nil {
fail(rw, err)
return
}
v, err := libraryView(res)
if err != nil {
fail(rw, err)
return
}
writeJSON(rw, v)
})
}