@rankcli/cli 0.0.31 → 0.0.32

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
  |----------|---------|----------|---------------|
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,8 +1922,17 @@ 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
1925
  const system = "You are an SEO expert. Respond with valid JSON only.";
1926
+ if (ai.provider === "apple" && ai.helper) {
1927
+ const reply = await generateWithAppleAI(ai.helper, {
1928
+ system,
1929
+ prompt: buildPrompt(url, issues, APPLE_MAX_ISSUES),
1930
+ maxTokens: APPLE_MAX_TOKENS
1931
+ });
1932
+ if ("error" in reply) return { error: `${ai.label} request failed: ${reply.error}` };
1933
+ return parseAnalysis(reply.text, ai);
1934
+ }
1935
+ const prompt = buildPrompt(url, issues);
1719
1936
  const timeoutMs = ai.local ? 3e5 : 9e4;
1720
1937
  const maxTokens = 4096;
1721
1938
  const headers = { "Content-Type": "application/json" };
@@ -1765,6 +1982,9 @@ async function runAIAnalysis(url, issues, ai) {
1765
1982
  if (!content) {
1766
1983
  return { error: `${ai.label} returned no content (a reasoning model may have used its whole token budget - try another --ai-model)` };
1767
1984
  }
1985
+ return parseAnalysis(content, ai);
1986
+ }
1987
+ function parseAnalysis(content, ai) {
1768
1988
  const parsed = extractJsonObject(content);
1769
1989
  const analysis = parsed ? normalizeAnalysis(parsed) : null;
1770
1990
  if (!analysis) {
@@ -4002,7 +4222,7 @@ async function testWebhook(channel, webhookUrl) {
4002
4222
  }
4003
4223
 
4004
4224
  // src/version.ts
4005
- var VERSION = "0.0.31";
4225
+ var VERSION = "0.0.32";
4006
4226
 
4007
4227
  // src/index.ts
4008
4228
  var program = new import_commander.Command();
@@ -4014,7 +4234,7 @@ program.command("logout").description("Log out of your RankCLI account").action(
4014
4234
  program.command("whoami").description("Show current logged in user").action(whoami);
4015
4235
  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
4236
  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) => {
4237
+ 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
4238
  process.exitCode = await audit(options);
4019
4239
  });
4020
4240
  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);
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,8 +1743,17 @@ 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
1746
  const system = "You are an SEO expert. Respond with valid JSON only.";
1747
+ if (ai.provider === "apple" && ai.helper) {
1748
+ const reply = await generateWithAppleAI(ai.helper, {
1749
+ system,
1750
+ prompt: buildPrompt(url, issues, APPLE_MAX_ISSUES),
1751
+ maxTokens: APPLE_MAX_TOKENS
1752
+ });
1753
+ if ("error" in reply) return { error: `${ai.label} request failed: ${reply.error}` };
1754
+ return parseAnalysis(reply.text, ai);
1755
+ }
1756
+ const prompt = buildPrompt(url, issues);
1540
1757
  const timeoutMs = ai.local ? 3e5 : 9e4;
1541
1758
  const maxTokens = 4096;
1542
1759
  const headers = { "Content-Type": "application/json" };
@@ -1586,6 +1803,9 @@ async function runAIAnalysis(url, issues, ai) {
1586
1803
  if (!content) {
1587
1804
  return { error: `${ai.label} returned no content (a reasoning model may have used its whole token budget - try another --ai-model)` };
1588
1805
  }
1806
+ return parseAnalysis(content, ai);
1807
+ }
1808
+ function parseAnalysis(content, ai) {
1589
1809
  const parsed = extractJsonObject(content);
1590
1810
  const analysis = parsed ? normalizeAnalysis(parsed) : null;
1591
1811
  if (!analysis) {
@@ -2059,8 +2279,8 @@ Examples:
2059
2279
  `;
2060
2280
 
2061
2281
  // src/commands/apply.ts
2062
- import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
2063
- import { join as join4 } from "path";
2282
+ import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
2283
+ import { join as join5 } from "path";
2064
2284
  import {
2065
2285
  runAuditWithFixes,
2066
2286
  generateFixes,
@@ -2094,11 +2314,11 @@ function truncate(str, maxLen) {
2094
2314
  }
2095
2315
  async function apply(options) {
2096
2316
  const cwd = process.cwd();
2097
- const configPath = join4(cwd, "rankcli.config.json");
2317
+ const configPath = join5(cwd, "rankcli.config.json");
2098
2318
  let config2 = null;
2099
- if (existsSync4(configPath)) {
2319
+ if (existsSync5(configPath)) {
2100
2320
  try {
2101
- config2 = JSON.parse(readFileSync4(configPath, "utf-8"));
2321
+ config2 = JSON.parse(readFileSync5(configPath, "utf-8"));
2102
2322
  } catch {
2103
2323
  }
2104
2324
  }
@@ -2222,7 +2442,7 @@ async function apply(options) {
2222
2442
  }
2223
2443
  if (config2 && !config2.url) {
2224
2444
  config2.url = url;
2225
- writeFileSync3(configPath, JSON.stringify(config2, null, 2));
2445
+ writeFileSync4(configPath, JSON.stringify(config2, null, 2));
2226
2446
  console.log("\n\u{1F4BE} Updated config with URL.");
2227
2447
  }
2228
2448
  console.log("\n" + "\u2500".repeat(50));
@@ -3858,7 +4078,7 @@ async function testWebhook(channel, webhookUrl) {
3858
4078
  }
3859
4079
 
3860
4080
  // src/version.ts
3861
- var VERSION = "0.0.31";
4081
+ var VERSION = "0.0.32";
3862
4082
 
3863
4083
  // src/index.ts
3864
4084
  var program = new Command();
@@ -3870,7 +4090,7 @@ program.command("logout").description("Log out of your RankCLI account").action(
3870
4090
  program.command("whoami").description("Show current logged in user").action(whoami);
3871
4091
  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
4092
  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) => {
4093
+ 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
4094
  process.exitCode = await audit(options);
3875
4095
  });
3876
4096
  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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rankcli/cli",
3
- "version": "0.0.31",
3
+ "version": "0.0.32",
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.23",
49
49
  "@supabase/supabase-js": "^2.98.0",
50
50
  "chalk": "^5.3.0",
51
51
  "commander": "^12.0.0",