claudeup 4.17.0 → 4.19.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.
Files changed (86) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/alias-adopt.test.ts +364 -0
  3. package/src/__tests__/alias-parser.test.ts +409 -0
  4. package/src/__tests__/alias-shell-writer.test.ts +668 -0
  5. package/src/__tests__/alias-store.test.ts +163 -0
  6. package/src/__tests__/gitignore-fixer.test.ts +64 -1
  7. package/src/__tests__/gitignore-prerun.test.ts +2 -2
  8. package/src/__tests__/gitignore-service.test.ts +42 -0
  9. package/src/__tests__/marketplaces.test.ts +40 -0
  10. package/src/__tests__/plugin-manager-fallback.test.ts +120 -0
  11. package/src/__tests__/plugin-setup.test.ts +111 -0
  12. package/src/__tests__/useGitignoreModal.test.ts +2 -2
  13. package/src/data/alias-flags.js +205 -0
  14. package/src/data/alias-flags.ts +301 -0
  15. package/src/data/gitignore-reasons.js +97 -0
  16. package/src/data/gitignore-reasons.ts +103 -0
  17. package/src/data/marketplaces.js +5 -3
  18. package/src/data/marketplaces.ts +5 -4
  19. package/src/services/alias-settings.js +51 -0
  20. package/src/services/alias-settings.ts +63 -0
  21. package/src/services/alias-shell-writer.js +1018 -0
  22. package/src/services/alias-shell-writer.ts +1247 -0
  23. package/src/services/alias-store.js +129 -0
  24. package/src/services/alias-store.ts +172 -0
  25. package/src/services/gitignore-fixer.js +70 -10
  26. package/src/services/gitignore-fixer.ts +76 -9
  27. package/src/services/gitignore-prerun.js +3 -3
  28. package/src/services/gitignore-prerun.ts +3 -3
  29. package/src/services/gitignore-service.js +20 -2
  30. package/src/services/gitignore-service.ts +23 -1
  31. package/src/services/marketplace-fetcher.js +96 -0
  32. package/src/services/marketplace-fetcher.ts +137 -0
  33. package/src/services/plugin-manager.js +6 -59
  34. package/src/services/plugin-manager.ts +16 -91
  35. package/src/services/plugin-setup.js +59 -4
  36. package/src/services/plugin-setup.ts +61 -4
  37. package/src/services/skillsmp-client.js +29 -9
  38. package/src/services/skillsmp-client.ts +38 -8
  39. package/src/types/gitignore.ts +1 -1
  40. package/src/ui/App.js +26 -11
  41. package/src/ui/App.tsx +25 -10
  42. package/src/ui/components/FlagDetailEditor.js +0 -0
  43. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  44. package/src/ui/components/TabBar.js +2 -1
  45. package/src/ui/components/TabBar.tsx +2 -1
  46. package/src/ui/components/layout/FooterHints.js +29 -0
  47. package/src/ui/components/layout/FooterHints.tsx +52 -0
  48. package/src/ui/components/layout/ScreenLayout.js +2 -1
  49. package/src/ui/components/layout/ScreenLayout.tsx +12 -3
  50. package/src/ui/components/layout/index.js +1 -0
  51. package/src/ui/components/layout/index.ts +5 -0
  52. package/src/ui/components/modals/ConfirmModal.js +1 -1
  53. package/src/ui/components/modals/ConfirmModal.tsx +1 -1
  54. package/src/ui/components/modals/SelectModal.js +8 -1
  55. package/src/ui/components/modals/SelectModal.tsx +12 -1
  56. package/src/ui/hooks/useGitignoreModal.js +7 -8
  57. package/src/ui/hooks/useGitignoreModal.ts +8 -9
  58. package/src/ui/renderers/gitignoreRenderers.js +36 -23
  59. package/src/ui/renderers/gitignoreRenderers.tsx +50 -41
  60. package/src/ui/screens/AliasScreen.js +1111 -0
  61. package/src/ui/screens/AliasScreen.tsx +1534 -0
  62. package/src/ui/screens/CliToolsScreen.js +6 -1
  63. package/src/ui/screens/CliToolsScreen.tsx +6 -1
  64. package/src/ui/screens/EnvVarsScreen.js +6 -1
  65. package/src/ui/screens/EnvVarsScreen.tsx +6 -1
  66. package/src/ui/screens/GitignoreScreen.js +189 -88
  67. package/src/ui/screens/GitignoreScreen.tsx +312 -132
  68. package/src/ui/screens/McpRegistryScreen.js +13 -2
  69. package/src/ui/screens/McpRegistryScreen.tsx +13 -2
  70. package/src/ui/screens/McpScreen.js +6 -1
  71. package/src/ui/screens/McpScreen.tsx +6 -1
  72. package/src/ui/screens/ModelSelectorScreen.js +8 -2
  73. package/src/ui/screens/ModelSelectorScreen.tsx +8 -2
  74. package/src/ui/screens/PluginsScreen.js +17 -3
  75. package/src/ui/screens/PluginsScreen.tsx +16 -3
  76. package/src/ui/screens/ProfilesScreen.js +8 -1
  77. package/src/ui/screens/ProfilesScreen.tsx +8 -1
  78. package/src/ui/screens/SkillsScreen.js +21 -4
  79. package/src/ui/screens/SkillsScreen.tsx +39 -5
  80. package/src/ui/screens/StatusLineScreen.js +7 -1
  81. package/src/ui/screens/StatusLineScreen.tsx +7 -1
  82. package/src/ui/screens/index.js +1 -0
  83. package/src/ui/screens/index.ts +1 -0
  84. package/src/ui/state/reducer.js +5 -1
  85. package/src/ui/state/reducer.ts +6 -1
  86. package/src/ui/state/types.ts +12 -2
@@ -0,0 +1,163 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import {
3
+ defaultAliasConfig,
4
+ defaultValueFor,
5
+ validateAliasName,
6
+ derivedChannelValues,
7
+ withoutDerivedChannel,
8
+ listValues,
9
+ CHANNELS_FLAG_ID,
10
+ DEVLOAD_FLAG_ID,
11
+ DEFAULT_ALIAS_NAME,
12
+ type FlagValue,
13
+ } from "../services/alias-store";
14
+ import { ALIAS_FLAGS } from "../data/alias-flags";
15
+
16
+ function textList(values: string[], enabled = true): FlagValue {
17
+ return { kind: "text-list", enabled, values };
18
+ }
19
+
20
+ describe("defaultAliasConfig", () => {
21
+ it("seeds every known flag with a sane default and the default alias name", () => {
22
+ const c = defaultAliasConfig();
23
+ expect(c.aliasName).toBe(DEFAULT_ALIAS_NAME);
24
+ expect(c.aliasName).toBe("c");
25
+ expect(c.flags["dangerously-skip-permissions"]).toEqual({
26
+ kind: "boolean",
27
+ enabled: false,
28
+ });
29
+ expect(c.flags["chrome"]).toEqual({ kind: "tri-state", state: "unset" });
30
+ expect(c.flags["effort"]).toEqual({
31
+ kind: "select",
32
+ enabled: false,
33
+ value: "low",
34
+ });
35
+ expect(c.flags["dangerously-load-development-channels"]).toEqual({
36
+ kind: "text-list",
37
+ enabled: false,
38
+ values: ["plugin:claudish@magus"],
39
+ });
40
+ expect(c.flags["debug"]).toEqual({
41
+ kind: "multi-with-custom",
42
+ enabled: false,
43
+ picked: [],
44
+ custom: [],
45
+ });
46
+ });
47
+
48
+ it("covers every flag in the catalog", () => {
49
+ const c = defaultAliasConfig();
50
+ for (const flag of ALIAS_FLAGS) {
51
+ expect(c.flags[flag.id]).toBeDefined();
52
+ }
53
+ });
54
+ });
55
+
56
+ describe("defaultValueFor", () => {
57
+ it("returns the right default for each kind", () => {
58
+ const ide = ALIAS_FLAGS.find((f) => f.id === "ide")!;
59
+ expect(defaultValueFor(ide)).toEqual({ kind: "boolean", enabled: false });
60
+
61
+ const chrome = ALIAS_FLAGS.find((f) => f.id === "chrome")!;
62
+ expect(defaultValueFor(chrome)).toEqual({
63
+ kind: "tri-state",
64
+ state: "unset",
65
+ });
66
+
67
+ const debug = ALIAS_FLAGS.find((f) => f.id === "debug")!;
68
+ expect(defaultValueFor(debug)).toEqual({
69
+ kind: "multi-with-custom",
70
+ enabled: false,
71
+ picked: [],
72
+ custom: [],
73
+ });
74
+ });
75
+ });
76
+
77
+ describe("derivedChannelValues (derive-on-display model)", () => {
78
+ it("returns dev-load values when the dev-load flag is enabled", () => {
79
+ const flags: Record<string, FlagValue> = {
80
+ [DEVLOAD_FLAG_ID]: textList(["plugin:claudish@magus"], true),
81
+ [CHANNELS_FLAG_ID]: textList([], false),
82
+ };
83
+ expect(derivedChannelValues(flags)).toEqual(["plugin:claudish@magus"]);
84
+ });
85
+
86
+ it("returns nothing when the dev-load flag is DISABLED", () => {
87
+ const flags: Record<string, FlagValue> = {
88
+ [DEVLOAD_FLAG_ID]: textList(["plugin:claudish@magus"], false),
89
+ [CHANNELS_FLAG_ID]: textList([], false),
90
+ };
91
+ expect(derivedChannelValues(flags)).toEqual([]);
92
+ });
93
+
94
+ it("returns nothing when the dev-load flag is absent", () => {
95
+ expect(derivedChannelValues({})).toEqual([]);
96
+ });
97
+
98
+ it("does NOT read from channels.values (provenance stays separate)", () => {
99
+ // channels holds OWN values; dev-load is the only derived source.
100
+ const flags: Record<string, FlagValue> = {
101
+ [CHANNELS_FLAG_ID]: textList(["plugin:own@m"], true),
102
+ [DEVLOAD_FLAG_ID]: textList(["plugin:dev@m"], true),
103
+ };
104
+ expect(derivedChannelValues(flags)).toEqual(["plugin:dev@m"]);
105
+ // channels.values is untouched — own only.
106
+ expect(listValues(flags[CHANNELS_FLAG_ID])).toEqual(["plugin:own@m"]);
107
+ });
108
+ });
109
+
110
+ describe("withoutDerivedChannel (delete-through to dev-load)", () => {
111
+ it("removes the value from dev-load, NOT from channels.values", () => {
112
+ const flags: Record<string, FlagValue> = {
113
+ [CHANNELS_FLAG_ID]: textList(["plugin:own@m"], true),
114
+ [DEVLOAD_FLAG_ID]: textList(["plugin:dev@m", "plugin:keep@m"], true),
115
+ };
116
+ const next = withoutDerivedChannel(flags, "plugin:dev@m");
117
+ expect(listValues(next[DEVLOAD_FLAG_ID])).toEqual(["plugin:keep@m"]);
118
+ // channels.values untouched — the deletion landed on the source flag.
119
+ expect(listValues(next[CHANNELS_FLAG_ID])).toEqual(["plugin:own@m"]);
120
+ });
121
+
122
+ it("disables dev-load when its last value is removed", () => {
123
+ const flags: Record<string, FlagValue> = {
124
+ [DEVLOAD_FLAG_ID]: textList(["plugin:only@m"], true),
125
+ };
126
+ const next = withoutDerivedChannel(flags, "plugin:only@m");
127
+ expect(next[DEVLOAD_FLAG_ID]).toEqual({
128
+ kind: "text-list",
129
+ enabled: false,
130
+ values: [],
131
+ });
132
+ });
133
+
134
+ it("is a no-op when the value isn't present", () => {
135
+ const flags: Record<string, FlagValue> = {
136
+ [DEVLOAD_FLAG_ID]: textList(["plugin:a@m"], true),
137
+ };
138
+ expect(listValues(withoutDerivedChannel(flags, "nope")[DEVLOAD_FLAG_ID])).toEqual([
139
+ "plugin:a@m",
140
+ ]);
141
+ });
142
+ });
143
+
144
+ describe("validateAliasName", () => {
145
+ it("accepts safe identifiers", () => {
146
+ expect(validateAliasName("c")).toBeNull();
147
+ expect(validateAliasName("claude")).toBeNull();
148
+ expect(validateAliasName("cc-yolo_2")).toBeNull();
149
+ expect(validateAliasName("_underscore")).toBeNull();
150
+ });
151
+
152
+ it("rejects empty, leading-digit, and special-char names", () => {
153
+ expect(validateAliasName("")).toMatch(/empty/);
154
+ expect(validateAliasName("9live")).toMatch(/letter/);
155
+ expect(validateAliasName("c laude")).toMatch(/letters/);
156
+ expect(validateAliasName("c'")).toMatch(/letters/);
157
+ expect(validateAliasName("c=d")).toMatch(/letters/);
158
+ });
159
+
160
+ it("rejects pathologically long names", () => {
161
+ expect(validateAliasName("a".repeat(64))).toMatch(/long/);
162
+ });
163
+ });
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, beforeEach, afterEach } from "bun:test";
2
- import { mkdtemp, rm, writeFile, readFile } from "node:fs/promises";
2
+ import { mkdtemp, rm, writeFile, readFile, mkdir } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { spawnSync } from "node:child_process";
@@ -50,6 +50,8 @@ describe("applySafeFixes", () => {
50
50
  expect(content).toContain(".env");
51
51
  expect(content).toContain(".mnemex/");
52
52
  expect(content).toContain("# Added by claudeup");
53
+ expect(content).toContain("Environment files commonly contain");
54
+ expect(content).toContain("Local memory/index data");
53
55
  });
54
56
 
55
57
  it("creates .gitignore if absent", async () => {
@@ -151,6 +153,67 @@ describe("applyDestructiveFix", () => {
151
153
  expect(ls.stdout).toContain(".mcp.json");
152
154
  });
153
155
 
156
+ it("adds unignore exceptions when a parent rule ignores a tracked path", async () => {
157
+ await mkdir(join(repo, ".claude"), { recursive: true });
158
+ await writeFile(join(repo, ".claude", "settings.json"), "{}");
159
+ await writeFile(join(repo, ".gitignore"), "/*\n");
160
+
161
+ const r = await applyDestructiveFix(repo, {
162
+ kind: "ignored-but-should-track",
163
+ path: ".claude/settings.json",
164
+ source: "builtin",
165
+ severity: "destructive",
166
+ });
167
+ expect(r.applied).toBe(true);
168
+
169
+ const ignore = await readFile(join(repo, ".gitignore"), "utf8");
170
+ expect(ignore).toContain("!/.claude/");
171
+ expect(ignore).toContain("!/.claude/settings.json");
172
+ expect(ignore).toContain("Team Claude settings should be committed");
173
+
174
+ const ls = git(repo, "ls-files");
175
+ expect(ls.stdout).toContain(".claude/settings.json");
176
+ });
177
+
178
+ it("does not fail when making an absent tracked path trackable", async () => {
179
+ await writeFile(join(repo, ".gitignore"), "/*\n");
180
+
181
+ const r = await applyDestructiveFix(repo, {
182
+ kind: "ignored-but-should-track",
183
+ path: "plugin.json",
184
+ source: "builtin",
185
+ severity: "destructive",
186
+ });
187
+ expect(r.applied).toBe(true);
188
+ expect(r.message).toContain("does not exist yet");
189
+
190
+ const ignore = await readFile(join(repo, ".gitignore"), "utf8");
191
+ expect(ignore).toContain("!/plugin.json");
192
+ });
193
+
194
+ it("uses the managed rule pattern when untracking child violations", async () => {
195
+ await mkdir(join(repo, "logs"), { recursive: true });
196
+ await writeFile(join(repo, "logs", "app.log"), "x");
197
+ git(repo, "add", "logs/app.log");
198
+ git(repo, "commit", "-q", "-m", "add log");
199
+
200
+ const r = await applyDestructiveFix(
201
+ repo,
202
+ {
203
+ kind: "tracked-but-should-ignore",
204
+ path: "logs/app.log",
205
+ source: "builtin",
206
+ severity: "destructive",
207
+ },
208
+ "logs/",
209
+ );
210
+ expect(r.applied).toBe(true);
211
+
212
+ const ignore = await readFile(join(repo, ".gitignore"), "utf8");
213
+ expect(ignore).toContain("logs/");
214
+ expect(ignore).not.toMatch(/^logs\/app\.log$/m);
215
+ });
216
+
154
217
  it("stages an untracked file", async () => {
155
218
  await writeFile(join(repo, ".mcp.json"), "{}");
156
219
  const r = await applyDestructiveFix(repo, {
@@ -69,7 +69,7 @@ describe("formatPrerunWarning", () => {
69
69
  it("returns a warning string when violations exist", () => {
70
70
  const w = formatPrerunWarning({ violationCount: 3, warnings: [] });
71
71
  expect(w).not.toBeNull();
72
- expect(w).toContain("3 gitignore");
73
- expect(w).toContain("Gitignore tab");
72
+ expect(w).toContain("3 git state");
73
+ expect(w).toContain("Git State tab");
74
74
  });
75
75
  });
@@ -6,6 +6,7 @@ import { spawnSync } from "node:child_process";
6
6
  import {
7
7
  loadGitignoreState,
8
8
  applyTemplate,
9
+ ensureProjectManifestForEdit,
9
10
  } from "../services/gitignore-service";
10
11
 
11
12
  function git(cwd: string, ...args: string[]): void {
@@ -91,3 +92,44 @@ describe("applyTemplate", () => {
91
92
  );
92
93
  });
93
94
  });
95
+
96
+ describe("ensureProjectManifestForEdit", () => {
97
+ let projectDir: string;
98
+
99
+ beforeEach(async () => {
100
+ projectDir = await mkdtemp(join(tmpdir(), "gitig-edit-"));
101
+ });
102
+
103
+ afterEach(async () => {
104
+ await rm(projectDir, { recursive: true, force: true });
105
+ });
106
+
107
+ it("creates an editable project manifest from resolved rules", async () => {
108
+ const path = await ensureProjectManifestForEdit(projectDir, {
109
+ rules: [
110
+ { pattern: ".env", action: "ignore", source: "builtin" },
111
+ { pattern: ".mcp.json", action: "track", source: "builtin" },
112
+ ],
113
+ conflicts: [],
114
+ });
115
+
116
+ expect(path).toBe(join(projectDir, ".claude", "gitignore.json"));
117
+ const written = JSON.parse(await readFile(path, "utf8"));
118
+ expect(written.ignore).toEqual([".env"]);
119
+ expect(written.track).toEqual([".mcp.json"]);
120
+ });
121
+
122
+ it("keeps an existing non-empty project manifest", async () => {
123
+ const path = join(projectDir, ".claude", "gitignore.json");
124
+ await mkdir(join(projectDir, ".claude"), { recursive: true });
125
+ await writeFile(path, JSON.stringify({ ignore: ["custom"], track: [] }));
126
+
127
+ await ensureProjectManifestForEdit(projectDir, {
128
+ rules: [{ pattern: ".env", action: "ignore", source: "builtin" }],
129
+ conflicts: [],
130
+ });
131
+
132
+ const written = JSON.parse(await readFile(path, "utf8"));
133
+ expect(written.ignore).toEqual(["custom"]);
134
+ });
135
+ });
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ defaultMarketplaces,
4
+ deprecatedMarketplaces,
5
+ getAllMarketplaces,
6
+ } from "../data/marketplaces.js";
7
+ import type { LocalMarketplace } from "../services/local-marketplace.js";
8
+
9
+ describe("marketplaces", () => {
10
+ it("uses the canonical Superpowers marketplace by default", () => {
11
+ const superpowers = defaultMarketplaces.find(
12
+ (mp) => mp.name === "superpowers-marketplace",
13
+ );
14
+
15
+ expect(superpowers).toBeDefined();
16
+ expect(superpowers?.displayName).toBe("Superpowers");
17
+ expect(superpowers?.source.repo).toBe("obra/superpowers-marketplace");
18
+ expect(superpowers?.featured).toBe(true);
19
+ expect(deprecatedMarketplaces.superpowers).toBe("superpowers-marketplace");
20
+ });
21
+
22
+ it("hides stale local superpowers entries behind the canonical marketplace", () => {
23
+ const local = new Map<string, LocalMarketplace>([
24
+ [
25
+ "superpowers",
26
+ {
27
+ name: "superpowers-dev",
28
+ description: "Development marketplace",
29
+ gitRepo: "obra/superpowers",
30
+ plugins: [],
31
+ },
32
+ ],
33
+ ]);
34
+
35
+ const names = getAllMarketplaces(local).map((mp) => mp.name);
36
+
37
+ expect(names).toContain("superpowers-marketplace");
38
+ expect(names).not.toContain("superpowers");
39
+ });
40
+ });
@@ -0,0 +1,120 @@
1
+ import { describe, it, expect, beforeEach } from "bun:test";
2
+ import {
3
+ resolveMarketplacePlugins,
4
+ clearMarketplaceCache,
5
+ } from "../services/marketplace-fetcher";
6
+ import type {
7
+ LocalMarketplace,
8
+ LocalMarketplacePlugin,
9
+ } from "../services/local-marketplace";
10
+
11
+ /**
12
+ * Regression coverage for the "deprecated everything" bug.
13
+ *
14
+ * When `fetchMarketplacePlugins` returns an empty list (network down,
15
+ * repo moved, GitHub blocked, etc.), the orphan-detection loop in
16
+ * `getAvailablePlugins` would flag every installed plugin as
17
+ * `isOrphaned: true` and the UI rendered them all as "deprecated".
18
+ *
19
+ * `resolveMarketplacePlugins` adds the local-marketplace cache as a
20
+ * fallback so installed plugins keep rendering with their real metadata
21
+ * even when the machine is offline.
22
+ */
23
+
24
+ function buildLocalMarketplace(
25
+ name: string,
26
+ plugins: LocalMarketplacePlugin[],
27
+ ): LocalMarketplace {
28
+ return { name, description: "", plugins };
29
+ }
30
+
31
+ describe("resolveMarketplacePlugins — offline fallback", () => {
32
+ beforeEach(() => {
33
+ // Session-level cache could otherwise carry over a previous test's
34
+ // successful fetch and short-circuit the fallback path.
35
+ clearMarketplaceCache();
36
+ });
37
+
38
+ it("falls back to the local cache when the remote fetch returns empty", async () => {
39
+ const local = new Map<string, LocalMarketplace>([
40
+ [
41
+ "magus",
42
+ buildLocalMarketplace("magus", [
43
+ {
44
+ name: "code-analysis",
45
+ version: "5.3.0",
46
+ description: "Codebase investigation tools",
47
+ category: "development",
48
+ author: { name: "Jack Rudenko" },
49
+ },
50
+ {
51
+ name: "dev",
52
+ version: "2.7.0",
53
+ description: "Dev workflow",
54
+ },
55
+ ]),
56
+ ],
57
+ ]);
58
+
59
+ // An invalid repo string short-circuits `fetchMarketplacePlugins` to
60
+ // `[]` without any network call, simulating the offline case.
61
+ const resolved = await resolveMarketplacePlugins(
62
+ "magus",
63
+ "not a valid repo string",
64
+ local,
65
+ );
66
+
67
+ expect(resolved).toHaveLength(2);
68
+ expect(resolved.map((p) => p.name).sort()).toEqual([
69
+ "code-analysis",
70
+ "dev",
71
+ ]);
72
+ const codeAnalysis = resolved.find((p) => p.name === "code-analysis");
73
+ expect(codeAnalysis?.version).toBe("5.3.0");
74
+ expect(codeAnalysis?.description).toBe("Codebase investigation tools");
75
+ expect(codeAnalysis?.category).toBe("development");
76
+ expect(codeAnalysis?.author?.name).toBe("Jack Rudenko");
77
+ });
78
+
79
+ it("returns empty when both remote and cache are empty", async () => {
80
+ const local = new Map<string, LocalMarketplace>();
81
+ const resolved = await resolveMarketplacePlugins(
82
+ "magus",
83
+ "not a valid repo string",
84
+ local,
85
+ );
86
+ expect(resolved).toEqual([]);
87
+ });
88
+
89
+ it("returns empty when remote fails AND cache exists but has no plugins", async () => {
90
+ const local = new Map<string, LocalMarketplace>([
91
+ ["magus", buildLocalMarketplace("magus", [])],
92
+ ]);
93
+ const resolved = await resolveMarketplacePlugins(
94
+ "magus",
95
+ "not a valid repo string",
96
+ local,
97
+ );
98
+ expect(resolved).toEqual([]);
99
+ });
100
+
101
+ it("does not consult the cache when the remote returns plugins", async () => {
102
+ // We can't easily exercise the success branch without a live network,
103
+ // but we can verify that a cache entry for a DIFFERENT marketplace
104
+ // doesn't bleed into the requested one.
105
+ const local = new Map<string, LocalMarketplace>([
106
+ [
107
+ "other-marketplace",
108
+ buildLocalMarketplace("other-marketplace", [
109
+ { name: "should-not-leak", version: "0.0.1", description: "" },
110
+ ]),
111
+ ],
112
+ ]);
113
+ const resolved = await resolveMarketplacePlugins(
114
+ "magus",
115
+ "not a valid repo string",
116
+ local,
117
+ );
118
+ expect(resolved).toEqual([]);
119
+ });
120
+ });
@@ -73,6 +73,8 @@ mock.module("node:child_process", () => ({
73
73
  // Import AFTER mocks are set up
74
74
  const {
75
75
  extractGoBinaryName,
76
+ extractGoVersion,
77
+ goDepNeedsInstall,
76
78
  installPluginDeps,
77
79
  checkMissingDeps,
78
80
  } = await import("../services/plugin-setup.js");
@@ -259,6 +261,115 @@ describe("checkMissingDeps — go packages", () => {
259
261
  });
260
262
  });
261
263
 
264
+ // ---------------------------------------------------------------------------
265
+ // 4b. Version-aware go dependencies — a bumped plugin must pull the new binary
266
+ // ---------------------------------------------------------------------------
267
+
268
+ describe("extractGoVersion", () => {
269
+ it("returns the pinned version", () => {
270
+ expect(
271
+ extractGoVersion("github.com/MadAppGang/tmux-mcp@v1.6.2"),
272
+ ).toBe("v1.6.2");
273
+ });
274
+ it("returns null for @latest (a moving target)", () => {
275
+ expect(
276
+ extractGoVersion("github.com/MadAppGang/tmux-mcp@latest"),
277
+ ).toBeNull();
278
+ });
279
+ it("returns null when unpinned", () => {
280
+ expect(extractGoVersion("github.com/user/tool")).toBeNull();
281
+ });
282
+ });
283
+
284
+ describe("goDepNeedsInstall — version awareness", () => {
285
+ beforeEach(() => {
286
+ availableBinaries = new Set(["go"]);
287
+ execResults = new Map();
288
+ });
289
+
290
+ it("reinstalls when the pinned version differs from the installed one", async () => {
291
+ availableBinaries.add("tmux-mcp");
292
+ execResults.set("tmux-mcp --version", { stdout: "v1.6.1", stderr: "" });
293
+
294
+ expect(
295
+ await goDepNeedsInstall("github.com/MadAppGang/tmux-mcp@v1.6.2"),
296
+ ).toBe(true);
297
+ });
298
+
299
+ it("skips when the installed version matches the pin (v-prefix insensitive)", async () => {
300
+ availableBinaries.add("tmux-mcp");
301
+ execResults.set("tmux-mcp --version", { stdout: "1.6.2", stderr: "" });
302
+
303
+ expect(
304
+ await goDepNeedsInstall("github.com/MadAppGang/tmux-mcp@v1.6.2"),
305
+ ).toBe(false);
306
+ });
307
+
308
+ it("reinstalls when the binary can't report its version", async () => {
309
+ availableBinaries.add("tmux-mcp");
310
+ execResults.set("tmux-mcp --version", {
311
+ stdout: "",
312
+ stderr: "flag provided but not defined: -version",
313
+ error: true,
314
+ });
315
+
316
+ expect(
317
+ await goDepNeedsInstall("github.com/MadAppGang/tmux-mcp@v1.6.2"),
318
+ ).toBe(true);
319
+ });
320
+
321
+ it("installs when the binary is absent", async () => {
322
+ expect(
323
+ await goDepNeedsInstall("github.com/MadAppGang/tmux-mcp@v1.6.2"),
324
+ ).toBe(true);
325
+ });
326
+
327
+ it("leaves an unpinned (@latest) present binary alone", async () => {
328
+ availableBinaries.add("tmux-mcp");
329
+ expect(
330
+ await goDepNeedsInstall("github.com/MadAppGang/tmux-mcp@latest"),
331
+ ).toBe(false);
332
+ });
333
+ });
334
+
335
+ describe("checkMissingDeps — pinned go version mismatch", () => {
336
+ beforeEach(() => {
337
+ availableBinaries = new Set(["go", "tmux-mcp"]);
338
+ execResults = new Map();
339
+ });
340
+
341
+ it("reports a present-but-outdated pinned binary as missing", async () => {
342
+ execResults.set("tmux-mcp --version", { stdout: "v1.6.1", stderr: "" });
343
+
344
+ const missing = await checkMissingDeps({
345
+ go: ["github.com/MadAppGang/tmux-mcp@v1.6.2"],
346
+ });
347
+
348
+ expect(missing.go).toEqual(["github.com/MadAppGang/tmux-mcp@v1.6.2"]);
349
+ });
350
+ });
351
+
352
+ describe("installPluginDeps — reinstalls an outdated pinned go binary", () => {
353
+ beforeEach(() => {
354
+ availableBinaries = new Set(["go", "tmux-mcp"]);
355
+ execResults = new Map();
356
+ });
357
+
358
+ it("runs go install when the pinned version is newer than installed", async () => {
359
+ execResults.set("tmux-mcp --version", { stdout: "v1.6.1", stderr: "" });
360
+ execResults.set("/usr/bin/go install", { stdout: "", stderr: "" });
361
+
362
+ const result = await installPluginDeps({
363
+ go: ["github.com/MadAppGang/tmux-mcp@v1.6.2"],
364
+ });
365
+
366
+ expect(result.installed).toContain(
367
+ "go:github.com/MadAppGang/tmux-mcp@v1.6.2",
368
+ );
369
+ expect(result.skipped).toHaveLength(0);
370
+ });
371
+ });
372
+
262
373
  // ---------------------------------------------------------------------------
263
374
  // 5. checkMissingDeps tests — required binaries
264
375
  // ---------------------------------------------------------------------------
@@ -31,7 +31,7 @@ describe("buildGitignoreModal", () => {
31
31
  expect(dismissed).toBe(true);
32
32
  });
33
33
 
34
- it("shows '(no safe-fixable entries)' label when safeCount is 0", () => {
34
+ it("shows no missing entries label when safeCount is 0", () => {
35
35
  const modal = buildGitignoreModal({
36
36
  violationCount: 2,
37
37
  safeCount: 0,
@@ -40,7 +40,7 @@ describe("buildGitignoreModal", () => {
40
40
  onDismiss: () => {},
41
41
  });
42
42
  if (modal.type !== "select") throw new Error("type guard");
43
- expect(modal.options[0].label).toContain("no safe-fixable");
43
+ expect(modal.options[0].label).toContain("no missing .gitignore");
44
44
  });
45
45
 
46
46
  it("includes violation count in the message", () => {