@rankcli/cli 0.0.32 → 0.0.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -211,7 +211,7 @@ rankcli apply --url https://mysite.com --auto # Auto-apply
211
211
 
212
212
  ### `rankcli keywords`
213
213
 
214
- AI-powered keyword research.
214
+ AI-powered keyword research. `--ai` runs on the same provider as `audit --ai` (Apple Intelligence on a Mac that has it, else your key, else a local Ollama) and takes the same `--ai-provider` / `--ai-key` / `--ai-model` / `--ai-base-url` flags. Keyword clustering uses embeddings on OpenAI and term vectors everywhere else; local models judge 5 tool ideas instead of 15.
215
215
 
216
216
  ```bash
217
217
  rankcli keywords --url https://mysite.com --ai
package/dist/index.js CHANGED
@@ -1922,19 +1922,27 @@ Respond with only this JSON object - no prose, no code fences:
1922
1922
  }`;
1923
1923
  }
1924
1924
  async function runAIAnalysis(url, issues, ai) {
1925
- const system = "You are an SEO expert. Respond with valid JSON only.";
1925
+ const reply = await completeWithAI(ai, {
1926
+ system: "You are an SEO expert. Respond with valid JSON only.",
1927
+ prompt: buildPrompt(url, issues, ai.provider === "apple" ? APPLE_MAX_ISSUES : MAX_ISSUES_IN_PROMPT),
1928
+ json: true
1929
+ });
1930
+ if ("error" in reply) return reply;
1931
+ return parseAnalysis(reply.text, ai);
1932
+ }
1933
+ async function completeWithAI(ai, request) {
1934
+ const { system, prompt } = request;
1926
1935
  if (ai.provider === "apple" && ai.helper) {
1927
1936
  const reply = await generateWithAppleAI(ai.helper, {
1928
1937
  system,
1929
- prompt: buildPrompt(url, issues, APPLE_MAX_ISSUES),
1930
- maxTokens: APPLE_MAX_TOKENS
1938
+ prompt,
1939
+ maxTokens: Math.min(request.maxTokens ?? APPLE_MAX_TOKENS, APPLE_MAX_TOKENS)
1931
1940
  });
1932
1941
  if ("error" in reply) return { error: `${ai.label} request failed: ${reply.error}` };
1933
- return parseAnalysis(reply.text, ai);
1942
+ return reply;
1934
1943
  }
1935
- const prompt = buildPrompt(url, issues);
1936
1944
  const timeoutMs = ai.local ? 3e5 : 9e4;
1937
- const maxTokens = 4096;
1945
+ const maxTokens = Math.max(request.maxTokens ?? 4096, 4096);
1938
1946
  const headers = { "Content-Type": "application/json" };
1939
1947
  let endpoint;
1940
1948
  let body;
@@ -1942,7 +1950,7 @@ async function runAIAnalysis(url, issues, ai) {
1942
1950
  endpoint = `${ai.baseUrl}/messages`;
1943
1951
  headers["x-api-key"] = ai.apiKey || "";
1944
1952
  headers["anthropic-version"] = "2023-06-01";
1945
- body = { model: ai.model, max_tokens: maxTokens, system, messages: [{ role: "user", content: prompt }] };
1953
+ body = { model: ai.model, max_tokens: maxTokens, ...system ? { system } : {}, messages: [{ role: "user", content: prompt }] };
1946
1954
  } else {
1947
1955
  endpoint = `${ai.baseUrl}/chat/completions`;
1948
1956
  if (ai.apiKey) headers["Authorization"] = `Bearer ${ai.apiKey}`;
@@ -1953,12 +1961,12 @@ async function runAIAnalysis(url, issues, ai) {
1953
1961
  body = {
1954
1962
  model: ai.model,
1955
1963
  messages: [
1956
- { role: "system", content: system },
1964
+ ...system ? [{ role: "system", content: system }] : [],
1957
1965
  { role: "user", content: prompt }
1958
1966
  ],
1959
1967
  // OpenAI's GPT-5.6 rejects max_tokens (and temperature); everyone else expects max_tokens.
1960
1968
  ...ai.provider === "openai" && !ai.local ? { max_completion_tokens: maxTokens } : { max_tokens: maxTokens },
1961
- ...ai.jsonMode ? { response_format: { type: "json_object" } } : {}
1969
+ ...ai.jsonMode && request.json ? { response_format: { type: "json_object" } } : {}
1962
1970
  };
1963
1971
  }
1964
1972
  let response;
@@ -1982,7 +1990,7 @@ async function runAIAnalysis(url, issues, ai) {
1982
1990
  if (!content) {
1983
1991
  return { error: `${ai.label} returned no content (a reasoning model may have used its whole token budget - try another --ai-model)` };
1984
1992
  }
1985
- return parseAnalysis(content, ai);
1993
+ return { text: content };
1986
1994
  }
1987
1995
  function parseAnalysis(content, ai) {
1988
1996
  const parsed = extractJsonObject(content);
@@ -3238,7 +3246,7 @@ async function trackHistory(options) {
3238
3246
  }
3239
3247
  const days = options.days || 30;
3240
3248
  const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1e3);
3241
- const { data: historyData, error } = await supabase.from("rankings").select("position, url, recorded_at").eq("keyword_id", keywordData.id).gte("recorded_at", cutoff.toISOString()).order("recorded_at", { ascending: false });
3249
+ const { data: historyData, error } = await supabase.from("keyword_position_history").select("position, url, checked_at").eq("keyword_id", keywordData.id).gte("checked_at", cutoff.toISOString()).order("checked_at", { ascending: false });
3242
3250
  if (error) {
3243
3251
  spinner.fail(`Failed to load history: ${error.message}`);
3244
3252
  return { success: false, error: error.message };
@@ -3247,7 +3255,7 @@ async function trackHistory(options) {
3247
3255
  const history = (historyData || []).map((row) => ({
3248
3256
  position: row.position,
3249
3257
  url: row.url,
3250
- recordedAt: new Date(row.recorded_at)
3258
+ recordedAt: new Date(row.checked_at)
3251
3259
  }));
3252
3260
  if (options.output === "json") {
3253
3261
  console.log(JSON.stringify({ keyword: options.keyword, history }, null, 2));
@@ -3255,7 +3263,9 @@ async function trackHistory(options) {
3255
3263
  console.log("\n" + import_chalk6.default.bold(`\u{1F4C8} History: "${options.keyword}"`));
3256
3264
  console.log("\u2550".repeat(50));
3257
3265
  if (history.length === 0) {
3258
- console.log(import_chalk6.default.dim("\nNo history available yet.\n"));
3266
+ console.log(import_chalk6.default.dim(`
3267
+ No checks recorded yet. Run 'rankcli track check --url <your site>' to record one.
3268
+ `));
3259
3269
  } else {
3260
3270
  const positions = history.slice(0, 30).reverse().map((h) => h.position || 101);
3261
3271
  const sparkline = positions.map((p) => {
@@ -3266,7 +3276,7 @@ async function trackHistory(options) {
3266
3276
  }).join("");
3267
3277
  console.log(`
3268
3278
  ${sparkline} (last ${positions.length} checks)`);
3269
- console.log(import_chalk6.default.dim(" \u2190 newer older \u2192\n"));
3279
+ console.log(import_chalk6.default.dim(" \u2190 older newer \u2192\n"));
3270
3280
  const recent = history.slice(0, 10);
3271
3281
  for (const entry of recent) {
3272
3282
  console.log(` ${entry.recordedAt.toLocaleDateString()} ${formatPosition(entry.position)}`);
@@ -3580,6 +3590,40 @@ async function geo(options) {
3580
3590
  // src/commands/keywords.ts
3581
3591
  var readline2 = __toESM(require("readline"));
3582
3592
  var import_agent_runtime7 = require("@rankcli/agent-runtime");
3593
+
3594
+ // src/lib/keyword-llm.ts
3595
+ async function openAIEmbeddings(ai, texts) {
3596
+ const response = await fetch(`${ai.baseUrl}/embeddings`, {
3597
+ method: "POST",
3598
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${ai.apiKey}` },
3599
+ body: JSON.stringify({ model: "text-embedding-3-small", input: texts }),
3600
+ signal: AbortSignal.timeout(6e4)
3601
+ });
3602
+ if (!response.ok) throw new Error(`OpenAI embeddings returned HTTP ${response.status}`);
3603
+ const data = await response.json();
3604
+ return data.data.map((item) => item.embedding);
3605
+ }
3606
+ function keywordLLMFromAI(ai) {
3607
+ const llm = {
3608
+ async complete(prompt, opts = {}) {
3609
+ const reply = await completeWithAI(ai, { system: opts.system, prompt, maxTokens: opts.maxTokens, json: true });
3610
+ if ("error" in reply) throw new Error(reply.error);
3611
+ return reply.text;
3612
+ }
3613
+ };
3614
+ if (ai.provider === "openai" && ai.apiKey && ai.jsonMode) {
3615
+ llm.embed = (texts) => openAIEmbeddings(ai, texts);
3616
+ }
3617
+ if (ai.provider === "apple") {
3618
+ llm.maxContentChars = 5e3;
3619
+ llm.maxToolIdeas = 5;
3620
+ } else if (ai.local) {
3621
+ llm.maxToolIdeas = 5;
3622
+ }
3623
+ return llm;
3624
+ }
3625
+
3626
+ // src/commands/keywords.ts
3583
3627
  async function keywords(options) {
3584
3628
  console.log("\n\u{1F511} RankCLI - Keyword Research\n");
3585
3629
  console.log("This tool helps you find keywords you can actually rank for,");
@@ -3740,65 +3784,37 @@ async function keywordsQuick(url, providedSeeds) {
3740
3784
  `);
3741
3785
  }
3742
3786
  }
3743
- var SUPABASE_URL = "https://eqzlmjbvrtrglknphdai.supabase.co";
3744
3787
  async function keywordsAI(url, options = {}) {
3745
3788
  console.log("\n\u{1F916} AI-Powered Keyword Research\n");
3746
- console.log("This uses GPT-4 to analyze your site and generate keyword recommendations");
3747
- console.log("with competitive analysis and free tool ideas.\n");
3748
- const openaiApiKey = options.openaiApiKey || process.env.OPENAI_API_KEY;
3749
3789
  try {
3750
3790
  const parsed = new URL(url.startsWith("http") ? url : `https://${url}`);
3751
3791
  url = parsed.href;
3752
- console.log(`\u{1F4CD} Analyzing: ${url}`);
3753
- console.log("");
3754
- if (!options.forceLocal) {
3755
- console.log("\u{1F517} Connecting to RankCLI cloud...");
3756
- try {
3757
- const response = await fetch(`${SUPABASE_URL}/functions/v1/ai-keywords`, {
3758
- method: "POST",
3759
- headers: {
3760
- "Content-Type": "application/json"
3761
- // Auth header would go here if user is logged in
3762
- },
3763
- body: JSON.stringify({
3764
- url,
3765
- mode: "analyze"
3766
- })
3767
- });
3768
- if (response.ok) {
3769
- const result2 = await response.json();
3770
- if (result2.tier === "free" && result2.teaser) {
3771
- console.log("\n" + "\u2500".repeat(60));
3772
- console.log("\u{1F512} FREE TIER - Limited Preview");
3773
- console.log("\u2500".repeat(60));
3774
- console.log(`Found ${result2.teaser.totalKeywordsFound} keywords`);
3775
- console.log(`Top keywords: ${result2.teaser.topKeywords.join(", ")}`);
3776
- console.log(`
3777
- ${result2.teaser.upgradeMessage}`);
3778
- console.log('\n\u{1F4A1} Run "rankcli login" to access your paid plan features.\n');
3779
- return;
3780
- }
3781
- displayAIKeywordResults(result2);
3782
- return;
3783
- } else {
3784
- const errorText = await response.text();
3785
- console.log(`\u26A0\uFE0F Cloud request failed, running locally...
3786
- `);
3787
- }
3788
- } catch (err) {
3789
- console.log("\u26A0\uFE0F Cloud unavailable, running locally...\n");
3790
- }
3792
+ const resolution = await resolveAIProvider(options);
3793
+ if (!resolution.ok) {
3794
+ console.log(`\u274C ${resolution.error}`);
3795
+ for (const hint of resolution.hints) console.log(` ${hint}`);
3796
+ console.log("");
3797
+ process.exitCode = 1;
3798
+ return;
3791
3799
  }
3792
- if (!openaiApiKey) {
3793
- console.log("\u274C OpenAI API key required for local processing.");
3794
- console.log(' Set OPENAI_API_KEY environment variable, or use "rankcli login" for cloud processing.\n');
3800
+ const { ai } = resolution;
3801
+ if (ai.notice) console.log(ai.notice);
3802
+ const check = await completeWithAI(ai, { prompt: 'Reply with the JSON object {"ok": true} and nothing else.', maxTokens: 50, json: true });
3803
+ if ("error" in check) {
3804
+ console.log(`\u274C ${check.error}`);
3805
+ console.log(" Choose another provider with --ai-provider, or fix the key or model and try again.\n");
3806
+ process.exitCode = 1;
3795
3807
  return;
3796
3808
  }
3797
- console.log("\u{1F52C} Running local AI analysis (this may take a minute)...\n");
3809
+ console.log(`\u{1F4CD} Analyzing: ${url}`);
3810
+ console.log(`\u{1F9E0} Model: ${ai.label} (${ai.model})
3811
+ `);
3812
+ console.log(`\u{1F52C} Running AI analysis (${ai.local ? "a few minutes on a local model" : "this may take a minute"})...
3813
+ `);
3798
3814
  const result = await (0, import_agent_runtime7.runAIKeywordResearch)({
3799
3815
  url,
3800
3816
  tier: "solo",
3801
- openaiApiKey,
3817
+ llm: keywordLLMFromAI(ai),
3802
3818
  enableEnhancedToolIdeas: true,
3803
3819
  competitiveSearchKeys: {
3804
3820
  braveApiKey: process.env.BRAVE_API_KEY,
@@ -3811,6 +3827,7 @@ ${result2.teaser.upgradeMessage}`);
3811
3827
  console.log(`
3812
3828
  \u274C Error: ${error instanceof Error ? error.message : "Unknown error"}
3813
3829
  `);
3830
+ process.exitCode = 1;
3814
3831
  }
3815
3832
  }
3816
3833
  function displayAIKeywordResults(result) {
@@ -4222,7 +4239,7 @@ async function testWebhook(channel, webhookUrl) {
4222
4239
  }
4223
4240
 
4224
4241
  // src/version.ts
4225
- var VERSION = "0.0.32";
4242
+ var VERSION = "0.0.33";
4226
4243
 
4227
4244
  // src/index.ts
4228
4245
  var program = new import_commander.Command();
@@ -4253,11 +4270,13 @@ program.command("fix").description("Agentic SEO fixer - detect, fix, commit, and
4253
4270
  }
4254
4271
  });
4255
4272
  program.command("geo").description("GEO analysis - Check AI search visibility (ChatGPT, Perplexity, Claude)").option("-u, --url <url>", "URL to analyze").option("-o, --output <format>", "Output format (json, console)", "console").action(geo);
4256
- program.command("keywords").description("AI-powered keyword research with actionable suggestions").option("-u, --url <url>", "Your website URL").option("-s, --seed <keywords>", "Seed keywords (comma-separated)").option("--auto", "Auto-extract seed keywords from your page").option("--quick", "Quick mode - skip questions, use defaults").option("--ai", "Use GPT-4 for enhanced analysis with free tool ideas").option("--local", "Force local processing (skip cloud worker)").option("--competitor", "Competitor gap analysis mode (SpyFu Kombat-style)").option("-c, --competitors <domains>", "Competitor domains (comma-separated)").action(async (options) => {
4273
+ program.command("keywords").description("AI-powered keyword research with actionable suggestions").option("-u, --url <url>", "Your website URL").option("-s, --seed <keywords>", "Seed keywords (comma-separated)").option("--auto", "Auto-extract seed keywords from your page").option("--quick", "Quick mode - skip questions, use defaults").option("--ai", "AI keyword research with free tool ideas, on your own provider (Apple Intelligence on a Mac that has it)").option("--ai-provider <provider>", "AI provider for --ai (auto-detected if omitted, or set RANKCLI_AI_PROVIDER)").option("--ai-key <key>", "AI provider API key (or the provider's env var)").option("--ai-model <model>", "Override the provider's default model").option("--ai-base-url <url>", "Any OpenAI-compatible server (implies --ai-provider custom)").option("--local", "No effect; --ai always runs locally on your provider (kept for old scripts)").option("--competitor", "Competitor gap analysis mode (SpyFu Kombat-style)").option("-c, --competitors <domains>", "Competitor domains (comma-separated)").action(async (options) => {
4257
4274
  if (options.ai && options.url) {
4258
4275
  await keywordsAI(options.url, {
4259
- forceLocal: options.local,
4260
- openaiApiKey: process.env.OPENAI_API_KEY
4276
+ aiProvider: options.aiProvider,
4277
+ aiKey: options.aiKey,
4278
+ aiModel: options.aiModel,
4279
+ aiBaseUrl: options.aiBaseUrl
4261
4280
  });
4262
4281
  } else if (options.competitor && options.url && options.seed) {
4263
4282
  const seeds = options.seed.split(",").map((s) => s.trim());
package/dist/index.mjs CHANGED
@@ -1743,19 +1743,27 @@ Respond with only this JSON object - no prose, no code fences:
1743
1743
  }`;
1744
1744
  }
1745
1745
  async function runAIAnalysis(url, issues, ai) {
1746
- const system = "You are an SEO expert. Respond with valid JSON only.";
1746
+ const reply = await completeWithAI(ai, {
1747
+ system: "You are an SEO expert. Respond with valid JSON only.",
1748
+ prompt: buildPrompt(url, issues, ai.provider === "apple" ? APPLE_MAX_ISSUES : MAX_ISSUES_IN_PROMPT),
1749
+ json: true
1750
+ });
1751
+ if ("error" in reply) return reply;
1752
+ return parseAnalysis(reply.text, ai);
1753
+ }
1754
+ async function completeWithAI(ai, request) {
1755
+ const { system, prompt } = request;
1747
1756
  if (ai.provider === "apple" && ai.helper) {
1748
1757
  const reply = await generateWithAppleAI(ai.helper, {
1749
1758
  system,
1750
- prompt: buildPrompt(url, issues, APPLE_MAX_ISSUES),
1751
- maxTokens: APPLE_MAX_TOKENS
1759
+ prompt,
1760
+ maxTokens: Math.min(request.maxTokens ?? APPLE_MAX_TOKENS, APPLE_MAX_TOKENS)
1752
1761
  });
1753
1762
  if ("error" in reply) return { error: `${ai.label} request failed: ${reply.error}` };
1754
- return parseAnalysis(reply.text, ai);
1763
+ return reply;
1755
1764
  }
1756
- const prompt = buildPrompt(url, issues);
1757
1765
  const timeoutMs = ai.local ? 3e5 : 9e4;
1758
- const maxTokens = 4096;
1766
+ const maxTokens = Math.max(request.maxTokens ?? 4096, 4096);
1759
1767
  const headers = { "Content-Type": "application/json" };
1760
1768
  let endpoint;
1761
1769
  let body;
@@ -1763,7 +1771,7 @@ async function runAIAnalysis(url, issues, ai) {
1763
1771
  endpoint = `${ai.baseUrl}/messages`;
1764
1772
  headers["x-api-key"] = ai.apiKey || "";
1765
1773
  headers["anthropic-version"] = "2023-06-01";
1766
- body = { model: ai.model, max_tokens: maxTokens, system, messages: [{ role: "user", content: prompt }] };
1774
+ body = { model: ai.model, max_tokens: maxTokens, ...system ? { system } : {}, messages: [{ role: "user", content: prompt }] };
1767
1775
  } else {
1768
1776
  endpoint = `${ai.baseUrl}/chat/completions`;
1769
1777
  if (ai.apiKey) headers["Authorization"] = `Bearer ${ai.apiKey}`;
@@ -1774,12 +1782,12 @@ async function runAIAnalysis(url, issues, ai) {
1774
1782
  body = {
1775
1783
  model: ai.model,
1776
1784
  messages: [
1777
- { role: "system", content: system },
1785
+ ...system ? [{ role: "system", content: system }] : [],
1778
1786
  { role: "user", content: prompt }
1779
1787
  ],
1780
1788
  // OpenAI's GPT-5.6 rejects max_tokens (and temperature); everyone else expects max_tokens.
1781
1789
  ...ai.provider === "openai" && !ai.local ? { max_completion_tokens: maxTokens } : { max_tokens: maxTokens },
1782
- ...ai.jsonMode ? { response_format: { type: "json_object" } } : {}
1790
+ ...ai.jsonMode && request.json ? { response_format: { type: "json_object" } } : {}
1783
1791
  };
1784
1792
  }
1785
1793
  let response;
@@ -1803,7 +1811,7 @@ async function runAIAnalysis(url, issues, ai) {
1803
1811
  if (!content) {
1804
1812
  return { error: `${ai.label} returned no content (a reasoning model may have used its whole token budget - try another --ai-model)` };
1805
1813
  }
1806
- return parseAnalysis(content, ai);
1814
+ return { text: content };
1807
1815
  }
1808
1816
  function parseAnalysis(content, ai) {
1809
1817
  const parsed = extractJsonObject(content);
@@ -3069,7 +3077,7 @@ async function trackHistory(options) {
3069
3077
  }
3070
3078
  const days = options.days || 30;
3071
3079
  const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1e3);
3072
- const { data: historyData, error } = await supabase.from("rankings").select("position, url, recorded_at").eq("keyword_id", keywordData.id).gte("recorded_at", cutoff.toISOString()).order("recorded_at", { ascending: false });
3080
+ const { data: historyData, error } = await supabase.from("keyword_position_history").select("position, url, checked_at").eq("keyword_id", keywordData.id).gte("checked_at", cutoff.toISOString()).order("checked_at", { ascending: false });
3073
3081
  if (error) {
3074
3082
  spinner.fail(`Failed to load history: ${error.message}`);
3075
3083
  return { success: false, error: error.message };
@@ -3078,7 +3086,7 @@ async function trackHistory(options) {
3078
3086
  const history = (historyData || []).map((row) => ({
3079
3087
  position: row.position,
3080
3088
  url: row.url,
3081
- recordedAt: new Date(row.recorded_at)
3089
+ recordedAt: new Date(row.checked_at)
3082
3090
  }));
3083
3091
  if (options.output === "json") {
3084
3092
  console.log(JSON.stringify({ keyword: options.keyword, history }, null, 2));
@@ -3086,7 +3094,9 @@ async function trackHistory(options) {
3086
3094
  console.log("\n" + chalk6.bold(`\u{1F4C8} History: "${options.keyword}"`));
3087
3095
  console.log("\u2550".repeat(50));
3088
3096
  if (history.length === 0) {
3089
- console.log(chalk6.dim("\nNo history available yet.\n"));
3097
+ console.log(chalk6.dim(`
3098
+ No checks recorded yet. Run 'rankcli track check --url <your site>' to record one.
3099
+ `));
3090
3100
  } else {
3091
3101
  const positions = history.slice(0, 30).reverse().map((h) => h.position || 101);
3092
3102
  const sparkline = positions.map((p) => {
@@ -3097,7 +3107,7 @@ async function trackHistory(options) {
3097
3107
  }).join("");
3098
3108
  console.log(`
3099
3109
  ${sparkline} (last ${positions.length} checks)`);
3100
- console.log(chalk6.dim(" \u2190 newer older \u2192\n"));
3110
+ console.log(chalk6.dim(" \u2190 older newer \u2192\n"));
3101
3111
  const recent = history.slice(0, 10);
3102
3112
  for (const entry of recent) {
3103
3113
  console.log(` ${entry.recordedAt.toLocaleDateString()} ${formatPosition(entry.position)}`);
@@ -3421,6 +3431,40 @@ import {
3421
3431
  formatTopicReport,
3422
3432
  runAIKeywordResearch
3423
3433
  } from "@rankcli/agent-runtime";
3434
+
3435
+ // src/lib/keyword-llm.ts
3436
+ async function openAIEmbeddings(ai, texts) {
3437
+ const response = await fetch(`${ai.baseUrl}/embeddings`, {
3438
+ method: "POST",
3439
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${ai.apiKey}` },
3440
+ body: JSON.stringify({ model: "text-embedding-3-small", input: texts }),
3441
+ signal: AbortSignal.timeout(6e4)
3442
+ });
3443
+ if (!response.ok) throw new Error(`OpenAI embeddings returned HTTP ${response.status}`);
3444
+ const data = await response.json();
3445
+ return data.data.map((item) => item.embedding);
3446
+ }
3447
+ function keywordLLMFromAI(ai) {
3448
+ const llm = {
3449
+ async complete(prompt, opts = {}) {
3450
+ const reply = await completeWithAI(ai, { system: opts.system, prompt, maxTokens: opts.maxTokens, json: true });
3451
+ if ("error" in reply) throw new Error(reply.error);
3452
+ return reply.text;
3453
+ }
3454
+ };
3455
+ if (ai.provider === "openai" && ai.apiKey && ai.jsonMode) {
3456
+ llm.embed = (texts) => openAIEmbeddings(ai, texts);
3457
+ }
3458
+ if (ai.provider === "apple") {
3459
+ llm.maxContentChars = 5e3;
3460
+ llm.maxToolIdeas = 5;
3461
+ } else if (ai.local) {
3462
+ llm.maxToolIdeas = 5;
3463
+ }
3464
+ return llm;
3465
+ }
3466
+
3467
+ // src/commands/keywords.ts
3424
3468
  async function keywords(options) {
3425
3469
  console.log("\n\u{1F511} RankCLI - Keyword Research\n");
3426
3470
  console.log("This tool helps you find keywords you can actually rank for,");
@@ -3581,65 +3625,37 @@ async function keywordsQuick(url, providedSeeds) {
3581
3625
  `);
3582
3626
  }
3583
3627
  }
3584
- var SUPABASE_URL = "https://eqzlmjbvrtrglknphdai.supabase.co";
3585
3628
  async function keywordsAI(url, options = {}) {
3586
3629
  console.log("\n\u{1F916} AI-Powered Keyword Research\n");
3587
- console.log("This uses GPT-4 to analyze your site and generate keyword recommendations");
3588
- console.log("with competitive analysis and free tool ideas.\n");
3589
- const openaiApiKey = options.openaiApiKey || process.env.OPENAI_API_KEY;
3590
3630
  try {
3591
3631
  const parsed = new URL(url.startsWith("http") ? url : `https://${url}`);
3592
3632
  url = parsed.href;
3593
- console.log(`\u{1F4CD} Analyzing: ${url}`);
3594
- console.log("");
3595
- if (!options.forceLocal) {
3596
- console.log("\u{1F517} Connecting to RankCLI cloud...");
3597
- try {
3598
- const response = await fetch(`${SUPABASE_URL}/functions/v1/ai-keywords`, {
3599
- method: "POST",
3600
- headers: {
3601
- "Content-Type": "application/json"
3602
- // Auth header would go here if user is logged in
3603
- },
3604
- body: JSON.stringify({
3605
- url,
3606
- mode: "analyze"
3607
- })
3608
- });
3609
- if (response.ok) {
3610
- const result2 = await response.json();
3611
- if (result2.tier === "free" && result2.teaser) {
3612
- console.log("\n" + "\u2500".repeat(60));
3613
- console.log("\u{1F512} FREE TIER - Limited Preview");
3614
- console.log("\u2500".repeat(60));
3615
- console.log(`Found ${result2.teaser.totalKeywordsFound} keywords`);
3616
- console.log(`Top keywords: ${result2.teaser.topKeywords.join(", ")}`);
3617
- console.log(`
3618
- ${result2.teaser.upgradeMessage}`);
3619
- console.log('\n\u{1F4A1} Run "rankcli login" to access your paid plan features.\n');
3620
- return;
3621
- }
3622
- displayAIKeywordResults(result2);
3623
- return;
3624
- } else {
3625
- const errorText = await response.text();
3626
- console.log(`\u26A0\uFE0F Cloud request failed, running locally...
3627
- `);
3628
- }
3629
- } catch (err) {
3630
- console.log("\u26A0\uFE0F Cloud unavailable, running locally...\n");
3631
- }
3633
+ const resolution = await resolveAIProvider(options);
3634
+ if (!resolution.ok) {
3635
+ console.log(`\u274C ${resolution.error}`);
3636
+ for (const hint of resolution.hints) console.log(` ${hint}`);
3637
+ console.log("");
3638
+ process.exitCode = 1;
3639
+ return;
3632
3640
  }
3633
- if (!openaiApiKey) {
3634
- console.log("\u274C OpenAI API key required for local processing.");
3635
- console.log(' Set OPENAI_API_KEY environment variable, or use "rankcli login" for cloud processing.\n');
3641
+ const { ai } = resolution;
3642
+ if (ai.notice) console.log(ai.notice);
3643
+ const check = await completeWithAI(ai, { prompt: 'Reply with the JSON object {"ok": true} and nothing else.', maxTokens: 50, json: true });
3644
+ if ("error" in check) {
3645
+ console.log(`\u274C ${check.error}`);
3646
+ console.log(" Choose another provider with --ai-provider, or fix the key or model and try again.\n");
3647
+ process.exitCode = 1;
3636
3648
  return;
3637
3649
  }
3638
- console.log("\u{1F52C} Running local AI analysis (this may take a minute)...\n");
3650
+ console.log(`\u{1F4CD} Analyzing: ${url}`);
3651
+ console.log(`\u{1F9E0} Model: ${ai.label} (${ai.model})
3652
+ `);
3653
+ console.log(`\u{1F52C} Running AI analysis (${ai.local ? "a few minutes on a local model" : "this may take a minute"})...
3654
+ `);
3639
3655
  const result = await runAIKeywordResearch({
3640
3656
  url,
3641
3657
  tier: "solo",
3642
- openaiApiKey,
3658
+ llm: keywordLLMFromAI(ai),
3643
3659
  enableEnhancedToolIdeas: true,
3644
3660
  competitiveSearchKeys: {
3645
3661
  braveApiKey: process.env.BRAVE_API_KEY,
@@ -3652,6 +3668,7 @@ ${result2.teaser.upgradeMessage}`);
3652
3668
  console.log(`
3653
3669
  \u274C Error: ${error instanceof Error ? error.message : "Unknown error"}
3654
3670
  `);
3671
+ process.exitCode = 1;
3655
3672
  }
3656
3673
  }
3657
3674
  function displayAIKeywordResults(result) {
@@ -4078,7 +4095,7 @@ async function testWebhook(channel, webhookUrl) {
4078
4095
  }
4079
4096
 
4080
4097
  // src/version.ts
4081
- var VERSION = "0.0.32";
4098
+ var VERSION = "0.0.33";
4082
4099
 
4083
4100
  // src/index.ts
4084
4101
  var program = new Command();
@@ -4109,11 +4126,13 @@ program.command("fix").description("Agentic SEO fixer - detect, fix, commit, and
4109
4126
  }
4110
4127
  });
4111
4128
  program.command("geo").description("GEO analysis - Check AI search visibility (ChatGPT, Perplexity, Claude)").option("-u, --url <url>", "URL to analyze").option("-o, --output <format>", "Output format (json, console)", "console").action(geo);
4112
- program.command("keywords").description("AI-powered keyword research with actionable suggestions").option("-u, --url <url>", "Your website URL").option("-s, --seed <keywords>", "Seed keywords (comma-separated)").option("--auto", "Auto-extract seed keywords from your page").option("--quick", "Quick mode - skip questions, use defaults").option("--ai", "Use GPT-4 for enhanced analysis with free tool ideas").option("--local", "Force local processing (skip cloud worker)").option("--competitor", "Competitor gap analysis mode (SpyFu Kombat-style)").option("-c, --competitors <domains>", "Competitor domains (comma-separated)").action(async (options) => {
4129
+ program.command("keywords").description("AI-powered keyword research with actionable suggestions").option("-u, --url <url>", "Your website URL").option("-s, --seed <keywords>", "Seed keywords (comma-separated)").option("--auto", "Auto-extract seed keywords from your page").option("--quick", "Quick mode - skip questions, use defaults").option("--ai", "AI keyword research with free tool ideas, on your own provider (Apple Intelligence on a Mac that has it)").option("--ai-provider <provider>", "AI provider for --ai (auto-detected if omitted, or set RANKCLI_AI_PROVIDER)").option("--ai-key <key>", "AI provider API key (or the provider's env var)").option("--ai-model <model>", "Override the provider's default model").option("--ai-base-url <url>", "Any OpenAI-compatible server (implies --ai-provider custom)").option("--local", "No effect; --ai always runs locally on your provider (kept for old scripts)").option("--competitor", "Competitor gap analysis mode (SpyFu Kombat-style)").option("-c, --competitors <domains>", "Competitor domains (comma-separated)").action(async (options) => {
4113
4130
  if (options.ai && options.url) {
4114
4131
  await keywordsAI(options.url, {
4115
- forceLocal: options.local,
4116
- openaiApiKey: process.env.OPENAI_API_KEY
4132
+ aiProvider: options.aiProvider,
4133
+ aiKey: options.aiKey,
4134
+ aiModel: options.aiModel,
4135
+ aiBaseUrl: options.aiBaseUrl
4117
4136
  });
4118
4137
  } else if (options.competitor && options.url && options.seed) {
4119
4138
  const seeds = options.seed.split(",").map((s) => s.trim());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rankcli/cli",
3
- "version": "0.0.32",
3
+ "version": "0.0.33",
4
4
  "description": "RankCLI - Ship code, get ranked. SEO meets CI/CD.",
5
5
  "homepage": "https://rankcli.dev",
6
6
  "main": "dist/index.js",
@@ -45,7 +45,7 @@
45
45
  "update-version": "node scripts/update-version.js"
46
46
  },
47
47
  "dependencies": {
48
- "@rankcli/agent-runtime": "^0.0.23",
48
+ "@rankcli/agent-runtime": "^0.0.24",
49
49
  "@supabase/supabase-js": "^2.98.0",
50
50
  "chalk": "^5.3.0",
51
51
  "commander": "^12.0.0",