@rankcli/cli 0.0.29 → 0.0.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -41,6 +41,7 @@ var import_open = __toESM(require("open"));
41
41
 
42
42
  // src/lib/config.ts
43
43
  var import_conf = __toESM(require("conf"));
44
+ var import_node_crypto = require("crypto");
44
45
  var import_supabase_js = require("@supabase/supabase-js");
45
46
  var INJECTED_CONFIG = {"supabaseUrl":"https://bspljbxwbjiqueeyzyat.supabase.co","supabaseAnonKey":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImJzcGxqYnh3YmppcXVlZXl6eWF0Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjkyNzQ1NTksImV4cCI6MjA4NDg1MDU1OX0.7gprxM2gT9cZ99sjZURLltrln78dEnwyFFKYpTzBaUU","webAppUrl":"https://rankcli.dev","decryptPassphrase":"lKFLeIw/xGPa6E8c4XswZX/oiLtJf/yLF/Y6zwovte8="};
46
47
  function getSupabaseUrl() {
@@ -80,6 +81,7 @@ function getApiUrl() {
80
81
  }
81
82
  var config = new import_conf.default({
82
83
  projectName: "rankcli",
84
+ ...process.env.RANKCLI_CONFIG_DIR ? { cwd: process.env.RANKCLI_CONFIG_DIR } : {},
83
85
  schema: {
84
86
  accessToken: { type: "string" },
85
87
  refreshToken: { type: "string" },
@@ -90,7 +92,9 @@ var config = new import_conf.default({
90
92
  apiKeyName: { type: "string" },
91
93
  planId: { type: "string" },
92
94
  planName: { type: "string" },
93
- sitesLimit: { type: "number" }
95
+ sitesLimit: { type: "number" },
96
+ planKeyFingerprint: { type: "string" },
97
+ planCheckedAt: { type: "number" }
94
98
  }
95
99
  });
96
100
  var supabaseClient = null;
@@ -141,15 +145,17 @@ async function validateApiKey(apiKey) {
141
145
  method: "POST",
142
146
  headers: {
143
147
  "Content-Type": "application/json",
144
- "X-API-Key": apiKey
148
+ "X-API-Key": apiKey,
149
+ "X-Endpoint": "cli"
145
150
  }
146
151
  });
147
- const data = await response.json();
152
+ const data = await response.json().catch(() => ({}));
148
153
  if (!response.ok) {
149
- return { valid: false, error: data.error || "Invalid API key" };
154
+ return { valid: false, status: response.status, error: data.error || `Invalid API key (HTTP ${response.status})` };
150
155
  }
151
156
  return {
152
157
  valid: true,
158
+ status: response.status,
153
159
  user: data.user,
154
160
  subscription: data.subscription
155
161
  };
@@ -157,6 +163,50 @@ async function validateApiKey(apiKey) {
157
163
  return { valid: false, error: err instanceof Error ? err.message : "Validation failed" };
158
164
  }
159
165
  }
166
+ function keyFingerprint(apiKey) {
167
+ return (0, import_node_crypto.createHash)("sha256").update(apiKey).digest("hex").slice(0, 16);
168
+ }
169
+ var PLAN_CACHE_MS = 6 * 60 * 60 * 1e3;
170
+ async function ensureApiKeyPlan(options = {}) {
171
+ if (!isApiKeyAuth()) return null;
172
+ const apiKey = getApiKey();
173
+ const fingerprint = keyFingerprint(apiKey);
174
+ const sameKey = config.get("planKeyFingerprint") === fingerprint;
175
+ const fresh = sameKey && Date.now() - (config.get("planCheckedAt") || 0) < PLAN_CACHE_MS;
176
+ if (fresh && !options.force) {
177
+ return { status: "ok", planId: config.get("planId"), planName: config.get("planName") || "Free", email: config.get("email"), cached: true };
178
+ }
179
+ const result = await validateApiKey(apiKey);
180
+ if (result.valid) {
181
+ config.set("planKeyFingerprint", fingerprint);
182
+ config.set("planCheckedAt", Date.now());
183
+ if (result.user?.email) config.set("email", result.user.email);
184
+ if (result.subscription?.planId && result.subscription.planId !== "free") {
185
+ saveSubscription(result.subscription);
186
+ } else {
187
+ setFreePlan();
188
+ }
189
+ return { status: "ok", planId: config.get("planId"), planName: config.get("planName") || "Free", email: result.user?.email, cached: false };
190
+ }
191
+ if (result.status === 403) {
192
+ config.set("planKeyFingerprint", fingerprint);
193
+ config.set("planCheckedAt", Date.now());
194
+ setFreePlan();
195
+ return { status: "free" };
196
+ }
197
+ if (result.status === 401) {
198
+ config.delete("planKeyFingerprint");
199
+ setFreePlan();
200
+ return { status: "invalid", error: result.error || "Invalid API key" };
201
+ }
202
+ if (!sameKey) setFreePlan();
203
+ return { status: "unavailable", error: result.error || "Validation failed" };
204
+ }
205
+ function setFreePlan() {
206
+ config.delete("planId");
207
+ config.set("planName", "Free");
208
+ config.set("sitesLimit", 1);
209
+ }
160
210
  async function getSession() {
161
211
  const accessToken = config.get("accessToken");
162
212
  const refreshToken = config.get("refreshToken");
@@ -216,6 +266,8 @@ function clearCredentials() {
216
266
  config.delete("planId");
217
267
  config.delete("planName");
218
268
  config.delete("sitesLimit");
269
+ config.delete("planKeyFingerprint");
270
+ config.delete("planCheckedAt");
219
271
  }
220
272
  function getUserInfo() {
221
273
  return {
@@ -250,7 +302,7 @@ async function browserLogin(spinner) {
250
302
  } catch (err) {
251
303
  spinner.fail("Could not start login");
252
304
  console.log(import_chalk.default.red(err instanceof Error ? err.message : "Network error"));
253
- return;
305
+ return false;
254
306
  }
255
307
  spinner.text = "Opening browser to approve...";
256
308
  try {
@@ -261,6 +313,10 @@ async function browserLogin(spinner) {
261
313
  If a browser didn't open, visit:
262
314
  ${start.url}
263
315
  `));
316
+ if (start.userCode) {
317
+ console.log(`Approve in your browser only if it shows this code: ${import_chalk.default.bold(start.userCode)}
318
+ `);
319
+ }
264
320
  const deadline = Date.now() + start.expiresInSeconds * 1e3;
265
321
  spinner.text = "Waiting for approval in your browser...";
266
322
  while (Date.now() < deadline) {
@@ -278,58 +334,75 @@ ${start.url}
278
334
  }
279
335
  if (status.status === "claimed" && status.apiKey) {
280
336
  saveApiKey(status.apiKey, status.apiKeyName, status.email);
281
- await fetchAndSaveSubscription();
337
+ await ensureApiKeyPlan({ force: true });
282
338
  spinner.succeed(`Logged in as ${import_chalk.default.green(status.email)}`);
283
- return;
339
+ return true;
284
340
  }
285
341
  if (status.status === "expired" || status.status === "not_found") {
286
342
  spinner.fail("Login link expired");
287
343
  console.log(import_chalk.default.dim('Run "rankcli login" again.'));
288
- return;
344
+ return false;
289
345
  }
290
346
  }
291
347
  spinner.fail("Timed out waiting for approval");
292
348
  console.log(import_chalk.default.dim(`Run "rankcli login" again when you're ready.`));
349
+ return false;
350
+ }
351
+ async function verifyApiKey(spinner, source) {
352
+ spinner.start("Validating API key...");
353
+ const plan = await ensureApiKeyPlan({ force: true });
354
+ if (!plan || plan.status === "invalid") {
355
+ spinner.fail("Invalid API key");
356
+ console.log(import_chalk.default.red(`
357
+ ${plan?.error || "The key was rejected"}`));
358
+ return 1;
359
+ }
360
+ if (plan.status === "unavailable") {
361
+ spinner.fail("Could not validate API key");
362
+ console.log(import_chalk.default.red(`
363
+ ${plan.error}`));
364
+ return 1;
365
+ }
366
+ spinner.succeed(`Authenticated via ${source}`);
367
+ const { email, planName } = getUserInfo();
368
+ if (email) console.log(import_chalk.default.dim(`
369
+ Email: ${email}`));
370
+ console.log(import_chalk.default.dim(`${email ? "" : "\n"}Plan: ${planName || "Free"}`));
371
+ if (plan.status === "free") {
372
+ console.log(import_chalk.default.dim("The key works for dashboard sync; the paid checks and API features need a paid plan."));
373
+ }
374
+ return 0;
293
375
  }
294
376
  async function login(options) {
377
+ const spinner = (0, import_ora.default)();
378
+ if (options.token !== void 0) {
379
+ const apiKey = options.token.trim();
380
+ if (!apiKey.startsWith("rankcli_")) {
381
+ console.log(import_chalk.default.red(apiKey ? 'Invalid API key format. Keys should start with "rankcli_"' : "Empty API key - is the RANKCLI_API_KEY secret set?"));
382
+ return 1;
383
+ }
384
+ if (process.env.RANKCLI_API_KEY && process.env.RANKCLI_API_KEY !== apiKey) {
385
+ console.log(import_chalk.default.red("RANKCLI_API_KEY is set to a different key and would take precedence over --token. Unset it or pass the same key."));
386
+ return 1;
387
+ }
388
+ clearCredentials();
389
+ saveApiKey(apiKey);
390
+ const code = await verifyApiKey(spinner, "API key");
391
+ if (code !== 0) clearCredentials();
392
+ return code;
393
+ }
394
+ if (process.env.RANKCLI_API_KEY) {
395
+ return verifyApiKey(spinner, "RANKCLI_API_KEY");
396
+ }
295
397
  if (isLoggedIn()) {
296
398
  const { email } = getUserInfo();
297
399
  console.log(import_chalk.default.yellow(`Already logged in as ${email}`));
298
400
  console.log(import_chalk.default.dim('Run "rankcli logout" to sign out first.'));
299
- return;
401
+ return 0;
300
402
  }
301
- const spinner = (0, import_ora.default)();
302
403
  try {
303
- if (options.token) {
304
- const apiKey = options.token;
305
- if (!apiKey.startsWith("rankcli_")) {
306
- console.log(import_chalk.default.red('Invalid API key format. Keys should start with "rankcli_"'));
307
- return;
308
- }
309
- spinner.start("Validating API key...");
310
- const result = await validateApiKey(apiKey);
311
- if (!result.valid) {
312
- spinner.fail("Invalid API key");
313
- console.log(import_chalk.default.red(`
314
- ${result.error}`));
315
- return;
316
- }
317
- saveApiKey(apiKey);
318
- if (result.subscription) {
319
- saveSubscription({
320
- planId: result.subscription.planId,
321
- planName: result.subscription.planName,
322
- sitesLimit: result.subscription.sitesLimit
323
- });
324
- }
325
- spinner.succeed(`Authenticated via API key`);
326
- console.log(import_chalk.default.dim(`
327
- Email: ${result.user?.email}`));
328
- console.log(import_chalk.default.dim(`Plan: ${result.subscription?.planName || "Free"}`));
329
- return;
330
- }
331
404
  if (options.browser !== false && !options.email) {
332
- await browserLogin(spinner);
405
+ return await browserLogin(spinner) ? 0 : 1;
333
406
  } else {
334
407
  let email = options.email;
335
408
  if (!email) {
@@ -368,11 +441,11 @@ ${error.message}`));
368
441
  console.log(import_chalk.default.dim("\nDon't have an account? Sign up at:"));
369
442
  console.log(import_chalk.default.cyan(` ${WEB_APP_URL}/signup`));
370
443
  }
371
- return;
444
+ return 1;
372
445
  }
373
446
  if (!data.session) {
374
447
  spinner.fail("No session returned");
375
- return;
448
+ return 1;
376
449
  }
377
450
  saveSession(data.session);
378
451
  await fetchAndSaveSubscription();
@@ -380,10 +453,12 @@ ${error.message}`));
380
453
  const { planName, sitesLimit } = getUserInfo();
381
454
  console.log(import_chalk.default.dim(`
382
455
  Plan: ${planName} (${sitesLimit} site${sitesLimit !== 1 ? "s" : ""})`));
456
+ return 0;
383
457
  }
384
458
  } catch (err) {
385
459
  spinner.fail("Login failed");
386
460
  console.error(import_chalk.default.red(err instanceof Error ? err.message : "Unknown error"));
461
+ return 1;
387
462
  }
388
463
  }
389
464
  async function logout() {
@@ -413,16 +488,10 @@ async function whoami() {
413
488
  const spinner = (0, import_ora.default)("Checking account...").start();
414
489
  try {
415
490
  if (isApiKeyAuth()) {
416
- const apiKey = getApiKey();
417
- if (apiKey) {
418
- const result = await validateApiKey(apiKey);
419
- if (result.valid && result.subscription) {
420
- saveSubscription({
421
- planId: result.subscription.planId,
422
- planName: result.subscription.planName,
423
- sitesLimit: result.subscription.sitesLimit
424
- });
425
- }
491
+ const plan = await ensureApiKeyPlan({ force: true });
492
+ if (plan?.status === "invalid") {
493
+ spinner.fail(`API key rejected: ${plan.error}`);
494
+ return;
426
495
  }
427
496
  } else {
428
497
  await fetchAndSaveSubscription();
@@ -494,7 +563,10 @@ async function setup(options) {
494
563
  await setupGSC(projectPath, options.gsc);
495
564
  }
496
565
  if (options.githubAction) {
497
- await setupGitHubAction(projectPath, options.schedule || "weekly");
566
+ if (!options.url) {
567
+ console.log("\u2139\uFE0F No --url given - the workflow audits https://example.com until you edit SITE_URL in it.\n");
568
+ }
569
+ await setupGitHubAction(projectPath, options.schedule || "weekly", options.url, options.sync);
498
570
  }
499
571
  }
500
572
  async function interactiveSetup(projectPath) {
@@ -627,9 +699,12 @@ async function setupGitHubAction(projectPath, schedule, siteUrl, syncToDashboard
627
699
  siteUrl: siteUrl || "https://example.com",
628
700
  features: {
629
701
  audit: true,
630
- autoFix: true,
702
+ // `rankcli fix --auto` needs a Solo+ plan, which the workflow can
703
+ // only prove with a RANKCLI_API_KEY secret - without one the step
704
+ // is always tier-blocked, so don't generate it.
705
+ autoFix: !!syncToDashboard,
631
706
  createIssues: true,
632
- createPRs: true
707
+ createPRs: !!syncToDashboard
633
708
  },
634
709
  syncToDashboard
635
710
  };
@@ -1067,6 +1142,11 @@ var TIER_LIMITS = {
1067
1142
  features: { geo: true, auto_fix: true, github: true, ai: true }
1068
1143
  }
1069
1144
  };
1145
+ function tierFromPlanId(planId) {
1146
+ if (!planId) return void 0;
1147
+ const base = planId.toLowerCase().replace(/^ltd_/, "").replace(/_(annual|yearly|monthly|founding|lifetime|ltd)$/, "");
1148
+ return base === "free" || base === "solo" || base === "pro" || base === "agency" ? base : void 0;
1149
+ }
1070
1150
  var UPSELL_MESSAGES = {
1071
1151
  // After free audit completes
1072
1152
  free_audit_complete: () => [
@@ -1385,100 +1465,403 @@ async function getPremiumChecks() {
1385
1465
  // src/models.ts
1386
1466
  var OPENAI_FAST = "gpt-5.6-luna";
1387
1467
  var ANTHROPIC_FAST = "claude-haiku-4-5-20251001";
1468
+ var OPENROUTER_DEFAULT = "openrouter/free";
1469
+ var GROQ_DEFAULT = "llama-3.3-70b-versatile";
1470
+ var GEMINI_DEFAULT = "gemini-3.7-flash";
1471
+ var DEEPSEEK_DEFAULT = "deepseek-flash";
1472
+ var MISTRAL_DEFAULT = "mistral-small-latest";
1473
+ var TOGETHER_DEFAULT = "openai/gpt-oss-120b";
1474
+ var FIREWORKS_DEFAULT = "accounts/fireworks/models/gpt-oss-120b";
1475
+ var CEREBRAS_DEFAULT = "gpt-oss-120b";
1388
1476
 
1389
- // src/commands/audit.ts
1390
- var { detectFramework: detectFramework2, getFrameworkDisplayName } = import_agent_runtime2.frameworks;
1391
- async function fetchHtml(url, timeout = 1e4) {
1477
+ // src/lib/ai.ts
1478
+ var AI_PROVIDERS = {
1479
+ // Hosted
1480
+ openrouter: { label: "OpenRouter", baseUrl: "https://openrouter.ai/api/v1", model: OPENROUTER_DEFAULT, envKeys: ["OPENROUTER_API_KEY"] },
1481
+ openai: { label: "OpenAI", baseUrl: "https://api.openai.com/v1", model: OPENAI_FAST, envKeys: ["OPENAI_API_KEY"], jsonMode: true },
1482
+ anthropic: { label: "Anthropic", baseUrl: "https://api.anthropic.com/v1", model: ANTHROPIC_FAST, envKeys: ["ANTHROPIC_API_KEY"], anthropic: true },
1483
+ groq: { label: "Groq", baseUrl: "https://api.groq.com/openai/v1", model: GROQ_DEFAULT, envKeys: ["GROQ_API_KEY"], jsonMode: true },
1484
+ gemini: { label: "Gemini", baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", model: GEMINI_DEFAULT, envKeys: ["GEMINI_API_KEY", "GOOGLE_API_KEY"] },
1485
+ deepseek: { label: "DeepSeek", baseUrl: "https://api.deepseek.com", model: DEEPSEEK_DEFAULT, envKeys: ["DEEPSEEK_API_KEY"], jsonMode: true },
1486
+ mistral: { label: "Mistral", baseUrl: "https://api.mistral.ai/v1", model: MISTRAL_DEFAULT, envKeys: ["MISTRAL_API_KEY"], jsonMode: true },
1487
+ cerebras: { label: "Cerebras", baseUrl: "https://api.cerebras.ai/v1", model: CEREBRAS_DEFAULT, envKeys: ["CEREBRAS_API_KEY"] },
1488
+ together: { label: "Together", baseUrl: "https://api.together.xyz/v1", model: TOGETHER_DEFAULT, envKeys: ["TOGETHER_API_KEY"] },
1489
+ fireworks: { label: "Fireworks", baseUrl: "https://api.fireworks.ai/inference/v1", model: FIREWORKS_DEFAULT, envKeys: ["FIREWORKS_API_KEY"] },
1490
+ // Local / self-hosted
1491
+ ollama: { label: "Ollama", baseUrl: "http://localhost:11434/v1", envKeys: [], local: true },
1492
+ lmstudio: { label: "LM Studio", baseUrl: "http://localhost:1234/v1", envKeys: [], local: true },
1493
+ llamacpp: { label: "llama.cpp", baseUrl: "http://localhost:8080/v1", envKeys: [], local: true },
1494
+ vllm: { label: "vLLM", baseUrl: "http://localhost:8000/v1", envKeys: [], local: true },
1495
+ custom: { label: "OpenAI-compatible server", envKeys: [], local: true }
1496
+ };
1497
+ var AI_PROVIDER_IDS = Object.keys(AI_PROVIDERS);
1498
+ var DETECT_ORDER = ["openrouter", "groq", "gemini", "deepseek", "cerebras", "mistral", "together", "fireworks", "anthropic", "openai"];
1499
+ var AI_ENV_KEYS = DETECT_ORDER.flatMap((id) => AI_PROVIDERS[id].envKeys);
1500
+ function providerFromKey(key) {
1501
+ if (key.startsWith("sk-or-")) return "openrouter";
1502
+ if (key.startsWith("sk-ant-")) return "anthropic";
1503
+ if (key.startsWith("gsk_")) return "groq";
1504
+ if (key.startsWith("AIza")) return "gemini";
1505
+ if (key.startsWith("csk-")) return "cerebras";
1506
+ return void 0;
1507
+ }
1508
+ function ollamaBaseUrl(env) {
1509
+ const raw = env.OLLAMA_HOST?.trim();
1510
+ if (!raw) return AI_PROVIDERS.ollama.baseUrl;
1392
1511
  try {
1393
- const controller = new AbortController();
1394
- const timeoutId = setTimeout(() => controller.abort(), timeout);
1395
- const response = await fetch(url, {
1396
- signal: controller.signal,
1397
- headers: {
1398
- "User-Agent": "RankCLI-Bot/1.0 (+https://rankcli.dev/bot)",
1399
- "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
1400
- }
1401
- });
1402
- clearTimeout(timeoutId);
1403
- if (!response.ok) {
1404
- return null;
1405
- }
1406
- return await response.text();
1512
+ const hasScheme = /^https?:\/\//i.test(raw);
1513
+ const u = new URL(hasScheme ? raw : `http://${raw}`);
1514
+ if (!hasScheme && !/:\d+$/.test(raw)) u.port = "11434";
1515
+ if (u.hostname === "0.0.0.0") u.hostname = "127.0.0.1";
1516
+ return `${u.origin}/v1`;
1517
+ } catch {
1518
+ return AI_PROVIDERS.ollama.baseUrl;
1519
+ }
1520
+ }
1521
+ async function fetchWithTimeout(url, init2, timeoutMs) {
1522
+ const controller = new AbortController();
1523
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
1524
+ try {
1525
+ return await fetch(url, { ...init2, signal: controller.signal });
1526
+ } finally {
1527
+ clearTimeout(timer);
1528
+ }
1529
+ }
1530
+ async function probeOllama(baseUrl, timeoutMs = 800) {
1531
+ try {
1532
+ const origin = new URL(baseUrl).origin;
1533
+ const res = await fetchWithTimeout(`${origin}/api/tags`, {}, timeoutMs);
1534
+ if (!res.ok) return null;
1535
+ const data = await res.json();
1536
+ return (data.models || []).map((m) => m.name || m.model || "").filter(Boolean);
1407
1537
  } catch {
1408
1538
  return null;
1409
1539
  }
1410
1540
  }
1411
- var AI_PROVIDER_DEFAULTS = {
1412
- openai: { baseUrl: "https://api.openai.com/v1", model: OPENAI_FAST },
1413
- openrouter: { baseUrl: "https://openrouter.ai/api/v1", model: "deepseek/deepseek-r1:free" },
1414
- groq: { baseUrl: "https://api.groq.com/openai/v1", model: "llama-3.3-70b-versatile" },
1415
- gemini: { baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", model: "gemini-2.0-flash" }
1416
- };
1417
- async function runAIAnalysis(url, issues, provider, apiKey) {
1418
- const isAnthropic = provider === "anthropic";
1419
- const providerConfig = AI_PROVIDER_DEFAULTS[provider] || AI_PROVIDER_DEFAULTS.openai;
1420
- const apiUrl = isAnthropic ? "https://api.anthropic.com/v1/messages" : `${providerConfig.baseUrl}/chat/completions`;
1421
- const prompt = `Analyze these SEO audit results and provide prioritized recommendations:
1541
+ async function firstServerModel(baseUrl, apiKey) {
1542
+ try {
1543
+ const res = await fetchWithTimeout(`${baseUrl}/models`, {
1544
+ headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {}
1545
+ }, 5e3);
1546
+ if (!res.ok) return {};
1547
+ const data = await res.json();
1548
+ return { model: data.data?.find((m) => m.id)?.id };
1549
+ } catch {
1550
+ return { unreachable: true };
1551
+ }
1552
+ }
1553
+ function noProviderHints() {
1554
+ return [
1555
+ `Set one of ${AI_ENV_KEYS.join(", ")}, or pass --ai-key`,
1556
+ "Or run a local model: start Ollama (https://ollama.com) and `ollama pull llama3.2` - it is picked up automatically",
1557
+ "Or point at any OpenAI-compatible server: --ai-base-url http://localhost:8080/v1 --ai-model <name>"
1558
+ ];
1559
+ }
1560
+ async function resolveAIProvider(opts, env = process.env, probe = probeOllama) {
1561
+ let provider = opts.aiProvider?.trim().toLowerCase();
1562
+ let notice;
1563
+ let ollamaModels = null;
1564
+ if (provider && !AI_PROVIDERS[provider]) {
1565
+ return {
1566
+ ok: false,
1567
+ error: `Unknown AI provider "${opts.aiProvider}"`,
1568
+ hints: [`Choose one of: ${AI_PROVIDER_IDS.join(", ")}`]
1569
+ };
1570
+ }
1571
+ if (!provider) {
1572
+ if (opts.aiBaseUrl) {
1573
+ provider = "custom";
1574
+ } else if (opts.aiKey) {
1575
+ provider = providerFromKey(opts.aiKey) ?? "openai";
1576
+ } else {
1577
+ provider = DETECT_ORDER.find((id) => AI_PROVIDERS[id].envKeys.some((k) => env[k]));
1578
+ }
1579
+ }
1580
+ if (!provider) {
1581
+ const base = ollamaBaseUrl(env);
1582
+ ollamaModels = await probe(base);
1583
+ if (ollamaModels === null) {
1584
+ return { ok: false, error: "AI analysis requested but no AI provider is configured", hints: noProviderHints() };
1585
+ }
1586
+ provider = "ollama";
1587
+ notice = `No AI key set - using local Ollama at ${base}`;
1588
+ }
1589
+ const preset = AI_PROVIDERS[provider];
1590
+ const baseUrl = (opts.aiBaseUrl || (provider === "ollama" ? ollamaBaseUrl(env) : preset.baseUrl) || "").replace(/\/+$/, "");
1591
+ if (!baseUrl) {
1592
+ return {
1593
+ ok: false,
1594
+ error: "The custom provider needs --ai-base-url",
1595
+ hints: ["e.g. --ai-base-url http://localhost:8080/v1 --ai-model <name>"]
1596
+ };
1597
+ }
1598
+ try {
1599
+ new URL(baseUrl);
1600
+ } catch {
1601
+ return { ok: false, error: `Invalid --ai-base-url "${opts.aiBaseUrl}"`, hints: ["Use a full URL such as http://localhost:11434/v1"] };
1602
+ }
1603
+ const apiKey = opts.aiKey || preset.envKeys.map((k) => env[k]).find(Boolean) || void 0;
1604
+ if (!apiKey && !preset.local) {
1605
+ return {
1606
+ ok: false,
1607
+ error: `No API key for ${preset.label}`,
1608
+ hints: [`Set ${preset.envKeys.join(" or ")}, or pass --ai-key`]
1609
+ };
1610
+ }
1611
+ let model = opts.aiModel || preset.model;
1612
+ if (!model && provider === "ollama") {
1613
+ ollamaModels ??= await probe(baseUrl);
1614
+ if (ollamaModels === null) {
1615
+ return { ok: false, error: `Could not reach Ollama at ${baseUrl}`, hints: ["Start it with `ollama serve`, or set OLLAMA_HOST"] };
1616
+ }
1617
+ model = ollamaModels.find((name) => !/embed/i.test(name));
1618
+ if (!model) {
1619
+ return { ok: false, error: "Ollama is running but has no chat models installed", hints: ["Install one, e.g. `ollama pull llama3.2`, or pass --ai-model"] };
1620
+ }
1621
+ }
1622
+ if (!model) {
1623
+ const found = await firstServerModel(baseUrl, apiKey);
1624
+ if (found.unreachable) {
1625
+ return { ok: false, error: `Could not reach ${preset.label} at ${baseUrl}`, hints: ["Is the server running? Or pass a different --ai-base-url"] };
1626
+ }
1627
+ model = found.model;
1628
+ if (!model) {
1629
+ return { ok: false, error: `${preset.label} at ${baseUrl} did not report a loaded model`, hints: ["Pass --ai-model <name>"] };
1630
+ }
1631
+ }
1632
+ if (notice) notice += ` (model ${model})`;
1633
+ return {
1634
+ ok: true,
1635
+ ai: {
1636
+ provider,
1637
+ label: preset.label,
1638
+ baseUrl,
1639
+ model,
1640
+ apiKey,
1641
+ // A base-URL override may point anywhere; only trust JSON mode on the real endpoint.
1642
+ jsonMode: !!preset.jsonMode && !opts.aiBaseUrl,
1643
+ anthropic: !!preset.anthropic,
1644
+ local: !!preset.local,
1645
+ notice
1646
+ }
1647
+ };
1648
+ }
1649
+ function tryParse(text) {
1650
+ try {
1651
+ return JSON.parse(text);
1652
+ } catch {
1653
+ try {
1654
+ return JSON.parse(text.replace(/,\s*([}\]])/g, "$1"));
1655
+ } catch {
1656
+ return void 0;
1657
+ }
1658
+ }
1659
+ }
1660
+ function balancedObjectAt(text, start) {
1661
+ let depth = 0;
1662
+ let inString = false;
1663
+ let escaped = false;
1664
+ for (let i = start; i < text.length; i++) {
1665
+ const ch = text[i];
1666
+ if (inString) {
1667
+ if (escaped) escaped = false;
1668
+ else if (ch === "\\") escaped = true;
1669
+ else if (ch === '"') inString = false;
1670
+ continue;
1671
+ }
1672
+ if (ch === '"') inString = true;
1673
+ else if (ch === "{") depth++;
1674
+ else if (ch === "}") {
1675
+ depth--;
1676
+ if (depth === 0) return text.slice(start, i + 1);
1677
+ }
1678
+ }
1679
+ return null;
1680
+ }
1681
+ function extractJsonObject(content) {
1682
+ const text = content.replace(/<think>[\s\S]*?<\/think>/gi, "").trim();
1683
+ const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
1684
+ const sources = fenced ? [fenced[1], text] : [text];
1685
+ for (const source of sources) {
1686
+ let from = source.indexOf("{");
1687
+ let attempts = 0;
1688
+ while (from !== -1 && attempts++ < 50) {
1689
+ const candidate = balancedObjectAt(source, from);
1690
+ if (candidate) {
1691
+ const parsed = tryParse(candidate);
1692
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
1693
+ }
1694
+ from = source.indexOf("{", from + 1);
1695
+ }
1696
+ }
1697
+ return null;
1698
+ }
1699
+ function normalizeAnalysis(raw) {
1700
+ const score = Number(raw.contentScore);
1701
+ const suggestions = Array.isArray(raw.suggestions) ? raw.suggestions.map((s) => typeof s === "string" ? s : JSON.stringify(s)).filter(Boolean) : [];
1702
+ const prioritizedFixes = Array.isArray(raw.prioritizedFixes) ? raw.prioritizedFixes.filter((f) => !!f && typeof f === "object" && "code" in f).map((f) => ({
1703
+ code: String(f.code),
1704
+ priority: Number(f.priority) || 3,
1705
+ rationale: String(f.rationale ?? "")
1706
+ })) : [];
1707
+ if (!Number.isFinite(score) && suggestions.length === 0 && prioritizedFixes.length === 0) return null;
1708
+ return {
1709
+ contentScore: Number.isFinite(score) ? Math.max(0, Math.min(100, Math.round(score))) : 0,
1710
+ suggestions,
1711
+ prioritizedFixes
1712
+ };
1713
+ }
1714
+ var SEVERITY_ORDER = { critical: 0, error: 1, warning: 2, notice: 3, info: 3 };
1715
+ var MAX_ISSUES_IN_PROMPT = 60;
1716
+ function buildPrompt(url, issues) {
1717
+ const ranked = [...issues].sort((a, b) => (SEVERITY_ORDER[a.severity] ?? 4) - (SEVERITY_ORDER[b.severity] ?? 4));
1718
+ const shown = ranked.slice(0, MAX_ISSUES_IN_PROMPT);
1719
+ const more = ranked.length > shown.length ? `
1720
+ (${ranked.length - shown.length} lower-severity issues omitted)` : "";
1721
+ return `Analyze these SEO audit results and provide prioritized recommendations:
1422
1722
 
1423
1723
  URL: ${url}
1424
1724
 
1425
1725
  Issues found:
1426
- ${issues.map((i) => `- [${i.severity.toUpperCase()}] ${i.code}: ${i.title}`).join("\n")}
1726
+ ${shown.map((i) => `- [${i.severity.toUpperCase()}] ${i.code}: ${i.title}`).join("\n")}${more}
1427
1727
 
1428
- Return JSON with:
1728
+ Respond with only this JSON object - no prose, no code fences:
1429
1729
  {
1430
1730
  "contentScore": <0-100 overall content quality>,
1431
- "suggestions": [<3-5 high-impact suggestions>],
1731
+ "suggestions": [<3-5 high-impact suggestions, as strings>],
1432
1732
  "prioritizedFixes": [
1433
1733
  {"code": "<issue code>", "priority": <1-5>, "rationale": "<why fix this first>"}
1434
1734
  ]
1435
1735
  }`;
1436
- try {
1437
- const headers = {
1438
- "Content-Type": "application/json"
1736
+ }
1737
+ async function runAIAnalysis(url, issues, ai) {
1738
+ const prompt = buildPrompt(url, issues);
1739
+ const system = "You are an SEO expert. Respond with valid JSON only.";
1740
+ const timeoutMs = ai.local ? 3e5 : 9e4;
1741
+ const maxTokens = 4096;
1742
+ const headers = { "Content-Type": "application/json" };
1743
+ let endpoint;
1744
+ let body;
1745
+ if (ai.anthropic) {
1746
+ endpoint = `${ai.baseUrl}/messages`;
1747
+ headers["x-api-key"] = ai.apiKey || "";
1748
+ headers["anthropic-version"] = "2023-06-01";
1749
+ body = { model: ai.model, max_tokens: maxTokens, system, messages: [{ role: "user", content: prompt }] };
1750
+ } else {
1751
+ endpoint = `${ai.baseUrl}/chat/completions`;
1752
+ if (ai.apiKey) headers["Authorization"] = `Bearer ${ai.apiKey}`;
1753
+ if (ai.provider === "openrouter") {
1754
+ headers["HTTP-Referer"] = "https://rankcli.dev";
1755
+ headers["X-Title"] = "RankCLI";
1756
+ }
1757
+ body = {
1758
+ model: ai.model,
1759
+ messages: [
1760
+ { role: "system", content: system },
1761
+ { role: "user", content: prompt }
1762
+ ],
1763
+ // OpenAI's GPT-5.6 rejects max_tokens (and temperature); everyone else expects max_tokens.
1764
+ ...ai.provider === "openai" && !ai.local ? { max_completion_tokens: maxTokens } : { max_tokens: maxTokens },
1765
+ ...ai.jsonMode ? { response_format: { type: "json_object" } } : {}
1439
1766
  };
1440
- let body;
1441
- if (isAnthropic) {
1442
- headers["x-api-key"] = apiKey;
1443
- headers["anthropic-version"] = "2023-06-01";
1444
- body = JSON.stringify({
1445
- model: ANTHROPIC_FAST,
1446
- max_tokens: 1500,
1447
- messages: [{ role: "user", content: prompt }]
1448
- });
1449
- } else {
1450
- headers["Authorization"] = `Bearer ${apiKey}`;
1451
- if (provider === "openrouter") {
1452
- headers["HTTP-Referer"] = "https://rankcli.dev";
1453
- headers["X-Title"] = "RankCLI";
1454
- }
1455
- body = JSON.stringify({
1456
- model: providerConfig.model,
1457
- messages: [
1458
- { role: "system", content: "You are an SEO expert. Respond with valid JSON only." },
1459
- { role: "user", content: prompt }
1460
- ],
1461
- // OpenAI's GPT-5.6 rejects max_tokens; other OpenAI-compatible servers still expect it.
1462
- ...provider === "openai" ? { max_completion_tokens: 1500 } : { max_tokens: 1500 },
1463
- // json_object mode is an OpenAI-specific extension - Groq supports
1464
- // it too, but not every OpenAI-compatible server does, so only
1465
- // request it for providers confirmed to support it.
1466
- ...provider === "openai" || provider === "groq" ? { response_format: { type: "json_object" } } : {}
1467
- });
1767
+ }
1768
+ let response;
1769
+ try {
1770
+ response = await fetchWithTimeout(endpoint, { method: "POST", headers, body: JSON.stringify(body) }, timeoutMs);
1771
+ } catch (error) {
1772
+ const reason = error instanceof Error && error.name === "AbortError" ? `timed out after ${Math.round(timeoutMs / 1e3)}s` : error instanceof Error ? error.cause instanceof Error ? error.cause.message : error.message : String(error);
1773
+ return { error: `${ai.label} request failed: ${reason}` };
1774
+ }
1775
+ if (!response.ok) {
1776
+ const detail = (await response.text().catch(() => "")).replace(/\s+/g, " ").slice(0, 200);
1777
+ return { error: `${ai.label} returned HTTP ${response.status}${detail ? `: ${detail}` : ""}` };
1778
+ }
1779
+ let data;
1780
+ try {
1781
+ data = await response.json();
1782
+ } catch {
1783
+ return { error: `${ai.label} returned a non-JSON response` };
1784
+ }
1785
+ const content = ai.anthropic ? data.content?.filter((b) => typeof b.text === "string").map((b) => b.text).join("") : data.choices?.[0]?.message?.content;
1786
+ if (!content) {
1787
+ return { error: `${ai.label} returned no content (a reasoning model may have used its whole token budget - try another --ai-model)` };
1788
+ }
1789
+ const parsed = extractJsonObject(content);
1790
+ const analysis = parsed ? normalizeAnalysis(parsed) : null;
1791
+ if (!analysis) {
1792
+ return { error: `${ai.label} (${ai.model}) did not return the requested JSON` };
1793
+ }
1794
+ return { analysis };
1795
+ }
1796
+
1797
+ // src/commands/audit.ts
1798
+ var { detectFramework: detectFramework2, getFrameworkDisplayName } = import_agent_runtime2.frameworks;
1799
+ var EXIT_CODES = {
1800
+ /** Audit completed and no --fail-on / --min-score gate tripped. */
1801
+ OK: 0,
1802
+ /** A --fail-on or --min-score gate tripped. */
1803
+ GATE_FAILED: 1,
1804
+ /** Bad invocation: missing/invalid --url or an invalid flag value. */
1805
+ USAGE: 2,
1806
+ /** No real audit: the site was unreachable, the URL answered 4xx/5xx, no page could be analyzed, or an internal error. */
1807
+ AUDIT_FAILED: 3
1808
+ };
1809
+ var FAIL_ON_LEVELS = ["error", "warning", "notice"];
1810
+ var SEVERITY_RANK = { critical: 4, error: 3, warning: 2, notice: 1, info: 1 };
1811
+ function evaluateGate(issues, score, failOn, minScore) {
1812
+ const failures = [];
1813
+ if (failOn) {
1814
+ const threshold = SEVERITY_RANK[failOn];
1815
+ const hits = issues.filter((i) => (SEVERITY_RANK[i.severity] ?? 0) >= threshold);
1816
+ if (hits.length > 0) {
1817
+ const counts = /* @__PURE__ */ new Map();
1818
+ for (const i of hits) counts.set(i.severity, (counts.get(i.severity) || 0) + 1);
1819
+ const breakdown = [...counts.entries()].sort((a, b) => (SEVERITY_RANK[b[0]] ?? 0) - (SEVERITY_RANK[a[0]] ?? 0)).map(([severity, n]) => `${n} ${severity}`).join(", ");
1820
+ failures.push(`${hits.length} issue${hits.length === 1 ? "" : "s"} at or above --fail-on ${failOn} (${breakdown})`);
1821
+ }
1822
+ }
1823
+ if (minScore !== void 0 && score < minScore) {
1824
+ failures.push(`score ${score} is below --min-score ${minScore}`);
1825
+ }
1826
+ return { failOn, minScore, passed: failures.length === 0, failures };
1827
+ }
1828
+ function parseGateOptions(options) {
1829
+ let failOn;
1830
+ let minScore;
1831
+ if (options.failOn !== void 0) {
1832
+ const level = String(options.failOn).trim().toLowerCase();
1833
+ if (!FAIL_ON_LEVELS.includes(level)) {
1834
+ return { error: `Invalid --fail-on "${options.failOn}" (use ${FAIL_ON_LEVELS.join(", ")})` };
1468
1835
  }
1469
- const response = await fetch(apiUrl, { method: "POST", headers, body });
1836
+ failOn = level;
1837
+ }
1838
+ if (options.minScore !== void 0) {
1839
+ const raw = String(options.minScore).trim();
1840
+ const n = Number(raw);
1841
+ if (raw === "" || !Number.isFinite(n) || n < 0 || n > 100) {
1842
+ return { error: `Invalid --min-score "${options.minScore}" (use a number from 0 to 100)` };
1843
+ }
1844
+ minScore = n;
1845
+ }
1846
+ return { failOn, minScore };
1847
+ }
1848
+ async function fetchHtml(url, timeout = 1e4) {
1849
+ try {
1850
+ const controller = new AbortController();
1851
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
1852
+ const response = await fetch(url, {
1853
+ signal: controller.signal,
1854
+ headers: {
1855
+ "User-Agent": "RankCLI-Bot/1.0 (+https://rankcli.dev/bot)",
1856
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
1857
+ }
1858
+ });
1859
+ clearTimeout(timeoutId);
1470
1860
  if (!response.ok) {
1471
- console.error(import_chalk4.default.yellow(`AI analysis failed: ${response.status}`));
1472
1861
  return null;
1473
1862
  }
1474
- const data = await response.json();
1475
- const content = isAnthropic ? data.content?.[0]?.text : data.choices?.[0]?.message?.content;
1476
- if (!content) return null;
1477
- const jsonMatch = content.match(/\{[\s\S]*\}/);
1478
- if (!jsonMatch) return null;
1479
- return JSON.parse(jsonMatch[0]);
1480
- } catch (error) {
1481
- console.error(import_chalk4.default.yellow(`AI analysis error: ${error}`));
1863
+ return await response.text();
1864
+ } catch {
1482
1865
  return null;
1483
1866
  }
1484
1867
  }
@@ -1490,6 +1873,9 @@ function mergeIssues(issueArrays) {
1490
1873
  if (existing) {
1491
1874
  const urls = /* @__PURE__ */ new Set([...existing.affectedUrls || [], ...issue.affectedUrls || []]);
1492
1875
  existing.affectedUrls = Array.from(urls);
1876
+ if ((SEVERITY_RANK[issue.severity] ?? 0) > (SEVERITY_RANK[existing.severity] ?? 0)) {
1877
+ existing.severity = issue.severity;
1878
+ }
1493
1879
  } else {
1494
1880
  issueMap.set(issue.code, { ...issue });
1495
1881
  }
@@ -1535,9 +1921,9 @@ async function syncToCloud(url, report, mergedIssues, pagesAudited, aiAnalysis,
1535
1921
  const data = await res.json();
1536
1922
  if (!res.ok) {
1537
1923
  if (data.error === "project_limit_reached") {
1538
- console.log(import_chalk4.default.yellow(`
1924
+ console.error(import_chalk4.default.yellow(`
1539
1925
  \u26A0\uFE0F Project limit reached (${data.count}/${data.limit})`));
1540
- console.log(import_chalk4.default.dim(` Upgrade at: ${WEB_APP_URL}/account`));
1926
+ console.error(import_chalk4.default.dim(` Upgrade at: ${WEB_APP_URL}/account`));
1541
1927
  } else {
1542
1928
  console.error(import_chalk4.default.dim(data.error || "Cloud sync failed"));
1543
1929
  }
@@ -1553,60 +1939,70 @@ async function syncToCloud(url, report, mergedIssues, pagesAudited, aiAnalysis,
1553
1939
  return null;
1554
1940
  }
1555
1941
  }
1942
+ function printUsage(loggedIn) {
1943
+ console.error("\n\u26A0\uFE0F Please provide a URL to audit:");
1944
+ console.error(" rankcli audit --url https://your-site.com");
1945
+ console.error('\nRun "rankcli audit --help" for every option, exit codes, and AI providers.');
1946
+ if (!loggedIn) {
1947
+ console.error(import_chalk4.default.dim("\nTip: Log in to sync results to the web dashboard:"));
1948
+ console.error(import_chalk4.default.dim(" rankcli login"));
1949
+ }
1950
+ console.error("");
1951
+ }
1556
1952
  async function audit(options) {
1557
1953
  const url = options.url;
1558
- const maxPages = parseInt(options.maxPages || "5", 10);
1559
- const enableAI = options.ai || false;
1560
- const detectedProvider = options.aiProvider || (process.env.OPENROUTER_API_KEY ? "openrouter" : process.env.GROQ_API_KEY ? "groq" : process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY ? "gemini" : process.env.ANTHROPIC_API_KEY ? "anthropic" : "openai");
1561
- const aiProvider = detectedProvider;
1562
- const providerEnvKey = {
1563
- openai: process.env.OPENAI_API_KEY,
1564
- anthropic: process.env.ANTHROPIC_API_KEY,
1565
- openrouter: process.env.OPENROUTER_API_KEY,
1566
- groq: process.env.GROQ_API_KEY,
1567
- gemini: process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY
1568
- };
1569
- const aiKey = options.aiKey || providerEnvKey[aiProvider] || "";
1570
- const loggedIn = isLoggedIn();
1571
- const { planId } = getUserInfo();
1572
- const tierId = planId && planId in TIER_LIMITS ? planId : loggedIn ? "free" : "free_anon";
1573
- const checksLimit = TIER_LIMITS[tierId].checks;
1574
- let premiumChecks;
1575
- if (checksLimit > TIER_LIMITS.free.checks) {
1576
- const passphrase = getDecryptPassphrase();
1577
- if (passphrase) {
1578
- try {
1579
- await initEngine(passphrase);
1580
- premiumChecks = await getPremiumChecks();
1581
- } catch (error) {
1582
- console.error(import_chalk4.default.dim(`(premium checks unavailable: ${error instanceof Error ? error.message : error})`));
1583
- }
1954
+ const json = options.output === "json";
1955
+ const fail = (code, message, extra = {}) => {
1956
+ if (json) {
1957
+ console.log(JSON.stringify({ url: url ?? null, error: message, exitCode: code, ...extra }, null, 2));
1958
+ } else {
1959
+ console.error(`
1960
+ \u274C ${message}
1961
+ `);
1584
1962
  }
1585
- }
1963
+ return code;
1964
+ };
1965
+ const gate = parseGateOptions(options);
1966
+ if (gate.error) return fail(EXIT_CODES.USAGE, gate.error);
1586
1967
  if (!url) {
1587
- console.log("\n\u26A0\uFE0F Please provide a URL to audit:");
1588
- console.log(" rankcli audit --url https://your-site.com");
1589
- console.log("\nOptions:");
1590
- console.log(" --max-pages <n> Max pages to crawl (default: 5)");
1591
- console.log(" --ai Enable AI analysis");
1592
- console.log(" --ai-provider <p> AI provider: openai, anthropic, openrouter, groq, or gemini (auto-detected from env if omitted)");
1593
- console.log(" --ai-key <key> API key (or set OPENAI_API_KEY, ANTHROPIC_API_KEY, OPENROUTER_API_KEY, GROQ_API_KEY, or GEMINI_API_KEY)");
1594
- console.log(" --output json Output as JSON");
1595
- if (!loggedIn) {
1596
- console.log(import_chalk4.default.dim("\nTip: Log in to sync results to the web dashboard:"));
1597
- console.log(import_chalk4.default.dim(" rankcli login"));
1598
- }
1599
- console.log("");
1600
- return;
1968
+ if (json) return fail(EXIT_CODES.USAGE, "No URL given - use --url https://your-site.com");
1969
+ printUsage(isLoggedIn());
1970
+ return EXIT_CODES.USAGE;
1601
1971
  }
1602
1972
  try {
1603
- new URL(url);
1973
+ const parsed = new URL(url);
1974
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("not http(s)");
1604
1975
  } catch {
1605
- console.log("\n\u274C Invalid URL format. Please provide a valid URL.\n");
1606
- return;
1976
+ return fail(EXIT_CODES.USAGE, `Invalid URL "${url}" - use a full http(s) URL such as https://example.com`);
1607
1977
  }
1608
- const startTime = Date.now();
1978
+ if (options.ai && options.aiProvider && !AI_PROVIDERS[options.aiProvider.trim().toLowerCase()]) {
1979
+ return fail(EXIT_CODES.USAGE, `Unknown --ai-provider "${options.aiProvider}" (use ${AI_PROVIDER_IDS.join(", ")})`);
1980
+ }
1981
+ const maxPages = Math.max(1, parseInt(options.maxPages || "5", 10) || 5);
1609
1982
  try {
1983
+ const loggedIn = isLoggedIn();
1984
+ const keyPlan = await ensureApiKeyPlan();
1985
+ if (keyPlan?.status === "invalid") {
1986
+ console.error(import_chalk4.default.yellow(`\u26A0\uFE0F RankCLI API key rejected (${keyPlan.error}) - auditing on the Free tier.`));
1987
+ } else if (keyPlan?.status === "unavailable") {
1988
+ console.error(import_chalk4.default.dim(`(could not verify your RankCLI plan: ${keyPlan.error})`));
1989
+ }
1990
+ const { planId } = getUserInfo();
1991
+ const tierId = tierFromPlanId(planId) ?? (loggedIn ? "free" : "free_anon");
1992
+ const checksLimit = TIER_LIMITS[tierId].checks;
1993
+ let premiumChecks;
1994
+ if (checksLimit > TIER_LIMITS.free.checks) {
1995
+ const passphrase = getDecryptPassphrase();
1996
+ if (passphrase) {
1997
+ try {
1998
+ await initEngine(passphrase);
1999
+ premiumChecks = await getPremiumChecks();
2000
+ } catch (error) {
2001
+ console.error(import_chalk4.default.dim(`(premium checks unavailable: ${error instanceof Error ? error.message : error})`));
2002
+ }
2003
+ }
2004
+ }
2005
+ const startTime = Date.now();
1610
2006
  const discoverySpinner = (0, import_ora2.default)("Discovering pages...").start();
1611
2007
  let pagesToAudit = [url];
1612
2008
  const homepageHtml = await fetchHtml(url, 1e4);
@@ -1626,6 +2022,7 @@ async function audit(options) {
1626
2022
  try {
1627
2023
  const report = await (0, import_agent_runtime2.runFullAudit)({
1628
2024
  url: pageUrl,
2025
+ auditRootUrl: url,
1629
2026
  maxPages: 1,
1630
2027
  checkBrokenLinks: options.checkLinks || false,
1631
2028
  // Bundled with --check-links: both mean "verify actual link
@@ -1662,40 +2059,68 @@ async function audit(options) {
1662
2059
  }
1663
2060
  if (!primaryReport) {
1664
2061
  auditSpinner.fail("Audit failed - could not analyze any pages");
1665
- return;
2062
+ return fail(EXIT_CODES.AUDIT_FAILED, `Audit failed - could not analyze ${url}`);
2063
+ }
2064
+ const pageFailure = primaryReport.issues.find((i) => i.code === "FETCH_ERROR" || i.code === "HTTP_ERROR_STATUS");
2065
+ if (pageFailure) {
2066
+ auditSpinner.fail(`Could not audit ${url}: ${pageFailure.title}`);
2067
+ } else {
2068
+ auditSpinner.succeed(`Audited ${successCount}/${pagesToAudit.length} pages in ${parallelTime}ms`);
1666
2069
  }
1667
- auditSpinner.succeed(`Audited ${successCount}/${pagesToAudit.length} pages in ${parallelTime}ms`);
1668
2070
  const mergedIssues = mergeIssues(allIssues);
2071
+ const score = primaryReport.healthScore?.overall || 0;
1669
2072
  let aiAnalysis = null;
1670
- if (enableAI && aiKey) {
1671
- const aiSpinner = (0, import_ora2.default)(`Running AI analysis (${aiProvider})...`).start();
1672
- aiAnalysis = await runAIAnalysis(url, mergedIssues, aiProvider, aiKey);
1673
- if (aiAnalysis) {
1674
- aiSpinner.succeed("AI analysis complete");
2073
+ let aiError;
2074
+ if (options.ai && !pageFailure) {
2075
+ const resolution = await resolveAIProvider({
2076
+ aiProvider: options.aiProvider,
2077
+ aiKey: options.aiKey,
2078
+ aiModel: options.aiModel,
2079
+ aiBaseUrl: options.aiBaseUrl
2080
+ });
2081
+ if (!resolution.ok) {
2082
+ aiError = resolution.error;
2083
+ console.error(import_chalk4.default.yellow(`
2084
+ \u26A0\uFE0F ${resolution.error}`));
2085
+ for (const hint of resolution.hints) console.error(import_chalk4.default.dim(` ${hint}`));
1675
2086
  } else {
1676
- aiSpinner.warn("AI analysis failed or returned no results");
2087
+ const { ai } = resolution;
2088
+ if (ai.notice) console.error(import_chalk4.default.dim(ai.notice));
2089
+ const aiSpinner = (0, import_ora2.default)(`Running AI analysis (${ai.label}, ${ai.model})...`).start();
2090
+ const result = await runAIAnalysis(url, mergedIssues, ai);
2091
+ if ("analysis" in result) {
2092
+ aiAnalysis = result.analysis;
2093
+ aiSpinner.succeed("AI analysis complete");
2094
+ } else {
2095
+ aiError = result.error;
2096
+ aiSpinner.warn(`AI analysis failed: ${result.error}`);
2097
+ }
1677
2098
  }
1678
- } else if (enableAI && !aiKey) {
1679
- console.log(import_chalk4.default.yellow("\n\u26A0\uFE0F AI analysis requested but no API key provided"));
1680
- console.log(import_chalk4.default.dim(" Set OPENAI_API_KEY, ANTHROPIC_API_KEY, OPENROUTER_API_KEY, GROQ_API_KEY, or GEMINI_API_KEY, or use --ai-key"));
1681
2099
  }
2100
+ const gateResult = evaluateGate(mergedIssues, score, gate.failOn, gate.minScore);
2101
+ const gated = gate.failOn !== void 0 || gate.minScore !== void 0;
2102
+ const exitCode = pageFailure ? EXIT_CODES.AUDIT_FAILED : gateResult.passed ? EXIT_CODES.OK : EXIT_CODES.GATE_FAILED;
1682
2103
  const totalTime = Date.now() - startTime;
1683
2104
  const frameworkName = detectedFramework.framework !== "unknown" ? getFrameworkDisplayName(detectedFramework.framework) : null;
1684
2105
  const actualChecksRun = (primaryReport.summary?.passed || 0) + (primaryReport.summary?.errors || 0) + (primaryReport.summary?.warnings || 0) || 200;
1685
- if (options.output === "json") {
2106
+ if (json) {
1686
2107
  let syncResult = null;
1687
2108
  if (loggedIn) {
1688
2109
  syncResult = await syncToCloud(url, primaryReport, mergedIssues, successCount, aiAnalysis, totalTime, actualChecksRun);
1689
2110
  }
1690
2111
  const jsonOutput = {
1691
2112
  url,
1692
- score: primaryReport.healthScore?.overall || 0,
2113
+ score,
1693
2114
  pagesAudited: successCount,
1694
2115
  totalTime,
1695
2116
  framework: frameworkName,
1696
2117
  issues: mergedIssues,
1697
2118
  healthScores: primaryReport.healthScore,
1698
2119
  aiAnalysis,
2120
+ ...aiError && { aiError },
2121
+ ...pageFailure && { error: pageFailure.title },
2122
+ ...gated && { gate: gateResult },
2123
+ exitCode,
1699
2124
  // Include cloud sync info if synced
1700
2125
  ...syncResult && {
1701
2126
  projectId: syncResult.projectId,
@@ -1704,11 +2129,13 @@ async function audit(options) {
1704
2129
  }
1705
2130
  };
1706
2131
  console.log(JSON.stringify(jsonOutput, null, 2));
2132
+ if (!gateResult.passed) {
2133
+ for (const failure of gateResult.failures) console.error(`Gate failed: ${failure}`);
2134
+ }
1707
2135
  } else {
1708
2136
  console.log("\n" + import_chalk4.default.bold("\u2550".repeat(60)));
1709
2137
  console.log(import_chalk4.default.bold.cyan(` SEO AUDIT REPORT: ${url}`));
1710
2138
  console.log(import_chalk4.default.bold("\u2550".repeat(60)));
1711
- const score = primaryReport.healthScore?.overall || 0;
1712
2139
  const scoreColor = score >= 80 ? import_chalk4.default.green : score >= 60 ? import_chalk4.default.yellow : import_chalk4.default.red;
1713
2140
  console.log(`
1714
2141
  ${import_chalk4.default.bold("Score:")} ${scoreColor.bold(score + "/100")}`);
@@ -1735,9 +2162,9 @@ async function audit(options) {
1735
2162
  console.log(` Local SEO: ${bar(hs.localSeo || 0)} ${hs.localSeo || 0}%`);
1736
2163
  console.log(` AI Readiness: ${bar(hs.aiReadiness || 0)} ${hs.aiReadiness || 0}%`);
1737
2164
  }
1738
- const errors = mergedIssues.filter((i) => i.severity === "error");
2165
+ const errors = mergedIssues.filter((i) => i.severity === "error" || i.severity === "critical");
1739
2166
  const warnings = mergedIssues.filter((i) => i.severity === "warning");
1740
- const notices = mergedIssues.filter((i) => i.severity === "notice");
2167
+ const notices = mergedIssues.filter((i) => i.severity === "notice" || i.severity === "info");
1741
2168
  console.log("\n" + import_chalk4.default.bold(" Issues by Severity:"));
1742
2169
  console.log(` ${import_chalk4.default.red("\u25CF")} Errors: ${errors.length}`);
1743
2170
  console.log(` ${import_chalk4.default.yellow("\u25CF")} Warnings: ${warnings.length}`);
@@ -1745,7 +2172,7 @@ async function audit(options) {
1745
2172
  console.log("\n" + import_chalk4.default.bold(" Top Issues:"));
1746
2173
  const topIssues = [...errors, ...warnings, ...notices].slice(0, 10);
1747
2174
  topIssues.forEach((issue, i) => {
1748
- const icon = issue.severity === "error" ? import_chalk4.default.red("\u2717") : issue.severity === "warning" ? import_chalk4.default.yellow("!") : import_chalk4.default.blue("i");
2175
+ const icon = issue.severity === "error" || issue.severity === "critical" ? import_chalk4.default.red("\u2717") : issue.severity === "warning" ? import_chalk4.default.yellow("!") : import_chalk4.default.blue("i");
1749
2176
  console.log(` ${i + 1}. ${icon} ${issue.title}`);
1750
2177
  if (issue.affectedUrls && issue.affectedUrls.length > 1) {
1751
2178
  console.log(import_chalk4.default.dim(` Affects ${issue.affectedUrls.length} pages`));
@@ -1766,11 +2193,16 @@ async function audit(options) {
1766
2193
  });
1767
2194
  }
1768
2195
  }
2196
+ if (gated) {
2197
+ console.log("");
2198
+ if (gateResult.passed) {
2199
+ console.log(import_chalk4.default.green(` \u2713 CI gate passed${gate.failOn ? ` (--fail-on ${gate.failOn})` : ""}${gate.minScore !== void 0 ? ` (--min-score ${gate.minScore})` : ""}`));
2200
+ } else {
2201
+ for (const failure of gateResult.failures) console.log(import_chalk4.default.red(` \u2717 CI gate failed: ${failure}`));
2202
+ }
2203
+ }
1769
2204
  console.log("\n" + import_chalk4.default.bold("\u2550".repeat(60)));
1770
- const userInfo = getUserInfo();
1771
- const tier = userInfo.planName?.toLowerCase() || "free";
1772
- const isFreeTier = !loggedIn || tier === "free";
1773
- const isSoloOrBelow = isFreeTier || tier === "solo";
2205
+ const isFreeTier = tierId === "free" || tierId === "free_anon";
1774
2206
  if (isFreeTier) {
1775
2207
  const checksRun = mergedIssues.length > 0 ? loggedIn ? 100 : 50 : 0;
1776
2208
  showUpsell("checks_limit", {
@@ -1796,13 +2228,46 @@ async function audit(options) {
1796
2228
  } else {
1797
2229
  showUpsell("free_audit_complete");
1798
2230
  }
2231
+ if (pageFailure) {
2232
+ console.error(import_chalk4.default.red(`
2233
+ \u274C ${pageFailure.title}: ${pageFailure.description}
2234
+ `));
2235
+ }
1799
2236
  }
2237
+ return exitCode;
1800
2238
  } catch (error) {
1801
- console.log(`
1802
- \u274C Audit failed: ${error instanceof Error ? error.message : "Unknown error"}
1803
- `);
2239
+ return fail(EXIT_CODES.AUDIT_FAILED, `Audit failed: ${error instanceof Error ? error.message : "Unknown error"}`);
1804
2240
  }
1805
2241
  }
2242
+ var AUDIT_HELP_TEXT = `
2243
+ Exit codes:
2244
+ 0 audit completed; no --fail-on / --min-score gate tripped
2245
+ 1 a --fail-on or --min-score gate tripped
2246
+ 2 bad invocation (missing/invalid --url, invalid flag value)
2247
+ 3 no real audit: site unreachable, the URL answered 4xx/5xx, no page
2248
+ could be analyzed, or an internal error
2249
+
2250
+ CI gating (both opt-in; without them any completed audit exits 0):
2251
+ --fail-on error fail on any error
2252
+ --fail-on warning fail on any warning or error
2253
+ --fail-on notice fail on any issue at all
2254
+ --min-score 80 fail if the overall score is below 80
2255
+
2256
+ AI analysis (--ai) runs on your own provider. Hosted: ${Object.entries(AI_PROVIDERS).filter(([, p]) => !p.local).map(([id]) => id).join(", ")}.
2257
+ Picked automatically from the first key found in:
2258
+ ${AI_ENV_KEYS.join(", ")}
2259
+ (OpenRouter first). No key at all? A running local Ollama is used.
2260
+ Local: --ai-provider ollama | lmstudio | llamacpp | vllm (no key needed;
2261
+ the loaded model is used unless you pass --ai-model), or any
2262
+ OpenAI-compatible server: --ai-base-url http://host:port/v1 --ai-model <name>
2263
+
2264
+ Examples:
2265
+ rankcli audit --url https://example.com
2266
+ rankcli audit --url https://example.com --fail-on error --min-score 80
2267
+ rankcli audit --url https://example.com -o json > report.json
2268
+ rankcli audit --url https://example.com --ai --ai-provider deepseek
2269
+ rankcli audit --url https://example.com --ai --ai-provider ollama --ai-model qwen3:8b
2270
+ `;
1806
2271
 
1807
2272
  // src/commands/apply.ts
1808
2273
  var import_fs3 = require("fs");
@@ -2074,6 +2539,29 @@ ${index + 1}. ${icon} ${fix2.issue.message}`);
2074
2539
  }
2075
2540
  }
2076
2541
  async function fix(options) {
2542
+ const result = await runFix(options);
2543
+ if (options.output === "json" && !result.json) {
2544
+ result.json = JSON.stringify({
2545
+ url: result.url ?? options.url ?? null,
2546
+ success: result.success,
2547
+ ...result.error && { error: result.error },
2548
+ dryRun: !!result.dryRun,
2549
+ fixes: (result.fixes || []).map((f) => ({
2550
+ code: f.issue.code,
2551
+ message: f.issue.message,
2552
+ file: f.file,
2553
+ applied: !!f.applied,
2554
+ skipped: f.skipped,
2555
+ skipReason: f.skipReason
2556
+ })),
2557
+ appliedCount: result.appliedCount ?? 0,
2558
+ skippedCount: result.skippedCount ?? 0,
2559
+ ...result.durationMs !== void 0 && { durationMs: result.durationMs }
2560
+ }, null, 2);
2561
+ }
2562
+ return result;
2563
+ }
2564
+ async function runFix(options) {
2077
2565
  const startTime = Date.now();
2078
2566
  const cwd = options.cwd || process.cwd();
2079
2567
  const url = options.url;
@@ -2098,8 +2586,9 @@ async function fix(options) {
2098
2586
  return { success: false, error };
2099
2587
  }
2100
2588
  const loggedIn = isLoggedIn();
2589
+ if (loggedIn) await ensureApiKeyPlan();
2101
2590
  const userInfo = getUserInfo();
2102
- const tier = userInfo.planName?.toLowerCase() || "free";
2591
+ const tier = tierFromPlanId(userInfo.planId) ?? tierFromPlanId(userInfo.planName) ?? "free";
2103
2592
  const hasAutoFix = loggedIn && hasFeature(tier, "auto_fix");
2104
2593
  if (!options.dryRun && !hasAutoFix) {
2105
2594
  if (options.output !== "json") {
@@ -3563,17 +4052,21 @@ async function testWebhook(channel, webhookUrl) {
3563
4052
  }
3564
4053
 
3565
4054
  // src/version.ts
3566
- var VERSION = "0.0.29";
4055
+ var VERSION = "0.0.30";
3567
4056
 
3568
4057
  // src/index.ts
3569
4058
  var program = new import_commander.Command();
3570
4059
  program.name("rankcli").description("RankCLI - Ship code, get ranked. SEO meets CI/CD.").version(VERSION);
3571
- program.command("login").description("Log in to your RankCLI account (opens your browser by default)").option("-e, --email <email>", "Log in with email/password instead of the browser").option("--no-browser", "Skip the browser and log in with email/password interactively").option("-t, --token <apiKey>", "API key for non-interactive authentication").action(login);
4060
+ program.command("login").description("Log in to your RankCLI account (opens your browser by default)").option("-e, --email <email>", "Log in with email/password instead of the browser").option("--no-browser", "Skip the browser and log in with email/password interactively").option("-t, --token <apiKey>", "API key for non-interactive authentication (validates it and resolves your plan)").action(async (options) => {
4061
+ process.exitCode = await login(options);
4062
+ });
3572
4063
  program.command("logout").description("Log out of your RankCLI account").action(logout);
3573
4064
  program.command("whoami").description("Show current logged in user").action(whoami);
3574
4065
  program.command("init").description("Set up RankCLI in your project (detects framework, git/GitHub, AI keys)").option("-y, --yes", "Skip prompts and use defaults").action(init);
3575
4066
  program.command("analyze").description("AI analyzes your codebase for SEO opportunities").option("-v, --verbose", "Show detailed output").action(analyze);
3576
- program.command("audit").description("Run a comprehensive SEO audit (200+ checks, Ahrefs-level)").option("-u, --url <url>", "URL to audit").option("-o, --output <format>", "Output format (json, console)", "console").option("--check-links", "Check for broken internal/external links (slower)").option("--max-pages <n>", "Max pages to crawl (default: 5)").option("--ai", "Enable AI analysis").option("--ai-provider <provider>", "AI provider: openai, anthropic, openrouter, groq, or gemini (auto-detected from env if omitted)").option("--ai-key <key>", "API key (or set OPENAI_API_KEY, ANTHROPIC_API_KEY, OPENROUTER_API_KEY, GROQ_API_KEY, or GEMINI_API_KEY)").action(audit);
4067
+ program.command("audit").description("Run a comprehensive SEO audit (200+ checks, Ahrefs-level)").option("-u, --url <url>", "URL to audit").option("-o, --output <format>", "Output format (json, console)", "console").option("--check-links", "Check for broken internal/external links (slower)").option("--max-pages <n>", "Max pages to crawl (default: 5)").option("--fail-on <level>", "CI gate: exit 1 if any issue is at or above this severity (error, warning, notice)").option("--min-score <n>", "CI gate: exit 1 if the overall score is below n (0-100)").option("--ai", "Enable AI analysis (your own key, or a local model)").option("--ai-provider <provider>", "AI provider (auto-detected if omitted): openrouter, openai, anthropic, groq, gemini, deepseek, mistral, cerebras, together, fireworks, ollama, lmstudio, llamacpp, vllm, custom").option("--ai-key <key>", "AI provider API key (or set the provider's env var, e.g. OPENROUTER_API_KEY)").option("--ai-model <model>", "Model id (defaults to a cheap model per provider, or the local server's loaded model)").option("--ai-base-url <url>", "OpenAI-compatible base URL, e.g. http://localhost:8080/v1 (implies --ai-provider custom)").addHelpText("after", AUDIT_HELP_TEXT).action(async (options) => {
4068
+ process.exitCode = await audit(options);
4069
+ });
3577
4070
  program.command("apply").description("Analyze a live URL and apply SEO fixes to your codebase").option("-u, --url <url>", "URL to analyze").option("--auto", "Auto-apply fixes without confirmation").option("--dry-run", "Preview changes without applying").action(apply);
3578
4071
  program.command("fix").description("Agentic SEO fixer - detect, fix, commit, and push").option("-u, --url <url>", "URL to analyze").option("--auto", "Auto-apply fixes without confirmation").option("--commit", "Commit changes with conventional commit message").option("--push", "Push changes after committing (requires --commit)").option("--dry-run", "Preview changes without applying").option("--only <codes>", "Fix only specific issue codes (comma-separated)", (val) => val.split(",")).option("-o, --output <format>", "Output format (text, json)", "text").action(async (options) => {
3579
4072
  const result = await fix({
@@ -3654,7 +4147,10 @@ program.command("report").description("Generate SEO performance report").option(
3654
4147
  program.command("watch").description("Continuous monitoring mode").action(() => {
3655
4148
  console.log("Watch mode coming soon...");
3656
4149
  });
3657
- program.command("setup").description("Set up SEO tracking (GA4, Search Console, GitHub Actions)").option("--ga4 <id>", "Google Analytics 4 Measurement ID (G-XXXXXXXXXX)").option("--gsc <code>", "Google Search Console verification code").option("--github-action", "Set up automated GitHub Action").option("--schedule <freq>", "GitHub Action schedule (daily, weekly, monthly)", "weekly").option("--all", "Interactive setup wizard").action(setup);
4150
+ program.command("setup").description("Set up SEO tracking (GA4, Search Console, GitHub Actions)").option("--ga4 <id>", "Google Analytics 4 Measurement ID (G-XXXXXXXXXX)").option("--gsc <code>", "Google Search Console verification code").option("--github-action", "Set up automated GitHub Action").option("--schedule <freq>", "GitHub Action schedule (daily, weekly, monthly)", "weekly").option("--all", "Interactive setup wizard").option("--url <url>", "Site URL the GitHub Action audits").option("--sync", "GitHub Action: sync audits to your dashboard and enable auto-fix PRs (needs a RANKCLI_API_KEY secret)").action(setup);
3658
4151
  program.command("gsc-setup").description("Show Google Search Console API setup instructions").action(showGSCInstructions);
3659
4152
  program.command("alerts").description("Configure Slack/Discord notifications for SEO events").option("-c, --channel <channel>", "Alert channel (slack, discord)").option("-w, --webhook <url>", "Webhook URL").option("--test", "Test the webhook").option("--list", "List current alert configurations").option("--remove <id>", "Remove an alert configuration").action(alerts);
3660
- program.parse();
4153
+ program.parseAsync().catch((error) => {
4154
+ console.error(error instanceof Error ? error.message : String(error));
4155
+ process.exitCode = 1;
4156
+ });