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.
@@ -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
@@ -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, ranking, competitor and backlink data (needs an SEO Business plan plus an API unit package).");
267
- ctx.out(`Find the v3 API key under Subscription info > API units (${SEMRUSH_API_UNITS}).`);
268
- await offerOpen(ctx, "Semrush API units", SEMRUSH_API_UNITS);
269
- const env = existingEnv(ctx);
270
- const current = env.SEMRUSH_API_KEY;
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, v3 (Enter keeps the current one)" : "Semrush API key (v3)");
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 but untested; run ${ctx.cliDisplay} setup --step semrush` : `Semrush needs a key; run ${ctx.cliDisplay} setup --step semrush` };
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
- const check = await ui.spin("Checking with Semrush (free API units balance call)...", () => checkSemrushKey(key, ctx.fetch));
290
- if (check.works === true) {
291
- if (check.unitsLeft === 0)
292
- ui.warn("Key accepted but the balance is 0 units; Semrush API needs an SEO Business plan and a unit package.");
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 accepted Semrush key after ${MAX_ATTEMPTS} attempts.` };
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: "Business plan + API unit package",
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 (and SEMRUSH_V4_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) its key was accepted with units left and only the v4 key is untested. */
660
- export const works = (id, s) => s?.state === "working" || (id === "semrush" && s?.state === "configured" && typeof s.data?.unitsLeft === "number" && s.data.unitsLeft > 0);
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
- const defaults = ctx.yes ? keyed : keyed.length ? keyed : ["dataforseo"];
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
- const phases = runPlan(opts.step);
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 (const name of phases) {
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; not available from Semrush) and estimated organic traffic, labelled by provider (never compare traffic across providers). " +
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: "Check the Semrush v3 API key with Semrush's free balance call: whether the key is accepted and how many API units are left, whether a v4 key " +
192
- "(used for backlinks) is set, plus the units crawlmark recorded this calendar month against its own cap. Accepted proves the key exists, not that the " +
193
- "plan includes the API (that shows on the first paid call). The v4 key has no free check. Free, never cached, records no spend, never shows a key. " +
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",