@elvatis_com/openclaw-cli-bridge-elvatis 1.8.9 → 1.9.0

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/CONTRIBUTING.md CHANGED
@@ -4,11 +4,11 @@
4
4
 
5
5
  **Kein Publish ohne erfolgreichen `/bridge-status` Test!**
6
6
 
7
- ### 1. Build
7
+ ### 1. Lint + Build
8
8
 
9
9
  ```bash
10
- npm run build
11
- # Exit 0 erwartet — TS-Fehler sind ok (--noEmitOnError false), aber Exit != 0 blockiert
10
+ npm run lint # ESLint — 0 errors required, warnings ok
11
+ npm run build # Exit 0 erwartet — TS-Fehler sind ok (--noEmitOnError false), aber Exit != 0 blockiert
12
12
  ```
13
13
 
14
14
  ### 2. Gateway neu starten
@@ -67,10 +67,12 @@ grep -rn "X\.Y\.Z\|version" \
67
67
  ```
68
68
 
69
69
  Typische Stellen:
70
- - `package.json` → `"version": "..."`
70
+ - `package.json` → `"version": "..."` (Hauptquelle — `index.ts` liest automatisch daraus)
71
71
  - `openclaw.plugin.json` → `"version": "..."`
72
72
  - `README.md` → `**Current version:** ...` (falls vorhanden)
73
73
 
74
+ > **Seit v1.9.0:** `index.ts` liest die Version automatisch aus `package.json` — kein manuelles Sync mehr nötig für die Runtime-Version.
75
+
74
76
  ---
75
77
 
76
78
  ## Breaking Changes
@@ -91,3 +93,24 @@ Die folgenden TS-Fehler sind bekannt und ignorierbar (kein Runtime-Problem):
91
93
  - `TS7006: Parameter implicitly has 'any' type` — minor, kein Effekt
92
94
 
93
95
  Build läuft mit `--noEmitOnError false` durch. `npm run build` → Exit 0 ist das Kriterium, nicht null TS-Fehler.
96
+
97
+ ---
98
+
99
+ ## Cookie Expiry Store (seit v1.9.0)
100
+
101
+ Cookie-Expiry-Daten werden jetzt in **einer** Datei gespeichert:
102
+ - `~/.openclaw/cookie-expiry.json` — enthält alle 4 Provider (grok, gemini, claude, chatgpt)
103
+
104
+ Legacy-Dateien (`grok-cookie-expiry.json`, `gemini-cookie-expiry.json`, etc.) werden beim ersten Start automatisch migriert und gelöscht.
105
+
106
+ ---
107
+
108
+ ## Model Fallback Chain (seit v1.9.0)
109
+
110
+ Wenn ein CLI-Modell fehlschlägt (Timeout, Fehler), wird automatisch ein leichteres Modell versucht:
111
+ - `gemini-2.5-pro` → `gemini-2.5-flash`
112
+ - `gemini-3-pro-preview` → `gemini-3-flash-preview`
113
+ - `claude-opus-4-6` → `claude-sonnet-4-6`
114
+ - `claude-sonnet-4-6` → `claude-haiku-4-5`
115
+
116
+ Die Response enthält das tatsächlich verwendete Modell im `model`-Feld.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > OpenClaw plugin that bridges locally installed AI CLIs (Codex, Gemini, Claude Code) as model providers — with slash commands for instant model switching, restore, health testing, and model listing.
4
4
 
5
- **Current version:** `1.8.9`
5
+ **Current version:** `1.9.0`
6
6
 
7
7
  ---
8
8
 
@@ -307,6 +307,18 @@ Slash commands (requireAuth=false, gateway commands.allowFrom is the auth layer)
307
307
  /cli-back → reads state file, restores previous model, clears state
308
308
  /cli-test → HTTP POST → localhost:31337, no global model change
309
309
  /cli-list → formatted table of all registered models
310
+
311
+ Proxy endpoints:
312
+ /health → simple {"status":"ok"}
313
+ /healthz → detailed JSON (version, uptime, provider status, model count)
314
+ /status → HTML dashboard (auto-refreshes every 30s)
315
+ /v1/models → OpenAI-compatible model list
316
+
317
+ Model fallback (v1.9.0):
318
+ cli-gemini/gemini-2.5-pro → cli-gemini/gemini-2.5-flash
319
+ cli-gemini/gemini-3-pro-preview → cli-gemini/gemini-3-flash-preview
320
+ cli-claude/claude-opus-4-6 → cli-claude/claude-sonnet-4-6
321
+ cli-claude/claude-sonnet-4-6 → cli-claude/claude-haiku-4-5
310
322
  ```
311
323
 
312
324
  ---
@@ -334,14 +346,27 @@ Slash commands (requireAuth=false, gateway commands.allowFrom is the auth layer)
334
346
  ## Development
335
347
 
336
348
  ```bash
349
+ npm run lint # eslint (TypeScript-aware)
337
350
  npm run typecheck # tsc --noEmit
338
- npm test # vitest run (83 tests)
351
+ npm test # vitest run (121 tests)
352
+ npm run ci # lint + typecheck + test
339
353
  ```
340
354
 
341
355
  ---
342
356
 
343
357
  ## Changelog
344
358
 
359
+ ### v1.9.0
360
+ - **feat:** Auto-source version from `package.json` — eliminates hardcoded version string sync issues (was stale across v1.8.2–v1.8.8)
361
+ - **feat:** ESLint config (`eslint.config.js`) — TypeScript-aware linting with `npm run lint`, integrated into CI pipeline
362
+ - **refactor:** Extract `/status` HTML dashboard into `src/status-template.ts` — easier to maintain and test
363
+ - **feat:** System Chrome startup check — logs a clear warning with install instructions if `google-chrome` / `chromium` is not found (required for stealth mode browser launches)
364
+ - **refactor:** Consolidate 4 cookie expiry files (`grok-`, `gemini-`, `claude-`, `chatgpt-cookie-expiry.json`) into single `~/.openclaw/cookie-expiry.json`. Legacy files are auto-migrated on first load.
365
+ - **fix:** Explicit `grokBrowser` cleanup on plugin unload — prevents orphaned Chromium processes on hot-reload. Launch promises (`_geminiLaunchPromise` etc.) are also cleared.
366
+ - **feat:** Model fallback chain — when a CLI model fails (timeout, error), automatically retries with a lighter variant: `gemini-2.5-pro` → `flash`, `claude-opus` → `sonnet` → `haiku`. Response includes the actual model used.
367
+ - **feat:** `/healthz` JSON endpoint — returns version, uptime, provider session status, and model count. Useful for monitoring scripts and health dashboards.
368
+ - **feat:** Status page now shows slash commands (`/cli-codex`, `/cli-sonnet`, etc.) next to model IDs
369
+
345
370
  ### v1.8.7
346
371
  - **fix:** Add missing cli-gemini/gemini-3-flash-preview and all Codex models to status page model list
347
372
  - **fix:** Remove duplicate cli-gemini/gemini-3-pro alias
package/SKILL.md CHANGED
@@ -68,4 +68,4 @@ On gateway restart, if any session has expired, a **WhatsApp alert** is sent aut
68
68
 
69
69
  See `README.md` for full configuration reference and architecture diagram.
70
70
 
71
- **Version:** 1.8.9
71
+ **Version:** 1.9.0
@@ -0,0 +1,26 @@
1
+ import eslint from "@eslint/js";
2
+ import tseslint from "typescript-eslint";
3
+
4
+ export default tseslint.config(
5
+ eslint.configs.recommended,
6
+ ...tseslint.configs.recommended,
7
+ {
8
+ rules: {
9
+ // Relaxed — this is a plugin, not a library; pragmatism over purity
10
+ "@typescript-eslint/no-explicit-any": "warn",
11
+ "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
12
+ "@typescript-eslint/no-require-imports": "off", // pre-existing dynamic require in index.ts
13
+ "no-console": "off",
14
+ "prefer-const": "warn", // pre-existing let-without-reassign patterns
15
+ "no-var": "error",
16
+ eqeqeq: ["error", "always"],
17
+ // Pre-existing patterns — suppress until dedicated cleanup
18
+ "no-useless-assignment": "off",
19
+ "no-misleading-character-class": "off",
20
+ "preserve-caught-error": "off",
21
+ },
22
+ },
23
+ {
24
+ ignores: ["dist/", "node_modules/", "test/"],
25
+ },
26
+ );
package/index.ts CHANGED
@@ -28,8 +28,20 @@
28
28
 
29
29
  import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
30
30
  import { homedir } from "node:os";
31
- import { join } from "node:path";
31
+ import { join, dirname } from "node:path";
32
+ import { fileURLToPath } from "node:url";
32
33
  import http from "node:http";
34
+
35
+ // ── Auto-source version from package.json (eliminates hardcoded version sync) ──
36
+ const __dirname_local = dirname(fileURLToPath(import.meta.url));
37
+ const PACKAGE_VERSION: string = (() => {
38
+ try {
39
+ const pkg = JSON.parse(readFileSync(join(__dirname_local, "package.json"), "utf-8")) as { version: string };
40
+ return pkg.version;
41
+ } catch {
42
+ return "0.0.0"; // fallback — should never happen in normal operation
43
+ }
44
+ })();
33
45
  import type {
34
46
  OpenClawPluginApi,
35
47
  ProviderAuthContext,
@@ -63,6 +75,8 @@ import {
63
75
  formatChatGPTExpiry,
64
76
  } from "./src/expiry-helpers.js";
65
77
  import type { BrowserContext, Browser } from "playwright";
78
+ import { checkSystemChrome } from "./src/chrome-check.js";
79
+ import { saveProviderExpiry, loadProviderExpiry, migrateLegacyFiles } from "./src/cookie-expiry-store.js";
66
80
 
67
81
  // ──────────────────────────────────────────────────────────────────────────────
68
82
  // Types derived from SDK (not re-exported by the package)
@@ -108,23 +122,22 @@ const STEALTH_IGNORE_DEFAULTS = ["--enable-automation"] as const;
108
122
 
109
123
  // ── Gemini web-session state ──────────────────────────────────────────────────
110
124
  let geminiContext: BrowserContext | null = null;
111
- const GEMINI_EXPIRY_FILE = join(homedir(), ".openclaw", "gemini-cookie-expiry.json");
112
125
 
113
126
  // ── Claude web-session state ─────────────────────────────────────────────────
114
127
  let claudeWebContext: BrowserContext | null = null;
115
- const CLAUDE_EXPIRY_FILE = join(homedir(), ".openclaw", "claude-cookie-expiry.json");
116
128
 
117
129
  // ── ChatGPT web-session state ────────────────────────────────────────────────
118
130
  let chatgptContext: BrowserContext | null = null;
119
- const CHATGPT_EXPIRY_FILE = join(homedir(), ".openclaw", "chatgpt-cookie-expiry.json");
120
131
 
132
+ // ── Consolidated cookie expiry (delegates to cookie-expiry-store.ts) ─────────
133
+ // Legacy per-provider files are auto-migrated on first load.
121
134
  interface GeminiExpiryInfo { expiresAt: number; loginAt: number; cookieName: string; }
122
135
 
123
136
  function saveGeminiExpiry(info: GeminiExpiryInfo): void {
124
- try { writeFileSync(GEMINI_EXPIRY_FILE, JSON.stringify(info, null, 2)); } catch { /* ignore */ }
137
+ saveProviderExpiry("gemini", info);
125
138
  }
126
139
  function loadGeminiExpiry(): GeminiExpiryInfo | null {
127
- try { return JSON.parse(readFileSync(GEMINI_EXPIRY_FILE, "utf-8")) as GeminiExpiryInfo; } catch { return null; }
140
+ return loadProviderExpiry("gemini");
128
141
  }
129
142
  // formatGeminiExpiry imported from ./src/expiry-helpers.js
130
143
  async function scanGeminiCookieExpiry(ctx: BrowserContext): Promise<GeminiExpiryInfo | null> {
@@ -141,10 +154,10 @@ async function scanGeminiCookieExpiry(ctx: BrowserContext): Promise<GeminiExpiry
141
154
  // ── Claude cookie expiry helpers ─────────────────────────────────────────────
142
155
  interface ClaudeExpiryInfo { expiresAt: number; loginAt: number; cookieName: string; }
143
156
  function saveClaudeExpiry(info: ClaudeExpiryInfo): void {
144
- try { writeFileSync(CLAUDE_EXPIRY_FILE, JSON.stringify(info, null, 2)); } catch { /* ignore */ }
157
+ saveProviderExpiry("claude", info);
145
158
  }
146
159
  function loadClaudeExpiry(): ClaudeExpiryInfo | null {
147
- try { return JSON.parse(readFileSync(CLAUDE_EXPIRY_FILE, "utf-8")) as ClaudeExpiryInfo; } catch { return null; }
160
+ return loadProviderExpiry("claude");
148
161
  }
149
162
  // formatClaudeExpiry imported from ./src/expiry-helpers.js
150
163
  async function scanClaudeCookieExpiry(ctx: BrowserContext): Promise<ClaudeExpiryInfo | null> {
@@ -163,10 +176,10 @@ async function scanClaudeCookieExpiry(ctx: BrowserContext): Promise<ClaudeExpiry
163
176
  // ── ChatGPT cookie expiry helpers ────────────────────────────────────────────
164
177
  interface ChatGPTExpiryInfo { expiresAt: number; loginAt: number; cookieName: string; }
165
178
  function saveChatGPTExpiry(info: ChatGPTExpiryInfo): void {
166
- try { writeFileSync(CHATGPT_EXPIRY_FILE, JSON.stringify(info, null, 2)); } catch { /* ignore */ }
179
+ saveProviderExpiry("chatgpt", info);
167
180
  }
168
181
  function loadChatGPTExpiry(): ChatGPTExpiryInfo | null {
169
- try { return JSON.parse(readFileSync(CHATGPT_EXPIRY_FILE, "utf-8")) as ChatGPTExpiryInfo; } catch { return null; }
182
+ return loadProviderExpiry("chatgpt");
170
183
  }
171
184
  // formatChatGPTExpiry imported from ./src/expiry-helpers.js
172
185
  async function scanChatGPTCookieExpiry(ctx: BrowserContext): Promise<ChatGPTExpiryInfo | null> {
@@ -184,9 +197,6 @@ async function scanChatGPTCookieExpiry(ctx: BrowserContext): Promise<ChatGPTExpi
184
197
 
185
198
  // ─────────────────────────────────────────────────────────────────────────────
186
199
 
187
- // Cookie expiry tracking file — written on /grok-login, read on startup
188
- const GROK_EXPIRY_FILE = join(homedir(), ".openclaw", "grok-cookie-expiry.json");
189
-
190
200
  interface GrokExpiryInfo {
191
201
  expiresAt: number; // epoch ms — earliest auth cookie expiry
192
202
  loginAt: number; // epoch ms — when /grok-login was last run
@@ -194,16 +204,11 @@ interface GrokExpiryInfo {
194
204
  }
195
205
 
196
206
  function saveGrokExpiry(info: GrokExpiryInfo): void {
197
- try {
198
- writeFileSync(GROK_EXPIRY_FILE, JSON.stringify(info, null, 2));
199
- } catch { /* ignore */ }
207
+ saveProviderExpiry("grok", info);
200
208
  }
201
209
 
202
210
  function loadGrokExpiry(): GrokExpiryInfo | null {
203
- try {
204
- const raw = readFileSync(GROK_EXPIRY_FILE, "utf-8");
205
- return JSON.parse(raw) as GrokExpiryInfo;
206
- } catch { return null; }
211
+ return loadProviderExpiry("grok");
207
212
  }
208
213
 
209
214
  // formatExpiryInfo imported from ./src/expiry-helpers.js
@@ -501,10 +506,16 @@ async function cleanupBrowsers(log: (msg: string) => void): Promise<void> {
501
506
  clearInterval(_keepAliveInterval);
502
507
  _keepAliveInterval = null;
503
508
  }
509
+ // Close all browser contexts first, then the browser instances.
510
+ // Closing contexts before browsers prevents orphaned Chromium processes.
504
511
  if (grokContext) {
505
512
  try { await grokContext.close(); } catch { /* ignore */ }
506
513
  grokContext = null;
507
514
  }
515
+ if (grokBrowser) {
516
+ try { await grokBrowser.close(); } catch { /* ignore */ }
517
+ grokBrowser = null;
518
+ }
508
519
  if (geminiContext) {
509
520
  try { await geminiContext.close(); } catch { /* ignore */ }
510
521
  geminiContext = null;
@@ -521,6 +532,11 @@ async function cleanupBrowsers(log: (msg: string) => void): Promise<void> {
521
532
  try { await _cdpBrowser.close(); } catch { /* ignore */ }
522
533
  _cdpBrowser = null;
523
534
  }
535
+ // Clear any pending launch promises to prevent stale references
536
+ _cdpBrowserLaunchPromise = null;
537
+ _geminiLaunchPromise = null;
538
+ _claudeLaunchPromise = null;
539
+ _chatgptLaunchPromise = null;
524
540
  log("[cli-bridge] browser resources cleaned up");
525
541
  }
526
542
 
@@ -939,7 +955,7 @@ function proxyTestRequest(
939
955
  const plugin = {
940
956
  id: "openclaw-cli-bridge-elvatis",
941
957
  name: "OpenClaw CLI Bridge",
942
- version: "1.8.9",
958
+ version: PACKAGE_VERSION,
943
959
  description:
944
960
  "Phase 1: openai-codex auth bridge. " +
945
961
  "Phase 2: HTTP proxy for gemini/claude CLIs. " +
@@ -956,6 +972,43 @@ const plugin = {
956
972
  const codexAuthPath = cfg.codexAuthPath ?? DEFAULT_CODEX_AUTH_PATH;
957
973
  const grokSessionPath = cfg.grokSessionPath ?? DEFAULT_SESSION_PATH;
958
974
 
975
+ // ── Model → slash command mapping for status page ──────────────────────────
976
+ const modelCommands: Record<string, string> = {};
977
+ for (const entry of CLI_MODEL_COMMANDS) {
978
+ // Strip vllm/ prefix to match CLI_MODELS IDs
979
+ const modelId = entry.model.replace(/^vllm\//, "");
980
+ modelCommands[modelId] = `/${entry.name}`;
981
+ }
982
+
983
+ // ── Default model fallback chain ──────────────────────────────────────────
984
+ // When a primary model fails (timeout, error), retry once with a lighter variant.
985
+ const modelFallbacks: Record<string, string> = {
986
+ "cli-gemini/gemini-2.5-pro": "cli-gemini/gemini-2.5-flash",
987
+ "cli-gemini/gemini-3-pro-preview": "cli-gemini/gemini-3-flash-preview",
988
+ "cli-claude/claude-opus-4-6": "cli-claude/claude-sonnet-4-6",
989
+ "cli-claude/claude-sonnet-4-6": "cli-claude/claude-haiku-4-5",
990
+ };
991
+
992
+ // ── Migrate legacy per-provider cookie expiry files to consolidated store ─
993
+ const migration = migrateLegacyFiles();
994
+ if (migration.migrated.length > 0) {
995
+ api.logger.info(`[cli-bridge] migrated cookie expiry data: ${migration.migrated.join(", ")} → cookie-expiry.json`);
996
+ }
997
+
998
+ // ── Chrome availability check ────────────────────────────────────────────
999
+ // Stealth mode uses channel: "chrome" (real system Chrome). If it's missing,
1000
+ // browser launches will fail or Cloudflare will block the bundled Chromium.
1001
+ const chromeCheck = checkSystemChrome();
1002
+ if (chromeCheck.available) {
1003
+ api.logger.info(`[cli-bridge] system Chrome found: ${chromeCheck.version ?? chromeCheck.path}`);
1004
+ } else {
1005
+ api.logger.warn(
1006
+ `[cli-bridge] ⚠ system Chrome not found! Web browser providers (/grok-login, /gemini-login, etc.) ` +
1007
+ `require Google Chrome or Chromium installed system-wide. ` +
1008
+ `Install with: sudo apt install google-chrome-stable (or chromium-browser)`
1009
+ );
1010
+ }
1011
+
959
1012
  // ── Session restore: only on first plugin load (not on hot-reloads) ──────
960
1013
  // The gateway polls every ~60s via openclaw status, which triggers a hot-reload
961
1014
  // (SIGUSR1 + hybrid mode). Module-level contexts (grokContext etc.) survive
@@ -975,8 +1028,8 @@ const plugin = {
975
1028
 
976
1029
  const profileProviders: Array<{
977
1030
  name: string;
1031
+ providerKey: import("./src/cookie-expiry-store.js").ProviderName;
978
1032
  profileDir: string;
979
- cookieFile: string;
980
1033
  verifySelector: string;
981
1034
  homeUrl: string;
982
1035
  loginCmd: string;
@@ -985,8 +1038,8 @@ const plugin = {
985
1038
  }> = [
986
1039
  {
987
1040
  name: "grok",
1041
+ providerKey: "grok",
988
1042
  profileDir: GROK_PROFILE_DIR,
989
- cookieFile: GROK_EXPIRY_FILE,
990
1043
  verifySelector: "textarea",
991
1044
  homeUrl: "https://grok.com",
992
1045
  loginCmd: "/grok-login",
@@ -995,8 +1048,8 @@ const plugin = {
995
1048
  },
996
1049
  {
997
1050
  name: "gemini",
1051
+ providerKey: "gemini",
998
1052
  profileDir: GEMINI_PROFILE_DIR,
999
- cookieFile: join(homedir(), ".openclaw", "gemini-cookie-expiry.json"),
1000
1053
  verifySelector: ".ql-editor",
1001
1054
  homeUrl: "https://gemini.google.com/app",
1002
1055
  loginCmd: "/gemini-login",
@@ -1005,8 +1058,8 @@ const plugin = {
1005
1058
  },
1006
1059
  {
1007
1060
  name: "claude-web",
1061
+ providerKey: "claude",
1008
1062
  profileDir: CLAUDE_PROFILE_DIR,
1009
- cookieFile: CLAUDE_EXPIRY_FILE,
1010
1063
  verifySelector: ".ProseMirror",
1011
1064
  homeUrl: "https://claude.ai/new",
1012
1065
  loginCmd: "/claude-login",
@@ -1015,8 +1068,8 @@ const plugin = {
1015
1068
  },
1016
1069
  {
1017
1070
  name: "chatgpt",
1071
+ providerKey: "chatgpt",
1018
1072
  profileDir: CHATGPT_PROFILE_DIR,
1019
- cookieFile: CHATGPT_EXPIRY_FILE,
1020
1073
  verifySelector: "#prompt-textarea",
1021
1074
  homeUrl: "https://chatgpt.com",
1022
1075
  loginCmd: "/chatgpt-login",
@@ -1026,22 +1079,22 @@ const plugin = {
1026
1079
  ];
1027
1080
 
1028
1081
  for (const p of profileProviders) {
1029
- if (!existsSync(p.profileDir) && !existsSync(p.cookieFile)) {
1082
+ const storedExpiry = loadProviderExpiry(p.providerKey);
1083
+ if (!existsSync(p.profileDir) && !storedExpiry) {
1030
1084
  api.logger.info(`[cli-bridge:${p.name}] no saved profile — skipping startup restore`);
1031
1085
  continue;
1032
1086
  }
1033
1087
  if (p.getCtx()) continue; // already connected
1034
1088
 
1035
1089
  // ── Cookie-first check ────────────────────────────────────────────
1036
- // If a cookie expiry file exists and is still valid (>1h left),
1090
+ // If cookie expiry data exists and is still valid (>1h left),
1037
1091
  // launch the persistent context immediately without a browser-based
1038
1092
  // selector check. Selector checks are fragile (slow pages, DOM changes).
1039
1093
  // The keep-alive (20h) will verify the session properly later.
1040
1094
  let cookiesValid = false;
1041
- if (existsSync(p.cookieFile)) {
1095
+ if (storedExpiry) {
1042
1096
  try {
1043
- const expInfo = JSON.parse(readFileSync(p.cookieFile, "utf-8")) as { expiresAt: number };
1044
- const msLeft = expInfo.expiresAt - Date.now();
1097
+ const msLeft = storedExpiry.expiresAt - Date.now();
1045
1098
  if (msLeft > 3_600_000) { // >1h remaining
1046
1099
  cookiesValid = true;
1047
1100
  api.logger.info(`[cli-bridge:${p.name}] cookie valid (${Math.floor(msLeft / 86_400_000)}d left) — restoring context without browser check`);
@@ -1274,6 +1327,8 @@ const plugin = {
1274
1327
  return chatgptContext;
1275
1328
  },
1276
1329
  version: plugin.version,
1330
+ modelCommands,
1331
+ modelFallbacks,
1277
1332
  getExpiryInfo: () => ({
1278
1333
  grok: (() => { const e = loadGrokExpiry(); return e ? formatExpiryInfo(e) : null; })(),
1279
1334
  gemini: (() => { const e = loadGeminiExpiry(); return e ? formatGeminiExpiry(e) : null; })(),
@@ -1309,7 +1364,7 @@ const plugin = {
1309
1364
  // One final attempt
1310
1365
  try {
1311
1366
  const server = await startProxyServer({
1312
- port, apiKey, timeoutMs,
1367
+ port, apiKey, timeoutMs, modelCommands, modelFallbacks,
1313
1368
  log: (msg) => api.logger.info(msg),
1314
1369
  warn: (msg) => api.logger.warn(msg),
1315
1370
  getGrokContext: () => grokContext,
@@ -2,7 +2,7 @@
2
2
  "id": "openclaw-cli-bridge-elvatis",
3
3
  "slug": "openclaw-cli-bridge-elvatis",
4
4
  "name": "OpenClaw CLI Bridge",
5
- "version": "1.8.9",
5
+ "version": "1.9.0",
6
6
  "license": "MIT",
7
7
  "description": "Phase 1: openai-codex auth bridge. Phase 2: local HTTP proxy routing model calls through gemini/claude CLIs (vllm provider).",
8
8
  "providers": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elvatis_com/openclaw-cli-bridge-elvatis",
3
- "version": "1.8.9",
3
+ "version": "1.9.0",
4
4
  "description": "Bridges gemini, claude, and codex CLI tools as OpenClaw model providers. Reads existing CLI auth without re-login.",
5
5
  "type": "module",
6
6
  "openclaw": {
@@ -12,11 +12,15 @@
12
12
  "build": "tsc --noEmitOnError false",
13
13
  "typecheck": "tsc -p tsconfig.check.json",
14
14
  "test": "vitest run",
15
- "ci": "npm run typecheck && npm run test"
15
+ "lint": "eslint .",
16
+ "ci": "npm run lint && npm run typecheck && npm run test"
16
17
  },
17
18
  "devDependencies": {
19
+ "@eslint/js": "^10.0.1",
18
20
  "@types/node": "^25.3.2",
21
+ "eslint": "^10.0.3",
19
22
  "typescript": "^5.9.3",
23
+ "typescript-eslint": "^8.57.0",
20
24
  "vitest": "^4.0.18"
21
25
  },
22
26
  "dependencies": {
@@ -0,0 +1,52 @@
1
+ /**
2
+ * chrome-check.ts
3
+ *
4
+ * Startup check for system Chrome availability.
5
+ * Playwright's stealth mode uses `channel: "chrome"` which requires a real
6
+ * Chrome/Chromium installation. Without it, browser launches fail silently
7
+ * or Cloudflare flags the bundled Chromium as automation.
8
+ */
9
+
10
+ import { execFileSync } from "node:child_process";
11
+
12
+ const CHROME_CANDIDATES = [
13
+ "google-chrome",
14
+ "google-chrome-stable",
15
+ "chromium-browser",
16
+ "chromium",
17
+ ];
18
+
19
+ export interface ChromeCheckResult {
20
+ available: boolean;
21
+ path: string | null;
22
+ version: string | null;
23
+ }
24
+
25
+ /**
26
+ * Check if a system Chrome installation exists.
27
+ * Returns the path and version if found, or null if missing.
28
+ */
29
+ export function checkSystemChrome(): ChromeCheckResult {
30
+ for (const candidate of CHROME_CANDIDATES) {
31
+ try {
32
+ const whichResult = execFileSync("which", [candidate], {
33
+ encoding: "utf-8",
34
+ timeout: 3000,
35
+ }).trim();
36
+
37
+ if (whichResult) {
38
+ let version: string | null = null;
39
+ try {
40
+ version = execFileSync(candidate, ["--version"], {
41
+ encoding: "utf-8",
42
+ timeout: 3000,
43
+ }).trim();
44
+ } catch { /* version check optional */ }
45
+ return { available: true, path: whichResult, version };
46
+ }
47
+ } catch {
48
+ // candidate not found — try next
49
+ }
50
+ }
51
+ return { available: false, path: null, version: null };
52
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * cookie-expiry-store.ts
3
+ *
4
+ * Consolidated cookie expiry tracking for all web providers.
5
+ * Replaces 4 separate JSON files with a single unified store:
6
+ * ~/.openclaw/cookie-expiry.json
7
+ *
8
+ * Migration: on first load, imports data from legacy per-provider files
9
+ * and deletes them.
10
+ */
11
+
12
+ import { readFileSync, writeFileSync, existsSync, unlinkSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { homedir } from "node:os";
15
+
16
+ export type ProviderName = "grok" | "gemini" | "claude" | "chatgpt";
17
+
18
+ export interface ExpiryInfo {
19
+ expiresAt: number; // epoch ms
20
+ loginAt: number; // epoch ms
21
+ cookieName: string;
22
+ }
23
+
24
+ export interface CookieExpiryData {
25
+ grok: ExpiryInfo | null;
26
+ gemini: ExpiryInfo | null;
27
+ claude: ExpiryInfo | null;
28
+ chatgpt: ExpiryInfo | null;
29
+ }
30
+
31
+ const STORE_PATH = join(homedir(), ".openclaw", "cookie-expiry.json");
32
+
33
+ // Legacy file paths (for migration)
34
+ const LEGACY_FILES: Record<ProviderName, string> = {
35
+ grok: join(homedir(), ".openclaw", "grok-cookie-expiry.json"),
36
+ gemini: join(homedir(), ".openclaw", "gemini-cookie-expiry.json"),
37
+ claude: join(homedir(), ".openclaw", "claude-cookie-expiry.json"),
38
+ chatgpt: join(homedir(), ".openclaw", "chatgpt-cookie-expiry.json"),
39
+ };
40
+
41
+ function emptyData(): CookieExpiryData {
42
+ return { grok: null, gemini: null, claude: null, chatgpt: null };
43
+ }
44
+
45
+ /**
46
+ * Migrate legacy per-provider files into the consolidated store.
47
+ * Safe to call multiple times — only imports files that exist.
48
+ */
49
+ export function migrateLegacyFiles(): { migrated: ProviderName[] } {
50
+ const migrated: ProviderName[] = [];
51
+ let data = loadAll();
52
+
53
+ for (const [provider, legacyPath] of Object.entries(LEGACY_FILES) as [ProviderName, string][]) {
54
+ if (!existsSync(legacyPath)) continue;
55
+ try {
56
+ const legacy = JSON.parse(readFileSync(legacyPath, "utf-8")) as ExpiryInfo;
57
+ // Only import if we don't already have newer data
58
+ if (!data[provider] || legacy.loginAt > data[provider]!.loginAt) {
59
+ data[provider] = legacy;
60
+ migrated.push(provider);
61
+ }
62
+ unlinkSync(legacyPath);
63
+ } catch {
64
+ // Corrupted legacy file — just delete it
65
+ try { unlinkSync(legacyPath); } catch { /* ignore */ }
66
+ }
67
+ }
68
+
69
+ if (migrated.length > 0) {
70
+ writeAll(data);
71
+ }
72
+ return { migrated };
73
+ }
74
+
75
+ /** Load the entire expiry store. Returns empty data if file doesn't exist. */
76
+ export function loadAll(): CookieExpiryData {
77
+ try {
78
+ const raw = readFileSync(STORE_PATH, "utf-8");
79
+ const parsed = JSON.parse(raw) as Partial<CookieExpiryData>;
80
+ return { ...emptyData(), ...parsed };
81
+ } catch {
82
+ return emptyData();
83
+ }
84
+ }
85
+
86
+ /** Save a single provider's expiry info. Merges with existing data. */
87
+ export function saveProviderExpiry(provider: ProviderName, info: ExpiryInfo): void {
88
+ const data = loadAll();
89
+ data[provider] = info;
90
+ writeAll(data);
91
+ }
92
+
93
+ /** Load a single provider's expiry info. */
94
+ export function loadProviderExpiry(provider: ProviderName): ExpiryInfo | null {
95
+ return loadAll()[provider];
96
+ }
97
+
98
+ /** Write the full store to disk. */
99
+ function writeAll(data: CookieExpiryData): void {
100
+ try {
101
+ writeFileSync(STORE_PATH, JSON.stringify(data, null, 2));
102
+ } catch { /* ignore write errors */ }
103
+ }
104
+
105
+ /** Get the store file path (for existsSync checks in startup restore). */
106
+ export function getStorePath(): string {
107
+ return STORE_PATH;
108
+ }
@@ -17,6 +17,7 @@ import { geminiComplete, geminiCompleteStream, type ChatMessage as GeminiBrowser
17
17
  import { claudeComplete, claudeCompleteStream, type ChatMessage as ClaudeBrowserChatMessage } from "./claude-browser.js";
18
18
  import { chatgptComplete, chatgptCompleteStream, type ChatMessage as ChatGPTBrowserChatMessage } from "./chatgpt-browser.js";
19
19
  import type { BrowserContext } from "playwright";
20
+ import { renderStatusPage, type StatusProvider } from "./status-template.js";
20
21
 
21
22
  export type GrokCompleteOptions = Parameters<typeof grokComplete>[1];
22
23
  export type GrokCompleteStreamOptions = Parameters<typeof grokCompleteStream>[1];
@@ -71,6 +72,14 @@ export interface ProxyServerOptions {
71
72
  version?: string;
72
73
  /** Returns the BitNet llama-server base URL (default: http://127.0.0.1:8082) */
73
74
  getBitNetServerUrl?: () => string;
75
+ /** Maps model ID → slash command name for the status page display */
76
+ modelCommands?: Record<string, string>;
77
+ /**
78
+ * Model fallback chain — maps a model prefix to a fallback model.
79
+ * When a CLI model fails (timeout, error), the request is retried once
80
+ * with the fallback model. Example: "cli-gemini/gemini-2.5-pro" → "cli-gemini/gemini-2.5-flash"
81
+ */
82
+ modelFallbacks?: Record<string, string>;
74
83
  }
75
84
 
76
85
  /** Available CLI bridge models for GET /v1/models */
@@ -158,121 +167,56 @@ async function handleRequest(
158
167
 
159
168
  const url = req.url ?? "/";
160
169
 
161
- // Health check
170
+ // Health check (simple)
162
171
  if (url === "/health" || url === "/v1/health") {
163
172
  res.writeHead(200, { "Content-Type": "application/json" });
164
173
  res.end(JSON.stringify({ status: "ok", service: "openclaw-cli-bridge" }));
165
174
  return;
166
175
  }
167
176
 
177
+ // Health check (detailed JSON — for monitoring scripts)
178
+ if (url === "/healthz" && req.method === "GET") {
179
+ const expiry = opts.getExpiryInfo?.() ?? { grok: null, gemini: null, claude: null, chatgpt: null };
180
+ const sessionStatus = (provider: string, ctxGetter: (() => import("playwright").BrowserContext | null) | undefined, expiryStr: string | null) => {
181
+ const connected = ctxGetter?.() !== null && ctxGetter?.() !== undefined;
182
+ let status: "connected" | "logged_in" | "expired" | "not_configured" = "not_configured";
183
+ if (connected) status = "connected";
184
+ else if (expiryStr?.startsWith("⚠️ EXPIRED")) status = "expired";
185
+ else if (expiryStr) status = "logged_in";
186
+ return { status, expiry: expiryStr };
187
+ };
188
+ const health = {
189
+ status: "ok",
190
+ service: "openclaw-cli-bridge",
191
+ version: opts.version ?? "?",
192
+ port: opts.port,
193
+ uptime_s: Math.floor(process.uptime()),
194
+ providers: {
195
+ grok: sessionStatus("grok", opts.getGrokContext, expiry.grok),
196
+ gemini: sessionStatus("gemini", opts.getGeminiContext, expiry.gemini),
197
+ claude: sessionStatus("claude", opts.getClaudeContext, expiry.claude),
198
+ chatgpt: sessionStatus("chatgpt", opts.getChatGPTContext, expiry.chatgpt),
199
+ },
200
+ models: CLI_MODELS.length,
201
+ };
202
+ res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders() });
203
+ res.end(JSON.stringify(health, null, 2));
204
+ return;
205
+ }
206
+
168
207
  // Browser status page — human-readable HTML dashboard
169
208
  if ((url === "/status" || url === "/") && req.method === "GET") {
170
209
  const expiry = opts.getExpiryInfo?.() ?? { grok: null, gemini: null, claude: null, chatgpt: null };
171
210
  const version = opts.version ?? "?";
172
211
 
173
- const providers = [
212
+ const providers: StatusProvider[] = [
174
213
  { name: "Grok", icon: "𝕏", expiry: expiry.grok, loginCmd: "/grok-login", ctx: opts.getGrokContext?.() ?? null },
175
214
  { name: "Gemini", icon: "✦", expiry: expiry.gemini, loginCmd: "/gemini-login", ctx: opts.getGeminiContext?.() ?? null },
176
215
  { name: "Claude", icon: "◆", expiry: expiry.claude, loginCmd: "/claude-login", ctx: opts.getClaudeContext?.() ?? null },
177
216
  { name: "ChatGPT", icon: "◉", expiry: expiry.chatgpt, loginCmd: "/chatgpt-login", ctx: opts.getChatGPTContext?.() ?? null },
178
217
  ];
179
218
 
180
- function statusBadge(p: typeof providers[0]): { label: string; color: string; dot: string } {
181
- if (p.ctx !== null) return { label: "Connected", color: "#22c55e", dot: "🟢" };
182
- if (!p.expiry) return { label: "Never logged in", color: "#6b7280", dot: "⚪" };
183
- if (p.expiry.startsWith("⚠️ EXPIRED")) return { label: "Expired", color: "#ef4444", dot: "🔴" };
184
- if (p.expiry.startsWith("🚨")) return { label: "Expiring soon", color: "#f59e0b", dot: "🟡" };
185
- return { label: "Logged in", color: "#3b82f6", dot: "🔵" };
186
- }
187
-
188
- const rows = providers.map(p => {
189
- const badge = statusBadge(p);
190
- const expiryText = p.expiry
191
- ? p.expiry.replace(/[⚠️🚨✅🕐]/gu, "").trim()
192
- : `Not logged in — run <code>${p.loginCmd}</code>`;
193
- return `
194
- <tr>
195
- <td style="padding:12px 16px;font-weight:600;font-size:15px">${p.icon} ${p.name}</td>
196
- <td style="padding:12px 16px">
197
- <span style="background:${badge.color}22;color:${badge.color};border:1px solid ${badge.color}44;
198
- border-radius:6px;padding:3px 10px;font-size:13px;font-weight:600">
199
- ${badge.dot} ${badge.label}
200
- </span>
201
- </td>
202
- <td style="padding:12px 16px;color:#9ca3af;font-size:13px">${expiryText}</td>
203
- <td style="padding:12px 16px;color:#6b7280;font-size:12px;font-family:monospace">${p.loginCmd}</td>
204
- </tr>`;
205
- }).join("");
206
-
207
- const cliModels = CLI_MODELS.filter(m => m.id.startsWith("cli-"));
208
- const codexModels = CLI_MODELS.filter(m => m.id.startsWith("openai-codex/"));
209
- const webModels = CLI_MODELS.filter(m => m.id.startsWith("web-"));
210
- const localModels = CLI_MODELS.filter(m => m.id.startsWith("local-"));
211
- const modelList = (models: typeof CLI_MODELS) =>
212
- models.map(m => `<li style="margin:2px 0;font-size:13px;color:#d1d5db"><code style="color:#93c5fd">${m.id}</code></li>`).join("");
213
-
214
- const html = `<!DOCTYPE html>
215
- <html lang="en">
216
- <head>
217
- <meta charset="UTF-8">
218
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
219
- <title>CLI Bridge Status</title>
220
- <meta http-equiv="refresh" content="30">
221
- <style>
222
- * { box-sizing: border-box; margin: 0; padding: 0; }
223
- body { background: #0f1117; color: #e5e7eb; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; min-height: 100vh; padding: 32px 24px; }
224
- h1 { font-size: 22px; font-weight: 700; color: #f9fafb; margin-bottom: 4px; }
225
- .subtitle { color: #6b7280; font-size: 13px; margin-bottom: 28px; }
226
- .card { background: #1a1d27; border: 1px solid #2d3148; border-radius: 12px; overflow: hidden; margin-bottom: 24px; }
227
- .card-header { padding: 14px 16px; border-bottom: 1px solid #2d3148; font-size: 12px; font-weight: 600; color: #6b7280; text-transform: uppercase; letter-spacing: 0.05em; }
228
- table { width: 100%; border-collapse: collapse; }
229
- tr:not(:last-child) td { border-bottom: 1px solid #1f2335; }
230
- .models { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
231
- ul { list-style: none; padding: 12px 16px; }
232
- .footer { color: #374151; font-size: 12px; text-align: center; margin-top: 16px; }
233
- code { background: #1e2130; padding: 1px 5px; border-radius: 4px; }
234
- </style>
235
- </head>
236
- <body>
237
- <h1>🌉 CLI Bridge</h1>
238
- <p class="subtitle">v${version} &nbsp;·&nbsp; Port ${opts.port} &nbsp;·&nbsp; Auto-refreshes every 30s</p>
239
-
240
- <div class="card">
241
- <div class="card-header">Web Session Providers</div>
242
- <table>
243
- <thead>
244
- <tr style="background:#13151f">
245
- <th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Provider</th>
246
- <th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Status</th>
247
- <th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Session</th>
248
- <th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Login</th>
249
- </tr>
250
- </thead>
251
- <tbody>${rows}</tbody>
252
- </table>
253
- </div>
254
-
255
- <div class="models">
256
- <div class="card">
257
- <div class="card-header">CLI Models (${cliModels.length})</div>
258
- <ul>${modelList(cliModels)}</ul>
259
- <div class="card-header">Codex Models (${codexModels.length})</div>
260
- <ul>${modelList(codexModels)}</ul>
261
- </div>
262
- <div class="card">
263
- <div class="card-header">Web Session Models (${webModels.length})</div>
264
- <ul>${modelList(webModels)}</ul>
265
- </div>
266
- <div class="card">
267
- <div class="card-header">Local Models (${localModels.length})</div>
268
- <ul>${modelList(localModels)}</ul>
269
- </div>
270
- </div>
271
-
272
- <p class="footer">openclaw-cli-bridge-elvatis v${version} &nbsp;·&nbsp; <a href="/v1/models" style="color:#4b5563">/v1/models</a> &nbsp;·&nbsp; <a href="/health" style="color:#4b5563">/health</a></p>
273
- </body>
274
- </html>`;
275
-
219
+ const html = renderStatusPage({ version, port: opts.port, providers, models: CLI_MODELS, modelCommands: opts.modelCommands });
276
220
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
277
221
  res.end(html);
278
222
  return;
@@ -645,14 +589,32 @@ async function handleRequest(
645
589
 
646
590
  // ── CLI runner routing (Gemini / Claude Code) ─────────────────────────────
647
591
  let content: string;
592
+ let usedModel = model;
648
593
  try {
649
594
  content = await routeToCliRunner(model, messages, opts.timeoutMs ?? 120_000);
650
595
  } catch (err) {
651
596
  const msg = (err as Error).message;
652
- opts.warn(`[cli-bridge] CLI error for ${model}: ${msg}`);
653
- res.writeHead(500, { "Content-Type": "application/json" });
654
- res.end(JSON.stringify({ error: { message: msg, type: "cli_error" } }));
655
- return;
597
+ // ── Model fallback: retry once with a lighter model if configured ────
598
+ const fallbackModel = opts.modelFallbacks?.[model];
599
+ if (fallbackModel) {
600
+ opts.warn(`[cli-bridge] ${model} failed (${msg}), falling back to ${fallbackModel}`);
601
+ try {
602
+ content = await routeToCliRunner(fallbackModel, messages, opts.timeoutMs ?? 120_000);
603
+ usedModel = fallbackModel;
604
+ opts.log(`[cli-bridge] fallback to ${fallbackModel} succeeded`);
605
+ } catch (fallbackErr) {
606
+ const fallbackMsg = (fallbackErr as Error).message;
607
+ opts.warn(`[cli-bridge] fallback ${fallbackModel} also failed: ${fallbackMsg}`);
608
+ res.writeHead(500, { "Content-Type": "application/json" });
609
+ res.end(JSON.stringify({ error: { message: `${model}: ${msg} | fallback ${fallbackModel}: ${fallbackMsg}`, type: "cli_error" } }));
610
+ return;
611
+ }
612
+ } else {
613
+ opts.warn(`[cli-bridge] CLI error for ${model}: ${msg}`);
614
+ res.writeHead(500, { "Content-Type": "application/json" });
615
+ res.end(JSON.stringify({ error: { message: msg, type: "cli_error" } }));
616
+ return;
617
+ }
656
618
  }
657
619
 
658
620
  if (stream) {
@@ -664,7 +626,7 @@ async function handleRequest(
664
626
  });
665
627
 
666
628
  // Role chunk
667
- sendSseChunk(res, { id, created, model, delta: { role: "assistant" }, finish_reason: null });
629
+ sendSseChunk(res, { id, created, model: usedModel, delta: { role: "assistant" }, finish_reason: null });
668
630
 
669
631
  // Content in chunks (~50 chars each for natural feel)
670
632
  const chunkSize = 50;
@@ -672,14 +634,14 @@ async function handleRequest(
672
634
  sendSseChunk(res, {
673
635
  id,
674
636
  created,
675
- model,
637
+ model: usedModel,
676
638
  delta: { content: content.slice(i, i + chunkSize) },
677
639
  finish_reason: null,
678
640
  });
679
641
  }
680
642
 
681
643
  // Stop chunk
682
- sendSseChunk(res, { id, created, model, delta: {}, finish_reason: "stop" });
644
+ sendSseChunk(res, { id, created, model: usedModel, delta: {}, finish_reason: "stop" });
683
645
  res.write("data: [DONE]\n\n");
684
646
  res.end();
685
647
  } else {
@@ -687,7 +649,7 @@ async function handleRequest(
687
649
  id,
688
650
  object: "chat.completion",
689
651
  created,
690
- model,
652
+ model: usedModel,
691
653
  choices: [
692
654
  {
693
655
  index: 0,
@@ -0,0 +1,130 @@
1
+ /**
2
+ * status-template.ts
3
+ *
4
+ * Generates the HTML dashboard for the /status endpoint.
5
+ * Extracted from proxy-server.ts for maintainability.
6
+ */
7
+
8
+ import type { BrowserContext } from "playwright";
9
+
10
+ export interface StatusProvider {
11
+ name: string;
12
+ icon: string;
13
+ expiry: string | null;
14
+ loginCmd: string;
15
+ ctx: BrowserContext | null;
16
+ }
17
+
18
+ export interface StatusTemplateOptions {
19
+ version: string;
20
+ port: number;
21
+ providers: StatusProvider[];
22
+ models: Array<{ id: string; name: string; contextWindow: number; maxTokens: number }>;
23
+ /** Maps model ID → slash command name (e.g. "openai-codex/gpt-5.3-codex" → "/cli-codex") */
24
+ modelCommands?: Record<string, string>;
25
+ }
26
+
27
+ function statusBadge(p: StatusProvider): { label: string; color: string; dot: string } {
28
+ if (p.ctx !== null) return { label: "Connected", color: "#22c55e", dot: "🟢" };
29
+ if (!p.expiry) return { label: "Never logged in", color: "#6b7280", dot: "⚪" };
30
+ if (p.expiry.startsWith("⚠️ EXPIRED")) return { label: "Expired", color: "#ef4444", dot: "🔴" };
31
+ if (p.expiry.startsWith("🚨")) return { label: "Expiring soon", color: "#f59e0b", dot: "🟡" };
32
+ return { label: "Logged in", color: "#3b82f6", dot: "🔵" };
33
+ }
34
+
35
+ export function renderStatusPage(opts: StatusTemplateOptions): string {
36
+ const { version, port, providers, models } = opts;
37
+
38
+ const rows = providers.map(p => {
39
+ const badge = statusBadge(p);
40
+ const expiryText = p.expiry
41
+ ? p.expiry.replace(/[⚠️🚨✅🕐]/gu, "").trim()
42
+ : `Not logged in — run <code>${p.loginCmd}</code>`;
43
+ return `
44
+ <tr>
45
+ <td style="padding:12px 16px;font-weight:600;font-size:15px">${p.icon} ${p.name}</td>
46
+ <td style="padding:12px 16px">
47
+ <span style="background:${badge.color}22;color:${badge.color};border:1px solid ${badge.color}44;
48
+ border-radius:6px;padding:3px 10px;font-size:13px;font-weight:600">
49
+ ${badge.dot} ${badge.label}
50
+ </span>
51
+ </td>
52
+ <td style="padding:12px 16px;color:#9ca3af;font-size:13px">${expiryText}</td>
53
+ <td style="padding:12px 16px;color:#6b7280;font-size:12px;font-family:monospace">${p.loginCmd}</td>
54
+ </tr>`;
55
+ }).join("");
56
+
57
+ const cliModels = models.filter(m => m.id.startsWith("cli-"));
58
+ const codexModels = models.filter(m => m.id.startsWith("openai-codex/"));
59
+ const webModels = models.filter(m => m.id.startsWith("web-"));
60
+ const localModels = models.filter(m => m.id.startsWith("local-"));
61
+ const cmds = opts.modelCommands ?? {};
62
+ const modelList = (items: typeof models) =>
63
+ items.map(m => {
64
+ const cmd = cmds[m.id];
65
+ const cmdBadge = cmd ? `<span style="color:#6b7280;font-size:11px;margin-left:8px">${cmd}</span>` : "";
66
+ return `<li style="margin:2px 0;font-size:13px;color:#d1d5db"><code style="color:#93c5fd">${m.id}</code>${cmdBadge}</li>`;
67
+ }).join("");
68
+
69
+ return `<!DOCTYPE html>
70
+ <html lang="en">
71
+ <head>
72
+ <meta charset="UTF-8">
73
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
74
+ <title>CLI Bridge Status</title>
75
+ <meta http-equiv="refresh" content="30">
76
+ <style>
77
+ * { box-sizing: border-box; margin: 0; padding: 0; }
78
+ body { background: #0f1117; color: #e5e7eb; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; min-height: 100vh; padding: 32px 24px; }
79
+ h1 { font-size: 22px; font-weight: 700; color: #f9fafb; margin-bottom: 4px; }
80
+ .subtitle { color: #6b7280; font-size: 13px; margin-bottom: 28px; }
81
+ .card { background: #1a1d27; border: 1px solid #2d3148; border-radius: 12px; overflow: hidden; margin-bottom: 24px; }
82
+ .card-header { padding: 14px 16px; border-bottom: 1px solid #2d3148; font-size: 12px; font-weight: 600; color: #6b7280; text-transform: uppercase; letter-spacing: 0.05em; }
83
+ table { width: 100%; border-collapse: collapse; }
84
+ tr:not(:last-child) td { border-bottom: 1px solid #1f2335; }
85
+ .models { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
86
+ ul { list-style: none; padding: 12px 16px; }
87
+ .footer { color: #374151; font-size: 12px; text-align: center; margin-top: 16px; }
88
+ code { background: #1e2130; padding: 1px 5px; border-radius: 4px; }
89
+ </style>
90
+ </head>
91
+ <body>
92
+ <h1>CLI Bridge</h1>
93
+ <p class="subtitle">v${version} &nbsp;&middot;&nbsp; Port ${port} &nbsp;&middot;&nbsp; Auto-refreshes every 30s</p>
94
+
95
+ <div class="card">
96
+ <div class="card-header">Web Session Providers</div>
97
+ <table>
98
+ <thead>
99
+ <tr style="background:#13151f">
100
+ <th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Provider</th>
101
+ <th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Status</th>
102
+ <th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Session</th>
103
+ <th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Login</th>
104
+ </tr>
105
+ </thead>
106
+ <tbody>${rows}</tbody>
107
+ </table>
108
+ </div>
109
+
110
+ <div class="models">
111
+ <div class="card">
112
+ <div class="card-header">CLI Models (${cliModels.length})</div>
113
+ <ul>${modelList(cliModels)}</ul>
114
+ <div class="card-header">Codex Models (${codexModels.length})</div>
115
+ <ul>${modelList(codexModels)}</ul>
116
+ </div>
117
+ <div class="card">
118
+ <div class="card-header">Web Session Models (${webModels.length})</div>
119
+ <ul>${modelList(webModels)}</ul>
120
+ </div>
121
+ <div class="card">
122
+ <div class="card-header">Local Models (${localModels.length})</div>
123
+ <ul>${modelList(localModels)}</ul>
124
+ </div>
125
+ </div>
126
+
127
+ <p class="footer">openclaw-cli-bridge-elvatis v${version} &nbsp;&middot;&nbsp; <a href="/v1/models" style="color:#4b5563">/v1/models</a> &nbsp;&middot;&nbsp; <a href="/health" style="color:#4b5563">/health</a> &nbsp;&middot;&nbsp; <a href="/healthz" style="color:#4b5563">/healthz</a></p>
128
+ </body>
129
+ </html>`;
130
+ }