@nuxtseo/cli 0.1.0 → 0.1.2

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
@@ -7,16 +7,28 @@ import { EXIT_CODE, fail, fromSdkFailure, ok, unexpectedFailure } from './failur
7
7
  import { extractGlobalOptions } from './parse.js';
8
8
  import { writeCliResponse, writeDiagnostic, writeOutput, writeProtocolResponse } from './runtime.js';
9
9
  import { getCredentialStatus, readConfig, resolveApiUrl, resolveSiteId, updateConfig } from './state/index.js';
10
+ import { checkForUpdate, updateNoticeLine } from './update-check.js';
10
11
  import { VERSION } from './version.js';
11
- function report(runtime, failure, json = false) {
12
+ function writeUpdateNotice(runtime, notice) {
13
+ if (notice)
14
+ writeDiagnostic(runtime, updateNoticeLine(notice));
15
+ }
16
+ function report(runtime, failure, json = false, notice = null) {
12
17
  if (json) {
13
18
  if (failure.protocolResponse !== undefined) {
14
19
  writeProtocolResponse(runtime, failure.protocolResponse);
15
20
  }
16
21
  else {
22
+ // Exit 2 and exit 7 are where a stale binary masquerades as broken docs
23
+ // or a broken server. Version skew is a plausible cause there, so the
24
+ // envelope names both versions and stderr carries the update command.
25
+ const carriesVersionSkew = failure.exitCode === EXIT_CODE.invalidInput || failure.exitCode === EXIT_CODE.infrastructure;
17
26
  writeCliResponse(runtime, {
18
27
  _tag: 'CliError',
19
28
  schemaVersion: 1,
29
+ ...(carriesVersionSkew
30
+ ? { cliVersion: VERSION, ...(notice ? { latestKnownVersion: notice.latest } : {}) }
31
+ : {}),
20
32
  error: {
21
33
  code: failure.code,
22
34
  exitCode: failure.exitCode,
@@ -135,6 +147,45 @@ async function promptPageUrl(runtime, action) {
135
147
  ? fail(EXIT_CODE.interrupted, `interrupted: ${action} cancelled.`)
136
148
  : ok(url);
137
149
  }
150
+ async function promptTextValue(runtime, options) {
151
+ const value = await prompts.text({
152
+ message: options.message,
153
+ placeholder: options.placeholder,
154
+ input: runtime.input,
155
+ output: runtime.error,
156
+ signal: runtime.signal,
157
+ validate: input => typeof input === 'string' && input.trim().length > 1
158
+ ? undefined
159
+ : `${options.label} is required.`,
160
+ });
161
+ return prompts.isCancel(value)
162
+ ? fail(EXIT_CODE.interrupted, `interrupted: ${options.label} entry cancelled.`)
163
+ : ok(value.trim());
164
+ }
165
+ function displayCommand(args) {
166
+ return `nuxtseo ${args.map(argument => /\s/.test(argument) ? JSON.stringify(argument) : argument).join(' ')}`;
167
+ }
168
+ function timeoutRetryFailure(failure, rawArgs, timeoutMs) {
169
+ if (failure.code !== 'request_timeout')
170
+ return failure;
171
+ const args = [];
172
+ for (let index = 0; index < rawArgs.length; index++) {
173
+ const argument = rawArgs[index];
174
+ if (argument === '--timeout-ms') {
175
+ index++;
176
+ continue;
177
+ }
178
+ if (argument.startsWith('--timeout-ms='))
179
+ continue;
180
+ args.push(argument);
181
+ }
182
+ args.push('--timeout-ms', String(Math.min(timeoutMs * 2, 300_000)));
183
+ return { ...failure, message: `${failure.message}\nRetry: ${displayCommand(args)}` };
184
+ }
185
+ async function runInteractiveCommand(args, runtime, globals) {
186
+ writeDiagnostic(runtime, `Command: ${displayCommand(args)}`);
187
+ return runExplicit(args, runtime, globals);
188
+ }
138
189
  async function interactiveBare(runtime, globals) {
139
190
  const [auth, apiUrl, config] = await Promise.all([
140
191
  getCredentialStatus({ env: runtime.env, paths: runtime.paths }),
@@ -153,49 +204,160 @@ async function interactiveBare(runtime, globals) {
153
204
  `API: ${apiUrl.value.apiUrl}`,
154
205
  `Current Site: ${config.value.siteId ?? 'none'}`,
155
206
  ].join('\n'), 'Status', { input: runtime.input, output: runtime.error });
156
- const selected = await prompts.select({
157
- message: 'What would you like to do?',
207
+ const group = await prompts.select({
208
+ message: 'Choose a task',
158
209
  options: [
159
210
  ...(!auth.value.authenticated ? [{ value: 'login', label: 'Log in', hint: 'Team API token' }] : []),
211
+ { value: 'fix', label: 'Fix my Site', hint: 'Issues, Pages, and scans' },
212
+ { value: 'performance', label: 'Understand performance', hint: 'Performance and Search Console' },
213
+ { value: 'research', label: 'Research growth', hint: 'Keywords, SERPs, and competitors' },
214
+ { value: 'content', label: 'Plan content', hint: 'Briefs, decay, and duplicates' },
215
+ { value: 'account', label: 'Account and setup', hint: config.value.siteId ?? 'no Site selected' },
216
+ { value: 'exit', label: 'Exit' },
217
+ ],
218
+ input: runtime.input,
219
+ output: runtime.error,
220
+ signal: runtime.signal,
221
+ });
222
+ if (prompts.isCancel(group) || group === 'exit')
223
+ return ok(undefined);
224
+ if (group === 'login')
225
+ return runInteractiveCommand(['login'], runtime, globals);
226
+ if (group === 'fix') {
227
+ const task = await prompts.select({
228
+ message: 'Fix my Site',
229
+ options: [
230
+ { value: 'actions', label: 'Issues and opportunities' },
231
+ { value: 'inspect', label: 'Inspect a Page' },
232
+ { value: 'scan', label: 'Scan a Page' },
233
+ { value: 'backlinks', label: 'Recover broken Backlinks' },
234
+ ],
235
+ input: runtime.input,
236
+ output: runtime.error,
237
+ signal: runtime.signal,
238
+ });
239
+ if (prompts.isCancel(task))
240
+ return ok(undefined);
241
+ if (task === 'inspect' || task === 'scan') {
242
+ const url = await promptPageUrl(runtime, task === 'inspect' ? 'Inspect' : 'Scan');
243
+ return url._tag === 'Err'
244
+ ? url
245
+ : runInteractiveCommand(['page', task, url.value], runtime, globals);
246
+ }
247
+ return runInteractiveCommand(task === 'actions' ? ['actions', 'list'] : ['backlinks', 'recoverable'], runtime, globals);
248
+ }
249
+ if (group === 'performance') {
250
+ const task = await prompts.select({
251
+ message: 'Understand performance',
252
+ options: [
253
+ { value: 'performance', label: 'Performance overview' },
254
+ { value: 'connection', label: 'Search Console connection' },
255
+ { value: 'pages', label: 'Search Console Pages' },
256
+ { value: 'keywords', label: 'Search Console queries' },
257
+ ],
258
+ input: runtime.input,
259
+ output: runtime.error,
260
+ signal: runtime.signal,
261
+ });
262
+ if (prompts.isCancel(task))
263
+ return ok(undefined);
264
+ const command = task === 'performance'
265
+ ? ['performance']
266
+ : task === 'connection'
267
+ ? ['search', 'status']
268
+ : ['search', 'analytics', task];
269
+ return runInteractiveCommand(command, runtime, globals);
270
+ }
271
+ if (group === 'research') {
272
+ const task = await prompts.select({
273
+ message: 'Research growth',
274
+ options: [
275
+ { value: 'overview', label: 'Site and competitors', hint: 'stored data' },
276
+ { value: 'keywords', label: 'Keyword ideas', hint: 'uses live research allowance' },
277
+ { value: 'serp', label: 'SERP analysis', hint: 'uses live research allowance' },
278
+ { value: 'rankings', label: 'Domain rankings', hint: 'uses live research allowance' },
279
+ { value: 'links', label: 'Link opportunities', hint: 'stored data' },
280
+ ],
281
+ input: runtime.input,
282
+ output: runtime.error,
283
+ signal: runtime.signal,
284
+ });
285
+ if (prompts.isCancel(task))
286
+ return ok(undefined);
287
+ if (task === 'overview')
288
+ return runInteractiveCommand(['research', 'overview'], runtime, globals);
289
+ if (task === 'links')
290
+ return runInteractiveCommand(['audit', 'link-opportunities'], runtime, globals);
291
+ const label = task === 'rankings' ? 'Domain' : 'Keyword';
292
+ const value = await promptTextValue(runtime, {
293
+ message: task === 'rankings' ? 'Which domain?' : 'Which keyword or topic?',
294
+ placeholder: task === 'rankings' ? 'example.com' : 'nuxt seo',
295
+ label,
296
+ });
297
+ if (value._tag === 'Err')
298
+ return value;
299
+ return runInteractiveCommand(['research', task, value.value], runtime, globals);
300
+ }
301
+ if (group === 'content') {
302
+ const task = await prompts.select({
303
+ message: 'Plan content',
304
+ options: [
305
+ { value: 'briefs', label: 'Content Briefs' },
306
+ { value: 'create', label: 'Create a Content Brief' },
307
+ { value: 'decay', label: 'Content decay' },
308
+ { value: 'duplicates', label: 'Duplicate clusters' },
309
+ ],
310
+ input: runtime.input,
311
+ output: runtime.error,
312
+ signal: runtime.signal,
313
+ });
314
+ if (prompts.isCancel(task))
315
+ return ok(undefined);
316
+ if (task === 'briefs')
317
+ return runInteractiveCommand(['content', 'briefs', 'list'], runtime, globals);
318
+ if (task === 'decay')
319
+ return runInteractiveCommand(['audit', 'content-decay'], runtime, globals);
320
+ if (task === 'duplicates')
321
+ return runInteractiveCommand(['audit', 'duplicates'], runtime, globals);
322
+ const keyword = await promptTextValue(runtime, {
323
+ message: 'Which target keyword?',
324
+ placeholder: 'nuxt seo',
325
+ label: 'Keyword',
326
+ });
327
+ return keyword._tag === 'Err'
328
+ ? keyword
329
+ : runInteractiveCommand(['content', 'briefs', 'create', keyword.value], runtime, globals);
330
+ }
331
+ const task = await prompts.select({
332
+ message: 'Account and setup',
333
+ options: [
160
334
  { 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
335
  { value: 'usage', label: 'Account usage' },
167
336
  { value: 'config', label: 'Configuration' },
168
- { value: 'exit', label: 'Exit' },
337
+ { value: 'logout', label: 'Log out' },
169
338
  ],
170
339
  input: runtime.input,
171
340
  output: runtime.error,
172
341
  signal: runtime.signal,
173
342
  });
174
- if (prompts.isCancel(selected) || selected === 'exit')
343
+ if (prompts.isCancel(task))
175
344
  return ok(undefined);
176
- if (selected === 'site')
345
+ if (task === 'site')
177
346
  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);
347
+ return runInteractiveCommand([task], runtime, globals);
193
348
  }
194
349
  export async function runCli(rawArgs, runtime) {
195
350
  const jsonRequested = rawArgs.includes('--json');
351
+ // Started before parsing so the registry round trip overlaps the command. The
352
+ // check reads a local cache and never rejects; a slow registry only delays
353
+ // this line, never the command result itself.
354
+ const updateCheck = checkForUpdate({ paths: runtime.paths, env: runtime.env });
196
355
  const parsed = extractGlobalOptions(rawArgs);
197
- if (parsed._tag === 'Err')
198
- return report(runtime, parsed.error, jsonRequested);
356
+ if (parsed._tag === 'Err') {
357
+ const notice = await updateCheck;
358
+ writeUpdateNotice(runtime, notice);
359
+ return report(runtime, parsed.error, jsonRequested, notice);
360
+ }
199
361
  const { args, options: globals } = parsed.value;
200
362
  const effectiveRuntime = {
201
363
  ...runtime,
@@ -204,23 +366,31 @@ export async function runCli(rawArgs, runtime) {
204
366
  runtime.requestSignal,
205
367
  AbortSignal.timeout(globals.timeoutMs),
206
368
  ]),
369
+ requestTimeoutMs: globals.timeoutMs,
207
370
  };
208
371
  if (args.length === 1 && (args[0] === '--version' || args[0] === '-v')) {
372
+ const notice = await updateCheck;
209
373
  if (globals.json)
210
374
  writeCliResponse(effectiveRuntime, { _tag: 'CliVersion', schemaVersion: 1, version: VERSION });
211
375
  else
212
376
  writeOutput(effectiveRuntime, VERSION);
377
+ writeUpdateNotice(effectiveRuntime, notice);
213
378
  return EXIT_CODE.success;
214
379
  }
215
380
  if (args.includes('--help') || args.includes('-h')) {
216
381
  const command = createRootCommand(effectiveRuntime, globals, { result: null });
217
382
  const validOptions = validateCommandOptions(command, args);
218
- if (validOptions._tag === 'Err')
219
- return report(effectiveRuntime, validOptions.error, globals.json);
383
+ if (validOptions._tag === 'Err') {
384
+ const notice = await updateCheck;
385
+ writeUpdateNotice(effectiveRuntime, notice);
386
+ return report(effectiveRuntime, validOptions.error, globals.json, notice);
387
+ }
220
388
  if (globals.json)
221
389
  writeCliResponse(effectiveRuntime, describeCommand(command, args, VERSION));
222
390
  else
223
391
  writeOutput(effectiveRuntime, await requestedUsage(effectiveRuntime, globals, args));
392
+ const notice = await updateCheck;
393
+ writeUpdateNotice(effectiveRuntime, notice);
224
394
  return EXIT_CODE.success;
225
395
  }
226
396
  const result = args.length === 0
@@ -228,5 +398,9 @@ export async function runCli(rawArgs, runtime) {
228
398
  ? await interactiveBare(effectiveRuntime, globals)
229
399
  : await nonInteractiveBare(effectiveRuntime, globals)
230
400
  : await runExplicit(args, effectiveRuntime, globals);
231
- return result._tag === 'Ok' ? EXIT_CODE.success : report(effectiveRuntime, result.error, globals.json);
401
+ const notice = await updateCheck;
402
+ writeUpdateNotice(effectiveRuntime, notice);
403
+ return result._tag === 'Ok'
404
+ ? EXIT_CODE.success
405
+ : report(effectiveRuntime, timeoutRetryFailure(result.error, rawArgs, globals.timeoutMs), globals.json, notice);
232
406
  }