outrank-cli 0.8.0 → 0.10.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.
- package/README.md +902 -874
- package/SKILL.md +16 -7
- package/package.json +2 -1
- package/src/adapters/cli/run.js +12 -5
- package/src/adapters/mcp/descriptions.js +8 -0
- package/src/adapters/mcp/input.js +8 -1
- package/src/adapters/mcp/instructions.js +13 -9
- package/src/adapters/mcp/remote.js +40 -0
- package/src/adapters/mcp/server.js +11 -3
- package/src/adapters/mcp/session.js +15 -8
- package/src/adapters/mcp/toolNotes.js +68 -7
- package/src/commands/articles.js +91 -4
- package/src/commands/products/blog.js +114 -16
- package/src/core/constants.js +3 -0
- package/src/core/files.js +37 -1
- package/src/core/redact.js +1 -1
- package/src/core/schema.js +7 -0
package/src/commands/articles.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { LIST_MAX_LIMIT, LIST_MIN_LIMIT, MIN_OFFSET, Surface } from '../core/constants.js';
|
|
2
2
|
import { MCP_USAGE_GUIDANCE, usageError } from '../core/errors.js';
|
|
3
|
-
import { image, int, object, oneOf, string, uuid } from '../core/schema.js';
|
|
3
|
+
import { bool, image, int, list, object, oneOf, string, textFile, uuid } from '../core/schema.js';
|
|
4
4
|
import { BodyMode, CommandKind, Toolset, defineCommand, defineGroup } from './registry.js';
|
|
5
5
|
|
|
6
6
|
const ARTICLE_UPDATE_STATUSES = ['generated', 'approved', 'published'];
|
|
@@ -10,7 +10,7 @@ const ARTICLE_EXPECTED_LENGTHS = ['auto', 'short', 'medium', 'long', 'comprehens
|
|
|
10
10
|
const ARTICLE_TITLE_HINT = 'At most 300 characters.';
|
|
11
11
|
const META_DESCRIPTION_HINT = 'Trimmed to 160 characters.';
|
|
12
12
|
const IMAGE_URL_HINT = 'Absolute http(s) URL.';
|
|
13
|
-
const ARTICLE_STATUS_HINT = '
|
|
13
|
+
const ARTICLE_STATUS_HINT = 'Outrank status only; publish to the CMS with articles publish.';
|
|
14
14
|
const ARTICLE_INSTRUCTIONS_HINT = 'Per-article instructions of the linked scheduled keyword.';
|
|
15
15
|
const ARTICLE_SUBTYPE_HINT =
|
|
16
16
|
'guide: howTo | explainer | comparison | reference; listicle: roundup | resources | examples.';
|
|
@@ -24,9 +24,21 @@ const ARTICLE_UPDATE_BODY = object().shape({
|
|
|
24
24
|
article_subtype: oneOf(ARTICLE_SUBTYPES).describe(ARTICLE_SUBTYPE_HINT),
|
|
25
25
|
expected_length: oneOf(ARTICLE_EXPECTED_LENGTHS),
|
|
26
26
|
});
|
|
27
|
+
const ARTICLE_CREATE_BODY = object().shape({
|
|
28
|
+
product_id: uuid(),
|
|
29
|
+
title: string().describe(ARTICLE_TITLE_HINT),
|
|
30
|
+
content: string().describe('The full article body as markdown. Do not repeat the title as a # heading.'),
|
|
31
|
+
content_html: string().describe('HTML body instead of content; converted to markdown.'),
|
|
32
|
+
meta_description: string().describe('At most 160 characters.'),
|
|
33
|
+
slug: string().describe('Lowercase letters, numbers and hyphens; defaults to the title.'),
|
|
34
|
+
image_url: string().describe(IMAGE_URL_HINT),
|
|
35
|
+
tags: list(),
|
|
36
|
+
publish: bool().describe("Also publish it to the product's connected CMS now."),
|
|
37
|
+
});
|
|
27
38
|
|
|
28
39
|
export const ARTICLES_HELP = `Usage:
|
|
29
40
|
outrank-cli articles list [--product <product-id>] [--status generated|approved|published|failed|deleted] [--title <text>] [--keyword <text>] [--content <text>] [--slug <text>] [--created-from <date>] [--created-to <date>] [--scheduled-from <date>] [--scheduled-to <date>] [--sort-by created_at|status|title] [--sort-order asc|desc] [--limit <n>] [--offset <n>]
|
|
41
|
+
outrank-cli articles create --product <product-id> --title <title> --content-file <path> [--publish] [--json <object>]
|
|
30
42
|
outrank-cli articles get <article-id>
|
|
31
43
|
outrank-cli articles get-content <article-id> [--format markdown|html]
|
|
32
44
|
outrank-cli articles update <article-id> --json <object>
|
|
@@ -35,6 +47,8 @@ export const ARTICLES_HELP = `Usage:
|
|
|
35
47
|
outrank-cli articles bulk-replace --json <object>
|
|
36
48
|
outrank-cli articles upload-image --file <path> [--article <article-id>] [--keyword <text>]
|
|
37
49
|
outrank-cli articles upload-featured-image <article-id> --file <path>
|
|
50
|
+
outrank-cli articles publish <article-id>
|
|
51
|
+
outrank-cli articles sync <article-id>
|
|
38
52
|
outrank-cli articles retry-publish <article-id>
|
|
39
53
|
outrank-cli articles delete <article-id>
|
|
40
54
|
outrank-cli articles precreate <keyword-id> # eligible future scheduled keyword only
|
|
@@ -45,6 +59,7 @@ JSON Input:
|
|
|
45
59
|
|
|
46
60
|
Examples:
|
|
47
61
|
outrank-cli articles list --product 00000000-0000-0000-0000-000000000000 --status published --limit 20
|
|
62
|
+
outrank-cli articles create --product 00000000-0000-0000-0000-000000000000 --title "How to choose a CRM" --content-file crm.md --publish
|
|
48
63
|
outrank-cli articles get 00000000-0000-0000-0000-000000000000
|
|
49
64
|
outrank-cli articles get-content 00000000-0000-0000-0000-000000000000 --format html
|
|
50
65
|
outrank-cli articles update 00000000-0000-0000-0000-000000000000 --json '{"title":"New title"}'
|
|
@@ -54,6 +69,8 @@ Examples:
|
|
|
54
69
|
outrank-cli articles bulk-replace --json-file bulk-replace.json
|
|
55
70
|
outrank-cli articles upload-image --file .\\diagram.png --article 00000000-0000-0000-0000-000000000000
|
|
56
71
|
outrank-cli articles upload-featured-image 00000000-0000-0000-0000-000000000000 --file .\\hero.jpg
|
|
72
|
+
outrank-cli articles publish 00000000-0000-0000-0000-000000000000
|
|
73
|
+
outrank-cli articles sync 00000000-0000-0000-0000-000000000000
|
|
57
74
|
outrank-cli articles retry-publish 00000000-0000-0000-0000-000000000000
|
|
58
75
|
outrank-cli articles precreate 00000000-0000-0000-0000-000000000000
|
|
59
76
|
outrank-cli articles generate 00000000-0000-0000-0000-000000000000
|
|
@@ -273,6 +290,71 @@ const uploadFeaturedImageCommand = defineCommand({
|
|
|
273
290
|
}),
|
|
274
291
|
});
|
|
275
292
|
|
|
293
|
+
const ARTICLE_CREATE_GUIDANCE =
|
|
294
|
+
'Pass --product <product-id>, --title and the body with --content-file article.md (markdown), or send ' +
|
|
295
|
+
'{"product_id": "...", "title": "...", "content": "..."} with --json. Add --publish to send it to the CMS.';
|
|
296
|
+
|
|
297
|
+
const CREATE_REQUIRED_FIELDS = Object.freeze([
|
|
298
|
+
{ field: 'product_id', option: '--product <product-id>' },
|
|
299
|
+
{ field: 'title', option: '--title <title>' },
|
|
300
|
+
]);
|
|
301
|
+
|
|
302
|
+
const refineCreate = (input, ctx) => {
|
|
303
|
+
const body = input.body ?? {};
|
|
304
|
+
const isMcp = ctx.surface === Surface.MCP;
|
|
305
|
+
const guidance = isMcp ? MCP_USAGE_GUIDANCE : ARTICLE_CREATE_GUIDANCE;
|
|
306
|
+
const missing = CREATE_REQUIRED_FIELDS.find(({ field }) => !(typeof body[field] === 'string' && body[field].trim()));
|
|
307
|
+
if (missing) {
|
|
308
|
+
throw usageError(isMcp ? `${missing.field} is required.` : `Missing required option: ${missing.option}.`, guidance);
|
|
309
|
+
}
|
|
310
|
+
const bodies = ['content', 'content_html'].filter((name) => body[name] !== undefined);
|
|
311
|
+
if (bodies.length !== 1) {
|
|
312
|
+
throw usageError('Send exactly one of content (markdown) or content_html.', guidance);
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const createCommand = defineCommand({
|
|
317
|
+
id: 'articles.create',
|
|
318
|
+
summary: 'Create an article from your own content and optionally publish it to the connected CMS.',
|
|
319
|
+
kind: CommandKind.WRITE,
|
|
320
|
+
toolsets: TOOLSETS,
|
|
321
|
+
input: {
|
|
322
|
+
productId: uuid(),
|
|
323
|
+
title: string(),
|
|
324
|
+
contentFile: textFile().describe('Local markdown file used as the body (CLI only).'),
|
|
325
|
+
publish: bool(),
|
|
326
|
+
},
|
|
327
|
+
cli: { options: { product: 'productId' } },
|
|
328
|
+
body: BodyMode.OPTIONAL_JSON_OR_EMPTY_OBJECT,
|
|
329
|
+
bodyField: ARTICLE_CREATE_BODY,
|
|
330
|
+
bodyMerge: { product_id: 'productId', title: 'title', content: 'contentFile', publish: 'publish' },
|
|
331
|
+
longRunning: { pollTool: 'articles.list' },
|
|
332
|
+
refine: refineCreate,
|
|
333
|
+
run: (input, ctx) => ctx.http({ method: 'POST', path: '/articles', body: input.body }),
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
const publishCommand = defineCommand({
|
|
337
|
+
id: 'articles.publish',
|
|
338
|
+
summary: "Publish an article to the product's connected CMS for the first time.",
|
|
339
|
+
kind: CommandKind.WRITE,
|
|
340
|
+
toolsets: TOOLSETS,
|
|
341
|
+
input: articleIdInput(),
|
|
342
|
+
cli: ARTICLE_ID_CLI,
|
|
343
|
+
longRunning: { pollTool: 'articles.get' },
|
|
344
|
+
run: (input, ctx) => ctx.http({ method: 'POST', path: `${ARTICLE_PATH}/publish`, pathParams: articleIdParams(input) }),
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const syncCommand = defineCommand({
|
|
348
|
+
id: 'articles.sync',
|
|
349
|
+
summary: 'Push the edits of a published article to the post already in the connected CMS.',
|
|
350
|
+
kind: CommandKind.WRITE,
|
|
351
|
+
toolsets: TOOLSETS,
|
|
352
|
+
input: articleIdInput(),
|
|
353
|
+
cli: ARTICLE_ID_CLI,
|
|
354
|
+
longRunning: { pollTool: 'articles.get' },
|
|
355
|
+
run: (input, ctx) => ctx.http({ method: 'POST', path: `${ARTICLE_PATH}/sync`, pathParams: articleIdParams(input) }),
|
|
356
|
+
});
|
|
357
|
+
|
|
276
358
|
const retryPublishCommand = defineCommand({
|
|
277
359
|
id: 'articles.retry-publish',
|
|
278
360
|
summary: 'Retry publishing an article to its integration.',
|
|
@@ -329,8 +411,10 @@ export const articlesGroup = defineGroup({
|
|
|
329
411
|
help: {
|
|
330
412
|
usage: ARTICLES_HELP,
|
|
331
413
|
guidance:
|
|
332
|
-
'Choose one of: list, get, get-content, update, update-content, replace, bulk-replace, upload-image, ' +
|
|
333
|
-
'upload-featured-image, retry-publish, delete, precreate, generate.'
|
|
414
|
+
'Choose one of: list, create, get, get-content, update, update-content, replace, bulk-replace, upload-image, ' +
|
|
415
|
+
'upload-featured-image, publish, sync, retry-publish, delete, precreate, generate. `create` stores your own ' +
|
|
416
|
+
'article (add --publish to send it to the CMS), `publish` sends an article to the CMS the first time, and ' +
|
|
417
|
+
'`sync` pushes later edits to the post already in the CMS.',
|
|
334
418
|
},
|
|
335
419
|
unknown: (word) => ({
|
|
336
420
|
message: `Unknown articles subcommand: ${word}.`,
|
|
@@ -338,6 +422,7 @@ export const articlesGroup = defineGroup({
|
|
|
338
422
|
}),
|
|
339
423
|
children: [
|
|
340
424
|
listCommand,
|
|
425
|
+
createCommand,
|
|
341
426
|
getCommand,
|
|
342
427
|
getContentCommand,
|
|
343
428
|
updateCommand,
|
|
@@ -346,6 +431,8 @@ export const articlesGroup = defineGroup({
|
|
|
346
431
|
bulkReplaceCommand,
|
|
347
432
|
uploadImageCommand,
|
|
348
433
|
uploadFeaturedImageCommand,
|
|
434
|
+
publishCommand,
|
|
435
|
+
syncCommand,
|
|
349
436
|
retryPublishCommand,
|
|
350
437
|
deleteCommand,
|
|
351
438
|
precreateCommand,
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
BLOG_PAGE_STATUSES,
|
|
8
8
|
BLOG_POSITION_MAX,
|
|
9
9
|
BLOG_POST_LIST_STATUSES,
|
|
10
|
+
BLOG_POST_WRITE_STATUSES,
|
|
10
11
|
BLOG_PREVIEW_DESIGN_SCOPE,
|
|
11
12
|
BLOG_PREVIEW_SCOPES,
|
|
12
13
|
BLOG_REDIRECT_GONE_STATUS_CODE,
|
|
@@ -19,7 +20,7 @@ import {
|
|
|
19
20
|
} from '../../core/constants.js';
|
|
20
21
|
import { Surface } from '../../core/constants.js';
|
|
21
22
|
import { MCP_USAGE_GUIDANCE, usageError } from '../../core/errors.js';
|
|
22
|
-
import { bool, int, json, list, object, oneOf, string, uuid } from '../../core/schema.js';
|
|
23
|
+
import { bool, int, json, list, object, oneOf, string, textFile, uuid } from '../../core/schema.js';
|
|
23
24
|
import { BodyMode, CommandKind, Toolset, defineCommand, defineGroup } from '../registry.js';
|
|
24
25
|
import { productInput } from './shared.js';
|
|
25
26
|
|
|
@@ -35,11 +36,13 @@ const BLOG_USAGE = `Usage:
|
|
|
35
36
|
outrank-cli products blog starter-posts <product-id>
|
|
36
37
|
outrank-cli products blog preview <product-id> --scope design|post|page [--target-id <id>]
|
|
37
38
|
outrank-cli products blog design get|update|publish|discard <product-id> [--json <object>]
|
|
39
|
+
outrank-cli products blog design patch <product-id> --json <object> [--publish]
|
|
38
40
|
outrank-cli products blog domain get|connect|verify|remove <product-id> [--json <object>]
|
|
39
41
|
outrank-cli products blog posts list <product-id> [--status all|published|scheduled|draft] [--search <text>] [--page <n>]
|
|
40
|
-
outrank-cli products blog posts create <product-id> [--title <title>]
|
|
41
|
-
outrank-cli products blog posts
|
|
42
|
-
outrank-cli products blog posts
|
|
42
|
+
outrank-cli products blog posts create <product-id> [--title <title>] [--content-file <path>] [--status draft|published|scheduled] [--scheduled-for <iso-datetime>] [--json <object>]
|
|
43
|
+
outrank-cli products blog posts update <product-id> <article-id> [--title <title>] [--content-file <path>] [--status draft|published|scheduled] [--scheduled-for <iso-datetime>] [--json <object>]
|
|
44
|
+
outrank-cli products blog posts get|get-content|unpublish|sync|delete <product-id> <article-id>
|
|
45
|
+
outrank-cli products blog posts update-content <product-id> <article-id> --json <object>
|
|
43
46
|
outrank-cli products blog posts publish <product-id> <article-id> [--scheduled-for <iso-datetime>]
|
|
44
47
|
outrank-cli products blog pages list|create|reorder <product-id> [--json <object>]
|
|
45
48
|
outrank-cli products blog pages get|update|delete <product-id> <page-id> [--json <object>]
|
|
@@ -135,6 +138,27 @@ const POST_SETTINGS_BODY = object().shape({
|
|
|
135
138
|
showToc: bool(),
|
|
136
139
|
});
|
|
137
140
|
|
|
141
|
+
const POST_WRITE_FIELDS = {
|
|
142
|
+
title: string(),
|
|
143
|
+
content: string().describe('Markdown body; replaces the whole body. Do not repeat the title as a # heading.'),
|
|
144
|
+
contentHtml: string().describe('HTML body instead of content; converted to markdown.'),
|
|
145
|
+
metaDescription: string().describe('Search description, at most 320 characters.'),
|
|
146
|
+
status: oneOf(BLOG_POST_WRITE_STATUSES).describe(
|
|
147
|
+
'draft keeps or takes the post offline, published puts it live now, scheduled needs scheduledFor.',
|
|
148
|
+
),
|
|
149
|
+
scheduledFor: string().describe('ISO 8601 date-time with offset, 1 minute to 365 days ahead.'),
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const POST_WRITE_BODY = object().shape({ ...POST_WRITE_FIELDS, ...POST_SETTINGS_BODY.properties });
|
|
153
|
+
|
|
154
|
+
const DESIGN_PATCH_BODY = object().shape({
|
|
155
|
+
config: json().describe(
|
|
156
|
+
'Only the design fields to change, for example {"theme": {"colors": {"accent": "#2563EB"}}}. Objects are ' +
|
|
157
|
+
'merged into the current draft; arrays replace the old array.',
|
|
158
|
+
),
|
|
159
|
+
publish: bool().describe('Also make the merged design live.'),
|
|
160
|
+
});
|
|
161
|
+
|
|
138
162
|
const POST_CONTENT_BODY = object().shape({
|
|
139
163
|
title: string(),
|
|
140
164
|
content: string().describe('Markdown body; replaces the whole body.'),
|
|
@@ -267,6 +291,45 @@ const refineSnippetContent = (input, ctx) => {
|
|
|
267
291
|
};
|
|
268
292
|
|
|
269
293
|
const POST_CONTENT_GUIDANCE = 'Send {"title": "..."}, {"content": "<markdown>"} or both.';
|
|
294
|
+
const POST_CREATE_GUIDANCE =
|
|
295
|
+
'Pass --title and the body with --content-file post.md (markdown), or send {"title": "...", "content": "..."} ' +
|
|
296
|
+
'with --json; add --status published to put it live now.';
|
|
297
|
+
const POST_UPDATE_GUIDANCE =
|
|
298
|
+
'Pass at least one change: --title, --content-file, --status, or a --json object with the fields to change.';
|
|
299
|
+
const SCHEDULE_GUIDANCE = 'Pass --status scheduled together with --scheduled-for <iso-datetime>.';
|
|
300
|
+
|
|
301
|
+
const refinePostWrite = ({ requireChange }) => (input, ctx) => {
|
|
302
|
+
const body = input.body ?? {};
|
|
303
|
+
const guidance = requireChange ? POST_UPDATE_GUIDANCE : POST_CREATE_GUIDANCE;
|
|
304
|
+
if (requireChange && Object.keys(body).length === 0) {
|
|
305
|
+
throw bodyUsageError(ctx, 'Send at least one field to change.', guidance);
|
|
306
|
+
}
|
|
307
|
+
if (body.content !== undefined && body.contentHtml !== undefined) {
|
|
308
|
+
throw bodyUsageError(ctx, 'Send content (markdown) or contentHtml, not both.', guidance);
|
|
309
|
+
}
|
|
310
|
+
if ((body.status === 'scheduled') !== Boolean(body.scheduledFor)) {
|
|
311
|
+
throw bodyUsageError(ctx, 'status scheduled and scheduledFor go together.', SCHEDULE_GUIDANCE);
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const refineDesignPatch = (input, ctx) => {
|
|
316
|
+
const config = input.body?.config;
|
|
317
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
318
|
+
throw bodyUsageError(
|
|
319
|
+
ctx,
|
|
320
|
+
'config must be an object with the design fields to change.',
|
|
321
|
+
'Send {"config": {"theme": {"colors": {"accent": "#2563EB"}}}}; add --publish to make it live.',
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const POST_WRITE_MERGE = { title: 'title', content: 'contentFile', status: 'status', scheduledFor: 'scheduledFor' };
|
|
327
|
+
const POST_WRITE_INPUT = {
|
|
328
|
+
title: string(),
|
|
329
|
+
contentFile: textFile().describe('Local markdown file used as the body (CLI only).'),
|
|
330
|
+
status: oneOf(BLOG_POST_WRITE_STATUSES),
|
|
331
|
+
scheduledFor: string(),
|
|
332
|
+
};
|
|
270
333
|
|
|
271
334
|
const refinePostContent = (input, ctx) => {
|
|
272
335
|
const { title, content } = input.body ?? {};
|
|
@@ -294,6 +357,7 @@ const blogCommand = ({
|
|
|
294
357
|
input = {},
|
|
295
358
|
body,
|
|
296
359
|
bodyField,
|
|
360
|
+
bodyMerge,
|
|
297
361
|
bodyOf,
|
|
298
362
|
query,
|
|
299
363
|
mcp,
|
|
@@ -310,6 +374,7 @@ const blogCommand = ({
|
|
|
310
374
|
cli: { positionals: [PRODUCT_ID, ...ids] },
|
|
311
375
|
...(body ? { body } : {}),
|
|
312
376
|
...(bodyField ? { bodyField } : {}),
|
|
377
|
+
...(bodyMerge ? { bodyMerge } : {}),
|
|
313
378
|
...(mcp ? { mcp } : {}),
|
|
314
379
|
...(refine ? { refine } : {}),
|
|
315
380
|
run: (values, ctx) =>
|
|
@@ -355,10 +420,12 @@ const designGroup = group({
|
|
|
355
420
|
'design update <product-id> --json <object>',
|
|
356
421
|
'design publish <product-id>',
|
|
357
422
|
'design discard <product-id>',
|
|
423
|
+
'design patch <product-id> --json <object> [--publish]',
|
|
358
424
|
]),
|
|
359
425
|
guidance:
|
|
360
|
-
'
|
|
361
|
-
'
|
|
426
|
+
'Change part of the theme with `patch` and {"config": {...}} (only the fields that change; add --publish to ' +
|
|
427
|
+
'make it live). `update` replaces the whole draft config. Preview a draft with ' +
|
|
428
|
+
'`outrank-cli products blog preview <product-id> --scope design`, then `publish` it.',
|
|
362
429
|
children: [
|
|
363
430
|
blogCommand({
|
|
364
431
|
id: 'design.get',
|
|
@@ -389,6 +456,18 @@ const designGroup = group({
|
|
|
389
456
|
method: 'POST',
|
|
390
457
|
suffix: '/design/discard',
|
|
391
458
|
}),
|
|
459
|
+
blogCommand({
|
|
460
|
+
id: 'design.patch',
|
|
461
|
+
summary: 'Change part of the blog theme (merged into the draft) and optionally publish it.',
|
|
462
|
+
kind: CommandKind.WRITE,
|
|
463
|
+
method: 'PATCH',
|
|
464
|
+
suffix: '/design',
|
|
465
|
+
input: { publish: bool() },
|
|
466
|
+
body: BodyMode.JSON,
|
|
467
|
+
bodyField: DESIGN_PATCH_BODY,
|
|
468
|
+
bodyMerge: { publish: 'publish' },
|
|
469
|
+
refine: refineDesignPatch,
|
|
470
|
+
}),
|
|
392
471
|
],
|
|
393
472
|
});
|
|
394
473
|
|
|
@@ -444,9 +523,12 @@ const postsGroup = group({
|
|
|
444
523
|
id: 'posts',
|
|
445
524
|
usage: usageFor([
|
|
446
525
|
'posts list <product-id> [--status all|published|scheduled|draft] [--search <text>] [--page <n>]',
|
|
447
|
-
'posts create <product-id> [--title <title>]'
|
|
526
|
+
'posts create <product-id> [--title <title>] [--content-file <path>] [--status draft|published|scheduled] ' +
|
|
527
|
+
'[--scheduled-for <iso-datetime>] [--json <object>]',
|
|
448
528
|
'posts get <product-id> <article-id>',
|
|
449
|
-
'posts update <product-id> <article-id> --
|
|
529
|
+
'posts update <product-id> <article-id> [--title <title>] [--content-file <path>] ' +
|
|
530
|
+
'[--status draft|published|scheduled] [--scheduled-for <iso-datetime>] [--json <object>]',
|
|
531
|
+
'posts delete <product-id> <article-id>',
|
|
450
532
|
'posts get-content <product-id> <article-id>',
|
|
451
533
|
'posts update-content <product-id> <article-id> --json <object>',
|
|
452
534
|
'posts publish <product-id> <article-id> [--scheduled-for <iso-datetime>]',
|
|
@@ -454,8 +536,10 @@ const postsGroup = group({
|
|
|
454
536
|
'posts sync <product-id> <article-id>',
|
|
455
537
|
]),
|
|
456
538
|
guidance:
|
|
457
|
-
'Posts are keyed by their article id. `create` a
|
|
458
|
-
'
|
|
539
|
+
'Posts are keyed by their article id. `create` writes a whole post in one call: title, body (--content-file ' +
|
|
540
|
+
'or "content"), SEO fields and --status published or scheduled. `update` changes only the fields it is ' +
|
|
541
|
+
'given, including the body and the status, and refreshes a live post on its own. A slug that is already ' +
|
|
542
|
+
'used returns CONFLICT with error.details.existingArticleId: update that post instead of creating it again.',
|
|
459
543
|
children: [
|
|
460
544
|
blogCommand({
|
|
461
545
|
id: 'posts.list',
|
|
@@ -468,12 +552,15 @@ const postsGroup = group({
|
|
|
468
552
|
}),
|
|
469
553
|
blogCommand({
|
|
470
554
|
id: 'posts.create',
|
|
471
|
-
summary: '
|
|
555
|
+
summary: 'Write a post on the blog in one call (title, body, SEO fields) as a draft, live or scheduled.',
|
|
472
556
|
kind: CommandKind.WRITE,
|
|
473
557
|
method: 'POST',
|
|
474
558
|
suffix: '/posts',
|
|
475
|
-
input:
|
|
476
|
-
|
|
559
|
+
input: POST_WRITE_INPUT,
|
|
560
|
+
body: BodyMode.OPTIONAL_JSON_OR_EMPTY_OBJECT,
|
|
561
|
+
bodyField: POST_WRITE_BODY,
|
|
562
|
+
bodyMerge: POST_WRITE_MERGE,
|
|
563
|
+
refine: refinePostWrite({ requireChange: false }),
|
|
477
564
|
}),
|
|
478
565
|
blogCommand({
|
|
479
566
|
id: 'posts.get',
|
|
@@ -485,13 +572,24 @@ const postsGroup = group({
|
|
|
485
572
|
}),
|
|
486
573
|
blogCommand({
|
|
487
574
|
id: 'posts.update',
|
|
488
|
-
summary: 'Change
|
|
575
|
+
summary: 'Change any field of one post (title, body, SEO, slug, category, cover, status); live posts refresh.',
|
|
489
576
|
kind: CommandKind.WRITE,
|
|
490
577
|
method: 'PATCH',
|
|
491
578
|
suffix: POST_PATH,
|
|
492
579
|
ids: POST_ID,
|
|
493
|
-
|
|
494
|
-
|
|
580
|
+
input: POST_WRITE_INPUT,
|
|
581
|
+
body: BodyMode.OPTIONAL_JSON_OR_EMPTY_OBJECT,
|
|
582
|
+
bodyField: POST_WRITE_BODY,
|
|
583
|
+
bodyMerge: POST_WRITE_MERGE,
|
|
584
|
+
refine: refinePostWrite({ requireChange: true }),
|
|
585
|
+
}),
|
|
586
|
+
blogCommand({
|
|
587
|
+
id: 'posts.delete',
|
|
588
|
+
summary: 'Take one post off the blog and delete it.',
|
|
589
|
+
kind: CommandKind.DESTRUCTIVE,
|
|
590
|
+
method: 'DELETE',
|
|
591
|
+
suffix: POST_PATH,
|
|
592
|
+
ids: POST_ID,
|
|
495
593
|
}),
|
|
496
594
|
blogCommand({
|
|
497
595
|
id: 'posts.get-content',
|
package/src/core/constants.js
CHANGED
|
@@ -27,6 +27,8 @@ export const MCP_STARTUP_RESOLVE_TIMEOUT_MS = 3_000;
|
|
|
27
27
|
|
|
28
28
|
export const MAX_IMAGE_UPLOAD_MEGABYTES = 5;
|
|
29
29
|
export const MAX_IMAGE_UPLOAD_BYTES = MAX_IMAGE_UPLOAD_MEGABYTES * BYTES_PER_MB;
|
|
30
|
+
export const MAX_TEXT_FILE_MEGABYTES = 1;
|
|
31
|
+
export const MAX_TEXT_FILE_BYTES = MAX_TEXT_FILE_MEGABYTES * BYTES_PER_MB;
|
|
30
32
|
export const MAX_UPSTREAM_ERROR_BODY_CHARS = 500;
|
|
31
33
|
export const MAX_RATE_LIMIT_HEADER_LENGTH = 64;
|
|
32
34
|
export const MAX_TOOL_NAME_LENGTH = 46;
|
|
@@ -50,6 +52,7 @@ export const BLOG_TEMPLATES = Object.freeze(['classic', 'editorial', 'journal',
|
|
|
50
52
|
export const BLOG_ANALYTICS_RANGES = Object.freeze(['7d', '30d', '90d']);
|
|
51
53
|
export const BLOG_DOMAIN_MODES = Object.freeze(['subdomain', 'apex', 'subfolder']);
|
|
52
54
|
export const BLOG_POST_LIST_STATUSES = Object.freeze(['all', 'published', 'scheduled', 'draft']);
|
|
55
|
+
export const BLOG_POST_WRITE_STATUSES = Object.freeze(['draft', 'published', 'scheduled']);
|
|
53
56
|
export const BLOG_SUBSCRIBER_STATUSES = Object.freeze(['subscribed', 'unsubscribed']);
|
|
54
57
|
export const BLOG_PREVIEW_DESIGN_SCOPE = 'design';
|
|
55
58
|
export const BLOG_PREVIEW_SCOPES = Object.freeze([BLOG_PREVIEW_DESIGN_SCOPE, 'post', 'page']);
|
package/src/core/files.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
|
-
import { MAX_IMAGE_UPLOAD_BYTES } from './constants.js';
|
|
4
|
+
import { MAX_IMAGE_UPLOAD_BYTES, MAX_TEXT_FILE_BYTES } from './constants.js';
|
|
5
5
|
import { usageError } from './errors.js';
|
|
6
6
|
|
|
7
7
|
const ImageMime = Object.freeze({
|
|
@@ -184,3 +184,39 @@ export const readImageUpload = async ({ filePath, allowedDir }) => {
|
|
|
184
184
|
|
|
185
185
|
return toUpload(buffer, fileName);
|
|
186
186
|
};
|
|
187
|
+
|
|
188
|
+
const UTF8_ENCODING = 'utf-8';
|
|
189
|
+
const BYTE_ORDER_MARK = '\uFEFF';
|
|
190
|
+
const LINE_BREAK_PATTERN = /\r\n?/g;
|
|
191
|
+
const LINE_FEED = '\n';
|
|
192
|
+
const TEXT_OVERSIZE_GUIDANCE = 'Split the content or shorten it below the limit, then retry.';
|
|
193
|
+
const TEXT_ENCODING_GUIDANCE = 'Save the file as UTF-8 text, then retry.';
|
|
194
|
+
|
|
195
|
+
export const readTextFile = async ({ filePath, option }) => {
|
|
196
|
+
if (typeof filePath !== 'string' || filePath === '') {
|
|
197
|
+
throw usageError(`Missing required option: ${option} <path>.`, `Pass a local file path with \`${option} <path>\`.`);
|
|
198
|
+
}
|
|
199
|
+
let buffer;
|
|
200
|
+
try {
|
|
201
|
+
const stats = await fs.stat(filePath);
|
|
202
|
+
if (!stats.isFile()) throw usageError(`Not a file: ${filePath}`, readGuidance(filePath));
|
|
203
|
+
if (stats.size > MAX_TEXT_FILE_BYTES) {
|
|
204
|
+
throw usageError(
|
|
205
|
+
`${filePath} is ${stats.size} bytes; the maximum is ${MAX_TEXT_FILE_BYTES} bytes.`,
|
|
206
|
+
TEXT_OVERSIZE_GUIDANCE,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
buffer = await fs.readFile(filePath);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
if (error?.cliCode) throw error;
|
|
212
|
+
throw usageError(`Failed to read file: ${error.message}`, readGuidance(filePath));
|
|
213
|
+
}
|
|
214
|
+
let text;
|
|
215
|
+
try {
|
|
216
|
+
text = new TextDecoder(UTF8_ENCODING, { fatal: true }).decode(buffer);
|
|
217
|
+
} catch {
|
|
218
|
+
throw usageError(`${filePath} is not valid UTF-8 text.`, TEXT_ENCODING_GUIDANCE);
|
|
219
|
+
}
|
|
220
|
+
const withoutBom = text.startsWith(BYTE_ORDER_MARK) ? text.slice(BYTE_ORDER_MARK.length) : text;
|
|
221
|
+
return withoutBom.replace(LINE_BREAK_PATTERN, LINE_FEED);
|
|
222
|
+
};
|
package/src/core/redact.js
CHANGED
|
@@ -3,7 +3,7 @@ import { SENSITIVE_BODY_FIELDS } from './constants.js';
|
|
|
3
3
|
export const REDACTED = '<redacted>';
|
|
4
4
|
const TOO_DEEP = '<nested too deeply>';
|
|
5
5
|
const DEFAULT_MAX_DEPTH = 32;
|
|
6
|
-
const API_KEY_PATTERN = /outr_(?:live|test)_[A-Za-z0-9_]+/g;
|
|
6
|
+
const API_KEY_PATTERN = /outr_(?:live|test|oat|ort)_[A-Za-z0-9_-]+/g;
|
|
7
7
|
const BEARER_PATTERN = /Bearer\s+[A-Za-z0-9._~+/=-]+/g;
|
|
8
8
|
const SENSITIVE_FIELD_SET = new Set(SENSITIVE_BODY_FIELDS.map((name) => name.toLowerCase()));
|
|
9
9
|
const SENSITIVE_KEY_PATTERN = /token|secret|password|passwd|api[_-]?key|authorization|credential|private[_-]?key/i;
|
package/src/core/schema.js
CHANGED
|
@@ -11,6 +11,7 @@ export const FieldKind = Object.freeze({
|
|
|
11
11
|
OBJECT: 'object',
|
|
12
12
|
JSON: 'json',
|
|
13
13
|
IMAGE: 'image',
|
|
14
|
+
TEXT_FILE: 'text-file',
|
|
14
15
|
});
|
|
15
16
|
|
|
16
17
|
export const BoolSerialize = Object.freeze({
|
|
@@ -63,6 +64,7 @@ export const list = ({ split = DEFAULT_LIST_SEPARATOR, serialize = ListSerialize
|
|
|
63
64
|
export const object = () => createField(FieldKind.OBJECT, { properties: undefined });
|
|
64
65
|
export const json = () => createField(FieldKind.JSON);
|
|
65
66
|
export const image = () => createField(FieldKind.IMAGE);
|
|
67
|
+
export const textFile = () => createField(FieldKind.TEXT_FILE);
|
|
66
68
|
|
|
67
69
|
export const isField = (value) => Boolean(value) && Object.values(FieldKind).includes(value.kind);
|
|
68
70
|
|
|
@@ -124,6 +126,10 @@ const parseScalar = (field, value, label) => {
|
|
|
124
126
|
if (!valid) throw usageError(`${label} must be an image upload (file_base64, file_name, file_type).`);
|
|
125
127
|
return value;
|
|
126
128
|
}
|
|
129
|
+
if (field.kind === FieldKind.TEXT_FILE) {
|
|
130
|
+
if (typeof value !== 'string') throw usageError(`${label} must be the path of a text file.`);
|
|
131
|
+
return value;
|
|
132
|
+
}
|
|
127
133
|
throw new Error(`Unknown field kind: ${field.kind}`);
|
|
128
134
|
};
|
|
129
135
|
|
|
@@ -254,6 +260,7 @@ const zodScalar = (z, field, options = {}) => {
|
|
|
254
260
|
if (field.kind === FieldKind.IMAGE) {
|
|
255
261
|
return z.object({ file_base64: z.string().min(1), file_name: z.string().min(1), file_type: z.string().min(1) });
|
|
256
262
|
}
|
|
263
|
+
if (field.kind === FieldKind.TEXT_FILE) return z.string();
|
|
257
264
|
throw new Error(`Unknown field kind: ${field.kind}`);
|
|
258
265
|
};
|
|
259
266
|
|