From fc544bfeb2ce3372eb816dbce52dd05e72f1c59d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jim=20Liu=20=E5=AE=9D=E7=8E=89?= Date: Mon, 12 Jan 2026 23:58:26 -0600 Subject: [PATCH] Add gemini-web skill --- skills/gemini-web/SKILL.md | 110 ++++++ skills/gemini-web/scripts/chrome-auth.ts | 338 ++++++++++++++++++ skills/gemini-web/scripts/client.ts | 413 ++++++++++++++++++++++ skills/gemini-web/scripts/cookie-store.ts | 140 ++++++++ skills/gemini-web/scripts/executor.ts | 262 ++++++++++++++ skills/gemini-web/scripts/get-cookie.ts | 21 ++ skills/gemini-web/scripts/index.ts | 2 + skills/gemini-web/scripts/main.ts | 340 ++++++++++++++++++ skills/gemini-web/scripts/paths.ts | 36 ++ skills/gemini-web/scripts/types.ts | 16 + 10 files changed, 1678 insertions(+) create mode 100644 skills/gemini-web/SKILL.md create mode 100644 skills/gemini-web/scripts/chrome-auth.ts create mode 100644 skills/gemini-web/scripts/client.ts create mode 100644 skills/gemini-web/scripts/cookie-store.ts create mode 100644 skills/gemini-web/scripts/executor.ts create mode 100644 skills/gemini-web/scripts/get-cookie.ts create mode 100644 skills/gemini-web/scripts/index.ts create mode 100644 skills/gemini-web/scripts/main.ts create mode 100644 skills/gemini-web/scripts/paths.ts create mode 100644 skills/gemini-web/scripts/types.ts diff --git a/skills/gemini-web/SKILL.md b/skills/gemini-web/SKILL.md new file mode 100644 index 0000000..7a39eda --- /dev/null +++ b/skills/gemini-web/SKILL.md @@ -0,0 +1,110 @@ +--- +name: gemini-web +description: Interacts with Gemini Web to generate text and images. Use when the user needs AI-generated content via Gemini, including text responses and image generation. +--- + +# Gemini Web Client + +## Quick start + +```bash +npx -y bun scripts/main.ts "Hello, Gemini" +npx -y bun scripts/main.ts --prompt "Explain quantum computing" +npx -y bun scripts/main.ts --prompt "A cute cat" --image cat.png +``` + +## Commands + +### Text generation + +```bash +# Simple prompt (positional) +npx -y bun scripts/main.ts "Your prompt here" + +# Explicit prompt flag +npx -y bun scripts/main.ts --prompt "Your prompt here" +npx -y bun scripts/main.ts -p "Your prompt here" + +# With model selection +npx -y bun scripts/main.ts -p "Hello" -m gemini-2.5-pro + +# Pipe from stdin +echo "Summarize this" | npx -y bun scripts/main.ts +``` + +### Image generation + +```bash +# Generate image with default path (./generated.png) +npx -y bun scripts/main.ts --prompt "A sunset over mountains" --image + +# Generate image with custom path +npx -y bun scripts/main.ts --prompt "A cute robot" --image robot.png + +# Shorthand +npx -y bun scripts/main.ts "A dragon" --image=dragon.png +``` + +### Output formats + +```bash +# Plain text (default) +npx -y bun scripts/main.ts "Hello" + +# JSON output +npx -y bun scripts/main.ts "Hello" --json +``` + +## Options + +| Option | Short | Description | +|--------|-------|-------------| +| `--prompt ` | `-p` | Prompt text | +| `--model ` | `-m` | Model: gemini-3-pro (default), gemini-2.5-pro, gemini-2.5-flash | +| `--image [path]` | | Generate image, save to path (default: generated.png) | +| `--json` | | Output as JSON | +| `--login` | | Refresh cookies only, then exit | +| `--cookie-path ` | | Custom cookie file path | +| `--profile-dir ` | | Chrome profile directory | +| `--help` | `-h` | Show help | + +## Models + +- `gemini-3-pro` - Default, latest model +- `gemini-2.5-pro` - Previous generation pro +- `gemini-2.5-flash` - Fast, lightweight + +## Authentication + +First run opens Chrome to authenticate with Google. Cookies are cached for subsequent runs. + +```bash +# Force cookie refresh +npx -y bun scripts/main.ts --login +``` + +## Environment variables + +| Variable | Description | +|----------|-------------| +| `GEMINI_WEB_DATA_DIR` | Data directory | +| `GEMINI_WEB_COOKIE_PATH` | Cookie file path | +| `GEMINI_WEB_CHROME_PROFILE_DIR` | Chrome profile directory | +| `GEMINI_WEB_CHROME_PATH` | Chrome executable path | + +## Examples + +### Generate text response +```bash +npx -y bun scripts/main.ts "What is the capital of France?" +``` + +### Generate image +```bash +npx -y bun scripts/main.ts "A photorealistic image of a golden retriever puppy" --image puppy.png +``` + +### Get JSON output for parsing +```bash +npx -y bun scripts/main.ts "Hello" --json | jq '.text' +``` diff --git a/skills/gemini-web/scripts/chrome-auth.ts b/skills/gemini-web/scripts/chrome-auth.ts new file mode 100644 index 0000000..9b135a1 --- /dev/null +++ b/skills/gemini-web/scripts/chrome-auth.ts @@ -0,0 +1,338 @@ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import { mkdir } from 'node:fs/promises'; +import net from 'node:net'; +import process from 'node:process'; + +import { fetchGeminiAccessToken } from './client.js'; +import type { GeminiWebLog } from './cookie-store.js'; +import { buildGeminiCookieMap, hasRequiredGeminiCookies } from './cookie-store.js'; +import { resolveGeminiWebChromeProfileDir } from './paths.js'; + +const GEMINI_URL = 'https://gemini.google.com/app'; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(() => reject(new Error('Unable to allocate a free TCP port for Chrome debugging.'))); + return; + } + const port = address.port; + server.close((err) => { + if (err) reject(err); + else resolve(port); + }); + }); + }); +} + +function findChromeExecutable(): string | undefined { + const override = process.env.GEMINI_WEB_CHROME_PATH?.trim(); + if (override && fs.existsSync(override)) return override; + + const candidates: string[] = []; + switch (process.platform) { + case 'darwin': + candidates.push( + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', + '/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', + ); + break; + case 'win32': + candidates.push( + 'C:\\\\Program Files\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe', + 'C:\\\\Program Files (x86)\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe', + 'C:\\\\Program Files\\\\Microsoft\\\\Edge\\\\Application\\\\msedge.exe', + 'C:\\\\Program Files (x86)\\\\Microsoft\\\\Edge\\\\Application\\\\msedge.exe', + ); + break; + default: + candidates.push( + '/usr/bin/google-chrome', + '/usr/bin/google-chrome-stable', + '/usr/bin/chromium', + '/usr/bin/chromium-browser', + '/snap/bin/chromium', + '/usr/bin/microsoft-edge', + '/usr/bin/microsoft-edge-stable', + ); + break; + } + + for (const p of candidates) { + if (fs.existsSync(p)) return p; + } + return undefined; +} + +async function fetchJson(url: string): Promise { + const res = await fetch(url, { redirect: 'follow' }); + if (!res.ok) { + throw new Error(`Request failed: ${res.status} ${res.statusText} (${url})`); + } + return (await res.json()) as T; +} + +async function waitForChromeDebugPort( + port: number, + timeoutMs: number, +): Promise<{ webSocketDebuggerUrl: string }> { + const start = Date.now(); + let lastError: unknown = null; + + while (Date.now() - start < timeoutMs) { + try { + const version = await fetchJson<{ webSocketDebuggerUrl?: string }>( + `http://127.0.0.1:${port}/json/version`, + ); + if (version.webSocketDebuggerUrl) { + return { webSocketDebuggerUrl: version.webSocketDebuggerUrl }; + } + lastError = new Error('Missing webSocketDebuggerUrl'); + } catch (error) { + lastError = error; + } + + await sleep(200); + } + + throw new Error( + `Chrome debugging endpoint did not become ready within ${timeoutMs}ms: ${lastError instanceof Error ? lastError.message : String(lastError)}`, + ); +} + +class CdpConnection { + private ws: WebSocket; + private nextId = 0; + private pending = new Map< + number, + { resolve: (value: unknown) => void; reject: (reason: Error) => void; timer: ReturnType | null } + >(); + + private constructor(ws: WebSocket) { + this.ws = ws; + this.ws.addEventListener('message', (event) => { + try { + const data = (() => { + if (typeof event.data === 'string') return event.data; + if (event.data instanceof ArrayBuffer) { + return new TextDecoder().decode(new Uint8Array(event.data)); + } + if (ArrayBuffer.isView(event.data)) { + return new TextDecoder().decode(event.data); + } + return String(event.data); + })(); + const msg = JSON.parse(data) as { id?: number; result?: unknown; error?: { message?: string } }; + if (!msg.id) return; + const pending = this.pending.get(msg.id); + if (!pending) return; + this.pending.delete(msg.id); + if (pending.timer) clearTimeout(pending.timer); + if (msg.error?.message) pending.reject(new Error(msg.error.message)); + else pending.resolve(msg.result); + } catch { + // ignore malformed events + } + }); + + this.ws.addEventListener('close', () => { + for (const [id, pending] of this.pending.entries()) { + this.pending.delete(id); + if (pending.timer) clearTimeout(pending.timer); + pending.reject(new Error('Chrome DevTools connection closed.')); + } + }); + } + + static async connect(url: string, timeoutMs: number): Promise { + const ws = new WebSocket(url); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('Timed out connecting to Chrome DevTools.')), timeoutMs); + ws.addEventListener('open', () => { + clearTimeout(timer); + resolve(); + }); + ws.addEventListener('error', () => { + clearTimeout(timer); + reject(new Error('Failed to connect to Chrome DevTools.')); + }); + }); + return new CdpConnection(ws); + } + + async send( + method: string, + params?: Record, + options?: { sessionId?: string; timeoutMs?: number }, + ): Promise { + const id = (this.nextId += 1); + const message: Record = { id, method }; + if (params) message.params = params; + if (options?.sessionId) message.sessionId = options.sessionId; + + const timeoutMs = options?.timeoutMs ?? 15_000; + + const result = await new Promise((resolve, reject) => { + const timer = + timeoutMs > 0 + ? setTimeout(() => { + this.pending.delete(id); + reject(new Error(`CDP command timeout (${method}) after ${timeoutMs}ms.`)); + }, timeoutMs) + : null; + this.pending.set(id, { + resolve, + reject: (reason) => reject(reason), + timer, + }); + this.ws.send(JSON.stringify(message)); + }); + + return result as T; + } + + close(): void { + try { + this.ws.close(); + } catch { + // ignore + } + } +} + +export async function getGeminiCookieMapViaChrome(options?: { + timeoutMs?: number; + debugConnectTimeoutMs?: number; + pollIntervalMs?: number; + log?: GeminiWebLog; + userDataDir?: string; + chromePath?: string; +}): Promise> { + const log = options?.log; + const timeoutMs = options?.timeoutMs ?? 5 * 60_000; + const debugConnectTimeoutMs = options?.debugConnectTimeoutMs ?? 30_000; + const pollIntervalMs = options?.pollIntervalMs ?? 2_000; + const userDataDir = options?.userDataDir ?? resolveGeminiWebChromeProfileDir(); + + const chromePath = options?.chromePath ?? findChromeExecutable(); + if (!chromePath) { + throw new Error( + 'Unable to locate a Chrome/Chromium executable. Install Google Chrome or set GEMINI_WEB_CHROME_PATH.', + ); + } + + await mkdir(userDataDir, { recursive: true }); + + const port = await getFreePort(); + log?.(`[gemini-web] Launching Chrome for cookie sync (profile: ${userDataDir})`); + + const chrome = spawn( + chromePath, + [ + `--remote-debugging-port=${port}`, + `--user-data-dir=${userDataDir}`, + '--no-first-run', + '--no-default-browser-check', + '--disable-blink-features=AutomationControlled', + '--start-maximized', + GEMINI_URL, + ], + { stdio: 'ignore' }, + ); + + let cdp: CdpConnection | null = null; + try { + const { webSocketDebuggerUrl } = await waitForChromeDebugPort(port, debugConnectTimeoutMs); + cdp = await CdpConnection.connect(webSocketDebuggerUrl, debugConnectTimeoutMs); + + const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url: GEMINI_URL }); + const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { + targetId, + flatten: true, + }); + + await cdp.send('Page.enable', {}, { sessionId }); + await cdp.send('Network.enable', {}, { sessionId }); + + log?.('[gemini-web] Please log in to Gemini in the opened browser window.'); + log?.('[gemini-web] Waiting for cookies to become available...'); + + const start = Date.now(); + let lastTokenError: string | null = null; + while (Date.now() - start < timeoutMs) { + const response = await cdp.send<{ cookies?: unknown[] }>( + 'Network.getCookies', + { urls: [GEMINI_URL, 'https://google.com/'] }, + { sessionId, timeoutMs: 10_000 }, + ); + + const rawCookies = Array.isArray(response.cookies) ? response.cookies : []; + const cookieMap = buildGeminiCookieMap( + rawCookies.filter( + (cookie): cookie is { name?: string; value?: string; domain?: string; path?: string; url?: string } => + Boolean(cookie && typeof cookie === 'object'), + ), + ); + + if (hasRequiredGeminiCookies(cookieMap)) { + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 10_000); + try { + await fetchGeminiAccessToken(cookieMap, controller.signal); + } finally { + clearTimeout(timer); + } + log?.('[gemini-web] Gemini cookies detected.'); + return cookieMap; + } catch (error) { + lastTokenError = error instanceof Error ? error.message : String(error); + } + } + + await sleep(pollIntervalMs); + } + + throw new Error( + `Timed out waiting for Gemini cookies after ${timeoutMs}ms${lastTokenError ? ` (last error: ${lastTokenError})` : ''}.`, + ); + } finally { + if (cdp) { + try { + await cdp.send('Browser.close', {}, { timeoutMs: 5_000 }); + } catch { + // ignore + } + cdp.close(); + } + + const killTimer = setTimeout(() => { + if (!chrome.killed) { + try { + chrome.kill('SIGKILL'); + } catch { + // ignore + } + } + }, 2_000); + killTimer.unref?.(); + try { + chrome.kill('SIGTERM'); + } catch { + // ignore + } + } +} diff --git a/skills/gemini-web/scripts/client.ts b/skills/gemini-web/scripts/client.ts new file mode 100644 index 0000000..816e751 --- /dev/null +++ b/skills/gemini-web/scripts/client.ts @@ -0,0 +1,413 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +export type GeminiWebModelId = 'gemini-3-pro' | 'gemini-2.5-pro' | 'gemini-2.5-flash'; + +export interface GeminiWebRunInput { + prompt: string; + files?: string[]; + model: GeminiWebModelId; + cookieMap: Record; + chatMetadata?: unknown; + signal?: AbortSignal; +} + +export interface GeminiWebCandidateImage { + url: string; + title?: string; + alt?: string; + kind: 'web' | 'generated' | 'raw'; +} + +export interface GeminiWebRunOutput { + rawResponseText: string; + text: string; + thoughts: string | null; + metadata: unknown; + images: GeminiWebCandidateImage[]; + errorCode?: number; + errorMessage?: string; +} + +const USER_AGENT = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; + +const MODEL_HEADER_NAME = 'x-goog-ext-525001261-jspb'; +const MODEL_HEADERS: Record = { + 'gemini-3-pro': '[1,null,null,null,"9d8ca3786ebdfbea",null,null,0,[4]]', + 'gemini-2.5-pro': '[1,null,null,null,"4af6c7f5da75d65d",null,null,0,[4]]', + 'gemini-2.5-flash': '[1,null,null,null,"9ec249fc9ad08861",null,null,0,[4]]', +}; + +const GEMINI_APP_URL = 'https://gemini.google.com/app'; +const GEMINI_STREAM_GENERATE_URL = + 'https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate'; +const GEMINI_UPLOAD_URL = 'https://content-push.googleapis.com/upload'; +const GEMINI_UPLOAD_PUSH_ID = 'feeds/mcudyrk2a4khkz'; + +function getNestedValue(value: unknown, pathParts: Array, fallback: T): T { + let current: unknown = value; + for (const part of pathParts) { + if (current == null) return fallback; + if (typeof part === 'number') { + if (!Array.isArray(current)) return fallback; + current = current[part]; + } else { + if (typeof current !== 'object') return fallback; + current = (current as Record)[part]; + } + } + return (current as T) ?? fallback; +} + +function buildCookieHeader(cookieMap: Record): string { + return Object.entries(cookieMap) + .filter(([, value]) => typeof value === 'string' && value.length > 0) + .map(([name, value]) => `${name}=${value}`) + .join('; '); +} + +export async function fetchGeminiAccessToken( + cookieMap: Record, + signal?: AbortSignal, +): Promise { + const cookieHeader = buildCookieHeader(cookieMap); + const res = await fetch(GEMINI_APP_URL, { + redirect: 'follow', + signal, + headers: { + cookie: cookieHeader, + 'user-agent': USER_AGENT, + }, + }); + const html = await res.text(); + + const tokens = ['SNlM0e', 'thykhd'] as const; + for (const key of tokens) { + const match = html.match(new RegExp(`"${key}":"(.*?)"`)); + if (match?.[1]) return match[1]; + } + throw new Error( + 'Unable to locate Gemini access token on gemini.google.com/app (missing SNlM0e/thykhd).', + ); +} + +function trimGeminiJsonEnvelope(text: string): string { + const start = text.indexOf('['); + const end = text.lastIndexOf(']'); + if (start === -1 || end === -1 || end <= start) { + throw new Error('Gemini response did not contain a JSON payload.'); + } + return text.slice(start, end + 1); +} + +function extractErrorCode(responseJson: unknown): number | undefined { + const code = getNestedValue(responseJson, [0, 5, 2, 0, 1, 0], -1); + return typeof code === 'number' && code >= 0 ? code : undefined; +} + +function extractGgdlUrls(rawText: string): string[] { + const matches = rawText.match(/https:\/\/lh3\.googleusercontent\.com\/gg-dl\/[^\s"']+/g) ?? []; + const seen = new Set(); + const urls: string[] = []; + for (const match of matches) { + if (seen.has(match)) continue; + seen.add(match); + urls.push(match); + } + return urls; +} + +function ensureFullSizeImageUrl(url: string): string { + if (url.includes('=s2048')) return url; + if (url.includes('=s')) return url; + return `${url}=s2048`; +} + +async function fetchWithCookiePreservingRedirects( + url: string, + init: Omit, + signal?: AbortSignal, + maxRedirects = 10, +): Promise { + let current = url; + for (let i = 0; i <= maxRedirects; i += 1) { + const res = await fetch(current, { ...init, redirect: 'manual', signal }); + if (res.status >= 300 && res.status < 400) { + const location = res.headers.get('location'); + if (!location) return res; + current = new URL(location, current).toString(); + continue; + } + return res; + } + throw new Error(`Too many redirects while downloading image (>${maxRedirects}).`); +} + +export async function downloadGeminiImage( + url: string, + cookieMap: Record, + outputPath: string, + signal?: AbortSignal, +): Promise { + const cookieHeader = buildCookieHeader(cookieMap); + const res = await fetchWithCookiePreservingRedirects(ensureFullSizeImageUrl(url), { + headers: { + cookie: cookieHeader, + 'user-agent': USER_AGENT, + }, + }, signal); + if (!res.ok) { + throw new Error(`Failed to download image: ${res.status} ${res.statusText} (${res.url})`); + } + + const data = new Uint8Array(await res.arrayBuffer()); + await mkdir(path.dirname(outputPath), { recursive: true }); + await writeFile(outputPath, data); +} + +async function uploadGeminiFile(filePath: string, signal?: AbortSignal): Promise<{ id: string; name: string }> { + const absPath = path.resolve(process.cwd(), filePath); + const data = await readFile(absPath); + const fileName = path.basename(absPath); + const form = new FormData(); + form.append('file', new Blob([data]), fileName); + + const res = await fetch(GEMINI_UPLOAD_URL, { + method: 'POST', + redirect: 'follow', + signal, + headers: { + 'push-id': GEMINI_UPLOAD_PUSH_ID, + 'user-agent': USER_AGENT, + }, + body: form, + }); + const text = await res.text(); + if (!res.ok) { + throw new Error(`File upload failed: ${res.status} ${res.statusText} (${text.slice(0, 200)})`); + } + return { id: text, name: fileName }; +} + +function buildGeminiFReqPayload( + prompt: string, + uploaded: Array<{ id: string; name: string }>, + chatMetadata: unknown, +): string { + const promptPayload = + uploaded.length > 0 + ? [ + prompt, + 0, + null, + // Matches gemini-webapi payload format: [[[fileId, 1]]] for a single attachment. + // Keep it extensible for multiple uploads by emitting one [[id, 1]] entry per file. + uploaded.map((file) => [[file.id, 1]]), + ] + : [prompt]; + + const innerList: unknown[] = [promptPayload, null, chatMetadata ?? null]; + return JSON.stringify([null, JSON.stringify(innerList)]); +} + +export function parseGeminiStreamGenerateResponse(rawText: string): { + metadata: unknown; + text: string; + thoughts: string | null; + images: GeminiWebCandidateImage[]; + errorCode?: number; +} { + const responseJson = JSON.parse(trimGeminiJsonEnvelope(rawText)) as unknown; + const errorCode = extractErrorCode(responseJson); + + const parts = Array.isArray(responseJson) ? responseJson : []; + let bodyIndex = 0; + let body: unknown = null; + for (let i = 0; i < parts.length; i += 1) { + const partBody = getNestedValue(parts[i], [2], null); + if (!partBody) continue; + try { + const parsed = JSON.parse(partBody) as unknown; + const candidateList = getNestedValue(parsed, [4], []); + if (Array.isArray(candidateList) && candidateList.length > 0) { + bodyIndex = i; + body = parsed; + break; + } + } catch { + // ignore + } + } + + const candidateList = getNestedValue(body, [4], []); + const firstCandidate = candidateList[0]; + const textRaw = getNestedValue(firstCandidate, [1, 0], ''); + const cardContent = /^http:\/\/googleusercontent\.com\/card_content\/\d+/.test(textRaw); + const text = cardContent + ? (getNestedValue(firstCandidate, [22, 0], null) ?? textRaw) + : textRaw; + const thoughts = getNestedValue(firstCandidate, [37, 0, 0], null); + const metadata = getNestedValue(body, [1], []); + + const images: GeminiWebCandidateImage[] = []; + + const webImages = getNestedValue(firstCandidate, [12, 1], []); + for (const webImage of webImages) { + const url = getNestedValue(webImage, [0, 0, 0], null); + if (!url) continue; + images.push({ + kind: 'web', + url, + title: getNestedValue(webImage, [7, 0], undefined), + alt: getNestedValue(webImage, [0, 4], undefined), + }); + } + + const hasGenerated = Boolean(getNestedValue(firstCandidate, [12, 7, 0], null)); + if (hasGenerated) { + let imgBody: unknown = null; + for (let i = bodyIndex; i < parts.length; i += 1) { + const partBody = getNestedValue(parts[i], [2], null); + if (!partBody) continue; + try { + const parsed = JSON.parse(partBody) as unknown; + const candidateImages = getNestedValue(parsed, [4, 0, 12, 7, 0], null); + if (candidateImages != null) { + imgBody = parsed; + break; + } + } catch { + // ignore + } + } + + const imgCandidate = getNestedValue(imgBody ?? body, [4, 0], null); + + const generated = getNestedValue(imgCandidate, [12, 7, 0], []); + for (const genImage of generated) { + const url = getNestedValue(genImage, [0, 3, 3], null); + if (!url) continue; + images.push({ + kind: 'generated', + url, + title: '[Generated Image]', + alt: '', + }); + } + } + + return { metadata, text, thoughts, images, errorCode }; +} + +export function isGeminiModelUnavailable(errorCode: number | undefined): boolean { + return errorCode === 1052; +} + +export async function runGeminiWebOnce(input: GeminiWebRunInput): Promise { + const cookieHeader = buildCookieHeader(input.cookieMap); + const at = await fetchGeminiAccessToken(input.cookieMap, input.signal); + + const uploaded: Array<{ id: string; name: string }> = []; + for (const file of input.files ?? []) { + if (input.signal?.aborted) { + throw new Error('Gemini web run aborted before upload.'); + } + uploaded.push(await uploadGeminiFile(file, input.signal)); + } + + const fReq = buildGeminiFReqPayload(input.prompt, uploaded, input.chatMetadata ?? null); + const params = new URLSearchParams(); + params.set('at', at); + params.set('f.req', fReq); + + const res = await fetch(GEMINI_STREAM_GENERATE_URL, { + method: 'POST', + redirect: 'follow', + signal: input.signal, + headers: { + 'content-type': 'application/x-www-form-urlencoded;charset=utf-8', + origin: 'https://gemini.google.com', + referer: 'https://gemini.google.com/', + 'x-same-domain': '1', + 'user-agent': USER_AGENT, + cookie: cookieHeader, + [MODEL_HEADER_NAME]: MODEL_HEADERS[input.model], + }, + body: params.toString(), + }); + + const rawResponseText = await res.text(); + if (!res.ok) { + return { + rawResponseText, + text: '', + thoughts: null, + metadata: input.chatMetadata ?? null, + images: [], + errorMessage: `Gemini request failed: ${res.status} ${res.statusText}`, + }; + } + + try { + const parsed = parseGeminiStreamGenerateResponse(rawResponseText); + return { + rawResponseText, + text: parsed.text ?? '', + thoughts: parsed.thoughts, + metadata: parsed.metadata, + images: parsed.images, + errorCode: parsed.errorCode, + }; + } catch (error) { + let responseJson: unknown = null; + try { + responseJson = JSON.parse(trimGeminiJsonEnvelope(rawResponseText)) as unknown; + } catch { + responseJson = null; + } + const errorCode = extractErrorCode(responseJson); + + return { + rawResponseText, + text: '', + thoughts: null, + metadata: input.chatMetadata ?? null, + images: [], + errorCode: typeof errorCode === 'number' ? errorCode : undefined, + errorMessage: error instanceof Error ? error.message : String(error ?? ''), + }; + } +} + +export async function runGeminiWebWithFallback( + input: Omit & { model: GeminiWebModelId }, +): Promise { + const attempt = await runGeminiWebOnce(input); + if (isGeminiModelUnavailable(attempt.errorCode) && input.model !== 'gemini-2.5-flash') { + const fallback = await runGeminiWebOnce({ ...input, model: 'gemini-2.5-flash' }); + return { ...fallback, effectiveModel: 'gemini-2.5-flash' }; + } + return { ...attempt, effectiveModel: input.model }; +} + +export async function saveFirstGeminiImageFromOutput( + output: GeminiWebRunOutput, + cookieMap: Record, + outputPath: string, + signal?: AbortSignal, +): Promise<{ saved: boolean; imageCount: number }> { + const generatedOrWeb = output.images.find((img) => img.kind === 'generated') ?? output.images[0]; + if (generatedOrWeb?.url) { + await downloadGeminiImage(generatedOrWeb.url, cookieMap, outputPath, signal); + return { saved: true, imageCount: output.images.length }; + } + + const ggdl = extractGgdlUrls(output.rawResponseText); + if (ggdl[0]) { + await downloadGeminiImage(ggdl[0], cookieMap, outputPath, signal); + return { saved: true, imageCount: ggdl.length }; + } + + return { saved: false, imageCount: 0 }; +} diff --git a/skills/gemini-web/scripts/cookie-store.ts b/skills/gemini-web/scripts/cookie-store.ts new file mode 100644 index 0000000..7806d4e --- /dev/null +++ b/skills/gemini-web/scripts/cookie-store.ts @@ -0,0 +1,140 @@ +import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { resolveGeminiWebCookiePath } from './paths.js'; + +export type GeminiWebLog = (message: string) => void; + +export const GEMINI_COOKIE_NAMES = [ + '__Secure-1PSID', + '__Secure-1PSIDTS', + '__Secure-1PSIDCC', + '__Secure-1PAPISID', + 'NID', + 'AEC', + 'SOCS', + '__Secure-BUCKET', + '__Secure-ENID', + 'SID', + 'HSID', + 'SSID', + 'APISID', + 'SAPISID', + '__Secure-3PSID', + '__Secure-3PSIDTS', + '__Secure-3PAPISID', + 'SIDCC', +] as const; + +export const GEMINI_REQUIRED_COOKIES = ['__Secure-1PSID', '__Secure-1PSIDTS'] as const; + +export interface GeminiCookieFileV1 { + version: 1; + updatedAt: string; + cookieMap: Record; +} + +export function hasRequiredGeminiCookies(cookieMap: Record): boolean { + return GEMINI_REQUIRED_COOKIES.every((name) => Boolean(cookieMap[name])); +} + +function resolveCookieDomain(cookie: { domain?: string; url?: string }): string | null { + const rawDomain = cookie.domain?.trim(); + if (rawDomain) { + return rawDomain.startsWith('.') ? rawDomain.slice(1) : rawDomain; + } + const rawUrl = cookie.url?.trim(); + if (rawUrl) { + try { + return new URL(rawUrl).hostname; + } catch { + return null; + } + } + return null; +} + +function pickCookieValue( + cookies: T[], + name: string, +): string | undefined { + const matches = cookies.filter((cookie) => cookie.name === name && typeof cookie.value === 'string'); + if (matches.length === 0) return undefined; + + const preferredDomain = matches.find((cookie) => { + const domain = resolveCookieDomain(cookie); + return domain === 'google.com' && (cookie.path ?? '/') === '/'; + }); + const googleDomain = matches.find((cookie) => (resolveCookieDomain(cookie) ?? '').endsWith('google.com')); + return (preferredDomain ?? googleDomain ?? matches[0])?.value; +} + +export function buildGeminiCookieMap< + T extends { name?: string; value?: string; domain?: string; path?: string; url?: string }, +>(cookies: T[]): Record { + const cookieMap: Record = {}; + for (const name of GEMINI_COOKIE_NAMES) { + const value = pickCookieValue(cookies, name); + if (value) cookieMap[name] = value; + } + return cookieMap; +} + +export async function readGeminiCookieMapFromDisk(options?: { + cookiePath?: string; + log?: GeminiWebLog; +}): Promise> { + const cookiePath = options?.cookiePath ?? resolveGeminiWebCookiePath(); + + try { + const raw = await readFile(cookiePath, 'utf8'); + const parsed = JSON.parse(raw) as Partial | Record; + + const cookieMap = + (parsed as Partial).version === 1 + ? (parsed as Partial).cookieMap + : (parsed as Record); + + if (!cookieMap || typeof cookieMap !== 'object') return {}; + const normalized: Record = {}; + for (const [key, value] of Object.entries(cookieMap)) { + if (typeof value === 'string' && value.trim()) { + normalized[key] = value; + } + } + + if (Object.keys(normalized).length > 0) { + options?.log?.(`[gemini-web] Loaded cookies from ${cookiePath}`); + } + + return normalized; + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (code === 'ENOENT') return {}; + options?.log?.( + `[gemini-web] Failed to read cookies from ${cookiePath}: ${error instanceof Error ? error.message : String(error)}`, + ); + return {}; + } +} + +export async function writeGeminiCookieMapToDisk( + cookieMap: Record, + options?: { cookiePath?: string; log?: GeminiWebLog }, +): Promise { + const cookiePath = options?.cookiePath ?? resolveGeminiWebCookiePath(); + await mkdir(path.dirname(cookiePath), { recursive: true }); + + const payload: GeminiCookieFileV1 = { + version: 1, + updatedAt: new Date().toISOString(), + cookieMap, + }; + + await writeFile(cookiePath, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 }); + try { + await chmod(cookiePath, 0o600); + } catch { + // ignore chmod failures (e.g. on Windows) + } + options?.log?.(`[gemini-web] Saved cookies to ${cookiePath}`); +} diff --git a/skills/gemini-web/scripts/executor.ts b/skills/gemini-web/scripts/executor.ts new file mode 100644 index 0000000..d09ea16 --- /dev/null +++ b/skills/gemini-web/scripts/executor.ts @@ -0,0 +1,262 @@ +import path from 'node:path'; +import type { BrowserRunOptions, BrowserRunResult, BrowserLogger, CookieParam } from '../browser/types.js'; +import { runGeminiWebWithFallback, saveFirstGeminiImageFromOutput } from './client.js'; +import type { GeminiWebModelId } from './client.js'; +import { + buildGeminiCookieMap, + hasRequiredGeminiCookies, + readGeminiCookieMapFromDisk, +} from './cookie-store.js'; +import type { GeminiWebOptions, GeminiWebResponse } from './types.js'; + +export { hasRequiredGeminiCookies } from './cookie-store.js'; + +function estimateTokenCount(text: string): number { + return Math.ceil(text.length / 4); +} + +function resolveInvocationPath(value: string | undefined): string | undefined { + if (!value) return undefined; + const trimmed = value.trim(); + if (!trimmed) return undefined; + return path.isAbsolute(trimmed) ? trimmed : path.resolve(process.cwd(), trimmed); +} + +function resolveGeminiWebModel( + desiredModel: string | null | undefined, + log?: BrowserLogger, +): GeminiWebModelId { + const desired = typeof desiredModel === 'string' ? desiredModel.trim() : ''; + if (!desired) return 'gemini-3-pro'; + + switch (desired) { + case 'gemini-3-pro': + case 'gemini-3.0-pro': + return 'gemini-3-pro'; + case 'gemini-2.5-pro': + return 'gemini-2.5-pro'; + case 'gemini-2.5-flash': + return 'gemini-2.5-flash'; + default: + if (desired.startsWith('gemini-')) { + log?.( + `[gemini-web] Unsupported Gemini web model "${desired}". Falling back to gemini-3-pro.`, + ); + } + return 'gemini-3-pro'; + } +} + +function buildInlineCookiesFromEnv(): CookieParam[] { + const cookies: CookieParam[] = []; + const psid = process.env.GEMINI_SECURE_1PSID?.trim(); + const psidts = process.env.GEMINI_SECURE_1PSIDTS?.trim(); + + if (psid) { + cookies.push({ name: '__Secure-1PSID', value: psid, domain: 'google.com', path: '/' }); + } + if (psidts) { + cookies.push({ name: '__Secure-1PSIDTS', value: psidts, domain: 'google.com', path: '/' }); + } + + return cookies; +} + +async function loadGeminiCookiesFromInline( + browserConfig: BrowserRunOptions['config'], + log?: BrowserLogger, +): Promise> { + const inline = browserConfig?.inlineCookies; + if (!inline || inline.length === 0) return {}; + + const cookieMap = buildGeminiCookieMap( + inline.filter((cookie): cookie is CookieParam => Boolean(cookie?.name && typeof cookie.value === 'string')), + ); + + if (Object.keys(cookieMap).length > 0) { + const source = browserConfig?.inlineCookiesSource ?? 'inline'; + log?.(`[gemini-web] Loaded Gemini cookies from inline payload (${source}): ${Object.keys(cookieMap).length} cookie(s).`); + } else { + log?.('[gemini-web] Inline cookie payload provided but no Gemini cookies matched.'); + } + + return cookieMap; +} + +export async function loadGeminiCookies( + browserConfig: BrowserRunOptions['config'], + log?: BrowserLogger, +): Promise> { + const inlineMap = await loadGeminiCookiesFromInline(browserConfig, log); + if (hasRequiredGeminiCookies(inlineMap)) return inlineMap; + + const diskMap = await readGeminiCookieMapFromDisk({ log }); + const merged = { ...diskMap, ...inlineMap }; + if (hasRequiredGeminiCookies(merged)) return merged; + + if (browserConfig?.cookieSync === false) { + log?.('[gemini-web] Cookie sync disabled and inline cookies missing Gemini auth tokens.'); + return merged; + } + + log?.( + '[gemini-web] Missing Gemini auth cookies. Run `npx -y bun skills/gemini-web/scripts/main.ts --login` to sign in and refresh cookies.', + ); + return merged; +} + +export async function loadGeminiCookieMap(log?: BrowserLogger): Promise> { + const diskMap = await readGeminiCookieMapFromDisk({ log }); + const inlineCookies = buildInlineCookiesFromEnv(); + const envMap = buildGeminiCookieMap(inlineCookies); + return { ...diskMap, ...envMap }; +} + +export function createGeminiWebExecutor( + geminiOptions: GeminiWebOptions, +): (runOptions: BrowserRunOptions) => Promise { + return async (runOptions: BrowserRunOptions): Promise => { + const startTime = Date.now(); + const log = runOptions.log; + + log?.('[gemini-web] Starting Gemini web executor (TypeScript)'); + + const cookieMap = await loadGeminiCookies(runOptions.config, log); + if (!hasRequiredGeminiCookies(cookieMap)) { + throw new Error( + 'Gemini browser mode requires auth cookies (missing __Secure-1PSID/__Secure-1PSIDTS). Run `npx -y bun skills/gemini-web/scripts/main.ts --login` to sign in and save cookies.', + ); + } + + const configTimeout = + typeof runOptions.config?.timeoutMs === 'number' && Number.isFinite(runOptions.config.timeoutMs) + ? Math.max(1_000, runOptions.config.timeoutMs) + : null; + + const defaultTimeoutMs = geminiOptions.youtube + ? 240_000 + : geminiOptions.generateImage || geminiOptions.editImage + ? 300_000 + : 120_000; + + const timeoutMs = Math.min(configTimeout ?? defaultTimeoutMs, 600_000); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + const generateImagePath = resolveInvocationPath(geminiOptions.generateImage); + const editImagePath = resolveInvocationPath(geminiOptions.editImage); + const outputPath = resolveInvocationPath(geminiOptions.outputPath); + const attachmentPaths = (runOptions.attachments ?? []).map((attachment) => attachment.path); + + let prompt = runOptions.prompt; + if (geminiOptions.aspectRatio && (generateImagePath || editImagePath)) { + prompt = `${prompt} (aspect ratio: ${geminiOptions.aspectRatio})`; + } + if (geminiOptions.youtube) { + prompt = `${prompt}\n\nYouTube video: ${geminiOptions.youtube}`; + } + if (generateImagePath && !editImagePath) { + prompt = `Generate an image: ${prompt}`; + } + + const model: GeminiWebModelId = resolveGeminiWebModel(runOptions.config?.desiredModel, log); + let response: GeminiWebResponse; + + try { + if (editImagePath) { + const intro = await runGeminiWebWithFallback({ + prompt: 'Here is an image to edit', + files: [editImagePath], + model, + cookieMap, + chatMetadata: null, + signal: controller.signal, + }); + const editPrompt = `Use image generation tool to ${prompt}`; + const out = await runGeminiWebWithFallback({ + prompt: editPrompt, + files: attachmentPaths, + model, + cookieMap, + chatMetadata: intro.metadata, + signal: controller.signal, + }); + response = { + text: out.text ?? null, + thoughts: geminiOptions.showThoughts ? out.thoughts : null, + has_images: false, + image_count: 0, + }; + + const resolvedOutputPath = outputPath ?? generateImagePath ?? 'generated.png'; + const imageSave = await saveFirstGeminiImageFromOutput(out, cookieMap, resolvedOutputPath, controller.signal); + response.has_images = imageSave.saved; + response.image_count = imageSave.imageCount; + if (!imageSave.saved) { + throw new Error(`No images generated. Response text:\n${out.text || '(empty response)'}`); + } + } else if (generateImagePath) { + const out = await runGeminiWebWithFallback({ + prompt, + files: attachmentPaths, + model, + cookieMap, + chatMetadata: null, + signal: controller.signal, + }); + response = { + text: out.text ?? null, + thoughts: geminiOptions.showThoughts ? out.thoughts : null, + has_images: false, + image_count: 0, + }; + const imageSave = await saveFirstGeminiImageFromOutput(out, cookieMap, generateImagePath, controller.signal); + response.has_images = imageSave.saved; + response.image_count = imageSave.imageCount; + if (!imageSave.saved) { + throw new Error(`No images generated. Response text:\n${out.text || '(empty response)'}`); + } + } else { + const out = await runGeminiWebWithFallback({ + prompt, + files: attachmentPaths, + model, + cookieMap, + chatMetadata: null, + signal: controller.signal, + }); + response = { + text: out.text ?? null, + thoughts: geminiOptions.showThoughts ? out.thoughts : null, + has_images: out.images.length > 0, + image_count: out.images.length, + }; + } + } finally { + clearTimeout(timeout); + } + + const answerText = response.text ?? ''; + let answerMarkdown = answerText; + + if (geminiOptions.showThoughts && response.thoughts) { + answerMarkdown = `## Thinking\n\n${response.thoughts}\n\n## Response\n\n${answerText}`; + } + + if (response.has_images && response.image_count > 0) { + const imagePath = generateImagePath || outputPath || 'generated.png'; + answerMarkdown += `\n\n*Generated ${response.image_count} image(s). Saved to: ${imagePath}*`; + } + + const tookMs = Date.now() - startTime; + log?.(`[gemini-web] Completed in ${tookMs}ms`); + + return { + answerText, + answerMarkdown, + tookMs, + answerTokens: estimateTokenCount(answerText), + answerChars: answerText.length, + }; + }; +} diff --git a/skills/gemini-web/scripts/get-cookie.ts b/skills/gemini-web/scripts/get-cookie.ts new file mode 100644 index 0000000..d3d9216 --- /dev/null +++ b/skills/gemini-web/scripts/get-cookie.ts @@ -0,0 +1,21 @@ +#!/usr/bin/env -S npx -y bun + +import process from 'node:process'; + +import { getGeminiCookieMapViaChrome } from './chrome-auth.js'; +import { writeGeminiCookieMapToDisk } from './cookie-store.js'; +import { resolveGeminiWebChromeProfileDir, resolveGeminiWebCookiePath } from './paths.js'; + +async function main(): Promise { + const cookiePath = resolveGeminiWebCookiePath(); + const profileDir = resolveGeminiWebChromeProfileDir(); + + const log = (msg: string) => console.log(msg); + const cookieMap = await getGeminiCookieMapViaChrome({ userDataDir: profileDir, log }); + await writeGeminiCookieMapToDisk(cookieMap, { cookiePath, log }); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/skills/gemini-web/scripts/index.ts b/skills/gemini-web/scripts/index.ts new file mode 100644 index 0000000..0443578 --- /dev/null +++ b/skills/gemini-web/scripts/index.ts @@ -0,0 +1,2 @@ +export { createGeminiWebExecutor } from './executor.js'; +export type { GeminiWebOptions, GeminiWebResponse } from './types.js'; diff --git a/skills/gemini-web/scripts/main.ts b/skills/gemini-web/scripts/main.ts new file mode 100644 index 0000000..d6d6636 --- /dev/null +++ b/skills/gemini-web/scripts/main.ts @@ -0,0 +1,340 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; + +import { fetchGeminiAccessToken, runGeminiWebWithFallback, saveFirstGeminiImageFromOutput } from './client.js'; +import { getGeminiCookieMapViaChrome } from './chrome-auth.js'; +import { + hasRequiredGeminiCookies, + readGeminiCookieMapFromDisk, + writeGeminiCookieMapToDisk, +} from './cookie-store.js'; +import { resolveGeminiWebChromeProfileDir, resolveGeminiWebCookiePath } from './paths.js'; + +function printUsage(exitCode = 0): never { + const cookiePath = resolveGeminiWebCookiePath(); + const profileDir = resolveGeminiWebChromeProfileDir(); + + console.log(`Usage: + npx -y bun skills/gemini-web/scripts/main.ts --prompt "Hello" + npx -y bun skills/gemini-web/scripts/main.ts "Hello" + npx -y bun skills/gemini-web/scripts/main.ts --prompt "A cute cat" --image generated.png + +Options: + -p, --prompt Prompt text + -m, --model gemini-3-pro | gemini-2.5-pro | gemini-2.5-flash (default: gemini-3-pro) + --json Output JSON + --image [path] Generate an image and save it (default: ./generated.png) + --login Only refresh cookies, then exit + --cookie-path Cookie file path (default: ${cookiePath}) + --profile-dir Chrome profile dir (default: ${profileDir}) + -h, --help Show help + +Env overrides: + GEMINI_WEB_DATA_DIR, GEMINI_WEB_COOKIE_PATH, GEMINI_WEB_CHROME_PROFILE_DIR, GEMINI_WEB_CHROME_PATH +`); + + process.exit(exitCode); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function readPromptFromStdin(): Promise { + if (process.stdin.isTTY) return null; + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const text = Buffer.concat(chunks).toString('utf8').trim(); + return text ? text : null; +} + +function parseArgs(argv: string[]): { + prompt?: string; + model?: string; + json?: boolean; + imagePath?: string; + loginOnly?: boolean; + cookiePath?: string; + profileDir?: string; +} { + const out: ReturnType = {}; + const positional: string[] = []; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i] ?? ''; + if (arg === '--help' || arg === '-h') printUsage(0); + if (arg === '--json') { + out.json = true; + continue; + } + if (arg === '--image' || arg === '--generate-image') { + const next = argv[i + 1]; + if (next && !next.startsWith('-')) { + out.imagePath = next; + i += 1; + } else { + out.imagePath = 'generated.png'; + } + continue; + } + if (arg.startsWith('--image=')) { + out.imagePath = arg.slice('--image='.length); + continue; + } + if (arg.startsWith('--generate-image=')) { + out.imagePath = arg.slice('--generate-image='.length); + continue; + } + if (arg === '--login') { + out.loginOnly = true; + continue; + } + if (arg === '--prompt' || arg === '-p') { + out.prompt = argv[i + 1] ?? ''; + i += 1; + continue; + } + if (arg.startsWith('--prompt=')) { + out.prompt = arg.slice('--prompt='.length); + continue; + } + if (arg === '--model' || arg === '-m') { + out.model = argv[i + 1] ?? ''; + i += 1; + continue; + } + if (arg.startsWith('--model=')) { + out.model = arg.slice('--model='.length); + continue; + } + if (arg === '--cookie-path') { + out.cookiePath = argv[i + 1] ?? ''; + i += 1; + continue; + } + if (arg.startsWith('--cookie-path=')) { + out.cookiePath = arg.slice('--cookie-path='.length); + continue; + } + if (arg === '--profile-dir') { + out.profileDir = argv[i + 1] ?? ''; + i += 1; + continue; + } + if (arg.startsWith('--profile-dir=')) { + out.profileDir = arg.slice('--profile-dir='.length); + continue; + } + + if (arg.startsWith('-')) { + throw new Error(`Unknown option: ${arg}`); + } + positional.push(arg); + } + + if (!out.prompt && positional.length > 0) { + out.prompt = positional.join(' ').trim(); + } + + if (out.prompt != null) out.prompt = out.prompt.trim(); + if (out.model != null) out.model = out.model.trim(); + if (out.imagePath != null) out.imagePath = out.imagePath.trim(); + if (out.cookiePath != null) out.cookiePath = out.cookiePath.trim(); + if (out.profileDir != null) out.profileDir = out.profileDir.trim(); + + if (out.imagePath === '') delete out.imagePath; + if (out.cookiePath === '') delete out.cookiePath; + if (out.profileDir === '') delete out.profileDir; + + return out; +} + +async function isCookieMapValid(cookieMap: Record): Promise { + if (!hasRequiredGeminiCookies(cookieMap)) return false; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 10_000); + try { + await fetchGeminiAccessToken(cookieMap, controller.signal); + return true; + } catch { + return false; + } finally { + clearTimeout(timer); + } +} + +async function ensureGeminiCookieMap(options: { + cookiePath: string; + profileDir: string; +}): Promise> { + const log = (msg: string) => console.log(msg); + + let cookieMap = await readGeminiCookieMapFromDisk({ cookiePath: options.cookiePath, log }); + if (await isCookieMapValid(cookieMap)) return cookieMap; + + log('[gemini-web] No valid cookies found. Opening browser to sync Gemini cookies...'); + cookieMap = await getGeminiCookieMapViaChrome({ userDataDir: options.profileDir, log }); + await writeGeminiCookieMapToDisk(cookieMap, { cookiePath: options.cookiePath, log }); + return cookieMap; +} + +function resolveModel(value: string): 'gemini-3-pro' | 'gemini-2.5-pro' | 'gemini-2.5-flash' { + const desired = value.trim(); + if (!desired) return 'gemini-3-pro'; + switch (desired) { + case 'gemini-3-pro': + case 'gemini-3.0-pro': + return 'gemini-3-pro'; + case 'gemini-2.5-pro': + return 'gemini-2.5-pro'; + case 'gemini-2.5-flash': + return 'gemini-2.5-flash'; + default: + console.error(`[gemini-web] Unsupported model "${desired}", falling back to gemini-3-pro.`); + return 'gemini-3-pro'; + } +} + +function resolveImageOutputPath(value: string | undefined): string | null { + if (value == null) return null; + const trimmed = value.trim(); + const raw = trimmed || 'generated.png'; + const resolved = path.isAbsolute(raw) ? raw : path.resolve(process.cwd(), raw); + + if (resolved.endsWith(path.sep)) return path.join(resolved, 'generated.png'); + try { + if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) { + return path.join(resolved, 'generated.png'); + } + } catch { + // ignore + } + return resolved; +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + const cookiePath = args.cookiePath ?? resolveGeminiWebCookiePath(); + const profileDir = args.profileDir ?? resolveGeminiWebChromeProfileDir(); + + if (args.loginOnly) { + await ensureGeminiCookieMap({ cookiePath, profileDir }); + return; + } + + const promptFromStdin = await readPromptFromStdin(); + const prompt = args.prompt || promptFromStdin; + if (!prompt) printUsage(1); + + let cookieMap = await ensureGeminiCookieMap({ cookiePath, profileDir }); + + const desiredModel = resolveModel(args.model || 'gemini-3-pro'); + const imagePath = resolveImageOutputPath(args.imagePath); + + try { + const effectivePrompt = imagePath ? `Generate an image: ${prompt}` : prompt; + const out = await runGeminiWebWithFallback({ + prompt: effectivePrompt, + files: [], + model: desiredModel, + cookieMap, + chatMetadata: null, + }); + + let imageSaved = false; + let imageCount = 0; + if (imagePath) { + const save = await saveFirstGeminiImageFromOutput(out, cookieMap, imagePath); + imageSaved = save.saved; + imageCount = save.imageCount; + if (!imageSaved) { + throw new Error(`No images generated. Response text:\n${out.text || '(empty response)'}`); + } + } + + if (args.json) { + process.stdout.write( + `${JSON.stringify( + imagePath ? { ...out, imageSaved, imageCount, imagePath } : out, + null, + 2, + )}\n`, + ); + if (out.errorMessage) process.exit(1); + return; + } + + if (out.errorMessage) { + throw new Error(out.errorMessage); + } + + process.stdout.write(out.text ?? ''); + if (!out.text?.endsWith('\n')) process.stdout.write('\n'); + if (imagePath) { + process.stdout.write(`Saved image (${imageCount || 1}) to: ${imagePath}\n`); + } + return; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + if (message.includes('Unable to locate Gemini access token')) { + console.error('[gemini-web] Cookies may be expired. Re-opening browser to refresh cookies...'); + await sleep(500); + cookieMap = await getGeminiCookieMapViaChrome({ userDataDir: profileDir, log: (m) => console.log(m) }); + await writeGeminiCookieMapToDisk(cookieMap, { cookiePath, log: (m) => console.log(m) }); + + const out = await runGeminiWebWithFallback({ + prompt: imagePath ? `Generate an image: ${prompt}` : prompt, + files: [], + model: desiredModel, + cookieMap, + chatMetadata: null, + }); + + let imageSaved = false; + let imageCount = 0; + if (imagePath) { + const save = await saveFirstGeminiImageFromOutput(out, cookieMap, imagePath); + imageSaved = save.saved; + imageCount = save.imageCount; + if (!imageSaved) { + throw new Error(`No images generated. Response text:\n${out.text || '(empty response)'}`); + } + } + + if (args.json) { + process.stdout.write( + `${JSON.stringify( + imagePath ? { ...out, imageSaved, imageCount, imagePath } : out, + null, + 2, + )}\n`, + ); + if (out.errorMessage) process.exit(1); + return; + } + + if (out.errorMessage) { + throw new Error(out.errorMessage); + } + + process.stdout.write(out.text ?? ''); + if (!out.text?.endsWith('\n')) process.stdout.write('\n'); + if (imagePath) { + process.stdout.write(`Saved image (${imageCount || 1}) to: ${imagePath}\n`); + } + return; + } + + throw error; + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/skills/gemini-web/scripts/paths.ts b/skills/gemini-web/scripts/paths.ts new file mode 100644 index 0000000..2b4a2b6 --- /dev/null +++ b/skills/gemini-web/scripts/paths.ts @@ -0,0 +1,36 @@ +import os from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; + +const APP_DATA_DIR = 'baoyu-skills'; +const GEMINI_DATA_DIR = 'gemini-web'; +const COOKIE_FILE_NAME = 'cookies.json'; +const PROFILE_DIR_NAME = 'chrome-profile'; + +export function resolveUserDataRoot(): string { + if (process.platform === 'win32') { + return process.env.APPDATA ?? path.join(os.homedir(), 'AppData', 'Roaming'); + } + if (process.platform === 'darwin') { + return path.join(os.homedir(), 'Library', 'Application Support'); + } + return process.env.XDG_DATA_HOME ?? path.join(os.homedir(), '.local', 'share'); +} + +export function resolveGeminiWebDataDir(): string { + const override = process.env.GEMINI_WEB_DATA_DIR?.trim(); + if (override) return path.resolve(override); + return path.join(resolveUserDataRoot(), APP_DATA_DIR, GEMINI_DATA_DIR); +} + +export function resolveGeminiWebCookiePath(): string { + const override = process.env.GEMINI_WEB_COOKIE_PATH?.trim(); + if (override) return path.resolve(override); + return path.join(resolveGeminiWebDataDir(), COOKIE_FILE_NAME); +} + +export function resolveGeminiWebChromeProfileDir(): string { + const override = process.env.GEMINI_WEB_CHROME_PROFILE_DIR?.trim(); + if (override) return path.resolve(override); + return path.join(resolveGeminiWebDataDir(), PROFILE_DIR_NAME); +} diff --git a/skills/gemini-web/scripts/types.ts b/skills/gemini-web/scripts/types.ts new file mode 100644 index 0000000..58cffbd --- /dev/null +++ b/skills/gemini-web/scripts/types.ts @@ -0,0 +1,16 @@ +export interface GeminiWebOptions { + youtube?: string; + generateImage?: string; + editImage?: string; + outputPath?: string; + showThoughts?: boolean; + aspectRatio?: string; +} + +export interface GeminiWebResponse { + text: string | null; + thoughts: string | null; + has_images: boolean; + image_count: number; + error?: string; +}