claudeup 4.40.1 → 4.42.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudeup",
3
- "version": "4.40.1",
3
+ "version": "4.42.0",
4
4
  "description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
5
5
  "type": "module",
6
6
  "main": "src/main.tsx",
@@ -64,8 +64,8 @@
64
64
  "typescript": "^5.6.3"
65
65
  },
66
66
  "optionalDependencies": {
67
- "claudeup-darwin-arm64": "4.40.1",
68
- "claudeup-darwin-x64": "4.40.1",
69
- "claudeup-linux-x64": "4.40.1"
67
+ "claudeup-darwin-arm64": "4.42.0",
68
+ "claudeup-darwin-x64": "4.42.0",
69
+ "claudeup-linux-x64": "4.42.0"
70
70
  }
71
71
  }
@@ -0,0 +1,98 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import {
3
+ COMPONENT_BADGE,
4
+ CONTRAST_REFERENCE_KEYS,
5
+ componentBadge,
6
+ resetThemeMode,
7
+ setThemeMode,
8
+ } from "../ui/theme-mode.js";
9
+
10
+ afterEach(() => {
11
+ resetThemeMode();
12
+ });
13
+
14
+ /** WCAG relative luminance of a #rrggbb string. */
15
+ function luminance(hex: string): number {
16
+ const channel = (v: number) => {
17
+ const c = v / 255;
18
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
19
+ };
20
+ const n = Number.parseInt(hex.slice(1), 16);
21
+ return (
22
+ 0.2126 * channel((n >> 16) & 0xff) +
23
+ 0.7152 * channel((n >> 8) & 0xff) +
24
+ 0.0722 * channel(n & 0xff)
25
+ );
26
+ }
27
+
28
+ function contrast(a: string, b: string): number {
29
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
30
+ return (hi + 0.05) / (lo + 0.05);
31
+ }
32
+
33
+ const KINDS = ["skill", "command", "agent", "mcp"] as const;
34
+
35
+ describe("componentBadge", () => {
36
+ it("returns no chip when the terminal never answered the theme query", () => {
37
+ // Unknown means no tint, the same rule the scope bar's fill follows: a
38
+ // light wash guessed onto a dark page is a bright band on every row.
39
+ for (const kind of KINDS) {
40
+ expect(componentBadge(kind)).toBeNull();
41
+ }
42
+ });
43
+
44
+ it("serves the light pair on a light terminal", () => {
45
+ setThemeMode("light");
46
+
47
+ expect(componentBadge("skill")).toEqual(COMPONENT_BADGE.skill.light);
48
+ expect(componentBadge("mcp")).toEqual(COMPONENT_BADGE.mcp.light);
49
+ });
50
+
51
+ it("serves the dark pair on a dark terminal", () => {
52
+ setThemeMode("dark");
53
+
54
+ expect(componentBadge("skill")).toEqual(COMPONENT_BADGE.skill.dark);
55
+ expect(componentBadge("mcp")).toEqual(COMPONENT_BADGE.mcp.dark);
56
+ });
57
+
58
+ it("keeps every glyph legible on its own chip", () => {
59
+ // 3:1 is the floor the rest of the palette is measured to. A chip whose
60
+ // glyph disappears into its wash is worse than no chip at all.
61
+ for (const kind of KINDS) {
62
+ for (const mode of ["light", "dark"] as const) {
63
+ const { bg, fg } = COMPONENT_BADGE[kind][mode];
64
+ expect(contrast(bg, fg)).toBeGreaterThanOrEqual(3);
65
+ }
66
+ }
67
+ });
68
+
69
+ it("keeps every chip quiet against the page it is drawn on", () => {
70
+ // The wash must read as a tint of the page, not as a block on it. Above
71
+ // ~1.6 it stops being a chip and starts being a highlight bar.
72
+ for (const kind of KINDS) {
73
+ for (const mode of ["light", "dark"] as const) {
74
+ const { bg } = COMPONENT_BADGE[kind][mode];
75
+ const page = CONTRAST_REFERENCE_KEYS[mode];
76
+ expect(contrast(bg, page)).toBeLessThan(1.6);
77
+ }
78
+ }
79
+ });
80
+
81
+ it("mirrors the reference pages theme.ts measures accents against", async () => {
82
+ // The duplicate exists so this module stays free of the renderer import.
83
+ // If theme.ts ever restates its backgrounds, the chips must move with it.
84
+ const { CONTRAST_REFERENCE } = await import("../ui/theme.js");
85
+
86
+ expect(CONTRAST_REFERENCE_KEYS.light).toBe(CONTRAST_REFERENCE.light);
87
+ expect(CONTRAST_REFERENCE_KEYS.dark).toBe(CONTRAST_REFERENCE.dark);
88
+ });
89
+
90
+ it("gives each kind a distinguishable hue", () => {
91
+ // Single-character colour carries no readable hue *shift*, only a readable
92
+ // hue *difference* — the same reason the scope squares are far apart.
93
+ for (const mode of ["light", "dark"] as const) {
94
+ const fills = KINDS.map((k) => COMPONENT_BADGE[k][mode].bg);
95
+ expect(new Set(fills).size).toBe(KINDS.length);
96
+ }
97
+ });
98
+ });
@@ -0,0 +1,94 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { COMPONENT_ICON } from "../ui/renderers/pluginRenderers.js";
3
+
4
+ /**
5
+ * Every component badge is exactly one terminal cell wide.
6
+ *
7
+ * This is the constraint that rules out the icons the ecosystem actually uses:
8
+ * Cursor's skill badge defaults to a lightning bolt and the community VS Code
9
+ * Skills sidebar uses a sparkle, but `⚡` (U+26A1) and `✨` (U+2728) are both
10
+ * East-Asian-Width Wide — two cells — and a two-cell badge shifts every name on
11
+ * the row out of alignment with the plugin names above it.
12
+ *
13
+ * Unicode has no `east_asian_width` in the JS standard library, so the wide
14
+ * ranges are spelled out. They cover the emoji and CJK blocks a plausible icon
15
+ * would come from; anything outside them is single-width in practice.
16
+ */
17
+ const WIDE_RANGES: ReadonlyArray<readonly [number, number]> = [
18
+ [0x1100, 0x115f], // Hangul Jamo
19
+ [0x2329, 0x232a], // angle brackets
20
+ [0x2e80, 0x303e], // CJK radicals, Kangxi
21
+ [0x3041, 0x33ff], // Hiragana through CJK compatibility
22
+ [0x3400, 0x4dbf], // CJK Extension A
23
+ [0x4e00, 0x9fff], // CJK Unified Ideographs
24
+ [0xa000, 0xa4cf], // Yi
25
+ [0xac00, 0xd7a3], // Hangul syllables
26
+ [0xf900, 0xfaff], // CJK compatibility ideographs
27
+ [0xfe30, 0xfe6f], // CJK compatibility forms
28
+ [0xff00, 0xff60], // fullwidth forms
29
+ [0xffe0, 0xffe6], // fullwidth signs
30
+ [0x1f300, 0x1f64f], // misc symbols and pictographs, emoticons
31
+ [0x1f900, 0x1f9ff], // supplemental symbols and pictographs
32
+ [0x20000, 0x3fffd], // CJK Extension B and beyond
33
+ // Individually Wide despite sitting among narrow neighbours. These are the
34
+ // ones that actually bite: ⚡ and ✨ are exactly the icons a future editor
35
+ // would reach for.
36
+ [0x231a, 0x231b], // watch, hourglass
37
+ [0x23e9, 0x23ec],
38
+ [0x25fd, 0x25fe],
39
+ [0x2614, 0x2615],
40
+ [0x26a1, 0x26a1], // ⚡ HIGH VOLTAGE SIGN
41
+ [0x2728, 0x2728], // ✨ SPARKLES
42
+ [0x274c, 0x274c],
43
+ [0x2b1b, 0x2b1c],
44
+ [0x2b50, 0x2b50],
45
+ ];
46
+
47
+ function isWide(ch: string): boolean {
48
+ const cp = ch.codePointAt(0) ?? 0;
49
+ return WIDE_RANGES.some(([lo, hi]) => cp >= lo && cp <= hi);
50
+ }
51
+
52
+ describe("component icons", () => {
53
+ it("covers every component kind", () => {
54
+ expect(Object.keys(COMPONENT_ICON).sort()).toEqual([
55
+ "agent",
56
+ "command",
57
+ "mcp",
58
+ "skill",
59
+ ]);
60
+ });
61
+
62
+ it("uses exactly one code point per icon", () => {
63
+ for (const [kind, icon] of Object.entries(COMPONENT_ICON)) {
64
+ expect([...icon], `${kind} must be a single code point`).toHaveLength(1);
65
+ }
66
+ });
67
+
68
+ it("never uses a double-width glyph", () => {
69
+ for (const [kind, icon] of Object.entries(COMPONENT_ICON)) {
70
+ expect(isWide(icon), `${kind} icon ${icon} is double-width`).toBe(false);
71
+ }
72
+ });
73
+
74
+ it("rejects the two emoji a future editor would reach for", () => {
75
+ // Negative control: the guard above only means something if it fires.
76
+ expect(isWide("⚡")).toBe(true);
77
+ expect(isWide("✨")).toBe(true);
78
+ expect(isWide("📁")).toBe(true);
79
+ });
80
+
81
+ it("gives each kind a distinct glyph", () => {
82
+ const icons = Object.values(COMPONENT_ICON);
83
+ expect(new Set(icons).size).toBe(icons.length);
84
+ });
85
+
86
+ it("does not reuse a glyph this screen already means something by", () => {
87
+ // ★ is "★ Official" on a marketplace row, ● is "● Installed", ■ is a scope
88
+ // segment, and ▼ / ▶ are the expand arrows. Reusing one would make a badge
89
+ // read as a status.
90
+ for (const taken of ["★", "●", "■", "▼", "▶", "○", "✓", "⚠"]) {
91
+ expect(Object.values(COMPONENT_ICON)).not.toContain(taken);
92
+ }
93
+ });
94
+ });
@@ -0,0 +1,174 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import fs from "fs-extra";
5
+ import {
6
+ scanMcpServers,
7
+ scanSkillDirs,
8
+ } from "../services/local-marketplace.js";
9
+ import { walkMarkdown, walkSkillDirs } from "../services/skills-manager.js";
10
+
11
+ /**
12
+ * The skill count in the plugin detail panel comes from a scan of the cloned
13
+ * marketplace. It used to read only the top level of `skills/`, which counts
14
+ * the *grouping* directories rather than the skills: dev reported 9 against 43
15
+ * on disk, mattpocock 5 against 37.
16
+ */
17
+ describe("scanSkillDirs", () => {
18
+ let root: string;
19
+
20
+ beforeEach(async () => {
21
+ root = await fs.mkdtemp(path.join(os.tmpdir(), "claudeup-skills-"));
22
+ });
23
+
24
+ afterEach(async () => {
25
+ await fs.remove(root);
26
+ });
27
+
28
+ async function writeSkill(...segments: string[]): Promise<void> {
29
+ const dir = path.join(root, ...segments);
30
+ await fs.ensureDir(dir);
31
+ await fs.writeFile(path.join(dir, "SKILL.md"), "---\nname: x\n---\n");
32
+ }
33
+
34
+ it("counts skills nested under grouping directories", async () => {
35
+ await writeSkill("skills", "frontend", "design-system");
36
+ await writeSkill("skills", "frontend", "forms");
37
+ await writeSkill("skills", "backend", "db-branching");
38
+ await writeSkill("skills", "flat-skill");
39
+
40
+ const found = await scanSkillDirs(path.join(root, "skills"));
41
+
42
+ expect(found.sort()).toEqual([
43
+ "db-branching",
44
+ "design-system",
45
+ "flat-skill",
46
+ "forms",
47
+ ]);
48
+ });
49
+
50
+ it("never counts a grouping directory as a skill", async () => {
51
+ await writeSkill("skills", "engineering", "code-review");
52
+ await writeSkill("skills", "engineering", "tdd");
53
+
54
+ const found = await scanSkillDirs(path.join(root, "skills"));
55
+
56
+ expect(found.sort()).toEqual(["code-review", "tdd"]);
57
+ expect(found).not.toContain("engineering");
58
+ });
59
+
60
+ it("returns nothing when the directory is absent", async () => {
61
+ expect(await scanSkillDirs(path.join(root, "nope"))).toEqual([]);
62
+ });
63
+
64
+ it("stops descending past four levels rather than walking the whole tree", async () => {
65
+ await writeSkill("skills", "a", "b", "c", "d", "too-deep");
66
+
67
+ expect(await scanSkillDirs(path.join(root, "skills"))).toEqual([]);
68
+ });
69
+ });
70
+
71
+ /**
72
+ * mnemex ships one MCP server and nothing else. The scan only looked in an
73
+ * `mcp-servers/` directory, so it reported zero of every component kind and the
74
+ * plugin read as shipping nothing at all.
75
+ */
76
+ describe("scanMcpServers", () => {
77
+ let root: string;
78
+
79
+ beforeEach(async () => {
80
+ root = await fs.mkdtemp(path.join(os.tmpdir(), "claudeup-mcp-"));
81
+ });
82
+
83
+ afterEach(async () => {
84
+ await fs.remove(root);
85
+ });
86
+
87
+ it("reads server names from a root .mcp.json", async () => {
88
+ await fs.writeJson(path.join(root, ".mcp.json"), {
89
+ mnemex: { command: "mnemex", args: ["--mcp"] },
90
+ });
91
+
92
+ expect(await scanMcpServers(root)).toEqual(["mnemex"]);
93
+ });
94
+
95
+ it("reads the mcpServers wrapper shape too", async () => {
96
+ await fs.writeJson(path.join(root, ".mcp.json"), {
97
+ mcpServers: { alpha: { command: "a" }, beta: { command: "b" } },
98
+ });
99
+
100
+ expect((await scanMcpServers(root)).sort()).toEqual(["alpha", "beta"]);
101
+ });
102
+
103
+ it("still reads an mcp-servers/ directory", async () => {
104
+ await fs.ensureDir(path.join(root, "mcp-servers"));
105
+ await fs.writeJson(path.join(root, "mcp-servers", "tmux.json"), {});
106
+
107
+ expect(await scanMcpServers(root)).toEqual(["tmux"]);
108
+ });
109
+
110
+ it("reports a server rather than zero when .mcp.json will not parse", async () => {
111
+ await fs.writeFile(path.join(root, ".mcp.json"), "{ not json");
112
+
113
+ expect(await scanMcpServers(root)).toEqual(["mcp"]);
114
+ });
115
+
116
+ it("reports none when the plugin declares no server", async () => {
117
+ expect(await scanMcpServers(root)).toEqual([]);
118
+ });
119
+ });
120
+
121
+ /**
122
+ * The clone walkers behind the local-first read. A marketplace the user has
123
+ * added is on disk, so listing its plugins must cost no GitHub request — the
124
+ * hourly cap of 60 unauthenticated requests is shared across every repo on
125
+ * screen, and once it ran out every expand answered "Could not read the plugin",
126
+ * including for marketplaces sitting complete in the cache.
127
+ */
128
+ describe("clone walkers", () => {
129
+ let root: string;
130
+
131
+ beforeEach(async () => {
132
+ root = await fs.mkdtemp(path.join(os.tmpdir(), "claudeup-walk-"));
133
+ });
134
+
135
+ afterEach(async () => {
136
+ await fs.remove(root);
137
+ });
138
+
139
+ async function write(rel: string, body = "x"): Promise<void> {
140
+ const full = path.join(root, rel);
141
+ await fs.ensureDir(path.dirname(full));
142
+ await fs.writeFile(full, body);
143
+ }
144
+
145
+ it("carries the grouping path so nested skills match the network shape", async () => {
146
+ await write("skills/frontend/design-system/SKILL.md");
147
+ await write("skills/backend/db/SKILL.md");
148
+ await write("skills/flat/SKILL.md");
149
+
150
+ const found = await walkSkillDirs(path.join(root, "skills"));
151
+ const byName = Object.fromEntries(found.map((f) => [f.name, f.group]));
152
+
153
+ expect(Object.keys(byName).sort()).toEqual(["db", "design-system", "flat"]);
154
+ expect(byName["design-system"]).toBe("frontend");
155
+ expect(byName.db).toBe("backend");
156
+ expect(byName.flat).toBeUndefined();
157
+ });
158
+
159
+ it("lists markdown commands and skips README", async () => {
160
+ await write("commands/go.md");
161
+ await write("commands/README.md");
162
+ await write("commands/deep/trace.md");
163
+
164
+ const found = await walkMarkdown(path.join(root, "commands"));
165
+
166
+ expect(found.map((f) => f.name).sort()).toEqual(["go", "trace"]);
167
+ expect(found.find((f) => f.name === "trace")?.group).toBe("deep");
168
+ });
169
+
170
+ it("returns nothing for a directory that is not there", async () => {
171
+ expect(await walkSkillDirs(path.join(root, "nope"))).toEqual([]);
172
+ expect(await walkMarkdown(path.join(root, "nope"))).toEqual([]);
173
+ });
174
+ });
@@ -0,0 +1,143 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { renderMarkdown } from "../ui/renderers/markdownRenderer.js";
3
+
4
+ /**
5
+ * The renderer emits OpenTUI elements, so these assert structure and counts
6
+ * rather than pixels: how many lines a construct produces, and that the raw
7
+ * markers (fences, pipes, hashes) do not survive into the output.
8
+ */
9
+
10
+ /** Flatten a rendered node tree to the text a reader would see. */
11
+ // biome-ignore lint/suspicious/noExplicitAny: walking opaque element trees
12
+ function textOf(node: any): string {
13
+ if (node === null || node === undefined || typeof node === "boolean") {
14
+ return "";
15
+ }
16
+ if (typeof node === "string" || typeof node === "number") return String(node);
17
+ if (Array.isArray(node)) return node.map(textOf).join("");
18
+ if (node.props) return textOf(node.props.children);
19
+ return "";
20
+ }
21
+
22
+ const render = (src: string, width = 80) =>
23
+ renderMarkdown(src, { width, maxLines: 500 });
24
+
25
+ describe("renderMarkdown", () => {
26
+ it("drops the # markers from a heading", () => {
27
+ const { nodes } = render("## Routing");
28
+
29
+ expect(nodes).toHaveLength(1);
30
+ expect(textOf(nodes[0])).toBe("Routing");
31
+ });
32
+
33
+ it("drops the fence markers and keeps the code", () => {
34
+ const { nodes } = render(
35
+ ["```bash", "which mnemex && mnemex --version", "```"].join("\n"),
36
+ );
37
+
38
+ const text = nodes.map(textOf).join("\n");
39
+ expect(text).not.toContain("```");
40
+ expect(text).toContain("which mnemex && mnemex --version");
41
+ expect(text).toContain("bash");
42
+ });
43
+
44
+ it("turns a pipe table into aligned columns with a rule, no pipes", () => {
45
+ const { nodes } = render(
46
+ [
47
+ "| Mode | Keywords |",
48
+ "|------|----------|",
49
+ "| Bug | debug, error |",
50
+ "| Test | coverage |",
51
+ ].join("\n"),
52
+ );
53
+
54
+ // header + rule + 2 body rows
55
+ expect(nodes).toHaveLength(4);
56
+ const text = nodes.map(textOf).join("\n");
57
+ expect(text).not.toContain("|");
58
+ expect(text).toContain("Mode");
59
+ expect(text).toContain("debug, error");
60
+ expect(textOf(nodes[1])).toMatch(/^\s*─+\s+─+$/);
61
+ });
62
+
63
+ it("narrows the widest table column instead of overflowing the pane", () => {
64
+ const { nodes } = render(
65
+ ["| Mode | Notes |", "|---|---|", `| Bug | ${"x".repeat(120)} |`].join(
66
+ "\n",
67
+ ),
68
+ 40,
69
+ );
70
+
71
+ for (const node of nodes) {
72
+ expect(textOf(node).length).toBeLessThanOrEqual(41);
73
+ }
74
+ expect(nodes.map(textOf).join("")).toContain("…");
75
+ });
76
+
77
+ it("strips inline markers inside table cells", () => {
78
+ const { nodes } = render(
79
+ [
80
+ "| Mode | Primary Commands |",
81
+ "|---|---|",
82
+ "| Bug | `context`, `callers` |",
83
+ "| Arch | **map** |",
84
+ ].join("\n"),
85
+ );
86
+
87
+ const text = nodes.map(textOf).join("\n");
88
+ expect(text).toContain("context, callers");
89
+ expect(text).not.toContain("`");
90
+ expect(text).not.toContain("**");
91
+ });
92
+
93
+ it("draws a rule for --- rather than printing hyphens as text", () => {
94
+ const { nodes } = render("---", 30);
95
+
96
+ expect(textOf(nodes[0])).toMatch(/^─+$/);
97
+ });
98
+
99
+ it("renders a list bullet and keeps ordered markers", () => {
100
+ const { nodes } = render(["- first", "2. second"].join("\n"));
101
+
102
+ expect(textOf(nodes[0])).toContain("•");
103
+ expect(textOf(nodes[0])).toContain("first");
104
+ expect(textOf(nodes[1])).toContain("2.");
105
+ });
106
+
107
+ it("strips inline code backticks and bold asterisks", () => {
108
+ const { nodes } = render("Run `bun test` and **stop**.");
109
+
110
+ const text = textOf(nodes[0]);
111
+ expect(text).toBe("Run bun test and stop.");
112
+ });
113
+
114
+ it("does not read bold markers that sit inside a code span", () => {
115
+ const { nodes } = render("Use `a**b` here.");
116
+
117
+ expect(textOf(nodes[0])).toBe("Use a**b here.");
118
+ });
119
+
120
+ it("marks a blockquote with a bar instead of a > character", () => {
121
+ const { nodes } = render("> With a sufficient number of users");
122
+
123
+ const text = textOf(nodes[0]);
124
+ expect(text).toContain("│");
125
+ expect(text).not.toContain(">");
126
+ });
127
+
128
+ it("reports the lines it stopped short of", () => {
129
+ const long = Array.from({ length: 30 }, (_, i) => `line ${i}`).join("\n");
130
+
131
+ const { nodes, withheld } = renderMarkdown(long, {
132
+ width: 80,
133
+ maxLines: 10,
134
+ });
135
+
136
+ expect(nodes).toHaveLength(10);
137
+ expect(withheld).toBe(20);
138
+ });
139
+
140
+ it("withholds nothing when the whole document fits", () => {
141
+ expect(render("one\ntwo").withheld).toBe(0);
142
+ });
143
+ });
@@ -38,3 +38,91 @@ describe("marketplaces", () => {
38
38
  expect(names).not.toContain("superpowers");
39
39
  });
40
40
  });
41
+
42
+ /**
43
+ * The community marketplaces added alongside the Magus channels.
44
+ *
45
+ * `name` is not cosmetic: plugin ids are built as `${plugin.name}@${mpName}`
46
+ * (plugin-manager.ts), so it MUST equal the upstream
47
+ * .claude-plugin/marketplace.json "name" field. A mismatch fails at install
48
+ * time with "not found in marketplace", nowhere near the typo that caused it.
49
+ *
50
+ * Verify against upstream with:
51
+ * gh api repos/<repo>/contents/.claude-plugin/marketplace.json \
52
+ * -H "Accept: application/vnd.github.raw" | jq -r '.name'
53
+ */
54
+ const COMMUNITY_MARKETPLACES: ReadonlyArray<[name: string, repo: string]> = [
55
+ ["mattpocock", "mattpocock/skills"],
56
+ ["ecc", "affaan-m/everything-claude-code"],
57
+ ["ponytail", "DietrichGebert/ponytail"],
58
+ ["addy-agent-skills", "addyosmani/agent-skills"],
59
+ ["humanizer", "blader/humanizer"],
60
+ ["opendesign", "manalkaff/opendesign"],
61
+ // A fork, not the original: cursor/plugins keeps every manifest under
62
+ // `.cursor-plugin/` and carries no `.claude-plugin/marketplace.json`, so
63
+ // Claude Code cannot resolve it at all.
64
+ ["open-pstack", "ericlitman/open-pstack"],
65
+ ];
66
+
67
+ describe("community marketplaces", () => {
68
+ it.each(COMMUNITY_MARKETPLACES)(
69
+ "%s points at the repo whose manifest declares that name",
70
+ (name, repo) => {
71
+ const mp = defaultMarketplaces.find((m) => m.name === name);
72
+ expect(mp).toBeDefined();
73
+ expect(mp?.source.repo).toBe(repo);
74
+ },
75
+ );
76
+
77
+ it.each(COMMUNITY_MARKETPLACES)(
78
+ "%s is visible by default via the always-show gate",
79
+ (name) => {
80
+ const mp = defaultMarketplaces.find((m) => m.name === name);
81
+ // plugin-manager's gate: configured || official || featured || owned.
82
+ // Without one of these the entry is only a name-to-repo mapping and
83
+ // never appears, which is a silent no-op rather than a failure.
84
+ expect(mp?.official || mp?.featured || mp?.owned).toBe(true);
85
+ },
86
+ );
87
+
88
+ it.each(COMMUNITY_MARKETPLACES)(
89
+ "%s claims neither Anthropic nor MadAppGang ownership",
90
+ (name) => {
91
+ const mp = defaultMarketplaces.find((m) => m.name === name);
92
+ // `official` badges it as Anthropic-managed; `owned` sorts it above
93
+ // everything as a MadAppGang channel. Both would be a false claim.
94
+ expect(mp?.official).toBeUndefined();
95
+ expect(mp?.owned).toBeUndefined();
96
+ },
97
+ );
98
+
99
+ it("introduces no duplicate marketplace name or repo", () => {
100
+ const names = defaultMarketplaces.map((m) => m.name);
101
+ const repos = defaultMarketplaces.map((m) => m.source.repo.toLowerCase());
102
+
103
+ expect(new Set(names).size).toBe(names.length);
104
+ expect(new Set(repos).size).toBe(repos.length);
105
+ });
106
+
107
+ it("surfaces every community marketplace through getAllMarketplaces", () => {
108
+ const names = getAllMarketplaces().map((mp) => mp.name);
109
+
110
+ for (const [name] of COMMUNITY_MARKETPLACES) {
111
+ expect(names).toContain(name);
112
+ }
113
+ });
114
+
115
+ it("keeps the Magus channels sorted above the community ones", () => {
116
+ const order = getAllMarketplaces().map((mp) => mp.name);
117
+ const lastMagus = Math.max(
118
+ order.indexOf("magus"),
119
+ order.indexOf("magus-marketing"),
120
+ order.indexOf("magus-alpha"),
121
+ );
122
+ const firstCommunity = Math.min(
123
+ ...COMMUNITY_MARKETPLACES.map(([name]) => order.indexOf(name)),
124
+ );
125
+
126
+ expect(lastMagus).toBeLessThan(firstCommunity);
127
+ });
128
+ });
@@ -0,0 +1,66 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import path from "node:path";
3
+ import {
4
+ checkoutDirName,
5
+ checkoutPath,
6
+ ensureCheckout,
7
+ } from "../services/plugin-checkout.js";
8
+
9
+ /**
10
+ * A checkout is a read-only shallow clone claudeup owns, used so that browsing
11
+ * what a plugin contains never requires installing it and never spends the
12
+ * GitHub API budget (60 requests an hour, unauthenticated, shared across every
13
+ * repo on screen).
14
+ */
15
+ describe("checkoutDirName", () => {
16
+ it("flattens owner/repo into one filesystem-safe segment", () => {
17
+ expect(checkoutDirName("addyosmani/agent-skills")).toBe(
18
+ "addyosmani__agent-skills",
19
+ );
20
+ expect(checkoutDirName("MadAppGang/magus")).toBe("MadAppGang__magus");
21
+ });
22
+
23
+ it("never yields a name containing a path separator", () => {
24
+ for (const repo of [
25
+ "a/b",
26
+ "../../etc/passwd",
27
+ "owner/repo.git",
28
+ "weird name/with spaces",
29
+ ]) {
30
+ const name = checkoutDirName(repo);
31
+ expect(name).not.toContain("/");
32
+ expect(name).not.toContain("\\");
33
+ // A traversal attempt must not survive as one: `..` collapses into the
34
+ // same separator run as everything else, so the result cannot escape
35
+ // the checkout root.
36
+ expect(path.basename(name)).toBe(name);
37
+ }
38
+ });
39
+
40
+ it("keeps dots that are part of a real repo name", () => {
41
+ expect(checkoutDirName("owner/repo.js")).toBe("owner__repo.js");
42
+ });
43
+ });
44
+
45
+ describe("checkoutPath", () => {
46
+ it("puts every checkout under one root, outside Claude Code's own dirs", () => {
47
+ const a = checkoutPath("owner/one");
48
+ const b = checkoutPath("owner/two");
49
+
50
+ expect(path.dirname(a)).toBe(path.dirname(b));
51
+ expect(path.dirname(a)).toContain("claudeup-checkouts");
52
+ // Claude Code owns `plugins/`; a checkout must not look like installed
53
+ // state, or a reader could mistake it for something Claude Code loads.
54
+ expect(a).not.toContain(`${path.sep}plugins${path.sep}`);
55
+ });
56
+ });
57
+
58
+ describe("ensureCheckout", () => {
59
+ it("refuses to clone from a test run", async () => {
60
+ // Not a precaution. A test that only meant to exercise the tree-API path
61
+ // shallow-cloned a real GitHub repo, because stubbing `globalThis.fetch`
62
+ // does not stop `git`.
63
+ expect(process.env.NODE_ENV).toBe("test");
64
+ expect(await ensureCheckout("blader/humanizer")).toBeNull();
65
+ });
66
+ });