claudeup 4.41.0 → 4.42.1

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/bin/claudeup.js CHANGED
@@ -6,6 +6,11 @@
6
6
  * optionalDependency (claudeup-<platform>-<arch>) — that binary embeds the Bun
7
7
  * runtime, so it runs without Bun installed. Falls back to running from source
8
8
  * via Bun for dev checkouts and unsupported platforms.
9
+ *
10
+ * Set CLAUDEUP_NO_BINARY=1 to skip the prebuilt binary and force the source
11
+ * path. That escape hatch exists because the launcher PREFERS the binary: when
12
+ * a shipped binary cannot start on a given machine, without it claudeup is a
13
+ * hard block rather than a slow start.
9
14
  */
10
15
 
11
16
  import { spawnSync } from "node:child_process";
@@ -18,7 +23,8 @@ const require = createRequire(import.meta.url);
18
23
  const args = process.argv.slice(2);
19
24
  const { platform, arch } = process;
20
25
 
21
- // 1. Prefer the prebuilt platform binary.
26
+ // 1. Prefer the prebuilt platform binary, unless the source path is forced.
27
+ const forceSource = process.env.CLAUDEUP_NO_BINARY === "1";
22
28
  const pkgName = `claudeup-${platform}-${arch}`;
23
29
  let binaryPath = null;
24
30
  try {
@@ -29,9 +35,16 @@ try {
29
35
  // optional dep not installed for this platform — fall through
30
36
  }
31
37
 
32
- if (binaryPath) {
38
+ if (binaryPath && !forceSource) {
33
39
  const result = spawnSync(binaryPath, args, { stdio: "inherit" });
34
- process.exit(result.status ?? 0);
40
+ // A binary that could not be executed AT ALL (ENOENT, EACCES, bad arch)
41
+ // leaves status null, and `status ?? 0` then reported SUCCESS for a run that
42
+ // never happened — the worst answer available. Only trust the status when the
43
+ // process actually ran; otherwise say so and fall through to the source path.
44
+ if (!result.error) process.exit(result.status ?? 0);
45
+ console.error(
46
+ `claudeup: prebuilt binary could not start (${result.error.message}); falling back to source.`,
47
+ );
35
48
  }
36
49
 
37
50
  // 2. Fallback: run from source via Bun.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudeup",
3
- "version": "4.41.0",
3
+ "version": "4.42.1",
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.41.0",
68
- "claudeup-darwin-x64": "4.41.0",
69
- "claudeup-linux-x64": "4.41.0"
67
+ "claudeup-darwin-arm64": "4.42.1",
68
+ "claudeup-darwin-x64": "4.42.1",
69
+ "claudeup-linux-x64": "4.42.1"
70
70
  }
71
71
  }
@@ -14,6 +14,16 @@
14
14
  * only the one matching the host os/cpu, and bin/claudeup.js execs it.
15
15
  *
16
16
  * Hard constraint: claudeup pins @opentui 0.1.x — 0.4.x breaks --compile.
17
+ *
18
+ * Hard constraint: the two --no-compile-autoload flags below must stay. A Bun
19
+ * standalone executable autoloads .env and bunfig.toml from its CURRENT
20
+ * DIRECTORY by default, and claudeup reads neither on purpose. Bun 1.4.0 dies
21
+ * in the .env path when that file is a symlink pointing at a FIFO — exactly how
22
+ * 1Password serves secrets into a git worktree — exiting 1 with nothing on
23
+ * stdout or stderr, because the crash lands before any JS runs and therefore
24
+ * before any error handler exists. A FIFO also hands its bytes to whoever opens
25
+ * it first, so an autoloading claudeup can swallow the secrets a dev server was
26
+ * waiting for. Dropping the flags re-arms both failures.
17
27
  */
18
28
 
19
29
  import { $ } from "bun";
@@ -53,7 +63,8 @@ for (const t of TARGETS) {
53
63
  await mkdir(path.join(outDir, "bin"), { recursive: true });
54
64
 
55
65
  console.log(`Building ${pkgName} (${t.bunTarget})…`);
56
- await $`bun build --compile --target=${t.bunTarget} ${entry} --outfile ${binPath}`;
66
+ // The --no-compile-autoload flags are load-bearing; see the header.
67
+ await $`bun build --compile --no-compile-autoload-dotenv --no-compile-autoload-bunfig --target=${t.bunTarget} ${entry} --outfile ${binPath}`;
57
68
 
58
69
  // Platform package: os/cpu-restricted, ships only the binary, declares NO
59
70
  // `bin` (the main package's launcher resolves and execs bin/claudeup).
@@ -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,190 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { spawnSync } from "node:child_process";
3
+ import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { loadProjectDotenv, parseDotenv } from "../services/dotenv";
7
+
8
+ function mkfifo(path: string): void {
9
+ const r = spawnSync("mkfifo", [path]);
10
+ if (r.status !== 0) {
11
+ throw new Error(`mkfifo failed: ${r.stderr?.toString() ?? "unknown"}`);
12
+ }
13
+ }
14
+
15
+ describe("loadProjectDotenv", () => {
16
+ let dir: string;
17
+
18
+ beforeEach(async () => {
19
+ dir = await mkdtemp(join(tmpdir(), "claudeup-dotenv-"));
20
+ });
21
+
22
+ afterEach(async () => {
23
+ await rm(dir, { recursive: true, force: true });
24
+ });
25
+
26
+ it("loads a regular .env", async () => {
27
+ await writeFile(join(dir, ".env"), "FOO=bar\nBAZ=qux\n");
28
+ const env: Record<string, string | undefined> = {};
29
+
30
+ const r = loadProjectDotenv(dir, env);
31
+
32
+ expect(env.FOO).toBe("bar");
33
+ expect(env.BAZ).toBe("qux");
34
+ expect(r.loaded).toEqual([".env"]);
35
+ expect(r.warnings).toEqual([]);
36
+ });
37
+
38
+ it("never overwrites a variable already in the environment", async () => {
39
+ await writeFile(join(dir, ".env"), "FOO=from-file\n");
40
+ const env: Record<string, string | undefined> = { FOO: "from-shell" };
41
+
42
+ const r = loadProjectDotenv(dir, env);
43
+
44
+ expect(env.FOO).toBe("from-shell");
45
+ expect(r.applied).not.toContain("FOO");
46
+ });
47
+
48
+ it("lets .env.local win over .env", async () => {
49
+ await writeFile(join(dir, ".env"), "FOO=base\nONLY_BASE=yes\n");
50
+ await writeFile(join(dir, ".env.local"), "FOO=override\n");
51
+ const env: Record<string, string | undefined> = {};
52
+
53
+ const r = loadProjectDotenv(dir, env);
54
+
55
+ expect(env.FOO).toBe("override");
56
+ expect(env.ONLY_BASE).toBe("yes");
57
+ expect(r.loaded).toEqual([".env", ".env.local"]);
58
+ });
59
+
60
+ it("says nothing when there is no .env at all", () => {
61
+ const env: Record<string, string | undefined> = {};
62
+
63
+ const r = loadProjectDotenv(dir, env);
64
+
65
+ expect(r.loaded).toEqual([]);
66
+ expect(r.warnings).toEqual([]);
67
+ expect(env).toEqual({});
68
+ });
69
+
70
+ // The regression this whole module exists for. claudeup 4.41.0 shipped a
71
+ // binary whose embedded Bun autoloaded .env and aborted on this exact shape:
72
+ // exit 1, nothing on stdout, nothing on stderr, in every git worktree where
73
+ // 1Password serves secrets through a pipe.
74
+ //
75
+ // Note the failure mode if this ever regresses: opening a FIFO with no writer
76
+ // BLOCKS, so a broken loader hangs here rather than failing an assertion.
77
+ // A timeout on this test is the same bug, reported differently.
78
+ it("skips a .env that is a symlink to a FIFO, and warns", async () => {
79
+ mkfifo(join(dir, "secrets-pipe"));
80
+ await symlink(join(dir, "secrets-pipe"), join(dir, ".env"));
81
+ const env: Record<string, string | undefined> = {};
82
+
83
+ const r = loadProjectDotenv(dir, env);
84
+
85
+ expect(r.loaded).toEqual([]);
86
+ expect(env).toEqual({});
87
+ expect(r.warnings).toHaveLength(1);
88
+ expect(r.warnings[0]).toContain(".env");
89
+ expect(r.warnings[0]).toContain("named pipe");
90
+ });
91
+
92
+ it("skips a .env that is a FIFO directly, and warns", () => {
93
+ mkfifo(join(dir, ".env"));
94
+ const env: Record<string, string | undefined> = {};
95
+
96
+ const r = loadProjectDotenv(dir, env);
97
+
98
+ expect(r.loaded).toEqual([]);
99
+ expect(r.warnings).toHaveLength(1);
100
+ expect(r.warnings[0]).toContain("named pipe");
101
+ });
102
+
103
+ it("follows a symlink that points at a regular file", async () => {
104
+ await writeFile(join(dir, "real-env"), "FOO=bar\n");
105
+ await symlink(join(dir, "real-env"), join(dir, ".env"));
106
+ const env: Record<string, string | undefined> = {};
107
+
108
+ const r = loadProjectDotenv(dir, env);
109
+
110
+ expect(env.FOO).toBe("bar");
111
+ expect(r.loaded).toEqual([".env"]);
112
+ expect(r.warnings).toEqual([]);
113
+ });
114
+
115
+ it("warns instead of throwing on a dangling symlink", async () => {
116
+ await symlink(join(dir, "nothing-here"), join(dir, ".env"));
117
+ const env: Record<string, string | undefined> = {};
118
+
119
+ const r = loadProjectDotenv(dir, env);
120
+
121
+ expect(r.loaded).toEqual([]);
122
+ expect(r.warnings).toHaveLength(1);
123
+ expect(r.warnings[0]).toContain("does not resolve");
124
+ });
125
+
126
+ it("skips a .env that is a directory, and warns", async () => {
127
+ await mkdir(join(dir, ".env"));
128
+ const env: Record<string, string | undefined> = {};
129
+
130
+ const r = loadProjectDotenv(dir, env);
131
+
132
+ expect(r.loaded).toEqual([]);
133
+ expect(r.warnings[0]).toContain("directory");
134
+ });
135
+
136
+ it("loads what it can from a file with unparseable lines", async () => {
137
+ await writeFile(
138
+ join(dir, ".env"),
139
+ "this is not a pair\nFOO=bar\n=novalue\n",
140
+ );
141
+ const env: Record<string, string | undefined> = {};
142
+
143
+ const r = loadProjectDotenv(dir, env);
144
+
145
+ expect(env.FOO).toBe("bar");
146
+ expect(r.warnings).toEqual([]);
147
+ });
148
+ });
149
+
150
+ describe("parseDotenv", () => {
151
+ it("ignores comments and blank lines", () => {
152
+ expect(parseDotenv("# note\n\nFOO=bar\n")).toEqual({ FOO: "bar" });
153
+ });
154
+
155
+ it("strips an export prefix", () => {
156
+ expect(parseDotenv("export FOO=bar\n")).toEqual({ FOO: "bar" });
157
+ });
158
+
159
+ it("strips surrounding quotes", () => {
160
+ expect(parseDotenv(`FOO="bar"\nBAZ='qux'\n`)).toEqual({
161
+ FOO: "bar",
162
+ BAZ: "qux",
163
+ });
164
+ });
165
+
166
+ it("expands escapes in double quotes but not single quotes", () => {
167
+ expect(parseDotenv(`A="one\\ntwo"\nB='one\\ntwo'\n`)).toEqual({
168
+ A: "one\ntwo",
169
+ B: "one\\ntwo",
170
+ });
171
+ });
172
+
173
+ it("drops a trailing comment from an unquoted value", () => {
174
+ expect(parseDotenv("FOO=bar # why\n")).toEqual({ FOO: "bar" });
175
+ });
176
+
177
+ it("keeps a # that is part of a quoted value", () => {
178
+ expect(parseDotenv(`FOO="bar # why"\n`)).toEqual({ FOO: "bar # why" });
179
+ });
180
+
181
+ it("keeps an = that appears inside a value", () => {
182
+ expect(parseDotenv("URL=postgres://u:p@h/db?a=b\n")).toEqual({
183
+ URL: "postgres://u:p@h/db?a=b",
184
+ });
185
+ });
186
+
187
+ it("rejects a key that is not a valid identifier", () => {
188
+ expect(parseDotenv("not-a-key=x\nOK=y\n")).toEqual({ OK: "y" });
189
+ });
190
+ });
@@ -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
+ });