@yancyyu/openhermit 1.6.28 → 1.6.30

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 (177) hide show
  1. package/dist-renderer/assets/ProjectEditorOverlay-DsQt4FHy.js +52 -0
  2. package/dist-renderer/assets/{TeamGraphOverlay-Ba5njic5.js → TeamGraphOverlay-BjZC53xf.js} +1 -1
  3. package/dist-renderer/assets/{_basePickBy-BvnK-OC1.js → _basePickBy-CrWocIjq.js} +1 -1
  4. package/dist-renderer/assets/{_baseUniq-DmFYXx9G.js → _baseUniq-B6d8ysWi.js} +1 -1
  5. package/dist-renderer/assets/{arc-DX4ZQFY4.js → arc-DAIYCFP8.js} +1 -1
  6. package/dist-renderer/assets/{architectureDiagram-VXUJARFQ-DfYr3vEN.js → architectureDiagram-VXUJARFQ-B3UudXJh.js} +1 -1
  7. package/dist-renderer/assets/{blockDiagram-VD42YOAC-DuXdVeWn.js → blockDiagram-VD42YOAC-DbptKQ4W.js} +1 -1
  8. package/dist-renderer/assets/{c4Diagram-YG6GDRKO-Bw2nixXe.js → c4Diagram-YG6GDRKO-C4WQuZpV.js} +1 -1
  9. package/dist-renderer/assets/channel-DbjZvWii.js +1 -0
  10. package/dist-renderer/assets/{chunk-4BX2VUAB-DLiNGQoE.js → chunk-4BX2VUAB-Dp7fVpI_.js} +1 -1
  11. package/dist-renderer/assets/{chunk-55IACEB6-B1L_8VIF.js → chunk-55IACEB6-B8KGfbAy.js} +1 -1
  12. package/dist-renderer/assets/{chunk-B4BG7PRW-DaZMWKGk.js → chunk-B4BG7PRW-BG1oJrjA.js} +1 -1
  13. package/dist-renderer/assets/{chunk-DI55MBZ5-ku-dflJG.js → chunk-DI55MBZ5-DRmxNjht.js} +1 -1
  14. package/dist-renderer/assets/{chunk-FMBD7UC4-DV-mF1dP.js → chunk-FMBD7UC4-D6VLvy16.js} +1 -1
  15. package/dist-renderer/assets/{chunk-QN33PNHL-ByGcDFQ0.js → chunk-QN33PNHL-DZou1667.js} +1 -1
  16. package/dist-renderer/assets/{chunk-QZHKN3VN-7dv-Min8.js → chunk-QZHKN3VN-CghmasSh.js} +1 -1
  17. package/dist-renderer/assets/{chunk-TZMSLE5B-WdXL5fTu.js → chunk-TZMSLE5B-B7apcMPK.js} +1 -1
  18. package/dist-renderer/assets/classDiagram-2ON5EDUG-D_FGxxsl.js +1 -0
  19. package/dist-renderer/assets/classDiagram-v2-WZHVMYZB-D_FGxxsl.js +1 -0
  20. package/dist-renderer/assets/clone-CJ1kxO2J.js +1 -0
  21. package/dist-renderer/assets/{cose-bilkent-S5V4N54A-CNcsvqPl.js → cose-bilkent-S5V4N54A-05e5uQDp.js} +1 -1
  22. package/dist-renderer/assets/{dagre-6UL2VRFP-DBNx4qqx.js → dagre-6UL2VRFP-B06bRykF.js} +1 -1
  23. package/dist-renderer/assets/{diagram-PSM6KHXK-BfVlT6sT.js → diagram-PSM6KHXK-CY7VYQ7c.js} +1 -1
  24. package/dist-renderer/assets/{diagram-QEK2KX5R-HvVjs0K6.js → diagram-QEK2KX5R-BjKEH7dD.js} +1 -1
  25. package/dist-renderer/assets/{diagram-S2PKOQOG-DYb_KnWS.js → diagram-S2PKOQOG-Bf4ELS1_.js} +1 -1
  26. package/dist-renderer/assets/{erDiagram-Q2GNP2WA-Ba-IgI5G.js → erDiagram-Q2GNP2WA-DJ753_L9.js} +1 -1
  27. package/dist-renderer/assets/{flowDiagram-NV44I4VS-2iDN8Kpj.js → flowDiagram-NV44I4VS-B71S-lC-.js} +1 -1
  28. package/dist-renderer/assets/{ganttDiagram-JELNMOA3-Byjf8Fa3.js → ganttDiagram-JELNMOA3-C_U42mSZ.js} +1 -1
  29. package/dist-renderer/assets/{gitGraphDiagram-V2S2FVAM-DbKvfZ_j.js → gitGraphDiagram-V2S2FVAM-DKUJU4Ns.js} +1 -1
  30. package/dist-renderer/assets/{graph-Enirf-f8.js → graph-DY3qbzqj.js} +1 -1
  31. package/dist-renderer/assets/{index-DY1zqsb6.js → index-BlOrAXp3.js} +551 -537
  32. package/dist-renderer/assets/{index-AjxP_rE_.js → index-Bs27J5gB.js} +1 -1
  33. package/dist-renderer/assets/{index-CtlzGepK.js → index-C8B_nKOF.js} +1 -1
  34. package/dist-renderer/assets/index-CmZPUEhS.css +1 -0
  35. package/dist-renderer/assets/{index-COZPUWJW.js → index-DLKyDr4T.js} +1 -1
  36. package/dist-renderer/assets/{index-DdhqolqE.js → index-Dhsk3_DD.js} +1 -1
  37. package/dist-renderer/assets/{index-ChR1D6ZF.js → index-GpUvV2xs.js} +1 -1
  38. package/dist-renderer/assets/{infoDiagram-HS3SLOUP-D6uicwz1.js → infoDiagram-HS3SLOUP-BNs0y3IG.js} +1 -1
  39. package/dist-renderer/assets/{journeyDiagram-XKPGCS4Q-DqwZsXlQ.js → journeyDiagram-XKPGCS4Q-CqPnw4UV.js} +1 -1
  40. package/dist-renderer/assets/{kanban-definition-3W4ZIXB7-fCDVhVUm.js → kanban-definition-3W4ZIXB7-SLlzcUJ2.js} +1 -1
  41. package/dist-renderer/assets/{layout-CPFgj98r.js → layout-BZLlNmbr.js} +1 -1
  42. package/dist-renderer/assets/{linear-CYiQ7Y3M.js → linear-qz6v45xy.js} +1 -1
  43. package/dist-renderer/assets/{mindmap-definition-VGOIOE7T-D31dS2KE.js → mindmap-definition-VGOIOE7T-B1-kmEWV.js} +1 -1
  44. package/dist-renderer/assets/{pieDiagram-ADFJNKIX-BOsCJfds.js → pieDiagram-ADFJNKIX-B8a02iNx.js} +1 -1
  45. package/dist-renderer/assets/{quadrantDiagram-AYHSOK5B-CYTVQCfr.js → quadrantDiagram-AYHSOK5B-BKv1Xfou.js} +1 -1
  46. package/dist-renderer/assets/{requirementDiagram-UZGBJVZJ-CODCFpkt.js → requirementDiagram-UZGBJVZJ-B3DUpZi2.js} +1 -1
  47. package/dist-renderer/assets/{sankeyDiagram-TZEHDZUN-Z4ce9ZtZ.js → sankeyDiagram-TZEHDZUN-DmPzuTsy.js} +1 -1
  48. package/dist-renderer/assets/{sequenceDiagram-WL72ISMW-CmS9TxhW.js → sequenceDiagram-WL72ISMW-Bo7RelRb.js} +1 -1
  49. package/dist-renderer/assets/{stateDiagram-FKZM4ZOC-o9k-ns3q.js → stateDiagram-FKZM4ZOC-1epX98gV.js} +1 -1
  50. package/dist-renderer/assets/{stateDiagram-v2-4FDKWEC3-CxHMyEt1.js → stateDiagram-v2-4FDKWEC3-03Ym9PTr.js} +1 -1
  51. package/dist-renderer/assets/{timeline-definition-IT6M3QCI-B6T3zrde.js → timeline-definition-IT6M3QCI-r6isC62H.js} +1 -1
  52. package/dist-renderer/assets/{treemap-GDKQZRPO-CVd5GNDw.js → treemap-GDKQZRPO-CGKpOUF2.js} +1 -1
  53. package/dist-renderer/assets/{xychartDiagram-PRI3JC2R-CleBrdqc.js → xychartDiagram-PRI3JC2R-t4-rwdAw.js} +1 -1
  54. package/dist-renderer/index.html +2 -2
  55. package/package.json +4 -1
  56. package/src/main/ipc/extensions.ts +353 -0
  57. package/src/main/server.ts +907 -184
  58. package/src/main/services/extensions/ExtensionFacadeService.ts +135 -0
  59. package/src/main/services/extensions/catalog/GlamaMcpEnrichmentService.ts +190 -0
  60. package/src/main/services/extensions/catalog/McpCatalogAggregator.ts +150 -0
  61. package/src/main/services/extensions/catalog/OfficialMcpRegistryService.ts +381 -0
  62. package/src/main/services/extensions/catalog/PluginCatalogService.ts +392 -0
  63. package/src/main/services/extensions/credentials/CredentialService.ts +343 -0
  64. package/src/main/services/extensions/install/McpInstallService.ts +407 -0
  65. package/src/main/services/extensions/install/PluginInstallService.ts +198 -0
  66. package/src/main/services/extensions/runtime/ClaudeCodeAdapter.ts +199 -0
  67. package/src/main/services/extensions/runtime/CodexAdapter.ts +100 -0
  68. package/src/main/services/extensions/runtime/CursorAdapter.ts +154 -0
  69. package/src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts +172 -0
  70. package/src/main/services/extensions/runtime/GeminiAdapter.ts +91 -0
  71. package/src/main/services/extensions/runtime/HarnessInstallAdapter.ts +49 -0
  72. package/src/main/services/extensions/runtime/McpConfigStateReader.ts +209 -0
  73. package/src/main/services/extensions/runtime/OpenCodeAdapter.ts +91 -0
  74. package/src/main/services/extensions/runtime/adapterRegistry.ts +54 -0
  75. package/src/main/services/extensions/runtime/mcpDiagnosticsParser.ts +214 -0
  76. package/src/main/services/extensions/runtime/mcpRuntimeJson.ts +45 -0
  77. package/src/main/services/extensions/skills/SkillImportService.ts +155 -0
  78. package/src/main/services/extensions/skills/SkillMetadataParser.ts +323 -0
  79. package/src/main/services/extensions/skills/SkillPlanService.ts +411 -0
  80. package/src/main/services/extensions/skills/SkillReviewService.ts +73 -0
  81. package/src/main/services/extensions/skills/SkillRootsResolver.ts +49 -0
  82. package/src/main/services/extensions/skills/SkillScaffoldService.ts +89 -0
  83. package/src/main/services/extensions/skills/SkillScanner.ts +117 -0
  84. package/src/main/services/extensions/skills/SkillValidator.ts +69 -0
  85. package/src/main/services/extensions/skills/SkillsCatalogService.ts +92 -0
  86. package/src/main/services/extensions/skills/SkillsMutationService.ts +146 -0
  87. package/src/main/services/extensions/skills/SkillsWatcherService.ts +134 -0
  88. package/src/main/services/extensions/state/McpInstallationStateService.ts +42 -0
  89. package/src/main/services/extensions/state/PluginInstallationStateService.ts +281 -0
  90. package/src/main/services/identity/AgentTeamsIdentityStore.ts +218 -0
  91. package/src/main/services/runtime/providerAwareCliEnv.ts +60 -0
  92. package/src/main/services/session-intelligence/UsageTelemetryService.ts +33 -18
  93. package/src/main/services/team/ClaudeBinaryResolver.ts +469 -0
  94. package/src/main/services/team/ClaudeDoctorProbe.ts +0 -0
  95. package/src/main/services/team/cliFlavor.ts +54 -0
  96. package/src/main/services/teams-mvp/CollaborationBoardService.ts +310 -0
  97. package/src/main/services/teams-mvp/TaskDispatchService.ts +883 -95
  98. package/src/main/services/teams-mvp/TeamProvisioningService.ts +58 -19
  99. package/src/main/services/teams-mvp/TeamWorkspaceService.ts +25 -2
  100. package/src/main/services/teams-mvp/index.ts +3 -0
  101. package/src/main/utils/atomicWrite.ts +72 -0
  102. package/src/main/utils/childProcess.ts +554 -0
  103. package/src/main/utils/cliEnv.ts +54 -0
  104. package/src/main/utils/cliPathMerge.ts +97 -0
  105. package/src/main/utils/pathDecoder.ts +664 -0
  106. package/src/main/utils/pathValidation.ts +432 -0
  107. package/src/main/utils/shellEnv.ts +331 -0
  108. package/src/renderer/App.tsx +5 -0
  109. package/src/renderer/api/httpClient.ts +128 -0
  110. package/src/renderer/components/extensions/ExtensionStoreView.tsx +59 -34
  111. package/src/renderer/components/extensions/ExtensionsSubTabTrigger.tsx +1 -1
  112. package/src/renderer/components/extensions/common/ExtensionToast.tsx +141 -0
  113. package/src/renderer/components/extensions/common/HarnessSelector.tsx +71 -0
  114. package/src/renderer/components/extensions/env/EnvVarPanel.tsx +335 -0
  115. package/src/renderer/components/extensions/env/ProjectEnvPanel.tsx +239 -0
  116. package/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx +14 -223
  117. package/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx +11 -0
  118. package/src/renderer/components/extensions/mcp/McpServersPanel.tsx +51 -1
  119. package/src/renderer/components/extensions/skills/SkillsPanel.tsx +1 -126
  120. package/src/renderer/components/layout/PaneContent.tsx +2 -0
  121. package/src/renderer/components/layout/SortableTab.tsx +1 -0
  122. package/src/renderer/components/layout/TabBarActions.tsx +12 -12
  123. package/src/renderer/components/schedules/SchedulesView.tsx +54 -22
  124. package/src/renderer/components/settings/sections/AdvancedSection.tsx +1 -1
  125. package/src/renderer/components/settings/sections/HarnessSection.tsx +2 -6
  126. package/src/renderer/components/settings/sections/TaskBusSection.tsx +144 -84
  127. package/src/renderer/components/sidebar/SidebarSessions.tsx +23 -0
  128. package/src/renderer/components/sidebar/WorkspaceBrowser.tsx +1 -7
  129. package/src/renderer/components/tasks/TasksView.tsx +343 -0
  130. package/src/renderer/components/team/HarnessSelect.tsx +71 -0
  131. package/src/renderer/components/team/TeamDetailView.tsx +55 -98
  132. package/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +21 -12
  133. package/src/renderer/components/team/dialogs/EditTeamDialog.tsx +8 -13
  134. package/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +1 -1
  135. package/src/renderer/components/team/editor/EditorContextMenu.tsx +8 -23
  136. package/src/renderer/components/team/editor/EditorFileTree.tsx +0 -4
  137. package/src/renderer/components/team/editor/EditorSelectionMenu.tsx +1 -8
  138. package/src/renderer/components/team/editor/ProjectEditorOverlay.tsx +0 -10
  139. package/src/renderer/components/team/kanban/KanbanBoard.tsx +31 -65
  140. package/src/renderer/components/team/members/MemberDetailDialog.tsx +8 -33
  141. package/src/renderer/components/team/messages/MessageComposer.tsx +39 -3
  142. package/src/renderer/components/team/messages/MessagesPanel.tsx +100 -26
  143. package/src/renderer/components/team/messages/StatusBlock.tsx +2 -24
  144. package/src/renderer/components/team/schedule/ScheduleEmptyState.tsx +1 -1
  145. package/src/renderer/components/terminal/TerminalPanel.tsx +156 -0
  146. package/src/renderer/components/ui/MentionableTextarea.tsx +0 -1
  147. package/src/renderer/hooks/useExtensionsTabState.ts +2 -2
  148. package/src/renderer/store/slices/extensionsSlice.ts +42 -107
  149. package/src/renderer/store/slices/scheduleSlice.ts +21 -0
  150. package/src/renderer/store/slices/teamSlice.ts +67 -25
  151. package/src/renderer/types/tabs.ts +1 -0
  152. package/src/shared/types/api.ts +58 -0
  153. package/src/shared/types/extensions/index.ts +1 -0
  154. package/src/shared/types/extensions/mcp.ts +2 -0
  155. package/src/shared/types/extensions/plugin.ts +2 -1
  156. package/src/shared/types/extensions/skill.ts +7 -0
  157. package/src/shared/types/team.ts +104 -1
  158. package/src/shared/utils/providerExtensionCapabilities.ts +1 -1
  159. package/dist-renderer/assets/ProjectEditorOverlay-A4DZTvSy.js +0 -57
  160. package/dist-renderer/assets/channel-Pre42N5O.js +0 -1
  161. package/dist-renderer/assets/classDiagram-2ON5EDUG-CdJsTJsj.js +0 -1
  162. package/dist-renderer/assets/classDiagram-v2-WZHVMYZB-CdJsTJsj.js +0 -1
  163. package/dist-renderer/assets/clone-BjQBiNfj.js +0 -1
  164. package/dist-renderer/assets/index-BIOJremZ.css +0 -1
  165. package/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts +0 -30
  166. package/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts +0 -27
  167. package/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts +0 -91
  168. package/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts +0 -326
  169. package/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts +0 -43
  170. package/src/features/recent-projects/main/index.ts +0 -3
  171. package/src/features/recent-projects/main/infrastructure/cache/InMemoryRecentProjectsCache.ts +0 -34
  172. package/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts +0 -116
  173. package/src/features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver.ts +0 -20
  174. package/src/features/recent-projects/main/infrastructure/identity/normalizeIdentityPath.ts +0 -10
  175. package/src/renderer/components/extensions/apikeys/ApiKeyCard.tsx +0 -143
  176. package/src/renderer/components/extensions/apikeys/ApiKeyFormDialog.tsx +0 -282
  177. package/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx +0 -280
@@ -0,0 +1,392 @@
1
+ /**
2
+ * Fetches and caches the Claude Code plugin marketplace catalog.
3
+ *
4
+ * - Fetches marketplace.json from raw.githubusercontent.com
5
+ * - ETag + If-None-Match for bandwidth efficiency
6
+ * - In-memory cache with TTL (15 min)
7
+ * - Stale cache fallback on network error
8
+ * - Deduplicates concurrent requests
9
+ */
10
+
11
+ import { promises as fsp } from 'node:fs';
12
+ import http from 'node:http';
13
+ import https from 'node:https';
14
+ import path from 'node:path';
15
+
16
+ import { getClaudeBasePath } from '@main/utils/pathDecoder';
17
+ import { buildPluginId } from '@shared/utils/extensionNormalizers';
18
+ import { createLogger } from '@shared/utils/logger';
19
+
20
+ import type { PluginCatalogItem } from '@shared/types/extensions';
21
+
22
+ const logger = createLogger('Extensions:PluginCatalog');
23
+
24
+ // ── Constants ──────────────────────────────────────────────────────────────
25
+
26
+ const MARKETPLACE_URL =
27
+ 'https://raw.githubusercontent.com/anthropics/claude-plugins-official/main/.claude-plugin/marketplace.json';
28
+
29
+ const CACHE_TTL_MS = 15 * 60 * 1_000; // 15 minutes
30
+ const HTTP_TIMEOUT_MS = 15_000; // 15 seconds
31
+ const MAX_REDIRECTS = 5;
32
+ const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB safety limit
33
+ const MAX_README_CACHE_SIZE = 50; // Max README entries to cache
34
+
35
+ // ── HTTP helpers (adapted from CliInstallerService) ────────────────────────
36
+
37
+ interface FetchOptions {
38
+ headers?: Record<string, string>;
39
+ }
40
+
41
+ interface FetchResponse {
42
+ statusCode: number;
43
+ headers: Record<string, string | string[] | undefined>;
44
+ body: string;
45
+ }
46
+
47
+ function httpsGetFollowRedirects(
48
+ url: string,
49
+ options: FetchOptions = {},
50
+ redirectsLeft = MAX_REDIRECTS,
51
+ timeoutMs = HTTP_TIMEOUT_MS
52
+ ): Promise<FetchResponse> {
53
+ return new Promise((resolve, reject) => {
54
+ const parsedUrl = new URL(url);
55
+ const transport = parsedUrl.protocol === 'http:' ? http : https;
56
+ let settled = false;
57
+
58
+ const settleResolve = (value: FetchResponse): void => {
59
+ if (settled) return;
60
+ settled = true;
61
+ resolve(value);
62
+ };
63
+ const settleReject = (err: Error): void => {
64
+ if (settled) return;
65
+ settled = true;
66
+ reject(err);
67
+ };
68
+
69
+ const reqOptions = {
70
+ headers: options.headers ?? {},
71
+ };
72
+
73
+ const req = transport.get(url, reqOptions, (res) => {
74
+ const status = res.statusCode ?? 0;
75
+
76
+ if (status >= 300 && status < 400 && res.headers.location) {
77
+ if (redirectsLeft <= 0) {
78
+ res.destroy();
79
+ settleReject(new Error('Too many redirects'));
80
+ return;
81
+ }
82
+ const redirectUrl = new URL(res.headers.location, url).toString();
83
+ res.destroy();
84
+ httpsGetFollowRedirects(redirectUrl, options, redirectsLeft - 1, timeoutMs).then(
85
+ settleResolve,
86
+ settleReject
87
+ );
88
+ return;
89
+ }
90
+
91
+ const chunks: Buffer[] = [];
92
+ let totalSize = 0;
93
+ res.on('data', (chunk: Buffer) => {
94
+ totalSize += chunk.length;
95
+ if (totalSize > MAX_BODY_SIZE) {
96
+ res.destroy(new Error(`Response body exceeds ${MAX_BODY_SIZE} bytes`));
97
+ return;
98
+ }
99
+ chunks.push(chunk);
100
+ });
101
+ res.on('end', () =>
102
+ settleResolve({
103
+ statusCode: status,
104
+ headers: res.headers as Record<string, string | string[] | undefined>,
105
+ body: Buffer.concat(chunks).toString('utf-8'),
106
+ })
107
+ );
108
+ res.on('error', settleReject);
109
+ });
110
+
111
+ req.setTimeout(timeoutMs, () => {
112
+ req.destroy(new Error(`Connection timed out after ${timeoutMs}ms fetching ${url}`));
113
+ });
114
+ req.on('error', (err) => settleReject(err instanceof Error ? err : new Error(String(err))));
115
+ });
116
+ }
117
+
118
+ // ── Marketplace JSON shape ─────────────────────────────────────────────────
119
+
120
+ interface MarketplaceJson {
121
+ name: string;
122
+ plugins: RawMarketplacePlugin[];
123
+ }
124
+
125
+ interface RawMarketplacePlugin {
126
+ name: string;
127
+ description?: string;
128
+ version?: string;
129
+ category?: string;
130
+ author?: { name: string; email?: string };
131
+ source: string | { source: string; url: string; sha?: string };
132
+ homepage?: string;
133
+ tags?: string[];
134
+ strict?: boolean;
135
+ lspServers?: Record<string, unknown>;
136
+ mcpServers?: Record<string, unknown>;
137
+ agents?: Record<string, unknown>;
138
+ commands?: Record<string, unknown>;
139
+ hooks?: Record<string, unknown>;
140
+ }
141
+
142
+ // ── Cache ──────────────────────────────────────────────────────────────────
143
+
144
+ interface CatalogCache {
145
+ items: PluginCatalogItem[];
146
+ etag: string | null;
147
+ fetchedAt: number;
148
+ }
149
+
150
+ // ── Service ────────────────────────────────────────────────────────────────
151
+
152
+ export class PluginCatalogService {
153
+ private cache: CatalogCache | null = null;
154
+ private fetchInFlight: Promise<PluginCatalogItem[]> | null = null;
155
+ private readmeCache = new Map<string, { content: string | null; fetchedAt: number }>();
156
+
157
+ /**
158
+ * Get all plugins shown in the store: the official Anthropic marketplace plus
159
+ * any plugins from marketplaces the user has registered locally (e.g. custom
160
+ * git marketplaces like `oh-my-claudecode@omc`). Each source is resilient —
161
+ * a failure in one does not hide the other.
162
+ */
163
+ async getPlugins(forceRefresh = false): Promise<PluginCatalogItem[]> {
164
+ const [official, local] = await Promise.all([
165
+ this.getOfficialPlugins(forceRefresh).catch((err) => {
166
+ logger.warn('Official marketplace unavailable:', err);
167
+ return [] as PluginCatalogItem[];
168
+ }),
169
+ this.getLocalPlugins().catch((err) => {
170
+ logger.warn('Local marketplaces unavailable:', err);
171
+ return [] as PluginCatalogItem[];
172
+ }),
173
+ ]);
174
+
175
+ // Official wins on pluginId collisions; append local plugins not already present.
176
+ const seen = new Set(official.map((p) => p.pluginId));
177
+ return [...official, ...local.filter((p) => !seen.has(p.pluginId))];
178
+ }
179
+
180
+ /**
181
+ * Get plugins from the official Anthropic marketplace.
182
+ * Uses in-memory cache with ETag validation.
183
+ */
184
+ private async getOfficialPlugins(forceRefresh = false): Promise<PluginCatalogItem[]> {
185
+ // Return cached if fresh and not forcing
186
+ if (!forceRefresh && this.cache && Date.now() - this.cache.fetchedAt < CACHE_TTL_MS) {
187
+ return this.cache.items;
188
+ }
189
+
190
+ // Deduplicate concurrent requests
191
+ if (this.fetchInFlight) {
192
+ return this.fetchInFlight;
193
+ }
194
+
195
+ this.fetchInFlight = this.fetchCatalog().finally(() => {
196
+ this.fetchInFlight = null;
197
+ });
198
+
199
+ return this.fetchInFlight;
200
+ }
201
+
202
+ /**
203
+ * Read plugins from marketplaces the user has registered locally.
204
+ *
205
+ * Source of truth: `~/.claude/plugins/known_marketplaces.json`, which maps a
206
+ * marketplace name to its on-disk `installLocation`. Each location holds a
207
+ * `.claude-plugin/marketplace.json` describing its plugins.
208
+ */
209
+ private async getLocalPlugins(): Promise<PluginCatalogItem[]> {
210
+ const pluginsDir = path.join(getClaudeBasePath(), 'plugins');
211
+ const knownPath = path.join(pluginsDir, 'known_marketplaces.json');
212
+
213
+ let known: Record<string, { installLocation?: string }>;
214
+ try {
215
+ known = JSON.parse(await fsp.readFile(knownPath, 'utf-8')) as Record<
216
+ string,
217
+ { installLocation?: string }
218
+ >;
219
+ } catch {
220
+ return []; // No known marketplaces (or unreadable) — nothing local to add.
221
+ }
222
+
223
+ const items: PluginCatalogItem[] = [];
224
+ for (const [, entry] of Object.entries(known)) {
225
+ const installLocation = entry?.installLocation;
226
+ if (!installLocation) continue;
227
+ const marketplaceJsonPath = path.join(installLocation, '.claude-plugin', 'marketplace.json');
228
+ try {
229
+ const json = JSON.parse(
230
+ await fsp.readFile(marketplaceJsonPath, 'utf-8')
231
+ ) as MarketplaceJson;
232
+ if (Array.isArray(json.plugins)) {
233
+ items.push(...this.parseMarketplace(json, 'local'));
234
+ }
235
+ } catch (err) {
236
+ logger.warn(`Failed to read local marketplace at ${marketplaceJsonPath}:`, err);
237
+ }
238
+ }
239
+ return items;
240
+ }
241
+
242
+ /**
243
+ * Get README content for a plugin by its pluginId.
244
+ * For external plugins (source is URL), fetches README from the GitHub repo.
245
+ * Returns null for local/bundled plugins or on error.
246
+ */
247
+ async getPluginReadme(pluginId: string): Promise<string | null> {
248
+ const cached = this.readmeCache.get(pluginId);
249
+ if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
250
+ return cached.content;
251
+ }
252
+
253
+ // Need catalog to find the plugin's repo URL
254
+ const plugins = await this.getPlugins();
255
+ const plugin = plugins.find((p) => p.pluginId === pluginId);
256
+ if (!plugin?.homepage) {
257
+ this.setReadmeCache(pluginId, null);
258
+ return null;
259
+ }
260
+
261
+ const readmeUrl = this.buildReadmeUrl(plugin.homepage);
262
+ if (!readmeUrl) {
263
+ this.setReadmeCache(pluginId, null);
264
+ return null;
265
+ }
266
+
267
+ try {
268
+ const response = await httpsGetFollowRedirects(readmeUrl);
269
+ if (response.statusCode === 200) {
270
+ this.setReadmeCache(pluginId, response.body);
271
+ return response.body;
272
+ }
273
+ this.setReadmeCache(pluginId, null);
274
+ return null;
275
+ } catch (err) {
276
+ logger.warn(`Failed to fetch README for ${pluginId}:`, err);
277
+ this.setReadmeCache(pluginId, null);
278
+ return null;
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Look up a single plugin by pluginId from the cached catalog.
284
+ * Used for main-side re-resolution during install.
285
+ */
286
+ async resolvePlugin(pluginId: string): Promise<PluginCatalogItem | null> {
287
+ const plugins = await this.getPlugins();
288
+ return plugins.find((p) => p.pluginId === pluginId) ?? null;
289
+ }
290
+
291
+ // ── Private ────────────────────────────────────────────────────────────
292
+
293
+ /** Set readme cache with LRU eviction */
294
+ private setReadmeCache(pluginId: string, content: string | null): void {
295
+ // Evict oldest entries if at capacity
296
+ if (this.readmeCache.size >= MAX_README_CACHE_SIZE && !this.readmeCache.has(pluginId)) {
297
+ let oldestKey: string | null = null;
298
+ let oldestTime = Infinity;
299
+ for (const [key, entry] of this.readmeCache) {
300
+ if (entry.fetchedAt < oldestTime) {
301
+ oldestTime = entry.fetchedAt;
302
+ oldestKey = key;
303
+ }
304
+ }
305
+ if (oldestKey) this.readmeCache.delete(oldestKey);
306
+ }
307
+ this.readmeCache.set(pluginId, { content, fetchedAt: Date.now() });
308
+ }
309
+
310
+ private async fetchCatalog(): Promise<PluginCatalogItem[]> {
311
+ const headers: Record<string, string> = {};
312
+ if (this.cache?.etag) {
313
+ headers['If-None-Match'] = this.cache.etag;
314
+ }
315
+
316
+ try {
317
+ const response = await httpsGetFollowRedirects(MARKETPLACE_URL, { headers });
318
+
319
+ // 304 Not Modified — cache is still valid
320
+ if (response.statusCode === 304 && this.cache) {
321
+ this.cache.fetchedAt = Date.now();
322
+ logger.info('Marketplace catalog not modified (304)');
323
+ return this.cache.items;
324
+ }
325
+
326
+ if (response.statusCode !== 200) {
327
+ throw new Error(`HTTP ${response.statusCode} fetching marketplace`);
328
+ }
329
+
330
+ const json = JSON.parse(response.body) as MarketplaceJson;
331
+ const items = this.parseMarketplace(json);
332
+ const etag = (response.headers.etag as string) ?? null;
333
+
334
+ this.cache = { items, etag, fetchedAt: Date.now() };
335
+ logger.info(`Fetched ${items.length} plugins from marketplace "${json.name}"`);
336
+ return items;
337
+ } catch (err) {
338
+ // Stale cache fallback
339
+ if (this.cache) {
340
+ logger.warn('Marketplace fetch failed, using stale cache:', err);
341
+ return this.cache.items;
342
+ }
343
+ logger.error('Marketplace fetch failed with no cache:', err);
344
+ throw err;
345
+ }
346
+ }
347
+
348
+ private parseMarketplace(
349
+ json: MarketplaceJson,
350
+ source: 'official' | 'local' = 'official'
351
+ ): PluginCatalogItem[] {
352
+ const marketplaceName = json.name;
353
+
354
+ return json.plugins.map((raw): PluginCatalogItem => {
355
+ const qualifiedName = buildPluginId(raw.name, marketplaceName);
356
+ const isExternal = typeof raw.source === 'object';
357
+ const homepage =
358
+ raw.homepage ?? (isExternal ? (raw.source as { url: string }).url : undefined);
359
+
360
+ return {
361
+ pluginId: qualifiedName,
362
+ marketplaceId: qualifiedName,
363
+ qualifiedName,
364
+ name: raw.name,
365
+ source,
366
+ description: raw.description ?? '',
367
+ category: raw.category ?? 'other',
368
+ author: raw.author,
369
+ version: raw.version,
370
+ homepage: homepage?.replace(/\.git$/, ''),
371
+ tags: raw.tags,
372
+ hasLspServers: raw.lspServers != null && Object.keys(raw.lspServers).length > 0,
373
+ hasMcpServers: raw.mcpServers != null && Object.keys(raw.mcpServers).length > 0,
374
+ hasAgents: raw.agents != null && Object.keys(raw.agents).length > 0,
375
+ hasCommands: raw.commands != null && Object.keys(raw.commands).length > 0,
376
+ hasHooks: raw.hooks != null && Object.keys(raw.hooks).length > 0,
377
+ isExternal,
378
+ };
379
+ });
380
+ }
381
+
382
+ /**
383
+ * Build raw GitHub README URL from a GitHub repo URL.
384
+ * e.g. https://github.com/org/repo → https://raw.githubusercontent.com/org/repo/main/README.md
385
+ */
386
+ private buildReadmeUrl(repoUrl: string): string | null {
387
+ const match = /github\.com\/([^/]+)\/([^/]+)/.exec(repoUrl);
388
+ if (!match) return null;
389
+ const [, owner, repo] = match;
390
+ return `https://raw.githubusercontent.com/${owner}/${repo}/main/README.md`;
391
+ }
392
+ }