@xquik/tweetclaw 1.6.5 → 1.6.7

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 (53) hide show
  1. package/README.md +22 -13
  2. package/dist/api-spec.d.ts +3 -0
  3. package/dist/api-spec.js +1427 -0
  4. package/dist/api-spec.js.map +1 -0
  5. package/dist/commands/xstatus.d.ts +17 -0
  6. package/dist/commands/xstatus.js +52 -0
  7. package/dist/commands/xstatus.js.map +1 -0
  8. package/dist/commands/xtrends.d.ts +16 -0
  9. package/dist/commands/xtrends.js +39 -0
  10. package/dist/commands/xtrends.js.map +1 -0
  11. package/dist/index.d.ts +107 -0
  12. package/dist/index.js +249 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/mpp.d.ts +7 -0
  15. package/dist/mpp.js +39 -0
  16. package/dist/mpp.js.map +1 -0
  17. package/dist/request.d.ts +7 -0
  18. package/dist/request.js +88 -0
  19. package/dist/request.js.map +1 -0
  20. package/dist/services/event-poller.d.ts +7 -0
  21. package/dist/services/event-poller.js +69 -0
  22. package/dist/services/event-poller.js.map +1 -0
  23. package/dist/tools/catalog.d.ts +17 -0
  24. package/dist/tools/catalog.js +110 -0
  25. package/dist/tools/catalog.js.map +1 -0
  26. package/dist/tools/explore.d.ts +5 -0
  27. package/dist/tools/explore.js +28 -0
  28. package/dist/tools/explore.js.map +1 -0
  29. package/dist/tools/result.d.ts +5 -0
  30. package/dist/tools/result.js +15 -0
  31. package/dist/tools/result.js.map +1 -0
  32. package/dist/tools/tweetclaw.d.ts +13 -0
  33. package/dist/tools/tweetclaw.js +62 -0
  34. package/dist/tools/tweetclaw.js.map +1 -0
  35. package/dist/truncate.d.ts +3 -0
  36. package/dist/truncate.js +25 -0
  37. package/dist/truncate.js.map +1 -0
  38. package/dist/types.d.ts +64 -0
  39. package/dist/types.js +2 -0
  40. package/dist/types.js.map +1 -0
  41. package/openclaw.plugin.json +7 -8
  42. package/package.json +19 -18
  43. package/skills/tweetclaw/SKILL.md +33 -42
  44. package/src/api-spec.ts +480 -12
  45. package/src/index.ts +135 -36
  46. package/src/mpp.ts +9 -11
  47. package/src/request.ts +27 -2
  48. package/src/tools/catalog.ts +145 -0
  49. package/src/tools/explore.ts +18 -44
  50. package/src/tools/result.ts +19 -0
  51. package/src/tools/tweetclaw.ts +49 -296
  52. package/src/types.ts +19 -0
  53. package/src/tools/executor.ts +0 -125
package/src/index.ts CHANGED
@@ -1,11 +1,13 @@
1
+ import { definePluginEntry } from 'openclaw/plugin-sdk/plugin-entry';
1
2
  import { handleXStatus } from './commands/xstatus.js';
2
3
  import { handleXTrends } from './commands/xtrends.js';
3
4
  import { initMpp } from './mpp.js';
4
5
  import { createProxiedRequest } from './request.js';
5
6
  import { createEventPoller } from './services/event-poller.js';
7
+ import { normalizeMethod, requestNeedsApproval } from './tools/catalog.js';
6
8
  import { handleExplore, SEARCH_DESCRIPTION } from './tools/explore.js';
7
9
  import { EXECUTE_DESCRIPTION, handleTweetclaw } from './tools/tweetclaw.js';
8
- import type { FetchFunction, PluginConfig } from './types.js';
10
+ import type { ExploreParams, FetchFunction, PluginConfig, TweetclawParams } from './types.js';
9
11
 
10
12
  interface PollerEvent {
11
13
  readonly eventType?: string;
@@ -23,6 +25,34 @@ function isPluginConfig(value: unknown): value is PluginConfig {
23
25
 
24
26
  const DEFAULT_POLLING_INTERVAL_SECONDS = 60;
25
27
 
28
+ const CONFIG_SCHEMA = {
29
+ additionalProperties: false,
30
+ anyOf: [
31
+ { required: ['apiKey'] },
32
+ { required: ['tempoSigningKey'] },
33
+ ],
34
+ properties: {
35
+ apiKey: {
36
+ description: 'Xquik API key (get one at dashboard.xquik.com). Required for account-backed X automation.',
37
+ minLength: 1,
38
+ type: 'string',
39
+ },
40
+ baseUrl: { default: 'https://xquik.com', type: 'string' },
41
+ pollingEnabled: { default: true, type: 'boolean' },
42
+ pollingInterval: {
43
+ default: 60,
44
+ description: 'Event polling interval in seconds',
45
+ type: 'number',
46
+ },
47
+ tempoSigningKey: {
48
+ description: 'MPP signing key for pay-per-use mode. No account needed. 32 read-only X-API endpoints.',
49
+ minLength: 1,
50
+ type: 'string',
51
+ },
52
+ },
53
+ type: 'object',
54
+ };
55
+
26
56
  interface ToolResult {
27
57
  readonly content: ReadonlyArray<{ readonly text: string; readonly type: string }>;
28
58
  readonly isError?: true;
@@ -78,7 +108,7 @@ interface OpenClawApi {
78
108
  readonly registerTool: (
79
109
  tool: {
80
110
  readonly description: string;
81
- readonly execute: (toolCallId: string, params: { readonly code: string }) => Promise<ToolResult>;
111
+ readonly execute: (toolCallId: string, params: unknown) => Promise<ToolResult>;
82
112
  readonly name: string;
83
113
  readonly parameters: unknown;
84
114
  },
@@ -96,44 +126,93 @@ interface OpenClawApi {
96
126
  ) => void;
97
127
  }
98
128
 
99
- const CODE_PARAMETER = {
129
+ const EXPLORE_PARAMETERS = {
100
130
  properties: {
101
- code: { description: 'Async arrow function to execute', type: 'string' },
131
+ category: { description: 'Endpoint category filter', type: 'string' },
132
+ free: { description: 'Filter by free or paid endpoints', type: 'boolean' },
133
+ limit: { default: 25, description: 'Maximum endpoint descriptors to return', maximum: 100, minimum: 1, type: 'number' },
134
+ method: { description: 'HTTP method filter', enum: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE'], type: 'string' },
135
+ mpp: { description: 'Filter by MPP eligibility', type: 'boolean' },
136
+ path: { description: 'Exact or partial API path filter', type: 'string' },
137
+ query: { description: 'Keyword search across endpoint metadata', type: 'string' },
102
138
  },
103
- required: ['code'],
104
139
  type: 'object',
105
140
  };
106
141
 
107
- const WRITE_METHOD_PATTERN = /\bmethod\s*:\s*['"`](?:DELETE|PATCH|POST|PUT)['"`]/iu;
108
- const HIGH_IMPACT_PATH_PATTERNS = [
109
- /\/api\/v1\/credits\/(?:quick-topup|topup)/u,
110
- /\/api\/v1\/draws/u,
111
- /\/api\/v1\/extractions/u,
112
- /\/api\/v1\/monitors/u,
113
- /\/api\/v1\/subscribe/u,
114
- /\/api\/v1\/webhooks/u,
115
- /\/api\/v1\/x\/communities/u,
116
- /\/api\/v1\/x\/dm\//u,
117
- /\/api\/v1\/x\/media/u,
118
- /\/api\/v1\/x\/profile/u,
119
- /\/api\/v1\/x\/tweets['"`]/u,
120
- ] as const;
121
-
122
- function toolCallCode(event: BeforeToolCallEvent): string | undefined {
123
- if (typeof event.params !== 'object' || event.params === null) {
142
+ const TWEETCLAW_PARAMETERS = {
143
+ additionalProperties: false,
144
+ properties: {
145
+ body: { description: 'JSON request body', type: ['object', 'array', 'string', 'number', 'boolean', 'null'] },
146
+ method: { default: 'GET', description: 'HTTP method', enum: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE'], type: 'string' },
147
+ path: { description: 'Concrete /api/v1/... endpoint path from the catalog', type: 'string' },
148
+ query: {
149
+ additionalProperties: { type: ['string', 'number', 'boolean'] },
150
+ description: 'Query parameters',
151
+ type: 'object',
152
+ },
153
+ },
154
+ required: ['path'],
155
+ type: 'object',
156
+ };
157
+
158
+ function asObject(value: unknown): Readonly<Record<string, unknown>> | undefined {
159
+ if (typeof value !== 'object' || value === null) {
124
160
  return undefined;
125
161
  }
162
+ return Object.fromEntries(Object.entries(value));
163
+ }
164
+
165
+ function asExploreParams(params: unknown): Readonly<ExploreParams> {
166
+ const value = asObject(params);
167
+ if (value === undefined) return {};
168
+
169
+ return {
170
+ ...(typeof value.category === 'string' ? { category: value.category } : {}),
171
+ ...(typeof value.free === 'boolean' ? { free: value.free } : {}),
172
+ ...(typeof value.limit === 'number' ? { limit: value.limit } : {}),
173
+ ...(typeof value.method === 'string' ? { method: value.method } : {}),
174
+ ...(typeof value.mpp === 'boolean' ? { mpp: value.mpp } : {}),
175
+ ...(typeof value.path === 'string' ? { path: value.path } : {}),
176
+ ...(typeof value.query === 'string' ? { query: value.query } : {}),
177
+ };
178
+ }
179
+
180
+ function asQueryParams(value: unknown): Readonly<Record<string, boolean | number | string>> | undefined {
181
+ const query = asObject(value);
182
+ if (query === undefined) return undefined;
183
+
184
+ const entries = Object.entries(query).filter(
185
+ (entry): entry is [string, boolean | number | string] =>
186
+ ['boolean', 'number', 'string'].includes(typeof entry[1]),
187
+ );
188
+ return Object.fromEntries(entries);
189
+ }
190
+
191
+ function asTweetclawParams(params: unknown): Readonly<TweetclawParams> {
192
+ const value = asObject(params);
193
+ if (value === undefined || typeof value.path !== 'string') {
194
+ return { path: '' };
195
+ }
126
196
 
127
- const { code } = event.params as { readonly code?: unknown };
128
- return typeof code === 'string' ? code : undefined;
197
+ const query = asQueryParams(value.query);
198
+ return {
199
+ ...(value.body === undefined ? {} : { body: value.body }),
200
+ ...(typeof value.method === 'string' ? { method: value.method } : {}),
201
+ path: value.path,
202
+ ...(query === undefined ? {} : { query }),
203
+ };
129
204
  }
130
205
 
131
- function requiresTweetclawApproval(code: string): boolean {
132
- if (WRITE_METHOD_PATTERN.test(code)) {
133
- return true;
206
+ function toolCallParams(event: BeforeToolCallEvent): Readonly<TweetclawParams> | undefined {
207
+ const params = asTweetclawParams(event.params);
208
+ if (params.path.length === 0) {
209
+ return undefined;
134
210
  }
211
+ return params;
212
+ }
135
213
 
136
- return HIGH_IMPACT_PATH_PATTERNS.some((pattern) => pattern.test(code));
214
+ function requiresTweetclawApproval(params: Readonly<TweetclawParams>): boolean {
215
+ return requestNeedsApproval(normalizeMethod(params.method), params.path);
137
216
  }
138
217
 
139
218
  function registerWriteApprovalHook(api: OpenClawApi): void {
@@ -153,15 +232,15 @@ function registerWriteApprovalHook(api: OpenClawApi): void {
153
232
  return undefined;
154
233
  }
155
234
 
156
- const code = toolCallCode(event);
157
- if (code === undefined || !requiresTweetclawApproval(code)) {
235
+ const params = toolCallParams(event);
236
+ if (params === undefined || !requiresTweetclawApproval(params)) {
158
237
  return undefined;
159
238
  }
160
239
 
161
240
  return {
162
241
  requireApproval: {
163
242
  description:
164
- 'TweetClaw is about to run code that can change X accounts, create jobs, or start a checkout flow. Review the tool call before allowing it.',
243
+ 'TweetClaw is about to invoke an endpoint that can change X accounts, create jobs, or expose private data. Review the tool call before allowing it.',
165
244
  pluginId: 'tweetclaw',
166
245
  severity: 'warning',
167
246
  timeoutBehavior: 'deny',
@@ -174,7 +253,7 @@ function registerWriteApprovalHook(api: OpenClawApi): void {
174
253
  );
175
254
  }
176
255
 
177
- export default function register(api: OpenClawApi, fetchFunction?: FetchFunction): void {
256
+ function register(api: OpenClawApi, fetchFunction?: FetchFunction): void {
178
257
  const config: unknown = api.pluginConfig;
179
258
  if (!isPluginConfig(config)) {
180
259
  api.logger.warn(
@@ -206,9 +285,12 @@ export default function register(api: OpenClawApi, fetchFunction?: FetchFunction
206
285
  api.registerTool(
207
286
  {
208
287
  description: SEARCH_DESCRIPTION,
209
- execute: async (_toolCallId, { code }) => handleExplore(code),
288
+ execute: async (_toolCallId, params) => {
289
+ await Promise.resolve();
290
+ return handleExplore(asExploreParams(params));
291
+ },
210
292
  name: 'explore',
211
- parameters: CODE_PARAMETER,
293
+ parameters: EXPLORE_PARAMETERS,
212
294
  },
213
295
  { name: 'explore' },
214
296
  );
@@ -216,9 +298,15 @@ export default function register(api: OpenClawApi, fetchFunction?: FetchFunction
216
298
  api.registerTool(
217
299
  {
218
300
  description: EXECUTE_DESCRIPTION,
219
- execute: async (_toolCallId, { code }) => handleTweetclaw({ apiKey: credential, baseUrl, code, fetchFunction }),
301
+ execute: async (_toolCallId, params) => handleTweetclaw({
302
+ apiKey: credential,
303
+ baseUrl,
304
+ fetchFunction,
305
+ mppMode: isMppMode,
306
+ params: asTweetclawParams(params),
307
+ }),
220
308
  name: 'tweetclaw',
221
- parameters: CODE_PARAMETER,
309
+ parameters: TWEETCLAW_PARAMETERS,
222
310
  },
223
311
  { name: 'tweetclaw', optional: true },
224
312
  );
@@ -273,3 +361,14 @@ export default function register(api: OpenClawApi, fetchFunction?: FetchFunction
273
361
 
274
362
  api.logger.info('TweetClaw: Plugin registered successfully');
275
363
  }
364
+
365
+ const plugin = definePluginEntry({
366
+ configSchema: CONFIG_SCHEMA,
367
+ description: 'Structured X/Twitter automation through Xquik',
368
+ id: 'tweetclaw',
369
+ name: 'TweetClaw',
370
+ register,
371
+ });
372
+
373
+ export { register };
374
+ export default plugin;
package/src/mpp.ts CHANGED
@@ -1,5 +1,3 @@
1
- import { resolveAsyncFunctionConstructor } from './tools/executor.js';
2
-
3
1
  type ModuleLoader = (name: string) => Promise<Record<string, unknown>>;
4
2
 
5
3
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -10,16 +8,16 @@ function isCallable(value: unknown): value is (...args: readonly unknown[]) => u
10
8
  return typeof value === 'function';
11
9
  }
12
10
 
11
+ async function loadDynamicModule(name: string): Promise<Record<string, unknown>> {
12
+ const mod: unknown = await import(name);
13
+ if (!isRecord(mod)) {
14
+ throw new Error(`Failed to load ${name}`);
15
+ }
16
+ return mod;
17
+ }
18
+
13
19
  function createModuleLoader(): ModuleLoader {
14
- const loader = resolveAsyncFunctionConstructor();
15
- const dynamicImport = new loader('n', 'return import(n)');
16
- return async (name: string): Promise<Record<string, unknown>> => {
17
- const mod: unknown = await dynamicImport(name);
18
- if (!isRecord(mod)) {
19
- throw new Error(`Failed to load ${name}`);
20
- }
21
- return mod;
22
- };
20
+ return loadDynamicModule;
23
21
  }
24
22
 
25
23
  async function initMpp(tempoSigningKey: string, loadModule?: ModuleLoader): Promise<void> {
package/src/request.ts CHANGED
@@ -7,6 +7,7 @@ const AUTHORIZATION_HEADER = 'authorization';
7
7
  const BEARER_PREFIX = 'Bearer ';
8
8
  const API_KEY_PREFIX = 'xq_';
9
9
  const API_V1_PREFIX = '/api/v1/';
10
+ const SUPPORT_TICKETS_PREFIX = '/api/v1/support/tickets';
10
11
 
11
12
  function buildAuthHeader(credential: string): Record<string, string> {
12
13
  if (credential.startsWith(API_KEY_PREFIX)) {
@@ -34,18 +35,42 @@ function buildFetchUrl(baseUrl: string, path: string, query?: Readonly<Record<st
34
35
  }
35
36
 
36
37
  const PROHIBITED_PATHS: ReadonlyArray<readonly [string, string]> = [
38
+ ['PATCH', '/api/v1/account'],
39
+ ['PUT', '/api/v1/account/x-identity'],
40
+ ['GET', '/api/v1/api-keys'],
41
+ ['POST', '/api/v1/api-keys'],
42
+ ['POST', '/api/v1/credits/topup'],
43
+ ['GET', '/api/v1/credits/topup/status'],
44
+ ['POST', '/api/v1/credits/quick-topup'],
45
+ ['POST', '/api/v1/subscribe'],
37
46
  ['POST', '/api/v1/x/accounts'],
38
47
  ['POST', '/api/v1/x/accounts/'],
48
+ ['POST', '/api/v1/x/accounts/bulk-retry'],
39
49
  ];
40
50
 
41
- const PROHIBITED_PATH_PATTERN = /^\/api\/v1\/x\/accounts\/[^/]+\/reauth\/?$/;
51
+ const PROHIBITED_PATH_PATTERNS: ReadonlyArray<readonly [string, RegExp]> = [
52
+ ['DELETE', /^\/api\/v1\/api-keys\/[^/]+\/?$/u],
53
+ ['DELETE', /^\/api\/v1\/x\/accounts\/[^/]+\/?$/u],
54
+ ['GET', /^\/api\/v1\/x\/accounts\/[^/]+\/?$/u],
55
+ ['POST', /^\/api\/v1\/x\/accounts\/[^/]+\/reauth\/?$/u],
56
+ ];
57
+
58
+ const SUPPORT_TICKET_METHODS: ReadonlySet<string> = new Set(['GET', 'PATCH', 'POST']);
59
+
60
+ function isSupportTicketPath(method: string, path: string): boolean {
61
+ return SUPPORT_TICKET_METHODS.has(method)
62
+ && (path === SUPPORT_TICKETS_PREFIX || path.startsWith(`${SUPPORT_TICKETS_PREFIX}/`));
63
+ }
42
64
 
43
65
  function isProhibitedRequest(method: string, path: string): boolean {
44
66
  const upperMethod = method.toUpperCase();
45
67
  const matchesStaticPath = PROHIBITED_PATHS.some(
46
68
  ([blockedMethod, blockedPath]) => upperMethod === blockedMethod && path === blockedPath,
47
69
  );
48
- return matchesStaticPath || (upperMethod === 'POST' && PROHIBITED_PATH_PATTERN.test(path));
70
+ const matchesPattern = PROHIBITED_PATH_PATTERNS.some(
71
+ ([blockedMethod, pattern]) => upperMethod === blockedMethod && pattern.test(path),
72
+ );
73
+ return matchesStaticPath || matchesPattern || isSupportTicketPath(upperMethod, path);
49
74
  }
50
75
 
51
76
  function validateRequestPath(method: string, path: string): void {
@@ -0,0 +1,145 @@
1
+ import { API_SPEC } from '../api-spec.js';
2
+ import type { EndpointInfo, ExploreParams, TweetclawParams } from '../types.js';
3
+
4
+ const API_V1_PREFIX = '/api/v1/';
5
+ const DEFAULT_EXPLORE_LIMIT = 25;
6
+ const MAX_EXPLORE_LIMIT = 100;
7
+
8
+ const specEndpoints: readonly EndpointInfo[] = API_SPEC.filter((endpoint) => endpoint.agentProhibited !== true);
9
+
10
+ function normalizeMethod(method?: string): string {
11
+ return (method ?? 'GET').toUpperCase();
12
+ }
13
+
14
+ function normalizeLimit(limit?: number): number {
15
+ if (limit === undefined || !Number.isFinite(limit)) {
16
+ return DEFAULT_EXPLORE_LIMIT;
17
+ }
18
+ return Math.min(Math.max(Math.trunc(limit), 1), MAX_EXPLORE_LIMIT);
19
+ }
20
+
21
+ function pathSegments(path: string): readonly string[] {
22
+ const normalized = path.endsWith('/') ? path.slice(0, -1) : path;
23
+ return normalized.split('/');
24
+ }
25
+
26
+ function matchesEndpointPath(endpointPath: string, requestPath: string): boolean {
27
+ if (endpointPath === requestPath) return true;
28
+ const endpointSegments = pathSegments(endpointPath);
29
+ const requestSegments = pathSegments(requestPath);
30
+ if (endpointSegments.length !== requestSegments.length) return false;
31
+
32
+ return endpointSegments.every((segment, index) => {
33
+ const requestSegment = String(requestSegments.at(index));
34
+ return segment.startsWith(':') ? requestSegment.length > 0 : segment === requestSegment;
35
+ });
36
+ }
37
+
38
+ function assertSafePath(path: string): void {
39
+ if (!path.startsWith(API_V1_PREFIX)) {
40
+ throw new Error(`Path must start with /api/v1/ but got: ${path}`);
41
+ }
42
+ if (path.includes('?') || path.includes('#')) {
43
+ throw new Error('Pass query parameters through the query object, not in the path.');
44
+ }
45
+ }
46
+
47
+ function findEndpoint(method: string, path: string): EndpointInfo | undefined {
48
+ return specEndpoints.find(
49
+ (endpoint) => endpoint.method === method && matchesEndpointPath(endpoint.path, path),
50
+ );
51
+ }
52
+
53
+ function normalizeQuery(query?: Readonly<Record<string, boolean | number | string>>): Readonly<Record<string, string>> | undefined {
54
+ if (query === undefined) return undefined;
55
+ return Object.fromEntries(Object.entries(query).map(([key, value]) => [key, String(value)]));
56
+ }
57
+
58
+ function requestNeedsApproval(method: string, path: string): boolean {
59
+ if (method !== 'GET') {
60
+ return true;
61
+ }
62
+
63
+ return path.startsWith('/api/v1/events')
64
+ || path.startsWith('/api/v1/webhooks')
65
+ || path === '/api/v1/x/accounts'
66
+ || path.startsWith('/api/v1/x/accounts/')
67
+ || path.startsWith('/api/v1/x/bookmarks')
68
+ || path.startsWith('/api/v1/x/dm/')
69
+ || path.startsWith('/api/v1/x/notifications')
70
+ || path.startsWith('/api/v1/x/timeline');
71
+ }
72
+
73
+ function resolveCatalogRequest(
74
+ params: Readonly<TweetclawParams>,
75
+ options?: Readonly<{ mppMode?: boolean }>,
76
+ ): {
77
+ readonly body?: unknown;
78
+ readonly endpoint: EndpointInfo;
79
+ readonly method: string;
80
+ readonly path: string;
81
+ readonly query?: Readonly<Record<string, string>>;
82
+ } {
83
+ const method = normalizeMethod(params.method);
84
+ const { body, path } = params;
85
+ assertSafePath(path);
86
+ const endpoint = findEndpoint(method, path);
87
+ if (endpoint === undefined) {
88
+ throw new Error(`Endpoint is not in the TweetClaw catalog: ${method} ${path}`);
89
+ }
90
+ if (options?.mppMode === true && endpoint.mpp === undefined) {
91
+ throw new Error(`Endpoint is not available in MPP mode: ${method} ${endpoint.path}`);
92
+ }
93
+
94
+ const query = normalizeQuery(params.query);
95
+ if (query === undefined) {
96
+ return { body, endpoint, method, path };
97
+ }
98
+ return { body, endpoint, method, path, query };
99
+ }
100
+
101
+ function endpointMatchesQuery(endpoint: EndpointInfo, query: string): boolean {
102
+ const normalized = query.toLowerCase();
103
+ const { category, method, parameters, path, responseShape, summary } = endpoint;
104
+ const haystack = [
105
+ category,
106
+ method,
107
+ path,
108
+ responseShape,
109
+ summary,
110
+ ...(parameters ?? []).flatMap((parameter) => [
111
+ parameter.description,
112
+ parameter.name,
113
+ parameter.type,
114
+ ]),
115
+ ].join(' ').toLowerCase();
116
+
117
+ return haystack.includes(normalized);
118
+ }
119
+
120
+ function exploreCatalog(params: Readonly<ExploreParams> = {}): readonly EndpointInfo[] {
121
+ const method = params.method === undefined ? undefined : normalizeMethod(params.method);
122
+ const query = params.query?.trim();
123
+ const category = params.category?.trim().toLowerCase();
124
+ const path = params.path?.trim();
125
+ const limit = normalizeLimit(params.limit);
126
+
127
+ return specEndpoints
128
+ .filter((endpoint) => method === undefined || endpoint.method === method)
129
+ .filter((endpoint) => category === undefined || endpoint.category.toLowerCase() === category)
130
+ .filter((endpoint) => params.free === undefined || endpoint.free === params.free)
131
+ .filter((endpoint) => params.mpp === undefined || (endpoint.mpp !== undefined) === params.mpp)
132
+ .filter((endpoint) => path === undefined || matchesEndpointPath(endpoint.path, path) || endpoint.path.includes(path))
133
+ .filter((endpoint) => query === undefined || query.length === 0 || endpointMatchesQuery(endpoint, query))
134
+ .slice(0, limit);
135
+ }
136
+
137
+ export {
138
+ exploreCatalog,
139
+ findEndpoint,
140
+ matchesEndpointPath,
141
+ normalizeMethod,
142
+ requestNeedsApproval,
143
+ resolveCatalogRequest,
144
+ specEndpoints,
145
+ };
@@ -1,53 +1,27 @@
1
- import { API_SPEC } from '../api-spec.js';
2
- import { errorResult, runInSandbox, specEndpoints, successResult } from './executor.js';
3
- import type { EndpointInfo, ToolResult } from '../types.js';
1
+ import { exploreCatalog, specEndpoints } from './catalog.js';
2
+ import { errorResult, successResult } from './result.js';
3
+ import type { EndpointInfo, ExploreParams, ToolResult } from '../types.js';
4
4
 
5
- const categories = [...new Set(API_SPEC.map((endpoint) => endpoint.category))].toSorted((a, b) => a.localeCompare(b)).join(', ');
5
+ const categories = [...new Set(specEndpoints.map((endpoint) => endpoint.category))]
6
+ .toSorted((a, b) => a.localeCompare(b))
7
+ .join(', ');
6
8
 
7
- const SEARCH_DESCRIPTION = `Search the X (Twitter) API spec for endpoints: post tweets, reply, like, retweet, follow, DM, update profile, upload media, search tweets, look up users, extract data, monitor accounts, run giveaways, compose tweets, and more. No network calls - runs against an in-memory endpoint catalog.
9
+ const SEARCH_DESCRIPTION = `Search the X (Twitter) API endpoint catalog. No network calls and no code execution.
8
10
 
9
- Write an async arrow function. The sandbox provides:
11
+ Use structured filters:
12
+ - query: keyword search across summaries, paths, response shapes, and parameters
13
+ - category: one of ${categories}
14
+ - method: GET, POST, PATCH, PUT, or DELETE
15
+ - path: exact or partial API path
16
+ - free: true for free endpoints, false for paid endpoints
17
+ - mpp: true for MPP-eligible endpoints only
18
+ - limit: 1-100 results, default 25
10
19
 
11
- \`\`\`typescript
12
- interface EndpointInfo {
13
- method: string;
14
- path: string;
15
- summary: string;
16
- category: string; // ${categories}
17
- free: boolean;
18
- parameters?: Array<{ name: string; in: 'query' | 'path' | 'body'; required: boolean; type: string; description: string }>;
19
- responseShape?: string;
20
- }
21
-
22
- declare const spec: { endpoints: EndpointInfo[] };
23
- \`\`\`
24
-
25
- ## Examples
26
-
27
- ### Find all free endpoints
28
- \`\`\`javascript
29
- async () => {
30
- return spec.endpoints.filter(e => e.free);
31
- }
32
- \`\`\`
33
-
34
- ### Find endpoints by category
35
- \`\`\`javascript
36
- async () => {
37
- return spec.endpoints.filter(e => e.category === 'composition');
38
- }
39
- \`\`\`
40
-
41
- ### Search by keyword
42
- \`\`\`javascript
43
- async () => {
44
- return spec.endpoints.filter(e => e.summary.toLowerCase().includes('tweet'));
45
- }
46
- \`\`\``;
20
+ Returns endpoint descriptors with method, path, summary, category, parameters, cost, and response shape.`;
47
21
 
48
- async function handleExplore(code: string): Promise<ToolResult> {
22
+ async function handleExplore(params: Readonly<ExploreParams> = {}): Promise<ToolResult> {
49
23
  try {
50
- const result: unknown = await runInSandbox(code, { spec: { endpoints: specEndpoints } });
24
+ const result = await Promise.resolve(exploreCatalog(params));
51
25
  return successResult(result);
52
26
  } catch (error: unknown) {
53
27
  return errorResult(error);
@@ -0,0 +1,19 @@
1
+ import { truncateResponse } from '../truncate.js';
2
+ import type { ToolResult } from '../types.js';
3
+
4
+ function extractErrorMessage(error: unknown): string {
5
+ if (error instanceof Error) {
6
+ return `${error.constructor.name}: ${error.message}`;
7
+ }
8
+ return String(error);
9
+ }
10
+
11
+ function successResult(content: unknown): ToolResult {
12
+ return { content: [{ text: truncateResponse(content), type: 'text' }] };
13
+ }
14
+
15
+ function errorResult(error: unknown): ToolResult {
16
+ return { content: [{ text: extractErrorMessage(error), type: 'text' }], isError: true };
17
+ }
18
+
19
+ export { errorResult, extractErrorMessage, successResult };