diff --git a/docs/subsystems/library.md b/docs/subsystems/library.md index 5c63234d..04b5ece9 100644 --- a/docs/subsystems/library.md +++ b/docs/subsystems/library.md @@ -38,6 +38,7 @@ agents RTK, a CLI that cuts shell output down before the model reads it. - A server an agent can't reach is not written there, and `mcpFile.supports` says why. For example, Claude Desktop takes no remote server, and Codex, Goose, dsh, Grok and Command Code take no SSE. - A server's headers (remote) or environment (command) can name a variable as `${NAME}` (#1250). `library.json` keeps the placeholder, and `refsOf` gives each MCP format the agent's own syntax, with the agent's source cited beside it: `${NAME}` for most, `{env:NAME}` for OpenCode and MiMo, `${env:NAME}` for Cursor, and Codex's own keys (`bearer_token_env_var`, `env_http_headers`, `env_vars`). Goose gets `${NAME}` plus `env_keys`. An agent with no syntax (Claude Desktop, Antigravity, Kimi, Cline, ZCode, dsh, Devin, Pi with pi-mcp-extension) is never written the value: `refsProblem` makes `supports` refuse the server, a magpie entry already there is taken out, and `AgentView.NoEnvRefs` greys its chip with the reason. Codex and Goose's extra keys are magpie's only while the server names a variable there (`refKeys`), so the user's own `env_vars` or `env_keys` are kept. `CheckServers` expands the variable from magpie's own environment, and one that isn't set is `novar`, naming it. - WorkBuddy's servers go in `$WORKBUDDY_CONFIG_DIR/mcp.json` (default `~/.workbuddy`), and CodeBuddy Code's go in the first of `.mcp.json`, `mcp.json` and `~/.codebuddy.json` that exists, as `resolveMcpFilePath` reads them (#1266). WorkBuddy connects a server only after the user has switched it on once in its own MCP settings. It keeps that approval in `mcp-approvals.json`, keyed by the command, args and env names or the URL's origin, so changing those asks again. magpie never writes the approval, and the servers page says so when a server is given to WorkBuddy. +- An agent with no place for an item is refused by `Takes`, never recorded as given it. The refusal lists the agents here that take that item (`takenBy`), and says where it goes instead (`noPlace`): OpenChamber reads OpenCode's global AGENTS.md, skills folder and opencode.json (its server's `shared.js`), so it gets them from OpenCode; Claude Desktop's chat keeps its instructions in its own settings, with the Claude account, and its Code tab reads Claude Code's. - Removing RTK never uses `rtk init --uninstall`. That command deletes Gemini's `GEMINI.md` whole and removes Claude Code, OpenCode and Cursor together. - CC Switch's own skills folder is only ever read. - Skills installed together (`InstallSkills`, `InstallMarketSkill`, `AddNewSkills`, through `installFrom` and `installChange`) install each on its own. One the library lists from the same source, or a folder in the library's own skills folder holding exactly that skill's files (left by an install that stopped before the library was saved), is had already and goes in `Result.Had`. Its files are never written, so the user's edits stay. Another skill by that name, listed or only in the folder, or a folder that can't be read, is left as it is and goes in `Result.Skipped` with why. The rest are installed and named in `Result.Installed`. Only when nothing was installed or had does the change fail, with the first reason. diff --git a/internal/library/library_test.go b/internal/library/library_test.go index 202f298e..ffa9e7d4 100644 --- a/internal/library/library_test.go +++ b/internal/library/library_test.go @@ -147,6 +147,23 @@ func TestTakesRefusesAlma(t *testing.T) { } } +// Claude Desktop keeps its instructions in its own settings and OpenChamber +// reads OpenCode's: the refusal says where they go instead, and lists the +// agents here that take that kind (MOMO on Discord: "has no user-wide +// place" said neither). +func TestTakesSaysWhereInstead(t *testing.T) { + sandbox(t) + _, err := Takes("claude-desktop", "instructions") + if err == nil || !strings.Contains(err.Error(), "give them to claude") || !strings.Contains(err.Error(), "agents here that take instructions: ") || !strings.Contains(err.Error(), "gemini") { + t.Errorf("claude-desktop instructions: %v", err) + } + for _, kind := range []string{"instructions", "mcp", "skills"} { + if _, err := Takes("openchamber", kind); err == nil || !strings.Contains(err.Error(), "give them to opencode") { + t.Errorf("openchamber %s: %v", kind, err) + } + } +} + // Every format writes a server so that reading it back gives it again, and // taking it out leaves the file as the user had it. func TestServerEveryFormat(t *testing.T) { diff --git a/internal/library/targets.go b/internal/library/targets.go index d2da53f6..551df59f 100644 --- a/internal/library/targets.go +++ b/internal/library/targets.go @@ -519,7 +519,40 @@ func Takes(q, kind string) (string, error) { } } if !has { - return "", fmt.Errorf("%s has no user-wide place for %s that magpie knows of", a.Name, what) + return "", fmt.Errorf("%s%s", noPlace(a, kind, what), takenBy(kind, what)) } return a.ID, nil } + +// noPlace says why an agent can't be given kind, and where it gets it +// instead when another agent's files are what it reads (MOMO on Discord). +func noPlace(a *agent.Agent, kind, what string) string { + switch { + case a.ID == "openchamber": + // OpenChamber's server reads the global AGENTS.md, skills folder + // and opencode.json from OpenCode's config folder + // (packages/web/server/lib/opencode/shared.js), and the OpenCode it + // runs reads them there too + return fmt.Sprintf("OpenChamber has no place of its own for %s: it runs OpenCode on OpenCode's config, so it has what OpenCode is given · give them to opencode", what) + case a.ID == "claude-desktop" && kind == "instructions": + // Desktop's chat takes its instructions in its own settings, kept + // with the Claude account, not in a file; its Code tab is Claude + // Code, which reads Claude Code's CLAUDE.md + return "Claude Desktop keeps its instructions in its own settings, with your Claude account, not in a file magpie can write; its Code tab runs Claude Code, which reads Claude Code's · give them to claude" + } + return fmt.Sprintf("%s has no user-wide place for %s that magpie knows of", a.Name, what) +} + +// takenBy lists the agents here that kind can be given to. +func takenBy(kind, what string) string { + var ids []string + for _, t := range Targets() { + if kind == "instructions" && t.Instructions != "" || kind == "mcp" && t.MCP != nil || kind == "skills" && t.Skills != "" { + ids = append(ids, t.Agent.ID) + } + } + if len(ids) == 0 { + return "" + } + return "\n agents here that take " + what + ": " + strings.Join(ids, ", ") +}