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