Files
magpie/library_cli.go
T
yetone 680ca5508f library: RTK's savings are read from rtk gain's stdout alone, a count rtk couldn't give says why instead of "Nothing saved yet", and the page says whose commands the count is (#741 part 2, on Windows with magpie 0.1.807, RTK 0.51.0 and Codex: the RTK card said 还没有节省记录 while rtk gain in a terminal listed savings) — ReadRTK ran rtk gain --daily --format json with CombinedOutput and json.Unmarshal'd the lot, and took any error, from rtk or from the JSON, for nothing saved. rtk writes warnings to stderr with the JSON on stdout — on Windows the console code page it has no decoder for (warn_unmapped_codepage), a command it couldn't resolve, a history table it found damaged — and any of them in front of the "{" made the output unreadable, so the card said nothing was saved however much was. rtkGain now runs it with stdout and stderr apart (stdin closed, 30s at most), parses stdout from its first "{" to its last "}" (a notice printed ahead of it is passed over), and when rtk fails or prints no summary it can read, gainErr says so with what rtk said ("rtk gain: <last lines>", "rtk gain printed no summary magpie can read: …"), shown on the card as "magpie couldn't read what RTK saved: <why>" and by magpie library as "what rtk saved isn't known". magpie counts no agent itself: the figure is rtk gain's, global, every command any agent or terminal ran through RTK on this computer, and the card now says that after it, so an empty one isn't read as magpie counting only some agents. On Windows, Codex with its Windows sandbox on (config.toml's [windows] sandbox = "elevated"/"unelevated", or the older elevated_windows_sandbox / experimental_windows_sandbox features, and not danger-full-access) runs its commands as its CodexSandbox account, whose own %LOCALAPPDATA%\rtk\history.db keeps what RTK saved there, or with a restricted token that can't write the user's — either way not in the rtk gain the user runs; ReadRTK reports that as codexSandbox, and the card (and magpie library) says why what Codex saves isn't counted. The count is read again each time the RTK tab is opened, so commands run since show without restarting magpie. TestRTKGainRead uses a fake rtk: a code-page warning on stderr with JSON on stdout (read — the old CombinedOutput code says nothing saved), a notice line before the JSON, zero commands, an rtk that exits 1, and one that prints no JSON, with the exact gainErr; TestCodexSandbox covers each config form. library-rtk-gain.test.cjs (webkit) checks the reason and the scope line, the Codex sandbox note, and that reopening the tab re-reads the count, in English and Chinese. go vet, builds (darwin, GOOS=linux -tags nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass, as do the Library and click-scroll GUI tests. Not tried on the reporter's machine: which stderr line broke it there isn't known (the card now shows it if it's still there), and the Codex sandbox account's separate history is from Codex's and RTK's source, not seen on a real Windows Codex.
2026-10-04 12:15:40 +08:00

434 lines
14 KiB
Go

package main
import (
"fmt"
"io"
"os"
"path/filepath"
"slices"
"strings"
"golang.org/x/term"
"github.com/yetone/magpie/internal/library"
)
const libraryUsage = `magpie library what the library gives each agent: instructions, MCP servers, skills
magpie library sync write it into the agents again (after one is installed, or edited by hand)
magpie library instructions print the shared instructions
magpie library instructions set <file|-> replace them with a file's text (- for stdin)
magpie library instructions agents <a,b…|none> the agents that get them
magpie library mcp add <name> <url | command args…> [agents=a,b…]
magpie library mcp agents <name> <a,b…|none>
magpie library mcp rm <name>
magpie library skill agents <name> <a,b…|none>
magpie library skill rm <name> (skills are installed from the app's Library page)
magpie library skill rm --all [--yes] every skill out of the library and the agents (asks first; --yes doesn't)
magpie library skill update [name] fetch a skill from GitHub again; with no name, every one from there
magpie library skill use-library <name> <agent> an agent's own skill by that name is in the way: set it aside, link the library's
magpie library skill keep-own <name> <agent> …or keep the agent's, and take the agent off the library's
magpie library rtk which agents run their shell commands through RTK (rtk-ai.app), to save tokens
magpie library rtk on|off <agent> switch it (on with RTK's own installer; off works with RTK gone)
magpie library rtk install install RTK (Homebrew, winget, or RTK's own script)
magpie library rtk upgrade bring RTK up to its latest release, the way it was installed
magpie library rtk path put RTK on the PATH the agents get, when it isn't (their hooks run it by name)
`
// libraryCmd is magpie library …: the instructions, MCP servers and skills
// magpie keeps once and writes into every agent.
func libraryCmd(args []string) error {
if len(args) < 2 {
return libraryStatus()
}
var (
res *library.Result
err error
)
switch sub, rest := args[1], args[2:]; sub {
case "sync":
res, err = library.Sync()
case "instructions":
if len(rest) == 0 {
v, err := library.ReadInstructions()
if err != nil {
return err
}
fmt.Print(v.Shared)
if v.Shared != "" && !strings.HasSuffix(v.Shared, "\n") {
fmt.Println()
}
return nil
}
switch {
case rest[0] == "set" && len(rest) == 2:
var b []byte
if rest[1] == "-" {
b, err = io.ReadAll(os.Stdin)
} else {
b, err = os.ReadFile(rest[1])
}
if err != nil {
return err
}
text := string(b)
res, err = library.SaveInstructions(library.InstructionsChange{Shared: &text})
case rest[0] == "agents" && len(rest) == 2:
var as []string
if as, err = libraryAgents(rest[1], "instructions"); err == nil {
res, err = library.SaveInstructions(library.InstructionsChange{Agents: as})
}
default:
return fmt.Errorf("usage:\n %s", libraryUsage)
}
case "mcp":
switch {
case len(rest) >= 3 && rest[0] == "add":
var cmd, agents []string
for _, a := range rest[2:] {
if v, ok := strings.CutPrefix(a, "agents="); ok {
if agents, err = libraryAgents(v, "mcp"); err != nil {
return err
}
} else {
cmd = append(cmd, a)
}
}
var s library.Server
if s, err = library.ServerOf(rest[1], cmd); err != nil {
return err
}
if agents != nil {
s.Agents = agents
}
res, err = library.SaveServer("", s)
case len(rest) == 3 && rest[0] == "agents":
var as []string
if as, err = libraryAgents(rest[2], "mcp"); err == nil {
res, err = library.ServerAgents(rest[1], as)
}
case len(rest) == 2 && rest[0] == "rm":
res, err = library.RemoveServer(rest[1])
default:
return fmt.Errorf("usage:\n %s", libraryUsage)
}
case "skill", "skills":
switch {
case len(rest) == 3 && rest[0] == "agents":
var as []string
if as, err = libraryAgents(rest[2], "skills"); err == nil {
res, err = library.SkillAgents(rest[1], as)
}
case len(rest) >= 2 && rest[0] == "rm" && slices.Contains(rest[1:], "--all"):
if res, err = removeEverySkill(rest[1:]); res == nil && err == nil {
return nil
}
case len(rest) == 2 && rest[0] == "rm":
res, err = library.RemoveSkill(rest[1])
case len(rest) == 3 && rest[0] == "use-library":
res, err = library.UseLibrarySkill(rest[1], rest[2])
case len(rest) == 3 && rest[0] == "keep-own":
res, err = library.KeepAgentSkill(rest[1], rest[2])
case len(rest) == 2 && rest[0] == "update":
if res, err = library.UpdateSkill(rest[1]); err == nil {
fmt.Println(green.Render("✓"), rest[1], "is up to date")
}
case len(rest) == 1 && rest[0] == "update":
res, err = library.UpdateSkills()
default:
return fmt.Errorf("usage:\n %s", libraryUsage)
}
case "rtk":
return rtkCmd(rest)
case "help", "-h", "--help":
fmt.Print(" " + libraryUsage)
return nil
default:
return fmt.Errorf("no library command %q\n %s", sub, libraryUsage)
}
if err != nil {
return err
}
printLibraryResult(res)
return nil
}
// removeEverySkill is magpie library skill rm --all [--yes] (#449): every
// skill out of the library, each as rm takes one. It asks first on a
// terminal; with no terminal to ask on, only --yes takes them. A library
// with no skills is said, and answers a nil result.
func removeEverySkill(args []string) (*library.Result, error) {
yes := false
for _, a := range args {
switch a {
case "--all":
case "--yes", "-y":
yes = true
default:
return nil, fmt.Errorf("usage:\n %s", libraryUsage)
}
}
v, err := library.Read(nil)
if err != nil {
return nil, err
}
var names []string
for _, s := range v.Skills {
names = append(names, s.Name)
}
if len(names) == 0 {
fmt.Println(green.Render("✓"), "the library has no skills")
return nil, nil
}
if !yes {
if !term.IsTerminal(int(os.Stdin.Fd())) {
return nil, fmt.Errorf("this removes all %s; run it with --yes to do so without being asked", plural(len(names), "skill"))
}
fmt.Printf("Remove all %s (%s) from the library and every agent? Folders magpie keeps go to its backups; folders of your own are only unlinked. [y/N] ",
plural(len(names), "skill"), strings.Join(names, ", "))
line, _ := stdin.ReadString('\n')
if a := strings.ToLower(strings.TrimSpace(line)); a != "y" && a != "yes" {
return nil, fmt.Errorf("nothing removed")
}
}
res, err := library.RemoveSkills(names)
if err != nil {
return nil, err
}
gone := 0
for _, n := range names {
if !slices.ContainsFunc(res.Unremoved, func(p library.Problem) bool { return p.What == "skill:"+n }) {
gone++
}
}
fmt.Println(green.Render("✓"), plural(gone, "skill"), "removed")
return res, nil
}
// agentList is a comma list of agents; none (or nothing) is no agent.
func agentList(s string) []string {
out := []string{}
for _, a := range strings.Split(s, ",") {
if a = strings.TrimSpace(a); a != "" && a != "none" {
out = append(out, a)
}
}
return out
}
// libraryAgents is agentList with each agent's id, refused when the library
// has no place in it for kind.
func libraryAgents(s, kind string) ([]string, error) {
out := []string{}
for _, a := range agentList(s) {
id, err := library.Takes(a, kind)
if err != nil {
return nil, err
}
out = append(out, id)
}
return out, nil
}
func printLibraryResult(res *library.Result) {
if len(res.Changed) == 0 {
fmt.Println(green.Render("✓"), "every agent already has it")
} else {
fmt.Println(green.Render("✓"), "written into", strings.Join(res.Changed, ", "))
}
for _, p := range res.Problems {
fmt.Println(amber.Render("!"), p.Agent, muted.Render(p.What+":"), p.Error)
if n, ok := strings.CutPrefix(p.What, "skill:"); ok && p.Own {
fmt.Println(muted.Render(" use the library's (the agent's kept aside): magpie library skill use-library " + n + " " + p.Agent))
fmt.Println(muted.Render(" or keep the agent's: magpie library skill keep-own " + n + " " + p.Agent))
}
}
if len(res.Updated) > 0 {
fmt.Println(green.Render("✓"), "up to date:", strings.Join(res.Updated, ", "))
}
for _, p := range res.Unupdated {
fmt.Println(amber.Render("!"), strings.TrimPrefix(p.What, "skill:"), muted.Render("not updated:"), p.Error)
}
for _, p := range res.Unremoved {
fmt.Println(amber.Render("!"), strings.TrimPrefix(p.What, "skill:"), muted.Render("not removed:"), p.Error)
}
for _, m := range res.Missing {
fmt.Println(amber.Render("!"), "the library no longer has", m)
}
if res.Backup != "" {
fmt.Println(muted.Render(" what was there before is kept in " + res.Backup))
}
}
func libraryStatus() error {
v, err := library.Read(nil)
if err != nil {
return err
}
on := func(ids []string) string {
if len(ids) == 0 {
return muted.Render("no agent")
}
return strings.Join(ids, ", ")
}
fmt.Println(bold.Render("Instructions"))
var to []string
for _, a := range v.Instructions.Agents {
if a.On {
to = append(to, a.Agent)
}
}
if strings.TrimSpace(v.Instructions.Shared) == "" {
fmt.Println(" ", muted.Render("none yet · magpie library instructions set <file>"))
} else {
fmt.Printf(" %d lines → %s\n", strings.Count(strings.TrimRight(v.Instructions.Shared, "\n"), "\n")+1, on(to))
}
for _, a := range v.Instructions.Agents {
if a.Edited {
fmt.Println(" ", amber.Render("!"), a.Agent, muted.Render("magpie's part was edited in "+a.Path))
}
}
fmt.Println(bold.Render("MCP servers"))
if len(v.Servers) == 0 {
fmt.Println(" ", muted.Render("none yet"))
}
for _, s := range v.Servers {
what := s.URL
if s.Command != "" {
what = strings.TrimSpace(s.Command + " " + strings.Join(s.Args, " "))
}
fmt.Printf(" %s %s → %s\n", bold.Render(s.Name), muted.Render(what), on(s.Agents))
for a, e := range s.Problems {
fmt.Println(" ", amber.Render("!"), a, muted.Render(e))
}
}
var found, own []string
for _, f := range v.FoundServers {
if f.Own {
own = append(own, f.Server.Name+muted.Render(" ("+strings.Join(f.Server.Agents, ", ")+"'s own)"))
} else {
found = append(found, f.Server.Name+muted.Render(" ("+strings.Join(f.Server.Agents, ", ")+")"))
}
}
if len(found) > 0 {
fmt.Println(" ", muted.Render("in your agents, not in the library:"), strings.Join(found, ", "))
}
if len(own) > 0 {
fmt.Println(" ", muted.Render("added by the agent itself, left as they are:"), strings.Join(own, ", "))
}
fmt.Println(bold.Render("Skills"))
if len(v.Skills) == 0 {
fmt.Println(" ", muted.Render("none yet"))
}
for _, s := range v.Skills {
fmt.Printf(" %s → %s\n", bold.Render(s.Name), on(s.Agents))
if s.Missing {
fmt.Println(" ", amber.Render("!"), muted.Render("its folder is gone"))
}
}
fmt.Println(muted.Render(" kept in " + v.Dir + " · magpie library help"))
return nil
}
// rtkCmd is magpie library rtk …: RTK's hook in each agent.
func rtkCmd(args []string) error {
var v *library.RTKView
switch {
case len(args) == 0:
v = library.ReadRTK()
case len(args) == 1 && args[0] == "install":
fmt.Println(muted.Render("installing rtk…"))
var err error
if v, err = library.InstallRTK(); err != nil {
return err
}
case len(args) == 1 && args[0] == "path":
var err error
if v, err = library.PathRTK(); err != nil {
return err
}
case len(args) == 1 && args[0] == "upgrade":
fmt.Println(muted.Render("upgrading rtk…"))
var err error
if v, err = library.UpgradeRTK(); err != nil {
return err
}
case len(args) == 2 && (args[0] == "on" || args[0] == "off"):
id, err := library.RTKTakes(args[1])
if err != nil {
return err
}
if v, err = library.SetRTK(id, args[0] == "on"); err != nil {
return err
}
default:
return fmt.Errorf("usage:\n %s", libraryUsage)
}
if v.Path == "" {
fmt.Println(amber.Render("!"), "rtk isn't installed —", v.URL)
if v.Install != "" {
fmt.Println(muted.Render(" magpie library rtk install runs: " + v.Install))
}
} else {
fmt.Println(bold.Render("RTK"), muted.Render(v.Version+" · "+v.Path))
v.CheckLatest()
switch {
case v.Note != "":
fmt.Println(amber.Render(" " + v.Note))
case v.Latest != "" && v.Version != "" && library.RTKNewer(v.Latest, v.Version):
up := "update it the way it was installed"
if v.Upgrade != "" {
up = "magpie library rtk upgrade runs: " + v.Upgrade
}
fmt.Println(amber.Render(" RTK "+v.Latest+" is out"), muted.Render("— "+up))
case len(args) == 1 && args[0] == "upgrade":
fmt.Println(green.Render(" up to date"))
}
if v.OffPath {
fmt.Println(amber.Render(" rtk isn't on your PATH"), muted.Render("— the agents' hooks run it by name, so RTK does nothing for them"))
switch {
case v.PathDir == "":
fmt.Println(muted.Render(" add " + filepath.Dir(v.Path) + " to PATH in your shell profile"))
case v.PathLink:
fmt.Println(muted.Render(" magpie library rtk path links it into " + v.PathDir))
default:
fmt.Println(muted.Render(" magpie library rtk path adds " + v.PathDir + " to your user PATH"))
}
}
switch g := v.Gain; {
case g != nil:
fmt.Printf(" %d tokens saved over %d commands (%.0f%% on average)\n", g.Saved, g.Commands, g.Pct)
case v.GainErr != "":
fmt.Println(amber.Render(" what rtk saved isn't known:"), muted.Render(v.GainErr))
}
switch v.CodexSandbox {
case "elevated":
fmt.Println(muted.Render(" Codex runs its commands in its Windows sandbox as its own account, so what RTK saves there isn't counted"))
case "unelevated":
fmt.Println(muted.Render(" Codex runs its commands in its Windows sandbox, which can't write RTK's history, so what RTK saves there isn't counted"))
}
}
for _, a := range v.Agents {
mark := muted.Render("off")
note := ""
if a.On {
mark = green.Render("on ")
if v.Path == "" {
mark, note = amber.Render("on "), muted.Render(" — its hook calls rtk, which isn't installed: install it, or switch this off")
} else if v.OffPath {
mark, note = amber.Render("on "), muted.Render(" — its hook can't find rtk on PATH, so it does nothing")
}
}
fmt.Println(" ", mark, a.Name+note)
}
if len(v.Agents) == 0 {
fmt.Println(" ", muted.Render("none of the agents here is one RTK has a hook for"))
}
if len(v.Restart) > 0 {
fmt.Println(muted.Render(" restart " + strings.Join(v.Restart, ", ") + " for it to take effect"))
}
if v.Backup != "" {
fmt.Println(muted.Render(" what was there before is kept in " + v.Backup))
}
return nil
}