crawlmark-mcp 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +7 -8
- package/README.md +92 -26
- package/dist/analysis/tech-issues.js +6 -10
- package/dist/cli-help.js +5 -7
- package/dist/cli.js +5 -4
- package/dist/clients.js +5 -8
- package/dist/config.js +2 -3
- package/dist/lib/clients.js +12 -6
- package/dist/lib/country.js +0 -11
- package/dist/lib/env-file.js +14 -5
- package/dist/lib/issue-snapshots.js +0 -6
- package/dist/lib/provider-data.js +2 -2
- package/dist/lib/vendor-mcp.js +155 -0
- package/dist/prompts/templates/keyword-research.md +1 -1
- package/dist/providers/ahrefs-shape.js +2 -2
- package/dist/providers/semrush-shape.js +20 -314
- package/dist/providers/semrush.js +73 -480
- package/dist/providers/seo-data.js +13 -1
- package/dist/providers/units-billing.js +1 -1
- package/dist/reports/render/sections.js +2 -1
- package/dist/reports/technical-issues.js +3 -11
- package/dist/reports/templates/run.js +8 -4
- package/dist/reports/templates/schema.js +20 -10
- package/dist/reports/templates/write.js +15 -2
- package/dist/setup/apps.js +1 -1
- package/dist/setup/menu.js +2 -1
- package/dist/setup/status-providers.js +1 -3
- package/dist/setup/status.js +16 -22
- package/dist/setup/vendor-mcp-step.js +339 -0
- package/dist/setup/wizard.js +46 -60
- package/dist/tools/competitors.js +1 -1
- package/dist/tools/raw.js +4 -5
- package/dist/tools/reports.js +6 -6
- package/package.json +1 -1
- package/dist/providers/semrush-site-audit.js +0 -175
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `vendor-mcp` step: add Semrush's and Ahrefs' own remote MCP servers to the user's AI apps, for ad-hoc questions.
|
|
3
|
+
* crawlmark never calls these servers and never writes a key for them: sign-in is OAuth in the app. Every change is
|
|
4
|
+
* previewed, asked about and backed up the same way as the crawlmark registration (src/setup/apps.ts), other servers are
|
|
5
|
+
* never touched, and an existing entry with the same URL is "already". A different entry with the same name is replaced
|
|
6
|
+
* only on an explicit yes (the default is no, so --yes never replaces one).
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { backupFile, mergeMcpServersJson, readIfExists, writeConfigWithBackup } from "../lib/claude-config.js";
|
|
10
|
+
import { claudeCodeVendorArgs, codexVendorArgs, codexVendorSnippet, cursorVendorEntry, desktopConnectorSteps, parseClaudeVendorGet, parseCodexVendorGet, planCodexVendorTomlEdit, readCodexVendorToml, VENDOR_IDS, VENDORS, } from "../lib/vendor-mcp.js";
|
|
11
|
+
import { APP_LABELS, cmdLine, detectApps, runApp } from "./apps.js";
|
|
12
|
+
import { PromptAbortedError } from "./prompter.js";
|
|
13
|
+
import { uiFor } from "./ui.js";
|
|
14
|
+
import { works } from "./wizard.js";
|
|
15
|
+
const OAUTH_NEXT = {
|
|
16
|
+
"claude-code": "In Claude Code, run /mcp and sign in to {label}.",
|
|
17
|
+
"claude-desktop": "",
|
|
18
|
+
cursor: "Restart Cursor (or reload its MCP servers), then sign in to {label} when it asks.",
|
|
19
|
+
codex: "If Codex needs it, sign in with `codex mcp login {name}`.",
|
|
20
|
+
};
|
|
21
|
+
function signInNote(app, v) {
|
|
22
|
+
return OAUTH_NEXT[app].replace("{label}", v.label).replace("{name}", v.name);
|
|
23
|
+
}
|
|
24
|
+
/** What an existing entry is, for display: its URL or command only (never headers, env or keys). */
|
|
25
|
+
function describeExisting(r) {
|
|
26
|
+
return `${r.url ? `url ${r.url}` : r.command ? `command ${r.command}` : "unknown"}${r.scope ? ` [scope: ${r.scope}]` : ""}`;
|
|
27
|
+
}
|
|
28
|
+
async function addClaudeCode(ctx, probe, v, notes) {
|
|
29
|
+
const addArgs = claudeCodeVendorArgs(v);
|
|
30
|
+
if (!probe.detected) {
|
|
31
|
+
notes.push({ kind: "warn", text: `Claude Code isn't installed (no working \`claude\` command). To add ${v.label} later: ${cmdLine("claude", addArgs)}` });
|
|
32
|
+
return "manual";
|
|
33
|
+
}
|
|
34
|
+
const existing = await ctx.exec("claude", ["mcp", "get", v.name]);
|
|
35
|
+
const add = async () => {
|
|
36
|
+
const r = await runApp(ctx, "claude", addArgs);
|
|
37
|
+
notes.push(r.code === 0 ? { kind: "success", text: `Added ${v.label} to Claude Code. ${signInNote("claude-code", v)}` } : { kind: "fail", text: `\`claude mcp add\` for ${v.label} failed (exit ${r.code}).` });
|
|
38
|
+
return r.code === 0 ? "registered" : "failed";
|
|
39
|
+
};
|
|
40
|
+
if (existing.code !== 0) {
|
|
41
|
+
ctx.out(`This adds ${v.label} to Claude Code for all your projects:`);
|
|
42
|
+
ctx.out(` ${cmdLine("claude", addArgs)}`);
|
|
43
|
+
if (!(await ctx.prompt.confirm(`Add ${v.label}'s MCP server to Claude Code?`, true))) {
|
|
44
|
+
notes.push({ kind: "info", text: `Skipped ${v.label} in Claude Code.` });
|
|
45
|
+
return "skipped";
|
|
46
|
+
}
|
|
47
|
+
if (ctx.dryRun) {
|
|
48
|
+
notes.push({ kind: "info", text: `Dry run: ${v.label} not added to Claude Code.` });
|
|
49
|
+
return "dry-run";
|
|
50
|
+
}
|
|
51
|
+
return add();
|
|
52
|
+
}
|
|
53
|
+
const current = parseClaudeVendorGet(existing.stdout);
|
|
54
|
+
if (!current) {
|
|
55
|
+
ctx.out(`Claude Code has a ${v.name} entry the wizard can't read; leaving it alone.`);
|
|
56
|
+
notes.push({ kind: "warn", text: `To replace it yourself: claude mcp remove ${v.name} -s <scope>, then ${cmdLine("claude", addArgs)}` });
|
|
57
|
+
return "manual";
|
|
58
|
+
}
|
|
59
|
+
if (current.url === v.url) {
|
|
60
|
+
notes.push({ kind: "success", text: `Claude Code: ${v.label} is already added.` });
|
|
61
|
+
return "already";
|
|
62
|
+
}
|
|
63
|
+
const scope = current.scope ?? "user";
|
|
64
|
+
ctx.out(`Claude Code already has a ${v.name} server, with a different setting:`);
|
|
65
|
+
ctx.out(` current: ${describeExisting(current)}`);
|
|
66
|
+
ctx.out(` new: url ${v.url} [scope: user]`);
|
|
67
|
+
if (!(await ctx.prompt.confirm(`Replace it (claude mcp remove ${v.name} -s ${scope}, then add)?`, false))) {
|
|
68
|
+
notes.push({ kind: "info", text: `Kept the existing ${v.name} entry in Claude Code.` });
|
|
69
|
+
return "skipped";
|
|
70
|
+
}
|
|
71
|
+
if (ctx.dryRun) {
|
|
72
|
+
notes.push({ kind: "info", text: `Dry run: Claude Code's ${v.name} entry not replaced.` });
|
|
73
|
+
return "dry-run";
|
|
74
|
+
}
|
|
75
|
+
const removed = await runApp(ctx, "claude", ["mcp", "remove", v.name, "-s", scope]);
|
|
76
|
+
if (removed.code !== 0) {
|
|
77
|
+
notes.push({ kind: "fail", text: `\`claude mcp remove ${v.name} -s ${scope}\` failed (exit ${removed.code}); nothing was added.` });
|
|
78
|
+
return "failed";
|
|
79
|
+
}
|
|
80
|
+
return add();
|
|
81
|
+
}
|
|
82
|
+
async function addCursor(ctx, v, notes) {
|
|
83
|
+
const app = APP_LABELS.cursor;
|
|
84
|
+
const path = ctx.paths.cursorConfig;
|
|
85
|
+
if (!path)
|
|
86
|
+
return "manual";
|
|
87
|
+
const entry = cursorVendorEntry(v);
|
|
88
|
+
try {
|
|
89
|
+
const merge = mergeMcpServersJson(readIfExists(path), entry, { app, name: v.name });
|
|
90
|
+
if (!merge.changed) {
|
|
91
|
+
notes.push({ kind: "success", text: `${app} already has ${v.label} (${path}).` });
|
|
92
|
+
return "already";
|
|
93
|
+
}
|
|
94
|
+
const prev = merge.previous;
|
|
95
|
+
const lines = [
|
|
96
|
+
merge.otherServers.length ? `Other servers kept unchanged: ${merge.otherServers.join(", ")}` : "No other servers in the file.",
|
|
97
|
+
...(merge.replaced ? [`Current ${v.name} entry (replaced): ${describeExisting({ url: typeof prev?.url === "string" ? prev.url : undefined, command: typeof prev?.command === "string" ? prev.command : undefined })}`] : []),
|
|
98
|
+
merge.replaced ? `New ${v.name} entry:` : `Adds this ${v.name} entry:`,
|
|
99
|
+
...JSON.stringify(entry, null, 2).split("\n").map((l) => ` ${l}`),
|
|
100
|
+
];
|
|
101
|
+
uiFor(ctx).preview(`${app} config: ${path}`, lines);
|
|
102
|
+
const question = merge.replaced ? `Replace the existing ${v.name} entry? (the current file is kept as .bak)` : "Write this change? (the current file is kept as .bak)";
|
|
103
|
+
if (!(await ctx.prompt.confirm(question, !merge.replaced))) {
|
|
104
|
+
notes.push({ kind: "info", text: `Skipped ${v.label} in ${app}.` });
|
|
105
|
+
return "skipped";
|
|
106
|
+
}
|
|
107
|
+
if (ctx.dryRun) {
|
|
108
|
+
notes.push({ kind: "info", text: `Dry run: ${app} config not written.` });
|
|
109
|
+
return "dry-run";
|
|
110
|
+
}
|
|
111
|
+
const backup = writeConfigWithBackup(path, merge.text);
|
|
112
|
+
notes.push({ kind: "success", text: `Added ${v.label} to ${app}'s config${backup ? ` (backup: ${backup})` : ""}. ${signInNote("cursor", v)}` });
|
|
113
|
+
return "registered";
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
if (err instanceof PromptAbortedError)
|
|
117
|
+
throw err;
|
|
118
|
+
notes.push({ kind: "fail", text: `Couldn't update ${app}'s config: ${err.message}` });
|
|
119
|
+
return "failed";
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** Claude Desktop's JSON config only takes local servers; remote ones are added in its Connectors settings. */
|
|
123
|
+
function addDesktop(ctx, v, notes) {
|
|
124
|
+
ctx.out(`Claude Desktop adds remote servers in its own settings, not in claude_desktop_config.json. To add ${v.label}:`);
|
|
125
|
+
for (const l of desktopConnectorSteps(v))
|
|
126
|
+
ctx.out(` ${l}`);
|
|
127
|
+
notes.push({ kind: "info", text: `Claude Desktop: add ${v.label} yourself in Settings > Connectors > Add custom connector (URL ${v.url}).` });
|
|
128
|
+
return "manual";
|
|
129
|
+
}
|
|
130
|
+
function codexRegistrationFor(ctx, v, get) {
|
|
131
|
+
if (get.code === 0) {
|
|
132
|
+
const parsed = parseCodexVendorGet(get.stdout);
|
|
133
|
+
if (parsed)
|
|
134
|
+
return { kind: "entry", ...parsed };
|
|
135
|
+
return { kind: "unreadable" };
|
|
136
|
+
}
|
|
137
|
+
// A failed get proves nothing by itself: the config file (read, never written here) decides.
|
|
138
|
+
try {
|
|
139
|
+
return readCodexVendorToml(ctx.paths.codexConfig ? readIfExists(ctx.paths.codexConfig) : undefined, v.name);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return { kind: "unreadable" };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
async function addCodexCli(ctx, v, notes) {
|
|
146
|
+
const addArgs = codexVendorArgs(v);
|
|
147
|
+
const state = codexRegistrationFor(ctx, v, await ctx.exec("codex", ["mcp", "get", v.name, "--json"]));
|
|
148
|
+
const backupConfig = () => (ctx.paths.codexConfig && existsSync(ctx.paths.codexConfig) ? backupFile(ctx.paths.codexConfig) : undefined);
|
|
149
|
+
const add = async (backup) => {
|
|
150
|
+
const b = backup ?? backupConfig();
|
|
151
|
+
const r = await runApp(ctx, "codex", addArgs);
|
|
152
|
+
notes.push(r.code === 0
|
|
153
|
+
? { kind: "success", text: `Added ${v.label} to Codex${b ? ` (backup: ${b})` : ""}. ${signInNote("codex", v)}` }
|
|
154
|
+
: { kind: "fail", text: `\`codex mcp add\` for ${v.label} failed (exit ${r.code}).` });
|
|
155
|
+
return r.code === 0 ? "registered" : "failed";
|
|
156
|
+
};
|
|
157
|
+
if (state.kind === "absent") {
|
|
158
|
+
ctx.out(`This adds ${v.label} to Codex:`);
|
|
159
|
+
ctx.out(` ${cmdLine("codex", addArgs)}`);
|
|
160
|
+
if (!(await ctx.prompt.confirm(`Add ${v.label}'s MCP server to Codex?`, true))) {
|
|
161
|
+
notes.push({ kind: "info", text: `Skipped ${v.label} in Codex.` });
|
|
162
|
+
return "skipped";
|
|
163
|
+
}
|
|
164
|
+
if (ctx.dryRun) {
|
|
165
|
+
notes.push({ kind: "info", text: `Dry run: ${v.label} not added to Codex.` });
|
|
166
|
+
return "dry-run";
|
|
167
|
+
}
|
|
168
|
+
return add();
|
|
169
|
+
}
|
|
170
|
+
if (state.kind === "unreadable") {
|
|
171
|
+
ctx.out(`Codex has a ${v.name} entry the wizard can't read; leaving it alone.`);
|
|
172
|
+
notes.push({ kind: "warn", text: `To replace it yourself: codex mcp remove ${v.name}, then ${cmdLine("codex", addArgs)}` });
|
|
173
|
+
return "manual";
|
|
174
|
+
}
|
|
175
|
+
if (state.url === v.url) {
|
|
176
|
+
notes.push({ kind: "success", text: `Codex: ${v.label} is already added.` });
|
|
177
|
+
return "already";
|
|
178
|
+
}
|
|
179
|
+
ctx.out(`Codex already has a ${v.name} server, with a different setting:`);
|
|
180
|
+
ctx.out(` current: ${describeExisting(state)}`);
|
|
181
|
+
ctx.out(` new: url ${v.url}`);
|
|
182
|
+
if (!(await ctx.prompt.confirm(`Replace it (codex mcp remove ${v.name}, then add)?`, false))) {
|
|
183
|
+
notes.push({ kind: "info", text: `Kept the existing ${v.name} entry in Codex.` });
|
|
184
|
+
return "skipped";
|
|
185
|
+
}
|
|
186
|
+
if (ctx.dryRun) {
|
|
187
|
+
notes.push({ kind: "info", text: `Dry run: Codex's ${v.name} entry not replaced.` });
|
|
188
|
+
return "dry-run";
|
|
189
|
+
}
|
|
190
|
+
const backup = backupConfig();
|
|
191
|
+
const removed = await runApp(ctx, "codex", ["mcp", "remove", v.name]);
|
|
192
|
+
if (removed.code !== 0) {
|
|
193
|
+
notes.push({ kind: "fail", text: `\`codex mcp remove ${v.name}\` failed (exit ${removed.code}); nothing was added.` });
|
|
194
|
+
return "failed";
|
|
195
|
+
}
|
|
196
|
+
return add(backup);
|
|
197
|
+
}
|
|
198
|
+
/** Codex without a working CLI: append the entry to config.toml. A different existing entry is left for the user. */
|
|
199
|
+
async function addCodexToml(ctx, v, notes) {
|
|
200
|
+
const path = ctx.paths.codexConfig;
|
|
201
|
+
if (!path)
|
|
202
|
+
return "manual";
|
|
203
|
+
let before;
|
|
204
|
+
try {
|
|
205
|
+
before = readIfExists(path);
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
notes.push({ kind: "fail", text: `Couldn't read Codex's config: ${err.message}` });
|
|
209
|
+
return "failed";
|
|
210
|
+
}
|
|
211
|
+
const plan = planCodexVendorTomlEdit(before, v);
|
|
212
|
+
if (!plan.ok) {
|
|
213
|
+
notes.push({ kind: "warn", text: `Couldn't update Codex's config (${path}): ${plan.reason} To add ${v.label} yourself, paste this into that file:\n${codexVendorSnippet(v).split("\n").map((l) => ` ${l}`).join("\n")}` });
|
|
214
|
+
return "manual";
|
|
215
|
+
}
|
|
216
|
+
if (!plan.changed) {
|
|
217
|
+
notes.push({ kind: "success", text: `Codex already has ${v.label} (${path}).` });
|
|
218
|
+
return "already";
|
|
219
|
+
}
|
|
220
|
+
uiFor(ctx).preview(`Codex config: ${path}`, [
|
|
221
|
+
plan.otherServers.length ? `Other servers kept unchanged: ${plan.otherServers.join(", ")}` : "No other servers in the file.",
|
|
222
|
+
`Adds this ${v.name} entry:`,
|
|
223
|
+
...codexVendorSnippet(v).split("\n").map((l) => ` ${l}`),
|
|
224
|
+
]);
|
|
225
|
+
if (!(await ctx.prompt.confirm("Write this change? (the current file is kept as .bak)", true))) {
|
|
226
|
+
notes.push({ kind: "info", text: `Skipped ${v.label} in Codex.` });
|
|
227
|
+
return "skipped";
|
|
228
|
+
}
|
|
229
|
+
if (ctx.dryRun) {
|
|
230
|
+
notes.push({ kind: "info", text: "Dry run: Codex config not written." });
|
|
231
|
+
return "dry-run";
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
const backup = writeConfigWithBackup(path, plan.text);
|
|
235
|
+
notes.push({ kind: "success", text: `Added ${v.label} to Codex's config${backup ? ` (backup: ${backup})` : ""}. Restart Codex. ${signInNote("codex", v)}` });
|
|
236
|
+
return "registered";
|
|
237
|
+
}
|
|
238
|
+
catch (err) {
|
|
239
|
+
notes.push({ kind: "fail", text: `Couldn't write Codex's config: ${err.message}` });
|
|
240
|
+
return "failed";
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/** Vendors whose crawlmark integration works, for pre-ticking when the step is run on its own. */
|
|
244
|
+
async function configuredVendors(ctx) {
|
|
245
|
+
try {
|
|
246
|
+
const report = await ctx.status(["ahrefs", "semrush"]);
|
|
247
|
+
return VENDOR_IDS.filter((id) => works(id, report.integrations.find((i) => i.id === id)));
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
return [];
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const names = (ids) => ids.map((id) => VENDORS[id].label).join(" and ");
|
|
254
|
+
export async function stepVendorMcp(ctx, opts = {}) {
|
|
255
|
+
const step = "vendor-mcp";
|
|
256
|
+
const ui = uiFor(ctx);
|
|
257
|
+
const later = `To add them later: ${ctx.cliDisplay} setup --step vendor-mcp`;
|
|
258
|
+
const selectedProviders = VENDOR_IDS.filter((id) => ctx.providers?.selected?.has(id));
|
|
259
|
+
let vendorIds;
|
|
260
|
+
let noticed = false;
|
|
261
|
+
if (!opts.asked) {
|
|
262
|
+
if (ctx.yes)
|
|
263
|
+
return { step, outcome: "skipped", message: `Not added (--yes). ${later}` };
|
|
264
|
+
// Plain and CI runs never get this extra question.
|
|
265
|
+
// The register step set up no apps: nothing to add these to, so don't ask.
|
|
266
|
+
if (opts.apps && opts.apps.length === 0)
|
|
267
|
+
return { step, outcome: "skipped", message: `No apps were set up to add them to. ${later}` };
|
|
268
|
+
if (!ui.fancy || selectedProviders.length === 0)
|
|
269
|
+
return { step, outcome: "skipped", message: selectedProviders.length ? `Not asked in plain mode. ${later}` : "Not needed (no Semrush or Ahrefs picked)." };
|
|
270
|
+
for (const id of selectedProviders)
|
|
271
|
+
ctx.out(VENDORS[id].notice);
|
|
272
|
+
noticed = true;
|
|
273
|
+
if (!(await ctx.prompt.confirm(`Also add ${names(selectedProviders)}'s own MCP server to these apps? (optional)`, false))) {
|
|
274
|
+
return { step, outcome: "skipped", message: `Not added. ${later}` };
|
|
275
|
+
}
|
|
276
|
+
vendorIds = selectedProviders;
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
if (ctx.yes)
|
|
280
|
+
return { step, outcome: "skipped", message: `--yes adds no vendor MCP servers; run ${ctx.cliDisplay} setup --step vendor-mcp without it.` };
|
|
281
|
+
const defaults = selectedProviders.length ? selectedProviders : await configuredVendors(ctx);
|
|
282
|
+
const choices = VENDOR_IDS.map((id) => ({ value: id, label: VENDORS[id].label, hint: id === "semrush" ? "OAuth, spends your Semrush API units" : "OAuth, spends your Ahrefs API units" }));
|
|
283
|
+
ctx.out("Semrush and Ahrefs each run their own MCP server. Adding one lets your AI app ask it ad-hoc questions. crawlmark's own Semrush and Ahrefs support (API key, caps, reports) is separate and unchanged.");
|
|
284
|
+
vendorIds = ctx.prompt.multiselect
|
|
285
|
+
? await ctx.prompt.multiselect("Add which vendor MCP servers?", choices, defaults, { summary: `Add: ${defaults.length ? names(defaults) : "none"}. Use these?` })
|
|
286
|
+
: defaults;
|
|
287
|
+
if (!vendorIds.length)
|
|
288
|
+
return { step, outcome: "skipped", message: "No vendor MCP servers picked." };
|
|
289
|
+
}
|
|
290
|
+
const probes = await ui.spin("Looking for AI apps...", () => detectApps(ctx), { plain: false });
|
|
291
|
+
let apps;
|
|
292
|
+
if (opts.apps)
|
|
293
|
+
apps = probes.filter((p) => opts.apps.includes(p.id));
|
|
294
|
+
else {
|
|
295
|
+
const detected = probes.filter((p) => p.detected).map((p) => p.id);
|
|
296
|
+
const choices = probes.map((p) => ({ value: p.id, label: p.label, ...(p.hint ? { hint: p.hint } : {}) }));
|
|
297
|
+
const picked = ctx.prompt.multiselect
|
|
298
|
+
? await ctx.prompt.multiselect("Add them to which apps?", choices, detected, { summary: `Add to: ${probes.filter((p) => p.detected).map((p) => p.label).join(", ") || "none"} (detected). Use these?` })
|
|
299
|
+
: detected;
|
|
300
|
+
apps = probes.filter((p) => picked.includes(p.id));
|
|
301
|
+
}
|
|
302
|
+
if (!apps.length)
|
|
303
|
+
return { step, outcome: "skipped", message: "No apps to add them to." };
|
|
304
|
+
const notes = [];
|
|
305
|
+
const outcomes = [];
|
|
306
|
+
for (const id of vendorIds) {
|
|
307
|
+
const v = VENDORS[id];
|
|
308
|
+
if (!noticed) {
|
|
309
|
+
ctx.out("");
|
|
310
|
+
ctx.out(v.notice);
|
|
311
|
+
}
|
|
312
|
+
for (const probe of apps) {
|
|
313
|
+
let status;
|
|
314
|
+
if (probe.id === "claude-code")
|
|
315
|
+
status = await addClaudeCode(ctx, probe, v, notes);
|
|
316
|
+
else if (probe.id === "claude-desktop")
|
|
317
|
+
status = addDesktop(ctx, v, notes);
|
|
318
|
+
else if (probe.id === "cursor")
|
|
319
|
+
status = await addCursor(ctx, v, notes);
|
|
320
|
+
else
|
|
321
|
+
status = probe.codexCli ? await addCodexCli(ctx, v, notes) : await addCodexToml(ctx, v, notes);
|
|
322
|
+
outcomes.push({ vendor: id, app: probe.id, status });
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
for (const n of notes) {
|
|
326
|
+
if (!ui.fancy)
|
|
327
|
+
ctx.out(n.text);
|
|
328
|
+
else
|
|
329
|
+
ui[n.kind](n.text);
|
|
330
|
+
}
|
|
331
|
+
const failed = outcomes.some((o) => o.status === "failed");
|
|
332
|
+
const added = outcomes.some((o) => o.status === "registered" || o.status === "already");
|
|
333
|
+
return {
|
|
334
|
+
step,
|
|
335
|
+
outcome: failed ? "failed" : added ? "done" : "skipped",
|
|
336
|
+
message: outcomes.map((o) => `${VENDORS[o.vendor].label} in ${APP_LABELS[o.app]}: ${o.status}.`).join(" "),
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
//# sourceMappingURL=vendor-mcp-step.js.map
|
package/dist/setup/wizard.js
CHANGED
|
@@ -9,13 +9,13 @@
|
|
|
9
9
|
import { existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
|
|
10
10
|
import { dirname, join, resolve } from "node:path";
|
|
11
11
|
import { APP_LABELS, registerApps } from "./apps.js";
|
|
12
|
+
import { stepVendorMcp } from "./vendor-mcp-step.js";
|
|
12
13
|
import { copyFileAtomic } from "../lib/atomic-file.js";
|
|
13
14
|
import { readEnvFile, writeEnvFile } from "../lib/env-file.js";
|
|
14
15
|
import { expandPath, sourceCheckoutMarker } from "../lib/home.js";
|
|
15
16
|
import { checkAhrefsKey } from "../providers/ahrefs.js";
|
|
16
17
|
import { describeAhrefsAccount } from "../providers/ahrefs-shape.js";
|
|
17
18
|
import { checkDataForSeoCredentials } from "../providers/dataforseo.js";
|
|
18
|
-
import { checkSemrushKey } from "../providers/semrush.js";
|
|
19
19
|
import { displayProviderValue, PROVIDER_LABELS, quoteProviderValue } from "../lib/provider-ids.js";
|
|
20
20
|
import { checkPsiKey } from "./checks.js";
|
|
21
21
|
import { checkCruxKey } from "../providers/crux.js";
|
|
@@ -26,7 +26,7 @@ import { uiFor } from "./ui.js";
|
|
|
26
26
|
import { tryAskingExamples } from "./try-asking.js";
|
|
27
27
|
import { DFS_LIVE_URL, DFS_SANDBOX_URL } from "../lib/env.js";
|
|
28
28
|
import { LINKS } from "./status.js";
|
|
29
|
-
export const STEP_NAMES = ["node", "provider", "dataforseo", "ahrefs", "semrush", "google", "psi", "discovery", "register"];
|
|
29
|
+
export const STEP_NAMES = ["node", "provider", "dataforseo", "ahrefs", "semrush", "google", "psi", "discovery", "register", "vendor-mcp"];
|
|
30
30
|
/**
|
|
31
31
|
* Phase 12: steps run only with --step (branding is also "Set up report branding" in the returning-user menu).
|
|
32
32
|
* They are not in the default flow or the step lists the wizard prints, so plain installer output is unchanged.
|
|
@@ -61,15 +61,17 @@ async function showStatus(ctx, only, opts = {}) {
|
|
|
61
61
|
return map;
|
|
62
62
|
}
|
|
63
63
|
/** Write keys to <home>/.env (0600), or say what would be written in a dry run. Values are never printed. */
|
|
64
|
-
function saveEnv(ctx, updates) {
|
|
64
|
+
function saveEnv(ctx, updates, remove = []) {
|
|
65
65
|
const keys = Object.keys(updates).join(", ");
|
|
66
66
|
const ui = uiFor(ctx);
|
|
67
67
|
if (ctx.dryRun) {
|
|
68
|
-
ui.info(`Dry run: would set ${keys} in ${ctx.paths.envFile} (mode 0600).`);
|
|
68
|
+
ui.info(`Dry run: would set ${keys}${remove.length ? ` and remove ${remove.join(", ")}` : ""} in ${ctx.paths.envFile} (mode 0600).`);
|
|
69
69
|
return;
|
|
70
70
|
}
|
|
71
|
-
const r = writeEnvFile(ctx.paths.envFile, updates);
|
|
71
|
+
const r = writeEnvFile(ctx.paths.envFile, updates, remove);
|
|
72
72
|
const changed = [...r.updated, ...r.added];
|
|
73
|
+
if (r.removed.length)
|
|
74
|
+
ui.info(`Removed ${r.removed.join(", ")} from ${ctx.paths.envFile}.`);
|
|
73
75
|
ui.success(changed.length ? `Saved ${changed.join(", ")} to ${ctx.paths.envFile} (mode 0600).` : `${ctx.paths.envFile} already had these values.`);
|
|
74
76
|
const shadowed = Object.keys(updates).filter((k) => ctx.shellEnvKeys.has(k));
|
|
75
77
|
if (shadowed.length) {
|
|
@@ -252,7 +254,6 @@ export async function stepAhrefs(ctx, opts = {}) {
|
|
|
252
254
|
}
|
|
253
255
|
return { step, outcome: "failed", message: `No working Ahrefs key after ${MAX_ATTEMPTS} attempts.` };
|
|
254
256
|
}
|
|
255
|
-
const SEMRUSH_API_UNITS = "https://www.semrush.com/accounts/subscription-info/api-units/";
|
|
256
257
|
const GOOGLE_SERVICE_ACCOUNTS = "https://console.cloud.google.com/iam-admin/serviceaccounts";
|
|
257
258
|
const GOOGLE_AUTH_OVERVIEW = "https://console.cloud.google.com/auth/overview";
|
|
258
259
|
export async function stepSemrush(ctx, opts = {}) {
|
|
@@ -263,69 +264,38 @@ export async function stepSemrush(ctx, opts = {}) {
|
|
|
263
264
|
if (!opts.ticked && !(await ctx.prompt.confirm(working ? "Change the Semrush API key?" : "Set up Semrush now?", !working))) {
|
|
264
265
|
return { step, outcome: "skipped", message: working ? "Kept the current Semrush key." : "Semrush not set up." };
|
|
265
266
|
}
|
|
266
|
-
ctx.out("Semrush: keyword
|
|
267
|
-
ctx.out(
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
267
|
+
ctx.out("Semrush: keyword volume and backlinks (needs a Semrush plan with API units: 50,000 come with SEO Classic Pro and Guru, and Semrush One Starter and Pro+).");
|
|
268
|
+
ctx.out("Create a v4 API key under your profile > API keys. Semrush's older v3 keys aren't supported; create a v4 key.");
|
|
269
|
+
const saved = existingEnv(ctx);
|
|
270
|
+
// In 0.1.0 SEMRUSH_API_KEY was the old v3 key and SEMRUSH_V4_API_KEY the v4 key, so the alias wins when both are set.
|
|
271
|
+
// Keeping it saves it as SEMRUSH_API_KEY and removes the alias.
|
|
272
|
+
const current = saved.SEMRUSH_V4_API_KEY || saved.SEMRUSH_API_KEY;
|
|
271
273
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
272
274
|
let key;
|
|
273
275
|
try {
|
|
274
|
-
key = await ctx.prompt.secret(current ? "Semrush API key,
|
|
276
|
+
key = await ctx.prompt.secret(current ? "Semrush API key (v4, from your profile > API keys; Enter keeps the current one)" : "Semrush API key (v4, from your profile > API keys)");
|
|
275
277
|
}
|
|
276
278
|
catch (err) {
|
|
277
279
|
if (err instanceof NeedsInputError) {
|
|
278
280
|
const keySet = Boolean(current) || st?.data?.keySet === true;
|
|
279
|
-
return { step, outcome: "skipped", message: keySet ? `Semrush key is set
|
|
281
|
+
return { step, outcome: "skipped", message: keySet ? `Semrush key is set; run ${ctx.cliDisplay} setup --step semrush to change it` : `Semrush needs a key; run ${ctx.cliDisplay} setup --step semrush` };
|
|
280
282
|
}
|
|
281
283
|
throw err;
|
|
282
284
|
}
|
|
285
|
+
// A key pasted with its quotes from a .env line or a doc.
|
|
286
|
+
key = key.trim().replace(/^(["'])(.*)\1$/, "$2").trim();
|
|
283
287
|
if (!key && current)
|
|
284
288
|
key = current;
|
|
285
289
|
if (!key) {
|
|
286
290
|
ui.fail("No key entered.");
|
|
287
291
|
continue;
|
|
288
292
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
else
|
|
294
|
-
ui.success(`Semrush key accepted ${check.unitsLeft.toLocaleString("en-US")} API units left`);
|
|
295
|
-
saveEnv(ctx, { SEMRUSH_API_KEY: key });
|
|
296
|
-
markWorking(ctx, "semrush");
|
|
297
|
-
await semrushV4(ctx);
|
|
298
|
-
return { step, outcome: "done", message: `Semrush key accepted (${check.unitsLeft.toLocaleString("en-US")} API units left).` };
|
|
299
|
-
}
|
|
300
|
-
if (check.works === false) {
|
|
301
|
-
ui.fail(`Semrush rejected that key${"errorCode" in check && check.errorCode ? ` (ERROR ${check.errorCode})` : ""}. Find the v3 key under Subscription info > API units.`);
|
|
302
|
-
continue;
|
|
303
|
-
}
|
|
304
|
-
ui.warn(`Couldn't reach Semrush's balance check, so the key is untested: ${check.error}`);
|
|
305
|
-
if (await ctx.prompt.confirm("Save it anyway?", false)) {
|
|
306
|
-
saveEnv(ctx, { SEMRUSH_API_KEY: key });
|
|
307
|
-
await semrushV4(ctx);
|
|
308
|
-
return { step, outcome: "done", message: `Saved an untested Semrush key; check later with \`${ctx.cliDisplay} setup --step semrush\`.` };
|
|
309
|
-
}
|
|
310
|
-
return { step, outcome: "failed", message: "Semrush unreachable; nothing saved." };
|
|
293
|
+
saveEnv(ctx, { SEMRUSH_API_KEY: key }, ["SEMRUSH_V4_API_KEY"]);
|
|
294
|
+
markWorking(ctx, "semrush");
|
|
295
|
+
ui.info("Saved. Semrush has no free key check, so the key is checked on the first paid call (keyword volume or backlinks).");
|
|
296
|
+
return { step, outcome: "done", message: "Semrush key saved (v4). Semrush has no free key check, so it is checked on the first paid call (keyword volume or backlinks)." };
|
|
311
297
|
}
|
|
312
|
-
return { step, outcome: "failed", message: `No
|
|
313
|
-
}
|
|
314
|
-
/** The optional v4 key (backlinks): saved untested, since Semrush has no free check for it (N15). Skipped under --yes. */
|
|
315
|
-
async function semrushV4(ctx) {
|
|
316
|
-
if (ctx.yes)
|
|
317
|
-
return;
|
|
318
|
-
if (!(await ctx.prompt.confirm("Add a Semrush v4 key too? (backlinks use the v4 overview; without it they use the older v3 report)", false)))
|
|
319
|
-
return;
|
|
320
|
-
const v4 = await ctx.prompt.secret("Semrush API key (v4, from your profile > API keys)");
|
|
321
|
-
if (!v4) {
|
|
322
|
-
uiFor(ctx).info("No v4 key entered; backlinks keep using the v3 report.");
|
|
323
|
-
return;
|
|
324
|
-
}
|
|
325
|
-
saveEnv(ctx, { SEMRUSH_V4_API_KEY: v4 });
|
|
326
|
-
uiFor(ctx).info(ctx.dryRun
|
|
327
|
-
? "Semrush has no free check for v4 keys, so it would be checked the first time backlinks_summary runs."
|
|
328
|
-
: "Saved; Semrush has no free check for v4 keys, so it's checked the first time backlinks_summary runs.");
|
|
298
|
+
return { step, outcome: "failed", message: `No Semrush key entered after ${MAX_ATTEMPTS} attempts.` };
|
|
329
299
|
}
|
|
330
300
|
const GOOGLE_OPTIONS = [
|
|
331
301
|
{ value: "A", label: "A. OAuth with Google Workspace: sign in once, sees every property you can open, sign-ins don't expire" },
|
|
@@ -625,7 +595,7 @@ const PROVIDER_ORDER = ["dataforseo", "ahrefs", "semrush"];
|
|
|
625
595
|
const PROVIDER_HINTS = {
|
|
626
596
|
dataforseo: "pay per call; free sandbox; also live SERPs, on-page, crawls",
|
|
627
597
|
ahrefs: "API v3, Lite plan or higher",
|
|
628
|
-
semrush: "
|
|
598
|
+
semrush: "v4 API key; keyword volume and backlinks",
|
|
629
599
|
};
|
|
630
600
|
/** What the wizard says when a provider isn't set up and --yes can't ask for its key. */
|
|
631
601
|
const NEEDS_KEY = {
|
|
@@ -637,7 +607,7 @@ const NEEDS_KEY = {
|
|
|
637
607
|
const KEY_VARS = {
|
|
638
608
|
dataforseo: "DATAFORSEO_LOGIN and DATAFORSEO_PASSWORD",
|
|
639
609
|
ahrefs: "AHREFS_API_KEY",
|
|
640
|
-
semrush: "SEMRUSH_API_KEY
|
|
610
|
+
semrush: "SEMRUSH_API_KEY",
|
|
641
611
|
};
|
|
642
612
|
const STATE_WORD = { working: "working", configured: "set, untested", error: "error", missing: "not set up" };
|
|
643
613
|
/** Read status rows without printing them (under a spinner in fancy mode). */
|
|
@@ -656,8 +626,8 @@ async function readStatus(ctx, only) {
|
|
|
656
626
|
const providerIds = () => PROVIDER_ORDER.filter((id) => PROVIDER_STEPS.includes(id));
|
|
657
627
|
/** A provider's setup_status row id (every provider with a credential step has one). */
|
|
658
628
|
const rowId = (id) => id;
|
|
659
|
-
/** A provider counts as working: its row is working, or (Semrush
|
|
660
|
-
export const works = (id, s) => s?.state === "working" || (id === "semrush" && s?.state === "configured" &&
|
|
629
|
+
/** A provider counts as working: its row is working, or (Semrush, which has no free key check) its key is set. */
|
|
630
|
+
export const works = (id, s) => s?.state === "working" || (id === "semrush" && s?.state === "configured" && s.data?.keySet === true);
|
|
661
631
|
const hasKey = (id, s) => (id === "dataforseo" ? s !== undefined && s.state !== "missing" : s?.data?.keySet === true);
|
|
662
632
|
function describeDefault(seo) {
|
|
663
633
|
const d = seo?.data;
|
|
@@ -680,8 +650,11 @@ export async function stepProviderPick(ctx) {
|
|
|
680
650
|
ctx.out(`SEO data providers: ${ids.map((id) => `${PROVIDER_LABELS[id]} (${(works(id, st.get(rowId(id))) ? "working" : STATE_WORD[st.get(rowId(id))?.state ?? "missing"])})`).join(", ")}`);
|
|
681
651
|
ctx.out(describeDefault(st.get("seoprovider")));
|
|
682
652
|
ctx.out("Keyword volumes, rankings, competitors and backlinks come from a paid provider. Pick the ones you have an API key for (none is required).");
|
|
653
|
+
if (ctx.ui?.fancy)
|
|
654
|
+
ctx.out("Space selects, Enter continues. To skip, press Enter with nothing selected; add one later with setup --step provider.");
|
|
683
655
|
const keyed = ids.filter((id) => hasKey(id, st.get(rowId(id))));
|
|
684
|
-
|
|
656
|
+
// Nothing is ticked for you: only providers you already have a key for start selected (a re-run).
|
|
657
|
+
const defaults = keyed;
|
|
685
658
|
const choices = ids.map((id) => ({ value: id, label: PROVIDER_LABELS[id], hint: PROVIDER_HINTS[id] }));
|
|
686
659
|
const picked = ctx.prompt.multiselect ? await ctx.prompt.multiselect("Which SEO data providers do you have?", choices, defaults) : [...defaults];
|
|
687
660
|
const selected = new Set(picked);
|
|
@@ -762,13 +735,14 @@ const STEPS = {
|
|
|
762
735
|
branding: { title: "Report branding", run: stepBranding },
|
|
763
736
|
discovery: { title: "Clients", run: stepDiscovery },
|
|
764
737
|
register: { title: "Register with Claude", run: stepRegister },
|
|
738
|
+
"vendor-mcp": { title: "Semrush and Ahrefs MCP servers (optional)", run: (ctx, opts) => stepVendorMcp(ctx, opts ?? {}) },
|
|
765
739
|
};
|
|
766
740
|
const isProviderStep = (p) => PROVIDER_STEPS.includes(p);
|
|
767
741
|
/** The run plan (spec 9.6) for a full run or one --step. */
|
|
768
742
|
export function runPlan(step) {
|
|
769
743
|
const creds = PROVIDER_STEPS;
|
|
770
744
|
if (!step)
|
|
771
|
-
return ["node", "provider:pick", ...creds, "provider:default", "google", "psi", "discovery", "register"];
|
|
745
|
+
return ["node", "provider:pick", ...creds, "provider:default", "google", "psi", "discovery", "register", "vendor-mcp"];
|
|
772
746
|
if (step === "provider")
|
|
773
747
|
return ["provider:pick", ...creds, "provider:default"];
|
|
774
748
|
if (isProviderStep(step))
|
|
@@ -787,6 +761,7 @@ const SUMMARY_LABELS = {
|
|
|
787
761
|
bing: "Bing",
|
|
788
762
|
branding: "Branding",
|
|
789
763
|
discovery: "Clients",
|
|
764
|
+
"vendor-mcp": "Vendor MCP",
|
|
790
765
|
};
|
|
791
766
|
const APP_OUTCOME = {
|
|
792
767
|
registered: "done",
|
|
@@ -888,11 +863,15 @@ export async function runWizard(ctx, opts = {}) {
|
|
|
888
863
|
// The returning-user menu (src/setup/menu.ts) shows the intro itself before it runs a step.
|
|
889
864
|
if (!opts.introShown)
|
|
890
865
|
ui.intro({ command: ctx.command ?? "setup", version: ctx.version ?? "", home: ctx.paths.home, dryRun: ctx.dryRun });
|
|
891
|
-
|
|
866
|
+
let phases = runPlan(opts.step);
|
|
867
|
+
// The vendor MCP question exists only in a fancy, interactive full run; plain, CI and --yes output is unchanged.
|
|
868
|
+
if (!opts.step && (ctx.yes || !ui.fancy))
|
|
869
|
+
phases = phases.filter((p) => p !== "vendor-mcp");
|
|
892
870
|
const results = [];
|
|
893
871
|
let picked;
|
|
894
872
|
let index = 0;
|
|
895
|
-
for (
|
|
873
|
+
for (let pi = 0; pi < phases.length; pi++) {
|
|
874
|
+
const name = phases[pi];
|
|
896
875
|
let result;
|
|
897
876
|
let record = true;
|
|
898
877
|
const run = async (title, fn) => {
|
|
@@ -914,6 +893,9 @@ export async function runWizard(ctx, opts = {}) {
|
|
|
914
893
|
picked = r.statuses;
|
|
915
894
|
return r.result;
|
|
916
895
|
});
|
|
896
|
+
// No Semrush or Ahrefs picked: the vendor MCP question has nothing to ask.
|
|
897
|
+
if (!opts.step && !(ctx.providers?.selected?.has("semrush") || ctx.providers?.selected?.has("ahrefs")))
|
|
898
|
+
phases = phases.filter((p) => p !== "vendor-mcp");
|
|
917
899
|
// The pick's line is shown; the summary's "provider" entry is the default phase's result.
|
|
918
900
|
record = false;
|
|
919
901
|
}
|
|
@@ -936,6 +918,10 @@ export async function runWizard(ctx, opts = {}) {
|
|
|
936
918
|
result = await run(STEPS[name].title, () => STEPS[name].run(ctx, { ticked: true }));
|
|
937
919
|
}
|
|
938
920
|
}
|
|
921
|
+
else if (name === "vendor-mcp") {
|
|
922
|
+
const apps = results.find((r) => r.step === "register")?.apps?.filter((a) => a.status === "registered" || a.status === "already").map((a) => a.id);
|
|
923
|
+
result = await run(STEPS[name].title, () => STEPS[name].run(ctx, opts.step ? { asked: true } : { apps: apps ?? [] }));
|
|
924
|
+
}
|
|
939
925
|
else {
|
|
940
926
|
const step = name;
|
|
941
927
|
result = await run(STEPS[step].title, () => STEPS[step].run(ctx));
|
|
@@ -172,7 +172,7 @@ export function registerCompetitorTools(server) {
|
|
|
172
172
|
"Through the client's data provider only (no fallback to another provider). Call with estimateOnly: true first, state the provider, cost and what's left, and run it only after the user agrees.";
|
|
173
173
|
server.registerTool("organic_visibility", {
|
|
174
174
|
title: "Organic visibility",
|
|
175
|
-
description: "A domain's organic search visibility from the client's data provider: ranking keywords (top 100), keywords by position band (top 3, 4-10, 11-20, 21-100;
|
|
175
|
+
description: "A domain's organic search visibility from the client's data provider: ranking keywords (top 100), keywords by position band (top 3, 4-10, 11-20, 21-100; Semrush's API has no visibility data, so a Semrush client is served by DataForSEO) and estimated organic traffic, labelled by provider (never compare traffic across providers). " +
|
|
176
176
|
"Comparisons with a month and a year ago appear only when stored snapshots (or, with history: true, the provider's own history, paid) have them. " +
|
|
177
177
|
SNAPSHOTS,
|
|
178
178
|
inputSchema: {
|
package/dist/tools/raw.js
CHANGED
|
@@ -188,13 +188,12 @@ export function registerRawTools(server) {
|
|
|
188
188
|
}, handler("ahrefs_status", () => ahrefsStatus()));
|
|
189
189
|
server.registerTool("semrush_status", {
|
|
190
190
|
title: "Semrush status",
|
|
191
|
-
description: "
|
|
192
|
-
"
|
|
193
|
-
"
|
|
194
|
-
"A missing or rejected key is reported in the result, not as a tool error. Change keys in a terminal (setup --step semrush), never through chat.",
|
|
191
|
+
description: "Whether a Semrush API key (v4, from the profile > API keys page) is set, what Semrush serves in crawlmark (keyword volume and backlinks; its v4 API has no rankings, " +
|
|
192
|
+
"competitors, visibility or Site Audit), and the units crawlmark recorded this calendar month against its own cap. Semrush has no free key check, so a rejected key shows " +
|
|
193
|
+
"on the first paid call. Free, never cached, records no spend, never shows a key. Change the key in a terminal (setup --step semrush), never through chat.",
|
|
195
194
|
inputSchema: {},
|
|
196
195
|
annotations: READ_ONLY,
|
|
197
|
-
}, handler("semrush_status", () => semrushStatus()));
|
|
196
|
+
}, handler("semrush_status", async () => semrushStatus()));
|
|
198
197
|
// ---------------------------------------------------- browser checks (free)
|
|
199
198
|
server.registerTool("lighthouse", {
|
|
200
199
|
title: "Lighthouse",
|