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
@@ -49,8 +49,30 @@ export async function applyAllSafeFixes(
49
49
  export async function applyDestructiveFix(
50
50
  cwd: string,
51
51
  violation: Violation,
52
+ rulePattern?: string,
52
53
  ): Promise<DestructiveFixResult> {
53
- return coreApplyDestructiveFix(cwd, violation);
54
+ return coreApplyDestructiveFix(cwd, violation, rulePattern);
55
+ }
56
+
57
+ export async function ensureProjectManifestForEdit(
58
+ cwd: string,
59
+ resolved: ResolvedManifest,
60
+ ): Promise<string> {
61
+ const projectManifestPath = join(cwd, ".claude", "gitignore.json");
62
+ if (existsSync(projectManifestPath)) {
63
+ const existing = await readFile(projectManifestPath, "utf8");
64
+ if (existing.trim().length > 0) return projectManifestPath;
65
+ }
66
+
67
+ const manifest: GitignoreManifest = { ignore: [], track: [] };
68
+ for (const rule of resolved.rules) {
69
+ if (rule.action === "ignore") manifest.ignore.push(rule.pattern);
70
+ else manifest.track.push(rule.pattern);
71
+ }
72
+
73
+ await mkdir(dirname(projectManifestPath), { recursive: true });
74
+ await writeFile(projectManifestPath, JSON.stringify(manifest, null, 2) + "\n");
75
+ return projectManifestPath;
54
76
  }
55
77
 
56
78
  export interface ApplyTemplateResult {
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Marketplace.json fetcher with offline fallback.
3
+ *
4
+ * Extracted from `plugin-manager.ts` so the fetch + fallback logic can be
5
+ * unit-tested without dragging in claude-settings.ts's module-level
6
+ * `os.homedir()` constants (which contaminate the test suite when
7
+ * imported via plugin-manager.ts).
8
+ */
9
+ import { isValidGitHubRepo } from "../utils/string-utils.js";
10
+ // Session-level cache for fetched marketplace data (no TTL - persists until explicit refresh)
11
+ const marketplaceCache = new Map();
12
+ export function clearMarketplaceCache() {
13
+ marketplaceCache.clear();
14
+ }
15
+ export async function fetchMarketplacePlugins(marketplaceName, repo) {
16
+ // Check cache first - session-level, no TTL
17
+ const cached = marketplaceCache.get(marketplaceName);
18
+ if (cached) {
19
+ return cached;
20
+ }
21
+ // Validate repo format to prevent SSRF
22
+ if (!isValidGitHubRepo(repo)) {
23
+ console.error(`Invalid GitHub repo format: ${repo}`);
24
+ return [];
25
+ }
26
+ try {
27
+ // Fetch marketplace.json from GitHub
28
+ const url = `https://raw.githubusercontent.com/${repo}/main/.claude-plugin/marketplace.json`;
29
+ const response = await fetch(url, {
30
+ signal: AbortSignal.timeout(10000), // 10s timeout
31
+ });
32
+ if (!response.ok) {
33
+ console.error(`Failed to fetch marketplace: ${response.status}`);
34
+ return [];
35
+ }
36
+ // Validate content-type
37
+ const contentType = response.headers.get("content-type");
38
+ if (contentType &&
39
+ !contentType.includes("application/json") &&
40
+ !contentType.includes("text/plain")) {
41
+ console.error(`Invalid content-type for marketplace: ${contentType}`);
42
+ return [];
43
+ }
44
+ const data = (await response.json());
45
+ const plugins = [];
46
+ if (data.plugins && Array.isArray(data.plugins)) {
47
+ for (const plugin of data.plugins) {
48
+ plugins.push({
49
+ name: plugin.name,
50
+ version: plugin.version || null,
51
+ description: plugin.description || "",
52
+ category: plugin.category,
53
+ author: plugin.author,
54
+ homepage: plugin.homepage,
55
+ tags: plugin.tags,
56
+ });
57
+ }
58
+ }
59
+ // Cache the result (session-level)
60
+ marketplaceCache.set(marketplaceName, plugins);
61
+ return plugins;
62
+ }
63
+ catch (error) {
64
+ console.error(`Error fetching marketplace ${marketplaceName}:`, error);
65
+ return [];
66
+ }
67
+ }
68
+ /**
69
+ * Resolve marketplace plugins with offline fallback.
70
+ *
71
+ * Calls `fetchMarketplacePlugins` (which hits GitHub) and, when it returns
72
+ * empty AND a local marketplace cache exists for the same name, falls back
73
+ * to the cached plugin manifest. This prevents every installed plugin from
74
+ * being flagged `isOrphaned: true` (and rendered "deprecated") whenever the
75
+ * machine can't reach `raw.githubusercontent.com` — the local cache already
76
+ * has the data, it just wasn't being consulted as a fallback.
77
+ *
78
+ * The two shapes are nearly identical; we drop a couple of LocalMarketplacePlugin
79
+ * fields (lspServers, agents, etc.) that MarketplacePlugin doesn't carry
80
+ * since the caller doesn't read them at this layer.
81
+ */
82
+ export async function resolveMarketplacePlugins(mpName, repo, localMarketplaces) {
83
+ const remote = await fetchMarketplacePlugins(mpName, repo);
84
+ if (remote.length > 0)
85
+ return remote;
86
+ const local = localMarketplaces.get(mpName);
87
+ if (!local || local.plugins.length === 0)
88
+ return remote;
89
+ return local.plugins.map((p) => ({
90
+ name: p.name,
91
+ version: p.version,
92
+ description: p.description,
93
+ category: p.category,
94
+ author: p.author,
95
+ }));
96
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Marketplace.json fetcher with offline fallback.
3
+ *
4
+ * Extracted from `plugin-manager.ts` so the fetch + fallback logic can be
5
+ * unit-tested without dragging in claude-settings.ts's module-level
6
+ * `os.homedir()` constants (which contaminate the test suite when
7
+ * imported via plugin-manager.ts).
8
+ */
9
+
10
+ import { isValidGitHubRepo } from "../utils/string-utils.js";
11
+ import type { LocalMarketplace } from "./local-marketplace.js";
12
+
13
+ export interface MarketplacePlugin {
14
+ name: string;
15
+ version: string | null;
16
+ description: string;
17
+ category?: string;
18
+ author?: { name: string; email?: string };
19
+ homepage?: string;
20
+ tags?: string[];
21
+ }
22
+
23
+ // Session-level cache for fetched marketplace data (no TTL - persists until explicit refresh)
24
+ const marketplaceCache = new Map<string, MarketplacePlugin[]>();
25
+
26
+ export function clearMarketplaceCache(): void {
27
+ marketplaceCache.clear();
28
+ }
29
+
30
+ export async function fetchMarketplacePlugins(
31
+ marketplaceName: string,
32
+ repo: string,
33
+ ): Promise<MarketplacePlugin[]> {
34
+ // Check cache first - session-level, no TTL
35
+ const cached = marketplaceCache.get(marketplaceName);
36
+ if (cached) {
37
+ return cached;
38
+ }
39
+
40
+ // Validate repo format to prevent SSRF
41
+ if (!isValidGitHubRepo(repo)) {
42
+ console.error(`Invalid GitHub repo format: ${repo}`);
43
+ return [];
44
+ }
45
+
46
+ try {
47
+ // Fetch marketplace.json from GitHub
48
+ const url = `https://raw.githubusercontent.com/${repo}/main/.claude-plugin/marketplace.json`;
49
+ const response = await fetch(url, {
50
+ signal: AbortSignal.timeout(10000), // 10s timeout
51
+ });
52
+
53
+ if (!response.ok) {
54
+ console.error(`Failed to fetch marketplace: ${response.status}`);
55
+ return [];
56
+ }
57
+
58
+ // Validate content-type
59
+ const contentType = response.headers.get("content-type");
60
+ if (
61
+ contentType &&
62
+ !contentType.includes("application/json") &&
63
+ !contentType.includes("text/plain")
64
+ ) {
65
+ console.error(`Invalid content-type for marketplace: ${contentType}`);
66
+ return [];
67
+ }
68
+
69
+ interface RawPlugin {
70
+ name: string;
71
+ version?: string | null;
72
+ description?: string;
73
+ category?: string;
74
+ author?: { name: string; email?: string };
75
+ homepage?: string;
76
+ tags?: string[];
77
+ }
78
+ const data = (await response.json()) as { plugins?: RawPlugin[] };
79
+ const plugins: MarketplacePlugin[] = [];
80
+
81
+ if (data.plugins && Array.isArray(data.plugins)) {
82
+ for (const plugin of data.plugins) {
83
+ plugins.push({
84
+ name: plugin.name,
85
+ version: plugin.version || null,
86
+ description: plugin.description || "",
87
+ category: plugin.category,
88
+ author: plugin.author,
89
+ homepage: plugin.homepage,
90
+ tags: plugin.tags,
91
+ });
92
+ }
93
+ }
94
+
95
+ // Cache the result (session-level)
96
+ marketplaceCache.set(marketplaceName, plugins);
97
+
98
+ return plugins;
99
+ } catch (error) {
100
+ console.error(`Error fetching marketplace ${marketplaceName}:`, error);
101
+ return [];
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Resolve marketplace plugins with offline fallback.
107
+ *
108
+ * Calls `fetchMarketplacePlugins` (which hits GitHub) and, when it returns
109
+ * empty AND a local marketplace cache exists for the same name, falls back
110
+ * to the cached plugin manifest. This prevents every installed plugin from
111
+ * being flagged `isOrphaned: true` (and rendered "deprecated") whenever the
112
+ * machine can't reach `raw.githubusercontent.com` — the local cache already
113
+ * has the data, it just wasn't being consulted as a fallback.
114
+ *
115
+ * The two shapes are nearly identical; we drop a couple of LocalMarketplacePlugin
116
+ * fields (lspServers, agents, etc.) that MarketplacePlugin doesn't carry
117
+ * since the caller doesn't read them at this layer.
118
+ */
119
+ export async function resolveMarketplacePlugins(
120
+ mpName: string,
121
+ repo: string,
122
+ localMarketplaces: Map<string, LocalMarketplace>,
123
+ ): Promise<MarketplacePlugin[]> {
124
+ const remote = await fetchMarketplacePlugins(mpName, repo);
125
+ if (remote.length > 0) return remote;
126
+
127
+ const local = localMarketplaces.get(mpName);
128
+ if (!local || local.plugins.length === 0) return remote;
129
+
130
+ return local.plugins.map((p) => ({
131
+ name: p.name,
132
+ version: p.version,
133
+ description: p.description,
134
+ category: p.category,
135
+ author: p.author,
136
+ }));
137
+ }
@@ -4,64 +4,11 @@ import { execSync } from "node:child_process";
4
4
  import { getConfiguredMarketplaces, getEnabledPlugins, readSettings, writeSettings, getGlobalConfiguredMarketplaces, getGlobalEnabledPlugins, getGlobalInstalledPluginVersions, getLocalEnabledPlugins, getLocalInstalledPluginVersions, updateInstalledPluginsRegistry, removeFromInstalledPluginsRegistry, } from "./claude-settings.js";
5
5
  import { defaultMarketplaces } from "../data/marketplaces.js";
6
6
  import { scanLocalMarketplaces, repairAllMarketplaces, } from "./local-marketplace.js";
7
- import { formatMarketplaceName, isValidGitHubRepo, parsePluginId, } from "../utils/string-utils.js";
7
+ import { formatMarketplaceName, parsePluginId, } from "../utils/string-utils.js";
8
+ import { clearMarketplaceCache as clearFetcherCache, fetchMarketplacePlugins, resolveMarketplacePlugins, } from "./marketplace-fetcher.js";
9
+ export { fetchMarketplacePlugins, resolveMarketplacePlugins, };
8
10
  // Cache for local marketplaces (session-level) - Promise-based to prevent race conditions
9
11
  let localMarketplacesPromise = null;
10
- // Session-level cache for fetched marketplace data (no TTL - persists until explicit refresh)
11
- const marketplaceCache = new Map();
12
- export async function fetchMarketplacePlugins(marketplaceName, repo) {
13
- // Check cache first - session-level, no TTL
14
- const cached = marketplaceCache.get(marketplaceName);
15
- if (cached) {
16
- return cached;
17
- }
18
- // Validate repo format to prevent SSRF
19
- if (!isValidGitHubRepo(repo)) {
20
- console.error(`Invalid GitHub repo format: ${repo}`);
21
- return [];
22
- }
23
- try {
24
- // Fetch marketplace.json from GitHub
25
- const url = `https://raw.githubusercontent.com/${repo}/main/.claude-plugin/marketplace.json`;
26
- const response = await fetch(url, {
27
- signal: AbortSignal.timeout(10000), // 10s timeout
28
- });
29
- if (!response.ok) {
30
- console.error(`Failed to fetch marketplace: ${response.status}`);
31
- return [];
32
- }
33
- // Validate content-type
34
- const contentType = response.headers.get("content-type");
35
- if (contentType &&
36
- !contentType.includes("application/json") &&
37
- !contentType.includes("text/plain")) {
38
- console.error(`Invalid content-type for marketplace: ${contentType}`);
39
- return [];
40
- }
41
- const data = (await response.json());
42
- const plugins = [];
43
- if (data.plugins && Array.isArray(data.plugins)) {
44
- for (const plugin of data.plugins) {
45
- plugins.push({
46
- name: plugin.name,
47
- version: plugin.version || null,
48
- description: plugin.description || "",
49
- category: plugin.category,
50
- author: plugin.author,
51
- homepage: plugin.homepage,
52
- tags: plugin.tags,
53
- });
54
- }
55
- }
56
- // Cache the result (session-level)
57
- marketplaceCache.set(marketplaceName, plugins);
58
- return plugins;
59
- }
60
- catch (error) {
61
- console.error(`Error fetching marketplace ${marketplaceName}:`, error);
62
- return [];
63
- }
64
- }
65
12
  export async function getAvailablePlugins(projectPath) {
66
13
  const configuredMarketplaces = await getConfiguredMarketplaces(projectPath);
67
14
  const enabledPlugins = await getEnabledPlugins(projectPath);
@@ -111,7 +58,7 @@ export async function getAvailablePlugins(projectPath) {
111
58
  const marketplace = defaultMarketplaces.find((m) => m.name === mpName);
112
59
  if (!marketplace)
113
60
  continue;
114
- const marketplacePlugins = await fetchMarketplacePlugins(mpName, marketplace.source.repo);
61
+ const marketplacePlugins = await resolveMarketplacePlugins(mpName, marketplace.source.repo, localMarketplaces);
115
62
  // Auto-sync local cache if remote has plugins the local cache doesn't
116
63
  localMarketplaces = await autoSyncIfStale(mpName, marketplacePlugins.map((p) => p.name), localMarketplaces);
117
64
  for (const plugin of marketplacePlugins) {
@@ -265,7 +212,7 @@ export async function getGlobalAvailablePlugins() {
265
212
  const marketplace = defaultMarketplaces.find((m) => m.name === mpName);
266
213
  if (!marketplace)
267
214
  continue;
268
- const marketplacePlugins = await fetchMarketplacePlugins(mpName, marketplace.source.repo);
215
+ const marketplacePlugins = await resolveMarketplacePlugins(mpName, marketplace.source.repo, localMarketplaces);
269
216
  // Auto-sync local cache if remote has plugins the local cache doesn't
270
217
  localMarketplaces = await autoSyncIfStale(mpName, marketplacePlugins.map((p) => p.name), localMarketplaces);
271
218
  for (const plugin of marketplacePlugins) {
@@ -414,7 +361,7 @@ export async function removeInstalledPluginVersion(pluginId, projectPath) {
414
361
  }
415
362
  // Clear marketplace cache
416
363
  export function clearMarketplaceCache() {
417
- marketplaceCache.clear();
364
+ clearFetcherCache();
418
365
  localMarketplacesPromise = null;
419
366
  }
420
367
  // Get local marketplaces (with Promise-based caching to prevent race conditions)
@@ -24,9 +24,19 @@ import {
24
24
  } from "./local-marketplace.js";
25
25
  import {
26
26
  formatMarketplaceName,
27
- isValidGitHubRepo,
28
27
  parsePluginId,
29
28
  } from "../utils/string-utils.js";
29
+ import {
30
+ clearMarketplaceCache as clearFetcherCache,
31
+ fetchMarketplacePlugins,
32
+ resolveMarketplacePlugins,
33
+ type MarketplacePlugin,
34
+ } from "./marketplace-fetcher.js";
35
+ export {
36
+ fetchMarketplacePlugins,
37
+ resolveMarketplacePlugins,
38
+ type MarketplacePlugin,
39
+ };
30
40
  // Cache for local marketplaces (session-level) - Promise-based to prevent race conditions
31
41
  let localMarketplacesPromise: Promise<Map<string, LocalMarketplace>> | null =
32
42
  null;
@@ -63,93 +73,6 @@ export interface PluginInfo {
63
73
  isOrphaned?: boolean;
64
74
  }
65
75
 
66
- export interface MarketplacePlugin {
67
- name: string;
68
- version: string | null;
69
- description: string;
70
- category?: string;
71
- author?: { name: string; email?: string };
72
- homepage?: string;
73
- tags?: string[];
74
- }
75
-
76
- // Session-level cache for fetched marketplace data (no TTL - persists until explicit refresh)
77
- const marketplaceCache = new Map<string, MarketplacePlugin[]>();
78
-
79
- export async function fetchMarketplacePlugins(
80
- marketplaceName: string,
81
- repo: string,
82
- ): Promise<MarketplacePlugin[]> {
83
- // Check cache first - session-level, no TTL
84
- const cached = marketplaceCache.get(marketplaceName);
85
- if (cached) {
86
- return cached;
87
- }
88
-
89
- // Validate repo format to prevent SSRF
90
- if (!isValidGitHubRepo(repo)) {
91
- console.error(`Invalid GitHub repo format: ${repo}`);
92
- return [];
93
- }
94
-
95
- try {
96
- // Fetch marketplace.json from GitHub
97
- const url = `https://raw.githubusercontent.com/${repo}/main/.claude-plugin/marketplace.json`;
98
- const response = await fetch(url, {
99
- signal: AbortSignal.timeout(10000), // 10s timeout
100
- });
101
-
102
- if (!response.ok) {
103
- console.error(`Failed to fetch marketplace: ${response.status}`);
104
- return [];
105
- }
106
-
107
- // Validate content-type
108
- const contentType = response.headers.get("content-type");
109
- if (
110
- contentType &&
111
- !contentType.includes("application/json") &&
112
- !contentType.includes("text/plain")
113
- ) {
114
- console.error(`Invalid content-type for marketplace: ${contentType}`);
115
- return [];
116
- }
117
-
118
- interface RawPlugin {
119
- name: string;
120
- version?: string | null;
121
- description?: string;
122
- category?: string;
123
- author?: { name: string; email?: string };
124
- homepage?: string;
125
- tags?: string[];
126
- }
127
- const data = (await response.json()) as { plugins?: RawPlugin[] };
128
- const plugins: MarketplacePlugin[] = [];
129
-
130
- if (data.plugins && Array.isArray(data.plugins)) {
131
- for (const plugin of data.plugins) {
132
- plugins.push({
133
- name: plugin.name,
134
- version: plugin.version || null,
135
- description: plugin.description || "",
136
- category: plugin.category,
137
- author: plugin.author,
138
- homepage: plugin.homepage,
139
- tags: plugin.tags,
140
- });
141
- }
142
- }
143
-
144
- // Cache the result (session-level)
145
- marketplaceCache.set(marketplaceName, plugins);
146
-
147
- return plugins;
148
- } catch (error) {
149
- console.error(`Error fetching marketplace ${marketplaceName}:`, error);
150
- return [];
151
- }
152
- }
153
76
 
154
77
  export async function getAvailablePlugins(
155
78
  projectPath?: string,
@@ -213,9 +136,10 @@ export async function getAvailablePlugins(
213
136
  const marketplace = defaultMarketplaces.find((m) => m.name === mpName);
214
137
  if (!marketplace) continue;
215
138
 
216
- const marketplacePlugins = await fetchMarketplacePlugins(
139
+ const marketplacePlugins = await resolveMarketplacePlugins(
217
140
  mpName,
218
141
  marketplace.source.repo,
142
+ localMarketplaces,
219
143
  );
220
144
 
221
145
  // Auto-sync local cache if remote has plugins the local cache doesn't
@@ -397,9 +321,10 @@ export async function getGlobalAvailablePlugins(): Promise<PluginInfo[]> {
397
321
  const marketplace = defaultMarketplaces.find((m) => m.name === mpName);
398
322
  if (!marketplace) continue;
399
323
 
400
- const marketplacePlugins = await fetchMarketplacePlugins(
324
+ const marketplacePlugins = await resolveMarketplacePlugins(
401
325
  mpName,
402
326
  marketplace.source.repo,
327
+ localMarketplaces,
403
328
  );
404
329
 
405
330
  // Auto-sync local cache if remote has plugins the local cache doesn't
@@ -592,7 +517,7 @@ export async function removeInstalledPluginVersion(
592
517
 
593
518
  // Clear marketplace cache
594
519
  export function clearMarketplaceCache(): void {
595
- marketplaceCache.clear();
520
+ clearFetcherCache();
596
521
  localMarketplacesPromise = null;
597
522
  }
598
523
 
@@ -241,6 +241,63 @@ export function extractGoBinaryName(pkg) {
241
241
  const withoutVersion = pkg.replace(/@[^/]*$/, "");
242
242
  return withoutVersion.split("/").pop() || pkg;
243
243
  }
244
+ /**
245
+ * Extract the pinned version from a Go module path, or null when unpinned.
246
+ * e.g., "github.com/MadAppGang/tmux-mcp@v1.6.2" → "v1.6.2"
247
+ * "github.com/MadAppGang/tmux-mcp@latest" → null (a moving target)
248
+ * "github.com/user/tool" → null
249
+ */
250
+ export function extractGoVersion(pkg) {
251
+ const at = pkg.lastIndexOf("@");
252
+ if (at === -1)
253
+ return null;
254
+ const version = pkg.slice(at + 1);
255
+ if (version === "" || version === "latest")
256
+ return null;
257
+ return version;
258
+ }
259
+ /** Normalize a semver-ish string for comparison: trim and drop a leading "v". */
260
+ function normalizeVersion(v) {
261
+ return v.trim().replace(/^v/, "");
262
+ }
263
+ /**
264
+ * Read an installed binary's version via `<binary> --version`, normalized, or
265
+ * null if it produced no usable version string. Used to tell whether a pinned Go
266
+ * dependency is already at the right version.
267
+ */
268
+ async function getInstalledBinaryVersion(binaryName) {
269
+ const { ok, stdout } = await run(binaryName, ["--version"], 10000);
270
+ if (!ok || !stdout)
271
+ return null;
272
+ const match = stdout.split("\n")[0].match(/v?\d+\.\d+\.\d+[^\s]*/);
273
+ return match ? normalizeVersion(match[0]) : null;
274
+ }
275
+ /**
276
+ * Decide whether a Go dependency needs (re)installing. This is what makes a
277
+ * plugin *update* actually update its binary: without a version check an
278
+ * already-present binary is skipped forever, so a bumped plugin never pulls the
279
+ * new binary.
280
+ *
281
+ * - binary absent → yes
282
+ * - pinned version, installed reports the same one → no
283
+ * - pinned version, installed reports a different one → yes
284
+ * - pinned version, installed version can't be read → yes (can't confirm the
285
+ * pin, so reinstall to be safe)
286
+ * - unpinned (@latest) and present → no (a moving target
287
+ * can't be verified without a network round-trip)
288
+ */
289
+ export async function goDepNeedsInstall(pkg) {
290
+ const binaryName = extractGoBinaryName(pkg);
291
+ if (!(await isBinaryAvailable(binaryName)))
292
+ return true;
293
+ const pinned = extractGoVersion(pkg);
294
+ if (!pinned)
295
+ return false;
296
+ const installed = await getInstalledBinaryVersion(binaryName);
297
+ if (installed === null)
298
+ return true;
299
+ return normalizeVersion(pinned) !== installed;
300
+ }
244
301
  /**
245
302
  * Install Go packages via `go install`
246
303
  */
@@ -253,8 +310,7 @@ async function installGoPackages(packages, result) {
253
310
  return;
254
311
  }
255
312
  for (const pkg of packages) {
256
- const binaryName = extractGoBinaryName(pkg);
257
- if (await isBinaryAvailable(binaryName)) {
313
+ if (!(await goDepNeedsInstall(pkg))) {
258
314
  result.skipped.push(`go:${pkg}`);
259
315
  continue;
260
316
  }
@@ -422,8 +478,7 @@ export async function checkMissingDeps(setup) {
422
478
  if (setup.go?.length) {
423
479
  const missingGo = [];
424
480
  for (const pkg of setup.go) {
425
- const bin = extractGoBinaryName(pkg);
426
- if (!(await isBinaryAvailable(bin))) {
481
+ if (await goDepNeedsInstall(pkg)) {
427
482
  missingGo.push(pkg);
428
483
  }
429
484
  }
@@ -295,6 +295,65 @@ export function extractGoBinaryName(pkg: string): string {
295
295
  return withoutVersion.split("/").pop() || pkg;
296
296
  }
297
297
 
298
+ /**
299
+ * Extract the pinned version from a Go module path, or null when unpinned.
300
+ * e.g., "github.com/MadAppGang/tmux-mcp@v1.6.2" → "v1.6.2"
301
+ * "github.com/MadAppGang/tmux-mcp@latest" → null (a moving target)
302
+ * "github.com/user/tool" → null
303
+ */
304
+ export function extractGoVersion(pkg: string): string | null {
305
+ const at = pkg.lastIndexOf("@");
306
+ if (at === -1) return null;
307
+ const version = pkg.slice(at + 1);
308
+ if (version === "" || version === "latest") return null;
309
+ return version;
310
+ }
311
+
312
+ /** Normalize a semver-ish string for comparison: trim and drop a leading "v". */
313
+ function normalizeVersion(v: string): string {
314
+ return v.trim().replace(/^v/, "");
315
+ }
316
+
317
+ /**
318
+ * Read an installed binary's version via `<binary> --version`, normalized, or
319
+ * null if it produced no usable version string. Used to tell whether a pinned Go
320
+ * dependency is already at the right version.
321
+ */
322
+ async function getInstalledBinaryVersion(
323
+ binaryName: string,
324
+ ): Promise<string | null> {
325
+ const { ok, stdout } = await run(binaryName, ["--version"], 10000);
326
+ if (!ok || !stdout) return null;
327
+ const match = stdout.split("\n")[0].match(/v?\d+\.\d+\.\d+[^\s]*/);
328
+ return match ? normalizeVersion(match[0]) : null;
329
+ }
330
+
331
+ /**
332
+ * Decide whether a Go dependency needs (re)installing. This is what makes a
333
+ * plugin *update* actually update its binary: without a version check an
334
+ * already-present binary is skipped forever, so a bumped plugin never pulls the
335
+ * new binary.
336
+ *
337
+ * - binary absent → yes
338
+ * - pinned version, installed reports the same one → no
339
+ * - pinned version, installed reports a different one → yes
340
+ * - pinned version, installed version can't be read → yes (can't confirm the
341
+ * pin, so reinstall to be safe)
342
+ * - unpinned (@latest) and present → no (a moving target
343
+ * can't be verified without a network round-trip)
344
+ */
345
+ export async function goDepNeedsInstall(pkg: string): Promise<boolean> {
346
+ const binaryName = extractGoBinaryName(pkg);
347
+ if (!(await isBinaryAvailable(binaryName))) return true;
348
+
349
+ const pinned = extractGoVersion(pkg);
350
+ if (!pinned) return false;
351
+
352
+ const installed = await getInstalledBinaryVersion(binaryName);
353
+ if (installed === null) return true;
354
+ return normalizeVersion(pinned) !== installed;
355
+ }
356
+
298
357
  /**
299
358
  * Install Go packages via `go install`
300
359
  */
@@ -311,8 +370,7 @@ async function installGoPackages(
311
370
  }
312
371
 
313
372
  for (const pkg of packages) {
314
- const binaryName = extractGoBinaryName(pkg);
315
- if (await isBinaryAvailable(binaryName)) {
373
+ if (!(await goDepNeedsInstall(pkg))) {
316
374
  result.skipped.push(`go:${pkg}`);
317
375
  continue;
318
376
  }
@@ -515,8 +573,7 @@ export async function checkMissingDeps(
515
573
  if (setup.go?.length) {
516
574
  const missingGo: string[] = [];
517
575
  for (const pkg of setup.go) {
518
- const bin = extractGoBinaryName(pkg);
519
- if (!(await isBinaryAvailable(bin))) {
576
+ if (await goDepNeedsInstall(pkg)) {
520
577
  missingGo.push(pkg);
521
578
  }
522
579
  }