@nuxtseo/cli 0.1.4 → 0.2.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/dist/commands.js CHANGED
@@ -1,24 +1,64 @@
1
1
  import * as prompts from '@clack/prompts';
2
+ import { publicV1BooleanFlagValue } from '@nuxtseo/protocol/v1/core';
2
3
  import { defineCommand } from 'citty';
4
+ import { dirname } from 'pathe';
3
5
  import { createApiClient, fromStateError, loadApiContext } from './api.js';
4
6
  import { openBrowser } from './browser.js';
5
7
  import { EXIT_CODE, fail, fromSdkFailure, ok } from './failures.js';
6
8
  import { awaitPairingApproval, createCliPairingClient, startPairing } from './pairing.js';
7
- import { parseAbsolutePageUrl, parseChoice, parseInteger } from './parse.js';
8
- import { renderAction, renderActionResolution, renderActions, renderContentBrief, renderContentBriefCreated, renderContentBriefs, renderContentDecay, renderDuplicateClusters, renderIndexingDiagnostics, renderKeywordResearch, renderLinkOpportunities, renderMentions, renderPageInspection, renderPageScan, renderPerformance, renderRankingsResearch, renderRecoverableBacklinks, renderResearchOverview, renderSearchAnalytics, renderSearchStatus, renderSerpResearch, renderSites, renderTimeline, renderUsage, } from './render.js';
9
+ import { parseAbsolutePageUrl, parseChoice, parseInteger, parsePageUrlOrPath, parseRatio } from './parse.js';
10
+ import { renderAccountToken, renderAction, renderActionDismissal, renderActionResolution, renderActions, renderAnalytics, renderAnnotation, renderAnnotationDeleted, renderAnnotations, renderAuditChanges, renderBacklinkAnchors, renderBacklinksHistory, renderBacklinksSummary, renderContentBrief, renderContentBriefCreated, renderContentBriefs, 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';
9
11
  import { writeCliResponse, writeDiagnostic, writeOutput, writeProtocolResponse } from './runtime.js';
10
12
  import { resolveSite } from './site.js';
13
+ import { installSkill, SKILL_AGENTS } from './skill.js';
11
14
  import { clearCredential, getCredentialStatus, readConfig, resolveApiUrl, saveCredential, updateConfig, } from './state/index.js';
12
15
  import { VERSION } from './version.js';
16
+ function emit(runtime, globals, value, render) {
17
+ if (globals.json)
18
+ writeProtocolResponse(runtime, value);
19
+ else
20
+ writeOutput(runtime, render(value));
21
+ }
13
22
  function present(runtime, globals, result, render) {
14
23
  if (result._tag === 'Err')
15
24
  return fromSdkFailure(result.error);
16
- if (globals.json)
17
- writeProtocolResponse(runtime, result.value);
18
- else
19
- writeOutput(runtime, render(result.value));
25
+ emit(runtime, globals, result.value, render);
20
26
  return ok(undefined);
21
27
  }
28
+ /**
29
+ * `--all` request cap. One page per request stays the default. `--all` repeats
30
+ * the same operation and writes one complete envelope per page, so nothing is
31
+ * merged or renamed. The cap bounds an unattended loop; reaching it exits 9 and
32
+ * names the resume argument, so a caller never mistakes a stop for an end.
33
+ */
34
+ export const ALL_PAGES_REQUEST_CAP = 50;
35
+ async function collect(runtime, globals, options) {
36
+ let position = options.start;
37
+ for (let request = 1;; request++) {
38
+ const response = await withSpinner(runtime, options.message, () => options.fetch(position));
39
+ if (response._tag === 'Err')
40
+ return fromSdkFailure(response.error);
41
+ emit(runtime, globals, response.value, options.render);
42
+ if (!options.all)
43
+ return ok(undefined);
44
+ const next = options.next(response.value, position);
45
+ if (next._tag === 'Done')
46
+ return ok(undefined);
47
+ if (request >= ALL_PAGES_REQUEST_CAP) {
48
+ return fail(EXIT_CODE.pagingCapReached, [
49
+ `paging_cap_reached: --all stopped after ${ALL_PAGES_REQUEST_CAP} requests.`,
50
+ `More pages remain. Run the command again with ${next.resume}.`,
51
+ ].join('\n'), undefined, 'paging_cap_reached');
52
+ }
53
+ position = next.position;
54
+ }
55
+ }
56
+ function offsetPage(cursor) {
57
+ const offset = cursor.offset + cursor.limit;
58
+ return cursor.hasMore
59
+ ? { _tag: 'More', position: { offset }, resume: `--offset ${offset}` }
60
+ : { _tag: 'Done' };
61
+ }
22
62
  async function withSpinner(runtime, message, task) {
23
63
  const spinner = runtime.interactive
24
64
  ? prompts.spinner({ output: runtime.error, signal: runtime.signal })
@@ -122,8 +162,11 @@ async function login(runtime, globals, options) {
122
162
  : await pairInBrowser(runtime, apiUrl.value.apiUrl, { openBrowser: !options.noBrowser });
123
163
  if (token._tag === 'Err')
124
164
  return token;
165
+ // Introspection proves the credential and reports what it can do. Listing
166
+ // Sites proved only that one read succeeded, and read the whole portfolio to
167
+ // do it.
125
168
  const client = createApiClient(apiUrl.value.apiUrl, token.value);
126
- const validated = await withSpinner(runtime, 'Validating token', () => client.sites.list({}, { signal: runtime.requestSignal }));
169
+ const validated = await withSpinner(runtime, 'Validating token', () => client.account.token({}, { signal: runtime.requestSignal }));
127
170
  if (validated._tag === 'Err')
128
171
  return fromSdkFailure(validated.error);
129
172
  const saved = await saveCredential(token.value, { paths: runtime.paths });
@@ -141,10 +184,16 @@ async function login(runtime, globals, options) {
141
184
  if (runtime.env.NUXTSEO_TOKEN !== undefined) {
142
185
  writeDiagnostic(runtime, 'warning: NUXTSEO_TOKEN remains active and takes precedence over the stored credential.');
143
186
  }
144
- if (globals.json)
187
+ if (globals.json) {
145
188
  writeProtocolResponse(runtime, validated.value);
146
- else
147
- writeOutput(runtime, `Logged in. ${validated.value.data.sites.length} Sites accessible. Credential: ${saved.value.storage}.`);
189
+ }
190
+ else {
191
+ writeOutput(runtime, [
192
+ `Logged in as ${validated.value.data.role} on ${validated.value.data.team.name ?? validated.value.data.team.id}.`,
193
+ `Credential: ${saved.value.storage}.`,
194
+ `Scopes: ${validated.value.data.scopes.join(', ')}`,
195
+ ].join('\n'));
196
+ }
148
197
  return ok(undefined);
149
198
  }
150
199
  async function logout(runtime, globals) {
@@ -168,18 +217,17 @@ async function whoami(runtime, globals) {
168
217
  const api = await loadApiContext(runtime, globals);
169
218
  if (api._tag === 'Err')
170
219
  return api;
171
- const listed = await withSpinner(runtime, 'Checking authentication', () => api.value.client.sites.list({}, { signal: runtime.requestSignal }));
172
- if (listed._tag === 'Err')
173
- return fromSdkFailure(listed.error);
220
+ const introspected = await withSpinner(runtime, 'Checking authentication', () => api.value.client.account.token({}, { signal: runtime.requestSignal }));
221
+ if (introspected._tag === 'Err')
222
+ return fromSdkFailure(introspected.error);
174
223
  if (globals.json) {
175
- writeProtocolResponse(runtime, listed.value);
224
+ writeProtocolResponse(runtime, introspected.value);
176
225
  }
177
226
  else {
178
227
  writeOutput(runtime, [
179
- 'Authenticated Team API token.',
228
+ renderAccountToken(introspected.value.data),
180
229
  `API: ${api.value.apiUrl}`,
181
- `Credential: ${api.value.credentialSource}`,
182
- `Accessible Sites: ${listed.value.data.sites.length}`,
230
+ `Credential source: ${api.value.credentialSource}`,
183
231
  `Selected Site: ${api.value.config.siteId ?? 'none'}`,
184
232
  ].join('\n'));
185
233
  }
@@ -297,21 +345,27 @@ async function usage(runtime, globals, group) {
297
345
  const response = await withSpinner(runtime, 'Loading usage', () => api.value.client.account.usage({ query: { group } }, { signal: runtime.requestSignal }));
298
346
  return present(runtime, globals, response, renderUsage);
299
347
  }
300
- async function actionsList(runtime, globals, limitInput, offsetInput) {
301
- const limit = parseInteger(limitInput, { name: '--limit', minimum: 1, maximum: 25, defaultValue: 10 });
348
+ async function actionsList(runtime, globals, args) {
349
+ const limit = parseInteger(args.limit, { name: '--limit', minimum: 1, maximum: 25, defaultValue: 10 });
302
350
  if (limit._tag === 'Err')
303
351
  return limit;
304
- const offset = parseInteger(offsetInput, { name: '--offset', minimum: 0, defaultValue: 0 });
352
+ const offset = parseInteger(args.offset, { name: '--offset', minimum: 0, defaultValue: 0 });
305
353
  if (offset._tag === 'Err')
306
354
  return offset;
307
355
  const resolved = await apiAndSite(runtime, globals);
308
356
  if (resolved._tag === 'Err')
309
357
  return resolved;
310
- const response = await withSpinner(runtime, 'Loading next actions', () => resolved.value.api.client.actions.list({
311
- params: { siteId: resolved.value.siteId },
312
- query: { limit: limit.value, offset: offset.value },
313
- }, { signal: runtime.requestSignal }));
314
- return present(runtime, globals, response, value => renderActions(value.data));
358
+ return collect(runtime, globals, {
359
+ all: args.all === true,
360
+ message: 'Loading next actions',
361
+ start: { offset: offset.value },
362
+ fetch: position => resolved.value.api.client.actions.list({
363
+ params: { siteId: resolved.value.siteId },
364
+ query: { limit: limit.value, offset: position.offset ?? 0 },
365
+ }, { signal: runtime.requestSignal }),
366
+ render: value => renderActions(value.data),
367
+ next: value => offsetPage(value.data.page),
368
+ });
315
369
  }
316
370
  async function actionsShow(runtime, globals, args) {
317
371
  const limit = parseInteger(args.limit, { name: '--limit', minimum: 1, maximum: 100, defaultValue: 50 });
@@ -362,16 +416,22 @@ async function pageInspect(runtime, globals, args) {
362
416
  const resolved = await apiAndSite(runtime, globals);
363
417
  if (resolved._tag === 'Err')
364
418
  return resolved;
365
- const response = await withSpinner(runtime, 'Inspecting Page', () => resolved.value.api.client.pages.inspect({
366
- params: { siteId: resolved.value.siteId },
367
- query: {
368
- url: url.value,
369
- includeResolved: args.includeResolved ? '1' : '0',
370
- limit: limit.value,
371
- offset: offset.value,
372
- },
373
- }, { signal: runtime.requestSignal }));
374
- return present(runtime, globals, response, value => renderPageInspection(value.data));
419
+ return collect(runtime, globals, {
420
+ all: args.all === true,
421
+ message: 'Inspecting Page',
422
+ start: { offset: offset.value },
423
+ fetch: position => resolved.value.api.client.pages.inspect({
424
+ params: { siteId: resolved.value.siteId },
425
+ query: {
426
+ url: url.value,
427
+ includeResolved: publicV1BooleanFlagValue(Boolean(args.includeResolved)),
428
+ limit: limit.value,
429
+ offset: position.offset ?? 0,
430
+ },
431
+ }, { signal: runtime.requestSignal }),
432
+ render: value => renderPageInspection(value.data),
433
+ next: value => offsetPage(value.data.observations.pagination),
434
+ });
375
435
  }
376
436
  async function pageScan(runtime, globals, rawUrl) {
377
437
  const url = parseAbsolutePageUrl(rawUrl);
@@ -411,8 +471,48 @@ async function searchStatus(runtime, globals) {
411
471
  }, { signal: runtime.requestSignal }));
412
472
  return present(runtime, globals, response, value => renderSearchStatus(value.data));
413
473
  }
474
+ const SEARCH_ANALYTICS_VIEWS = [
475
+ 'pages',
476
+ 'keywords',
477
+ 'countries',
478
+ 'devices',
479
+ 'timeseries',
480
+ 'page-detail',
481
+ 'keyword-detail',
482
+ 'analysis',
483
+ ];
484
+ /** The views the server pages with `total` and `rows`, so `--all` can walk them. */
485
+ const SEARCH_ANALYTICS_ROW_VIEWS = new Set(['pages', 'keywords', 'countries', 'devices', 'analysis']);
486
+ const SEARCH_ANALYSIS_PRESETS = [
487
+ 'striking-distance',
488
+ 'opportunity',
489
+ 'decay',
490
+ 'zero-click',
491
+ 'non-brand',
492
+ 'brand-only',
493
+ 'movers-rising',
494
+ 'movers-declining',
495
+ ];
496
+ const INDEXING_TRANSITION_FIELDS = [
497
+ 'indexStatus',
498
+ 'coverageState',
499
+ 'robotsTxtState',
500
+ 'indexingState',
501
+ 'pageFetchState',
502
+ 'googleCanonical',
503
+ ];
504
+ function optionalInteger(input, options) {
505
+ if (input === undefined)
506
+ return ok(undefined);
507
+ return parseInteger(input, { ...options, defaultValue: options.minimum });
508
+ }
509
+ function optionalChoice(input, options) {
510
+ if (input === undefined)
511
+ return ok(undefined);
512
+ return parseChoice(input, options);
513
+ }
414
514
  async function searchAnalytics(runtime, globals, args) {
415
- const view = parseChoice(args.view, { name: 'view', choices: ['pages', 'keywords'] });
515
+ const view = parseChoice(args.view, { name: 'view', choices: SEARCH_ANALYTICS_VIEWS });
416
516
  if (view._tag === 'Err')
417
517
  return view;
418
518
  const period = parseChoice(args.period, { name: '--period', choices: ['7d', '28d', '3m', '6m', '12m'], defaultValue: '28d' });
@@ -430,30 +530,100 @@ async function searchAnalytics(runtime, globals, args) {
430
530
  const sortDir = parseChoice(args.sortDir, { name: '--sort-dir', choices: ['asc', 'desc'], defaultValue: 'desc' });
431
531
  if (sortDir._tag === 'Err')
432
532
  return sortDir;
533
+ const filter = optionalChoice(args.filter, { name: '--filter', choices: ['top-level', 'new', 'lost', 'improving', 'declining'] });
534
+ if (filter._tag === 'Err')
535
+ return filter;
536
+ const preset = optionalChoice(args.preset, { name: '--preset', choices: SEARCH_ANALYSIS_PRESETS });
537
+ if (preset._tag === 'Err')
538
+ return preset;
539
+ const pageUrl = args.pageUrl === undefined ? ok(undefined) : parsePageUrlOrPath(args.pageUrl);
540
+ if (pageUrl._tag === 'Err')
541
+ return pageUrl;
542
+ const minClicks = optionalInteger(args.minClicks, { name: '--min-clicks', minimum: 0 });
543
+ if (minClicks._tag === 'Err')
544
+ return minClicks;
545
+ const maxClicks = optionalInteger(args.maxClicks, { name: '--max-clicks', minimum: 0 });
546
+ if (maxClicks._tag === 'Err')
547
+ return maxClicks;
548
+ const minImpressions = optionalInteger(args.minImpressions, { name: '--min-impressions', minimum: 0 });
549
+ if (minImpressions._tag === 'Err')
550
+ return minImpressions;
551
+ const maxImpressions = optionalInteger(args.maxImpressions, { name: '--max-impressions', minimum: 0 });
552
+ if (maxImpressions._tag === 'Err')
553
+ return maxImpressions;
554
+ const minPosition = optionalInteger(args.minPosition, { name: '--min-position', minimum: 1, maximum: 100 });
555
+ if (minPosition._tag === 'Err')
556
+ return minPosition;
557
+ const maxPosition = optionalInteger(args.maxPosition, { name: '--max-position', minimum: 1, maximum: 100 });
558
+ if (maxPosition._tag === 'Err')
559
+ return maxPosition;
560
+ const maxCtr = args.maxCtr === undefined
561
+ ? ok(undefined)
562
+ : parseRatio(args.maxCtr, '--max-ctr');
563
+ if (maxCtr._tag === 'Err')
564
+ return maxCtr;
565
+ // The server rejects these combinations too. Refusing here costs no request
566
+ // and names the exact missing argument.
567
+ if (view.value === 'page-detail' && !pageUrl.value)
568
+ return fail(EXIT_CODE.invalidInput, 'The page-detail view requires --page-url.');
569
+ if (view.value === 'keyword-detail' && !args.keyword)
570
+ return fail(EXIT_CODE.invalidInput, 'The keyword-detail view requires --keyword.');
571
+ if (view.value === 'analysis' && !preset.value)
572
+ return fail(EXIT_CODE.invalidInput, 'The analysis view requires --preset.');
573
+ if (view.value === 'analysis' && (preset.value === 'brand-only' || preset.value === 'non-brand') && !args.brandTerms?.trim())
574
+ return fail(EXIT_CODE.invalidInput, `The ${preset.value} preset requires --brand-terms.`);
575
+ if (args.all && !SEARCH_ANALYTICS_ROW_VIEWS.has(view.value))
576
+ return fail(EXIT_CODE.invalidInput, `--all supports only these views: ${[...SEARCH_ANALYTICS_ROW_VIEWS].join(', ')}.`);
433
577
  const resolved = await apiAndSite(runtime, globals);
434
578
  if (resolved._tag === 'Err')
435
579
  return resolved;
436
- const response = await withSpinner(runtime, 'Loading Search Console rows', () => resolved.value.api.client.search.queryAnalytics({
437
- params: { siteId: resolved.value.siteId },
438
- query: {
439
- view: view.value,
440
- period: period.value,
441
- limit: limit.value,
442
- page: page.value,
443
- search: args.search,
444
- sort: sort.value,
445
- sortDir: sortDir.value,
580
+ return collect(runtime, globals, {
581
+ all: args.all === true,
582
+ message: 'Loading Search Console rows',
583
+ start: { page: page.value },
584
+ fetch: position => resolved.value.api.client.search.queryAnalytics({
585
+ params: { siteId: resolved.value.siteId },
586
+ query: {
587
+ view: view.value,
588
+ period: period.value,
589
+ limit: limit.value,
590
+ page: position.page ?? 1,
591
+ search: args.search,
592
+ sort: sort.value,
593
+ sortDir: sortDir.value,
594
+ filter: filter.value,
595
+ pageUrl: pageUrl.value,
596
+ keyword: args.keyword,
597
+ preset: preset.value,
598
+ brandTerms: args.brandTerms,
599
+ minClicks: minClicks.value,
600
+ maxClicks: maxClicks.value,
601
+ minImpressions: minImpressions.value,
602
+ maxImpressions: maxImpressions.value,
603
+ minPosition: minPosition.value,
604
+ maxPosition: maxPosition.value,
605
+ maxCtr: maxCtr.value,
606
+ },
607
+ }, { signal: runtime.requestSignal }),
608
+ render: value => renderSearchAnalytics(value.data),
609
+ next: (value, position) => {
610
+ const data = value.data;
611
+ if (!('total' in data) || !('rows' in data))
612
+ return { _tag: 'Done' };
613
+ const current = position.page ?? 1;
614
+ if (data.rows.length === 0 || current * limit.value >= data.total)
615
+ return { _tag: 'Done' };
616
+ return { _tag: 'More', position: { page: current + 1 }, resume: `--page ${current + 1}` };
446
617
  },
447
- }, { signal: runtime.requestSignal }));
448
- return present(runtime, globals, response, value => renderSearchAnalytics(value.data));
618
+ });
449
619
  }
620
+ /** The two retained-coverage views, declared once so `--help --json` publishes them. */
621
+ const SEARCH_INDEXING_VIEWS = ['summary', 'urls'];
450
622
  async function searchIndexing(runtime, globals, args) {
451
- const view = parseChoice(args.view, { name: 'view', choices: ['summary', 'urls'] });
623
+ const view = parseChoice(args.view, { name: 'view', choices: SEARCH_INDEXING_VIEWS });
452
624
  if (view._tag === 'Err')
453
625
  return view;
454
- const status = args.status === undefined
455
- ? ok(undefined)
456
- : parseChoice(args.status, { name: '--status', choices: ['indexed', 'not_indexed', 'pending'] });
626
+ const status = optionalChoice(args.status, { name: '--status', choices: ['indexed', 'not_indexed', 'pending'] });
457
627
  if (status._tag === 'Err')
458
628
  return status;
459
629
  const limit = parseInteger(args.limit, { name: '--limit', minimum: 1, maximum: 500, defaultValue: 50 });
@@ -462,20 +632,62 @@ async function searchIndexing(runtime, globals, args) {
462
632
  const offset = parseInteger(args.offset, { name: '--offset', minimum: 0, defaultValue: 0 });
463
633
  if (offset._tag === 'Err')
464
634
  return offset;
635
+ if (args.all && view.value !== 'urls')
636
+ return fail(EXIT_CODE.invalidInput, '--all supports the urls view only.');
637
+ const resolved = await apiAndSite(runtime, globals);
638
+ if (resolved._tag === 'Err')
639
+ return resolved;
640
+ return collect(runtime, globals, {
641
+ all: args.all === true,
642
+ message: 'Loading Search Console indexing',
643
+ start: { offset: offset.value },
644
+ fetch: position => resolved.value.api.client.search.readIndexingDiagnostics({
645
+ params: { siteId: resolved.value.siteId },
646
+ query: {
647
+ view: view.value,
648
+ issue: args.issue,
649
+ status: status.value,
650
+ limit: limit.value,
651
+ offset: position.offset ?? 0,
652
+ },
653
+ }, { signal: runtime.requestSignal }),
654
+ render: value => renderIndexingDiagnostics(value.data),
655
+ next: value => value.data.view === 'urls'
656
+ ? offsetPage({ offset: value.data.offset, limit: value.data.limit, hasMore: value.data.hasMore })
657
+ : { _tag: 'Done' },
658
+ });
659
+ }
660
+ async function searchIndexHistory(runtime, globals, args) {
661
+ const url = args.url === undefined ? ok(undefined) : parsePageUrlOrPath(args.url);
662
+ if (url._tag === 'Err')
663
+ return url;
664
+ const field = optionalChoice(args.field, { name: '--field', choices: INDEXING_TRANSITION_FIELDS });
665
+ if (field._tag === 'Err')
666
+ return field;
667
+ const days = parseInteger(args.days, { name: '--days', minimum: 1, maximum: 730, defaultValue: 180 });
668
+ if (days._tag === 'Err')
669
+ return days;
465
670
  const resolved = await apiAndSite(runtime, globals);
466
671
  if (resolved._tag === 'Err')
467
672
  return resolved;
468
- const response = await withSpinner(runtime, 'Loading Search Console indexing', () => resolved.value.api.client.search.readIndexingDiagnostics({
673
+ const response = await withSpinner(runtime, 'Loading indexing history', () => resolved.value.api.client.search.readIndexingHistory({
469
674
  params: { siteId: resolved.value.siteId },
470
- query: {
471
- view: view.value,
472
- issue: args.issue,
473
- status: status.value,
474
- limit: limit.value,
475
- offset: offset.value,
476
- },
675
+ query: { url: url.value, field: field.value, days: days.value },
676
+ }, { signal: runtime.requestSignal }));
677
+ return present(runtime, globals, response, value => renderIndexingHistory(value.data));
678
+ }
679
+ async function searchInspect(runtime, globals, rawUrl) {
680
+ const url = parsePageUrlOrPath(rawUrl);
681
+ if (url._tag === 'Err')
682
+ return url;
683
+ const resolved = await apiAndSite(runtime, globals);
684
+ if (resolved._tag === 'Err')
685
+ return resolved;
686
+ const response = await withSpinner(runtime, 'Loading URL Inspection', () => resolved.value.api.client.search.readUrlInspection({
687
+ params: { siteId: resolved.value.siteId },
688
+ query: { url: url.value },
477
689
  }, { signal: runtime.requestSignal }));
478
- return present(runtime, globals, response, value => renderIndexingDiagnostics(value.data));
690
+ return present(runtime, globals, response, value => renderUrlInspection(value.data));
479
691
  }
480
692
  async function researchOverview(runtime, globals) {
481
693
  const resolved = await apiAndSite(runtime, globals);
@@ -579,6 +791,173 @@ async function researchRankings(runtime, globals, args) {
579
791
  }, { signal: runtime.requestSignal }));
580
792
  return present(runtime, globals, response, value => renderRankingsResearch(value.data));
581
793
  }
794
+ const ANALYTICS_VIEWS = [
795
+ 'performance',
796
+ 'top-pages',
797
+ 'source-medium',
798
+ 'key-events',
799
+ 'countries',
800
+ 'devices',
801
+ 'dimension',
802
+ ];
803
+ const ANALYTICS_DIMENSIONS = [
804
+ 'date',
805
+ 'page',
806
+ 'hostname',
807
+ 'source',
808
+ 'medium',
809
+ 'campaign',
810
+ 'country',
811
+ 'region',
812
+ 'city',
813
+ 'deviceCategory',
814
+ 'browser',
815
+ 'operatingSystem',
816
+ ];
817
+ async function auditChanges(runtime, globals, args) {
818
+ const from = optionalInteger(args.from, { name: '--from', minimum: 1 });
819
+ if (from._tag === 'Err')
820
+ return from;
821
+ const to = optionalInteger(args.to, { name: '--to', minimum: 1 });
822
+ if (to._tag === 'Err')
823
+ return to;
824
+ const resolved = await apiAndSite(runtime, globals);
825
+ if (resolved._tag === 'Err')
826
+ return resolved;
827
+ const response = await withSpinner(runtime, 'Loading crawl changes', () => resolved.value.api.client.audit.changes({
828
+ params: { siteId: resolved.value.siteId },
829
+ query: { from: from.value, to: to.value },
830
+ }, { signal: runtime.requestSignal }));
831
+ return present(runtime, globals, response, value => renderAuditChanges(value.data));
832
+ }
833
+ async function auditLinkStructure(runtime, globals) {
834
+ const resolved = await apiAndSite(runtime, globals);
835
+ if (resolved._tag === 'Err')
836
+ return resolved;
837
+ const response = await withSpinner(runtime, 'Loading link structure', () => resolved.value.api.client.audit.linkStructure({
838
+ params: { siteId: resolved.value.siteId },
839
+ }, { signal: runtime.requestSignal }));
840
+ return present(runtime, globals, response, value => renderLinkStructure(value.data));
841
+ }
842
+ async function sitemapsList(runtime, globals) {
843
+ const resolved = await apiAndSite(runtime, globals);
844
+ if (resolved._tag === 'Err')
845
+ return resolved;
846
+ const response = await withSpinner(runtime, 'Loading sitemaps', () => resolved.value.api.client.sitemaps.list({
847
+ params: { siteId: resolved.value.siteId },
848
+ }, { signal: runtime.requestSignal }));
849
+ return present(runtime, globals, response, value => renderSitemaps(value.data));
850
+ }
851
+ async function sitemapsUrls(runtime, globals, args) {
852
+ const limit = parseInteger(args.limit, { name: '--limit', minimum: 1, maximum: 1000, defaultValue: 500 });
853
+ if (limit._tag === 'Err')
854
+ return limit;
855
+ const resolved = await apiAndSite(runtime, globals);
856
+ if (resolved._tag === 'Err')
857
+ return resolved;
858
+ return collect(runtime, globals, {
859
+ all: args.all === true,
860
+ message: 'Loading sitemap URLs',
861
+ start: { cursor: args.cursor },
862
+ fetch: position => resolved.value.api.client.sitemaps.listUrls({
863
+ params: { siteId: resolved.value.siteId },
864
+ query: {
865
+ generationId: args.generationId,
866
+ feedpath: args.feedpath,
867
+ cursor: position.cursor,
868
+ limit: limit.value,
869
+ },
870
+ }, { signal: runtime.requestSignal }),
871
+ render: value => renderSitemapUrls(value.data),
872
+ next: (value) => {
873
+ const cursor = value.data.page.nextCursor;
874
+ return cursor
875
+ ? { _tag: 'More', position: { cursor }, resume: `--cursor ${cursor}` }
876
+ : { _tag: 'Done' };
877
+ },
878
+ });
879
+ }
880
+ async function sitemapsAction(runtime, globals, action, rawUrl) {
881
+ const sitemapUrl = parseAbsolutePageUrl(rawUrl);
882
+ if (sitemapUrl._tag === 'Err')
883
+ return sitemapUrl;
884
+ const resolved = await apiAndSite(runtime, globals);
885
+ if (resolved._tag === 'Err')
886
+ return resolved;
887
+ const confirmed = await confirmMutation(runtime, globals, `${action === 'submit' ? 'Submit' : 'Delete'} ${sitemapUrl.value} in Search Console?`);
888
+ if (confirmed._tag === 'Err')
889
+ return confirmed;
890
+ if (!confirmed.value) {
891
+ writeDiagnostic(runtime, 'Mutation cancelled.');
892
+ return ok(undefined);
893
+ }
894
+ const response = await withSpinner(runtime, `Sending the sitemap ${action}`, () => resolved.value.api.client.sitemaps.createAction({
895
+ params: { siteId: resolved.value.siteId },
896
+ body: { action, sitemapUrl: sitemapUrl.value },
897
+ }, { signal: runtime.requestSignal }));
898
+ return present(runtime, globals, response, value => renderSitemapAction(value.data));
899
+ }
900
+ async function analyticsQuery(runtime, globals, args) {
901
+ const view = parseChoice(args.view, { name: 'view', choices: ANALYTICS_VIEWS });
902
+ if (view._tag === 'Err')
903
+ return view;
904
+ const compare = parseChoice(args.compare, { name: '--compare', choices: ['previous', 'year', 'none'], defaultValue: 'previous' });
905
+ if (compare._tag === 'Err')
906
+ return compare;
907
+ const phase = parseChoice(args.performancePhase, { name: '--phase', choices: ['current', 'prior', 'full'], defaultValue: 'full' });
908
+ if (phase._tag === 'Err')
909
+ return phase;
910
+ const dimension = optionalChoice(args.dimension, { name: '--dimension', choices: ANALYTICS_DIMENSIONS });
911
+ if (dimension._tag === 'Err')
912
+ return dimension;
913
+ if (view.value === 'dimension' && !dimension.value)
914
+ return fail(EXIT_CODE.invalidInput, 'The dimension view requires --dimension.');
915
+ const period = args.period?.trim();
916
+ if (period !== undefined && period.length === 0)
917
+ return fail(EXIT_CODE.invalidInput, '--period cannot be empty.');
918
+ const resolved = await apiAndSite(runtime, globals);
919
+ if (resolved._tag === 'Err')
920
+ return resolved;
921
+ const response = await withSpinner(runtime, 'Loading Web Analytics', () => resolved.value.api.client.analytics.query({
922
+ params: { siteId: resolved.value.siteId, view: view.value },
923
+ query: {
924
+ period: period ?? '28d',
925
+ compare: compare.value,
926
+ stableData: args.stableData ?? true,
927
+ filters: args.filters,
928
+ hostScope: args.hostScope ?? true,
929
+ dimension: dimension.value,
930
+ performancePhase: phase.value,
931
+ comparePrior: args.comparePrior ?? true,
932
+ fresh: args.fresh ?? false,
933
+ },
934
+ }, { signal: runtime.requestSignal }));
935
+ return present(runtime, globals, response, value => renderAnalytics(view.value, value.data));
936
+ }
937
+ async function skillInstallCommand(runtime, globals, args) {
938
+ const agent = parseChoice(args.agent, { name: '--agent', choices: SKILL_AGENTS, defaultValue: 'claude' });
939
+ if (agent._tag === 'Err')
940
+ return agent;
941
+ // The state directory is `<home>/.nuxtseo`, so its parent is the home the
942
+ // agent directories sit beside.
943
+ const homeDirectory = dirname(runtime.paths.directory);
944
+ const installed = await installSkill({ agent: agent.value, homeDirectory, target: args.target });
945
+ if (installed._tag === 'Err')
946
+ return installed;
947
+ if (globals.json) {
948
+ writeCliResponse(runtime, {
949
+ _tag: 'CliSkillInstall',
950
+ schemaVersion: 1,
951
+ agent: installed.value.agent,
952
+ source: installed.value.source,
953
+ destination: installed.value.destination,
954
+ });
955
+ }
956
+ else {
957
+ writeOutput(runtime, `Installed the nuxtseo-cli skill for ${installed.value.agent} at ${installed.value.destination}.`);
958
+ }
959
+ return ok(undefined);
960
+ }
582
961
  async function auditContentDecay(runtime, globals) {
583
962
  const resolved = await apiAndSite(runtime, globals);
584
963
  if (resolved._tag === 'Err')
@@ -645,11 +1024,17 @@ async function contentBriefList(runtime, globals, args) {
645
1024
  const resolved = await apiAndSite(runtime, globals);
646
1025
  if (resolved._tag === 'Err')
647
1026
  return resolved;
648
- const response = await withSpinner(runtime, 'Loading Content Briefs', () => resolved.value.api.client.content.listBriefs({
649
- params: { siteId: resolved.value.siteId },
650
- query: { status: status.value, limit: limit.value, offset: offset.value },
651
- }, { signal: runtime.requestSignal }));
652
- return present(runtime, globals, response, value => renderContentBriefs(value.data));
1027
+ return collect(runtime, globals, {
1028
+ all: args.all === true,
1029
+ message: 'Loading Content Briefs',
1030
+ start: { offset: offset.value },
1031
+ fetch: position => resolved.value.api.client.content.listBriefs({
1032
+ params: { siteId: resolved.value.siteId },
1033
+ query: { status: status.value, limit: limit.value, offset: position.offset ?? 0 },
1034
+ }, { signal: runtime.requestSignal }),
1035
+ render: value => renderContentBriefs(value.data),
1036
+ next: value => offsetPage(value.data.page),
1037
+ });
653
1038
  }
654
1039
  async function contentBriefShow(runtime, globals, briefId) {
655
1040
  const resolved = await apiAndSite(runtime, globals);
@@ -680,21 +1065,386 @@ async function contentBriefCreate(runtime, globals, args) {
680
1065
  }, { signal: runtime.requestSignal }));
681
1066
  return present(runtime, globals, response, value => renderContentBriefCreated(value.data));
682
1067
  }
683
- async function backlinksRecoverable(runtime, globals, limitInput, offsetInput) {
684
- const limit = parseInteger(limitInput, { name: '--limit', minimum: 1, maximum: 200, defaultValue: 100 });
1068
+ const VITALS_FORM_FACTORS = ['phone', 'desktop', 'all'];
1069
+ const VITALS_METRICS = ['lcp', 'inp', 'cls'];
1070
+ const VITALS_FORM_FACTOR_WIRE = {
1071
+ phone: 'PHONE',
1072
+ desktop: 'DESKTOP',
1073
+ all: 'ALL_FORM_FACTORS',
1074
+ };
1075
+ /**
1076
+ * CrUX field vitals. This is a different dataset from `nuxtseo performance`,
1077
+ * which reads the stored Lighthouse lab overview (ADR-0129).
1078
+ */
1079
+ async function fieldVitals(runtime, globals, args) {
1080
+ const formFactor = parseChoice(args.formFactor, {
1081
+ name: '--form-factor',
1082
+ choices: VITALS_FORM_FACTORS,
1083
+ defaultValue: 'phone',
1084
+ });
1085
+ if (formFactor._tag === 'Err')
1086
+ return formFactor;
1087
+ const url = args.url === undefined
1088
+ ? ok(undefined)
1089
+ : parseAbsolutePageUrl(args.url);
1090
+ if (url._tag === 'Err')
1091
+ return url;
1092
+ const resolved = await apiAndSite(runtime, globals);
1093
+ if (resolved._tag === 'Err')
1094
+ return resolved;
1095
+ const response = await withSpinner(runtime, 'Loading field vitals', () => resolved.value.api.client.performance.vitals({
1096
+ params: { siteId: resolved.value.siteId },
1097
+ query: {
1098
+ url: url.value,
1099
+ formFactor: VITALS_FORM_FACTOR_WIRE[formFactor.value],
1100
+ view: args.view,
1101
+ },
1102
+ }, { signal: runtime.requestSignal }));
1103
+ return present(runtime, globals, response, value => renderFieldVitals(value.data));
1104
+ }
1105
+ async function fieldVitalFindings(runtime, globals, args) {
1106
+ const metric = args.metric === undefined
1107
+ ? ok(undefined)
1108
+ : parseChoice(args.metric, { name: '--metric', choices: VITALS_METRICS, defaultValue: 'lcp' });
1109
+ if (metric._tag === 'Err')
1110
+ return metric;
1111
+ const limit = parseInteger(args.limit, { name: '--limit', minimum: 1, maximum: 50, defaultValue: 20 });
1112
+ if (limit._tag === 'Err')
1113
+ return limit;
1114
+ const offset = parseInteger(args.offset, { name: '--offset', minimum: 0, defaultValue: 0 });
1115
+ if (offset._tag === 'Err')
1116
+ return offset;
1117
+ const resolved = await apiAndSite(runtime, globals);
1118
+ if (resolved._tag === 'Err')
1119
+ return resolved;
1120
+ return collect(runtime, globals, {
1121
+ all: args.all === true,
1122
+ message: 'Loading field vital findings',
1123
+ start: { offset: offset.value },
1124
+ fetch: position => resolved.value.api.client.performance.vitalFindings({
1125
+ params: { siteId: resolved.value.siteId },
1126
+ query: {
1127
+ metrics: metric.value === undefined ? undefined : [metric.value],
1128
+ onlyFailing: publicV1BooleanFlagValue(args.onlyFailing ?? true),
1129
+ limit: limit.value,
1130
+ offset: position.offset ?? 0,
1131
+ },
1132
+ }, { signal: runtime.requestSignal }),
1133
+ render: value => renderFieldVitalFindings(value.data),
1134
+ next: value => offsetPage({
1135
+ offset: value.data.offset,
1136
+ limit: limit.value,
1137
+ hasMore: value.data.offset + value.data.findings.length < value.data.total,
1138
+ }),
1139
+ });
1140
+ }
1141
+ async function backlinksSummary(runtime, globals) {
1142
+ const resolved = await apiAndSite(runtime, globals);
1143
+ if (resolved._tag === 'Err')
1144
+ return resolved;
1145
+ const response = await withSpinner(runtime, 'Loading Backlinks summary', () => resolved.value.api.client.backlinks.summary({
1146
+ params: { siteId: resolved.value.siteId },
1147
+ }, { signal: runtime.requestSignal }));
1148
+ return present(runtime, globals, response, value => renderBacklinksSummary(value.data));
1149
+ }
1150
+ async function backlinksReferringDomains(runtime, globals, args) {
1151
+ const limit = parseInteger(args.limit, { name: '--limit', minimum: 1, maximum: 1000, defaultValue: 100 });
1152
+ if (limit._tag === 'Err')
1153
+ return limit;
1154
+ const resolved = await apiAndSite(runtime, globals);
1155
+ if (resolved._tag === 'Err')
1156
+ return resolved;
1157
+ const response = await withSpinner(runtime, 'Loading referring domains', () => resolved.value.api.client.backlinks.referringDomains({
1158
+ params: { siteId: resolved.value.siteId },
1159
+ query: { limit: limit.value },
1160
+ }, { signal: runtime.requestSignal }));
1161
+ return present(runtime, globals, response, value => renderReferringDomains(value.data));
1162
+ }
1163
+ async function backlinksAnchors(runtime, globals, args) {
1164
+ const limit = parseInteger(args.limit, { name: '--limit', minimum: 1, maximum: 1000, defaultValue: 100 });
685
1165
  if (limit._tag === 'Err')
686
1166
  return limit;
687
- const offset = parseInteger(offsetInput, { name: '--offset', minimum: 0, defaultValue: 0 });
1167
+ const resolved = await apiAndSite(runtime, globals);
1168
+ if (resolved._tag === 'Err')
1169
+ return resolved;
1170
+ const response = await withSpinner(runtime, 'Loading Backlink anchors', () => resolved.value.api.client.backlinks.anchors({
1171
+ params: { siteId: resolved.value.siteId },
1172
+ query: { limit: limit.value },
1173
+ }, { signal: runtime.requestSignal }));
1174
+ return present(runtime, globals, response, value => renderBacklinkAnchors(value.data));
1175
+ }
1176
+ async function backlinksHistory(runtime, globals, args) {
1177
+ if (args.from !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(args.from))
1178
+ return fail(EXIT_CODE.invalidInput, '--from must be a date in YYYY-MM-DD form.', undefined, 'invalid_cli_input');
1179
+ const resolved = await apiAndSite(runtime, globals);
1180
+ if (resolved._tag === 'Err')
1181
+ return resolved;
1182
+ const response = await withSpinner(runtime, 'Loading Backlinks history', () => resolved.value.api.client.backlinks.history({
1183
+ params: { siteId: resolved.value.siteId },
1184
+ query: { dateFrom: args.from },
1185
+ }, { signal: runtime.requestSignal }));
1186
+ return present(runtime, globals, response, value => renderBacklinksHistory(value.data));
1187
+ }
1188
+ async function siteStatus(runtime, globals) {
1189
+ const resolved = await apiAndSite(runtime, globals);
1190
+ if (resolved._tag === 'Err')
1191
+ return resolved;
1192
+ const response = await withSpinner(runtime, 'Loading Site status', () => resolved.value.api.client.sites.status({
1193
+ params: { siteId: resolved.value.siteId },
1194
+ }, { signal: runtime.requestSignal }));
1195
+ return present(runtime, globals, response, value => renderSiteStatus(value.data));
1196
+ }
1197
+ async function actionDismiss(runtime, globals, args) {
1198
+ if (!args.actionId)
1199
+ return fail(EXIT_CODE.invalidInput, 'Pass the action id to dismiss.', undefined, 'invalid_cli_input');
1200
+ if (!args.artifactVersion)
1201
+ return fail(EXIT_CODE.invalidInput, 'Pass --artifact-version, the artifactVersion returned with this action.', undefined, 'invalid_cli_input');
1202
+ const confirmed = await confirmMutation(runtime, globals, `Dismiss action ${args.actionId}? The page stays removed.`);
1203
+ if (confirmed._tag === 'Err')
1204
+ return confirmed;
1205
+ if (!confirmed.value) {
1206
+ writeDiagnostic(runtime, 'Mutation cancelled.');
1207
+ return ok(undefined);
1208
+ }
1209
+ const resolved = await apiAndSite(runtime, globals);
1210
+ if (resolved._tag === 'Err')
1211
+ return resolved;
1212
+ const response = await withSpinner(runtime, 'Dismissing action', () => resolved.value.api.client.actions.dismiss({
1213
+ params: { siteId: resolved.value.siteId, actionId: args.actionId },
1214
+ body: { artifactVersion: args.artifactVersion },
1215
+ }, { signal: runtime.requestSignal }));
1216
+ return present(runtime, globals, response, value => renderActionDismissal(value.data));
1217
+ }
1218
+ async function pageIssues(runtime, globals, args) {
1219
+ if (args.actionId && (args.source || args.issue))
1220
+ return fail(EXIT_CODE.invalidInput, 'Pass --action-id, or pass --source and --issue, not both.', undefined, 'invalid_cli_input');
1221
+ if (!args.actionId && !(args.source && args.issue))
1222
+ return fail(EXIT_CODE.invalidInput, 'Pass --action-id, or pass both --source and --issue.', undefined, 'invalid_cli_input');
1223
+ const limit = parseInteger(args.limit, { name: '--limit', minimum: 1, maximum: 200, defaultValue: 100 });
1224
+ if (limit._tag === 'Err')
1225
+ return limit;
1226
+ const offset = parseInteger(args.offset, { name: '--offset', minimum: 0, defaultValue: 0 });
688
1227
  if (offset._tag === 'Err')
689
1228
  return offset;
690
1229
  const resolved = await apiAndSite(runtime, globals);
691
1230
  if (resolved._tag === 'Err')
692
1231
  return resolved;
693
- const response = await withSpinner(runtime, 'Loading recoverable Backlinks', () => resolved.value.api.client.backlinks.recoverable({
1232
+ return collect(runtime, globals, {
1233
+ all: args.all === true,
1234
+ message: 'Loading Page Issues',
1235
+ start: { offset: offset.value },
1236
+ fetch: position => resolved.value.api.client.pages.issues({
1237
+ params: { siteId: resolved.value.siteId },
1238
+ query: {
1239
+ actionId: args.actionId,
1240
+ source: args.source,
1241
+ issue: args.issue,
1242
+ url: args.url,
1243
+ pathPrefix: args.pathPrefix,
1244
+ includeResolved: publicV1BooleanFlagValue(args.includeResolved === true),
1245
+ limit: limit.value,
1246
+ offset: position.offset ?? 0,
1247
+ },
1248
+ }, { signal: runtime.requestSignal }),
1249
+ render: value => renderPageIssues(value.data),
1250
+ next: value => offsetPage({
1251
+ offset: value.data.offset,
1252
+ limit: limit.value,
1253
+ hasMore: value.data.offset + value.data.issues.length < value.data.total,
1254
+ }),
1255
+ });
1256
+ }
1257
+ async function performanceScans(runtime, globals, args) {
1258
+ const limit = parseInteger(args.limit, { name: '--limit', minimum: 1, maximum: 100, defaultValue: 25 });
1259
+ if (limit._tag === 'Err')
1260
+ return limit;
1261
+ const resolved = await apiAndSite(runtime, globals);
1262
+ if (resolved._tag === 'Err')
1263
+ return resolved;
1264
+ const response = await withSpinner(runtime, 'Loading Scans', () => resolved.value.api.client.performance.scans({
1265
+ params: { siteId: resolved.value.siteId },
1266
+ query: { limit: limit.value },
1267
+ }, { signal: runtime.requestSignal }));
1268
+ return present(runtime, globals, response, value => renderScans(value.data));
1269
+ }
1270
+ async function performanceScanDetail(runtime, globals, scanId) {
1271
+ if (!scanId)
1272
+ return fail(EXIT_CODE.invalidInput, 'Pass the Scan id. List them with `nuxtseo scans list`.', undefined, 'invalid_cli_input');
1273
+ const resolved = await apiAndSite(runtime, globals);
1274
+ if (resolved._tag === 'Err')
1275
+ return resolved;
1276
+ const response = await withSpinner(runtime, 'Loading Scan', () => resolved.value.api.client.performance.scanDetail({
1277
+ params: { siteId: resolved.value.siteId, scanId },
1278
+ }, { signal: runtime.requestSignal }));
1279
+ return present(runtime, globals, response, value => renderScanDetail(value.data));
1280
+ }
1281
+ async function monitoredPages(runtime, globals) {
1282
+ const resolved = await apiAndSite(runtime, globals);
1283
+ if (resolved._tag === 'Err')
1284
+ return resolved;
1285
+ const response = await withSpinner(runtime, 'Loading monitored Pages', () => resolved.value.api.client.performance.monitoredPages({
1286
+ params: { siteId: resolved.value.siteId },
1287
+ }, { signal: runtime.requestSignal }));
1288
+ return present(runtime, globals, response, value => renderMonitoredPages(value.data));
1289
+ }
1290
+ async function annotationsList(runtime, globals) {
1291
+ const resolved = await apiAndSite(runtime, globals);
1292
+ if (resolved._tag === 'Err')
1293
+ return resolved;
1294
+ const response = await withSpinner(runtime, 'Loading annotations', () => resolved.value.api.client.timeline.listAnnotations({
1295
+ params: { siteId: resolved.value.siteId },
1296
+ }, { signal: runtime.requestSignal }));
1297
+ return present(runtime, globals, response, value => renderAnnotations(value.data));
1298
+ }
1299
+ const ANNOTATION_DAY = /^\d{4}-\d{2}-\d{2}$/;
1300
+ /**
1301
+ * An optional annotation field on the wire. `note` and `url` are nullable, and
1302
+ * the operation clears one when it is sent as null, so the CLI needs a way to
1303
+ * SAY null. An empty flag value is that way: `--note ""` and `--url ""` clear.
1304
+ * Without it there was no clearing at all, and `--url ""` failed SDK body
1305
+ * validation with exit 2 and no request, which is the opposite of the
1306
+ * operation's own documented behaviour.
1307
+ */
1308
+ function annotationField(value) {
1309
+ if (value === undefined)
1310
+ return undefined;
1311
+ // Only an exactly empty value clears. Whitespace is content the server
1312
+ // trims, so treating it as a clear would guess at intent.
1313
+ return value === '' ? null : value;
1314
+ }
1315
+ async function annotationCreate(runtime, globals, args) {
1316
+ if (!args.date || !ANNOTATION_DAY.test(args.date))
1317
+ return fail(EXIT_CODE.invalidInput, 'Pass --date as YYYY-MM-DD.', undefined, 'invalid_cli_input');
1318
+ if (!args.title)
1319
+ return fail(EXIT_CODE.invalidInput, 'Pass --title, the marker label.', undefined, 'invalid_cli_input');
1320
+ const confirmed = await confirmMutation(runtime, globals, `Place an annotation on ${args.date}: ${args.title}?`);
1321
+ if (confirmed._tag === 'Err')
1322
+ return confirmed;
1323
+ if (!confirmed.value) {
1324
+ writeDiagnostic(runtime, 'Mutation cancelled.');
1325
+ return ok(undefined);
1326
+ }
1327
+ const resolved = await apiAndSite(runtime, globals);
1328
+ if (resolved._tag === 'Err')
1329
+ return resolved;
1330
+ const response = await withSpinner(runtime, 'Creating annotation', () => resolved.value.api.client.timeline.createAnnotation({
1331
+ params: { siteId: resolved.value.siteId },
1332
+ body: { date: args.date, title: args.title, note: annotationField(args.note) ?? null, url: annotationField(args.url) ?? null },
1333
+ }, { signal: runtime.requestSignal }));
1334
+ return present(runtime, globals, response, value => renderAnnotation(value.data));
1335
+ }
1336
+ async function annotationUpdate(runtime, globals, args) {
1337
+ if (!args.annotationId)
1338
+ return fail(EXIT_CODE.invalidInput, 'Pass the annotation id. List them with `nuxtseo annotations list`.', undefined, 'invalid_cli_input');
1339
+ if (args.date !== undefined && !ANNOTATION_DAY.test(args.date))
1340
+ return fail(EXIT_CODE.invalidInput, '--date must be YYYY-MM-DD.', undefined, 'invalid_cli_input');
1341
+ if (args.date === undefined && args.title === undefined && args.note === undefined && args.url === undefined)
1342
+ return fail(EXIT_CODE.invalidInput, 'Pass at least one of --date, --title, --note or --url.', undefined, 'invalid_cli_input');
1343
+ if (args.title !== undefined && args.title.trim() === '')
1344
+ return fail(EXIT_CODE.invalidInput, '--title cannot be empty. Every annotation needs a label.', undefined, 'invalid_cli_input');
1345
+ const confirmed = await confirmMutation(runtime, globals, `Change annotation ${args.annotationId}?`);
1346
+ if (confirmed._tag === 'Err')
1347
+ return confirmed;
1348
+ if (!confirmed.value) {
1349
+ writeDiagnostic(runtime, 'Mutation cancelled.');
1350
+ return ok(undefined);
1351
+ }
1352
+ const resolved = await apiAndSite(runtime, globals);
1353
+ if (resolved._tag === 'Err')
1354
+ return resolved;
1355
+ const response = await withSpinner(runtime, 'Updating annotation', () => resolved.value.api.client.timeline.updateAnnotation({
1356
+ params: { siteId: resolved.value.siteId, annotationId: args.annotationId },
1357
+ body: { date: args.date, title: args.title, note: annotationField(args.note), url: annotationField(args.url) },
1358
+ }, { signal: runtime.requestSignal }));
1359
+ return present(runtime, globals, response, value => renderAnnotation(value.data));
1360
+ }
1361
+ async function annotationDelete(runtime, globals, args) {
1362
+ if (!args.annotationId)
1363
+ return fail(EXIT_CODE.invalidInput, 'Pass the annotation id. List them with `nuxtseo annotations list`.', undefined, 'invalid_cli_input');
1364
+ const confirmed = await confirmMutation(runtime, globals, `Delete annotation ${args.annotationId}? This cannot be undone.`);
1365
+ if (confirmed._tag === 'Err')
1366
+ return confirmed;
1367
+ if (!confirmed.value) {
1368
+ writeDiagnostic(runtime, 'Mutation cancelled.');
1369
+ return ok(undefined);
1370
+ }
1371
+ const resolved = await apiAndSite(runtime, globals);
1372
+ if (resolved._tag === 'Err')
1373
+ return resolved;
1374
+ const response = await withSpinner(runtime, 'Deleting annotation', () => resolved.value.api.client.timeline.deleteAnnotation({
1375
+ params: { siteId: resolved.value.siteId, annotationId: args.annotationId },
1376
+ }, { signal: runtime.requestSignal }));
1377
+ return present(runtime, globals, response, value => renderAnnotationDeleted(value.data));
1378
+ }
1379
+ async function searchCohorts(runtime, globals, args) {
1380
+ const limit = parseInteger(args.limit, { name: '--limit', minimum: 1, maximum: 50, defaultValue: 12 });
1381
+ if (limit._tag === 'Err')
1382
+ return limit;
1383
+ const minPages = parseInteger(args.minPages, { name: '--min-pages', minimum: 1, maximum: 500, defaultValue: 5 });
1384
+ if (minPages._tag === 'Err')
1385
+ return minPages;
1386
+ const resolved = await apiAndSite(runtime, globals);
1387
+ if (resolved._tag === 'Err')
1388
+ return resolved;
1389
+ const response = await withSpinner(runtime, 'Loading route-family indexing', () => resolved.value.api.client.search.readIndexingCohorts({
1390
+ params: { siteId: resolved.value.siteId },
1391
+ query: { limit: limit.value, minSectionPages: minPages.value },
1392
+ }, { signal: runtime.requestSignal }));
1393
+ return present(runtime, globals, response, value => renderIndexCohorts(value.data));
1394
+ }
1395
+ async function researchDomainTraffic(runtime, globals, domain) {
1396
+ if (!domain)
1397
+ return fail(EXIT_CODE.invalidInput, 'Pass the domain, for example nuxtseo.com.', undefined, 'invalid_cli_input');
1398
+ const resolved = await apiAndSite(runtime, globals);
1399
+ if (resolved._tag === 'Err')
1400
+ return resolved;
1401
+ const response = await withSpinner(runtime, 'Estimating domain traffic', () => resolved.value.api.client.research.domainTraffic({
1402
+ params: { siteId: resolved.value.siteId },
1403
+ query: { domain },
1404
+ }, { signal: runtime.requestSignal }));
1405
+ return present(runtime, globals, response, value => renderDomainTraffic(value.data));
1406
+ }
1407
+ /** Domains arrive as one comma-separated positional, so the count is explicit and quoting is not needed. */
1408
+ async function researchDomainAvailability(runtime, globals, input) {
1409
+ const domains = (input ?? '').split(',').map(value => value.trim()).filter(Boolean);
1410
+ if (domains.length === 0)
1411
+ return fail(EXIT_CODE.invalidInput, 'Pass at least one domain.', undefined, 'invalid_cli_input');
1412
+ if (domains.length > 10)
1413
+ return fail(EXIT_CODE.invalidInput, 'Pass at most 10 domains, comma separated.', undefined, 'invalid_cli_input');
1414
+ const resolved = await apiAndSite(runtime, globals);
1415
+ if (resolved._tag === 'Err')
1416
+ return resolved;
1417
+ const response = await withSpinner(runtime, 'Checking domain availability', () => resolved.value.api.client.research.domainAvailability({
694
1418
  params: { siteId: resolved.value.siteId },
695
- query: { limit: limit.value, offset: offset.value },
1419
+ query: { domains },
696
1420
  }, { signal: runtime.requestSignal }));
697
- return present(runtime, globals, response, value => renderRecoverableBacklinks(value.data));
1421
+ return present(runtime, globals, response, value => renderDomainAvailability(value.data));
1422
+ }
1423
+ async function backlinksRecoverable(runtime, globals, args) {
1424
+ const limit = parseInteger(args.limit, { name: '--limit', minimum: 1, maximum: 200, defaultValue: 100 });
1425
+ if (limit._tag === 'Err')
1426
+ return limit;
1427
+ const offset = parseInteger(args.offset, { name: '--offset', minimum: 0, defaultValue: 0 });
1428
+ if (offset._tag === 'Err')
1429
+ return offset;
1430
+ const resolved = await apiAndSite(runtime, globals);
1431
+ if (resolved._tag === 'Err')
1432
+ return resolved;
1433
+ return collect(runtime, globals, {
1434
+ all: args.all === true,
1435
+ message: 'Loading recoverable Backlinks',
1436
+ start: { offset: offset.value },
1437
+ fetch: position => resolved.value.api.client.backlinks.recoverable({
1438
+ params: { siteId: resolved.value.siteId },
1439
+ query: { limit: limit.value, offset: position.offset ?? 0 },
1440
+ }, { signal: runtime.requestSignal }),
1441
+ render: value => renderRecoverableBacklinks(value.data),
1442
+ next: value => offsetPage({
1443
+ offset: value.data.offset,
1444
+ limit: limit.value,
1445
+ hasMore: value.data.offset + value.data.items.length < value.data.total,
1446
+ }),
1447
+ });
698
1448
  }
699
1449
  async function mentionsList(runtime, globals, args) {
700
1450
  const limit = parseInteger(args.limit, { name: '--limit', minimum: 1, maximum: 200, defaultValue: 100 });
@@ -705,7 +1455,7 @@ async function mentionsList(runtime, globals, args) {
705
1455
  return resolved;
706
1456
  const response = await withSpinner(runtime, 'Loading Mentions', () => resolved.value.api.client.mentions.list({
707
1457
  params: { siteId: resolved.value.siteId },
708
- query: { limit: limit.value, includeFiltered: args.includeFiltered ? '1' : '0' },
1458
+ query: { limit: limit.value, includeFiltered: publicV1BooleanFlagValue(Boolean(args.includeFiltered)) },
709
1459
  }, { signal: runtime.requestSignal }));
710
1460
  return present(runtime, globals, response, value => renderMentions(value.data));
711
1461
  }
@@ -718,8 +1468,20 @@ function capture(execution, task, positionalArguments, expectedPositionals) {
718
1468
  execution.result = await task();
719
1469
  };
720
1470
  }
721
- function positional(name, description) {
722
- return { type: 'positional', required: true, valueHint: name, description };
1471
+ /**
1472
+ * `options` is the machine-readable list of legal values for a positional that
1473
+ * takes one of a fixed set. `--help --json` publishes it, so the skill-command
1474
+ * gate can check a documented `search indexing summary` against what the binary
1475
+ * really accepts instead of taking the tail on trust.
1476
+ */
1477
+ function positional(name, description, options) {
1478
+ return {
1479
+ type: 'positional',
1480
+ required: true,
1481
+ valueHint: name,
1482
+ description,
1483
+ ...(options ? { options: [...options] } : {}),
1484
+ };
723
1485
  }
724
1486
  export function createRootCommand(runtime, globals, execution) {
725
1487
  const sites = defineCommand({
@@ -744,8 +1506,13 @@ export function createRootCommand(runtime, globals, execution) {
744
1506
  args: {
745
1507
  limit: { type: 'string', description: 'Maximum actions, 1 to 25' },
746
1508
  offset: { type: 'string', description: 'Pagination offset' },
1509
+ all: { type: 'boolean', description: 'Repeat until every page is written; one envelope per page' },
747
1510
  },
748
- run: ({ args }) => capture(execution, () => actionsList(runtime, globals, args.limit, args.offset), args._, 0)(),
1511
+ run: ({ args }) => capture(execution, () => actionsList(runtime, globals, {
1512
+ limit: args.limit,
1513
+ offset: args.offset,
1514
+ all: args.all,
1515
+ }), args._, 0)(),
749
1516
  }),
750
1517
  show: defineCommand({
751
1518
  meta: { name: 'show', description: 'Show one action and its evidence' },
@@ -767,6 +1534,17 @@ export function createRootCommand(runtime, globals, execution) {
767
1534
  args: { actionId: positional('action-id', 'Action ID') },
768
1535
  run: ({ args }) => capture(execution, () => actionsResolve(runtime, globals, args.actionId), args._, 1)(),
769
1536
  }),
1537
+ dismiss: defineCommand({
1538
+ meta: { name: 'dismiss', description: 'Dismiss a broken-page action so the page stays removed' },
1539
+ args: {
1540
+ 'actionId': positional('action-id', 'Action ID'),
1541
+ 'artifact-version': { type: 'string', description: 'The artifactVersion returned with this action' },
1542
+ },
1543
+ run: ({ args }) => capture(execution, () => actionDismiss(runtime, globals, {
1544
+ actionId: args.actionId,
1545
+ artifactVersion: args['artifact-version'],
1546
+ }), args._, 1)(),
1547
+ }),
770
1548
  },
771
1549
  });
772
1550
  const page = defineCommand({
@@ -779,14 +1557,41 @@ export function createRootCommand(runtime, globals, execution) {
779
1557
  'include-resolved': { type: 'boolean', description: 'Include resolved observations' },
780
1558
  'limit': { type: 'string', description: 'Maximum observations, 1 to 200' },
781
1559
  'offset': { type: 'string', description: 'Pagination offset' },
1560
+ 'all': { type: 'boolean', description: 'Repeat until every page is written; one envelope per page' },
782
1561
  },
783
1562
  run: ({ args }) => capture(execution, () => pageInspect(runtime, globals, {
784
1563
  url: args.url,
785
1564
  includeResolved: args['include-resolved'],
786
1565
  limit: args.limit,
787
1566
  offset: args.offset,
1567
+ all: args.all,
788
1568
  }), args._, 1)(),
789
1569
  }),
1570
+ issues: defineCommand({
1571
+ meta: { name: 'issues', description: 'Browse raw Page Issue rows by action, or by source and issue' },
1572
+ args: {
1573
+ 'action-id': { type: 'string', description: 'Action whose stamped observation keys to browse' },
1574
+ 'source': { type: 'string', description: 'Page Issue source family, for example crawl' },
1575
+ 'issue': { type: 'string', description: 'Source-scoped issue type, for example broken-internal-links' },
1576
+ 'url': { type: 'string', description: 'Exact affected URL' },
1577
+ 'path-prefix': { type: 'string', description: 'Only routes whose path starts with this prefix' },
1578
+ 'include-resolved': { type: 'boolean', description: 'Include closed rows and why they closed' },
1579
+ 'limit': { type: 'string', description: 'Maximum rows, 1 to 200' },
1580
+ 'offset': { type: 'string', description: 'Pagination offset' },
1581
+ 'all': { type: 'boolean', description: 'Repeat until every page is written; one envelope per page' },
1582
+ },
1583
+ run: ({ args }) => capture(execution, () => pageIssues(runtime, globals, {
1584
+ actionId: args['action-id'],
1585
+ source: args.source,
1586
+ issue: args.issue,
1587
+ url: args.url,
1588
+ pathPrefix: args['path-prefix'],
1589
+ includeResolved: args['include-resolved'],
1590
+ limit: args.limit,
1591
+ offset: args.offset,
1592
+ all: args.all,
1593
+ }), args._, 0)(),
1594
+ }),
790
1595
  scan: defineCommand({
791
1596
  meta: { name: 'scan', description: 'Start mobile and desktop Page scans' },
792
1597
  args: { url: positional('url', 'Absolute Page URL') },
@@ -795,15 +1600,164 @@ export function createRootCommand(runtime, globals, execution) {
795
1600
  },
796
1601
  });
797
1602
  const backlinks = defineCommand({
798
- meta: { name: 'backlinks', description: 'Read stored inbound link evidence' },
1603
+ meta: { name: 'backlinks', description: 'Read inbound link evidence for the Site' },
799
1604
  subCommands: {
800
- recoverable: defineCommand({
1605
+ 'summary': defineCommand({
1606
+ meta: { name: 'summary', description: 'Read whole-domain inbound link totals' },
1607
+ run: ({ args }) => capture(execution, () => backlinksSummary(runtime, globals), args._, 0)(),
1608
+ }),
1609
+ 'referring-domains': defineCommand({
1610
+ meta: { name: 'referring-domains', description: 'List the domains that link to the Site' },
1611
+ args: {
1612
+ limit: { type: 'string', description: 'Maximum domains, 1 to 1000' },
1613
+ },
1614
+ run: ({ args }) => capture(execution, () => backlinksReferringDomains(runtime, globals, {
1615
+ limit: args.limit,
1616
+ }), args._, 0)(),
1617
+ }),
1618
+ 'anchors': defineCommand({
1619
+ meta: { name: 'anchors', description: 'List the anchor text of inbound links' },
1620
+ args: {
1621
+ limit: { type: 'string', description: 'Maximum anchors, 1 to 1000' },
1622
+ },
1623
+ run: ({ args }) => capture(execution, () => backlinksAnchors(runtime, globals, {
1624
+ limit: args.limit,
1625
+ }), args._, 0)(),
1626
+ }),
1627
+ 'history': defineCommand({
1628
+ meta: { name: 'history', description: 'Read the monthly inbound link series' },
1629
+ args: {
1630
+ from: { type: 'string', description: 'Start date, YYYY-MM-DD' },
1631
+ },
1632
+ run: ({ args }) => capture(execution, () => backlinksHistory(runtime, globals, {
1633
+ from: args.from,
1634
+ }), args._, 0)(),
1635
+ }),
1636
+ 'recoverable': defineCommand({
801
1637
  meta: { name: 'recoverable', description: 'List inbound links whose destination is broken' },
802
1638
  args: {
803
1639
  limit: { type: 'string', description: 'Maximum links, 1 to 200' },
804
1640
  offset: { type: 'string', description: 'Pagination offset' },
1641
+ all: { type: 'boolean', description: 'Repeat until every page is written; one envelope per page' },
1642
+ },
1643
+ run: ({ args }) => capture(execution, () => backlinksRecoverable(runtime, globals, {
1644
+ limit: args.limit,
1645
+ offset: args.offset,
1646
+ all: args.all,
1647
+ }), args._, 0)(),
1648
+ }),
1649
+ },
1650
+ });
1651
+ const vitals = defineCommand({
1652
+ meta: { name: 'vitals', description: 'Read field Core Web Vitals from real-user data' },
1653
+ subCommands: {
1654
+ summary: defineCommand({
1655
+ meta: { name: 'summary', description: 'Read the latest field p75 and its change' },
1656
+ args: {
1657
+ 'url': { type: 'string', description: 'One absolute Page URL; omit for the Site origin' },
1658
+ 'form-factor': { type: 'enum', options: [...VITALS_FORM_FACTORS], description: 'Device class' },
1659
+ },
1660
+ run: ({ args }) => capture(execution, () => fieldVitals(runtime, globals, {
1661
+ view: 'summary',
1662
+ url: args.url,
1663
+ formFactor: args['form-factor'],
1664
+ }), args._, 0)(),
1665
+ }),
1666
+ trend: defineCommand({
1667
+ meta: { name: 'trend', description: 'Read the 25-week field p75 series' },
1668
+ args: {
1669
+ 'url': { type: 'string', description: 'One absolute Page URL; omit for the Site origin' },
1670
+ 'form-factor': { type: 'enum', options: [...VITALS_FORM_FACTORS], description: 'Device class' },
1671
+ },
1672
+ run: ({ args }) => capture(execution, () => fieldVitals(runtime, globals, {
1673
+ view: 'trend',
1674
+ url: args.url,
1675
+ formFactor: args['form-factor'],
1676
+ }), args._, 0)(),
1677
+ }),
1678
+ findings: defineCommand({
1679
+ meta: { name: 'findings', description: 'List failing field vitals with the element and a fix prompt' },
1680
+ args: {
1681
+ 'metric': { type: 'enum', options: [...VITALS_METRICS], description: 'Restrict to one vital' },
1682
+ 'only-failing': { type: 'boolean', default: true, description: 'Keep only failing elements; use --no-only-failing for every element' },
1683
+ 'limit': { type: 'string', description: 'Maximum findings, 1 to 50' },
1684
+ 'offset': { type: 'string', description: 'Pagination offset' },
1685
+ 'all': { type: 'boolean', description: 'Repeat until every page is written; one envelope per page' },
805
1686
  },
806
- run: ({ args }) => capture(execution, () => backlinksRecoverable(runtime, globals, args.limit, args.offset), args._, 0)(),
1687
+ run: ({ args }) => capture(execution, () => fieldVitalFindings(runtime, globals, {
1688
+ metric: args.metric,
1689
+ onlyFailing: args['only-failing'],
1690
+ limit: args.limit,
1691
+ offset: args.offset,
1692
+ all: args.all,
1693
+ }), args._, 0)(),
1694
+ }),
1695
+ },
1696
+ });
1697
+ const scans = defineCommand({
1698
+ meta: { name: 'scans', description: 'Read retained Lighthouse Scans and the Pages behind them' },
1699
+ subCommands: {
1700
+ list: defineCommand({
1701
+ meta: { name: 'list', description: 'List retained Scans, newest first' },
1702
+ args: { limit: { type: 'string', description: 'Maximum Scans, 1 to 100' } },
1703
+ run: ({ args }) => capture(execution, () => performanceScans(runtime, globals, { limit: args.limit }), args._, 0)(),
1704
+ }),
1705
+ show: defineCommand({
1706
+ meta: { name: 'show', description: 'Show one Scan with the failing checks behind its scores' },
1707
+ args: { scanId: positional('scan-id', 'Scan ID') },
1708
+ run: ({ args }) => capture(execution, () => performanceScanDetail(runtime, globals, args.scanId), args._, 1)(),
1709
+ }),
1710
+ pages: defineCommand({
1711
+ meta: { name: 'pages', description: 'List the Pages this Site scans on a schedule' },
1712
+ run: ({ args }) => capture(execution, () => monitoredPages(runtime, globals), args._, 0)(),
1713
+ }),
1714
+ },
1715
+ });
1716
+ const annotations = defineCommand({
1717
+ meta: { name: 'annotations', description: 'Mark what changed on the charts for this Site' },
1718
+ subCommands: {
1719
+ list: defineCommand({
1720
+ meta: { name: 'list', description: 'List the chart annotations for this Site' },
1721
+ run: ({ args }) => capture(execution, () => annotationsList(runtime, globals), args._, 0)(),
1722
+ }),
1723
+ create: defineCommand({
1724
+ meta: { name: 'create', description: 'Place one dated marker on the charts for this Site' },
1725
+ args: {
1726
+ date: { type: 'string', description: 'Day the change happened, YYYY-MM-DD' },
1727
+ title: { type: 'string', description: 'Marker label' },
1728
+ note: { type: 'string', description: 'Optional hover description' },
1729
+ url: { type: 'string', description: 'Optional http(s) link' },
1730
+ },
1731
+ run: ({ args }) => capture(execution, () => annotationCreate(runtime, globals, {
1732
+ date: args.date,
1733
+ title: args.title,
1734
+ note: args.note,
1735
+ url: args.url,
1736
+ }), args._, 0)(),
1737
+ }),
1738
+ update: defineCommand({
1739
+ meta: { name: 'update', description: 'Change one annotation' },
1740
+ args: {
1741
+ annotationId: positional('annotation-id', 'Annotation ID'),
1742
+ date: { type: 'string', description: 'Day the change happened, YYYY-MM-DD' },
1743
+ title: { type: 'string', description: 'Marker label' },
1744
+ note: { type: 'string', description: 'Hover description. Pass an empty string to clear it' },
1745
+ url: { type: 'string', description: 'http(s) link. Pass an empty string to clear it' },
1746
+ },
1747
+ run: ({ args }) => capture(execution, () => annotationUpdate(runtime, globals, {
1748
+ annotationId: args.annotationId,
1749
+ date: args.date,
1750
+ title: args.title,
1751
+ note: args.note,
1752
+ url: args.url,
1753
+ }), args._, 1)(),
1754
+ }),
1755
+ delete: defineCommand({
1756
+ meta: { name: 'delete', description: 'Remove one annotation permanently' },
1757
+ args: { annotationId: positional('annotation-id', 'Annotation ID') },
1758
+ run: ({ args }) => capture(execution, () => annotationDelete(runtime, globals, {
1759
+ annotationId: args.annotationId,
1760
+ }), args._, 1)(),
807
1761
  }),
808
1762
  },
809
1763
  });
@@ -826,11 +1780,11 @@ export function createRootCommand(runtime, globals, execution) {
826
1780
  const research = defineCommand({
827
1781
  meta: { name: 'research', description: 'Read stored and live search research' },
828
1782
  subCommands: {
829
- overview: defineCommand({
1783
+ 'overview': defineCommand({
830
1784
  meta: { name: 'overview', description: 'Read stored Site metrics and competitor research' },
831
1785
  run: ({ args }) => capture(execution, () => researchOverview(runtime, globals), args._, 0)(),
832
1786
  }),
833
- keywords: defineCommand({
1787
+ 'keywords': defineCommand({
834
1788
  meta: { name: 'keywords', description: 'Find live keyword ideas with volume and difficulty' },
835
1789
  args: {
836
1790
  'topic': positional('topic', 'Keyword or topic'),
@@ -857,7 +1811,17 @@ export function createRootCommand(runtime, globals, execution) {
857
1811
  verbose: args.verbose,
858
1812
  }), args._, 1)(),
859
1813
  }),
860
- serp: defineCommand({
1814
+ 'domain-traffic': defineCommand({
1815
+ meta: { name: 'domain-traffic', description: 'Estimate the organic search traffic of any domain' },
1816
+ args: { domain: positional('domain', 'Domain, for example nuxtseo.com') },
1817
+ run: ({ args }) => capture(execution, () => researchDomainTraffic(runtime, globals, args.domain), args._, 1)(),
1818
+ }),
1819
+ 'domain-availability': defineCommand({
1820
+ meta: { name: 'domain-availability', description: 'Check whether domains are registered, up to 10' },
1821
+ args: { domains: positional('domains', 'Comma-separated domains, up to 10') },
1822
+ run: ({ args }) => capture(execution, () => researchDomainAvailability(runtime, globals, args.domains), args._, 1)(),
1823
+ }),
1824
+ 'serp': defineCommand({
861
1825
  meta: { name: 'serp', description: 'Analyze one live Google SERP' },
862
1826
  args: {
863
1827
  'keyword': positional('keyword', 'Keyword'),
@@ -870,7 +1834,7 @@ export function createRootCommand(runtime, globals, execution) {
870
1834
  locationCode: args['location-code'],
871
1835
  }), args._, 1)(),
872
1836
  }),
873
- rankings: defineCommand({
1837
+ 'rankings': defineCommand({
874
1838
  meta: { name: 'rankings', description: 'Research live keyword rankings for a domain' },
875
1839
  args: {
876
1840
  'domain': positional('domain', 'Domain without a protocol'),
@@ -906,6 +1870,21 @@ export function createRootCommand(runtime, globals, execution) {
906
1870
  meta: { name: 'link-opportunities', description: 'List internal link opportunities' },
907
1871
  run: ({ args }) => capture(execution, () => auditLinkOpportunities(runtime, globals), args._, 0)(),
908
1872
  }),
1873
+ 'link-structure': defineCommand({
1874
+ meta: { name: 'link-structure', description: 'Read template links, dead ends, and generic anchors' },
1875
+ run: ({ args }) => capture(execution, () => auditLinkStructure(runtime, globals), args._, 0)(),
1876
+ }),
1877
+ 'changes': defineCommand({
1878
+ meta: { name: 'changes', description: 'Compare the latest crawl with its baseline' },
1879
+ args: {
1880
+ from: { type: 'string', description: 'Baseline crawl generation' },
1881
+ to: { type: 'string', description: 'Comparison crawl generation' },
1882
+ },
1883
+ run: ({ args }) => capture(execution, () => auditChanges(runtime, globals, {
1884
+ from: args.from,
1885
+ to: args.to,
1886
+ }), args._, 0)(),
1887
+ }),
909
1888
  },
910
1889
  });
911
1890
  const content = defineCommand({
@@ -924,11 +1903,13 @@ export function createRootCommand(runtime, globals, execution) {
924
1903
  },
925
1904
  limit: { type: 'string', description: 'Maximum Content Briefs, 1 to 100' },
926
1905
  offset: { type: 'string', description: 'Pagination offset' },
1906
+ all: { type: 'boolean', description: 'Repeat until every page is written; one envelope per page' },
927
1907
  },
928
1908
  run: ({ args }) => capture(execution, () => contentBriefList(runtime, globals, {
929
1909
  status: args.status,
930
1910
  limit: args.limit,
931
1911
  offset: args.offset,
1912
+ all: args.all,
932
1913
  }), args._, 0)(),
933
1914
  }),
934
1915
  show: defineCommand({
@@ -954,39 +1935,66 @@ export function createRootCommand(runtime, globals, execution) {
954
1935
  const search = defineCommand({
955
1936
  meta: { name: 'search', description: 'Read Search Console state' },
956
1937
  subCommands: {
957
- status: defineCommand({
1938
+ 'status': defineCommand({
958
1939
  meta: { name: 'status', description: 'Read the stored Search Console connection' },
959
1940
  run: ({ args }) => capture(execution, () => searchStatus(runtime, globals), args._, 0)(),
960
1941
  }),
961
- analytics: defineCommand({
962
- meta: { name: 'analytics', description: 'List Search Console query or Page rows' },
1942
+ 'analytics': defineCommand({
1943
+ meta: { name: 'analytics', description: 'Read a Search Console breakdown, trend, detail, or analysis' },
963
1944
  args: {
964
- 'view': positional('pages-or-keywords', 'pages or keywords'),
1945
+ 'view': positional('view', SEARCH_ANALYTICS_VIEWS.join(', '), SEARCH_ANALYTICS_VIEWS),
965
1946
  'period': { type: 'enum', options: ['7d', '28d', '3m', '6m', '12m'], description: 'Search Console period' },
966
1947
  'limit': { type: 'string', description: 'Maximum rows, 1 to 100' },
967
1948
  'page': { type: 'string', description: 'Result page number' },
1949
+ 'all': { type: 'boolean', description: 'Repeat until every page is written; row views only' },
968
1950
  'search': { type: 'string', description: 'Filter rows by text' },
969
1951
  'sort': { type: 'enum', options: ['clicks', 'impressions', 'ctr', 'position'], description: 'Sort field' },
970
1952
  'sort-dir': { type: 'enum', options: ['asc', 'desc'], description: 'Sort direction' },
1953
+ 'filter': { type: 'enum', options: ['top-level', 'new', 'lost', 'improving', 'declining'], description: 'Row set filter' },
1954
+ 'page-url': { type: 'string', description: 'Page URL or path; required for page-detail' },
1955
+ 'keyword': { type: 'string', description: 'Keyword; required for keyword-detail' },
1956
+ 'preset': { type: 'enum', options: [...SEARCH_ANALYSIS_PRESETS], description: 'Analysis preset; required for analysis' },
1957
+ 'brand-terms': { type: 'string', description: 'Comma separated brand terms; required for brand analysis' },
1958
+ 'min-clicks': { type: 'string', description: 'Minimum clicks' },
1959
+ 'max-clicks': { type: 'string', description: 'Maximum clicks' },
1960
+ 'min-impressions': { type: 'string', description: 'Minimum impressions' },
1961
+ 'max-impressions': { type: 'string', description: 'Maximum impressions' },
1962
+ 'min-position': { type: 'string', description: 'Minimum position, 1 to 100' },
1963
+ 'max-position': { type: 'string', description: 'Maximum position, 1 to 100' },
1964
+ 'max-ctr': { type: 'string', description: 'Maximum click-through rate, 0 to 1' },
971
1965
  },
972
1966
  run: ({ args }) => capture(execution, () => searchAnalytics(runtime, globals, {
973
1967
  view: args.view,
974
1968
  period: args.period,
975
1969
  limit: args.limit,
976
1970
  page: args.page,
1971
+ all: args.all,
977
1972
  search: args.search,
978
1973
  sort: args.sort,
979
1974
  sortDir: args['sort-dir'],
1975
+ filter: args.filter,
1976
+ pageUrl: args['page-url'],
1977
+ keyword: args.keyword,
1978
+ preset: args.preset,
1979
+ brandTerms: args['brand-terms'],
1980
+ minClicks: args['min-clicks'],
1981
+ maxClicks: args['max-clicks'],
1982
+ minImpressions: args['min-impressions'],
1983
+ maxImpressions: args['max-impressions'],
1984
+ minPosition: args['min-position'],
1985
+ maxPosition: args['max-position'],
1986
+ maxCtr: args['max-ctr'],
980
1987
  }), args._, 1)(),
981
1988
  }),
982
- indexing: defineCommand({
1989
+ 'indexing': defineCommand({
983
1990
  meta: { name: 'indexing', description: 'Read retained Search Console indexing coverage' },
984
1991
  args: {
985
- view: positional('summary-or-urls', 'summary or urls'),
1992
+ view: positional('summary-or-urls', 'summary or urls', SEARCH_INDEXING_VIEWS),
986
1993
  issue: { type: 'string', description: 'Filter URLs by issue type' },
987
1994
  status: { type: 'enum', options: ['indexed', 'not_indexed', 'pending'], description: 'Filter URLs by indexing status' },
988
1995
  limit: { type: 'string', description: 'Maximum URLs, 1 to 500' },
989
1996
  offset: { type: 'string', description: 'Pagination offset' },
1997
+ all: { type: 'boolean', description: 'Repeat until every page is written; the urls view only' },
990
1998
  },
991
1999
  run: ({ args }) => capture(execution, () => searchIndexing(runtime, globals, {
992
2000
  view: args.view,
@@ -994,8 +2002,90 @@ export function createRootCommand(runtime, globals, execution) {
994
2002
  status: args.status,
995
2003
  limit: args.limit,
996
2004
  offset: args.offset,
2005
+ all: args.all,
997
2006
  }), args._, 1)(),
998
2007
  }),
2008
+ 'cohorts': defineCommand({
2009
+ meta: { name: 'cohorts', description: 'Read the not-indexed rate per route family' },
2010
+ args: {
2011
+ 'limit': { type: 'string', description: 'Maximum route families, 1 to 50' },
2012
+ 'min-pages': { type: 'string', description: 'Ignore families under this many crawled pages, 1 to 500' },
2013
+ },
2014
+ run: ({ args }) => capture(execution, () => searchCohorts(runtime, globals, {
2015
+ limit: args.limit,
2016
+ minPages: args['min-pages'],
2017
+ }), args._, 0)(),
2018
+ }),
2019
+ 'index-history': defineCommand({
2020
+ meta: { name: 'index-history', description: 'Read retained indexing changes and their candidate causes' },
2021
+ args: {
2022
+ url: { type: 'string', description: 'One Page URL or path; omit for the whole Site' },
2023
+ field: { type: 'enum', options: [...INDEXING_TRANSITION_FIELDS], description: 'Indexing field to track' },
2024
+ days: { type: 'string', description: 'Window in days, 1 to 730' },
2025
+ },
2026
+ run: ({ args }) => capture(execution, () => searchIndexHistory(runtime, globals, {
2027
+ url: args.url,
2028
+ field: args.field,
2029
+ days: args.days,
2030
+ }), args._, 0)(),
2031
+ }),
2032
+ 'inspect': defineCommand({
2033
+ meta: { name: 'inspect', description: 'Read the latest URL Inspection result for one URL' },
2034
+ args: { url: positional('url', 'Page URL or path') },
2035
+ run: ({ args }) => capture(execution, () => searchInspect(runtime, globals, args.url), args._, 1)(),
2036
+ }),
2037
+ },
2038
+ });
2039
+ const sitemaps = defineCommand({
2040
+ meta: { name: 'sitemaps', description: 'Read and manage Search Console sitemaps' },
2041
+ subCommands: {
2042
+ list: defineCommand({
2043
+ meta: { name: 'list', description: 'List the sitemaps Search Console knows' },
2044
+ run: ({ args }) => capture(execution, () => sitemapsList(runtime, globals), args._, 0)(),
2045
+ }),
2046
+ urls: defineCommand({
2047
+ meta: { name: 'urls', description: 'List sitemap URL membership for one generation' },
2048
+ args: {
2049
+ 'generation-id': { type: 'string', description: 'Pin the read to one upstream generation' },
2050
+ 'feedpath': { type: 'string', description: 'Restrict to one sitemap path' },
2051
+ 'cursor': { type: 'string', description: 'Opaque cursor from a previous response' },
2052
+ 'limit': { type: 'string', description: 'Maximum URLs, 1 to 1000' },
2053
+ 'all': { type: 'boolean', description: 'Repeat until every page is written; one envelope per page' },
2054
+ },
2055
+ run: ({ args }) => capture(execution, () => sitemapsUrls(runtime, globals, {
2056
+ generationId: args['generation-id'],
2057
+ feedpath: args.feedpath,
2058
+ cursor: args.cursor,
2059
+ limit: args.limit,
2060
+ all: args.all,
2061
+ }), args._, 0)(),
2062
+ }),
2063
+ submit: defineCommand({
2064
+ meta: { name: 'submit', description: 'Submit one sitemap to Search Console' },
2065
+ args: { url: positional('sitemap-url', 'Absolute sitemap URL') },
2066
+ run: ({ args }) => capture(execution, () => sitemapsAction(runtime, globals, 'submit', args.url), args._, 1)(),
2067
+ }),
2068
+ delete: defineCommand({
2069
+ meta: { name: 'delete', description: 'Delete one sitemap from Search Console' },
2070
+ args: { url: positional('sitemap-url', 'Absolute sitemap URL') },
2071
+ run: ({ args }) => capture(execution, () => sitemapsAction(runtime, globals, 'delete', args.url), args._, 1)(),
2072
+ }),
2073
+ },
2074
+ });
2075
+ const skill = defineCommand({
2076
+ meta: { name: 'skill', description: 'Install the packaged coding agent skill' },
2077
+ subCommands: {
2078
+ install: defineCommand({
2079
+ meta: { name: 'install', description: 'Copy the nuxtseo-cli skill into an agent skill directory' },
2080
+ args: {
2081
+ agent: { type: 'enum', options: [...SKILL_AGENTS], description: 'Agent skill directory; default claude' },
2082
+ target: { type: 'string', description: 'Write into this directory instead' },
2083
+ },
2084
+ run: ({ args }) => capture(execution, () => skillInstallCommand(runtime, globals, {
2085
+ agent: args.agent,
2086
+ target: args.target,
2087
+ }), args._, 0)(),
2088
+ }),
999
2089
  },
1000
2090
  });
1001
2091
  const timeline = defineCommand({
@@ -1086,8 +2176,44 @@ export function createRootCommand(runtime, globals, execution) {
1086
2176
  meta: { name: 'performance', description: 'Read the stored Site performance overview' },
1087
2177
  run: ({ args }) => capture(execution, () => performance(runtime, globals), args._, 0)(),
1088
2178
  }),
2179
+ annotations,
1089
2180
  research,
2181
+ scans,
1090
2182
  search,
2183
+ sitemaps,
2184
+ status: defineCommand({
2185
+ meta: { name: 'status', description: 'Read the Site assessment and the one thing to do next' },
2186
+ run: ({ args }) => capture(execution, () => siteStatus(runtime, globals), args._, 0)(),
2187
+ }),
2188
+ vitals,
2189
+ analytics: defineCommand({
2190
+ meta: { name: 'analytics', description: 'Read Web Analytics for the selected Site' },
2191
+ args: {
2192
+ 'view': positional('view', ANALYTICS_VIEWS.join(', ')),
2193
+ 'period': { type: 'string', description: 'Analytics period, for example 28d' },
2194
+ 'compare': { type: 'enum', options: ['previous', 'year', 'none'], description: 'Comparison window' },
2195
+ 'dimension': { type: 'enum', options: [...ANALYTICS_DIMENSIONS], description: 'Breakdown dimension; required for the dimension view' },
2196
+ 'filters': { type: 'string', description: 'JSON array of dimension filters' },
2197
+ 'phase': { type: 'enum', options: ['current', 'prior', 'full'], description: 'Performance phase' },
2198
+ 'stable-data': { type: 'boolean', default: true, description: 'Exclude unstable recent days; use --no-stable-data to include them' },
2199
+ 'host-scope': { type: 'boolean', default: true, description: 'Restrict to the Site host; use --no-host-scope to include every host' },
2200
+ 'compare-prior': { type: 'boolean', default: true, description: 'Include the prior window; use --no-compare-prior to skip it' },
2201
+ 'fresh': { type: 'boolean', description: 'Bypass the server cache' },
2202
+ },
2203
+ run: ({ args }) => capture(execution, () => analyticsQuery(runtime, globals, {
2204
+ view: args.view,
2205
+ period: args.period,
2206
+ compare: args.compare,
2207
+ dimension: args.dimension,
2208
+ filters: args.filters,
2209
+ performancePhase: args.phase,
2210
+ stableData: args['stable-data'],
2211
+ hostScope: args['host-scope'],
2212
+ comparePrior: args['compare-prior'],
2213
+ fresh: args.fresh,
2214
+ }), args._, 1)(),
2215
+ }),
2216
+ skill,
1091
2217
  timeline,
1092
2218
  },
1093
2219
  });