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