diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index dfcdd90c..5772a958 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -816,6 +816,17 @@ const I18N = { "MCP servers": "MCP 服务器", "Skills": "技能", "Library folder": "资源库文件夹", + "RTK rewrites the shell commands an agent runs — git status, cargo test, ls… — to print only what the model needs, so they cost fewer tokens. Switch it on for an agent and magpie runs RTK's own installer for it.": "RTK 会改写 Agent 执行的命令行命令(git status、cargo test、ls……),只输出模型需要的部分,从而省 token。为某个 Agent 打开后,magpie 会替它运行 RTK 自带的安装程序。", + "{saved} tokens saved over {n} commands — {pct}% on average": "{n} 条命令共节省 {saved} token,平均省 {pct}%", + "Nothing saved yet: the agents' commands go through RTK once it's switched on and the agent is restarted.": "还没有节省记录:打开后重启 Agent,它的命令就会经过 RTK。", + "RTK isn't installed. Install it (brew install rtk, or see its site), then come back here.": "尚未安装 RTK。先安装(brew install rtk,或见其官网),再回到这里。", + "Get RTK": "获取 RTK", + "Check again": "重新检测", + "restart an agent after switching it": "切换后需重启对应 Agent", + "{agent} runs its commands through RTK": "{agent} 的命令经过 RTK", + "None of your agents is one RTK has a hook for.": "你的 Agent 中没有 RTK 支持的。", + "RTK is on for {agent} — restart it to use it": "已为 {agent} 打开 RTK,重启后生效", + "RTK is off for {agent} — restart it to see it": "已为 {agent} 关闭 RTK,重启后生效", "No agents to write to": "没有可写入的 Agent", "Install Claude Code, Codex, Gemini CLI, OpenCode… and the library can give them the same instructions, MCP servers and skills.": "安装 Claude Code、Codex、Gemini CLI、OpenCode 等之后,资源库就能给它们同一份指令、MCP 服务器和技能。", "magpie keeps a copy of every file before it writes it.": "magpie 写入任何文件前都会先备份一份。", diff --git a/internal/gui/assets/library.css b/internal/gui/assets/library.css index 4f5103c0..99585e3f 100644 --- a/internal/gui/assets/library.css +++ b/internal/gui/assets/library.css @@ -273,3 +273,5 @@ body.window .lib-head { top: -2px; margin: -2px -16px 0; padding: 8px 16px 4px; .mk-sheet .mk-hint .mk-ext { padding: 0; font-family: var(--code); font-size: 11.5px; } .mk-sheet .mk-hint .mk-ext:hover { background: none; text-decoration: underline; text-underline-offset: 2px; } .mk-sheet .bar .mk-id { margin-left: 2px; } +.lib-rtk-gain { margin: 6px 12px 10px; font-size: 12px; line-height: 1.5; color: var(--muted); } +.lib-card > .lib-acts { padding: 0 12px 12px; } diff --git a/internal/gui/assets/library.js b/internal/gui/assets/library.js index cd62c304..7879f134 100644 --- a/internal/gui/assets/library.js +++ b/internal/gui/assets/library.js @@ -162,6 +162,7 @@ async function load() { if (!lib) renderLoading(); + rtk = null; // asked again when its tab is drawn: an agent may have been installed since try { lib = await api("library"); render(); @@ -224,6 +225,7 @@ ["instructions", t("Instructions")], ["mcp", t("MCP servers") + (counts.mcp ? " · " + counts.mcp : "")], ["skills", t("Skills") + (counts.skills ? " · " + counts.skills : "")], + ["rtk", "RTK"], ], tab, (id) => { tab = id; try { localStorage.setItem("magpie.libTab", id); } catch {} render(); page.scrollTop = 0; }); tabs.classList.add("lib-tabs"); head.append(tabs, el("span", "grow")); @@ -243,6 +245,7 @@ shown = tab; if (tab === "instructions") renderInstructions(body); else if (tab === "mcp") renderServers(body); + else if (tab === "rtk") renderRTK(body); else renderSkills(body); page.append(body); for (const n of body.querySelectorAll(".row-head .note")) n.title = n.textContent; @@ -269,6 +272,88 @@ function intro(text) { return el("p", "lib-intro", text); } + // ---------- RTK ---------- + + // RTK (rtk-ai.app) cuts down what the shell commands an agent runs print, + // so their output costs fewer tokens. magpie runs rtk's own installer for + // each agent switched on, and reads the agents' files for which have it. + let rtk = null; // /api/library/rtk + const rtkBusy = new Set(); // agents being switched + let rtkLoading = false; + async function loadRTK() { + if (rtkLoading) return; + rtkLoading = true; + try { + rtk = await api("library/rtk"); + } catch (e) { + status(e.message, "err"); + } + rtkLoading = false; + if (tab === "rtk" && rtk) render(); + } + function renderRTK(body) { + body.append(intro(t("RTK rewrites the shell commands an agent runs — git status, cargo test, ls… — to print only what the model needs, so they cost fewer tokens. Switch it on for an agent and magpie runs RTK's own installer for it."))); + if (!rtk) { + body.append(el("div", "row skeleton")); + loadRTK(); + return; + } + const card = el("div", "list lib-card"); + const ttl = el("div", "lib-cardhead"); + ttl.append(glyph(GLYPH.cmd), el("b", "", "RTK"), el("span", "grow")); + if (rtk.path) { + ttl.append(el("span", "note mono", rtk.version ? "v" + rtk.version : tilde(rtk.path))); + card.append(ttl); + const g = rtk.gain; + card.append(el("p", "lib-rtk-gain", g + ? t("{saved} tokens saved over {n} commands — {pct}% on average", { saved: tokens(g.saved), n: g.commands.toLocaleString(), pct: Math.round(g.pct) }) + : t("Nothing saved yet: the agents' commands go through RTK once it's switched on and the agent is restarted."))); + } else { + card.append(ttl); + const p = el("p", "lib-rtk-gain", t("RTK isn't installed. Install it (brew install rtk, or see its site), then come back here.")); + card.append(p); + const acts = el("div", "lib-acts"); + acts.append(button(t("Get RTK"), "action", () => browse(rtk.url)), button(t("Check again"), "", () => { rtk = null; render(); })); + card.append(acts); + } + body.append(card); + const rh = el("div", "row-head"); + rh.append(el("span", "label", t("Agents")), el("span", "grow"), el("span", "note", t("restart an agent after switching it"))); + body.append(rh); + const list = el("div", "list lib-list"); + const rows = rtk.agents.filter((a) => !isHidden(a)); + for (const a of rows) { + const row = el("div", "row lib-row"); + const who = el("div", "who"); + who.append(el("div", "name", a.name)); + row.append(icon(a.icon), who, el("span", "grow")); + const sw = toggle(a.on, t("{agent} runs its commands through RTK", { agent: a.name }), (on) => setRTK(a, on)); + if (!rtk.path || rtkBusy.has(a.id)) sw.disabled = true; + row.append(sw); + list.append(row); + } + if (!rows.length) list.append(el("div", "lib-none", t("None of your agents is one RTK has a hook for."))); + body.append(list); + } + async function setRTK(a, on) { + rtkBusy.add(a.id); + render(); + try { + rtk = await api("library/rtk", { agent: a.id, on }); + const names = (rtk.restart || [a.id]).map((id) => rtk.agents.find((x) => x.id === id)?.name || id).join(", "); + status(on ? t("RTK is on for {agent} — restart it to use it", { agent: names }) : t("RTK is off for {agent} — restart it to see it", { agent: names }), "ok", 6000); + } catch (e) { + status(e.message, "err", 8000); + } + rtkBusy.delete(a.id); + render(); + } + function tokens(n) { + if (n >= 1e6) return (n / 1e6).toFixed(1) + "M"; + if (n >= 1e3) return (n / 1e3).toFixed(1) + "k"; + return String(n); + } + // ---------- instructions ---------- function autosize(ta, min) { diff --git a/internal/gui/library.go b/internal/gui/library.go index aee9e2c8..025ff695 100644 --- a/internal/gui/library.go +++ b/internal/gui/library.go @@ -131,6 +131,26 @@ func libraryRoutes(mux *http.ServeMux, w Windows) { } writeJSON(rw, p) }) + // RTK: which agents have its hook, and switching one on or off + mux.HandleFunc("GET /api/library/rtk", func(rw http.ResponseWriter, r *http.Request) { + writeJSON(rw, library.ReadRTK()) + }) + 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/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 == "" { diff --git a/internal/library/rtk.go b/internal/library/rtk.go new file mode 100644 index 00000000..9a1f53a1 --- /dev/null +++ b/internal/library/rtk.go @@ -0,0 +1,354 @@ +package library + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "sync" + "time" + + "github.com/yetone/magpie/internal/agent" + "github.com/yetone/magpie/internal/proc" +) + +// RTK (rtk-ai.app) is a CLI that the shell commands an agent runs go +// through — git status, cargo test, ls — cut down to what the model needs, +// so their output costs fewer tokens. Each agent gets it through a hook its +// own installer writes (rtk init -g …): magpie runs that installer for the +// agents switched on, and reads each one's files to say which have it, since +// rtk init --show knows only of Claude Code, OpenCode and Cursor. + +// RTKURL is where rtk is installed from. +const RTKURL = "https://www.rtk-ai.app" + +// rtkSpec is how rtk's installer names one agent, and what it writes there. +type rtkSpec struct { + flags []string // after rtk init -g + // patch: the installer takes --auto-patch (Codex's refuses it) + patch bool + // has says whether the agent has rtk's hook, by its files + has func(a *agent.Agent) bool + // touches are the files the installer edits (not those it only adds), + // kept aside by magpie first + touches func(a *agent.Agent) []string + // dir is the agent's folder, made first: rtk writes nothing for an + // agent whose folder isn't there yet + dir func(a *agent.Agent) string + // withClaude: the installer gives it to Claude Code too, which is + // pointed at a folder thrown away after + withClaude bool +} + +func contains(path, s string) bool { + b, err := os.ReadFile(path) + return err == nil && bytes.Contains(b, []byte(s)) +} + +func exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +var rtkSpecs = map[string]rtkSpec{ + "claude": { + patch: true, + has: func(*agent.Agent) bool { + return contains(filepath.Join(claudeDir(), "settings.json"), "rtk hook claude") + }, + touches: func(*agent.Agent) []string { + return []string{filepath.Join(claudeDir(), "settings.json"), filepath.Join(claudeDir(), "CLAUDE.md")} + }, + dir: func(*agent.Agent) string { return claudeDir() }, + }, + "codex": { + flags: []string{"--codex"}, + has: func(*agent.Agent) bool { return contains(filepath.Join(codexDir(), "hooks.json"), "rtk hook codex") }, + touches: func(*agent.Agent) []string { + return []string{filepath.Join(codexDir(), "hooks.json"), filepath.Join(codexDir(), "AGENTS.md")} + }, + dir: func(*agent.Agent) string { return codexDir() }, + }, + "gemini": { + flags: []string{"--gemini"}, patch: true, + has: func(*agent.Agent) bool { + return contains(filepath.Join(home(), ".gemini", "settings.json"), "rtk-hook-gemini") + }, + touches: func(*agent.Agent) []string { + return []string{filepath.Join(home(), ".gemini", "settings.json"), filepath.Join(home(), ".gemini", "GEMINI.md")} + }, + dir: func(*agent.Agent) string { return filepath.Join(home(), ".gemini") }, + }, + "opencode": { + flags: []string{"--opencode"}, patch: true, withClaude: true, + has: func(a *agent.Agent) bool { return exists(filepath.Join(filepath.Dir(a.Path), "plugins", "rtk.ts")) }, + touches: func(*agent.Agent) []string { return nil }, + dir: func(a *agent.Agent) string { return filepath.Dir(a.Path) }, + }, + "cursor": { + flags: []string{"--agent", "cursor"}, patch: true, withClaude: true, + has: func(*agent.Agent) bool { + return contains(filepath.Join(home(), ".cursor", "hooks.json"), "rtk hook cursor") + }, + touches: func(*agent.Agent) []string { return []string{filepath.Join(home(), ".cursor", "hooks.json")} }, + dir: func(*agent.Agent) string { return filepath.Join(home(), ".cursor") }, + }, + "copilot": { + flags: []string{"--copilot"}, patch: true, + has: func(*agent.Agent) bool { return exists(filepath.Join(home(), ".copilot", "hooks", "rtk-rewrite.json")) }, + touches: func(*agent.Agent) []string { + return []string{filepath.Join(home(), ".copilot", "copilot-instructions.md")} + }, + dir: func(*agent.Agent) string { return filepath.Join(home(), ".copilot") }, + }, + "pi": { + flags: []string{"--agent", "pi"}, patch: true, + has: func(*agent.Agent) bool { return exists(filepath.Join(home(), ".pi", "agent", "extensions", "rtk.ts")) }, + touches: func(*agent.Agent) []string { return nil }, + dir: func(*agent.Agent) string { return filepath.Join(home(), ".pi", "agent") }, + }, + "omp": { + flags: []string{"--agent", "omp"}, patch: true, + has: func(*agent.Agent) bool { return exists(filepath.Join(home(), ".omp", "agent", "extensions", "rtk.ts")) }, + touches: func(*agent.Agent) []string { return nil }, + dir: func(*agent.Agent) string { return filepath.Join(home(), ".omp", "agent") }, + }, + "hermes": { + flags: []string{"--agent", "hermes"}, patch: true, + has: func(*agent.Agent) bool { + return exists(filepath.Join(home(), ".hermes", "plugins", "rtk-rewrite", "plugin.yaml")) + }, + touches: func(*agent.Agent) []string { return []string{filepath.Join(home(), ".hermes", "config.yaml")} }, + dir: func(*agent.Agent) string { return filepath.Join(home(), ".hermes") }, + }, +} + +// rtkBundle: rtk's uninstaller for Claude Code or OpenCode takes it out of +// all three of these at once, so the others are put back after. +var rtkBundle = []string{"claude", "opencode", "cursor"} + +// RTKAgent is one agent rtk can be given to, and whether it has it. +type RTKAgent struct { + ID string `json:"id"` + Name string `json:"name"` + Icon string `json:"icon"` + On bool `json:"on"` +} + +// RTKGain is what rtk says it saved, over every command it has recorded. +type RTKGain struct { + Commands int `json:"commands"` + Input int64 `json:"input"` + Saved int64 `json:"saved"` + Pct float64 `json:"pct"` +} + +// RTKView is the RTK part of the Library page. +type RTKView struct { + Path string `json:"path,omitempty"` // "" when rtk isn't installed + Version string `json:"version,omitempty"` + Gain *RTKGain `json:"gain,omitempty"` + Agents []RTKAgent `json:"agents"` + URL string `json:"url"` + // Restart are the agents a change reached, to be restarted to see it + Restart []string `json:"restart,omitempty"` + Backup string `json:"backup,omitempty"` +} + +var rtkMu sync.Mutex + +func rtkPath() string { + p, err := exec.LookPath("rtk") + if err != nil { + return "" + } + return p +} + +func rtkRun(bin string, args ...string) (string, error) { return rtkRunEnv(bin, nil, args...) } + +func rtkRunEnv(bin string, env []string, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + cmd := proc.CommandContext(ctx, bin, args...) + // rtk asks before a change it isn't told to make: nothing answers it + cmd.Stdin = nil + if env != nil { + cmd.Env = append(os.Environ(), env...) + } + out, err := cmd.CombinedOutput() + text := strings.TrimSpace(string(out)) + if err != nil { + if text == "" { + text = err.Error() + } + return text, fmt.Errorf("rtk %s: %s", strings.Join(args, " "), lastLines(text, 4)) + } + return text, nil +} + +func lastLines(s string, n int) string { + ls := strings.Split(strings.TrimSpace(s), "\n") + if len(ls) > n { + ls = ls[len(ls)-n:] + } + return strings.Join(ls, " ") +} + +// rtkAgents are the agents on this machine rtk can be given to. +func rtkAgents() []*agent.Agent { + var out []*agent.Agent + for _, a := range agent.Detected() { + if _, ok := rtkSpecs[a.ID]; ok { + out = append(out, a) + } + } + return out +} + +// ReadRTK says whether rtk is installed, what it saved, and which agents +// have it. +func ReadRTK() *RTKView { + v := &RTKView{Agents: []RTKAgent{}, URL: RTKURL, Path: rtkPath()} + for _, a := range rtkAgents() { + v.Agents = append(v.Agents, RTKAgent{ID: a.ID, Name: a.Name, Icon: a.Icon, On: rtkSpecs[a.ID].has(a)}) + } + if v.Path == "" { + return v + } + if out, err := rtkRun(v.Path, "--version"); err == nil { + v.Version = strings.TrimSpace(strings.TrimPrefix(out, "rtk")) + } + if out, err := rtkRun(v.Path, "gain", "--format", "json"); err == nil { + var g struct { + Summary struct { + Commands int `json:"total_commands"` + Input int64 `json:"total_input"` + Saved int64 `json:"total_saved"` + Pct float64 `json:"avg_savings_pct"` + } `json:"summary"` + } + if json.Unmarshal([]byte(out), &g) == nil && g.Summary.Commands > 0 { + s := g.Summary + v.Gain = &RTKGain{Commands: s.Commands, Input: s.Input, Saved: s.Saved, Pct: s.Pct} + } + } + return v +} + +// SetRTK gives rtk to an agent, or takes it away, with rtk's own installer. +func SetRTK(id string, on bool) (*RTKView, error) { + rtkMu.Lock() + defer rtkMu.Unlock() + bin := rtkPath() + if bin == "" { + return nil, fmt.Errorf("rtk isn't installed — get it from %s", RTKURL) + } + agents := map[string]*agent.Agent{} + for _, a := range rtkAgents() { + agents[a.ID] = a + } + a := agents[id] + if a == nil { + if _, ok := rtkSpecs[id]; !ok { + return nil, fmt.Errorf("rtk has no hook for %s", id) + } + return nil, fmt.Errorf("%s isn't installed", id) + } + sp := rtkSpecs[id] + if sp.has(a) == on { + v := ReadRTK() + return v, nil + } + // what else the uninstaller takes, to put back + var back []*agent.Agent + if !on && (id == "claude" || id == "opencode") { + for _, o := range rtkBundle { + if b := agents[o]; b != nil && o != id && rtkSpecs[o].has(b) { + back = append(back, b) + } + } + } + b := newBackups() + for _, x := range append([]*agent.Agent{a}, back...) { + for _, p := range rtkSpecs[x.ID].touches(x) { + if err := b.keep(x.ID, p); err != nil { + return nil, err + } + } + } + if on { + if err := rtkInstall(bin, a); err != nil { + return nil, err + } + } else { + if _, err := rtkRun(bin, append(append([]string{"init", "-g"}, sp.flags...), "--uninstall")...); err != nil { + return nil, err + } + for _, x := range back { + if err := rtkInstall(bin, x); err != nil { + return nil, fmt.Errorf("rtk was taken out of %s, but putting it back into %s failed: %w", a.Name, x.Name, err) + } + } + if sp.has(a) { + return nil, fmt.Errorf("rtk's uninstaller left its hook in %s", a.Name) + } + } + if b.dir != "" { + pruneBackups() + } + v := ReadRTK() + // those put back are as they were: only this one has anything new + v.Restart, v.Backup = []string{a.ID}, b.dir + return v, nil +} + +func rtkInstall(bin string, a *agent.Agent) error { + sp := rtkSpecs[a.ID] + if err := os.MkdirAll(sp.dir(a), 0o755); err != nil { + return err + } + args := append([]string{"init", "-g"}, sp.flags...) + if sp.patch { + args = append(args, "--auto-patch") + } + var env []string + if sp.withClaude { + tmp, err := os.MkdirTemp("", "magpie-rtk-") + if err != nil { + return err + } + defer os.RemoveAll(tmp) + env = []string{"CLAUDE_CONFIG_DIR=" + tmp} + } + out, err := rtkRunEnv(bin, env, args...) + if err != nil { + return err + } + if !sp.has(a) { + return fmt.Errorf("rtk's installer didn't add its hook to %s: %s", a.Name, lastLines(out, 3)) + } + return nil +} + +// RTKTakes is the id of the agent q names when rtk can be given to it. +func RTKTakes(q string) (string, error) { + a, err := agent.Find(q) + if err != nil { + return "", err + } + if _, ok := rtkSpecs[a.ID]; !ok { + var ids []string + for id := range rtkSpecs { + ids = append(ids, id) + } + slices.Sort(ids) + return "", fmt.Errorf("rtk has no hook for %s (it has for %s)", a.Name, strings.Join(ids, ", ")) + } + return a.ID, nil +} diff --git a/internal/library/rtk_test.go b/internal/library/rtk_test.go new file mode 100644 index 00000000..e48e0417 --- /dev/null +++ b/internal/library/rtk_test.go @@ -0,0 +1,95 @@ +package library + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +// fakeRTK is an rtk on PATH that acts as rtk 0.50's installer does for +// Claude Code, OpenCode and Cursor: installing OpenCode or Cursor gives it to +// Claude Code as well, and uninstalling Claude Code or OpenCode takes all +// three away. +const fakeRTK = `#!/bin/sh +c="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" +claude() { [ -d "$c" ] && echo '{"hooks":{"PreToolUse":[{"hooks":[{"command":"rtk hook claude"}]}]}}' > "$c/settings.json"; } +case "$1" in +--version) echo "rtk 9.9.9"; exit 0 ;; +gain) echo '{"summary":{"total_commands":3,"total_input":100,"total_saved":60,"avg_savings_pct":60}}'; exit 0 ;; +esac +shift 2 +case "$*" in +"--auto-patch") claude ;; +"--opencode --auto-patch") claude; /bin/mkdir -p "$XDG_CONFIG_HOME/opencode/plugins"; echo x > "$XDG_CONFIG_HOME/opencode/plugins/rtk.ts" ;; +"--agent cursor --auto-patch") claude; echo '{"hooks":{"preToolUse":[{"command":"rtk hook cursor"}]}}' > "$HOME/.cursor/hooks.json" ;; +"--agent cursor --uninstall") echo '{}' > "$HOME/.cursor/hooks.json" ;; +"--uninstall"|"--opencode --uninstall") + echo '{}' > "$c/settings.json"; /bin/rm -f "$XDG_CONFIG_HOME/opencode/plugins/rtk.ts"; echo '{}' > "$HOME/.cursor/hooks.json" ;; +*) echo "unknown: $*" >&2; exit 2 ;; +esac +` + +func TestRTK(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("the fake rtk is a shell script") + } + h := sandbox(t) + bin := filepath.Join(h, "bin") + write(t, filepath.Join(bin, "rtk"), fakeRTK) + if err := os.Chmod(filepath.Join(bin, "rtk"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin) + state := func() map[string]bool { + m := map[string]bool{} + for _, a := range ReadRTK().Agents { + m[a.ID] = a.On + } + return m + } + want := func(claude, opencode, cursor bool) { + t.Helper() + s := state() + if s["claude"] != claude || s["opencode"] != opencode || s["cursor"] != cursor { + t.Fatalf("claude, opencode, cursor = %v, %v, %v; want %v, %v, %v", s["claude"], s["opencode"], s["cursor"], claude, opencode, cursor) + } + } + set := func(id string, on bool) { + t.Helper() + if _, err := SetRTK(id, on); err != nil { + t.Fatal(err) + } + } + + v := ReadRTK() + if v.Version != "9.9.9" || v.Gain == nil || v.Gain.Saved != 60 { + t.Fatalf("version %q, gain %+v", v.Version, v.Gain) + } + want(false, false, false) + // OpenCode's and Cursor's installers leave Claude Code as it was + set("opencode", true) + set("cursor", true) + want(false, true, true) + set("claude", true) + want(true, true, true) + // Claude Code's uninstaller takes the other two with it: they're put back + set("claude", false) + want(false, true, true) + set("claude", true) + set("opencode", false) + want(true, false, true) + set("cursor", false) + want(true, false, false) + + if _, err := SetRTK("goose", true); err == nil { + t.Fatal("goose has no rtk hook, yet it was switched on") + } + t.Setenv("PATH", "") + if v := ReadRTK(); v.Path != "" { + t.Fatalf("rtk found at %s with nothing on PATH", v.Path) + } + if _, err := SetRTK("claude", false); err == nil { + t.Fatal("switched without rtk installed") + } +} diff --git a/library_cli.go b/library_cli.go index 47e9f931..8b5cc6f1 100644 --- a/library_cli.go +++ b/library_cli.go @@ -19,6 +19,8 @@ const libraryUsage = `magpie library what the library gives magpie library mcp rm magpie library skill agents magpie library skill rm (skills are installed from the app's Library page) + magpie library rtk which agents run their shell commands through RTK (rtk-ai.app), to save tokens + magpie library rtk on|off switch it, with RTK's own installer ` // libraryCmd is magpie library …: the instructions, MCP servers and skills @@ -115,6 +117,8 @@ func libraryCmd(args []string) error { default: return fmt.Errorf("usage:\n %s", libraryUsage) } + case "rtk": + return rtkCmd(rest) case "help", "-h", "--help": fmt.Print(" " + libraryUsage) return nil @@ -239,3 +243,47 @@ func libraryStatus() error { 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) == 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) + } else { + fmt.Println(bold.Render("RTK"), muted.Render(v.Version+" · "+v.Path)) + if g := v.Gain; g != nil { + fmt.Printf(" %d tokens saved over %d commands (%.0f%% on average)\n", g.Saved, g.Commands, g.Pct) + } + } + for _, a := range v.Agents { + mark := muted.Render("off") + if a.On { + mark = green.Render("on ") + } + fmt.Println(" ", mark, a.Name) + } + 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 +}