@nuxtseo/cli 0.2.1 → 0.4.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 +4 -8
- package/dist/ansi.d.ts +8 -0
- package/dist/ansi.js +35 -0
- package/dist/cli.js +17 -21
- package/dist/commands.d.ts +7 -0
- package/dist/commands.js +91 -116
- package/dist/failures.d.ts +46 -6
- package/dist/failures.js +101 -10
- package/dist/pairing.js +2 -4
- package/dist/pull.d.ts +81 -0
- package/dist/pull.js +358 -0
- package/dist/render.d.ts +5 -9
- package/dist/render.js +1 -22
- package/dist/runtime.d.ts +5 -0
- package/dist/runtime.js +7 -2
- package/dist/skill.d.ts +99 -0
- package/dist/skill.js +372 -15
- package/dist/state/auth.js +2 -2
- package/dist/state/files.js +6 -6
- package/dist/update-check.d.ts +5 -0
- package/dist/update-check.js +18 -1
- package/package.json +4 -4
- package/skills/nuxtseo-cli/SKILL.md +187 -254
- package/skills/nuxtseo-cli/references/commands.md +111 -18
- package/skills/nuxtseo-cli/references/indexing.md +3 -2
- package/skills/nuxtseo-cli/references/protocol.md +84 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
#
|
|
1
|
+
# `nuxtseo` CLI
|
|
2
2
|
|
|
3
|
-
Use
|
|
3
|
+
Use Nuxt SEO public Site operations from a terminal, script, or coding agent.
|
|
4
4
|
The CLI sends feature requests through `@nuxtseo/sdk`. It does not call MCP or
|
|
5
5
|
private routes. Live research uses declared public API operations.
|
|
6
6
|
|
|
@@ -81,7 +81,7 @@ When keychain support is unavailable, it warns on stderr and uses
|
|
|
81
81
|
scope before the request that needs it.
|
|
82
82
|
|
|
83
83
|
`nuxtseo logout` removes the local credential. Revoke the token from the
|
|
84
|
-
|
|
84
|
+
Nuxt SEO dashboard when it must stop working everywhere. An environment token
|
|
85
85
|
always wins and is unchanged by login or logout.
|
|
86
86
|
|
|
87
87
|
API host resolution order is:
|
|
@@ -216,9 +216,6 @@ nuxtseo audit link-opportunities
|
|
|
216
216
|
nuxtseo audit link-structure
|
|
217
217
|
nuxtseo audit content-decay
|
|
218
218
|
nuxtseo audit duplicates
|
|
219
|
-
nuxtseo content briefs list
|
|
220
|
-
nuxtseo content briefs show <brief-id>
|
|
221
|
-
nuxtseo content briefs create <keyword>
|
|
222
219
|
nuxtseo timeline list
|
|
223
220
|
nuxtseo skill install
|
|
224
221
|
```
|
|
@@ -256,7 +253,7 @@ reads retained rows and spends nothing.
|
|
|
256
253
|
`search indexing summary` reads retained URL Inspection coverage.
|
|
257
254
|
`search index-history` dates an indexing change against known releases.
|
|
258
255
|
`search inspect <url>` reads Google's own verdict for one URL.
|
|
259
|
-
`page inspect <url>` reads the
|
|
256
|
+
`page inspect <url>` reads the Nuxt SEO observation store for the same URL.
|
|
260
257
|
`research keywords`, `research serp`, and `research rankings` can use the Team
|
|
261
258
|
research limit. Keyword responses report cache use in `evidence`. SERP and
|
|
262
259
|
ranking responses report `cached: true`. A cached response spends nothing.
|
|
@@ -279,7 +276,6 @@ The CLI fetches one page per invocation by default. It never merges responses.
|
|
|
279
276
|
| `search analytics` | `--limit 1..100`, `--page >=1` | `--limit 25 --page 1` | row views only |
|
|
280
277
|
| `search indexing` | `--limit 1..500`, `--offset >=0` | `--limit 50 --offset 0` | `urls` view only |
|
|
281
278
|
| `sitemaps urls` | `--cursor`, `--limit 1..1000` | `--limit 500` | yes |
|
|
282
|
-
| `content briefs list` | `--limit 1..100`, `--offset >=0` | `--limit 25 --offset 0` | yes |
|
|
283
279
|
| `timeline list` | `--kind`, `--feature`, `--severity`, `--since`, `--cursor`, `--limit 1..100` | `--limit 25` | no |
|
|
284
280
|
|
|
285
281
|
Keep the server order for actions. For another page, pass the next offset or
|
package/dist/ansi.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Removes every ANSI escape sequence from one string.
|
|
3
|
+
*/
|
|
4
|
+
export declare function stripAnsi(value: string): string;
|
|
5
|
+
/**
|
|
6
|
+
* Serializes a value as JSON with every string value free of ANSI escapes.
|
|
7
|
+
*/
|
|
8
|
+
export declare function stringifyWithoutAnsi(value: unknown): string;
|
package/dist/ansi.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal styling never belongs in machine output.
|
|
3
|
+
*
|
|
4
|
+
* `citty` builds its own argument errors with `cyan(...)`, and it decides once,
|
|
5
|
+
* at import time, whether to emit colour. The CLI cannot turn that decision off
|
|
6
|
+
* after the fact, and the same risk exists for any other dependency whose text
|
|
7
|
+
* reaches a JSON envelope. So the CLI strips escape sequences at the boundary
|
|
8
|
+
* where machine output is written, which covers every producer at once.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* A control sequence: `ESC [`, parameter and intermediate bytes, then one final
|
|
12
|
+
* byte. Colour, cursor moves and erase sequences all take this form.
|
|
13
|
+
*/
|
|
14
|
+
// eslint-disable-next-line no-control-regex
|
|
15
|
+
const ANSI_CONTROL_SEQUENCE = /\u001B\[[\u0030-\u003F]*[\u0020-\u002F]*[\u0040-\u007E]/g;
|
|
16
|
+
/**
|
|
17
|
+
* An operating system command, such as a terminal hyperlink or a window title.
|
|
18
|
+
* It ends with BEL or with `ESC \`.
|
|
19
|
+
*/
|
|
20
|
+
// eslint-disable-next-line no-control-regex
|
|
21
|
+
const ANSI_OPERATING_SYSTEM_COMMAND = /\u001B\][^\u0007\u001B]*(?:\u0007|\u001B\\)/g;
|
|
22
|
+
/**
|
|
23
|
+
* Removes every ANSI escape sequence from one string.
|
|
24
|
+
*/
|
|
25
|
+
export function stripAnsi(value) {
|
|
26
|
+
return value
|
|
27
|
+
.replace(ANSI_OPERATING_SYSTEM_COMMAND, '')
|
|
28
|
+
.replace(ANSI_CONTROL_SEQUENCE, '');
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Serializes a value as JSON with every string value free of ANSI escapes.
|
|
32
|
+
*/
|
|
33
|
+
export function stringifyWithoutAnsi(value) {
|
|
34
|
+
return JSON.stringify(value, (_key, entry) => typeof entry === 'string' ? stripAnsi(entry) : entry);
|
|
35
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as prompts from '@clack/prompts';
|
|
2
2
|
import { renderUsage, runCommand } from 'citty';
|
|
3
|
+
import { stripAnsi } from './ansi.js';
|
|
3
4
|
import { fromStateError, loadApiContext } from './api.js';
|
|
4
5
|
import { commandWithGlobalOptions, describeCommand, resolveCommand, validateCommandOptions, } from './command-contract.js';
|
|
5
6
|
import { createRootCommand } from './commands.js';
|
|
@@ -15,6 +16,9 @@ function writeUpdateNotice(runtime, notice) {
|
|
|
15
16
|
}
|
|
16
17
|
function report(runtime, failure, json = false, notice = null) {
|
|
17
18
|
if (json) {
|
|
19
|
+
// `--json` means a program reads both streams. Terminal styling is noise
|
|
20
|
+
// there, so stderr is stripped as well as stdout.
|
|
21
|
+
failure = { ...failure, message: stripAnsi(failure.message) };
|
|
18
22
|
if (failure.protocolResponse !== undefined) {
|
|
19
23
|
writeProtocolResponse(runtime, failure.protocolResponse);
|
|
20
24
|
}
|
|
@@ -198,7 +202,7 @@ async function interactiveBare(runtime, globals) {
|
|
|
198
202
|
return fromStateError(apiUrl.error);
|
|
199
203
|
if (config._tag === 'Err')
|
|
200
204
|
return fromStateError(config.error);
|
|
201
|
-
prompts.intro(`
|
|
205
|
+
prompts.intro(`nuxtseo ${VERSION}`, { input: runtime.input, output: runtime.error });
|
|
202
206
|
prompts.note([
|
|
203
207
|
`Authentication: ${auth.value.authenticated ? auth.value.source : 'not configured'}`,
|
|
204
208
|
`API: ${apiUrl.value.apiUrl}`,
|
|
@@ -211,7 +215,7 @@ async function interactiveBare(runtime, globals) {
|
|
|
211
215
|
{ value: 'fix', label: 'Fix my Site', hint: 'Issues, Pages, and Scans' },
|
|
212
216
|
{ value: 'performance', label: 'Understand performance', hint: 'Performance and Search Console' },
|
|
213
217
|
{ value: 'research', label: 'Research growth', hint: 'Keywords, SERPs, and competitors' },
|
|
214
|
-
{ value: 'content', label: 'Plan content', hint: '
|
|
218
|
+
{ value: 'content', label: 'Plan content', hint: 'Decay, duplicates, and annotations' },
|
|
215
219
|
{ value: 'account', label: 'Account and setup', hint: config.value.siteId ?? 'no Site selected' },
|
|
216
220
|
{ value: 'exit', label: 'Exit' },
|
|
217
221
|
],
|
|
@@ -352,8 +356,6 @@ async function interactiveBare(runtime, globals) {
|
|
|
352
356
|
const task = await prompts.select({
|
|
353
357
|
message: 'Plan content',
|
|
354
358
|
options: [
|
|
355
|
-
{ value: 'briefs', label: 'Content Briefs' },
|
|
356
|
-
{ value: 'create', label: 'Create a Content Brief' },
|
|
357
359
|
{ value: 'decay', label: 'Content decay' },
|
|
358
360
|
{ value: 'duplicates', label: 'Duplicate clusters' },
|
|
359
361
|
{ value: 'annotations', label: 'Chart annotations', hint: 'mark what you shipped' },
|
|
@@ -364,22 +366,11 @@ async function interactiveBare(runtime, globals) {
|
|
|
364
366
|
});
|
|
365
367
|
if (typeof task === 'symbol')
|
|
366
368
|
return ok(undefined);
|
|
367
|
-
if (task === 'briefs')
|
|
368
|
-
return runInteractiveCommand(['content', 'briefs', 'list'], runtime, globals);
|
|
369
369
|
if (task === 'decay')
|
|
370
370
|
return runInteractiveCommand(['audit', 'content-decay'], runtime, globals);
|
|
371
371
|
if (task === 'duplicates')
|
|
372
372
|
return runInteractiveCommand(['audit', 'duplicates'], runtime, globals);
|
|
373
|
-
|
|
374
|
-
return runInteractiveCommand(['annotations', 'list'], runtime, globals);
|
|
375
|
-
const keyword = await promptTextValue(runtime, {
|
|
376
|
-
message: 'Which target keyword?',
|
|
377
|
-
placeholder: 'nuxt seo',
|
|
378
|
-
label: 'Keyword',
|
|
379
|
-
});
|
|
380
|
-
return keyword._tag === 'Err'
|
|
381
|
-
? keyword
|
|
382
|
-
: runInteractiveCommand(['content', 'briefs', 'create', keyword.value], runtime, globals);
|
|
373
|
+
return runInteractiveCommand(['annotations', 'list'], runtime, globals);
|
|
383
374
|
}
|
|
384
375
|
const task = await prompts.select({
|
|
385
376
|
message: 'Account and setup',
|
|
@@ -405,7 +396,7 @@ export async function runCli(rawArgs, runtime) {
|
|
|
405
396
|
// Started before parsing so the registry round trip overlaps the command. The
|
|
406
397
|
// check reads a local cache and never rejects; a slow registry only delays
|
|
407
398
|
// this line, never the command result itself.
|
|
408
|
-
const updateCheck = checkForUpdate({ paths: runtime.paths, env: runtime.env });
|
|
399
|
+
const updateCheck = checkForUpdate({ paths: runtime.paths, env: runtime.env, onDiagnostic: line => writeDiagnostic(runtime, line) });
|
|
409
400
|
const parsed = extractGlobalOptions(rawArgs);
|
|
410
401
|
if (parsed._tag === 'Err') {
|
|
411
402
|
const notice = await updateCheck;
|
|
@@ -416,10 +407,15 @@ export async function runCli(rawArgs, runtime) {
|
|
|
416
407
|
const effectiveRuntime = {
|
|
417
408
|
...runtime,
|
|
418
409
|
interactive: runtime.interactive && !globals.json && !globals.noInput,
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
410
|
+
// A getter, so each request reads a fresh `--timeout-ms` deadline. One timer
|
|
411
|
+
// made here covered the whole process: an `--all` walk of healthy pages
|
|
412
|
+
// exited 6 once their SUM passed the per-request limit.
|
|
413
|
+
get requestSignal() {
|
|
414
|
+
return AbortSignal.any([
|
|
415
|
+
runtime.requestSignal,
|
|
416
|
+
AbortSignal.timeout(globals.timeoutMs),
|
|
417
|
+
]);
|
|
418
|
+
},
|
|
423
419
|
requestTimeoutMs: globals.timeoutMs,
|
|
424
420
|
};
|
|
425
421
|
if (args.length === 1 && (args[0] === '--version' || args[0] === '-v')) {
|
package/dist/commands.d.ts
CHANGED
|
@@ -24,4 +24,11 @@ export type NextPage = {
|
|
|
24
24
|
} | {
|
|
25
25
|
_tag: 'Done';
|
|
26
26
|
};
|
|
27
|
+
/**
|
|
28
|
+
* The seed rule is known before the request runs. The provider answers a
|
|
29
|
+
* refused seed with 200, an empty list, and a `message`, which reads as "no
|
|
30
|
+
* demand for this topic". So the CLI checks the rule first and exits 2 without
|
|
31
|
+
* spending a research request.
|
|
32
|
+
*/
|
|
33
|
+
export declare function parseResearchTopic(topic: string): CliResult<string>;
|
|
27
34
|
export declare function createRootCommand(runtime: CliRuntime, globals: GlobalOptions, execution: CommandExecution): CommandDef<any>;
|
package/dist/commands.js
CHANGED
|
@@ -8,17 +8,37 @@ import { openBrowser } from './browser.js';
|
|
|
8
8
|
import { EXIT_CODE, fail, fromSdkFailure, ok } from './failures.js';
|
|
9
9
|
import { awaitPairingApproval, createCliPairingClient, startPairing } from './pairing.js';
|
|
10
10
|
import { parseAbsolutePageUrl, parseChoice, parseInteger, parsePageUrlOrPath, parseRatio } from './parse.js';
|
|
11
|
-
import {
|
|
11
|
+
import { PULL_PERIODS, runPull } from './pull.js';
|
|
12
|
+
import { renderAccountToken, renderAction, renderActionDismissal, renderActionResolution, renderActions, renderAnalytics, renderAnnotation, renderAnnotationDeleted, renderAnnotations, renderAuditChanges, renderBacklinkAnchors, renderBacklinksHistory, renderBacklinksSummary, renderContentDecay, renderDomainAvailability, renderDomainTraffic, renderDuplicateClusters, renderFieldVitalFindings, renderFieldVitals, renderIndexCohorts, renderIndexingDiagnostics, renderIndexingHistory, renderKeywordResearch, renderLinkOpportunities, renderLinkStructure, renderMentions, renderMonitoredPages, renderPageInspection, renderPageIssues, renderPageScan, renderPerformance, renderRankingsResearch, renderRecoverableBacklinks, renderReferringDomains, renderResearchOverview, renderScanDetail, renderScans, renderSearchAnalytics, renderSearchStatus, renderSerpResearch, renderSitemapAction, renderSitemaps, renderSitemapUrls, renderSites, renderSiteStatus, renderTimeline, renderUrlInspection, renderUsage, } from './render.js';
|
|
12
13
|
import { writeCliResponse, writeDiagnostic, writeOutput, writeProtocolResponse } from './runtime.js';
|
|
13
14
|
import { resolveSite } from './site.js';
|
|
14
15
|
import { installSkill, SKILL_AGENTS } from './skill.js';
|
|
15
16
|
import { clearCredential, getCredentialStatus, readConfig, resolveApiUrl, saveCredential, updateConfig, } from './state/index.js';
|
|
16
17
|
import { VERSION } from './version.js';
|
|
18
|
+
/**
|
|
19
|
+
* Some read operations answer 200 with an empty list and a `message` that says
|
|
20
|
+
* why it is empty. A refused argument and a genuinely empty result look the
|
|
21
|
+
* same in the envelope, so a caller that reads only the list draws the wrong
|
|
22
|
+
* conclusion. Text output already prints the note. Machine output must not
|
|
23
|
+
* change the envelope, so the note goes to stderr, where it cannot be lost.
|
|
24
|
+
*/
|
|
25
|
+
function reportDataNote(runtime, value) {
|
|
26
|
+
const data = typeof value === 'object' && value !== null ? value.data : undefined;
|
|
27
|
+
if (typeof data !== 'object' || data === null)
|
|
28
|
+
return;
|
|
29
|
+
const { message, tip } = data;
|
|
30
|
+
const lines = [message, tip].filter((line) => typeof line === 'string' && line.trim().length > 0);
|
|
31
|
+
if (lines.length > 0)
|
|
32
|
+
writeDiagnostic(runtime, `Note: ${lines.join(' ')}`);
|
|
33
|
+
}
|
|
17
34
|
function emit(runtime, globals, value, render) {
|
|
18
|
-
if (globals.json)
|
|
35
|
+
if (globals.json) {
|
|
19
36
|
writeProtocolResponse(runtime, value);
|
|
20
|
-
|
|
37
|
+
reportDataNote(runtime, value);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
21
40
|
writeOutput(runtime, render(value));
|
|
41
|
+
}
|
|
22
42
|
}
|
|
23
43
|
function present(runtime, globals, result, render) {
|
|
24
44
|
if (result._tag === 'Err')
|
|
@@ -211,7 +231,7 @@ async function logout(runtime, globals) {
|
|
|
211
231
|
else {
|
|
212
232
|
writeOutput(runtime, cleared.value.removed ? 'Local credential removed.' : 'No stored credential.');
|
|
213
233
|
}
|
|
214
|
-
writeDiagnostic(runtime, 'Token revocation remains in the
|
|
234
|
+
writeDiagnostic(runtime, 'Token revocation remains in the Nuxt SEO dashboard. NUXTSEO_TOKEN, when set, is unchanged.');
|
|
215
235
|
return ok(undefined);
|
|
216
236
|
}
|
|
217
237
|
async function whoami(runtime, globals) {
|
|
@@ -350,7 +370,7 @@ async function feedbackSubmit(runtime, globals, args) {
|
|
|
350
370
|
const body = accountFeedbackBodySchema.safeParse({ ...args, cliVersion: VERSION });
|
|
351
371
|
if (!body.success)
|
|
352
372
|
return fail(EXIT_CODE.invalidInput, 'Pass valid --command, --comment, and --agent values. Read feedback submit --help for limits.', undefined, 'invalid_cli_input');
|
|
353
|
-
const confirmed = await confirmMutation(runtime, globals, 'Submit this agent report to
|
|
373
|
+
const confirmed = await confirmMutation(runtime, globals, 'Submit this agent report to Nuxt SEO feedback?');
|
|
354
374
|
if (confirmed._tag === 'Err')
|
|
355
375
|
return confirmed;
|
|
356
376
|
if (!confirmed.value) {
|
|
@@ -718,7 +738,37 @@ async function researchOverview(runtime, globals) {
|
|
|
718
738
|
}, { signal: runtime.requestSignal }));
|
|
719
739
|
return present(runtime, globals, response, value => renderResearchOverview(value.data));
|
|
720
740
|
}
|
|
741
|
+
/**
|
|
742
|
+
* DataForSEO refuses these characters, so the research provider removes them
|
|
743
|
+
* before it counts the words of a seed. Mirrored from
|
|
744
|
+
* `modules/dataforseo/src/runtime/server/utils/seo-tools/keyword-research.ts`.
|
|
745
|
+
*/
|
|
746
|
+
const RESEARCH_FORBIDDEN_CHARS = /[`!@%^()={};<>?\\|#/~\u2015\u{1F000}-\u{1FFFF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/gu;
|
|
747
|
+
/** The provider accepts 1 to 3 words per seed keyword. */
|
|
748
|
+
const RESEARCH_SEED_MAX_WORDS = 3;
|
|
749
|
+
function sanitizeResearchSeed(seed) {
|
|
750
|
+
return seed.replace(RESEARCH_FORBIDDEN_CHARS, '').replace(/\s+/g, ' ').trim();
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* The seed rule is known before the request runs. The provider answers a
|
|
754
|
+
* refused seed with 200, an empty list, and a `message`, which reads as "no
|
|
755
|
+
* demand for this topic". So the CLI checks the rule first and exits 2 without
|
|
756
|
+
* spending a research request.
|
|
757
|
+
*/
|
|
758
|
+
export function parseResearchTopic(topic) {
|
|
759
|
+
const seeds = topic.split(',').map(sanitizeResearchSeed).filter(seed => seed.length >= 2);
|
|
760
|
+
if (seeds.length === 0)
|
|
761
|
+
return fail(EXIT_CODE.invalidInput, 'topic must hold letters or digits. Characters like @ # / % ! are removed.');
|
|
762
|
+
const tooLong = seeds.find(seed => seed.split(' ').length > RESEARCH_SEED_MAX_WORDS);
|
|
763
|
+
if (tooLong !== undefined) {
|
|
764
|
+
return fail(EXIT_CODE.invalidInput, `topic seed "${tooLong}" holds more than ${RESEARCH_SEED_MAX_WORDS} words. Use 1 to 3 words per seed. Separate seeds with a comma.`);
|
|
765
|
+
}
|
|
766
|
+
return ok(topic);
|
|
767
|
+
}
|
|
721
768
|
async function researchKeywordIdeas(runtime, globals, args) {
|
|
769
|
+
const topic = parseResearchTopic(args.topic);
|
|
770
|
+
if (topic._tag === 'Err')
|
|
771
|
+
return topic;
|
|
722
772
|
const minVolume = parseInteger(args.minVolume, { name: '--min-volume', minimum: 0, defaultValue: 10 });
|
|
723
773
|
if (minVolume._tag === 'Err')
|
|
724
774
|
return minVolume;
|
|
@@ -747,7 +797,7 @@ async function researchKeywordIdeas(runtime, globals, args) {
|
|
|
747
797
|
const response = await withSpinner(runtime, 'Researching keyword ideas', () => resolved.value.api.client.research.keywords({
|
|
748
798
|
params: { siteId: resolved.value.siteId },
|
|
749
799
|
query: {
|
|
750
|
-
topic:
|
|
800
|
+
topic: topic.value,
|
|
751
801
|
minVolume: minVolume.value,
|
|
752
802
|
maxVolume: maxVolume.value,
|
|
753
803
|
minDifficulty: minDifficulty.value,
|
|
@@ -954,6 +1004,10 @@ async function analyticsQuery(runtime, globals, args) {
|
|
|
954
1004
|
}, { signal: runtime.requestSignal }));
|
|
955
1005
|
return present(runtime, globals, response, value => renderAnalytics(view.value, value.data));
|
|
956
1006
|
}
|
|
1007
|
+
/** A count with its noun, so output never reads "1 files". */
|
|
1008
|
+
function count(total, noun) {
|
|
1009
|
+
return `${total} ${noun}${total === 1 ? '' : 's'}`;
|
|
1010
|
+
}
|
|
957
1011
|
async function skillInstallCommand(runtime, globals, args) {
|
|
958
1012
|
const agent = parseChoice(args.agent, { name: '--agent', choices: SKILL_AGENTS, defaultValue: 'claude' });
|
|
959
1013
|
if (agent._tag === 'Err')
|
|
@@ -961,7 +1015,7 @@ async function skillInstallCommand(runtime, globals, args) {
|
|
|
961
1015
|
// The state directory is `<home>/.nuxtseo`, so its parent is the home the
|
|
962
1016
|
// agent directories sit beside.
|
|
963
1017
|
const homeDirectory = dirname(runtime.paths.directory);
|
|
964
|
-
const installed = await installSkill({ agent: agent.value, homeDirectory, target: args.target });
|
|
1018
|
+
const installed = await installSkill({ agent: agent.value, homeDirectory, target: args.target, paths: runtime.paths });
|
|
965
1019
|
if (installed._tag === 'Err')
|
|
966
1020
|
return installed;
|
|
967
1021
|
if (globals.json) {
|
|
@@ -969,12 +1023,20 @@ async function skillInstallCommand(runtime, globals, args) {
|
|
|
969
1023
|
_tag: 'CliSkillInstall',
|
|
970
1024
|
schemaVersion: 1,
|
|
971
1025
|
agent: installed.value.agent,
|
|
1026
|
+
version: installed.value.version,
|
|
972
1027
|
source: installed.value.source,
|
|
973
1028
|
destination: installed.value.destination,
|
|
1029
|
+
resolvedDestination: installed.value.resolvedDestination,
|
|
1030
|
+
written: installed.value.written,
|
|
1031
|
+
pruned: installed.value.pruned,
|
|
974
1032
|
});
|
|
975
1033
|
}
|
|
976
1034
|
else {
|
|
977
|
-
writeOutput(runtime,
|
|
1035
|
+
writeOutput(runtime, [
|
|
1036
|
+
`Installed the nuxtseo-cli skill ${installed.value.version} for ${installed.value.agent}.`,
|
|
1037
|
+
`Location: ${installed.value.resolvedDestination}`,
|
|
1038
|
+
`Wrote ${count(installed.value.written, 'file')}. Removed ${count(installed.value.pruned, 'stale file')}.`,
|
|
1039
|
+
].join('\n'));
|
|
978
1040
|
}
|
|
979
1041
|
return ok(undefined);
|
|
980
1042
|
}
|
|
@@ -1026,65 +1088,6 @@ async function timelineEntries(runtime, globals, args) {
|
|
|
1026
1088
|
}, { signal: runtime.requestSignal }));
|
|
1027
1089
|
return present(runtime, globals, response, value => renderTimeline(value.data));
|
|
1028
1090
|
}
|
|
1029
|
-
async function contentBriefList(runtime, globals, args) {
|
|
1030
|
-
const status = args.status === undefined
|
|
1031
|
-
? ok(undefined)
|
|
1032
|
-
: parseChoice(args.status, {
|
|
1033
|
-
name: '--status',
|
|
1034
|
-
choices: ['queued', 'researching', 'ready', 'written', 'published', 'stale', 'failed', 'archived'],
|
|
1035
|
-
});
|
|
1036
|
-
if (status._tag === 'Err')
|
|
1037
|
-
return status;
|
|
1038
|
-
const limit = parseInteger(args.limit, { name: '--limit', minimum: 1, maximum: 100, defaultValue: 25 });
|
|
1039
|
-
if (limit._tag === 'Err')
|
|
1040
|
-
return limit;
|
|
1041
|
-
const offset = parseInteger(args.offset, { name: '--offset', minimum: 0, defaultValue: 0 });
|
|
1042
|
-
if (offset._tag === 'Err')
|
|
1043
|
-
return offset;
|
|
1044
|
-
const resolved = await apiAndSite(runtime, globals);
|
|
1045
|
-
if (resolved._tag === 'Err')
|
|
1046
|
-
return resolved;
|
|
1047
|
-
return collect(runtime, globals, {
|
|
1048
|
-
all: args.all === true,
|
|
1049
|
-
message: 'Loading Content Briefs',
|
|
1050
|
-
start: { offset: offset.value },
|
|
1051
|
-
fetch: position => resolved.value.api.client.content.listBriefs({
|
|
1052
|
-
params: { siteId: resolved.value.siteId },
|
|
1053
|
-
query: { status: status.value, limit: limit.value, offset: position.offset ?? 0 },
|
|
1054
|
-
}, { signal: runtime.requestSignal }),
|
|
1055
|
-
render: value => renderContentBriefs(value.data),
|
|
1056
|
-
next: value => offsetPage(value.data.page),
|
|
1057
|
-
});
|
|
1058
|
-
}
|
|
1059
|
-
async function contentBriefShow(runtime, globals, briefId) {
|
|
1060
|
-
const resolved = await apiAndSite(runtime, globals);
|
|
1061
|
-
if (resolved._tag === 'Err')
|
|
1062
|
-
return resolved;
|
|
1063
|
-
const response = await withSpinner(runtime, 'Loading Content Brief', () => resolved.value.api.client.content.showBrief({
|
|
1064
|
-
params: { siteId: resolved.value.siteId, briefId },
|
|
1065
|
-
}, { signal: runtime.requestSignal }));
|
|
1066
|
-
return present(runtime, globals, response, value => renderContentBrief(value.data));
|
|
1067
|
-
}
|
|
1068
|
-
async function contentBriefCreate(runtime, globals, args) {
|
|
1069
|
-
const targetPage = args.targetPage === undefined ? ok(undefined) : parseAbsolutePageUrl(args.targetPage);
|
|
1070
|
-
if (targetPage._tag === 'Err')
|
|
1071
|
-
return targetPage;
|
|
1072
|
-
const resolved = await apiAndSite(runtime, globals);
|
|
1073
|
-
if (resolved._tag === 'Err')
|
|
1074
|
-
return resolved;
|
|
1075
|
-
const confirmed = await confirmMutation(runtime, globals, `Create a Content Brief for ${args.keyword}?`);
|
|
1076
|
-
if (confirmed._tag === 'Err')
|
|
1077
|
-
return confirmed;
|
|
1078
|
-
if (!confirmed.value) {
|
|
1079
|
-
writeDiagnostic(runtime, 'Mutation cancelled.');
|
|
1080
|
-
return ok(undefined);
|
|
1081
|
-
}
|
|
1082
|
-
const response = await withSpinner(runtime, 'Creating Content Brief', () => resolved.value.api.client.content.createBrief({
|
|
1083
|
-
params: { siteId: resolved.value.siteId },
|
|
1084
|
-
body: { keyword: args.keyword, targetPage: targetPage.value },
|
|
1085
|
-
}, { signal: runtime.requestSignal }));
|
|
1086
|
-
return present(runtime, globals, response, value => renderContentBriefCreated(value.data));
|
|
1087
|
-
}
|
|
1088
1091
|
const VITALS_FORM_FACTORS = ['phone', 'desktop', 'all'];
|
|
1089
1092
|
const VITALS_METRICS = ['lcp', 'inp', 'cls'];
|
|
1090
1093
|
const VITALS_FORM_FACTOR_WIRE = {
|
|
@@ -1907,51 +1910,6 @@ export function createRootCommand(runtime, globals, execution) {
|
|
|
1907
1910
|
}),
|
|
1908
1911
|
},
|
|
1909
1912
|
});
|
|
1910
|
-
const content = defineCommand({
|
|
1911
|
-
meta: { name: 'content', description: 'Read and create Content Briefs' },
|
|
1912
|
-
subCommands: {
|
|
1913
|
-
briefs: defineCommand({
|
|
1914
|
-
meta: { name: 'briefs', description: 'Manage Content Briefs' },
|
|
1915
|
-
subCommands: {
|
|
1916
|
-
list: defineCommand({
|
|
1917
|
-
meta: { name: 'list', description: 'List Content Briefs' },
|
|
1918
|
-
args: {
|
|
1919
|
-
status: {
|
|
1920
|
-
type: 'enum',
|
|
1921
|
-
options: ['queued', 'researching', 'ready', 'written', 'published', 'stale', 'failed', 'archived'],
|
|
1922
|
-
description: 'Filter by status',
|
|
1923
|
-
},
|
|
1924
|
-
limit: { type: 'string', description: 'Maximum Content Briefs, 1 to 100' },
|
|
1925
|
-
offset: { type: 'string', description: 'Pagination offset' },
|
|
1926
|
-
all: { type: 'boolean', description: 'Repeat until every page is written; one envelope per page' },
|
|
1927
|
-
},
|
|
1928
|
-
run: ({ args }) => capture(execution, () => contentBriefList(runtime, globals, {
|
|
1929
|
-
status: args.status,
|
|
1930
|
-
limit: args.limit,
|
|
1931
|
-
offset: args.offset,
|
|
1932
|
-
all: args.all,
|
|
1933
|
-
}), args._, 0)(),
|
|
1934
|
-
}),
|
|
1935
|
-
show: defineCommand({
|
|
1936
|
-
meta: { name: 'show', description: 'Show one Content Brief' },
|
|
1937
|
-
args: { briefId: positional('brief-id', 'Content Brief ID') },
|
|
1938
|
-
run: ({ args }) => capture(execution, () => contentBriefShow(runtime, globals, args.briefId), args._, 1)(),
|
|
1939
|
-
}),
|
|
1940
|
-
create: defineCommand({
|
|
1941
|
-
meta: { name: 'create', description: 'Create a Content Brief' },
|
|
1942
|
-
args: {
|
|
1943
|
-
'keyword': positional('keyword', 'Target keyword'),
|
|
1944
|
-
'target-page': { type: 'string', description: 'Absolute target Page URL' },
|
|
1945
|
-
},
|
|
1946
|
-
run: ({ args }) => capture(execution, () => contentBriefCreate(runtime, globals, {
|
|
1947
|
-
keyword: args.keyword,
|
|
1948
|
-
targetPage: args['target-page'],
|
|
1949
|
-
}), args._, 1)(),
|
|
1950
|
-
}),
|
|
1951
|
-
},
|
|
1952
|
-
}),
|
|
1953
|
-
},
|
|
1954
|
-
});
|
|
1955
1913
|
const search = defineCommand({
|
|
1956
1914
|
meta: { name: 'search', description: 'Read Search Console state' },
|
|
1957
1915
|
subCommands: {
|
|
@@ -2108,6 +2066,23 @@ export function createRootCommand(runtime, globals, execution) {
|
|
|
2108
2066
|
}),
|
|
2109
2067
|
},
|
|
2110
2068
|
});
|
|
2069
|
+
const pull = defineCommand({
|
|
2070
|
+
meta: { name: 'pull', description: 'Write every stored-evidence read for one Site as NDJSON' },
|
|
2071
|
+
args: {
|
|
2072
|
+
'include': { type: 'string', description: 'Comma separated command names to run instead of the default set' },
|
|
2073
|
+
'exclude': { type: 'string', description: 'Comma separated command names to drop' },
|
|
2074
|
+
'with-research': { type: 'boolean', description: 'Add the reads that can start live research; they draw on the Team allowance' },
|
|
2075
|
+
'period': { type: 'enum', options: [...PULL_PERIODS], description: 'Search Console period for the search analytics reads' },
|
|
2076
|
+
'concurrency': { type: 'string', description: 'Reads in flight at once, 1 to 8' },
|
|
2077
|
+
},
|
|
2078
|
+
run: ({ args }) => capture(execution, () => runPull(runtime, globals, {
|
|
2079
|
+
include: args.include,
|
|
2080
|
+
exclude: args.exclude,
|
|
2081
|
+
withResearch: args['with-research'],
|
|
2082
|
+
period: args.period,
|
|
2083
|
+
concurrency: args.concurrency,
|
|
2084
|
+
}, apiAndSite), args._, 0)(),
|
|
2085
|
+
});
|
|
2111
2086
|
const timeline = defineCommand({
|
|
2112
2087
|
meta: { name: 'timeline', description: 'Read the Timeline — what changed on the Site' },
|
|
2113
2088
|
subCommands: {
|
|
@@ -2138,10 +2113,10 @@ export function createRootCommand(runtime, globals, execution) {
|
|
|
2138
2113
|
meta: {
|
|
2139
2114
|
name: 'nuxtseo',
|
|
2140
2115
|
version: VERSION,
|
|
2141
|
-
description: 'Use
|
|
2116
|
+
description: 'Use Nuxt SEO from a terminal or automation',
|
|
2142
2117
|
},
|
|
2143
2118
|
args: {
|
|
2144
|
-
'api-url': { type: 'string', description: 'Override the
|
|
2119
|
+
'api-url': { type: 'string', description: 'Override the Nuxt SEO API host' },
|
|
2145
2120
|
'site': { type: 'string', description: 'Override the selected Site' },
|
|
2146
2121
|
'json': { type: 'boolean', description: 'Write machine JSON; protocol envelopes remain unchanged' },
|
|
2147
2122
|
'no-input': { type: 'boolean', description: 'Disable every interactive prompt' },
|
|
@@ -2176,7 +2151,7 @@ export function createRootCommand(runtime, globals, execution) {
|
|
|
2176
2151
|
}),
|
|
2177
2152
|
sites,
|
|
2178
2153
|
feedback: defineCommand({
|
|
2179
|
-
meta: { name: 'feedback', description: 'Report CLI problems to
|
|
2154
|
+
meta: { name: 'feedback', description: 'Report CLI problems to Nuxt SEO' },
|
|
2180
2155
|
subCommands: {
|
|
2181
2156
|
submit: defineCommand({
|
|
2182
2157
|
meta: { name: 'submit', description: 'Submit sanitized agent feedback without selecting a Site' },
|
|
@@ -2211,7 +2186,6 @@ export function createRootCommand(runtime, globals, execution) {
|
|
|
2211
2186
|
actions,
|
|
2212
2187
|
audit,
|
|
2213
2188
|
backlinks,
|
|
2214
|
-
content,
|
|
2215
2189
|
mentions,
|
|
2216
2190
|
page,
|
|
2217
2191
|
performance: defineCommand({
|
|
@@ -2219,6 +2193,7 @@ export function createRootCommand(runtime, globals, execution) {
|
|
|
2219
2193
|
run: ({ args }) => capture(execution, () => performance(runtime, globals), args._, 0)(),
|
|
2220
2194
|
}),
|
|
2221
2195
|
annotations,
|
|
2196
|
+
pull,
|
|
2222
2197
|
research,
|
|
2223
2198
|
scans,
|
|
2224
2199
|
search,
|
package/dist/failures.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ export declare const EXIT_CODE: {
|
|
|
12
12
|
readonly interrupted: 130;
|
|
13
13
|
};
|
|
14
14
|
export type ExitCode = typeof EXIT_CODE[keyof typeof EXIT_CODE];
|
|
15
|
-
export type LocalFailureCode = 'authentication_required' | 'authorization_required' | 'confirmation_required' | 'conflict' | 'contract_violation' | 'infrastructure_failure' | 'interrupted' | 'invalid_cli_input' | 'not_found' | 'paging_cap_reached' | 'request_timeout' | 'retryable_failure' | 'site_ambiguous' | 'site_empty' | 'site_not_accessible' | 'state_error' | 'token_input_required' | 'transport_failure' | 'unexpected';
|
|
15
|
+
export type LocalFailureCode = 'authentication_required' | 'authorization_required' | 'confirmation_required' | 'conflict' | 'contract_violation' | 'infrastructure_failure' | 'interrupted' | 'invalid_cli_input' | 'not_found' | 'paging_cap_reached' | 'pull_incomplete' | 'request_timeout' | 'retryable_failure' | 'site_ambiguous' | 'site_empty' | 'site_not_accessible' | 'state_error' | 'token_input_required' | 'transport_failure' | 'unexpected';
|
|
16
16
|
export type CliFailureCode = LocalFailureCode | Extract<SdkFailure, {
|
|
17
17
|
_tag: 'ApiFailure';
|
|
18
18
|
}>['code'];
|
|
@@ -24,14 +24,54 @@ export interface CliFailure {
|
|
|
24
24
|
cause?: unknown;
|
|
25
25
|
protocolResponse?: unknown;
|
|
26
26
|
}
|
|
27
|
+
export interface CliErr {
|
|
28
|
+
_tag: 'Err';
|
|
29
|
+
error: CliFailure;
|
|
30
|
+
}
|
|
27
31
|
export type CliResult<T> = {
|
|
28
32
|
_tag: 'Ok';
|
|
29
33
|
value: T;
|
|
34
|
+
} | CliErr;
|
|
35
|
+
export declare function ok<T>(value: T): CliResult<T>;
|
|
36
|
+
export declare function fail(exitCode: ExitCode, message: string, cause?: unknown, code?: CliFailureCode): CliErr;
|
|
37
|
+
/**
|
|
38
|
+
* Which side of a `contract_violation` runs the older contract.
|
|
39
|
+
*
|
|
40
|
+
* The CLI knows the contract version it was built against. The API reports its
|
|
41
|
+
* own in the `X-NuxtSEO-Version` response header, which the SDK keeps on
|
|
42
|
+
* `metadata.version`. When the two differ, the direction of the skew is a fact.
|
|
43
|
+
* When they match, or the header is absent, the CLI cannot observe a direction,
|
|
44
|
+
* so the message must not name one.
|
|
45
|
+
*
|
|
46
|
+
* Bug 2026-09-21: a field landed in a client response schema before the API
|
|
47
|
+
* deployed it. A newer CLI rejected an older API response, and the old message
|
|
48
|
+
* told the operator to update the CLI, which was already the newest part.
|
|
49
|
+
*/
|
|
50
|
+
export type ContractSkew = {
|
|
51
|
+
_tag: 'CliContractOlder';
|
|
52
|
+
cliContract: string;
|
|
53
|
+
apiContract: string;
|
|
30
54
|
} | {
|
|
31
|
-
_tag: '
|
|
32
|
-
|
|
55
|
+
_tag: 'ApiContractOlder';
|
|
56
|
+
cliContract: string;
|
|
57
|
+
apiContract: string;
|
|
58
|
+
} | {
|
|
59
|
+
_tag: 'ContractVersionsMatch';
|
|
60
|
+
contract: string;
|
|
61
|
+
} | {
|
|
62
|
+
_tag: 'ApiContractUnknown';
|
|
63
|
+
cliContract: string;
|
|
33
64
|
};
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Classify the skew from what the response actually carried.
|
|
67
|
+
*
|
|
68
|
+
* `apiContract` is the value of the `X-NuxtSEO-Version` response header, or
|
|
69
|
+
* `undefined` when the API sent no header the SDK could read.
|
|
70
|
+
*/
|
|
71
|
+
export declare function classifyContractSkew(apiContract: string | undefined, cliContract?: string): ContractSkew;
|
|
72
|
+
/**
|
|
73
|
+
* The remediation a caller can act on, and nothing the CLI cannot observe.
|
|
74
|
+
*/
|
|
75
|
+
export declare function contractSkewLines(skew: ContractSkew): string[];
|
|
76
|
+
export declare function fromSdkFailure(error: SdkFailure): CliErr;
|
|
37
77
|
export declare function unexpectedFailure(cause: unknown): CliFailure;
|