@paperboyseo/cli 0.0.0-stage → 0.1.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.
@@ -0,0 +1,194 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { createInterface } from 'node:readline';
3
+ import { CliError } from '../errors.js';
4
+ import { resolveSiteId } from '../site.js';
5
+ import { VERSION } from '../version.js';
6
+ /**
7
+ * `paperboy mcp`: a Model Context Protocol server over stdio. It exposes the REST API as tools, with the same
8
+ * key and base URL as the rest of the CLI. Messages are newline-delimited JSON-RPC 2.0 on stdin and stdout;
9
+ * logs go to stderr. No SDK: the protocol surface used here is small.
10
+ */
11
+ const PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];
12
+ const site = { type: 'string', description: 'Site id or domain. Optional when the account has one site or a default is saved.' };
13
+ const str = (args, key) => {
14
+ const v = args[key];
15
+ return typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined;
16
+ };
17
+ const whole = (args, key, fallback, max) => {
18
+ const v = args[key];
19
+ return typeof v === 'number' && Number.isInteger(v) && v >= 1 && v <= max ? v : fallback;
20
+ };
21
+ const need = (args, key) => {
22
+ const v = str(args, key);
23
+ if (!v)
24
+ throw new CliError('usage', `"${key}" is required.`);
25
+ return v;
26
+ };
27
+ const enc = encodeURIComponent;
28
+ export const TOOLS = [
29
+ {
30
+ name: 'list_sites',
31
+ description: 'List the sites on this Paperboy account, with publish target and Search Console state.',
32
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
33
+ annotations: { readOnlyHint: true },
34
+ run: async (_a, d) => (await d.api.get('/api/v1/sites')).data,
35
+ },
36
+ {
37
+ name: 'suggest_topics',
38
+ description: 'Suggest articles worth writing next, from Search Console data, with the reason and signals for each.',
39
+ inputSchema: { type: 'object', properties: { site, n: { type: 'integer', minimum: 1, maximum: 50, description: 'How many topics. Default 5.' } }, additionalProperties: false },
40
+ annotations: { readOnlyHint: true },
41
+ run: async (a, d) => (await d.api.post(`/api/v1/sites/${enc(await d.resolveSite(str(a, 'site')))}/topics`, { n: whole(a, 'n', 5, 50) })).data,
42
+ },
43
+ {
44
+ name: 'write_article',
45
+ description: 'Start writing an article: research, write, fact check. This spends money on the Anthropic key the server uses. Returns a job id at once; poll get_job. Whether the article publishes by itself follows the site approval setting, so ask the user before calling this on a site set to autopilot.',
46
+ inputSchema: {
47
+ type: 'object',
48
+ properties: {
49
+ site,
50
+ topic_id: { type: 'string', description: 'A topic id from suggest_topics.' },
51
+ topic: { type: 'string', description: 'Free text topic, used when there is no topic id.' },
52
+ },
53
+ additionalProperties: false,
54
+ },
55
+ annotations: { readOnlyHint: false, destructiveHint: false },
56
+ run: async (a, d) => {
57
+ const body = { publish: false };
58
+ const id = str(a, 'topic_id');
59
+ const text = str(a, 'topic');
60
+ if (id)
61
+ body['topic_id'] = id;
62
+ else if (text)
63
+ body['topic'] = text;
64
+ const path = `/api/v1/sites/${enc(await d.resolveSite(str(a, 'site')))}/articles`;
65
+ return (await d.api.post(path, body, { idempotencyKey: randomUUID() })).data;
66
+ },
67
+ },
68
+ {
69
+ name: 'get_job',
70
+ description: 'Status of a job started by write_article: queued, running, done or failed, with the current step and the article id when done.',
71
+ inputSchema: { type: 'object', properties: { job_id: { type: 'string' } }, required: ['job_id'], additionalProperties: false },
72
+ annotations: { readOnlyHint: true },
73
+ run: async (a, d) => (await d.api.get(`/api/v1/jobs/${enc(need(a, 'job_id'))}`)).data,
74
+ },
75
+ {
76
+ name: 'get_article',
77
+ description: 'An article with its status, URL and the fact-check verdict: every check, and each held claim with the problem found.',
78
+ inputSchema: { type: 'object', properties: { article_id: { type: 'string' } }, required: ['article_id'], additionalProperties: false },
79
+ annotations: { readOnlyHint: true },
80
+ run: async (a, d) => (await d.api.get(`/api/v1/articles/${enc(need(a, 'article_id'))}`)).data,
81
+ },
82
+ {
83
+ name: 'fact_check',
84
+ description: 'Fact check a page URL or a Markdown draft. Every cited link is fetched and each claim is compared with the text of the page it cites. Returns pass, a score and one entry per claim: verified, held, unsupported or mismatch.',
85
+ inputSchema: {
86
+ type: 'object',
87
+ properties: {
88
+ url: { type: 'string', description: 'A public page to check.' },
89
+ markdown: { type: 'string', description: 'A draft in Markdown, with its sources linked inline.' },
90
+ sources: { type: 'array', items: { type: 'string' }, description: 'Extra source URLs to read.' },
91
+ },
92
+ additionalProperties: false,
93
+ },
94
+ annotations: { readOnlyHint: true, openWorldHint: true },
95
+ run: async (a, d) => {
96
+ const body = {};
97
+ if (str(a, 'url'))
98
+ body['url'] = str(a, 'url');
99
+ else if (str(a, 'markdown'))
100
+ body['markdown'] = str(a, 'markdown');
101
+ else
102
+ throw new CliError('usage', 'Send "url" or "markdown".');
103
+ if (Array.isArray(a['sources']))
104
+ body['sources'] = a['sources'].filter((s) => typeof s === 'string');
105
+ return (await d.api.post('/api/v1/fact-check', body, { idempotencyKey: randomUUID(), timeoutMs: 120_000 })).data;
106
+ },
107
+ },
108
+ {
109
+ name: 'publish_article',
110
+ description: 'Publish an article that passed the fact check to the site publish target (WordPress, Ghost or webhook). This makes it public: confirm with the user first.',
111
+ inputSchema: { type: 'object', properties: { article_id: { type: 'string' } }, required: ['article_id'], additionalProperties: false },
112
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
113
+ run: async (a, d) => (await d.api.post(`/api/v1/articles/${enc(need(a, 'article_id'))}/publish`, {})).data,
114
+ },
115
+ {
116
+ name: 'get_performance',
117
+ description: 'Search Console totals (clicks, impressions, average position) and daily rows for a site.',
118
+ inputSchema: { type: 'object', properties: { site, days: { type: 'integer', minimum: 1, maximum: 365, description: 'Window in days. Default 28.' } }, additionalProperties: false },
119
+ annotations: { readOnlyHint: true },
120
+ run: async (a, d) => (await d.api.get(`/api/v1/sites/${enc(await d.resolveSite(str(a, 'site')))}/performance`, { query: { days: whole(a, 'days', 28, 365) } })).data,
121
+ },
122
+ ];
123
+ const ok = (id, result) => ({ jsonrpc: '2.0', id: id ?? null, result });
124
+ const fail = (id, code, message) => ({ jsonrpc: '2.0', id: id ?? null, error: { code, message } });
125
+ /** Handles one JSON-RPC message. Returns the response, or null for notifications. */
126
+ export async function handleMessage(msg, deps) {
127
+ const { id, method, params = {} } = msg;
128
+ if (typeof method !== 'string')
129
+ return id === undefined ? null : fail(id, -32600, 'Invalid request.');
130
+ if (id === undefined)
131
+ return null; // notifications/initialized, notifications/cancelled, and so on
132
+ switch (method) {
133
+ case 'initialize': {
134
+ const asked = typeof params['protocolVersion'] === 'string' ? params['protocolVersion'] : '';
135
+ return ok(id, {
136
+ protocolVersion: PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0],
137
+ capabilities: { tools: {} },
138
+ serverInfo: { name: 'paperboy', version: VERSION },
139
+ instructions: 'Paperboy writes and fact checks SEO articles. write_article spends money and publish_article makes content public: ask the user first.',
140
+ });
141
+ }
142
+ case 'ping':
143
+ return ok(id, {});
144
+ case 'tools/list':
145
+ return ok(id, { tools: TOOLS.map(({ name, description, inputSchema, annotations }) => ({ name, description, inputSchema, annotations })) });
146
+ case 'tools/call': {
147
+ const tool = TOOLS.find((t) => t.name === params['name']);
148
+ if (!tool)
149
+ return fail(id, -32602, `Unknown tool "${String(params['name'])}".`);
150
+ const args = params['arguments'] && typeof params['arguments'] === 'object' ? params['arguments'] : {};
151
+ try {
152
+ const data = await tool.run(args, deps);
153
+ return ok(id, { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
154
+ }
155
+ catch (err) {
156
+ return ok(id, { isError: true, content: [{ type: 'text', text: err instanceof Error ? err.message : String(err) }] });
157
+ }
158
+ }
159
+ default:
160
+ return fail(id, -32601, `Method not found: ${method}`);
161
+ }
162
+ }
163
+ export async function mcp(ctx) {
164
+ const api = ctx.client({ timeoutMs: 130_000 });
165
+ const deps = {
166
+ api,
167
+ resolveSite: (s) => resolveSiteId(s ? { ...ctx, settings: { ...ctx.settings, site: s } } : ctx),
168
+ };
169
+ const send = (m) => process.stdout.write(JSON.stringify(m) + '\n');
170
+ process.stderr.write(`paperboy mcp ${VERSION} ready on stdio (${ctx.settings.baseUrl})\n`);
171
+ const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
172
+ const pending = new Set();
173
+ for await (const line of rl) {
174
+ if (!line.trim())
175
+ continue;
176
+ let msg;
177
+ try {
178
+ msg = JSON.parse(line);
179
+ }
180
+ catch {
181
+ send(fail(null, -32700, 'Parse error.'));
182
+ continue;
183
+ }
184
+ // Tool calls can take minutes: do not hold up other requests.
185
+ const job = handleMessage(msg, deps).then((res) => {
186
+ if (res)
187
+ send(res);
188
+ });
189
+ pending.add(job);
190
+ void job.finally(() => pending.delete(job));
191
+ }
192
+ await Promise.all(pending);
193
+ return 0;
194
+ }
@@ -0,0 +1,20 @@
1
+ import { CliError } from '../errors.js';
2
+ export async function publish(ctx) {
3
+ const { out, flags } = ctx;
4
+ const id = ctx.args[0];
5
+ if (!id)
6
+ throw new CliError('usage', 'Usage: paperboy publish <article-id>');
7
+ if (ctx.deps.interactive && !flags.yes && !out.json) {
8
+ const ok = await ctx.deps.confirm(`Publish article ${id} now?`);
9
+ if (!ok)
10
+ throw new CliError('cancelled', 'Cancelled. Nothing was published.');
11
+ }
12
+ const { data } = await ctx.client().post(`/api/v1/articles/${encodeURIComponent(id)}/publish`, {});
13
+ out.result('publish', data, () => {
14
+ if (data.published)
15
+ out.line(`${out.style.green('Published')}${data.url ? `: ${data.url}` : ''}`);
16
+ else
17
+ out.line(`${out.style.yellow('Not published.')} The article was not published; check the fact-check verdict with \`paperboy status\`.`);
18
+ });
19
+ return data.published ? 0 : 1;
20
+ }
@@ -0,0 +1,38 @@
1
+ import { intFlag } from '../args.js';
2
+ import { fixed, num, padLeft, padRight, sparkline } from '../output.js';
3
+ import { resolveSiteId } from '../site.js';
4
+ export async function report(ctx) {
5
+ const { out, flags } = ctx;
6
+ const days = intFlag(flags.days, 'days', 28, 365);
7
+ const site = await resolveSiteId(ctx);
8
+ const api = ctx.client();
9
+ const base = `/api/v1/sites/${encodeURIComponent(site)}`;
10
+ if (flags.markdown) {
11
+ const { data } = await api.post(`${base}/reports`, { days });
12
+ out.result('report', data, () => out.line(data.markdown.replace(/\n+$/, '')));
13
+ return 0;
14
+ }
15
+ const { data } = await api.get(`${base}/performance`, { query: { days } });
16
+ out.result('report', data, () => {
17
+ const s = out.style;
18
+ const t = data.totals ?? {};
19
+ const daily = data.daily ?? [];
20
+ const ctr = t.impressions && t.clicks !== undefined ? (t.clicks / t.impressions) * 100 : t.ctr !== undefined ? t.ctr * 100 : undefined;
21
+ out.line(s.bold(`Last ${data.days ?? days} days`));
22
+ out.line(` clicks ${num(t.clicks)}`);
23
+ out.line(` impressions ${num(t.impressions)}`);
24
+ out.line(` avg position ${fixed(t.position)}`);
25
+ if (ctr !== undefined)
26
+ out.line(` ctr ${fixed(ctr, 2)}%`);
27
+ if (daily.length) {
28
+ out.line();
29
+ out.line(`clicks per day ${sparkline(daily.map((d) => d.clicks))}`);
30
+ out.line();
31
+ out.line(s.dim(`${padRight('date', 12)}${padLeft('clicks', 8)}${padLeft('impressions', 13)}`));
32
+ for (const d of daily.slice(-7)) {
33
+ out.line(`${padRight(d.date, 12)}${padLeft(num(d.clicks), 8)}${padLeft(num(d.impressions), 13)}`);
34
+ }
35
+ }
36
+ });
37
+ return 0;
38
+ }
@@ -0,0 +1,47 @@
1
+ import { CliError } from '../errors.js';
2
+ import { num } from '../output.js';
3
+ import { resolveSiteId } from '../site.js';
4
+ function ownerLabel(me) {
5
+ if (typeof me.owner === 'string')
6
+ return me.owner;
7
+ return me.owner?.email ?? me.owner?.name ?? me.owner?.id ?? me.email ?? 'unknown';
8
+ }
9
+ export async function status(ctx) {
10
+ const { out } = ctx;
11
+ const api = ctx.client();
12
+ const { data: me } = await api.get('/api/v1/me');
13
+ let site = null;
14
+ try {
15
+ const id = await resolveSiteId(ctx);
16
+ const res = await api.get(`/api/v1/sites/${encodeURIComponent(id)}`);
17
+ site = res.data.site;
18
+ }
19
+ catch (err) {
20
+ if (!(err instanceof CliError && err.code === 'no_site'))
21
+ throw err;
22
+ }
23
+ out.result('status', { me, site }, () => {
24
+ const s = out.style;
25
+ out.line(`${s.bold('owner:')} ${ownerLabel(me)}`);
26
+ if (me.credits?.enabled)
27
+ out.line(`${s.bold('credits:')} ${num(me.credits.balance)}`);
28
+ if (!site) {
29
+ out.line('site: none yet. Run `paperboy connect https://yoursite.com` to add one.');
30
+ return;
31
+ }
32
+ out.line(`${s.bold('site:')} ${site.domain} (${site.id})`);
33
+ out.line(`${s.bold('publish:')} ${site.publish_method ?? 'not configured'}`);
34
+ out.line(`${s.bold('gsc:')} ${site.gsc_connected ? s.green('connected') : s.yellow('not connected')}`);
35
+ const articles = site.articles ?? [];
36
+ if (articles.length) {
37
+ out.line(s.bold('last articles:'));
38
+ for (const a of articles) {
39
+ out.line(` ${a.title} ${s.dim(`[${a.id}] ${a.status ?? ''}${a.url ? ` ${a.url}` : ''}`.trimEnd())}`);
40
+ }
41
+ }
42
+ else {
43
+ out.line('last articles: none yet');
44
+ }
45
+ });
46
+ return 0;
47
+ }
@@ -0,0 +1,44 @@
1
+ import { intFlag } from '../args.js';
2
+ import { num } from '../output.js';
3
+ import { resolveSiteId } from '../site.js';
4
+ export function describeSignals(signals) {
5
+ if (!signals)
6
+ return '';
7
+ const parts = [];
8
+ if (typeof signals.impressions === 'number')
9
+ parts.push(`${num(signals.impressions)} impressions`);
10
+ if (typeof signals.position === 'number')
11
+ parts.push(`position ${signals.position.toFixed(1)}`);
12
+ const gate = signals.serp_gate;
13
+ if (gate !== undefined && gate !== null) {
14
+ const text = typeof gate === 'boolean' ? (gate ? 'pass' : 'blocked') : typeof gate === 'string' ? gate : JSON.stringify(gate);
15
+ parts.push(`serp gate: ${text}`);
16
+ }
17
+ return parts.join(', ');
18
+ }
19
+ export async function topics(ctx) {
20
+ const { out } = ctx;
21
+ const n = intFlag(ctx.flags.n, 'n', 5, 50);
22
+ const site = await resolveSiteId(ctx);
23
+ const { data } = await ctx.client().post(`/api/v1/sites/${encodeURIComponent(site)}/topics`, { n });
24
+ const list = data.topics ?? [];
25
+ out.result('topics', data, () => {
26
+ if (list.length === 0) {
27
+ out.line('No topics yet. Search Console may still be collecting data for this site.');
28
+ return;
29
+ }
30
+ list.forEach((t, i) => {
31
+ out.line(`${out.style.bold(`${i + 1}. ${t.title}`)} ${out.style.dim(`[${t.id}]`)}`);
32
+ if (t.query)
33
+ out.line(` query: ${t.query}`);
34
+ if (t.reason)
35
+ out.line(` why: ${t.reason}`);
36
+ const signals = describeSignals(t.signals);
37
+ if (signals)
38
+ out.line(` signals: ${signals}`);
39
+ out.line();
40
+ });
41
+ out.line(`Write one with: paperboy write --topic ${list[0]?.id ?? '<id>'}`);
42
+ });
43
+ return 0;
44
+ }
@@ -0,0 +1,70 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { CliError } from '../errors.js';
3
+ import { pollJob } from '../jobs.js';
4
+ import { resolveSiteId } from '../site.js';
5
+ export function summarizeChecks(checks) {
6
+ let verified = 0;
7
+ let held = 0;
8
+ let other = 0;
9
+ for (const c of checks) {
10
+ if (c.status === 'verified')
11
+ verified++;
12
+ else if (c.status === 'held')
13
+ held++;
14
+ else
15
+ other++;
16
+ }
17
+ return { verified, held, other };
18
+ }
19
+ export async function write(ctx) {
20
+ const { out, flags } = ctx;
21
+ const api = ctx.client();
22
+ const site = await resolveSiteId(ctx);
23
+ // Free text always has a space in it; ids never do.
24
+ const body = { publish: Boolean(flags.publish) };
25
+ if (flags.topic !== undefined) {
26
+ if (flags.topic.trim() === '')
27
+ throw new CliError('usage', '--topic needs a topic id or some text.');
28
+ body[/\s/.test(flags.topic.trim()) ? 'topic' : 'topic_id'] = flags.topic.trim();
29
+ }
30
+ if (flags.publish && ctx.deps.interactive && !flags.yes && !out.json) {
31
+ const ok = await ctx.deps.confirm('Publish automatically when the fact-check passes?');
32
+ if (!ok)
33
+ throw new CliError('cancelled', 'Cancelled. Re-run without --publish to keep it as a draft.');
34
+ }
35
+ const wait = flags.wait ?? (flags['no-wait'] ? false : ctx.deps.interactive);
36
+ const { data: started } = await api.post(`/api/v1/sites/${encodeURIComponent(site)}/articles`, body, { idempotencyKey: randomUUID() });
37
+ out.info(`Started job ${started.job_id}.`);
38
+ if (!wait) {
39
+ out.result('write', started, () => {
40
+ out.line(`The article is being written. Check on it with: paperboy status`);
41
+ });
42
+ return 0;
43
+ }
44
+ const job = await pollJob(ctx, api, started.job_id);
45
+ const articleId = job.article_id ?? started.article_id;
46
+ if (!articleId)
47
+ throw new CliError('bad_response', 'The job finished but no article id was returned.');
48
+ const raw = await api.get(`/api/v1/articles/${encodeURIComponent(articleId)}`);
49
+ const article = 'article' in raw.data ? raw.data.article : raw.data;
50
+ out.result('write', { job_id: started.job_id, article }, () => {
51
+ const checks = article.verdict?.checks ?? [];
52
+ const counts = summarizeChecks(checks);
53
+ out.line();
54
+ out.line(out.style.bold(article.title));
55
+ out.line(`status: ${article.status}`);
56
+ if (article.url)
57
+ out.line(`url: ${article.url}`);
58
+ if (article.verdict) {
59
+ const verdict = article.verdict.pass ? out.style.green('PASS') : out.style.red('FAIL');
60
+ const score = typeof article.verdict.score === 'number' ? ` (score ${article.verdict.score})` : '';
61
+ out.line(`fact-check: ${verdict}${score}: ${counts.verified} claims verified, ${counts.held} held`);
62
+ for (const c of checks.filter((x) => x.status !== 'verified')) {
63
+ out.line(` ${out.style.yellow(c.status.toUpperCase())} ${c.claim}`);
64
+ }
65
+ }
66
+ if (!article.url)
67
+ out.line(`Publish it with: paperboy publish ${article.id}`);
68
+ });
69
+ return 0;
70
+ }
package/dist/config.js ADDED
@@ -0,0 +1,66 @@
1
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { DEFAULT_BASE_URL } from './version.js';
5
+ export function configDir(env) {
6
+ if (env['PAPERBOY_CONFIG_DIR'])
7
+ return env['PAPERBOY_CONFIG_DIR'];
8
+ if (env['XDG_CONFIG_HOME'])
9
+ return join(env['XDG_CONFIG_HOME'], 'paperboy');
10
+ return join(env['HOME'] ?? env['USERPROFILE'] ?? homedir(), '.config', 'paperboy');
11
+ }
12
+ export function configPath(env) {
13
+ return join(configDir(env), 'config.json');
14
+ }
15
+ export function readConfig(env) {
16
+ try {
17
+ const parsed = JSON.parse(readFileSync(configPath(env), 'utf8'));
18
+ if (!parsed || typeof parsed !== 'object')
19
+ return {};
20
+ const raw = parsed;
21
+ const out = {};
22
+ if (typeof raw['base_url'] === 'string')
23
+ out.base_url = raw['base_url'];
24
+ if (typeof raw['api_key'] === 'string')
25
+ out.api_key = raw['api_key'];
26
+ if (typeof raw['default_site'] === 'string')
27
+ out.default_site = raw['default_site'];
28
+ return out;
29
+ }
30
+ catch {
31
+ return {};
32
+ }
33
+ }
34
+ /** Merge `patch` into the stored config. Directory is 0700 and the file 0600. */
35
+ export function updateConfig(env, patch) {
36
+ const dir = configDir(env);
37
+ const file = configPath(env);
38
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
39
+ try {
40
+ chmodSync(dir, 0o700);
41
+ }
42
+ catch {
43
+ // Not fatal, for example on filesystems without permissions.
44
+ }
45
+ const next = { ...readConfig(env), ...patch };
46
+ writeFileSync(file, JSON.stringify(next, null, 2) + '\n', { mode: 0o600 });
47
+ try {
48
+ chmodSync(file, 0o600);
49
+ }
50
+ catch {
51
+ // Same as above.
52
+ }
53
+ return file;
54
+ }
55
+ /** Precedence: flag, then environment, then config file. */
56
+ export function resolveSettings(flags, env, file) {
57
+ const baseUrl = flags['base-url'] || env['PAPERBOY_BASE_URL'] || file.base_url || DEFAULT_BASE_URL;
58
+ return {
59
+ baseUrl: baseUrl.replace(/\/+$/, ''),
60
+ apiKey: flags['api-key'] || env['PAPERBOY_API_KEY'] || file.api_key || undefined,
61
+ site: flags.site || file.default_site || undefined,
62
+ };
63
+ }
64
+ export function maskKey(key) {
65
+ return key.slice(0, 12) + '...';
66
+ }
@@ -0,0 +1,9 @@
1
+ import { ApiClient } from './api.js';
2
+ export function makeClient(ctx, overrides = {}) {
3
+ return new ApiClient({
4
+ baseUrl: overrides.baseUrl ?? ctx.settings.baseUrl,
5
+ apiKey: 'apiKey' in overrides ? overrides.apiKey : ctx.settings.apiKey,
6
+ fetch: ctx.deps.fetch,
7
+ timeoutMs: overrides.timeoutMs,
8
+ });
9
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,47 @@
1
+ /** An error the CLI knows how to present to the user. */
2
+ export class CliError extends Error {
3
+ code;
4
+ status;
5
+ constructor(code, message, status) {
6
+ super(message);
7
+ this.name = 'CliError';
8
+ this.code = code;
9
+ this.status = status;
10
+ }
11
+ }
12
+ /** An error response from the Paperboy API, already mapped to a friendly message. */
13
+ export class ApiError extends CliError {
14
+ requestId;
15
+ apiMessage;
16
+ constructor(opts) {
17
+ super(opts.code, friendlyMessage(opts.status, opts.apiMessage, opts.requestId), opts.status);
18
+ this.name = 'ApiError';
19
+ this.requestId = opts.requestId;
20
+ this.apiMessage = opts.apiMessage;
21
+ }
22
+ }
23
+ export function friendlyMessage(status, apiMessage, requestId) {
24
+ const detail = apiMessage ? ` ${apiMessage}` : '';
25
+ const ref = requestId ? ` (request ${requestId})` : '';
26
+ switch (status) {
27
+ case 401:
28
+ return 'Not signed in, or the API key was rejected. Run `paperboy connect` to sign in.';
29
+ case 402:
30
+ return `Not enough credits for this action. Add credits in your Paperboy dashboard and try again.${ref}`;
31
+ case 403:
32
+ return `You do not have access to that.${detail}${ref}`;
33
+ case 404:
34
+ return `Not found.${detail}${ref}`;
35
+ case 409:
36
+ return `Conflict.${detail}${ref}`;
37
+ case 422:
38
+ return `The request was not accepted.${detail}${ref}`;
39
+ case 429:
40
+ return `Rate limited. Wait a moment and try again.${ref}`;
41
+ default:
42
+ if (status >= 500) {
43
+ return `The Paperboy server had a problem (HTTP ${status}). Try again shortly.${detail}${ref}`;
44
+ }
45
+ return `${apiMessage ?? `Request failed with HTTP ${status}.`}${ref}`;
46
+ }
47
+ }
package/dist/help.js ADDED
@@ -0,0 +1,39 @@
1
+ export const HELP = `paperboy - the open-source growth engine from the command line
2
+
3
+ Usage: paperboy <command> [options]
4
+
5
+ Commands:
6
+ connect [site-url] Sign in (device flow) and optionally add a site + Search Console
7
+ topics Pick topics worth writing, from Search Console data
8
+ write Write, fact-check and (optionally) publish an article
9
+ check <url|file.md> Fact-check a page or a markdown file (exit 1 if it fails)
10
+ publish <article-id> Publish a drafted article
11
+ status Show account, credits and site status
12
+ report Search performance report for the site
13
+ mcp Run an MCP server on stdio, so an AI agent can use these commands as tools
14
+
15
+ Global options:
16
+ --json Print exactly one JSON object per command
17
+ --site <id|domain> Site to use (default: saved default, or your only site)
18
+ --api-key <key> API key (env PAPERBOY_API_KEY)
19
+ --base-url <url> API base URL (env PAPERBOY_BASE_URL, default https://paperboyseo.com)
20
+ --no-color Disable colored output (also honors NO_COLOR)
21
+ -y, --yes Do not ask for confirmation
22
+ -h, --help Show this help
23
+ -v, --version Show the version
24
+
25
+ Command options:
26
+ connect --self-host <url> Use a self-hosted instance (saved as base_url)
27
+ --api-key <key> Skip the browser flow and save this key
28
+ --no-wait Print the Search Console link without waiting
29
+ --no-open Do not open the browser
30
+ topics -n, --n <count> How many topics (default 5)
31
+ write --topic <id|text> A topic id from \`paperboy topics\`, or free text in quotes
32
+ --publish Publish when the fact-check passes
33
+ --wait / --no-wait Wait for the job (default: wait on a terminal)
34
+ check --sources <file> Extra source URLs or notes, one per line
35
+ report --days <n> Window in days (default 28)
36
+ --markdown Render a markdown report on the server
37
+
38
+ Exit codes: 0 ok, 1 fact-check failed (or nothing published), 2 error.
39
+ Docs: https://paperboyseo.com/docs/cli`;
package/dist/jobs.js ADDED
@@ -0,0 +1,34 @@
1
+ import { CliError } from './errors.js';
2
+ function jobErrorMessage(error) {
3
+ if (!error)
4
+ return 'The job failed.';
5
+ if (typeof error === 'string')
6
+ return error;
7
+ return error.message ?? error.code ?? 'The job failed.';
8
+ }
9
+ /**
10
+ * Poll GET /api/v1/jobs/{id} until it is done or failed. Time is counted from the
11
+ * sleeps themselves so the loop is deterministic when `sleep` is stubbed in tests.
12
+ */
13
+ export async function pollJob(ctx, api, jobId, opts = {}) {
14
+ const intervalMs = opts.intervalMs ?? 2000;
15
+ const timeoutMs = opts.timeoutMs ?? 10 * 60_000;
16
+ let waited = 0;
17
+ let lastStep;
18
+ for (;;) {
19
+ const { data: job } = await api.get(`/api/v1/jobs/${encodeURIComponent(jobId)}`);
20
+ if (job.step && job.step !== lastStep) {
21
+ lastStep = job.step;
22
+ ctx.out.info(ctx.out.style.dim(` ${job.step}`));
23
+ }
24
+ if (job.status === 'done')
25
+ return job;
26
+ if (job.status === 'failed')
27
+ throw new CliError('job_failed', jobErrorMessage(job.error));
28
+ if (waited >= timeoutMs) {
29
+ throw new CliError('job_timeout', `Job ${jobId} is still running. Check on it later with \`paperboy status\`.`);
30
+ }
31
+ await ctx.deps.sleep(intervalMs);
32
+ waited += intervalMs;
33
+ }
34
+ }