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
@@ -1,14 +1,18 @@
1
1
  import {
2
- parseArgs,
3
- getRequiredPositional,
4
- readJsonInput,
5
- hasFlag,
6
- parseIntegerOption,
7
- getOption,
8
- getOptions,
9
- } from '../core/args.js';
10
- import { requestAgentApi } from '../core/api.js';
11
- import { cliSuccess } from '../core/errors.js';
2
+ KEYWORDS_GENERATE_MAX_COUNT,
3
+ KEYWORDS_GENERATE_MIN_COUNT,
4
+ KEYWORDS_SUGGEST_MAX_LIMIT,
5
+ LIST_MAX_LIMIT,
6
+ LIST_MIN_LIMIT,
7
+ MAX_DIFFICULTY,
8
+ MIN_DIFFICULTY,
9
+ MIN_OFFSET,
10
+ MIN_SEARCH_VOLUME,
11
+ Surface,
12
+ } from '../core/constants.js';
13
+ import { MCP_USAGE_GUIDANCE, usageError } from '../core/errors.js';
14
+ import { int, list, oneOf, string, uuid } from '../core/schema.js';
15
+ import { BodyMode, CommandKind, Toolset, defineCommand, defineGroup } from './registry.js';
12
16
 
13
17
  export const KEYWORDS_HELP = `Usage:
14
18
  outrank-cli keywords list --product <product-id> [--scope scheduled|stored|all] [--status published|scheduled|generating_article|pregenerated|disabled] [--q <text>] [--article-type <value>] [--article-subtype <value>] [--scheduled-from <date>] [--scheduled-to <date>] [--search-volume-min <n>] [--search-volume-max <n>] [--difficulty-min <n>] [--difficulty-max <n>] [--limit <n>] [--offset <n>]
@@ -43,262 +47,275 @@ Examples:
43
47
  outrank-cli keywords delete 00000000-0000-0000-0000-000000000000
44
48
  `;
45
49
 
46
- const buildQueryPath = (basePath, searchParams) => {
47
- const url = new URL(`https://local${basePath}`);
48
- for (const [key, value] of Object.entries(searchParams)) {
49
- if (value !== undefined && value !== null && value !== '') {
50
- url.searchParams.set(key, String(value));
51
- }
52
- }
53
- return `${url.pathname}${url.search}`;
54
- };
55
-
56
- const getRequiredProductOption = (parsed) => {
57
- const productId = getOption(parsed, 'product');
58
- if (!productId) {
59
- throw Object.assign(new Error('Missing required option: --product <product-id>.'), {
60
- cliCode: 'CLI_INVALID_USAGE',
61
- guidance: 'Pass the target product UUID with `--product <product-id>`. Use `outrank-cli products list` if you need to look it up first.',
62
- });
63
- }
64
- return productId;
65
- };
66
-
67
- const handleKeywordsList = async (parsed) => {
68
- const path = buildQueryPath('/keywords', {
69
- product_id: getRequiredProductOption(parsed),
70
- scope: getOption(parsed, 'scope'),
71
- status: getOption(parsed, 'status'),
72
- q: getOption(parsed, 'q'),
73
- article_type: getOption(parsed, 'article-type'),
74
- article_subtype: getOption(parsed, 'article-subtype'),
75
- scheduled_from: getOption(parsed, 'scheduled-from'),
76
- scheduled_to: getOption(parsed, 'scheduled-to'),
77
- search_volume_min: parseIntegerOption(parsed, 'search-volume-min', { min: 0 }),
78
- search_volume_max: parseIntegerOption(parsed, 'search-volume-max', { min: 0 }),
79
- difficulty_min: parseIntegerOption(parsed, 'difficulty-min', { min: 0, max: 100 }),
80
- difficulty_max: parseIntegerOption(parsed, 'difficulty-max', { min: 0, max: 100 }),
81
- limit: parseIntegerOption(parsed, 'limit', { min: 1, max: 100 }),
82
- offset: parseIntegerOption(parsed, 'offset', { min: 0 }),
83
- });
84
- const response = await requestAgentApi(parsed, {
85
- method: 'GET',
86
- path,
87
- });
88
- return response.payload;
89
- };
90
-
91
- const handleKeywordsGet = async (parsed) => {
92
- const keywordId = getRequiredPositional(parsed, 0, '<keyword-id>');
93
- const path = buildQueryPath(`/keywords/${keywordId}`, {
94
- kind: getOption(parsed, 'kind'),
95
- });
96
- const response = await requestAgentApi(parsed, {
97
- method: 'GET',
98
- path,
99
- });
100
- return response.payload;
101
- };
50
+ const GenerateSource = Object.freeze({ COMPETITORS: 'competitors', AUDIENCES: 'audiences', BOTH: 'both' });
51
+ const GENERATE_SOURCES = Object.values(GenerateSource);
52
+ const KEYWORD_PATH = '/keywords/{keywordId}';
53
+ const KEYWORD_ID_CLI = { positionals: ['keywordId'] };
54
+ const TOOLSETS = [Toolset.KEYWORDS];
102
55
 
103
- const handleKeywordsExport = async (parsed) => {
104
- const path = buildQueryPath('/keywords/export', {
105
- product_id: getRequiredProductOption(parsed),
106
- });
107
- const response = await requestAgentApi(parsed, {
108
- method: 'GET',
109
- path,
110
- });
111
- return response.payload;
56
+ const PRODUCT_REQUIRED = {
57
+ guidance:
58
+ 'Pass the target product UUID with `--product <product-id>`. Use `outrank-cli products list` if you need to ' +
59
+ 'look it up first.',
112
60
  };
113
-
114
- const handleKeywordsGenerationStatus = async (parsed) => {
115
- const path = buildQueryPath('/keywords/generation-status', {
116
- product_id: getRequiredProductOption(parsed),
117
- });
118
- const response = await requestAgentApi(parsed, {
119
- method: 'GET',
120
- path,
121
- });
122
- return response.payload;
61
+ const COUNT_REQUIRED = {
62
+ guidance:
63
+ 'Ask the human how many keywords to generate first. The dashboard-equivalent flow allows 5-30 keywords when ' +
64
+ 'enough scheduler slots are free. Use `outrank-cli keywords generation-status --product <product-id>` to ' +
65
+ 'inspect current slot capacity.',
123
66
  };
124
-
125
- const handleKeywordsAdd = async (parsed) => {
126
- const body = await readJsonInput(parsed);
127
- body.product_id = getRequiredProductOption(parsed);
128
- const response = await requestAgentApi(parsed, {
129
- method: 'POST',
130
- path: '/keywords',
131
- body,
132
- });
133
- return response.payload;
67
+ const SOURCE_GUIDANCE =
68
+ 'Choose a dashboard generation source with `--source competitors`, `--source audiences`, or `--source both`.';
69
+ const LEGACY_GENERATE_OPTION = {
70
+ message: 'The generate command no longer accepts --theme or --country.',
71
+ guidance:
72
+ 'Use `--count`, `--source`, repeated `--competitor`, and repeated `--audience` to match the dashboard ' +
73
+ 'Generate Keywords flow.',
134
74
  };
75
+ const SUGGEST_TERM_GUIDANCE = 'Pass either `--keyword <text>` or `--theme <text>` when requesting keyword suggestions.';
135
76
 
136
- const handleKeywordsUpdate = async (parsed) => {
137
- const keywordId = getRequiredPositional(parsed, 0, '<keyword-id>');
138
- const body = await readJsonInput(parsed);
139
- const response = await requestAgentApi(parsed, {
140
- method: 'PATCH',
141
- path: `/keywords/${keywordId}`,
142
- body,
143
- });
144
- return response.payload;
145
- };
146
-
147
- const handleKeywordsSuggest = async (parsed) => {
148
- const keyword = getOption(parsed, 'keyword');
149
- const theme = getOption(parsed, 'theme');
150
- if (!keyword && !theme) {
151
- throw Object.assign(new Error('Missing required option: --keyword <text> or --theme <text>.'), {
152
- cliCode: 'CLI_INVALID_USAGE',
153
- guidance: 'Pass either `--keyword <text>` or `--theme <text>` when requesting keyword suggestions.',
154
- });
155
- }
156
-
157
- const body = {
158
- product_id: getRequiredProductOption(parsed),
159
- ...(keyword ? { keyword } : {}),
160
- ...(theme ? { theme } : {}),
161
- ...(getOption(parsed, 'keyword-region') ? { keyword_region: getOption(parsed, 'keyword-region') } : {}),
162
- ...(parseIntegerOption(parsed, 'limit', { min: 1, max: 20 }) !== undefined
163
- ? { limit: parseIntegerOption(parsed, 'limit', { min: 1, max: 20 }) }
164
- : {}),
165
- };
166
-
167
- const response = await requestAgentApi(parsed, {
168
- method: 'POST',
169
- path: '/keywords/suggest',
170
- body,
171
- });
172
- return response.payload;
173
- };
77
+ const productOption = () => uuid().required(PRODUCT_REQUIRED);
78
+ const productCli = { options: { product: 'productId' }, placeholders: { productId: '<product-id>' } };
174
79
 
175
- const handleKeywordsGenerate = async (parsed) => {
176
- if (getOption(parsed, 'theme') || getOption(parsed, 'country')) {
177
- throw Object.assign(
178
- new Error('The generate command no longer accepts --theme or --country.'),
179
- {
180
- cliCode: 'CLI_INVALID_USAGE',
181
- guidance:
182
- 'Use `--count`, `--source`, repeated `--competitor`, and repeated `--audience` to match the dashboard Generate Keywords flow.',
80
+ const listCommand = defineCommand({
81
+ id: 'keywords.list',
82
+ summary: 'List keywords for a product with optional filters and pagination.',
83
+ kind: CommandKind.READ,
84
+ toolsets: TOOLSETS,
85
+ input: {
86
+ productId: productOption(),
87
+ scope: string(),
88
+ status: string(),
89
+ q: string(),
90
+ articleType: string(),
91
+ articleSubtype: string(),
92
+ scheduledFrom: string(),
93
+ scheduledTo: string(),
94
+ searchVolumeMin: int({ min: MIN_SEARCH_VOLUME }),
95
+ searchVolumeMax: int({ min: MIN_SEARCH_VOLUME }),
96
+ difficultyMin: int({ min: MIN_DIFFICULTY, max: MAX_DIFFICULTY }),
97
+ difficultyMax: int({ min: MIN_DIFFICULTY, max: MAX_DIFFICULTY }),
98
+ limit: int({ min: LIST_MIN_LIMIT, max: LIST_MAX_LIMIT }),
99
+ offset: int({ min: MIN_OFFSET }),
100
+ },
101
+ cli: productCli,
102
+ run: (input, ctx) =>
103
+ ctx.http({
104
+ method: 'GET',
105
+ path: '/keywords',
106
+ query: {
107
+ product_id: input.productId,
108
+ scope: input.scope,
109
+ status: input.status,
110
+ q: input.q,
111
+ article_type: input.articleType,
112
+ article_subtype: input.articleSubtype,
113
+ scheduled_from: input.scheduledFrom,
114
+ scheduled_to: input.scheduledTo,
115
+ search_volume_min: input.searchVolumeMin,
116
+ search_volume_max: input.searchVolumeMax,
117
+ difficulty_min: input.difficultyMin,
118
+ difficulty_max: input.difficultyMax,
119
+ limit: input.limit,
120
+ offset: input.offset,
183
121
  },
184
- );
185
- }
186
-
187
- const source = getOption(parsed, 'source') || 'both';
188
- if (!['competitors', 'audiences', 'both'].includes(source)) {
189
- throw Object.assign(new Error('--source must be one of: competitors, audiences, both.'), {
190
- cliCode: 'CLI_INVALID_USAGE',
191
- guidance:
192
- 'Choose a dashboard generation source with `--source competitors`, `--source audiences`, or `--source both`.',
193
- });
194
- }
122
+ }),
123
+ });
195
124
 
196
- const competitors = getOptions(parsed, 'competitor');
197
- const audiences = getOptions(parsed, 'audience');
198
- const count = parseIntegerOption(parsed, 'count', { min: 5, max: 30 });
125
+ const getCommand = defineCommand({
126
+ id: 'keywords.get',
127
+ summary: 'Get one keyword by id.',
128
+ kind: CommandKind.READ,
129
+ toolsets: TOOLSETS,
130
+ input: { keywordId: uuid().required(), kind: string() },
131
+ cli: KEYWORD_ID_CLI,
132
+ run: (input, ctx) =>
133
+ ctx.http({
134
+ method: 'GET',
135
+ path: KEYWORD_PATH,
136
+ pathParams: { keywordId: input.keywordId },
137
+ query: { kind: input.kind },
138
+ }),
139
+ });
199
140
 
200
- if (count === undefined) {
201
- throw Object.assign(new Error('Missing required option: --count <n>.'), {
202
- cliCode: 'CLI_INVALID_USAGE',
203
- guidance:
204
- 'Ask the human how many keywords to generate first. The dashboard-equivalent flow allows 5-30 keywords when enough scheduler slots are free. Use `outrank-cli keywords generation-status --product <product-id>` to inspect current slot capacity.',
205
- });
206
- }
141
+ const exportCommand = defineCommand({
142
+ id: 'keywords.export',
143
+ summary: 'Export the keyword plan for a product.',
144
+ kind: CommandKind.READ,
145
+ toolsets: TOOLSETS,
146
+ input: { productId: productOption() },
147
+ cli: productCli,
148
+ run: (input, ctx) => ctx.http({ method: 'GET', path: '/keywords/export', query: { product_id: input.productId } }),
149
+ });
207
150
 
208
- const body = {
209
- product_id: getRequiredProductOption(parsed),
210
- keywords_count: count,
211
- filters: {
212
- isGenerateByCompetitors: source === 'competitors' || source === 'both',
213
- isGenerateByAudiences: source === 'audiences' || source === 'both',
214
- ...(competitors.length > 0 ? { competitors } : {}),
215
- ...(audiences.length > 0 ? { target_audiences: audiences } : {}),
216
- },
217
- };
218
-
219
- const response = await requestAgentApi(parsed, {
220
- method: 'POST',
221
- path: '/keywords/generate',
222
- body,
223
- });
224
- return response.payload;
225
- };
151
+ const generationStatusCommand = defineCommand({
152
+ id: 'keywords.generation-status',
153
+ summary: 'Show keyword generation status and scheduler slot capacity for a product.',
154
+ kind: CommandKind.READ,
155
+ toolsets: TOOLSETS,
156
+ input: { productId: productOption() },
157
+ cli: productCli,
158
+ run: (input, ctx) =>
159
+ ctx.http({ method: 'GET', path: '/keywords/generation-status', query: { product_id: input.productId } }),
160
+ });
226
161
 
227
- const handleKeywordsBulkProcess = async (parsed) => {
228
- const body = await readJsonInput(parsed);
229
- body.product_id = getRequiredProductOption(parsed);
162
+ const addCommand = defineCommand({
163
+ id: 'keywords.add',
164
+ summary: 'Add a keyword to a product from a JSON body.',
165
+ kind: CommandKind.WRITE,
166
+ toolsets: TOOLSETS,
167
+ input: { productId: productOption() },
168
+ cli: productCli,
169
+ body: BodyMode.JSON,
170
+ bodyMerge: { product_id: 'productId' },
171
+ run: (input, ctx) => ctx.http({ method: 'POST', path: '/keywords', body: input.body }),
172
+ });
230
173
 
231
- const keywordRegion = getOption(parsed, 'keyword-region');
232
- if (keywordRegion) {
233
- body.keyword_region = keywordRegion;
234
- }
174
+ const updateCommand = defineCommand({
175
+ id: 'keywords.update',
176
+ summary: 'Update a keyword (instructions, schedule, quality questions) from a JSON body.',
177
+ kind: CommandKind.WRITE,
178
+ toolsets: TOOLSETS,
179
+ input: { keywordId: uuid().required() },
180
+ cli: KEYWORD_ID_CLI,
181
+ body: BodyMode.JSON,
182
+ run: (input, ctx) =>
183
+ ctx.http({ method: 'PATCH', path: KEYWORD_PATH, pathParams: { keywordId: input.keywordId }, body: input.body }),
184
+ });
235
185
 
236
- const response = await requestAgentApi(parsed, {
237
- method: 'POST',
238
- path: '/keywords/bulk-process',
239
- body,
240
- });
241
- return response.payload;
242
- };
243
-
244
- const handleKeywordsBulkReschedule = async (parsed) => {
245
- const body = await readJsonInput(parsed);
246
- const response = await requestAgentApi(parsed, {
247
- method: 'POST',
248
- path: '/keywords/bulk-reschedule',
249
- body,
250
- });
251
- return response.payload;
252
- };
186
+ const suggestCommand = defineCommand({
187
+ id: 'keywords.suggest',
188
+ summary: 'Suggest keywords for a product from a seed keyword or a theme.',
189
+ kind: CommandKind.READ,
190
+ toolsets: TOOLSETS,
191
+ input: {
192
+ keyword: string(),
193
+ theme: string(),
194
+ productId: productOption(),
195
+ keywordRegion: string(),
196
+ limit: int({ min: LIST_MIN_LIMIT, max: KEYWORDS_SUGGEST_MAX_LIMIT }),
197
+ },
198
+ cli: productCli,
199
+ refine: (input, ctx) => {
200
+ if (input.keyword || input.theme) return;
201
+ if (ctx.surface === Surface.MCP) {
202
+ throw usageError('Missing required argument: keyword or theme.', MCP_USAGE_GUIDANCE);
203
+ }
204
+ throw usageError('Missing required option: --keyword <text> or --theme <text>.', SUGGEST_TERM_GUIDANCE);
205
+ },
206
+ run: (input, ctx) =>
207
+ ctx.http({
208
+ method: 'POST',
209
+ path: '/keywords/suggest',
210
+ body: {
211
+ product_id: input.productId,
212
+ ...(input.keyword ? { keyword: input.keyword } : {}),
213
+ ...(input.theme ? { theme: input.theme } : {}),
214
+ ...(input.keywordRegion ? { keyword_region: input.keywordRegion } : {}),
215
+ ...(input.limit !== undefined ? { limit: input.limit } : {}),
216
+ },
217
+ }),
218
+ });
253
219
 
254
- const handleKeywordsDelete = async (parsed) => {
255
- const keywordId = getRequiredPositional(parsed, 0, '<keyword-id>');
256
- const response = await requestAgentApi(parsed, {
257
- method: 'DELETE',
258
- path: `/keywords/${keywordId}`,
259
- });
260
- return response.payload;
261
- };
220
+ const generateCommand = defineCommand({
221
+ id: 'keywords.generate',
222
+ summary: 'Generate keywords for a product from competitors and/or target audiences.',
223
+ kind: CommandKind.WRITE,
224
+ toolsets: TOOLSETS,
225
+ input: {
226
+ source: oneOf(GENERATE_SOURCES).default(GenerateSource.BOTH).guidance(SOURCE_GUIDANCE),
227
+ count: int({ min: KEYWORDS_GENERATE_MIN_COUNT, max: KEYWORDS_GENERATE_MAX_COUNT }).required(COUNT_REQUIRED),
228
+ competitors: list({ split: null }),
229
+ audiences: list({ split: null }),
230
+ productId: productOption(),
231
+ },
232
+ cli: {
233
+ options: { product: 'productId', competitor: 'competitors', audience: 'audiences' },
234
+ placeholders: { productId: '<product-id>', count: '<n>' },
235
+ rejectedOptions: { theme: LEGACY_GENERATE_OPTION, country: LEGACY_GENERATE_OPTION },
236
+ },
237
+ longRunning: { pollTool: 'keywords.generation-status' },
238
+ run: (input, ctx) =>
239
+ ctx.http({
240
+ method: 'POST',
241
+ path: '/keywords/generate',
242
+ body: {
243
+ product_id: input.productId,
244
+ keywords_count: input.count,
245
+ filters: {
246
+ isGenerateByCompetitors: input.source !== GenerateSource.AUDIENCES,
247
+ isGenerateByAudiences: input.source !== GenerateSource.COMPETITORS,
248
+ ...(input.competitors?.length ? { competitors: input.competitors } : {}),
249
+ ...(input.audiences?.length ? { target_audiences: input.audiences } : {}),
250
+ },
251
+ },
252
+ }),
253
+ });
262
254
 
263
- const handleKeywordsBulkDelete = async (parsed) => {
264
- const body = await readJsonInput(parsed);
265
- const response = await requestAgentApi(parsed, {
266
- method: 'POST',
267
- path: '/keywords/bulk-delete',
268
- body,
269
- });
270
- return response.payload;
271
- };
255
+ const bulkProcessCommand = defineCommand({
256
+ id: 'keywords.bulk-process',
257
+ summary: 'Bulk-process a list of keywords for a product from a JSON body.',
258
+ kind: CommandKind.READ,
259
+ toolsets: TOOLSETS,
260
+ input: { productId: productOption(), keywordRegion: string() },
261
+ cli: productCli,
262
+ body: BodyMode.JSON,
263
+ bodyMerge: { product_id: 'productId', keyword_region: 'keywordRegion' },
264
+ run: (input, ctx) => ctx.http({ method: 'POST', path: '/keywords/bulk-process', body: input.body }),
265
+ });
272
266
 
273
- export const handleKeywordsCommand = async (argv) => {
274
- const subcommand = argv[0];
275
- const parsed = parseArgs(argv.slice(1));
276
- const wantsHelp = !subcommand || subcommand === '--help' || subcommand === '-h' || subcommand === 'help' || hasFlag(parsed, 'help');
267
+ const bulkRescheduleCommand = defineCommand({
268
+ id: 'keywords.bulk-reschedule',
269
+ summary: 'Reschedule several keywords from a JSON body.',
270
+ kind: CommandKind.WRITE,
271
+ toolsets: TOOLSETS,
272
+ body: BodyMode.JSON,
273
+ run: (input, ctx) => ctx.http({ method: 'POST', path: '/keywords/bulk-reschedule', body: input.body }),
274
+ });
277
275
 
278
- if (wantsHelp) {
279
- return cliSuccess(
280
- {
281
- usage: KEYWORDS_HELP.trim(),
282
- },
283
- 'Choose one of: list, get, export, generation-status, add, update, suggest, generate, bulk-process, bulk-reschedule, delete, bulk-delete.',
284
- );
285
- }
276
+ const deleteCommand = defineCommand({
277
+ id: 'keywords.delete',
278
+ summary: 'Delete one keyword.',
279
+ kind: CommandKind.DESTRUCTIVE,
280
+ toolsets: TOOLSETS,
281
+ input: { keywordId: uuid().required() },
282
+ cli: KEYWORD_ID_CLI,
283
+ run: (input, ctx) => ctx.http({ method: 'DELETE', path: KEYWORD_PATH, pathParams: { keywordId: input.keywordId } }),
284
+ });
286
285
 
287
- if (subcommand === 'list') return handleKeywordsList(parsed);
288
- if (subcommand === 'get') return handleKeywordsGet(parsed);
289
- if (subcommand === 'export') return handleKeywordsExport(parsed);
290
- if (subcommand === 'generation-status') return handleKeywordsGenerationStatus(parsed);
291
- if (subcommand === 'add') return handleKeywordsAdd(parsed);
292
- if (subcommand === 'update') return handleKeywordsUpdate(parsed);
293
- if (subcommand === 'suggest') return handleKeywordsSuggest(parsed);
294
- if (subcommand === 'generate') return handleKeywordsGenerate(parsed);
295
- if (subcommand === 'bulk-process') return handleKeywordsBulkProcess(parsed);
296
- if (subcommand === 'bulk-reschedule') return handleKeywordsBulkReschedule(parsed);
297
- if (subcommand === 'delete') return handleKeywordsDelete(parsed);
298
- if (subcommand === 'bulk-delete') return handleKeywordsBulkDelete(parsed);
286
+ const bulkDeleteCommand = defineCommand({
287
+ id: 'keywords.bulk-delete',
288
+ summary: 'Delete several keywords from a JSON body of ids.',
289
+ kind: CommandKind.DESTRUCTIVE,
290
+ toolsets: TOOLSETS,
291
+ body: BodyMode.JSON,
292
+ run: (input, ctx) => ctx.http({ method: 'POST', path: '/keywords/bulk-delete', body: input.body }),
293
+ });
299
294
 
300
- throw Object.assign(new Error(`Unknown keywords subcommand: ${subcommand}.`), {
301
- cliCode: 'CLI_INVALID_USAGE',
295
+ export const keywordsGroup = defineGroup({
296
+ id: 'keywords',
297
+ help: {
298
+ usage: KEYWORDS_HELP,
299
+ guidance:
300
+ 'Choose one of: list, get, export, generation-status, add, update, suggest, generate, bulk-process, ' +
301
+ 'bulk-reschedule, delete, bulk-delete.',
302
+ },
303
+ unknown: (word) => ({
304
+ message: `Unknown keywords subcommand: ${word}.`,
302
305
  guidance: 'Run `outrank-cli keywords --help` to see available keyword commands.',
303
- });
304
- };
306
+ }),
307
+ children: [
308
+ listCommand,
309
+ getCommand,
310
+ exportCommand,
311
+ generationStatusCommand,
312
+ addCommand,
313
+ updateCommand,
314
+ suggestCommand,
315
+ generateCommand,
316
+ bulkProcessCommand,
317
+ bulkRescheduleCommand,
318
+ deleteCommand,
319
+ bulkDeleteCommand,
320
+ ],
321
+ });