Files
magpie/internal/library/rtk.go
T
yetone aad799eae6 library: an RTK release winget or Homebrew doesn't have yet says "v0.51.0 is out · waiting for winget" with no Upgrade, until it does (#1025, Fermin214)
On Windows, RTK installed by winget was 0.50.0 and GitHub had 0.51.0, which winget didn't yet. Library → RTK showed "v0.51.0 可升级" with Upgrade; Upgrade ran winget upgrade, which rightly said "winget's RTK is 0.50.0 so far…", and the card went back to "可升级" with Upgrade. The card compared rtk's version only with GitHub's latest release, never with what the package manager that installed it has, and the upgrade's answer was forgotten as soon as the card was read again.

RTKView.CheckLatest now also asks, when the latest release is newer than this rtk and it was installed by winget or Homebrew, what that package manager has: winget show --id rtk-ai.rtk --exact (the version is the first one after the line naming the package, as winget's labels are in the system's language), and brew info --json=v2 rtk without auto-update, as the Agents page's CLI updates read Homebrew. An upgrade through either records the version it left as the newest that package manager has. The answer is kept six hours (a failure fifteen minutes) and is asked again after that. When it has nothing newer than this rtk, the view says waiting (winget or Homebrew) and waitingHas. The card then shows "v{v} is out · waiting for {via}" (zh "v{v} 已发布 · 等待 {via}", ja, de), with a tooltip saying what it has, and no Upgrade button. Once it has the release, Upgrade is back. A package manager that can't be asked, or doesn't say, leaves Upgrade offered as before. GET /api/library/rtk runs the whole check within its 3s budget. magpie library rtk prints the same. Cargo (built from rtk's repository) and rtk's own script (its GitHub releases) can't lag, so they are unchanged. The Agents page's CLI updates already compare with the installing channel's own latest (Homebrew's or npm's), so they don't have this bug. The Japanese "v{v} is out" is shortened to "v{v} 公開済み", because with Upgrade beside it the card head overflowed at 440px.

TestRTKWaitsForWinget (a fake winget whose show prints Chinese labels: unaskable leaves Upgrade; asked, it waits; the reporter's upgrade waits with the "so far" note, and so does the card read again; winget given 0.51.0 and asked again, Upgrade is back and works) and the extended TestRTKUpgradeBrew (Homebrew's 0.49.0 after brew upgrade waits, on the card read again too) fail without the change (waiting <nil>) and pass. TestRTKChannelVersion covers winget show in English, German and Chinese with spinner frames, no package, and brew info's JSON. -race -count=10 on them. library-rtk-upgrade.test.cjs's new test (Upgrade finds winget behind: the waiting tag, no button, the note; a reload still waits; winget has it: Upgrade back; card head and page fit at 440px) fails without the library.js change and passes on Chromium and WebKit in en, zh, ja and de. i18n-keys, gui-ja, gui-de and the library-rtk-* tests pass on both engines. go test -tags nogui ./internal/..., go vet, gofmt -l, darwin, linux (nogui) and windows builds pass. Not tried with a real winget or on Windows: winget show's output is taken from its documented format, and the version line is assumed to follow the package line in every language.
2026-10-06 20:56:52 +08:00

903 lines
30 KiB
Go

package library
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"slices"
"strings"
"sync"
"time"
"github.com/pelletier/go-toml/v2"
"github.com/yetone/magpie/internal/agent"
"github.com/yetone/magpie/internal/appdir"
"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
// blocked says why rtk's installer mustn't be run for the agent as it
// is on this machine, "" when it can be
blocked func() string
}
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"},
blocked: codexBlocked,
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, blocked: openCodeBlocked,
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") },
},
}
// rtkNoHook are agents RTK has no hook for, and why: the Library lists
// them all the same, so one isn't simply missing as if magpie hadn't seen
// it (Discord, lc: rtk 没有识别 deepseek harness).
//
// DeepSeek Harness: RTK works by rewriting the shell command an agent is
// about to run (git status → rtk git status), and dsh has no way for a hook
// to do that — its pre-tool hooks only allow, deny or ask, and its Claude
// Code hook bridge drops updatedInput (deepseek-harness's
// pre-tool-input-rewrite note is only proposed). rtk init has no
// --agent dsh either (rtk 0.51; rtk-ai/rtk#3847, and #3934 only adds
// instructions to AGENTS.md, which rewrite nothing).
var rtkNoHook = map[string]string{
"dsh": "RTK has no hook for DeepSeek Harness yet: RTK works by rewriting the shell command an agent is about to run, and dsh's hooks can only allow or deny a command, not change it; rtk init has no --agent dsh either (github.com/rtk-ai/rtk/issues/3847)",
}
func opencodePlugin(a *agent.Agent) string {
return filepath.Join(filepath.Dir(a.Path), "plugins", "rtk.ts")
}
// openCodeBlocked: rtk's OpenCode plugin (hooks/opencode/rtk.ts, which
// rtk init --opencode writes to plugins/rtk.ts, up to rtk 0.50 and its
// develop branch) is written for OpenCode 1's plugin API — a named export
// of a function. OpenCode 2 reads the same plugins folder but takes only a
// default export of {id, setup|effect}, and turns rtk's away: "Plugin must
// export a default definition with an id and an effect or setup function"
// (rtk-ai/rtk#4311, #3898; the fix, #4187, isn't merged). So with an
// OpenCode 2 here, rtk isn't given to OpenCode: it would only add a plugin
// that fails to load.
func openCodeBlocked() string {
v := openCodeVersion()
if major, _, _ := strings.Cut(v, "."); major != "" && major != "0" && major != "1" {
return fmt.Sprintf("RTK doesn't support OpenCode 2 yet: its plugin is written for OpenCode 1, and OpenCode %s refuses to load it (\"Plugin must export a default definition with an id and an effect or setup function\", github.com/rtk-ai/rtk/issues/4311)", v)
}
return ""
}
// codexBlocked: rtk has a hook for Codex from 0.50.0 on (rtk hook codex in
// hooks.json); before that rtk init --codex only puts @RTK.md in AGENTS.md,
// which rewrites no command, so an older rtk isn't run for Codex.
func codexBlocked() string {
v := rtkVersion()
if v == "" || !older(v, 0, 50) {
return ""
}
return fmt.Sprintf("Codex's hook needs RTK 0.50 or newer, and this RTK is %s: older ones only add @RTK.md to AGENTS.md, which rewrites no command. Update RTK (brew upgrade rtk, or its installer again), then switch it on", v)
}
// older says whether version v is before major.minor.
func older(v string, major, minor int) bool {
var a, b int
if n, _ := fmt.Sscanf(v, "%d.%d", &a, &b); n < 2 {
return false
}
return a < major || a == major && b < minor
}
// rtkVersion is the version the rtk here says it is, "" when there's none
// or it doesn't say; a test sets it.
var rtkVersion = func() string {
bin := rtkPath()
if bin == "" {
return ""
}
out, err := rtkRun(bin, "--version")
if err != nil {
return ""
}
if m := semver.FindStringSubmatch(out); m != nil {
return m[1]
}
return ""
}
// semver finds the version in what opencode --version prints: OpenCode 1
// prints "1.18.32", OpenCode 2 "opencode v2.0.18".
var semver = regexp.MustCompile(`(?:^|[^\w.])v?(\d+\.\d+\.\d+\S*)`)
// openCodeVersion is the version the opencode on this machine says it is,
// "" when there's none or it doesn't say; a test sets it. OpenCode 2's
// installer puts it in ~/.opencode/bin as opencode, with opencode2 beside
// it running it.
var openCodeVersion = func() string {
name := "opencode"
if runtime.GOOS == "windows" {
name = "opencode.exe"
}
bin, err := exec.LookPath("opencode")
if err != nil {
bin = filepath.Join(home(), ".opencode", "bin", name)
if !exists(bin) {
return ""
}
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := proc.ProbeContext(ctx, bin, "--version")
cmd.Stdin = nil
out, err := cmd.Output()
if err != nil {
return ""
}
if m := semver.FindStringSubmatch(string(out)); m != nil {
return m[1]
}
return ""
}
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 edit.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"`
// Blocked says why rtk can't be switched on for it here (OpenCode 2);
// one that has it can still be switched off
Blocked string `json:"blocked,omitempty"`
// NoHook: rtk has no hook for it at all (rtkNoHook), and Blocked says why
NoHook bool `json:"noHook,omitempty"`
}
// 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"`
}
// RTKDay is what rtk saved on one day it ran commands.
type RTKDay struct {
Date string `json:"date"` // 2006-01-02, local
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"`
// GainErr is why what rtk saved isn't known: rtk gain failed or
// printed nothing readable
GainErr string `json:"gainErr,omitempty"`
// CodexSandbox is the Windows sandbox Codex runs its commands in,
// when it has rtk: what rtk saves there isn't counted in Gain
CodexSandbox string `json:"codexSandbox,omitempty"`
// Days are what it saved each day it ran commands, oldest first
Days []RTKDay `json:"days,omitempty"`
// Latest is rtk's latest release, "" when it isn't known (see
// CheckLatest); Upgrade the command Upgrade RTK runs, "" when magpie
// doesn't know how this rtk was installed
Latest string `json:"latest,omitempty"`
Upgrade string `json:"upgrade,omitempty"`
// Waiting is the package manager this rtk was installed with (winget,
// Homebrew) when Latest is out but it has nothing newer than this rtk
// yet, WaitingHas what it has: an upgrade through it would change
// nothing, so the page offers none (#1025)
Waiting string `json:"waiting,omitempty"`
WaitingHas string `json:"waitingHas,omitempty"`
// Note is what an upgrade left to say: Homebrew's rtk behind rtk's
// own release
Note string `json:"note,omitempty"`
// OffPath: rtk isn't on the PATH an agent started now gets, so its
// hooks, which run rtk by name, do nothing (Pi's says "rtk binary not
// found in PATH"). PathDir is where Put RTK on PATH makes it found — a
// link to it made in that folder (PathLink), or (Windows) that folder,
// rtk's own, added to the user's PATH; "" when magpie has nowhere
PathDir string `json:"pathDir,omitempty"`
PathLink bool `json:"pathLink,omitempty"`
OffPath bool `json:"offPath,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).
// winget's is in its package folder, linked from WinGet\Links only when
// winget could make the link. Found there, it may not be on the PATH the
// agents get: see rtkReached.
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" {
winget := filepath.Join(appdir.Getenv("LOCALAPPDATA"), "Microsoft", "WinGet")
name, dirs = "rtk.exe", []string{filepath.Join(winget, "Links"), filepath.Join(home(), ".cargo", "bin")}
if appdir.Getenv("LOCALAPPDATA") != "" {
pkgs, _ := filepath.Glob(filepath.Join(winget, "Packages", "rtk-ai.rtk_*"))
dirs = append(dirs, pkgs...)
}
}
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 = shown(c)
}
}
for _, a := range rtkAgents() {
sp := rtkSpecs[a.ID]
ra := RTKAgent{ID: a.ID, Name: a.Name, Icon: a.Icon, On: sp.has(a)}
if sp.blocked != nil {
ra.Blocked = sp.blocked()
}
v.Agents = append(v.Agents, ra)
}
for _, a := range agent.Detected() {
if why, ok := rtkNoHook[a.ID]; ok {
v.Agents = append(v.Agents, RTKAgent{ID: a.ID, Name: a.Name, Icon: a.Icon, Blocked: why, NoHook: true})
}
}
if v.Path == "" {
return v
}
if !rtkReached() {
v.OffPath = true
v.PathDir, v.PathLink = rtkPathDir(v.Path)
}
if out, err := rtkRun(v.Path, "--version"); err == nil {
v.Version = strings.TrimSpace(strings.TrimPrefix(out, "rtk"))
}
if c := upgraderOf(v.Path); c != nil {
v.Upgrade = shown(c)
}
v.Gain, v.Days, v.GainErr = rtkGain(v.Path)
if runtime.GOOS == "windows" {
for _, a := range v.Agents {
if a.ID == "codex" && a.On {
v.CodexSandbox = codexSandbox()
}
}
}
return v
}
// rtkGain is what rtk says it saved (rtk gain, from its history database:
// every command run through rtk on this machine by this user, from any
// agent or terminal, since its history began), and the days it saved it on,
// oldest first; nil and why when rtk didn't say, which the page shows rather
// than "nothing saved yet" (#741).
//
// Only its stdout is JSON: what rtk writes to stderr — a warning about a
// hook, a console code page, a corrupted table — went into the same buffer
// and made all of it unreadable, so it is read apart, and a line printed
// around the JSON object is passed over too.
func rtkGain(bin string) (*RTKGain, []RTKDay, string) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// --daily adds the days to the summary (rtk 0.28 on)
cmd := proc.CommandContext(ctx, bin, "gain", "--daily", "--format", "json")
cmd.Stdin = nil
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
if err := cmd.Run(); err != nil {
said := installerSaid(stderr.String()+"\n"+stdout.String(), 3)
if said == "" {
said = err.Error()
}
return nil, nil, "rtk gain: " + said
}
return parseGain(stdout.String())
}
func parseGain(out string) (*RTKGain, []RTKDay, string) {
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"`
Daily []struct {
Date string `json:"date"`
Commands int `json:"commands"`
Input int64 `json:"input_tokens"`
Saved int64 `json:"saved_tokens"`
Pct float64 `json:"savings_pct"`
} `json:"daily"`
}
i, j := strings.Index(out, "{"), strings.LastIndex(out, "}")
if i < 0 || j < i || json.Unmarshal([]byte(out[i:j+1]), &g) != nil || g.Summary == nil {
return nil, nil, strings.TrimSuffix("rtk gain printed no summary magpie can read: "+installerSaid(out, 2), ": ")
}
s := g.Summary
if s.Commands == 0 {
return nil, nil, ""
}
var days []RTKDay
for _, d := range g.Daily {
days = append(days, RTKDay{Date: d.Date, Commands: d.Commands, Input: d.Input, Saved: d.Saved, Pct: d.Pct})
}
slices.SortFunc(days, func(a, b RTKDay) int { return strings.Compare(a.Date, b.Date) })
return &RTKGain{Commands: s.Commands, Input: s.Input, Saved: s.Saved, Pct: s.Pct}, days, ""
}
// codexSandbox is the Windows sandbox Codex runs its commands in, from its
// config.toml ([windows] sandbox, or the features it replaced): "elevated"
// runs them as Codex's own sandbox account (CodexSandboxOffline/Online,
// started with that account's profile), so the rtk its hook puts in front
// of them keeps its history in that account's AppData, not the user's;
// "unelevated" runs them with a token that can't write the user's. Either
// way rtk gain here doesn't count them. "" when Codex doesn't sandbox them.
func codexSandbox() string {
raw, err := os.ReadFile(filepath.Join(codexDir(), "config.toml"))
if err != nil {
return ""
}
var c struct {
SandboxMode string `toml:"sandbox_mode"`
Windows struct {
Sandbox string `toml:"sandbox"`
} `toml:"windows"`
Features map[string]any `toml:"features"`
}
if toml.Unmarshal(raw, &c) != nil || c.SandboxMode == "danger-full-access" {
return ""
}
switch {
case c.Windows.Sandbox != "":
if c.Windows.Sandbox == "elevated" || c.Windows.Sandbox == "unelevated" {
return c.Windows.Sandbox
}
case c.Features["elevated_windows_sandbox"] == true:
return "elevated"
case c.Features["experimental_windows_sandbox"] == true, c.Features["enable_experimental_windows_sandbox"] == true:
return "unelevated"
}
return ""
}
// shown is a command as the page shows it: a script's own line.
func shown(c []string) string {
if c[0] == "sh" {
return c[2]
}
return strings.Join(c, " ")
}
// 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 && rtkNoHook[id] != "":
return nil, errors.New(rtkNoHook[id])
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
}
if on && sp.blocked != nil {
if why := sp.blocked(); why != "" {
return nil, errors.New(why)
}
}
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)
}
out, err := runInstaller(c, 5*time.Minute, "HOMEBREW_NO_AUTO_UPDATE=1")
if err != nil {
return nil, err
}
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 why := rtkNoHook[a.ID]; why != "" {
return "", errors.New(why)
}
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
}