rtk: an agent left with the hook of an rtk that isn't installed (never was, or removed since) is shown with RTK missing — its shell commands fail — and can still be switched off: magpie takes out just what rtk's installer put in (the hook in settings.json / hooks.json, RTK.md and the @RTK.md line, Gemini's hook script, Copilot's instructions block, the OpenCode / Pi / OMP extension, Hermes' plugin) itself, needing no rtk, the files kept aside first; rtk's own uninstaller is no longer used, which deleted Gemini's GEMINI.md whole and took Claude Code, OpenCode and Cursor away together. Gemini is given rtk hook-only, so the user's GEMINI.md isn't written over. Install RTK on the page (and magpie library rtk install) installs rtk with Homebrew, winget on Windows, or rtk's own script into ~/.local/bin, only when clicked

This commit is contained in:
yetone committed 2026-09-26 21:53:05 +08:00
1 parent 2eb5015df3
commit 0218eeb5ca
7 files changed
+555 -99

No files matched your search

+6 -1
View File
@@ -821,7 +821,12 @@ const I18N = {
"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,或见其官网),再回到这里。",
"RTK isn't installed. magpie can install it for you, or get it from its site.": "尚未安装 RTK。magpie 可以帮你安装,也可以去官网获取。",
"Installing RTK… this can take a minute.": "正在安装 RTK…可能需要一分钟。",
"Install RTK": "安装 RTK",
"RTK is installed — switch it on for your agents below": "RTK 已安装 — 在下方为你的 agent 打开",
"RTK missing": "RTK 缺失",
"{agent}'s hook calls rtk, which isn't installed, so its shell commands fail. Install RTK, or switch this off.": "{agent} 的 hook 会调用 rtk,但 rtk 没有安装,它的 shell 命令会失败。请安装 RTK,或关掉这个开关。",
"Get RTK": "获取 RTK",
"Check again": "重新检测",
"restart an agent after switching it": "切换后需重启对应 Agent",
+1
View File
@@ -275,3 +275,4 @@ body.window .lib-head { top: -2px; margin: -2px -16px 0; padding: 8px 16px 4px;
.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; }
.lib-rtk-cmd { margin: -4px 12px 10px; font: 11.5px var(--code); color: var(--muted); user-select: text; }
+28 -5
View File
@@ -276,7 +276,8 @@
// 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.
// each agent switched on, takes out what it wrote for one switched off
// (with or without rtk), 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;
@@ -310,10 +311,17 @@
: 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);
card.append(el("p", "lib-rtk-gain", rtkInstalling
? t("Installing RTK… this can take a minute.")
: t("RTK isn't installed. magpie can install it for you, or get it from its site.")));
if (rtk.install) card.append(el("p", "lib-rtk-cmd", rtk.install));
const acts = el("div", "lib-acts");
acts.append(button(t("Get RTK"), "action", () => browse(rtk.url)), button(t("Check again"), "", () => { rtk = null; render(); }));
if (rtk.install) {
const ib = button(rtkInstalling ? t("Installing…") : t("Install RTK"), "action", installRTK);
ib.disabled = rtkInstalling;
acts.append(ib);
}
acts.append(button(t("Get RTK"), rtk.install ? "" : "action", () => browse(rtk.url)), button(t("Check again"), "", () => { rtk = null; render(); }));
card.append(acts);
}
body.append(card);
@@ -327,8 +335,10 @@
const who = el("div", "who");
who.append(el("div", "name", a.name));
row.append(icon(a.icon), who, el("span", "grow"));
// its hook calls an rtk that isn't there: switching it off still works
if (a.on && !rtk.path) row.append(tag(t("RTK missing"), "warn", t("{agent}'s hook calls rtk, which isn't installed, so its shell commands fail. Install RTK, or switch this off.", { agent: a.name })));
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;
if ((!rtk.path && !a.on) || rtkBusy.has(a.id) || rtkInstalling) sw.disabled = true;
row.append(sw);
list.append(row);
}
@@ -348,6 +358,19 @@
rtkBusy.delete(a.id);
render();
}
let rtkInstalling = false;
async function installRTK() {
rtkInstalling = true;
render();
try {
rtk = await api("library/rtk/install", {});
status(t("RTK is installed — switch it on for your agents below"), "ok", 6000);
} catch (e) {
status(e.message, "err", 10000);
}
rtkInstalling = false;
render();
}
function tokens(n) {
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
if (n >= 1e3) return (n / 1e3).toFixed(1) + "k";
+10 -1
View File
@@ -131,7 +131,8 @@ func libraryRoutes(mux *http.ServeMux, w Windows) {
}
writeJSON(rw, p)
})
// RTK: which agents have its hook, and switching one on or off
// 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) {
writeJSON(rw, library.ReadRTK())
})
@@ -151,6 +152,14 @@ func libraryRoutes(mux *http.ServeMux, w Windows) {
}
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)
})
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 == "" {
+356 -82
View File
@@ -4,25 +4,30 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
"sync"
"time"
"github.com/yetone/magpie/internal/agent"
"github.com/yetone/magpie/internal/edit"
"github.com/yetone/magpie/internal/proc"
"gopkg.in/yaml.v3"
)
// 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.
// agents switched on, takes out what it wrote for those switched off, 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"
@@ -34,9 +39,15 @@ type rtkSpec struct {
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
// files are those the installer writes and remove takes back, kept
// aside by magpie first
files func(a *agent.Agent) []string
// remove takes out what the installer put in, and only that. It is
// magpie's own, not rtk init --uninstall: it works with rtk gone (its
// hooks left behind then fail every command), and rtk's uninstaller
// deletes Gemini's GEMINI.md whole and takes Claude Code, OpenCode and
// Cursor away together.
remove func(a *agent.Agent) error
// 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
@@ -55,82 +66,288 @@ func exists(path string) bool {
return err == nil
}
// rtkRef is the line rtk adds to an instructions file to pull its own in:
// @RTK.md, or @ and RTK.md's whole path.
func rtkRef(line string) bool {
t := strings.TrimSpace(line)
return strings.HasPrefix(t, "@") && (t == "@RTK.md" || strings.HasSuffix(t, "/RTK.md") || strings.HasSuffix(t, `\RTK.md`))
}
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")}
files: func(*agent.Agent) []string {
d := claudeDir()
return []string{filepath.Join(d, "settings.json"), filepath.Join(d, "CLAUDE.md"), filepath.Join(d, "RTK.md")}
},
remove: func(*agent.Agent) error {
d := claudeDir()
return errors.Join(
dropHook(filepath.Join(d, "settings.json"), "hooks.PreToolUse", "rtk hook claude"),
dropLines(filepath.Join(d, "CLAUDE.md"), rtkRef),
rm(filepath.Join(d, "RTK.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")}
files: func(*agent.Agent) []string {
d := codexDir()
return []string{filepath.Join(d, "hooks.json"), filepath.Join(d, "AGENTS.md"), filepath.Join(d, "RTK.md")}
},
remove: func(*agent.Agent) error {
d := codexDir()
return errors.Join(
dropHook(filepath.Join(d, "hooks.json"), "hooks.PreToolUse", "rtk hook codex"),
dropLines(filepath.Join(d, "AGENTS.md"), rtkRef),
rm(filepath.Join(d, "RTK.md")))
},
dir: func(*agent.Agent) string { return codexDir() },
},
"gemini": {
flags: []string{"--gemini"}, patch: true,
// --hook-only: without it the installer writes its own GEMINI.md
// over the user's
flags: []string{"--gemini", "--hook-only"}, 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")}
files: func(*agent.Agent) []string {
d := filepath.Join(home(), ".gemini")
return []string{filepath.Join(d, "settings.json"), filepath.Join(d, "hooks", "rtk-hook-gemini.sh"), filepath.Join(d, "hooks", ".rtk-hook.sha256")}
},
remove: func(*agent.Agent) error {
d := filepath.Join(home(), ".gemini")
err := errors.Join(
dropHook(filepath.Join(d, "settings.json"), "hooks.BeforeTool", "rtk-hook-gemini"),
rm(filepath.Join(d, "hooks", "rtk-hook-gemini.sh"), filepath.Join(d, "hooks", ".rtk-hook.sha256")))
os.Remove(filepath.Join(d, "hooks")) // when nothing else is in it
return err
},
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) },
has: func(a *agent.Agent) bool { return exists(opencodePlugin(a)) },
files: func(a *agent.Agent) []string { return []string{opencodePlugin(a)} },
remove: func(a *agent.Agent) error { return rm(opencodePlugin(a)) },
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") },
files: func(*agent.Agent) []string { return []string{filepath.Join(home(), ".cursor", "hooks.json")} },
remove: func(*agent.Agent) error {
return dropHook(filepath.Join(home(), ".cursor", "hooks.json"), "hooks.preToolUse", "rtk hook cursor")
},
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")}
files: func(*agent.Agent) []string {
d := filepath.Join(home(), ".copilot")
return []string{filepath.Join(d, "hooks", "rtk-rewrite.json"), filepath.Join(d, "copilot-instructions.md")}
},
remove: func(*agent.Agent) error {
d := filepath.Join(home(), ".copilot")
return errors.Join(
rm(filepath.Join(d, "hooks", "rtk-rewrite.json")),
dropBlock(filepath.Join(d, "copilot-instructions.md"), "<!-- rtk-instructions", "<!-- /rtk-instructions -->"))
},
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") },
},
"pi": extensionSpec("pi"),
"omp": extensionSpec("omp"),
"hermes": {
flags: []string{"--agent", "hermes"}, patch: true,
has: func(*agent.Agent) bool {
return exists(filepath.Join(home(), ".hermes", "plugins", "rtk-rewrite", "plugin.yaml"))
has: func(*agent.Agent) bool { return exists(filepath.Join(hermesPlugin(), "plugin.yaml")) },
files: func(*agent.Agent) []string {
return []string{filepath.Join(home(), ".hermes", "config.yaml"), filepath.Join(hermesPlugin(), "plugin.yaml"), filepath.Join(hermesPlugin(), "__init__.py")}
},
touches: func(*agent.Agent) []string { return []string{filepath.Join(home(), ".hermes", "config.yaml")} },
dir: func(*agent.Agent) string { return filepath.Join(home(), ".hermes") },
remove: func(*agent.Agent) error {
return errors.Join(os.RemoveAll(hermesPlugin()), dropHermesPlugin(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"}
func opencodePlugin(a *agent.Agent) string {
return filepath.Join(filepath.Dir(a.Path), "plugins", "rtk.ts")
}
func hermesPlugin() string { return filepath.Join(home(), ".hermes", "plugins", "rtk-rewrite") }
// extensionSpec is pi's and omp's: an extension of rtk's own, and nothing else.
func extensionSpec(id string) rtkSpec {
ext := func() string { return filepath.Join(home(), "."+id, "agent", "extensions", "rtk.ts") }
return rtkSpec{
flags: []string{"--agent", id}, patch: true,
has: func(*agent.Agent) bool { return exists(ext()) },
files: func(*agent.Agent) []string { return []string{ext()} },
remove: func(*agent.Agent) error { return rm(ext()) },
dir: func(*agent.Agent) string { return filepath.Join(home(), "."+id, "agent") },
}
}
func rm(paths ...string) error {
var errs []error
for _, p := range paths {
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
// dropHook takes the hooks whose command has mark out of the array at key:
// Claude Code's, Codex's and Gemini's are groups, each with its own hooks,
// a group left with none going too; Cursor's are the hooks themselves. An
// array left empty goes, and hooks with it when that leaves it empty.
func dropHook(path, key, mark string) error {
raw, ok := edit.GetJSON(path, key)
if !ok {
return nil
}
var list []json.RawMessage
if json.Unmarshal([]byte(raw), &list) != nil {
return fmt.Errorf("%s: %s isn't a list", path, key)
}
var keep []json.RawMessage
changed := false
for _, e := range list {
var h struct {
Command string `json:"command"`
Hooks []json.RawMessage `json:"hooks"`
}
json.Unmarshal(e, &h)
if strings.Contains(h.Command, mark) {
changed = true
continue
}
if h.Hooks != nil && bytes.Contains(e, []byte(mark)) {
var inner []json.RawMessage
for _, x := range h.Hooks {
var c struct {
Command string `json:"command"`
}
json.Unmarshal(x, &c)
if !strings.Contains(c.Command, mark) {
inner = append(inner, x)
}
}
if len(inner) < len(h.Hooks) {
changed = true
if len(inner) == 0 {
continue
}
var g map[string]json.RawMessage
json.Unmarshal(e, &g)
g["hooks"], _ = json.Marshal(inner)
e, _ = json.Marshal(g)
}
}
keep = append(keep, e)
}
if !changed {
return nil
}
if len(keep) > 0 {
return edit.SetJSON(path, edit.KV{Path: key, Value: keep})
}
if err := edit.DelJSON(path, key); err != nil {
return err
}
parent := key[:strings.LastIndex(key, ".")]
if v, ok := edit.GetJSON(path, parent); ok && strings.TrimSpace(v) == "{}" {
return edit.DelJSON(path, parent)
}
return nil
}
// dropLines takes out the lines match says are rtk's, with the blank line
// rtk put before one; a file left with nothing in it goes.
func dropLines(path string, match func(string) bool) error {
b, err := os.ReadFile(path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
ls := strings.Split(string(b), "\n")
var out []string
for i, l := range ls {
if !match(l) {
out = append(out, l)
continue
}
next := ""
if i+1 < len(ls) {
next = ls[i+1]
}
if n := len(out); n > 0 && strings.TrimSpace(out[n-1]) == "" && strings.TrimSpace(next) == "" {
out = out[:n-1]
}
}
if len(out) == len(ls) {
return nil
}
return writeOrRemove(path, strings.Join(out, "\n"))
}
// dropBlock takes out the lines from the one with open to the one with
// close, as dropLines does.
func dropBlock(path, open, close string) error {
in := false
return dropLines(path, func(l string) bool {
switch {
case strings.Contains(l, open):
in = true
case in && strings.Contains(l, close):
in = false
return true
}
return in
})
}
func writeOrRemove(path, s string) error {
if strings.TrimSpace(s) == "" {
return os.Remove(path)
}
return edit.WriteAtomic(path, []byte(s))
}
// dropHermesPlugin takes rtk-rewrite out of Hermes' plugins.enabled, and
// the keys rtk added for it when that leaves them empty.
func dropHermesPlugin(path string) error {
if err := dropLines(path, func(l string) bool { return strings.TrimSpace(l) == "- rtk-rewrite" }); err != nil {
return err
}
var c struct {
Plugins map[string]any `yaml:"plugins"`
}
b, err := os.ReadFile(path)
if err != nil || yaml.Unmarshal(b, &c) != nil || c.Plugins == nil {
return nil
}
if l, ok := c.Plugins["enabled"]; ok && (l == nil || fmt.Sprint(l) == "[]") {
if err := edit.DelYAML(path, "plugins.enabled"); err != nil {
return err
}
delete(c.Plugins, "enabled")
}
if len(c.Plugins) == 0 {
return edit.DelYAML(path, "plugins")
}
return nil
}
// RTKAgent is one agent rtk can be given to, and whether it has it.
type RTKAgent struct {
@@ -155,6 +372,8 @@ type RTKView struct {
Gain *RTKGain `json:"gain,omitempty"`
Agents []RTKAgent `json:"agents"`
URL string `json:"url"`
// Install is the command Install RTK runs, shown before it is clicked
Install string `json:"install,omitempty"`
// Restart are the agents a change reached, to be restarted to see it
Restart []string `json:"restart,omitempty"`
Backup string `json:"backup,omitempty"`
@@ -162,12 +381,23 @@ type RTKView struct {
var rtkMu sync.Mutex
// rtkPath is rtk's, "" when it isn't installed; one installed since magpie
// started may not be on its PATH yet, so where the installers put it is
// looked in too (Homebrew's are on it from the start: proc.UserPath).
func rtkPath() string {
p, err := exec.LookPath("rtk")
if err != nil {
return ""
if p, err := exec.LookPath("rtk"); err == nil {
return p
}
return p
name, dirs := "rtk", []string{filepath.Join(home(), ".local", "bin"), filepath.Join(home(), ".cargo", "bin")}
if runtime.GOOS == "windows" {
name, dirs = "rtk.exe", []string{filepath.Join(os.Getenv("LOCALAPPDATA"), "Microsoft", "WinGet", "Links"), filepath.Join(home(), ".cargo", "bin")}
}
for _, d := range dirs {
if p := filepath.Join(d, name); exists(p) {
return p
}
}
return ""
}
func rtkRun(bin string, args ...string) (string, error) { return rtkRunEnv(bin, nil, args...) }
@@ -215,6 +445,14 @@ func rtkAgents() []*agent.Agent {
// have it.
func ReadRTK() *RTKView {
v := &RTKView{Agents: []RTKAgent{}, URL: RTKURL, Path: rtkPath()}
if v.Path == "" {
if c := rtkInstaller(); c != nil {
v.Install = strings.Join(c, " ")
if c[0] == "sh" {
v.Install = c[2]
}
}
}
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)})
}
@@ -241,45 +479,35 @@ func ReadRTK() *RTKView {
return v
}
// SetRTK gives rtk to an agent, or takes it away, with rtk's own installer.
// SetRTK gives rtk to an agent with rtk's own installer, or takes it away
// by taking out what that put in — which needs no rtk, so an agent left
// with the hook of an rtk since removed can be put right.
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)
var a *agent.Agent
for _, x := range rtkAgents() {
if x.ID == id {
a = x
}
}
sp, ok := rtkSpecs[id]
switch {
case !ok:
return nil, fmt.Errorf("rtk has no hook for %s", id)
case a == nil:
return nil, fmt.Errorf("%s isn't installed", id)
case sp.has(a) == on:
return ReadRTK(), nil
}
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)
}
}
bin := rtkPath()
if on && bin == "" {
return nil, fmt.Errorf("rtk isn't installed — install it first (%s)", RTKURL)
}
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
}
for _, p := range sp.files(a) {
if err := b.keep(a.ID, p); err != nil {
return nil, err
}
}
if on {
@@ -287,23 +515,17 @@ func SetRTK(id string, on bool) (*RTKView, error) {
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 err := sp.remove(a); err != nil {
return nil, fmt.Errorf("taking rtk out of %s: %w", a.Name, err)
}
if sp.has(a) {
return nil, fmt.Errorf("rtk's uninstaller left its hook in %s", a.Name)
return nil, fmt.Errorf("rtk's hook is still 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
}
@@ -336,6 +558,58 @@ func rtkInstall(bin string, a *agent.Agent) error {
return nil
}
// rtkScript is rtk's own installer, which puts it in ~/.local/bin.
const rtkScript = "https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh"
// rtkInstaller is how rtk is installed here: with Homebrew when there is
// one, winget on Windows, else rtk's own script; nil when there's no way.
func rtkInstaller() []string {
if runtime.GOOS == "windows" {
if _, err := exec.LookPath("winget"); err == nil {
return []string{"winget", "install", "--id", "rtk-ai.rtk", "--exact", "--silent", "--accept-package-agreements", "--accept-source-agreements", "--disable-interactivity"}
}
return nil
}
if _, err := exec.LookPath("brew"); err == nil {
return []string{"brew", "install", "rtk"}
}
if _, err := exec.LookPath("curl"); err == nil {
return []string{"sh", "-c", "curl -fsSL " + rtkScript + " | sh"}
}
return nil
}
// InstallRTK installs rtk, when the user asks for it.
func InstallRTK() (*RTKView, error) {
rtkMu.Lock()
defer rtkMu.Unlock()
if rtkPath() != "" {
return ReadRTK(), nil
}
c := rtkInstaller()
if c == nil {
return nil, fmt.Errorf("no way to install rtk here — get it from %s", RTKURL)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
cmd := proc.CommandContext(ctx, c[0], c[1:]...)
cmd.Stdin = nil
cmd.Env = append(os.Environ(), "HOMEBREW_NO_AUTO_UPDATE=1", "NONINTERACTIVE=1")
out, err := cmd.CombinedOutput()
if err != nil {
text := strings.TrimSpace(string(out))
if text == "" {
text = err.Error()
}
return nil, fmt.Errorf("%s: %s", strings.Join(c, " "), lastLines(text, 4))
}
v := ReadRTK()
if v.Path == "" {
return nil, fmt.Errorf("%s ran, but rtk still isn't found: %s", strings.Join(c, " "), lastLines(string(out), 3))
}
return v, 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)
+138 -8
View File
@@ -1,16 +1,17 @@
package library
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"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.
// Claude Code as well. Its uninstaller isn't there: magpie doesn't use it.
const fakeRTK = `#!/bin/sh
c="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
claude() { [ -d "$c" ] && echo '{"hooks":{"PreToolUse":[{"hooks":[{"command":"rtk hook claude"}]}]}}' > "$c/settings.json"; }
@@ -23,9 +24,6 @@ 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
`
@@ -73,7 +71,7 @@ func TestRTK(t *testing.T) {
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
// taking it out of Claude Code leaves the other two as they are
set("claude", false)
want(false, true, true)
set("claude", true)
@@ -89,7 +87,139 @@ func TestRTK(t *testing.T) {
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")
if _, err := SetRTK("opencode", true); err == nil {
t.Fatal("switched on without rtk installed")
}
// the hook of an rtk since removed can still be taken out
set("claude", false)
want(false, false, false)
}
// TestRTKRemove: switching an agent off takes out just what rtk's installer
// put in, as rtk 0.50 writes it, with no rtk to do it.
func TestRTKRemove(t *testing.T) {
h := sandbox(t)
hook := func(key, matcher, cmd string) string {
return `"hooks": {
"` + key + `": [
{
"matcher": "` + matcher + `",
"hooks": [
{
"type": "command",
"command": "` + cmd + `"
}
]
}
]
}`
}
claude := filepath.Join(h, ".claude")
write(t, filepath.Join(claude, "settings.json"), `{
"model": "opus",
"hooks": {
"PreToolUse": [
{
"matcher": "Write",
"hooks": [{"type": "command", "command": "lint"}]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "rtk hook claude"
}
]
}
]
}
}
`)
write(t, filepath.Join(claude, "CLAUDE.md"), "# mine\n\n@RTK.md\n")
write(t, filepath.Join(claude, "RTK.md"), "# RTK\n")
codex := filepath.Join(h, ".codex")
write(t, filepath.Join(codex, "hooks.json"), "{\n "+hook("PreToolUse", "Bash", "rtk hook codex")+"\n}\n")
write(t, filepath.Join(codex, "AGENTS.md"), "@"+filepath.Join(codex, "RTK.md")+"\n")
write(t, filepath.Join(codex, "RTK.md"), "# RTK\n")
gemini := filepath.Join(h, ".gemini")
sh := filepath.Join(gemini, "hooks", "rtk-hook-gemini.sh")
write(t, filepath.Join(gemini, "settings.json"), "{\n \"theme\": \"dark\",\n "+hook("BeforeTool", "run_shell_command", sh)+"\n}\n")
write(t, sh, "#!/bin/sh\n")
write(t, filepath.Join(gemini, "hooks", ".rtk-hook.sha256"), "x\n")
write(t, filepath.Join(gemini, "GEMINI.md"), "# g\n")
write(t, filepath.Join(h, ".cursor", "hooks.json"), `{"version":1,"hooks":{"preToolUse":[{"command":"rtk hook cursor","matcher":"Shell"}]}}`)
copilot := filepath.Join(h, ".copilot")
write(t, filepath.Join(copilot, "hooks", "rtk-rewrite.json"), "{}")
write(t, filepath.Join(copilot, "copilot-instructions.md"), "# c\n\n<!-- rtk-instructions v2 -->\nAlways prefix shell commands with rtk\n<!-- /rtk-instructions -->\n")
write(t, filepath.Join(h, ".pi", "agent", "extensions", "rtk.ts"), "x")
write(t, filepath.Join(h, ".config", "opencode", "plugins", "rtk.ts"), "x")
ids := []string{"claude", "codex", "gemini", "cursor", "copilot", "pi", "opencode"}
v := ReadRTK()
if v.Path != "" {
t.Fatalf("rtk found at %s", v.Path)
}
on := map[string]bool{}
for _, a := range v.Agents {
on[a.ID] = a.On
}
for _, id := range ids {
if !on[id] {
t.Fatalf("%s's hook isn't seen: %v", id, on)
}
if _, err := SetRTK(id, false); err != nil {
t.Fatal(err)
}
}
for _, a := range ReadRTK().Agents {
if a.On {
t.Fatalf("%s still has rtk", a.ID)
}
}
jsonIs := func(p, want string) {
t.Helper()
var got, w any
if err := json.Unmarshal([]byte(read(t, p)), &got); err != nil {
t.Fatalf("%s: %v\n%s", p, err, read(t, p))
}
json.Unmarshal([]byte(want), &w)
if !reflect.DeepEqual(got, w) {
t.Fatalf("%s:\n%s\nwant %s", p, read(t, p), want)
}
}
jsonIs(filepath.Join(claude, "settings.json"), `{"model":"opus","hooks":{"PreToolUse":[{"matcher":"Write","hooks":[{"type":"command","command":"lint"}]}]}}`)
jsonIs(filepath.Join(codex, "hooks.json"), `{}`)
jsonIs(filepath.Join(gemini, "settings.json"), `{"theme":"dark"}`)
jsonIs(filepath.Join(h, ".cursor", "hooks.json"), `{"version":1}`)
for p, want := range map[string]string{
filepath.Join(claude, "CLAUDE.md"): "# mine\n",
filepath.Join(copilot, "copilot-instructions.md"): "# c\n",
filepath.Join(gemini, "GEMINI.md"): "# g\n",
filepath.Join(claude, "RTK.md"): "",
filepath.Join(codex, "AGENTS.md"): "",
filepath.Join(codex, "RTK.md"): "",
sh: "",
filepath.Join(copilot, "hooks", "rtk-rewrite.json"): "",
} {
if got := read(t, p); got != want {
t.Errorf("%s = %q, want %q", p, got, want)
}
}
}
func TestDropHermesPlugin(t *testing.T) {
p := filepath.Join(t.TempDir(), "config.yaml")
for in, want := range map[string]string{
"model: x\nplugins:\n enabled:\n - rtk-rewrite\n": "model: x\n",
"model: x\nplugins:\n enabled:\n - a\n - rtk-rewrite\n": "model: x\nplugins:\n enabled:\n - a\n",
} {
write(t, p, in)
if err := dropHermesPlugin(p); err != nil {
t.Fatal(err)
}
if got := read(t, p); got != want {
t.Errorf("%q → %q, want %q", in, got, want)
}
}
}
+16 -2
View File
@@ -20,7 +20,8 @@ const libraryUsage = `magpie library what the library gives
magpie library skill agents <name> <a,b…|none>
magpie library skill rm <name> (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 <agent> switch it, with RTK's own installer
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)
`
// libraryCmd is magpie library …: the instructions, MCP servers and skills
@@ -250,6 +251,12 @@ func rtkCmd(args []string) error {
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) == 2 && (args[0] == "on" || args[0] == "off"):
id, err := library.RTKTakes(args[1])
if err != nil {
@@ -263,6 +270,9 @@ func rtkCmd(args []string) error {
}
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))
if g := v.Gain; g != nil {
@@ -271,10 +281,14 @@ func rtkCmd(args []string) error {
}
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")
}
}
fmt.Println(" ", mark, a.Name)
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"))