claudeup 4.22.0 → 4.24.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.22.0",
3
+ "version": "4.24.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.22.0",
68
- "claudeup-darwin-x64": "4.22.0",
69
- "claudeup-linux-x64": "4.22.0"
67
+ "claudeup-darwin-arm64": "4.24.0",
68
+ "claudeup-darwin-x64": "4.24.0",
69
+ "claudeup-linux-x64": "4.24.0"
70
70
  }
71
71
  }
@@ -0,0 +1,87 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { isEnabledButNotInstalled } from "../services/plugin-manager.js";
3
+ import type { PluginInfo } from "../services/plugin-manager.js";
4
+
5
+ /**
6
+ * "Enabled but not installed" is a broken state that previously rendered
7
+ * identically to a healthy install: a green scope square and no version.
8
+ *
9
+ * It is also invisible to every update path, because the check is
10
+ * `installedVersion && compare(latest, installedVersion) > 0` — with no
11
+ * installed version that is false, so neither the update list nor the
12
+ * prerunner's auto-update will ever touch it.
13
+ */
14
+
15
+ const plugin = (over: Partial<PluginInfo>): PluginInfo =>
16
+ ({
17
+ id: "x@magus",
18
+ name: "x",
19
+ version: "1.0.0",
20
+ description: "",
21
+ marketplace: "magus",
22
+ marketplaceDisplay: "Magus",
23
+ enabled: true,
24
+ ...over,
25
+ }) as PluginInfo;
26
+
27
+ describe("isEnabledButNotInstalled", () => {
28
+ test("enabled in project scope with no installed version is flagged", () => {
29
+ // The real case: terminal/code-analysis are in this project's enabledPlugins
30
+ // but have no registry entry for it and none at user scope.
31
+ expect(
32
+ isEnabledButNotInstalled(
33
+ plugin({ projectScope: { enabled: true }, installedVersion: undefined }),
34
+ ),
35
+ ).toBe(true);
36
+ });
37
+
38
+ test("a healthy install is not flagged", () => {
39
+ expect(
40
+ isEnabledButNotInstalled(
41
+ plugin({ projectScope: { enabled: true }, installedVersion: "1.0.0" }),
42
+ ),
43
+ ).toBe(false);
44
+ });
45
+
46
+ test('installedVersion "0.0.0" means installed-with-unknown-version, not missing', () => {
47
+ // Anthropic's official plugins record exactly this. Treating it as missing
48
+ // would mislabel every one of them as broken.
49
+ expect(
50
+ isEnabledButNotInstalled(
51
+ plugin({ userScope: { enabled: true }, installedVersion: "0.0.0" }),
52
+ ),
53
+ ).toBe(false);
54
+ });
55
+
56
+ test("not enabled anywhere is not flagged — it is simply available", () => {
57
+ expect(isEnabledButNotInstalled(plugin({ installedVersion: undefined }))).toBe(false);
58
+ });
59
+
60
+ test("orphaned plugins are excluded — they have their own 'deprecated' state", () => {
61
+ expect(
62
+ isEnabledButNotInstalled(
63
+ plugin({
64
+ isOrphaned: true,
65
+ projectScope: { enabled: true },
66
+ installedVersion: undefined,
67
+ }),
68
+ ),
69
+ ).toBe(false);
70
+ });
71
+
72
+ test("any enabled scope counts, not just project", () => {
73
+ for (const scope of ["userScope", "projectScope", "localScope"] as const) {
74
+ expect(
75
+ isEnabledButNotInstalled(plugin({ [scope]: { enabled: true } })),
76
+ ).toBe(true);
77
+ }
78
+ });
79
+
80
+ test("a scope present but disabled does not count as enabled", () => {
81
+ expect(
82
+ isEnabledButNotInstalled(
83
+ plugin({ projectScope: { enabled: false }, installedVersion: undefined }),
84
+ ),
85
+ ).toBe(false);
86
+ });
87
+ });
@@ -0,0 +1,91 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildPluginBrowserItems } from "../ui/adapters/pluginsAdapter.js";
3
+ import type { Marketplace } from "../types/index.js";
4
+
5
+ /**
6
+ * The marketplace category badge is the line users read on every claudeup launch,
7
+ * and it had no coverage. The MadAppGang byline must not leak onto anyone else's
8
+ * marketplace, and the Official/Deprecated markers must still win.
9
+ */
10
+
11
+ const mp = (over: Partial<Marketplace> & { name: string }): Marketplace => ({
12
+ displayName: over.displayName ?? over.name,
13
+ source: { source: "github", repo: `someone/${over.name}` },
14
+ description: "",
15
+ ...over,
16
+ });
17
+
18
+ /** Badge for a marketplace when it has plugins and is expanded. */
19
+ function badgeFor(marketplace: Marketplace): string | undefined {
20
+ const items = buildPluginBrowserItems({
21
+ marketplaces: [marketplace],
22
+ plugins: [
23
+ {
24
+ id: `demo@${marketplace.name}`,
25
+ name: "demo",
26
+ marketplace: marketplace.name,
27
+ // Only the fields the adapter groups on matter here.
28
+ } as never,
29
+ ],
30
+ collapsedMarketplaces: new Set(),
31
+ });
32
+ const category = items.find((i) => i.kind === "category");
33
+ return category && "badge" in category ? category.badge : undefined;
34
+ }
35
+
36
+ describe("marketplace category badge", () => {
37
+ test("MadAppGang marketplaces carry the byline instead of a generic label", () => {
38
+ for (const name of ["magus", "magus-marketing", "magus-alpha"]) {
39
+ const badge = badgeFor(
40
+ mp({ name, source: { source: "github", repo: `MadAppGang/${name}` } }),
41
+ );
42
+ expect(badge).toBe("made with ❤️ by MadAppGang");
43
+ }
44
+ });
45
+
46
+ test("owner match is case-insensitive", () => {
47
+ // The npm/GitHub casing of the org has varied in config; don't depend on it.
48
+ const badge = badgeFor(
49
+ mp({ name: "magus", source: { source: "github", repo: "madappgang/magus" } }),
50
+ );
51
+ expect(badge).toBe("made with ❤️ by MadAppGang");
52
+ });
53
+
54
+ test("someone else's marketplace does NOT claim the byline", () => {
55
+ const badge = badgeFor(
56
+ mp({
57
+ name: "superpowers-marketplace",
58
+ source: { source: "github", repo: "obra/superpowers-marketplace" },
59
+ }),
60
+ );
61
+ expect(badge).toBe("✓ Added");
62
+ });
63
+
64
+ test("a repo merely containing 'madappgang' downstream does not match", () => {
65
+ // Guards against a substring check — only the owner segment counts.
66
+ const badge = badgeFor(
67
+ mp({ name: "impostor", source: { source: "github", repo: "evil/madappgang-magus" } }),
68
+ );
69
+ expect(badge).toBe("✓ Added");
70
+ });
71
+
72
+ test("Official and Deprecated still take precedence", () => {
73
+ expect(
74
+ badgeFor(
75
+ mp({
76
+ name: "claude-plugins-official",
77
+ source: { source: "github", repo: "anthropics/claude-plugins-official" },
78
+ }),
79
+ ),
80
+ ).toBe("★ Official");
81
+
82
+ expect(
83
+ badgeFor(
84
+ mp({
85
+ name: "claude-code-plugins",
86
+ source: { source: "github", repo: "anthropics/claude-code" },
87
+ }),
88
+ ),
89
+ ).toBe("⚠ Deprecated");
90
+ });
91
+ });
@@ -86,10 +86,10 @@ describe("resolveExtends", () => {
86
86
  extends: "growth-marketer",
87
87
  };
88
88
  const merged = resolveExtends(entry);
89
- // seo/nanobanana/video-editing ship on the magus-marketing channel and
89
+ // seo/image-generate/video-editing ship on the magus-marketing channel and
90
90
  // pin it explicitly — they must NOT be rewritten to @magus.
91
91
  expect(merged.plugins!["seo@magus-marketing"]).toBe("latest");
92
- expect(merged.plugins!["nanobanana@magus-marketing"]).toBe("latest");
92
+ expect(merged.plugins!["image-generate@magus-marketing"]).toBe("latest");
93
93
  expect(merged.plugins!["video-editing@magus-marketing"]).toBe("latest");
94
94
  expect(merged.plugins!["seo@magus"]).toBeUndefined();
95
95
  // Bare names still default to @magus.
@@ -201,7 +201,7 @@ export const PREDEFINED_PROFILES: PredefinedProfile[] = [
201
201
  "statusline",
202
202
  "seo@magus-marketing",
203
203
  "browser-use",
204
- "nanobanana@magus-marketing",
204
+ "image-generate@magus-marketing",
205
205
  "video-editing@magus-marketing",
206
206
  ],
207
207
  anthropicPlugins: [
@@ -74,6 +74,30 @@ export interface PluginInfo {
74
74
  isOrphaned?: boolean;
75
75
  }
76
76
 
77
+ /**
78
+ * True when a plugin is enabled in some scope but no installed version resolves
79
+ * anywhere — it is listed in `enabledPlugins` but was never actually installed.
80
+ *
81
+ * This is a broken state, not a healthy one: Claude Code will not load the
82
+ * plugin, and because the update check is `installedVersion && compare(...)`,
83
+ * it is also invisible to both the update list and the prerunner's auto-update.
84
+ * It previously rendered identically to a working install.
85
+ *
86
+ * `isOrphaned` is a different failure — installed, but no longer offered by any
87
+ * marketplace — and is rendered as "deprecated" already.
88
+ *
89
+ * "0.0.0" means installed-with-unknown-version (Anthropic's official plugins
90
+ * record exactly that), so it must NOT count as missing.
91
+ */
92
+ export function isEnabledButNotInstalled(plugin: PluginInfo): boolean {
93
+ if (plugin.isOrphaned) return false;
94
+ const enabledSomewhere =
95
+ !!plugin.userScope?.enabled ||
96
+ !!plugin.projectScope?.enabled ||
97
+ !!plugin.localScope?.enabled;
98
+ return enabledSomewhere && !plugin.installedVersion;
99
+ }
100
+
77
101
 
78
102
  export async function getAvailablePlugins(
79
103
  projectPath?: string,
@@ -65,9 +65,22 @@ function categoryStyling(
65
65
  if (mp.official) {
66
66
  return { tone: "yellow", badge: "★ Official" };
67
67
  }
68
+ if (isMadAppGang(mp)) {
69
+ return { tone: "green", badge: "made with ❤️ by MadAppGang" };
70
+ }
68
71
  return { tone: "green", badge: "✓ Added" };
69
72
  }
70
73
 
74
+ /**
75
+ * Our own marketplaces (magus, magus-marketing, magus-alpha) get the byline
76
+ * instead of the generic "Added". Matched on the repo owner rather than a list
77
+ * of names, so a new channel picks it up without a code change. Same idiom the
78
+ * marketplace sort uses to float MadAppGang entries to the top.
79
+ */
80
+ function isMadAppGang(mp: Marketplace): boolean {
81
+ return (mp.source.repo ?? "").toLowerCase().startsWith("madappgang/");
82
+ }
83
+
71
84
  /**
72
85
  * Builds the flat list of items for the PluginsScreen list panel.
73
86
  * Extracted from PluginsScreen so it can be tested independently.
@@ -15,6 +15,7 @@ import {
15
15
  } from "../components/primitives/index.js";
16
16
  import { theme } from "../theme.js";
17
17
  import { highlightMatches } from "../../utils/fuzzy-search.js";
18
+ import { isEnabledButNotInstalled } from "../../services/plugin-manager.js";
18
19
 
19
20
  // ─── Category renderers ───────────────────────────────────────────────────────
20
21
 
@@ -113,10 +114,16 @@ function pluginRow(item: PluginPluginItem, isSelected: boolean): React.ReactNode
113
114
  const hasLocal = !!plugin.localScope?.enabled;
114
115
  const hasAnyScope = hasUser || hasProject || hasLocal;
115
116
 
117
+ // Enabled somewhere but nothing actually installed — a broken state that used
118
+ // to render exactly like a healthy install.
119
+ const notInstalled = isEnabledButNotInstalled(plugin);
120
+
116
121
  // Build version string — only show if plugin is installed in at least one scope
117
122
  let versionStr = "";
118
123
  if (plugin.isOrphaned) {
119
124
  versionStr = " deprecated";
125
+ } else if (notInstalled) {
126
+ versionStr = " not installed";
120
127
  } else if (hasAnyScope && plugin.installedVersion && plugin.installedVersion !== "0.0.0") {
121
128
  versionStr = ` v${plugin.installedVersion}`;
122
129
  if (plugin.hasUpdate && plugin.version) {
@@ -158,8 +165,13 @@ function pluginRow(item: PluginPluginItem, isSelected: boolean): React.ReactNode
158
165
  <span> </span>
159
166
  <ScopeSquares user={hasUser} project={hasProject} local={hasLocal} />
160
167
  <span> </span>
161
- <span fg={hasAnyScope ? theme.colors.text : theme.colors.muted}>{displayName}</span>
162
- <MetaText text={versionStr} tone={plugin.hasUpdate ? "warning" : "muted"} />
168
+ <span fg={hasAnyScope && !notInstalled ? theme.colors.text : theme.colors.muted}>
169
+ {displayName}
170
+ </span>
171
+ <MetaText
172
+ text={versionStr}
173
+ tone={notInstalled || plugin.hasUpdate ? "warning" : "muted"}
174
+ />
163
175
  </text>
164
176
  );
165
177
  }