Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions docs/adapters/browser/gemini.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Gemini

**Mode**: 🔐 Browser · **Domain**: `gemini.google.com`

## Commands

| Command | Description |
|---------|-------------|
| `opencli gemini new` | Start a new Gemini web chat |
| `opencli gemini ask <prompt>` | Send a prompt and return only the assistant reply |
| `opencli gemini image <prompt>` | Generate images in Gemini and optionally save them locally |

## Usage Examples

```bash
# Start a fresh chat
opencli gemini new

# Ask Gemini and return minimal plain-text output
opencli gemini ask "Reply with exactly: HELLO"

# Ask in a new chat and wait longer
opencli gemini ask "Summarize this design in 3 bullets" --new true --timeout 90

# Generate an icon image with short flags
opencli gemini image "Generate a tiny cyan moon icon" --rt 1:1 --st icon

# Only generate in Gemini and print the page link without downloading files
opencli gemini image "A watercolor sunset over a lake" --sd true

# Save generated images to a custom directory
opencli gemini image "A flat illustration of a robot" --op ~/tmp/gemini-images
```

## Options

### `ask`

| Option | Description |
|--------|-------------|
| `prompt` | Prompt to send (required positional argument) |
| `--timeout` | Max seconds to wait for a reply (default: `60`) |
| `--new` | Start a new chat before sending (default: `false`) |

### `image`

| Option | Description |
|--------|-------------|
| `prompt` | Image prompt to send (required positional argument) |
| `--rt` | Aspect ratio shorthand: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `3:2`, `2:3` |
| `--st` | Optional style shorthand, e.g. `icon`, `anime`, `watercolor` |
| `--op` | Output directory for downloaded images (default: `~/tmp/gemini-images`) |
| `--sd` | Skip download and only print the Gemini page link |

## Behavior

- `ask` uses plain minimal output and returns only the assistant response text prefixed with `💬`.
- `image` also uses plain output and prints `status / file / link` instead of a table.
- `image` always starts from a fresh Gemini chat before sending the prompt.
- When `--sd` is enabled, `image` keeps the generation in Gemini and only prints the conversation link.

## Prerequisites

- Chrome is running
- You are already logged into `gemini.google.com`
- [Browser Bridge extension](/guide/browser-bridge) is installed

## Caveats

- This adapter drives the Gemini consumer web UI, not a public API.
- It depends on the current browser session and may fail if Gemini shows login, consent, challenge, quota, or other gating UI.
- DOM or product changes on Gemini can break composer detection, new-chat handling, or image export behavior.
46 changes: 46 additions & 0 deletions src/clis/gemini/ask.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
import { GEMINI_DOMAIN, getGeminiTranscriptLines, sendGeminiMessage, startNewGeminiChat, waitForGeminiResponse } from './utils.js';

function normalizeBooleanFlag(value: unknown): boolean {
if (typeof value === 'boolean') return value;
const normalized = String(value ?? '').trim().toLowerCase();
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
}

const NO_RESPONSE_PREFIX = '[NO RESPONSE]';

export const askCommand = cli({
site: 'gemini',
name: 'ask',
description: 'Send a prompt to Gemini and return only the assistant response',
domain: GEMINI_DOMAIN,
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
defaultFormat: 'plain',
timeoutSeconds: 180,
args: [
{ name: 'prompt', required: true, positional: true, help: 'Prompt to send' },
{ name: 'timeout', required: false, help: 'Max seconds to wait (default: 60)', default: '60' },
{ name: 'new', required: false, help: 'Start a new chat first (true/false, default: false)', default: 'false' },
],
columns: ['response'],
func: async (page: IPage, kwargs: any) => {
const prompt = kwargs.prompt as string;
const timeout = parseInt(kwargs.timeout as string, 10) || 60;
const startFresh = normalizeBooleanFlag(kwargs.new);

if (startFresh) await startNewGeminiChat(page);

const beforeLines = await getGeminiTranscriptLines(page);
await sendGeminiMessage(page, prompt);
const response = await waitForGeminiResponse(page, beforeLines, prompt, timeout);

if (!response) {
return [{ response: `💬 ${NO_RESPONSE_PREFIX} No Gemini response within ${timeout}s.` }];
}

return [{ response: `💬 ${response}` }];
},
});
115 changes: 115 additions & 0 deletions src/clis/gemini/image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import * as os from 'node:os';
import * as path from 'node:path';
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
import { saveBase64ToFile } from '../../utils.js';
import { GEMINI_DOMAIN, exportGeminiImages, getGeminiVisibleImageUrls, sendGeminiMessage, startNewGeminiChat, waitForGeminiImages } from './utils.js';

function extFromMime(mime: string): string {
if (mime.includes('png')) return '.png';
if (mime.includes('webp')) return '.webp';
if (mime.includes('gif')) return '.gif';
return '.jpg';
}

function normalizeBooleanFlag(value: unknown): boolean {
if (typeof value === 'boolean') return value;
const normalized = String(value ?? '').trim().toLowerCase();
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
}

function displayPath(filePath: string): string {
const home = os.homedir();
return filePath.startsWith(home) ? `~${filePath.slice(home.length)}` : filePath;
}

function buildImagePrompt(prompt: string, options: {
ratio?: string;
style?: string;
}): string {
const extras: string[] = [];
if (options.ratio) extras.push(`aspect ratio ${options.ratio}`);
if (options.style) extras.push(`style ${options.style}`);
if (extras.length === 0) return prompt;
return `${prompt}

Image requirements: ${extras.join(', ')}.`;
}

function normalizeRatio(value: string): string {
const normalized = value.trim();
const allowed = new Set(['1:1', '16:9', '9:16', '4:3', '3:4', '3:2', '2:3']);
return allowed.has(normalized) ? normalized : '1:1';
}
async function currentGeminiLink(page: IPage): Promise<string> {
const url = await page.evaluate('window.location.href').catch(() => '');
return typeof url === 'string' && url ? url : 'https://gemini.google.com/app';
}

export const imageCommand = cli({
site: 'gemini',
name: 'image',
description: 'Generate images with Gemini web and save them locally',
domain: GEMINI_DOMAIN,
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
defaultFormat: 'plain',
timeoutSeconds: 240,
args: [
{ name: 'prompt', positional: true, required: true, help: 'Image prompt to send to Gemini' },
{ name: 'rt', default: '1:1', help: 'Ratio shorthand for aspect ratio (1:1, 16:9, 9:16, 4:3, 3:4, 3:2, 2:3)' },
{ name: 'st', default: '', help: 'Style shorthand, e.g. anime, icon, watercolor' },
{ name: 'op', default: path.join(os.homedir(), 'tmp', 'gemini-images'), help: 'Output directory shorthand' },
{ name: 'sd', type: 'boolean', default: false, help: 'Skip download shorthand; only show Gemini page link' },
],
columns: ['status', 'file', 'link'],
func: async (page: IPage, kwargs: any) => {
const prompt = kwargs.prompt as string;
const ratio = normalizeRatio(String(kwargs.rt ?? '1:1'));
const style = String(kwargs.st ?? '').trim();
const outputDir = (kwargs.op as string) || path.join(os.homedir(), 'tmp', 'gemini-images');
const timeout = 120;
const startFresh = true;
const skipDownloadRaw = kwargs.sd;
const skipDownload = skipDownloadRaw === '' || skipDownloadRaw === true || normalizeBooleanFlag(skipDownloadRaw);

const effectivePrompt = buildImagePrompt(prompt, {
ratio,
style: style || undefined,
});

if (startFresh) await startNewGeminiChat(page);

const beforeUrls = await getGeminiVisibleImageUrls(page);
await sendGeminiMessage(page, effectivePrompt);
const urls = await waitForGeminiImages(page, beforeUrls, timeout);
const link = await currentGeminiLink(page);

if (!urls.length) {
return [{ status: '⚠️ no-images', file: '📁 -', link: `🔗 ${link}` }];
}

if (skipDownload) {
return [{ status: '🎨 generated', file: '📁 -', link: `🔗 ${link}` }];
}

const assets = await exportGeminiImages(page, urls);
if (!assets.length) {
return [{ status: '⚠️ export-failed', file: '📁 -', link: `🔗 ${link}` }];
}

const stamp = Date.now();
const results = [];
for (let index = 0; index < assets.length; index += 1) {
const asset = assets[index];
const base64 = asset.dataUrl.replace(/^data:[^;]+;base64,/, '');
const suffix = assets.length > 1 ? `_${index + 1}` : '';
const filePath = path.join(outputDir, `gemini_${stamp}${suffix}${extFromMime(asset.mimeType)}`);
await saveBase64ToFile(base64, filePath);
results.push({ status: '✅ saved', file: `📁 ${displayPath(filePath)}`, link: `🔗 ${link}` });
}

return results;
},
});
22 changes: 22 additions & 0 deletions src/clis/gemini/new.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
import { GEMINI_DOMAIN, startNewGeminiChat } from './utils.js';

export const newCommand = cli({
site: 'gemini',
name: 'new',
description: 'Start a new conversation in Gemini web chat',
domain: GEMINI_DOMAIN,
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
args: [],
columns: ['Status', 'Action'],
func: async (page: IPage) => {
const action = await startNewGeminiChat(page);
return [{
Status: 'Success',
Action: action === 'navigate' ? 'Reloaded /app as fallback' : 'Clicked New chat',
}];
},
});
36 changes: 36 additions & 0 deletions src/clis/gemini/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { collectGeminiTranscriptAdditions, sanitizeGeminiResponseText } from './utils.js';

describe('sanitizeGeminiResponseText', () => {
it('strips a prompt echo only when it appears as a prefixed block', () => {
const prompt = 'Reply with the word opencli';
const value = `Reply with the word opencli\n\nopencli`;
expect(sanitizeGeminiResponseText(value, prompt)).toBe('opencli');
});

it('does not strip prompt text that appears later in a legitimate answer', () => {
const prompt = 'opencli';
const value = 'You asked about opencli, and opencli is the right keyword here.';
expect(sanitizeGeminiResponseText(value, prompt)).toBe(value);
});

it('removes known Gemini footer noise', () => {
const value = 'Answer body\nGemini can make mistakes.\nGoogle Terms';
expect(sanitizeGeminiResponseText(value, '')).toBe('Answer body');
});
});

describe('collectGeminiTranscriptAdditions', () => {
it('joins multiple new transcript lines instead of keeping only the last line', () => {
const before = ['Older answer'];
const current = ['Older answer', 'First new line', 'Second new line'];
expect(collectGeminiTranscriptAdditions(before, current, '')).toBe('First new line\nSecond new line');
});

it('filters prompt echoes out of transcript additions', () => {
const prompt = 'Tell me a haiku';
const before = ['Previous'];
const current = ['Previous', 'Tell me a haiku', 'Tell me a haiku\n\nSoft spring rain arrives'];
expect(collectGeminiTranscriptAdditions(before, current, prompt)).toBe('Soft spring rain arrives');
});
});
Loading