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.
- package/package.json +1 -1
- package/src/__tests__/alias-adopt.test.ts +364 -0
- package/src/__tests__/alias-parser.test.ts +409 -0
- package/src/__tests__/alias-shell-writer.test.ts +668 -0
- package/src/__tests__/alias-store.test.ts +163 -0
- package/src/__tests__/gitignore-fixer.test.ts +64 -1
- package/src/__tests__/gitignore-prerun.test.ts +2 -2
- package/src/__tests__/gitignore-service.test.ts +42 -0
- package/src/__tests__/marketplaces.test.ts +40 -0
- package/src/__tests__/plugin-manager-fallback.test.ts +120 -0
- package/src/__tests__/plugin-setup.test.ts +111 -0
- package/src/__tests__/useGitignoreModal.test.ts +2 -2
- package/src/data/alias-flags.js +205 -0
- package/src/data/alias-flags.ts +301 -0
- package/src/data/gitignore-reasons.js +97 -0
- package/src/data/gitignore-reasons.ts +103 -0
- package/src/data/marketplaces.js +5 -3
- package/src/data/marketplaces.ts +5 -4
- package/src/services/alias-settings.js +51 -0
- package/src/services/alias-settings.ts +63 -0
- package/src/services/alias-shell-writer.js +1018 -0
- package/src/services/alias-shell-writer.ts +1247 -0
- package/src/services/alias-store.js +129 -0
- package/src/services/alias-store.ts +172 -0
- package/src/services/gitignore-fixer.js +70 -10
- package/src/services/gitignore-fixer.ts +76 -9
- package/src/services/gitignore-prerun.js +3 -3
- package/src/services/gitignore-prerun.ts +3 -3
- package/src/services/gitignore-service.js +20 -2
- package/src/services/gitignore-service.ts +23 -1
- package/src/services/marketplace-fetcher.js +96 -0
- package/src/services/marketplace-fetcher.ts +137 -0
- package/src/services/plugin-manager.js +6 -59
- package/src/services/plugin-manager.ts +16 -91
- package/src/services/plugin-setup.js +59 -4
- package/src/services/plugin-setup.ts +61 -4
- package/src/services/skillsmp-client.js +29 -9
- package/src/services/skillsmp-client.ts +38 -8
- package/src/types/gitignore.ts +1 -1
- package/src/ui/App.js +26 -11
- package/src/ui/App.tsx +25 -10
- package/src/ui/components/FlagDetailEditor.js +0 -0
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/TabBar.js +2 -1
- package/src/ui/components/TabBar.tsx +2 -1
- package/src/ui/components/layout/FooterHints.js +29 -0
- package/src/ui/components/layout/FooterHints.tsx +52 -0
- package/src/ui/components/layout/ScreenLayout.js +2 -1
- package/src/ui/components/layout/ScreenLayout.tsx +12 -3
- package/src/ui/components/layout/index.js +1 -0
- package/src/ui/components/layout/index.ts +5 -0
- package/src/ui/components/modals/ConfirmModal.js +1 -1
- package/src/ui/components/modals/ConfirmModal.tsx +1 -1
- package/src/ui/components/modals/SelectModal.js +8 -1
- package/src/ui/components/modals/SelectModal.tsx +12 -1
- package/src/ui/hooks/useGitignoreModal.js +7 -8
- package/src/ui/hooks/useGitignoreModal.ts +8 -9
- package/src/ui/renderers/gitignoreRenderers.js +36 -23
- package/src/ui/renderers/gitignoreRenderers.tsx +50 -41
- package/src/ui/screens/AliasScreen.js +1111 -0
- package/src/ui/screens/AliasScreen.tsx +1534 -0
- package/src/ui/screens/CliToolsScreen.js +6 -1
- package/src/ui/screens/CliToolsScreen.tsx +6 -1
- package/src/ui/screens/EnvVarsScreen.js +6 -1
- package/src/ui/screens/EnvVarsScreen.tsx +6 -1
- package/src/ui/screens/GitignoreScreen.js +189 -88
- package/src/ui/screens/GitignoreScreen.tsx +312 -132
- package/src/ui/screens/McpRegistryScreen.js +13 -2
- package/src/ui/screens/McpRegistryScreen.tsx +13 -2
- package/src/ui/screens/McpScreen.js +6 -1
- package/src/ui/screens/McpScreen.tsx +6 -1
- package/src/ui/screens/ModelSelectorScreen.js +8 -2
- package/src/ui/screens/ModelSelectorScreen.tsx +8 -2
- package/src/ui/screens/PluginsScreen.js +17 -3
- package/src/ui/screens/PluginsScreen.tsx +16 -3
- package/src/ui/screens/ProfilesScreen.js +8 -1
- package/src/ui/screens/ProfilesScreen.tsx +8 -1
- package/src/ui/screens/SkillsScreen.js +21 -4
- package/src/ui/screens/SkillsScreen.tsx +39 -5
- package/src/ui/screens/StatusLineScreen.js +7 -1
- package/src/ui/screens/StatusLineScreen.tsx +7 -1
- package/src/ui/screens/index.js +1 -0
- package/src/ui/screens/index.ts +1 -0
- package/src/ui/state/reducer.js +5 -1
- package/src/ui/state/reducer.ts +6 -1
- 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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
519
|
-
if (!(await isBinaryAvailable(bin))) {
|
|
576
|
+
if (await goDepNeedsInstall(pkg)) {
|
|
520
577
|
missingGo.push(pkg);
|
|
521
578
|
}
|
|
522
579
|
}
|