* gateway: draw through a Grok subscription (Grok Imagine) A Grok subscription (SuperGrok, X Premium+) could not draw: Drawers() returned nothing for it and draw() refused with "Grok (SuperGrok) can't draw: magpie draws only through an OpenAI-compatible API, and Grok (SuperGrok) has none". Grok Build's own image_gen / image_edit tools call the backend's Imagine API at <cli-chat-proxy.grok.com/v1>/images/generations and /images/edits with the CLI's sign-in, which magpie already uses for chat. - drawsGrok / grokDrawers: a Grok account draws with grok-imagine-image and grok-imagine-image-quality, like a ChatGPT account does with GPT Image - the Imagine API takes an aspect ratio, not a size or quality: size WIDTHxHEIGHT is matched to the nearest ratio it accepts (aspectAmong, aspectOf is unchanged); the image is asked for as base64 - an edit is JSON with the images as data URLs - a plan's images cost nothing extra, so AutoDrawer ranks Grok with ChatGPT (ChatGPT still wins a tie on release date) - provider.grokBase is exported as GrokBase, like CodexBase, for the test Tested against a real SuperGrok account through the gateway: generation, 16:9, the quality model and an edit. Video is not covered: the Grok CLI's video tools need zero data retention off or a storage bucket. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * gateway: film through a Grok subscription, and a generate_video tool for agents A Grok account that draws (c6578bc) can make videos too: Grok Imagine's video endpoints sit behind the same sign-in. The gateway speaks OpenAI's Videos API, so its SDK works as it is: POST /v1/videos, GET /v1/videos/{id}, GET /v1/videos/{id}/content. A request is taken on at once and polled; the id carries the provider and the vendor's request id, so nothing is kept in the gateway and a restart loses no video. Status is mapped (pending at 0% -> queued, pending -> in_progress, done -> completed, failed/expired -> failed, with why). seconds (1-15, the vendor's limit, which it says when exceeded), size (-> aspect ratio and 480p/720p/1080p by the shorter side), input_reference for image-to-video (URL, data URL, {image_url}, or a multipart file) and reference_images are taken; a model that isn't a Grok video model is refused by name. /content answers 409 until the video is ready. `magpie mcp image` lists a second tool, generate_video: it starts the video, waits (up to 10 minutes), saves the mp4 into the project (generated-videos/ unless path says where, never over a file) and says where, so an agent can animate an image it just drew by passing its path. Checked against a real SuperGrok account: text-to-video, image-to-video (input_reference), reference_images, both video models, the OpenAI Python SDK (videos.create/retrieve/download_content), the MCP tool end to end with two videos at once, and the error paths. Unit tests use a fake Grok upstream. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * gateway: videos follow the Settings' off switch, and an id can't change the vendor's path Review of the video change: - Image generation "off" turns off the video a request that names no model gets (videomaker, beside drawer), so generate_video says it is off too. - A video id is put in the vendor's URL with the account's token: its parts must be [A-Za-z0-9_-]+ (parseVideoID) and the vendor id is path-escaped, so video_grok.x%3Fa=1.1 and %2F answer 404 and ask the vendor nothing. - The id carries a short mark of the account that started it; after a switch the video says it was started with another account instead of the vendor's not-found. - generate_video waits up to ten minutes, and Codex gives a tool a minute: magpie-image's Codex entry gets tool_timeout_sec (Goose's timeout) of eleven minutes, the user's own value kept. - grok-imagine-video stops at 720p (the vendor turns 1080p away for it; 1.5 makes 1080p), so a 1080p size is asked at 720p there. - A model may be named without its provider (grok-imagine-video). - "seconds": 6.0 is 6; 6.5 is still refused. - completed_at is left out: the vendor doesn't say when a video finished, and a time made up at each poll changed each time. Checked against a real SuperGrok account: bare model name, seconds 2.0, 1080p on the base model asked at 720p (the mp4 is 1280x720), 1080p accepted on 1.5, both malformed ids, no completed_at. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * gateway: TestVideoModelByItsBareName asks its two models in a fixed order It checked the body of the second request the upstream got, but asked the two models by ranging over a map, so it failed about one run in seven (Ubuntu CI). Ask them from a slice. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * library: an installed magpie-image gets the longer tool timeout on the next sync magpie-image already ships, so many users have it in Codex and Goose as it was written before: no tool_timeout_sec in Codex, and the timeout: 300 every Goose server was given. syncMCP skipped an entry whose command, args and env were unchanged, so an upgrade left Codex at its one-minute default, and Goose kept 300 as if the user had chosen it. - mcpFile.behind says an entry lacks the timeout magpie gives the server now: Codex's tool_timeout_sec is missing, or Goose's timeout is still exactly the 300 magpie wrote. syncMCP rewrites such an entry; merged() doesn't keep that 300 as the user's. A value the user chose (any other number) is left alone. - Test: an entry as main wrote it is given 660 in both, one the user set stays, and a second sync changes nothing. It fails without the change ("changed []"). Also, from the review: - seconds is validated once in readFilming: a decimal number that is whole and at least 1 (hex, inf, nan, underscores, negatives and 0 are refused), and the object echoes 6 for 6.0. - imagemcp: Run's doc comment is above Run again. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
355 lines
11 KiB
Go
355 lines
11 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"`
|
|
// Unimported are, for skills brought in together, the ones that
|
|
// couldn't be (What is skill:<name>)
|
|
Unimported []Problem `json:"unimported,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"`
|
|
// Own is a skill the agent has a folder of its own for, not the
|
|
// library's: the page offers to use the library's or keep the agent's
|
|
Own bool `json:"own,omitempty"`
|
|
}
|
|
|
|
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)
|
|
}
|
|
// ~/.agents/skills first: an agent that reads it too gets no second
|
|
// link to what is there already
|
|
shared := realDir(sharedSkillsDir())
|
|
for _, first := range []bool{true, false} {
|
|
for _, t := range all {
|
|
if (t.Skills != "" && realDir(t.Skills) == shared) == first {
|
|
l.syncSkills(t, res, all)
|
|
}
|
|
}
|
|
}
|
|
l.syncProjects(res)
|
|
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)
|
|
// an agent given no server, with none of magpie's in it, isn't read:
|
|
// a file of its that can't be read is nothing the library did
|
|
if len(a.MCP) == 0 && !slices.ContainsFunc(l.MCP, func(s *Server) bool { return slices.Contains(s.Agents, id) }) {
|
|
return
|
|
}
|
|
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 || t.MCP.holds(name)) && !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) && t.MCP.has(s) && !t.MCP.behind(s, old) {
|
|
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"`
|
|
// ProjectSkills is the folder in a project it reads skills from
|
|
ProjectSkills string `json:"projectSkills,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"`
|
|
Check *SkillCheck `json:"check,omitempty"` // what the last check for updates found
|
|
}
|
|
|
|
// 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"`
|
|
Projects []ProjectView `json:"projects"`
|
|
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, ProjectSkills: ProjectSkillsDir(t.Agent.ID)}
|
|
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
|
|
}
|
|
sv.Check = lastCheck(s.Name)
|
|
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)
|
|
v.Projects = projectViews(l, problems)
|
|
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)
|
|
}
|
|
k := serverKey(l.MCP[i])
|
|
l.MCP = slices.Delete(l.MCP, i, i+1)
|
|
// its icon goes with it, unless another server runs the same thing
|
|
if !slices.ContainsFunc(l.MCP, func(x *Server) bool { return serverKey(x) == k }) {
|
|
delete(l.Icons, k)
|
|
}
|
|
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)
|
|
})
|
|
}
|