outrank-cli 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +317 -20
  2. package/SKILL.md +20 -2
  3. package/bin/outrank-mcp.js +3 -0
  4. package/package.json +21 -5
  5. package/src/adapters/cli/jsonInput.js +42 -0
  6. package/src/adapters/cli/parse.js +124 -0
  7. package/src/adapters/cli/run.js +139 -0
  8. package/src/adapters/mcp/constants.js +49 -0
  9. package/src/adapters/mcp/descriptions.js +87 -0
  10. package/src/adapters/mcp/help.js +34 -0
  11. package/src/adapters/mcp/input.js +195 -0
  12. package/src/adapters/mcp/instructions.js +139 -0
  13. package/src/adapters/mcp/logger.js +45 -0
  14. package/src/adapters/mcp/main.js +36 -0
  15. package/src/adapters/mcp/options.js +118 -0
  16. package/src/adapters/mcp/result.js +33 -0
  17. package/src/adapters/mcp/server.js +174 -0
  18. package/src/adapters/mcp/session.js +274 -0
  19. package/src/adapters/mcp/toolNotes.js +426 -0
  20. package/src/adapters/mcp/toolsets.js +106 -0
  21. package/src/commands/articles.js +285 -301
  22. package/src/commands/auth.js +204 -86
  23. package/src/commands/billing.js +102 -104
  24. package/src/commands/improvements.js +319 -211
  25. package/src/commands/index.js +22 -0
  26. package/src/commands/keywords.js +266 -249
  27. package/src/commands/marketplace/buyer.js +386 -0
  28. package/src/commands/marketplace/index.js +83 -0
  29. package/src/commands/marketplace/notifications.js +60 -0
  30. package/src/commands/marketplace/seller.js +595 -0
  31. package/src/commands/marketplace/shared.js +27 -0
  32. package/src/commands/products/backlinks.js +181 -0
  33. package/src/commands/products/core.js +149 -0
  34. package/src/commands/products/gsc.js +250 -0
  35. package/src/commands/products/index.js +174 -0
  36. package/src/commands/products/integrations.js +343 -0
  37. package/src/commands/products/linking.js +192 -0
  38. package/src/commands/products/shared.js +8 -0
  39. package/src/commands/registry.js +272 -0
  40. package/src/commands/subscription.js +20 -30
  41. package/src/commands/usage.js +17 -30
  42. package/src/core/config.js +243 -62
  43. package/src/core/constants.js +101 -0
  44. package/src/core/context.js +311 -0
  45. package/src/core/errors.js +39 -0
  46. package/src/core/execute.js +71 -0
  47. package/src/core/files.js +186 -0
  48. package/src/core/http.js +393 -0
  49. package/src/core/profileSelection.js +7 -0
  50. package/src/core/query.js +11 -0
  51. package/src/core/redact.js +27 -0
  52. package/src/core/schema.js +222 -0
  53. package/src/core/version.js +12 -0
  54. package/src/index.js +30 -58
  55. package/src/commands/marketplace.js +0 -740
  56. package/src/commands/products.js +0 -1185
  57. package/src/core/api.js +0 -82
  58. package/src/core/args.js +0 -144
@@ -0,0 +1,426 @@
1
+ const ARTICLE_TYPE_FIELDS =
2
+ 'article_type (guide | listicle), article_subtype (guide: howTo | explainer | comparison | reference; listicle: ' +
3
+ 'roundup | resources | examples), expected_length (auto | short | medium | long | comprehensive)';
4
+ const REWRITE_BRIEF_BODY =
5
+ 'Fields: rewrite_brief { summary (required), weakSections [{ heading, reason, suggestedChange }], sectionsToAdd ' +
6
+ '[{ heading, rationale }], sectionsToKeepAsIs [], keywordsToIncorporate [], titleSuggestion, ' +
7
+ 'metaDescriptionSuggestion, globalNotes [] }. The brief is replaced as a whole: send every part you want to keep.';
8
+ const REVIEW_BODY =
9
+ 'Fields: decisions { <hunkId>: kept | rejected }, edited_hunks { <hunkId>: replacement text }, edited_title, ' +
10
+ 'edited_meta_description. Hunk ids come from improvements_executions_get.';
11
+ const DATE_HINT = 'YYYY-MM-DD or ISO-8601.';
12
+ const ISO_HINT = 'ISO-8601 date or timestamp, for example 2026-06-01T09:00:00Z.';
13
+ const BACKLINK_STATUS_HINT = 'created | verified | not_verified | refunded | lost | recovering.';
14
+ const DESC_ONLY_HINT = 'Only desc is accepted.';
15
+ const COUNTRY_HINT = 'Two-letter country code of the payout bank account, for example US.';
16
+ const WEBFLOW_TOKEN_BODY = 'Fields: api_token (required)';
17
+ const FRAMER_BODY = 'Fields: project_url (https://framer.com/projects/<id>, required), server_api_key (required)';
18
+ const ADMIN_NOTE = 'Works only when the user who created the API key is an organization admin.';
19
+ const BACKLINK_SUBSCRIPTION_NOTE = 'Needs an active backlink subscription.';
20
+
21
+ export const TOOL_NOTES = Object.freeze({
22
+ 'auth.whoami': {
23
+ note:
24
+ 'Returns the user, the organization (id, credits, product_count = purchased product slots) and entitlements ' +
25
+ '(the toolsets this organization can use). Calling it also refreshes the tool list of this server.',
26
+ },
27
+ 'auth.status': { note: 'Local check: makes no request and never shows the key.' },
28
+ 'billing.portal-url': { note: 'Give the returned link to the user; they manage billing in the browser.' },
29
+ 'billing.purchase-products.quote': {
30
+ note: 'Always call this before billing_purchase_products_buy and show the quoted recurring total to the user.',
31
+ fields: {
32
+ additionalProducts: 'How many product slots to add.',
33
+ idempotencyKey: 'Optional; one is generated and returned when omitted.',
34
+ },
35
+ },
36
+ 'billing.purchase-products.buy': {
37
+ note:
38
+ 'Charges the stored card with no browser step. If the call times out or fails in transit, retry with exactly ' +
39
+ 'the same idempotency_key: a new key can charge the user twice.',
40
+ fields: {
41
+ additionalProducts: 'Same number that was quoted.',
42
+ confirmChargeCents: 'The recurring total in cents from the quote. Never guess it.',
43
+ idempotencyKey: 'A unique key you create once for this purchase and reuse on every retry.',
44
+ },
45
+ },
46
+ 'keywords.list': {
47
+ note: 'Scheduled keywords are the content plan; stored keywords are unscheduled inventory.',
48
+ fields: {
49
+ scope: 'scheduled (default) | stored | all.',
50
+ status: 'published | scheduled | generating_article | pregenerated | disabled.',
51
+ q: 'Text search in the keyword.',
52
+ scheduledFrom: DATE_HINT,
53
+ scheduledTo: DATE_HINT,
54
+ },
55
+ },
56
+ 'keywords.get': { fields: { kind: 'scheduled | stored. Omit to look in both.' } },
57
+ 'keywords.export': { note: 'Returns the whole plan in one response, which can be large.' },
58
+ 'keywords.add': {
59
+ note:
60
+ 'Keywords already on the plan come back under existing, and ones that could not be scheduled under skipped ' +
61
+ 'with a reason: report both.',
62
+ body:
63
+ 'Fields: items [keyword text, or { keyword, scheduled_for?, search_volume?, difficulty?, article_type?, ' +
64
+ 'article_subtype?, quality_questions? }] and/or stored_keyword_ids [] to promote stored keywords. Items ' +
65
+ 'without scheduled_for take the next free slot. product_id is taken from the product_id argument and ' +
66
+ 'replaces the same key in the body.',
67
+ },
68
+ 'keywords.update': {
69
+ note: 'Only scheduled keywords without a created article can be changed.',
70
+ body:
71
+ `Fields (at least one): scheduled_for (${DATE_HINT} today or later, on a publish day), instructions, ` +
72
+ `quality_questions [{ question, answer?, id? }] (replaces the list), ${ARTICLE_TYPE_FIELDS}.`,
73
+ },
74
+ 'keywords.suggest': {
75
+ note: 'Returns candidates only; nothing is added to the plan until keywords_add.',
76
+ fields: {
77
+ keyword: 'Seed keyword. Pass this or theme.',
78
+ theme: 'Topic to explore. Pass this or keyword.',
79
+ keywordRegion: 'Region code such as us or uk. Defaults to the product setting.',
80
+ },
81
+ },
82
+ 'keywords.generate': {
83
+ note:
84
+ 'The same action as Generate Keywords in the dashboard: it writes scheduled keywords. Ask the user how many ' +
85
+ 'to generate, and check free slots with keywords_generation_status first.',
86
+ fields: {
87
+ count: 'How many keywords to generate.',
88
+ source: 'competitors | audiences | both.',
89
+ competitors: 'Competitor ids from products_competitors_list to limit the research to.',
90
+ audiences: 'Target audience texts to limit the research to.',
91
+ },
92
+ },
93
+ 'keywords.bulk-process': {
94
+ note: 'Looks up search volume and difficulty for a list of keywords. It adds nothing to the plan.',
95
+ body:
96
+ 'Fields: keywords [] (required). Large lists are slow. product_id and keyword_region are taken from the ' +
97
+ 'arguments and replace the same keys in the body.',
98
+ fields: { keywordRegion: 'Region code such as us or uk. Defaults to the product setting.' },
99
+ },
100
+ 'keywords.bulk-reschedule': {
101
+ body: 'Fields: items [{ id, scheduled_for }]. Failed items come back under skipped with a reason.',
102
+ },
103
+ 'keywords.delete': { note: 'Deletes a scheduled keyword for good. Stored keywords cannot be deleted.' },
104
+ 'keywords.bulk-delete': {
105
+ body: 'Fields: items [{ id, kind? (scheduled | stored) }]. Stored keywords are skipped, never deleted.',
106
+ },
107
+ 'articles.list': {
108
+ fields: {
109
+ productId: 'Limit the list to one product.',
110
+ status: 'generated | approved | published | deleted | failed.',
111
+ sortBy: 'created_at (default) | status | title.',
112
+ sortOrder: 'asc | desc (default).',
113
+ createdFrom: DATE_HINT,
114
+ createdTo: DATE_HINT,
115
+ scheduledFrom: DATE_HINT,
116
+ scheduledTo: DATE_HINT,
117
+ },
118
+ },
119
+ 'articles.get-content': {
120
+ note: 'The body can be long. Markdown is the stored form; html is a rendering of it.',
121
+ fields: { format: 'markdown (default) | html.' },
122
+ },
123
+ 'articles.update': {
124
+ note:
125
+ 'Setting status to published publishes through the product integration for real. The slug cannot be changed.',
126
+ body:
127
+ 'Fields (at least one): title, meta_description, image_url, status (generated | approved | published), ' +
128
+ `instructions, ${ARTICLE_TYPE_FIELDS}.`,
129
+ },
130
+ 'articles.update-content': {
131
+ note: 'Replaces the whole body, so read it with articles_get_content first.',
132
+ body: 'Fields: content (markdown, required).',
133
+ },
134
+ 'articles.replace': {
135
+ note: 'Replaces every occurrence, case-sensitively, and saves at once unless preview_only is true.',
136
+ body: 'Fields: find (required), replace_with (required, may be empty), preview_only (true to preview).',
137
+ },
138
+ 'articles.bulk-replace': {
139
+ note: 'Previews by default; show the preview to the user, then repeat with confirm true to write.',
140
+ body:
141
+ 'Fields: product_id (required), find (required), replace_with (required), confirm (true to write), and the ' +
142
+ 'filters status, title, content. Only the 200 newest articles are scanned.',
143
+ },
144
+ 'articles.upload-image': {
145
+ note: 'Returns a hosted URL and alt text to place in the markdown body.',
146
+ fields: { keyword: 'Keyword text for the image metadata when there is no article yet.' },
147
+ },
148
+ 'articles.retry-publish': {
149
+ note:
150
+ 'Only for a generated article with a publish error. Most integrations create a new post on every publish, so ' +
151
+ 'a repeated call can duplicate a post that did reach the site: check the site first.',
152
+ },
153
+ 'articles.delete': { note: 'Soft delete: the article is hidden, not erased.' },
154
+ 'articles.precreate': {
155
+ note: 'Generates the article of an eligible future scheduled keyword ahead of time. Uses a generation slot.',
156
+ },
157
+ 'articles.generate': {
158
+ note: 'Only for a keyword scheduled for today. Uses article quota and runs the normal create-and-publish flow.',
159
+ },
160
+ 'improvements.settings.get': { note: 'Also shows whether the CMS can receive a push (readiness).' },
161
+ 'improvements.settings.update': {
162
+ note:
163
+ 'Turning enabled on starts an unattended loop (weekly discovery, scheduling on publishing_days, execution ' +
164
+ 'about every 15 minutes), and auto_push publishes rewrites without review: get the user to agree first.',
165
+ body: 'Fields (at least one): enabled, auto_push, publishing_days (at most 2 of Mon Tue Wed Thu Fri Sat Sun).',
166
+ },
167
+ 'improvements.analyze': { note: 'Then list the results with improvements_candidates_list.' },
168
+ 'improvements.candidates.list': {
169
+ fields: { type: 'outsider | ctr_gap | declining | content_gap | thin_content | all.' },
170
+ },
171
+ 'improvements.candidates.edit-brief': { body: REWRITE_BRIEF_BODY },
172
+ 'improvements.candidates.schedule': { fields: { scheduledFor: ISO_HINT } },
173
+ 'improvements.tasks.reschedule': { fields: { scheduledFor: ISO_HINT } },
174
+ 'improvements.tasks.edit-brief': { note: 'Only for a task that is still scheduled.', body: REWRITE_BRIEF_BODY },
175
+ 'improvements.tasks.execute': {
176
+ note: 'Then poll improvements_executions_get until status is completed.',
177
+ fields: { improveImages: 'false keeps the current images. Default: images are regenerated.' },
178
+ },
179
+ 'improvements.executions.list': {
180
+ fields: { reviewStatus: 'awaiting_review | pushed | auto_pushed | rejected | all.' },
181
+ },
182
+ 'improvements.executions.get': {
183
+ note: 'Returns before and after, the changed hunks with ids, and validation issues.',
184
+ },
185
+ 'improvements.executions.decisions': {
186
+ note: 'Saves review decisions without publishing. Works only while review_status is awaiting_review.',
187
+ body: `${REVIEW_BODY} Sent maps replace the saved ones.`,
188
+ },
189
+ 'improvements.executions.push': {
190
+ note:
191
+ 'Overwrites the article in Outrank and then publishes it to the CMS; this cannot be undone. Confirm with the ' +
192
+ 'user first. CMS_PUSH_FAILED means Outrank was updated but the CMS rejected the push.',
193
+ body: `Omit it to accept every change. ${REVIEW_BODY}`,
194
+ },
195
+ 'products.list': { fields: { status: 'active | disabled.' } },
196
+ 'products.get': { note: 'Returns the General settings and services [{ service, status }] of the product.' },
197
+ 'products.create': {
198
+ note:
199
+ 'Agree the details with the user first, and quote a product slot with billing_purchase_products_quote when ' +
200
+ 'the limit is reached. Creation queues about 30 days of keywords: poll keywords_generation_status.',
201
+ body:
202
+ 'All fields are required: name (2-50 chars), url, description (50-8000 chars), target_audiences (3-7 texts), ' +
203
+ 'language, keyword_region (for example us, uk, global-us), competitors (3-7 of { name, link }).',
204
+ },
205
+ 'products.update': {
206
+ note: 'General settings only. Backlink settings belong to products_backlinks_update.',
207
+ body:
208
+ 'Fields (at least one, others are rejected): name, description, target_audiences (3-7, replaces the list), ' +
209
+ 'sitemap_url (.xml), blog_root_url, example_article_urls (max 3), auto_keyword_generation_disabled.',
210
+ },
211
+ 'products.pause': { note: 'Affects billing-related product state: relay the returned guidance to the user.' },
212
+ 'products.resume': { note: 'Affects billing-related product state: relay the returned guidance to the user.' },
213
+ 'products.output-settings.get': {
214
+ note: 'The Article settings. data._meta describes each field, its options and its limits: use it before writing.',
215
+ },
216
+ 'products.output-settings.update': {
217
+ body:
218
+ 'Fields (at least one, others are rejected): language, keyword_region, tone_and_style, image_style, ' +
219
+ 'featured_image_style, global_article_prompt, global_keyword_prompt, internal_links (0-5), brand_color ' +
220
+ '(#RRGGBB) and the booleans auto_publish, table_of_contents, is_cta_included, is_video_included, ' +
221
+ 'mention_similar_products, is_infographics_enabled, is_first_person_enabled, are_in_article_emojis_disabled, ' +
222
+ 'use_different_featured_style. Allowed option values are in products_output_settings_get data._meta.',
223
+ },
224
+ 'products.target-audiences.replace': {
225
+ body: 'Fields: items (3-7 audience texts). The list replaces the current one.',
226
+ },
227
+ 'products.competitors.replace': {
228
+ body:
229
+ 'Fields: items (3-7 of { id?, name, link, favicon_url? }). Keep the id of a competitor to keep it; a ' +
230
+ 'competitor whose id is missing from items is deleted.',
231
+ },
232
+ 'products.integrations.providers': { note: 'Shows which platforms support create, update, test and connect.' },
233
+ 'products.integrations.prepare': {
234
+ note: 'WordPress plugin only: returns the token the user pastes into the plugin, plus ready create payloads.',
235
+ fields: { provider: 'Must be wordpress-plugin.' },
236
+ body: 'Fields: domain (defaults to the product URL).',
237
+ },
238
+ 'products.integrations.connect': {
239
+ note: 'Starts a browser sign-in. Give connect_url to the user, then poll products_integrations_poll.',
240
+ fields: { provider: 'wordpress-com or notion.' },
241
+ body: 'wordpress-com needs { name, publish_status (publish | draft) }. notion takes no body.',
242
+ },
243
+ 'products.integrations.poll': { fields: { provider: 'wordpress-com or notion.' } },
244
+ 'products.integrations.notion.schema': { fields: { databaseId: 'Notion database id from the databases tool.' } },
245
+ 'products.integrations.webflow.sites': { body: `${WEBFLOW_TOKEN_BODY}.` },
246
+ 'products.integrations.webflow.collections': { body: `${WEBFLOW_TOKEN_BODY}, site_id (required).` },
247
+ 'products.integrations.webflow.fields': { body: `${WEBFLOW_TOKEN_BODY}, collection_id (required).` },
248
+ 'products.integrations.webflow.items': { body: `${WEBFLOW_TOKEN_BODY}, collection_id (required).` },
249
+ 'products.integrations.framer.collections': { body: `${FRAMER_BODY}.` },
250
+ 'products.integrations.framer.fields': { body: `${FRAMER_BODY}, collection_id (required).` },
251
+ 'products.integrations.validate': {
252
+ note:
253
+ 'Tests credentials without saving them. A failed check is ok true with data.passed false. Shopify returns the ' +
254
+ 'shop name and blogs, Webflow the sites, Framer the collections.',
255
+ fields: { provider: 'webhook, ghost, shopify, wix, framer, wordpress-plugin or webflow.' },
256
+ body:
257
+ 'By provider: webhook { endpoint, access_token }; ghost { url, api_key }; shopify { access_token }; wix ' +
258
+ '{ site_id, api_key }; framer { project_url, server_api_key }; wordpress-plugin { domain, api_key }; webflow ' +
259
+ '{ api_token }.',
260
+ },
261
+ 'products.integrations.create': {
262
+ note:
263
+ 'A product keeps one publishing integration. Validate the credentials first. nextjs-blog returns a one-time ' +
264
+ 'secrets.api_key: show it to the user at once. Use connect for notion and wordpress-com.',
265
+ body:
266
+ 'provider (required) plus: webhook { name, endpoint, access_token }; ghost { name, url, api_key, ' +
267
+ 'publish_status (draft | published) }; shopify { name, access_token, blog_id, author_name, publish_status ' +
268
+ '(draft | publish) }; wix { name, site_id, api_key, publish_status (draft | publish) }; webflow { name, ' +
269
+ 'api_token, site_id, collection_id, fields }; framer { name, project_url, server_api_key }; wordpress-plugin ' +
270
+ '{ domain, api_key }; nextjs-blog takes provider only.',
271
+ },
272
+ 'products.integrations.update': {
273
+ note: 'The integration decides which fields apply. framer, nextjs-blog and wordpress-com cannot be updated.',
274
+ body:
275
+ 'Send only the fields to change, without provider. They are the create fields of that platform; notion ' +
276
+ 'takes { name, database_id, fields }.',
277
+ },
278
+ 'products.integrations.test': { note: 'Runs a live check against the connected site.' },
279
+ 'products.integrations.delete': {
280
+ note: 'Deleting the active integration also clears the integration fields of the product.',
281
+ },
282
+ 'products.gsc.connect': { note: 'Give connect_url to the user, then poll products_gsc_poll.' },
283
+ 'products.gsc.accounts': { note: 'Lists the Google identities already connected to the organization.' },
284
+ 'products.gsc.properties': { fields: { email: 'A connected Google identity from products_gsc_accounts.' } },
285
+ 'products.gsc.set-property': {
286
+ note: 'Only a verified property can be saved.',
287
+ fields: { email: 'Required. The connected Google identity.', siteUrl: 'Required. The verified property URL.' },
288
+ },
289
+ 'products.gsc.performance': { fields: { months: 'How many months back.' } },
290
+ 'products.gsc.search-analytics': {
291
+ fields: {
292
+ dimensions: 'Comma-separated: query, page, date, country, device, searchAppearance. Default: query.',
293
+ months: 'How many months back. Use this or start_date with end_date.',
294
+ startDate: 'YYYY-MM-DD, together with end_date.',
295
+ endDate: 'YYYY-MM-DD, together with start_date.',
296
+ page: 'Filter by page URL.',
297
+ query: 'Filter by search query.',
298
+ country: 'Filter by country code.',
299
+ device: 'desktop | mobile | tablet.',
300
+ },
301
+ },
302
+ 'products.gsc.inspect-url': { fields: { url: 'Page URL to inspect.', languageCode: 'For example en-US.' } },
303
+ 'products.linking.sources.update': {
304
+ body:
305
+ 'Fields: source_type (sitemap | root_url | integration), source_url (omit it for integration; a sitemap ' +
306
+ 'must end in .xml), additional_linking_sources (max 3 of { id, source_type, source_url, purpose (linking | ' +
307
+ 'validation_only) }; replaces the list).',
308
+ },
309
+ 'products.linking.detect': { note: 'Runs discovery of internal link targets again.' },
310
+ 'products.linking.target-config.update': {
311
+ note: BACKLINK_SUBSCRIPTION_NOTE,
312
+ body: 'Fields (at least one): only_prioritized (needs 3 prioritized targets), apply_to_internal.',
313
+ },
314
+ 'products.linking.targets.add-manual': {
315
+ note: `${BACKLINK_SUBSCRIPTION_NOTE} An existing URL is left unchanged; use the upsert tool to change it.`,
316
+ body: 'Fields: url (required, on the product domain), title, is_prioritized, anchor_texts (max 10).',
317
+ },
318
+ 'products.linking.targets.upsert': {
319
+ note: BACKLINK_SUBSCRIPTION_NOTE,
320
+ body: 'Fields: url (required, on the product domain) and at least one of title, is_prioritized, anchor_texts.',
321
+ },
322
+ 'products.linking.targets.clear-customizations': { note: 'Clears the custom anchor texts; the target stays.' },
323
+ 'products.backlinks.get': { note: 'Backlink exchange settings. Marketplace placements are under marketplace_*.' },
324
+ 'products.backlinks.update': {
325
+ body: 'Fields (at least one): enabled, min_domain_rating (needs an active backlink subscription).',
326
+ },
327
+ 'products.backlinks.earned': {
328
+ fields: {
329
+ status: BACKLINK_STATUS_HINT,
330
+ sortBy: 'created_at (default) | status | credits_applied.',
331
+ sortOrder: DESC_ONLY_HINT,
332
+ },
333
+ },
334
+ 'products.backlinks.unverified': {
335
+ fields: {
336
+ status: BACKLINK_STATUS_HINT,
337
+ sortBy: 'created_at (default) | verification_error_reason.',
338
+ sortOrder: DESC_ONLY_HINT,
339
+ },
340
+ },
341
+ 'products.backlinks.subscribe-quote': {
342
+ note: 'Read-only quote. The user buys or changes the add-on in the dashboard.',
343
+ },
344
+ 'marketplace.seller.status': {
345
+ note: 'Start here for anything about selling: the guidance names the next onboarding step.',
346
+ fields: { refreshStripe: 'true to re-read the payout account from Stripe.' },
347
+ },
348
+ 'marketplace.seller.onboard': {
349
+ note:
350
+ 'Makes up to four calls: reads the status, adopts a reusable CMS integration, creates the Stripe account when ' +
351
+ 'a country is given, and reads eligibility. Report the eligibility checks to the user.',
352
+ fields: { country: COUNTRY_HINT },
353
+ },
354
+ 'marketplace.seller.eligibility': { fields: { refresh: 'true to recompute before reading.' } },
355
+ 'marketplace.seller.settings.update': {
356
+ body:
357
+ 'camelCase fields: isActive, approvalMode (true = approve each placement by hand), dailyArticleLimit (1-3), ' +
358
+ 'publishingDays (at least 3 weekday numbers, 0 = Sunday).',
359
+ },
360
+ 'marketplace.seller.integrations.claim': {
361
+ fields: { integrationType: 'Platform of the integration, for example wordpress-plugin, webflow, ghost.' },
362
+ },
363
+ 'marketplace.seller.integrations.set-activation': {
364
+ fields: {
365
+ integrationType: 'Platform of the integration.',
366
+ activationStatus: 'active | disabled.',
367
+ },
368
+ },
369
+ 'marketplace.seller.integrations.delete': { fields: { integrationType: 'Platform of the integration.' } },
370
+ 'marketplace.seller.stripe.onboard': {
371
+ note: 'Returns onboardingUrl: the user finishes identity and bank details in the browser.',
372
+ fields: { country: COUNTRY_HINT },
373
+ },
374
+ 'marketplace.seller.stripe.manage': { note: 'Give the returned link to the user.' },
375
+ 'marketplace.seller.placements.list': {
376
+ fields: {
377
+ status: 'all (default) | needs_approval | planned | live | link_not_found | lost | rejected.',
378
+ search: 'Text search.',
379
+ },
380
+ },
381
+ 'marketplace.seller.placements.approve': { note: 'Publishes at once when the scheduled date is due.' },
382
+ 'marketplace.seller.placements.decline': { note: `Refunds the buyer and removes the article. ${ADMIN_NOTE}` },
383
+ 'marketplace.seller.earnings': { note: 'Amounts are in cents.' },
384
+ 'marketplace.buyer.status': {
385
+ note: 'Start here for anything about buying placements: the guidance names the next setup step.',
386
+ },
387
+ 'marketplace.buyer.subscription': {
388
+ note: 'Its subscription id is the confirm_subscription_id that a cancellation needs.',
389
+ },
390
+ 'marketplace.buyer.checkout': {
391
+ note: `Returns a Stripe Checkout link that the user completes in the browser. ${ADMIN_NOTE}`,
392
+ fields: { tier: 'starter_1 | growth_3 | scale_8.' },
393
+ },
394
+ 'marketplace.buyer.change-plan': {
395
+ note:
396
+ 'cancel_scheduled and cancel_subscription take effect immediately: read the id with ' +
397
+ 'marketplace_buyer_subscription, have the user confirm the cancellation, then pass it as ' +
398
+ `confirm_subscription_id. ${ADMIN_NOTE}`,
399
+ fields: {
400
+ action: 'change_tier | cancel_scheduled | cancel_subscription | restore_subscription.',
401
+ tier: 'starter_1 | growth_3 | scale_8. Needed for change_tier.',
402
+ confirmSubscriptionId: 'Required for the two cancel actions. Never invent it.',
403
+ },
404
+ },
405
+ 'marketplace.buyer.target-pages.add': {
406
+ fields: { urls: 'Pages on the product domain (up to 50 per call).', source: 'manual (default) | discovered.' },
407
+ },
408
+ 'marketplace.buyer.target-pages.discover': {
409
+ fields: { source: 'sitemap (default) | root_url | integration.', sourceUrl: 'Where to look.' },
410
+ },
411
+ 'marketplace.buyer.participation.on': { note: 'Needs the plan and at least one active target page.' },
412
+ 'marketplace.buyer.placements.list': {
413
+ fields: { status: 'all (default) | live | in_progress | lost | rejected.', search: 'Text search.' },
414
+ },
415
+ 'marketplace.buyer.article.edit': {
416
+ note: 'Possible only before the article is published. Keep the placement backlink exactly as it is.',
417
+ body: 'Fields: content (the full article markdown; only the section with the backlink may change).',
418
+ },
419
+ 'marketplace.buyer.credits.history': { fields: { kind: 'all (default) | charge | refund.' } },
420
+ 'marketplace.notifications.list': {
421
+ fields: { role: 'buyer | seller.', type: 'Notification type filter.', sort: 'oldest, or omit for newest first.' },
422
+ },
423
+ 'marketplace.notifications.mark-read': {
424
+ fields: { role: 'buyer | seller.', ids: 'Notification ids. Omit to mark every unread one as read.' },
425
+ },
426
+ });
@@ -0,0 +1,106 @@
1
+ import { CommandKind, Toolset } from '../../commands/registry.js';
2
+ import {
3
+ EntitlementMode,
4
+ SelectionSource,
5
+ TOOLSETS_ALL,
6
+ TOOLSETS_AUTO,
7
+ TOOLSETS_SEPARATOR,
8
+ } from './constants.js';
9
+
10
+ export const ALL_TOOLSETS = Object.freeze(Object.values(Toolset));
11
+ const READ_KINDS = Object.freeze([CommandKind.READ, CommandKind.LOCAL]);
12
+ const ENTITLEMENT_MODES = Object.freeze(Object.values(EntitlementMode));
13
+
14
+ export const OverrideKind = Object.freeze({ AUTO: 'auto', ALL: 'all', LIST: 'list' });
15
+
16
+ export const isReadKind = (spec) => READ_KINDS.includes(spec.kind);
17
+
18
+ const KEYWORDS = Object.freeze([TOOLSETS_AUTO, TOOLSETS_ALL]);
19
+
20
+ export const parseToolsetOverride = (value) => {
21
+ const text = typeof value === 'string' ? value : '';
22
+ const names = [...new Set(text.split(TOOLSETS_SEPARATOR).map((name) => name.trim()).filter(Boolean))];
23
+ const unknown = names.filter((name) => !ALL_TOOLSETS.includes(name) && !KEYWORDS.includes(name));
24
+ const mixesKeyword = names.length > 1 && names.some((name) => KEYWORDS.includes(name));
25
+ if (names.length === 0 || (names.length === 1 && names[0] === TOOLSETS_AUTO)) {
26
+ return { kind: OverrideKind.AUTO, toolsets: [], unknown, mixesKeyword };
27
+ }
28
+ if (names.length === 1 && names[0] === TOOLSETS_ALL) {
29
+ return { kind: OverrideKind.ALL, toolsets: [...ALL_TOOLSETS], unknown, mixesKeyword };
30
+ }
31
+ const toolsets = names.filter((name) => ALL_TOOLSETS.includes(name));
32
+ return { kind: OverrideKind.LIST, toolsets, unknown, mixesKeyword };
33
+ };
34
+
35
+ const fullModes = (toolsets) => new Map(toolsets.map((toolset) => [toolset, EntitlementMode.FULL]));
36
+
37
+ const isPlainObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
38
+
39
+ const addModes = (modes, toolsets) => {
40
+ for (const [toolset, mode] of Object.entries(toolsets)) {
41
+ if (!ALL_TOOLSETS.includes(toolset) || modes.has(toolset)) continue;
42
+ modes.set(toolset, ENTITLEMENT_MODES.includes(mode) ? mode : EntitlementMode.READ);
43
+ }
44
+ };
45
+
46
+ const modesFromEntitlements = (entitlements) => {
47
+ const modes = new Map();
48
+ addModes(modes, entitlements.toolsets);
49
+ if (isPlainObject(entitlements.unverified_toolsets)) addModes(modes, entitlements.unverified_toolsets);
50
+ modes.set(Toolset.CORE, EntitlementMode.FULL);
51
+ return modes;
52
+ };
53
+
54
+ const hasToolsetMap = (entitlements) => isPlainObject(entitlements) && isPlainObject(entitlements.toolsets);
55
+
56
+ const resolveModes = ({ entitlements, override }) => {
57
+ if (override.kind === OverrideKind.ALL) return { source: SelectionSource.ALL, modes: fullModes(ALL_TOOLSETS) };
58
+ if (override.kind === OverrideKind.LIST) {
59
+ return { source: SelectionSource.OVERRIDE, modes: fullModes(override.toolsets) };
60
+ }
61
+ if (entitlements === null) return { source: SelectionSource.UNRESTRICTED, modes: fullModes(ALL_TOOLSETS) };
62
+ if (hasToolsetMap(entitlements)) {
63
+ return { source: SelectionSource.ENTITLEMENTS, modes: modesFromEntitlements(entitlements) };
64
+ }
65
+ return { source: SelectionSource.FALLBACK, modes: fullModes([Toolset.CORE]) };
66
+ };
67
+
68
+ const bestMode = (spec, modes) => {
69
+ const found = spec.toolsets.map((toolset) => modes.get(toolset)).filter(Boolean);
70
+ if (found.includes(EntitlementMode.FULL)) return EntitlementMode.FULL;
71
+ return found.includes(EntitlementMode.READ) ? EntitlementMode.READ : undefined;
72
+ };
73
+
74
+ const AUTO_SOURCES = Object.freeze([
75
+ SelectionSource.ENTITLEMENTS,
76
+ SelectionSource.UNRESTRICTED,
77
+ SelectionSource.FALLBACK,
78
+ ]);
79
+
80
+ const isVisible = (spec, modes, source) => {
81
+ const mode = bestMode(spec, modes);
82
+ if (mode === EntitlementMode.FULL) return true;
83
+ if (AUTO_SOURCES.includes(source) && spec.entryPoint) return true;
84
+ return mode === EntitlementMode.READ && isReadKind(spec);
85
+ };
86
+
87
+ export const selectTools = ({ specs, entitlements, override = parseToolsetOverride(), readOnly = false }) => {
88
+ const { source, modes } = resolveModes({ entitlements, override });
89
+ const tools = specs
90
+ .filter((spec) => spec.mcp !== false)
91
+ .filter((spec) => isVisible(spec, modes, source))
92
+ .filter((spec) => !readOnly || isReadKind(spec));
93
+ return {
94
+ tools,
95
+ source,
96
+ readOnly,
97
+ enabled: Object.fromEntries(ALL_TOOLSETS.filter((toolset) => modes.has(toolset)).map((t) => [t, modes.get(t)])),
98
+ disabledToolsets: ALL_TOOLSETS.filter((toolset) => !modes.has(toolset)),
99
+ };
100
+ };
101
+
102
+ export const isBlockedByOperator = (spec, selection) => {
103
+ if (selection.readOnly && !isReadKind(spec)) return true;
104
+ if (selection.source !== SelectionSource.OVERRIDE) return false;
105
+ return !spec.toolsets.some((toolset) => Object.hasOwn(selection.enabled, toolset));
106
+ };