seo-intel 1.1.8 → 1.1.10

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/.env.example CHANGED
@@ -8,6 +8,8 @@
8
8
  # ── Analysis Model (cloud, pick one) ──────────────────────────────────────
9
9
  # Gemini: Best value — 1M context, cheapest (~$0.01-0.05/analysis)
10
10
  GEMINI_API_KEY=
11
+ # GEMINI_TIMEOUT_MS=120000
12
+ # OPENCLAW_ANALYSIS_MODEL=default
11
13
 
12
14
  # Claude: Best quality — nuanced strategic reasoning (~$0.10-0.30/analysis)
13
15
  # ANTHROPIC_API_KEY=
package/CHANGELOG.md CHANGED
@@ -1,42 +1,67 @@
1
1
  # Changelog
2
2
 
3
- ## 1.1.8 (2026-03-27)
3
+ ## 1.1.10 (2026-03-27)
4
4
 
5
- - Rebranded all references from froggo.pro → ukkometa.fi (endpoints, dashboard links, license validation, bot user-agents, skill)
6
- - Pricing updated: €9.99/mo · €79/yr
7
- - Contact updated: ukko@ukkometa.fi
8
- - Added README.md and CHANGELOG.md to npm package and LS zip
5
+ ### Security
6
+ - Fix SSRF: llms.txt URLs now respect robots.txt before enqueue (crawler/index.js)
7
+
8
+ ### Fixes
9
+ - SQL injection audit complete — all queries use parameterised statements (no changes needed)
10
+
11
+ ### Testing
12
+ - Mock crawl test passes end-to-end: crawls http://localhost:19876, stores 7 pages in SQLite
13
+ - CI: Ubuntu job now runs mock crawl test after smoke checks
14
+ - Fixed mock-crawl-test.js: server binds to 127.0.0.1, CLI resolved from install root, DB assertions corrected
15
+
16
+ ## 1.1.9 (2026-03-27)
17
+
18
+ ### Security
19
+ - Fix shell injection risk in Gemini CLI integration (execSync → spawnSync + stdin)
20
+ - Fix SSRF vector in llms.txt URL processing (hostname validation)
21
+ - Fix SSRF: llms.txt URLs now respect robots.txt before enqueue
22
+ - Set license cache file permissions to 0600 (owner-only)
23
+ - SQL injection audit — all queries verified to use parameterised statements
24
+
25
+ ### Fixes
26
+ - Crawler no longer upgrades http://localhost to https (fixes local/mock testing)
27
+ - Updater cache moved to ~/.seo-intel/ (fixes permission errors on Linux global install)
28
+ - Clear actionable error when Gemini times out and OpenClaw gateway is down
29
+ - Project name passed to error context for timeout messages
30
+
31
+ ### Improvements
32
+ - Defensive logging when license variant name is unrecognised
33
+ - Setup wizard: Cloud Analysis column (gemini-3.1-pro, claude-sonnet-4-6, claude-opus-4-6, gpt-5.4, deepseek-r1) with API key input
34
+ - Setup wizard: Agentic Setup picker — OpenClaw, Claude Code, Codex CLI, Perplexity with tailored copy-paste prompts
35
+ - Setup wizard: Step 2 expanded to 1100px, OpenClaw as floating sidebar
9
36
 
10
37
  ## 1.1.7 (2026-03-26)
11
38
 
12
- ### New Features
13
- - **Programmatic Template Intelligence** (`seo-intel templates <project>`) — detect URL pattern groups (e.g. `/token/*`, `/blog/*`), stealth-crawl samples, overlay GSC data, and score each group with keep/noindex/improve verdicts. Pro-gated.
14
- - **Stale domain auto-pruning** — domains removed from config are now automatically cleaned from the DB (pages, keywords, extractions, schemas, headings, links) on next crawl. No more ghost data from renamed/removed subdomains.
15
- - **Manual prune** — `seo-intel competitors <project> --prune` to clean stale DB entries on demand.
16
- - **Full body text storage** — crawler now stores full page body text in DB (up to 200K chars) for richer extraction and analysis. Log output stays compact.
39
+ ### New
40
+ - **Programmatic Template Intelligence** (`seo-intel templates <project>`) — detect URL pattern groups (e.g. `/token/*`, `/blog/*`), stealth-crawl samples, overlay GSC data, and score each group with keep/noindex/improve verdicts
41
+ - **Stale domain auto-pruning** — domains removed from config are automatically cleaned from DB on next crawl
42
+ - **Manual prune** — `seo-intel competitors <project> --prune` to clean stale DB entries on demand
43
+ - **Full body text storage** — crawler stores full page content in DB for richer offline extraction
17
44
 
18
45
  ### Improvements
19
- - **Background crawl/extract** — long-running crawl and extract jobs now survive browser tab close. Terminal shows "backgrounded" instead of "disconnected", and jobs continue server-side.
20
- - **Dashboard terminal** — stealth flag now visible in terminal command display. Stop button properly closes SSE + server-side process. Status bar syncs with terminal state.
21
- - **Templates button** added to dashboard terminal panel.
22
- - **Dashboard refresh** — crawl and analyze now always regenerate the multi-project dashboard, keeping all projects current.
23
- - **Config remove = DB remove** — `--remove` and `--remove-owned` now auto-prune matching DB data, not just config JSON.
46
+ - **Background crawl/extract** — long-running jobs survive browser tab close
47
+ - **Dashboard terminal** — stealth flag visible, stop button works properly, status bar syncs
48
+ - **Templates button** added to dashboard terminal panel
49
+ - **Dashboard refresh** — crawl and analyze always regenerate full multi-project dashboard
50
+ - **Config remove = DB remove** — `--remove` and `--remove-owned` auto-prune matching DB data
24
51
 
25
52
  ### Fixes
26
- - SSE disconnect no longer kills crawl/extract processes (detached child process).
27
- - Terminal command display now shows `--stealth` flag when enabled.
53
+ - SSE disconnect no longer kills crawl/extract processes
54
+ - Terminal command display shows `--stealth` flag when enabled
28
55
 
29
56
  ## 1.1.6 (2026-03-24)
30
57
 
31
- - Stop button, stealth sync, extraction layout, EADDRINUSE recovery.
58
+ - Stop button for crawl/extract jobs in dashboard
59
+ - Stealth toggle sync between status bar and terminal
60
+ - Extraction status bar layout improvements (CSS grid)
61
+ - EADDRINUSE recovery — server opens existing dashboard instead of crashing
32
62
 
33
63
  ## 1.1.5 (2026-03-21)
34
64
 
35
- - Update checker, job stop API, background analyze, LAN Ollama hosts, `html` CLI command, wizard UX improvements.
36
-
37
- ## 1.1.8 (2026-03-27)
38
-
39
- - Rebranded all references from froggo.pro → ukkometa.fi (endpoints, dashboard links, license validation, bot user-agents, skill)
40
- - Pricing updated: €9.99/mo · €79/yr
41
- - Contact updated: ukko@ukkometa.fi
42
- - Added README.md and CHANGELOG.md to npm package and LS zip
65
+ - Update checker, job stop API, background analyze
66
+ - LAN Ollama host support with fallback
67
+ - `html` CLI command, wizard UX improvements
package/cli.js CHANGED
@@ -1,4 +1,16 @@
1
1
  #!/usr/bin/env node
2
+
3
+ // ── Node.js version guard ───────────────────────────────────────────────
4
+ const [major, minor] = process.versions.node.split('.').map(Number);
5
+ if (major < 22 || (major === 22 && minor < 5)) {
6
+ console.error(
7
+ `\n SEO Intel requires Node.js 22.5 or later.\n` +
8
+ ` You have Node.js ${process.versions.node}.\n\n` +
9
+ ` Install the latest LTS: https://nodejs.org\n`
10
+ );
11
+ process.exit(1);
12
+ }
13
+
2
14
  import 'dotenv/config';
3
15
  import { program } from 'commander';
4
16
  import { spawnSync } from 'child_process';
@@ -47,6 +59,13 @@ checkForUpdates();
47
59
  try { mkdirSync(join(__dirname, 'reports'), { recursive: true }); } catch { /* ok */ }
48
60
  try { mkdirSync(join(__dirname, 'config'), { recursive: true }); } catch { /* ok */ }
49
61
 
62
+ function defaultSiteUrl(domain) {
63
+ const host = String(domain || '').trim();
64
+ const hostname = host.split(':')[0].replace(/^\[|\]$/g, '');
65
+ const protocol = hostname === 'localhost' || hostname === '127.0.0.1' ? 'http' : 'https';
66
+ return `${protocol}://${host}`;
67
+ }
68
+
50
69
  // ── AI AVAILABILITY PREFLIGHT ────────────────────────────────────────────
51
70
  /**
52
71
  * Check if any AI extraction backend is reachable.
@@ -435,7 +454,7 @@ program
435
454
  else if (config.competitors?.includes(site)) site.role = 'competitor';
436
455
  else site.role = 'owned';
437
456
  }
438
- if (!site.url && site.domain) site.url = `https://${site.domain}`;
457
+ if (!site.url && site.domain) site.url = defaultSiteUrl(site.domain);
439
458
  }
440
459
 
441
460
  const sites = opts.domain
@@ -674,7 +693,8 @@ program
674
693
  console.log(chalk.gray(`Prompt saved: ${promptPath}`));
675
694
 
676
695
  // Call Gemini via gemini CLI (reuse existing auth)
677
- const result = await callGemini(prompt);
696
+ process.env._SEO_INTEL_PROJECT = project;
697
+ const result = await callAnalysisModel(prompt, opts.model);
678
698
 
679
699
  if (!result) {
680
700
  console.error(chalk.red('No response from model.'));
@@ -991,17 +1011,117 @@ function loadConfig(project) {
991
1011
  }
992
1012
 
993
1013
  async function callGemini(prompt) {
994
- // Use gemini CLI (already auth'd via OpenClaw)
995
- const { execSync } = await import('child_process');
1014
+ return callAnalysisModel(prompt, 'gemini');
1015
+ }
1016
+
1017
+ function getOpenClawToken() {
1018
+ const envToken = process.env.OPENCLAW_TOKEN?.trim();
1019
+ if (envToken) return envToken;
1020
+
996
1021
  try {
997
- const result = execSync(
998
- `echo ${JSON.stringify(prompt)} | gemini -p -`,
999
- { maxBuffer: 10 * 1024 * 1024, timeout: 120000 }
1000
- ).toString();
1001
- return result;
1022
+ const configPath = join(process.env.HOME || process.env.USERPROFILE || '', '.openclaw', 'openclaw.json');
1023
+ const raw = readFileSync(configPath, 'utf8');
1024
+ const matches = [...raw.matchAll(/"token":\s*"([a-f0-9]{40,})"/g)];
1025
+ if (matches.length > 0) return matches[matches.length - 1][1];
1026
+ } catch {}
1027
+
1028
+ return null;
1029
+ }
1030
+
1031
+ async function callOpenClaw(prompt, model = 'default') {
1032
+ const token = getOpenClawToken();
1033
+ if (!token) throw new Error('OpenClaw token not found');
1034
+
1035
+ const timeoutMs = parseInt(process.env.OPENCLAW_TIMEOUT_MS || process.env.GEMINI_TIMEOUT_MS || '120000', 10);
1036
+ const controller = new AbortController();
1037
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
1038
+
1039
+ try {
1040
+ const res = await fetch('http://127.0.0.1:18789/v1/chat/completions', {
1041
+ method: 'POST',
1042
+ signal: controller.signal,
1043
+ headers: {
1044
+ 'Authorization': `Bearer ${token}`,
1045
+ 'Content-Type': 'application/json',
1046
+ },
1047
+ body: JSON.stringify({
1048
+ model: model === 'openclaw' ? 'default' : model,
1049
+ messages: [{ role: 'user', content: prompt }],
1050
+ temperature: 0.2,
1051
+ max_tokens: 4000,
1052
+ }),
1053
+ });
1054
+
1055
+ if (!res.ok) throw new Error(`OpenClaw API error: ${res.status} ${await res.text()}`);
1056
+
1057
+ const data = await res.json();
1058
+ return data.choices?.[0]?.message?.content || null;
1059
+ } finally {
1060
+ clearTimeout(timeout);
1061
+ }
1062
+ }
1063
+
1064
+ async function callAnalysisModel(prompt, model = 'gemini') {
1065
+ const requestedModel = String(model || 'gemini').trim();
1066
+ const normalizedModel = requestedModel.toLowerCase();
1067
+
1068
+ if (normalizedModel !== 'gemini') {
1069
+ try {
1070
+ return await callOpenClaw(prompt, requestedModel);
1071
+ } catch (err) {
1072
+ console.error('[openclaw]', err.message);
1073
+ return null;
1074
+ }
1075
+ }
1076
+
1077
+ const timeoutMs = parseInt(process.env.GEMINI_TIMEOUT_MS || '120000', 10);
1078
+ try {
1079
+ const result = spawnSync('gemini', ['-p', '-'], {
1080
+ input: prompt,
1081
+ encoding: 'utf8',
1082
+ timeout: timeoutMs,
1083
+ maxBuffer: 10 * 1024 * 1024
1084
+ });
1085
+
1086
+ if (result.error) throw result.error;
1087
+ if (result.status !== 0) {
1088
+ throw new Error(result.stderr?.trim() || `gemini exited with status ${result.status}`);
1089
+ }
1090
+
1091
+ return result.stdout;
1002
1092
  } catch (err) {
1003
- console.error('[gemini]', err.message);
1004
- return null;
1093
+ const fallbackModel = process.env.OPENCLAW_ANALYSIS_MODEL || 'default';
1094
+ try {
1095
+ console.warn(`[gemini] ${err.message}`);
1096
+ console.log(chalk.yellow(`Gemini CLI unavailable, retrying via OpenClaw (${fallbackModel})...\n`));
1097
+ return await callOpenClaw(prompt, fallbackModel);
1098
+ } catch (fallbackErr) {
1099
+ // Produce clear, actionable error messages
1100
+ const geminiMsg = err.message || '';
1101
+ const ocMsg = fallbackErr.message || '';
1102
+
1103
+ const isTimeout = geminiMsg.includes('ETIMEDOUT') || geminiMsg.includes('timeout') || err.name === 'AbortError';
1104
+ const isGatewayDown = ocMsg.includes('ECONNREFUSED') || ocMsg.includes('token not found') || ocMsg.includes('gateway');
1105
+
1106
+ console.error(chalk.red('\n ✗ Analysis failed — no model available\n'));
1107
+
1108
+ if (isTimeout) {
1109
+ console.error(chalk.yellow(' Gemini timed out.') + chalk.dim(' Try: GEMINI_TIMEOUT_MS=180000 seo-intel analyze ' + (process.env._SEO_INTEL_PROJECT || '<project>')));
1110
+ } else {
1111
+ console.error(chalk.dim(` Gemini: ${geminiMsg}`));
1112
+ }
1113
+
1114
+ if (isGatewayDown) {
1115
+ console.error(chalk.yellow(' OpenClaw gateway is not running.'));
1116
+ console.error(chalk.dim(' Start it: ') + chalk.cyan('openclaw gateway'));
1117
+ console.error(chalk.dim(' Or set key: ') + chalk.cyan('echo "GEMINI_API_KEY=your-key" >> .env'));
1118
+ } else {
1119
+ console.error(chalk.dim(` OpenClaw: ${ocMsg}`));
1120
+ }
1121
+
1122
+ console.error(chalk.dim('\n Docs: https://ukkometa.fi/en/seo-intel/setup/\n'));
1123
+ return null;
1124
+ }
1005
1125
  }
1006
1126
  }
1007
1127
 
@@ -1268,8 +1388,8 @@ program
1268
1388
  if (info.npmVersion) {
1269
1389
  console.log(chalk.gray(' npm registry: ') + chalk.white(info.npmVersion));
1270
1390
  }
1271
- if (info.froggoVersion) {
1272
- console.log(chalk.gray(' ukkometa.fi: ') + chalk.white(info.froggoVersion));
1391
+ if (info.ukkometaVersion) {
1392
+ console.log(chalk.gray(' ukkometa.fi: ') + chalk.white(info.ukkometaVersion));
1273
1393
  }
1274
1394
 
1275
1395
  if (!info.hasUpdate) {
@@ -1428,7 +1548,7 @@ program
1428
1548
  // ── Add competitor
1429
1549
  if (opts.add) {
1430
1550
  const domain = domainFromUrl(opts.add);
1431
- const url = opts.add.startsWith('http') ? opts.add : `https://${opts.add}`;
1551
+ const url = opts.add.startsWith('http') ? opts.add : defaultSiteUrl(opts.add);
1432
1552
  if (config.competitors.some(c => c.domain === domain)) {
1433
1553
  console.log(chalk.yellow(` ⚠ ${domain} is already a competitor`));
1434
1554
  } else {
@@ -1455,7 +1575,7 @@ program
1455
1575
  if (opts.addOwned) {
1456
1576
  if (!config.owned) config.owned = [];
1457
1577
  const domain = domainFromUrl(opts.addOwned);
1458
- const url = opts.addOwned.startsWith('http') ? opts.addOwned : `https://${opts.addOwned}`;
1578
+ const url = opts.addOwned.startsWith('http') ? opts.addOwned : defaultSiteUrl(opts.addOwned);
1459
1579
  if (config.owned.some(o => o.domain === domain)) {
1460
1580
  console.log(chalk.yellow(` ⚠ ${domain} is already an owned domain`));
1461
1581
  } else {
@@ -1482,7 +1602,7 @@ program
1482
1602
  // ── Change target
1483
1603
  if (opts.setTarget) {
1484
1604
  const domain = domainFromUrl(opts.setTarget);
1485
- const url = opts.setTarget.startsWith('http') ? opts.setTarget : `https://${opts.setTarget}`;
1605
+ const url = opts.setTarget.startsWith('http') ? opts.setTarget : defaultSiteUrl(opts.setTarget);
1486
1606
  config.target = { url, domain, role: 'target' };
1487
1607
  config.context.url = url;
1488
1608
  console.log(chalk.green(` ✓ Target changed to: ${domain}`));
package/crawler/index.js CHANGED
@@ -11,6 +11,7 @@ const MAX_PAGES = parseInt(process.env.CRAWL_MAX_PAGES || '50');
11
11
  const MAX_DEPTH = parseInt(process.env.CRAWL_MAX_DEPTH || '3');
12
12
  const TIMEOUT = parseInt(process.env.CRAWL_TIMEOUT_MS || '12000');
13
13
  const PAGE_BUDGET = parseInt(process.env.PAGE_BUDGET_MS || '25000'); // hard per-page wall-clock limit
14
+ const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1']);
14
15
 
15
16
  // ── Content quality gate ────────────────────────────────────────────────
16
17
  const SHELL_PATTERNS = /id=["'](root|app|__next|__nuxt)["']|<noscript[^>]*>.*enable javascript/i;
@@ -190,7 +191,10 @@ export async function* crawlDomain(startUrl, opts = {}) {
190
191
  const effectiveUA = isDocsHostname ? GOOGLEBOT_UA : defaultUA;
191
192
 
192
193
  async function tryLoadLlmsTxt() {
193
- const llmsUrl = `https://${base.hostname}/llms.txt`;
194
+ const llmsOrigin = base.protocol === 'http:' && !LOOPBACK_HOSTNAMES.has(base.hostname)
195
+ ? `https://${base.host}`
196
+ : base.origin;
197
+ const llmsUrl = `${llmsOrigin}/llms.txt`;
194
198
  try {
195
199
  const controller = new AbortController();
196
200
  const t = setTimeout(() => controller.abort(), Math.min(TIMEOUT, 8000));
@@ -224,6 +228,15 @@ export async function* crawlDomain(startUrl, opts = {}) {
224
228
  const unique = [...new Set(urls)];
225
229
  let added = 0;
226
230
  for (const u of unique) {
231
+ try {
232
+ if (new URL(u).hostname !== base.hostname) continue;
233
+ } catch {
234
+ continue;
235
+ }
236
+ if (!opts.stealth) {
237
+ const robotsResult = await checkRobots(u).catch(() => ({ allowed: true }));
238
+ if (!robotsResult.allowed) continue;
239
+ }
227
240
  if (!queue.some(q => q.url === u)) {
228
241
  queue.push({ url: u, depth: 1 });
229
242
  added++;
package/lib/license.js CHANGED
@@ -1,22 +1,25 @@
1
1
  /**
2
2
  * SEO Intel — License System
3
3
  *
4
- * Validation priority:
5
- * 1. FROGGO_TOKEN → validate against Froggo API
6
- * 2. SEO_INTEL_LICENSEvalidate against Lemon Squeezy API
7
- * 3. No key → Free tier
4
+ * Validation flow:
5
+ * 1. SEO_INTEL_LICENSEactivate/validate against Lemon Squeezy License API
6
+ * 2. No key Free tier
8
7
  *
9
8
  * Local cache: ~/.seo-intel/license-cache.json
10
9
  * - LS keys: cache 24h, stale up to 7 days if API unreachable
11
- * - Froggo tokens: cache 24h, stale up to 24h if API unreachable
12
10
  * - Beyond stale limit → degrade to free tier + warn
13
11
  *
12
+ * Activation flow (Lemon Squeezy):
13
+ * - First run: POST /v1/licenses/activate → stores instance_id locally
14
+ * - Subsequent runs: POST /v1/licenses/validate with instance_id
15
+ * - Machine-specific: instance_name = "seo-intel-<machineId>"
16
+ *
14
17
  * Free tier: crawl + raw HTML export only. No AI extraction or analysis.
15
- * Solo (€19.99/mo or €199.99/yr via LS): Full AI extraction + analysis, all commands.
18
+ * Solo (€19.99/mo or €199.99/yr): Full AI extraction + analysis, all commands.
16
19
  * Agency: Later phase — not sold yet.
17
20
  */
18
21
 
19
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
22
+ import { chmodSync, existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
20
23
  import { join, dirname } from 'path';
21
24
  import { fileURLToPath } from 'url';
22
25
  import { hostname, userInfo, platform } from 'os';
@@ -32,8 +35,6 @@ const CACHE_PATH = join(CACHE_DIR, 'license-cache.json');
32
35
  // Stale limits (ms)
33
36
  const LS_CACHE_TTL = 24 * 60 * 60 * 1000; // 24h fresh
34
37
  const LS_STALE_LIMIT = 7 * 24 * 60 * 60 * 1000; // 7 days stale max
35
- const FROGGO_CACHE_TTL = 24 * 60 * 60 * 1000; // 24h fresh
36
- const FROGGO_STALE_LIMIT = 24 * 60 * 60 * 1000; // 24h stale max
37
38
 
38
39
  // ── Tiers ──────────────────────────────────────────────────────────────────
39
40
 
@@ -91,9 +92,18 @@ function writeCache(data) {
91
92
  try {
92
93
  mkdirSync(CACHE_DIR, { recursive: true });
93
94
  writeFileSync(CACHE_PATH, JSON.stringify(data, null, 2), 'utf8');
95
+ try { chmodSync(CACHE_PATH, 0o600); } catch {}
94
96
  } catch { /* best-effort */ }
95
97
  }
96
98
 
99
+ function getTierFromVariantName(variantName) {
100
+ const normalized = (variantName || '').toLowerCase();
101
+ if (!normalized.includes('agency') && !normalized.includes('solo') && !normalized.includes('pro')) {
102
+ console.warn(`[license] Unknown variant name: "${normalized}" — defaulting to solo`);
103
+ }
104
+ return normalized.includes('agency') ? 'agency' : 'solo';
105
+ }
106
+
97
107
  /**
98
108
  * Check if cached validation is still usable.
99
109
  * Returns { valid, tier, stale } or null if cache is expired/missing.
@@ -103,123 +113,176 @@ function checkCache(key) {
103
113
  if (!cache || cache.key !== key) return null;
104
114
 
105
115
  const age = Date.now() - (cache.validatedAt || 0);
106
- const ttl = cache.source === 'froggo' ? FROGGO_CACHE_TTL : LS_CACHE_TTL;
107
- const staleLimit = cache.source === 'froggo' ? FROGGO_STALE_LIMIT : LS_STALE_LIMIT;
108
116
 
109
- if (age < ttl) {
110
- return { valid: true, tier: cache.tier, stale: false, source: cache.source };
117
+ if (age < LS_CACHE_TTL) {
118
+ return { valid: true, tier: cache.tier, stale: false, source: 'lemon-squeezy', instanceId: cache.instanceId };
111
119
  }
112
- if (age < staleLimit) {
113
- return { valid: true, tier: cache.tier, stale: true, source: cache.source };
120
+ if (age < LS_STALE_LIMIT) {
121
+ return { valid: true, tier: cache.tier, stale: true, source: 'lemon-squeezy', instanceId: cache.instanceId };
114
122
  }
115
123
  return null; // Expired beyond stale limit
116
124
  }
117
125
 
118
- // ── Lemon Squeezy Validation ───────────────────────────────────────────────
126
+ // ── Lemon Squeezy License API ────────────────────────────────────────────
119
127
 
120
128
  /**
121
- * Validate a license key against Lemon Squeezy API.
122
- * Returns { valid, tier, error? } — never throws.
129
+ * Activate a license key against Lemon Squeezy.
130
+ * POST https://api.lemonsqueezy.com/v1/licenses/activate
131
+ * Params: license_key, instance_name
132
+ * Returns instance_id on success.
123
133
  */
124
- async function validateWithLS(key) {
134
+ async function activateWithLS(key) {
125
135
  try {
126
136
  const controller = new AbortController();
127
137
  const timeout = setTimeout(() => controller.abort(), 8000);
128
138
 
129
- const res = await fetch('https://api.lemonsqueezy.com/v1/licenses/validate', {
139
+ const body = new URLSearchParams({
140
+ license_key: key,
141
+ instance_name: `seo-intel-${getMachineId()}`,
142
+ });
143
+
144
+ const res = await fetch('https://api.lemonsqueezy.com/v1/licenses/activate', {
130
145
  signal: controller.signal,
131
146
  method: 'POST',
132
- headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
133
- body: JSON.stringify({
134
- license_key: key,
135
- instance_name: `seo-intel-${getMachineId()}`,
136
- }),
147
+ headers: { 'Accept': 'application/json' },
148
+ body,
137
149
  });
138
150
 
139
151
  clearTimeout(timeout);
140
152
  const data = await res.json();
141
153
 
142
- if (data.valid) {
143
- // Determine tier from LS metadata
144
- // Convention: product variant name contains "solo" or "agency"
145
- const variantName = (data.meta?.variant_name || data.license_key?.key_data?.variant || '').toLowerCase();
146
- const tier = variantName.includes('agency') ? 'agency' : 'solo';
147
- return { valid: true, tier };
154
+ if (data.activated) {
155
+ const variantName = (data.meta?.variant_name || '').toLowerCase();
156
+ const tier = getTierFromVariantName(variantName);
157
+ return {
158
+ valid: true,
159
+ tier,
160
+ instanceId: data.instance?.id || null,
161
+ meta: data.meta,
162
+ };
163
+ }
164
+
165
+ // Already at activation limit? Try validate instead (might be same machine re-activating)
166
+ if (data.error && data.error.includes('activation limit')) {
167
+ return { valid: false, error: data.error, activationLimitReached: true };
148
168
  }
149
169
 
150
- return { valid: false, error: data.error || 'License key not valid' };
170
+ return { valid: false, error: data.error || 'Activation failed' };
151
171
  } catch (err) {
152
172
  return { valid: false, error: `Network error: ${err.message}`, offline: true };
153
173
  }
154
174
  }
155
175
 
156
- // ── Froggo Token Validation ────────────────────────────────────────────────
157
-
158
176
  /**
159
- * Validate a Froggo marketplace token.
160
- * Returns { valid, tier, error? } — never throws.
177
+ * Validate a license key against Lemon Squeezy.
178
+ * POST https://api.lemonsqueezy.com/v1/licenses/validate
179
+ * Params: license_key, instance_id (optional)
161
180
  */
162
- async function validateWithFroggo(token) {
181
+ async function validateWithLS(key, instanceId) {
163
182
  try {
164
183
  const controller = new AbortController();
165
184
  const timeout = setTimeout(() => controller.abort(), 8000);
166
185
 
167
- const res = await fetch('https://ukkometa.fi/api/seo-intel/licenses/validate', {
186
+ const params = { license_key: key };
187
+ if (instanceId) params.instance_id = instanceId;
188
+ const body = new URLSearchParams(params);
189
+
190
+ const res = await fetch('https://api.lemonsqueezy.com/v1/licenses/validate', {
168
191
  signal: controller.signal,
169
192
  method: 'POST',
170
- headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
171
- body: JSON.stringify({
172
- token,
173
- product: 'seo-intel',
174
- }),
193
+ headers: { 'Accept': 'application/json' },
194
+ body,
175
195
  });
176
196
 
177
197
  clearTimeout(timeout);
178
198
  const data = await res.json();
179
199
 
180
200
  if (data.valid) {
181
- const tier = (data.tier || 'solo').toLowerCase();
182
- return { valid: true, tier: tier === 'agency' ? 'agency' : 'solo' };
201
+ const variantName = (data.meta?.variant_name || '').toLowerCase();
202
+ const tier = getTierFromVariantName(variantName);
203
+ return {
204
+ valid: true,
205
+ tier,
206
+ instanceId: data.instance?.id || instanceId,
207
+ status: data.license_key?.status,
208
+ meta: data.meta,
209
+ };
183
210
  }
184
211
 
185
- return { valid: false, error: data.error || 'Token not valid' };
212
+ return { valid: false, error: data.error || 'License key not valid', status: data.license_key?.status };
186
213
  } catch (err) {
187
214
  return { valid: false, error: `Network error: ${err.message}`, offline: true };
188
215
  }
189
216
  }
190
217
 
218
+ /**
219
+ * Deactivate a license key instance.
220
+ * POST https://api.lemonsqueezy.com/v1/licenses/deactivate
221
+ * Params: license_key, instance_id
222
+ */
223
+ export async function deactivateLicense() {
224
+ const keyInfo = readKeyFromEnv();
225
+ if (!keyInfo) return { deactivated: false, error: 'No license key found' };
226
+
227
+ const cache = readCache();
228
+ const instanceId = cache?.instanceId;
229
+ if (!instanceId) return { deactivated: false, error: 'No active instance to deactivate' };
230
+
231
+ try {
232
+ const controller = new AbortController();
233
+ const timeout = setTimeout(() => controller.abort(), 8000);
234
+
235
+ const body = new URLSearchParams({
236
+ license_key: keyInfo.value,
237
+ instance_id: instanceId,
238
+ });
239
+
240
+ const res = await fetch('https://api.lemonsqueezy.com/v1/licenses/deactivate', {
241
+ signal: controller.signal,
242
+ method: 'POST',
243
+ headers: { 'Accept': 'application/json' },
244
+ body,
245
+ });
246
+
247
+ clearTimeout(timeout);
248
+ const data = await res.json();
249
+
250
+ if (data.deactivated) {
251
+ // Clear local cache
252
+ writeCache({});
253
+ _cachedLicense = undefined;
254
+ return { deactivated: true };
255
+ }
256
+
257
+ return { deactivated: false, error: data.error || 'Deactivation failed' };
258
+ } catch (err) {
259
+ return { deactivated: false, error: `Network error: ${err.message}` };
260
+ }
261
+ }
262
+
191
263
  // ── License Loading ─────────────────────────────────────────────────────────
192
264
 
193
265
  let _cachedLicense = undefined;
194
266
 
195
267
  /**
196
- * Read the license key / token from environment or .env file.
268
+ * Read the license key from environment or .env file.
197
269
  * Returns { type, value } or null.
198
270
  */
199
271
  function readKeyFromEnv() {
200
- // 1. Check Froggo token first (marketplace priority)
201
- let froggoToken = process.env.FROGGO_TOKEN;
202
272
  let lsKey = process.env.SEO_INTEL_LICENSE;
203
273
 
204
- // 2. Check .env file
205
- if (!froggoToken || !lsKey) {
274
+ // Check .env file
275
+ if (!lsKey) {
206
276
  const envPath = join(ROOT, '.env');
207
277
  if (existsSync(envPath)) {
208
278
  try {
209
279
  const content = readFileSync(envPath, 'utf8');
210
- if (!froggoToken) {
211
- const match = content.match(/^FROGGO_TOKEN=(.+)$/m);
212
- if (match) froggoToken = match[1].trim().replace(/^["']|["']$/g, '');
213
- }
214
- if (!lsKey) {
215
- const match = content.match(/^SEO_INTEL_LICENSE=(.+)$/m);
216
- if (match) lsKey = match[1].trim().replace(/^["']|["']$/g, '');
217
- }
280
+ const match = content.match(/^SEO_INTEL_LICENSE=(.+)$/m);
281
+ if (match) lsKey = match[1].trim().replace(/^["']|["']$/g, '');
218
282
  } catch { /* ok */ }
219
283
  }
220
284
  }
221
285
 
222
- if (froggoToken) return { type: 'froggo', value: froggoToken };
223
286
  if (lsKey) return { type: 'lemon-squeezy', value: lsKey };
224
287
  return null;
225
288
  }
@@ -260,7 +323,13 @@ export function loadLicense() {
260
323
  }
261
324
 
262
325
  /**
263
- * Async license activation — validates against remote API and caches result.
326
+ * Async license activation — validates against Lemon Squeezy and caches result.
327
+ *
328
+ * Flow:
329
+ * 1. If we have a cached instanceId → validate with it
330
+ * 2. If no instanceId → activate (creates new instance)
331
+ * 3. If activation limit reached → validate without instanceId (key-level check)
332
+ *
264
333
  * Call this at startup or when loadLicense() returns needsActivation: true.
265
334
  * Returns the validated license object.
266
335
  */
@@ -273,34 +342,46 @@ export async function activateLicense() {
273
342
  return _cachedLicense;
274
343
  }
275
344
 
345
+ // Check if we already have an instanceId from a previous activation
346
+ const cache = readCache();
347
+ const existingInstanceId = (cache && cache.key === keyInfo.value) ? cache.instanceId : null;
348
+
276
349
  let result;
277
- if (keyInfo.type === 'froggo') {
278
- result = await validateWithFroggo(keyInfo.value);
350
+
351
+ if (existingInstanceId) {
352
+ // We have a stored instance — validate it
353
+ result = await validateWithLS(keyInfo.value, existingInstanceId);
279
354
  } else {
280
- result = await validateWithLS(keyInfo.value);
355
+ // No stored instance — try to activate
356
+ result = await activateWithLS(keyInfo.value);
357
+
358
+ // If activation limit reached, fall back to key-level validate
359
+ if (!result.valid && result.activationLimitReached) {
360
+ result = await validateWithLS(keyInfo.value);
361
+ }
281
362
  }
282
363
 
283
364
  if (result.valid) {
284
- // Cache the successful validation
365
+ // Cache the successful validation with instanceId
285
366
  writeCache({
286
367
  key: keyInfo.value,
287
368
  tier: result.tier,
288
369
  validatedAt: Date.now(),
289
- source: keyInfo.type,
370
+ source: 'lemon-squeezy',
371
+ instanceId: result.instanceId || existingInstanceId,
290
372
  machineId: getMachineId(),
291
373
  });
292
374
 
293
375
  const tierData = TIERS[result.tier] || TIERS.solo;
294
- _cachedLicense = { active: true, tier: result.tier, key: keyInfo.value, source: keyInfo.type, ...tierData };
376
+ _cachedLicense = { active: true, tier: result.tier, key: keyInfo.value, source: 'lemon-squeezy', ...tierData };
295
377
  return _cachedLicense;
296
378
  }
297
379
 
298
380
  if (result.offline) {
299
381
  // Network error — check if we have any stale cache at all
300
- const cache = readCache();
301
382
  if (cache && cache.key === keyInfo.value) {
302
383
  const tierData = TIERS[cache.tier] || TIERS.solo;
303
- _cachedLicense = { active: true, tier: cache.tier, key: keyInfo.value, source: cache.source, stale: true, ...tierData };
384
+ _cachedLicense = { active: true, tier: cache.tier, key: keyInfo.value, source: 'lemon-squeezy', stale: true, ...tierData };
304
385
  return _cachedLicense;
305
386
  }
306
387
  }
package/lib/updater.js CHANGED
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
20
+ import { homedir } from 'os';
20
21
  import { join, dirname } from 'path';
21
22
  import { fileURLToPath } from 'url';
22
23
 
@@ -40,8 +41,8 @@ export function getCurrentVersion() {
40
41
 
41
42
  // ── Cache file ─────────────────────────────────────────────────────────────
42
43
 
43
- const CACHE_DIR = join(ROOT, '.cache');
44
- const CACHE_FILE = join(CACHE_DIR, 'update-check.json');
44
+ const CACHE_DIR = join(homedir(), '.seo-intel');
45
+ const CACHE_FILE = join(CACHE_DIR, 'update-cache.json');
45
46
  const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
46
47
 
47
48
  function readCache() {
@@ -116,7 +117,7 @@ async function checkNpm() {
116
117
  * Check ukkometa.fi for latest version.
117
118
  * Endpoint returns { version, changelog?, downloadUrl? }
118
119
  */
119
- async function checkFroggo() {
120
+ async function checkUkkometa() {
120
121
  const controller = new AbortController();
121
122
  const timeout = setTimeout(() => controller.abort(), 5000);
122
123
 
@@ -165,12 +166,12 @@ export function checkForUpdates() {
165
166
  const current = getCurrentVersion();
166
167
 
167
168
  // Check both sources in parallel
168
- const [npmVersion, froggoData] = await Promise.all([
169
+ const [npmVersion, ukkometaData] = await Promise.all([
169
170
  checkNpm(),
170
- checkFroggo(),
171
+ checkUkkometa(),
171
172
  ]);
172
173
 
173
- const froggoVersion = froggoData?.version || null;
174
+ const ukkometaVersion = ukkometaData?.version || null;
174
175
 
175
176
  // Determine the highest available version
176
177
  let latestVersion = current;
@@ -182,11 +183,11 @@ export function checkForUpdates() {
182
183
  latestVersion = npmVersion;
183
184
  source = 'npm';
184
185
  }
185
- if (froggoVersion && compareSemver(froggoVersion, latestVersion) > 0) {
186
- latestVersion = froggoVersion;
187
- source = 'froggo';
188
- changelog = froggoData.changelog;
189
- downloadUrl = froggoData.downloadUrl;
186
+ if (ukkometaVersion && compareSemver(ukkometaVersion, latestVersion) > 0) {
187
+ latestVersion = ukkometaVersion;
188
+ source = 'ukkometa';
189
+ changelog = ukkometaData.changelog;
190
+ downloadUrl = ukkometaData.downloadUrl;
190
191
  }
191
192
 
192
193
  const hasUpdate = compareSemver(latestVersion, current) > 0;
@@ -199,10 +200,10 @@ export function checkForUpdates() {
199
200
  changelog,
200
201
  downloadUrl,
201
202
  npmVersion,
202
- froggoVersion,
203
- security: froggoData?.security || false,
204
- securitySeverity: froggoData?.securitySeverity || null,
205
- updatePolicy: froggoData?.updatePolicy || null,
203
+ ukkometaVersion,
204
+ security: ukkometaData?.security || false,
205
+ securitySeverity: ukkometaData?.securitySeverity || null,
206
+ updatePolicy: ukkometaData?.updatePolicy || null,
206
207
  };
207
208
 
208
209
  writeCache(_updateResult);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seo-intel",
3
- "version": "1.1.8",
3
+ "version": "1.1.10",
4
4
  "description": "Local Ahrefs-style SEO competitor intelligence. Crawl → SQLite → cloud analysis.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
package/server.js CHANGED
@@ -501,13 +501,11 @@ async function handleRequest(req, res) {
501
501
  envContent = readFileSync(envPath, 'utf8');
502
502
  }
503
503
 
504
- // Determine key type
505
- const isFroggo = key.startsWith('FROGGO_') || key.length > 60;
506
- const envVar = isFroggo ? 'FROGGO_TOKEN' : 'SEO_INTEL_LICENSE';
504
+ const envVar = 'SEO_INTEL_LICENSE';
507
505
 
508
- // Remove existing lines for both key types
506
+ // Remove existing license line
509
507
  const lines = envContent.split('\n').filter(l =>
510
- !l.startsWith('SEO_INTEL_LICENSE=') && !l.startsWith('FROGGO_TOKEN=')
508
+ !l.startsWith('SEO_INTEL_LICENSE=')
511
509
  );
512
510
  lines.push(`${envVar}=${key}`);
513
511
 
package/setup/wizard.html CHANGED
@@ -170,6 +170,10 @@ body {
170
170
  max-width: var(--max-width);
171
171
  margin: 0 auto;
172
172
  }
173
+ #step2.step-panel {
174
+ max-width: 1100px;
175
+ margin: 0 auto;
176
+ }
173
177
  .step-panel {
174
178
  display: none;
175
179
  animation: fadeIn 0.3s ease;
@@ -333,7 +337,7 @@ body {
333
337
  /* ─── Model Cards (Step 2) ───────────────────────────────────────────── */
334
338
  .model-columns {
335
339
  display: grid;
336
- grid-template-columns: 1fr 1fr;
340
+ grid-template-columns: 1fr 1fr 1fr;
337
341
  gap: 20px;
338
342
  }
339
343
  .model-section {
@@ -1286,39 +1290,133 @@ input::placeholder {
1286
1290
 
1287
1291
  <!-- OpenClaw Setup Banner -->
1288
1292
  <div id="openclawBanner" class="openclaw-banner" style="display:none;">
1289
- <div class="openclaw-banner-recommended" id="ocBannerRecommended" style="display:none;">
1290
- <div class="openclaw-banner-icon">
1291
- <!-- OpenClaw logo placeholder — replace with actual SVG when available -->
1292
- <svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
1293
- <circle cx="14" cy="14" r="13" stroke="currentColor" stroke-width="1.5" opacity="0.6"/>
1294
- <path d="M9 12c0-2.8 2.2-5 5-5s5 2.2 5 5c0 1.8-1 3.4-2.4 4.3L18 20H10l1.4-3.7C10 15.4 9 13.8 9 12z" fill="currentColor" opacity="0.8"/>
1295
- </svg>
1293
+ <!-- Always-shown agentic setup block -->
1294
+ <div id="ocBannerRecommended" style="display:none;">
1295
+ <div class="openclaw-banner-title" style="margin-bottom:8px;">
1296
+ <i class="fa-solid fa-wand-magic-sparkles" style="color:var(--accent-purple); margin-right:6px;"></i>
1297
+ Agentic Setup let an AI handle this
1296
1298
  </div>
1297
- <div class="openclaw-banner-content">
1298
- <div class="openclaw-banner-title">OpenClaw Setup Available</div>
1299
- <div class="openclaw-banner-desc">OpenClaw guides you through the entire setup conversationally — LLM configuration, cloud model routing, OAuth, and troubleshooting. <strong>Recommended for the best experience.</strong></div>
1300
- <div style="margin-top:10px; padding:8px 10px; background:rgba(10,10,10,0.5); border:1px solid var(--border-subtle); border-radius:var(--radius); font-family:var(--font-mono); font-size:0.72rem; color:var(--text-secondary); display:flex; align-items:center; gap:8px;">
1299
+ <div class="openclaw-banner-desc" style="margin-bottom:14px;">Pick your agent. Copy the prompt. Paste it in. Done.</div>
1300
+
1301
+ <!-- Runtime tabs -->
1302
+ <div style="display:flex; gap:6px; flex-wrap:wrap; margin-bottom:12px;" id="agentRuntimeTabs">
1303
+ <button class="btn btn-sm agent-runtime-tab active" data-runtime="openclaw" onclick="selectAgentRuntime('openclaw', this)" style="font-size:0.65rem;">
1304
+ <i class="fa-solid fa-wand-magic-sparkles" style="margin-right:4px;"></i>OpenClaw
1305
+ </button>
1306
+ <button class="btn btn-sm agent-runtime-tab" data-runtime="claudecode" onclick="selectAgentRuntime('claudecode', this)" style="font-size:0.65rem;">
1307
+ <i class="fa-solid fa-terminal" style="margin-right:4px;"></i>Claude Code
1308
+ </button>
1309
+ <button class="btn btn-sm agent-runtime-tab" data-runtime="codex" onclick="selectAgentRuntime('codex', this)" style="font-size:0.65rem;">
1310
+ <i class="fa-solid fa-code" style="margin-right:4px;"></i>Codex CLI
1311
+ </button>
1312
+ <button class="btn btn-sm agent-runtime-tab" data-runtime="perplexity" onclick="selectAgentRuntime('perplexity', this)" style="font-size:0.65rem;">
1313
+ <i class="fa-solid fa-magnifying-glass" style="margin-right:4px;"></i>Perplexity
1314
+ </button>
1315
+ </div>
1316
+
1317
+ <!-- Runtime panels -->
1318
+ <div id="agentRuntime-openclaw" class="agent-runtime-panel">
1319
+ <div style="font-size:0.68rem; color:var(--text-muted); margin-bottom:8px;">OpenClaw is running — it can guide setup directly. Run this in your terminal first:</div>
1320
+ <div style="padding:8px 10px; background:rgba(10,10,10,0.5); border:1px solid var(--border-subtle); border-radius:var(--radius); font-family:var(--font-mono); font-size:0.68rem; color:var(--text-secondary); display:flex; align-items:center; gap:8px; margin-bottom:8px;">
1301
1321
  <span style="color:var(--text-muted);">$</span>
1302
1322
  <span id="clawhubCmd">clawhub install ukkometa/seo-intel</span>
1303
- <button class="btn btn-sm" style="margin-left:auto; padding:3px 8px; font-size:0.65rem;" onclick="navigator.clipboard.writeText('clawhub install ukkometa/seo-intel');this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500);">Copy</button>
1323
+ <button class="btn btn-sm" style="margin-left:auto; padding:3px 8px; font-size:0.6rem;" onclick="navigator.clipboard.writeText('clawhub install ukkometa/seo-intel');this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500);">Copy</button>
1324
+ </div>
1325
+ <div style="font-size:0.65rem; color:var(--text-muted); margin-bottom:8px;">Or paste this into your OpenClaw chat:</div>
1326
+ <div style="position:relative; padding:8px 10px; background:rgba(10,10,10,0.5); border:1px solid var(--border-subtle); border-radius:var(--radius); font-family:var(--font-mono); font-size:0.62rem; color:var(--text-secondary); line-height:1.6; margin-bottom:10px;">
1327
+ <div id="oc-prompt">Set up SEO Intel on my machine.
1328
+ Check dependencies, install Ollama if needed, recommend a model based on my VRAM, configure a project with my target site, and run a test crawl.</div>
1329
+ <button class="btn btn-sm" style="position:absolute; top:6px; right:6px; padding:2px 6px; font-size:0.5rem;" onclick="navigator.clipboard.writeText(document.getElementById('oc-prompt').textContent.trim());this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500);">Copy</button>
1304
1330
  </div>
1305
1331
  <div class="openclaw-banner-actions">
1306
- <button class="btn btn-gold" onclick="startAgentSetup()"><i class="fa-solid fa-play"></i> Start Setup</button>
1307
- <button class="btn" onclick="continueManualSetup()"><i class="fa-solid fa-list-check"></i> Continue Manually</button>
1332
+ <button class="btn btn-gold" onclick="startAgentSetup()"><i class="fa-solid fa-play"></i> Start Agent Setup</button>
1333
+ <button class="btn" onclick="continueManualSetup()"><i class="fa-solid fa-list-check"></i> Manual</button>
1308
1334
  </div>
1309
1335
  </div>
1310
- </div>
1311
- <div class="openclaw-banner-minimal" id="ocBannerMinimal" style="display:none;">
1312
- <div class="openclaw-banner-icon" style="color:var(--text-muted); font-size:1rem;">
1313
- <svg width="20" height="20" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
1314
- <circle cx="14" cy="14" r="13" stroke="currentColor" stroke-width="1.5" opacity="0.6"/>
1315
- <path d="M9 12c0-2.8 2.2-5 5-5s5 2.2 5 5c0 1.8-1 3.4-2.4 4.3L18 20H10l1.4-3.7C10 15.4 9 13.8 9 12z" fill="currentColor" opacity="0.8"/>
1316
- </svg>
1336
+
1337
+ <div id="agentRuntime-claudecode" class="agent-runtime-panel" style="display:none;">
1338
+ <div style="font-size:0.68rem; color:var(--text-muted); margin-bottom:8px;">Open Claude Code in your terminal (<code style="background:rgba(255,255,255,0.05);padding:1px 4px;border-radius:3px;">claude</code>) and paste this prompt:</div>
1339
+ <div style="position:relative; padding:8px 10px; background:rgba(10,10,10,0.5); border:1px solid var(--border-subtle); border-radius:var(--radius); font-family:var(--font-mono); font-size:0.62rem; color:var(--text-secondary); line-height:1.6; margin-bottom:8px;">
1340
+ <div id="cc-prompt">I'm setting up SEO Intel (npm package: seo-intel).
1341
+ Help me:
1342
+ 1. Check Node.js 22.5+ is installed
1343
+ 2. Install Ollama if missing (https://ollama.com)
1344
+ 3. Pull a Qwen model matching my VRAM (run: system_profiler SPDisplaysDataType | grep VRAM)
1345
+ 4. Create a config file at ./config/myproject.json with my target site
1346
+ 5. Run: seo-intel crawl myproject
1347
+ 6. Fix any errors that come up
1348
+
1349
+ Start by checking what's already installed.</div>
1350
+ <button class="btn btn-sm" style="position:absolute; top:6px; right:6px; padding:2px 6px; font-size:0.5rem;" onclick="navigator.clipboard.writeText(document.getElementById('cc-prompt').textContent.trim());this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500);">Copy</button>
1351
+ </div>
1352
+ <div style="font-size:0.62rem; color:var(--text-muted);">Run Claude Code with: <code style="background:rgba(255,255,255,0.05);padding:1px 4px;border-radius:3px;">claude --permission-mode bypassPermissions</code> for full access</div>
1317
1353
  </div>
1318
- <div class="openclaw-banner-content">
1319
- <div class="openclaw-banner-desc" style="color:var(--text-muted); font-size:0.72rem;">
1320
- <strong style="color:var(--text-secondary);">Tip:</strong> Run <code style="background:rgba(124,109,235,0.1); padding:1px 5px; border-radius:3px; color:var(--accent-purple);">clawhub install ukkometa/seo-intel</code> for guided agent setup with cloud model routing.
1354
+
1355
+ <div id="agentRuntime-codex" class="agent-runtime-panel" style="display:none;">
1356
+ <div style="font-size:0.68rem; color:var(--text-muted); margin-bottom:8px;">Open Codex CLI (<code style="background:rgba(255,255,255,0.05);padding:1px 4px;border-radius:3px;">codex</code>) and paste this prompt:</div>
1357
+ <div style="position:relative; padding:8px 10px; background:rgba(10,10,10,0.5); border:1px solid var(--border-subtle); border-radius:var(--radius); font-family:var(--font-mono); font-size:0.62rem; color:var(--text-secondary); line-height:1.6; margin-bottom:8px;">
1358
+ <div id="cx-prompt">Set up the seo-intel npm CLI tool on this machine.
1359
+ Tasks:
1360
+ 1. Verify Node.js 22.5+ (install via nvm if needed)
1361
+ 2. Install Ollama (https://ollama.com) if not present
1362
+ 3. Pull qwen3.5:9b or smaller if VRAM < 6GB
1363
+ 4. Create ./config/myproject.json — ask me for my target domain and up to 3 competitor domains
1364
+ 5. Add GEMINI_API_KEY or ANTHROPIC_API_KEY to .env if I have one
1365
+ 6. Run: seo-intel crawl myproject
1366
+ 7. Fix any errors
1367
+
1368
+ Use full disk access. Check what's installed before proceeding.</div>
1369
+ <button class="btn btn-sm" style="position:absolute; top:6px; right:6px; padding:2px 6px; font-size:0.5rem;" onclick="navigator.clipboard.writeText(document.getElementById('cx-prompt').textContent.trim());this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500);">Copy</button>
1321
1370
  </div>
1371
+ <div style="font-size:0.62rem; color:var(--text-muted);">Codex config: <code style="background:rgba(255,255,255,0.05);padding:1px 4px;border-radius:3px;">sandbox_mode = "danger-full-access"</code> recommended for install tasks</div>
1372
+ </div>
1373
+
1374
+ <div id="agentRuntime-perplexity" class="agent-runtime-panel" style="display:none;">
1375
+ <div style="font-size:0.68rem; color:var(--text-muted); margin-bottom:8px;">Open Perplexity Computer and paste this prompt:</div>
1376
+ <div style="position:relative; padding:8px 10px; background:rgba(10,10,10,0.5); border:1px solid var(--border-subtle); border-radius:var(--radius); font-family:var(--font-mono); font-size:0.62rem; color:var(--text-secondary); line-height:1.6; margin-bottom:8px;">
1377
+ <div id="pp-prompt">I want to set up a local SEO analysis tool called SEO Intel.
1378
+ It's installed via: npm install -g seo-intel
1379
+
1380
+ Please help me:
1381
+ 1. Check that Node.js 22.5+ is installed on my machine
1382
+ 2. Install Ollama (ollama.com) if it's not there
1383
+ 3. Download a Qwen AI model that fits my machine's RAM
1384
+ 4. Create a project config file pointing at my website and competitors
1385
+ 5. Run the first crawl and show me the results
1386
+
1387
+ Ask me for my website URL before starting.</div>
1388
+ <button class="btn btn-sm" style="position:absolute; top:6px; right:6px; padding:2px 6px; font-size:0.5rem;" onclick="navigator.clipboard.writeText(document.getElementById('pp-prompt').textContent.trim());this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500);">Copy</button>
1389
+ </div>
1390
+ <div style="font-size:0.62rem; color:var(--text-muted);">Works best with Perplexity Computer's full computer-use mode enabled</div>
1391
+ </div>
1392
+ </div>
1393
+
1394
+ <div id="ocBannerMinimal" style="display:none;">
1395
+ <div class="openclaw-banner-desc" style="color:var(--text-muted); font-size:0.72rem; margin-bottom:10px;">
1396
+ <strong style="color:var(--text-secondary);">Agent setup available.</strong> Pick a runtime below and copy the prompt:
1397
+ </div>
1398
+ <!-- Same runtime tabs, minimal version -->
1399
+ <div style="display:flex; gap:6px; flex-wrap:wrap; margin-bottom:10px;">
1400
+ <button class="btn btn-sm agent-runtime-tab-min active" data-runtime="claudecode" onclick="selectAgentRuntimeMin('claudecode', this)" style="font-size:0.62rem;"><i class="fa-solid fa-terminal" style="margin-right:3px;"></i>Claude Code</button>
1401
+ <button class="btn btn-sm agent-runtime-tab-min" data-runtime="codex" onclick="selectAgentRuntimeMin('codex', this)" style="font-size:0.62rem;"><i class="fa-solid fa-code" style="margin-right:3px;"></i>Codex CLI</button>
1402
+ <button class="btn btn-sm agent-runtime-tab-min" data-runtime="perplexity" onclick="selectAgentRuntimeMin('perplexity', this)" style="font-size:0.62rem;"><i class="fa-solid fa-magnifying-glass" style="margin-right:3px;"></i>Perplexity</button>
1403
+ <button class="btn btn-sm agent-runtime-tab-min" data-runtime="openclaw" onclick="selectAgentRuntimeMin('openclaw', this)" style="font-size:0.62rem;"><i class="fa-solid fa-wand-magic-sparkles" style="margin-right:3px;"></i>OpenClaw</button>
1404
+ </div>
1405
+ <div id="minRuntime-claudecode" class="min-runtime-panel" style="position:relative; padding:7px 9px; background:rgba(10,10,10,0.5); border:1px solid var(--border-subtle); border-radius:var(--radius); font-family:var(--font-mono); font-size:0.6rem; color:var(--text-secondary); line-height:1.5;">
1406
+ <div>Set up seo-intel: check Node 22.5+, install Ollama, pull qwen3.5:9b, create config, run crawl.</div>
1407
+ <button class="btn btn-sm" style="position:absolute; top:4px; right:4px; padding:2px 5px; font-size:0.48rem;" onclick="navigator.clipboard.writeText(this.previousElementSibling.textContent.trim());this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500);">Copy</button>
1408
+ </div>
1409
+ <div id="minRuntime-codex" class="min-runtime-panel" style="display:none; position:relative; padding:7px 9px; background:rgba(10,10,10,0.5); border:1px solid var(--border-subtle); border-radius:var(--radius); font-family:var(--font-mono); font-size:0.6rem; color:var(--text-secondary); line-height:1.5;">
1410
+ <div>Install seo-intel deps (Node 22.5+, Ollama, qwen3.5:9b), create project config, run: seo-intel crawl myproject. Use full disk access.</div>
1411
+ <button class="btn btn-sm" style="position:absolute; top:4px; right:4px; padding:2px 5px; font-size:0.48rem;" onclick="navigator.clipboard.writeText(this.previousElementSibling.textContent.trim());this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500);">Copy</button>
1412
+ </div>
1413
+ <div id="minRuntime-perplexity" class="min-runtime-panel" style="display:none; position:relative; padding:7px 9px; background:rgba(10,10,10,0.5); border:1px solid var(--border-subtle); border-radius:var(--radius); font-family:var(--font-mono); font-size:0.6rem; color:var(--text-secondary); line-height:1.5;">
1414
+ <div>Help me set up SEO Intel local SEO tool: install Node.js 22.5+, Ollama, download a Qwen model, create config for my website. Ask for my URL.</div>
1415
+ <button class="btn btn-sm" style="position:absolute; top:4px; right:4px; padding:2px 5px; font-size:0.48rem;" onclick="navigator.clipboard.writeText(this.previousElementSibling.textContent.trim());this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500);">Copy</button>
1416
+ </div>
1417
+ <div id="minRuntime-openclaw" class="min-runtime-panel" style="display:none; position:relative; padding:7px 9px; background:rgba(10,10,10,0.5); border:1px solid var(--border-subtle); border-radius:var(--radius); font-family:var(--font-mono); font-size:0.6rem; color:var(--text-secondary); line-height:1.5;">
1418
+ <div>clawhub install ukkometa/seo-intel</div>
1419
+ <button class="btn btn-sm" style="position:absolute; top:4px; right:4px; padding:2px 5px; font-size:0.48rem;" onclick="navigator.clipboard.writeText('clawhub install ukkometa/seo-intel');this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500);">Copy</button>
1322
1420
  </div>
1323
1421
  </div>
1324
1422
  </div>
@@ -1338,10 +1436,15 @@ input::placeholder {
1338
1436
  </div>
1339
1437
 
1340
1438
  <!-- ─── Step 2: Model Selection ───────────────────────────────────────── -->
1341
- <div class="step-panel" id="step2">
1342
- <div style="display:grid; grid-template-columns:1fr 260px; gap:16px; align-items:start;">
1439
+ <div class="step-panel" id="step2" style="max-width:none;">
1440
+ <div style="position:relative;">
1343
1441
  <div class="card" style="margin-bottom:0; min-width:0;">
1344
- <h2><i class="fa-solid fa-microchip"></i> Model Selection</h2>
1442
+ <div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:4px;">
1443
+ <h2 style="margin-bottom:0;"><i class="fa-solid fa-microchip"></i> Model Selection</h2>
1444
+ <button class="btn btn-sm" onclick="document.getElementById('openclawFloating').style.display=''" style="font-size:0.65rem; color:var(--accent-purple); border-color:rgba(124,109,235,0.3); background:rgba(124,109,235,0.06);">
1445
+ <i class="fa-solid fa-wand-magic-sparkles" style="margin-right:4px;"></i>OpenClaw
1446
+ </button>
1447
+ </div>
1345
1448
 
1346
1449
  <!-- Ollama Hosts -->
1347
1450
  <div id="ollamaHostsPanel" style="margin-bottom:16px; padding:12px 14px; background:var(--bg-card); border:1px solid var(--border-card); border-radius:var(--radius);">
@@ -1443,6 +1546,122 @@ input::placeholder {
1443
1546
  </div>
1444
1547
  </div>
1445
1548
  </div>
1549
+ <!-- ─── Cloud Analysis column ─────────────────────────────────────── -->
1550
+ <div class="model-section" id="cloudAnalysisSection">
1551
+ <h3><i class="fa-solid fa-cloud"></i> Cloud Analysis</h3>
1552
+ <p class="section-note">Use cloud frontier models for analysis. Add your API key to .env</p>
1553
+ <!-- Cloud model cards -->
1554
+ <div id="cloudAnalysisModels">
1555
+ <!-- Gemini 3.1 Pro -->
1556
+ <div class="model-radio-card cloud-model" data-model="gemini-3.1-pro" data-provider="gemini" data-envvar="GEMINI_API_KEY" onclick="selectCloudAnalysisModel(this)">
1557
+ <div style="display:flex; justify-content:space-between; align-items:flex-start;">
1558
+ <div>
1559
+ <div style="font-weight:600; font-size:0.82rem; color:var(--text-primary);">gemini-3.1-pro</div>
1560
+ <div style="font-size:0.68rem; color:var(--text-muted); margin-top:2px;">Google Gemini</div>
1561
+ </div>
1562
+ <span style="font-size:0.55rem; font-weight:700; letter-spacing:0.06em; padding:2px 6px; border-radius:4px; background:rgba(232,213,163,0.15); color:#e8d5a3;">BEST</span>
1563
+ </div>
1564
+ <div style="font-size:0.65rem; color:var(--text-muted); margin-top:6px;">Highest quality Gemini — best for deep competitive analysis</div>
1565
+ <div class="cloud-privacy-note"><i class="fa-solid fa-key"></i> GEMINI_API_KEY</div>
1566
+ </div>
1567
+ <!-- Claude Sonnet 4.6 -->
1568
+ <div class="model-radio-card cloud-model" data-model="claude-sonnet-4-6" data-provider="anthropic" data-envvar="ANTHROPIC_API_KEY" onclick="selectCloudAnalysisModel(this)">
1569
+ <div style="display:flex; justify-content:space-between; align-items:flex-start;">
1570
+ <div>
1571
+ <div style="font-weight:600; font-size:0.82rem; color:var(--text-primary);">claude-sonnet-4-6</div>
1572
+ <div style="font-size:0.68rem; color:var(--text-muted); margin-top:2px;">Anthropic</div>
1573
+ </div>
1574
+ <span style="font-size:0.55rem; font-weight:700; letter-spacing:0.06em; padding:2px 6px; border-radius:4px; background:rgba(96,165,250,0.15); color:#60a5fa;">FAST</span>
1575
+ </div>
1576
+ <div style="font-size:0.65rem; color:var(--text-muted); margin-top:6px;">Best cost/quality balance — fast and reliable</div>
1577
+ <div class="cloud-privacy-note"><i class="fa-solid fa-key"></i> ANTHROPIC_API_KEY</div>
1578
+ </div>
1579
+ <!-- Claude Opus 4.6 -->
1580
+ <div class="model-radio-card cloud-model" data-model="claude-opus-4-6" data-provider="anthropic" data-envvar="ANTHROPIC_API_KEY" onclick="selectCloudAnalysisModel(this)">
1581
+ <div style="display:flex; justify-content:space-between; align-items:flex-start;">
1582
+ <div>
1583
+ <div style="font-weight:600; font-size:0.82rem; color:var(--text-primary);">claude-opus-4-6</div>
1584
+ <div style="font-size:0.68rem; color:var(--text-muted); margin-top:2px;">Anthropic</div>
1585
+ </div>
1586
+ <span style="font-size:0.55rem; font-weight:700; letter-spacing:0.06em; padding:2px 6px; border-radius:4px; background:rgba(124,109,235,0.15); color:#a78bfa;">DEEP</span>
1587
+ </div>
1588
+ <div style="font-size:0.65rem; color:var(--text-muted); margin-top:6px;">Deepest analysis, highest quality — slower</div>
1589
+ <div class="cloud-privacy-note"><i class="fa-solid fa-key"></i> ANTHROPIC_API_KEY</div>
1590
+ </div>
1591
+ <!-- GPT-5.4 -->
1592
+ <div class="model-radio-card cloud-model" data-model="gpt-5.4" data-provider="openai" data-envvar="OPENAI_API_KEY" onclick="selectCloudAnalysisModel(this)">
1593
+ <div style="display:flex; justify-content:space-between; align-items:flex-start;">
1594
+ <div>
1595
+ <div style="font-weight:600; font-size:0.82rem; color:var(--text-primary);">gpt-5.4</div>
1596
+ <div style="font-size:0.68rem; color:var(--text-muted); margin-top:2px;">OpenAI</div>
1597
+ </div>
1598
+ <span style="font-size:0.55rem; font-weight:700; letter-spacing:0.06em; padding:2px 6px; border-radius:4px; background:rgba(156,163,175,0.15); color:#9ca3af;">SOLID</span>
1599
+ </div>
1600
+ <div style="font-size:0.65rem; color:var(--text-muted); margin-top:6px;">Reliable, broad knowledge — solid all-around</div>
1601
+ <div class="cloud-privacy-note"><i class="fa-solid fa-key"></i> OPENAI_API_KEY</div>
1602
+ </div>
1603
+ <!-- DeepSeek R1 -->
1604
+ <div class="model-radio-card cloud-model" data-model="deepseek-r1" data-provider="deepseek" data-envvar="DEEPSEEK_API_KEY" onclick="selectCloudAnalysisModel(this)">
1605
+ <div style="display:flex; justify-content:space-between; align-items:flex-start;">
1606
+ <div>
1607
+ <div style="font-weight:600; font-size:0.82rem; color:var(--text-primary);">deepseek-r1</div>
1608
+ <div style="font-size:0.68rem; color:var(--text-muted); margin-top:2px;">DeepSeek</div>
1609
+ </div>
1610
+ <span style="font-size:0.55rem; font-weight:700; letter-spacing:0.06em; padding:2px 6px; border-radius:4px; background:rgba(52,211,153,0.15); color:#34d399;">BUDGET</span>
1611
+ </div>
1612
+ <div style="font-size:0.65rem; color:var(--text-muted); margin-top:6px;">Best reasoning model at budget price</div>
1613
+ <div class="cloud-privacy-note"><i class="fa-solid fa-key"></i> DEEPSEEK_API_KEY</div>
1614
+ </div>
1615
+ </div>
1616
+ <!-- API Key input area (shown when a cloud card is selected) -->
1617
+ <div id="cloudKeyArea" style="display:none; margin-top:12px; padding:12px; background:rgba(124,109,235,0.04); border:1px solid rgba(124,109,235,0.18); border-radius:var(--radius);">
1618
+ <label style="font-size:0.7rem; font-weight:600; color:var(--text-secondary); display:block; margin-bottom:6px;"><i class="fa-solid fa-key" style="color:var(--accent-purple); margin-right:5px;"></i> API Key</label>
1619
+ <div style="display:flex; gap:6px; align-items:center; margin-bottom:6px;">
1620
+ <input type="password" id="cloudApiKeyInput" placeholder="" style="flex:1; background:var(--bg-elevated); border:1px solid var(--border-subtle); border-radius:var(--radius); padding:6px 8px; color:var(--text-primary); font-family:var(--font-mono); font-size:0.7rem;">
1621
+ <button class="btn btn-sm" onclick="saveCloudApiKeyFromUI()" style="white-space:nowrap; padding:4px 10px; font-size:0.62rem;"><i class="fa-solid fa-floppy-disk"></i> Save to .env</button>
1622
+ </div>
1623
+ <p id="cloudKeySaveStatus" style="font-size:0.62rem; color:var(--text-muted); min-height:1em;"></p>
1624
+ <p style="font-size:0.6rem; color:var(--text-muted); margin-top:4px;">Or use <strong style="color:var(--accent-purple);">OpenClaw</strong> to skip API key management entirely</p>
1625
+ </div>
1626
+ </div>
1627
+
1628
+
1629
+ <p style="font-size:0.72rem; color:var(--text-muted); line-height:1.6; margin-bottom:12px;">
1630
+ Route analysis through frontier models. OpenClaw manages API keys, OAuth, and model routing automatically.
1631
+ </p>
1632
+
1633
+ <div style="font-size:0.68rem; color:var(--text-secondary); margin-bottom:12px;">
1634
+ <div style="display:flex; align-items:center; gap:6px; margin-bottom:6px;">
1635
+ <i class="fa-solid fa-check" style="color:var(--accent-purple); font-size:0.6rem; width:12px;"></i>
1636
+ Claude Opus 4.6
1637
+ </div>
1638
+ <div style="display:flex; align-items:center; gap:6px; margin-bottom:6px;">
1639
+ <i class="fa-solid fa-check" style="color:var(--accent-purple); font-size:0.6rem; width:12px;"></i>
1640
+ Gemini 3.1 Pro
1641
+ </div>
1642
+ <div style="display:flex; align-items:center; gap:6px; margin-bottom:6px;">
1643
+ <i class="fa-solid fa-check" style="color:var(--accent-purple); font-size:0.6rem; width:12px;"></i>
1644
+ GPT-5.4
1645
+ </div>
1646
+ </div>
1647
+
1648
+ <p style="font-size:0.62rem; color:var(--text-muted); line-height:1.5; margin-bottom:8px;">
1649
+ No .env keys needed. OAuth-based model access means the pipeline test may show "no API key" — this is normal when using OpenClaw.
1650
+ </p>
1651
+
1652
+ <div style="padding:6px 8px; background:rgba(10,10,10,0.5); border:1px solid var(--border-subtle); border-radius:var(--radius); font-family:var(--font-mono); font-size:0.65rem; color:var(--text-secondary); display:flex; align-items:center; gap:6px; margin-bottom:8px;">
1653
+ <span style="color:var(--text-muted);">$</span>
1654
+ <span>clawhub install ukkometa/seo-intel</span>
1655
+ <button class="btn btn-sm" style="margin-left:auto; padding:2px 6px; font-size:0.55rem;" onclick="navigator.clipboard.writeText('clawhub install ukkometa/seo-intel');this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500);">Copy</button>
1656
+ </div>
1657
+
1658
+ <p style="font-size:0.6rem; color:var(--text-muted); margin-bottom:6px;">Or paste into OpenClaw chat:</p>
1659
+ <div style="padding:8px 10px; background:rgba(10,10,10,0.5); border:1px solid var(--border-subtle); border-radius:var(--radius); font-family:var(--font-mono); font-size:0.6rem; color:var(--text-secondary); line-height:1.6; position:relative;">
1660
+ <div style="white-space:pre;">Set up SEO Intel cloud analysis.
1661
+ Use Gemini for extraction, Claude for analysis.
1662
+ Configure API keys and run a test crawl.</div>
1663
+ <button class="btn btn-sm" style="position:absolute; top:6px; right:6px; padding:2px 6px; font-size:0.5rem;" onclick="navigator.clipboard.writeText('Set up SEO Intel cloud analysis.\nUse Gemini for extraction, Claude for analysis.\nConfigure API keys and run a test crawl.');this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500);">Copy</button>
1664
+ </div>
1446
1665
  </div>
1447
1666
  <!-- Unified upgrade card (free tier only) -->
1448
1667
  <div id="unifiedUpgrade" style="display:none; position:relative; margin-top:0; padding:48px 32px; border:1px solid rgba(232,213,163,0.15); border-radius:var(--radius); overflow:hidden; text-align:center;">
@@ -1475,8 +1694,9 @@ input::placeholder {
1475
1694
  </div>
1476
1695
  </div>
1477
1696
 
1478
- <!-- OpenClaw sidebar card -->
1479
- <div class="card" style="margin-bottom:0; position:sticky; top:20px;">
1697
+ <!-- OpenClaw floating sidebar -->
1698
+ <div class="card" id="openclawFloating" style="margin-bottom:0; position:fixed; top:80px; right:20px; width:240px; z-index:50; box-shadow:0 8px 40px rgba(0,0,0,0.5); border-color:rgba(124,109,235,0.3);">
1699
+ <button onclick="document.getElementById('openclawFloating').style.display='none'" style="position:absolute;top:8px;right:8px;background:none;border:none;color:var(--text-muted);cursor:pointer;font-size:0.7rem;"><i class="fa-solid fa-xmark"></i></button>
1480
1700
  <div style="display:flex; align-items:center; gap:10px; margin-bottom:12px;">
1481
1701
  <svg width="22" height="22" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
1482
1702
  <circle cx="14" cy="14" r="13" stroke="var(--accent-purple)" stroke-width="1.5" opacity="0.6"/>
@@ -1523,7 +1743,7 @@ Configure API keys and run a test crawl.</div>
1523
1743
  </div>
1524
1744
  </div>
1525
1745
 
1526
- </div><!-- close grid -->
1746
+ </div><!-- close step2 outer grid -->
1527
1747
  </div>
1528
1748
 
1529
1749
  <!-- ─── Step 3: Project Configuration ─────────────────────────────────── -->
@@ -2987,6 +3207,22 @@ Configure API keys and run a test crawl.</div>
2987
3207
  let agentHistory = [];
2988
3208
  let agentBusy = false;
2989
3209
 
3210
+ window.selectAgentRuntime = function(runtime, btn) {
3211
+ document.querySelectorAll('.agent-runtime-tab').forEach(b => b.classList.remove('active'));
3212
+ btn.classList.add('active');
3213
+ document.querySelectorAll('.agent-runtime-panel').forEach(p => p.style.display = 'none');
3214
+ const panel = document.getElementById('agentRuntime-' + runtime);
3215
+ if (panel) panel.style.display = '';
3216
+ };
3217
+
3218
+ window.selectAgentRuntimeMin = function(runtime, btn) {
3219
+ document.querySelectorAll('.agent-runtime-tab-min').forEach(b => b.classList.remove('active'));
3220
+ btn.classList.add('active');
3221
+ document.querySelectorAll('.min-runtime-panel').forEach(p => p.style.display = 'none');
3222
+ const panel = document.getElementById('minRuntime-' + runtime);
3223
+ if (panel) panel.style.display = '';
3224
+ };
3225
+
2990
3226
  window.startAgentSetup = function() {
2991
3227
  // Hide wizard steps, show agent panel
2992
3228
  document.querySelector('.wizard-body').style.display = 'none';
@@ -3152,6 +3388,90 @@ Configure API keys and run a test crawl.</div>
3152
3388
  btn.disabled = false;
3153
3389
  };
3154
3390
 
3391
+ // ── Cloud Analysis Column ─────────────────────────────────────────────
3392
+
3393
+ window.selectCloudAnalysisModel = function(card) {
3394
+ const modelId = card.dataset.modelId;
3395
+ const provider = card.dataset.provider;
3396
+ const envVar = card.dataset.envVar;
3397
+ const placeholder = card.dataset.keyPlaceholder || '';
3398
+
3399
+ // Set global state
3400
+ window.selectedAnalysisModel = 'cloud:' + modelId;
3401
+ window.selectedCloudProvider = provider;
3402
+
3403
+ // Deselect all local Ollama analysis model cards
3404
+ document.querySelectorAll('#analysisModels .model-radio-card').forEach(c => c.classList.remove('selected'));
3405
+
3406
+ // Deselect other cloud cards, select this one
3407
+ document.querySelectorAll('#cloudAnalysisModels .model-radio-card').forEach(c => c.classList.remove('selected'));
3408
+ card.classList.add('selected');
3409
+
3410
+ // Hide analysis pull row and old cloud hint
3411
+ const pullRow = document.getElementById('analysisPullRow');
3412
+ const cloudHint = document.getElementById('analysisCloudHint');
3413
+ if (pullRow) pullRow.style.display = 'none';
3414
+ if (cloudHint) cloudHint.style.display = 'none';
3415
+
3416
+ // Show key input area with correct env var placeholder
3417
+ const keyArea = document.getElementById('cloudKeyArea');
3418
+ const keyInput = document.getElementById('cloudApiKeyInput');
3419
+ const saveStatus = document.getElementById('cloudKeySaveStatus');
3420
+ if (keyArea) keyArea.style.display = 'block';
3421
+ if (keyInput) {
3422
+ keyInput.placeholder = placeholder;
3423
+ keyInput.dataset.envVar = envVar;
3424
+ keyInput.dataset.provider = provider;
3425
+ }
3426
+ if (saveStatus) saveStatus.textContent = '';
3427
+
3428
+ // Also update state.selectedAnalysis for consistency with existing code
3429
+ if (typeof state !== 'undefined') state.selectedAnalysis = 'cloud:' + modelId;
3430
+ };
3431
+
3432
+ window.saveCloudApiKeyFromUI = function() {
3433
+ const keyInput = document.getElementById('cloudApiKeyInput');
3434
+ const saveStatus = document.getElementById('cloudKeySaveStatus');
3435
+ if (!keyInput || !saveStatus) return;
3436
+ const envVar = keyInput.dataset.envVar;
3437
+ const provider = keyInput.dataset.provider;
3438
+ const value = keyInput.value.trim();
3439
+ if (!value) { saveStatus.textContent = 'Enter an API key first.'; saveStatus.style.color = 'var(--color-danger)'; return; }
3440
+ saveCloudApiKey(provider, envVar, value);
3441
+ };
3442
+
3443
+ window.saveCloudApiKey = async function(provider, envVar, keyValue) {
3444
+ const saveStatus = document.getElementById('cloudKeySaveStatus');
3445
+ if (saveStatus) { saveStatus.textContent = 'Saving…'; saveStatus.style.color = 'var(--text-muted)'; }
3446
+ try {
3447
+ const res = await fetch('/api/setup/save-env', {
3448
+ method: 'POST',
3449
+ headers: { 'Content-Type': 'application/json' },
3450
+ body: JSON.stringify({ key: envVar, value: keyValue }),
3451
+ });
3452
+ const data = await res.json();
3453
+ if (!res.ok) throw new Error(data.error || 'Save failed');
3454
+ if (saveStatus) { saveStatus.textContent = '✓ Saved to .env'; saveStatus.style.color = 'var(--color-success)'; }
3455
+ } catch (err) {
3456
+ if (saveStatus) { saveStatus.textContent = err.message; saveStatus.style.color = 'var(--color-danger)'; }
3457
+ }
3458
+ };
3459
+
3460
+ // Patch: selecting a local Ollama analysis model should deselect any cloud card
3461
+ const _origSelectAnalysisModel = typeof selectAnalysisModel !== 'undefined' ? selectAnalysisModel : null;
3462
+ // Hook into the cloud deselect when local analysis cards are clicked via delegated event
3463
+ document.addEventListener('click', function(e) {
3464
+ const card = e.target.closest('#analysisModels .model-radio-card');
3465
+ if (card) {
3466
+ // Deselect cloud cards
3467
+ document.querySelectorAll('#cloudAnalysisModels .model-radio-card').forEach(c => c.classList.remove('selected'));
3468
+ const keyArea = document.getElementById('cloudKeyArea');
3469
+ if (keyArea) keyArea.style.display = 'none';
3470
+ window.selectedAnalysisModel = undefined;
3471
+ window.selectedCloudProvider = undefined;
3472
+ }
3473
+ });
3474
+
3155
3475
  // ── Init ──────────────────────────────────────────────────────────────
3156
3476
  initStep3();
3157
3477
  runSystemCheck();