@elliotding/ai-agent-mcp 0.1.25 → 0.1.26

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 (237) hide show
  1. package/package.json +4 -1
  2. package/.prompt-cache/cmd-cmd-client-sdk-ai-hub-generate-testcase.md +0 -101
  3. package/.prompt-cache/cmd-cmd-client-sdk-ai-hub-submit_zct_job.md +0 -158
  4. package/.prompt-cache/skill-skill-client-sdk-ai-hub-analyze-conf-status.md +0 -311
  5. package/.prompt-cache/skill-skill-client-sdk-ai-hub-analyze-sdk-log.md +0 -64
  6. package/.prompt-cache/skill-skill-client-sdk-ai-hub-analyze-zmb-log-errors.md +0 -84
  7. package/ai-resource-telemetry.json +0 -40
  8. package/dist/api/cached-client.d.ts +0 -48
  9. package/dist/api/cached-client.d.ts.map +0 -1
  10. package/dist/api/cached-client.js +0 -126
  11. package/dist/api/cached-client.js.map +0 -1
  12. package/dist/api/client.d.ts +0 -281
  13. package/dist/api/client.d.ts.map +0 -1
  14. package/dist/api/client.js +0 -371
  15. package/dist/api/client.js.map +0 -1
  16. package/dist/auth/index.d.ts +0 -8
  17. package/dist/auth/index.d.ts.map +0 -1
  18. package/dist/auth/index.js +0 -26
  19. package/dist/auth/index.js.map +0 -1
  20. package/dist/auth/middleware.d.ts +0 -36
  21. package/dist/auth/middleware.d.ts.map +0 -1
  22. package/dist/auth/middleware.js +0 -194
  23. package/dist/auth/middleware.js.map +0 -1
  24. package/dist/auth/permissions.d.ts +0 -60
  25. package/dist/auth/permissions.d.ts.map +0 -1
  26. package/dist/auth/permissions.js +0 -262
  27. package/dist/auth/permissions.js.map +0 -1
  28. package/dist/auth/token-validator.d.ts +0 -52
  29. package/dist/auth/token-validator.d.ts.map +0 -1
  30. package/dist/auth/token-validator.js +0 -215
  31. package/dist/auth/token-validator.js.map +0 -1
  32. package/dist/cache/cache-manager.d.ts +0 -49
  33. package/dist/cache/cache-manager.d.ts.map +0 -1
  34. package/dist/cache/cache-manager.js +0 -191
  35. package/dist/cache/cache-manager.js.map +0 -1
  36. package/dist/cache/index.d.ts +0 -6
  37. package/dist/cache/index.d.ts.map +0 -1
  38. package/dist/cache/index.js +0 -12
  39. package/dist/cache/index.js.map +0 -1
  40. package/dist/cache/redis-client.d.ts +0 -45
  41. package/dist/cache/redis-client.d.ts.map +0 -1
  42. package/dist/cache/redis-client.js +0 -210
  43. package/dist/cache/redis-client.js.map +0 -1
  44. package/dist/config/constants.d.ts +0 -28
  45. package/dist/config/constants.d.ts.map +0 -1
  46. package/dist/config/constants.js +0 -31
  47. package/dist/config/constants.js.map +0 -1
  48. package/dist/config/index.d.ts +0 -71
  49. package/dist/config/index.d.ts.map +0 -1
  50. package/dist/config/index.js +0 -190
  51. package/dist/config/index.js.map +0 -1
  52. package/dist/filesystem/manager.d.ts +0 -45
  53. package/dist/filesystem/manager.d.ts.map +0 -1
  54. package/dist/filesystem/manager.js +0 -246
  55. package/dist/filesystem/manager.js.map +0 -1
  56. package/dist/git/multi-source-manager.d.ts +0 -78
  57. package/dist/git/multi-source-manager.d.ts.map +0 -1
  58. package/dist/git/multi-source-manager.js +0 -577
  59. package/dist/git/multi-source-manager.js.map +0 -1
  60. package/dist/git/operations.d.ts +0 -27
  61. package/dist/git/operations.d.ts.map +0 -1
  62. package/dist/git/operations.js +0 -83
  63. package/dist/git/operations.js.map +0 -1
  64. package/dist/index.d.ts +0 -6
  65. package/dist/index.d.ts.map +0 -1
  66. package/dist/index.js +0 -122
  67. package/dist/index.js.map +0 -1
  68. package/dist/monitoring/health.d.ts +0 -35
  69. package/dist/monitoring/health.d.ts.map +0 -1
  70. package/dist/monitoring/health.js +0 -105
  71. package/dist/monitoring/health.js.map +0 -1
  72. package/dist/prompts/cache.d.ts +0 -69
  73. package/dist/prompts/cache.d.ts.map +0 -1
  74. package/dist/prompts/cache.js +0 -163
  75. package/dist/prompts/cache.js.map +0 -1
  76. package/dist/prompts/generator.d.ts +0 -49
  77. package/dist/prompts/generator.d.ts.map +0 -1
  78. package/dist/prompts/generator.js +0 -160
  79. package/dist/prompts/generator.js.map +0 -1
  80. package/dist/prompts/index.d.ts +0 -13
  81. package/dist/prompts/index.d.ts.map +0 -1
  82. package/dist/prompts/index.js +0 -24
  83. package/dist/prompts/index.js.map +0 -1
  84. package/dist/prompts/manager.d.ts +0 -207
  85. package/dist/prompts/manager.d.ts.map +0 -1
  86. package/dist/prompts/manager.js +0 -566
  87. package/dist/prompts/manager.js.map +0 -1
  88. package/dist/resources/index.d.ts +0 -6
  89. package/dist/resources/index.d.ts.map +0 -1
  90. package/dist/resources/index.js +0 -10
  91. package/dist/resources/index.js.map +0 -1
  92. package/dist/resources/loader.d.ts +0 -88
  93. package/dist/resources/loader.d.ts.map +0 -1
  94. package/dist/resources/loader.js +0 -492
  95. package/dist/resources/loader.js.map +0 -1
  96. package/dist/server/http.d.ts +0 -57
  97. package/dist/server/http.d.ts.map +0 -1
  98. package/dist/server/http.js +0 -435
  99. package/dist/server/http.js.map +0 -1
  100. package/dist/server.d.ts +0 -13
  101. package/dist/server.d.ts.map +0 -1
  102. package/dist/server.js +0 -201
  103. package/dist/server.js.map +0 -1
  104. package/dist/session/manager.d.ts +0 -91
  105. package/dist/session/manager.d.ts.map +0 -1
  106. package/dist/session/manager.js +0 -251
  107. package/dist/session/manager.js.map +0 -1
  108. package/dist/telemetry/index.d.ts +0 -3
  109. package/dist/telemetry/index.d.ts.map +0 -1
  110. package/dist/telemetry/index.js +0 -7
  111. package/dist/telemetry/index.js.map +0 -1
  112. package/dist/telemetry/manager.d.ts +0 -151
  113. package/dist/telemetry/manager.d.ts.map +0 -1
  114. package/dist/telemetry/manager.js +0 -367
  115. package/dist/telemetry/manager.js.map +0 -1
  116. package/dist/tools/index.d.ts +0 -13
  117. package/dist/tools/index.d.ts.map +0 -1
  118. package/dist/tools/index.js +0 -29
  119. package/dist/tools/index.js.map +0 -1
  120. package/dist/tools/manage-subscription.d.ts +0 -47
  121. package/dist/tools/manage-subscription.d.ts.map +0 -1
  122. package/dist/tools/manage-subscription.js +0 -317
  123. package/dist/tools/manage-subscription.js.map +0 -1
  124. package/dist/tools/registry.d.ts +0 -40
  125. package/dist/tools/registry.d.ts.map +0 -1
  126. package/dist/tools/registry.js +0 -85
  127. package/dist/tools/registry.js.map +0 -1
  128. package/dist/tools/resolve-prompt-content.d.ts +0 -35
  129. package/dist/tools/resolve-prompt-content.d.ts.map +0 -1
  130. package/dist/tools/resolve-prompt-content.js +0 -99
  131. package/dist/tools/resolve-prompt-content.js.map +0 -1
  132. package/dist/tools/search-resources.d.ts +0 -35
  133. package/dist/tools/search-resources.d.ts.map +0 -1
  134. package/dist/tools/search-resources.js +0 -159
  135. package/dist/tools/search-resources.js.map +0 -1
  136. package/dist/tools/sync-resources.d.ts +0 -54
  137. package/dist/tools/sync-resources.d.ts.map +0 -1
  138. package/dist/tools/sync-resources.js +0 -735
  139. package/dist/tools/sync-resources.js.map +0 -1
  140. package/dist/tools/track-usage.d.ts +0 -63
  141. package/dist/tools/track-usage.d.ts.map +0 -1
  142. package/dist/tools/track-usage.js +0 -90
  143. package/dist/tools/track-usage.js.map +0 -1
  144. package/dist/tools/uninstall-resource.d.ts +0 -30
  145. package/dist/tools/uninstall-resource.d.ts.map +0 -1
  146. package/dist/tools/uninstall-resource.js +0 -174
  147. package/dist/tools/uninstall-resource.js.map +0 -1
  148. package/dist/tools/upload-resource.d.ts +0 -81
  149. package/dist/tools/upload-resource.d.ts.map +0 -1
  150. package/dist/tools/upload-resource.js +0 -393
  151. package/dist/tools/upload-resource.js.map +0 -1
  152. package/dist/transport/sse.d.ts +0 -29
  153. package/dist/transport/sse.d.ts.map +0 -1
  154. package/dist/transport/sse.js +0 -271
  155. package/dist/transport/sse.js.map +0 -1
  156. package/dist/types/errors.d.ts +0 -60
  157. package/dist/types/errors.d.ts.map +0 -1
  158. package/dist/types/errors.js +0 -112
  159. package/dist/types/errors.js.map +0 -1
  160. package/dist/types/index.d.ts +0 -7
  161. package/dist/types/index.d.ts.map +0 -1
  162. package/dist/types/index.js +0 -23
  163. package/dist/types/index.js.map +0 -1
  164. package/dist/types/mcp.d.ts +0 -50
  165. package/dist/types/mcp.d.ts.map +0 -1
  166. package/dist/types/mcp.js +0 -6
  167. package/dist/types/mcp.js.map +0 -1
  168. package/dist/types/resources.d.ts +0 -109
  169. package/dist/types/resources.d.ts.map +0 -1
  170. package/dist/types/resources.js +0 -7
  171. package/dist/types/resources.js.map +0 -1
  172. package/dist/types/tools.d.ts +0 -253
  173. package/dist/types/tools.d.ts.map +0 -1
  174. package/dist/types/tools.js +0 -6
  175. package/dist/types/tools.js.map +0 -1
  176. package/dist/utils/cursor-paths.d.ts +0 -84
  177. package/dist/utils/cursor-paths.d.ts.map +0 -1
  178. package/dist/utils/cursor-paths.js +0 -166
  179. package/dist/utils/cursor-paths.js.map +0 -1
  180. package/dist/utils/log-cleaner.d.ts +0 -18
  181. package/dist/utils/log-cleaner.d.ts.map +0 -1
  182. package/dist/utils/log-cleaner.js +0 -112
  183. package/dist/utils/log-cleaner.js.map +0 -1
  184. package/dist/utils/logger.d.ts +0 -59
  185. package/dist/utils/logger.d.ts.map +0 -1
  186. package/dist/utils/logger.js +0 -292
  187. package/dist/utils/logger.js.map +0 -1
  188. package/dist/utils/validation.d.ts +0 -58
  189. package/dist/utils/validation.d.ts.map +0 -1
  190. package/dist/utils/validation.js +0 -214
  191. package/dist/utils/validation.js.map +0 -1
  192. package/src/api/cached-client.ts +0 -144
  193. package/src/api/client.ts +0 -697
  194. package/src/auth/index.ts +0 -11
  195. package/src/auth/middleware.ts +0 -244
  196. package/src/auth/permissions.ts +0 -323
  197. package/src/auth/token-validator.ts +0 -292
  198. package/src/cache/cache-manager.ts +0 -243
  199. package/src/cache/index.ts +0 -6
  200. package/src/cache/redis-client.ts +0 -249
  201. package/src/config/constants.ts +0 -33
  202. package/src/config/index.ts +0 -269
  203. package/src/filesystem/manager.ts +0 -235
  204. package/src/git/multi-source-manager.ts +0 -654
  205. package/src/git/operations.ts +0 -93
  206. package/src/index.ts +0 -157
  207. package/src/monitoring/health.ts +0 -132
  208. package/src/prompts/cache.ts +0 -140
  209. package/src/prompts/generator.ts +0 -143
  210. package/src/prompts/index.ts +0 -20
  211. package/src/prompts/manager.ts +0 -718
  212. package/src/resources/index.ts +0 -13
  213. package/src/resources/loader.ts +0 -563
  214. package/src/server/http.ts +0 -549
  215. package/src/server.ts +0 -206
  216. package/src/session/manager.ts +0 -296
  217. package/src/telemetry/index.ts +0 -10
  218. package/src/telemetry/manager.ts +0 -419
  219. package/src/tools/index.ts +0 -13
  220. package/src/tools/manage-subscription.ts +0 -388
  221. package/src/tools/registry.ts +0 -97
  222. package/src/tools/resolve-prompt-content.ts +0 -113
  223. package/src/tools/search-resources.ts +0 -185
  224. package/src/tools/sync-resources.ts +0 -829
  225. package/src/tools/track-usage.ts +0 -113
  226. package/src/tools/uninstall-resource.ts +0 -199
  227. package/src/tools/upload-resource.ts +0 -431
  228. package/src/transport/sse.ts +0 -308
  229. package/src/types/errors.ts +0 -146
  230. package/src/types/index.ts +0 -7
  231. package/src/types/mcp.ts +0 -61
  232. package/src/types/resources.ts +0 -141
  233. package/src/types/tools.ts +0 -305
  234. package/src/utils/cursor-paths.ts +0 -135
  235. package/src/utils/log-cleaner.ts +0 -92
  236. package/src/utils/logger.ts +0 -333
  237. package/src/utils/validation.ts +0 -262
@@ -1,829 +0,0 @@
1
- /**
2
- * sync_resources Tool
3
- *
4
- * Synchronises the user's subscribed AI resources.
5
- *
6
- * Resource delivery strategy (v1.5):
7
- * - Command / Skill : registered as MCP Prompts (NOT written to local filesystem).
8
- * Content is generated into .prompt-cache/ and registered via PromptManager.
9
- * - Rule : downloaded to ~/.cursor/rules/ (Cursor engine requires local files).
10
- * - MCP : downloaded to ~/.cursor/mcp-servers/ and registered in mcp.json.
11
- *
12
- * Flow:
13
- * 1. Fetch subscription list from CSP server (REST API).
14
- * 2. (non-check) Trigger Git sync on server side via multiSourceGitManager.
15
- * 3. For each subscription: handle per type as above.
16
- * 4. Update telemetry: subscribed_rules + configured_mcps lists.
17
- */
18
-
19
- import * as fs from 'fs/promises';
20
- import * as path from 'path';
21
- import { logger, logToolCall, logToolStep, logToolResult } from '../utils/logger';
22
- import { apiClient } from '../api/client';
23
- import { multiSourceGitManager } from '../git/multi-source-manager';
24
- import {
25
- getCursorResourcePath,
26
- getCursorTypeDirForClient,
27
- getCursorRootDirForClient,
28
- } from '../utils/cursor-paths';
29
- import { MCPServerError } from '../types/errors';
30
- import type {
31
- SyncResourcesParams,
32
- SyncResourcesResult,
33
- LocalAction,
34
- ToolResult,
35
- } from '../types/tools';
36
- import { telemetry } from '../telemetry/index.js';
37
- import { promptManager } from '../prompts/index.js';
38
-
39
- /**
40
- * Server-side in-memory download cache.
41
- *
42
- * Purpose: avoid redundant API download calls for resources whose content has
43
- * not changed between syncs IN THE SAME SERVER SESSION.
44
- *
45
- * IMPORTANT — this cache ONLY skips the network download; it NEVER skips
46
- * generating LocalAction instructions. Whether the user's local files are
47
- * already up-to-date is determined client-side by direct content comparison
48
- * (string equality check) in write_file actions and `skip_if_exists` checks
49
- * on merge_mcp_json actions. This ensures a manual sync always re-delivers
50
- * actions so the user can recover deleted local files, even when the resource
51
- * content is unchanged.
52
- *
53
- * Key format: `${userToken}::${resourceId}`
54
- * Value: the last downloadResource() response (hash + files).
55
- *
56
- * The cache is process-scoped and cleared on server restart.
57
- */
58
- interface CachedDownload {
59
- hash: string;
60
- files: Array<{ path: string; content: string }>;
61
- }
62
- const downloadCache = new Map<string, CachedDownload>();
63
-
64
- function syncCacheKey(userToken: string, resourceId: string): string {
65
- return `${userToken}::${resourceId}`;
66
- }
67
-
68
- /**
69
- * Extract the `description` field from YAML frontmatter in a Markdown file.
70
- * Frontmatter is delimited by leading `---` and closing `---` lines.
71
- * Returns undefined if no frontmatter or no description key is found.
72
- */
73
- function extractFrontmatterDescription(content: string): string | undefined {
74
- if (!content.startsWith('---')) return undefined;
75
- const end = content.indexOf('\n---', 3);
76
- if (end === -1) return undefined;
77
- const frontmatter = content.slice(3, end);
78
- for (const line of frontmatter.split('\n')) {
79
- const match = /^description:\s*(.+)$/.exec(line.trim());
80
- if (match) return match[1]!.trim().replace(/^['"]|['"]$/g, '');
81
- }
82
- return undefined;
83
- }
84
-
85
-
86
- export async function syncResources(params: unknown): Promise<ToolResult<SyncResourcesResult>> {
87
- const startTime = Date.now();
88
-
89
- const typedParams = params as SyncResourcesParams;
90
-
91
- logger.info({
92
- tool: 'sync_resources',
93
- params: typedParams,
94
- timestamp: new Date().toISOString()
95
- }, 'sync_resources tool invoked');
96
-
97
- logToolStep('sync_resources', 'Tool invocation started', { params: typedParams });
98
-
99
- try {
100
- const mode = typedParams.mode || 'incremental';
101
- const scope = typedParams.scope || 'global';
102
- const types = typedParams.types;
103
- const userToken = typedParams.user_token;
104
- const configuredMcpServers = new Set(typedParams.configured_mcp_servers || []);
105
-
106
- logToolStep('sync_resources', 'Parameters validated', {
107
- mode,
108
- scope,
109
- types,
110
- configuredMcpCount: configuredMcpServers.size,
111
- });
112
-
113
- // ── Step 1: Fetch subscription list ────────────────────────────────────
114
- logToolStep('sync_resources', 'Step 1: Fetching subscriptions from API', { scope, types });
115
- const t1 = Date.now();
116
-
117
- const subscriptions = await apiClient.getSubscriptions({ types }, userToken);
118
-
119
- logToolStep('sync_resources', 'Subscriptions fetched', {
120
- total: subscriptions.total,
121
- duration: Date.now() - t1,
122
- ids: subscriptions.subscriptions.map(s => s.id),
123
- });
124
-
125
- // ── Step 2: Server-side Git sync (skip in check mode) ──────────────────
126
- logToolStep('sync_resources', 'Step 2: Server-side Git sync');
127
-
128
- if (mode === 'check') {
129
- const statuses = await multiSourceGitManager.checkAllSources();
130
- logToolStep('sync_resources', 'Repository status check completed', {
131
- sources: statuses.map(s => ({ name: s.source, exists: s.exists, hasRemote: s.hasRemote })),
132
- });
133
- } else {
134
- const t2 = Date.now();
135
- const gitResults = await multiSourceGitManager.syncAllSources();
136
- logToolStep('sync_resources', 'Server-side Git sync completed', {
137
- duration: Date.now() - t2,
138
- summary: {
139
- cloned: gitResults.filter(r => r.action === 'cloned').length,
140
- pulled: gitResults.filter(r => r.action === 'pulled').length,
141
- upToDate: gitResults.filter(r => r.action === 'up-to-date').length,
142
- skipped: gitResults.filter(r => r.action === 'skipped').length,
143
- },
144
- });
145
- }
146
-
147
- // ── Step 3: Download each subscribed resource ──────────────────────────
148
- // Command / Skill → registered as MCP Prompts on the server (no local I/O)
149
- // Rule / MCP → file content is returned as LocalAction instructions
150
- // so that the AI Agent executes the writes on the user's
151
- // LOCAL machine (not on this potentially remote server).
152
- logToolStep('sync_resources', 'Step 3: Processing subscribed resources', {
153
- count: subscriptions.total,
154
- });
155
-
156
- const tally = { total: subscriptions.total, synced: 0, cached: 0, failed: 0 };
157
-
158
- const details: Array<{
159
- id: string;
160
- name: string;
161
- action: 'synced' | 'cached' | 'failed';
162
- version: string;
163
- }> = [];
164
-
165
- // Accumulated local file-system actions the AI must perform on the user's machine.
166
- const localActions: LocalAction[] = [];
167
-
168
- // Track which prompt names are expected from the current subscription list.
169
- // After the loop, any prompt registered in PromptManager but NOT in this set
170
- // is stale (from a previous connection / subscription change) and will be pruned.
171
- const expectedPromptNames = new Set<string>();
172
-
173
- for (let i = 0; i < subscriptions.subscriptions.length; i++) {
174
- const sub = subscriptions.subscriptions[i];
175
- if (!sub) continue;
176
-
177
- // Safe access — `resource` metadata is only present when detail=true was requested.
178
- const resourceVersion = sub.resource?.version ?? 'unknown';
179
-
180
- logToolStep('sync_resources', `Processing ${i + 1}/${tally.total}`, {
181
- resourceId: sub.id,
182
- resourceName: sub.name,
183
- resourceType: sub.type,
184
- });
185
-
186
- try {
187
- // Resolve the destination path inside the Cursor directory.
188
- // getCursorResourcePath throws for unrecognised types, caught below.
189
- const destPath = getCursorResourcePath(sub.type, sub.name);
190
-
191
- // In check mode: report whether the resource is already available.
192
- // Command/Skill: check the in-memory Prompt registry (no local files).
193
- // Rule/MCP: check whether the local file / mcp.json entry exists.
194
- if (mode === 'check') {
195
- if (sub.type === 'command' || sub.type === 'skill') {
196
- const meta = {
197
- resource_id: sub.id,
198
- resource_type: sub.type as 'command' | 'skill',
199
- resource_name: sub.name,
200
- team: (sub as any).team ?? 'general',
201
- };
202
- const isRegistered = promptManager.has(promptManager.buildPromptName(meta), userToken ?? '');
203
- if (isRegistered) {
204
- tally.cached++;
205
- details.push({ id: sub.id, name: sub.name, action: 'cached', version: resourceVersion });
206
- } else {
207
- tally.failed++;
208
- details.push({ id: sub.id, name: sub.name, action: 'failed', version: resourceVersion });
209
- }
210
- } else {
211
- try {
212
- await fs.access(destPath);
213
- tally.cached++;
214
- details.push({ id: sub.id, name: sub.name, action: 'cached', version: resourceVersion });
215
- logToolStep('sync_resources', 'Resource already present (check mode)', {
216
- resourceId: sub.id, destPath,
217
- });
218
- } catch {
219
- tally.failed++;
220
- details.push({ id: sub.id, name: sub.name, action: 'failed', version: resourceVersion });
221
- logToolStep('sync_resources', 'Resource missing (check mode)', {
222
- resourceId: sub.id, destPath,
223
- });
224
- }
225
- }
226
- continue;
227
- }
228
-
229
- // ── Command / Skill: MCP Prompt mode (no local file write) ──────────
230
- // Download content → generate intermediate cache → register as MCP Prompt.
231
- if (sub.type === 'command' || sub.type === 'skill') {
232
- logToolStep('sync_resources', `Registering ${sub.type} as MCP Prompt`, {
233
- resourceId: sub.id,
234
- resourceName: sub.name,
235
- });
236
- try {
237
- const tDl = Date.now();
238
- const downloadResult = await apiClient.downloadResource(sub.id, userToken);
239
- logToolStep('sync_resources', 'Download complete (Prompt mode)', {
240
- resourceId: sub.id,
241
- fileCount: downloadResult.files.length,
242
- duration: Date.now() - tDl,
243
- });
244
-
245
- // When the API returns no files (expected for Command/Skill in MCP Prompt
246
- // mode — content lives in the server-side git repo, not the API), fall back
247
- // to reading the files directly from the local git checkout.
248
- let sourceFiles = downloadResult.files;
249
- if (sourceFiles.length === 0) {
250
- sourceFiles = await multiSourceGitManager.readResourceFiles(
251
- sub.name,
252
- sub.type as 'command' | 'skill',
253
- );
254
- if (sourceFiles.length > 0) {
255
- logToolStep('sync_resources', 'Loaded resource files from local git checkout', {
256
- resourceId: sub.id,
257
- fileCount: sourceFiles.length,
258
- });
259
- } else {
260
- logger.warn(
261
- { resourceId: sub.id, resourceName: sub.name },
262
- 'No files found via API or local git — prompt will have empty content',
263
- );
264
- }
265
- }
266
-
267
- // Primary Markdown content selection:
268
- // - skill: prefer SKILL.md (canonical entrypoint for all skill content)
269
- // - command: prefer the file whose name matches the resource name
270
- // - fallback: first .md file, then first file of any type
271
- const isSkill = sub.type === 'skill';
272
- const primaryFile = isSkill
273
- ? (sourceFiles.find((f) => path.basename(f.path) === 'SKILL.md') ??
274
- sourceFiles.find((f) => f.path.endsWith('.md')) ??
275
- sourceFiles[0])
276
- : (sourceFiles.find((f) => path.basename(f.path).replace(/\.md$/, '') === sub.name) ??
277
- sourceFiles.find((f) => f.path.endsWith('.md')) ??
278
- sourceFiles[0]);
279
-
280
- const rawContent = primaryFile?.content ?? '';
281
-
282
- // Extract description from frontmatter (---\ndescription: ...\n---)
283
- // falling back to the subscription's description field or resource name.
284
- const frontmatterDesc = extractFrontmatterDescription(rawContent);
285
- const description =
286
- frontmatterDesc ??
287
- (sub as any).description ??
288
- sub.name;
289
-
290
- const meta = {
291
- resource_id: sub.id,
292
- resource_type: sub.type as 'command' | 'skill',
293
- resource_name: sub.name,
294
- team: (sub as any).team ?? 'general',
295
- description,
296
- rawContent,
297
- };
298
- // userToken is required so the prompt is scoped to this user's namespace.
299
- const effectiveToken = userToken ?? '';
300
- await promptManager.registerPrompt(meta, effectiveToken);
301
-
302
- // Track this prompt name so stale prompts can be pruned after the loop.
303
- expectedPromptNames.add(promptManager.buildPromptName(meta));
304
-
305
- // Clean up any legacy local files that may have been written by an
306
- // older version of sync_resources. Command/Skill resources are now
307
- // served exclusively as MCP Prompts; stale local files would cause
308
- // the AI to read outdated content (without the track_usage header).
309
- try {
310
- const legacyPath = getCursorResourcePath(sub.type, `${sub.name}.md`);
311
- await fs.unlink(legacyPath);
312
- logger.info(
313
- { resourceId: sub.id, legacyPath },
314
- 'Removed legacy local file for Command/Skill resource',
315
- );
316
- } catch {
317
- // File didn't exist — nothing to clean up.
318
- }
319
-
320
- tally.synced++;
321
- details.push({ id: sub.id, name: sub.name, action: 'synced', version: resourceVersion });
322
- logToolStep('sync_resources', `${sub.type} registered as MCP Prompt`, {
323
- resourceId: sub.id,
324
- promptCount: promptManager.sizeFor(userToken ?? ''),
325
- });
326
- } catch (promptErr) {
327
- logger.error(
328
- { resourceId: sub.id, error: (promptErr as Error).message },
329
- 'Failed to register Command/Skill as MCP Prompt',
330
- );
331
- tally.failed++;
332
- details.push({ id: sub.id, name: sub.name, action: 'failed', version: resourceVersion });
333
- }
334
- continue;
335
- }
336
-
337
- // ── Download (with server-session cache) ─────────────────────────────
338
- // The download cache avoids redundant API calls when the same resource
339
- // is synced multiple times within one server session without any content
340
- // change. It ONLY caches the network response; LocalAction generation
341
- // always proceeds so that users can recover deleted local files by
342
- // re-running sync — even when the resource content is unchanged.
343
- const cacheKey = syncCacheKey(userToken ?? '', sub.id);
344
- let downloadResult: { hash: string; files: Array<{ path: string; content: string }> };
345
-
346
- const cached = downloadCache.get(cacheKey);
347
- if (mode === 'incremental' && cached) {
348
- // Reuse the previously downloaded content without hitting the API.
349
- // full mode always bypasses this branch to guarantee a fresh download.
350
- downloadResult = cached;
351
- logToolStep('sync_resources', 'Using cached download (no API call)', {
352
- resourceId: sub.id,
353
- cachedHash: cached.hash,
354
- });
355
- } else {
356
- logToolStep('sync_resources', 'Downloading resource', {
357
- resourceId: sub.id,
358
- resourceType: sub.type,
359
- });
360
- const tDl = Date.now();
361
- const apiResult = await apiClient.downloadResource(sub.id, userToken);
362
- logToolStep('sync_resources', 'Download complete', {
363
- resourceId: sub.id,
364
- fileCount: apiResult.files.length,
365
- duration: Date.now() - tDl,
366
- });
367
- downloadResult = { hash: apiResult.hash, files: apiResult.files };
368
- // Refresh cache with the latest download.
369
- downloadCache.set(cacheKey, downloadResult);
370
- }
371
-
372
- // When the API returns no files (expected when the MCP server is deployed
373
- // remotely and content lives in the server-side git repo), fall back to
374
- // reading the files directly from the local git checkout.
375
- let resourceFiles = downloadResult.files;
376
- if (resourceFiles.length === 0) {
377
- logger.info(
378
- { resourceId: sub.id, resourceName: sub.name, type: sub.type },
379
- 'sync_resources: API returned no files — triggering git-checkout fallback',
380
- );
381
- const gitType = sub.type as 'command' | 'skill' | 'rule' | 'mcp';
382
- const gitFiles = await multiSourceGitManager.readResourceFiles(sub.name, gitType);
383
- if (gitFiles.length > 0) {
384
- resourceFiles = gitFiles;
385
- logger.info(
386
- {
387
- resourceId: sub.id,
388
- resourceName: sub.name,
389
- type: sub.type,
390
- fileCount: resourceFiles.length,
391
- files: resourceFiles.map((f) => f.path),
392
- },
393
- 'sync_resources: git-checkout fallback succeeded',
394
- );
395
- logToolStep('sync_resources', 'Loaded resource files from local git checkout', {
396
- resourceId: sub.id,
397
- fileCount: resourceFiles.length,
398
- });
399
- } else {
400
- logger.warn(
401
- { resourceId: sub.id, resourceName: sub.name, type: sub.type },
402
- 'sync_resources: git-checkout fallback found no files — marking resource failed',
403
- );
404
- tally.failed++;
405
- details.push({ id: sub.id, name: sub.name, action: 'failed', version: resourceVersion });
406
- continue;
407
- }
408
- }
409
-
410
- // ── MCP resource ──────────────────────────────────────────────────────
411
- // Read mcp-config.json to determine Format A (local executable, has
412
- // "command" field) vs Format B (remote URL map, no "command" field).
413
- //
414
- // IMPORTANT: all paths in LocalAction instructions must use the CLIENT-side
415
- // helper (tilde-based) so they resolve correctly on the user's machine,
416
- // not on this (possibly remote Linux) server.
417
- if (sub.type === 'mcp') {
418
- const mcpConfigFile = resourceFiles.find(
419
- (f) => path.basename(f.path) === 'mcp-config.json',
420
- );
421
- // ~/.cursor/mcp.json on the user's machine
422
- const mcpJsonPath = `${getCursorRootDirForClient()}/mcp.json`;
423
-
424
- // ── Optimization: skip if already configured (incremental mode only) ────
425
- // In incremental mode, if the AI Agent reports this MCP server is already
426
- // in ~/.cursor/mcp.json, skip downloading and generating write_file actions.
427
- // This reduces API calls, network traffic, and AI Agent execution overhead.
428
- // In full mode, always proceed to allow file recovery.
429
- if (mode === 'incremental' && mcpConfigFile) {
430
- let cfg: Record<string, unknown> = {};
431
- try { cfg = JSON.parse(mcpConfigFile.content) as Record<string, unknown>; }
432
- catch { /* ignore parse errors, proceed to download */ }
433
-
434
- // Format A: check if the single server is configured
435
- if (typeof cfg['command'] === 'string') {
436
- const serverName = (cfg['name'] as string | undefined) ?? sub.name;
437
- if (configuredMcpServers.has(serverName)) {
438
- logger.info(
439
- { resourceId: sub.id, resourceName: sub.name, serverName },
440
- 'sync_resources: MCP server already configured — skipping download',
441
- );
442
- tally.cached++;
443
- details.push({ id: sub.id, name: sub.name, action: 'cached', version: resourceVersion });
444
- continue;
445
- }
446
- }
447
- // Format B: check if all servers in the map are configured
448
- else if (Object.keys(cfg).length > 0) {
449
- const allConfigured = Object.keys(cfg).every((k) => configuredMcpServers.has(k));
450
- if (allConfigured) {
451
- logger.info(
452
- { resourceId: sub.id, resourceName: sub.name, serverKeys: Object.keys(cfg) },
453
- 'sync_resources: All MCP servers already configured — skipping download',
454
- );
455
- tally.cached++;
456
- details.push({ id: sub.id, name: sub.name, action: 'cached', version: resourceVersion });
457
- continue;
458
- }
459
- }
460
- }
461
-
462
- logger.info(
463
- {
464
- resourceId: sub.id,
465
- resourceName: sub.name,
466
- mcpJsonPath,
467
- hasMcpConfigFile: !!mcpConfigFile,
468
- availableFiles: resourceFiles.map((f) => f.path),
469
- },
470
- 'sync_resources: processing MCP resource',
471
- );
472
-
473
- if (mcpConfigFile) {
474
- let cfg: Record<string, unknown> = {};
475
- try { cfg = JSON.parse(mcpConfigFile.content) as Record<string, unknown>; }
476
- catch {
477
- logger.warn(
478
- { resourceId: sub.id, resourceName: sub.name },
479
- 'sync_resources: failed to parse mcp-config.json — treating as empty config',
480
- );
481
- }
482
-
483
- if (typeof cfg['command'] === 'string') {
484
- // ── Format A: local executable ──────────────────────────────────
485
- const installDir = `${getCursorTypeDirForClient('mcp')}/${sub.name}`;
486
- const writeActions: string[] = [];
487
- for (const file of resourceFiles) {
488
- const normalised = path.normalize(file.path);
489
- if (normalised.startsWith('..')) continue;
490
- const fileDest = `${installDir}/${normalised}`;
491
- localActions.push({
492
- action: 'write_file',
493
- path: fileDest,
494
- content: file.content,
495
- });
496
- writeActions.push(fileDest);
497
- }
498
- const env = (cfg['env'] ?? {}) as Record<string, string>;
499
- const missingEnv = Object.entries(env).filter(([, v]) => v === '').map(([k]) => k);
500
- const looksLikePath = (a: string) =>
501
- a.startsWith('./') || a.startsWith('../') || a.includes('/') || /\.\w+$/.test(a);
502
- const args = ((cfg['args'] ?? []) as string[]).map((a) =>
503
- path.isAbsolute(a) || !looksLikePath(a) ? a : `${installDir}/${a.replace(/^\.\//, '')}`,
504
- );
505
- const serverName = (cfg['name'] as string | undefined) ?? sub.name;
506
- localActions.push({
507
- action: 'merge_mcp_json',
508
- mcp_json_path: mcpJsonPath,
509
- server_name: serverName,
510
- entry: { ...cfg, args },
511
- // skip_if_exists: preserve user-edited env values; the entry
512
- // is already configured if the key exists in mcp.json.
513
- skip_if_exists: true,
514
- ...(missingEnv.length > 0 ? {
515
- missing_env: missingEnv,
516
- setup_hint: `Fill in env vars in ${mcpJsonPath} under mcpServers["${sub.name}"]: ${missingEnv.join(', ')}.`,
517
- } : {}),
518
- });
519
- logger.info(
520
- {
521
- resourceId: sub.id,
522
- resourceName: sub.name,
523
- format: 'A',
524
- installDir,
525
- mcpJsonPath,
526
- serverName,
527
- writeFiles: writeActions,
528
- missingEnv,
529
- },
530
- 'sync_resources: MCP Format A — write_file + merge_mcp_json actions queued',
531
- );
532
- logToolStep('sync_resources', 'Local-executable MCP: write_file + merge_mcp_json queued', { resourceId: sub.id });
533
- } else {
534
- // ── Format B: remote URL map ────────────────────────────────────
535
- const queuedServers: string[] = [];
536
- for (const [serverName, entry] of Object.entries(cfg)) {
537
- const e = entry as Record<string, unknown>;
538
- const env = (e['env'] ?? {}) as Record<string, string>;
539
- const missingEnv = Object.entries(env).filter(([, v]) => v === '').map(([k]) => k);
540
- localActions.push({
541
- action: 'merge_mcp_json',
542
- mcp_json_path: mcpJsonPath,
543
- server_name: serverName,
544
- entry: e,
545
- // skip_if_exists: user may have customised env values; do
546
- // not overwrite an existing entry on every incremental sync.
547
- skip_if_exists: true,
548
- ...(missingEnv.length > 0 ? {
549
- missing_env: missingEnv,
550
- setup_hint: `Fill in env vars in ${mcpJsonPath} under mcpServers["${serverName}"]: ${missingEnv.join(', ')}.`,
551
- } : {}),
552
- });
553
- queuedServers.push(serverName);
554
- }
555
- logger.info(
556
- {
557
- resourceId: sub.id,
558
- resourceName: sub.name,
559
- format: 'B',
560
- mcpJsonPath,
561
- serverKeys: queuedServers,
562
- },
563
- 'sync_resources: MCP Format B — merge_mcp_json actions queued',
564
- );
565
- logToolStep('sync_resources', 'Remote-URL MCP: merge_mcp_json queued', {
566
- resourceId: sub.id, serverKeys: Object.keys(cfg),
567
- });
568
- }
569
- } else {
570
- // No mcp-config.json: heuristic fallback
571
- const installDir = `${getCursorTypeDirForClient('mcp')}/${sub.name}`;
572
- const writeActions: string[] = [];
573
- for (const file of resourceFiles) {
574
- const normalised = path.normalize(file.path);
575
- if (normalised.startsWith('..')) continue;
576
- const fileDest = `${installDir}/${normalised}`;
577
- localActions.push({
578
- action: 'write_file',
579
- path: fileDest,
580
- content: file.content,
581
- });
582
- writeActions.push(fileDest);
583
- }
584
- const jsEntry = resourceFiles.find((f) => f.path.endsWith('.js'));
585
- const pyEntry = resourceFiles.find((f) => f.path.endsWith('.py'));
586
- const entryFile = jsEntry ?? pyEntry ?? resourceFiles[0];
587
- const cmd = jsEntry ? 'node' : 'python3';
588
- const entryPath = `${installDir}/${entryFile?.path ?? ''}`;
589
- localActions.push({
590
- action: 'merge_mcp_json',
591
- mcp_json_path: mcpJsonPath,
592
- server_name: sub.name,
593
- entry: { command: cmd, args: [entryPath] },
594
- skip_if_exists: true,
595
- });
596
- logger.info(
597
- {
598
- resourceId: sub.id,
599
- resourceName: sub.name,
600
- format: 'heuristic',
601
- installDir,
602
- mcpJsonPath,
603
- cmd,
604
- entryPath,
605
- writeFiles: writeActions,
606
- },
607
- 'sync_resources: MCP heuristic fallback — write_file + merge_mcp_json actions queued',
608
- );
609
- logToolStep('sync_resources', 'MCP heuristic fallback: write_file + merge_mcp_json queued', { resourceId: sub.id });
610
- }
611
-
612
- tally.synced++;
613
- details.push({ id: sub.id, name: sub.name, action: 'synced', version: resourceVersion });
614
- continue;
615
- }
616
-
617
- // ── Rule resource ─────────────────────────────────────────────────────
618
- // Return write_file actions; the AI Agent executes them on the user's
619
- // LOCAL machine. The AI compares file content directly (string equality)
620
- // against the existing local file and skips the write when content is
621
- // identical — avoiding unnecessary disk I/O. If the local file is missing
622
- // or has different content, the AI writes it unconditionally, which also
623
- // recovers files that were accidentally deleted by the user.
624
- if (sub.type === 'rule') {
625
- const typeDir = getCursorTypeDirForClient(sub.type);
626
- const writeActions: Array<{ destPath: string; contentLength: number }> = [];
627
-
628
- for (const file of resourceFiles) {
629
- const normalised = path.normalize(file.path);
630
- if (normalised.startsWith('..')) {
631
- logger.warn({ resourceId: sub.id, filePath: file.path }, 'Skipping suspicious file path');
632
- continue;
633
- }
634
- const destPath = `${typeDir}/${normalised}`;
635
- localActions.push({
636
- action: 'write_file',
637
- path: destPath,
638
- content: file.content,
639
- });
640
- writeActions.push({ destPath, contentLength: file.content.length });
641
- }
642
-
643
- logger.info(
644
- {
645
- resourceId: sub.id,
646
- resourceName: sub.name,
647
- typeDir,
648
- fileCount: writeActions.length,
649
- files: writeActions,
650
- clientSideNote: 'AI will compare file content directly; write is skipped if content is identical',
651
- },
652
- 'sync_resources: Rule — write_file actions queued for AI (client-side content comparison)',
653
- );
654
-
655
- tally.synced++;
656
- details.push({ id: sub.id, name: sub.name, action: 'synced', version: resourceVersion });
657
- logToolStep('sync_resources', 'Rule: write_file actions queued for AI', {
658
- resourceId: sub.id,
659
- fileCount: resourceFiles.length,
660
- });
661
- continue;
662
- }
663
-
664
- // Fallback for any unrecognised types (should not happen in practice).
665
- logger.warn({ resourceId: sub.id, type: sub.type }, 'Unrecognised resource type — skipping');
666
- tally.failed++;
667
- details.push({ id: sub.id, name: sub.name, action: 'failed', version: resourceVersion });
668
-
669
- } catch (error) {
670
- logger.error({
671
- resourceId: sub.id,
672
- resourceName: sub.name,
673
- error: error instanceof Error ? error.message : String(error),
674
- }, 'Failed to sync resource');
675
-
676
- tally.failed++;
677
- details.push({ id: sub.id, name: sub.name, action: 'failed', version: sub.resource?.version ?? 'unknown' });
678
- }
679
- }
680
-
681
- // ── Step 4: Prune stale prompts ────────────────────────────────────────
682
- // Remove any prompt registered in a previous session that is no longer in
683
- // the current subscription list. This prevents prompt count from growing
684
- // unboundedly across reconnections.
685
- // In 'check' mode we skip pruning — we never registered any prompts above.
686
- if (mode !== 'check') {
687
- promptManager.pruneStalePrompts(expectedPromptNames, userToken ?? '');
688
- }
689
-
690
- // ── Step 5: Health score ───────────────────────────────────────────────
691
- const healthScore = tally.total > 0
692
- ? Math.round(((tally.synced + tally.cached) / tally.total) * 100)
693
- : 100;
694
-
695
- const result: SyncResourcesResult = {
696
- mode,
697
- health_score: healthScore,
698
- summary: tally,
699
- details,
700
- ...(localActions.length > 0 ? { local_actions_required: localActions } : {}),
701
- };
702
-
703
- const duration = Date.now() - startTime;
704
- logToolCall('sync_resources', 'user-id', params as Record<string, unknown>, duration);
705
- logToolResult('sync_resources', true, result);
706
-
707
- logger.info({
708
- tool: 'sync_resources',
709
- mode,
710
- total: tally.total,
711
- synced: tally.synced,
712
- cached: tally.cached,
713
- failed: tally.failed,
714
- healthScore,
715
- duration,
716
- timestamp: new Date().toISOString()
717
- }, 'sync_resources completed successfully');
718
-
719
- // Update telemetry snapshot lists (fire-and-forget).
720
- // Rules: cannot track individual invocations; report subscription list only.
721
- const subscribedRules = subscriptions.subscriptions
722
- .filter((s) => s.type === 'rule')
723
- .map((s) => ({
724
- resource_id: s.id,
725
- resource_name: s.name,
726
- subscribed_at: (s as any).subscribed_at ?? new Date().toISOString(),
727
- }));
728
- if (userToken) telemetry.updateSubscribedRules(subscribedRules, userToken).catch(() => {});
729
-
730
- // MCPs: individual invocation tracking is each MCP server's own responsibility.
731
- const configuredMcps = subscriptions.subscriptions
732
- .filter((s) => s.type === 'mcp')
733
- .map((s) => ({
734
- resource_id: s.id,
735
- resource_name: s.name,
736
- configured_at: (s as any).subscribed_at ?? new Date().toISOString(),
737
- }));
738
- if (userToken) telemetry.updateConfiguredMcps(configuredMcps, userToken).catch(() => {});
739
-
740
- return { success: true, data: result };
741
-
742
- } catch (error) {
743
- const duration = Date.now() - startTime;
744
-
745
- logger.error({
746
- tool: 'sync_resources',
747
- error: error instanceof Error
748
- ? { message: error.message, stack: error.stack, name: error.name }
749
- : String(error),
750
- duration,
751
- timestamp: new Date().toISOString()
752
- }, 'sync_resources failed with error');
753
-
754
- logToolResult('sync_resources', false, undefined, error instanceof Error ? error : new Error(String(error)));
755
-
756
- return {
757
- success: false,
758
- error: {
759
- code: error instanceof MCPServerError ? error.code : 'UNKNOWN_ERROR',
760
- message: error instanceof Error ? error.message : String(error),
761
- },
762
- };
763
- }
764
- }
765
-
766
- // Tool definition for registry
767
- export const syncResourcesTool = {
768
- name: 'sync_resources',
769
- description:
770
- 'Synchronize subscribed AI resources. ' +
771
- 'Command and Skill resources are registered as MCP Prompts on the server. ' +
772
- 'If the user subscribed to a NEW Command or Skill in THIS conversation and you need to execute it immediately, do NOT wait for native prompts/get. ' +
773
- 'After this tool completes, call `resolve_prompt_content` with the new prompt_name or resource_id, then execute the returned content. ' +
774
- 'Rule and MCP resources are returned as `local_actions_required` — an ordered list of ' +
775
- 'write_file, merge_mcp_json, or other actions that the AI Agent MUST execute on the ' +
776
- 'USER\'S LOCAL MACHINE after receiving the response. ' +
777
- 'Execute every action in the list in order before reporting success to the user. ' +
778
- 'OPTIMIZATION: Before calling this tool in incremental mode, read ~/.cursor/mcp.json ' +
779
- 'and pass Object.keys(mcpServers || {}) as `configured_mcp_servers` parameter. ' +
780
- 'This allows the server to skip downloading MCP resources that are already configured, ' +
781
- 'significantly reducing API calls, network traffic, and local action overhead. ' +
782
- 'For write_file actions: ' +
783
- ' (1) Read the existing file at `path` (if it exists) using fs.readFile() or equivalent. ' +
784
- ' (2) Compare the file content directly (string equality) against the `content` field. ' +
785
- ' (3) SKIP the write if they are EXACTLY equal — the file is already up-to-date. ' +
786
- ' (4) Otherwise, create any missing parent directories and write the file. ' +
787
- 'For merge_mcp_json actions: ' +
788
- ' (1) If the action has `skip_if_exists: true` AND `mcpServers[server_name]` already exists ' +
789
- ' in ~/.cursor/mcp.json, SKIP this action entirely — the server is already configured ' +
790
- ' and any user-edited env values must not be overwritten. ' +
791
- ' (2) Otherwise read the existing ~/.cursor/mcp.json, smart-merge the provided entry ' +
792
- ' (preserve existing user env values), then write the file back.',
793
- inputSchema: {
794
- type: 'object' as const,
795
- properties: {
796
- mode: {
797
- type: 'string',
798
- description: 'Sync mode: check (status only), incremental (updates only), full (all)',
799
- enum: ['check', 'incremental', 'full'],
800
- default: 'incremental',
801
- },
802
- scope: {
803
- type: 'string',
804
- description: 'Installation scope: global (~/.cursor/), workspace (.cursor/), or all',
805
- enum: ['global', 'workspace', 'all'],
806
- default: 'global',
807
- },
808
- types: {
809
- type: 'array',
810
- description: 'Filter by resource types (empty = all types)',
811
- },
812
- user_token: {
813
- type: 'string',
814
- description:
815
- 'DO NOT set this field — it is automatically injected by the MCP server from ' +
816
- 'the authenticated SSE connection. The server always provides the correct token.',
817
- },
818
- configured_mcp_servers: {
819
- type: 'array',
820
- description:
821
- 'List of MCP server names already configured in ~/.cursor/mcp.json. ' +
822
- 'In incremental mode, the server skips downloading these MCP resources to reduce overhead. ' +
823
- 'To populate this: read ~/.cursor/mcp.json and extract Object.keys(mcpServers || {}). ' +
824
- 'Example: ["github", "gitlab", "postgres"]. Ignored in full mode (always downloads).',
825
- },
826
- },
827
- },
828
- handler: syncResources,
829
- };