@rankcli/cli 0.0.31 → 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
@@ -157,7 +157,11 @@ The gates are opt-in: without them, a completed audit exits `0` however many iss
157
157
 
158
158
  #### AI analysis (`--ai`)
159
159
 
160
- AI analysis runs on your own provider - nothing goes through RankCLI. With no `--ai-provider`, the first key found wins, in this order (OpenRouter first):
160
+ AI analysis runs on your own provider - nothing goes through RankCLI.
161
+
162
+ **On a Mac with Apple Intelligence, it's the default.** On macOS 26 or later on Apple silicon with Apple Intelligence turned on, `--ai` uses Apple's on-device model: free, private, no key and no network round trip. The first run builds a small Swift helper with the Xcode command-line tools (a few seconds, cached in `~/Library/Caches/rankcli`). The on-device model has a small context window, so it sees the 25 most severe issues. To use another provider, pass `--ai-provider`, or set it once with `RANKCLI_AI_PROVIDER=openrouter` (any provider id below).
163
+
164
+ Otherwise, with no `--ai-provider`, the first key found wins, in this order (OpenRouter first):
161
165
 
162
166
  | Provider | Env var | Base URL | Default model |
163
167
  |----------|---------|----------|---------------|
@@ -207,7 +211,7 @@ rankcli apply --url https://mysite.com --auto # Auto-apply
207
211
 
208
212
  ### `rankcli keywords`
209
213
 
210
- 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.
211
215
 
212
216
  ```bash
213
217
  rankcli keywords --url https://mysite.com --ai
package/dist/index.js CHANGED
@@ -1453,6 +1453,177 @@ var TOGETHER_DEFAULT = "openai/gpt-oss-120b";
1453
1453
  var FIREWORKS_DEFAULT = "accounts/fireworks/models/gpt-oss-120b";
1454
1454
  var CEREBRAS_DEFAULT = "gpt-oss-120b";
1455
1455
 
1456
+ // src/lib/apple-ai.ts
1457
+ var import_node_child_process = require("child_process");
1458
+ var import_node_crypto2 = require("crypto");
1459
+ var import_node_fs = require("fs");
1460
+ var import_node_os = require("os");
1461
+ var import_node_path = require("path");
1462
+
1463
+ // src/lib/apple-ai-source.ts
1464
+ var APPLE_AI_SWIFT_SOURCE = String.raw`import Foundation
1465
+ import FoundationModels
1466
+
1467
+ func reply(_ object: [String: Any], code: Int32 = 0) -> Never {
1468
+ let data = (try? JSONSerialization.data(withJSONObject: object)) ?? Data("{\"error\":\"could not encode the reply\"}".utf8)
1469
+ FileHandle.standardOutput.write(data)
1470
+ exit(code)
1471
+ }
1472
+
1473
+ let model = SystemLanguageModel.default
1474
+
1475
+ if CommandLine.arguments.contains("--check") {
1476
+ switch model.availability {
1477
+ case .available:
1478
+ reply(["available": true])
1479
+ case .unavailable(let reason):
1480
+ switch reason {
1481
+ case .deviceNotEligible: reply(["available": false, "reason": "deviceNotEligible"])
1482
+ case .appleIntelligenceNotEnabled: reply(["available": false, "reason": "appleIntelligenceNotEnabled"])
1483
+ case .modelNotReady: reply(["available": false, "reason": "modelNotReady"])
1484
+ @unknown default: reply(["available": false, "reason": "unavailable"])
1485
+ }
1486
+ @unknown default:
1487
+ reply(["available": false, "reason": "unavailable"])
1488
+ }
1489
+ }
1490
+
1491
+ struct Request: Decodable {
1492
+ let system: String?
1493
+ let prompt: String
1494
+ let maxTokens: Int?
1495
+ }
1496
+
1497
+ let input = FileHandle.standardInput.readDataToEndOfFile()
1498
+ guard let request = try? JSONDecoder().decode(Request.self, from: input) else {
1499
+ reply(["error": "expected a JSON request on stdin"], code: 2)
1500
+ }
1501
+
1502
+ let session: LanguageModelSession
1503
+ if let system = request.system {
1504
+ session = LanguageModelSession(instructions: system)
1505
+ } else {
1506
+ session = LanguageModelSession()
1507
+ }
1508
+
1509
+ do {
1510
+ let response = try await session.respond(
1511
+ to: request.prompt,
1512
+ options: GenerationOptions(maximumResponseTokens: request.maxTokens)
1513
+ )
1514
+ reply(["text": response.content])
1515
+ } catch let error as LanguageModelSession.GenerationError {
1516
+ switch error {
1517
+ case .exceededContextWindowSize:
1518
+ reply(["error": "the prompt is too long for the on-device model"], code: 1)
1519
+ case .guardrailViolation:
1520
+ reply(["error": "the on-device model's guardrails declined the request"], code: 1)
1521
+ case .assetsUnavailable:
1522
+ reply(["error": "the on-device model isn't downloaded yet"], code: 1)
1523
+ default:
1524
+ reply(["error": String(describing: error)], code: 1)
1525
+ }
1526
+ } catch {
1527
+ reply(["error": String(describing: error)], code: 1)
1528
+ }
1529
+ `;
1530
+
1531
+ // src/lib/apple-ai.ts
1532
+ function isAppleIntelligenceCapable(platform = process.platform, arch = process.arch, darwinRelease = (0, import_node_os.release)()) {
1533
+ return platform === "darwin" && arch === "arm64" && Number(darwinRelease.split(".")[0]) >= 25;
1534
+ }
1535
+ var REASONS = {
1536
+ deviceNotEligible: "this Mac doesn't support Apple Intelligence",
1537
+ appleIntelligenceNotEnabled: "Apple Intelligence is turned off (System Settings > Apple Intelligence & Siri)",
1538
+ modelNotReady: "the on-device model is still downloading"
1539
+ };
1540
+ function run(file, args, opts) {
1541
+ return new Promise((resolve) => {
1542
+ const child = (0, import_node_child_process.spawn)(file, args, { stdio: ["pipe", "pipe", "pipe"] });
1543
+ let stdout = "";
1544
+ let stderr = "";
1545
+ let timedOut = false;
1546
+ const timer = setTimeout(() => {
1547
+ timedOut = true;
1548
+ child.kill("SIGKILL");
1549
+ }, opts.timeoutMs);
1550
+ child.stdout.on("data", (d) => stdout += d);
1551
+ child.stderr.on("data", (d) => stderr += d);
1552
+ child.on("error", (error) => {
1553
+ clearTimeout(timer);
1554
+ resolve({ code: null, stdout, stderr: stderr || error.message, timedOut });
1555
+ });
1556
+ child.on("close", (code) => {
1557
+ clearTimeout(timer);
1558
+ resolve({ code, stdout, stderr, timedOut });
1559
+ });
1560
+ child.stdin.on("error", () => {
1561
+ });
1562
+ child.stdin.end(opts.input ?? "");
1563
+ });
1564
+ }
1565
+ function hasDeveloperTools() {
1566
+ return new Promise((resolve) => (0, import_node_child_process.execFile)("xcode-select", ["-p"], (error) => resolve(!error)));
1567
+ }
1568
+ var helperPromise = null;
1569
+ function ensureHelper(env) {
1570
+ if (env.RANKCLI_APPLE_AI_HELPER) return Promise.resolve({ helper: env.RANKCLI_APPLE_AI_HELPER });
1571
+ helperPromise ??= (async () => {
1572
+ const hash = (0, import_node_crypto2.createHash)("sha256").update(APPLE_AI_SWIFT_SOURCE).digest("hex").slice(0, 12);
1573
+ const dir = (0, import_node_path.join)((0, import_node_os.homedir)(), "Library", "Caches", "rankcli", `apple-ai-${hash}`);
1574
+ const helper = (0, import_node_path.join)(dir, "rankcli-apple-ai");
1575
+ if ((0, import_node_fs.existsSync)(helper)) return { helper };
1576
+ const failed = (0, import_node_path.join)(dir, "build-failed");
1577
+ if ((0, import_node_fs.existsSync)(failed) && Date.now() - (0, import_node_fs.statSync)(failed).mtimeMs < 7 * 24 * 60 * 60 * 1e3) {
1578
+ return { error: (0, import_node_fs.readFileSync)(failed, "utf8") };
1579
+ }
1580
+ if (!await hasDeveloperTools()) {
1581
+ return { error: "using it needs the Xcode command-line tools (xcode-select --install)" };
1582
+ }
1583
+ (0, import_node_fs.mkdirSync)(dir, { recursive: true });
1584
+ const source = (0, import_node_path.join)(dir, "main.swift");
1585
+ (0, import_node_fs.writeFileSync)(source, APPLE_AI_SWIFT_SOURCE);
1586
+ process.stderr.write("Preparing Apple Intelligence for RankCLI (one-time build of a small helper)...\n");
1587
+ const tmp = `${helper}.${process.pid}`;
1588
+ const build = await run("xcrun", ["swiftc", "-O", "-target", "arm64-apple-macos26.0", source, "-o", tmp], { timeoutMs: 18e4 });
1589
+ if (build.code !== 0 || !(0, import_node_fs.existsSync)(tmp)) {
1590
+ const firstError = build.stderr.split("\n").find((line) => /error:/.test(line))?.trim();
1591
+ const error = `the helper didn't build${firstError ? ` (${firstError})` : ""} - it needs the macOS 26 SDK (update Xcode or its command-line tools)`;
1592
+ (0, import_node_fs.writeFileSync)(failed, error);
1593
+ return { error };
1594
+ }
1595
+ (0, import_node_fs.renameSync)(tmp, helper);
1596
+ return { helper };
1597
+ })();
1598
+ return helperPromise;
1599
+ }
1600
+ async function checkAppleAI(env = process.env) {
1601
+ if (!env.RANKCLI_APPLE_AI_HELPER && !isAppleIntelligenceCapable()) {
1602
+ return { available: false, reason: "it needs macOS 26 or later on Apple silicon" };
1603
+ }
1604
+ const ensured = await ensureHelper(env);
1605
+ if ("error" in ensured) return { available: false, reason: ensured.error };
1606
+ const check = await run(ensured.helper, ["--check"], { timeoutMs: 15e3 });
1607
+ try {
1608
+ const status = JSON.parse(check.stdout);
1609
+ if (status.available) return { available: true, helper: ensured.helper };
1610
+ return { available: false, reason: REASONS[status.reason ?? ""] ?? "Apple Intelligence is unavailable" };
1611
+ } catch {
1612
+ return { available: false, reason: `the helper failed (${(check.stderr || check.stdout).trim().slice(0, 120) || `exit ${check.code}`})` };
1613
+ }
1614
+ }
1615
+ async function generateWithAppleAI(helper, request, timeoutMs = 12e4) {
1616
+ const result = await run(helper, [], { input: JSON.stringify(request), timeoutMs });
1617
+ if (result.timedOut) return { error: `timed out after ${Math.round(timeoutMs / 1e3)}s` };
1618
+ try {
1619
+ const reply = JSON.parse(result.stdout);
1620
+ if (typeof reply.text === "string") return { text: reply.text };
1621
+ return { error: reply.error || "no reply" };
1622
+ } catch {
1623
+ return { error: (result.stderr || result.stdout).trim().slice(0, 200) || `exit ${result.code}` };
1624
+ }
1625
+ }
1626
+
1456
1627
  // src/lib/ai.ts
1457
1628
  var AI_PROVIDERS = {
1458
1629
  // Hosted
@@ -1467,6 +1638,7 @@ var AI_PROVIDERS = {
1467
1638
  together: { label: "Together", baseUrl: "https://api.together.xyz/v1", model: TOGETHER_DEFAULT, envKeys: ["TOGETHER_API_KEY"] },
1468
1639
  fireworks: { label: "Fireworks", baseUrl: "https://api.fireworks.ai/inference/v1", model: FIREWORKS_DEFAULT, envKeys: ["FIREWORKS_API_KEY"] },
1469
1640
  // Local / self-hosted
1641
+ apple: { label: "Apple Intelligence", model: "on-device", envKeys: [], local: true },
1470
1642
  ollama: { label: "Ollama", baseUrl: "http://localhost:11434/v1", envKeys: [], local: true },
1471
1643
  lmstudio: { label: "LM Studio", baseUrl: "http://localhost:1234/v1", envKeys: [], local: true },
1472
1644
  llamacpp: { label: "llama.cpp", baseUrl: "http://localhost:8080/v1", envKeys: [], local: true },
@@ -1531,28 +1703,62 @@ async function firstServerModel(baseUrl, apiKey) {
1531
1703
  }
1532
1704
  function noProviderHints() {
1533
1705
  return [
1706
+ "On a Mac with Apple Intelligence (macOS 26+), the on-device model is used automatically",
1534
1707
  `Set one of ${AI_ENV_KEYS.join(", ")}, or pass --ai-key`,
1535
1708
  "Or run a local model: start Ollama (https://ollama.com) and `ollama pull llama3.2` - it is picked up automatically",
1536
1709
  "Or point at any OpenAI-compatible server: --ai-base-url http://localhost:8080/v1 --ai-model <name>"
1537
1710
  ];
1538
1711
  }
1539
- async function resolveAIProvider(opts, env = process.env, probe = probeOllama) {
1540
- let provider = opts.aiProvider?.trim().toLowerCase();
1712
+ function appleResolution(status, notice) {
1713
+ const preset = AI_PROVIDERS.apple;
1714
+ return {
1715
+ ok: true,
1716
+ ai: {
1717
+ provider: "apple",
1718
+ label: preset.label,
1719
+ baseUrl: "",
1720
+ model: preset.model,
1721
+ jsonMode: false,
1722
+ anthropic: false,
1723
+ local: true,
1724
+ helper: status.helper,
1725
+ notice
1726
+ }
1727
+ };
1728
+ }
1729
+ async function resolveAIProvider(opts, env = process.env, probe = probeOllama, probeApple = checkAppleAI) {
1730
+ const requested = opts.aiProvider?.trim() || (!opts.aiKey && !opts.aiBaseUrl ? env.RANKCLI_AI_PROVIDER?.trim() : void 0);
1731
+ let provider = requested?.toLowerCase();
1541
1732
  let notice;
1542
1733
  let ollamaModels = null;
1543
1734
  if (provider && !AI_PROVIDERS[provider]) {
1544
1735
  return {
1545
1736
  ok: false,
1546
- error: `Unknown AI provider "${opts.aiProvider}"`,
1737
+ error: `Unknown AI provider "${requested}"`,
1547
1738
  hints: [`Choose one of: ${AI_PROVIDER_IDS.join(", ")}`]
1548
1739
  };
1549
1740
  }
1741
+ if (provider === "apple") {
1742
+ const apple = await probeApple(env);
1743
+ if (!apple.available) {
1744
+ return {
1745
+ ok: false,
1746
+ error: `Apple Intelligence isn't available: ${apple.reason}`,
1747
+ hints: ["Choose another provider with --ai-provider (e.g. openrouter, ollama)"]
1748
+ };
1749
+ }
1750
+ return appleResolution(apple);
1751
+ }
1550
1752
  if (!provider) {
1551
1753
  if (opts.aiBaseUrl) {
1552
1754
  provider = "custom";
1553
1755
  } else if (opts.aiKey) {
1554
1756
  provider = providerFromKey(opts.aiKey) ?? "openai";
1555
1757
  } else {
1758
+ const apple = await probeApple(env);
1759
+ if (apple.available) {
1760
+ return appleResolution(apple, "Using Apple Intelligence on this Mac (on-device, free) - pass --ai-provider to use another");
1761
+ }
1556
1762
  provider = DETECT_ORDER.find((id) => AI_PROVIDERS[id].envKeys.some((k) => env[k]));
1557
1763
  }
1558
1764
  }
@@ -1692,9 +1898,11 @@ function normalizeAnalysis(raw) {
1692
1898
  }
1693
1899
  var SEVERITY_ORDER = { critical: 0, error: 1, warning: 2, notice: 3, info: 3 };
1694
1900
  var MAX_ISSUES_IN_PROMPT = 60;
1695
- function buildPrompt(url, issues) {
1901
+ var APPLE_MAX_ISSUES = 25;
1902
+ var APPLE_MAX_TOKENS = 1200;
1903
+ function buildPrompt(url, issues, maxIssues = MAX_ISSUES_IN_PROMPT) {
1696
1904
  const ranked = [...issues].sort((a, b) => (SEVERITY_ORDER[a.severity] ?? 4) - (SEVERITY_ORDER[b.severity] ?? 4));
1697
- const shown = ranked.slice(0, MAX_ISSUES_IN_PROMPT);
1905
+ const shown = ranked.slice(0, maxIssues);
1698
1906
  const more = ranked.length > shown.length ? `
1699
1907
  (${ranked.length - shown.length} lower-severity issues omitted)` : "";
1700
1908
  return `Analyze these SEO audit results and provide prioritized recommendations:
@@ -1714,10 +1922,27 @@ Respond with only this JSON object - no prose, no code fences:
1714
1922
  }`;
1715
1923
  }
1716
1924
  async function runAIAnalysis(url, issues, ai) {
1717
- const prompt = buildPrompt(url, issues);
1718
- 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;
1935
+ if (ai.provider === "apple" && ai.helper) {
1936
+ const reply = await generateWithAppleAI(ai.helper, {
1937
+ system,
1938
+ prompt,
1939
+ maxTokens: Math.min(request.maxTokens ?? APPLE_MAX_TOKENS, APPLE_MAX_TOKENS)
1940
+ });
1941
+ if ("error" in reply) return { error: `${ai.label} request failed: ${reply.error}` };
1942
+ return reply;
1943
+ }
1719
1944
  const timeoutMs = ai.local ? 3e5 : 9e4;
1720
- const maxTokens = 4096;
1945
+ const maxTokens = Math.max(request.maxTokens ?? 4096, 4096);
1721
1946
  const headers = { "Content-Type": "application/json" };
1722
1947
  let endpoint;
1723
1948
  let body;
@@ -1725,7 +1950,7 @@ async function runAIAnalysis(url, issues, ai) {
1725
1950
  endpoint = `${ai.baseUrl}/messages`;
1726
1951
  headers["x-api-key"] = ai.apiKey || "";
1727
1952
  headers["anthropic-version"] = "2023-06-01";
1728
- 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 }] };
1729
1954
  } else {
1730
1955
  endpoint = `${ai.baseUrl}/chat/completions`;
1731
1956
  if (ai.apiKey) headers["Authorization"] = `Bearer ${ai.apiKey}`;
@@ -1736,12 +1961,12 @@ async function runAIAnalysis(url, issues, ai) {
1736
1961
  body = {
1737
1962
  model: ai.model,
1738
1963
  messages: [
1739
- { role: "system", content: system },
1964
+ ...system ? [{ role: "system", content: system }] : [],
1740
1965
  { role: "user", content: prompt }
1741
1966
  ],
1742
1967
  // OpenAI's GPT-5.6 rejects max_tokens (and temperature); everyone else expects max_tokens.
1743
1968
  ...ai.provider === "openai" && !ai.local ? { max_completion_tokens: maxTokens } : { max_tokens: maxTokens },
1744
- ...ai.jsonMode ? { response_format: { type: "json_object" } } : {}
1969
+ ...ai.jsonMode && request.json ? { response_format: { type: "json_object" } } : {}
1745
1970
  };
1746
1971
  }
1747
1972
  let response;
@@ -1765,6 +1990,9 @@ async function runAIAnalysis(url, issues, ai) {
1765
1990
  if (!content) {
1766
1991
  return { error: `${ai.label} returned no content (a reasoning model may have used its whole token budget - try another --ai-model)` };
1767
1992
  }
1993
+ return { text: content };
1994
+ }
1995
+ function parseAnalysis(content, ai) {
1768
1996
  const parsed = extractJsonObject(content);
1769
1997
  const analysis = parsed ? normalizeAnalysis(parsed) : null;
1770
1998
  if (!analysis) {
@@ -3018,7 +3246,7 @@ async function trackHistory(options) {
3018
3246
  }
3019
3247
  const days = options.days || 30;
3020
3248
  const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1e3);
3021
- 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 });
3022
3250
  if (error) {
3023
3251
  spinner.fail(`Failed to load history: ${error.message}`);
3024
3252
  return { success: false, error: error.message };
@@ -3027,7 +3255,7 @@ async function trackHistory(options) {
3027
3255
  const history = (historyData || []).map((row) => ({
3028
3256
  position: row.position,
3029
3257
  url: row.url,
3030
- recordedAt: new Date(row.recorded_at)
3258
+ recordedAt: new Date(row.checked_at)
3031
3259
  }));
3032
3260
  if (options.output === "json") {
3033
3261
  console.log(JSON.stringify({ keyword: options.keyword, history }, null, 2));
@@ -3035,7 +3263,9 @@ async function trackHistory(options) {
3035
3263
  console.log("\n" + import_chalk6.default.bold(`\u{1F4C8} History: "${options.keyword}"`));
3036
3264
  console.log("\u2550".repeat(50));
3037
3265
  if (history.length === 0) {
3038
- 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
+ `));
3039
3269
  } else {
3040
3270
  const positions = history.slice(0, 30).reverse().map((h) => h.position || 101);
3041
3271
  const sparkline = positions.map((p) => {
@@ -3046,7 +3276,7 @@ async function trackHistory(options) {
3046
3276
  }).join("");
3047
3277
  console.log(`
3048
3278
  ${sparkline} (last ${positions.length} checks)`);
3049
- console.log(import_chalk6.default.dim(" \u2190 newer older \u2192\n"));
3279
+ console.log(import_chalk6.default.dim(" \u2190 older newer \u2192\n"));
3050
3280
  const recent = history.slice(0, 10);
3051
3281
  for (const entry of recent) {
3052
3282
  console.log(` ${entry.recordedAt.toLocaleDateString()} ${formatPosition(entry.position)}`);
@@ -3360,6 +3590,40 @@ async function geo(options) {
3360
3590
  // src/commands/keywords.ts
3361
3591
  var readline2 = __toESM(require("readline"));
3362
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
3363
3627
  async function keywords(options) {
3364
3628
  console.log("\n\u{1F511} RankCLI - Keyword Research\n");
3365
3629
  console.log("This tool helps you find keywords you can actually rank for,");
@@ -3520,65 +3784,37 @@ async function keywordsQuick(url, providedSeeds) {
3520
3784
  `);
3521
3785
  }
3522
3786
  }
3523
- var SUPABASE_URL = "https://eqzlmjbvrtrglknphdai.supabase.co";
3524
3787
  async function keywordsAI(url, options = {}) {
3525
3788
  console.log("\n\u{1F916} AI-Powered Keyword Research\n");
3526
- console.log("This uses GPT-4 to analyze your site and generate keyword recommendations");
3527
- console.log("with competitive analysis and free tool ideas.\n");
3528
- const openaiApiKey = options.openaiApiKey || process.env.OPENAI_API_KEY;
3529
3789
  try {
3530
3790
  const parsed = new URL(url.startsWith("http") ? url : `https://${url}`);
3531
3791
  url = parsed.href;
3532
- console.log(`\u{1F4CD} Analyzing: ${url}`);
3533
- console.log("");
3534
- if (!options.forceLocal) {
3535
- console.log("\u{1F517} Connecting to RankCLI cloud...");
3536
- try {
3537
- const response = await fetch(`${SUPABASE_URL}/functions/v1/ai-keywords`, {
3538
- method: "POST",
3539
- headers: {
3540
- "Content-Type": "application/json"
3541
- // Auth header would go here if user is logged in
3542
- },
3543
- body: JSON.stringify({
3544
- url,
3545
- mode: "analyze"
3546
- })
3547
- });
3548
- if (response.ok) {
3549
- const result2 = await response.json();
3550
- if (result2.tier === "free" && result2.teaser) {
3551
- console.log("\n" + "\u2500".repeat(60));
3552
- console.log("\u{1F512} FREE TIER - Limited Preview");
3553
- console.log("\u2500".repeat(60));
3554
- console.log(`Found ${result2.teaser.totalKeywordsFound} keywords`);
3555
- console.log(`Top keywords: ${result2.teaser.topKeywords.join(", ")}`);
3556
- console.log(`
3557
- ${result2.teaser.upgradeMessage}`);
3558
- console.log('\n\u{1F4A1} Run "rankcli login" to access your paid plan features.\n');
3559
- return;
3560
- }
3561
- displayAIKeywordResults(result2);
3562
- return;
3563
- } else {
3564
- const errorText = await response.text();
3565
- console.log(`\u26A0\uFE0F Cloud request failed, running locally...
3566
- `);
3567
- }
3568
- } catch (err) {
3569
- console.log("\u26A0\uFE0F Cloud unavailable, running locally...\n");
3570
- }
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;
3571
3799
  }
3572
- if (!openaiApiKey) {
3573
- console.log("\u274C OpenAI API key required for local processing.");
3574
- 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;
3575
3807
  return;
3576
3808
  }
3577
- 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
+ `);
3578
3814
  const result = await (0, import_agent_runtime7.runAIKeywordResearch)({
3579
3815
  url,
3580
3816
  tier: "solo",
3581
- openaiApiKey,
3817
+ llm: keywordLLMFromAI(ai),
3582
3818
  enableEnhancedToolIdeas: true,
3583
3819
  competitiveSearchKeys: {
3584
3820
  braveApiKey: process.env.BRAVE_API_KEY,
@@ -3591,6 +3827,7 @@ ${result2.teaser.upgradeMessage}`);
3591
3827
  console.log(`
3592
3828
  \u274C Error: ${error instanceof Error ? error.message : "Unknown error"}
3593
3829
  `);
3830
+ process.exitCode = 1;
3594
3831
  }
3595
3832
  }
3596
3833
  function displayAIKeywordResults(result) {
@@ -4002,7 +4239,7 @@ async function testWebhook(channel, webhookUrl) {
4002
4239
  }
4003
4240
 
4004
4241
  // src/version.ts
4005
- var VERSION = "0.0.31";
4242
+ var VERSION = "0.0.33";
4006
4243
 
4007
4244
  // src/index.ts
4008
4245
  var program = new import_commander.Command();
@@ -4014,7 +4251,7 @@ program.command("logout").description("Log out of your RankCLI account").action(
4014
4251
  program.command("whoami").description("Show current logged in user").action(whoami);
4015
4252
  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);
4016
4253
  program.command("analyze").description("AI analyzes your codebase for SEO opportunities").option("-v, --verbose", "Show detailed output").action(analyze);
4017
- 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) => {
4254
+ program.command("audit").description("Run an SEO + GEO audit (280+ checks)").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; Apple Intelligence on a Mac that has it, or set RANKCLI_AI_PROVIDER): apple, 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) => {
4018
4255
  process.exitCode = await audit(options);
4019
4256
  });
4020
4257
  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);
@@ -4033,11 +4270,13 @@ program.command("fix").description("Agentic SEO fixer - detect, fix, commit, and
4033
4270
  }
4034
4271
  });
4035
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);
4036
- 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) => {
4037
4274
  if (options.ai && options.url) {
4038
4275
  await keywordsAI(options.url, {
4039
- forceLocal: options.local,
4040
- openaiApiKey: process.env.OPENAI_API_KEY
4276
+ aiProvider: options.aiProvider,
4277
+ aiKey: options.aiKey,
4278
+ aiModel: options.aiModel,
4279
+ aiBaseUrl: options.aiBaseUrl
4041
4280
  });
4042
4281
  } else if (options.competitor && options.url && options.seed) {
4043
4282
  const seeds = options.seed.split(",").map((s) => s.trim());
package/dist/index.mjs CHANGED
@@ -1274,6 +1274,177 @@ var TOGETHER_DEFAULT = "openai/gpt-oss-120b";
1274
1274
  var FIREWORKS_DEFAULT = "accounts/fireworks/models/gpt-oss-120b";
1275
1275
  var CEREBRAS_DEFAULT = "gpt-oss-120b";
1276
1276
 
1277
+ // src/lib/apple-ai.ts
1278
+ import { execFile, spawn } from "child_process";
1279
+ import { createHash as createHash2 } from "crypto";
1280
+ import { existsSync as existsSync4, mkdirSync, readFileSync as readFileSync4, renameSync, statSync as statSync2, writeFileSync as writeFileSync3 } from "fs";
1281
+ import { homedir, release } from "os";
1282
+ import { join as join4 } from "path";
1283
+
1284
+ // src/lib/apple-ai-source.ts
1285
+ var APPLE_AI_SWIFT_SOURCE = String.raw`import Foundation
1286
+ import FoundationModels
1287
+
1288
+ func reply(_ object: [String: Any], code: Int32 = 0) -> Never {
1289
+ let data = (try? JSONSerialization.data(withJSONObject: object)) ?? Data("{\"error\":\"could not encode the reply\"}".utf8)
1290
+ FileHandle.standardOutput.write(data)
1291
+ exit(code)
1292
+ }
1293
+
1294
+ let model = SystemLanguageModel.default
1295
+
1296
+ if CommandLine.arguments.contains("--check") {
1297
+ switch model.availability {
1298
+ case .available:
1299
+ reply(["available": true])
1300
+ case .unavailable(let reason):
1301
+ switch reason {
1302
+ case .deviceNotEligible: reply(["available": false, "reason": "deviceNotEligible"])
1303
+ case .appleIntelligenceNotEnabled: reply(["available": false, "reason": "appleIntelligenceNotEnabled"])
1304
+ case .modelNotReady: reply(["available": false, "reason": "modelNotReady"])
1305
+ @unknown default: reply(["available": false, "reason": "unavailable"])
1306
+ }
1307
+ @unknown default:
1308
+ reply(["available": false, "reason": "unavailable"])
1309
+ }
1310
+ }
1311
+
1312
+ struct Request: Decodable {
1313
+ let system: String?
1314
+ let prompt: String
1315
+ let maxTokens: Int?
1316
+ }
1317
+
1318
+ let input = FileHandle.standardInput.readDataToEndOfFile()
1319
+ guard let request = try? JSONDecoder().decode(Request.self, from: input) else {
1320
+ reply(["error": "expected a JSON request on stdin"], code: 2)
1321
+ }
1322
+
1323
+ let session: LanguageModelSession
1324
+ if let system = request.system {
1325
+ session = LanguageModelSession(instructions: system)
1326
+ } else {
1327
+ session = LanguageModelSession()
1328
+ }
1329
+
1330
+ do {
1331
+ let response = try await session.respond(
1332
+ to: request.prompt,
1333
+ options: GenerationOptions(maximumResponseTokens: request.maxTokens)
1334
+ )
1335
+ reply(["text": response.content])
1336
+ } catch let error as LanguageModelSession.GenerationError {
1337
+ switch error {
1338
+ case .exceededContextWindowSize:
1339
+ reply(["error": "the prompt is too long for the on-device model"], code: 1)
1340
+ case .guardrailViolation:
1341
+ reply(["error": "the on-device model's guardrails declined the request"], code: 1)
1342
+ case .assetsUnavailable:
1343
+ reply(["error": "the on-device model isn't downloaded yet"], code: 1)
1344
+ default:
1345
+ reply(["error": String(describing: error)], code: 1)
1346
+ }
1347
+ } catch {
1348
+ reply(["error": String(describing: error)], code: 1)
1349
+ }
1350
+ `;
1351
+
1352
+ // src/lib/apple-ai.ts
1353
+ function isAppleIntelligenceCapable(platform = process.platform, arch = process.arch, darwinRelease = release()) {
1354
+ return platform === "darwin" && arch === "arm64" && Number(darwinRelease.split(".")[0]) >= 25;
1355
+ }
1356
+ var REASONS = {
1357
+ deviceNotEligible: "this Mac doesn't support Apple Intelligence",
1358
+ appleIntelligenceNotEnabled: "Apple Intelligence is turned off (System Settings > Apple Intelligence & Siri)",
1359
+ modelNotReady: "the on-device model is still downloading"
1360
+ };
1361
+ function run(file, args, opts) {
1362
+ return new Promise((resolve) => {
1363
+ const child = spawn(file, args, { stdio: ["pipe", "pipe", "pipe"] });
1364
+ let stdout = "";
1365
+ let stderr = "";
1366
+ let timedOut = false;
1367
+ const timer = setTimeout(() => {
1368
+ timedOut = true;
1369
+ child.kill("SIGKILL");
1370
+ }, opts.timeoutMs);
1371
+ child.stdout.on("data", (d) => stdout += d);
1372
+ child.stderr.on("data", (d) => stderr += d);
1373
+ child.on("error", (error) => {
1374
+ clearTimeout(timer);
1375
+ resolve({ code: null, stdout, stderr: stderr || error.message, timedOut });
1376
+ });
1377
+ child.on("close", (code) => {
1378
+ clearTimeout(timer);
1379
+ resolve({ code, stdout, stderr, timedOut });
1380
+ });
1381
+ child.stdin.on("error", () => {
1382
+ });
1383
+ child.stdin.end(opts.input ?? "");
1384
+ });
1385
+ }
1386
+ function hasDeveloperTools() {
1387
+ return new Promise((resolve) => execFile("xcode-select", ["-p"], (error) => resolve(!error)));
1388
+ }
1389
+ var helperPromise = null;
1390
+ function ensureHelper(env) {
1391
+ if (env.RANKCLI_APPLE_AI_HELPER) return Promise.resolve({ helper: env.RANKCLI_APPLE_AI_HELPER });
1392
+ helperPromise ??= (async () => {
1393
+ const hash = createHash2("sha256").update(APPLE_AI_SWIFT_SOURCE).digest("hex").slice(0, 12);
1394
+ const dir = join4(homedir(), "Library", "Caches", "rankcli", `apple-ai-${hash}`);
1395
+ const helper = join4(dir, "rankcli-apple-ai");
1396
+ if (existsSync4(helper)) return { helper };
1397
+ const failed = join4(dir, "build-failed");
1398
+ if (existsSync4(failed) && Date.now() - statSync2(failed).mtimeMs < 7 * 24 * 60 * 60 * 1e3) {
1399
+ return { error: readFileSync4(failed, "utf8") };
1400
+ }
1401
+ if (!await hasDeveloperTools()) {
1402
+ return { error: "using it needs the Xcode command-line tools (xcode-select --install)" };
1403
+ }
1404
+ mkdirSync(dir, { recursive: true });
1405
+ const source = join4(dir, "main.swift");
1406
+ writeFileSync3(source, APPLE_AI_SWIFT_SOURCE);
1407
+ process.stderr.write("Preparing Apple Intelligence for RankCLI (one-time build of a small helper)...\n");
1408
+ const tmp = `${helper}.${process.pid}`;
1409
+ const build = await run("xcrun", ["swiftc", "-O", "-target", "arm64-apple-macos26.0", source, "-o", tmp], { timeoutMs: 18e4 });
1410
+ if (build.code !== 0 || !existsSync4(tmp)) {
1411
+ const firstError = build.stderr.split("\n").find((line) => /error:/.test(line))?.trim();
1412
+ const error = `the helper didn't build${firstError ? ` (${firstError})` : ""} - it needs the macOS 26 SDK (update Xcode or its command-line tools)`;
1413
+ writeFileSync3(failed, error);
1414
+ return { error };
1415
+ }
1416
+ renameSync(tmp, helper);
1417
+ return { helper };
1418
+ })();
1419
+ return helperPromise;
1420
+ }
1421
+ async function checkAppleAI(env = process.env) {
1422
+ if (!env.RANKCLI_APPLE_AI_HELPER && !isAppleIntelligenceCapable()) {
1423
+ return { available: false, reason: "it needs macOS 26 or later on Apple silicon" };
1424
+ }
1425
+ const ensured = await ensureHelper(env);
1426
+ if ("error" in ensured) return { available: false, reason: ensured.error };
1427
+ const check = await run(ensured.helper, ["--check"], { timeoutMs: 15e3 });
1428
+ try {
1429
+ const status = JSON.parse(check.stdout);
1430
+ if (status.available) return { available: true, helper: ensured.helper };
1431
+ return { available: false, reason: REASONS[status.reason ?? ""] ?? "Apple Intelligence is unavailable" };
1432
+ } catch {
1433
+ return { available: false, reason: `the helper failed (${(check.stderr || check.stdout).trim().slice(0, 120) || `exit ${check.code}`})` };
1434
+ }
1435
+ }
1436
+ async function generateWithAppleAI(helper, request, timeoutMs = 12e4) {
1437
+ const result = await run(helper, [], { input: JSON.stringify(request), timeoutMs });
1438
+ if (result.timedOut) return { error: `timed out after ${Math.round(timeoutMs / 1e3)}s` };
1439
+ try {
1440
+ const reply = JSON.parse(result.stdout);
1441
+ if (typeof reply.text === "string") return { text: reply.text };
1442
+ return { error: reply.error || "no reply" };
1443
+ } catch {
1444
+ return { error: (result.stderr || result.stdout).trim().slice(0, 200) || `exit ${result.code}` };
1445
+ }
1446
+ }
1447
+
1277
1448
  // src/lib/ai.ts
1278
1449
  var AI_PROVIDERS = {
1279
1450
  // Hosted
@@ -1288,6 +1459,7 @@ var AI_PROVIDERS = {
1288
1459
  together: { label: "Together", baseUrl: "https://api.together.xyz/v1", model: TOGETHER_DEFAULT, envKeys: ["TOGETHER_API_KEY"] },
1289
1460
  fireworks: { label: "Fireworks", baseUrl: "https://api.fireworks.ai/inference/v1", model: FIREWORKS_DEFAULT, envKeys: ["FIREWORKS_API_KEY"] },
1290
1461
  // Local / self-hosted
1462
+ apple: { label: "Apple Intelligence", model: "on-device", envKeys: [], local: true },
1291
1463
  ollama: { label: "Ollama", baseUrl: "http://localhost:11434/v1", envKeys: [], local: true },
1292
1464
  lmstudio: { label: "LM Studio", baseUrl: "http://localhost:1234/v1", envKeys: [], local: true },
1293
1465
  llamacpp: { label: "llama.cpp", baseUrl: "http://localhost:8080/v1", envKeys: [], local: true },
@@ -1352,28 +1524,62 @@ async function firstServerModel(baseUrl, apiKey) {
1352
1524
  }
1353
1525
  function noProviderHints() {
1354
1526
  return [
1527
+ "On a Mac with Apple Intelligence (macOS 26+), the on-device model is used automatically",
1355
1528
  `Set one of ${AI_ENV_KEYS.join(", ")}, or pass --ai-key`,
1356
1529
  "Or run a local model: start Ollama (https://ollama.com) and `ollama pull llama3.2` - it is picked up automatically",
1357
1530
  "Or point at any OpenAI-compatible server: --ai-base-url http://localhost:8080/v1 --ai-model <name>"
1358
1531
  ];
1359
1532
  }
1360
- async function resolveAIProvider(opts, env = process.env, probe = probeOllama) {
1361
- let provider = opts.aiProvider?.trim().toLowerCase();
1533
+ function appleResolution(status, notice) {
1534
+ const preset = AI_PROVIDERS.apple;
1535
+ return {
1536
+ ok: true,
1537
+ ai: {
1538
+ provider: "apple",
1539
+ label: preset.label,
1540
+ baseUrl: "",
1541
+ model: preset.model,
1542
+ jsonMode: false,
1543
+ anthropic: false,
1544
+ local: true,
1545
+ helper: status.helper,
1546
+ notice
1547
+ }
1548
+ };
1549
+ }
1550
+ async function resolveAIProvider(opts, env = process.env, probe = probeOllama, probeApple = checkAppleAI) {
1551
+ const requested = opts.aiProvider?.trim() || (!opts.aiKey && !opts.aiBaseUrl ? env.RANKCLI_AI_PROVIDER?.trim() : void 0);
1552
+ let provider = requested?.toLowerCase();
1362
1553
  let notice;
1363
1554
  let ollamaModels = null;
1364
1555
  if (provider && !AI_PROVIDERS[provider]) {
1365
1556
  return {
1366
1557
  ok: false,
1367
- error: `Unknown AI provider "${opts.aiProvider}"`,
1558
+ error: `Unknown AI provider "${requested}"`,
1368
1559
  hints: [`Choose one of: ${AI_PROVIDER_IDS.join(", ")}`]
1369
1560
  };
1370
1561
  }
1562
+ if (provider === "apple") {
1563
+ const apple = await probeApple(env);
1564
+ if (!apple.available) {
1565
+ return {
1566
+ ok: false,
1567
+ error: `Apple Intelligence isn't available: ${apple.reason}`,
1568
+ hints: ["Choose another provider with --ai-provider (e.g. openrouter, ollama)"]
1569
+ };
1570
+ }
1571
+ return appleResolution(apple);
1572
+ }
1371
1573
  if (!provider) {
1372
1574
  if (opts.aiBaseUrl) {
1373
1575
  provider = "custom";
1374
1576
  } else if (opts.aiKey) {
1375
1577
  provider = providerFromKey(opts.aiKey) ?? "openai";
1376
1578
  } else {
1579
+ const apple = await probeApple(env);
1580
+ if (apple.available) {
1581
+ return appleResolution(apple, "Using Apple Intelligence on this Mac (on-device, free) - pass --ai-provider to use another");
1582
+ }
1377
1583
  provider = DETECT_ORDER.find((id) => AI_PROVIDERS[id].envKeys.some((k) => env[k]));
1378
1584
  }
1379
1585
  }
@@ -1513,9 +1719,11 @@ function normalizeAnalysis(raw) {
1513
1719
  }
1514
1720
  var SEVERITY_ORDER = { critical: 0, error: 1, warning: 2, notice: 3, info: 3 };
1515
1721
  var MAX_ISSUES_IN_PROMPT = 60;
1516
- function buildPrompt(url, issues) {
1722
+ var APPLE_MAX_ISSUES = 25;
1723
+ var APPLE_MAX_TOKENS = 1200;
1724
+ function buildPrompt(url, issues, maxIssues = MAX_ISSUES_IN_PROMPT) {
1517
1725
  const ranked = [...issues].sort((a, b) => (SEVERITY_ORDER[a.severity] ?? 4) - (SEVERITY_ORDER[b.severity] ?? 4));
1518
- const shown = ranked.slice(0, MAX_ISSUES_IN_PROMPT);
1726
+ const shown = ranked.slice(0, maxIssues);
1519
1727
  const more = ranked.length > shown.length ? `
1520
1728
  (${ranked.length - shown.length} lower-severity issues omitted)` : "";
1521
1729
  return `Analyze these SEO audit results and provide prioritized recommendations:
@@ -1535,10 +1743,27 @@ Respond with only this JSON object - no prose, no code fences:
1535
1743
  }`;
1536
1744
  }
1537
1745
  async function runAIAnalysis(url, issues, ai) {
1538
- const prompt = buildPrompt(url, issues);
1539
- 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;
1756
+ if (ai.provider === "apple" && ai.helper) {
1757
+ const reply = await generateWithAppleAI(ai.helper, {
1758
+ system,
1759
+ prompt,
1760
+ maxTokens: Math.min(request.maxTokens ?? APPLE_MAX_TOKENS, APPLE_MAX_TOKENS)
1761
+ });
1762
+ if ("error" in reply) return { error: `${ai.label} request failed: ${reply.error}` };
1763
+ return reply;
1764
+ }
1540
1765
  const timeoutMs = ai.local ? 3e5 : 9e4;
1541
- const maxTokens = 4096;
1766
+ const maxTokens = Math.max(request.maxTokens ?? 4096, 4096);
1542
1767
  const headers = { "Content-Type": "application/json" };
1543
1768
  let endpoint;
1544
1769
  let body;
@@ -1546,7 +1771,7 @@ async function runAIAnalysis(url, issues, ai) {
1546
1771
  endpoint = `${ai.baseUrl}/messages`;
1547
1772
  headers["x-api-key"] = ai.apiKey || "";
1548
1773
  headers["anthropic-version"] = "2023-06-01";
1549
- 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 }] };
1550
1775
  } else {
1551
1776
  endpoint = `${ai.baseUrl}/chat/completions`;
1552
1777
  if (ai.apiKey) headers["Authorization"] = `Bearer ${ai.apiKey}`;
@@ -1557,12 +1782,12 @@ async function runAIAnalysis(url, issues, ai) {
1557
1782
  body = {
1558
1783
  model: ai.model,
1559
1784
  messages: [
1560
- { role: "system", content: system },
1785
+ ...system ? [{ role: "system", content: system }] : [],
1561
1786
  { role: "user", content: prompt }
1562
1787
  ],
1563
1788
  // OpenAI's GPT-5.6 rejects max_tokens (and temperature); everyone else expects max_tokens.
1564
1789
  ...ai.provider === "openai" && !ai.local ? { max_completion_tokens: maxTokens } : { max_tokens: maxTokens },
1565
- ...ai.jsonMode ? { response_format: { type: "json_object" } } : {}
1790
+ ...ai.jsonMode && request.json ? { response_format: { type: "json_object" } } : {}
1566
1791
  };
1567
1792
  }
1568
1793
  let response;
@@ -1586,6 +1811,9 @@ async function runAIAnalysis(url, issues, ai) {
1586
1811
  if (!content) {
1587
1812
  return { error: `${ai.label} returned no content (a reasoning model may have used its whole token budget - try another --ai-model)` };
1588
1813
  }
1814
+ return { text: content };
1815
+ }
1816
+ function parseAnalysis(content, ai) {
1589
1817
  const parsed = extractJsonObject(content);
1590
1818
  const analysis = parsed ? normalizeAnalysis(parsed) : null;
1591
1819
  if (!analysis) {
@@ -2059,8 +2287,8 @@ Examples:
2059
2287
  `;
2060
2288
 
2061
2289
  // src/commands/apply.ts
2062
- import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
2063
- import { join as join4 } from "path";
2290
+ import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
2291
+ import { join as join5 } from "path";
2064
2292
  import {
2065
2293
  runAuditWithFixes,
2066
2294
  generateFixes,
@@ -2094,11 +2322,11 @@ function truncate(str, maxLen) {
2094
2322
  }
2095
2323
  async function apply(options) {
2096
2324
  const cwd = process.cwd();
2097
- const configPath = join4(cwd, "rankcli.config.json");
2325
+ const configPath = join5(cwd, "rankcli.config.json");
2098
2326
  let config2 = null;
2099
- if (existsSync4(configPath)) {
2327
+ if (existsSync5(configPath)) {
2100
2328
  try {
2101
- config2 = JSON.parse(readFileSync4(configPath, "utf-8"));
2329
+ config2 = JSON.parse(readFileSync5(configPath, "utf-8"));
2102
2330
  } catch {
2103
2331
  }
2104
2332
  }
@@ -2222,7 +2450,7 @@ async function apply(options) {
2222
2450
  }
2223
2451
  if (config2 && !config2.url) {
2224
2452
  config2.url = url;
2225
- writeFileSync3(configPath, JSON.stringify(config2, null, 2));
2453
+ writeFileSync4(configPath, JSON.stringify(config2, null, 2));
2226
2454
  console.log("\n\u{1F4BE} Updated config with URL.");
2227
2455
  }
2228
2456
  console.log("\n" + "\u2500".repeat(50));
@@ -2849,7 +3077,7 @@ async function trackHistory(options) {
2849
3077
  }
2850
3078
  const days = options.days || 30;
2851
3079
  const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1e3);
2852
- 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 });
2853
3081
  if (error) {
2854
3082
  spinner.fail(`Failed to load history: ${error.message}`);
2855
3083
  return { success: false, error: error.message };
@@ -2858,7 +3086,7 @@ async function trackHistory(options) {
2858
3086
  const history = (historyData || []).map((row) => ({
2859
3087
  position: row.position,
2860
3088
  url: row.url,
2861
- recordedAt: new Date(row.recorded_at)
3089
+ recordedAt: new Date(row.checked_at)
2862
3090
  }));
2863
3091
  if (options.output === "json") {
2864
3092
  console.log(JSON.stringify({ keyword: options.keyword, history }, null, 2));
@@ -2866,7 +3094,9 @@ async function trackHistory(options) {
2866
3094
  console.log("\n" + chalk6.bold(`\u{1F4C8} History: "${options.keyword}"`));
2867
3095
  console.log("\u2550".repeat(50));
2868
3096
  if (history.length === 0) {
2869
- 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
+ `));
2870
3100
  } else {
2871
3101
  const positions = history.slice(0, 30).reverse().map((h) => h.position || 101);
2872
3102
  const sparkline = positions.map((p) => {
@@ -2877,7 +3107,7 @@ async function trackHistory(options) {
2877
3107
  }).join("");
2878
3108
  console.log(`
2879
3109
  ${sparkline} (last ${positions.length} checks)`);
2880
- console.log(chalk6.dim(" \u2190 newer older \u2192\n"));
3110
+ console.log(chalk6.dim(" \u2190 older newer \u2192\n"));
2881
3111
  const recent = history.slice(0, 10);
2882
3112
  for (const entry of recent) {
2883
3113
  console.log(` ${entry.recordedAt.toLocaleDateString()} ${formatPosition(entry.position)}`);
@@ -3201,6 +3431,40 @@ import {
3201
3431
  formatTopicReport,
3202
3432
  runAIKeywordResearch
3203
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
3204
3468
  async function keywords(options) {
3205
3469
  console.log("\n\u{1F511} RankCLI - Keyword Research\n");
3206
3470
  console.log("This tool helps you find keywords you can actually rank for,");
@@ -3361,65 +3625,37 @@ async function keywordsQuick(url, providedSeeds) {
3361
3625
  `);
3362
3626
  }
3363
3627
  }
3364
- var SUPABASE_URL = "https://eqzlmjbvrtrglknphdai.supabase.co";
3365
3628
  async function keywordsAI(url, options = {}) {
3366
3629
  console.log("\n\u{1F916} AI-Powered Keyword Research\n");
3367
- console.log("This uses GPT-4 to analyze your site and generate keyword recommendations");
3368
- console.log("with competitive analysis and free tool ideas.\n");
3369
- const openaiApiKey = options.openaiApiKey || process.env.OPENAI_API_KEY;
3370
3630
  try {
3371
3631
  const parsed = new URL(url.startsWith("http") ? url : `https://${url}`);
3372
3632
  url = parsed.href;
3373
- console.log(`\u{1F4CD} Analyzing: ${url}`);
3374
- console.log("");
3375
- if (!options.forceLocal) {
3376
- console.log("\u{1F517} Connecting to RankCLI cloud...");
3377
- try {
3378
- const response = await fetch(`${SUPABASE_URL}/functions/v1/ai-keywords`, {
3379
- method: "POST",
3380
- headers: {
3381
- "Content-Type": "application/json"
3382
- // Auth header would go here if user is logged in
3383
- },
3384
- body: JSON.stringify({
3385
- url,
3386
- mode: "analyze"
3387
- })
3388
- });
3389
- if (response.ok) {
3390
- const result2 = await response.json();
3391
- if (result2.tier === "free" && result2.teaser) {
3392
- console.log("\n" + "\u2500".repeat(60));
3393
- console.log("\u{1F512} FREE TIER - Limited Preview");
3394
- console.log("\u2500".repeat(60));
3395
- console.log(`Found ${result2.teaser.totalKeywordsFound} keywords`);
3396
- console.log(`Top keywords: ${result2.teaser.topKeywords.join(", ")}`);
3397
- console.log(`
3398
- ${result2.teaser.upgradeMessage}`);
3399
- console.log('\n\u{1F4A1} Run "rankcli login" to access your paid plan features.\n');
3400
- return;
3401
- }
3402
- displayAIKeywordResults(result2);
3403
- return;
3404
- } else {
3405
- const errorText = await response.text();
3406
- console.log(`\u26A0\uFE0F Cloud request failed, running locally...
3407
- `);
3408
- }
3409
- } catch (err) {
3410
- console.log("\u26A0\uFE0F Cloud unavailable, running locally...\n");
3411
- }
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;
3412
3640
  }
3413
- if (!openaiApiKey) {
3414
- console.log("\u274C OpenAI API key required for local processing.");
3415
- 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;
3416
3648
  return;
3417
3649
  }
3418
- 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
+ `);
3419
3655
  const result = await runAIKeywordResearch({
3420
3656
  url,
3421
3657
  tier: "solo",
3422
- openaiApiKey,
3658
+ llm: keywordLLMFromAI(ai),
3423
3659
  enableEnhancedToolIdeas: true,
3424
3660
  competitiveSearchKeys: {
3425
3661
  braveApiKey: process.env.BRAVE_API_KEY,
@@ -3432,6 +3668,7 @@ ${result2.teaser.upgradeMessage}`);
3432
3668
  console.log(`
3433
3669
  \u274C Error: ${error instanceof Error ? error.message : "Unknown error"}
3434
3670
  `);
3671
+ process.exitCode = 1;
3435
3672
  }
3436
3673
  }
3437
3674
  function displayAIKeywordResults(result) {
@@ -3858,7 +4095,7 @@ async function testWebhook(channel, webhookUrl) {
3858
4095
  }
3859
4096
 
3860
4097
  // src/version.ts
3861
- var VERSION = "0.0.31";
4098
+ var VERSION = "0.0.33";
3862
4099
 
3863
4100
  // src/index.ts
3864
4101
  var program = new Command();
@@ -3870,7 +4107,7 @@ program.command("logout").description("Log out of your RankCLI account").action(
3870
4107
  program.command("whoami").description("Show current logged in user").action(whoami);
3871
4108
  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);
3872
4109
  program.command("analyze").description("AI analyzes your codebase for SEO opportunities").option("-v, --verbose", "Show detailed output").action(analyze);
3873
- 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) => {
4110
+ program.command("audit").description("Run an SEO + GEO audit (280+ checks)").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; Apple Intelligence on a Mac that has it, or set RANKCLI_AI_PROVIDER): apple, 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) => {
3874
4111
  process.exitCode = await audit(options);
3875
4112
  });
3876
4113
  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);
@@ -3889,11 +4126,13 @@ program.command("fix").description("Agentic SEO fixer - detect, fix, commit, and
3889
4126
  }
3890
4127
  });
3891
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);
3892
- 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) => {
3893
4130
  if (options.ai && options.url) {
3894
4131
  await keywordsAI(options.url, {
3895
- forceLocal: options.local,
3896
- openaiApiKey: process.env.OPENAI_API_KEY
4132
+ aiProvider: options.aiProvider,
4133
+ aiKey: options.aiKey,
4134
+ aiModel: options.aiModel,
4135
+ aiBaseUrl: options.aiBaseUrl
3897
4136
  });
3898
4137
  } else if (options.competitor && options.url && options.seed) {
3899
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.31",
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.22",
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",