magus-cli 7.11.4 → 7.11.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,195 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { testRender } from "@opentui/react/test-utils";
6
+ import { act, useEffect } from "react";
7
+ import { KeyRouter } from "../ui/input/KeyRouter.js";
8
+ import McpRegistryScreen from "../ui/screens/McpRegistryScreen.js";
9
+ import { AppProvider, useNavigation } from "../ui/state/AppContext.js";
10
+ import { DimensionsProvider } from "../ui/state/DimensionsContext.js";
11
+
12
+ // REGRESSION: MTL-337 debounce window and cursor jump — round 2 of /dev:fix session dev-fix-20261101-153739-a08a5829
13
+ //
14
+ // The real McpRegistryScreen is mounted under the real providers, with a fake
15
+ // `fetch` for the registry endpoint and a throwaway project path. Keys go
16
+ // through the test renderer's mock keyboard; the outcome is read from what the
17
+ // screen wrote to .mcp.json.
18
+
19
+ // Real-looking hosts for the servers whose host decides "official"; every
20
+ // other name gets an example.org host.
21
+ const HOSTS: Record<string, string> = {
22
+ "com.notion/mcp": "https://mcp.notion.com/mcp",
23
+ "ai.smithery/smithery-notion": "https://server.smithery.ai/notion/mcp",
24
+ "com.linear/mcp": "https://mcp.linear.com/mcp",
25
+ };
26
+
27
+ const server = (name: string) => ({
28
+ name,
29
+ url: HOSTS[name] ?? `https://${name.replace(/[^a-z]/g, "")}.example.org/mcp`,
30
+ short_description: `${name} description`,
31
+ });
32
+
33
+ type Deferred = { query: string; resolve: (names: string[]) => void };
34
+
35
+ let cleanup: Array<() => Promise<void> | void> = [];
36
+ const realFetch = globalThis.fetch;
37
+
38
+ afterEach(async () => {
39
+ globalThis.fetch = realFetch;
40
+ for (const fn of cleanup.reverse()) await fn();
41
+ cleanup = [];
42
+ });
43
+
44
+ function GoToRegistry() {
45
+ const { navigateToScreen } = useNavigation();
46
+ // biome-ignore lint/correctness/useExhaustiveDependencies: navigate once on mount
47
+ useEffect(() => navigateToScreen("mcp-registry"), []);
48
+ return null;
49
+ }
50
+
51
+ async function mount(initialNames: string[]) {
52
+ const project = mkdtempSync(join(tmpdir(), "mcp-registry-screen-"));
53
+ cleanup.push(() => rmSync(project, { recursive: true, force: true }));
54
+
55
+ const pending: Deferred[] = [];
56
+ globalThis.fetch = (async (input: unknown) => {
57
+ const url = new URL(String(input));
58
+ const query = url.searchParams.get("q") ?? "";
59
+ const names = await new Promise<string[]>((resolve) => {
60
+ if (query === "") resolve(initialNames);
61
+ else pending.push({ query, resolve });
62
+ });
63
+ return new Response(JSON.stringify({ servers: names.map(server) }), {
64
+ status: 200,
65
+ headers: { "content-type": "application/json" },
66
+ });
67
+ }) as typeof fetch;
68
+
69
+ const { renderer, mockInput, renderOnce, captureCharFrame } =
70
+ await testRender(
71
+ <AppProvider initialProjectPath={project}>
72
+ <DimensionsProvider>
73
+ <KeyRouter>
74
+ <GoToRegistry />
75
+ <McpRegistryScreen />
76
+ </KeyRouter>
77
+ </DimensionsProvider>
78
+ </AppProvider>,
79
+ { width: 110, height: 30 },
80
+ );
81
+ cleanup.push(async () => {
82
+ await act(async () => renderer.destroy());
83
+ });
84
+
85
+ const settle = async (ms = 30) => {
86
+ for (let i = 0; i < 3; i++) {
87
+ await new Promise((r) => setTimeout(r, ms));
88
+ await renderOnce();
89
+ }
90
+ };
91
+ const send = async (fn: () => void | Promise<void>, ms?: number) =>
92
+ act(async () => {
93
+ await fn();
94
+ await settle(ms);
95
+ });
96
+ await act(() => settle());
97
+
98
+ const mcpJson = () => {
99
+ const path = join(project, ".mcp.json");
100
+ return existsSync(path) ? readFileSync(path, "utf8") : "";
101
+ };
102
+ return { mockInput, send, settle, pending, mcpJson, frame: captureCharFrame };
103
+ }
104
+
105
+ describe("registry screen: the list is not actionable while a query change is pending", () => {
106
+ test("typing a query then Enter, Enter inside the debounce installs nothing from the old list", async () => {
107
+ const m = await mount(["com.old/alpha", "com.old/beta"]);
108
+
109
+ // "/" opens the search box, the text is typed, the first Enter leaves it,
110
+ // the second Enter installs the row under the cursor. All of it happens
111
+ // well inside the 300 ms debounce, before loadServers has started.
112
+ await m.send(() => m.mockInput.typeText("/notion"), 5);
113
+ await m.send(() => m.mockInput.pressEnter(), 5);
114
+ await m.send(() => m.mockInput.pressEnter(), 5);
115
+
116
+ expect(m.pending).toHaveLength(0); // debounce has not fired yet
117
+ expect(m.mcpJson()).not.toContain("com.old/");
118
+ });
119
+
120
+ test("after the new query's response lands, Enter installs the new list's first row", async () => {
121
+ const m = await mount(["com.old/alpha", "com.old/beta"]);
122
+ await m.send(() => m.mockInput.typeText("/notion"), 5);
123
+ await m.send(() => m.mockInput.pressEnter(), 5);
124
+ await m.settle(400); // debounce fires, request is now pending
125
+ expect(m.pending).toHaveLength(1);
126
+ await m.send(() => m.pending[0]?.resolve(["com.notion/mcp"]));
127
+ await m.send(() => m.mockInput.pressEnter(), 100);
128
+
129
+ expect(m.mcpJson()).toContain("com.notion/mcp");
130
+ });
131
+ });
132
+
133
+ describe("registry screen: the cursor follows the name only when the query is unchanged", () => {
134
+ test("a new query puts the cursor on row 0 of the new results, even when the old row-0 server is in them", async () => {
135
+ const m = await mount(["com.old/alpha", "com.old/beta"]);
136
+ await m.send(() => m.mockInput.typeText("/x"), 5);
137
+ await m.send(() => m.mockInput.pressEnter(), 5);
138
+ await m.settle(400);
139
+ expect(m.pending).toHaveLength(1);
140
+ // The old row-0 server comes back, but second.
141
+ await m.send(() =>
142
+ m.pending[0]?.resolve(["com.new/first", "com.old/alpha"]),
143
+ );
144
+ await m.send(() => m.mockInput.pressEnter(), 100);
145
+
146
+ expect(m.mcpJson()).toContain("com.new/first");
147
+ expect(m.mcpJson()).not.toContain("com.old/alpha");
148
+ });
149
+ });
150
+
151
+ describe("registry screen: the official badge follows the search", () => {
152
+ const rowLines = (frame: string, needle: string) =>
153
+ frame.split("\n").filter((line) => line.includes(needle));
154
+
155
+ test("the unfiltered list shows no official badge on any row", async () => {
156
+ const m = await mount([
157
+ "com.linear/mcp",
158
+ "ai.smithery/smithery-notion",
159
+ "com.notion/mcp",
160
+ ]);
161
+ await m.send(() => m.settle(400)); // the initial load runs behind the debounce
162
+ expect(m.frame()).toContain("notion/mcp"); // the rows did render
163
+ expect(m.frame()).not.toContain("official");
164
+ });
165
+
166
+ test("after searching notion only the real notion server is badged, and it is first", async () => {
167
+ const m = await mount([
168
+ "com.linear/mcp",
169
+ "ai.smithery/smithery-notion",
170
+ "com.notion/mcp",
171
+ ]);
172
+ await m.send(() => m.mockInput.typeText("/notion"), 5);
173
+ await m.send(() => m.mockInput.pressEnter(), 5);
174
+ await m.settle(400);
175
+ expect(m.pending).toHaveLength(1);
176
+ // The endpoint answers with the third-party server before the real one.
177
+ await m.send(() =>
178
+ m.pending[0]?.resolve(["ai.smithery/smithery-notion", "com.notion/mcp"]),
179
+ );
180
+
181
+ await m.send(() => m.settle(100));
182
+ const frame = m.frame();
183
+ const lines = frame.split("\n");
184
+ const notionRow = lines.findIndex((l) => l.includes("com.notion/mcp"));
185
+ const smitheryRow = lines.findIndex((l) =>
186
+ l.includes("ai.smithery/smithery-notion"),
187
+ );
188
+ expect(notionRow).toBeGreaterThanOrEqual(0);
189
+ expect(notionRow).toBeLessThan(smitheryRow); // official first
190
+ expect(lines[notionRow]).toContain("official");
191
+ expect(rowLines(frame, "smithery-notion").join("\n")).not.toContain(
192
+ "official",
193
+ );
194
+ });
195
+ });
@@ -0,0 +1,209 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { parseKeypress } from "@opentui/core";
3
+ import {
4
+ createLatestOnlyGuard,
5
+ isOfficialMcpServer,
6
+ officialFirst,
7
+ } from "../services/mcp-registry.js";
8
+ import type { KeyPress } from "../ui/input/key-dispatch.js";
9
+ import { SCREEN_BINDINGS, matchBinding } from "../ui/keymap.js";
10
+
11
+ // REGRESSION: MTL-337 registry screen installs the wrong server / no remove / no official first — Fixed in /dev:fix session dev-fix-20261101-153739-a08a5829
12
+
13
+ const srv = (name: string, url: string) => ({
14
+ name,
15
+ url,
16
+ short_description: "",
17
+ });
18
+
19
+ const press = (raw: string): KeyPress => {
20
+ const k = parseKeypress(raw);
21
+ return {
22
+ name: k?.name ?? "",
23
+ sequence: k?.sequence ?? "",
24
+ ctrl: k?.ctrl === true,
25
+ meta: k?.meta === true,
26
+ shift: k?.shift === true,
27
+ };
28
+ };
29
+
30
+ describe("isOfficialMcpServer", () => {
31
+ const notion = srv("com.notion/mcp", "https://mcp.notion.com/mcp");
32
+
33
+ test("an empty or whitespace query makes nothing official", () => {
34
+ expect(isOfficialMcpServer(notion, "")).toBe(false);
35
+ expect(isOfficialMcpServer(notion, " ")).toBe(false);
36
+ });
37
+
38
+ test("com.notion/mcp served from mcp.notion.com is official for the query notion", () => {
39
+ expect(isOfficialMcpServer(notion, "notion")).toBe(true);
40
+ });
41
+
42
+ test("a third-party host is not official for the brand it mentions", () => {
43
+ expect(
44
+ isOfficialMcpServer(
45
+ srv("ai.smithery/smithery-notion", "https://server.smithery.ai/notion"),
46
+ "notion",
47
+ ),
48
+ ).toBe(false);
49
+ expect(
50
+ isOfficialMcpServer(
51
+ srv("com.foreverworks/notion", "https://mcp.foreverworks.com/notion"),
52
+ "notion",
53
+ ),
54
+ ).toBe(false);
55
+ });
56
+
57
+ test("a server is official for its own brand, but not for another brand's query", () => {
58
+ const smithery = srv(
59
+ "ai.smithery/smithery-notion",
60
+ "https://server.smithery.ai/notion",
61
+ );
62
+ expect(isOfficialMcpServer(smithery, "smithery")).toBe(true);
63
+ expect(isOfficialMcpServer(notion, "smithery")).toBe(false);
64
+ });
65
+
66
+ test("the query is case-insensitive and split into words", () => {
67
+ expect(isOfficialMcpServer(notion, "Notion MCP")).toBe(true);
68
+ expect(isOfficialMcpServer(notion, "mcp-NOTION")).toBe(true);
69
+ });
70
+
71
+ test("a word must equal the brand, a substring does not count", () => {
72
+ expect(isOfficialMcpServer(notion, "notional")).toBe(false);
73
+ expect(isOfficialMcpServer(notion, "not")).toBe(false);
74
+ });
75
+
76
+ test("com.mcparmory/notion packaged on pypi is not official", () => {
77
+ expect(
78
+ isOfficialMcpServer(
79
+ srv("com.mcparmory/notion", "pypi:mcparmory-notion"),
80
+ "mcparmory notion",
81
+ ),
82
+ ).toBe(false);
83
+ });
84
+
85
+ test("an io.github.<owner> namespace is never official, even when the host matches", () => {
86
+ expect(
87
+ isOfficialMcpServer(
88
+ srv("io.github.someone/notion", "https://github.io/someone/notion"),
89
+ "github someone notion",
90
+ ),
91
+ ).toBe(false);
92
+ expect(
93
+ isOfficialMcpServer(
94
+ srv("io.github.notion/mcp", "https://mcp.github.io/mcp"),
95
+ "github notion",
96
+ ),
97
+ ).toBe(false);
98
+ });
99
+
100
+ test("npm: and pypi: sources are not official", () => {
101
+ expect(
102
+ isOfficialMcpServer(srv("com.notion/mcp", "npm:@notion/mcp"), "notion"),
103
+ ).toBe(false);
104
+ expect(
105
+ isOfficialMcpServer(srv("com.notion/mcp", "pypi:notion-mcp"), "notion"),
106
+ ).toBe(false);
107
+ });
108
+
109
+ test("an unparsable URL is not official", () => {
110
+ expect(
111
+ isOfficialMcpServer(srv("com.notion/mcp", "not a url"), "notion"),
112
+ ).toBe(false);
113
+ expect(isOfficialMcpServer(srv("com.notion/mcp", ""), "notion")).toBe(
114
+ false,
115
+ );
116
+ });
117
+
118
+ test("a namespace that is only a string prefix of the host does not match on a label boundary", () => {
119
+ expect(
120
+ isOfficialMcpServer(srv("com.not/x", "https://notion.com/mcp"), "not"),
121
+ ).toBe(false);
122
+ expect(
123
+ isOfficialMcpServer(srv("com.not/x", "https://notion.com/mcp"), "notion"),
124
+ ).toBe(false);
125
+ });
126
+ });
127
+
128
+ describe("officialFirst", () => {
129
+ const a = srv("io.github.a/one", "https://a.example.org/mcp");
130
+ const o1 = srv("com.notion/mcp", "https://mcp.notion.com/mcp");
131
+ const b = srv("com.mcparmory/notion", "pypi:mcparmory-notion");
132
+ const o2 = srv("com.notion/second", "https://api.notion.com/second");
133
+ const other = srv("com.linear/mcp", "https://mcp.linear.com/mcp");
134
+
135
+ test("official servers move to the front, endpoint order kept inside each group", () => {
136
+ expect(officialFirst([a, o1, b, other, o2], "notion")).toEqual([
137
+ o1,
138
+ o2,
139
+ a,
140
+ b,
141
+ other,
142
+ ]);
143
+ });
144
+
145
+ test("with an empty query the order is unchanged", () => {
146
+ expect(officialFirst([a, o1, b, o2], "")).toEqual([a, o1, b, o2]);
147
+ });
148
+
149
+ test("does not mutate its input", () => {
150
+ const input = [a, o1, b];
151
+ const snapshot = [...input];
152
+ const out = officialFirst(input, "notion");
153
+ expect(input).toEqual(snapshot);
154
+ expect(out).not.toBe(input);
155
+ });
156
+
157
+ test("an empty list stays empty", () => {
158
+ expect(officialFirst([], "notion")).toEqual([]);
159
+ });
160
+ });
161
+
162
+ describe("MCP registry keymap", () => {
163
+ test("d is bound to a remove action on the mcp-registry screen", () => {
164
+ expect(matchBinding(SCREEN_BINDINGS["mcp-registry"], press("d"))?.id).toBe(
165
+ "remove",
166
+ );
167
+ });
168
+ });
169
+
170
+ describe("stale registry responses", () => {
171
+ // The screen (McpRegistryScreen.tsx) is a React/OpenTUI component whose
172
+ // loadServers closes over component state; the repo has no harness that
173
+ // drives it with fake fetches. The guard is therefore tested as the small
174
+ // pure helper the screen will call: take a token before each request, apply
175
+ // the response only when isLatest(token) still holds.
176
+ test("only the newest request's response is applied when responses arrive out of order", async () => {
177
+ const guard = createLatestOnlyGuard();
178
+ const deferred = <T>() => {
179
+ let resolve!: (v: T) => void;
180
+ const promise = new Promise<T>((r) => {
181
+ resolve = r;
182
+ });
183
+ return { promise, resolve };
184
+ };
185
+ const first = deferred<string[]>();
186
+ const second = deferred<string[]>();
187
+ let applied: string[] | null = null;
188
+ const appliedFrom: string[] = [];
189
+
190
+ const load = async (label: string, p: Promise<string[]>) => {
191
+ const token = guard.next();
192
+ const result = await p;
193
+ if (guard.isLatest(token)) {
194
+ applied = result;
195
+ appliedFrom.push(label);
196
+ }
197
+ };
198
+
199
+ const a = load("old", first.promise);
200
+ const b = load("new", second.promise);
201
+ second.resolve(["new-server"]); // newest answers first
202
+ await b;
203
+ first.resolve(["old-server"]); // stale answer lands last
204
+ await a;
205
+
206
+ expect(applied).toEqual(["new-server"]);
207
+ expect(appliedFrom).toEqual(["new"]);
208
+ });
209
+ });
@@ -0,0 +1,46 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { isNotInstalledAnswer } from "../services/claude-cli.js";
3
+
4
+ /**
5
+ * `isNotInstalledAnswer` classifies the CLI's answer to an uninstall at a
6
+ * scope that holds nothing for this folder. Claude Code has two wordings for
7
+ * it, and both mean "nothing to do here", never a failure.
8
+ *
9
+ * MEASURED on Claude Code 2.1.312, from a folder with no registry row while
10
+ * other folders hold PROJECT rows for the plugin: `--scope user` and
11
+ * `--scope local` exit 1 with the strings below. The older wording is what the
12
+ * CLI says when no folder holds a row at all.
13
+ */
14
+
15
+ const ID = "code-analysis@magus";
16
+
17
+ /** The exact text `claude plugin uninstall … --scope user` printed. */
18
+ const MEASURED_USER = `✘ Failed to uninstall plugin "${ID}": Plugin "${ID}" is installed in project scope, not user. Use --scope project to uninstall.`;
19
+ /** The exact text `claude plugin uninstall … --scope local` printed. */
20
+ const MEASURED_LOCAL = `✘ Failed to uninstall plugin "${ID}": Plugin "${ID}" is installed in project scope, not local. Use --scope project to uninstall.`;
21
+
22
+ describe("isNotInstalledAnswer", () => {
23
+ // REGRESSION: deprecated plugin `d` failed with "installed in project scope, not user" and the row came back — Fixed in /dev:fix session dev-fix-20261029-2140-uninstall
24
+ test("should treat the measured 'installed in project scope, not user' answer as nothing to remove", () => {
25
+ expect(isNotInstalledAnswer(new Error(MEASURED_USER))).toBe(true);
26
+ });
27
+
28
+ // REGRESSION: deprecated plugin `d` failed with "installed in project scope, not user" and the row came back — Fixed in /dev:fix session dev-fix-20261029-2140-uninstall
29
+ test("should treat the measured 'installed in project scope, not local' answer as nothing to remove", () => {
30
+ expect(isNotInstalledAnswer(new Error(MEASURED_LOCAL))).toBe(true);
31
+ });
32
+
33
+ // REGRESSION: deprecated plugin `d` failed with "installed in project scope, not user" and the row came back — Fixed in /dev:fix session dev-fix-20261029-2140-uninstall
34
+ test("should still treat the older 'not installed in user scope' answer as nothing to remove", () => {
35
+ expect(
36
+ isNotInstalledAnswer(
37
+ new Error(`Plugin "${ID}" is not installed in user scope.`),
38
+ ),
39
+ ).toBe(true);
40
+ });
41
+
42
+ // REGRESSION: deprecated plugin `d` failed with "installed in project scope, not user" and the row came back — Fixed in /dev:fix session dev-fix-20261029-2140-uninstall
43
+ test("should still count an unrelated error as a failure", () => {
44
+ expect(isNotInstalledAnswer(new Error("claude timed out"))).toBe(false);
45
+ });
46
+ });