@nuxtseo/cli 0.4.1 → 0.5.1

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 CHANGED
@@ -239,10 +239,14 @@ a different dataset: `vitals summary` and `vitals trend` read CrUX field p75,
239
239
  and `vitals findings` names the DOM element behind each failing vital.
240
240
  `status` reads the Site assessment: the verdict, the one ranked Next Action, and
241
241
  what changed. Start there. `status`, `page issues` and `search cohorts` refuse
242
- with exit `4` or `5` on an archived or paused Site.
242
+ with exit `4` or `5` on a paused Site. Removed Sites return `not_found` with exit `8`.
243
243
  The four live `backlinks` reads need the Site to have a URL. Without one they
244
244
  exit `2`, and retrying will not help; set the Site URL first.
245
- `search cohorts` returns two lists. Only `established` is proven; `ranked` is
245
+ `status` reports `assessment.scope`: `site` when the headline covers the whole
246
+ Site, `route-family` when one family leads. `assessment.aggregate` always holds
247
+ the whole-Site verdict.
248
+ `search cohorts` returns `analysed: false` with `reason: sampled-index-state`
249
+ when the not-indexed list is a sample. Otherwise it returns two lists. Only `established` is proven; `ranked` is
246
250
  ordered by raw rate with no significance test and is leads only.
247
251
  `backlinks summary`, `backlinks referring-domains`, `backlinks anchors`,
248
252
  `backlinks history`, `research domain-traffic` and `research domain-availability`
@@ -250,7 +254,7 @@ can use the Team research limit. Each response reports
250
254
  cache use in `evidence`; a cached response spends nothing. `backlinks recoverable`
251
255
  reads retained rows and spends nothing.
252
256
  `search status` reads stored connection state. It never waits for Google.
253
- `search indexing summary` reads retained URL Inspection coverage.
257
+ `search indexing summary` reads retained URL Inspection coverage. `asOf` and `oldestCheckAt` bound the age of its verdicts.
254
258
  `search index-history` dates an indexing change against known releases.
255
259
  `search inspect <url>` reads Google's own verdict for one URL.
256
260
  `page inspect <url>` reads the Nuxt SEO observation store for the same URL.
@@ -331,3 +335,21 @@ and what to do for each exit code.
331
335
  The CLI prints the exact server error code. When available, stderr also includes
332
336
  the request ID, retry delay, rate limit, reset time, and structured details.
333
337
  The CLI never falls back to MCP, a private route, or cached feature data.
338
+
339
+ ### Claim response
340
+
341
+ `actions resolve` saves your Claim. It returns before a requested scan finishes starting.
342
+ `status: "open"` stays unchanged until the engine verifies the Action.
343
+ Human output shows `Claimed, verifying` and the suggested check-back time.
344
+ Claimed Actions leave `actions list`. Use `actions show <action-id>` to check them.
345
+
346
+ Both `resolve` and `show` return these additive fields:
347
+
348
+ | Field | Meaning |
349
+ | --- | --- |
350
+ | `markedAt` | ISO timestamp when your Claim was saved, or `null` before a Claim. |
351
+ | `checkBackAt` | Suggested ISO timestamp to check again, or `null` when no automated check applies. |
352
+
353
+ A check-back time does not promise a completed check.
354
+ `scan-queued` can mean a re-assessment using scheduled crawl results, without a new scan.
355
+ A self-resolved Claim returns `status: "verified"` and `verification: "user-resolved"`.
package/dist/cli-entry.js CHANGED
@@ -12,6 +12,7 @@ function outputError(cause) {
12
12
  }
13
13
  process.once('SIGINT', abort);
14
14
  process.once('SIGTERM', abort);
15
+ // stdout writes can finish after runCli returns. Keep this handler for the process lifetime.
15
16
  process.stdout.once('error', outputError);
16
17
  async function readStdin() {
17
18
  process.stdin.setEncoding('utf8');
@@ -38,4 +39,3 @@ process.exitCode = await runCli(process.argv.slice(2), {
38
39
  });
39
40
  process.removeListener('SIGINT', abort);
40
41
  process.removeListener('SIGTERM', abort);
41
- process.stdout.removeListener('error', outputError);
package/dist/commands.js CHANGED
@@ -175,10 +175,11 @@ async function login(runtime, globals, options) {
175
175
  const apiUrl = await resolveApiUrl({ apiUrl: globals.apiUrl, env: runtime.env, paths: runtime.paths });
176
176
  if (apiUrl._tag === 'Err')
177
177
  return fromStateError(apiUrl.error);
178
- // Pairing needs a browser and a person. Without an interactive terminal the
179
- // old paste path is the only one that can complete, so CI keeps working
180
- // unchanged.
181
- const token = options.withToken || !runtime.interactive
178
+ // Pairing needs a person to approve it, and `--no-browser` says that person
179
+ // will open the printed URL themselves, so it is available on a headless
180
+ // machine too. Without the flag a non-interactive terminal keeps the paste
181
+ // path, so CI keeps working unchanged.
182
+ const token = options.withToken || (!runtime.interactive && !options.noBrowser)
182
183
  ? await readLoginToken(runtime)
183
184
  : await pairInBrowser(runtime, apiUrl.value.apiUrl, { openBrowser: !options.noBrowser });
184
185
  if (token._tag === 'Err')
@@ -1222,7 +1223,11 @@ async function actionDismiss(runtime, globals, args) {
1222
1223
  return fail(EXIT_CODE.invalidInput, 'Pass the action id to dismiss.', undefined, 'invalid_cli_input');
1223
1224
  if (!args.artifactVersion)
1224
1225
  return fail(EXIT_CODE.invalidInput, 'Pass --artifact-version, the artifactVersion returned with this action.', undefined, 'invalid_cli_input');
1225
- const confirmed = await confirmMutation(runtime, globals, `Dismiss action ${args.actionId}? The page stays removed.`);
1226
+ if (args.note !== undefined && (!args.note.trim() || args.note.trim().length > 2000))
1227
+ return fail(EXIT_CODE.invalidInput, 'Pass --note with 1 to 2000 characters.', undefined, 'invalid_cli_input');
1228
+ const confirmed = await confirmMutation(runtime, globals, args.note
1229
+ ? `Dismiss action ${args.actionId}? Your note records this decision. No fix is verified.`
1230
+ : `Dismiss action ${args.actionId}? The page stays removed.`);
1226
1231
  if (confirmed._tag === 'Err')
1227
1232
  return confirmed;
1228
1233
  if (!confirmed.value) {
@@ -1234,7 +1239,7 @@ async function actionDismiss(runtime, globals, args) {
1234
1239
  return resolved;
1235
1240
  const response = await withSpinner(runtime, 'Dismissing action', () => resolved.value.api.client.actions.dismiss({
1236
1241
  params: { siteId: resolved.value.siteId, actionId: args.actionId },
1237
- body: { artifactVersion: args.artifactVersion },
1242
+ body: { artifactVersion: args.artifactVersion, ...(args.note !== undefined ? { note: args.note.trim() } : {}) },
1238
1243
  }, { signal: runtime.requestSignal }));
1239
1244
  return present(runtime, globals, response, value => renderActionDismissal(value.data));
1240
1245
  }
@@ -1558,14 +1563,16 @@ export function createRootCommand(runtime, globals, execution) {
1558
1563
  run: ({ args }) => capture(execution, () => actionsResolve(runtime, globals, args.actionId), args._, 1)(),
1559
1564
  }),
1560
1565
  dismiss: defineCommand({
1561
- meta: { name: 'dismiss', description: 'Dismiss a broken-page action so the page stays removed' },
1566
+ meta: { name: 'dismiss', description: 'Dismiss reviewed work with a note, or confirm an unlinked page removal' },
1562
1567
  args: {
1563
1568
  'actionId': positional('action-id', 'Action ID'),
1564
1569
  'artifact-version': { type: 'string', description: 'The artifactVersion returned with this action' },
1570
+ 'note': { type: 'string', description: 'Why this action is intentional or incorrect, 1 to 2000 characters' },
1565
1571
  },
1566
1572
  run: ({ args }) => capture(execution, () => actionDismiss(runtime, globals, {
1567
1573
  actionId: args.actionId,
1568
1574
  artifactVersion: args['artifact-version'],
1575
+ note: args.note,
1569
1576
  }), args._, 1)(),
1570
1577
  }),
1571
1578
  },
package/dist/failures.js CHANGED
@@ -41,7 +41,7 @@ function defaultFailureCode(exitCode) {
41
41
  export function fail(exitCode, message, cause, code = defaultFailureCode(exitCode)) {
42
42
  return { _tag: 'Err', error: { _tag: 'CliFailure', code, exitCode, message, cause } };
43
43
  }
44
- function apiExitCode(code, retryable) {
44
+ function apiExitCode(code, retryable, details = {}) {
45
45
  switch (code) {
46
46
  case 'invalid_request':
47
47
  return EXIT_CODE.invalidInput;
@@ -57,8 +57,14 @@ function apiExitCode(code, retryable) {
57
57
  return EXIT_CODE.conflict;
58
58
  case 'rate_limited':
59
59
  case 'quota_exhausted':
60
- case 'provider_unavailable':
61
60
  return EXIT_CODE.retryable;
61
+ case 'provider_unavailable':
62
+ // A provider that refuses to serve until its own billing is settled
63
+ // cannot be retried into working, so the caller must not treat it as a
64
+ // transient outage.
65
+ return details.providerCode === 'provider_payment_required'
66
+ ? EXIT_CODE.authorization
67
+ : EXIT_CODE.retryable;
62
68
  case 'not_found':
63
69
  return EXIT_CODE.notFound;
64
70
  case 'contract_violation':
@@ -201,7 +207,7 @@ export function fromSdkFailure(error) {
201
207
  error: {
202
208
  _tag: 'CliFailure',
203
209
  code: error.code,
204
- exitCode: apiExitCode(error.code, error.retryable),
210
+ exitCode: apiExitCode(error.code, error.retryable, error.details),
205
211
  message: [
206
212
  `${error.code}: ${error.message}`,
207
213
  ...metadataLines(error),
package/dist/pull.js CHANGED
@@ -325,6 +325,11 @@ export async function runPull(runtime, globals, args, resolve, catalogue = PULL_
325
325
  if (outcome.exitCode === EXIT_CODE.authentication || outcome.exitCode === EXIT_CODE.interrupted)
326
326
  stopped = true;
327
327
  writeDiagnostic(runtime, `[${completed}/${operations.length}] ${candidate.name} ${outcome.failure ? `failed: ${outcome.failure.code}` : 'ok'}`);
328
+ if (outcome.failure?.code === 'request_timeout' && timeoutMs < 300_000) {
329
+ const retryTimeout = Math.min(timeoutMs * 2, 300_000);
330
+ // Retry only this read. Repeating a full paid pull can spend twice.
331
+ writeDiagnostic(runtime, `Retry: nuxtseo pull --site ${resolved.value.siteId} --include ${JSON.stringify(candidate.name)} --period ${period.value} --timeout-ms ${retryTimeout}${candidate.spendsResearch ? ' --with-research' : ''} --json`);
332
+ }
328
333
  });
329
334
  const failed = exitCodes.filter(code => code !== EXIT_CODE.success).length;
330
335
  writeDiagnostic(runtime, [
package/dist/render.d.ts CHANGED
@@ -3,7 +3,7 @@ import type { AccountTokenData } from '@nuxtseo/protocol/v1/account';
3
3
  import type { ActionDismiss, ActionList, ActionResolve, ActionShow } from '@nuxtseo/protocol/v1/actions';
4
4
  import type { AnalyticsQueryData, AnalyticsView } from '@nuxtseo/protocol/v1/analytics';
5
5
  import type { AuditChanges, AuditContentDecay, AuditDuplicateClusters, AuditLinkOpportunities, AuditLinkStructure } from '@nuxtseo/protocol/v1/audit';
6
- import type { BacklinkAnchors, BacklinksHistory, BacklinksSummary, MentionsData, RecoverableBacklinksData, ReferringDomains } from '@nuxtseo/protocol/v1/backlinks';
6
+ import type { BacklinkAnchorsData, BacklinksHistory, BacklinksSummaryData, MentionsData, RecoverableBacklinksData, ReferringDomainsData } from '@nuxtseo/protocol/v1/backlinks';
7
7
  import type { IndexCohorts, IndexingDiagnosticsData, IndexingHistoryData, SearchAnalyticsData, SearchStatusData, UrlInspectionData } from '@nuxtseo/protocol/v1/gsc';
8
8
  import type { PageInspectData, PageIssues, PageScan } from '@nuxtseo/protocol/v1/pages';
9
9
  import type { FieldVitalFindings, FieldVitals, MonitoredPages, PerformanceScanDetail, PerformanceScans, SitePerformanceOverview } from '@nuxtseo/protocol/v1/performance';
@@ -46,9 +46,14 @@ export declare function renderRecoverableBacklinks(data: RecoverableBacklinksDat
46
46
  export declare function renderMentions(data: MentionsData): string;
47
47
  export declare function renderFieldVitals(data: FieldVitals): string;
48
48
  export declare function renderFieldVitalFindings(data: FieldVitalFindings): string;
49
- export declare function renderBacklinksSummary(data: BacklinksSummary): string;
50
- export declare function renderReferringDomains(data: ReferringDomains): string;
51
- export declare function renderBacklinkAnchors(data: BacklinkAnchors): string;
49
+ /**
50
+ * With Link Spam Networks, the two profile counts print without them, and each
51
+ * count says how many more the networks hold. `linkSpamNetworks` is absent from
52
+ * an API that predates it, and the summary then prints as it always did.
53
+ */
54
+ export declare function renderBacklinksSummary(data: BacklinksSummaryData): string;
55
+ export declare function renderReferringDomains(data: ReferringDomainsData): string;
56
+ export declare function renderBacklinkAnchors(data: BacklinkAnchorsData): string;
52
57
  export declare function renderBacklinksHistory(data: BacklinksHistory): string;
53
58
  export declare function renderSiteStatus(data: SiteStatus): string;
54
59
  export declare function renderActionDismissal(data: ActionDismiss): string;
package/dist/render.js CHANGED
@@ -58,7 +58,9 @@ export function renderAction(data) {
58
58
  return [
59
59
  fields([
60
60
  ['Action', data.id],
61
- ['Status', data.status],
61
+ ['Status', data.markedAt && data.status === 'open' ? 'Claimed, verifying' : data.status],
62
+ ['Claimed at', data.markedAt ?? ''],
63
+ ['Check back at', data.checkBackAt ?? ''],
62
64
  ['Diagnosis', data.diagnosis],
63
65
  ['Do', data.action],
64
66
  ['Finding', data.findingType],
@@ -70,7 +72,9 @@ export function renderAction(data) {
70
72
  export function renderActionResolution(data) {
71
73
  return fields([
72
74
  ['Action', data.actionId],
73
- ['Status', data.status],
75
+ ['Status', data.markedAt && data.status === 'open' ? 'Claimed, verifying' : data.status],
76
+ ['Claimed at', data.markedAt ?? ''],
77
+ ['Check back at', data.checkBackAt ?? ''],
74
78
  ['Verification', data.verification],
75
79
  ]);
76
80
  }
@@ -429,7 +433,11 @@ export function renderIndexingDiagnostics(data) {
429
433
  ['Indexed', data.indexed],
430
434
  ['Not indexed', Math.max(0, data.totalUrls - data.indexed)],
431
435
  ['Indexed rate', indexedPercent],
436
+ ['Oldest check', data.oldestCheckAt],
432
437
  ['As of', data.asOf],
438
+ ['As of source', data.asOfSource],
439
+ ['Verdicts older than 7d', data.verdictFreshness ? `${data.verdictFreshness.olderThan7dPercent}%` : null],
440
+ ['Verdicts older than 30d', data.verdictFreshness ? `${data.verdictFreshness.olderThan30dPercent}%` : null],
433
441
  ]),
434
442
  ...(issues.length > 0 ? ['Issues', ...issues] : []),
435
443
  ].join('\n');
@@ -437,7 +445,7 @@ export function renderIndexingDiagnostics(data) {
437
445
  if (data.urls.length === 0)
438
446
  return 'No Search Console indexing URLs match these filters.';
439
447
  return [
440
- `Search Console indexing URLs (${data.urls.length} of ${data.total})`,
448
+ `Search Console indexing URLs (${data.urls.length} of ${data.totalSemantics === 'lower-bound' ? 'at least ' : ''}${data.total})`,
441
449
  ...data.urls.map(row => `${row.verdict ?? 'unknown'} ${row.coverageState ?? 'unknown'}\n ${row.url}`),
442
450
  data.hasMore ? `More URLs available after offset ${data.offset + data.limit}.` : '',
443
451
  ].filter(Boolean).join('\n');
@@ -618,12 +626,53 @@ export function renderFieldVitalFindings(data) {
618
626
  function spendLine(evidence) {
619
627
  return ['Source', `${evidence._tag} (${evidence.spend.units} unit)`];
620
628
  }
629
+ function counted(count, one, many) {
630
+ return `${count} ${count === 1 ? one : many}`;
631
+ }
632
+ /**
633
+ * The anchor is third-party text. JSON quoting keeps a network on one line and
634
+ * prints a control character, such as a terminal escape, as an escape code.
635
+ */
636
+ function linkSpamNetworkLine(network) {
637
+ return [
638
+ counted(network.referringDomains, 'domain', 'domains'),
639
+ JSON.stringify(network.anchor),
640
+ network.linkingPath === null ? '' : `from ${network.linkingPath}`,
641
+ network.otherSites > 0 ? `on ${counted(network.otherSites, 'other Site', 'other Sites')}` : '',
642
+ ].filter(Boolean).join(' ');
643
+ }
644
+ /**
645
+ * The two profile counts without the networks, each followed by the part of the
646
+ * total the networks hold, so the two numbers add up to the total. The server
647
+ * never subtracts below the stored domains outside every network, so that part
648
+ * can be smaller than the networks' own counts.
649
+ */
650
+ function countsWithoutNetworks(data, without, networks) {
651
+ const width = Math.max(String(without.backlinks).length, String(without.referringDomains).length);
652
+ const inNetworks = counted(networks.length, 'link spam network', 'link spam networks');
653
+ const count = (total, own, networkCount) => {
654
+ const held = total === null ? networkCount : Math.max(total - own, 0);
655
+ return `${String(own).padEnd(width)} (${held} more in ${inNetworks})`;
656
+ };
657
+ return {
658
+ backlinks: count(data.backlinks, without.backlinks, networks.reduce((sum, network) => sum + network.backlinks, 0)),
659
+ referringDomains: count(data.referringDomains, without.referringDomains, networks.reduce((sum, network) => sum + network.referringDomains, 0)),
660
+ };
661
+ }
662
+ /**
663
+ * With Link Spam Networks, the two profile counts print without them, and each
664
+ * count says how many more the networks hold. `linkSpamNetworks` is absent from
665
+ * an API that predates it, and the summary then prints as it always did.
666
+ */
621
667
  export function renderBacklinksSummary(data) {
622
- return fields([
668
+ const networks = data.linkSpamNetworks ?? [];
669
+ const without = networks.length > 0 ? data.withoutLinkSpamNetworks ?? null : null;
670
+ const counts = without ? countsWithoutNetworks(data, without, networks) : data;
671
+ const totals = fields([
623
672
  ['Target', data.target],
624
673
  ['Domain rank', data.rank],
625
- ['Backlinks', data.backlinks],
626
- ['Referring domains', data.referringDomains],
674
+ ['Backlinks', counts.backlinks],
675
+ ['Referring domains', counts.referringDomains],
627
676
  ['Referring main domains', data.referringMainDomains],
628
677
  ['Referring pages', data.referringPages],
629
678
  ['Broken backlinks', data.brokenBacklinks],
@@ -631,6 +680,30 @@ export function renderBacklinksSummary(data) {
631
680
  ['Spam score', data.spamScore],
632
681
  spendLine(data.evidence),
633
682
  ]);
683
+ if (networks.length === 0)
684
+ return totals;
685
+ return [
686
+ totals,
687
+ [`Link spam networks (${networks.length})`, ...networks.map(network => ` ${linkSpamNetworkLine(network)}`)].join('\n'),
688
+ ].join('\n\n');
689
+ }
690
+ const LINK_SPAM_NETWORK_MARK = ' (link spam network)';
691
+ /**
692
+ * A row the server tagged as a Link Spam Network member ends with a marker, and
693
+ * one line under the list counts them. An untagged row is not known to be in a
694
+ * network, which is never "verified clean". An API that predates the field
695
+ * tags nothing, and the list prints as it always did.
696
+ */
697
+ function networkMark(item) {
698
+ return item.linkSpamNetworkId ? LINK_SPAM_NETWORK_MARK : '';
699
+ }
700
+ function linkSpamNetworkFooter(items) {
701
+ const tagged = items.filter(item => item.linkSpamNetworkId).length;
702
+ if (tagged === 0)
703
+ return [];
704
+ return [tagged === 1
705
+ ? '1 row is in a link spam network. Google ignores most links like these.'
706
+ : `${tagged} rows are in link spam networks. Google ignores most links like these.`];
634
707
  }
635
708
  export function renderReferringDomains(data) {
636
709
  if (data.items.length === 0)
@@ -638,17 +711,17 @@ export function renderReferringDomains(data) {
638
711
  return [
639
712
  `Referring domains for ${data.target} (${data.items.length} of ${data.total})`,
640
713
  ...data.items.map(item => [
641
- `${item.domain}`,
714
+ `${item.domain}${networkMark(item)}`,
642
715
  ` ${fields([
643
716
  ['rank', item.rank],
644
717
  ['backlinks', item.backlinks],
645
718
  ['referring pages', item.referringPages],
646
719
  ['broken backlinks', item.brokenBacklinks],
647
- ['spam score', item.spamScore],
648
720
  ['sessions (90d)', item.sessions],
649
721
  ['first seen', item.firstSeen],
650
722
  ]).split('\n').join('\n ')}`,
651
723
  ].join('\n')),
724
+ ...linkSpamNetworkFooter(data.items),
652
725
  fields([spendLine(data.evidence)]),
653
726
  ].join('\n');
654
727
  }
@@ -658,7 +731,7 @@ export function renderBacklinkAnchors(data) {
658
731
  return [
659
732
  `Backlink anchors for ${data.target} (${data.items.length} of ${data.total})`,
660
733
  ...data.items.map(item => [
661
- `${item.anchor || '(empty anchor)'}`,
734
+ `${item.anchor || '(empty anchor)'}${networkMark(item)}`,
662
735
  ` ${fields([
663
736
  ['backlinks', item.backlinks],
664
737
  ['referring domains', item.referringDomains],
@@ -667,6 +740,7 @@ export function renderBacklinkAnchors(data) {
667
740
  ['first seen', item.firstSeen],
668
741
  ]).split('\n').join('\n ')}`,
669
742
  ].join('\n')),
743
+ ...linkSpamNetworkFooter(data.items),
670
744
  fields([spendLine(data.evidence)]),
671
745
  ].join('\n');
672
746
  }
@@ -687,12 +761,16 @@ export function renderSiteStatus(data) {
687
761
  blocks.push([
688
762
  data.assessment.summary,
689
763
  ` ${data.assessment.recommendation}`,
764
+ ...(data.assessment.scope === 'route-family' && data.assessment.aggregate
765
+ ? [`Whole Site: ${data.assessment.aggregate.summary}`]
766
+ : []),
690
767
  ].join('\n'));
691
768
  }
692
769
  blocks.push(fields([
693
770
  ['Site', data.site],
694
771
  ['Reach', data.assessment?.reachStage],
695
772
  ['Health', data.assessment?.healthStage],
773
+ ['Headline scope', data.assessment?.scope],
696
774
  ['Assessed', data.assessment?.assessedAt],
697
775
  ['Data quality', data.dataQuality.status === 'complete'
698
776
  ? 'complete'
package/dist/skill.js CHANGED
@@ -211,6 +211,22 @@ export function skillNoticeFor(installed, current, agent) {
211
211
  }
212
212
  export function skillNoticeLine(notice) {
213
213
  const installed = notice.installed === null ? 'skill version unknown' : `skill ${notice.installed} installed`;
214
+ if (notice.installed !== null) {
215
+ const installedParts = /^(\d+)\.(\d+)\.(\d+)$/.exec(notice.installed);
216
+ const currentParts = /^(\d+)\.(\d+)\.(\d+)$/.exec(notice.current);
217
+ if (installedParts && currentParts) {
218
+ for (let part = 1; part <= 3; part++) {
219
+ const difference = Number(installedParts[part]) - Number(currentParts[part]);
220
+ if (difference > 0)
221
+ return `${installed}, CLI ${notice.current}. Update CLI before refreshing the skill: pnpm add -g @nuxtseo/cli`;
222
+ if (difference < 0)
223
+ break;
224
+ }
225
+ }
226
+ else {
227
+ return `${installed}, CLI ${notice.current}. Check which version is newer before refreshing the skill.`;
228
+ }
229
+ }
214
230
  return `${installed}, CLI ${notice.current}. Refresh with: nuxtseo skill install --agent ${notice.agent}`;
215
231
  }
216
232
  export function parseSkillCheckCache(content) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nuxtseo/cli",
3
3
  "type": "module",
4
- "version": "0.4.1",
4
+ "version": "0.5.1",
5
5
  "description": "Command line interface for the Nuxt SEO public API.",
6
6
  "license": "MIT",
7
7
  "homepage": "https://nuxtseo.com/pro",
@@ -33,8 +33,8 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "@clack/prompts": "^1.8.0",
36
- "@nuxtseo/protocol": "^0.4.1",
37
- "@nuxtseo/sdk": "^0.4.1",
36
+ "@nuxtseo/protocol": "^0.5.1",
37
+ "@nuxtseo/sdk": "^0.5.1",
38
38
  "citty": "^0.2.2",
39
39
  "pathe": "^2.0.3"
40
40
  },
@@ -28,13 +28,23 @@ Read the matching file before you act on its subject:
28
28
  pnpm add -g @nuxtseo/cli # global install, Node 22+
29
29
  ```
30
30
 
31
- Inside the Nuxt SEO monorepo, run `node packages/cli/dist/cli-entry.js`
32
- instead.
31
+ Inside the Nuxt SEO monorepo, build the workspace dependencies in order
32
+ before using `node packages/cli/dist/cli-entry.js`:
33
33
 
34
- 2. **Skill version.** Run `nuxtseo --version --json`. If stderr carries a line
35
- like `skill 0.3.0 installed, CLI 0.4.0. Refresh with: nuxtseo skill install
36
- --agent claude`, run that command and re-read this file. A stale skill hides
37
- commands, so a missing command reads as a missing feature. If
34
+ ```sh
35
+ pnpm --filter @nuxtseo/protocol build
36
+ pnpm --filter @nuxtseo/sdk build
37
+ pnpm --filter @nuxtseo/cli build
38
+ node packages/cli/dist/cli-entry.js --version --json
39
+ ```
40
+
41
+ Use a task-owned worktree. Missing `dist` files and missing named exports
42
+ can mean stale builds. Rebuild all three packages before diagnosing source.
43
+
44
+ 2. **Skill version.** Run `nuxtseo --version --json`. Compare the CLI version
45
+ with this file's frontmatter version. If the CLI is older, update the CLI
46
+ first. If the skill is older, run `nuxtseo skill install --agent claude` and
47
+ re-read this file. An older skill hides commands. If
38
48
  `NUXTSEO_NO_UPDATE_CHECK` is set, the line never appears; compare this
39
49
  file's frontmatter `version` with the binary instead.
40
50
 
@@ -107,10 +117,25 @@ nuxtseo status --site <site-id> --json
107
117
  ```
108
118
 
109
119
  - `available: false` means the first assessment has not run.
120
+ - Read `assessment.scope` before you quote `assessment.summary`. `site` means
121
+ the headline describes the whole Site. `route-family` means it describes one
122
+ Route family: a lead to check, never the Site verdict. `assessment.aggregate`
123
+ is always the whole-Site verdict. If `healthStage` is `quality_rejection`,
124
+ the whole Site is affected, whatever a route family shows.
125
+ `quality_rejection` is a broad indexing stage. It includes discovery gaps.
126
+ Read the summary and Google coverage reasons before blaming content quality.
127
+ It does not prove that Google crawled and rejected the pages.
128
+ The stored assessment and retained indexing summary have separate clocks.
129
+ Compare `assessment.assessedAt` with indexing `asOf` before comparing their counts.
110
130
  - Read `dataQuality.status` before you quote the verdict. `degraded` means
111
131
  proofs were missing, so the verdict is Provisional. `unavailable` means it
112
132
  has not run.
113
133
  - If `nextAction.kind` is `action`, pass its `actionId` to step 3.
134
+ - `openCount` counts open and in-progress Actions. `actions list` returns the
135
+ admitted, unclaimed worklist. These counts can differ. A stored Next Action
136
+ can still be readable through `actions show` when the worklist omits it.
137
+ - A `candidate:` finding with diagnosis `Shadow observation` is a canary,
138
+ not a verified repair cause. Inspect its details before proposing work.
114
139
 
115
140
  For the whole Site instead of the next action, run one `pull`. It performs
116
141
  every spend-free Site read and writes one envelope per line:
@@ -119,6 +144,18 @@ every spend-free Site read and writes one envelope per line:
119
144
  nuxtseo pull --site <site-id> --json > site.ndjson
120
145
  ```
121
146
 
147
+ Inspect every line's `command` and `error`. If one read times out, retry that
148
+ read with the suggested timeout. Do not repeat successful or paid reads.
149
+ If the longer request returns a provider failure, report the request ID.
150
+ Retained `page issues` rows can name URLs, but do not replace Google's verdict.
151
+
152
+ For full analytics exports, use `--all --limit 100`. Each run stops after
153
+ 50 requests. If it exits with `paging_cap_reached`, preserve the arguments
154
+ and continue with the printed resume argument. Save each run separately.
155
+ Check row keys for repeats across pages. Repeated keyword rows can indicate
156
+ unstable upstream ordering. Report that gap instead of claiming complete
157
+ keyword coverage. Use Site timeseries totals for the Site-wide comparison.
158
+
122
159
  **2. Pick a ranked action.**
123
160
 
124
161
  ```sh
@@ -128,6 +165,10 @@ nuxtseo actions list --site <site-id> --json
128
165
  Keep server order. Check `evidence.freshness` on each row. If `verdict` is
129
166
  `aged` with a large `ageHours`, live-check a cheap sample before you fix. The
130
167
  Site may have changed since the observation.
168
+ If freshness `confidence` is `reasserted`, its age dates the repeated finding.
169
+ It does not date the underlying page observation. Read evidence group source
170
+ clocks in `actions show`. Live-check affected pages when those clocks are
171
+ unknown or old, even when the finding was recently reasserted.
131
172
 
132
173
  **3. Read the evidence.**
133
174
 
@@ -137,7 +178,11 @@ nuxtseo actions show <action-id> --site <site-id> --json
137
178
 
138
179
  It names the pages, the finding type, and when it was observed. If
139
180
  `data.workPrompt` is not null, it is the Work Prompt: the task for this action,
140
- grounded in its evidence. Start the fix from it. Two reads go deeper:
181
+ grounded in its evidence. Start the fix from it.
182
+ The Work Prompt serves several clients and may name MCP alternatives.
183
+ Use its CLI commands in this workflow. Never switch to MCP as a fallback.
184
+
185
+ Two reads go deeper:
141
186
 
142
187
  - `page issues --action-id <action-id>`: the raw observations, with URLs,
143
188
  status codes, redirect targets, and Lighthouse selectors. This is the
@@ -170,8 +215,14 @@ from `actions show`, then:
170
215
  nuxtseo actions dismiss <action-id> --site <site-id> --artifact-version <v> --yes --json
171
216
  ```
172
217
 
173
- The server admits a dismiss only for a broken-page action with complete 404 or
174
- 410 evidence and no internal referrers.
218
+ Without a note, the server admits a dismiss only for a broken-page action with
219
+ complete 404 or 410 evidence and no internal referrers.
220
+
221
+ If the owner confirms an action is intentional or incorrect, record that
222
+ decision with `--note "<reason>"`. Review the exact scope first. A note dismisses
223
+ the whole action, so never use it to hide a mix of intentional and broken URLs.
224
+ Dismissal keeps the observations and does not verify a fix. The dashboard can
225
+ restore the action later.
175
226
 
176
227
  **7. Mark the day.**
177
228
 
@@ -190,7 +241,7 @@ you got:
190
241
  | --- | --- |
191
242
  | `page inspect` | `observations.coverage` |
192
243
  | `page issues` | `availableKeys` |
193
- | `search cohorts` | `analysed: false`: no completed crawl, or no Google index state |
244
+ | `search cohorts` | `analysed: false`: no completed crawl, no Google index state, or `reason: "sampled-index-state"` |
194
245
  | `vitals summary` | `available: false` |
195
246
  | `research keywords`, `research rankings`, `research domain-*`, `page issues`, `search cohorts`, `vitals findings` | `data.message`, and `data.tip` for the repair |
196
247
 
@@ -200,15 +251,23 @@ a seed over three words, still exits `0`, and returns `keywords: []` with
200
251
 
201
252
  **A refusal is not an empty Site.** Exit `4` is a plan, scope, or entitlement
202
253
  blocker, for example `entitlement_required`. Exit `5` on `status`, `page
203
- issues`, or `search cohorts` can mean an archived or paused Site. Report the
204
- blocker and stop. Do not read it as "no data" or "nothing wrong". Do not
205
- retry unchanged, and do not route around it with `pull` or another read.
254
+ issues`, or `search cohorts` can mean a paused Site.
255
+ Removed Sites return `not_found` with exit `8`. Report the blocker and stop.
256
+ Do not read it as "no data" or "nothing wrong". Do not retry unchanged,
257
+ and do not route around it with `pull` or another read.
206
258
 
207
259
  **Quote the count the result ships.** Never quote the length of a list. Use
208
260
  `data.total` from `page issues`, and `coverage.accessibilityChecksTotal` and
209
261
  `coverage.bestPracticesChecksTotal` from `scans show`. `possiblyTruncated:
210
262
  true` marks the list as a floor.
211
263
 
264
+ For `search indexing urls`, read `totalSemantics` before quoting `total`.
265
+ `lower-bound` means the provider used lookahead instead of counting every URL.
266
+ The total can rise between pages without a filter change. Follow `hasMore` or
267
+ use `--all`. A nonempty terminal page reports `exact` for that retained URL population.
268
+ An empty page beyond offset zero reports `unknown`; its total can equal the requested offset.
269
+ Do not compare this population with a summary or cohort from another capture.
270
+
212
271
  **Keep these datasets apart:**
213
272
 
214
273
  - `performance` and `scans *` read Lighthouse **lab** Scans. `vitals summary`
@@ -224,20 +283,40 @@ true` marks the list as a floor.
224
283
  `search inspect` read retained Search Console evidence.
225
284
  - An indexed count comes from `search indexing summary`, never from analytics
226
285
  rows. It counts retained URL Inspection evidence as of `data.asOf`, not
227
- Google's live index. Name that source, quote `asOf`, and never label the
228
- figure current or "right now", even in a summary line. See
286
+ Google's live index. Name that source, quote `asOf` with `oldestCheckAt`
287
+ (the evidence range), and never label the figure current or "right now",
288
+ even in a summary line. See
229
289
  [references/indexing.md](references/indexing.md).
230
290
 
231
291
  **Route families: `established` versus `ranked`.** Read `search cohorts`
232
- before you list single not-indexed URLs. Never merge its two lists:
292
+ before you list single not-indexed URLs. If it returns `analysed: false` with
293
+ `reason: "sampled-index-state"`, the not-indexed list is a capped sample of the
294
+ count Search Console reports. No family rate exists. Read `search indexing
295
+ summary` instead, and never infer that unlisted URLs are indexed. Never merge
296
+ its two lists:
233
297
 
234
298
  - `established` holds only families whose not-indexed rate is significant
235
299
  against the rest of the Site, after a Bonferroni correction. Only these rows
236
300
  are a finding or a cause.
301
+ - `coverage.notIndexedNotInCrawl` counts not-indexed URLs that the Nuxt SEO
302
+ crawl did not reach. It does not count pages Google never crawled.
237
303
  - `ranked` orders every family by raw rate, with no significance test. A row
238
304
  with `statisticallyEstablished: false` is a lead to verify, for example by
239
305
  inspecting its URLs. Presenting it as a cause is a hard failure.
240
306
 
307
+ **Compare equal windows.** Read `search analytics timeseries` for the Site's
308
+ current and previous totals. `audit content-decay` ships its own `window`.
309
+ It may span three months even when `pull --period 28d` selects 28-day analytics.
310
+ Quote that window separately. Never present its losses as the 28-day decline.
311
+ Content decay and duplicate reports return fixed shortlists. If `total` exceeds the returned
312
+ row count, disclose both counts, even when `page.hasMore` is false. That flag
313
+ does not promise the shortlist includes every Finding.
314
+
315
+ **Check alternate formats.** A `.md` URL can serve the HTML page's content
316
+ with a `Link: <html-url>; rel="canonical"` response header. Compare HTML
317
+ siblings with HTML siblings. Do not remove a valid header canonical or add
318
+ `noindex` just to clear a missing HTML canonical warning.
319
+
241
320
  ## Guardrails
242
321
 
243
322
  - **Get consent before a mutation.** `page scan`, `actions resolve`, `actions
@@ -120,19 +120,19 @@ lines.
120
120
 
121
121
  | Command | What it returns, and its flags |
122
122
  | --- | --- |
123
- | `status` | The Site assessment in one read: verdict, the single ranked Next Action, open and verified counts, degraded pipeline steps, recent changes, Receipts. `available: false` means the first assessment has not run. If the Site is archived or paused, this exits `4` or `5` rather than answering |
123
+ | `status` | The Site assessment in one read: verdict, the single ranked Next Action, open and verified counts, degraded pipeline steps, recent changes, Receipts. `assessment.scope` says whether the headline covers the whole Site (`site`) or one route family (`route-family`); `assessment.aggregate` is always the whole-Site verdict. `available: false` means the first assessment has not run. Paused Sites exit `4` or `5`. Removed Sites return `not_found` with exit `8` |
124
124
  | `actions list` | Server-ranked issues and opportunities. `--limit 1..25` (default 10), `--offset`. Each row carries `evidence.freshness`; `verdict: "aged"` means the observation is over a day old, so live-check before fixing |
125
125
  | `actions show <action-id>` | One issue or opportunity, plus its evidence. `--limit 1..100` (default 50), `--group-id`, `--cursor` |
126
126
  | `actions resolve <action-id>` | Mutation. Claims the issue or opportunity and starts server verification. Reads the action first and sends `artifactVersion` for you |
127
- | `actions dismiss <action-id>` | Mutation. Says the page stays removed. Needs `--artifact-version`, read from `actions show`. Admitted only for a broken-page action with complete 404 or 410 evidence and no internal referrers |
127
+ | `actions dismiss <action-id>` | Mutation. Needs `--artifact-version`, read from `actions show`. `--note` records why the owner considers the action intentional or incorrect. Without a note, complete unlinked 404 or 410 evidence is required. Dismissal preserves observations and never verifies a fix |
128
128
 
129
129
  ## Pages, Scans, and lab performance
130
130
 
131
131
  | Command | What it returns, and its flags |
132
132
  | --- | --- |
133
133
  | `page inspect <url>` | Stored observations, Lighthouse rows and keywords for one Page. `--limit 1..200`, `--offset`, `--include-resolved`. Carries `observations.coverage`: `never-scanned` means no source recorded this Page, `scanned-clear` means recorded and all clear |
134
- | `page issues` | Raw Page Issue rows. Pass `--action-id`, or pass both `--source` and `--issue`. Passing both selectors exits `2`. `--url`, `--path-prefix`, `--include-resolved`, `--limit 1..200`, `--offset`. When the selector matches nothing, `availableKeys` lists the pairs this Site does have. If the Site is archived or paused, this exits `4` or `5` rather than answering |
135
- | `page scan <url>` | Mutation. Starts a mobile Scan and a desktop Scan |
134
+ | `page issues` | Raw Page Issue rows. Pass `--action-id`, or pass both `--source` and `--issue`. Passing both selectors exits `2`. `--url`, `--path-prefix`, `--include-resolved`, `--limit 1..200`, `--offset`. When the selector matches nothing, `availableKeys` lists the pairs this Site does have. Paused Sites exit `4` or `5`. Removed Sites return `not_found` with exit `8` |
135
+ | `page scan <url>` | Mutation. Adds or re-enables the URL as a monitored Page, then starts mobile and desktop Scans |
136
136
  | `performance` | Stored Lighthouse lab overview for the Site: medians for perf, a11y, SEO, LCP, TBT, CLS. Lab Scans, never real-user field data |
137
137
  | `scans list` | Retained Lighthouse Scans, newest first, with scores, lab vitals, and the error on a failed Scan. `--limit 1..100` (default 25) |
138
138
  | `scans show <scan-id>` | One Scan, and the evidence behind its scores: wasteful assets, repeat-visit cache policy, third-party scripts, the LCP element, and every failing accessibility and best-practices check with its CSS selectors and DOM snippets. Read `coverage` before quoting a total |
@@ -141,6 +141,17 @@ lines.
141
141
  `page inspect` and `page scan` take an absolute URL, for example
142
142
  `https://example.com/about`.
143
143
 
144
+ `page scan` enables recurring monitoring.
145
+ Read `scans pages` before scanning many new URLs. The server refuses a new
146
+ Page when five monitored Pages are already enabled. That limit comes from
147
+ the Page scan service, not the Team plan.
148
+
149
+ `pageCreated: true` means a monitored Page was created. The URL may already
150
+ have retained Scans. Automatic homepage assessment Scans use a separate
151
+ slot without a monitored Page. A manual homepage Scan can therefore create
152
+ a monitored Page while older homepage assessment Scans exist.
153
+ The monitored Page then follows its own recurring schedule.
154
+
144
155
  ## Field Core Web Vitals
145
156
 
146
157
  | Command | What it returns, and its flags |
@@ -158,7 +169,7 @@ lines.
158
169
  | `search indexing <summary\|urls>` | Retained URL Inspection coverage. `summary` returns indexed counts. `urls` supports `--issue`, `--status`, `--limit 1..500` (default 50), `--offset` |
159
170
  | `search index-history` | Retained indexing changes and candidate causes. `--url`, `--field`, `--days 1..730`. Dates a regression against a release |
160
171
  | `search inspect <url>` | Google's own verdict for one URL. Takes an absolute URL or a site-relative path |
161
- | `search cohorts` | Not-indexed rate per route family. `--limit 1..50` (default 12), `--min-pages 1..500` (default 5). If the Site is archived or paused, this exits `4` or `5` rather than answering |
172
+ | `search cohorts` | Not-indexed rate per route family. Returns `analysed: false` when the not-indexed list is a sample (`reason: sampled-index-state`). `--limit 1..50` (default 12), `--min-pages 1..500` (default 5). Paused Sites exit `4` or `5`. Removed Sites return `not_found` with exit `8` |
162
173
  | `sitemaps list` | Search Console sitemap snapshot: errors, warnings, URL counts, sync status |
163
174
  | `sitemaps urls` | Sitemap URL membership. `--generation-id`, `--feedpath`, `--cursor`, `--limit 1..1000` |
164
175
  | `sitemaps submit <sitemap-url>` | Mutation. Submits one sitemap through the Site's stored Search Console credential |
@@ -177,23 +188,33 @@ different numbers. Read the one that answers your question.
177
188
  | Field | What it counts |
178
189
  | --- | --- |
179
190
  | `total` | The number of rows the query matched, not clicks or impressions |
180
- | `totals` | Clicks, impressions, CTR and position for the full upstream query population. `totalsScope` says so in words. It may include rows outside this page. `null` when the server has no total |
191
+ | `totals` | Upstream period totals from a separate read without grouping dimensions. These can include anonymized queries. Read `totalsScope`. `null` means unavailable |
181
192
  | `representedTotals` | Clicks and impressions for the rows in this response only, after local filtering. `representedTotalsScope` says so in words |
182
193
 
183
- So `representedTotals` is at or below `totals`, and raising `--limit` moves
184
- `representedTotals` while leaving `totals` still. Never sum `rows` by hand when
185
- `totals` is present.
194
+ Raising `--limit` changes `representedTotals` while `totals` covers the full window.
195
+ Grouped Page impressions can exceed whole-Site impressions when several Pages appear in one search.
196
+ Do not assume `representedTotals` is at or below `totals`.
197
+ Use `totals` for traffic totals, and `representedTotals` for the returned rows.
198
+ Neither row filters nor pagination make `totals` a sum of the returned rows.
199
+
200
+ Keyword lists omit `site:` and `inurl:` search operators after upstream paging.
201
+ `total` and `page` still count upstream rows.
202
+ Even `--all` can return fewer keyword rows than `total`.
186
203
 
187
- ### Never compare a keyword total with a page total
204
+ ### Read keyword coverage separately
188
205
 
189
- Google suppresses query rows below a privacy threshold. So the `keywords` view
190
- sees a fraction of the traffic the `pages` view sees. On a typical property
191
- that is roughly half the impressions. Query-dimension totals are therefore
192
- always at or below page-dimension totals for the same Site and period. Never
193
- compare the two, and never report one as the Site total.
206
+ Google suppresses query rows below a privacy threshold.
207
+ Keyword rows therefore omit some traffic, even after `--all`.
208
+ Their `representedTotals` measure only the returned keywords.
194
209
 
195
- Read the `pages` view for "how much traffic". Read the `keywords` view for
196
- "which terms are visible". A missing query row is never evidence of no demand.
210
+ The engine can read dimensionless totals that include anonymized traffic.
211
+ Then `keywords.totals` can equal `pages.totals` for the same Site and period.
212
+ Do not assume `keywords.totals` is the sum of visible keyword rows.
213
+
214
+ Read `timeseries.totals` for whole-Site traffic, `pages` rows for landing pages,
215
+ and `keywords` rows for visible terms. Use `prevTotals` to compare Site traffic
216
+ across periods.
217
+ A missing query row is never evidence of no demand.
197
218
 
198
219
  ## Web Analytics
199
220
 
@@ -215,9 +236,9 @@ research boundary in SKILL.md first.
215
236
  | `research rankings <domain>` | Live domain rankings: current keywords and domain metrics. `--limit 1..100` (default 50), `--min-position 1..100` (default 1), `--max-position 1..100` (default 20), `--location-code` (default 2840), `--order position\|traffic` (default `position`). So a default run returns only the top 20 positions. If `--max-position` is under `--min-position`, the run exits `2`. An unusable domain also exits `2`, with `invalid_request`. An empty `data.keywords` with exit `0` carries the reason in `data.message`. `data.cached` reports cache use |
216
237
  | `research domain-traffic <domain>` | A live organic traffic estimate for any domain: trend, top pages, top countries. The domain does not have to be one of your Sites |
217
238
  | `research domain-availability <domains>` | Registration status. One comma-separated positional, up to 10 domains |
218
- | `backlinks summary` | Whole-domain inbound link totals for the Site's own domain: `backlinks`, `referringDomains`, `referringPages`, `brokenBacklinks`, `brokenPages`, `crawledPages`, `spamScore`, `rank`. Takes no flags; it always reads the registered Site URL, so use `research domain-traffic` for any other domain. Every count is nullable. A `null` count means the provider returned no value. Never read it as zero. `evidence` reports cache use. Needs a Site URL |
219
- | `backlinks referring-domains` | Domains that link to the Site. `--limit 1..1000` (default 100). `sessions` is 90-day referral traffic, or null when no Web Analytics property is linked. Needs a Site URL |
220
- | `backlinks anchors` | Anchor text distribution of inbound links. `--limit 1..1000` (default 100). Needs a Site URL |
239
+ | `backlinks summary` | Whole-domain inbound link totals for the Site's own domain: `backlinks`, `referringDomains`, `referringPages`, `brokenBacklinks`, `brokenPages`, `crawledPages`, `spamScore`, `rank`. Takes no flags; it always reads the registered Site URL, so use `research domain-traffic` for any other domain. Every count is nullable. A `null` count means the provider returned no value. Never read it as zero. `linkSpamNetworks` groups referring domains that each place one generated anchor with the Site's host in it. Google ignores most links like these. `withoutLinkSpamNetworks` gives `referringDomains` and `backlinks` without them, or `null` when there is no network or no total. `evidence` reports cache use. Needs a Site URL |
240
+ | `backlinks referring-domains` | Domains that link to the Site. `--limit 1..1000` (default 100). `sessions` is 90-day referral traffic, or null when no Web Analytics property is linked. `linkSpamNetworkId` names the link spam network of a known member domain; null means not known, never verified clean. Needs a Site URL |
241
+ | `backlinks anchors` | Anchor text distribution of inbound links. `--limit 1..1000` (default 100). `linkSpamNetworkId` names the link spam network whose generated anchor the row is; null means not known, never verified clean. Needs a Site URL |
221
242
  | `backlinks history` | A monthly inbound link series. `--from YYYY-MM-DD`. Defaults to about twelve months. Needs a Site URL |
222
243
  | `backlinks recoverable` | Stored recoverable backlinks. `--limit 1..200`, `--offset`. Reads retained rows |
223
244
  | `mentions list` | Stored mentions. `--limit 1..200` (default 100), `--include-filtered` to keep the Mentions AI triage marked a false positive. Reads retained rows |
@@ -254,3 +275,21 @@ nuxtseo actions list --help --json
254
275
 
255
276
  Use it instead of guessing a flag. Prefer the tables above for anything they
256
277
  already answer.
278
+
279
+ ### Claim response
280
+
281
+ `actions resolve` saves your Claim. It returns before a requested scan finishes starting.
282
+ `status: "open"` stays unchanged until the engine verifies the Action.
283
+ Human output shows `Claimed, verifying` and the suggested check-back time.
284
+ Claimed Actions leave `actions list`. Use `actions show <action-id>` to check them.
285
+
286
+ Both `resolve` and `show` return these additive fields:
287
+
288
+ | Field | Meaning |
289
+ | --- | --- |
290
+ | `markedAt` | ISO timestamp when your Claim was saved, or `null` before a Claim. |
291
+ | `checkBackAt` | Suggested ISO timestamp to check again, or `null` when no automated check applies. |
292
+
293
+ A check-back time does not promise a completed check.
294
+ `scan-queued` can mean a re-assessment using scheduled crawl results, without a new scan.
295
+ A self-resolved Claim returns `status: "verified"` and `verification: "user-resolved"`.
@@ -11,11 +11,17 @@ Start with the retained URL Inspection summary:
11
11
  nuxtseo search indexing summary --site <site-id> --json
12
12
  ```
13
13
 
14
- Report `data.totalUrls`, `data.indexed`, and `data.asOf`. These are exact for
15
- the retained URL Inspection evidence at that timestamp. They are not a live
16
- count of Google's whole index. State that boundary with the result. If
17
- `data.asOf` is null, say the retained snapshot time is unavailable. Never call
18
- that result current or live.
14
+ Report `data.totalUrls`, `data.indexed`, `data.oldestCheckAt`, and
15
+ `data.asOf`. These are exact for the retained URL Inspection evidence in that
16
+ date range. `data.asOf` is when gscdump counted the verdicts, or the newest
17
+ check (`data.asOfSource` names it: `capture`, `rollup`, or `newest-check`). The
18
+ verdicts run back to `data.oldestCheckAt`, so quote both. When
19
+ `data.verdictFreshness` is set, quote it too: a fresh count can rest on old
20
+ verdicts, and `olderThan30dPercent` says how many. They are not a live
21
+ count of Google's whole index. The retained set can be far smaller than the
22
+ Search Console UI totals. State that boundary with the result. If `data.asOf`
23
+ is null, say the evidence age is unknown. Never call that result current or
24
+ live.
19
25
 
20
26
  Use the URL view when the user asks which URLs hold a verdict:
21
27
 
@@ -54,7 +60,7 @@ Keep these reads separate:
54
60
 
55
61
  | Question | Command | Report |
56
62
  | --- | --- | --- |
57
- | What is the indexed count for retained URLs? | `search indexing summary` | `totalUrls`, `indexed`, `asOf` |
63
+ | What is the indexed count for retained URLs? | `search indexing summary` | `totalUrls`, `indexed`, `oldestCheckAt`, `asOf` |
58
64
  | Which retained URLs are indexed or not indexed? | `search indexing urls` | `total`, then the complete `urls` stream |
59
65
  | Which URLs appeared in the current period? | `search analytics pages` | Rows with `impressions > 0`, then each `url` |
60
66
  | What does Google say about one URL now? | `search inspect <url>` | The returned URL Inspection verdict |
@@ -34,7 +34,7 @@ These fields carry the reason:
34
34
  | `research domain-traffic` | `data.message` |
35
35
  | `research domain-availability` | `data.message` |
36
36
  | `page issues` | `data.message`, plus `availableKeys` |
37
- | `search cohorts` | `data.message`, plus `reason` |
37
+ | `search cohorts` | `data.message`, plus `reason` (`no-completed-crawl`, `no-inspection-join`, or `sampled-index-state`) |
38
38
  | `vitals findings` | `data.message`, plus `source` |
39
39
 
40
40
  `evidence._tag: "no-provider"` does not prove the data is absent. On `research