@nuxtseo/cli 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # NuxtSEO CLI
2
2
 
3
3
  Use NuxtSEO public Site operations from a terminal, script, or coding agent.
4
- The CLI sends feature requests through `@nuxtseo/sdk`. It does not call MCP,
5
- private routes, or providers.
4
+ The CLI sends feature requests through `@nuxtseo/sdk`. It does not call MCP or
5
+ private routes. Live research uses declared public API operations.
6
6
 
7
7
  ## Install
8
8
 
@@ -148,7 +148,8 @@ Agent controls:
148
148
  | `--no-input` | Disable every prompt even when stdin and stdout are TTYs |
149
149
  | `--timeout-ms <milliseconds>` | Apply one deadline to Site resolution, SDK retries, and the operation; default `30000`, maximum `300000` |
150
150
 
151
- Request timeouts return `CliError` code `request_timeout` and exit `6`.
151
+ Request timeouts return `CliError` code `request_timeout` and exit `6`. The
152
+ error prints an exact retry command with a longer deadline.
152
153
  `SIGINT` and `SIGTERM` abort the active request and return exit `130`.
153
154
 
154
155
  ## Commands and paging
@@ -170,8 +171,28 @@ nuxtseo page inspect <url>
170
171
  nuxtseo page scan <url>
171
172
  nuxtseo performance
172
173
  nuxtseo search status
174
+ nuxtseo search analytics <pages|keywords>
175
+ nuxtseo research overview
176
+ nuxtseo research keywords <topic>
177
+ nuxtseo research serp <keyword>
178
+ nuxtseo research rankings <domain>
179
+ nuxtseo audit link-opportunities
180
+ nuxtseo audit content-decay
181
+ nuxtseo audit duplicates
182
+ nuxtseo content briefs list
183
+ nuxtseo content briefs show <brief-id>
184
+ nuxtseo content briefs create <keyword>
173
185
  ```
174
186
 
187
+ Run bare `nuxtseo` in a terminal for a task-based menu. It groups Site fixes,
188
+ performance, research, content, and account setup. The menu prints the direct
189
+ command it runs.
190
+
191
+ `search status` reads stored connection state. It never waits for Google.
192
+ `research keywords`, `research serp`, and `research rankings` can use the Team
193
+ research allowance. Keyword responses report cache use in `evidence`. SERP and
194
+ ranking responses report `cached: true`. Cached responses use no unit.
195
+
175
196
  The CLI fetches one page per invocation. It does not auto-page or merge
176
197
  responses.
177
198
 
@@ -182,18 +203,29 @@ responses.
182
203
  | `page inspect` | `--limit 1..200`, `--offset >=0` | `--limit 100 --offset 0` |
183
204
  | `backlinks recoverable` | `--limit 1..200`, `--offset >=0` | `--limit 100 --offset 0` |
184
205
  | `mentions list` | `--limit 1..200` | `--limit 100` |
206
+ | `search analytics` | `--limit 1..100`, `--page >=1` | `--limit 25 --page 1` |
207
+ | `content briefs list` | `--limit 1..100`, `--offset >=0` | `--limit 25 --offset 0` |
185
208
 
186
209
  Keep the server order for actions. For another page, pass the next offset or
187
210
  cursor reported by the response. A cursor is opaque; do not edit or infer it.
188
211
 
189
212
  ## Coding agents
190
213
 
191
- The package ships an agent skill that teaches Claude Code how to drive the CLI.
192
- Copy it into a project after install:
214
+ The package ships an agent skill that teaches coding agents how to drive the
215
+ CLI. Copy it into a global skill directory after installation.
216
+
217
+ Claude Code:
218
+
219
+ ```sh
220
+ mkdir -p ~/.claude/skills
221
+ cp -R node_modules/@nuxtseo/cli/skills/nuxtseo-cli ~/.claude/skills/
222
+ ```
223
+
224
+ Codex:
193
225
 
194
226
  ```sh
195
- mkdir -p .claude/skills
196
- cp -R node_modules/@nuxtseo/cli/skills/nuxtseo-cli .claude/skills/
227
+ mkdir -p ~/.codex/skills
228
+ cp -R node_modules/@nuxtseo/cli/skills/nuxtseo-cli ~/.codex/skills/
197
229
  ```
198
230
 
199
231
  The skill covers the JSON contract, Site selection, paging, mutation consent,
package/dist/cli.js CHANGED
@@ -135,6 +135,45 @@ async function promptPageUrl(runtime, action) {
135
135
  ? fail(EXIT_CODE.interrupted, `interrupted: ${action} cancelled.`)
136
136
  : ok(url);
137
137
  }
138
+ async function promptTextValue(runtime, options) {
139
+ const value = await prompts.text({
140
+ message: options.message,
141
+ placeholder: options.placeholder,
142
+ input: runtime.input,
143
+ output: runtime.error,
144
+ signal: runtime.signal,
145
+ validate: input => typeof input === 'string' && input.trim().length > 1
146
+ ? undefined
147
+ : `${options.label} is required.`,
148
+ });
149
+ return prompts.isCancel(value)
150
+ ? fail(EXIT_CODE.interrupted, `interrupted: ${options.label} entry cancelled.`)
151
+ : ok(value.trim());
152
+ }
153
+ function displayCommand(args) {
154
+ return `nuxtseo ${args.map(argument => /\s/.test(argument) ? JSON.stringify(argument) : argument).join(' ')}`;
155
+ }
156
+ function timeoutRetryFailure(failure, rawArgs, timeoutMs) {
157
+ if (failure.code !== 'request_timeout')
158
+ return failure;
159
+ const args = [];
160
+ for (let index = 0; index < rawArgs.length; index++) {
161
+ const argument = rawArgs[index];
162
+ if (argument === '--timeout-ms') {
163
+ index++;
164
+ continue;
165
+ }
166
+ if (argument.startsWith('--timeout-ms='))
167
+ continue;
168
+ args.push(argument);
169
+ }
170
+ args.push('--timeout-ms', String(Math.min(timeoutMs * 2, 300_000)));
171
+ return { ...failure, message: `${failure.message}\nRetry: ${displayCommand(args)}` };
172
+ }
173
+ async function runInteractiveCommand(args, runtime, globals) {
174
+ writeDiagnostic(runtime, `Command: ${displayCommand(args)}`);
175
+ return runExplicit(args, runtime, globals);
176
+ }
138
177
  async function interactiveBare(runtime, globals) {
139
178
  const [auth, apiUrl, config] = await Promise.all([
140
179
  getCredentialStatus({ env: runtime.env, paths: runtime.paths }),
@@ -153,43 +192,147 @@ async function interactiveBare(runtime, globals) {
153
192
  `API: ${apiUrl.value.apiUrl}`,
154
193
  `Current Site: ${config.value.siteId ?? 'none'}`,
155
194
  ].join('\n'), 'Status', { input: runtime.input, output: runtime.error });
156
- const selected = await prompts.select({
157
- message: 'What would you like to do?',
195
+ const group = await prompts.select({
196
+ message: 'Choose a task',
158
197
  options: [
159
198
  ...(!auth.value.authenticated ? [{ value: 'login', label: 'Log in', hint: 'Team API token' }] : []),
199
+ { value: 'fix', label: 'Fix my Site', hint: 'Issues, Pages, and scans' },
200
+ { value: 'performance', label: 'Understand performance', hint: 'Performance and Search Console' },
201
+ { value: 'research', label: 'Research growth', hint: 'Keywords, SERPs, and competitors' },
202
+ { value: 'content', label: 'Plan content', hint: 'Briefs, decay, and duplicates' },
203
+ { value: 'account', label: 'Account and setup', hint: config.value.siteId ?? 'no Site selected' },
204
+ { value: 'exit', label: 'Exit' },
205
+ ],
206
+ input: runtime.input,
207
+ output: runtime.error,
208
+ signal: runtime.signal,
209
+ });
210
+ if (prompts.isCancel(group) || group === 'exit')
211
+ return ok(undefined);
212
+ if (group === 'login')
213
+ return runInteractiveCommand(['login'], runtime, globals);
214
+ if (group === 'fix') {
215
+ const task = await prompts.select({
216
+ message: 'Fix my Site',
217
+ options: [
218
+ { value: 'actions', label: 'Issues and opportunities' },
219
+ { value: 'inspect', label: 'Inspect a Page' },
220
+ { value: 'scan', label: 'Scan a Page' },
221
+ { value: 'backlinks', label: 'Recover broken Backlinks' },
222
+ ],
223
+ input: runtime.input,
224
+ output: runtime.error,
225
+ signal: runtime.signal,
226
+ });
227
+ if (prompts.isCancel(task))
228
+ return ok(undefined);
229
+ if (task === 'inspect' || task === 'scan') {
230
+ const url = await promptPageUrl(runtime, task === 'inspect' ? 'Inspect' : 'Scan');
231
+ return url._tag === 'Err'
232
+ ? url
233
+ : runInteractiveCommand(['page', task, url.value], runtime, globals);
234
+ }
235
+ return runInteractiveCommand(task === 'actions' ? ['actions', 'list'] : ['backlinks', 'recoverable'], runtime, globals);
236
+ }
237
+ if (group === 'performance') {
238
+ const task = await prompts.select({
239
+ message: 'Understand performance',
240
+ options: [
241
+ { value: 'performance', label: 'Performance overview' },
242
+ { value: 'connection', label: 'Search Console connection' },
243
+ { value: 'pages', label: 'Search Console Pages' },
244
+ { value: 'keywords', label: 'Search Console queries' },
245
+ ],
246
+ input: runtime.input,
247
+ output: runtime.error,
248
+ signal: runtime.signal,
249
+ });
250
+ if (prompts.isCancel(task))
251
+ return ok(undefined);
252
+ const command = task === 'performance'
253
+ ? ['performance']
254
+ : task === 'connection'
255
+ ? ['search', 'status']
256
+ : ['search', 'analytics', task];
257
+ return runInteractiveCommand(command, runtime, globals);
258
+ }
259
+ if (group === 'research') {
260
+ const task = await prompts.select({
261
+ message: 'Research growth',
262
+ options: [
263
+ { value: 'overview', label: 'Site and competitors', hint: 'stored data' },
264
+ { value: 'keywords', label: 'Keyword ideas', hint: 'uses live research allowance' },
265
+ { value: 'serp', label: 'SERP analysis', hint: 'uses live research allowance' },
266
+ { value: 'rankings', label: 'Domain rankings', hint: 'uses live research allowance' },
267
+ { value: 'links', label: 'Link opportunities', hint: 'stored data' },
268
+ ],
269
+ input: runtime.input,
270
+ output: runtime.error,
271
+ signal: runtime.signal,
272
+ });
273
+ if (prompts.isCancel(task))
274
+ return ok(undefined);
275
+ if (task === 'overview')
276
+ return runInteractiveCommand(['research', 'overview'], runtime, globals);
277
+ if (task === 'links')
278
+ return runInteractiveCommand(['audit', 'link-opportunities'], runtime, globals);
279
+ const label = task === 'rankings' ? 'Domain' : 'Keyword';
280
+ const value = await promptTextValue(runtime, {
281
+ message: task === 'rankings' ? 'Which domain?' : 'Which keyword or topic?',
282
+ placeholder: task === 'rankings' ? 'example.com' : 'nuxt seo',
283
+ label,
284
+ });
285
+ if (value._tag === 'Err')
286
+ return value;
287
+ return runInteractiveCommand(['research', task, value.value], runtime, globals);
288
+ }
289
+ if (group === 'content') {
290
+ const task = await prompts.select({
291
+ message: 'Plan content',
292
+ options: [
293
+ { value: 'briefs', label: 'Content Briefs' },
294
+ { value: 'create', label: 'Create a Content Brief' },
295
+ { value: 'decay', label: 'Content decay' },
296
+ { value: 'duplicates', label: 'Duplicate clusters' },
297
+ ],
298
+ input: runtime.input,
299
+ output: runtime.error,
300
+ signal: runtime.signal,
301
+ });
302
+ if (prompts.isCancel(task))
303
+ return ok(undefined);
304
+ if (task === 'briefs')
305
+ return runInteractiveCommand(['content', 'briefs', 'list'], runtime, globals);
306
+ if (task === 'decay')
307
+ return runInteractiveCommand(['audit', 'content-decay'], runtime, globals);
308
+ if (task === 'duplicates')
309
+ return runInteractiveCommand(['audit', 'duplicates'], runtime, globals);
310
+ const keyword = await promptTextValue(runtime, {
311
+ message: 'Which target keyword?',
312
+ placeholder: 'nuxt seo',
313
+ label: 'Keyword',
314
+ });
315
+ return keyword._tag === 'Err'
316
+ ? keyword
317
+ : runInteractiveCommand(['content', 'briefs', 'create', keyword.value], runtime, globals);
318
+ }
319
+ const task = await prompts.select({
320
+ message: 'Account and setup',
321
+ options: [
160
322
  { value: 'site', label: 'Switch Site', hint: config.value.siteId ?? 'none selected' },
161
- { value: 'actions', label: 'Next actions' },
162
- { value: 'inspect', label: 'Inspect a Page' },
163
- { value: 'scan', label: 'Scan a Page' },
164
- { value: 'performance', label: 'Performance overview' },
165
- { value: 'search', label: 'Search Console status' },
166
323
  { value: 'usage', label: 'Account usage' },
167
324
  { value: 'config', label: 'Configuration' },
168
- { value: 'exit', label: 'Exit' },
325
+ { value: 'logout', label: 'Log out' },
169
326
  ],
170
327
  input: runtime.input,
171
328
  output: runtime.error,
172
329
  signal: runtime.signal,
173
330
  });
174
- if (prompts.isCancel(selected) || selected === 'exit')
331
+ if (prompts.isCancel(task))
175
332
  return ok(undefined);
176
- if (selected === 'site')
333
+ if (task === 'site')
177
334
  return switchSite(runtime, globals);
178
- if (selected === 'inspect' || selected === 'scan') {
179
- const url = await promptPageUrl(runtime, selected === 'inspect' ? 'Inspect' : 'Scan');
180
- return url._tag === 'Err'
181
- ? url
182
- : runExplicit(['page', selected, url.value], runtime, globals);
183
- }
184
- const commands = {
185
- login: ['login'],
186
- actions: ['actions', 'list'],
187
- performance: ['performance'],
188
- search: ['search', 'status'],
189
- usage: ['usage'],
190
- config: ['config'],
191
- };
192
- return runExplicit(commands[selected], runtime, globals);
335
+ return runInteractiveCommand([task], runtime, globals);
193
336
  }
194
337
  export async function runCli(rawArgs, runtime) {
195
338
  const jsonRequested = rawArgs.includes('--json');
@@ -204,6 +347,7 @@ export async function runCli(rawArgs, runtime) {
204
347
  runtime.requestSignal,
205
348
  AbortSignal.timeout(globals.timeoutMs),
206
349
  ]),
350
+ requestTimeoutMs: globals.timeoutMs,
207
351
  };
208
352
  if (args.length === 1 && (args[0] === '--version' || args[0] === '-v')) {
209
353
  if (globals.json)
@@ -228,5 +372,7 @@ export async function runCli(rawArgs, runtime) {
228
372
  ? await interactiveBare(effectiveRuntime, globals)
229
373
  : await nonInteractiveBare(effectiveRuntime, globals)
230
374
  : await runExplicit(args, effectiveRuntime, globals);
231
- return result._tag === 'Ok' ? EXIT_CODE.success : report(effectiveRuntime, result.error, globals.json);
375
+ return result._tag === 'Ok'
376
+ ? EXIT_CODE.success
377
+ : report(effectiveRuntime, timeoutRetryFailure(result.error, rawArgs, globals.timeoutMs), globals.json);
232
378
  }
package/dist/commands.js CHANGED
@@ -4,8 +4,8 @@ import { createApiClient, fromStateError, loadApiContext } from './api.js';
4
4
  import { openBrowser } from './browser.js';
5
5
  import { EXIT_CODE, fail, fromSdkFailure, ok } from './failures.js';
6
6
  import { awaitPairingApproval, createCliPairingClient, startPairing } from './pairing.js';
7
- import { parseAbsolutePageUrl, parseInteger } from './parse.js';
8
- import { renderAction, renderActionResolution, renderActions, renderMentions, renderPageInspection, renderPageScan, renderPerformance, renderRecoverableBacklinks, renderSearchStatus, renderSites, renderUsage, } from './render.js';
7
+ import { parseAbsolutePageUrl, parseChoice, parseInteger } from './parse.js';
8
+ import { renderAction, renderActionResolution, renderActions, renderContentBrief, renderContentBriefCreated, renderContentBriefs, renderContentDecay, renderDuplicateClusters, renderKeywordResearch, renderLinkOpportunities, renderMentions, renderPageInspection, renderPageScan, renderPerformance, renderRankingsResearch, renderRecoverableBacklinks, renderResearchOverview, renderSearchAnalytics, renderSearchStatus, renderSerpResearch, renderSites, renderUsage, } from './render.js';
9
9
  import { writeCliResponse, writeDiagnostic, writeOutput, writeProtocolResponse } from './runtime.js';
10
10
  import { resolveSite } from './site.js';
11
11
  import { clearCredential, getCredentialStatus, readConfig, resolveApiUrl, saveCredential, updateConfig, } from './state/index.js';
@@ -23,7 +23,10 @@ async function withSpinner(runtime, message, task) {
23
23
  const spinner = runtime.interactive
24
24
  ? prompts.spinner({ output: runtime.error, signal: runtime.signal })
25
25
  : null;
26
- spinner?.start(message);
26
+ const deadline = runtime.requestTimeoutMs === undefined
27
+ ? ''
28
+ : `, ${Math.ceil(runtime.requestTimeoutMs / 1_000)}s limit`;
29
+ spinner?.start(`${message}${deadline}`);
27
30
  return task().then((result) => {
28
31
  spinner?.stop();
29
32
  return result;
@@ -408,6 +411,224 @@ async function searchStatus(runtime, globals) {
408
411
  }, { signal: runtime.requestSignal }));
409
412
  return present(runtime, globals, response, value => renderSearchStatus(value.data));
410
413
  }
414
+ async function searchAnalytics(runtime, globals, args) {
415
+ const view = parseChoice(args.view, { name: 'view', choices: ['pages', 'keywords'] });
416
+ if (view._tag === 'Err')
417
+ return view;
418
+ const period = parseChoice(args.period, { name: '--period', choices: ['7d', '28d', '3m', '6m', '12m'], defaultValue: '28d' });
419
+ if (period._tag === 'Err')
420
+ return period;
421
+ const limit = parseInteger(args.limit, { name: '--limit', minimum: 1, maximum: 100, defaultValue: 25 });
422
+ if (limit._tag === 'Err')
423
+ return limit;
424
+ const page = parseInteger(args.page, { name: '--page', minimum: 1, defaultValue: 1 });
425
+ if (page._tag === 'Err')
426
+ return page;
427
+ const sort = parseChoice(args.sort, { name: '--sort', choices: ['clicks', 'impressions', 'ctr', 'position'], defaultValue: 'clicks' });
428
+ if (sort._tag === 'Err')
429
+ return sort;
430
+ const sortDir = parseChoice(args.sortDir, { name: '--sort-dir', choices: ['asc', 'desc'], defaultValue: 'desc' });
431
+ if (sortDir._tag === 'Err')
432
+ return sortDir;
433
+ const resolved = await apiAndSite(runtime, globals);
434
+ if (resolved._tag === 'Err')
435
+ 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,
446
+ },
447
+ }, { signal: runtime.requestSignal }));
448
+ return present(runtime, globals, response, value => renderSearchAnalytics(value.data));
449
+ }
450
+ async function researchOverview(runtime, globals) {
451
+ const resolved = await apiAndSite(runtime, globals);
452
+ if (resolved._tag === 'Err')
453
+ return resolved;
454
+ const response = await withSpinner(runtime, 'Loading stored research', () => resolved.value.api.client.research.overview({
455
+ params: { siteId: resolved.value.siteId },
456
+ }, { signal: runtime.requestSignal }));
457
+ return present(runtime, globals, response, value => renderResearchOverview(value.data));
458
+ }
459
+ async function researchKeywordIdeas(runtime, globals, args) {
460
+ const minVolume = parseInteger(args.minVolume, { name: '--min-volume', minimum: 0, defaultValue: 10 });
461
+ if (minVolume._tag === 'Err')
462
+ return minVolume;
463
+ const maxVolume = parseInteger(args.maxVolume, { name: '--max-volume', minimum: 0, defaultValue: 10_000 });
464
+ if (maxVolume._tag === 'Err')
465
+ return maxVolume;
466
+ const minDifficulty = parseInteger(args.minDifficulty, { name: '--min-difficulty', minimum: 0, maximum: 100, defaultValue: 0 });
467
+ if (minDifficulty._tag === 'Err')
468
+ return minDifficulty;
469
+ const maxDifficulty = parseInteger(args.maxDifficulty, { name: '--max-difficulty', minimum: 0, maximum: 100, defaultValue: 60 });
470
+ if (maxDifficulty._tag === 'Err')
471
+ return maxDifficulty;
472
+ const limit = parseInteger(args.limit, { name: '--limit', minimum: 1, maximum: 100, defaultValue: 20 });
473
+ if (limit._tag === 'Err')
474
+ return limit;
475
+ const locationCode = parseInteger(args.locationCode, { name: '--location-code', minimum: 1, defaultValue: 2840 });
476
+ if (locationCode._tag === 'Err')
477
+ return locationCode;
478
+ if (maxVolume.value < minVolume.value)
479
+ return fail(EXIT_CODE.invalidInput, '--max-volume must be at least --min-volume.');
480
+ if (maxDifficulty.value < minDifficulty.value)
481
+ return fail(EXIT_CODE.invalidInput, '--max-difficulty must be at least --min-difficulty.');
482
+ const resolved = await apiAndSite(runtime, globals);
483
+ if (resolved._tag === 'Err')
484
+ return resolved;
485
+ const response = await withSpinner(runtime, 'Researching keyword ideas', () => resolved.value.api.client.research.keywords({
486
+ params: { siteId: resolved.value.siteId },
487
+ query: {
488
+ topic: args.topic,
489
+ minVolume: minVolume.value,
490
+ maxVolume: maxVolume.value,
491
+ minDifficulty: minDifficulty.value,
492
+ maxDifficulty: maxDifficulty.value,
493
+ intent: args.intent,
494
+ includeRelated: args.related ?? true,
495
+ verbose: args.verbose ?? false,
496
+ limit: limit.value,
497
+ locationCode: locationCode.value,
498
+ },
499
+ }, { signal: runtime.requestSignal }));
500
+ return present(runtime, globals, response, value => renderKeywordResearch(value.data));
501
+ }
502
+ async function researchSerp(runtime, globals, args) {
503
+ const depth = parseInteger(args.depth, { name: '--depth', minimum: 1, maximum: 20, defaultValue: 10 });
504
+ if (depth._tag === 'Err')
505
+ return depth;
506
+ const locationCode = parseInteger(args.locationCode, { name: '--location-code', minimum: 1, defaultValue: 2840 });
507
+ if (locationCode._tag === 'Err')
508
+ return locationCode;
509
+ const resolved = await apiAndSite(runtime, globals);
510
+ if (resolved._tag === 'Err')
511
+ return resolved;
512
+ const response = await withSpinner(runtime, 'Analyzing the SERP', () => resolved.value.api.client.research.serp({
513
+ params: { siteId: resolved.value.siteId },
514
+ query: { keyword: args.keyword, depth: depth.value, locationCode: locationCode.value },
515
+ }, { signal: runtime.requestSignal }));
516
+ return present(runtime, globals, response, value => renderSerpResearch(value.data));
517
+ }
518
+ async function researchRankings(runtime, globals, args) {
519
+ const limit = parseInteger(args.limit, { name: '--limit', minimum: 1, maximum: 100, defaultValue: 50 });
520
+ if (limit._tag === 'Err')
521
+ return limit;
522
+ const minPosition = parseInteger(args.minPosition, { name: '--min-position', minimum: 1, maximum: 100, defaultValue: 1 });
523
+ if (minPosition._tag === 'Err')
524
+ return minPosition;
525
+ const maxPosition = parseInteger(args.maxPosition, { name: '--max-position', minimum: 1, maximum: 100, defaultValue: 20 });
526
+ if (maxPosition._tag === 'Err')
527
+ return maxPosition;
528
+ const locationCode = parseInteger(args.locationCode, { name: '--location-code', minimum: 1, defaultValue: 2840 });
529
+ if (locationCode._tag === 'Err')
530
+ return locationCode;
531
+ const order = parseChoice(args.order, { name: '--order', choices: ['position', 'traffic'], defaultValue: 'position' });
532
+ if (order._tag === 'Err')
533
+ return order;
534
+ if (maxPosition.value < minPosition.value)
535
+ return fail(EXIT_CODE.invalidInput, '--max-position must be at least --min-position.');
536
+ const resolved = await apiAndSite(runtime, globals);
537
+ if (resolved._tag === 'Err')
538
+ return resolved;
539
+ const response = await withSpinner(runtime, 'Researching domain rankings', () => resolved.value.api.client.research.rankings({
540
+ params: { siteId: resolved.value.siteId },
541
+ query: {
542
+ domain: args.domain,
543
+ limit: limit.value,
544
+ minPosition: minPosition.value,
545
+ maxPosition: maxPosition.value,
546
+ locationCode: locationCode.value,
547
+ order: order.value,
548
+ },
549
+ }, { signal: runtime.requestSignal }));
550
+ return present(runtime, globals, response, value => renderRankingsResearch(value.data));
551
+ }
552
+ async function auditContentDecay(runtime, globals) {
553
+ const resolved = await apiAndSite(runtime, globals);
554
+ if (resolved._tag === 'Err')
555
+ return resolved;
556
+ const response = await withSpinner(runtime, 'Loading content decay', () => resolved.value.api.client.audit.contentDecay({
557
+ params: { siteId: resolved.value.siteId },
558
+ }, { signal: runtime.requestSignal }));
559
+ return present(runtime, globals, response, value => renderContentDecay(value.data));
560
+ }
561
+ async function auditDuplicateClusters(runtime, globals) {
562
+ const resolved = await apiAndSite(runtime, globals);
563
+ if (resolved._tag === 'Err')
564
+ return resolved;
565
+ const response = await withSpinner(runtime, 'Loading duplicate clusters', () => resolved.value.api.client.audit.duplicateClusters({
566
+ params: { siteId: resolved.value.siteId },
567
+ }, { signal: runtime.requestSignal }));
568
+ return present(runtime, globals, response, value => renderDuplicateClusters(value.data));
569
+ }
570
+ async function auditLinkOpportunities(runtime, globals) {
571
+ const resolved = await apiAndSite(runtime, globals);
572
+ if (resolved._tag === 'Err')
573
+ return resolved;
574
+ const response = await withSpinner(runtime, 'Loading link opportunities', () => resolved.value.api.client.audit.linkOpportunities({
575
+ params: { siteId: resolved.value.siteId },
576
+ }, { signal: runtime.requestSignal }));
577
+ return present(runtime, globals, response, value => renderLinkOpportunities(value.data));
578
+ }
579
+ async function contentBriefList(runtime, globals, args) {
580
+ const status = args.status === undefined
581
+ ? ok(undefined)
582
+ : parseChoice(args.status, {
583
+ name: '--status',
584
+ choices: ['queued', 'researching', 'ready', 'written', 'published', 'stale', 'failed', 'archived'],
585
+ });
586
+ if (status._tag === 'Err')
587
+ return status;
588
+ const limit = parseInteger(args.limit, { name: '--limit', minimum: 1, maximum: 100, defaultValue: 25 });
589
+ if (limit._tag === 'Err')
590
+ return limit;
591
+ const offset = parseInteger(args.offset, { name: '--offset', minimum: 0, defaultValue: 0 });
592
+ if (offset._tag === 'Err')
593
+ return offset;
594
+ const resolved = await apiAndSite(runtime, globals);
595
+ if (resolved._tag === 'Err')
596
+ return resolved;
597
+ const response = await withSpinner(runtime, 'Loading Content Briefs', () => resolved.value.api.client.content.listBriefs({
598
+ params: { siteId: resolved.value.siteId },
599
+ query: { status: status.value, limit: limit.value, offset: offset.value },
600
+ }, { signal: runtime.requestSignal }));
601
+ return present(runtime, globals, response, value => renderContentBriefs(value.data));
602
+ }
603
+ async function contentBriefShow(runtime, globals, briefId) {
604
+ const resolved = await apiAndSite(runtime, globals);
605
+ if (resolved._tag === 'Err')
606
+ return resolved;
607
+ const response = await withSpinner(runtime, 'Loading Content Brief', () => resolved.value.api.client.content.showBrief({
608
+ params: { siteId: resolved.value.siteId, briefId },
609
+ }, { signal: runtime.requestSignal }));
610
+ return present(runtime, globals, response, value => renderContentBrief(value.data));
611
+ }
612
+ async function contentBriefCreate(runtime, globals, args) {
613
+ const targetPage = args.targetPage === undefined ? ok(undefined) : parseAbsolutePageUrl(args.targetPage);
614
+ if (targetPage._tag === 'Err')
615
+ return targetPage;
616
+ const resolved = await apiAndSite(runtime, globals);
617
+ if (resolved._tag === 'Err')
618
+ return resolved;
619
+ const confirmed = await confirmMutation(runtime, globals, `Create a Content Brief for ${args.keyword}?`);
620
+ if (confirmed._tag === 'Err')
621
+ return confirmed;
622
+ if (!confirmed.value) {
623
+ writeDiagnostic(runtime, 'Mutation cancelled.');
624
+ return ok(undefined);
625
+ }
626
+ const response = await withSpinner(runtime, 'Creating Content Brief', () => resolved.value.api.client.content.createBrief({
627
+ params: { siteId: resolved.value.siteId },
628
+ body: { keyword: args.keyword, targetPage: targetPage.value },
629
+ }, { signal: runtime.requestSignal }));
630
+ return present(runtime, globals, response, value => renderContentBriefCreated(value.data));
631
+ }
411
632
  async function backlinksRecoverable(runtime, globals, limitInput, offsetInput) {
412
633
  const limit = parseInteger(limitInput, { name: '--limit', minimum: 1, maximum: 200, defaultValue: 100 });
413
634
  if (limit._tag === 'Err')
@@ -551,13 +772,162 @@ export function createRootCommand(runtime, globals, execution) {
551
772
  }),
552
773
  },
553
774
  });
775
+ const research = defineCommand({
776
+ meta: { name: 'research', description: 'Read stored and live search research' },
777
+ subCommands: {
778
+ overview: defineCommand({
779
+ meta: { name: 'overview', description: 'Read stored Site metrics and competitor research' },
780
+ run: ({ args }) => capture(execution, () => researchOverview(runtime, globals), args._, 0)(),
781
+ }),
782
+ keywords: defineCommand({
783
+ meta: { name: 'keywords', description: 'Find live keyword ideas with volume and difficulty' },
784
+ args: {
785
+ 'topic': positional('topic', 'Keyword or topic'),
786
+ 'min-volume': { type: 'string', description: 'Minimum monthly volume' },
787
+ 'max-volume': { type: 'string', description: 'Maximum monthly volume' },
788
+ 'min-difficulty': { type: 'string', description: 'Minimum difficulty, 0 to 100' },
789
+ 'max-difficulty': { type: 'string', description: 'Maximum difficulty, 0 to 100' },
790
+ 'limit': { type: 'string', description: 'Maximum keywords, 1 to 100' },
791
+ 'location-code': { type: 'string', description: 'DataForSEO location code' },
792
+ 'intent': { type: 'string', description: 'Search intent filter' },
793
+ 'related': { type: 'boolean', default: true, description: 'Include related keywords; use --no-related to exclude them' },
794
+ 'verbose': { type: 'boolean', description: 'Include full keyword evidence' },
795
+ },
796
+ run: ({ args }) => capture(execution, () => researchKeywordIdeas(runtime, globals, {
797
+ topic: args.topic,
798
+ minVolume: args['min-volume'],
799
+ maxVolume: args['max-volume'],
800
+ minDifficulty: args['min-difficulty'],
801
+ maxDifficulty: args['max-difficulty'],
802
+ limit: args.limit,
803
+ locationCode: args['location-code'],
804
+ intent: args.intent,
805
+ related: args.related,
806
+ verbose: args.verbose,
807
+ }), args._, 1)(),
808
+ }),
809
+ serp: defineCommand({
810
+ meta: { name: 'serp', description: 'Analyze one live Google SERP' },
811
+ args: {
812
+ 'keyword': positional('keyword', 'Keyword'),
813
+ 'depth': { type: 'string', description: 'Organic result depth, 1 to 20' },
814
+ 'location-code': { type: 'string', description: 'DataForSEO location code' },
815
+ },
816
+ run: ({ args }) => capture(execution, () => researchSerp(runtime, globals, {
817
+ keyword: args.keyword,
818
+ depth: args.depth,
819
+ locationCode: args['location-code'],
820
+ }), args._, 1)(),
821
+ }),
822
+ rankings: defineCommand({
823
+ meta: { name: 'rankings', description: 'Research live keyword rankings for a domain' },
824
+ args: {
825
+ 'domain': positional('domain', 'Domain without a protocol'),
826
+ 'limit': { type: 'string', description: 'Maximum keywords, 1 to 100' },
827
+ 'min-position': { type: 'string', description: 'Minimum position, 1 to 100' },
828
+ 'max-position': { type: 'string', description: 'Maximum position, 1 to 100' },
829
+ 'location-code': { type: 'string', description: 'DataForSEO location code' },
830
+ 'order': { type: 'enum', options: ['position', 'traffic'], description: 'Sort rankings' },
831
+ },
832
+ run: ({ args }) => capture(execution, () => researchRankings(runtime, globals, {
833
+ domain: args.domain,
834
+ limit: args.limit,
835
+ minPosition: args['min-position'],
836
+ maxPosition: args['max-position'],
837
+ locationCode: args['location-code'],
838
+ order: args.order,
839
+ }), args._, 1)(),
840
+ }),
841
+ },
842
+ });
843
+ const audit = defineCommand({
844
+ meta: { name: 'audit', description: 'Read stored Site audit reports' },
845
+ subCommands: {
846
+ 'content-decay': defineCommand({
847
+ meta: { name: 'content-decay', description: 'List Pages losing Search Console clicks' },
848
+ run: ({ args }) => capture(execution, () => auditContentDecay(runtime, globals), args._, 0)(),
849
+ }),
850
+ 'duplicates': defineCommand({
851
+ meta: { name: 'duplicates', description: 'List duplicate Page clusters' },
852
+ run: ({ args }) => capture(execution, () => auditDuplicateClusters(runtime, globals), args._, 0)(),
853
+ }),
854
+ 'link-opportunities': defineCommand({
855
+ meta: { name: 'link-opportunities', description: 'List internal link opportunities' },
856
+ run: ({ args }) => capture(execution, () => auditLinkOpportunities(runtime, globals), args._, 0)(),
857
+ }),
858
+ },
859
+ });
860
+ const content = defineCommand({
861
+ meta: { name: 'content', description: 'Read and create Content Briefs' },
862
+ subCommands: {
863
+ briefs: defineCommand({
864
+ meta: { name: 'briefs', description: 'Manage Content Briefs' },
865
+ subCommands: {
866
+ list: defineCommand({
867
+ meta: { name: 'list', description: 'List Content Briefs' },
868
+ args: {
869
+ status: {
870
+ type: 'enum',
871
+ options: ['queued', 'researching', 'ready', 'written', 'published', 'stale', 'failed', 'archived'],
872
+ description: 'Filter by status',
873
+ },
874
+ limit: { type: 'string', description: 'Maximum Content Briefs, 1 to 100' },
875
+ offset: { type: 'string', description: 'Pagination offset' },
876
+ },
877
+ run: ({ args }) => capture(execution, () => contentBriefList(runtime, globals, {
878
+ status: args.status,
879
+ limit: args.limit,
880
+ offset: args.offset,
881
+ }), args._, 0)(),
882
+ }),
883
+ show: defineCommand({
884
+ meta: { name: 'show', description: 'Show one Content Brief' },
885
+ args: { briefId: positional('brief-id', 'Content Brief ID') },
886
+ run: ({ args }) => capture(execution, () => contentBriefShow(runtime, globals, args.briefId), args._, 1)(),
887
+ }),
888
+ create: defineCommand({
889
+ meta: { name: 'create', description: 'Create a Content Brief' },
890
+ args: {
891
+ 'keyword': positional('keyword', 'Target keyword'),
892
+ 'target-page': { type: 'string', description: 'Absolute target Page URL' },
893
+ },
894
+ run: ({ args }) => capture(execution, () => contentBriefCreate(runtime, globals, {
895
+ keyword: args.keyword,
896
+ targetPage: args['target-page'],
897
+ }), args._, 1)(),
898
+ }),
899
+ },
900
+ }),
901
+ },
902
+ });
554
903
  const search = defineCommand({
555
904
  meta: { name: 'search', description: 'Read Search Console state' },
556
905
  subCommands: {
557
906
  status: defineCommand({
558
- meta: { name: 'status', description: 'Read Search Console status' },
907
+ meta: { name: 'status', description: 'Read the stored Search Console connection' },
559
908
  run: ({ args }) => capture(execution, () => searchStatus(runtime, globals), args._, 0)(),
560
909
  }),
910
+ analytics: defineCommand({
911
+ meta: { name: 'analytics', description: 'List Search Console query or Page rows' },
912
+ args: {
913
+ 'view': positional('pages-or-keywords', 'pages or keywords'),
914
+ 'period': { type: 'enum', options: ['7d', '28d', '3m', '6m', '12m'], description: 'Search Console period' },
915
+ 'limit': { type: 'string', description: 'Maximum rows, 1 to 100' },
916
+ 'page': { type: 'string', description: 'Result page number' },
917
+ 'search': { type: 'string', description: 'Filter rows by text' },
918
+ 'sort': { type: 'enum', options: ['clicks', 'impressions', 'ctr', 'position'], description: 'Sort field' },
919
+ 'sort-dir': { type: 'enum', options: ['asc', 'desc'], description: 'Sort direction' },
920
+ },
921
+ run: ({ args }) => capture(execution, () => searchAnalytics(runtime, globals, {
922
+ view: args.view,
923
+ period: args.period,
924
+ limit: args.limit,
925
+ page: args.page,
926
+ search: args.search,
927
+ sort: args.sort,
928
+ sortDir: args['sort-dir'],
929
+ }), args._, 1)(),
930
+ }),
561
931
  },
562
932
  });
563
933
  return defineCommand({
@@ -613,13 +983,16 @@ export function createRootCommand(runtime, globals, execution) {
613
983
  run: ({ args }) => capture(execution, () => usage(runtime, globals, args.group), args._, 0)(),
614
984
  }),
615
985
  actions,
986
+ audit,
616
987
  backlinks,
988
+ content,
617
989
  mentions,
618
990
  page,
619
991
  performance: defineCommand({
620
992
  meta: { name: 'performance', description: 'Read the stored Site performance overview' },
621
993
  run: ({ args }) => capture(execution, () => performance(runtime, globals), args._, 0)(),
622
994
  }),
995
+ research,
623
996
  search,
624
997
  },
625
998
  });
package/dist/failures.js CHANGED
@@ -89,9 +89,14 @@ export function fromSdkFailure(error) {
89
89
  message: [
90
90
  `${error.code}: ${error.message}`,
91
91
  ...metadataLines(error),
92
- error.code === 'unauthorized' || error.code === 'auth_expired'
92
+ // `unauthorized` is the caller's own credential. `auth_expired` is the
93
+ // Site's Search Console connection, which no token change repairs.
94
+ error.code === 'unauthorized'
93
95
  ? 'Replace NUXTSEO_TOKEN, or run `nuxtseo login` when using a stored credential.'
94
96
  : undefined,
97
+ error.code === 'auth_expired'
98
+ ? 'Open site settings in the dashboard, then Search Console, to reconnect.'
99
+ : undefined,
95
100
  ].filter((line) => line !== undefined).join('\n'),
96
101
  protocolResponse: error.response,
97
102
  },
package/dist/parse.d.ts CHANGED
@@ -21,3 +21,8 @@ export declare function parseInteger(input: string | undefined, options: {
21
21
  maximum?: number;
22
22
  defaultValue: number;
23
23
  }): CliResult<number>;
24
+ export declare function parseChoice<const TChoice extends string>(input: string | undefined, options: {
25
+ name: string;
26
+ choices: readonly TChoice[];
27
+ defaultValue?: TChoice;
28
+ }): CliResult<TChoice>;
package/dist/parse.js CHANGED
@@ -124,3 +124,10 @@ export function parseInteger(input, options) {
124
124
  }
125
125
  return ok(parsed);
126
126
  }
127
+ export function parseChoice(input, options) {
128
+ if (input === undefined && options.defaultValue !== undefined)
129
+ return ok(options.defaultValue);
130
+ if (input !== undefined && options.choices.includes(input))
131
+ return ok(input);
132
+ return fail(EXIT_CODE.invalidInput, `${options.name} must be one of: ${options.choices.join(', ')}.`);
133
+ }
package/dist/render.d.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  import type { AccountUsageResponse, SitesListResponse } from '@nuxtseo/protocol/v1';
2
2
  import type { ActionList, ActionResolve, ActionShow } from '@nuxtseo/protocol/v1/actions';
3
+ import type { AuditContentDecay, AuditDuplicateClusters, AuditLinkOpportunities } from '@nuxtseo/protocol/v1/audit';
3
4
  import type { Mentions, RecoverableBacklinks } from '@nuxtseo/protocol/v1/backlinks';
4
- import type { SearchStatusData } from '@nuxtseo/protocol/v1/gsc';
5
+ import type { ContentBrief, ContentBriefCreateData, ContentBriefListData } from '@nuxtseo/protocol/v1/content';
6
+ import type { SearchAnalyticsData, SearchStatusData } from '@nuxtseo/protocol/v1/gsc';
5
7
  import type { PageInspect, PageScan } from '@nuxtseo/protocol/v1/pages';
6
8
  import type { SitePerformanceOverview } from '@nuxtseo/protocol/v1/performance';
9
+ import type { KeywordResearchData, RankingsResearchData, SerpResearchData, StoredResearchOverview } from '@nuxtseo/protocol/v1/research';
7
10
  export declare function renderSites(response: SitesListResponse, selectedSiteId?: string): string;
8
11
  export declare function renderUsage(response: AccountUsageResponse): string;
9
12
  export declare function renderActions(data: ActionList): string;
@@ -13,5 +16,16 @@ export declare function renderPageInspection(data: PageInspect): string;
13
16
  export declare function renderPageScan(data: PageScan): string;
14
17
  export declare function renderPerformance(data: SitePerformanceOverview): string;
15
18
  export declare function renderSearchStatus(data: SearchStatusData): string;
19
+ export declare function renderSearchAnalytics(data: SearchAnalyticsData): string;
20
+ export declare function renderResearchOverview(data: StoredResearchOverview): string;
21
+ export declare function renderKeywordResearch(data: KeywordResearchData): string;
22
+ export declare function renderSerpResearch(data: SerpResearchData): string;
23
+ export declare function renderRankingsResearch(data: RankingsResearchData): string;
24
+ export declare function renderContentBriefs(data: ContentBriefListData): string;
25
+ export declare function renderContentBrief(data: ContentBrief): string;
26
+ export declare function renderContentBriefCreated(data: ContentBriefCreateData): string;
27
+ export declare function renderContentDecay(data: AuditContentDecay): string;
28
+ export declare function renderDuplicateClusters(data: AuditDuplicateClusters): string;
29
+ export declare function renderLinkOpportunities(data: AuditLinkOpportunities): string;
16
30
  export declare function renderRecoverableBacklinks(data: RecoverableBacklinks): string;
17
31
  export declare function renderMentions(data: Mentions): string;
package/dist/render.js CHANGED
@@ -107,16 +107,130 @@ export function renderPerformance(data) {
107
107
  ]);
108
108
  }
109
109
  export function renderSearchStatus(data) {
110
+ if (data._tag === 'Connected')
111
+ return fields([['Site', data.site], ['Search Console', 'connected']]);
112
+ if (data._tag === 'ActionRequired') {
113
+ const next = data.reason === 'credential_inactive'
114
+ ? 'Reconnect Search Console in Site settings.'
115
+ : 'Connect Search Console in Site settings.';
116
+ return `${fields([['Site', data.site], ['Search Console', 'action required']])}\n${next}`;
117
+ }
118
+ return `${fields([['Site', data.site], ['Search Console', 'disconnected']])}\nConnect Search Console in Site settings.`;
119
+ }
120
+ export function renderSearchAnalytics(data) {
121
+ if (data.type === 'pages') {
122
+ if (data.rows.length === 0)
123
+ return 'No Search Console Page rows match these filters.';
124
+ return [
125
+ `Search Console Pages (${data.rows.length} of ${data.total})`,
126
+ ...data.rows.map(row => `${row.clicks} clicks ${row.impressions} impressions position ${row.pos.toFixed(1)}\n ${row.url}`),
127
+ ].join('\n');
128
+ }
129
+ if (data.type === 'keywords') {
130
+ if (data.rows.length === 0)
131
+ return 'No Search Console query rows match these filters.';
132
+ return [
133
+ `Search Console queries (${data.rows.length} of ${data.total})`,
134
+ ...data.rows.map(row => `${row.clicks} clicks ${row.impressions} impressions position ${row.pos.toFixed(1)}\n ${row.keyword}`),
135
+ ].join('\n');
136
+ }
137
+ return fields([
138
+ ['View', data.type],
139
+ ['Period', data.period],
140
+ ['Rows', 'rows' in data ? data.rows.length : data.type === 'timeseries' ? data.daily.length : 0],
141
+ ]);
142
+ }
143
+ export function renderResearchOverview(data) {
144
+ const subject = data.subject
145
+ ? fields([
146
+ ['Domain', data.subject.domain],
147
+ ['Organic traffic', data.subject.metrics.organicTraffic],
148
+ ['Keywords', data.subject.metrics.totalKeywords],
149
+ ['Traffic value', data.subject.metrics.trafficValue],
150
+ ])
151
+ : 'No stored Site research.';
152
+ const competitors = data.competitors.length === 0
153
+ ? 'No stored competitors.'
154
+ : data.competitors.map(row => `${row.domain} ${row.metrics.organicTraffic ?? 'unknown'} traffic ${row.quickWins.length} quick wins`).join('\n');
155
+ return [subject, `Competitors: ${data.summary.competitorCount}`, competitors].join('\n');
156
+ }
157
+ export function renderKeywordResearch(data) {
158
+ if (data.keywords.length === 0)
159
+ return data.message ?? `No keyword ideas found for ${data.topic}.`;
160
+ return [
161
+ `Keyword ideas for ${data.topic} (${data.keywords.length} of ${data.totalFound})${data.evidence._tag === 'cache' ? ' cached' : ''}`,
162
+ ...data.keywords.map(row => `${row.volume ?? 'unknown'} volume ${row.difficulty ?? 'unknown'} difficulty ${row.intent ?? 'unknown intent'}\n ${row.keyword}`),
163
+ ...(data.tip ? [`Tip: ${data.tip}`] : []),
164
+ ].join('\n');
165
+ }
166
+ export function renderSerpResearch(data) {
167
+ if (data.results.length === 0)
168
+ return `No organic SERP results found for ${data.keyword}.`;
169
+ return [
170
+ `SERP for ${data.keyword} ${data.fetchedAt}${data.cached ? ' cached' : ''}`,
171
+ data.serpFeatures.length ? `Features: ${data.serpFeatures.join(', ')}` : 'Features: none',
172
+ ...data.results.map(row => `${row.position}. ${row.title}\n ${row.domain}\n ${row.url}`),
173
+ ].join('\n');
174
+ }
175
+ export function renderRankingsResearch(data) {
176
+ if (data.keywords.length === 0)
177
+ return data.message ?? `No rankings found for ${data.domain}.`;
178
+ return [
179
+ `${data.domain} rankings (${data.keywords.length} of ${data.totalFound})${data.cached ? ' cached' : ''}`,
180
+ ...data.keywords.map(row => `${row.position}. ${row.keyword} ${row.volume} volume ${row.traffic} traffic\n ${row.url}`),
181
+ ].join('\n');
182
+ }
183
+ export function renderContentBriefs(data) {
184
+ if (data.briefs.length === 0)
185
+ return 'No Content Briefs match these filters.';
186
+ return [
187
+ `Content Briefs (${data.briefs.length} of ${data.page.total})`,
188
+ ...data.briefs.map(brief => `${brief.id} ${brief.status}\n ${brief.keyword}`),
189
+ ].join('\n');
190
+ }
191
+ export function renderContentBrief(data) {
110
192
  return fields([
111
- ['Site', data.site],
112
- ['Connected', data.connected],
113
- ['Sync status', data.syncStatus],
114
- ['Progress', `${data.progress.percent}%`],
115
- ['Last sync', data.lastSyncAt],
116
- ['Data available', data.hasData],
117
- ['Coverage', `${data.daysSynced}/${data.daysAvailable} days`],
193
+ ['Content Brief', data.id],
194
+ ['Keyword', data.keyword],
195
+ ['Status', data.status],
196
+ ['Generated', data.generatedAt],
197
+ ['Updated', data.updatedAt],
198
+ ['Error', data.error],
118
199
  ]);
119
200
  }
201
+ export function renderContentBriefCreated(data) {
202
+ return `${data.created ? 'Created' : 'Found existing'} Content Brief.\n${renderContentBrief(data.brief)}`;
203
+ }
204
+ export function renderContentDecay(data) {
205
+ if (!data.connected)
206
+ return 'Connect Search Console to find content decay.';
207
+ if (data.rows.length === 0)
208
+ return 'No decaying Pages found.';
209
+ return [
210
+ `Content decay (${data.rows.length} of ${data.total}) ${data.clicksLost} clicks lost`,
211
+ ...data.rows.map(row => `${row.clicksDelta} clicks ${row.clicksChangePct.toFixed(1)}%\n ${row.url}`),
212
+ ].join('\n');
213
+ }
214
+ export function renderDuplicateClusters(data) {
215
+ if (data.clusters.length === 0)
216
+ return 'No duplicate clusters found.';
217
+ return [
218
+ `Duplicate clusters (${data.clusters.length} of ${data.total}) ${data.pagesAffected} Pages`,
219
+ ...data.clusters.map(cluster => `${cluster.size} Pages ${cluster.pathPattern}\n Keep: ${cluster.keepPath ?? 'not selected'}`),
220
+ ].join('\n');
221
+ }
222
+ export function renderLinkOpportunities(data) {
223
+ if (!data.connected)
224
+ return 'Connect Search Console to find link opportunities.';
225
+ if (data.textUnavailable)
226
+ return 'Page text is unavailable. Run a Site scan first.';
227
+ if (data.rows.length === 0)
228
+ return 'No link opportunities found.';
229
+ return [
230
+ `Link opportunities (${data.rows.length} of ${data.total})`,
231
+ ...data.rows.map(row => `${row.phrase}\n From: ${row.sourceUrl}\n To: ${row.targetUrl}`),
232
+ ].join('\n');
233
+ }
120
234
  export function renderRecoverableBacklinks(data) {
121
235
  if (data.items.length === 0)
122
236
  return 'No recoverable Backlinks. Every stored inbound link resolves.';
package/dist/runtime.d.ts CHANGED
@@ -10,6 +10,7 @@ export interface CliRuntime {
10
10
  interactive: boolean;
11
11
  signal: AbortSignal;
12
12
  requestSignal: AbortSignal;
13
+ requestTimeoutMs?: number;
13
14
  readStdin: () => Promise<string>;
14
15
  }
15
16
  export declare function writeOutput(runtime: CliRuntime, text: string): void;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nuxtseo/cli",
3
3
  "type": "module",
4
- "version": "0.1.0",
4
+ "version": "0.1.1",
5
5
  "description": "Command line interface for the NuxtSEO public API.",
6
6
  "license": "MIT",
7
7
  "homepage": "https://nuxtseo.com/pro",
@@ -31,7 +31,7 @@
31
31
  "node": ">=22"
32
32
  },
33
33
  "peerDependencies": {
34
- "@nuxtseo/sdk": "^0.1.0"
34
+ "@nuxtseo/sdk": "^0.1.1"
35
35
  },
36
36
  "dependencies": {
37
37
  "@clack/prompts": "^1.7.0",
@@ -44,10 +44,10 @@
44
44
  "devDependencies": {
45
45
  "@arethetypeswrong/cli": "^0.18.5",
46
46
  "@types/node": "^26.2.0",
47
- "publint": "^0.3.23",
47
+ "publint": "^0.3.24",
48
48
  "typescript": "npm:typescript-native-bridge@6.0.3-bridge.10.tsgo.7.0.2",
49
- "@nuxtseo/protocol": "0.1.0",
50
- "@nuxtseo/sdk": "0.1.0"
49
+ "@nuxtseo/sdk": "0.1.1",
50
+ "@nuxtseo/protocol": "0.1.1"
51
51
  },
52
52
  "publishConfig": {
53
53
  "access": "public"
@@ -1,35 +1,31 @@
1
1
  ---
2
2
  name: nuxtseo-cli
3
- description: Drive the `nuxtseo` CLI (@nuxtseo/cli) to read and act on the NuxtSEO Pro data a Site already stores - next actions, page observations, Lighthouse and Core Web Vitals, Search Console sync state, and account usage. Use this whenever the user mentions NuxtSEO, `nuxtseo`, "what should I fix on my site", next actions, page scans, or asks you to check or resolve stored SEO issues, even when they do not name the CLI. Also use it before writing any script or CI step that shells out to `nuxtseo`. The CLI does NOT do research work; keyword ideas, SERP analysis, Search Console query and page data, rank tracking, competitors, and content briefs live in the `nuxt-seo-pro` MCP server instead, and this skill says which tool to reach for.
3
+ description: Drive the `nuxtseo` CLI for Site triage, Search Console rows, keyword and competitor research, SERP analysis, rankings, link opportunities, Content Briefs, content decay, duplicate clusters, scans, and issue resolution. Use whenever the user mentions NuxtSEO, the `nuxtseo` command, Site SEO work, or NuxtSEO automation.
4
4
  ---
5
5
 
6
6
  # NuxtSEO CLI
7
7
 
8
8
  `nuxtseo` reads a Site through the NuxtSEO public API. Every command goes
9
- through `@nuxtseo/sdk`. The CLI never calls MCP, private routes, or providers,
10
- so it has no hidden fallback: what it prints is what the API returned.
9
+ through `@nuxtseo/sdk`. The CLI never calls MCP or private routes. Live
10
+ research reaches providers only through a declared public API operation.
11
11
 
12
12
  Use it to answer "what is wrong with this site and what did I break", then fix
13
13
  the code in the repo you are working in.
14
14
 
15
- ## What this CLI does not cover
15
+ ## Data boundaries
16
16
 
17
- The CLI reads what the product already stored for a Site. It runs no research.
17
+ Most commands read stored Site evidence. Three commands can start live research:
18
18
 
19
- | The user wants | Reach for |
20
- | --- | --- |
21
- | Keyword ideas, volume, difficulty, SERP analysis | MCP `keyword_research`, `domain_info` |
22
- | Search Console queries, pages, striking distance | MCP `gsc_query` |
23
- | Rank history for tracked keywords | MCP `rank_tracker` |
24
- | Competitors, mentions, backlinks, link opportunities | MCP `competitors`, `mentions`, `backlinks`, `link_opportunities` |
25
- | Content briefs, content decay, duplicate clusters | MCP `content_briefs`, `content_decay`, `duplicate_clusters` |
19
+ - `research keywords`
20
+ - `research serp`
21
+ - `research rankings`
26
22
 
27
- Those tools come from the `nuxt-seo-pro` MCP server, which the user connects
28
- separately. If the MCP server is not connected and the job is research shaped,
29
- say so and stop. Do not approximate research output from CLI data.
23
+ These commands use the Team research allowance when the server misses its
24
+ cache. Keyword JSON reports cache use in `evidence`. SERP and ranking JSON
25
+ report `cached`.
30
26
 
31
- `nuxtseo search status` reports only whether Search Console is connected and how
32
- far the sync got. It returns no query rows.
27
+ `search status` reads stored connection state. It never waits for Google.
28
+ `search analytics` reads Search Console rows through the public API.
33
29
 
34
30
  ## Get the binary
35
31
 
@@ -109,13 +105,26 @@ handling failures, read [CLI protocol](references/protocol.md).
109
105
  | `sites list` | Every accessible Site | Source of Site IDs |
110
106
  | `sites use <site-id>` | – | Persists a default Site for the human, not for you |
111
107
  | `usage` | Plan and meters | `--group integrations\|compute\|capacity` |
112
- | `actions list` | Server ranked next actions | `--limit 1..25` (default 10), `--offset` |
113
- | `actions show <action-id>` | One action plus evidence | `--limit 1..100` (default 50), `--group-id`, `--cursor` |
114
- | `actions resolve <action-id>` | – | Mutation. Claims the action and starts server verification |
108
+ | `actions list` | Server-ranked issues and opportunities | `--limit 1..25` (default 10), `--offset` |
109
+ | `actions show <action-id>` | One issue or opportunity plus evidence | `--limit 1..100` (default 50), `--group-id`, `--cursor` |
110
+ | `actions resolve <action-id>` | – | Mutation. Claims the issue or opportunity and starts server verification |
111
+ | `backlinks recoverable` | Stored recoverable backlinks | `--limit 1..200`, `--offset` |
112
+ | `mentions list` | Stored mentions | `--limit 1..200` |
115
113
  | `page inspect <url>` | Stored observations, Lighthouse, keywords | `--limit 1..200`, `--offset`, `--include-resolved` |
116
114
  | `page scan <url>` | – | Mutation. Starts mobile and desktop scans |
117
115
  | `performance` | Site performance overview | Medians for perf, a11y, SEO, LCP, TBT, CLS |
118
- | `search status` | Search Console connection and sync progress | Check this before trusting search data |
116
+ | `search status` | Stored Search Console connection | Provider free |
117
+ | `search analytics <pages\|keywords>` | Search Console Page or query rows | `--period`, `--limit`, `--page`, `--search` |
118
+ | `research overview` | Stored Site and competitor research | Metrics, history, gaps, and quick wins |
119
+ | `research keywords <topic>` | Live keyword ideas | Volume, difficulty, intent, and cost data |
120
+ | `research serp <keyword>` | Live SERP snapshot | Results, features, and fetch time |
121
+ | `research rankings <domain>` | Live domain rankings | Current keywords and domain metrics |
122
+ | `audit link-opportunities` | Stored internal link opportunities | Includes crawl coverage |
123
+ | `audit content-decay` | Stored decaying Pages | Includes Search Console loss evidence |
124
+ | `audit duplicates` | Stored duplicate clusters | Includes members and keep candidate |
125
+ | `content briefs list` | Content Brief summaries | `--status`, `--limit`, `--offset` |
126
+ | `content briefs show <brief-id>` | One Content Brief | Includes its grounded payload |
127
+ | `content briefs create <keyword>` | – | Mutation. `--target-page` is optional |
119
128
  | `config` | Local config path, API host, selected Site | Local only |
120
129
 
121
130
  `page inspect` and `page scan` take an absolute URL, for example
@@ -152,11 +161,7 @@ command exits `5` with `stale_evidence`; re-run step 2 and decide again.
152
161
  - Ask before you mutate. `actions resolve` and `page scan` consume quota and
153
162
  change server state. `--yes` is consent you are borrowing from the user, so
154
163
  get it first unless the user already asked for that exact action.
155
- - Do not loop over pages or URLs unattended. Each call is a real API request
156
- against a metered plan. Check `nuxtseo usage --json` if you plan a batch.
157
- - Report failures as they are. The CLI has no cache and no fallback path, so a
158
- failure means the data is genuinely unavailable, not that another route may
159
- work.
160
- - Do not treat an empty result as a clean bill of health. `search status` may
161
- report an unfinished sync, and a Page with no stored observations may simply
162
- never have been crawled.
164
+ - Do not loop over Pages, keywords, or domains unattended. Check `usage` first.
165
+ - Live research can consume allowance. A cached result does not consume a unit.
166
+ - Report failures as they are. The CLI has no MCP or private-route fallback.
167
+ - Do not treat an empty result as clean. The Site may have incomplete evidence.