@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 +6 -2
- package/dist/index.js +307 -68
- package/dist/index.mjs +313 -74
- package/package.json +2 -2
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.
|
|
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
|
-
|
|
1540
|
-
|
|
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 "${
|
|
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
|
-
|
|
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,
|
|
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
|
|
1718
|
-
|
|
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("
|
|
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.
|
|
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(
|
|
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
|
|
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
|
-
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
console.log(
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
|
|
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
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
|
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", "
|
|
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
|
-
|
|
4040
|
-
|
|
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
|
-
|
|
1361
|
-
|
|
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 "${
|
|
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
|
-
|
|
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,
|
|
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
|
|
1539
|
-
|
|
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
|
|
2063
|
-
import { join as
|
|
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 =
|
|
2325
|
+
const configPath = join5(cwd, "rankcli.config.json");
|
|
2098
2326
|
let config2 = null;
|
|
2099
|
-
if (
|
|
2327
|
+
if (existsSync5(configPath)) {
|
|
2100
2328
|
try {
|
|
2101
|
-
config2 = JSON.parse(
|
|
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
|
-
|
|
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("
|
|
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.
|
|
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(
|
|
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
|
|
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
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
console.log(
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
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
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
|
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", "
|
|
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
|
-
|
|
3896
|
-
|
|
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.
|
|
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.
|
|
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",
|