@nuxtseo/cli 0.2.0 → 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/ansi.d.ts +8 -0
- package/dist/ansi.js +35 -0
- package/dist/cli.js +23 -14
- package/dist/commands.d.ts +7 -0
- package/dist/commands.js +133 -11
- package/dist/failures.d.ts +46 -6
- package/dist/failures.js +101 -10
- package/dist/pairing.js +2 -4
- package/dist/pull.d.ts +81 -0
- package/dist/pull.js +362 -0
- package/dist/render.d.ts +5 -5
- package/dist/render.js +1 -1
- package/dist/runtime.d.ts +5 -0
- package/dist/runtime.js +7 -2
- package/dist/skill.d.ts +99 -0
- package/dist/skill.js +372 -15
- package/dist/update-check.d.ts +5 -0
- package/dist/update-check.js +18 -1
- package/package.json +6 -6
- package/skills/nuxtseo-cli/SKILL.md +94 -1
- package/skills/nuxtseo-cli/references/commands.md +104 -12
- package/skills/nuxtseo-cli/references/protocol.md +72 -1
package/dist/skill.js
CHANGED
|
@@ -1,8 +1,25 @@
|
|
|
1
|
-
import {
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
}
|
package/dist/update-check.d.ts
CHANGED
|
@@ -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;
|
package/dist/update-check.js
CHANGED
|
@@ -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.
|
|
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",
|
|
@@ -32,8 +32,9 @@
|
|
|
32
32
|
"node": ">=22"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@clack/prompts": "^1.
|
|
36
|
-
"@nuxtseo/
|
|
35
|
+
"@clack/prompts": "^1.8.0",
|
|
36
|
+
"@nuxtseo/protocol": "^0.3.0",
|
|
37
|
+
"@nuxtseo/sdk": "^0.3.0",
|
|
37
38
|
"citty": "^0.2.2",
|
|
38
39
|
"pathe": "^2.0.3"
|
|
39
40
|
},
|
|
@@ -42,10 +43,9 @@
|
|
|
42
43
|
},
|
|
43
44
|
"devDependencies": {
|
|
44
45
|
"@arethetypeswrong/cli": "^0.18.5",
|
|
45
|
-
"@
|
|
46
|
-
"@types/node": "^26.4.0",
|
|
46
|
+
"@types/node": "^26.5.1",
|
|
47
47
|
"publint": "^0.3.24",
|
|
48
|
-
"typescript": "npm:typescript-native-bridge@6.0.3-bridge.
|
|
48
|
+
"typescript": "npm:typescript-native-bridge@6.0.3-bridge.16.tsgo.7.0.2"
|
|
49
49
|
},
|
|
50
50
|
"publishConfig": {
|
|
51
51
|
"access": "public"
|
|
@@ -71,7 +71,8 @@ accessible Site with its ID.
|
|
|
71
71
|
|
|
72
72
|
## How to call it as an agent
|
|
73
73
|
|
|
74
|
-
Always pass `--json
|
|
74
|
+
Always pass `--json`. Pass `--site <site-id>` for Site commands.
|
|
75
|
+
`feedback submit` needs no Site.
|
|
75
76
|
|
|
76
77
|
```sh
|
|
77
78
|
nuxtseo actions list --site site_123 --json
|
|
@@ -81,6 +82,20 @@ nuxtseo actions list --site site_123 --json
|
|
|
81
82
|
also disables prompts. Diagnostics, warnings, spinners, and failure messages go
|
|
82
83
|
to stderr, so stdout stays parseable.
|
|
83
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
|
+
|
|
84
99
|
`--site` matters for a second reason. An explicit Site ID goes straight to the
|
|
85
100
|
operation and skips the Sites read. A token whose role cannot list Sites still
|
|
86
101
|
works. If more than one Site is accessible and `--site` is absent, the CLI
|
|
@@ -128,6 +143,7 @@ your progress:
|
|
|
128
143
|
|
|
129
144
|
```
|
|
130
145
|
Triage progress:
|
|
146
|
+
- [ ] 0. version check: this skill matches the binary
|
|
131
147
|
- [ ] 1. status: read the verdict and the Next Action
|
|
132
148
|
- [ ] 2. actions list: pick a ranked action, check its freshness
|
|
133
149
|
- [ ] 3. actions show: read the evidence behind it
|
|
@@ -137,12 +153,44 @@ Triage progress:
|
|
|
137
153
|
- [ ] 7. annotations create: mark the day the fix shipped
|
|
138
154
|
```
|
|
139
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
|
+
|
|
140
176
|
**Step 1. Read the Site verdict.**
|
|
141
177
|
|
|
142
178
|
```sh
|
|
143
179
|
nuxtseo status --site <site-id> --json
|
|
144
180
|
```
|
|
145
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
|
+
|
|
146
194
|
This is the cheapest orientation: the verdict, the single ranked Next Action,
|
|
147
195
|
and what changed recently. Read `dataQuality.status` before quoting it.
|
|
148
196
|
`degraded` means the assessment ran with proofs missing, so the verdict is real
|
|
@@ -267,6 +315,41 @@ merge them.
|
|
|
267
315
|
`analysed: false` means there is no completed crawl, or no Google index state
|
|
268
316
|
to join against. That is a coverage gap, not healthy indexing. Say which.
|
|
269
317
|
|
|
318
|
+
## Report CLI feedback
|
|
319
|
+
|
|
320
|
+
If CLI behavior blocks or slows your work, submit feedback once per distinct problem during the task.
|
|
321
|
+
Report confusing output, missing context, incorrect help, unexpected failures, and useful workarounds.
|
|
322
|
+
Continue the user's task after reporting.
|
|
323
|
+
|
|
324
|
+
```sh
|
|
325
|
+
nuxtseo feedback submit --command "search cohorts" --agent "Codex" \
|
|
326
|
+
--comment "Agent report by Codex. Expected a reason for missing data. Received an empty list. Used search status instead." \
|
|
327
|
+
--yes --json
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
Use your actual agent name. Start the comment with an agent disclosure.
|
|
331
|
+
Include reproduction steps, expected behavior, actual behavior, and any workaround.
|
|
332
|
+
Use `--intent improvement` for suggestions. The default is `bug`.
|
|
333
|
+
If the affected response has a request ID, pass it with `--request-id`.
|
|
334
|
+
The CLI adds its version automatically.
|
|
335
|
+
|
|
336
|
+
Send sanitized details only. Remove tokens, cookies, personal data, private URLs, and customer content.
|
|
337
|
+
Use command names and placeholder arguments. Never paste raw logs or full response bodies.
|
|
338
|
+
|
|
339
|
+
Self-reporting sanitized CLI feedback is authorized by this skill. No separate confirmation is needed.
|
|
340
|
+
This permission covers feedback only. Respect any user instruction that forbids reporting.
|
|
341
|
+
The endpoint requires a working credential with `feedback:write`, available to every Team role.
|
|
342
|
+
It uses no paid limit and allows ten reports per credential each hour.
|
|
343
|
+
|
|
344
|
+
Success returns `data.id` and `data.status: "new"`. Keep the ID with your task notes.
|
|
345
|
+
If reporting fails, mention the failure and continue. Never retry uncertain submissions or report failures recursively.
|
|
346
|
+
If authentication is broken, describe the feedback to the user. Do not invent another endpoint.
|
|
347
|
+
|
|
348
|
+
Reports use the existing feedback queue through `POST /api/v1/account/feedback`.
|
|
349
|
+
Health checks show unresolved reports across all dates.
|
|
350
|
+
Operators use **Pro feedback** in admin to record a fix reference and set `fixed` after verification.
|
|
351
|
+
Fixed, replied, closed, and ignored reports leave the health check list. Reopening restores them.
|
|
352
|
+
|
|
270
353
|
## Guardrails
|
|
271
354
|
|
|
272
355
|
- **Get consent before a mutation.** `actions resolve`, `actions dismiss`,
|
|
@@ -284,10 +367,20 @@ to join against. That is a coverage gap, not healthy indexing. Say which.
|
|
|
284
367
|
or `no-provider` tag means nothing was spent. SERP and ranking JSON report
|
|
285
368
|
`cached`. `backlinks recoverable` and `mentions list` read retained rows and
|
|
286
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.
|
|
287
372
|
- **Never loop unattended.** Check `usage` before any run over Pages, keywords,
|
|
288
373
|
or domains. Use `--all` rather than your own offset loop. It writes one
|
|
289
374
|
envelope per line and stops at 50 requests with exit `9`. Treat exit `9` as a
|
|
290
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.
|
|
291
384
|
- **Report the result as the CLI gave it.** Report failures as they are; there
|
|
292
385
|
is no MCP or private-route fallback. Never treat an empty result as clean:
|
|
293
386
|
`page inspect` names the kind of empty through `observations.coverage`,
|