323 lines
9.5 KiB
Go
323 lines
9.5 KiB
Go
package library
|
|
|
|
import (
|
|
"fmt"
|
|
"slices"
|
|
)
|
|
|
|
// Result is what a change did to the agents.
|
|
type Result struct {
|
|
Changed []string `json:"changed"` // agents whose files were written
|
|
Problems []Problem `json:"problems"` // what couldn't be written, and why
|
|
Backup string `json:"backup,omitempty"`
|
|
// Missing are the servers and skills (mcp:<name>, skill:<name>) a
|
|
// profile named that the library no longer has
|
|
Missing []string `json:"missing,omitempty"`
|
|
// Updated and Unupdated are, for an update of every skill, the ones
|
|
// fetched again and the ones that couldn't be (What is skill:<name>)
|
|
Updated []string `json:"updated,omitempty"`
|
|
Unupdated []Problem `json:"unupdated,omitempty"`
|
|
}
|
|
|
|
// Problem is one thing that couldn't be given to an agent.
|
|
type Problem struct {
|
|
Agent string `json:"agent"`
|
|
What string `json:"what"` // instructions, mcp:<name>, skill:<name>
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
func (r *Result) changed(agent string) {
|
|
if !slices.Contains(r.Changed, agent) {
|
|
r.Changed = append(r.Changed, agent)
|
|
}
|
|
}
|
|
|
|
func (r *Result) fail(agent, what string, err error) {
|
|
r.Problems = append(r.Problems, Problem{Agent: agent, What: what, Error: err.Error()})
|
|
}
|
|
|
|
// sync writes the library into every agent on this machine.
|
|
func (l *Library) sync() *Result {
|
|
res := &Result{Changed: []string{}, Problems: []Problem{}}
|
|
b := l.kept
|
|
if b == nil {
|
|
b = newBackups()
|
|
}
|
|
all := Targets()
|
|
for _, t := range all {
|
|
l.syncInstructions(t, b, res)
|
|
l.syncMCP(t, b, res)
|
|
l.syncSkills(t, res, all)
|
|
}
|
|
res.Backup = b.dir
|
|
if b.dir != "" {
|
|
pruneBackups()
|
|
}
|
|
return res
|
|
}
|
|
|
|
// Sync writes the library into the agents again: after an agent is
|
|
// installed, or a profile brings another library in.
|
|
func Sync() (*Result, error) {
|
|
return change(func(*Library) error { return nil })
|
|
}
|
|
|
|
func (l *Library) syncMCP(t *Target, b *backups, res *Result) {
|
|
if t.MCP == nil {
|
|
return
|
|
}
|
|
id := t.Agent.ID
|
|
a := l.applied(id)
|
|
entries, err := t.MCP.entries()
|
|
if err != nil {
|
|
res.fail(id, "mcp", err)
|
|
return
|
|
}
|
|
write := func(what string, f func() error) bool {
|
|
for _, p := range t.MCP.files() {
|
|
if err := b.keep(id, p); err != nil {
|
|
res.fail(id, what, err)
|
|
return false
|
|
}
|
|
}
|
|
if err := f(); err != nil {
|
|
res.fail(id, what, err)
|
|
return false
|
|
}
|
|
res.changed(id)
|
|
return true
|
|
}
|
|
var mine []string
|
|
for _, name := range a.MCP {
|
|
if s := l.server(name); s != nil && slices.Contains(s.Agents, id) && t.MCP.supports(s) == nil {
|
|
continue
|
|
}
|
|
if _, ok := entries[name]; ok && !write("mcp:"+name, func() error { return t.MCP.del(name) }) {
|
|
mine = append(mine, name)
|
|
}
|
|
}
|
|
for _, s := range l.MCP {
|
|
if !slices.Contains(s.Agents, id) {
|
|
continue
|
|
}
|
|
if err := t.MCP.supports(s); err != nil {
|
|
res.fail(id, "mcp:"+s.Name, err)
|
|
continue
|
|
}
|
|
old := entries[s.Name]
|
|
if old != nil {
|
|
if cur, ok := t.MCP.decode(s.Name, old); ok && cur.same(s) {
|
|
mine = append(mine, s.Name)
|
|
continue
|
|
}
|
|
}
|
|
if write("mcp:"+s.Name, func() error { return t.MCP.put(s, old) }) {
|
|
mine = append(mine, s.Name)
|
|
}
|
|
}
|
|
a.MCP = mine
|
|
}
|
|
|
|
// ---- the page -------------------------------------------------------------
|
|
|
|
// AgentView is an agent as the page lists it, with where it keeps each.
|
|
type AgentView struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Icon string `json:"icon"`
|
|
Instructions string `json:"instructions,omitempty"`
|
|
MCP string `json:"mcp,omitempty"`
|
|
Skills string `json:"skills,omitempty"`
|
|
SkillsAlso []string `json:"skillsAlso,omitempty"`
|
|
Note string `json:"note,omitempty"`
|
|
NoSSE bool `json:"noSSE,omitempty"`
|
|
NoRemote bool `json:"noRemote,omitempty"`
|
|
MCPVia string `json:"mcpVia,omitempty"`
|
|
}
|
|
|
|
// ServerView is a library server, and what each agent it's on made of it.
|
|
type ServerView struct {
|
|
*Server
|
|
Icon string `json:"icon,omitempty"`
|
|
// Problems are the agents it couldn't be given to, and why
|
|
Problems map[string]string `json:"problems,omitempty"`
|
|
}
|
|
|
|
// SkillView is a library skill as the page shows it.
|
|
type SkillView struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Source string `json:"source,omitempty"`
|
|
Kind string `json:"kind"` // github, folder, or "" for one kept in the library
|
|
Origin string `json:"origin,omitempty"` // on GitHub, as CC Switch installed it: it can be updated from there
|
|
Icon string `json:"icon,omitempty"`
|
|
Agents []string `json:"agents"`
|
|
Missing bool `json:"missing,omitempty"` // its folder is gone
|
|
Problems map[string]string `json:"problems,omitempty"`
|
|
}
|
|
|
|
// View is the Library page.
|
|
type View struct {
|
|
Agents []AgentView `json:"agents"`
|
|
Servers []ServerView `json:"servers"`
|
|
FoundServers []Found `json:"foundServers"`
|
|
Skills []SkillView `json:"skills"`
|
|
FoundSkills []FoundSkill `json:"foundSkills"`
|
|
Instructions *InstructionsView `json:"instructions"`
|
|
Dir string `json:"dir"`
|
|
Backups string `json:"backups"`
|
|
}
|
|
|
|
// Read is the whole page: the library, and what's found in the agents.
|
|
// problems are those of the last change, for the page to keep showing.
|
|
func Read(problems []Problem) (*View, error) {
|
|
iv, err := ReadInstructions()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
l, err := load()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
v := &View{Agents: []AgentView{}, Servers: []ServerView{}, Skills: []SkillView{}, Instructions: iv, Dir: Dir(), Backups: BackupDir()}
|
|
targets := Targets()
|
|
for _, t := range targets {
|
|
av := AgentView{ID: t.Agent.ID, Name: t.Agent.Name, Icon: t.Agent.Icon, Instructions: t.Instructions, Skills: t.Skills,
|
|
SkillsAlso: t.SkillsAlso, Note: t.Note, MCPVia: t.MCPVia}
|
|
if t.MCP != nil {
|
|
av.MCP = t.MCP.Path
|
|
av.NoSSE = t.MCP.supports(&Server{Transport: "sse"}) != nil
|
|
av.NoRemote = t.MCP.supports(&Server{Transport: "http"}) != nil
|
|
}
|
|
v.Agents = append(v.Agents, av)
|
|
}
|
|
of := func(what string) map[string]string {
|
|
m := map[string]string{}
|
|
for _, p := range problems {
|
|
if p.What == what {
|
|
m[p.Agent] = p.Error
|
|
}
|
|
}
|
|
if len(m) == 0 {
|
|
return nil
|
|
}
|
|
return m
|
|
}
|
|
for _, s := range l.MCP {
|
|
// one on no agent yet has none, not null, for the page to look in
|
|
if s.Agents == nil {
|
|
c := *s
|
|
c.Agents = []string{}
|
|
s = &c
|
|
}
|
|
sv := ServerView{Server: s, Icon: serverIcon(l, s), Problems: of("mcp:" + s.Name)}
|
|
for _, t := range targets {
|
|
if t.MCP != nil && slices.Contains(s.Agents, t.Agent.ID) {
|
|
if err := t.MCP.supports(s); err != nil {
|
|
if sv.Problems == nil {
|
|
sv.Problems = map[string]string{}
|
|
}
|
|
sv.Problems[t.Agent.ID] = err.Error()
|
|
}
|
|
}
|
|
}
|
|
v.Servers = append(v.Servers, sv)
|
|
}
|
|
for _, s := range l.Skills {
|
|
sv := SkillView{Name: s.Name, Agents: append([]string{}, s.Agents...), Icon: skillIcon(s), Problems: of("skill:" + s.Name)}
|
|
if s.Source != nil {
|
|
sv.Source, sv.Kind = s.Source.String(), s.Source.Kind
|
|
}
|
|
if o, ok := ccSwitchOrigin(s); ok {
|
|
o.Path = ""
|
|
sv.Origin = o.String()
|
|
}
|
|
if m, ok := readMeta(skillDir(s.Name)); ok {
|
|
sv.Description = m.Description
|
|
} else {
|
|
sv.Missing = true
|
|
}
|
|
v.Skills = append(v.Skills, sv)
|
|
}
|
|
v.FoundServers = foundServers(l)
|
|
for i := range v.FoundServers {
|
|
v.FoundServers[i].Icon = serverIcon(l, v.FoundServers[i].Server)
|
|
}
|
|
v.FoundSkills = foundSkills(l)
|
|
return v, nil
|
|
}
|
|
|
|
// ---- servers --------------------------------------------------------------
|
|
|
|
// SaveServer adds a server, or replaces the one called old (renaming it).
|
|
func SaveServer(old string, s Server) (*Result, error) {
|
|
if err := s.check(); err != nil {
|
|
return nil, err
|
|
}
|
|
return change(func(l *Library) error {
|
|
if s.Name != old && l.server(s.Name) != nil {
|
|
return fmt.Errorf("the library already has a server called %s", s.Name)
|
|
}
|
|
if old != "" {
|
|
i := slices.IndexFunc(l.MCP, func(x *Server) bool { return x.Name == old })
|
|
if i < 0 {
|
|
return fmt.Errorf("no server called %s", old)
|
|
}
|
|
l.MCP = slices.Delete(l.MCP, i, i+1)
|
|
}
|
|
s.Agents = slices.Sorted(slices.Values(s.Agents))
|
|
l.MCP = append(l.MCP, &s)
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// ServerAgents sets which agents get a server.
|
|
func ServerAgents(name string, agents []string) (*Result, error) {
|
|
return change(func(l *Library) error {
|
|
s := l.server(name)
|
|
if s == nil {
|
|
return fmt.Errorf("no server called %s", name)
|
|
}
|
|
s.Agents = slices.Sorted(slices.Values(agents))
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// RemoveServer takes a server out of the library and out of every agent
|
|
// magpie gave it to.
|
|
func RemoveServer(name string) (*Result, error) {
|
|
return change(func(l *Library) error {
|
|
i := slices.IndexFunc(l.MCP, func(x *Server) bool { return x.Name == name })
|
|
if i < 0 {
|
|
return fmt.Errorf("no server called %s", name)
|
|
}
|
|
l.MCP = slices.Delete(l.MCP, i, i+1)
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// ImportServer takes a server the agents have into the library: the agents
|
|
// that have it as it is get the library's from then on, the same entry.
|
|
func ImportServer(name string) (*Result, error) {
|
|
return change(func(l *Library) error {
|
|
for _, f := range foundServers(l) {
|
|
if f.Server.Name != name {
|
|
continue
|
|
}
|
|
s := f.Server
|
|
s.Agents = slices.Sorted(slices.Values(s.Agents))
|
|
l.MCP = append(l.MCP, s)
|
|
for _, id := range s.Agents {
|
|
a := l.applied(id)
|
|
if !slices.Contains(a.MCP, name) {
|
|
a.MCP = append(a.MCP, name)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
return fmt.Errorf("no agent has a server called %s that the library hasn't", name)
|
|
})
|
|
}
|