@nuxtseo/cli 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/skill.js CHANGED
@@ -1,8 +1,25 @@
1
- import { cp, mkdir, stat } from 'node:fs/promises';
1
+ import { copyFile, mkdir, readdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import { dirname, join, relative } from 'pathe';
4
4
  import { EXIT_CODE, fail, ok } from './failures.js';
5
+ import { VERSION } from './version.js';
5
6
  export const SKILL_NAME = 'nuxtseo-cli';
7
+ /** The stamp an install leaves beside the skill so a later run can read its version. */
8
+ export const SKILL_VERSION_FILENAME = '.skill-version.json';
9
+ /** The skill entry file, which also carries the version in its frontmatter. */
10
+ export const SKILL_MARKDOWN_FILENAME = 'SKILL.md';
11
+ const SKILL_CHECK_FILENAME = 'skill-check.json';
12
+ /**
13
+ * How long one answer stays authoritative, matching the npm update check.
14
+ *
15
+ * Declared here rather than imported so `update-check.ts` can depend on this
16
+ * module and the two never form an import cycle.
17
+ */
18
+ export const SKILL_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
19
+ /** The same escape hatch the npm update check honours. */
20
+ function checksDisabled(env) {
21
+ return env?.NUXTSEO_NO_UPDATE_CHECK === '1' || env?.NUXTSEO_NO_UPDATE_CHECK === 'true';
22
+ }
6
23
  /**
7
24
  * Directories inside the packaged skill that exist for this repository and not
8
25
  * for the person installing it. `npm` keeps them out of the tarball through the
@@ -26,6 +43,314 @@ export function skillSourceDirectory(moduleUrl = import.meta.url) {
26
43
  export function skillDestination(homeDirectory, agent) {
27
44
  return join(homeDirectory, AGENT_DIRECTORIES[agent], 'skills', SKILL_NAME);
28
45
  }
46
+ /**
47
+ * The install target, followed through a symlink.
48
+ *
49
+ * People keep their skills in one directory and link each agent's skill folder
50
+ * at it. `fs.cp` lstats the target, sees a link rather than a directory, and
51
+ * refuses with `ERR_FS_CP_DIR_TO_NON_DIR` before it copies anything. That
52
+ * reported as a permission problem on a directory the user owned and could
53
+ * write. Follow the link first so the copy writes where the person pointed it.
54
+ */
55
+ export async function resolveSkillTarget(destination) {
56
+ return await realpath(destination).catch(() => {
57
+ // Nothing installed yet, so there is no link to follow. Create the literal path.
58
+ return destination;
59
+ });
60
+ }
61
+ /** An errno or Node error as a person can act on it: code, call, and path. */
62
+ export function describeIoCause(cause) {
63
+ if (!cause || typeof cause !== 'object')
64
+ return String(cause);
65
+ const error = cause;
66
+ const parts = [
67
+ typeof error.code === 'string' ? error.code : undefined,
68
+ typeof error.syscall === 'string' ? `during ${error.syscall}` : undefined,
69
+ typeof error.path === 'string' ? `on ${error.path}` : undefined,
70
+ typeof error.dest === 'string' ? `to ${error.dest}` : undefined,
71
+ ].filter((part) => part !== undefined);
72
+ if (parts.length > 0)
73
+ return parts.join(' ');
74
+ return typeof error.message === 'string' ? error.message : String(cause);
75
+ }
76
+ async function collectSkillFiles(root, directory = root) {
77
+ const entries = await readdir(directory, { withFileTypes: true });
78
+ const collected = [];
79
+ for (const entry of entries) {
80
+ const source = join(directory, entry.name);
81
+ const path = relative(root, source);
82
+ const [segment] = path.split('/');
83
+ if (segment && SKILL_LOCAL_ONLY.has(segment))
84
+ continue;
85
+ if (entry.isDirectory())
86
+ collected.push(...await collectSkillFiles(root, source));
87
+ else if (entry.isFile())
88
+ collected.push({ path, source });
89
+ }
90
+ return collected;
91
+ }
92
+ /**
93
+ * Write the running version into the skill frontmatter.
94
+ *
95
+ * An agent reads `SKILL.md` and nothing else, so the file has to say which
96
+ * release it came from. Without it a skill from an older CLI hides every
97
+ * command added since, and the agent cannot tell.
98
+ */
99
+ export function stampSkillVersion(markdown, version) {
100
+ const stamp = `version: ${version}`;
101
+ const lines = markdown.split('\n');
102
+ if (lines[0]?.trim() !== '---')
103
+ return `---\n${stamp}\n---\n\n${markdown}`;
104
+ const closing = lines.findIndex((line, index) => index > 0 && line.trim() === '---');
105
+ if (closing === -1)
106
+ return `---\n${stamp}\n---\n\n${markdown}`;
107
+ const block = lines.slice(1, closing).filter(line => !/^version\s*:/.test(line));
108
+ return [lines[0], ...block, stamp, ...lines.slice(closing)].join('\n');
109
+ }
110
+ /** The version recorded in a `SKILL.md` frontmatter, when it carries one. */
111
+ export function frontmatterVersion(markdown) {
112
+ const lines = markdown.split('\n');
113
+ if (lines[0]?.trim() !== '---')
114
+ return null;
115
+ const closing = lines.findIndex((line, index) => index > 0 && line.trim() === '---');
116
+ if (closing === -1)
117
+ return null;
118
+ for (const line of lines.slice(1, closing)) {
119
+ const match = /^version\s*:\s*(\S+)\s*$/.exec(line);
120
+ if (match)
121
+ return match[1];
122
+ }
123
+ return null;
124
+ }
125
+ function parseVersionStamp(content) {
126
+ let value;
127
+ try {
128
+ value = JSON.parse(content);
129
+ }
130
+ catch {
131
+ // A corrupt stamp is disposable state; the frontmatter answers instead.
132
+ return null;
133
+ }
134
+ if (typeof value !== 'object' || value === null)
135
+ return null;
136
+ const version = value.version;
137
+ return typeof version === 'string' && version ? version : null;
138
+ }
139
+ /**
140
+ * The file manifest a previous install recorded in its stamp.
141
+ *
142
+ * Pruning may only ever touch paths this tool wrote itself. A stamp without a
143
+ * readable manifest (a hand copy, an older release, a mangled file) therefore
144
+ * parses as `null`, and `pruneRemoved` deletes nothing rather than guessing.
145
+ */
146
+ function parseInstallManifest(content) {
147
+ let value;
148
+ try {
149
+ value = JSON.parse(content);
150
+ }
151
+ catch {
152
+ // Same deal as a corrupt version stamp: never prune on a guess.
153
+ return null;
154
+ }
155
+ if (typeof value !== 'object' || value === null)
156
+ return null;
157
+ const files = value.files;
158
+ if (!Array.isArray(files))
159
+ return null;
160
+ const manifest = new Set();
161
+ for (const file of files) {
162
+ if (typeof file !== 'string' || !file)
163
+ return null;
164
+ manifest.add(file);
165
+ }
166
+ return manifest;
167
+ }
168
+ async function readInstallManifest(destination) {
169
+ const content = await readFile(join(destination, SKILL_VERSION_FILENAME), 'utf8').catch(() => {
170
+ // No stamp: nothing was ever recorded, so nothing may be pruned.
171
+ return null;
172
+ });
173
+ return content === null ? null : parseInstallManifest(content);
174
+ }
175
+ /**
176
+ * Which version of the skill sits in a destination directory.
177
+ *
178
+ * Reads the stamp first, then the frontmatter, so a skill installed by hand or
179
+ * by an older release still reports something the caller can compare.
180
+ */
181
+ export async function readInstalledSkill(destination) {
182
+ const stamp = await readFile(join(destination, SKILL_VERSION_FILENAME), 'utf8').catch(() => {
183
+ // No stamp means an older install or a hand copy. Fall through to the frontmatter.
184
+ return null;
185
+ });
186
+ const stamped = stamp === null ? null : parseVersionStamp(stamp);
187
+ if (stamped !== null)
188
+ return { _tag: 'SkillVersion', version: stamped };
189
+ const markdown = await readFile(join(destination, SKILL_MARKDOWN_FILENAME), 'utf8').catch(() => {
190
+ // No entry file means no skill is installed for this agent.
191
+ return null;
192
+ });
193
+ if (markdown === null)
194
+ return { _tag: 'SkillMissing' };
195
+ const declared = frontmatterVersion(markdown);
196
+ return declared === null ? { _tag: 'SkillUnversioned' } : { _tag: 'SkillVersion', version: declared };
197
+ }
198
+ /**
199
+ * A stale installed skill, or `null` when nothing needs saying.
200
+ *
201
+ * An absent skill is not stale: the person may not use one. Any other
202
+ * difference from the running binary is, in both directions, because the two
203
+ * describe different command sets.
204
+ */
205
+ export function skillNoticeFor(installed, current, agent) {
206
+ if (installed._tag === 'SkillMissing')
207
+ return null;
208
+ if (installed._tag === 'SkillUnversioned')
209
+ return { agent, installed: null, current };
210
+ return installed.version === current ? null : { agent, installed: installed.version, current };
211
+ }
212
+ export function skillNoticeLine(notice) {
213
+ const installed = notice.installed === null ? 'skill version unknown' : `skill ${notice.installed} installed`;
214
+ return `${installed}, CLI ${notice.current}. Refresh with: nuxtseo skill install --agent ${notice.agent}`;
215
+ }
216
+ export function parseSkillCheckCache(content) {
217
+ let value;
218
+ try {
219
+ value = JSON.parse(content);
220
+ }
221
+ catch {
222
+ // A corrupt cache file is disposable state, not an error: recheck instead.
223
+ return null;
224
+ }
225
+ if (typeof value !== 'object' || value === null)
226
+ return null;
227
+ const record = value;
228
+ if (typeof record.lastCheckedAt !== 'string' || typeof record.cliVersion !== 'string')
229
+ return null;
230
+ if (typeof record.installed !== 'object' || record.installed === null)
231
+ return null;
232
+ const installed = {};
233
+ for (const agent of SKILL_AGENTS) {
234
+ const entry = record.installed[agent];
235
+ if (typeof entry === 'string' || entry === null)
236
+ installed[agent] = entry;
237
+ }
238
+ return { lastCheckedAt: record.lastCheckedAt, cliVersion: record.cliVersion, installed };
239
+ }
240
+ /**
241
+ * Whether the filesystem has to be read again.
242
+ *
243
+ * A cache from a different binary is never reusable: the binary version is half
244
+ * of the comparison, so an upgrade must warn on the very next run.
245
+ */
246
+ export function skillCheckDue(cache, now, current) {
247
+ if (!cache || cache.cliVersion !== current)
248
+ return true;
249
+ const checkedAt = Date.parse(cache.lastCheckedAt);
250
+ return Number.isNaN(checkedAt) || now.getTime() - checkedAt >= SKILL_CHECK_INTERVAL_MS;
251
+ }
252
+ function skillCheckFile(paths) {
253
+ return join(paths.directory, SKILL_CHECK_FILENAME);
254
+ }
255
+ async function readSkillCheckCache(paths) {
256
+ const content = await readFile(skillCheckFile(paths), 'utf8').catch(() => {
257
+ // A missing cache file is the common first-run case.
258
+ return null;
259
+ });
260
+ return typeof content === 'string' ? parseSkillCheckCache(content) : null;
261
+ }
262
+ async function writeSkillCheckCache(paths, cache) {
263
+ // Persisting the cache is best effort. A failed write only costs one extra
264
+ // directory read on the next run, so the failure is ignorable by design.
265
+ await mkdir(paths.directory, { recursive: true, mode: 0o700 }).catch(() => {
266
+ // See the comment above: the cache is disposable state.
267
+ return undefined;
268
+ });
269
+ await writeFile(skillCheckFile(paths), `${JSON.stringify(cache, null, 2)}\n`, {
270
+ encoding: 'utf8',
271
+ mode: 0o600,
272
+ }).catch(() => {
273
+ // See the comment above: the cache is disposable state.
274
+ return undefined;
275
+ });
276
+ }
277
+ function noticeFromCache(cache, current) {
278
+ for (const agent of SKILL_AGENTS) {
279
+ const version = cache.installed[agent];
280
+ if (version === undefined)
281
+ continue;
282
+ const installed = version === null
283
+ ? { _tag: 'SkillUnversioned' }
284
+ : { _tag: 'SkillVersion', version };
285
+ const notice = skillNoticeFor(installed, current, agent);
286
+ if (notice)
287
+ return notice;
288
+ }
289
+ return null;
290
+ }
291
+ /**
292
+ * One line's worth of "your installed skill is not this binary".
293
+ *
294
+ * Costs one cached read per day, and nothing at all when the caller disabled
295
+ * update checks.
296
+ */
297
+ export async function checkSkillVersion(options) {
298
+ if (checksDisabled(options.env))
299
+ return null;
300
+ const now = options.now?.() ?? new Date();
301
+ const cache = await readSkillCheckCache(options.paths);
302
+ if (cache && !skillCheckDue(cache, now, VERSION))
303
+ return noticeFromCache(cache, VERSION);
304
+ const homeDirectory = options.homeDirectory ?? dirname(options.paths.directory);
305
+ const installed = {};
306
+ let notice = null;
307
+ for (const agent of SKILL_AGENTS) {
308
+ const destination = await resolveSkillTarget(skillDestination(homeDirectory, agent));
309
+ const state = await readInstalledSkill(destination);
310
+ if (state._tag === 'SkillMissing')
311
+ continue;
312
+ installed[agent] = state._tag === 'SkillVersion' ? state.version : null;
313
+ notice ??= skillNoticeFor(state, VERSION, agent);
314
+ }
315
+ await writeSkillCheckCache(options.paths, {
316
+ lastCheckedAt: now.toISOString(),
317
+ cliVersion: VERSION,
318
+ installed,
319
+ });
320
+ return notice;
321
+ }
322
+ function sourceFailure(source, cause) {
323
+ return fail(EXIT_CODE.infrastructure, `skill_source_unreadable: Could not read the packaged skill at ${source}.\nCause: ${describeIoCause(cause)}.\nReinstall @nuxtseo/cli, then run the command again.`, cause);
324
+ }
325
+ function destinationFailure(destination, cause) {
326
+ return fail(EXIT_CODE.infrastructure, `skill_install_failed: Could not write the skill to ${destination}.\nCause: ${describeIoCause(cause)}.\nRead the cause above, repair that path, then run the command again.`, cause);
327
+ }
328
+ /**
329
+ * Remove installed files the previous install recorded and this release no
330
+ * longer ships.
331
+ *
332
+ * A plain recursive copy only adds. A reference file dropped from the package
333
+ * stays in the install forever and keeps answering an agent that asks for it.
334
+ * The prune set is the previous stamp's manifest, nothing more: a file the
335
+ * install never wrote (or cannot prove it wrote) belongs to the person, and
336
+ * deleting it would destroy data this package never shipped.
337
+ */
338
+ async function pruneRemoved(destination, keep, recorded) {
339
+ if (!recorded)
340
+ return 0;
341
+ const existing = await collectSkillFiles(destination).catch(() => {
342
+ // A fresh install has nothing to prune.
343
+ return [];
344
+ });
345
+ let pruned = 0;
346
+ for (const entry of existing) {
347
+ if (keep.has(entry.path) || !recorded.has(entry.path))
348
+ continue;
349
+ await rm(entry.source, { force: true });
350
+ pruned += 1;
351
+ }
352
+ return pruned;
353
+ }
29
354
  export async function installSkill(options) {
30
355
  const source = options.sourceDirectory ?? skillSourceDirectory();
31
356
  const readable = await stat(source).catch(() => {
@@ -36,21 +361,53 @@ export async function installSkill(options) {
36
361
  if (!readable?.isDirectory()) {
37
362
  return fail(EXIT_CODE.infrastructure, `skill_source_missing: The packaged skill is missing at ${source}.\nReinstall @nuxtseo/cli, then run the command again.`);
38
363
  }
364
+ const files = await collectSkillFiles(source).catch((cause) => cause);
365
+ if (!Array.isArray(files))
366
+ return sourceFailure(source, files);
39
367
  const destination = options.target
40
368
  ? join(options.target, SKILL_NAME)
41
369
  : skillDestination(options.homeDirectory, options.agent);
42
- const written = await mkdir(dirname(destination), { recursive: true })
43
- .then(() => cp(source, destination, {
44
- recursive: true,
45
- force: true,
46
- filter: (entry) => {
47
- const [segment] = relative(source, entry).split('/');
48
- return !segment || !SKILL_LOCAL_ONLY.has(segment);
49
- },
50
- }))
51
- .then(() => ok(undefined))
52
- .catch(cause => fail(EXIT_CODE.infrastructure, `skill_install_failed: Could not write the skill to ${destination}.\nCheck the directory permissions, then run the command again.`, cause));
53
- if (written._tag === 'Err')
54
- return written;
55
- return ok({ agent: options.agent, source, destination });
370
+ const resolvedDestination = await resolveSkillTarget(destination);
371
+ // Read before the stamp is overwritten: the previous install's manifest is
372
+ // the only proof of what a prune may touch.
373
+ const previous = await readInstallManifest(resolvedDestination);
374
+ const keep = new Set([SKILL_VERSION_FILENAME]);
375
+ const write = async () => {
376
+ await mkdir(resolvedDestination, { recursive: true });
377
+ for (const file of files) {
378
+ const target = join(resolvedDestination, file.path);
379
+ await mkdir(dirname(target), { recursive: true });
380
+ if (file.path === SKILL_MARKDOWN_FILENAME)
381
+ await writeFile(target, stampSkillVersion(await readFile(file.source, 'utf8'), VERSION), 'utf8');
382
+ else
383
+ await copyFile(file.source, target);
384
+ keep.add(file.path);
385
+ }
386
+ await writeFile(join(resolvedDestination, SKILL_VERSION_FILENAME), `${JSON.stringify({ version: VERSION, skill: SKILL_NAME, installedAt: new Date().toISOString(), files: [...keep].sort() }, null, 2)}\n`, 'utf8');
387
+ return await pruneRemoved(resolvedDestination, keep, previous);
388
+ };
389
+ const result = await write().then(pruned => ok(pruned)).catch(cause => destinationFailure(resolvedDestination, cause));
390
+ if (result._tag === 'Err')
391
+ return result;
392
+ if (options.paths) {
393
+ // The freshly installed version is now the answer, so clear the stale
394
+ // notice the same run rather than a day later. Merge into the recorded
395
+ // map: another agent's entry still has to answer for its own skill, and
396
+ // replacing the map would hide its stale warning until the cache expires.
397
+ const cached = await readSkillCheckCache(options.paths);
398
+ await writeSkillCheckCache(options.paths, {
399
+ lastCheckedAt: new Date().toISOString(),
400
+ cliVersion: VERSION,
401
+ installed: { ...cached?.installed, [options.agent]: VERSION },
402
+ });
403
+ }
404
+ return ok({
405
+ agent: options.agent,
406
+ source,
407
+ destination,
408
+ resolvedDestination,
409
+ version: VERSION,
410
+ written: files.length,
411
+ pruned: result.value,
412
+ });
56
413
  }
@@ -15,6 +15,11 @@ export interface UpdateCheckOptions {
15
15
  env?: Readonly<Record<string, string | undefined>>;
16
16
  fetch?: RegistryLatestFetch;
17
17
  now?: () => Date;
18
+ /**
19
+ * Receives one diagnostic line per stale local artefact, before this check
20
+ * resolves. The caller owns the stream, so this module never writes to one.
21
+ */
22
+ onDiagnostic?: (line: string) => void;
18
23
  }
19
24
  export declare function isNewerVersion(candidate: string, current: string): boolean;
20
25
  export declare function updateNoticeFor(latest: string | null, current: string): UpdateNotice | null;
@@ -1,4 +1,5 @@
1
1
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { checkSkillVersion, skillNoticeLine } from './skill.js';
2
3
  import { VERSION } from './version.js';
3
4
  /** How long a registry answer stays authoritative, matching npm's update cache. */
4
5
  export const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
@@ -85,13 +86,28 @@ export async function fetchRegistryLatest(url, signal) {
85
86
  const version = body.version;
86
87
  return typeof version === 'string' && version ? version : null;
87
88
  }
89
+ async function emitSkillNotice(check, onDiagnostic) {
90
+ const notice = await check.catch(() => {
91
+ // A local read failure must never break a command. The run continues silently.
92
+ return null;
93
+ });
94
+ if (notice && onDiagnostic)
95
+ onDiagnostic(skillNoticeLine(notice));
96
+ }
88
97
  export async function checkForUpdate(options) {
89
98
  if (updateCheckDisabled(options.env))
90
99
  return null;
100
+ // The installed agent skill is the other half of "is this install current".
101
+ // It runs beside the registry fetch so neither waits for the other.
102
+ const skillCheck = options.onDiagnostic
103
+ ? checkSkillVersion({ paths: options.paths, env: options.env, now: options.now })
104
+ : Promise.resolve(null);
91
105
  const now = options.now?.() ?? new Date();
92
106
  const cache = await readUpdateCheckCache(options.paths);
93
- if (!updateCheckDue(cache, now))
107
+ if (!updateCheckDue(cache, now)) {
108
+ await emitSkillNotice(skillCheck, options.onDiagnostic);
94
109
  return updateNoticeFor(cache.latest, VERSION);
110
+ }
95
111
  const fetchLatest = options.fetch ?? fetchRegistryLatest;
96
112
  const latest = await fetchLatest(REGISTRY_LATEST_URL, AbortSignal.timeout(FETCH_TIMEOUT_MS)).catch(() => {
97
113
  // Registry reachability is best effort. The run continues without a hint.
@@ -99,6 +115,7 @@ export async function checkForUpdate(options) {
99
115
  });
100
116
  if (latest !== null)
101
117
  await writeUpdateCheckCache(options.paths, { lastCheckedAt: now.toISOString(), latest });
118
+ await emitSkillNotice(skillCheck, options.onDiagnostic);
102
119
  // A stale cached answer still beats no answer while the registry is unreachable.
103
120
  return updateNoticeFor(latest ?? cache?.latest ?? null, VERSION);
104
121
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nuxtseo/cli",
3
3
  "type": "module",
4
- "version": "0.2.1",
4
+ "version": "0.3.0",
5
5
  "description": "Command line interface for the NuxtSEO public API.",
6
6
  "license": "MIT",
7
7
  "homepage": "https://nuxtseo.com/pro",
@@ -33,8 +33,8 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "@clack/prompts": "^1.8.0",
36
- "@nuxtseo/protocol": "^0.2.1",
37
- "@nuxtseo/sdk": "^0.2.1",
36
+ "@nuxtseo/protocol": "^0.3.0",
37
+ "@nuxtseo/sdk": "^0.3.0",
38
38
  "citty": "^0.2.2",
39
39
  "pathe": "^2.0.3"
40
40
  },
@@ -82,6 +82,20 @@ nuxtseo actions list --site site_123 --json
82
82
  also disables prompts. Diagnostics, warnings, spinners, and failure messages go
83
83
  to stderr, so stdout stays parseable.
84
84
 
85
+ Read `data.scopes` from `whoami` before you plan a loop, not when a step fails.
86
+ A Team token carries only the scopes its role allows. A `viewer` token carries
87
+ every `*:read` scope plus `feedback:write`, and nothing else. That token reads
88
+ the whole Site and cannot write, so steps 5, 6, and 7 of the triage loop below
89
+ all exit `4`: `page scan` needs `page:write`, `actions resolve` and `actions
90
+ dismiss` need `actions:write`, and the `annotations` writes need
91
+ `timeline:write`. `content briefs create` needs `content:write`, and the
92
+ `sitemaps` writes need `sites:write`. Live research reads need only
93
+ `research:read`, so a `viewer` token can still run them.
94
+
95
+ If a write scope is missing, do steps 1 to 4, then hand the user the exact
96
+ commands for the rest. Do not spend calls discovering the block one step at a
97
+ time.
98
+
85
99
  `--site` matters for a second reason. An explicit Site ID goes straight to the
86
100
  operation and skips the Sites read. A token whose role cannot list Sites still
87
101
  works. If more than one Site is accessible and `--site` is absent, the CLI
@@ -129,6 +143,7 @@ your progress:
129
143
 
130
144
  ```
131
145
  Triage progress:
146
+ - [ ] 0. version check: this skill matches the binary
132
147
  - [ ] 1. status: read the verdict and the Next Action
133
148
  - [ ] 2. actions list: pick a ranked action, check its freshness
134
149
  - [ ] 3. actions show: read the evidence behind it
@@ -138,12 +153,44 @@ Triage progress:
138
153
  - [ ] 7. annotations create: mark the day the fix shipped
139
154
  ```
140
155
 
156
+ **Step 0. Check this skill matches the binary.**
157
+
158
+ ```sh
159
+ nuxtseo --version --json
160
+ ```
161
+
162
+ This writes a `CliVersion` value, so read `version` at the top level. There is
163
+ no `data` wrapper. This skill records CLI 0.2.1: the `skill install` command
164
+ copies this file out of that package release, so a matching binary means a
165
+ matching skill. Compare the binary version with 0.2.1. If the binary differs,
166
+ it may hold commands this skill never names. Refresh the skill first:
167
+
168
+ ```sh
169
+ nuxtseo skill install --agent claude
170
+ ```
171
+
172
+ Then re-read this file. The refreshed copy records its own CLI version, so the
173
+ check passes on the re-read. A stale skill fails silently: it lists fewer
174
+ commands, so a missing command reads as a missing feature.
175
+
141
176
  **Step 1. Read the Site verdict.**
142
177
 
143
178
  ```sh
144
179
  nuxtseo status --site <site-id> --json
145
180
  ```
146
181
 
182
+ If you need the whole Site rather than the next action, start with `pull`
183
+ instead:
184
+
185
+ ```sh
186
+ nuxtseo pull --site <site-id> --json > site.ndjson
187
+ ```
188
+
189
+ One invocation performs every stored-evidence read and writes one envelope per
190
+ line. It spends nothing, and it never mutates. Read
191
+ [references/commands.md](references/commands.md) for its filters and its
192
+ failure behaviour. Then return to step 2 with the rows it gave you.
193
+
147
194
  This is the cheapest orientation: the verdict, the single ranked Next Action,
148
195
  and what changed recently. Read `dataQuality.status` before quoting it.
149
196
  `degraded` means the assessment ran with proofs missing, so the verdict is real
@@ -320,10 +367,20 @@ Fixed, replied, closed, and ignored reports leave the health check list. Reopeni
320
367
  or `no-provider` tag means nothing was spent. SERP and ranking JSON report
321
368
  `cached`. `backlinks recoverable` and `mentions list` read retained rows and
322
369
  spend nothing.
370
+ - **Prefer one `pull` over a shell loop.** `pull` performs every spend-free
371
+ Site read in one invocation. Never write a shell loop over `nuxtseo` calls.
323
372
  - **Never loop unattended.** Check `usage` before any run over Pages, keywords,
324
373
  or domains. Use `--all` rather than your own offset loop. It writes one
325
374
  envelope per line and stops at 50 requests with exit `9`. Treat exit `9` as a
326
375
  stop, then resume with the argument stderr names.
376
+ - **Read `data.message` before you read a list.** `research keywords` refuses a
377
+ seed over three words and still exits `0`, with `keywords: []` and
378
+ `evidence._tag: "no-provider"`. The reason sits in `data.message`, and the
379
+ repair sits in `data.tip`. A `no-provider` tag can mean a refused argument.
380
+ Never read it as proof that the topic has no demand. `research rankings`,
381
+ `research domain-traffic`, `research domain-availability`, `page issues`,
382
+ `search cohorts`, and `vitals findings` carry the same `data.message`. The
383
+ full list is in references/protocol.md.
327
384
  - **Report the result as the CLI gave it.** Report failures as they are; there
328
385
  is no MCP or private-route fallback. Never treat an empty result as clean:
329
386
  `page inspect` names the kind of empty through `observations.coverage`,