diff --git a/docs/subsystems/library.md b/docs/subsystems/library.md index 96cd4334..ec239e89 100644 --- a/docs/subsystems/library.md +++ b/docs/subsystems/library.md @@ -19,7 +19,7 @@ agents RTK, a CLI that cuts shell output down before the model reads it. | MCP servers | A `Server` is a command or a URL (`http`, `sse`). `mcpFile` encodes it in each agent's own format (one `mcpFormat` per agent family) and merges it with the user's own fields for that entry. `Also` files get the same servers; `Extra` files are only read when importing. `CheckServers` connects as an agent would and keeps the result in memory only. A server that needs OAuth is signed in once in magpie, and agents reach it through the gateway's `/mcp/` (#615). | [`mcp.go`](../../internal/library/mcp.go), [`mcp_check.go`](../../internal/library/mcp_check.go), [`mcp_signin.go`](../../internal/library/mcp_signin.go) | | Skills | A skill is a folder with a `SKILL.md`. It comes from GitHub, from a folder on this machine (linked, so editing the folder edits the skill) or from the market. Each agent gets a link or a marked copy (#896, `SkillHow`). An agent in a WSL distro always gets a copy. | [`skills.go`](../../internal/library/skills.go), [`skill_how.go`](../../internal/library/skill_how.go), [`skill_edits.go`](../../internal/library/skill_edits.go), [`skill_from.go`](../../internal/library/skill_from.go) | | Backups | Before a change first writes an agent file, `backups` copies it to `BackupDir`, one folder per change. The last `keepBackups` (30) changes are kept. | [`backup.go`](../../internal/library/backup.go) | -| RTK | `SetRTK` runs rtk's own installer (`rtk init -g …`) for an agent being switched on. For one being switched off, it removes exactly what the installer wrote, using magpie's own code, which works even with rtk gone. `ReadRTK` reads each agent's files to say which have the hook. `rtkSpec.blocked` stops the installer where it would break an agent. | [`rtk.go`](../../internal/library/rtk.go) | +| RTK | `SetRTK` runs rtk's own installer (`rtk init -g …`) for an agent being switched on. For one being switched off, it removes exactly what the installer wrote, using magpie's own code, which works even with rtk gone. `ReadRTK` reads each agent's files to say which have the hook. `rtkSpec.blocked` stops the installer where it would break an agent. A detected agent RTK has no hook for at all is still listed, from `rtkNoHook`, with the reason and a switch that can't be turned on (dsh: its hooks can allow or deny a command but not rewrite it, and `rtk init` has no `--agent dsh`). | [`rtk.go`](../../internal/library/rtk.go) | ## Runtime path @@ -42,7 +42,7 @@ agents RTK, a CLI that cuts shell output down before the model reads it. ```sh go test -tags nogui ./internal/library -run 'TestTargets|TestServer|TestImportServer|TestCheck|TestInstructions|TestInstructionSets|TestSkill|TestEverySkillAgents|TestRTK' go test -tags nogui ./internal/library -node --test internal/gui/tests/library-items.test.cjs internal/gui/tests/library-rtk-gain.test.cjs +node --test internal/gui/tests/library-items.test.cjs internal/gui/tests/library-rtk-gain.test.cjs internal/gui/tests/library-rtk-nohook.test.cjs ``` The package's `TestMain` runs in a home of its own (`testenv`), never the diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 6a50c082..ccb54958 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -2943,6 +2943,8 @@ const I18N = { "{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,或重新运行其安装脚本)再打开。", "RTK's plugin is written for OpenCode 1, and OpenCode 2 refuses to load it. Switch this off until RTK supports OpenCode 2.": "RTK 插件为 OpenCode 1 编写,OpenCode 2 拒绝加载。在 RTK 支持 OpenCode 2 前请关闭此开关。", "RTK's plugin is written for OpenCode 1, and OpenCode 2 refuses to load it. It can be switched on once RTK supports OpenCode 2.": "RTK 插件为 OpenCode 1 编写,OpenCode 2 拒绝加载。RTK 支持 OpenCode 2 后才能打开。", + "No RTK hook": "RTK 暂不支持", + "RTK can't be given to {agent} yet: RTK works by rewriting the shell command an agent is about to run, and {agent}'s hooks can only allow or deny a command, not change it. RTK's installer has no option for it either (github.com/rtk-ai/rtk/issues/3847).": "暂时无法给 {agent} 接入 RTK:RTK 的做法是在 agent 执行 shell 命令前把命令改写掉,而 {agent} 的 hook 只能放行或拦截命令,不能改写;RTK 的安装程序也还没有它的选项(github.com/rtk-ai/rtk/issues/3847)。", "Get RTK": "获取 RTK", "Check again": "重新检测", "restart an agent after switching it": "切换后需重启对应 Agent", @@ -6344,6 +6346,8 @@ const I18N = { "{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} のフックには RTK 0.50 以降が必要です。古いバージョンは AGENTS.md に @RTK.md を追加するだけで、コマンドを書き換えません。RTK を更新(brew upgrade rtk、またはインストーラを再実行)してからオンにしてください。", "RTK's plugin is written for OpenCode 1, and OpenCode 2 refuses to load it. Switch this off until RTK supports OpenCode 2.": "RTK のプラグインは OpenCode 1 向けに書かれており、OpenCode 2 は読み込みを拒否します。RTK が OpenCode 2 に対応するまでオフにしてください。", "RTK's plugin is written for OpenCode 1, and OpenCode 2 refuses to load it. It can be switched on once RTK supports OpenCode 2.": "RTK のプラグインは OpenCode 1 向けに書かれており、OpenCode 2 は読み込みを拒否します。RTK が OpenCode 2 に対応したらオンにできます。", + "No RTK hook": "RTK 未対応", + "RTK can't be given to {agent} yet: RTK works by rewriting the shell command an agent is about to run, and {agent}'s hooks can only allow or deny a command, not change it. RTK's installer has no option for it either (github.com/rtk-ai/rtk/issues/3847).": "{agent} にはまだ RTK を使えません。RTK はエージェントが実行しようとするシェルコマンドを書き換えて動きますが、{agent} のフックはコマンドを許可・拒否できるだけで書き換えられません。RTK のインストーラーにも {agent} 用のオプションはありません(github.com/rtk-ai/rtk/issues/3847)。", "Get RTK": "RTK を入手", "Check again": "再確認", "restart an agent after switching it": "切り替え後はエージェントを再起動してください", @@ -9747,6 +9751,8 @@ const I18N = { "{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.": "Der Hook von {agent} benötigt RTK 0.50 oder neuer: Ältere Versionen fügen nur @RTK.md zu AGENTS.md hinzu, was keinen Befehl umschreibt. Aktualisieren Sie RTK (brew upgrade rtk oder erneut über den Installer) und schalten Sie es dann ein.", "RTK's plugin is written for OpenCode 1, and OpenCode 2 refuses to load it. Switch this off until RTK supports OpenCode 2.": "Die Erweiterung von RTK ist für OpenCode 1 geschrieben, und OpenCode 2 lädt sie nicht. Schalten Sie dies aus, bis RTK OpenCode 2 unterstützt.", "RTK's plugin is written for OpenCode 1, and OpenCode 2 refuses to load it. It can be switched on once RTK supports OpenCode 2.": "Die Erweiterung von RTK ist für OpenCode 1 geschrieben, und OpenCode 2 lädt sie nicht. Sie lässt sich einschalten, sobald RTK OpenCode 2 unterstützt.", + "No RTK hook": "Kein RTK-Hook", + "RTK can't be given to {agent} yet: RTK works by rewriting the shell command an agent is about to run, and {agent}'s hooks can only allow or deny a command, not change it. RTK's installer has no option for it either (github.com/rtk-ai/rtk/issues/3847).": "RTK lässt sich {agent} noch nicht geben: RTK schreibt den Shell-Befehl um, den ein Agent gleich ausführt, und die Hooks von {agent} können einen Befehl nur erlauben oder ablehnen, nicht ändern. Auch der Installer von RTK hat keine Option dafür (github.com/rtk-ai/rtk/issues/3847).", "Get RTK": "RTK holen", "Check again": "Erneut prüfen", "restart an agent after switching it": "Agenten nach dem Umschalten neu starten", diff --git a/internal/gui/assets/library.js b/internal/gui/assets/library.js index f3b4bd1c..41f0bb25 100644 --- a/internal/gui/assets/library.js +++ b/internal/gui/assets/library.js @@ -781,7 +781,9 @@ 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 }))); + // an agent RTK has no hook for (DeepSeek Harness): listed, with why + if (a.noHook) row.append(tag(t("No RTK hook"), "warn", t("RTK can't be given to {agent} yet: RTK works by rewriting the shell command an agent is about to run, and {agent}'s hooks can only allow or deny a command, not change it. RTK's installer has no option for it either (github.com/rtk-ai/rtk/issues/3847).", { agent: a.name }))); + else 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 }))); else if (a.blocked) row.append(tag(t("Not for OpenCode 2"), "warn", a.on ? t("RTK's plugin is written for OpenCode 1, and OpenCode 2 refuses to load it. Switch this off until RTK supports OpenCode 2.") : t("RTK's plugin is written for OpenCode 1, and OpenCode 2 refuses to load it. It can be switched on once RTK supports OpenCode 2."))); diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md index bc674ed5..17894848 100644 --- a/internal/gui/tests/README.md +++ b/internal/gui/tests/README.md @@ -1933,3 +1933,12 @@ every string has zh, ja and de. English and Chinese, Chromium and WebKit. ```sh node --test internal/gui/tests/ledger-protocol.test.cjs ``` + +`library-rtk-nohook.test.cjs` checks that an agent RTK has no hook for +(DeepSeek Harness) is listed in Library › RTK with a No RTK hook tag saying +why, and that its switch can't be turned on, while Pi's can. English and +Chinese, Chromium and WebKit. + +```sh +node --test internal/gui/tests/library-rtk-nohook.test.cjs +``` diff --git a/internal/gui/tests/library-rtk-nohook.test.cjs b/internal/gui/tests/library-rtk-nohook.test.cjs new file mode 100644 index 00000000..aa7158b9 --- /dev/null +++ b/internal/gui/tests/library-rtk-nohook.test.cjs @@ -0,0 +1,94 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// Discord (lc): "rtk 没有识别 deepseek harness". RTK has no hook for +// DeepSeek Harness (dsh's hooks can't rewrite a command, and rtk init has no +// --agent dsh), and the RTK tab left it out, as if magpie hadn't seen it. +// It is listed now, tagged No RTK hook with why, and its switch can't be +// turned on; 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("dsh", "DeepSeek Harness")], + instructions: { agents: [], sets: [] }, foundServers: [], projects: [], foundSkills: [], skills: [], servers: [], +}); +const rtkView = () => ({ + path: `${HOME}/.local/bin/rtk`, version: "0.51.0", url: "https://www.rtk-ai.app", + agents: [ + { id: "pi", name: "Pi", icon: "", on: false }, + { id: "dsh", name: "DeepSeek Harness", icon: "", on: false, noHook: true, blocked: "RTK has no hook for DeepSeek Harness yet" }, + ], +}); + +function server(lang, calls) { + 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") { + if (req.method() !== "GET") calls.push(req.postData()); + return route.fulfill({ json: rtkView() }); + } + 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: { tag: "No RTK hook", why: "DeepSeek Harness's hooks can only allow or deny a command, not change it" }, + zh: { tag: "RTK 暂不支持", why: "DeepSeek Harness 的 hook 只能放行或拦截命令,不能改写" }, +}; + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + test(engine + ": an agent RTK has no hook for is listed with why", 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 row = (name) => v.locator(".lib-row").filter({ has: page.locator(".name", { hasText: name }) }); + await row("DeepSeek Harness").waitFor(); + const tag = row("DeepSeek Harness").locator(".lib-tag"); + assert.deepEqual(await tag.allTextContents(), [w.tag]); + assert((await tag.getAttribute("title")).includes(w.why), await tag.getAttribute("title")); + const sw = row("DeepSeek Harness").locator(".lib-switch"); + assert.equal(await sw.isDisabled(), true); + // Pi has a hook: nothing to say, and it can be switched on + assert.equal(await row("Pi").locator(".lib-tag").count(), 0); + assert.equal(await row("Pi").locator(".lib-switch").isDisabled(), false); + await sw.click({ force: true }); + assert.deepEqual(calls, []); + await ctx.close(); + }); + } + assert.deepEqual(errors, []); + }); +} diff --git a/internal/library/rtk.go b/internal/library/rtk.go index 2bdb4cad..44cdb4b8 100644 --- a/internal/library/rtk.go +++ b/internal/library/rtk.go @@ -184,6 +184,21 @@ var rtkSpecs = map[string]rtkSpec{ }, } +// 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") } @@ -453,6 +468,8 @@ type RTKAgent struct { // 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. @@ -597,6 +614,11 @@ func ReadRTK() *RTKView { } 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 } @@ -738,6 +760,8 @@ func SetRTK(id string, on bool) (*RTKView, error) { } 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: @@ -857,6 +881,9 @@ func RTKTakes(q string) (string, error) { 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 { diff --git a/internal/library/rtk_test.go b/internal/library/rtk_test.go index bec66e76..20560342 100644 --- a/internal/library/rtk_test.go +++ b/internal/library/rtk_test.go @@ -412,3 +412,33 @@ func TestCodexSandbox(t *testing.T) { } } } + +// TestRTKNoHookListed (Discord, lc: rtk 没有识别 deepseek harness): an +// agent RTK has no hook for, DeepSeek Harness, is listed all the same with +// why, not left out as if magpie hadn't seen it, and it can't be switched on. +func TestRTKNoHookListed(t *testing.T) { + h := sandbox(t) + bin := filepath.Join(h, "bin") + write(t, filepath.Join(bin, "dsh"), "#!/bin/sh\n") + os.Chmod(filepath.Join(bin, "dsh"), 0o755) + write(t, filepath.Join(bin, "dsh.exe"), "") + var dsh *RTKAgent + for _, a := range ReadRTK().Agents { + if a.ID == "dsh" { + dsh = &a + } + } + if dsh == nil { + t.Fatal("DeepSeek Harness isn't listed") + } + // what the page reads: noHook, and Blocked saying why + if b, _ := json.Marshal(dsh); dsh.On || !strings.Contains(string(b), `"noHook":true`) || !strings.Contains(dsh.Blocked, "rewriting the shell command") { + t.Fatalf("listed as %s", b) + } + if _, err := SetRTK("dsh", true); err == nil || !strings.Contains(err.Error(), "DeepSeek Harness") { + t.Fatalf("switched on: %v", err) + } + if _, err := RTKTakes("dsh"); err == nil || !strings.Contains(err.Error(), "rewriting the shell command") { + t.Fatalf("magpie library rtk on dsh: %v", err) + } +} diff --git a/library_cli.go b/library_cli.go index 366c00e2..40ba4f48 100644 --- a/library_cli.go +++ b/library_cli.go @@ -424,6 +424,9 @@ func rtkCmd(args []string) error { for _, a := range v.Agents { mark := muted.Render("off") note := "" + if a.NoHook { + mark, note = muted.Render(" – "), muted.Render(" — "+a.Blocked) + } if a.On { mark = green.Render("on ") if v.Path == "" {