@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/dist/render.js CHANGED
@@ -34,6 +34,13 @@ export function renderUsage(response) {
34
34
  });
35
35
  return [`Plan: ${response.data.plan}`, ...meters].join('\n');
36
36
  }
37
+ function evidenceAgeLine(freshness) {
38
+ if (freshness.verdict === 'aged')
39
+ return ` evidence observed ${freshness.ageHours}h ago (${freshness.confidence})`;
40
+ if (freshness.verdict === 'unknown')
41
+ return ' evidence age unknown';
42
+ return '';
43
+ }
37
44
  export function renderActions(data) {
38
45
  if (data.actions.length === 0)
39
46
  return 'No next actions.';
@@ -42,7 +49,8 @@ export function renderActions(data) {
42
49
  ...data.actions.map(action => [
43
50
  `${action.id} ${action.diagnosis}`,
44
51
  ` ${action.status}, effort ${action.effort}, ${action.affectedPages ?? 'unknown'} affected pages`,
45
- ].join('\n')),
52
+ evidenceAgeLine(action.evidence.freshness),
53
+ ].filter(Boolean).join('\n')),
46
54
  data.page.hasMore ? `More actions available after offset ${data.page.offset + data.page.limit}.` : '',
47
55
  ].filter(Boolean).join('\n');
48
56
  }
@@ -76,9 +84,12 @@ export function renderPageInspection(data) {
76
84
  ['SEO', data.performance.lighthouse.seo],
77
85
  ])
78
86
  : 'No Lighthouse scan.';
87
+ const empty = data.observations.coverage === 'issues-open'
88
+ ? ''
89
+ : ` (${data.observations.coverage})`;
79
90
  return [
80
91
  data.page.url,
81
- `Observations: ${data.observations.total}`,
92
+ `Observations: ${data.observations.total}${empty}`,
82
93
  ...observations,
83
94
  lighthouse,
84
95
  `Tracked keywords: ${data.search.keywords.length}`,
@@ -107,16 +118,130 @@ export function renderPerformance(data) {
107
118
  ]);
108
119
  }
109
120
  export function renderSearchStatus(data) {
121
+ if (data._tag === 'Connected')
122
+ return fields([['Site', data.site], ['Search Console', 'connected']]);
123
+ if (data._tag === 'ActionRequired') {
124
+ const next = data.reason === 'credential_inactive'
125
+ ? 'Reconnect Search Console in Site settings.'
126
+ : 'Connect Search Console in Site settings.';
127
+ return `${fields([['Site', data.site], ['Search Console', 'action required']])}\n${next}`;
128
+ }
129
+ return `${fields([['Site', data.site], ['Search Console', 'disconnected']])}\nConnect Search Console in Site settings.`;
130
+ }
131
+ export function renderSearchAnalytics(data) {
132
+ if (data.type === 'pages') {
133
+ if (data.rows.length === 0)
134
+ return 'No Search Console Page rows match these filters.';
135
+ return [
136
+ `Search Console Pages (${data.rows.length} of ${data.total})`,
137
+ ...data.rows.map(row => `${row.clicks} clicks ${row.impressions} impressions position ${row.pos.toFixed(1)}\n ${row.url}`),
138
+ ].join('\n');
139
+ }
140
+ if (data.type === 'keywords') {
141
+ if (data.rows.length === 0)
142
+ return 'No Search Console query rows match these filters.';
143
+ return [
144
+ `Search Console queries (${data.rows.length} of ${data.total})`,
145
+ ...data.rows.map(row => `${row.clicks} clicks ${row.impressions} impressions position ${row.pos.toFixed(1)}\n ${row.keyword}`),
146
+ ].join('\n');
147
+ }
148
+ return fields([
149
+ ['View', data.type],
150
+ ['Period', data.period],
151
+ ['Rows', 'rows' in data ? data.rows.length : data.type === 'timeseries' ? data.daily.length : 0],
152
+ ]);
153
+ }
154
+ export function renderResearchOverview(data) {
155
+ const subject = data.subject
156
+ ? fields([
157
+ ['Domain', data.subject.domain],
158
+ ['Organic traffic', data.subject.metrics.organicTraffic],
159
+ ['Keywords', data.subject.metrics.totalKeywords],
160
+ ['Traffic value', data.subject.metrics.trafficValue],
161
+ ])
162
+ : 'No stored Site research.';
163
+ const competitors = data.competitors.length === 0
164
+ ? 'No stored competitors.'
165
+ : data.competitors.map(row => `${row.domain} ${row.metrics.organicTraffic ?? 'unknown'} traffic ${row.quickWins.length} quick wins`).join('\n');
166
+ return [subject, `Competitors: ${data.summary.competitorCount}`, competitors].join('\n');
167
+ }
168
+ export function renderKeywordResearch(data) {
169
+ if (data.keywords.length === 0)
170
+ return data.message ?? `No keyword ideas found for ${data.topic}.`;
171
+ return [
172
+ `Keyword ideas for ${data.topic} (${data.keywords.length} of ${data.totalFound})${data.evidence._tag === 'cache' ? ' cached' : ''}`,
173
+ ...data.keywords.map(row => `${row.volume ?? 'unknown'} volume ${row.difficulty ?? 'unknown'} difficulty ${row.intent ?? 'unknown intent'}\n ${row.keyword}`),
174
+ ...(data.tip ? [`Tip: ${data.tip}`] : []),
175
+ ].join('\n');
176
+ }
177
+ export function renderSerpResearch(data) {
178
+ if (data.results.length === 0)
179
+ return `No organic SERP results found for ${data.keyword}.`;
180
+ return [
181
+ `SERP for ${data.keyword} ${data.fetchedAt}${data.cached ? ' cached' : ''}`,
182
+ data.serpFeatures.length ? `Features: ${data.serpFeatures.join(', ')}` : 'Features: none',
183
+ ...data.results.map(row => `${row.position}. ${row.title}\n ${row.domain}\n ${row.url}`),
184
+ ].join('\n');
185
+ }
186
+ export function renderRankingsResearch(data) {
187
+ if (data.keywords.length === 0)
188
+ return data.message ?? `No rankings found for ${data.domain}.`;
189
+ return [
190
+ `${data.domain} rankings (${data.keywords.length} of ${data.totalFound})${data.cached ? ' cached' : ''}`,
191
+ ...data.keywords.map(row => `${row.position}. ${row.keyword} ${row.volume} volume ${row.traffic} traffic\n ${row.url}`),
192
+ ].join('\n');
193
+ }
194
+ export function renderContentBriefs(data) {
195
+ if (data.briefs.length === 0)
196
+ return 'No Content Briefs match these filters.';
197
+ return [
198
+ `Content Briefs (${data.briefs.length} of ${data.page.total})`,
199
+ ...data.briefs.map(brief => `${brief.id} ${brief.status}\n ${brief.keyword}`),
200
+ ].join('\n');
201
+ }
202
+ export function renderContentBrief(data) {
110
203
  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`],
204
+ ['Content Brief', data.id],
205
+ ['Keyword', data.keyword],
206
+ ['Status', data.status],
207
+ ['Generated', data.generatedAt],
208
+ ['Updated', data.updatedAt],
209
+ ['Error', data.error],
118
210
  ]);
119
211
  }
212
+ export function renderContentBriefCreated(data) {
213
+ return `${data.created ? 'Created' : 'Found existing'} Content Brief.\n${renderContentBrief(data.brief)}`;
214
+ }
215
+ export function renderContentDecay(data) {
216
+ if (!data.connected)
217
+ return 'Connect Search Console to find content decay.';
218
+ if (data.rows.length === 0)
219
+ return 'No decaying Pages found.';
220
+ return [
221
+ `Content decay (${data.rows.length} of ${data.total}) ${data.clicksLost} clicks lost`,
222
+ ...data.rows.map(row => `${row.clicksDelta} clicks ${row.clicksChangePct.toFixed(1)}%\n ${row.url}`),
223
+ ].join('\n');
224
+ }
225
+ export function renderDuplicateClusters(data) {
226
+ if (data.clusters.length === 0)
227
+ return 'No duplicate clusters found.';
228
+ return [
229
+ `Duplicate clusters (${data.clusters.length} of ${data.total}) ${data.pagesAffected} Pages`,
230
+ ...data.clusters.map(cluster => `${cluster.size} Pages ${cluster.pathPattern}\n Keep: ${cluster.keepPath ?? 'not selected'}`),
231
+ ].join('\n');
232
+ }
233
+ export function renderLinkOpportunities(data) {
234
+ if (!data.connected)
235
+ return 'Connect Search Console to find link opportunities.';
236
+ if (data.textUnavailable)
237
+ return 'Page text is unavailable. Run a Site scan first.';
238
+ if (data.rows.length === 0)
239
+ return 'No link opportunities found.';
240
+ return [
241
+ `Link opportunities (${data.rows.length} of ${data.total})`,
242
+ ...data.rows.map(row => `${row.phrase}\n From: ${row.sourceUrl}\n To: ${row.targetUrl}`),
243
+ ].join('\n');
244
+ }
120
245
  export function renderRecoverableBacklinks(data) {
121
246
  if (data.items.length === 0)
122
247
  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;
@@ -1,10 +1,12 @@
1
1
  export declare const STATE_DIRECTORY_NAME = ".nuxtseo";
2
2
  export declare const AUTH_FILENAME = "auth.json";
3
3
  export declare const CONFIG_FILENAME = "config.json";
4
+ export declare const UPDATE_CHECK_FILENAME = "update-check.json";
4
5
  export interface StatePaths {
5
6
  directory: string;
6
7
  authFile: string;
7
8
  configFile: string;
9
+ updateCheckFile: string;
8
10
  }
9
11
  export declare function createStatePaths(homeDirectory: string): StatePaths;
10
12
  export declare function defaultStatePaths(): StatePaths;
@@ -3,12 +3,14 @@ import { join } from 'pathe';
3
3
  export const STATE_DIRECTORY_NAME = '.nuxtseo';
4
4
  export const AUTH_FILENAME = 'auth.json';
5
5
  export const CONFIG_FILENAME = 'config.json';
6
+ export const UPDATE_CHECK_FILENAME = 'update-check.json';
6
7
  export function createStatePaths(homeDirectory) {
7
8
  const directory = join(homeDirectory, STATE_DIRECTORY_NAME);
8
9
  return {
9
10
  directory,
10
11
  authFile: join(directory, AUTH_FILENAME),
11
12
  configFile: join(directory, CONFIG_FILENAME),
13
+ updateCheckFile: join(directory, UPDATE_CHECK_FILENAME),
12
14
  };
13
15
  }
14
16
  export function defaultStatePaths() {
@@ -0,0 +1,25 @@
1
+ import type { StatePaths } from './state/index.js';
2
+ /** How long a registry answer stays authoritative, matching npm's update cache. */
3
+ export declare const UPDATE_CHECK_INTERVAL_MS: number;
4
+ export interface UpdateNotice {
5
+ current: string;
6
+ latest: string;
7
+ }
8
+ export interface UpdateCheckCache {
9
+ lastCheckedAt: string;
10
+ latest: string | null;
11
+ }
12
+ export type RegistryLatestFetch = (url: string, signal: AbortSignal) => Promise<string | null>;
13
+ export interface UpdateCheckOptions {
14
+ paths: StatePaths;
15
+ env?: Readonly<Record<string, string | undefined>>;
16
+ fetch?: RegistryLatestFetch;
17
+ now?: () => Date;
18
+ }
19
+ export declare function isNewerVersion(candidate: string, current: string): boolean;
20
+ export declare function updateNoticeFor(latest: string | null, current: string): UpdateNotice | null;
21
+ export declare function updateCheckDue(cache: UpdateCheckCache | null, now: Date): boolean;
22
+ export declare function parseUpdateCheckCache(content: string): UpdateCheckCache | null;
23
+ export declare function fetchRegistryLatest(url: string, signal: AbortSignal): Promise<string | null>;
24
+ export declare function checkForUpdate(options: UpdateCheckOptions): Promise<UpdateNotice | null>;
25
+ export declare function updateNoticeLine(notice: UpdateNotice): string;
@@ -0,0 +1,107 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { VERSION } from './version.js';
3
+ /** How long a registry answer stays authoritative, matching npm's update cache. */
4
+ export const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
5
+ const REGISTRY_LATEST_URL = 'https://registry.npmjs.org/@nuxtseo/cli/latest';
6
+ const FETCH_TIMEOUT_MS = 2_000;
7
+ function updateCheckDisabled(env) {
8
+ return env?.NUXTSEO_NO_UPDATE_CHECK === '1' || env?.NUXTSEO_NO_UPDATE_CHECK === 'true';
9
+ }
10
+ export function isNewerVersion(candidate, current) {
11
+ const core = (value) => {
12
+ const parts = value.trim().replace(/^v/, '').split('-')[0].split('.');
13
+ if (parts.length < 3)
14
+ return null;
15
+ const numbers = parts.slice(0, 3).map(part => Number.parseInt(part, 10));
16
+ return numbers.some(part => Number.isNaN(part)) ? null : numbers;
17
+ };
18
+ const candidateCore = core(candidate);
19
+ const currentCore = core(current);
20
+ if (!candidateCore || !currentCore)
21
+ return false;
22
+ for (let index = 0; index < 3; index++) {
23
+ if (candidateCore[index] !== currentCore[index])
24
+ return candidateCore[index] > currentCore[index];
25
+ }
26
+ // Same core: a prerelease candidate is not an update over the stable current.
27
+ return false;
28
+ }
29
+ export function updateNoticeFor(latest, current) {
30
+ return latest !== null && isNewerVersion(latest, current) ? { current, latest } : null;
31
+ }
32
+ export function updateCheckDue(cache, now) {
33
+ if (!cache)
34
+ return true;
35
+ const checkedAt = Date.parse(cache.lastCheckedAt);
36
+ return Number.isNaN(checkedAt) || now.getTime() - checkedAt >= UPDATE_CHECK_INTERVAL_MS;
37
+ }
38
+ export function parseUpdateCheckCache(content) {
39
+ let value;
40
+ try {
41
+ value = JSON.parse(content);
42
+ }
43
+ catch {
44
+ // A corrupt cache file is disposable state, not an error: refetch instead.
45
+ return null;
46
+ }
47
+ if (typeof value !== 'object' || value === null)
48
+ return null;
49
+ const record = value;
50
+ if (typeof record.lastCheckedAt !== 'string')
51
+ return null;
52
+ if (record.latest !== null && typeof record.latest !== 'string')
53
+ return null;
54
+ return { lastCheckedAt: record.lastCheckedAt, latest: record.latest };
55
+ }
56
+ async function readUpdateCheckCache(paths) {
57
+ const content = await readFile(paths.updateCheckFile, 'utf8').catch(() => {
58
+ // A missing or unreadable cache file is the common first-run case.
59
+ return null;
60
+ });
61
+ return typeof content === 'string' ? parseUpdateCheckCache(content) : null;
62
+ }
63
+ async function writeUpdateCheckCache(paths, cache) {
64
+ // Persisting the cache is best effort. A failed write only costs one extra
65
+ // registry fetch on the next run, so the failure is ignorable by design.
66
+ await mkdir(paths.directory, { recursive: true, mode: 0o700 }).catch(() => {
67
+ // See the comment above: the cache is disposable state.
68
+ return undefined;
69
+ });
70
+ await writeFile(paths.updateCheckFile, `${JSON.stringify(cache, null, 2)}\n`, {
71
+ encoding: 'utf8',
72
+ mode: 0o600,
73
+ }).catch(() => {
74
+ // See the comment above: the cache is disposable state.
75
+ return undefined;
76
+ });
77
+ }
78
+ export async function fetchRegistryLatest(url, signal) {
79
+ const response = await fetch(url, { signal, headers: { accept: 'application/json' } });
80
+ if (!response.ok)
81
+ return null;
82
+ const body = await response.json();
83
+ if (typeof body !== 'object' || body === null)
84
+ return null;
85
+ const version = body.version;
86
+ return typeof version === 'string' && version ? version : null;
87
+ }
88
+ export async function checkForUpdate(options) {
89
+ if (updateCheckDisabled(options.env))
90
+ return null;
91
+ const now = options.now?.() ?? new Date();
92
+ const cache = await readUpdateCheckCache(options.paths);
93
+ if (!updateCheckDue(cache, now))
94
+ return updateNoticeFor(cache.latest, VERSION);
95
+ const fetchLatest = options.fetch ?? fetchRegistryLatest;
96
+ const latest = await fetchLatest(REGISTRY_LATEST_URL, AbortSignal.timeout(FETCH_TIMEOUT_MS)).catch(() => {
97
+ // Registry reachability is best effort. The run continues without a hint.
98
+ return null;
99
+ });
100
+ if (latest !== null)
101
+ await writeUpdateCheckCache(options.paths, { lastCheckedAt: now.toISOString(), latest });
102
+ // A stale cached answer still beats no answer while the registry is unreachable.
103
+ return updateNoticeFor(latest ?? cache?.latest ?? null, VERSION);
104
+ }
105
+ export function updateNoticeLine(notice) {
106
+ return `${notice.latest} available, current ${notice.current}. Update with: pnpm add -g @nuxtseo/cli`;
107
+ }
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.2",
5
5
  "description": "Command line interface for the NuxtSEO public API.",
6
6
  "license": "MIT",
7
7
  "homepage": "https://nuxtseo.com/pro",
@@ -30,13 +30,11 @@
30
30
  "engines": {
31
31
  "node": ">=22"
32
32
  },
33
- "peerDependencies": {
34
- "@nuxtseo/sdk": "^0.1.0"
35
- },
36
33
  "dependencies": {
37
34
  "@clack/prompts": "^1.7.0",
38
35
  "citty": "^0.2.2",
39
- "pathe": "^2.0.3"
36
+ "pathe": "^2.0.3",
37
+ "@nuxtseo/sdk": "^0.1.2"
40
38
  },
41
39
  "optionalDependencies": {
42
40
  "@napi-rs/keyring": "^1.3.0"
@@ -44,10 +42,9 @@
44
42
  "devDependencies": {
45
43
  "@arethetypeswrong/cli": "^0.18.5",
46
44
  "@types/node": "^26.2.0",
47
- "publint": "^0.3.23",
45
+ "publint": "^0.3.24",
48
46
  "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"
47
+ "@nuxtseo/protocol": "0.1.2"
51
48
  },
52
49
  "publishConfig": {
53
50
  "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 |
115
- | `page inspect <url>` | Stored observations, Lighthouse, keywords | `--limit 1..200`, `--offset`, `--include-resolved` |
108
+ | `actions list` | Server-ranked issues and opportunities | `--limit 1..25` (default 10), `--offset`. JSON carries `evidence.freshness`; `verdict: "aged"` means the observation is over a day old, so live-check before fixing |
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` |
113
+ | `page inspect <url>` | Stored observations, Lighthouse, keywords | `--limit 1..200`, `--offset`, `--include-resolved`. JSON carries `observations.coverage`: `never-scanned` means no source recorded this Page, `scanned-clear` means recorded and all clear |
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
@@ -132,7 +141,10 @@ flag, and prefer the table above for anything it already answers.
132
141
  This is the sequence that turns CLI output into a code change:
133
142
 
134
143
  1. `nuxtseo actions list --site <id> --json` gives ranked work with an ID, a
135
- diagnosis, an effort, and an affected page count.
144
+ diagnosis, an effort, and an affected page count. Check
145
+ `evidence.freshness` per row: `verdict: "aged"` with a large `ageHours`
146
+ means the evidence is old. Live-check a cheap sample before fixing, because
147
+ the site may have moved on since the observation.
136
148
  2. `nuxtseo actions show <action-id> --site <id> --json` gives the evidence:
137
149
  which pages, which finding type, and when it was observed.
138
150
  3. Fix the cause in the repository. The evidence names URLs; map them back to
@@ -152,11 +164,9 @@ command exits `5` with `stale_evidence`; re-run step 2 and decide again.
152
164
  - Ask before you mutate. `actions resolve` and `page scan` consume quota and
153
165
  change server state. `--yes` is consent you are borrowing from the user, so
154
166
  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.
167
+ - Do not loop over Pages, keywords, or domains unattended. Check `usage` first.
168
+ - Live research can consume allowance. A cached result does not consume a unit.
169
+ - Report failures as they are. The CLI has no MCP or private-route fallback.
170
+ - Do not treat an empty result as clean. `page inspect` JSON says which empty
171
+ it is through `observations.coverage`; other commands may still have
172
+ incomplete evidence.
@@ -32,6 +32,22 @@ Local outcomes have no server body. They carry `schemaVersion: 1` and one tag:
32
32
 
33
33
  Discriminate on `_tag`. Protocol envelopes never carry one.
34
34
 
35
+ A `CliError` for exit `2` or exit `7` also carries `cliVersion`, and
36
+ `latestKnownVersion` when the CLI knows the registry holds a newer version.
37
+ Those two exits are where a stale binary looks like broken docs or a broken
38
+ server; check the versions before debugging anything else.
39
+
40
+ ## Update hints
41
+
42
+ Every run checks the npm registry for a newer CLI, cached for 24 hours. When a
43
+ newer version is known, one line goes to stderr, for example:
44
+
45
+ ```
46
+ 0.2.0 available, current 0.1.1. Update with: pnpm add -g @nuxtseo/cli
47
+ ```
48
+
49
+ Set `NUXTSEO_NO_UPDATE_CHECK=1` to disable the check, for example in CI.
50
+
35
51
  ## Paging
36
52
 
37
53
  One invocation makes one request. The CLI never auto-pages or merges responses.
@@ -51,7 +67,7 @@ Branch on the exit code, not message text.
51
67
  | `4` | Forbidden, scope, or entitlement failure | Report the plan or token blocker |
52
68
  | `5` | Conflict, stale evidence, or ambiguous Site | Re-read. Pass `--site` if ambiguous |
53
69
  | `6` | Rate limit, quota, provider outage, or timeout | Read retry metadata, then wait |
54
- | `7` | Local state, network, contract, or infrastructure failure | Report it with the request ID |
70
+ | `7` | Local state, network, contract, or infrastructure failure | Report it with the request ID. If the message names `contract_violation`, update the CLI first |
55
71
  | `8` | Resource or Site not found | Run `sites list` for a valid Site ID |
56
72
  | `127` | Shell cannot find `nuxtseo` | Install the CLI |
57
73
  | `130` | Interrupted or cancelled | Check whether a mutation ran before retrying |