Files
magpie/internal/library/rtk.go
T

629 lines
19 KiB
Go

package library
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, 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"
// 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
// 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
// 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
}
// 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")
},
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") },
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": {
// --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")
},
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(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")
},
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")) },
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": extensionSpec("pi"),
"omp": extensionSpec("omp"),
"hermes": {
flags: []string{"--agent", "hermes"}, patch: true,
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")}
},
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") },
},
}
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 {
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"`
// 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"`
}
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 {
if p, err := exec.LookPath("rtk"); err == nil {
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...) }
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()}
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)})
}
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 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()
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
}
bin := rtkPath()
if on && bin == "" {
return nil, fmt.Errorf("rtk isn't installed — install it first (%s)", RTKURL)
}
b := newBackups()
for _, p := range sp.files(a) {
if err := b.keep(a.ID, p); err != nil {
return nil, err
}
}
if on {
if err := rtkInstall(bin, a); err != nil {
return nil, err
}
} else {
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 hook is still in %s", a.Name)
}
}
if b.dir != "" {
pruneBackups()
}
v := ReadRTK()
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
}
// 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)
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
}