library: RTK says when rtk isn't on the PATH the agents get, and puts it there when asked (#601, Moody-Sin: magpie好像没有正确安装RTK — Pi said "RTK disabled: rtk binary not found in PATH" while the Library showed RTK working) — rtk's hooks run it by name (Pi's extension probes rtk --version and turns itself off when that fails; Claude Code's and Codex's are rtk hook …; the commands they rewrite to are rtk git status and so on), but magpie looked for rtk in more places than an agent's PATH: ~/.local/bin and ~/.cargo/bin, which the app adds to its own PATH whether the user's shell has them or not (rtk's own install.sh puts it in ~/.local/bin and only warns when that isn't on PATH), and WinGet\Links on Windows, which winget doesn't always link into. So rtk was "installed", the agents were switched on with it by its whole path, and none of them could run it. Now the PATH a terminal opened now has is read — the login shell's (proc.LoginPath, the one UserPath already asks for, kept a minute) or on Windows the registry's (moved from provider's registryPath) — and when rtk isn't in it the RTK card says so, with what Put RTK on PATH will do, and each agent that has rtk is tagged Not on PATH (不在 PATH 中). Put RTK on PATH (magpie library rtk path) links rtk into the first of ~/.local/bin, ~/bin, /opt/homebrew/bin, /usr/local/bin, linuxbrew's bin, ~/.cargo/bin that is on that PATH and writable, or on Windows adds rtk's own folder to the user's PATH in the registry (a link needs Developer Mode, and a copy is left behind when winget upgrades rtk; winget's package folder doesn't change across upgrades) and tells Explorer; with no such folder it says which folder to add to PATH in the shell profile — shell profiles are never edited. rtk is also found in winget's package folder (WinGet\Packages\rtk-ai.rtk_*), and rtk's script upgrades a linked rtk where the link points, not over the link. TestRTKOffPath and library-rtk-path.test.cjs (en, zh) fail without the change. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build and vet, go test -tags nogui -count=1 ./... under a temp HOME pass but the known TestFXInState flake. The Windows registry path is built, not run on Windows.
This commit is contained in:
1 parent
4b5d6d725f
commit
9848a9062a
15 files changed
+536
-38
No files matched your search
@@ -2116,6 +2116,16 @@ const I18N = {
|
||||
"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,或关掉这个开关。",
|
||||
"Not on PATH": "不在 PATH 中",
|
||||
"{agent}'s hook runs rtk by name and can't find it, so RTK does nothing for it. Put RTK on PATH above.": "{agent} 的 hook 按名字调用 rtk,但找不到它,所以 RTK 对它不起作用。请在上方把 RTK 加到 PATH。",
|
||||
"RTK is in {dir}, which isn't on your PATH. Agents run rtk by name, so they can't find it and RTK does nothing for them (Pi says \"rtk binary not found in PATH\").": "RTK 在 {dir},但这个目录不在你的 PATH 中。Agent 是按名字调用 rtk 的,所以找不到它,RTK 对它们不起作用(Pi 会显示 \"rtk binary not found in PATH\")。",
|
||||
"Add {dir} to PATH in your shell profile, then check again.": "请在 shell 配置文件里把 {dir} 加到 PATH,然后重新检测。",
|
||||
"Put RTK on PATH links it into {dir}.": "“加到 PATH”会在 {dir} 里建一个指向它的链接。",
|
||||
"Put RTK on PATH adds {dir} to your user PATH.": "“加到 PATH”会把 {dir} 加到你的用户 PATH。",
|
||||
"Put RTK on PATH": "加到 PATH",
|
||||
"Putting RTK on PATH…": "正在加到 PATH…",
|
||||
"RTK is on your PATH — restart your agents, and the terminals they run in, to use it": "RTK 已在 PATH 中,重启 Agent 及其所在的终端后生效",
|
||||
"RTK is on your PATH": "RTK 已在 PATH 中",
|
||||
"Not for OpenCode 2": "不支持 OpenCode 2",
|
||||
"Update RTK": "需更新 RTK",
|
||||
"{agent}'s hook needs RTK 0.50 or newer: 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.": "{agent} 的 hook 需要 RTK 0.50 或更新版本:旧版只会在 AGENTS.md 里加一行 @RTK.md,并不会改写命令。请先更新 RTK(brew upgrade rtk,或重新运行它的安装脚本),再打开。",
|
||||
|
||||
@@ -697,6 +697,25 @@
|
||||
}
|
||||
card.append(ttl);
|
||||
if (rtk.note) card.append(el("p", "lib-rtk-note", rtk.note));
|
||||
// found where magpie looks, but not on the PATH the agents get: their
|
||||
// hooks run rtk by name, so it does nothing for them (#601)
|
||||
if (rtk.offPath) {
|
||||
const dir = tilde(rtk.path.replace(/[\\/][^\\/]*$/, ""));
|
||||
card.append(el("p", "lib-rtk-note lib-rtk-offpath", t("RTK is in {dir}, which isn't on your PATH. Agents run rtk by name, so they can't find it and RTK does nothing for them (Pi says \"rtk binary not found in PATH\").", { dir })));
|
||||
card.append(el("p", "lib-rtk-cmd", !rtk.pathDir
|
||||
? t("Add {dir} to PATH in your shell profile, then check again.", { dir })
|
||||
: rtk.pathLink
|
||||
? t("Put RTK on PATH links it into {dir}.", { dir: tilde(rtk.pathDir) })
|
||||
: t("Put RTK on PATH adds {dir} to your user PATH.", { dir: tilde(rtk.pathDir) })));
|
||||
const acts = el("div", "lib-acts");
|
||||
if (rtk.pathDir) {
|
||||
const pb = button(rtkPathing ? t("Putting RTK on PATH…") : t("Put RTK on PATH"), "action", pathRTK);
|
||||
pb.disabled = rtkPathing;
|
||||
acts.append(pb);
|
||||
}
|
||||
acts.append(button(t("Check again"), rtk.pathDir ? "" : "action", () => { rtk = null; render(); }));
|
||||
card.append(acts);
|
||||
}
|
||||
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) })
|
||||
@@ -730,6 +749,8 @@
|
||||
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 })));
|
||||
// …or one that is, off the PATH the agent gets
|
||||
else if (a.on && rtk.offPath) row.append(tag(t("Not on PATH"), "warn", t("{agent}'s hook runs rtk by name and can't find it, so RTK does nothing for it. Put RTK on PATH above.", { agent: a.name })));
|
||||
// OpenCode 2 won't load rtk's plugin (written for OpenCode 1): it can't
|
||||
// be switched on, and one already there can be switched off
|
||||
if (a.blocked && a.id !== "opencode") row.append(tag(t("Update RTK"), "warn", t("{agent}'s hook needs RTK 0.50 or newer: 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.", { agent: a.name })));
|
||||
@@ -840,6 +861,19 @@
|
||||
rtkUpgrading = false;
|
||||
render();
|
||||
}
|
||||
let rtkPathing = false;
|
||||
async function pathRTK() {
|
||||
rtkPathing = true;
|
||||
render();
|
||||
try {
|
||||
rtk = await api("library/rtk/path", {});
|
||||
status(rtk.restart?.length ? t("RTK is on your PATH — restart your agents, and the terminals they run in, to use it") : t("RTK is on your PATH"), "ok", 6000);
|
||||
} catch (e) {
|
||||
status(e.message, "err", 10000);
|
||||
}
|
||||
rtkPathing = false;
|
||||
render();
|
||||
}
|
||||
let rtkInstalling = false;
|
||||
async function installRTK() {
|
||||
rtkInstalling = true;
|
||||
|
||||
@@ -198,6 +198,15 @@ func libraryRoutes(mux *http.ServeMux, w Windows) {
|
||||
}
|
||||
writeJSON(rw, v)
|
||||
})
|
||||
// rtk put on the PATH the agents get, when magpie found it off it
|
||||
mux.HandleFunc("POST /api/library/rtk/path", func(rw http.ResponseWriter, r *http.Request) {
|
||||
v, err := library.PathRTK()
|
||||
if err != nil {
|
||||
fail(rw, err)
|
||||
return
|
||||
}
|
||||
writeJSON(rw, v)
|
||||
})
|
||||
mux.HandleFunc("POST /api/library/rtk/install", func(rw http.ResponseWriter, r *http.Request) {
|
||||
v, err := library.InstallRTK()
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// Run with Node's test runner and Playwright on the module path; see README.md.
|
||||
// #601: an rtk magpie found off the PATH the agents get (in ~/.local/bin,
|
||||
// which the user's shell doesn't have) was shown as working, while Pi's
|
||||
// extension said "RTK disabled: rtk binary not found in PATH". The RTK card
|
||||
// now says rtk isn't on PATH and what Put RTK on PATH will do, an agent that
|
||||
// has it is marked Not on PATH, and the button puts it there; in English
|
||||
// and Chinese. No backend: the API is faked here.
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { test } = require("node:test");
|
||||
const { chromium, webkit } = require("playwright");
|
||||
|
||||
const assets = path.resolve(__dirname, "../assets");
|
||||
const HOME = "/Users/aimer";
|
||||
const agent = (id, name) => ({ id, name, icon: "", skills: `${HOME}/.${id}/skills`, mcp: `${HOME}/.${id}/mcp.json` });
|
||||
const lib = () => ({
|
||||
dir: `${HOME}/.magpie/library`, backups: `${HOME}/.magpie/backups`, home: HOME,
|
||||
agents: [agent("pi", "Pi"), agent("codex", "Codex")],
|
||||
instructions: { agents: [], sets: [] }, foundServers: [], projects: [], foundSkills: [], skills: [], servers: [],
|
||||
});
|
||||
const rtkView = (off) => ({
|
||||
path: `${HOME}/.local/bin/rtk`, version: "0.50.0", url: "https://www.rtk-ai.app",
|
||||
agents: [{ id: "pi", name: "Pi", icon: "", on: true }, { id: "codex", name: "Codex", icon: "", on: false }],
|
||||
...(off ? { offPath: true, pathDir: `${HOME}/bin`, pathLink: true } : { restart: ["pi"] }),
|
||||
});
|
||||
|
||||
function server(lang, calls) {
|
||||
let off = true;
|
||||
return async (route) => {
|
||||
const req = route.request(), url = new URL(req.url());
|
||||
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` });
|
||||
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
|
||||
if (url.pathname === "/api/state") return route.fulfill({ json: { agents: [], profiles: [], settings: { lang, theme: "light" } } });
|
||||
if (url.pathname === "/api/plugins") return route.fulfill({ json: { plugins: [] } });
|
||||
if (url.pathname === "/api/library") return route.fulfill({ json: lib() });
|
||||
if (url.pathname === "/api/library/rtk") return route.fulfill({ json: rtkView(off) });
|
||||
if (url.pathname === "/api/library/rtk/path") {
|
||||
calls.push(req.method());
|
||||
off = false;
|
||||
return route.fulfill({ json: rtkView(false) });
|
||||
}
|
||||
if (url.pathname === "/api/usage/quotas") return route.fulfill({ json: [] });
|
||||
if (url.pathname === "/api/groups") return route.fulfill({ json: { groups: [] } });
|
||||
if (url.pathname === "/api/providers") return route.fulfill({ json: { providers: [], gateway: { running: true } } });
|
||||
if (url.pathname.startsWith("/api/")) return route.fulfill({ json: {} });
|
||||
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
|
||||
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
|
||||
await route.fulfill({ body: await fs.readFile(file), contentType });
|
||||
};
|
||||
}
|
||||
|
||||
const words = {
|
||||
en: { note: "RTK is in ~/.local/bin, which isn't on your PATH", how: "Put RTK on PATH links it into ~/bin.", tag: "Not on PATH", button: "Put RTK on PATH", done: "RTK is on your PATH — restart your agents, and the terminals they run in, to use it" },
|
||||
zh: { note: "RTK 在 ~/.local/bin,但这个目录不在你的 PATH 中", how: "“加到 PATH”会在 ~/bin 里建一个指向它的链接。", tag: "不在 PATH 中", button: "加到 PATH", done: "RTK 已在 PATH 中,重启 Agent 及其所在的终端后生效" },
|
||||
};
|
||||
|
||||
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
|
||||
test(engine + ": rtk off PATH is said, and put on it", async (t) => {
|
||||
assert(["chromium", "webkit"].includes(engine), "BROWSER must be chromium or webkit");
|
||||
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
|
||||
const errors = [];
|
||||
t.after(async () => {
|
||||
if (errors.length) console.log(errors);
|
||||
await browser.close();
|
||||
});
|
||||
for (const lang of ["en", "zh"]) {
|
||||
await t.test(lang, async () => {
|
||||
const w = words[lang], calls = [];
|
||||
const ctx = await browser.newContext({ viewport: { width: 980, height: 800 } });
|
||||
await ctx.addInitScript(() => { try { localStorage.setItem("magpie.libTab", "rtk"); } catch {} });
|
||||
const page = await ctx.newPage();
|
||||
page.setDefaultTimeout(5000);
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
await page.route("**/*", server(lang, calls));
|
||||
await page.goto("http://magpie.test/");
|
||||
await page.locator('button[data-view="library"]').click();
|
||||
const v = page.locator("#view-library");
|
||||
const note = v.locator(".lib-rtk-offpath");
|
||||
await note.waitFor();
|
||||
assert((await note.textContent()).startsWith(w.note), await note.textContent());
|
||||
assert.equal(await v.locator(".lib-rtk-cmd").textContent(), w.how);
|
||||
const row = (name) => v.locator(".lib-row").filter({ has: page.locator(".name", { hasText: name }) });
|
||||
assert.deepEqual(await row("Pi").locator(".lib-tag").allTextContents(), [w.tag]);
|
||||
// Codex hasn't got it: nothing to say there
|
||||
assert.equal(await row("Codex").locator(".lib-tag").count(), 0);
|
||||
|
||||
await v.getByRole("button", { name: w.button, exact: true }).click();
|
||||
await page.waitForFunction((want) => document.querySelector("#status").textContent === want, w.done).catch(() => {});
|
||||
assert.equal(await page.locator("#status").textContent(), w.done);
|
||||
assert.deepEqual(calls, ["POST"]);
|
||||
assert.equal(await note.count(), 0);
|
||||
assert.equal(await row("Pi").locator(".lib-tag").count(), 0);
|
||||
await ctx.close();
|
||||
});
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
}
|
||||
@@ -40,6 +40,10 @@ func sandbox(t *testing.T) string {
|
||||
roots := piGlobalRoots
|
||||
piGlobalRoots = func() []string { return nil }
|
||||
t.Cleanup(func() { piGlobalRoots = roots })
|
||||
// a terminal's PATH is the test's, never the developer's login shell's
|
||||
up := userPath
|
||||
userPath = func() []string { return filepath.SplitList(os.Getenv("PATH")) }
|
||||
t.Cleanup(func() { userPath = up })
|
||||
for _, k := range agentenv.Vars {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
|
||||
+24
-4
@@ -484,9 +484,17 @@ type RTKView struct {
|
||||
Upgrade string `json:"upgrade,omitempty"`
|
||||
// Note is what an upgrade left to say: Homebrew's rtk behind rtk's
|
||||
// own release
|
||||
Note string `json:"note,omitempty"`
|
||||
Agents []RTKAgent `json:"agents"`
|
||||
URL string `json:"url"`
|
||||
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
|
||||
@@ -499,13 +507,21 @@ 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" {
|
||||
name, dirs = "rtk.exe", []string{filepath.Join(os.Getenv("LOCALAPPDATA"), "Microsoft", "WinGet", "Links"), filepath.Join(home(), ".cargo", "bin")}
|
||||
winget := filepath.Join(os.Getenv("LOCALAPPDATA"), "Microsoft", "WinGet")
|
||||
name, dirs = "rtk.exe", []string{filepath.Join(winget, "Links"), filepath.Join(home(), ".cargo", "bin")}
|
||||
if os.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) {
|
||||
@@ -576,6 +592,10 @@ func ReadRTK() *RTKView {
|
||||
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"))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/yetone/magpie/internal/proc"
|
||||
)
|
||||
|
||||
// rtk's hooks run it by name — Pi's extension asks rtk --version and turns
|
||||
// itself off ("RTK disabled: rtk binary not found in PATH") when that
|
||||
// fails, Claude Code's and Codex's hooks are rtk hook …, and the commands
|
||||
// they rewrite to are rtk git status and so on — so an agent needs rtk on
|
||||
// its own PATH. magpie finds rtk in more places than that: the folders
|
||||
// installers use, which the app adds to its PATH whether the user's shell
|
||||
// has them or not (~/.local/bin, where rtk's own script puts it and only
|
||||
// warns when it isn't on PATH), and winget's, whose link isn't always made.
|
||||
// So magpie says when rtk isn't on the PATH a terminal opened now has, and
|
||||
// puts it there when asked: a link in a folder on it, or on Windows its
|
||||
// folder added to the user's PATH, as winget does when it can't link.
|
||||
// Shell profiles are left alone.
|
||||
|
||||
// userPath is the PATH an agent started now gets (proc.LoginPath); nil
|
||||
// when it can't be told. A test sets it.
|
||||
var userPath = proc.LoginPath
|
||||
|
||||
func rtkName() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "rtk.exe"
|
||||
}
|
||||
return "rtk"
|
||||
}
|
||||
|
||||
// rtkReached says whether an agent started now finds rtk by name; true when
|
||||
// the PATH it gets can't be told.
|
||||
func rtkReached() bool {
|
||||
dirs := userPath()
|
||||
if dirs == nil {
|
||||
return true
|
||||
}
|
||||
for _, d := range dirs {
|
||||
if d != "" && runnable(filepath.Join(d, rtkName())) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func runnable(p string) bool {
|
||||
st, err := os.Stat(p)
|
||||
return err == nil && !st.IsDir() && (runtime.GOOS == "windows" || st.Mode()&0o111 != 0)
|
||||
}
|
||||
|
||||
// writable says whether a file can be made in dir.
|
||||
func writable(dir string) bool {
|
||||
f, err := os.CreateTemp(dir, ".magpie-")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
f.Close()
|
||||
os.Remove(f.Name())
|
||||
return true
|
||||
}
|
||||
|
||||
// PathRTK puts the rtk magpie found on the PATH agents get, when the user
|
||||
// asks for it (see rtkOnPath).
|
||||
func PathRTK() (*RTKView, error) {
|
||||
rtkMu.Lock()
|
||||
defer rtkMu.Unlock()
|
||||
bin := rtkPath()
|
||||
if bin == "" {
|
||||
return nil, fmt.Errorf("rtk isn't installed — install it first (%s)", RTKURL)
|
||||
}
|
||||
if rtkReached() {
|
||||
return ReadRTK(), nil
|
||||
}
|
||||
dir, link := rtkPathDir(bin)
|
||||
if dir == "" {
|
||||
return nil, fmt.Errorf("magpie found no folder on your PATH it can put rtk in — add %s to PATH in your shell profile", filepath.Dir(bin))
|
||||
}
|
||||
if err := rtkOnPath(bin, dir, link); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v := ReadRTK()
|
||||
if v.OffPath {
|
||||
return nil, fmt.Errorf("rtk still isn't on your PATH after putting it in %s", dir)
|
||||
}
|
||||
for _, a := range v.Agents {
|
||||
if a.On {
|
||||
v.Restart = append(v.Restart, a.ID)
|
||||
}
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//go:build !windows
|
||||
|
||||
package library
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// rtkPathDir is the folder on the user's PATH a link to rtk goes in: the
|
||||
// first of those a user's tools usually are in that is on it, can be
|
||||
// written to and has no rtk in it; "" when none is. link is always true:
|
||||
// a link keeps up with rtk upgraded where it is.
|
||||
func rtkPathDir(string) (dir string, link bool) {
|
||||
on := userPath()
|
||||
h := home()
|
||||
for _, d := range []string{
|
||||
filepath.Join(h, ".local", "bin"), filepath.Join(h, "bin"),
|
||||
"/opt/homebrew/bin", "/usr/local/bin", "/home/linuxbrew/.linuxbrew/bin",
|
||||
filepath.Join(h, ".cargo", "bin"),
|
||||
} {
|
||||
if !slices.Contains(on, d) {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(d, "rtk")); err == nil {
|
||||
continue // something called rtk that doesn't run
|
||||
}
|
||||
if st, err := os.Stat(d); err == nil && st.IsDir() && writable(d) {
|
||||
return d, true
|
||||
}
|
||||
}
|
||||
return "", true
|
||||
}
|
||||
|
||||
// rtkOnPath links rtk into dir.
|
||||
func rtkOnPath(bin, dir string, _ bool) error {
|
||||
return os.Symlink(bin, filepath.Join(dir, "rtk"))
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//go:build !windows
|
||||
|
||||
package library
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRTKOffPath (#601): an rtk magpie finds where rtk's script puts it,
|
||||
// ~/.local/bin, which the app adds to its own PATH but the user's shell
|
||||
// may not have, isn't one the agents' hooks can run — Pi's turns itself
|
||||
// off with "rtk binary not found in PATH". The page says so instead of
|
||||
// showing RTK working, and Put RTK on PATH links it into a folder on it.
|
||||
func TestRTKOffPath(t *testing.T) {
|
||||
h := sandbox(t)
|
||||
local := filepath.Join(h, ".local", "bin")
|
||||
write(t, filepath.Join(local, "rtk"), fakeRTK)
|
||||
os.Chmod(filepath.Join(local, "rtk"), 0o755)
|
||||
tools := filepath.Join(h, "tools")
|
||||
write(t, filepath.Join(tools, "curl"), "#!/bin/sh\n")
|
||||
os.Chmod(filepath.Join(tools, "curl"), 0o755)
|
||||
t.Setenv("PATH", tools)
|
||||
|
||||
v := ReadRTK()
|
||||
if v.Path != filepath.Join(local, "rtk") || !v.OffPath {
|
||||
t.Fatalf("rtk at %q, off PATH %v; want it found in ~/.local/bin and said to be off PATH", v.Path, v.OffPath)
|
||||
}
|
||||
if v.PathDir != "" {
|
||||
t.Fatalf("no folder on PATH rtk can go in, yet PathDir is %q", v.PathDir)
|
||||
}
|
||||
if _, err := PathRTK(); err == nil || !strings.Contains(err.Error(), local) {
|
||||
t.Fatalf("PathRTK with nowhere to put rtk: %v; want it to say to add %s to PATH", err, local)
|
||||
}
|
||||
if _, err := SetRTK("claude", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// ~/bin is on the shell's PATH: rtk is linked in there
|
||||
userBin := filepath.Join(h, "bin")
|
||||
t.Setenv("PATH", tools+string(os.PathListSeparator)+userBin)
|
||||
if v = ReadRTK(); !v.OffPath || v.PathDir != userBin || !v.PathLink {
|
||||
t.Fatalf("off PATH %v, PathDir %q, link %v; want %s linked into", v.OffPath, v.PathDir, v.PathLink, userBin)
|
||||
}
|
||||
v, err := PathRTK()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.OffPath || !slices.Contains(v.Restart, "claude") {
|
||||
t.Fatalf("after PathRTK: off PATH %v, restart %v", v.OffPath, v.Restart)
|
||||
}
|
||||
if to, err := os.Readlink(filepath.Join(userBin, "rtk")); err != nil || to != filepath.Join(local, "rtk") {
|
||||
t.Fatalf("~/bin/rtk links to %q (%v)", to, err)
|
||||
}
|
||||
// upgraded with its script into ~/.local/bin, where the link points,
|
||||
// not over the link
|
||||
c := rtkUpgrader(filepath.Join(userBin, "rtk"))
|
||||
real, _ := filepath.EvalSymlinks(local)
|
||||
if len(c) != 4 || c[3] != real {
|
||||
t.Fatalf("upgrade through the link: %q; want rtk's script into %s", c, real)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
ptr "unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
)
|
||||
|
||||
// rtkPathDir is rtk's own folder, added to the user's PATH: a link needs
|
||||
// Developer Mode or an administrator, and a copy or a hard link is left
|
||||
// behind when winget upgrades rtk, while its package folder stays the same.
|
||||
func rtkPathDir(bin string) (dir string, link bool) {
|
||||
return filepath.Dir(bin), false
|
||||
}
|
||||
|
||||
// rtkOnPath adds dir to the user's PATH in the registry, where a terminal
|
||||
// opened from now on takes it up, and to magpie's own.
|
||||
func rtkOnPath(_, dir string, _ bool) error {
|
||||
k, err := registry.OpenKey(registry.CURRENT_USER, `Environment`, registry.QUERY_VALUE|registry.SET_VALUE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer k.Close()
|
||||
v, typ, err := k.GetStringValue("Path")
|
||||
if err != nil && err != registry.ErrNotExist {
|
||||
return err
|
||||
}
|
||||
for _, d := range strings.Split(v, ";") {
|
||||
if strings.EqualFold(strings.TrimRight(strings.TrimSpace(d), `\`), strings.TrimRight(dir, `\`)) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if v = strings.TrimRight(v, ";"); v != "" {
|
||||
v += ";"
|
||||
}
|
||||
v += dir
|
||||
// kept REG_EXPAND_SZ, as Windows has it, so %USERPROFILE% in it still works
|
||||
if err == registry.ErrNotExist || typ == registry.EXPAND_SZ {
|
||||
err = k.SetExpandStringValue("Path", v)
|
||||
} else {
|
||||
err = k.SetStringValue("Path", v)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("adding %s to your PATH: %w", dir, err)
|
||||
}
|
||||
os.Setenv("PATH", os.Getenv("PATH")+";"+dir)
|
||||
settingChanged()
|
||||
return nil
|
||||
}
|
||||
|
||||
// settingChanged tells Explorer the environment changed, so what it starts
|
||||
// next — a terminal from the Start menu — has the new PATH.
|
||||
func settingChanged() {
|
||||
const hwndBroadcast, wmSettingChange, smtoAbortIfHung = 0xffff, 0x001A, 0x0002
|
||||
env, _ := windows.UTF16PtrFromString("Environment")
|
||||
var res uintptr
|
||||
windows.NewLazySystemDLL("user32.dll").NewProc("SendMessageTimeoutW").Call(
|
||||
hwndBroadcast, wmSettingChange, 0, uintptr(ptr.Pointer(env)), smtoAbortIfHung, 5000, uintptr(ptr.Pointer(&res)))
|
||||
}
|
||||
@@ -143,7 +143,11 @@ func rtkUpgrader(bin string) []string {
|
||||
return []string{"cargo", "install", "--git", "https://github.com/rtk-ai/rtk", "--force"}
|
||||
}
|
||||
case runtime.GOOS != "windows":
|
||||
// its own script, told the folder it is in (RTK_INSTALL_DIR)
|
||||
// its own script, told the folder it is in (RTK_INSTALL_DIR): the
|
||||
// one a link to it points into (Put RTK on PATH's), not the link's
|
||||
if st, err := os.Lstat(bin); err == nil && st.Mode()&os.ModeSymlink != 0 {
|
||||
dir = filepath.Dir(real)
|
||||
}
|
||||
if have("curl") {
|
||||
return []string{"sh", "-c", "curl -fsSL " + rtkScript + " | sh", dir}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -24,10 +25,37 @@ func UserPath() {
|
||||
go func() {
|
||||
if p := shellPath(); p != "" {
|
||||
addPath(filepath.SplitList(p))
|
||||
login.Lock()
|
||||
login.dirs, login.at = filepath.SplitList(p), time.Now()
|
||||
login.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
var login struct {
|
||||
sync.Mutex
|
||||
dirs []string
|
||||
at time.Time
|
||||
}
|
||||
|
||||
// LoginPath is the PATH a terminal opened now has — the login shell's — for
|
||||
// telling whether a command an agent runs by name is found there, which
|
||||
// the app's own PATH can't: UserPath adds folders the shell may not have.
|
||||
// nil when the shell doesn't say; asked again after a minute.
|
||||
func LoginPath() []string {
|
||||
login.Lock()
|
||||
defer login.Unlock()
|
||||
if login.dirs != nil && time.Since(login.at) < time.Minute {
|
||||
return login.dirs
|
||||
}
|
||||
p := shellPath()
|
||||
if p == "" {
|
||||
return nil
|
||||
}
|
||||
login.dirs, login.at = filepath.SplitList(p), time.Now()
|
||||
return login.dirs
|
||||
}
|
||||
|
||||
// UserBinDirs are the folders a user's command-line tools are installed in
|
||||
// that exist here — npm's prefixes, each Node version of nvm, fnm and mise,
|
||||
// bun, volta, pnpm, asdf, the standalone installers' ~/.local/bin,
|
||||
|
||||
@@ -3,6 +3,9 @@ package proc
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/windows/registry"
|
||||
)
|
||||
|
||||
// UserPath has nothing to do on Windows: an app started from the Start menu
|
||||
@@ -29,3 +32,36 @@ func UserBinDirs() []string {
|
||||
}
|
||||
return have
|
||||
}
|
||||
|
||||
// LoginPath is the PATH the registry has now, the machine's then the
|
||||
// user's: what a terminal opened now gets, and what a magpie started
|
||||
// before a CLI was installed doesn't.
|
||||
func LoginPath() []string {
|
||||
var dirs []string
|
||||
for _, k := range []struct {
|
||||
root registry.Key
|
||||
path string
|
||||
}{
|
||||
{registry.LOCAL_MACHINE, `SYSTEM\CurrentControlSet\Control\Session Manager\Environment`},
|
||||
{registry.CURRENT_USER, `Environment`},
|
||||
} {
|
||||
key, err := registry.OpenKey(k.root, k.path, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if v, _, err := key.GetStringValue("Path"); err == nil {
|
||||
// %SystemRoot%\system32 as Windows expands it: os.ExpandEnv of
|
||||
// $SystemRoot$\system32 made C:\Windows$\system32
|
||||
if x, err := registry.ExpandString(v); err == nil {
|
||||
v = x
|
||||
}
|
||||
for _, d := range strings.Split(v, ";") {
|
||||
if d = strings.TrimSpace(d); d != "" {
|
||||
dirs = append(dirs, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
key.Close()
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/windows/registry"
|
||||
"github.com/yetone/magpie/internal/proc"
|
||||
)
|
||||
|
||||
// refreshPath takes up the PATH an installer just wrote to the registry,
|
||||
@@ -21,35 +21,5 @@ func refreshPath() {
|
||||
os.Setenv("PATH", strings.Join(path, ";"))
|
||||
}
|
||||
|
||||
// registryPath is the PATH the registry has now, the machine's then the
|
||||
// user's: what a terminal opened now gets, and what a magpie started
|
||||
// before a CLI was installed doesn't.
|
||||
func registryPath() []string {
|
||||
var dirs []string
|
||||
for _, k := range []struct {
|
||||
root registry.Key
|
||||
path string
|
||||
}{
|
||||
{registry.LOCAL_MACHINE, `SYSTEM\CurrentControlSet\Control\Session Manager\Environment`},
|
||||
{registry.CURRENT_USER, `Environment`},
|
||||
} {
|
||||
key, err := registry.OpenKey(k.root, k.path, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if v, _, err := key.GetStringValue("Path"); err == nil {
|
||||
// %SystemRoot%\system32 as Windows expands it: os.ExpandEnv of
|
||||
// $SystemRoot$\system32 made C:\Windows$\system32
|
||||
if x, err := registry.ExpandString(v); err == nil {
|
||||
v = x
|
||||
}
|
||||
for _, d := range strings.Split(v, ";") {
|
||||
if d = strings.TrimSpace(d); d != "" {
|
||||
dirs = append(dirs, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
key.Close()
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
// registryPath is the PATH the registry has now (proc.LoginPath).
|
||||
func registryPath() []string { return proc.LoginPath() }
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
@@ -30,6 +31,7 @@ const libraryUsage = `magpie library what the library gives
|
||||
magpie library rtk on|off <agent> switch it (on with RTK's own installer; off works with RTK gone)
|
||||
magpie library rtk install install RTK (Homebrew, winget, or RTK's own script)
|
||||
magpie library rtk upgrade bring RTK up to its latest release, the way it was installed
|
||||
magpie library rtk path put RTK on the PATH the agents get, when it isn't (their hooks run it by name)
|
||||
`
|
||||
|
||||
// libraryCmd is magpie library …: the instructions, MCP servers and skills
|
||||
@@ -339,6 +341,11 @@ func rtkCmd(args []string) error {
|
||||
if v, err = library.InstallRTK(); err != nil {
|
||||
return err
|
||||
}
|
||||
case len(args) == 1 && args[0] == "path":
|
||||
var err error
|
||||
if v, err = library.PathRTK(); err != nil {
|
||||
return err
|
||||
}
|
||||
case len(args) == 1 && args[0] == "upgrade":
|
||||
fmt.Println(muted.Render("upgrading rtk…"))
|
||||
var err error
|
||||
@@ -376,6 +383,17 @@ func rtkCmd(args []string) error {
|
||||
case len(args) == 1 && args[0] == "upgrade":
|
||||
fmt.Println(green.Render(" up to date"))
|
||||
}
|
||||
if v.OffPath {
|
||||
fmt.Println(amber.Render(" rtk isn't on your PATH"), muted.Render("— the agents' hooks run it by name, so RTK does nothing for them"))
|
||||
switch {
|
||||
case v.PathDir == "":
|
||||
fmt.Println(muted.Render(" add " + filepath.Dir(v.Path) + " to PATH in your shell profile"))
|
||||
case v.PathLink:
|
||||
fmt.Println(muted.Render(" magpie library rtk path links it into " + v.PathDir))
|
||||
default:
|
||||
fmt.Println(muted.Render(" magpie library rtk path adds " + v.PathDir + " to your user PATH"))
|
||||
}
|
||||
}
|
||||
if g := v.Gain; g != nil {
|
||||
fmt.Printf(" %d tokens saved over %d commands (%.0f%% on average)\n", g.Saved, g.Commands, g.Pct)
|
||||
}
|
||||
@@ -387,6 +405,8 @@ func rtkCmd(args []string) error {
|
||||
mark = green.Render("on ")
|
||||
if v.Path == "" {
|
||||
mark, note = amber.Render("on "), muted.Render(" — its hook calls rtk, which isn't installed: install it, or switch this off")
|
||||
} else if v.OffPath {
|
||||
mark, note = amber.Render("on "), muted.Render(" — its hook can't find rtk on PATH, so it does nothing")
|
||||
}
|
||||
}
|
||||
fmt.Println(" ", mark, a.Name+note)
|
||||
|
||||
Reference in new issue
Block a user