@xquik/tweetclaw 1.6.4 → 1.6.6

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 +25 -14
  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 +17 -17
  43. package/skills/tweetclaw/SKILL.md +37 -44
  44. package/src/api-spec.ts +491 -12
  45. package/src/index.ts +212 -10
  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;
@@ -34,6 +64,28 @@ interface CommandContext {
34
64
  readonly senderId?: string;
35
65
  }
36
66
 
67
+ interface BeforeToolCallEvent {
68
+ readonly params?: unknown;
69
+ readonly toolName?: string;
70
+ }
71
+
72
+ interface ToolApprovalRequest {
73
+ readonly description: string;
74
+ readonly pluginId?: string;
75
+ readonly severity?: 'critical' | 'info' | 'warning';
76
+ readonly timeoutBehavior?: 'allow' | 'deny';
77
+ readonly timeoutMs?: number;
78
+ readonly title: string;
79
+ }
80
+
81
+ interface BeforeToolCallResult {
82
+ readonly requireApproval?: ToolApprovalRequest;
83
+ }
84
+
85
+ type BeforeToolCallHandler = (
86
+ event: BeforeToolCallEvent,
87
+ ) => BeforeToolCallResult | Promise<BeforeToolCallResult | undefined> | undefined;
88
+
37
89
  interface OpenClawApi {
38
90
  readonly logger: {
39
91
  readonly debug?: (message: string) => void;
@@ -56,23 +108,152 @@ interface OpenClawApi {
56
108
  readonly registerTool: (
57
109
  tool: {
58
110
  readonly description: string;
59
- readonly execute: (toolCallId: string, params: { readonly code: string }) => Promise<ToolResult>;
111
+ readonly execute: (toolCallId: string, params: unknown) => Promise<ToolResult>;
60
112
  readonly name: string;
61
113
  readonly parameters: unknown;
62
114
  },
63
115
  options?: { readonly name?: string; readonly optional?: boolean },
64
116
  ) => void;
117
+ readonly on?: (
118
+ name: 'before_tool_call',
119
+ handler: BeforeToolCallHandler,
120
+ options?: { readonly priority?: number },
121
+ ) => void;
122
+ readonly registerHook?: (
123
+ name: 'before_tool_call',
124
+ handler: BeforeToolCallHandler,
125
+ options?: { readonly priority?: number },
126
+ ) => void;
65
127
  }
66
128
 
67
- const CODE_PARAMETER = {
129
+ const EXPLORE_PARAMETERS = {
130
+ properties: {
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' },
138
+ },
139
+ type: 'object',
140
+ };
141
+
142
+ const TWEETCLAW_PARAMETERS = {
143
+ additionalProperties: false,
68
144
  properties: {
69
- code: { description: 'Async arrow function to execute', type: 'string' },
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
+ },
70
153
  },
71
- required: ['code'],
154
+ required: ['path'],
72
155
  type: 'object',
73
156
  };
74
157
 
75
- export default function register(api: OpenClawApi, fetchFunction?: FetchFunction): void {
158
+ function asObject(value: unknown): Readonly<Record<string, unknown>> | undefined {
159
+ if (typeof value !== 'object' || value === null) {
160
+ return undefined;
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
+ }
196
+
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
+ };
204
+ }
205
+
206
+ function toolCallParams(event: BeforeToolCallEvent): Readonly<TweetclawParams> | undefined {
207
+ const params = asTweetclawParams(event.params);
208
+ if (params.path.length === 0) {
209
+ return undefined;
210
+ }
211
+ return params;
212
+ }
213
+
214
+ function requiresTweetclawApproval(params: Readonly<TweetclawParams>): boolean {
215
+ return requestNeedsApproval(normalizeMethod(params.method), params.path);
216
+ }
217
+
218
+ function registerWriteApprovalHook(api: OpenClawApi): void {
219
+ const registerHook = api.on ?? api.registerHook;
220
+ if (registerHook === undefined) {
221
+ api.logger.warn(
222
+ 'TweetClaw: OpenClaw approval hooks are unavailable. Keep explicit user approval before write actions.',
223
+ );
224
+ return;
225
+ }
226
+
227
+ registerHook.call(
228
+ api,
229
+ 'before_tool_call',
230
+ (event): BeforeToolCallResult | undefined => {
231
+ if (event.toolName !== 'tweetclaw') {
232
+ return undefined;
233
+ }
234
+
235
+ const params = toolCallParams(event);
236
+ if (params === undefined || !requiresTweetclawApproval(params)) {
237
+ return undefined;
238
+ }
239
+
240
+ return {
241
+ requireApproval: {
242
+ description:
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.',
244
+ pluginId: 'tweetclaw',
245
+ severity: 'warning',
246
+ timeoutBehavior: 'deny',
247
+ timeoutMs: 60_000,
248
+ title: 'Approve TweetClaw Action',
249
+ },
250
+ };
251
+ },
252
+ { priority: 50 },
253
+ );
254
+ }
255
+
256
+ function register(api: OpenClawApi, fetchFunction?: FetchFunction): void {
76
257
  const config: unknown = api.pluginConfig;
77
258
  if (!isPluginConfig(config)) {
78
259
  api.logger.warn(
@@ -98,14 +279,18 @@ export default function register(api: OpenClawApi, fetchFunction?: FetchFunction
98
279
  }
99
280
 
100
281
  const request = createProxiedRequest(baseUrl, credential, fetchFunction);
282
+ registerWriteApprovalHook(api);
101
283
 
102
284
  // --- Tools (2-tool approach, execute inside tool object) ---
103
285
  api.registerTool(
104
286
  {
105
287
  description: SEARCH_DESCRIPTION,
106
- execute: async (_toolCallId, { code }) => handleExplore(code),
288
+ execute: async (_toolCallId, params) => {
289
+ await Promise.resolve();
290
+ return handleExplore(asExploreParams(params));
291
+ },
107
292
  name: 'explore',
108
- parameters: CODE_PARAMETER,
293
+ parameters: EXPLORE_PARAMETERS,
109
294
  },
110
295
  { name: 'explore' },
111
296
  );
@@ -113,9 +298,15 @@ export default function register(api: OpenClawApi, fetchFunction?: FetchFunction
113
298
  api.registerTool(
114
299
  {
115
300
  description: EXECUTE_DESCRIPTION,
116
- 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
+ }),
117
308
  name: 'tweetclaw',
118
- parameters: CODE_PARAMETER,
309
+ parameters: TWEETCLAW_PARAMETERS,
119
310
  },
120
311
  { name: 'tweetclaw', optional: true },
121
312
  );
@@ -170,3 +361,14 @@ export default function register(api: OpenClawApi, fetchFunction?: FetchFunction
170
361
 
171
362
  api.logger.info('TweetClaw: Plugin registered successfully');
172
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 };