Add gemini-web skill

This commit is contained in:
Jim Liu 宝玉 2026-01-12 23:58:26 -06:00
parent aeaf645bc0
commit fc544bfeb2
10 changed files with 1678 additions and 0 deletions

110
skills/gemini-web/SKILL.md Normal file
View File

@ -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 <text>` | `-p` | Prompt text |
| `--model <id>` | `-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 <path>` | | Custom cookie file path |
| `--profile-dir <path>` | | 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'
```

View File

@ -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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function getFreePort(): Promise<number> {
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<T = unknown>(url: string): Promise<T> {
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<typeof setTimeout> | 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<CdpConnection> {
const ws = new WebSocket(url);
await new Promise<void>((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<T = unknown>(
method: string,
params?: Record<string, unknown>,
options?: { sessionId?: string; timeoutMs?: number },
): Promise<T> {
const id = (this.nextId += 1);
const message: Record<string, unknown> = { 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<unknown>((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<Record<string, string>> {
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
}
}
}

View File

@ -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<string, string>;
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<GeminiWebModelId, string> = {
'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<T>(value: unknown, pathParts: Array<string | number>, 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<string, unknown>)[part];
}
}
return (current as T) ?? fallback;
}
function buildCookieHeader(cookieMap: Record<string, string>): 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<string, string>,
signal?: AbortSignal,
): Promise<string> {
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<number>(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<string>();
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<RequestInit, 'redirect'>,
signal?: AbortSignal,
maxRedirects = 10,
): Promise<Response> {
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<string, string>,
outputPath: string,
signal?: AbortSignal,
): Promise<void> {
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<string | null>(parts[i], [2], null);
if (!partBody) continue;
try {
const parsed = JSON.parse(partBody) as unknown;
const candidateList = getNestedValue<unknown[]>(parsed, [4], []);
if (Array.isArray(candidateList) && candidateList.length > 0) {
bodyIndex = i;
body = parsed;
break;
}
} catch {
// ignore
}
}
const candidateList = getNestedValue<unknown[]>(body, [4], []);
const firstCandidate = candidateList[0];
const textRaw = getNestedValue<string>(firstCandidate, [1, 0], '');
const cardContent = /^http:\/\/googleusercontent\.com\/card_content\/\d+/.test(textRaw);
const text = cardContent
? (getNestedValue<string | null>(firstCandidate, [22, 0], null) ?? textRaw)
: textRaw;
const thoughts = getNestedValue<string | null>(firstCandidate, [37, 0, 0], null);
const metadata = getNestedValue<unknown>(body, [1], []);
const images: GeminiWebCandidateImage[] = [];
const webImages = getNestedValue<unknown[]>(firstCandidate, [12, 1], []);
for (const webImage of webImages) {
const url = getNestedValue<string | null>(webImage, [0, 0, 0], null);
if (!url) continue;
images.push({
kind: 'web',
url,
title: getNestedValue<string | undefined>(webImage, [7, 0], undefined),
alt: getNestedValue<string | undefined>(webImage, [0, 4], undefined),
});
}
const hasGenerated = Boolean(getNestedValue<unknown>(firstCandidate, [12, 7, 0], null));
if (hasGenerated) {
let imgBody: unknown = null;
for (let i = bodyIndex; i < parts.length; i += 1) {
const partBody = getNestedValue<string | null>(parts[i], [2], null);
if (!partBody) continue;
try {
const parsed = JSON.parse(partBody) as unknown;
const candidateImages = getNestedValue<unknown | null>(parsed, [4, 0, 12, 7, 0], null);
if (candidateImages != null) {
imgBody = parsed;
break;
}
} catch {
// ignore
}
}
const imgCandidate = getNestedValue<unknown>(imgBody ?? body, [4, 0], null);
const generated = getNestedValue<unknown[]>(imgCandidate, [12, 7, 0], []);
for (const genImage of generated) {
const url = getNestedValue<string | null>(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<GeminiWebRunOutput> {
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<GeminiWebRunInput, 'model'> & { model: GeminiWebModelId },
): Promise<GeminiWebRunOutput & { effectiveModel: GeminiWebModelId }> {
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<string, string>,
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 };
}

View File

@ -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<string, string>;
}
export function hasRequiredGeminiCookies(cookieMap: Record<string, string>): 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<T extends { name?: string; value?: string; domain?: string; path?: string; url?: string }>(
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<string, string> {
const cookieMap: Record<string, string> = {};
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<Record<string, string>> {
const cookiePath = options?.cookiePath ?? resolveGeminiWebCookiePath();
try {
const raw = await readFile(cookiePath, 'utf8');
const parsed = JSON.parse(raw) as Partial<GeminiCookieFileV1> | Record<string, unknown>;
const cookieMap =
(parsed as Partial<GeminiCookieFileV1>).version === 1
? (parsed as Partial<GeminiCookieFileV1>).cookieMap
: (parsed as Record<string, unknown>);
if (!cookieMap || typeof cookieMap !== 'object') return {};
const normalized: Record<string, string> = {};
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<string, string>,
options?: { cookiePath?: string; log?: GeminiWebLog },
): Promise<void> {
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}`);
}

View File

@ -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<Record<string, string>> {
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<Record<string, string>> {
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<Record<string, string>> {
const diskMap = await readGeminiCookieMapFromDisk({ log });
const inlineCookies = buildInlineCookiesFromEnv();
const envMap = buildGeminiCookieMap(inlineCookies);
return { ...diskMap, ...envMap };
}
export function createGeminiWebExecutor(
geminiOptions: GeminiWebOptions,
): (runOptions: BrowserRunOptions) => Promise<BrowserRunResult> {
return async (runOptions: BrowserRunOptions): Promise<BrowserRunResult> => {
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,
};
};
}

View File

@ -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<void> {
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);
});

View File

@ -0,0 +1,2 @@
export { createGeminiWebExecutor } from './executor.js';
export type { GeminiWebOptions, GeminiWebResponse } from './types.js';

View File

@ -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 <text> Prompt text
-m, --model <id> 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 <path> Cookie file path (default: ${cookiePath})
--profile-dir <path> 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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function readPromptFromStdin(): Promise<string | null> {
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<typeof parseArgs> = {};
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<string, string>): Promise<boolean> {
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<Record<string, string>> {
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<void> {
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);
});

View File

@ -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);
}

View File

@ -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;
}